FMG API From Scratch Part 3: Your First API Call, and Why "OK" Isn't Enough

Part 2 was all concepts. This part makes the first real calls: log in, read something, log out. Then we turn those calls into a small Python client that every later part builds on, and that is deliberately suspicious of good news.

The code for this part is in the fmg-api-from-scratch repo at tag part-03.

Anatomy of a request

Every FortiManager API call is an HTTP POST to one URL, /jsonrpc, with a JSON body. The operation, the object path and the data all live in the body, unlike the FortiOS REST API where the URL and HTTP method carry the meaning.

{
  "id": 1,
  "method": "get",
  "params": [
    { "url": "/sys/status" }
  ],
  "session": "<session-token>"
}
FieldWhat it is
idA number you choose. FortiManager echoes it back so you can match responses to requests
methodWhat to do: the verb (table below)
paramsA list holding one object per request. Almost always one
params[].urlThe object path, such as /sys/status or /dvmdb/adom/<adom>/device. This is where the database from Part 2 is chosen
params[].dataThe payload for writes and actions
sessionThe token you got at login. Absent on the login request itself

The verbs I use in this series:

MethodUsed for
getRead
addCreate
setCreate or overwrite (see the test below, it does not behave the way the word suggests)
updateChange specific fields
deleteRemove
execRun an action: log in, log out, execute a script, install

Step 1: log in

Session authentication is an exec to /sys/login/user:

{
  "id": 1,
  "method": "exec",
  "params": [
    {
      "url": "/sys/login/user",
      "data": { "user": "<api-user>", "passwd": "<password>" }
    }
  ]
}

The account needs JSON API Access enabled and read privileges as a minimum. Part 4 builds a proper least-privilege account for this. Fortinet’s API Best Practices guide describes the two authentication options: a regular administrator with JSON API access (log in, get a session, send it with each request), or a REST API admin with a permanent API key that saves the login and logout steps. I’m using session login throughout because that’s where the worked examples cluster, and swapping to a key later is a small change to one method.

The response:

{
  "cid": 38,
  "id": 1,
  "result": [
    {
      "status": { "code": 0, "message": "OK" },
      "url": "/sys/login/user"
    }
  ],
  "session": "<REDACTED>"
}

The session token is at the top level, not inside result. You send it back as "session" in every later request. Treat it like a password: anyone holding it is you until it’s logged out or expires.

cid is a counter FortiManager adds. I haven’t found it documented, so I don’t rely on it.

If you try these calls by hand with curl, don’t put the password on the command line. It ends up in your shell history and in the process list. Feed the JSON in with -d @- from a here-document, or from a file with tight permissions.

Step 2: read something

Here is a get against the Device Database from Part 2: the copy of one device’s DNS configuration that FortiManager holds.

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

And the real response from my lab (device name replaced with a placeholder):

{
  "cid": 39,
  "id": 2,
  "result": [
    {
      "data": {
        "alt-primary": "0.0.0.0",
        "alt-secondary": "0.0.0.0",
        "cache-notfound-responses": 0,
        "dns-cache-limit": 5000,
        "dns-cache-ttl": 1800,
        "domain": ["lab.local"],
        "interface": [],
        "interface-select-method": 0,
        "oid": 153,
        "primary": "192.0.2.53",
        "protocol": 1,
        "retry": 2,
        "secondary": "8.8.8.8",
        "server-select-method": 1,
        "source-ip": "0.0.0.0",
        "ssl-certificate": ["Fortinet_Factory"],
        "timeout": 5,
        "vrf-select": 0
      },
      "status": { "code": 0, "message": "OK" },
      "url": "/pm/config/device/<device>/global/system/dns"
    }
  ]
}

(I’ve trimmed a few unremarkable fields, and the primary resolver is a documentation address.) Three things to notice:

  1. It isn’t the CLI. protocol, interface-select-method, server-select-method and vrf-select come back as integers, not the words you’d see in show system dns. The next section shows how to get the words.
  2. Lists are lists. domain and ssl-certificate are arrays even when they hold one value.
  3. oid is FortiManager’s numeric ID for the object. Part 8 needs numeric IDs for install.

Getting names instead of numbers

Add "verbose": 1 to the request and FortiManager returns the enumerations as CLI names. Where you put it matters:

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

It sits at the top level of the body, next to method and params, not inside params[0]. I tested both on FortiManager 7.6.6. Inside params[0] it is silently ignored: no error, and you get integers exactly as before. At the top level:

