From 7860152f57e5af9154680a9e23c7a3d371bc074e Mon Sep 17 00:00:00 2001 From: Minseong Choi Date: Mon, 20 Jul 2026 04:47:32 +0900 Subject: [PATCH] feat(auth)!: go fully passwordless and fix cross-check review findings Remove password authentication everywhere; the only session doors are passkey (WebAuthn), email OTP, in-game bind codes, QR scan-login, and op-login vouching. Remediates the 33-finding cross-check review across backend, CLI, panel, plugins, and docs. Backend/CLI: - Drop password routes and fields from account/user/onboard/auth handlers; align tests (new account subtests, naming reserves "console", op-login/onboard/qr-login test updates). - Add migrations 0016_op_login.sql and 0017_drop_password.sql. - Thread panel/admin hostnames from hostcfg through api.go, setup_panel.go, tui_root.go and tui_preflight.go instead of hardcoding; bootstrap.sh writes panel-hostname/admin-hostname into felis.toml. - Reword breakglass and TUI copy for passwordless flows. Panel: - Delete the ChangePassword page and all password UI; align login/auth/api/types with the passwordless contract; add the migration and op-login approval flows. - i18n: convert ImageBuildPage durations/status badges and ServerLuckPerms strings to translation keys; drop 72 orphan keys per locale; unify the title as "Felis - Console". Plugins (all six rebuilt): - Velocity waiting router returns 503 at_capacity during wake; MOTD/control-channel copy and config comments. - Paper zh menu title; Limbo bind-code TTL 600s with panel_url preference; unified /link lines in fabric/forge/neoforge; shared link-client javadoc contract fixes. Docs: openapi.yaml, sequence-diagrams.md, deploy/limbo/README.md and plugins/README.md aligned with the implementation. BREAKING CHANGE: migration 0017 irreversibly drops users.password_hash and users.must_change_password; password login cannot be restored after migrating. --- cmd/felis/api.go | 22 +- cmd/felis/breakglass.go | 15 +- cmd/felis/setup.go | 2 +- cmd/felis/setup_panel.go | 8 +- cmd/felis/tui_connect.go | 7 +- cmd/felis/tui_edge_apply.go | 2 +- cmd/felis/tui_menu.go | 6 +- cmd/felis/tui_menu_test.go | 2 +- cmd/felis/tui_owner.go | 4 +- cmd/felis/tui_preflight.go | 7 +- cmd/felis/tui_root.go | 10 +- deploy/bootstrap.sh | 2 + deploy/limbo/README.md | 2 +- docs/openapi.yaml | 176 ++++--- docs/sequence-diagrams.md | 4 +- internal/api/api.go | 32 +- internal/api/api_test.go | 41 +- internal/api/handlers_access.go | 111 +++++ internal/api/handlers_account.go | 49 +- internal/api/handlers_account_test.go | 24 + internal/api/handlers_auth_email.go | 18 +- internal/api/handlers_auth_email_test.go | 8 +- internal/api/handlers_internal.go | 27 +- internal/api/handlers_onboard.go | 11 +- internal/api/handlers_onboard_test.go | 2 +- internal/api/handlers_op_login.go | 23 +- internal/api/handlers_op_login_test.go | 4 +- internal/api/handlers_qr_login_test.go | 28 +- internal/api/handlers_user.go | 16 + internal/api/handlers_users.go | 10 +- internal/api/pgrepo.go | 9 +- internal/api/repo.go | 27 +- internal/naming/naming.go | 7 +- internal/naming/naming_test.go | 1 + internal/panel/static/index.html | 2 +- internal/store/migrations/0016_op_login.sql | 28 ++ .../store/migrations/0017_drop_password.sql | 11 + panel/dev/mockApi.ts | 116 ++--- panel/index.html | 2 +- panel/src/App.tsx | 10 +- panel/src/components/CreateUserDialog.tsx | 212 +++------ panel/src/components/EditServerDialog.tsx | 4 +- panel/src/components/RequireAuth.tsx | 4 +- panel/src/components/ServerCard.tsx | 2 +- panel/src/i18n/resources/en-US/account.json | 36 +- panel/src/i18n/resources/en-US/admin.json | 43 +- panel/src/i18n/resources/en-US/auth.json | 32 +- panel/src/i18n/resources/en-US/backups.json | 3 - panel/src/i18n/resources/en-US/common.json | 5 +- panel/src/i18n/resources/en-US/dashboard.json | 4 - panel/src/i18n/resources/en-US/errors.json | 8 +- panel/src/i18n/resources/en-US/ops.json | 5 - panel/src/i18n/resources/en-US/servers.json | 43 +- .../src/i18n/resources/en-US/submissions.json | 10 +- panel/src/i18n/resources/zh-CN/account.json | 42 +- panel/src/i18n/resources/zh-CN/admin.json | 43 +- panel/src/i18n/resources/zh-CN/auth.json | 32 +- panel/src/i18n/resources/zh-CN/backups.json | 3 - panel/src/i18n/resources/zh-CN/common.json | 5 +- panel/src/i18n/resources/zh-CN/dashboard.json | 4 - panel/src/i18n/resources/zh-CN/errors.json | 8 +- panel/src/i18n/resources/zh-CN/ops.json | 5 - panel/src/i18n/resources/zh-CN/servers.json | 43 +- .../src/i18n/resources/zh-CN/submissions.json | 10 +- panel/src/lib/api.test.ts | 161 ++++--- panel/src/lib/api.ts | 89 +++- panel/src/lib/auth.test.ts | 16 +- panel/src/lib/auth.ts | 7 +- panel/src/lib/config.ts | 6 + panel/src/lib/tier.tsx | 6 +- panel/src/lib/types.ts | 33 +- panel/src/pages/Account.tsx | 265 ++++++++++- panel/src/pages/ChangePassword.tsx | 135 ------ panel/src/pages/Dashboard.tsx | 4 +- panel/src/pages/Login.tsx | 449 +++++++++++------- panel/src/pages/MySubmissionsPage.tsx | 8 +- panel/src/pages/ServerLuckPerms.tsx | 10 +- panel/src/pages/admin/ImageAdmin.tsx | 8 +- panel/src/pages/admin/ImageBuildPage.tsx | 29 +- panel/src/pages/admin/SubmissionsPage.tsx | 12 +- panel/src/pages/admin/UserDetailPage.tsx | 63 +-- panel/src/pages/servers/ServersPage.tsx | 4 +- plugins/README.md | 2 +- .../lolicon/felis/fabric/FelisFabricMod.java | 18 +- .../lolicon/felis/forge/FelisForgeMod.java | 16 +- .../lolicon/felis/limbo/FelisLimboPlugin.java | 23 +- .../felis/neoforge/FelisNeoForgeMod.java | 16 +- .../lolicon/felis/paper/FelisPaperPlugin.java | 60 ++- .../lolicon/felis/link/FelisApiClient.java | 36 +- .../best/lolicon/felis/link/LinkClient.java | 9 +- .../best/lolicon/felis/link/LinkCode.java | 12 +- .../best/lolicon/felis/link/ServerView.java | 3 +- .../felis/velocity/ControlChannel.java | 4 +- .../felis/velocity/FelisVelocityConfig.java | 42 +- .../felis/velocity/FelisVelocityPlugin.java | 249 +++++++--- .../lolicon/felis/velocity/MotdResponder.java | 8 +- .../lolicon/felis/velocity/WaitingRouter.java | 72 ++- 97 files changed, 1923 insertions(+), 1444 deletions(-) create mode 100644 internal/store/migrations/0016_op_login.sql create mode 100644 internal/store/migrations/0017_drop_password.sql delete mode 100644 panel/src/pages/ChangePassword.tsx diff --git a/cmd/felis/api.go b/cmd/felis/api.go index b211df8..5629f60 100644 --- a/cmd/felis/api.go +++ b/cmd/felis/api.go @@ -215,12 +215,13 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int { Restorer: restorer, Backuper: backuper, Submissions: submissions, - // The external face is fronted by SessionAuth: it prefers a local-password - // session cookie and otherwise delegates to the Cloudflare-Access JWT verifier, - // so both auth models coexist on one face. The delegate's Keyfunc is - // intentionally nil — the JWT path fails closed until a JWKS-backed key function - // is wired (deployment integration point) — while the local-password path is - // live the moment `felis breakGlass` flips local_auth_enabled on. + // The external face is fronted by SessionAuth: it prefers a local session + // cookie (minted by the passwordless doors) and otherwise delegates to the + // Cloudflare-Access JWT verifier, so both auth models coexist on one face. The + // delegate's Keyfunc is intentionally nil — the JWT path fails closed until a + // JWKS-backed key function is wired (deployment integration point) — while the + // local session path is live the moment `felis breakGlass` flips + // local_auth_enabled on. External: api.SessionAuth{ Repo: repo, Delegate: api.AccessVerifier{Audience: cfg.Auth.AccessJWTAud}, @@ -229,6 +230,7 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int { }, RootDomain: cfg.Server.RootDomain, AdminHostname: cfg.Auth.AdminHostname, + PanelHostname: cfg.Auth.PanelHostname, WakeCooldown: 30 * time.Second, // Bound concurrent console/build-log SSE streams per principal. Generous enough // for legitimate multi-tab / multi-server watching, while capping how many @@ -271,7 +273,13 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int { fmt.Fprintln(stderr, "felis api: passkey verifier disabled (auth.panel_hostname unset) — passkey endpoints return 503") } - externalHandler := panel.Handler(a.ExternalHandler(), cfg.Server.RootDomain, cfg.Auth.PanelHostname, cfg.Auth.AdminHostname, resolvedVersion()) + // Derive the console hostnames when felis.toml leaves them unset, exactly as the + // setup/breakGlass paths do — otherwise the SPA cannot tell which face it is + // serving and falls back to the player console on op.console.. + externalHandler := panel.Handler(a.ExternalHandler(), cfg.Server.RootDomain, + defaultPanelHostname(cfg.Server.RootDomain, cfg.Auth.PanelHostname), + defaultAdminHostname(cfg.Server.RootDomain, cfg.Auth.AdminHostname), + resolvedVersion()) internalSrv := newAPIServer(*internalAddr, a.InternalHandler()) externalSrv := newAPIServer(cfg.Server.Listen, externalHandler) diff --git a/cmd/felis/breakglass.go b/cmd/felis/breakglass.go index d62ae58..0909db3 100644 --- a/cmd/felis/breakglass.go +++ b/cmd/felis/breakglass.go @@ -26,7 +26,7 @@ import ( // authority is local root, so it legitimately BYPASSES the web Zero-Trust + Passkey // path: critical recovery runs direct-to-Postgres. The thin-thread operation it // ships here is the one that bootstraps everything else — provision (or reset) the -// single Owner account and turn local-password login on — so that even with the web +// single Owner account and turn local session sign-in on — so that even with the web // auth path unconfigured an operator can get into op.console. It is a genuine // interactive TUI, NOT a CLI: bare `felis` prints CLI usage, while `felis breakGlass` // opens this full-screen console. It refuses to run unless euid is 0 (sudo/root). @@ -44,7 +44,7 @@ import ( // When a staff account already exists the console opens on a thin top-level menu // (menuModel) so that operations are peers, not tails of one wizard. Two account // operations are wired today: (1) provision/reset the Owner — the thin thread above, -// which also re-enables local-password login — and (2) add an Operator: an +// which also re-enables local session sign-in — and (2) add an Operator: an // insert-only mint of an additional staff admin (provisionOperator) that // deliberately never touches the global local_auth toggle. On a fresh machine (no // Owner yet) the menu is skipped: bootstrapping the first Owner is the only sensible @@ -156,7 +156,7 @@ func cmdBreakGlass(args []string, stdout, stderr io.Writer) int { // The TUI runs on the alternate screen, which is torn down on exit and takes its // display with it. Re-print a durable summary to the normal screen so the - // outcome — and any generated one-time password — survives in scrollback long + // outcome — and the one-time setup URL — survives in scrollback long // enough for the operator to log in. if res.provisioned { if res.isOperator { @@ -164,7 +164,7 @@ func cmdBreakGlass(args []string, stdout, stderr io.Writer) int { // so the summary must not claim it did — only the Owner thread enables login. fmt.Fprintf(stdout, "\nfelis breakGlass: Operator account %q provisioned.\n", res.username) } else { - fmt.Fprintf(stdout, "\nfelis breakGlass: Owner account %q provisioned; local-password login is ENABLED.\n", res.username) + fmt.Fprintf(stdout, "\nfelis breakGlass: Owner account %q provisioned; local session sign-in is ENABLED.\n", res.username) } fmt.Fprintf(stdout, "Recorded as %q (mode: %s, os user: %s).\n", res.accountable, res.mode, res.osUser) if res.setupTokenURL != "" { @@ -318,8 +318,9 @@ func provisionOperator(ctx context.Context, s ownerStore, username, email string } // enableLocalAuth flips the runtime local_auth_enabled toggle on -// direct-to-Postgres. It is a load-bearing write of break-glass: without it -// handleLogin returns 403 and the freshly provisioned Owner cannot log in, so a +// direct-to-Postgres. It is a load-bearing write of break-glass: without it every +// session-minting door (passkey / email-OTP / bind / op-login) returns 403 +// local_auth_disabled and the freshly provisioned Owner cannot log in, so a // successful provisionOwner with local auth off is not a usable thin thread. func enableLocalAuth(ctx context.Context, s ownerStore) error { // The setting is read back with json.Unmarshal into a bool, so the stored jsonb @@ -349,7 +350,7 @@ type breakGlassOutcome struct { } // performBreakGlass executes a resolved break-glass operation: provision (or reset) -// the Owner, enable local-password login, then record a best-effort accountability +// the Owner, enable local session sign-in, then record a best-effort accountability // audit row. The Owner is passwordless — the setup-token flow handles first-login // setup. The audit write is best-effort: a logging failure is reported via auditErr // but does NOT fail the recovery — break-glass must still work when the audit sink diff --git a/cmd/felis/setup.go b/cmd/felis/setup.go index fe71602..a8fd898 100644 --- a/cmd/felis/setup.go +++ b/cmd/felis/setup.go @@ -123,7 +123,7 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int { } panelURL := res.panelURL if panelURL == "" { - panelURL = localPanelURL(setup.cfg.Server.RootDomain) + panelURL = localPanelURL(setup.cfg.Server.RootDomain, setup.cfg.Auth.AdminHostname) } if !res.provisioned && !res.connectConfigured { diff --git a/cmd/felis/setup_panel.go b/cmd/felis/setup_panel.go index e362d92..4fb2743 100644 --- a/cmd/felis/setup_panel.go +++ b/cmd/felis/setup_panel.go @@ -32,11 +32,11 @@ func setupPanelNodePort() int { return port } -func localPanelURL(rootDomain string) string { +func localPanelURL(rootDomain, adminHostname string) string { if ip := rootDomainEmbeddedIP(rootDomain); ip != "" { return fmt.Sprintf("https://%s:%d", ip, setupPanelNodePort()) } - host := defaultAdminHostname(rootDomain, "") + host := defaultAdminHostname(rootDomain, adminHostname) if host == "" { return "" } @@ -61,8 +61,8 @@ func localPanelOrigin() string { return fmt.Sprintf("https://127.0.0.1:%d", setupPanelNodePort()) } -func checkPanelAccess(rootDomain string) panelAccessResult { - base := localPanelURL(rootDomain) +func checkPanelAccess(rootDomain, adminHostname string) panelAccessResult { + base := localPanelURL(rootDomain, adminHostname) if base == "" { return panelAccessResult{err: fmt.Errorf("root domain is empty")} } diff --git a/cmd/felis/tui_connect.go b/cmd/felis/tui_connect.go index 35a3ef5..3f0c65f 100644 --- a/cmd/felis/tui_connect.go +++ b/cmd/felis/tui_connect.go @@ -15,7 +15,8 @@ import ( // None is privileged: "Local" installs nothing, "Cloudflare Tunnel" is a // turnkey integration, and "Reverse proxy" just records hostnames and hands the // operator a copy-paste guide. The admin console is gated by the Owner's -// local-password session regardless; Cloudflare Access is an *additional* layer. +// local session (passwordless sign-in) regardless; Cloudflare Access is an +// *additional* layer. type connectChooserModel struct { rootDomain string adminHost string @@ -46,8 +47,8 @@ func (m *connectChooserModel) build() *huh.Form { // A dim, untitled footnote — deliberately subordinate to the picker above // so the screen reads as a menu, not an info page. huh.NewNote().Description( - "⚠ Local / reverse proxy gate the admin console on your Owner password alone. "+ - "Cloudflare Access adds an edge check in front."), + "⚠ Local / reverse proxy gate the admin console on your Owner sign-in alone "+ + "(passkey / email code). Cloudflare Access adds an edge check in front."), ))) } diff --git a/cmd/felis/tui_edge_apply.go b/cmd/felis/tui_edge_apply.go index fe643e4..691aed7 100644 --- a/cmd/felis/tui_edge_apply.go +++ b/cmd/felis/tui_edge_apply.go @@ -72,7 +72,7 @@ func applyCloudflareEdge(ctx context.Context, result *cfsetup.Result, panelHost, // applyReverseProxy records the operator's chosen public hostnames and rolls the // API so the panel serves them. No Access audience is set: the admin console is -// gated by the Owner's local-password session, and the operator's own reverse +// gated by the Owner's local session (passwordless sign-in), and the operator's own reverse // proxy (Caddy/nginx/Traefik/…) terminates TLS in front of the NodePort origin. func applyReverseProxy(ctx context.Context, panelHost, adminHost string) error { if adminHost == "" { diff --git a/cmd/felis/tui_menu.go b/cmd/felis/tui_menu.go index c4102b1..938e8b7 100644 --- a/cmd/felis/tui_menu.go +++ b/cmd/felis/tui_menu.go @@ -56,10 +56,10 @@ func (m *menuModel) build() *huh.Form { huh.NewOption("Back up a world now (Sync)", bgSyncBackup), ), // A dim footnote spelling out the one behavioural difference that matters: - // Owner-reset re-enables local-password login, operator-add never touches the - // global auth toggle. + // Owner-reset re-enables local session sign-in, operator-add never touches + // the global auth toggle. huh.NewNote().Description( - "Owner reset re-enables local-password login. Adding an Operator mints an "+ + "Owner reset re-enables local session sign-in. Adding an Operator mints an "+ "additional staff admin and leaves the global auth toggle untouched."), ))) } diff --git a/cmd/felis/tui_menu_test.go b/cmd/felis/tui_menu_test.go index 1337770..6b16c6f 100644 --- a/cmd/felis/tui_menu_test.go +++ b/cmd/felis/tui_menu_test.go @@ -96,7 +96,7 @@ func TestProvisionCmdSelectsPathByOperation(t *testing.T) { if msg.err != nil { t.Fatalf("owner provision: %v", msg.err) } - // performBreakGlass upserts the single Owner and enables local-password login. + // performBreakGlass upserts the single Owner and enables local session sign-in. if len(f.upserts) != 1 || len(f.inserts) != 0 { t.Fatalf("want 1 upsert and 0 inserts (performBreakGlass), got upserts=%d inserts=%d", len(f.upserts), len(f.inserts)) } diff --git a/cmd/felis/tui_owner.go b/cmd/felis/tui_owner.go index a116591..a01c63f 100644 --- a/cmd/felis/tui_owner.go +++ b/cmd/felis/tui_owner.go @@ -103,7 +103,7 @@ func newOwnerModel(ctx context.Context, store ownerStore, osUser string, adminEx // newOperatorModel builds the model for the Add-Operator break-glass operation. It // always starts at admin authentication: adding an Operator presupposes an existing // admin (that is why the menu only offers it when one exists), so there is no -// bootstrap branch and the password is always generated. The username is left empty +// bootstrap branch. The username is left empty // on purpose — defaulting it to "owner" (as the Owner flow does) would make the // happy path insert a duplicate and hit ErrConflict on every attempt. func newOperatorModel(ctx context.Context, store ownerStore, osUser string) *ownerModel { @@ -285,7 +285,7 @@ func (m *ownerModel) provisionCmd() tea.Cmd { // performAddOperator and performBreakGlass share a signature; the operation // discriminator selects which one runs. The operator path is insert-only and // never flips local auth (see performAddOperator); the Owner path upserts and - // enables local-password login. + // enables local session sign-in. perform := performBreakGlass if m.operation == bgAddOperator { perform = performAddOperator diff --git a/cmd/felis/tui_preflight.go b/cmd/felis/tui_preflight.go index f4d5353..3392e0b 100644 --- a/cmd/felis/tui_preflight.go +++ b/cmd/felis/tui_preflight.go @@ -16,6 +16,7 @@ import ( type preflightModel struct { dbURL string rootDomain string + adminHost string sp spinner.Model state pfState @@ -55,11 +56,11 @@ type pfMigApplyMsg struct { type pfPanelMsg struct{ err error } -func newPreflightModel(dbURL, rootDomain string) *preflightModel { +func newPreflightModel(dbURL, rootDomain, adminHostname string) *preflightModel { sp := spinner.New() sp.Spinner = spinner.Dot sp.Style = tuiLabel - return &preflightModel{dbURL: dbURL, rootDomain: rootDomain, sp: sp, state: pfCheckDB} + return &preflightModel{dbURL: dbURL, rootDomain: rootDomain, adminHost: adminHostname, sp: sp, state: pfCheckDB} } func (m *preflightModel) Init() tea.Cmd { @@ -221,7 +222,7 @@ func (m *preflightModel) applyMigrations() tea.Cmd { func (m *preflightModel) checkPanel() tea.Cmd { return func() tea.Msg { - return pfPanelMsg{err: checkPanelAccess(m.rootDomain).err} + return pfPanelMsg{err: checkPanelAccess(m.rootDomain, m.adminHost).err} } } diff --git a/cmd/felis/tui_root.go b/cmd/felis/tui_root.go index 0902e3a..161fd1b 100644 --- a/cmd/felis/tui_root.go +++ b/cmd/felis/tui_root.go @@ -179,7 +179,7 @@ func newRootModel(ctx context.Context, store ownerStore, dbURL, rootDomain, admi } } else { rm.stage = stagePreflight - rm.screen = newPreflightModel(dbURL, rootDomain) + rm.screen = newPreflightModel(dbURL, rootDomain, adminHostname) } return rm } @@ -524,7 +524,7 @@ func (m *rootModel) applyConnectResult(msg connectResultMsg) { m.result.connectConfigured = true m.result.reverseProxyGuide = msg.guide } - m.result.panelURL = panelURLFor(msg.method, msg.panelHostname, m.rootDomain) + m.result.panelURL = panelURLFor(msg.method, msg.panelHostname, m.rootDomain, m.adminHost) } func (m *rootModel) showSummary() (tea.Model, tea.Cmd) { @@ -555,7 +555,7 @@ func (m *rootModel) showStatus() (tea.Model, tea.Cmd) { method = connectCloudflare accessLabel = connectMethodLabel(connectCloudflare) } - m.result.panelURL = panelURLFor(method, m.panelHost, m.rootDomain) + m.result.panelURL = panelURLFor(method, m.panelHost, m.rootDomain, m.adminHost) return m.adopt(&summaryModel{ panelURL: m.result.panelURL, accessLabel: accessLabel, @@ -564,9 +564,9 @@ func (m *rootModel) showStatus() (tea.Model, tea.Cmd) { }) } -func panelURLFor(method connectMethod, panelHostname, rootDomain string) string { +func panelURLFor(method connectMethod, panelHostname, rootDomain, adminHostname string) string { if method != connectLocal && panelHostname != "" { return "https://" + panelHostname } - return localPanelURL(rootDomain) + return localPanelURL(rootDomain, adminHostname) } diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index f70bd84..8d76eac 100644 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -1031,6 +1031,8 @@ EOF api-base-url=http://${api_ip}:8081 service-token=${SERVICE_TOKEN} root-domain=${FELIS_ROOT_DOMAIN} +panel-hostname=console.${FELIS_ROOT_DOMAIN} +admin-hostname=op.console.${FELIS_ROOT_DOMAIN} login-server=${LOGIN_SERVER} lobby-server=${LOBBY_SERVER} EOF diff --git a/deploy/limbo/README.md b/deploy/limbo/README.md index 75edab4..43bf8db 100644 --- a/deploy/limbo/README.md +++ b/deploy/limbo/README.md @@ -56,7 +56,7 @@ Configuration (deployment inputs, never compiled in; env wins over a | `FELIS_SERVICE_TOKEN` | internal service token (secret) | *(required for login)* | | `FELIS_ROOT_DOMAIN` | deployment zone, builds `https://console.` | *(required for login)* | | `FELIS_LOBBY_SERVER` | Velocity server name to transfer to | `lobby` | -| `FELIS_LOGIN_TIMEOUT_SECONDS` | login window (clamped 30–3600) | `300` | +| `FELIS_LOGIN_TIMEOUT_SECONDS` | login window (clamped 30–3600) | `600` | | `FELIS_HEALTH_PORT` | readiness port | `8080` | If the API config **or** the root domain is absent the login flow stays **OFF** and diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 1d76365..d42e37a 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -90,12 +90,12 @@ components: in: cookie name: felis_session description: >- - Opaque local-password session cookie (external face). Minted by - POST /api/v1/auth/login when local auth is enabled, HttpOnly+Secure+ - SameSite=Lax and host-only, so an op.console session never reaches the - player console. Only its sha-256 is persisted. SessionAuth prefers this - cookie and otherwise delegates to accessJWT, so the two models coexist on - one face. + Opaque session cookie (external face). Minted by the passwordless + session doors — passkey login, email-OTP, bind code, and op-login + finish — HttpOnly+Secure+SameSite=Lax and host-only, so an op.console + session never reaches the player console. Only its sha-256 is + persisted. SessionAuth prefers this cookie and otherwise delegates to + accessJWT, so the two models coexist on one face. responses: NoContent: @@ -234,7 +234,7 @@ components: MyServerView: type: object description: One row of the caller's server list (internal/api/repo.go MyServerView). - required: [name, subdomain, owned, claimable] + required: [name, subdomain, owned, claimable, playersOnline, playersMax] properties: name: { type: string } subdomain: { type: string } @@ -243,6 +243,11 @@ components: phase: allOf: [{ $ref: '#/components/schemas/Phase' }] description: Present only when known. + playersOnline: + type: integer + format: int32 + description: Best-effort from live CRD status; 0 when the cluster is unreachable. + playersMax: { type: integer, format: int32 } BackupView: type: object @@ -331,32 +336,30 @@ components: UserView: type: object description: One row of the admin user list (internal/api/repo.go UserView). - required: [id, username, role, disabled, email_verified, must_change_password, server_count, created_at, updated_at] + required: [id, username, role, disabled, email_verified, server_count, created_at, updated_at] properties: id: { type: string } username: { type: string } email: { type: string } - role: { type: string, enum: [admin, user] } + role: { type: string, enum: [owner, admin, user] } disabled: { type: boolean } email_verified: { type: boolean } server_count: { type: integer } - must_change_password: { type: boolean } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } UserDetail: type: object description: Full admin view of one user (internal/api/repo.go UserDetail). - required: [id, username, role, disabled, email_verified, must_change_password, server_count, created_at, updated_at, linked_accounts] + required: [id, username, role, disabled, email_verified, server_count, created_at, updated_at, linked_accounts] properties: id: { type: string } username: { type: string } email: { type: string } - role: { type: string, enum: [admin, user] } + role: { type: string, enum: [owner, admin, user] } disabled: { type: boolean } email_verified: { type: boolean } server_count: { type: integer } - must_change_password: { type: boolean } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } deleted_at: @@ -550,27 +553,6 @@ paths: '503': $ref: '#/components/responses/ServiceUnavailable' - /api/v1/servers/by-host/{host}: - get: - tags: [servers-internal] - operationId: serverByHost - summary: Resolve a server by its connecting hostname (velocity host routing). - x-felis-face: [internal] - x-felis-tier: service - security: [{ serviceToken: [] }] - parameters: - - { name: host, in: path, required: true, schema: { type: string } } - responses: - '200': - description: The matching server's status projection. - content: - application/json: - schema: { $ref: '#/components/schemas/ServerInfo' } - '401': - $ref: '#/components/responses/Unauthorized' - '404': - $ref: '#/components/responses/NotFound' - /api/v1/internal/servers/{name}/ready: post: tags: [servers-internal] @@ -787,12 +769,13 @@ paths: auth_source: type: string enum: [mojang, thirdparty] - default: mojang description: > Which Yggdrasil authenticated the in-game UUID (spec §10 - dual-Yggdrasil). Optional; an omitted value defaults to the - Mojang-priority source. Captured here because only the in-game - side sees the authentication; it is copied onto the link at verify. + dual-Yggdrasil). Optional; when omitted it is derived from the + UUID's version nibble (felis-nano rewrites third-party profiles + to UUIDv3; Mojang profiles are v4), defaulting to mojang. + Captured here because only the in-game side sees the + authentication; it is copied onto the link at verify. responses: '201': description: Code minted. @@ -804,6 +787,12 @@ paths: properties: code: { type: string } expires_at: { type: string, format: date-time } + panel_url: + type: string + description: > + Where to redeem the code (https://). Present + only when a panel hostname is configured, so the in-game + message can print a clickable destination. '400': $ref: '#/components/responses/BadRequest' '401': @@ -817,10 +806,11 @@ paths: description: > Internal-only, read-only. After a new player scans the QR-encoded link code and the web verify writes the durable account_links row, velocity polls this - for the UUID it minted against and admits the player on linked:true, binding - the in-game session to user_id. Keyed by the verified UUID (not the scanned - code), so it consumes nothing and is safe to poll repeatedly; an unlinked or - never-seen UUID returns linked:false, and user_id is present only when linked. + for the UUID it minted against and admits the player on linked:true. Keyed by + the verified UUID (not the scanned code), so it consumes nothing and is safe + to poll repeatedly; an unlinked or never-seen UUID returns linked:false. The + response is deliberately just the boolean — the plugin keys everything on the + UUID it already holds, so no identity detail crosses back. x-felis-face: [internal] x-felis-tier: service security: [{ serviceToken: [] }] @@ -828,7 +818,7 @@ paths: - { name: mc_uuid, in: path, required: true, schema: { type: string, format: uuid } } responses: '200': - description: Link-completion status; user_id is present only when linked. + description: Link-completion status. content: application/json: schema: @@ -836,7 +826,6 @@ paths: required: [linked] properties: linked: { type: boolean } - user_id: { type: string } '401': $ref: '#/components/responses/Unauthorized' @@ -970,9 +959,11 @@ paths: operationId: opLoginPending summary: List live pending op.console login requests, oldest first (spec §B). description: > - Internal-only. Velocity polls it and pushes waiting requests to online admins, - who approve one with /felis web op approve . No pending request is secret - to the operator crew. + Internal-only. Lists the requests awaiting an in-game vouch. Today no plugin + consumes it — the staff member reads the request id off the op.console page + and an admin approves it with /felis web op approve ; the route exists so + velocity can later push the waiting list to online admins. No pending request + is secret to the operator crew. x-felis-face: [internal] x-felis-tier: service security: [{ serviceToken: [] }] @@ -1640,6 +1631,62 @@ paths: '503': $ref: '#/components/responses/ServiceUnavailable' + /api/v1/servers/{name}/access/luckperms/{player}: + get: + tags: [access] + operationId: accessLuckPermsInfo + summary: Read a player's LuckPerms groups and permission nodes (spec §7). Owner/admin only. + description: >- + Translates to "lp user permission info" over RCON and parses the + paginated, colour-coded reply (up to 10 pages) into structured entries. + Parent groups (granted group. nodes without a world context) are + split out from plain permission nodes. The raw concatenated RCON output + is echoed back for anything the parser cannot represent. + x-felis-face: [external] + x-felis-tier: app + security: [{ accessJWT: [] }] + parameters: + - { name: name, in: path, required: true, schema: { type: string } } + - { name: player, in: path, required: true, schema: { type: string } } + responses: + '200': + description: Parsed LuckPerms state plus the raw command output. + content: + application/json: + schema: + type: object + required: [player, groups, permissions, output] + properties: + player: { type: string } + groups: + type: array + items: { type: string } + permissions: + type: array + items: + type: object + required: [node, value] + properties: + node: { type: string } + value: { type: boolean, description: "false = negated (§c) node" } + world: { type: string, description: "present only for world-scoped nodes" } + output: { type: string } + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + description: Server not running. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '503': + $ref: '#/components/responses/ServiceUnavailable' + /api/v1/servers/{name}/status: get: tags: [servers] @@ -2456,28 +2503,29 @@ paths: application/json: schema: type: object - required: [user_id, email, role, is_admin, must_change_password] + required: [user_id, email, role, is_admin, is_owner, email_verified] properties: user_id: { type: string } email: { type: string, format: email } role: type: string - enum: [user, admin] + enum: [user, admin, owner] description: The principal's role, mirroring users.role. is_admin: type: boolean description: >- - True only when role is admin AND the request arrived via the - admin Access path (Principal.IsAdmin()). - must_change_password: + True only when role is admin or owner AND the request arrived + via the admin Access path (Principal.IsAdmin()). + is_owner: type: boolean description: >- - True when a local-password staff account still owes its - first-login password change. Meaningful only on the - local-password path (false on the JWT path). The panel routes - such an account straight to the change-password card. Reachable - while set, alongside change-password and logout, because the - rest of the API is fenced off until the change completes. + True only for the Owner principal on the admin Access path + (Principal.IsOwner()); gates owner-only panel surfaces. + email_verified: + type: boolean + description: >- + Whether the account's email has been verified; the panel + nudges unverified accounts through the email-OTP flow. '401': $ref: '#/components/responses/Unauthorized' @@ -2771,13 +2819,14 @@ paths: application/json: schema: type: object - required: [username, role, password] + required: [username, role] + description: >- + Passwordless: the new account signs in via the session doors + (email-OTP / passkey / bind code); no credential is set here. properties: username: { type: string } email: { type: string, format: email } role: { type: string, enum: [admin, user] } - password: { type: string, format: password } - must_change_password: { type: boolean, default: true } responses: '201': description: User created. @@ -3091,7 +3140,10 @@ paths: summary: Force-link a Minecraft UUID to a user, bypassing the code-verification flow (admin only). description: >- The UUID must not already be bound to a different user (409). Same (user, uuid) - pair is idempotent (200). auth_source defaults to "mojang". + pair is idempotent (200). When auth_source is omitted it is derived from the + UUID's version nibble exactly as on the mint path (v3 → thirdparty, else + mojang), so a force-linked thirdparty account keeps its reclaim-guard + protection. x-felis-face: [external] x-felis-tier: owner security: [{ accessJWT: [] }] diff --git a/docs/sequence-diagrams.md b/docs/sequence-diagrams.md index b3bc478..63aa8eb 100644 --- a/docs/sequence-diagrams.md +++ b/docs/sequence-diagrams.md @@ -120,10 +120,10 @@ sequenceDiagram Game->>Game: read verified online-mode UUID Game->>LinkClient: requestCode(mc_uuid) LinkClient->>APIInternal: POST /api/v1/internal/account/link/code {mc_uuid} - APIInternal->>APIInternal: validate UUID; default auth_source=mojang if absent; generate 8-symbol code + APIInternal->>APIInternal: validate UUID; derive auth_source from the UUID version nibble if absent (v3 → thirdparty, else mojang); generate 8-symbol code APIInternal->>Repo: CreateLinkCode(code, mc_uuid, auth_source, expires_at) Repo-->>APIInternal: inserted account_link_codes row - APIInternal-->>LinkClient: 201 {code, expires_at} + APIInternal-->>LinkClient: 201 {code, expires_at, panel_url?} LinkClient-->>Game: LinkCode Game-->>Player: show one-time code in chat diff --git a/internal/api/api.go b/internal/api/api.go index 35288cf..f97a5a2 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -100,6 +100,12 @@ type API struct { // console (console.) the gate is inert. AdminHostname string + // PanelHostname is the player console host (console.) from + // config. Used to render user-facing panel URLs (the /link code's panel_url + // hint); empty falls back to console. (see panelURL), mirroring + // AdminHostname's fallback. + PanelHostname string + // WakeCooldown throttles repeated wakes per server (spec §9.1: cooldown hangs // on the wake lever). Zero disables throttling. WakeCooldown time.Duration @@ -143,6 +149,21 @@ type API struct { streamCap *streamLimiter } +// panelURL returns the public player-console origin ("https://console."), +// preferring the configured PanelHostname and falling back to the conventional +// console. label — the same convention hostIsAdminConsole applies +// to the operator host. Empty when neither is configured (a bare test API). +func (a *API) panelURL() string { + host := a.PanelHostname + if host == "" && a.RootDomain != "" { + host = "console." + a.RootDomain + } + if host == "" { + return "" + } + return "https://" + host +} + // now returns the current time using the injected clock. func (a *API) now() time.Time { if a.Now != nil { @@ -237,7 +258,6 @@ func (a *API) internalAPIRoutes() []apiRoute { {Method: "GET", Pattern: "/readyz", Public: true, h: a.handleReadyz}, {Method: "GET", Pattern: "/api/v1/servers", h: a.handleListServers}, - {Method: "GET", Pattern: "/api/v1/servers/by-host/{host}", h: a.handleByHost}, {Method: "POST", Pattern: "/api/v1/internal/servers/{name}/ready", h: a.handleReady}, {Method: "POST", Pattern: "/api/v1/internal/servers/{name}/join-event", h: a.handleJoinEvent}, // Domain-autostart (spec §9.1, §14): velocity drives the wake lever and polls @@ -282,10 +302,11 @@ func (a *API) internalAPIRoutes() []apiRoute { // (Mojang-first) and rewrites third-party UUIDs into a per-source namespace // before returning the canonical profile (handlers_hasjoined.go). {Method: "GET", Pattern: "/session/minecraft/hasJoined", Public: true, h: a.handleHasJoined}, - // Op-login (passwordless console login): an in-game op requests a login that - // the web owner/admin approves, then redeems for a session. Internal face - // carries the pending queue and the approve action (service-token auth, no - // Principal); the external face carries the start/status/finish the op drives. + // Op-login (passwordless op.console login): a staff member starts the login + // on the web, and an ONLINE in-game admin vouches for it via velocity's + // /felis web op approve. Internal face carries the pending queue and the + // approve action (service-token auth, no Principal); the public face carries + // the start/status/finish the staff member's browser drives. {Method: "GET", Pattern: "/api/v1/internal/op-login/pending", h: a.handleOpLoginPending}, {Method: "POST", Pattern: "/api/v1/internal/op-login/{id}/approve", h: a.handleOpLoginApprove}, @@ -362,6 +383,7 @@ func (a *API) externalAPIRoutes() []apiRoute { {Method: "GET", Pattern: "/api/v1/servers/{name}/access/ban", h: a.handleAccessBanList}, {Method: "POST", Pattern: "/api/v1/servers/{name}/access/permission", h: a.handleAccessPermission}, {Method: "POST", Pattern: "/api/v1/servers/{name}/access/group", h: a.handleAccessGroup}, + {Method: "GET", Pattern: "/api/v1/servers/{name}/access/luckperms/{player}", h: a.handleAccessLuckPermsInfo}, {Method: "GET", Pattern: "/api/v1/servers/{name}/status", h: a.handleStatus}, // Identity self-read (spec §14 tiering): the panel reads this once at boot to // learn its own tier and decide which navigation surfaces to render. App-tier — diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 8677ac8..e5f2b65 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -52,7 +52,7 @@ type fakeRepo struct { linkAuthSource map[string]string // world backups (spec §7, §22). A nil slice lists empty. backups []fakeBackup - // local-password auth (spec §B). staff is keyed by username (the login key); + // session auth (spec §B, passwordless). staff is keyed by username (the login key); // sessions by token_hash; settings by key. They mirror the PG contract so the // hermetic tests exercise the same fail-closed semantics the integration impl // honors. @@ -1600,45 +1600,6 @@ func TestMeIdentity(t *testing.T) { }) } -// ---- by-host ---- - -func TestByHost(t *testing.T) { - cl := newFakeCluster() - cl.bySub["survival"] = &ServerInfo{Name: "survival", Subdomain: "survival", Phase: "Running", Ready: true} - api := newTestAPI(newFakeRepo(), cl) - h := api.InternalHandler() - tok := map[string]string{"Authorization": "Bearer "} // okInternal ignores it - - t.Run("foreign domain rejected", func(t *testing.T) { - w := do(h, "GET", "/api/v1/servers/by-host/survival.evil.example.org", "", tok) - if w.Code != http.StatusBadRequest { - t.Fatalf("code = %d, want 400", w.Code) - } - }) - t.Run("multi-label rejected", func(t *testing.T) { - w := do(h, "GET", "/api/v1/servers/by-host/a.b."+testRoot, "", tok) - if w.Code != http.StatusBadRequest { - t.Fatalf("code = %d, want 400", w.Code) - } - }) - t.Run("unknown server 404", func(t *testing.T) { - w := do(h, "GET", "/api/v1/servers/by-host/creative."+testRoot, "", tok) - if w.Code != http.StatusNotFound { - t.Fatalf("code = %d, want 404", w.Code) - } - }) - t.Run("found", func(t *testing.T) { - w := do(h, "GET", "/api/v1/servers/by-host/survival."+testRoot, "", tok) - if w.Code != http.StatusOK { - t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String()) - } - var info ServerInfo - if err := json.Unmarshal(w.Body.Bytes(), &info); err != nil || info.Name != "survival" { - t.Fatalf("unexpected body %s err %v", w.Body.String(), err) - } - }) -} - // ---- fleet (SysAdmin cockpit read) ---- // TestFleetAdminRead proves the SysAdmin cockpit's fleet read is admin-tier AND diff --git a/internal/api/handlers_access.go b/internal/api/handlers_access.go index aa30141..6fea391 100644 --- a/internal/api/handlers_access.go +++ b/internal/api/handlers_access.go @@ -403,6 +403,117 @@ func (a *API) handleAccessGroup(w http.ResponseWriter, r *http.Request) { }) } +// lpPermissionView is one parsed LuckPerms permission entry returned by the +// luckperms read projector. World is surfaced only when the entry carries a +// world= context (the one context the panel renders); Value comes from the +// entry's color code (LuckPerms renders granted nodes green, negated red). +type lpPermissionView struct { + Node string `json:"node"` + Value bool `json:"value"` + World string `json:"world,omitempty"` +} + +// maxLPInfoPages bounds how many "permission info" pages the read projector +// chases per request. LuckPerms paginates its reply, so one command shows only +// the first page; we follow the header's page count up to this cap. +// ponytail: 10 pages ≈ 150 entries — raise if a real user outgrows it. +const maxLPInfoPages = 10 + +// handleAccessLuckPermsInfo is the read projector for a player's LuckPerms +// state: it runs "lp user permission info" over the same owner-gated +// RCON spine as every access mutation and returns a best-effort parse — parent +// groups split out from plain permission nodes — PLUS the raw reply, like the +// whitelist/players/banlist reads. Page 1 goes through issueAccessCommand (the +// gate); further pages are fetched best-effort directly, so a mid-fetch failure +// keeps what was already read instead of erroring a half-served response. +// No audit (a read). +func (a *API) handleAccessLuckPermsInfo(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + player := r.PathValue("player") + if !mcNameRe.MatchString(player) { + writeError(w, r, errInvalidPlayer) + return + } + + out, ok := a.issueAccessCommand(w, r, name, "lp user "+player+" permission info") + if !ok { + return + } + raw := out + entries, pages := parseLuckPermsInfo(out) + for page := 2; page <= pages && page <= maxLPInfoPages; page++ { + more, err := a.Console.RunCommand(r.Context(), name, + fmt.Sprintf("lp user %s permission info %d", player, page)) + if err != nil { + break // best-effort: keep the pages we have + } + raw += "\n" + more + e, _ := parseLuckPermsInfo(more) + entries = append(entries, e...) + } + + // Split parent groups ("group.", granted, no context) from plain + // permission nodes. A negated or world-scoped group.* entry stays in + // permissions — folding it into groups would lose the negation/scope. + groups := []string{} + permissions := []lpPermissionView{} + for _, e := range entries { + if g, isGroup := strings.CutPrefix(e.Node, "group."); isGroup && e.Value && e.World == "" && lpCtxRe.MatchString(g) { + groups = append(groups, g) + continue + } + permissions = append(permissions, e) + } + writeJSON(w, http.StatusOK, map[string]any{ + "player": player, "groups": groups, "permissions": permissions, "output": raw, + }) +} + +var ( + // lpEntryRe matches one "permission info" entry: the "> " marker, then any + // legacy color codes, then the node (lpNodeRe's charset). Anchoring on the + // marker rather than lines follows banEntryRe's rationale: RCON concatenates + // multi-message replies with a server-dependent separator, so a line split is + // unreliable. Group 1 keeps the color codes so the entry's value survives the + // later color strip (§a = granted, §c = negated). + lpEntryRe = regexp.MustCompile(`>\s*((?:§[0-9a-fk-or])*)([A-Za-z0-9_.*-]{1,64})`) + // lpPageRe reads the pagination header ("page 1 of 3") AFTER color stripping. + lpPageRe = regexp.MustCompile(`page\s+(\d+)\s+of\s+(\d+)`) + // lpColorRe strips legacy §-color codes. + lpColorRe = regexp.MustCompile(`§[0-9a-fk-or]`) + // lpWorldRe reads a world= context from an entry's color-stripped tail. + lpWorldRe = regexp.MustCompile(`world=([A-Za-z0-9_-]{1,48})`) +) + +// parseLuckPermsInfo extracts permission entries and the total page count from +// one "lp user permission info" reply. Best-effort and +// LuckPerms-specific (INTEGRATION-ONLY against a real server) — the caller +// always returns the raw reply alongside, so an unrecognised format loses +// nothing. An entry's value defaults to granted when no color code precedes the +// node (a color-stripping RCON transport); pages is 0 when no header parses. +func parseLuckPermsInfo(out string) (entries []lpPermissionView, pages int) { + matches := lpEntryRe.FindAllStringSubmatchIndex(out, -1) + for i, m := range matches { + colors := out[m[2]:m[3]] + node := out[m[4]:m[5]] + // The entry's tail (up to the next marker) carries its contexts. + tailEnd := len(out) + if i+1 < len(matches) { + tailEnd = matches[i+1][0] + } + tail := lpColorRe.ReplaceAllString(out[m[5]:tailEnd], "") + e := lpPermissionView{Node: node, Value: !strings.Contains(colors, "§c")} + if wm := lpWorldRe.FindStringSubmatch(tail); wm != nil { + e.World = wm[1] + } + entries = append(entries, e) + } + if pm := lpPageRe.FindStringSubmatch(lpColorRe.ReplaceAllString(out, "")); pm != nil { + pages, _ = strconv.Atoi(pm[2]) + } + return entries, pages +} + // parseWhitelistOutput extracts player names from vanilla's "whitelist list" // reply, whose format is "There are N whitelisted player(s): a, b, c" (and "There // are no whitelisted players" / a trailing colon for the empty case). The parse diff --git a/internal/api/handlers_account.go b/internal/api/handlers_account.go index b3e5e96..13d7b88 100644 --- a/internal/api/handlers_account.go +++ b/internal/api/handlers_account.go @@ -51,6 +51,26 @@ func validAuthSource(s string) bool { return s == authSourceMojang || s == authSourceThirdParty } +// deriveAuthSource infers the auth source from the UUID's version nibble when +// the minting backend omitted auth_source. Felis-nano rewrites every +// third-party profile to a name-based UUIDv3 under its namespace before it ever +// reaches the proxy, while Mojang profiles keep their random v4 — so on a +// nano-fronted deployment the version nibble alone identifies the source, and +// no Java plugin has to learn the field. Anything unparseable keeps the +// historical Mojang-priority default. +func deriveAuthSource(mcUUID string) string { + hex := strings.ReplaceAll(mcUUID, "-", "") + if len(hex) != 32 { + return authSourceMojang + } + switch hex[12] { + case '3': + return authSourceThirdParty + default: + return authSourceMojang + } +} + // newLinkCode returns a cryptographically random, unambiguous link code. func newLinkCode() (string, error) { buf := make([]byte, linkCodeLen) @@ -88,12 +108,13 @@ func (a *API) handleCreateLinkCode(w http.ResponseWriter, r *http.Request) { writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required")) return } - // Default an omitted source to Mojang (spec §10 priority) but reject an - // unrecognised one — a typo'd source must not silently land as a stored value - // the panel will later mislabel. + // Default an omitted source from the UUID's version nibble (v3 = felis-nano + // third-party rewrite, v4 = Mojang; see deriveAuthSource) but reject an + // unrecognised explicit one — a typo'd source must not silently land as a + // stored value the panel will later mislabel. authSource := req.AuthSource if authSource == "" { - authSource = authSourceMojang + authSource = deriveAuthSource(req.MCUUID) } if !validAuthSource(authSource) { writeError(w, r, newError(http.StatusBadRequest, "bad_request", @@ -110,10 +131,17 @@ func (a *API) handleCreateLinkCode(w http.ResponseWriter, r *http.Request) { writeError(w, r, err) return } - writeJSON(w, http.StatusCreated, map[string]any{ + // panel_url tells the in-game side where the player redeems the code, so + // every plugin renders the same address from one source of truth instead of + // each baking in its own hostname. Omitted when no hostname is configured. + resp := map[string]any{ "code": code, "expires_at": expiresAt.UTC(), - }) + } + if u := a.panelURL(); u != "" { + resp["panel_url"] = u + } + writeJSON(w, http.StatusCreated, resp) } // handleLinkStatus reports whether an in-game UUID has finished linking yet — the @@ -125,8 +153,9 @@ func (a *API) handleCreateLinkCode(w http.ResponseWriter, r *http.Request) { // as a QR → player scans it on a phone already signed in to console. // → that web session's verify (handleLinkVerify) writes the durable account_links // row bound to THAT user → velocity polls HERE for the same UUID it minted against -// → on {linked:true} it admits the player, binding the in-game session to user_id -// with no reconnect — the whole point of scanning over typing. +// → on {linked:true} it admits the player with no reconnect — the whole point of +// scanning over typing. The response is deliberately just the boolean: the plugin +// keys everything on the UUID it already holds, so no identity detail crosses back. // // The poll is keyed by the verified mc_uuid velocity already holds, not by the // scanned code, so it is a pure idempotent read of the durable link (UserByMCUUID): @@ -147,7 +176,7 @@ func (a *API) handleLinkStatus(w http.ResponseWriter, r *http.Request) { writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required")) return } - userID, err := a.Repo.UserByMCUUID(r.Context(), mcUUID) + _, err := a.Repo.UserByMCUUID(r.Context(), mcUUID) switch { case errors.Is(err, ErrNotFound): // Not linked yet. For the poller this is simply "keep waiting": velocity @@ -159,7 +188,7 @@ func (a *API) handleLinkStatus(w http.ResponseWriter, r *http.Request) { writeError(w, r, err) return } - writeJSON(w, http.StatusOK, map[string]any{"linked": true, "user_id": userID}) + writeJSON(w, http.StatusOK, map[string]any{"linked": true}) } // linkVerifyRequest is the panel verify-code body (spec §10): the logged-in user diff --git a/internal/api/handlers_account_test.go b/internal/api/handlers_account_test.go index 9e9ddf8..1171cc0 100644 --- a/internal/api/handlers_account_test.go +++ b/internal/api/handlers_account_test.go @@ -120,6 +120,30 @@ func TestCreateLinkCode(t *testing.T) { } } }) + t.Run("panel_url points at the web console", func(t *testing.T) { + // The mint response carries the redeem address so every plugin renders the + // same hostname from one source of truth (derived console. here). + w := do(ih, "POST", "/api/v1/internal/account/link/code", `{"mc_uuid":"`+mcUUID+`"}`, nil) + if w.Code != http.StatusCreated { + t.Fatalf("code = %d, want 201 (%s)", w.Code, w.Body.String()) + } + if got := acctBody(t, w)["panel_url"]; got != "https://console."+testRoot { + t.Errorf("panel_url = %v, want https://console.%s", got, testRoot) + } + }) + t.Run("omitted auth_source with a v3 UUID derives thirdparty", func(t *testing.T) { + // A felis-nano rewrite is a name-based UUIDv3; the version nibble alone must + // classify it so no Java plugin has to learn the auth_source field. + const v3UUID = "33333333-3333-3333-8333-333333333333" + w := do(ih, "POST", "/api/v1/internal/account/link/code", `{"mc_uuid":"`+v3UUID+`"}`, nil) + if w.Code != http.StatusCreated { + t.Fatalf("code = %d, want 201 (%s)", w.Code, w.Body.String()) + } + code, _ := acctBody(t, w)["code"].(string) + if rec := repo.linkCodes[code]; rec.authSource != authSourceThirdParty { + t.Errorf("derived authSource = %q, want %q", rec.authSource, authSourceThirdParty) + } + }) t.Run("explicit thirdparty is stored", func(t *testing.T) { body := `{"mc_uuid":"` + mcUUID + `","auth_source":"` + authSourceThirdParty + `"}` w := do(ih, "POST", "/api/v1/internal/account/link/code", body, nil) diff --git a/internal/api/handlers_auth_email.go b/internal/api/handlers_auth_email.go index 7ed449b..c1c59dc 100644 --- a/internal/api/handlers_auth_email.go +++ b/internal/api/handlers_auth_email.go @@ -7,11 +7,11 @@ import ( ) // Pre-session Email-OTP LOGIN (spec §B, console. returning-player door). -// This is the passwordless counterpart of handleLogin and the returning-player -// counterpart of handleBindRedeem: an account that already proved control of an -// email (email_verified, migration 0010) logs back in with a one-time code mailed -// to that address — no password, no in-game Bind Code. The two halves are Public, -// pre-session routes: the caller has no principal yet, so identity is resolved from +// This is the returning-player counterpart of handleBindRedeem: an account that +// already proved control of an email (email_verified, migration 0010) logs back in +// with a one-time code mailed to that address — no password exists anywhere in the +// product, and no in-game Bind Code is needed the second time. The two halves are +// Public, pre-session routes: the caller has no principal yet, so identity is resolved from // the typed email via UserByEmail, exactly as handleBindRedeem resolves it from the // code. // @@ -55,9 +55,9 @@ type loginEmailStartRequest struct { } // handleLoginEmailStart mints and mails a login code for a returning account (Public, -// pre-session). It gates on local sessions being enabled — like handleLogin and -// handleBindRedeem, minting a code toward a felis_session while SessionAuth would -// reject that cookie is pointless — reserves the per-recipient cooldown, resolves the +// pre-session). It gates on local sessions being enabled — like handleBindRedeem +// and the op-login door, minting a code toward a felis_session while SessionAuth +// would reject that cookie is pointless — reserves the per-recipient cooldown, resolves the // address to an account, and (only if one exists) mints a code under otpPurposeLogin. // An address with no verified account yields the SAME 202 as a successful send with // no code minted: the response never distinguishes the two, and the reservation is @@ -164,7 +164,7 @@ type loginEmailVerifyRequest struct { // handleLoginEmailVerify redeems a login code into a session (Public, pre-session). // It resolves the address to an account, verifies the code under otpPurposeLogin, and -// on success mints the same host-only felis_session as handleLogin. A missing account, +// on success mints the same host-only felis_session as handleBindRedeem. A missing account, // a wrong code, AND an attempt-exhausted (locked) code all return the IDENTICAL 400 // invalid_code, so a code-less caller cannot tell an unknown address from a bad guess // or farm a lockout into an is-this-a-real-account oracle. Staff are refused — but only diff --git a/internal/api/handlers_auth_email_test.go b/internal/api/handlers_auth_email_test.go index 20dfec1..8bf0930 100644 --- a/internal/api/handlers_auth_email_test.go +++ b/internal/api/handlers_auth_email_test.go @@ -56,7 +56,7 @@ func errEnvelope(t *testing.T, w *httptest.ResponseRecorder) (code, msg string) // TestLoginEmailVertical walks the whole returning-player slice: a typed lowercase // address resolves the mixed-case stored account, the code is mailed to the account's // STORED casing (the address of record), and redeeming it mints the same host-only -// felis_session as the password door — single-use, audited on both halves by the +// felis_session as the other session doors — single-use, audited on both halves by the // account's username. The redeem never rewrites users.email (login re-proves an // already-verified address via ConsumeLoginEmailOTP), so the stored casing is // untouched by definition. @@ -113,7 +113,7 @@ func TestLoginEmailVertical(t *testing.T) { if vb["user_id"] != "u1" || vb["role"] != "user" { t.Fatalf("verify body = %v, want user_id:u1 role:user", vb) } - // The HttpOnly cookie is the whole point — same contract as handleLogin. + // The HttpOnly cookie is the whole point — same contract as every session door. cookies := w.Result().Cookies() if len(cookies) != 1 || cookies[0].Name != sessionCookieName || cookies[0].Value == "" { t.Fatalf("want one non-empty %s cookie, got %v", sessionCookieName, cookies) @@ -208,8 +208,8 @@ func TestLoginEmailStartNeutralOnUnknownAddress(t *testing.T) { // TestLoginEmailGates covers the shared front doors of both halves: the fail-closed // local-auth toggle, the cross-site-forgery Content-Type guard (these are Public, -// credential-minting routes — same rationale as handleLogin), and the input gates -// that must reject before any mint or lookup. +// credential-minting routes — same rationale as handleBindRedeem), and the input +// gates that must reject before any mint or lookup. func TestLoginEmailGates(t *testing.T) { t.Run("local auth disabled -> 403 on both halves", func(t *testing.T) { api := newTestAPI(newFakeRepo(), newFakeCluster()) // no LocalAuthEnabledKey: fails closed diff --git a/internal/api/handlers_internal.go b/internal/api/handlers_internal.go index d579839..b2d765a 100644 --- a/internal/api/handlers_internal.go +++ b/internal/api/handlers_internal.go @@ -4,7 +4,6 @@ import ( "context" "errors" "net/http" - "strings" "felis.lolicon.best/internal/apis/felis/v1alpha1" "felis.lolicon.best/internal/naming" @@ -43,25 +42,6 @@ func (a *API) handleListServers(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"servers": servers}) } -// handleByHost resolves host=subdomain.{root_domain} to its server (spec §7 -// GET /servers/by-host/{host}). The host is validated against the configured -// root domain — the only place the deployment zone enters the lookup. -func (a *API) handleByHost(w http.ResponseWriter, r *http.Request) { - host := strings.ToLower(r.PathValue("host")) - if err := naming.ValidateHostname(host, a.RootDomain); err != nil { - writeError(w, r, newError(http.StatusBadRequest, "bad_host", "invalid host: %v", err)) - return - } - subdomain := strings.TrimSuffix(host, "."+a.RootDomain) - - info, err := a.Cluster.GetBySubdomain(r.Context(), subdomain) - if err != nil { - a.writeLookupError(w, r, err) - return - } - writeJSON(w, http.StatusOK, info) -} - // handleReady accepts a backend's push that a server is up (spec §7 // /internal/servers/{name}/ready). The RCON probe is the authoritative gate, so // this is advisory: it audits the signal and returns 204. @@ -159,8 +139,9 @@ func (a *API) handleInternalWake(w http.ResponseWriter, r *http.Request) { return } // Global running-server cap (spec §9.1), shared with the external wake. velocity - // treats 503 at_capacity as "cluster full, hold the player", distinct from the - // 429 cooldown's "already waking, keep waiting". + // treats 503 at_capacity as "cluster full, tell the player to try later" and does + // NOT enqueue them (nothing is coming up, so waiting would only strand them), + // distinct from the 429 cooldown's "already waking, keep waiting". ok, err := a.withinRunningCap(r.Context(), info) if err != nil { writeError(w, r, err) @@ -278,7 +259,7 @@ func (a *API) handleInternalClaim(w http.ResponseWriter, r *http.Request) { // the lobby GUI needs to render one server tile, composed from the lifecycle view // (phase/ready/players from the CRD status) and the business ownership row // (claimable = nobody owns it yet). It is the only internal response carrying -// claimable, so it has its own shape — the §11 list/by-host/status views never +// claimable, so it has its own shape — the §11 list/status views never // expose ownership, and folding owner data into ServerInfo would force the // lifecycle layer to consult Postgres. // diff --git a/internal/api/handlers_onboard.go b/internal/api/handlers_onboard.go index dffe134..a9f6d3f 100644 --- a/internal/api/handlers_onboard.go +++ b/internal/api/handlers_onboard.go @@ -32,7 +32,7 @@ import ( // No app-level attempt cap is enforced here (unlike the email-OTP flow, whose 1e6 // keyspace demanded one): the code's ~1e12 keyspace, single use and short TTL make // blind brute force non-viable, and rate-limiting is deferred to the edge exactly as -// for the public /auth/login. The idempotent returning-player branch (a UUID already +// for the other public session doors (email-OTP, op-login). The idempotent returning-player branch (a UUID already // linked to a role=user player is fetched, not re-created) is a DELIBERATE standing // "log in via the game" door, not merely first-time onboarding: control of the // in-game identity is the root of trust, so re-minting a code always re-grants a @@ -62,15 +62,16 @@ type bindRedeemRequest struct { // handleBindRedeem redeems a Bind Code into a player account + session (Public). It is // the account-less player's only door into console.: no prior principal, -// no Zero Trust in front (unlike op.console). Like handleLogin it is a cookie-minting -// public route, so it requires local sessions to be enabled and a JSON content type -// (the cross-site-forgery guard) and mints the same host-only felis_session cookie. +// no Zero Trust in front (unlike op.console). Like the email-OTP login door it is a +// cookie-minting public route, so it requires local sessions to be enabled and a JSON +// content type (the cross-site-forgery guard) and mints the same host-only +// felis_session cookie. // The code is trimmed and uppercased so a player who typed it with stray spaces or in // lowercase still matches, mirroring handleLinkVerify. func (a *API) handleBindRedeem(w http.ResponseWriter, r *http.Request) { // The minted session is a felis_session cookie, honored only when local sessions // are enabled (SessionAuth). Minting one while they are off would hand back a dead - // cookie, so refuse loudly and consistently with handleLogin. This couples the + // cookie, so refuse loudly, consistently with the other session doors. This couples the // player bootstrap to the same toggle that gates op.console local login; a future // deployment wanting player cookies without local admin login would decouple them // in SessionAuth — out of scope here (KNOWN coupling). diff --git a/internal/api/handlers_onboard_test.go b/internal/api/handlers_onboard_test.go index 710edf9..3b98e76 100644 --- a/internal/api/handlers_onboard_test.go +++ b/internal/api/handlers_onboard_test.go @@ -227,7 +227,7 @@ func TestBindRedeemExpiredCode(t *testing.T) { // TestBindRedeemLocalAuthDisabled proves the bootstrap refuses to mint a session that // SessionAuth would not honor: with local sessions off it returns 403, never a dead -// cookie, mirroring handleLogin. +// cookie, mirroring the email-OTP login door. func TestBindRedeemLocalAuthDisabled(t *testing.T) { repo := newFakeRepo() // local_auth_enabled never set → fail closed api := newTestAPI(repo, newFakeCluster()) diff --git a/internal/api/handlers_op_login.go b/internal/api/handlers_op_login.go index 551de42..3c0595a 100644 --- a/internal/api/handlers_op_login.go +++ b/internal/api/handlers_op_login.go @@ -10,14 +10,15 @@ import ( // op.console STAFF login (spec §B op-login): the two-factor door for the most // sensitive tier. Unlike the console. player doors (email OTP / bind // code), a staff web session is never minted from a single factor. The flow is a -// three-call state machine over op_login_requests (migration 0012), all Public +// three-call state machine over op_login_requests (migration 0016), all Public // pre-session routes (the caller has no principal yet), plus two internal-face routes -// velocity drives on behalf of online admins: +// for the in-game side (approve is driven by velocity's /felis command; pending has +// no consumer yet — see handleOpLoginPending): // // POST /api/v1/auth/op-login/start (public) — mint a request + mail an OTP // GET /api/v1/auth/op-login/status/{id} (public) — poll until an admin approves // POST /api/v1/auth/op-login/finish (public) — redeem code+approval → session -// GET /api/v1/internal/op-login/pending (internal) — the online-admin push list +// GET /api/v1/internal/op-login/pending (internal) — list requests awaiting a vouch // POST /api/v1/internal/op-login/{id}/approve (internal) — an in-game admin vouches // // The two factors: @@ -26,9 +27,9 @@ import ( // start and redeemed by finish, reusing the email_otps lifecycle (the purpose // column keeps it from ever colliding with a console login_email or onboard code). // - An in-game vouch — an already-trusted admin who is ONLINE approves the pending -// request via velocity's /felis command (internal approve). Only a linked -// role=admin account may approve; velocity additionally gates the command on -// in-game op, so the API check is defence in depth over its own user table. +// request via velocity's /felis command (internal approve). The API's own user +// table is the sole authority: only a UUID linked to a role=admin account may +// approve (velocity's command runs for any player and relies on this check). // // finish mints the session only when BOTH have landed. Neither factor alone — a mailed // code without an approval, or an approval without the code — yields a session. @@ -317,8 +318,10 @@ func (a *API) handleOpLoginFinish(w http.ResponseWriter, r *http.Request) { } // handleOpLoginPending lists live pending staff login requests, oldest first (internal -// face). Velocity polls it and pushes the waiting requests to online admins, who -// approve one with /felis web op approve . Internal-only: velocity holds a service +// face). Today no plugin consumes it: the approver learns the request id out-of-band +// (the op.console start screen shows it to the person logging in) and runs +// /felis web op approve . The route exists so velocity can later push the waiting +// list to online admins without an API change. Internal-only: velocity holds a service // token and no pending request is secret to the operator crew. func (a *API) handleOpLoginPending(w http.ResponseWriter, r *http.Request) { reqs, err := a.Repo.ListPendingOpLogins(r.Context(), a.now()) @@ -340,8 +343,8 @@ func (a *API) handleOpLoginPending(w http.ResponseWriter, r *http.Request) { // opLoginApproveRequest is the internal approve body: the online-mode UUID of the // in-game admin running /felis web op approve. The API resolves it to a linked account -// and refuses unless that account is role=admin — defence in depth over velocity's own -// in-game op gate, checked against the API's authoritative user table. +// and refuses unless that account is role=admin — this check against the API's +// authoritative user table is the only gate; velocity's command itself is unprivileged. type opLoginApproveRequest struct { ApproverUUID string `json:"approver_uuid"` } diff --git a/internal/api/handlers_op_login_test.go b/internal/api/handlers_op_login_test.go index 1bb3f8f..cf006cc 100644 --- a/internal/api/handlers_op_login_test.go +++ b/internal/api/handlers_op_login_test.go @@ -20,8 +20,8 @@ import ( // all collapse to one op_login_invalid envelope; an early-but-correct code is // preserved (approval is read before the code is consumed), and a wrong code costs // an attempt without burning the approval. -// - Admin-only approval. Only a linked role=admin UUID may vouch; the check is the -// API's own user table, defence in depth over velocity's in-game op gate. +// - Admin-only approval. Only a linked role=admin UUID may vouch; the API's own +// user table is the sole gate (velocity's command itself is unprivileged). const opUUID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" // the seeded admin's linked in-game UUID diff --git a/internal/api/handlers_qr_login_test.go b/internal/api/handlers_qr_login_test.go index d928a28..5278ec0 100644 --- a/internal/api/handlers_qr_login_test.go +++ b/internal/api/handlers_qr_login_test.go @@ -12,11 +12,12 @@ func statusPath(mcUUID string) string { // TestQRLoginCompletionPollVertical walks the QR scan-to-login flow end to end and // proves its load-bearing invariant: the internal completion poll reports the link -// only after the WEB verify writes it, and reports it bound to the exact Principal -// that verified — never to a UUID the poll itself could name. velocity mints and -// polls on the internal face (it holds no web Principal); the durable bind is born -// on the external face from a logged-in user. That split is the whole security -// model of the scan, so the test drives both faces of one API. +// only after the WEB verify writes it. velocity mints and polls on the internal +// face (it holds no web Principal); the durable bind is born on the external face +// from a logged-in user. That split is the whole security model of the scan, so +// the test drives both faces of one API. The poll carries ONLY the boolean — the +// plugin keys everything on the UUID it already holds, so no identity detail +// (user_id) ever crosses back, in either state. func TestQRLoginCompletionPollVertical(t *testing.T) { const mcUUID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" user := &Principal{UserID: "u-scan", Email: "scan@example.net", Role: "user"} @@ -54,9 +55,8 @@ func TestQRLoginCompletionPollVertical(t *testing.T) { t.Fatalf("verify: code = %d, want 200 (%s)", w.Code, w.Body.String()) } - // Now the poll flips: velocity sees linked:true and the user_id it must bind the - // in-game session to — and that user_id is the verifier's, the only identity the - // poll could ever return, since the poll cannot mint a link of its own. + // Now the poll flips: velocity sees linked:true and admits the player. The + // response stays identity-free — linked is the entire contract. w = do(ih, "GET", statusPath(mcUUID), "", nil) if w.Code != http.StatusOK { t.Fatalf("post-verify poll: code = %d, want 200 (%s)", w.Code, w.Body.String()) @@ -65,8 +65,8 @@ func TestQRLoginCompletionPollVertical(t *testing.T) { if b["linked"] != true { t.Fatalf("post-verify poll body = %v, want linked:true", b) } - if got := b["user_id"]; got != user.UserID { - t.Fatalf("post-verify poll user_id = %v, want %q (the verifier's id)", got, user.UserID) + if _, ok := b["user_id"]; ok { + t.Fatalf("post-verify poll leaked user_id: %v", b) } } @@ -101,8 +101,8 @@ func TestQRLoginStatusIdempotent(t *testing.T) { t.Fatalf("poll %d: code = %d, want 200 (%s)", i, w.Code, w.Body.String()) } b := acctBody(t, w) - if b["linked"] != true || b["user_id"] != "u-held" { - t.Fatalf("poll %d body = %v, want linked:true user_id:u-held", i, b) + if b["linked"] != true { + t.Fatalf("poll %d body = %v, want linked:true", i, b) } } // The read must not have disturbed the durable link. @@ -112,8 +112,8 @@ func TestQRLoginStatusIdempotent(t *testing.T) { } // TestQRLoginStatusFaceSeparation enforces that the poll is internal-only. It -// reads who a UUID is linked to — a fact the public web face must not be able to -// fish out by UUID — so crossing onto the external face must 404, not answer. +// reads whether a UUID is linked — a fact the public web face must not be able +// to fish out by UUID — so crossing onto the external face must 404, not answer. func TestQRLoginStatusFaceSeparation(t *testing.T) { user := &Principal{UserID: "u1", Email: "u1@example.net", Role: "user"} api := newTestAPI(newFakeRepo(), newFakeCluster()) diff --git a/internal/api/handlers_user.go b/internal/api/handlers_user.go index 9236ca2..8b805af 100644 --- a/internal/api/handlers_user.go +++ b/internal/api/handlers_user.go @@ -203,6 +203,22 @@ func (a *API) handleMyServers(w http.ResponseWriter, r *http.Request) { writeError(w, r, err) return } + // Player counts are presentational and best-effort, mirroring handleFleet's + // owner join: the list exists for ownership/claim state, so a cluster hiccup + // must degrade to 0/0 counts, never 500 the whole list. The CRD status is the + // only source of live counts (spec §1) — Postgres never stores them. + if infos, err := a.Cluster.ListServers(r.Context()); err == nil { + byName := make(map[string]ServerInfo, len(infos)) + for _, s := range infos { + byName[s.Name] = s + } + for i := range servers { + if info, ok := byName[servers[i].Name]; ok { + servers[i].PlayersOnline = info.PlayersOnline + servers[i].PlayersMax = info.PlayersMax + } + } + } writeJSON(w, http.StatusOK, map[string]any{"servers": servers}) } diff --git a/internal/api/handlers_users.go b/internal/api/handlers_users.go index e086891..41030fc 100644 --- a/internal/api/handlers_users.go +++ b/internal/api/handlers_users.go @@ -437,7 +437,15 @@ func (a *API) handleLinkAccount(w http.ResponseWriter, r *http.Request) { return } if body.AuthSource == "" { - body.AuthSource = "mojang" + // Same version-nibble inference as the mint path (handlers_account.go): + // defaulting to mojang here would leave a force-linked thirdparty UUID + // outside the reclaim guard. + body.AuthSource = deriveAuthSource(body.MCUUID) + } + if !validAuthSource(body.AuthSource) { + writeError(w, r, newError(http.StatusBadRequest, "bad_request", + "auth_source must be %q or %q", authSourceMojang, authSourceThirdParty)) + return } if err := a.Repo.LinkAccount(r.Context(), userID, body.MCUUID, body.AuthSource); err != nil { diff --git a/internal/api/pgrepo.go b/internal/api/pgrepo.go index fb7dccd..e9eb29d 100644 --- a/internal/api/pgrepo.go +++ b/internal/api/pgrepo.go @@ -818,10 +818,11 @@ func (p *PGRepo) IsUsernameBlacklisted(ctx context.Context, mcUUID string) (bool // who authenticates through the third-party Yggdrasil — the admin-on-Yggdrasil reclaim // exception (spec §B3). The EXISTS joins account_links to users on exactly three // conjuncts: the UUID is linked, that link authenticated via 'thirdparty', and the -// linked user is an admin. It intentionally does not test password_hash: an Operator -// who signs in via SSO (Cloudflare Access, §14) carries role='admin' with a NULL hash -// and must be protected just the same — the hash is orthogonal to "is staff" and "logs -// in via the Login Server". Keyed by UUID, the only identity velocity holds. +// linked user is an admin. It intentionally does not test HOW the account signs in: +// an Operator may authenticate via SSO (Cloudflare Access, §14) or any local +// passwordless door and must be protected just the same — the sign-in method is +// orthogonal to "is staff" and "logs in via the Login Server". Keyed by UUID, the +// only identity velocity holds. func (p *PGRepo) IsProtectedAdminLink(ctx context.Context, mcUUID string) (bool, error) { var ok bool err := p.db.QueryRowContext(ctx, diff --git a/internal/api/repo.go b/internal/api/repo.go index f46443c..c70e3db 100644 --- a/internal/api/repo.go +++ b/internal/api/repo.go @@ -18,13 +18,18 @@ type ServerRecord struct { } // MyServerView is a row of GET /api/v1/me/servers: a server the caller owns, -// may auto-start, or may claim. +// may auto-start, or may claim. PlayersOnline/PlayersMax are NOT stored in +// Postgres — handleMyServers joins them best-effort from the CRD status +// (Cluster.ListServers) at read time, so a cluster hiccup renders 0/0, never +// a 500. type MyServerView struct { - Name string `json:"name"` - Subdomain string `json:"subdomain"` - Owned bool `json:"owned"` - Claimable bool `json:"claimable"` - Phase string `json:"phase,omitempty"` + Name string `json:"name"` + Subdomain string `json:"subdomain"` + Owned bool `json:"owned"` + Claimable bool `json:"claimable"` + Phase string `json:"phase,omitempty"` + PlayersOnline int32 `json:"playersOnline"` + PlayersMax int32 `json:"playersMax"` } // AuditEntry is one row written to audit_logs (spec §6). The actor is the Access @@ -197,8 +202,8 @@ type Repo interface { // // - code missing/expired → ErrLinkCodeInvalid (does not consume it); // - the uuid is not yet linked → create a role='user' player row with id - // newUserID (NULL password_hash, username derived from the uuid so it is unique - // and deterministic), write the account_links binding, consume the code, and + // newUserID (username derived from the uuid so it is unique and + // deterministic), write the account_links binding, consume the code, and // return newUserID; // - the uuid is already linked to a role='user' player → return THAT user // (idempotent "log in via the game"), consuming the code; @@ -456,9 +461,9 @@ type Repo interface { // Mojang-priority reclaim must never bar them. The predicate is exactly three // conjuncts: the UUID is linked (account_links), that link authenticated via // 'thirdparty' (auth_source), and the linked user is an admin (role='admin'). - // It deliberately does NOT require a local password hash: an Operator who signs - // in through SSO (Cloudflare Access, IdP-agnostic per §14) carries role='admin' - // with no password_hash, and must be protected all the same — a password hash is + // It deliberately does NOT ask HOW the staff account signs in: an Operator may + // authenticate via SSO (Cloudflare Access, IdP-agnostic per §14) or any local + // passwordless door, and must be protected all the same — the sign-in method is // orthogonal to both "is staff" and "logs in via the Login Server". An unlinked // UUID, a Mojang-sourced link, or a non-admin link all yield false, so the // exception never broadens to ordinary thirdparty players (Mojang priority still diff --git a/internal/naming/naming.go b/internal/naming/naming.go index 08410ea..7ef4267 100644 --- a/internal/naming/naming.go +++ b/internal/naming/naming.go @@ -23,6 +23,7 @@ var reserved = map[string]struct{}{ "lobby": {}, "admin": {}, "panel": {}, + "console": {}, // the player web console (console.); op.console carries a dot and can never collide "api": {}, "felis": {}, "velocity": {}, @@ -112,12 +113,6 @@ func ValidateSystemServerName(name string) error { return nil } -// IsReserved reports whether label is on the reserved list. -func IsReserved(label string) bool { - _, ok := reserved[label] - return ok -} - // worldVolumeName mirrors operator.dataVolumeName: the per-server StatefulSet's // volumeClaimTemplate is named "world", so a single-replica server's world PVC // is "world--0". This is the one naming convention shared by the operator diff --git a/internal/naming/naming_test.go b/internal/naming/naming_test.go index 6adc0c0..cdcb7c2 100644 --- a/internal/naming/naming_test.go +++ b/internal/naming/naming_test.go @@ -26,6 +26,7 @@ func TestValidateServerName(t *testing.T) { {"lobby", false}, // reserved {"admin", false}, // reserved {"api", false}, // reserved + {"console", false}, // reserved web console host } for _, c := range cases { err := naming.ValidateServerName(c.name) diff --git a/internal/panel/static/index.html b/internal/panel/static/index.html index bfb31b6..f42230b 100644 --- a/internal/panel/static/index.html +++ b/internal/panel/static/index.html @@ -3,7 +3,7 @@ - Felis Control Panel + Felis Console
Felis panel assets were not built into this binary.
diff --git a/internal/store/migrations/0016_op_login.sql b/internal/store/migrations/0016_op_login.sql new file mode 100644 index 0000000..ac3d127 --- /dev/null +++ b/internal/store/migrations/0016_op_login.sql @@ -0,0 +1,28 @@ +-- op.console staff sign-in (spec §B op-login): a staff account signs in at +-- op.console with an email-OTP (minted under purpose 'op_login', stored in +-- player_email_otp) PLUS an in-game admin vouching for the attempt via +-- /felis web op approve . A row here is the vouch half of that +-- pair: it exists from the moment the OTP checks out until the approved +-- request is exchanged for a session (consumed_at) or expires. +-- +-- Lifecycle (derived, no state column): pending while approved_at IS NULL, +-- approved once ApproveOpLogin stamps approved_at/approved_by, dead once +-- consumed_at is set or expires_at passes. Both the approve and the consume +-- UPDATE re-check the full liveness predicate, so a double approval or a +-- replayed finish is a no-op. +CREATE TABLE op_login_requests ( + id text PRIMARY KEY, -- opaque handle shown to the staff member and typed in-game + user_id text NOT NULL REFERENCES users(id), -- the staff account signing in + email text NOT NULL, -- snapshot for the audit trail (users.email may change later) + expires_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + consumed_at timestamptz, -- set exactly once by the finish path + approved_at timestamptz, -- set by the in-game admin's approval + approved_by text REFERENCES users(id) -- the approving admin's web account +); + +-- ListPendingOpLogins serves the in-game admin's approval prompt: live rows +-- only (pending, unconsumed, unexpired), oldest first. +CREATE INDEX idx_op_login_requests_pending + ON op_login_requests (created_at) + WHERE consumed_at IS NULL AND approved_at IS NULL; diff --git a/internal/store/migrations/0017_drop_password.sql b/internal/store/migrations/0017_drop_password.sql new file mode 100644 index 0000000..0f0adc6 --- /dev/null +++ b/internal/store/migrations/0017_drop_password.sql @@ -0,0 +1,11 @@ +-- Global passwordless: retire the 0003 password columns. +-- The product no longer has a password anywhere — web sessions are minted only +-- by the passwordless doors (passkey, email-OTP, bind code, op-login vouch) and +-- `felis breakGlass` hands the Owner a one-time setup URL instead of a +-- credential. No code path reads or writes these columns any more, so keeping +-- them would preserve stale bcrypt material for an auth model that cannot use +-- it. Dropping the hashes is deliberate and irreversible: it guarantees no +-- legacy password can ever authenticate again. +ALTER TABLE users + DROP COLUMN password_hash, + DROP COLUMN must_change_password; diff --git a/panel/dev/mockApi.ts b/panel/dev/mockApi.ts index e7b309e..aeac13e 100644 --- a/panel/dev/mockApi.ts +++ b/panel/dev/mockApi.ts @@ -7,7 +7,6 @@ import type { CreateServerRequest, FleetServer, Identity, - LoginResult, Phase, ServerInfo, WhitelistImage, @@ -43,7 +42,6 @@ interface MockAccount { role: Role; email: string; linked: boolean; - mustChangePassword: boolean; emailVerified: boolean; disabled?: boolean; created_at?: string; @@ -104,8 +102,8 @@ interface SessionContext extends RequestContext { const SESSION_COOKIE = "felis_mock_session"; const ROOT_DOMAIN = "dev.felis.localhost"; const API_BASE = "/api/v1"; -const MOCK_PASSWORD = "devpassword"; const MOCK_LINK_CODE = "LINK1234"; +const MOCK_OTP_CODE = "123456"; const MC_UUID = "00000000-0000-4000-8000-000000000001"; const RESET_ROUTE = `${API_BASE}/__mock/reset`; @@ -138,7 +136,7 @@ const LOGIN_HINT_STYLE = ` const LOGIN_HINT_SCRIPT = ` (() => { const id = "felis-mock-login-hint"; - const html = ''; + const html = ''; const sync = () => { const existing = document.getElementById(id); if (location.pathname === "/login") { @@ -198,10 +196,9 @@ function mockBackups(): BackupView[] { function initialState(): MockState { return { accounts: { - owner: account("owner", "owner", true, false, false), - user: account("user", "user", false, false, false), - linked: account("linked", "user", true, false, true), - setup: account("setup", "admin", true, true, false), + owner: account("owner", "owner", true, false), + user: account("user", "user", false, false), + linked: account("linked", "user", true, true), }, images: [ { image_ref: "registry.felis.svc:5000/paper-1.21:demo", enabled: true, source: "demo" }, @@ -214,29 +211,29 @@ function initialState(): MockState { ], servers: [ server("survival", "Survival SMP", "Running", "owner", { - players: 12, - maxPlayers: 20, + playersOnline: 12, + playersMax: 20, autostartPolicy: "public", }), server("lobby", "Hub Lobby", "Running", "linked", { - players: 28, - maxPlayers: 60, + playersOnline: 28, + playersMax: 60, autostartPolicy: "public", }), server("creative", "Creative Lab", "Stopped", "user", { autostartPolicy: "public", - maxPlayers: 16, + playersMax: 16, }), server("modded", "Modded Testbed", "Starting", "owner", { autostartPolicy: "allowlist", - maxPlayers: 12, + playersMax: 12, }), server("broken", "Broken Node", "Failed", "user", { autostartPolicy: "ownerOnly", - maxPlayers: 8, + playersMax: 8, }), server("claim-me", "Claimable Node", "Stopped", null, { - maxPlayers: 10, + playersMax: 10, }), ...generatedServers(), ], @@ -259,7 +256,7 @@ function initialState(): MockState { "dupe_glitcher", "griefKing", "nukebot", "AFK_farmer", "chat_spammer", "xray_cheater", "fly_hacker", ], - // 12 online, matching the server's players:12 — past the search threshold (>8) + // 12 online, matching the server's playersOnline:12 — past the search threshold (>8) // and a page (>10) so the roster's filter + paging are both exercisable, with a // few non-whitelisted names to try kick / ban on. online: [ @@ -398,8 +395,8 @@ function generatedServers(): MockServer[] { const max = 10 + ((i * 7) % 50); out.push( server(`${theme}-${String(n).padStart(2, "0")}`, `${theme} #${n}`, phase, owners[i % owners.length], { - players: phase === "Running" ? 1 + ((i * 3) % max) : 0, - maxPlayers: max, + playersOnline: phase === "Running" ? 1 + ((i * 3) % max) : 0, + playersMax: max, autostartPolicy: policies[i % policies.length], }), ); @@ -416,13 +413,10 @@ function mockStartupMessage(): string { ` API base: ${API_BASE}`, ` Root domain: ${ROOT_DOMAIN}`, "", - " Accounts:", - ` owner / ${MOCK_PASSWORD} admin, linked`, - ` user / ${MOCK_PASSWORD} user, not linked`, - ` linked / ${MOCK_PASSWORD} user, linked`, - ` setup / ${MOCK_PASSWORD} admin, first-login password change`, - "", - ` Link code: ${MOCK_LINK_CODE}`, + " Sign-in (passwordless):", + ` Email OTP: any email / code ${MOCK_OTP_CODE} → owner (admin, linked)`, + ` Link code: ${MOCK_LINK_CODE} → linked (user, linked)`, + " Passkey: any assertion accepted → owner (admin, linked)", ` Reset state: curl -X POST http://127.0.0.1:5173${RESET_ROUTE}`, "", ].join("\n"); @@ -432,14 +426,12 @@ function account( id: AccountID, role: Role, linked: boolean, - mustChangePassword: boolean, emailVerified: boolean, ): MockAccount { return { id, role, linked, - mustChangePassword, emailVerified, email: `${id}@mock.felis.local`, }; @@ -458,8 +450,8 @@ function server( displayName, phase, desiredState: phase === "Stopped" ? "Stopped" : "Running", - players: phase === "Running" ? 1 : 0, - maxPlayers: 20, + playersOnline: phase === "Running" ? 1 : 0, + playersMax: 20, autostartPolicy: "ownerOnly", owned: false, claimable: false, @@ -515,15 +507,6 @@ function clearSessionCookie(res: ServerResponse): void { res.setHeader("Set-Cookie", `${SESSION_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax`); } -function loginAccount(username: string, state: MockState): string | null { - const normalized = username.toLowerCase(); - const acc = state.accounts[normalized]; - if (acc && !acc.disabled) { - return normalized; - } - return null; -} - function identity(accountInfo: MockAccount): Identity { return { user_id: `mock-${accountInfo.id}`, @@ -531,7 +514,6 @@ function identity(accountInfo: MockAccount): Identity { role: accountInfo.role, is_admin: isAdmin(accountInfo.role), is_owner: isOwner(accountInfo.role), - must_change_password: accountInfo.mustChangePassword, email_verified: accountInfo.emailVerified, }; } @@ -556,8 +538,8 @@ function visibleServers(state: MockState, accountInfo: MockAccount): ServerInfo[ // fleetView projects the internal mock servers into the GET /fleet wire shape // (the SysAdmin cockpit's read). It is the mock mirror of the Go fleetServerView: -// the CRD field names (playersOnline/playersMax, ready, endpoint*) — NOT the -// me/servers projection's players/maxPlayers — plus the owner joined as the email +// the CRD field names (playersOnline/playersMax, ready, endpoint*) plus the +// runtime `ready`/`endpoint*` fields, and the owner joined as the email // (COALESCE(email, username) server-side). Endpoint and live player counts are // gated on Running, exactly as the real cluster reports them. function fleetView(state: MockState): FleetServer[] { @@ -572,8 +554,8 @@ function fleetView(state: MockState): FleetServer[] { autostartPolicy: s.autostartPolicy, endpointMode: "domain", endpointAddress: ready ? `10.43.0.${10 + i}:25565` : undefined, - playersOnline: ready ? s.players ?? 0 : 0, - playersMax: s.maxPlayers ?? 0, + playersOnline: ready ? s.playersOnline ?? 0 : 0, + playersMax: s.playersMax ?? 0, owner: s.owner ? state.accounts[s.owner].email : "", }; }); @@ -592,7 +574,7 @@ function projectServer(serverInfo: MockServer, accountInfo: MockAccount): Server function setPhase(serverInfo: MockServer, phase: Phase): void { serverInfo.phase = phase; serverInfo.desiredState = phase === "Stopped" ? "Stopped" : "Running"; - serverInfo.players = phase === "Running" ? Math.max(serverInfo.players ?? 0, 1) : 0; + serverInfo.playersOnline = phase === "Running" ? Math.max(serverInfo.playersOnline ?? 0, 1) : 0; } function policy(value: unknown): AutostartPolicy { @@ -615,8 +597,8 @@ function createServer( const created = server(name, req.displayName?.trim() || name, "Stopped", owner, { subdomain, - players: 0, - maxPlayers: 20, + playersOnline: 0, + playersMax: 20, autostartPolicy: policy(req.autostartPolicy), }); state.servers.unshift(created); @@ -630,23 +612,6 @@ function sendCreateError(res: ServerResponse, code: CreateError): void { async function handlePublic(ctx: RequestContext): Promise { switch (route(ctx)) { - case "POST auth/login": { - const body = await readJSON<{ username?: string; password?: string }>(ctx.req); - const accountID = body.username ? loginAccount(body.username.trim(), ctx.state) : null; - if (!accountID || body.password !== MOCK_PASSWORD) { - sendError(ctx.res, 403, "invalid_credentials", "invalid mock credentials"); - return true; - } - const accountInfo = ctx.state.accounts[accountID]; - setSessionCookie(ctx.res, accountID); - const out: LoginResult = { - user_id: `mock-${accountInfo.id}`, - role: accountInfo.role, - must_change_password: accountInfo.mustChangePassword, - }; - sendJSON(ctx.res, 200, out); - return true; - } case "POST auth/bind": { const body = await readJSON<{ code?: string }>(ctx.req); const code = body.code?.trim().toUpperCase(); @@ -734,7 +699,7 @@ async function handlePublic(ctx: RequestContext): Promise { } case "POST auth/email/verify": { const body = await readJSON<{ email?: string; code?: string }>(ctx.req); - if (!body.email || body.code !== "123456") { + if (!body.email || body.code !== MOCK_OTP_CODE) { sendError(ctx.res, 400, "invalid_code", "email code is invalid or expired"); return true; } @@ -805,10 +770,6 @@ async function handleSession(ctx: SessionContext): Promise { } sendJSON(ctx.res, 200, { servers: fleetView(ctx.state) }); return true; - case "POST auth/change-password": - ctx.account.mustChangePassword = false; - sendJSON(ctx.res, 200, { ok: true }); - return true; case "GET backups": // Admin sees every archive; a user only worlds they formerly owned — mirrors // AllBackups vs BackupsForUser. The panel filters by server_name client-side. @@ -838,7 +799,7 @@ async function handleSession(ctx: SessionContext): Promise { } case "POST account/email/verify": { const body = await readJSON<{ code?: string }>(ctx.req); - if (body.code?.trim() !== "123456") { + if (body.code?.trim() !== MOCK_OTP_CODE) { sendError(ctx.res, 400, "invalid_code", "email code is invalid or expired"); return true; } @@ -931,7 +892,6 @@ async function handleUserRoute(ctx: SessionContext): Promise { disabled: !!acc.disabled, email_verified: acc.emailVerified, server_count: serverCount, - must_change_password: acc.mustChangePassword, created_at: acc.created_at || new Date().toISOString(), updated_at: acc.updated_at || new Date().toISOString(), } as UserView; @@ -971,7 +931,6 @@ async function handleUserRoute(ctx: SessionContext): Promise { role: body.role || "user", email: body.email || `${username}@example.com`, linked: false, - mustChangePassword: body.must_change_password ?? false, emailVerified: true, disabled: false, created_at: new Date().toISOString(), @@ -996,7 +955,6 @@ async function handleUserRoute(ctx: SessionContext): Promise { disabled: false, email_verified: true, server_count: 0, - must_change_password: newAcc.mustChangePassword, created_at: newAcc.created_at, updated_at: newAcc.updated_at, } as UserView); @@ -1033,7 +991,6 @@ async function handleUserRoute(ctx: SessionContext): Promise { disabled: !!acc.disabled, email_verified: acc.emailVerified, server_count: serverCount, - must_change_password: acc.mustChangePassword, created_at: acc.created_at || new Date().toISOString(), updated_at: acc.updated_at || new Date().toISOString(), linked_accounts, @@ -1069,7 +1026,6 @@ async function handleUserRoute(ctx: SessionContext): Promise { disabled: !!acc.disabled, email_verified: acc.emailVerified, server_count: serverCount, - must_change_password: acc.mustChangePassword, created_at: acc.created_at || new Date().toISOString(), updated_at: acc.updated_at, } as UserView); @@ -1119,14 +1075,6 @@ async function handleUserRoute(ctx: SessionContext): Promise { return true; } - // POST /api/v1/users/{id}/reset-password - if (is("POST", ctx) && subAction === "reset-password") { - acc.mustChangePassword = true; - acc.updated_at = new Date().toISOString(); - sendJSON(ctx.res, 200, { ok: true, email: acc.email || "" }); - return true; - } - // GET /api/v1/users/{id}/quotas if (is("GET", ctx) && subAction === "quotas") { if (!acc.quota) { @@ -1791,7 +1739,7 @@ function handleAccessMock(ctx: SessionContext, serverInfo: MockServer): boolean return true; } if (is("GET", ctx) && sub === "players") { - const max = serverInfo.maxPlayers ?? 0; + const max = serverInfo.playersMax ?? 0; sendJSON(ctx.res, 200, { name: serverInfo.name, online: access.online.length, diff --git a/panel/index.html b/panel/index.html index ef82329..1cee448 100644 --- a/panel/index.html +++ b/panel/index.html @@ -3,7 +3,7 @@ - Felis · Control Panel + Felis · Console