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;
#[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,
}
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 {
for rule in &self.rules {
if !matches!(rule.direction, Direction::Egress | Direction::Any) {
continue;
}
if !rule_matches(rule, dst.ip(), Some(dst.port()), protocol, shared) {
continue;
}
return rule.action;
}
self.default_egress
}
pub fn evaluate_egress_ip(
&self,
dst: IpAddr,
protocol: Protocol,
shared: &SharedState,
) -> Action {
for rule in &self.rules {
if !matches!(rule.direction, Direction::Egress | Direction::Any) {
continue;
}
if !rule.ports.is_empty() {
continue;
}
if !rule_matches(rule, dst, None, protocol, shared) {
continue;
}
return rule.action;
}
self.default_egress
}
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
}
}
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 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)
}
fn matches_destination(dest: &Destination, addr: IpAddr, shared: &SharedState) -> bool {
match dest {
Destination::Any => true,
Destination::Cidr(network) => matches_cidr(network, addr),
Destination::Group(group) => matches_group(*group, addr, shared),
Destination::Domain(domain) => {
shared.any_resolved_hostname(addr, |hostname| hostname == domain.as_str())
}
Destination::DomainSuffix(suffix) => {
shared.any_resolved_hostname(addr, |hostname| matches_suffix(hostname, suffix.as_str()))
}
}
}
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(v4, 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());
}
}