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
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
847 lines
30 KiB
Rust
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");
|
|
}
|
|
}
|