Backup & migration
Three workflows live in the same place: JSON config backup & restore for day-to-day snapshots, OPNsense / pfSense XML import for migrating off another firewall, and commit-confirm for safe remote edits that auto-revert if you lose access. Versioned history sits underneath all three so any change is one click away from a rollback.
JSON backup & restore
Export the entire configuration — rules, NAT, aliases, VPN, geo-IP, DNS, DHCP, IDS settings, multi-WAN policy, reverse proxy, users, OAuth providers — as a single JSON document:
# Redacted export — secret fields are the literal "**REDACTED**"
curl https://aifw.local/api/v1/config/export \
-H "Authorization: Bearer $TOKEN" \
-o aifw-backup-$(date +%F).json
# Portable export — secrets wrapped under a passphrase (Argon2id + AES-256-GCM)
curl -X POST https://aifw.local/api/v1/config/export \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"passphrase": "correct horse battery staple"}' \
-o aifw-backup-$(date +%F).json
Restore by POSTing the same document back (add a top-level "passphrase" for a portable export). The importer validates the shape, snapshots the current config to history first, then applies the new state through the same engine writers (RuleEngine, NatEngine, AliasEngine, …) that the UI uses — so pf actually picks up the changes.
curl -X POST https://aifw.local/api/v1/config/import \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data @aifw-backup.json
Secrets in exports. A backup never carries live keys in the clear.
GET /config/export(and the UI’s Download Backup) replaces every secret field — WireGuard private/preshared keys, IPsec PSKs and private keys, CARP passwords, the DDNS TSIG secret — with**REDACTED**; such a file restores cleanly onto the appliance it came from, because the importer fills the sentinels back in from the tunnels/VIPs the box already holds (matched by id) and refuses, naming each one, when it cannot. For a backup you can restore on a replacement box, use thePOSTform (UI: Portable backup): secrets are wrapped per field under a key derived from your passphrase with Argon2id (64 MiB, t=3) and AES-256-GCM, and the KDF salt travels inconfig.secrets. The passphrase is not stored anywhere. Config-history views (/config/version,/config/diff,aifw config show) are redacted too; the stored versions themselves are full-fidelity and sealed at rest.Secrets at rest. Integration secrets in the database (the above plus OAuth client secrets, S3/SMTP credentials, ACME DNS tokens, AI provider keys, TOTP seeds, cluster peer keys) are sealed with AES-256-GCM under a master key in
/var/db/aifw/secrets.key(0600, besidejwt.key). A raw copy ofaifw.dbis only useful together with itssecrets.key; move to a new box without the key and every sealed value reads as unset. Back the two up together.
Preview a candidate import without applying via POST /api/v1/config/import-preview. The response lists every record that will be created / updated / deleted so you can sanity-check the diff first.
S3 backup destination
Push backups off-box on a schedule. Configure a bucket, region, prefix, and access keys; AiFw uploads a fresh JSON snapshot on every config save plus on a configurable rotation schedule.
Uploads follow the same rule as downloads: with a backup passphrase set (secrets_passphrase, write-only like the S3 secret; UI: Settings → S3 Backup Sync), each object’s secrets are passphrase-wrapped and the object restores on any appliance that knows the passphrase. Without one, uploads are redacted and only restore cleanly onto the appliance that produced them. POST /backup/s3/import unlocks a wrapped object with the stored passphrase, or with a "passphrase" in the request body when it differs.
curl -X PUT https://aifw.local/api/v1/backup/s3/config \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"enabled": true,
"bucket": "ops-aifw-backups",
"region": "us-east-1",
"prefix": "site-a/",
"path_style": false,
"access_key_id": "AKIA...",
"secret_access_key": "...",
"secrets_passphrase": "correct horse battery staple"
}'
# Verify credentials + bucket access without touching anything:
curl -X POST https://aifw.local/api/v1/backup/s3/test \
-H "Authorization: Bearer $TOKEN"
# Restore a specific snapshot:
curl https://aifw.local/api/v1/backup/s3/list \
-H "Authorization: Bearer $TOKEN"
curl -X POST https://aifw.local/api/v1/backup/s3/import \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"key": "site-a/2026-05-08T03:15:22Z.json"}'
S3-compatible endpoints work too — set endpoint and path_style: true for MinIO, Backblaze B2, Wasabi, Cloudflare R2, ….
OPNsense import
Migrating from OPNsense or pfSense? Drop the appliance’s config.xml straight into AiFw. The importer was rewritten in #230 and hardened across #248–#252 with a documented atomicity model and round-trip rollback test coverage.
Atomicity model
- Parse first.
quick-xmlwalks the document. Every error that can be detected from the XML alone (malformed source/destination, unknown action, unparseable port range) surfaces in the preview — no half-applied state. - Preview the diff.
POST /api/v1/config/preview-opnsensereturns aliases, NAT entries, rules, and static routes that will be created, plus a skipped list for rules that referenced network keywords (lan,wanip,(self), …) that don’t map cleanly. AiFw drops those to skipped instead of guessing — silent fidelity loss is the worst outcome. - Apply with rollback.
POST /api/v1/config/import-opnsenseapplies strictly — any failed required step aborts. The whole target config is pre-validated (same converters and engine validators the apply uses) before the first destructive change, all database mutations run in a single SQL transaction that rolls back automatically on failure, and after the transaction commits the pf/kernel applies are verified against what the engines rendered. Kernel-side pf/route changes can’t ride in the SQL transaction, so a post-commit failure recovers by re-applying a pre-import snapshot (see the documented atomicity model in the importer source). Aliases route throughAliasEngine, NAT throughNatEngine, rules throughRuleEngine, static routes through the sameapply_route_to_systempath the manual REST endpoint uses, and DNS forwarders through the same path the rDNS resolver UI writes — not/etc/resolv.conf(see #231). - Snapshot & commit-confirm. Before any write the importer saves a
pre-OPNsense-importconfig-history version, and after a successful apply it arms commit-confirm with a 600-second timeout. If the admin loses access to the appliance before they confirm, AiFw rolls back automatically. - Reject → BlockReturn. OPNsense’s
rejectaction maps to AiFwAction::BlockReturn, which produces the same per-protocol response (TCP RST, ICMP unreachable for UDP/ICMP) the original ruleset emitted.
Walkthrough (UI)
Backup & restore → OPNsense import → Upload config.xml → review preview (aliases, NAT, rules, skipped list, interface map) → Apply → Confirm within the commit-confirm window.
Walkthrough (API)
# 1) Preview
curl -X POST https://aifw.local/api/v1/config/preview-opnsense \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"xml": "'"$(base64 -w0 < /tmp/config.xml)"'",
"interface_map": { "wan": "em0", "lan": "em1" }
}'
# 2) Apply (uses same body shape; commit-confirm starts on success)
curl -X POST https://aifw.local/api/v1/config/import-opnsense \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data @import-body.json
# 3) Confirm (within 600 seconds) — otherwise auto-rollback
curl -X POST https://aifw.local/api/v1/config/commit-confirm/confirm \
-H "Authorization: Bearer $TOKEN"
Max XML size is 10 MiB (real OPNsense configs are 50–500 KiB).
Versioned config history
Every save — manual, scheduled, OPNsense import, S3 restore — produces a numbered version. The history endpoints expose them for inspection, diffing, and selective restore.
curl https://aifw.local/api/v1/config/history \
-H "Authorization: Bearer $TOKEN"
# Diff two versions
curl "https://aifw.local/api/v1/config/diff?from=42&to=43" \
-H "Authorization: Bearer $TOKEN"
# Preview what restoring a version would change
curl "https://aifw.local/api/v1/config/restore-preview?version=42" \
-H "Authorization: Bearer $TOKEN"
# Apply
curl -X POST https://aifw.local/api/v1/config/restore \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"version": 42}'
Retention is configurable via GET / PUT /api/v1/config/retention — cap by count, by age, or both.
Commit confirm
Editing remotely and worried about locking yourself out? Wrap the change in commit-confirm. AiFw applies the new config, starts a timer, and reverts unless you explicitly confirm.
# Arm — default timeout is 300 seconds
curl -X POST https://aifw.local/api/v1/config/commit-confirm \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"timeout_secs": 300, "description": "tightening LAN rules"}'
# Check status
curl https://aifw.local/api/v1/config/commit-confirm/status \
-H "Authorization: Bearer $TOKEN"
# Confirm
curl -X POST https://aifw.local/api/v1/config/commit-confirm/confirm \
-H "Authorization: Bearer $TOKEN"
If the timer expires, the snapshot taken at arm time is restored and the config that lost you access is rolled back — same engine writers, same snapshot-based apply.
API endpoints
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/v1/config/export |
Export entire config as JSON (secrets redacted) |
POST |
/api/v1/config/export |
Export with secrets wrapped under {"passphrase"} |
POST |
/api/v1/config/import |
Import a JSON config ("passphrase" unlocks a portable export) |
POST |
/api/v1/config/import-preview |
Dry-run a candidate import |
GET |
/api/v1/config/history |
List config-history versions |
GET |
/api/v1/config/version |
Get one version |
GET |
/api/v1/config/diff |
Diff two versions |
GET |
/api/v1/config/check |
Validate the live config |
POST |
/api/v1/config/save |
Save a manual version |
POST |
/api/v1/config/restore |
Restore a version |
GET |
/api/v1/config/restore-preview |
Preview a restore |
GET / PUT |
/api/v1/config/retention |
Retention policy |
POST |
/api/v1/config/preview-opnsense |
Preview an OPNsense XML import |
POST |
/api/v1/config/import-opnsense |
Apply an OPNsense XML import |
POST |
/api/v1/config/commit-confirm |
Arm commit-confirm |
GET |
/api/v1/config/commit-confirm/status |
Status of pending confirm |
POST |
/api/v1/config/commit-confirm/confirm |
Accept the pending change |
GET / PUT |
/api/v1/backup/s3/config |
S3 backup destination config |
POST |
/api/v1/backup/s3/test |
Test S3 credentials + bucket |
GET |
/api/v1/backup/s3/list |
List S3-stored snapshots |
POST |
/api/v1/backup/s3/import |
Restore a snapshot from S3 |
See also
- Comparison with pfSense / OPNsense → (OPNsense migration row)
- Auth & RBAC → (
backup:read/backup:writeperms) - Features overview →
- Source:
aifw-api/src/backup.rs - Source:
aifw-api/src/opnsense/ - Source:
aifw-api/src/backup_s3.rs