Files
OPNexus/docker-compose.yml
T
Julian Boldt b1a96c7563
Release / verify (push) Waiting to run
Release / test (push) Blocked by required conditions
CI / backend (push) Blocked by required conditions
CI / frontend (push) Blocked by required conditions
CI / checks (push) Blocked by required conditions
Release v1.34.0
- **Zum Update:** Das erste Update auf diese Version übernimmt auch die `docker-compose.yml` (siehe unten; braucht wie die
  CLI-Aktualisierung `cosign` und `cosign.pub`), migriert die Datenbank (Revision 0032; ein Rollback spielt die Sicherung davor
  zurück) und stellt das Backend auf einen unprivilegierten Benutzer um. Nichts davon braucht einen Handgriff.
- **Nur ein Backend führt die Hintergrunddienste aus (HA, Stufe H0):** Laufen zwei Backends an derselben Datenbank, verschickten
  beide jeden Alarm und brachen Zyklen mit `UniqueViolation` ab. Jetzt führt nur der Prozess mit der Leader-Sperre in der
  Datenbank die neun Hintergrundschleifen aus; die anderen warten in Bereitschaft und übernehmen innerhalb einer halben Minute,
  wenn der Leader ausfällt. Verliert der Leader seine Datenbankverbindung, hält er alle Schleifen sofort an (geprüft alle 5 Sekunden
  und vor jedem Durchlauf). An einer Replica startet kein Leader. Rolle in `GET /api/health` und als Metrik `opnexus_leader`.
  Zwei gleichzeitige Erststarts legen das Admin-Konto nur einmal an.
- **Umzug und Kaltreserve (HA, Stufe H1):** `opnexus backup --full --with-env --with-ca` nimmt zusätzlich die `.env` und die
  interne CA des Proxys mit, `opnexus restore DATEI --with-env --with-ca` spielt sie zurück (ohne Datenbank-Zugang, Version und
  Adressen des Zielservers; die CA bleibt dieselbe, Browser vertrauen weiter). Neu: `opnexus migrate-host BENUTZER@ZIEL` zieht
  eine Installation in einem Schritt auf einen anderen Server um (Vorprüfung, Quelle anhalten, Sicherung mit Zufalls-Passphrase,
  Restore, Kontrolle). Im Lab 90 Sekunden mit drei Firewalls. `opnexus status` zeigt das Alter der letzten Vollsicherung.
  Handbuch: „Umzug und Notfall“.
- **Betriebseinstellungen im Webinterface:** Was bisher nur in der `.env` oder gar nur im Code stand, lässt sich unter
  Einstellungen › Betriebseinstellungen ändern: Sitzungsdauer und Anmeldesperren, Bestätigungsfrist beim Ausrollen, Zeitlimit für
  Firewall-Anfragen, Messintervalle und Aufbewahrung, Intervall der Versionsprüfung, Heartbeat-Adresse, CA des Mailservers (PEM
  einfügen statt Datei einbinden), Zeitlimit Mailversand. Mit Grenzen, Standardwert, Zurücksetzen und Audit-Eintrag. Ein Wert in der
  `.env` hat weiter Vorrang und wird als „vom Server“ angezeigt. Adressen, TLS und Datenbank-Zugang bleiben bewusst in der `.env`.
- **Heartbeat für einen externen Überwacher:** Die Heartbeat-Adresse (nur `https://`) wird nach jedem erfolgreichen Prüfzyklus
  aufgerufen; bleibt der Aufruf aus, schlägt der Überwacher Alarm. Die Adresse wird nie angezeigt oder protokolliert.
- **Updates bringen die Compose-Datei mit:** Bisher tauschte `opnexus update` nur die Images; Änderungen an der
  `docker-compose.yml` (neue Variablen, Härtung) kamen bei bestehenden Installationen nie an. Jetzt übernimmt das Update die Datei
  des Zielreleases aus dem signierten Updater-Image, sichert die alte als `docker-compose.yml.bak-<version>` und legt sie beim
  Rollback zurück. Eine hier angepasste Datei wird nicht überschrieben (neue Fassung als `docker-compose.yml.new`); eigene
  Anpassungen gehören in `docker-compose.override.yml`.
- **Alarm „Platte des OPNexus-Servers fast voll“** (standardmäßig an, ab 90 %, einstellbar): eine volle Platte hält die Datenbank
  an und damit jeden anderen Alarm. Auch im Dashboard und als Metrik `opnexus_server_disk_used_percent`.
