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.
This commit is contained in:
1 parent
fd3ef5240e
commit
df27974359
29 files changed
+1268
-191
No files matched your search
+97
-113
@@ -1,36 +1,24 @@
|
||||
//! Servidor local da interface: a mesma filosofia do Guará (`guara ui`) —
|
||||
//! 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 é o que o shell desktop (Tauri, em `apps/gui`) vai carregar depois.
|
||||
//! 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::{audit::AuditLog, capture::RawCapture, iface, netlink, scan::ScanTable};
|
||||
use sabia_core::engine::Engine;
|
||||
use std::collections::HashMap;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
const INDEX_HTML: &str = include_str!("index.html");
|
||||
const APP_CSS: &str = include_str!("app.css");
|
||||
const APP_JS: &str = include_str!("app.js");
|
||||
const LOGO_SVG: &str = include_str!("sabia-logo.svg");
|
||||
|
||||
struct Session {
|
||||
table: Arc<Mutex<ScanTable>>,
|
||||
running: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct AppState {
|
||||
sessions: Mutex<HashMap<String, Session>>,
|
||||
}
|
||||
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 state = Arc::new(AppState::default());
|
||||
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}"));
|
||||
@@ -40,10 +28,10 @@ pub fn serve(port: u16) -> sabia_core::Result<()> {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let state = state.clone();
|
||||
let engine = engine.clone();
|
||||
let token = token.clone();
|
||||
thread::spawn(move || {
|
||||
let _ = handle(stream, &state, &token);
|
||||
let _ = handle(stream, &engine, &token);
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
@@ -51,12 +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().as_nanos();
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
format!("{nanos:x}")
|
||||
}
|
||||
|
||||
fn open_browser(url: &str) -> std::io::Result<()> {
|
||||
std::process::Command::new("xdg-open").arg(url).spawn().map(|_| ())
|
||||
#[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 {
|
||||
@@ -74,7 +85,6 @@ 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();
|
||||
|
||||
// consome o resto dos headers
|
||||
loop {
|
||||
let mut l = String::new();
|
||||
if reader.read_line(&mut l)? == 0 || l == "\r\n" {
|
||||
@@ -93,28 +103,54 @@ fn parse_request(stream: &mut TcpStream) -> std::io::Result<Request> {
|
||||
}
|
||||
}
|
||||
let token = query.get("token").cloned();
|
||||
Ok(Request { method, path, query, token })
|
||||
Ok(Request {
|
||||
method,
|
||||
path,
|
||||
query,
|
||||
token,
|
||||
})
|
||||
}
|
||||
|
||||
fn urldecode(s: &str) -> String {
|
||||
s.replace('+', " ").replace("%3A", ":").replace("%2F", "/").replace("%20", " ")
|
||||
s.replace('+', " ")
|
||||
.replace("%3A", ":")
|
||||
.replace("%2F", "/")
|
||||
.replace("%20", " ")
|
||||
}
|
||||
|
||||
fn handle(mut stream: TcpStream, state: &Arc<AppState>, expected_token: &str) -> std::io::Result<()> {
|
||||
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());
|
||||
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()),
|
||||
"/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())
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -122,10 +158,15 @@ fn handle(mut stream: TcpStream, state: &Arc<AppState>, expected_token: &str) ->
|
||||
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\"}");
|
||||
return respond(
|
||||
&mut stream,
|
||||
401,
|
||||
"application/json",
|
||||
b"{\"error\":\"token invalido\"}",
|
||||
);
|
||||
}
|
||||
|
||||
let body = api(state, &req);
|
||||
let body = api(engine, &req);
|
||||
respond(&mut stream, 200, "application/json", body.as_bytes())
|
||||
}
|
||||
|
||||
@@ -141,102 +182,45 @@ fn respond(stream: &mut TcpStream, code: u16, ctype: &str, body: &[u8]) -> std::
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn api(state: &Arc<AppState>, req: &Request) -> String {
|
||||
fn api(engine: &Arc<Engine>, req: &Request) -> String {
|
||||
match (req.method.as_str(), req.path.as_str()) {
|
||||
("GET", "/api/version") => {
|
||||
format!("{{\"version\":{:?}}}", env!("CARGO_PKG_VERSION"))
|
||||
}
|
||||
("GET", "/api/update/check") => {
|
||||
match sabia_core::update::check(env!("CARGO_PKG_VERSION")) {
|
||||
Ok(status) => serde_json::to_string(&status).unwrap_or_else(|_| "{}".into()),
|
||||
Err(e) => format!("{{\"error\":{:?}}}", e.to_string()),
|
||||
}
|
||||
}
|
||||
("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 = iface::list_wireless().unwrap_or_default();
|
||||
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 netlink::set_monitor_mode(&name, enable) {
|
||||
Ok(()) => {
|
||||
if let Ok(mut log) = AuditLog::open(".sabia-audit.log") {
|
||||
let _ = log.record("monitor_mode", &name, &format!("{{\"enable\":{enable}}}"));
|
||||
}
|
||||
"{\"ok\":true}".into()
|
||||
}
|
||||
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();
|
||||
start_scan(state, name)
|
||||
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();
|
||||
stop_scan(state, &name);
|
||||
engine.stop_scan(&name);
|
||||
"{\"ok\":true}".into()
|
||||
}
|
||||
("GET", "/api/scan") => {
|
||||
let name = req.query.get("iface").cloned().unwrap_or_default();
|
||||
let sessions = state.sessions.lock().unwrap();
|
||||
match sessions.get(&name) {
|
||||
Some(s) => {
|
||||
let table = s.table.lock().unwrap();
|
||||
serde_json::to_string(&table.access_points()).unwrap_or_else(|_| "[]".into())
|
||||
}
|
||||
None => "[]".into(),
|
||||
}
|
||||
serde_json::to_string(&engine.scan_results(&name)).unwrap_or_else(|_| "[]".into())
|
||||
}
|
||||
_ => "{\"error\":\"rota desconhecida\"}".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn start_scan(state: &Arc<AppState>, name: String) -> String {
|
||||
let mut sessions = state.sessions.lock().unwrap();
|
||||
if sessions.contains_key(&name) {
|
||||
return "{\"ok\":true,\"already_running\":true}".into();
|
||||
}
|
||||
let table = Arc::new(Mutex::new(ScanTable::new()));
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
|
||||
let t = table.clone();
|
||||
let r = running.clone();
|
||||
let iface_name = name.clone();
|
||||
thread::spawn(move || {
|
||||
let cap = match RawCapture::open(&iface_name) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("falha ao abrir captura em {iface_name}: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = cap.set_read_timeout(Duration::from_millis(500));
|
||||
let channels = netlink::supported_channels(&iface_name).unwrap_or_default();
|
||||
let mut idx = 0usize;
|
||||
let mut last_hop = std::time::Instant::now();
|
||||
let mut buf = [0u8; 65535];
|
||||
while r.load(Ordering::SeqCst) {
|
||||
if !channels.is_empty() && last_hop.elapsed() > Duration::from_millis(300) {
|
||||
idx = (idx + 1) % channels.len();
|
||||
let _ = netlink::set_channel(&iface_name, channels[idx]);
|
||||
last_hop = std::time::Instant::now();
|
||||
}
|
||||
if let Ok(Some(n)) = cap.recv(&mut buf) {
|
||||
let mut table = t.lock().unwrap();
|
||||
table.ingest(&buf[..n]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
sessions.insert(name, Session { table, running });
|
||||
"{\"ok\":true}".into()
|
||||
}
|
||||
|
||||
fn stop_scan(state: &Arc<AppState>, name: &str) {
|
||||
let mut sessions = state.sessions.lock().unwrap();
|
||||
if let Some(s) = sessions.remove(name) {
|
||||
s.running.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user