// Package config loads the Control Service configuration from the process // environment. Every secret comes from the environment (environment.d); nothing // is compiled into the binary and nothing is read from a committed file. package config import ( "errors" "fmt" "log/slog" "os" "strconv" "strings" "time" // Embed the IANA time zone database so CONTROL_TIMEZONE resolves even in a // container without tzdata, such as the test image. _ "time/tzdata" ) // Config is the fully resolved runtime configuration. type Config struct { // Addr is the listen address, for example ":8791". Addr string // DBPath is the SQLite database file, for example "/data/control.db". DBPath string // SessionSecret signs the session cookie. Required, >= 16 bytes. SessionSecret []byte // AdminUsername is the single dashboard login name (Sam). AdminUsername string // AdminPassword is the plaintext password. Optional if AdminPasswordHash is set. AdminPassword string // AdminPasswordHash is a bcrypt hash. Optional if AdminPassword is set. AdminPasswordHash string // AdminAPIToken authenticates the versioned admin API (Phase 8). It is // separate from the session login and comes from environment.d. When it is // empty the admin API answers 503 and nothing else changes. AdminAPIToken string // DeviceKeycodeHash is a bcrypt hash of the shared device keycode (Phase 8). // It is optional: the code is seeded into SQLite at start-up and can be // changed at runtime with `control keycode`. It is never stored in plaintext // and never logged. DeviceKeycodeHash string // CookieSecure sets the Secure attribute on cookies. Keep true behind HTTPS. CookieSecure bool // SessionTTL is how long a session cookie stays valid. SessionTTL time.Duration // LogLevel is the slog level. LogLevel slog.Level // AppEnv is a label such as "production" or "development". AppEnv string // Version is injected at build time. Version string // ScheduleLocation is the timezone weekly schedule windows are evaluated in // (CONTROL_TIMEZONE). Windows are wall-clock, so this must be the family's // zone, not the container's default of UTC. It defaults to // DefaultTimezone. ScheduleLocation *time.Location // MQTTEnabled turns the broker link on. When false the service runs // HTTP-only and never touches the broker. MQTTEnabled bool // MQTTBroker is the broker URL, for example "tls://mosquitto:8883" or // "tcp://127.0.0.1:1883". MQTTBroker string // MQTTClientID is the MQTT client id of the Control Service. MQTTClientID string // MQTTUsername and MQTTPassword are the Control Service credentials // (AGENTS.md §10 rule 6: per-device credentials). MQTTUsername string MQTTPassword string // MQTTCAFile is the PEM bundle used to verify the broker certificate. MQTTCAFile string // MQTTInsecure allows a plaintext, credential-less broker link. It exists // only until the shared Mosquitto gets the TLS listener; never set it in // production. MQTTInsecure bool } // Load reads and validates the configuration. func Load() (*Config, error) { cfg := &Config{ Addr: env("CONTROL_ADDR", ":8791"), DBPath: env("CONTROL_DB_PATH", "/data/control.db"), AdminUsername: env("CONTROL_ADMIN_USERNAME", "sam"), AdminPassword: os.Getenv("CONTROL_ADMIN_PASSWORD"), AdminPasswordHash: os.Getenv("CONTROL_ADMIN_PASSWORD_HASH"), AdminAPIToken: os.Getenv("CONTROL_ADMIN_API_TOKEN"), DeviceKeycodeHash: os.Getenv("CONTROL_DEVICE_KEYCODE_HASH"), CookieSecure: envBool("CONTROL_COOKIE_SECURE", true), AppEnv: env("CONTROL_APP_ENV", "production"), MQTTEnabled: envBool("CONTROL_MQTT_ENABLED", true), MQTTBroker: env("CONTROL_MQTT_BROKER", DefaultMQTTBroker), MQTTClientID: env("CONTROL_MQTT_CLIENT_ID", "control-service"), MQTTUsername: os.Getenv("CONTROL_MQTT_USERNAME"), MQTTPassword: os.Getenv("CONTROL_MQTT_PASSWORD"), MQTTCAFile: env("CONTROL_MQTT_CA_FILE", "/etc/webrestrict/tls/ca.crt"), MQTTInsecure: envBool("CONTROL_MQTT_INSECURE", false), } secret := os.Getenv("CONTROL_SESSION_SECRET") if len(secret) < 16 { return nil, errors.New("CONTROL_SESSION_SECRET must be set and at least 16 characters") } cfg.SessionSecret = []byte(secret) if cfg.AdminPassword == "" && cfg.AdminPasswordHash == "" { return nil, errors.New("set CONTROL_ADMIN_PASSWORD or CONTROL_ADMIN_PASSWORD_HASH") } if cfg.AdminUsername == "" { return nil, errors.New("CONTROL_ADMIN_USERNAME must not be empty") } ttl, err := time.ParseDuration(env("CONTROL_SESSION_TTL", "12h")) if err != nil { return nil, fmt.Errorf("CONTROL_SESSION_TTL: %w", err) } cfg.SessionTTL = ttl lvl, err := parseLevel(env("CONTROL_LOG_LEVEL", "info")) if err != nil { return nil, err } cfg.LogLevel = lvl loc, err := time.LoadLocation(env("CONTROL_TIMEZONE", DefaultTimezone)) if err != nil { return nil, fmt.Errorf("CONTROL_TIMEZONE: %w", err) } cfg.ScheduleLocation = loc if err := cfg.validateMQTT(); err != nil { return nil, err } return cfg, nil } // DefaultMQTTBroker is where the shared Mosquitto on `.13` lives, as seen from // inside this project's container. The compose file maps host.docker.internal // to the host gateway. const DefaultMQTTBroker = "tcp://host.docker.internal:1883" // DefaultTimezone is the schedule timezone used when CONTROL_TIMEZONE is unset. // It matches the lab host's configured zone; set CONTROL_TIMEZONE to override. const DefaultTimezone = "Australia/Sydney" // MQTTTLS reports whether the broker URL asks for TLS. func (c *Config) MQTTTLS() bool { return strings.HasPrefix(c.MQTTBroker, "tls://") || strings.HasPrefix(c.MQTTBroker, "ssl://") } // validateMQTT refuses a link that carries neither TLS nor credentials, unless // CONTROL_MQTT_INSECURE explicitly allows it (AGENTS.md §10 rule 6). func (c *Config) validateMQTT() error { if !c.MQTTEnabled { return nil } if c.MQTTBroker == "" { return errors.New("CONTROL_MQTT_BROKER must not be empty when MQTT is enabled") } if c.MQTTUsername == "" || c.MQTTPassword == "" { if !c.MQTTInsecure { return errors.New("CONTROL_MQTT_USERNAME and CONTROL_MQTT_PASSWORD are required; " + "set CONTROL_MQTT_INSECURE=true only for the pre-TLS lab broker") } } if !c.MQTTTLS() && !c.MQTTInsecure { return errors.New("CONTROL_MQTT_BROKER must use tls:// (AGENTS.md §10 rule 6); " + "set CONTROL_MQTT_INSECURE=true only for the pre-TLS lab broker") } return nil } func env(key, fallback string) string { if v := strings.TrimSpace(os.Getenv(key)); v != "" { return v } return fallback } func envBool(key string, fallback bool) bool { v := strings.TrimSpace(os.Getenv(key)) if v == "" { return fallback } b, err := strconv.ParseBool(v) if err != nil { return fallback } return b } func parseLevel(s string) (slog.Level, error) { switch strings.ToLower(strings.TrimSpace(s)) { case "debug": return slog.LevelDebug, nil case "info", "": return slog.LevelInfo, nil case "warn", "warning": return slog.LevelWarn, nil case "error": return slog.LevelError, nil default: return 0, fmt.Errorf("CONTROL_LOG_LEVEL: unknown level %q", s) } }