mod web; use clap::{Parser, Subcommand}; use sabia_core::engine::{Engine, ScanOptions}; use sabia_core::iface; use std::io::IsTerminal; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::Duration; #[derive(Parser)] #[command( name = "sabia", about = "Sabiá — auditoria e monitoramento de redes Wi-Fi", version )] struct Cli { /// Audit log file path (default: `.sabia-audit.log`). #[arg(long, global = true, default_value = ".sabia-audit.log")] audit: PathBuf, #[command(subcommand)] cmd: Cmd, } #[derive(Subcommand)] enum Cmd { /// List interfaces, channels, and toggle monitor mode. Iface { #[command(subcommand)] action: IfaceAction, }, /// Capture live traffic and display networks, clients, and handshakes. Scan { iface: String, /// Fixed channel; when omitted, hops across supported channels. #[arg(long)] channel: Option, /// Frequency band when hopping channels: `2.4`, `5`, `6`, or `all`. #[arg(long)] band: Option, /// Channel dwell time in milliseconds. #[arg(long, default_value_t = 350)] hop_ms: u64, /// Stop scanning after N seconds (default: run until Ctrl+C). #[arg(long)] seconds: Option, /// Save captured frames to a `.pcap` file. #[arg(long)] out: Option, /// After capturing, attempt a wordlist crack on the first handshake. #[arg(long, value_name = "DICIONARIO")] crack: Option, /// Output only the final JSON summary. #[arg(long)] json: bool, }, /// Analyze a `.pcap` file offline (networks, clients, and handshakes). Analyze { pcap: PathBuf, #[arg(long)] json: bool, }, /// Run a dictionary attack against a handshake in a `.pcap` file. Crack { pcap: PathBuf, /// Wordlist file (one candidate passphrase per line). #[arg(short, long)] wordlist: PathBuf, #[arg(long)] bssid: Option, #[arg(long)] station: Option, /// Apply common mutation rules to each word (capitalization, numeric /// suffixes, simple leetspeak) — multiplies total runtime. #[arg(long)] rules: bool, #[arg(long)] json: bool, }, /// Send deauthentication frames to an AP/station (authorized testing only). Deauth { iface: String, #[arg(long)] bssid: String, /// Target client MAC; defaults to broadcast when omitted. #[arg(long)] station: Option, #[arg(long, default_value_t = 5)] count: u32, #[arg(long, default_value_t = 7)] reason: u16, /// Confirm explicit authorization to test this target network. #[arg(long)] yes: bool, }, /// Diagnose privileges, external tools, and wireless interfaces. Doctor { #[arg(long)] json: bool, }, /// Inspect and verify the hash-chained audit trail. Audit { #[command(subcommand)] action: AuditAction, }, /// Check for and apply `sabia` CLI updates. Update { #[arg(long)] apply: bool, }, /// Launch the web UI (local HTTP server + browser). Ui { #[arg(long, default_value = "4337")] port: u16, /// Do not open the browser automatically. #[arg(long)] no_open: bool, }, } #[derive(Subcommand)] enum IfaceAction { List, /// List channels supported by the wireless phy. Channels { iface: String, #[arg(long)] json: bool, }, /// Enable or disable monitor mode on a Wi-Fi interface. Monitor { iface: String, #[arg(value_enum)] state: OnOff, }, } #[derive(Subcommand)] enum AuditAction { /// Verify the cryptographic hash chain integrity. Verify { path: Option }, /// Show the last N entries. Show { #[arg(long, default_value_t = 20)] limit: usize, }, } #[derive(Clone, Copy, clap::ValueEnum)] enum OnOff { On, Off, } fn main() { let cli = Cli::parse(); if let Err(e) = run(cli) { eprintln!("erro: {e}"); std::process::exit(1); } } fn run(cli: Cli) -> sabia_core::Result<()> { let audit_path = cli.audit.clone(); let engine = Engine::with_audit_path(&audit_path); match cli.cmd { Cmd::Iface { action } => match action { IfaceAction::List => { for i in iface::list_all()? { let tag = if i.phy.is_some() { "wifi" } else { "—" }; println!( "{:<16} {:<18} {:<6} {:<10} {}", i.name, i.mac, tag, format!("{:?}", i.mode).to_lowercase(), if i.up { "up" } else { "down" } ); } } IfaceAction::Channels { iface: name, json } => { let channels = engine.channels(&name)?; if json { println!("{}", serde_json::to_string_pretty(&channels)?); } else { for c in channels { println!( "canal {:<4} {:>5} MHz {:<6} {}{}", c.channel, c.freq_mhz, c.band.label(), if c.disabled { "desabilitado" } else { "ok" }, c.max_power_dbm .map(|p| format!(" até {p:.1} dBm")) .unwrap_or_default() ); } } } IfaceAction::Monitor { iface: name, state } => { let enable = matches!(state, OnOff::On); engine.set_monitor(&name, enable)?; println!( "{name}: modo monitor {}", if enable { "ativado" } else { "desativado" } ); } }, Cmd::Scan { iface: name, channel, band, hop_ms, seconds, out, crack, json, } => { let options = ScanOptions { channel, band, hop_ms, pcap_out: out, duration_secs: seconds, }; engine.start_scan_with(&name, options)?; let running = Arc::new(AtomicBool::new(true)); let r = running.clone(); let _ = ctrlc::set_handler(move || r.store(false, Ordering::SeqCst)); let interactive = std::io::stdout().is_terminal() && !json; while running.load(Ordering::SeqCst) { std::thread::sleep(Duration::from_secs(1)); let snapshot = match engine.scan_snapshot(&name) { Ok(s) => s, Err(_) => break, }; if !snapshot.running { break; } if interactive { print!("\x1b[2J\x1b[H"); print_snapshot(&snapshot); } } engine.stop_scan(&name); let snapshot = engine.scan_snapshot(&name)?; if json { println!("{}", serde_json::to_string_pretty(&snapshot)?); } else { if interactive { print!("\x1b[2J\x1b[H"); } print_snapshot(&snapshot); } if let Some(wordlist) = crack { crack_first(&engine, &name, &wordlist)?; } } Cmd::Analyze { pcap, json } => { let snapshot = engine.analyze_pcap(&pcap)?; if json { println!("{}", serde_json::to_string_pretty(&snapshot)?); } else { print_snapshot(&snapshot); } } Cmd::Crack { pcap, wordlist, bssid, station, rules, json, } => { let mut last = 0u64; let report = sabia_core::crack::crack_pcap( &pcap, &wordlist, bssid.as_deref(), station.as_deref(), rules, |tried| { if tried.saturating_sub(last) >= 4096 { eprint!("\r{tried} senhas testadas…"); last = tried; } }, )?; if !json { eprintln!(); } if json { println!("{}", serde_json::to_string_pretty(&report)?); } else if let Some(password) = &report.password { println!( "SENHA ENCONTRADA: {password}\n{} tentativas em {} ms ({})", report.tried, report.elapsed_ms, report.method.as_deref().unwrap_or("?") ); } else { println!( "senha não encontrada ({} tentativas em {} ms)", report.tried, report.elapsed_ms ); } } Cmd::Deauth { iface: name, bssid, station, count, reason, yes, } => { if !yes { return Err(sabia_core::SabiaError::InvalidInput( "desautenticação derruba clientes; confirme com --yes apenas em rede própria ou com autorização".into(), )); } let report = engine.deauth(&name, &bssid, station.as_deref(), count, reason)?; println!( "{} quadros enviados para {} via {} (motivo {})", report.frames_sent, report.station, report.iface, report.reason ); } Cmd::Doctor { json } => { let report = engine.doctor(); if json { println!("{}", serde_json::to_string_pretty(&report)?); } else { print_doctor(&report); } } Cmd::Audit { action } => match action { AuditAction::Verify { path } => { let path = path.unwrap_or_else(|| audit_path.clone()); if !path.exists() { println!( "{} não existe (nenhuma ação registrada ainda)", path.display() ); } else { match sabia_core::audit::AuditLog::verify(&path)? { None => println!("cadeia de auditoria íntegra ({})", path.display()), Some(idx) => { println!("cadeia ROMPIDA na entrada {idx} ({})", path.display()) } } } } AuditAction::Show { limit } => { let entries = sabia_core::audit::AuditLog::read_entries(&audit_path, Some(limit))?; if entries.is_empty() { println!("nenhuma entrada em {}", audit_path.display()); } for e in entries { println!( "{} {:<18} {:<18} {}", e.timestamp, e.action, e.target, e.detail ); } } }, Cmd::Update { apply } => { let current = env!("CARGO_PKG_VERSION"); if apply { let exe = std::env::current_exe()?; let version = sabia_core::update::apply(&exe)?; println!("atualizado para {version}"); } else { let status = sabia_core::update::check(current)?; if status.update_available { println!( "atualização disponível: {} -> {} (rode `sabia update --apply`)", status.current_version, status.latest_version.unwrap_or_default() ); } else { println!("já na última versão ({})", status.current_version); } } } Cmd::Ui { port, no_open } => { web::serve(port, audit_path, !no_open)?; } } Ok(()) } fn crack_first(engine: &Engine, iface: &str, wordlist: &PathBuf) -> sabia_core::Result<()> { let Some(hs) = engine .handshakes(Some(iface)) .into_iter() .find(|h| h.crackable) else { println!("\nnenhum handshake quebrável capturado"); return Ok(()); }; println!( "\nquebrando handshake de {} ({}) com {}…", hs.ssid.as_deref().unwrap_or("?"), hs.bssid, wordlist.display() ); let id = engine.start_crack(iface, Some(&hs.bssid), Some(&hs.station), wordlist, false)?; loop { let job = engine.crack_job(&id).expect("job"); if job.status == "running" { eprint!("\r{} senhas testadas…", job.tried); std::thread::sleep(Duration::from_millis(500)); continue; } eprintln!(); match job.report { Some(report) => match report.password { Some(password) => println!( "SENHA ENCONTRADA: {password}\n{} tentativas em {} ms", report.tried, report.elapsed_ms ), None => println!( "senha não encontrada ({} tentativas em {} ms)", report.tried, report.elapsed_ms ), }, None => println!("falha: {}", job.error.unwrap_or_default()), } break; } Ok(()) } fn print_snapshot(snapshot: &sabia_core::scan::ScanSnapshot) { println!( "{} {}{} {} quadros · {} APs · {} clientes · {} handshakes", snapshot.iface, if snapshot.running { "● " } else { "" }, snapshot .channel .map(|c| format!("canal {c}")) .unwrap_or_default(), snapshot.stats.frames, snapshot.stats.aps, snapshot.stats.clients, snapshot.stats.handshakes ); if !snapshot.access_points.is_empty() { println!( "\n{:<18} {:<24} {:<6} {:<8} {:<12} CLIENTES", "BSSID", "SSID", "CANAL", "SINAL", "SEGURANÇA" ); for ap in &snapshot.access_points { println!( "{:<18} {:<24} {:<6} {:<8} {:<12} {}", ap.bssid, ap.ssid.clone().unwrap_or_else(|| "".into()), ap.channel.map(|c| c.to_string()).unwrap_or_default(), ap.signal_dbm .map(|s| format!("{s} dBm")) .unwrap_or_default(), ap.security.clone().unwrap_or_default(), ap.clients.len() ); } } if !snapshot.handshakes.is_empty() { println!("\nHANDSHAKES"); for hs in &snapshot.handshakes { println!( "{:<18} {:<18} {:<16} msgs {:<8} {}{}", hs.bssid, hs.station, hs.ssid.clone().unwrap_or_default(), hs.messages, hs.kind, if hs.crackable { " · quebrável" } else { "" } ); } } } fn print_doctor(report: &sabia_core::doctor::DoctorReport) { println!("Sabiá {} ({})", report.version, report.platform); println!( "root: {} · iw: {} · ip: {} · curl: {} · captura: {}", yes_no(report.root), yes_no(report.iw), yes_no(report.ip), yes_no(report.curl), report.capture_backend ); println!( "auditoria: {} ({})", report.audit_path, if report.audit_ok { "íntegra" } else { "corrompida" } ); if !report.interfaces.is_empty() { println!("\nINTERFACES"); for i in &report.interfaces { println!( "{:<16} {:<18} {:<10} monitor: {:<3} driver: {}", i.name, i.mac, i.mode, yes_no(i.monitor_supported), i.driver.as_deref().unwrap_or("?") ); } } if !report.notes.is_empty() { println!("\nAVISOS"); for note in &report.notes { println!("- {note}"); } } } fn yes_no(value: bool) -> &'static str { if value { "sim" } else { "não" } }