FMG API From Scratch Part 1: Why FortiManager, and Not Straight to FortiOS

I have a Python script called fmg_push.py. It renders Jinja templates locally, pushes the result into FortiManager, stages it against a device, reads back what FortiManager thinks it staged, and installs it to the live FortiGate. It’s about a thousand lines long, it works, and it took a lot longer to get right than it should have, because for a while it failed in ways that looked exactly like success.

This series rebuilds it from the first API call. By the end you’ll have your own working version and you’ll know why every part of it is there, including the parts I got wrong first. The code lives in a public repo, one tagged commit per part from Part 3 onward, so you can check out the code as it stood at each step and compare it with yours.

This series is the foundation: a careful, tested tool that changes one managed device safely. It stops short of building a whole FortiGate site from nothing (registering a new device, templates, interfaces, SD-WAN, a policy package). That is a larger job, and it deserves its own follow-up series built on this tool.

This first part has no code at all. It answers the question I should have asked before I wrote a line: why go through FortiManager at all?

Who this is for

You should be comfortable with:

  • Reading and writing FortiOS CLI, and knowing roughly what FortiManager is for. If ADOMs and device registration are new, the NSE 5 series covers them.
  • Basic Python. If you’ve followed the Python for Network Engineers series, you have enough.
  • JSON, and what an HTTP POST is.

By Part 9 you’ll have a small command-line tool that can authenticate to FortiManager, render a config from templates and variables, push it, stage it against one device, show you what will change, and install it, with a service account that can do that and nothing else.

The question first: why not talk to the FortiGate directly?

FortiOS has a perfectly good REST API. I’ve used it on this site already, for parsing policy and SD-WAN health with jq and in the Python series. For one device, it’s the right tool: a URL, an HTTP method, a token, done.

So why would anyone add a layer?

Straight to FortiOS RESTThrough FortiManager
ScopeOne device per connectionOne endpoint, every device in the ADOM you’re allowed to touch
What a write doesChanges the running config on that device as the request landsChanges FortiManager’s copy of the config. Nothing reaches the device until you ask for an install
Review before changeYou build your own diffFortiManager compares its new copy against the config it retrieves from the device and shows the difference (Install Preview)
HistoryWhatever you log yourselfConfiguration revisions per device, with revert
CredentialsA token per device to create, store and rotateOne service account, scoped to an ADOM and a trusted host
Templates and variablesYours to buildProvisioning templates, Jinja CLI templates and metadata variables, all native
DriftYou find out by polling and diffingFortiManager tracks whether its copy and the device agree
If the credential leaksYou lose one deviceYou lose everything that account can reach

That last row is worth pausing on. FortiManager doesn’t remove risk, it concentrates it. A stolen device token is a bad day. A stolen FortiManager admin credential with write access to a whole ADOM is a much worse one. That’s why Part 4 is entirely about the service account, and why I keep FortiManager’s API reachable only from a management network and never from the internet.

The line that matters

The real reason to go through FortiManager isn’t any single row of that table. It’s how a change actually reaches the device. From the FortiManager 7.6.6 Administration Guide (Key Concepts, page 57):

When FortiManager installs changes to managed devices, for example installing Policy Packages and CLI templates to a FortiGate, it follows a sequence where the configuration is first copied to the device’s Device Database on FortiManager before actual installation to the target device.

And on the next page:

The diff between the old and new configuration is installed to the target FortiGate, but not the original content.

Read those twice. FortiManager doesn’t fire your config at a FortiGate. It builds a complete new configuration file for that device inside its own database, retrieves the device’s current configuration, works out the difference, and installs only the difference.

That has a consequence that catches nearly everyone who automates FortiManager for the first time: writing to the API and changing a device are two different operations. You write into FortiManager’s databases with one set of calls, then ask FortiManager to install with another. Part 2 is about those databases, because nearly everything that went wrong in my own script came from misunderstanding which one I was writing to.

When straight to FortiOS is still the right answer

