Initial commit
This commit is contained in:
@@ -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
|
||||
+11
@@ -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).
|
||||
@@ -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
|
||||
@@ -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 <eure-repo-url> 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}}' <image>`).
|
||||
- **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.
|
||||
@@ -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: <container-name-im-edge-netzwerk>:<port>
|
||||
# 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
|
||||
@@ -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:
|
||||
+377
@@ -0,0 +1,377 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# generate.sh - liest config/sites.yaml und erzeugt daraus:
|
||||
# - generated/Caddyfile
|
||||
# - generated/haproxy.cfg
|
||||
# - generated/acl/<name>.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 <<EOF
|
||||
{
|
||||
email $ACME_EMAIL
|
||||
admin off
|
||||
servers {
|
||||
trusted_proxies static private_ranges
|
||||
}
|
||||
}
|
||||
|
||||
EOF
|
||||
|
||||
for i in "${!DOMAIN_NAME[@]}"; do
|
||||
name="${DOMAIN_NAME[$i]}"
|
||||
backend="${DOMAIN_BACKEND[$i]}"
|
||||
scheme="${DOMAIN_SCHEME[$i]}"
|
||||
tls_insecure="${DOMAIN_TLS_INSECURE[$i]}"
|
||||
tls_mode="${DOMAIN_TLS_MODE[$i]}"
|
||||
|
||||
if [ "$tls_mode" = "off" ]; then
|
||||
echo "http://$name {"
|
||||
else
|
||||
echo "$name {"
|
||||
fi
|
||||
[ "$tls_mode" = "internal" ] && echo " tls internal"
|
||||
echo " encode gzip zstd"
|
||||
echo ""
|
||||
echo " reverse_proxy $scheme://$backend {"
|
||||
if [ "$tls_insecure" = "true" ]; then
|
||||
echo " transport http {"
|
||||
echo " tls"
|
||||
echo " tls_insecure_skip_verify"
|
||||
echo " }"
|
||||
fi
|
||||
echo " header_up X-Forwarded-Proto {scheme}"
|
||||
echo " }"
|
||||
echo ""
|
||||
echo " log {"
|
||||
echo " output stdout"
|
||||
echo " format json"
|
||||
echo " }"
|
||||
echo "}"
|
||||
echo ""
|
||||
done
|
||||
} > "$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"
|
||||
@@ -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: []
|
||||
@@ -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:
|
||||
#
|
||||
# GLOBAL<TAB>acme_email
|
||||
# DOMAIN<TAB>name<TAB>backend<TAB>backend_scheme<TAB>backend_tls_insecure<TAB>tls_mode<TAB>allowed_ips(komma-getrennt)
|
||||
# TCP<TAB>name<TAB>listen_port<TAB>backend<TAB>allowed_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
|
||||
}
|
||||
@@ -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)"
|
||||
@@ -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)"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 <repo> /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
|
||||
Reference in New Issue
Block a user