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

+38
View File
@@ -18,6 +18,7 @@ import (
// Configuration ist die Wurzel der dynamischen Traefik-Konfiguration.
type Configuration struct {
HTTP *HTTPConfiguration `json:"http,omitempty" yaml:"http,omitempty"`
TCP *TCPConfiguration `json:"tcp,omitempty" yaml:"tcp,omitempty"`
}
// HTTPConfiguration enthält Router, Services und Middlewares der HTTP-Ebene.
@@ -60,6 +61,43 @@ type Server struct {
URL string `json:"url" yaml:"url"`
}
// TCPConfiguration enthält Router und Services der TCP-Ebene. Sultan nutzt
// sie nur für TLS-Passthrough (Routing per SNI, TLS endet beim Backend).
type TCPConfiguration struct {
Routers map[string]*TCPRouter `json:"routers,omitempty" yaml:"routers,omitempty"`
Services map[string]*TCPService `json:"services,omitempty" yaml:"services,omitempty"`
}
// TCPRouter entspricht einem Traefik-TCP-Router.
type TCPRouter struct {
EntryPoints []string `json:"entryPoints,omitempty" yaml:"entryPoints,omitempty"`
Service string `json:"service" yaml:"service"`
Rule string `json:"rule" yaml:"rule"`
Priority int `json:"priority,omitempty" yaml:"priority,omitempty"`
TLS *TCPRouterTLS `json:"tls,omitempty" yaml:"tls,omitempty"`
}
// TCPRouterTLS aktiviert TLS für einen TCP-Router; Passthrough reicht die
// Verbindung unverändert an das Backend durch.
type TCPRouterTLS struct {
Passthrough bool `json:"passthrough,omitempty" yaml:"passthrough,omitempty"`
}
// TCPService entspricht einem Traefik-TCP-Service.
type TCPService struct {
LoadBalancer *TCPServersLoadBalancer `json:"loadBalancer,omitempty" yaml:"loadBalancer,omitempty"`
}
// TCPServersLoadBalancer verteilt Verbindungen auf die angegebenen Server.
type TCPServersLoadBalancer struct {
Servers []TCPServer `json:"servers" yaml:"servers"`
}
// TCPServer ist ein einzelnes TCP-Backend (host:port).
type TCPServer struct {
Address string `json:"address" yaml:"address"`
}
// Middleware ist eine Traefik-Middleware als verschachtelte Map, z. B.
// {"redirectRegex": {"regex": "...", "permanent": "true"}}.
//
+123 -17
View File
@@ -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 {
+2 -2
View File
@@ -276,7 +276,7 @@ func TestFromLabels_Errors(t *testing.T) {
labels map[string]string
want error
}{
{"TCP nicht unterstützt", "h", map[string]string{"traefik.tcp.routers.db.rule": "HostSNI(`*`)"}, ErrUnsupportedLabel},
{"UDP nicht unterstützt", "h", map[string]string{"traefik.udp.routers.dns.entrypoints": "dns"}, ErrUnsupportedLabel},
{"UDP nicht unterstützt", "h", map[string]string{"traefik.udp.services.dns.loadbalancer.server.port": "53"}, ErrUnsupportedLabel},
{"unbekannte HTTP-Art", "h", map[string]string{"traefik.http.serverstransports.x.insecureskipverify": "true"}, ErrUnsupportedLabel},
{"Schlüssel zu kurz", "h", map[string]string{"traefik.http.routers.r": "x"}, ErrUnsupportedLabel},
@@ -333,7 +333,7 @@ func TestFromLabels_Middlewares_ObjectThenValue(t *testing.T) {
func TestFromLabels_CollectsAllErrors(t *testing.T) {
_, err := FromLabels("h", map[string]string{
"traefik.enable": "true",
"traefik.tcp.routers.db.rule": "HostSNI(`*`)",
"traefik.udp.routers.dns.entrypoints": "dns",
"traefik.http.services.s.loadbalancer.server.port": "nope",
"traefik.http.routers.r.service": "s",
})
+21 -7
View File
@@ -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 {
+159
View File
@@ -0,0 +1,159 @@
package traefik
import (
"errors"
"reflect"
"testing"
)
// TLS-Passthrough für eigene CAs (z. B. tinyCA im Kundenlabor).
func caLabels() map[string]string {
return map[string]string{
"traefik.enable": "true",
"traefik.tcp.routers.ca.rule": "HostSNI(`ca.lab.kunde.de`)",
"traefik.tcp.routers.ca.entrypoints": "websecure",
"traefik.tcp.routers.ca.tls.passthrough": "true",
"traefik.tcp.services.ca.loadbalancer.server.port": "9000",
}
}
func TestFromLabels_TCPPassthrough(t *testing.T) {
got, err := FromLabels("100.64.0.42", caLabels())
if err != nil {
t.Fatalf("unerwarteter Fehler: %v", err)
}
want := &Configuration{TCP: &TCPConfiguration{
Routers: map[string]*TCPRouter{
"ca": {
EntryPoints: []string{"websecure"},
Service: "ca",
Rule: "HostSNI(`ca.lab.kunde.de`)",
TLS: &TCPRouterTLS{Passthrough: true},
},
},
Services: map[string]*TCPService{
"ca": {LoadBalancer: &TCPServersLoadBalancer{Servers: []TCPServer{{Address: "100.64.0.42:9000"}}}},
},
}}
if !reflect.DeepEqual(got, want) {
t.Errorf("Konfiguration weicht ab\n got: %s\nwant: %s", mustJSON(t, got), mustJSON(t, want))
}
}
func TestFromLabels_TCPAttributes(t *testing.T) {
l := caLabels()
delete(l, "traefik.tcp.routers.ca.tls.passthrough")
l["traefik.TCP.Routers.ca.TLS"] = "true"
l["traefik.tcp.routers.ca.priority"] = "7"
l["traefik.tcp.routers.ca.service"] = "ca"
got, err := FromLabels("2001:db8::1", l)
if err != nil {
t.Fatalf("unerwarteter Fehler: %v", err)
}
r := got.TCP.Routers["ca"]
if r.Priority != 7 || r.TLS == nil || r.TLS.Passthrough {
t.Errorf("Router = %+v", r)
}
if addr := got.TCP.Services["ca"].LoadBalancer.Servers[0].Address; addr != "[2001:db8::1]:9000" {
t.Errorf("Adresse = %q", addr)
}
}
// Ohne service-Label bindet sich der Router an den einzigen TCP-Service.
func TestFromLabels_TCPDefaultServiceBinding(t *testing.T) {
got, err := FromLabels("10.0.0.1", caLabels())
if err != nil {
t.Fatal(err)
}
if got.TCP.Routers["ca"].Service != "ca" {
t.Errorf("Service = %q", got.TCP.Routers["ca"].Service)
}
}
// HTTP und TCP im selben Container: getrennte Namensräume, wie bei Traefik.
func TestFromLabels_HTTPAndTCPTogether(t *testing.T) {
l := caLabels()
l["traefik.http.routers.ca.rule"] = "Host(`ca.lab.kunde.de`)"
l["traefik.http.routers.ca.entrypoints"] = "web"
l["traefik.http.services.ca.loadbalancer.server.port"] = "80"
got, err := FromLabels("10.0.0.1", l)
if err != nil {
t.Fatal(err)
}
if got.HTTP.Routers["ca"].Service != "ca" || got.TCP.Routers["ca"].Service != "ca" {
t.Errorf("Bindung falsch: %s", mustJSON(t, got))
}
}
func TestFromLabels_TCPErrors(t *testing.T) {
tests := map[string]struct {
key, value string
want error
}{
"certresolver": {"traefik.tcp.routers.ca.tls.certresolver", "le", ErrUnsupportedLabel},
"middlewares": {"traefik.tcp.routers.ca.middlewares", "x", ErrUnsupportedLabel},
"tcp middlewares": {"traefik.tcp.middlewares.x.ipallowlist.sourcerange", "10.0.0.0/8", ErrUnsupportedLabel},
"proxyprotocol": {"traefik.tcp.services.ca.loadbalancer.proxyprotocol.version", "2", ErrUnsupportedLabel},
"udp": {"traefik.udp.routers.dns.entrypoints", "dns", ErrUnsupportedLabel},
"passthrough kaputt": {"traefik.tcp.routers.ca.tls.passthrough", "vielleicht", ErrInvalidLabel},
"port kaputt": {"traefik.tcp.services.ca.loadbalancer.server.port", "0", ErrInvalidLabel},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
l := caLabels()
l[tt.key] = tt.value
if _, err := FromLabels("10.0.0.1", l); !errors.Is(err, tt.want) {
t.Errorf("erwartet %v, bekommen %v", tt.want, err)
}
})
}
l := caLabels()
delete(l, "traefik.tcp.routers.ca.rule")
if _, err := FromLabels("10.0.0.1", l); !errors.Is(err, ErrIncomplete) {
t.Errorf("fehlende Regel: %v", err)
}
}
func TestMerge_TCP(t *testing.T) {
a, err := FromLabels("10.0.0.1", caLabels())
if err != nil {
t.Fatal(err)
}
b, err := FromLabels("10.0.0.2", map[string]string{
"traefik.enable": "true",
"traefik.tcp.routers.db.rule": "HostSNI(`db.kunde.de`)",
"traefik.tcp.routers.db.tls.passthrough": "true",
"traefik.tcp.services.db.loadbalancer.server.port": "5432",
})
if err != nil {
t.Fatal(err)
}
got, err := Merge(a, b, nil)
if err != nil {
t.Fatalf("Merge: %v", err)
}
if len(got.TCP.Routers) != 2 || len(got.TCP.Services) != 2 || got.HTTP != nil {
t.Errorf("Ergebnis: %s", mustJSON(t, got))
}
c, _ := FromLabels("10.0.0.3", caLabels()) // gleicher Name, andere Adresse
if _, err := Merge(a, c); !errors.Is(err, ErrConflict) {
t.Errorf("erwartet ErrConflict, bekommen %v", err)
}
}
func TestMarshal_TCP(t *testing.T) {
c, err := FromLabels("10.0.0.1", caLabels())
if err != nil {
t.Fatal(err)
}
got, err := c.Marshal(FormatJSON)
if err != nil {
t.Fatal(err)
}
want := `{"tcp":{"routers":{"ca":{"entryPoints":["websecure"],"service":"ca","rule":"HostSNI(` + "`ca.lab.kunde.de`" + `)","tls":{"passthrough":true}}},"services":{"ca":{"loadBalancer":{"servers":[{"address":"10.0.0.1:9000"}]}}}}}`
if string(got) != want {
t.Errorf("JSON\n got: %s\nwant: %s", got, want)
}
}