diff --git a/docs/ACCEPTANCE_CHECKLIST.md b/docs/ACCEPTANCE_CHECKLIST.md index 51ca181..7ae079a 100644 --- a/docs/ACCEPTANCE_CHECKLIST.md +++ b/docs/ACCEPTANCE_CHECKLIST.md @@ -129,6 +129,9 @@ - [ ] После `docker restart` anti-torrent правила продолжают восстанавливаться корректно и не ломаются host firewall логикой. - [ ] Проверено, где реально проходит VPN-трафик в вашей Docker-схеме (`INPUT` vs `FORWARD`) и это отражено в эксплуатационной заметке. - [ ] Для release-кандидата зафиксирован итог проверки `INPUT` vs `FORWARD` на реальном сервере (команды/логи/вывод) и подтверждено, что текущие правила firewall покрывают реальный путь трафика. +- [ ] `status` показывает блок `Docker/Amnezia port path` с полями `docker_userland_proxy`, `published_udp_port` и note про Docker-managed `FORWARD`. +- [ ] Если `docker_userland_proxy=false` и `FORWARD policy DROP`, при отсутствии явных Docker accept-признаков `status` выводит warning, но `awg2-harness` не изменяет `FORWARD` автоматически. +- [ ] Сверено соответствие: `config host_firewall.amnezia_port` ↔ опубликованный Docker UDP host port ↔ `docker exec amnezia-awg2 wg show` (`listening port`). ## K. Packaging / release diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f421c5a..57c5750 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -77,10 +77,12 @@ swappiness = 1 `host_firewall.ssh_port` — SSH порт хоста, который обязательно должен быть открыт. Production default: `2323`. -`host_firewall.amnezia_port` — порт Amnezia на хосте. Production default: `443`. +`host_firewall.amnezia_port` — внешний UDP/TCP порт Amnezia, который AWG2 открывает в host `INPUT` (в зависимости от `host_firewall.amnezia_protocols`). Значение должно совпадать с портом, указанным в панели/инсталляторе AmneziaWG. В старых инсталляциях это мог быть `44344`, в новой целевой конфигурации default — `443`. `host_firewall.amnezia_protocols` — протоколы для порта Amnezia (`udp`, `tcp` или оба). +Важно: `awg2-harness` не управляет host `FORWARD`/Docker NAT-цепочками. Проект сознательно не добавляет универсальные правила в `FORWARD` и не меняет Docker-managed forwarding path. + `host_firewall.allow_icmp` — разрешать ICMP/ICMPv6 на входе. `host_firewall.default_input_policy` — политика INPUT chain (`drop` или `accept`). Для production рекомендуется `drop`. diff --git a/traffic_core/src/docker.rs b/traffic_core/src/docker.rs index 4068663..483de0d 100644 --- a/traffic_core/src/docker.rs +++ b/traffic_core/src/docker.rs @@ -1,5 +1,7 @@ use std::process::Command; +use serde_json::Value; + use crate::{config::Config, error::AppError}; #[derive(Debug, Clone)] @@ -11,6 +13,52 @@ pub struct RuntimeTarget { 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])? @@ -62,3 +110,216 @@ pub fn run_capture(args: &[&str]) -> anyhow::Result { } 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_iptables_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 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 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, + }); + } + + for (k, v) in ports { + if !k.ends_with("/udp") { + continue; + } + if let Some(host_port) = extract_host_port_from_binding(v) { + let container_port = k + .split('/') + .next() + .and_then(|x| x.parse::().ok()) + .unwrap_or(expected_udp_port); + return Ok(PublishedUdpPortPath { + host_port, + container_ip, + container_port, + }); + } + } + + 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) +} + +#[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_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 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()); + } +} diff --git a/traffic_core/src/status.rs b/traffic_core/src/status.rs index a8248b1..6a8d32f 100644 --- a/traffic_core/src/status.rs +++ b/traffic_core/src/status.rs @@ -39,6 +39,11 @@ pub fn print_status(cfg: &Config) -> anyhow::Result<()> { println!(" netns: {}", target.netns_path); println!(" interface: {}", target.interface_name); + let docker_path_diag = docker::collect_port_path_diagnostics( + &target.container_name, + cfg.host_firewall.amnezia_port, + ); + let peers = wg::discover_peers( target.container_pid, &target.container_name, @@ -191,6 +196,8 @@ pub fn print_status(cfg: &Config) -> anyhow::Result<()> { } } + print_docker_amnezia_port_path(&docker_path_diag); + println!("Anti-torrent:"); println!(" enabled: {}", cfg.anti_torrent.enabled); println!( @@ -303,6 +310,11 @@ fn print_degraded_sections(cfg: &Config) -> anyhow::Result<()> { } } + println!("Docker/Amnezia port path:"); + println!(" docker_userland_proxy: unknown"); + println!(" published_udp_port: unknown"); + println!(" note: FORWARD is Docker-managed; awg2-harness does not modify host FORWARD"); + println!("Anti-torrent:"); println!(" enabled: {}", cfg.anti_torrent.enabled); println!( @@ -440,6 +452,25 @@ fn print_swap_status(cfg: &Config) { } } +fn print_docker_amnezia_port_path(diag: &docker::PortPathDiagnostics) { + println!("Docker/Amnezia port path:"); + println!( + " docker_userland_proxy: {}", + diag.docker_userland_proxy.as_str() + ); + match &diag.published_udp_port { + Some(p) => println!( + " published_udp_port: {} -> {}:{}", + p.host_port, p.container_ip, p.container_port + ), + None => println!(" published_udp_port: unknown"), + } + println!(" note: FORWARD is Docker-managed; awg2-harness does not modify host FORWARD"); + if let Some(w) = diag.warning() { + println!(" warning: {w}"); + } +} + fn handshake_is_online(epoch: Option, threshold_secs: u64) -> &'static str { if let Some(ts) = epoch { let now = SystemTime::now()