Warning
OPNsense 26.7.7 with lighttpd + kernel TLS (kTLS) can send truncated backup XML (more body bytes than its declared Content-Length), so a backup download fails or yields a partial config. Disable kTLS on the appliance (kern.ipc.tls.enable=0 in /boot/loader.conf, then reboot) or set OPN_TLS_MAX_VERSION=1.2. Root cause and permanent fix (Caddy): OPNsense backup framing RCA.
One binary. Your entire firewall fleet. Backed up, watched, audited, and under version control.
A single self-hosted Go daemon that keeps every OPNsense firewall (and optionally every Unifi controller) in a git-tracked, SHA-256 deduplicated archive β then lets a local LLM write the security review of every change it makes.
0 agents on the firewalls Β· 0 database servers Β· 0 runtime dependencies Β· 1 static binary.
π Quick Start Β· β¨ Features Β· πΈ Screenshots Β· π§ How It Works Β· π§ Configuration Β· π Security Β· β FAQ
Tip
30-second demo β point it at one firewall, get a WebUI on :6464, a deduplicated archive,
and a git repo with AI-reviewed commit messages. No agents on the firewalls. No database server.
No runtime dependencies.
OPN_TARGETS='opn01.lan:8443' OPN_APIKEY='...' OPN_APISECRET='...' \
go run paepcke.de/opnborg/cmd/opnborg@main
# βΈ WebUI live at http://localhost:6464| Feature | What it actually does | |
|---|---|---|
| π₯οΈ | Hive dashboard | Live version, reachability and backup state per firewall, rendered as animated SVG by an embedded HTTP server β no JS framework, no build step. |
| πΎ | Smart backups | Every pass is SHA-256 hashed; unchanged configs are skipped, changed ones land in a timestamped .archive/ with CONFIG-CURRENT / CONFIG-LAST rotation. |
| π | Fleet sync | Pick a master host β its plugin set and remote-syslog client config are replicated to every other target, so the hive never drifts. |
| π€ | AI security audit | A local LLM (Ollama, or any OpenAI-compatible endpoint) reads an enriched diff and writes the commit message: headline, security summary, and a tag: low|medium|high|critical line. |
| π§ | BorgAUDIT | Commit-history review page: full diffs, a threat-level map, the admin who performed each change, and a per-commit approval ledger. |
| ποΈ | Unifi aware | .unf backups, autoBackup folder watch, and inventory export (CSV/JSON) from the same binary, on the same dashboard. |
| π | Syslog collector | Built-in RFC5424 server with rotated retention β point your firewalls at it, read it from the WebUI host. |
| π | Two-mode WebUI | Monitoring is always read-only and open; sensitive actions unlock per browser session behind Argon2id credentials. |
| βοΈ | Git native | go-git manages the storage repo: commit per backup pass, aggressive repack, SSH upstream push with host-key pinning. No git binary required. |
| π¦ | One static binary | CGO_ENABLED=0, embedded assets, zero runtime deps. Linux, BSD, macOS, Windows on amd64/arm64/armv7. |
Real captures from a live
opnborgdaemon, not mockups: a four-member edge hive is backed up, reviewed by the model, committed to git, and displayed in the dark WebUI theme β authenticated as an admin so the full diff and approval controls are visible.
π₯οΈ The hive β fleet status at a glance
Every member tile carries its live firmware version, asset tag, last-seen time and
current.xml / .archive links β here with one member degraded so the status colors are visible.
The BorgDASHBOARD below summarises the backup store, the local git repo and upstream sync health.
π§ BorgAUDIT β the AI-reviewed change history
Threat-level summary, severity-tagged commits, and a full unified diff on demand.
Two changes are approved in the ledger; a critical, needs-review DMZ rule change is still
waiting for the operator, and the severity map at the top filters the list on click.
β‘ Live backup console β force a pass and watch it stream
/force arms a pass; this page streams the daemon log line by line, shows live
stats (16 lines Β· 4 servers Β· 7 changes Β· 0 errors in this capture), and redirects back to the
hive when the pass completes.
π§ Config dashboard β every parsed env knob, live probes
Startup configuration, masked secrets, Argon2id auth state, the git store, and live model probes (server reachable, REST API ready, model found) β all on one page.
π¨οΈ Real console output β version, commit and colored status lines
Exactly what a pass looks like on a real tty: [OK]/[SUCCESS]/[FINISH] green,
[WARN] yellow, [ERROR]/[FAIL] red, [START*] cyan. Color is emitted only for
interactive stdout β set NO_COLOR or OPN_NOCOLOR to pipe plain text.
./opnborg -v prints the same SemVer + build commit shown at startup.
flowchart LR
subgraph fleet["π° Your fleet"]
OPN["OPNsense<br/>firewalls"]
UNI["Unifi<br/>controllers"]
end
subgraph borg["βοΈ opnborg β one binary"]
PULL["fetch<br/>HTTPS + keypin"]
STORE["SHA-256 store<br/>archive + rotation"]
GIT["go-git repo<br/>commit + repack"]
AI{"local LLM<br/>review"}
UI["WebUI :6464<br/>hive Β· audit Β· config"]
SYS["RFC5424<br/>syslog sink"]
end
OPN -->|"config XML"| PULL
UNI -->|".unf"| PULL
PULL --> STORE
STORE --> GIT
GIT --> AI
AI -->|"commit msg + tag"| GIT
GIT --> UI
OPN -->|"logs"| SYS
STORE --> UI
GIT -->|"SSH push"| UP[("upstream<br/>git remote")]
- Pull β the daemon polls each target over HTTPS (OS trust store intentionally bypassed,
OPN_TLSKEYPINused for MitM-proofing) and fetches the live config. - Diff & dedup β a SHA-256 of the payload is compared with the previous
CONFIG-CURRENT. No change, no write. Change, and the XML is archived and the symlinks rotate. - Commit β the storage directory is a git repo. Each pass stages the changed files, skips the approval ledger via a hardwired path guard, and commits.
- Review β if a model is configured, an enriched diff (file metadata, widened hunks,
detected XML sections) is sent to Ollama or an OpenAI-compatible endpoint. The model's answer
is the commit message, including a
tag:severity line. - Surface β the WebUI renders the hive, the audit history, and the approval ledger.
OPN_PATH='/tmp/opn' \
OPN_TARGETS='opn01.lan:8443,opn02.lan:8443' \
OPN_APIKEY='...' \
OPN_APISECRET='...' \
go run paepcke.de/opnborg/cmd/opnborg@mainPrebuilt binaries: Releases Β·
go install paepcke.de/opnborg/cmd/opnborg@main
Add OPN_NODAEMON=1 for a single pass instead of the daemon.
./opnborg -v prints the release SemVer plus the embedded build commit, and every startup logs
the same [VERSION:β¦][COMMIT:β¦] pair β so you always know exactly which artifact is deployed.
π³ Docker / Docker Compose β distroless, linux/amd64 + linux/arm64
Published from every v* tag to ghcr.io/paepckehh/opnborg β tags: latest, edge, v0, v0.1, exact release.
services:
opnborg:
image: ghcr.io/paepckehh/opnborg:latest
restart: unless-stopped
ports: ["6464:6464"]
volumes: [opnborg-data:/var/opnborg]
environment:
OPN_TARGETS: "opn01.lan:443,opn02.lan:443"
OPN_APIKEY: "+RIb6YWNdcDWMMM7W5ZY..."
OPN_APISECRET: "8VbjM3HKKqQW2ozO..."
volumes:
opnborg-data:[!WARNING] Expose
6464to trusted networks only β and armOPN_AUTH_HASH/OPN_AUTH_SALTto unlock admin mode.
βοΈ NixOS / Nix
Available as a Nix package. Ready-made modules in this repo:
opnborg-docker.nix Β· opnborg-docker-complex.nix Β· opnborg-prometheus-grafana.nix (Prometheus + Grafana; Wazuh/Influx/Graylog WIP)
Everything is environment variables β the binary accepts only -v / -h. A local .env
file is auto-loaded. Ready-to-use examples live in the repo: example.sh,
example-env-config-simple.sh, example-env-config-complex.sh, example-env-config-unifi.sh,
example-env-config-dev.sh.
Important
Booleans are presence-based. OPN_DEBUG=0 and OPN_DEBUG=false are both true β
unset the variable to disable a feature. OPN_GIT_ENABLE is opt-in.
π― Basics
| Variable | Default | Description |
|---|---|---|
OPN_APIKEY / OPN_APISECRET |
β | OPNsense backup user API credentials (required) |
OPN_TARGETS |
β | Comma-separated targets, optional #<asset-tag> (opn01.lan:8443#RACK-PROD01) |
OPN_TARGETS_<GROUP> |
β | Named groups: OPN_TARGETS_INTRANET='opn01.lan:8443,...' |
OPN_TARGETS_DESC_<GROUP> |
β | WebUI group description |
OPN_TARGETS_IMGURL_<GROUP> |
β | WebUI group image URL (description becomes the tooltip) |
OPN_PATH |
. |
Backup store path |
OPN_TLSKEYPIN |
β | SPKI SHA-256 keypin β MitM-proofing for the OPNsense API |
OPN_TLS_MIN_VERSION |
1.3 |
Minimum TLS version for OPNsense API fetches (1.2 / 1.3) |
OPN_TLS_MAX_VERSION |
1.3 |
Maximum TLS version β set to 1.2 when an appliance's FreeBSD kTLS record stream corrupts TLS 1.3 backup downloads |
OPN_SLEEP |
3600 |
Poll interval in seconds (min 10) |
OPN_MASTER |
β | Master host β replicate its config across the hive |
OPN_SYNC_PKG |
β | Sync installed plugins from master to all targets |
OPN_EMAIL |
git@opnborg |
Git commit author email |
OPN_NODAEMON |
β | One-shot mode: single pass, then exit |
OPN_DEBUG |
β | Verbose debug logging |
OPN_NOCOLOR |
β | Disable ANSI colors on stdout (also honors NO_COLOR) |
βοΈ Git store β native go-git, no external binary
OPN_PATH is managed as a git repo: auto-init, .gitignore upkeep, commit per backup pass,
aggressive repack (delta window = 250) after each commit.
| Variable | Description |
|---|---|
OPN_GIT_ENABLE |
Auto commit each backup pass β opt-in |
OPN_GIT_UPSTREAM |
SSH remote to push to; unset = local-only |
OPN_GIT_SSH_KEY |
PEM private key for upstream auth (required with upstream) |
OPN_GIT_SSH_HOSTKEY |
SHA256:<base64> host-key pin; unset skips verification |
π€ AI security audit β Ollama first, OpenAI-compatible fallback
The LLM reads an enriched diff and writes the commit message: a headline, a short security
summary, and a trailing tag: low|medium|high|critical[, needs-review] line that is surfaced on
the BorgAUDIT page. Requires OPN_GIT_ENABLE.
export OLLAMA_DESC_URL='http://localhost:11434'
export OLLAMA_DESC_MODEL='llama3'
# optional fallback (used when Ollama is unset or fails):
export OPENAPI_DESC_URL='http://192.168.6.222:11434/v1' # model defaults to gpt-4o-mini
# export OPENAPI_DESC_MODEL='...'
# export OPENAPI_DESC_TOKEN='...'.unf-only commits and any model failure fall back to opnborg auto update β the backup
pipeline never blocks on a model.
π WebUI auth β monitoring by default, admin opt-in
Monitoring mode is always open (green badge): dashboards, audit page, progress. Sensitive actions β config downloads, audit diffs, forced backup, approvals β require an admin session (red/yellow badge): Argon2id (time=8, 64 MiB) credentials, HttpOnly SameSite=Strict cookies, 12 h sliding TTL, exponential global lockout on failed logins.
Generate OPN_AUTH_HASH + OPN_AUTH_SALT from a password on the /auth-hash page β the
password is never stored or logged. Without valid credentials, login is impossible and sensitive
paths return 403 Forbidden.
π₯οΈ WebUI routes & HTTP options
| Route | Access | What |
|---|---|---|
/ |
open | Hive dashboard |
/config |
open | Auth, AI, git & store tiles |
/audit |
open | BorgAUDIT β commit history, threat dashboard |
/progress |
open | Live forced-backup progress |
/force |
open | Trigger a backup pass now |
/files/ |
admin | Config downloads |
/approve, /approve-all |
admin | Approval ledger actions (POST) |
/auth/*, /auth-hash |
open | Login/logout/state, credential generator |
| Variable | Default | Description |
|---|---|---|
OPN_HTTPD_SERVER |
127.0.0.1:6464 |
Listen address |
OPN_HTTPD_DISABLE |
β | Turn the WebUI off |
OPN_HTTPD_CACERT / OPN_HTTPD_CAKEY |
β | Enable HTTPS |
OPN_HTTPD_CACLIENT |
β | Enforce mTLS |
OPN_HTTPD_COLOR_FG / OPN_HTTPD_COLOR_BG |
white / #333333 |
Theme colors |
OPN_RSYSLOG_ENABLE + OPN_RSYSLOG_SERVER |
β | Built-in RFC5424 collector (e.g. 192.168.0.1:5140) |
ποΈ Unifi controller β backups, watch, inventory
export OPN_UNIFI_WEBUI='https://192.168.1.10:8443#RACK-PROD03'
export OPN_UNIFI_BACKUP_USER='backup'
export OPN_UNIFI_BACKUP_SECRET='start'
export OPN_UNIFI_VERSION='8.5.6' # required
# optional:
export OPN_UNIFI_WATCH_PATH='/var/lib/unifi/data/backup/autobackup' # watch-only deploy is valid
export OPN_UNIFI_EXPORT='1'; export OPN_UNIFI_FORMAT='csv' # or 'json'
export OPN_UNIFI_MONGODB_URI='mongodb://127.0.0.1:27117'
export OPN_UNIFI_BACKUP_DESC='Network controller' # (+_IMGURL) like target groupsπ Observability links β Prometheus, Grafana, Wazuh
OPN_PROMETHEUS_WEBUI Β· OPN_WAZUH_WEBUI Β· OPN_GRAFANA_WEBUI Β· OPN_GRAFANA_DASHBOARD_FREEBSD Β· OPN_GRAFANA_DASHBOARD_HAPROXY Β· OPN_GRAFANA_DASHBOARD_UNIFI β deep links into your existing monitoring stack, rendered on the dashboard.
ποΈ Storage layout
<OPN_PATH>/<server>/
current.xml # latest backup (admin-only download)
.archive/<Y>/<M>/<timestamp>-<server>.xml
CONFIG-CURRENT/LAST # symlink rotation
sha256.db # dedup log
.db/
approval.db # security-approval ledger (SQLite, always gitignored)
approval.db-wal
approval.db-shm
.git/ # when OPN_GIT_ENABLE is set
Logs/current.log # rotated by lumberjack (256 MB, 256 backups, 180 d, gzipped)
opnborg is built for people who treat firewall configs as production state:
- Least-privilege API user β a dedicated OPNsense user with Diagnostics: Configuration History (plus System: Firmware only if you enable plugin sync).
- Keypin instead of CA trust β the OPNsense API is used with an explicit SPKI SHA-256 pin; the OS trust store is deliberately not consulted.
- Two-mode access β unauthenticated sessions are monitoring-only, always.
current.xml,current.unfand the archive are never served without a live admin session, and return 403 when no credentials are configured at all. A regression test guards this invariant. - Constant-time auth β Argon2id-derived keys compared with
subtle.ConstantTimeCompare, per-session random tokens, global exponential lockout on failed logins. - Ledger isolation β the SQLite approval ledger lives in
.db/and stays out of your git history through four independent layers: the store.gitignore(.db/plus the three file names, reconciled on every startup and every commit), a hardwired staging skip over everyapproval.db*path, an index filter (gitLedgerScrubIndex) that unstages any ledger path before the commit tree is built β even one staged by a manualgit add .β and a post-commit tree verification that fails the tick loudly if ledger content leaked in anyway. A startup audit flags ledger paths found in existing history so the blob can be purged upstream. The opnborg source checkout carries the same.gitignoreguard for itself.
How do I create an OPNsense API key?
System β Access β Users β add a backup user (scrambled password) β grant
Diagnostics: Configuration History (+ System: Firmware for plugin sync) β API Keys β Add.
How do I generate OPN_TLSKEYPIN?
go run paepcke.de/tlsinfo/cmd/tlsinfo@latest <your-opn-server-name>
# X509 Cert KeyPin [base64] : [FezOCC3qZFzBmD5xRKtDoLgK445Kr0DeJBj2TWVvR9M=]
export OPN_TLSKEYPIN='FezOCC3qZFzBmD5xRKtDoLgK445Kr0DeJBj2TWVvR9M='How do I enable the AI audit?
Install Ollama, ollama pull llama3, set OLLAMA_DESC_URL +
OLLAMA_DESC_MODEL, enable OPN_GIT_ENABLE, restart. The config dashboard then shows a live
model probe (server reachable, API ready, model ready).
How do I arm admin mode?
/config β Create Authentication Env Vars β copy both OPN_AUTH_* lines into your env β
restart. The generator is always available, even when credentials are already armed.
Operational notes
- Targets need an explicit port unless
:443; plain HTTP is unsupported. - OS trust-store verification is intentionally off β use
OPN_TLSKEYPIN. - Backup XML downloads are Connection: close and retried on a fresh TLS stream (3 attempts): a
truncated / surplus-framed body (the
Unsolicited response received on idle HTTP channel+[FAIL:XML-VALIDATION]pair some OPNsense/FreeBSD kTLS appliances emit) is a per-connection record-stream fault β a new connection fixes it. LowerOPN_TLS_MAX_VERSIONto1.2for an appliance that trips it on every pass. - Auth sessions live in process memory only: a restart revokes every admin session.
- The
.db/ledger directory (database plus WAL sidecars, and anything else inside) is always gitignored and hardwire-excluded from commit staging.
PRs welcome β see AGENTS.md for the build, test and release workflow.
BSD 3-Clause, see LICENSE.
π Sponsors: pvz.digital Β· debitor.de π§βπ¨ UX Borg design: @Codebase-Torben & @Jones71190
π Citation
@misc{opnborg,
author = {Michael Paepcke},
title = {Selfhost-able OPNSense Appliance Configuration Management & Backup Portal},
year = {2024},
publisher = {GitHub},
journal = {GitHub repository},
howpublished = {\url{https://paepcke.de/opnborg}}
}Made with βοΈ by Michael Paepcke β the hive thanks you for your compliance.