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:
+175
@@ -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
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user