initial commit
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
|
||||
|
||||
plugins {
|
||||
java
|
||||
application
|
||||
id("com.gradleup.shadow") version "9.5.1"
|
||||
id("io.freefair.lombok") version "9.5.0"
|
||||
}
|
||||
|
||||
group = "io.icybear.redapricot"
|
||||
version = "0.1.0"
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("io.vertx:vertx-core:5.1.5")
|
||||
testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.10.2")
|
||||
}
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(21)
|
||||
}
|
||||
}
|
||||
|
||||
application {
|
||||
mainClass = "io.icybear.redapricot.Main"
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
// Fat "shadow" jar: build/libs/redapricot-server-<version>-all.jar
|
||||
// java -jar build/libs/redapricot-server-0.1.0-all.jar <config.json>
|
||||
// mergeServiceFiles() is required so Vert.x/Netty SPI (META-INF/services/*)
|
||||
// survives the relocation into a single jar.
|
||||
tasks.named<ShadowJar>("shadowJar") {
|
||||
archiveClassifier.set("all")
|
||||
mergeServiceFiles()
|
||||
manifest {
|
||||
attributes["Main-Class"] = "io.icybear.redapricot.Main"
|
||||
}
|
||||
}
|
||||
|
||||
// Build the shadow jar as part of the default `build`/`assemble` lifecycle.
|
||||
tasks.named("assemble") {
|
||||
dependsOn("shadowJar")
|
||||
}
|
||||
|
||||
// Make `installDist` output predictable for the e2e harness: it produces
|
||||
// build/install/redapricot-server/lib/*.jar + a start script.
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"listen": "0.0.0.0:25565",
|
||||
"psk": "change-me-to-a-long-random-passphrase",
|
||||
"timestampWindowMs": 30000,
|
||||
"pendingTimeoutMs": 10000
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
rootProject.name = "redapricot-server"
|
||||
@@ -0,0 +1,48 @@
|
||||
package io.icybear.redapricot;
|
||||
|
||||
import io.icybear.redapricot.util.Json;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
|
||||
/** Hub configuration, loaded from a JSON file. See PROTOCOL.md §9.1. */
|
||||
public final class Config {
|
||||
public final String host;
|
||||
public final int port;
|
||||
public final String psk;
|
||||
public final long timestampWindowMs;
|
||||
public final long pendingTimeoutMs;
|
||||
|
||||
public Config(String host, int port, String psk, long timestampWindowMs, long pendingTimeoutMs) {
|
||||
this.host = host;
|
||||
this.port = port;
|
||||
this.psk = psk;
|
||||
this.timestampWindowMs = timestampWindowMs;
|
||||
this.pendingTimeoutMs = pendingTimeoutMs;
|
||||
}
|
||||
|
||||
public static Config load(Path file) throws Exception {
|
||||
Map<String, Object> m = Json.parseObject(Files.readString(file));
|
||||
String listen = str(m, "listen", "0.0.0.0:25565");
|
||||
int idx = listen.lastIndexOf(':');
|
||||
if (idx < 0) throw new IllegalArgumentException("listen must be host:port");
|
||||
String host = listen.substring(0, idx);
|
||||
int port = Integer.parseInt(listen.substring(idx + 1));
|
||||
String psk = str(m, "psk", null);
|
||||
if (psk == null || psk.isEmpty()) throw new IllegalArgumentException("psk is required");
|
||||
long tsWin = num(m, "timestampWindowMs", 30000);
|
||||
long pending = num(m, "pendingTimeoutMs", 10000);
|
||||
return new Config(host, port, psk, tsWin, pending);
|
||||
}
|
||||
|
||||
private static String str(Map<String, Object> m, String k, String def) {
|
||||
Object v = m.get(k);
|
||||
return v == null ? def : v.toString();
|
||||
}
|
||||
|
||||
private static long num(Map<String, Object> m, String k, long def) {
|
||||
Object v = m.get(k);
|
||||
return v == null ? def : (long) ((Number) v).doubleValue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package io.icybear.redapricot;
|
||||
|
||||
import io.icybear.redapricot.net.EncryptedFrames;
|
||||
import io.icybear.redapricot.util.ProtoReader;
|
||||
import io.icybear.redapricot.util.ProtoWriter;
|
||||
|
||||
/**
|
||||
* An authenticated control session (Magic 0x01). Carries pattern registrations
|
||||
* and control requests; never tunnels game data.
|
||||
*/
|
||||
public final class ControlSession {
|
||||
private static final System.Logger LOG = System.getLogger("redapricot.control");
|
||||
|
||||
private final Hub hub;
|
||||
private final EncryptedFrames frames;
|
||||
private final String id;
|
||||
|
||||
public ControlSession(Hub hub, EncryptedFrames frames, String id) {
|
||||
this.hub = hub;
|
||||
this.frames = frames;
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String id() { return id; }
|
||||
|
||||
public void onFrame(byte[] payload) {
|
||||
ProtoReader r = new ProtoReader(payload);
|
||||
int type = r.readUByte();
|
||||
switch (type) {
|
||||
case Protocol.CTL_REGISTER -> {
|
||||
String pattern = r.readString();
|
||||
hub.register(pattern, this);
|
||||
sendRegisterAck(pattern, 0);
|
||||
}
|
||||
case Protocol.CTL_UNREGISTER -> {
|
||||
String pattern = r.readString();
|
||||
hub.unregister(pattern, this);
|
||||
}
|
||||
case Protocol.CTL_PING -> {
|
||||
long nonce = r.readI64();
|
||||
sendPong(nonce);
|
||||
}
|
||||
case Protocol.FRAME_ERROR -> LOG.log(System.Logger.Level.WARNING,
|
||||
"control " + id + " error: " + r.readString());
|
||||
default -> LOG.log(System.Logger.Level.WARNING,
|
||||
"control " + id + " unknown message type " + type);
|
||||
}
|
||||
}
|
||||
|
||||
public void sendControlRequest(byte[] cid, String pattern, String playerIp, int playerPort) {
|
||||
byte[] msg = new ProtoWriter()
|
||||
.u8(Protocol.CTL_CONTROL_REQUEST)
|
||||
.bytes(cid)
|
||||
.string(pattern)
|
||||
.string(playerIp)
|
||||
.u16(playerPort)
|
||||
.toBytes();
|
||||
frames.send(msg);
|
||||
LOG.log(System.Logger.Level.INFO,
|
||||
"control-request pattern=" + pattern + " player=" + playerIp + ":" + playerPort);
|
||||
}
|
||||
|
||||
private void sendRegisterAck(String pattern, int status) {
|
||||
frames.send(new ProtoWriter()
|
||||
.u8(Protocol.CTL_REGISTER_ACK)
|
||||
.string(pattern)
|
||||
.u8(status)
|
||||
.toBytes());
|
||||
}
|
||||
|
||||
private void sendPong(long nonce) {
|
||||
frames.send(new ProtoWriter().u8(Protocol.CTL_PONG).i64(nonce).toBytes());
|
||||
}
|
||||
|
||||
public void onClose() {
|
||||
hub.removeSession(this);
|
||||
LOG.log(System.Logger.Level.INFO, "control session " + id + " closed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package io.icybear.redapricot;
|
||||
|
||||
import io.icybear.redapricot.crypto.Crypto;
|
||||
import io.icybear.redapricot.util.Hex;
|
||||
import io.vertx.core.Vertx;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* Shared hub state: the pattern registry and the pending-player table. A single
|
||||
* verticle instance owns this, so access is confined to one event loop; the
|
||||
* concurrent maps are defensive.
|
||||
*/
|
||||
public final class Hub {
|
||||
private static final System.Logger LOG = System.getLogger("redapricot.hub");
|
||||
|
||||
public final Vertx vertx;
|
||||
public final Config config;
|
||||
public final byte[] pskBytes;
|
||||
public final String pskAddress;
|
||||
|
||||
private final Map<String, ControlSession> patterns = new ConcurrentHashMap<>();
|
||||
private final Map<String, PendingPlayer> pending = new ConcurrentHashMap<>();
|
||||
|
||||
public Hub(Vertx vertx, Config config) {
|
||||
this.vertx = vertx;
|
||||
this.config = config;
|
||||
this.pskBytes = config.psk.getBytes(StandardCharsets.UTF_8);
|
||||
this.pskAddress = Crypto.pskAddress(config.psk);
|
||||
}
|
||||
|
||||
// ---- pattern registry ----
|
||||
|
||||
public void register(String pattern, ControlSession session) {
|
||||
String key = normalizeAddress(pattern);
|
||||
patterns.put(key, session);
|
||||
LOG.log(System.Logger.Level.INFO, "registered pattern '" + key + "' -> " + session.id());
|
||||
}
|
||||
|
||||
public void unregister(String pattern, ControlSession session) {
|
||||
String key = normalizeAddress(pattern);
|
||||
patterns.remove(key, session);
|
||||
}
|
||||
|
||||
public ControlSession match(String address) {
|
||||
return patterns.get(normalizeAddress(address));
|
||||
}
|
||||
|
||||
/** Drop every pattern currently owned by a (closing) session. */
|
||||
public void removeSession(ControlSession session) {
|
||||
patterns.entrySet().removeIf(e -> e.getValue() == session);
|
||||
}
|
||||
|
||||
// ---- pending players ----
|
||||
|
||||
public byte[] newCid() {
|
||||
byte[] cid = new byte[Protocol.CID_LEN];
|
||||
ThreadLocalRandom.current().nextBytes(cid);
|
||||
return cid;
|
||||
}
|
||||
|
||||
public void addPending(PendingPlayer p) {
|
||||
pending.put(p.cidHex, p);
|
||||
p.timerId = vertx.setTimer(config.pendingTimeoutMs, id -> {
|
||||
PendingPlayer removed = pending.remove(p.cidHex);
|
||||
if (removed != null) {
|
||||
LOG.log(System.Logger.Level.WARNING, "pending player " + p.cidHex + " timed out");
|
||||
removed.socket.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public PendingPlayer takePending(byte[] cid) {
|
||||
String hex = Hex.encode(cid);
|
||||
PendingPlayer p = pending.remove(hex);
|
||||
if (p != null && p.timerId >= 0) vertx.cancelTimer(p.timerId);
|
||||
return p;
|
||||
}
|
||||
|
||||
public void removePending(String cidHex) {
|
||||
PendingPlayer p = pending.remove(cidHex);
|
||||
if (p != null && p.timerId >= 0) vertx.cancelTimer(p.timerId);
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
/** Lower-cased, FML-suffix-stripped, trailing-dot-stripped hostname. */
|
||||
public static String normalizeAddress(String addr) {
|
||||
int nul = addr.indexOf('\0');
|
||||
if (nul >= 0) addr = addr.substring(0, nul);
|
||||
addr = addr.toLowerCase(Locale.ROOT);
|
||||
while (addr.endsWith(".")) addr = addr.substring(0, addr.length() - 1);
|
||||
return addr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package io.icybear.redapricot;
|
||||
|
||||
import io.icybear.redapricot.crypto.Crypto;
|
||||
import io.icybear.redapricot.net.EncryptedFrames;
|
||||
import io.icybear.redapricot.util.Hex;
|
||||
import io.icybear.redapricot.util.ProtoReader;
|
||||
import io.icybear.redapricot.util.VarInt;
|
||||
import io.vertx.core.buffer.Buffer;
|
||||
import io.vertx.core.net.NetSocket;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* Per-socket state machine: reads the initial Minecraft Handshake, then either
|
||||
* establishes an encrypted redapricot session (control / worker) or routes the
|
||||
* connection as a player. See PROTOCOL.md §2 and §4.
|
||||
*/
|
||||
public final class HubConnection {
|
||||
private static final System.Logger LOG = System.getLogger("redapricot.conn");
|
||||
private static final AtomicLong SEQ = new AtomicLong();
|
||||
private static final int MAX_HANDSHAKE = 8192;
|
||||
|
||||
private final Hub hub;
|
||||
private final NetSocket socket;
|
||||
private final String id;
|
||||
|
||||
private Buffer hs = Buffer.buffer();
|
||||
private boolean dispatched = false;
|
||||
private Runnable closeCleanup = () -> {};
|
||||
|
||||
// encryption / session state
|
||||
private EncryptedFrames frames;
|
||||
|
||||
public HubConnection(Hub hub, NetSocket socket) {
|
||||
this.hub = hub;
|
||||
this.socket = socket;
|
||||
this.id = "#" + SEQ.incrementAndGet();
|
||||
}
|
||||
|
||||
public void start() {
|
||||
socket.handler(this::onRaw);
|
||||
socket.closeHandler(v -> closeCleanup.run());
|
||||
socket.exceptionHandler(t -> socket.close());
|
||||
}
|
||||
|
||||
private void onRaw(Buffer b) {
|
||||
if (dispatched) return;
|
||||
hs.appendBuffer(b);
|
||||
if (hs.length() > MAX_HANDSHAKE) {
|
||||
LOG.log(System.Logger.Level.WARNING, id + " handshake too large; closing");
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
tryParseHandshake();
|
||||
}
|
||||
|
||||
private void tryParseHandshake() {
|
||||
VarInt.Read lr;
|
||||
try {
|
||||
lr = VarInt.tryRead(hs, 0);
|
||||
} catch (RuntimeException e) {
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
if (lr == null) return;
|
||||
int pktLen = lr.value();
|
||||
int hdr = lr.size();
|
||||
if (pktLen < 0 || pktLen > Protocol.MAX_FRAME) {
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
if (hs.length() < hdr + pktLen) return; // wait for the full packet
|
||||
|
||||
byte[] packet = hs.getBytes(hdr, hdr + pktLen);
|
||||
Buffer afterHandshake = hs.getBuffer(hdr + pktLen, hs.length());
|
||||
dispatched = true;
|
||||
try {
|
||||
dispatch(packet, afterHandshake);
|
||||
} catch (RuntimeException e) {
|
||||
LOG.log(System.Logger.Level.WARNING, id + " handshake error: " + e);
|
||||
socket.close();
|
||||
}
|
||||
}
|
||||
|
||||
private void dispatch(byte[] packet, Buffer afterHandshake) {
|
||||
ProtoReader r = new ProtoReader(packet);
|
||||
int packetId = r.readVarInt();
|
||||
if (packetId != 0x00) {
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
r.readVarInt(); // protocol version (ignored)
|
||||
String address = r.readString();
|
||||
int port = r.readU16(); // server port (ignored)
|
||||
int intent = r.readVarInt();
|
||||
|
||||
if (intent == Protocol.INTENT_REDAPRICOT) {
|
||||
beginRedapricot(address, afterHandshake);
|
||||
} else if (intent == Protocol.INTENT_RESERVED) {
|
||||
LOG.log(System.Logger.Level.INFO, id + " reserved intent 18; closing");
|
||||
socket.close();
|
||||
} else {
|
||||
handlePlayer(address);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- redapricot session (Intent 17) ----
|
||||
|
||||
private void beginRedapricot(String address, Buffer afterHandshake) {
|
||||
if (!address.equalsIgnoreCase(hub.pskAddress)) {
|
||||
LOG.log(System.Logger.Level.WARNING, id + " bad PSK address; closing");
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
// Phase A ciphers derived from the configured PSK.
|
||||
frames = new EncryptedFrames(
|
||||
socket,
|
||||
Crypto.decryptCipher(hub.pskBytes, Crypto.DIR_C2S),
|
||||
Crypto.encryptCipher(hub.pskBytes, Crypto.DIR_S2C),
|
||||
this::onRekeyFrame);
|
||||
socket.handler(frames::feed);
|
||||
frames.feed(afterHandshake);
|
||||
}
|
||||
|
||||
private void onRekeyFrame(byte[] payload) {
|
||||
ProtoReader r = new ProtoReader(payload);
|
||||
int magic = r.readUByte();
|
||||
int randLen = r.readVarInt();
|
||||
if (randLen < 8 || randLen > 64) {
|
||||
LOG.log(System.Logger.Level.WARNING, id + " bad rekey randLen; closing");
|
||||
frames.close();
|
||||
return;
|
||||
}
|
||||
byte[] rand = r.readBytes(randLen);
|
||||
long ts = r.readI64();
|
||||
long now = System.currentTimeMillis();
|
||||
if (Math.abs(now - ts) > hub.config.timestampWindowMs) {
|
||||
LOG.log(System.Logger.Level.WARNING, id + " rekey timestamp outside window; closing");
|
||||
frames.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// REKEY = Rand || Timestamp(I64 big-endian). Magic is excluded.
|
||||
byte[] rekey = new byte[randLen + 8];
|
||||
System.arraycopy(rand, 0, rekey, 0, randLen);
|
||||
long t = ts;
|
||||
for (int i = 7; i >= 0; i--) {
|
||||
rekey[randLen + i] = (byte) (t & 0xFF);
|
||||
t >>>= 8;
|
||||
}
|
||||
|
||||
frames.switchCiphers(
|
||||
Crypto.decryptCipher(rekey, Crypto.DIR_C2S),
|
||||
Crypto.encryptCipher(rekey, Crypto.DIR_S2C));
|
||||
frames.send(new byte[]{(byte) Protocol.CTL_SESSION_READY});
|
||||
|
||||
if (magic == Protocol.MAGIC_CONTROL) {
|
||||
ControlSession session = new ControlSession(hub, frames, id);
|
||||
frames.setHandler(session::onFrame);
|
||||
closeCleanup = session::onClose;
|
||||
LOG.log(System.Logger.Level.INFO, id + " control session established");
|
||||
} else if (magic == Protocol.MAGIC_WORKER) {
|
||||
WorkerConn worker = new WorkerConn(hub, frames, id);
|
||||
frames.setHandler(worker::onFrame);
|
||||
closeCleanup = worker::onClose;
|
||||
LOG.log(System.Logger.Level.INFO, id + " worker conn established");
|
||||
} else {
|
||||
LOG.log(System.Logger.Level.WARNING, id + " bad magic " + magic + "; closing");
|
||||
frames.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- player connection ----
|
||||
|
||||
private void handlePlayer(String address) {
|
||||
String pattern = Hub.normalizeAddress(address);
|
||||
ControlSession session = hub.match(address);
|
||||
if (session == null) {
|
||||
LOG.log(System.Logger.Level.INFO, id + " no route for '" + pattern + "'; closing");
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
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;
|
||||
|
||||
socket.pause();
|
||||
Buffer buffered = hs.copy(); // handshake + any pipelined bytes, forwarded verbatim
|
||||
|
||||
PendingPlayer p = new PendingPlayer(cid, cidHex, socket, buffered, pattern, ip, port);
|
||||
hub.addPending(p);
|
||||
closeCleanup = () -> hub.removePending(cidHex);
|
||||
|
||||
session.sendControlRequest(cid, pattern, ip, port);
|
||||
LOG.log(System.Logger.Level.INFO,
|
||||
id + " player " + ip + ":" + port + " matched '" + pattern + "' cid=" + cidHex);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package io.icybear.redapricot;
|
||||
|
||||
import io.vertx.core.AbstractVerticle;
|
||||
import io.vertx.core.Promise;
|
||||
import io.vertx.core.net.NetServer;
|
||||
import io.vertx.core.net.NetServerOptions;
|
||||
|
||||
/** Vert.x verticle that accepts every inbound connection on the hub port. */
|
||||
public final class HubServer extends AbstractVerticle {
|
||||
private static final System.Logger LOG = System.getLogger("redapricot.server");
|
||||
|
||||
private final Config config;
|
||||
private Hub hub;
|
||||
|
||||
public HubServer(Config config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start(Promise<Void> startPromise) {
|
||||
hub = new Hub(vertx, config);
|
||||
|
||||
NetServerOptions opts = new NetServerOptions()
|
||||
.setHost(config.host)
|
||||
.setPort(config.port)
|
||||
.setTcpNoDelay(true)
|
||||
.setReuseAddress(true);
|
||||
|
||||
NetServer server = vertx.createNetServer(opts);
|
||||
server.connectHandler(socket -> new HubConnection(hub, socket).start());
|
||||
server.listen().onComplete(ar -> {
|
||||
if (ar.succeeded()) {
|
||||
LOG.log(System.Logger.Level.INFO,
|
||||
"redapricot hub listening on " + config.host + ":" + ar.result().actualPort());
|
||||
LOG.log(System.Logger.Level.INFO, "PSK handshake address: " + hub.pskAddress);
|
||||
startPromise.complete();
|
||||
} else {
|
||||
startPromise.fail(ar.cause());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package io.icybear.redapricot;
|
||||
|
||||
import io.vertx.core.Vertx;
|
||||
import io.vertx.core.VertxOptions;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
/** Entry point: {@code java -jar redapricot-server.jar <config.json>}. */
|
||||
public final class Main {
|
||||
private static final System.Logger LOG = System.getLogger("redapricot.main");
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
if (args.length < 1) {
|
||||
System.err.println("usage: redapricot-server <config.json>");
|
||||
System.exit(2);
|
||||
}
|
||||
Config config = Config.load(Path.of(args[0]));
|
||||
|
||||
// A single verticle instance keeps all connection handling on one event
|
||||
// loop, so the shared hub state needs no locking.
|
||||
Vertx vertx = Vertx.vertx(new VertxOptions());
|
||||
vertx.deployVerticle(new HubServer(config)).onComplete(ar -> {
|
||||
if (ar.failed()) {
|
||||
LOG.log(System.Logger.Level.ERROR, "failed to start hub", ar.cause());
|
||||
vertx.close();
|
||||
System.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(vertx::close));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package io.icybear.redapricot;
|
||||
|
||||
import io.vertx.core.buffer.Buffer;
|
||||
import io.vertx.core.net.NetSocket;
|
||||
|
||||
/** A player connection awaiting a worker-conn takeover, keyed by CID. */
|
||||
public final class PendingPlayer {
|
||||
public final byte[] cid;
|
||||
public final String cidHex;
|
||||
public final NetSocket socket;
|
||||
public final Buffer buffered; // raw bytes already read from the player (handshake + pipelined)
|
||||
public final String pattern;
|
||||
public final String playerIp;
|
||||
public final int playerPort;
|
||||
public long timerId = -1;
|
||||
|
||||
public PendingPlayer(byte[] cid, String cidHex, NetSocket socket, Buffer buffered,
|
||||
String pattern, String playerIp, int playerPort) {
|
||||
this.cid = cid;
|
||||
this.cidHex = cidHex;
|
||||
this.socket = socket;
|
||||
this.buffered = buffered;
|
||||
this.pattern = pattern;
|
||||
this.playerIp = playerIp;
|
||||
this.playerPort = playerPort;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package io.icybear.redapricot;
|
||||
|
||||
/** Shared redapricot protocol constants. See PROTOCOL.md. */
|
||||
public final class Protocol {
|
||||
private Protocol() {}
|
||||
|
||||
public static final int INTENT_REDAPRICOT = 17;
|
||||
public static final int INTENT_RESERVED = 18;
|
||||
|
||||
public static final int MAGIC_CONTROL = 0x01;
|
||||
public static final int MAGIC_WORKER = 0x02;
|
||||
|
||||
public static final int CID_LEN = 16;
|
||||
public static final int MAX_FRAME = 1 << 20; // 1 MiB payload cap
|
||||
|
||||
// Control-session message types
|
||||
public static final int CTL_SESSION_READY = 0x00;
|
||||
public static final int CTL_REGISTER = 0x01;
|
||||
public static final int CTL_UNREGISTER = 0x02;
|
||||
public static final int CTL_REGISTER_ACK = 0x03;
|
||||
public static final int CTL_CONTROL_REQUEST = 0x04;
|
||||
public static final int CTL_PING = 0x05;
|
||||
public static final int CTL_PONG = 0x06;
|
||||
|
||||
// Worker-conn mux frame types
|
||||
public static final int MUX_SYN = 0x00;
|
||||
public static final int MUX_DATA = 0x01;
|
||||
public static final int MUX_FIN = 0x02;
|
||||
public static final int MUX_RST = 0x03;
|
||||
|
||||
// Any redapricot connection
|
||||
public static final int FRAME_ERROR = 0x7F;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package io.icybear.redapricot;
|
||||
|
||||
import io.icybear.redapricot.net.EncryptedFrames;
|
||||
import io.icybear.redapricot.util.ProtoReader;
|
||||
import io.icybear.redapricot.util.ProtoWriter;
|
||||
import io.vertx.core.buffer.Buffer;
|
||||
import io.vertx.core.net.NetSocket;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* An authenticated worker connection (Magic 0x02). Multiplexes many player
|
||||
* streams; the client opens streams via SYN(CID) to take over pending players.
|
||||
*/
|
||||
public final class WorkerConn {
|
||||
private static final System.Logger LOG = System.getLogger("redapricot.worker");
|
||||
|
||||
private final Hub hub;
|
||||
private final EncryptedFrames frames;
|
||||
private final String id;
|
||||
private final Map<Integer, NetSocket> streams = new HashMap<>();
|
||||
|
||||
public WorkerConn(Hub hub, EncryptedFrames frames, String id) {
|
||||
this.hub = hub;
|
||||
this.frames = frames;
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public void onFrame(byte[] payload) {
|
||||
ProtoReader r = new ProtoReader(payload);
|
||||
int type = r.readUByte();
|
||||
int sid = r.readVarInt();
|
||||
switch (type) {
|
||||
case Protocol.MUX_SYN -> handleSyn(sid, r.readBytes(Protocol.CID_LEN));
|
||||
case Protocol.MUX_DATA -> handleData(sid, r.readBytes(r.remaining()));
|
||||
case Protocol.MUX_FIN, Protocol.MUX_RST -> closeStream(sid);
|
||||
case Protocol.FRAME_ERROR -> LOG.log(System.Logger.Level.WARNING,
|
||||
"worker " + id + " error frame");
|
||||
default -> LOG.log(System.Logger.Level.WARNING, "worker " + id + " unknown mux type " + type);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleSyn(int sid, byte[] cid) {
|
||||
PendingPlayer p = hub.takePending(cid);
|
||||
if (p == null) {
|
||||
LOG.log(System.Logger.Level.WARNING, "worker " + id + " SYN for unknown CID");
|
||||
sendRst(sid);
|
||||
return;
|
||||
}
|
||||
NetSocket player = p.socket;
|
||||
streams.put(sid, player);
|
||||
|
||||
// From now on the player socket belongs to this stream.
|
||||
player.handler(buf -> {
|
||||
sendData(sid, buf.getBytes());
|
||||
if (frames.writeQueueFull()) {
|
||||
player.pause();
|
||||
frames.socket().drainHandler(v -> player.resume());
|
||||
}
|
||||
});
|
||||
player.closeHandler(v -> {
|
||||
if (streams.remove(sid) != null) sendFin(sid);
|
||||
});
|
||||
player.exceptionHandler(t -> {
|
||||
if (streams.remove(sid) != null) sendFin(sid);
|
||||
});
|
||||
|
||||
// Forward the buffered handshake (and any pipelined bytes), then resume.
|
||||
sendData(sid, p.buffered.getBytes());
|
||||
player.resume();
|
||||
LOG.log(System.Logger.Level.INFO,
|
||||
"worker " + id + " stream " + sid + " bound to " + p.pattern);
|
||||
}
|
||||
|
||||
private void handleData(int sid, byte[] data) {
|
||||
NetSocket player = streams.get(sid);
|
||||
if (player == null) return;
|
||||
player.write(Buffer.buffer(data));
|
||||
if (player.writeQueueFull()) {
|
||||
frames.socket().pause();
|
||||
player.drainHandler(v -> frames.socket().resume());
|
||||
}
|
||||
}
|
||||
|
||||
private void closeStream(int sid) {
|
||||
NetSocket player = streams.remove(sid);
|
||||
if (player != null) player.close();
|
||||
}
|
||||
|
||||
private void sendData(int sid, byte[] data) {
|
||||
frames.send(new ProtoWriter().u8(Protocol.MUX_DATA).varInt(sid).bytes(data).toBytes());
|
||||
}
|
||||
|
||||
private void sendFin(int sid) {
|
||||
frames.send(new ProtoWriter().u8(Protocol.MUX_FIN).varInt(sid).toBytes());
|
||||
}
|
||||
|
||||
private void sendRst(int sid) {
|
||||
frames.send(new ProtoWriter().u8(Protocol.MUX_RST).varInt(sid).toBytes());
|
||||
}
|
||||
|
||||
public void onClose() {
|
||||
for (NetSocket player : streams.values()) player.close();
|
||||
streams.clear();
|
||||
LOG.log(System.Logger.Level.INFO, "worker " + id + " closed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package io.icybear.redapricot.crypto;
|
||||
|
||||
import io.icybear.redapricot.util.Hex;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.ChaCha20ParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
/**
|
||||
* Crypto primitives for redapricot: SHA3 hashing, per-direction key derivation,
|
||||
* and ChaCha20 stream ciphers. See PROTOCOL.md §3.
|
||||
*/
|
||||
public final class Crypto {
|
||||
private Crypto() {}
|
||||
|
||||
public static final int DIR_C2S = 0x01;
|
||||
public static final int DIR_S2C = 0x02;
|
||||
|
||||
public static byte[] sha3_224(byte[] in) {
|
||||
return digest("SHA3-224", in);
|
||||
}
|
||||
|
||||
/** Handshake address for Intent 17: lowercase hex of SHA3-224(PSK). */
|
||||
public static String pskAddress(String psk) {
|
||||
return Hex.encode(sha3_224(psk.getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
|
||||
/** keyDir = SHA3-256(phaseKey || dirByte). */
|
||||
public static byte[] deriveKey(byte[] phaseKey, int dir) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA3-256");
|
||||
md.update(phaseKey);
|
||||
md.update((byte) dir);
|
||||
return md.digest();
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ChaCha20 stream cipher (RFC 8439) for a 32-byte key, 12-byte zero
|
||||
* nonce, counter 0. Encryption and decryption are the identical XOR operation,
|
||||
* so {@code opmode} is cosmetic; the keystream position advances across
|
||||
* {@code update()} calls, giving a continuous stream.
|
||||
*/
|
||||
public static Cipher chacha20(byte[] key, int opmode) {
|
||||
try {
|
||||
Cipher c = Cipher.getInstance("ChaCha20");
|
||||
c.init(opmode, new SecretKeySpec(key, "ChaCha20"),
|
||||
new ChaCha20ParameterSpec(new byte[12], 0));
|
||||
return c;
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new IllegalStateException("ChaCha20 unavailable", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static Cipher encryptCipher(byte[] phaseKey, int dir) {
|
||||
return chacha20(deriveKey(phaseKey, dir), Cipher.ENCRYPT_MODE);
|
||||
}
|
||||
|
||||
public static Cipher decryptCipher(byte[] phaseKey, int dir) {
|
||||
return chacha20(deriveKey(phaseKey, dir), Cipher.DECRYPT_MODE);
|
||||
}
|
||||
|
||||
private static byte[] digest(String alg, byte[] in) {
|
||||
try {
|
||||
return MessageDigest.getInstance(alg).digest(in);
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package io.icybear.redapricot.net;
|
||||
|
||||
import io.icybear.redapricot.Protocol;
|
||||
import io.icybear.redapricot.util.VarInt;
|
||||
import io.vertx.core.buffer.Buffer;
|
||||
import io.vertx.core.net.NetSocket;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
|
||||
/**
|
||||
* Length-prefixed encrypted frame transport over a NetSocket (PROTOCOL.md §3.1).
|
||||
* The VarInt length prefix is plaintext; the payload is ChaCha20-encrypted. The
|
||||
* cipher instances maintain a continuous per-direction keystream across frames.
|
||||
*/
|
||||
public final class EncryptedFrames {
|
||||
public interface FrameHandler {
|
||||
void handle(byte[] payload);
|
||||
}
|
||||
|
||||
private final NetSocket socket;
|
||||
private Cipher in;
|
||||
private Cipher out;
|
||||
private FrameHandler handler;
|
||||
private Buffer buf = Buffer.buffer();
|
||||
private boolean closed = false;
|
||||
|
||||
public EncryptedFrames(NetSocket socket, Cipher in, Cipher out, FrameHandler handler) {
|
||||
this.socket = socket;
|
||||
this.in = in;
|
||||
this.out = out;
|
||||
this.handler = handler;
|
||||
}
|
||||
|
||||
public void setHandler(FrameHandler h) { this.handler = h; }
|
||||
|
||||
/** Swap both ciphers at a frame boundary (Phase A → Phase B rekey). */
|
||||
public void switchCiphers(Cipher in, Cipher out) {
|
||||
this.in = in;
|
||||
this.out = out;
|
||||
}
|
||||
|
||||
public NetSocket socket() { return socket; }
|
||||
|
||||
public boolean isClosed() { return closed; }
|
||||
|
||||
/** Feed raw incoming ciphertext (plaintext length prefixes + encrypted payloads). */
|
||||
public void feed(Buffer incoming) {
|
||||
if (closed) return;
|
||||
if (incoming != null && incoming.length() > 0) buf.appendBuffer(incoming);
|
||||
pump();
|
||||
}
|
||||
|
||||
private void pump() {
|
||||
while (!closed) {
|
||||
VarInt.Read r;
|
||||
try {
|
||||
r = VarInt.tryRead(buf, 0);
|
||||
} catch (RuntimeException e) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (r == null) return;
|
||||
int payloadLen = r.value();
|
||||
int hdr = r.size();
|
||||
if (payloadLen < 0 || payloadLen > Protocol.MAX_FRAME) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (buf.length() < hdr + payloadLen) return;
|
||||
byte[] ct = buf.getBytes(hdr, hdr + payloadLen);
|
||||
byte[] pt = in.update(ct);
|
||||
if (pt == null) pt = new byte[0];
|
||||
buf = buf.getBuffer(hdr + payloadLen, buf.length());
|
||||
FrameHandler h = handler;
|
||||
if (h != null) {
|
||||
try {
|
||||
h.handle(pt);
|
||||
} catch (RuntimeException e) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Encrypt and send one frame payload. */
|
||||
public void send(byte[] payload) {
|
||||
if (closed) return;
|
||||
byte[] ct = out.update(payload);
|
||||
if (ct == null) ct = new byte[0];
|
||||
Buffer f = Buffer.buffer(ct.length + VarInt.MAX_BYTES);
|
||||
VarInt.write(f, ct.length);
|
||||
f.appendBytes(ct);
|
||||
socket.write(f);
|
||||
}
|
||||
|
||||
public boolean writeQueueFull() { return socket.writeQueueFull(); }
|
||||
|
||||
public void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
socket.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package io.icybear.redapricot.util;
|
||||
|
||||
public final class Hex {
|
||||
private Hex() {}
|
||||
private static final char[] HEX = "0123456789abcdef".toCharArray();
|
||||
|
||||
public static String encode(byte[] in) {
|
||||
char[] out = new char[in.length * 2];
|
||||
for (int i = 0; i < in.length; i++) {
|
||||
int v = in[i] & 0xFF;
|
||||
out[i * 2] = HEX[v >>> 4];
|
||||
out[i * 2 + 1] = HEX[v & 0x0F];
|
||||
}
|
||||
return new String(out);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package io.icybear.redapricot.util;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Minimal, dependency-free JSON parser sufficient for redapricot config files.
|
||||
* Produces Map<String,Object>, List<Object>, String, Double, Boolean, null.
|
||||
*/
|
||||
public final class Json {
|
||||
private final String s;
|
||||
private int i;
|
||||
|
||||
private Json(String s) { this.s = s; }
|
||||
|
||||
public static Object parse(String text) {
|
||||
Json p = new Json(text);
|
||||
p.ws();
|
||||
Object v = p.value();
|
||||
p.ws();
|
||||
if (p.i != p.s.length()) throw new IllegalArgumentException("trailing JSON at " + p.i);
|
||||
return v;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public static Map<String, Object> parseObject(String text) {
|
||||
Object v = parse(text);
|
||||
if (!(v instanceof Map)) throw new IllegalArgumentException("expected JSON object");
|
||||
return (Map<String, Object>) v;
|
||||
}
|
||||
|
||||
private Object value() {
|
||||
char c = peek();
|
||||
return switch (c) {
|
||||
case '{' -> object();
|
||||
case '[' -> array();
|
||||
case '"' -> string();
|
||||
case 't', 'f' -> bool();
|
||||
case 'n' -> nul();
|
||||
default -> number();
|
||||
};
|
||||
}
|
||||
|
||||
private Map<String, Object> object() {
|
||||
expect('{');
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
ws();
|
||||
if (peek() == '}') { i++; return m; }
|
||||
while (true) {
|
||||
ws();
|
||||
String key = string();
|
||||
ws();
|
||||
expect(':');
|
||||
ws();
|
||||
m.put(key, value());
|
||||
ws();
|
||||
char c = next();
|
||||
if (c == '}') return m;
|
||||
if (c != ',') throw err("expected , or }");
|
||||
}
|
||||
}
|
||||
|
||||
private List<Object> array() {
|
||||
expect('[');
|
||||
List<Object> a = new ArrayList<>();
|
||||
ws();
|
||||
if (peek() == ']') { i++; return a; }
|
||||
while (true) {
|
||||
ws();
|
||||
a.add(value());
|
||||
ws();
|
||||
char c = next();
|
||||
if (c == ']') return a;
|
||||
if (c != ',') throw err("expected , or ]");
|
||||
}
|
||||
}
|
||||
|
||||
private String string() {
|
||||
expect('"');
|
||||
StringBuilder sb = new StringBuilder();
|
||||
while (true) {
|
||||
char c = next();
|
||||
if (c == '"') return sb.toString();
|
||||
if (c == '\\') {
|
||||
char e = next();
|
||||
switch (e) {
|
||||
case '"' -> sb.append('"');
|
||||
case '\\' -> sb.append('\\');
|
||||
case '/' -> sb.append('/');
|
||||
case 'b' -> sb.append('\b');
|
||||
case 'f' -> sb.append('\f');
|
||||
case 'n' -> sb.append('\n');
|
||||
case 'r' -> sb.append('\r');
|
||||
case 't' -> sb.append('\t');
|
||||
case 'u' -> {
|
||||
int cp = Integer.parseInt(s.substring(i, i + 4), 16);
|
||||
i += 4;
|
||||
sb.append((char) cp);
|
||||
}
|
||||
default -> throw err("bad escape");
|
||||
}
|
||||
} else {
|
||||
sb.append(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Object number() {
|
||||
int start = i;
|
||||
while (i < s.length() && "+-0123456789.eE".indexOf(s.charAt(i)) >= 0) i++;
|
||||
String num = s.substring(start, i);
|
||||
if (num.isEmpty()) throw err("bad value");
|
||||
if (num.contains(".") || num.contains("e") || num.contains("E")) return Double.parseDouble(num);
|
||||
return Double.parseDouble(num); // keep numbers as Double uniformly
|
||||
}
|
||||
|
||||
private Boolean bool() {
|
||||
if (s.startsWith("true", i)) { i += 4; return Boolean.TRUE; }
|
||||
if (s.startsWith("false", i)) { i += 5; return Boolean.FALSE; }
|
||||
throw err("bad literal");
|
||||
}
|
||||
|
||||
private Object nul() {
|
||||
if (s.startsWith("null", i)) { i += 4; return null; }
|
||||
throw err("bad literal");
|
||||
}
|
||||
|
||||
private void ws() {
|
||||
while (i < s.length() && Character.isWhitespace(s.charAt(i))) i++;
|
||||
}
|
||||
|
||||
private char peek() {
|
||||
if (i >= s.length()) throw err("unexpected end");
|
||||
return s.charAt(i);
|
||||
}
|
||||
|
||||
private char next() {
|
||||
if (i >= s.length()) throw err("unexpected end");
|
||||
return s.charAt(i++);
|
||||
}
|
||||
|
||||
private void expect(char c) {
|
||||
if (next() != c) throw err("expected " + c);
|
||||
}
|
||||
|
||||
private IllegalArgumentException err(String msg) {
|
||||
return new IllegalArgumentException("JSON: " + msg + " at index " + i);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package io.icybear.redapricot.util;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/** Cursor-based reader for redapricot/Minecraft primitive types over a byte array. */
|
||||
public final class ProtoReader {
|
||||
private final byte[] buf;
|
||||
private int pos;
|
||||
private final int end;
|
||||
|
||||
public ProtoReader(byte[] buf) { this(buf, 0, buf.length); }
|
||||
|
||||
public ProtoReader(byte[] buf, int off, int len) {
|
||||
this.buf = buf;
|
||||
this.pos = off;
|
||||
this.end = off + len;
|
||||
}
|
||||
|
||||
public int remaining() { return end - pos; }
|
||||
|
||||
public int readUByte() {
|
||||
if (pos >= end) throw new IllegalStateException("underflow");
|
||||
return buf[pos++] & 0xFF;
|
||||
}
|
||||
|
||||
public int readVarInt() {
|
||||
int value = 0;
|
||||
int shift = 0;
|
||||
while (true) {
|
||||
int b = readUByte();
|
||||
value |= (b & 0x7F) << shift;
|
||||
if ((b & 0x80) == 0) return value;
|
||||
shift += 7;
|
||||
if (shift >= 32) throw new IllegalArgumentException("VarInt too big");
|
||||
}
|
||||
}
|
||||
|
||||
public int readU16() {
|
||||
int hi = readUByte();
|
||||
int lo = readUByte();
|
||||
return (hi << 8) | lo;
|
||||
}
|
||||
|
||||
public long readI64() {
|
||||
long v = 0;
|
||||
for (int i = 0; i < 8; i++) v = (v << 8) | readUByte();
|
||||
return v;
|
||||
}
|
||||
|
||||
public byte[] readBytes(int n) {
|
||||
if (n < 0 || n > remaining()) throw new IllegalStateException("bad length " + n);
|
||||
byte[] out = new byte[n];
|
||||
System.arraycopy(buf, pos, out, 0, n);
|
||||
pos += n;
|
||||
return out;
|
||||
}
|
||||
|
||||
public String readString() {
|
||||
int len = readVarInt();
|
||||
return new String(readBytes(len), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package io.icybear.redapricot.util;
|
||||
|
||||
import io.vertx.core.buffer.Buffer;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/** Builder for redapricot/Minecraft primitive types, backed by a Vert.x Buffer. */
|
||||
public final class ProtoWriter {
|
||||
private final Buffer b = Buffer.buffer();
|
||||
|
||||
public ProtoWriter u8(int v) { b.appendByte((byte) v); return this; }
|
||||
|
||||
public ProtoWriter varInt(int v) { VarInt.write(b, v); return this; }
|
||||
|
||||
public ProtoWriter u16(int v) {
|
||||
b.appendByte((byte) (v >>> 8));
|
||||
b.appendByte((byte) v);
|
||||
return this;
|
||||
}
|
||||
|
||||
public ProtoWriter i64(long v) { b.appendLong(v); return this; }
|
||||
|
||||
public ProtoWriter bytes(byte[] x) { b.appendBytes(x); return this; }
|
||||
|
||||
public ProtoWriter string(String s) {
|
||||
byte[] u = s.getBytes(StandardCharsets.UTF_8);
|
||||
varInt(u.length);
|
||||
b.appendBytes(u);
|
||||
return this;
|
||||
}
|
||||
|
||||
public byte[] toBytes() { return b.getBytes(); }
|
||||
|
||||
public Buffer buffer() { return b; }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package io.icybear.redapricot.util;
|
||||
|
||||
import io.vertx.core.buffer.Buffer;
|
||||
|
||||
/** Minecraft-style VarInt (LEB128, 7 data bits/byte, max 5 bytes). */
|
||||
public final class VarInt {
|
||||
private VarInt() {}
|
||||
|
||||
public static final int MAX_BYTES = 5;
|
||||
|
||||
/** Result of a partial read: either complete (value/size) or null when more bytes are needed. */
|
||||
public record Read(int value, int size) {}
|
||||
|
||||
/** Write {@code value} as a VarInt to the buffer. */
|
||||
public static void write(Buffer buf, int value) {
|
||||
while ((value & ~0x7F) != 0) {
|
||||
buf.appendByte((byte) ((value & 0x7F) | 0x80));
|
||||
value >>>= 7;
|
||||
}
|
||||
buf.appendByte((byte) (value & 0x7F));
|
||||
}
|
||||
|
||||
/** Encoded size in bytes of {@code value}. */
|
||||
public static int size(int value) {
|
||||
int n = 1;
|
||||
while ((value & ~0x7F) != 0) { value >>>= 7; n++; }
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to read a VarInt from {@code buf} starting at {@code off}, without consuming.
|
||||
* Returns null if the buffer does not yet hold the full VarInt.
|
||||
* Throws IllegalArgumentException if it exceeds 5 bytes.
|
||||
*/
|
||||
public static Read tryRead(Buffer buf, int off) {
|
||||
int value = 0;
|
||||
int shift = 0;
|
||||
int i = off;
|
||||
while (true) {
|
||||
if (i >= buf.length()) return null; // need more bytes
|
||||
int b = buf.getByte(i) & 0xFF;
|
||||
value |= (b & 0x7F) << shift;
|
||||
i++;
|
||||
if ((b & 0x80) == 0) {
|
||||
return new Read(value, i - off);
|
||||
}
|
||||
shift += 7;
|
||||
if (shift >= 32) throw new IllegalArgumentException("VarInt too big");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package io.icybear.redapricot;
|
||||
|
||||
import io.icybear.redapricot.crypto.Crypto;
|
||||
import io.icybear.redapricot.util.ProtoReader;
|
||||
import io.icybear.redapricot.util.ProtoWriter;
|
||||
import io.icybear.redapricot.util.VarInt;
|
||||
import io.vertx.core.buffer.Buffer;
|
||||
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.assertNull;
|
||||
|
||||
class CryptoCodecTest {
|
||||
|
||||
@Test
|
||||
void varIntRoundTrip() {
|
||||
int[] cases = {0, 1, 127, 128, 255, 300, 16384, 2097151, 1 << 30};
|
||||
for (int v : cases) {
|
||||
Buffer b = Buffer.buffer();
|
||||
VarInt.write(b, v);
|
||||
assertEquals(VarInt.size(v), b.length(), "size for " + v);
|
||||
VarInt.Read r = VarInt.tryRead(b, 0);
|
||||
assertEquals(v, r.value());
|
||||
assertEquals(b.length(), r.size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void varIntTryReadNeedsMoreBytes() {
|
||||
Buffer partial = Buffer.buffer();
|
||||
partial.appendByte((byte) 0x80); // continuation set, but no following byte
|
||||
assertNull(VarInt.tryRead(partial, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void protoStringAndTypesRoundTrip() {
|
||||
byte[] enc = new ProtoWriter()
|
||||
.u8(0x04).string("mc.EXAMPLE.com").string("127.0.0.1").u16(45123).i64(1234567890123L)
|
||||
.toBytes();
|
||||
ProtoReader r = new ProtoReader(enc);
|
||||
assertEquals(0x04, r.readUByte());
|
||||
assertEquals("mc.EXAMPLE.com", r.readString());
|
||||
assertEquals("127.0.0.1", r.readString());
|
||||
assertEquals(45123, r.readU16());
|
||||
assertEquals(1234567890123L, r.readI64());
|
||||
}
|
||||
|
||||
/** Locked against the identical Go client assertion (SHA3-224 of "test-psk"). */
|
||||
@Test
|
||||
void pskAddressMatchesReference() {
|
||||
assertEquals("90188f2d84e273e4d6fb27194b4a88ad10bcc20de00c493beae6d18f",
|
||||
Crypto.pskAddress("test-psk"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void perDirectionKeysDifferAndAreStable() {
|
||||
byte[] pk = "phase-key".getBytes();
|
||||
byte[] c2s = Crypto.deriveKey(pk, Crypto.DIR_C2S);
|
||||
byte[] s2c = Crypto.deriveKey(pk, Crypto.DIR_S2C);
|
||||
assertEquals(32, c2s.length);
|
||||
assertEquals(32, s2c.length);
|
||||
assertArrayEquals(c2s, Crypto.deriveKey(pk, Crypto.DIR_C2S)); // deterministic
|
||||
assertFalse(java.util.Arrays.equals(c2s, s2c)); // directions differ
|
||||
}
|
||||
|
||||
@Test
|
||||
void normalizeAddressStripsFmlAndCase() {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user