I’m not arguing you should route everything through FortiManager. Direct is right when:

  • The device isn’t managed. My scratch FortiGate-VM for the Golden Template series deliberately isn’t registered to FortiManager, so local CLI is fine on it.
  • You’re reading, not writing. Monitor endpoints (session tables, SD-WAN health, interface counters) are a device-level question and the device is the source of truth.
  • You’re bootstrapping. Before a device is registered there’s nothing for FortiManager to manage.
  • FortiManager is the thing that’s broken. I’ve done this once, and I’ll be honest about it: my lab FortiManager’s licence had lapsed and the GUI wouldn’t load, and I needed a route on the hub. I applied it on the console with content identical to what I’d already staged in FortiManager, and then had to reconcile the two afterwards. It worked. It was also a documented, deliberate exception to my own rule of no local changes on managed devices, and I’d rather not need it again.

The rule I’ve settled on: pick one control plane per device, and if you have to break it, write down why and reconcile afterwards. Two systems both believing they own a config is how drift starts.

Two APIs that look alike and aren’t

Both FortiOS and FortiManager speak JSON over HTTPS, so it’s tempting to assume they work the same way. They don’t.

FortiOS REST puts the meaning in the URL and the HTTP method. To read the DNS settings of one device:

GET https://<fortigate>/api/v2/cmdb/system/dns
Authorization: Bearer <api-token>

FortiManager’s JSON API is JSON-RPC. Everything, the operation, the object path and the data, goes in the body of a POST to a single endpoint, /jsonrpc. The equivalent read looks like this:

POST https://<fortimanager>/jsonrpc
Content-Type: application/json

{
  "id": 1,
  "method": "get",
  "params": [{ "url": "/pm/config/device/<device>/global/system/dns" }],
  "session": "<session-token>"
}

Two things to notice, both of which come back later in the series:

  1. That FortiManager call isn’t reading the FortiGate. It’s reading FortiManager’s copy of that device’s configuration, its Device Database. The two agree only if nothing has changed on either side since they last synchronised.
  2. The HTTP layer tells you very little. Success or failure is reported inside the response body, in a status code, and, as Part 3 shows, “success” turns out to mean less than you’d hope.

Here’s what comes back from my lab, trimmed to the interesting fields, with the device name replaced and the resolver swapped for a documentation address:

{
  "result": [
    {
      "data": {
        "domain": ["lab.local"],
        "primary": "192.0.2.53",
        "protocol": 1,
        "secondary": "8.8.8.8"
      },
      "status": { "code": 0, "message": "OK" },
      "url": "/pm/config/device/<device>/global/system/dns"
    }
  ]
}

Notice that protocol is the integer 1, not a word from the CLI. Part 3 has the full response and shows how to get the words back.

Where the documentation is, and isn’t

This is the part that surprised me. FortiManager’s API is powerful and the documentation around it is spread across several places, so it helps to know where to look. I started out thinking it was barely documented. That was an overstatement: it is documented in pieces, by different people, and none of the pieces tells you what happens when a call goes wrong.

SourceWhat it gives youThe catch
FortiManager API Best Practices GuidePublic. Explains the JSON API is JSON-RPC and covers authentication, asynchronous tasks, workspace locking and loggingDeliberately short. It’s guidance, not a reference, and it doesn’t cover scripts, install or script logs
Getting started, in the same guideThe two authentication options (a REST API admin with a permanent key, or a normal admin with JSON API access using session login)Points you at the Fortinet Developer Network for the full reference
Fortinet Developer NetworkThe full API referenceYou may need a Fortinet account to get in
How-To FortiManager API (community)Worked examples: login, reading ADOMs and devices, creating and running CLI scripts, the scope shape, script log retrievalA community resource, not Fortinet’s. It shows what to send, not what happens when a field is wrong
The Ansible collection fortinet.fortimanagerOne module per endpoint, including the install preview calls. A good index of URLsWhether a call works on your version is for you to test
FortiManager Administration GuideThe GUI, thoroughly, and the install sequence quoted aboveThin to silent on many raw endpoints. Fortinet publishes a version per release, and I use the 7.6.6 one
The GUI’s own Preview buttonThe exact JSON API request behind a change made in the Device Database editor, with a copy buttonOnly covers changes you can make in the GUI. Covered in the 7.6.6 Administration Guide under “Preview the JSON request or CLI script for changes in the device database”
Fortinet Community technical tip on the FortiManager and FortiAnalyzer APIA short worked first callOne example, not a tour
Third-party libraries: pyfmg, pyFortimanagerAPI, and Network to Code’s fortimanager-ansibleWorked code to read and compare againstI haven’t tested any of them for this series
Your browser’s developer toolsEvery call the FortiManager GUI makes, which is how I found some of the endpoints I couldn’t find anywhere elseReverse-engineering, covered in Part 5

