diff --git a/cmd/myday/main.go b/cmd/myday/main.go new file mode 100644 index 0000000..995d93f --- /dev/null +++ b/cmd/myday/main.go @@ -0,0 +1,424 @@ +// Command myday is the MyDay entrypoint. +// +// It assembles config → core services → the registry → the door and serves +// (SPEC-003 §3). In setup mode the door refuses a non-loopback bind, so a fresh +// instance bootstraps over an SSH tunnel to 127.0.0.1:8080 (D-025). +// +// 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 := cfg.Secrets.TOTPEncryptionKey + generatedKey := false + switch { + case config.SecretIsPlaceholder(sealerKey): + k, err := config.GenerateSecret(24) // 24 bytes -> 32 base64url chars == auth.KeySize + if err != nil { + return err + } + sealerKey, generatedKey = k, true + case len(sealerKey) != auth.KeySize: + // An operator-supplied key of the wrong size: fail closed and say so, + // rather than run a ceremony whose result we cannot store (A9). + return fmt.Errorf("myday: secrets.totp_encryption_key must be exactly %d bytes, got %d", + auth.KeySize, len(sealerKey)) + } + 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") + } + + 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. + preSession, err := httpx.NewPreSessionCSRF([]byte(cfg.Secrets.CookieSecret), cfg.IsSecure()) + 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, + Secure: cfg.IsSecure(), + 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" }, + Secure: cfg.IsSecure(), + 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 loopback only; " + + "bootstrap via an SSH tunnel to 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 +} + +// 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 +// (loopback-only, 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}, + } +}