support regex and player name sniff
This commit is contained in:
+48
-10
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
+4
-2
@@ -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)
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user