Files
sultan/pkg/agent/sultand.go
T
br ca6cdcca63 software steht so weit.
jetzt build + docker build und testen
2026-10-02 13:25:34 +02:00

304 lines
9.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <Tag> 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
}