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). | | `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. | | `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 For `Intent == 17` the hub additionally requires
`ServerAddress == lowercase_hex(SHA3-224(PSK))` — a 56-character hex string. `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: For player connections the hub normalizes `ServerAddress` before matching:
lower-cased, and any trailing `.` or Forge/FML `\0`-suffix (`host\0FML\0`) 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 ## 3. Encryption
@@ -181,21 +182,50 @@ Type : u8
| `0x00` | SessionReady | S → C | *(none)* — the confirmation frame from §4 | | `0x00` | SessionReady | S → C | *(none)* — the confirmation frame from §4 |
| `0x01` | Register | C → S | `Pattern: String` | | `0x01` | Register | C → S | `Pattern: String` |
| `0x02` | Unregister | C → S | `Pattern: String` | | `0x02` | Unregister | C → S | `Pattern: String` |
| `0x03` | RegisterAck | S → C | `Pattern: String`, `Status: u8` (0 = ok) | | `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` | | `0x04` | ControlRequest | S → C | `CID: Bytes[16]`, `Pattern: String`, `PlayerIP: String`, `PlayerPort: U16`, `Username: String` |
| `0x05` | Ping | C → S | `Nonce: I64` | | `0x05` | Ping | C → S | `Nonce: I64` |
| `0x06` | Pong | S → C | `Nonce: I64` | | `0x06` | Pong | S → C | `Nonce: I64` |
* **Register / Unregister**: the client may (un)register a PATTERN at any time. * **Register / Unregister**: the client may (un)register a PATTERN at any time.
Patterns are stored lower-cased. Re-registering an existing pattern reassigns A PATTERN is a **regular expression** (§5.1) and is stored **verbatim** — the
it to the newest session (last writer wins). 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 * **ControlRequest**: emitted by the hub when a player Handshake matches a
PATTERN this session registered. `CID` is 16 cryptographically-random bytes PATTERN this session registered. `Pattern` is the **registered pattern string
generated by the hub, unique to that pending player. `PlayerIP`/`PlayerPort` that matched** (echoed verbatim), *not* the player's hostname — so the client
are the player's source address (used for HAProxy v2). 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 * **Ping/Pong**: optional keepalive so idle control sessions survive NAT
timeouts. The client pings periodically; the hub echoes the nonce. 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) ## 6. Error frame (any redapricot connection)
At any time either side may send, then close: At any time either side may send, then close:
@@ -310,11 +340,17 @@ big-endian.
"maxConn": 4, "maxConn": 4,
"pingIntervalMs": 20000, "pingIntervalMs": 20000,
"mappings": [ "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 ## 10. Constants summary
| Name | Value | | Name | Value |
@@ -327,6 +363,8 @@ big-endian.
| key derivation | `SHA3-256(PK ‖ 0x01)` c→s, `SHA3-256(PK ‖ 0x02)` s→c | | key derivation | `SHA3-256(PK ‖ 0x01)` c→s, `SHA3-256(PK ‖ 0x02)` s→c |
| rekey material | `Rand ‖ Timestamp(I64 BE)` | | rekey material | `Rand ‖ Timestamp(I64 BE)` |
| Magic: control / worker | `0x01` / `0x02` | | 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 | | CID length | 16 bytes |
| max frame payload | 1 MiB | | max frame payload | 1 MiB |
| saturation threshold | active streams `> 8` | | 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 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**, 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** 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 one or more hostname patterns — each a **regular expression**. When a player
hostname (and any normal `Intent`), the hub assigns a random **CID**, buffers 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 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 The client picks a **worker connection** — a multiplexed, encrypted TCP link
that carries many players as lightweight *streams* — opens a stream for that CID, 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", # "psk": "same-as-the-hub",
# "maxConn": 4, # "maxConn": 4,
# "mappings": [ # "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 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 **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 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` Minecraft. The hub matches the hostname against the registered regex patterns
behind the client. With `proxyProtocol: true`, the real server sees your true IP 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). (enable `proxy-protocol` / a compatible front-end on that server to consume it).
## Container image (client) ## 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. | | `maxConn` | `1` (clamped 18) | Max worker connections in the pool. |
| `pingIntervalMs` | `20000` | Control-session keepalive interval. | | `pingIntervalMs` | `20000` | Control-session keepalive interval. |
| `mappings[]` | *(≥1 required)* | Route table (below). | | `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[].destination` | — | Real server `host:port` to forward to. |
| `mappings[].proxyProtocol` | `false` | Prepend a HAProxy v2 header carrying the player's IP. | | `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 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 streams spreading across multiple worker connections, HAProxy v2 source-address
propagation, player- and destination-initiated disconnect propagation, wrong-PSK propagation, player- and destination-initiated disconnect propagation, wrong-PSK
rejection, and dropping of unmatched hostnames. The Go and Java crypto layers are 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() pattern, _ := r.String()
ip, _ := r.String() ip, _ := r.String()
port, _ := r.U16() 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: case CtlPong:
// ignore // ignore
default: 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, // handleControlRequest reacts to a matched player: allocate a worker stream,
// SYN it, and bridge it to the mapped destination. // 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)] mapping, ok := c.mappings[NormalizeAddress(pattern)]
if !ok { if !ok {
log.Printf("control-request for unmapped pattern %q; ignoring", pattern) log.Printf("control-request for unmapped pattern %q; ignoring", pattern)
return 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() wc, sid, err := c.pool.Allocate()
if err != nil { if err != nil {
log.Printf("worker allocate failed: %v", err) log.Printf("worker allocate failed: %v", err)
+1 -1
View File
@@ -5,7 +5,7 @@
"pingIntervalMs": 20000, "pingIntervalMs": 20000,
"mappings": [ "mappings": [
{ {
"pattern": "mc.example.com", "pattern": "mc\\.example\\.com",
"destination": "127.0.0.1:25566", "destination": "127.0.0.1:25566",
"proxyProtocol": true "proxyProtocol": true
} }
+12 -5
View File
@@ -51,7 +51,7 @@ Client Hub
│─ Frame#1 [magic=0x01, rand, ts] ──────▶ check |now-ts| ≤ window │─ Frame#1 [magic=0x01, rand, ts] ──────▶ check |now-ts| ≤ window
│ (both switch to Phase-B keys = ChaCha20(SHA3-256(rand‖ts ‖ dir))) │ (both switch to Phase-B keys = ChaCha20(SHA3-256(rand‖ts ‖ dir)))
│◀──────────── Frame [SessionReady] ─────│ │◀──────────── Frame [SessionReady] ─────│
│─ Register("mc.example.com") ──────────▶ patterns["mc.example.com"] = session │─ Register("mc\.example\.com") ────────▶ patterns["mc\.example\.com"] = (regex, session)
│◀──────────── RegisterAck ──────────────│ │◀──────────── RegisterAck ──────────────│
│ ... periodic Ping/Pong ... │ │ ... periodic Ping/Pong ... │
``` ```
@@ -64,10 +64,10 @@ share a keystream beyond that first frame.
``` ```
Player Hub Client Destination 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, │ (+ maybe pipelined Login Start) │ pause player socket,
│ │ buffer bytes, mint CID │ │ buffer bytes, mint CID
│ │─ ControlRequest(CID, pattern, ip:port) ─▶ │ │─ ControlRequest(CID, pattern, ip:port, user) ─▶
│ │ allocate worker+stream │ │ allocate worker+stream
│ │◀──────── SYN(streamId, CID) ────────────│ │ │◀──────── SYN(streamId, CID) ────────────│
│ │ takePending(CID) → bind dial destination, │ │ takePending(CID) → bind dial destination,
@@ -81,6 +81,12 @@ Player Hub Client Destinatio
Key points: 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 * 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 bytes are lost while the takeover is arranged; the buffered handshake is
forwarded **verbatim**, so the real server sees exactly what the player sent 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). 2. No per-stream flow control (see §6).
3. Single-event-loop hub (see §5) bounds throughput to one core. 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). 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 5. Pattern ownership is last-writer-wins; two clients registering the identical
hostname will silently reassign it. 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 These are deliberate scope choices for a connectivity-focused P2P tool, not
oversights; each is a small, well-isolated change away from being hardened. 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 // TestLargeTransfer pushes a multi-megabyte payload both ways to exercise mux
// framing and back-pressure. // framing and back-pressure.
func TestLargeTransfer(t *testing.T) { func TestLargeTransfer(t *testing.T) {
@@ -27,8 +27,8 @@ public final class ControlSession {
switch (type) { switch (type) {
case Protocol.CTL_REGISTER -> { case Protocol.CTL_REGISTER -> {
String pattern = r.readString(); String pattern = r.readString();
hub.register(pattern, this); int status = hub.register(pattern, this);
sendRegisterAck(pattern, 0); sendRegisterAck(pattern, status);
} }
case Protocol.CTL_UNREGISTER -> { case Protocol.CTL_UNREGISTER -> {
String pattern = r.readString(); 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() byte[] msg = new ProtoWriter()
.u8(Protocol.CTL_CONTROL_REQUEST) .u8(Protocol.CTL_CONTROL_REQUEST)
.bytes(cid) .bytes(cid)
.string(pattern) .string(pattern)
.string(playerIp) .string(playerIp)
.u16(playerPort) .u16(playerPort)
.string(username)
.toBytes(); .toBytes();
frames.send(msg); 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) { private void sendRegisterAck(String pattern, int status) {
@@ -7,11 +7,12 @@ import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ThreadLocalRandom; 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 * 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 byte[] pskBytes;
public final String pskAddress; 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<>(); 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) { public Hub(Vertx vertx, Config config) {
this.vertx = vertx; this.vertx = vertx;
this.config = config; this.config = config;
@@ -38,24 +45,51 @@ public final class Hub {
// ---- pattern registry ---- // ---- pattern registry ----
public void register(String pattern, ControlSession session) { /**
String key = normalizeAddress(pattern); * Compile {@code pattern} as a case-insensitive regular expression and register
patterns.put(key, session); * it for {@code session}. The pattern string is the registry key, used verbatim
LOG.info("registered pattern '{}' -> {}", key, session.id()); * (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) { public void unregister(String pattern, ControlSession session) {
String key = normalizeAddress(pattern); // Remove only if this session still owns the pattern (a newer session may have taken it).
patterns.remove(key, session); 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. */ /** Drop every pattern owned by a (closing) session, plus any players still pending for it. */
public void removeSession(ControlSession session) { public void removeSession(ControlSession session) {
patterns.entrySet().removeIf(e -> e.getValue() == session); patterns.entrySet().removeIf(e -> e.getValue().session() == session);
pending.values().removeIf(p -> { pending.values().removeIf(p -> {
if (p.getOwner() != session) return false; if (p.getOwner() != session) return false;
if (p.getTimerId() >= 0) vertx.cancelTimer(p.getTimerId()); if (p.getTimerId() >= 0) vertx.cancelTimer(p.getTimerId());
@@ -98,7 +132,11 @@ public final class Hub {
// ---- helpers ---- // ---- 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) { public static String normalizeAddress(String addr) {
int nul = addr.indexOf('\0'); int nul = addr.indexOf('\0');
if (nul >= 0) addr = addr.substring(0, nul); if (nul >= 0) addr = addr.substring(0, nul);
@@ -105,7 +105,7 @@ public final class HubConnection {
LOG.info("{} reserved intent 18; closing", id); LOG.info("{} reserved intent 18; closing", id);
socket.close(); socket.close();
} else { } else {
handlePlayer(address); handlePlayer(address, intent, afterHandshake);
} }
} }
@@ -177,18 +177,25 @@ public final class HubConnection {
// ---- player connection ---- // ---- player connection ----
private void handlePlayer(String address) { private void handlePlayer(String address, int intent, Buffer afterHandshake) {
String pattern = Hub.normalizeAddress(address); String host = Hub.normalizeAddress(address);
ControlSession session = hub.match(address); Hub.Match matched = hub.match(address);
if (session == null) { if (matched == null) {
LOG.info("{} no route for '{}'; closing", id, pattern); LOG.info("{} no route for '{}'; closing", id, host);
socket.close(); socket.close();
return; 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(); byte[] cid = hub.newCid();
String cidHex = Hex.encode(cid); String cidHex = Hex.encode(cid);
String ip = socket.remoteAddress() != null ? socket.remoteAddress().host() : "0.0.0.0"; String ip = socket.remoteAddress() != null ? socket.remoteAddress().host() : "0.0.0.0";
int port = socket.remoteAddress() != null ? socket.remoteAddress().port() : 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(); socket.pause();
Buffer buffered = hs.copy(); // handshake + any pipelined bytes, forwarded verbatim Buffer buffered = hs.copy(); // handshake + any pipelined bytes, forwarded verbatim
@@ -197,7 +204,27 @@ public final class HubConnection {
hub.addPending(p); hub.addPending(p);
closeCleanup = () -> hub.removePending(cidHex); closeCleanup = () -> hub.removePending(cidHex);
session.sendControlRequest(cid, pattern, ip, port); session.sendControlRequest(cid, pattern, ip, port, username);
LOG.info("{} player {}:{} matched '{}' cid={}", id, ip, port, pattern, cidHex); 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_PING = 0x05;
public static final int CTL_PONG = 0x06; 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 // Worker-conn mux frame types
public static final int MUX_SYN = 0x00; public static final int MUX_SYN = 0x00;
public static final int MUX_DATA = 0x01; 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.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; 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.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
class CryptoCodecTest { 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."));
assertEquals("mc.example.com", Hub.normalizeAddress("MC.Example.com\u0000FML\u00000")); 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"));
}
} }