commit c6fa5d32013c85e9d35fb1291af32c4373c25139 Author: Marcus Date: Fri Jul 31 22:17:22 2026 +0200 Initial commit diff --git a/.env b/.env new file mode 100644 index 0000000..4605b32 --- /dev/null +++ b/.env @@ -0,0 +1,18 @@ +# Zentrale Versionsverwaltung für die Container-Images. +# docker-compose.yml UND update/update.sh lesen beide von hier - +# eine Änderung hier genügt, keine Duplikate pflegen. +# +# Der Updater (update/update.sh) holt bei jedem Lauf neue Patch-Builds +# INNERHALB dieser Tags (z.B. neue haproxy:3.2.x). Ein Sprung auf eine neue +# Minor-/Major-Version (z.B. 3.2 -> 3.3) bleibt bewusst manuell: hier den +# Tag ändern, dann `make reload`. + +HAPROXY_IMAGE=haproxy:3.2-alpine +CADDY_IMAGE=caddy:2.11-alpine + +# Lädt automatisch das generierte Override (zusätzliche HAProxy-Ports für +# tcp_services aus config/sites.yaml) bei JEDEM docker-compose-Aufruf mit, +# ohne dass ihr an "-f ..." denken müsst. Wird von generate.sh befüllt; +# die Datei muss aber bereits existieren, deshalb liegt hier ein Platzhalter +# im Repo (siehe generated/docker-compose.override.yml). +COMPOSE_FILE=docker-compose.yml:generated/docker-compose.override.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..77c6a5c --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +generated/Caddyfile +generated/haproxy.cfg +generated/acl/*.lst +update/update.log* +# Hook-Skripte sind hostspezifisch/optional - nur die .example-Vorlagen +# werden versioniert. +update/pre_update_hook.sh +update/post_update_hook.sh +# Hinweis: generated/docker-compose.override.yml bewusst NICHT ignoriert - +# muss als Platzhalter existieren, bevor der erste `make generate`-Lauf +# stattfindet (siehe .env, COMPOSE_FILE). diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2060c0c --- /dev/null +++ b/Makefile @@ -0,0 +1,41 @@ +.PHONY: generate up down reload restart logs validate ps update update-check + +# Erzeugt Caddyfile + haproxy.cfg + acl/*.lst + docker-compose.override.yml +# aus config/sites.yaml. Reines Bash+AWK, läuft direkt hier, kein Docker nötig. +generate: + ./generate.sh + +# Prüft die generierten Configs auf Syntaxfehler, BEVOR sie scharf geschaltet +# werden (nutzt die in .env gepinnten Images, keine doppelte Versionsangabe +# nötig - für die Prüfung selbst braucht es Docker, für die Generierung nicht) +validate: generate + docker compose run --rm --no-deps caddy caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile + docker compose run --rm --no-deps haproxy haproxy -c -f /usr/local/etc/haproxy/haproxy.cfg + +# Erstinbetriebnahme +up: validate + docker compose up -d caddy haproxy + +down: + docker compose down + +# Nach Änderungen an config/sites.yaml: neu generieren + validieren + neu starten +reload: validate + docker compose restart caddy haproxy + +restart: + docker compose restart caddy haproxy + +logs: + docker compose logs -f caddy haproxy + +ps: + docker compose ps + +# Zeigt verfügbare Updates (Projekt + Images), ändert nichts +update-check: + ./update/update.sh --dry-run + +# Aktualisiert Projekt + Images ohne Rückfrage, generiert+validiert, startet neu, rollt bei Fehlschlag zurück +update: + ./update/update.sh --force diff --git a/README.md b/README.md new file mode 100644 index 0000000..14ea4b6 --- /dev/null +++ b/README.md @@ -0,0 +1,318 @@ +# 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. diff --git a/config/sites.yaml b/config/sites.yaml new file mode 100644 index 0000000..a1aaf6d --- /dev/null +++ b/config/sites.yaml @@ -0,0 +1,92 @@ +# ============================================================================= +# Zentrale Konfiguration für Caddy + HAProxy +# ----------------------------------------------------------------------------- +# Dies ist die EINZIGE Datei, die du im Alltag anfassen musst. Daraus werden +# per `make generate` (reines Bash+AWK-Skript, kein Python, kein extra +# Docker-Image) automatisch Caddyfile, haproxy.cfg, die IP-Filterlisten und +# die zusätzlichen Port-Freigaben generiert. +# +# WICHTIG - Format-Regeln (der Parser ist bewusst einfach gehalten, damit er +# ohne externe Tools auskommt, dafür ist er strikter als ein vollwertiger +# YAML-Parser): +# - Exakt 2 Leerzeichen pro Einrückungsebene, keine Tabs +# - Keine mehrzeiligen Strings, keine YAML-Anker/-Referenzen +# - "off" als tls_mode IMMER in Anführungszeichen (siehe unten) +# - Werte NICHT in Anführungszeichen, außer bei "off" (s.o.) +# +# Workflow nach jeder Änderung: +# make reload +# ============================================================================= + +global: + acme_email: admin@example.de + +# ----------------------------------------------------------------------------- +# Domains (HTTPS, über Caddy) +# ----------------------------------------------------------------------------- +# backend: : +# backend_scheme: http oder https (womit das Backend selbst spricht) +# backend_tls_insecure: true, wenn das Backend ein selbstsigniertes Zertifikat +# verwendet (z.B. Mailcow-interner nginx) +# allowed_ips: Liste von CIDRs, die auf diese Domain zugreifen +# dürfen. LEER (Zeile weglassen) = öffentlich. +# Jede Domain hat ihre EIGENE Liste. +# tls_mode: auto (Standard, echtes Let's-Encrypt-Zertifikat) +# internal (Caddys interne CA, kein ACME, keine +# Rate-Limits, gut zum Testen) +# "off" (kein TLS, nur HTTP - Anführungszeichen!) +# ----------------------------------------------------------------------------- +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 + - 198.51.100.10/32 + + - 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 + + - name: example.abw-weiland.de + backend: example-backend:8080 + backend_scheme: http + backend_tls_insecure: false + tls_mode: auto + + # Beispiel: DNS zeigt noch nicht drauf - erstmal ohne TLS testen + # - name: staging.abw-weiland.de + # backend: staging-backend:8080 + # backend_scheme: http + # backend_tls_insecure: false + # tls_mode: "off" + # allowed_ips: + # - 10.0.0.0/8 + + # Beispiel: rein internes Tool, interne CA statt Let's Encrypt + # - name: internal-tool.abw-weiland.de + # backend: internal-tool:80 + # backend_scheme: http + # backend_tls_insecure: false + # tls_mode: internal + # allowed_ips: + # - 10.0.0.0/8 + +# ----------------------------------------------------------------------------- +# Zusätzliche rohe TCP-Ports (KEIN HTTP/Caddy dazwischen). Für Dienste, die +# nicht per Domain/SNI laufen, z.B. der CheckMK-Agent-Push-Port. Jeder +# Eintrag hat seine EIGENE IP-Liste, unabhängig von den Domains oben. Der +# Port wird automatisch in generated/docker-compose.override.yml freigegeben. +# ----------------------------------------------------------------------------- +tcp_services: + - name: checkmk-agent-push + listen_port: 6557 + backend: checkmk:6557 + allowed_ips: + - 10.10.0.0/24 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4b715b3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,90 @@ +services: + # --------------------------------------------------------------------------- + # HAProxy: einziger öffentlich erreichbarer Dienst (Ports 80/443 + ggf. + # weitere, siehe generated/docker-compose.override.yml für tcp_services). + # Aufgaben: IP-Filterung (L4, vor TLS-Handshake) + Passthrough zu Caddy. + # --------------------------------------------------------------------------- + haproxy: + image: ${HAPROXY_IMAGE:-haproxy:3.2-alpine} + container_name: edge-haproxy + restart: unless-stopped + depends_on: + - caddy + ports: + - "80:80" + - "443:443" + volumes: + - ./generated/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro + - ./generated/acl:/usr/local/etc/haproxy/acl:ro + networks: + - edge + cap_drop: + - ALL + cap_add: + - NET_BIND_SERVICE + security_opt: + - no-new-privileges:true + read_only: true + tmpfs: + - /tmp + healthcheck: + test: ["CMD", "haproxy", "-c", "-f", "/usr/local/etc/haproxy/haproxy.cfg"] + interval: 30s + timeout: 5s + retries: 3 + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + + # --------------------------------------------------------------------------- + # Caddy: TLS-Terminierung + automatisches Let's Encrypt + Host-basiertes + # Routing zu den eigentlichen Backends. Nicht öffentlich exponiert - + # erreichbar nur über HAProxy im "edge"-Netzwerk. + # --------------------------------------------------------------------------- + caddy: + image: ${CADDY_IMAGE:-caddy:2.11-alpine} + container_name: edge-caddy + restart: unless-stopped + expose: + - "80" + - "443" + volumes: + - ./generated/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + networks: + - edge + cap_drop: + - ALL + cap_add: + - NET_BIND_SERVICE + security_opt: + - no-new-privileges:true + read_only: true + tmpfs: + - /tmp + healthcheck: + test: ["CMD", "caddy", "version"] + interval: 30s + timeout: 5s + retries: 3 + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + +networks: + # Externes Netzwerk, damit andere docker-compose-Projekte (Mailcow, Checkmk, + # eigene Anwendungen, ...) sich anhängen und per Containername erreichbar + # sind, ohne dass dieser Stack sie kennen oder verwalten muss. + # + # Einmalig anlegen: docker network create edge + edge: + external: true + +volumes: + caddy_data: + caddy_config: diff --git a/generate.sh b/generate.sh new file mode 100644 index 0000000..8df4d40 --- /dev/null +++ b/generate.sh @@ -0,0 +1,377 @@ +#!/usr/bin/env bash +# ============================================================================= +# generate.sh - liest config/sites.yaml und erzeugt daraus: +# - generated/Caddyfile +# - generated/haproxy.cfg +# - generated/acl/.lst (eine Datei je Domain/TCP-Service MIT +# allowed_ips) +# - generated/docker-compose.override.yml (Portfreigaben für tcp_services) +# +# Läuft direkt auf dem Host (oder wo auch immer `make` aufgerufen wird) - +# braucht nur bash + awk, kein Python, kein zusätzliches Docker-Image. +# ============================================================================= +set -euo pipefail + +PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$PROJECT_DIR" + +CONFIG_FILE="config/sites.yaml" +AWK_SCRIPT="lib/parse-sites.awk" +OUT_DIR="generated" +ACL_DIR="$OUT_DIR/acl" + +if [ ! -f "$CONFIG_FILE" ]; then + echo "Fehler: $CONFIG_FILE nicht gefunden." >&2 + exit 1 +fi +if ! command -v awk >/dev/null 2>&1; then + echo "Fehler: awk wird benötigt, ist aber nicht installiert." >&2 + exit 1 +fi + +mkdir -p "$OUT_DIR" "$ACL_DIR" + +# ----------------------------------------------------------------------- +# 1. sites.yaml per AWK in TSV-Zeilen übersetzen und in Bash-Arrays laden +# ----------------------------------------------------------------------- +DOMAIN_NAME=(); DOMAIN_BACKEND=(); DOMAIN_SCHEME=(); DOMAIN_TLS_INSECURE=() +DOMAIN_TLS_MODE=(); DOMAIN_IPS=(); DOMAIN_SLUG=() +TCP_NAME=(); TCP_PORT=(); TCP_BACKEND=(); TCP_IPS=(); TCP_SLUG=() +ACME_EMAIL="" + +slugify() { + # z.B. "git.abw-weiland.de" -> "git_abw_weiland_de" + printf '%s' "$1" | tr -c 'a-zA-Z0-9' '_' | tr '[:upper:]' '[:lower:]' | sed -E 's/_+/_/g; s/^_//; s/_$//' +} + +while IFS=$'\t' read -r type c1 c2 c3 c4 c5 c6; do + case "$type" in + DOMAIN) + DOMAIN_NAME+=("$c1") + DOMAIN_BACKEND+=("$c2") + DOMAIN_SCHEME+=("$c3") + DOMAIN_TLS_INSECURE+=("$c4") + DOMAIN_TLS_MODE+=("$c5") + DOMAIN_IPS+=("$c6") + DOMAIN_SLUG+=("$(slugify "$c1")") + ;; + TCP) + TCP_NAME+=("$c1") + TCP_PORT+=("$c2") + TCP_BACKEND+=("$c3") + TCP_IPS+=("$c4") + TCP_SLUG+=("$(slugify "$c1")") + ;; + GLOBAL) + ACME_EMAIL="$c1" + ;; + esac +done < <(awk -f "$AWK_SCRIPT" "$CONFIG_FILE") + +# ----------------------------------------------------------------------- +# 2. Validierung - bricht VOR jeder Dateiausgabe ab, wenn etwas nicht passt +# ----------------------------------------------------------------------- +errors=0 +err() { echo "Fehler: $*" >&2; errors=$((errors + 1)); } + +[ -z "$ACME_EMAIL" ] && err "global.acme_email fehlt oder ist leer." + +is_valid_cidr() { + local cidr="$1" + local ipv4_octet='(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])' + if [[ "$cidr" =~ ^${ipv4_octet}\.${ipv4_octet}\.${ipv4_octet}\.${ipv4_octet}/(3[0-2]|[12]?[0-9])$ ]]; then + return 0 + fi + # Grobe IPv6-Prüfung (keine vollständige Validierung wie eine echte IP-Bibliothek): + if [[ "$cidr" =~ ^[0-9A-Fa-f:]+/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$ ]]; then + return 0 + fi + return 1 +} + +validate_ips() { + local context="$1" ips="$2" + [ -z "$ips" ] && return 0 + local IFS=',' + local cidr + for cidr in $ips; do + is_valid_cidr "$cidr" || err "'$cidr' bei $context ist kein gültiges CIDR (z.B. 10.0.0.0/8 oder 203.0.113.10/32)." + done +} + +declare -A seen_names +declare -A seen_ports + +for i in "${!DOMAIN_NAME[@]}"; do + name="${DOMAIN_NAME[$i]}" + [ -z "$name" ] && err "Domain #$((i+1)) hat keinen Namen." + [ -z "${DOMAIN_BACKEND[$i]}" ] && err "Domain '$name' hat kein backend." + [ -z "${DOMAIN_SCHEME[$i]}" ] && err "Domain '$name' hat kein backend_scheme." + case "${DOMAIN_TLS_MODE[$i]}" in + auto|internal|off) ;; + *) err "Domain '$name' hat ungültigen tls_mode '${DOMAIN_TLS_MODE[$i]}' - erlaubt: auto, internal, off." ;; + esac + if [ -n "${seen_names[$name]:-}" ]; then + err "Name '$name' ist mehrfach definiert." + fi + seen_names[$name]=1 + validate_ips "Domain '$name'" "${DOMAIN_IPS[$i]}" +done + +for i in "${!TCP_NAME[@]}"; do + name="${TCP_NAME[$i]}" + port="${TCP_PORT[$i]}" + [ -z "$name" ] && err "tcp_service #$((i+1)) hat keinen Namen." + [ -z "$port" ] && err "tcp_service '$name' hat keinen listen_port." + [ -z "${TCP_BACKEND[$i]}" ] && err "tcp_service '$name' hat kein backend." + if [ "$port" = "80" ] || [ "$port" = "443" ]; then + err "tcp_service '$name' will Port $port belegen - der ist für Caddy/ACME reserviert." + fi + if [ -n "${seen_names[$name]:-}" ]; then + err "Name '$name' ist mehrfach definiert (Domain oder tcp_service)." + fi + seen_names[$name]=1 + if [ -n "${seen_ports[$port]:-}" ]; then + err "Port $port wird von mehreren tcp_services verwendet." + fi + seen_ports[$port]=1 + validate_ips "tcp_service '$name'" "${TCP_IPS[$i]}" +done + +if [ "$errors" -gt 0 ]; then + echo "Abgebrochen: $errors Fehler gefunden, nichts wurde geschrieben." >&2 + exit 1 +fi + +# ----------------------------------------------------------------------- +# 3. ACL-Dateien schreiben (alte zuerst löschen, damit umbenannte/entfernte +# Domains keine verwaisten Dateien hinterlassen) +# ----------------------------------------------------------------------- +rm -f "$ACL_DIR"/*.lst 2>/dev/null || true + +write_acl() { + local slug="$1" ips="$2" + [ -z "$ips" ] && return 0 + local IFS=',' + : > "$ACL_DIR/$slug.lst" + local cidr + for cidr in $ips; do + echo "$cidr" >> "$ACL_DIR/$slug.lst" + done +} + +for i in "${!DOMAIN_NAME[@]}"; do write_acl "${DOMAIN_SLUG[$i]}" "${DOMAIN_IPS[$i]}"; done +for i in "${!TCP_NAME[@]}"; do write_acl "${TCP_SLUG[$i]}" "${TCP_IPS[$i]}"; done + +# ----------------------------------------------------------------------- +# 4. Caddyfile schreiben +# ----------------------------------------------------------------------- +{ + cat < "$OUT_DIR/Caddyfile" + +# ----------------------------------------------------------------------- +# 5. haproxy.cfg schreiben +# ----------------------------------------------------------------------- +{ + cat <<'EOF' +# ============================================================================= +# GENERIERTE DATEI - NICHT VON HAND BEARBEITEN +# Quelle: config/sites.yaml -> generate.sh -> diese Datei +# ============================================================================= + +global + log stdout format raw local0 + maxconn 4096 + tune.ssl.default-dh-param 2048 + +defaults + log global + option dontlognull + timeout connect 5s + timeout client 30s + timeout server 30s + timeout http-request 10s + timeout http-keep-alive 10s + +# ----------------------------------------------------------------------------- +# Port 80: HTTP - ACME-Challenge + Weiterleitung an Caddy. IP-Filterung pro +# Domain, ACME-Pfad IMMER ausgenommen - sonst schlägt die Zertifikats- +# erneuerung fehl. +# ----------------------------------------------------------------------------- +frontend fe_http + bind *:80 + mode http + option httplog + +EOF + + for i in "${!DOMAIN_NAME[@]}"; do + if [ -n "${DOMAIN_IPS[$i]}" ]; then + name="${DOMAIN_NAME[$i]}" + slug="${DOMAIN_SLUG[$i]}" + echo " acl host_$slug hdr(host) -i $name" + echo " acl allowed_$slug src -f /usr/local/etc/haproxy/acl/$slug.lst" + echo " http-request deny deny_status 403 if host_$slug !allowed_$slug !{ path_beg /.well-known/acme-challenge/ }" + echo "" + fi + done + + cat <<'EOF' + default_backend be_caddy_http + +# ----------------------------------------------------------------------------- +# Port 443: TCP-Passthrough zu Caddy. IP-Filterung anhand SNI, VOR dem +# TLS-Handshake. Domains mit tls_mode "off" tauchen hier bewusst nicht auf. +# ----------------------------------------------------------------------------- +frontend fe_https + bind *:443 + mode tcp + option tcplog + tcp-request inspect-delay 5s + tcp-request content accept if { req_ssl_hello_type 1 } + +EOF + + for i in "${!DOMAIN_NAME[@]}"; do + if [ -n "${DOMAIN_IPS[$i]}" ] && [ "${DOMAIN_TLS_MODE[$i]}" != "off" ]; then + name="${DOMAIN_NAME[$i]}" + slug="${DOMAIN_SLUG[$i]}" + echo " acl sni_$slug req.ssl_sni -i $name" + echo " acl allowed_$slug src -f /usr/local/etc/haproxy/acl/$slug.lst" + echo " tcp-request content reject if sni_$slug !allowed_$slug" + echo "" + fi + done + + cat <<'EOF' + default_backend be_caddy_tcp + +backend be_caddy_http + mode http + server caddy caddy:80 check + +backend be_caddy_tcp + mode tcp + server caddy caddy:443 check +EOF + + if [ "${#TCP_NAME[@]}" -gt 0 ]; then + cat <<'EOF' + +# ----------------------------------------------------------------------------- +# Zusätzliche rohe TCP-Ports (kein Caddy dazwischen) +# ----------------------------------------------------------------------------- +EOF + for i in "${!TCP_NAME[@]}"; do + name="${TCP_NAME[$i]}" + slug="${TCP_SLUG[$i]}" + port="${TCP_PORT[$i]}" + backend="${TCP_BACKEND[$i]}" + echo "# $name" + echo "frontend fe_tcp_$slug" + echo " bind *:$port" + echo " mode tcp" + echo " option tcplog" + if [ -n "${TCP_IPS[$i]}" ]; then + echo " acl allowed_$slug src -f /usr/local/etc/haproxy/acl/$slug.lst" + echo " tcp-request connection reject if !allowed_$slug" + fi + echo " default_backend be_tcp_$slug" + echo "" + echo "backend be_tcp_$slug" + echo " mode tcp" + echo " server $slug $backend check" + echo "" + done + fi +} > "$OUT_DIR/haproxy.cfg" + +# ----------------------------------------------------------------------- +# 6. docker-compose.override.yml schreiben (Portfreigaben für tcp_services) +# ----------------------------------------------------------------------- +{ + cat <<'EOF' +# ============================================================================= +# GENERIERTE DATEI - NICHT VON HAND BEARBEITEN +# Quelle: config/sites.yaml (tcp_services) -> generate.sh -> diese Datei +# +# Wird automatisch per COMPOSE_FILE (siehe .env) zusätzlich zur +# docker-compose.yml geladen und ergänzt die Portfreigaben für HAProxy. +# ============================================================================= +services: + haproxy: +EOF + if [ "${#TCP_NAME[@]}" -gt 0 ]; then + echo " ports:" + for i in "${!TCP_NAME[@]}"; do + echo " - \"${TCP_PORT[$i]}:${TCP_PORT[$i]}\"" + done + else + echo " ports: []" + fi +} > "$OUT_DIR/docker-compose.override.yml" + +# ----------------------------------------------------------------------- +# 7. Zusammenfassung +# ----------------------------------------------------------------------- +filtered_domains="" +for i in "${!DOMAIN_NAME[@]}"; do + [ -n "${DOMAIN_IPS[$i]}" ] && filtered_domains="$filtered_domains ${DOMAIN_NAME[$i]}" +done +filtered_tcp="" +for i in "${!TCP_NAME[@]}"; do + [ -n "${TCP_IPS[$i]}" ] && filtered_tcp="$filtered_tcp ${TCP_NAME[$i]}" +done +non_auto_tls="" +for i in "${!DOMAIN_NAME[@]}"; do + [ "${DOMAIN_TLS_MODE[$i]}" != "auto" ] && non_auto_tls="$non_auto_tls ${DOMAIN_NAME[$i]}(${DOMAIN_TLS_MODE[$i]})" +done + +echo "[ok] ${#DOMAIN_NAME[@]} Domain(s), ${#TCP_NAME[@]} TCP-Service(s) verarbeitet." +[ -n "$filtered_domains" ] && echo "[ok] IP-gefiltert:$filtered_domains" +[ -n "$filtered_tcp" ] && echo "[ok] IP-gefiltert (TCP):$filtered_tcp" +[ -n "$non_auto_tls" ] && echo "[ok] Abweichender TLS-Modus:$non_auto_tls" +echo "[ok] Geschrieben nach $OUT_DIR/: Caddyfile, haproxy.cfg, docker-compose.override.yml, acl/*.lst" diff --git a/generated/docker-compose.override.yml b/generated/docker-compose.override.yml new file mode 100644 index 0000000..5384dc2 --- /dev/null +++ b/generated/docker-compose.override.yml @@ -0,0 +1,6 @@ +# Platzhalter - wird von `make generate` überschrieben. +# Muss beim allerersten Start bereits existieren, da COMPOSE_FILE in .env +# darauf verweist (siehe .env-Kommentar). +services: + haproxy: + ports: [] diff --git a/lib/parse-sites.awk b/lib/parse-sites.awk new file mode 100644 index 0000000..34a2bc7 --- /dev/null +++ b/lib/parse-sites.awk @@ -0,0 +1,163 @@ +# ============================================================================= +# parse-sites.awk +# ----------------------------------------------------------------------------- +# Liest config/sites.yaml (fester, dokumentierter Formatstil - siehe Kopf der +# sites.yaml) und gibt eine einfache, tab-getrennte Zwischenform aus, die +# generate.sh mit reinem Bash weiterverarbeitet: +# +# GLOBALacme_email +# DOMAINnamebackendbackend_schemebackend_tls_insecuretls_modeallowed_ips(komma-getrennt) +# TCPnamelisten_portbackendallowed_ips(komma-getrennt) +# +# Bewusst ohne gawk-spezifische Features (kein 3-arg match(), kein gensub()) +# geschrieben, damit es auch mit mawk oder busybox awk (Alpine) läuft. +# ============================================================================= + +function trim(s) { + sub(/^[ \t\r]+/, "", s) + sub(/[ \t\r]+$/, "", s) + return s +} + +function stripq(s) { + gsub(/"/, "", s) + return s +} + +function flush_domain() { + if (in_domain) { + ips = "" + for (i = 1; i <= n_ips; i++) { ips = ips (i > 1 ? "," : "") ips_arr[i] } + printf "DOMAIN\t%s\t%s\t%s\t%s\t%s\t%s\n", d_name, d_backend, d_scheme, d_tls_insecure, d_tls_mode, ips + in_domain = 0 + } +} + +function flush_tcp() { + if (in_tcp) { + ips = "" + for (i = 1; i <= n_ips; i++) { ips = ips (i > 1 ? "," : "") ips_arr[i] } + printf "TCP\t%s\t%s\t%s\t%s\n", t_name, t_port, t_backend, ips + in_tcp = 0 + } +} + +BEGIN { + section = "" + in_domain = 0 + in_tcp = 0 + in_allowed_ips = 0 + acme_email = "" +} + +{ + line = $0 + sub(/\r$/, "", line) + + # volle Kommentarzeilen und Leerzeilen überspringen + if (line ~ /^[ \t]*#/ || line ~ /^[ \t]*$/) next + + # Einrückung ermitteln (Anzahl führender Leerzeichen) + stripped = line + gsub(/^ */, "", stripped) + indent = length(line) - length(stripped) + content = stripped + + # Top-Level-Abschnitt + if (indent == 0) { + flush_domain() + flush_tcp() + if (content ~ /^global:/) { section = "global" } + else if (content ~ /^domains:/) { section = "domains" } + else if (content ~ /^tcp_services:/) { section = "tcp_services" } + else { section = "" } + next + } + + if (section == "global" && indent == 2) { + if (content ~ /^acme_email:/) { + val = content + sub(/^acme_email:[ \t]*/, "", val) + acme_email = stripq(trim(val)) + } + next + } + + if (section == "domains") { + if (indent == 2 && content ~ /^- name:/) { + flush_domain() + in_domain = 1 + in_allowed_ips = 0 + n_ips = 0 + d_backend = "" + d_scheme = "" + d_tls_insecure = "false" + d_tls_mode = "auto" + val = content + sub(/^- name:[ \t]*/, "", val) + d_name = stripq(trim(val)) + next + } + if (in_domain && indent == 4) { + if (content ~ /^backend:/) { + val = content; sub(/^backend:[ \t]*/, "", val); d_backend = stripq(trim(val)) + in_allowed_ips = 0; next + } + if (content ~ /^backend_scheme:/) { + val = content; sub(/^backend_scheme:[ \t]*/, "", val); d_scheme = stripq(trim(val)) + in_allowed_ips = 0; next + } + if (content ~ /^backend_tls_insecure:/) { + val = content; sub(/^backend_tls_insecure:[ \t]*/, "", val); d_tls_insecure = stripq(trim(val)) + in_allowed_ips = 0; next + } + if (content ~ /^tls_mode:/) { + val = content; sub(/^tls_mode:[ \t]*/, "", val); d_tls_mode = stripq(trim(val)) + in_allowed_ips = 0; next + } + if (content ~ /^allowed_ips:/) { in_allowed_ips = 1; next } + } + if (in_domain && in_allowed_ips && indent == 6 && content ~ /^- /) { + val = content; sub(/^- [ \t]*/, "", val) + n_ips++; ips_arr[n_ips] = stripq(trim(val)) + next + } + } + + if (section == "tcp_services") { + if (indent == 2 && content ~ /^- name:/) { + flush_tcp() + in_tcp = 1 + in_allowed_ips = 0 + n_ips = 0 + t_port = "" + t_backend = "" + val = content + sub(/^- name:[ \t]*/, "", val) + t_name = stripq(trim(val)) + next + } + if (in_tcp && indent == 4) { + if (content ~ /^listen_port:/) { + val = content; sub(/^listen_port:[ \t]*/, "", val); t_port = stripq(trim(val)) + in_allowed_ips = 0; next + } + if (content ~ /^backend:/) { + val = content; sub(/^backend:[ \t]*/, "", val); t_backend = stripq(trim(val)) + in_allowed_ips = 0; next + } + if (content ~ /^allowed_ips:/) { in_allowed_ips = 1; next } + } + if (in_tcp && in_allowed_ips && indent == 6 && content ~ /^- /) { + val = content; sub(/^- [ \t]*/, "", val) + n_ips++; ips_arr[n_ips] = stripq(trim(val)) + next + } + } +} + +END { + flush_domain() + flush_tcp() + printf "GLOBAL\t%s\n", acme_email +} diff --git a/update/post_update_hook.sh.example b/update/post_update_hook.sh.example new file mode 100644 index 0000000..3f87a29 --- /dev/null +++ b/update/post_update_hook.sh.example @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# ============================================================================= +# post_update_hook.sh - läuft am Ende von update.sh, falls diese Datei +# existiert (Name exakt "post_update_hook.sh", ohne ".example") UND +# tatsächlich ein Update angewendet wurde (erfolgreich oder mit Rollback - +# bei "keine Änderungen" oder --dry-run läuft dieser Hook NICHT). +# +# Beispiele: Benachrichtigung über Erfolg/Fehlschlag, CheckMK-Spooler +# anstoßen, Wartungsfenster wieder schließen. +# ============================================================================= +set -euo pipefail + +echo "post_update_hook.sh: nichts zu tun (Vorlage - anpassen oder loeschen)" diff --git a/update/pre_update_hook.sh.example b/update/pre_update_hook.sh.example new file mode 100644 index 0000000..3bd6682 --- /dev/null +++ b/update/pre_update_hook.sh.example @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# ============================================================================= +# pre_update_hook.sh - läuft ganz am Anfang von update.sh, falls diese Datei +# existiert (Name exakt "pre_update_hook.sh", ohne ".example"). +# +# Beispiele: Benachrichtigung an Slack/Mail, Backup von generated/acl/, +# Wartungsfenster in eurem Monitoring markieren. +# +# Ein Fehler hier bricht das Update NICHT ab (nur eine Warnung im Log) - +# wenn ein Fehlschlag hier das Update verhindern soll, das explizit selbst +# mit "exit 1" tun. +# ============================================================================= +set -euo pipefail + +echo "pre_update_hook.sh: nichts zu tun (Vorlage - anpassen oder loeschen)" diff --git a/update/reverse-proxy-update.service b/update/reverse-proxy-update.service new file mode 100644 index 0000000..99d2cb7 --- /dev/null +++ b/update/reverse-proxy-update.service @@ -0,0 +1,23 @@ +[Unit] +Description=Reverse-Proxy Update (Caddy/HAProxy Images + Projekt) +Wants=network-online.target +After=network-online.target docker.service +Requires=docker.service + +[Service] +Type=oneshot +# Der Git-Checkout IST das Laufzeitverzeichnis (kein separates Deployment- +# Ziel). Pfad an euren tatsächlichen Klon-Ort anpassen, falls abweichend +# von /opt/reverse-proxy: +WorkingDirectory=/opt/reverse-proxy +ExecStart=/opt/reverse-proxy/update/update.sh --force + +# Muss Docker ansprechen können - entweder root oder ein Nutzer in der +# docker-Gruppe. Läuft der Rest eurer Infra unter einem Service-User, +# hier entsprechend eintragen: +User=root + +# Exit-Code 1 (= Update erfolgreich angewendet) und 2 (= fehlgeschlagen/ +# Rollback) sollen NICHT als systemd-Fehlschlag gewertet werden, nur ein +# echter Absturz (>2) soll das: +SuccessExitStatus=0 1 2 diff --git a/update/reverse-proxy-update.timer b/update/reverse-proxy-update.timer new file mode 100644 index 0000000..8673efd --- /dev/null +++ b/update/reverse-proxy-update.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Woechentliches Update fuer den Reverse-Proxy-Stack (Caddy/HAProxy) + +[Timer] +# Sonntags 04:00 Uhr, mit bis zu 30 Minuten zufälligem Versatz +OnCalendar=Sun *-*-* 04:00:00 +RandomizedDelaySec=1800 +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/update/update.sh b/update/update.sh new file mode 100644 index 0000000..b4a748c --- /dev/null +++ b/update/update.sh @@ -0,0 +1,281 @@ +#!/usr/bin/env bash +# ============================================================================= +# update.sh - aktualisiert das Projekt (git pull --ff-only, falls dieses +# Verzeichnis ein Git-Checkout ist) und die Caddy-/HAProxy-Images, validiert +# die neue Konfiguration VOR jedem Neustart und macht bei fehlgeschlagenem +# Healthcheck automatisch ein Rollback. +# +# Verzeichnismodell: dieses Verzeichnis IST der Git-Checkout (analog zu +# mailcow-dockerized) - kein separates Laufzeitverzeichnis, kein Sync-Schritt. +# `git clone /opt/reverse-proxy` und hier direkt arbeiten. +# +# Aufruf: +# update/update.sh interaktiv: fragt vor dem Anwenden nach +# update/update.sh --dry-run zeigt verfügbare Updates, fragt nichts, ändert nichts +# update/update.sh -f|--force wendet ohne Rückfrage an (für Cron/systemd) +# update/update.sh --yes Alias für --force (Kompatibilität) +# +# Hooks (optional, siehe update/*_update_hook.sh.example): +# update/pre_update_hook.sh läuft ganz am Anfang, falls vorhanden +# update/post_update_hook.sh läuft nach einem angewendeten Update +# (erfolgreich oder mit Rollback), falls vorhanden +# +# Exit-Codes (bewusst für Monitoring/CheckMK-Einbindung gewählt): +# 0 = keine Änderungen bzw. Dry-Run ohne Befund bzw. vom Nutzer abgelehnt +# 1 = Update erfolgreich angewendet +# 2 = Update fehlgeschlagen (mit oder ohne erfolgreichem Rollback), oder +# Bestätigung erforderlich aber nicht möglich (nicht-interaktiv ohne --force) +# - siehe update/update.log +# ============================================================================= +set -euo pipefail + +PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$PROJECT_DIR" + +LOG_DIR="$PROJECT_DIR/update" +LOG_FILE="$LOG_DIR/update.log" +mkdir -p "$LOG_DIR" + +DRY_RUN=0 +FORCE=0 +HEALTH_TIMEOUT="${HEALTH_TIMEOUT:-60}" + +for arg in "$@"; do + case "$arg" in + --dry-run) DRY_RUN=1 ;; + -f|--force|--yes) FORCE=1 ;; + -h|--help) + echo "Usage: $0 [--dry-run] [-f|--force]" + echo " --dry-run zeigt verfuegbare Updates, fragt nichts, aendert nichts" + echo " -f, --force wendet ohne interaktive Rueckfrage an (fuer Cron/systemd)" + echo " --yes Alias fuer --force" + echo "Ohne Optionen: interaktiv, fragt vor dem Anwenden nach." + exit 0 ;; + *) + echo "Unbekannte Option: $arg" >&2 + exit 64 ;; + esac +done + +# .env laden (gleiche Datei, die auch docker-compose.yml verwendet) ----------- +if [ -f "$PROJECT_DIR/.env" ]; then + set -a + # shellcheck disable=SC1091 + source "$PROJECT_DIR/.env" + set +a +fi +HAPROXY_IMAGE="${HAPROXY_IMAGE:-haproxy:3.2-alpine}" +CADDY_IMAGE="${CADDY_IMAGE:-caddy:2.11-alpine}" + +log() { + printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE" +} + +run_hook() { + local hook_file="$PROJECT_DIR/update/$1" + if [ -f "$hook_file" ]; then + log "Fuehre $1 aus..." + if bash "$hook_file" >> "$LOG_FILE" 2>&1; then + log "$1 erfolgreich." + else + log "WARNUNG: $1 ist mit Fehler beendet worden (Exit-Code $?), mache trotzdem weiter." + fi + fi +} + +# einfache Log-Rotation, keine externen Abhängigkeiten (z.B. logrotate) nötig +if [ -f "$LOG_FILE" ] && [ "$(wc -c < "$LOG_FILE")" -gt 5242880 ]; then + mv "$LOG_FILE" "$LOG_FILE.1" +fi + +log "=== Update-Lauf gestartet (dry-run=$DRY_RUN, force=$FORCE) ===" +trap 'log "=== Update-Lauf abgebrochen (unerwarteter Fehler, Zeile $LINENO) ==="' ERR + +run_hook "pre_update_hook.sh" + +PROJECT_CHANGED=0 + +# ----------------------------------------------------------------------- +# 1. Projekt selbst aktualisieren (git pull --ff-only, falls dieses +# Verzeichnis ein Git-Checkout mit konfiguriertem Upstream ist). +# Fast-forward-only, damit lokale Änderungen niemals überschrieben +# werden - schlägt der Pull fehl, wird nur gewarnt, nicht erzwungen. +# ----------------------------------------------------------------------- +if [ -d "$PROJECT_DIR/.git" ]; then + if git fetch --quiet 2>>"$LOG_FILE"; then + BEHIND="$(git rev-list 'HEAD..@{u}' --count 2>/dev/null || echo 0)" + if [ "$BEHIND" -gt 0 ]; then + log "Projekt: $BEHIND neue(r) Commit(s) verfuegbar" + else + log "Projekt: bereits aktuell" + fi + else + log "Projekt: WARNUNG - git fetch fehlgeschlagen (kein Netzwerk/kein Remote konfiguriert?), Selbst-Update uebersprungen" + BEHIND=0 + fi +else + log "Projekt: kein Git-Checkout - Selbst-Update uebersprungen (config/sites.yaml muss manuell gepflegt werden)" + BEHIND=0 +fi + +# ----------------------------------------------------------------------- +# 2. Aktuellen Image-Stand merken (Grundlage für Rollback) + neue Images +# pruefen. Bleibt bewusst auf der in .env gepinnten Minor-Version +# (z.B. haproxy:3.2-alpine) - nur neue Patch-/Security-Builds, keine +# automatischen Major/Minor-Sprünge. Größere Versionssprünge: Tag in +# .env manuell ändern, dann `make reload`. +# ----------------------------------------------------------------------- +OLD_HAPROXY_ID="$(docker compose images -q haproxy 2>/dev/null || true)" +OLD_CADDY_ID="$(docker compose images -q caddy 2>/dev/null || true)" + +log "Pruefe auf neue Images ($HAPROXY_IMAGE, $CADDY_IMAGE)..." +docker compose pull haproxy caddy >> "$LOG_FILE" 2>&1 + +NEW_HAPROXY_ID="$(docker compose images -q haproxy)" +NEW_CADDY_ID="$(docker compose images -q caddy)" + +IMAGES_CHANGED=0 +if [ "$OLD_HAPROXY_ID" != "$NEW_HAPROXY_ID" ]; then + log " -> haproxy: neues Image (${OLD_HAPROXY_ID:0:12} -> ${NEW_HAPROXY_ID:0:12})" + IMAGES_CHANGED=1 +else + log " -> haproxy: bereits aktuell (${NEW_HAPROXY_ID:0:12})" +fi +if [ "$OLD_CADDY_ID" != "$NEW_CADDY_ID" ]; then + log " -> caddy: neues Image (${OLD_CADDY_ID:0:12} -> ${NEW_CADDY_ID:0:12})" + IMAGES_CHANGED=1 +else + log " -> caddy: bereits aktuell (${NEW_CADDY_ID:0:12})" +fi + +if [ "$DRY_RUN" -eq 1 ]; then + log "=== Dry-Run beendet, nichts wurde angewendet ===" + exit 0 +fi + +if [ "$IMAGES_CHANGED" -eq 0 ] && [ "$BEHIND" -eq 0 ]; then + log "=== Keine Aenderungen, fertig ===" + exit 0 +fi + +# ----------------------------------------------------------------------- +# 3. Bestätigung einholen (mailcow-Stil): interaktiv nachfragen, außer +# --force/-f/--yes wurde übergeben. Ohne Terminal UND ohne --force wird +# sauber abgebrochen statt zu hängen oder blind anzuwenden. +# ----------------------------------------------------------------------- +if [ "$FORCE" -ne 1 ]; then + if [ -t 0 ]; then + echo "" + read -r -p "Update anwenden? Caddy und HAProxy werden neu gestartet. [y/N] " response < /dev/tty + if [[ ! "$response" =~ ^([yY][eE][sS]|[yY])+$ ]]; then + log "=== Vom Nutzer abgelehnt, nichts wurde angewendet ===" + exit 0 + fi + else + log "FEHLER: Aenderungen verfuegbar, aber nicht interaktiv und --force nicht gesetzt - breche ab." + exit 2 + fi +fi + +# ----------------------------------------------------------------------- +# 4. Projekt-Update jetzt tatsächlich ziehen (falls vorhanden) +# ----------------------------------------------------------------------- +if [ "$BEHIND" -gt 0 ]; then + if git pull --ff-only --quiet 2>>"$LOG_FILE"; then + log "Projekt: aktualisiert ($BEHIND Commit(s))" + PROJECT_CHANGED=1 + else + log "Projekt: WARNUNG - git pull --ff-only fehlgeschlagen (lokale Aenderungen? divergierter Branch?), uebersprungen" + fi +fi + +# ----------------------------------------------------------------------- +# 5. Konfiguration neu generieren (reines Bash+AWK, kein Docker-Image +# nötig) und VOR jedem Neustart validieren. Bei Fehlern wird nichts +# angefasst. +# ----------------------------------------------------------------------- +log "Generiere Konfiguration aus config/sites.yaml..." +if ! "$PROJECT_DIR/generate.sh" >> "$LOG_FILE" 2>&1; then + log "FEHLER: Config-Generierung fehlgeschlagen - breche ab, nichts wurde neu gestartet." + run_hook "post_update_hook.sh" + exit 2 +fi + +log "Validiere generated/Caddyfile..." +if ! docker compose run --rm --no-deps caddy caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile >> "$LOG_FILE" 2>&1; then + log "FEHLER: generated/Caddyfile ist ungueltig - breche ab, nichts wurde neu gestartet." + run_hook "post_update_hook.sh" + exit 2 +fi + +log "Validiere generated/haproxy.cfg mit dem neuen Image..." +if ! docker compose run --rm --no-deps haproxy haproxy -c -f /usr/local/etc/haproxy/haproxy.cfg >> "$LOG_FILE" 2>&1; then + log "FEHLER: generated/haproxy.cfg ist mit dem neuen Image ungueltig - breche ab, nichts wurde neu gestartet." + run_hook "post_update_hook.sh" + exit 2 +fi + +# ----------------------------------------------------------------------- +# 6. Neu starten + Healthcheck abwarten +# ----------------------------------------------------------------------- +wait_healthy() { + local svc="$1" elapsed=0 cid status + cid="$(docker compose ps -q "$svc")" + [ -z "$cid" ] && return 1 + while [ "$elapsed" -lt "$HEALTH_TIMEOUT" ]; do + status="$(docker inspect --format='{{.State.Health.Status}}' "$cid" 2>/dev/null || echo none)" + [ "$status" = "healthy" ] && return 0 + [ "$status" = "unhealthy" ] && return 1 + sleep 3 + elapsed=$((elapsed + 3)) + done + return 1 +} + +log "Starte caddy + haproxy mit aktualisierten Images neu..." +docker compose up -d --no-deps caddy haproxy >> "$LOG_FILE" 2>&1 + +ALL_HEALTHY=1 +for svc in caddy haproxy; do + if wait_healthy "$svc"; then + log " -> $svc: healthy" + else + log " -> $svc: NICHT healthy innerhalb von ${HEALTH_TIMEOUT}s" + ALL_HEALTHY=0 + fi +done + +if [ "$ALL_HEALTHY" -eq 1 ]; then + log "=== Update erfolgreich angewendet ===" + run_hook "post_update_hook.sh" + exit 1 +fi + +# ----------------------------------------------------------------------- +# 7. Rollback: alte, lokal noch vorhandene Image-IDs zurück auf den in .env +# gepinnten Tag mappen und neu starten. +# ----------------------------------------------------------------------- +log "Healthcheck fehlgeschlagen - fuehre Rollback durch..." + +[ -n "$OLD_HAPROXY_ID" ] && docker tag "$OLD_HAPROXY_ID" "$HAPROXY_IMAGE" +[ -n "$OLD_CADDY_ID" ] && docker tag "$OLD_CADDY_ID" "$CADDY_IMAGE" + +docker compose up -d --no-deps caddy haproxy >> "$LOG_FILE" 2>&1 + +ROLLBACK_OK=1 +for svc in caddy haproxy; do + if wait_healthy "$svc"; then + log " -> $svc: nach Rollback wieder healthy" + else + log " -> $svc: KRITISCH - auch nach Rollback nicht healthy, bitte manuell pruefen (docker compose logs $svc)" + ROLLBACK_OK=0 + fi +done + +if [ "$ROLLBACK_OK" -eq 1 ]; then + log "=== Update fehlgeschlagen, Rollback erfolgreich ===" +else + log "=== Update fehlgeschlagen, Rollback NICHT vollstaendig erfolgreich - manuelles Eingreifen noetig ===" +fi +run_hook "post_update_hook.sh" +exit 2