mirror of
https://github.com/cfal/shoes.git
synced 2026-11-07 00:13:35 +00:00
2295 lines
84 KiB
Rust
2295 lines
84 KiB
Rust
use log::{debug, error};
|
|
use lru::LruCache;
|
|
use parking_lot::RwLock;
|
|
use std::hash::Hash;
|
|
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
|
use std::num::NonZeroUsize;
|
|
use std::sync::atomic::{AtomicU32, Ordering};
|
|
use std::sync::Arc;
|
|
|
|
use crate::address::{Address, NetLocation};
|
|
use crate::address::{AddressMask, NetLocationMask};
|
|
use crate::option_util::OneOrSome;
|
|
use crate::resolver::{resolve_single_address, Resolver};
|
|
|
|
/// Cache key for routing decisions.
|
|
/// We cache based on the destination address and port.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
enum RoutingCacheKey {
|
|
/// Hostname destination (e.g., "example.com:443")
|
|
Hostname { hostname: String, port: u16 },
|
|
/// IPv4 destination
|
|
Ipv4 { addr: Ipv4Addr, port: u16 },
|
|
/// IPv6 destination
|
|
Ipv6 { addr: Ipv6Addr, port: u16 },
|
|
}
|
|
|
|
impl RoutingCacheKey {
|
|
fn from_location(location: &NetLocation) -> Self {
|
|
match location.address() {
|
|
Address::Hostname(hostname) => RoutingCacheKey::Hostname {
|
|
hostname: hostname.to_lowercase(),
|
|
port: location.port(),
|
|
},
|
|
Address::Ipv4(addr) => RoutingCacheKey::Ipv4 {
|
|
addr: *addr,
|
|
port: location.port(),
|
|
},
|
|
Address::Ipv6(addr) => RoutingCacheKey::Ipv6 {
|
|
addr: *addr,
|
|
port: location.port(),
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Cached routing decision.
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub(crate) enum CachedDecision {
|
|
/// Index into the rules Vec for an Allow decision
|
|
Allow(usize),
|
|
/// Block decision
|
|
Block,
|
|
}
|
|
|
|
/// LRU cache for routing decisions.
|
|
/// Uses RwLock for concurrent access - reads use peek() which doesn't require mutable access.
|
|
pub struct RoutingCache {
|
|
inner: RwLock<LruCache<RoutingCacheKey, CachedDecision>>,
|
|
}
|
|
|
|
impl std::fmt::Debug for RoutingCache {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
let cache = self.inner.read();
|
|
f.debug_struct("RoutingCache")
|
|
.field("len", &cache.len())
|
|
.field("cap", &cache.cap())
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl RoutingCache {
|
|
/// Default cache capacity
|
|
pub const DEFAULT_CAPACITY: usize = 10_000;
|
|
|
|
/// Create a new routing cache with the specified capacity.
|
|
pub fn new(capacity: usize) -> Self {
|
|
let cap = NonZeroUsize::new(capacity).unwrap_or(NonZeroUsize::new(1).unwrap());
|
|
Self {
|
|
inner: RwLock::new(LruCache::new(cap)),
|
|
}
|
|
}
|
|
|
|
/// Look up a cached decision.
|
|
/// Uses peek() which doesn't update LRU order (avoids write lock).
|
|
#[inline]
|
|
pub fn get(&self, location: &NetLocation) -> Option<CachedDecision> {
|
|
let key = RoutingCacheKey::from_location(location);
|
|
self.inner.read().peek(&key).copied()
|
|
}
|
|
|
|
/// Insert a decision into the cache.
|
|
#[inline]
|
|
pub fn insert(&self, location: &NetLocation, decision: CachedDecision) {
|
|
let key = RoutingCacheKey::from_location(location);
|
|
self.inner.write().put(key, decision);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn clear(&self) {
|
|
self.inner.write().clear();
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn len(&self) -> usize {
|
|
self.inner.read().len()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn is_empty(&self) -> bool {
|
|
self.inner.read().is_empty()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct ConnectRule<T> {
|
|
pub masks: Vec<NetLocationMask>,
|
|
pub action: ConnectAction<T>,
|
|
}
|
|
|
|
impl<T> ConnectRule<T> {
|
|
pub fn new(masks: Vec<NetLocationMask>, action: ConnectAction<T>) -> Self {
|
|
Self { masks, action }
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum ConnectAction<T> {
|
|
Allow {
|
|
override_address: Option<NetLocation>,
|
|
client_proxies: OneOrSome<T>,
|
|
next_proxy_index: AtomicU32,
|
|
},
|
|
Block,
|
|
}
|
|
|
|
impl<T> ConnectAction<T> {
|
|
pub fn new_allow(override_address: Option<NetLocation>, client_proxies: OneOrSome<T>) -> Self {
|
|
ConnectAction::Allow {
|
|
override_address,
|
|
client_proxies,
|
|
next_proxy_index: AtomicU32::new(0),
|
|
}
|
|
}
|
|
|
|
pub fn new_block() -> Self {
|
|
ConnectAction::Block
|
|
}
|
|
|
|
pub fn to_decision(&self, target_location: NetLocation) -> ConnectDecision<'_, T> {
|
|
match self {
|
|
ConnectAction::Allow {
|
|
override_address,
|
|
client_proxies,
|
|
next_proxy_index,
|
|
} => {
|
|
let client_proxy = match client_proxies {
|
|
OneOrSome::One(item) => item,
|
|
OneOrSome::Some(v) => select_proxy(v, next_proxy_index),
|
|
};
|
|
|
|
ConnectDecision::Allow {
|
|
client_proxy,
|
|
remote_location: match override_address {
|
|
Some(l) => {
|
|
if l.port() > 0 {
|
|
l.clone()
|
|
} else {
|
|
// If port of 0 is specified for the replacement location,
|
|
// take the requested port.
|
|
NetLocation::new(l.address().clone(), target_location.port())
|
|
}
|
|
}
|
|
None => target_location,
|
|
},
|
|
}
|
|
}
|
|
ConnectAction::Block => ConnectDecision::Block,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Threshold for enabling cache based on rule count.
|
|
/// If rule count exceeds this value, caching is enabled even without DNS resolution.
|
|
const CACHE_RULE_THRESHOLD: usize = 16;
|
|
|
|
// TODO: Replace linear rule matching with radix set/trie
|
|
#[derive(Debug)]
|
|
pub struct ClientProxySelector<T> {
|
|
rules: Vec<ConnectRule<T>>,
|
|
default_rule_index: Option<usize>,
|
|
/// If false, hostname rules will not trigger DNS resolution to match against IP-based
|
|
/// destinations. This is useful when a huge blocklist or rule list is provided.
|
|
/// However, this means that the user needs to make sure DNS resolutions are not done
|
|
/// manually before hitting the server. Default is false (don't resolve).
|
|
resolve_rule_hostnames: bool,
|
|
/// LRU cache for routing decisions. Speeds up repeated lookups for the same destination.
|
|
/// None if caching is disabled (few rules and no DNS resolution).
|
|
cache: Option<RoutingCache>,
|
|
}
|
|
|
|
unsafe impl<T: Send> Send for ClientProxySelector<T> {}
|
|
unsafe impl<T: Sync> Sync for ClientProxySelector<T> {}
|
|
|
|
#[derive(Debug)]
|
|
pub enum ConnectDecision<'a, T> {
|
|
Allow {
|
|
client_proxy: &'a T,
|
|
remote_location: NetLocation,
|
|
},
|
|
Block,
|
|
}
|
|
|
|
impl<T> ClientProxySelector<T> {
|
|
pub fn new(rules: Vec<ConnectRule<T>>) -> Self {
|
|
Self::with_options(rules, false)
|
|
}
|
|
|
|
/// Create a new ClientProxySelector with configurable hostname resolution behavior.
|
|
///
|
|
/// # Arguments
|
|
/// * `rules` - The list of routing rules
|
|
/// * `resolve_rule_hostnames` - If true, hostname rules will be resolved via DNS to match
|
|
/// against IP-based destinations. If false (default), hostname rules only match hostname
|
|
/// destinations directly. Setting this to false is more performant for large rule sets.
|
|
pub fn with_options(rules: Vec<ConnectRule<T>>, resolve_rule_hostnames: bool) -> Self {
|
|
Self::with_options_and_cache_size(
|
|
rules,
|
|
resolve_rule_hostnames,
|
|
RoutingCache::DEFAULT_CAPACITY,
|
|
)
|
|
}
|
|
|
|
/// Create a new ClientProxySelector with configurable hostname resolution and cache size.
|
|
///
|
|
/// # Arguments
|
|
/// * `rules` - The list of routing rules
|
|
/// * `resolve_rule_hostnames` - If true, hostname rules will be resolved via DNS to match
|
|
/// against IP-based destinations. If false (default), hostname rules only match hostname
|
|
/// destinations directly.
|
|
/// * `cache_capacity` - Maximum number of routing decisions to cache. Set to 0 to disable caching.
|
|
///
|
|
/// # Caching behavior
|
|
/// Caching is automatically enabled when:
|
|
/// - `resolve_rule_hostnames` is true (DNS lookups are expensive), OR
|
|
/// - Rule count exceeds `CACHE_RULE_THRESHOLD` (16)
|
|
///
|
|
/// For simple configurations with few rules and no DNS resolution, caching is disabled
|
|
/// as the overhead of cache key construction exceeds the cost of linear rule matching.
|
|
pub fn with_options_and_cache_size(
|
|
rules: Vec<ConnectRule<T>>,
|
|
resolve_rule_hostnames: bool,
|
|
cache_capacity: usize,
|
|
) -> Self {
|
|
let mut default_rule_index: Option<usize> = None;
|
|
// find a default rule which we'll use for multidirectional forwarding..
|
|
// TODO: ideally, we'd check the rule for each target during multidirectional forwarding
|
|
// and use the right one instead.
|
|
for (i, rule) in rules.iter().enumerate() {
|
|
// if it allows forwarding only to a single address, we don't want to use that as
|
|
// the default decision.
|
|
if let ConnectAction::Allow {
|
|
ref override_address,
|
|
..
|
|
} = rule.action
|
|
{
|
|
if override_address.is_some() {
|
|
continue;
|
|
}
|
|
}
|
|
let is_cover_rule = rule.masks.iter().any(|mask| mask.address_mask.netmask == 0);
|
|
if is_cover_rule {
|
|
default_rule_index = Some(i);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Enable caching if:
|
|
// 1. DNS resolution is enabled (expensive operation), OR
|
|
// 2. Many rules (linear scan becomes expensive)
|
|
let cache = if resolve_rule_hostnames || rules.len() > CACHE_RULE_THRESHOLD {
|
|
Some(RoutingCache::new(cache_capacity.max(1)))
|
|
} else {
|
|
None
|
|
};
|
|
|
|
Self {
|
|
rules,
|
|
default_rule_index,
|
|
resolve_rule_hostnames,
|
|
cache,
|
|
}
|
|
}
|
|
|
|
pub fn default_decision(&self) -> ConnectDecision<'_, T> {
|
|
match self.default_rule_index {
|
|
Some(i) => {
|
|
let rule = &self.rules[i];
|
|
// the remote location is unused because we don't choose a default rule with
|
|
// an override_address, so just pass a port of 0.
|
|
rule.action.to_decision(NetLocation::UNSPECIFIED)
|
|
}
|
|
None => ConnectDecision::Block,
|
|
}
|
|
}
|
|
|
|
/// Judge a connection request, using the cache for faster repeated lookups.
|
|
///
|
|
/// This is the primary method for routing decisions. It first checks the cache,
|
|
/// and only performs the full rule matching if there's a cache miss.
|
|
///
|
|
/// Note: Caching is only enabled when `resolve_rule_hostnames` is true or there are
|
|
/// more than 16 rules. For simple configurations, direct rule matching is faster.
|
|
pub async fn judge<'a>(
|
|
&'a self,
|
|
location: NetLocation,
|
|
resolver: &Arc<dyn Resolver>,
|
|
) -> std::io::Result<ConnectDecision<'a, T>> {
|
|
// If caching is disabled, go directly to rule matching
|
|
let cache = match &self.cache {
|
|
Some(c) => c,
|
|
None => return self.judge_uncached(location, resolver).await,
|
|
};
|
|
|
|
// Fast path: check cache first
|
|
if let Some(cached) = cache.get(&location) {
|
|
return Ok(self.cached_to_decision(cached, location));
|
|
}
|
|
|
|
// Slow path: full rule matching
|
|
match match_rule(
|
|
&self.rules,
|
|
&location,
|
|
resolver,
|
|
self.resolve_rule_hostnames,
|
|
)
|
|
.await?
|
|
{
|
|
Some((rule_index, rule)) => {
|
|
// Cache the result
|
|
cache.insert(&location, CachedDecision::Allow(rule_index));
|
|
Ok(rule.action.to_decision(location))
|
|
}
|
|
None => {
|
|
// Cache the block decision
|
|
cache.insert(&location, CachedDecision::Block);
|
|
Ok(ConnectDecision::Block)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Judge without using the cache. Useful for testing or when cache bypass is needed.
|
|
pub async fn judge_uncached<'a>(
|
|
&'a self,
|
|
location: NetLocation,
|
|
resolver: &Arc<dyn Resolver>,
|
|
) -> std::io::Result<ConnectDecision<'a, T>> {
|
|
match match_rule(
|
|
&self.rules,
|
|
&location,
|
|
resolver,
|
|
self.resolve_rule_hostnames,
|
|
)
|
|
.await?
|
|
{
|
|
Some((_rule_index, rule)) => Ok(rule.action.to_decision(location)),
|
|
None => Ok(ConnectDecision::Block),
|
|
}
|
|
}
|
|
|
|
/// Convert a cached decision back to a ConnectDecision.
|
|
#[inline]
|
|
fn cached_to_decision(
|
|
&self,
|
|
cached: CachedDecision,
|
|
location: NetLocation,
|
|
) -> ConnectDecision<'_, T> {
|
|
match cached {
|
|
CachedDecision::Allow(rule_index) => {
|
|
self.rules[rule_index].action.to_decision(location)
|
|
}
|
|
CachedDecision::Block => ConnectDecision::Block,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn cache_size(&self) -> usize {
|
|
self.cache.as_ref().map_or(0, |c| c.len())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn clear_cache(&self) {
|
|
if let Some(cache) = &self.cache {
|
|
cache.clear();
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn is_cache_enabled(&self) -> bool {
|
|
self.cache.is_some()
|
|
}
|
|
}
|
|
|
|
#[inline]
|
|
fn select_proxy<'a, T>(proxy_list: &'a [T], index: &'a AtomicU32) -> &'a T {
|
|
match proxy_list.len() {
|
|
0 => {
|
|
panic!("Empty proxy list");
|
|
}
|
|
1 => &proxy_list[0],
|
|
_ => {
|
|
let proxy_index = index.fetch_add(1, Ordering::Relaxed) as usize;
|
|
&proxy_list[proxy_index % proxy_list.len()]
|
|
}
|
|
}
|
|
}
|
|
|
|
#[inline]
|
|
fn ip_to_u128(ip: IpAddr) -> u128 {
|
|
match ip {
|
|
IpAddr::V4(addr) => ipv4_to_u128(addr),
|
|
IpAddr::V6(addr) => ipv6_to_u128(addr),
|
|
}
|
|
}
|
|
|
|
#[inline]
|
|
fn ipv4_to_u128(ip: Ipv4Addr) -> u128 {
|
|
ipv6_to_u128(ip.to_ipv6_mapped())
|
|
}
|
|
|
|
#[inline]
|
|
fn ipv6_to_u128(ip: Ipv6Addr) -> u128 {
|
|
u128::from(ip)
|
|
}
|
|
|
|
#[inline]
|
|
fn matches_domain(base_domain: &str, hostname: &str) -> bool {
|
|
if hostname.ends_with(base_domain) {
|
|
let hostname_len = hostname.len();
|
|
let base_domain_len = base_domain.len();
|
|
if hostname_len == base_domain_len {
|
|
true
|
|
} else {
|
|
// hostname_len > base_domain_len since hostname ends with base_domain.
|
|
hostname.as_bytes()[hostname_len - base_domain_len - 1] == b'.'
|
|
}
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
/// Returns the matching rule and its index in the rules Vec.
|
|
#[inline]
|
|
async fn match_rule<'a, T>(
|
|
rules: &'a [ConnectRule<T>],
|
|
location: &NetLocation,
|
|
resolver: &Arc<dyn Resolver>,
|
|
resolve_rule_hostnames: bool,
|
|
) -> std::io::Result<Option<(usize, &'a ConnectRule<T>)>> {
|
|
// We only resolve when necessary.
|
|
let mut resolved_ip: Option<u128> = None;
|
|
|
|
for (rule_index, rule) in rules.iter().enumerate() {
|
|
for mask in rule.masks.iter() {
|
|
match match_mask(
|
|
mask,
|
|
location,
|
|
&mut resolved_ip,
|
|
resolver,
|
|
resolve_rule_hostnames,
|
|
)
|
|
.await
|
|
{
|
|
Ok(is_match) => {
|
|
if is_match {
|
|
debug!("Found matching mask for {location} -> {mask:?}");
|
|
return Ok(Some((rule_index, rule)));
|
|
}
|
|
}
|
|
Err(MatchMaskError::Fatal(e)) => {
|
|
return Err(std::io::Error::other(format!(
|
|
"fatal error while matching mask for {location}: {e}"
|
|
)));
|
|
}
|
|
Err(MatchMaskError::NonFatal(e)) => {
|
|
error!("Non-fatal error while trying to match mask for {location}: {e}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(None)
|
|
}
|
|
|
|
enum MatchMaskError {
|
|
NonFatal(std::io::Error),
|
|
Fatal(std::io::Error),
|
|
}
|
|
|
|
// Helper function for testing - exposed for unit tests
|
|
#[cfg(test)]
|
|
pub fn matches_domain_for_test(base_domain: &str, hostname: &str) -> bool {
|
|
matches_domain(base_domain, hostname)
|
|
}
|
|
|
|
#[inline]
|
|
async fn match_mask(
|
|
location_mask: &NetLocationMask,
|
|
location: &NetLocation,
|
|
resolved_ip: &mut Option<u128>,
|
|
resolver: &Arc<dyn Resolver>,
|
|
resolve_rule_hostnames: bool,
|
|
) -> std::result::Result<bool, MatchMaskError> {
|
|
let NetLocationMask {
|
|
address_mask: AddressMask { address, netmask },
|
|
port,
|
|
} = location_mask;
|
|
|
|
let netmask = *netmask;
|
|
let port = *port;
|
|
|
|
if port > 0 && port != location.port() {
|
|
return Ok(false);
|
|
}
|
|
|
|
if netmask == 0 {
|
|
return Ok(true);
|
|
}
|
|
|
|
if let Some(hostname) = address.hostname() {
|
|
if let Some(remote_hostname) = location.address().hostname() {
|
|
return Ok(matches_domain(hostname, remote_hostname));
|
|
}
|
|
|
|
// We don't care about netmasks when hostnames are provided, so we can do direct matching
|
|
// without resolving when both the remote location and the provided rule address are hostnames,
|
|
// and simply return if it doesn't match.
|
|
if !resolve_rule_hostnames {
|
|
return Ok(false);
|
|
}
|
|
}
|
|
|
|
let masked_ip = match resolved_ip {
|
|
Some(ip) => *ip,
|
|
None => {
|
|
// fatal error if the destination we are trying to get to cannot be resolved.
|
|
let socket_addr = resolve_single_address(resolver, location)
|
|
.await
|
|
.map_err(MatchMaskError::Fatal)?;
|
|
let ip = ip_to_u128(socket_addr.ip());
|
|
resolved_ip.replace(ip);
|
|
ip
|
|
}
|
|
} & netmask;
|
|
|
|
match address {
|
|
Address::Ipv4(ip_addr) => {
|
|
let mask = ipv4_to_u128(*ip_addr) & netmask;
|
|
if mask == masked_ip {
|
|
return Ok(true);
|
|
}
|
|
}
|
|
Address::Ipv6(ip_addr) => {
|
|
let mask = ipv6_to_u128(*ip_addr) & netmask;
|
|
if mask == masked_ip {
|
|
return Ok(true);
|
|
}
|
|
}
|
|
Address::Hostname(_) => {
|
|
// non-fatal error when the rule address cannot be resolved.
|
|
// TODO: could this be cached?
|
|
let socket_addrs = resolver
|
|
.resolve_location(&NetLocation::new(address.clone(), port))
|
|
.await
|
|
.map_err(MatchMaskError::NonFatal)?;
|
|
for socket_addr in socket_addrs {
|
|
let mask = ip_to_u128(socket_addr.ip()) & netmask;
|
|
if mask == masked_ip {
|
|
return Ok(true);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(false)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::resolver::Resolver;
|
|
use std::future::Future;
|
|
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
|
|
use std::pin::Pin;
|
|
|
|
// =========================================================================
|
|
// Test Infrastructure
|
|
// =========================================================================
|
|
|
|
/// A mock resolver for testing that returns predefined results
|
|
#[derive(Debug)]
|
|
struct MockResolver {
|
|
mappings: std::collections::HashMap<String, Vec<SocketAddr>>,
|
|
}
|
|
|
|
impl MockResolver {
|
|
fn new() -> Self {
|
|
Self {
|
|
mappings: std::collections::HashMap::new(),
|
|
}
|
|
}
|
|
|
|
fn with_mapping(mut self, hostname: &str, port: u16, addrs: Vec<IpAddr>) -> Self {
|
|
let key = format!("{}:{}", hostname, port);
|
|
let socket_addrs: Vec<SocketAddr> = addrs
|
|
.into_iter()
|
|
.map(|ip| SocketAddr::new(ip, port))
|
|
.collect();
|
|
self.mappings.insert(key, socket_addrs);
|
|
self
|
|
}
|
|
}
|
|
|
|
impl Resolver for MockResolver {
|
|
fn resolve_location(
|
|
&self,
|
|
location: &NetLocation,
|
|
) -> Pin<Box<dyn Future<Output = std::io::Result<Vec<SocketAddr>>> + Send>> {
|
|
let key = format!("{}:{}", location.address(), location.port());
|
|
let result = self.mappings.get(&key).cloned();
|
|
Box::pin(async move {
|
|
result.ok_or_else(|| {
|
|
std::io::Error::new(
|
|
std::io::ErrorKind::NotFound,
|
|
format!("No mock mapping for {}", key),
|
|
)
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Helper to create a mock resolver as Arc<dyn Resolver>
|
|
fn mock_resolver() -> Arc<dyn Resolver> {
|
|
Arc::new(MockResolver::new())
|
|
}
|
|
|
|
/// Helper to create a mock resolver with mappings as Arc<dyn Resolver>
|
|
#[allow(dead_code)]
|
|
fn mock_resolver_with_mappings(mappings: Vec<(&str, u16, Vec<IpAddr>)>) -> Arc<dyn Resolver> {
|
|
let mut resolver = MockResolver::new();
|
|
for (hostname, port, addrs) in mappings {
|
|
resolver = resolver.with_mapping(hostname, port, addrs);
|
|
}
|
|
Arc::new(resolver)
|
|
}
|
|
|
|
/// Simple proxy type for testing
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
struct TestProxy {
|
|
name: String,
|
|
}
|
|
|
|
impl TestProxy {
|
|
fn new(name: &str) -> Self {
|
|
Self {
|
|
name: name.to_string(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Helper to create an allow rule with a single proxy
|
|
fn allow_rule(masks: Vec<&str>, proxy_name: &str) -> ConnectRule<TestProxy> {
|
|
let masks: Vec<NetLocationMask> = masks
|
|
.into_iter()
|
|
.map(|s| NetLocationMask::from(s).unwrap())
|
|
.collect();
|
|
ConnectRule::new(
|
|
masks,
|
|
ConnectAction::new_allow(None, OneOrSome::One(TestProxy::new(proxy_name))),
|
|
)
|
|
}
|
|
|
|
/// Helper to create an allow rule with multiple proxies (for round-robin)
|
|
fn allow_rule_multi(masks: Vec<&str>, proxy_names: Vec<&str>) -> ConnectRule<TestProxy> {
|
|
let masks: Vec<NetLocationMask> = masks
|
|
.into_iter()
|
|
.map(|s| NetLocationMask::from(s).unwrap())
|
|
.collect();
|
|
let proxies: Vec<TestProxy> = proxy_names.into_iter().map(TestProxy::new).collect();
|
|
ConnectRule::new(
|
|
masks,
|
|
ConnectAction::new_allow(None, OneOrSome::Some(proxies)),
|
|
)
|
|
}
|
|
|
|
/// Helper to create a block rule
|
|
fn block_rule(masks: Vec<&str>) -> ConnectRule<TestProxy> {
|
|
let masks: Vec<NetLocationMask> = masks
|
|
.into_iter()
|
|
.map(|s| NetLocationMask::from(s).unwrap())
|
|
.collect();
|
|
ConnectRule::new(masks, ConnectAction::new_block())
|
|
}
|
|
|
|
/// Helper to create an allow rule with address override
|
|
fn allow_rule_with_override(
|
|
masks: Vec<&str>,
|
|
proxy_name: &str,
|
|
override_addr: &str,
|
|
) -> ConnectRule<TestProxy> {
|
|
let masks: Vec<NetLocationMask> = masks
|
|
.into_iter()
|
|
.map(|s| NetLocationMask::from(s).unwrap())
|
|
.collect();
|
|
let override_location = NetLocation::from_str(override_addr, Some(0)).unwrap();
|
|
ConnectRule::new(
|
|
masks,
|
|
ConnectAction::new_allow(
|
|
Some(override_location),
|
|
OneOrSome::One(TestProxy::new(proxy_name)),
|
|
),
|
|
)
|
|
}
|
|
|
|
// =========================================================================
|
|
// Domain Matching Tests (matches_domain function)
|
|
// =========================================================================
|
|
|
|
#[test]
|
|
fn test_matches_domain_exact_match() {
|
|
assert!(matches_domain_for_test("example.com", "example.com"));
|
|
assert!(matches_domain_for_test("foo.bar.com", "foo.bar.com"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_matches_domain_subdomain_match() {
|
|
assert!(matches_domain_for_test("example.com", "www.example.com"));
|
|
assert!(matches_domain_for_test(
|
|
"example.com",
|
|
"sub.www.example.com"
|
|
));
|
|
assert!(matches_domain_for_test("bar.com", "foo.bar.com"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_matches_domain_no_match_different_domain() {
|
|
assert!(!matches_domain_for_test("example.com", "notexample.com"));
|
|
assert!(!matches_domain_for_test("example.com", "example.org"));
|
|
assert!(!matches_domain_for_test("foo.com", "bar.com"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_matches_domain_no_match_partial_suffix() {
|
|
// "malicious-example.com" ends with "example.com" as a string,
|
|
// but it's NOT a subdomain - this is a critical security test
|
|
assert!(!matches_domain_for_test(
|
|
"example.com",
|
|
"malicious-example.com"
|
|
));
|
|
assert!(!matches_domain_for_test("example.com", "fakeexample.com"));
|
|
assert!(!matches_domain_for_test("bar.com", "foobar.com"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_matches_domain_no_match_base_longer() {
|
|
assert!(!matches_domain_for_test("www.example.com", "example.com"));
|
|
assert!(!matches_domain_for_test("sub.example.com", "example.com"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_matches_domain_empty_strings() {
|
|
assert!(!matches_domain_for_test("example.com", ""));
|
|
assert!(!matches_domain_for_test("", "example.com"));
|
|
assert!(matches_domain_for_test("", "")); // Both empty matches
|
|
}
|
|
|
|
#[test]
|
|
fn test_matches_domain_single_label() {
|
|
assert!(matches_domain_for_test("localhost", "localhost"));
|
|
assert!(!matches_domain_for_test("localhost", "notlocalhost"));
|
|
assert!(!matches_domain_for_test("host", "localhost")); // partial suffix
|
|
}
|
|
|
|
// =========================================================================
|
|
// IPv4 Address Matching Tests
|
|
// =========================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_ipv4_exact_match() {
|
|
let rules = vec![
|
|
allow_rule(vec!["192.168.1.1/32"], "proxy1"),
|
|
allow_rule(vec!["0.0.0.0/0"], "default"),
|
|
];
|
|
let selector = ClientProxySelector::new(rules);
|
|
let resolver = mock_resolver();
|
|
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 1)), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "proxy1");
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow, got Block"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_ipv4_exact_match_no_match() {
|
|
let rules = vec![
|
|
allow_rule(vec!["192.168.1.1/32"], "proxy1"),
|
|
allow_rule(vec!["0.0.0.0/0"], "default"),
|
|
];
|
|
let selector = ClientProxySelector::new(rules);
|
|
let resolver = mock_resolver();
|
|
|
|
// Different IP should fall through to default
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 2)), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "default");
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow, got Block"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_ipv4_cidr_24() {
|
|
let rules = vec![
|
|
allow_rule(vec!["192.168.1.0/24"], "lan"),
|
|
allow_rule(vec!["0.0.0.0/0"], "default"),
|
|
];
|
|
let selector = ClientProxySelector::new(rules);
|
|
let resolver = mock_resolver();
|
|
|
|
// All addresses in 192.168.1.0/24 should match
|
|
for last_octet in [0u8, 1, 100, 254, 255] {
|
|
let location =
|
|
NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, last_octet)), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(
|
|
client_proxy.name, "lan",
|
|
"Failed for 192.168.1.{}",
|
|
last_octet
|
|
);
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow for 192.168.1.{}", last_octet),
|
|
}
|
|
}
|
|
|
|
// Address outside the range should not match
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 2, 1)), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "default");
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_ipv4_cidr_16() {
|
|
let rules = vec![
|
|
allow_rule(vec!["10.0.0.0/8"], "private_a"),
|
|
allow_rule(vec!["172.16.0.0/12"], "private_b"),
|
|
allow_rule(vec!["192.168.0.0/16"], "private_c"),
|
|
allow_rule(vec!["0.0.0.0/0"], "default"),
|
|
];
|
|
let selector = ClientProxySelector::new(rules);
|
|
let resolver = mock_resolver();
|
|
|
|
// Test 10.0.0.0/8 range
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(10, 255, 255, 255)), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "private_a");
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
|
|
// Test 172.16.0.0/12 range (172.16.0.0 - 172.31.255.255)
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(172, 20, 5, 1)), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "private_b");
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
|
|
// Test 192.168.0.0/16 range
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 100, 50)), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "private_c");
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
|
|
// Public IP should go to default
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(8, 8, 8, 8)), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "default");
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_ipv4_localhost() {
|
|
let rules = vec![
|
|
allow_rule(vec!["127.0.0.0/8"], "loopback"),
|
|
allow_rule(vec!["0.0.0.0/0"], "default"),
|
|
];
|
|
let selector = ClientProxySelector::new(rules);
|
|
let resolver = mock_resolver();
|
|
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(127, 0, 0, 1)), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "loopback");
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
|
|
// 127.x.x.x should all match loopback
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(127, 255, 255, 255)), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "loopback");
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// IPv6 Address Matching Tests
|
|
// =========================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_ipv6_exact_match() {
|
|
let rules = vec![
|
|
allow_rule(vec!["::1/128"], "loopback"),
|
|
allow_rule(vec!["::/0"], "default"),
|
|
];
|
|
let selector = ClientProxySelector::new(rules);
|
|
let resolver = mock_resolver();
|
|
|
|
let location = NetLocation::new(Address::Ipv6(Ipv6Addr::LOCALHOST), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "loopback");
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_ipv6_cidr() {
|
|
let rules = vec![
|
|
allow_rule(vec!["fe80::/10"], "link_local"),
|
|
allow_rule(vec!["fc00::/7"], "unique_local"),
|
|
allow_rule(vec!["::/0"], "default"),
|
|
];
|
|
let selector = ClientProxySelector::new(rules);
|
|
let resolver = mock_resolver();
|
|
|
|
// Link-local address
|
|
let location = NetLocation::new(Address::Ipv6("fe80::1".parse().unwrap()), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "link_local");
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
|
|
// Unique local address (fc00::/7 covers fc00:: and fd00::)
|
|
let location = NetLocation::new(Address::Ipv6("fd00::1234".parse().unwrap()), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "unique_local");
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
|
|
// Global unicast should go to default
|
|
let location = NetLocation::new(Address::Ipv6("2001:db8::1".parse().unwrap()), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "default");
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Hostname/Domain Matching Tests
|
|
// =========================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_hostname_exact_match() {
|
|
let rules = vec![
|
|
allow_rule(vec!["example.com"], "example_proxy"),
|
|
allow_rule(vec!["0.0.0.0/0"], "default"),
|
|
];
|
|
let selector = ClientProxySelector::new(rules);
|
|
let resolver = mock_resolver();
|
|
|
|
let location = NetLocation::new(Address::Hostname("example.com".to_string()), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "example_proxy");
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_hostname_subdomain_match() {
|
|
let rules = vec![
|
|
allow_rule(vec!["example.com"], "example_proxy"),
|
|
allow_rule(vec!["0.0.0.0/0"], "default"),
|
|
];
|
|
let selector = ClientProxySelector::new(rules);
|
|
let resolver = mock_resolver();
|
|
|
|
// Subdomain should match
|
|
let location = NetLocation::new(Address::Hostname("www.example.com".to_string()), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "example_proxy");
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
|
|
// Deep subdomain should match
|
|
let location = NetLocation::new(Address::Hostname("sub.www.example.com".to_string()), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "example_proxy");
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_hostname_no_false_subdomain_match() {
|
|
// SECURITY CRITICAL: malicious-example.com should NOT match example.com rule
|
|
let rules = vec![
|
|
allow_rule(vec!["example.com"], "example_proxy"),
|
|
allow_rule(vec!["0.0.0.0/0"], "default"),
|
|
];
|
|
let selector = ClientProxySelector::new(rules);
|
|
let resolver = mock_resolver();
|
|
|
|
let location = NetLocation::new(Address::Hostname("malicious-example.com".to_string()), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(
|
|
client_proxy.name, "default",
|
|
"SECURITY: malicious-example.com should NOT match example.com rule"
|
|
);
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
|
|
let location = NetLocation::new(Address::Hostname("fakeexample.com".to_string()), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "default");
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_hostname_different_tld() {
|
|
let rules = vec![
|
|
allow_rule(vec!["example.com"], "example_proxy"),
|
|
allow_rule(vec!["0.0.0.0/0"], "default"),
|
|
];
|
|
let selector = ClientProxySelector::new(rules);
|
|
let resolver = mock_resolver();
|
|
|
|
// Different TLD should not match
|
|
let location = NetLocation::new(Address::Hostname("example.org".to_string()), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "default");
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Port Matching Tests
|
|
// =========================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_port_specific_rule() {
|
|
let rules = vec![
|
|
allow_rule(vec!["0.0.0.0/0:443"], "https_proxy"),
|
|
allow_rule(vec!["0.0.0.0/0:80"], "http_proxy"),
|
|
allow_rule(vec!["0.0.0.0/0"], "default"),
|
|
];
|
|
let selector = ClientProxySelector::new(rules);
|
|
let resolver = mock_resolver();
|
|
|
|
// Port 443 should match https_proxy
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(1, 2, 3, 4)), 443);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "https_proxy");
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
|
|
// Port 80 should match http_proxy
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(1, 2, 3, 4)), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "http_proxy");
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
|
|
// Other ports should go to default
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(1, 2, 3, 4)), 8080);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "default");
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_port_with_ip_restriction() {
|
|
let rules = vec![
|
|
allow_rule(vec!["192.168.1.0/24:22"], "ssh_lan"),
|
|
allow_rule(vec!["0.0.0.0/0:22"], "ssh_default"),
|
|
allow_rule(vec!["0.0.0.0/0"], "default"),
|
|
];
|
|
let selector = ClientProxySelector::new(rules);
|
|
let resolver = mock_resolver();
|
|
|
|
// LAN SSH should match ssh_lan
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 100)), 22);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "ssh_lan");
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
|
|
// External SSH should match ssh_default
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(8, 8, 8, 8)), 22);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "ssh_default");
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
|
|
// LAN HTTP should go to default (not ssh_lan because port doesn't match)
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 100)), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "default");
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Rule Priority Tests (First Match Wins)
|
|
// =========================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_first_rule_wins() {
|
|
let rules = vec![
|
|
allow_rule(vec!["192.168.1.0/24"], "specific"),
|
|
allow_rule(vec!["192.168.0.0/16"], "less_specific"),
|
|
allow_rule(vec!["0.0.0.0/0"], "default"),
|
|
];
|
|
let selector = ClientProxySelector::new(rules);
|
|
let resolver = mock_resolver();
|
|
|
|
// 192.168.1.x should match the first (more specific) rule
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 1)), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "specific");
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
|
|
// 192.168.2.x should match the second rule
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 2, 1)), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "less_specific");
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_rule_order_matters_security() {
|
|
// SECURITY: If a block rule comes after an allow rule for overlapping ranges,
|
|
// the allow rule takes precedence
|
|
let rules = vec![
|
|
allow_rule(vec!["192.168.1.0/24"], "allowed"),
|
|
block_rule(vec!["192.168.0.0/16"]),
|
|
allow_rule(vec!["0.0.0.0/0"], "default"),
|
|
];
|
|
let selector = ClientProxySelector::new(rules);
|
|
let resolver = mock_resolver();
|
|
|
|
// 192.168.1.x matches the allow rule first
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 1)), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "allowed");
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow for 192.168.1.1"),
|
|
}
|
|
|
|
// 192.168.2.x should be blocked
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 2, 1)), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { .. } => panic!("Expected Block for 192.168.2.1"),
|
|
ConnectDecision::Block => {} // Expected
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Block Action Tests
|
|
// =========================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_block_rule() {
|
|
let rules = vec![
|
|
block_rule(vec!["192.168.1.0/24"]),
|
|
allow_rule(vec!["0.0.0.0/0"], "default"),
|
|
];
|
|
let selector = ClientProxySelector::new(rules);
|
|
let resolver = mock_resolver();
|
|
|
|
// Blocked range
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 1)), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { .. } => panic!("Expected Block"),
|
|
ConnectDecision::Block => {} // Expected
|
|
}
|
|
|
|
// Non-blocked range
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(8, 8, 8, 8)), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "default");
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_block_hostname() {
|
|
let rules = vec![
|
|
block_rule(vec!["blocked.com"]),
|
|
allow_rule(vec!["0.0.0.0/0"], "default"),
|
|
];
|
|
let selector = ClientProxySelector::new(rules);
|
|
let resolver = mock_resolver();
|
|
|
|
// Blocked hostname
|
|
let location = NetLocation::new(Address::Hostname("blocked.com".to_string()), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { .. } => panic!("Expected Block for blocked.com"),
|
|
ConnectDecision::Block => {} // Expected
|
|
}
|
|
|
|
// Subdomain of blocked hostname should also be blocked
|
|
let location = NetLocation::new(Address::Hostname("www.blocked.com".to_string()), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { .. } => panic!("Expected Block for www.blocked.com"),
|
|
ConnectDecision::Block => {} // Expected
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Default Rule Tests
|
|
// =========================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_no_default_rule_blocks() {
|
|
let rules = vec![allow_rule(vec!["192.168.1.0/24"], "lan")];
|
|
let selector = ClientProxySelector::new(rules);
|
|
let resolver = mock_resolver();
|
|
|
|
// Non-matching address with no default rule should block
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(8, 8, 8, 8)), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { .. } => panic!("Expected Block when no default rule"),
|
|
ConnectDecision::Block => {} // Expected
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_default_decision() {
|
|
let rules = vec![
|
|
allow_rule(vec!["192.168.1.0/24"], "lan"),
|
|
allow_rule(vec!["0.0.0.0/0"], "default"),
|
|
];
|
|
let selector = ClientProxySelector::new(rules);
|
|
|
|
// default_decision should return the catch-all rule
|
|
let decision = selector.default_decision();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "default");
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_default_decision_no_catchall() {
|
|
// Rules without a catch-all should return Block for default_decision
|
|
let rules = vec![allow_rule(vec!["192.168.1.0/24"], "lan")];
|
|
let selector = ClientProxySelector::new(rules);
|
|
|
|
let decision = selector.default_decision();
|
|
match decision {
|
|
ConnectDecision::Allow { .. } => panic!("Expected Block when no catch-all rule"),
|
|
ConnectDecision::Block => {} // Expected
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_default_decision_skips_override_rules() {
|
|
// Rules with override_address should not be used as default
|
|
let rules = vec![
|
|
allow_rule_with_override(vec!["0.0.0.0/0"], "redirect", "10.0.0.1:8080"),
|
|
allow_rule(vec!["192.168.0.0/16"], "fallback"),
|
|
];
|
|
let selector = ClientProxySelector::new(rules);
|
|
|
|
// default_decision should skip the override rule and use the next catch-all
|
|
let decision = selector.default_decision();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "fallback");
|
|
}
|
|
ConnectDecision::Block => {} // Also acceptable if no valid default found
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Round-Robin Proxy Selection Tests
|
|
// =========================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_round_robin_proxy_selection() {
|
|
let rules = vec![allow_rule_multi(
|
|
vec!["0.0.0.0/0"],
|
|
vec!["proxy1", "proxy2", "proxy3"],
|
|
)];
|
|
let selector = ClientProxySelector::new(rules);
|
|
let resolver = mock_resolver();
|
|
|
|
let mut seen = vec![];
|
|
for _ in 0..6 {
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(1, 2, 3, 4)), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
seen.push(client_proxy.name.clone());
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
}
|
|
|
|
// Should cycle through proxies in order
|
|
assert_eq!(seen[0], "proxy1");
|
|
assert_eq!(seen[1], "proxy2");
|
|
assert_eq!(seen[2], "proxy3");
|
|
assert_eq!(seen[3], "proxy1");
|
|
assert_eq!(seen[4], "proxy2");
|
|
assert_eq!(seen[5], "proxy3");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_single_proxy_no_rotation() {
|
|
let rules = vec![allow_rule(vec!["0.0.0.0/0"], "only_proxy")];
|
|
let selector = ClientProxySelector::new(rules);
|
|
let resolver = mock_resolver();
|
|
|
|
for _ in 0..5 {
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(1, 2, 3, 4)), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "only_proxy");
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Address Override Tests
|
|
// =========================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_address_override() {
|
|
let rules = vec![allow_rule_with_override(
|
|
vec!["0.0.0.0/0"],
|
|
"proxy",
|
|
"10.0.0.1:8080",
|
|
)];
|
|
let selector = ClientProxySelector::new(rules);
|
|
let resolver = mock_resolver();
|
|
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(1, 2, 3, 4)), 443);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow {
|
|
client_proxy,
|
|
remote_location,
|
|
} => {
|
|
assert_eq!(client_proxy.name, "proxy");
|
|
assert_eq!(remote_location.address().to_string(), "10.0.0.1");
|
|
assert_eq!(remote_location.port(), 8080);
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_address_override_preserve_port() {
|
|
// Override with port 0 should preserve the original port
|
|
let rules = vec![allow_rule_with_override(
|
|
vec!["0.0.0.0/0"],
|
|
"proxy",
|
|
"10.0.0.1:0",
|
|
)];
|
|
let selector = ClientProxySelector::new(rules);
|
|
let resolver = mock_resolver();
|
|
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(1, 2, 3, 4)), 443);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow {
|
|
remote_location, ..
|
|
} => {
|
|
assert_eq!(remote_location.address().to_string(), "10.0.0.1");
|
|
assert_eq!(remote_location.port(), 443); // Original port preserved
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Multiple Masks Per Rule Tests
|
|
// =========================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_multiple_masks_in_rule() {
|
|
let rules = vec![
|
|
allow_rule(
|
|
vec!["192.168.1.0/24", "192.168.2.0/24", "10.0.0.0/8"],
|
|
"internal",
|
|
),
|
|
allow_rule(vec!["0.0.0.0/0"], "default"),
|
|
];
|
|
let selector = ClientProxySelector::new(rules);
|
|
let resolver = mock_resolver();
|
|
|
|
// All three ranges should match "internal"
|
|
for ip in [
|
|
Ipv4Addr::new(192, 168, 1, 1),
|
|
Ipv4Addr::new(192, 168, 2, 100),
|
|
Ipv4Addr::new(10, 50, 50, 50),
|
|
] {
|
|
let location = NetLocation::new(Address::Ipv4(ip), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "internal", "Failed for {}", ip);
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow for {}", ip),
|
|
}
|
|
}
|
|
|
|
// Other IPs should go to default
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(8, 8, 8, 8)), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "default");
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_multiple_hostnames_in_rule() {
|
|
let rules = vec![
|
|
allow_rule(
|
|
vec!["google.com", "youtube.com", "gmail.com"],
|
|
"google_proxy",
|
|
),
|
|
allow_rule(vec!["0.0.0.0/0"], "default"),
|
|
];
|
|
let selector = ClientProxySelector::new(rules);
|
|
let resolver = mock_resolver();
|
|
|
|
for hostname in [
|
|
"google.com",
|
|
"www.google.com",
|
|
"youtube.com",
|
|
"mail.gmail.com",
|
|
] {
|
|
let location = NetLocation::new(Address::Hostname(hostname.to_string()), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "google_proxy", "Failed for {}", hostname);
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow for {}", hostname),
|
|
}
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Edge Cases and Security Tests
|
|
// =========================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_empty_rules_blocks_all() {
|
|
let rules: Vec<ConnectRule<TestProxy>> = vec![];
|
|
let selector = ClientProxySelector::new(rules);
|
|
let resolver = mock_resolver();
|
|
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(1, 2, 3, 4)), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { .. } => panic!("Expected Block with empty rules"),
|
|
ConnectDecision::Block => {} // Expected
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_ipv4_mapped_ipv6() {
|
|
// IPv4-mapped IPv6 addresses (::ffff:192.168.1.1) should be handled correctly
|
|
let rules = vec![
|
|
allow_rule(vec!["192.168.1.0/24"], "lan"),
|
|
allow_rule(vec!["0.0.0.0/0"], "default"),
|
|
];
|
|
let selector = ClientProxySelector::new(rules);
|
|
let resolver = mock_resolver();
|
|
|
|
// Regular IPv4
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 1)), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "lan");
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_broadcast_and_special_addresses() {
|
|
let rules = vec![
|
|
block_rule(vec!["255.255.255.255/32"]), // Broadcast
|
|
block_rule(vec!["0.0.0.0/32"]), // Unspecified
|
|
allow_rule(vec!["0.0.0.0/0"], "default"),
|
|
];
|
|
let selector = ClientProxySelector::new(rules);
|
|
let resolver = mock_resolver();
|
|
|
|
// Broadcast should be blocked
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::BROADCAST), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { .. } => panic!("Expected Block for broadcast"),
|
|
ConnectDecision::Block => {} // Expected
|
|
}
|
|
|
|
// Unspecified should be blocked
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::UNSPECIFIED), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { .. } => panic!("Expected Block for unspecified"),
|
|
ConnectDecision::Block => {} // Expected
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_case_sensitivity_hostname() {
|
|
let rules = vec![
|
|
allow_rule(vec!["Example.COM"], "example_proxy"),
|
|
allow_rule(vec!["0.0.0.0/0"], "default"),
|
|
];
|
|
let selector = ClientProxySelector::new(rules);
|
|
let resolver = mock_resolver();
|
|
|
|
// Note: DNS hostnames are case-insensitive by spec, but our implementation
|
|
// does case-sensitive matching. This test documents current behavior.
|
|
let location = NetLocation::new(Address::Hostname("example.com".to_string()), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
// Current behavior: case-sensitive, so this goes to default
|
|
// If we want case-insensitive, this should be "example_proxy"
|
|
assert_eq!(client_proxy.name, "default");
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_concurrent_access_round_robin() {
|
|
// Test that concurrent access to round-robin is safe (uses AtomicU32)
|
|
use std::sync::Arc as StdArc;
|
|
|
|
let rules = vec![allow_rule_multi(
|
|
vec!["0.0.0.0/0"],
|
|
vec!["p1", "p2", "p3", "p4"],
|
|
)];
|
|
let selector = StdArc::new(ClientProxySelector::new(rules));
|
|
let resolver = mock_resolver();
|
|
|
|
let mut handles = vec![];
|
|
for _ in 0..10 {
|
|
let selector = selector.clone();
|
|
let resolver = resolver.clone();
|
|
handles.push(tokio::spawn(async move {
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(1, 2, 3, 4)), 80);
|
|
let _ = selector.judge(location, &resolver).await;
|
|
}));
|
|
}
|
|
|
|
for handle in handles {
|
|
handle.await.unwrap();
|
|
}
|
|
// If we got here without panic, concurrent access is safe
|
|
}
|
|
|
|
// =========================================================================
|
|
// Regression Tests for Specific Scenarios
|
|
// =========================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_localhost_bypass() {
|
|
// Common pattern: bypass proxy for localhost
|
|
// Note: With resolve_rule_hostnames=false (default), hostname destinations
|
|
// won't match IP rules unless the hostname rule comes first.
|
|
let rules = vec![
|
|
allow_rule(vec!["localhost"], "direct_host"), // Hostname rule first
|
|
allow_rule_with_override(vec!["127.0.0.0/8"], "direct", "127.0.0.1:0"),
|
|
allow_rule(vec!["0.0.0.0/0"], "proxy"),
|
|
];
|
|
let selector = ClientProxySelector::new(rules);
|
|
let resolver = mock_resolver();
|
|
|
|
// 127.0.0.1 should use direct (matches IP rule)
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(127, 0, 0, 1)), 8080);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "direct");
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
|
|
// localhost hostname should use direct_host (matches hostname rule)
|
|
let location = NetLocation::new(Address::Hostname("localhost".to_string()), 8080);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "direct_host");
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_mixed_ip_and_hostname_rules() {
|
|
// Note: With resolve_rule_hostnames=false (default), hostname destinations
|
|
// encountering IP rules will attempt to resolve the hostname. To avoid this,
|
|
// place hostname rules before IP rules, or use resolve_rule_hostnames=true
|
|
// with proper DNS mock mappings.
|
|
let rules = vec![
|
|
block_rule(vec!["malware.com"]),
|
|
allow_rule(vec!["trusted.com"], "trusted"), // Hostname rules before IP rules
|
|
allow_rule(vec!["192.168.0.0/16"], "lan"),
|
|
allow_rule(vec!["0.0.0.0/0"], "default"),
|
|
];
|
|
let selector = ClientProxySelector::new(rules);
|
|
let resolver = mock_resolver();
|
|
|
|
// Malware hostname blocked
|
|
let location = NetLocation::new(Address::Hostname("malware.com".to_string()), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
assert!(matches!(decision, ConnectDecision::Block));
|
|
|
|
// LAN IP allowed through lan proxy
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 1)), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "lan");
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
|
|
// Trusted hostname allowed through trusted proxy
|
|
let location = NetLocation::new(Address::Hostname("api.trusted.com".to_string()), 443);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "trusted");
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Routing Cache Tests
|
|
// =========================================================================
|
|
|
|
#[test]
|
|
fn test_routing_cache_key_from_location_hostname() {
|
|
let location = NetLocation::new(Address::Hostname("Example.COM".to_string()), 443);
|
|
let key = RoutingCacheKey::from_location(&location);
|
|
match key {
|
|
RoutingCacheKey::Hostname { hostname, port } => {
|
|
assert_eq!(hostname, "example.com"); // Should be lowercased
|
|
assert_eq!(port, 443);
|
|
}
|
|
_ => panic!("Expected Hostname key"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_routing_cache_key_from_location_ipv4() {
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 1)), 80);
|
|
let key = RoutingCacheKey::from_location(&location);
|
|
match key {
|
|
RoutingCacheKey::Ipv4 { addr, port } => {
|
|
assert_eq!(addr, Ipv4Addr::new(192, 168, 1, 1));
|
|
assert_eq!(port, 80);
|
|
}
|
|
_ => panic!("Expected Ipv4 key"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_routing_cache_key_from_location_ipv6() {
|
|
let location = NetLocation::new(Address::Ipv6(Ipv6Addr::LOCALHOST), 8080);
|
|
let key = RoutingCacheKey::from_location(&location);
|
|
match key {
|
|
RoutingCacheKey::Ipv6 { addr, port } => {
|
|
assert_eq!(addr, Ipv6Addr::LOCALHOST);
|
|
assert_eq!(port, 8080);
|
|
}
|
|
_ => panic!("Expected Ipv6 key"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_routing_cache_basic_operations() {
|
|
let cache = RoutingCache::new(100);
|
|
assert!(cache.is_empty());
|
|
assert_eq!(cache.len(), 0);
|
|
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(1, 2, 3, 4)), 80);
|
|
|
|
// Initially no entry
|
|
assert!(cache.get(&location).is_none());
|
|
|
|
// Insert and retrieve
|
|
cache.insert(&location, CachedDecision::Allow(5));
|
|
assert_eq!(cache.len(), 1);
|
|
|
|
let cached = cache.get(&location);
|
|
assert!(cached.is_some());
|
|
match cached.unwrap() {
|
|
CachedDecision::Allow(idx) => assert_eq!(idx, 5),
|
|
CachedDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_routing_cache_block_decision() {
|
|
let cache = RoutingCache::new(100);
|
|
let location = NetLocation::new(Address::Hostname("blocked.com".to_string()), 443);
|
|
|
|
cache.insert(&location, CachedDecision::Block);
|
|
|
|
let cached = cache.get(&location);
|
|
assert!(matches!(cached, Some(CachedDecision::Block)));
|
|
}
|
|
|
|
#[test]
|
|
fn test_routing_cache_different_ports() {
|
|
let cache = RoutingCache::new(100);
|
|
let loc_80 = NetLocation::new(Address::Ipv4(Ipv4Addr::new(1, 2, 3, 4)), 80);
|
|
let loc_443 = NetLocation::new(Address::Ipv4(Ipv4Addr::new(1, 2, 3, 4)), 443);
|
|
|
|
cache.insert(&loc_80, CachedDecision::Allow(1));
|
|
cache.insert(&loc_443, CachedDecision::Allow(2));
|
|
|
|
// Same IP, different ports should be different cache entries
|
|
match cache.get(&loc_80).unwrap() {
|
|
CachedDecision::Allow(idx) => assert_eq!(idx, 1),
|
|
_ => panic!("Expected Allow(1)"),
|
|
}
|
|
match cache.get(&loc_443).unwrap() {
|
|
CachedDecision::Allow(idx) => assert_eq!(idx, 2),
|
|
_ => panic!("Expected Allow(2)"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_routing_cache_hostname_case_insensitive() {
|
|
let cache = RoutingCache::new(100);
|
|
|
|
// Insert with uppercase
|
|
let loc_upper = NetLocation::new(Address::Hostname("EXAMPLE.COM".to_string()), 443);
|
|
cache.insert(&loc_upper, CachedDecision::Allow(1));
|
|
|
|
// Retrieve with lowercase - should find it
|
|
let loc_lower = NetLocation::new(Address::Hostname("example.com".to_string()), 443);
|
|
assert!(cache.get(&loc_lower).is_some());
|
|
|
|
// Retrieve with mixed case - should find it
|
|
let loc_mixed = NetLocation::new(Address::Hostname("Example.Com".to_string()), 443);
|
|
assert!(cache.get(&loc_mixed).is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_routing_cache_clear() {
|
|
let cache = RoutingCache::new(100);
|
|
|
|
// Add some entries
|
|
for i in 0..10 {
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, i)), 80);
|
|
cache.insert(&location, CachedDecision::Allow(i as usize));
|
|
}
|
|
assert_eq!(cache.len(), 10);
|
|
|
|
// Clear
|
|
cache.clear();
|
|
assert!(cache.is_empty());
|
|
assert_eq!(cache.len(), 0);
|
|
|
|
// Verify entries are gone
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 0)), 80);
|
|
assert!(cache.get(&location).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_routing_cache_lru_eviction() {
|
|
// Small cache to test eviction
|
|
let cache = RoutingCache::new(3);
|
|
|
|
// Insert 3 entries
|
|
let loc1 = NetLocation::new(Address::Ipv4(Ipv4Addr::new(1, 0, 0, 1)), 80);
|
|
let loc2 = NetLocation::new(Address::Ipv4(Ipv4Addr::new(1, 0, 0, 2)), 80);
|
|
let loc3 = NetLocation::new(Address::Ipv4(Ipv4Addr::new(1, 0, 0, 3)), 80);
|
|
|
|
cache.insert(&loc1, CachedDecision::Allow(1));
|
|
cache.insert(&loc2, CachedDecision::Allow(2));
|
|
cache.insert(&loc3, CachedDecision::Allow(3));
|
|
assert_eq!(cache.len(), 3);
|
|
|
|
// Insert 4th entry - should evict the oldest (loc1)
|
|
let loc4 = NetLocation::new(Address::Ipv4(Ipv4Addr::new(1, 0, 0, 4)), 80);
|
|
cache.insert(&loc4, CachedDecision::Allow(4));
|
|
assert_eq!(cache.len(), 3);
|
|
|
|
// loc1 should be evicted
|
|
assert!(cache.get(&loc1).is_none());
|
|
// Others should still be present
|
|
assert!(cache.get(&loc2).is_some());
|
|
assert!(cache.get(&loc3).is_some());
|
|
assert!(cache.get(&loc4).is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_routing_cache_overwrite() {
|
|
let cache = RoutingCache::new(100);
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(1, 2, 3, 4)), 80);
|
|
|
|
// Insert Allow
|
|
cache.insert(&location, CachedDecision::Allow(1));
|
|
match cache.get(&location).unwrap() {
|
|
CachedDecision::Allow(idx) => assert_eq!(idx, 1),
|
|
_ => panic!("Expected Allow(1)"),
|
|
}
|
|
|
|
// Overwrite with Block
|
|
cache.insert(&location, CachedDecision::Block);
|
|
assert!(matches!(cache.get(&location), Some(CachedDecision::Block)));
|
|
|
|
// Overwrite with different Allow
|
|
cache.insert(&location, CachedDecision::Allow(5));
|
|
match cache.get(&location).unwrap() {
|
|
CachedDecision::Allow(idx) => assert_eq!(idx, 5),
|
|
_ => panic!("Expected Allow(5)"),
|
|
}
|
|
}
|
|
|
|
/// Helper to create a selector with caching enabled (via resolve_rule_hostnames=true)
|
|
fn selector_with_cache(rules: Vec<ConnectRule<TestProxy>>) -> ClientProxySelector<TestProxy> {
|
|
ClientProxySelector::with_options(rules, true)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_selector_cache_disabled_for_few_rules() {
|
|
// With few rules and resolve_rule_hostnames=false, cache should be disabled
|
|
let rules = vec![
|
|
allow_rule(vec!["192.168.1.0/24"], "lan"),
|
|
allow_rule(vec!["0.0.0.0/0"], "default"),
|
|
];
|
|
let selector = ClientProxySelector::new(rules);
|
|
assert!(!selector.is_cache_enabled());
|
|
|
|
// Routing should still work correctly
|
|
let resolver = mock_resolver();
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 100)), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "lan");
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_selector_cache_enabled_with_dns_resolution() {
|
|
// With resolve_rule_hostnames=true, cache should be enabled even with few rules
|
|
let rules = vec![
|
|
allow_rule(vec!["192.168.1.0/24"], "lan"),
|
|
allow_rule(vec!["0.0.0.0/0"], "default"),
|
|
];
|
|
let selector = ClientProxySelector::with_options(rules, true);
|
|
assert!(selector.is_cache_enabled());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_selector_cache_enabled_with_many_rules() {
|
|
// With many rules (>16), cache should be enabled even without DNS resolution
|
|
let mut rules: Vec<ConnectRule<TestProxy>> = (0..20)
|
|
.map(|i| allow_rule(vec![&format!("10.0.{}.0/24", i)], &format!("rule{}", i)))
|
|
.collect();
|
|
rules.push(allow_rule(vec!["0.0.0.0/0"], "default"));
|
|
|
|
let selector = ClientProxySelector::new(rules);
|
|
assert!(selector.is_cache_enabled());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_selector_cache_hit() {
|
|
// Use selector_with_cache to ensure caching is enabled for this test
|
|
let rules = vec![
|
|
allow_rule(vec!["192.168.1.0/24"], "lan"),
|
|
allow_rule(vec!["0.0.0.0/0"], "default"),
|
|
];
|
|
let selector = selector_with_cache(rules);
|
|
let resolver = mock_resolver();
|
|
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 100)), 80);
|
|
|
|
// First call - cache miss
|
|
assert_eq!(selector.cache_size(), 0);
|
|
let decision = selector.judge(location.clone(), &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "lan");
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
|
|
// Should be cached now
|
|
assert_eq!(selector.cache_size(), 1);
|
|
|
|
// Second call - cache hit (same result)
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "lan");
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
|
|
// Still only 1 entry (was a hit, not a new insert)
|
|
assert_eq!(selector.cache_size(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_selector_cache_block_decision() {
|
|
let rules = vec![
|
|
block_rule(vec!["192.168.1.0/24"]),
|
|
allow_rule(vec!["0.0.0.0/0"], "default"),
|
|
];
|
|
let selector = selector_with_cache(rules);
|
|
let resolver = mock_resolver();
|
|
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 100)), 80);
|
|
|
|
// First call - cache miss, should block
|
|
let decision = selector.judge(location.clone(), &resolver).await.unwrap();
|
|
assert!(matches!(decision, ConnectDecision::Block));
|
|
assert_eq!(selector.cache_size(), 1);
|
|
|
|
// Second call - cache hit, still blocks
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
assert!(matches!(decision, ConnectDecision::Block));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_selector_clear_cache() {
|
|
let rules = vec![allow_rule(vec!["0.0.0.0/0"], "default")];
|
|
let selector = selector_with_cache(rules);
|
|
let resolver = mock_resolver();
|
|
|
|
// Make some cached entries
|
|
for i in 0..5 {
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(10, 0, 0, i)), 80);
|
|
let _ = selector.judge(location, &resolver).await.unwrap();
|
|
}
|
|
assert_eq!(selector.cache_size(), 5);
|
|
|
|
// Clear cache
|
|
selector.clear_cache();
|
|
assert_eq!(selector.cache_size(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_selector_judge_uncached() {
|
|
let rules = vec![allow_rule(vec!["0.0.0.0/0"], "default")];
|
|
let selector = selector_with_cache(rules);
|
|
let resolver = mock_resolver();
|
|
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(1, 2, 3, 4)), 80);
|
|
|
|
// Use judge_uncached - should NOT populate cache
|
|
let decision = selector
|
|
.judge_uncached(location.clone(), &resolver)
|
|
.await
|
|
.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "default");
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
|
|
// Cache should still be empty
|
|
assert_eq!(selector.cache_size(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_selector_cache_different_destinations() {
|
|
let rules = vec![
|
|
allow_rule(vec!["192.168.0.0/16"], "lan"),
|
|
allow_rule(vec!["10.0.0.0/8"], "private"),
|
|
allow_rule(vec!["0.0.0.0/0"], "default"),
|
|
];
|
|
let selector = selector_with_cache(rules);
|
|
let resolver = mock_resolver();
|
|
|
|
// Hit different rules
|
|
let loc_lan = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 1)), 80);
|
|
let loc_private = NetLocation::new(Address::Ipv4(Ipv4Addr::new(10, 0, 0, 1)), 80);
|
|
let loc_public = NetLocation::new(Address::Ipv4(Ipv4Addr::new(8, 8, 8, 8)), 80);
|
|
|
|
let d1 = selector.judge(loc_lan.clone(), &resolver).await.unwrap();
|
|
let d2 = selector
|
|
.judge(loc_private.clone(), &resolver)
|
|
.await
|
|
.unwrap();
|
|
let d3 = selector.judge(loc_public.clone(), &resolver).await.unwrap();
|
|
|
|
// Verify correct routing
|
|
match d1 {
|
|
ConnectDecision::Allow { client_proxy, .. } => assert_eq!(client_proxy.name, "lan"),
|
|
_ => panic!("Expected lan"),
|
|
}
|
|
match d2 {
|
|
ConnectDecision::Allow { client_proxy, .. } => assert_eq!(client_proxy.name, "private"),
|
|
_ => panic!("Expected private"),
|
|
}
|
|
match d3 {
|
|
ConnectDecision::Allow { client_proxy, .. } => assert_eq!(client_proxy.name, "default"),
|
|
_ => panic!("Expected default"),
|
|
}
|
|
|
|
// All 3 should be cached
|
|
assert_eq!(selector.cache_size(), 3);
|
|
|
|
// Verify cache hits return same results
|
|
let d1_cached = selector.judge(loc_lan, &resolver).await.unwrap();
|
|
match d1_cached {
|
|
ConnectDecision::Allow { client_proxy, .. } => assert_eq!(client_proxy.name, "lan"),
|
|
_ => panic!("Expected lan from cache"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_selector_cache_hostname_destinations() {
|
|
let rules = vec![
|
|
allow_rule(vec!["google.com"], "google"),
|
|
allow_rule(vec!["0.0.0.0/0"], "default"),
|
|
];
|
|
let selector = selector_with_cache(rules);
|
|
let resolver = mock_resolver();
|
|
|
|
// Hostname match
|
|
let loc_google = NetLocation::new(Address::Hostname("www.google.com".to_string()), 443);
|
|
let decision = selector.judge(loc_google.clone(), &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "google");
|
|
}
|
|
_ => panic!("Expected google"),
|
|
}
|
|
|
|
// Should be cached
|
|
assert_eq!(selector.cache_size(), 1);
|
|
|
|
// Case-insensitive cache hit
|
|
let loc_google_upper =
|
|
NetLocation::new(Address::Hostname("WWW.GOOGLE.COM".to_string()), 443);
|
|
let decision = selector.judge(loc_google_upper, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "google");
|
|
}
|
|
_ => panic!("Expected google from cache"),
|
|
}
|
|
|
|
// Should still be 1 entry (case-insensitive hit)
|
|
assert_eq!(selector.cache_size(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_selector_cache_with_custom_size() {
|
|
// Use resolve_rule_hostnames=true to enable caching with custom size
|
|
let rules = vec![allow_rule(vec!["0.0.0.0/0"], "default")];
|
|
let selector = ClientProxySelector::with_options_and_cache_size(rules, true, 5);
|
|
let resolver = mock_resolver();
|
|
|
|
// Fill cache to capacity
|
|
for i in 0..5 {
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(10, 0, 0, i)), 80);
|
|
let _ = selector.judge(location, &resolver).await.unwrap();
|
|
}
|
|
assert_eq!(selector.cache_size(), 5);
|
|
|
|
// Add one more - should evict oldest
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(10, 0, 0, 100)), 80);
|
|
let _ = selector.judge(location, &resolver).await.unwrap();
|
|
assert_eq!(selector.cache_size(), 5); // Still 5, one was evicted
|
|
|
|
// First entry should be evicted - verify by checking the cache is full
|
|
// but we can't directly access the internal cache to verify eviction
|
|
// So we verify indirectly: if we add another entry, size stays at 5
|
|
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(10, 0, 0, 200)), 80);
|
|
let _ = selector.judge(location, &resolver).await.unwrap();
|
|
assert_eq!(selector.cache_size(), 5);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_selector_concurrent_cache_access() {
|
|
use std::sync::Arc as StdArc;
|
|
|
|
let rules = vec![allow_rule(vec!["0.0.0.0/0"], "default")];
|
|
let selector = StdArc::new(selector_with_cache(rules));
|
|
let resolver = mock_resolver();
|
|
|
|
// Spawn multiple tasks accessing the cache concurrently
|
|
let mut handles = vec![];
|
|
for i in 0..10 {
|
|
let selector = selector.clone();
|
|
let resolver = resolver.clone();
|
|
handles.push(tokio::spawn(async move {
|
|
for j in 0..100 {
|
|
let location =
|
|
NetLocation::new(Address::Ipv4(Ipv4Addr::new(10, 0, i as u8, j as u8)), 80);
|
|
let decision = selector.judge(location, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "default");
|
|
}
|
|
ConnectDecision::Block => panic!("Expected Allow"),
|
|
}
|
|
}
|
|
}));
|
|
}
|
|
|
|
// Wait for all tasks
|
|
for handle in handles {
|
|
handle.await.unwrap();
|
|
}
|
|
|
|
// Should have cached entries (exact number depends on timing)
|
|
assert!(selector.cache_size() > 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_selector_cache_preserves_rule_priority() {
|
|
// Ensure caching doesn't break rule priority
|
|
let rules = vec![
|
|
allow_rule(vec!["192.168.1.100/32"], "specific"), // Rule 0
|
|
allow_rule(vec!["192.168.1.0/24"], "subnet"), // Rule 1
|
|
allow_rule(vec!["192.168.0.0/16"], "network"), // Rule 2
|
|
allow_rule(vec!["0.0.0.0/0"], "default"), // Rule 3
|
|
];
|
|
let selector = selector_with_cache(rules);
|
|
let resolver = mock_resolver();
|
|
|
|
// Specific IP should match rule 0 (most specific)
|
|
let loc_specific = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 100)), 80);
|
|
let decision = selector
|
|
.judge(loc_specific.clone(), &resolver)
|
|
.await
|
|
.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "specific");
|
|
}
|
|
_ => panic!("Expected specific"),
|
|
}
|
|
|
|
// Verify from cache - should still be "specific"
|
|
let decision_cached = selector.judge(loc_specific, &resolver).await.unwrap();
|
|
match decision_cached {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "specific");
|
|
}
|
|
_ => panic!("Expected specific from cache"),
|
|
}
|
|
|
|
// Different IP in same subnet should match rule 1
|
|
let loc_subnet = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 50)), 80);
|
|
let decision = selector.judge(loc_subnet, &resolver).await.unwrap();
|
|
match decision {
|
|
ConnectDecision::Allow { client_proxy, .. } => {
|
|
assert_eq!(client_proxy.name, "subnet");
|
|
}
|
|
_ => panic!("Expected subnet"),
|
|
}
|
|
}
|
|
}
|