fix9: безопасный host firewall apply и строгий конфиг
- host_firewall секция стала обязательной (убран silent enable)\n- host firewall применяется атомарным nft batch\n- добавлен verify-after-apply с hard-fail при drift\n- усилен tolerant parser drift (кавычки, ipv6-icmp alias)\n- расширены unit-тесты config/firewall\n- дополнен acceptance checklist блоком host firewall
This commit is contained in:
@@ -117,6 +117,18 @@
|
|||||||
- [ ] Известно, как вернуть `amnezia-tc.service` и `amnezia-tc-watcher.service`.
|
- [ ] Известно, как вернуть `amnezia-tc.service` и `amnezia-tc-watcher.service`.
|
||||||
- [ ] Rollback проверен на тестовом сервере.
|
- [ ] 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`) и это отражено в эксплуатационной заметке.
|
||||||
|
|
||||||
## K. Packaging / release
|
## K. Packaging / release
|
||||||
|
|
||||||
- [ ] Package собирается на отдельной Debian 13 amd64 build-machine.
|
- [ ] Package собирается на отдельной Debian 13 amd64 build-machine.
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ fn default_input_policy() -> String {
|
|||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub amnezia: AmneziaConfig,
|
pub amnezia: AmneziaConfig,
|
||||||
pub traffic: TrafficConfig,
|
pub traffic: TrafficConfig,
|
||||||
#[serde(default)]
|
|
||||||
pub host_firewall: HostFirewallConfig,
|
pub host_firewall: HostFirewallConfig,
|
||||||
pub anti_torrent: AntiTorrentConfig,
|
pub anti_torrent: AntiTorrentConfig,
|
||||||
pub reboot: RebootConfig,
|
pub reboot: RebootConfig,
|
||||||
@@ -474,4 +473,44 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert!(cfg.validate_config().is_err());
|
assert!(cfg.validate_config().is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_requires_host_firewall_section() {
|
||||||
|
let raw = r#"
|
||||||
|
[amnezia]
|
||||||
|
container_names = ["amnezia-awg2"]
|
||||||
|
interface_candidates = ["awg0"]
|
||||||
|
interface_patterns = ["awg*"]
|
||||||
|
peer_ipv4_cidr = "10.8.1.0/24"
|
||||||
|
|
||||||
|
[traffic]
|
||||||
|
enabled = true
|
||||||
|
peer_limit = "70mbit"
|
||||||
|
root_rate = "1000mbit"
|
||||||
|
reconcile_interval_secs = 15
|
||||||
|
container_ready_timeout_secs = 30
|
||||||
|
class_start_id = 10
|
||||||
|
ifb_name = "ifb0"
|
||||||
|
|
||||||
|
[anti_torrent]
|
||||||
|
enabled = false
|
||||||
|
max_connections_per_peer = 100
|
||||||
|
peer_ipv4_cidr = "10.8.1.0/24"
|
||||||
|
apply_in_amnezia_netns = false
|
||||||
|
|
||||||
|
[reboot]
|
||||||
|
enabled = false
|
||||||
|
window_start_utc = "01:00"
|
||||||
|
window_end_utc = "02:00"
|
||||||
|
|
||||||
|
[swap]
|
||||||
|
enabled = false
|
||||||
|
path = "/swapfile"
|
||||||
|
size_mb = 2048
|
||||||
|
priority = 1
|
||||||
|
swappiness = 1
|
||||||
|
"#;
|
||||||
|
let parsed = toml::from_str::<Config>(raw);
|
||||||
|
assert!(parsed.is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+139
-131
@@ -5,7 +5,11 @@ use crate::{
|
|||||||
netns::{NsTransport, build_host_transport, build_transport},
|
netns::{NsTransport, build_host_transport, build_transport},
|
||||||
};
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::time::Duration;
|
use std::{
|
||||||
|
io::Write,
|
||||||
|
process::{Command, Stdio},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
const ANTITORRENT_TABLE: &str = "awg2_harness";
|
const ANTITORRENT_TABLE: &str = "awg2_harness";
|
||||||
const HOST_FW_TABLE: &str = "awg2_host_firewall";
|
const HOST_FW_TABLE: &str = "awg2_host_firewall";
|
||||||
@@ -123,129 +127,72 @@ fn apply_host_firewall(cfg: &Config) -> anyhow::Result<()> {
|
|||||||
.trim()
|
.trim()
|
||||||
.to_ascii_lowercase();
|
.to_ascii_lowercase();
|
||||||
|
|
||||||
run_allow_notfound(
|
apply_host_firewall_batch(cfg, &policy)?;
|
||||||
transport.as_ref(),
|
|
||||||
&["nft", "delete", "table", "inet", HOST_FW_TABLE],
|
|
||||||
)?;
|
|
||||||
run_step(
|
|
||||||
transport.as_ref(),
|
|
||||||
&["nft", "add", "table", "inet", HOST_FW_TABLE],
|
|
||||||
)?;
|
|
||||||
run_step(
|
|
||||||
transport.as_ref(),
|
|
||||||
&[
|
|
||||||
"nft",
|
|
||||||
"add",
|
|
||||||
"chain",
|
|
||||||
"inet",
|
|
||||||
HOST_FW_TABLE,
|
|
||||||
"input",
|
|
||||||
"{",
|
|
||||||
"type",
|
|
||||||
"filter",
|
|
||||||
"hook",
|
|
||||||
"input",
|
|
||||||
"priority",
|
|
||||||
"filter;",
|
|
||||||
"policy",
|
|
||||||
&format!("{};", policy),
|
|
||||||
"}",
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
|
|
||||||
run_step(
|
if let Some(reason) = host_firewall_drift_reason(cfg)? {
|
||||||
transport.as_ref(),
|
anyhow::bail!("host firewall verify-after-apply failed: {reason}");
|
||||||
&[
|
}
|
||||||
"nft",
|
Ok(())
|
||||||
"add",
|
}
|
||||||
"rule",
|
|
||||||
"inet",
|
fn apply_host_firewall_batch(cfg: &Config, policy: &str) -> anyhow::Result<()> {
|
||||||
HOST_FW_TABLE,
|
let mut batch = String::new();
|
||||||
"input",
|
batch.push_str(&format!("delete table inet {HOST_FW_TABLE}\n"));
|
||||||
"iif",
|
batch.push_str(&format!("add table inet {HOST_FW_TABLE}\n"));
|
||||||
"lo",
|
batch.push_str(&format!(
|
||||||
"accept",
|
"add chain inet {HOST_FW_TABLE} input {{ type filter hook input priority filter; policy accept; }}\n"
|
||||||
],
|
));
|
||||||
)?;
|
batch.push_str(&format!(
|
||||||
run_step(
|
"add rule inet {HOST_FW_TABLE} input iif \"lo\" accept\n"
|
||||||
transport.as_ref(),
|
));
|
||||||
&[
|
batch.push_str(&format!(
|
||||||
"nft",
|
"add rule inet {HOST_FW_TABLE} input ct state established,related accept\n"
|
||||||
"add",
|
));
|
||||||
"rule",
|
batch.push_str(&format!(
|
||||||
"inet",
|
"add rule inet {HOST_FW_TABLE} input tcp dport {} accept\n",
|
||||||
HOST_FW_TABLE,
|
cfg.host_firewall.ssh_port
|
||||||
"input",
|
));
|
||||||
"ct",
|
|
||||||
"state",
|
|
||||||
"established,related",
|
|
||||||
"accept",
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
run_step(
|
|
||||||
transport.as_ref(),
|
|
||||||
&[
|
|
||||||
"nft",
|
|
||||||
"add",
|
|
||||||
"rule",
|
|
||||||
"inet",
|
|
||||||
HOST_FW_TABLE,
|
|
||||||
"input",
|
|
||||||
"tcp",
|
|
||||||
"dport",
|
|
||||||
&cfg.host_firewall.ssh_port.to_string(),
|
|
||||||
"accept",
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
for proto in &cfg.host_firewall.amnezia_protocols {
|
for proto in &cfg.host_firewall.amnezia_protocols {
|
||||||
let p = proto.trim().to_ascii_lowercase();
|
let p = proto.trim().to_ascii_lowercase();
|
||||||
run_step(
|
batch.push_str(&format!(
|
||||||
transport.as_ref(),
|
"add rule inet {HOST_FW_TABLE} input {p} dport {} accept\n",
|
||||||
&[
|
cfg.host_firewall.amnezia_port
|
||||||
"nft",
|
));
|
||||||
"add",
|
|
||||||
"rule",
|
|
||||||
"inet",
|
|
||||||
HOST_FW_TABLE,
|
|
||||||
"input",
|
|
||||||
&p,
|
|
||||||
"dport",
|
|
||||||
&cfg.host_firewall.amnezia_port.to_string(),
|
|
||||||
"accept",
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
}
|
}
|
||||||
if cfg.host_firewall.allow_icmp {
|
if cfg.host_firewall.allow_icmp {
|
||||||
run_step(
|
batch.push_str(&format!(
|
||||||
transport.as_ref(),
|
"add rule inet {HOST_FW_TABLE} input ip protocol icmp accept\n"
|
||||||
&[
|
));
|
||||||
"nft",
|
batch.push_str(&format!(
|
||||||
"add",
|
"add rule inet {HOST_FW_TABLE} input ip6 nexthdr icmpv6 accept\n"
|
||||||
"rule",
|
));
|
||||||
"inet",
|
}
|
||||||
HOST_FW_TABLE,
|
batch.push_str(&format!(
|
||||||
"input",
|
"set chain inet {HOST_FW_TABLE} input {{ policy {policy}; }}\n"
|
||||||
"ip",
|
));
|
||||||
"protocol",
|
run_nft_batch(&batch)
|
||||||
"icmp",
|
}
|
||||||
"accept",
|
|
||||||
],
|
fn run_nft_batch(script: &str) -> anyhow::Result<()> {
|
||||||
)?;
|
let mut child = Command::new("nft")
|
||||||
run_step(
|
.args(["-f", "-"])
|
||||||
transport.as_ref(),
|
.stdin(Stdio::piped())
|
||||||
&[
|
.stdout(Stdio::piped())
|
||||||
"nft",
|
.stderr(Stdio::piped())
|
||||||
"add",
|
.spawn()?;
|
||||||
"rule",
|
|
||||||
"inet",
|
if let Some(mut stdin) = child.stdin.take() {
|
||||||
HOST_FW_TABLE,
|
stdin.write_all(script.as_bytes())?;
|
||||||
"input",
|
} else {
|
||||||
"ip6",
|
anyhow::bail!("failed to open stdin for nft batch");
|
||||||
"nexthdr",
|
}
|
||||||
"icmpv6",
|
|
||||||
"accept",
|
let out = child.wait_with_output()?;
|
||||||
],
|
if !out.status.success() {
|
||||||
)?;
|
anyhow::bail!(
|
||||||
|
"nft batch apply failed: {}",
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -336,47 +283,53 @@ pub fn host_firewall_drift_reason(cfg: &Config) -> anyhow::Result<Option<String>
|
|||||||
Err(e) => return Err(anyhow::anyhow!("host firewall inspect failed: {e}")),
|
Err(e) => return Err(anyhow::anyhow!("host firewall inspect failed: {e}")),
|
||||||
};
|
};
|
||||||
let nt = normalize_nft_text(&table);
|
let nt = normalize_nft_text(&table);
|
||||||
|
Ok(host_firewall_drift_reason_from_text(&nt, cfg))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn host_firewall_drift_reason_from_text(nt: &str, cfg: &Config) -> Option<String> {
|
||||||
let ssh = format!("tcp dport {} accept", cfg.host_firewall.ssh_port);
|
let ssh = format!("tcp dport {} accept", cfg.host_firewall.ssh_port);
|
||||||
if !nt.contains("chain input") {
|
if !nt.contains("chain input") {
|
||||||
return Ok(Some("missing input chain".into()));
|
return Some("missing input chain".into());
|
||||||
}
|
}
|
||||||
if !nt.contains("type filter hook input") {
|
if !nt.contains("type filter hook input") {
|
||||||
return Ok(Some("missing input hook".into()));
|
return Some("missing input hook".into());
|
||||||
}
|
}
|
||||||
let pol = format!(
|
let pol = format!(
|
||||||
"policy {}",
|
"policy {}",
|
||||||
cfg.host_firewall.default_input_policy.to_ascii_lowercase()
|
cfg.host_firewall.default_input_policy.to_ascii_lowercase()
|
||||||
);
|
);
|
||||||
if !nt.contains(&pol) {
|
if !nt.contains(&pol) {
|
||||||
return Ok(Some("wrong input policy".into()));
|
return Some("wrong input policy".into());
|
||||||
}
|
}
|
||||||
if !nt.contains("iif lo accept") {
|
if !nt.contains("iif lo accept") {
|
||||||
return Ok(Some("missing loopback allow".into()));
|
return Some("missing loopback allow".into());
|
||||||
}
|
}
|
||||||
if !(nt.contains("ct state established,related accept")
|
if !(nt.contains("ct state established,related accept")
|
||||||
|| nt.contains("ct state related,established accept"))
|
|| nt.contains("ct state related,established accept"))
|
||||||
{
|
{
|
||||||
return Ok(Some("missing established/related allow".into()));
|
return Some("missing established/related allow".into());
|
||||||
}
|
}
|
||||||
if !nt.contains(&ssh) {
|
if !nt.contains(&ssh) {
|
||||||
return Ok(Some("missing ssh allow rule".into()));
|
return Some("missing ssh allow rule".into());
|
||||||
}
|
}
|
||||||
for proto in &cfg.host_firewall.amnezia_protocols {
|
for proto in &cfg.host_firewall.amnezia_protocols {
|
||||||
let p = proto.trim().to_ascii_lowercase();
|
let p = proto.trim().to_ascii_lowercase();
|
||||||
let need = format!("{p} dport {} accept", cfg.host_firewall.amnezia_port);
|
let need = format!("{p} dport {} accept", cfg.host_firewall.amnezia_port);
|
||||||
if !nt.contains(&need) {
|
if !nt.contains(&need) {
|
||||||
return Ok(Some(format!("missing amnezia allow rule for {p}")));
|
return Some(format!("missing amnezia allow rule for {p}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if cfg.host_firewall.allow_icmp {
|
if cfg.host_firewall.allow_icmp {
|
||||||
if !nt.contains("ip protocol icmp accept") {
|
if !nt.contains("ip protocol icmp accept") {
|
||||||
return Ok(Some("missing icmp allow rule".into()));
|
return Some("missing icmp allow rule".into());
|
||||||
}
|
}
|
||||||
if !nt.contains("ip6 nexthdr icmpv6 accept") {
|
if !(nt.contains("ip6 nexthdr icmpv6 accept")
|
||||||
return Ok(Some("missing icmpv6 allow rule".into()));
|
|| nt.contains("ip6 nexthdr ipv6-icmp accept"))
|
||||||
|
{
|
||||||
|
return Some("missing icmpv6 allow rule".into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(None)
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_rule(transport: &dyn NsTransport, cidr: &str, max_conn: u32) -> anyhow::Result<()> {
|
fn apply_rule(transport: &dyn NsTransport, cidr: &str, max_conn: u32) -> anyhow::Result<()> {
|
||||||
@@ -535,7 +488,7 @@ fn firewall_rule_contract_matches(table: &str, cidr: &str, max_conn: u32) -> boo
|
|||||||
|
|
||||||
fn normalize_nft_text(input: &str) -> String {
|
fn normalize_nft_text(input: &str) -> String {
|
||||||
input
|
input
|
||||||
.replace(['\n', '\t', '{', '}', ';', ','], " ")
|
.replace(['\n', '\t', '{', '}', ';', ',', '"'], " ")
|
||||||
.split_whitespace()
|
.split_whitespace()
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(" ")
|
.join(" ")
|
||||||
@@ -661,4 +614,59 @@ table inet awg2_harness {
|
|||||||
"#;
|
"#;
|
||||||
assert!(firewall_rule_contract_matches(table, "10.8.1.0/24", 100));
|
assert!(firewall_rule_contract_matches(table, "10.8.1.0/24", 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn host_firewall_parser_tolerates_quoted_lo_and_ipv6_icmp_alias() {
|
||||||
|
let table = r#"
|
||||||
|
table inet awg2_host_firewall {
|
||||||
|
chain input {
|
||||||
|
type filter hook input priority filter; policy drop;
|
||||||
|
iif "lo" accept
|
||||||
|
ct state established,related accept
|
||||||
|
tcp dport 2323 accept
|
||||||
|
udp dport 443 accept
|
||||||
|
ip protocol icmp accept
|
||||||
|
ip6 nexthdr ipv6-icmp accept
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
let cfg = Config {
|
||||||
|
amnezia: crate::config::AmneziaConfig {
|
||||||
|
container_names: vec!["amnezia-awg2".into()],
|
||||||
|
interface_candidates: vec!["awg0".into()],
|
||||||
|
interface_patterns: vec!["awg*".into()],
|
||||||
|
peer_ipv4_cidr: "10.8.1.0/24".into(),
|
||||||
|
},
|
||||||
|
traffic: crate::config::TrafficConfig {
|
||||||
|
enabled: true,
|
||||||
|
peer_limit: "70mbit".into(),
|
||||||
|
root_rate: "1000mbit".into(),
|
||||||
|
reconcile_interval_secs: 15,
|
||||||
|
container_ready_timeout_secs: 30,
|
||||||
|
class_start_id: 10,
|
||||||
|
ifb_name: "ifb0".into(),
|
||||||
|
},
|
||||||
|
host_firewall: crate::config::HostFirewallConfig::default(),
|
||||||
|
anti_torrent: crate::config::AntiTorrentConfig {
|
||||||
|
enabled: false,
|
||||||
|
max_connections_per_peer: 100,
|
||||||
|
peer_ipv4_cidr: "10.8.1.0/24".into(),
|
||||||
|
apply_in_amnezia_netns: false,
|
||||||
|
},
|
||||||
|
reboot: crate::config::RebootConfig {
|
||||||
|
enabled: false,
|
||||||
|
window_start_utc: "01:00".into(),
|
||||||
|
window_end_utc: "02:00".into(),
|
||||||
|
},
|
||||||
|
swap: crate::config::SwapConfig {
|
||||||
|
enabled: false,
|
||||||
|
path: "/swapfile".into(),
|
||||||
|
size_mb: 2048,
|
||||||
|
priority: 1,
|
||||||
|
swappiness: 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let nt = normalize_nft_text(table);
|
||||||
|
assert_eq!(host_firewall_drift_reason_from_text(&nt, &cfg), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user