diff --git a/PROTOCOL.md b/PROTOCOL.md index 7e9f415..9e16a55 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -72,7 +72,7 @@ The hub reads exactly one Handshake packet and dispatches on `Intent`: |---------------|---------| | `17` | redapricot session establishment (control session *or* worker conn). | | `18` | Reserved for redapricot management/status. Never matched against patterns. The reference hub replies with a status line and closes. | -| anything else | **Player** connection. `ServerAddress` is matched (case-insensitively) against registered PATTERNs. | +| anything else | **Player** connection. `ServerAddress` is matched against the registered **regex** PATTERNs (§5.1). | For `Intent == 17` the hub additionally requires `ServerAddress == lowercase_hex(SHA3-224(PSK))` — a 56-character hex string. @@ -81,7 +81,8 @@ the connection. For player connections the hub normalizes `ServerAddress` before matching: lower-cased, and any trailing `.` or Forge/FML `\0`-suffix (`host\0FML\0`) -stripped to the bare hostname. +stripped to the bare hostname. The resulting hostname is then tested against the +registered regex patterns (§5.1). ## 3. Encryption @@ -181,21 +182,50 @@ Type : u8 | `0x00` | SessionReady | S → C | *(none)* — the confirmation frame from §4 | | `0x01` | Register | C → S | `Pattern: String` | | `0x02` | Unregister | C → S | `Pattern: String` | -| `0x03` | RegisterAck | S → C | `Pattern: String`, `Status: u8` (0 = ok) | -| `0x04` | ControlRequest | S → C | `CID: Bytes[16]`, `Pattern: String`, `PlayerIP: String`, `PlayerPort: U16` | +| `0x03` | RegisterAck | S → C | `Pattern: String`, `Status: u8` (0 = ok, 1 = invalid pattern) | +| `0x04` | ControlRequest | S → C | `CID: Bytes[16]`, `Pattern: String`, `PlayerIP: String`, `PlayerPort: U16`, `Username: String` | | `0x05` | Ping | C → S | `Nonce: I64` | | `0x06` | Pong | S → C | `Nonce: I64` | * **Register / Unregister**: the client may (un)register a PATTERN at any time. - Patterns are stored lower-cased. Re-registering an existing pattern reassigns - it to the newest session (last writer wins). + A PATTERN is a **regular expression** (§5.1) and is stored **verbatim** — the + exact string is the registry key (never normalized, so regex metacharacters are + preserved). Re-registering the identical pattern string reassigns it to the + newest session (last writer wins); `Unregister` only removes it if the + requesting session still owns it. +* **RegisterAck**: acknowledges a `Register`. `Status` is `0` on success, or `1` + if the pattern is not a valid regular expression (in which case nothing is + registered). The `Pattern` echoes the string that was registered. * **ControlRequest**: emitted by the hub when a player Handshake matches a - PATTERN this session registered. `CID` is 16 cryptographically-random bytes - generated by the hub, unique to that pending player. `PlayerIP`/`PlayerPort` - are the player's source address (used for HAProxy v2). + PATTERN this session registered. `Pattern` is the **registered pattern string + that matched** (echoed verbatim), *not* the player's hostname — so the client + can look the pattern up in its own route table. `CID` is 16 + cryptographically-random bytes generated by the hub, unique to that pending + player. `PlayerIP`/`PlayerPort` are the player's source address (used for + HAProxy v2). `Username` is the player's name, read best-effort from the Login + Start packet — present when the client pipelined it with the Handshake (the + usual case), otherwise an empty string. It is informational (logging) only. * **Ping/Pong**: optional keepalive so idle control sessions survive NAT timeouts. The client pings periodically; the hub echoes the nonce. +### 5.1 Pattern matching + +A registered PATTERN is a **regular expression** (the reference hub uses +`java.util.regex`). Matching is: + +* **Case-insensitive** — patterns are compiled with a case-insensitive flag, and + the player hostname is lower-cased during normalization (§2.1). +* **Whole-string (anchored)** — the pattern must match the *entire* normalized + hostname, as if wrapped in `^…$`. `mc\.example\.com` matches `mc.example.com` + but not `mc.example.com.evil` or `sub.mc.example.com`. +* **First match wins** — the hostname is tested against every registered pattern; + the first that matches routes the player. If several patterns overlap, which + one wins is unspecified. + +Because the pattern is a regex, a literal dot must be escaped (`mc\.example\.com`); +an unescaped `.` is the regex "any character" wildcard. A pattern that fails to +compile is rejected at `Register` time with `RegisterAck` status `1`. + ## 6. Error frame (any redapricot connection) At any time either side may send, then close: @@ -310,11 +340,17 @@ big-endian. "maxConn": 4, "pingIntervalMs": 20000, "mappings": [ - { "pattern": "mc.example.com", "destination": "127.0.0.1:25566", "proxyProtocol": true } + { "pattern": "mc\\.example\\.com", "destination": "127.0.0.1:25566", "proxyProtocol": true } ] } ``` +Each `pattern` is a regular expression (§5.1) matched against the whole +normalized player hostname, case-insensitively. Escape literal dots (`mc\.example\.com`, +which is `mc\\.example\\.com` in JSON); an unescaped `.` matches any character. +Use ordinary regex to route wildcards, e.g. `.*\.example\.com` for every +subdomain or `(alpha|beta)\.mc\.net` for a fixed set. + ## 10. Constants summary | Name | Value | @@ -327,6 +363,8 @@ big-endian. | key derivation | `SHA3-256(PK ‖ 0x01)` c→s, `SHA3-256(PK ‖ 0x02)` s→c | | rekey material | `Rand ‖ Timestamp(I64 BE)` | | Magic: control / worker | `0x01` / `0x02` | +| RegisterAck status: ok / invalid pattern | `0x00` / `0x01` | +| pattern matching | case-insensitive, whole-string regex; first match wins | | CID length | 16 bytes | | max frame payload | 1 MiB | | saturation threshold | active streams `> 8` | diff --git a/README.md b/README.md index f566c39..98facf1 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,9 @@ A client opens a **control session** to the hub: it sends a Minecraft handshake with `Intent = 17` and a `Server Address` equal to `hex(SHA3-224(PSK))`, then the link switches to ChaCha20-encrypted frames keyed by the shared **PSK**, re-keyed to a per-connection secret. Over that session the client **registers** -one or more hostname patterns. When a player connects to the hub with a matching -hostname (and any normal `Intent`), the hub assigns a random **CID**, buffers +one or more hostname patterns — each a **regular expression**. When a player +connects to the hub with a hostname that matches a registered pattern (and any +normal `Intent`), the hub assigns a random **CID**, buffers the player's bytes, and asks the client (via the control session) to take over. The client picks a **worker connection** — a multiplexed, encrypted TCP link that carries many players as lightweight *streams* — opens a stream for that CID, @@ -114,7 +115,7 @@ cp client/config.example.json client.json # "psk": "same-as-the-hub", # "maxConn": 4, # "mappings": [ -# { "pattern": "mc.example.com", "destination": "127.0.0.1:25566", "proxyProtocol": true } +# { "pattern": "mc\\.example\\.com", "destination": "127.0.0.1:25566", "proxyProtocol": true } # ] # } bin/redapricot-client client.json @@ -122,8 +123,8 @@ bin/redapricot-client client.json **3. Connect a player.** Point a DNS record for `mc.example.com` at the hub (or just add the hub's IP with that hostname), then join `mc.example.com` in -Minecraft. The hub matches the pattern and tunnels you to `127.0.0.1:25566` -behind the client. With `proxyProtocol: true`, the real server sees your true IP +Minecraft. The hub matches the hostname against the registered regex patterns +and tunnels you to `127.0.0.1:25566` behind the client. With `proxyProtocol: true`, the real server sees your true IP (enable `proxy-protocol` / a compatible front-end on that server to consume it). ## Container image (client) @@ -172,7 +173,7 @@ secrets. The base image and build flags live in `.ko.yaml`. | `maxConn` | `1` (clamped 1–8) | Max worker connections in the pool. | | `pingIntervalMs` | `20000` | Control-session keepalive interval. | | `mappings[]` | *(≥1 required)* | Route table (below). | -| `mappings[].pattern` | — | Hostname players use (matched case-insensitively). | +| `mappings[].pattern` | — | Regex matched against the whole player hostname, case-insensitively. Escape dots (`mc\.example\.com`); `.` is a wildcard. | | `mappings[].destination` | — | Real server `host:port` to forward to. | | `mappings[].proxyProtocol` | `false` | Prepend a HAProxy v2 header carrying the player's IP. | @@ -197,7 +198,8 @@ go test ./e2e/... -v ``` The e2e suite covers: a full player round-trip with verbatim handshake -forwarding and case-insensitive matching, multi-megabyte transfers, concurrent +forwarding and case-insensitive matching, regex wildcard pattern routing, +multi-megabyte transfers, concurrent streams spreading across multiple worker connections, HAProxy v2 source-address propagation, player- and destination-initiated disconnect propagation, wrong-PSK rejection, and dropping of unmatched hostnames. The Go and Java crypto layers are diff --git a/client/client.go b/client/client.go index 04591a6..52c3525 100644 --- a/client/client.go +++ b/client/client.go @@ -191,7 +191,8 @@ func (c *Client) dispatchControl(payload []byte) { pattern, _ := r.String() ip, _ := r.String() port, _ := r.U16() - go c.handleControlRequest(cid, pattern, ip, int(port)) + username, _ := r.String() + go c.handleControlRequest(cid, pattern, ip, int(port), username) case CtlPong: // ignore default: @@ -217,12 +218,13 @@ func (c *Client) pingLoop(ctx context.Context, fc *wire.FramedConn) { // handleControlRequest reacts to a matched player: allocate a worker stream, // SYN it, and bridge it to the mapped destination. -func (c *Client) handleControlRequest(cid []byte, pattern, ip string, port int) { +func (c *Client) handleControlRequest(cid []byte, pattern, ip string, port int, username string) { mapping, ok := c.mappings[NormalizeAddress(pattern)] if !ok { log.Printf("control-request for unmapped pattern %q; ignoring", pattern) return } + log.Printf("player %s:%d joined as %q via pattern %q -> %s", ip, port, username, pattern, mapping.Destination) wc, sid, err := c.pool.Allocate() if err != nil { log.Printf("worker allocate failed: %v", err) diff --git a/client/config.example.json b/client/config.example.json index 5893629..ad385ed 100644 --- a/client/config.example.json +++ b/client/config.example.json @@ -5,7 +5,7 @@ "pingIntervalMs": 20000, "mappings": [ { - "pattern": "mc.example.com", + "pattern": "mc\\.example\\.com", "destination": "127.0.0.1:25566", "proxyProtocol": true } diff --git a/docs/architecture.md b/docs/architecture.md index 2d02c9a..82a303a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -51,7 +51,7 @@ Client Hub │─ Frame#1 [magic=0x01, rand, ts] ──────▶ check |now-ts| ≤ window │ (both switch to Phase-B keys = ChaCha20(SHA3-256(rand‖ts ‖ dir))) │◀──────────── Frame [SessionReady] ─────│ - │─ Register("mc.example.com") ──────────▶ patterns["mc.example.com"] = session + │─ Register("mc\.example\.com") ────────▶ patterns["mc\.example\.com"] = (regex, session) │◀──────────── RegisterAck ──────────────│ │ ... periodic Ping/Pong ... │ ``` @@ -64,10 +64,10 @@ share a keystream beyond that first frame. ``` Player Hub Client Destination - │─ Handshake(addr="mc.example.com", Intent=2)─▶ normalize+match + │─ Handshake(addr="mc.example.com", Intent=2)─▶ normalize + regex-match │ (+ maybe pipelined Login Start) │ pause player socket, │ │ buffer bytes, mint CID - │ │─ ControlRequest(CID, pattern, ip:port) ─▶ + │ │─ ControlRequest(CID, pattern, ip:port, user) ─▶ │ │ allocate worker+stream │ │◀──────── SYN(streamId, CID) ────────────│ │ │ takePending(CID) → bind dial destination, @@ -81,6 +81,12 @@ Player Hub Client Destinatio Key points: +* **Patterns are regexes.** Each registered pattern is a case-insensitive + regular expression, matched against the *whole* normalized hostname (anchored, + first match wins). The hub echoes the **matched pattern string** — not the + player's hostname — in `ControlRequest`, so the client can look it straight up + in its own route table. Invalid patterns are rejected at registration with a + non-zero `RegisterAck` status. * The hub **pauses** the player socket the instant it matches, so no player bytes are lost while the takeover is arranged; the buffered handshake is forwarded **verbatim**, so the real server sees exactly what the player sent @@ -187,8 +193,9 @@ trade for a near-zero-overhead mux. 2. No per-stream flow control (see §6). 3. Single-event-loop hub (see §5) bounds throughput to one core. 4. `Intent 18` is reserved but only stubbed (the hub logs and closes). -5. Pattern ownership is last-writer-wins; two clients registering the same - hostname will silently reassign it. +5. Pattern ownership is last-writer-wins; two clients registering the identical + pattern string will silently reassign it. Overlapping-but-distinct regexes are + both kept, and when several match one hostname the winner is unspecified. These are deliberate scope choices for a connectivity-focused P2P tool, not oversights; each is a small, well-isolated change away from being hardened. diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 1e62aa5..1fd3142 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -63,6 +63,37 @@ func TestRoundTrip(t *testing.T) { } } +// TestRegexPatternMatch verifies a wildcard regex pattern routes a matching +// player (whole-hostname, case-insensitive) and drops a non-matching one, and +// that the client maps the hub-echoed pattern back to its destination. +func TestRegexPatternMatch(t *testing.T) { + const psk = "e2e-regex" + port := freePort(t) + hubAddr := fmt.Sprintf("127.0.0.1:%d", port) + startHub(t, port, psk) + dest := newMockDest(t, modeEcho) + startClient(t, hubAddr, psk, 2, []client.Mapping{ + {Pattern: `mc\d+\.local`, Destination: dest.addr}, + }) + + // Matches the regex (digit wildcard, mixed case); the whole hostname matches. + pc := dialPlayer(t, hubAddr, "MC7.Local") + defer pc.Close() + playerEcho(t, pc, []byte("regex hello")) + ev := dest.waitEvent(t, 5*time.Second) + if ev.handshakeAddr != "MC7.Local" { + t.Fatalf("destination saw handshake address %q, want verbatim %q", ev.handshakeAddr, "MC7.Local") + } + + // A host that does not fully match the pattern is dropped ('\d+' needs a digit). + pc2 := dialPlayer(t, hubAddr, "mc.local") + defer pc2.Close() + _ = pc2.SetReadDeadline(time.Now().Add(3 * time.Second)) + if _, err := pc2.Read(make([]byte, 16)); err == nil { + t.Fatalf("expected the hub to drop a host that does not match the regex") + } +} + // TestLargeTransfer pushes a multi-megabyte payload both ways to exercise mux // framing and back-pressure. func TestLargeTransfer(t *testing.T) { diff --git a/server/src/main/java/io/icybear/redapricot/ControlSession.java b/server/src/main/java/io/icybear/redapricot/ControlSession.java index b3715b5..913c07e 100644 --- a/server/src/main/java/io/icybear/redapricot/ControlSession.java +++ b/server/src/main/java/io/icybear/redapricot/ControlSession.java @@ -27,8 +27,8 @@ public final class ControlSession { switch (type) { case Protocol.CTL_REGISTER -> { String pattern = r.readString(); - hub.register(pattern, this); - sendRegisterAck(pattern, 0); + int status = hub.register(pattern, this); + sendRegisterAck(pattern, status); } case Protocol.CTL_UNREGISTER -> { String pattern = r.readString(); @@ -43,16 +43,17 @@ public final class ControlSession { } } - public void sendControlRequest(byte[] cid, String pattern, String playerIp, int playerPort) { + public void sendControlRequest(byte[] cid, String pattern, String playerIp, int playerPort, String username) { byte[] msg = new ProtoWriter() .u8(Protocol.CTL_CONTROL_REQUEST) .bytes(cid) .string(pattern) .string(playerIp) .u16(playerPort) + .string(username) .toBytes(); frames.send(msg); - LOG.info("control-request pattern={} player={}:{}", pattern, playerIp, playerPort); + LOG.info("control-request pattern={} player={}:{} user={}", pattern, playerIp, playerPort, username); } private void sendRegisterAck(String pattern, int status) { diff --git a/server/src/main/java/io/icybear/redapricot/Hub.java b/server/src/main/java/io/icybear/redapricot/Hub.java index 845435f..d19199a 100644 --- a/server/src/main/java/io/icybear/redapricot/Hub.java +++ b/server/src/main/java/io/icybear/redapricot/Hub.java @@ -7,11 +7,12 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import java.nio.charset.StandardCharsets; -import java.security.SecureRandom; import java.util.Locale; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ThreadLocalRandom; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; /** * Shared hub state: the pattern registry and the pending-player table. A single @@ -26,9 +27,15 @@ public final class Hub { public final byte[] pskBytes; public final String pskAddress; - private final Map patterns = new ConcurrentHashMap<>(); + private final Map patterns = new ConcurrentHashMap<>(); private final Map pending = new ConcurrentHashMap<>(); + /** A compiled routing pattern and the control session that registered it. */ + private record Registration(Pattern regex, ControlSession session) {} + + /** A successful match: the registered pattern that matched and its owning session. */ + public record Match(String pattern, ControlSession session) {} + public Hub(Vertx vertx, Config config) { this.vertx = vertx; this.config = config; @@ -38,24 +45,51 @@ public final class Hub { // ---- pattern registry ---- - public void register(String pattern, ControlSession session) { - String key = normalizeAddress(pattern); - patterns.put(key, session); - LOG.info("registered pattern '{}' -> {}", key, session.id()); + /** + * Compile {@code pattern} as a case-insensitive regular expression and register + * it for {@code session}. The pattern string is the registry key, used verbatim + * (never normalized — normalizing would corrupt regex metacharacters). Re-registering + * an existing pattern reassigns it to the newest session (last writer wins). + * + * @return {@link Protocol#REGISTER_OK} on success, or {@link Protocol#REGISTER_ERR_PATTERN} + * if {@code pattern} is not a valid regular expression (nothing is stored). + */ + public int register(String pattern, ControlSession session) { + Pattern regex; + try { + regex = Pattern.compile(pattern, Pattern.CASE_INSENSITIVE); + } catch (PatternSyntaxException e) { + LOG.warn("rejecting invalid pattern '{}': {}", pattern, e.getMessage()); + return Protocol.REGISTER_ERR_PATTERN; + } + patterns.put(pattern, new Registration(regex, session)); + LOG.info("registered pattern '{}' -> {}", pattern, session.id()); + return Protocol.REGISTER_OK; } public void unregister(String pattern, ControlSession session) { - String key = normalizeAddress(pattern); - patterns.remove(key, session); + // Remove only if this session still owns the pattern (a newer session may have taken it). + patterns.computeIfPresent(pattern, (k, reg) -> reg.session() == session ? null : reg); } - public ControlSession match(String address) { - return patterns.get(normalizeAddress(address)); + /** + * Find the first registered pattern whose regex matches the whole normalized + * hostname, or {@code null} if none match. If several patterns match, which one + * is returned is unspecified. + */ + public Match match(String address) { + String host = normalizeAddress(address); + for (Map.Entry e : patterns.entrySet()) { + if (e.getValue().regex().matcher(host).matches()) { + return new Match(e.getKey(), e.getValue().session()); + } + } + return null; } /** Drop every pattern owned by a (closing) session, plus any players still pending for it. */ public void removeSession(ControlSession session) { - patterns.entrySet().removeIf(e -> e.getValue() == session); + patterns.entrySet().removeIf(e -> e.getValue().session() == session); pending.values().removeIf(p -> { if (p.getOwner() != session) return false; if (p.getTimerId() >= 0) vertx.cancelTimer(p.getTimerId()); @@ -98,7 +132,11 @@ public final class Hub { // ---- helpers ---- - /** Lower-cased, FML-suffix-stripped, trailing-dot-stripped hostname. */ + /** + * Normalize a player's requested hostname before regex matching: lower-cased, + * FML/Forge {@code \0}-suffix stripped, trailing dots stripped. Applied to the + * incoming address only — registered patterns are kept verbatim. + */ public static String normalizeAddress(String addr) { int nul = addr.indexOf('\0'); if (nul >= 0) addr = addr.substring(0, nul); diff --git a/server/src/main/java/io/icybear/redapricot/HubConnection.java b/server/src/main/java/io/icybear/redapricot/HubConnection.java index 33c590f..41f0d4c 100644 --- a/server/src/main/java/io/icybear/redapricot/HubConnection.java +++ b/server/src/main/java/io/icybear/redapricot/HubConnection.java @@ -105,7 +105,7 @@ public final class HubConnection { LOG.info("{} reserved intent 18; closing", id); socket.close(); } else { - handlePlayer(address); + handlePlayer(address, intent, afterHandshake); } } @@ -177,18 +177,25 @@ public final class HubConnection { // ---- player connection ---- - private void handlePlayer(String address) { - String pattern = Hub.normalizeAddress(address); - ControlSession session = hub.match(address); - if (session == null) { - LOG.info("{} no route for '{}'; closing", id, pattern); + private void handlePlayer(String address, int intent, Buffer afterHandshake) { + String host = Hub.normalizeAddress(address); + Hub.Match matched = hub.match(address); + if (matched == null) { + LOG.info("{} no route for '{}'; closing", id, host); socket.close(); return; } + // Echo the registered pattern (not the player's hostname) so the client can + // map it back to a destination. + ControlSession session = matched.session(); + String pattern = matched.pattern(); byte[] cid = hub.newCid(); String cidHex = Hex.encode(cid); String ip = socket.remoteAddress() != null ? socket.remoteAddress().host() : "0.0.0.0"; int port = socket.remoteAddress() != null ? socket.remoteAddress().port() : 0; + // Best-effort username: only login/transfer intents carry a Login Start, and only + // if the client pipelined it into this same buffer (the usual case). + String username = (intent == 2 || intent == 3) ? parseLoginName(afterHandshake) : ""; socket.pause(); Buffer buffered = hs.copy(); // handshake + any pipelined bytes, forwarded verbatim @@ -197,7 +204,27 @@ public final class HubConnection { hub.addPending(p); closeCleanup = () -> hub.removePending(cidHex); - session.sendControlRequest(cid, pattern, ip, port); - LOG.info("{} player {}:{} matched '{}' cid={}", id, ip, port, pattern, cidHex); + session.sendControlRequest(cid, pattern, ip, port, username); + LOG.info("{} player {}:{} host '{}' user '{}' matched pattern '{}' cid={}", + id, ip, port, host, username, pattern, cidHex); + } + + /** + * Best-effort read of the player's username from a pipelined Login Start packet + * (Login state, packet id 0x00, first field {@code Name: String}). Returns "" if + * the packet is not (yet) fully present or does not parse as a Login Start; the hub + * never blocks waiting for it. + */ + private static String parseLoginName(Buffer afterHandshake) { + if (afterHandshake == null || afterHandshake.length() == 0) return ""; + try { + ProtoReader r = new ProtoReader(afterHandshake); + int pktLen = r.readVarInt(); + if (pktLen <= 0 || pktLen > r.remaining()) return ""; // not fully buffered + if (r.readVarInt() != 0x00) return ""; // not a Login Start + return r.readString(); + } catch (RuntimeException e) { + return ""; + } } } diff --git a/server/src/main/java/io/icybear/redapricot/Protocol.java b/server/src/main/java/io/icybear/redapricot/Protocol.java index 67d6f62..791304b 100644 --- a/server/src/main/java/io/icybear/redapricot/Protocol.java +++ b/server/src/main/java/io/icybear/redapricot/Protocol.java @@ -22,6 +22,10 @@ public final class Protocol { public static final int CTL_PING = 0x05; public static final int CTL_PONG = 0x06; + // RegisterAck status codes + public static final int REGISTER_OK = 0x00; + public static final int REGISTER_ERR_PATTERN = 0x01; // pattern is not a valid regular expression + // Worker-conn mux frame types public static final int MUX_SYN = 0x00; public static final int MUX_DATA = 0x01; diff --git a/server/src/test/java/io/icybear/redapricot/CryptoCodecTest.java b/server/src/test/java/io/icybear/redapricot/CryptoCodecTest.java index 5ff0fdc..657de50 100644 --- a/server/src/test/java/io/icybear/redapricot/CryptoCodecTest.java +++ b/server/src/test/java/io/icybear/redapricot/CryptoCodecTest.java @@ -10,7 +10,10 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; class CryptoCodecTest { @@ -71,4 +74,52 @@ class CryptoCodecTest { assertEquals("mc.example.com", Hub.normalizeAddress("mc.example.com.")); assertEquals("mc.example.com", Hub.normalizeAddress("MC.Example.com\u0000FML\u00000")); } + + /** A Hub whose event loop is never touched (register/match/normalize use no Vert.x state). */ + private static Hub testHub() { + return new Hub(null, new Config("0.0.0.0", 25565, "test-psk", 30_000L, 10_000L)); + } + + private static ControlSession testSession(Hub hub, String id) { + return new ControlSession(hub, null, id); + } + + @Test + void matchesRegexPatternCaseInsensitivelyAndAnchored() { + Hub hub = testHub(); + ControlSession s = testSession(hub, "s1"); + assertEquals(Protocol.REGISTER_OK, hub.register("mc\\d+\\.example\\.com", s)); + + // Case-insensitive, digit wildcard, whole-string (anchored) match. + Hub.Match m = hub.match("MC7.Example.com"); + assertNotNull(m); + assertEquals("mc\\d+\\.example\\.com", m.pattern()); // the registered pattern, echoed verbatim + assertSame(s, m.session()); + + assertNull(hub.match("mc.example.com")); // \d+ needs a digit + assertNull(hub.match("mc7.example.com.evil")); // anchored: no trailing suffix + assertNull(hub.match("evil.mc7.example.com")); // anchored: no leading prefix + } + + @Test + void unregisterRemovesOnlyOwnedPattern() { + Hub hub = testHub(); + ControlSession a = testSession(hub, "a"); + ControlSession b = testSession(hub, "b"); + hub.register("mc\\.example\\.com", a); + + hub.unregister("mc\\.example\\.com", b); // not the owner -> no-op + assertNotNull(hub.match("mc.example.com")); + + hub.unregister("mc\\.example\\.com", a); // owner -> removed + assertNull(hub.match("mc.example.com")); + } + + @Test + void invalidRegexIsRejectedAndNotStored() { + Hub hub = testHub(); + ControlSession s = testSession(hub, "s1"); + assertNotEquals(Protocol.REGISTER_OK, hub.register("mc[.example.com", s)); // unbalanced '[' + assertNull(hub.match("mc.example.com")); + } }