// Package config loads the Compass/NHS service configuration from the process // environment. Every secret comes from the environment (environment.d); nothing // is compiled into the binary and nothing secret is read from a committed file. package config import ( "errors" "fmt" "log/slog" "os" "strconv" "strings" "time" // Embed the IANA time zone database so SCHOOL_TIMEZONE resolves even in a // container without tzdata. _ "time/tzdata" ) // Config is the fully resolved runtime configuration. type Config struct { // Addr is the listen address, for example ":8792". Addr string // DBPath is the SQLite database file, for example "/data/school.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 // 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 // Location is the family timezone. Due dates and briefs are wall-clock, so // this must be the family's zone, not the container default of UTC. Location *time.Location // Calendar is the Home Assistant calendar target for important school // events (AGENTS.md §4 FR-8). Calendar CalendarConfig } // CalendarConfig points at Sam's Home Assistant calendar. Every value comes // from the environment (environment.d); the token never reaches git, the // database or the logs (AGENTS.md §10 rule 3, AUD-110). // // Environment variables: // // SCHOOL_HA_URL base URL, for example http://192.168.20.30:8123 // SCHOOL_HA_TOKEN long-lived access token (secret) // SCHOOL_HA_CALENDAR_ENTITY calendar entity id, for example calendar.school // SCHOOL_HA_INSECURE true to skip TLS verification (internal CA) // SCHOOL_HA_TIMEOUT per-request timeout, default 20s // // Calendar writing stays off until all three of URL, token and entity are set, // so the service still starts before the calendar is configured. type CalendarConfig struct { BaseURL string Token string EntityID string Insecure bool Timeout time.Duration } // Enabled reports whether the calendar target is fully configured. func (c CalendarConfig) Enabled() bool { return c.BaseURL != "" && c.Token != "" && c.EntityID != "" } // Missing lists the environment variables that are still unset, in the order // the operator should set them. An empty result means the target is usable. func (c CalendarConfig) Missing() []string { var out []string if c.BaseURL == "" { out = append(out, "SCHOOL_HA_URL") } if c.Token == "" { out = append(out, "SCHOOL_HA_TOKEN") } if c.EntityID == "" { out = append(out, "SCHOOL_HA_CALENDAR_ENTITY") } return out } // DefaultTimezone is the family timezone used when SCHOOL_TIMEZONE is unset. const DefaultTimezone = "Australia/Sydney" // Load reads and validates the configuration. func Load() (*Config, error) { cfg := &Config{ Addr: env("SCHOOL_ADDR", ":8792"), DBPath: env("SCHOOL_DB_PATH", "/data/school.db"), AdminUsername: env("SCHOOL_ADMIN_USERNAME", "sam"), AdminPassword: os.Getenv("SCHOOL_ADMIN_PASSWORD"), AdminPasswordHash: os.Getenv("SCHOOL_ADMIN_PASSWORD_HASH"), CookieSecure: envBool("SCHOOL_COOKIE_SECURE", true), AppEnv: env("SCHOOL_APP_ENV", "production"), } secret := os.Getenv("SCHOOL_SESSION_SECRET") if len(secret) < 16 { return nil, errors.New("SCHOOL_SESSION_SECRET must be set and at least 16 characters") } cfg.SessionSecret = []byte(secret) if cfg.AdminPassword == "" && cfg.AdminPasswordHash == "" { return nil, errors.New("set SCHOOL_ADMIN_PASSWORD or SCHOOL_ADMIN_PASSWORD_HASH") } if cfg.AdminUsername == "" { return nil, errors.New("SCHOOL_ADMIN_USERNAME must not be empty") } ttl, err := time.ParseDuration(env("SCHOOL_SESSION_TTL", "12h")) if err != nil { return nil, fmt.Errorf("SCHOOL_SESSION_TTL: %w", err) } cfg.SessionTTL = ttl lvl, err := parseLevel(env("SCHOOL_LOG_LEVEL", "info")) if err != nil { return nil, err } cfg.LogLevel = lvl loc, err := time.LoadLocation(env("SCHOOL_TIMEZONE", DefaultTimezone)) if err != nil { return nil, fmt.Errorf("SCHOOL_TIMEZONE: %w", err) } cfg.Location = loc cfg.Calendar = loadCalendar() return cfg, nil } // DBConfig is the minimum a database-only command needs. The parse subcommand // opens the same SQLite file as the service without requiring the service's // session secret or login. type DBConfig struct { DBPath string Location *time.Location Calendar CalendarConfig } // LoadDB reads the database path and the family timezone only. func LoadDB() (*DBConfig, error) { loc, err := time.LoadLocation(env("SCHOOL_TIMEZONE", DefaultTimezone)) if err != nil { return nil, fmt.Errorf("SCHOOL_TIMEZONE: %w", err) } return &DBConfig{ DBPath: env("SCHOOL_DB_PATH", "/data/school.db"), Location: loc, Calendar: loadCalendar(), }, nil } // loadCalendar reads the Home Assistant calendar target. It never fails: an // empty value simply leaves the target disabled, so the service boots before // the calendar is configured and the calendar commands fail loudly instead. func loadCalendar() CalendarConfig { timeout := 20 * time.Second if v := strings.TrimSpace(os.Getenv("SCHOOL_HA_TIMEOUT")); v != "" { if d, err := time.ParseDuration(v); err == nil && d > 0 { timeout = d } } return CalendarConfig{ BaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("SCHOOL_HA_URL")), "/"), Token: strings.TrimSpace(os.Getenv("SCHOOL_HA_TOKEN")), EntityID: strings.TrimSpace(os.Getenv("SCHOOL_HA_CALENDAR_ENTITY")), Insecure: envBool("SCHOOL_HA_INSECURE", false), Timeout: timeout, } } 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("SCHOOL_LOG_LEVEL: unknown level %q", s) } }