feat: HTTP protocol plugin + XDP globals patching from config

protocol-http (compile-time feature):
- HttpHandler: incremental HTTP/1.1 request parse (fragment-safe),
  header size/timeout limits, method whitelist, Host required,
  blocked_paths, optional User-Agent requirement
- [protocol.http] config section; registry wiring behind feature

XDP globals:
- src/xdp/globals.rs: XdpGlobals + build_rodata_image() mirroring
  xdp/core/config.h layout; set via OpenMapMut::set_initial_value()
  before load ([xdp] section: ports, udp policy, throttle, challenge)
- wire set_globals into rampart binary startup

fix: gate preflight() behind xdp feature (dead code without it)
This commit is contained in:
loki5512344 2026-08-24 12:18:22 +02:00
parent 8b35ac693c
commit 6863249ad4
Signed by: boba
GPG key ID: 253067914055423B
14 changed files with 972 additions and 22 deletions

View file

@ -89,3 +89,54 @@ webhook_url = "https://hooks.example.test/rampart"
Some("https://hooks.example.test/rampart")
);
}
#[test]
fn xdp_section_defaults() {
let config = Config::parse_str("").expect("empty config must parse");
let xdp = &config.xdp;
assert_eq!(xdp.protected_port_start, 1);
assert_eq!(xdp.protected_port_end, 65535);
assert_eq!(xdp.udp_policy, "pass");
assert_eq!(xdp.udp_rate_hit_count, 100);
assert_eq!(xdp.udp_rate_window_ms, 1000);
assert!(!xdp.syn_challenge_enabled);
assert!(xdp.challenge_secret_hex.is_none());
assert_eq!(xdp.challenge_timeout_ms, 3000);
assert!(xdp.throttle_enabled);
assert!(xdp.events_enabled);
}
#[test]
fn parses_xdp_section() {
let config = Config::parse_str(
r#"
[xdp]
enabled = true
interface = "ens3"
protected_port_start = 25560
protected_port_end = 25600
udp_policy = "drop"
udp_rate_hit_count = 250
udp_rate_window_ms = 500
syn_challenge_enabled = true
challenge_secret_hex = "dead_beef_dead_beef"
challenge_timeout_ms = 1500
throttle_enabled = false
events_enabled = false
"#,
)
.expect("[xdp] section must parse");
let xdp = &config.xdp;
assert!(xdp.enabled);
assert_eq!(xdp.interface, "ens3");
assert_eq!(xdp.protected_port_start, 25560);
assert_eq!(xdp.protected_port_end, 25600);
assert_eq!(xdp.udp_policy, "drop");
assert_eq!(xdp.udp_rate_hit_count, 250);
assert_eq!(xdp.udp_rate_window_ms, 500);
assert!(xdp.syn_challenge_enabled);
assert_eq!(xdp.challenge_secret_hex.as_deref(), Some("dead_beef_dead_beef"));
assert_eq!(xdp.challenge_timeout_ms, 1500);
assert!(!xdp.throttle_enabled);
assert!(!xdp.events_enabled);
}

131
tests/http_protocol.rs Normal file
View file

