//! 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, token: Option, } 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(); 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, 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, 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(), } }