fix(velocity): 菜单加入和 /felis go 先查实时状态,运行中的服直接进入,内部 wake 对已运行服不再做启动权限校验

This commit is contained in:
Lemon-miaow committed 2026-09-26 21:21:59 +08:00
1 parent 3843082153
commit 4afabc390d
4 files changed
+155 -53

No files matched your search

+12
View File
@@ -130,6 +130,18 @@ func (a *API) handleInternalWake(w http.ResponseWriter, r *http.Request) {
a.writeLookupError(w, r, err) a.writeLookupError(w, r, err)
return return
} }
// A server that is up and meant to stay up has nothing to wake, and joining it
// is open to every linked player: host routing admits them on the link check
// alone. Putting the no-op through autostartPolicy answered a friend's menu
// "join" with "you may not start this server". Nothing changes here, so there
// is no cooldown to spend and nothing to audit.
if info.Ready && info.DesiredState == string(v1alpha1.DesiredRunning) {
writeJSON(w, http.StatusAccepted, map[string]any{
"name": name, "desiredState": info.DesiredState,
"phase": info.Phase, "ready": true,
})
return
}
rec, err := a.Repo.ServerByName(r.Context(), name) rec, err := a.Repo.ServerByName(r.Context(), name)
if err != nil && !errors.Is(err, ErrNotFound) { if err != nil && !errors.Is(err, ErrNotFound) {
writeError(w, r, err) writeError(w, r, err)
@@ -153,6 +153,59 @@ func TestInternalWakeAutostartGate(t *testing.T) {
}) })
} }
// A running server is joined, not woken: the menu and /felis go wake before they
// move anyone, and a friend who is not the owner of a running ownerOnly server was
// told "you may not start it". Only an up-and-staying-up server skips the gate; one
// that is on its way down is a real start and stays policy-gated.
func TestInternalWakeOfRunningServerIsNotGated(t *testing.T) {
body := `{"mc_uuid":"` + wakeUUID + `"}`
running := func(desired v1alpha1.DesiredState) (*API, *fakeCluster, *fakeRepo) {
api, cl := newInternalWakeAPI("ownerOnly")
cl.byName["survival"] = &ServerInfo{Name: "survival", Phase: "Running", Ready: true,
AutostartPolicy: "ownerOnly", DesiredState: string(desired)}
repo := api.Repo.(*fakeRepo)
repo.byName["survival"] = &ServerRecord{Name: "survival", OwnerID: "owner1"}
repo.links[wakeUUID] = "someone-else"
api.WakeCooldown = time.Minute
return api, cl, repo
}
t.Run("up: a non-owner gets 202 ready and nothing changes", func(t *testing.T) {
api, cl, repo := running(v1alpha1.DesiredRunning)
w := internalWake(api, body)
if w.Code != http.StatusAccepted {
t.Fatalf("code = %d, want 202 (body %s)", w.Code, w.Body.String())
}
var got map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("body not JSON: %v (%s)", err, w.Body.String())
}
if got["ready"] != true || got["phase"] != "Running" {
t.Fatalf("reply = %v, want ready true and phase Running", got)
}
if _, set := cl.desired["survival"]; set {
t.Fatal("a no-op wake must not write desiredState")
}
if len(repo.audits) != 0 {
t.Fatalf("a no-op wake must not be audited as a wake: %+v", repo.audits)
}
// Nothing was woken, so the cooldown is untouched: a second join is not a 429.
if w := internalWake(api, body); w.Code != http.StatusAccepted {
t.Fatalf("second join code = %d, want 202", w.Code)
}
})
t.Run("stopping: a non-owner's wake is still a start and still forbidden", func(t *testing.T) {
api, cl, _ := running(v1alpha1.DesiredStopped)
if w := internalWake(api, body); w.Code != http.StatusForbidden {
t.Fatalf("code = %d, want 403", w.Code)
}
if _, set := cl.desired["survival"]; set {
t.Fatal("desiredState must not change on a forbidden wake")
}
})
}
func TestInternalWakeCooldownIsShared(t *testing.T) { func TestInternalWakeCooldownIsShared(t *testing.T) {
api, _ := newInternalWakeAPI("public") api, _ := newInternalWakeAPI("public")
api.WakeCooldown = time.Minute api.WakeCooldown = time.Minute
@@ -144,51 +144,35 @@ public final class WaitingRouter {
} }
/** /**
* enqueueFromMenu parks a player who is already on the proxy (sitting in the * enqueueFromMenu moves a player who is already on the proxy (sitting in the
* lobby) on a server they asked for through the felis-paper {@code /menu}, then * lobby) to a server they asked for through the felis-paper {@code /menu}: straight
* wakes it and lets {@link #tick()} transfer them when ready — the same shared * in when it is running, otherwise woken and transferred by {@link #tick()} once
* waiting queue used by host-based autostart routing (spec §12, §27 scenario 10). * ready — the same shared waiting queue used by host-based autostart routing (spec
* It differs from initial-server routing only in origin: the player drove it from * §12, §27 scenario 10). It differs from initial-server routing only in origin: the
* a GUI button rather than a connecting virtual host, so the waiter is flagged to * player drove it from a GUI button rather than a connecting virtual host, so the
* fire {@link MenuTransferListener} on transfer. * move fires {@link MenuTransferListener}.
*/ */
void enqueueFromMenu(Player player, String serverName) { void enqueueFromMenu(Player player, String serverName) {
authorizeAndWait(player, serverName, true, false); authorizeAndWait(player, serverName, true);
} }
/** /**
* enqueueFromCommand parks a player who drove {@code /felis go <server>} from chat * enqueueFromCommand is {@link #enqueueFromMenu} for {@code /felis go <server>} typed
* onto the server they named, then wakes it and lets {@link #tick()} transfer them * in chat, differing only in that it is NOT flagged {@code fromMenu}: a command has no
* when ready — the same shared waiting queue as host-based routing and the menu * felis-paper GUI tile to notify, so no {@code TransferReady} frame is emitted. A wake
* path, differing only in that it is NOT flagged {@code fromMenu}: a command-driven * stays autostartPolicy-gated on the verified UUID exactly as for the other origins,
* go has no felis-paper GUI tile to notify, so no {@code TransferReady} frame is * so this adds a new entry point, not a new authority.
* emitted on readiness. The wake stays autostartPolicy-gated on the verified UUID
* exactly as the other origins, so this adds a new entry point, not a new authority.
*/ */
void enqueueFromCommand(Player player, String serverName) { void enqueueFromCommand(Player player, String serverName) {
authorizeAndWait(player, serverName, false, false); authorizeAndWait(player, serverName, false);
} }
/** /**
* enqueueFromInvite is {@link #enqueueFromCommand} for an accepted invite, differing in * enqueueFromInvite is {@link #enqueueFromCommand} for an accepted invite. An invite can
* one thing: a server that is ALREADY RUNNING is joined directly instead of woken. * only name the server its sender is standing on, so the target is running by
* construction and the invitee is joined straight onto it.
* *
* <p>An invite can only name the server its sender is standing on, so the target is * <p>It does leave a mark, though, and one that outlives the invite: landing there fires
* running by construction — and a running felis server is already reachable by any
* linked player through {@code <name>.<root-domain>}, which
* {@link #onServerPreConnect} admits on the link check alone: no wake, no
* autostartPolicy consultation. Routing an accept through {@link #wakeAndWaitLinked}
* instead asks the API to wake a server that needs no waking, and autostartPolicy
* defaults to ownerOnly, so the API answers 403 and the invitee is turned away from a
* place they could have walked into unaided — the green button does nothing for
* exactly the people you would invite.
*
* <p>Joining a live backend therefore grants no authority the invitee did not already
* have. WAKING a stopped one still does, which is why the not-ready case falls through
* to the policy-gated path unchanged: only the owner may start a stopped ownerOnly
* server, invite or no invite.
*
* <p>It does leave a mark, though, and one that outlives the invite: landing here fires
* {@link #onServerConnected}, whose join-event appends the player to the server's * {@link #onServerConnected}, whose join-event appends the player to the server's
* allowlist. On an autostartPolicy=allowlist server that row is the wake permission, so * allowlist. On an autostartPolicy=allowlist server that row is the wake permission, so
* an accepted invite ends in the invitee being able to start the server later. That is * an accepted invite ends in the invitee being able to start the server later. That is
@@ -197,7 +181,7 @@ public final class WaitingRouter {
* up front (FelisVelocityPlugin#accessNotice), because they are the one causing it. * up front (FelisVelocityPlugin#accessNotice), because they are the one causing it.
*/ */
void enqueueFromInvite(Player player, String serverName) { void enqueueFromInvite(Player player, String serverName) {
authorizeAndWait(player, serverName, false, true); authorizeAndWait(player, serverName, false);
} }
@Subscribe @Subscribe
@@ -449,8 +433,7 @@ public final class WaitingRouter {
} }
} }
private void authorizeAndWait(Player player, String serverName, boolean fromMenu, private void authorizeAndWait(Player player, String serverName, boolean fromMenu) {
boolean joinIfReady) {
UUID id = player.getUniqueId(); UUID id = player.getUniqueId();
boolean zh = FelisVelocityPlugin.zh(player); boolean zh = FelisVelocityPlugin.zh(player);
// Asking for the server you are standing on is a no-op, and it has to be caught // Asking for the server you are standing on is a no-op, and it has to be caught
@@ -485,29 +468,60 @@ public final class WaitingRouter {
NamedTextColor.RED)); NamedTextColor.RED));
return; return;
} }
// Same ready-or-wake split as the host path above, for the one caller whose if (joinIfRunning(player, serverName, fromMenu)) {
// target is running by construction. See enqueueFromInvite for why joining a return;
// live backend is not an escalation and waking a stopped one still is.
if (joinIfReady) {
ServerView view = registry.view(serverName);
Optional<RegisteredServer> backend = registry.registered(serverName);
if (view != null && view.ready() && backend.isPresent()) {
transfer(player, serverName, backend.get());
return;
}
} }
wakeAndWaitLinked(player, serverName, fromMenu); wakeAndWaitLinked(player, serverName, fromMenu);
}); });
} }
/**
* joinIfRunning is the ready-or-wake split of the host path, for the entries that
* start from inside the proxy: a server a fresh status poll reports up is joined
* directly, at the address the poll carries.
*
* <p>A running felis server is already reachable by any linked player through
* {@code <name>.<root-domain>}, which {@link #onServerPreConnect} admits on the link
* check alone, so joining one grants nothing. Waking it instead asks the API to
* start a server that needs no starting, and autostartPolicy defaults to ownerOnly:
* a friend's green "join" tile answered "you're not allowed to start it". WAKING a
* stopped server is still a start, and still policy-gated. A poll that fails falls
* through to the wake, which reports the failure its own way.
*/
private boolean joinIfRunning(Player player, String serverName, boolean fromMenu) {
ServerView status;
try {
status = api.serverStatus(serverName);
} catch (LinkException e) {
return false;
}
if (!status.ready()) {
return false;
}
registry.observe(status);
Optional<RegisteredServer> backend = registry.registered(serverName);
if (backend.isEmpty()) {
return false; // up, but not listed or addressed yet → the queue waits for it
}
MenuTransferListener listener = menuListener;
if (fromMenu && listener != null) {
listener.onReady(player, serverName);
}
transfer(player, serverName, backend.get());
return true;
}
// Caller already ran the authoritative link-status check and is off the event // Caller already ran the authoritative link-status check and is off the event
// thread. Keep the wake and queue mutation together so every entry has passed // thread. Keep the wake and queue mutation together so every entry has passed
// both the account gate and the server-side autostart policy. // both the account gate and the server-side autostart policy.
private void wakeAndWaitLinked(Player player, String serverName, boolean fromMenu) { private void wakeAndWaitLinked(Player player, String serverName, boolean fromMenu) {
UUID id = player.getUniqueId(); UUID id = player.getUniqueId();
boolean zh = FelisVelocityPlugin.zh(player); boolean zh = FelisVelocityPlugin.zh(player);
boolean up = false;
try { try {
api.wake(serverName, id); // An up server answers ready without waking anything; the queue still
// does the move, so the player just is not told it is starting.
up = api.wake(serverName, id).ready();
} catch (LinkException e) { } catch (LinkException e) {
switch (e.statusCode()) { switch (e.statusCode()) {
case 403: case 403:
@@ -551,10 +565,12 @@ public final class WaitingRouter {
return; return;
} }
} }
player.sendMessage(Component.text( if (!up) {
zh ? "正在启动「" + serverName + "」——就绪后会自动把你传送过去。" player.sendMessage(Component.text(
: "Starting « " + serverName + " » — you'll be moved in automatically.", zh ? "正在启动「" + serverName + "」——就绪后会自动把你传送过去。"
NamedTextColor.GRAY)); : "Starting « " + serverName + " » — you'll be moved in automatically.",
NamedTextColor.GRAY));
}
waiting.put(id, new Waiter( waiting.put(id, new Waiter(
serverName, System.currentTimeMillis() + WAIT_TIMEOUT_MILLIS, fromMenu)); serverName, System.currentTimeMillis() + WAIT_TIMEOUT_MILLIS, fromMenu));
} }
@@ -342,6 +342,25 @@ public final class WaitingRouterTest {
assertEq("both moved (command)", List.of("epsilon"), List.copyOf(command.connects)); assertEq("both moved (command)", List.of("epsilon"), List.copyOf(command.connects));
assertEq("only the menu entry tells the lobby", List.of(menu.name + "@epsilon"), List.copyOf(notified)); assertEq("only the menu entry tells the lobby", List.of(menu.name + "@epsilon"), List.copyOf(notified));
// A friend's running server: the menu and /felis go join it without waking it.
// The API refuses a non-owner's wake of an ownerOnly server, and that refusal
// was all a friend got from the green tile while every entry woke first.
api.wakeError.put("beta", "403 forbidden");
Fakes.FakePlayer friend = player(null, true);
friend.current = lobby;
router.enqueueFromMenu(friend.player, "beta");
Fakes.await("friend moved (menu)", () -> friend.connects.size() == 1);
assertEq("running server via the menu: joined", List.of("beta"), List.copyOf(friend.connects));
assertEq("running server via the menu: the lobby is told", true, notified.contains(friend.name + "@beta"));
Fakes.FakePlayer friend2 = player(null, true);
friend2.current = lobby;
router.enqueueFromCommand(friend2.player, "beta");
Fakes.await("friend moved (command)", () -> friend2.connects.size() == 1);
assertEq("running server via /felis go: joined", List.of("beta"), List.copyOf(friend2.connects));
assertEq("running server: never woken", 0, api.count("POST " + SERVERS + "beta/wake"));
assertEq("running server: nobody refused", false, friend.said("not allowed") || friend2.said("not allowed"));
api.wakeError.remove("beta");
// Asking for the server you stand on is answered at once, case-insensitively. // Asking for the server you stand on is answered at once, case-insensitively.
Fakes.FakePlayer there = player(null, true); Fakes.FakePlayer there = player(null, true);
there.current = beta; there.current = beta;
@@ -498,8 +517,10 @@ public final class WaitingRouterTest {
// view is a server as GET /servers lists it: up at its direct address, or down on // view is a server as GET /servers lists it: up at its direct address, or down on
// the fallback, where the operator writes the fallback server's name ("login") // the fallback, where the operator writes the fallback server's name ("login")
// into the address. addr is also what the stub API reports once the server is up. // into the address. The stub API's status route answers the same: ready as listed,
// and addr as the address it reports once the server is up.
private static ServerView view(String name, boolean ready, String addr) { private static ServerView view(String name, boolean ready, String addr) {
api.ready.put(name, ready);
if (addr != null) { if (addr != null) {
api.address.put(name, addr); api.address.put(name, addr);
} }