feat(smart-proxy): add hot-reloadable global ingress security policy across Rust and TypeScript proxy layers

This commit is contained in:
2026-04-26 15:11:10 +00:00
parent 8fa3a51b03
commit af4908b63f
53 changed files with 2350 additions and 1196 deletions
@@ -1,5 +1,5 @@
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine;
/// Basic auth validator.
pub struct BasicAuthValidator {
+45 -26
View File
@@ -21,7 +21,7 @@ struct DomainScopedEntry {
}
/// Represents an IP pattern for matching.
#[derive(Debug)]
#[derive(Debug, Clone)]
enum IpPattern {
/// Exact IP match
Exact(IpAddr),
@@ -31,6 +31,37 @@ enum IpPattern {
Wildcard,
}
/// Compiled block list for early ingress filtering.
#[derive(Debug, Clone)]
pub struct IpBlockList {
block_list: Vec<IpPattern>,
}
impl IpBlockList {
pub fn new(block_list: &[String]) -> Self {
Self {
block_list: block_list.iter().map(|s| IpPattern::parse(s)).collect(),
}
}
pub fn empty() -> Self {
Self {
block_list: Vec::new(),
}
}
pub fn is_empty(&self) -> bool {
self.block_list.is_empty()
}
pub fn is_blocked(&self, ip: &IpAddr) -> bool {
let normalized = IpFilter::normalize_ip(ip);
self.block_list
.iter()
.any(|pattern| pattern.matches(&normalized))
}
}
impl IpPattern {
fn parse(s: &str) -> Self {
let s = s.trim();
@@ -68,8 +99,7 @@ fn domain_matches_pattern(pattern: &str, domain: &str) -> bool {
}
if p.starts_with("*.") {
let suffix = &p[1..]; // e.g., ".abc.xyz"
d.len() > suffix.len()
&& d[d.len() - suffix.len()..].eq_ignore_ascii_case(suffix)
d.len() > suffix.len() && d[d.len() - suffix.len()..].eq_ignore_ascii_case(suffix)
} else {
false
}
@@ -127,7 +157,11 @@ impl IpFilter {
if let Some(req_domain) = domain {
for entry in &self.domain_scoped {
if entry.pattern.matches(ip) {
if entry.domains.iter().any(|d| domain_matches_pattern(d, req_domain)) {
if entry
.domains
.iter()
.any(|d| domain_matches_pattern(d, req_domain))
{
return true;
}
}
@@ -212,10 +246,7 @@ mod tests {
#[test]
fn test_block_trumps_allow() {
let filter = IpFilter::new(
&[plain("10.0.0.0/8")],
&["10.0.0.5".to_string()],
);
let filter = IpFilter::new(&[plain("10.0.0.0/8")], &["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));
@@ -255,30 +286,21 @@ mod tests {
#[test]
fn test_domain_scoped_allows_matching_domain() {
let filter = IpFilter::new(
&[scoped("10.8.0.2", &["outline.abc.xyz"])],
&[],
);
let filter = IpFilter::new(&[scoped("10.8.0.2", &["outline.abc.xyz"])], &[]);
let ip: IpAddr = "10.8.0.2".parse().unwrap();
assert!(filter.is_allowed_for_domain(&ip, Some("outline.abc.xyz")));
}
#[test]
fn test_domain_scoped_denies_non_matching_domain() {
let filter = IpFilter::new(
&[scoped("10.8.0.2", &["outline.abc.xyz"])],
&[],
);
let filter = IpFilter::new(&[scoped("10.8.0.2", &["outline.abc.xyz"])], &[]);
let ip: IpAddr = "10.8.0.2".parse().unwrap();
assert!(!filter.is_allowed_for_domain(&ip, Some("app.abc.xyz")));
}
#[test]
fn test_domain_scoped_denies_without_domain() {
let filter = IpFilter::new(
&[scoped("10.8.0.2", &["outline.abc.xyz"])],
&[],
);
let filter = IpFilter::new(&[scoped("10.8.0.2", &["outline.abc.xyz"])], &[]);
let ip: IpAddr = "10.8.0.2".parse().unwrap();
// Without domain context, domain-scoped entries cannot match
assert!(!filter.is_allowed_for_domain(&ip, None));
@@ -286,10 +308,7 @@ mod tests {
#[test]
fn test_domain_scoped_wildcard_domain() {
let filter = IpFilter::new(
&[scoped("10.8.0.2", &["*.abc.xyz"])],
&[],
);
let filter = IpFilter::new(&[scoped("10.8.0.2", &["*.abc.xyz"])], &[]);
let ip: IpAddr = "10.8.0.2".parse().unwrap();
assert!(filter.is_allowed_for_domain(&ip, Some("outline.abc.xyz")));
assert!(filter.is_allowed_for_domain(&ip, Some("app.abc.xyz")));
@@ -300,8 +319,8 @@ mod tests {
fn test_plain_and_domain_scoped_coexist() {
let filter = IpFilter::new(
&[
plain("1.2.3.4"), // full route access
scoped("10.8.0.2", &["outline.abc.xyz"]), // scoped access
plain("1.2.3.4"), // full route access
scoped("10.8.0.2", &["outline.abc.xyz"]), // scoped access
],
&[],
);
@@ -1,4 +1,4 @@
use jsonwebtoken::{decode, DecodingKey, Validation, Algorithm};
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
use serde::{Deserialize, Serialize};
/// JWT claims (minimal structure).
@@ -160,10 +160,7 @@ mod tests {
#[test]
fn test_extract_token_bearer() {
assert_eq!(
JwtValidator::extract_token("Bearer abc123"),
Some("abc123")
);
assert_eq!(JwtValidator::extract_token("Bearer abc123"), Some("abc123"));
}
#[test]
+4 -4
View File
@@ -2,12 +2,12 @@
//!
//! IP filtering, rate limiting, and authentication for RustProxy.
pub mod ip_filter;
pub mod rate_limiter;
pub mod basic_auth;
pub mod ip_filter;
pub mod jwt_auth;
pub mod rate_limiter;
pub use ip_filter::*;
pub use rate_limiter::*;
pub use basic_auth::*;
pub use ip_filter::*;
pub use jwt_auth::*;
pub use rate_limiter::*;
@@ -79,7 +79,7 @@ mod tests {
assert!(limiter.check("client-a"));
assert!(limiter.check("client-a"));
assert!(!limiter.check("client-a")); // blocked
// Different key should still be allowed
// Different key should still be allowed
assert!(limiter.check("client-b"));
assert!(limiter.check("client-b"));
}