tcp stream (und damit: tls passthrough) ist implementiert
This commit is contained in:
1 parent
6a5cb95f92
commit
fdcd06c93f
16 files changed
+1008
-75
No files matched your search
@@ -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.
|
||||
* `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.
|
||||
* **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
|
||||
|
||||
@@ -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 | **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-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. |
|
||||
|
||||
---
|
||||
@@ -804,7 +813,7 @@ services:
|
||||
|
||||
| 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/store` | SQLite: Mandanten, Knoten, Enrollment |
|
||||
| `pkg/auth` | Ed25519-Request-Signatur, Verifier, Schlüsseldateien |
|
||||
|
||||
@@ -10,14 +10,14 @@ Maßgeblich ist aktuell [.ai/sultan-system-architecture.md](.ai/sultan-system-ar
|
||||
## 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:
|
||||
- [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/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.
|
||||
- **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/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/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 ./...
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -233,6 +233,26 @@ services:
|
||||
- Ä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ß“).
|
||||
|
||||
#### 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
|
||||
|
||||
Der Agent meldet `http://<backend-ip>:<veröffentlichter Port>`. Prüfen vom **Ingress** aus:
|
||||
|
||||
+2
-2
@@ -25,9 +25,9 @@ Aus dem Vertrag gehen diese Werte in den Mandanten:
|
||||
| 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--…`). |
|
||||
| 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“.** |
|
||||
| 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.
|
||||
|
||||
|
||||
+46
-19
@@ -13,13 +13,19 @@ import (
|
||||
"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_-]`)
|
||||
|
||||
// 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
|
||||
// im Container) auf den veröffentlichten Host-Port umgerechnet. Fehlt das
|
||||
// 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 {
|
||||
workload registry.Workload
|
||||
running bool
|
||||
routers []string
|
||||
services []string
|
||||
routers []string // "<proto>:<name>"
|
||||
services []string // "<proto>:<name>"
|
||||
}
|
||||
var candidates []candidate
|
||||
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{
|
||||
workload: registry.Workload{Name: c.Name(), Host: host, Labels: labels},
|
||||
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 {
|
||||
for _, r := range cand.routers {
|
||||
@@ -84,7 +96,10 @@ func Workloads(containers []docker.Container, host string) (workloads []registry
|
||||
if collides(c) {
|
||||
continue
|
||||
}
|
||||
down = append(down, c.services...)
|
||||
for _, s := range c.services {
|
||||
_, name, _ := strings.Cut(s, ":")
|
||||
down = append(down, name)
|
||||
}
|
||||
}
|
||||
workloads = append(workloads, c.workload)
|
||||
}
|
||||
@@ -142,6 +157,18 @@ type serviceLabels struct {
|
||||
func translatePorts(c docker.Container) (map[string]string, string) {
|
||||
labels := maps.Clone(c.Labels)
|
||||
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) {
|
||||
for _, pub := range reachable {
|
||||
return pub, len(reachable) == 1
|
||||
@@ -153,10 +180,10 @@ func translatePorts(c docker.Container) (map[string]string, string) {
|
||||
hasRouters := false
|
||||
for k := range labels {
|
||||
lk := strings.ToLower(k)
|
||||
if strings.HasPrefix(lk, "traefik.http.routers.") {
|
||||
if strings.HasPrefix(lk, routersPrefix(proto)) {
|
||||
hasRouters = true
|
||||
}
|
||||
rest, ok := strings.CutPrefix(lk, servicesPrefix)
|
||||
rest, ok := strings.CutPrefix(lk, svcPrefix)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
@@ -166,7 +193,7 @@ func translatePorts(c docker.Container) (map[string]string, string) {
|
||||
}
|
||||
s := services[svc]
|
||||
if s == nil {
|
||||
name, _, _ := strings.Cut(k[len(servicesPrefix):], ".")
|
||||
name, _, _ := strings.Cut(k[len(svcPrefix):], ".")
|
||||
s = &serviceLabels{name: name}
|
||||
services[svc] = s
|
||||
}
|
||||
@@ -177,35 +204,35 @@ func translatePorts(c docker.Container) (map[string]string, string) {
|
||||
|
||||
if len(services) == 0 {
|
||||
if !hasRouters {
|
||||
return labels, ""
|
||||
return ""
|
||||
}
|
||||
pub, ok := single()
|
||||
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(), "-")
|
||||
labels[servicesPrefix+svc+".loadbalancer.server.port"] = strconv.Itoa(int(pub))
|
||||
return labels, ""
|
||||
labels[svcPrefix+svc+".loadbalancer.server.port"] = strconv.Itoa(int(pub))
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, s := range services {
|
||||
if s.portKey == "" {
|
||||
pub, ok := single()
|
||||
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
|
||||
}
|
||||
private, err := strconv.Atoi(strings.TrimSpace(labels[s.portKey]))
|
||||
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)]
|
||||
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))
|
||||
}
|
||||
return labels, ""
|
||||
return ""
|
||||
}
|
||||
@@ -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
@@ -13,6 +13,9 @@
|
||||
// Mahnstufe 1/2 zeigt er einen Hinweis und leitet per
|
||||
// <meta http-equiv="refresh"> zurück; ein Cookie lässt den Besucher
|
||||
// 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
|
||||
// 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).
|
||||
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
|
||||
// per customRequestHeaders und überschreibt damit Werte des Besuchers.
|
||||
const (
|
||||
@@ -164,6 +172,8 @@ const (
|
||||
ViolationFeature ViolationKind = "feature"
|
||||
ViolationForbiddenMiddleware ViolationKind = "forbidden_middleware"
|
||||
ViolationMiddlewareTarget ViolationKind = "middleware_target"
|
||||
ViolationTCPPassthrough ViolationKind = "tcp_passthrough"
|
||||
ViolationEntryPoint ViolationKind = "entrypoint"
|
||||
)
|
||||
|
||||
// Violation ist ein Verstoß eines Routers (Originalname).
|
||||
@@ -254,14 +264,21 @@ func (e *Engine) Apply(in Input) (Result, error) {
|
||||
at := now()
|
||||
stage := StageFor(in.Tenant, in.License, at)
|
||||
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
|
||||
}
|
||||
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
|
||||
// deterministisch sind).
|
||||
var accepted []string
|
||||
var accepted, acceptedTCP []string
|
||||
for _, name := range sortedKeys(src.Routers) {
|
||||
blocked, observed := e.checkRouter(name, src.Routers[name], src, in.License)
|
||||
res.Violations = append(res.Violations, observed...)
|
||||
@@ -271,21 +288,44 @@ func (e *Engine) Apply(in Input) (Result, error) {
|
||||
}
|
||||
accepted = append(accepted, name)
|
||||
}
|
||||
|
||||
// 2. Routenlimit: überzählige Router (nach Namen) verwerfen.
|
||||
if !in.License.AllowsRoutes(len(accepted)) {
|
||||
for _, name := range accepted[in.License.MaxRoutes:] {
|
||||
res.Violations = append(res.Violations, Violation{Kind: ViolationRouteLimit, Router: name,
|
||||
Detail: fmt.Sprintf("max_routes = %d", in.License.MaxRoutes)})
|
||||
for _, name := range sortedKeys(tcpSrc.Routers) {
|
||||
if v := e.checkTCPRouter(name, tcpSrc.Routers[name], tcpSrc, in.License); v != nil {
|
||||
res.Violations = append(res.Violations, *v)
|
||||
continue
|
||||
}
|
||||
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.
|
||||
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{
|
||||
Routers: map[string]*traefik.Router{},
|
||||
Services: map[string]*traefik.Service{},
|
||||
@@ -324,7 +364,7 @@ func (e *Engine) Apply(in Input) (Result, error) {
|
||||
}
|
||||
if routerStage != StageNormal {
|
||||
if e.InterceptorURL == "" {
|
||||
return Result{}, ErrNoInterceptor
|
||||
return nil, ErrNoInterceptor
|
||||
}
|
||||
if slices.Contains(ackStages, routerStage) {
|
||||
// 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 {
|
||||
out.Middlewares = nil
|
||||
}
|
||||
res.Config = &traefik.Configuration{HTTP: out}
|
||||
return res, nil
|
||||
return out, 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
|
||||
@@ -482,6 +557,50 @@ func (e *Engine) checkRouterRules(name string, r *traefik.Router, src *traefik.H
|
||||
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 sortedKeys[V any](m map[string]V) []string {
|
||||
@@ -507,6 +626,14 @@ func copyService(s *traefik.Service) *traefik.Service {
|
||||
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 {
|
||||
return traefik.Middleware(copyMap(map[string]any(m)))
|
||||
}
|
||||
|
||||
+33
-7
@@ -24,6 +24,29 @@ var (
|
||||
// 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.
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -36,7 +59,8 @@ func HostsFromRule(rule string) ([]string, error) {
|
||||
if p.peek().kind != tEOF {
|
||||
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 {
|
||||
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
|
||||
// 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 {
|
||||
case opCall:
|
||||
switch strings.ToLower(n.name) {
|
||||
case "host":
|
||||
case a.host:
|
||||
if positive {
|
||||
return true, n.args, nil
|
||||
}
|
||||
case "hostregexp":
|
||||
case a.regexp:
|
||||
if positive {
|
||||
return false, nil, ErrHostRegexp
|
||||
}
|
||||
}
|
||||
return false, nil, nil
|
||||
case opNot:
|
||||
_, h, err := analyze(n.kids[0], !positive)
|
||||
_, h, err := a.analyze(n.kids[0], !positive)
|
||||
return false, h, err
|
||||
default: // opAnd, opOr
|
||||
c1, h1, err := analyze(n.kids[0], positive)
|
||||
c1, h1, err := a.analyze(n.kids[0], positive)
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
c2, h2, err := analyze(n.kids[1], positive)
|
||||
c2, h2, err := a.analyze(n.kids[1], positive)
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -239,9 +239,16 @@ func (r *Registry) evaluate(ctx context.Context, nodeID string, state NodeState)
|
||||
continue
|
||||
}
|
||||
merged = m
|
||||
if maint, ok := workloadMaintenance(w.Labels); ok && c.HTTP != nil {
|
||||
for svc := range c.HTTP.Services {
|
||||
serviceMaint[svc] = maint
|
||||
if maint, ok := workloadMaintenance(w.Labels); ok {
|
||||
if c.HTTP != nil {
|
||||
for svc := range c.HTTP.Services {
|
||||
serviceMaint[svc] = maint
|
||||
}
|
||||
}
|
||||
if c.TCP != nil {
|
||||
for svc := range c.TCP.Services {
|
||||
serviceMaint[svc] = maint
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
// Configuration ist die Wurzel der dynamischen Traefik-Konfiguration.
|
||||
type Configuration struct {
|
||||
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.
|
||||
@@ -60,6 +61,43 @@ type Server struct {
|
||||
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.
|
||||
// {"redirectRegex": {"regex": "...", "permanent": "true"}}.
|
||||
//
|
||||
|
||||
+123
-17
@@ -46,6 +46,8 @@ func FromLabels(host string, labels map[string]string) (*Configuration, error) {
|
||||
routers: map[string]*Router{},
|
||||
services: map[string]*serviceSpec{},
|
||||
middlewares: map[string]Middleware{},
|
||||
tcpRouters: map[string]*TCPRouter{},
|
||||
tcpServices: map[string]*serviceSpec{},
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(labels))
|
||||
@@ -93,6 +95,8 @@ type builder struct {
|
||||
routers map[string]*Router
|
||||
services map[string]*serviceSpec
|
||||
middlewares map[string]Middleware
|
||||
tcpRouters map[string]*TCPRouter
|
||||
tcpServices map[string]*serviceSpec
|
||||
errs []error
|
||||
}
|
||||
|
||||
@@ -101,7 +105,7 @@ func (b *builder) apply(key, value string) error {
|
||||
switch strings.ToLower(parts[0]) {
|
||||
case "enable", "docker":
|
||||
return nil
|
||||
case "http":
|
||||
case "http", "tcp":
|
||||
default:
|
||||
return ErrUnsupportedLabel
|
||||
}
|
||||
@@ -112,6 +116,9 @@ func (b *builder) apply(key, value string) error {
|
||||
if name == "" {
|
||||
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 {
|
||||
case "routers":
|
||||
r, ok := b.routers[name]
|
||||
@@ -173,6 +180,65 @@ func setRouterAttr(r *Router, attr, value string) error {
|
||||
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 {
|
||||
if r.TLS == nil {
|
||||
r.TLS = &RouterTLS{}
|
||||
@@ -239,15 +305,20 @@ func setMiddlewareAttr(m Middleware, attr []string, value string) error {
|
||||
}
|
||||
|
||||
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 {
|
||||
return &Configuration{}
|
||||
return nil
|
||||
}
|
||||
http := &HTTPConfiguration{}
|
||||
|
||||
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{}
|
||||
for name, s := range b.services {
|
||||
if s.port == "" {
|
||||
@@ -271,17 +342,8 @@ func (b *builder) build(host string) *Configuration {
|
||||
if r.Rule == "" {
|
||||
b.errs = append(b.errs, fmt.Errorf("router %q: %w: rule fehlt", name, ErrIncomplete))
|
||||
}
|
||||
if r.Service != "" {
|
||||
continue
|
||||
}
|
||||
// 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
|
||||
if r.Service == "" {
|
||||
r.Service = b.defaultService("router", name, b.services)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -289,7 +351,51 @@ func (b *builder) build(host string) *Configuration {
|
||||
if len(b.middlewares) > 0 {
|
||||
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 {
|
||||
|
||||
@@ -276,7 +276,7 @@ func TestFromLabels_Errors(t *testing.T) {
|
||||
labels map[string]string
|
||||
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},
|
||||
{"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},
|
||||
@@ -333,7 +333,7 @@ func TestFromLabels_Middlewares_ObjectThenValue(t *testing.T) {
|
||||
func TestFromLabels_CollectsAllErrors(t *testing.T) {
|
||||
_, err := FromLabels("h", map[string]string{
|
||||
"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.routers.r.service": "s",
|
||||
})
|
||||
|
||||
+21
-7
@@ -21,22 +21,36 @@ func Merge(configs ...*Configuration) (*Configuration, error) {
|
||||
Services: map[string]*Service{},
|
||||
Middlewares: map[string]Middleware{},
|
||||
}
|
||||
tcp := &TCPConfiguration{
|
||||
Routers: map[string]*TCPRouter{},
|
||||
Services: map[string]*TCPService{},
|
||||
}
|
||||
var errs []error
|
||||
for _, c := range configs {
|
||||
if c == nil || c.HTTP == nil {
|
||||
if c == nil {
|
||||
continue
|
||||
}
|
||||
errs = append(errs, mergeInto(out.Routers, c.HTTP.Routers, "router")...)
|
||||
errs = append(errs, mergeInto(out.Services, c.HTTP.Services, "service")...)
|
||||
errs = append(errs, mergeInto(out.Middlewares, c.HTTP.Middlewares, "middleware")...)
|
||||
if c.HTTP != nil {
|
||||
errs = append(errs, mergeInto(out.Routers, c.HTTP.Routers, "router")...)
|
||||
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 {
|
||||
return nil, errors.Join(errs...)
|
||||
}
|
||||
if len(out.Routers) == 0 && len(out.Services) == 0 && len(out.Middlewares) == 0 {
|
||||
return &Configuration{}, nil
|
||||
res := &Configuration{}
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user