The live instance had no way to add a passkey or a user. Two core pages now
make the signed-in experience usable (SPEC-010):
* the ACCOUNT page (GET /{GUID}/account) shows the user's factors (passkey
count · TOTP enrolled), an "Add a passkey" button that LINKS to the
existing D-041 ceremony screen — the WebAuthn flow is never duplicated —
and a "Don't ask again" control writing users.passkey_opt_out;
* the admin USERS page (GET /{GUID}/users) lists every user (name · email ·
role · manager · identity status) and creates one: only roles below the
creator (D-019), the manager defaulting to the creator, then users.Create
-> identities.Mint -> the /{GUID} invite is EMAILED and shown for
copy/paste.
Both ride the door as core dispatch modules, so the door still gates them
(session + role + CSRF). The entrypoint requires admin for the users
functions; account stays at the team floor.
Additive support: auth.Identities.TOTPEnrolled, users.Users.List,
server.Deps.Credentials (the banner's "has a passkey?" read) and the
CoreDispatchNames guard now covers account/users.
598 lines
20 KiB
Go
598 lines
20 KiB
Go
// 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-043): the bootstrap is gated by
|
|
// the `setup` config toggle (D-045), 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/account"
|
|
"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()
|
|
|
|
// D-045: three states, decided by the `setup` toggle and whether the
|
|
// instance has any user at all.
|
|
//
|
|
// * `setup: true` → the WIZARD (the operator's switch);
|
|
// * `setup: false`, no user → LOCKED (an "enable setup" boilerplate;
|
|
// deliberately not publicly claimable);
|
|
// * `setup: false`, a user exists → PRODUCTIVE.
|
|
//
|
|
// `bootstrapped` is LATCHED at startup: a live per-request count would be a
|
|
// needless DB hit, and no user can appear while the instance is locked (the
|
|
// wizard is off). An unreadable count fails closed to "no user" → locked,
|
|
// which asks the operator to act rather than exposing the bootstrap.
|
|
bootstrapped := func() bool {
|
|
n, err := us.Count(context.Background())
|
|
return err == nil && n > 0
|
|
}()
|
|
inSetup := cfg.Setup
|
|
|
|
// 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 (D-045) reports whether the WIZARD is enabled: the `setup`
|
|
// toggle is on and the wizard has not completed in this process.
|
|
setupMode := func() bool {
|
|
if setupFlow != nil && setupFlow.Done() {
|
|
return false
|
|
}
|
|
return inSetup
|
|
}
|
|
// locked (D-045) reports the non-claimable dead end: `setup: false` and no
|
|
// user exists. Latched at startup (see `bootstrapped`).
|
|
locked := func() bool { return !cfg.Setup && !bootstrapped }
|
|
|
|
// 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),
|
|
Code: cfg.SetupCode, // D-044: operator override; blank → generated
|
|
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
|
|
}
|
|
|
|
// SPEC-010: the account page (factors · add a passkey · "don't ask again")
|
|
// and the admin Users page (list · create + email the enrolment invite).
|
|
// The account page reuses the SPEC-006 §8 passkey ceremony by linking to
|
|
// it; the invite goes through the same mailer recovery uses.
|
|
accounts, err := account.New(account.Deps{
|
|
Users: us,
|
|
Identities: ids,
|
|
Credentials: ids,
|
|
Mailer: mailer,
|
|
BaseURL: cfg.Server.BaseURL,
|
|
Audit: aud,
|
|
Logger: slog.Default(),
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
reg := buildRegistry(authFlow, setupFlow, recoveryFlow, modules, console, accounts)
|
|
|
|
srv, err := server.New(server.Deps{
|
|
Config: cfg,
|
|
Resolver: ids,
|
|
Registry: reg,
|
|
Sessions: sessions,
|
|
Identities: ids,
|
|
// SPEC-010 §6: the passkey banner needs "does this identity have a
|
|
// passkey?" — the identity service also implements Credentials.
|
|
Credentials: ids,
|
|
Users: us,
|
|
Audit: aud,
|
|
CSRF: csrf,
|
|
PreSession: preSession,
|
|
SetupMode: setupMode,
|
|
// D-045: the non-claimable locked state — `setup: false` and no admin.
|
|
LockedMode: locked,
|
|
// D-044/D-045: the first-run gate — the OPTIONAL setup code + the
|
|
// signed unlock cookie, and the first-run pages (the code form, the
|
|
// `GET /` landing, and the locked boilerplate).
|
|
SetupGate: setupFlow.Gate(),
|
|
// 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.
|
|
// SPEC-010: the Users page and its create action are admin-only
|
|
// (D-019 creation authority is still enforced by users.Create).
|
|
RoleGate: server.NewTiers().
|
|
Require(module.ConsoleModule, module.FunctionModules, users.RoleAdmin).
|
|
Require(module.ConsoleModule, module.FunctionToggle, users.RoleAdmin).
|
|
Require(account.UsersModule, account.FunctionList, users.RoleAdmin).
|
|
Require(account.UsersModule, account.FunctionCreate, 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
|
|
}
|
|
|
|
switch {
|
|
case setupMode():
|
|
slog.Warn("myday: SETUP MODE — the instance is not configured yet")
|
|
fmt.Fprint(os.Stderr, setupBanner(cfg, setupFlow.Code(), setupFlow.CodeRequired()))
|
|
case locked():
|
|
slog.Warn("myday: LOCKED — no admin exists and setup is disabled")
|
|
fmt.Fprint(os.Stderr, lockedBanner())
|
|
}
|
|
|
|
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, accounts *account.Actions) *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)
|
|
}
|
|
|
|
// SPEC-010: the account page (GET /{GUID}/account · POST
|
|
// /{GUID}?do=account&action=account.passkey_opt_out) and the admin Users
|
|
// page (GET /{GUID}/users · POST /{GUID}?do=users&action=users.create).
|
|
if accounts != nil {
|
|
accounts.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
|
|
}
|
|
|
|
// setupContainerID is the LXC container MyDay runs in (the owner's topology —
|
|
// D-043/D-044). It is only used to make the banner's "read the code" hint
|
|
// copy-pasteable from the Proxmox host.
|
|
const setupContainerID = "11020"
|
|
|
|
// setupBanner is the unmistakable first-run notice (D-044/D-045): that the
|
|
// instance is in setup mode, the wizard URL, and — only when `setup_code` is
|
|
// set (`codeRequired`) — the printed setup CODE. It is printed to stderr, so it
|
|
// lands in the same journal as the structured logs.
|
|
func setupBanner(cfg *config.Config, code string, codeRequired bool) string {
|
|
public := strings.TrimRight(strings.TrimSpace(cfg.Server.BaseURL), "/")
|
|
setupURL := public + "/setup"
|
|
if public == "" {
|
|
setupURL = "http://" + strings.TrimSpace(cfg.Server.Listen) + "/setup"
|
|
}
|
|
codeBlock := ""
|
|
if codeRequired {
|
|
codeBlock = fmt.Sprintf(`
|
|
The wizard is protected by a one-time SETUP CODE:
|
|
|
|
SETUP CODE: %s
|
|
|
|
Read the code again:
|
|
journalctl -u myday (in the container)
|
|
pct exec %s -- journalctl -u myday (from the Proxmox host)
|
|
`, code, setupContainerID)
|
|
}
|
|
line := strings.Repeat("=", 70)
|
|
return fmt.Sprintf(`
|
|
%s
|
|
MyDay is in SETUP MODE — no admin exists yet.
|
|
%s
|
|
|
|
Open the setup wizard at:
|
|
|
|
%s
|
|
%s
|
|
Until setup is complete:
|
|
- GET / points at the wizard;
|
|
- every other route answers 404.
|
|
|
|
Finishing the wizard writes config.yaml with setup: false and creates the
|
|
admin. RESTART MyDay to adopt the persisted secrets:
|
|
|
|
systemctl restart myday
|
|
|
|
%s
|
|
`, line, line, setupURL, codeBlock, line)
|
|
}
|
|
|
|
// lockedBanner is the notice for the non-claimable locked state (D-045):
|
|
// `setup: false` and no admin. The operator must flip the toggle and restart.
|
|
func lockedBanner() string {
|
|
line := strings.Repeat("=", 70)
|
|
return fmt.Sprintf(`
|
|
%s
|
|
MyDay is LOCKED — no admin exists and setup is disabled.
|
|
%s
|
|
|
|
To bring this instance to life, set
|
|
|
|
setup: true
|
|
|
|
in config.yaml and restart MyDay, then open /setup:
|
|
|
|
systemctl restart myday
|
|
|
|
%s
|
|
`, line, line, line)
|
|
}
|
|
|
|
// webauthnConfig derives the Relying Party configuration from server.base_url.
|
|
// An empty/unparseable base URL falls back to loopback, which keeps setup mode
|
|
// (the `setup` toggle, D-045) 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},
|
|
}
|
|
}
|