nautobot-ssot-fortinet
Bidirectional Nautobot ↔ FortiGate sync for firewall, wireless, DCIM (Device + interfaces + IPs + VLANs + static routes), and live runtime state — built on the Nautobot SSoT framework.
What this does
Seven Nautobot Jobs, spanning three sync domains plus a live-status observer:
| # | Job | Direction | Object kinds |
|---|---|---|---|
| 1 | FortiGate → Nautobot (firewall) | Pull | Addresses, address groups, services, service groups, policies + rules, NAT (VIPs) |
| 2 | FortiGate → Nautobot (wireless) | Pull | WirelessNetworks (SSIDs), RadioProfiles (radios), optional AP Devices |
| 3 | FortiGate → Nautobot (device + interfaces) | Pull | FortiGate as dcim.Device, physical + aggregate + VLAN sub-interfaces, interface IPs, static routes |
| 4 | FortiGate Live Status | Observability | Real-time wifi clients, DHCP leases, ARP table (joined by MAC), plus managed APs |
| 5 | Nautobot → FortiGate (firewall) | Push | Full CRUD across address / group / service / service-group / policy / NAT |
| 6 | Nautobot → FortiGate (wireless) | Push | VAP + RadioProfile CRUD (VAP delete blocked by FortiOS REST — see docs) |
| 7 | Nautobot → FortiGate (device + interfaces) | Push | VLAN sub-interface CRUD (v3.3+), static route CRUD (v3.4+, opt-in via push_routes) |
All sync Jobs (1, 2, 3, 5, 6, 7) are idempotent (re-running produces zero diffs when nothing changed) and additive-only by default (destructive deletes opt-in via a per-Job BooleanVar). Job 4 (Live Status) writes no Nautobot records; each run drops a timestamped JSON snapshot of observed device state.
The device + interfaces push (Job 7) is additionally scoped to protect
the appliance: only virtual VLAN sub-interfaces with a valid parent,
and only routes with seq_num >= 1000. Anything outside that envelope
is refused before the REST call — a bad write to a live FortiGate's
interface or route table can disconnect it.
How it works
┌────────────┐
│ Nautobot │
└─────┬──────┘
↑ │
3 pull │ ▼ 3 push
│
┌────────────┐ monitor/* (live state)
│ FortiGate │ ─────────────────────────→ Live Status Job
└────────────┘
- Pull Jobs (3) read
cmdb/*endpoints and write to Nautobot ORM: firewall-models for firewall objects, core wireless for VAPs and RadioProfiles, and coredcim+ IPAM + our ownFortinetStaticRoutemodel for the device / interfaces / routes. The FortiGate is the source of truth; Nautobot mirrors it. - Push Jobs (3) read from Nautobot ORM and write to FortiGate
cmdb/*endpoints. Operators edit objects in Nautobot's UI; the matching push Job propagates them to the device. Full CRUD across firewall (v2.x), wireless (v2.x, VAP delete excepted), and device+interface (VLAN sub-interfaces since v3.3, static routes since v3.4). - Live status Job queries
monitor/*endpoints for real-time observed state (connected clients, leases, ARP) and renders them in the Job result page + downloadable JSON snapshot. No Nautobot writes.
Quick start
# 1. Bring up the dev stack
cp development/.env.example development/.env
# edit development/.env — set NAUTOBOT_SECRET_KEY + FortiGate credentials
make -C development up
# 2. Wait ~60s, then seed the ExternalIntegration + SecretsGroup
make -C development seed
# 3. Open https://ssot-fortinet-dev.l.warehack.ing/ (or the loopback
# fallback http://127.0.0.1:8080/ if you don't have the wildcard DNS
# resolving), enable the Jobs you want in /extras/jobs/ , then run
# them from /plugins/ssot/
For full installation steps including production deployment, see
docs/admin/install.md.
Live-validated against real hardware
This integration was validated end-to-end against a FortiWiFi-61E
during development, and against a FortiGate-100F cluster in Kevin
Mueller's production environment (v3.2 series). The live e2e harness in
development/scripts/e2e_push_*.py connects to a real FortiGate, exercises
CRUD per object kind, and asserts a second run produces zero diffs (full
idempotency). Run all of them with make -C development e2e-push-all.
Real-world quirks discovered & handled:
| FortiOS reality | Translation |
|---|---|
interface-subnet address type |
Treated as ipmask (FortiOS already resolves to CIDR) |
Space-separated multi-port ("88 464" Kerberos) |
Normalized to comma ("88,464") for firewall-models' validator |
ICMP6 protocol |
Mapped to IPv6-ICMP (IANA name used by firewall-models) |
'513:512-1023' src-port qualifier (RLOGIN/RSH) |
Source-port stripped, destination kept |
protocol: "ALL" pseudo-protocol (webproxy) |
Skipped — no Nautobot equivalent |
protocol: "IP" + protocol-number: N |
Mapped to named IANA protocol (e.g. 89 → OSPFIGP) |
| FortiOS WTP-profile mode vs Nautobot WirelessNetwork mode | Most-common platform-mode per VAP wins |
FortiOS radio band strings (802.11ax-5G) |
Pattern-matched to 2.4GHz/5GHz/6GHz enum |
FortiOS VAP security: "wep128" |
Lossy mapping → Open with annotation in description |
See docs/user/external_interactions.md for the complete
field-by-field reference.
What's required
- Nautobot 3.1+ (for the wireless models)
- nautobot-ssot 4.2+ (SSoT framework)
- nautobot-firewall-models 3.0+ (firewall object target)
- fortigate-api 2.0+ (REST client; we've validated against FortiOS 7.0.14 on real hardware; the underlying library covers 6.4–7.x)
- Python 3.10–3.13
See docs/admin/compatibility_matrix.md for the
detailed compatibility matrix and version pinning rationale.
What's not in scope (yet)
Real gaps as of v3.4.2. See CHANGELOG.md and the v3.x summary for what has shipped so far (Policy/NAT push, Device sync, VLAN sub-interfaces, static routes — all live-validated).
- IPv6 firewall addresses (
firewall/address6andaddrgrp6) — IPv4 only right now. IPv6 interface IPs on Devices are already synced (v3.0+), but the standalone AddressObject types aren't. - Source NAT (FortiOS
ippool) — pull and push only cover Destination NAT (VIPs). SNAT pools have three subtype variants (overload, one-to-one, fixed-port-range) that map differently to Nautobot semantics; each needs a design pass. - VPN sync — Nautobot has
vpn.VPN/vpn.VPNTunnel/ endpoint models and FortiOS has phase1/phase2 data, but nothing bridges them yet. Tunnel interfaces are also skipped in the device sync until this lands. firewall.schedule— policy push currently hardcodesschedule: "always". Time-based rules can't be authored in Nautobot yet.- Zone objects (
system.zone) — referenced in policy interface fields as strings, but not synced as first-class Nautobot records. - Multi-VDOM aware UI — Jobs scope by VDOM, but the per-VDOM dropdown in the SSoT dashboard is a Nautobot-side enhancement, not in this app.
If any of these are blocking your deployment, file an issue on GitHub — the roadmap prioritizes by real operator need.
Project layout
src/nautobot_ssot_fortinet/
├── clients/fortigate.py ExternalIntegration → FortiGateAPI factory
├── diffsync/
│ ├── models/
│ │ ├── firewall.py Vendor-neutral firewall DiffSync models
│ │ ├── wireless.py Vendor-neutral wireless DiffSync models
│ │ ├── devices.py Vendor-neutral Device + Interface + Route models (v3.0+)
│ │ ├── nautobot_firewall.py Nautobot-side CRUD (firewall pull target)
│ │ ├── nautobot_wireless.py Nautobot-side CRUD (wireless pull target)
│ │ ├── nautobot_devices.py Nautobot-side CRUD (device+route pull target)
│ │ ├── fortigate_target_firewall.py FortiGate-side CRUD (firewall push)
│ │ ├── fortigate_target_wireless.py FortiGate-side CRUD (wireless push)
│ │ └── fortigate_target_devices.py FortiGate-side CRUD (device+route push, v3.3+)
│ └── adapters/
│ ├── fortigate_firewall.py Firewall pull source
│ ├── fortigate_wireless.py Wireless pull source
│ ├── fortigate_devices.py Device+route pull source (v3.0+)
│ ├── fortigate_firewall_target.py Firewall push target
│ ├── fortigate_wireless_target.py Wireless push target
│ ├── fortigate_devices_target.py Device+route push target (v3.3+)
│ ├── nautobot_firewall.py Nautobot-side firewall reader
│ ├── nautobot_wireless.py Nautobot-side wireless reader
│ └── nautobot_devices.py Nautobot-side device+route reader
├── jobs.py 7 Job classes (see the SSoT dashboard)
├── models.py FortinetStaticRoute Django model (v3.1+)
├── views.py, urls.py, tables.py, UI for FortinetStaticRoute at
├── forms.py, filters.py, /plugins/ssot-fortinet/static-routes/
├── navigation.py, templates/
├── migrations/ Django migrations for FortinetStaticRoute
└── utils/fortios.py Translation helpers (subnet→CIDR, security→auth, etc.)
Testing
$ pytest -q
302 passed in 0.28s
Unit tests use a MagicMock-stubbed Django (per tests/conftest.py) so they
run in milliseconds without bootstrapping Nautobot. Integration coverage
is provided by the live e2e harnesses in development/scripts/ — those
require a real FortiGate. make -C development e2e-push-all runs every
push CRUD e2e in sequence; make -C development e2e-jobs-lifecycle
runs the Job.run() regression guard.
Status
v3.x — bidirectional sync for firewall, wireless, and DCIM (Device +
Interfaces + VLAN sub-interfaces + Static Routes), live-validated
against a FortiWiFi-61E and against a real FortiGate-100F cluster in
production. Multi-device deployments handle cross-device uniqueness
via the name-mangling convention (<host>__<vdom>__<original>);
multi-VDOM hostnames with __ characters would need attention if that
convention conflicts with your host naming.
Roadmap: see CHANGELOG.md
and the v3.x summary.
License
Apache-2.0.
Author
Ryan Malloy — ryan@supported.systems
Metadata
Release files for nautobot-ssot-fortinet 2026.5.19.4
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| nautobot_ssot_fortinet-2026.5.19.4.tar.gz | 359.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| nautobot_ssot_fortinet-2026.5.19.4-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 460.7 kB
Release files / nautobot_ssot_fortinet-2026.5.19.4.tar.gz
| Download URL | nautobot_ssot_fortinet-2026.5.19.4.tar.gz |
|---|---|
| Size | 359.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
b025aa227a5534110d089ba7b535da08f3f67c2db4ec1d4f030d2a2f57f5c9c8
|
|
BLAKE2b-256 checksum How to use checksums |
fada4f22dd3d9c1abafb4b37c2103dbdf849a44ffbe9eed424ab359453b36243
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.3 {"installer":{"name":"uv","version":"0.11.3","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"EndeavourOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / nautobot_ssot_fortinet-2026.5.19.4-py3-none-any.whl
| Download URL | nautobot_ssot_fortinet-2026.5.19.4-py3-none-any.whl |
|---|---|
| Size | 101.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
ac754adfaa82b5a034217e7d2590639522654e81b9d3700ca3697c5c14b67081
|
|
BLAKE2b-256 checksum How to use checksums |
3acb71340998f6ed24a8857575d964780ee821792b1e9cc17cd3f61210a637fe
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.3 {"installer":{"name":"uv","version":"0.11.3","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"EndeavourOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|