Добавлена диагностика Docker/Amnezia port path в status и уточнена документация host firewall

This commit is contained in:
2026-05-11 19:22:39 +05:00
parent 640178a592
commit adc244daa7
4 changed files with 298 additions and 1 deletions
+3
View File
@@ -129,6 +129,9 @@
- [ ] После `docker restart` anti-torrent правила продолжают восстанавливаться корректно и не ломаются host firewall логикой. - [ ] После `docker restart` anti-torrent правила продолжают восстанавливаться корректно и не ломаются host firewall логикой.
- [ ] Проверено, где реально проходит VPN-трафик в вашей Docker-схеме (`INPUT` vs `FORWARD`) и это отражено в эксплуатационной заметке. - [ ] Проверено, где реально проходит VPN-трафик в вашей Docker-схеме (`INPUT` vs `FORWARD`) и это отражено в эксплуатационной заметке.
- [ ] Для release-кандидата зафиксирован итог проверки `INPUT` vs `FORWARD` на реальном сервере (команды/логи/вывод) и подтверждено, что текущие правила firewall покрывают реальный путь трафика. - [ ] Для 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 ## K. Packaging / release
+3 -1
View File
@@ -77,10 +77,12 @@ swappiness = 1
`host_firewall.ssh_port` — SSH порт хоста, который обязательно должен быть открыт. Production default: `2323`. `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` или оба). `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.allow_icmp` — разрешать ICMP/ICMPv6 на входе.
`host_firewall.default_input_policy` — политика INPUT chain (`drop` или `accept`). Для production рекомендуется `drop`. `host_firewall.default_input_policy` — политика INPUT chain (`drop` или `accept`). Для production рекомендуется `drop`.
+261
View File
@@ -1,5 +1,7 @@
use std::process::Command; use std::process::Command;
use serde_json::Value;
use crate::{config::Config, error::AppError}; use crate::{config::Config, error::AppError};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -11,6 +13,52 @@ pub struct RuntimeTarget {
pub interface_name: 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<PublishedUdpPortPath>,
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<RuntimeTarget> { pub fn discover_target(cfg: &Config) -> anyhow::Result<RuntimeTarget> {
let container_name = find_running_container(&cfg.amnezia.container_names)?; let container_name = find_running_container(&cfg.amnezia.container_names)?;
let container_id = run_capture(&["docker", "inspect", "--format", "{{.Id}}", &container_name])? let container_id = run_capture(&["docker", "inspect", "--format", "{{.Id}}", &container_name])?
@@ -62,3 +110,216 @@ pub fn run_capture(args: &[&str]) -> anyhow::Result<String> {
} }
Ok(String::from_utf8_lossy(&out.stdout).to_string()) 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<TriState> {
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<PublishedUdpPortPath> {
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::<Value>(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<PublishedUdpPortPath> {
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::<u16>().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<String, Value>, key: &str) -> Option<u16> {
let v = ports.get(key)?;
extract_host_port_from_binding(v)
}
fn extract_host_port_from_binding(v: &Value) -> Option<u16> {
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::<u16>().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());
}
}
+31
View File
@@ -39,6 +39,11 @@ pub fn print_status(cfg: &Config) -> anyhow::Result<()> {
println!(" netns: {}", target.netns_path); println!(" netns: {}", target.netns_path);
println!(" interface: {}", target.interface_name); 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( let peers = wg::discover_peers(
target.container_pid, target.container_pid,
&target.container_name, &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!("Anti-torrent:");
println!(" enabled: {}", cfg.anti_torrent.enabled); println!(" enabled: {}", cfg.anti_torrent.enabled);
println!( 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!("Anti-torrent:");
println!(" enabled: {}", cfg.anti_torrent.enabled); println!(" enabled: {}", cfg.anti_torrent.enabled);
println!( 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<u64>, threshold_secs: u64) -> &'static str { fn handshake_is_online(epoch: Option<u64>, threshold_secs: u64) -> &'static str {
if let Some(ts) = epoch { if let Some(ts) = epoch {
let now = SystemTime::now() let now = SystemTime::now()