retention.audit_days and retention.revisions_keep (DDI_RETENTION_*) drive a cleanup five minutes after the start and then daily. The latest revision, revisions agents run, fall back to or target, and revisions with pending deployments are never deleted; deployment rows stay. A cleanup that deletes anything records retention.cleanup in the audit log. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
435 lines
15 KiB
Go
435 lines
15 KiB
Go
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
|
|
|
|
// Package config loads the management server configuration from an optional
|
|
// YAML file, overridden by environment variables (docs/ARCHITECTURE.md §7).
|
|
package config
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.yaml.in/yaml/v3"
|
|
|
|
"git.beugsiud.de/beug.k/DDI/internal/brand"
|
|
)
|
|
|
|
// Defaults.
|
|
const (
|
|
DefaultHTTPListen = ":8443"
|
|
DefaultAgentAPIListen = ":8444"
|
|
DefaultShutdownTimeout = 15 * time.Second
|
|
DefaultSessionLifetime = 12 * time.Hour
|
|
DefaultSessionIdleTimeout = time.Hour
|
|
DefaultKeygenURL = "https://license.beugsiud.de"
|
|
DefaultKeygenProductID = ""
|
|
DefaultLicenseGrace = 14 * 24 * time.Hour
|
|
DefaultLicenseRevalidate = time.Hour
|
|
)
|
|
|
|
// Duration is a time.Duration that also accepts a day suffix ("14d").
|
|
type Duration time.Duration
|
|
|
|
// ParseDuration parses Go durations plus whole days ("14d").
|
|
func ParseDuration(s string) (time.Duration, error) {
|
|
s = strings.TrimSpace(s)
|
|
if days, ok := strings.CutSuffix(s, "d"); ok {
|
|
n, err := strconv.Atoi(days)
|
|
if err != nil || n < 0 {
|
|
return 0, fmt.Errorf("invalid duration %q", s)
|
|
}
|
|
return time.Duration(n) * 24 * time.Hour, nil
|
|
}
|
|
d, err := time.ParseDuration(s)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid duration %q", s)
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
// UnmarshalYAML implements yaml.Unmarshaler.
|
|
func (d *Duration) UnmarshalYAML(n *yaml.Node) error {
|
|
v, err := ParseDuration(n.Value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
*d = Duration(v)
|
|
return nil
|
|
}
|
|
|
|
// Std returns the value as time.Duration.
|
|
func (d Duration) Std() time.Duration { return time.Duration(d) }
|
|
|
|
// Config is the server configuration.
|
|
type Config struct {
|
|
HTTP HTTP `yaml:"http"`
|
|
AgentAPI AgentAPI `yaml:"agent_api"`
|
|
Database Database `yaml:"database"`
|
|
MasterKeyFile string `yaml:"master_key_file"`
|
|
StateDir string `yaml:"state_dir"`
|
|
Log Log `yaml:"log"`
|
|
Session Session `yaml:"session"`
|
|
License License `yaml:"license"`
|
|
Retention Retention `yaml:"retention"`
|
|
ShutdownTimeout Duration `yaml:"shutdown_timeout"`
|
|
}
|
|
|
|
// Retention limits how long history is kept (ADR 0024). A daily cleanup
|
|
// applies it; 0 keeps everything.
|
|
type Retention struct {
|
|
// AuditDays deletes audit entries older than this many days.
|
|
AuditDays int `yaml:"audit_days"`
|
|
// RevisionsKeep keeps the newest this many configuration revisions.
|
|
// The latest revision, revisions an agent runs, falls back to or is
|
|
// told to apply, and revisions with pending deployments are always
|
|
// kept in addition.
|
|
RevisionsKeep int `yaml:"revisions_keep"`
|
|
}
|
|
|
|
// HTTP configures the web UI/API listener.
|
|
type HTTP struct {
|
|
Listen string `yaml:"listen"`
|
|
TLSCertFile string `yaml:"tls_cert_file"`
|
|
TLSKeyFile string `yaml:"tls_key_file"`
|
|
// PublicURL is the externally visible base URL (OIDC redirects, links).
|
|
PublicURL string `yaml:"public_url"`
|
|
}
|
|
|
|
// AgentAPI configures the gRPC listener for the agents (ADR 0021). Its TLS
|
|
// certificate is issued by the internal agent CA at every start.
|
|
type AgentAPI struct {
|
|
Listen string `yaml:"listen"`
|
|
// PublicAddress ("host:port") is what agents connect to; it appears in
|
|
// the enrollment command. Default: host of http.public_url (or of the
|
|
// machine) and the port of Listen.
|
|
PublicAddress string `yaml:"public_address"`
|
|
// ServerNames become the SANs of the server certificate. Default: the
|
|
// host of PublicAddress.
|
|
ServerNames []string `yaml:"server_names"`
|
|
}
|
|
|
|
// Database configures PostgreSQL access.
|
|
type Database struct {
|
|
// URL is a libpq connection URL or key/value string.
|
|
URL string `yaml:"url"`
|
|
// URLFile reads the URL from a file (keeps the password out of the config).
|
|
URLFile string `yaml:"url_file"`
|
|
MaxConns int32 `yaml:"max_conns"`
|
|
AutoMigrate *bool `yaml:"auto_migrate"`
|
|
}
|
|
|
|
// Log configures logging.
|
|
type Log struct {
|
|
Level string `yaml:"level"`
|
|
Format string `yaml:"format"`
|
|
}
|
|
|
|
// Session configures web sessions.
|
|
type Session struct {
|
|
Lifetime Duration `yaml:"lifetime"`
|
|
IdleTimeout Duration `yaml:"idle_timeout"`
|
|
}
|
|
|
|
// License configures the licensing provider (ADR 0016). Account ID and public
|
|
// key are normally embedded at build time; the fields here only apply to
|
|
// builds without embedded values.
|
|
type License struct {
|
|
KeygenURL string `yaml:"keygen_url"`
|
|
ProductID string `yaml:"product_id"`
|
|
AccountID string `yaml:"account_id"`
|
|
PublicKey string `yaml:"public_key"`
|
|
GracePeriod Duration `yaml:"grace_period"`
|
|
RevalidateInterval Duration `yaml:"revalidate_interval"`
|
|
}
|
|
|
|
// TLSEnabled reports whether the HTTP listener serves TLS.
|
|
func (c Config) TLSEnabled() bool { return c.HTTP.TLSCertFile != "" }
|
|
|
|
// AutoMigrate reports whether migrations run at startup (default true).
|
|
func (c Config) AutoMigrate() bool { return c.Database.AutoMigrate == nil || *c.Database.AutoMigrate }
|
|
|
|
// LookupFunc matches os.LookupEnv.
|
|
type LookupFunc func(key string) (string, bool)
|
|
|
|
// EnvName returns the full environment variable name for key.
|
|
func EnvName(key string) string { return brand.EnvPrefix + key }
|
|
|
|
// Defaults returns the configuration used when nothing is set.
|
|
func Defaults() Config {
|
|
return Config{
|
|
HTTP: HTTP{Listen: DefaultHTTPListen},
|
|
AgentAPI: AgentAPI{Listen: DefaultAgentAPIListen},
|
|
MasterKeyFile: filepath.Join(brand.ConfigDir, "master.key"),
|
|
StateDir: brand.StateDir,
|
|
Log: Log{Level: "info", Format: "json"},
|
|
Session: Session{Lifetime: Duration(DefaultSessionLifetime), IdleTimeout: Duration(DefaultSessionIdleTimeout)},
|
|
ShutdownTimeout: Duration(DefaultShutdownTimeout),
|
|
Database: Database{MaxConns: 10},
|
|
License: License{
|
|
KeygenURL: DefaultKeygenURL,
|
|
ProductID: DefaultKeygenProductID,
|
|
GracePeriod: Duration(DefaultLicenseGrace),
|
|
RevalidateInterval: Duration(DefaultLicenseRevalidate),
|
|
},
|
|
}
|
|
}
|
|
|
|
// Load reads the YAML file at path (if not empty), applies environment
|
|
// overrides and validates the result.
|
|
func Load(path string, lookup LookupFunc) (Config, error) {
|
|
cfg := Defaults()
|
|
if path != "" {
|
|
f, err := os.Open(path) //nolint:gosec // path is given by the operator
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("open config file: %w", err)
|
|
}
|
|
defer f.Close() //nolint:errcheck // read-only
|
|
if err := decodeYAML(f, &cfg); err != nil {
|
|
return Config{}, fmt.Errorf("config file %s: %w", path, err)
|
|
}
|
|
}
|
|
if err := applyEnv(&cfg, lookup); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if cfg.Database.URL == "" && cfg.Database.URLFile != "" {
|
|
data, err := os.ReadFile(cfg.Database.URLFile)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("database.url_file: %w", err)
|
|
}
|
|
cfg.Database.URL = strings.TrimSpace(string(data))
|
|
}
|
|
cfg.resolveAgentAPI()
|
|
return cfg, cfg.Validate()
|
|
}
|
|
|
|
// hostname is replaced in tests.
|
|
var hostname = os.Hostname
|
|
|
|
// resolveAgentAPI fills in the defaults of the agent API that derive from
|
|
// other settings. Invalid inputs are left for Validate to report.
|
|
func (c *Config) resolveAgentAPI() {
|
|
a := &c.AgentAPI
|
|
if a.PublicAddress == "" {
|
|
_, port, err := net.SplitHostPort(a.Listen)
|
|
if err != nil {
|
|
return
|
|
}
|
|
host := ""
|
|
if u, err := url.Parse(c.HTTP.PublicURL); err == nil && c.HTTP.PublicURL != "" {
|
|
host = u.Hostname()
|
|
}
|
|
if host == "" {
|
|
if h, _, err := net.SplitHostPort(a.Listen); err == nil && h != "" && !net.ParseIP(h).IsUnspecified() {
|
|
host = h
|
|
}
|
|
}
|
|
if host == "" {
|
|
if h, err := hostname(); err == nil {
|
|
host = strings.ToLower(h)
|
|
}
|
|
}
|
|
if host == "" {
|
|
host = "localhost"
|
|
}
|
|
a.PublicAddress = net.JoinHostPort(host, port)
|
|
}
|
|
if len(a.ServerNames) == 0 {
|
|
if h, _, err := net.SplitHostPort(a.PublicAddress); err == nil && h != "" {
|
|
a.ServerNames = []string{h}
|
|
}
|
|
}
|
|
}
|
|
|
|
// serverName matches DNS names (with an optional leading wildcard label).
|
|
var serverName = regexp.MustCompile(`^(\*\.)?([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$`)
|
|
|
|
func validServerName(s string) bool {
|
|
if net.ParseIP(s) != nil {
|
|
return true
|
|
}
|
|
return len(s) <= 253 && serverName.MatchString(s)
|
|
}
|
|
|
|
func (a AgentAPI) validate() []error {
|
|
var errs []error
|
|
if _, _, err := net.SplitHostPort(a.Listen); err != nil {
|
|
errs = append(errs, fmt.Errorf("agent_api.listen: invalid address %q: %w", a.Listen, err))
|
|
}
|
|
host, port, err := net.SplitHostPort(a.PublicAddress)
|
|
if n, perr := strconv.Atoi(port); err != nil || host == "" || perr != nil || n < 1 || n > 65535 {
|
|
errs = append(errs, fmt.Errorf("agent_api.public_address: want host:port, got %q", a.PublicAddress))
|
|
}
|
|
if len(a.ServerNames) == 0 {
|
|
errs = append(errs, errors.New("agent_api.server_names must not be empty"))
|
|
}
|
|
for _, n := range a.ServerNames {
|
|
if !validServerName(n) {
|
|
errs = append(errs, fmt.Errorf("agent_api.server_names: invalid name %q", n))
|
|
}
|
|
}
|
|
return errs
|
|
}
|
|
|
|
func decodeYAML(r io.Reader, cfg *Config) error {
|
|
data, err := io.ReadAll(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dec := yaml.NewDecoder(bytes.NewReader(data))
|
|
dec.KnownFields(true)
|
|
if err := dec.Decode(cfg); err != nil && !errors.Is(err, io.EOF) {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type envBinding struct {
|
|
name string // without prefix, or full name if raw
|
|
raw bool // name is used without brand prefix
|
|
set func(*Config, string) error
|
|
}
|
|
|
|
func str(dst func(*Config) *string) func(*Config, string) error {
|
|
return func(c *Config, v string) error { *dst(c) = v; return nil }
|
|
}
|
|
|
|
func integer(dst func(*Config) *int) func(*Config, string) error {
|
|
return func(c *Config, v string) error {
|
|
n, err := strconv.Atoi(strings.TrimSpace(v))
|
|
if err != nil {
|
|
return fmt.Errorf("invalid number %q", v)
|
|
}
|
|
*dst(c) = n
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func dur(dst func(*Config) *Duration) func(*Config, string) error {
|
|
return func(c *Config, v string) error {
|
|
d, err := ParseDuration(v)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
*dst(c) = Duration(d)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
var envBindings = []envBinding{
|
|
{name: "HTTP_LISTEN", set: str(func(c *Config) *string { return &c.HTTP.Listen })},
|
|
{name: "TLS_CERT_FILE", set: str(func(c *Config) *string { return &c.HTTP.TLSCertFile })},
|
|
{name: "TLS_KEY_FILE", set: str(func(c *Config) *string { return &c.HTTP.TLSKeyFile })},
|
|
{name: "PUBLIC_URL", set: str(func(c *Config) *string { return &c.HTTP.PublicURL })},
|
|
{name: "AGENT_API_LISTEN", set: str(func(c *Config) *string { return &c.AgentAPI.Listen })},
|
|
{name: "AGENT_API_PUBLIC_ADDRESS", set: str(func(c *Config) *string { return &c.AgentAPI.PublicAddress })},
|
|
{name: "AGENT_API_SERVER_NAMES", set: func(c *Config, v string) error {
|
|
c.AgentAPI.ServerNames = nil
|
|
for _, n := range strings.Split(v, ",") {
|
|
if n = strings.TrimSpace(n); n != "" {
|
|
c.AgentAPI.ServerNames = append(c.AgentAPI.ServerNames, n)
|
|
}
|
|
}
|
|
return nil
|
|
}},
|
|
{name: "DATABASE_URL", set: str(func(c *Config) *string { return &c.Database.URL })},
|
|
{name: "DATABASE_URL_FILE", set: str(func(c *Config) *string { return &c.Database.URLFile })},
|
|
{name: "MASTER_KEY_FILE", set: str(func(c *Config) *string { return &c.MasterKeyFile })},
|
|
{name: "STATE_DIR", set: str(func(c *Config) *string { return &c.StateDir })},
|
|
{name: "LOG_LEVEL", set: str(func(c *Config) *string { return &c.Log.Level })},
|
|
{name: "LOG_FORMAT", set: str(func(c *Config) *string { return &c.Log.Format })},
|
|
{name: "SHUTDOWN_TIMEOUT", set: dur(func(c *Config) *Duration { return &c.ShutdownTimeout })},
|
|
{name: "SESSION_LIFETIME", set: dur(func(c *Config) *Duration { return &c.Session.Lifetime })},
|
|
{name: "SESSION_IDLE_TIMEOUT", set: dur(func(c *Config) *Duration { return &c.Session.IdleTimeout })},
|
|
{name: "LICENSE_GRACE_PERIOD", set: dur(func(c *Config) *Duration { return &c.License.GracePeriod })},
|
|
{name: "RETENTION_AUDIT_DAYS", set: integer(func(c *Config) *int { return &c.Retention.AuditDays })},
|
|
{name: "RETENTION_REVISIONS_KEEP", set: integer(func(c *Config) *int { return &c.Retention.RevisionsKeep })},
|
|
// Names given by the project brief, accepted without prefix.
|
|
{name: "KEYGEN_URL", raw: true, set: str(func(c *Config) *string { return &c.License.KeygenURL })},
|
|
{name: "KEYGEN_ACCOUNT_ID", raw: true, set: str(func(c *Config) *string { return &c.License.AccountID })},
|
|
{name: "KEYGEN_PRODUCT_ID", raw: true, set: str(func(c *Config) *string { return &c.License.ProductID })},
|
|
{name: "KEYGEN_PUBLIC_KEY", raw: true, set: str(func(c *Config) *string { return &c.License.PublicKey })},
|
|
}
|
|
|
|
func applyEnv(cfg *Config, lookup LookupFunc) error {
|
|
var errs []error
|
|
for _, b := range envBindings {
|
|
name := b.name
|
|
if !b.raw {
|
|
name = EnvName(b.name)
|
|
}
|
|
v, ok := lookup(name)
|
|
if !ok || v == "" {
|
|
continue
|
|
}
|
|
if err := b.set(cfg, v); err != nil {
|
|
errs = append(errs, fmt.Errorf("%s: %w", name, err))
|
|
}
|
|
}
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
// Validate checks the configuration for consistency. A missing database URL
|
|
// is reported by the commands that need one, not here.
|
|
func (c Config) Validate() error {
|
|
var errs []error
|
|
if _, _, err := net.SplitHostPort(c.HTTP.Listen); err != nil {
|
|
errs = append(errs, fmt.Errorf("http.listen: invalid address %q: %w", c.HTTP.Listen, err))
|
|
}
|
|
if (c.HTTP.TLSCertFile == "") != (c.HTTP.TLSKeyFile == "") {
|
|
errs = append(errs, errors.New("http.tls_cert_file and http.tls_key_file must be set together"))
|
|
}
|
|
if c.HTTP.PublicURL != "" {
|
|
u, err := url.Parse(c.HTTP.PublicURL)
|
|
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" {
|
|
errs = append(errs, fmt.Errorf("http.public_url: want an absolute http(s) URL, got %q", c.HTTP.PublicURL))
|
|
}
|
|
}
|
|
if c.ShutdownTimeout <= 0 {
|
|
errs = append(errs, errors.New("shutdown_timeout must be positive"))
|
|
}
|
|
if c.Session.Lifetime <= 0 || c.Session.IdleTimeout <= 0 {
|
|
errs = append(errs, errors.New("session.lifetime and session.idle_timeout must be positive"))
|
|
}
|
|
if c.License.GracePeriod < 0 {
|
|
errs = append(errs, errors.New("license.grace_period must not be negative"))
|
|
}
|
|
if c.License.RevalidateInterval < Duration(time.Minute) {
|
|
errs = append(errs, errors.New("license.revalidate_interval must be at least 1m"))
|
|
}
|
|
// Plain HTTP is only acceptable for a local test server.
|
|
u, err := url.Parse(c.License.KeygenURL)
|
|
validKeygen := err == nil && u.Host != "" &&
|
|
(u.Scheme == "https" || (u.Scheme == "http" && isLoopback(u.Hostname())))
|
|
if !validKeygen {
|
|
errs = append(errs, fmt.Errorf("license.keygen_url: want an https URL, got %q", c.License.KeygenURL))
|
|
}
|
|
if c.Database.MaxConns < 1 {
|
|
errs = append(errs, errors.New("database.max_conns must be at least 1"))
|
|
}
|
|
if c.Retention.AuditDays < 0 || c.Retention.AuditDays > 36500 {
|
|
errs = append(errs, errors.New("retention.audit_days must be between 0 (keep all) and 36500"))
|
|
}
|
|
if c.Retention.RevisionsKeep < 0 {
|
|
errs = append(errs, errors.New("retention.revisions_keep must not be negative (0 keeps all)"))
|
|
}
|
|
errs = append(errs, c.AgentAPI.validate()...)
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
func isLoopback(host string) bool {
|
|
if host == "localhost" {
|
|
return true
|
|
}
|
|
ip := net.ParseIP(host)
|
|
return ip != nil && ip.IsLoopback()
|
|
}
|