Loading deploy/bootstrap.sh +6 −0 Changes for deploy/bootstrap.sh: 6 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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 Loading plugins/shared/src/main/java/best/lolicon/felis/link/Control.java +39 −0 Changes for plugins/shared/src/main/java/best/lolicon/felis/link/Control.java: 39 added lines, 0 removed lines. Original line number Diff line number Diff line package best.lolicon.felis.link; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.Map; /** Loading Loading @@ -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<String> 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() + "'"); } Loading Loading @@ -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 + "'"); } Loading Loading @@ -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<String> strList(Map<?, ?> o, String key) { List<String> 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; Loading plugins/shared/src/main/java/best/lolicon/felis/link/ControlFrame.java +63 −7 Changes for plugins/shared/src/main/java/best/lolicon/felis/link/ControlFrame.java: 63 added lines, 7 removed lines. Original line number Diff line number Diff line package best.lolicon.felis.link; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Objects; /** Loading @@ -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. * * <p>There are six frame types, discriminated by {@link #type()}: * <p>There are nine frame types, discriminated by {@link #type()}: * <ul> * <li><b>Upstream</b> (lobby → velocity): {@link #WAKE_REQUEST} and * <li><b>Upstream from the lobby</b>: {@link #WAKE_REQUEST} and * {@link #CLAIM_REQUEST} carry {@code player}+{@code server}; * {@link #STATUS_QUERY} carries {@code server}.</li> * {@link #STATUS_QUERY} carries {@code server}; {@link #LIST_REQUEST} carries * nothing.</li> * <li><b>Upstream from the login gate</b>: {@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.</li> * <li><b>Downstream</b> (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.</li> * 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.</li> * </ul> * * <p>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. * * <p>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 Loading Loading @@ -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; Loading @@ -61,9 +80,16 @@ public final class ControlFrame { private final boolean claimable; private final String code; private final String message; private final List<String> 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<String> servers) { this.type = type; this.player = player; this.server = server; Loading @@ -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) ---- Loading Loading @@ -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<String> servers) { List<String> 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() { Loading Loading @@ -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<String> servers() { return servers; } @Override public boolean equals(Object o) { if (this == o) { Loading @@ -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 Loading plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java +60 −12 Changes for plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java: 60 added lines, 12 removed lines. Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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). * * <p><b>Path safety.</b> 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; Loading Loading @@ -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)); } /** Loading @@ -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)); } /** Loading @@ -82,7 +98,7 @@ public final class FelisApiClient { Objects.requireNonNull(name, "name"); Objects.requireNonNull(mcUuid, "mcUuid"); String body = "{\"mc_uuid\":\"" + mcUuid + "\"}"; HttpResponse<String> res = send(post("/api/v1/internal/servers/" + name + "/join-event", body)); HttpResponse<String> 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()); Loading @@ -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 — Loading @@ -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)); } /** Loading Loading @@ -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. * * <p>{@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. * <p>{@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"); Loading Loading @@ -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); Loading @@ -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"); Loading plugins/shared/test/best/lolicon/felis/link/ControlRoundTripTest.java +23 −0 Changes for plugins/shared/test/best/lolicon/felis/link/ControlRoundTripTest.java: 23 added lines, 0 removed lines. Original line number Diff line number Diff line 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 Loading Loading @@ -31,6 +33,7 @@ public final class ControlRoundTripTest { wireCarriesRefinedStatusFields(); errorOmitsServerWhenAbsentButRoundTrips(); escapesAwkwardStrings(); listUpdateCarriesNamesInOrder(); rejectsMalformedAndUnknownFrames(); System.out.println("ControlRoundTripTest OK (" + checks + " checks)"); } Loading @@ -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. Loading @@ -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 Loading Loading
deploy/bootstrap.sh +6 −0 Changes for deploy/bootstrap.sh: 6 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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 Loading
plugins/shared/src/main/java/best/lolicon/felis/link/Control.java +39 −0 Changes for plugins/shared/src/main/java/best/lolicon/felis/link/Control.java: 39 added lines, 0 removed lines. Original line number Diff line number Diff line package best.lolicon.felis.link; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.Map; /** Loading Loading @@ -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<String> 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() + "'"); } Loading Loading @@ -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 + "'"); } Loading Loading @@ -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<String> strList(Map<?, ?> o, String key) { List<String> 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; Loading
plugins/shared/src/main/java/best/lolicon/felis/link/ControlFrame.java +63 −7 Changes for plugins/shared/src/main/java/best/lolicon/felis/link/ControlFrame.java: 63 added lines, 7 removed lines. Original line number Diff line number Diff line package best.lolicon.felis.link; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Objects; /** Loading @@ -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. * * <p>There are six frame types, discriminated by {@link #type()}: * <p>There are nine frame types, discriminated by {@link #type()}: * <ul> * <li><b>Upstream</b> (lobby → velocity): {@link #WAKE_REQUEST} and * <li><b>Upstream from the lobby</b>: {@link #WAKE_REQUEST} and * {@link #CLAIM_REQUEST} carry {@code player}+{@code server}; * {@link #STATUS_QUERY} carries {@code server}.</li> * {@link #STATUS_QUERY} carries {@code server}; {@link #LIST_REQUEST} carries * nothing.</li> * <li><b>Upstream from the login gate</b>: {@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.</li> * <li><b>Downstream</b> (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.</li> * 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.</li> * </ul> * * <p>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. * * <p>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 Loading Loading @@ -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; Loading @@ -61,9 +80,16 @@ public final class ControlFrame { private final boolean claimable; private final String code; private final String message; private final List<String> 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<String> servers) { this.type = type; this.player = player; this.server = server; Loading @@ -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) ---- Loading Loading @@ -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<String> servers) { List<String> 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() { Loading Loading @@ -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<String> servers() { return servers; } @Override public boolean equals(Object o) { if (this == o) { Loading @@ -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 Loading
plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java +60 −12 Changes for plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java: 60 added lines, 12 removed lines. Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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). * * <p><b>Path safety.</b> 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; Loading Loading @@ -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)); } /** Loading @@ -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)); } /** Loading @@ -82,7 +98,7 @@ public final class FelisApiClient { Objects.requireNonNull(name, "name"); Objects.requireNonNull(mcUuid, "mcUuid"); String body = "{\"mc_uuid\":\"" + mcUuid + "\"}"; HttpResponse<String> res = send(post("/api/v1/internal/servers/" + name + "/join-event", body)); HttpResponse<String> 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()); Loading @@ -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 — Loading @@ -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)); } /** Loading Loading @@ -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. * * <p>{@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. * <p>{@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"); Loading Loading @@ -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); Loading @@ -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"); Loading
plugins/shared/test/best/lolicon/felis/link/ControlRoundTripTest.java +23 −0 Changes for plugins/shared/test/best/lolicon/felis/link/ControlRoundTripTest.java: 23 added lines, 0 removed lines. Original line number Diff line number Diff line 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 Loading Loading @@ -31,6 +33,7 @@ public final class ControlRoundTripTest { wireCarriesRefinedStatusFields(); errorOmitsServerWhenAbsentButRoundTrips(); escapesAwkwardStrings(); listUpdateCarriesNamesInOrder(); rejectsMalformedAndUnknownFrames(); System.out.println("ControlRoundTripTest OK (" + checks + " checks)"); } Loading @@ -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. Loading @@ -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 Loading