Unverified Commit 4afabc39 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

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

parent 38430821
Loading
Loading
Loading
Loading
+12 −0
Changes for internal/api/handlers_internal.go: 12 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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)
+53 −0
Changes for internal/api/handlers_internal_wake_test.go: 53 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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
+63 −47
Changes for plugins/velocity/src/main/java/best/lolicon/felis/velocity/WaitingRouter.java: 63 added lines, 47 removed lines.
Original line number Diff line number Diff line
@@ -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 <server>} 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 <server>} 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.
     *
     * <p>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 <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
     * <p>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<RegisteredServer> backend = registry.registered(serverName);
                if (view != null && view.ready() && backend.isPresent()) {
                    transfer(player, serverName, backend.get());
            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.
     *
     * <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
    // 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;
            }
        }
        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));
    }
+22 −1
Changes for plugins/velocity/test/best/lolicon/felis/velocity/WaitingRouterTest.java: 22 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -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);
        }