// Copyright (c) 2026, Pim van Pelt package config import ( "fmt" "net" "os" "regexp" "strconv" "strings" "time" "gopkg.in/yaml.v3" ) // Config is the top-level parsed and validated configuration. type Config struct { HealthChecker HealthCheckerConfig HealthChecks map[string]HealthCheck Backends map[string]Backend Frontends map[string]Frontend } // HealthCheckerConfig holds global health checker settings. type HealthCheckerConfig struct { TransitionHistory int Netns string // network namespace for probes; "" = current netns } // HealthCheck describes how to probe a backend. type HealthCheck struct { Type string Port uint16 // destination port; required for tcp/http/https HTTP *HTTPParams // non-nil for type http and https TCP *TCPParams // non-nil for type tcp ProbeIPv4Src net.IP // source address for IPv4 probes; nil = OS picks ProbeIPv6Src net.IP // source address for IPv6 probes; nil = OS picks Interval time.Duration FastInterval time.Duration // optional; used while health counter is degraded DownInterval time.Duration // optional; used while fully down Timeout time.Duration Rise int // default 2 Fall int // default 3 } // HTTPParams holds validated parameters for http/https health checks. type HTTPParams struct { Path string Host string // Host header; defaults to backend IP if empty ResponseCodeMin int // inclusive lower bound; default 200 ResponseCodeMax int // inclusive upper bound; default 200 ResponseRegexp *regexp.Regexp // nil if not configured ServerName string // TLS SNI; falls back to Host if empty (https only) InsecureSkipVerify bool // skip TLS certificate verification (https only) } // TCPParams holds validated parameters for tcp health checks. type TCPParams struct { SSL bool ServerName string InsecureSkipVerify bool } // Backend is a single named backend server. type Backend struct { Address net.IP HealthCheck string // name reference into Config.HealthChecks; "" = no probing, assume healthy Enabled bool // default true; false = exclude from serving entirely } // PoolBackend is a backend reference within a pool, with pool-local weight. type PoolBackend struct { Weight int // 0-100, default 100 } // Pool is an ordered tier of backends within a frontend. type Pool struct { Name string Backends map[string]PoolBackend // keyed by backend name } // Frontend is a single virtual IP entry. type Frontend struct { Description string Address net.IP Protocol string // "tcp", "udp", or "" (all traffic) Port uint16 // 0 means omitted (all ports) Pools []Pool // ordered tiers; first pool with any up backend is active } // ---- raw YAML types -------------------------------------------------------- type rawConfig struct { Maglev rawMaglev `yaml:"maglev"` } type rawMaglev struct { HealthChecker rawHealthCheckerCfg `yaml:"healthchecker"` HealthChecks map[string]rawHealthCheck `yaml:"healthchecks"` Backends map[string]rawBackend `yaml:"backends"` Frontends map[string]rawFrontend `yaml:"frontends"` } type rawHealthCheckerCfg struct { TransitionHistory int `yaml:"transition-history"` Netns string `yaml:"netns"` } type rawHealthCheck struct { Type string `yaml:"type"` Port uint16 `yaml:"port"` Params rawParams `yaml:"params"` ProbeIPv4Src string `yaml:"probe-ipv4-src"` ProbeIPv6Src string `yaml:"probe-ipv6-src"` Interval string `yaml:"interval"` FastInterval string `yaml:"fast-interval"` DownInterval string `yaml:"down-interval"` Timeout string `yaml:"timeout"` Rise int `yaml:"rise"` Fall int `yaml:"fall"` } type rawParams struct { // HTTP / HTTPS Path string `yaml:"path"` Host string `yaml:"host"` ResponseCode string `yaml:"response-code"` ResponseRegexp string `yaml:"response-regexp"` ServerName string `yaml:"server-name"` InsecureSkipVerify bool `yaml:"insecure-skip-verify"` // TCP SSL bool `yaml:"ssl"` } type rawBackend struct { Address string `yaml:"address"` HealthCheck string `yaml:"healthcheck"` Enabled *bool `yaml:"enabled"` // nil → default true } type rawPoolBackend struct { Weight *int `yaml:"weight"` // nil → default 100 } type rawPool struct { Name string `yaml:"name"` Backends map[string]rawPoolBackend `yaml:"backends"` } type rawFrontend struct { Description string `yaml:"description"` Address string `yaml:"address"` Protocol string `yaml:"protocol"` Port uint16 `yaml:"port"` Pools []rawPool `yaml:"pools"` } // ---- Load ------------------------------------------------------------------ // Load reads and validates the config file at path. func Load(path string) (*Config, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read config %q: %w", path, err) } return parse(data) } func parse(data []byte) (*Config, error) { var raw rawConfig if err := yaml.Unmarshal(data, &raw); err != nil { return nil, fmt.Errorf("parse yaml: %w", err) } return convert(&raw.Maglev) } func convert(r *rawMaglev) (*Config, error) { cfg := &Config{} // ---- healthchecker -------------------------------------------------------- cfg.HealthChecker.Netns = r.HealthChecker.Netns cfg.HealthChecker.TransitionHistory = r.HealthChecker.TransitionHistory if cfg.HealthChecker.TransitionHistory == 0 { cfg.HealthChecker.TransitionHistory = 5 } if cfg.HealthChecker.TransitionHistory < 1 { return nil, fmt.Errorf("healthchecker.transition-history must be >= 1") } // ---- healthchecks --------------------------------------------------------- cfg.HealthChecks = make(map[string]HealthCheck, len(r.HealthChecks)) for name, rh := range r.HealthChecks { hc, err := convertHealthCheck(&rh) if err != nil { return nil, fmt.Errorf("healthcheck %q: %w", name, err) } cfg.HealthChecks[name] = hc } // ---- backends ------------------------------------------------------------- cfg.Backends = make(map[string]Backend, len(r.Backends)) for name, rb := range r.Backends { b, err := convertBackend(name, &rb, cfg.HealthChecks) if err != nil { return nil, fmt.Errorf("backend %q: %w", name, err) } cfg.Backends[name] = b } // ---- frontends ------------------------------------------------------------ cfg.Frontends = make(map[string]Frontend, len(r.Frontends)) for name, rf := range r.Frontends { fe, err := convertFrontend(name, &rf, cfg.Backends) if err != nil { return nil, fmt.Errorf("frontend %q: %w", name, err) } cfg.Frontends[name] = fe } return cfg, nil } func convertHealthCheck(r *rawHealthCheck) (HealthCheck, error) { h := HealthCheck{Type: r.Type, Port: r.Port} switch r.Type { case "icmp": // ICMP does not use ports. if r.Port != 0 { return HealthCheck{}, fmt.Errorf("type icmp does not use a port") } case "tcp": if r.Port == 0 { return HealthCheck{}, fmt.Errorf("type tcp requires port") } h.TCP = &TCPParams{ SSL: r.Params.SSL, ServerName: r.Params.ServerName, InsecureSkipVerify: r.Params.InsecureSkipVerify, } case "http", "https": if r.Port == 0 { return HealthCheck{}, fmt.Errorf("type %s requires port", r.Type) } if r.Params.Path == "" { return HealthCheck{}, fmt.Errorf("type http requires params.path") } min, max, err := parseCodeRange(r.Params.ResponseCode, 200) if err != nil { return HealthCheck{}, err } hp := &HTTPParams{ Path: r.Params.Path, Host: r.Params.Host, ResponseCodeMin: min, ResponseCodeMax: max, InsecureSkipVerify: r.Params.InsecureSkipVerify, } // TLS SNI: server-name takes precedence, falls back to host. hp.ServerName = r.Params.ServerName if hp.ServerName == "" { hp.ServerName = r.Params.Host } if r.Params.ResponseRegexp != "" { re, err := regexp.Compile(r.Params.ResponseRegexp) if err != nil { return HealthCheck{}, fmt.Errorf("invalid response-regexp %q: %w", r.Params.ResponseRegexp, err) } hp.ResponseRegexp = re } h.HTTP = hp default: return HealthCheck{}, fmt.Errorf("type must be \"icmp\", \"tcp\", \"http\", or \"https\", got %q", r.Type) } var err error if r.ProbeIPv4Src != "" { if h.ProbeIPv4Src, err = parseOptionalIPFamily(r.ProbeIPv4Src, 4, "probe-ipv4-src"); err != nil { return HealthCheck{}, err } } if r.ProbeIPv6Src != "" { if h.ProbeIPv6Src, err = parseOptionalIPFamily(r.ProbeIPv6Src, 6, "probe-ipv6-src"); err != nil { return HealthCheck{}, err } } if r.Interval == "" { return HealthCheck{}, fmt.Errorf("interval is required") } if h.Interval, err = time.ParseDuration(r.Interval); err != nil || h.Interval <= 0 { return HealthCheck{}, fmt.Errorf("interval %q must be a positive duration", r.Interval) } if r.FastInterval != "" { if h.FastInterval, err = time.ParseDuration(r.FastInterval); err != nil || h.FastInterval <= 0 { return HealthCheck{}, fmt.Errorf("fast-interval %q must be a positive duration", r.FastInterval) } } if r.DownInterval != "" { if h.DownInterval, err = time.ParseDuration(r.DownInterval); err != nil || h.DownInterval <= 0 { return HealthCheck{}, fmt.Errorf("down-interval %q must be a positive duration", r.DownInterval) } } if r.Timeout == "" { return HealthCheck{}, fmt.Errorf("timeout is required") } if h.Timeout, err = time.ParseDuration(r.Timeout); err != nil || h.Timeout <= 0 { return HealthCheck{}, fmt.Errorf("timeout %q must be a positive duration", r.Timeout) } h.Fall = r.Fall if h.Fall == 0 { h.Fall = 3 } if h.Fall < 1 { return HealthCheck{}, fmt.Errorf("fall must be >= 1") } h.Rise = r.Rise if h.Rise == 0 { h.Rise = 2 } if h.Rise < 1 { return HealthCheck{}, fmt.Errorf("rise must be >= 1") } return h, nil } func convertBackend(name string, r *rawBackend, hcs map[string]HealthCheck) (Backend, error) { ip := net.ParseIP(r.Address) if ip == nil { return Backend{}, fmt.Errorf("invalid address %q", r.Address) } b := Backend{ Address: ip, HealthCheck: r.HealthCheck, Enabled: boolDefault(r.Enabled, true), } if b.HealthCheck != "" { if _, ok := hcs[b.HealthCheck]; !ok { return Backend{}, fmt.Errorf("healthcheck %q not defined", b.HealthCheck) } } return b, nil } func convertFrontend(name string, r *rawFrontend, backends map[string]Backend) (Frontend, error) { fe := Frontend{ Description: r.Description, Protocol: r.Protocol, Port: r.Port, } ip := net.ParseIP(r.Address) if ip == nil { return Frontend{}, fmt.Errorf("invalid address %q", r.Address) } fe.Address = ip switch r.Protocol { case "", "tcp", "udp": default: return Frontend{}, fmt.Errorf("protocol must be \"tcp\", \"udp\", or omitted, got %q", r.Protocol) } if r.Port != 0 && r.Protocol == "" { return Frontend{}, fmt.Errorf("port requires protocol to be set") } if r.Protocol != "" && r.Port == 0 { return Frontend{}, fmt.Errorf("protocol %q requires port to be set (1-65535)", r.Protocol) } if len(r.Pools) == 0 { return Frontend{}, fmt.Errorf("pools must not be empty") } var firstFamily int firstBackend := true for pi, rp := range r.Pools { if rp.Name == "" { return Frontend{}, fmt.Errorf("pools[%d].name must not be empty", pi) } if len(rp.Backends) == 0 { return Frontend{}, fmt.Errorf("pool %q backends must not be empty", rp.Name) } pool := Pool{Name: rp.Name, Backends: make(map[string]PoolBackend, len(rp.Backends))} for bName, rpb := range rp.Backends { b, ok := backends[bName] if !ok { return Frontend{}, fmt.Errorf("pool %q backend %q not defined", rp.Name, bName) } fam := ipFamily(b.Address) if firstBackend { firstFamily = fam firstBackend = false } else if fam != firstFamily { return Frontend{}, fmt.Errorf("pool %q backend %q has different address family than first backend", rp.Name, bName) } w := intDefault(rpb.Weight, 100) if w < 0 || w > 100 { return Frontend{}, fmt.Errorf("pool %q backend %q weight %d out of range [0, 100]", rp.Name, bName, w) } pool.Backends[bName] = PoolBackend{Weight: w} } fe.Pools = append(fe.Pools, pool) } return fe, nil } // ---- helpers --------------------------------------------------------------- func parseOptionalIPFamily(s string, family int, field string) (net.IP, error) { if s == "" { return nil, nil } ip := net.ParseIP(s) if ip == nil { return nil, fmt.Errorf("%s %q is not a valid IP address", field, s) } if ipFamily(ip) != family { return nil, fmt.Errorf("%s %q must be an IPv%d address", field, s, family) } return ip, nil } func ipFamily(ip net.IP) int { if ip.To4() != nil { return 4 } return 6 } func parseCodeRange(s string, defaultCode int) (min, max int, err error) { if s == "" { return defaultCode, defaultCode, nil } if idx := strings.IndexByte(s, '-'); idx > 0 { min, err = strconv.Atoi(s[:idx]) if err != nil { return 0, 0, fmt.Errorf("invalid response-code range %q", s) } max, err = strconv.Atoi(s[idx+1:]) if err != nil { return 0, 0, fmt.Errorf("invalid response-code range %q", s) } return min, max, nil } min, err = strconv.Atoi(s) if err != nil { return 0, 0, fmt.Errorf("invalid response-code %q", s) } return min, min, nil } func boolDefault(p *bool, def bool) bool { if p == nil { return def } return *p } func intDefault(p *int, def int) int { if p == nil { return def } return *p }