diff --git a/internal/api/handlers_internal.go b/internal/api/handlers_internal.go index ab41811..f683b20 100644 --- a/internal/api/handlers_internal.go +++ b/internal/api/handlers_internal.go @@ -130,6 +130,18 @@ func (a *API) handleInternalWake(w http.ResponseWriter, r *http.Request) { a.writeLookupError(w, r, err) 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) if err != nil && !errors.Is(err, ErrNotFound) { writeError(w, r, err) diff --git a/internal/api/handlers_internal_wake_test.go b/internal/api/handlers_internal_wake_test.go index 1a64456..c1042e7 100644 --- a/internal/api/handlers_internal_wake_test.go +++ b/internal/api/handlers_internal_wake_test.go @@ -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) { api, _ := newInternalWakeAPI("public") api.WakeCooldown = time.Minute diff --git a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/WaitingRouter.java b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/WaitingRouter.java index 0c55363..f40707b 100644 --- a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/WaitingRouter.java +++ b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/WaitingRouter.java @@ -144,51 +144,35 @@ public final class WaitingRouter { } /** - * enqueueFromMenu parks 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 - * wakes it and lets {@link #tick()} transfer them when ready — the same shared - * waiting queue used by host-based autostart routing (spec §12, §27 scenario 10). - * It differs from initial-server routing only in origin: the player drove it from - * a GUI button rather than a connecting virtual host, so the waiter is flagged to - * fire {@link MenuTransferListener} on transfer. + * enqueueFromMenu moves a player who is already on the proxy (sitting in the + * lobby) to a server they asked for through the felis-paper {@code /menu}: straight + * in when it is running, otherwise woken and transferred by {@link #tick()} once + * ready — the same shared waiting queue used by host-based autostart routing (spec + * §12, §27 scenario 10). It differs from initial-server routing only in origin: the + * player drove it from a GUI button rather than a connecting virtual host, so the + * move fires {@link MenuTransferListener}. */ void enqueueFromMenu(Player player, String serverName) { - authorizeAndWait(player, serverName, true, false); + authorizeAndWait(player, serverName, true); } /** - * enqueueFromCommand parks a player who drove {@code /felis go } from chat - * onto the server they named, then wakes it and lets {@link #tick()} transfer them - * when ready — the same shared waiting queue as host-based routing and the menu - * path, differing only in that it is NOT flagged {@code fromMenu}: a command-driven - * go has no felis-paper GUI tile to notify, so no {@code TransferReady} frame is - * 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. + * enqueueFromCommand is {@link #enqueueFromMenu} for {@code /felis go } typed + * in chat, differing only in that it is NOT flagged {@code fromMenu}: a command has no + * felis-paper GUI tile to notify, so no {@code TransferReady} frame is emitted. A wake + * stays autostartPolicy-gated on the verified UUID exactly as for the other origins, + * so this adds a new entry point, not a new authority. */ 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 - * one thing: a server that is ALREADY RUNNING is joined directly instead of woken. + * enqueueFromInvite is {@link #enqueueFromCommand} for an accepted invite. An invite can + * only name the server its sender is standing on, so the target is running by + * construction and the invitee is joined straight onto it. * - *

An invite can only name the server its sender is standing on, so the target is - * running by construction — and a running felis server is already reachable by any - * linked player through {@code .}, 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. - * - *

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. - * - *

It does leave a mark, though, and one that outlives the invite: landing here fires + *

It does leave a mark, though, and one that outlives the invite: landing there fires * {@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 * 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. */ void enqueueFromInvite(Player player, String serverName) { - authorizeAndWait(player, serverName, false, true); + authorizeAndWait(player, serverName, false); } @Subscribe @@ -449,8 +433,7 @@ public final class WaitingRouter { } } - private void authorizeAndWait(Player player, String serverName, boolean fromMenu, - boolean joinIfReady) { + private void authorizeAndWait(Player player, String serverName, boolean fromMenu) { UUID id = player.getUniqueId(); boolean zh = FelisVelocityPlugin.zh(player); // 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)); return; } - // Same ready-or-wake split as the host path above, for the one caller whose - // target is running by construction. See enqueueFromInvite for why joining a - // live backend is not an escalation and waking a stopped one still is. - if (joinIfReady) { - ServerView view = registry.view(serverName); - Optional backend = registry.registered(serverName); - if (view != null && view.ready() && backend.isPresent()) { - transfer(player, serverName, backend.get()); - return; - } + if (joinIfRunning(player, serverName, fromMenu)) { + return; } 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. + * + *

A running felis server is already reachable by any linked player through + * {@code .}, 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 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 // thread. Keep the wake and queue mutation together so every entry has passed // both the account gate and the server-side autostart policy. private void wakeAndWaitLinked(Player player, String serverName, boolean fromMenu) { UUID id = player.getUniqueId(); boolean zh = FelisVelocityPlugin.zh(player); + boolean up = false; 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) { switch (e.statusCode()) { case 403: @@ -551,10 +565,12 @@ public final class WaitingRouter { return; } } - player.sendMessage(Component.text( - zh ? "正在启动「" + serverName + "」——就绪后会自动把你传送过去。" - : "Starting « " + serverName + " » — you'll be moved in automatically.", - NamedTextColor.GRAY)); + if (!up) { + player.sendMessage(Component.text( + zh ? "正在启动「" + serverName + "」——就绪后会自动把你传送过去。" + : "Starting « " + serverName + " » — you'll be moved in automatically.", + NamedTextColor.GRAY)); + } waiting.put(id, new Waiter( serverName, System.currentTimeMillis() + WAIT_TIMEOUT_MILLIS, fromMenu)); } diff --git a/plugins/velocity/test/best/lolicon/felis/velocity/WaitingRouterTest.java b/plugins/velocity/test/best/lolicon/felis/velocity/WaitingRouterTest.java index 0210d78..2ff99df 100644 --- a/plugins/velocity/test/best/lolicon/felis/velocity/WaitingRouterTest.java +++ b/plugins/velocity/test/best/lolicon/felis/velocity/WaitingRouterTest.java @@ -342,6 +342,25 @@ public final class WaitingRouterTest { 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)); + // 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. Fakes.FakePlayer there = player(null, true); 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 // 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) { + api.ready.put(name, ready); if (addr != null) { api.address.put(name, addr); }