- **Härtung aus einem externen Audit:**
  - **Kein root mehr in den Containern:** Backend (uid 10001) und Frontend (`node`) laufen unprivilegiert, ohne neue Rechte (`no-new-privileges`), das Frontend zusätzlich
    ohne Capabilities. Das Backend startet über `backend/scripts/start.sh` noch kurz als root, stellt die vorhandenen Datenvolumes einmalig um und gibt dann alle Rechte ab:
    ein Update braucht keinen Handgriff, ein Rückweg auf eine ältere Version bleibt möglich. Notausgang für Sonderfälle: `OPNEXUS_RUN_AS_ROOT=1` in der `.env`.
    `wg-hub` (braucht root für WireGuard) und `gui-gateway` (läuft schon als nobody) sind unverändert. Der Proxy (Caddy) und der optionale Updater (Docker-Socket) laufen weiter wie bisher.
  - **Vollsicherung konsistent:** Ändern sich Geheimnisse, während die Sicherung läuft (jemand legt gerade ein Objekt an oder löscht eines), wird sie bis zu dreimal wiederholt;
    gelingt das nicht, bricht sie mit einer klaren Meldung ab, statt ein unstimmiges Archiv zu schreiben.
  - **Zieladress-Schutz überall:** Audit-Export (Webhook und Syslog), Offsite-S3 und der Update-Feed verbinden nur noch zu Adressen, die die Prüfung bestanden haben
    (kein Loopback, Link-Local, Metadaten-Dienst; auch nicht über DNS-Rebinding oder Weiterleitungen). Adressen im eigenen Netz bleiben erlaubt. Der Update-Feed wird beim Lesen auf 1 MB begrenzt, nicht erst danach.
- **Fehlende Basis-Rechte werden sichtbar:** Fehlen dem API-Schlüssel schon die Rechte für die Grundmessungen (Speicher, Last, Platte), schlug das ganze Sampling still fehl und der
  Rechte-Alarm konnte nie auslösen. Jetzt entsteht ein Befund „Grundwerte“ (Recht „Lobby: Dashboard“), der sich nach Korrektur von selbst schließt.
- **Installation:** Die `INSTALL.txt` im Bundle und die Anleitung auf der Website legen die `.env` jetzt mit `install -m 600` an (nur für dich lesbar), statt mit `cp`.
- **Oberfläche:** Die Aktionsknöpfe der Backup-Tabelle auf der Host-Seite werden auf schmalen Bildschirmen als Symbole gezeigt, statt abgeschnitten zu werden.
2026-10-24 18:55:10 +02:00

330 lines
15 KiB
YAML

