diff --git a/docs/ACCEPTANCE_CHECKLIST.md b/docs/ACCEPTANCE_CHECKLIST.md index a891f08..8523c69 100644 --- a/docs/ACCEPTANCE_CHECKLIST.md +++ b/docs/ACCEPTANCE_CHECKLIST.md @@ -146,6 +146,8 @@ - [ ] Проверено, где реально проходит 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`. +- [ ] `status` показывает поля `host_forward_policy_drop` и `docker_forward_rules_present` в блоке `Docker/Amnezia port path`. - [ ] Если `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`). - [ ] При нескольких UDP bindings без однозначного соответствия `host_firewall.amnezia_port` диагностика `published_udp_port` показывает `unknown` (ambiguous), а не произвольный первый порт. +- [ ] Для диагностики FORWARD используется best-effort проверка: `iptables -S FORWARD` и fallback через `nft list ruleset`; `status` не падает при недоступности одной из команд. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 1b31299..235bdff 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -81,6 +81,8 @@ swappiness = 1 Для диагностического блока `Docker/Amnezia port path` в `status` приоритет сопоставления такой: сначала ищется published UDP binding с `HostPort == host_firewall.amnezia_port`, затем fallback на ключ `"/udp"` как container port, затем (если UDP binding ровно один) берётся он. Если UDP bindings несколько и однозначного совпадения нет, статус показывает `published_udp_port: unknown`. +Диагностика `FORWARD` в этом блоке является best-effort: сначала используется `iptables -S FORWARD`, затем fallback через `nft list ruleset`; результаты только отображаются в `status` и не изменяют host `FORWARD`/Docker NAT. + `host_firewall.amnezia_protocols` — протоколы для порта Amnezia (`udp`, `tcp` или оба). Важно: `awg2-harness` не управляет host `FORWARD`/Docker NAT-цепочками. Проект сознательно не добавляет универсальные правила в `FORWARD` и не меняет Docker-managed forwarding path. diff --git a/traffic_core/src/docker.rs b/traffic_core/src/docker.rs index 7c37fba..1e398ab 100644 --- a/traffic_core/src/docker.rs +++ b/traffic_core/src/docker.rs @@ -118,7 +118,7 @@ pub fn collect_port_path_diagnostics( 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)); + inspect_forward_state().unwrap_or((TriState::Unknown, TriState::Unknown)); PortPathDiagnostics { docker_userland_proxy, @@ -146,6 +146,42 @@ fn inspect_iptables_forward_state() -> anyhow::Result<(TriState, TriState)> { 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; @@ -279,6 +315,34 @@ fn parse_forward_policy_and_docker_rules(raw: &str) -> (TriState, TriState) { (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::*; @@ -432,6 +496,61 @@ mod tests { ); } + #[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 { diff --git a/traffic_core/src/status.rs b/traffic_core/src/status.rs index 6a8d32f..32a5b8a 100644 --- a/traffic_core/src/status.rs +++ b/traffic_core/src/status.rs @@ -313,6 +313,8 @@ fn print_degraded_sections(cfg: &Config) -> anyhow::Result<()> { println!("Docker/Amnezia port path:"); println!(" docker_userland_proxy: unknown"); println!(" published_udp_port: unknown"); + println!(" host_forward_policy_drop: unknown"); + println!(" docker_forward_rules_present: unknown"); println!(" note: FORWARD is Docker-managed; awg2-harness does not modify host FORWARD"); println!("Anti-torrent:"); @@ -465,6 +467,14 @@ fn print_docker_amnezia_port_path(diag: &docker::PortPathDiagnostics) { ), None => println!(" published_udp_port: unknown"), } + println!( + " host_forward_policy_drop: {}", + diag.host_forward_policy_drop.as_str() + ); + println!( + " docker_forward_rules_present: {}", + diag.docker_forward_rules_present.as_str() + ); println!(" note: FORWARD is Docker-managed; awg2-harness does not modify host FORWARD"); if let Some(w) = diag.warning() { println!(" warning: {w}");