From 20994be996e72f0063367353c55c1c6d0291e342 Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Wed, 7 Oct 2026 17:19:20 +0800 Subject: [PATCH] feat: configure authenticated player entry routes --- docs/openapi.yaml | 71 ++++++++ docs/operations.md | 46 +++++ internal/api/api.go | 3 + internal/api/handlers_entry_policy.go | 137 +++++++++++++++ internal/api/handlers_entry_policy_test.go | 88 ++++++++++ panel/dev/mockApi.ts | 17 ++ panel/e2e/player-entry.smoke.spec.ts | 46 +++++ panel/e2e/startup-platform.smoke.spec.ts | 2 +- panel/src/components/PlayerEntrySettings.tsx | 97 +++++++++++ panel/src/i18n/resources/en-US/admin.json | 39 ++++- panel/src/i18n/resources/zh-CN/admin.json | 39 ++++- panel/src/lib/api.ts | 4 + panel/src/lib/openapi.gen.ts | 142 +++++++++++++++ panel/src/lib/types.ts | 10 ++ .../pages/admin/PlatformSettingsPage.test.tsx | 6 +- .../src/pages/admin/PlatformSettingsPage.tsx | 2 + .../best/lolicon/felis/limbo/LoginFlow.java | 4 + .../lolicon/felis/limbo/LoginFlowTest.java | 18 ++ .../best/lolicon/felis/link/EntryPolicy.java | 29 ++++ .../lolicon/felis/link/FelisApiClient.java | 4 + .../felis/velocity/FelisVelocityPlugin.java | 5 + .../lolicon/felis/velocity/WaitingRouter.java | 163 +++++++++++++++++- .../best/lolicon/felis/velocity/Fakes.java | 7 + .../felis/velocity/WaitingRouterTest.java | 82 ++++++++- 24 files changed, 1044 insertions(+), 17 deletions(-) create mode 100644 internal/api/handlers_entry_policy.go create mode 100644 internal/api/handlers_entry_policy_test.go create mode 100644 panel/e2e/player-entry.smoke.spec.ts create mode 100644 panel/src/components/PlayerEntrySettings.tsx create mode 100644 plugins/shared/src/main/java/best/lolicon/felis/link/EntryPolicy.java diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 5e1e04b..2b897a0 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -341,6 +341,18 @@ components: startedAt: { type: string, format: date-time } logsAvailable: { type: boolean } + EntryPolicySettings: + type: object + required: [mode, defaultServer, requireAccountLink, offlineAction, waitingSpace, fallbackServer, revision] + properties: + mode: { type: string, enum: [lobby, direct, domain] } + defaultServer: { type: string, description: Required for direct mode; optional fallback destination for unmatched hostnames. } + requireAccountLink: { type: boolean, description: Require Limbo web sign-in and panel association in addition to proxy game identity authentication. } + offlineAction: { type: string, enum: [wake, fallback, disconnect] } + waitingSpace: { type: string, enum: [login, lobby], description: Web sign-in currently requires lobby waiting. } + fallbackServer: { type: string, description: Required for fallback action and distinct from the default server. } + revision: { type: string, description: Opaque revision; stale or concurrent writes return 409. } + WakePolicySettings: type: object required: [maxRunningServers, wakeCooldownSeconds, revision, managed] @@ -4454,6 +4466,65 @@ paths: '409': { description: Another node task is running or this task cannot be retried. } '503': { description: Host node execution service is unavailable. } + /api/v1/internal/settings/entry-policy: + get: + tags: [internal] + operationId: internalEntryPolicy + summary: Read player entry policy for the authenticated proxy and login gate. + x-felis-face: [internal] + x-felis-tier: service + x-felis-callers: [velocity, limbo] + security: [{ serviceToken: [] }] + responses: + '200': + description: Current policy; proxy snapshots it for each new connection. + content: + application/json: + schema: { $ref: '#/components/schemas/EntryPolicySettings' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /api/v1/settings/entry-policy: + get: + tags: [account] + operationId: getEntryPolicy + summary: Read player entry policy (Owner). + x-felis-face: [external] + x-felis-tier: owner + security: [{ sessionCookie: [] }] + responses: + '200': + description: Current policy and revision; an unsaved deployment preserves its legacy routing. + content: + application/json: + schema: { $ref: '#/components/schemas/EntryPolicySettings' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + put: + tags: [account] + operationId: setEntryPolicy + summary: Save player entry policy (Owner, fresh reauthentication). + description: Applies to new connections within the proxy's 15-second refresh interval. Does not change online-mode, autostart authorization or existing players, and does not stop system spaces automatically. + x-felis-face: [external] + x-felis-tier: owner + security: [{ sessionCookie: [] }] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/EntryPolicySettings' } + responses: + '200': + description: Saved policy and revision. + content: + application/json: + schema: { $ref: '#/components/schemas/EntryPolicySettings' } + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { description: Selected server does not exist. } + '409': { description: Policy changed; reload before saving. } + /api/v1/settings/wake-policy: get: tags: [account] diff --git a/docs/operations.md b/docs/operations.md index 0fe6c32..7a312c9 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -903,3 +903,49 @@ configuration, or test the profile-query API. Saving requires Owner access on the operator host and recent reauthentication for a local session. A revision conflict preserves the draft; discard it and reload before editing the newer configuration. + +## 8. Player entry policy + +Owner → Platform settings → Player entry policy configures the proxy's destination +independently from game identity authentication and panel account association. + +| Mode | Destination after authentication | +| --- | --- | +| Lobby | Formal lobby, where the player selects a server | +| Direct to main server | Selected main server, regardless of connection hostname | +| Route by hostname | Server matched by hostname; optional default for unmatched hostnames | + +For a single-server deployment, select **Direct to main server**, choose the main +server, and disable **Require panel account linking**. A running main server then +accepts authenticated players without entering the login space or lobby. Game +identity authentication and the global blacklist remain enforced; this setting does +not enable offline-mode or change authentication sources. Players can associate a +panel account separately through `/link`. + +Choose how to handle an offline destination: + +- **Start automatically and wait** uses the existing wake permission, maintenance, + admission limit and cooldown checks. Select a running Limbo login space or formal + lobby for the wait. An unlinked player requires an autostart policy that permits + their verified identity; selecting a main server does not grant startup rights. +- **Enter a fallback server** requires a separate running server. The proxy does not + start the fallback implicitly; it rejects the connection if both destinations + are unavailable. +- **Reject with an explanation** requires no waiting space. + +When panel account linking is required, Limbo retains its web sign-in, blacklist +check, timeout and release controls. The existing web sign-in flow requires the +login space and formal lobby, and uses the lobby for startup waiting. Automatic +Limbo waiting does not issue a link code or start a web sign-in timer; the proxy's +queue controls startup progress and disconnects Limbo waits after failure or timeout. +A failed transfer includes the backend refusal when available. + +Saving requires fresh Owner reauthentication and the current revision. The proxy +reads changes within 15 seconds and snapshots the policy for each new connection; +current players keep their connection policy. Existing deployments retain hostname +routing, required web sign-in and lobby waiting until a policy is saved. Update the +API and game plugins together before using this setting. + +Unused login/lobby spaces can be stopped from **Login & lobby**. Saving a policy +does not stop them automatically or disconnect existing players. Re-running setup +preserves the desired state of existing system servers. diff --git a/internal/api/api.go b/internal/api/api.go index d8cc130..9e4595b 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -437,6 +437,7 @@ func (a *API) internalAPIRoutes() []apiRoute { // and keyed by the verified UUID (not the scanned code), so it consumes nothing // and is safe to poll repeatedly. {Method: "GET", Pattern: "/api/v1/internal/account/link/status/{mc_uuid}", Callers: gate, h: a.handleLinkStatus}, + {Method: "GET", Pattern: "/api/v1/internal/settings/entry-policy", Callers: gate, h: a.handleGetEntryPolicy}, // Account migration (spec §B3 inherit), in-game side: /felis migrate puts the // account linked to the running player's verified UUID into migrate mode. Internal // only — the initiator is proven by online-mode auth, and the sensitive proof @@ -655,6 +656,8 @@ func (a *API) externalAPIRoutes() []apiRoute { {Method: "POST", Pattern: "/api/v1/settings/node-control/tasks", Owner: true, Admin: true, h: a.handleStartNodeTask}, {Method: "GET", Pattern: "/api/v1/settings/node-control/tasks/{id}", Owner: true, Admin: true, h: a.handleNodeTask}, {Method: "POST", Pattern: "/api/v1/settings/node-control/tasks/{id}/retry", Owner: true, Admin: true, h: a.handleRetryNodeTask}, + {Method: "GET", Pattern: "/api/v1/settings/entry-policy", Owner: true, Admin: true, h: a.handleGetEntryPolicy}, + {Method: "PUT", Pattern: "/api/v1/settings/entry-policy", Owner: true, Admin: true, h: a.handleSetEntryPolicy}, {Method: "GET", Pattern: "/api/v1/settings/wake-policy", Owner: true, Admin: true, h: a.handleGetWakePolicy}, {Method: "PUT", Pattern: "/api/v1/settings/wake-policy", Owner: true, Admin: true, h: a.handleSetWakePolicy}, {Method: "GET", Pattern: "/api/v1/settings/auth-sources", Owner: true, Admin: true, h: a.handleGetAuthSources}, diff --git a/internal/api/handlers_entry_policy.go b/internal/api/handlers_entry_policy.go new file mode 100644 index 0000000..3da8aef --- /dev/null +++ b/internal/api/handlers_entry_policy.go @@ -0,0 +1,137 @@ +package api + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + + "felis.lolicon.best/internal/naming" +) + +const entryPolicyKey = "player_entry_policy" + +type entryPolicy struct { + Mode string `json:"mode"` + DefaultServer string `json:"defaultServer"` + RequireAccountLink bool `json:"requireAccountLink"` + OfflineAction string `json:"offlineAction"` + WaitingSpace string `json:"waitingSpace"` + FallbackServer string `json:"fallbackServer"` +} + +type entryPolicyView struct { + entryPolicy + Revision string `json:"revision"` +} + +func defaultEntryPolicy() entryPolicy { + // Preserve existing host routing and web association until the Owner saves a policy. + return entryPolicy{Mode: "domain", RequireAccountLink: true, OfflineAction: "wake", WaitingSpace: "lobby"} +} + +func (p entryPolicy) valid() bool { + if p.Mode != "lobby" && p.Mode != "direct" && p.Mode != "domain" { + return false + } + if p.OfflineAction != "wake" && p.OfflineAction != "fallback" && p.OfflineAction != "disconnect" { + return false + } + if p.WaitingSpace != "login" && p.WaitingSpace != "lobby" { + return false + } + if p.RequireAccountLink && p.WaitingSpace != "lobby" { + return false + } + if p.Mode == "direct" && p.DefaultServer == "" { + return false + } + if p.OfflineAction == "fallback" && (p.FallbackServer == "" || p.FallbackServer == p.DefaultServer) { + return false + } + for _, name := range []string{p.DefaultServer, p.FallbackServer} { + if name != "" && (naming.ValidateServerName(name) != nil || naming.IsSystemServer(name)) { + return false + } + } + return true +} + +func (a *API) readEntryPolicy(ctx context.Context) (entryPolicyView, []byte, error) { + view := entryPolicyView{entryPolicy: defaultEntryPolicy()} + raw, err := a.Repo.GetSetting(ctx, entryPolicyKey) + if errors.Is(err, ErrNotFound) { + raw = nil + } else if err != nil { + return view, nil, err + } else if err = json.Unmarshal(raw, &view.entryPolicy); err != nil { + return view, nil, err + } + if !view.entryPolicy.valid() { + return view, nil, errors.New("invalid player entry policy") + } + canonical, _ := json.Marshal(view.entryPolicy) + sum := sha256.Sum256(canonical) + view.Revision = hex.EncodeToString(sum[:]) + return view, raw, nil +} + +func (a *API) handleGetEntryPolicy(w http.ResponseWriter, r *http.Request) { + view, _, err := a.readEntryPolicy(r.Context()) + if err != nil { + writeError(w, r, err) + return + } + writeJSON(w, 200, view) +} + +func (a *API) handleSetEntryPolicy(w http.ResponseWriter, r *http.Request) { + if !a.requireReauth(w, r, principalFromContext(r.Context())) { + return + } + if err := requireJSONContentType(r); err != nil { + writeError(w, r, err) + return + } + var body entryPolicyView + if err := decodeJSON(w, r, &body); err != nil { + writeError(w, r, err) + return + } + if !body.entryPolicy.valid() { + writeError(w, r, newError(400, "bad_request", "invalid entry mode, target or offline policy")) + return + } + for _, name := range []string{body.DefaultServer, body.FallbackServer} { + if name == "" { + continue + } + if _, err := a.Cluster.GetServer(r.Context(), name); err != nil { + a.writeLookupError(w, r, err) + return + } + } + current, expected, err := a.readEntryPolicy(r.Context()) + if err != nil { + writeError(w, r, err) + return + } + if body.Revision != current.Revision { + writeError(w, r, newError(409, "conflict", "player entry policy changed; reload before saving")) + return + } + raw, _ := json.Marshal(body.entryPolicy) + if err = a.Repo.CompareAndSetSetting(r.Context(), entryPolicyKey, expected, raw); err != nil { + writeError(w, r, err) + return + } + a.audit(r, "platform.entry_policy", "platform") + view, _, err := a.readEntryPolicy(r.Context()) + if err != nil { + writeError(w, r, err) + return + } + writeJSON(w, http.StatusOK, view) +} diff --git a/internal/api/handlers_entry_policy_test.go b/internal/api/handlers_entry_policy_test.go new file mode 100644 index 0000000..b2d2ecb --- /dev/null +++ b/internal/api/handlers_entry_policy_test.go @@ -0,0 +1,88 @@ +package api + +import ( + "context" + "encoding/json" + "testing" +) + +func TestEntryPolicyPersistenceAndAuthorization(t *testing.T) { + repo, cluster := newFakeRepo(), newFakeCluster() + cluster.byName["main"] = &ServerInfo{Name: "main"} + a := newTestAPI(repo, cluster) + path := "/api/v1/settings/entry-policy" + for _, p := range []*Principal{nil, {UserID: "admin", Role: "admin", ViaAdminAccess: true}, {UserID: "owner", Role: "owner"}} { + a.External = staticExternal{p: p} + for _, method := range []string{"GET", "PUT"} { + if w := do(a.ExternalHandler(), method, path, `{}`, jsonHeader); w.Code != 401 && w.Code != 403 { + t.Fatalf("unauthorized: %d", w.Code) + } + } + } + p := &Principal{UserID: "owner", Role: "owner", ViaAdminAccess: true} + a.External = staticExternal{p: p} + view, _, err := a.readEntryPolicy(context.Background()) + if err != nil || !view.RequireAccountLink || view.Mode != "domain" { + t.Fatal(view, err) + } + save := func(v entryPolicyView) int { + body, _ := json.Marshal(v) + return do(a.ExternalHandler(), "PUT", path, string(body), jsonHeader).Code + } + view.Mode = "direct" + view.DefaultServer = "main" + view.RequireAccountLink = false + view.WaitingSpace = "login" + if code := save(view); code != 200 { + t.Fatal("save", code) + } + if code := save(view); code != 409 { + t.Fatal("stale save", code) + } + replica := newTestAPI(repo, cluster) + persisted, _, err := replica.readEntryPolicy(context.Background()) + if err != nil || persisted.Mode != "direct" || persisted.DefaultServer != "main" || persisted.RequireAccountLink { + t.Fatal(persisted, err) + } + persisted.DefaultServer = "missing" + if code := save(persisted); code != 404 { + t.Fatal("missing target", code) + } + p.ViaSession = true + p.EmailVerified = true + repo.passkeyCreds["key"] = PasskeyCredential{ID: "key", UserID: "owner", UserVerified: true} + persisted.DefaultServer = "main" + if code := save(persisted); code != 403 { + t.Fatal("reauth", code) + } + repo.settings[entryPolicyKey] = []byte(`{"mode":"invalid"}`) + if _, _, err := a.readEntryPolicy(context.Background()); err == nil { + t.Fatal("invalid persisted policy accepted") + } +} + +func TestEntryPolicyValidation(t *testing.T) { + good := entryPolicy{Mode: "direct", DefaultServer: "main", OfflineAction: "wake", WaitingSpace: "login"} + if !good.valid() { + t.Fatal("valid policy rejected") + } + for _, mutate := range []func(*entryPolicy){ + func(p *entryPolicy) { p.Mode = "invalid" }, func(p *entryPolicy) { p.DefaultServer = "" }, func(p *entryPolicy) { p.DefaultServer = "login" }, func(p *entryPolicy) { p.OfflineAction = "fallback" }, func(p *entryPolicy) { p.OfflineAction = "fallback"; p.FallbackServer = "main" }, func(p *entryPolicy) { p.WaitingSpace = "invalid" }, func(p *entryPolicy) { p.RequireAccountLink = true }, + } { + p := good + mutate(&p) + if p.valid() { + t.Fatal("invalid policy accepted", p) + } + } +} + +func TestEntryPolicyInternalCallerBoundary(t *testing.T) { + a := newTestAPI(newFakeRepo(), newFakeCluster()) + a.Internal = CallerTokens{CallerVelocity: "proxy", CallerLimbo: "login", CallerBuild: "build"} + for token, want := range map[string]int{"proxy": 200, "login": 200, "build": 403, "invalid": 401} { + if w := do(a.InternalHandler(), "GET", "/api/v1/internal/settings/entry-policy", "", map[string]string{"Authorization": "Bearer " + token}); w.Code != want { + t.Fatal(token, w.Code, w.Body.String()) + } + } +} diff --git a/panel/dev/mockApi.ts b/panel/dev/mockApi.ts index c2792ab..63eabb9 100644 --- a/panel/dev/mockApi.ts +++ b/panel/dev/mockApi.ts @@ -6,6 +6,7 @@ import type { AuthSourceConfig, AuthSourcesSettings, WakePolicySettings, + EntryPolicySettings, AutostartPolicy, BackupView, Build, @@ -94,6 +95,7 @@ interface MockState { updateWindow: { start: string | null; end: string | null }; authSources: AuthSourcesSettings; wakePolicy: WakePolicySettings; + entryPolicy: EntryPolicySettings; // Each server's world volume, seeded on first visit. files: Record; } @@ -457,6 +459,7 @@ function initialState(): MockState { }, ], updateWindow: { start: null, end: null }, + entryPolicy: { mode: "domain", defaultServer: "", requireAccountLink: true, offlineAction: "wake", waitingSpace: "lobby", fallbackServer: "", revision: "initial" }, wakePolicy: { maxRunningServers: 0, wakeCooldownSeconds: 30, revision: "initial", managed: false }, authSources: { sources: [{ tag: "littleskin", prefix: "LS", url: "https://littleskin.cn/api/yggdrasil/sessionserver/session/minecraft/hasJoined", api_url: "", enabled: true }], @@ -1023,6 +1026,20 @@ async function handleSession(ctx: SessionContext): Promise { if (!isOwner(ctx.account.role)) sendError(ctx.res, 403, "forbidden", "Owner account required"); else sendJSON(ctx.res, 200, { available: false, tasks: [] }); return true; + case "GET settings/entry-policy": + case "PUT settings/entry-policy": { + if (!isOwner(ctx.account.role)) sendError(ctx.res, 403, "forbidden", "Owner account required"); + else if (is("GET", ctx)) sendJSON(ctx.res, 200, ctx.state.entryPolicy); + else { + const body = await readJSON(ctx.req); + if (body.revision !== ctx.state.entryPolicy.revision) sendError(ctx.res, 409, "conflict", "Entry policy changed"); + else { + ctx.state.entryPolicy = { ...body, revision: createHash("sha256").update(JSON.stringify(body)).digest("hex") }; + sendJSON(ctx.res, 200, ctx.state.entryPolicy); + } + } + return true; + } case "GET settings/wake-policy": case "PUT settings/wake-policy": { if (!isOwner(ctx.account.role)) { diff --git a/panel/e2e/player-entry.smoke.spec.ts b/panel/e2e/player-entry.smoke.spec.ts new file mode 100644 index 0000000..ea1a732 --- /dev/null +++ b/panel/e2e/player-entry.smoke.spec.ts @@ -0,0 +1,46 @@ +import { test, expect, t, expectFitsScreen } from "./fixtures"; + +test("entry policy keeps its form visible, saves direct routing and retains Limbo control", async ({ page, signIn }) => { + await signIn("owner"); + let release!: () => void; + const pending = new Promise((resolve) => { release = resolve; }); + await page.route("**/api/v1/settings/entry-policy", async (route) => { await pending; await route.continue(); }); + await page.goto("/admin/platform"); + const direct = page.getByRole("radio", { name: `${t("admin:entry_direct")} ${t("admin:entry_direct_hint")}` }); + await expect(direct).toBeVisible(); + await expect(direct).toBeDisabled(); + release(); + await expect(direct).toBeEnabled(); + const selected = page.getByRole("radio", { checked: true }); + await selected.focus(); + await page.keyboard.press("Home"); + await expect(page.getByRole("radio", { name: `${t("admin:entry_lobby")} ${t("admin:entry_lobby_hint")}` })).toBeFocused(); + await page.keyboard.press("ArrowRight"); + await expect(direct).toBeFocused(); + await expect(page.getByLabel(t("admin:entry_link"), { exact: true })).toHaveAttribute("aria-checked", "false"); + await expect(page.getByLabel(t("admin:entry_waiting"))).toHaveCount(0); + await page.getByLabel(t("admin:entry_main"), { exact: true }).click(); + await page.getByRole("option", { name: /survival/i }).click(); + await page.getByRole("button", { name: t("admin:entry_save") }).click(); + await expect(page.getByText(t("admin:entry_saved"), { exact: true })).toBeVisible(); + await page.reload(); + await expect(direct).toHaveAttribute("aria-checked", "true"); + await expect(page.getByLabel(t("admin:entry_main"), { exact: true })).toContainText("survival"); + await page.getByLabel(t("admin:entry_offline")).click(); + await page.getByRole("option", { name: t("admin:entry_wake"), exact: true }).click(); + await expect(page.getByLabel(t("admin:entry_waiting"))).toContainText(t("admin:entry_wait_login")); + await direct.click(); + await expect(page.getByLabel(t("admin:entry_waiting"))).toContainText(t("admin:entry_wait_login")); + await page.getByLabel(t("admin:entry_link"), { exact: true }).click(); + await expect(page.getByLabel(t("admin:entry_waiting"))).toBeDisabled(); + await expect(page.getByLabel(t("admin:entry_waiting"))).toContainText(t("admin:entry_lobby")); + await page.getByLabel(t("admin:entry_link"), { exact: true }).click(); + for (const viewport of [{ width: 1440, height: 1050 }, { width: 375, height: 812 }]) { + await page.setViewportSize(viewport); + await expectFitsScreen(page); + } + await page.setViewportSize({ width: 1440, height: 1050 }); + await page.getByRole("main").evaluate((el) => { el.scrollTop = 0; }); + await expect(direct).toBeInViewport(); + await page.screenshot({ path: "/tmp/felis-player-entry.png" }); +}); diff --git a/panel/e2e/startup-platform.smoke.spec.ts b/panel/e2e/startup-platform.smoke.spec.ts index f77721f..cf464c8 100644 --- a/panel/e2e/startup-platform.smoke.spec.ts +++ b/panel/e2e/startup-platform.smoke.spec.ts @@ -103,7 +103,7 @@ test("Owner executes node management and receives stage, failure logs and retry" await page.getByLabel(t("admin:node_control_ip")).fill("192.0.2.10"); const submit = page.getByRole("button", { name: t("admin:node_control_enable"), exact: true }); await expect(submit).toBeDisabled(); - await page.getByRole("switch").click(); + await page.getByLabel(t("admin:node_control_confirm_enable")).click(); await submit.click(); expect(submitted).toMatchObject({ action: "enable", externalIP: "192.0.2.10", confirmMaintenance: true }); await expect(page.getByText(t("admin:node_control_stage_database_backup"), { exact: true })).toBeVisible(); diff --git a/panel/src/components/PlayerEntrySettings.tsx b/panel/src/components/PlayerEntrySettings.tsx new file mode 100644 index 0000000..bfdbd29 --- /dev/null +++ b/panel/src/components/PlayerEntrySettings.tsx @@ -0,0 +1,97 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { ArrowRight, DoorOpen, Globe, Loader2, LogIn, RefreshCw, Route, Save, Server, ShieldCheck } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { MessageLine } from "@/components/MessageLine"; +import { isReauthCancelled, useReauth } from "@/components/ReauthDialog"; +import { api, humanizeError } from "@/lib/api"; +import { useAsync, useUnsavedGuard } from "@/lib/hooks"; +import type { EntryPolicySettings } from "@/lib/types"; + +const modes = [{ value: "lobby", icon: DoorOpen }, { value: "direct", icon: Server }, { value: "domain", icon: Globe }] as const; + +const initial: EntryPolicySettings = { mode: "domain", defaultServer: "", requireAccountLink: true, offlineAction: "wake", waitingSpace: "lobby", fallbackServer: "", revision: "" }; + +export function PlayerEntrySettings() { + const { t } = useTranslation("admin"); + const query = useAsync(api.getEntryPolicy, []); + const fleet = useAsync(api.fleet, []); + const reauth = useReauth(); + const [draft, setDraft] = useState(initial); + const [saved, setSaved] = useState(null); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState<{ kind: "success" | "error"; text: string } | null>(null); + const dirty = saved !== null && JSON.stringify(draft) !== JSON.stringify(saved); + useUnsavedGuard(dirty); + useEffect(() => { + if (query.data) { setSaved(query.data); setDraft(query.data); } + }, [query.data]); + const busy = query.loading || saving || !saved; + const servers = (fleet.data ?? []).filter((server) => !server.system && server.name !== "login" && server.name !== "lobby"); + const valid = (draft.mode !== "direct" || !!draft.defaultServer) + && (draft.offlineAction !== "fallback" || (!!draft.fallbackServer && draft.fallbackServer !== draft.defaultServer)); + function change(update: Partial) { setDraft((value) => ({ ...value, ...update })); setMessage(null); } + function serverSelect(field: "defaultServer" | "fallbackServer", optional = false) { + const value = draft[field]; + return ; + } + async function save() { + if (busy || !dirty || !valid) return; + setSaving(true); setMessage(null); + try { + const policy = await reauth.guard(() => api.setEntryPolicy(draft)); + setSaved(policy); setDraft(policy); + setMessage({ kind: "success", text: t("entry_saved") }); + } catch (error) { + if (!isReauthCancelled(error)) setMessage({ kind: "error", text: humanizeError(error) }); + } finally { setSaving(false); } + } + return + + +

{t("entry_description")}

+ {query.loading &&

{t("platform_loading")}

} +
{ + if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(event.key) || busy) return; + event.preventDefault(); + const choices = Array.from(event.currentTarget.querySelectorAll('[role="radio"]')); + const current = choices.indexOf(event.target as HTMLButtonElement); + const index = event.key === "Home" ? 0 : event.key === "End" ? choices.length - 1 : (current + (event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 1) + choices.length) % choices.length; + choices[index].focus(); choices[index].click(); + }}> + {modes.map(({ value: mode, icon: Icon }) => )} +
+
+ {draft.requireAccountLink && <>}{draft.mode === "lobby" ? t("entry_lobby") : draft.mode === "domain" ? t("entry_domain_target") : draft.defaultServer || t("entry_select_server")} +
+
+ {draft.mode !== "lobby" &&
{serverSelect("defaultServer", draft.mode === "domain")}

{t(draft.mode === "direct" ? "entry_main_hint" : "entry_default_hint")}

} +
+ {draft.offlineAction === "fallback" &&
{serverSelect("fallbackServer")}
} + {draft.offlineAction === "wake" &&

{t(draft.requireAccountLink ? "entry_web_wait" : "entry_wake_hint")}

} +
+

{t("entry_link_hint")}

change({ requireAccountLink: checked, ...(checked ? { waitingSpace: "lobby" } : {}) })} />
+

{t("entry_components")} {t("entry_manage_spaces")}

+ {query.error != null && } + {fleet.error != null && } + {message && } +

{t("entry_scope")}

{dirty && }
+ {reauth.dialog} +
+
; +} diff --git a/panel/src/i18n/resources/en-US/admin.json b/panel/src/i18n/resources/en-US/admin.json index 520ba7f..3e22d46 100644 --- a/panel/src/i18n/resources/en-US/admin.json +++ b/panel/src/i18n/resources/en-US/admin.json @@ -308,7 +308,7 @@ "scan_not_kept": "The file was not retained for this build. It may have exceeded the size limit or its generation step may have failed.", "scan_download_failed": "Unable to download: {{reason}}", "platform_title": "Platform settings", - "platform_subtitle": "Global controls for server startup and wake requests.", + "platform_subtitle": "Manage player entry, server startup and distributed nodes.", "platform_reload": "Reload", "platform_wake_title": "Startup and wake policy", "platform_loading": "Loading the saved policy…", @@ -393,5 +393,40 @@ "node_control_enable_ip_hint": "Required. Enter the controller’s currently registered fixed IP, using a static address or DHCP reservation.", "node_control_ip_hint": "Required. Enter the fixed IP configured on the target worker.", "node_control_incomplete": "Complete all required fields and acknowledge the maintenance conditions before executing.", - "node_control_back": "Back" + "node_control_back": "Back", + "entry_title": "Player entry policy", + "entry_description": "Configure game identity verification, panel account linking and destination independently. Existing deployments retain their current flow until the first save.", + "entry_mode": "Entry mode", + "entry_lobby": "Lobby", + "entry_direct": "Direct to main server", + "entry_domain": "Route by hostname", + "entry_lobby_hint": "Authenticate, then enter the lobby to choose a server.", + "entry_direct_hint": "Authenticate, then enter the selected main server.", + "entry_domain_hint": "Select the destination using the connection hostname.", + "entry_preview": "Entry flow preview", + "entry_identity": "Game identity verification", + "entry_web": "Limbo web sign-in", + "entry_domain_target": "Hostname destination", + "entry_main": "Main server", + "entry_default": "Default destination (optional)", + "entry_select_server": "Select a server", + "entry_no_default": "No default destination", + "entry_main_hint": "All entry hostnames lead to this server. Its autostart policy still controls startup permission.", + "entry_default_hint": "Used for unmatched hostnames. Without a default, web sign-in connections enter the lobby; other connections are rejected.", + "entry_offline": "When the destination is offline", + "entry_wake": "Start automatically and wait", + "entry_fallback": "Enter a fallback server", + "entry_disconnect": "Reject with an explanation", + "entry_fallback_server": "Fallback server", + "entry_waiting": "Startup waiting space", + "entry_wait_login": "Login space (Limbo)", + "entry_wake_hint": "The waiting space must be running. Startup failure or timeout is reported; a connection waiting in Limbo is then disconnected.", + "entry_web_wait": "Web sign-in uses the lobby while waiting for startup.", + "entry_link": "Require panel account linking", + "entry_link_hint": "Players must complete web sign-in in Limbo when enabled. Otherwise game identity is still verified and panel linking remains optional.", + "entry_components": "Web sign-in requires the login space and lobby. Automatic authentication only requires the selected destination and waiting space. Unused spaces can be stopped manually.", + "entry_manage_spaces": "Manage login space and lobby", + "entry_scope": "Applies to new connections within 15 seconds. Current players are unaffected.", + "entry_save": "Save entry policy", + "entry_saved": "Entry policy saved. New connections use it after the proxy refreshes its configuration." } diff --git a/panel/src/i18n/resources/zh-CN/admin.json b/panel/src/i18n/resources/zh-CN/admin.json index 5c84288..e510fd0 100644 --- a/panel/src/i18n/resources/zh-CN/admin.json +++ b/panel/src/i18n/resources/zh-CN/admin.json @@ -304,7 +304,7 @@ "scan_not_kept": "构建未留存此文件,可能由于文件过大或生成步骤失败。", "scan_download_failed": "下载失败:{{reason}}", "platform_title": "平台设置", - "platform_subtitle": "统一管理服务器启动与唤醒的全局策略。", + "platform_subtitle": "管理玩家入口、服务器启动与分布式节点。", "platform_reload": "重新读取", "platform_wake_title": "启动与唤醒策略", "platform_loading": "正在读取已保存的策略…", @@ -389,5 +389,40 @@ "node_control_enable_ip_hint": "必填。填写主控当前登记的固定 IP;应为静态地址或已配置 DHCP 地址保留。", "node_control_ip_hint": "必填。填写目标 worker 已配置的固定 IP。", "node_control_incomplete": "填写全部必填项并确认维护条件后,方可执行。", - "node_control_back": "返回" + "node_control_back": "返回", + "entry_title": "玩家进入策略", + "entry_description": "分别配置游戏身份验证、面板账号关联和进入目标。已部署环境在首次保存前保留原有流程。", + "entry_mode": "进入方式", + "entry_lobby": "正式大厅", + "entry_direct": "直达主服", + "entry_domain": "按域名进入", + "entry_lobby_hint": "认证完成后进入大厅,由玩家选择服务器。", + "entry_direct_hint": "认证完成后直接进入指定主服。", + "entry_domain_hint": "按连接域名选择目标服务器。", + "entry_preview": "进入流程预览", + "entry_identity": "游戏身份认证", + "entry_web": "Limbo 网页登录", + "entry_domain_target": "域名对应服务器", + "entry_main": "默认主服", + "entry_default": "默认目标(选填)", + "entry_select_server": "选择服务器", + "entry_no_default": "不设置默认目标", + "entry_main_hint": "所有入口均连接此服务器。启动权限仍由目标服务器的自动启动策略决定。", + "entry_default_hint": "连接域名未匹配服务器时使用。未设置时,要求网页登录的连接进入大厅,其余连接被拒绝。", + "entry_offline": "目标未运行时", + "entry_wake": "自动启动并等待", + "entry_fallback": "进入备用服务器", + "entry_disconnect": "拒绝连接并说明原因", + "entry_fallback_server": "备用服务器", + "entry_waiting": "启动等待空间", + "entry_wait_login": "登录空间(Limbo)", + "entry_wake_hint": "等待空间须处于运行状态。启动失败或等待超时将显示原因;Limbo 等待连接随后断开。", + "entry_web_wait": "启用网页登录时,启动等待使用正式大厅。", + "entry_link": "要求关联面板账号", + "entry_link_hint": "启用后,玩家须在 Limbo 完成网页登录。关闭后仍验证游戏身份,面板账号可另行关联。", + "entry_components": "网页登录需要登录空间及正式大厅;自动认证仅需要所选进入目标和等待空间。未使用的空间可手动停用。", + "entry_manage_spaces": "管理登录空间与大厅", + "entry_scope": "保存后对新连接生效;代理在 15 秒内读取配置,当前在线玩家不受影响。", + "entry_save": "保存进入策略", + "entry_saved": "进入策略已保存。新连接将在代理读取配置后使用此策略。" } diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts index 1fd3a29..fe78df7 100644 --- a/panel/src/lib/api.ts +++ b/panel/src/lib/api.ts @@ -26,6 +26,7 @@ import type { AuthSourceConfig, AuthSourcesSettings, WakePolicySettings, + EntryPolicySettings, MinecraftProfile, LinkStatus, BindResult, @@ -960,6 +961,9 @@ export const api = rejectingSync({ linkSources: () => request<{ sources: MinecraftAuthSource[] }>("GET", "/account/link/sources"), + getEntryPolicy: () => request("GET", "/settings/entry-policy"), + setEntryPolicy: (policy: EntryPolicySettings) => request("PUT", "/settings/entry-policy", policy), + getWakePolicy: () => request("GET", "/settings/wake-policy"), setWakePolicy: (policy: Omit) => request("PUT", "/settings/wake-policy", policy), getAuthSources: () => request("GET", "/settings/auth-sources"), diff --git a/panel/src/lib/openapi.gen.ts b/panel/src/lib/openapi.gen.ts index 7f535af..56fcd25 100644 --- a/panel/src/lib/openapi.gen.ts +++ b/panel/src/lib/openapi.gen.ts @@ -1334,6 +1334,44 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/internal/settings/entry-policy": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Read player entry policy for the authenticated proxy and login gate. */ + get: operations["internalEntryPolicy"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/settings/entry-policy": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Read player entry policy (Owner). */ + get: operations["getEntryPolicy"]; + /** + * Save player entry policy (Owner, fresh reauthentication). + * @description Applies to new connections within the proxy's 15-second refresh interval. Does not change online-mode, autostart authorization or existing players, and does not stop system spaces automatically. + */ + put: operations["setEntryPolicy"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/settings/wake-policy": { parameters: { query?: never; @@ -3036,6 +3074,25 @@ export interface components { startedAt?: string; logsAvailable: boolean; }; + EntryPolicySettings: { + /** @enum {string} */ + mode: "lobby" | "direct" | "domain"; + /** @description Required for direct mode; optional fallback destination for unmatched hostnames. */ + defaultServer: string; + /** @description Require Limbo web sign-in and panel association in addition to proxy game identity authentication. */ + requireAccountLink: boolean; + /** @enum {string} */ + offlineAction: "wake" | "fallback" | "disconnect"; + /** + * @description Web sign-in currently requires lobby waiting. + * @enum {string} + */ + waitingSpace: "login" | "lobby"; + /** @description Required for fallback action and distinct from the default server. */ + fallbackServer: string; + /** @description Opaque revision; stale or concurrent writes return 409. */ + revision: string; + }; WakePolicySettings: { maxRunningServers: number; wakeCooldownSeconds: number; @@ -7086,6 +7143,91 @@ export interface operations { }; }; }; + internalEntryPolicy: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Current policy; proxy snapshots it for each new connection. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EntryPolicySettings"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + }; + }; + getEntryPolicy: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Current policy and revision; an unsaved deployment preserves its legacy routing. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EntryPolicySettings"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + }; + }; + setEntryPolicy: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["EntryPolicySettings"]; + }; + }; + responses: { + /** @description Saved policy and revision. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EntryPolicySettings"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + /** @description Selected server does not exist. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Policy changed; reload before saving. */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; getWakePolicy: { parameters: { query?: never; diff --git a/panel/src/lib/types.ts b/panel/src/lib/types.ts index ef828c2..c475e23 100644 --- a/panel/src/lib/types.ts +++ b/panel/src/lib/types.ts @@ -796,3 +796,13 @@ export interface NodeControlTask { error?: string; log?: string; } + +export interface EntryPolicySettings { + mode: "lobby" | "direct" | "domain"; + defaultServer: string; + requireAccountLink: boolean; + offlineAction: "wake" | "fallback" | "disconnect"; + waitingSpace: "login" | "lobby"; + fallbackServer: string; + revision: string; +} diff --git a/panel/src/pages/admin/PlatformSettingsPage.test.tsx b/panel/src/pages/admin/PlatformSettingsPage.test.tsx index 1580ce5..1524c2d 100644 --- a/panel/src/pages/admin/PlatformSettingsPage.test.tsx +++ b/panel/src/pages/admin/PlatformSettingsPage.test.tsx @@ -5,7 +5,7 @@ import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; import { PlatformSettingsPage } from "./PlatformSettingsPage"; import type { WakePolicySettings } from "@/lib/types"; -const calls = vi.hoisted(() => ({ getWakePolicy: vi.fn(), setWakePolicy: vi.fn(), nodes: vi.fn(), nodeTasks: vi.fn(), nodeTask: vi.fn(), startNodeTask: vi.fn(), retryNodeTask: vi.fn() })); +const calls = vi.hoisted(() => ({ getEntryPolicy: vi.fn(), setEntryPolicy: vi.fn(), fleet: vi.fn(), getWakePolicy: vi.fn(), setWakePolicy: vi.fn(), nodes: vi.fn(), nodeTasks: vi.fn(), nodeTask: vi.fn(), startNodeTask: vi.fn(), retryNodeTask: vi.fn() })); vi.mock("@/lib/api", async (original) => ({ ...await original(), api: calls })); const runtime = vi.hoisted(() => ({ distributed: false, fallback: false, apiBase: "/api/v1", rootDomain: "example.test" })); vi.mock("@/lib/hooks", async (original) => ({ ...await original(), useConfig: () => runtime })); @@ -17,6 +17,8 @@ beforeEach(() => { vi.clearAllMocks(); runtime.distributed = false; runtime.fallback = false; + calls.getEntryPolicy.mockResolvedValue({ mode: "domain", defaultServer: "", requireAccountLink: true, offlineAction: "wake", waitingSpace: "lobby", fallbackServer: "", revision: "initial" }); + calls.fleet.mockResolvedValue([]); calls.nodes.mockResolvedValue([]); calls.nodeTasks.mockResolvedValue({ available: false, tasks: [] }); calls.getWakePolicy.mockResolvedValue(policy); @@ -42,7 +44,7 @@ describe("platform policy", () => { await userEvent.click(screen.getByRole("button", { name: "Save and apply" })); await screen.findByRole("alert"); expect(limit().value).toBe("2"); - expect(screen.getByRole("button", { name: "Reload" })).toHaveProperty("disabled", true); + expect(screen.getAllByRole("button", { name: "Reload" })[0]).toHaveProperty("disabled", true); await userEvent.click(screen.getByRole("button", { name: "Save and apply" })); await screen.findByText("Saved. The new policy is active without a restart."); expect(calls.setWakePolicy).toHaveBeenLastCalledWith({ maxRunningServers: 2, wakeCooldownSeconds: 30, revision: "initial" }); diff --git a/panel/src/pages/admin/PlatformSettingsPage.tsx b/panel/src/pages/admin/PlatformSettingsPage.tsx index 6965e1b..ded4ade 100644 --- a/panel/src/pages/admin/PlatformSettingsPage.tsx +++ b/panel/src/pages/admin/PlatformSettingsPage.tsx @@ -2,6 +2,7 @@ import { Link } from "react-router-dom"; import { useEffect, useState } from "react"; import { Loader2, RefreshCw, Save, Settings } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { PlayerEntrySettings } from "@/components/PlayerEntrySettings"; import { NodeControlPanel } from "@/components/NodeControlPanel"; import { DistributedNodes } from "@/components/DistributedNodes"; import { Badge } from "@/components/ui/badge"; @@ -52,6 +53,7 @@ export function PlatformSettingsPage() { } return
{t("platform_reload")}} /> + {t("platform_wake_title")} {query.loading &&

{t("platform_loading")}

}
diff --git a/plugins/limbo/src/main/java/best/lolicon/felis/limbo/LoginFlow.java b/plugins/limbo/src/main/java/best/lolicon/felis/limbo/LoginFlow.java index 1b17c36..8bdf53b 100644 --- a/plugins/limbo/src/main/java/best/lolicon/felis/limbo/LoginFlow.java +++ b/plugins/limbo/src/main/java/best/lolicon/felis/limbo/LoginFlow.java @@ -137,6 +137,10 @@ final class LoginFlow { disconnect(id, BLACKLISTED); return; } + if (!api.entryPolicy().requireAccountLink()) { + // The proxy owns routing and startup progress; this space is only a waiting room. + 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 diff --git a/plugins/limbo/test/best/lolicon/felis/limbo/LoginFlowTest.java b/plugins/limbo/test/best/lolicon/felis/limbo/LoginFlowTest.java index 6e32ea0..38dcfa5 100644 --- a/plugins/limbo/test/best/lolicon/felis/limbo/LoginFlowTest.java +++ b/plugins/limbo/test/best/lolicon/felis/limbo/LoginFlowTest.java @@ -65,6 +65,7 @@ public final class LoginFlowTest { QUIET.setLevel(Level.OFF); Stub stub = new Stub(); try { + automaticWaitDoesNotRequireWebSignIn(stub); linkedPlayerGoesStraightToTheLobby(stub); unlinkedPlayerGetsTheCodeAndIsReleasedOnceLinked(stub); codeWithoutPanelUrlPointsAtTheConsole(stub); @@ -91,6 +92,18 @@ public final class LoginFlowTest { // ---- the flow ---- + private static void automaticWaitDoesNotRequireWebSignIn(Stub stub) { + stub.requireAccountLink = false; + Rig rig = new Rig(stub); + Seat seat = rig.join(stub.player(false, false), "Automatic"); + rig.gate.advance(20 * 1000); + assertEq("automatic wait: no code", 0, stub.mints.get()); + assertEq("automatic wait: no web release", 0, seat.releases.size()); + assertEq("automatic wait: no login timeout", null, seat.disconnected); + assertEq("automatic wait: no web timers", 0, rig.gate.pending()); + stub.requireAccountLink = true; + } + private static void linkedPlayerGoesStraightToTheLobby(Stub stub) { Rig rig = new Rig(stub); UUID id = rig.stub.player(false, true); @@ -595,6 +608,7 @@ public final class LoginFlowTest { final Map hits = new ConcurrentHashMap<>(); final AtomicInteger mints = new AtomicInteger(); volatile int failWith; + volatile boolean requireAccountLink = true; volatile int mintStatus = 201; volatile String panelUrl; @@ -632,6 +646,10 @@ public final class LoginFlowTest { reply(ex, 401, "{\"error\":{\"code\":\"unauthorized\",\"message\":\"unauthorized\"}}"); return; } + if (path.endsWith("/settings/entry-policy")) { + reply(ex, 200, "{\"mode\":\"domain\",\"requireAccountLink\":" + requireAccountLink + ",\"offlineAction\":\"wake\",\"waitingSpace\":\"lobby\"}"); + return; + } String kind = path.contains("/link/status/") ? "/link/status/" : path.contains("/blacklist/") ? "/blacklist/" : path.endsWith("/link/code") ? "/link/code" : path; diff --git a/plugins/shared/src/main/java/best/lolicon/felis/link/EntryPolicy.java b/plugins/shared/src/main/java/best/lolicon/felis/link/EntryPolicy.java new file mode 100644 index 0000000..0cf1cd5 --- /dev/null +++ b/plugins/shared/src/main/java/best/lolicon/felis/link/EntryPolicy.java @@ -0,0 +1,29 @@ +package best.lolicon.felis.link; + +import java.util.Map; + +/** Owner-selected entry routing; authentication remains the proxy's responsibility. */ +public record EntryPolicy(String mode, String defaultServer, boolean requireAccountLink, + String offlineAction, String waitingSpace, String fallbackServer) { + public static EntryPolicy legacy() { + return new EntryPolicy("domain", "", true, "wake", "lobby", ""); + } + + public static EntryPolicy fromJson(Map data) throws LinkException { + String mode = text(data, "mode"); + String action = text(data, "offlineAction"); + String space = text(data, "waitingSpace"); + if (!(mode.equals("lobby") || mode.equals("direct") || mode.equals("domain")) + || !(action.equals("wake") || action.equals("fallback") || action.equals("disconnect")) + || !(space.equals("login") || space.equals("lobby")) + || !(data.get("requireAccountLink") instanceof Boolean)) { + throw new LinkException(503, "entry_policy_unavailable", "Invalid player entry policy"); + } + return new EntryPolicy(mode, text(data, "defaultServer"), (boolean) data.get("requireAccountLink"), + action, space, text(data, "fallbackServer")); + } + + private static String text(Map data, String key) { + return data.get(key) instanceof String value ? value : ""; + } +} diff --git a/plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java b/plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java index 22a6e91..1767126 100644 --- a/plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java +++ b/plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java @@ -69,6 +69,10 @@ public final class FelisApiClient { return stats; } + public EntryPolicy entryPolicy() throws LinkException { + return EntryPolicy.fromJson(getObject("/api/v1/internal/settings/entry-policy", 200)); + } + /** listServers returns the lifecycle view of every MinecraftServer (GET /servers). */ public List listServers() throws LinkException { return ServerView.listFrom(getObject("/api/v1/servers", 200)); diff --git a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java index 36aabcd..778bcac 100644 --- a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java +++ b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java @@ -324,6 +324,11 @@ public final class FelisVelocityPlugin { private void refreshRegistrations() { if (router != null) { router.pruneLinks(); + try { + router.setEntryPolicy(apiClient.entryPolicy()); + } catch (LinkException error) { + logger.warn("Felis: entry policy refresh failed; retaining the last policy: {}", error.getMessage()); + } } ServerListSource.Result r = serverList.next(); if (r.servers != null) { diff --git a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/WaitingRouter.java b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/WaitingRouter.java index 7ba0e98..ff3e563 100644 --- a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/WaitingRouter.java +++ b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/WaitingRouter.java @@ -1,6 +1,7 @@ package best.lolicon.felis.velocity; import best.lolicon.felis.link.FelisApiClient; +import best.lolicon.felis.link.EntryPolicy; import best.lolicon.felis.link.LinkException; import best.lolicon.felis.link.ServerView; @@ -93,6 +94,11 @@ public final class WaitingRouter { private final String loginServer; private final String lobbyServer; + private volatile EntryPolicy entryPolicy = EntryPolicy.legacy(); + private final Map entrySessions = new ConcurrentHashMap<>(); + + void setEntryPolicy(EntryPolicy policy) { this.entryPolicy = policy; } + private final LinkGate links; private final Map waiting = new ConcurrentHashMap<>(); private final Map pendingTargets = new ConcurrentHashMap<>(); @@ -195,6 +201,10 @@ public final class WaitingRouter { // linked runs the link check through the gate and notes when the answer came from // the outage fallback rather than felis-api. private boolean linked(UUID id) throws LinkException { + EntryPolicy policy = entrySessions.get(id); + if (policy != null && !policy.requireAccountLink()) { + return !api.isBlacklisted(id); + } LinkGate.Result r = links.check(id); if (r.degraded) { log.warn("Felis: felis-api unreachable; admitting {} on a link confirmation from the last {} min", @@ -281,17 +291,20 @@ public final class WaitingRouter { } @Subscribe - public void onChooseInitialServer(PlayerChooseInitialServerEvent event) { + public EventTask onChooseInitialServer(PlayerChooseInitialServerEvent event) { Player player = event.getPlayer(); UUID id = player.getUniqueId(); - pendingTargets.remove(id); // a reconnect must never inherit an earlier host - Optional host = virtualHost(player); - if (host.isEmpty()) { - return; // velocity.toml's only fallback is login + entrySessions.remove(id); + waiting.remove(id); + EntryPolicy policy = entryPolicy; + if (!policy.requireAccountLink()) { + return EventTask.async(() -> chooseAuthenticated(event, policy)); } - Optional targetOpt = registry.resolveByHost(host.get()); + entrySessions.put(id, policy); + pendingTargets.remove(id); // a reconnect must never inherit an earlier host + Optional targetOpt = policyTarget(player, policy); if (targetOpt.isEmpty()) { - return; // unknown host also falls through to login + return null; // unknown host also falls through to login } ServerView target = targetOpt.get(); Optional login = login(); @@ -302,7 +315,7 @@ public final class WaitingRouter { ? "Felis 登录网关不可用,请稍后重连。" : "The Felis login gate is unavailable. Please reconnect shortly.", NamedTextColor.RED)); - return; + return null; } // login. is a valid system hostname, but it is the gate rather // than a post-auth destination. Remembering it would redirect the successful @@ -311,6 +324,77 @@ public final class WaitingRouter { pendingTargets.put(id, target.name()); } event.setInitialServer(login.get()); + return null; + } + + private Optional policyTarget(Player player, EntryPolicy policy) { + if (policy.mode().equals("lobby")) return Optional.ofNullable(registry.view(lobbyServer)); + if (policy.mode().equals("domain")) { + Optional host = virtualHost(player).flatMap(registry::resolveByHost) + .filter(server -> !server.name().equalsIgnoreCase(loginServer)); + if (host.isPresent()) return host; + } + return Optional.ofNullable(registry.view(policy.defaultServer())); + } + + private void chooseAuthenticated(PlayerChooseInitialServerEvent event, EntryPolicy policy) { + Player player = event.getPlayer(); + event.setInitialServer(null); + pendingTargets.remove(player.getUniqueId()); + try { + if (api.isBlacklisted(player.getUniqueId())) { + entryDisconnect(player, "此游戏身份已被禁止登录。", "This game identity is barred."); + return; + } + entrySessions.put(player.getUniqueId(), policy); + ServerView target = policyTarget(player, policy).orElse(null); + if (target == null) { + entryDisconnect(player, "未配置此入口的目标服务器,请联系管理员。", "No destination is configured for this entry. Contact the administrator."); + return; + } + // Refresh readiness instead of routing on the registration cache alone. + target = api.serverStatus(target.name()); + registry.observe(target); + if (target.ready() && registry.registered(target.name()).isPresent()) { + event.setInitialServer(registry.registered(target.name()).get()); + return; + } + if (policy.offlineAction().equals("fallback")) { + ServerView fallback = api.serverStatus(policy.fallbackServer()); + registry.observe(fallback); + if (fallback.ready() && registry.registered(fallback.name()).isPresent()) { + event.setInitialServer(registry.registered(fallback.name()).get()); + return; + } + entryDisconnect(player, "目标服务器与备用服务器均不可用。", "The destination and fallback server are unavailable."); + return; + } + if (policy.offlineAction().equals("disconnect")) { + entryDisconnect(player, "目标服务器当前未运行,请稍后重连。", "The destination is offline. Reconnect later."); + return; + } + String space = policy.waitingSpace().equals("login") ? loginServer : lobbyServer; + ServerView spaceStatus = api.serverStatus(space); + registry.observe(spaceStatus); + Optional waitingSpace = proxy.getServer(space); + if (!spaceStatus.ready() || waitingSpace.isEmpty()) { + entryDisconnect(player, "等待空间不可用,请联系管理员。", "The waiting space is unavailable. Contact the administrator."); + return; + } + wakeAndWaitLinked(player, target.name(), false); + if (!waiting.containsKey(player.getUniqueId())) { + entryDisconnect(player, "服务器无法自动启动,请联系管理员检查启动权限、维护状态及资源。", "Automatic startup was refused. Contact the administrator to check permissions, maintenance and capacity."); + return; + } + event.setInitialServer(waitingSpace.get()); + } catch (LinkException error) { + log.warn("Felis: entry routing failed for {}: {}", player.getUniqueId(), error.getMessage()); + entryDisconnect(player, "身份或路由服务暂不可用,请稍后重连。", "Identity or routing service is unavailable. Reconnect later."); + } + } + + private static void entryDisconnect(Player player, String zh, String en) { + player.disconnect(Component.text(FelisVelocityPlugin.zh(player) ? zh : en, NamedTextColor.RED)); } /** @@ -320,6 +404,8 @@ public final class WaitingRouter { */ @Subscribe public EventTask onServerPreConnect(ServerPreConnectEvent event) { + EntryPolicy session = entrySessions.get(event.getPlayer().getUniqueId()); + if (session != null && !session.requireAccountLink()) return null; RegisteredServer previous = event.getPreviousServer(); if (previous == null || !serverNamed(previous, loginServer)) { return null; @@ -398,6 +484,27 @@ public final class WaitingRouter { return; } + EntryPolicy policy = entrySessions.getOrDefault(id, EntryPolicy.legacy()); + if (policy.offlineAction().equals("disconnect")) { + entryDisconnect(player, "目标服务器当前未运行,请稍后重连。", "The destination is offline. Reconnect later."); + return; + } + if (policy.offlineAction().equals("fallback")) { + try { + ServerView fallback = api.serverStatus(policy.fallbackServer()); + registry.observe(fallback); + Optional destination = registry.registered(fallback.name()); + if (fallback.ready() && destination.isPresent()) { + pendingTargets.remove(id); + event.setResult(ServerPreConnectEvent.ServerResult.allowed(destination.get())); + return; + } + } catch (LinkException error) { + log.warn("Felis: fallback lookup failed: {}", error.getMessage()); + } + entryDisconnect(player, "备用服务器不可用,请稍后重连。", "The fallback server is unavailable. Reconnect later."); + return; + } pendingTargets.remove(id, targetName); event.setResult(ServerPreConnectEvent.ServerResult.allowed(event.getOriginalServer())); wakeAndWaitLinked(player, targetName, false); @@ -407,6 +514,7 @@ public final class WaitingRouter { public void onDisconnect(DisconnectEvent event) { UUID id = event.getPlayer().getUniqueId(); pendingTargets.remove(id); + entrySessions.remove(id); waiting.remove(id); lastGateNotice.remove(id); } @@ -428,6 +536,15 @@ public final class WaitingRouter { String name = event.getServer().getServerInfo().getName(); UUID id = event.getPlayer().getUniqueId(); pendingTargets.remove(id, name); + EntryPolicy session = entrySessions.get(id); + Waiter waiter = waiting.get(id); + if (session != null && !session.requireAccountLink() && waiter != null) { + event.getPlayer().sendMessage(Component.text( + FelisVelocityPlugin.zh(event.getPlayer()) + ? "正在等待「" + waiter.serverName + "」启动。服务器就绪后将自动连接。" + : "Waiting for « " + waiter.serverName + " » to start. Connection proceeds automatically when ready.", + NamedTextColor.GRAY)); + } if (!registry.isManaged(name) || name.equalsIgnoreCase(loginServer) || name.equalsIgnoreCase(lobbyServer)) { @@ -479,6 +596,8 @@ public final class WaitingRouter { continue; } Player player = po.get(); + EntryPolicy session = entrySessions.get(id); + if (session != null && !session.requireAccountLink() && player.getCurrentServer().isEmpty()) continue; boolean zh = FelisVelocityPlugin.zh(player); Optional poll = polled.get(w.serverName); if (poll == null) { @@ -486,11 +605,19 @@ public final class WaitingRouter { polled.put(w.serverName, poll); } ServerView status = poll.orElse(null); + if (status != null && status.ready() && now - w.sinceMillis > MAX_WAIT_MILLIS) { + waiting.remove(id); + disconnectAutomaticWait(player); + player.sendMessage(Component.text("服务器等待超时 / Server waiting timed out", NamedTextColor.RED)); + continue; + } if (status == null || !status.ready()) { if (status != null && !stillComing(player, zh, w, status, now)) { waiting.remove(id); + disconnectAutomaticWait(player); } else if (now > w.deadlineMillis || now - w.sinceMillis > MAX_WAIT_MILLIS) { waiting.remove(id); + disconnectAutomaticWait(player); player.sendMessage(Component.text( zh ? "「" + w.serverName + "」启动耗时超出预期。你可以稍后在大厅重试。" : "« " + w.serverName + " » is taking longer than expected to start. " @@ -505,6 +632,10 @@ public final class WaitingRouter { try { if (!linked(id)) { waiting.remove(id); + if (session != null && !session.requireAccountLink()) { + entryDisconnect(player, "此游戏身份已被禁止登录。", "This game identity is barred."); + continue; + } player.sendMessage(Component.text( zh ? "你的账户已不再绑定。请重连以重新登录。" : "Your account is no longer linked. Reconnect to sign in again.", @@ -530,6 +661,21 @@ public final class WaitingRouter { } } + private void disconnectAutomaticWait(Player player) { + disconnectAutomaticWait(player, Component.text(FelisVelocityPlugin.zh(player) + ? "服务器启动失败或等待超时,请联系管理员检查启动日志。" + : "Startup failed or waiting timed out. Contact the administrator to inspect the startup log.", + NamedTextColor.RED)); + } + + private void disconnectAutomaticWait(Player player, Component reason) { + EntryPolicy policy = entrySessions.get(player.getUniqueId()); + if (policy != null && !policy.requireAccountLink() && player.getCurrentServer() + .map(server -> serverNamed(server.getServer(), loginServer)).orElse(false)) { + player.disconnect(reason); + } + } + // poll asks felis-api how one waited-on server is doing; empty when it does not // answer, which the waiters ride out until their window closes. private Optional poll(String serverName) { @@ -851,6 +997,7 @@ public final class WaitingRouter { .append(reason.get()) : line.append(Component.text(zh ? "请重试。" : " Please try again.", NamedTextColor.RED)); player.sendMessage(line); + disconnectAutomaticWait(player, line); }); } diff --git a/plugins/velocity/test/best/lolicon/felis/velocity/Fakes.java b/plugins/velocity/test/best/lolicon/felis/velocity/Fakes.java index 42cc525..d0bb542 100644 --- a/plugins/velocity/test/best/lolicon/felis/velocity/Fakes.java +++ b/plugins/velocity/test/best/lolicon/felis/velocity/Fakes.java @@ -459,6 +459,7 @@ final class Fakes { static final class Api implements AutoCloseable { final Set linked = ConcurrentHashMap.newKeySet(); volatile boolean linkDown; + final Set barred = ConcurrentHashMap.newKeySet(); final Map ready = new ConcurrentHashMap<>(); /** address is the direct endpoint a server reports while it is ready. */ final Map address = new ConcurrentHashMap<>(); @@ -536,6 +537,12 @@ final class Fakes { } } } + String blacklist = "/api/v1/internal/player/blacklist/"; + if (path.startsWith(blacklist)) { + if (linkDown) reply(ex, 500, "{}"); + else reply(ex, 200, "{\"blacklisted\":" + barred.contains(UUID.fromString(path.substring(blacklist.length()))) + "}"); + return; + } String status = "/api/v1/internal/account/link/status/"; String servers = "/api/v1/internal/servers/"; String menuAccess = "/api/v1/internal/player/menu-access/"; diff --git a/plugins/velocity/test/best/lolicon/felis/velocity/WaitingRouterTest.java b/plugins/velocity/test/best/lolicon/felis/velocity/WaitingRouterTest.java index 6fed806..bfb4a14 100644 --- a/plugins/velocity/test/best/lolicon/felis/velocity/WaitingRouterTest.java +++ b/plugins/velocity/test/best/lolicon/felis/velocity/WaitingRouterTest.java @@ -1,6 +1,7 @@ package best.lolicon.felis.velocity; import best.lolicon.felis.link.FelisApiClient; +import best.lolicon.felis.link.EntryPolicy; import best.lolicon.felis.link.LinkConfig; import best.lolicon.felis.link.ServerView; @@ -81,12 +82,81 @@ public final class WaitingRouterTest { slowApi(); backToLobby(); disconnectAndRelease(); + automaticEntry(); } finally { api.close(); } System.out.println("WaitingRouterTest OK (" + checks + " checks)"); } + private static void automaticEntry() { + reg.refresh(servers(true)); + api.ready.put("beta", true); + router.setEntryPolicy(new EntryPolicy("direct", "beta", false, "disconnect", "login", "")); + Fakes.FakePlayer direct = player("alpha.mc.test", false); + assertEq("automatic direct ignores hostname and web linking", "beta", choose(direct)); + assertEq("automatic does not query link status", 0, api.count(LINK + direct.id)); + Fakes.FakePlayer barred = player(null, false); + api.barred.add(barred.id); + assertEq("barred automatic connection has no destination", null, choose(barred)); + assertEq("barred automatic connection explained", true, barred.disconnectedWith != null && barred.disconnectedWith.contains("barred")); + api.linkDown = true; + assertEq("identity service failure fails closed", null, choose(player(null, false))); + api.linkDown = false; + router.setEntryPolicy(new EntryPolicy("direct", "alpha", false, "fallback", "login", "beta")); + api.ready.put("alpha", false); + assertEq("offline destination uses configured fallback", "beta", choose(player(null, false))); + router.setEntryPolicy(new EntryPolicy("direct", "alpha", false, "disconnect", "login", "")); + assertEq("offline disconnect has no destination", null, choose(player(null, false))); + router.setEntryPolicy(new EntryPolicy("domain", "beta", false, "disconnect", "login", "")); + assertEq("unknown hostname uses default", "beta", choose(player("unknown.mc.test", false))); + router.setEntryPolicy(new EntryPolicy("direct", "alpha", false, "wake", "login", "")); + api.ready.put("login", true); + Fakes.FakePlayer waiting = player(null, false); + int before = api.count("POST " + SERVERS + "alpha/wake"); + assertEq("automatic startup waits in Limbo", "login", choose(waiting)); + assertEq("automatic startup requests wake", before + 1, api.count("POST " + SERVERS + "alpha/wake")); + waiting.current = login; + router.onServerConnected(new ServerConnectedEvent(waiting.player, login, null)); + assertEq("Limbo waiting immediately shows destination progress", true, waiting.said("Waiting for « alpha »")); + api.ready.put("alpha", true); + router.tick(); + assertEq("automatic wait transfers without panel account", List.of("alpha"), List.copyOf(waiting.connects)); + api.ready.put("alpha", false); + api.ready.put("login", false); + int wakes = api.count("POST " + SERVERS + "alpha/wake"); + Fakes.FakePlayer noSpace = player(null, false); + assertEq("stopped waiting space rejects connection", null, choose(noSpace)); + assertEq("stopped waiting space does not initiate startup", wakes, api.count("POST " + SERVERS + "alpha/wake")); + api.ready.put("login", true); + Fakes.FakePlayer failed = player(null, false); + assertEq("failed startup starts with a wait", "login", choose(failed)); + failed.current = login; + api.gaveUp.add("alpha"); + router.tick(); + assertEq("failed startup disconnects Limbo wait", true, failed.disconnectedWith != null); + api.gaveUp.remove("alpha"); + Fakes.FakePlayer revoked = player(null, false); + assertEq("barred wait initially enters Limbo", "login", choose(revoked)); + revoked.current = login; + api.barred.add(revoked.id); + api.ready.put("alpha", true); + router.tick(); + assertEq("blacklist revocation disconnects the wait with its reason", true, revoked.disconnectedWith != null && revoked.disconnectedWith.contains("barred")); + api.ready.put("alpha", false); + api.policy.put("alpha", "ownerOnly"); + assertEq("automatic wake cannot bypass owner-only startup", null, choose(player(null, false))); + api.policy.remove("alpha"); + api.ready.put("lobby", true); + router.setEntryPolicy(new EntryPolicy("lobby", "", false, "disconnect", "login", "")); + assertEq("lobby mode ignores a server hostname", "lobby", choose(player("beta.mc.test", false))); + net.proxy.unregisterServer(net.proxy.getServer("login").orElseThrow().getServerInfo()); + net.proxy.unregisterServer(net.proxy.getServer("lobby").orElseThrow().getServerInfo()); + router.setEntryPolicy(new EntryPolicy("direct", "beta", false, "disconnect", "login", "")); + assertEq("direct entry works without login or lobby registrations", "beta", choose(player(null, false))); + router.setEntryPolicy(EntryPolicy.legacy()); + } + // The server list the stub control plane serves. "gone" is dropped by a later // refresh; "fresh" has no backend address yet. private static List servers(boolean withGone) { @@ -858,7 +928,7 @@ public final class WaitingRouterTest { net.remove("login"); Fakes.FakePlayer stranded = player("beta.mc.test", true); PlayerChooseInitialServerEvent e = new PlayerChooseInitialServerEvent(stranded.player, null); - router.onChooseInitialServer(e); + chooseEvent(e); assertEq("no login: no initial server", false, e.getInitialServer().isPresent()); assertEq("no login: disconnected with a reason", "The Felis login gate is unavailable. Please reconnect shortly.", stranded.disconnectedWith); @@ -880,10 +950,18 @@ public final class WaitingRouterTest { // try server, login, preset) and returns the server the player will land on. private static String choose(Fakes.FakePlayer p) { PlayerChooseInitialServerEvent e = new PlayerChooseInitialServerEvent(p.player, login); - router.onChooseInitialServer(e); + chooseEvent(e); return e.getInitialServer().map(s -> s.getServerInfo().getName()).orElse(null); } + private static void chooseEvent(PlayerChooseInitialServerEvent event) { + EventTask task = router.onChooseInitialServer(event); + if (task != null) task.execute(new Continuation() { + public void resume() { } + public void resumeWithException(Throwable error) { throw new AssertionError(error); } + }); + } + // release is the login gate asking to move the player to the lobby, with the // router's async part run to completion the way Velocity's event manager would. private static ServerPreConnectEvent release(Fakes.FakePlayer p) {