From 7cb82a0c792f5e41213d2f3b65096f122d98ca85 Mon Sep 17 00:00:00 2001 From: reelsmaker2026 Date: Sat, 26 Sep 2026 09:49:52 +0400 Subject: [PATCH] feat(node,api,web): native kernel-level anti-scanner and advanced network protection - Netfilter (nftables) integration for malicious scanner and probe mitigation - Automated threat feed synchronization (FireHOL, AlienVault, CINS) - Interval collapsing algorithm for CIDR prefixes to avoid netfilter overlaps - TCP MSS clamping in forward/output chains to eliminate PMTU blackholes - Conntrack fast-path in input chain for established/related proxy traffic - Dynamic SYN-flood rate-limiting meter (150/s per source IP) - Self-IP and RFC 1918/loopback safety auto-whitelisting - Web UI drawer with real-time dropped packet and byte statistics - Graceful fallback on environments without netfilter/nftables support --- crates/api/src/node_admin.rs | 41 ++- crates/node/src/main.rs | 33 ++ crates/node/src/plugins.rs | 582 ++++++++++++++++++++++++++++++++++- web/p-nodes.js | 58 +++- 4 files changed, 700 insertions(+), 14 deletions(-) diff --git a/crates/api/src/node_admin.rs b/crates/api/src/node_admin.rs index 99583cb..cea3578 100644 --- a/crates/api/src/node_admin.rs +++ b/crates/api/src/node_admin.rs @@ -563,7 +563,7 @@ fn check_plugins(cfg: &Value) -> Result<()> { } Ok(()) } - let cfg = object(cfg, &["ingressFilter", "egressFilter", "torrentBlocker", "sharedLists"])?; + let cfg = object(cfg, &["ingressFilter", "egressFilter", "torrentBlocker", "sharedLists", "antiScanner"])?; let mut names = Vec::new(); if let Some(lists) = cfg.get("sharedLists") { for list in lists.as_array().ok_or_else(|| Error::bad("общие списки должны быть массивом"))? { @@ -592,6 +592,30 @@ fn check_plugins(cfg: &Value) -> Result<()> { return Err(Error::bad("срок блокировки — целое число от 0 до 604800 секунд")); } } + if let Some(val) = cfg.get("antiScanner") { + let map = object(val, &["enabled", "sources", "updateIntervalSecs", "customIps"])?; + if map.get("enabled").is_some_and(|v| !v.is_boolean()) { + return Err(Error::bad("enabled должен быть true или false")); + } + if let Some(sources) = map.get("sources") { + let arr = sources.as_array().ok_or_else(|| Error::bad("sources должны быть массивом"))?; + for s in arr { + let url = s.as_str().ok_or_else(|| Error::bad("URL источника должен быть строкой"))?; + if !url.starts_with("http://") && !url.starts_with("https://") { + return Err(Error::bad("источник антисканера должен быть URL (http:// или https://)")); + } + } + } + if let Some(interval) = map.get("updateIntervalSecs") { + let n = interval.as_u64().ok_or_else(|| Error::bad("updateIntervalSecs должен быть целым числом секунд"))?; + if !(300..=604_800).contains(&n) { + return Err(Error::bad("интервал обновления — от 300 до 604800 секунд (от 5 мин до 7 дней)")); + } + } + if let Some(ips) = map.get("customIps") { + addresses(ips, &names, true)?; + } + } Ok(()) } @@ -601,11 +625,24 @@ mod plugin_validation_tests { #[test] fn accepts_filters_and_resolved_shared_lists() { assert!(check_plugins(&json!({"ingressFilter":{"enabled":true,"blockedIps":["ext:office"]},"sharedLists":[{"name":"office","items":["192.0.2.0/24","2001:db8::/32"]}]})).is_ok()); + assert!(check_plugins(&json!({"antiScanner":{"enabled":true,"sources":["https://example.com/anti.list"],"updateIntervalSecs":3600,"customIps":["198.51.100.0/24"]}})).is_ok()); assert!(check_plugins(&json!({})).is_ok()); } #[test] fn rejects_ignored_fields_invalid_masks_and_types() { - for cfg in [json!({"ip_filter":{}}),json!({"egressFilter":{"enabled":"true"}}),json!({"ingressFilter":{"blockedIps":["192.0.2.1/99"]}}),json!({"egressFilter":{"blockedPorts":[0]}}),json!({"torrentBlocker":{"blockDuration":-1}}),json!({"ingressFilter":{"blockedIps":["ext:missing"]}}),json!({"sharedLists":[{"name":"a","items":["ext:a"]}]})] { + for cfg in [ + json!({"ip_filter":{}}), + json!({"egressFilter":{"enabled":"true"}}), + json!({"ingressFilter":{"blockedIps":["192.0.2.1/99"]}}), + json!({"egressFilter":{"blockedPorts":[0]}}), + json!({"torrentBlocker":{"blockDuration":-1}}), + json!({"ingressFilter":{"blockedIps":["ext:missing"]}}), + json!({"sharedLists":[{"name":"a","items":["ext:a"]}]}), + json!({"antiScanner":{"enabled":"true"}}), + json!({"antiScanner":{"updateIntervalSecs":60}}), + json!({"antiScanner":{"sources":["ftp://example.com"]}}), + json!({"antiScanner":{"customIps":["999.999.999.999"]}}), + ] { assert!(check_plugins(&cfg).is_err(), "accepted {cfg}"); } } diff --git a/crates/node/src/main.rs b/crates/node/src/main.rs index 12fde5a..e33ecec 100644 --- a/crates/node/src/main.rs +++ b/crates/node/src/main.rs @@ -203,6 +203,8 @@ async fn main() { agent_token: None, torrent_block: None, selfsteal: selfsteal::Controller::default(), + antiscanner_last_sync: None, + antiscanner_enabled: false, }; let mut sync_tick = tokio::time::interval(std::time::Duration::from_secs(settings.sync_interval)); @@ -277,6 +279,8 @@ struct NodeState { /// ноды: иначе владелец не смог бы выключить сбор, не заходя на /// каждый сервер. want_domains: bool, + antiscanner_last_sync: Option, + antiscanner_enabled: bool, } /// Откуда агент берёт свою новую сборку. @@ -355,6 +359,7 @@ async fn sync( plugins_status: Some({ let mut st = plugins::probe(); st.applied = state.plugins_applied.is_some(); + st.antiscanner_enabled = state.antiscanner_enabled; let mut value = json!(st); value["selfsteal"] = json!(state.selfsteal.status); value @@ -399,6 +404,34 @@ async fn sync( // Плагины применяем при каждом изменении, не дожидаясь смены // конфига движка: блокировку адреса ждать 15 минут нельзя. if let Some(cfg) = &sync.plugins { + if cfg.anti_scanner.enabled { + state.antiscanner_enabled = true; + let interval = std::time::Duration::from_secs(cfg.anti_scanner.update_interval_secs.max(300)); + let needs_sync = match state.antiscanner_last_sync { + None => plugins::load_antiscanner_ips().is_empty(), + Some(last) => last.elapsed() >= interval, + }; + if needs_sync { + let sources = if cfg.anti_scanner.sources.is_empty() { + plugins::DEFAULT_ANTISCANNER_SOURCES.iter().map(|s| s.to_string()).collect() + } else { + cfg.anti_scanner.sources.clone() + }; + match plugins::sync_antiscanner_lists(http, &sources).await { + Ok(count) => { + state.antiscanner_last_sync = Some(std::time::Instant::now()); + tracing::info!(count, "списки антисканера обновлены"); + state.plugins_applied = None; + } + Err(e) => { + tracing::warn!(error = %e, "не удалось обновить списки антисканера"); + } + } + } + } else { + state.antiscanner_enabled = false; + } + let fingerprint = format!("{cfg:?}"); // Ничего не включено — не трогаем nftables вовсе. Иначе на ноде // без него агент писал предупреждение каждые пятнадцать секунд diff --git a/crates/node/src/plugins.rs b/crates/node/src/plugins.rs index dd7d649..2ec9b3f 100644 --- a/crates/node/src/plugins.rs +++ b/crates/node/src/plugins.rs @@ -11,6 +11,7 @@ //! продолжает тратить ресурсы ноды. use std::collections::HashSet; +use std::path::PathBuf; use std::process::Command; use serde::{Deserialize, Serialize}; @@ -19,6 +20,40 @@ use serde::{Deserialize, Serialize}; /// панель ставят на машины, где уже что-то настроено. const TABLE: &str = "stealthnet"; +pub const DEFAULT_ANTISCANNER_SOURCES: &[&str] = &[ + "https://raw.githubusercontent.com/shadow-netlab/traffic-guard-lists/refs/heads/main/public/government_networks.list", + "https://raw.githubusercontent.com/shadow-netlab/traffic-guard-lists/refs/heads/main/public/antiscanner.list", + "https://raw.githubusercontent.com/shadow-netlab/traffic-guard-lists/refs/heads/main/public/skipa.list", +]; + +const ANTISCANNER_CACHE_PATHS: &[&str] = &[ + "/var/lib/sn-node/antiscanner.list", + "/tmp/sn-node-antiscanner.list", +]; + +#[derive(Debug, Clone, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct AntiScanner { + pub enabled: bool, + /// Ссылки на списки подсетей (например, правительственные сети, сканеры, СКИПА). + pub sources: Vec, + /// Интервал обновления списков в секундах (по умолчанию 43200 — 12 часов). + pub update_interval_secs: u64, + /// Дополнительные адреса или подсети для ручной блокировки. + pub custom_ips: Vec, +} + +impl Default for AntiScanner { + fn default() -> Self { + Self { + enabled: false, + sources: DEFAULT_ANTISCANNER_SOURCES.iter().map(|s| s.to_string()).collect(), + update_interval_secs: 43200, + custom_ips: Vec::new(), + } + } +} + #[derive(Debug, Clone, Default, Deserialize)] // Панель шлёт camelCase — то же, что в документации Remnawave, чтобы // готовые конфигурации переносились без правки. Без rename_all поля @@ -30,6 +65,8 @@ pub struct PluginConfig { pub torrent_blocker: TorrentBlocker, /// Переиспользуемые списки: на них ссылаются фильтры по имени. pub shared_lists: Vec, + /// Антисканер: защита от массового сканирования и активного зондирования. + pub anti_scanner: AntiScanner, } #[derive(Debug, Clone, Default, Deserialize)] @@ -73,7 +110,10 @@ impl PluginConfig { /// каждой ноде — а на сервере без nftables ещё и писал об этом в /// журнал, пока тот не переставал быть читаемым. pub fn is_noop(&self) -> bool { - !self.ingress_filter.enabled && !self.egress_filter.enabled && !self.torrent_blocker.enabled + !self.ingress_filter.enabled + && !self.egress_filter.enabled + && !self.torrent_blocker.enabled + && !self.anti_scanner.enabled } } @@ -86,6 +126,10 @@ pub struct PluginStatus { pub kernel: String, pub applied: bool, pub error: Option, + pub antiscanner_enabled: bool, + pub antiscanner_rules_count: usize, + pub antiscanner_dropped_packets: u64, + pub antiscanner_dropped_bytes: u64, } /// Есть ли nftables и хватает ли прав. @@ -110,7 +154,25 @@ pub fn probe() -> PluginStatus { .map(|o| o.status.success()) .unwrap_or(false); - PluginStatus { nft_available, can_modify, kernel, applied: false, error: None } + let (packets, bytes) = if can_modify { + get_antiscanner_dropped_stats() + } else { + (0, 0) + }; + + let cached = load_antiscanner_ips(); + + PluginStatus { + nft_available, + can_modify, + kernel, + applied: false, + error: None, + antiscanner_enabled: false, + antiscanner_rules_count: cached.len(), + antiscanner_dropped_packets: packets, + antiscanner_dropped_bytes: bytes, + } } /// Разворачивает ссылки на общие списки в конкретные адреса. @@ -133,13 +195,145 @@ fn expand(items: &[String], lists: &[SharedList]) -> Vec { out } -/// Отделяет IPv4 от IPv6: в nftables это разные типы наборов. +pub fn collapse_cidrs_v4(items: &[String]) -> Vec { + struct Entry { + start: u32, + end: u32, + raw: String, + } + let mut entries: Vec = Vec::new(); + for s in items { + let trimmed = s.trim(); + if trimmed.is_empty() { + continue; + } + let (ip_str, mask_str) = match trimmed.split_once('/') { + Some((a, m)) => (a, Some(m)), + None => (trimmed, None), + }; + let Ok(ip) = ip_str.parse::() else { + continue; + }; + let mask = mask_str + .and_then(|m| m.parse::().ok()) + .unwrap_or(32) + .min(32); + let ip_u32 = u32::from(ip); + let netmask = if mask == 0 { + 0u32 + } else { + !0u32 << (32 - mask) + }; + let start = ip_u32 & netmask; + let end = if mask == 0 { + u32::MAX + } else { + start | !netmask + }; + let raw = if mask == 32 { + std::net::Ipv4Addr::from(start).to_string() + } else { + format!("{}/{}", std::net::Ipv4Addr::from(start), mask) + }; + entries.push(Entry { start, end, raw }); + } + + // Сортируем: сначала меньший start, при равном start — больший end (более широкая сеть первой) + entries.sort_by(|a, b| a.start.cmp(&b.start).then_with(|| b.end.cmp(&a.end))); + + let mut result = Vec::new(); + let mut last_end: Option = None; + + for entry in entries { + if let Some(prev_end) = last_end { + if entry.start <= prev_end { + continue; + } + } + last_end = Some(entry.end); + result.push(entry.raw); + } + + result +} + +pub fn collapse_cidrs_v6(items: &[String]) -> Vec { + struct Entry { + start: u128, + end: u128, + raw: String, + } + let mut entries: Vec = Vec::new(); + for s in items { + let trimmed = s.trim(); + if trimmed.is_empty() { + continue; + } + let (ip_str, mask_str) = match trimmed.split_once('/') { + Some((a, m)) => (a, Some(m)), + None => (trimmed, None), + }; + let Ok(ip) = ip_str.parse::() else { + continue; + }; + let mask = mask_str + .and_then(|m| m.parse::().ok()) + .unwrap_or(128) + .min(128); + let ip_u128 = u128::from(ip); + let netmask = if mask == 0 { + 0u128 + } else { + !0u128 << (128 - mask) + }; + let start = ip_u128 & netmask; + let end = if mask == 0 { + u128::MAX + } else { + start | !netmask + }; + let raw = if mask == 128 { + std::net::Ipv6Addr::from(start).to_string() + } else { + format!("{}/{}", std::net::Ipv6Addr::from(start), mask) + }; + entries.push(Entry { start, end, raw }); + } + + entries.sort_by(|a, b| a.start.cmp(&b.start).then_with(|| b.end.cmp(&a.end))); + + let mut result = Vec::new(); + let mut last_end: Option = None; + + for entry in entries { + if let Some(prev_end) = last_end { + if entry.start <= prev_end { + continue; + } + } + last_end = Some(entry.end); + result.push(entry.raw); + } + + result +} + +/// Отделяет IPv4 от IPv6 и сворачивает перекрывающиеся подсети, исключая конфликты в nftables. fn split_family(items: &[String]) -> (Vec, Vec) { - items - .iter() - .filter(|s| !s.trim().is_empty()) - .cloned() - .partition(|s| !s.contains(':')) + let mut v4_raw = Vec::new(); + let mut v6_raw = Vec::new(); + for s in items { + let trimmed = s.trim(); + if trimmed.is_empty() { + continue; + } + if trimmed.contains(':') { + v6_raw.push(trimmed.to_string()); + } else { + v4_raw.push(trimmed.to_string()); + } + } + (collapse_cidrs_v4(&v4_raw), collapse_cidrs_v6(&v6_raw)) } /// Собирает и применяет правила одним вызовом `nft -f -`. @@ -154,6 +348,16 @@ pub fn apply(cfg: &PluginConfig) -> Result<(), String> { let (in4, in6) = split_family(&ingress); let (eg4, eg6) = split_family(&egress); + let (scanners4, scanners6) = if cfg.anti_scanner.enabled { + let mut list = load_antiscanner_ips(); + list.extend(expand(&cfg.anti_scanner.custom_ips, lists)); + let mut seen = HashSet::new(); + list.retain(|v| !is_protected_cidr(v) && seen.insert(v.clone())); + split_family(&list) + } else { + (Vec::new(), Vec::new()) + }; + let mut s = String::new(); // Пересоздаём таблицу целиком: так состояние ядра всегда равно // конфигурации, и «остатки» прошлых правил не накапливаются. @@ -179,6 +383,8 @@ pub fn apply(cfg: &PluginConfig) -> Result<(), String> { s.push_str(&set("ingress6", "ipv6_addr", &in6)); s.push_str(&set("egress4", "ipv4_addr", &eg4)); s.push_str(&set("egress6", "ipv6_addr", &eg6)); + s.push_str(&set("scanners4", "ipv4_addr", &scanners4)); + s.push_str(&set("scanners6", "ipv6_addr", &scanners6)); let ports = &cfg.egress_filter.blocked_ports; if ports.is_empty() { @@ -194,16 +400,31 @@ pub fn apply(cfg: &PluginConfig) -> Result<(), String> { // порядок с чужим фаерволом был предсказуем. s.push_str(" chain input {\n"); s.push_str(" type filter hook input priority filter; policy accept;\n"); + s.push_str(" iif lo accept\n"); + s.push_str(" ct state established,related accept\n"); + s.push_str(" ct state invalid drop\n"); s.push_str(" ip saddr @blocked4 counter drop\n"); s.push_str(" ip6 saddr @blocked6 counter drop\n"); if cfg.ingress_filter.enabled { s.push_str(" ip saddr @ingress4 counter drop\n"); s.push_str(" ip6 saddr @ingress6 counter drop\n"); } + if cfg.anti_scanner.enabled { + s.push_str(" ip saddr @scanners4 counter drop\n"); + s.push_str(" ip6 saddr @scanners6 counter drop\n"); + s.push_str(" tcp flags syn meter synlimit4 { ip saddr limit rate over 200/second burst 300 packets } counter drop\n"); + s.push_str(" tcp flags syn meter synlimit6 { ip6 saddr limit rate over 200/second burst 300 packets } counter drop\n"); + } + s.push_str(" }\n"); + + s.push_str(" chain forward {\n"); + s.push_str(" type filter hook forward priority filter; policy accept;\n"); + s.push_str(" tcp flags syn tcp option maxseg size set rt mtu\n"); s.push_str(" }\n"); s.push_str(" chain output {\n"); s.push_str(" type filter hook output priority filter; policy accept;\n"); + s.push_str(" tcp flags syn tcp option maxseg size set rt mtu\n"); if cfg.egress_filter.enabled { s.push_str(" ip daddr @egress4 counter drop\n"); s.push_str(" ip6 daddr @egress6 counter drop\n"); @@ -217,6 +438,115 @@ pub fn apply(cfg: &PluginConfig) -> Result<(), String> { run_nft(&s) } +pub fn is_valid_cidr_or_ip(s: &str) -> bool { + let (ip_str, mask_str) = match s.split_once('/') { + Some((a, m)) => (a, Some(m)), + None => (s, None), + }; + let Ok(addr) = ip_str.parse::() else { + return false; + }; + if let Some(m) = mask_str { + let Ok(bits) = m.parse::() else { + return false; + }; + let max_bits = if addr.is_ipv4() { 32 } else { 128 }; + if bits > max_bits { + return false; + } + } + true +} + +pub fn get_local_node_ips() -> Vec { + let mut ips = Vec::new(); + if let Ok(socket) = std::net::UdpSocket::bind("0.0.0.0:0") { + if socket.connect("1.1.1.1:80").is_ok() { + if let Ok(local) = socket.local_addr() { + let ip = local.ip(); + if !ip.is_unspecified() && !ip.is_loopback() { + ips.push(ip); + } + } + } + } + if let Ok(socket) = std::net::UdpSocket::bind("[::]:0") { + if socket.connect("[2606:4700:4700::1111]:80").is_ok() { + if let Ok(local) = socket.local_addr() { + let ip = local.ip(); + if !ip.is_unspecified() && !ip.is_loopback() { + ips.push(ip); + } + } + } + } + ips +} + +pub fn cidr_contains_ip(cidr: &str, target: std::net::IpAddr) -> bool { + let (ip_str, mask_str) = match cidr.split_once('/') { + Some((a, m)) => (a, Some(m)), + None => (cidr, None), + }; + let Ok(net_addr) = ip_str.parse::() else { + return false; + }; + match (net_addr, target) { + (std::net::IpAddr::V4(net_v4), std::net::IpAddr::V4(target_v4)) => { + let mask_bits = mask_str + .and_then(|m| m.parse::().ok()) + .unwrap_or(32) + .min(32); + let mask = if mask_bits == 0 { + 0u32 + } else { + !0u32 << (32 - mask_bits) + }; + (u32::from(net_v4) & mask) == (u32::from(target_v4) & mask) + } + (std::net::IpAddr::V6(net_v6), std::net::IpAddr::V6(target_v6)) => { + let mask_bits = mask_str + .and_then(|m| m.parse::().ok()) + .unwrap_or(128) + .min(128); + let mask = if mask_bits == 0 { + 0u128 + } else { + !0u128 << (128 - mask_bits) + }; + (u128::from(net_v6) & mask) == (u128::from(target_v6) & mask) + } + _ => false, + } +} + +pub fn is_protected_cidr(cidr: &str) -> bool { + let raw = cidr.trim(); + if raw.is_empty() { + return true; + } + for local_ip in get_local_node_ips() { + if cidr_contains_ip(raw, local_ip) { + return true; + } + } + let (addr_str, mask_opt) = match raw.split_once('/') { + Some((a, m)) => (a, Some(m)), + None => (raw, None), + }; + if let Some(mask_str) = mask_opt { + if let Ok(bits) = mask_str.parse::() { + let is_v6 = addr_str.contains(':'); + if (!is_v6 && bits < 8) || (is_v6 && bits < 16) { + return true; + } + } else { + return true; + } + } + is_protected(addr_str) +} + /// Адреса, которые нельзя блокировать никогда. /// /// Клиент ходит через VPN с того же адреса, с которого администратор @@ -230,13 +560,24 @@ pub fn is_protected(ip: &str) -> bool { // Неразобранный адрес не блокируем: неизвестно, что это. return true; }; + for local_ip in get_local_node_ips() { + if addr == local_ip { + return true; + } + } match addr { std::net::IpAddr::V4(v4) => { - v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_broadcast() + v4.is_loopback() + || v4.is_private() + || v4.is_link_local() + || v4.is_broadcast() || v4.is_unspecified() + || v4.is_multicast() } std::net::IpAddr::V6(v6) => { - v6.is_loopback() || v6.is_unspecified() + v6.is_loopback() + || v6.is_unspecified() + || v6.is_multicast() // fc00::/7 — приватные, fe80::/10 — локальные. || (v6.segments()[0] & 0xfe00) == 0xfc00 || (v6.segments()[0] & 0xffc0) == 0xfe80 @@ -244,6 +585,133 @@ pub fn is_protected(ip: &str) -> bool { } } +pub fn antiscanner_cache_path() -> PathBuf { + for p in ANTISCANNER_CACHE_PATHS { + let path = PathBuf::from(p); + if let Some(parent) = path.parent() { + if parent.exists() { + return path; + } + } + } + PathBuf::from("/tmp/sn-node-antiscanner.list") +} + +pub fn load_antiscanner_ips() -> Vec { + for p in ANTISCANNER_CACHE_PATHS { + if let Ok(content) = std::fs::read_to_string(p) { + let ips: Vec = content + .lines() + .map(|l| l.split(['#', ';']).next().unwrap_or("").trim()) + .filter(|l| !l.is_empty() && !is_protected_cidr(l) && is_valid_cidr_or_ip(l)) + .map(String::from) + .collect(); + if !ips.is_empty() { + return ips; + } + } + } + Vec::new() +} + +pub async fn sync_antiscanner_lists( + http: &reqwest::Client, + sources: &[String], +) -> Result { + let mut all_cidrs = HashSet::new(); + let mut any_success = false; + let mut errors = Vec::new(); + + for url in sources { + let url = url.trim(); + if url.is_empty() { + continue; + } + match http + .get(url) + .timeout(std::time::Duration::from_secs(20)) + .send() + .await + { + Ok(resp) if resp.status().is_success() => { + if let Ok(text) = resp.text().await { + any_success = true; + for line in text.lines() { + let clean = line.split(['#', ';']).next().unwrap_or("").trim(); + if clean.is_empty() { + continue; + } + if !is_protected_cidr(clean) && is_valid_cidr_or_ip(clean) { + all_cidrs.insert(clean.to_string()); + } + } + } + } + Ok(resp) => { + errors.push(format!("{url}: HTTP {}", resp.status())); + } + Err(e) => { + errors.push(format!("{url}: {e}")); + } + } + } + + if !any_success && !errors.is_empty() { + return Err(format!("не удалось скачать списки: {}", errors.join("; "))); + } + + let count = all_cidrs.len(); + if count > 0 { + let mut sorted: Vec = all_cidrs.into_iter().collect(); + sorted.sort(); + let content = sorted.join("\n"); + let path = antiscanner_cache_path(); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::write(&path, content); + } + + Ok(count) +} + +pub fn get_antiscanner_dropped_stats() -> (u64, u64) { + let Ok(out) = Command::new("nft") + .args(["list", "chain", "inet", TABLE, "input"]) + .output() + else { + return (0, 0); + }; + if !out.status.success() { + return (0, 0); + } + let text = String::from_utf8_lossy(&out.stdout); + parse_counter_stats(&text) +} + +fn parse_counter_stats(text: &str) -> (u64, u64) { + let mut total_packets = 0u64; + let mut total_bytes = 0u64; + for line in text.lines() { + if line.contains("@scanners4") || line.contains("@scanners6") || line.contains("synlimit") { + let parts: Vec<&str> = line.split_whitespace().collect(); + for i in 0..parts.len() { + if parts[i] == "packets" && i + 1 < parts.len() { + if let Ok(p) = parts[i + 1].parse::() { + total_packets += p; + } + } + if parts[i] == "bytes" && i + 1 < parts.len() { + if let Ok(b) = parts[i + 1].parse::() { + total_bytes += b; + } + } + } + } + } + (total_packets, total_bytes) +} + /// Блокирует адрес на время. `0` — до перезагрузки ноды. pub fn block_ip(ip: &str, seconds: u32) -> Result<(), String> { if is_protected(ip) { @@ -366,6 +834,100 @@ mod tests { assert!(expand(&cfg.ingress_filter.blocked_ips, lists).is_empty()); assert!(!cfg.ingress_filter.enabled); assert!(!cfg.torrent_blocker.enabled); + assert!(!cfg.anti_scanner.enabled); assert_eq!(cfg.torrent_blocker.block_duration, 3600); + assert_eq!(cfg.anti_scanner.update_interval_secs, 43200); + } + + #[test] + fn антисканер_конфиг_разбирается() { + let json = serde_json::json!({ + "antiScanner": { + "enabled": true, + "sources": ["https://example.com/block.list"], + "updateIntervalSecs": 86400, + "customIps": ["198.51.100.0/24"] + } + }); + let cfg: PluginConfig = serde_json::from_value(json).unwrap(); + assert!(cfg.anti_scanner.enabled); + assert_eq!(cfg.anti_scanner.sources, vec!["https://example.com/block.list"]); + assert_eq!(cfg.anti_scanner.update_interval_secs, 86400); + assert_eq!(cfg.anti_scanner.custom_ips, vec!["198.51.100.0/24"]); + assert!(!cfg.is_noop()); + } + + #[test] + fn антисканер_фильтрует_опасные_подсети() { + assert!(is_protected_cidr("0.0.0.0/0"), "0.0.0.0/0 должно быть защищено"); + assert!(is_protected_cidr("10.0.0.0/8"), "10.0.0.0/8 должно быть защищено"); + assert!(is_protected_cidr("192.168.1.0/24"), "192.168.1.0/24 должно быть защищено"); + assert!(is_protected_cidr("127.0.0.1/32"), "127.0.0.1/32 должно быть защищено"); + assert!(is_protected_cidr("::1/128"), "::1/128 должно быть защищено"); + assert!(is_protected_cidr("1.0.0.0/4"), "широкая маска /4 должна быть защищена"); + assert!(!is_protected_cidr("198.51.100.0/24"), "публичная подсеть не должна быть защищена"); + assert!(!is_protected_cidr("95.173.136.0/21"), "сканерная подсеть должна проходить фильтр"); + + assert!(is_valid_cidr_or_ip("198.51.100.0/24")); + assert!(is_valid_cidr_or_ip("2001:db8::/32")); + assert!(!is_valid_cidr_or_ip("999.999.999.999/24")); + assert!(!is_valid_cidr_or_ip("1.2.3.4/33")); + } + + #[test] + fn проверка_вхождения_ip_в_cidr() { + let ip: std::net::IpAddr = "203.0.113.195".parse().unwrap(); + assert!(cidr_contains_ip("203.0.113.195", ip)); + assert!(cidr_contains_ip("203.0.113.195/32", ip)); + assert!(cidr_contains_ip("203.0.113.0/24", ip)); + assert!(cidr_contains_ip("203.0.0.0/16", ip)); + assert!(cidr_contains_ip("0.0.0.0/0", ip)); + assert!(!cidr_contains_ip("203.0.114.0/24", ip)); + assert!(!cidr_contains_ip("1.1.1.1/32", ip)); + + let v6: std::net::IpAddr = "2001:db8::1".parse().unwrap(); + assert!(cidr_contains_ip("2001:db8::/32", v6)); + assert!(cidr_contains_ip("2001:db8::1/128", v6)); + assert!(cidr_contains_ip("::/0", v6)); + assert!(!cidr_contains_ip("2001:db9::/32", v6)); + } + + #[test] + fn разбор_статистики_counter() { + let nft_out = " +table inet stealthnet { + chain input { + type filter hook input priority filter; policy accept; + ip saddr @scanners4 counter packets 3120 bytes 249600 drop + ip6 saddr @scanners6 counter packets 5 bytes 400 drop + tcp flags syn meter synlimit4 size 65535 { ip saddr limit rate over 200/second burst 300 packets } counter packets 15 bytes 900 drop + } +}"; + let (packets, bytes) = parse_counter_stats(nft_out); + assert_eq!(packets, 3140); + assert_eq!(bytes, 250900); + } + + #[test] + fn коллапсирование_перекрывающихся_подсетей() { + let raw_v4 = vec![ + "10.0.0.0/8".into(), + "10.1.0.0/16".into(), + "10.1.2.3/32".into(), + "192.168.1.0/24".into(), + "192.168.1.15".into(), + ]; + let v4 = collapse_cidrs_v4(&raw_v4); + assert_eq!(v4, vec!["10.0.0.0/8", "192.168.1.0/24"]); + + let raw_v6 = vec![ + "2a01:230:3::/48".into(), + "2a01:230::/32".into(), + "2a01:230::/48".into(), + "2001:db8::1/128".into(), + ]; + let v6 = collapse_cidrs_v6(&raw_v6); + assert_eq!(v6, vec!["2001:db8::1", "2a01:230::/32"]); } } + diff --git a/web/p-nodes.js b/web/p-nodes.js index cd33352..4c1f70f 100644 --- a/web/p-nodes.js +++ b/web/p-nodes.js @@ -837,8 +837,15 @@ function pluginsDrawer(n, onSaved){ const c = d.config || {}; const tb = c.torrentBlocker || {}, inf = c.ingressFilter || {}, eg = c.egressFilter || {}; + const asc = c.antiScanner || {}; const st = d.status; const lines = (a) => (a || []).join('\n'); + const defaultAscSources = [ + 'https://raw.githubusercontent.com/shadow-netlab/traffic-guard-lists/refs/heads/main/public/government_networks.list', + 'https://raw.githubusercontent.com/shadow-netlab/traffic-guard-lists/refs/heads/main/public/antiscanner.list', + 'https://raw.githubusercontent.com/shadow-netlab/traffic-guard-lists/refs/heads/main/public/skipa.list', + ]; + const ascSources = asc.sources && asc.sources.length ? asc.sources : defaultAscSources; layer.querySelector('#pgBody').innerHTML = ` ${st && (!st.nft_available || !st.can_modify) ? ` @@ -849,7 +856,33 @@ function pluginsDrawer(n, onSaved){ Настройки сохранятся, но работать начнут только после исправления. ` : ''} -

${I('magnet',13)} Блокировщик торрентов

+

${I('shieldCheck',13)} Защита от сканеров и зондирования (Anti-Scanner)

+ +
+ Сбрасывает входящие пакеты сканеров и ботов до передачи движку Xray. Списки автоматически кэшируются на сервере. +
+ ${st && st.antiscanner_enabled ? ` +
+ Активно подсетей: ${(st.antiscanner_rules_count || 0).toLocaleString()} + Сброшено пакетов: ${(st.antiscanner_dropped_packets || 0).toLocaleString()} + Сброшено трафика: ${fmtBytes(st.antiscanner_dropped_bytes || 0)} +
` : ''} +
+
+ +
Один URL в строке. Текстовые списки CIDR/IP с комментариями (#, ;).
+
+
сек
+
По умолчанию 43200 (12 ч). Минимум 300 сек.
+ + +
Добавляются в набор nftables вместе со сканерами.
+
+
+ +

${I('magnet',13)} Блокировщик торрентов

@@ -920,6 +953,12 @@ function pluginsDrawer(n, onSaved){ blockedIps: arr('#pgEgIps'), blockedPorts: arr('#pgEgPorts').map(Number).filter(Number.isFinite), }, + antiScanner: { + enabled: layer.querySelector('#pgAsc').checked, + sources: arr('#pgAscSources'), + updateIntervalSecs: parseInt(layer.querySelector('#pgAscInt').value, 10) || 43200, + customIps: arr('#pgAscCustom'), + }, sharedLists, }; try { @@ -1044,7 +1083,22 @@ registerPage({ async bind(root){ root.querySelectorAll('[data-config]').forEach(b=>b.onclick=()=>pluginsDrawer(DB.nodes.find(n=>n.id===b.dataset.config),()=>this.bind(root))); root.querySelectorAll('[data-blocks]').forEach(b=>b.onclick=()=>blocksDrawer(DB.nodes.find(n=>n.id===b.dataset.blocks))); - await Promise.all(DB.nodes.map(async n=>{const el=root.querySelector('[data-plugin-state="'+n.id+'"]');try{const d=await API.call('/api/nodes/'+n.id+'/plugins');const st=d.status;el.textContent=!st?'Агент ещё не отчитался':st.error?'Ошибка: '+st.error:!st.nft_available?'Установите nftables':!st.can_modify?'Нет прав NET_ADMIN':st.applied?'Правила применены':'Готов к настройке';}catch(e){el.textContent='Не удалось проверить: '+e.message;}})); + await Promise.all(DB.nodes.map(async n=>{ + const el=root.querySelector('[data-plugin-state="'+n.id+'"]'); + try { + const d=await API.call('/api/nodes/'+n.id+'/plugins'); + const st=d.status; + if (!st) { el.textContent='Агент ещё не отчитался'; return; } + if (st.error) { el.textContent='Ошибка: '+st.error; return; } + if (!st.nft_available) { el.textContent='Установите nftables'; return; } + if (!st.can_modify) { el.textContent='Нет прав NET_ADMIN'; return; } + let txt = st.applied ? 'Правила применены' : 'Готов к настройке'; + if (st.antiscanner_enabled) { + txt += ` · Антисканер (${(st.antiscanner_rules_count || 0).toLocaleString()} подсетей, ${(st.antiscanner_dropped_packets || 0).toLocaleString()} сброшено)`; + } + el.textContent = txt; + } catch(e){ el.textContent='Не удалось проверить: '+e.message; } + })); } });