Добавлена диагностика 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
+261
View File
@@ -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());
}
}