name: opnexus
# Dieses File enthält nur fertige Release-Images (kein `build:`) und ist
# damit zugleich das Compose-File des Release-Bundles.
# - Fertige Release-Images: in .env OPNEXUS_IMAGE_REPO und OPNEXUS_VERSION
# setzen (siehe .env.example), dann `opnexus update` oder
# docker compose pull && docker compose up -d --no-build
# - Entwicklung/Selbstbau aus dem Repo: docker-compose.override.yml wird
# automatisch mitgeladen und ergänzt die Build-Kontexte:
# docker compose up -d --build
# (taggt lokal als local/opnexus-<dienst>:latest)
# Shared bound-size log driver — without this, Docker's default json-file
# driver keeps every log line forever, which on a long-running self-hosted
# install can quietly fill the disk (this has already happened once here
# via accumulated build cache, see the 2026-09-12 health audit — logs are
# the same failure mode).
x-logging: &default-logging
driver: json-file
options:
max-size: "10m"
max-file: "3"
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
logging: *default-logging
# Docker's default 64MB /dev/shm is too small for Postgres's parallel
# vacuum/query workers on anything but a toy dataset — found the hard
# way running VACUUM on a multi-GB table (see docs/HANDBUCH.md's "Logs" section for how
# large that table gets). Official Postgres image docs recommend raising this.
shm_size: "256mb"
environment:
# Datenbank- und Benutzername. Wirken nur beim allerersten Start eines
# leeren Volumes; eine bestehende Installation behält ihre Namen und
# trägt sie in .env ein (siehe docs/HANDBUCH.md, "Datenbank-Namen").
POSTGRES_DB: ${POSTGRES_DB:-opnexus}
POSTGRES_USER: ${POSTGRES_USER:-opnexus}
# No insecure fallback on purpose — a guessable baked-in default
# would ship as public, well-known source. Set it in .env.
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env before starting (see .env.example)}
volumes:
- postgres_data:/var/lib/postgresql/data
# No host port published — only reachable from other containers on
# this stack's own network. Keeps us out of "which host ports are
# free" territory entirely for this service.
healthcheck:
test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""]
interval: 5s
timeout: 5s
retries: 10
# Nightly logical dump of OPNexus's OWN database (users, rules, NAT,
# VPN/cert entities, audit trail, deployment history — everything
# except the secret material, which never touches Postgres in the
# first place, see the *_secret_store.py modules). This is distinct
# from the app's own backup feature under /hosts/{id}/backups, which
# backs up the MANAGED FIREWALLS' configs, not OPNexus's own data —
# before this, a lost/corrupted postgres_data volume had no recovery
# path at all. Reuses the postgres image directly (pg_dump is already
# in it) rather than adding a new image to build/maintain.
# Excludes firewall_log_entries/host_resource_samples: high-volume,
# disposable operational history (see docs/HANDBUCH.md's "Logs" section for
# how large these get) rather than
# config a disaster recovery actually needs — including them would
# make the backup itself multi-GB and a disk-usage risk in its own
# right, working against the point of this service.
postgres-backup:
image: postgres:16-alpine
restart: unless-stopped
logging: *default-logging
depends_on:
postgres:
condition: service_healthy
environment:
PGPASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env before starting (see .env.example)}
volumes:
- postgres_backups:/backups
entrypoint: ["sh", "-c"]
command:
- |
set -e
while true; do
ts=$$(date +%Y%m%d-%H%M%S)
echo "postgres-backup: dumping to /backups/opnexus-$$ts.dump"
pg_dump -h postgres -U ${POSTGRES_USER:-opnexus} -d ${POSTGRES_DB:-opnexus} -F c \
--exclude-table=firewall_log_entries --exclude-table=host_resource_samples \
-f "/backups/opnexus-$$ts.dump"
find /backups -name 'opnexus-*.dump' -mtime +14 -delete
sleep 86400
done
backend:
image: ${OPNEXUS_IMAGE_REPO:-local}/opnexus-backend:${OPNEXUS_VERSION:-latest}
restart: unless-stopped
logging: *default-logging
depends_on:
postgres:
condition: service_healthy
environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-opnexus}:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env before starting (see .env.example)}@postgres:5432/${POSTGRES_DB:-opnexus}
OPNSENSE_SECRET_DIR: /data/opnsense-secrets
OPNSENSE_BACKUP_DIR: /data/opnsense-backups
IPSEC_PSK_SECRET_DIR: /data/ipsec-psk-secrets
OPENVPN_KEY_SECRET_DIR: /data/openvpn-key-secrets
WIREGUARD_KEY_SECRET_DIR: /data/wireguard-key-secrets
CERT_KEY_SECRET_DIR: /data/cert-key-secrets
ACME_SECRET_DIR: /data/acme-secrets
TOTP_SECRET_DIR: /data/totp-secrets
NOTIFICATION_SECRET_DIR: /data/notification-secrets
OVERLAY_DIR: /data/overlay
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-}
COOKIE_SECURE: ${COOKIE_SECURE:-false}
TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-false}
UPDATE_CHECK_URL: ${UPDATE_CHECK_URL:-}
OPNEXUS_IMAGE_REPO: ${OPNEXUS_IMAGE_REPO:-}
# 1 = Backend wie früher als root betreiben (Notausgang, siehe backend/scripts/start.sh).
OPNEXUS_RUN_AS_ROOT: ${OPNEXUS_RUN_AS_ROOT:-0}
ALLOW_INSECURE_FIREWALL_URLS: ${ALLOW_INSECURE_FIREWALL_URLS:-false}
ALLOW_INSECURE_SMTP_AUTH: ${ALLOW_INSECURE_SMTP_AUTH:-false}
SMTP_CA_FILE: ${SMTP_CA_FILE:-}
UPDATE_CHECK_INTERVAL_HOURS: ${UPDATE_CHECK_INTERVAL_HOURS:-12}
# Optionaler Heartbeat an einen externen Überwacher (nur https://), siehe Handbuch.
HEARTBEAT_URL: ${HEARTBEAT_URL:-}
# Optionaler Updater-Dienst (Profil "updater", siehe unten). Ohne ihn
# zeigt das Webinterface nur an, wie man ihn aktiviert.
UPDATER_URL: ${UPDATER_URL:-http://updater:8080}
# Optionaler GUI-Fernzugriff (Profil "gui", siehe gui-gateway unten).
# Leer = aus; das Backend lässt den Schalter dann nicht einschalten.
OPNEXUS_GUI_SITES: ${OPNEXUS_GUI_SITES:-}
GUI_GATEWAY_TOKEN: ${GUI_GATEWAY_TOKEN:-}
volumes:
- secrets:/data/opnsense-secrets
- backups:/data/opnsense-backups
- ipsec_psk_secrets:/data/ipsec-psk-secrets
- openvpn_key_secrets:/data/openvpn-key-secrets
- wireguard_key_secrets:/data/wireguard-key-secrets
- cert_key_secrets:/data/cert-key-secrets
- acme_secrets:/data/acme-secrets
- totp_secrets:/data/totp-secrets
- notification_secrets:/data/notification-secrets
# Management-Overlay: Konfiguration für den wg-hub und dessen Status.
- overlay_state:/data/overlay
# Gleiches Volume wie der postgres-backup-Service: hier landet das
# automatische Sicherheits-Backup vor einer Schema-Migration.
- postgres_backups:/backups
# Zugriffstoken des Updaters (nur lesen). Leer, solange der Updater
# nicht aktiviert ist.
- updater_state:/updater:ro
# Das Backend startet als root nur, um die Volumes umzustellen, und läuft dann
# als uid 10001 (backend/scripts/start.sh). Neue Rechte kann es nicht mehr erlangen.
security_opt: ["no-new-privileges:true"]
# Bound to loopback only by default — reachable from this host, not
# the network. Set BIND_ADDRESS in .env (e.g. to 0.0.0.0, or a
# specific interface IP) for network-wide access instead of editing
# this file — see docs/HANDBUCH.md's "Zugriff aus dem Netz und TLS" for the TLS-proxy
# alternative, which is safer than widening this directly.
ports:
- "${BIND_ADDRESS:-127.0.0.1}:${BACKEND_PORT:-8100}:8000"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health', timeout=3)"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
frontend:
image: ${OPNEXUS_IMAGE_REPO:-local}/opnexus-frontend:${OPNEXUS_VERSION:-latest}
restart: unless-stopped
logging: *default-logging
environment:
# Docker always sets HOSTNAME=<container-id> in the environment,
# and Next.js's standalone server.js binds to `process.env.HOSTNAME
# || '0.0.0.0'` — so without this override it silently listens only
# on the container's own IP, never on 127.0.0.1. Harmless for the
# published port (Docker's NAT reaches the container IP directly,
# not via loopback) but breaks any in-container check (e.g. the
# healthcheck below) that queries localhost.
HOSTNAME: "0.0.0.0"
# Läuft als unprivilegierter Benutzer (frontend/Dockerfile) und braucht keine
# Capabilities.
cap_drop: ["ALL"]
security_opt: ["no-new-privileges:true"]
depends_on:
backend:
condition: service_healthy
# Same rationale and same BIND_ADDRESS override as the backend
# service above.
ports:
- "${BIND_ADDRESS:-127.0.0.1}:${FRONTEND_PORT:-3100}:3000"
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/login', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
# TLS termination in front of everything. The only service meant to be
# reachable from the network; backend/frontend stay on 127.0.0.1.
proxy:
image: ${OPNEXUS_IMAGE_REPO:-local}/opnexus-proxy:${OPNEXUS_VERSION:-latest}
restart: unless-stopped
logging: *default-logging
depends_on:
backend:
condition: service_healthy
frontend:
condition: service_healthy
environment:
OPNEXUS_SITES: ${OPNEXUS_SITES:-localhost}
OPNEXUS_DEFAULT_SNI: ${OPNEXUS_DEFAULT_SNI:-localhost}
OPNEXUS_GUI_SITES: ${OPNEXUS_GUI_SITES:-https://localhost:9441}
ports:
- "${PROXY_BIND_ADDRESS:-0.0.0.0}:${PROXY_HTTPS_PORT:-443}:443"
- "${PROXY_BIND_ADDRESS:-0.0.0.0}:${PROXY_HTTP_PORT:-80}:80"
# Slot-Ports des GUI-Fernzugriffs (Profil "gui"). Standardmäßig nur auf
# 127.0.0.1; ohne den gui-gateway-Dienst antworten sie nur mit 502.
# Host- und Container-Port sind absichtlich gleich: die Ports stehen so
# auch in OPNEXUS_GUI_SITES, und der Proxy meldet dem Gateway den Port,
# auf dem die Verbindung ankam.
# Ohne GUI_PORTS belegt Docker zufällige freie Host-Ports: ein Update darf
# den Proxy nicht daran scheitern lassen, dass 9441-9444 schon vergeben
# sind (z. B. Portainer auf 0.0.0.0:9443). Zum Einschalten GUI_PORTS setzen.
- "${GUI_BIND_ADDRESS:-127.0.0.1}:${GUI_PORTS:-}:${GUI_PORTS:-9441-9444}"
volumes:
- caddy_data:/data
- caddy_config:/config
# ── Optional: Management-Overlay (WireGuard-Hub) ─────────────────────
# Standardmäßig NICHT gestartet. Aktivieren mit COMPOSE_PROFILES=overlay in
# .env und `docker compose up -d`. Damit erreicht OPNexus auch Firewalls hinter
# NAT/CGNAT: sie wählen sich per WireGuard hier ein (UDP-Port ${OVERLAY_PORT:-51820}
# muss vom Internet erreichbar sein). Gleiches Image wie das Backend, mit
# NET_ADMIN nur für die WireGuard-Schnittstelle. Einrichtung: Handbuch,
# "Firewalls hinter NAT".
wg-hub:
image: ${OPNEXUS_IMAGE_REPO:-local}/opnexus-backend:${OPNEXUS_VERSION:-latest}
profiles: ["overlay"]
restart: unless-stopped
logging: *default-logging
command: ["python", "-m", "app.overlay_hub"]
cap_add:
- NET_ADMIN
sysctls:
net.ipv4.ip_forward: "0"
environment:
OVERLAY_DIR: /data/overlay
volumes:
- overlay_state:/data/overlay
ports:
- "${OVERLAY_PORT:-51820}:51820/udp"
# ── Optional: GUI-Fernzugriff auf verwaltete Firewalls ──────────────
# Standardmäßig NICHT gestartet und dreifach aus: Profil "gui"
# (COMPOSE_PROFILES=gui), GUI_BIND_ADDRESS (Standard 127.0.0.1) und der
# Schalter unter Einstellungen. Gleiches Image wie das Backend, aber ohne
# Datenbank, ohne Secret-Volumes und ohne eigene Ports: der Proxy terminiert
# TLS auf den Slot-Ports und leitet hierher weiter; mit dem Backend spricht
# das Gateway nur über /internal/gui/* (GUI_GATEWAY_TOKEN). Siehe Handbuch,
# "Firewall-Oberfläche über OPNexus".
gui-gateway:
image: ${OPNEXUS_IMAGE_REPO:-local}/opnexus-backend:${OPNEXUS_VERSION:-latest}
profiles: ["gui"]
restart: unless-stopped
logging: *default-logging
command: ["python", "-m", "app.gui_gateway"]
depends_on:
backend:
condition: service_healthy
environment:
GUI_GATEWAY_TOKEN: ${GUI_GATEWAY_TOKEN:-}
OPNEXUS_GUI_SITES: ${OPNEXUS_GUI_SITES:-}
GUI_BACKEND_URL: http://backend:8000
user: "65534:65534"
read_only: true
cap_drop: ["ALL"]
security_opt: ["no-new-privileges:true"]
healthcheck:
test: ["CMD", "python", "-c", "import socket; socket.create_connection(('127.0.0.1', 8080), 3).close()"]
interval: 10s
timeout: 5s
retries: 5
start_period: 5s
# ── Optional: Updates aus dem Webinterface ──────────────────────────
# Standardmäßig NICHT gestartet. Aktivieren mit COMPOSE_PROFILES=updater in
# .env und `docker compose up -d`. Dies ist der einzige Container mit
# Zugriff auf den Docker-Socket (= root-gleiche Rechte auf diesem Host): er
# hat keine veröffentlichten Ports, ist nur mit seinem Token ansprechbar
# und kann nur "aktualisieren"/"zurückrollen" (updater/server.py). Das
# Backend selbst bekommt den Socket nie. Nur für Release-Images
# (OPNEXUS_IMAGE_REPO gesetzt).
updater:
profiles: ["updater"]
image: ${OPNEXUS_IMAGE_REPO:-local}/opnexus-updater:${OPNEXUS_VERSION:-latest}
restart: unless-stopped
logging: *default-logging
volumes:
- /var/run/docker.sock:/var/run/docker.sock
# Das Verzeichnis mit docker-compose.yml und .env (der Updater schreibt
# nur OPNEXUS_VERSION und .opnexus-update-state hinein).
- .:/project
- updater_state:/state
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=3)"]
interval: 10s
timeout: 5s
retries: 5
start_period: 5s
volumes:
updater_state:
postgres_data:
postgres_backups:
secrets:
backups:
ipsec_psk_secrets:
openvpn_key_secrets:
wireguard_key_secrets:
cert_key_secrets:
acme_secrets:
totp_secrets:
notification_secrets:
overlay_state:
caddy_data:
caddy_config: