#[cfg(target_os = "linux")]
use cellos_core::EgressRule;
pub(crate) fn egress_destination_matches_declared_resolver(
rule_host: &str,
rule_port: u16,
resolvers: &[cellos_core::DnsResolver],
) -> bool {
resolvers.iter().any(|resolver| {
parse_do53_endpoint(resolver)
.is_some_and(|(host, port)| host == rule_host && port == rule_port)
})
}
#[cfg(target_os = "linux")]
pub(crate) fn generate_nft_ruleset(
cell_id: &str,
egress_rules: &[EgressRule],
dns_authority: Option<&cellos_core::DnsAuthority>,
) -> String {
let raw = cellos_core::sanitize_cgroup_leaf_segment(cell_id);
let safe_id = if raw.len() > 32 {
&raw[..32]
} else {
raw.as_str()
};
let table = format!("cellos_{safe_id}");
let mut lines = vec![
format!("table inet {table} {{"),
" chain output {".to_string(),
" type filter hook output priority 0; policy drop;".to_string(),
" oif \"lo\" accept".to_string(),
];
if let Some(dns_auth) = dns_authority {
if dns_auth.block_direct_workload_dns {
for resolver in &dns_auth.resolvers {
if let Some((ip, port)) = parse_do53_endpoint(resolver) {
let nft_proto = match resolver.protocol {
cellos_core::DnsResolverProtocol::Do53Udp => "udp",
cellos_core::DnsResolverProtocol::Do53Tcp => "tcp",
_ => continue,
};
let fam = if ip.contains(':') { "ip6" } else { "ip" };
lines.push(format!(
" {fam} daddr {ip} {nft_proto} dport {port} accept"
));
}
}
lines.push(" udp dport 53 drop".to_string());
lines.push(" tcp dport 53 drop".to_string());
}
if dns_auth.block_udp_doq || dns_auth.block_udp_http3 {
let mut udp_resolver_accepts: Vec<String> = Vec::new();
for resolver in &dns_auth.resolvers {
if let Some((ip, port)) = parse_udp_resolver_endpoint(resolver) {
let fam = if ip.contains(':') { "ip6" } else { "ip" };
let line = format!(" {fam} daddr {ip} udp dport {port} accept");
if !udp_resolver_accepts.contains(&line) {
udp_resolver_accepts.push(line);
}
}
}
for accept_line in udp_resolver_accepts {
lines.push(accept_line);
}
if dns_auth.block_udp_doq {
lines.push(" udp dport 853 drop".to_string());
}
if dns_auth.block_udp_http3 {
lines.push(" udp dport 443 drop".to_string());
}
}
}
for rule in egress_rules {
let proto = rule.protocol.as_deref().unwrap_or("tcp");
lines.push(format!(
" ip daddr {} {} dport {} accept",
rule.host, proto, rule.port
));
lines.push(format!(
" ip6 daddr {} {} dport {} accept",
rule.host, proto, rule.port
));
}
lines.push(" }".to_string());
lines.push("}".to_string());
lines.join("\n")
}
pub(crate) fn pick_resolver_socket_addr(
resolvers: &[cellos_core::DnsResolver],
) -> std::net::SocketAddr {
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
for resolver in resolvers {
if let Some((host, port)) = parse_do53_endpoint(resolver) {
if let Ok(ip) = host.parse::<IpAddr>() {
return SocketAddr::new(ip, port);
}
}
}
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 53)), 53)
}
pub(crate) fn parse_do53_endpoint(resolver: &cellos_core::DnsResolver) -> Option<(String, u16)> {
use cellos_core::DnsResolverProtocol;
match resolver.protocol {
DnsResolverProtocol::Do53Udp | DnsResolverProtocol::Do53Tcp => {}
_ => return None,
}
let endpoint = resolver.endpoint.trim();
if let Some(stripped) = endpoint.strip_prefix('[') {
let close = stripped.find(']')?;
let host = &stripped[..close];
let rest = &stripped[close + 1..];
let port_str = rest.strip_prefix(':')?;
let port: u16 = port_str.parse().ok()?;
return Some((host.to_string(), port));
}
let (host, port_str) = endpoint.rsplit_once(':')?;
let port: u16 = port_str.parse().ok()?;
if host.is_empty() {
return None;
}
Some((host.to_string(), port))
}
#[cfg(target_os = "linux")]
pub(crate) fn parse_udp_resolver_endpoint(
resolver: &cellos_core::DnsResolver,
) -> Option<(String, u16)> {
use cellos_core::DnsResolverProtocol;
match resolver.protocol {
DnsResolverProtocol::Do53Udp | DnsResolverProtocol::Doh | DnsResolverProtocol::Doq => {}
DnsResolverProtocol::Do53Tcp | DnsResolverProtocol::Dot => return None,
}
let endpoint = resolver.endpoint.trim();
let stripped_scheme = endpoint
.strip_prefix("https://")
.or_else(|| endpoint.strip_prefix("quic://"))
.or_else(|| endpoint.strip_prefix("h3://"))
.unwrap_or(endpoint);
let host_port = match stripped_scheme.find('/') {
Some(idx) => &stripped_scheme[..idx],
None => stripped_scheme,
};
if let Some(after_open) = host_port.strip_prefix('[') {
let close = after_open.find(']')?;
let host = &after_open[..close];
let rest = &after_open[close + 1..];
let port_str = rest.strip_prefix(':')?;
let port: u16 = port_str.parse().ok()?;
return Some((host.to_string(), port));
}
let (host, port_str) = host_port.rsplit_once(':')?;
let port: u16 = port_str.parse().ok()?;
if host.is_empty() {
return None;
}
Some((host.to_string(), port))
}
#[cfg(target_os = "linux")]
pub(crate) fn apply_nft_in_ns(child_pid: u32, ruleset: &str) -> bool {
use std::io::Write;
let netns = format!("/proc/{child_pid}/ns/net");
let mut nft = match std::process::Command::new("nsenter")
.args(["--net", &netns, "nft", "-f", "-"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => {
tracing::warn!(
target: "cellos.supervisor.linux_isolation",
error = %e,
"nsenter unavailable — nftables policy not applied (install util-linux + nftables)"
);
return false;
}
};
if let Some(mut stdin) = nft.stdin.take() {
if let Err(e) = stdin.write_all(ruleset.as_bytes()) {
tracing::warn!(
target: "cellos.supervisor.linux_isolation",
error = %e,
"nft stdin write failed"
);
}
}
match nft.wait() {
Ok(status) if status.success() => true,
Ok(status) => {
tracing::warn!(
target: "cellos.supervisor.linux_isolation",
code = ?status.code(),
"nft ruleset apply failed (non-zero exit) — process may have already exited"
);
false
}
Err(e) => {
tracing::warn!(
target: "cellos.supervisor.linux_isolation",
error = %e,
"nft wait failed"
);
false
}
}
}
#[cfg(target_os = "linux")]
pub(crate) fn scan_nft_counters_in_ns(netns_path: &str) -> Vec<crate::nft_counters::NftCounterRow> {
let output = std::process::Command::new("nsenter")
.args(["--net", netns_path, "nft", "-j", "list", "ruleset"])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output();
let raw = match output {
Ok(o) if o.status.success() => o.stdout,
Ok(o) => {
tracing::warn!(
target: "cellos.supervisor.per_flow_enforcement",
code = ?o.status.code(),
stderr = %String::from_utf8_lossy(&o.stderr),
"nft list ruleset --json failed (non-zero exit) — per-flow events skipped"
);
return Vec::new();
}
Err(e) => {
tracing::warn!(
target: "cellos.supervisor.per_flow_enforcement",
error = %e,
"nsenter/nft unavailable — per-flow events skipped (install util-linux + nftables)"
);
return Vec::new();
}
};
let raw_str = match std::str::from_utf8(&raw) {
Ok(s) => s,
Err(e) => {
tracing::warn!(
target: "cellos.supervisor.per_flow_enforcement",
error = %e,
"nft JSON output was not valid UTF-8 — per-flow events skipped"
);
return Vec::new();
}
};
match crate::nft_counters::parse_nft_list_ruleset_json(raw_str) {
Ok(rows) => rows,
Err(e) => {
tracing::warn!(
target: "cellos.supervisor.per_flow_enforcement",
error = %e,
"nft JSON parse failed — per-flow events skipped"
);
Vec::new()
}
}
}
#[cfg(target_os = "linux")]
pub(crate) fn read_proc_caps() -> [Option<String>; 5] {
let Ok(status) = std::fs::read_to_string("/proc/self/status") else {
return [None, None, None, None, None];
};
let mut eff = None;
let mut prm = None;
let mut bnd = None;
let mut amb = None;
let mut inh = None;
for line in status.lines() {
if let Some(val) = line.strip_prefix("CapEff:\t") {
eff = Some(val.trim().to_ascii_lowercase());
} else if let Some(val) = line.strip_prefix("CapPrm:\t") {
prm = Some(val.trim().to_ascii_lowercase());
} else if let Some(val) = line.strip_prefix("CapBnd:\t") {
bnd = Some(val.trim().to_ascii_lowercase());
} else if let Some(val) = line.strip_prefix("CapAmb:\t") {
amb = Some(val.trim().to_ascii_lowercase());
} else if let Some(val) = line.strip_prefix("CapInh:\t") {
inh = Some(val.trim().to_ascii_lowercase());
}
}
[eff, prm, bnd, amb, inh]
}
#[cfg(test)]
#[cfg(target_os = "linux")]
mod tests {
use super::generate_nft_ruleset;
use cellos_core::{DnsAuthority, DnsResolver, DnsResolverProtocol, EgressRule};
fn declared_resolver() -> DnsResolver {
DnsResolver {
resolver_id: "resolver-do53-internal".into(),
endpoint: "10.10.0.53:53".into(),
protocol: DnsResolverProtocol::Do53Udp,
trust_kid: None,
dnssec: None,
}
}
#[test]
fn nft_blocks_external_resolver_when_block_direct_workload_dns_set() {
let egress = vec![EgressRule {
host: "8.8.8.8".into(),
port: 53,
protocol: Some("udp".into()),
dns_egress_justification: None,
}];
let dns_auth = DnsAuthority {
resolvers: vec![declared_resolver()],
block_direct_workload_dns: true,
..DnsAuthority::default()
};
let ruleset = generate_nft_ruleset("cell-test", &egress, Some(&dns_auth));
assert!(
ruleset.contains("udp dport 53 drop"),
"expected udp dport 53 drop in:\n{ruleset}"
);
assert!(
ruleset.contains("tcp dport 53 drop"),
"expected tcp dport 53 drop in:\n{ruleset}"
);
let drop_idx = ruleset
.find("udp dport 53 drop")
.expect("drop line present");
let accept_idx = ruleset
.find("ip daddr 8.8.8.8 udp dport 53 accept")
.expect("egress accept line present");
assert!(
drop_idx < accept_idx,
"drop must precede accept; drop@{drop_idx} accept@{accept_idx}\n{ruleset}"
);
}
#[test]
fn nft_allows_declared_resolver_endpoint() {
let egress: Vec<EgressRule> = Vec::new();
let dns_auth = DnsAuthority {
resolvers: vec![declared_resolver()],
block_direct_workload_dns: true,
..DnsAuthority::default()
};
let ruleset = generate_nft_ruleset("cell-allow", &egress, Some(&dns_auth));
assert!(
ruleset.contains("ip daddr 10.10.0.53 udp dport 53 accept"),
"expected resolver allow line in:\n{ruleset}"
);
let allow_idx = ruleset
.find("ip daddr 10.10.0.53 udp dport 53 accept")
.expect("resolver allow present");
let drop_idx = ruleset
.find("udp dport 53 drop")
.expect("drop line present");
assert!(
allow_idx < drop_idx,
"resolver allow must precede :53 drop; allow@{allow_idx} drop@{drop_idx}\n{ruleset}"
);
}
#[test]
fn nft_unchanged_when_block_direct_workload_dns_disabled() {
let egress = vec![EgressRule {
host: "10.0.0.1".into(),
port: 443,
protocol: Some("tcp".into()),
dns_egress_justification: None,
}];
let ruleset_a = generate_nft_ruleset("cell-legacy", &egress, None);
assert!(
!ruleset_a.contains("udp dport 53 drop"),
"legacy ruleset must not gain SEC-22 drop:\n{ruleset_a}"
);
let dns_auth = DnsAuthority {
resolvers: vec![declared_resolver()],
block_direct_workload_dns: false,
..DnsAuthority::default()
};
let ruleset_b = generate_nft_ruleset("cell-flagoff", &egress, Some(&dns_auth));
assert!(
!ruleset_b.contains("udp dport 53 drop"),
"ruleset must not gain SEC-22 drop when flag false:\n{ruleset_b}"
);
assert!(
ruleset_b.contains("ip daddr 10.0.0.1 tcp dport 443 accept"),
"legacy egress accept must still be emitted:\n{ruleset_b}"
);
}
#[test]
fn nft_handles_bracketed_ipv6_resolver_endpoint() {
let dns_auth = DnsAuthority {
resolvers: vec![DnsResolver {
resolver_id: "resolver-v6".into(),
endpoint: "[fe80::1]:53".into(),
protocol: DnsResolverProtocol::Do53Tcp,
trust_kid: None,
dnssec: None,
}],
block_direct_workload_dns: true,
..DnsAuthority::default()
};
let ruleset = generate_nft_ruleset("cell-v6", &[], Some(&dns_auth));
assert!(
ruleset.contains("ip6 daddr fe80::1 tcp dport 53 accept"),
"expected ip6 resolver allow in:\n{ruleset}"
);
}
#[test]
fn nft_drops_port_53_udp_when_not_in_egress_rules() {
let egress = vec![EgressRule {
host: "10.0.0.1".into(),
port: 443,
protocol: Some("tcp".into()),
dns_egress_justification: None,
}];
let ruleset = generate_nft_ruleset("cell-fc34a", &egress, None);
assert!(
!ruleset.contains("udp dport 53 accept"),
"no udp/53 accept must be emitted when no port-53 egress declared:\n{ruleset}"
);
assert!(
ruleset.contains("type filter hook output priority 0; policy drop;"),
"missing chain-level policy drop in:\n{ruleset}"
);
assert!(
ruleset.contains("ip daddr 10.0.0.1 tcp dport 443 accept"),
"expected non-DNS egress accept to remain present:\n{ruleset}"
);
}
#[test]
fn nft_drops_port_53_tcp_when_not_in_egress_rules() {
let egress = vec![EgressRule {
host: "203.0.113.7".into(),
port: 22,
protocol: Some("tcp".into()),
dns_egress_justification: None,
}];
let ruleset = generate_nft_ruleset("cell-fc34b", &egress, None);
assert!(
!ruleset.contains("tcp dport 53 accept"),
"no tcp/53 accept must be emitted when no port-53 egress declared:\n{ruleset}"
);
assert!(
ruleset.contains("type filter hook output priority 0; policy drop;"),
"missing chain-level policy drop in:\n{ruleset}"
);
assert!(
ruleset.contains("ip daddr 203.0.113.7 tcp dport 22 accept"),
"expected non-DNS egress accept to remain present:\n{ruleset}"
);
}
fn doh_resolver_one_one_one_one() -> DnsResolver {
DnsResolver {
resolver_id: "test-doh".into(),
endpoint: "https://1.1.1.1:443/dns-query".into(),
protocol: DnsResolverProtocol::Doh,
trust_kid: None,
dnssec: None,
}
}
fn doq_resolver_one_one_one_one() -> DnsResolver {
DnsResolver {
resolver_id: "test-doq".into(),
endpoint: "quic://1.1.1.1:853".into(),
protocol: DnsResolverProtocol::Doq,
trust_kid: None,
dnssec: None,
}
}
#[test]
fn nft_drops_udp_853_when_block_udp_doq_set() {
let dns_auth = DnsAuthority {
resolvers: vec![doh_resolver_one_one_one_one()],
block_udp_doq: true,
..DnsAuthority::default()
};
let ruleset = generate_nft_ruleset("cell-doq-drop", &[], Some(&dns_auth));
assert!(
ruleset.contains("udp dport 853 drop"),
"expected udp dport 853 drop in:\n{ruleset}"
);
assert!(
!ruleset.contains("udp dport 853 accept"),
"no resolver accept on UDP/853 expected when only DoH declared:\n{ruleset}"
);
assert!(
ruleset.contains("ip daddr 1.1.1.1 udp dport 443 accept"),
"expected DoH resolver accept-leg in:\n{ruleset}"
);
}
#[test]
fn nft_allows_udp_853_to_declared_resolver_when_block_udp_doq_set() {
let dns_auth = DnsAuthority {
resolvers: vec![doq_resolver_one_one_one_one()],
block_udp_doq: true,
..DnsAuthority::default()
};
let ruleset = generate_nft_ruleset("cell-doq-allow", &[], Some(&dns_auth));
assert!(
ruleset.contains("ip daddr 1.1.1.1 udp dport 853 accept"),
"expected DoQ resolver accept-leg in:\n{ruleset}"
);
assert!(
ruleset.contains("udp dport 853 drop"),
"expected UDP/853 default drop in:\n{ruleset}"
);
let allow_idx = ruleset
.find("ip daddr 1.1.1.1 udp dport 853 accept")
.expect("resolver allow present");
let drop_idx = ruleset
.find("udp dport 853 drop")
.expect("drop line present");
assert!(
allow_idx < drop_idx,
"DoQ accept must precede DoQ drop; allow@{allow_idx} drop@{drop_idx}\n{ruleset}"
);
}
#[test]
fn nft_does_not_drop_udp_853_when_block_udp_doq_unset() {
let dns_auth = DnsAuthority {
resolvers: vec![doq_resolver_one_one_one_one()],
..DnsAuthority::default()
};
let ruleset = generate_nft_ruleset("cell-doq-off", &[], Some(&dns_auth));
assert!(
!ruleset.contains("udp dport 853 drop"),
"no UDP/853 drop expected when block_udp_doq=false:\n{ruleset}"
);
}
#[test]
fn nft_drops_udp_443_when_block_udp_http3_set() {
let dns_auth = DnsAuthority {
resolvers: vec![DnsResolver {
resolver_id: "test-do53".into(),
endpoint: "8.8.8.8:53".into(),
protocol: DnsResolverProtocol::Do53Udp,
trust_kid: None,
dnssec: None,
}],
block_udp_http3: true,
..DnsAuthority::default()
};
let ruleset = generate_nft_ruleset("cell-h3-drop", &[], Some(&dns_auth));
assert!(
ruleset.contains("udp dport 443 drop"),
"expected udp dport 443 drop in:\n{ruleset}"
);
assert!(
ruleset.contains("ip daddr 8.8.8.8 udp dport 53 accept"),
"expected do53-udp resolver accept-leg in:\n{ruleset}"
);
}
#[test]
fn nft_allows_udp_443_to_declared_resolver_when_block_udp_http3_set() {
let dns_auth = DnsAuthority {
resolvers: vec![doh_resolver_one_one_one_one()],
block_udp_http3: true,
..DnsAuthority::default()
};
let ruleset = generate_nft_ruleset("cell-h3-allow", &[], Some(&dns_auth));
assert!(
ruleset.contains("ip daddr 1.1.1.1 udp dport 443 accept"),
"expected DoH resolver accept-leg in:\n{ruleset}"
);
assert!(
ruleset.contains("udp dport 443 drop"),
"expected UDP/443 drop in:\n{ruleset}"
);
let allow_idx = ruleset
.find("ip daddr 1.1.1.1 udp dport 443 accept")
.expect("resolver allow present");
let drop_idx = ruleset
.find("udp dport 443 drop")
.expect("drop line present");
assert!(
allow_idx < drop_idx,
"HTTP/3 accept must precede HTTP/3 drop; allow@{allow_idx} drop@{drop_idx}\n{ruleset}"
);
}
#[test]
fn nft_does_not_drop_udp_443_when_block_udp_http3_unset() {
let dns_auth = DnsAuthority {
resolvers: vec![doh_resolver_one_one_one_one()],
..DnsAuthority::default()
};
let ruleset = generate_nft_ruleset("cell-h3-off", &[], Some(&dns_auth));
assert!(
!ruleset.contains("udp dport 443 drop"),
"no UDP/443 drop expected when block_udp_http3=false:\n{ruleset}"
);
}
#[test]
fn nft_block_udp_doq_and_block_udp_http3_dedupes_resolver_accept_lines() {
let dns_auth = DnsAuthority {
resolvers: vec![doh_resolver_one_one_one_one()],
block_udp_doq: true,
block_udp_http3: true,
..DnsAuthority::default()
};
let ruleset = generate_nft_ruleset("cell-both", &[], Some(&dns_auth));
let accept_count = ruleset
.matches("ip daddr 1.1.1.1 udp dport 443 accept")
.count();
assert_eq!(
accept_count, 1,
"DoH accept-leg must dedupe to one line when both flags set:\n{ruleset}"
);
assert!(
ruleset.contains("udp dport 853 drop"),
"expected DoQ drop in:\n{ruleset}"
);
assert!(
ruleset.contains("udp dport 443 drop"),
"expected HTTP/3 drop in:\n{ruleset}"
);
}
}