support regex and player name sniff

This commit is contained in:
iceBear67
2026-07-15 21:26:50 +08:00
parent 3a5ad7e318
commit ada07e0e36
11 changed files with 250 additions and 49 deletions
+48 -10
View File
@@ -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` |
+9 -7
View File
@@ -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 18) | 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
+4 -2
View File
@@ -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)
+1 -1
View File
@@ -5,7 +5,7 @@
"pingIntervalMs": 20000,
"mappings": [
{
"pattern": "mc.example.com",
"pattern": "mc\\.example\\.com",
"destination": "127.0.0.1:25566",
"proxyProtocol": true
}
+12 -5
View File
@@ -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.
+31
View File
@@ -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) {
@@ -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) {
@@ -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<String, ControlSession> patterns = new ConcurrentHashMap<>();
private final Map<String, Registration> patterns = new ConcurrentHashMap<>();
private final Map<String, PendingPlayer> 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<String, Registration> 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);
@@ -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 "";
}
}
}
@@ -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;
@@ -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"));
}
}