Files
sultan/pkg/traefik/labels.go
T
br c8df9d7651 erste module drin.
zwischenstand: Architektur aktualisiert, TOFU implementiert,
2026-10-01 22:22:13 +02:00

304 lines
7.8 KiB
Go

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{},
}
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
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":
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)
}
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
}
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 {
if len(b.routers) == 0 && len(b.services) == 0 && len(b.middlewares) == 0 {
return &Configuration{}
}
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 == "" {
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 != "" {
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 len(b.middlewares) > 0 {
http.Middlewares = b.middlewares
}
return &Configuration{HTTP: http}
}
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
}