Files
felhom-controller/controller/internal/config/config.go
T
admin 0d52a42c17
gates / gates (push) Successful in 12s
R-359 + R-397: the off-site store gets checked, and the advertised check becomes real
Nothing ever verified that the off-site copies are still readable. The
whole-guest tier has verify jobs; the tier holding the customer's documents and
photos had none -- the complete set of restic verbs this controller used
contained no `check`. We would have found out at restore time, with a customer
waiting. On 2026-08-21 a deliberately damaged pack was caught at once by plain
`restic check`; we had never run it.

R-397: NotifyIntegrityOK/NotifyIntegrityFailed existed with no caller, the hub
allowlists both event types and carries the Hungarian text for both, the
settings checkbox exists, and the debug button posts to /api/debug/backup/
integrity. Everything was built except the part that runs. SIXTH instance of
that shape in this project.

THE HAZARD SHAPES THE WHOLE DESIGN. resticStep self-heals a crash lock by
running `unlock --remove-all` and retrying, and its own comment records why that
is safe: every caller holds the in-process single-flight mutex, so any lock it
meets is stale. A check that did not take that flag could meet a LIVE prune's
lock from this same box, remove it, and retry over the top of it. So the check
TAKES THE FLAG and SKIPS rather than waits -- waiting would pin the nightly
backup behind it, and a skip costs nothing because due-ness makes tomorrow try
again. TestR359_SkipsWhenRunningFlagHeld asserts the NON-EFFECTS: restic never
invoked, `unlock` never in any argv. Its red-proof prints the real thing --
restic running `check` while the flag was held.

DUE-NESS, NOT A WEEKDAY. Daily job, weekly behaviour: "is the last successful
check older than 7 days?" not "is it Sunday?". R-341 is exactly the other shape,
a dated check quietly missed and never caught up. No Weekly primitive added.

THREE OUTCOMES, NOT TWO. Skipped, Unreachable and failed are different facts.
"I could not look" is not "I looked and it is broken" -- R-339 already owns
reachability, and a second alarm for the same fact trains the operator to
discount the one alarm that means the backups are damaged. A timeout is
unreachable, never damage. A failure advances due-ness (a broken store must not
be re-checked nightly); a skip and an unreachable store do not.

Success is severity `info`, which severityNotifies DROPS -- it mails NOBODY, by
design. A weekly success e-mail is how people stop reading their alerts.

The customer gets a SENTENCE; restic's words go to the log, truncated (R-379:
615 bytes of raw database text reached a customer once). read-data-subset ships
OFF and a malformed value is refused at read time rather than handed to restic,
where one typo would fail the whole check.

Published on OffboxReportStatus, NOT on report.BackupReport's IntegrityOK --
those were retired by R-331 YESTERDAY and TestBackupReport_DeadFieldsStayZero
still passes unmodified.

Also: the monitoring page stopped promising a Sunday job that never existed, and
the debug button got its dispatch case.

PART 0 WAS NOT BUILT, AND R-398 WAS MY OWN MISTAKE. The seam it asked for
already exists: offboxRunner/SetOffboxRunner/m.runner() has been injectable
since the off-site tier shipped, and other tests drive restic-backed paths
through it. A resticStepFn seam would have been WORSE here -- it would replace
the `unlock --remove-all` escalation and hide it from the assertions that must
see it. R-358's AST ordering test is converted to a real execution test instead,
which immediately surfaced something the AST walk could not: unlockStale
legitimately runs before the restore.

Four red-proofs, each printing the pre-fix behaviour. Green gate: 28 packages,
rc 0. All 12 controller gates OK.
2026-08-30 21:03:29 +02:00

469 lines
18 KiB
Go

