use std::process::Command; use serde_json::Value; use crate::{config::Config, error::AppError}; #[derive(Debug, Clone)] pub struct RuntimeTarget { pub container_name: String, pub container_id: String, pub container_pid: u32, pub netns_path: String, pub interface_name: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TriState { True, False, Unknown, } impl TriState { pub fn as_str(self) -> &'static str { match self { Self::True => "true", Self::False => "false", Self::Unknown => "unknown", } } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct PublishedUdpPortPath { pub host_port: u16, pub container_ip: String, pub container_port: u16, } #[derive(Debug, Clone)] pub struct PortPathDiagnostics { pub docker_userland_proxy: TriState, pub published_udp_port: Option, pub host_forward_policy_drop: TriState, pub docker_forward_rules_present: TriState, } impl PortPathDiagnostics { pub fn warning(&self) -> Option<&'static str> { if self.docker_userland_proxy == TriState::False && self.host_forward_policy_drop == TriState::True && self.docker_forward_rules_present != TriState::True { return Some( "userland-proxy=false AND host FORWARD policy DROP AND Docker accept rules are missing/unknown", ); } None } } pub fn discover_target(cfg: &Config) -> anyhow::Result { let container_name = find_running_container(&cfg.amnezia.container_names)?; let container_id = run_capture(&["docker", "inspect", "--format", "{{.Id}}", &container_name])? .trim() .to_string(); let pid_str = run_capture(&[ "docker", "inspect", "--format", "{{.State.Pid}}", &container_name, ])?; let container_pid = pid_str.trim().parse::()?; let netns_path = format!("/proc/{container_pid}/ns/net"); let interface_name = crate::iface::detect_interface(cfg, container_pid, &container_name)?; Ok(RuntimeTarget { container_name, container_id, container_pid, netns_path, interface_name, }) } fn find_running_container(names: &[String]) -> anyhow::Result { let ps = run_capture(&["docker", "ps", "--format", "{{.Names}}"])?; for name in names { if ps.lines().any(|x| x.trim() == name) { return Ok(name.clone()); } } Err(AppError::NotFound("amnezia container not running".to_string()).into()) } pub fn run_capture(args: &[&str]) -> anyhow::Result { let (program, rest) = args .split_first() .ok_or_else(|| anyhow::anyhow!("empty command"))?; let out = Command::new(program).args(rest).output()?; if !out.status.success() { return Err(AppError::Command(format!( "{} {:?}: {}", program, rest, String::from_utf8_lossy(&out.stderr) )) .into()); } Ok(String::from_utf8_lossy(&out.stdout).to_string()) } pub fn collect_port_path_diagnostics( container_name: &str, expected_udp_port: u16, ) -> PortPathDiagnostics { let docker_userland_proxy = detect_userland_proxy().unwrap_or(TriState::Unknown); let published_udp_port = discover_published_udp_port(container_name, expected_udp_port).ok(); let (host_forward_policy_drop, docker_forward_rules_present) = inspect_forward_state().unwrap_or((TriState::Unknown, TriState::Unknown)); PortPathDiagnostics { docker_userland_proxy, published_udp_port, host_forward_policy_drop, docker_forward_rules_present, } } fn detect_userland_proxy() -> anyhow::Result { let raw = run_capture(&["docker", "info", "--format", "{{json .}}"])?; Ok(parse_userland_proxy_from_docker_info(&raw)) } fn discover_published_udp_port( container_name: &str, expected_udp_port: u16, ) -> anyhow::Result { let raw = run_capture(&["docker", "inspect", container_name])?; parse_udp_port_path_from_inspect(&raw, expected_udp_port) } fn inspect_iptables_forward_state() -> anyhow::Result<(TriState, TriState)> { let fwd = run_capture(&["iptables", "-S", "FORWARD"])?; Ok(parse_forward_policy_and_docker_rules(&fwd)) } fn inspect_forward_state() -> anyhow::Result<(TriState, TriState)> { let iptables_state = inspect_iptables_forward_state().unwrap_or((TriState::Unknown, TriState::Unknown)); let nft_state = inspect_nft_forward_state().unwrap_or((TriState::Unknown, TriState::Unknown)); Ok(merge_forward_states(iptables_state, nft_state)) } fn inspect_nft_forward_state() -> anyhow::Result<(TriState, TriState)> { let ruleset = run_capture(&["nft", "list", "ruleset"])?; Ok(parse_nft_forward_policy_and_docker_rules(&ruleset)) } fn merge_forward_states(a: (TriState, TriState), b: (TriState, TriState)) -> (TriState, TriState) { (merge_policy_drop(a.0, b.0), merge_docker_rules(a.1, b.1)) } fn merge_policy_drop(a: TriState, b: TriState) -> TriState { if a == TriState::True || b == TriState::True { TriState::True } else if a == TriState::False || b == TriState::False { TriState::False } else { TriState::Unknown } } fn merge_docker_rules(a: TriState, b: TriState) -> TriState { if a == TriState::True || b == TriState::True { TriState::True } else if a == TriState::False && b == TriState::False { TriState::False } else { TriState::Unknown } } fn parse_userland_proxy_from_docker_info(raw: &str) -> TriState { let Ok(v) = serde_json::from_str::(raw) else { return TriState::Unknown; }; match v.get("UserlandProxy").and_then(|x| x.as_bool()) { Some(true) => TriState::True, Some(false) => TriState::False, None => TriState::Unknown, } } fn parse_udp_port_path_from_inspect( raw: &str, expected_udp_port: u16, ) -> anyhow::Result { let v: Value = serde_json::from_str(raw)?; let arr = v .as_array() .ok_or_else(|| anyhow::anyhow!("docker inspect output is not array"))?; let obj = arr .first() .ok_or_else(|| anyhow::anyhow!("docker inspect output is empty"))?; let networks = obj .get("NetworkSettings") .and_then(|x| x.get("Networks")) .and_then(|x| x.as_object()); let container_ip = networks .and_then(|nets| { nets.values() .find_map(|n| n.get("IPAddress").and_then(|x| x.as_str())) }) .unwrap_or("unknown") .to_string(); let ports = obj .get("NetworkSettings") .and_then(|x| x.get("Ports")) .and_then(|x| x.as_object()) .ok_or_else(|| anyhow::anyhow!("docker inspect has no NetworkSettings.Ports"))?; let mut udp_bindings: Vec<(u16, u16)> = Vec::new(); for (k, v) in ports { if !k.ends_with("/udp") { continue; } let Some(host_port) = extract_host_port_from_binding(v) else { continue; }; let container_port = k .split('/') .next() .and_then(|x| x.parse::().ok()) .ok_or_else(|| anyhow::anyhow!("invalid udp port key in docker inspect: {k}"))?; udp_bindings.push((host_port, container_port)); } if let Some((host_port, container_port)) = udp_bindings .iter() .copied() .find(|(host_port, _)| *host_port == expected_udp_port) { return Ok(PublishedUdpPortPath { host_port, container_ip, container_port, }); } let expected_key = format!("{expected_udp_port}/udp"); if let Some(host_port) = extract_host_port_for_udp_key(ports, &expected_key) { return Ok(PublishedUdpPortPath { host_port, container_ip, container_port: expected_udp_port, }); } if udp_bindings.len() == 1 { let (host_port, container_port) = udp_bindings[0]; return Ok(PublishedUdpPortPath { host_port, container_ip, container_port, }); } if udp_bindings.len() > 1 { return Err(anyhow::anyhow!( "ambiguous udp published ports in docker inspect" )); } Err(anyhow::anyhow!( "no published udp port found in docker inspect" )) } fn extract_host_port_for_udp_key(ports: &serde_json::Map, key: &str) -> Option { let v = ports.get(key)?; extract_host_port_from_binding(v) } fn extract_host_port_from_binding(v: &Value) -> Option { let arr = v.as_array()?; let first = arr.first()?.as_object()?; first .get("HostPort") .and_then(|x| x.as_str()) .and_then(|x| x.parse::().ok()) } fn parse_forward_policy_and_docker_rules(raw: &str) -> (TriState, TriState) { let mut policy_drop = TriState::Unknown; let mut docker_rules = TriState::False; for line in raw.lines() { let t = line.trim(); if t.starts_with("-P FORWARD ") { policy_drop = if t.eq_ignore_ascii_case("-P FORWARD DROP") { TriState::True } else { TriState::False }; } if t.contains(" DOCKER") || t.contains(" DOCKER-") { docker_rules = TriState::True; } } (policy_drop, docker_rules) } fn parse_nft_forward_policy_and_docker_rules(raw: &str) -> (TriState, TriState) { let nt = normalize_nft_forward_text(raw); let policy_drop = if nt.contains("hook forward") && nt.contains("policy drop") { TriState::True } else if nt.contains("hook forward") && nt.contains("policy accept") { TriState::False } else { TriState::Unknown }; let docker_rules = if nt.contains("docker") && nt.contains("forward") { TriState::True } else { TriState::Unknown }; (policy_drop, docker_rules) } fn normalize_nft_forward_text(raw: &str) -> String { raw.to_ascii_lowercase() .replace(['\n', '\t', '{', '}', ';', '"'], " ") .split_whitespace() .collect::>() .join(" ") } #[cfg(test)] mod tests { use super::*; #[test] fn parse_userland_proxy_from_docker_info_works() { assert_eq!( parse_userland_proxy_from_docker_info(r#"{"UserlandProxy":true}"#), TriState::True ); assert_eq!( parse_userland_proxy_from_docker_info(r#"{"UserlandProxy":false}"#), TriState::False ); assert_eq!( parse_userland_proxy_from_docker_info("{}"), TriState::Unknown ); } #[test] fn parse_udp_port_path_from_inspect_works() { let raw = r#"[ { "NetworkSettings": { "Networks": { "bridge": { "IPAddress": "172.29.172.2" } }, "Ports": { "443/udp": [{ "HostIp": "0.0.0.0", "HostPort": "443" }] } } } ]"#; let got = parse_udp_port_path_from_inspect(raw, 443).expect("parse inspect"); assert_eq!( got, PublishedUdpPortPath { host_port: 443, container_ip: "172.29.172.2".to_string(), container_port: 443, } ); } #[test] fn parse_udp_port_prefers_host_port_match() { let raw = r#"[ { "NetworkSettings": { "Networks": { "bridge": { "IPAddress": "172.29.172.2" } }, "Ports": { "44344/udp": [{ "HostIp": "0.0.0.0", "HostPort": "443" }], "443/udp": [{ "HostIp": "0.0.0.0", "HostPort": "5443" }] } } } ]"#; let got = parse_udp_port_path_from_inspect(raw, 443).expect("parse inspect"); assert_eq!( got, PublishedUdpPortPath { host_port: 443, container_ip: "172.29.172.2".to_string(), container_port: 44344, } ); } #[test] fn parse_udp_port_fallbacks_to_expected_key() { let raw = r#"[ { "NetworkSettings": { "Networks": { "bridge": { "IPAddress": "172.29.172.2" } }, "Ports": { "443/udp": [{ "HostIp": "0.0.0.0", "HostPort": "5443" }], "44344/udp": [{ "HostIp": "0.0.0.0", "HostPort": "15443" }] } } } ]"#; let got = parse_udp_port_path_from_inspect(raw, 443).expect("parse inspect"); assert_eq!( got, PublishedUdpPortPath { host_port: 5443, container_ip: "172.29.172.2".to_string(), container_port: 443, } ); } #[test] fn parse_udp_port_uses_single_udp_binding() { let raw = r#"[ { "NetworkSettings": { "Networks": { "bridge": { "IPAddress": "172.29.172.2" } }, "Ports": { "44344/udp": [{ "HostIp": "0.0.0.0", "HostPort": "44344" }] } } } ]"#; let got = parse_udp_port_path_from_inspect(raw, 443).expect("parse inspect"); assert_eq!( got, PublishedUdpPortPath { host_port: 44344, container_ip: "172.29.172.2".to_string(), container_port: 44344, } ); } #[test] fn parse_udp_port_returns_ambiguous_on_multiple_without_match() { let raw = r#"[ { "NetworkSettings": { "Networks": { "bridge": { "IPAddress": "172.29.172.2" } }, "Ports": { "10000/udp": [{ "HostIp": "0.0.0.0", "HostPort": "10000" }], "20000/udp": [{ "HostIp": "0.0.0.0", "HostPort": "20000" }] } } } ]"#; let err = parse_udp_port_path_from_inspect(raw, 443).expect_err("must be ambiguous"); assert!(err.to_string().contains("ambiguous udp published ports")); } #[test] fn parse_forward_policy_and_docker_rules_works() { let raw = r#" -P FORWARD DROP -A FORWARD -j DOCKER-USER -A FORWARD -j DOCKER-FORWARD "#; assert_eq!( parse_forward_policy_and_docker_rules(raw), (TriState::True, TriState::True) ); } #[test] fn parse_nft_forward_policy_and_docker_rules_works() { let raw = r#" table inet docker_filter { chain forward { type filter hook forward priority filter; policy accept; jump DOCKER-USER } } "#; assert_eq!( parse_nft_forward_policy_and_docker_rules(raw), (TriState::False, TriState::True) ); } #[test] fn parse_nft_forward_policy_drop_detects_drop_forward_chain() { let raw = r#" table inet filter { chain forward { type filter hook forward priority filter; policy drop; } } "#; assert_eq!( parse_nft_forward_policy_and_docker_rules(raw), (TriState::True, TriState::Unknown) ); } #[test] fn merge_forward_states_preserves_nft_docker_rules() { assert_eq!( merge_forward_states( (TriState::Unknown, TriState::Unknown), (TriState::False, TriState::True) ), (TriState::False, TriState::True) ); } #[test] fn merge_forward_states_is_conservative_on_drop_policy() { assert_eq!( merge_forward_states( (TriState::True, TriState::Unknown), (TriState::False, TriState::True) ), (TriState::True, TriState::True) ); } #[test] fn warning_is_emitted_for_risky_forward_state() { let diag = PortPathDiagnostics { docker_userland_proxy: TriState::False, published_udp_port: None, host_forward_policy_drop: TriState::True, docker_forward_rules_present: TriState::Unknown, }; assert!(diag.warning().is_some()); } }