Files
sabia/crates/sabia-cli/src/main.rs
T
Lucas Rayan a3ca73e610 feat: orquestra as novas funções no engine, CLI e app desktop; resiliência de rede
- engine.rs: expõe Evil Twin (PSK/Enterprise), crack com regras/máscara,
  relatório e exportação .hc22000 pros três front-ends (CLI, servidor web
  da UI, Tauri). set_monitor() agora solta a interface do NetworkManager
  antes de ativar modo monitor e devolve ao desativar — sem isso o NM
  reafirma a própria conexão por cima e a captura cai sozinha pouco
  depois ("está em modo managed"). Evil Twin grava um arquivo de estado
  em disco antes de soltar as interfaces; se o processo morrer no meio
  (Ctrl+C sem handler, kill -9, queda de energia), o próximo início do
  engine desfaz tudo sozinho (self_heal_eviltwin_leftovers) — sem isso as
  duas interfaces ficavam presas em managed=no pro NetworkManager pra
  sempre e o Wi-Fi inteiro sumia do painel do sistema. hostapd/hostapd-wpe
  que morrem na hora de subir agora reportam a saída real capturada em
  log, em vez de um processo "<defunct>" mudo. Novo
  ScanSnapshot.warning: avisa quando a sessão roda >12s sem receber
  nenhum quadro — sintoma de placa que aceita ativar modo monitor sem
  entregar captura de verdade (comum em Wi-Fi interno de notebook).
- sabia-cli (main.rs/web/mod.rs) e o app desktop (Tauri main.rs) ganham
  as rotas/comandos equivalentes pra tudo isso.
2026-10-24 14:54:35 -03:00

544 lines
17 KiB
Rust

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 {
/// Trilha de auditoria (padrão: .sabia-audit.log).
#[arg(long, global = true, default_value = ".sabia-audit.log")]
audit: PathBuf,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Lista interfaces, canais e controla o modo monitor.
Iface {
#[command(subcommand)]
action: IfaceAction,
},
/// Captura o ar e mostra redes, clientes e handshakes.
Scan {
iface: String,
/// Canal fixo; sem isso, percorre os canais suportados.
#[arg(long)]
channel: Option<u32>,
/// Banda ao percorrer canais: 2.4, 5, 6 ou all.
#[arg(long)]
band: Option<String>,
/// Tempo em cada canal (ms).
#[arg(long, default_value_t = 350)]
hop_ms: u64,
/// Para a varredura depois de N segundos (padrão: até Ctrl+C).
#[arg(long)]
seconds: Option<u64>,
/// Salva os quadros capturados em um .pcap.
#[arg(long)]
out: Option<PathBuf>,
/// Depois de capturar, tenta quebrar o primeiro handshake.
#[arg(long, value_name = "DICIONARIO")]
crack: Option<PathBuf>,
/// Mostra apenas o JSON final.
#[arg(long)]
json: bool,
},
/// Analisa um .pcap offline (redes, clientes e handshakes).
Analyze {
pcap: PathBuf,
#[arg(long)]
json: bool,
},
/// Quebra um handshake de um .pcap por dicionário.
Crack {
pcap: PathBuf,
/// Dicionário com uma senha por linha.
#[arg(short, long)]
wordlist: PathBuf,
#[arg(long)]
bssid: Option<String>,
#[arg(long)]
station: Option<String>,
/// Aplica mutações comuns a cada palavra (maiúscula, sufixos
/// numéricos, leet simples) — multiplica o tempo de verdade.
#[arg(long)]
rules: bool,
#[arg(long)]
json: bool,
},
/// Envia desautenticação para um AP/estação (teste autorizado).
Deauth {
iface: String,
#[arg(long)]
bssid: String,
/// Sem isso, o destino é broadcast.
#[arg(long)]
station: Option<String>,
#[arg(long, default_value_t = 5)]
count: u32,
#[arg(long, default_value_t = 7)]
reason: u16,
/// Confirma que você tem autorização para testar esta rede.
#[arg(long)]
yes: bool,
},
/// Diagnóstico de privilégios, ferramentas e interfaces.
Doctor {
#[arg(long)]
json: bool,
},
/// Inspeciona a trilha de auditoria.
Audit {
#[command(subcommand)]
action: AuditAction,
},
/// Verifica e aplica atualizações do próprio sabia.
Update {
#[arg(long)]
apply: bool,
},
/// Abre a interface gráfica (servidor local + navegador).
Ui {
#[arg(long, default_value = "4337")]
port: u16,
/// Não tenta abrir o navegador automaticamente.
#[arg(long)]
no_open: bool,
},
}
#[derive(Subcommand)]
enum IfaceAction {
List,
/// Mostra os canais suportados pelo rádio.
Channels {
iface: String,
#[arg(long)]
json: bool,
},
/// Liga/desliga modo monitor numa interface Wi-Fi.
Monitor {
iface: String,
#[arg(value_enum)]
state: OnOff,
},
}
#[derive(Subcommand)]
enum AuditAction {
/// Confere a integridade da cadeia.
Verify { path: Option<PathBuf> },
/// Mostra as últimas entradas.
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(|| "<oculta>".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"
}
}