feat(cmd): add the myday entrypoint
Wires the door: config → store → migrations → the discovered module registry → nav into the shell → the reserved `static` workflow → serve. Generates the TOTP sealing key at startup in setup mode (SPEC-009 D-1) so the wizard's TOTP enrollment precedes Finish. Fails closed on a bad module set.
This commit is contained in:
parent
dd865d4f27
commit
f5c6b95c27
1 changed files with 424 additions and 0 deletions
424
cmd/myday/main.go
Normal file
424
cmd/myday/main.go
Normal file
|
|
@ -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},
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue