I Got Tired of Clicking. So I Automated Retrieve Configuration

Managing retrieve configurations across dozens — or hundreds — of FortiGates is one of those tasks that sounds simple but quickly becomes a pain. Log into FortiManager, navigate to the right Administrative Domain (ADOM), do into Summary config , trigger a retrieval, wait and repeat.

I built a Python tool to automate this entire workflow. In this article, I’ll walk through the code step by step, explaining the design decisions along the way.

The full source code is available on Github. Feedback and pull requests are welcome.

What the Tool Does

At a high level, the script:

  1. Authenticates to FortiManager via its JSON-RPC API.
  2. Detects whether ADOMs are enabled and lists the available ones.
  3. Lets you pick one or more devices interactively.
  4. Triggers a non-blocking configuration retrieval task.
  5. Polls the task in real time, displaying a live progress bar.
  6. Prints a final pass/fail summary table.

Project Setup

The tool has no exotic dependencies — just Python’s standard library plus requests for HTTP calls.

import os
import requests
import json
import time
import sys

# Suppress SSL warnings for self-signed certs (common in lab environments)
requests.packages.urllib3.disable_warnings()

The disable_warnings() call suppresses the InsecureRequestWarning you’d otherwise see on every API call when using verify=False. This is standard practice for internal network tools talking to appliances with self-signed certificates.

Terminal Colours

Before any logic, the script defines a simple Colors class using ANSI escape codes. This gives the CLI output a clean, readable look — green for success, red for failure, cyan for prompts.

class Colors:
    HEADER = '\033[95m'
    BLUE = '\033[94m'
    CYAN = '\033[96m'
    GREEN = '\033[92m'
    YELLOW = '\033[93m'
    RED = '\033[91m'
    BOLD = '\033[1m'
    END = '\033[0m'

Usage is simple: wrap any string with a color code and terminate it with Colors.END.

print(f"{Colors.GREEN}✔ Session Established.{Colors.END}")

Handling Password Input Across Environments

One subtle but important detail: password masking behaves differently depending on where Python is running. The standard getpass module works in a real terminal but breaks in IDEs like PyCharm that intercept stdin.

def read_password(label):
    import getpass
    in_pycharm = 'PYCHARM_HOSTED' in os.environ or 'PYDEV_CONSOLE_EXECUTE_HOOK' in os.environ
    if in_pycharm:
        return input(label)
    try:
        return getpass.getpass(label)
    except Exception:
        return input(label)

The function checks for known PyCharm environment variables and falls back to plain input() when detected. In a normal terminal, getpass.getpass() hides the typed characters as expected.

Step 1 — Authenticating to FortiManager

FortiManager exposes a single JSON-RPC endpoint at /jsonrpc. All API calls, including login, go through this URL as POST requests.

host = input(f"{Colors.CYAN}{Colors.BOLD}FMG IP/URL:{Colors.END} ").strip()
user = input(f"{Colors.CYAN}{Colors.BOLD}Admin Username:{Colors.END} ").strip()
pwd = read_password(f"{Colors.CYAN}{Colors.BOLD}Admin Password:{Colors.END} ")
base_url = f"https://{host}/jsonrpc"

login_res = requests.post(base_url, json={
    "id": 1,
    "method": "exec",
    "params": [{"data": {"user": user, "passwd": pwd}, "url": "/sys/login/user"}]
}, verify=False).json()

session = login_res.get("session")

On a successful login, FortiManager returns a session token — a string that must be included in every subsequent request. If login fails, session will be None or absent.

Step 2 — Detecting ADOM Configuration

Not all FortiManager deployments use ADOMs. The script queries the system status to check whether ADOM mode is active, and adapts accordingly.

status_res = requests.post(base_url, json={
    "id": 1,
    "session": session,
    "method": "get",
    "params": [{"url": "/sys/status"}],
    "verbose": 1
}, verify=False).json()

adom_enabled = status_res['result'][0]['data'].get('Admin Domain Configuration') != 'Disabled'
selected_adom = 'root' if not adom_enabled else None

