fix8: продакшн host firewall + dual reconcile firewall

- добавлена секция host_firewall в конфиг и defaults\n- реализован host firewall reconcile в отдельной таблице\n- apply-firewall теперь делает host firewall + anti-torrent\n- убрана JSON-инспекция из full-table drift для anti-torrent\n- расширен status вывод drift по host firewall/anti-torrent
This commit is contained in:
2026-05-11 18:14:46 +05:00
parent 7a4e9764b1
commit 381c106f9d
6 changed files with 466 additions and 55 deletions
+134
View File
@@ -2,10 +2,36 @@ use std::{fs, net::Ipv4Addr, path::Path, process::Command};
use serde::Deserialize;
fn default_host_firewall_enabled() -> bool {
true
}
fn default_ssh_port() -> u16 {
2323
}
fn default_amnezia_port() -> u16 {
443
}
fn default_amnezia_protocols() -> Vec<String> {
vec!["udp".to_string()]
}
fn default_allow_icmp() -> bool {
true
}
fn default_input_policy() -> String {
"drop".to_string()
}
#[derive(Debug, Clone, Deserialize)]
pub struct Config {
pub amnezia: AmneziaConfig,
pub traffic: TrafficConfig,
#[serde(default)]
pub host_firewall: HostFirewallConfig,
pub anti_torrent: AntiTorrentConfig,
pub reboot: RebootConfig,
pub swap: SwapConfig,
@@ -38,6 +64,35 @@ pub struct AntiTorrentConfig {
pub apply_in_amnezia_netns: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct HostFirewallConfig {
#[serde(default = "default_host_firewall_enabled")]
pub enabled: bool,
#[serde(default = "default_ssh_port")]
pub ssh_port: u16,
#[serde(default = "default_amnezia_port")]
pub amnezia_port: u16,
#[serde(default = "default_amnezia_protocols")]
pub amnezia_protocols: Vec<String>,
#[serde(default = "default_allow_icmp")]
pub allow_icmp: bool,
#[serde(default = "default_input_policy")]
pub default_input_policy: String,
}
impl Default for HostFirewallConfig {
fn default() -> Self {
Self {
enabled: default_host_firewall_enabled(),
ssh_port: default_ssh_port(),
amnezia_port: default_amnezia_port(),
amnezia_protocols: default_amnezia_protocols(),
allow_icmp: default_allow_icmp(),
default_input_policy: default_input_policy(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct RebootConfig {
pub enabled: bool,
@@ -115,6 +170,37 @@ impl Config {
if self.anti_torrent.enabled && self.anti_torrent.max_connections_per_peer == 0 {
anyhow::bail!("anti_torrent.max_connections_per_peer must be > 0");
}
if self.host_firewall.ssh_port == 0 {
anyhow::bail!("host_firewall.ssh_port must be in 1..=65535");
}
if self.host_firewall.amnezia_port == 0 {
anyhow::bail!("host_firewall.amnezia_port must be in 1..=65535");
}
if self.host_firewall.amnezia_protocols.is_empty() {
anyhow::bail!(
"host_firewall.amnezia_protocols must not be empty; allowed values: tcp, udp"
);
}
let mut normalized = std::collections::BTreeSet::new();
for p in &self.host_firewall.amnezia_protocols {
let pp = p.trim().to_ascii_lowercase();
if pp != "tcp" && pp != "udp" {
anyhow::bail!(
"host_firewall.amnezia_protocols contains invalid value: {p}; allowed: tcp, udp"
);
}
if !normalized.insert(pp) {
anyhow::bail!("host_firewall.amnezia_protocols contains duplicate protocol: {p}");
}
}
let policy = self
.host_firewall
.default_input_policy
.trim()
.to_ascii_lowercase();
if policy != "drop" && policy != "accept" {
anyhow::bail!("host_firewall.default_input_policy must be one of: drop, accept");
}
if self.reboot.enabled && self.reboot.window_start_utc == self.reboot.window_end_utc {
anyhow::bail!("reboot window start and end must differ");
}
@@ -146,6 +232,9 @@ impl Config {
if self.anti_torrent.enabled {
required.push("nft");
}
if self.host_firewall.enabled {
required.push("nft");
}
if self.reboot.enabled {
required.push("systemctl");
}
@@ -319,6 +408,7 @@ mod tests {
class_start_id: 10,
ifb_name: "ifb0".into(),
},
host_firewall: HostFirewallConfig::default(),
anti_torrent: AntiTorrentConfig {
enabled: false,
max_connections_per_peer: 100,
@@ -340,4 +430,48 @@ mod tests {
};
assert!(cfg.validate_config().is_err());
}
#[test]
fn host_firewall_rejects_empty_protocols() {
let cfg = Config {
amnezia: AmneziaConfig {
container_names: vec!["x".into()],
interface_candidates: vec!["awg0".into()],
interface_patterns: vec!["awg*".into()],
peer_ipv4_cidr: "10.8.1.0/24".into(),
},
traffic: 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: HostFirewallConfig {
amnezia_protocols: vec![],
..HostFirewallConfig::default()
},
anti_torrent: AntiTorrentConfig {
enabled: false,
max_connections_per_peer: 100,
peer_ipv4_cidr: "10.8.1.0/24".into(),
apply_in_amnezia_netns: false,
},
reboot: RebootConfig {
enabled: false,
window_start_utc: "01:00".into(),
window_end_utc: "02:00".into(),
},
swap: SwapConfig {
enabled: false,
path: "/swapfile".into(),
size_mb: 2048,
priority: 1,
swappiness: 1,
},
};
assert!(cfg.validate_config().is_err());
}
}
+230 -53
View File
@@ -7,6 +7,9 @@ use crate::{
use serde_json::Value;
use std::time::Duration;
const ANTITORRENT_TABLE: &str = "awg2_harness";
const HOST_FW_TABLE: &str = "awg2_host_firewall";
#[derive(Debug, Clone)]
struct FirewallActual {
table_exists: bool,
@@ -25,8 +28,14 @@ pub fn apply_firewall(cfg: &Config) -> anyhow::Result<()> {
let _lock =
FileLock::acquire_timeout("/run/awg2-harness/firewall.lock", Duration::from_secs(10))?;
apply_host_firewall(cfg)?;
apply_antitorrent_firewall(cfg)?;
Ok(())
}
fn apply_antitorrent_firewall(cfg: &Config) -> anyhow::Result<()> {
if !cfg.anti_torrent.enabled {
apply_firewall_disabled(cfg)?;
apply_antitorrent_disabled(cfg)?;
return Ok(());
}
@@ -39,12 +48,12 @@ pub fn apply_firewall(cfg: &Config) -> anyhow::Result<()> {
run_allow_notfound(
transport.as_ref(),
&["nft", "delete", "table", "inet", "awg2_harness"],
&["nft", "delete", "table", "inet", ANTITORRENT_TABLE],
)?;
run_step(
transport.as_ref(),
&["nft", "add", "table", "inet", "awg2_harness"],
&["nft", "add", "table", "inet", ANTITORRENT_TABLE],
)?;
run_step(
transport.as_ref(),
@@ -53,7 +62,7 @@ pub fn apply_firewall(cfg: &Config) -> anyhow::Result<()> {
"add",
"counter",
"inet",
"awg2_harness",
ANTITORRENT_TABLE,
"antitorrent_dropped_v4",
],
)?;
@@ -67,21 +76,21 @@ pub fn apply_firewall(cfg: &Config) -> anyhow::Result<()> {
let verify = inspect_firewall(transport.as_ref(), cfg)?;
if firewall_has_drift(&verify) {
anyhow::bail!(
"firewall verify-after-apply failed: {}",
"anti-torrent firewall verify-after-apply failed: {}",
firewall_drift_reason(&verify)
);
}
Ok(())
}
fn apply_firewall_disabled(cfg: &Config) -> anyhow::Result<()> {
fn apply_antitorrent_disabled(cfg: &Config) -> anyhow::Result<()> {
if cfg.anti_torrent.apply_in_amnezia_netns {
match docker::discover_target(cfg) {
Ok(target) => {
let transport = build_transport(target.container_pid, &target.container_name);
run_allow_notfound(
transport.as_ref(),
&["nft", "delete", "table", "inet", "awg2_harness"],
&["nft", "delete", "table", "inet", ANTITORRENT_TABLE],
)?;
}
Err(_) => {
@@ -92,21 +101,157 @@ fn apply_firewall_disabled(cfg: &Config) -> anyhow::Result<()> {
let transport = build_host_transport();
run_allow_notfound(
transport.as_ref(),
&["nft", "delete", "table", "inet", "awg2_harness"],
&["nft", "delete", "table", "inet", ANTITORRENT_TABLE],
)?;
}
Ok(())
}
fn apply_host_firewall(cfg: &Config) -> anyhow::Result<()> {
let transport = build_host_transport();
if !cfg.host_firewall.enabled {
run_allow_notfound(
transport.as_ref(),
&["nft", "delete", "table", "inet", HOST_FW_TABLE],
)?;
return Ok(());
}
let policy = cfg
.host_firewall
.default_input_policy
.trim()
.to_ascii_lowercase();
run_allow_notfound(
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(
transport.as_ref(),
&[
"nft",
"add",
"rule",
"inet",
HOST_FW_TABLE,
"input",
"iif",
"lo",
"accept",
],
)?;
run_step(
transport.as_ref(),
&[
"nft",
"add",
"rule",
"inet",
HOST_FW_TABLE,
"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 {
let p = proto.trim().to_ascii_lowercase();
run_step(
transport.as_ref(),
&[
"nft",
"add",
"rule",
"inet",
HOST_FW_TABLE,
"input",
&p,
"dport",
&cfg.host_firewall.amnezia_port.to_string(),
"accept",
],
)?;
}
if cfg.host_firewall.allow_icmp {
run_step(
transport.as_ref(),
&[
"nft",
"add",
"rule",
"inet",
HOST_FW_TABLE,
"input",
"ip",
"protocol",
"icmp",
"accept",
],
)?;
run_step(
transport.as_ref(),
&[
"nft",
"add",
"rule",
"inet",
HOST_FW_TABLE,
"input",
"ip6",
"nexthdr",
"icmpv6",
"accept",
],
)?;
}
Ok(())
}
fn inspect_firewall(transport: &dyn NsTransport, cfg: &Config) -> anyhow::Result<FirewallActual> {
let json = transport.run(&["nft", "-j", "list", "table", "inet", "awg2_harness"]);
if let Ok(raw) = json {
if let Some(actual) = inspect_firewall_json(&raw, cfg)? {
return Ok(actual);
}
}
let table = match transport.run(&["nft", "list", "table", "inet", "awg2_harness"]) {
let table = match transport.run(&["nft", "list", "table", "inet", ANTITORRENT_TABLE]) {
Ok(out) => out,
Err(e) if is_nft_not_found(&e.to_string()) => {
return Ok(FirewallActual {
@@ -168,13 +313,79 @@ fn firewall_transport(cfg: &Config) -> anyhow::Result<Box<dyn NsTransport>> {
}
}
pub fn antitorrent_drift_reason(cfg: &Config) -> anyhow::Result<Option<String>> {
if !cfg.anti_torrent.enabled {
return Ok(None);
}
let transport = firewall_transport(cfg)?;
let actual = inspect_firewall(transport.as_ref(), cfg)?;
if firewall_has_drift(&actual) {
return Ok(Some(firewall_drift_reason(&actual)));
}
Ok(None)
}
pub fn host_firewall_drift_reason(cfg: &Config) -> anyhow::Result<Option<String>> {
if !cfg.host_firewall.enabled {
return Ok(None);
}
let transport = build_host_transport();
let table = match transport.run(&["nft", "list", "table", "inet", HOST_FW_TABLE]) {
Ok(out) => out,
Err(e) if is_nft_not_found(&e.to_string()) => return Ok(Some("missing nft table".into())),
Err(e) => return Err(anyhow::anyhow!("host firewall inspect failed: {e}")),
};
let nt = normalize_nft_text(&table);
let ssh = format!("tcp dport {} accept", cfg.host_firewall.ssh_port);
if !nt.contains("chain input") {
return Ok(Some("missing input chain".into()));
}
if !nt.contains("type filter hook input") {
return Ok(Some("missing input hook".into()));
}
let pol = format!(
"policy {}",
cfg.host_firewall.default_input_policy.to_ascii_lowercase()
);
if !nt.contains(&pol) {
return Ok(Some("wrong input policy".into()));
}
if !nt.contains("iif lo accept") {
return Ok(Some("missing loopback allow".into()));
}
if !(nt.contains("ct state established,related accept")
|| nt.contains("ct state related,established accept"))
{
return Ok(Some("missing established/related allow".into()));
}
if !nt.contains(&ssh) {
return Ok(Some("missing ssh allow rule".into()));
}
for proto in &cfg.host_firewall.amnezia_protocols {
let p = proto.trim().to_ascii_lowercase();
let need = format!("{p} dport {} accept", cfg.host_firewall.amnezia_port);
if !nt.contains(&need) {
return Ok(Some(format!("missing amnezia allow rule for {p}")));
}
}
if cfg.host_firewall.allow_icmp {
if !nt.contains("ip protocol icmp accept") {
return Ok(Some("missing icmp allow rule".into()));
}
if !nt.contains("ip6 nexthdr icmpv6 accept") {
return Ok(Some("missing icmpv6 allow rule".into()));
}
}
Ok(None)
}
fn apply_rule(transport: &dyn NsTransport, cidr: &str, max_conn: u32) -> anyhow::Result<()> {
let _ = transport.run(&[
"nft",
"add",
"rule",
"inet",
"awg2_harness",
ANTITORRENT_TABLE,
"forward_antitorrent",
"ip",
"saddr",
@@ -213,7 +424,7 @@ fn run_step_chain(transport: &dyn NsTransport) -> anyhow::Result<()> {
"add",
"chain",
"inet",
"awg2_harness",
ANTITORRENT_TABLE,
"forward_antitorrent",
"{",
"type",
@@ -330,40 +541,6 @@ fn normalize_nft_text(input: &str) -> String {
.join(" ")
}
fn inspect_firewall_json(raw: &str, cfg: &Config) -> anyhow::Result<Option<FirewallActual>> {
let v: Value = serde_json::from_str(raw)?;
let Some(arr) = v.get("nftables").and_then(|x| x.as_array()) else {
return Ok(None);
};
let mut text = String::new();
for item in arr {
text.push_str(&item.to_string());
text.push(' ');
}
let nt = normalize_nft_text(&text);
let table_exists = nt.contains("awg2_harness");
if !table_exists {
return Ok(None);
}
let actual = FirewallActual {
table_exists,
counter_exists: nt.contains("antitorrent_dropped_v4"),
chain_exists: nt.contains("forward_antitorrent"),
rule_matches: firewall_rule_contract_matches(
&nt,
&cfg.anti_torrent.peer_ipv4_cidr,
cfg.anti_torrent.max_connections_per_peer,
),
hook_forward_present: nt.contains("hook") && nt.contains("forward"),
policy_accept_present: nt.contains("policy") && nt.contains("accept"),
ct_state_new_present: nt.contains("ct") && nt.contains("state") && nt.contains("new"),
meter_present: nt.contains("awg2_peer_connlimit_v4"),
counter_name_used: nt.contains("antitorrent_dropped_v4"),
drop_present: nt.contains("drop"),
};
Ok(Some(actual))
}
pub fn antitorrent_drop_counter(cfg: &Config) -> anyhow::Result<Option<(u64, u64)>> {
if !cfg.anti_torrent.enabled {
return Ok(None);
@@ -376,7 +553,7 @@ pub fn antitorrent_drop_counter(cfg: &Config) -> anyhow::Result<Option<(u64, u64
"list",
"counter",
"inet",
"awg2_harness",
ANTITORRENT_TABLE,
"antitorrent_dropped_v4",
]);
+62
View File
@@ -165,6 +165,29 @@ pub fn print_status(cfg: &Config) -> anyhow::Result<()> {
Err(_) => println!(" filter statistics: unavailable"),
}
println!("Host firewall:");
println!(" enabled: {}", cfg.host_firewall.enabled);
println!(" ssh_port: {}/tcp", cfg.host_firewall.ssh_port);
println!(
" amnezia_port: {}/{}",
cfg.host_firewall.amnezia_port,
cfg.host_firewall.amnezia_protocols.join(",")
);
println!(
" input_policy: {}",
cfg.host_firewall.default_input_policy.to_ascii_lowercase()
);
if cfg.host_firewall.enabled {
match firewall::host_firewall_drift_reason(cfg) {
Ok(None) => println!(" drift: no"),
Ok(Some(reason)) => {
println!(" drift: yes");
println!(" reason: {}", reason);
}
Err(e) => println!(" drift: unknown ({e})"),
}
}
println!("Anti-torrent:");
println!(" enabled: {}", cfg.anti_torrent.enabled);
println!(
@@ -173,6 +196,14 @@ pub fn print_status(cfg: &Config) -> anyhow::Result<()> {
);
if cfg.anti_torrent.enabled {
match firewall::antitorrent_drift_reason(cfg) {
Ok(None) => println!(" drift: no"),
Ok(Some(reason)) => {
println!(" drift: yes");
println!(" reason: {}", reason);
}
Err(e) => println!(" drift: unknown ({e})"),
}
match firewall::antitorrent_drop_counter(cfg) {
Ok(Some((packets, bytes))) => {
println!(" dropped_packets: {}", packets);
@@ -243,6 +274,29 @@ fn print_degraded_sections(cfg: &Config) -> anyhow::Result<()> {
println!("Traffic actual:");
println!(" drift: unknown (container/netns not discovered)");
println!("Host firewall:");
println!(" enabled: {}", cfg.host_firewall.enabled);
println!(" ssh_port: {}/tcp", cfg.host_firewall.ssh_port);
println!(
" amnezia_port: {}/{}",
cfg.host_firewall.amnezia_port,
cfg.host_firewall.amnezia_protocols.join(",")
);
println!(
" input_policy: {}",
cfg.host_firewall.default_input_policy.to_ascii_lowercase()
);
if cfg.host_firewall.enabled {
match firewall::host_firewall_drift_reason(cfg) {
Ok(None) => println!(" drift: no"),
Ok(Some(reason)) => {
println!(" drift: yes");
println!(" reason: {}", reason);
}
Err(e) => println!(" drift: unknown ({e})"),
}
}
println!("Anti-torrent:");
println!(" enabled: {}", cfg.anti_torrent.enabled);
println!(
@@ -250,6 +304,14 @@ fn print_degraded_sections(cfg: &Config) -> anyhow::Result<()> {
cfg.anti_torrent.max_connections_per_peer
);
if cfg.anti_torrent.enabled {
match firewall::antitorrent_drift_reason(cfg) {
Ok(None) => println!(" drift: no"),
Ok(Some(reason)) => {
println!(" drift: yes");
println!(" reason: {}", reason);
}
Err(e) => println!(" drift: unknown ({e})"),
}
match firewall::antitorrent_drop_counter(cfg) {
Ok(Some((packets, bytes))) => {
println!(" dropped_packets: {}", packets);