diff --git a/src/cache.rs b/src/cache.rs index ee7f7f0..6907551 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -38,9 +38,63 @@ impl Cache { (), ) .await?; + self.conn + .execute( + "CREATE TABLE IF NOT EXISTS trusted_networks (\ + gateway_mac TEXT PRIMARY KEY, \ + trusted_at INTEGER NOT NULL\ + )", + (), + ) + .await?; Ok(()) } + /// Best-effort: gibt bei jedem Fehler `false` zurück (nur eine Warnung wird geloggt) - + /// ein Cache-Lesefehler darf ein zuvor bereits bestätigtes Netzwerk nicht plötzlich + /// als unbekannt erscheinen lassen, aber sicherheitshalber auch nicht automatisch + /// als vertrauenswürdig gelten lassen; der Aufrufer fragt in diesem Fall erneut nach. + pub async fn is_trusted_network(&self, gateway_mac: &MacAddress) -> bool { + let key = gateway_mac.to_lower_colon(); + match self + .conn + .query( + "SELECT 1 FROM trusted_networks WHERE gateway_mac = ?1", + (key,), + ) + .await + { + Ok(mut rows) => matches!(rows.next().await, Ok(Some(_))), + Err(e) => { + crate::log::warn( + "cache", + &format!("Cache-Lesefehler (trusted_networks): {e}"), + ); + false + } + } + } + + /// Best-effort Upsert: merkt sich ein Netzwerk (identifiziert über die Gateway-MAC) + /// dauerhaft als vertrauenswürdig für zukünftige nmap-Scans. + pub async fn trust_network(&self, gateway_mac: &MacAddress, now: i64) { + let key = gateway_mac.to_lower_colon(); + if let Err(e) = self + .conn + .execute( + "INSERT INTO trusted_networks (gateway_mac, trusted_at) VALUES (?1, ?2) \ + ON CONFLICT(gateway_mac) DO UPDATE SET trusted_at = excluded.trusted_at", + (key, now), + ) + .await + { + crate::log::warn( + "cache", + &format!("Cache-Schreibfehler (trusted_networks, ignoriert): {e}"), + ); + } + } + /// Best-effort: gibt bei jedem Fehler `None` zurück (nur eine Warnung wird geloggt). pub async fn get(&self, mac: &MacAddress) -> Option { let key = mac.to_lower_colon(); @@ -189,7 +243,13 @@ mod tests { #[test] fn test_ensure_cache_dir_creates_new_directory() { - let temp_dir = std::env::temp_dir().join(format!("mac2ip_test_{}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos())); + let temp_dir = std::env::temp_dir().join(format!( + "mac2ip_test_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); assert!(!temp_dir.exists()); assert!(ensure_cache_dir(&temp_dir).is_ok()); assert!(temp_dir.exists()); diff --git a/src/cli.rs b/src/cli.rs index 949755b..024e104 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -45,6 +45,17 @@ pub struct Cli { /// Kommagetrennte Liste von CIDR-Subnetzen für den nmap-Scan (überschreibt Auto-Erkennung) #[arg(long, env = "MAC2IP_NETWORKS", value_delimiter = ',')] pub networks: Option>, + + /// Kommagetrennte Liste von Gateway-MAC-Adressen, deren Netzwerke ohne Rückfrage für + /// nmap-Scans (Schritt 3) vertraut werden (überschreibt die Konfigurationsdatei vollständig) + #[arg(long, env = "MAC2IP_TRUSTED_NETWORKS", value_delimiter = ',')] + pub trusted_networks: Option>, + + /// Beantwortet die "nmap-Scan in diesem Netzwerk erlauben?"-Rückfrage vor Schritt 3 + /// automatisch mit Ja (und merkt sich das Netzwerk dauerhaft im Cache), statt + /// interaktiv nachzufragen bzw. im --json-Modus den Scan abzulehnen + #[arg(long)] + pub auto_trust_networks: bool, } #[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] diff --git a/src/config.rs b/src/config.rs index 4952700..abbc921 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,6 +4,8 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; +use crate::mac::MacAddress; + pub const DEFAULT_CACHE_TTL_SECONDS: u64 = 1800; // 30 Min: Ping-Check ist die primäre // Absicherung gegen veraltete Einträge, die TTL ist nur eine zusätzliche Absicherung // gegen den Fall, dass eine alte IP inzwischen an ein anderes, ebenfalls @@ -40,6 +42,11 @@ pub struct AppConfig { pub nmap_timeout_seconds: u64, #[serde(default)] pub networks: Vec, + /// Gateway-MAC-Adressen von Netzwerken, in denen ein nmap-Scan (Schritt 3) ohne + /// interaktive Rückfrage erlaubt ist. Wird um per Rückfrage/`--auto-trust-networks` + /// bestätigte Netzwerke ergänzt, die im Cache gespeichert werden (siehe `src/trust.rs`). + #[serde(default)] + pub trusted_networks: Vec, } impl Default for AppConfig { @@ -50,10 +57,23 @@ impl Default for AppConfig { log_level: DEFAULT_LOG_LEVEL.to_string(), nmap_timeout_seconds: DEFAULT_NMAP_TIMEOUT_SECONDS, networks: Vec::new(), + trusted_networks: Vec::new(), } } } +impl AppConfig { + /// Reine, testbare Funktion: validiert die konfigurierten vertrauten Netzwerke. + /// Nicht als MAC-Adresse parsbare Einträge werden übersprungen (kein harter Fehler, + /// da eine fehlerhafte Config-Zeile den Lookup nicht insgesamt scheitern lassen soll). + pub fn trusted_network_macs(&self) -> Vec { + self.trusted_networks + .iter() + .filter_map(|s| MacAddress::parse(s).ok()) + .collect() + } +} + /// Initialisiert den Konfigurationspfad bei `config-ctdra` (Standardname "config", /// optionaler expliziter Pfad via `--config`). pub fn init_config_path(custom_path: Option<&Path>) { @@ -90,4 +110,7 @@ pub fn apply_cli_overrides(config: &mut AppConfig, cli: &crate::cli::Cli) { if let Some(networks) = &cli.networks { config.networks = networks.clone(); } + if let Some(trusted) = &cli.trusted_networks { + config.trusted_networks = trusted.iter().map(MacAddress::to_lower_colon).collect(); + } } diff --git a/src/error.rs b/src/error.rs index e4dbe24..ed8c7c7 100644 --- a/src/error.rs +++ b/src/error.rs @@ -6,6 +6,11 @@ use crate::mac::MacAddressError; pub enum Mac2IpError { #[error("keine IP-Adresse für MAC '{mac}' gefunden")] NotFound { mac: String }, + #[error( + "nmap-Scan abgelehnt: Netzwerk nicht vertrauenswürdig{}", + .gateway_mac.as_ref().map(|m| format!(" (Gateway-MAC {m})")).unwrap_or_default() + )] + UntrustedNetwork { gateway_mac: Option }, #[error("MAC-Adresse ungültig: {0}")] InvalidMac(#[from] MacAddressError), #[error("E/A-Fehler: {0}")] diff --git a/src/lib.rs b/src/lib.rs index 0f87c66..4e4142a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,3 +10,4 @@ pub mod mac; pub mod network; pub mod output; pub mod resolver; +pub mod trust; diff --git a/src/main.rs b/src/main.rs index adf8d6a..72d5ecf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,7 +23,15 @@ async fn main() -> ExitCode { let cache = cache::try_open_cache(&app_config.cache_db_path).await; - match resolver::resolve(&cli.mac, &app_config, cache.as_ref(), cli.json).await { + match resolver::resolve( + &cli.mac, + &app_config, + cache.as_ref(), + cli.json, + cli.auto_trust_networks, + ) + .await + { Ok(result) => { output::print_success(&result, cli.json); ExitCode::SUCCESS diff --git a/src/network.rs b/src/network.rs index da42b20..2b06331 100644 --- a/src/network.rs +++ b/src/network.rs @@ -30,6 +30,31 @@ pub async fn run_ip_route_show_scope_link() -> std::io::Result { Ok(String::from_utf8_lossy(&out.stdout).into_owned()) } +pub async fn run_ip_route_show_default() -> std::io::Result { + let out = tokio::process::Command::new("ip") + .args(["-4", "route", "show", "default"]) + .output() + .await?; + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) +} + +/// Ermittelt die MAC-Adresse des aktuellen Default-Gateways - dient als stabiler +/// Identifikator für "dieses Netzwerk" (bleibt bei DHCP-Subnetzwechsel gleich, +/// solange derselbe Router im Einsatz ist), unabhängig von SSID/CIDR. +/// Gibt `None` zurück, wenn kein Default-Gateway existiert oder dessen MAC nicht +/// ermittelt werden kann (z. B. kein Nachbar-Eintrag trotz Ping). +pub async fn detect_default_gateway_mac() -> Option { + let route_output = run_ip_route_show_default().await.ok()?; + let gateway_ip = parse_default_gateway_ip(&route_output)?; + + // Erzwingt best-effort einen frischen Nachbar-Tabelleneintrag; Ergebnis wird + // ignoriert, da `ip neigh` auch von einem veralteten/STALE-Eintrag profitiert. + let _ = ping_check(gateway_ip).await; + + let neigh_output = run_ip_neigh_show().await.ok()?; + parse_ip_neigh_mac_for_ip(&neigh_output, gateway_ip) +} + pub async fn ping_check(ip: IpAddr) -> bool { tokio::process::Command::new("ping") .args([ @@ -106,22 +131,23 @@ pub async fn run_nmap_scan( let child = cmd.spawn()?; let pgid = child.id(); - let out = match tokio::time::timeout(Duration::from_secs(timeout_secs), child.wait_with_output()) - .await - { - Ok(out) => out?, - Err(_) => { - if let Some(pgid) = pgid { - // Negative PID = Signal an die gesamte Prozessgruppe (sudo + nmap). - let _ = tokio::process::Command::new("kill") - .arg("-KILL") - .arg(format!("-{pgid}")) - .output() - .await; + let out = + match tokio::time::timeout(Duration::from_secs(timeout_secs), child.wait_with_output()) + .await + { + Ok(out) => out?, + Err(_) => { + if let Some(pgid) = pgid { + // Negative PID = Signal an die gesamte Prozessgruppe (sudo + nmap). + let _ = tokio::process::Command::new("kill") + .arg("-KILL") + .arg(format!("-{pgid}")) + .output() + .await; + } + return Err(NmapRunError::Timeout); } - return Err(NmapRunError::Timeout); - } - }; + }; if !out.status.success() { let stderr = String::from_utf8_lossy(&out.stderr); @@ -159,6 +185,34 @@ pub fn parse_ip_neigh_output(output: &str, mac: &MacAddress) -> Option { None } +/// Parst `ip -4 route show default`, z. B. "default via 192.168.1.1 dev eth0 proto dhcp +/// metric 100". Gibt die Gateway-IP zurück (Token direkt nach "via"). +pub fn parse_default_gateway_ip(output: &str) -> Option { + output.lines().find_map(|line| { + let tokens: Vec<&str> = line.split_whitespace().collect(); + let pos = tokens.iter().position(|&t| t == "via")?; + tokens.get(pos + 1)?.parse().ok() + }) +} + +/// Parst `ip neigh show`-Ausgabe und gibt die `lladdr`-MAC des Eintrags zurück, dessen +/// erste Spalte (IP) mit `target_ip` übereinstimmt. Kehrbild zu [`parse_ip_neigh_output`]. +pub fn parse_ip_neigh_mac_for_ip(output: &str, target_ip: IpAddr) -> Option { + for line in output.lines() { + let tokens: Vec<&str> = line.split_whitespace().collect(); + if tokens.first().and_then(|t| t.parse::().ok()) != Some(target_ip) { + continue; + } + if let Some(pos) = tokens.iter().position(|&t| t == "lladdr") + && let Some(&mac_str) = tokens.get(pos + 1) + && let Ok(mac) = mac_str.parse() + { + return Some(mac); + } + } + None +} + /// Extrahiert lokale CIDR-Subnetze aus `ip -4 route show scope link`. /// Die CIDR ist bereits das erste whitespace-getrennte Token pro passender Zeile. /// Überspringt `default`-Zeilen und das Loopback-Interface. diff --git a/src/resolver.rs b/src/resolver.rs index 2648753..1995611 100644 --- a/src/resolver.rs +++ b/src/resolver.rs @@ -8,6 +8,7 @@ use crate::config::AppConfig; use crate::error::Mac2IpError; use crate::mac::MacAddress; use crate::network::{self, NmapRunError, SudoMode}; +use crate::trust::{self, TrustDecision}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MatchSource { @@ -44,6 +45,7 @@ pub async fn resolve( config: &AppConfig, cache: Option<&Cache>, json_mode: bool, + auto_trust_networks: bool, ) -> Result { let now = now_unix(); @@ -86,6 +88,20 @@ pub async fn resolve( // Schritt 3: nmap crate::log::debug("resolver", "Kein Treffer via ip neigh, starte nmap-Scan"); + + match trust::ensure_network_trusted(config, cache, auto_trust_networks, json_mode, now).await { + TrustDecision::Allowed => {} + TrustDecision::Denied { gateway_mac } => { + crate::log::warn( + "resolver", + "nmap-Scan übersprungen: Netzwerk nicht als vertrauenswürdig bestätigt", + ); + return Err(Mac2IpError::UntrustedNetwork { + gateway_mac: gateway_mac.map(|m| m.to_string()), + }); + } + } + let subnets = if !config.networks.is_empty() { config.networks.clone() } else { diff --git a/src/trust.rs b/src/trust.rs new file mode 100644 index 0000000..7dd7075 --- /dev/null +++ b/src/trust.rs @@ -0,0 +1,110 @@ +//! Netzwerk-Vertrauensprüfung vor dem nmap-Scan (Schritt 3). +//! +//! Ein ARP-/Ping-Scan ganzer Subnetze ist auf Netzwerken, die man nicht selbst +//! administriert (Firmen-/Gast-WLAN etc.), potenziell heikel (IDS-Alarm, Verstoß gegen +//! Nutzungsordnungen). Das aktuelle Netzwerk wird daher über die MAC-Adresse seines +//! Default-Gateways identifiziert (stabil über IP-/Subnetzwechsel hinweg) und nmap darf +//! nur laufen, wenn dieses Netzwerk explizit vertraut ist: +//! - statisch über `AppConfig::trusted_networks` (Config-Datei/CLI/ENV), oder +//! - dynamisch über eine frühere Bestätigung, die im Cache gespeichert wurde, oder +//! - automatisch, wenn `--auto-trust-networks` gesetzt ist (merkt sich das Netzwerk +//! danach ebenfalls im Cache). +//! +//! Ist keins davon der Fall, wird im interaktiven Modus nachgefragt; im --json-Modus +//! ist keine Rückfrage möglich, daher wird sicherheitshalber abgelehnt. + +use std::io::Write; + +use crate::cache::Cache; +use crate::config::AppConfig; +use crate::mac::MacAddress; +use crate::network; + +pub enum TrustDecision { + Allowed, + Denied { gateway_mac: Option }, +} + +pub async fn ensure_network_trusted( + config: &AppConfig, + cache: Option<&Cache>, + auto_trust: bool, + json_mode: bool, + now: i64, +) -> TrustDecision { + let Some(gateway_mac) = network::detect_default_gateway_mac().await else { + crate::log::warn( + "trust", + "Gateway-MAC konnte nicht ermittelt werden, Netzwerk kann nicht identifiziert werden", + ); + if auto_trust { + crate::log::warn( + "trust", + "--auto-trust-networks gesetzt: nmap-Scan wird trotz unbekannter Gateway-MAC ausgeführt", + ); + return TrustDecision::Allowed; + } + return TrustDecision::Denied { gateway_mac: None }; + }; + + if config.trusted_network_macs().contains(&gateway_mac) { + return TrustDecision::Allowed; + } + + if let Some(cache) = cache + && cache.is_trusted_network(&gateway_mac).await + { + return TrustDecision::Allowed; + } + + if auto_trust { + crate::log::info( + "trust", + &format!( + "Netzwerk (Gateway-MAC {gateway_mac}) automatisch als vertrauenswürdig markiert" + ), + ); + if let Some(cache) = cache { + cache.trust_network(&gateway_mac, now).await; + } + return TrustDecision::Allowed; + } + + if json_mode { + // Keine Rückfrage im --json-Modus möglich (stdin ggf. nicht an ein Terminal + // gebunden, Ausgabe darf nicht durch einen Prompt verunreinigt werden). + return TrustDecision::Denied { + gateway_mac: Some(gateway_mac), + }; + } + + if prompt_trust_confirmation(gateway_mac).await { + if let Some(cache) = cache { + cache.trust_network(&gateway_mac, now).await; + } + TrustDecision::Allowed + } else { + TrustDecision::Denied { + gateway_mac: Some(gateway_mac), + } + } +} + +/// Blockierende stdin-Abfrage, daher in `spawn_blocking` ausgelagert, damit der +/// Tokio-Runtime-Thread währenddessen nicht blockiert. +async fn prompt_trust_confirmation(gateway_mac: MacAddress) -> bool { + tokio::task::spawn_blocking(move || { + eprint!( + "Unbekanntes Netzwerk (Gateway-MAC {gateway_mac}). nmap-Scan in diesem Netzwerk erlauben und dauerhaft merken? [y/N]: " + ); + let _ = std::io::stderr().flush(); + + let mut input = String::new(); + if std::io::stdin().read_line(&mut input).is_err() { + return false; + } + matches!(input.trim().to_lowercase().as_str(), "y" | "yes" | "j" | "ja") + }) + .await + .unwrap_or(false) +} diff --git a/tests/config_overrides.rs b/tests/config_overrides.rs index 34b13c3..25a83ee 100644 --- a/tests/config_overrides.rs +++ b/tests/config_overrides.rs @@ -14,6 +14,8 @@ fn base_cli() -> Cli { cache_db_path: None, nmap_timeout_seconds: None, networks: None, + trusted_networks: None, + auto_trust_networks: false, } } @@ -61,6 +63,18 @@ fn overrides_networks() { assert_eq!(config.networks, vec!["10.0.0.0/24".to_string()]); } +#[test] +fn overrides_trusted_networks() { + let mut config = AppConfig::default(); + let mut cli = base_cli(); + cli.trusted_networks = Some(vec![MacAddress::parse("aa:bb:cc:dd:ee:ff").unwrap()]); + apply_cli_overrides(&mut config, &cli); + assert_eq!( + config.trusted_networks, + vec!["aa:bb:cc:dd:ee:ff".to_string()] + ); +} + #[test] fn partial_overrides_only_touch_provided_fields() { let default_config = AppConfig::default(); @@ -77,4 +91,5 @@ fn partial_overrides_only_touch_provided_fields() { default_config.nmap_timeout_seconds ); assert_eq!(config.networks, default_config.networks); + assert_eq!(config.trusted_networks, default_config.trusted_networks); } diff --git a/tests/gateway_trust.rs b/tests/gateway_trust.rs new file mode 100644 index 0000000..b7ac6e0 --- /dev/null +++ b/tests/gateway_trust.rs @@ -0,0 +1,94 @@ +use std::net::IpAddr; + +use mac2ip::config::AppConfig; +use mac2ip::mac::MacAddress; +use mac2ip::network::{parse_default_gateway_ip, parse_ip_neigh_mac_for_ip}; + +fn mac(s: &str) -> MacAddress { + MacAddress::parse(s).unwrap() +} + +fn ip(s: &str) -> IpAddr { + s.parse().unwrap() +} + +// --- parse_default_gateway_ip --- + +#[test] +fn parses_gateway_ip_from_default_route() { + let output = "default via 192.168.1.1 dev eth0 proto dhcp metric 100\n"; + assert_eq!(parse_default_gateway_ip(output), Some(ip("192.168.1.1"))); +} + +#[test] +fn parses_gateway_ip_ignoring_extra_trailing_fields() { + let output = "default via 10.0.0.254 dev wlan0 proto static metric 600 onlink\n"; + assert_eq!(parse_default_gateway_ip(output), Some(ip("10.0.0.254"))); +} + +#[test] +fn no_default_route_returns_none() { + assert!(parse_default_gateway_ip("").is_none()); +} + +#[test] +fn missing_via_token_returns_none() { + let output = "default dev eth0 proto dhcp metric 100\n"; + assert!(parse_default_gateway_ip(output).is_none()); +} + +// --- parse_ip_neigh_mac_for_ip --- + +#[test] +fn finds_mac_for_matching_ip() { + let output = "192.168.1.1 dev eth0 lladdr aa:bb:cc:dd:ee:ff REACHABLE\n"; + let result = parse_ip_neigh_mac_for_ip(output, ip("192.168.1.1")); + assert_eq!(result, Some(mac("aa:bb:cc:dd:ee:ff"))); +} + +#[test] +fn ignores_entries_for_other_ips() { + let output = "\ +192.168.1.2 dev eth0 lladdr 11:22:33:44:55:66 STALE +192.168.1.1 dev eth0 lladdr aa:bb:cc:dd:ee:ff REACHABLE +"; + let result = parse_ip_neigh_mac_for_ip(output, ip("192.168.1.1")); + assert_eq!(result, Some(mac("aa:bb:cc:dd:ee:ff"))); +} + +#[test] +fn returns_none_when_ip_not_present() { + let output = "192.168.1.2 dev eth0 lladdr 11:22:33:44:55:66 STALE\n"; + assert!(parse_ip_neigh_mac_for_ip(output, ip("192.168.1.1")).is_none()); +} + +// --- AppConfig::trusted_network_macs --- + +#[test] +fn trusted_network_macs_parses_valid_entries() { + let config = AppConfig { + trusted_networks: vec!["aa:bb:cc:dd:ee:ff".to_string()], + ..Default::default() + }; + assert_eq!( + config.trusted_network_macs(), + vec![mac("aa:bb:cc:dd:ee:ff")] + ); +} + +#[test] +fn trusted_network_macs_skips_invalid_entries() { + let config = AppConfig { + trusted_networks: vec!["not-a-mac".to_string(), "aa:bb:cc:dd:ee:ff".to_string()], + ..Default::default() + }; + assert_eq!( + config.trusted_network_macs(), + vec![mac("aa:bb:cc:dd:ee:ff")] + ); +} + +#[test] +fn trusted_network_macs_empty_by_default() { + assert!(AppConfig::default().trusted_network_macs().is_empty()); +}