diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index f7b00ac..64ea5da 100644 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -1806,6 +1806,12 @@ try = ["${LOGIN_SERVER}"] [advanced] haproxy-protocol = false +# Off on purpose. Velocity answers bungeecord:main itself, before any plugin event, so +# with it on EVERY backend — including each user's own server and whatever plugins its +# owner installed — can KickPlayer or ConnectOther anyone on the network, and no plugin +# can restrict that to one server. The login gate releases players over felis:control +# instead, which felis-velocity accepts only from the login server. +bungee-plugin-message-channel = false [query] enabled = false diff --git a/plugins/shared/src/main/java/best/lolicon/felis/link/Control.java b/plugins/shared/src/main/java/best/lolicon/felis/link/Control.java index e8d2621..7ff3e65 100644 --- a/plugins/shared/src/main/java/best/lolicon/felis/link/Control.java +++ b/plugins/shared/src/main/java/best/lolicon/felis/link/Control.java @@ -1,6 +1,8 @@ package best.lolicon.felis.link; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; import java.util.Map; /** @@ -65,6 +67,22 @@ public final class Control { kv(sb, "server", frame.server()); } break; + case ControlFrame.LIST_REQUEST: + break; + case ControlFrame.LIST_UPDATE: + sb.append(",\"servers\":["); + List names = frame.servers(); + for (int i = 0; i < names.size(); i++) { + if (i > 0) { + sb.append(','); + } + jsonString(sb, names.get(i)); + } + sb.append(']'); + break; + case ControlFrame.LOGIN_RELEASE: + kv(sb, "player", frame.player()); + break; default: throw new IllegalArgumentException("control: cannot encode unknown frame type '" + frame.type() + "'"); } @@ -107,6 +125,12 @@ public final class Control { return ControlFrame.transferReady(str(o, "player"), str(o, "server")); case ControlFrame.ERROR: return ControlFrame.error(str(o, "code"), str(o, "message"), str(o, "server")); + case ControlFrame.LIST_REQUEST: + return ControlFrame.listRequest(); + case ControlFrame.LIST_UPDATE: + return ControlFrame.listUpdate(strList(o, "servers")); + case ControlFrame.LOGIN_RELEASE: + return ControlFrame.loginRelease(str(o, "player")); default: throw new IllegalArgumentException("control: unknown frame type '" + type + "'"); } @@ -175,6 +199,21 @@ public final class Control { return v instanceof String ? (String) v : null; } + // strList keeps the string entries of an array field and skips anything else, so a + // partly malformed list still yields the names that are well-formed. + private static List strList(Map o, String key) { + List out = new ArrayList<>(); + Object v = o.get(key); + if (v instanceof List) { + for (Object e : (List) v) { + if (e instanceof String) { + out.add((String) e); + } + } + } + return out; + } + private static boolean bool(Map o, String key) { Object v = o.get(key); return v instanceof Boolean && (Boolean) v; diff --git a/plugins/shared/src/main/java/best/lolicon/felis/link/ControlFrame.java b/plugins/shared/src/main/java/best/lolicon/felis/link/ControlFrame.java index 465239d..96935c1 100644 --- a/plugins/shared/src/main/java/best/lolicon/felis/link/ControlFrame.java +++ b/plugins/shared/src/main/java/best/lolicon/felis/link/ControlFrame.java @@ -1,5 +1,8 @@ package best.lolicon.felis.link; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import java.util.Objects; /** @@ -11,16 +14,26 @@ import java.util.Objects; * is just the immutable value, source-shared into both the velocity and paper jars * so the two ends can never drift on field names. * - *

