Добавлена диагностика Docker/Amnezia port path в status и уточнена документация host firewall
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user