FieldWithout verboseWith top-level verbose
protocol1"cleartext"
server-select-method1"least-rtt"
interface-select-method0"auto"
cache-notfound-responses0"disable"
log0"disable"
vrf-select00

The last row is the catch. vrf-select stayed an integer, so the mapping isn’t complete for every field. Use verbose to read and understand a value, but when a number matters, check it against the CLI or the GUI’s Preview button from Part 2 instead of assuming.

A useful side effect of the /sys/status call is that it splits the version into parts:

{
  "Branch Point": "3654",
  "Build": "3654",
  "Major": 7,
  "Minor": 6,
  "Patch": 6,
  "Platform Full Name": "FortiManager-VM64-KVM",
  "Version": "v7.6.6-build3654 260127 (GA.M)"
}

(trimmed, and with the serial number and hostname removed). Separate Major, Minor and Patch fields mean a script can refuse to run against a version it hasn’t been tested on, without parsing a string.

Step 3: log out

{
  "id": 4,
  "method": "exec",
  "params": [{ "url": "/sys/logout" }],
  "session": "<session-token>"
}

Do it every time. Sessions left open use resources on the FortiManager and, on an account that’s meant to be locked down, are one more token that could be stolen. The Python client below logs out automatically.

What failure looks like

Here is where it stops being a tutorial and starts being useful. Send FortiManager a wrong password and the login call returns HTTP 200:

{
  "cid": 34,
  "id": 1,
  "result": [
    {
      "status": { "code": -22, "message": "Login fail" },
      "url": "/sys/login/user"
    }
  ]
}

The HTTP layer was perfectly happy. The failure is inside the body, in status.code. Anything that only checks response.raise_for_status() sails straight past it.

A script that ignores that and carries on sends its next requests with no session, and each comes back with:

{
  "status": { "code": -11, "message": "No permission for the resource" },
  "url": "/sys/status"
}

That message is misleading. It isn’t a permissions problem. The call had no valid session, and FortiManager reports that as “no permission”. If you see -11 after a login that “worked”, check the session first, and check whether the login really worked at all.

So the rules, for any FortiManager client you write:

  1. Check the status code in the body on every call. HTTP success only means the request arrived.
  2. Stop at the first failed login. Never send anything with an empty session.
  3. Treat a status of 0 as necessary, not sufficient. Some reads come back with status 0 and no data key at all. That means either “nothing to return for these arguments” or “no such endpoint”, and the response alone can’t tell you which. Part 7 has a real example of each reading being wrong.
  4. Never put credentials in error messages or logs.

How add, set and update really behave

Part 6 depends on knowing exactly what these three verbs do to an object that already exists, so I tested them rather than trusting the names. The test object is a throwaway script in the lab ADOM, at /pm/config/adom/<adom>/obj/fmg/script, created, changed and deleted inside a try/finally so nothing is left behind.

StepResult
add a new objectSucceeds. The URL is the collection, and the response echoes the new name
add the same name againFails with -2 Object already exists
update on the object URL with only descChanges desc. content is untouched
set on the object URL with only contentChanges content. desc, name and type are all kept
set on a name that doesn’t existSucceeds and creates it
get on a name that doesn’t existFails with -3 Object does not exist

The surprise is the fourth row. I expected set to replace the whole object, so that fields I didn’t send would be reset. On this object in 7.6.6 it behaved like update, with one difference: it also creates the object if it’s missing. Two cautions follow from that:

  • This is one object type on one version. Don’t assume every table behaves this way. If you depend on a field being cleared or reset, write it explicitly, then get the object back and check.
  • Don’t use set to remove a field. If you sent a smaller payload and hoped the rest would vanish, it may not.

Note also that an object that doesn’t exist gives you a real error (-3) when you ask for it by its exact URL. The “OK with no data” reply in rule 3 is a different, quieter kind of answer.

The client

Here’s the whole thing. It’s about 150 lines of code, and each rule above has a home in it.

"""fmg_client.py - a minimal FortiManager JSON-RPC client.

Rules this client enforces (each one is a lesson from the series):

* HTTP 200 proves nothing. Success or failure lives in the response body,
  so every call checks the status code inside `result[0].status`.
* A status of 0 is necessary, not sufficient. `get()` also insists that a
  `data` key is present, because "OK" with no data can mean "nothing to
  return for these arguments" or "no such endpoint", and only a second try
  with known-good arguments can tell the two apart.
* A failed login stops everything. Nothing is ever sent with an empty session.
* Secrets never appear in exceptions or in repr().
* TLS verification is on by default. Turning it off is explicit and warns.
"""
from __future__ import annotations

