Compare commits

...

4 Commits

Author SHA1 Message Date
e988d935b6 v27.5.0
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-04-06 12:46:09 +00:00
99a026627d feat(security): add domain-scoped IP allow list support across HTTP and passthrough filtering 2026-04-06 12:46:09 +00:00
572e31587a v27.4.0
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-04-04 19:25:06 +00:00
8587fb997c feat(rustproxy): add HTTP/3 proxy service wiring for QUIC listeners 2026-04-04 19:25:06 +00:00
12 changed files with 267 additions and 58 deletions

View File

@@ -1,5 +1,18 @@
# Changelog
## 2026-04-06 - 27.5.0 - feat(security)
add domain-scoped IP allow list support across HTTP and passthrough filtering
- extend route security types to accept IP allow entries scoped to specific domains
- apply domain-aware IP checks using Host headers for HTTP and SNI context for QUIC and passthrough connections
- preserve compatibility for existing plain allow list entries and add validation and tests for scoped matching
## 2026-04-04 - 27.4.0 - feat(rustproxy)
add HTTP/3 proxy service wiring for QUIC listeners
- registers H3ProxyService with the UDP listener manager so QUIC connections can serve HTTP/3
- keeps proxy IP configuration intact while enabling HTTP/3 handling during listener setup
## 2026-04-04 - 27.3.1 - fix(metrics)
correct frontend and backend protocol connection tracking across h1, h2, h3, and websocket traffic

View File

@@ -1,6 +1,6 @@
{
"name": "@push.rocks/smartproxy",
"version": "27.3.1",
"version": "27.5.0",
"private": false,
"description": "A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.",
"main": "dist_ts/index.js",

View File

@@ -103,14 +103,30 @@ pub struct JwtAuthConfig {
pub exclude_paths: Option<Vec<String>>,
}
/// An entry in the IP allow list: either a plain IP/CIDR string
/// or a domain-scoped entry that restricts the IP to specific domains.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum IpAllowEntry {
/// Plain IP/CIDR — allowed for all domains on this route
Plain(String),
/// Domain-scoped — allowed only when the requested domain matches
DomainScoped {
ip: String,
domains: Vec<String>,
},
}
/// Security options for routes.
/// Matches TypeScript: `IRouteSecurity`
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RouteSecurity {
/// IP addresses that are allowed to connect
/// IP addresses that are allowed to connect.
/// Entries can be plain strings (full route access) or objects with
/// `{ ip, domains }` to scope access to specific domains.
#[serde(skip_serializing_if = "Option::is_none")]
pub ip_allow_list: Option<Vec<String>>,
pub ip_allow_list: Option<Vec<IpAllowEntry>>,
/// IP addresses that are blocked from connecting
#[serde(skip_serializing_if = "Option::is_none")]
pub ip_block_list: Option<Vec<String>>,

View File

@@ -35,13 +35,17 @@ impl RequestFilter {
let client_ip = peer_addr.ip();
let request_path = req.uri().path();
// IP filter
// IP filter (domain-aware: extract Host header for domain-scoped entries)
if security.ip_allow_list.is_some() || security.ip_block_list.is_some() {
let allow = security.ip_allow_list.as_deref().unwrap_or(&[]);
let block = security.ip_block_list.as_deref().unwrap_or(&[]);
let filter = IpFilter::new(allow, block);
let normalized = IpFilter::normalize_ip(&client_ip);
if !filter.is_allowed(&normalized) {
let host = req.headers()
.get("host")
.and_then(|v| v.to_str().ok())
.map(|h| h.split(':').next().unwrap_or(h));
if !filter.is_allowed_for_domain(&normalized, host) {
return Some(error_response(StatusCode::FORBIDDEN, "Access denied"));
}
}
@@ -203,14 +207,15 @@ impl RequestFilter {
}
/// Check IP-based security (for use in passthrough / TCP-level connections).
/// `domain` is the SNI from the TLS handshake (if available) for domain-scoped filtering.
/// Returns true if allowed, false if blocked.
pub fn check_ip_security(security: &RouteSecurity, client_ip: &std::net::IpAddr) -> bool {
pub fn check_ip_security(security: &RouteSecurity, client_ip: &std::net::IpAddr, domain: Option<&str>) -> bool {
if security.ip_allow_list.is_some() || security.ip_block_list.is_some() {
let allow = security.ip_allow_list.as_deref().unwrap_or(&[]);
let block = security.ip_block_list.as_deref().unwrap_or(&[]);
let filter = IpFilter::new(allow, block);
let normalized = IpFilter::normalize_ip(client_ip);
filter.is_allowed(&normalized)
filter.is_allowed_for_domain(&normalized, domain)
} else {
true
}

View File

@@ -100,7 +100,7 @@ impl ConnectionRegistry {
let mut recycled = 0u64;
self.connections.retain(|_, entry| {
if entry.route_id.as_deref() == Some(route_id) {
if !RequestFilter::check_ip_security(new_security, &entry.source_ip) {
if !RequestFilter::check_ip_security(new_security, &entry.source_ip, entry.domain.as_deref()) {
info!(
"Terminating connection from {} — IP now blocked on route '{}'",
entry.source_ip, route_id

View File

@@ -409,10 +409,10 @@ pub async fn quic_accept_loop(
}
};
// Check route-level IP security (previously missing for QUIC)
// Check route-level IP security for QUIC (domain from SNI context)
if let Some(ref security) = route.security {
if !rustproxy_http::request_filter::RequestFilter::check_ip_security(
security, &ip,
security, &ip, ctx.domain,
) {
debug!("QUIC connection from {} blocked by route security", real_addr);
continue;

View File

@@ -737,10 +737,10 @@ impl TcpListenerManager {
},
);
// Check route-level IP security
// Check route-level IP security (fast path: no SNI available)
if let Some(ref security) = quick_match.route.security {
if !rustproxy_http::request_filter::RequestFilter::check_ip_security(
security, &peer_addr.ip(),
security, &peer_addr.ip(), None,
) {
warn!("Connection from {} blocked by route security", peer_addr);
return Ok(());
@@ -929,11 +929,12 @@ impl TcpListenerManager {
},
);
// Check route-level IP security for passthrough connections
// Check route-level IP security for passthrough connections (SNI available)
if let Some(ref security) = route_match.route.security {
if !rustproxy_http::request_filter::RequestFilter::check_ip_security(
security,
&peer_addr.ip(),
domain.as_deref(),
) {
warn!("Connection from {} blocked by route security", peer_addr);
return Ok(());

View File

@@ -2,12 +2,24 @@ use ipnet::IpNet;
use std::net::IpAddr;
use std::str::FromStr;
use rustproxy_config::IpAllowEntry;
/// IP filter supporting CIDR ranges, wildcards, and exact matches.
/// Supports domain-scoped allow entries that restrict an IP to specific domains.
pub struct IpFilter {
/// Plain allow entries — IP allowed for any domain on the route
allow_list: Vec<IpPattern>,
/// Domain-scoped allow entries — IP allowed only for matching domains
domain_scoped: Vec<DomainScopedEntry>,
block_list: Vec<IpPattern>,
}
/// A domain-scoped allow entry: IP + list of allowed domain patterns.
struct DomainScopedEntry {
pattern: IpPattern,
domains: Vec<String>,
}
/// Represents an IP pattern for matching.
#[derive(Debug)]
enum IpPattern {
@@ -31,10 +43,6 @@ impl IpPattern {
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())
}
@@ -48,19 +56,56 @@ impl IpPattern {
}
}
/// Simple domain pattern matching (exact, `*`, or `*.suffix`).
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..]; // e.g., ".abc.xyz"
d.len() > suffix.len()
&& d[d.len() - suffix.len()..].eq_ignore_ascii_case(suffix)
} else {
false
}
}
impl IpFilter {
/// Create a new IP filter from allow and block lists.
pub fn new(allow_list: &[String], block_list: &[String]) -> Self {
/// Create a new IP filter from allow entries and a block list.
pub fn new(allow_entries: &[IpAllowEntry], block_list: &[String]) -> Self {
let mut allow_list = Vec::new();
let mut domain_scoped = Vec::new();
for entry in allow_entries {
match entry {
IpAllowEntry::Plain(ip) => {
allow_list.push(IpPattern::parse(ip));
}
IpAllowEntry::DomainScoped { ip, domains } => {
domain_scoped.push(DomainScopedEntry {
pattern: IpPattern::parse(ip),
domains: domains.clone(),
});
}
}
}
Self {
allow_list: allow_list.iter().map(|s| IpPattern::parse(s)).collect(),
allow_list,
domain_scoped,
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 if an IP is allowed, considering domain-scoped entries.
/// If `domain` is Some, domain-scoped entries are evaluated against it.
/// If `domain` is None, only plain allow entries are considered.
pub fn is_allowed_for_domain(&self, ip: &IpAddr, domain: Option<&str>) -> bool {
// Check block list first
if !self.block_list.is_empty() {
for pattern in &self.block_list {
@@ -70,14 +115,36 @@ impl IpFilter {
}
}
// 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));
// If there are any allow entries (plain or domain-scoped), IP must match
let has_any_allow = !self.allow_list.is_empty() || !self.domain_scoped.is_empty();
if has_any_allow {
// Check plain allow list — grants access to entire route
if self.allow_list.iter().any(|p| p.matches(ip)) {
return true;
}
// Check domain-scoped entries — grants access only if domain matches
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)) {
return true;
}
}
}
}
return false;
}
true
}
/// Check if an IP is allowed (backwards-compat wrapper, no domain context).
pub fn is_allowed(&self, ip: &IpAddr) -> bool {
self.is_allowed_for_domain(ip, None)
}
/// Normalize IPv4-mapped IPv6 addresses (::ffff:x.x.x.x -> x.x.x.x)
pub fn normalize_ip(ip: &IpAddr) -> IpAddr {
match ip {
@@ -97,19 +164,28 @@ impl IpFilter {
mod tests {
use super::*;
fn plain(s: &str) -> IpAllowEntry {
IpAllowEntry::Plain(s.to_string())
}
fn scoped(ip: &str, domains: &[&str]) -> IpAllowEntry {
IpAllowEntry::DomainScoped {
ip: ip.to_string(),
domains: domains.iter().map(|s| s.to_string()).collect(),
}
}
#[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));
assert!(filter.is_allowed_for_domain(&ip, Some("example.com")));
}
#[test]
fn test_allow_list_exact() {
let filter = IpFilter::new(
&["10.0.0.1".to_string()],
&[],
);
fn test_plain_allow_list_exact() {
let filter = IpFilter::new(&[plain("10.0.0.1")], &[]);
let allowed: IpAddr = "10.0.0.1".parse().unwrap();
let denied: IpAddr = "10.0.0.2".parse().unwrap();
assert!(filter.is_allowed(&allowed));
@@ -117,11 +193,8 @@ mod tests {
}
#[test]
fn test_allow_list_cidr() {
let filter = IpFilter::new(
&["10.0.0.0/8".to_string()],
&[],
);
fn test_plain_allow_list_cidr() {
let filter = IpFilter::new(&[plain("10.0.0.0/8")], &[]);
let allowed: IpAddr = "10.255.255.255".parse().unwrap();
let denied: IpAddr = "192.168.1.1".parse().unwrap();
assert!(filter.is_allowed(&allowed));
@@ -130,10 +203,7 @@ mod tests {
#[test]
fn test_block_list() {
let filter = IpFilter::new(
&[],
&["192.168.1.100".to_string()],
);
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));
@@ -143,7 +213,7 @@ mod tests {
#[test]
fn test_block_trumps_allow() {
let filter = IpFilter::new(
&["10.0.0.0/8".to_string()],
&[plain("10.0.0.0/8")],
&["10.0.0.5".to_string()],
);
let blocked: IpAddr = "10.0.0.5".parse().unwrap();
@@ -154,20 +224,14 @@ mod tests {
#[test]
fn test_wildcard_allow() {
let filter = IpFilter::new(
&["*".to_string()],
&[],
);
let filter = IpFilter::new(&[plain("*")], &[]);
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 filter = IpFilter::new(&[], &["*".to_string()]);
let ip: IpAddr = "1.2.3.4".parse().unwrap();
assert!(!filter.is_allowed(&ip));
}
@@ -186,4 +250,97 @@ mod tests {
let normalized = IpFilter::normalize_ip(&ip);
assert_eq!(normalized, ip);
}
// Domain-scoped tests
#[test]
fn test_domain_scoped_allows_matching_domain() {
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 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 ip: IpAddr = "10.8.0.2".parse().unwrap();
// Without domain context, domain-scoped entries cannot match
assert!(!filter.is_allowed_for_domain(&ip, None));
}
#[test]
fn test_domain_scoped_wildcard_domain() {
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")));
assert!(!filter.is_allowed_for_domain(&ip, Some("other.com")));
}
#[test]
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
],
&[],
);
let admin: IpAddr = "1.2.3.4".parse().unwrap();
let vpn: IpAddr = "10.8.0.2".parse().unwrap();
let other: IpAddr = "9.9.9.9".parse().unwrap();
// Admin IP has full access
assert!(filter.is_allowed_for_domain(&admin, Some("anything.abc.xyz")));
assert!(filter.is_allowed_for_domain(&admin, Some("outline.abc.xyz")));
// VPN IP only has scoped access
assert!(filter.is_allowed_for_domain(&vpn, Some("outline.abc.xyz")));
assert!(!filter.is_allowed_for_domain(&vpn, Some("app.abc.xyz")));
// Unknown IP denied
assert!(!filter.is_allowed_for_domain(&other, Some("outline.abc.xyz")));
}
#[test]
fn test_block_trumps_domain_scoped() {
let filter = IpFilter::new(
&[scoped("10.8.0.2", &["outline.abc.xyz"])],
&["10.8.0.2".to_string()],
);
let ip: IpAddr = "10.8.0.2".parse().unwrap();
assert!(!filter.is_allowed_for_domain(&ip, Some("outline.abc.xyz")));
}
#[test]
fn test_domain_matches_pattern_fn() {
assert!(domain_matches_pattern("example.com", "example.com"));
assert!(domain_matches_pattern("*.abc.xyz", "outline.abc.xyz"));
assert!(domain_matches_pattern("*.abc.xyz", "app.abc.xyz"));
assert!(!domain_matches_pattern("*.abc.xyz", "abc.xyz")); // suffix only, not exact parent
assert!(domain_matches_pattern("*", "anything.com"));
assert!(!domain_matches_pattern("outline.abc.xyz", "app.abc.xyz"));
// Case insensitive
assert!(domain_matches_pattern("*.ABC.XYZ", "outline.abc.xyz"));
}
}

View File

@@ -198,7 +198,9 @@ impl RustProxy {
};
if let Some(ref allow_list) = default_security.ip_allow_list {
security.ip_allow_list = Some(allow_list.clone());
security.ip_allow_list = Some(
allow_list.iter().map(|s| rustproxy_config::IpAllowEntry::Plain(s.clone())).collect()
);
}
if let Some(ref block_list) = default_security.ip_block_list {
security.ip_block_list = Some(block_list.clone());
@@ -845,6 +847,10 @@ impl RustProxy {
connection_registry,
);
udp_mgr.set_proxy_ips(conn_config.proxy_ips);
// Wire up H3ProxyService so QUIC connections can serve HTTP/3
let http_proxy = listener.http_proxy().clone();
let h3_svc = rustproxy_http::h3_service::H3ProxyService::new(http_proxy);
udp_mgr.set_h3_service(std::sync::Arc::new(h3_svc));
self.udp_listener_manager = Some(udp_mgr);
}
}

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartproxy',
version: '27.3.1',
version: '27.5.0',
description: 'A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.'
}

View File

@@ -141,8 +141,10 @@ export interface IRouteAuthentication {
* Security options for routes
*/
export interface IRouteSecurity {
// Access control lists
ipAllowList?: string[]; // IP addresses that are allowed to connect
// Access control lists.
// Entries can be plain IP/CIDR strings (full route access) or
// objects { ip, domains } to scope access to specific domains on this route.
ipAllowList?: Array<string | { ip: string; domains: string[] }>;
ipBlockList?: string[]; // IP addresses that are blocked from connecting
// Connection limits

View File

@@ -196,10 +196,19 @@ export class RouteValidator {
// Validate IP allow/block lists
if (route.security.ipAllowList) {
const allowList = Array.isArray(route.security.ipAllowList) ? route.security.ipAllowList : [route.security.ipAllowList];
for (const ip of allowList) {
if (!this.isValidIPPattern(ip)) {
errors.push(`Invalid IP pattern in allow list: ${ip}`);
for (const entry of allowList) {
if (typeof entry === 'string') {
if (!this.isValidIPPattern(entry)) {
errors.push(`Invalid IP pattern in allow list: ${entry}`);
}
} else if (entry && typeof entry === 'object') {
if (!this.isValidIPPattern(entry.ip)) {
errors.push(`Invalid IP pattern in domain-scoped allow entry: ${entry.ip}`);
}
if (!Array.isArray(entry.domains) || entry.domains.length === 0) {
errors.push(`Domain-scoped allow entry for ${entry.ip} must have non-empty domains array`);
}
}
}
}