Добавлена диагностика Docker/Amnezia port path в status и уточнена документация host firewall
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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<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> {
|
||||
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<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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<u64>, threshold_secs: u64) -> &'static str {
|
||||
if let Some(ts) = epoch {
|
||||
let now = SystemTime::now()
|
||||
|
||||
Reference in New Issue
Block a user