// Package config loads MyDay's configuration from `config.yaml` into a typed // struct and validates it fail-closed (SPEC-003 §1, D-010/D-035). // // Settings live in the config file, not the database (D-035): infra · secrets · // domain · mail · locale. The real `config.yaml` is gitignored (D-010); the // committed `config.example.yaml` is used as a development fallback so a fresh // checkout can still start (in setup mode, loopback-only). // // Fail-closed rule (SPEC-003 §1): in PRODUCTIVE mode (`setup: false`) the // process refuses to start if a required secret is missing or weak. Setup mode // tolerates the placeholder values in the example, because the operator has not // provisioned the instance yet — and setup mode is loopback-only (D-025). // // Spec: docs/specs/spec-003-core-door.md §1, §7. package config import ( "bytes" "crypto/rand" "encoding/base64" "errors" "fmt" "io/fs" "os" "path/filepath" "strings" "time" "gopkg.in/yaml.v3" ) // FileName is the productive configuration file (gitignored — D-010). const FileName = "config.yaml" // ExampleFileName is the committed template, used as a development fallback. const ExampleFileName = "config.example.yaml" // Secret-length policy. The TOTP key must be exactly totpKeyLen bytes because // it feeds AES-256-GCM (mirrors auth.KeySize); the other secrets are signing / // derivation keys and must be at least minSecretLen bytes. const ( minSecretLen = 32 totpKeyLen = 32 ) // Config is the typed shape of `config.yaml` / `config.example.yaml`. type Config struct { // Setup forces setup mode (D-026). Together with "no user exists" it makes // the instance serve only /setup, loopback-only (D-025, SPEC-005 §3). Setup bool `yaml:"setup"` // AdminRecovery is the config approval flag for admin recovery (D-033). // Without it nothing happens (dual control). AdminRecovery bool `yaml:"admin_recovery"` Server Server `yaml:"server"` App App `yaml:"app"` Database Database `yaml:"database"` Secrets Secrets `yaml:"secrets"` Mail Mail `yaml:"mail"` Modules map[string]bool `yaml:"modules"` // Enrollment bounds the invite window (A1). SPEC-006 refuses enrollment // outside it. Enrollment Enrollment `yaml:"enrollment"` // source is the path actually read (config.yaml or the example fallback). source string } // Server holds the listener, its public base URL, and the trusted proxies. type Server struct { Listen string `yaml:"listen"` // BaseURL is used in invite / recovery links and decides whether cookies // are Secure (https). BaseURL string `yaml:"base_url"` // TrustedProxies are the peers whose X-Forwarded-For is honoured (A6). TrustedProxies []string `yaml:"trusted_proxies"` } // App holds locale and timezone. type App struct { Locale string `yaml:"locale"` Timezone string `yaml:"timezone"` } // Database points at the SQLite file (D-012). type Database struct { Path string `yaml:"path"` } // Secrets holds the symmetric key material (D-010). Never commit real values. // The wizard generates a strong value for any field left at a placeholder // (R-6); the startup path does the same in setup mode (M1s). type Secrets struct { CookieSecret string `yaml:"cookie_secret"` CSRFKey string `yaml:"csrf_key"` TOTPEncryptionKey string `yaml:"totp_encryption_key"` // A9 } // Mail holds the SMTP settings used for invites and recovery. type Mail struct { Host string `yaml:"host"` Port int `yaml:"port"` Username string `yaml:"username"` Password string `yaml:"password"` From string `yaml:"from"` } // Enrollment holds the invite-window settings (A1). type Enrollment struct { // Window is a Go duration string, e.g. "72h". Empty defaults to 72h. Window string `yaml:"window"` } // Sentinel errors. var ( // ErrListenRequired is returned when server.listen is empty. ErrListenRequired = errors.New("config: server.listen is required") // ErrDatabaseRequired is returned when database.path is empty. ErrDatabaseRequired = errors.New("config: database.path is required") // ErrBaseURLRequired is returned when server.base_url is empty in // productive mode (invite/recovery links need it). ErrBaseURLRequired = errors.New("config: server.base_url is required in productive mode") // ErrWeakSecret is returned when a required secret is missing or weak. ErrWeakSecret = errors.New("config: required secret is missing or weak") // ErrInvalidEnrollWindow is returned when enrollment.window is not a valid // positive Go duration. ErrInvalidEnrollWindow = errors.New("config: enrollment.window is not a valid duration") ) // DefaultEnrollWindow is the code default for the bounded enrollment window // (A1) when enrollment.window is unset. const DefaultEnrollWindow = 72 * time.Hour // EnrollWindow returns the configured enrollment window, defaulting to 72h. func (c *Config) EnrollWindow() time.Duration { d, err := time.ParseDuration(strings.TrimSpace(c.Enrollment.Window)) if err != nil || d <= 0 { return DefaultEnrollWindow } return d } // Load reads and validates the configuration at path. // // Development fallback (SPEC-003 §1): when path is the canonical `config.yaml` // and it does not exist, the committed `config.example.yaml` in the same // directory is used instead. Any other missing path is an error. func Load(path string) (*Config, error) { used, data, err := read(path) if err != nil { return nil, err } dec := yaml.NewDecoder(bytes.NewReader(data)) dec.KnownFields(true) // fail-closed: an unknown key is a configuration error var c Config if err := dec.Decode(&c); err != nil { return nil, fmt.Errorf("config: parse %s: %w", used, err) } c.source = used if err := c.Validate(); err != nil { return nil, fmt.Errorf("config: %s: %w", used, err) } return &c, nil } // Source returns the path the configuration was actually loaded from. func (c *Config) Source() string { return c.source } // Marshal renders c as the YAML document written to `config.yaml` (D-035). The // wizard persists through it (SPEC-005 §4). The unexported source field is not // part of the document. func Marshal(c *Config) ([]byte, error) { if c == nil { return nil, errors.New("config: cannot marshal a nil config") } body, err := yaml.Marshal(c) if err != nil { return nil, fmt.Errorf("config: marshal: %w", err) } const header = "# MyDay configuration — written by MyDay (the /setup wizard or the Modules page, D-035).\n" + "# Settings live HERE, not in the database. This file holds SECRETS:\n" + "# it is gitignored — never commit it (D-010).\n\n" return append([]byte(header), body...), nil } // IsSecure reports whether the app is served over https (so cookies are Secure). func (c *Config) IsSecure() bool { return strings.HasPrefix(strings.ToLower(strings.TrimSpace(c.Server.BaseURL)), "https://") } // GenerateSecret returns n cryptographically-random bytes, base64url-encoded // without padding (R-6): the value carries no YAML-hostile characters and its // encoded length is 4*ceil(n/3) — 24 bytes yield the 32 characters the TOTP // sealer requires (auth.KeySize). func GenerateSecret(n int) (string, error) { if n <= 0 { return "", errors.New("config: secret length must be positive") } b := make([]byte, n) if _, err := rand.Read(b); err != nil { return "", fmt.Errorf("config: secret entropy: %w", err) } return base64.RawURLEncoding.EncodeToString(b), nil } // Validate applies the fail-closed rules (SPEC-003 §1). func (c *Config) Validate() error { if strings.TrimSpace(c.Server.Listen) == "" { return ErrListenRequired } if strings.TrimSpace(c.Database.Path) == "" { return ErrDatabaseRequired } // The enrollment window (A1) is parsed in both modes: a typo is a config // error, never a silently-unbounded window. if w := strings.TrimSpace(c.Enrollment.Window); w != "" { d, err := time.ParseDuration(w) if err != nil || d <= 0 { return fmt.Errorf("%w: %q", ErrInvalidEnrollWindow, w) } } // Setup mode may run with placeholder secrets: the operator has not yet // provisioned the instance, and it is loopback-only (D-025). Productive // mode is strict. if c.Setup { return nil } if strings.TrimSpace(c.Server.BaseURL) == "" { return ErrBaseURLRequired } for _, s := range []struct{ name, value string }{ {"secrets.cookie_secret", c.Secrets.CookieSecret}, {"secrets.csrf_key", c.Secrets.CSRFKey}, {"secrets.totp_encryption_key", c.Secrets.TOTPEncryptionKey}, } { if err := checkSecret(s.name, s.value, minSecretLen); err != nil { return err } } // The TOTP key feeds AES-256-GCM, so it must be exactly 32 bytes. if n := len(c.Secrets.TOTPEncryptionKey); n != totpKeyLen { return fmt.Errorf("%w: secrets.totp_encryption_key must be exactly %d bytes, got %d", ErrWeakSecret, totpKeyLen, n) } return nil } // read loads the file, falling back to config.example.yaml for the canonical // config.yaml only. func read(path string) (used string, data []byte, err error) { data, err = os.ReadFile(path) if err == nil { return path, data, nil } if !errors.Is(err, fs.ErrNotExist) { return "", nil, fmt.Errorf("config: read %q: %w", path, err) } if filepath.Base(path) != FileName { return "", nil, fmt.Errorf("config: read %q: %w", path, err) } alt := filepath.Join(filepath.Dir(path), ExampleFileName) data, altErr := os.ReadFile(alt) if altErr != nil { return "", nil, fmt.Errorf("config: read %q (and fallback %q): %w", path, alt, err) } return alt, data, nil } // checkSecret rejects an empty, short, or placeholder secret value. func checkSecret(name, value string, minLen int) error { if strings.TrimSpace(value) == "" { return fmt.Errorf("%w: %s is empty", ErrWeakSecret, name) } if len(value) < minLen { return fmt.Errorf("%w: %s is %d bytes, need at least %d", ErrWeakSecret, name, len(value), minLen) } if isPlaceholder(value) { return fmt.Errorf("%w: %s still holds a placeholder value", ErrWeakSecret, name) } return nil } // SecretIsPlaceholder reports whether a secret value is empty or still holds // one of the shipped placeholder markers (R-6). The /setup wizard replaces such // values with strong generated ones, so a first boot from config.example.yaml // persists a viable productive configuration. func SecretIsPlaceholder(value string) bool { return strings.TrimSpace(value) == "" || isPlaceholder(value) } // isPlaceholder detects the well-known placeholder markers shipped in the // example so a copy that was never edited fails closed in productive mode. func isPlaceholder(value string) bool { upper := strings.ToUpper(value) return strings.Contains(upper, "CHANGE_ME") || strings.Contains(upper, "CHANGEME") || strings.Contains(upper, "PLACEHOLDER") }