support regex and player name sniff
This commit is contained in:
@@ -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