diff --git a/src/cache.rs b/src/cache.rs new file mode 100644 index 0000000..833626b --- /dev/null +++ b/src/cache.rs @@ -0,0 +1,175 @@ +//! Globaler, systemweiter Cache (MAC -> IP) auf Basis der `turso`-Crate (lokale Datei). +//! +//! Der Cache liegt absichtlich an einem einzigen, für alle Nutzer gemeinsamen Pfad +//! (Standard: `/var/lib/mac2ip/cache.db`). Ist dieser Pfad nicht anlegbar/beschreibbar +//! (z. B. bei einem Entwicklungslauf ohne vorherige Paketinstallation), wird der Cache +//! für den aktuellen Lauf einfach deaktiviert (Warnung, best-effort) - es gibt bewusst +//! KEINEN Fallback auf einen Pro-User-Pfad, da der Cache explizit global sein soll. + +use std::net::IpAddr; +use std::path::{Path, PathBuf}; + +use crate::mac::MacAddress; + +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +pub struct CacheEntry { + pub ip: IpAddr, + #[allow(dead_code)] + pub source: String, + pub updated_at: i64, +} + +pub struct Cache { + conn: turso::Connection, +} + +impl Cache { + async fn init_schema(&self) -> turso::Result<()> { + self.conn + .execute( + "CREATE TABLE IF NOT EXISTS mac_ip_cache (\ + mac TEXT PRIMARY KEY, \ + ip TEXT NOT NULL, \ + source TEXT NOT NULL, \ + updated_at INTEGER NOT NULL\ + )", + (), + ) + .await?; + Ok(()) + } + + /// 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(); + let mut rows = match self + .conn + .query( + "SELECT ip, source, updated_at FROM mac_ip_cache WHERE mac = ?1", + (key,), + ) + .await + { + Ok(rows) => rows, + Err(e) => { + crate::log::warn("cache", &format!("Cache-Lesefehler: {e}")); + return None; + } + }; + + match rows.next().await { + Ok(Some(row)) => { + let ip_str: String = row.get(0).ok()?; + let source: String = row.get(1).ok()?; + let updated_at: i64 = row.get(2).ok()?; + ip_str.parse().ok().map(|ip| CacheEntry { + ip, + source, + updated_at, + }) + } + Ok(None) => None, + Err(e) => { + crate::log::warn("cache", &format!("Cache-Lesefehler: {e}")); + None + } + } + } + + /// Best-effort Upsert: Fehler werden nur geloggt, niemals propagiert - ein + /// Cache-Schreibfehler darf den 3-Stufen-Lookup nie zum Scheitern bringen. + pub async fn upsert(&self, mac: &MacAddress, ip: IpAddr, source: &str, now: i64) { + let key = mac.to_lower_colon(); + if let Err(e) = self + .conn + .execute( + "INSERT INTO mac_ip_cache (mac, ip, source, updated_at) VALUES (?1, ?2, ?3, ?4) \ + ON CONFLICT(mac) DO UPDATE SET ip = excluded.ip, source = excluded.source, updated_at = excluded.updated_at", + (key, ip.to_string(), source.to_string(), now), + ) + .await + { + crate::log::warn("cache", &format!("Cache-Schreibfehler (ignoriert): {e}")); + } + } +} + +/// Reine, injectable-Zeit-Funktion - unit-testbar ohne echte Systemzeit. +pub fn is_expired(updated_at: i64, now: i64, ttl_seconds: u64) -> bool { + now.saturating_sub(updated_at) as u64 > ttl_seconds +} + +fn ensure_cache_dir(dir: &Path) -> Result<(), String> { + if !dir.exists() { + std::fs::create_dir_all(dir).map_err(|e| { + format!( + "Verzeichnis '{}' konnte nicht angelegt werden: {e}", + dir.display() + ) + })?; + } + // create_dir_all() unterliegt dem Prozess-Umask; explizites chmod ist nötig, um + // wirklich 0777 zu erreichen (Verzeichnis-Rechte steuern nicht die Umask neuer Dateien). + #[cfg(unix)] + std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o777)) + .map_err(|e| format!("Verzeichnis-Rechte konnten nicht gesetzt werden: {e}"))?; + Ok(()) +} + +fn chmod_cache_sidecars(db_path: &Path) { + #[cfg(unix)] + for suffix in ["", "-wal", "-shm", "-journal"] { + let p = PathBuf::from(format!("{}{suffix}", db_path.display())); + if p.exists() { + let _ = std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o666)); + } + } +} + +/// Best-effort Cache-Öffnung: gibt `None` zurück (statt Fehler), wenn der Cache aus +/// irgendeinem Grund nicht verfügbar ist - der Aufrufer führt den 3-Stufen-Algorithmus +/// dann einfach ohne Cache aus. +pub async fn try_open_cache(db_path: &Path) -> Option { + if let Some(dir) = db_path.parent() + && let Err(reason) = ensure_cache_dir(dir) + { + crate::log::warn("cache", &format!("Cache deaktiviert: {reason}")); + return None; + } + + let db = match turso::Builder::new_local(db_path.to_string_lossy().as_ref()) + .build() + .await + { + Ok(db) => db, + Err(e) => { + crate::log::warn( + "cache", + &format!("Cache-Datenbank konnte nicht geöffnet werden: {e}"), + ); + return None; + } + }; + + let conn = match db.connect() { + Ok(c) => c, + Err(e) => { + crate::log::warn("cache", &format!("Cache-Verbindung fehlgeschlagen: {e}")); + return None; + } + }; + + let cache = Cache { conn }; + if let Err(e) = cache.init_schema().await { + crate::log::warn( + "cache", + &format!("Cache-Schema konnte nicht initialisiert werden: {e}"), + ); + return None; + } + + chmod_cache_sidecars(db_path); + Some(cache) +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..949755b --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,78 @@ +//! Kommandozeilen-Interface. + +use std::path::PathBuf; + +use clap::{Parser, ValueEnum}; + +use crate::mac::MacAddress; + +#[derive(Parser, Debug)] +#[command( + name = "mac2ip", + author, + version, + about = "Findet zuverlässig die aktuelle IP-Adresse zu einer MAC-Adresse im lokalen Netzwerk", + long_about = None +)] +pub struct Cli { + /// MAC-Adresse, deren aktuelle IP-Adresse ermittelt werden soll (z. B. aa:bb:cc:dd:ee:ff) + pub mac: MacAddress, + + /// Gibt das Ergebnis als einzeiliges JSON-Objekt aus; unterdrückt alle sonstigen Log-Ausgaben + #[arg(long)] + pub json: bool, + + /// Benutzerdefinierter Pfad zur Konfigurationsdatei + #[arg(long)] + pub config: Option, + + /// Logging-Level (error, warn, info, debug) + #[arg(long, value_enum, env = "MAC2IP_LOG_LEVEL")] + pub log_level: Option, + + /// Cache-TTL in Sekunden (Standard: 1800) + #[arg(long, env = "MAC2IP_CACHE_TTL_SECONDS")] + pub cache_ttl_seconds: Option, + + /// Pfad zur globalen Cache-Datenbankdatei (Standard: /var/lib/mac2ip/cache.db) + #[arg(long, env = "MAC2IP_CACHE_DB_PATH")] + pub cache_db_path: Option, + + /// Timeout in Sekunden für einen einzelnen nmap-Subnetz-Scan (Standard: 120) + #[arg(long, env = "MAC2IP_NMAP_TIMEOUT_SECONDS")] + pub nmap_timeout_seconds: Option, + + /// Kommagetrennte Liste von CIDR-Subnetzen für den nmap-Scan (überschreibt Auto-Erkennung) + #[arg(long, env = "MAC2IP_NETWORKS", value_delimiter = ',')] + pub networks: Option>, +} + +#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] +pub enum LogLevelArg { + Error, + Warn, + Info, + Debug, +} + +impl LogLevelArg { + pub fn as_str(&self) -> &'static str { + match self { + Self::Error => "error", + Self::Warn => "warn", + Self::Info => "info", + Self::Debug => "debug", + } + } +} + +impl From for logger_ctdra::LogLevel { + fn from(value: LogLevelArg) -> Self { + match value { + LogLevelArg::Error => logger_ctdra::LogLevel::Error, + LogLevelArg::Warn => logger_ctdra::LogLevel::Warn, + LogLevelArg::Info => logger_ctdra::LogLevel::Info, + LogLevelArg::Debug => logger_ctdra::LogLevel::Debug, + } + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..4952700 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,93 @@ +//! Anwendungskonfiguration: persistente Datei (via `config-ctdra`) + CLI-Overlay. + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +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 +// erreichbares Gerät vergeben wurde. +pub const DEFAULT_CACHE_DB_PATH: &str = "/var/lib/mac2ip/cache.db"; +pub const DEFAULT_LOG_LEVEL: &str = "info"; +pub const DEFAULT_NMAP_TIMEOUT_SECONDS: u64 = 120; + +fn default_cache_ttl_seconds() -> u64 { + DEFAULT_CACHE_TTL_SECONDS +} + +fn default_cache_db_path() -> PathBuf { + PathBuf::from(DEFAULT_CACHE_DB_PATH) +} + +fn default_log_level() -> String { + DEFAULT_LOG_LEVEL.to_string() +} + +fn default_nmap_timeout_seconds() -> u64 { + DEFAULT_NMAP_TIMEOUT_SECONDS +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +pub struct AppConfig { + #[serde(default = "default_cache_ttl_seconds")] + pub cache_ttl_seconds: u64, + #[serde(default = "default_cache_db_path")] + pub cache_db_path: PathBuf, + #[serde(default = "default_log_level")] + pub log_level: String, + #[serde(default = "default_nmap_timeout_seconds")] + pub nmap_timeout_seconds: u64, + #[serde(default)] + pub networks: Vec, +} + +impl Default for AppConfig { + fn default() -> Self { + Self { + cache_ttl_seconds: DEFAULT_CACHE_TTL_SECONDS, + cache_db_path: PathBuf::from(DEFAULT_CACHE_DB_PATH), + log_level: DEFAULT_LOG_LEVEL.to_string(), + nmap_timeout_seconds: DEFAULT_NMAP_TIMEOUT_SECONDS, + networks: Vec::new(), + } + } +} + +/// Initialisiert den Konfigurationspfad bei `config-ctdra` (Standardname "config", +/// optionaler expliziter Pfad via `--config`). +pub fn init_config_path(custom_path: Option<&Path>) { + config_ctdra::set_config_name("config"); + if let Some(path) = custom_path { + config_ctdra::set_custom_path(path.to_path_buf()); + } +} + +pub fn get_config_file_path() -> PathBuf { + config_ctdra::get_config_path() +} + +pub fn load_config() -> AppConfig { + config_ctdra::load_config::() +} + +/// Reine, testbare Overlay-Funktion: überschreibt Konfigurationsfelder mit +/// expliziten CLI-Werten (clap hat CLI>ENV bereits selbst aufgelöst; hier wird +/// nur noch "vorhanden > Datei/Default" angewendet). +pub fn apply_cli_overrides(config: &mut AppConfig, cli: &crate::cli::Cli) { + if let Some(level) = &cli.log_level { + config.log_level = level.as_str().to_string(); + } + if let Some(ttl) = cli.cache_ttl_seconds { + config.cache_ttl_seconds = ttl; + } + if let Some(path) = &cli.cache_db_path { + config.cache_db_path = path.clone(); + } + if let Some(timeout) = cli.nmap_timeout_seconds { + config.nmap_timeout_seconds = timeout; + } + if let Some(networks) = &cli.networks { + config.networks = networks.clone(); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..e4dbe24 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,13 @@ +//! Fehlertypen für mac2ip. + +use crate::mac::MacAddressError; + +#[derive(Debug, thiserror::Error)] +pub enum Mac2IpError { + #[error("keine IP-Adresse für MAC '{mac}' gefunden")] + NotFound { mac: String }, + #[error("MAC-Adresse ungültig: {0}")] + InvalidMac(#[from] MacAddressError), + #[error("E/A-Fehler: {0}")] + Io(#[from] std::io::Error), +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..0f87c66 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,12 @@ +//! mac2ip - findet zuverlässig die aktuelle IP-Adresse zu einer MAC-Adresse +//! im lokalen Netzwerk (Cache -> `ip neigh` -> `nmap`). + +pub mod cache; +pub mod cli; +pub mod config; +pub mod error; +pub mod log; +pub mod mac; +pub mod network; +pub mod output; +pub mod resolver; diff --git a/src/log.rs b/src/log.rs new file mode 100644 index 0000000..129b2b7 --- /dev/null +++ b/src/log.rs @@ -0,0 +1,43 @@ +//! JSON-Modus-bewusster Wrapper um `logger-ctdra`. +//! +//! Im `--json`-Modus dürfen keinerlei Log-Ausgaben (auch keine Fehler/Warnungen) +//! auf stdout/stderr erscheinen, da nur ein einzelnes JSON-Objekt erwartet wird. +//! Alle Module rufen ausschließlich diese Funktionen auf, niemals `logger_ctdra` +//! direkt, damit die Unterdrückung an genau einer Stelle greift. + +use std::sync::atomic::{AtomicBool, Ordering}; + +static JSON_MODE: AtomicBool = AtomicBool::new(false); + +/// Muss als allererste Logger-bezogene Aktion in main() aufgerufen werden. +pub fn set_json_mode(enabled: bool) { + JSON_MODE.store(enabled, Ordering::Relaxed); +} + +pub fn is_json_mode() -> bool { + JSON_MODE.load(Ordering::Relaxed) +} + +pub fn error(tag: &str, message: &str) { + if !is_json_mode() { + logger_ctdra::error(tag, message); + } +} + +pub fn warn(tag: &str, message: &str) { + if !is_json_mode() { + logger_ctdra::warn(tag, message); + } +} + +pub fn info(tag: &str, message: &str) { + if !is_json_mode() { + logger_ctdra::info(tag, message); + } +} + +pub fn debug(tag: &str, message: &str) { + if !is_json_mode() { + logger_ctdra::debug(tag, message); + } +} diff --git a/src/mac.rs b/src/mac.rs new file mode 100644 index 0000000..7e81c30 --- /dev/null +++ b/src/mac.rs @@ -0,0 +1,77 @@ +//! Repräsentation und Parsing von MAC-Adressen. + +use std::fmt; +use std::str::FromStr; + +/// Kanonische Darstellung einer MAC-Adresse (6 Bytes). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct MacAddress([u8; 6]); + +#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)] +pub enum MacAddressError { + #[error("ungültige MAC-Adresse: '{0}'")] + InvalidFormat(String), +} + +impl MacAddress { + /// Parst eine MAC-Adresse aus ':' oder '-' getrennter Hex-Notation (case-insensitive). + pub fn parse(input: &str) -> Result { + let err = || MacAddressError::InvalidFormat(input.to_string()); + + let normalized = input.trim(); + let separator = if normalized.contains(':') { + ':' + } else if normalized.contains('-') { + '-' + } else { + return Err(err()); + }; + + let parts: Vec<&str> = normalized.split(separator).collect(); + if parts.len() != 6 { + return Err(err()); + } + + let mut bytes = [0u8; 6]; + for (i, part) in parts.iter().enumerate() { + if part.len() != 2 { + return Err(err()); + } + bytes[i] = u8::from_str_radix(part, 16).map_err(|_| err())?; + } + + Ok(Self(bytes)) + } + + /// Kleinschreibung, Doppelpunkt-getrennt (Vergleichsform, wie `ip neigh` sie ausgibt). + pub fn to_lower_colon(&self) -> String { + self.0 + .iter() + .map(|b| format!("{b:02x}")) + .collect::>() + .join(":") + } + + /// Großschreibung, Doppelpunkt-getrennt (wie nmap sie ausgibt). + pub fn to_upper_colon(&self) -> String { + self.0 + .iter() + .map(|b| format!("{b:02X}")) + .collect::>() + .join(":") + } +} + +impl FromStr for MacAddress { + type Err = MacAddressError; + + fn from_str(s: &str) -> Result { + Self::parse(s) + } +} + +impl fmt::Display for MacAddress { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.to_lower_colon()) + } +} diff --git a/src/main.rs b/src/main.rs index 79004d9..adf8d6a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,13 +1,36 @@ -fn main() { - // TODO: Hauptlogik der Anwendung implementieren - println!("Hello, World!"); -} +use std::process::ExitCode; -#[cfg(test)] -mod tests { - #[test] - fn it_works() { - // TODO: Unit-Tests für die Anwendung schreiben - assert_eq!(2 + 2, 4); +use clap::Parser; + +use mac2ip::{cache, cli::Cli, config, log, output, resolver}; + +#[tokio::main] +async fn main() -> ExitCode { + let cli = Cli::parse(); + + // Muss vor jedem weiteren Logger-Aufruf geschehen, damit im --json-Modus + // wirklich keine einzige Log-Zeile ausgegeben wird. + log::set_json_mode(cli.json); + + config::init_config_path(cli.config.as_deref()); + let mut app_config = config::load_config(); + config::apply_cli_overrides(&mut app_config, &cli); + + match logger_ctdra::LogLevel::try_from(app_config.log_level.as_str()) { + Ok(level) => logger_ctdra::set_log_level(level), + Err(fallback) => logger_ctdra::set_log_level(fallback), + } + + 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 { + Ok(result) => { + output::print_success(&result, cli.json); + ExitCode::SUCCESS + } + Err(err) => { + output::print_failure(&cli.mac, &err, cli.json); + ExitCode::FAILURE + } } } diff --git a/src/network.rs b/src/network.rs new file mode 100644 index 0000000..920bcbf --- /dev/null +++ b/src/network.rs @@ -0,0 +1,178 @@ +//! Netzwerk-Interaktion: Auto-Erkennung lokaler Subnetze, `ip neigh`, Ping-Erreichbarkeit +//! und der nmap-Scan (Schritt 3). Bewusst getrennt in dünne, das System aufrufende +//! Exec-Funktionen und reine, unit-testbare Parse-Funktionen. + +use std::net::IpAddr; +use std::time::Duration; + +use crate::mac::MacAddress; + +/// Schnelle Liveness-Probe, absichtlich kein Konfigurationsfeld (kein Nutzer-Tuning nötig). +const PING_TIMEOUT_SECS: u32 = 1; + +// --------------------------------------------------------------------------- +// Exec-Funktionen (führen echte Subprozesse aus) +// --------------------------------------------------------------------------- + +pub async fn run_ip_neigh_show() -> std::io::Result { + let out = tokio::process::Command::new("ip") + .args(["neigh", "show"]) + .output() + .await?; + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) +} + +pub async fn run_ip_route_show_scope_link() -> std::io::Result { + let out = tokio::process::Command::new("ip") + .args(["-4", "route", "show", "scope", "link"]) + .output() + .await?; + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) +} + +pub async fn ping_check(ip: IpAddr) -> bool { + tokio::process::Command::new("ping") + .args([ + "-c", + "1", + "-W", + &PING_TIMEOUT_SECS.to_string(), + &ip.to_string(), + ]) + // stdout/stderr von `ping` sind für uns irrelevant (nur der Exit-Code zählt) und + // dürfen insbesondere im --json-Modus nicht in unseren eigenen Output durchsickern. + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .await + .map(|s| s.success()) + .unwrap_or(false) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SudoMode { + /// Bereits root - nmap wird ohne sudo-Prefix aufgerufen. + None, + /// sudo darf interaktiv nach einem Passwort fragen. + Interactive, + /// sudo darf NICHT interaktiv nach einem Passwort fragen (`sudo -n`). + NonInteractive, +} + +#[derive(Debug, thiserror::Error)] +pub enum NmapRunError { + #[error("sudo-Zugangsdaten nicht verfügbar (nicht-interaktiver Modus)")] + SudoUnavailable, + #[error("nmap-Scan hat das Zeitlimit überschritten")] + Timeout, + #[error("E/A-Fehler: {0}")] + Io(#[from] std::io::Error), +} + +pub async fn run_nmap_scan( + cidr: &str, + timeout_secs: u64, + mode: SudoMode, +) -> Result { + let mut cmd = match mode { + SudoMode::None => { + let mut c = tokio::process::Command::new("nmap"); + c.arg("-sn").arg("-n").arg(cidr); + c + } + SudoMode::Interactive => { + let mut c = tokio::process::Command::new("sudo"); + c.args(["nmap", "-sn", "-n", cidr]); + c + } + SudoMode::NonInteractive => { + let mut c = tokio::process::Command::new("sudo"); + c.args(["-n", "nmap", "-sn", "-n", cidr]); + c + } + }; + // Erzwingt englische Fehlermeldungen, damit die "password"-Erkennung unten + // unabhängig von der System-Locale funktioniert (z. B. "Passwort ist notwendig" + // auf einem deutschen System würde sonst nicht erkannt werden). + cmd.env("LC_ALL", "C").env("LANG", "C"); + + let out = tokio::time::timeout(Duration::from_secs(timeout_secs), cmd.output()) + .await + .map_err(|_| NmapRunError::Timeout)??; + + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + if stderr.to_lowercase().contains("password") { + return Err(NmapRunError::SudoUnavailable); + } + return Err(NmapRunError::Io(std::io::Error::other(stderr.into_owned()))); + } + + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) +} + +// --------------------------------------------------------------------------- +// Reine Parse-Funktionen (kein Subprozess, vollständig unit-testbar) +// --------------------------------------------------------------------------- + +/// Parst `ip neigh show`-Ausgabe, z. B. Zeilen wie +/// "192.168.1.5 dev eth0 lladdr aa:bb:cc:dd:ee:ff REACHABLE". +/// Gibt die IP zurück, sobald eine Zeile mit passender `lladdr`-MAC gefunden wird. +pub fn parse_ip_neigh_output(output: &str, mac: &MacAddress) -> Option { + let target = mac.to_lower_colon(); + for line in output.lines() { + let tokens: Vec<&str> = line.split_whitespace().collect(); + if tokens.is_empty() { + continue; + } + if let Some(pos) = tokens.iter().position(|&t| t == "lladdr") + && let Some(&found_mac) = tokens.get(pos + 1) + && found_mac.eq_ignore_ascii_case(&target) + && let Ok(ip) = tokens[0].parse() + { + return Some(ip); + } + } + 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. +pub fn parse_local_subnets(ip_route_output: &str) -> Vec { + ip_route_output + .lines() + .filter(|l| !l.trim_start().starts_with("default")) + .filter(|l| !l.split_whitespace().any(|t| t == "lo")) + .filter_map(|l| l.split_whitespace().next()) + .filter(|t| t.contains('/')) + .map(|s| s.to_string()) + .collect() +} + +/// Parst `nmap -sn`-Ausgabe: verknüpft "Nmap scan report for IP" mit der +/// nächstfolgenden "MAC Address: XX:XX:XX:XX:XX:XX (Vendor)"-Zeile, bevor ein neuer +/// Host-Block beginnt. +pub fn parse_nmap_output(output: &str, mac: &MacAddress) -> Option { + let target = mac.to_lower_colon(); + let mut current_ip: Option = None; + + for line in output.lines() { + if let Some(rest) = line.strip_prefix("Nmap scan report for ") { + // rest ist entweder "192.168.1.5" oder "hostname (192.168.1.5)" + let rest = rest.trim(); + current_ip = if let Some(open) = rest.rfind('(') { + rest[open + 1..].trim_end_matches(')').trim().parse().ok() + } else { + rest.parse().ok() + }; + } else if let Some(rest) = line.trim_start().strip_prefix("MAC Address: ") + && let Some(mac_token) = rest.split_whitespace().next() + && mac_token.eq_ignore_ascii_case(&target) + && let Some(ip) = current_ip + { + return Some(ip); + } + } + None +} diff --git a/src/output.rs b/src/output.rs new file mode 100644 index 0000000..522d960 --- /dev/null +++ b/src/output.rs @@ -0,0 +1,68 @@ +//! Human- und JSON-Ausgabe des Lookup-Ergebnisses. + +use serde::Serialize; + +use crate::error::Mac2IpError; +use crate::mac::MacAddress; +use crate::resolver::ResolveResult; + +#[derive(Serialize, Debug, PartialEq)] +pub struct SuccessJson { + pub status: &'static str, + pub mac: String, + pub ip: String, + pub source: &'static str, +} + +#[derive(Serialize, Debug, PartialEq)] +pub struct FailureJson { + pub status: &'static str, + pub mac: String, + pub error: String, +} + +pub fn success_json(result: &ResolveResult) -> SuccessJson { + SuccessJson { + status: "ok", + mac: result.mac.to_lower_colon(), + ip: result.ip.to_string(), + source: result.source.as_str(), + } +} + +pub fn failure_json(mac: &MacAddress, err: &Mac2IpError) -> FailureJson { + FailureJson { + status: "error", + mac: mac.to_lower_colon(), + error: err.to_string(), + } +} + +pub fn print_success(result: &ResolveResult, json: bool) { + if json { + println!( + "{}", + serde_json::to_string(&success_json(result)) + .expect("JSON-Serialisierung schlägt nicht fehl") + ); + } else { + println!( + "{} -> {} (Quelle: {})", + result.mac, + result.ip, + result.source.as_str() + ); + } +} + +pub fn print_failure(mac: &MacAddress, err: &Mac2IpError, json: bool) { + if json { + println!( + "{}", + serde_json::to_string(&failure_json(mac, err)) + .expect("JSON-Serialisierung schlägt nicht fehl") + ); + } else { + crate::log::error("mac2ip", &err.to_string()); + } +} diff --git a/src/resolver.rs b/src/resolver.rs new file mode 100644 index 0000000..2648753 --- /dev/null +++ b/src/resolver.rs @@ -0,0 +1,140 @@ +//! Orchestrierung des 3-Stufen-Algorithmus: Cache -> `ip neigh` -> `nmap`. + +use std::net::IpAddr; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::cache::{self, Cache}; +use crate::config::AppConfig; +use crate::error::Mac2IpError; +use crate::mac::MacAddress; +use crate::network::{self, NmapRunError, SudoMode}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MatchSource { + Cache, + Arp, + Nmap, +} + +impl MatchSource { + pub fn as_str(&self) -> &'static str { + match self { + Self::Cache => "cache", + Self::Arp => "arp", + Self::Nmap => "nmap", + } + } +} + +pub struct ResolveResult { + pub mac: MacAddress, + pub ip: IpAddr, + pub source: MatchSource, +} + +fn now_unix() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +pub async fn resolve( + mac: &MacAddress, + config: &AppConfig, + cache: Option<&Cache>, + json_mode: bool, +) -> Result { + let now = now_unix(); + + // Schritt 1: Cache + if let Some(cache) = cache + && let Some(entry) = cache.get(mac).await + && !cache::is_expired(entry.updated_at, now, config.cache_ttl_seconds) + && network::ping_check(entry.ip).await + { + crate::log::info( + "resolver", + &format!("Treffer im Cache für {mac}: {}", entry.ip), + ); + return Ok(ResolveResult { + mac: *mac, + ip: entry.ip, + source: MatchSource::Cache, + }); + } + + // Schritt 2: ip neigh + crate::log::debug( + "resolver", + "Cache-Treffer nicht verfügbar, prüfe 'ip neigh show'", + ); + if let Ok(output) = network::run_ip_neigh_show().await + && let Some(ip) = network::parse_ip_neigh_output(&output, mac) + && network::ping_check(ip).await + { + crate::log::info("resolver", &format!("Treffer via ip neigh für {mac}: {ip}")); + if let Some(cache) = cache { + cache.upsert(mac, ip, MatchSource::Arp.as_str(), now).await; + } + return Ok(ResolveResult { + mac: *mac, + ip, + source: MatchSource::Arp, + }); + } + + // Schritt 3: nmap + crate::log::debug("resolver", "Kein Treffer via ip neigh, starte nmap-Scan"); + let subnets = if !config.networks.is_empty() { + config.networks.clone() + } else { + network::run_ip_route_show_scope_link() + .await + .map(|o| network::parse_local_subnets(&o)) + .unwrap_or_default() + }; + + let sudo_mode = if sudo_ctdra::is_run_as_root() { + SudoMode::None + } else if json_mode { + SudoMode::NonInteractive + } else { + SudoMode::Interactive + }; + + for cidr in subnets { + match network::run_nmap_scan(&cidr, config.nmap_timeout_seconds, sudo_mode).await { + Ok(output) => { + if let Some(ip) = network::parse_nmap_output(&output, mac) + && network::ping_check(ip).await + { + crate::log::info("resolver", &format!("Treffer via nmap für {mac}: {ip}")); + if let Some(cache) = cache { + cache.upsert(mac, ip, MatchSource::Nmap.as_str(), now).await; + } + return Ok(ResolveResult { + mac: *mac, + ip, + source: MatchSource::Nmap, + }); + } + } + Err(NmapRunError::SudoUnavailable) => { + crate::log::warn( + "nmap", + "sudo -n fehlgeschlagen (keine Zugangsdaten) - Schritt 3 wird abgebrochen", + ); + break; // sudo-Session-Status ist global, ein Retry pro Subnetz bringt nichts + } + Err(e) => { + crate::log::warn("nmap", &format!("nmap-Scan für {cidr} fehlgeschlagen: {e}")); + continue; // Fehler pro Subnetz (z. B. Timeout) - nächstes Subnetz versuchen + } + } + } + + Err(Mac2IpError::NotFound { + mac: mac.to_string(), + }) +}