v0.1.0: logo, i18n pt/en, CI com caches, testes e documentação
- Logo oficial (sabia.png) e conjunto de ícones do app desktop. - Interface em português e inglês: tradutor por observação do DOM, seletor de idioma e checagem de cobertura de tradução no CI. - CI/CD com caches (rust-cache, .deb do Tauri em cache) e pipeline de release multiplataforma (CLI em 5 alvos + app desktop via tauri-action), templates de issue/PR. - Suíte de testes de integração do sabia-core (scan e2e, handshake/crack 4-way e PMKID, pcap, trilha de auditoria) — 46 testes verdes. - Documentação completa pt/en: README, ROADMAP, SECURITY, CONTRIBUTING, CHANGELOG e docs/ (cli, interface, arquitetura), com enquadramento de uso autorizado. - Correções de clippy (-D warnings) e formatação em toda a árvore. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
df27974359
commit
791538c9d9
112 files changed
+15620
-494
No files matched your search
+835
-58
@@ -1,26 +1,116 @@
|
||||
//! Ponte única entre o núcleo e as duas interfaces (servidor local da CLI e
|
||||
//! o app desktop Tauri): ambos chamam exatamente os mesmos métodos, então
|
||||
//! qualquer coisa que funcione em um funciona idêntico no outro.
|
||||
//!
|
||||
//! Sessões de captura, jobs de quebra, exportação de handshakes e deauth
|
||||
//! ficam todos aqui — a CLI e a GUI não reimplementam nada.
|
||||
|
||||
use crate::audit::AuditLog;
|
||||
use crate::error::Result;
|
||||
use crate::iface::{self, Interface};
|
||||
use crate::netlink;
|
||||
use crate::scan::{AccessPoint, ScanTable};
|
||||
use crate::crack::{self, CrackReport};
|
||||
use crate::doctor::{self, DoctorReport};
|
||||
use crate::error::{Result, SabiaError};
|
||||
use crate::handshake::Handshake;
|
||||
use crate::iface::{self, Interface, Mode};
|
||||
use crate::netlink::{self, ChannelInfo};
|
||||
use crate::pcapreader::PcapReader;
|
||||
use crate::pcapwriter::PcapWriter;
|
||||
use crate::scan::{AccessPoint, Client, ScanSnapshot, ScanTable};
|
||||
use crate::update::{self, UpdateStatus};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ScanOptions {
|
||||
/// Canal fixo; sem ele, percorre os canais suportados.
|
||||
pub channel: Option<u32>,
|
||||
/// `2.4`, `5`, `6` ou `all` (usado quando `channel` é `None`).
|
||||
pub band: Option<String>,
|
||||
/// Tempo em cada canal ao pular.
|
||||
pub hop_ms: u64,
|
||||
/// Salva os quadros capturados neste `.pcap`.
|
||||
pub pcap_out: Option<PathBuf>,
|
||||
/// Encerra sozinho depois de N segundos.
|
||||
pub duration_secs: Option<u64>,
|
||||
}
|
||||
|
||||
impl Default for ScanOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
channel: None,
|
||||
band: None,
|
||||
hop_ms: 350,
|
||||
pcap_out: None,
|
||||
duration_secs: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SessionInfo {
|
||||
pub iface: String,
|
||||
pub running: bool,
|
||||
pub channel: Option<u32>,
|
||||
pub channels: Vec<u32>,
|
||||
pub frames: u64,
|
||||
pub aps: usize,
|
||||
pub clients: usize,
|
||||
pub handshakes: usize,
|
||||
pub started_unix: u64,
|
||||
pub pcap_out: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CrackJobInfo {
|
||||
pub id: String,
|
||||
pub iface: String,
|
||||
pub bssid: Option<String>,
|
||||
pub station: Option<String>,
|
||||
pub status: String,
|
||||
pub tried: u64,
|
||||
pub started_unix: u64,
|
||||
pub finished_unix: Option<u64>,
|
||||
pub report: Option<CrackReport>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ExportReport {
|
||||
pub path: String,
|
||||
pub frames: usize,
|
||||
pub handshakes: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DeauthReport {
|
||||
pub iface: String,
|
||||
pub bssid: String,
|
||||
pub station: String,
|
||||
pub reason: u16,
|
||||
pub frames_sent: usize,
|
||||
}
|
||||
|
||||
struct Session {
|
||||
table: Arc<Mutex<ScanTable>>,
|
||||
running: Arc<AtomicBool>,
|
||||
channel: Arc<Mutex<Option<u32>>>,
|
||||
channels: Vec<u32>,
|
||||
started_unix: u64,
|
||||
pcap_out: Option<PathBuf>,
|
||||
error: Arc<Mutex<Option<String>>>,
|
||||
handle: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
pub struct Engine {
|
||||
sessions: Mutex<HashMap<String, Session>>,
|
||||
jobs: Mutex<HashMap<String, Arc<Mutex<CrackJobInfo>>>>,
|
||||
job_seq: AtomicU64,
|
||||
audit_path: PathBuf,
|
||||
audit_lock: Arc<Mutex<()>>,
|
||||
}
|
||||
|
||||
impl Default for Engine {
|
||||
@@ -31,9 +121,16 @@ impl Default for Engine {
|
||||
|
||||
impl Engine {
|
||||
pub fn new() -> Self {
|
||||
Self::with_audit_path(PathBuf::from(".sabia-audit.log"))
|
||||
}
|
||||
|
||||
pub fn with_audit_path(path: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
sessions: Mutex::new(HashMap::new()),
|
||||
audit_path: PathBuf::from(".sabia-audit.log"),
|
||||
jobs: Mutex::new(HashMap::new()),
|
||||
job_seq: AtomicU64::new(0),
|
||||
audit_path: path.into(),
|
||||
audit_lock: Arc::new(Mutex::new(())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,91 +138,597 @@ impl Engine {
|
||||
env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
|
||||
pub fn audit_path(&self) -> &Path {
|
||||
&self.audit_path
|
||||
}
|
||||
|
||||
pub fn list_ifaces(&self) -> Result<Vec<Interface>> {
|
||||
iface::list_wireless()
|
||||
}
|
||||
|
||||
pub fn doctor(&self) -> DoctorReport {
|
||||
doctor::diagnose(self.version(), &self.audit_path)
|
||||
}
|
||||
|
||||
pub fn set_monitor(&self, name: &str, enable: bool) -> Result<()> {
|
||||
netlink::set_monitor_mode(name, enable)?;
|
||||
if let Ok(mut log) = AuditLog::open(&self.audit_path) {
|
||||
let _ = log.record("monitor_mode", name, &format!("{{\"enable\":{enable}}}"));
|
||||
iface::get(name)?;
|
||||
if !enable {
|
||||
self.stop_scan(name);
|
||||
}
|
||||
netlink::set_monitor_mode(name, enable)?;
|
||||
self.log(
|
||||
"monitor_mode",
|
||||
name,
|
||||
serde_json::json!({ "enable": enable }),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn start_scan(&self, name: &str) -> Result<()> {
|
||||
use crate::capture::RawCapture;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
pub fn channels(&self, name: &str) -> Result<Vec<ChannelInfo>> {
|
||||
netlink::channels(name)
|
||||
}
|
||||
|
||||
let mut sessions = self.sessions.lock().unwrap();
|
||||
if sessions.contains_key(name) {
|
||||
return Ok(());
|
||||
pub fn set_channel(&self, name: &str, channel: u32) -> Result<()> {
|
||||
netlink::set_channel(name, channel)?;
|
||||
if let Some(session) = self.sessions.lock().unwrap().get(name) {
|
||||
*session.channel.lock().unwrap() = Some(channel);
|
||||
}
|
||||
self.log(
|
||||
"set_channel",
|
||||
name,
|
||||
serde_json::json!({ "channel": channel }),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn start_scan(&self, name: &str) -> Result<()> {
|
||||
self.start_scan_with(name, ScanOptions::default())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn start_scan_with(&self, name: &str, options: ScanOptions) -> Result<()> {
|
||||
use crate::capture::RawCapture;
|
||||
|
||||
let info = iface::get(name)?;
|
||||
if info.mode == Mode::Managed {
|
||||
return Err(SabiaError::InvalidInput(format!(
|
||||
"{name} está em modo managed; ative o modo monitor antes de capturar"
|
||||
)));
|
||||
}
|
||||
|
||||
let cap = RawCapture::open(name)?;
|
||||
cap.set_read_timeout(Duration::from_millis(200))?;
|
||||
|
||||
let writer = match options.pcap_out.as_ref() {
|
||||
Some(path) => Some(PcapWriter::create(path)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let channels = match options.channel {
|
||||
Some(channel) => vec![channel],
|
||||
None => netlink::channel_plan(name, options.band.as_deref()).unwrap_or_default(),
|
||||
};
|
||||
let hop = Duration::from_millis(options.hop_ms.clamp(100, 5_000));
|
||||
|
||||
// Substitui uma sessão anterior (se houver) só depois que a nova
|
||||
// captura abriu, para não derrubar a antiga se algo falhar.
|
||||
self.stop_scan(name);
|
||||
|
||||
let table = Arc::new(Mutex::new(ScanTable::new()));
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
let channel = Arc::new(Mutex::new(None));
|
||||
let error = Arc::new(Mutex::new(None));
|
||||
let started_unix = unix_now();
|
||||
let pcap_out = options.pcap_out.clone();
|
||||
|
||||
let t = table.clone();
|
||||
let r = running.clone();
|
||||
let ch = channel.clone();
|
||||
let err_slot = error.clone();
|
||||
let iface_name = name.to_string();
|
||||
thread::spawn(move || {
|
||||
let cap = match RawCapture::open(&iface_name) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("falha ao abrir captura em {iface_name}: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = cap.set_read_timeout(Duration::from_millis(500));
|
||||
let channels = netlink::supported_channels(&iface_name).unwrap_or_default();
|
||||
let audit_path = self.audit_path.clone();
|
||||
let audit_lock = self.audit_lock.clone();
|
||||
let deadline = options
|
||||
.duration_secs
|
||||
.map(|secs| Instant::now() + Duration::from_secs(secs.max(1)));
|
||||
|
||||
let thread_channels = channels.clone();
|
||||
let handle = std::thread::spawn(move || {
|
||||
let channels = thread_channels;
|
||||
let mut writer = writer;
|
||||
let mut idx = 0usize;
|
||||
let mut last_hop = std::time::Instant::now();
|
||||
let mut last_hop = Instant::now();
|
||||
let mut buf = [0u8; 65535];
|
||||
while r.load(Ordering::SeqCst) {
|
||||
if !channels.is_empty() && last_hop.elapsed() > Duration::from_millis(300) {
|
||||
idx = (idx + 1) % channels.len();
|
||||
let _ = netlink::set_channel(&iface_name, channels[idx]);
|
||||
last_hop = std::time::Instant::now();
|
||||
|
||||
if !channels.is_empty() {
|
||||
let first = channels[0];
|
||||
if netlink::set_channel(&iface_name, first).is_ok() {
|
||||
*ch.lock().unwrap() = Some(first);
|
||||
}
|
||||
if let Ok(Some(n)) = cap.recv(&mut buf) {
|
||||
let mut table = t.lock().unwrap();
|
||||
table.ingest(&buf[..n]);
|
||||
}
|
||||
|
||||
while r.load(Ordering::SeqCst) {
|
||||
if deadline.is_some_and(|d| Instant::now() >= d) {
|
||||
break;
|
||||
}
|
||||
if channels.len() > 1 && last_hop.elapsed() >= hop {
|
||||
idx = (idx + 1) % channels.len();
|
||||
let next = channels[idx];
|
||||
if netlink::set_channel(&iface_name, next).is_ok() {
|
||||
*ch.lock().unwrap() = Some(next);
|
||||
}
|
||||
last_hop = Instant::now();
|
||||
}
|
||||
|
||||
match cap.recv(&mut buf) {
|
||||
Ok(Some(n)) => {
|
||||
let frame = &buf[..n];
|
||||
t.lock().unwrap().ingest(frame);
|
||||
if let Some(w) = writer.as_mut() {
|
||||
if let Err(e) = w.write_frame(frame) {
|
||||
*err_slot.lock().unwrap() = Some(e.to_string());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
*err_slot.lock().unwrap() = Some(e.to_string());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(w) = writer.as_mut() {
|
||||
let _ = w.flush();
|
||||
}
|
||||
r.store(false, Ordering::SeqCst);
|
||||
|
||||
let stats = t.lock().unwrap().stats();
|
||||
append_audit(
|
||||
&audit_path,
|
||||
&audit_lock,
|
||||
"scan_stop",
|
||||
&iface_name,
|
||||
&serde_json::json!({
|
||||
"aps": stats.aps,
|
||||
"clients": stats.clients,
|
||||
"handshakes": stats.handshakes,
|
||||
"frames": stats.frames,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
self.sessions.lock().unwrap().insert(
|
||||
name.to_string(),
|
||||
Session {
|
||||
table,
|
||||
running,
|
||||
channel,
|
||||
channels,
|
||||
started_unix,
|
||||
pcap_out,
|
||||
error,
|
||||
handle: Some(handle),
|
||||
},
|
||||
);
|
||||
|
||||
self.log(
|
||||
"scan_start",
|
||||
name,
|
||||
serde_json::json!({ "options": options }),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn start_scan_with(&self, _name: &str, _options: ScanOptions) -> Result<()> {
|
||||
Err(SabiaError::UnsupportedPlatform)
|
||||
}
|
||||
|
||||
pub fn stop_scan(&self, name: &str) {
|
||||
let handle = {
|
||||
let mut sessions = self.sessions.lock().unwrap();
|
||||
match sessions.get_mut(name) {
|
||||
Some(session) => {
|
||||
session.running.store(false, Ordering::SeqCst);
|
||||
session.handle.take()
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
};
|
||||
if let Some(handle) = handle {
|
||||
let _ = handle.join();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_sessions(&self) -> Vec<SessionInfo> {
|
||||
let sessions = self.sessions.lock().unwrap();
|
||||
let mut out: Vec<SessionInfo> = sessions
|
||||
.iter()
|
||||
.map(|(name, session)| {
|
||||
let stats = session.table.lock().unwrap().stats();
|
||||
SessionInfo {
|
||||
iface: name.clone(),
|
||||
running: session.running.load(Ordering::SeqCst),
|
||||
channel: *session.channel.lock().unwrap(),
|
||||
channels: session.channels.clone(),
|
||||
frames: stats.frames,
|
||||
aps: stats.aps,
|
||||
clients: stats.clients,
|
||||
handshakes: stats.handshakes,
|
||||
started_unix: session.started_unix,
|
||||
pcap_out: session.pcap_out.as_ref().map(|p| p.display().to_string()),
|
||||
error: session.error.lock().unwrap().clone(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| a.iface.cmp(&b.iface));
|
||||
out
|
||||
}
|
||||
|
||||
pub fn scan_snapshot(&self, name: &str) -> Result<ScanSnapshot> {
|
||||
let sessions = self.sessions.lock().unwrap();
|
||||
let session = sessions.get(name).ok_or_else(|| {
|
||||
SabiaError::InvalidInput(format!("nenhuma sessão de captura em {name}"))
|
||||
})?;
|
||||
let table = session.table.lock().unwrap();
|
||||
let snapshot = ScanSnapshot {
|
||||
iface: name.to_string(),
|
||||
running: session.running.load(Ordering::SeqCst),
|
||||
channel: *session.channel.lock().unwrap(),
|
||||
stats: table.stats(),
|
||||
access_points: table.access_points(),
|
||||
clients: table.clients(),
|
||||
handshakes: table.handshakes(),
|
||||
};
|
||||
drop(table);
|
||||
drop(sessions);
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
/// Compatibilidade com a API antiga: só os APs da sessão.
|
||||
pub fn scan_results(&self, name: &str) -> Vec<AccessPoint> {
|
||||
self.sessions
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(name)
|
||||
.map(|s| s.table.lock().unwrap().access_points())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn scan_clients(&self, name: &str) -> Vec<Client> {
|
||||
self.sessions
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(name)
|
||||
.map(|s| s.table.lock().unwrap().clients())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Analisa um `.pcap` offline e devolve o mesmo snapshot da captura ao
|
||||
/// vivo (sem sessão e sem tocar no rádio).
|
||||
pub fn analyze_pcap(&self, path: impl AsRef<Path>) -> Result<ScanSnapshot> {
|
||||
let path = path.as_ref();
|
||||
let mut reader = PcapReader::open(path)?;
|
||||
if !reader.supports_80211() {
|
||||
return Err(SabiaError::InvalidPcap(
|
||||
"link-type não é 802.11 nem radiotap".into(),
|
||||
));
|
||||
}
|
||||
let mut table = ScanTable::new();
|
||||
while let Some(frame) = reader.next_frame()? {
|
||||
table.ingest(&frame.data);
|
||||
}
|
||||
Ok(ScanSnapshot {
|
||||
iface: path.display().to_string(),
|
||||
running: false,
|
||||
channel: None,
|
||||
stats: table.stats(),
|
||||
access_points: table.access_points(),
|
||||
clients: table.clients(),
|
||||
handshakes: table.handshakes(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn handshakes(&self, name: Option<&str>) -> Vec<crate::handshake::HandshakeInfo> {
|
||||
let sessions = self.sessions.lock().unwrap();
|
||||
let mut out = Vec::new();
|
||||
for (iface_name, session) in sessions.iter() {
|
||||
if name.is_some_and(|want| want != iface_name) {
|
||||
continue;
|
||||
}
|
||||
out.extend(session.table.lock().unwrap().handshakes());
|
||||
}
|
||||
out.sort_by_key(|h| std::cmp::Reverse(h.last_seen_unix));
|
||||
out
|
||||
}
|
||||
|
||||
pub fn export_handshake(
|
||||
&self,
|
||||
name: &str,
|
||||
bssid: &str,
|
||||
station: &str,
|
||||
out: impl AsRef<Path>,
|
||||
) -> Result<ExportReport> {
|
||||
let hs = self
|
||||
.session_handshake(name, bssid, station)?
|
||||
.ok_or_else(|| SabiaError::InvalidInput("handshake não encontrado".into()))?;
|
||||
let frames = hs.raw_frames();
|
||||
if frames.is_empty() {
|
||||
return Err(SabiaError::InvalidInput(
|
||||
"handshake sem quadros crus (captura antiga)".into(),
|
||||
));
|
||||
}
|
||||
let path = out.as_ref();
|
||||
let mut writer = PcapWriter::create(path)?;
|
||||
for frame in &frames {
|
||||
writer.write_frame(frame)?;
|
||||
}
|
||||
writer.flush()?;
|
||||
self.log(
|
||||
"export_handshake",
|
||||
name,
|
||||
serde_json::json!({ "bssid": bssid, "station": station, "path": path, "frames": frames.len() }),
|
||||
);
|
||||
Ok(ExportReport {
|
||||
path: path.display().to_string(),
|
||||
frames: frames.len(),
|
||||
handshakes: 1,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn export_all_handshakes(&self, name: &str, out: impl AsRef<Path>) -> Result<ExportReport> {
|
||||
let entries = {
|
||||
let sessions = self.sessions.lock().unwrap();
|
||||
let session = sessions.get(name).ok_or_else(|| {
|
||||
SabiaError::InvalidInput(format!("nenhuma sessão de captura em {name}"))
|
||||
})?;
|
||||
let entries = session.table.lock().unwrap().handshake_entries();
|
||||
entries
|
||||
};
|
||||
let path = out.as_ref();
|
||||
let mut writer = PcapWriter::create(path)?;
|
||||
let mut frames = 0usize;
|
||||
let mut handshakes = 0usize;
|
||||
for hs in &entries {
|
||||
let raw = hs.raw_frames();
|
||||
if raw.is_empty() {
|
||||
continue;
|
||||
}
|
||||
for frame in &raw {
|
||||
writer.write_frame(frame)?;
|
||||
frames += 1;
|
||||
}
|
||||
handshakes += 1;
|
||||
}
|
||||
writer.flush()?;
|
||||
if handshakes == 0 {
|
||||
return Err(SabiaError::InvalidInput(
|
||||
"nenhum handshake com quadros crus para exportar".into(),
|
||||
));
|
||||
}
|
||||
self.log(
|
||||
"export_handshakes",
|
||||
name,
|
||||
serde_json::json!({ "path": path, "frames": frames, "handshakes": handshakes }),
|
||||
);
|
||||
Ok(ExportReport {
|
||||
path: path.display().to_string(),
|
||||
frames,
|
||||
handshakes,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn start_crack(
|
||||
&self,
|
||||
name: &str,
|
||||
bssid: Option<&str>,
|
||||
station: Option<&str>,
|
||||
wordlist: impl AsRef<Path>,
|
||||
) -> Result<String> {
|
||||
let wordlist = wordlist.as_ref();
|
||||
if !wordlist.is_file() {
|
||||
return Err(SabiaError::InvalidInput(format!(
|
||||
"dicionário não encontrado: {}",
|
||||
wordlist.display()
|
||||
)));
|
||||
}
|
||||
let table = {
|
||||
let sessions = self.sessions.lock().unwrap();
|
||||
sessions.get(name).map(|s| s.table.clone()).ok_or_else(|| {
|
||||
SabiaError::InvalidInput(format!("nenhuma sessão de captura em {name}"))
|
||||
})?
|
||||
};
|
||||
let hs = {
|
||||
let table = table.lock().unwrap();
|
||||
match (bssid, station) {
|
||||
(Some(b), Some(s)) => table.handshake(b, s).filter(|h| h.is_crackable()).cloned(),
|
||||
(Some(b), None) => table.first_crackable(Some(b), None).cloned(),
|
||||
(None, Some(s)) => table.first_crackable(None, Some(s)).cloned(),
|
||||
(None, None) => table.first_crackable(None, None).cloned(),
|
||||
}
|
||||
}
|
||||
.ok_or_else(|| {
|
||||
SabiaError::InvalidInput("nenhum handshake quebrável para os filtros informados".into())
|
||||
})?;
|
||||
|
||||
let id = format!("crack-{}", self.job_seq.fetch_add(1, Ordering::SeqCst) + 1);
|
||||
let job = Arc::new(Mutex::new(CrackJobInfo {
|
||||
id: id.clone(),
|
||||
iface: name.to_string(),
|
||||
bssid: Some(hs.bssid.clone()),
|
||||
station: Some(hs.station.clone()),
|
||||
status: "running".into(),
|
||||
tried: 0,
|
||||
started_unix: unix_now(),
|
||||
finished_unix: None,
|
||||
report: None,
|
||||
error: None,
|
||||
}));
|
||||
self.jobs.lock().unwrap().insert(id.clone(), job.clone());
|
||||
|
||||
let audit_path = self.audit_path.clone();
|
||||
let audit_lock = self.audit_lock.clone();
|
||||
let iface_name = name.to_string();
|
||||
let wordlist_path = wordlist.to_path_buf();
|
||||
let job_thread = job.clone();
|
||||
let hs_bssid = hs.bssid.clone();
|
||||
let hs_station = hs.station.clone();
|
||||
let thread_wordlist = wordlist_path.clone();
|
||||
let thread_bssid = hs_bssid.clone();
|
||||
let thread_station = hs_station.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let progress_job = job_thread.clone();
|
||||
let result = crack::crack_handshake(&hs, &thread_wordlist, move |tried| {
|
||||
if let Ok(mut j) = progress_job.lock() {
|
||||
j.tried = tried;
|
||||
}
|
||||
});
|
||||
let mut guard = job_thread.lock().unwrap();
|
||||
guard.finished_unix = Some(unix_now());
|
||||
match result {
|
||||
Ok(report) => {
|
||||
guard.status = "done".into();
|
||||
guard.tried = report.tried;
|
||||
if let Some(password) = report.password.as_deref() {
|
||||
table.lock().unwrap().set_password(
|
||||
&thread_bssid,
|
||||
&thread_station,
|
||||
password,
|
||||
);
|
||||
append_audit(
|
||||
&audit_path,
|
||||
&audit_lock,
|
||||
"crack_success",
|
||||
&iface_name,
|
||||
&serde_json::json!({
|
||||
"bssid": thread_bssid,
|
||||
"station": thread_station,
|
||||
"tried": report.tried,
|
||||
"elapsed_ms": report.elapsed_ms,
|
||||
}),
|
||||
);
|
||||
}
|
||||
guard.report = Some(report);
|
||||
}
|
||||
Err(e) => {
|
||||
guard.status = "error".into();
|
||||
guard.error = Some(e.to_string());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if let Ok(mut log) = AuditLog::open(&self.audit_path) {
|
||||
let _ = log.record("scan_start", name, "{}");
|
||||
self.log(
|
||||
"crack_start",
|
||||
name,
|
||||
serde_json::json!({ "job": id, "bssid": hs_bssid, "station": hs_station, "wordlist": wordlist_path }),
|
||||
);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub fn crack_job(&self, id: &str) -> Option<CrackJobInfo> {
|
||||
self.jobs
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(id)
|
||||
.map(|j| j.lock().unwrap().clone())
|
||||
}
|
||||
|
||||
pub fn crack_jobs(&self) -> Vec<CrackJobInfo> {
|
||||
let jobs = self.jobs.lock().unwrap();
|
||||
let mut out: Vec<_> = jobs.values().map(|j| j.lock().unwrap().clone()).collect();
|
||||
out.sort_by(|a, b| {
|
||||
b.started_unix
|
||||
.cmp(&a.started_unix)
|
||||
.then_with(|| a.id.cmp(&b.id))
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn deauth(
|
||||
&self,
|
||||
name: &str,
|
||||
bssid: &str,
|
||||
station: Option<&str>,
|
||||
count: u32,
|
||||
reason: u16,
|
||||
) -> Result<DeauthReport> {
|
||||
use crate::inject::{self, RawInjector};
|
||||
|
||||
if !(1..=256).contains(&count) {
|
||||
return Err(SabiaError::InvalidInput(
|
||||
"quantidade de quadros deve ficar entre 1 e 256".into(),
|
||||
));
|
||||
}
|
||||
sessions.insert(name.to_string(), Session { table, running });
|
||||
Ok(())
|
||||
let info = iface::get(name)?;
|
||||
if info.mode == Mode::Managed {
|
||||
return Err(SabiaError::InvalidInput(format!(
|
||||
"{name} está em modo managed; ative o modo monitor antes de injetar"
|
||||
)));
|
||||
}
|
||||
let ap = parse_mac(bssid)?;
|
||||
let dest = match station {
|
||||
Some(s) => parse_mac(s)?,
|
||||
None => inject::BROADCAST,
|
||||
};
|
||||
let reason = inject::Reason::from_u16(reason);
|
||||
let frame = inject::deauth_frame(ap, dest, reason);
|
||||
let injector = RawInjector::open(name)?;
|
||||
|
||||
let mut frames_sent = 0usize;
|
||||
for _ in 0..count {
|
||||
injector.send(&frame)?;
|
||||
frames_sent += 1;
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
|
||||
let report = DeauthReport {
|
||||
iface: name.to_string(),
|
||||
bssid: bssid.to_string(),
|
||||
station: station.unwrap_or("ff:ff:ff:ff:ff:ff").to_string(),
|
||||
reason,
|
||||
frames_sent,
|
||||
};
|
||||
self.log(
|
||||
"deauth",
|
||||
name,
|
||||
serde_json::json!({
|
||||
"bssid": bssid,
|
||||
"station": report.station,
|
||||
"reason": reason,
|
||||
"frames": frames_sent,
|
||||
}),
|
||||
);
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Captura crua (AF_PACKET) só existe no Linux (ver `crate::capture`).
|
||||
/// Em macOS/Windows o app compila e roda, mas a captura ainda não tem
|
||||
/// backend nativo — próximo passo natural do projeto.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn start_scan(&self, _name: &str) -> Result<()> {
|
||||
Err(crate::error::SabiaError::UnsupportedPlatform)
|
||||
pub fn deauth(
|
||||
&self,
|
||||
_name: &str,
|
||||
_bssid: &str,
|
||||
_station: Option<&str>,
|
||||
_count: u32,
|
||||
_reason: u16,
|
||||
) -> Result<DeauthReport> {
|
||||
Err(SabiaError::UnsupportedPlatform)
|
||||
}
|
||||
|
||||
pub fn stop_scan(&self, name: &str) {
|
||||
let mut sessions = self.sessions.lock().unwrap();
|
||||
if let Some(s) = sessions.remove(name) {
|
||||
s.running.store(false, Ordering::SeqCst);
|
||||
if let Ok(mut log) = AuditLog::open(&self.audit_path) {
|
||||
let _ = log.record("scan_stop", name, "{}");
|
||||
}
|
||||
pub fn audit_verify(&self) -> Result<Option<usize>> {
|
||||
if !self.audit_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
AuditLog::verify(&self.audit_path)
|
||||
}
|
||||
|
||||
pub fn scan_results(&self, name: &str) -> Vec<AccessPoint> {
|
||||
let sessions = self.sessions.lock().unwrap();
|
||||
match sessions.get(name) {
|
||||
Some(s) => s.table.lock().unwrap().access_points(),
|
||||
None => Vec::new(),
|
||||
pub fn audit_entries(&self, limit: Option<usize>) -> Result<Vec<crate::audit::AuditEntry>> {
|
||||
if !self.audit_path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
AuditLog::read_entries(&self.audit_path, limit)
|
||||
}
|
||||
|
||||
pub fn update_check(&self) -> Result<UpdateStatus> {
|
||||
@@ -136,4 +739,178 @@ impl Engine {
|
||||
let exe = std::env::current_exe()?;
|
||||
update::apply(&exe)
|
||||
}
|
||||
|
||||
fn session_handshake(
|
||||
&self,
|
||||
name: &str,
|
||||
bssid: &str,
|
||||
station: &str,
|
||||
) -> Result<Option<Handshake>> {
|
||||
let sessions = self.sessions.lock().unwrap();
|
||||
let session = sessions.get(name).ok_or_else(|| {
|
||||
SabiaError::InvalidInput(format!("nenhuma sessão de captura em {name}"))
|
||||
})?;
|
||||
let table = session.table.lock().unwrap();
|
||||
Ok(table.handshake(bssid, station).cloned())
|
||||
}
|
||||
|
||||
fn log(&self, action: &str, target: &str, details: serde_json::Value) {
|
||||
append_audit(&self.audit_path, &self.audit_lock, action, target, &details);
|
||||
}
|
||||
}
|
||||
|
||||
fn append_audit(
|
||||
path: &Path,
|
||||
lock: &Arc<Mutex<()>>,
|
||||
action: &str,
|
||||
target: &str,
|
||||
details: &serde_json::Value,
|
||||
) {
|
||||
let _guard = lock.lock().unwrap();
|
||||
let Ok(mut log) = AuditLog::open(path) else {
|
||||
return;
|
||||
};
|
||||
let text = details.to_string();
|
||||
let _ = log.record(action, target, &text);
|
||||
}
|
||||
|
||||
fn parse_mac(text: &str) -> Result<[u8; 6]> {
|
||||
crate::dot11::parse_mac(text)
|
||||
.ok_or_else(|| SabiaError::InvalidInput(format!("MAC inválido: {text}")))
|
||||
}
|
||||
|
||||
fn unix_now() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::radiotap::Band;
|
||||
use crate::testutil::{
|
||||
ap, beacon_frame, frame_with_eapol, handshake_frames, radiotap_wrap, sta,
|
||||
};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn seed_session(engine: &Engine, name: &str) -> Arc<Mutex<ScanTable>> {
|
||||
let mut table = ScanTable::new();
|
||||
let beacon = radiotap_wrap(&beacon_frame(ap(), "RedeWPA", 6, &[2]), 2437, -40);
|
||||
table.ingest(&beacon);
|
||||
let (m1, m2) = handshake_frames("RedeWPA", "password", 2, false);
|
||||
let f1 = frame_with_eapol(true, ap(), sta(), [0x33; 6], &m1);
|
||||
let f2 = frame_with_eapol(false, ap(), sta(), [0x33; 6], &m2);
|
||||
table.ingest(&radiotap_wrap(&f1, 2437, -40));
|
||||
table.ingest(&radiotap_wrap(&f2, 2437, -41));
|
||||
assert_eq!(table.stats().handshakes, 1);
|
||||
|
||||
let table = Arc::new(Mutex::new(table));
|
||||
engine.sessions.lock().unwrap().insert(
|
||||
name.to_string(),
|
||||
Session {
|
||||
table: table.clone(),
|
||||
running: Arc::new(AtomicBool::new(false)),
|
||||
channel: Arc::new(Mutex::new(Some(6))),
|
||||
channels: vec![6],
|
||||
started_unix: 1,
|
||||
pcap_out: None,
|
||||
error: Arc::new(Mutex::new(None)),
|
||||
handle: None,
|
||||
},
|
||||
);
|
||||
table
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyzes_pcap_and_snapshot() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let pcap = dir.path().join("capture.pcap");
|
||||
let engine = Engine::with_audit_path(dir.path().join("audit.log"));
|
||||
let table = seed_session(&engine, "wlan-test");
|
||||
let hs = {
|
||||
let table = table.lock().unwrap();
|
||||
table
|
||||
.handshake("00:11:22:33:44:55", "66:77:88:99:aa:bb")
|
||||
.cloned()
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
let mut writer = PcapWriter::create(&pcap).unwrap();
|
||||
let beacon = radiotap_wrap(&beacon_frame(ap(), "RedeWPA", 6, &[2]), 2437, -40);
|
||||
writer.write_frame(&beacon).unwrap();
|
||||
for frame in hs.raw_frames() {
|
||||
writer.write_frame(&frame).unwrap();
|
||||
}
|
||||
writer.flush().unwrap();
|
||||
|
||||
let snapshot = engine.analyze_pcap(&pcap).unwrap();
|
||||
assert_eq!(snapshot.access_points.len(), 1);
|
||||
assert_eq!(snapshot.access_points[0].ssid.as_deref(), Some("RedeWPA"));
|
||||
assert_eq!(snapshot.access_points[0].band, Some(Band::B2G4));
|
||||
assert_eq!(snapshot.handshakes.len(), 1);
|
||||
assert!(snapshot.handshakes[0].crackable);
|
||||
assert_eq!(snapshot.stats.frames, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_handshake_and_cracks_it() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let engine = Engine::with_audit_path(dir.path().join("audit.log"));
|
||||
seed_session(&engine, "wlan-test");
|
||||
|
||||
let export = dir.path().join("hs.pcap");
|
||||
let report = engine
|
||||
.export_handshake(
|
||||
"wlan-test",
|
||||
"00:11:22:33:44:55",
|
||||
"66:77:88:99:aa:bb",
|
||||
&export,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(report.frames, 2);
|
||||
assert!(export.is_file());
|
||||
|
||||
let wordlist = dir.path().join("words.txt");
|
||||
std::fs::write(&wordlist, "errada\noutra\npassword\nnunca\n").unwrap();
|
||||
let id = engine
|
||||
.start_crack(
|
||||
"wlan-test",
|
||||
Some("00:11:22:33:44:55"),
|
||||
Some("66:77:88:99:aa:bb"),
|
||||
&wordlist,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let job = engine.crack_job(&id).unwrap();
|
||||
if job.status != "running" {
|
||||
assert_eq!(job.status, "done");
|
||||
assert_eq!(
|
||||
job.report.as_ref().and_then(|r| r.password.as_deref()),
|
||||
Some("password")
|
||||
);
|
||||
break;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "job não terminou");
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
|
||||
let hs = engine.handshakes(Some("wlan-test"));
|
||||
assert_eq!(hs[0].password.as_deref(), Some("password"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_are_clear_for_missing_inputs() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let engine = Engine::with_audit_path(dir.path().join("audit.log"));
|
||||
assert!(engine.scan_snapshot("nope").is_err());
|
||||
assert!(engine
|
||||
.start_crack("nope", None, None, dir.path().join("nada.txt"))
|
||||
.is_err());
|
||||
assert!(engine.crack_job("nope").is_none());
|
||||
assert!(engine.audit_entries(Some(10)).unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user