I’ll use plain requests throughout rather than one of those libraries, on purpose. When you’re learning a management API you want to see every call and every response, and a wrapper hides exactly what you need to look at. Once you understand what’s happening underneath, a library may well save you typing.

The Preview button deserves a special mention because it’s the most underused thing in the whole product. It’s documented, it’s in the GUI, and it shows you the JSON that FortiManager itself would send. Before you guess an endpoint, make the change once in the GUI and read the preview.

What we’re building

Nine parts, each ending with a piece of working code:

PartTopicYou end up with
1Why FortiManager, and not straight to FortiOSThe design decision, and a map of the docs
2ADOM database, Device database and the live deviceThe mental model that makes everything else make sense
3Your first API callA login, a read, and a small Python client class with tests
4A safe service accountLeast-privilege access, an audit of the account, a pinned certificate
5DiscoverySurveying the box by shape, and finding endpoints the documentation doesn’t cover
6PushScripts, variables and Jinja, and the trap I fell into
7Stage and inspectTasks, logs found by their task id, and reading back what FortiManager staged
8InstallPreview then commit, and a guard so only the diff you read gets installed
9The finished toolThe final fmg_push.py, tests and a hardening checklist

Titles and order may shift as I write them up. If a later part teaches me something that changes an earlier one, I’ll fix the earlier one and say so.

The lab and the versions

  • FortiManager 7.6.6 (build 3654, a VM on Proxmox). This is the version that matters for the series, because the API you call is FortiManager’s. I run it deliberately, and haven’t moved to 7.6.7.
  • A physical FortiGate 70G on FortiOS 7.6.7 (build 3704), and FortiGate-VMs on 7.6.6, all managed by that FortiManager. FortiOS versions vary in the lab on purpose.

One caveat on that combination. The FortiManager 7.6.6 release notes list the supported FortiOS versions as “7.6.0 to 7.6.6”, alongside 7.4.0 to 7.4.12, 7.2.0 to 7.2.13 and 7.0.0 to 7.0.19. FortiOS 7.6.7 is not on that list. It works in my lab, and the 70G is managed without complaint, but that is an observation, not a guarantee. Check the release notes and compatibility information for your own combination before trusting it anywhere that matters.

To follow along you need:

  • A FortiManager VM. Fortinet’s VM trial licence guide describes a free trial. At the time of writing Fortinet’s licensing documentation limits it to three managed devices or VDOMs and two ADOMs, and it has no FortiGuard subscriptions or FortiAnalyzer features. I couldn’t find the trial’s duration stated, so check when you activate it. Three devices is plenty here, because the series only ever needs one or two.
  • At least one FortiGate, physical or VM, registered to it. If you haven’t built that yet, my FortiManager lab on Proxmox series does.
  • A Linux host with Python 3 on the management network to run the scripts from. Mine is a small jumpbox VM. Don’t run this from your laptop across the internet, and never expose FortiManager’s API beyond your management network. It manages your whole fleet.

The repo

Everything in this series is in a public GitHub repo, fmg-api-from-scratch, with one tagged commit per part from Part 3 (part-03, part-04 and so on up to part-09). Parts 1 and 2 have no code. It contains placeholders only: no real addresses, serial numbers, keys or configs, an .env.example and never an .env, and I ran a secret scan over the full history before publishing it. It is MIT licensed.

The honest bit

A fair amount of what follows was worked out by watching the FortiManager GUI talk to its own API, or by probing, because the documentation didn’t cover it. I’ll flag which calls are documented and which I captured, because the second kind can change between FortiManager releases without notice. Everything here is tested on the versions above and nothing else.

I’ll also tell you where I was wrong. A “successful” response that did nothing, an endpoint that returned a plausible answer that was out of date, and a fix that briefly deleted variables a working device depended on. Those aren’t embarrassing footnotes, they’re the reason the order of this series is what it is.

What comes next

Part 2 opens up the box. FortiManager keeps two databases, one for the ADOM and one for each device, and the difference between them is why some of my pushes “succeeded” and installed nothing at all.

Sources: