Files
DDI/server/internal/config/config.go
T
sir_melonchenandClaude Opus 5.5 dd77485efd feat(server): retention for audit entries and configuration revisions
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>
2026-10-15 00:32:45 +02:00

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()
}