Esqueleto inicial: sabia-core + sabia-cli + UI local, OpenWolf

- sabia-core: descoberta de interfaces, controle de modo monitor/canal
  via iw (ponte até ter nl80211 nativo), captura crua 802.11 via
  AF_PACKET (sem libpcap), parsing de radiotap e quadros de
  gerenciamento (beacon/probe/EAPOL), agregação em tabela de APs/
  clientes, escrita de .pcap, trilha de auditoria encadeada por hash,
  auto-atualização via GitHub releases (curl, sem dependências de
  rede extras)
- sabia-cli: `sabia iface`, `sabia scan`, `sabia update`,
  `sabia audit-verify`, `sabia ui` (servidor local + navegador,
  protegido por token, mesma filosofia do Guará)
- OpenWolf inicializado para memória de projeto entre agentes
This commit is contained in:
Lucas committed 2026-10-23 08:22:04 -03:00
commit e989413018
76 files changed
+8386

No files matched your search

+222
View File
@@ -0,0 +1,222 @@
//! 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.
use sabia_core::{audit::AuditLog, capture::RawCapture, iface, netlink, scan::ScanTable};
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::thread;
use std::time::Duration;
const INDEX_HTML: &str = include_str!("index.html");
struct Session {
table: Arc<Mutex<ScanTable>>,
running: Arc<AtomicBool>,
}
#[derive(Default)]
struct AppState {
sessions: Mutex<HashMap<String, Session>>,
}
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());
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 state = state.clone();
let token = token.clone();
thread::spawn(move || {
let _ = handle(stream, &state, &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<()> {
std::process::Command::new("xdg-open").arg(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();
// consome o resto dos headers
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, state: &Arc<AppState>, 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.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(state, &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(state: &Arc<AppState>, req: &Request) -> String {
match (req.method.as_str(), req.path.as_str()) {
("GET", "/api/ifaces") => {
let list = iface::list_wireless().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()
}
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)
}
("POST", "/api/scan/stop") => {
let name = req.query.get("iface").cloned().unwrap_or_default();
stop_scan(state, &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(),
}
}
_ => "{\"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);
}
}