feat: configure authenticated player entry routes
This commit is contained in:
24 files changed
+1044
-17
No files matched your search
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
@@ -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},
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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": "进入策略已保存。新连接将在代理读取配置后使用此策略。"
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user