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:
@@ -13,6 +13,14 @@ container_ready_timeout_secs = 120
|
|||||||
class_start_id = 10
|
class_start_id = 10
|
||||||
ifb_name = "ifb0"
|
ifb_name = "ifb0"
|
||||||
|
|
||||||
|
[host_firewall]
|
||||||
|
enabled = true
|
||||||
|
ssh_port = 2323
|
||||||
|
amnezia_port = 443
|
||||||
|
amnezia_protocols = ["udp"]
|
||||||
|
allow_icmp = true
|
||||||
|
default_input_policy = "drop"
|
||||||
|
|
||||||
[anti_torrent]
|
[anti_torrent]
|
||||||
enabled = true
|
enabled = true
|
||||||
max_connections_per_peer = 100
|
max_connections_per_peer = 100
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ container_ready_timeout_secs = 120
|
|||||||
class_start_id = 10
|
class_start_id = 10
|
||||||
ifb_name = "ifb0"
|
ifb_name = "ifb0"
|
||||||
|
|
||||||
|
[host_firewall]
|
||||||
|
enabled = true
|
||||||
|
ssh_port = 2323
|
||||||
|
amnezia_port = 443
|
||||||
|
amnezia_protocols = ["udp"]
|
||||||
|
allow_icmp = true
|
||||||
|
default_input_policy = "drop"
|
||||||
|
|
||||||
[anti_torrent]
|
[anti_torrent]
|
||||||
enabled = true
|
enabled = true
|
||||||
max_connections_per_peer = 100
|
max_connections_per_peer = 100
|
||||||
@@ -65,6 +73,18 @@ swappiness = 1
|
|||||||
|
|
||||||
`anti_torrent.enabled` — включает nftables connection-count guard против high-peer-count P2P-нагрузки. Это новое расширение, не часть old bash.
|
`anti_torrent.enabled` — включает nftables connection-count guard против high-peer-count P2P-нагрузки. Это новое расширение, не часть old bash.
|
||||||
|
|
||||||
|
`host_firewall.enabled` — включает host-level INPUT firewall в отдельной AWG2-owned таблице `inet awg2_host_firewall`.
|
||||||
|
|
||||||
|
`host_firewall.ssh_port` — SSH порт хоста, который обязательно должен быть открыт. Production default: `2323`.
|
||||||
|
|
||||||
|
`host_firewall.amnezia_port` — порт Amnezia на хосте. Production default: `443`.
|
||||||
|
|
||||||
|
`host_firewall.amnezia_protocols` — протоколы для порта Amnezia (`udp`, `tcp` или оба).
|
||||||
|
|
||||||
|
`host_firewall.allow_icmp` — разрешать ICMP/ICMPv6 на входе.
|
||||||
|
|
||||||
|
`host_firewall.default_input_policy` — политика INPUT chain (`drop` или `accept`). Для production рекомендуется `drop`.
|
||||||
|
|
||||||
`anti_torrent.max_connections_per_peer` — максимум одновременных conntrack flows на один VPN peer IP. Default `100`; `250` — более мягкий вариант.
|
`anti_torrent.max_connections_per_peer` — максимум одновременных conntrack flows на один VPN peer IP. Default `100`; `250` — более мягкий вариант.
|
||||||
|
|
||||||
`anti_torrent.peer_ipv4_cidr` — подсеть, на которую распространяется правило. Обычно должна совпадать с `amnezia.peer_ipv4_cidr`.
|
`anti_torrent.peer_ipv4_cidr` — подсеть, на которую распространяется правило. Обычно должна совпадать с `amnezia.peer_ipv4_cidr`.
|
||||||
@@ -76,6 +96,8 @@ swappiness = 1
|
|||||||
|
|
||||||
Для production рекомендуется `true`, если peer source IP (`10.8.1.x`) видны именно внутри netns контейнера.
|
Для production рекомендуется `true`, если peer source IP (`10.8.1.x`) видны именно внутри netns контейнера.
|
||||||
|
|
||||||
|
`apply-firewall` в текущей модели делает **оба** reconcile: host firewall (host namespace) и anti-torrent firewall (configured namespace).
|
||||||
|
|
||||||
`reboot.enabled` — включает плановую перезагрузку.
|
`reboot.enabled` — включает плановую перезагрузку.
|
||||||
|
|
||||||
`reboot.window_start_utc` / `reboot.window_end_utc` — UTC-окно плановой перезагрузки. В старом Rust-модуле это было зашито как 01:00–02:00 UTC.
|
`reboot.window_start_utc` / `reboot.window_end_utc` — UTC-окно плановой перезагрузки. В старом Rust-модуле это было зашито как 01:00–02:00 UTC.
|
||||||
|
|||||||
+10
-2
@@ -189,6 +189,14 @@ container_ready_timeout_secs = 120
|
|||||||
class_start_id = 10
|
class_start_id = 10
|
||||||
ifb_name = "ifb0"
|
ifb_name = "ifb0"
|
||||||
|
|
||||||
|
[host_firewall]
|
||||||
|
enabled = true
|
||||||
|
ssh_port = 2323
|
||||||
|
amnezia_port = 443
|
||||||
|
amnezia_protocols = ["udp"]
|
||||||
|
allow_icmp = true
|
||||||
|
default_input_policy = "drop"
|
||||||
|
|
||||||
[anti_torrent]
|
[anti_torrent]
|
||||||
enabled = true
|
enabled = true
|
||||||
max_connections_per_peer = 100
|
max_connections_per_peer = 100
|
||||||
@@ -204,7 +212,7 @@ window_end_utc = "02:00"
|
|||||||
enabled = true
|
enabled = true
|
||||||
path = "/swapfile"
|
path = "/swapfile"
|
||||||
size_mb = 2048
|
size_mb = 2048
|
||||||
priority = -2
|
priority = 1
|
||||||
swappiness = 1
|
swappiness = 1
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -249,7 +257,7 @@ WantedBy=multi-user.target
|
|||||||
|
|
||||||
## 12. Reboot и swap
|
## 12. Reboot и swap
|
||||||
|
|
||||||
Логика reboot работает только через встроенный scheduler `traffic_core`. Окно перезагрузки задаётся в конфиге, включая корректные окна через полночь. Механика `systemctl --no-block reboot` сохраняется.
|
Логика reboot вынесена в отдельный production-бинарь `reboot_module` (`awg2-reboot-scheduler`). Окно перезагрузки задаётся в конфиге, включая корректные окна через полночь. Механика `systemctl --no-block reboot` сохраняется.
|
||||||
|
|
||||||
`ensure-swap` работает как exclusive reconciler: при `swap.enabled=true` пакет полностью владеет swap-state хоста. После reconcile активен ровно один swap (`swap.path`), все прочие активные swap entries выключены (`swapoff`), а все старые swap-строки в `/etc/fstab` заменены на одну managed-запись AWG2. Для block-device swap выполняются только `swapoff` и удаление записи из `fstab`; device не удаляется.
|
`ensure-swap` работает как exclusive reconciler: при `swap.enabled=true` пакет полностью владеет swap-state хоста. После reconcile активен ровно один swap (`swap.path`), все прочие активные swap entries выключены (`swapoff`), а все старые swap-строки в `/etc/fstab` заменены на одну managed-запись AWG2. Для block-device swap выполняются только `swapoff` и удаление записи из `fstab`; device не удаляется.
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,36 @@ use std::{fs, net::Ipv4Addr, path::Path, process::Command};
|
|||||||
|
|
||||||
use serde::Deserialize;
|
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)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
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 anti_torrent: AntiTorrentConfig,
|
pub anti_torrent: AntiTorrentConfig,
|
||||||
pub reboot: RebootConfig,
|
pub reboot: RebootConfig,
|
||||||
pub swap: SwapConfig,
|
pub swap: SwapConfig,
|
||||||
@@ -38,6 +64,35 @@ pub struct AntiTorrentConfig {
|
|||||||
pub apply_in_amnezia_netns: bool,
|
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)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct RebootConfig {
|
pub struct RebootConfig {
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
@@ -115,6 +170,37 @@ impl Config {
|
|||||||
if self.anti_torrent.enabled && self.anti_torrent.max_connections_per_peer == 0 {
|
if self.anti_torrent.enabled && self.anti_torrent.max_connections_per_peer == 0 {
|
||||||
anyhow::bail!("anti_torrent.max_connections_per_peer must be > 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 {
|
if self.reboot.enabled && self.reboot.window_start_utc == self.reboot.window_end_utc {
|
||||||
anyhow::bail!("reboot window start and end must differ");
|
anyhow::bail!("reboot window start and end must differ");
|
||||||
}
|
}
|
||||||
@@ -146,6 +232,9 @@ impl Config {
|
|||||||
if self.anti_torrent.enabled {
|
if self.anti_torrent.enabled {
|
||||||
required.push("nft");
|
required.push("nft");
|
||||||
}
|
}
|
||||||
|
if self.host_firewall.enabled {
|
||||||
|
required.push("nft");
|
||||||
|
}
|
||||||
if self.reboot.enabled {
|
if self.reboot.enabled {
|
||||||
required.push("systemctl");
|
required.push("systemctl");
|
||||||
}
|
}
|
||||||
@@ -319,6 +408,7 @@ mod tests {
|
|||||||
class_start_id: 10,
|
class_start_id: 10,
|
||||||
ifb_name: "ifb0".into(),
|
ifb_name: "ifb0".into(),
|
||||||
},
|
},
|
||||||
|
host_firewall: HostFirewallConfig::default(),
|
||||||
anti_torrent: AntiTorrentConfig {
|
anti_torrent: AntiTorrentConfig {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
max_connections_per_peer: 100,
|
max_connections_per_peer: 100,
|
||||||
@@ -340,4 +430,48 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert!(cfg.validate_config().is_err());
|
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
@@ -7,6 +7,9 @@ use crate::{
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
const ANTITORRENT_TABLE: &str = "awg2_harness";
|
||||||
|
const HOST_FW_TABLE: &str = "awg2_host_firewall";
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct FirewallActual {
|
struct FirewallActual {
|
||||||
table_exists: bool,
|
table_exists: bool,
|
||||||
@@ -25,8 +28,14 @@ pub fn apply_firewall(cfg: &Config) -> anyhow::Result<()> {
|
|||||||
let _lock =
|
let _lock =
|
||||||
FileLock::acquire_timeout("/run/awg2-harness/firewall.lock", Duration::from_secs(10))?;
|
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 {
|
if !cfg.anti_torrent.enabled {
|
||||||
apply_firewall_disabled(cfg)?;
|
apply_antitorrent_disabled(cfg)?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,12 +48,12 @@ pub fn apply_firewall(cfg: &Config) -> anyhow::Result<()> {
|
|||||||
|
|
||||||
run_allow_notfound(
|
run_allow_notfound(
|
||||||
transport.as_ref(),
|
transport.as_ref(),
|
||||||
&["nft", "delete", "table", "inet", "awg2_harness"],
|
&["nft", "delete", "table", "inet", ANTITORRENT_TABLE],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
run_step(
|
run_step(
|
||||||
transport.as_ref(),
|
transport.as_ref(),
|
||||||
&["nft", "add", "table", "inet", "awg2_harness"],
|
&["nft", "add", "table", "inet", ANTITORRENT_TABLE],
|
||||||
)?;
|
)?;
|
||||||
run_step(
|
run_step(
|
||||||
transport.as_ref(),
|
transport.as_ref(),
|
||||||
@@ -53,7 +62,7 @@ pub fn apply_firewall(cfg: &Config) -> anyhow::Result<()> {
|
|||||||
"add",
|
"add",
|
||||||
"counter",
|
"counter",
|
||||||
"inet",
|
"inet",
|
||||||
"awg2_harness",
|
ANTITORRENT_TABLE,
|
||||||
"antitorrent_dropped_v4",
|
"antitorrent_dropped_v4",
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
@@ -67,21 +76,21 @@ pub fn apply_firewall(cfg: &Config) -> anyhow::Result<()> {
|
|||||||
let verify = inspect_firewall(transport.as_ref(), cfg)?;
|
let verify = inspect_firewall(transport.as_ref(), cfg)?;
|
||||||
if firewall_has_drift(&verify) {
|
if firewall_has_drift(&verify) {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"firewall verify-after-apply failed: {}",
|
"anti-torrent firewall verify-after-apply failed: {}",
|
||||||
firewall_drift_reason(&verify)
|
firewall_drift_reason(&verify)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Ok(())
|
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 {
|
if cfg.anti_torrent.apply_in_amnezia_netns {
|
||||||
match docker::discover_target(cfg) {
|
match docker::discover_target(cfg) {
|
||||||
Ok(target) => {
|
Ok(target) => {
|
||||||
let transport = build_transport(target.container_pid, &target.container_name);
|
let transport = build_transport(target.container_pid, &target.container_name);
|
||||||
run_allow_notfound(
|
run_allow_notfound(
|
||||||
transport.as_ref(),
|
transport.as_ref(),
|
||||||
&["nft", "delete", "table", "inet", "awg2_harness"],
|
&["nft", "delete", "table", "inet", ANTITORRENT_TABLE],
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
@@ -92,21 +101,157 @@ fn apply_firewall_disabled(cfg: &Config) -> anyhow::Result<()> {
|
|||||||
let transport = build_host_transport();
|
let transport = build_host_transport();
|
||||||
run_allow_notfound(
|
run_allow_notfound(
|
||||||
transport.as_ref(),
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn inspect_firewall(transport: &dyn NsTransport, cfg: &Config) -> anyhow::Result<FirewallActual> {
|
fn inspect_firewall(transport: &dyn NsTransport, cfg: &Config) -> anyhow::Result<FirewallActual> {
|
||||||
let json = transport.run(&["nft", "-j", "list", "table", "inet", "awg2_harness"]);
|
let table = match transport.run(&["nft", "list", "table", "inet", ANTITORRENT_TABLE]) {
|
||||||
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"]) {
|
|
||||||
Ok(out) => out,
|
Ok(out) => out,
|
||||||
Err(e) if is_nft_not_found(&e.to_string()) => {
|
Err(e) if is_nft_not_found(&e.to_string()) => {
|
||||||
return Ok(FirewallActual {
|
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<()> {
|
fn apply_rule(transport: &dyn NsTransport, cidr: &str, max_conn: u32) -> anyhow::Result<()> {
|
||||||
let _ = transport.run(&[
|
let _ = transport.run(&[
|
||||||
"nft",
|
"nft",
|
||||||
"add",
|
"add",
|
||||||
"rule",
|
"rule",
|
||||||
"inet",
|
"inet",
|
||||||
"awg2_harness",
|
ANTITORRENT_TABLE,
|
||||||
"forward_antitorrent",
|
"forward_antitorrent",
|
||||||
"ip",
|
"ip",
|
||||||
"saddr",
|
"saddr",
|
||||||
@@ -213,7 +424,7 @@ fn run_step_chain(transport: &dyn NsTransport) -> anyhow::Result<()> {
|
|||||||
"add",
|
"add",
|
||||||
"chain",
|
"chain",
|
||||||
"inet",
|
"inet",
|
||||||
"awg2_harness",
|
ANTITORRENT_TABLE,
|
||||||
"forward_antitorrent",
|
"forward_antitorrent",
|
||||||
"{",
|
"{",
|
||||||
"type",
|
"type",
|
||||||
@@ -330,40 +541,6 @@ fn normalize_nft_text(input: &str) -> String {
|
|||||||
.join(" ")
|
.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)>> {
|
pub fn antitorrent_drop_counter(cfg: &Config) -> anyhow::Result<Option<(u64, u64)>> {
|
||||||
if !cfg.anti_torrent.enabled {
|
if !cfg.anti_torrent.enabled {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -376,7 +553,7 @@ pub fn antitorrent_drop_counter(cfg: &Config) -> anyhow::Result<Option<(u64, u64
|
|||||||
"list",
|
"list",
|
||||||
"counter",
|
"counter",
|
||||||
"inet",
|
"inet",
|
||||||
"awg2_harness",
|
ANTITORRENT_TABLE,
|
||||||
"antitorrent_dropped_v4",
|
"antitorrent_dropped_v4",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -165,6 +165,29 @@ pub fn print_status(cfg: &Config) -> anyhow::Result<()> {
|
|||||||
Err(_) => println!(" filter statistics: unavailable"),
|
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!("Anti-torrent:");
|
||||||
println!(" enabled: {}", cfg.anti_torrent.enabled);
|
println!(" enabled: {}", cfg.anti_torrent.enabled);
|
||||||
println!(
|
println!(
|
||||||
@@ -173,6 +196,14 @@ pub fn print_status(cfg: &Config) -> anyhow::Result<()> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if cfg.anti_torrent.enabled {
|
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) {
|
match firewall::antitorrent_drop_counter(cfg) {
|
||||||
Ok(Some((packets, bytes))) => {
|
Ok(Some((packets, bytes))) => {
|
||||||
println!(" dropped_packets: {}", packets);
|
println!(" dropped_packets: {}", packets);
|
||||||
@@ -243,6 +274,29 @@ fn print_degraded_sections(cfg: &Config) -> anyhow::Result<()> {
|
|||||||
println!("Traffic actual:");
|
println!("Traffic actual:");
|
||||||
println!(" drift: unknown (container/netns not discovered)");
|
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!("Anti-torrent:");
|
||||||
println!(" enabled: {}", cfg.anti_torrent.enabled);
|
println!(" enabled: {}", cfg.anti_torrent.enabled);
|
||||||
println!(
|
println!(
|
||||||
@@ -250,6 +304,14 @@ fn print_degraded_sections(cfg: &Config) -> anyhow::Result<()> {
|
|||||||
cfg.anti_torrent.max_connections_per_peer
|
cfg.anti_torrent.max_connections_per_peer
|
||||||
);
|
);
|
||||||
if cfg.anti_torrent.enabled {
|
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) {
|
match firewall::antitorrent_drop_counter(cfg) {
|
||||||
Ok(Some((packets, bytes))) => {
|
Ok(Some((packets, bytes))) => {
|
||||||
println!(" dropped_packets: {}", packets);
|
println!(" dropped_packets: {}", packets);
|
||||||
|
|||||||
Reference in New Issue
Block a user