package config
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"strings"
"gopkg.in/yaml.v3"
)
// Config is the top-level configuration structure.
// Contains ONLY infrastructure/customer identity.
// App-specific config lives in per-app app.yaml files.
// OffsiteConfig is the hub-served offsite target descriptor (SLICE 1/2). Non-secret; the transient password
// is fetched once via the hub consume endpoint (never in config). Mirrors hub offsite.Descriptor.
type OffsiteConfig struct {
Enabled bool `yaml:"enabled"`
Type string `yaml:"type"` // "shared" | "dedicated"
Host string `yaml:"host"`
User string `yaml:"user"`
Port int `yaml:"port"` // 23
RepoPath string `yaml:"repo_path"` // /home/<repo>
QuotaGB int `yaml:"quota_gb"`
BoxType string `yaml:"box_type"`
HostFingerprint string `yaml:"host_fingerprint"` // SHA256:… — verified before pinning (no blind TOFU)
}
type Config struct {
Customer CustomerConfig `yaml:"customer"`
Infrastructure InfrastructureConfig `yaml:"infrastructure"`
Paths PathsConfig `yaml:"paths"`
Web WebConfig `yaml:"web"`
Git GitConfig `yaml:"git"`
Stacks StacksConfig `yaml:"stacks"`
Backup BackupConfig `yaml:"backup"`
Monitoring MonitoringConfig `yaml:"monitoring"`
Hub HubConfig `yaml:"hub"`
SelfUpdate SelfUpdateConfig `yaml:"self_update"`
Notifications NotificationsConfig `yaml:"notifications"`
Logging LoggingConfig `yaml:"logging"`
Assets AssetsConfig `yaml:"assets"`
System SystemConfig `yaml:"system"`
LocalAPI LocalAPIConfig `yaml:"local_api"`
Quiesce QuiesceConfig `yaml:"quiesce"`
MailRelay MailRelayConfig `yaml:"mail_relay"`
Offsite OffsiteConfig `yaml:"offsite"`
}
// MailRelayConfig tunes the in-controller SMTP shim (app email → shim → hub → Resend).
// The shim only runs when hub.enabled AND the global app-email toggle is on; it binds to
// the app Docker network ONLY (never published to host/internet). ShimHost is the DNS
// name injected into apps' SMTP_HOST (the controller's container name on the app network).
type MailRelayConfig struct {
// Enabled is a hard kill-switch: false disables the shim regardless of the runtime
// toggle. nil/true → the runtime app-email toggle decides. (Operational override only.)
Enabled *bool `yaml:"enabled"`
PlainListen string `yaml:"plain_listen"` // plaintext+STARTTLS, default ":2525"
TLSListen string `yaml:"tls_listen"` // implicit-TLS, default ":2465"
PlainNoTLSListen string `yaml:"plain_no_tls_listen"` // plaintext-only, no STARTTLS, default ":2526"
ShimHost string `yaml:"shim_host"` // app-network DNS name of the controller; default "felhom-controller"
FromDomains []string `yaml:"from_domains"` // From-header allowlist; default ["felhom.eu"]
}
// HardEnabled reports the operational kill-switch (default on unless explicitly false).
func (m MailRelayConfig) HardEnabled() bool {
return m.Enabled == nil || *m.Enabled
}
// LocalAPIConfig is the in-guest controller's handle on the host agent's per-guest local API
// (doc 03 §6, slice 8A). The agent mints the token + serves a self-signed leaf; the controller
// reaches it over the bridge, pinning the leaf SHA-256. Seeded from bootstrap.json at first run.
type LocalAPIConfig struct {
Endpoint string `yaml:"endpoint"` // host bridge IP:port, e.g. "192.168.0.162:8443"
Fingerprint string `yaml:"fingerprint"` // agent leaf-cert SHA-256 (hex) to pin
Token string `yaml:"token"` // per-guest bearer; SECRET
}
// QuiesceConfig tunes the slice-8B app-consistent backup loop (doc 03 §8): poll the agent's
// /backup/due, and when due stop the app stacks around the agent vzdump for a clean-shutdown
// backup. Runs only when the local API is configured (a provisioned guest). MaxQuiesce bounds the
// app downtime — the controller unquiesces no matter what once it elapses.
type QuiesceConfig struct {
Enabled *bool `yaml:"enabled"` // nil/true → on when local API configured; false → off
PollInterval string `yaml:"poll_interval"` // /backup/due check cadence (default "5m")
StatusPoll string `yaml:"status_poll_interval"` // /backup/status poll while quiesced (default "10s")
MaxQuiesce string `yaml:"max_quiesce_duration"` // hard downtime bound (default "30m")
}
// QuiesceEnabled reports whether the quiesce loop should run (default on unless explicitly false).
func (q QuiesceConfig) QuiesceEnabled() bool {
return q.Enabled == nil || *q.Enabled
}
type SystemConfig struct {
ReservedMemoryMB int `yaml:"reserved_memory_mb"`
}
type CustomerConfig struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
Domain string `yaml:"domain"`
Email string `yaml:"email"`
TelegramChatID string `yaml:"telegram_chat_id"`
}
type InfrastructureConfig struct {
CFTunnelToken string `yaml:"cf_tunnel_token"`
CFAPIToken string `yaml:"cf_api_token"`
}
type PathsConfig struct {
StacksDir string `yaml:"stacks_dir"`
DataDir string `yaml:"data_dir"`
SystemDataPath string `yaml:"system_data_path"`
HDDPath string `yaml:"hdd_path"`
}
type WebConfig struct {
Listen string `yaml:"listen"`
SetupListen string `yaml:"setup_listen"` // Plain HTTP listener for setup wizard (only active during setup mode)
PasswordHash string `yaml:"password_hash"`
SessionSecret string `yaml:"session_secret"`
// Customer-claim arc (v0.122.0, DRILL-day0-vm F-4): the hub-baked claim/reset code state —
// bcrypt(code) + monotonic generation + issue time (RFC3339). Day-0 configs carry these so a
// fresh box is claim-gated from FIRST boot; live boxes get fresher values via the report ACK
// (settings.json wins when its generation is newer). A set password always beats the gate.
ClaimCodeHash string `yaml:"claim_code_hash"`
ClaimCodeGeneration int `yaml:"claim_code_generation"`
ClaimCodeIssuedAt string `yaml:"claim_code_issued_at"`
}
type GitConfig struct {
RepoURL string `yaml:"repo_url"`
Branch string `yaml:"branch"`
SyncInterval string `yaml:"sync_interval"`
Username string `yaml:"username"`
Token string `yaml:"token"`
}
type StacksConfig struct {
Protected []string `yaml:"protected"`
UpdateWindow string `yaml:"update_window"`
ComposeCommand string `yaml:"compose_command"`
}
type BackupConfig struct {
Enabled bool `yaml:"enabled"`
ResticPasswordFile string `yaml:"restic_password_file"`
DBDumpSchedule string `yaml:"db_dump_schedule"`
ResticSchedule string `yaml:"restic_schedule"`
Retention RetentionConfig `yaml:"retention"`
PruneSchedule string `yaml:"prune_schedule"`
}
type RetentionConfig struct {
KeepDaily int `yaml:"keep_daily"`
KeepWeekly int `yaml:"keep_weekly"`
KeepMonthly int `yaml:"keep_monthly"`
}
type MonitoringConfig struct {
Enabled bool `yaml:"enabled"`
HealthchecksBase string `yaml:"healthchecks_base"`
PingUUIDs PingUUIDsConfig `yaml:"ping_uuids"`
HealthCheckSchedule string `yaml:"health_check_schedule"`
SystemHealthInterval string `yaml:"system_health_interval"`
Thresholds ThresholdsConfig `yaml:"thresholds"`
// Integrity (R-359) sits beside PingUUIDs.BackupIntegrity, which has existed all along for a
// check that did not.
Integrity IntegrityConfig `yaml:"integrity"`
}
type PingUUIDsConfig struct {
Heartbeat string `yaml:"heartbeat"`
DBDump string `yaml:"db_dump"`
Backup string `yaml:"backup"`
SystemHealth string `yaml:"system_health"`
BackupIntegrity string `yaml:"backup_integrity"`
}
// IntegrityMaxAgeDays / IntegrityReadDataSubset (R-359) configure the off-site integrity check.
//
// IntegrityMaxAgeDays is a MAX AGE, not a weekday. The job runs daily and asks "is the last successful
// check older than this?", so a box that was switched off on its check day is checked the next day it
// is on. R-341 is the failure that shape avoids: a dated check that was quietly missed for five days
// because nothing asked again.
//
// IntegrityReadDataSubset is EMPTY by default and that is a decision, not an oversight (R-399). An
// empty value runs restic's structure-and-index check, which downloads no pack data. A value like
// "5%" adds `--read-data-subset=5%`, which downloads and re-hashes that fraction of the store every
// run — a bandwidth and money cost that nothing has yet measured against the real store, so the
// default must not be chosen here.
type IntegrityConfig struct {
MaxAgeDays int `yaml:"max_age_days"`
ReadDataSubset string `yaml:"read_data_subset"`
}
type ThresholdsConfig struct {
DiskWarnPercent int `yaml:"disk_warn_percent"`
DiskCritPercent int `yaml:"disk_crit_percent"`
BackupMaxAgeHours int `yaml:"backup_max_age_hours"`
CPUWarnPercent int `yaml:"cpu_warn_percent"`
MemoryWarnPercent int `yaml:"memory_warn_percent"`
TemperatureWarnCelsius int `yaml:"temperature_warn_celsius"`
}
type SelfUpdateConfig struct {
Enabled bool `yaml:"enabled"`
CheckInterval string `yaml:"check_interval"`
Image string `yaml:"image"`
AutoUpdate bool `yaml:"auto_update"`
AutoUpdateTime string `yaml:"auto_update_time"`
HealthTimeoutSeconds int `yaml:"health_timeout_seconds"`
}
type NotificationsConfig struct {
CustomerEvents []string `yaml:"customer_events"`
OperatorEvents []string `yaml:"operator_events"`
}
type LoggingConfig struct {
Level string `yaml:"level"`
File string `yaml:"file"`
MaxSizeMB int `yaml:"max_size_mb"`
MaxFiles int `yaml:"max_files"`
}
type AssetsConfig struct {
SourceURL string `yaml:"source_url"` // Only used during build, not runtime
SyncEnabled bool `yaml:"sync_enabled"` // Download assets from Hub API
SyncSchedule string `yaml:"sync_schedule"` // Daily sync time (HH:MM), default "05:00"
}
type HubConfig struct {
Enabled bool `yaml:"enabled"`
URL string `yaml:"url"`
APIKey string `yaml:"api_key"`
PushInterval string `yaml:"push_interval"`
}
// Load reads and parses the config file, applies defaults, and validates.
func Load(path string) (*Config, error) {
cfg, err := loadAndParse(path)
if err != nil {
return nil, err
}
if err := validate(cfg); err != nil {
return nil, fmt.Errorf("config validation: %w", err)
}
return cfg, nil
}
// LoadPermissive reads and parses the config file, applies defaults, but skips validation.
// Used during setup mode where customer.id and domain may not be set yet.
func LoadPermissive(path string) (*Config, error) {
return loadAndParse(path)
}
// Default returns a Config with all defaults applied. Used when the config file
// is missing or unreadable and the controller needs to enter setup mode.
func Default() *Config {
cfg := &Config{}
applyDefaults(cfg)
return cfg
}
func loadAndParse(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading config file: %w", err)
}
// F-C2-1: parse the RAW bytes — do NOT os.ExpandEnv the whole file. A bcrypt password_hash
// ($2a$10$…) is full of `$word` sequences that ExpandEnv silently replaces with (usually empty)
// env values, corrupting the stored hash on load (a silent auth-integrity bug). Typed env
// overrides are the sanctioned mechanism — applyEnvOverrides (e.g. FELHOM_WEB_PASSWORD_HASH),
// applied after parse. No shipped controller.yaml relies on file-level ${VAR} interpolation.
cfg := &Config{}
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("parsing config file: %w", err)
}
applyDefaults(cfg)
applyEnvOverrides(cfg)
return cfg, nil
}
// LoadFromBytes parses YAML config from raw bytes (for validation without file I/O).
func LoadFromBytes(data []byte) (*Config, error) {
// F-C2-1: parse RAW bytes — see loadAndParse. os.ExpandEnv would corrupt a bcrypt password_hash.
cfg := &Config{}
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("parsing config: %w", err)
}
applyDefaults(cfg)
applyEnvOverrides(cfg)
if err := validate(cfg); err != nil {
return nil, err
}
return cfg, nil
}
// FileHash returns the SHA256 hex digest of the config file at the given path.
func FileHash(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
h := sha256.Sum256(data)
return hex.EncodeToString(h[:]), nil
}
func applyDefaults(cfg *Config) {
d := func(val *string, def string) {
if *val == "" {
*val = def
}
}
di := func(val *int, def int) {
if *val == 0 {
*val = def
}
}
d(&cfg.Paths.StacksDir, "/opt/docker/stacks")
d(&cfg.Paths.DataDir, "/opt/docker/felhom-controller/data")
d(&cfg.Paths.SystemDataPath, "/mnt/sys_drive")
d(&cfg.Web.Listen, ":8080")
d(&cfg.Web.SetupListen, ":8081")
d(&cfg.Git.Branch, "main")
d(&cfg.Git.SyncInterval, "15m")
d(&cfg.Stacks.UpdateWindow, "03:00-05:00")
d(&cfg.Backup.DBDumpSchedule, "02:30")
d(&cfg.Backup.ResticSchedule, "03:00")
d(&cfg.Backup.PruneSchedule, "weekly")
di(&cfg.Backup.Retention.KeepDaily, 7)
di(&cfg.Backup.Retention.KeepWeekly, 4)
di(&cfg.Backup.Retention.KeepMonthly, 6)
d(&cfg.Backup.ResticPasswordFile, "/opt/docker/felhom-controller/data/restic-password")
d(&cfg.Monitoring.HealthchecksBase, "https://status.felhom.eu")
d(&cfg.Monitoring.HealthCheckSchedule, "06:00")
d(&cfg.Monitoring.SystemHealthInterval, "5m")
di(&cfg.Monitoring.Thresholds.DiskWarnPercent, 80)
di(&cfg.Monitoring.Thresholds.DiskCritPercent, 90)
di(&cfg.Monitoring.Thresholds.BackupMaxAgeHours, 36)
di(&cfg.Monitoring.Thresholds.CPUWarnPercent, 90)
di(&cfg.Monitoring.Thresholds.MemoryWarnPercent, 85)
di(&cfg.Monitoring.Thresholds.TemperatureWarnCelsius, 75)
d(&cfg.Hub.PushInterval, "15m")
d(&cfg.SelfUpdate.CheckInterval, "6h")
d(&cfg.SelfUpdate.Image, "gitea.dooplex.hu/admin/felhom-controller")
d(&cfg.SelfUpdate.AutoUpdateTime, "04:30")
di(&cfg.SelfUpdate.HealthTimeoutSeconds, 60)
d(&cfg.Logging.Level, "info")
di(&cfg.Logging.MaxSizeMB, 10)
di(&cfg.Logging.MaxFiles, 3)
d(&cfg.Assets.SourceURL, "https://felhom.eu")
d(&cfg.Assets.SyncSchedule, "05:00")
di(&cfg.System.ReservedMemoryMB, 384)
d(&cfg.Quiesce.PollInterval, "5m")
d(&cfg.Quiesce.StatusPoll, "10s")
d(&cfg.Quiesce.MaxQuiesce, "30m")
d(&cfg.MailRelay.PlainListen, ":2525")
d(&cfg.MailRelay.TLSListen, ":2465")
d(&cfg.MailRelay.PlainNoTLSListen, ":2526")
d(&cfg.MailRelay.ShimHost, "felhom-controller")
if len(cfg.MailRelay.FromDomains) == 0 {
cfg.MailRelay.FromDomains = []string{"felhom.eu"}
}
}
func applyEnvOverrides(cfg *Config) {
envStr := func(key string, target *string) {
if v := os.Getenv(key); v != "" {
*target = v
}
}
envStr("FELHOM_CUSTOMER_ID", &cfg.Customer.ID)
envStr("FELHOM_CUSTOMER_DOMAIN", &cfg.Customer.Domain)
envStr("FELHOM_WEB_LISTEN", &cfg.Web.Listen)
envStr("FELHOM_WEB_PASSWORD_HASH", &cfg.Web.PasswordHash)
envStr("FELHOM_PATHS_STACKS_DIR", &cfg.Paths.StacksDir)
envStr("FELHOM_PATHS_HDD_PATH", &cfg.Paths.HDDPath)
envStr("FELHOM_LOGGING_LEVEL", &cfg.Logging.Level)
envStr("FELHOM_MONITORING_SYSTEM_HEALTH_INTERVAL", &cfg.Monitoring.SystemHealthInterval)
}
func validate(cfg *Config) error {
var errs []string
if cfg.Customer.ID == "" {
errs = append(errs, "customer.id is required")
}
if cfg.Customer.Domain == "" {
errs = append(errs, "customer.domain is required")
}
switch cfg.Logging.Level {
case "debug", "info", "warn", "error":
default:
errs = append(errs, fmt.Sprintf("logging.level must be debug|info|warn|error, got %q", cfg.Logging.Level))
}
if cfg.Monitoring.Thresholds.DiskWarnPercent >= cfg.Monitoring.Thresholds.DiskCritPercent {
errs = append(errs, "disk_warn_percent must be less than disk_crit_percent")
}
if len(errs) > 0 {
return fmt.Errorf("validation errors:\n - %s", strings.Join(errs, "\n - "))
}
return nil
}
// alwaysProtectedStacks are controller-MANAGED infra stacks that are protected regardless of the
// configured list. cfg.Stacks.Protected comes from controller.yaml (golden/bootstrap-generated) and
// predates these, so a box whose controller.yaml has not been regenerated would otherwise expose
// them as ordinary, stoppable/deletable apps. Protection means: no stop/delete from the UI, and the
// app-backup loops skip them (they are infrastructure, not customer apps — samba's share data is
// classified through its own registry instead, see stacks.ClassifiedBinds).
var alwaysProtectedStacks = map[string]bool{
"samba": true, // LAN network-sharing infra stack (R-7)
}
// IsProtectedStack checks if a stack name is protected — either controller-managed infra (always)
// or listed in the configured protected list.
func (cfg *Config) IsProtectedStack(name string) bool {
if alwaysProtectedStacks[strings.ToLower(name)] {
return true
}
for _, p := range cfg.Stacks.Protected {
if strings.EqualFold(p, name) {
return true
}
}
return false
}
// AppLogoURL returns the primary logo URL (SVG). Use AppLogoPNGURL as fallback.
func (cfg *Config) AppLogoURL(slug string) string {
return fmt.Sprintf("/static/assets/%s-logo.svg", slug)
}
// AppLogoPNGURL returns the PNG fallback logo URL.
func (cfg *Config) AppLogoPNGURL(slug string) string {
return fmt.Sprintf("/static/assets/%s-logo.png", slug)
}
// AppScreenshotURL returns the local URL for an app's screenshot.
func (cfg *Config) AppScreenshotURL(slug string, index int) string {
return fmt.Sprintf("/static/assets/%s-screenshot-%d.webp", slug, index)
}
// AppPageURL returns the URL for an app's detail page.
// This links to the local controller-hosted app detail page.
func (cfg *Config) AppPageURL(slug string) string {
return fmt.Sprintf("/apps/%s", slug)
}
// WebClaimGeneration returns the config-baked claim-code generation (0 when none). Satisfies the
// web.ClaimHatchConfig seam for the --print-reset-code escape hatch.
func (cfg *Config) WebClaimGeneration() int {
return cfg.Web.ClaimCodeGeneration
}