fix(velocity): felis:control 按来源服务器限权并关闭 bungeecord 通道
This commit is contained in:
15 files changed
+1148
-57
No files matched your search
@@ -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++;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user