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

fix(velocity): felis:control 按来源服务器限权并关闭 bungeecord 通道

parent 3247b9e6
Loading
Loading
Loading
Loading
+6 −0
Changes for deploy/bootstrap.sh: 6 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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
+39 −0
Changes for plugins/shared/src/main/java/best/lolicon/felis/link/Control.java: 39 added lines, 0 removed lines.
Original line number Diff line number Diff line
package best.lolicon.felis.link;

import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

/**
@@ -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;
+63 −7
Changes for plugins/shared/src/main/java/best/lolicon/felis/link/ControlFrame.java: 63 added lines, 7 removed lines.
Original line number Diff line number Diff line
package best.lolicon.felis.link;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;

/**
@@ -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
+60 −12
Changes for plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java: 60 added lines, 12 removed lines.
Original line number Diff line number Diff line
@@ -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");
+23 −0
Changes for plugins/shared/test/best/lolicon/felis/link/ControlRoundTripTest.java: 23 added lines, 0 removed lines.
Original line number Diff line number Diff line
package best.lolicon.felis.link;

import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;

/**
 * ControlRoundTripTest is a hermetic, dependency-free check of the {@code
@@ -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
Loading