feat(rustproxy): introduce a Rust-powered proxy engine and workspace with core crates for proxy functionality, ACME/TLS support, passthrough and HTTP proxies, metrics, nftables integration, routing/security, management IPC, tests, and README updates
This commit is contained in:
111
rust/crates/rustproxy-security/src/basic_auth.rs
Normal file
111
rust/crates/rustproxy-security/src/basic_auth.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
|
||||
/// Basic auth validator.
|
||||
pub struct BasicAuthValidator {
|
||||
users: Vec<(String, String)>,
|
||||
realm: String,
|
||||
}
|
||||
|
||||
impl BasicAuthValidator {
|
||||
pub fn new(users: Vec<(String, String)>, realm: Option<String>) -> Self {
|
||||
Self {
|
||||
users,
|
||||
realm: realm.unwrap_or_else(|| "Restricted".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate an Authorization header value.
|
||||
/// Returns the username if valid.
|
||||
pub fn validate(&self, auth_header: &str) -> Option<String> {
|
||||
let auth_header = auth_header.trim();
|
||||
if !auth_header.starts_with("Basic ") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let encoded = &auth_header[6..];
|
||||
let decoded = BASE64.decode(encoded).ok()?;
|
||||
let credentials = String::from_utf8(decoded).ok()?;
|
||||
|
||||
let mut parts = credentials.splitn(2, ':');
|
||||
let username = parts.next()?;
|
||||
let password = parts.next()?;
|
||||
|
||||
for (u, p) in &self.users {
|
||||
if u == username && p == password {
|
||||
return Some(username.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Get the realm for WWW-Authenticate header.
|
||||
pub fn realm(&self) -> &str {
|
||||
&self.realm
|
||||
}
|
||||
|
||||
/// Generate the WWW-Authenticate header value.
|
||||
pub fn www_authenticate(&self) -> String {
|
||||
format!("Basic realm=\"{}\"", self.realm)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use base64::Engine;
|
||||
|
||||
fn make_validator() -> BasicAuthValidator {
|
||||
BasicAuthValidator::new(
|
||||
vec![
|
||||
("admin".to_string(), "secret".to_string()),
|
||||
("user".to_string(), "pass".to_string()),
|
||||
],
|
||||
Some("TestRealm".to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
fn encode_basic(user: &str, pass: &str) -> String {
|
||||
let encoded = BASE64.encode(format!("{}:{}", user, pass));
|
||||
format!("Basic {}", encoded)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_credentials() {
|
||||
let validator = make_validator();
|
||||
let header = encode_basic("admin", "secret");
|
||||
assert_eq!(validator.validate(&header), Some("admin".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_password() {
|
||||
let validator = make_validator();
|
||||
let header = encode_basic("admin", "wrong");
|
||||
assert_eq!(validator.validate(&header), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_not_basic_scheme() {
|
||||
let validator = make_validator();
|
||||
assert_eq!(validator.validate("Bearer sometoken"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_malformed_base64() {
|
||||
let validator = make_validator();
|
||||
assert_eq!(validator.validate("Basic !!!not-base64!!!"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_www_authenticate_format() {
|
||||
let validator = make_validator();
|
||||
assert_eq!(validator.www_authenticate(), "Basic realm=\"TestRealm\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_realm() {
|
||||
let validator = BasicAuthValidator::new(vec![], None);
|
||||
assert_eq!(validator.www_authenticate(), "Basic realm=\"Restricted\"");
|
||||
}
|
||||
}
|
||||
189
rust/crates/rustproxy-security/src/ip_filter.rs
Normal file
189
rust/crates/rustproxy-security/src/ip_filter.rs
Normal file
@@ -0,0 +1,189 @@
|
||||
use ipnet::IpNet;
|
||||
use std::net::IpAddr;
|
||||
use std::str::FromStr;
|
||||
|
||||
/// IP filter supporting CIDR ranges, wildcards, and exact matches.
|
||||
pub struct IpFilter {
|
||||
allow_list: Vec<IpPattern>,
|
||||
block_list: Vec<IpPattern>,
|
||||
}
|
||||
|
||||
/// Represents an IP pattern for matching.
|
||||
#[derive(Debug)]
|
||||
enum IpPattern {
|
||||
/// Exact IP match
|
||||
Exact(IpAddr),
|
||||
/// CIDR range match
|
||||
Cidr(IpNet),
|
||||
/// Wildcard (matches everything)
|
||||
Wildcard,
|
||||
}
|
||||
|
||||
impl IpPattern {
|
||||
fn parse(s: &str) -> Self {
|
||||
let s = s.trim();
|
||||
if s == "*" {
|
||||
return IpPattern::Wildcard;
|
||||
}
|
||||
if let Ok(net) = IpNet::from_str(s) {
|
||||
return IpPattern::Cidr(net);
|
||||
}
|
||||
if let Ok(addr) = IpAddr::from_str(s) {
|
||||
return IpPattern::Exact(addr);
|
||||
}
|
||||
// Try as CIDR by appending default prefix
|
||||
if let Ok(addr) = IpAddr::from_str(s) {
|
||||
return IpPattern::Exact(addr);
|
||||
}
|
||||
// Fallback: treat as exact, will never match an invalid string
|
||||
IpPattern::Exact(IpAddr::from_str("0.0.0.0").unwrap())
|
||||
}
|
||||
|
||||
fn matches(&self, ip: &IpAddr) -> bool {
|
||||
match self {
|
||||
IpPattern::Wildcard => true,
|
||||
IpPattern::Exact(addr) => addr == ip,
|
||||
IpPattern::Cidr(net) => net.contains(ip),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IpFilter {
|
||||
/// Create a new IP filter from allow and block lists.
|
||||
pub fn new(allow_list: &[String], block_list: &[String]) -> Self {
|
||||
Self {
|
||||
allow_list: allow_list.iter().map(|s| IpPattern::parse(s)).collect(),
|
||||
block_list: block_list.iter().map(|s| IpPattern::parse(s)).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if an IP is allowed.
|
||||
/// If allow_list is non-empty, IP must match at least one entry.
|
||||
/// If block_list is non-empty, IP must NOT match any entry.
|
||||
pub fn is_allowed(&self, ip: &IpAddr) -> bool {
|
||||
// Check block list first
|
||||
if !self.block_list.is_empty() {
|
||||
for pattern in &self.block_list {
|
||||
if pattern.matches(ip) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If allow list is non-empty, must match at least one
|
||||
if !self.allow_list.is_empty() {
|
||||
return self.allow_list.iter().any(|p| p.matches(ip));
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Normalize IPv4-mapped IPv6 addresses (::ffff:x.x.x.x -> x.x.x.x)
|
||||
pub fn normalize_ip(ip: &IpAddr) -> IpAddr {
|
||||
match ip {
|
||||
IpAddr::V6(v6) => {
|
||||
if let Some(v4) = v6.to_ipv4_mapped() {
|
||||
IpAddr::V4(v4)
|
||||
} else {
|
||||
*ip
|
||||
}
|
||||
}
|
||||
_ => *ip,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_empty_lists_allow_all() {
|
||||
let filter = IpFilter::new(&[], &[]);
|
||||
let ip: IpAddr = "192.168.1.1".parse().unwrap();
|
||||
assert!(filter.is_allowed(&ip));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allow_list_exact() {
|
||||
let filter = IpFilter::new(
|
||||
&["10.0.0.1".to_string()],
|
||||
&[],
|
||||
);
|
||||
let allowed: IpAddr = "10.0.0.1".parse().unwrap();
|
||||
let denied: IpAddr = "10.0.0.2".parse().unwrap();
|
||||
assert!(filter.is_allowed(&allowed));
|
||||
assert!(!filter.is_allowed(&denied));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allow_list_cidr() {
|
||||
let filter = IpFilter::new(
|
||||
&["10.0.0.0/8".to_string()],
|
||||
&[],
|
||||
);
|
||||
let allowed: IpAddr = "10.255.255.255".parse().unwrap();
|
||||
let denied: IpAddr = "192.168.1.1".parse().unwrap();
|
||||
assert!(filter.is_allowed(&allowed));
|
||||
assert!(!filter.is_allowed(&denied));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_list() {
|
||||
let filter = IpFilter::new(
|
||||
&[],
|
||||
&["192.168.1.100".to_string()],
|
||||
);
|
||||
let blocked: IpAddr = "192.168.1.100".parse().unwrap();
|
||||
let allowed: IpAddr = "192.168.1.101".parse().unwrap();
|
||||
assert!(!filter.is_allowed(&blocked));
|
||||
assert!(filter.is_allowed(&allowed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_trumps_allow() {
|
||||
let filter = IpFilter::new(
|
||||
&["10.0.0.0/8".to_string()],
|
||||
&["10.0.0.5".to_string()],
|
||||
);
|
||||
let blocked: IpAddr = "10.0.0.5".parse().unwrap();
|
||||
let allowed: IpAddr = "10.0.0.6".parse().unwrap();
|
||||
assert!(!filter.is_allowed(&blocked));
|
||||
assert!(filter.is_allowed(&allowed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wildcard_allow() {
|
||||
let filter = IpFilter::new(
|
||||
&["*".to_string()],
|
||||
&[],
|
||||
);
|
||||
let ip: IpAddr = "1.2.3.4".parse().unwrap();
|
||||
assert!(filter.is_allowed(&ip));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wildcard_block() {
|
||||
let filter = IpFilter::new(
|
||||
&[],
|
||||
&["*".to_string()],
|
||||
);
|
||||
let ip: IpAddr = "1.2.3.4".parse().unwrap();
|
||||
assert!(!filter.is_allowed(&ip));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_ipv4_mapped_ipv6() {
|
||||
let mapped: IpAddr = "::ffff:192.168.1.1".parse().unwrap();
|
||||
let normalized = IpFilter::normalize_ip(&mapped);
|
||||
let expected: IpAddr = "192.168.1.1".parse().unwrap();
|
||||
assert_eq!(normalized, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_pure_ipv4() {
|
||||
let ip: IpAddr = "10.0.0.1".parse().unwrap();
|
||||
let normalized = IpFilter::normalize_ip(&ip);
|
||||
assert_eq!(normalized, ip);
|
||||
}
|
||||
}
|
||||
174
rust/crates/rustproxy-security/src/jwt_auth.rs
Normal file
174
rust/crates/rustproxy-security/src/jwt_auth.rs
Normal file
@@ -0,0 +1,174 @@
|
||||
use jsonwebtoken::{decode, DecodingKey, Validation, Algorithm};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// JWT claims (minimal structure).
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
pub sub: Option<String>,
|
||||
pub exp: Option<u64>,
|
||||
pub iss: Option<String>,
|
||||
pub aud: Option<String>,
|
||||
}
|
||||
|
||||
/// JWT auth validator.
|
||||
pub struct JwtValidator {
|
||||
decoding_key: DecodingKey,
|
||||
validation: Validation,
|
||||
}
|
||||
|
||||
impl JwtValidator {
|
||||
pub fn new(
|
||||
secret: &str,
|
||||
algorithm: Option<&str>,
|
||||
issuer: Option<&str>,
|
||||
audience: Option<&str>,
|
||||
) -> Self {
|
||||
let algo = match algorithm {
|
||||
Some("HS384") => Algorithm::HS384,
|
||||
Some("HS512") => Algorithm::HS512,
|
||||
Some("RS256") => Algorithm::RS256,
|
||||
_ => Algorithm::HS256,
|
||||
};
|
||||
|
||||
let mut validation = Validation::new(algo);
|
||||
if let Some(iss) = issuer {
|
||||
validation.set_issuer(&[iss]);
|
||||
}
|
||||
if let Some(aud) = audience {
|
||||
validation.set_audience(&[aud]);
|
||||
}
|
||||
|
||||
Self {
|
||||
decoding_key: DecodingKey::from_secret(secret.as_bytes()),
|
||||
validation,
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a JWT token string (without "Bearer " prefix).
|
||||
/// Returns the claims if valid.
|
||||
pub fn validate(&self, token: &str) -> Result<Claims, String> {
|
||||
decode::<Claims>(token, &self.decoding_key, &self.validation)
|
||||
.map(|data| data.claims)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Extract token from Authorization header.
|
||||
pub fn extract_token(auth_header: &str) -> Option<&str> {
|
||||
let header = auth_header.trim();
|
||||
if header.starts_with("Bearer ") {
|
||||
Some(&header[7..])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use jsonwebtoken::{encode, EncodingKey, Header};
|
||||
|
||||
fn make_token(secret: &str, claims: &Claims) -> String {
|
||||
encode(
|
||||
&Header::default(),
|
||||
claims,
|
||||
&EncodingKey::from_secret(secret.as_bytes()),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn future_exp() -> u64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
+ 3600
|
||||
}
|
||||
|
||||
fn past_exp() -> u64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
- 3600
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_token() {
|
||||
let secret = "test-secret";
|
||||
let claims = Claims {
|
||||
sub: Some("user123".to_string()),
|
||||
exp: Some(future_exp()),
|
||||
iss: None,
|
||||
aud: None,
|
||||
};
|
||||
let token = make_token(secret, &claims);
|
||||
let validator = JwtValidator::new(secret, None, None, None);
|
||||
let result = validator.validate(&token);
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap().sub, Some("user123".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expired_token() {
|
||||
let secret = "test-secret";
|
||||
let claims = Claims {
|
||||
sub: Some("user123".to_string()),
|
||||
exp: Some(past_exp()),
|
||||
iss: None,
|
||||
aud: None,
|
||||
};
|
||||
let token = make_token(secret, &claims);
|
||||
let validator = JwtValidator::new(secret, None, None, None);
|
||||
assert!(validator.validate(&token).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrong_secret() {
|
||||
let claims = Claims {
|
||||
sub: Some("user123".to_string()),
|
||||
exp: Some(future_exp()),
|
||||
iss: None,
|
||||
aud: None,
|
||||
};
|
||||
let token = make_token("correct-secret", &claims);
|
||||
let validator = JwtValidator::new("wrong-secret", None, None, None);
|
||||
assert!(validator.validate(&token).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issuer_validation() {
|
||||
let secret = "test-secret";
|
||||
let claims = Claims {
|
||||
sub: Some("user123".to_string()),
|
||||
exp: Some(future_exp()),
|
||||
iss: Some("my-issuer".to_string()),
|
||||
aud: None,
|
||||
};
|
||||
let token = make_token(secret, &claims);
|
||||
|
||||
// Correct issuer
|
||||
let validator = JwtValidator::new(secret, None, Some("my-issuer"), None);
|
||||
assert!(validator.validate(&token).is_ok());
|
||||
|
||||
// Wrong issuer
|
||||
let validator = JwtValidator::new(secret, None, Some("other-issuer"), None);
|
||||
assert!(validator.validate(&token).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_token_bearer() {
|
||||
assert_eq!(
|
||||
JwtValidator::extract_token("Bearer abc123"),
|
||||
Some("abc123")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_token_non_bearer() {
|
||||
assert_eq!(JwtValidator::extract_token("Basic abc123"), None);
|
||||
assert_eq!(JwtValidator::extract_token("abc123"), None);
|
||||
}
|
||||
}
|
||||
13
rust/crates/rustproxy-security/src/lib.rs
Normal file
13
rust/crates/rustproxy-security/src/lib.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
//! # rustproxy-security
|
||||
//!
|
||||
//! IP filtering, rate limiting, and authentication for RustProxy.
|
||||
|
||||
pub mod ip_filter;
|
||||
pub mod rate_limiter;
|
||||
pub mod basic_auth;
|
||||
pub mod jwt_auth;
|
||||
|
||||
pub use ip_filter::*;
|
||||
pub use rate_limiter::*;
|
||||
pub use basic_auth::*;
|
||||
pub use jwt_auth::*;
|
||||
97
rust/crates/rustproxy-security/src/rate_limiter.rs
Normal file
97
rust/crates/rustproxy-security/src/rate_limiter.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
use dashmap::DashMap;
|
||||
use std::time::Instant;
|
||||
|
||||
/// Sliding window rate limiter.
|
||||
pub struct RateLimiter {
|
||||
/// Map of key -> list of request timestamps
|
||||
windows: DashMap<String, Vec<Instant>>,
|
||||
/// Maximum requests per window
|
||||
max_requests: u64,
|
||||
/// Window duration in seconds
|
||||
window_seconds: u64,
|
||||
}
|
||||
|
||||
impl RateLimiter {
|
||||
pub fn new(max_requests: u64, window_seconds: u64) -> Self {
|
||||
Self {
|
||||
windows: DashMap::new(),
|
||||
max_requests,
|
||||
window_seconds,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a request is allowed for the given key.
|
||||
/// Returns true if allowed, false if rate limited.
|
||||
pub fn check(&self, key: &str) -> bool {
|
||||
let now = Instant::now();
|
||||
let window = std::time::Duration::from_secs(self.window_seconds);
|
||||
|
||||
let mut entry = self.windows.entry(key.to_string()).or_default();
|
||||
let timestamps = entry.value_mut();
|
||||
|
||||
// Remove expired entries
|
||||
timestamps.retain(|t| now.duration_since(*t) < window);
|
||||
|
||||
if timestamps.len() as u64 >= self.max_requests {
|
||||
false
|
||||
} else {
|
||||
timestamps.push(now);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Clean up expired entries (call periodically).
|
||||
pub fn cleanup(&self) {
|
||||
let now = Instant::now();
|
||||
let window = std::time::Duration::from_secs(self.window_seconds);
|
||||
|
||||
self.windows.retain(|_, timestamps| {
|
||||
timestamps.retain(|t| now.duration_since(*t) < window);
|
||||
!timestamps.is_empty()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_allow_under_limit() {
|
||||
let limiter = RateLimiter::new(5, 60);
|
||||
for _ in 0..5 {
|
||||
assert!(limiter.check("client-1"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_over_limit() {
|
||||
let limiter = RateLimiter::new(3, 60);
|
||||
assert!(limiter.check("client-1"));
|
||||
assert!(limiter.check("client-1"));
|
||||
assert!(limiter.check("client-1"));
|
||||
assert!(!limiter.check("client-1")); // 4th request blocked
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_keys_independent() {
|
||||
let limiter = RateLimiter::new(2, 60);
|
||||
assert!(limiter.check("client-a"));
|
||||
assert!(limiter.check("client-a"));
|
||||
assert!(!limiter.check("client-a")); // blocked
|
||||
// Different key should still be allowed
|
||||
assert!(limiter.check("client-b"));
|
||||
assert!(limiter.check("client-b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cleanup_removes_expired() {
|
||||
let limiter = RateLimiter::new(100, 0); // 0 second window = immediately expired
|
||||
limiter.check("client-1");
|
||||
// Sleep briefly to let entries expire
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
limiter.cleanup();
|
||||
// After cleanup, the key should be allowed again (entries expired)
|
||||
assert!(limiter.check("client-1"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user