Initial commit
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user