Central dashboard for monitoring and managing a fleet of OPNsense, pfSense, and Securepoint UTM firewalls from one place — OPNsense/pfSense including sites behind NAT (reached through an outbound push agent), Securepoint polled directly over its API.
- What: one dashboard for a fleet of OPNsense, pfSense, and Securepoint UTM firewalls — live status, IPsec/VPN, gateways, firmware compliance, service checks, logs (snapshots, critical-event rollup, AI analysis), audit log, notifications, group-based permissions.
- How boxes connect:
direct(dashboard → box API) orpush(box → outboundwss://, a stdlib-only FreeBSD agent). Push works behind NAT with no inbound access and no stored API key; the agent also tunnels the box's REST API (relay) and its web GUI (GUI proxy). Securepoint is direct-poll/agentless (itsspcgi.cgiJSON API, with optional SSH enrichment for richer IPsec data). - Agent lifecycle: dashboard-triggered, signature-verified (Ed25519) self-update with two-layer rollback, one-time enrollment, uninstall. Config (e.g. IPsec ping monitors) is pushed on every (re)connect — the agent persists nothing, the DB is the source of truth.
- Stack: Elixir / Phoenix LiveView (server-rendered UI) on MariaDB, pure-
stdlib agent. Ships as the
orbitrelease image behind a thin nginx; TLS is operator-side. (The former FastAPI + React stack has been retired — prod runs orbit only, no parallel operation.) - Run it:
cp .env.example .env, set the secrets,docker compose up -d --build. See Quickstart.
- Hub (default landing page) — central operations overview with connected agents, push rates, error counters, and CRIT alerts broken down by section (checks, IPsec, connectivity, firmware, …). Quick access to the full fleet.
- Instances — register firewalls, see live status (CPU, memory, disk, uptime, interfaces and throughput) and recent history; tag and annotate each box (customer/site labels + free-form notes).
- VPN / IPsec overview — tunnel state across the fleet, with human-readable
connection names pulled from each box's
config.xml; restart a tunnel from the UI. - Gateways — per-gateway up/down and latency.
- Top talkers — per-box traffic insight from the
pfstate table (top sources/destinations/flows by bytes, per interface) — no NetFlow, no extra agent. - Firmware compliance — which boxes are up to date, which have updates pending; check (and stage) updates in bulk.
- Firewall rules editor (OPNsense) — browse rules per interface tab, and add/edit/clone/delete/reorder/enable them with an apply step. Source, Destination and Interface are multi-value (networks, aliases, CIDRs); ports suggest named services and port aliases. Every write is group-scoped, write-role gated and audited.
- Certificates — fleet-wide certificate inventory with an expiry timeline and filters, so nothing silently lapses.
- Service checks — each box rolled up to OK / WARN / CRIT per service, exported
for Checkmk/OMD (one piggyback host per firewall, no agent on the box) and as a
Prometheus scrape endpoint (
/api/export/prometheus) for Grafana. - Bulk actions + CSV export — run
firmware_check/ipsec_restartacross many instances in parallel and export the results. - Logs — the push agent collects the box's important logs hourly (system, filter, IPsec, OpenVPN, resolver, gateways, … capped at 250 KB each); the instance page shows the raw snapshots, and an optional AI log analysis sends an anonymized version (public IPs, hostnames, secrets scrubbed — with preview) to a configured LLM provider. A global Logs page rolls the whole fleet up to critical events: lines are rated by syslog severity (plus curated patterns for PRI-less logs), noise-filtered, and aggregated into one row per message pattern with a count.
- Remote Packet Capture — live pcap streaming and one-shot snapshots via the agent
(no SSH required). Supports arbitrary BPF filters with convenient presets (exclude
agent traffic automatically,
not vlan, IPsec on WAN,ether host, etc.). Live viewer has packet list + hex dump (Ethernet/IP/TCP/UDP). Snapshots support up to 600 s / 20 MiB. tcpdump is terminated cleanly on viewer close. The Hub page (default landing) now surfaces CRIT alerts grouped by section/check key. - Config backups — the push agent ships each box's
config.xmlwhenever it changes; the dashboard keeps the newest versions encrypted at rest (Fernet), with a version list and a diff viewer to see what changed between two points. - Groups & permissions — every instance belongs to exactly one group; users only see (and act on) instances of their groups, across every view, bulk action, export and the GUI proxy. SuperAdmins manage groups, users and memberships — rights management only, no instance access implied.
- Audit log — who did what, when.
- Notifications — Mattermost (webhook), Telegram and email on state changes (all optional); each group can override channel targets for its instances, with fallback to the global config.
- Read-only API keys — service-account auth for Checkmk and other integrations; keys can be bound to instance groups (e.g. one Checkmk key per customer).
Transport and device type are decoupled. Two paths are in use today:
| Transport | Who initiates | Use |
|---|---|---|
direct |
Dashboard → firewall API | Firewall directly reachable from the dashboard (OPNsense/pfSense REST API, or Securepoint spcgi.cgi) |
push |
Firewall → outbound wss://…/api/ws/agent |
Primary for OPNsense/pfSense behind NAT |
In push mode a small stdlib-only Python agent runs on the firewall (FreeBSD), opens an outbound WebSocket to the dashboard, and pushes metrics on an interval. It also exposes an optional relay — the dashboard tunnels HTTP requests to the box's own REST API through the agent connection, so the dashboard needs no inbound access and no stored API key. The same tunnel is reused for live packet capture (raw pcap streaming) and GUI proxy. The agent supports dashboard-triggered self-update, one-time enrollment (trade a code for a token), and uninstall.
Securepoint UTM boxes are direct-poll only — no on-box agent. The dashboard
maps the appliance's spcgi.cgi JSON API (session-auth) onto the same DeviceClient
contract as the others, so VPN/IPsec and service status surface in the same views.
Optionally, enabling SSH enrichment lets the dashboard pull IPsec via
swanctl --raw for richer detail (SPIs, cookies, byte counters) the spcgi.cgi API
doesn't expose. Agent-only features (relay, GUI proxy, on-box ping monitors,
self-update) don't apply to Securepoint.
- App: Elixir + Phoenix LiveView (server-rendered UI, no separate SPA),
Ecto on MariaDB, Bandit web server, structlog-style structured logging. Runs as a
mix release(orbit/). In-process schedulers handle metrics retention, the GeoIP refresh and CrowdSec sync — no external job runner. - Database: MariaDB 11. Metrics are bucketed on the fly from the raw table (no rollup table, no TimescaleDB). Orbit owns the schema via Ecto migrations (applied at boot; a baseline migration captures the current schema and creates it on an empty DB).
- Agent: pure-stdlib Python (no pip dependencies), runs on OPNsense/pfSense (FreeBSD).
- Container: the
orbitrelease image runs Bandit on:4000; a thin nginx service is the only front door — it proxies the UI,/api, the agent websocket (/api/ws) and the LiveView socket (/live) toorbit:4000. - Deployment: Docker Compose. TLS is operator-side (host reverse proxy, cloud LB) forwarding HTTP to the nginx front door.
Prerequisites: Docker, Docker Compose, and (optionally) just.
# 1. Configure secrets
cp .env.example .env
just gen-key # paste output into DASH_MASTER_KEY in .env
openssl rand -base64 48 # paste into ORBIT_SECRET_KEY_BASE in .env
# also set DB_PASSWORD, DB_ROOT_PASSWORD, and DASH_PUBLIC_HOST (your real domain)
# 2. Start the stack (MariaDB + orbit release + nginx front door)
docker compose up -d --build
# 3. Open
# http://localhost (DASH_PORT in .env to remap; put your TLS proxy in front)The two secrets, in detail.
-
DASH_MASTER_KEY— the Fernet key every stored credential is encrypted with (firewall API keys, SSH keys, notification tokens). Must be url-safe base64 of exactly 32 bytes; the app refuses to boot on anything else.Generate it with any ONE of these — all produce url-safe base64 of 32 bytes:
just gen-key # from a repo checkout openssl rand -base64 32 | tr '+/' '-_' # anywhere openssl exists python3 -c "import base64,os;print(base64.urlsafe_b64encode(os.urandom(32)).decode())"
openssl rand -base64 32on its own is not enough: plain base64 uses+and/, which a Fernet key may not contain — measured, 72% of runs produce a key the app rejects. Thetris what makes it url-safe.Never change this key on a database that already holds data. Every
*_enccolumn is sealed with it; a new key does not re-encrypt anything, it makes the existing rows unreadable. Back it up somewhere your disaster recovery can reach. -
ORBIT_SECRET_KEY_BASE— signs cookies and tokens. Needs at least 64 bytes;openssl rand -base64 48gives exactly that and has no character restriction, so it needs notr. The app refuses to boot if it is shorter.
Schema. Orbit owns the database schema (Ecto migrations, applied
automatically at boot by Orbit.Repo.Migrator — no manual step). A greenfield
database is created from the baseline migration on first boot; a cutover from
the old stack finds the tables already present and the idempotent baseline adopts
them as a no-op, just recording the schema version. Data is never touched.
Future schema changes are ordinary migrations under
orbit/priv/repo/migrations/ — scaffold one with
just orbit-migration <name>, apply with just orbit-migrate (or on the next boot).
Accounts live in that DB, so on a cutover the bootstrap admin/superadmin you seeded under the old stack keep working — no re-seed.
To pull a published image instead of building locally, edit compose.yml — swap the
build: block under orbit for image: ghcr.io/styliteag/dashboard:latest.
Everything long-lived in this dashboard is a websocket: the LiveView UI, the
agent hub (/api/ws/agent), the browser terminal and packet capture. All of
them are legitimately idle for stretches — an operator reads a terminal, a
quiet interface produces no packets. A proxy that closes idle connections cuts
them, and the symptom points at the wrong layer: the UI drops what you typed
mid-form, agents flap offline, a terminal dies while you read it. The dashboard
logs only a fresh connect, because from its side nothing failed.
Whatever terminates TLS in front of orbit must therefore:
| Requirement | Why |
|---|---|
Allow the websocket upgrade on /live and /api/ws/ |
Without it the UI silently falls back to long-polling and agents cannot connect at all |
| Idle timeout well above 20s (60s+; 180s is a good default) | The client heartbeat is 20s. A timeout near it turns every heartbeat into a race |
Pass the original Host through |
It is the routing key for the optional GUI proxy (gui-<slug> origins) |
Set X-Forwarded-Proto: https |
Orbit speaks plain HTTP behind you and reads it to mark cookies Secure |
The idle timeout is the one that bites, because the defaults are hostile:
- HAProxy —
timeout tunnelhas no default: unset, a tunnelled connection is governed bytimeout client/timeout server, which are commonly 30s. Inmode tcpthat silently cuts every websocket.defaults timeout tunnel 180s # supersedes client/server once tunnelled timeout client-fin 30s # else half-closed sockets pile up in FIN_WAIT - Traefik —
entryPoints.<name>.transport.respondingTimeouts.idleTimeoutdefaults to180s, which is fine. Do not lower it. (v3 also defaultsreadTimeoutto60s; that one applies to reading a request, not to an established tunnel.) - nginx —
proxy_read_timeoutdefaults to60s; raise it to180son the websocket locations, and setproxy_http_version 1.1plus theUpgrade/Connectionheaders or the upgrade never happens.
Note that a chain of proxies is only as permissive as its strictest link,
and a load balancer in mode tcp in front of Traefik is easy to forget —
send-proxy / PROXY protocol in your config is the tell that one is there.
Diagnosing it takes two minutes, and does not need a login:
python3 scripts/ws_idle_probe.py dash.example.com # find the cut
python3 scripts/ws_idle_probe.py dash.example.com --heartbeat 20 # confirm the fixIt reports how the connection ended, which identifies the layer: a websocket
close frame with code 1002 after ~60s is orbit's own idle timeout and means
the path is clean, while a bare TCP eof at any other time is a proxy — and
the time it happened is that proxy's idle timeout.
On the OPNsense/pfSense box (FreeBSD):
# Copy the agent/ files to the box, then:
sh install.sh
vi /usr/local/etc/orbit-agent.conf # set dashboard_url + agent_token (or enroll_code)
sysrc orbit_agent_enable=YES
service orbit_agent start
tail -f /var/log/orbit_agent.logThe agent auto-discovers the box's own GUI/API port from config.xml (it does not
assume 443/4444). Config reference: agent/orbit-agent.conf.example.
Reach a NAT'd firewall's web GUI through its agent — no inbound access or VPN. The dashboard tunnels raw TCP to the firewall over the agent's WebSocket; a reverse proxy in front gives each firewall a per-instance origin (so the GUI's absolute URLs resolve) and a valid TLS cert. Access is gated by a one-time handoff from your dashboard session → an origin-scoped cookie checked on every request by orbit itself, bound to that one firewall.
Off by default. It needs the reverse proxy set up, so enable it only then:
DASH_GUI_PROXY_ENABLED=true
DASH_GUI_BASE_TEMPLATE=https://gui-{slug}.gui.example.com # prod; {slug} = instance slug
DASH_GUI_IDLE_MINUTES=15 # close idle forwardersWith it on, instance pages show an Open GUI button (→ new tab). Leave it false
and the button is hidden — no wildcard/DNS needed.
-
Dev (no wildcard, no certs): already enabled in
compose-dev.yml. The button openshttp://<slug>.localhost:8000— every*.localhostname resolves to 127.0.0.1 in modern browsers, so nothing needs configuring. -
Prod, behind your existing reverse proxy (wildcard subdomain): terminate TLS for
*.gui.example.com(DNS-01 wildcard cert — HTTP-01 cannot issue wildcards) and forward the whole wildcard to orbit over HTTP. Two ready-made Traefik examples, both covering v2 and v3 rule syntax — use one, never both:docker/compose.traefik-gui.example.yml(Docker provider, labels; a compose overlay you add with a second-f) anddocker/traefik-gui.example.yml(file provider). SetDASH_GUI_PROXY_ENABLED=trueandDASH_GUI_BASE_TEMPLATE=https://gui-{slug}.gui.example.com. That's the whole setup: no sidecar, no extra compose profile, no per-instance config.Orbit host-matches
gui-<slug>itself (OrbitWeb.GuiProxy, an endpoint plug), exchanges the one-shot handoff token for an origin-scopedorbit_guicookie, gates every subsequent asset on it, and reverse-proxies to that firewall's internal forwarder (127.0.0.1:14400+id→ agent tunnel). Each instance gets a persistent, URL-safeslug(auto-derived from its name — "Firewall Büro Süd" →firewall-buero-sued, editable, unique), and the host→instance lookup is a DB read, so there is nogui-Ncap and nothing to regenerate when instances change.Two things your proxy must do — Traefik does both by default, so this only matters if a middleware of yours changes them: pass the original
Hostthrough (passHostHeader: true— it is the routing key; rewrite it and you get the dashboard login instead of the firewall), and setX-Forwarded-Proto: https(orbit speaks plain HTTP behind you and reads that header to mark the GUI cookieSecure).Traefik v2 and v3 spell the wildcard rule differently, and the v2 form silently matches nothing on v3 — a 404 from Traefik, not a startup error. Both example files carry both spellings side by side.
Security: each origin fronts a firewall admin GUI — the
orbit_guicookie gate is what keeps it closed. The cookie is bound to one instance id, so a session for one firewall cannot satisfy another's gate. The forwarders bind loopback inside the orbit container and are never published.
orbit/ Elixir/Phoenix LiveView app + orbit/Dockerfile (prod release image)
compose.yml production stack (MariaDB + orbit + nginx front door)
compose-dev.yml dev stack (db + orbit dev container, src bind-mounted)
docker/ nginx.orbit.conf (front-door vhost → orbit:4000), proxy examples
agent/ stdlib push agent for OPNsense/pfSense + install.sh + rc.d
checkmk/ Checkmk special-agent plugin (pulls /api/export/checkmk)
scripts/ sign_agent.py — Ed25519 signing for agent self-update
tools/ uv project hosting the python tooling (signing, notices,
agent + checkmk test runners); imports no app code
docs/ public operator docs (Securepoint SSH, glossary, …)
.github/workflows/ release.yml — multi-arch publish on tag push
VERSION source of truth, baked into image at build
release.sh version bump + tag + push helper
Everything runs in containers — there is no local Elixir toolchain to install.
cp .env.example .env # set DASH_MASTER_KEY at minimum (just gen-key)
just orbit-setup # one-time: hex/rebar + deps into the cached volumes
just dev-up # docker compose -f compose-dev.yml up -d --build
just dev-logs
# Browse: http://localhost:8000
# Health: http://localhost:8000/api/health-exSaving a file under orbit/lib or orbit/assets triggers a Phoenix hot reload;
Ecto migrations apply on container start.
The python tooling (agent signing, notices, the agent and checkmk test suites)
lives in tools/ and needs just tools-install once.
just orbit-test # mix test — the dashboard suite
just orbit-lint # mix format --check-formatted + compile --warnings-as-errors
just agent-test # pytest over agent/tests (runs in the tools venv)
just checkmk-test # pytest over checkmk/testsjust release patch # or: minor / majorrelease.sh bumps VERSION, inserts a dated section in CHANGELOG.md, commits, tags ${VERSION}, and pushes. The .github/workflows/release.yml workflow then builds a multi-arch image (linux/amd64,linux/arm64) and publishes it to:
docker.io/styliteag/dashboard:${VERSION}and:latestghcr.io/styliteag/dashboard:${VERSION}and:latest
Required CI secrets: DOCKERHUB_USERNAME, DOCKERHUB_TOKEN (GHCR uses the default GITHUB_TOKEN).
-
Firewall API credentials are stored encrypted at rest with Fernet. The master key (
DASH_MASTER_KEY) lives only in.env. -
Prefer the push agent / relay for boxes behind NAT — the dashboard then needs no inbound reachability and stores no per-box API key (the relay forwards to the box's loopback API, and on OPNsense the agent auto-provisions a dedicated
orbituser). -
For direct transport, expose each firewall's API only over HTTPS with a source-IP allowlist for the dashboard host, pin the per-instance CA bundle, and use a dedicated service user with minimal ACLs (Diagnostics read, IPsec start/stop, Firmware update) — never root.
-
Checkmk integration authenticates with a read-only API key (
POST /api/apikeys, stored hashed, rejected on any non-GET request) — the admin password stays out of WATO. -
Agent self-update is signature-verified (Ed25519). The agent bakes a public key (
_UPDATE_PUBKEYinagent/orbit_agent.py); every pushed update must carry a valid signature over the code, so a compromised dashboard can't push forged agent code — the dashboard only relays the signature, it never holds the key. Setup:- Generate a keypair once, offline:
just sign-agent --gen→ keepPRIV_B64offline, bakePUB_HEXinto_UPDATE_PUBKEY. - Put the private key where the release machine can read it:
DASH_AGENT_SIGNING_KEYin the environment or the gitignored repo-root.env.
release.sh(just release) then signs the agent automatically before tagging — it refreshesagent/orbit_agent.py.sigfrom the current agent bytes, verifies it against the baked_UPDATE_PUBKEY, and includes it in the release commit (the signature is committed because it isn't secret; only the private key is). If_UPDATE_PUBKEYis set but no signing key is available, the release aborts — so only the offline key holder can cut a release, and a build that would brick every agent's self-update can't ship. To sign by hand outside a release:DASH_AGENT_SIGNING_KEY=<PRIV_B64> just sign-agent.Dev escape hatch. While iterating on the agent you can push an unsigned/stale build without re-signing by telling the agent (not the dashboard) to skip the check — it's off by default and logs a loud warning when active. Two channels, since the agent runs on the box, not in compose:
- Locally-run agent:
AGENT_INSECURE_SKIP_SIG=1 python agent/orbit_agent.py. - Installed agent (rc.d): add
"insecure_skip_sig": trueto its/usr/local/etc/orbit-agent.confand restart it (service orbit_agent restart) — the env var doesn't reach an rc.d-launched process, so use the config flag there.
Never set either in production — it disables the forgery protection. It doesn't flow from
compose-dev; you set it on the agent itself. - Generate a keypair once, offline:
docs/securepoint-ssh.md— SSH enrichment for Securepoint IPsec detail.CHECKMK.md— full Checkmk integration guide (what's exposed, API key, datasource program, piggyback hosts, troubleshooting).checkmk/README.md— Checkmk special-agent install and auth.
STYLiTE Orbit Dashboard is source-available under the Business Source
License 1.1 (BSL 1.1) — see LICENSE and LICENSING.md.
- ✅ Read, build, modify, and run it for your own organization.
- ❌ Offering it to third parties as a hosted / managed service, or reselling it,
needs a commercial license — contact
office@stylite.de. - Each released version becomes GPL-3.0-or-later four years after its release.
BSL is not an OSI-approved "Open Source" license; the correct term is source-available.