diff --git a/.ai/sultan-system-architecture.md b/.ai/sultan-system-architecture.md index e6038f6..69c1a49 100644 --- a/.ai/sultan-system-architecture.md +++ b/.ai/sultan-system-architecture.md @@ -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..{rule,entrypoints,service,priority,tls,tls.passthrough}` und `traefik.tcp.services..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 `____`. + * **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//`, `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_` 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 | diff --git a/CLAUDE.md b/CLAUDE.md index a87aec1..9ff44b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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_.[.]` (`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; `*.` 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 `____`, 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______` per Cookie `sultan_ack=`. `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 `____`, 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______` per Cookie `sultan_ack=`. `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 diff --git a/docs/community.md b/docs/community.md index 4751859..742db7d 100644 --- a/docs/community.md +++ b/docs/community.md @@ -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://:`. Prüfen vom **Ingress** aus: diff --git a/docs/hoster.md b/docs/hoster.md index f999263..7f47f6b 100644 --- a/docs/hoster.md +++ b/docs/hoster.md @@ -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.` zählt, auch Redirect-Router). `-1` = unbegrenzt. Überzählige werden alphabetisch verworfen. | +| Routen | `--max-routes` | Anzahl Router (jeder `traefik.http.routers.` und `traefik.tcp.routers.` 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. diff --git a/pkg/agent/workloads.go b/pkg/agent/workloads.go index 6859f2a..45e0222 100644 --- a/pkg/agent/workloads.go +++ b/pkg/agent/workloads.go @@ -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 // ":" + services []string // ":" } 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 "" } diff --git a/pkg/agent/workloads_tcp_test.go b/pkg/agent/workloads_tcp_test.go new file mode 100644 index 0000000..568b3a0 --- /dev/null +++ b/pkg/agent/workloads_tcp_test.go @@ -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) + } +} diff --git a/pkg/policy/policy.go b/pkg/policy/policy.go index 5bff20f..81a5441 100644 --- a/pkg/policy/policy.go +++ b/pkg/policy/policy.go @@ -13,6 +13,9 @@ // Mahnstufe 1/2 zeigt er einen Hinweis und leitet per // 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))) } diff --git a/pkg/policy/rule.go b/pkg/policy/rule.go index e842273..d919c89 100644 --- a/pkg/policy/rule.go +++ b/pkg/policy/rule.go @@ -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 } diff --git a/pkg/policy/tcp_test.go b/pkg/policy/tcp_test.go new file mode 100644 index 0000000..9a44c52 --- /dev/null +++ b/pkg/policy/tcp_test.go @@ -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") + } +} diff --git a/pkg/registry/registry.go b/pkg/registry/registry.go index 07d6a73..3628093 100644 --- a/pkg/registry/registry.go +++ b/pkg/registry/registry.go @@ -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 + } } } } diff --git a/pkg/registry/tcp_test.go b/pkg/registry/tcp_test.go new file mode 100644 index 0000000..d76ee0c --- /dev/null +++ b/pkg/registry/tcp_test.go @@ -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) + } +} diff --git a/pkg/traefik/config.go b/pkg/traefik/config.go index 4cdc434..ed4b54a 100644 --- a/pkg/traefik/config.go +++ b/pkg/traefik/config.go @@ -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"}}. // diff --git a/pkg/traefik/labels.go b/pkg/traefik/labels.go index 6362a00..a8968e0 100644 --- a/pkg/traefik/labels.go +++ b/pkg/traefik/labels.go @@ -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 { diff --git a/pkg/traefik/labels_test.go b/pkg/traefik/labels_test.go index 87f26b0..dfd501f 100644 --- a/pkg/traefik/labels_test.go +++ b/pkg/traefik/labels_test.go @@ -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", }) diff --git a/pkg/traefik/merge.go b/pkg/traefik/merge.go index 3531288..73f144f 100644 --- a/pkg/traefik/merge.go +++ b/pkg/traefik/merge.go @@ -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 { diff --git a/pkg/traefik/tcp_test.go b/pkg/traefik/tcp_test.go new file mode 100644 index 0000000..5a59ed7 --- /dev/null +++ b/pkg/traefik/tcp_test.go @@ -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) + } +}