feat(rustproxy): add authenticated VPN route security

This commit is contained in:
2026-05-24 01:25:06 +00:00
parent c161ac664d
commit c7785d2f78
12 changed files with 310 additions and 14 deletions
+1 -1
View File
@@ -162,7 +162,7 @@ async fn handle_h3_request(
// route matching, ALPN protocol detection, connection pool, H1/H2/H3 auto.
let conn_activity = ConnActivity::new_standalone();
let response = http_proxy
.handle_request(req, peer_addr, port, cancel, conn_activity)
.handle_request(req, peer_addr, port, cancel, conn_activity, None)
.await
.map_err(|e| anyhow::anyhow!("Backend request failed: {}", e))?;
@@ -25,6 +25,7 @@ use std::pin::Pin;
use std::task::{Context, Poll};
use rustproxy_metrics::MetricsCollector;
use rustproxy_config::VpnConnectionInfo;
use rustproxy_routing::RouteManager;
use rustproxy_security::RateLimiter;
@@ -461,6 +462,20 @@ impl HttpProxyService {
cancel: CancellationToken,
) where
I: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
{
self.handle_io_with_vpn(stream, peer_addr, port, cancel, None).await;
}
/// Handle an incoming HTTP connection with optional authenticated VPN metadata.
pub async fn handle_io_with_vpn<I>(
self: Arc<Self>,
stream: I,
peer_addr: std::net::SocketAddr,
port: u16,
cancel: CancellationToken,
vpn_info: Option<VpnConnectionInfo>,
) where
I: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
{
let io = TokioIo::new(stream);
@@ -484,6 +499,7 @@ impl HttpProxyService {
let la_inner = Arc::clone(&last_activity);
let ar_inner = Arc::clone(&active_requests);
let cancel_inner = cancel.clone();
let vpn_info = Arc::new(vpn_info);
let service = hyper::service::service_fn(move |req: Request<Incoming>| {
// Detect frontend protocol from the first request on this connection.
// OnceLock ensures only the first call opens the counter.
@@ -499,6 +515,7 @@ impl HttpProxyService {
let svc = Arc::clone(&self);
let peer = peer_addr;
let cn = cancel_inner.clone();
let vpn = Arc::clone(&vpn_info);
let la = Arc::clone(&la_inner);
let st = start;
let ca = ConnActivity {
@@ -510,7 +527,7 @@ impl HttpProxyService {
};
async move {
let req = req.map(|body| BoxBody::new(body));
let result = svc.handle_request(req, peer, port, cn, ca).await;
let result = svc.handle_request(req, peer, port, cn, ca, vpn.as_ref().as_ref()).await;
// Mark request end — update activity timestamp before guard drops
la.store(st.elapsed().as_millis() as u64, Ordering::Relaxed);
drop(req_guard); // Explicitly drop to decrement active_requests
@@ -600,6 +617,7 @@ impl HttpProxyService {
port: u16,
cancel: CancellationToken,
mut conn_activity: ConnActivity,
vpn_info: Option<&VpnConnectionInfo>,
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
let host = extract_request_host(&req).map(str::to_string);
@@ -679,11 +697,12 @@ impl HttpProxyService {
.or_insert_with(|| Arc::new(RateLimiter::new(rl.max_requests, rl.window)))
.clone()
});
if let Some(response) = RequestFilter::apply_with_rate_limiter(
if let Some(response) = RequestFilter::apply_with_rate_limiter_and_vpn(
security,
&req,
&peer_addr,
rate_limiter.as_ref(),
vpn_info,
) {
return Ok(response);
}
@@ -9,7 +9,7 @@ use http_body_util::BodyExt;
use http_body_util::Full;
use hyper::{Request, Response, StatusCode};
use rustproxy_config::RouteSecurity;
use rustproxy_config::{RouteSecurity, VpnClientAllowEntry, VpnConnectionInfo};
use rustproxy_security::{BasicAuthValidator, IpFilter, JwtValidator, RateLimiter};
use crate::request_host::extract_request_host;
@@ -33,9 +33,22 @@ impl RequestFilter {
req: &Request<impl hyper::body::Body>,
peer_addr: &SocketAddr,
rate_limiter: Option<&Arc<RateLimiter>>,
) -> Option<Response<BoxBody<Bytes, hyper::Error>>> {
Self::apply_with_rate_limiter_and_vpn(security, req, peer_addr, rate_limiter, None)
}
/// Apply security filters with optional rate limiter and authenticated VPN metadata.
/// Returns Some(response) if the request should be blocked.
pub fn apply_with_rate_limiter_and_vpn(
security: &RouteSecurity,
req: &Request<impl hyper::body::Body>,
peer_addr: &SocketAddr,
rate_limiter: Option<&Arc<RateLimiter>>,
vpn_info: Option<&VpnConnectionInfo>,
) -> Option<Response<BoxBody<Bytes, hyper::Error>>> {
let client_ip = peer_addr.ip();
let request_path = req.uri().path();
let host = extract_request_host(req);
// IP filter (domain-aware: use the same host extraction as route matching)
if security.ip_allow_list.is_some() || security.ip_block_list.is_some() {
@@ -43,12 +56,15 @@ impl RequestFilter {
let block = security.ip_block_list.as_deref().unwrap_or(&[]);
let filter = IpFilter::new(allow, block);
let normalized = IpFilter::normalize_ip(&client_ip);
let host = extract_request_host(req);
if !filter.is_allowed_for_domain(&normalized, host) {
return Some(error_response(StatusCode::FORBIDDEN, "Access denied"));
}
}
if !Self::check_vpn_security(security, vpn_info, host) {
return Some(error_response(StatusCode::FORBIDDEN, "VPN access denied"));
}
// Rate limiting
if let Some(ref rate_limit_config) = security.rate_limit {
if rate_limit_config.enabled {
@@ -177,6 +193,49 @@ impl RequestFilter {
None
}
/// Check VPN-specific route access control.
pub fn check_vpn_security(
security: &RouteSecurity,
vpn_info: Option<&VpnConnectionInfo>,
domain: Option<&str>,
) -> bool {
let Some(vpn_security) = security.vpn.as_ref() else {
return true;
};
let has_client_policy = vpn_security.allowed_clients.is_some()
|| vpn_security.allowed_assigned_ips.is_some();
let allowed_clients = vpn_security.allowed_clients.as_deref().unwrap_or(&[]);
let allowed_assigned_ips = vpn_security.allowed_assigned_ips.as_deref().unwrap_or(&[]);
let requires_vpn = vpn_security.required.unwrap_or(false);
let Some(vpn_info) = vpn_info else {
return !requires_vpn;
};
if !has_client_policy {
return true;
}
if allowed_clients.is_empty() && allowed_assigned_ips.is_empty() {
return false;
}
if allowed_assigned_ips.iter().any(|ip| ip == &vpn_info.assigned_ip) {
return true;
}
allowed_clients.iter().any(|entry| match entry {
VpnClientAllowEntry::Plain(client_id) => client_id == &vpn_info.client_id,
VpnClientAllowEntry::DomainScoped { client_id, domains } => {
client_id == &vpn_info.client_id
&& domain
.map(|d| domains.iter().any(|pattern| domain_matches_pattern(pattern, d)))
.unwrap_or(false)
}
})
}
/// Check if a request path matches any pattern in the exclude list.
fn path_matches_exclude_list(_path: &str, _security: &RouteSecurity) -> bool {
// No global exclude paths on RouteSecurity currently,
@@ -286,6 +345,23 @@ impl RequestFilter {
}
}
fn domain_matches_pattern(pattern: &str, domain: &str) -> bool {
let p = pattern.trim();
let d = domain.trim();
if p == "*" {
return true;
}
if p.eq_ignore_ascii_case(d) {
return true;
}
if p.starts_with("*.") {
let suffix = &p[1..];
d.len() > suffix.len() && d[d.len() - suffix.len()..].eq_ignore_ascii_case(suffix)
} else {
false
}
}
fn error_response(status: StatusCode, message: &str) -> Response<BoxBody<Bytes, hyper::Error>> {
Response::builder()
.status(status)
@@ -303,7 +379,7 @@ mod tests {
use bytes::Bytes;
use http_body_util::Empty;
use hyper::{Request, StatusCode, Version};
use rustproxy_config::{IpAllowEntry, RouteSecurity};
use rustproxy_config::{IpAllowEntry, RouteSecurity, RouteVpnSecurity, VpnClientAllowEntry, VpnConnectionInfo};
use super::RequestFilter;
@@ -319,6 +395,7 @@ mod tests {
rate_limit: None,
basic_auth: None,
jwt_auth: None,
vpn: None,
}
}
@@ -364,4 +441,55 @@ mod tests {
.expect("non-matching domain should be denied");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[test]
fn vpn_policy_with_allow_list_preserves_direct_traffic() {
let mut security = domain_scoped_security();
security.ip_allow_list = None;
security.vpn = Some(RouteVpnSecurity {
required: Some(false),
allowed_clients: Some(vec![VpnClientAllowEntry::Plain("client-1".to_string())]),
allowed_assigned_ips: None,
});
assert!(RequestFilter::check_vpn_security(&security, None, Some("app.example.com")));
}
#[test]
fn vpn_policy_denies_unlisted_vpn_client() {
let mut security = domain_scoped_security();
security.ip_allow_list = None;
security.vpn = Some(RouteVpnSecurity {
required: Some(false),
allowed_clients: Some(vec![VpnClientAllowEntry::Plain("client-1".to_string())]),
allowed_assigned_ips: None,
});
let vpn_info = VpnConnectionInfo {
client_id: "client-2".to_string(),
assigned_ip: "10.8.0.3".to_string(),
transport_type: Some("wireguard".to_string()),
remote_addr: Some("198.51.100.10:51820".to_string()),
};
assert!(!RequestFilter::check_vpn_security(&security, Some(&vpn_info), Some("app.example.com")));
}
#[test]
fn vpn_required_with_empty_policy_denies_all_vpn_clients() {
let mut security = domain_scoped_security();
security.ip_allow_list = None;
security.vpn = Some(RouteVpnSecurity {
required: Some(true),
allowed_clients: Some(vec![]),
allowed_assigned_ips: None,
});
let vpn_info = VpnConnectionInfo {
client_id: "client-1".to_string(),
assigned_ip: "10.8.0.2".to_string(),
transport_type: Some("wireguard".to_string()),
remote_addr: Some("198.51.100.10:51820".to_string()),
};
assert!(!RequestFilter::check_vpn_security(&security, Some(&vpn_info), Some("app.example.com")));
}
}