Metadata-Version: 2.5
Name: nautobot-ssot-fortinet
Version: 2026.5.19.4
Summary: Nautobot SSoT integration for Fortinet (FortiGate firewall + FortiAP wireless, bidirectional sync).
Project-URL: Homepage, https://github.com/rsp2k/nautobot-ssot-fortinet
Project-URL: Issues, https://github.com/rsp2k/nautobot-ssot-fortinet/issues
Project-URL: Source, https://github.com/rsp2k/nautobot-ssot-fortinet
Author-email: Ryan Malloy <ryan@supported.systems>
Keywords: diffsync,firewall,fortiap,fortigate,fortinet,fortios,nautobot,nautobot-app,nautobot-plugin,ssot,wireless
Classifier: Development Status :: 2 - Pre-Alpha
Classifier: Framework :: Django
Classifier: Intended Audience :: System Administrators
Classifier: Intended Audience :: Telecommunications Industry
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: System :: Networking
Classifier: Topic :: System :: Networking :: Firewalls
Requires-Python: <3.14,>=3.10
Requires-Dist: fortigate-api<3,>=2.0
Requires-Dist: nautobot-firewall-models<4,>=3.0
Requires-Dist: nautobot-ssot<5,>=4.2
Requires-Dist: nautobot<4,>=3.1
Provides-Extra: dev
Requires-Dist: pytest-django>=4.8; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: responses>=0.25; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Provides-Extra: docs
Requires-Dist: markdown-version-annotations<2,>=1.0; extra == 'docs'
Requires-Dist: mkdocs-glightbox<1,>=0.4; extra == 'docs'
Requires-Dist: mkdocs-include-markdown-plugin<8,>=6.2; extra == 'docs'
Requires-Dist: mkdocs-material<10,>=9.5; extra == 'docs'
Requires-Dist: mkdocs<2,>=1.6; extra == 'docs'
Requires-Dist: mkdocstrings[python]<1,>=0.26; extra == 'docs'
Requires-Dist: pymdown-extensions>=10.7; extra == 'docs'
Description-Content-Type: text/markdown

# 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](https://docs.nautobot.com/projects/ssot/en/latest/).

[![Tests](https://img.shields.io/badge/tests-302%20passing-brightgreen)](#testing)
[![Live-validated](https://img.shields.io/badge/live--validated-FortiWiFi--61E%20%2B%20FortiGate--100F-blue)](#what-this-does)
[![Python](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12%20%7C%203.13-blue)](https://github.com/rsp2k/nautobot-ssot-fortinet/blob/main/pyproject.toml)
[![Nautobot](https://img.shields.io/badge/nautobot-3.1%2B-orange)](https://nautobot.com/)

## 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 core `dcim` + IPAM + our own `FortinetStaticRoute`
  model 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

```bash
# 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`](https://github.com/rsp2k/nautobot-ssot-fortinet/blob/main/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`](https://github.com/rsp2k/nautobot-ssot-fortinet/blob/main/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`](https://github.com/rsp2k/nautobot-ssot-fortinet/blob/main/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`](https://github.com/rsp2k/nautobot-ssot-fortinet/blob/main/CHANGELOG.md) and the [v3.x summary](https://nautobot-ssot-fortinet.readthedocs.io/en/latest/admin/release_notes/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/address6` and `addrgrp6`) — 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 hardcodes `schedule: "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`](https://github.com/rsp2k/nautobot-ssot-fortinet/blob/main/CHANGELOG.md)
and the [v3.x summary](https://nautobot-ssot-fortinet.readthedocs.io/en/latest/admin/release_notes/v3.x-summary/).

## License

Apache-2.0.

## Author

Ryan Malloy — `ryan@supported.systems`
