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