tcp stream (und damit: tls passthrough) ist implementiert

This commit is contained in:
br committed 2026-10-11 01:04:45 +02:00
1 parent 6a5cb95f92
commit fdcd06c93f
16 files changed
+1008 -75

No files matched your search

+10 -1
View File
@@ -187,6 +187,14 @@ Umsetzung in `pkg/policy`. Jeder Verstoß entfernt nur den betroffenen Router un
* Ohne Feature `custom_middleware` sind nur Basistypen erlaubt: redirectScheme, redirectRegex, stripPrefix(Regex), addPrefix, replacePath(Regex), compress, headers. * Ohne Feature `custom_middleware` sind nur Basistypen erlaubt: redirectScheme, redirectRegex, stripPrefix(Regex), addPrefix, replacePath(Regex), compress, headers.
* `chain` und `errors` sind **immer** verboten, weil sie per Namen auf andere Middlewares/Services verweisen und den Namensraum umgehen könnten. * `chain` und `errors` sind **immer** verboten, weil sie per Namen auf andere Middlewares/Services verweisen und den Namensraum umgehen könnten.
* Nicht referenzierte Services/Middlewares werden nicht ausgeliefert. * Nicht referenzierte Services/Middlewares werden nicht ausgeliefert.
* **TCP / TLS-Passthrough (umgesetzt 2026-10-11):** für Dienste mit eigener CA bzw. eigener TLS-Verwaltung (z. B. tinyCA im Kundenlabor). Traefik routet per SNI und reicht die TLS-Verbindung unverändert zum Backend durch.
* Labels: `traefik.tcp.routers.<n>.{rule,entrypoints,service,priority,tls,tls.passthrough}` und `traefik.tcp.services.<n>.loadbalancer.server.port`. TCP-Middlewares, TLS-Terminierung (certresolver/options), proxyProtocol und UDP bleiben `ErrUnsupportedLabel`.
* Nur mit Feature **`tcp_stream`** (in jedem Tier vergebbar, auch `community`).
* Nur **`tls.passthrough=true`**, nur Entrypoint **`websecure`** (wird immer gesetzt; ein anderer Entrypoint ist ein Verstoß).
* Regel: Jeder `||`-Zweig muss durch `HostSNI(...)` beschränkt sein, **nur explizite Hosts**: ``HostSNI(`*`)``, Wildcards und `HostSNIRegexp` sind verboten. Jeder Host muss in `allowed_domains` stehen; eine Wildcard dort (`*.lab.kunde.de`) deckt einzelne Hosts darunter.
* Jeder TCP-Router zählt gegen `max_routes`; HTTP-Router werden zuerst vergeben, danach TCP (jeweils nach Namen).
* Gleicher Namensraum `<tenant>__<node>__<name>`.
* **Stufen:** Ohne HTTP gibt es keinen Interceptor. Mahnstufe 1/2 lassen durch; ab Paywall (auch `landing`) sowie bei jeder Wartung (Knoten, Dienst, Healthcheck, getrennter Knoten) wird die Route **entfernt**; Sperre/drain wie bei HTTP.
### 3.4 Bedrohungsmodell: Erstkontakt (TOFU) und Server-Events ### 3.4 Bedrohungsmodell: Erstkontakt (TOFU) und Server-Events
@@ -794,6 +802,7 @@ services:
| 2026-10-02 | **Build & Auslieferung:** ein distroless-Image (nonroot) für Server und Agent, Multi-Arch amd64+arm64 per buildx mit Go-Cross-Compile (kein QEMU); Registry: eigener **Zot**-Server `zot.chmln.de`, Image `zot.chmln.de/sultan` (ohne Owner-Ebene, daher kein Rewrite-Proxy nötig); Zugangsdaten für den Push nur in `deploy/.env` (gitignored), ohne Passwort bricht `push-image.sh` ab. (Ursprünglich geplant: Gitea-Registry hinter Rewrite-Proxy – verworfen.) Binaries + `SHA256SUMS` statisch unter `sultan/releases/<version>/`, `releases/stable` als Zeiger. Agent-Container mit `network_mode: host`. Vorerst manuell über `deploy/*.sh`, später Gitea Actions bei Tags. | Kein Go-Stack beim Nutzer; ein Image für alle Architekturen; Agent meldet sonst die Container-IP als Backend; eigene Registry mit kurzen Image-Namen ohne Proxy-Tricks. | | 2026-10-02 | **Build & Auslieferung:** ein distroless-Image (nonroot) für Server und Agent, Multi-Arch amd64+arm64 per buildx mit Go-Cross-Compile (kein QEMU); Registry: eigener **Zot**-Server `zot.chmln.de`, Image `zot.chmln.de/sultan` (ohne Owner-Ebene, daher kein Rewrite-Proxy nötig); Zugangsdaten für den Push nur in `deploy/.env` (gitignored), ohne Passwort bricht `push-image.sh` ab. (Ursprünglich geplant: Gitea-Registry hinter Rewrite-Proxy – verworfen.) Binaries + `SHA256SUMS` statisch unter `sultan/releases/<version>/`, `releases/stable` als Zeiger. Agent-Container mit `network_mode: host`. Vorerst manuell über `deploy/*.sh`, später Gitea Actions bei Tags. | Kein Go-Stack beim Nutzer; ein Image für alle Architekturen; Agent meldet sonst die Container-IP als Backend; eigene Registry mit kurzen Image-Namen ohne Proxy-Tricks. |
| 2026-10-02 | **Kleinere Punkte:** Rate-Limit `/enroll` 10/min/IP vor der Signaturprüfung (`-1` = aus); `SULTAN_<FLAG>` für alle Flags, Flags gewinnen; „wieder online“ nur nach Offline-Alarm; Checkmk-Zeiten in Server-Zeitzone. Ein Agent, dessen Enrollment mit 429 scheitert, beendet sich; der Neustart erfolgt über systemd bzw. Docker `restart`. | Schutz vor Durchprobieren; Container-/systemd-freundliche Konfiguration; vollständige Alarmkette. | | 2026-10-02 | **Kleinere Punkte:** Rate-Limit `/enroll` 10/min/IP vor der Signaturprüfung (`-1` = aus); `SULTAN_<FLAG>` für alle Flags, Flags gewinnen; „wieder online“ nur nach Offline-Alarm; Checkmk-Zeiten in Server-Zeitzone. Ein Agent, dessen Enrollment mit 429 scheitert, beendet sich; der Neustart erfolgt über systemd bzw. Docker `restart`. | Schutz vor Durchprobieren; Container-/systemd-freundliche Konfiguration; vollständige Alarmkette. |
| 2026-10-03 | **Gestoppter Container = Wartungsseite, entfernter = Route weg.** Der Agent meldet gestoppte Container mit (Ports aus `PortBindings`) und deren Services als `down_services`; laufende Container gewinnen bei Namensgleichheit. Leerer `--public-url`-Wert wird ignoriert; `sultan version` zeigt die Zeitzone; `TZ` kommt über Compose (distroless enthält tzdata). Server-Compose mit `network_mode: host` neben Traefik. | Erster Test gegen echtes Traefik: `docker stop` lieferte 404 statt 503. Abschalten bleibt über Entfernen möglich. | | 2026-10-03 | **Gestoppter Container = Wartungsseite, entfernter = Route weg.** Der Agent meldet gestoppte Container mit (Ports aus `PortBindings`) und deren Services als `down_services`; laufende Container gewinnen bei Namensgleichheit. Leerer `--public-url`-Wert wird ignoriert; `sultan version` zeigt die Zeitzone; `TZ` kommt über Compose (distroless enthält tzdata). Server-Compose mit `network_mode: host` neben Traefik. | Erster Test gegen echtes Traefik: `docker stop` lieferte 404 statt 503. Abschalten bleibt über Entfernen möglich. |
| 2026-10-11 | **TCP nur als TLS-Passthrough** (Feature `tcp_stream`, auch Community): `HostSNI` mit expliziten Hosts aus `allowed_domains`, keine Wildcard-/Regexp-Router; nur Entrypoint `websecure`; zählt gegen `max_routes`. Ohne Interceptor: Mahnhinweis lässt durch, ab Paywall und bei Wartung Route entfernt. UDP und übrige TCP-Labels weiterhin abgelehnt. | Kunden mit eigener CA/TLS (tinyCA) brauchen Passthrough; DNS-Wildcard ist kein Grund für einen Wildcard-Router – host-genau bleibt prüf- und abrechenbar. Seiten lassen sich bei TCP nicht einblenden. |
| geplant | Geordneter Zertifikatswechsel; optionale Root-CA. (Signierte Server-Events: umgesetzt.) | Zweite Sicherheitsschicht neben dem TLS-Pin; Betrieb ohne Aussperren. | | geplant | Geordneter Zertifikatswechsel; optionale Root-CA. (Signierte Server-Events: umgesetzt.) | Zweite Sicherheitsschicht neben dem TLS-Pin; Betrieb ohne Aussperren. |
--- ---
@@ -804,7 +813,7 @@ services:
| Package | Inhalt | | Package | Inhalt |
|---|---| |---|---|
| `pkg/traefik` | Labels → Traefik-Konfiguration, Merge, JSON/YAML | | `pkg/traefik` | Labels → Traefik-Konfiguration (HTTP, TCP-Passthrough), Merge, JSON/YAML |
| `pkg/stream` | SSE-Hub (Server) und Client (Agent) inkl. Keepalive, Backoff, Disconnect | | `pkg/stream` | SSE-Hub (Server) und Client (Agent) inkl. Keepalive, Backoff, Disconnect |
| `pkg/store` | SQLite: Mandanten, Knoten, Enrollment | | `pkg/store` | SQLite: Mandanten, Knoten, Enrollment |
| `pkg/auth` | Ed25519-Request-Signatur, Verifier, Schlüsseldateien | | `pkg/auth` | Ed25519-Request-Signatur, Verifier, Schlüsseldateien |
+3 -3
View File
@@ -10,14 +10,14 @@ Maßgeblich ist aktuell [.ai/sultan-system-architecture.md](.ai/sultan-system-ar
## Projektstatus ## Projektstatus
Go-Modul `sultan` (Go 1.26). `main.go` ist nur eine Hülle um `pkg/cli`; umgesetzt sind `sultan server|agent|token|dump-landing|version|help`. Packages: Go-Modul `sultan` (Go 1.26). `main.go` ist nur eine Hülle um `pkg/cli`; umgesetzt sind `sultan server|agent|token|dump-landing|version|help`. Packages:
- [pkg/traefik](pkg/traefik) – übersetzt flache `traefik.*`-Labels (`FromLabels`) in Traefiks dynamische Konfiguration, führt Knoten-Configs zusammen (`Merge`) und rendert JSON/YAML (`Marshal`) für `/v1/traefik-config`. Traefik *pollt* diesen Endpunkt über seinen HTTP-Provider; Sultan schreibt nie in Traefik und emuliert keinen Docker-Socket. - [pkg/traefik](pkg/traefik) – übersetzt flache `traefik.*`-Labels (`FromLabels`, HTTP und TCP für TLS-Passthrough) in Traefiks dynamische Konfiguration, führt Knoten-Configs zusammen (`Merge`) und rendert JSON/YAML (`Marshal`) für `/v1/traefik-config`. Traefik *pollt* diesen Endpunkt über seinen HTTP-Provider; Sultan schreibt nie in Traefik und emuliert keinen Docker-Socket.
- [pkg/stream](pkg/stream) – Server→Agent-Kanal als **SSE**: `Hub` (Server, ein Stream pro Knoten, Keepalive `:\n\n`, Neuverbindung ersetzt alte mit `ErrReplaced`, `OnConnect`/`OnDisconnect` nur bei echtem Online/Offline), `Client` (Agent, Idle-Timeout 90s, `Run` mit exponentiellem Backoff, `Prepare` zum Signieren), `Decoder`. Authentifizierung macht der Aufrufer vor `Hub.Serve`. Agent→Server (`service_state`) läuft *nicht* über diesen Stream, sondern über eigene Requests. - [pkg/stream](pkg/stream) – Server→Agent-Kanal als **SSE**: `Hub` (Server, ein Stream pro Knoten, Keepalive `:\n\n`, Neuverbindung ersetzt alte mit `ErrReplaced`, `OnConnect`/`OnDisconnect` nur bei echtem Online/Offline), `Client` (Agent, Idle-Timeout 90s, `Run` mit exponentiellem Backoff, `Prepare` zum Signieren), `Decoder`. Authentifizierung macht der Aufrufer vor `Hub.Serve`. Agent→Server (`service_state`) läuft *nicht* über diesen Stream, sondern über eigene Requests.
- [pkg/store](pkg/store) – SQLite-Persistenz (Schema Spec 4.3) über `modernc.org/sqlite` (reines Go, kein cgo → statisches Binary). Migrationen sind eine append-only Liste in `store.go`, Version in `PRAGMA user_version`; neuere DB-Version → `ErrSchemaTooNew`. Zeitstempel werden aus Go gesetzt (injizierbare Uhr `Store.now`) und als UTC-Text fester Breite gespeichert, damit SQL-Vergleiche funktionieren – keine `CURRENT_TIMESTAMP`-Defaults verwenden. Enrollment-Tokens nur als SHA-256; `Enroll` verbraucht Token und legt Knoten atomar an. Fehler: `ErrNotFound`/`ErrExists`/`ErrInvalid`/`ErrTokenUsed`/`ErrTokenExpired`. - [pkg/store](pkg/store) – SQLite-Persistenz (Schema Spec 4.3) über `modernc.org/sqlite` (reines Go, kein cgo → statisches Binary). Migrationen sind eine append-only Liste in `store.go`, Version in `PRAGMA user_version`; neuere DB-Version → `ErrSchemaTooNew`. Zeitstempel werden aus Go gesetzt (injizierbare Uhr `Store.now`) und als UTC-Text fester Breite gespeichert, damit SQL-Vergleiche funktionieren – keine `CURRENT_TIMESTAMP`-Defaults verwenden. Enrollment-Tokens nur als SHA-256; `Enroll` verbraucht Token und legt Knoten atomar an. Fehler: `ErrNotFound`/`ErrExists`/`ErrInvalid`/`ErrTokenUsed`/`ErrTokenExpired`.
- [pkg/auth](pkg/auth) – Ed25519-Request-Signatur, **in jedem Tier Pflicht** (kein Bypass, auch nicht für `community`/Homelab). `Signer.Sign` (passt als `stream.Client.Prepare`) signiert Methode, Pfad+Query, Zeitstempel, Nonce und Body-Hash (`X-Sultan-*`-Header). `Verifier.Verify` prüft in der Reihenfolge TLS → Signatur → Zeitfenster (±5min) → Nonce → Store, liefert `Identity{Node, Tenant}`; die Knoten-ID für `Hub.Serve` kommt *nur* daraus. `VerifyUnregistered` für Enrollment (Besitznachweis des Schlüssels). Gesperrt (`ErrForbidden`) wird nur bei Quarantäne; Zahlungsverzug/Ablauf/Drain regelt die Policy über das Routing. `StatusCode(err)` für HTTP-Antworten. Schlüsseldateien: PKCS#8-PEM, 0600, werden nie überschrieben. - [pkg/auth](pkg/auth) – Ed25519-Request-Signatur, **in jedem Tier Pflicht** (kein Bypass, auch nicht für `community`/Homelab). `Signer.Sign` (passt als `stream.Client.Prepare`) signiert Methode, Pfad+Query, Zeitstempel, Nonce und Body-Hash (`X-Sultan-*`-Header). `Verifier.Verify` prüft in der Reihenfolge TLS → Signatur → Zeitfenster (±5min) → Nonce → Store, liefert `Identity{Node, Tenant}`; die Knoten-ID für `Hub.Serve` kommt *nur* daraus. `VerifyUnregistered` für Enrollment (Besitznachweis des Schlüssels). Gesperrt (`ErrForbidden`) wird nur bei Quarantäne; Zahlungsverzug/Ablauf/Drain regelt die Policy über das Routing. `StatusCode(err)` für HTTP-Antworten. Schlüsseldateien: PKCS#8-PEM, 0600, werden nie überschrieben.
- **Kein Klartext:** `Signer` signiert nur `https`-URLs, `Verifier` lehnt Requests ohne `r.TLS` ab (`ErrInsecureTransport`, 426). Tests für Agent↔Server laufen daher über TLS-Testserver. - **Kein Klartext:** `Signer` signiert nur `https`-URLs, `Verifier` lehnt Requests ohne `r.TLS` ab (`ErrInsecureTransport`, 426). Tests für Agent↔Server laufen daher über TLS-Testserver.
- [pkg/crypto](pkg/crypto) – Transport: `LoadOrCreateServerCert(dataDir)` (selbstsigniert ECDSA P-256, keine CA, 20 Jahre, `server.key` 0600; fehlt eine der Dateien → Fehler statt Neuerzeugung), `ServerTLSConfig` (nur TLS 1.3), `PinnedTLSConfig(fingerprint)` – Pinning im Handshake am Fingerprint, **unabhängig von der Adresse**; ohne Fingerprint `ErrUnknownServer` (kein blindes TOFU). Enrollment-Token `sltn_enroll_<secret>.<fingerprint>[.<base64url(server-url)>]` (`NewEnrollmentToken(fp, url)`/`ParseEnrollmentToken`, `ValidateServerURL`); in den Store kommt nur `Token.Secret`. Paketname kollidiert mit Stdlib-`crypto` – bei Bedarf beim Import aliasen. - [pkg/crypto](pkg/crypto) – Transport: `LoadOrCreateServerCert(dataDir)` (selbstsigniert ECDSA P-256, keine CA, 20 Jahre, `server.key` 0600; fehlt eine der Dateien → Fehler statt Neuerzeugung), `ServerTLSConfig` (nur TLS 1.3), `PinnedTLSConfig(fingerprint)` – Pinning im Handshake am Fingerprint, **unabhängig von der Adresse**; ohne Fingerprint `ErrUnknownServer` (kein blindes TOFU). Enrollment-Token `sltn_enroll_<secret>.<fingerprint>[.<base64url(server-url)>]` (`NewEnrollmentToken(fp, url)`/`ParseEnrollmentToken`, `ValidateServerURL`); in den Store kommt nur `Token.Secret`. Paketname kollidiert mit Stdlib-`crypto` – bei Bedarf beim Import aliasen.
- [pkg/license](pkg/license) – Lizenz nach Spec 4.1: `Issuer.Issue` (normalisiert + signiert mit Master-Key, Präfix `SULTAN-LICENSE-V1`), `Parse` (strikt, unbekannte Felder abgelehnt) / `Verify`, `AllowsDomain` (`*.x.de` = alle Subdomains, nicht Apex; `*.<tld>` verboten), `AllowsRoutes` (-1 = unbegrenzt), `HasFeature`, `Expired`/`DaysLeft`. - [pkg/license](pkg/license) – Lizenz nach Spec 4.1: `Issuer.Issue` (normalisiert + signiert mit Master-Key, Präfix `SULTAN-LICENSE-V1`), `Parse` (strikt, unbekannte Felder abgelehnt) / `Verify`, `AllowsDomain` (`*.x.de` = alle Subdomains, nicht Apex; `*.<tld>` verboten), `AllowsRoutes` (-1 = unbegrenzt), `HasFeature`, `Expired`/`DaysLeft`.
- [pkg/policy](pkg/policy) – `HostsFromRule` (echter Parser für Traefik-Regeln; jeder `||`-Zweig muss Host-beschränkt sein, `HostRegexp` verboten), `StageFor` (blocked/landing/paywall/firemark/watermark, Mahnwesen nur kommerziell) und `Engine.Apply`: prüft Router (Domain, Referenzen, Middleware-Gates, Routenlimit), Namensraum `<tenant>__<node>__<name>`, leitet bei Stufe ≠ normal oder Healthcheck-Ausfall zu `sultan__interceptor` um (Header `X-Sultan-Stage/Tenant/Tier`); bei watermark/firemark zusätzlich Durchlass-Router `sultan__<tenant>__<node>__<router>` per Cookie `sultan_ack=<stufe>`. `DunningSchedule` (8/25/32 Tage) berechnet die Mahnstufe. Wartung: `Input.Maintenance` (Knoten) und `Input.ServiceMaintenance` (Label `sultan.maintenance`), Vorrang Ablauf/Paywall > Wartung > Mahnhinweis, Header `X-Sultan-Until`/`X-Sultan-Public-Reason` (URL-kodiert). Andockstelle `MiddlewareTargetCheck` (+ `EnforceMiddlewareTargets`) für die geplante Middleware-Zielbegrenzung; nil = dulden. - [pkg/policy](pkg/policy) – `HostsFromRule` (echter Parser für Traefik-Regeln; jeder `||`-Zweig muss Host-beschränkt sein, `HostRegexp` verboten), `StageFor` (blocked/landing/paywall/firemark/watermark, Mahnwesen nur kommerziell) und `Engine.Apply`: prüft Router (Domain, Referenzen, Middleware-Gates, Routenlimit), Namensraum `<tenant>__<node>__<name>`, leitet bei Stufe ≠ normal oder Healthcheck-Ausfall zu `sultan__interceptor` um (Header `X-Sultan-Stage/Tenant/Tier`); bei watermark/firemark zusätzlich Durchlass-Router `sultan__<tenant>__<node>__<router>` per Cookie `sultan_ack=<stufe>`. `DunningSchedule` (8/25/32 Tage) berechnet die Mahnstufe. Wartung: `Input.Maintenance` (Knoten) und `Input.ServiceMaintenance` (Label `sultan.maintenance`), Vorrang Ablauf/Paywall > Wartung > Mahnhinweis, Header `X-Sultan-Until`/`X-Sultan-Public-Reason` (URL-kodiert). Andockstelle `MiddlewareTargetCheck` (+ `EnforceMiddlewareTargets`) für die geplante Middleware-Zielbegrenzung; nil = dulden. TCP (`checkTCPRouter`/`tcpOutput`, `HostsFromSNIRule`): nur Feature `tcp_stream`, `tls.passthrough`, Entrypoint `TCPEntryPoint`, explizite `HostSNI`-Hosts; zählt gegen `max_routes` (HTTP zuerst); kein Interceptor → Route weg ab Paywall und bei Wartung.
- [pkg/registry](pkg/registry) – Aggregator: `Update(nodeID, NodeState)` (vollständiger Schnappschuss: Workloads mit Host+Labels, `DownServices`), `Remove` (der Server ruft es beim Trennen nur ohne aktive Wartung auf; `pruneDisconnected` räumt nach Wartungsende), `Refresh` (zeitabhängige Stufen, Directory-Änderungen; fail-static bei Fehlern, `ErrUnknownNode` entfernt), `Config`, `Handler` für `/v1/traefik-config`. `OnViolation` meldet jeden Verstoß nur beim ersten Auftreten. Mandant/Lizenz kommen über das `Directory`-Interface (Server-Implementierung: `pkg/server/directory.go`). Verstöße entfernen nur den Router und landen in `Result.Violations`. Ergebnisse mehrerer Knoten mit `traefik.Merge` kombinieren. - [pkg/registry](pkg/registry) – Aggregator: `Update(nodeID, NodeState)` (vollständiger Schnappschuss: Workloads mit Host+Labels, `DownServices`), `Remove` (der Server ruft es beim Trennen nur ohne aktive Wartung auf; `pruneDisconnected` räumt nach Wartungsende), `Refresh` (zeitabhängige Stufen, Directory-Änderungen; fail-static bei Fehlern, `ErrUnknownNode` entfernt), `Config`, `Handler` für `/v1/traefik-config`. `OnViolation` meldet jeden Verstoß nur beim ersten Auftreten. Mandant/Lizenz kommen über das `Directory`-Interface (Server-Implementierung: `pkg/server/directory.go`). Verstöße entfernen nur den Router und landen in `Result.Violations`. Ergebnisse mehrerer Knoten mit `traefik.Merge` kombinieren.
- [pkg/server](pkg/server) – `sultan server`: `New` (Datenverzeichnis 0700, DB, Zertifikat, `master.key`), `Start`/`Run`/`Shutdown` (Hub zuerst schließen, sonst hängen SSE-Requests), Routen beider Ports (`handlers.go`), Knotenstatus über Hub-Callbacks, Refresh-Schleife, Audit-Log „Policy-Verstoß“ per `slog`. Tests laufen gegen echte Ports (`127.0.0.1:0`) mit Test-Agent (Pinning + Signatur). - [pkg/server](pkg/server) – `sultan server`: `New` (Datenverzeichnis 0700, DB, Zertifikat, `master.key`), `Start`/`Run`/`Shutdown` (Hub zuerst schließen, sonst hängen SSE-Requests), Routen beider Ports (`handlers.go`), Knotenstatus über Hub-Callbacks, Refresh-Schleife, Audit-Log „Policy-Verstoß“ per `slog`. Tests laufen gegen echte Ports (`127.0.0.1:0`) mit Test-Agent (Pinning + Signatur).
@@ -50,7 +50,7 @@ go test -run TestFromLabels_Errors ./pkg/traefik # einzelner Test
go test -cover ./... go test -cover ./...
``` ```
Middleware-Optionen werden bewusst als unveränderte Label-Strings in verschachtelten Maps durchgereicht (Traefik parst sie beim Einlesen wie Labels); nur Router- und Service-Felder sind typisiert. Nicht unterstützte Labels (TCP/UDP, unbekannte Attribute) liefern `ErrUnsupportedLabel` statt still ignoriert zu werden. Middleware-Optionen werden bewusst als unveränderte Label-Strings in verschachtelten Maps durchgereicht (Traefik parst sie beim Einlesen wie Labels); nur Router- und Service-Felder sind typisiert. Nicht unterstützte Labels (UDP, TCP außer Passthrough-Routern/Port, unbekannte Attribute) liefern `ErrUnsupportedLabel` statt still ignoriert zu werden. Der Agent rechnet Ports für `http` und `tcp` getrennt um (`protocols` in `workloads.go`).
## Big Picture ## Big Picture
+20
View File
@@ -233,6 +233,26 @@ services:
- Änderungen (Start, Stop, neue Labels) kommen innerhalb einer Sekunde bei Traefik an; Neustarts sind nicht nötig. - Änderungen (Start, Stop, neue Labels) kommen innerhalb einer Sekunde bei Traefik an; Neustarts sind nicht nötig.
- Nicht übernommene Container stehen mit Grund im Agent-Log („Container nicht übernommen“). Fremde Domains landen im Server-Log („Policy-Verstoß“). - Nicht übernommene Container stehen mit Grund im Agent-Log („Container nicht übernommen“). Fremde Domains landen im Server-Log („Policy-Verstoß“).
#### TLS-Passthrough (eigene CA, z. B. tinyCA)
Soll TLS erst beim Backend enden, braucht der Mandant das Feature `tcp_stream` (`tenant add … --features tcp_stream`). Dann:
```yaml
ports:
- "9000:9000"
labels:
- "traefik.enable=true"
- "traefik.tcp.routers.ca.rule=HostSNI(`ca.lab.example.de`)"
- "traefik.tcp.routers.ca.entrypoints=websecure"
- "traefik.tcp.routers.ca.tls.passthrough=true"
- "traefik.tcp.services.ca.loadbalancer.server.port=9000"
```
- Nur einzelne Hosts in `HostSNI` (mehrere mit `||`). ``HostSNI(`*`)``, `*.lab.example.de` und `HostSNIRegexp` werden abgelehnt, auch wenn das DNS eine Wildcard hat. In den Mandanten-Domains darf `*.lab.example.de` stehen.
- Nur Entrypoint `websecure` (443). Andere Hosts auf 443 laufen weiter normal über HTTP.
- Jeder TCP-Router zählt als Route.
- Bei Wartung oder gestopptem Container gibt es keine Wartungsseite, die Route ist dann weg.
### 3.4 Erreichbarkeit Traefik → Backend ### 3.4 Erreichbarkeit Traefik → Backend
Der Agent meldet `http://<backend-ip>:<veröffentlichter Port>`. Prüfen vom **Ingress** aus: Der Agent meldet `http://<backend-ip>:<veröffentlichter Port>`. Prüfen vom **Ingress** aus:
+2 -2
View File
@@ -25,9 +25,9 @@ Aus dem Vertrag gehen diese Werte in den Mandanten:
| Name, Kontakt | `--name`, `--email` | für dich (Listen, Benachrichtigungen) | | Name, Kontakt | `--name`, `--email` | für dich (Listen, Benachrichtigungen) |
| Domains | `--domains` | `kunde.de` = genau diese; `*.kunde.de` = alle Subdomains, **ohne** `kunde.de` selbst → meist beides eintragen. Wildcard auf TLD (`*.de`) ist verboten. Umlaut-Domains als Punycode (`xn--…`). | | Domains | `--domains` | `kunde.de` = genau diese; `*.kunde.de` = alle Subdomains, **ohne** `kunde.de` selbst → meist beides eintragen. Wildcard auf TLD (`*.de`) ist verboten. Umlaut-Domains als Punycode (`xn--…`). |
| Paket | `--tier pro` oder `enterprise` | Beide verhalten sich technisch derzeit **gleich** (Mahnwesen, Vertragsende). Der Unterschied ist vertraglich. `community` = kein Mahnwesen, läuft nicht ab. | | Paket | `--tier pro` oder `enterprise` | Beide verhalten sich technisch derzeit **gleich** (Mahnwesen, Vertragsende). Der Unterschied ist vertraglich. `community` = kein Mahnwesen, läuft nicht ab. |
| Routen | `--max-routes` | Anzahl Router (jeder `traefik.http.routers.<name>` zählt, auch Redirect-Router). `-1` = unbegrenzt. Überzählige werden alphabetisch verworfen. | | Routen | `--max-routes` | Anzahl Router (jeder `traefik.http.routers.<name>` und `traefik.tcp.routers.<name>` zählt, auch Redirect-Router). `-1` = unbegrenzt. Überzählige werden alphabetisch verworfen. |
| Vertragsende | `--valid-until JJJJ-MM-TT` | Default: heute + 1 Jahr. **Danach sehen Besucher die Landingpage „Hosting abgelaufen“.** | | Vertragsende | `--valid-until JJJJ-MM-TT` | Default: heute + 1 Jahr. **Danach sehen Besucher die Landingpage „Hosting abgelaufen“.** |
| Zusatzfunktionen | `--features` | `custom_middleware`, `websockets`, `tcp_stream`. Ohne `custom_middleware` sind nur Standard-Middlewares erlaubt (Redirects, Pfad-Umschreibung, Header, Kompression). BasicAuth, Rate-Limit, IP-Allowlist usw. brauchen das Feature, sonst wird der Router mit Verstoß abgelehnt. `chain` und `errors` sind nie erlaubt. | | Zusatzfunktionen | `--features` | `custom_middleware`, `websockets`, `tcp_stream`. Ohne `custom_middleware` sind nur Standard-Middlewares erlaubt (Redirects, Pfad-Umschreibung, Header, Kompression). BasicAuth, Rate-Limit, IP-Allowlist usw. brauchen das Feature, sonst wird der Router mit Verstoß abgelehnt. `chain` und `errors` sind nie erlaubt. `tcp_stream` erlaubt TLS-Passthrough (Kunde mit eigener CA/TLS, z. B. tinyCA): nur `HostSNI` mit einzelnen Hosts aus den Domains, nur Port 443; ab Paywall oder bei Wartung ist die Route ganz weg (keine Hinweisseite möglich). |
**Achtung:** Vertragsende, Domains und Paket lassen sich bisher **nicht nachträglich ändern** (`tenant update` fehlt, siehe Lücken). Deshalb `--valid-until` und `--domains` beim Anlegen sorgfältig setzen. **Achtung:** Vertragsende, Domains und Paket lassen sich bisher **nicht nachträglich ändern** (`tenant update` fehlt, siehe Lücken). Deshalb `--valid-until` und `--domains` beim Anlegen sorgfältig setzen.
+46 -19
View File
@@ -13,13 +13,19 @@ import (
"sultan/pkg/registry" "sultan/pkg/registry"
) )
const servicesPrefix = "traefik.http.services." // protocols sind die Label-Ebenen mit Routern und Services (HTTP und TCP
// für TLS-Passthrough). Router- und Service-Namen sind je Ebene getrennt.
var protocols = []string{"http", "tcp"}
func routersPrefix(proto string) string { return "traefik." + proto + ".routers." }
func servicesPrefix(proto string) string { return "traefik." + proto + ".services." }
var unsafeName = regexp.MustCompile(`[^A-Za-z0-9_-]`) var unsafeName = regexp.MustCompile(`[^A-Za-z0-9_-]`)
// Workloads übersetzt Container in Workloads für service_state. // Workloads übersetzt Container in Workloads für service_state.
// //
// Berücksichtigt werden nur Container mit traefik.enable=true. Weil der // Berücksichtigt werden nur Container mit traefik.enable=true. Die Regeln
// gelten für traefik.http.* und traefik.tcp.* (TLS-Passthrough) getrennt. Weil der
// Ingress auf einem anderen Host sitzt, wird loadbalancer.server.port (Port // Ingress auf einem anderen Host sitzt, wird loadbalancer.server.port (Port
// im Container) auf den veröffentlichten Host-Port umgerechnet. Fehlt das // im Container) auf den veröffentlichten Host-Port umgerechnet. Fehlt das
// Port-Label, gilt wie bei Traefiks Docker-Provider der einzige // Port-Label, gilt wie bei Traefiks Docker-Provider der einzige
@@ -36,8 +42,8 @@ func Workloads(containers []docker.Container, host string) (workloads []registry
type candidate struct { type candidate struct {
workload registry.Workload workload registry.Workload
running bool running bool
routers []string routers []string // "<proto>:<name>"
services []string services []string // "<proto>:<name>"
} }
var candidates []candidate var candidates []candidate
taken := map[string]bool{} // Namen laufender Container, "r:"/"s:" + klein taken := map[string]bool{} // Namen laufender Container, "r:"/"s:" + klein
@@ -53,8 +59,14 @@ func Workloads(containers []docker.Container, host string) (workloads []registry
cand := candidate{ cand := candidate{
workload: registry.Workload{Name: c.Name(), Host: host, Labels: labels}, workload: registry.Workload{Name: c.Name(), Host: host, Labels: labels},
running: c.State == "running", running: c.State == "running",
routers: labelNames(labels, "traefik.http.routers."), }
services: labelNames(labels, servicesPrefix), for _, proto := range protocols {
for _, r := range labelNames(labels, routersPrefix(proto)) {
cand.routers = append(cand.routers, proto+":"+r)
}
for _, s := range labelNames(labels, servicesPrefix(proto)) {
cand.services = append(cand.services, proto+":"+s)
}
} }
if cand.running { if cand.running {
for _, r := range cand.routers { for _, r := range cand.routers {
@@ -84,7 +96,10 @@ func Workloads(containers []docker.Container, host string) (workloads []registry
if collides(c) { if collides(c) {
continue continue
} }
down = append(down, c.services...) for _, s := range c.services {
_, name, _ := strings.Cut(s, ":")
down = append(down, name)
}
} }
workloads = append(workloads, c.workload) workloads = append(workloads, c.workload)
} }
@@ -142,6 +157,18 @@ type serviceLabels struct {
func translatePorts(c docker.Container) (map[string]string, string) { func translatePorts(c docker.Container) (map[string]string, string) {
labels := maps.Clone(c.Labels) labels := maps.Clone(c.Labels)
reachable := reachablePorts(c.Ports) reachable := reachablePorts(c.Ports)
for _, proto := range protocols {
if warning := translateProtoPorts(c, labels, reachable, proto); warning != "" {
return nil, warning
}
}
return labels, ""
}
// translateProtoPorts rechnet die Service-Ports einer Ebene (http/tcp) in
// labels auf veröffentlichte Host-Ports um.
func translateProtoPorts(c docker.Container, labels map[string]string, reachable map[uint16]uint16, proto string) string {
svcPrefix := servicesPrefix(proto)
single := func() (uint16, bool) { single := func() (uint16, bool) {
for _, pub := range reachable { for _, pub := range reachable {
return pub, len(reachable) == 1 return pub, len(reachable) == 1
@@ -153,10 +180,10 @@ func translatePorts(c docker.Container) (map[string]string, string) {
hasRouters := false hasRouters := false
for k := range labels { for k := range labels {
lk := strings.ToLower(k) lk := strings.ToLower(k)
if strings.HasPrefix(lk, "traefik.http.routers.") { if strings.HasPrefix(lk, routersPrefix(proto)) {
hasRouters = true hasRouters = true
} }
rest, ok := strings.CutPrefix(lk, servicesPrefix) rest, ok := strings.CutPrefix(lk, svcPrefix)
if !ok { if !ok {
continue continue
} }
@@ -166,7 +193,7 @@ func translatePorts(c docker.Container) (map[string]string, string) {
} }
s := services[svc] s := services[svc]
if s == nil { if s == nil {
name, _, _ := strings.Cut(k[len(servicesPrefix):], ".") name, _, _ := strings.Cut(k[len(svcPrefix):], ".")
s = &serviceLabels{name: name} s = &serviceLabels{name: name}
services[svc] = s services[svc] = s
} }
@@ -177,35 +204,35 @@ func translatePorts(c docker.Container) (map[string]string, string) {
if len(services) == 0 { if len(services) == 0 {
if !hasRouters { if !hasRouters {
return labels, "" return ""
} }
pub, ok := single() pub, ok := single()
if !ok { if !ok {
return nil, fmt.Sprintf("Container %s: %d erreichbare Ports – Label loadbalancer.server.port angeben", c.Name(), len(reachable)) return fmt.Sprintf("Container %s: %d erreichbare Ports – Label loadbalancer.server.port angeben", c.Name(), len(reachable))
} }
svc := unsafeName.ReplaceAllString(c.Name(), "-") svc := unsafeName.ReplaceAllString(c.Name(), "-")
labels[servicesPrefix+svc+".loadbalancer.server.port"] = strconv.Itoa(int(pub)) labels[svcPrefix+svc+".loadbalancer.server.port"] = strconv.Itoa(int(pub))
return labels, "" return ""
} }
for _, s := range services { for _, s := range services {
if s.portKey == "" { if s.portKey == "" {
pub, ok := single() pub, ok := single()
if !ok { if !ok {
return nil, fmt.Sprintf("Container %s, Service %s: %d erreichbare Ports – Label loadbalancer.server.port angeben", c.Name(), s.name, len(reachable)) return fmt.Sprintf("Container %s, Service %s: %d erreichbare Ports – Label loadbalancer.server.port angeben", c.Name(), s.name, len(reachable))
} }
labels[servicesPrefix+s.name+".loadbalancer.server.port"] = strconv.Itoa(int(pub)) labels[svcPrefix+s.name+".loadbalancer.server.port"] = strconv.Itoa(int(pub))
continue continue
} }
private, err := strconv.Atoi(strings.TrimSpace(labels[s.portKey])) private, err := strconv.Atoi(strings.TrimSpace(labels[s.portKey]))
if err != nil { if err != nil {
return nil, fmt.Sprintf("Container %s: Port %q nicht lesbar", c.Name(), labels[s.portKey]) return fmt.Sprintf("Container %s: Port %q nicht lesbar", c.Name(), labels[s.portKey])
} }
pub, ok := reachable[uint16(private)] pub, ok := reachable[uint16(private)]
if !ok { if !ok {
return nil, fmt.Sprintf("Container %s: Port %d ist von außen nicht erreichbar (nicht veröffentlicht oder nur 127.0.0.1)", c.Name(), private) return fmt.Sprintf("Container %s: Port %d ist von außen nicht erreichbar (nicht veröffentlicht oder nur 127.0.0.1)", c.Name(), private)
} }
labels[s.portKey] = strconv.Itoa(int(pub)) labels[s.portKey] = strconv.Itoa(int(pub))
} }
return labels, "" return ""
} }
+78
View File
@@ -0,0 +1,78 @@
package agent
import (
"reflect"
"testing"
"sultan/pkg/docker"
)
func caLabels() map[string]string {
return map[string]string{
"traefik.enable": "true",
"traefik.tcp.routers.ca.rule": "HostSNI(`ca.lab.kunde.de`)",
"traefik.tcp.routers.ca.tls.passthrough": "true",
"traefik.tcp.services.ca.loadbalancer.server.port": "9000",
}
}
// TLS-Passthrough: Auch TCP-Services zeigen auf den veröffentlichten Host-Port.
func TestWorkloads_TCPTranslatesPublishedPort(t *testing.T) {
got, _, warnings := Workloads([]docker.Container{ctr("tinyca", caLabels(), pub(9000, 19000))}, "h")
if len(warnings) != 0 || len(got) != 1 {
t.Fatalf("got=%+v warnings=%v", got, warnings)
}
if p := got[0].Labels["traefik.tcp.services.ca.loadbalancer.server.port"]; p != "19000" {
t.Errorf("Port = %q", p)
}
_, _, warnings = Workloads([]docker.Container{ctr("tinyca", caLabels(), pub(80, 8080))}, "h")
if len(warnings) != 1 {
t.Errorf("unerreichbarer TCP-Port: Warnungen = %v", warnings)
}
}
func TestWorkloads_TCPDefaultService(t *testing.T) {
labels := map[string]string{"traefik.enable": "true", "traefik.tcp.routers.ca.rule": "HostSNI(`ca.lab.kunde.de`)"}
got, _, warnings := Workloads([]docker.Container{ctr("tiny.ca", labels, pub(9000, 19000))}, "h")
if len(warnings) != 0 || len(got) != 1 {
t.Fatalf("got=%+v warnings=%v", got, warnings)
}
if got[0].Labels["traefik.tcp.services.tiny-ca.loadbalancer.server.port"] != "19000" {
t.Errorf("Default-Service fehlt: %v", got[0].Labels)
}
if _, ok := got[0].Labels["traefik.http.services.tiny-ca.loadbalancer.server.port"]; ok {
t.Error("ohne HTTP-Router kein HTTP-Service")
}
}
// HTTP (z. B. Redirect auf :80) und TCP im selben Container.
func TestWorkloads_HTTPAndTCP(t *testing.T) {
labels := caLabels()
labels["traefik.http.routers.ca-http.rule"] = "Host(`ca.lab.kunde.de`)"
labels["traefik.http.services.ca-http.loadbalancer.server.port"] = "80"
got, _, warnings := Workloads([]docker.Container{ctr("tinyca", labels, pub(80, 8080), pub(9000, 19000))}, "h")
if len(warnings) != 0 || len(got) != 1 {
t.Fatalf("got=%+v warnings=%v", got, warnings)
}
l := got[0].Labels
if l["traefik.http.services.ca-http.loadbalancer.server.port"] != "8080" || l["traefik.tcp.services.ca.loadbalancer.server.port"] != "19000" {
t.Errorf("Labels = %v", l)
}
}
func TestWorkloads_TCPStoppedIsDown(t *testing.T) {
_, down, _ := Workloads([]docker.Container{stopped(ctr("tinyca", caLabels(), pub(9000, 19000)), "exited")}, "h")
if !reflect.DeepEqual(down, []string{"ca"}) {
t.Errorf("down = %v", down)
}
// Ein laufender Container mit demselben TCP-Router verdrängt den gestoppten.
got, down, _ := Workloads([]docker.Container{
stopped(ctr("a-alt", caLabels(), pub(9000, 19001)), "exited"),
ctr("b-neu", caLabels(), pub(9000, 19000)),
}, "h")
if len(got) != 1 || got[0].Name != "b-neu" || len(down) != 0 {
t.Errorf("got=%+v down=%v", got, down)
}
}
+141 -14
View File
@@ -13,6 +13,9 @@
// Mahnstufe 1/2 zeigt er einen Hinweis und leitet per // Mahnstufe 1/2 zeigt er einen Hinweis und leitet per
// <meta http-equiv="refresh"> zurück; ein Cookie lässt den Besucher // <meta http-equiv="refresh"> zurück; ein Cookie lässt den Besucher
// danach über einen zusätzlichen Router direkt zum Original durch. // danach über einen zusätzlichen Router direkt zum Original durch.
// - TCP (nur TLS-Passthrough, Feature tcp_stream): HostSNI mit expliziten
// eigenen Hosts, nur Entrypoint websecure. Ohne Interceptor entfällt die
// Route ab Paywall und bei jeder Wartung; Hinweisstufen lassen durch.
// //
// Verstöße entfernen den betroffenen Router und werden als Violation // Verstöße entfernen den betroffenen Router und werden als Violation
// gemeldet (für das Audit-Log); der Rest des Knotens bleibt aktiv. // gemeldet (für das Audit-Log); der Rest des Knotens bleibt aktiv.
@@ -56,6 +59,11 @@ const (
// Das Präfix "sultan__" ist reserviert (Mandanten-ID "sultan" ist verboten). // Das Präfix "sultan__" ist reserviert (Mandanten-ID "sultan" ist verboten).
const InterceptorService = "sultan__interceptor" const InterceptorService = "sultan__interceptor"
// TCPEntryPoint ist der einzige Entrypoint für TCP-Router (TLS-Passthrough
// per SNI auf :443). Sultan setzt ihn immer; ohne Angabe läge ein TCP-Router
// in Traefik auf allen Entrypoints.
const TCPEntryPoint = "websecure"
// Header, mit denen der Interceptor seinen Auftrag erhält. Traefik setzt sie // Header, mit denen der Interceptor seinen Auftrag erhält. Traefik setzt sie
// per customRequestHeaders und überschreibt damit Werte des Besuchers. // per customRequestHeaders und überschreibt damit Werte des Besuchers.
const ( const (
@@ -164,6 +172,8 @@ const (
ViolationFeature ViolationKind = "feature" ViolationFeature ViolationKind = "feature"
ViolationForbiddenMiddleware ViolationKind = "forbidden_middleware" ViolationForbiddenMiddleware ViolationKind = "forbidden_middleware"
ViolationMiddlewareTarget ViolationKind = "middleware_target" ViolationMiddlewareTarget ViolationKind = "middleware_target"
ViolationTCPPassthrough ViolationKind = "tcp_passthrough"
ViolationEntryPoint ViolationKind = "entrypoint"
) )
// Violation ist ein Verstoß eines Routers (Originalname). // Violation ist ein Verstoß eines Routers (Originalname).
@@ -254,14 +264,21 @@ func (e *Engine) Apply(in Input) (Result, error) {
at := now() at := now()
stage := StageFor(in.Tenant, in.License, at) stage := StageFor(in.Tenant, in.License, at)
res := Result{Config: &traefik.Configuration{}, Stage: stage} res := Result{Config: &traefik.Configuration{}, Stage: stage}
if stage == StageBlocked || in.NodeDrained || in.Config == nil || in.Config.HTTP == nil { if stage == StageBlocked || in.NodeDrained || in.Config == nil || (in.Config.HTTP == nil && in.Config.TCP == nil) {
return res, nil return res, nil
} }
src := in.Config.HTTP src := in.Config.HTTP
if src == nil {
src = &traefik.HTTPConfiguration{}
}
tcpSrc := in.Config.TCP
if tcpSrc == nil {
tcpSrc = &traefik.TCPConfiguration{}
}
// 1. Router einzeln prüfen (sortiert, damit Ergebnis und Routenlimit // 1. Router einzeln prüfen (sortiert, damit Ergebnis und Routenlimit
// deterministisch sind). // deterministisch sind).
var accepted []string var accepted, acceptedTCP []string
for _, name := range sortedKeys(src.Routers) { for _, name := range sortedKeys(src.Routers) {
blocked, observed := e.checkRouter(name, src.Routers[name], src, in.License) blocked, observed := e.checkRouter(name, src.Routers[name], src, in.License)
res.Violations = append(res.Violations, observed...) res.Violations = append(res.Violations, observed...)
@@ -271,21 +288,44 @@ func (e *Engine) Apply(in Input) (Result, error) {
} }
accepted = append(accepted, name) accepted = append(accepted, name)
} }
for _, name := range sortedKeys(tcpSrc.Routers) {
// 2. Routenlimit: überzählige Router (nach Namen) verwerfen. if v := e.checkTCPRouter(name, tcpSrc.Routers[name], tcpSrc, in.License); v != nil {
if !in.License.AllowsRoutes(len(accepted)) { res.Violations = append(res.Violations, *v)
for _, name := range accepted[in.License.MaxRoutes:] { continue
res.Violations = append(res.Violations, Violation{Kind: ViolationRouteLimit, Router: name,
Detail: fmt.Sprintf("max_routes = %d", in.License.MaxRoutes)})
} }
accepted = accepted[:in.License.MaxRoutes] acceptedTCP = append(acceptedTCP, name)
} }
if len(accepted) == 0 {
return res, nil // 2. Routenlimit über HTTP- und TCP-Router: HTTP zuerst, überzählige
// (nach Namen) verwerfen.
if !in.License.AllowsRoutes(len(accepted) + len(acceptedTCP)) {
limit := func(names []string, max int) []string {
max = min(max, len(names))
for _, name := range names[max:] {
res.Violations = append(res.Violations, Violation{Kind: ViolationRouteLimit, Router: name,
Detail: fmt.Sprintf("max_routes = %d", in.License.MaxRoutes)})
}
return names[:max]
}
accepted = limit(accepted, in.License.MaxRoutes)
acceptedTCP = limit(acceptedTCP, in.License.MaxRoutes-len(accepted))
} }
// 3. Ausgabe mit Namensraum und ggf. Umleitung zum Interceptor. // 3. Ausgabe mit Namensraum und ggf. Umleitung zum Interceptor.
prefix := in.Tenant.ID + "__" + in.NodeID + "__" prefix := in.Tenant.ID + "__" + in.NodeID + "__"
if len(accepted) > 0 {
out, err := e.httpOutput(in, src, accepted, stage, prefix, at)
if err != nil {
return Result{}, err
}
res.Config.HTTP = out
}
res.Config.TCP = tcpOutput(in, tcpSrc, acceptedTCP, stage, prefix, at)
return res, nil
}
// httpOutput erzeugt die HTTP-Konfiguration der angenommenen Router.
func (e *Engine) httpOutput(in Input, src *traefik.HTTPConfiguration, accepted []string, stage Stage, prefix string, at time.Time) (*traefik.HTTPConfiguration, error) {
out := &traefik.HTTPConfiguration{ out := &traefik.HTTPConfiguration{
Routers: map[string]*traefik.Router{}, Routers: map[string]*traefik.Router{},
Services: map[string]*traefik.Service{}, Services: map[string]*traefik.Service{},
@@ -324,7 +364,7 @@ func (e *Engine) Apply(in Input) (Result, error) {
} }
if routerStage != StageNormal { if routerStage != StageNormal {
if e.InterceptorURL == "" { if e.InterceptorURL == "" {
return Result{}, ErrNoInterceptor return nil, ErrNoInterceptor
} }
if slices.Contains(ackStages, routerStage) { if slices.Contains(ackStages, routerStage) {
// Durchlass nach bestätigtem Hinweis: gleiche Regel + Cookie, // Durchlass nach bestätigtem Hinweis: gleiche Regel + Cookie,
@@ -369,8 +409,43 @@ func (e *Engine) Apply(in Input) (Result, error) {
if len(out.Middlewares) == 0 { if len(out.Middlewares) == 0 {
out.Middlewares = nil out.Middlewares = nil
} }
res.Config = &traefik.Configuration{HTTP: out} return out, nil
return res, nil }
// tcpOutput erzeugt die TCP-Konfiguration der angenommenen Router. Ohne HTTP
// gibt es keinen Interceptor: Hinweisstufen lassen durch, ab Paywall und bei
// jeder Wartung (auch Healthcheck, getrennter Knoten) entfällt die Route.
func tcpOutput(in Input, src *traefik.TCPConfiguration, accepted []string, stage Stage, prefix string, at time.Time) *traefik.TCPConfiguration {
if stage == StagePaywall || stage == StageLanding {
return nil
}
out := &traefik.TCPConfiguration{
Routers: map[string]*traefik.TCPRouter{},
Services: map[string]*traefik.TCPService{},
}
for _, name := range accepted {
r := src.Routers[name]
if _, inMaint := maintenanceFor(in, r.Service, at); inMaint {
continue
}
nr := *r
nr.EntryPoints = []string{TCPEntryPoint}
nr.TLS = &traefik.TCPRouterTLS{Passthrough: true}
if isExternal(r.Service) {
nr.Service = r.Service
} else {
nr.Service = prefix + r.Service
out.Services[nr.Service] = copyTCPService(src.Services[r.Service])
}
out.Routers[prefix+name] = &nr
}
if len(out.Routers) == 0 {
return nil
}
if len(out.Services) == 0 {
out.Services = nil
}
return out
} }
// maintenanceFor liefert die geltende Wartung eines Services: zuerst die des // maintenanceFor liefert die geltende Wartung eines Services: zuerst die des
@@ -482,6 +557,50 @@ func (e *Engine) checkRouterRules(name string, r *traefik.Router, src *traefik.H
return nil, nil return nil, nil
} }
// checkTCPRouter prüft einen TCP-Router: nur mit Feature tcp_stream, nur
// TLS-Passthrough auf TCPEntryPoint, nur explizite eigene Hosts per HostSNI.
func (e *Engine) checkTCPRouter(name string, r *traefik.TCPRouter, src *traefik.TCPConfiguration, l license.License) *Violation {
v := func(kind ViolationKind, format string, args ...any) *Violation {
return &Violation{Kind: kind, Router: name, Detail: "TCP: " + fmt.Sprintf(format, args...)}
}
if !l.HasFeature(license.FeatureTCPStream) {
return v(ViolationFeature, "TCP-Router erfordert %s", license.FeatureTCPStream)
}
hosts, err := HostsFromSNIRule(r.Rule)
switch {
case errors.Is(err, ErrUnconstrained):
return v(ViolationCatchAll, "Regel %q nicht auf explizite Hosts beschränkt", r.Rule)
case errors.Is(err, ErrHostRegexp):
return v(ViolationHostRegexp, "Regel %q", r.Rule)
case err != nil:
return v(ViolationRuleSyntax, "%v", err)
}
for _, h := range hosts {
if !l.AllowsDomain(h) {
return v(ViolationDomain, "Host %q nicht in allowed_domains", h)
}
}
if r.TLS == nil || !r.TLS.Passthrough {
return v(ViolationTCPPassthrough, "nur tls.passthrough=true wird unterstützt")
}
for _, ep := range r.EntryPoints {
if ep != TCPEntryPoint {
return v(ViolationEntryPoint, "Entrypoint %q nicht erlaubt (nur %s)", ep, TCPEntryPoint)
}
}
if isExternal(r.Service) {
if !slices.Contains(e.AllowedExternalRefs, r.Service) {
return v(ViolationExternalRef, "Service %q", r.Service)
}
} else if src.Services[r.Service] == nil {
return v(ViolationUnknownRef, "Service %q nicht auf diesem Knoten definiert", r.Service)
}
return nil
}
func isExternal(ref string) bool { return strings.Contains(ref, "@") } func isExternal(ref string) bool { return strings.Contains(ref, "@") }
func sortedKeys[V any](m map[string]V) []string { func sortedKeys[V any](m map[string]V) []string {
@@ -507,6 +626,14 @@ func copyService(s *traefik.Service) *traefik.Service {
return c return c
} }
func copyTCPService(s *traefik.TCPService) *traefik.TCPService {
c := &traefik.TCPService{}
if s.LoadBalancer != nil {
c.LoadBalancer = &traefik.TCPServersLoadBalancer{Servers: slices.Clone(s.LoadBalancer.Servers)}
}
return c
}
func copyMiddleware(m traefik.Middleware) traefik.Middleware { func copyMiddleware(m traefik.Middleware) traefik.Middleware {
return traefik.Middleware(copyMap(map[string]any(m))) return traefik.Middleware(copyMap(map[string]any(m)))
} }
+33 -7
View File
@@ -24,6 +24,29 @@ var (
// Hosts schränken nicht ein (sie schließen nur aus). Positives HostRegexp // Hosts schränken nicht ein (sie schließen nur aus). Positives HostRegexp
// ist nicht erlaubt, weil sich ein Regex nicht gegen die Lizenz prüfen lässt. // ist nicht erlaubt, weil sich ein Regex nicht gegen die Lizenz prüfen lässt.
func HostsFromRule(rule string) ([]string, error) { func HostsFromRule(rule string) ([]string, error) {
return hostsFromRule(rule, "host", "hostregexp")
}
// HostsFromSNIRule ist das Gegenstück für TCP-Regeln: Jeder ||-Zweig muss
// durch HostSNI(...) beschränkt sein, HostSNIRegexp ist verboten. Erlaubt
// sind nur explizite Hosts – HostSNI(`*`) und Wildcards ergeben
// ErrUnconstrained.
func HostsFromSNIRule(rule string) ([]string, error) {
hosts, err := hostsFromRule(rule, "hostsni", "hostsniregexp")
if err != nil {
return nil, err
}
for _, h := range hosts {
if strings.Contains(h, "*") {
return nil, fmt.Errorf("%w: %q ist kein expliziter Host", ErrUnconstrained, h)
}
}
return hosts, nil
}
// hostsFromRule parst rule; hostMatcher/regexpMatcher sind die Matcher-Namen
// in Kleinschreibung.
func hostsFromRule(rule, hostMatcher, regexpMatcher string) ([]string, error) {
toks, err := lex(rule) toks, err := lex(rule)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -36,7 +59,8 @@ func HostsFromRule(rule string) ([]string, error) {
if p.peek().kind != tEOF { if p.peek().kind != tEOF {
return nil, p.errorf("unerwartetes %q", p.peek().text) return nil, p.errorf("unerwartetes %q", p.peek().text)
} }
constrained, hosts, err := analyze(root, true) a := analyzer{host: hostMatcher, regexp: regexpMatcher}
constrained, hosts, err := a.analyze(root, true)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -48,29 +72,31 @@ func HostsFromRule(rule string) ([]string, error) {
// analyze ermittelt, ob ein Ausdruck auf Hosts beschränkt ist, und sammelt // analyze ermittelt, ob ein Ausdruck auf Hosts beschränkt ist, und sammelt
// die Hosts aus positiven (nicht negierten) Host-Matchern. // die Hosts aus positiven (nicht negierten) Host-Matchern.
func analyze(n *node, positive bool) (constrained bool, hosts []string, err error) { type analyzer struct{ host, regexp string }
func (a analyzer) analyze(n *node, positive bool) (constrained bool, hosts []string, err error) {
switch n.op { switch n.op {
case opCall: case opCall:
switch strings.ToLower(n.name) { switch strings.ToLower(n.name) {
case "host": case a.host:
if positive { if positive {
return true, n.args, nil return true, n.args, nil
} }
case "hostregexp": case a.regexp:
if positive { if positive {
return false, nil, ErrHostRegexp return false, nil, ErrHostRegexp
} }
} }
return false, nil, nil return false, nil, nil
case opNot: case opNot:
_, h, err := analyze(n.kids[0], !positive) _, h, err := a.analyze(n.kids[0], !positive)
return false, h, err return false, h, err
default: // opAnd, opOr default: // opAnd, opOr
c1, h1, err := analyze(n.kids[0], positive) c1, h1, err := a.analyze(n.kids[0], positive)
if err != nil { if err != nil {
return false, nil, err return false, nil, err
} }
c2, h2, err := analyze(n.kids[1], positive) c2, h2, err := a.analyze(n.kids[1], positive)
if err != nil { if err != nil {
return false, nil, err return false, nil, err
} }
+269
View File
@@ -0,0 +1,269 @@
package policy
import (
"errors"
"reflect"
"testing"
"time"
"sultan/pkg/license"
"sultan/pkg/store"
"sultan/pkg/traefik"
)
func TestHostsFromSNIRule(t *testing.T) {
tests := map[string][]string{
"HostSNI(`a.de`)": {"a.de"},
"HostSNI(`a.de`) || HostSNI(`b.de`)": {"a.de", "b.de"},
"HostSNI(`a.de`, `b.de`)": {"a.de", "b.de"},
"hostsni(`a.de`) && ClientIP(`10.0.0.0/8`)": {"a.de"},
"HostSNI(`a.de`) && (ALPN(`h2`) || ALPN(`acme`))": {"a.de"},
"(HostSNI(`a.de`) || HostSNI(`b.de`)) && !ClientIP(`1.2.3.4`)": {"a.de", "b.de"},
}
for rule, want := range tests {
got, err := HostsFromSNIRule(rule)
if err != nil {
t.Errorf("%s: unerwarteter Fehler %v", rule, err)
continue
}
if !reflect.DeepEqual(got, want) {
t.Errorf("%s: Hosts = %v, erwartet %v", rule, got, want)
}
}
}
// Nur explizite Hosts: HostSNI(`*`) wäre ein Catch-all für den ganzen
// Entrypoint, Wildcards wie *.x.de versteht Traefik in HostSNI ohnehin nicht.
func TestHostsFromSNIRule_Rejected(t *testing.T) {
tests := map[string]error{
"HostSNI(`*`)": ErrUnconstrained,
"HostSNI(`*.lab.kunde.de`)": ErrUnconstrained,
"HostSNI(`a.de`) || HostSNI(`*`)": ErrUnconstrained,
"ClientIP(`10.0.0.0/8`)": ErrUnconstrained,
"HostSNI(`a.de`) || ALPN(`h2`)": ErrUnconstrained,
"Host(`a.de`)": ErrUnconstrained, // HTTP-Matcher zählt hier nicht
"HostSNIRegexp(`^.+\\.kunde\\.de$`)": ErrHostRegexp,
"HostSNI(`a.de`) && HostSNIRegexp(`x`)": ErrHostRegexp,
"HostSNI(`a.de`": ErrSyntax,
}
for rule, want := range tests {
if _, err := HostsFromSNIRule(rule); !errors.Is(err, want) {
t.Errorf("%s: erwartet %v, bekommen %v", rule, want, err)
}
}
}
// Umgekehrt schränkt HostSNI eine HTTP-Regel nicht ein.
func TestHostsFromRule_IgnoresHostSNI(t *testing.T) {
if _, err := HostsFromRule("HostSNI(`a.de`)"); !errors.Is(err, ErrUnconstrained) {
t.Errorf("erwartet ErrUnconstrained, bekommen %v", err)
}
}
func caLabels() map[string]string {
return map[string]string{
"traefik.tcp.routers.ca.rule": "HostSNI(`ca.lab.kunde.de`)",
"traefik.tcp.routers.ca.entrypoints": "websecure",
"traefik.tcp.routers.ca.tls.passthrough": "true",
"traefik.tcp.services.ca.loadbalancer.server.port": "9000",
}
}
func tcpInput(t *testing.T, labels map[string]string) Input {
in := input(t, labels)
in.License = lic(license.TierPro, 10, license.FeatureTCPStream)
return in
}
func TestApply_TCPPassthrough(t *testing.T) {
res, err := engine().Apply(tcpInput(t, caLabels()))
if err != nil {
t.Fatal(err)
}
if len(res.Violations) != 0 {
t.Errorf("Violations = %+v", res.Violations)
}
want := &traefik.Configuration{TCP: &traefik.TCPConfiguration{
Routers: map[string]*traefik.TCPRouter{
"kunde__node-1__ca": {
EntryPoints: []string{TCPEntryPoint},
Service: "kunde__node-1__ca",
Rule: "HostSNI(`ca.lab.kunde.de`)",
TLS: &traefik.TCPRouterTLS{Passthrough: true},
},
},
Services: map[string]*traefik.TCPService{
"kunde__node-1__ca": {LoadBalancer: &traefik.TCPServersLoadBalancer{Servers: []traefik.TCPServer{{Address: "100.64.0.42:9000"}}}},
},
}}
if !reflect.DeepEqual(res.Config, want) {
t.Errorf("Konfiguration weicht ab\n got: %+v\nwant: %+v", res.Config.TCP, want.TCP)
}
}
// Ohne entrypoints-Label würde Traefik den Router auf alle Entrypoints legen
// (auch :80) – Sultan setzt immer websecure.
func TestApply_TCPEntryPointDefault(t *testing.T) {
labels := caLabels()
delete(labels, "traefik.tcp.routers.ca.entrypoints")
res, err := engine().Apply(tcpInput(t, labels))
if err != nil {
t.Fatal(err)
}
r := res.Config.TCP.Routers["kunde__node-1__ca"]
if r == nil || !reflect.DeepEqual(r.EntryPoints, []string{TCPEntryPoint}) {
t.Errorf("Router = %+v", r)
}
}
func TestApply_TCPViolations(t *testing.T) {
tests := map[string]struct {
mutate func(map[string]string, *Input)
want ViolationKind
}{
"ohne Feature": {func(_ map[string]string, in *Input) {
in.License = lic(license.TierPro, 10)
}, ViolationFeature},
"ohne Passthrough": {func(l map[string]string, _ *Input) {
delete(l, "traefik.tcp.routers.ca.tls.passthrough")
l["traefik.tcp.routers.ca.tls"] = "true"
}, ViolationTCPPassthrough},
"Passthrough aus": {func(l map[string]string, _ *Input) {
l["traefik.tcp.routers.ca.tls.passthrough"] = "false"
}, ViolationTCPPassthrough},
"anderer Entrypoint": {func(l map[string]string, _ *Input) {
l["traefik.tcp.routers.ca.entrypoints"] = "websecure,postgres"
}, ViolationEntryPoint},
"Catch-all": {func(l map[string]string, _ *Input) {
l["traefik.tcp.routers.ca.rule"] = "HostSNI(`*`)"
}, ViolationCatchAll},
"Wildcard": {func(l map[string]string, _ *Input) {
l["traefik.tcp.routers.ca.rule"] = "HostSNI(`*.lab.kunde.de`)"
}, ViolationCatchAll},
"Regexp": {func(l map[string]string, _ *Input) {
l["traefik.tcp.routers.ca.rule"] = "HostSNIRegexp(`.+`)"
}, ViolationHostRegexp},
"Syntax": {func(l map[string]string, _ *Input) {
l["traefik.tcp.routers.ca.rule"] = "HostSNI(`ca.lab.kunde.de`"
}, ViolationRuleSyntax},
"fremde Domain": {func(l map[string]string, _ *Input) {
l["traefik.tcp.routers.ca.rule"] = "HostSNI(`ca.lab.kunde.de`) || HostSNI(`ca.fremd.de`)"
}, ViolationDomain},
"externer Service": {func(l map[string]string, _ *Input) {
l["traefik.tcp.routers.ca.service"] = "ca@file"
}, ViolationExternalRef},
"unbekannter Service": {func(l map[string]string, _ *Input) {
l["traefik.tcp.routers.ca.service"] = "andere"
}, ViolationUnknownRef},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
labels := caLabels()
in := tcpInput(t, labels)
tt.mutate(labels, &in)
in.Config = cfg(t, labels)
res, err := engine().Apply(in)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(kinds(res.Violations), []ViolationKind{tt.want}) || res.Violations[0].Router != "ca" {
t.Errorf("Violations = %+v, erwartet %s", res.Violations, tt.want)
}
if res.Config.TCP != nil {
t.Errorf("Router trotz Verstoß übernommen: %+v", res.Config.TCP)
}
})
}
}
// Die Domainprüfung erlaubt einzelne Hosts unter einer Wildcard aus
// allowed_domains (*.lab.kunde.de).
func TestApply_TCPHostUnderLicenseWildcard(t *testing.T) {
in := tcpInput(t, caLabels())
in.License.AllowedDomains = []string{"*.lab.kunde.de"}
res, err := engine().Apply(in)
if err != nil || len(res.Violations) != 0 || res.Config.TCP == nil {
t.Errorf("res=%+v violations=%+v err=%v", res.Config, res.Violations, err)
}
}
// TCP-Router zählen gegen max_routes; HTTP-Router werden zuerst vergeben.
func TestApply_TCPRouteLimit(t *testing.T) {
labels := caLabels()
for k, v := range wpLabels() {
labels[k] = v
}
in := tcpInput(t, labels)
in.License.MaxRoutes = 1
res, err := engine().Apply(in)
if err != nil {
t.Fatal(err)
}
if res.Config.HTTP == nil || len(res.Config.HTTP.Routers) != 1 || res.Config.TCP != nil {
t.Errorf("erwartet nur den HTTP-Router: %+v / %+v", res.Config.HTTP, res.Config.TCP)
}
if !reflect.DeepEqual(kinds(res.Violations), []ViolationKind{ViolationRouteLimit}) || res.Violations[0].Router != "ca" {
t.Errorf("Violations = %+v", res.Violations)
}
in.License.MaxRoutes = 2
res, _ = engine().Apply(in)
if res.Config.TCP == nil || len(res.Violations) != 0 {
t.Errorf("max_routes=2: %+v %+v", res.Config.TCP, res.Violations)
}
}
// Ohne Interceptor gilt: Hinweisstufen lassen durch, ab Paywall und bei jeder
// Wartung ist die Route weg.
func TestApply_TCPStages(t *testing.T) {
future := now.Add(time.Hour)
tests := map[string]struct {
mutate func(*Input)
keep bool
}{
"normal": {func(*Input) {}, true},
"Mahnstufe 1": {func(in *Input) { in.Tenant.DunningLevel = 1 }, true},
"Mahnstufe 2": {func(in *Input) { in.Tenant.DunningLevel = 2 }, true},
"Paywall": {func(in *Input) { in.Tenant.DunningLevel = 3 }, false},
"ausgesetzt": {func(in *Input) { in.Tenant.Status = store.TenantSuspendedPayment }, false},
"abgelaufen": {func(in *Input) { in.Tenant.Status = store.TenantExpired }, false},
"Quarantäne": {func(in *Input) { in.Tenant.Status = store.TenantQuarantined }, false},
"gedraint": {func(in *Input) { in.NodeDrained = true }, false},
"Knotenwartung": {func(in *Input) { in.Maintenance = &Maintenance{Until: future} }, false},
"Knotenwartung vorbei": {func(in *Input) {
in.Maintenance = &Maintenance{Until: now.Add(-time.Minute)}
}, true},
"Dienstwartung": {func(in *Input) { in.ServiceMaintenance = map[string]Maintenance{"ca": {}} }, false},
"Healthcheck": {func(in *Input) { in.DownServices = map[string]bool{"ca": true} }, false},
"getrennt": {func(in *Input) { in.Disconnected = true }, false},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
in := tcpInput(t, caLabels())
tt.mutate(&in)
res, err := (&Engine{now: engine().now}).Apply(in) // ohne Interceptor: TCP braucht keinen
if err != nil {
t.Fatal(err)
}
if got := res.Config.TCP != nil; got != tt.keep {
t.Errorf("Route vorhanden = %v, erwartet %v", got, tt.keep)
}
if len(res.Violations) != 0 {
t.Errorf("Violations = %+v", res.Violations)
}
})
}
}
func TestApply_TCPDoesNotMutateInput(t *testing.T) {
labels := caLabels()
delete(labels, "traefik.tcp.routers.ca.entrypoints")
in := tcpInput(t, labels)
before := cfg(t, labels)
if _, err := engine().Apply(in); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(in.Config, before) {
t.Error("Eingabe-Konfiguration wurde verändert")
}
}
+10 -3
View File
@@ -239,9 +239,16 @@ func (r *Registry) evaluate(ctx context.Context, nodeID string, state NodeState)
continue continue
} }
merged = m merged = m
if maint, ok := workloadMaintenance(w.Labels); ok && c.HTTP != nil { if maint, ok := workloadMaintenance(w.Labels); ok {
for svc := range c.HTTP.Services { if c.HTTP != nil {
serviceMaint[svc] = maint for svc := range c.HTTP.Services {
serviceMaint[svc] = maint
}
}
if c.TCP != nil {
for svc := range c.TCP.Services {
serviceMaint[svc] = maint
}
} }
} }
} }
+53
View File
@@ -0,0 +1,53 @@
package registry
import (
"testing"
"sultan/pkg/license"
)
func caWorkload(extra map[string]string) Workload {
labels := map[string]string{
"traefik.enable": "true",
"traefik.tcp.routers.ca.rule": "HostSNI(`ca.lab.kunde.de`)",
"traefik.tcp.routers.ca.tls.passthrough": "true",
"traefik.tcp.services.ca.loadbalancer.server.port": "9000",
}
for k, v := range extra {
labels[k] = v
}
return Workload{Name: "ca_passthrough_test", Host: "100.64.0.1", Labels: labels}
}
// TLS-Passthrough kommt mit Feature tcp_stream neben HTTP-Routen in die
// Ausgabe; ohne Feature wird nur der TCP-Router verworfen.
func TestRegistry_TCPPassthrough(t *testing.T) {
r, dir, a := newRegistry()
r.Update(ctx, "n1", NodeState{Workloads: []Workload{wp("wordpress", "100.64.0.1", "kunde.de"), caWorkload(nil)}})
if c := r.Config(); c.TCP != nil || len(routers(r)) != 1 {
t.Fatalf("ohne Feature: %+v", c.TCP)
}
if e := a.entries(); len(e) != 1 || e[0].Router != "ca" {
t.Errorf("Audit = %+v", e)
}
dir.set("n1", func(nc *NodeContext) { nc.License.Features = []license.Feature{license.FeatureTCPStream} })
r.Refresh(ctx)
c := r.Config()
if c.TCP == nil || c.TCP.Routers["kunde__n1__ca"] == nil || len(routers(r)) != 1 {
t.Fatalf("mit Feature: %+v", c.TCP)
}
if addr := c.TCP.Services["kunde__n1__ca"].LoadBalancer.Servers[0].Address; addr != "100.64.0.1:9000" {
t.Errorf("Adresse = %q", addr)
}
}
// sultan.maintenance an einem TCP-Dienst entfernt dessen Route.
func TestRegistry_TCPServiceMaintenanceLabel(t *testing.T) {
r, dir, _ := newRegistry()
dir.set("n1", func(nc *NodeContext) { nc.License.Features = []license.Feature{license.FeatureTCPStream} })
r.Update(ctx, "n1", NodeState{Workloads: []Workload{caWorkload(map[string]string{"sultan.maintenance": "true"})}})
if c := r.Config(); c.TCP != nil {
t.Errorf("Route trotz Wartung: %+v", c.TCP)
}
}
+38
View File
@@ -18,6 +18,7 @@ import (
// Configuration ist die Wurzel der dynamischen Traefik-Konfiguration. // Configuration ist die Wurzel der dynamischen Traefik-Konfiguration.
type Configuration struct { type Configuration struct {
HTTP *HTTPConfiguration `json:"http,omitempty" yaml:"http,omitempty"` HTTP *HTTPConfiguration `json:"http,omitempty" yaml:"http,omitempty"`
TCP *TCPConfiguration `json:"tcp,omitempty" yaml:"tcp,omitempty"`
} }
// HTTPConfiguration enthält Router, Services und Middlewares der HTTP-Ebene. // HTTPConfiguration enthält Router, Services und Middlewares der HTTP-Ebene.
@@ -60,6 +61,43 @@ type Server struct {
URL string `json:"url" yaml:"url"` URL string `json:"url" yaml:"url"`
} }
// TCPConfiguration enthält Router und Services der TCP-Ebene. Sultan nutzt
// sie nur für TLS-Passthrough (Routing per SNI, TLS endet beim Backend).
type TCPConfiguration struct {
Routers map[string]*TCPRouter `json:"routers,omitempty" yaml:"routers,omitempty"`
Services map[string]*TCPService `json:"services,omitempty" yaml:"services,omitempty"`
}
// TCPRouter entspricht einem Traefik-TCP-Router.
type TCPRouter struct {
EntryPoints []string `json:"entryPoints,omitempty" yaml:"entryPoints,omitempty"`
Service string `json:"service" yaml:"service"`
Rule string `json:"rule" yaml:"rule"`
Priority int `json:"priority,omitempty" yaml:"priority,omitempty"`
TLS *TCPRouterTLS `json:"tls,omitempty" yaml:"tls,omitempty"`
}
// TCPRouterTLS aktiviert TLS für einen TCP-Router; Passthrough reicht die
// Verbindung unverändert an das Backend durch.
type TCPRouterTLS struct {
Passthrough bool `json:"passthrough,omitempty" yaml:"passthrough,omitempty"`
}
// TCPService entspricht einem Traefik-TCP-Service.
type TCPService struct {
LoadBalancer *TCPServersLoadBalancer `json:"loadBalancer,omitempty" yaml:"loadBalancer,omitempty"`
}
// TCPServersLoadBalancer verteilt Verbindungen auf die angegebenen Server.
type TCPServersLoadBalancer struct {
Servers []TCPServer `json:"servers" yaml:"servers"`
}
// TCPServer ist ein einzelnes TCP-Backend (host:port).
type TCPServer struct {
Address string `json:"address" yaml:"address"`
}
// Middleware ist eine Traefik-Middleware als verschachtelte Map, z. B. // Middleware ist eine Traefik-Middleware als verschachtelte Map, z. B.
// {"redirectRegex": {"regex": "...", "permanent": "true"}}. // {"redirectRegex": {"regex": "...", "permanent": "true"}}.
// //
+123 -17
View File
@@ -46,6 +46,8 @@ func FromLabels(host string, labels map[string]string) (*Configuration, error) {
routers: map[string]*Router{}, routers: map[string]*Router{},
services: map[string]*serviceSpec{}, services: map[string]*serviceSpec{},
middlewares: map[string]Middleware{}, middlewares: map[string]Middleware{},
tcpRouters: map[string]*TCPRouter{},
tcpServices: map[string]*serviceSpec{},
} }
keys := make([]string, 0, len(labels)) keys := make([]string, 0, len(labels))
@@ -93,6 +95,8 @@ type builder struct {
routers map[string]*Router routers map[string]*Router
services map[string]*serviceSpec services map[string]*serviceSpec
middlewares map[string]Middleware middlewares map[string]Middleware
tcpRouters map[string]*TCPRouter
tcpServices map[string]*serviceSpec
errs []error errs []error
} }
@@ -101,7 +105,7 @@ func (b *builder) apply(key, value string) error {
switch strings.ToLower(parts[0]) { switch strings.ToLower(parts[0]) {
case "enable", "docker": case "enable", "docker":
return nil return nil
case "http": case "http", "tcp":
default: default:
return ErrUnsupportedLabel return ErrUnsupportedLabel
} }
@@ -112,6 +116,9 @@ func (b *builder) apply(key, value string) error {
if name == "" { if name == "" {
return fmt.Errorf("%w: leerer Name", ErrInvalidLabel) return fmt.Errorf("%w: leerer Name", ErrInvalidLabel)
} }
if strings.EqualFold(parts[0], "tcp") {
return b.applyTCP(kind, name, strings.ToLower(strings.Join(attr, ".")), value)
}
switch kind { switch kind {
case "routers": case "routers":
r, ok := b.routers[name] r, ok := b.routers[name]
@@ -173,6 +180,65 @@ func setRouterAttr(r *Router, attr, value string) error {
return nil return nil
} }
// applyTCP übersetzt traefik.tcp.*. Unterstützt wird nur, was für
// TLS-Passthrough nötig ist; TCP-Middlewares und TLS-Terminierung nicht.
func (b *builder) applyTCP(kind, name, attr, value string) error {
switch kind {
case "routers":
r, ok := b.tcpRouters[name]
if !ok {
r = &TCPRouter{}
b.tcpRouters[name] = r
}
return setTCPRouterAttr(r, attr, value)
case "services":
if attr != "loadbalancer.server.port" {
return ErrUnsupportedLabel
}
s, ok := b.tcpServices[name]
if !ok {
s = &serviceSpec{}
b.tcpServices[name] = s
}
return setServiceAttr(s, attr, value)
default:
return ErrUnsupportedLabel
}
}
func setTCPRouterAttr(r *TCPRouter, attr, value string) error {
switch attr {
case "rule":
r.Rule = strings.TrimSpace(value)
case "entrypoints":
r.EntryPoints = splitList(value)
case "service":
r.Service = strings.TrimSpace(value)
case "priority":
p, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil {
return fmt.Errorf("%w: %q ist keine Zahl", ErrInvalidLabel, value)
}
r.Priority = p
case "tls", "tls.passthrough":
on, err := strconv.ParseBool(strings.TrimSpace(value))
if err != nil {
return fmt.Errorf("%w: %q ist kein Bool", ErrInvalidLabel, value)
}
if attr == "tls.passthrough" {
if r.TLS == nil {
r.TLS = &TCPRouterTLS{}
}
r.TLS.Passthrough = on
} else if on && r.TLS == nil {
r.TLS = &TCPRouterTLS{}
}
default:
return ErrUnsupportedLabel
}
return nil
}
func ensureTLS(r *Router) *RouterTLS { func ensureTLS(r *Router) *RouterTLS {
if r.TLS == nil { if r.TLS == nil {
r.TLS = &RouterTLS{} r.TLS = &RouterTLS{}
@@ -239,15 +305,20 @@ func setMiddlewareAttr(m Middleware, attr []string, value string) error {
} }
func (b *builder) build(host string) *Configuration { func (b *builder) build(host string) *Configuration {
cfg := &Configuration{HTTP: b.buildHTTP(host), TCP: b.buildTCP(host)}
if (len(b.services) > 0 || len(b.tcpServices) > 0) && strings.TrimSpace(host) == "" {
b.errs = append(b.errs, fmt.Errorf("%w: keine Backend-Adresse (host) angegeben", ErrIncomplete))
}
return cfg
}
func (b *builder) buildHTTP(host string) *HTTPConfiguration {
if len(b.routers) == 0 && len(b.services) == 0 && len(b.middlewares) == 0 { if len(b.routers) == 0 && len(b.services) == 0 && len(b.middlewares) == 0 {
return &Configuration{} return nil
} }
http := &HTTPConfiguration{} http := &HTTPConfiguration{}
if len(b.services) > 0 { if len(b.services) > 0 {
if strings.TrimSpace(host) == "" {
b.errs = append(b.errs, fmt.Errorf("%w: keine Backend-Adresse (host) angegeben", ErrIncomplete))
}
http.Services = map[string]*Service{} http.Services = map[string]*Service{}
for name, s := range b.services { for name, s := range b.services {
if s.port == "" { if s.port == "" {
@@ -271,17 +342,8 @@ func (b *builder) build(host string) *Configuration {
if r.Rule == "" { if r.Rule == "" {
b.errs = append(b.errs, fmt.Errorf("router %q: %w: rule fehlt", name, ErrIncomplete)) b.errs = append(b.errs, fmt.Errorf("router %q: %w: rule fehlt", name, ErrIncomplete))
} }
if r.Service != "" { if r.Service == "" {
continue r.Service = b.defaultService("router", name, b.services)
}
// Wie Traefiks Docker-Provider: ohne explizite Angabe wird der
// Router an den einzigen Service des Containers gebunden.
if len(b.services) != 1 {
b.errs = append(b.errs, fmt.Errorf("router %q: %w: service fehlt und ist bei %d Services nicht eindeutig", name, ErrIncomplete, len(b.services)))
continue
}
for svc := range b.services {
r.Service = svc
} }
} }
} }
@@ -289,7 +351,51 @@ func (b *builder) build(host string) *Configuration {
if len(b.middlewares) > 0 { if len(b.middlewares) > 0 {
http.Middlewares = b.middlewares http.Middlewares = b.middlewares
} }
return &Configuration{HTTP: http} return http
}
func (b *builder) buildTCP(host string) *TCPConfiguration {
if len(b.tcpRouters) == 0 && len(b.tcpServices) == 0 {
return nil
}
tcp := &TCPConfiguration{}
if len(b.tcpServices) > 0 {
tcp.Services = map[string]*TCPService{}
for name, s := range b.tcpServices {
if s.port == "" {
b.errs = append(b.errs, fmt.Errorf("tcp service %q: %w: loadbalancer.server.port fehlt", name, ErrIncomplete))
continue
}
tcp.Services[name] = &TCPService{LoadBalancer: &TCPServersLoadBalancer{
Servers: []TCPServer{{Address: net.JoinHostPort(host, s.port)}},
}}
}
}
if len(b.tcpRouters) > 0 {
tcp.Routers = b.tcpRouters
for name, r := range b.tcpRouters {
if r.Rule == "" {
b.errs = append(b.errs, fmt.Errorf("tcp router %q: %w: rule fehlt", name, ErrIncomplete))
}
if r.Service == "" {
r.Service = b.defaultService("tcp router", name, b.tcpServices)
}
}
}
return tcp
}
// defaultService bindet wie Traefiks Docker-Provider einen Router ohne
// explizite Angabe an den einzigen Service des Containers.
func (b *builder) defaultService(kind, router string, services map[string]*serviceSpec) string {
if len(services) != 1 {
b.errs = append(b.errs, fmt.Errorf("%s %q: %w: service fehlt und ist bei %d Services nicht eindeutig", kind, router, ErrIncomplete, len(services)))
return ""
}
for svc := range services {
return svc
}
return ""
} }
func splitList(value string) []string { func splitList(value string) []string {
+2 -2
View File
@@ -276,7 +276,7 @@ func TestFromLabels_Errors(t *testing.T) {
labels map[string]string labels map[string]string
want error want error
}{ }{
{"TCP nicht unterstützt", "h", map[string]string{"traefik.tcp.routers.db.rule": "HostSNI(`*`)"}, ErrUnsupportedLabel}, {"UDP nicht unterstützt", "h", map[string]string{"traefik.udp.routers.dns.entrypoints": "dns"}, ErrUnsupportedLabel},
{"UDP nicht unterstützt", "h", map[string]string{"traefik.udp.services.dns.loadbalancer.server.port": "53"}, ErrUnsupportedLabel}, {"UDP nicht unterstützt", "h", map[string]string{"traefik.udp.services.dns.loadbalancer.server.port": "53"}, ErrUnsupportedLabel},
{"unbekannte HTTP-Art", "h", map[string]string{"traefik.http.serverstransports.x.insecureskipverify": "true"}, ErrUnsupportedLabel}, {"unbekannte HTTP-Art", "h", map[string]string{"traefik.http.serverstransports.x.insecureskipverify": "true"}, ErrUnsupportedLabel},
{"Schlüssel zu kurz", "h", map[string]string{"traefik.http.routers.r": "x"}, ErrUnsupportedLabel}, {"Schlüssel zu kurz", "h", map[string]string{"traefik.http.routers.r": "x"}, ErrUnsupportedLabel},
@@ -333,7 +333,7 @@ func TestFromLabels_Middlewares_ObjectThenValue(t *testing.T) {
func TestFromLabels_CollectsAllErrors(t *testing.T) { func TestFromLabels_CollectsAllErrors(t *testing.T) {
_, err := FromLabels("h", map[string]string{ _, err := FromLabels("h", map[string]string{
"traefik.enable": "true", "traefik.enable": "true",
"traefik.tcp.routers.db.rule": "HostSNI(`*`)", "traefik.udp.routers.dns.entrypoints": "dns",
"traefik.http.services.s.loadbalancer.server.port": "nope", "traefik.http.services.s.loadbalancer.server.port": "nope",
"traefik.http.routers.r.service": "s", "traefik.http.routers.r.service": "s",
}) })
+21 -7
View File
@@ -21,22 +21,36 @@ func Merge(configs ...*Configuration) (*Configuration, error) {
Services: map[string]*Service{}, Services: map[string]*Service{},
Middlewares: map[string]Middleware{}, Middlewares: map[string]Middleware{},
} }
tcp := &TCPConfiguration{
Routers: map[string]*TCPRouter{},
Services: map[string]*TCPService{},
}
var errs []error var errs []error
for _, c := range configs { for _, c := range configs {
if c == nil || c.HTTP == nil { if c == nil {
continue continue
} }
errs = append(errs, mergeInto(out.Routers, c.HTTP.Routers, "router")...) if c.HTTP != nil {
errs = append(errs, mergeInto(out.Services, c.HTTP.Services, "service")...) errs = append(errs, mergeInto(out.Routers, c.HTTP.Routers, "router")...)
errs = append(errs, mergeInto(out.Middlewares, c.HTTP.Middlewares, "middleware")...) errs = append(errs, mergeInto(out.Services, c.HTTP.Services, "service")...)
errs = append(errs, mergeInto(out.Middlewares, c.HTTP.Middlewares, "middleware")...)
}
if c.TCP != nil {
errs = append(errs, mergeInto(tcp.Routers, c.TCP.Routers, "tcp router")...)
errs = append(errs, mergeInto(tcp.Services, c.TCP.Services, "tcp service")...)
}
} }
if len(errs) > 0 { if len(errs) > 0 {
return nil, errors.Join(errs...) return nil, errors.Join(errs...)
} }
if len(out.Routers) == 0 && len(out.Services) == 0 && len(out.Middlewares) == 0 { res := &Configuration{}
return &Configuration{}, nil if len(out.Routers) > 0 || len(out.Services) > 0 || len(out.Middlewares) > 0 {
res.HTTP = out
} }
return &Configuration{HTTP: out}, nil if len(tcp.Routers) > 0 || len(tcp.Services) > 0 {
res.TCP = tcp
}
return res, nil
} }
func mergeInto[V any](dst, src map[string]V, kind string) []error { func mergeInto[V any](dst, src map[string]V, kind string) []error {
+159
View File
@@ -0,0 +1,159 @@
package traefik
import (
"errors"
"reflect"
"testing"
)
// TLS-Passthrough für eigene CAs (z. B. tinyCA im Kundenlabor).
func caLabels() map[string]string {
return map[string]string{
"traefik.enable": "true",
"traefik.tcp.routers.ca.rule": "HostSNI(`ca.lab.kunde.de`)",
"traefik.tcp.routers.ca.entrypoints": "websecure",
"traefik.tcp.routers.ca.tls.passthrough": "true",
"traefik.tcp.services.ca.loadbalancer.server.port": "9000",
}
}
func TestFromLabels_TCPPassthrough(t *testing.T) {
got, err := FromLabels("100.64.0.42", caLabels())
if err != nil {
t.Fatalf("unerwarteter Fehler: %v", err)
}
want := &Configuration{TCP: &TCPConfiguration{
Routers: map[string]*TCPRouter{
"ca": {
EntryPoints: []string{"websecure"},
Service: "ca",
Rule: "HostSNI(`ca.lab.kunde.de`)",
TLS: &TCPRouterTLS{Passthrough: true},
},
},
Services: map[string]*TCPService{
"ca": {LoadBalancer: &TCPServersLoadBalancer{Servers: []TCPServer{{Address: "100.64.0.42:9000"}}}},
},
}}
if !reflect.DeepEqual(got, want) {
t.Errorf("Konfiguration weicht ab\n got: %s\nwant: %s", mustJSON(t, got), mustJSON(t, want))
}
}
func TestFromLabels_TCPAttributes(t *testing.T) {
l := caLabels()
delete(l, "traefik.tcp.routers.ca.tls.passthrough")
l["traefik.TCP.Routers.ca.TLS"] = "true"
l["traefik.tcp.routers.ca.priority"] = "7"
l["traefik.tcp.routers.ca.service"] = "ca"
got, err := FromLabels("2001:db8::1", l)
if err != nil {
t.Fatalf("unerwarteter Fehler: %v", err)
}
r := got.TCP.Routers["ca"]
if r.Priority != 7 || r.TLS == nil || r.TLS.Passthrough {
t.Errorf("Router = %+v", r)
}
if addr := got.TCP.Services["ca"].LoadBalancer.Servers[0].Address; addr != "[2001:db8::1]:9000" {
t.Errorf("Adresse = %q", addr)
}
}
// Ohne service-Label bindet sich der Router an den einzigen TCP-Service.
func TestFromLabels_TCPDefaultServiceBinding(t *testing.T) {
got, err := FromLabels("10.0.0.1", caLabels())
if err != nil {
t.Fatal(err)
}
if got.TCP.Routers["ca"].Service != "ca" {
t.Errorf("Service = %q", got.TCP.Routers["ca"].Service)
}
}
// HTTP und TCP im selben Container: getrennte Namensräume, wie bei Traefik.
func TestFromLabels_HTTPAndTCPTogether(t *testing.T) {
l := caLabels()
l["traefik.http.routers.ca.rule"] = "Host(`ca.lab.kunde.de`)"
l["traefik.http.routers.ca.entrypoints"] = "web"
l["traefik.http.services.ca.loadbalancer.server.port"] = "80"
got, err := FromLabels("10.0.0.1", l)
if err != nil {
t.Fatal(err)
}
if got.HTTP.Routers["ca"].Service != "ca" || got.TCP.Routers["ca"].Service != "ca" {
t.Errorf("Bindung falsch: %s", mustJSON(t, got))
}
}
func TestFromLabels_TCPErrors(t *testing.T) {
tests := map[string]struct {
key, value string
want error
}{
"certresolver": {"traefik.tcp.routers.ca.tls.certresolver", "le", ErrUnsupportedLabel},
"middlewares": {"traefik.tcp.routers.ca.middlewares", "x", ErrUnsupportedLabel},
"tcp middlewares": {"traefik.tcp.middlewares.x.ipallowlist.sourcerange", "10.0.0.0/8", ErrUnsupportedLabel},
"proxyprotocol": {"traefik.tcp.services.ca.loadbalancer.proxyprotocol.version", "2", ErrUnsupportedLabel},
"udp": {"traefik.udp.routers.dns.entrypoints", "dns", ErrUnsupportedLabel},
"passthrough kaputt": {"traefik.tcp.routers.ca.tls.passthrough", "vielleicht", ErrInvalidLabel},
"port kaputt": {"traefik.tcp.services.ca.loadbalancer.server.port", "0", ErrInvalidLabel},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
l := caLabels()
l[tt.key] = tt.value
if _, err := FromLabels("10.0.0.1", l); !errors.Is(err, tt.want) {
t.Errorf("erwartet %v, bekommen %v", tt.want, err)
}
})
}
l := caLabels()
delete(l, "traefik.tcp.routers.ca.rule")
if _, err := FromLabels("10.0.0.1", l); !errors.Is(err, ErrIncomplete) {
t.Errorf("fehlende Regel: %v", err)
}
}
func TestMerge_TCP(t *testing.T) {
a, err := FromLabels("10.0.0.1", caLabels())
if err != nil {
t.Fatal(err)
}
b, err := FromLabels("10.0.0.2", map[string]string{
"traefik.enable": "true",
"traefik.tcp.routers.db.rule": "HostSNI(`db.kunde.de`)",
"traefik.tcp.routers.db.tls.passthrough": "true",
"traefik.tcp.services.db.loadbalancer.server.port": "5432",
})
if err != nil {
t.Fatal(err)
}
got, err := Merge(a, b, nil)
if err != nil {
t.Fatalf("Merge: %v", err)
}
if len(got.TCP.Routers) != 2 || len(got.TCP.Services) != 2 || got.HTTP != nil {
t.Errorf("Ergebnis: %s", mustJSON(t, got))
}
c, _ := FromLabels("10.0.0.3", caLabels()) // gleicher Name, andere Adresse
if _, err := Merge(a, c); !errors.Is(err, ErrConflict) {
t.Errorf("erwartet ErrConflict, bekommen %v", err)
}
}
func TestMarshal_TCP(t *testing.T) {
c, err := FromLabels("10.0.0.1", caLabels())
if err != nil {
t.Fatal(err)
}
got, err := c.Marshal(FormatJSON)
if err != nil {
t.Fatal(err)
}
want := `{"tcp":{"routers":{"ca":{"entryPoints":["websecure"],"service":"ca","rule":"HostSNI(` + "`ca.lab.kunde.de`" + `)","tls":{"passthrough":true}}},"services":{"ca":{"loadBalancer":{"servers":[{"address":"10.0.0.1:9000"}]}}}}}`
if string(got) != want {
t.Errorf("JSON\n got: %s\nwant: %s", got, want)
}
}