diff --git a/cmd/felis/breakglass.go b/cmd/felis/breakglass.go index ce7c869..194f959 100644 --- a/cmd/felis/breakglass.go +++ b/cmd/felis/breakglass.go @@ -388,14 +388,17 @@ func newSetupToken() (raw, hash string, err error) { // binds their Minecraft account via a one-time link code the login gate handed // them in-game, the bound user is promoted to role='admin' (passwordless Owner), // local auth is enabled, and a one-time setup URL is minted for the first web -// login where the Owner verifies email / enrolls a passkey. adminHostname is the -// op.console host the URL points at; osUser is recorded as the accountable actor. +// login where the Owner verifies email / enrolls a passkey. panelHostname is the +// panel host the URL points at: the wizard enrolls the passkey, and the only wired +// WebAuthn verifier (cmd/felis/api.go) is scoped to the panel host, so the +// ceremony's origin MUST be the panel face — op.console has no verifier wired and +// cannot enroll at all. osUser is recorded as the accountable actor. // // Local auth is as load-bearing here as it is in break-glass, and for a sharper // reason: an MC-bound Owner has no password AND no email, so the setup token is // their ONLY door. CompleteOwnerSetup therefore commits the identity bind, auth // toggle, and token together; any failed write leaves the link code retryable. -func performSetupMCBind(ctx context.Context, s ownerStore, code, adminHostname, osUser string) (breakGlassOutcome, error) { +func performSetupMCBind(ctx context.Context, s ownerStore, code, panelHostname, osUser string) (breakGlassOutcome, error) { code = strings.TrimSpace(strings.ToUpper(code)) if code == "" { return breakGlassOutcome{}, errors.New("link code is required") @@ -420,9 +423,9 @@ func performSetupMCBind(ctx context.Context, s ownerStore, code, adminHostname, ownerIdentity: mcUUID, auditErr: auditSetupMCBind(ctx, s, osUser, mcUUID, authSource), } - host := strings.TrimSpace(adminHostname) + host := strings.TrimSpace(panelHostname) if host == "" { - host = "op.console.localhost" + host = "console.localhost" } out.setupTokenURL = "https://" + host + "/setup?token=" + raw return out, nil diff --git a/cmd/felis/breakglass_test.go b/cmd/felis/breakglass_test.go index ddb81f8..f59cefc 100644 --- a/cmd/felis/breakglass_test.go +++ b/cmd/felis/breakglass_test.go @@ -650,7 +650,7 @@ func TestPerformSetupMCBind(t *testing.T) { t.Run("binds the owner and mints a setup URL whose token hash is what is stored", func(t *testing.T) { f := &fakeOwnerStore{redeemUserID: "usr-owner-1", redeemMCUUID: "mc-uuid-1", redeemAuthSource: "mojang"} - out, err := performSetupMCBind(ctx, f, " abc-123 ", "op.console.example.com", "deploybot") + out, err := performSetupMCBind(ctx, f, " abc-123 ", "console.example.com", "deploybot") if err != nil { t.Fatalf("performSetupMCBind: %v", err) } @@ -672,7 +672,7 @@ func TestPerformSetupMCBind(t *testing.T) { if out.ownerIdentity != "mc-uuid-1" { t.Errorf("owner identity = %q, want the verified Minecraft UUID", out.ownerIdentity) } - const prefix = "https://op.console.example.com/setup?token=" + const prefix = "https://console.example.com/setup?token=" if !strings.HasPrefix(out.setupTokenURL, prefix) { t.Fatalf("setup URL = %q, want prefix %q", out.setupTokenURL, prefix) } @@ -711,7 +711,7 @@ func TestPerformSetupMCBind(t *testing.T) { t.Run("an empty link code mints nothing", func(t *testing.T) { f := &fakeOwnerStore{} - if _, err := performSetupMCBind(ctx, f, " ", "op.console.example.com", "root"); err == nil { + if _, err := performSetupMCBind(ctx, f, " ", "console.example.com", "root"); err == nil { t.Fatal("want error for an empty link code") } if len(f.redeems) != 0 || len(f.tokens) != 0 { @@ -724,7 +724,7 @@ func TestPerformSetupMCBind(t *testing.T) { t.Run("a link-code redemption failure mints no token", func(t *testing.T) { f := &fakeOwnerStore{redeemErr: errors.New("code expired")} - if _, err := performSetupMCBind(ctx, f, "abc-123", "op.console.example.com", "root"); err == nil { + if _, err := performSetupMCBind(ctx, f, "abc-123", "console.example.com", "root"); err == nil { t.Fatal("want error when the link code cannot be redeemed") } if len(f.tokens) != 0 { @@ -737,7 +737,7 @@ func TestPerformSetupMCBind(t *testing.T) { t.Run("a local-auth failure fails the bind rather than minting an unredeemable URL", func(t *testing.T) { f := &fakeOwnerStore{redeemUserID: "usr-owner-1", setErr: errors.New("db down")} - if _, err := performSetupMCBind(ctx, f, "abc-123", "op.console.example.com", "root"); err == nil { + if _, err := performSetupMCBind(ctx, f, "abc-123", "console.example.com", "root"); err == nil { t.Fatal("want error when local auth cannot be enabled") } if len(f.redeems) != 0 || len(f.tokens) != 0 { @@ -747,7 +747,7 @@ func TestPerformSetupMCBind(t *testing.T) { t.Run("a token-store failure rolls the bind back", func(t *testing.T) { f := &fakeOwnerStore{redeemUserID: "usr-owner-1", createTokenErr: errors.New("db down")} - if _, err := performSetupMCBind(ctx, f, "abc-123", "op.console.example.com", "root"); err == nil { + if _, err := performSetupMCBind(ctx, f, "abc-123", "console.example.com", "root"); err == nil { t.Fatal("want error when the setup token cannot be stored") } if len(f.redeems) != 0 { @@ -763,7 +763,7 @@ func TestPerformSetupMCBind(t *testing.T) { t.Run("an audit failure does not cost the operator their install", func(t *testing.T) { f := &fakeOwnerStore{redeemUserID: "usr-owner-1", auditErr: errors.New("audit sink down")} - out, err := performSetupMCBind(ctx, f, "abc-123", "op.console.example.com", "root") + out, err := performSetupMCBind(ctx, f, "abc-123", "console.example.com", "root") if err != nil { t.Fatalf("an audit failure must not fail the bind: %v", err) } @@ -778,14 +778,14 @@ func TestPerformSetupMCBind(t *testing.T) { } }) - t.Run("defaults the op.console host when adminHostname is empty", func(t *testing.T) { + t.Run("defaults the console host when panelHostname is empty", func(t *testing.T) { f := &fakeOwnerStore{redeemUserID: "usr-owner-1"} out, err := performSetupMCBind(ctx, f, "abc-123", " ", "root") if err != nil { t.Fatalf("performSetupMCBind: %v", err) } - if !strings.HasPrefix(out.setupTokenURL, "https://op.console.localhost/setup?token=") { - t.Errorf("setup URL = %q, want the op.console.localhost default host", out.setupTokenURL) + if !strings.HasPrefix(out.setupTokenURL, "https://console.localhost/setup?token=") { + t.Errorf("setup URL = %q, want the console.localhost default host", out.setupTokenURL) } }) } diff --git a/cmd/felis/setup.go b/cmd/felis/setup.go index 4a31d71..fe71602 100644 --- a/cmd/felis/setup.go +++ b/cmd/felis/setup.go @@ -239,7 +239,7 @@ func provisionSystemServers(ctx context.Context, cfg *config.Config, out io.Writ ensureSecretReplica(ctx, cl, controlNS, cfg.K8s.Namespace, naming.ForwardingSecretName, naming.ForwardingSecretKey, "forwarding-secret"), } - outcomes := ensureSystemServers(ctx, cl, cfg.K8s.Namespace, cfg.Velocity.LoginImage, cfg.Velocity.LobbyImage, apiBaseURL, cfg.Server.RootDomain) + outcomes := ensureSystemServers(ctx, cl, cfg.K8s.Namespace, cfg.Velocity.LoginImage, cfg.Velocity.LobbyImage, apiBaseURL, cfg.Server.RootDomain, defaultPanelHostname(cfg.Server.RootDomain, cfg.Auth.PanelHostname)) outcomes = append(secretOutcomes, outcomes...) fmt.Fprintln(out, "\nfelis setup: login/lobby system servers (always-on, reaper-exempt):") for _, o := range outcomes { diff --git a/cmd/felis/systemservers.go b/cmd/felis/systemservers.go index f39d13f..276e63e 100644 --- a/cmd/felis/systemservers.go +++ b/cmd/felis/systemservers.go @@ -63,16 +63,17 @@ const felisLimboHealthPort int32 = 8080 // The felis-limbo login plugin reads its deployment configuration from these // environment variables (env wins over its felis-link.properties template). The -// non-secret three are baked into the login pod's Spec.Env here at provision time +// non-secret four are baked into the login pod's Spec.Env here at provision time // (they derive from the deployment: the internal API URL, the root domain, the -// lobby server name); the service-token secret is injected separately by the -// operator via secretKeyRef. Without the token the plugin fail-safes to -// readiness-only, so a login pod that has the URL/domain but not yet the token is -// safe (it simply does not authenticate) rather than broken. +// resolved panel host, the lobby server name); the service-token secret is injected +// separately by the operator via secretKeyRef. Without the token the plugin +// fail-safes to readiness-only, so a login pod that has the URL/domain but not yet +// the token is safe (it simply does not authenticate) rather than broken. const ( - envAPIBaseURL = "FELIS_API_BASE_URL" - envRootDomain = "FELIS_ROOT_DOMAIN" - envLobbyServer = "FELIS_LOBBY_SERVER" + envAPIBaseURL = "FELIS_API_BASE_URL" + envRootDomain = "FELIS_ROOT_DOMAIN" + envPanelHostname = "FELIS_PANEL_HOSTNAME" + envLobbyServer = "FELIS_LOBBY_SERVER" ) // buildSystemServer constructs an always-on, reaper-exempt MinecraftServer from @@ -153,11 +154,14 @@ func buildSystemServer(in systemServerSpec, namespace string) (*v1alpha1.Minecra // only safe fallback, so it carries no fallback of its own: if it is down the // proxy refuses the connection rather than routing onward past authentication. // -// apiBaseURL is the felis-api internal face the login plugin authenticates to and -// rootDomain builds the console URL the plugin links players at; both are baked in -// as plain env. The service token is NOT passed here — the operator injects it via -// secretKeyRef so the credential never lands in the CRD. -func loginSystemServer(image, namespace, apiBaseURL, rootDomain string) (*v1alpha1.MinecraftServer, error) { +// apiBaseURL is the felis-api internal face the login plugin authenticates to; +// panelHostname is the resolved console/panel host the plugin links players at (the +// single source of truth for that host — see defaultPanelHostname), and rootDomain +// is kept for the plugin's own console. fallback when the panel env is absent +// (an older operator). All three are baked in as plain env. The service token is NOT +// passed here — the operator injects it via secretKeyRef so the credential never +// lands in the CRD. +func loginSystemServer(image, namespace, apiBaseURL, rootDomain, panelHostname string) (*v1alpha1.MinecraftServer, error) { return buildSystemServer(systemServerSpec{ name: naming.SystemLoginServer, subdomain: naming.SystemLoginServer, @@ -170,6 +174,7 @@ func loginSystemServer(image, namespace, apiBaseURL, rootDomain string) (*v1alph env: []v1alpha1.EnvVar{ {Name: envAPIBaseURL, Value: apiBaseURL}, {Name: envRootDomain, Value: rootDomain}, + {Name: envPanelHostname, Value: panelHostname}, {Name: envLobbyServer, Value: naming.SystemLobbyServer}, }, }, namespace) @@ -233,7 +238,7 @@ type systemServerOutcome struct { // service is created. It never deletes or overwrites. The caller supplies the // K8s client and namespace; this function performs no signal-handler or client // setup of its own. -func ensureSystemServers(ctx context.Context, cl client.Client, namespace, loginImage, lobbyImage, apiBaseURL, rootDomain string) []systemServerOutcome { +func ensureSystemServers(ctx context.Context, cl client.Client, namespace, loginImage, lobbyImage, apiBaseURL, rootDomain, panelHostname string) []systemServerOutcome { type plan struct { name string image string @@ -241,7 +246,7 @@ func ensureSystemServers(ctx context.Context, cl client.Client, namespace, login } plans := []plan{ {name: naming.SystemLoginServer, image: loginImage, build: func(image, ns string) (*v1alpha1.MinecraftServer, error) { - return loginSystemServer(image, ns, apiBaseURL, rootDomain) + return loginSystemServer(image, ns, apiBaseURL, rootDomain, panelHostname) }}, {name: naming.SystemLobbyServer, image: lobbyImage, build: lobbySystemServer}, } diff --git a/cmd/felis/systemservers_test.go b/cmd/felis/systemservers_test.go index 5d44d3c..1c23584 100644 --- a/cmd/felis/systemservers_test.go +++ b/cmd/felis/systemservers_test.go @@ -56,7 +56,7 @@ func TestBuildSystemServerShape(t *testing.T) { // fallback of its own; the lobby falls back to login. Neither may fall back to // the lobby — that would route a player past authentication. func TestSystemServerFallbackPolicy(t *testing.T) { - login, err := loginSystemServer("reg/limbo:1", "minecraft", "http://felis-api.felis.svc.cluster.local:8081", "mc.example.net") + login, err := loginSystemServer("reg/limbo:1", "minecraft", "http://felis-api.felis.svc.cluster.local:8081", "mc.example.net", "console.mc.example.net") if err != nil { t.Fatalf("loginSystemServer: %v", err) } @@ -107,11 +107,12 @@ func TestBuildSystemServerRejectsBadInput(t *testing.T) { } // The login gate needs its deployment config as plain env: the internal API URL, -// the root domain, and the lobby name — but NEVER the service token (that is -// injected by the operator via secretKeyRef, never a literal in the CRD). +// the root domain, the resolved panel host, and the lobby name — but NEVER the +// service token (that is injected by the operator via secretKeyRef, never a literal +// in the CRD). func TestLoginSystemServerEnv(t *testing.T) { login, err := loginSystemServer("reg/limbo:1", "minecraft", - "http://felis-api.felis.svc.cluster.local:8081", "mc.example.net") + "http://felis-api.felis.svc.cluster.local:8081", "mc.example.net", "console.mc.example.net") if err != nil { t.Fatalf("loginSystemServer: %v", err) } @@ -120,9 +121,10 @@ func TestLoginSystemServerEnv(t *testing.T) { got[e.Name] = e.Value } want := map[string]string{ - "FELIS_API_BASE_URL": "http://felis-api.felis.svc.cluster.local:8081", - "FELIS_ROOT_DOMAIN": "mc.example.net", - "FELIS_LOBBY_SERVER": naming.SystemLobbyServer, + "FELIS_API_BASE_URL": "http://felis-api.felis.svc.cluster.local:8081", + "FELIS_ROOT_DOMAIN": "mc.example.net", + "FELIS_PANEL_HOSTNAME": "console.mc.example.net", + "FELIS_LOBBY_SERVER": naming.SystemLobbyServer, } for k, v := range want { if got[k] != v { @@ -352,7 +354,7 @@ func TestEnsureSystemServersIdempotent(t *testing.T) { cl := fake.NewClientBuilder().WithScheme(scheme).Build() ctx := context.Background() - first := ensureSystemServers(ctx, cl, "minecraft", "reg/limbo:1", "reg/lobby:1", "http://felis-api.felis.svc.cluster.local:8081", "mc.example.net") + first := ensureSystemServers(ctx, cl, "minecraft", "reg/limbo:1", "reg/lobby:1", "http://felis-api.felis.svc.cluster.local:8081", "mc.example.net", "console.mc.example.net") if len(first) != 2 { t.Fatalf("first run outcomes = %d, want 2", len(first)) } @@ -378,7 +380,7 @@ func TestEnsureSystemServersIdempotent(t *testing.T) { } // Re-run: both already exist → skipped, nothing created, no error. - second := ensureSystemServers(ctx, cl, "minecraft", "reg/limbo:1", "reg/lobby:1", "http://felis-api.felis.svc.cluster.local:8081", "mc.example.net") + second := ensureSystemServers(ctx, cl, "minecraft", "reg/limbo:1", "reg/lobby:1", "http://felis-api.felis.svc.cluster.local:8081", "mc.example.net", "console.mc.example.net") for _, o := range second { if o.err != nil { t.Fatalf("%s: unexpected error on re-run: %v", o.name, o.err) @@ -405,7 +407,7 @@ func TestEnsureSystemServersRejectsLegacyLoginNameCollision(t *testing.T) { out := ensureSystemServers( context.Background(), cl, "minecraft", "reg/limbo:1", "", - "http://felis-api.felis.svc.cluster.local:8081", "mc.example.net", + "http://felis-api.felis.svc.cluster.local:8081", "mc.example.net", "console.mc.example.net", ) if len(out) != 2 { t.Fatalf("outcomes = %d, want 2", len(out)) @@ -425,7 +427,7 @@ func TestEnsureSystemServersSkipsUnsetImage(t *testing.T) { ctx := context.Background() // login image set, lobby image empty → login created, lobby skipped. - out := ensureSystemServers(ctx, cl, "minecraft", "reg/limbo:1", "", "http://felis-api.felis.svc.cluster.local:8081", "mc.example.net") + out := ensureSystemServers(ctx, cl, "minecraft", "reg/limbo:1", "", "http://felis-api.felis.svc.cluster.local:8081", "mc.example.net", "console.mc.example.net") byName := map[string]systemServerOutcome{} for _, o := range out { byName[o.name] = o diff --git a/cmd/felis/tui_mc_bind.go b/cmd/felis/tui_mc_bind.go index 07963fe..d2731eb 100644 --- a/cmd/felis/tui_mc_bind.go +++ b/cmd/felis/tui_mc_bind.go @@ -18,7 +18,7 @@ import ( type mcBindModel struct { ctx context.Context store ownerStore - adminHost string + panelHost string osUser string step mcBindStep @@ -47,14 +47,14 @@ type mcBindMsg struct { err error } -func newMCBindModel(ctx context.Context, store ownerStore, adminHost, osUser string) *mcBindModel { +func newMCBindModel(ctx context.Context, store ownerStore, panelHost, osUser string) *mcBindModel { sp := spinner.New() sp.Spinner = spinner.Dot sp.Style = tuiLabel m := &mcBindModel{ ctx: ctx, store: store, - adminHost: adminHost, + panelHost: panelHost, osUser: osUser, sp: sp, step: mcBindForm, @@ -156,7 +156,7 @@ func (m *mcBindModel) onFormComplete() (tea.Model, tea.Cmd) { m.working = "Binding Minecraft account…" code := strings.TrimSpace(strings.ToUpper(m.linkCode)) return m, tea.Batch(m.sp.Tick, func() tea.Msg { - out, err := performSetupMCBind(m.ctx, m.store, code, m.adminHost, m.osUser) + out, err := performSetupMCBind(m.ctx, m.store, code, m.panelHost, m.osUser) return mcBindMsg{outcome: out, err: err} }) } diff --git a/cmd/felis/tui_menu_test.go b/cmd/felis/tui_menu_test.go index a2bbcfd..1337770 100644 --- a/cmd/felis/tui_menu_test.go +++ b/cmd/felis/tui_menu_test.go @@ -185,7 +185,7 @@ func TestOwnerResultCmdCarriesIsOperator(t *testing.T) { } func TestMCBindCarriesAuditWarning(t *testing.T) { - m := newMCBindModel(context.Background(), &fakeOwnerStore{}, "op.console.example.com", "root") + m := newMCBindModel(context.Background(), &fakeOwnerStore{}, "console.example.com", "root") next, _ := m.Update(mcBindMsg{outcome: breakGlassOutcome{ ownerIdentity: "mc-uuid-1", setupTokenURL: "https://op.console.example.com/setup?token=t0ken", diff --git a/cmd/felis/tui_root.go b/cmd/felis/tui_root.go index 478e9d0..29f4b9d 100644 --- a/cmd/felis/tui_root.go +++ b/cmd/felis/tui_root.go @@ -211,7 +211,7 @@ func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.stage = stageOwner if m.mode == consoleModeSetup { - return m.adopt(newMCBindModel(m.ctx, m.store, m.adminHost, m.osUser)) + return m.adopt(newMCBindModel(m.ctx, m.store, defaultPanelHostname(m.rootDomain, m.panelHost), m.osUser)) } return m.adopt(newOwnerModel(m.ctx, m.store, m.osUser, false)) diff --git a/internal/api/handlers_setup.go b/internal/api/handlers_setup.go index 27970c3..64879d2 100644 --- a/internal/api/handlers_setup.go +++ b/internal/api/handlers_setup.go @@ -11,7 +11,7 @@ import ( // Setup-token redemption (spec §B setup bootstrap). The `felis setup` MC-bind // flow mints a one-time token and prints a URL like: // -// https://op.console./setup?token= +// https://console./setup?token= // // The Owner opens that URL in a browser; the SPA reads the token from the query // string and POSTs it here. This handler consumes the token (single-use, hashed diff --git a/plugins/limbo/src/main/java/best/lolicon/felis/limbo/FelisLimboPlugin.java b/plugins/limbo/src/main/java/best/lolicon/felis/limbo/FelisLimboPlugin.java index 0e66324..1b9d7c8 100644 --- a/plugins/limbo/src/main/java/best/lolicon/felis/limbo/FelisLimboPlugin.java +++ b/plugins/limbo/src/main/java/best/lolicon/felis/limbo/FelisLimboPlugin.java @@ -196,14 +196,22 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener { return; } + // The console host the player links at. Prefer the resolved FELIS_PANEL_HOSTNAME + // the provisioner bakes in (single source of truth — it honours a custom + // panel_hostname); fall back to console. only for an older operator whose + // env predates it. With neither set there is no link to build, so login stays off. + String panelHost = trimmed(System.getenv("FELIS_PANEL_HOSTNAME")); String rootDomain = trimmed(System.getenv("FELIS_ROOT_DOMAIN")); - if (rootDomain == null) { - LOG.warning("FelisLimbo: FELIS_ROOT_DOMAIN unset — cannot build the console login link"); + if (panelHost == null && rootDomain != null) { + panelHost = "console." + rootDomain; + } + if (panelHost == null) { + LOG.warning("FelisLimbo: neither FELIS_PANEL_HOSTNAME nor FELIS_ROOT_DOMAIN set — cannot build the console login link"); loginEnabled = false; return; } - this.consoleUrl = "https://console." + rootDomain; + this.consoleUrl = "https://" + panelHost; String lobby = trimmed(System.getenv("FELIS_LOBBY_SERVER")); this.lobbyServer = lobby != null ? lobby : "lobby"; this.timeoutMillis = loginTimeoutSeconds() * 1000L; @@ -229,6 +237,14 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener { disconnectOnMain(id, "该用户名已被回收保护 / This username is under reclaim protection. Contact staff."); return; } + // Check registration before minting: an already-linked player needs no + // bind code, so send them straight to the lobby instead of flashing a + // useless code. Only unlinked players get one. The on-demand /link + // command (proxy + lobby) stays the door to a fresh web session. + if (apiClient.linkStatus(id)) { + getServer().getScheduler().runTask(this, () -> transferToLobby(id)); + return; + } LinkCode code = linkClient.requestCode(id); getServer().getScheduler().runTask(this, () -> presentAndPoll(id, code)); } catch (LinkException e) {