//! 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, token: Option, body: Vec, } impl Request { fn text(&self, key: &str) -> Option { if !self.body.is_empty() { if let Ok(serde_json::Value::Object(map)) = serde_json::from_slice::(&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 { self.text(key).and_then(|v| v.parse().ok()) } fn u32(&self, key: &str) -> Option { self.text(key).and_then(|v| v.parse().ok()) } fn u16(&self, key: &str) -> Option { self.text(key).and_then(|v| v.parse().ok()) } } fn parse_request(stream: &mut TcpStream) -> std::io::Result { 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 { 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, 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( "", &format!(""), ); } 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(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, 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::>()), 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::(&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"); } }