package traefik import ( "errors" "fmt" "net" "sort" "strconv" "strings" ) var ( // ErrUnsupportedLabel meldet ein Traefik-Label, das Sultan (noch) nicht übersetzt. ErrUnsupportedLabel = errors.New("nicht unterstütztes Label") // ErrInvalidLabel meldet ein Label mit ungültigem Wert. ErrInvalidLabel = errors.New("ungültiges Label") // ErrIncomplete meldet eine Konfiguration, der Pflichtangaben fehlen. ErrIncomplete = errors.New("unvollständige Konfiguration") ) const labelPrefix = "traefik." // FromLabels übersetzt die flachen Traefik-Labels eines Containers (bzw. // einer /etc/sultan.d-Datei) in eine verschachtelte Konfiguration. // // host ist die Adresse, unter der der Ingress das Backend erreicht (LAN- oder // NetBird-IP); sie wird zusammen mit loadbalancer.server.port in die // Server-URLs eingesetzt. // // Wie bei Traefik mit exposedByDefault=false wird nur übersetzt, wenn // traefik.enable=true gesetzt ist; sonst ist das Ergebnis leer. Label-Schlüssel // werden ohne Beachtung der Groß-/Kleinschreibung erkannt, Router-, Service- // und Middleware-Namen behalten ihre Schreibweise. traefik.docker.* wird // ignoriert. Alle gefundenen Fehler werden gesammelt per errors.Join // zurückgegeben. func FromLabels(host string, labels map[string]string) (*Configuration, error) { enabled, err := isEnabled(labels) if err != nil { return nil, err } if !enabled { return &Configuration{}, nil } b := builder{ 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)) for k := range labels { keys = append(keys, k) } sort.Strings(keys) for _, key := range keys { if !strings.HasPrefix(strings.ToLower(key), labelPrefix) { continue } if err := b.apply(key, labels[key]); err != nil { b.errs = append(b.errs, fmt.Errorf("%s: %w", key, err)) } } cfg := b.build(host) if len(b.errs) > 0 { return nil, errors.Join(b.errs...) } return cfg, nil } func isEnabled(labels map[string]string) (bool, error) { for k, v := range labels { if strings.EqualFold(k, labelPrefix+"enable") { enabled, err := strconv.ParseBool(strings.TrimSpace(v)) if err != nil { return false, fmt.Errorf("%s: %w: %q ist kein Bool", k, ErrInvalidLabel, v) } return enabled, nil } } return false, nil } type serviceSpec struct { port string scheme string passHostHeader *bool } 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 } func (b *builder) apply(key, value string) error { parts := strings.Split(key[len(labelPrefix):], ".") switch strings.ToLower(parts[0]) { case "enable", "docker": return nil case "http", "tcp": default: return ErrUnsupportedLabel } if len(parts) < 4 { return ErrUnsupportedLabel } kind, name, attr := strings.ToLower(parts[1]), parts[2], parts[3:] 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] if !ok { r = &Router{} b.routers[name] = r } return setRouterAttr(r, strings.ToLower(strings.Join(attr, ".")), value) case "services": s, ok := b.services[name] if !ok { s = &serviceSpec{} b.services[name] = s } return setServiceAttr(s, strings.ToLower(strings.Join(attr, ".")), value) case "middlewares": m, ok := b.middlewares[name] if !ok { m = Middleware{} b.middlewares[name] = m } return setMiddlewareAttr(m, attr, value) default: return ErrUnsupportedLabel } } func setRouterAttr(r *Router, attr, value string) error { switch attr { case "rule": r.Rule = strings.TrimSpace(value) case "entrypoints": r.EntryPoints = splitList(value) case "middlewares": r.Middlewares = 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": on, err := strconv.ParseBool(strings.TrimSpace(value)) if err != nil { return fmt.Errorf("%w: %q ist kein Bool", ErrInvalidLabel, value) } if on && r.TLS == nil { r.TLS = &RouterTLS{} } case "tls.certresolver": ensureTLS(r).CertResolver = strings.TrimSpace(value) case "tls.options": ensureTLS(r).Options = strings.TrimSpace(value) default: return ErrUnsupportedLabel } 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{} } return r.TLS } func setServiceAttr(s *serviceSpec, attr, value string) error { value = strings.TrimSpace(value) switch attr { case "loadbalancer.server.port": p, err := strconv.Atoi(value) if err != nil || p < 1 || p > 65535 { return fmt.Errorf("%w: %q ist kein gültiger Port", ErrInvalidLabel, value) } s.port = strconv.Itoa(p) case "loadbalancer.server.scheme": scheme := strings.ToLower(value) switch scheme { case "http", "https", "h2c": s.scheme = scheme default: return fmt.Errorf("%w: Schema %q nicht erlaubt (http, https, h2c)", ErrInvalidLabel, value) } case "loadbalancer.passhostheader": v, err := strconv.ParseBool(value) if err != nil { return fmt.Errorf("%w: %q ist kein Bool", ErrInvalidLabel, value) } s.passHostHeader = &v default: return ErrUnsupportedLabel } return nil } // setMiddlewareAttr legt value unter dem Pfad attr in m ab. attr[0] ist der // Middleware-Typ (z. B. redirectregex), der Rest die Optionen. func setMiddlewareAttr(m Middleware, attr []string, value string) error { node := map[string]any(m) for i, p := range attr { if p == "" { return fmt.Errorf("%w: leeres Pfadsegment", ErrInvalidLabel) } if i == len(attr)-1 { if _, isMap := node[p].(map[string]any); isMap { return fmt.Errorf("%w: %q ist bereits ein Objekt", ErrInvalidLabel, p) } node[p] = value return nil } switch child := node[p].(type) { case nil: next := map[string]any{} node[p] = next node = next case map[string]any: node = child default: return fmt.Errorf("%w: %q ist bereits ein Wert", ErrInvalidLabel, p) } } return nil } 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 nil } http := &HTTPConfiguration{} if len(b.services) > 0 { http.Services = map[string]*Service{} for name, s := range b.services { if s.port == "" { b.errs = append(b.errs, fmt.Errorf("service %q: %w: loadbalancer.server.port fehlt", name, ErrIncomplete)) continue } scheme := s.scheme if scheme == "" { scheme = "http" } http.Services[name] = &Service{LoadBalancer: &ServersLoadBalancer{ Servers: []Server{{URL: scheme + "://" + net.JoinHostPort(host, s.port)}}, PassHostHeader: s.passHostHeader, }} } } if len(b.routers) > 0 { http.Routers = b.routers for name, r := range b.routers { if r.Rule == "" { b.errs = append(b.errs, fmt.Errorf("router %q: %w: rule fehlt", name, ErrIncomplete)) } if r.Service == "" { r.Service = b.defaultService("router", name, b.services) } } } if len(b.middlewares) > 0 { http.Middlewares = b.middlewares } 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 { var out []string for _, v := range strings.Split(value, ",") { if v = strings.TrimSpace(v); v != "" { out = append(out, v) } } return out }