@ -0,0 +1,131 @@
#![cfg(feature = "protocol-http")]
use rampart::config::HttpProtocolConfig;
use rampart::protocol::http::HttpProtocolHandler;
use rampart::protocol::{ProtocolHandler, ProtocolRegistry};
use tokio::io::AsyncWriteExt;
use tokio::net::{TcpListener, TcpStream};
const REQUEST: &[u8] = b"GET /index.html HTTP/1.1\r\nHost: example.com\r\n\r\n";
fn handler() -> HttpProtocolHandler {
HttpProtocolHandler::new(&HttpProtocolConfig::default(), &["127.0.0.1:9000".to_string()])
}
async fn socket_pair() -> (TcpStream, TcpStream) {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("local addr");
let client = TcpStream::connect(addr).await.expect("connect");
let (server, _) = listener.accept().await.expect("accept");
(client, server)
}
#[tokio::test]
async fn valid_request_is_allowed() {
let (mut client, mut server) = socket_pair().await;
client.write_all(REQUEST).await.expect("write");
assert!(handler().handshake(&mut server).await.is_ok());
}
#[tokio::test]
async fn fragmented_head_is_accumulated_and_parsed() {
let (mut client, mut server) = socket_pair().await;
for chunk in REQUEST.chunks(7) {
client.write_all(chunk).await.expect("write chunk");
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
assert!(handler().handshake(&mut server).await.is_ok());
}
#[tokio::test]
async fn oversized_head_is_rejected() {
let config = HttpProtocolConfig {
max_header_bytes: 32,
..HttpProtocolConfig::default()
};
let (mut client, mut server) = socket_pair().await;
client
.write_all(b"GET / HTTP/1.1\r\nHost: a-very-long-host-name-value\r\n")
.await
.expect("write");
let err = handler_with(&config)
.handshake(&mut server)
.await
.expect_err("oversized head must be rejected");
assert!(err.to_string().contains("exceeds"));
}
#[tokio::test]
async fn unknown_method_is_rejected() {
let (mut client, mut server) = socket_pair().await;
client
.write_all(b"TRACE / HTTP/1.1\r\nHost: h\r\n\r\n")
.await
.expect("write");
let err = handler()
.handshake(&mut server)
.await
.expect_err("unknown method must be rejected");
assert!(err.to_string().contains("method"));
}
#[tokio::test]
async fn missing_host_is_rejected() {
let (mut client, mut server) = socket_pair().await;
client.write_all(b"GET / HTTP/1.1\r\n\r\n").await.expect("write");
let err = handler()
.handshake(&mut server)
.await
.expect_err("missing host must be rejected");
assert!(err.to_string().contains("host"));
}
#[tokio::test]
async fn blocked_path_is_rejected() {
let config = HttpProtocolConfig {
blocked_paths: vec!["/admin".to_string()],
..HttpProtocolConfig::default()
};
let (mut client, mut server) = socket_pair().await;
client
.write_all(b"GET /admin/secrets HTTP/1.1\r\nHost: h\r\n\r\n")
.await
.expect("write");
let err = handler_with(&config)
.handshake(&mut server)
.await
.expect_err("blocked path must be rejected");
assert!(err.to_string().contains("blocklisted"));
}
#[tokio::test]
async fn required_user_agent_missing_is_rejected() {
let config = HttpProtocolConfig {
require_user_agent: true,
..HttpProtocolConfig::default()
};
let (mut client, mut server) = socket_pair().await;
client
.write_all(b"GET / HTTP/1.1\r\nHost: h\r\n\r\n")
.await
.expect("write");
let err = handler_with(&config)
.handshake(&mut server)
.await
.expect_err("missing user-agent must be rejected");
assert!(err.to_string().contains("user-agent"));
}
#[tokio::test]
async fn registry_resolves_http_handler_when_feature_enabled() {
let mut registry = ProtocolRegistry::new();
registry.register(Box::new(handler()));
assert_eq!(registry.names(), vec!["http"]);
let resolved = registry.get("http").expect("http handler must resolve");
assert_eq!(resolved.name(), "http");
assert!(registry.primary().is_ok());
}
fn handler_with(config: &HttpProtocolConfig) -> HttpProtocolHandler {
HttpProtocolHandler::new(config, &["127.0.0.1:9000".to_string()])
}

118
tests/xdp_globals.rs Normal file
View file

@ -0,0 +1,118 @@
#![cfg(feature = "xdp")]
use rampart::config::Config;
use rampart::xdp::XdpGlobals;
#[test]
fn rodata_image_size_is_stable() {
let img = XdpGlobals::default().build_rodata_image();
assert_eq!(img.len(), 96, ".rodata image size must match config.h layout");
}
#[test]
fn rodata_defaults_match_config_h() {
let img = XdpGlobals::default().build_rodata_image();
let rd = |off: usize| u64::from_le_bytes(img[off..off + 8].try_into().expect("8 bytes"));
let rd32 = |off: usize| u32::from_le_bytes(img[off..off + 4].try_into().expect("4 bytes"));
assert_eq!(&img[0..2], 1u16.to_le_bytes());
assert_eq!(&img[2..4], 65535u16.to_le_bytes());
assert_eq!(rd32(4), 10); // G_SYN_HIT_COUNT
assert_eq!(rd(8), 3_000_000_000); // G_SYN_WINDOW_NS
assert_eq!(rd(16), 60_000_000_000); // G_SYN_BAN_DURATION_NS
assert_eq!(rd(24), 30_000_000_000); // G_CONNTRACK_IDLE_NS
assert_eq!(rd(32), 120_000_000_000); // G_PLAYER_IDLE_NS
assert_eq!(rd(40), 300_000_000_000); // G_BAN_DURATION_NS
assert_eq!(img[48], 4); // G_MAX_OUT_OF_ORDER
assert_eq!(img[49], 0); // G_UDP_POLICY
assert_eq!(rd32(52), 100); // G_UDP_HIT_COUNT (padding 50..52)
assert_eq!(rd(56), 1_000_000_000); // G_UDP_WINDOW_NS
assert_eq!(img[64], 1); // G_FEATURE_SYN_THROTTLE
assert_eq!(img[65], 1); // G_FEATURE_EVENTS
assert_eq!(img[66], 0); // G_SYN_CHALLENGE_ENABLED
assert_eq!(rd(72), 0xA5A5_A5A5_A5A5_A5A5); // G_CHALLENGE_SECRET (padding 67..72)
assert_eq!(rd32(80), 3000); // G_CHALLENGE_TIMEOUT_MS
assert_eq!(rd(88), 300_000_000_000); // G_CHALLENGE_VERIFIED_TTL_NS (padding 84..88)
}
#[test]
fn rodata_padding_bytes_stay_zero() {
let g = XdpGlobals {
max_out_of_order: u8::MAX,
udp_policy: u8::MAX,
feature_syn_throttle: true,
feature_events: true,
syn_challenge_enabled: true,
..XdpGlobals::default()
};
let img = g.build_rodata_image();
for off in [50, 51, 67, 68, 69, 70, 71, 84, 85, 86, 87] {
assert_eq!(img[off], 0, "alignment padding at {off} must stay zeroed");
}
}
#[test]
fn from_config_maps_fields() {
let cfg = Config::parse_str(
r#"
[xdp]
protected_port_start = 25560
protected_port_end = 25600
udp_policy = "rate-limit"
udp_rate_hit_count = 500
udp_rate_window_ms = 2000
syn_challenge_enabled = true
challenge_secret_hex = "0011223344556677"
challenge_timeout_ms = 7000
throttle_enabled = false
events_enabled = false
"#,
)
.expect("[xdp] section must parse");
let g = XdpGlobals::from_config(&cfg.xdp).expect("globals must build");
assert_eq!(g.start_port, 25560);
assert_eq!(g.end_port, 25600);
assert_eq!(g.udp_policy, 2);
assert_eq!(g.udp_hit_count, 500);
assert_eq!(g.udp_window_ns, 2_000_000_000);
assert!(!g.feature_syn_throttle);
assert!(!g.feature_events);
assert!(g.syn_challenge_enabled);
assert_eq!(g.challenge_secret, 0x0011_2233_4455_6677);
assert_eq!(g.challenge_timeout_ms, 7000);
let img = g.build_rodata_image();
assert_eq!(img[49], 2);
assert_eq!(img[64], 0);
assert_eq!(&img[0..2], 25560u16.to_le_bytes());
}
#[test]
fn from_config_rejects_bad_udp_policy() {
let cfg = Config::parse_str("[xdp]\nudp_policy = \"flood\"\n").expect("config parses");
assert!(XdpGlobals::from_config(&cfg.xdp).is_err());
}
#[test]
fn from_config_rejects_inverted_port_range() {
let cfg = Config::parse_str("[xdp]\nprotected_port_start = 100\nprotected_port_end = 50\n").expect("config parses");
assert!(XdpGlobals::from_config(&cfg.xdp).is_err());
}
#[test]
fn from_config_rejects_bad_secret_hex() {
for secret in ["nothex", "0011", "001122334455667788"] {
let toml = format!("[xdp]\nchallenge_secret_hex = \"{secret}\"\n");
let cfg = Config::parse_str(&toml).expect("config parses");
assert!(
XdpGlobals::from_config(&cfg.xdp).is_err(),
"secret '{secret}' must be rejected"
);
}
}
#[test]
fn xdp_section_defaults_match_config_h() {
let cfg = Config::parse_str("").expect("empty config parses");
let g = XdpGlobals::from_config(&cfg.xdp).expect("globals must build");
assert_eq!(g, XdpGlobals::default());
}