304 lines
7.8 KiB
Go
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
|
|
}
|