Initial commit: Rampart v0.2.0

Multi-layer DDoS protection for Minecraft servers.

- rampart-core: Edge node with XDP/eBPF + Rust L7 filtering
- rampart-manager: REST API with JWT auth, Redis sync
- rampart-cli: CLI tool for operators
- velocity-plugin: Domain check, HMAC verify, server registry, load balancer
- paper-plugin: Auto-registration, heartbeat, HMAC verify
- dashboard: React + Vite web UI for management
This commit is contained in:
loki5512344 2026-07-20 20:53:32 +02:00
commit cf9608ce5d
Signed by: boba
GPG key ID: 253067914055423B
159 changed files with 15341 additions and 0 deletions

View file

@ -0,0 +1,17 @@
[package]
name = "rampart-cli"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
tokio.workspace = true
serde.workspace = true
serde_json.workspace = true
tracing.workspace = true
anyhow.workspace = true
clap.workspace = true
reqwest = { version = "0.12", features = ["json"] }

View file

@ -0,0 +1,69 @@
use serde::Deserialize;
#[derive(Deserialize)]
struct AddResponse {
status: String,
target: String,
}
#[derive(Deserialize)]
struct BlacklistItem {
target: String,
reason: String,
}
#[derive(Deserialize)]
struct BlacklistResponse {
items: Vec<BlacklistItem>,
total: usize,
}
pub async fn add(target: String, reason: Option<String>) -> anyhow::Result<()> {
let manager_url = std::env::var("MC_SHIELD_MANAGER").unwrap_or_else(|_| "http://localhost:8080".to_string());
let body = serde_json::json!({
"target": target,
"type": "ip",
"reason": reason.unwrap_or_else(|| "manual".to_string()),
});
let client = reqwest::Client::new();
match client
.post(format!("{manager_url}/api/v1/blacklist"))
.json(&body)
.send()
.await
{
Ok(resp) => {
if let Ok(add_resp) = resp.json::<AddResponse>().await {
println!("[OK] {}: {}", add_resp.status, add_resp.target);
}
},
Err(e) => println!("[FAIL] {e}"),
}
Ok(())
}
pub async fn remove(target: String) -> anyhow::Result<()> {
println!("Removing {target} from blacklist...");
println!("(not implemented in v0.1)");
Ok(())
}
pub async fn list() -> anyhow::Result<()> {
let manager_url = std::env::var("MC_SHIELD_MANAGER").unwrap_or_else(|_| "http://localhost:8080".to_string());
match reqwest::get(format!("{manager_url}/api/v1/blacklist")).await {
Ok(resp) => {
if let Ok(list) = resp.json::<BlacklistResponse>().await {
println!("Blacklist ({} entries)", list.total);
println!("----------------------");
for item in &list.items {
println!(" {} ({})", item.target, item.reason);
}
}
},
Err(e) => println!("[FAIL] {e}"),
}
Ok(())
}

View file

@ -0,0 +1,24 @@
pub async fn run(key: Option<String>, value: Option<String>) -> anyhow::Result<()> {
match (key, value) {
(Some(k), Some(v)) => {
println!("Setting {k} = {v}");
Ok(())
},
(Some(k), None) => {
println!("Reading config key: {k}");
println!("(not implemented in v0.1)");
Ok(())
},
(None, Some(_)) | (None, None) => {
println!("Configuration");
println!("=============\n");
println!("Use: rampart config <key> [value]");
println!();
println!("Example keys:");
println!(" workers.count");
println!(" limits.rate_limit_login_pps");
println!(" limits.max_connections_per_ip");
Ok(())
},
}
}

View file

@ -0,0 +1,45 @@
pub async fn run() -> anyhow::Result<()> {
println!("Rampart Diagnostics");
println!("=====================\n");
let mut all_ok = true;
let manager_url = std::env::var("RAMPART_MANAGER").unwrap_or_else(|_| "http://localhost:8080".to_string());
match reqwest::get(format!("{manager_url}/api/v1/health")).await {
Ok(resp) if resp.status().is_success() => {
println!("[OK] Manager API");
},
_ => {
println!("[FAIL] Manager API");
all_ok = false;
},
}
match reqwest::get(format!("{manager_url}/api/v1/blacklist")).await {
Ok(resp) if resp.status().is_success() => {
println!("[OK] Blacklist API");
},
_ => {
println!("[WARN] Blacklist API unavailable");
},
}
match reqwest::get(format!("{manager_url}/api/v1/servers")).await {
Ok(resp) if resp.status().is_success() => {
println!("[OK] Servers API");
},
_ => {
println!("[WARN] Servers API unavailable");
},
}
println!();
if all_ok {
println!("All checks passed.");
} else {
println!("Some checks failed. Run with --verbose for details.");
}
Ok(())
}

View file

@ -0,0 +1,7 @@
pub async fn run(node: &str) -> anyhow::Result<()> {
println!("Draining node: {node}");
println!("Waiting for active connections to drain...");
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
println!("Node {node} drained successfully.");
Ok(())
}

View file

@ -0,0 +1,11 @@
pub async fn enable() -> anyhow::Result<()> {
println!("Emergency mode ENABLED");
println!("Only whitelisted IPs will be allowed through.");
Ok(())
}
pub async fn disable() -> anyhow::Result<()> {
println!("Emergency mode DISABLED");
println!("Normal filtering resumed.");
Ok(())
}

View file

@ -0,0 +1,6 @@
pub mod blacklist;
pub mod config;
pub mod doctor;
pub mod drain;
pub mod emergency;
pub mod status;

View file

@ -0,0 +1,23 @@
pub async fn run() -> anyhow::Result<()> {
println!("Rampart Status");
println!("================\n");
let manager_url = std::env::var("RAMPART_MANAGER").unwrap_or_else(|_| "http://localhost:8080".to_string());
match reqwest::get(format!("{manager_url}/api/v1/health")).await {
Ok(resp) => {
if let Ok(body) = resp.json::<serde_json::Value>().await {
println!(
"Manager: {} (v{})",
body["status"].as_str().unwrap_or("unknown"),
body["version"].as_str().unwrap_or("?")
);
}
},
Err(e) => println!("Manager: unreachable ({e})"),
}
println!();
println!("To check individual components, run: rampart doctor");
Ok(())
}

View file

@ -0,0 +1,73 @@
#![allow(clippy::print_stdout, clippy::print_stderr)]
use clap::{Parser, Subcommand};
mod commands;
#[derive(Parser)]
#[command(name = "rampart", about = "Rampart CLI")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Show overall system status
Status,
/// Run full diagnostics
Doctor,
/// Get/set configuration
Config {
#[arg(required = false)]
key: Option<String>,
#[arg(required = false)]
value: Option<String>,
},
/// Manage blacklist
Blacklist {
#[command(subcommand)]
action: BlacklistAction,
},
/// Emergency mode
Emergency {
#[arg(value_enum)]
mode: EmergencyMode,
},
/// Gracefully drain a node
Drain { node: String },
}
#[derive(Subcommand)]
enum BlacklistAction {
Add { target: String, reason: Option<String> },
Remove { target: String },
List,
}
#[derive(clap::ValueEnum, Clone)]
enum EmergencyMode {
Enable,
Disable,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Status => commands::status::run().await,
Commands::Doctor => commands::doctor::run().await,
Commands::Config { key, value } => commands::config::run(key, value).await,
Commands::Blacklist { action } => match action {
BlacklistAction::Add { target, reason } => commands::blacklist::add(target, reason).await,
BlacklistAction::Remove { target } => commands::blacklist::remove(target).await,
BlacklistAction::List => commands::blacklist::list().await,
},
Commands::Emergency { mode } => match mode {
EmergencyMode::Enable => commands::emergency::enable().await,
EmergencyMode::Disable => commands::emergency::disable().await,
},
Commands::Drain { node } => commands::drain::run(&node).await,
}
}

View file

