feat: configure authenticated player entry routes

This commit is contained in:
Lemon-miaow committed 2026-10-07 17:19:20 +08:00
1 parent 07973a4bbd
commit 20994be996
24 files changed
+1044 -17

No files matched your search

+71
View File
@@ -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]
+46
View File
@@ -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.
+3
View File
@@ -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},
+137
View File
@@ -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)
}
@@ -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())
}
}
}
+17
View File
@@ -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<string, MockTree>;
}
@@ -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<boolean> {
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<EntryPolicySettings>(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)) {
+46
View File
@@ -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<void>((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" });
});
+1 -1
View File
@@ -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();
@@ -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<EntryPolicySettings | null>(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<EntryPolicySettings>) { setDraft((value) => ({ ...value, ...update })); setMessage(null); }
function serverSelect(field: "defaultServer" | "fallbackServer", optional = false) {
const value = draft[field];
return <Select value={value || "__unset__"} disabled={busy || fleet.loading || !!fleet.error} onValueChange={(name) => change({ [field]: name === "__unset__" ? "" : name })}>
<SelectTrigger id={`entry-${field}`}><SelectValue placeholder={t("entry_select_server")} /></SelectTrigger>
<SelectContent>
{optional && <SelectItem value="__unset__">{t("entry_no_default")}</SelectItem>}
{!optional && !value && <SelectItem value="__unset__" disabled>{t("entry_select_server")}</SelectItem>}
{value && !servers.some((server) => server.name === value) && <SelectItem value={value}>{value}</SelectItem>}
{servers.map((server) => <SelectItem key={server.name} value={server.name}>{server.displayName || server.name} · {server.name}</SelectItem>)}
</SelectContent>
</Select>;
}
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 <Card>
<CardHeader className="flex-row items-center justify-between gap-3"><CardTitle className="flex items-center gap-2"><Route className="h-4 w-4 text-primary" aria-hidden="true" />{t("entry_title")}</CardTitle><Button variant="outline" size="sm" disabled={dirty || saving || query.loading} onClick={() => { query.reload(); fleet.reload(); }}><RefreshCw />{t("platform_reload")}</Button></CardHeader>
<CardContent className="space-y-6" aria-busy={query.loading}>
<p className="text-sm text-muted-foreground">{t("entry_description")}</p>
{query.loading && <p role="status" className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 className="h-4 w-4 animate-spin" />{t("platform_loading")}</p>}
<div role="radiogroup" aria-label={t("entry_mode")} className="grid gap-3 sm:grid-cols-3" onKeyDown={(event) => {
if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(event.key) || busy) return;
event.preventDefault();
const choices = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('[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 }) => <Button key={mode} type="button" role="radio" tabIndex={draft.mode === mode ? 0 : -1} aria-checked={draft.mode === mode} variant="outline" disabled={busy} className={`h-auto justify-start whitespace-normal px-4 py-3 text-left active:scale-100 ${draft.mode === mode ? "border-primary/40 bg-primary/5 ring-1 ring-primary/20" : ""}`} onClick={() => { if (draft.mode === mode) return; change({ mode, ...(mode === "lobby" ? { defaultServer: "" } : {}), ...(mode === "direct" ? { requireAccountLink: false, offlineAction: "disconnect", waitingSpace: "login", fallbackServer: "" } : {}) }); }}>
<span className="space-y-1"><span className="flex items-center gap-2"><Icon className={draft.mode === mode ? "text-primary" : "text-muted-foreground"} aria-hidden="true" />{t(`entry_${mode}`)}</span><span className="block text-xs font-normal leading-relaxed text-muted-foreground">{t(`entry_${mode}_hint`)}</span></span>
</Button>)}
</div>
<div className="flex flex-wrap items-center gap-2 rounded-lg bg-muted/40 px-4 py-3 text-sm" aria-label={t("entry_preview")}>
<span className="flex items-center gap-2"><ShieldCheck className="h-4 w-4 text-primary" aria-hidden="true" />{t("entry_identity")}</span>{draft.requireAccountLink && <><ArrowRight className="h-4 w-4 text-muted-foreground" /><span className="flex items-center gap-2"><LogIn className="h-4 w-4 text-muted-foreground" aria-hidden="true" />{t("entry_web")}</span></>}<ArrowRight className="h-4 w-4 text-muted-foreground" /><span className="font-medium">{draft.mode === "lobby" ? t("entry_lobby") : draft.mode === "domain" ? t("entry_domain_target") : draft.defaultServer || t("entry_select_server")}</span>
</div>
<div className="grid gap-6 md:grid-cols-2">
{draft.mode !== "lobby" && <div className="space-y-2"><Label htmlFor="entry-defaultServer">{t(draft.mode === "direct" ? "entry_main" : "entry_default")}</Label>{serverSelect("defaultServer", draft.mode === "domain")}<p className="text-xs leading-relaxed text-muted-foreground">{t(draft.mode === "direct" ? "entry_main_hint" : "entry_default_hint")}</p></div>}
<div className="space-y-2"><Label htmlFor="entry-offline">{t("entry_offline")}</Label><Select value={draft.offlineAction} disabled={busy} onValueChange={(value) => change({ offlineAction: value as EntryPolicySettings["offlineAction"], ...(value !== "fallback" ? { fallbackServer: "" } : {}) })}><SelectTrigger id="entry-offline"><SelectValue /></SelectTrigger><SelectContent>{(["wake", "fallback", "disconnect"] as const).map((action) => <SelectItem key={action} value={action}>{t(`entry_${action}`)}</SelectItem>)}</SelectContent></Select></div>
{draft.offlineAction === "fallback" && <div className="space-y-2"><Label htmlFor="entry-fallbackServer">{t("entry_fallback_server")}</Label>{serverSelect("fallbackServer")}</div>}
{draft.offlineAction === "wake" && <div className="space-y-2"><Label htmlFor="entry-waiting">{t("entry_waiting")}</Label><Select value={draft.waitingSpace} disabled={busy || draft.requireAccountLink} onValueChange={(value) => change({ waitingSpace: value as EntryPolicySettings["waitingSpace"] })}><SelectTrigger id="entry-waiting"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="login">{t("entry_wait_login")}</SelectItem><SelectItem value="lobby">{t("entry_lobby")}</SelectItem></SelectContent></Select><p className="text-xs leading-relaxed text-muted-foreground">{t(draft.requireAccountLink ? "entry_web_wait" : "entry_wake_hint")}</p></div>}
</div>
<div className="flex items-start justify-between gap-4 border-t border-border pt-5"><div className="space-y-1"><Label htmlFor="entry-link">{t("entry_link")}</Label><p className="text-xs leading-relaxed text-muted-foreground">{t("entry_link_hint")}</p></div><Switch id="entry-link" checked={draft.requireAccountLink} disabled={busy} onCheckedChange={(checked) => change({ requireAccountLink: checked, ...(checked ? { waitingSpace: "lobby" } : {}) })} /></div>
<p className="text-xs leading-relaxed text-muted-foreground">{t("entry_components")} <Link className="font-medium text-primary hover:underline" to="/admin/lobby">{t("entry_manage_spaces")}</Link></p>
{query.error != null && <MessageLine kind="error" message={humanizeError(query.error)} />}
{fleet.error != null && <MessageLine kind="error" message={humanizeError(fleet.error)} />}
{message && <MessageLine kind={message.kind} message={message.text} />}
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border pt-4"><p className="text-xs text-muted-foreground">{t("entry_scope")}</p><div className="flex gap-2">{dirty && <Button variant="outline" disabled={saving} onClick={() => { if (saved) setDraft(saved); setMessage(null); }}>{t("platform_discard")}</Button>}<Button disabled={busy || !dirty || !valid} onClick={() => void save()}>{saving ? <Loader2 className="animate-spin" /> : <Save />}{t("entry_save")}</Button></div></div>
{reauth.dialog}
</CardContent>
</Card>;
}
+37 -2
View File
@@ -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."
}
+37 -2
View File
@@ -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": "进入策略已保存。新连接将在代理读取配置后使用此策略。"
}
+4
View File
@@ -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<EntryPolicySettings>("GET", "/settings/entry-policy"),
setEntryPolicy: (policy: EntryPolicySettings) => request<EntryPolicySettings>("PUT", "/settings/entry-policy", policy),
getWakePolicy: () => request<WakePolicySettings>("GET", "/settings/wake-policy"),
setWakePolicy: (policy: Omit<WakePolicySettings, "managed">) => request<WakePolicySettings>("PUT", "/settings/wake-policy", policy),
getAuthSources: () => request<AuthSourcesSettings>("GET", "/settings/auth-sources"),
+142
View File
@@ -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;
+10
View File
@@ -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;
}
@@ -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<typeof import("@/lib/api")>(), api: calls }));
const runtime = vi.hoisted(() => ({ distributed: false, fallback: false, apiBase: "/api/v1", rootDomain: "example.test" }));
vi.mock("@/lib/hooks", async (original) => ({ ...await original<typeof import("@/lib/hooks")>(), 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" });
@@ -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 <div className="space-y-6">
<PageHeader icon={Settings} title={t("platform_title")} subtitle={t("platform_subtitle")} actions={<Button variant="outline" size="sm" disabled={dirty || busy} onClick={query.reload}><RefreshCw />{t("platform_reload")}</Button>} />
<PlayerEntrySettings />
<Card><CardHeader><CardTitle>{t("platform_wake_title")}</CardTitle></CardHeader><CardContent className="space-y-6">
{query.loading && <p role="status" className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 className="h-4 w-4 animate-spin" />{t("platform_loading")}</p>}
<div className="grid gap-6 md:grid-cols-2">
@@ -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
@@ -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<String, AtomicInteger> 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;
@@ -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 : "";
}
}
@@ -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<ServerView> listServers() throws LinkException {
return ServerView.listFrom(getObject("/api/v1/servers", 200));
@@ -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) {
@@ -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<UUID, EntryPolicy> entrySessions = new ConcurrentHashMap<>();
void setEntryPolicy(EntryPolicy policy) { this.entryPolicy = policy; }
private final LinkGate links;
private final Map<UUID, Waiter> waiting = new ConcurrentHashMap<>();
private final Map<UUID, String> 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<String> 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<ServerView> targetOpt = registry.resolveByHost(host.get());
entrySessions.put(id, policy);
pendingTargets.remove(id); // a reconnect must never inherit an earlier host
Optional<ServerView> 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<RegisteredServer> 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.<root-domain> 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<ServerView> policyTarget(Player player, EntryPolicy policy) {
if (policy.mode().equals("lobby")) return Optional.ofNullable(registry.view(lobbyServer));
if (policy.mode().equals("domain")) {
Optional<ServerView> 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<RegisteredServer> 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<RegisteredServer> 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<ServerView> 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<ServerView> 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);
});
}
@@ -459,6 +459,7 @@ final class Fakes {
static final class Api implements AutoCloseable {
final Set<UUID> linked = ConcurrentHashMap.newKeySet();
volatile boolean linkDown;
final Set<UUID> barred = ConcurrentHashMap.newKeySet();
final Map<String, Boolean> ready = new ConcurrentHashMap<>();
/** address is the direct endpoint a server reports while it is ready. */
final Map<String, String> 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/";
@@ -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<ServerView> 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) {