Files
sabia/crates/sabia-cli/src/web/mod.rs
T
Lucas RayanandClaude Sonnet 5 55e19a8e5b
CI / Format and clippy (push) Successful in 1m13s
CI / Dependency audit (push) Successful in 13s
CI / Tests (Linux) (push) Successful in 1m35s
Release / Create release (push) Successful in 7s
CI / Check desktop app (Linux) (push) Successful in 3m19s
Release / CLI (linux-aarch64) (push) Successful in 1m3s
Release / CLI (linux-x86_64) (push) Successful in 1m28s
Release / Desktop app (linux-aarch64) (push) Successful in 7m16s
Release / Desktop app (linux-x86_64) (push) Successful in 10m17s
Release / Desktop app (windows) (push) Successful in 4m54s
chore(i18n): finish translating code comments and scripts to English
Completes the English-only standardization for code, comments, scripts
and CI templates (UI/CLI-facing strings stay bilingual pt-BR/en,
untouched). Covers the remaining crates/, apps/gui/ui/ and
apps/gui/src-tauri/ comments, .gitea/ workflow and template comments,
.githooks/ hooks, and the scripts/ source comments for the files
already renamed in a prior commit (check.sh, generate-logo.py,
install-hooks.sh).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
2026-10-30 20:44:39 -03:00

847 lines
30 KiB
Rust

