Files
sabia/crates/sabia-cli/src/web/mod.rs
T
Lucas df27974359 App desktop (Tauri), GitHub Actions de CI/release, elevação automática
- Move a UI de crates/sabia-cli/src/web/ para apps/gui/ui/: fonte única
  servida tanto pelo `sabia ui` quanto pelo app desktop.
- sabia-core::engine::Engine centraliza sessões de scan/monitor/update —
  usado pelo servidor local da CLI e pelo app Tauri, sem duplicar lógica.
- apps/gui/src-tauri: app desktop Tauri 2, mesma interface, comandos IPC
  espelhando a API HTTP. Pede elevação de administrador/root
  automaticamente ao abrir (modo monitor e captura crua exigem):
  manifesto requireAdministrator no Windows (UAC antes mesmo do app
  abrir), pkexec no Linux e osascript no macOS, com reinício automático
  e aviso + botão de nova tentativa na interface se for negada.
- Ícone gerado a partir do logo (apps/gui/src-tauri/icons/source.png).
- .github/workflows/ci.yml: fmt, clippy, testes (Linux/Windows/macOS),
  auditoria de dependências, compilação do app desktop.
- .github/workflows/release.yml: ao empurrar uma tag vX.Y.Z, builda a
  CLI (Linux x86_64/aarch64, macOS x86_64/aarch64, Windows x86_64) e o
  app desktop pras mesmas plataformas, publica tudo numa release do
  GitHub com checksums SHA-256.
- sabia-core::update: corrige o nome do asset esperado (casava pelo
  nome fixo, mas o release empacota com a versão no nome) e a extração
  (tar.gz no Linux/macOS, zip no Windows).
- LICENSE-MIT, LICENSE-APACHE, CHANGELOG.md com a entrada 0.1.0.
- cargo fmt + clippy (-D warnings) limpos em todo o workspace.
2026-10-23 08:56:08 -03:00

227 lines
7.1 KiB
Rust

//! Servidor local da interface: uma única página HTML/JS servida em
//! `127.0.0.1`, protegida por um token gerado na hora, falando com o
//! 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 std::collections::HashMap;
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::thread;
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");
pub fn serve(port: u16) -> sabia_core::Result<()> {
let token = gen_token();
let listener = TcpListener::bind(("127.0.0.1", port))?;
let engine = Arc::new(Engine::new());
println!("Sabiá UI em http://127.0.0.1:{port}/?token={token}");
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 engine = engine.clone();
let token = token.clone();
thread::spawn(move || {
let _ = handle(stream, &engine, &token);
});
}
Ok(())
}
fn gen_token() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
format!("{nanos:x}")
}
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(|_| ())
}
}
struct Request {
method: String,
path: String,
query: HashMap<String, String>,
token: Option<String>,
}
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();
loop {
let mut l = String::new();
if reader.read_line(&mut l)? == 0 || l == "\r\n" {
break;
}
}
let (path, query_str) = match full_path.split_once('?') {
Some((p, q)) => (p.to_string(), q.to_string()),
None => (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));
}
}
let token = query.get("token").cloned();
Ok(Request {
method,
path,
query,
token,
})
}
fn urldecode(s: &str) -> String {
s.replace('+', " ")
.replace("%3A", ":")
.replace("%2F", "/")
.replace("%20", " ")
}
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(),
);
}
// 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" => {
return respond(
&mut stream,
200,
"application/javascript",
APP_JS.as_bytes(),
)
}
"/sabia-logo.svg" => {
return respond(&mut stream, 200, "image/svg+xml", LOGO_SVG.as_bytes())
}
_ => {}
}
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"{\"error\":\"token invalido\"}",
);
}
let body = api(engine, &req);
respond(&mut stream, 200, "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",
401 => "Unauthorized",
404 => "Not Found",
_ => "Error",
};
write!(stream, "HTTP/1.1 {code} {status}\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len())?;
stream.write_all(body)?;
Ok(())
}
fn api(engine: &Arc<Engine>, req: &Request) -> String {
match (req.method.as_str(), req.path.as_str()) {
("GET", "/api/version") => format!("{{\"version\":{:?}}}", engine.version()),
("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()),
},
("GET", "/api/ifaces") => {
let list = engine.list_ifaces().unwrap_or_default();
serde_json::to_string(&list).unwrap_or_else(|_| "[]".into())
}
("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()),
}
}
("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()),
}
}
("POST", "/api/scan/stop") => {
let name = req.query.get("iface").cloned().unwrap_or_default();
engine.stop_scan(&name);
"{\"ok\":true}".into()
}
("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())
}
_ => "{\"error\":\"rota desconhecida\"}".into(),
}
}