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:
10 files changed
+72
-46
No files matched your search
@@ -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
|
// 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),
|
// 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
|
// 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
|
// login where the Owner verifies email / enrolls a passkey. panelHostname is the
|
||||||
// op.console host the URL points at; osUser is recorded as the accountable actor.
|
// 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
|
// 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
|
// 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
|
// their ONLY door. CompleteOwnerSetup therefore commits the identity bind, auth
|
||||||
// toggle, and token together; any failed write leaves the link code retryable.
|
// 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))
|
code = strings.TrimSpace(strings.ToUpper(code))
|
||||||
if code == "" {
|
if code == "" {
|
||||||
return breakGlassOutcome{}, errors.New("link code is required")
|
return breakGlassOutcome{}, errors.New("link code is required")
|
||||||
@@ -420,9 +423,9 @@ func performSetupMCBind(ctx context.Context, s ownerStore, code, adminHostname,
|
|||||||
ownerIdentity: mcUUID,
|
ownerIdentity: mcUUID,
|
||||||
auditErr: auditSetupMCBind(ctx, s, osUser, mcUUID, authSource),
|
auditErr: auditSetupMCBind(ctx, s, osUser, mcUUID, authSource),
|
||||||
}
|
}
|
||||||
host := strings.TrimSpace(adminHostname)
|
host := strings.TrimSpace(panelHostname)
|
||||||
if host == "" {
|
if host == "" {
|
||||||
host = "op.console.localhost"
|
host = "console.localhost"
|
||||||
}
|
}
|
||||||
out.setupTokenURL = "https://" + host + "/setup?token=" + raw
|
out.setupTokenURL = "https://" + host + "/setup?token=" + raw
|
||||||
return out, nil
|
return out, nil
|
||||||
|
|||||||
@@ -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) {
|
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"}
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("performSetupMCBind: %v", err)
|
t.Fatalf("performSetupMCBind: %v", err)
|
||||||
}
|
}
|
||||||
@@ -672,7 +672,7 @@ func TestPerformSetupMCBind(t *testing.T) {
|
|||||||
if out.ownerIdentity != "mc-uuid-1" {
|
if out.ownerIdentity != "mc-uuid-1" {
|
||||||
t.Errorf("owner identity = %q, want the verified Minecraft UUID", out.ownerIdentity)
|
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) {
|
if !strings.HasPrefix(out.setupTokenURL, prefix) {
|
||||||
t.Fatalf("setup URL = %q, want prefix %q", 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) {
|
t.Run("an empty link code mints nothing", func(t *testing.T) {
|
||||||
f := &fakeOwnerStore{}
|
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")
|
t.Fatal("want error for an empty link code")
|
||||||
}
|
}
|
||||||
if len(f.redeems) != 0 || len(f.tokens) != 0 {
|
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) {
|
t.Run("a link-code redemption failure mints no token", func(t *testing.T) {
|
||||||
f := &fakeOwnerStore{redeemErr: errors.New("code expired")}
|
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")
|
t.Fatal("want error when the link code cannot be redeemed")
|
||||||
}
|
}
|
||||||
if len(f.tokens) != 0 {
|
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) {
|
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")}
|
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")
|
t.Fatal("want error when local auth cannot be enabled")
|
||||||
}
|
}
|
||||||
if len(f.redeems) != 0 || len(f.tokens) != 0 {
|
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) {
|
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")}
|
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")
|
t.Fatal("want error when the setup token cannot be stored")
|
||||||
}
|
}
|
||||||
if len(f.redeems) != 0 {
|
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) {
|
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")}
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("an audit failure must not fail the bind: %v", err)
|
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"}
|
f := &fakeOwnerStore{redeemUserID: "usr-owner-1"}
|
||||||
out, err := performSetupMCBind(ctx, f, "abc-123", " ", "root")
|
out, err := performSetupMCBind(ctx, f, "abc-123", " ", "root")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("performSetupMCBind: %v", err)
|
t.Fatalf("performSetupMCBind: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(out.setupTokenURL, "https://op.console.localhost/setup?token=") {
|
if !strings.HasPrefix(out.setupTokenURL, "https://console.localhost/setup?token=") {
|
||||||
t.Errorf("setup URL = %q, want the op.console.localhost default host", out.setupTokenURL)
|
t.Errorf("setup URL = %q, want the console.localhost default host", out.setupTokenURL)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
+1
-1
@@ -239,7 +239,7 @@ func provisionSystemServers(ctx context.Context, cfg *config.Config, out io.Writ
|
|||||||
ensureSecretReplica(ctx, cl, controlNS, cfg.K8s.Namespace,
|
ensureSecretReplica(ctx, cl, controlNS, cfg.K8s.Namespace,
|
||||||
naming.ForwardingSecretName, naming.ForwardingSecretKey, "forwarding-secret"),
|
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...)
|
outcomes = append(secretOutcomes, outcomes...)
|
||||||
fmt.Fprintln(out, "\nfelis setup: login/lobby system servers (always-on, reaper-exempt):")
|
fmt.Fprintln(out, "\nfelis setup: login/lobby system servers (always-on, reaper-exempt):")
|
||||||
for _, o := range outcomes {
|
for _, o := range outcomes {
|
||||||
|
|||||||
+17
-12
@@ -63,15 +63,16 @@ const felisLimboHealthPort int32 = 8080
|
|||||||
|
|
||||||
// The felis-limbo login plugin reads its deployment configuration from these
|
// The felis-limbo login plugin reads its deployment configuration from these
|
||||||
// environment variables (env wins over its felis-link.properties template). The
|
// 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
|
// (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
|
// resolved panel host, the lobby server name); the service-token secret is injected
|
||||||
// operator via secretKeyRef. Without the token the plugin fail-safes to
|
// separately by the operator via secretKeyRef. Without the token the plugin
|
||||||
// readiness-only, so a login pod that has the URL/domain but not yet the token is
|
// fail-safes to readiness-only, so a login pod that has the URL/domain but not yet
|
||||||
// safe (it simply does not authenticate) rather than broken.
|
// the token is safe (it simply does not authenticate) rather than broken.
|
||||||
const (
|
const (
|
||||||
envAPIBaseURL = "FELIS_API_BASE_URL"
|
envAPIBaseURL = "FELIS_API_BASE_URL"
|
||||||
envRootDomain = "FELIS_ROOT_DOMAIN"
|
envRootDomain = "FELIS_ROOT_DOMAIN"
|
||||||
|
envPanelHostname = "FELIS_PANEL_HOSTNAME"
|
||||||
envLobbyServer = "FELIS_LOBBY_SERVER"
|
envLobbyServer = "FELIS_LOBBY_SERVER"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -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
|
// 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.
|
// proxy refuses the connection rather than routing onward past authentication.
|
||||||
//
|
//
|
||||||
// apiBaseURL is the felis-api internal face the login plugin authenticates to and
|
// apiBaseURL is the felis-api internal face the login plugin authenticates to;
|
||||||
// rootDomain builds the console URL the plugin links players at; both are baked in
|
// panelHostname is the resolved console/panel host the plugin links players at (the
|
||||||
// as plain env. The service token is NOT passed here — the operator injects it via
|
// single source of truth for that host — see defaultPanelHostname), and rootDomain
|
||||||
// secretKeyRef so the credential never lands in the CRD.
|
// is kept for the plugin's own console.<root> fallback when the panel env is absent
|
||||||
func loginSystemServer(image, namespace, apiBaseURL, rootDomain string) (*v1alpha1.MinecraftServer, error) {
|
// (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{
|
return buildSystemServer(systemServerSpec{
|
||||||
name: naming.SystemLoginServer,
|
name: naming.SystemLoginServer,
|
||||||
subdomain: naming.SystemLoginServer,
|
subdomain: naming.SystemLoginServer,
|
||||||
@@ -170,6 +174,7 @@ func loginSystemServer(image, namespace, apiBaseURL, rootDomain string) (*v1alph
|
|||||||
env: []v1alpha1.EnvVar{
|
env: []v1alpha1.EnvVar{
|
||||||
{Name: envAPIBaseURL, Value: apiBaseURL},
|
{Name: envAPIBaseURL, Value: apiBaseURL},
|
||||||
{Name: envRootDomain, Value: rootDomain},
|
{Name: envRootDomain, Value: rootDomain},
|
||||||
|
{Name: envPanelHostname, Value: panelHostname},
|
||||||
{Name: envLobbyServer, Value: naming.SystemLobbyServer},
|
{Name: envLobbyServer, Value: naming.SystemLobbyServer},
|
||||||
},
|
},
|
||||||
}, namespace)
|
}, namespace)
|
||||||
@@ -233,7 +238,7 @@ type systemServerOutcome struct {
|
|||||||
// service is created. It never deletes or overwrites. The caller supplies the
|
// service is created. It never deletes or overwrites. The caller supplies the
|
||||||
// K8s client and namespace; this function performs no signal-handler or client
|
// K8s client and namespace; this function performs no signal-handler or client
|
||||||
// setup of its own.
|
// 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 {
|
type plan struct {
|
||||||
name string
|
name string
|
||||||
image string
|
image string
|
||||||
@@ -241,7 +246,7 @@ func ensureSystemServers(ctx context.Context, cl client.Client, namespace, login
|
|||||||
}
|
}
|
||||||
plans := []plan{
|
plans := []plan{
|
||||||
{name: naming.SystemLoginServer, image: loginImage, build: func(image, ns string) (*v1alpha1.MinecraftServer, error) {
|
{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},
|
{name: naming.SystemLobbyServer, image: lobbyImage, build: lobbySystemServer},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ func TestBuildSystemServerShape(t *testing.T) {
|
|||||||
// fallback of its own; the lobby falls back to login. Neither may fall back to
|
// fallback of its own; the lobby falls back to login. Neither may fall back to
|
||||||
// the lobby — that would route a player past authentication.
|
// the lobby — that would route a player past authentication.
|
||||||
func TestSystemServerFallbackPolicy(t *testing.T) {
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("loginSystemServer: %v", err)
|
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 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
|
// the root domain, the resolved panel host, and the lobby name — but NEVER the
|
||||||
// injected by the operator via secretKeyRef, never a literal in the CRD).
|
// service token (that is injected by the operator via secretKeyRef, never a literal
|
||||||
|
// in the CRD).
|
||||||
func TestLoginSystemServerEnv(t *testing.T) {
|
func TestLoginSystemServerEnv(t *testing.T) {
|
||||||
login, err := loginSystemServer("reg/limbo:1", "minecraft",
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("loginSystemServer: %v", err)
|
t.Fatalf("loginSystemServer: %v", err)
|
||||||
}
|
}
|
||||||
@@ -122,6 +123,7 @@ func TestLoginSystemServerEnv(t *testing.T) {
|
|||||||
want := map[string]string{
|
want := map[string]string{
|
||||||
"FELIS_API_BASE_URL": "http://felis-api.felis.svc.cluster.local:8081",
|
"FELIS_API_BASE_URL": "http://felis-api.felis.svc.cluster.local:8081",
|
||||||
"FELIS_ROOT_DOMAIN": "mc.example.net",
|
"FELIS_ROOT_DOMAIN": "mc.example.net",
|
||||||
|
"FELIS_PANEL_HOSTNAME": "console.mc.example.net",
|
||||||
"FELIS_LOBBY_SERVER": naming.SystemLobbyServer,
|
"FELIS_LOBBY_SERVER": naming.SystemLobbyServer,
|
||||||
}
|
}
|
||||||
for k, v := range want {
|
for k, v := range want {
|
||||||
@@ -352,7 +354,7 @@ func TestEnsureSystemServersIdempotent(t *testing.T) {
|
|||||||
cl := fake.NewClientBuilder().WithScheme(scheme).Build()
|
cl := fake.NewClientBuilder().WithScheme(scheme).Build()
|
||||||
ctx := context.Background()
|
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 {
|
if len(first) != 2 {
|
||||||
t.Fatalf("first run outcomes = %d, want 2", len(first))
|
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.
|
// 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 {
|
for _, o := range second {
|
||||||
if o.err != nil {
|
if o.err != nil {
|
||||||
t.Fatalf("%s: unexpected error on re-run: %v", o.name, o.err)
|
t.Fatalf("%s: unexpected error on re-run: %v", o.name, o.err)
|
||||||
@@ -405,7 +407,7 @@ func TestEnsureSystemServersRejectsLegacyLoginNameCollision(t *testing.T) {
|
|||||||
|
|
||||||
out := ensureSystemServers(
|
out := ensureSystemServers(
|
||||||
context.Background(), cl, "minecraft", "reg/limbo:1", "",
|
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 {
|
if len(out) != 2 {
|
||||||
t.Fatalf("outcomes = %d, want 2", len(out))
|
t.Fatalf("outcomes = %d, want 2", len(out))
|
||||||
@@ -425,7 +427,7 @@ func TestEnsureSystemServersSkipsUnsetImage(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// login image set, lobby image empty → login created, lobby skipped.
|
// 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{}
|
byName := map[string]systemServerOutcome{}
|
||||||
for _, o := range out {
|
for _, o := range out {
|
||||||
byName[o.name] = o
|
byName[o.name] = o
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import (
|
|||||||
type mcBindModel struct {
|
type mcBindModel struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
store ownerStore
|
store ownerStore
|
||||||
adminHost string
|
panelHost string
|
||||||
osUser string
|
osUser string
|
||||||
|
|
||||||
step mcBindStep
|
step mcBindStep
|
||||||
@@ -47,14 +47,14 @@ type mcBindMsg struct {
|
|||||||
err error
|
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.New()
|
||||||
sp.Spinner = spinner.Dot
|
sp.Spinner = spinner.Dot
|
||||||
sp.Style = tuiLabel
|
sp.Style = tuiLabel
|
||||||
m := &mcBindModel{
|
m := &mcBindModel{
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
store: store,
|
store: store,
|
||||||
adminHost: adminHost,
|
panelHost: panelHost,
|
||||||
osUser: osUser,
|
osUser: osUser,
|
||||||
sp: sp,
|
sp: sp,
|
||||||
step: mcBindForm,
|
step: mcBindForm,
|
||||||
@@ -156,7 +156,7 @@ func (m *mcBindModel) onFormComplete() (tea.Model, tea.Cmd) {
|
|||||||
m.working = "Binding Minecraft account…"
|
m.working = "Binding Minecraft account…"
|
||||||
code := strings.TrimSpace(strings.ToUpper(m.linkCode))
|
code := strings.TrimSpace(strings.ToUpper(m.linkCode))
|
||||||
return m, tea.Batch(m.sp.Tick, func() tea.Msg {
|
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}
|
return mcBindMsg{outcome: out, err: err}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ func TestOwnerResultCmdCarriesIsOperator(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestMCBindCarriesAuditWarning(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{
|
next, _ := m.Update(mcBindMsg{outcome: breakGlassOutcome{
|
||||||
ownerIdentity: "mc-uuid-1",
|
ownerIdentity: "mc-uuid-1",
|
||||||
setupTokenURL: "https://op.console.example.com/setup?token=t0ken",
|
setupTokenURL: "https://op.console.example.com/setup?token=t0ken",
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
m.stage = stageOwner
|
m.stage = stageOwner
|
||||||
if m.mode == consoleModeSetup {
|
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))
|
return m.adopt(newOwnerModel(m.ctx, m.store, m.osUser, false))
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
// Setup-token redemption (spec §B setup bootstrap). The `felis setup` MC-bind
|
// Setup-token redemption (spec §B setup bootstrap). The `felis setup` MC-bind
|
||||||
// flow mints a one-time token and prints a URL like:
|
// 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
|
// 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
|
// 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;
|
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"));
|
String rootDomain = trimmed(System.getenv("FELIS_ROOT_DOMAIN"));
|
||||||
if (rootDomain == null) {
|
if (panelHost == null && rootDomain != null) {
|
||||||
LOG.warning("FelisLimbo: FELIS_ROOT_DOMAIN unset — cannot build the console login link");
|
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;
|
loginEnabled = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.consoleUrl = "https://console." + rootDomain;
|
this.consoleUrl = "https://" + panelHost;
|
||||||
String lobby = trimmed(System.getenv("FELIS_LOBBY_SERVER"));
|
String lobby = trimmed(System.getenv("FELIS_LOBBY_SERVER"));
|
||||||
this.lobbyServer = lobby != null ? lobby : "lobby";
|
this.lobbyServer = lobby != null ? lobby : "lobby";
|
||||||
this.timeoutMillis = loginTimeoutSeconds() * 1000L;
|
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.");
|
disconnectOnMain(id, "该用户名已被回收保护 / This username is under reclaim protection. Contact staff.");
|
||||||
return;
|
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);
|
LinkCode code = linkClient.requestCode(id);
|
||||||
getServer().getScheduler().runTask(this, () -> presentAndPoll(id, code));
|
getServer().getScheduler().runTask(this, () -> presentAndPoll(id, code));
|
||||||
} catch (LinkException e) {
|
} catch (LinkException e) {
|
||||||
|
|||||||
Reference in new issue
Block a user