import warnings
from typing import Any

import requests


class FMGError(RuntimeError):
    """FortiManager answered, but the body reported a failure (or we refused to send)."""

    def __init__(self, code: int | None, message: str, url: str | None = None):
        self.code = code
        self.message = message
        self.url = url
        detail = f"FMG error {code}: {message}" if code is not None else f"FMG client error: {message}"
        if url:
            detail += f" ({url})"
        super().__init__(detail)


class FMGShapeError(FMGError):
    """The status said OK but the response is not shaped the way it should be."""

    def __init__(self, message: str, url: str | None = None):
        super().__init__(None, message, url)


class FMGClient:
    def __init__(self, host: str, *, verify: bool | str = True,
                 timeout: float = 30, http: Any = None):
        self.url = f"https://{host}/jsonrpc"
        self.timeout = timeout
        if http is None:
            http = requests.Session()
            http.verify = verify
        if verify is False:
            warnings.warn(
                "TLS verification is disabled. Fine for a throwaway lab, "
                "not for anything else.",
                UserWarning,
                stacklevel=2,
            )
        self._http = http
        self._session: str | None = None
        self._id = 0

    def __repr__(self) -> str:
        return f"FMGClient(url={self.url!r}, logged_in={self._session is not None})"

    def __enter__(self) -> "FMGClient":
        return self

    def __exit__(self, exc_type, exc, tb) -> None:
        # Always try to log out, but never let a logout failure hide the
        # exception that got us here.
        try:
            self.logout()
        except (FMGError, requests.RequestException):
            if exc_type is None:
                raise

    def _request(self, method: str, url: str, data: Any = None, *,
                 top: dict | None = None, **extra: Any) -> dict:
        """POST one JSON-RPC request; return the whole payload once its status is OK.

        `extra` goes inside params[0]; `top` goes at the top level of the body.
        """
        self._id += 1
        params: dict[str, Any] = {"url": url}
        if data is not None:
            params["data"] = data
        params.update(extra)

        body: dict[str, Any] = {"id": self._id, "method": method, "params": [params]}
        if self._session:
            body["session"] = self._session
        if top:
            body.update(top)

        resp = self._http.post(self.url, json=body, timeout=self.timeout)
        resp.raise_for_status()          # HTTP-level problems only
        payload = resp.json()

        results = payload.get("result")
        if not results:
            raise FMGShapeError("response has no 'result' list", url)
        status = results[0].get("status", {})
        code = status.get("code")
        if code != 0:                    # the check that actually matters
            raise FMGError(code, status.get("message", "(no message)"),
                           results[0].get("url", url))
        return payload

    def _call(self, method: str, url: str, data: Any = None, *,
              top: dict | None = None, **extra: Any) -> dict:
        return self._request(method, url, data, top=top, **extra)["result"][0]

    def _require_login(self, url: str) -> None:
        if not self._session:
            raise FMGError(None, "not logged in - call login() first", url)

    def login(self, user: str, password: str) -> None:
        payload = self._request("exec", "/sys/login/user",
                                data={"user": user, "passwd": password})
        session = payload.get("session")
        if not session:
            raise FMGShapeError("login reported success but returned no session token",
                                "/sys/login/user")
        self._session = session

    def logout(self) -> None:
        if not self._session:
            return
        try:
            self._call("exec", "/sys/logout")
        finally:
            self._session = None

    def get(self, url: str, *, verbose: bool = False, **extra: Any) -> Any:
        """Read. Returns the `data` block; raises if it is missing.

        verbose=True asks for enum values as CLI names ("cleartext") instead of
        integers (1). It must sit at the top level of the request body.
        """
        self._require_login(url)
        result = self._call("get", url, top={"verbose": 1} if verbose else None, **extra)
        if "data" not in result:
            raise FMGShapeError(
                "status was OK but the response has no 'data' key - nothing to "
                "return for these arguments, or no such endpoint? Try known-good arguments", url)
        return result["data"]

    def execute(self, url: str, data: Any = None, **extra: Any) -> dict:
        """Run an action. Returns the whole first result (some actions have no data)."""
        self._require_login(url)
        return self._call("exec", url, data, **extra)

