use std::net::{IpAddr, SocketAddr};
use ipnetwork::IpNetwork;
use serde::{Deserialize, Serialize};
use crate::shared::SharedState;
use super::destination::{matches_cidr, matches_group};
use super::name::{DomainName, DomainNameError};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetworkPolicy {
#[serde(default = "Action::deny")]
pub default_egress: Action,
#[serde(default = "Action::deny")]
pub default_ingress: Action,
#[serde(default)]
pub rules: Vec<Rule>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Action {
Allow,
Deny,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Rule {
pub direction: Direction,
pub destination: Destination,
#[serde(default)]
pub protocols: Vec<Protocol>,
#[serde(default)]
pub ports: Vec<PortRange>,
pub action: Action,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Direction {
Egress,
Ingress,
Any,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Destination {
Any,
Cidr(IpNetwork),
Domain(DomainName),
DomainSuffix(DomainName),
Group(DestinationGroup),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DestinationGroup {
Public,
Loopback,
Private,
LinkLocal,
Metadata,
Multicast,
Host,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Protocol {
Tcp,
Udp,
Icmpv4,
Icmpv6,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortRange {
pub start: u16,
pub end: u16,
}
#[derive(Debug, Clone, Copy)]
pub enum HostnameSource<'a> {
Sni(&'a str),
CacheOnly,
Deferred,
}
impl HostnameSource<'_> {
pub fn label(&self) -> &'static str {
match self {
HostnameSource::Sni(_) => "sni",
HostnameSource::CacheOnly => "cache",
HostnameSource::Deferred => "deferred",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EgressEvaluation {
Allow,
Deny,
DeferUntilHostname,
}
impl NetworkPolicy {
pub fn none() -> Self {
Self {
default_egress: Action::Deny,
default_ingress: Action::Deny,
rules: vec![],
}
}
pub fn allow_all() -> Self {
Self {
default_egress: Action::Allow,
default_ingress: Action::Allow,
rules: vec![],
}
}
pub fn public_only() -> Self {
Self {
default_egress: Action::Deny,
default_ingress: Action::Allow,
rules: vec![Rule::allow_egress(Destination::Group(
DestinationGroup::Public,
))],
}
}
pub fn non_local() -> Self {
Self {
default_egress: Action::Deny,
default_ingress: Action::Allow,
rules: vec![
Rule::allow_egress(Destination::Group(DestinationGroup::Public)),
Rule::allow_egress(Destination::Group(DestinationGroup::Private)),
],
}
}
pub fn evaluate_egress(
&self,
dst: SocketAddr,
protocol: Protocol,
shared: &SharedState,
) -> Action {
self.egress_walk(
dst.ip(),
Some(dst.port()),
protocol,
shared,
HostnameSource::CacheOnly,
)
.into()
}
pub fn evaluate_egress_ip(
&self,
dst: IpAddr,
protocol: Protocol,
shared: &SharedState,
) -> Action {
self.egress_walk(dst, None, protocol, shared, HostnameSource::CacheOnly)
.into()
}
pub fn evaluate_egress_with_source(
&self,
dst: SocketAddr,
protocol: Protocol,
shared: &SharedState,
source: HostnameSource<'_>,
) -> EgressEvaluation {
self.egress_walk(dst.ip(), Some(dst.port()), protocol, shared, source)
}
fn egress_walk(
&self,
addr: IpAddr,
port: Option<u16>,
protocol: Protocol,
shared: &SharedState,
source: HostnameSource<'_>,
) -> EgressEvaluation {
for rule in &self.rules {
if !matches!(rule.direction, Direction::Egress | Direction::Any) {
continue;
}
if !rule.protocols.is_empty() && !rule.protocols.contains(&protocol) {
continue;
}
if !rule.ports.is_empty() {
let Some(p) = port else {
continue;
};
if !rule.ports.iter().any(|range| range.contains(p)) {
continue;
}
}
match matches_destination_with_source(&rule.destination, addr, shared, source) {
DestinationMatch::Match => return rule.action.into(),
DestinationMatch::Defer => return EgressEvaluation::DeferUntilHostname,
DestinationMatch::NoMatch => continue,
}
}
self.default_egress.into()
}
pub fn evaluate_ingress(
&self,
peer: SocketAddr,
guest_port: u16,
protocol: Protocol,
shared: &SharedState,
) -> Action {
for rule in &self.rules {
if !matches!(rule.direction, Direction::Ingress | Direction::Any) {
continue;
}
if !rule_matches(rule, peer.ip(), Some(guest_port), protocol, shared) {
continue;
}
return rule.action;
}
self.default_ingress
}
pub fn has_domain_rules(&self) -> bool {
self.rules.iter().any(|r| {
matches!(
r.destination,
Destination::Domain(_) | Destination::DomainSuffix(_)
)
})
}
pub fn dns_query_denied(&self, name: &DomainName) -> bool {
for rule in &self.rules {
if !matches!(rule.direction, Direction::Egress | Direction::Any) {
continue;
}
let matched = match &rule.destination {
Destination::Domain(d) => name.as_str() == d.as_str(),
Destination::DomainSuffix(s) => matches_suffix(name.as_str(), s.as_str()),
_ => false,
};
if matched {
return rule.action == Action::Deny;
}
}
false
}
pub fn deny_domain<S: AsRef<str>>(self, name: S) -> Result<Self, DomainNameError> {
self.deny_domains([name])
}
pub fn allow_domain<S: AsRef<str>>(self, name: S) -> Result<Self, DomainNameError> {
self.allow_domains([name])
}
pub fn deny_domain_suffix<S: AsRef<str>>(self, suffix: S) -> Result<Self, DomainNameError> {
self.deny_domain_suffixes([suffix])
}
pub fn allow_domain_suffix<S: AsRef<str>>(self, suffix: S) -> Result<Self, DomainNameError> {
self.allow_domain_suffixes([suffix])
}
pub fn deny_domains<I, S>(self, names: I) -> Result<Self, DomainNameError>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
self.prepend_egress_rules(names, |d| Rule::deny_egress(Destination::Domain(d)))
}
pub fn allow_domains<I, S>(self, names: I) -> Result<Self, DomainNameError>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
self.prepend_egress_rules(names, |d| Rule::allow_egress(Destination::Domain(d)))
}
pub fn deny_domain_suffixes<I, S>(self, suffixes: I) -> Result<Self, DomainNameError>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
self.prepend_egress_rules(suffixes, |d| {
Rule::deny_egress(Destination::DomainSuffix(d))
})
}
pub fn allow_domain_suffixes<I, S>(self, suffixes: I) -> Result<Self, DomainNameError>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
self.prepend_egress_rules(suffixes, |d| {
Rule::allow_egress(Destination::DomainSuffix(d))
})
}
fn prepend_egress_rules<I, S, F>(
mut self,
names: I,
mk_rule: F,
) -> Result<Self, DomainNameError>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
F: Fn(DomainName) -> Rule,
{
let mut new_rules = Vec::new();
for name in names {
let domain: DomainName = name.as_ref().parse()?;
new_rules.push(mk_rule(domain));
}
new_rules.extend(std::mem::take(&mut self.rules));
self.rules = new_rules;
Ok(self)
}
}
impl Action {
pub fn is_allow(self) -> bool {
matches!(self, Action::Allow)
}
pub fn is_deny(self) -> bool {
matches!(self, Action::Deny)
}
pub fn allow() -> Self {
Action::Allow
}
pub fn deny() -> Self {
Action::Deny
}
}
impl From<Action> for EgressEvaluation {
fn from(action: Action) -> Self {
match action {
Action::Allow => EgressEvaluation::Allow,
Action::Deny => EgressEvaluation::Deny,
}
}
}
impl From<EgressEvaluation> for Action {
fn from(eval: EgressEvaluation) -> Self {
match eval {
EgressEvaluation::Allow => Action::Allow,
EgressEvaluation::Deny => Action::Deny,
EgressEvaluation::DeferUntilHostname => {
debug_assert!(
false,
"EgressEvaluation::DeferUntilHostname leaked through a CacheOnly/Sni evaluator"
);
Action::Deny
}
}
}
}
impl Default for NetworkPolicy {
fn default() -> Self {
Self::public_only()
}
}
impl Rule {
pub fn allow_egress(destination: Destination) -> Self {
Self::new(Direction::Egress, destination, Action::Allow)
}
pub fn deny_egress(destination: Destination) -> Self {
Self::new(Direction::Egress, destination, Action::Deny)
}
pub fn allow_ingress(destination: Destination) -> Self {
Self::new(Direction::Ingress, destination, Action::Allow)
}
pub fn deny_ingress(destination: Destination) -> Self {
Self::new(Direction::Ingress, destination, Action::Deny)
}
pub fn allow_any(destination: Destination) -> Self {
Self::new(Direction::Any, destination, Action::Allow)
}
pub fn deny_any(destination: Destination) -> Self {
Self::new(Direction::Any, destination, Action::Deny)
}
fn new(direction: Direction, destination: Destination, action: Action) -> Self {
Self {
direction,
destination,
protocols: Vec::new(),
ports: Vec::new(),
action,
}
}
}
impl PortRange {
pub fn single(port: u16) -> Self {
Self {
start: port,
end: port,
}
}
pub fn range(start: u16, end: u16) -> Self {
Self { start, end }
}
pub fn contains(&self, port: u16) -> bool {
port >= self.start && port <= self.end
}
}
fn rule_matches(
rule: &Rule,
addr: IpAddr,
port: Option<u16>,
protocol: Protocol,
shared: &SharedState,
) -> bool {
if !rule.protocols.is_empty() && !rule.protocols.contains(&protocol) {
return false;
}
if !rule.ports.is_empty() {
let Some(p) = port else {
return false;
};
if !rule.ports.iter().any(|range| range.contains(p)) {
return false;
}
}
matches_destination(&rule.destination, addr, shared)
}
enum DestinationMatch {
Match,
NoMatch,
Defer,
}
impl From<bool> for DestinationMatch {
fn from(matched: bool) -> Self {
if matched {
DestinationMatch::Match
} else {
DestinationMatch::NoMatch
}
}
}
fn matches_destination_with_source(
dest: &Destination,
addr: IpAddr,
shared: &SharedState,
source: HostnameSource<'_>,
) -> DestinationMatch {
match dest {
Destination::Any => DestinationMatch::Match,
Destination::Cidr(network) => matches_cidr(network, addr).into(),
Destination::Group(group) => matches_group(*group, addr, shared).into(),
Destination::Domain(domain) => match source {
HostnameSource::Sni(name) => (name == domain.as_str()).into(),
HostnameSource::CacheOnly => shared
.any_resolved_hostname(addr, |hostname| hostname == domain.as_str())
.into(),
HostnameSource::Deferred => DestinationMatch::Defer,
},
Destination::DomainSuffix(suffix) => match source {
HostnameSource::Sni(name) => matches_suffix(name, suffix.as_str()).into(),
HostnameSource::CacheOnly => shared
.any_resolved_hostname(addr, |hostname| matches_suffix(hostname, suffix.as_str()))
.into(),
HostnameSource::Deferred => DestinationMatch::Defer,
},
}
}
fn matches_destination(dest: &Destination, addr: IpAddr, shared: &SharedState) -> bool {
matches!(
matches_destination_with_source(dest, addr, shared, HostnameSource::CacheOnly),
DestinationMatch::Match,
)
}
fn matches_suffix(hostname: &str, suffix: &str) -> bool {
if hostname == suffix {
return true;
}
if hostname.len() > suffix.len() + 1 {
let (prefix, tail) = hostname.split_at(hostname.len() - suffix.len());
return prefix.ends_with('.') && tail == suffix;
}
false
}
#[cfg(test)]
mod tests {
use std::net::{Ipv4Addr, Ipv6Addr};
use std::time::Duration;
use super::*;
use crate::shared::ResolvedHostnameFamily;
const PYPI_V4: &str = "151.101.0.223";
const FILES_V4: &str = "151.101.64.223";
const CLOUDFLARE_V6: &str = "2606:4700:4700::1111";
fn ip(s: &str) -> IpAddr {
s.parse().unwrap()
}
fn sock(ip_str: &str, port: u16) -> SocketAddr {
SocketAddr::new(ip(ip_str), port)
}
fn cache(shared: &SharedState, host: &str, family: ResolvedHostnameFamily, ip_str: &str) {
shared.cache_resolved_hostname(host, family, [ip(ip_str)], Duration::from_secs(60));
}
fn shared_with_host(host: &str, ip_str: &str) -> SharedState {
let shared = SharedState::new(4);
cache(&shared, host, ResolvedHostnameFamily::Ipv4, ip_str);
shared
}
fn shared_with_gateway() -> (SharedState, Ipv4Addr, Ipv6Addr) {
let shared = SharedState::new(4);
let v4 = Ipv4Addr::new(100, 96, 0, 1);
let v6 = Ipv6Addr::new(0xfd42, 0x6d73, 0x62, 0, 0, 0, 0, 1);
shared.set_gateway_ips(Some(v4), Some(v6));
(shared, v4, v6)
}
fn egress_tcp(policy: &NetworkPolicy, ip_str: &str, shared: &SharedState) -> Action {
policy.evaluate_egress(sock(ip_str, 443), Protocol::Tcp, shared)
}
fn allow_rule(dest: Destination) -> NetworkPolicy {
NetworkPolicy {
default_egress: Action::Deny,
default_ingress: Action::Allow,
rules: vec![Rule::allow_egress(dest)],
}
}
fn allow_domain_tcp_443(domain: &str) -> Rule {
Rule {
direction: Direction::Egress,
destination: Destination::Domain(domain.parse().unwrap()),
protocols: vec![Protocol::Tcp],
ports: vec![PortRange::single(443)],
action: Action::Allow,
}
}
#[test]
fn exact_domain_rules_match_resolved_hostnames() {
let shared = shared_with_host("pypi.org", PYPI_V4);
let policy = allow_rule(Destination::Domain("pypi.org".parse().unwrap()));
assert!(egress_tcp(&policy, PYPI_V4, &shared).is_allow());
}
#[test]
fn exact_domain_rules_normalize_user_input() {
let shared = shared_with_host("pypi.org", PYPI_V4);
let policy = allow_rule(Destination::Domain("PyPI.Org.".parse().unwrap()));
assert!(egress_tcp(&policy, PYPI_V4, &shared).is_allow());
}
#[test]
fn suffix_rules_match_resolved_hostnames() {
let shared = shared_with_host("files.pythonhosted.org", FILES_V4);
let policy = allow_rule(Destination::DomainSuffix(
".pythonhosted.org".parse().unwrap(),
));
assert!(egress_tcp(&policy, FILES_V4, &shared).is_allow());
}
#[test]
fn suffix_rules_normalize_user_input() {
let shared = shared_with_host("files.pythonhosted.org", FILES_V4);
let policy = allow_rule(Destination::DomainSuffix(
".PythonHosted.Org.".parse().unwrap(),
));
assert!(egress_tcp(&policy, FILES_V4, &shared).is_allow());
}
#[test]
fn unresolved_domain_rules_do_not_match_by_ip_alone() {
let shared = SharedState::new(4);
let policy = allow_rule(Destination::Domain("pypi.org".parse().unwrap()));
assert!(egress_tcp(&policy, PYPI_V4, &shared).is_deny());
}
#[test]
fn exact_domain_rules_match_resolved_hostnames_for_icmp() {
let shared = shared_with_host("pypi.org", PYPI_V4);
let policy = allow_rule(Destination::Domain("pypi.org".parse().unwrap()));
assert!(
policy
.evaluate_egress_ip(ip(PYPI_V4), Protocol::Icmpv4, &shared)
.is_allow()
);
}
#[test]
fn deserialized_policies_normalize_domain_values() {
let shared = shared_with_host("pypi.org", PYPI_V4);
let policy: NetworkPolicy = serde_json::from_str(
r#"{
"default_egress": "deny",
"default_ingress": "allow",
"rules": [
{
"direction": "egress",
"destination": { "domain": "PyPI.Org." },
"action": "allow"
}
]
}"#,
)
.unwrap();
assert!(egress_tcp(&policy, PYPI_V4, &shared).is_allow());
}
#[test]
fn default_deny_allows_multiple_domain_rules_after_dns() {
let shared = SharedState::new(4);
cache(&shared, "pypi.org", ResolvedHostnameFamily::Ipv4, PYPI_V4);
cache(
&shared,
"files.pythonhosted.org",
ResolvedHostnameFamily::Ipv4,
FILES_V4,
);
let policy = NetworkPolicy {
default_egress: Action::Deny,
default_ingress: Action::Allow,
rules: vec![
allow_domain_tcp_443("pypi.org"),
allow_domain_tcp_443("files.pythonhosted.org"),
],
};
assert!(
policy
.evaluate_egress(sock(PYPI_V4, 443), Protocol::Tcp, &shared)
.is_allow(),
"pypi.org:443 should be allowed after DNS resolution"
);
assert!(
policy
.evaluate_egress(sock(FILES_V4, 443), Protocol::Tcp, &shared)
.is_allow(),
"files.pythonhosted.org:443 should be allowed after DNS resolution"
);
}
#[test]
fn domain_rule_does_not_match_other_cached_hostnames() {
let shared = shared_with_host("pypi.org", PYPI_V4);
let policy = allow_rule(Destination::Domain("example.com".parse().unwrap()));
assert!(egress_tcp(&policy, PYPI_V4, &shared).is_deny());
}
#[test]
fn suffix_rule_matches_apex_domain_itself() {
let shared = shared_with_host("pythonhosted.org", FILES_V4);
let policy = allow_rule(Destination::DomainSuffix(
".pythonhosted.org".parse().unwrap(),
));
assert!(egress_tcp(&policy, FILES_V4, &shared).is_allow());
}
#[test]
fn suffix_rule_does_not_false_match_adjacent_domain() {
let shared = shared_with_host("evilpythonhosted.org", FILES_V4);
let policy = allow_rule(Destination::DomainSuffix(
".pythonhosted.org".parse().unwrap(),
));
assert!(egress_tcp(&policy, FILES_V4, &shared).is_deny());
}
#[test]
fn allow_rule_before_deny_wins_on_shared_ip() {
let shared = shared_with_host("pypi.org", PYPI_V4);
let policy = NetworkPolicy {
default_egress: Action::Deny,
default_ingress: Action::Allow,
rules: vec![
Rule::allow_egress(Destination::Domain("pypi.org".parse().unwrap())),
Rule {
direction: Direction::Egress,
destination: Destination::Cidr("151.101.0.0/16".parse().unwrap()),
protocols: Vec::new(),
ports: Vec::new(),
action: Action::Deny,
},
],
};
assert!(egress_tcp(&policy, PYPI_V4, &shared).is_allow());
}
#[test]
fn udp_egress_consults_domain_cache() {
let shared = shared_with_host("pypi.org", PYPI_V4);
let policy = NetworkPolicy {
default_egress: Action::Deny,
default_ingress: Action::Allow,
rules: vec![Rule {
direction: Direction::Egress,
destination: Destination::Domain("pypi.org".parse().unwrap()),
protocols: vec![Protocol::Udp],
ports: vec![PortRange::single(443)],
action: Action::Allow,
}],
};
assert!(
policy
.evaluate_egress(sock(PYPI_V4, 443), Protocol::Udp, &shared)
.is_allow()
);
}
#[test]
fn ipv4_and_ipv6_caches_are_independent() {
let shared = shared_with_host("pypi.org", PYPI_V4);
let policy = allow_rule(Destination::Domain("pypi.org".parse().unwrap()));
assert!(
egress_tcp(&policy, PYPI_V4, &shared).is_allow(),
"cached IPv4 address should match"
);
assert!(
egress_tcp(&policy, CLOUDFLARE_V6, &shared).is_deny(),
"uncached IPv6 address must not match through an IPv4-only cache entry"
);
}
#[test]
fn group_host_matches_gateway_v4() {
let (shared, gw4, _) = shared_with_gateway();
let policy = NetworkPolicy {
default_egress: Action::Allow,
default_ingress: Action::Allow,
rules: vec![Rule::deny_egress(Destination::Group(
DestinationGroup::Host,
))],
};
let dst = SocketAddr::new(IpAddr::V4(gw4), 80);
assert_eq!(
policy.evaluate_egress(dst, Protocol::Tcp, &shared),
Action::Deny
);
}
#[test]
fn group_host_matches_gateway_v6() {
let (shared, _, gw6) = shared_with_gateway();
let policy = NetworkPolicy {
default_egress: Action::Allow,
default_ingress: Action::Allow,
rules: vec![Rule::deny_egress(Destination::Group(
DestinationGroup::Host,
))],
};
let dst = SocketAddr::new(IpAddr::V6(gw6), 80);
assert_eq!(
policy.evaluate_egress(dst, Protocol::Tcp, &shared),
Action::Deny
);
}
#[test]
fn group_host_does_not_match_other_ips() {
let (shared, _, _) = shared_with_gateway();
let policy = NetworkPolicy {
default_egress: Action::Allow,
default_ingress: Action::Allow,
rules: vec![Rule::deny_egress(Destination::Group(
DestinationGroup::Host,
))],
};
let dst = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)), 80);
assert_eq!(
policy.evaluate_egress(dst, Protocol::Tcp, &shared),
Action::Allow
);
}
#[test]
fn public_only_preset_denies_host_gateway() {
let (shared, gw4, gw6) = shared_with_gateway();
let policy = NetworkPolicy::public_only();
let v4 = SocketAddr::new(IpAddr::V4(gw4), 80);
assert_eq!(
policy.evaluate_egress(v4, Protocol::Tcp, &shared),
Action::Deny,
"default policy should deny host via IPv4 gateway"
);
let v6 = SocketAddr::new(IpAddr::V6(gw6), 80);
assert_eq!(
policy.evaluate_egress(v6, Protocol::Tcp, &shared),
Action::Deny,
"default policy should deny host via IPv6 gateway (ULA fd42::/8)"
);
}
#[test]
fn allow_all_preset_permits_host_gateway() {
let (shared, gw4, _) = shared_with_gateway();
let policy = NetworkPolicy::allow_all();
let v4 = SocketAddr::new(IpAddr::V4(gw4), 80);
assert_eq!(
policy.evaluate_egress(v4, Protocol::Tcp, &shared),
Action::Allow
);
}
#[test]
fn group_host_allow_overrides_private_deny_when_ordered_first() {
let (shared, gw4, _) = shared_with_gateway();
let mut policy = NetworkPolicy::public_only();
policy.rules.insert(
0,
Rule::allow_egress(Destination::Group(DestinationGroup::Host)),
);
let v4 = SocketAddr::new(IpAddr::V4(gw4), 80);
assert_eq!(
policy.evaluate_egress(v4, Protocol::Tcp, &shared),
Action::Allow
);
let other_private = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5)), 80);
assert_eq!(
policy.evaluate_egress(other_private, Protocol::Tcp, &shared),
Action::Deny,
"non-host private destinations should still be blocked"
);
}
#[test]
fn default_ingress_allows_unfiltered_with_no_rules() {
let shared = SharedState::new(4);
let policy = NetworkPolicy::default();
let peer = sock("198.51.100.10", 54321);
assert!(
policy
.evaluate_ingress(peer, 8080, Protocol::Tcp, &shared)
.is_allow()
);
}
#[test]
fn ingress_rule_does_not_fire_on_egress() {
let shared = SharedState::new(4);
let policy = NetworkPolicy {
default_egress: Action::Deny,
default_ingress: Action::Deny,
rules: vec![Rule::allow_ingress(Destination::Group(
DestinationGroup::Private,
))],
};
assert!(
policy
.evaluate_egress(sock("10.0.0.5", 443), Protocol::Tcp, &shared)
.is_deny()
);
assert!(
policy
.evaluate_ingress(sock("10.0.0.5", 54321), 8080, Protocol::Tcp, &shared)
.is_allow()
);
}
#[test]
fn any_direction_rule_matches_egress_and_ingress() {
let shared = SharedState::new(4);
let policy = NetworkPolicy {
default_egress: Action::Allow,
default_ingress: Action::Allow,
rules: vec![Rule::deny_any(Destination::Cidr(
"1.2.3.4/32".parse().unwrap(),
))],
};
assert!(
policy
.evaluate_egress(sock("1.2.3.4", 443), Protocol::Tcp, &shared)
.is_deny()
);
assert!(
policy
.evaluate_ingress(sock("1.2.3.4", 54321), 8080, Protocol::Tcp, &shared)
.is_deny()
);
assert!(
policy
.evaluate_egress(sock("8.8.8.8", 443), Protocol::Tcp, &shared)
.is_allow()
);
}
#[test]
fn serde_round_trip_uses_snake_case() {
let policy = NetworkPolicy {
default_egress: Action::Deny,
default_ingress: Action::Allow,
rules: vec![
Rule {
direction: Direction::Egress,
destination: Destination::Group(DestinationGroup::LinkLocal),
protocols: vec![Protocol::Tcp, Protocol::Icmpv4],
ports: vec![],
action: Action::Allow,
},
Rule {
direction: Direction::Any,
destination: Destination::DomainSuffix(".example.com".parse().unwrap()),
protocols: vec![],
ports: vec![],
action: Action::Deny,
},
],
};
let json = serde_json::to_string(&policy).unwrap();
assert!(json.contains("\"default_egress\""), "JSON: {json}");
assert!(json.contains("\"default_ingress\""), "JSON: {json}");
assert!(json.contains("\"egress\""), "JSON: {json}");
assert!(json.contains("\"any\""), "JSON: {json}");
assert!(json.contains("\"allow\""), "JSON: {json}");
assert!(json.contains("\"deny\""), "JSON: {json}");
assert!(json.contains("\"link_local\""), "JSON: {json}");
assert!(json.contains("\"domain_suffix\""), "JSON: {json}");
assert!(json.contains("\"icmpv4\""), "JSON: {json}");
assert!(json.contains("\"tcp\""), "JSON: {json}");
assert!(!json.contains("\"Egress\""), "JSON: {json}");
assert!(!json.contains("\"Allow\""), "JSON: {json}");
assert!(!json.contains("\"LinkLocal\""), "JSON: {json}");
assert!(!json.contains("\"DomainSuffix\""), "JSON: {json}");
assert!(!json.contains("\"linkLocal\""), "JSON: {json}");
assert!(!json.contains("\"domainSuffix\""), "JSON: {json}");
assert!(!json.contains("\"defaultEgress\""), "JSON: {json}");
let back: NetworkPolicy = serde_json::from_str(&json).unwrap();
assert_eq!(back.rules.len(), policy.rules.len());
assert!(matches!(back.default_egress, Action::Deny));
assert!(matches!(back.default_ingress, Action::Allow));
assert!(matches!(back.rules[0].direction, Direction::Egress));
assert!(matches!(back.rules[1].direction, Direction::Any));
}
#[test]
fn multi_protocol_rule_matches_any_listed() {
let shared = SharedState::new(4);
let policy = NetworkPolicy {
default_egress: Action::Deny,
default_ingress: Action::Allow,
rules: vec![Rule {
direction: Direction::Egress,
destination: Destination::Group(DestinationGroup::Public),
protocols: vec![Protocol::Tcp, Protocol::Udp],
ports: vec![PortRange::single(443)],
action: Action::Allow,
}],
};
assert!(
policy
.evaluate_egress(sock("8.8.8.8", 443), Protocol::Tcp, &shared)
.is_allow()
);
assert!(
policy
.evaluate_egress(sock("8.8.8.8", 443), Protocol::Udp, &shared)
.is_allow()
);
assert!(
policy
.evaluate_egress(sock("8.8.8.8", 443), Protocol::Icmpv4, &shared)
.is_deny()
);
}
#[test]
fn multi_port_rule_matches_any_listed() {
let shared = SharedState::new(4);
let policy = NetworkPolicy {
default_egress: Action::Deny,
default_ingress: Action::Allow,
rules: vec![Rule {
direction: Direction::Egress,
destination: Destination::Group(DestinationGroup::Public),
protocols: vec![Protocol::Tcp],
ports: vec![PortRange::single(80), PortRange::single(443)],
action: Action::Allow,
}],
};
assert!(
policy
.evaluate_egress(sock("8.8.8.8", 80), Protocol::Tcp, &shared)
.is_allow()
);
assert!(
policy
.evaluate_egress(sock("8.8.8.8", 443), Protocol::Tcp, &shared)
.is_allow()
);
assert!(
policy
.evaluate_egress(sock("8.8.8.8", 8080), Protocol::Tcp, &shared)
.is_deny()
);
}
#[test]
fn public_group_matches_complement_of_other_categories() {
let shared = SharedState::new(4);
let policy = allow_rule(Destination::Group(DestinationGroup::Public));
assert!(egress_tcp(&policy, "8.8.8.8", &shared).is_allow());
assert!(egress_tcp(&policy, "10.0.0.5", &shared).is_deny());
assert!(egress_tcp(&policy, "127.0.0.1", &shared).is_deny());
assert!(egress_tcp(&policy, "169.254.169.254", &shared).is_deny());
}
fn deny_domain_policy(dest: Destination) -> NetworkPolicy {
NetworkPolicy {
default_egress: Action::Allow,
default_ingress: Action::Allow,
rules: vec![Rule::deny_egress(dest)],
}
}
fn name(s: &str) -> DomainName {
s.parse().expect("valid domain name")
}
#[test]
fn dns_query_denied_matches_exact_domain() {
let policy = deny_domain_policy(Destination::Domain(name("evil.com")));
assert!(policy.dns_query_denied(&name("evil.com")));
assert!(!policy.dns_query_denied(&name("good.com")));
}
#[test]
fn dns_query_denied_matches_suffix_apex_and_subdomain() {
let policy = deny_domain_policy(Destination::DomainSuffix(name(".evil.com")));
assert!(
policy.dns_query_denied(&name("evil.com")),
"apex must match"
);
assert!(
policy.dns_query_denied(&name("foo.evil.com")),
"subdomain must match"
);
assert!(
policy.dns_query_denied(&name("deep.sub.evil.com")),
"deeper subdomain must match"
);
}
#[test]
fn dns_query_denied_does_not_match_disjoint_suffix() {
let policy = deny_domain_policy(Destination::DomainSuffix(name(".evil.com")));
assert!(!policy.dns_query_denied(&name("notevil.com")));
}
#[test]
fn dns_query_denied_ignores_cidr_group_any_rules() {
let policy = NetworkPolicy {
default_egress: Action::Allow,
default_ingress: Action::Allow,
rules: vec![
Rule::deny_egress(Destination::Any),
Rule::deny_egress(Destination::Cidr("10.0.0.0/8".parse().unwrap())),
Rule::deny_egress(Destination::Group(DestinationGroup::Public)),
],
};
assert!(!policy.dns_query_denied(&name("anything.example")));
}
#[test]
fn dns_query_denied_first_match_wins_when_allow_precedes_deny() {
let policy = NetworkPolicy {
default_egress: Action::Allow,
default_ingress: Action::Allow,
rules: vec![
Rule::allow_egress(Destination::Domain(name("evil.com"))),
Rule::deny_egress(Destination::DomainSuffix(name(".evil.com"))),
],
};
assert!(!policy.dns_query_denied(&name("evil.com")));
assert!(policy.dns_query_denied(&name("foo.evil.com")));
}
#[test]
fn dns_query_denied_ignores_port_and_protocol_filters() {
let policy = NetworkPolicy {
default_egress: Action::Allow,
default_ingress: Action::Allow,
rules: vec![Rule {
direction: Direction::Egress,
destination: Destination::Domain(name("evil.com")),
protocols: vec![Protocol::Tcp],
ports: vec![PortRange::single(443)],
action: Action::Deny,
}],
};
assert!(policy.dns_query_denied(&name("evil.com")));
}
#[test]
fn dns_query_denied_ignores_default_egress() {
let policy = NetworkPolicy {
default_egress: Action::Deny,
default_ingress: Action::Allow,
rules: vec![],
};
assert!(!policy.dns_query_denied(&name("anything.example")));
}
#[test]
fn dns_query_denied_skips_ingress_only_rules() {
let policy = NetworkPolicy {
default_egress: Action::Allow,
default_ingress: Action::Allow,
rules: vec![Rule::deny_ingress(Destination::Domain(name("evil.com")))],
};
assert!(!policy.dns_query_denied(&name("evil.com")));
}
#[test]
fn dns_query_denied_any_direction_rule_applies() {
let policy = NetworkPolicy {
default_egress: Action::Allow,
default_ingress: Action::Allow,
rules: vec![Rule::deny_any(Destination::Domain(name("evil.com")))],
};
assert!(policy.dns_query_denied(&name("evil.com")));
}
#[test]
fn hostname_source_sni_ignores_dns_cache() {
let shared = shared_with_host("evil.com", PYPI_V4);
let policy = allow_rule(Destination::Domain(name("pypi.org")));
let eval = policy.evaluate_egress_with_source(
sock(PYPI_V4, 443),
Protocol::Tcp,
&shared,
HostnameSource::Sni("pypi.org"),
);
assert_eq!(eval, EgressEvaluation::Allow);
}
#[test]
fn hostname_source_sni_denies_when_cache_would_allow() {
let shared = shared_with_host("pypi.org", PYPI_V4);
let policy = allow_rule(Destination::Domain(name("pypi.org")));
let eval = policy.evaluate_egress_with_source(
sock(PYPI_V4, 443),
Protocol::Tcp,
&shared,
HostnameSource::Sni("evil.com"),
);
assert_eq!(eval, EgressEvaluation::Deny);
}
#[test]
fn deferred_returns_defer_for_first_matching_domain_rule() {
let shared = SharedState::new(4);
let policy = NetworkPolicy {
default_egress: Action::Deny,
default_ingress: Action::Allow,
rules: vec![Rule::allow_egress(Destination::Domain(name("pypi.org")))],
};
let eval = policy.evaluate_egress_with_source(
sock(PYPI_V4, 443),
Protocol::Tcp,
&shared,
HostnameSource::Deferred,
);
assert_eq!(eval, EgressEvaluation::DeferUntilHostname);
}
#[test]
fn deferred_returns_allow_for_earlier_matching_cidr_allow() {
let shared = SharedState::new(4);
let policy = NetworkPolicy {
default_egress: Action::Deny,
default_ingress: Action::Allow,
rules: vec![
Rule::allow_egress(Destination::Cidr("151.101.0.0/16".parse().unwrap())),
Rule::allow_egress(Destination::Domain(name("pypi.org"))),
],
};
let eval = policy.evaluate_egress_with_source(
sock(PYPI_V4, 443),
Protocol::Tcp,
&shared,
HostnameSource::Deferred,
);
assert_eq!(eval, EgressEvaluation::Allow);
}
#[test]
fn deferred_returns_deny_for_earlier_matching_cidr_deny() {
let shared = SharedState::new(4);
let policy = NetworkPolicy {
default_egress: Action::Allow,
default_ingress: Action::Allow,
rules: vec![
Rule::deny_egress(Destination::Cidr("151.101.0.0/16".parse().unwrap())),
Rule::allow_egress(Destination::Domain(name("pypi.org"))),
],
};
let eval = policy.evaluate_egress_with_source(
sock(PYPI_V4, 443),
Protocol::Tcp,
&shared,
HostnameSource::Deferred,
);
assert_eq!(eval, EgressEvaluation::Deny);
}
#[test]
fn deferred_skips_domain_rule_pruned_by_protocol_or_port_filter() {
let shared = SharedState::new(4);
let policy = NetworkPolicy {
default_egress: Action::Deny,
default_ingress: Action::Allow,
rules: vec![Rule {
direction: Direction::Egress,
destination: Destination::Domain(name("pypi.org")),
protocols: vec![Protocol::Udp], ports: vec![],
action: Action::Allow,
}],
};
let eval = policy.evaluate_egress_with_source(
sock(PYPI_V4, 443),
Protocol::Tcp,
&shared,
HostnameSource::Deferred,
);
assert_eq!(eval, EgressEvaluation::Deny);
let policy_port = NetworkPolicy {
default_egress: Action::Deny,
default_ingress: Action::Allow,
rules: vec![Rule {
direction: Direction::Egress,
destination: Destination::Domain(name("pypi.org")),
protocols: vec![],
ports: vec![PortRange::single(80)], action: Action::Allow,
}],
};
let eval = policy_port.evaluate_egress_with_source(
sock(PYPI_V4, 443),
Protocol::Tcp,
&shared,
HostnameSource::Deferred,
);
assert_eq!(eval, EgressEvaluation::Deny);
}
#[test]
fn cache_only_source_matches_evaluate_egress_wrapper() {
let shared = shared_with_host("pypi.org", PYPI_V4);
let policy = allow_rule(Destination::Domain(name("pypi.org")));
let dst = sock(PYPI_V4, 443);
let wrapper = policy.evaluate_egress(dst, Protocol::Tcp, &shared);
let with_source = policy.evaluate_egress_with_source(
dst,
Protocol::Tcp,
&shared,
HostnameSource::CacheOnly,
);
assert_eq!(wrapper, Action::Allow);
assert_eq!(with_source, EgressEvaluation::Allow);
}
#[test]
fn hostname_source_sni_matches_domain_suffix() {
let shared = SharedState::new(4); let policy = allow_rule(Destination::DomainSuffix(name(".pythonhosted.org")));
let eval = policy.evaluate_egress_with_source(
sock(FILES_V4, 443),
Protocol::Tcp,
&shared,
HostnameSource::Sni("files.pythonhosted.org"),
);
assert_eq!(eval, EgressEvaluation::Allow);
}
#[test]
#[should_panic(expected = "DeferUntilHostname")]
fn defer_through_action_conversion_debug_panics() {
let _: Action = EgressEvaluation::DeferUntilHostname.into();
}
#[test]
fn deny_domains_prepends_one_rule_per_name() {
let policy = NetworkPolicy {
default_egress: Action::Allow,
default_ingress: Action::Allow,
rules: vec![Rule::allow_egress(Destination::Group(
DestinationGroup::Public,
))],
};
let policy = policy
.deny_domains(["evil.com", "tracker.example"])
.unwrap();
assert_eq!(policy.rules.len(), 3);
assert!(matches!(
&policy.rules[0],
Rule { action: Action::Deny, destination: Destination::Domain(d), .. }
if d.as_str() == "evil.com"
));
assert!(matches!(
&policy.rules[1],
Rule { action: Action::Deny, destination: Destination::Domain(d), .. }
if d.as_str() == "tracker.example"
));
assert!(matches!(
&policy.rules[2].destination,
Destination::Group(DestinationGroup::Public),
));
}
#[test]
fn deny_domains_outranks_existing_allow_public() {
let shared = shared_with_host("evil.com", PYPI_V4);
let policy = NetworkPolicy::default().deny_domains(["evil.com"]).unwrap();
assert!(egress_tcp(&policy, PYPI_V4, &shared).is_deny());
}
#[test]
fn deny_domain_suffixes_prepends_suffix_rules() {
let policy = NetworkPolicy {
default_egress: Action::Allow,
default_ingress: Action::Allow,
rules: vec![],
};
let policy = policy.deny_domain_suffixes([".evil.com"]).unwrap();
assert!(matches!(
&policy.rules[0],
Rule {
action: Action::Deny,
destination: Destination::DomainSuffix(_),
..
}
));
}
#[test]
fn allow_domains_and_deny_domains_chain() {
let policy = NetworkPolicy::default()
.deny_domains(["evil.com"])
.unwrap()
.allow_domains(["pypi.org"])
.unwrap();
assert!(matches!(
&policy.rules[0],
Rule { action: Action::Allow, destination: Destination::Domain(d), .. }
if d.as_str() == "pypi.org"
));
assert!(matches!(
&policy.rules[1],
Rule { action: Action::Deny, destination: Destination::Domain(d), .. }
if d.as_str() == "evil.com"
));
}
#[test]
fn deny_domains_invalid_input_returns_error() {
let result = NetworkPolicy::default().deny_domains(["not a domain!"]);
assert!(result.is_err());
}
#[test]
fn deny_domains_empty_input_is_noop() {
let before = NetworkPolicy::default();
let after = before.clone().deny_domains(Vec::<&str>::new()).unwrap();
assert_eq!(after.rules.len(), before.rules.len());
}
#[test]
fn singular_domain_helpers_match_plural_one_element_form() {
let plural = NetworkPolicy::default().deny_domains(["evil.com"]).unwrap();
let singular = NetworkPolicy::default().deny_domain("evil.com").unwrap();
assert_eq!(plural.rules.len(), singular.rules.len());
assert!(matches!(
(&plural.rules[0], &singular.rules[0]),
(
Rule { destination: Destination::Domain(a), .. },
Rule { destination: Destination::Domain(b), .. },
) if a == b
));
}
#[test]
fn singular_domain_suffix_helper_chains() {
let policy = NetworkPolicy::default()
.deny_domain("evil.com")
.unwrap()
.deny_domain_suffix(".tracking.example")
.unwrap();
assert!(matches!(
&policy.rules[0].destination,
Destination::DomainSuffix(_),
));
assert!(matches!(
&policy.rules[1].destination,
Destination::Domain(_),
));
}
}