From fdcd06c93fa806d89d053d6ef514685103ffe21a Mon Sep 17 00:00:00 2001
From: Bjoern Runden
Date: Sun, 11 Oct 2026 01:04:45 +0200
Subject: [PATCH] tcp stream (und damit: tls passthrough) ist implementiert
---
.ai/sultan-system-architecture.md | 11 +-
CLAUDE.md | 6 +-
docs/community.md | 20 +++
docs/hoster.md | 4 +-
pkg/agent/workloads.go | 65 +++++---
pkg/agent/workloads_tcp_test.go | 78 +++++++++
pkg/policy/policy.go | 155 +++++++++++++++--
pkg/policy/rule.go | 40 ++++-
pkg/policy/tcp_test.go | 269 ++++++++++++++++++++++++++++++
pkg/registry/registry.go | 13 +-
pkg/registry/tcp_test.go | 53 ++++++
pkg/traefik/config.go | 38 +++++
pkg/traefik/labels.go | 140 ++++++++++++++--
pkg/traefik/labels_test.go | 4 +-
pkg/traefik/merge.go | 28 +++-
pkg/traefik/tcp_test.go | 159 ++++++++++++++++++
16 files changed, 1008 insertions(+), 75 deletions(-)
create mode 100644 pkg/agent/workloads_tcp_test.go
create mode 100644 pkg/policy/tcp_test.go
create mode 100644 pkg/registry/tcp_test.go
create mode 100644 pkg/traefik/tcp_test.go
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)
+ }
+}