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.
This commit is contained in:
1 parent
7b39c67830
commit
a3ca73e610
5 files changed
+3478
-39
No files matched your search
@@ -72,6 +72,10 @@ enum Cmd {
|
||||
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,
|
||||
},
|
||||
@@ -275,6 +279,7 @@ fn run(cli: Cli) -> sabia_core::Result<()> {
|
||||
wordlist,
|
||||
bssid,
|
||||
station,
|
||||
rules,
|
||||
json,
|
||||
} => {
|
||||
let mut last = 0u64;
|
||||
@@ -283,6 +288,7 @@ fn run(cli: Cli) -> sabia_core::Result<()> {
|
||||
&wordlist,
|
||||
bssid.as_deref(),
|
||||
station.as_deref(),
|
||||
rules,
|
||||
|tried| {
|
||||
if tried.saturating_sub(last) >= 4096 {
|
||||
eprint!("\r{tried} senhas testadas…");
|
||||
@@ -412,7 +418,7 @@ fn crack_first(engine: &Engine, iface: &str, wordlist: &PathBuf) -> sabia_core::
|
||||
hs.bssid,
|
||||
wordlist.display()
|
||||
);
|
||||
let id = engine.start_crack(iface, Some(&hs.bssid), Some(&hs.station), wordlist)?;
|
||||
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" {
|
||||
|
||||
@@ -15,13 +15,29 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
const INDEX_HTML: &str = include_str!("../../../../apps/gui/ui/index.html");
|
||||
const APP_CSS: &str = include_str!("../../../../apps/gui/ui/app.css");
|
||||
const APP_JS: &str = include_str!("../../../../apps/gui/ui/app.js");
|
||||
const I18N_JS: &str = include_str!("../../../../apps/gui/ui/i18n.js");
|
||||
const I18N_EN_JS: &str = include_str!("../../../../apps/gui/ui/i18n-en.js");
|
||||
const LOGO_PNG: &[u8] = include_bytes!("../../../../apps/gui/ui/sabia.png");
|
||||
const PAGED_JS: &str = include_str!("../../../../apps/gui/ui/paged.polyfill.min.js");
|
||||
|
||||
pub fn serve(port: u16, audit_path: std::path::PathBuf, open: bool) -> sabia_core::Result<()> {
|
||||
let token = gen_token();
|
||||
let listener = TcpListener::bind(("127.0.0.1", port))?;
|
||||
let engine = Arc::new(Engine::with_audit_path(audit_path));
|
||||
|
||||
// Sem isso, Ctrl+C (ou um `kill` normal) durante um Evil Twin ativo
|
||||
// mata o processo sem rodar a limpeza: hostapd/dnsmasq ficam órfãos
|
||||
// e as duas interfaces ficam presas em `managed no` pro
|
||||
// NetworkManager — o painel de Wi-Fi do sistema some por completo até
|
||||
// o próximo início do engine se auto-curar.
|
||||
{
|
||||
let engine = engine.clone();
|
||||
let _ = ctrlc::set_handler(move || {
|
||||
engine.shutdown_cleanup();
|
||||
std::process::exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
println!("Sabiá UI em http://127.0.0.1:{port}/?token={token}");
|
||||
if open {
|
||||
let _ = open_browser(&format!("http://127.0.0.1:{port}/?token={token}"));
|
||||
@@ -225,7 +241,48 @@ fn handle(
|
||||
);
|
||||
}
|
||||
|
||||
if req.path == "/api/report.html" {
|
||||
if req.token.as_deref() != Some(expected_token) {
|
||||
return respond(&mut stream, 401, "text/plain", b"token invalido");
|
||||
}
|
||||
let Some(iface) = req.text("iface") else {
|
||||
return respond(&mut stream, 400, "text/plain", b"iface obrigatoria");
|
||||
};
|
||||
return match engine.report(&iface) {
|
||||
Ok(model) => {
|
||||
let download = req.bool("download");
|
||||
let mut html = sabia_core::report::render(
|
||||
&model,
|
||||
sabia_core::report::ReportOptions {
|
||||
inline_pagedjs: download,
|
||||
},
|
||||
);
|
||||
if download {
|
||||
html = html.replace(
|
||||
"<!--SABIA_PAGEDJS-->",
|
||||
&format!("<script>{PAGED_JS}</script>"),
|
||||
);
|
||||
}
|
||||
respond(
|
||||
&mut stream,
|
||||
200,
|
||||
"text/html; charset=utf-8",
|
||||
html.as_bytes(),
|
||||
)
|
||||
}
|
||||
Err(e) => respond(&mut stream, 400, "text/plain", e.to_string().as_bytes()),
|
||||
};
|
||||
}
|
||||
|
||||
match req.path.as_str() {
|
||||
"/paged.polyfill.min.js" => {
|
||||
return respond(
|
||||
&mut stream,
|
||||
200,
|
||||
"application/javascript",
|
||||
PAGED_JS.as_bytes(),
|
||||
)
|
||||
}
|
||||
"/app.css" => return respond(&mut stream, 200, "text/css", APP_CSS.as_bytes()),
|
||||
"/app.js" => {
|
||||
return respond(
|
||||
@@ -235,6 +292,22 @@ fn handle(
|
||||
APP_JS.as_bytes(),
|
||||
)
|
||||
}
|
||||
"/i18n.js" => {
|
||||
return respond(
|
||||
&mut stream,
|
||||
200,
|
||||
"application/javascript",
|
||||
I18N_JS.as_bytes(),
|
||||
)
|
||||
}
|
||||
"/i18n-en.js" => {
|
||||
return respond(
|
||||
&mut stream,
|
||||
200,
|
||||
"application/javascript",
|
||||
I18N_EN_JS.as_bytes(),
|
||||
)
|
||||
}
|
||||
"/sabia.png" => return respond(&mut stream, 200, "image/png", LOGO_PNG),
|
||||
_ => {}
|
||||
}
|
||||
@@ -408,13 +481,210 @@ fn api(engine: &Arc<Engine>, req: &Request) -> (u16, String) {
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/handshakes/export-hc22000") => {
|
||||
let (Some(iface), Some(bssid), Some(station), Some(out)) = (
|
||||
req.text("iface"),
|
||||
req.text("bssid"),
|
||||
req.text("station"),
|
||||
req.text("out"),
|
||||
) else {
|
||||
return err(400, "iface, bssid, station e out são obrigatórios");
|
||||
};
|
||||
match engine.export_hc22000(&iface, &bssid, &station, &out) {
|
||||
Ok(report) => ok(&report),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/handshakes/export-all-hc22000") => {
|
||||
let (Some(iface), Some(out)) = (req.text("iface"), req.text("out")) else {
|
||||
return err(400, "iface e out são obrigatórios");
|
||||
};
|
||||
match engine.export_all_hc22000(&iface, &out) {
|
||||
Ok(report) => ok(&report),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("GET", "/api/report") => {
|
||||
let Some(iface) = req.text("iface") else {
|
||||
return err(400, "iface obrigatória");
|
||||
};
|
||||
match engine.report(&iface) {
|
||||
Ok(model) => ok(&model),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("GET", "/api/wps/tools") => ok(&engine.wps_tools()),
|
||||
("POST", "/api/wps") => {
|
||||
if !req.bool("confirm") {
|
||||
return err(400, "confirme a autorização do teste (confirm=true)");
|
||||
}
|
||||
let (Some(iface), Some(bssid)) = (req.text("iface"), req.text("bssid")) else {
|
||||
return err(400, "iface e bssid são obrigatórios");
|
||||
};
|
||||
let tool = req.text("tool").unwrap_or_else(|| "reaver".into());
|
||||
let channel = req.u32("channel").unwrap_or(0);
|
||||
let pin = req.text("pin");
|
||||
match engine.start_wps(&iface, &bssid, channel, &tool, pin.as_deref()) {
|
||||
Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("GET", "/api/wps") => {
|
||||
let Some(id) = req.text("id") else {
|
||||
return err(400, "id obrigatório");
|
||||
};
|
||||
match engine.wps_job(&id) {
|
||||
Some(job) => ok(&job),
|
||||
None => err(404, "job não encontrado"),
|
||||
}
|
||||
}
|
||||
("GET", "/api/wps/jobs") => ok(&engine.wps_jobs()),
|
||||
("POST", "/api/wps/stop") => {
|
||||
let Some(id) = req.text("id") else {
|
||||
return err(400, "id obrigatório");
|
||||
};
|
||||
match engine.stop_wps(&id) {
|
||||
Ok(()) => ok_json(serde_json::json!({ "ok": true })),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/eviltwin") => {
|
||||
if !req.bool("confirm") {
|
||||
return err(400, "confirme a autorização do teste (confirm=true)");
|
||||
}
|
||||
let (Some(iface), Some(ap_iface), Some(bssid)) =
|
||||
(req.text("iface"), req.text("ap_iface"), req.text("bssid"))
|
||||
else {
|
||||
return err(400, "iface, ap_iface e bssid são obrigatórios");
|
||||
};
|
||||
let portal_port = req.u16("portal_port").unwrap_or(8080);
|
||||
let deauth = req.bool("deauth");
|
||||
match engine.start_evil_twin(&iface, &ap_iface, &bssid, portal_port, deauth) {
|
||||
Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("GET", "/api/eviltwin") => {
|
||||
let Some(id) = req.text("id") else {
|
||||
return err(400, "id obrigatório");
|
||||
};
|
||||
match engine.eviltwin_job(&id) {
|
||||
Some(job) => ok(&job),
|
||||
None => err(404, "job não encontrado"),
|
||||
}
|
||||
}
|
||||
("GET", "/api/eviltwins") => ok(&engine.eviltwin_jobs()),
|
||||
("POST", "/api/eviltwin/stop") => {
|
||||
let Some(id) = req.text("id") else {
|
||||
return err(400, "id obrigatório");
|
||||
};
|
||||
match engine.stop_evil_twin(&id) {
|
||||
Ok(()) => ok_json(serde_json::json!({ "ok": true })),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/eviltwin-enterprise") => {
|
||||
if !req.bool("confirm") {
|
||||
return err(400, "confirme a autorização do teste (confirm=true)");
|
||||
}
|
||||
let (Some(iface), Some(ap_iface), Some(bssid)) =
|
||||
(req.text("iface"), req.text("ap_iface"), req.text("bssid"))
|
||||
else {
|
||||
return err(400, "iface, ap_iface e bssid são obrigatórios");
|
||||
};
|
||||
match engine.start_evil_twin_enterprise(&iface, &ap_iface, &bssid) {
|
||||
Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("GET", "/api/eviltwin-enterprise") => {
|
||||
let Some(id) = req.text("id") else {
|
||||
return err(400, "id obrigatório");
|
||||
};
|
||||
match engine.enterprise_twin_job(&id) {
|
||||
Some(job) => ok(&job),
|
||||
None => err(404, "job não encontrado"),
|
||||
}
|
||||
}
|
||||
("GET", "/api/eviltwins-enterprise") => ok(&engine.enterprise_twin_jobs()),
|
||||
("POST", "/api/eviltwin-enterprise/stop") => {
|
||||
let Some(id) = req.text("id") else {
|
||||
return err(400, "id obrigatório");
|
||||
};
|
||||
match engine.stop_evil_twin_enterprise(&id) {
|
||||
Ok(()) => ok_json(serde_json::json!({ "ok": true })),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/pmkid") => {
|
||||
if !req.bool("confirm") {
|
||||
return err(400, "confirme a autorização do teste (confirm=true)");
|
||||
}
|
||||
let (Some(iface), Some(bssid)) = (req.text("iface"), req.text("bssid")) else {
|
||||
return err(400, "iface e bssid são obrigatórios");
|
||||
};
|
||||
let attempts = req.u32("attempts").unwrap_or(5);
|
||||
match engine.pmkid_hunt(&iface, &bssid, attempts) {
|
||||
Ok(report) => ok(&report),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/pmf") => {
|
||||
if !req.bool("confirm") {
|
||||
return err(400, "confirme a autorização do teste (confirm=true)");
|
||||
}
|
||||
let (Some(iface), Some(bssid), Some(station)) =
|
||||
(req.text("iface"), req.text("bssid"), req.text("station"))
|
||||
else {
|
||||
return err(400, "iface, bssid e station são obrigatórios");
|
||||
};
|
||||
let reason = req.u16("reason").unwrap_or(7);
|
||||
match engine.pmf_test(&iface, &bssid, &station, reason) {
|
||||
Ok(report) => ok(&report),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/crack") => {
|
||||
let (Some(iface), Some(wordlist)) = (req.text("iface"), req.text("wordlist")) else {
|
||||
return err(400, "iface e wordlist são obrigatórios");
|
||||
let Some(iface) = req.text("iface") else {
|
||||
return err(400, "iface é obrigatória");
|
||||
};
|
||||
let bssid = req.text("bssid");
|
||||
let station = req.text("station");
|
||||
match engine.start_crack(&iface, bssid.as_deref(), station.as_deref(), &wordlist) {
|
||||
let rules = req.bool("rules");
|
||||
let result = match req.text("wordlist") {
|
||||
Some(wordlist) if !wordlist.trim().is_empty() => engine.start_crack(
|
||||
&iface,
|
||||
bssid.as_deref(),
|
||||
station.as_deref(),
|
||||
&wordlist,
|
||||
rules,
|
||||
),
|
||||
_ => {
|
||||
engine.start_crack_default(&iface, bssid.as_deref(), station.as_deref(), rules)
|
||||
}
|
||||
};
|
||||
match result {
|
||||
Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/crack/mask") => {
|
||||
let Some(iface) = req.text("iface") else {
|
||||
return err(400, "iface é obrigatória");
|
||||
};
|
||||
let Some(mask) = req.text("mask").filter(|m| !m.trim().is_empty()) else {
|
||||
return err(400, "máscara é obrigatória");
|
||||
};
|
||||
let bssid = req.text("bssid");
|
||||
let station = req.text("station");
|
||||
let max_candidates = req.u64("max_candidates").unwrap_or(50_000_000);
|
||||
match engine.start_crack_mask(
|
||||
&iface,
|
||||
bssid.as_deref(),
|
||||
station.as_deref(),
|
||||
&mask,
|
||||
max_candidates,
|
||||
) {
|
||||
Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
@@ -441,17 +711,94 @@ fn api(engine: &Arc<Engine>, req: &Request) -> (u16, String) {
|
||||
};
|
||||
let count = req.u32("count").unwrap_or(5);
|
||||
let reason = req.u16("reason").unwrap_or(7);
|
||||
match engine.deauth(
|
||||
let direction = req.text("direction").unwrap_or_else(|| "ap".into());
|
||||
let frame_kind = req.text("frame").unwrap_or_else(|| "deauth".into());
|
||||
match engine.deauth_directional(
|
||||
&iface,
|
||||
&bssid,
|
||||
req.text("station").as_deref(),
|
||||
count,
|
||||
reason,
|
||||
&direction,
|
||||
&frame_kind,
|
||||
) {
|
||||
Ok(report) => ok(&report),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/deauth/start") => {
|
||||
if !req.bool("confirm") {
|
||||
return err(
|
||||
400,
|
||||
"confirme a autorização do teste (confirm=true) antes de desautenticar",
|
||||
);
|
||||
}
|
||||
let (Some(iface), Some(bssid)) = (req.text("iface"), req.text("bssid")) else {
|
||||
return err(400, "iface e bssid são obrigatórios");
|
||||
};
|
||||
let reason = req.u16("reason").unwrap_or(7);
|
||||
let direction = req.text("direction").unwrap_or_else(|| "both".into());
|
||||
let frame_kind = req.text("frame").unwrap_or_else(|| "deauth".into());
|
||||
let interval_ms = req.u64("interval_ms").unwrap_or(120);
|
||||
let max_secs = req.u64("max_secs");
|
||||
let stop_on_handshake = req.bool("stop_on_handshake");
|
||||
match engine.start_deauth_job(
|
||||
&iface,
|
||||
&bssid,
|
||||
req.text("station").as_deref(),
|
||||
reason,
|
||||
&direction,
|
||||
&frame_kind,
|
||||
interval_ms,
|
||||
max_secs,
|
||||
stop_on_handshake,
|
||||
) {
|
||||
Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/deauth/stop") => {
|
||||
let Some(id) = req.text("id") else {
|
||||
return err(400, "id obrigatório");
|
||||
};
|
||||
match engine.stop_deauth_job(&id) {
|
||||
Ok(()) => ok_json(serde_json::json!({ "ok": true })),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("GET", "/api/deauth/job") => {
|
||||
let Some(id) = req.text("id") else {
|
||||
return err(400, "id obrigatório");
|
||||
};
|
||||
match engine.deauth_job(&id) {
|
||||
Some(job) => ok(&job),
|
||||
None => err(404, "job não encontrado"),
|
||||
}
|
||||
}
|
||||
("GET", "/api/deauth/jobs") => {
|
||||
let jobs = engine.deauth_jobs();
|
||||
match req.text("iface") {
|
||||
Some(iface) => ok(&jobs
|
||||
.into_iter()
|
||||
.filter(|j| j.iface.eq_ignore_ascii_case(&iface))
|
||||
.collect::<Vec<_>>()),
|
||||
None => ok(&jobs),
|
||||
}
|
||||
}
|
||||
("POST", "/api/handshakes/capture") => {
|
||||
if !req.bool("confirm") {
|
||||
return err(400, "confirme a autorização do teste (confirm=true)");
|
||||
}
|
||||
let (Some(iface), Some(bssid)) = (req.text("iface"), req.text("bssid")) else {
|
||||
return err(400, "iface e bssid são obrigatórios");
|
||||
};
|
||||
let seconds = req.u64("seconds");
|
||||
match engine.capture_handshake(&iface, &bssid, req.text("station").as_deref(), seconds)
|
||||
{
|
||||
Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/analyze") => {
|
||||
let pcap = if !req.body.is_empty() {
|
||||
serde_json::from_slice::<AnalyzeBody>(&req.body)
|
||||
|
||||
Reference in new issue
Block a user