use std::net::IpAddr;
use ipnet::{IpNet, Ipv4Net, Ipv6Net};
use crate::error::NpxcError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Decision {
Allow,
Deny,
}
#[derive(Debug, Clone)]
enum HostMatch {
Net(IpNet),
Domain(String),
}
#[derive(Debug, Clone)]
enum PortMatch {
Any,
Only(u16),
}
#[derive(Debug, Clone)]
struct Rule {
host: HostMatch,
port: PortMatch,
}
impl Rule {
fn matches(&self, ip: IpAddr, port: u16, hostname: Option<&str>) -> bool {
let port_ok = match self.port {
PortMatch::Any => true,
PortMatch::Only(p) => p == port,
};
if !port_ok {
return false;
}
match &self.host {
HostMatch::Net(net) => net.contains(&ip),
HostMatch::Domain(d) => hostname.is_some_and(|h| h.eq_ignore_ascii_case(d)),
}
}
}
#[derive(Debug, Clone)]
pub struct Policy {
rules: Vec<Rule>,
dns_resolver: IpAddr,
}
impl Policy {
pub fn build(allow: &[String], dns_resolver: IpAddr) -> Result<Self, NpxcError> {
let mut rules = Vec::with_capacity(allow.len() + 1);
for entry in allow {
rules.push(parse_rule(entry)?);
}
rules.push(Rule {
host: HostMatch::Net(host_net(dns_resolver)),
port: PortMatch::Only(53),
});
Ok(Self {
rules,
dns_resolver,
})
}
#[must_use]
pub fn evaluate(&self, ip: IpAddr, port: u16, hostname: Option<&str>) -> Decision {
if self.rules.iter().any(|r| r.matches(ip, port, hostname)) {
Decision::Allow
} else {
Decision::Deny
}
}
#[must_use]
pub fn allows_name(&self, name: &str) -> bool {
self.rules.iter().any(|r| match &r.host {
HostMatch::Domain(d) => d.eq_ignore_ascii_case(name),
HostMatch::Net(_) => false,
})
}
#[must_use]
pub fn dns_resolver(&self) -> IpAddr {
self.dns_resolver
}
}
pub fn validate(allow: &[String]) -> Result<(), NpxcError> {
for entry in allow {
parse_rule(entry)?;
}
Ok(())
}
fn host_net(ip: IpAddr) -> IpNet {
match ip {
IpAddr::V4(a) => IpNet::V4(Ipv4Net::new(a, 32).expect("/32 is a valid prefix")),
IpAddr::V6(a) => IpNet::V6(Ipv6Net::new(a, 128).expect("/128 is a valid prefix")),
}
}
fn parse_rule(entry: &str) -> Result<Rule, NpxcError> {
let (host_part, port) = split_host_port(entry)?;
let port = port.map_or(PortMatch::Any, PortMatch::Only);
if let Ok(net) = host_part.parse::<IpNet>() {
return Ok(Rule {
host: HostMatch::Net(net),
port,
});
}
if let Ok(ip) = host_part.parse::<IpAddr>() {
return Ok(Rule {
host: HostMatch::Net(host_net(ip)),
port,
});
}
if host_part.is_empty() {
return Err(NpxcError::Config(format!(
"invalid allow entry {entry:?}: empty host"
)));
}
Ok(Rule {
host: HostMatch::Domain(host_part.to_ascii_lowercase()),
port,
})
}
fn split_host_port(entry: &str) -> Result<(&str, Option<u16>), NpxcError> {
let parse_port = |p: &str| {
p.parse::<u16>()
.map_err(|_| NpxcError::Config(format!("invalid port in allow entry {entry:?}")))
};
if let Some(rest) = entry.strip_prefix('[') {
let (addr, after) = rest.split_once(']').ok_or_else(|| {
NpxcError::Config(format!("unterminated '[' in allow entry {entry:?}"))
})?;
if after.is_empty() {
return Ok((addr, None));
}
let port = after
.strip_prefix(':')
.ok_or_else(|| NpxcError::Config(format!("expected ':port' after ']' in {entry:?}")))?;
return Ok((addr, Some(parse_port(port)?)));
}
if entry.bytes().filter(|&b| b == b':').count() == 1 {
let (host, port) = entry.rsplit_once(':').expect("one colon present");
return Ok((host, Some(parse_port(port)?)));
}
Ok((entry, None))
}
#[cfg(test)]
mod tests {
use super::*;
const DNS: IpAddr = IpAddr::V4(std::net::Ipv4Addr::new(1, 1, 1, 1));
fn policy(entries: &[&str]) -> Policy {
let owned: Vec<String> = entries.iter().map(|s| (*s).to_string()).collect();
Policy::build(&owned, DNS).unwrap()
}
fn ip(s: &str) -> IpAddr {
s.parse().unwrap()
}
#[test]
fn empty_allowlist_denies_everything_but_dns() {
let p = policy(&[]);
assert_eq!(
p.evaluate(ip("93.184.216.34"), 443, Some("example.com")),
Decision::Deny
);
assert_eq!(p.evaluate(DNS, 53, None), Decision::Allow);
assert_eq!(p.evaluate(DNS, 443, None), Decision::Deny);
assert_eq!(p.evaluate(ip("8.8.8.8"), 53, None), Decision::Deny);
}
#[test]
fn domain_rule_matches_sni_case_insensitively() {
let p = policy(&["api.anthropic.com:443"]);
assert_eq!(
p.evaluate(ip("1.2.3.4"), 443, Some("api.anthropic.com")),
Decision::Allow
);
assert_eq!(
p.evaluate(ip("1.2.3.4"), 443, Some("API.Anthropic.COM")),
Decision::Allow
);
assert_eq!(
p.evaluate(ip("1.2.3.4"), 443, Some("evil.com")),
Decision::Deny
);
assert_eq!(
p.evaluate(ip("1.2.3.4"), 80, Some("api.anthropic.com")),
Decision::Deny
);
assert_eq!(p.evaluate(ip("1.2.3.4"), 443, None), Decision::Deny);
}
#[test]
fn domain_rule_without_port_matches_any_port() {
let p = policy(&["example.com"]);
assert_eq!(
p.evaluate(ip("1.2.3.4"), 443, Some("example.com")),
Decision::Allow
);
assert_eq!(
p.evaluate(ip("1.2.3.4"), 8443, Some("example.com")),
Decision::Allow
);
}
#[test]
fn cidr_rule_matches_destination_ip() {
let p = policy(&["10.0.0.0/24:5432"]);
assert_eq!(p.evaluate(ip("10.0.0.5"), 5432, None), Decision::Allow);
assert_eq!(p.evaluate(ip("10.0.0.250"), 5432, None), Decision::Allow);
assert_eq!(p.evaluate(ip("10.0.1.5"), 5432, None), Decision::Deny);
assert_eq!(p.evaluate(ip("10.0.0.5"), 5433, None), Decision::Deny);
}
#[test]
fn bare_ip_rule_is_a_host_route() {
let p = policy(&["10.0.0.5:5432"]);
assert_eq!(p.evaluate(ip("10.0.0.5"), 5432, None), Decision::Allow);
assert_eq!(p.evaluate(ip("10.0.0.6"), 5432, None), Decision::Deny);
}
#[test]
fn ip_rule_ignores_hostname() {
let p = policy(&["93.184.216.34:443"]);
assert_eq!(
p.evaluate(ip("93.184.216.34"), 443, Some("anything.example")),
Decision::Allow
);
assert_eq!(p.evaluate(ip("93.184.216.34"), 443, None), Decision::Allow);
}
#[test]
fn bracketed_ipv6_with_port() {
let p = policy(&["[2001:db8::1]:443"]);
assert_eq!(p.evaluate(ip("2001:db8::1"), 443, None), Decision::Allow);
assert_eq!(p.evaluate(ip("2001:db8::2"), 443, None), Decision::Deny);
}
#[test]
fn bare_ipv6_without_port_matches_any_port() {
let p = policy(&["2001:db8::1"]);
assert_eq!(p.evaluate(ip("2001:db8::1"), 443, None), Decision::Allow);
assert_eq!(p.evaluate(ip("2001:db8::1"), 9999, None), Decision::Allow);
}
#[test]
fn ipv6_cidr_rule_without_port() {
let p = policy(&["2001:db8::/32"]);
assert_eq!(p.evaluate(ip("2001:db8::dead"), 443, None), Decision::Allow);
assert_eq!(p.evaluate(ip("2001:dead::1"), 443, None), Decision::Deny);
}
#[test]
fn bracketed_ipv6_cidr_with_port() {
let p = policy(&["[2001:db8::/32]:443"]);
assert_eq!(p.evaluate(ip("2001:db8::dead"), 443, None), Decision::Allow);
assert_eq!(p.evaluate(ip("2001:db8::dead"), 80, None), Decision::Deny);
assert_eq!(p.evaluate(ip("2001:dead::1"), 443, None), Decision::Deny);
}
#[test]
fn invalid_port_is_an_error() {
let err = Policy::build(&["host:notaport".to_string()], DNS).unwrap_err();
assert!(matches!(err, NpxcError::Config(_)));
}
#[test]
fn invalid_port_out_of_range_is_an_error() {
let err = Policy::build(&["host:70000".to_string()], DNS).unwrap_err();
assert!(matches!(err, NpxcError::Config(_)));
}
#[test]
fn validate_accepts_well_formed_entries() {
let entries = vec![
"api.anthropic.com:443".to_string(),
"10.0.0.5/32:5432".to_string(),
"example.com".to_string(),
"[2001:db8::1]:443".to_string(),
];
assert!(validate(&entries).is_ok());
}
#[test]
fn validate_rejects_bad_entry() {
assert!(validate(&["good.com:443".to_string(), "bad:port".to_string()]).is_err());
}
#[test]
fn allows_name_matches_domain_rules_case_insensitively() {
let p = policy(&["api.anthropic.com:443", "10.0.0.0/24:5432"]);
assert!(p.allows_name("api.anthropic.com"));
assert!(p.allows_name("API.Anthropic.com"));
assert!(!p.allows_name("evil.com"));
assert!(!p.allows_name("10.0.0.5"));
}
#[test]
fn allows_name_ignores_port() {
let p = policy(&["example.com:443"]);
assert!(p.allows_name("example.com"));
}
#[test]
fn dns_resolver_is_recorded() {
let p = policy(&[]);
assert_eq!(p.dns_resolver(), DNS);
}
}