use std::net::IpAddr;
use std::sync::Arc;
use crate::config::{AuthKind, Protocol, RateLimit};
use crate::net::AddrSpec;
use crate::socks5::Command;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[non_exhaustive]
pub enum Verdict {
Pass,
Block,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[non_exhaustive]
pub enum Scope {
Client,
Socks,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Rule {
pub name: Option<Arc<str>>,
pub verdict: Verdict,
pub scope: Scope,
pub from: AddrSpec,
pub to: AddrSpec,
pub commands: Vec<Command>,
pub protocols: Vec<Protocol>,
pub methods: Vec<AuthKind>,
pub bandwidth: Option<RateLimit>,
pub source_line: usize,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct ClientContext {
pub client_ip: IpAddr,
pub client_port: u16,
pub proxy_ip: IpAddr,
pub proxy_port: u16,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct SocksContext<'a> {
pub client_ip: IpAddr,
pub client_port: u16,
pub dest_host: Option<&'a str>,
pub dest_ip: IpAddr,
pub dest_port: u16,
pub command: Command,
pub protocol: Protocol,
pub method: AuthKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RuleDecision {
pub verdict: Verdict,
pub source_line: Option<usize>,
pub rule_name: Option<Arc<str>>,
pub bandwidth: Option<RateLimit>,
}
impl Rule {
fn matches_client(&self, ctx: &ClientContext) -> bool {
self.scope == Scope::Client
&& self.from.matches(ctx.client_ip, ctx.client_port)
&& self.to.matches(ctx.proxy_ip, ctx.proxy_port)
}
fn matches_socks(&self, ctx: &SocksContext<'_>) -> bool {
self.scope == Scope::Socks
&& self.from.matches(ctx.client_ip, ctx.client_port)
&& self
.to
.matches_dest(ctx.dest_host, ctx.dest_ip, ctx.dest_port)
&& (self.commands.is_empty() || self.commands.contains(&ctx.command))
&& (self.protocols.is_empty() || self.protocols.contains(&ctx.protocol))
&& (self.methods.is_empty() || self.methods.contains(&ctx.method))
}
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct RuleSet {
pub rules: Vec<Rule>,
}
impl RuleSet {
pub fn new(rules: Vec<Rule>) -> Self {
RuleSet { rules }
}
#[cfg(test)]
pub(crate) fn evaluate_client(&self, ctx: &ClientContext) -> Verdict {
self.evaluate_client_detail(ctx).verdict
}
pub(crate) fn evaluate_client_detail(&self, ctx: &ClientContext) -> RuleDecision {
for rule in &self.rules {
if rule.matches_client(ctx) {
return RuleDecision {
verdict: rule.verdict,
source_line: Some(rule.source_line),
rule_name: rule.name.clone(),
bandwidth: None,
};
}
}
RuleDecision {
verdict: Verdict::Block,
source_line: None,
rule_name: None,
bandwidth: None,
}
}
#[cfg(test)]
pub(crate) fn evaluate_socks(&self, ctx: &SocksContext<'_>) -> Verdict {
self.evaluate_socks_detail(ctx).verdict
}
pub(crate) fn udp_associate_reachable(
&self,
client_ip: IpAddr,
client_port: u16,
method: AuthKind,
) -> bool {
for rule in &self.rules {
let applies = rule.scope == Scope::Socks
&& rule.from.matches(client_ip, client_port)
&& (rule.commands.is_empty() || rule.commands.contains(&Command::UdpAssociate))
&& (rule.protocols.is_empty() || rule.protocols.contains(&Protocol::Udp))
&& (rule.methods.is_empty() || rule.methods.contains(&method));
if !applies {
continue;
}
match rule.verdict {
Verdict::Pass => return true,
Verdict::Block if rule.to.matches_all() => return false,
Verdict::Block => continue,
}
}
false
}
pub(crate) fn evaluate_socks_detail(&self, ctx: &SocksContext<'_>) -> RuleDecision {
for rule in &self.rules {
if rule.matches_socks(ctx) {
return RuleDecision {
verdict: rule.verdict,
source_line: Some(rule.source_line),
rule_name: rule.name.clone(),
bandwidth: rule.bandwidth.clone(),
};
}
}
RuleDecision {
verdict: Verdict::Block,
source_line: None,
rule_name: None,
bandwidth: None,
}
}
pub(crate) fn has_scope(&self, scope: Scope) -> bool {
self.rules.iter().any(|r| r.scope == scope)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn spec(cidr: &str) -> AddrSpec {
AddrSpec::new(cidr.parse().unwrap(), None)
}
fn client_rule(verdict: Verdict, from: &str) -> Rule {
Rule {
name: None,
verdict,
scope: Scope::Client,
from: spec(from),
to: spec("0.0.0.0/0"),
commands: vec![],
protocols: vec![],
methods: vec![],
bandwidth: None,
source_line: 0,
}
}
fn socks_rule(verdict: Verdict, to: &str, commands: Vec<Command>) -> Rule {
Rule {
name: None,
verdict,
scope: Scope::Socks,
from: spec("0.0.0.0/0"),
to: spec(to),
commands,
protocols: vec![],
methods: vec![],
bandwidth: None,
source_line: 0,
}
}
fn client_ctx(ip: &str) -> ClientContext {
ClientContext {
client_ip: ip.parse().unwrap(),
client_port: 5000,
proxy_ip: "0.0.0.0".parse().unwrap(),
proxy_port: 1080,
}
}
fn socks_ctx(dest: &str, cmd: Command) -> SocksContext<'static> {
SocksContext {
client_ip: "10.0.0.5".parse().unwrap(),
client_port: 5000,
dest_host: None,
dest_ip: dest.parse().unwrap(),
dest_port: 443,
command: cmd,
protocol: Protocol::Tcp,
method: AuthKind::None,
}
}
fn socks_ctx_host<'a>(host: &'a str, dest: &str, cmd: Command) -> SocksContext<'a> {
SocksContext {
dest_host: Some(host),
..socks_ctx(dest, cmd)
}
}
#[test]
fn udp_associate_reachable_gates_on_command() {
let connect_only = RuleSet::new(vec![socks_rule(
Verdict::Pass,
"0.0.0.0/0",
vec![Command::Connect],
)]);
assert!(!connect_only.udp_associate_reachable(
"10.0.0.5".parse().unwrap(),
5000,
AuthKind::None
));
let with_udp = RuleSet::new(vec![socks_rule(
Verdict::Pass,
"10.0.0.0/8",
vec![Command::UdpAssociate],
)]);
assert!(with_udp.udp_associate_reachable(
"10.0.0.5".parse().unwrap(),
5000,
AuthKind::None
));
let any_cmd = RuleSet::new(vec![socks_rule(Verdict::Pass, "0.0.0.0/0", vec![])]);
assert!(any_cmd.udp_associate_reachable("10.0.0.5".parse().unwrap(), 5000, AuthKind::None));
let blocked = RuleSet::new(vec![socks_rule(
Verdict::Block,
"0.0.0.0/0",
vec![Command::UdpAssociate],
)]);
assert!(!blocked.udp_associate_reachable(
"10.0.0.5".parse().unwrap(),
5000,
AuthKind::None
));
}
#[test]
fn udp_associate_reachable_respects_first_match() {
let client: IpAddr = "10.0.0.5".parse().unwrap();
let mk = |verdict: Verdict, from: &str, to: AddrSpec, commands: Vec<Command>| Rule {
name: None,
verdict,
scope: Scope::Socks,
from: spec(from),
to,
commands,
protocols: vec![],
methods: vec![],
bandwidth: None,
source_line: 0,
};
let blocked_first = RuleSet::new(vec![
mk(Verdict::Block, "10.0.0.5/32", AddrSpec::any(), vec![]),
mk(
Verdict::Pass,
"10.0.0.0/8",
AddrSpec::any(),
vec![Command::UdpAssociate],
),
]);
assert!(!blocked_first.udp_associate_reachable(client, 5000, AuthKind::None));
let narrow_block_first = RuleSet::new(vec![
mk(
Verdict::Block,
"10.0.0.5/32",
spec("10.0.0.0/8"),
vec![Command::UdpAssociate],
),
mk(
Verdict::Pass,
"10.0.0.0/8",
AddrSpec::any(),
vec![Command::UdpAssociate],
),
]);
assert!(narrow_block_first.udp_associate_reachable(client, 5000, AuthKind::None));
let pass_first = RuleSet::new(vec![
mk(
Verdict::Pass,
"10.0.0.0/8",
spec("8.8.8.8/32"),
vec![Command::UdpAssociate],
),
mk(Verdict::Block, "10.0.0.5/32", AddrSpec::any(), vec![]),
]);
assert!(pass_first.udp_associate_reachable(client, 5000, AuthKind::None));
}
#[test]
fn socks_rule_matches_requested_hostname() {
use crate::net::HostPattern;
let rs = RuleSet::new(vec![Rule {
name: None,
verdict: Verdict::Pass,
scope: Scope::Socks,
from: AddrSpec::any(),
to: AddrSpec::host(HostPattern::Suffix("example.com".into()), None),
commands: vec![],
protocols: vec![],
methods: vec![],
bandwidth: None,
source_line: 1,
}]);
assert_eq!(
rs.evaluate_socks(&socks_ctx_host(
"api.example.com",
"203.0.113.7",
Command::Connect
)),
Verdict::Pass
);
assert_eq!(
rs.evaluate_socks(&socks_ctx_host("evil.com", "203.0.113.7", Command::Connect)),
Verdict::Block
);
assert_eq!(
rs.evaluate_socks(&socks_ctx("203.0.113.7", Command::Connect)),
Verdict::Block
);
}
#[test]
fn socks_decision_carries_rule_bandwidth() {
let mut rule = socks_rule(Verdict::Pass, "0.0.0.0/0", vec![Command::Connect]);
rule.bandwidth = Some(RateLimit {
limit: 1024,
window: std::time::Duration::from_secs(1),
});
let rs = RuleSet::new(vec![rule]);
let allowed = rs.evaluate_socks_detail(&socks_ctx("8.8.8.8", Command::Connect));
assert_eq!(allowed.verdict, Verdict::Pass);
assert_eq!(allowed.bandwidth.as_ref().map(|b| b.limit), Some(1024));
let denied = rs.evaluate_socks_detail(&socks_ctx("8.8.8.8", Command::UdpAssociate));
assert_eq!(denied.verdict, Verdict::Block);
assert_eq!(denied.bandwidth, None);
}
#[test]
fn deny_by_default_when_empty() {
let rs = RuleSet::default();
assert_eq!(rs.evaluate_client(&client_ctx("1.2.3.4")), Verdict::Block);
assert_eq!(
rs.evaluate_socks(&socks_ctx("8.8.8.8", Command::Connect)),
Verdict::Block
);
}
#[test]
fn first_match_wins() {
let rs = RuleSet::new(vec![
client_rule(Verdict::Block, "10.0.0.0/8"),
client_rule(Verdict::Pass, "0.0.0.0/0"),
]);
assert_eq!(rs.evaluate_client(&client_ctx("10.0.0.5")), Verdict::Block);
assert_eq!(rs.evaluate_client(&client_ctx("8.8.8.8")), Verdict::Pass);
}
#[test]
fn detailed_decision_includes_rule_line() {
let mut rule = client_rule(Verdict::Pass, "0.0.0.0/0");
rule.source_line = 42;
let rs = RuleSet::new(vec![rule]);
let decision = rs.evaluate_client_detail(&client_ctx("8.8.8.8"));
assert_eq!(decision.verdict, Verdict::Pass);
assert_eq!(decision.source_line, Some(42));
assert_eq!(decision.rule_name, None);
}
#[test]
fn detailed_decision_includes_rule_name() {
let mut rule = client_rule(Verdict::Pass, "0.0.0.0/0");
rule.name = Some(Arc::from("lan-clients"));
let rs = RuleSet::new(vec![rule]);
let decision = rs.evaluate_client_detail(&client_ctx("8.8.8.8"));
assert_eq!(decision.rule_name.as_deref(), Some("lan-clients"));
}
#[test]
fn socks_command_filtering() {
let rs = RuleSet::new(vec![socks_rule(
Verdict::Pass,
"0.0.0.0/0",
vec![Command::Connect],
)]);
assert_eq!(
rs.evaluate_socks(&socks_ctx("8.8.8.8", Command::Connect)),
Verdict::Pass
);
assert_eq!(
rs.evaluate_socks(&socks_ctx("8.8.8.8", Command::UdpAssociate)),
Verdict::Block
);
}
#[test]
fn socks_dest_filtering_blocks_loopback() {
let rs = RuleSet::new(vec![
socks_rule(Verdict::Block, "127.0.0.0/8", vec![]),
socks_rule(Verdict::Pass, "0.0.0.0/0", vec![]),
]);
assert_eq!(
rs.evaluate_socks(&socks_ctx("127.0.0.1", Command::Connect)),
Verdict::Block
);
assert_eq!(
rs.evaluate_socks(&socks_ctx("93.184.216.34", Command::Connect)),
Verdict::Pass
);
}
#[test]
fn has_scope_detection() {
let rs = RuleSet::new(vec![client_rule(Verdict::Pass, "0.0.0.0/0")]);
assert!(rs.has_scope(Scope::Client));
assert!(!rs.has_scope(Scope::Socks));
}
}