A few design choices worth explaining:

  • One function builds and checks every request. _request is the only place that talks to the network, so there is exactly one place where the status check can be forgotten, and it isn’t.
  • Two places for extra fields, on purpose. Anything passed as a keyword goes inside params[0]. Top-level fields such as verbose go through a separate top argument, because as the verbose test showed, putting a field in the wrong one fails silently.
  • FMGError carries the code, the message and the URL, and nothing else. The request body, which contains the password on login, is never included, so it can’t leak into a log or a traceback. There’s a test for that.
  • FMGShapeError is for “OK, but wrong”. It covers a missing result, a login with no session, and a get with no data.
  • The with block always logs out, and if logout itself fails while another exception is already in flight, the original exception wins. Otherwise a tidy-up failure hides the real problem.
  • TLS verification is on by default. verify=False works, because lab FortiManagers usually have self-signed certificates, but it emits a warning every time. verify="/path/to/ca.pem" is the right fix, and Part 4 covers it.

Using it

The example script reads credentials from a .env file (copy .env.example, never commit the real one) and does the same three things as the raw calls:

with FMGClient(host, verify=tls_setting()) as fmg:
    fmg.login(user, password)

    status = fmg.get("/sys/status")
    print(f"FortiManager {status['Version']}")

    dns = fmg.get(f"/pm/config/device/{args.device}/global/system/dns")
    print(json.dumps(dns, indent=2))
python examples/first_call.py --device <device-name>

The real output from my lab (trimmed, with the device name and resolver address replaced):

FortiManager v7.6.6-build3654 260127 (GA.M)

Device Database copy of system dns for <device>:
{
  "domain": ["lab.local"],
  "interface-select-method": 0,
  "oid": 153,
  "primary": "192.0.2.53",
  "protocol": 1,
  "secondary": "8.8.8.8",
  "server-select-method": 1,
  "ssl-certificate": ["Fortinet_Factory"],
  "vrf-select": 0
}

To get names instead of numbers, pass verbose=True: fmg.get(url, verbose=True).

Testing it without a FortiManager

I don’t want a reader to need a FortiManager to check that the client’s error handling works, so the tests use a fake HTTP object that replays canned responses. The canned responses are shaped like the real ones above: the login failure, the -11 with no session, and a status-0 response with no data.

python -m unittest -v

There are 18 tests at this tag, covering:

  • a failed login raising even though the HTTP status was 200
  • the password never appearing in an exception or in repr()
  • get() before login sending nothing at all
  • a status of 0 with no data being rejected
  • verbose landing at the top level of the body, never inside params
  • logout happening even when your own code raises, without hiding your exception
  • TLS verification being on by default and verify=False warning

One caution about tests that pass: my first run reported “Ran 0 tests, OK”, because Python’s test discovery needs an __init__.py in the tests folder. A green result that proves nothing is worse than a red one. So I also checked the tests could fail by deliberately breaking the client: ignoring the body’s status code, dropping the data check, allowing calls without a login, and moving verbose into params. Each one made the suite fail. If you change the client, try the same trick.

The mistake worth passing on

In my original script I checked the status code but never checked that the response had the shape I expected. A status of 0 with no data looked exactly like “the log isn’t there yet”, and I spent a while debugging the wrong thing. The client now refuses that shape, so it fails loudly instead of quietly returning nothing.

I’ll tell the full story in Part 7, including the part I got wrong: I concluded the endpoint didn’t exist, and it did. It simply had nothing to return for the device I was asking about. The response was ambiguous, and I read it the way that suited my theory. The general rule is to treat “OK with no data” as a question, and to answer it by asking the same endpoint something you know has an answer.

Rules of thumb

  • The HTTP status tells you the request arrived. result[0].status.code tells you whether it worked.
  • -22 is a failed login. -11 after a login means check the session before the permissions.
  • A client should stop at the first failed login and never send an empty session.
  • Read the data you expected, not just the status you hoped for.
  • verbose goes at the top level of the request body. In the wrong place it does nothing, and says nothing.
  • After a set or update, get the object back and check it.
  • Log out every time, and let a with block make sure of it.

What comes next

Part 4 builds the account this client logs in with: a dedicated administrator with an admin profile that can do exactly what the script needs and nothing else, scoped to one ADOM and one trusted host, with the credentials kept in .env. We’ll also test that the restrictions actually hold, by trying to do things the account shouldn’t be able to do.

Sources: