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::Arc;
use crate::address::{Address, NetLocation, ResolvedLocation};
use crate::address::{AddressMask, NetLocationMask};
use crate::client_proxy_chain::ClientChainGroup;
use crate::resolver::{Resolver, resolve_location};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum RoutingCacheKey {
Hostname { hostname: String, port: u16 },
Ipv4 { addr: Ipv4Addr, port: u16 },
Ipv6 { addr: Ipv6Addr, port: u16 },
}
impl RoutingCacheKey {
fn from_location(location: &NetLocation) -> Self {
match location.address() {
Address::Hostname(hostname) => RoutingCacheKey::Hostname {
hostname: hostname
.strip_suffix('.')
.unwrap_or(hostname)
.to_ascii_lowercase(),
port: location.port(),
},
Address::Ipv4(addr) => RoutingCacheKey::Ipv4 {
addr: *addr,
port: location.port(),
},
Address::Ipv6(addr) => RoutingCacheKey::Ipv6 {
addr: *addr,
port: location.port(),
},
}
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum CachedDecision {
Allow(usize),
Block,
}
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 {
pub const DEFAULT_CAPACITY: usize = 10_000;
pub fn new(capacity: usize) -> Self {
let cap = NonZeroUsize::new(capacity).unwrap_or(NonZeroUsize::new(1).unwrap());
Self {
inner: RwLock::new(LruCache::new(cap)),
}
}
#[inline]
pub fn get(&self, location: &NetLocation) -> Option<CachedDecision> {
let key = RoutingCacheKey::from_location(location);
self.inner.read().peek(&key).copied()
}
#[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 {
pub masks: Vec<NetLocationMask>,
pub action: ConnectAction,
}
impl ConnectRule {
pub fn new(masks: Vec<NetLocationMask>, action: ConnectAction) -> Self {
Self { masks, action }
}
}
#[derive(Debug)]
pub enum ConnectAction {
Allow {
override_address: Option<NetLocation>,
chain_group: ClientChainGroup,
},
Block,
}
impl ConnectAction {
pub fn new_allow(override_address: Option<NetLocation>, chain_group: ClientChainGroup) -> Self {
ConnectAction::Allow {
override_address,
chain_group,
}
}
pub fn new_block() -> Self {
ConnectAction::Block
}
pub fn to_decision(&self, location: ResolvedLocation) -> ConnectDecision<'_> {
match self {
ConnectAction::Allow {
override_address,
chain_group,
} => {
let remote_location = match override_address {
Some(l) => {
let new_loc = if l.port() > 0 {
l.clone()
} else {
NetLocation::new(l.address().clone(), location.location().port())
};
new_loc.into()
}
None => {
location
}
};
ConnectDecision::Allow {
chain_group,
remote_location,
}
}
ConnectAction::Block => ConnectDecision::Block,
}
}
}
const CACHE_RULE_THRESHOLD: usize = 16;
#[derive(Debug)]
pub struct ClientProxySelector {
rules: Vec<ConnectRule>,
resolve_rule_hostnames: bool,
cache: Option<RoutingCache>,
has_ip_rules: bool,
has_hostname_rules: bool,
}
#[derive(Debug)]
pub enum ConnectDecision<'a> {
Allow {
chain_group: &'a ClientChainGroup,
remote_location: ResolvedLocation,
},
Block,
}
impl ClientProxySelector {
pub fn new(rules: Vec<ConnectRule>) -> Self {
Self::with_options(rules, false)
}
pub fn with_options(rules: Vec<ConnectRule>, resolve_rule_hostnames: bool) -> Self {
Self::with_options_and_cache_size(
rules,
resolve_rule_hostnames,
RoutingCache::DEFAULT_CAPACITY,
)
}
pub fn with_options_and_cache_size(
rules: Vec<ConnectRule>,
resolve_rule_hostnames: bool,
cache_capacity: usize,
) -> Self {
let cache = if cache_capacity != 0
&& (resolve_rule_hostnames || rules.len() > CACHE_RULE_THRESHOLD)
{
Some(RoutingCache::new(cache_capacity))
} else {
None
};
let has_ip_rules = rules.iter().flat_map(|rule| &rule.masks).any(|mask| {
mask.address_mask.netmask != 0 && mask.address_mask.address.hostname().is_none()
});
let has_hostname_rules = rules.iter().flat_map(|rule| &rule.masks).any(|mask| {
mask.address_mask.netmask != 0 && mask.address_mask.address.hostname().is_some()
});
Self {
rules,
resolve_rule_hostnames,
cache,
has_ip_rules,
has_hostname_rules,
}
}
#[inline]
pub async fn judge<'a>(
&'a self,
location: ResolvedLocation,
resolver: &Arc<dyn Resolver>,
) -> std::io::Result<ConnectDecision<'a>> {
let resolved_ip = location.resolved_addr().map(|addr| ip_to_u128(addr.ip()));
let dns_dependent = if location.location().address().hostname().is_some() {
self.has_ip_rules
} else {
self.resolve_rule_hostnames && self.has_hostname_rules
};
if dns_dependent {
return self.judge_uncached(location, resolved_ip, resolver).await;
}
let cache = match &self.cache {
Some(c) => c,
None => {
return self.judge_uncached(location, resolved_ip, resolver).await;
}
};
if let Some(cached) = cache.get(location.location()) {
return Ok(self.cached_to_decision(cached, location));
}
let mut location = location;
match match_rule(
&self.rules,
&mut location,
resolved_ip,
resolver,
self.resolve_rule_hostnames,
)
.await?
{
Some(rule_index) => {
cache.insert(location.location(), CachedDecision::Allow(rule_index));
Ok(self.rules[rule_index].action.to_decision(location))
}
None => {
cache.insert(location.location(), CachedDecision::Block);
Ok(ConnectDecision::Block)
}
}
}
#[inline]
pub async fn judge_uncached<'a>(
&'a self,
location: ResolvedLocation,
resolved_ip: Option<u128>,
resolver: &Arc<dyn Resolver>,
) -> std::io::Result<ConnectDecision<'a>> {
let mut location = location;
match match_rule(
&self.rules,
&mut location,
resolved_ip,
resolver,
self.resolve_rule_hostnames,
)
.await?
{
Some(rule_index) => Ok(self.rules[rule_index].action.to_decision(location)),
None => Ok(ConnectDecision::Block),
}
}
#[inline]
fn cached_to_decision(
&self,
cached: CachedDecision,
location: ResolvedLocation,
) -> ConnectDecision<'_> {
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 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 {
let base_domain = base_domain.strip_suffix('.').unwrap_or(base_domain);
let hostname = hostname.strip_suffix('.').unwrap_or(hostname);
if hostname.len() >= base_domain.len()
&& hostname
.get(hostname.len() - base_domain.len()..)
.is_some_and(|suffix| suffix.eq_ignore_ascii_case(base_domain))
{
let hostname_len = hostname.len();
let base_domain_len = base_domain.len();
if hostname_len == base_domain_len {
true
} else {
hostname.as_bytes()[hostname_len - base_domain_len - 1] == b'.'
}
} else {
false
}
}
#[inline]
async fn match_rule(
rules: &[ConnectRule],
location: &mut ResolvedLocation,
mut resolved_ip: Option<u128>,
resolver: &Arc<dyn Resolver>,
resolve_rule_hostnames: bool,
) -> std::io::Result<Option<usize>> {
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 {} -> {mask:?}",
location.location()
);
return Ok(Some(rule_index));
}
}
Err(MatchMaskError::Fatal(e)) => {
return Err(std::io::Error::other(format!(
"fatal error while matching mask for {}: {e}",
location.location()
)));
}
Err(MatchMaskError::NonFatal(e)) => {
error!(
"Non-fatal error while trying to match mask for {}: {e}",
location.location()
);
}
}
}
}
Ok(None)
}
enum MatchMaskError {
NonFatal(std::io::Error),
Fatal(std::io::Error),
}
#[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: &mut ResolvedLocation,
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.location().port() {
return Ok(false);
}
if netmask == 0 {
return Ok(true);
}
if let Some(hostname) = address.hostname() {
if let Some(remote_hostname) = location.location().address().hostname() {
return Ok(matches_domain(hostname, remote_hostname));
}
if !resolve_rule_hostnames {
return Ok(false);
}
}
let masked_ip = match resolved_ip {
Some(ip) => *ip,
None => {
let socket_addr = resolve_location(location, resolver)
.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(_) => {
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;
#[tokio::test]
async fn cached_route_rechecks_changed_ip() {
let selector = ClientProxySelector::with_options(
vec![
block_rule(vec!["127.0.0.0/8"]),
allow_rule(vec!["0.0.0.0/0"], "direct"),
],
true,
);
let resolver = mock_resolver();
let hostname = NetLocation::from_str("example.com:443", None).unwrap();
let public =
ResolvedLocation::with_resolved(hostname.clone(), "1.1.1.1:443".parse().unwrap());
assert!(matches!(
selector.judge(public, &resolver).await.unwrap(),
ConnectDecision::Allow { .. }
));
let private = ResolvedLocation::with_resolved(hostname, "127.0.0.1:443".parse().unwrap());
assert!(matches!(
selector.judge(private, &resolver).await.unwrap(),
ConnectDecision::Block
));
}
#[tokio::test]
async fn domain_rules_agree_with_cache_on_case_and_root_dot() {
for capacity in [0, 10] {
let selector = ClientProxySelector::with_options_and_cache_size(
vec![
block_rule(vec!["Blocked.Example."]),
allow_rule(vec!["0.0.0.0/0"], "direct"),
],
true,
capacity,
);
for name in [
"BLOCKED.example:443",
"blocked.EXAMPLE.:443",
"sub.blocked.example:443",
] {
let destination = NetLocation::from_str(name, None).unwrap();
assert!(matches!(
selector
.judge(destination.into(), &mock_resolver())
.await
.unwrap(),
ConnectDecision::Block
));
}
if capacity == 0 {
assert!(!selector.is_cache_enabled());
}
}
}
#[derive(Debug)]
struct MockResolver {
mappings: std::collections::HashMap<String, Vec<SocketAddr>>,
}
impl MockResolver {
fn new() -> Self {
Self {
mappings: std::collections::HashMap::new(),
}
}
}
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),
)
})
})
}
}
fn mock_resolver() -> Arc<dyn Resolver> {
Arc::new(MockResolver::new())
}
fn mock_chain_group() -> ClientChainGroup {
use crate::resolver::NativeResolver;
use crate::tcp::chain_builder::build_client_chain_group;
let resolver: Arc<dyn Resolver> = Arc::new(NativeResolver::new());
build_client_chain_group(crate::option_util::NoneOrSome::None, resolver)
}
fn allow_rule(masks: Vec<&str>, _proxy_name: &str) -> ConnectRule {
let masks: Vec<NetLocationMask> = masks
.into_iter()
.map(|s| NetLocationMask::from(s).unwrap())
.collect();
ConnectRule::new(masks, ConnectAction::new_allow(None, mock_chain_group()))
}
fn allow_rule_multi(masks: Vec<&str>, _proxy_names: Vec<&str>) -> ConnectRule {
let masks: Vec<NetLocationMask> = masks
.into_iter()
.map(|s| NetLocationMask::from(s).unwrap())
.collect();
ConnectRule::new(masks, ConnectAction::new_allow(None, mock_chain_group()))
}
fn block_rule(masks: Vec<&str>) -> ConnectRule {
let masks: Vec<NetLocationMask> = masks
.into_iter()
.map(|s| NetLocationMask::from(s).unwrap())
.collect();
ConnectRule::new(masks, ConnectAction::new_block())
}
fn allow_rule_with_override(
masks: Vec<&str>,
_proxy_name: &str,
override_addr: &str,
) -> ConnectRule {
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), mock_chain_group()),
)
}
#[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() {
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("", "")); }
#[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")); }
#[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.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
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();
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 2)), 80);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
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();
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.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
ConnectDecision::Block => panic!("Expected Allow for 192.168.1.{}", last_octet),
}
}
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 2, 1)), 80);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
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();
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(10, 255, 255, 255)), 80);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
ConnectDecision::Block => panic!("Expected Allow"),
}
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(172, 20, 5, 1)), 80);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
ConnectDecision::Block => panic!("Expected Allow"),
}
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 100, 50)), 80);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
ConnectDecision::Block => panic!("Expected Allow"),
}
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(8, 8, 8, 8)), 80);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
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.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
ConnectDecision::Block => panic!("Expected Allow"),
}
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(127, 255, 255, 255)), 80);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
ConnectDecision::Block => panic!("Expected Allow"),
}
}
#[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.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
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();
let location = NetLocation::new(Address::Ipv6("fe80::1".parse().unwrap()), 80);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
ConnectDecision::Block => panic!("Expected Allow"),
}
let location = NetLocation::new(Address::Ipv6("fd00::1234".parse().unwrap()), 80);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
ConnectDecision::Block => panic!("Expected Allow"),
}
let location = NetLocation::new(Address::Ipv6("2001:db8::1".parse().unwrap()), 80);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
ConnectDecision::Block => panic!("Expected Allow"),
}
}
#[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.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
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();
let location = NetLocation::new(Address::Hostname("www.example.com".to_string()), 80);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
ConnectDecision::Block => panic!("Expected Allow"),
}
let location = NetLocation::new(Address::Hostname("sub.www.example.com".to_string()), 80);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
ConnectDecision::Block => panic!("Expected Allow"),
}
}
#[tokio::test]
async fn test_hostname_no_false_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();
let location = NetLocation::new(Address::Hostname("malicious-example.com".to_string()), 80);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
ConnectDecision::Block => panic!("Expected Allow"),
}
let location = NetLocation::new(Address::Hostname("fakeexample.com".to_string()), 80);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
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();
let location = NetLocation::new(Address::Hostname("example.org".to_string()), 80);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
ConnectDecision::Block => panic!("Expected Allow"),
}
}
#[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();
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(1, 2, 3, 4)), 443);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
ConnectDecision::Block => panic!("Expected Allow"),
}
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(1, 2, 3, 4)), 80);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
ConnectDecision::Block => panic!("Expected Allow"),
}
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(1, 2, 3, 4)), 8080);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
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();
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 100)), 22);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
ConnectDecision::Block => panic!("Expected Allow"),
}
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(8, 8, 8, 8)), 22);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
ConnectDecision::Block => panic!("Expected Allow"),
}
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 100)), 80);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
ConnectDecision::Block => panic!("Expected Allow"),
}
}
#[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();
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 1)), 80);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
ConnectDecision::Block => panic!("Expected Allow"),
}
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 2, 1)), 80);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
ConnectDecision::Block => panic!("Expected Allow"),
}
}
#[tokio::test]
async fn test_rule_order_matters_security() {
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();
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 1)), 80);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
ConnectDecision::Block => panic!("Expected Allow for 192.168.1.1"),
}
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 2, 1)), 80);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => panic!("Expected Block for 192.168.2.1"),
ConnectDecision::Block => {} }
}
#[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();
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 1)), 80);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => panic!("Expected Block"),
ConnectDecision::Block => {} }
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(8, 8, 8, 8)), 80);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
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();
let location = NetLocation::new(Address::Hostname("blocked.com".to_string()), 80);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => panic!("Expected Block for blocked.com"),
ConnectDecision::Block => {} }
let location = NetLocation::new(Address::Hostname("www.blocked.com".to_string()), 80);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => panic!("Expected Block for www.blocked.com"),
ConnectDecision::Block => {} }
}
#[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();
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(8, 8, 8, 8)), 80);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => panic!("Expected Block when no default rule"),
ConnectDecision::Block => {} }
}
#[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();
for _ in 0..6 {
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(1, 2, 3, 4)), 80);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {
}
ConnectDecision::Block => panic!("Expected Allow"),
}
}
}
#[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.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
ConnectDecision::Block => panic!("Expected Allow"),
}
}
}
#[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.into(), &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(), 8080);
}
ConnectDecision::Block => panic!("Expected Allow"),
}
}
#[tokio::test]
async fn test_address_override_preserve_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.into(), &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); }
ConnectDecision::Block => panic!("Expected Allow"),
}
}
#[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();
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.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {
}
ConnectDecision::Block => panic!("Expected Allow for {}", ip),
}
}
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(8, 8, 8, 8)), 80);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
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.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {
}
ConnectDecision::Block => panic!("Expected Allow for {}", hostname),
}
}
}
#[tokio::test]
async fn test_empty_rules_blocks_all() {
let rules: Vec<ConnectRule> = 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.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => panic!("Expected Block with empty rules"),
ConnectDecision::Block => {} }
}
#[tokio::test]
async fn test_ipv4_mapped_ipv6() {
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();
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 1)), 80);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
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"]), block_rule(vec!["0.0.0.0/32"]), 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::BROADCAST), 80);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => panic!("Expected Block for broadcast"),
ConnectDecision::Block => {} }
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::UNSPECIFIED), 80);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => panic!("Expected Block for unspecified"),
ConnectDecision::Block => {} }
}
#[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();
let location = NetLocation::new(Address::Hostname("example.com".to_string()), 80);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {
}
ConnectDecision::Block => panic!("Expected Allow"),
}
}
#[tokio::test]
async fn test_concurrent_access_round_robin() {
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.into(), &resolver).await;
}));
}
for handle in handles {
handle.await.unwrap();
}
}
#[tokio::test]
async fn test_localhost_bypass() {
let rules = vec![
allow_rule(vec!["localhost"], "direct_host"), 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();
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(127, 0, 0, 1)), 8080);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
ConnectDecision::Block => panic!("Expected Allow"),
}
let location = NetLocation::new(Address::Hostname("localhost".to_string()), 8080);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
ConnectDecision::Block => panic!("Expected Allow"),
}
}
#[tokio::test]
async fn test_mixed_ip_and_hostname_rules() {
let rules = vec![
block_rule(vec!["malware.com"]),
allow_rule(vec!["trusted.com"], "trusted"), 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();
let location = NetLocation::new(Address::Hostname("malware.com".to_string()), 80);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
assert!(matches!(decision, ConnectDecision::Block));
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 1)), 80);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
ConnectDecision::Block => panic!("Expected Allow"),
}
let location = NetLocation::new(Address::Hostname("api.trusted.com".to_string()), 443);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
ConnectDecision::Block => panic!("Expected Allow"),
}
}
#[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"); 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);
assert!(cache.get(&location).is_none());
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));
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);
let loc_upper = NetLocation::new(Address::Hostname("EXAMPLE.COM".to_string()), 443);
cache.insert(&loc_upper, CachedDecision::Allow(1));
let loc_lower = NetLocation::new(Address::Hostname("example.com".to_string()), 443);
assert!(cache.get(&loc_lower).is_some());
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);
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);
cache.clear();
assert!(cache.is_empty());
assert_eq!(cache.len(), 0);
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() {
let cache = RoutingCache::new(3);
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);
let loc4 = NetLocation::new(Address::Ipv4(Ipv4Addr::new(1, 0, 0, 4)), 80);
cache.insert(&loc4, CachedDecision::Allow(4));
assert_eq!(cache.len(), 3);
assert!(cache.get(&loc1).is_none());
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);
cache.insert(&location, CachedDecision::Allow(1));
match cache.get(&location).unwrap() {
CachedDecision::Allow(idx) => assert_eq!(idx, 1),
_ => panic!("Expected Allow(1)"),
}
cache.insert(&location, CachedDecision::Block);
assert!(matches!(cache.get(&location), Some(CachedDecision::Block)));
cache.insert(&location, CachedDecision::Allow(5));
match cache.get(&location).unwrap() {
CachedDecision::Allow(idx) => assert_eq!(idx, 5),
_ => panic!("Expected Allow(5)"),
}
}
fn selector_with_cache(rules: Vec<ConnectRule>) -> ClientProxySelector {
ClientProxySelector::with_options(rules, true)
}
#[tokio::test]
async fn test_selector_cache_disabled_for_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::new(rules);
assert!(!selector.is_cache_enabled());
let resolver = mock_resolver();
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 100)), 80);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
ConnectDecision::Block => panic!("Expected Allow"),
}
}
#[tokio::test]
async fn test_selector_cache_enabled_with_dns_resolution() {
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() {
let mut rules: Vec<ConnectRule> = (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() {
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);
assert_eq!(selector.cache_size(), 0);
let decision = selector
.judge(location.clone().into(), &resolver)
.await
.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
ConnectDecision::Block => panic!("Expected Allow"),
}
assert_eq!(selector.cache_size(), 1);
let decision = selector.judge(location.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
ConnectDecision::Block => panic!("Expected Allow"),
}
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);
let decision = selector
.judge(location.clone().into(), &resolver)
.await
.unwrap();
assert!(matches!(decision, ConnectDecision::Block));
assert_eq!(selector.cache_size(), 1);
let decision = selector.judge(location.into(), &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();
for i in 0..5 {
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(10, 0, 0, i)), 80);
let _ = selector.judge(location.into(), &resolver).await.unwrap();
}
assert_eq!(selector.cache_size(), 5);
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);
let decision = selector
.judge_uncached(location.into(), None, &resolver)
.await
.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
ConnectDecision::Block => panic!("Expected Allow"),
}
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();
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().into(), &resolver)
.await
.unwrap();
let d2 = selector
.judge(loc_private.clone().into(), &resolver)
.await
.unwrap();
let d3 = selector
.judge(loc_public.clone().into(), &resolver)
.await
.unwrap();
assert!(
matches!(d1, ConnectDecision::Allow { .. }),
"Expected lan to allow"
);
assert!(
matches!(d2, ConnectDecision::Allow { .. }),
"Expected private to allow"
);
assert!(
matches!(d3, ConnectDecision::Allow { .. }),
"Expected default to allow"
);
assert_eq!(selector.cache_size(), 3);
let d1_cached = selector.judge(loc_lan.into(), &resolver).await.unwrap();
assert!(
matches!(d1_cached, ConnectDecision::Allow { .. }),
"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();
let loc_google = NetLocation::new(Address::Hostname("www.google.com".to_string()), 443);
let decision = selector
.judge(loc_google.clone().into(), &resolver)
.await
.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
_ => panic!("Expected google"),
}
assert_eq!(selector.cache_size(), 1);
let loc_google_upper =
NetLocation::new(Address::Hostname("WWW.GOOGLE.COM".to_string()), 443);
let decision = selector
.judge(loc_google_upper.into(), &resolver)
.await
.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
_ => panic!("Expected google from cache"),
}
assert_eq!(selector.cache_size(), 1);
}
#[tokio::test]
async fn test_selector_cache_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();
for i in 0..5 {
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(10, 0, 0, i)), 80);
let _ = selector.judge(location.into(), &resolver).await.unwrap();
}
assert_eq!(selector.cache_size(), 5);
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(10, 0, 0, 100)), 80);
let _ = selector.judge(location.into(), &resolver).await.unwrap();
assert_eq!(selector.cache_size(), 5);
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(10, 0, 0, 200)), 80);
let _ = selector.judge(location.into(), &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();
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.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
ConnectDecision::Block => panic!("Expected Allow"),
}
}
}));
}
for handle in handles {
handle.await.unwrap();
}
assert!(selector.cache_size() > 0);
}
#[tokio::test]
async fn test_selector_cache_preserves_rule_priority() {
let rules = vec![
allow_rule(vec!["192.168.1.100/32"], "specific"), allow_rule(vec!["192.168.1.0/24"], "subnet"), allow_rule(vec!["192.168.0.0/16"], "network"), allow_rule(vec!["0.0.0.0/0"], "default"), ];
let selector = selector_with_cache(rules);
let resolver = mock_resolver();
let loc_specific = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 100)), 80);
let decision = selector
.judge(loc_specific.clone().into(), &resolver)
.await
.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
_ => panic!("Expected specific"),
}
let decision_cached = selector
.judge(loc_specific.into(), &resolver)
.await
.unwrap();
match decision_cached {
ConnectDecision::Allow { .. } => {}
_ => panic!("Expected specific from cache"),
}
let loc_subnet = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 50)), 80);
let decision = selector.judge(loc_subnet.into(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => {}
_ => panic!("Expected subnet"),
}
}
#[test]
fn test_netmask_value_for_cidr_0() {
let mask = NetLocationMask::from("0.0.0.0/0").unwrap();
assert_eq!(
mask.address_mask.netmask, 0,
"0.0.0.0/0 should have netmask == 0, got {}",
mask.address_mask.netmask
);
}
#[test]
fn test_netmask_value_for_cidr_24() {
let mask = NetLocationMask::from("172.17.0.0/24").unwrap();
assert_ne!(
mask.address_mask.netmask, 0,
"172.17.0.0/24 should have netmask != 0"
);
}
}