If ADOMs are disabled, the script defaults to the root domain and skips the ADOM selection screen entirely.

Step 3 — Listing and Selecting ADOMs

When ADOMs are enabled, the script fetches the list and filters it to only show relevant domains — those managing FortiGate, FortiCarrier, FortiFirewall, FortiWiFi, or FortiProxy devices.

adom_res = requests.post(base_url, json={
    "id": 2,
    "session": session,
    "method": "get",
    "verbose": 1,
    "params": [{"url": "/dvmdb/adom", "fields": ["name", "restricted_prds"]}]
}, verify=False).json()

allowed_products = ["fos", "foc", "ffw", "fwc", "fpx"]

filtered_adoms = []
for a in raw_data:
    if not isinstance(a, dict) or a.get('name') == 'rootp':
        continue
    product_code = str(a.get('restricted_prds', '')).lower()
    if product_code in allowed_products:
        filtered_adoms.append(a['name'])

Because verbose: 1 is set in the API request, FortiManager returns restricted_prds as a readable string (e.g. "fos", "fwc") rather than a raw integer. The script takes advantage of this by simply converting the value to lowercase and checking it directly against the allowed list.

The rootp ADOM is the Global ADOM which does not contain any devices, hence its filtered out.

ADOMs are displayed in a two-column layout for readability, and the user picks one by index.

Step 4 — Listing and Selecting Devices

Once an ADOM is selected, the script fetches all managed devices within it.

dev_res = requests.post(base_url, json={
    "id": 1,
    "session": session,
    "verbose": 1,
    "method": "get",
    "params": [{"url": f"/dvmdb/adom/{selected_adom}/device"}]
}, verify=False).json()

devices = dev_res['result'][0].get('data', [])

The device selection prompt supports several convenient input formats and the parsing logic handles all four cases.

Choice ([all], [0,2], [0-5], [b]ack, [e]xit):

This gives operators maximum flexibility — grab one device, a comma-separated list, a contiguous range, or everything at once.

Step 5 — Triggering the Retrieval Task

Config retrieval is triggered via the dvm/cmd/reload/dev-list API endpoint. The nonblocking flag is critical — it means the API call returns immediately with a taskid rather than blocking until the operation finishes.

exec_res = requests.post(base_url, json={
    "id": 1,
    "session": session,
    "method": "exec",
    "params": [{
        "url": "dvm/cmd/reload/dev-list",
        "data": {
            "adom": selected_adom,
            "flags": ["create_task", "nonblocking"],
            "reload-dev-member-list": target_list,
            "from": "dvm"
        }
    }]
}, verify=False).json()

task_id = exec_res['result'][0].get('data', {}).get('taskid')

The create_task flag tells FortiManager to track this operation as a formal task, making it queryable by ID.

Step 6 — Polling the Task with a Live Progress Bar

Once we have the taskid, the script enters a polling loop, querying the task status every second and re-rendering the terminal with updated progress.

while True:
    status_res = requests.post(base_url, json={
        "id": 1,
        "session": session,
        "method": "get",
        "params": [{"url": f"/task/task/{task_id}"}]
    }, verify=False).json()

    task_data = status_res['result'][0]['data']
    percent = task_data.get('percent', 0)

    clear_terminal()
    for dev_entry in task_data.get('line', []):
        color = Colors.GREEN if "finish" in dev_entry.get('detail', '').lower() else Colors.YELLOW
        print(f"  {Colors.CYAN}→{Colors.END} {dev_entry.get('name', ''):<25} | {color}{dev_entry.get('detail', ''):<25}{Colors.END}")

    bar = '█' * int(25 * percent / 100) + '░' * (25 - int(25 * percent / 100))
    print(f"\n  Progress: |{Colors.BLUE}{bar}{Colors.END}| {percent}%")

    if percent >= 100:
        clear_terminal()
        print_final_table(task_data)
        break

    time.sleep(1)

The progress bar is built using Unicode block characters — for completed and for remaining — scaled to 25 characters wide. The clear_terminal() call before each redraw gives it a live-updating feel without flickering.

Step 7 — Printing the Final Summary Table

