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:
Lucas RayanandClaude Opus 5.5 committed 2026-10-23 12:46:19 -03:00
1 parent df27974359
commit 791538c9d9
112 files changed
+15620 -494

No files matched your search

+350 -77
View File
@@ -3,31 +3,32 @@
//! núcleo por uma API JSON simples. A mesma página em `apps/gui/ui` é o
//! que o app desktop (Tauri) carrega — só muda o transporte.
use sabia_core::engine::Engine;
use sabia_core::engine::{Engine, ScanOptions};
use serde::Deserialize;
use std::collections::HashMap;
use std::io::{BufRead, BufReader, Write};
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 LOGO_SVG: &str = include_str!("../../../../apps/gui/ui/sabia-logo.svg");
const LOGO_PNG: &[u8] = include_bytes!("../../../../apps/gui/ui/sabia.png");
pub fn serve(port: u16) -> sabia_core::Result<()> {
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::new());
let engine = Arc::new(Engine::with_audit_path(audit_path));
println!("Sabiá UI em http://127.0.0.1:{port}/?token={token}");
let _ = open_browser(&format!("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 stream = match stream {
Ok(s) => s,
Err(_) => continue,
};
let Ok(stream) = stream else { continue };
let engine = engine.clone();
let token = token.clone();
thread::spawn(move || {
@@ -38,36 +39,35 @@ pub fn serve(port: u16) -> sabia_core::Result<()> {
}
fn gen_token() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.unwrap_or_default()
.as_nanos();
format!("{nanos:x}")
}
#[cfg(target_os = "linux")]
fn open_browser(url: &str) -> std::io::Result<()> {
#[cfg(target_os = "linux")]
{
std::process::Command::new("xdg-open")
.arg(url)
.spawn()
.map(|_| ())
}
#[cfg(target_os = "macos")]
{
std::process::Command::new("open")
.arg(url)
.spawn()
.map(|_| ())
}
#[cfg(windows)]
{
std::process::Command::new("cmd")
.args(["/C", "start", "", url])
.spawn()
.map(|_| ())
}
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 {
@@ -75,6 +75,44 @@ struct Request {
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> {
@@ -85,37 +123,87 @@ fn parse_request(stream: &mut TcpStream) -> std::io::Result<Request> {
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" {
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) = match full_path.split_once('?') {
Some((p, q)) => (p.to_string(), q.to_string()),
None => (full_path, String::new()),
};
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();
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 {
s.replace('+', " ")
.replace("%3A", ":")
.replace("%2F", "/")
.replace("%20", " ")
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(
@@ -137,7 +225,6 @@ fn handle(
);
}
// Assets estáticos: não carregam dado nenhum, então não exigem token.
match req.path.as_str() {
"/app.css" => return respond(&mut stream, 200, "text/css", APP_CSS.as_bytes()),
"/app.js" => {
@@ -148,9 +235,7 @@ fn handle(
APP_JS.as_bytes(),
)
}
"/sabia-logo.svg" => {
return respond(&mut stream, 200, "image/svg+xml", LOGO_SVG.as_bytes())
}
"/sabia.png" => return respond(&mut stream, 200, "image/png", LOGO_PNG),
_ => {}
}
@@ -162,65 +247,253 @@ fn handle(
&mut stream,
401,
"application/json",
b"{\"error\":\"token invalido\"}",
b"{\"ok\":false,\"error\":\"token invalido\"}",
);
}
let body = api(engine, &req);
respond(&mut stream, 200, "application/json", body.as_bytes())
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\nConnection: close\r\n\r\n", body.len())?;
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 api(engine: &Arc<Engine>, req: &Request) -> String {
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") => format!("{{\"version\":{:?}}}", engine.version()),
("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) => serde_json::to_string(&status).unwrap_or_else(|_| "{}".into()),
Err(e) => format!("{{\"error\":{:?}}}", e.to_string()),
Ok(status) => ok(&status),
Err(e) => err(400, e),
},
("GET", "/api/ifaces") => {
let list = engine.list_ifaces().unwrap_or_default();
serde_json::to_string(&list).unwrap_or_else(|_| "[]".into())
("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 name = req.query.get("iface").cloned().unwrap_or_default();
let enable = req
.query
.get("enable")
.map(|v| v == "true")
.unwrap_or(false);
match engine.set_monitor(&name, enable) {
Ok(()) => "{\"ok\":true}".into(),
Err(e) => format!("{{\"ok\":false,\"error\":{:?}}}", e.to_string()),
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 name = req.query.get("iface").cloned().unwrap_or_default();
match engine.start_scan(&name) {
Ok(()) => "{\"ok\":true}".into(),
Err(e) => format!("{{\"ok\":false,\"error\":{:?}}}", e.to_string()),
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 name = req.query.get("iface").cloned().unwrap_or_default();
engine.stop_scan(&name);
"{\"ok\":true}".into()
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 name = req.query.get("iface").cloned().unwrap_or_default();
serde_json::to_string(&engine.scan_results(&name)).unwrap_or_else(|_| "[]".into())
let Some(iface) = req.text("iface") else {
return err(400, "iface obrigatória");
};
ok(&engine.scan_results(&iface))
}
_ => "{\"error\":\"rota desconhecida\"}".into(),
("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/crack") => {
let (Some(iface), Some(wordlist)) = (req.text("iface"), req.text("wordlist")) else {
return err(400, "iface e wordlist são obrigatórios");
};
let bssid = req.text("bssid");
let station = req.text("station");
match engine.start_crack(&iface, bssid.as_deref(), station.as_deref(), &wordlist) {
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);
match engine.deauth(
&iface,
&bssid,
req.text("station").as_deref(),
count,
reason,
) {
Ok(report) => ok(&report),
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");
}
}