NextMyDay/internal/core/config/config.go
Claus Lohmar f0927fb0f0 feat(server): gate /setup by the direct tunnel and trust the proxy scheme
Implements D-025 (revised 2026-10-15) and D-043.

Setup gate (D-025):
- Remove the bind refusal (CheckSetupBind/ErrNonLoopbackSetup/server.IsLoopback);
  Serve binds any address, so the app can listen on 0.0.0.0:8080 behind Zoraxy.
- The gate is now per request and strict (httpx.SetupRequestAllowed): a loopback
  TCP peer AND no X-Forwarded-For — the direct SSH tunnel. Anything else is 404
  before dispatch; an unparseable peer fails closed (AC-7/AC-10). The raw XFF is
  read before ClientIP SETS it (A6).

Proxy scheme (D-043):
- httpx.RequestIsHTTPS resolves the scheme per request: direct TLS, or a trusted
  proxy's X-Forwarded-Proto. The door carries it on the request context
  (httpx.WithSecure) so the session cookie, its __Host- prefix, the pre-session
  and ceremony cookies, and HSTS all agree (AC-13).
- Over plain http (the tunnel) cookies stay bare and non-Secure, which fixes the
  bootstrap from an https:// config.example.yaml; Config.IsSecure() is removed
  as now dead.

Tests: rewrite the setup-gate tests for AC-7/AC-10, add AC-13, and unit-test
SetupRequestAllowed / RequestIsHTTPS.
2026-10-15 05:40:36 +00:00

302 lines
10 KiB
Go

// 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, reachable only over the tunnel).
//
// 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 tunnel-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, tunnel-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
}
// 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 tunnel-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")
}