v0.3: 6-layer architecture complete
Layers: Layer 1: XDP/eBPF — TCP state machine, SYN throttle, blacklist, ringbuf Layer 2: PoW Challenge — SHA-256 hashcash, dynamic difficulty, constant-time verify Layer 3: Rust Core — HMAC handshake, rate limit, death code (existing) Layer 4: Velocity — Physics check, CAPTCHA, protocol verification Layer 5: Paper — Heartbeat, auto-registration (existing) Layer 6: Traffic Intel — EWMA, 168h profiling, reputation, alerts Infra: XDP→Prometheus metrics, ClickHouse + Grafana dashboard, Docker Compose Testing: 100-IP DDoS simulation, MHDDoS ref analysis, load test report Fixes: VarInt sign extension UB, pure ACK deadlock, RST/FIN cleanup Ref: MHDDoS, Sonar, LimboFilter, AtomGuard, Infrarust, MC-XDP-eBPF, PowGo
This commit is contained in:
parent
78fc6e00c7
commit
269daa071f
66 changed files with 4529 additions and 1003 deletions
|
|
@ -0,0 +1,111 @@
|
|||
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;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
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 {
|
||||
|
||||
private static final int MAX_ATTEMPTS = 3;
|
||||
private static final long EXPIRY_MS = 30_000;
|
||||
|
||||
private final Logger logger;
|
||||
private final ProxyServer server;
|
||||
private final Map<UUID, CaptchaSession> pending = new ConcurrentHashMap<>();
|
||||
private final Random random = new Random();
|
||||
|
||||
public CaptchaHandler(Logger logger, ProxyServer server) {
|
||||
this.logger = logger;
|
||||
this.server = server;
|
||||
}
|
||||
|
||||
public void challenge(Player player) {
|
||||
int token = 1000 + random.nextInt(9000);
|
||||
pending.put(player.getUniqueId(), new CaptchaSession(token, 0, System.currentTimeMillis() + EXPIRY_MS));
|
||||
player.sendMessage(Component.text("§e[Rampart] Please type §6/verify " + token + " §eto verify you are not a bot."));
|
||||
logger.info("CAPTCHA challenge sent to {}", player.getUsername());
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onPlayerChat(PlayerChatEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
CaptchaSession session = pending.get(player.getUniqueId());
|
||||
if (session == null) return;
|
||||
|
||||
if (System.currentTimeMillis() > session.expiry) {
|
||||
pending.remove(player.getUniqueId());
|
||||
player.disconnect(Component.text("CAPTCHA timed out"));
|
||||
logger.info("CAPTCHA timed out for {}", player.getUsername());
|
||||
return;
|
||||
}
|
||||
|
||||
String message = event.getMessage().trim();
|
||||
if (message.startsWith("/verify ")) {
|
||||
event.setResult(PlayerChatEvent.ChatResult.denied());
|
||||
String[] parts = message.split(" ");
|
||||
if (parts.length == 2) {
|
||||
try {
|
||||
int guess = Integer.parseInt(parts[1]);
|
||||
if (guess == session.token) {
|
||||
pending.remove(player.getUniqueId());
|
||||
player.sendMessage(Component.text("§a[Rampart] You have been verified."));
|
||||
logger.info("CAPTCHA passed for {}", player.getUsername());
|
||||
HmacCheckListener.markVerified(player.getUniqueId());
|
||||
PhysicsCheckListener.clearSuspicion(player.getUniqueId());
|
||||
return;
|
||||
}
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
}
|
||||
session.attempts++;
|
||||
if (session.attempts >= MAX_ATTEMPTS) {
|
||||
pending.remove(player.getUniqueId());
|
||||
player.disconnect(Component.text("CAPTCHA failed"));
|
||||
logger.warn("CAPTCHA failed for {}", player.getUsername());
|
||||
} else {
|
||||
player.sendMessage(Component.text("§c[Rampart] Incorrect. Attempts remaining: " + (MAX_ATTEMPTS - session.attempts)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onCommand(CommandExecuteEvent event) {
|
||||
if (!(event.getCommandSource() instanceof Player player)) return;
|
||||
CaptchaSession session = pending.get(player.getUniqueId());
|
||||
if (session == null) return;
|
||||
String cmd = event.getCommand();
|
||||
if (!cmd.startsWith("verify ")) {
|
||||
event.setResult(CommandExecuteEvent.CommandResult.denied());
|
||||
player.sendMessage(Component.text("§c[Rampart] You must complete the CAPTCHA first. Type /verify <number>"));
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onDisconnect(DisconnectEvent event) {
|
||||
pending.remove(event.getPlayer().getUniqueId());
|
||||
}
|
||||
|
||||
private static class CaptchaSession {
|
||||
final int token;
|
||||
int attempts;
|
||||
final long expiry;
|
||||
|
||||
CaptchaSession(int token, int attempts, long expiry) {
|
||||
this.token = token;
|
||||
this.attempts = attempts;
|
||||
this.expiry = expiry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,9 @@ import javax.crypto.Mac;
|
|||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public class HmacCheckListener {
|
||||
|
||||
|
|
@ -16,6 +19,8 @@ public class HmacCheckListener {
|
|||
private static final String HMAC_ALGO = "HmacSHA256";
|
||||
private static final int HEX_SIG_LENGTH = 64;
|
||||
|
||||
private static final Map<UUID, Boolean> verifiedPlayers = new ConcurrentHashMap<>();
|
||||
|
||||
private final Logger logger;
|
||||
private final byte[] secret;
|
||||
|
||||
|
|
@ -24,6 +29,11 @@ public class HmacCheckListener {
|
|||
this.secret = secret.getBytes();
|
||||
}
|
||||
|
||||
public static void markVerified(UUID uuid) {
|
||||
verifiedPlayers.put(uuid, true);
|
||||
PhysicsCheckListener.clearSuspicion(uuid);
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onLogin(LoginEvent event) {
|
||||
var player = event.getPlayer();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,184 @@
|
|||
package me.rampart.velocity;
|
||||
|
||||
import com.velocitypowered.api.event.Subscribe;
|
||||
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.event.player.PlayerResourcePackStatusEvent;
|
||||
import com.velocitypowered.api.event.player.PlayerSettingsChangedEvent;
|
||||
import com.velocitypowered.api.event.player.TabCompleteEvent;
|
||||
import com.velocitypowered.api.proxy.Player;
|
||||
import com.velocitypowered.api.proxy.ProxyServer;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Deque;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class PhysicsCheckListener {
|
||||
|
||||
private static final double[] EXPECTED_Y = new double[128];
|
||||
private static final int FALL_THRESHOLD = 3;
|
||||
private static final int PROTOCOL_THRESHOLD = 3;
|
||||
private static final int VEHICLE_THRESHOLD = 2;
|
||||
private static final int PROTOCOL_HISTORY = 5;
|
||||
private static final double MAX_BOAT_SPEED = 0.6;
|
||||
private static final double MAX_MINECART_SPEED = 0.4;
|
||||
|
||||
static {
|
||||
for (int t = 0; t < 128; t++) {
|
||||
EXPECTED_Y[t] = 3.92 * (Math.pow(0.98, t) - 1);
|
||||
}
|
||||
}
|
||||
|
||||
private static final Map<UUID, PlayerTracker> tracked = new ConcurrentHashMap<>();
|
||||
|
||||
private final Logger logger;
|
||||
private final ProxyServer server;
|
||||
|
||||
public PhysicsCheckListener(Logger logger, ProxyServer server) {
|
||||
this.logger = logger;
|
||||
this.server = server;
|
||||
server.getScheduler().buildTask(this, this::tickAll)
|
||||
.repeat(50, TimeUnit.MILLISECONDS)
|
||||
.schedule();
|
||||
}
|
||||
|
||||
public static void clearSuspicion(UUID uuid) {
|
||||
PlayerTracker tracker = tracked.get(uuid);
|
||||
if (tracker != null) {
|
||||
tracker.suspicionScore = 0;
|
||||
tracker.consecutiveFallViolations = 0;
|
||||
tracker.protocolViolations = 0;
|
||||
tracker.reVerificationTriggered = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onLogin(LoginEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
tracked.put(player.getUniqueId(), new PlayerTracker());
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onDisconnect(DisconnectEvent event) {
|
||||
tracked.remove(event.getPlayer().getUniqueId());
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onPlayerSettings(PlayerSettingsChangedEvent event) {
|
||||
PlayerTracker tracker = tracked.get(event.getPlayer().getUniqueId());
|
||||
if (tracker == null) return;
|
||||
tracker.virtualTick++;
|
||||
tracker.lastEventTime = System.currentTimeMillis();
|
||||
tracker.addProtocol("Settings");
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onPlayerChat(PlayerChatEvent event) {
|
||||
PlayerTracker tracker = tracked.get(event.getPlayer().getUniqueId());
|
||||
if (tracker == null) return;
|
||||
tracker.virtualTick++;
|
||||
tracker.lastEventTime = System.currentTimeMillis();
|
||||
tracker.addProtocol("Chat");
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onTabComplete(TabCompleteEvent event) {
|
||||
PlayerTracker tracker = tracked.get(event.getPlayer().getUniqueId());
|
||||
if (tracker == null) return;
|
||||
tracker.addProtocol("TabComplete");
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onResourcePackStatus(PlayerResourcePackStatusEvent event) {
|
||||
PlayerTracker tracker = tracked.get(event.getPlayer().getUniqueId());
|
||||
if (tracker == null) return;
|
||||
tracker.addProtocol("ResourcePack");
|
||||
}
|
||||
|
||||
private void tickAll() {
|
||||
for (Map.Entry<UUID, PlayerTracker> entry : tracked.entrySet()) {
|
||||
Player player = server.getPlayer(entry.getKey()).orElse(null);
|
||||
if (player == null) continue;
|
||||
PlayerTracker tracker = entry.getValue();
|
||||
checkFalling(player, tracker);
|
||||
checkProtocol(player, tracker);
|
||||
}
|
||||
}
|
||||
|
||||
private void checkFalling(Player player, PlayerTracker tracker) {
|
||||
if (player.hasPermission("rampart.bypass.fly")) return;
|
||||
if (tracker.virtualTick >= 128) return;
|
||||
if (tracker.virtualTick < 0) return;
|
||||
|
||||
double expectedDelta = EXPECTED_Y[tracker.virtualTick];
|
||||
double actualDelta = 0;
|
||||
if (tracker.lastEventTime > 0) {
|
||||
long dt = System.currentTimeMillis() - tracker.lastEventTime;
|
||||
actualDelta = 3.92 * (Math.pow(0.98, Math.max(1, dt / 50.0)) - 1);
|
||||
}
|
||||
if (Math.abs(actualDelta - expectedDelta) > 0.001) {
|
||||
tracker.consecutiveFallViolations++;
|
||||
if (tracker.consecutiveFallViolations >= FALL_THRESHOLD) {
|
||||
tracker.suspicionScore += FALL_THRESHOLD;
|
||||
tracker.consecutiveFallViolations = 0;
|
||||
logger.warn("Falling check triggered for {} (score={})",
|
||||
player.getUsername(), tracker.suspicionScore);
|
||||
if (tracker.suspicionScore >= FALL_THRESHOLD) {
|
||||
triggerReVerify(player, tracker);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracker.consecutiveFallViolations = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private void checkProtocol(Player player, PlayerTracker tracker) {
|
||||
if (tracker.protocolHistory.size() < PROTOCOL_HISTORY) return;
|
||||
boolean unexpected = false;
|
||||
String last = tracker.protocolHistory.getLast();
|
||||
if (last.equals("Chat") || last.equals("TabComplete")) {
|
||||
} else {
|
||||
unexpected = true;
|
||||
tracker.protocolViolations++;
|
||||
if (tracker.protocolViolations >= PROTOCOL_THRESHOLD) {
|
||||
tracker.suspicionScore += PROTOCOL_THRESHOLD;
|
||||
tracker.protocolViolations = 0;
|
||||
logger.warn("Protocol check triggered for {} (score={})",
|
||||
player.getUsername(), tracker.suspicionScore);
|
||||
if (tracker.suspicionScore >= PROTOCOL_THRESHOLD) {
|
||||
triggerReVerify(player, tracker);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void triggerReVerify(Player player, PlayerTracker tracker) {
|
||||
if (tracker.reVerificationTriggered) return;
|
||||
tracker.reVerificationTriggered = true;
|
||||
player.disconnect(Component.text("Re-verification required. Please reconnect."));
|
||||
logger.info("Triggered re-verification for {}", player.getUsername());
|
||||
}
|
||||
|
||||
private static class PlayerTracker {
|
||||
int virtualTick = 0;
|
||||
long lastEventTime = 0;
|
||||
int consecutiveFallViolations = 0;
|
||||
int protocolViolations = 0;
|
||||
int suspicionScore = 0;
|
||||
boolean reVerificationTriggered = false;
|
||||
Deque<String> protocolHistory = new ArrayDeque<>();
|
||||
|
||||
void addProtocol(String type) {
|
||||
protocolHistory.addLast(type);
|
||||
if (protocolHistory.size() > PROTOCOL_HISTORY) {
|
||||
protocolHistory.removeFirst();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@ import java.util.List;
|
|||
id = "rampart",
|
||||
name = "Rampart",
|
||||
version = "0.1.0",
|
||||
description = "HMAC hostname verification + domain whitelist + Redis server registry for Rampart",
|
||||
description = "HMAC hostname verification + domain whitelist + Redis server registry + load balancer",
|
||||
authors = {"loki"}
|
||||
)
|
||||
public class RampartVelocity {
|
||||
|
|
@ -25,20 +25,21 @@ public class RampartVelocity {
|
|||
@Inject
|
||||
public RampartVelocity(ProxyServer server, Logger logger) {
|
||||
this.logger = logger;
|
||||
EventManager em = server.getEventManager();
|
||||
|
||||
String secret = System.getenv("RAMPART_HMAC_SECRET");
|
||||
List<String> allowed = loadDomainWhitelist();
|
||||
|
||||
if (!allowed.isEmpty()) {
|
||||
logger.info("Domain whitelist: {} domains loaded", allowed.size());
|
||||
server.getEventManager().register(this, new DomainCheckListener(logger, allowed));
|
||||
em.register(this, new DomainCheckListener(logger, allowed));
|
||||
} else {
|
||||
logger.warn("RAMPART_ALLOWED_DOMAINS not set — domain check disabled");
|
||||
}
|
||||
|
||||
if (secret != null && !secret.isEmpty()) {
|
||||
logger.info("HMAC verification enabled");
|
||||
server.getEventManager().register(this, new HmacCheckListener(logger, secret));
|
||||
em.register(this, new HmacCheckListener(logger, secret));
|
||||
} else {
|
||||
logger.warn("RAMPART_HMAC_SECRET not set — HMAC verification disabled");
|
||||
}
|
||||
|
|
@ -50,7 +51,19 @@ public class RampartVelocity {
|
|||
|
||||
serverRegistry = new ServerRegistry(server, logger, redisUrl);
|
||||
serverRegistry.startSync();
|
||||
logger.info("Server registry sync started with Redis at {}", redisUrl);
|
||||
|
||||
ServerRouter router = new ServerRouter(serverRegistry, logger);
|
||||
em.register(this, router);
|
||||
|
||||
PhysicsCheckListener physicsCheck = new PhysicsCheckListener(logger, server);
|
||||
em.register(this, physicsCheck);
|
||||
logger.info("Physics checks enabled");
|
||||
|
||||
CaptchaHandler captchaHandler = new CaptchaHandler(logger, server);
|
||||
em.register(this, captchaHandler);
|
||||
logger.info("CAPTCHA handler enabled");
|
||||
|
||||
logger.info("Server registry + load balancer started with Redis at {}", redisUrl);
|
||||
}
|
||||
|
||||
private List<String> loadDomainWhitelist() {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import java.util.List;
|
|||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.stream.Collectors;
|
||||
|
|
@ -22,6 +23,7 @@ public class ServerRegistry {
|
|||
private final Logger logger;
|
||||
private final String redisUrl;
|
||||
private final AtomicInteger counter = new AtomicInteger(0);
|
||||
private final ConcurrentHashMap<String, Double> tpsCache = new ConcurrentHashMap<>();
|
||||
private volatile List<RegisteredServer> cachedServers = new ArrayList<>();
|
||||
|
||||
public ServerRegistry(ProxyServer proxyServer, Logger logger, String redisUrl) {
|
||||
|
|
@ -91,6 +93,8 @@ public class ServerRegistry {
|
|||
if (port <= 0) continue;
|
||||
String status = extractJsonString(json, "status");
|
||||
if (!"online".equals(status)) continue;
|
||||
double tps = extractJsonDouble(json, "tps");
|
||||
tpsCache.put(name, tps);
|
||||
servers.add(new ServerInfo(name, InetSocketAddress.createUnresolved(ip, port)));
|
||||
} catch (Exception e) {
|
||||
logger.warn("Failed to parse server data for key {}: {}", key, e.getMessage());
|
||||
|
|
@ -113,6 +117,10 @@ public class ServerRegistry {
|
|||
return cachedServers;
|
||||
}
|
||||
|
||||
public double getServerTps(String name) {
|
||||
return tpsCache.getOrDefault(name, 20.0);
|
||||
}
|
||||
|
||||
private static String extractJsonString(String json, String key) {
|
||||
String search = "\"" + key + "\":\"";
|
||||
int start = json.indexOf(search);
|
||||
|
|
@ -139,4 +147,21 @@ public class ServerRegistry {
|
|||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
private static double extractJsonDouble(String json, String key) {
|
||||
String search = "\"" + key + "\":";
|
||||
int start = json.indexOf(search);
|
||||
if (start < 0) return 20.0;
|
||||
start += search.length();
|
||||
int end = start;
|
||||
while (end < json.length() && (Character.isDigit(json.charAt(end)) || json.charAt(end) == '.')) {
|
||||
end++;
|
||||
}
|
||||
if (end == start) return 20.0;
|
||||
try {
|
||||
return Double.parseDouble(json.substring(start, end));
|
||||
} catch (NumberFormatException e) {
|
||||
return 20.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,49 @@
|
|||
package me.rampart.velocity;
|
||||
|
||||
import com.velocitypowered.api.event.Subscribe;
|
||||
import com.velocitypowered.api.event.player.ServerPreConnectEvent;
|
||||
import com.velocitypowered.api.proxy.server.RegisteredServer;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public class ServerRouter {
|
||||
|
||||
private final ServerRegistry registry;
|
||||
private static final double TPS_DEAD = 12.0;
|
||||
|
||||
public ServerRouter(ServerRegistry registry) {
|
||||
private final ServerRegistry registry;
|
||||
private final Logger logger;
|
||||
|
||||
public ServerRouter(ServerRegistry registry, Logger logger) {
|
||||
this.registry = registry;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onServerPreConnect(ServerPreConnectEvent event) {
|
||||
if (event.getResult().getServer().isPresent()) {
|
||||
return;
|
||||
}
|
||||
String domain = event.getPlayer().getVirtualHost()
|
||||
.map(vh -> vh.getHostString().split("\0")[0])
|
||||
.orElse("");
|
||||
|
||||
Optional<RegisteredServer> target = routeServer(domain);
|
||||
if (target.isPresent()) {
|
||||
event.setResult(ServerPreConnectEvent.ServerResult.allowed(target.get()));
|
||||
} else {
|
||||
logger.warn("No available backend server for {} (domain: {})",
|
||||
event.getPlayer().getRemoteAddress(), domain);
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<RegisteredServer> routeServer(String domain) {
|
||||
for (RegisteredServer server : registry.getCachedServers()) {
|
||||
double tps = registry.getServerTps(server.getServerInfo().getName());
|
||||
if (tps >= TPS_DEAD) {
|
||||
return Optional.of(server);
|
||||
}
|
||||
}
|
||||
return registry.getNextServer();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue