fix(setup): source the console host from the panel hostname, not op.console

The owner setup URL and the limbo login link were built from the admin host
(op.console.<root>, with an op.console.localhost fallback) and a hardcoded
console.<root>, so an operator who set a custom panel_hostname got an unreachable setup
link and a wrong login target. Thread the resolved panel host (defaultPanelHostname)
through performSetupMCBind, the MC-bind TUI, and the login system-server env
(new FELIS_PANEL_HOSTNAME); the limbo plugin prefers it and keeps console.<root> only as
the fallback for an older operator whose env predates it. This also matters for security:
the only wired WebAuthn verifier is scoped to the panel host, so passkey enrollment must
land on the panel face, never op.console.

While here, the limbo login handler checks link status before minting a bind code: an
already-linked player is sent straight to the lobby instead of being shown a useless code.
This commit is contained in:
flyemoji committed 2026-07-16 13:27:02 +09:00
1 parent b5cd4501e5
commit 9ea35304e3
10 files changed
+78 -52

No files matched your search

+8 -5
View File
@@ -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
+10 -10
View File
@@ -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)
}
})
}
+1 -1
View File
@@ -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 {
+20 -15
View File
@@ -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.<root> 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},
}
+13 -11
View File
@@ -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
+4 -4
View File
@@ -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}
})
}
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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))
+1 -1
View File
@@ -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.<root>/setup?token=<raw>
// https://console.<root>/setup?token=<raw>
//
// 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
@@ -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.<root> 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) {