fix(velocity): felis:control 按来源服务器限权并关闭 bungeecord 通道
This commit is contained in:
15 files changed
+1148
-57
No files matched your search
@@ -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
|
||||
|
||||
@@ -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<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() + "'");
|
||||
}
|
||||
@@ -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<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;
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <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
|
||||
@@ -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<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;
|
||||
@@ -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<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() {
|
||||
@@ -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) {
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
*
|
||||
* <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;
|
||||
|
||||
@@ -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<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());
|
||||
@@ -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.
|
||||
*
|
||||
* <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");
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>Run: {@code javac -d <out> shared/src/main/java/best/lolicon/felis/link/*.java
|
||||
* shared/test/best/lolicon/felis/link/FelisApiClientTest.java && java -cp <out>
|
||||
* best.lolicon.felis.link.FelisApiClientTest}.
|
||||
*/
|
||||
public final class FelisApiClientTest {
|
||||
|
||||
private static int checks;
|
||||
private static final List<String> 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++;
|
||||
}
|
||||
}
|
||||
+25
-2
@@ -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" \
|
||||
|
||||
@@ -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;
|
||||
*
|
||||
* <p><b>Anti-spoof (spec §14).</b> 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
|
||||
* <em>server</em> 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.
|
||||
*
|
||||
* <p><b>Load.</b> 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.
|
||||
*
|
||||
* <p><b>Threading.</b> {@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.
|
||||
*
|
||||
* <p>The three upstream frames map onto the menu's buttons (spec §12): a
|
||||
* <p>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}).
|
||||
*
|
||||
* <p>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<String, CachedStatus> statusCache = new ConcurrentHashMap<>();
|
||||
private final Map<String, Long> 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<String> listed() {
|
||||
List<String> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
*
|
||||
* <p>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:
|
||||
*
|
||||
* <ul>
|
||||
* <li>the lobby may send {@code StatusQuery}, {@code WakeRequest},
|
||||
* {@code ClaimRequest} and {@code ListRequest};</li>
|
||||
* <li>the login gate may send {@code LoginRelease} and nothing else;</li>
|
||||
* <li>any other backend is refused outright, whatever the type.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>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<String> 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<String> managed;
|
||||
|
||||
ControlPolicy(String loginServer, String lobbyServer, Predicate<String> 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);
|
||||
}
|
||||
}
|
||||
+70
-1
@@ -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<ServerView> 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(
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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<UUID, double[]> 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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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<UUID, Long> 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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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<UUID, Waiter> waiting = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, String> pendingTargets = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, Long> 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<RegisteredServer> 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<String, Boolean> readyCache = new HashMap<>(); // one status poll per distinct server
|
||||
for (Map.Entry<UUID, Waiter> 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 + "」。请重试。"
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>Run: {@code javac -d <out> 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 <out>
|
||||
* best.lolicon.felis.velocity.ControlPolicyTest}.
|
||||
*/
|
||||
public final class ControlPolicyTest {
|
||||
|
||||
private static int checks;
|
||||
|
||||
private static final Set<String> 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++;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>Run: {@code javac -d <out> 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 <out>
|
||||
* 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++;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user