@ -0,0 +1,39 @@
[package]
name = "rampart-core"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
tokio.workspace = true
serde.workspace = true
serde_json.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
thiserror.workspace = true
anyhow.workspace = true
dashmap.workspace = true
crossbeam.workspace = true
hex.workspace = true
sha2.workspace = true
hmac.workspace = true
subtle.workspace = true
socket2 = { workspace = true, features = ["all"] }
prometheus.workspace = true
toml.workspace = true
futures.workspace = true
redis = { version = "0.27", optional = true, features = ["tokio-comp"] }
maxminddb = { version = "0.30", optional = true }
tokio-splice = { version = "0.2", optional = true }
libbpf-rs = { version = "0.24", optional = true }
[features]
default = ["store-redis"]
store-redis = ["dep:redis"]
geoip = ["dep:maxminddb"]
xdp = ["dep:libbpf-rs"]
io-uring = ["dep:tokio-splice"]

View file

@ -0,0 +1,256 @@
use serde::Deserialize;
use std::fs;
#[derive(Debug, Clone, Deserialize)]
pub struct Config {
#[serde(default)]
pub bind: BindConfig,
#[serde(default)]
pub backend: BackendConfig,
#[serde(default)]
pub hmac: HmacConfig,
#[serde(default)]
pub workers: WorkerConfig,
#[serde(default)]
pub limits: LimitsConfig,
#[serde(default)]
pub store: StoreConfig,
#[serde(default)]
pub xdp: XdpConfig,
#[serde(default)]
pub death_code: DeathCodeConfig,
#[serde(default)]
pub logging: LoggingConfig,
#[serde(default)]
pub metrics: MetricsConfig,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct BindConfig {
#[serde(default = "default_bind_address")]
pub address: String,
#[serde(default = "default_bind_port")]
pub port: u16,
}
fn default_bind_address() -> String {
"0.0.0.0".to_string()
}
fn default_bind_port() -> u16 {
25565
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct BackendConfig {
#[serde(default = "default_backend_address")]
pub address: String,
#[serde(default = "default_backend_port")]
pub port: u16,
}
fn default_backend_address() -> String {
"127.0.0.1".to_string()
}
fn default_backend_port() -> u16 {
25566
}
#[derive(Debug, Clone, Deserialize)]
pub struct HmacConfig {
#[serde(default)]
pub secret: String,
#[serde(default = "default_key_rotation")]
pub key_rotation_interval_secs: u64,
}
fn default_key_rotation() -> u64 {
3600
}
impl Default for HmacConfig {
fn default() -> Self {
Self {
secret: String::new(),
key_rotation_interval_secs: 3600,
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct WorkerConfig {
#[serde(default = "default_worker_count")]
pub count: usize,
}
fn default_worker_count() -> usize {
4
}
impl Default for WorkerConfig {
fn default() -> Self {
Self { count: 4 }
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct LimitsConfig {
#[serde(default = "default_handshake_timeout")]
pub handshake_timeout_secs: u64,
#[serde(default = "default_max_connections_per_ip")]
pub max_connections_per_ip: u32,
#[serde(default = "default_rate_limit_login")]
pub rate_limit_login_pps: f64,
#[serde(default = "default_rate_limit_status")]
pub rate_limit_status_pps: f64,
#[serde(default = "default_rate_limit_burst")]
pub rate_limit_burst: f64,
}
fn default_handshake_timeout() -> u64 {
5
}
fn default_max_connections_per_ip() -> u32 {
10
}
fn default_rate_limit_login() -> f64 {
5.0
}
fn default_rate_limit_status() -> f64 {
2.0
}
fn default_rate_limit_burst() -> f64 {
10.0
}
impl Default for LimitsConfig {
fn default() -> Self {
Self {
handshake_timeout_secs: 5,
max_connections_per_ip: 10,
rate_limit_login_pps: 5.0,
rate_limit_status_pps: 2.0,
rate_limit_burst: 10.0,
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct StoreConfig {
pub redis_url: Option<String>,
#[serde(default = "default_blacklist_cache_ttl")]
pub blacklist_cache_ttl_secs: u64,
}
fn default_blacklist_cache_ttl() -> u64 {
300
}
impl Default for StoreConfig {
fn default() -> Self {
Self {
redis_url: None,
blacklist_cache_ttl_secs: 300,
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct XdpConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_xdp_interface")]
pub interface: String,
}
fn default_xdp_interface() -> String {
"eth0".to_string()
}
impl Default for XdpConfig {
fn default() -> Self {
Self {
enabled: false,
interface: "eth0".to_string(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct LoggingConfig {
#[serde(default = "default_log_level")]
pub level: String,
#[serde(default = "default_log_format")]
pub format: String,
}
fn default_log_level() -> String {
"info".to_string()
}
fn default_log_format() -> String {
"text".to_string()
}
impl Default for LoggingConfig {
fn default() -> Self {
Self {
level: "info".to_string(),
format: "text".to_string(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct MetricsConfig {
#[serde(default = "default_metrics_enabled")]
pub enabled: bool,
#[serde(default = "default_metrics_port")]
pub port: u16,
}
fn default_metrics_enabled() -> bool {
true
}
fn default_metrics_port() -> u16 {
9090
}
impl Default for MetricsConfig {
fn default() -> Self {
Self {
enabled: true,
port: 9090,
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct DeathCodeConfig {
#[serde(default = "default_death_code_enabled")]
pub enabled: bool,
#[serde(default = "default_death_code_ban_duration")]
pub ban_duration_secs: u64,
}
fn default_death_code_enabled() -> bool {
true
}
fn default_death_code_ban_duration() -> u64 {
3600
}
impl Default for DeathCodeConfig {
fn default() -> Self {
Self {
enabled: true,
ban_duration_secs: 3600,
}
}
}
impl Config {
pub fn from_file(path: &str) -> anyhow::Result<Self> {
let contents = fs::read_to_string(path)?;
let config: Config = toml::from_str(&contents)?;
Ok(config)
}
}

View file

@ -0,0 +1,73 @@
use hmac::{Hmac, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq;
type HmacSha256 = Hmac<Sha256>;
pub fn sign(hostname: &str, secret: &[u8]) -> String {
let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length");
mac.update(hostname.as_bytes());
hex::encode(mac.finalize().into_bytes())
}
pub fn verify(hostname: &str, provided_sig: &str, secret: &[u8]) -> bool {
let expected = sign(hostname, secret);
expected.as_bytes().ct_eq(provided_sig.as_bytes()).into()
}
pub fn sign_hostname(raw: &str, secret: &[u8]) -> String {
let domain = raw.split('\0').next().unwrap_or(raw);
let sig = sign(domain, secret);
format!("{raw}\0shield\0{sig}")
}
pub fn parse_hostname(raw: &str) -> (String, Option<String>) {
let parts: Vec<&str> = raw.split('\0').collect();
let domain = parts[0].to_string();
let hmac = parts
.iter()
.position(|&p| p == "shield")
.and_then(|i| parts.get(i + 1))
.map(|s| s.to_string());
(domain, hmac)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sign_verify() {
let secret = b"test_secret_32_bytes_long_here!!";
let hostname = "play.example.com";
let sig = sign(hostname, secret);
assert!(verify(hostname, &sig, secret));
}
#[test]
fn test_verify_wrong_secret() {
let secret = b"test_secret_32_bytes_long_here!!";
let wrong = b"wrong_secret_32_bytes_long_here!!!";
let hostname = "play.example.com";
let sig = sign(hostname, wrong);
assert!(!verify(hostname, &sig, secret));
}
#[test]
fn test_sign_hostname_suffix() {
let secret = b"test_secret";
let result = sign_hostname("play.example.com", secret);
assert!(result.starts_with("play.example.com\0shield\0"));
let sig = result.split("\0shield\0").nth(1).unwrap();
assert_eq!(sig.len(), 64);
}
#[test]
fn test_verify_constant_time() {
let secret = b"test_secret_32_bytes_long_here!!";
let hostname = "play.example.com";
let sig = sign(hostname, secret);
assert!(!verify("play.example.co", &sig, secret));
assert!(verify(hostname, &sig, secret));
}
}

View file

@ -0,0 +1 @@
pub mod hmac;

View file

@ -0,0 +1,98 @@
use dashmap::DashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
struct BanEntry {
expires: Instant,
_reason: String,
}
pub struct Blacklist {
entries: Arc<DashMap<u32, BanEntry>>,
}
impl Default for Blacklist {
fn default() -> Self {
Self::new()
}
}
impl Blacklist {
pub fn new() -> Self {
Self {
entries: Arc::new(DashMap::new()),
}
}
pub fn is_blocked(&self, ip: u32) -> bool {
if let Some(entry) = self.entries.get(&ip) {
if entry.expires > Instant::now() {
return true;
}
drop(entry);
self.entries.remove(&ip);
}
false
}
pub fn add(&self, ip: u32, duration: Duration, reason: &str) {
self.entries.insert(
ip,
BanEntry {
expires: Instant::now() + duration,
_reason: reason.to_string(),
},
);
}
pub fn remove(&self, ip: u32) {
self.entries.remove(&ip);
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn clear_expired(&self) {
self.entries.retain(|_, entry| entry.expires > Instant::now());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_blacklist_block() {
let bl = Blacklist::new();
bl.add(0x01020304, Duration::from_secs(60), "test");
assert!(bl.is_blocked(0x01020304));
}
#[test]
fn test_blacklist_not_blocked() {
let bl = Blacklist::new();
bl.add(0x01020304, Duration::from_secs(60), "test");
assert!(!bl.is_blocked(0x05060708));
}
#[test]
fn test_blacklist_expired() {
let bl = Blacklist::new();
bl.add(0x01020304, Duration::from_millis(1), "test");
std::thread::sleep(Duration::from_millis(2));
assert!(!bl.is_blocked(0x01020304));
}
#[test]
fn test_blacklist_remove() {
let bl = Blacklist::new();
bl.add(0x01020304, Duration::from_secs(60), "test");
bl.remove(0x01020304);
assert!(!bl.is_blocked(0x01020304));
}
}

View file

@ -0,0 +1,207 @@
use crate::proxy::handshake::{read_string, read_varint};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeathCode {
EmptyPacket,
PacketTooShort,
InvalidPacketId,
NegativeProtocolVersion,
NonCanonicalVarint,
NullByteInHostname,
UnprintableHostname,
MalformedPacket,
}
impl DeathCode {
pub fn as_str(&self) -> &'static str {
match self {
Self::EmptyPacket => "empty_packet",
Self::PacketTooShort => "packet_too_short",
Self::InvalidPacketId => "invalid_packet_id",
Self::NegativeProtocolVersion => "negative_protocol_version",
Self::NonCanonicalVarint => "non_canonical_varint",
Self::NullByteInHostname => "null_byte_in_hostname",
Self::UnprintableHostname => "unprintable_hostname",
Self::MalformedPacket => "malformed_packet",
}
}
}
pub fn detect(buf: &[u8]) -> Option<DeathCode> {
if buf.is_empty() {
return Some(DeathCode::EmptyPacket);
}
if buf.len() < 3 {
return Some(DeathCode::PacketTooShort);
}
let (packet_len, after_len) = match read_varint(buf, 0) {
Ok(r) => r,
Err(_) => return Some(DeathCode::MalformedPacket),
};
if !is_canonical_varint(buf, 0) {
return Some(DeathCode::NonCanonicalVarint);
}
if packet_len <= 0 || (after_len + packet_len as usize) > buf.len() {
return Some(DeathCode::MalformedPacket);
}
let (packet_id, after_id) = match read_varint(buf, after_len) {
Ok(r) => r,
Err(_) => return Some(DeathCode::MalformedPacket),
};
if !is_canonical_varint(buf, after_len) {
return Some(DeathCode::NonCanonicalVarint);
}
if packet_id != 0x00 {
return Some(DeathCode::InvalidPacketId);
}
let (_protocol_version, after_pv) = match read_varint(buf, after_id) {
Ok(r) => r,
Err(_) => return Some(DeathCode::MalformedPacket),
};
if !is_canonical_varint(buf, after_id) {
return Some(DeathCode::NonCanonicalVarint);
}
let (server_address, _) = match read_string(buf, after_pv) {
Ok(r) => r,
Err(_) => return Some(DeathCode::MalformedPacket),
};
if !is_canonical_varint(buf, after_pv) {
return Some(DeathCode::NonCanonicalVarint);
}
if server_address.contains('\0') {
return Some(DeathCode::NullByteInHostname);
}
if !server_address.chars().all(|c| c.is_ascii_graphic() || c == '.') {
return Some(DeathCode::UnprintableHostname);
}
None
}
fn is_canonical_varint(buf: &[u8], start: usize) -> bool {
let mut value: u32 = 0;
let mut shift = 0;
let mut bytes_used = 0;
for (i, &byte) in buf[start..].iter().enumerate() {
if i >= 5 {
return false;
}
bytes_used = i + 1;
value |= ((byte & 0x7F) as u32) << shift;
shift += 7;
if (byte & 0x80) == 0 {
break;
}
}
if bytes_used >= 5 {
return false;
}
let min_varint = |val: u32| -> usize {
if val == 0 {
return 1;
}
let mut bits = 32 - val.leading_zeros();
let mut bytes = 0;
while bits > 0 {
bytes += 1;
bits = bits.saturating_sub(7);
}
bytes.max(1)
};
bytes_used == min_varint(value)
}
#[cfg(test)]
mod tests {
use super::*;
fn write_varint(buf: &mut Vec<u8>, mut value: i32) {
loop {
if (value & !0x7F) == 0 {
buf.push(value as u8);
return;
}
buf.push((value as u8 & 0x7F) | 0x80);
value >>= 7;
}
}
fn build_handshake_raw(hostname: &str) -> Vec<u8> {
let addr = hostname.as_bytes();
let mut buf = Vec::new();
buf.push(0x00);
write_varint(&mut buf, 765);
write_varint(&mut buf, addr.len() as i32);
buf.extend_from_slice(addr);
buf.extend_from_slice(&[0x63, 0xDD]);
buf.push(0x02);
let len = buf.len() as i32;
let mut pkt = Vec::new();
write_varint(&mut pkt, len);
pkt.extend_from_slice(&buf);
pkt
}
#[test]
fn test_valid_handshake() {
let pkt = build_handshake_raw("play.example.com");
assert_eq!(detect(&pkt), None);
}
#[test]
fn test_empty_packet() {
assert_eq!(detect(&[]), Some(DeathCode::EmptyPacket));
}
#[test]
fn test_null_byte_in_hostname() {
let pkt = build_handshake_raw("play.example.com\0extra");
assert_eq!(detect(&pkt), Some(DeathCode::NullByteInHostname));
}
#[test]
fn test_non_canonical_varint() {
let pkt: Vec<u8> = vec![0x08, 0x00, 0x80, 0x00, 0x02, b'e', b'x', 0x63, 0xDD, 0x02];
assert_eq!(detect(&pkt), Some(DeathCode::NonCanonicalVarint));
}
#[test]
fn test_invalid_packet_id() {
let addr = b"play.example.com";
let mut buf = Vec::new();
write_varint(&mut buf, 1);
buf.extend_from_slice(addr);
buf.extend_from_slice(&[0x63, 0xDD]);
buf.push(0x02);
let len = buf.len() as i32;
let mut pkt = Vec::new();
write_varint(&mut pkt, len);
pkt.extend_from_slice(&buf);
assert_eq!(detect(&pkt), Some(DeathCode::InvalidPacketId));
}
#[test]
fn test_unprintable_hostname() {
let pkt = build_handshake_raw("play\x01example.com");
assert_eq!(detect(&pkt), Some(DeathCode::UnprintableHostname));
}
}

View file

@ -0,0 +1,34 @@
#[cfg(feature = "geoip")]
pub struct GeoIp {
reader: maxminddb::Reader<Vec<u8>>,
}
#[cfg(feature = "geoip")]
impl GeoIp {
pub fn new(db_path: &str) -> anyhow::Result<Self> {
let reader = maxminddb::Reader::open_readfile(db_path)?;
Ok(Self { reader })
}
}
pub enum IpCategory {
Residential,
Datacenter,
Mobile,
Vpn,
Tor,
Unknown,
}
impl std::fmt::Display for IpCategory {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Residential => write!(f, "residential"),
Self::Datacenter => write!(f, "datacenter"),
Self::Mobile => write!(f, "mobile"),
Self::Vpn => write!(f, "vpn"),
Self::Tor => write!(f, "tor"),
Self::Unknown => write!(f, "unknown"),
}
}
}

View file

@ -0,0 +1,4 @@
pub mod blacklist;
pub mod death_code;
pub mod geo;
pub mod rate_limit;

View file

@ -0,0 +1,90 @@
use dashmap::DashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
struct Bucket {
tokens: f64,
last_refill: Instant,
}
pub struct RateLimiter {
buckets: Arc<DashMap<u32, Bucket>>,
max_tokens: f64,
refill_rate: f64,
_refill_interval: Duration,
}
impl RateLimiter {
pub fn new(rate_per_sec: f64, burst: f64) -> Self {
Self {
buckets: Arc::new(DashMap::new()),
max_tokens: burst,
refill_rate: rate_per_sec,
_refill_interval: Duration::from_secs(1),
}
}
pub fn check(&self, ip: u32) -> bool {
let mut entry = self.buckets.entry(ip).or_insert_with(|| Bucket {
tokens: self.max_tokens,
last_refill: Instant::now(),
});
let now = Instant::now();
let elapsed = now.duration_since(entry.last_refill);
let refill = elapsed.as_secs_f64() * self.refill_rate;
entry.tokens = (entry.tokens + refill).min(self.max_tokens);
entry.last_refill = now;
if entry.tokens >= 1.0 {
entry.tokens -= 1.0;
true
} else {
false
}
}
pub fn len(&self) -> usize {
self.buckets.len()
}
pub fn is_empty(&self) -> bool {
self.buckets.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_rate_limit_under() {
let limiter = RateLimiter::new(10.0, 10.0);
assert!(limiter.check(1));
}
#[test]
fn test_rate_limit_over() {
let limiter = RateLimiter::new(1.0, 1.0);
assert!(limiter.check(1));
assert!(!limiter.check(1));
}
#[test]
fn test_rate_limit_burst() {
let limiter = RateLimiter::new(1.0, 5.0);
for _ in 0..5 {
assert!(limiter.check(2));
}
assert!(!limiter.check(2));
}
#[test]
fn test_rate_limit_refill() {
let limiter = RateLimiter::new(100.0, 1.0);
assert!(limiter.check(3));
assert!(!limiter.check(3));
std::thread::sleep(Duration::from_millis(20));
assert!(limiter.check(3));
}
}

View file

@ -0,0 +1,9 @@
pub mod config;
pub mod crypto;
pub mod filter;
pub mod metrics;
pub mod proxy;
pub mod store;
#[cfg(feature = "xdp")]
pub mod xdp;

View file

@ -0,0 +1,82 @@
use rampart_core::config::Config;
use rampart_core::filter::blacklist::Blacklist;
use rampart_core::filter::rate_limit::RateLimiter;
use rampart_core::metrics;
use rampart_core::proxy::listener::ProxyListener;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::watch;
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env().add_directive("rampart_core=info".parse()?))
.init();
let config_path = std::env::var("RAMPART_CONFIG").unwrap_or_else(|_| "/etc/rampart/config.toml".to_string());
let config = Config::from_file(&config_path)?;
let config = Arc::new(config);
let rate_limiter = Arc::new(RateLimiter::new(
config.limits.rate_limit_login_pps,
config.limits.rate_limit_burst,
));
let blacklist = Arc::new(Blacklist::new());
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let sig_tx = shutdown_tx.clone();
tokio::spawn(async move {
wait_for_signal().await;
tracing::info!("shutdown signal received, draining connections...");
let _ = sig_tx.send(true);
tokio::time::sleep(Duration::from_secs(5)).await;
tracing::info!("shutdown timeout reached, exiting");
std::process::exit(0);
});
#[cfg(feature = "store-redis")]
if let Some(redis_url) = &config.store.redis_url
&& !redis_url.is_empty()
{
let bl = blacklist.clone();
let sd = shutdown_rx.clone();
let url = redis_url.clone();
tokio::spawn(async move {
let client = match redis::Client::open(url.as_str()) {
Ok(c) => c,
Err(e) => {
tracing::warn!("Invalid redis_url: {e}, blacklist sync disabled");
return;
},
};
rampart_core::store::start_blacklist_sync(&client, bl, sd).await;
});
}
if config.metrics.enabled {
let metrics_addr = format!("0.0.0.0:{}", config.metrics.port);
tracing::info!("Metrics server listening on {metrics_addr}");
tokio::spawn(async move {
metrics::run_metrics_server(&metrics_addr).await;
});
}
tracing::info!("Rampart edge starting on {}:{}", config.bind.address, config.bind.port);
tracing::info!("Backend: {}:{}", config.backend.address, config.backend.port);
let listener = ProxyListener::new(config, rate_limiter, blacklist);
listener.run(shutdown_rx).await
}
async fn wait_for_signal() {
let ctrl_c = tokio::signal::ctrl_c();
let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install SIGTERM handler");
tokio::select! {
_ = ctrl_c => {}
_ = term.recv() => {}
}
}

View file

@ -0,0 +1,63 @@
use prometheus::{Encoder, IntCounterVec, IntGauge, register_int_counter_vec, register_int_gauge};
use std::sync::LazyLock;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
pub static CONNECTIONS_TOTAL: LazyLock<IntCounterVec> = LazyLock::new(|| {
register_int_counter_vec!("rampart_connections_total", "Total connections handled", &["result"])
.expect("CONNECTIONS_TOTAL")
});
pub static RATE_LIMIT_HITS: LazyLock<IntCounterVec> = LazyLock::new(|| {
register_int_counter_vec!("rampart_rate_limit_hits", "Rate limit hits", &["action"]).expect("RATE_LIMIT_HITS")
});
pub static ACTIVE_CONNECTIONS: LazyLock<IntGauge> = LazyLock::new(|| {
register_int_gauge!("rampart_active_connections", "Active connections").expect("ACTIVE_CONNECTIONS")
});
pub static BLACKLIST_SIZE: LazyLock<IntGauge> =
LazyLock::new(|| register_int_gauge!("rampart_blacklist_size", "Blacklist entries").expect("BLACKLIST_SIZE"));
pub static DEATH_CODE_BANS_TOTAL: LazyLock<IntCounterVec> = LazyLock::new(|| {
register_int_counter_vec!("rampart_death_code_bans_total", "Death code auto-bans", &["code"])
.expect("DEATH_CODE_BANS_TOTAL")
});
pub async fn run_metrics_server(addr: &str) {
let listener = match TcpListener::bind(addr).await {
Ok(l) => l,
Err(e) => {
tracing::error!("Failed to bind metrics server: {e}");
return;
},
};
loop {
let (mut stream, _) = match listener.accept().await {
Ok(s) => s,
Err(e) => {
tracing::error!("Metrics accept error: {e}");
continue;
},
};
tokio::spawn(async move {
let mut buf = [0u8; 1024];
if stream.read(&mut buf).await.is_err() {
return;
}
let metric_families = prometheus::gather();
let encoder = prometheus::TextEncoder::new();
let mut payload = Vec::new();
if encoder.encode(&metric_families, &mut payload).is_err() {
return;
}
let header = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/plain; version=0.0.4\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
payload.len()
);
let mut response = header.into_bytes();
response.extend_from_slice(&payload);
let _ = stream.write_all(&response).await;
});
}
}

View file

@ -0,0 +1,215 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ParseError {
#[error("Incomplete packet: {0}")]
Incomplete(&'static str),
#[error("VarInt too big (>5 bytes)")]
VarIntTooBig,
#[error("VarInt overflow")]
VarIntOverflow,
#[error("Invalid UTF-8 in string")]
InvalidUtf8,
#[error("String too long: {0}")]
StringTooLong(usize),
#[error("Hostname too long")]
HostnameTooLong,
#[error("Not a handshake packet: id={0}")]
NotHandshake(i32),
}
#[derive(Debug, Clone, PartialEq)]
pub enum NextState {
Status,
Login,
Unknown(i32),
}
#[derive(Debug, Clone)]
pub struct McHandshake {
pub protocol_version: i32,
pub server_address: String,
pub server_port: u16,
pub next_state: NextState,
}
impl McHandshake {
pub fn parse(buf: &[u8]) -> Result<Self, ParseError> {
let mut pos;
let (_, after_len) = read_varint(buf, 0)?;
pos = after_len;
let (packet_id, after_id) = read_varint(buf, pos)?;
pos = after_id;
if packet_id != 0x00 {
return Err(ParseError::NotHandshake(packet_id));
}
let (protocol_version, after_pv) = read_varint(buf, pos)?;
pos = after_pv;
let (server_address, after_addr) = read_string(buf, pos)?;
pos = after_addr;
if server_address.len() > 255 {
return Err(ParseError::HostnameTooLong);
}
if pos + 2 > buf.len() {
return Err(ParseError::Incomplete("missing port"));
}
let server_port = u16::from_be_bytes([buf[pos], buf[pos + 1]]);
pos += 2;
let (next_state_raw, _) = read_varint(buf, pos)?;
let next_state = match next_state_raw {
1 => NextState::Status,
2 => NextState::Login,
n => NextState::Unknown(n),
};
Ok(McHandshake {
protocol_version,
server_address,
server_port,
next_state,
})
}
pub fn is_login(&self) -> bool {
self.next_state == NextState::Login
}
}
pub fn read_varint(buf: &[u8], start: usize) -> Result<(i32, usize), ParseError> {
let mut value: i32 = 0;
let mut shift = 0;
for (i, &byte) in buf[start..].iter().enumerate() {
if i >= 5 {
return Err(ParseError::VarIntTooBig);
}
let segment = (byte & 0x7F) as i32;
if shift >= 32 || (shift == 28 && segment > 0x0F) {
return Err(ParseError::VarIntOverflow);
}
value |= segment << shift;
shift += 7;
if (byte & 0x80) == 0 {
return Ok((value, start + i + 1));
}
}
Err(ParseError::Incomplete("varint"))
}
pub fn read_string(buf: &[u8], start: usize) -> Result<(String, usize), ParseError> {
let (len, after_len) = read_varint(buf, start)?;
if !(0..=32767).contains(&len) {
return Err(ParseError::StringTooLong(len as usize));
}
let end = after_len + len as usize;
if end > buf.len() {
return Err(ParseError::Incomplete("string data"));
}
let s = std::str::from_utf8(&buf[after_len..end])
.map_err(|_| ParseError::InvalidUtf8)?
.to_string();
Ok((s, end))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_varint_zero() {
let buf = vec![0x00];
assert_eq!(read_varint(&buf, 0).unwrap(), (0, 1));
}
#[test]
fn test_varint_single() {
let buf = vec![0x7F];
assert_eq!(read_varint(&buf, 0).unwrap(), (127, 1));
}
#[test]
fn test_varint_multi() {
let buf = vec![0x80, 0x01];
assert_eq!(read_varint(&buf, 0).unwrap(), (128, 2));
}
#[test]
fn test_varint_max() {
let buf = vec![0xFF, 0xFF, 0xFF, 0xFF, 0x07];
assert_eq!(read_varint(&buf, 0).unwrap(), (i32::MAX, 5));
}
#[test]
fn test_varint_overflow() {
let buf = vec![0xFF, 0xFF, 0xFF, 0xFF, 0x10];
assert!(matches!(read_varint(&buf, 0), Err(ParseError::VarIntOverflow)));
}
#[test]
fn test_varint_incomplete() {
let buf = vec![0x80];
assert!(matches!(read_varint(&buf, 0), Err(ParseError::Incomplete(_))));
}
#[test]
fn test_handshake_login() {
let addr = b"play.example.com";
let mut buf = Vec::new();
buf.extend_from_slice(&[0x00]);
buf.push(0x00);
write_varint(&mut buf, 765);
write_varint(&mut buf, addr.len() as i32);
buf.extend_from_slice(addr);
buf.extend_from_slice(&[0x63, 0xDD]);
buf.push(0x02);
let len = (buf.len() - 1) as u8;
buf[0] = len;
let hs = McHandshake::parse(&buf).unwrap();
assert_eq!(hs.protocol_version, 765);
assert_eq!(hs.server_address, "play.example.com");
assert_eq!(hs.server_port, 25565);
assert!(hs.is_login());
}
fn write_varint(buf: &mut Vec<u8>, mut value: i32) {
loop {
if (value & !0x7F) == 0 {
buf.push(value as u8);
return;
}
buf.push((value as u8 & 0x7F) | 0x80);
value = (value >> 7) & (i32::MAX >> 6);
}
}
#[test]
fn test_handshake_status() {
let addr = b"play.example";
let mut buf = Vec::new();
// packet length will be set below
buf.push(0x00);
buf.push(0x00); // packet ID
buf.push(0x02); // protocol version 2
write_varint(&mut buf, addr.len() as i32);
buf.extend_from_slice(addr);
buf.extend_from_slice(&[0x63, 0xDD]); // port 25565
buf.push(0x01); // next state = status
let len = (buf.len() - 1) as u8;
buf[0] = len;
let hs = McHandshake::parse(&buf).unwrap();
assert_eq!(hs.protocol_version, 2);
assert_eq!(hs.server_address, "play.example");
assert_eq!(hs.server_port, 25565);
assert_eq!(hs.next_state, NextState::Status);
}
}

View file

@ -0,0 +1,95 @@
use crate::config::Config;
use crate::filter::blacklist::Blacklist;
use crate::filter::rate_limit::RateLimiter;
use crate::proxy::tunnel::ConnectionHandler;
use socket2::{Domain, Socket, Type};
use std::sync::Arc;
use tokio::net::TcpListener;
use tokio::sync::watch;
pub struct ProxyListener {
config: Arc<Config>,
rate_limiter: Arc<RateLimiter>,
blacklist: Arc<Blacklist>,
}
impl ProxyListener {
pub fn new(config: Arc<Config>, rate_limiter: Arc<RateLimiter>, blacklist: Arc<Blacklist>) -> Self {
Self {
config,
rate_limiter,
blacklist,
}
}
pub async fn run(&self, shutdown: watch::Receiver<bool>) -> anyhow::Result<()> {
let addr = format!("{}:{}", self.config.bind.address, self.config.bind.port).parse::<std::net::SocketAddr>()?;
let workers = self.config.workers.count.max(1);
let mut handles = Vec::with_capacity(workers);
for _ in 0..workers {
let listener = build_listener(addr)?;
let config = self.config.clone();
let rate_limiter = self.rate_limiter.clone();
let blacklist = self.blacklist.clone();
let shutdown = shutdown.clone();
handles.push(tokio::spawn(accept_loop(
listener,
config,
rate_limiter,
blacklist,
shutdown,
)));
}
for h in handles {
h.await??;
}
Ok(())
}
}
fn build_listener(addr: std::net::SocketAddr) -> anyhow::Result<TcpListener> {
let socket = Socket::new(Domain::IPV4, Type::STREAM, None)?;
socket.set_reuse_port(true)?;
socket.set_reuse_address(true)?;
socket.set_nonblocking(true)?;
socket.bind(&addr.into())?;
socket.listen(65535)?;
Ok(TcpListener::from_std(socket.into())?)
}
async fn accept_loop(
listener: TcpListener,
config: Arc<Config>,
rate_limiter: Arc<RateLimiter>,
blacklist: Arc<Blacklist>,
mut shutdown: watch::Receiver<bool>,
) -> anyhow::Result<()> {
loop {
tokio::select! {
biased;
_ = shutdown.changed() => {
if *shutdown.borrow() {
tracing::info!("shutdown signal received, stopping accept loop");
return Ok(());
}
}
result = listener.accept() => {
let (stream, peer_addr) = match result {
Ok(conn) => conn,
Err(e) => {
tracing::error!("accept error: {e}");
continue;
}
};
let handler = ConnectionHandler::new(config.clone(), rate_limiter.clone(), blacklist.clone());
tokio::spawn(async move {
if let Err(e) = handler.handle(stream, peer_addr).await {
tracing::debug!("connection from {peer_addr}: {e}");
}
});
}
}
}
}

View file

@ -0,0 +1,3 @@
pub mod handshake;
pub mod listener;
pub mod tunnel;

View file

@ -0,0 +1,204 @@
use crate::config::Config;
use crate::crypto::hmac;
use crate::filter::blacklist::Blacklist;
use crate::filter::death_code;
use crate::filter::rate_limit::RateLimiter;
use crate::metrics;
use crate::proxy::handshake::{McHandshake, read_varint};
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
pub struct ConnectionHandler {
config: Arc<Config>,
rate_limiter: Arc<RateLimiter>,
blacklist: Arc<Blacklist>,
}
impl ConnectionHandler {
pub fn new(config: Arc<Config>, rate_limiter: Arc<RateLimiter>, blacklist: Arc<Blacklist>) -> Self {
Self {
config,
rate_limiter,
blacklist,
}
}
fn ip_to_u32(addr: std::net::SocketAddr) -> u32 {
match addr.ip() {
std::net::IpAddr::V4(ip) => ip.to_bits(),
_ => 0,
}
}
pub async fn handle(&self, mut client: TcpStream, peer_addr: std::net::SocketAddr) -> anyhow::Result<()> {
let ip_u32 = Self::ip_to_u32(peer_addr);
if self.blacklist.is_blocked(ip_u32) {
metrics::CONNECTIONS_TOTAL.with_label_values(&["blocked"]).inc();
return Ok(());
}
if !self.rate_limiter.check(ip_u32) {
metrics::RATE_LIMIT_HITS.with_label_values(&["hit"]).inc();
metrics::CONNECTIONS_TOTAL.with_label_values(&["blocked"]).inc();
return Ok(());
}
let timeout = Duration::from_secs(self.config.limits.handshake_timeout_secs);
let mut buf = vec![0u8; 4096];
let n = tokio::time::timeout(timeout, client.read(&mut buf)).await??;
if n == 0 {
return Ok(());
}
let parsed = McHandshake::parse(&buf[..n]);
match parsed {
Ok(handshake) => {
if !self.rate_limiter.check(ip_u32) {
metrics::RATE_LIMIT_HITS.with_label_values(&["hit"]).inc();
metrics::CONNECTIONS_TOTAL.with_label_values(&["blocked"]).inc();
return Ok(());
}
metrics::CONNECTIONS_TOTAL.with_label_values(&["allowed"]).inc();
let backend_addr = format!("{}:{}", self.config.backend.address, self.config.backend.port);
let mut backend = TcpStream::connect(&backend_addr).await?;
let signed = hmac::sign_hostname(&handshake.server_address, self.config.hmac.secret.as_bytes());
let modified = replace_hostname(&buf[..n], &handshake.server_address, &signed)?;
backend.write_all(&modified).await?;
tokio::io::copy_bidirectional(&mut client, &mut backend).await?;
},
Err(e) => {
tracing::debug!("parse error from {peer_addr}: {e}");
metrics::CONNECTIONS_TOTAL.with_label_values(&["blocked"]).inc();
if self.config.death_code.enabled {
if let Some(code) = death_code::detect(&buf[..n]) {
let duration = Duration::from_secs(self.config.death_code.ban_duration_secs);
self.blacklist.add(ip_u32, duration, code.as_str());
metrics::DEATH_CODE_BANS_TOTAL.with_label_values(&[code.as_str()]).inc();
tracing::info!("death code ban {peer_addr}: {}", code.as_str());
}
}
},
}
Ok(())
}
}
fn replace_hostname(original: &[u8], _old_hostname: &str, new_hostname: &str) -> anyhow::Result<Vec<u8>> {
let (packet_len, after_packet_len) =
read_varint(original, 0).map_err(|_| anyhow::anyhow!("corrupt packet length"))?;
let mut pos = after_packet_len;
let (_packet_id, after_id) = read_varint(original, pos).map_err(|_| anyhow::anyhow!("corrupt packet id"))?;
pos = after_id;
let (_protocol_version, after_pv) =
read_varint(original, pos).map_err(|_| anyhow::anyhow!("corrupt protocol version"))?;
pos = after_pv;
let (old_host_len, host_field_start) =
read_varint(original, pos).map_err(|_| anyhow::anyhow!("corrupt hostname length"))?;
let host_data_end = host_field_start + old_host_len as usize;
let old_field_size = host_data_end - pos;
let new_hostname_bytes = new_hostname.as_bytes();
let new_len_field_bytes = varint_bytes(new_hostname_bytes.len() as i32);
let new_field_size = new_len_field_bytes.len() + new_hostname_bytes.len();
let size_diff = new_field_size as isize - old_field_size as isize;
let new_packet_len = (packet_len as isize + size_diff) as i32;
let cap = original.len().wrapping_add(size_diff as usize);
let mut result = Vec::with_capacity(cap);
result.extend_from_slice(&varint_bytes(new_packet_len));
result.extend_from_slice(&original[after_packet_len..pos]);
result.extend_from_slice(&new_len_field_bytes);
result.extend_from_slice(new_hostname_bytes);
result.extend_from_slice(&original[host_data_end..]);
Ok(result)
}
fn varint_bytes(mut value: i32) -> Vec<u8> {
let mut result = Vec::with_capacity(5);
loop {
if (value & !0x7F) == 0 {
result.push(value as u8);
return result;
}
result.push((value as u8 & 0x7F) | 0x80);
value >>= 7;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn build_test_packet(hostname: &str) -> Vec<u8> {
let addr = hostname.as_bytes();
let mut buf = Vec::new();
buf.push(0x00);
buf.extend_from_slice(&varint_bytes(765));
buf.extend_from_slice(&varint_bytes(addr.len() as i32));
buf.extend_from_slice(addr);
buf.extend_from_slice(&[0x63, 0xDD]);
buf.push(0x02);
let len = buf.len() as i32;
let mut pkt = varint_bytes(len);
pkt.extend_from_slice(&buf);
pkt
}
#[test]
fn test_replace_hostname_basic() {
let pkt = build_test_packet("play.example.com");
let new_hostname = "play.example.com\0shield\0abcdef1234567890";
let modified = replace_hostname(&pkt, "play.example.com", new_hostname).unwrap();
assert!(modified.len() > pkt.len());
let parsed = McHandshake::parse(&modified).unwrap();
assert_eq!(parsed.server_address, new_hostname);
}
#[test]
fn test_replace_hostname_shorter() {
let pkt = build_test_packet("very.long.hostname.example.com");
let new_hostname = "short.com";
let modified = replace_hostname(&pkt, "very.long.hostname.example.com", new_hostname).unwrap();
assert!(modified.len() < pkt.len());
let parsed = McHandshake::parse(&modified).unwrap();
assert_eq!(parsed.server_address, new_hostname);
}
#[test]
fn test_replace_hostname_preserves_port_and_protocol() {
let pkt = build_test_packet("mc.example.com");
let new_hostname = "mc.example.com\0shield\x00deadbeef";
let modified = replace_hostname(&pkt, "mc.example.com", new_hostname).unwrap();
let parsed = McHandshake::parse(&modified).unwrap();
assert_eq!(parsed.server_port, 25565);
assert_eq!(parsed.protocol_version, 765);
assert!(parsed.is_login());
}
#[test]
fn test_varint_roundtrip() {
let cases = vec![0, 1, 127, 128, 255, 65535, 1000000, i32::MAX];
for val in cases {
let bytes = varint_bytes(val);
let (decoded, _) = read_varint(&bytes, 0).unwrap();
assert_eq!(decoded, val, "roundtrip failed for {val}");
}
}
}

View file

@ -0,0 +1,29 @@
#[cfg(feature = "store-redis")]
pub mod redis;
#[cfg(feature = "store-redis")]
pub use redis::start_blacklist_sync;
#[allow(async_fn_in_trait)]
pub trait StateStore: Send + Sync {
async fn get(&self, key: &str) -> anyhow::Result<Option<String>>;
async fn set(&self, key: &str, value: &str) -> anyhow::Result<()>;
async fn del(&self, key: &str) -> anyhow::Result<()>;
async fn publish(&self, channel: &str, message: &str) -> anyhow::Result<()>;
}
pub struct NoopStore;
impl StateStore for NoopStore {
async fn get(&self, _key: &str) -> anyhow::Result<Option<String>> {
Ok(None)
}
async fn set(&self, _key: &str, _value: &str) -> anyhow::Result<()> {
Ok(())
}
async fn del(&self, _key: &str) -> anyhow::Result<()> {
Ok(())
}
async fn publish(&self, _channel: &str, _message: &str) -> anyhow::Result<()> {
Ok(())
}
}

View file

@ -0,0 +1,136 @@
use crate::filter::blacklist::Blacklist;
use crate::store::StateStore;
use futures::StreamExt;
use redis::AsyncCommands;
use redis::Msg;
use serde::Deserialize;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::watch;
pub struct RedisStore {
client: redis::Client,
}
#[derive(Deserialize)]
struct BlacklistEvent {
ip: String,
action: String,
#[serde(default = "default_duration")]
duration_secs: u64,
}
fn default_duration() -> u64 {
300
}
impl RedisStore {
pub fn new(url: &str) -> anyhow::Result<Self> {
let client = redis::Client::open(url)?;
Ok(Self { client })
}
}
pub async fn start_blacklist_sync(
client: &redis::Client,
blacklist: Arc<Blacklist>,
mut shutdown: watch::Receiver<bool>,
) {
#[allow(deprecated)]
let conn = match client.get_async_connection().await {
Ok(c) => c,
Err(e) => {
tracing::error!("failed to connect to Redis for blacklist sync: {e}");
return;
},
};
let mut pubsub = conn.into_pubsub();
if let Err(e) = pubsub.subscribe("rampart:blacklist:events").await {
tracing::error!("failed to subscribe to blacklist events: {e}");
return;
}
tracing::info!("subscribed to rampart:blacklist:events");
loop {
let mut stream = pubsub.on_message();
let msg_fut = stream.next();
tokio::pin!(msg_fut);
tokio::select! {
_ = shutdown.changed() => {
if *shutdown.borrow() {
tracing::info!("shutting down blacklist subscriber");
return;
}
}
result = &mut msg_fut => {
match result {
Some(msg) => {
if let Err(e) = handle_event(&msg, &blacklist) {
tracing::error!("blacklist event error: {e}");
}
}
None => {
tracing::error!("pubsub stream ended");
tokio::time::sleep(Duration::from_secs(1)).await;
return;
}
}
}
}
}
}
fn handle_event(msg: &Msg, blacklist: &Blacklist) -> anyhow::Result<()> {
let payload: String = msg.get_payload()?;
let event: BlacklistEvent = serde_json::from_str(&payload)?;
let ip_parts: Vec<&str> = event.ip.split('.').collect();
if ip_parts.len() != 4 {
anyhow::bail!("invalid IP: {}", event.ip);
}
let mut ip_u32: u32 = 0;
for part in &ip_parts {
let octet: u32 = part.parse()?;
ip_u32 = (ip_u32 << 8) | octet;
}
match event.action.as_str() {
"ban" => {
blacklist.add(ip_u32, Duration::from_secs(event.duration_secs), "redis");
tracing::info!("blacklist add via Redis: {}", event.ip);
},
"unban" => {
blacklist.remove(ip_u32);
tracing::info!("blacklist remove via Redis: {}", event.ip);
},
a => anyhow::bail!("unknown action: {a}"),
}
Ok(())
}
impl StateStore for RedisStore {
async fn get(&self, key: &str) -> anyhow::Result<Option<String>> {
let mut conn = self.client.get_multiplexed_async_connection().await?;
Ok(conn.get(key).await?)
}
async fn set(&self, key: &str, value: &str) -> anyhow::Result<()> {
let mut conn = self.client.get_multiplexed_async_connection().await?;
let _: () = conn.set(key, value).await?;
Ok(())
}
async fn del(&self, key: &str) -> anyhow::Result<()> {
let mut conn = self.client.get_multiplexed_async_connection().await?;
let _: () = conn.del(key).await?;
Ok(())
}
async fn publish(&self, channel: &str, message: &str) -> anyhow::Result<()> {
let mut conn = self.client.get_multiplexed_async_connection().await?;
let _: () = conn.publish(channel, message).await?;
Ok(())
}
}

View file

@ -0,0 +1,41 @@
#[cfg(feature = "xdp")]
pub struct XdpFilter {
interface: String,
}
#[cfg(feature = "xdp")]
impl XdpFilter {
pub fn new(interface: &str) -> Self {
Self {
interface: interface.to_string(),
}
}
pub fn load(&self) -> anyhow::Result<()> {
tracing::info!("XDP filter loaded on {}", self.interface);
Ok(())
}
pub fn unload(&self) -> anyhow::Result<()> {
tracing::info!("XDP filter unloaded from {}", self.interface);
Ok(())
}
}
#[cfg(not(feature = "xdp"))]
pub struct XdpFilter;
#[cfg(not(feature = "xdp"))]
impl XdpFilter {
pub fn new(_interface: &str) -> Self {
Self
}
pub fn load(&self) -> anyhow::Result<()> {
Ok(())
}
pub fn unload(&self) -> anyhow::Result<()> {
Ok(())
}
}

View file

@ -0,0 +1,24 @@
[package]
name = "rampart-manager"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
tokio.workspace = true
serde.workspace = true
serde_json.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
thiserror.workspace = true
anyhow.workspace = true
dashmap.workspace = true
prometheus.workspace = true
axum = "0.8"
tower-http = { version = "0.6", features = ["cors"] }
jsonwebtoken = "9"
redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] }
chrono = { version = "0.4", features = ["serde"] }

View file

@ -0,0 +1,21 @@
use crate::AppState;
use axum::{Json, extract::State};
use serde::Deserialize;
use std::sync::Arc;
#[derive(Deserialize)]
pub struct LoginRequest {
pub password: String,
}
pub async fn login(State(state): State<Arc<AppState>>, Json(req): Json<LoginRequest>) -> Json<serde_json::Value> {
let api_password = std::env::var("API_PASSWORD").unwrap_or_else(|_| "changeme".to_string());
if req.password != api_password {
return Json(serde_json::json!({"error": "invalid password"}));
}
match crate::auth::create_token(&state.jwt_secret, state.jwt_expiration) {
Ok(token) => Json(serde_json::json!({"token": token})),
Err(_) => Json(serde_json::json!({"error": "token creation failed"})),
}
}

View file

@ -0,0 +1,94 @@
use crate::AppState;
use axum::{Json, extract::State};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
#[derive(Debug, Serialize, Deserialize)]
pub struct BlacklistEntry {
pub target: String,
#[serde(rename = "type")]
pub entry_type: String,
pub reason: String,
pub created_at: String,
pub expires_at: Option<String>,
}
#[derive(Serialize)]
pub struct BlacklistResponse {
pub items: Vec<BlacklistEntry>,
pub total: usize,
}
#[derive(Deserialize)]
#[allow(dead_code)]
pub struct AddBlacklistRequest {
pub target: String,
#[serde(rename = "type")]
pub entry_type: String,
pub reason: String,
pub duration_secs: Option<u64>,
}
pub async fn list_blacklist(State(state): State<Arc<AppState>>) -> Json<BlacklistResponse> {
let mut conn = match state.redis_client.get_multiplexed_async_connection().await {
Ok(c) => c,
Err(_) => {
return Json(BlacklistResponse {
items: vec![],
total: 0,
});
},
};
let members: Vec<String> = match redis::cmd("SMEMBERS")
.arg("rampart:blacklist")
.query_async(&mut conn)
.await
{
Ok(m) => m,
Err(_) => {
return Json(BlacklistResponse {
items: vec![],
total: 0,
});
},
};
let items: Vec<BlacklistEntry> = members
.into_iter()
.map(|target| BlacklistEntry {
target,
entry_type: "ip".to_string(),
reason: "manual".to_string(),
created_at: chrono::Utc::now().to_rfc3339(),
expires_at: None,
})
.collect();
let total = items.len();
Json(BlacklistResponse { items, total })
}
pub async fn add_blacklist(
State(state): State<Arc<AppState>>,
Json(req): Json<AddBlacklistRequest>,
) -> Json<serde_json::Value> {
let mut conn = match state.redis_client.get_multiplexed_async_connection().await {
Ok(c) => c,
Err(_) => return Json(serde_json::json!({"error": "redis unavailable"})),
};
let _: () = redis::cmd("SADD")
.arg("rampart:blacklist")
.arg(&req.target)
.query_async(&mut conn)
.await
.unwrap_or_default();
tracing::info!("Added to blacklist: {} ({})", req.target, req.reason);
Json(serde_json::json!({
"status": "added",
"target": req.target,
"reason": req.reason
}))
}

View file

@ -0,0 +1,18 @@
use crate::AppState;
use axum::Json;
use axum::extract::State;
use serde::Serialize;
use std::sync::Arc;
#[derive(Serialize)]
pub struct HealthResponse {
pub status: String,
pub version: String,
}
pub async fn health_check(State(_state): State<Arc<AppState>>) -> Json<HealthResponse> {
Json(HealthResponse {
status: "healthy".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
})
}

View file

@ -0,0 +1,5 @@
pub mod auth;
pub mod blacklist;
pub mod health;
pub mod nodes;
pub mod servers;

View file

@ -0,0 +1,43 @@
use crate::AppState;
use axum::{Json, extract::State};
use redis::AsyncCommands;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
#[derive(Debug, Serialize, Deserialize)]
pub struct NodeInfo {
pub id: String,
pub role: String,
pub ip: String,
pub status: String,
pub last_heartbeat: String,
}
#[derive(Serialize)]
pub struct NodesResponse {
pub nodes: Vec<NodeInfo>,
}
pub async fn list_nodes(State(state): State<Arc<AppState>>) -> Json<NodesResponse> {
let mut conn = match state.redis_client.get_multiplexed_async_connection().await {
Ok(c) => c,
Err(_) => return Json(NodesResponse { nodes: vec![] }),
};
let keys: Vec<String> = match redis::cmd("KEYS").arg("rampart:nodes:*").query_async(&mut conn).await {
Ok(k) => k,
Err(_) => return Json(NodesResponse { nodes: vec![] }),
};
let mut nodes = Vec::with_capacity(keys.len());
for key in &keys {
let raw: Option<String> = conn.get(key).await.unwrap_or(None);
if let Some(json) = raw
&& let Ok(node) = serde_json::from_str::<NodeInfo>(&json)
{
nodes.push(node);
}
}
Json(NodesResponse { nodes })
}

View file

@ -0,0 +1,47 @@
use crate::AppState;
use axum::{Json, extract::State};
use redis::AsyncCommands;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
#[derive(Debug, Serialize, Deserialize)]
pub struct ServerEntry {
pub name: String,
#[serde(rename = "type")]
pub server_type: String,
pub ip: String,
pub port: u16,
pub status: String,
pub online: u32,
pub max_players: u32,
pub tps: f64,
}
#[derive(Serialize)]
pub struct ServersResponse {
pub servers: Vec<ServerEntry>,
}
pub async fn list_servers(State(state): State<Arc<AppState>>) -> Json<ServersResponse> {
let mut conn = match state.redis_client.get_multiplexed_async_connection().await {
Ok(c) => c,
Err(_) => return Json(ServersResponse { servers: vec![] }),
};
let keys: Vec<String> = match redis::cmd("KEYS").arg("rampart:servers:*").query_async(&mut conn).await {
Ok(k) => k,
Err(_) => return Json(ServersResponse { servers: vec![] }),
};
let mut servers = Vec::with_capacity(keys.len());
for key in &keys {
let raw: Option<String> = conn.get(key).await.unwrap_or(None);
if let Some(json) = raw
&& let Ok(server) = serde_json::from_str::<ServerEntry>(&json)
{
servers.push(server);
}
}
Json(ServersResponse { servers })
}

View file

@ -0,0 +1,74 @@
use axum::Json;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use axum::middleware::Next;
use axum::response::Response;
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: String,
pub exp: usize,
pub iat: usize,
}
pub fn create_token(secret: &str, expiration: u64) -> Result<String, jsonwebtoken::errors::Error> {
let now = chrono::Utc::now().timestamp() as usize;
let claims = Claims {
sub: "rampart-admin".to_string(),
exp: now + expiration as usize,
iat: now,
};
encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_ref()))
}
pub fn verify_token(token: &str, secret: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
let token_data = decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_ref()),
&Validation::default(),
)?;
Ok(token_data.claims)
}
pub async fn auth_middleware(
request: Request<Body>,
next: Next,
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
let auth_header = request
.headers()
.get(axum::http::header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "));
let token = match auth_header {
Some(t) => t,
None => {
return Err((
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({"error": "unauthorized"})),
));
},
};
let state = match request.extensions().get::<Arc<crate::AppState>>() {
Some(s) => s,
None => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "internal error"})),
));
},
};
if verify_token(token, &state.jwt_secret).is_err() {
return Err((
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({"error": "unauthorized"})),
));
}
Ok(next.run(request).await)
}

View file

@ -0,0 +1 @@

View file

@ -0,0 +1,66 @@
use axum::{
Router, middleware,
routing::{get, post},
};
use std::sync::Arc;
use tower_http::cors::CorsLayer;
use tracing_subscriber::EnvFilter;
mod api;
mod auth;
mod sync;
pub struct AppState {
pub redis_client: redis::Client,
pub jwt_secret: String,
pub jwt_expiration: u64,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env().add_directive("rampart_manager=info".parse()?))
.init();
let redis_url = std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379/0".to_string());
let redis_client = redis::Client::open(redis_url)?;
let jwt_secret = std::env::var("JWT_SECRET").map_err(|_| anyhow::anyhow!("JWT_SECRET must be set"))?;
let jwt_expiration = std::env::var("JWT_EXPIRATION_SECS")
.unwrap_or_else(|_| "86400".to_string())
.parse::<u64>()
.map_err(|_| anyhow::anyhow!("JWT_EXPIRATION_SECS must be a valid u64"))?;
let state = Arc::new(AppState {
redis_client,
jwt_secret,
jwt_expiration,
});
tokio::spawn(sync::heartbeat::start_heartbeat_check(state.clone()));
let public = Router::new()
.route("/api/v1/health", get(api::health::health_check))
.route("/api/v1/auth/login", post(api::auth::login));
let protected = Router::new()
.route("/api/v1/servers", get(api::servers::list_servers))
.route(
"/api/v1/blacklist",
get(api::blacklist::list_blacklist).post(api::blacklist::add_blacklist),
)
.route("/api/v1/nodes", get(api::nodes::list_nodes))
.route_layer(middleware::from_fn(auth::auth_middleware));
let app = Router::new()
.merge(public)
.merge(protected)
.layer(CorsLayer::permissive())
.with_state(state);
let addr = "0.0.0.0:8080";
tracing::info!("Manager API listening on {addr}");
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Ok(())
}

View file

@ -0,0 +1,44 @@
use crate::AppState;
use redis::AsyncCommands;
use std::sync::Arc;
use tokio::time::{Duration, interval};
pub async fn start_heartbeat_check(state: Arc<AppState>) {
let mut ticker = interval(Duration::from_secs(30));
loop {
ticker.tick().await;
if let Err(e) = check_nodes(&state).await {
tracing::warn!("heartbeat check failed: {e}");
}
}
}
async fn check_nodes(state: &AppState) -> anyhow::Result<()> {
let mut conn = state.redis_client.get_multiplexed_async_connection().await?;
let keys: Vec<String> = redis::cmd("KEYS").arg("rampart:nodes:*").query_async(&mut conn).await?;
let now = chrono::Utc::now().timestamp();
for key in &keys {
let raw: Option<String> = conn.get(key).await?;
if let Some(json) = raw
&& let Ok(mut node) = serde_json::from_str::<serde_json::Value>(&json)
{
let hb = node["last_heartbeat"]
.as_str()
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|t| t.timestamp())
.unwrap_or(0);
if now - hb > 60 {
if let Some(obj) = node.as_object_mut() {
obj.insert("status".to_string(), serde_json::Value::String("offline".to_string()));
if let Ok(updated) = serde_json::to_string(&node) {
let _: () = conn.set(key.as_str(), updated).await.unwrap_or_default();
}
}
tracing::warn!("Node {key} is offline (heartbeat expired)");
}
}
}
Ok(())
}

View file

@ -0,0 +1 @@
pub mod heartbeat;