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:
86
rust/crates/rustproxy-routing/src/matchers/domain.rs
Normal file
86
rust/crates/rustproxy-routing/src/matchers/domain.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
/// Match a domain against a pattern supporting wildcards.
|
||||
///
|
||||
/// Supported patterns:
|
||||
/// - `*` matches any domain
|
||||
/// - `*.example.com` matches any subdomain of example.com
|
||||
/// - `example.com` exact match
|
||||
/// - `**.example.com` matches any depth of subdomain
|
||||
pub fn domain_matches(pattern: &str, domain: &str) -> bool {
|
||||
let pattern = pattern.trim().to_lowercase();
|
||||
let domain = domain.trim().to_lowercase();
|
||||
|
||||
if pattern == "*" {
|
||||
return true;
|
||||
}
|
||||
|
||||
if pattern == domain {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Wildcard patterns
|
||||
if pattern.starts_with("*.") {
|
||||
let suffix = &pattern[2..]; // e.g., "example.com"
|
||||
// Match exact parent or any single-level subdomain
|
||||
if domain == suffix {
|
||||
return true;
|
||||
}
|
||||
if domain.ends_with(&format!(".{}", suffix)) {
|
||||
// Check it's a single level subdomain for `*.`
|
||||
let prefix = &domain[..domain.len() - suffix.len() - 1];
|
||||
return !prefix.contains('.');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if pattern.starts_with("**.") {
|
||||
let suffix = &pattern[3..];
|
||||
// Match exact parent or any depth of subdomain
|
||||
return domain == suffix || domain.ends_with(&format!(".{}", suffix));
|
||||
}
|
||||
|
||||
// Use glob-match for more complex patterns
|
||||
glob_match::glob_match(&pattern, &domain)
|
||||
}
|
||||
|
||||
/// Check if a domain matches any of the given patterns.
|
||||
pub fn domain_matches_any(patterns: &[&str], domain: &str) -> bool {
|
||||
patterns.iter().any(|p| domain_matches(p, domain))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_exact_match() {
|
||||
assert!(domain_matches("example.com", "example.com"));
|
||||
assert!(!domain_matches("example.com", "other.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wildcard_all() {
|
||||
assert!(domain_matches("*", "anything.com"));
|
||||
assert!(domain_matches("*", "sub.domain.example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wildcard_subdomain() {
|
||||
assert!(domain_matches("*.example.com", "www.example.com"));
|
||||
assert!(domain_matches("*.example.com", "api.example.com"));
|
||||
assert!(domain_matches("*.example.com", "example.com"));
|
||||
assert!(!domain_matches("*.example.com", "deep.sub.example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_double_wildcard() {
|
||||
assert!(domain_matches("**.example.com", "www.example.com"));
|
||||
assert!(domain_matches("**.example.com", "deep.sub.example.com"));
|
||||
assert!(domain_matches("**.example.com", "example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_case_insensitive() {
|
||||
assert!(domain_matches("Example.COM", "example.com"));
|
||||
assert!(domain_matches("*.EXAMPLE.com", "WWW.example.COM"));
|
||||
}
|
||||
}
|
||||
98
rust/crates/rustproxy-routing/src/matchers/header.rs
Normal file
98
rust/crates/rustproxy-routing/src/matchers/header.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
use std::collections::HashMap;
|
||||
use regex::Regex;
|
||||
|
||||
/// Match HTTP headers against a set of patterns.
|
||||
///
|
||||
/// Pattern values can be:
|
||||
/// - Exact string: `"application/json"`
|
||||
/// - Regex (surrounded by /): `"/^text\/.*/"`
|
||||
pub fn headers_match(
|
||||
patterns: &HashMap<String, String>,
|
||||
headers: &HashMap<String, String>,
|
||||
) -> bool {
|
||||
for (key, pattern) in patterns {
|
||||
let key_lower = key.to_lowercase();
|
||||
|
||||
// Find the header (case-insensitive)
|
||||
let header_value = headers
|
||||
.iter()
|
||||
.find(|(k, _)| k.to_lowercase() == key_lower)
|
||||
.map(|(_, v)| v.as_str());
|
||||
|
||||
let header_value = match header_value {
|
||||
Some(v) => v,
|
||||
None => return false, // Required header not present
|
||||
};
|
||||
|
||||
// Check if pattern is a regex (surrounded by /)
|
||||
if pattern.starts_with('/') && pattern.ends_with('/') && pattern.len() > 2 {
|
||||
let regex_str = &pattern[1..pattern.len() - 1];
|
||||
match Regex::new(regex_str) {
|
||||
Ok(re) => {
|
||||
if !re.is_match(header_value) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// Invalid regex, fall back to exact match
|
||||
if header_value != pattern {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Exact match
|
||||
if header_value != pattern {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_exact_header_match() {
|
||||
let patterns: HashMap<String, String> = {
|
||||
let mut m = HashMap::new();
|
||||
m.insert("Content-Type".to_string(), "application/json".to_string());
|
||||
m
|
||||
};
|
||||
let headers: HashMap<String, String> = {
|
||||
let mut m = HashMap::new();
|
||||
m.insert("content-type".to_string(), "application/json".to_string());
|
||||
m
|
||||
};
|
||||
assert!(headers_match(&patterns, &headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_regex_header_match() {
|
||||
let patterns: HashMap<String, String> = {
|
||||
let mut m = HashMap::new();
|
||||
m.insert("Content-Type".to_string(), "/^text\\/.*/".to_string());
|
||||
m
|
||||
};
|
||||
let headers: HashMap<String, String> = {
|
||||
let mut m = HashMap::new();
|
||||
m.insert("content-type".to_string(), "text/html".to_string());
|
||||
m
|
||||
};
|
||||
assert!(headers_match(&patterns, &headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_missing_header() {
|
||||
let patterns: HashMap<String, String> = {
|
||||
let mut m = HashMap::new();
|
||||
m.insert("X-Custom".to_string(), "value".to_string());
|
||||
m
|
||||
};
|
||||
let headers: HashMap<String, String> = HashMap::new();
|
||||
assert!(!headers_match(&patterns, &headers));
|
||||
}
|
||||
}
|
||||
126
rust/crates/rustproxy-routing/src/matchers/ip.rs
Normal file
126
rust/crates/rustproxy-routing/src/matchers/ip.rs
Normal file
@@ -0,0 +1,126 @@
|
||||
use std::net::IpAddr;
|
||||
use std::str::FromStr;
|
||||
use ipnet::IpNet;
|
||||
|
||||
/// Match an IP address against a pattern.
|
||||
///
|
||||
/// Supported patterns:
|
||||
/// - `*` matches any IP
|
||||
/// - `192.168.1.0/24` CIDR range
|
||||
/// - `192.168.1.100` exact match
|
||||
/// - `192.168.1.*` wildcard (converted to CIDR)
|
||||
/// - `::ffff:192.168.1.100` IPv6-mapped IPv4
|
||||
pub fn ip_matches(pattern: &str, ip: &str) -> bool {
|
||||
let pattern = pattern.trim();
|
||||
|
||||
if pattern == "*" {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Normalize IPv4-mapped IPv6
|
||||
let normalized_ip = normalize_ip_str(ip);
|
||||
|
||||
// Try CIDR match
|
||||
if pattern.contains('/') {
|
||||
if let Ok(net) = IpNet::from_str(pattern) {
|
||||
if let Ok(addr) = IpAddr::from_str(&normalized_ip) {
|
||||
return net.contains(&addr);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle wildcard patterns like 192.168.1.*
|
||||
if pattern.contains('*') {
|
||||
let pattern_cidr = wildcard_to_cidr(pattern);
|
||||
if let Some(cidr) = pattern_cidr {
|
||||
if let Ok(net) = IpNet::from_str(&cidr) {
|
||||
if let Ok(addr) = IpAddr::from_str(&normalized_ip) {
|
||||
return net.contains(&addr);
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exact match
|
||||
let normalized_pattern = normalize_ip_str(pattern);
|
||||
normalized_ip == normalized_pattern
|
||||
}
|
||||
|
||||
/// Check if an IP matches any of the given patterns.
|
||||
pub fn ip_matches_any(patterns: &[String], ip: &str) -> bool {
|
||||
patterns.iter().any(|p| ip_matches(p, ip))
|
||||
}
|
||||
|
||||
/// Normalize IPv4-mapped IPv6 addresses.
|
||||
fn normalize_ip_str(ip: &str) -> String {
|
||||
let ip = ip.trim();
|
||||
if ip.starts_with("::ffff:") {
|
||||
return ip[7..].to_string();
|
||||
}
|
||||
ip.to_string()
|
||||
}
|
||||
|
||||
/// Convert a wildcard IP pattern to CIDR notation.
|
||||
/// e.g., "192.168.1.*" -> "192.168.1.0/24"
|
||||
fn wildcard_to_cidr(pattern: &str) -> Option<String> {
|
||||
let parts: Vec<&str> = pattern.split('.').collect();
|
||||
if parts.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut octets = [0u8; 4];
|
||||
let mut prefix_len = 0;
|
||||
|
||||
for (i, part) in parts.iter().enumerate() {
|
||||
if *part == "*" {
|
||||
break;
|
||||
}
|
||||
if let Ok(n) = part.parse::<u8>() {
|
||||
octets[i] = n;
|
||||
prefix_len += 8;
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
Some(format!("{}.{}.{}.{}/{}", octets[0], octets[1], octets[2], octets[3], prefix_len))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_wildcard_all() {
|
||||
assert!(ip_matches("*", "192.168.1.100"));
|
||||
assert!(ip_matches("*", "::1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exact_match() {
|
||||
assert!(ip_matches("192.168.1.100", "192.168.1.100"));
|
||||
assert!(!ip_matches("192.168.1.100", "192.168.1.101"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cidr() {
|
||||
assert!(ip_matches("192.168.1.0/24", "192.168.1.100"));
|
||||
assert!(ip_matches("192.168.1.0/24", "192.168.1.1"));
|
||||
assert!(!ip_matches("192.168.1.0/24", "192.168.2.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wildcard_pattern() {
|
||||
assert!(ip_matches("192.168.1.*", "192.168.1.100"));
|
||||
assert!(ip_matches("192.168.1.*", "192.168.1.1"));
|
||||
assert!(!ip_matches("192.168.1.*", "192.168.2.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ipv6_mapped() {
|
||||
assert!(ip_matches("192.168.1.100", "::ffff:192.168.1.100"));
|
||||
assert!(ip_matches("192.168.1.0/24", "::ffff:192.168.1.50"));
|
||||
}
|
||||
}
|
||||
9
rust/crates/rustproxy-routing/src/matchers/mod.rs
Normal file
9
rust/crates/rustproxy-routing/src/matchers/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
pub mod domain;
|
||||
pub mod path;
|
||||
pub mod ip;
|
||||
pub mod header;
|
||||
|
||||
pub use domain::*;
|
||||
pub use path::*;
|
||||
pub use ip::*;
|
||||
pub use header::*;
|
||||
65
rust/crates/rustproxy-routing/src/matchers/path.rs
Normal file
65
rust/crates/rustproxy-routing/src/matchers/path.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
/// Match a URL path against a pattern supporting wildcards.
|
||||
///
|
||||
/// Supported patterns:
|
||||
/// - `/api/*` matches `/api/anything` (single level)
|
||||
/// - `/api/**` matches `/api/any/depth/here`
|
||||
/// - `/exact/path` exact match
|
||||
/// - `/prefix*` prefix match
|
||||
pub fn path_matches(pattern: &str, path: &str) -> bool {
|
||||
// Exact match
|
||||
if pattern == path {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Double-star: match any depth
|
||||
if pattern.ends_with("/**") {
|
||||
let prefix = &pattern[..pattern.len() - 3];
|
||||
return path == prefix || path.starts_with(&format!("{}/", prefix));
|
||||
}
|
||||
|
||||
// Single-star at end: match single path segment
|
||||
if pattern.ends_with("/*") {
|
||||
let prefix = &pattern[..pattern.len() - 2];
|
||||
if path == prefix {
|
||||
return true;
|
||||
}
|
||||
if path.starts_with(&format!("{}/", prefix)) {
|
||||
let rest = &path[prefix.len() + 1..];
|
||||
// Single level means no more slashes
|
||||
return !rest.contains('/');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Star anywhere: use glob matching
|
||||
if pattern.contains('*') {
|
||||
return glob_match::glob_match(pattern, path);
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_exact_path() {
|
||||
assert!(path_matches("/api/users", "/api/users"));
|
||||
assert!(!path_matches("/api/users", "/api/posts"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_wildcard() {
|
||||
assert!(path_matches("/api/*", "/api/users"));
|
||||
assert!(path_matches("/api/*", "/api/posts"));
|
||||
assert!(!path_matches("/api/*", "/api/users/123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_double_wildcard() {
|
||||
assert!(path_matches("/api/**", "/api/users"));
|
||||
assert!(path_matches("/api/**", "/api/users/123"));
|
||||
assert!(path_matches("/api/**", "/api/users/123/posts"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user