There are six frame types, discriminated by {@link #type()}: + *

There are nine frame types, discriminated by {@link #type()}: *

    - *
  • Upstream (lobby → velocity): {@link #WAKE_REQUEST} and + *
  • Upstream from the lobby: {@link #WAKE_REQUEST} and * {@link #CLAIM_REQUEST} carry {@code player}+{@code server}; - * {@link #STATUS_QUERY} carries {@code server}.
  • + * {@link #STATUS_QUERY} carries {@code server}; {@link #LIST_REQUEST} carries + * nothing. + *
  • Upstream from the login gate: {@link #LOGIN_RELEASE} carries nothing + * but the informational {@code player}. It replaces the BungeeCord + * {@code Connect} the gate used to send, so {@code bungeecord:main} can be + * switched off proxy-wide.
  • *
  • Downstream (velocity → lobby): {@link #STATUS_UPDATE} is the tile - * projection; {@link #TRANSFER_READY} tells the lobby a parked player's - * backend is up; {@link #ERROR} reports a refusal.
  • + * projection; {@link #LIST_UPDATE} is the set of tiles to show; + * {@link #TRANSFER_READY} tells the lobby a parked player's backend is up; + * {@link #ERROR} reports a refusal. *
* + *

Which upstream types a backend may send is decided by the proxy from the + * connection they arrive on (the lobby's set, or the login gate's single type); a + * frame from any other backend is dropped whatever its type. + * *

The {@code player} field is informational only on the upstream frames: * Velocity derives the real identity from the {@code ServerConnection} the message * arrived on, never from this field, so a compromised backend cannot act as another @@ -50,6 +63,12 @@ public final class ControlFrame { public static final String TRANSFER_READY = "TransferReady"; /** Downstream: a refusal (code, message, optional server). */ public static final String ERROR = "Error"; + /** Upstream (lobby): ask for the current tile list; answered by {@link #LIST_UPDATE}. */ + public static final String LIST_REQUEST = "ListRequest"; + /** Downstream: the user servers the lobby should show, in display order (servers). */ + public static final String LIST_UPDATE = "ListUpdate"; + /** Upstream (login gate): the player finished signing in; move them to the lobby (player). */ + public static final String LOGIN_RELEASE = "LoginRelease"; private final String type; private final String player; @@ -61,9 +80,16 @@ public final class ControlFrame { private final boolean claimable; private final String code; private final String message; + private final List servers; private ControlFrame(String type, String player, String server, String phase, boolean ready, int playersOnline, int playersMax, boolean claimable, String code, String message) { + this(type, player, server, phase, ready, playersOnline, playersMax, claimable, code, message, List.of()); + } + + private ControlFrame(String type, String player, String server, String phase, boolean ready, + int playersOnline, int playersMax, boolean claimable, String code, String message, + List servers) { this.type = type; this.player = player; this.server = server; @@ -74,6 +100,7 @@ public final class ControlFrame { this.claimable = claimable; this.code = code; this.message = message; + this.servers = servers; } // ---- factories (tolerant: no field validation, so decode can always rebuild) ---- @@ -104,6 +131,28 @@ public final class ControlFrame { return new ControlFrame(ERROR, null, server, null, false, 0, 0, false, code, message); } + public static ControlFrame listRequest() { + return new ControlFrame(LIST_REQUEST, null, null, null, false, 0, 0, false, null, null); + } + + /** listUpdate carries the tile names; null entries are dropped, the list is copied. */ + public static ControlFrame listUpdate(List servers) { + List copy = new ArrayList<>(); + if (servers != null) { + for (String s : servers) { + if (s != null) { + copy.add(s); + } + } + } + return new ControlFrame(LIST_UPDATE, null, null, null, false, 0, 0, false, null, null, + Collections.unmodifiableList(copy)); + } + + public static ControlFrame loginRelease(String player) { + return new ControlFrame(LOGIN_RELEASE, player, null, null, false, 0, 0, false, null, null); + } + // ---- accessors ---- public String type() { @@ -146,6 +195,11 @@ public final class ControlFrame { return message; } + /** servers is the {@link #LIST_UPDATE} payload; empty (never null) on every other type. */ + public List servers() { + return servers; + } + @Override public boolean equals(Object o) { if (this == o) { @@ -164,12 +218,14 @@ public final class ControlFrame { && Objects.equals(server, f.server) && Objects.equals(phase, f.phase) && Objects.equals(code, f.code) - && Objects.equals(message, f.message); + && Objects.equals(message, f.message) + && Objects.equals(servers, f.servers); } @Override public int hashCode() { - return Objects.hash(type, player, server, phase, ready, playersOnline, playersMax, claimable, code, message); + return Objects.hash(type, player, server, phase, ready, playersOnline, playersMax, claimable, code, message, + servers); } @Override diff --git a/plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java b/plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java index 151313e..25c2167 100644 --- a/plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java +++ b/plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java @@ -2,14 +2,17 @@ package best.lolicon.felis.link; import java.io.IOException; import java.net.URI; +import java.net.URLEncoder; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.UUID; +import java.util.regex.Pattern; /** * FelisApiClient is the proxy's read/drive client for the felis-api internal face @@ -27,8 +30,21 @@ import java.util.UUID; * refused this UUID (do not enqueue the player); 429 means a wake is already * cooling down ("already waking, keep waiting"); 503 means the cluster is at * capacity (tell the player to try later — nothing is coming up). + * + *

Path safety. Server names reach this client from plugin messages and + * chat, so every value spliced into a request path goes through + * {@link #serverSegment} or {@link #segment}. A name outside the platform's own + * alphabet ({@code ^[a-z0-9-]{3,32}$}, no leading or trailing dash — the rule + * {@code naming.ValidateServerName} enforces server-side) is refused before any + * request is built, and what passes is percent-encoded as well. Without this a + * WakeRequest for {@code victim/join-event?} became {@code POST + * …/servers/victim/join-event}, which appends the sender to another tenant's + * allowlist. */ public final class FelisApiClient { + // Mirrors internal/naming.serverNameRE plus its no-leading/trailing-dash rule. + private static final Pattern SERVER_NAME = Pattern.compile("^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$"); + private final LinkConfig config; private final HttpClient http; @@ -56,7 +72,7 @@ public final class FelisApiClient { /** serverStatus reads one server's current lifecycle view (internal status). */ public ServerView serverStatus(String name) throws LinkException { - return ServerView.fromJson(getObject("/api/v1/internal/servers/" + Objects.requireNonNull(name, "name") + "/status", 200)); + return ServerView.fromJson(getObject("/api/v1/internal/servers/" + serverSegment(name) + "/status", 200)); } /** @@ -70,7 +86,7 @@ public final class FelisApiClient { Objects.requireNonNull(name, "name"); Objects.requireNonNull(mcUuid, "mcUuid"); String body = "{\"mc_uuid\":\"" + mcUuid + "\"}"; - return ServerView.fromJson(postObject("/api/v1/internal/servers/" + name + "/wake", body, 202)); + return ServerView.fromJson(postObject("/api/v1/internal/servers/" + serverSegment(name) + "/wake", body, 202)); } /** @@ -82,7 +98,7 @@ public final class FelisApiClient { Objects.requireNonNull(name, "name"); Objects.requireNonNull(mcUuid, "mcUuid"); String body = "{\"mc_uuid\":\"" + mcUuid + "\"}"; - HttpResponse res = send(post("/api/v1/internal/servers/" + name + "/join-event", body)); + HttpResponse res = send(post("/api/v1/internal/servers/" + serverSegment(name) + "/join-event", body)); int status = res.statusCode(); if (status != 204 && status != 200) { throw parseError(status, res.body()); @@ -103,7 +119,7 @@ public final class FelisApiClient { Objects.requireNonNull(name, "name"); Objects.requireNonNull(mcUuid, "mcUuid"); String body = "{\"mc_uuid\":\"" + mcUuid + "\"}"; - Map res = postObject("/api/v1/internal/servers/" + name + "/claim", body, 200); + Map res = postObject("/api/v1/internal/servers/" + serverSegment(name) + "/claim", body, 200); Object claimed = res.get("claimed"); if (!(claimed instanceof Boolean) || !((Boolean) claimed)) { // A 200 that doesn't affirm the claim is a contract breach, not a refusal — @@ -122,7 +138,7 @@ public final class FelisApiClient { */ public MenuStatus menuStatus(String name) throws LinkException { Objects.requireNonNull(name, "name"); - return MenuStatus.fromJson(getObject("/api/v1/internal/servers/" + name + "/menu", 200)); + return MenuStatus.fromJson(getObject("/api/v1/internal/servers/" + serverSegment(name) + "/menu", 200)); } /** @@ -173,15 +189,15 @@ public final class FelisApiClient { * {@link LinkException}s; a 200 that does not affirm {@code approved:true} is a * contract breach, not a refusal. * - *

{@code requestId} is interpolated into the request path, so the caller must - * pass a validated opaque handle (the 32-hex id minted by op-login start) — never - * unsanitised chat input. The Velocity command validates the charset first. + *

{@code requestId} is interpolated into the request path. It is + * percent-encoded here, and the Velocity command also validates its charset + * before calling. */ public void opLoginApprove(String requestId, UUID approverUuid) throws LinkException { Objects.requireNonNull(requestId, "requestId"); Objects.requireNonNull(approverUuid, "approverUuid"); String body = "{\"approver_uuid\":\"" + approverUuid + "\"}"; - Map res = postObject("/api/v1/internal/op-login/" + requestId + "/approve", body, 200); + Map res = postObject("/api/v1/internal/op-login/" + segment(requestId) + "/approve", body, 200); Object approved = res.get("approved"); if (!(approved instanceof Boolean) || !((Boolean) approved)) { throw new LinkException(200, "bad_response", "approve returned 200 without approved=true"); @@ -213,6 +229,29 @@ public final class FelisApiClient { // ---- transport ---- + /** + * serverSegment admits {@code name} into a request path only when it is a + * well-formed Felis server name. The refusal carries a 400 so callers that relay + * {@code LinkException} messages to a player treat it as a request error, and it + * does not echo the input back. + */ + static String serverSegment(String name) throws LinkException { + Objects.requireNonNull(name, "name"); + if (!SERVER_NAME.matcher(name).matches()) { + throw new LinkException(400, "invalid_server_name", "not a valid Felis server name"); + } + return name; + } + + /** segment percent-encodes one opaque path segment; "." and ".." are refused. */ + static String segment(String value) throws LinkException { + Objects.requireNonNull(value, "value"); + if (value.isEmpty() || value.equals(".") || value.equals("..")) { + throw new LinkException(400, "invalid_path_segment", "not a valid request path segment"); + } + return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); + } + private Map getObject(String path, int expect) throws LinkException { HttpRequest req = base(path).GET().build(); return expectObject(send(req), expect); @@ -222,16 +261,25 @@ public final class FelisApiClient { return expectObject(send(post(path, body)), expect); } - private HttpRequest post(String path, String body) { + private HttpRequest post(String path, String body) throws LinkException { return base(path) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); } - private HttpRequest.Builder base(String path) { + private HttpRequest.Builder base(String path) throws LinkException { + URI uri; + try { + uri = URI.create(config.apiBaseUrl() + path); + } catch (IllegalArgumentException e) { + // Unreachable for paths built from the segment helpers above; kept so a + // malformed base URL surfaces as a LinkException the callers already + // handle rather than an unchecked throw out of a scheduler task. + throw new LinkException(0, "bad_request", "could not build the felis-api request URL", e); + } return HttpRequest.newBuilder() - .uri(URI.create(config.apiBaseUrl() + path)) + .uri(uri) .timeout(config.timeout()) .header("Authorization", "Bearer " + config.serviceToken()) .header("Accept", "application/json"); diff --git a/plugins/shared/test/best/lolicon/felis/link/ControlRoundTripTest.java b/plugins/shared/test/best/lolicon/felis/link/ControlRoundTripTest.java index f25be87..91bd8a0 100644 --- a/plugins/shared/test/best/lolicon/felis/link/ControlRoundTripTest.java +++ b/plugins/shared/test/best/lolicon/felis/link/ControlRoundTripTest.java @@ -1,6 +1,8 @@ package best.lolicon.felis.link; import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; /** * ControlRoundTripTest is a hermetic, dependency-free check of the {@code @@ -31,6 +33,7 @@ public final class ControlRoundTripTest { wireCarriesRefinedStatusFields(); errorOmitsServerWhenAbsentButRoundTrips(); escapesAwkwardStrings(); + listUpdateCarriesNamesInOrder(); rejectsMalformedAndUnknownFrames(); System.out.println("ControlRoundTripTest OK (" + checks + " checks)"); } @@ -46,6 +49,10 @@ public final class ControlRoundTripTest { roundTrip(ControlFrame.transferReady("Notch", "survival")); roundTrip(ControlFrame.error("quota_exceeded", "server quota exhausted", "survival")); roundTrip(ControlFrame.error("not_linked", "link your account first", null)); + roundTrip(ControlFrame.listRequest()); + roundTrip(ControlFrame.listUpdate(Arrays.asList("alpha", "beta-2", "gamma"))); + roundTrip(ControlFrame.listUpdate(List.of())); + roundTrip(ControlFrame.loginRelease("Notch")); } // The discriminator the dispatch switch keys on must appear verbatim on the wire. @@ -56,6 +63,22 @@ public final class ControlRoundTripTest { assertContains(ControlFrame.statusUpdate("s", "Running", true, 1, 2, false), "\"type\":\"StatusUpdate\""); assertContains(ControlFrame.transferReady("p", "s"), "\"type\":\"TransferReady\""); assertContains(ControlFrame.error("c", "m", null), "\"type\":\"Error\""); + assertContains(ControlFrame.listRequest(), "\"type\":\"ListRequest\""); + assertContains(ControlFrame.listUpdate(List.of("a")), "\"type\":\"ListUpdate\""); + assertContains(ControlFrame.loginRelease("p"), "\"type\":\"LoginRelease\""); + } + + // ListUpdate is the lobby's whole tile set: order is display order and must hold, + // an empty list stays empty (never null), and non-string entries a hand-written + // frame might carry are skipped rather than failing the whole list. + private static void listUpdateCarriesNamesInOrder() { + ControlFrame f = decode(ControlFrame.listUpdate(Arrays.asList("zeta", "alpha", null, "mid"))); + assertEq("list order (null dropped)", List.of("zeta", "alpha", "mid"), f.servers()); + assertEq("empty list", List.of(), decode(ControlFrame.listUpdate(null)).servers()); + assertEq("servers on a non-list frame", List.of(), decode(ControlFrame.statusQuery("s")).servers()); + ControlFrame mixed = Control.decode( + "{\"type\":\"ListUpdate\",\"servers\":[\"a\",1,true,\"b\"]}".getBytes(StandardCharsets.UTF_8)); + assertEq("non-string entries skipped", List.of("a", "b"), mixed.servers()); } // StatusUpdate refines the spec's "players" into ready + online + max; the GUI diff --git a/plugins/shared/test/best/lolicon/felis/link/FelisApiClientTest.java b/plugins/shared/test/best/lolicon/felis/link/FelisApiClientTest.java new file mode 100644 index 0000000..b8aaebd --- /dev/null +++ b/plugins/shared/test/best/lolicon/felis/link/FelisApiClientTest.java @@ -0,0 +1,147 @@ +package best.lolicon.felis.link; + +import com.sun.net.httpserver.HttpServer; + +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * FelisApiClientTest checks, against a stub felis-api on a loopback port, that no + * caller-supplied value can re-aim a request at a different route. It is framework + * free like the other test mains: a failed assertion throws and the process exits + * non-zero. + * + *

The case that motivated it: a WakeRequest for {@code victim/join-event?} used to + * become {@code POST /api/v1/internal/servers/victim/join-event}, which appends the + * sender to another tenant's allowlist. The stub records every raw request path, so + * "refused" here means no request reached the server at all. + * + *

Run: {@code javac -d shared/src/main/java/best/lolicon/felis/link/*.java + * shared/test/best/lolicon/felis/link/FelisApiClientTest.java && java -cp + * best.lolicon.felis.link.FelisApiClientTest}. + */ +public final class FelisApiClientTest { + + private static int checks; + private static final List seen = new CopyOnWriteArrayList<>(); + + public static void main(String[] args) throws Exception { + HttpServer stub = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + stub.createContext("/", exchange -> { + seen.add(exchange.getRequestMethod() + " " + exchange.getRequestURI().getRawPath()); + String path = exchange.getRequestURI().getRawPath(); + String body; + int status; + if (path.endsWith("/wake")) { + status = 202; + body = "{\"name\":\"alpha\",\"phase\":\"Starting\",\"ready\":false}"; + } else if (path.endsWith("/approve")) { + status = 200; + body = "{\"approved\":true}"; + } else { + status = 200; + body = "{\"name\":\"alpha\",\"phase\":\"Running\",\"ready\":true,\"claimable\":false}"; + } + byte[] b = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(status, b.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(b); + } + }); + stub.start(); + try { + FelisApiClient api = new FelisApiClient(new LinkConfig( + "http://127.0.0.1:" + stub.getAddress().getPort(), "test-token")); + wellFormedNamesReachTheirRoute(api); + pathBendingNamesNeverLeaveTheClient(api); + opaqueSegmentsArePercentEncoded(api); + } finally { + stub.stop(0); + } + System.out.println("FelisApiClientTest OK (" + checks + " checks)"); + } + + private static void wellFormedNamesReachTheirRoute(FelisApiClient api) throws LinkException { + seen.clear(); + UUID id = UUID.fromString("00000000-0000-0000-0000-000000000001"); + api.wake("alpha", id); + api.menuStatus("my-server-2"); + api.serverStatus("abc"); + assertEq("routes hit", List.of( + "POST /api/v1/internal/servers/alpha/wake", + "GET /api/v1/internal/servers/my-server-2/menu", + "GET /api/v1/internal/servers/abc/status"), seen); + } + + private static void pathBendingNamesNeverLeaveTheClient(FelisApiClient api) { + UUID id = UUID.fromString("00000000-0000-0000-0000-000000000002"); + String[] bad = { + "victim/join-event?", // the reported re-aim + "victim/join-event", + "../account/link/code", + "a b", // used to throw IllegalArgumentException out of URI.create + "Alpha", // server names are lower-case + "-lead", + "trail-", + "ab", // too short + "a".repeat(33), // too long + "alpha#frag", + "alpha%2Fjoin-event", + "", + }; + seen.clear(); + for (String name : bad) { + expectRefused("wake " + name, () -> api.wake(name, id)); + expectRefused("reportJoin " + name, () -> api.reportJoin(name, id)); + expectRefused("claim " + name, () -> api.claim(name, id)); + expectRefused("menuStatus " + name, () -> api.menuStatus(name)); + expectRefused("serverStatus " + name, () -> api.serverStatus(name)); + } + assertEq("requests sent for malformed names", List.of(), seen); + } + + private static void opaqueSegmentsArePercentEncoded(FelisApiClient api) throws LinkException { + assertEq("slash", "a%2Fb", FelisApiClient.segment("a/b")); + assertEq("query", "a%3Fb", FelisApiClient.segment("a?b")); + assertEq("space", "a%20b", FelisApiClient.segment("a b")); + expectRefused("dot", () -> FelisApiClient.segment(".")); + expectRefused("dotdot", () -> FelisApiClient.segment("..")); + + seen.clear(); + api.opLoginApprove("0123abcd", UUID.fromString("00000000-0000-0000-0000-000000000003")); + assertEq("approve route", List.of("POST /api/v1/internal/op-login/0123abcd/approve"), seen); + } + + // ---- harness ---- + + interface Call { + void run() throws LinkException; + } + + private static void expectRefused(String what, Call call) { + try { + call.run(); + } catch (LinkException e) { + if (e.statusCode() != 400) { + throw new AssertionError(what + ": refused with status " + e.statusCode() + ", want 400"); + } + checks++; + return; + } catch (RuntimeException e) { + throw new AssertionError(what + ": threw " + e + " instead of a LinkException", e); + } + throw new AssertionError(what + ": was not refused"); + } + + private static void assertEq(String what, Object want, Object got) { + if (want == null ? got != null : !want.equals(got)) { + throw new AssertionError(what + " = " + got + ", want " + want); + } + checks++; + } +} diff --git a/plugins/test.sh b/plugins/test.sh index ff3c98d..ee2225a 100644 --- a/plugins/test.sh +++ b/plugins/test.sh @@ -5,9 +5,12 @@ # # Two gates, both runnable on any machine with a JDK 21 and Gradle: # -# 1. The three hand-written, framework-free test mains under shared/test and +# 1. The hand-written, framework-free test mains under shared/test and # velocity/test. They check what "compiles" cannot: the felis:control codec -# round-trips every frame kind (spec §12), /invite prompts cannot double-fire +# round-trips every frame kind (spec §12), no server name can re-aim a +# felis-api request path, only the lobby and the login gate may drive +# felis:control (and each only with its own frames), the link-status outage +# fallback fails closed outside its window, /invite prompts cannot double-fire # or outlive their TTL, and the invite card really is a green/red clickable # prompt. InviteCardTest needs the adventure jars the velocity plugin compiles # against; they are fetched from Maven Central below, pinned by version and @@ -56,6 +59,26 @@ javac -d "$work/shared-classes" \ plugins/shared/test/best/lolicon/felis/link/ControlRoundTripTest.java java -cp "$work/shared-classes" best.lolicon.felis.link.ControlRoundTripTest +echo "==> FelisApiClientTest (request paths cannot be re-aimed, shared)" +javac -d "$work/shared-classes" \ + plugins/shared/src/main/java/best/lolicon/felis/link/*.java \ + plugins/shared/test/best/lolicon/felis/link/FelisApiClientTest.java +java -cp "$work/shared-classes" best.lolicon.felis.link.FelisApiClientTest + +echo "==> ControlPolicyTest (who may send what on felis:control, velocity)" +mkdir -p "$work/policy-classes" +javac -d "$work/policy-classes" \ + plugins/shared/src/main/java/best/lolicon/felis/link/*.java \ + plugins/velocity/src/main/java/best/lolicon/felis/velocity/ControlPolicy.java \ + plugins/velocity/src/main/java/best/lolicon/felis/velocity/LinkGate.java \ + plugins/velocity/src/main/java/best/lolicon/felis/velocity/FrameBudget.java \ + plugins/velocity/test/best/lolicon/felis/velocity/ControlPolicyTest.java \ + plugins/velocity/test/best/lolicon/felis/velocity/LinkGateTest.java +java -cp "$work/policy-classes" best.lolicon.felis.velocity.ControlPolicyTest + +echo "==> LinkGateTest (outage fallback + frame budget, velocity)" +java -cp "$work/policy-classes" best.lolicon.felis.velocity.LinkGateTest + echo "==> InviteBookTest (/invite prompt store, velocity)" mkdir -p "$work/velocity-classes" javac -d "$work/velocity-classes" \ diff --git a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/ControlChannel.java b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/ControlChannel.java index 869abdb..72960ba 100644 --- a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/ControlChannel.java +++ b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/ControlChannel.java @@ -5,8 +5,10 @@ import best.lolicon.felis.link.ControlFrame; import best.lolicon.felis.link.FelisApiClient; import best.lolicon.felis.link.LinkException; import best.lolicon.felis.link.MenuStatus; +import best.lolicon.felis.link.ServerView; import com.velocitypowered.api.event.Subscribe; +import com.velocitypowered.api.event.connection.DisconnectEvent; import com.velocitypowered.api.event.connection.PluginMessageEvent; import com.velocitypowered.api.proxy.Player; import com.velocitypowered.api.proxy.ProxyServer; @@ -15,7 +17,11 @@ import com.velocitypowered.api.proxy.messages.ChannelIdentifier; import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier; import org.slf4j.Logger; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; /** * ControlChannel is the proxy end of the {@code felis:control} plugin-message channel @@ -28,11 +34,19 @@ import java.util.UUID; * *

Anti-spoof (spec §14). The acting identity is taken from the * {@link ServerConnection} the message arrived on — {@code source.getPlayer()} — and - * never from the frame's {@code player} field, so a compromised backend cannot drive - * an action as another player. A frame whose source is not a backend server (e.g. a - * client) is consumed and dropped. The frame's {@code server} field is data, not - * identity: it names which backend the player asked for, and the autostartPolicy / - * ownership gates server-side decide whether this UUID may act on it. + * never from the frame's {@code player} field. That alone does not make a frame + * trustworthy: every user backend runs plugins its owner chose, and the player on a + * connection is whoever happens to be standing on that server. So the connection's + * server decides what may be sent at all ({@link ControlPolicy}): the lobby + * gets the menu frames, the login gate gets {@code LoginRelease}, and every other + * backend is dropped. A frame from a client is dropped too. The {@code server} field + * must name a managed user server; the autostartPolicy / ownership gates + * server-side then decide whether this UUID may act on it. + * + *

Load. Each accepted frame is at most one blocking felis-api call, so + * frames are metered per player ({@link FrameBudget}) and menu projections are + * shared across players for {@link #STATUS_TTL_MILLIS}: a lobby full of players + * opening the menu at once reads each server's status once, not once per player. * *

Threading. {@code felis:control} frames arrive on a Velocity event * thread, but every felis-api call below blocks on HTTP. So each handler does the @@ -42,32 +56,65 @@ import java.util.UUID; * from inside that callback. {@code setResult} must run before the handler returns; * it cannot be set from the async hop. * - *

The three upstream frames map onto the menu's buttons (spec §12): a + *

The lobby's upstream frames map onto the menu (spec §12): a {@code ListRequest} + * asks which tiles to draw ({@code ListUpdate} back, built from the registry); a * {@code StatusQuery} refreshes a tile ({@code StatusUpdate} back); a * {@code WakeRequest} (owned server) wakes and parks; a {@code ClaimRequest} * (ownerless server) runs the two-rule split — claim asserts ownership/quota, then * the wake applies the autostartPolicy gate — and parks on success. Refusals come * back as {@code Error}; readiness as {@code TransferReady} just before the proxy * Connects the player (via {@link WaitingRouter.MenuTransferListener}). + * + *

The login gate's one frame, {@code LoginRelease}, asks the proxy to move the + * player to the lobby. It replaces the BungeeCord {@code Connect} the gate used to + * send: {@code bungeecord:main} is handled inside Velocity before any plugin event, + * so it cannot be restricted to the gate and is switched off in velocity.toml + * instead. The release itself is still authorized by + * {@link WaitingRouter#onServerPreConnect}. */ public final class ControlChannel implements WaitingRouter.MenuTransferListener { /** The namespaced channel both ends register; shared with the codec's name. */ static final ChannelIdentifier CHANNEL = MinecraftChannelIdentifier.from(Control.CHANNEL); + // How long one server's menu projection is reused across players. + static final long STATUS_TTL_MILLIS = 2_000L; + // Two full menu pages (45 tiles + the list each) in a burst, then 10 frames a second. + private static final int FRAME_BURST = 96; + private static final double FRAME_REFILL_PER_SECOND = 10.0; + // Upper bound on names in one ListUpdate. A proxy→backend plugin message is capped + // at 32767 bytes; 500 names of at most 32 chars stays well under it. + static final int MAX_LISTED = 500; + // A refused source is logged at most once per interval, so a hostile backend + // cannot turn its own refusals into a log flood. + private static final long REFUSAL_LOG_INTERVAL_MILLIS = 60_000L; + private final ProxyServer proxy; private final Logger log; private final FelisApiClient api; private final WaitingRouter router; + private final ServerRegistry registry; private final FelisVelocityPlugin plugin; + private final ControlPolicy policy; + private final String loginServer; + private final String lobbyServer; + private final FrameBudget budget = + new FrameBudget(FRAME_BURST, FRAME_REFILL_PER_SECOND, System::currentTimeMillis); + private final Map statusCache = new ConcurrentHashMap<>(); + private final Map lastRefusalLog = new ConcurrentHashMap<>(); - ControlChannel(ProxyServer proxy, Logger log, FelisApiClient api, - WaitingRouter router, FelisVelocityPlugin plugin) { + ControlChannel(ProxyServer proxy, Logger log, FelisApiClient api, WaitingRouter router, + ServerRegistry registry, FelisVelocityPlugin plugin, + String loginServer, String lobbyServer) { this.proxy = proxy; this.log = log; this.api = api; this.router = router; + this.registry = registry; this.plugin = plugin; + this.loginServer = loginServer; + this.lobbyServer = lobbyServer; + this.policy = new ControlPolicy(loginServer, lobbyServer, registry::isManaged); } /** @@ -96,13 +143,31 @@ public final class ControlChannel implements WaitingRouter.MenuTransferListener } ServerConnection source = (ServerConnection) event.getSource(); Player player = source.getPlayer(); + String sourceName = source.getServerInfo().getName(); ControlFrame frame; try { frame = Control.decode(event.getData()); } catch (IllegalArgumentException e) { - log.debug("Felis: dropping malformed felis:control frame from {}: {}", - source.getServerInfo().getName(), e.getMessage()); + log.debug("Felis: dropping malformed felis:control frame from {}: {}", sourceName, e.getMessage()); + return; + } + + ControlPolicy.Verdict verdict = policy.check(sourceName, frame); + if (verdict != ControlPolicy.Verdict.ACCEPT) { + refused(sourceName, frame, verdict); + if (verdict == ControlPolicy.Verdict.BAD_SERVER && frame.server() != null + && ControlFrame.STATUS_QUERY.equals(frame.type())) { + // The tile asked about a server that is gone (or never was): answer so + // it stops saying "loading", without echoing a malformed name back. + send(source, ControlFrame.error("not_found", "unknown server", + ControlPolicy.wellFormed(frame.server()) ? frame.server() : null)); + } + return; + } + if (!budget.tryTake(player.getUniqueId())) { + log.debug("Felis: felis:control budget exhausted for {}; dropping '{}'", + player.getUniqueId(), frame.type()); return; } @@ -116,25 +181,69 @@ public final class ControlChannel implements WaitingRouter.MenuTransferListener case ControlFrame.CLAIM_REQUEST: handleClaim(source, player, frame.server()); break; + case ControlFrame.LIST_REQUEST: + send(source, ControlFrame.listUpdate(listed())); + break; + case ControlFrame.LOGIN_RELEASE: + router.releaseFromLogin(player); + break; default: - // Downstream-only types (StatusUpdate/TransferReady/Error) are not - // actionable arriving upstream; a well-behaved lobby never sends them. - log.debug("Felis: ignoring non-actionable felis:control frame '{}' from {}", - frame.type(), player.getUsername()); + // ControlPolicy admits only the types above. + break; } } + @Subscribe + public void onDisconnect(DisconnectEvent event) { + budget.forget(event.getPlayer().getUniqueId()); + } + + // listed is the lobby's tile set: every managed user server, by name. The system + // servers are the lobby itself and the gate in front of it, so neither is a tile. + private List listed() { + List names = new ArrayList<>(); + for (ServerView v : registry.all()) { + if (policy.isUserServer(v.name())) { + names.add(v.name()); + } + } + names.sort(String::compareTo); + return names.size() > MAX_LISTED ? names.subList(0, MAX_LISTED) : names; + } + + private void refused(String sourceName, ControlFrame frame, ControlPolicy.Verdict verdict) { + if (verdict == ControlPolicy.Verdict.BAD_SERVER) { + log.debug("Felis: dropping felis:control '{}' from {}: not a managed user server", + frame.type(), sourceName); + return; + } + long now = System.currentTimeMillis(); + Long last = lastRefusalLog.get(sourceName); + if (last != null && now - last < REFUSAL_LOG_INTERVAL_MILLIS) { + return; + } + lastRefusalLog.put(sourceName, now); + log.warn("Felis: refused felis:control '{}' from backend {} ({}). Only {} may send menu frames " + + "and only {} may send LoginRelease; a user backend sending these is running " + + "a plugin that tries to act for the players on it.", + frame.type(), sourceName, verdict, lobbyServer, loginServer); + } + // A StatusQuery refreshes one tile: read the menu projection and answer with a // StatusUpdate, or an Error if felis-api refuses (e.g. 404 unknown server). private void handleStatusQuery(ServerConnection source, String server) { - if (isBlank(server)) { - return; // nothing to look up + CachedStatus cached = statusCache.get(server); + if (cached != null && System.currentTimeMillis() - cached.atMillis <= STATUS_TTL_MILLIS) { + send(source, cached.frame); + return; } plugin.async(() -> { try { MenuStatus s = api.menuStatus(server); - send(source, ControlFrame.statusUpdate( - s.name(), s.phase(), s.ready(), s.playersOnline(), s.playersMax(), s.claimable())); + ControlFrame frame = ControlFrame.statusUpdate( + s.name(), s.phase(), s.ready(), s.playersOnline(), s.playersMax(), s.claimable()); + statusCache.put(server, new CachedStatus(frame, System.currentTimeMillis())); + send(source, frame); } catch (LinkException e) { send(source, errorFrame(e, server)); } @@ -145,10 +254,6 @@ public final class ControlChannel implements WaitingRouter.MenuTransferListener // it and park them in the shared queue. enqueueFromMenu does the HTTP off-thread // and reports its own refusals to the player; nothing to await here. private void handleWake(ServerConnection source, Player player, String server) { - if (isBlank(server)) { - send(source, ControlFrame.error("bad_request", "wake without a server", null)); - return; - } router.enqueueFromMenu(player, server); } @@ -156,10 +261,6 @@ public final class ControlChannel implements WaitingRouter.MenuTransferListener // split. Claim first (ownership + quota); only on success wake-and-park (the // autostartPolicy gate). A claim refusal answers with Error and never wakes. private void handleClaim(ServerConnection source, Player player, String server) { - if (isBlank(server)) { - send(source, ControlFrame.error("bad_request", "claim without a server", null)); - return; - } UUID id = player.getUniqueId(); plugin.async(() -> { try { @@ -168,6 +269,7 @@ public final class ControlChannel implements WaitingRouter.MenuTransferListener send(source, errorFrame(e, server)); return; // claim refused → do not wake a server the player doesn't own } + statusCache.remove(server); // it is no longer claimable // Owned now → run the second rule. enqueueFromMenu spawns its own async // hop for the wake, which is fine from here. router.enqueueFromMenu(player, server); @@ -203,7 +305,13 @@ public final class ControlChannel implements WaitingRouter.MenuTransferListener return ControlFrame.error(code, message, server); } - private static boolean isBlank(String s) { - return s == null || s.isEmpty(); + private static final class CachedStatus { + final ControlFrame frame; + final long atMillis; + + CachedStatus(ControlFrame frame, long atMillis) { + this.frame = frame; + this.atMillis = atMillis; + } } } diff --git a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/ControlPolicy.java b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/ControlPolicy.java new file mode 100644 index 0000000..80b23d5 --- /dev/null +++ b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/ControlPolicy.java @@ -0,0 +1,95 @@ +package best.lolicon.felis.velocity; + +import best.lolicon.felis.link.ControlFrame; + +import java.util.Set; +import java.util.function.Predicate; +import java.util.regex.Pattern; + +/** + * ControlPolicy decides whether a {@code felis:control} frame is acted on, from two + * facts the proxy knows and the sender cannot forge: which backend connection the + * frame arrived on, and which servers the registry currently manages. It holds no + * Velocity types so the whole decision is unit-testable (ControlPolicyTest). + * + *

Every user backend runs code its owner chose, so "arrived from a backend" says + * nothing about who wrote the frame. Before this gate any server owner could install + * a plugin that sent ClaimRequests and WakeRequests on behalf of whoever was standing + * on their server, and pick the {@code server} field freely. The rules are now: + * + *

    + *
  • the lobby may send {@code StatusQuery}, {@code WakeRequest}, + * {@code ClaimRequest} and {@code ListRequest};
  • + *
  • the login gate may send {@code LoginRelease} and nothing else;
  • + *
  • any other backend is refused outright, whatever the type.
  • + *
+ * + *

A frame that names a server must name a user server the registry manages and + * that is well-formed by the platform's naming rule. The two system servers are not + * menu targets: the lobby never lists them, and waking or claiming either is not a + * player action. + */ +final class ControlPolicy { + + /** The outcome of {@link #check}; everything but ACCEPT means drop the frame. */ + enum Verdict { + ACCEPT, + /** The frame came from a backend that is neither the lobby nor the login gate. */ + FOREIGN_SOURCE, + /** The source may use the channel, but not for this frame type. */ + TYPE_NOT_ALLOWED, + /** The frame names no server, a malformed one, a system one or an unknown one. */ + BAD_SERVER + } + + // Mirrors internal/naming.serverNameRE and its no-leading/trailing-dash rule. + private static final Pattern SERVER_NAME = Pattern.compile("^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$"); + + private static final Set LOBBY_TYPES = Set.of( + ControlFrame.STATUS_QUERY, + ControlFrame.WAKE_REQUEST, + ControlFrame.CLAIM_REQUEST, + ControlFrame.LIST_REQUEST); + + private final String loginServer; + private final String lobbyServer; + private final Predicate managed; + + ControlPolicy(String loginServer, String lobbyServer, Predicate managed) { + this.loginServer = loginServer; + this.lobbyServer = lobbyServer; + this.managed = managed; + } + + Verdict check(String sourceServer, ControlFrame frame) { + if (sourceServer == null) { + return Verdict.FOREIGN_SOURCE; + } + if (sourceServer.equalsIgnoreCase(loginServer)) { + return ControlFrame.LOGIN_RELEASE.equals(frame.type()) ? Verdict.ACCEPT : Verdict.TYPE_NOT_ALLOWED; + } + if (!sourceServer.equalsIgnoreCase(lobbyServer)) { + return Verdict.FOREIGN_SOURCE; + } + if (!LOBBY_TYPES.contains(frame.type())) { + return Verdict.TYPE_NOT_ALLOWED; + } + if (ControlFrame.LIST_REQUEST.equals(frame.type())) { + return Verdict.ACCEPT; + } + return isUserServer(frame.server()) ? Verdict.ACCEPT : Verdict.BAD_SERVER; + } + + /** wellFormed is the platform naming rule alone, with no registry lookup. */ + static boolean wellFormed(String name) { + return name != null && SERVER_NAME.matcher(name).matches(); + } + + /** isUserServer is true for a well-formed, managed server that is not login or lobby. */ + boolean isUserServer(String name) { + return wellFormed(name) + && !name.equalsIgnoreCase(loginServer) + && !name.equalsIgnoreCase(lobbyServer) + && managed.test(name); + } +} diff --git a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java index 112d5ff..ccb4d19 100644 --- a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java +++ b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java @@ -15,6 +15,7 @@ import com.velocitypowered.api.command.CommandManager; import com.velocitypowered.api.command.CommandMeta; import com.velocitypowered.api.command.CommandSource; import com.velocitypowered.api.event.Subscribe; +import com.velocitypowered.api.event.connection.DisconnectEvent; import com.velocitypowered.api.event.proxy.ProxyInitializeEvent; import com.velocitypowered.api.plugin.Plugin; import com.velocitypowered.api.plugin.annotation.DataDirectory; @@ -78,6 +79,11 @@ public final class FelisVelocityPlugin { private final Logger logger; private final Path dataDirectory; private final InviteBook invites = new InviteBook(INVITE_TTL.toMillis(), INVITE_COOLDOWN.toMillis()); + // Every acting command below is one felis-api call made with the service token. + // Five in a burst, then one every five seconds, per player: plenty for a person + // typing, and a bound on a client macro that would otherwise mint link codes or + // fire claims as fast as it can send chat. + private final FrameBudget commandBudget = new FrameBudget(5, 0.2, System::currentTimeMillis); private FelisVelocityConfig config; private LinkClient linkClient; @@ -107,6 +113,8 @@ public final class FelisVelocityPlugin { registerFelisCommand(); registerInviteCommand(); + warnIfBungeeChannelOpen(); + this.onlineMode = proxy.getConfiguration().isOnlineMode(); if (!onlineMode) { logger.error("Felis routing DISABLED: the proxy is in offline mode (online-mode=false). " @@ -133,7 +141,8 @@ public final class FelisVelocityPlugin { // same waiting queue through this channel. Opened only with routing active — // it depends on the same online-mode + root-domain guards, and its claim/wake // identity is the verified UUID off the backend connection (spec §14). - ControlChannel control = new ControlChannel(proxy, logger, apiClient, router, this); + ControlChannel control = new ControlChannel(proxy, logger, apiClient, router, registry, this, + config.loginServer(), config.lobbyServer()); control.register(); proxy.getEventManager().register(this, control); @@ -147,6 +156,22 @@ public final class FelisVelocityPlugin { config.rootDomain(), config.loginServer(), config.lobbyServer()); } + @Subscribe + public void onDisconnect(DisconnectEvent event) { + commandBudget.forget(event.getPlayer().getUniqueId()); + } + + // withinBudget spends one command token for the player, or tells them to slow down. + private boolean withinBudget(Player player) { + if (commandBudget.tryTake(player.getUniqueId())) { + return true; + } + player.sendMessage(Component.text( + zh(player) ? "操作太频繁了,请过几秒再试。" : "Slow down — try again in a few seconds.", + NamedTextColor.YELLOW)); + return false; + } + /** async runs a task on Velocity's scheduler so felis-api I/O never blocks the proxy thread. */ void async(Runnable task) { proxy.getScheduler().buildTask(this, task).schedule(); @@ -156,7 +181,36 @@ public final class FelisVelocityPlugin { proxy.getScheduler().buildTask(this, task).delay(interval).repeat(interval).schedule(); } + /** + * warnIfBungeeChannelOpen reports a proxy that still answers {@code bungeecord:main}. + * Velocity handles that channel itself, before any plugin event, so no plugin can + * restrict who uses it: with it on, any user backend can KickPlayer or ConnectOther + * anyone on the network. The installer writes + * {@code bungee-plugin-message-channel = false}; this catches a hand-edited or + * older velocity.toml. The switch is not part of the plugin API, so it is read + * reflectively, and an unreadable value is reported as unknown. + */ + private void warnIfBungeeChannelOpen() { + Object cfg = proxy.getConfiguration(); + Boolean open; + try { + open = (Boolean) cfg.getClass().getMethod("isBungeePluginChannelEnabled").invoke(cfg); + } catch (ReflectiveOperationException | ClassCastException | SecurityException e) { + open = null; + } + if (Boolean.FALSE.equals(open)) { + return; + } + logger.error("Felis: the BungeeCord plugin-message channel is {}. Every user backend can then kick or " + + "move any player on the network. Set 'bungee-plugin-message-channel = false' under " + + "[advanced] in velocity.toml (re-running the installer writes it) and restart.", + open == null ? "in an unknown state" : "ENABLED"); + } + private void refreshRegistrations() { + if (router != null) { + router.pruneLinks(); + } try { List servers = apiClient.listServers(); registry.refresh(servers); @@ -188,6 +242,9 @@ public final class FelisVelocityPlugin { } private void requestAndReply(Player player) { + if (!withinBudget(player)) { + return; + } boolean zh = zh(player); player.sendMessage(Component.text( zh ? "正在获取绑定码……" : "Requesting a link code…", NamedTextColor.GRAY)); @@ -460,6 +517,9 @@ public final class FelisVelocityPlugin { : "You're already on « " + match.name() + " ».", NamedTextColor.GRAY)); return false; } + if (!withinBudget(player)) { + return false; + } // Wake + park + transfer through the shared waiting queue; it reports its own // policy-gate (403) and transient refusals to the player. if (joinIfReady) { @@ -494,6 +554,9 @@ public final class FelisVelocityPlugin { : "« " + name + " » isn't a claimable felis server.", NamedTextColor.YELLOW)); return; } + if (!withinBudget(player)) { + return; + } UUID uuid = player.getUniqueId(); player.sendMessage(Component.text( zh ? "正在认领「" + name + "」……" : "Claiming « " + name + " »…", NamedTextColor.GRAY)); @@ -526,6 +589,9 @@ public final class FelisVelocityPlugin { player.sendMessage(routingDisabled(zh)); return; } + if (!withinBudget(player)) { + return; + } UUID uuid = player.getUniqueId(); String who = player.getUsername(); player.sendMessage(Component.text( @@ -617,6 +683,9 @@ public final class FelisVelocityPlugin { NamedTextColor.RED)); return; } + if (!withinBudget(player)) { + return; + } UUID approver = player.getUniqueId(); String who = player.getUsername(); player.sendMessage(Component.text( diff --git a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FrameBudget.java b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FrameBudget.java new file mode 100644 index 0000000..4ad1f43 --- /dev/null +++ b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FrameBudget.java @@ -0,0 +1,50 @@ +package best.lolicon.felis.velocity; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.LongSupplier; + +/** + * FrameBudget is a per-player token bucket over the {@code felis:control} frames the + * proxy turns into felis-api calls. A menu page costs one ListRequest plus one + * StatusQuery per tile, so the bucket holds a couple of full pages; what it stops is + * a client macro (or a modified lobby) replaying frames in a loop, each of which + * would otherwise be a blocking internal API request. + * + *

Refill is continuous at {@code refillPerSecond}; the clock is injected so the + * arithmetic is testable (FrameBudgetTest). + */ +final class FrameBudget { + private final double capacity; + private final double refillPerMilli; + private final LongSupplier clock; + private final Map buckets = new ConcurrentHashMap<>(); // {tokens, lastMillis} + + FrameBudget(int capacity, double refillPerSecond, LongSupplier clock) { + this.capacity = capacity; + this.refillPerMilli = refillPerSecond / 1000.0; + this.clock = clock; + } + + /** tryTake spends one token for {@code id}; false means drop this frame. */ + boolean tryTake(UUID id) { + long now = clock.getAsLong(); + double[] b = buckets.computeIfAbsent(id, k -> new double[] {capacity, now}); + synchronized (b) { + double tokens = Math.min(capacity, b[0] + (now - (long) b[1]) * refillPerMilli); + b[1] = now; + if (tokens < 1.0) { + b[0] = tokens; + return false; + } + b[0] = tokens - 1.0; + return true; + } + } + + /** forget drops a departed player's bucket so the map does not grow without bound. */ + void forget(UUID id) { + buckets.remove(id); + } +} diff --git a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/LinkGate.java b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/LinkGate.java new file mode 100644 index 0000000..cc82bca --- /dev/null +++ b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/LinkGate.java @@ -0,0 +1,93 @@ +package best.lolicon.felis.velocity; + +import best.lolicon.felis.link.LinkException; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.LongSupplier; + +/** + * LinkGate answers "is this verified UUID linked to a Felis account?" for every + * routing decision, and keeps answering during a short felis-api outage. + * + *

felis-api is asked every time; its answer always wins when it gives one, so an + * unlink or a disabled account takes effect on the next check. Only when the call + * fails in a way that says nothing about the account — a transport error or a 5xx — + * does the gate fall back to a positive answer it received for the same UUID within + * {@code graceMillis}. Without that fallback every felis-api restart turned into + * "login verification is temporarily unavailable" for everyone leaving the login gate + * at that moment. A UUID never seen linked, a 4xx, or a positive answer older than + * the grace still fails closed. + * + *

Records outlive a disconnect on purpose: a player who drops during an outage and + * reconnects is exactly the case the grace exists for. {@link #prune} bounds the map + * to the UUIDs seen linked within the grace. + */ +final class LinkGate { + + /** LinkCheck is the one felis-api call the gate wraps (FelisApiClient::linkStatus). */ + interface LinkCheck { + boolean linkStatus(UUID id) throws LinkException; + } + + /** Result is the gate's answer plus whether it came from the fallback. */ + static final class Result { + final boolean linked; + final boolean degraded; + + Result(boolean linked, boolean degraded) { + this.linked = linked; + this.degraded = degraded; + } + } + + private final LinkCheck api; + private final long graceMillis; + private final LongSupplier clock; + private final Map lastPositive = new ConcurrentHashMap<>(); + + LinkGate(LinkCheck api, long graceMillis, LongSupplier clock) { + this.api = api; + this.graceMillis = graceMillis; + this.clock = clock; + } + + /** + * check asks felis-api and records a positive answer. It rethrows the + * LinkException when the fallback does not apply, so callers keep their existing + * fail-closed branch for that case. + */ + Result check(UUID id) throws LinkException { + try { + boolean linked = api.linkStatus(id); + if (linked) { + lastPositive.put(id, clock.getAsLong()); + } else { + lastPositive.remove(id); + } + return new Result(linked, false); + } catch (LinkException e) { + if (!outage(e)) { + throw e; + } + Long at = lastPositive.get(id); + if (at != null && clock.getAsLong() - at <= graceMillis) { + return new Result(true, true); + } + throw e; + } + } + + /** prune drops records older than the grace; the plugin calls it on its refresh loop. */ + void prune() { + long cutoff = clock.getAsLong() - graceMillis; + lastPositive.values().removeIf(at -> at < cutoff); + } + + // A transport failure or a server-side error says nothing about the account; a + // 4xx is felis-api answering, and its answer stands. + private static boolean outage(LinkException e) { + return e.statusCode() == 0 || e.statusCode() >= 500; + } +} 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 8e076e7..0869efd 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 @@ -25,6 +25,7 @@ import java.util.Map; import java.util.Optional; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; /** * WaitingRouter implements the §11 domain-autostart routing loop and its waiting @@ -51,9 +52,17 @@ import java.util.concurrent.ConcurrentHashMap; * (we tell them to try later — nothing is coming up, so we do not enqueue). Real * user-backend joins are reported back so the reaper sees activity and the player is * auto-added to the allowlist. + * + *

Link checks go through {@link LinkGate}, which rides out a short felis-api + * outage on a recent positive answer for the same UUID and otherwise fails closed. */ public final class WaitingRouter { private static final long WAIT_TIMEOUT_MILLIS = 120_000L; + // How long a positive link answer can stand in for felis-api while it is down. + private static final long LINK_GRACE_MILLIS = 10 * 60_000L; + // The login gate re-sends its release with backoff (and during a felis-api outage + // on every retry), so a denial line is shown at most once per this interval. + private static final long GATE_NOTICE_INTERVAL_MILLIS = 15_000L; private final ProxyServer proxy; private final Logger log; @@ -63,8 +72,14 @@ public final class WaitingRouter { private final String loginServer; private final String lobbyServer; + private final LinkGate links; private final Map waiting = new ConcurrentHashMap<>(); private final Map pendingTargets = new ConcurrentHashMap<>(); + private final Map lastGateNotice = new ConcurrentHashMap<>(); + // tick is scheduled at a fixed rate and makes blocking calls; when felis-api is + // slow a run can outlast the interval, and overlapping runs would multiply the + // load on the thing that is already slow. + private final AtomicBoolean ticking = new AtomicBoolean(false); // Notified just before a menu-originated waiter is transferred, so the lobby's // felis:control face can tell the player's GUI the backend is ready. Null until @@ -80,6 +95,44 @@ public final class WaitingRouter { this.plugin = plugin; this.loginServer = loginServer; this.lobbyServer = lobbyServer; + this.links = new LinkGate(api::linkStatus, LINK_GRACE_MILLIS, System::currentTimeMillis); + } + + /** pruneLinks bounds the LinkGate's fallback records; called on the refresh loop. */ + void pruneLinks() { + links.prune(); + } + + /** + * releaseFromLogin is the proxy end of the login gate's {@code LoginRelease}: ask + * Velocity to move the player to the lobby. The move is authorized where every + * login exit is, in {@link #onServerPreConnect}, so a gate that sends this early + * gets a denial and nothing else. ControlChannel has already checked the frame + * came from the login server, so the player is standing on it. + */ + void releaseFromLogin(Player player) { + Optional lobby = proxy.getServer(lobbyServer); + if (lobby.isEmpty()) { + log.warn("Felis: login release for {} but the lobby '{}' is not registered yet", + player.getUniqueId(), lobbyServer); + return; // the gate retries; the next refresh registers the lobby + } + player.createConnectionRequest(lobby.get()).connect().whenComplete((result, err) -> { + if (err != null) { + log.warn("Felis: login release for {} failed: {}", player.getUniqueId(), err.toString()); + } + }); + } + + // 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 { + LinkGate.Result r = links.check(id); + if (r.degraded) { + log.warn("Felis: felis-api unreachable; admitting {} on a link confirmation from the last {} min", + id, LINK_GRACE_MILLIS / 60_000L); + } + return r.linked; } /** @@ -213,8 +266,8 @@ public final class WaitingRouter { UUID id = player.getUniqueId(); boolean zh = FelisVelocityPlugin.zh(player); try { - if (!api.linkStatus(id)) { - player.sendMessage(Component.text( + if (!linked(id)) { + gateNotice(player, Component.text( zh ? "请先完成登录,再离开登录区。" : "Finish signing in before leaving the login area.", NamedTextColor.YELLOW)); return; @@ -222,7 +275,7 @@ public final class WaitingRouter { } catch (LinkException e) { log.warn("Felis: could not verify login release for {} (status={}): {}", id, e.statusCode(), e.getMessage()); - player.sendMessage(Component.text( + gateNotice(player, Component.text( zh ? "登录验证暂时不可用,请稍候重试。" : "Login verification is temporarily unavailable. Please wait and try again.", NamedTextColor.RED)); @@ -265,6 +318,19 @@ public final class WaitingRouter { UUID id = event.getPlayer().getUniqueId(); pendingTargets.remove(id); waiting.remove(id); + lastGateNotice.remove(id); + } + + // gateNotice shows a login-gate denial, suppressing repeats the gate's own retry + // loop would otherwise print every few seconds. + private void gateNotice(Player player, Component line) { + long now = System.currentTimeMillis(); + Long last = lastGateNotice.get(player.getUniqueId()); + if (last != null && now - last < GATE_NOTICE_INTERVAL_MILLIS) { + return; + } + lastGateNotice.put(player.getUniqueId(), now); + player.sendMessage(line); } @Subscribe @@ -281,16 +347,27 @@ public final class WaitingRouter { try { api.reportJoin(name, id); } catch (LinkException e) { - log.debug("Felis: join-event {} failed (status={}): {}", name, e.statusCode(), e.getMessage()); + // A lost join-event leaves the reaper blind to real activity and skips + // the allowlist append, so it is an operator-visible failure. + log.warn("Felis: join-event for {} on {} failed (status={}): {}", + id, name, e.statusCode(), e.getMessage()); } }); } /** tick drains the waiting queue; the plugin schedules it on the async pool. */ void tick() { - if (waiting.isEmpty()) { + if (waiting.isEmpty() || !ticking.compareAndSet(false, true)) { return; } + try { + drain(); + } finally { + ticking.set(false); + } + } + + private void drain() { long now = System.currentTimeMillis(); Map readyCache = new HashMap<>(); // one status poll per distinct server for (Map.Entry e : new ArrayList<>(waiting.entrySet())) { @@ -328,7 +405,7 @@ public final class WaitingRouter { continue; // ready but not yet registered → next tick } try { - if (!api.linkStatus(id)) { + if (!linked(id)) { waiting.remove(id); player.sendMessage(Component.text( zh ? "你的账户已不再绑定。请重连以重新登录。" @@ -376,7 +453,7 @@ public final class WaitingRouter { } plugin.async(() -> { try { - if (!api.linkStatus(id)) { + if (!linked(id)) { player.sendMessage(Component.text( zh ? "请先完成登录,再加入服务器。" : "Finish signing in before joining a server.", NamedTextColor.YELLOW)); @@ -457,6 +534,8 @@ public final class WaitingRouter { private void transfer(Player player, String serverName, RegisteredServer backend) { player.createConnectionRequest(backend).connect().whenComplete((result, err) -> { if (err != null || (result != null && !result.isSuccessful())) { + log.warn("Felis: transfer of {} to {} failed: {}", player.getUniqueId(), serverName, + err != null ? err.toString() : result.getStatus()); player.sendMessage(Component.text( FelisVelocityPlugin.zh(player) ? "无法把你连接到「" + serverName + "」。请重试。" diff --git a/plugins/velocity/test/best/lolicon/felis/velocity/ControlPolicyTest.java b/plugins/velocity/test/best/lolicon/felis/velocity/ControlPolicyTest.java new file mode 100644 index 0000000..0ee5331 --- /dev/null +++ b/plugins/velocity/test/best/lolicon/felis/velocity/ControlPolicyTest.java @@ -0,0 +1,105 @@ +package best.lolicon.felis.velocity; + +import best.lolicon.felis.link.ControlFrame; + +import java.util.Set; + +/** + * ControlPolicyTest pins down who may send what on {@code felis:control}. It needs no + * Velocity classes: {@link ControlPolicy} takes the source server's name and a + * managed-server predicate, which is everything the proxy knows that a backend cannot + * forge. Framework free: a failed assertion throws and the process exits non-zero. + * + *

Run: {@code javac -d shared/src/main/java/best/lolicon/felis/link/*.java + * velocity/src/main/java/best/lolicon/felis/velocity/ControlPolicy.java + * velocity/test/best/lolicon/felis/velocity/ControlPolicyTest.java && java -cp + * best.lolicon.felis.velocity.ControlPolicyTest}. + */ +public final class ControlPolicyTest { + + private static int checks; + + private static final Set MANAGED = Set.of("login", "lobby", "alpha", "beta", "victim"); + private static final ControlPolicy POLICY = new ControlPolicy("login", "lobby", MANAGED::contains); + + public static void main(String[] args) { + lobbyMayDriveTheMenu(); + loginMayOnlyRelease(); + userBackendsAreRefusedWhateverTheySend(); + menuFramesMustNameAManagedUserServer(); + System.out.println("ControlPolicyTest OK (" + checks + " checks)"); + } + + private static void lobbyMayDriveTheMenu() { + expect("lobby StatusQuery", ControlPolicy.Verdict.ACCEPT, "lobby", ControlFrame.statusQuery("alpha")); + expect("lobby WakeRequest", ControlPolicy.Verdict.ACCEPT, "lobby", ControlFrame.wakeRequest("p", "alpha")); + expect("lobby ClaimRequest", ControlPolicy.Verdict.ACCEPT, "lobby", ControlFrame.claimRequest("p", "beta")); + expect("lobby ListRequest", ControlPolicy.Verdict.ACCEPT, "lobby", ControlFrame.listRequest()); + expect("source name case", ControlPolicy.Verdict.ACCEPT, "LOBBY", ControlFrame.listRequest()); + // The lobby has no business releasing players from the gate. + expect("lobby LoginRelease", ControlPolicy.Verdict.TYPE_NOT_ALLOWED, "lobby", ControlFrame.loginRelease("p")); + // Downstream-only types arriving upstream are not actionable. + expect("lobby StatusUpdate", ControlPolicy.Verdict.TYPE_NOT_ALLOWED, "lobby", + ControlFrame.statusUpdate("alpha", "Running", true, 1, 2, false)); + expect("lobby Error", ControlPolicy.Verdict.TYPE_NOT_ALLOWED, "lobby", ControlFrame.error("x", "y", null)); + } + + private static void loginMayOnlyRelease() { + expect("login LoginRelease", ControlPolicy.Verdict.ACCEPT, "login", ControlFrame.loginRelease("p")); + expect("login WakeRequest", ControlPolicy.Verdict.TYPE_NOT_ALLOWED, "login", + ControlFrame.wakeRequest("p", "alpha")); + expect("login ClaimRequest", ControlPolicy.Verdict.TYPE_NOT_ALLOWED, "login", + ControlFrame.claimRequest("p", "alpha")); + expect("login ListRequest", ControlPolicy.Verdict.TYPE_NOT_ALLOWED, "login", ControlFrame.listRequest()); + } + + // The reported hole: a server owner's plugin on "alpha" sending menu frames for + // whoever stands there, or releasing players from the gate. + private static void userBackendsAreRefusedWhateverTheySend() { + for (ControlFrame f : new ControlFrame[] { + ControlFrame.claimRequest("visitor", "beta"), + ControlFrame.wakeRequest("visitor", "victim"), + ControlFrame.statusQuery("alpha"), + ControlFrame.listRequest(), + ControlFrame.loginRelease("visitor"), + }) { + expect("alpha " + f.type(), ControlPolicy.Verdict.FOREIGN_SOURCE, "alpha", f); + expect("unmanaged " + f.type(), ControlPolicy.Verdict.FOREIGN_SOURCE, "somewhere", f); + expect("null source " + f.type(), ControlPolicy.Verdict.FOREIGN_SOURCE, null, f); + } + } + + private static void menuFramesMustNameAManagedUserServer() { + String[] bad = { + null, "", "victim/join-event?", "victim/join-event", "Victim", "unknown", + "login", "lobby", // system servers are not menu targets + "-x-", "a b", + }; + for (String server : bad) { + expect("wake " + server, ControlPolicy.Verdict.BAD_SERVER, "lobby", ControlFrame.wakeRequest("p", server)); + expect("claim " + server, ControlPolicy.Verdict.BAD_SERVER, "lobby", ControlFrame.claimRequest("p", server)); + expect("status " + server, ControlPolicy.Verdict.BAD_SERVER, "lobby", ControlFrame.statusQuery(server)); + } + assertTrue("user server listed", POLICY.isUserServer("alpha")); + assertTrue("lobby not listed", !POLICY.isUserServer("lobby")); + assertTrue("wellFormed ok", ControlPolicy.wellFormed("abc-1")); + assertTrue("wellFormed slash", !ControlPolicy.wellFormed("a/b")); + } + + // ---- harness ---- + + private static void expect(String what, ControlPolicy.Verdict want, String source, ControlFrame f) { + ControlPolicy.Verdict got = POLICY.check(source, f); + if (got != want) { + throw new AssertionError(what + ": " + got + ", want " + want); + } + checks++; + } + + private static void assertTrue(String what, boolean v) { + if (!v) { + throw new AssertionError(what); + } + checks++; + } +} diff --git a/plugins/velocity/test/best/lolicon/felis/velocity/LinkGateTest.java b/plugins/velocity/test/best/lolicon/felis/velocity/LinkGateTest.java new file mode 100644 index 0000000..ada65b5 --- /dev/null +++ b/plugins/velocity/test/best/lolicon/felis/velocity/LinkGateTest.java @@ -0,0 +1,150 @@ +package best.lolicon.felis.velocity; + +import best.lolicon.felis.link.LinkException; + +import java.util.UUID; +import java.util.concurrent.atomic.AtomicLong; + +/** + * LinkGateTest covers the two rate/fallback helpers the proxy keeps in memory, + * {@link LinkGate} and {@link FrameBudget}, on an injected clock. Framework free: a + * failed assertion throws and the process exits non-zero. + * + *

Run: {@code javac -d shared/src/main/java/best/lolicon/felis/link/*.java + * velocity/src/main/java/best/lolicon/felis/velocity/{LinkGate,FrameBudget}.java + * velocity/test/best/lolicon/felis/velocity/LinkGateTest.java && java -cp + * best.lolicon.felis.velocity.LinkGateTest}. + */ +public final class LinkGateTest { + + private static int checks; + private static final AtomicLong now = new AtomicLong(1_000_000L); + + // What the stub felis-api does next: answer true/false, or fail with a status. + private static volatile Boolean answer = Boolean.TRUE; + private static volatile int failStatus = 0; + + private static final long GRACE = 600_000L; + + public static void main(String[] args) throws Exception { + apiAnswerAlwaysWins(); + outageFallsBackOnlyToARecentPositive(); + clientErrorsNeverFallBack(); + pruneBoundsTheRecords(); + frameBudgetRefills(); + System.out.println("LinkGateTest OK (" + checks + " checks)"); + } + + private static LinkGate gate() { + return new LinkGate(id -> { + if (answer == null) { + throw new LinkException(failStatus, "x", "stub failure"); + } + return answer; + }, GRACE, now::get); + } + + private static void apiAnswerAlwaysWins() throws LinkException { + LinkGate g = gate(); + UUID id = UUID.randomUUID(); + answer = true; + LinkGate.Result r = g.check(id); + assertEq("linked", true, r.linked); + assertEq("not degraded", false, r.degraded); + // An unlink takes effect on the very next check, grace or not. + answer = false; + assertEq("unlinked now", false, g.check(id).linked); + // …and it also clears the fallback: an outage right after an unlink fails closed. + answer = null; + failStatus = 0; + expectThrows("outage after unlink", g, id); + } + + private static void outageFallsBackOnlyToARecentPositive() throws LinkException { + LinkGate g = gate(); + UUID seen = UUID.randomUUID(); + UUID stranger = UUID.randomUUID(); + answer = true; + g.check(seen); + + answer = null; + failStatus = 0; // transport error + now.addAndGet(GRACE - 1); + LinkGate.Result r = g.check(seen); + assertEq("fallback linked", true, r.linked); + assertEq("fallback degraded", true, r.degraded); + failStatus = 503; + assertEq("5xx falls back too", true, g.check(seen).linked); + expectThrows("never-seen UUID fails closed", g, stranger); + + now.addAndGet(2); + expectThrows("positive older than grace fails closed", g, seen); + } + + private static void clientErrorsNeverFallBack() throws LinkException { + LinkGate g = gate(); + UUID id = UUID.randomUUID(); + answer = true; + g.check(id); + answer = null; + for (int status : new int[] {400, 401, 403, 404, 429}) { + failStatus = status; + expectThrows("status " + status, g, id); + } + } + + private static void pruneBoundsTheRecords() throws LinkException { + LinkGate g = gate(); + UUID id = UUID.randomUUID(); + answer = true; + g.check(id); + now.addAndGet(GRACE + 1); + g.prune(); + now.addAndGet(-(GRACE + 1)); // even back inside the window, the record is gone + answer = null; + failStatus = 0; + expectThrows("pruned record", g, id); + } + + private static void frameBudgetRefills() { + FrameBudget b = new FrameBudget(3, 2.0, now::get); + UUID id = UUID.randomUUID(); + assertEq("t1", true, b.tryTake(id)); + assertEq("t2", true, b.tryTake(id)); + assertEq("t3", true, b.tryTake(id)); + assertEq("burst spent", false, b.tryTake(id)); + now.addAndGet(499); + assertEq("not yet refilled", false, b.tryTake(id)); + now.addAndGet(2); + assertEq("one token after 500ms at 2/s", true, b.tryTake(id)); + assertEq("and only one", false, b.tryTake(id)); + now.addAndGet(60_000); + assertEq("capped at capacity 1", true, b.tryTake(id)); + assertEq("capped at capacity 2", true, b.tryTake(id)); + assertEq("capped at capacity 3", true, b.tryTake(id)); + assertEq("capped at capacity 4", false, b.tryTake(id)); + UUID other = UUID.randomUUID(); + assertEq("buckets are per player", true, b.tryTake(other)); + b.forget(id); + assertEq("forget resets", true, b.tryTake(id)); + } + + // ---- harness ---- + + private static void expectThrows(String what, LinkGate g, UUID id) { + try { + g.check(id); + } catch (LinkException expected) { + checks++; + return; + } + throw new AssertionError(what + ": expected the LinkException to propagate"); + } + + private static void assertEq(String what, Object want, Object got) { + if (want == null ? got != null : !want.equals(got)) { + throw new AssertionError(what + " = " + got + ", want " + want); + } + checks++; + } +}