feat: velocity routing, server registry and RampartVelocity tests

This commit is contained in:
loki5512344 2026-08-03 10:54:28 +02:00
parent fa6de281fb
commit 29ce6fb8e9
Signed by: boba
GPG key ID: 253067914055423B
41 changed files with 1239 additions and 532 deletions

View file

@ -8,8 +8,6 @@ import org.bukkit.event.player.PlayerLoginEvent;
public class HmacLoginListener implements Listener {
private static final int HEX_SIG_LENGTH = 64;
private final RampartPaper plugin;
public HmacLoginListener(RampartPaper plugin) {
@ -21,28 +19,10 @@ public class HmacLoginListener implements Listener {
String secretEnv = System.getenv("RAMPART_HMAC_SECRET");
if (secretEnv == null || secretEnv.isEmpty()) return;
byte[] secret = secretEnv.getBytes();
String raw = event.getHostname();
if (raw == null || raw.isEmpty()) return;
int sepIdx = raw.indexOf(RampartPaper.SHIELD_SEPARATOR);
if (sepIdx < 0) {
return;
}
String domain = raw.substring(0, sepIdx);
String sig = raw.substring(sepIdx + RampartPaper.SHIELD_SEPARATOR.length());
if (sig.length() != HEX_SIG_LENGTH) {
plugin.getLogger().warning("Invalid sig length from " + event.getAddress() +
": got " + sig.length() + ", expected " + HEX_SIG_LENGTH);
event.disallow(PlayerLoginEvent.Result.KICK_OTHER,
Component.text("Connection rejected: invalid signature"));
return;
}
String expected = plugin.hmacHex(domain, secret);
if (expected == null || !plugin.constantTimeEquals(sig, expected)) {
if (!plugin.verifyHostname(raw)) {
plugin.getLogger().warning("HMAC verification failed for " + event.getAddress());
event.disallow(PlayerLoginEvent.Result.KICK_OTHER,
Component.text("Connection rejected: invalid signature"));

View file

@ -2,14 +2,25 @@ package me.rampart.paper;
import org.bukkit.plugin.java.JavaPlugin;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
public class RampartPaper extends JavaPlugin {
static final String SHIELD_SEPARATOR = "\0shield\0";
static final String HMAC_ALGO = "HmacSHA256";
static final int HEX_SIG_LENGTH = 64;
private ShieldAgent shieldAgent;
private long rotationSecs = 3600;
private long ttlSecs = 60;
@Override
public void onEnable() {
rotationSecs = envLong("RAMPART_HMAC_ROTATION_SECS", 3600);
ttlSecs = envLong("RAMPART_HMAC_TTL_SECS", 60);
String secret = System.getenv("RAMPART_HMAC_SECRET");
if (secret == null || secret.isEmpty()) {
getLogger().warning("RAMPART_HMAC_SECRET not set — HMAC verification disabled");
@ -35,22 +46,66 @@ public class RampartPaper extends JavaPlugin {
}
}
String hmacHex(String data, byte[] secret) {
boolean verifyHostname(String raw) {
String secretEnv = System.getenv("RAMPART_HMAC_SECRET");
if (secretEnv == null || secretEnv.isEmpty()) return false;
return verifyHostname(raw, secretEnv.getBytes(StandardCharsets.UTF_8), rotationSecs, ttlSecs);
}
private boolean verifyHostname(String raw, byte[] secret, long rotation, long ttl) {
int sepIdx = raw.indexOf(SHIELD_SEPARATOR);
if (sepIdx < 0) return false;
String domain = raw.substring(0, sepIdx);
String rest = raw.substring(sepIdx + SHIELD_SEPARATOR.length());
int tsSep = rest.indexOf('\0');
if (tsSep < 0) return false;
String tsStr = rest.substring(0, tsSep);
String sig = rest.substring(tsSep + 1);
if (sig.length() != HEX_SIG_LENGTH || !sig.matches("[0-9a-f]+")) return false;
long ts;
try {
var mac = javax.crypto.Mac.getInstance("HmacSHA256");
mac.init(new javax.crypto.spec.SecretKeySpec(secret, "HmacSHA256"));
byte[] raw = mac.doFinal(data.getBytes());
StringBuilder sb = new StringBuilder(raw.length * 2);
for (byte b : raw) {
sb.append(String.format("%02x", b & 0xFF));
ts = Long.parseLong(tsStr);
} catch (NumberFormatException e) {
return false;
}
long now = System.currentTimeMillis() / 1000;
if (now < ts || now - ts > ttl) return false;
long tsBucket = ts / rotation;
for (long bucket : new long[]{tsBucket, tsBucket - 1}) {
byte[] derivedKey = hmacBytes(secret, ("rampart-key-" + bucket).getBytes(StandardCharsets.UTF_8));
if (derivedKey == null) continue;
String expected = toHex(hmacBytes(derivedKey, (domain + "|" + ts).getBytes(StandardCharsets.UTF_8)));
if (expected != null && constantTimeEquals(sig, expected)) {
return true;
}
return sb.toString();
}
return false;
}
private byte[] hmacBytes(byte[] key, byte[] data) {
try {
Mac mac = Mac.getInstance(HMAC_ALGO);
mac.init(new SecretKeySpec(key, HMAC_ALGO));
return mac.doFinal(data);
} catch (Exception e) {
getLogger().severe("HMAC error: " + e.getMessage());
return null;
}
}
private static String toHex(byte[] raw) {
StringBuilder sb = new StringBuilder(raw.length * 2);
for (byte b : raw) {
sb.append(String.format("%02x", b & 0xFF));
}
return sb.toString();
}
boolean constantTimeEquals(String a, String b) {
if (a.length() != b.length()) return false;
int result = 0;
@ -59,4 +114,14 @@ public class RampartPaper extends JavaPlugin {
}
return result == 0;
}
private static long envLong(String name, long def) {
String value = System.getenv(name);
if (value == null || value.isEmpty()) return def;
try {
return Long.parseLong(value.trim());
} catch (NumberFormatException e) {
return def;
}
}
}

View file

@ -14,6 +14,7 @@ public class ShieldAgent {
private final RampartPaper plugin;
private final Jedis jedis;
private final String serverName;
private final String serverDomain;
private final String serverIp;
private final int serverPort;
private BukkitRunnable task;
@ -35,6 +36,9 @@ public class ShieldAgent {
}
this.serverName = name;
String domain = System.getenv("RAMPART_SERVER_DOMAIN");
this.serverDomain = (domain == null) ? "" : domain;
String ip = System.getenv("RAMPART_SERVER_IP");
if (ip == null || ip.isEmpty()) {
try {
@ -108,7 +112,8 @@ public class ShieldAgent {
}
private String buildJson(String status, int online, int maxPlayers, double tps) {
return "{\"name\":\"" + serverName + "\",\"type\":\"paper\",\"ip\":\"" + serverIp
return "{\"name\":\"" + serverName + "\",\"type\":\"paper\",\"domain\":\"" + serverDomain
+ "\",\"ip\":\"" + serverIp
+ "\",\"port\":" + serverPort + ",\"status\":\"" + status
+ "\",\"online\":" + online + ",\"max_players\":" + maxPlayers
+ ",\"tps\":" + tps

View file

@ -14,6 +14,7 @@ dependencies {
implementation("redis.clients:jedis:5.2.0")
testImplementation("com.velocitypowered:velocity-api:3.4.0-SNAPSHOT")
testImplementation("org.junit.jupiter:junit-jupiter:5.11.4")
testImplementation("org.slf4j:slf4j-api:2.0.16")
testRuntimeOnly("org.slf4j:slf4j-simple:2.0.16")

View file

@ -3,7 +3,6 @@ package me.rampart.velocity;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.command.CommandExecuteEvent;
import com.velocitypowered.api.event.connection.DisconnectEvent;
import com.velocitypowered.api.event.connection.LoginEvent;
import com.velocitypowered.api.event.player.PlayerChatEvent;
import com.velocitypowered.api.proxy.Player;
import com.velocitypowered.api.proxy.ProxyServer;
@ -14,7 +13,6 @@ import java.util.Map;
import java.util.Random;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
public class CaptchaHandler {

View file

@ -7,6 +7,7 @@ import org.slf4j.Logger;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Map;
@ -23,10 +24,14 @@ public class HmacCheckListener {
private final Logger logger;
private final byte[] secret;
private final long rotationSecs;
private final long ttlSecs;
public HmacCheckListener(Logger logger, String secret) {
public HmacCheckListener(Logger logger, String secret, long rotationSecs, long ttlSecs) {
this.logger = logger;
this.secret = secret.getBytes();
this.secret = secret.getBytes(StandardCharsets.UTF_8);
this.rotationSecs = rotationSecs;
this.ttlSecs = ttlSecs;
}
public static void markVerified(UUID uuid) {
@ -34,6 +39,10 @@ public class HmacCheckListener {
PhysicsCheckListener.clearSuspicion(uuid);
}
public static boolean isVerified(UUID uuid) {
return verifiedPlayers.containsKey(uuid);
}
@Subscribe
public void onLogin(LoginEvent event) {
var player = event.getPlayer();
@ -53,60 +62,83 @@ public class HmacCheckListener {
return;
}
int sepIdx = raw.indexOf(SHIELD_SEPARATOR);
if (sepIdx < 0) {
if (raw.indexOf(SHIELD_SEPARATOR) < 0) {
event.setResult(LoginEvent.ComponentResult.denied(
Component.text("Connection rejected: unsigned connection")
));
return;
}
String domain = raw.substring(0, sepIdx);
String sig = raw.substring(sepIdx + SHIELD_SEPARATOR.length());
if (sig.length() != HEX_SIG_LENGTH) {
logger.warn("Invalid HMAC signature length from {}: got {}, expected {}",
player.getRemoteAddress(), sig.length(), HEX_SIG_LENGTH);
event.setResult(LoginEvent.ComponentResult.denied(
Component.text("Connection rejected: invalid signature")
));
return;
}
String expected = hmacHex(domain);
if (expected == null) {
event.setResult(LoginEvent.ComponentResult.denied(
Component.text("Connection rejected: internal error")
));
return;
}
if (!constantTimeEquals(sig, expected)) {
logger.warn("HMAC verification failed for {} (domain: {})",
player.getRemoteAddress(), domain);
if (!verifyHostname(raw, secret, rotationSecs, ttlSecs)) {
logger.warn("HMAC verification failed for {} (hostname: {})",
player.getRemoteAddress(), raw);
event.setResult(LoginEvent.ComponentResult.denied(
Component.text("Connection rejected: invalid signature")
));
}
}
private String hmacHex(String data) {
static boolean verifyHostname(String raw, byte[] secret, long rotationSecs, long ttlSecs) {
if (raw == null) return false;
int sepIdx = raw.indexOf(SHIELD_SEPARATOR);
if (sepIdx < 0) return false;
String domain = raw.substring(0, sepIdx);
String rest = raw.substring(sepIdx + SHIELD_SEPARATOR.length());
int tsSep = rest.indexOf('\0');
if (tsSep < 0) return false;
String tsStr = rest.substring(0, tsSep);
String sig = rest.substring(tsSep + 1);
if (sig.length() != HEX_SIG_LENGTH || !sig.matches("[0-9a-f]+")) return false;
long ts;
try {
ts = Long.parseLong(tsStr);
} catch (NumberFormatException e) {
return false;
}
long now = System.currentTimeMillis() / 1000;
if (now < ts || now - ts > ttlSecs) return false;
long tsBucket = ts / rotationSecs;
for (long bucket : new long[]{tsBucket, tsBucket - 1}) {
String expected = sign(domain, ts, bucket, secret);
if (expected != null && constantTimeEquals(sig, expected)) {
return true;
}
}
return false;
}
private static String sign(String domain, long ts, long bucket, byte[] masterSecret) {
byte[] derivedKey = hmacRaw(masterSecret, ("rampart-key-" + bucket).getBytes(StandardCharsets.UTF_8));
if (derivedKey == null) return null;
byte[] sig = hmacRaw(derivedKey, (domain + "|" + ts).getBytes(StandardCharsets.UTF_8));
if (sig == null) return null;
return toHex(sig);
}
private static byte[] hmacRaw(byte[] key, byte[] data) {
try {
Mac mac = Mac.getInstance(HMAC_ALGO);
mac.init(new SecretKeySpec(secret, HMAC_ALGO));
byte[] raw = mac.doFinal(data.getBytes());
StringBuilder sb = new StringBuilder(raw.length * 2);
for (byte b : raw) {
sb.append(String.format("%02x", b & 0xFF));
}
return sb.toString();
mac.init(new SecretKeySpec(key, HMAC_ALGO));
return mac.doFinal(data);
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
logger.error("HMAC error", e);
return null;
}
}
private boolean constantTimeEquals(String a, String b) {
private static String toHex(byte[] raw) {
StringBuilder sb = new StringBuilder(raw.length * 2);
for (byte b : raw) {
sb.append(String.format("%02x", b & 0xFF));
}
return sb.toString();
}
private static boolean constantTimeEquals(String a, String b) {
if (a.length() != b.length()) return false;
int result = 0;
for (int i = 0; i < a.length(); i++) {

View file

@ -160,6 +160,7 @@ public class PhysicsCheckListener {
private void triggerReVerify(Player player, PlayerTracker tracker) {
if (tracker.reVerificationTriggered) return;
if (HmacCheckListener.isVerified(player.getUniqueId())) return;
tracker.reVerificationTriggered = true;
player.disconnect(Component.text("Re-verification required. Please reconnect."));
logger.info("Triggered re-verification for {}", player.getUsername());

View file

@ -2,7 +2,10 @@ package me.rampart.velocity;
import com.google.inject.Inject;
import com.velocitypowered.api.event.EventManager;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.player.ServerPostConnectEvent;
import com.velocitypowered.api.plugin.Plugin;
import com.velocitypowered.api.proxy.Player;
import com.velocitypowered.api.proxy.ProxyServer;
import org.slf4j.Logger;
@ -38,8 +41,10 @@ public class RampartVelocity {
}
if (secret != null && !secret.isEmpty()) {
logger.info("HMAC verification enabled");
em.register(this, new HmacCheckListener(logger, secret));
long rotationSecs = envLong("RAMPART_HMAC_ROTATION_SECS", 3600);
long ttlSecs = envLong("RAMPART_HMAC_TTL_SECS", 60);
logger.info("HMAC verification enabled (rotation={}s, ttl={}s)", rotationSecs, ttlSecs);
em.register(this, new HmacCheckListener(logger, secret, rotationSecs, ttlSecs));
} else {
logger.warn("RAMPART_HMAC_SECRET not set — HMAC verification disabled");
}
@ -61,11 +66,39 @@ public class RampartVelocity {
CaptchaHandler captchaHandler = new CaptchaHandler(logger, server);
em.register(this, captchaHandler);
em.register(this, new CaptchaChallengeListener(captchaHandler));
logger.info("CAPTCHA handler enabled");
logger.info("Server registry + load balancer started with Redis at {}", redisUrl);
}
private static class CaptchaChallengeListener {
private final CaptchaHandler captchaHandler;
CaptchaChallengeListener(CaptchaHandler captchaHandler) {
this.captchaHandler = captchaHandler;
}
@Subscribe
public void onServerPostConnect(ServerPostConnectEvent event) {
Player player = event.getPlayer();
if (player.isActive()) {
captchaHandler.challenge(player);
}
}
}
private static long envLong(String name, long def) {
String value = System.getenv(name);
if (value == null || value.isEmpty()) return def;
try {
return Long.parseLong(value.trim());
} catch (NumberFormatException e) {
return def;
}
}
private List<String> loadDomainWhitelist() {
String env = System.getenv("RAMPART_ALLOWED_DOMAINS");
if (env == null || env.isEmpty()) return Collections.emptyList();

View file

@ -24,6 +24,7 @@ public class ServerRegistry {
private final String redisUrl;
private final AtomicInteger counter = new AtomicInteger(0);
private final ConcurrentHashMap<String, Double> tpsCache = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, String> serverDomains = new ConcurrentHashMap<>();
private volatile List<RegisteredServer> cachedServers = new ArrayList<>();
public ServerRegistry(ProxyServer proxyServer, Logger logger, String redisUrl) {
@ -63,6 +64,7 @@ public class ServerRegistry {
if (!redisNames.contains(name)) {
proxyServer.getServer(name).ifPresent(s ->
proxyServer.unregisterServer(s.getServerInfo()));
serverDomains.remove(name);
unregistered++;
}
}
@ -95,6 +97,12 @@ public class ServerRegistry {
if (!"online".equals(status)) continue;
double tps = extractJsonDouble(json, "tps");
tpsCache.put(name, tps);
String domain = extractJsonString(json, "domain");
if (domain != null && !domain.isEmpty()) {
serverDomains.put(name, domain.trim());
} else {
serverDomains.remove(name);
}
servers.add(new ServerInfo(name, InetSocketAddress.createUnresolved(ip, port)));
} catch (Exception e) {
logger.warn("Failed to parse server data for key {}: {}", key, e.getMessage());
@ -121,6 +129,10 @@ public class ServerRegistry {
return tpsCache.getOrDefault(name, 20.0);
}
public String getServerDomain(String name) {
return serverDomains.get(name);
}
private static String extractJsonString(String json, String key) {
String search = "\"" + key + "\":\"";
int start = json.indexOf(search);

View file

@ -38,6 +38,16 @@ public class ServerRouter {
}
public Optional<RegisteredServer> routeServer(String domain) {
String cleanDomain = domain == null ? "" : domain.trim();
if (!cleanDomain.isEmpty()) {
for (RegisteredServer server : registry.getCachedServers()) {
String name = server.getServerInfo().getName();
if (cleanDomain.equals(registry.getServerDomain(name))
&& registry.getServerTps(name) >= TPS_DEAD) {
return Optional.of(server);
}
}
}
for (RegisteredServer server : registry.getCachedServers()) {
double tps = registry.getServerTps(server.getServerInfo().getName());
if (tps >= TPS_DEAD) {

View file

@ -113,6 +113,101 @@ public class RampartVelocityTest {
assertTrue(DomainCheckUtil.isDomainAllowed("192.168.1.1", List.of()));
}
// --- Dual-key hostname verification (mirrors HmacCheckListener.verifyHostname) ---
@Test
void hostnameVerifyAcceptsValidSignature() {
long ts = System.currentTimeMillis() / 1000;
String raw = signHostname("play.example.com", ts, 3600, "test_secret");
assertTrue(HmacCheckListener.verifyHostname(raw, "test_secret".getBytes(), 3600, 60));
}
@Test
void hostnameVerifyRejectsWrongSecret() {
long ts = System.currentTimeMillis() / 1000;
String raw = signHostname("play.example.com", ts, 3600, "secret_a");
assertFalse(HmacCheckListener.verifyHostname(raw, "secret_b".getBytes(), 3600, 60));
}
@Test
void hostnameVerifyRejectsTamperedSig() {
long ts = System.currentTimeMillis() / 1000;
String raw = signHostname("play.example.com", ts, 3600, "test_secret");
char flip = raw.charAt(raw.length() - 1) == '0' ? '1' : '0';
String tampered = raw.substring(0, raw.length() - 1) + flip;
assertFalse(HmacCheckListener.verifyHostname(tampered, "test_secret".getBytes(), 3600, 60));
}
@Test
void hostnameVerifyRejectsUnsigned() {
assertFalse(HmacCheckListener.verifyHostname("play.example.com", "test_secret".getBytes(), 3600, 60));
}
@Test
void hostnameVerifyRejectsMalformedTs() {
String raw = "play.example.com\0shield\0notanumber\0" + "a".repeat(64);
assertFalse(HmacCheckListener.verifyHostname(raw, "test_secret".getBytes(), 3600, 60));
}
@Test
void hostnameVerifyRejectsBadSigLength() {
long ts = System.currentTimeMillis() / 1000;
String raw = "play.example.com\0shield\0" + ts + "\0" + "a".repeat(63);
assertFalse(HmacCheckListener.verifyHostname(raw, "test_secret".getBytes(), 3600, 60));
}
@Test
void hostnameVerifyRejectsExpired() {
long ts = System.currentTimeMillis() / 1000 - 120;
String raw = signHostname("play.example.com", ts, 3600, "test_secret");
assertFalse(HmacCheckListener.verifyHostname(raw, "test_secret".getBytes(), 3600, 60));
}
@Test
void hostnameVerifyRejectsFutureTimestamp() {
long ts = System.currentTimeMillis() / 1000 + 3600;
String raw = signHostname("play.example.com", ts, 3600, "test_secret");
assertFalse(HmacCheckListener.verifyHostname(raw, "test_secret".getBytes(), 3600, 60));
}
@Test
void hostnameVerifyAcceptsPreviousRotationBucket() {
long ts = System.currentTimeMillis() / 1000;
long bucket = ts / 3600 - 1;
String raw = "play.example.com\0shield\0" + ts + "\0"
+ sign("play.example.com", ts, bucket, "test_secret");
assertTrue(HmacCheckListener.verifyHostname(raw, "test_secret".getBytes(), 3600, 60));
}
private static String signHostname(String domain, long ts, long rotationSecs, String secret) {
long bucket = ts / rotationSecs;
return domain + "\0shield\0" + ts + "\0" + sign(domain, ts, bucket, secret);
}
private static String sign(String domain, long ts, long bucket, String secret) {
byte[] derived = hmacRaw(secret.getBytes(), ("rampart-key-" + bucket).getBytes());
byte[] sig = hmacRaw(derived, (domain + "|" + ts).getBytes());
return toHex(sig);
}
private static byte[] hmacRaw(byte[] key, byte[] data) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key, "HmacSHA256"));
return mac.doFinal(data);
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
throw new AssertionError(e);
}
}
private static String toHex(byte[] raw) {
StringBuilder sb = new StringBuilder(raw.length * 2);
for (byte b : raw) {
sb.append(String.format("%02x", b & 0xFF));
}
return sb.toString();
}
// --- HMAC utility (mirrors HmacCheckListener) ---
private String hmacHex(String data, String secret) {