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
@@ -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"));
}
}