mac2ip-Kernlogik implementieren: Cache -> ip neigh -> nmap

Implementiert den 3-stufigen Auflösungsalgorithmus für MAC-zu-IP-Lookups:

- mac.rs: MacAddress-Typ (Parsing/Kanonisierung, direkt als clap-Typ nutzbar)
- cache.rs: globaler, systemweiter Cache via turso (lokale Datei), TTL-Logik,
  best-effort Fehlerbehandlung (Cache-I/O darf den Lookup nie scheitern lassen)
- network.rs: ip neigh / ping / nmap, getrennt in dünne Exec-Funktionen und
  reine, testbare Parse-Funktionen
- resolver.rs: Orchestrierung der drei Stufen inkl. sudo/sudo -n-Entscheidung
  für den nmap-Schritt
- cli.rs/config.rs: clap-CLI + config-ctdra-Integration mit vollständiger
  CLI > ENV > Datei > Default-Overlay-Kette
- log.rs: JSON-Modus-bewusster logger-ctdra-Wrapper (unterdrückt jegliche
  Log-Ausgabe im --json-Modus an einer einzigen Stelle)
- output.rs: Human- und JSON-Ausgabe des Ergebnisses
- lib.rs: Modul-Wurzel, damit tests/*.rs als Integrationstests zugreifen
  können; main.rs wird dünner Einstiegspunkt

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0118Wbg95ADSDynYfci2qUjK
This commit is contained in:
2026-09-13 18:28:39 +02:00
co-authored by Claude Sonnet 5
parent e40cba3ecb
commit 1ec2b92c87
11 changed files with 910 additions and 10 deletions
+175
View File
@@ -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<CacheEntry> {
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<Cache> {
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)
}
+78
View File
@@ -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<PathBuf>,
/// Logging-Level (error, warn, info, debug)
#[arg(long, value_enum, env = "MAC2IP_LOG_LEVEL")]
pub log_level: Option<LogLevelArg>,
/// Cache-TTL in Sekunden (Standard: 1800)
#[arg(long, env = "MAC2IP_CACHE_TTL_SECONDS")]
pub cache_ttl_seconds: Option<u64>,
/// Pfad zur globalen Cache-Datenbankdatei (Standard: /var/lib/mac2ip/cache.db)
#[arg(long, env = "MAC2IP_CACHE_DB_PATH")]
pub cache_db_path: Option<PathBuf>,
/// Timeout in Sekunden für einen einzelnen nmap-Subnetz-Scan (Standard: 120)
#[arg(long, env = "MAC2IP_NMAP_TIMEOUT_SECONDS")]
pub nmap_timeout_seconds: Option<u64>,
/// Kommagetrennte Liste von CIDR-Subnetzen für den nmap-Scan (überschreibt Auto-Erkennung)
#[arg(long, env = "MAC2IP_NETWORKS", value_delimiter = ',')]
pub networks: Option<Vec<String>>,
}
#[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<LogLevelArg> 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,
}
}
}
+93
View File
@@ -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<String>,
}
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::<AppConfig>()
}
/// 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();
}
}
+13
View File
@@ -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),
}
+12
View File
@@ -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;
+43
View File
@@ -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);
}
}
+77
View File
@@ -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<Self, MacAddressError> {
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::<Vec<_>>()
.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::<Vec<_>>()
.join(":")
}
}
impl FromStr for MacAddress {
type Err = MacAddressError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
impl fmt::Display for MacAddress {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.to_lower_colon())
}
}
+33 -10
View File
@@ -1,13 +1,36 @@
fn main() { use std::process::ExitCode;
// TODO: Hauptlogik der Anwendung implementieren
println!("Hello, World!");
}
#[cfg(test)] use clap::Parser;
mod tests {
#[test] use mac2ip::{cache, cli::Cli, config, log, output, resolver};
fn it_works() {
// TODO: Unit-Tests für die Anwendung schreiben #[tokio::main]
assert_eq!(2 + 2, 4); 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
}
} }
} }
+178
View File
@@ -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<String> {
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<String> {
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<String, NmapRunError> {
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<IpAddr> {
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<String> {
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<IpAddr> {
let target = mac.to_lower_colon();
let mut current_ip: Option<IpAddr> = 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
}
+68
View File
@@ -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());
}
}
+140
View File
@@ -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<ResolveResult, Mac2IpError> {
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(),
})
}