package agent import ( "bytes" "errors" "fmt" "io" "net/url" "os" "path/filepath" "regexp" "slices" "strings" "time" "unicode" "unicode/utf8" "gopkg.in/yaml.v3" "sultan/pkg/license" "sultan/pkg/registry" ) // ServiceSpec ist ein Dienst aus /etc/sultan.d/*.yaml (Spec 4.1b) – für // Bare-Metal-/LAMP-Hosts ohne Docker-Labels. type ServiceSpec struct { Name string `yaml:"name"` URL string `yaml:"url"` // lokaler Dienst; sein Port gilt auch für den Ingress Hosts []string `yaml:"hosts"` HTTPS bool `yaml:"https"` CertResolver string `yaml:"certresolver"` EntryPoints []string `yaml:"entrypoints"` // Default websecure (https) bzw. web RedirectHTTPToHTTPS bool `yaml:"redirect_http_to_https"` RedirectWWW string `yaml:"redirect_www"` // none | to_www | to_non_www Healthcheck *HealthSpec `yaml:"healthcheck"` Maintenance bool `yaml:"maintenance"` // Dienst in Wartung (Wartungsseite) MaintenancePublic string `yaml:"maintenance_public_reason"` // optional, ersetzt den Standardtext Labels map[string]string `yaml:"labels"` // rohe traefik.*-Labels, überschreiben die erzeugten } // HealthSpec beschreibt den Healthcheck eines Dienstes. type HealthSpec struct { URL string `yaml:"url"` // Default: URL des Dienstes Interval Duration `yaml:"interval"` // Default 30s Timeout Duration `yaml:"timeout"` // Default 5s Failures int `yaml:"failures"` // Fehlschläge in Folge bis "down", Default 2 ExpectStatus int `yaml:"expect_status"` ExpectTitle string `yaml:"expect_title"` ExpectContains *ContainsSpec `yaml:"expect_contains"` } // ContainsSpec: Ein Element muss Text enthalten. type ContainsSpec struct { Tag string `yaml:"tag"` Text string `yaml:"text"` } // Duration liest Go-Dauern wie "30s" aus YAML. type Duration struct{ time.Duration } func (d *Duration) UnmarshalYAML(n *yaml.Node) error { var s string if err := n.Decode(&s); err != nil { return err } v, err := time.ParseDuration(s) if err != nil { return fmt.Errorf("Dauer %q nicht lesbar (z. B. 30s, 1m)", s) } d.Duration = v return nil } var serviceNameRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$`) // ParseServices liest eine Datei; unbekannte Felder sind Fehler. func ParseServices(data []byte) ([]ServiceSpec, error) { dec := yaml.NewDecoder(bytes.NewReader(data)) dec.KnownFields(true) var f struct { Services []ServiceSpec `yaml:"services"` } if err := dec.Decode(&f); err != nil && !errors.Is(err, io.EOF) { return nil, err } seen := map[string]bool{} for i := range f.Services { s := &f.Services[i] if err := s.normalize(); err != nil { return nil, fmt.Errorf("Dienst %d (%q): %w", i+1, s.Name, err) } if seen[s.Name] { return nil, fmt.Errorf("Dienst %q doppelt", s.Name) } seen[s.Name] = true } return f.Services, nil } func (s *ServiceSpec) normalize() error { if !serviceNameRe.MatchString(s.Name) { return errors.New("name fehlt oder ungültig (a–z, 0–9, - und _)") } u, err := url.Parse(s.URL) if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" { return fmt.Errorf("url %q muss eine http(s)-URL sein", s.URL) } if len(s.Hosts) == 0 { return errors.New("hosts fehlt") } for i, h := range s.Hosts { n, err := license.NormalizeDomain(h) if err != nil || strings.HasPrefix(n, "*.") { return fmt.Errorf("Host %q ungültig (keine Wildcards)", h) } s.Hosts[i] = n } switch s.RedirectWWW { case "", "none", "to_www", "to_non_www": default: return fmt.Errorf("redirect_www %q ungültig (none, to_www, to_non_www)", s.RedirectWWW) } if utf8.RuneCountInString(s.MaintenancePublic) > 300 || strings.ContainsFunc(s.MaintenancePublic, unicode.IsControl) { return errors.New("maintenance_public_reason: einzeilig, höchstens 300 Zeichen") } if s.RedirectHTTPToHTTPS && !s.HTTPS { return errors.New("redirect_http_to_https erfordert https: true") } if h := s.Healthcheck; h != nil { if h.URL == "" { h.URL = s.URL } if h.Interval.Duration <= 0 { h.Interval.Duration = 30 * time.Second } if h.Timeout.Duration <= 0 { h.Timeout.Duration = 5 * time.Second } if h.Failures <= 0 { h.Failures = 2 } if h.ExpectStatus == 0 { h.ExpectStatus = 200 } if h.ExpectStatus < 100 || h.ExpectStatus > 599 { return fmt.Errorf("expect_status %d ungültig", h.ExpectStatus) } if c := h.ExpectContains; c != nil && (c.Tag == "" || c.Text == "") { return errors.New("expect_contains braucht tag und text") } } return nil } // LoadServiceDir liest alle *.yaml/*.yml eines Verzeichnisses (alphabetisch). // Fehlerhafte Dateien und doppelte Dienstnamen werden mit Warnung // übersprungen, damit eine kaputte Datei nicht alle Dienste abschaltet. Ein // fehlendes Verzeichnis bedeutet: keine Dienste. func LoadServiceDir(dir string) ([]ServiceSpec, []string) { entries, err := os.ReadDir(dir) if errors.Is(err, os.ErrNotExist) { return nil, nil } if err != nil { return nil, []string{err.Error()} } var specs []ServiceSpec var warnings []string origin := map[string]string{} for _, e := range entries { ext := filepath.Ext(e.Name()) if e.IsDir() || (ext != ".yaml" && ext != ".yml") { continue } data, err := os.ReadFile(filepath.Join(dir, e.Name())) if err != nil { warnings = append(warnings, fmt.Sprintf("%s: %v", e.Name(), err)) continue } parsed, err := ParseServices(data) if err != nil { warnings = append(warnings, fmt.Sprintf("%s: %v", e.Name(), err)) continue } for _, s := range parsed { if first, dup := origin[s.Name]; dup { warnings = append(warnings, fmt.Sprintf("%s: Dienst %q schon in %s definiert – übersprungen", e.Name(), s.Name, first)) continue } origin[s.Name] = e.Name() specs = append(specs, s) } } return specs, warnings } // FileWorkloads übersetzt Dienste in Workloads mit Traefik-Labels; danach // gelten dieselben Regeln (FromLabels, Policy) wie für Docker-Container. func FileWorkloads(specs []ServiceSpec, host string) []registry.Workload { var out []registry.Workload for _, s := range specs { out = append(out, registry.Workload{Name: "sultan.d/" + s.Name, Host: host, Labels: s.labels()}) } return out } func (s ServiceSpec) labels() map[string]string { u, _ := url.Parse(s.URL) port := u.Port() if port == "" { port = map[string]string{"http": "80", "https": "443"}[u.Scheme] } scheme := "http" if s.HTTPS { scheme = "https" } hosts := slices.Clone(s.Hosts) var bases []string if s.RedirectWWW == "to_www" || s.RedirectWWW == "to_non_www" { for _, h := range s.Hosts { if b := strings.TrimPrefix(h, "www."); !slices.Contains(bases, b) { bases = append(bases, b) } } // Beide Varianten müssen beim Router ankommen, sonst greift die // Umleitung nie. for _, b := range bases { for _, h := range []string{b, "www." + b} { if !slices.Contains(hosts, h) { hosts = append(hosts, h) } } } } var rules []string for _, h := range hosts { rules = append(rules, "Host(`"+h+"`)") } rule := strings.Join(rules, " || ") entryPoints := s.EntryPoints if len(entryPoints) == 0 { entryPoints = []string{"web"} if s.HTTPS { entryPoints = []string{"websecure"} } } n := s.Name router := "traefik.http.routers." + n + "." l := map[string]string{ "traefik.enable": "true", router + "rule": rule, router + "entrypoints": strings.Join(entryPoints, ","), router + "service": n, "traefik.http.services." + n + ".loadbalancer.server.port": port, } if u.Scheme == "https" { l["traefik.http.services."+n+".loadbalancer.server.scheme"] = "https" } if s.HTTPS { l[router+"tls"] = "true" if s.CertResolver != "" { l[router+"tls.certresolver"] = s.CertResolver } } if len(bases) > 0 { alts := make([]string, len(bases)) for i, b := range bases { alts[i] = regexp.QuoteMeta(b) } mw := "traefik.http.middlewares." + n + "-www.redirectregex." if s.RedirectWWW == "to_non_www" { l[mw+"regex"] = `^https?://www\.(` + strings.Join(alts, "|") + `)(.*)` l[mw+"replacement"] = scheme + "://${1}${2}" } else { l[mw+"regex"] = `^https?://(` + strings.Join(alts, "|") + `)(.*)` l[mw+"replacement"] = scheme + "://www.${1}${2}" } l[mw+"permanent"] = "true" l[router+"middlewares"] = n + "-www" } if s.RedirectHTTPToHTTPS { httpRouter := "traefik.http.routers." + n + "-http." l[httpRouter+"rule"] = rule l[httpRouter+"entrypoints"] = "web" l[httpRouter+"service"] = n l[httpRouter+"middlewares"] = n + "-https" l["traefik.http.middlewares."+n+"-https.redirectscheme.scheme"] = "https" l["traefik.http.middlewares."+n+"-https.redirectscheme.permanent"] = "true" } if s.Maintenance { l[registry.LabelMaintenance] = "true" if s.MaintenancePublic != "" { l[registry.LabelMaintenancePublicReason] = s.MaintenancePublic } } for k, v := range s.Labels { l[k] = v } return l }