From f8c6f2e92d6bea0e2a96d7114626491036291613 Mon Sep 17 00:00:00 2001 From: Crimson Date: Mon, 11 May 2026 19:30:25 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A3=D1=82=D0=BE=D1=87=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=B4=D0=B8=D0=B0=D0=B3=D0=BD=D0=BE=D1=81=D1=82=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=20UDP=20port=20mapping=20=D0=B4=D0=BB=D1=8F=20Dock?= =?UTF-8?q?er=20=D0=B8=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D1=8B=20checklist/docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ACCEPTANCE_CHECKLIST.md | 33 +++++---- docs/CONFIGURATION.md | 2 + traffic_core/src/docker.rs | 140 +++++++++++++++++++++++++++++++---- 3 files changed, 143 insertions(+), 32 deletions(-) diff --git a/docs/ACCEPTANCE_CHECKLIST.md b/docs/ACCEPTANCE_CHECKLIST.md index 7ae079a..a891f08 100644 --- a/docs/ACCEPTANCE_CHECKLIST.md +++ b/docs/ACCEPTANCE_CHECKLIST.md @@ -117,22 +117,6 @@ - [ ] Известно, как вернуть `amnezia-tc.service` и `amnezia-tc-watcher.service`. - [ ] Rollback проверен на тестовом сервере. -## L. Host firewall (ports/SSH safety) - -- [ ] `awg2-harness validate-config` падает, если отсутствует секция `[host_firewall]`. -- [ ] `apply-firewall` не блокирует SSH на `host_firewall.ssh_port` (default `2323`). -- [ ] После `apply-firewall` порт `host_firewall.amnezia_port` (default `443`) доступен по всем протоколам из `host_firewall.amnezia_protocols`. -- [ ] Если эксплуатационно требуется закрыть legacy SSH `22`, это явно подтверждено проверкой доступа на `ssh_port` перед закрытием `22`. -- [ ] При `host_firewall.enabled = false` удаляется только таблица `inet awg2_host_firewall`. -- [ ] Повторный `apply-firewall` идемпотентен и не вносит лишних изменений в healthy state. -- [ ] При невалидном `host_firewall.amnezia_protocols` (`icmp`, `foo`, дубли) `validate-config` завершается ошибкой. -- [ ] После `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 - [ ] Package собирается на отдельной Debian 13 amd64 build-machine. @@ -148,3 +132,20 @@ - [ ] `apt install ./awg2-harness__amd64.deb` проходит на clean Debian 13 Amnezia host. - [ ] После ручной установки config `awg2-harness validate-config` проходит. - [ ] Сервис стартует только после явного `systemctl enable --now awg2-harness.service`. + +## L. Host firewall (ports/SSH safety) + +- [ ] `awg2-harness validate-config` падает, если отсутствует секция `[host_firewall]`. +- [ ] `apply-firewall` не блокирует SSH на `host_firewall.ssh_port` (default `2323`). +- [ ] После `apply-firewall` порт `host_firewall.amnezia_port` (default `443`) доступен по всем протоколам из `host_firewall.amnezia_protocols`. +- [ ] Если эксплуатационно требуется закрыть legacy SSH `22`, это явно подтверждено проверкой доступа на `ssh_port` перед закрытием `22`. +- [ ] При `host_firewall.enabled = false` удаляется только таблица `inet awg2_host_firewall`. +- [ ] Повторный `apply-firewall` идемпотентен и не вносит лишних изменений в healthy state. +- [ ] При невалидном `host_firewall.amnezia_protocols` (`icmp`, `foo`, дубли) `validate-config` завершается ошибкой. +- [ ] После `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`). +- [ ] При нескольких UDP bindings без однозначного соответствия `host_firewall.amnezia_port` диагностика `published_udp_port` показывает `unknown` (ambiguous), а не произвольный первый порт. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 57c5750..1b31299 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -79,6 +79,8 @@ swappiness = 1 `host_firewall.amnezia_port` — внешний UDP/TCP порт Amnezia, который AWG2 открывает в host `INPUT` (в зависимости от `host_firewall.amnezia_protocols`). Значение должно совпадать с портом, указанным в панели/инсталляторе AmneziaWG. В старых инсталляциях это мог быть `44344`, в новой целевой конфигурации default — `443`. +Для диагностического блока `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`. + `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 483de0d..f7dc38a 100644 --- a/traffic_core/src/docker.rs +++ b/traffic_core/src/docker.rs @@ -187,6 +187,34 @@ fn parse_udp_port_path_from_inspect( .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 { @@ -196,22 +224,19 @@ fn parse_udp_port_path_from_inspect( }); } - 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, - }); - } + 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!( @@ -286,6 +311,20 @@ mod tests { "443/udp": [{ "HostIp": "0.0.0.0", "HostPort": "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"); @@ -294,11 +333,80 @@ mod tests { 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#"