# Reverse-Proxy-Stack: HAProxy + Caddy Ein schlanker, gehärteter Edge-Layer, der sich vor beliebig viele andere docker-compose-Projekte schalten lässt (Mailcow, Checkmk, eigene Apps, ...). **Eine einzige Konfigurationsdatei** (`config/sites.yaml`) steuert beide Dienste. Generiert wird per **reinem Bash+AWK-Skript** (`generate.sh`) direkt auf dem Host - kein Python, kein zusätzliches Docker-Image, keine Build-Schritte. `bash` und `awk` sind auf jedem Linux-System sowieso vorhanden. ## Architektur ``` Internet │ ▼ ┌─────────────────────────────────────────┐ │ HAProxy (edge-haproxy) │ Ports 80/443(/weitere) öffentlich │ - IP-Filterung auf TCP-Ebene │ │ (per SNI, VOR dem TLS-Handshake) │ │ - reiner Passthrough, keine │ │ TLS-Terminierung │ └─────────────────────────────────────────┘ │ (internes "edge"-Netzwerk) ▼ ┌─────────────────────────────────────────┐ │ Caddy (edge-caddy) │ nur intern erreichbar │ - TLS-Terminierung, automatisch │ │ Let's Encrypt je Domain │ │ - Host-basiertes Routing zu Backends │ └─────────────────────────────────────────┘ │ ▼ Backend-Container anderer Compose-Projekte (im selben "edge"-Netzwerk, z.B. Mailcow, Checkmk, ...) ``` **Warum diese Aufteilung und nicht nur Caddy allein?** - HAProxy filtert unerwünschte IPs bereits beim TCP-Handshake anhand des SNI-Felds - noch bevor Ressourcen für TLS-Termination oder HTTP-Parsing verbraucht werden. - Caddy bleibt für das, worin es am stärksten ist: automatisches HTTPS ohne manuelle Zertifikatsverwaltung, sauberes Host-Routing. - ACME-HTTP-01-Challenges laufen unabhängig von der IP-Filterung immer durch - damit euch nicht das passiert, was beim Mailcow-Setup mit der Hairpin-NAT/ACME-Problematik schon mal Kopfzerbrechen bereitet hat. ## Warum Bash+AWK statt Python-Generator-Container? Die Vorgängerversion nutzte einen kleinen Python/Jinja2-Container zum Generieren. Funktional identisch, aber: eigenes Dockerfile, eigener Build, eigenes Image im Umlauf - für eine reine Text-Transformation unverhältnismäßig viel Gewicht. `generate.sh` macht exakt dasselbe mit Bordmitteln: - `lib/parse-sites.awk` übersetzt `sites.yaml` in ein einfaches, tab-getrenntes Zwischenformat (bewusst ohne gawk-spezifische Features geschrieben, läuft also mit mawk, gawk und busybox-awk gleichermaßen). - `generate.sh` liest dieses Zwischenformat in Bash-Arrays, validiert (CIDR-Format, doppelte Namen/Ports, reservierte Ports, gültiger `tls_mode`) und schreibt `Caddyfile`, `haproxy.cfg`, die `acl/*.lst`-Dateien und `docker-compose.override.yml`. **Preis dafür**: `sites.yaml` muss einem festen, dokumentierten Einrückungs- und Formatstil folgen (siehe Kopf der Datei) - der Parser ist bewusst simpel gehalten und kein vollwertiger YAML-Parser. Solange ihr die Datei nach dem Muster der bestehenden Einträge erweitert, ist das in der Praxis kein Problem. ## Einmalige Einrichtung ### Verzeichnismodell: Git-Checkout = Laufzeitverzeichnis Analog zu mailcow-dockerized: kein separates Deployment-Verzeichnis, kein Sync-Schritt. Der Git-Checkout ist der Ort, an dem der Stack läuft. ```bash # Auf dem Server, einmalig: cd /opt git clone reverse-proxy cd reverse-proxy docker network create edge # einmalig, falls noch nicht geschehen make up ``` **WICHTIG:** `config/sites.yaml` ändert ihr direkt hier und committet es - `update.sh` zieht künftige Änderungen per `git pull --ff-only` (siehe "Automatische Updates" unten). Lokale, nicht committete Änderungen werden nie überschrieben - ein `--ff-only`-Pull schlägt in dem Fall einfach fehl und wird im Log als Warnung vermerkt, statt etwas zu riskieren. ### Andere Compose-Projekte anbinden Damit andere Compose-Projekte über diesen Proxy erreichbar sind, müssen sie sich im selben Netzwerk befinden. In deren `docker-compose.yml`: ```yaml services: irgendein-dienst: # ... networks: - edge networks: edge: external: true ``` Der Backend-Name in `config/sites.yaml` (`backend: irgendein-dienst:8080`) muss dann dem Servicenamen/Containernamen im `edge`-Netzwerk entsprechen. ## Alltägliche Bedienung | Aufgabe | Befehl | |-----------------------------------|------------------| | Neue Domain hinzufügen | `sites.yaml` bearbeiten, dann `make reload` | | IP-Filterung für Domain/Port ändern | `allowed_ips:` bei der jeweiligen Domain bzw. dem `tcp_service` anpassen, dann `make reload` | | Configs nur generieren (ohne Neustart) | `make generate` | | Configs vor dem Ausrollen validieren | `make validate` | | Logs ansehen | `make logs` | | Stack stoppen | `make down` | `make reload` generiert die Configs neu (Bash, lokal, sofort), validiert `Caddyfile` (`caddy validate`) und `haproxy.cfg` (`haproxy -c`) und bricht bei Fehlern ab, **bevor** irgendwas neu gestartet wird. ## IP-Filterung: pro Backend, unabhängig voneinander Jede Domain und jeder TCP-Service in `config/sites.yaml` hat eine **eigene** `allowed_ips`-Liste. Kein globaler Schalter - die Filterung ergibt sich einfach daraus, ob bei einem Eintrag eine Liste steht oder nicht: ```yaml domains: - name: git.abw-weiland.de backend: gitea:3000 backend_scheme: http backend_tls_insecure: false tls_mode: auto allowed_ips: - 203.0.113.0/24 # Büro - 198.51.100.10/32 # Homeoffice - name: monitor.abw-weiland.de backend: checkmk:5000 backend_scheme: http backend_tls_insecure: false tls_mode: auto allowed_ips: - 10.0.0.0/8 # CheckMK-Web-UI nur aus dem eigenen Netz tcp_services: - name: checkmk-agent-push listen_port: 6557 backend: checkmk:6557 allowed_ips: - 10.10.0.0/24 # Agent-Port: ANDERE IPs als die Web-UI oben ``` - **Domains** laufen über Caddy/HTTPS. Die IP-Filterung passiert in HAProxy zweifach: auf Port 443 anhand des SNI (vor dem TLS-Handshake) und auf Port 80 anhand des Host-Headers - jeweils mit Ausnahme des ACME-Challenge-Pfads. - **`tcp_services:`** sind rohe TCP-Ports ohne Caddy dazwischen - für Dienste, die nicht per Domain/SNI laufen, z.B. einen CheckMK-Agent-Push-Port. Der `listen_port` wird automatisch in `generated/docker-compose.override.yml` freigegeben (via `COMPOSE_FILE` in `.env` bei jedem `docker compose`-Aufruf mitgeladen) - kein manuelles Anfassen der `docker-compose.yml` nötig. - `allowed_ips:` weglassen bzw. leer lassen = öffentlich erreichbar. - `generate.sh` validiert jede Liste (CIDR-Format), erkennt doppelte Domain-/Service-Namen und doppelt vergebene Ports, und verweigert Port 80/443 für `tcp_services` (die sind für Caddy/ACME reserviert) - alles **vor** jedem Deployment. ## TLS-Modus: ob wirklich ein Zertifikat erstellt wird Pro Domain über `tls_mode` steuerbar: | Wert (Standard: `auto`) | Bedeutung | |--------------------------|-----------| | `auto` | Echtes Let's-Encrypt-Zertifikat, öffentlich vertrauenswürdig. Braucht funktionierendes DNS + offenen Port 80. | | `internal` | Caddys eigene interne CA - selbstsigniert, kein ACME-Request, keine Rate-Limits. Browser zeigen eine Zertifikatswarnung, außer die interne CA ist dort importiert. Gut zum Testen, ohne Let's-Encrypt-Rate-Limits zu riskieren. | | `"off"` | Kein TLS, nur HTTP (Port 80). Kein Zertifikat wird angefragt. Sinnvoll z.B. wenn DNS noch nicht steht. | **Wichtig:** `"off"` immer in Anführungszeichen schreiben - unquotiert liest YAML es als Boolean `false`. Der Generator normalisiert das zwar über `stripq()`, sauberer ist es aber, es gleich richtig zu schreiben. Bei `tls_mode: "off"` entfällt die Domain automatisch aus der SNI-basierten IP-Filterung auf Port 443 (läuft dort ohnehin nicht) - eine gesetzte `allowed_ips`-Liste wirkt dann nur noch auf Port 80. ## Automatische Updates `update/update.sh` orientiert sich am Update-Skript von mailcow-dockerized (interaktive Bestätigung, Hook-Punkte), angepasst auf unseren Fall (2-Container-Edge-Stack statt Dutzende interdependenter Container): 1. **Das Projekt selbst** - `git pull --ff-only` direkt in diesem Verzeichnis (siehe "Verzeichnismodell" oben). Ohne Git-Checkout wird der Schritt übersprungen (Info im Log). Fast-forward-only heißt: lokale, nicht committete Änderungen werden nie überschrieben, der Pull schlägt in dem Fall einfach fehl (Warnung im Log). 2. **Die Images** - `haproxy`/`caddy` werden neu gepullt, bleiben aber auf der in `.env` gepinnten Minor-Version (z.B. `haproxy:3.2-alpine`). Nur neue Patch-/Security-Builds, kein automatischer Sprung auf `3.3`. Größere Versionswechsel: Tag in `.env` ändern, dann `make reload`. **Interaktive Bestätigung**: Werden Änderungen gefunden, fragt das Skript (wie bei mailcow) `Update anwenden? Caddy und HAProxy werden neu gestartet. [y/N]`, bevor es etwas anfasst - außer ihr übergebt `-f`/`--force`/`--yes`. Läuft das Skript nicht-interaktiv (Cron/systemd) und ohne `--force`, bricht es sauber mit Exit-Code 2 ab, statt zu hängen oder blind anzuwenden. **Hooks** (optional, siehe `update/*_update_hook.sh.example`): Existiert `update/pre_update_hook.sh`, läuft es ganz am Anfang. Existiert `update/post_update_hook.sh`, läuft es am Ende - aber nur, wenn tatsächlich ein Update angewendet wurde (erfolgreich oder mit Rollback), nicht bei "keine Änderungen" oder `--dry-run`. Beide Dateien sind git-ignoriert (hostspezifisch) - Vorlage kopieren und anpassen: `cp update/pre_update_hook.sh.example update/pre_update_hook.sh`. Ablauf bei erkannten Änderungen: ``` git pull --ff-only -> Images pullen -> [Bestätigung, außer --force] -> generate.sh (Bash) -> Caddyfile + haproxy.cfg validieren -> Container neu starten -> Healthcheck abwarten (60s) -> healthy: fertig -> nicht healthy: automatisches Rollback auf die vorherigen Image-IDs, erneuter Healthcheck, Ergebnis geloggt ``` Nichts wird neu gestartet, solange Generierung oder Validierung fehlschlagen. Anders als mailcow (das Rollback nur manuell per `git reflog` anbietet) macht dieser Stack das Rollback automatisch - bei nur zwei Containern und der Rolle als Eingangstor für alles dahinter ist ein automatischer Recovery-Pfad hier wichtiger als bei einem großen, komplexen Stack. **Manuell ausführen:** ```bash update/update.sh # interaktiv, fragt vor dem Anwenden nach make update-check # zeigt verfügbare Updates, fragt nichts (--dry-run) make update # wendet ohne Rückfrage an (--force, für Cron/systemd) ``` **Log**: `update/update.log` (einfache Größenrotation bei 5 MB). **Exit-Codes** (für Monitoring, z.B. als CheckMK-Local-Check einbindbar): - `0` - keine Änderungen, Dry-Run ohne Befund, oder vom Nutzer abgelehnt - `1` - Update erfolgreich angewendet - `2` - Update fehlgeschlagen (mit oder ohne erfolgreiches Rollback), oder Bestätigung erforderlich aber nicht möglich (nicht-interaktiv ohne `--force`) **Automatisierung per systemd-Timer** (in `update/` enthalten): ```bash sudo cp update/reverse-proxy-update.service update/reverse-proxy-update.timer /etc/systemd/system/ # WorkingDirectory/ExecStart in der .service-Datei an euren Deploy-Pfad anpassen sudo systemctl daemon-reload sudo systemctl enable --now reverse-proxy-update.timer ``` Läuft standardmäßig sonntags 04:00 Uhr (± 30 Min. Zufallsversatz). ## Hardening-Entscheidungen - **Image-Pinning**: `caddy:2.11-alpine`, `haproxy:3.2-alpine` (aktuelle LTS-Reihe, Support bis 2030-Q2). Für maximale Reproduzierbarkeit könnt ihr die Tags zusätzlich per Digest fixieren (`docker inspect --format '{{index .RepoDigests 0}}' `). - **cap_drop: ALL** + gezieltes `cap_add: NET_BIND_SERVICE`. - **read_only: true** Root-Dateisystem für beide Container, mit `tmpfs` für `/tmp` bzw. dedizierten Volumes (Caddy-Zertifikate in `caddy_data`). - **security_opt: no-new-privileges**. - **Healthchecks**: HAProxy validiert die eigene Config (`haproxy -c`), Caddy prüft zumindest die Prozess-Lauffähigkeit. - **`generate.sh` läuft komplett ohne Docker/Netzwerkzugriff** - reines Text-Processing auf dem Host, kleinstmögliche Angriffsfläche für diesen Schritt. ## Projektstruktur ``` reverse-proxy/ ├── config/ │ └── sites.yaml # <- einzige Datei, die ihr im Alltag bearbeitet ├── lib/ │ └── parse-sites.awk # AWK-Parser (portabel: mawk/gawk/busybox) ├── generate.sh # Bash-Generator, kein Python/Docker nötig ├── generated/ # Output, wird gemountet │ ├── Caddyfile │ ├── haproxy.cfg │ ├── acl/ # eine .lst-Datei je gefilterter Domain/Service │ └── docker-compose.override.yml # Portfreigaben für tcp_services ├── update/ │ ├── update.sh # Updater: Projekt + Images, mit Rollback │ ├── update.log # wird beim ersten Lauf angelegt │ ├── pre_update_hook.sh.example # Vorlage, siehe "Automatische Updates" │ ├── post_update_hook.sh.example # Vorlage, siehe "Automatische Updates" │ ├── reverse-proxy-update.service # systemd-Unit │ └── reverse-proxy-update.timer # systemd-Timer (wöchentlich) ├── .env # Image-Tags + COMPOSE_FILE ├── docker-compose.yml ├── Makefile └── README.md ``` `generated/Caddyfile`, `generated/haproxy.cfg` und `generated/acl/*.lst` sind reiner Build-Output und stehen in `.gitignore`. Ausnahme: `generated/docker-compose.override.yml` liegt als Platzhalter im Repo, weil `.env` (`COMPOSE_FILE=...`) schon beim allerersten `docker compose`-Aufruf darauf verweist, bevor `make generate` je gelaufen ist.