When the task reaches 100%, the script renders a final summary table showing the result for each device.

def print_final_table(task_data):
    print(f"\n{Colors.BOLD}{Colors.HEADER}TASK SUMMARY{Colors.END}")
    print(f" Total: {task_data.get('num_lines', 0)}  |  "
          f"Done: {Colors.GREEN}{task_data.get('num_done', 0)}{Colors.END}  |  "
          f"Errors: {Colors.RED}{task_data.get('num_err', 0)}{Colors.END}\n")

    header = f" {'STATUS':<10} | {'DEVICE NAME':<25} | {'IP ADDRESS':<18} | {'DETAILS'}"
    print(f"{Colors.BOLD}{header}{Colors.END}")
    print(" " + "-" * (len(header) + 10))

    for entry in task_data.get('line', []):
        is_pass = entry.get('state') in ('done', 4) and entry.get('err', 0) == 0
        badge = f"{Colors.GREEN}PASS{Colors.END}" if is_pass else f"{Colors.RED}FAIL{Colors.END}"
        print(f" {badge:<19} | {entry.get('name', 'Unknown'):<25} | {entry.get('ip', 'N/A'):<18} | {entry.get('detail', '')}")

A device is considered a PASS if its state is done (or integer 4, which FortiManager also uses) and its error count is zero. Everything else is flagged as FAIL in red.

Step 8 — Session Cleanup

The finally block ensures the session is always closed, even if the script crashes or the user exits mid-operation.

finally:
    if 'session' in locals():
        requests.post(base_url, json={
            "id": 1,
            "session": session,
            "method": "exec",
            "params": [{"url": "/sys/logout"}]
        }, verify=False)
    print(f"\n{Colors.BLUE}✔ Session closed safely.{Colors.END}")

A device is considered a PASS if its state is done (or integer 4, which FortiManager also uses) and its error count is zero. Everything else is flagged as FAIL in red.

Sample Output

FortiManager Retrieve Configuration — Sample Output
python3 retrieve_config.py
════════════════════════════════════════════════════════════    FORTIMANAGER RETRIEVE CONFIGURATION AUTOMATION ════════════════════════════════════════════════════════════
FMG IP/URL: 192.168.1.100 Admin Username: admin Admin Password: •••••••• ✔ Session Established.
--- AVAILABLE ADOMS ---
  [0] EMEA-ADOM                       [1] APAC-ADOM   [2] US-ADOM                         [3] LAB-ADOM
Select ADOM Index (or 'e'xit): 0
--- DEVICES IN EMEA-ADOM ---
  [0] FGT-LONDON-01                   (FGT60FTK1234ABCD)   [1] FGT-PARIS-01                    (FGT80FTK5678EFGH)   [2] FGT-BERLIN-01                   (FGT60FTK9012IJKL)   [3] FGT-DUBAI-01                    (FGT100FTK3456MNOP)
Choice ([all], [0,2], [0-5], [b]ack, [e]xit): 0-2
⚙ RUNNING RETRIEVAL (TASK ID: 1042)
   FGT-LONDON-01             | Finished    FGT-PARIS-01              | Retrieving config...    FGT-BERLIN-01             | Pending   Progress: |█████████████░░░░░░░░░░░░52%
TASK SUMMARY  Total: 3  |  Done: 3  |  Errors: 0
STATUS DEVICE NAME IP ADDRESS DETAILS
PASS FGT-LONDON-01 10.10.1.1 Retrieved successfully
PASS FGT-PARIS-01 10.10.2.1 Retrieved successfully
PASS FGT-BERLIN-01 10.10.3.1 Retrieved successfully
Next Action: [1] Same ADOM [2] Change ADOM [3] Exit: 3 ✔ Session closed safely.

Putting It All Together

The full flow looks like this:

Login → Check ADOM status → Select ADOM → List devices
→ Select devices → Trigger retrieval → Poll task → Summary → Logout

Each step is a self-contained block inside the main while True loop, with continue statements to restart the loop on invalid input and sys.exit(0) for clean exits at any point.

Scroll to Top