//! Local web UI server: serves the single-page HTML/JS app on
//! `127.0.0.1`, protected by a per-session random token, communicating with
//! the core over JSON endpoints. The exact same frontend in `apps/gui/ui`
//! powers the Tauri desktop app.
use sabia_core::engine::{Engine, ScanOptions};
use serde::Deserialize;
use std::collections::HashMap;
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::thread;
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!("../../../../logo.png");
const PAGED_JS: &str = include_str!("../../../../apps/gui/ui/vendor/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));
// Without a signal handler, Ctrl+C (or `SIGTERM`) during an active Evil Twin
// exits before cleanup runs: `hostapd`/`dnsmasq` are left orphaned
// and both interfaces remain stuck in `managed no` under
// NetworkManager until the next engine startup runs self-healing.
//
{
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}"));
}
for stream in listener.incoming() {
let Ok(stream) = stream else { continue };
let engine = engine.clone();
let token = token.clone();
thread::spawn(move || {
let _ = handle(stream, &engine, &token);
});
}
Ok(())
}
fn gen_token() -> String {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
format!("{nanos:x}")
}
#[cfg(target_os = "linux")]
fn open_browser(url: &str) -> std::io::Result<()> {
std::process::Command::new("xdg-open")
.arg(url)
.spawn()
.map(|_| ())
}
#[cfg(target_os = "macos")]
fn open_browser(url: &str) -> std::io::Result<()> {
std::process::Command::new("open")
.arg(url)
.spawn()
.map(|_| ())
}
#[cfg(windows)]
fn open_browser(url: &str) -> std::io::Result<()> {
std::process::Command::new("cmd")
.args(["/C", "start", "", url])
.spawn()
.map(|_| ())
}
struct Request {
method: String,
path: String,
query: HashMap<String, String>,
token: Option<String>,
body: Vec<u8>,
}
impl Request {
fn text(&self, key: &str) -> Option<String> {
if !self.body.is_empty() {
if let Ok(serde_json::Value::Object(map)) =
serde_json::from_slice::<serde_json::Value>(&self.body)
{
if let Some(value) = map.get(key) {
return match value {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Null => None,
other => Some(other.to_string()),
};
}
}
}
self.query.get(key).cloned()
}
fn bool(&self, key: &str) -> bool {
self.text(key)
.map(|v| v == "true" || v == "1" || v == "on")
.unwrap_or(false)
}
fn u64(&self, key: &str) -> Option<u64> {
self.text(key).and_then(|v| v.parse().ok())
}
fn u32(&self, key: &str) -> Option<u32> {
self.text(key).and_then(|v| v.parse().ok())
}
fn u16(&self, key: &str) -> Option<u16> {
self.text(key).and_then(|v| v.parse().ok())
}
}
fn parse_request(stream: &mut TcpStream) -> std::io::Result<Request> {
let mut reader = BufReader::new(stream.try_clone()?);
let mut line = String::new();
reader.read_line(&mut line)?;
let mut parts = line.split_whitespace();
let method = parts.next().unwrap_or("GET").to_string();
let full_path = parts.next().unwrap_or("/").to_string();
let mut content_length = 0usize;
let mut header_token = None;
loop {
let mut l = String::new();
if reader.read_line(&mut l)? == 0 || l == "\r\n" || l == "\n" {
break;
}
if let Some((name, value)) = l.split_once(':') {
let name = name.trim().to_ascii_lowercase();
let value = value.trim();
match name.as_str() {
"content-length" => content_length = value.parse().unwrap_or(0),
"x-sabia-token" => header_token = Some(value.to_string()),
"authorization" => {
header_token = value
.strip_prefix("Bearer ")
.map(|v| v.trim().to_string())
.or_else(|| Some(value.to_string()));
}
_ => {}
}
}
}
let (path, query_str) = full_path
.split_once('?')
.map(|(p, q)| (p.to_string(), q.to_string()))
.unwrap_or((full_path, String::new()));
let mut query = HashMap::new();
for pair in query_str.split('&') {
if let Some((k, v)) = pair.split_once('=') {
query.insert(urldecode(k), urldecode(v));
} else if !pair.is_empty() {
query.insert(urldecode(pair), String::new());
}
}
let token = query.get("token").cloned().or(header_token);
let mut body = vec![0u8; content_length.min(4 * 1024 * 1024)];
if !body.is_empty() {
reader.read_exact(&mut body)?;
}
Ok(Request {
method,
path,
query,
token,
body,
})
}
fn urldecode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'+' => out.push(b' '),
b'%' if i + 2 < bytes.len() => {
if let (Some(hi), Some(lo)) = (hexval(bytes[i + 1]), hexval(bytes[i + 2])) {
out.push((hi << 4) | lo);
i += 2;
} else {
out.push(b'%');
}
}
b => out.push(b),
}
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
fn hexval(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
fn handle(
mut stream: TcpStream,
engine: &Arc<Engine>,
expected_token: &str,
) -> std::io::Result<()> {
let req = parse_request(&mut stream)?;
if req.path == "/" {
if req.token.as_deref() != Some(expected_token) {
return respond(&mut stream, 401, "text/plain", b"token invalido");
}
return respond(
&mut stream,
200,
"text/html; charset=utf-8",
INDEX_HTML.as_bytes(),
);
}
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() {
"/vendor/paged.polyfill.min.js" | "/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(
&mut stream,
200,
"application/javascript",
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(),
)
}
"/logo.png" => return respond(&mut stream, 200, "image/png", LOGO_PNG),
_ => {}
}
if !req.path.starts_with("/api/") {
return respond(&mut stream, 404, "text/plain", b"not found");
}
if req.token.as_deref() != Some(expected_token) {
return respond(
&mut stream,
401,
"application/json",
b"{\"ok\":false,\"error\":\"token invalido\"}",
);
}
let (code, body) = api(engine, &req);
respond(&mut stream, code, "application/json", body.as_bytes())
}
fn respond(stream: &mut TcpStream, code: u16, ctype: &str, body: &[u8]) -> std::io::Result<()> {
let status = match code {
200 => "OK",
400 => "Bad Request",
401 => "Unauthorized",
404 => "Not Found",
500 => "Internal Server Error",
_ => "Error",
};
write!(
stream,
"HTTP/1.1 {code} {status}\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n",
body.len()
)?;
stream.write_all(body)?;
Ok(())
}
fn ok<T: serde::Serialize>(value: &T) -> (u16, String) {
match serde_json::to_string(value) {
Ok(body) => (200, body),
Err(e) => err(500, e.to_string()),
}
}
fn ok_json(value: serde_json::Value) -> (u16, String) {
(200, value.to_string())
}
fn err(code: u16, message: impl ToString) -> (u16, String) {
(
code,
serde_json::json!({ "ok": false, "error": message.to_string() }).to_string(),
)
}
#[derive(Deserialize)]
struct AnalyzeBody {
pcap: String,
}
fn api(engine: &Arc<Engine>, req: &Request) -> (u16, String) {
match (req.method.as_str(), req.path.as_str()) {
("GET", "/api/version") => ok_json(serde_json::json!({ "version": engine.version() })),
("GET", "/api/doctor") => ok(&engine.doctor()),
("GET", "/api/update/check") => match engine.update_check() {
Ok(status) => ok(&status),
Err(e) => err(400, e),
},
("POST", "/api/update/apply") => match engine.update_apply() {
Ok(version) => ok_json(serde_json::json!({ "ok": true, "version": version })),
Err(e) => err(400, e),
},
("GET", "/api/ifaces") => match engine.list_ifaces() {
Ok(list) => ok(&list),
Err(e) => err(400, e),
},
("GET", "/api/sessions") => ok(&engine.list_sessions()),
("GET", "/api/channels") => {
let Some(iface) = req.text("iface") else {
return err(400, "iface obrigatória");
};
match engine.channels(&iface) {
Ok(list) => ok(&list),
Err(e) => err(400, e),
}
}
("POST", "/api/monitor") => {
let Some(iface) = req.text("iface") else {
return err(400, "iface obrigatória");
};
match engine.set_monitor(&iface, req.bool("enable")) {
Ok(()) => ok_json(serde_json::json!({ "ok": true })),
Err(e) => err(400, e),
}
}
("POST", "/api/channel") => {
let (Some(iface), Some(channel)) = (req.text("iface"), req.u32("channel")) else {
return err(400, "iface e channel obrigatórios");
};
match engine.set_channel(&iface, channel) {
Ok(()) => ok_json(serde_json::json!({ "ok": true })),
Err(e) => err(400, e),
}
}
("POST", "/api/scan/start") => {
let Some(iface) = req.text("iface") else {
return err(400, "iface obrigatória");
};
let options = ScanOptions {
channel: req.u32("channel"),
band: req.text("band"),
hop_ms: req.u64("hop_ms").unwrap_or(350),
pcap_out: req.text("pcap_out").map(Into::into),
duration_secs: req.u64("seconds"),
};
match engine.start_scan_with(&iface, options) {
Ok(()) => ok_json(serde_json::json!({ "ok": true })),
Err(e) => err(400, e),
}
}
("POST", "/api/scan/stop") => {
let Some(iface) = req.text("iface") else {
return err(400, "iface obrigatória");
};
engine.stop_scan(&iface);
ok_json(serde_json::json!({ "ok": true }))
}
("GET", "/api/scan/snapshot") => {
let Some(iface) = req.text("iface") else {
return err(400, "iface obrigatória");
};
match engine.scan_snapshot(&iface) {
Ok(snapshot) => ok(&snapshot),
Err(e) => err(400, e),
}
}
("GET", "/api/scan") => {
let Some(iface) = req.text("iface") else {
return err(400, "iface obrigatória");
};
ok(&engine.scan_results(&iface))
}
("GET", "/api/scan/clients") => {
let Some(iface) = req.text("iface") else {
return err(400, "iface obrigatória");
};
ok(&engine.scan_clients(&iface))
}
("GET", "/api/handshakes") => ok(&engine.handshakes(req.text("iface").as_deref())),
("POST", "/api/handshakes/export") => {
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_handshake(&iface, &bssid, &station, &out) {
Ok(report) => ok(&report),
Err(e) => err(400, e),
}
}
("POST", "/api/handshakes/export-all") => {
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_handshakes(&iface, &out) {
Ok(report) => ok(&report),
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) = req.text("iface") else {
return err(400, "iface é obrigatória");
};
let bssid = req.text("bssid");
let station = req.text("station");
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),
}
}
("GET", "/api/crack") => {
let Some(id) = req.text("id") else {
return err(400, "id obrigatório");
};
match engine.crack_job(&id) {
Some(job) => ok(&job),
None => err(404, "job não encontrado"),
}
}
("GET", "/api/cracks") => ok(&engine.crack_jobs()),
("POST", "/api/deauth") => {
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 count = req.u32("count").unwrap_or(5);
let reason = req.u16("reason").unwrap_or(7);
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)
.ok()
.map(|b| b.pcap)
.or_else(|| req.text("pcap"))
} else {
req.text("pcap")
};
let Some(pcap) = pcap else {
return err(400, "pcap obrigatório");
};
match engine.analyze_pcap(&pcap) {
Ok(snapshot) => ok(&snapshot),
Err(e) => err(400, e),
}
}
("GET", "/api/audit/verify") => match engine.audit_verify() {
Ok(None) => ok_json(serde_json::json!({ "ok": true, "broken_at": null })),
Ok(Some(idx)) => ok_json(serde_json::json!({ "ok": false, "broken_at": idx })),
Err(e) => err(400, e),
},
("GET", "/api/audit") => {
let limit = req.u64("limit").map(|v| v as usize).or(Some(50));
match engine.audit_entries(limit) {
Ok(entries) => ok(&entries),
Err(e) => err(400, e),
}
}
_ => err(404, "rota desconhecida"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decodes_url_components() {
assert_eq!(urldecode("a%20b%2Fc+d"), "a b/c d");
assert_eq!(urldecode("100%25"), "100%");
assert_eq!(urldecode("caf%C3%A9"), "café");
assert_eq!(urldecode("bad%2"), "bad%2");
}
}