tcp stream (und damit: tls passthrough) ist implementiert

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

No files matched your search

+141 -14
View File
@@ -13,6 +13,9 @@
// Mahnstufe 1/2 zeigt er einen Hinweis und leitet per
// <meta http-equiv="refresh"> zurück; ein Cookie lässt den Besucher
// danach über einen zusätzlichen Router direkt zum Original durch.
// - TCP (nur TLS-Passthrough, Feature tcp_stream): HostSNI mit expliziten
// eigenen Hosts, nur Entrypoint websecure. Ohne Interceptor entfällt die
// Route ab Paywall und bei jeder Wartung; Hinweisstufen lassen durch.
//
// Verstöße entfernen den betroffenen Router und werden als Violation
// gemeldet (für das Audit-Log); der Rest des Knotens bleibt aktiv.
@@ -56,6 +59,11 @@ const (
// Das Präfix "sultan__" ist reserviert (Mandanten-ID "sultan" ist verboten).
const InterceptorService = "sultan__interceptor"
// TCPEntryPoint ist der einzige Entrypoint für TCP-Router (TLS-Passthrough
// per SNI auf :443). Sultan setzt ihn immer; ohne Angabe läge ein TCP-Router
// in Traefik auf allen Entrypoints.
const TCPEntryPoint = "websecure"
// Header, mit denen der Interceptor seinen Auftrag erhält. Traefik setzt sie
// per customRequestHeaders und überschreibt damit Werte des Besuchers.
const (
@@ -164,6 +172,8 @@ const (
ViolationFeature ViolationKind = "feature"
ViolationForbiddenMiddleware ViolationKind = "forbidden_middleware"
ViolationMiddlewareTarget ViolationKind = "middleware_target"
ViolationTCPPassthrough ViolationKind = "tcp_passthrough"
ViolationEntryPoint ViolationKind = "entrypoint"
)
// Violation ist ein Verstoß eines Routers (Originalname).
@@ -254,14 +264,21 @@ func (e *Engine) Apply(in Input) (Result, error) {
at := now()
stage := StageFor(in.Tenant, in.License, at)
res := Result{Config: &traefik.Configuration{}, Stage: stage}
if stage == StageBlocked || in.NodeDrained || in.Config == nil || in.Config.HTTP == nil {
if stage == StageBlocked || in.NodeDrained || in.Config == nil || (in.Config.HTTP == nil && in.Config.TCP == nil) {
return res, nil
}
src := in.Config.HTTP
if src == nil {
src = &traefik.HTTPConfiguration{}
}
tcpSrc := in.Config.TCP
if tcpSrc == nil {
tcpSrc = &traefik.TCPConfiguration{}
}
// 1. Router einzeln prüfen (sortiert, damit Ergebnis und Routenlimit
// deterministisch sind).
var accepted []string
var accepted, acceptedTCP []string
for _, name := range sortedKeys(src.Routers) {
blocked, observed := e.checkRouter(name, src.Routers[name], src, in.License)
res.Violations = append(res.Violations, observed...)
@@ -271,21 +288,44 @@ func (e *Engine) Apply(in Input) (Result, error) {
}
accepted = append(accepted, name)
}
// 2. Routenlimit: überzählige Router (nach Namen) verwerfen.
if !in.License.AllowsRoutes(len(accepted)) {
for _, name := range accepted[in.License.MaxRoutes:] {
res.Violations = append(res.Violations, Violation{Kind: ViolationRouteLimit, Router: name,
Detail: fmt.Sprintf("max_routes = %d", in.License.MaxRoutes)})
for _, name := range sortedKeys(tcpSrc.Routers) {
if v := e.checkTCPRouter(name, tcpSrc.Routers[name], tcpSrc, in.License); v != nil {
res.Violations = append(res.Violations, *v)
continue
}
accepted = accepted[:in.License.MaxRoutes]
acceptedTCP = append(acceptedTCP, name)
}
if len(accepted) == 0 {
return res, nil
// 2. Routenlimit über HTTP- und TCP-Router: HTTP zuerst, überzählige
// (nach Namen) verwerfen.
if !in.License.AllowsRoutes(len(accepted) + len(acceptedTCP)) {
limit := func(names []string, max int) []string {
max = min(max, len(names))
for _, name := range names[max:] {
res.Violations = append(res.Violations, Violation{Kind: ViolationRouteLimit, Router: name,
Detail: fmt.Sprintf("max_routes = %d", in.License.MaxRoutes)})
}
return names[:max]
}
accepted = limit(accepted, in.License.MaxRoutes)
acceptedTCP = limit(acceptedTCP, in.License.MaxRoutes-len(accepted))
}
// 3. Ausgabe mit Namensraum und ggf. Umleitung zum Interceptor.
prefix := in.Tenant.ID + "__" + in.NodeID + "__"
if len(accepted) > 0 {
out, err := e.httpOutput(in, src, accepted, stage, prefix, at)
if err != nil {
return Result{}, err
}
res.Config.HTTP = out
}
res.Config.TCP = tcpOutput(in, tcpSrc, acceptedTCP, stage, prefix, at)
return res, nil
}
// httpOutput erzeugt die HTTP-Konfiguration der angenommenen Router.
func (e *Engine) httpOutput(in Input, src *traefik.HTTPConfiguration, accepted []string, stage Stage, prefix string, at time.Time) (*traefik.HTTPConfiguration, error) {
out := &traefik.HTTPConfiguration{
Routers: map[string]*traefik.Router{},
Services: map[string]*traefik.Service{},
@@ -324,7 +364,7 @@ func (e *Engine) Apply(in Input) (Result, error) {
}
if routerStage != StageNormal {
if e.InterceptorURL == "" {
return Result{}, ErrNoInterceptor
return nil, ErrNoInterceptor
}
if slices.Contains(ackStages, routerStage) {
// Durchlass nach bestätigtem Hinweis: gleiche Regel + Cookie,
@@ -369,8 +409,43 @@ func (e *Engine) Apply(in Input) (Result, error) {
if len(out.Middlewares) == 0 {
out.Middlewares = nil
}
res.Config = &traefik.Configuration{HTTP: out}
return res, nil
return out, nil
}
// tcpOutput erzeugt die TCP-Konfiguration der angenommenen Router. Ohne HTTP
// gibt es keinen Interceptor: Hinweisstufen lassen durch, ab Paywall und bei
// jeder Wartung (auch Healthcheck, getrennter Knoten) entfällt die Route.
func tcpOutput(in Input, src *traefik.TCPConfiguration, accepted []string, stage Stage, prefix string, at time.Time) *traefik.TCPConfiguration {
if stage == StagePaywall || stage == StageLanding {
return nil
}
out := &traefik.TCPConfiguration{
Routers: map[string]*traefik.TCPRouter{},
Services: map[string]*traefik.TCPService{},
}
for _, name := range accepted {
r := src.Routers[name]
if _, inMaint := maintenanceFor(in, r.Service, at); inMaint {
continue
}
nr := *r
nr.EntryPoints = []string{TCPEntryPoint}
nr.TLS = &traefik.TCPRouterTLS{Passthrough: true}
if isExternal(r.Service) {
nr.Service = r.Service
} else {
nr.Service = prefix + r.Service
out.Services[nr.Service] = copyTCPService(src.Services[r.Service])
}
out.Routers[prefix+name] = &nr
}
if len(out.Routers) == 0 {
return nil
}
if len(out.Services) == 0 {
out.Services = nil
}
return out
}
// maintenanceFor liefert die geltende Wartung eines Services: zuerst die des
@@ -482,6 +557,50 @@ func (e *Engine) checkRouterRules(name string, r *traefik.Router, src *traefik.H
return nil, nil
}
// checkTCPRouter prüft einen TCP-Router: nur mit Feature tcp_stream, nur
// TLS-Passthrough auf TCPEntryPoint, nur explizite eigene Hosts per HostSNI.
func (e *Engine) checkTCPRouter(name string, r *traefik.TCPRouter, src *traefik.TCPConfiguration, l license.License) *Violation {
v := func(kind ViolationKind, format string, args ...any) *Violation {
return &Violation{Kind: kind, Router: name, Detail: "TCP: " + fmt.Sprintf(format, args...)}
}
if !l.HasFeature(license.FeatureTCPStream) {
return v(ViolationFeature, "TCP-Router erfordert %s", license.FeatureTCPStream)
}
hosts, err := HostsFromSNIRule(r.Rule)
switch {
case errors.Is(err, ErrUnconstrained):
return v(ViolationCatchAll, "Regel %q nicht auf explizite Hosts beschränkt", r.Rule)
case errors.Is(err, ErrHostRegexp):
return v(ViolationHostRegexp, "Regel %q", r.Rule)
case err != nil:
return v(ViolationRuleSyntax, "%v", err)
}
for _, h := range hosts {
if !l.AllowsDomain(h) {
return v(ViolationDomain, "Host %q nicht in allowed_domains", h)
}
}
if r.TLS == nil || !r.TLS.Passthrough {
return v(ViolationTCPPassthrough, "nur tls.passthrough=true wird unterstützt")
}
for _, ep := range r.EntryPoints {
if ep != TCPEntryPoint {
return v(ViolationEntryPoint, "Entrypoint %q nicht erlaubt (nur %s)", ep, TCPEntryPoint)
}
}
if isExternal(r.Service) {
if !slices.Contains(e.AllowedExternalRefs, r.Service) {
return v(ViolationExternalRef, "Service %q", r.Service)
}
} else if src.Services[r.Service] == nil {
return v(ViolationUnknownRef, "Service %q nicht auf diesem Knoten definiert", r.Service)
}
return nil
}
func isExternal(ref string) bool { return strings.Contains(ref, "@") }
func sortedKeys[V any](m map[string]V) []string {
@@ -507,6 +626,14 @@ func copyService(s *traefik.Service) *traefik.Service {
return c
}
func copyTCPService(s *traefik.TCPService) *traefik.TCPService {
c := &traefik.TCPService{}
if s.LoadBalancer != nil {
c.LoadBalancer = &traefik.TCPServersLoadBalancer{Servers: slices.Clone(s.LoadBalancer.Servers)}
}
return c
}
func copyMiddleware(m traefik.Middleware) traefik.Middleware {
return traefik.Middleware(copyMap(map[string]any(m)))
}