// Command myday is the MyDay entrypoint. // // It assembles config → core services → the registry → the door and serves // (SPEC-003 §3). Any address may be bound (D-025 revised / D-043): the // bootstrap is protected per request — in setup mode the door serves only the // direct SSH tunnel — not by refusing a non-loopback bind. // // Spec: docs/specs/spec-003-core-door.md, docs/specs/spec-006-enroll-login.md. package main import ( "context" "fmt" "log/slog" "net/url" "os" "path/filepath" "strings" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/flow" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/totp" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/webauthn" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/config" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/mail" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/module" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/recovery" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/server" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/setup" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/web" // Build-time module discovery (D-028): blank-importing the aggregator runs // every module directory's init() self-registration. The generated file is // produced by `go generate ./internal/modules`. _ "git.lohmar.co.uk/cclohmar/NextMyDay/internal/modules" ) // envConfigPath lets an operator point at a non-default config file // (D-010: "env override"). It overrides the config path only. const envConfigPath = "MYDAY_CONFIG" func main() { if err := run(); err != nil { slog.Error("myday: fatal", "error", err) os.Exit(1) } } func run() error { cfgPath := config.FileName if p := os.Getenv(envConfigPath); p != "" { cfgPath = p } cfg, err := config.Load(cfgPath) if err != nil { return err } slog.Info("myday: config loaded", "source", cfg.Source(), "setup", cfg.Setup) // The core owns the DB connection and the migration runner (D-036 §12.1). db, err := store.Open(cfg.Database.Path) if err != nil { return err } defer func() { _ = db.Close() }() if err := db.Migrate(); err != nil { return err } // SPEC-008: build-time discovery (D-028) + the runtime registry. Modules // register themselves from init(); Load applies the `modules:` config map // (D-038). The core owns the migration runner (AC-5): module migrations run // in registration order, after the core schema, each version prefixed with // the module name. modules, err := module.Load(cfg.Modules) if err != nil { return err } if err := store.RunMigrations(db.DB, modules.Migrations()); err != nil { return fmt.Errorf("myday: module migrations: %w", err) } modules.LogLoaded(slog.Default()) // AC-2: a visible registry beats hidden magic // SPEC-009 §4: the shell's nav comes from the module registry (D-038) — a // disabled module's entry is absent. Set once, here: the same single, // explicit wiring point as discovery (D-028). web.SetNav(modules) aud := audit.New(db.DB) us := users.New(db.DB, aud) // The TOTP sealer (A9) needs an exactly-32-byte key. // // R-6 + D-041: the /setup wizard enrolls the admin's TOTP secret BEFORE it // persists the generated secrets (step 2b precedes `finish`), and that // secret is sealed with this key — so the key must exist when the process // STARTS. A first boot from config.example.yaml (placeholder secrets) // otherwise failed with "auth: no TOTP sealer configured" on // `admin.totp.begin` and the documented bootstrap could never complete. // R-6 already rules that a placeholder is replaced with a strong generated // value, so generate it here; it is adopted (and thereby persisted by the // wizard's `finish`, which skips a non-placeholder) only in setup mode. sealerKey, generatedKey, err := prepareTOTPKey(cfg) if err != nil { return err } sealer, err := auth.NewAEADSealer([]byte(sealerKey)) if err != nil { return fmt.Errorf("myday: build TOTP sealer: %w", err) } core := auth.New(db.DB, aud, auth.WithEnrollWindow(cfg.EnrollWindow()), auth.WithSealer(sealer)) ids := core.Identities() sessions := core.Sessions() // Setup mode (SPEC-005 §3): `config.setup` OR the instance is not yet // bootstrapped (no user, or no ACTIVE admin). It is LATCHED at startup, so // the wizard can run to completion in this process — mid-wizard the admin // exists but is still pending, and a live "no user" check would end setup // mode too early. The wizard clears it via Done(); `config.Setup` is the // startup flag and is never mutated (so the door reads it race-free). bootstrapped := func() bool { ctx := context.Background() n, err := us.Count(ctx) if err != nil || n == 0 { return false // fail-safe: an unreadable count means "not bootstrapped" } idn, err := ids.ByUserID(ctx, 1) if err != nil { return false } return idn.Status == auth.StatusActive } inSetup := cfg.Setup || !bootstrapped() // Adopt a generated TOTP key only where it is legitimate: a first boot // (setup mode). Outside setup mode a placeholder is a configuration fault // and the process refuses to start (the fail-closed rule, SPEC-003 §1). if generatedKey { if !inSetup { return fmt.Errorf("myday: secrets.totp_encryption_key must be exactly %d bytes (SPEC-003 D-2)", auth.KeySize) } cfg.Secrets.TOTPEncryptionKey = sealerKey slog.Info("myday: generated secrets.totp_encryption_key for setup (R-6) — " + "the wizard enrolls TOTP before it persists, so the key must exist now") } // M1s: the CSRF and cookie signing keys drive the whole pre-session // ceremony (A15), so they must be strong from the FIRST request too — not // only after `finish` persists them (which would leave the wizard signing // with the shipped, publicly-known placeholder until a restart). Generated // here in setup mode only; `finish` then persists these very values. if inSetup { generated, err := prepareSigningKeys(cfg) if err != nil { return err } for _, name := range generated { slog.Info("myday: generated a strong signing key for setup (R-6/M1s)", "name", name) } } csrf, err := httpx.NewCSRF([]byte(cfg.Secrets.CSRFKey)) if err != nil { return err } // A15: the anonymous enroll/login forms carry a signed double-submit cookie. // Its `Secure` flag is resolved per request by the door (D-043). preSession, err := httpx.NewPreSessionCSRF([]byte(cfg.Secrets.CookieSecret)) if err != nil { return err } var setupFlow *setup.Actions setupMode := func() bool { if setupFlow != nil && setupFlow.Done() { return false } return inSetup } // SPEC-006: the enroll/login flow (WebAuthn + TOTP). The passkey nudge // (SPEC-006 §8) is suppressed in setup mode — a loopback passkey is // unusable at the public domain (D-041). wa, err := webauthn.New(webauthnConfig(cfg.Server.BaseURL)) if err != nil { return fmt.Errorf("myday: build webauthn: %w", err) } totpSvc := totp.New("MyDay", ids) authFlow, err := flow.New(flow.Deps{ Identities: ids, Credentials: ids, Sessions: sessions, Users: us, WebAuthn: wa, TOTP: totpSvc, CSRF: preSession, Audit: aud, SuppressNudge: setupMode, BaseURL: cfg.Server.BaseURL, Logger: slog.Default(), }) if err != nil { return err } // SPEC-005 §4 + D-041: the wizard enrolls the top admin with TOTP ONLY — // no passkey, because it would bind RP ID 127.0.0.1 over loopback. It reuses // the SPEC-006 TOTP ceremony through a TOTP-only flow (TOTPOnlyEnroll) whose // successful ceremony lands back on the wizard. wizardFlow, err := flow.New(flow.Deps{ Identities: ids, Credentials: ids, Sessions: sessions, Users: us, WebAuthn: wa, TOTP: totpSvc, CSRF: preSession, Audit: aud, TOTPOnlyEnroll: true, SuccessPath: func(string) string { return "/setup" }, BaseURL: cfg.Server.BaseURL, Logger: slog.Default(), }) if err != nil { return err } setupFlow, err = setup.New(setup.Deps{ Config: cfg, ConfigPath: configTarget(cfg), Users: us, Identities: ids, Enroll: wizardFlow, Audit: aud, CSRF: preSession, Logger: slog.Default(), }) if err != nil { return err } // SPEC-007: the recovery flow (manager escalation) and the config-gated // admin recovery. Mail is best-effort: with no SMTP host the fallback Nop // mailer discards delivery (and logs it). var mailer mail.Mailer = mail.Nop{} if cfg.Mail.Host != "" { mailer = mail.NewSMTP(cfg.Mail.Host, cfg.Mail.Port, cfg.Mail.Username, cfg.Mail.Password, cfg.Mail.From) } else if !inSetup { slog.Warn("myday: mail.host is not configured — recovery notifications will not be delivered") } recoveryRequests := recovery.NewStore(db.DB, aud) recoveryFlow, err := recovery.New(recovery.Deps{ Requests: recoveryRequests, Users: us, Identities: ids, Mailer: mailer, CSRF: preSession, Audit: aud, BaseURL: cfg.Server.BaseURL, PendingTTL: recovery.DefaultPendingTTL, Logger: slog.Default(), }) if err != nil { return err } // SPEC-007 §5 (D-033): admin recovery runs at startup ONLY when the config // approval flag is set. It rotates user 1's identity, emails the enrolment // link to the address on record, audits, and clears the flag. Without the // flag nothing happens (dual control, AC-5). It never runs mid-bootstrap. if !setupMode() { acted, err := recovery.RunAdminRecovery(context.Background(), recovery.AdminDeps{ Config: cfg, ConfigPath: configTarget(cfg), Users: us, Identities: ids, Mailer: mailer, Audit: aud, BaseURL: cfg.Server.BaseURL, Logger: slog.Default(), }) if err != nil { return err } if acted { slog.Warn("myday: admin recovery rotated user 1's identity — " + "the enrolment link was emailed to the address on record") } } // SPEC-008 §7: the core Modules page (D-038). It is a CORE page carried by // the `core` dispatch module; the role gate below restricts it to the admin. console, err := module.NewPage(module.PageDeps{ Registry: modules, Config: cfg, ConfigPath: configTarget(cfg), Audit: aud, Logger: slog.Default(), }) if err != nil { return err } reg := buildRegistry(authFlow, setupFlow, recoveryFlow, modules, console) srv, err := server.New(server.Deps{ Config: cfg, Resolver: ids, Registry: reg, Sessions: sessions, Identities: ids, Users: us, Audit: aud, CSRF: csrf, PreSession: preSession, SetupMode: setupMode, // SPEC-007: the tiered role gate (D-034). The floor admits any // authenticated user; role-ABOVE rules live in the services. // SPEC-008: the Modules page is admin-only — toggling modules changes // what the whole instance can do. RoleGate: server.NewTiers(). Require(module.ConsoleModule, module.FunctionModules, users.RoleAdmin). Require(module.ConsoleModule, module.FunctionToggle, users.RoleAdmin), }) if err != nil { return err } // SPEC-008 §4: run each enabled module's optional background work. if err := modules.Start(context.Background()); err != nil { return err } if setupMode() { slog.Warn("myday: SETUP MODE — serving /setup over the direct SSH tunnel only; " + "bootstrap via `ssh -L 8080:127.0.0.1:8080` before exposing this instance") } if err := srv.Serve(cfg.Server.Listen); err != nil { return err } return nil } // buildRegistry assembles the door's dispatch registry (SPEC-008 owns the // discovered set; this is the surface the door dispatches against). func buildRegistry(authFlow *flow.Actions, setupFlow *setup.Actions, recoveryFlow *recovery.Actions, mods *module.Registry, console *module.Page) *server.MapRegistry { reg := server.NewMapRegistry() // Core level-1 workflows (reserved: home · access · setup · static). reg.RegisterCore("home", server.Home) // SPEC-009 §3: the reserved `static` workflow serves the embedded web // assets. It is core owned and is served in setup mode too, so the // bootstrap wizard has a stylesheet (SPEC-005 §3). reg.RegisterCore(server.StaticWorkflow, server.StaticHandler()) // SPEC-005: the /setup wizard (the page + its steps). if setupFlow != nil { server.RegisterSetupFlow(reg, setupFlow) } // SPEC-008: the discovered modules (D-028) — the `home` dashboard among // them. A disabled module is not mounted, so its routes 404 (D-038). if mods != nil { mods.Mount(reg) } // SPEC-006: the enroll/login screens + the `auth` action module. if authFlow != nil { server.RegisterAuthFlow(reg, authFlow) } // SPEC-007: the /access recovery entry + the approve/deny actions. It // extends the `auth` module registered above (or creates it). if recoveryFlow != nil { server.RegisterRecoveryFlow(reg, recoveryFlow) } else { reg.RegisterCore("access", server.AccessPlaceholder) } // SPEC-008 §7: the core Modules page (GET /{GUID}/core/modules · POST // /{GUID}?do=core&action=modules.toggle). if console != nil { console.Register(reg) } return reg } // prepareTOTPKey returns the TOTP sealing key the process will use, generating // a strong one when the configured value is a placeholder (R-6/D-1). generated // reports that the value was minted here — run() adopts it only in setup mode, // where the process must still be able to seal a ceremony result. // // An operator-supplied key of the wrong size fails closed: rather than run a // ceremony whose result we cannot store (A9), say so and stop. func prepareTOTPKey(cfg *config.Config) (key string, generated bool, err error) { key = cfg.Secrets.TOTPEncryptionKey if config.SecretIsPlaceholder(key) { // 24 bytes -> 32 base64url chars == auth.KeySize. key, err = config.GenerateSecret(24) if err != nil { return "", false, err } return key, true, nil } if len(key) != auth.KeySize { return "", false, fmt.Errorf("myday: secrets.totp_encryption_key must be exactly %d bytes, got %d", auth.KeySize, len(key)) } return key, false, nil } // prepareSigningKeys replaces placeholder CSRF / cookie signing keys with strong // generated values (R-6/M1s) and returns the names of the secrets it generated. // // run() calls it only in setup mode: the wizard's pre-session double-submit // ceremony (A15) is signed with these keys from the first request, so the // shipped, publicly-known placeholder must never be used. In productive mode // config.Validate already refuses a placeholder (fail closed, SPEC-003 §1). func prepareSigningKeys(cfg *config.Config) (generated []string, err error) { for _, s := range []struct { name string dst *string }{ {"secrets.csrf_key", &cfg.Secrets.CSRFKey}, {"secrets.cookie_secret", &cfg.Secrets.CookieSecret}, } { if !config.SecretIsPlaceholder(*s.dst) { continue } v, err := config.GenerateSecret(32) if err != nil { return generated, err } *s.dst = v generated = append(generated, s.name) } return generated, nil } // configTarget picks where the wizard persists settings: `config.yaml` beside // the loaded file. It NEVER overwrites the committed `config.example.yaml` // (D-010) — so a dev run on the example writes a real config.yaml next to it. func configTarget(cfg *config.Config) string { src := cfg.Source() if filepath.Base(src) == config.ExampleFileName { return filepath.Join(filepath.Dir(src), config.FileName) } return src } // webauthnConfig derives the Relying Party configuration from server.base_url. // An empty/unparseable base URL falls back to loopback, which keeps setup mode // (the tunnel-only gate, D-025) working before the domain is configured. func webauthnConfig(baseURL string) webauthn.Config { defaultCfg := webauthn.Config{ RPID: "localhost", RPDisplayName: "MyDay", RPOrigins: []string{"http://localhost"}, } u, err := url.Parse(strings.TrimSpace(baseURL)) if err != nil || u.Host == "" || u.Scheme == "" { return defaultCfg } return webauthn.Config{ RPID: u.Hostname(), RPDisplayName: "MyDay", RPOrigins: []string{u.Scheme + "://" + u.Host}, } }