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:
commit
e989413018
76 files changed
+8386
No files matched your search
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "sabia-cli"
|
||||
description = "sabia: CLI de auditoria e monitoramento de redes Wi-Fi"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
authors.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "sabia"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
sabia-core = { path = "../sabia-core" }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
ctrlc = "3"
|
||||
@@ -0,0 +1,216 @@
|
||||
mod web;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use sabia_core::{audit::AuditLog, capture::RawCapture, iface, netlink, pcapwriter::PcapWriter, scan::ScanTable};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "sabia", about = "Sabiá — auditoria e monitoramento de redes Wi-Fi", version)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
cmd: Cmd,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Lista interfaces de rede e mostra quais são Wi-Fi.
|
||||
Iface {
|
||||
#[command(subcommand)]
|
||||
action: IfaceAction,
|
||||
},
|
||||
/// Varre o ar na interface (já em modo monitor) e mostra APs/clientes vistos.
|
||||
Scan {
|
||||
iface: String,
|
||||
/// Canal fixo; sem isso, pula entre todos os canais suportados.
|
||||
#[arg(long)]
|
||||
channel: Option<u32>,
|
||||
/// Para a varredura depois de N segundos (padrão: até Ctrl+C).
|
||||
#[arg(long)]
|
||||
seconds: Option<u64>,
|
||||
/// Salva tudo que foi capturado em um .pcap (inclui handshakes).
|
||||
#[arg(long)]
|
||||
out: Option<PathBuf>,
|
||||
},
|
||||
/// Verifica e aplica atualizações do próprio sabia.
|
||||
Update {
|
||||
#[arg(long)]
|
||||
apply: bool,
|
||||
},
|
||||
/// Confere a integridade da trilha de auditoria.
|
||||
AuditVerify { path: PathBuf },
|
||||
/// Abre a interface gráfica (servidor local + navegador).
|
||||
Ui {
|
||||
#[arg(long, default_value = "4337")]
|
||||
port: u16,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum IfaceAction {
|
||||
List,
|
||||
/// Liga/desliga modo monitor numa interface Wi-Fi.
|
||||
Monitor { iface: String, #[arg(value_enum)] state: OnOff },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, clap::ValueEnum)]
|
||||
enum OnOff {
|
||||
On,
|
||||
Off,
|
||||
}
|
||||
|
||||
fn audit_path() -> PathBuf {
|
||||
PathBuf::from(".sabia-audit.log")
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let cli = Cli::parse();
|
||||
if let Err(e) = run(cli) {
|
||||
eprintln!("erro: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn run(cli: Cli) -> sabia_core::Result<()> {
|
||||
match cli.cmd {
|
||||
Cmd::Iface { action } => match action {
|
||||
IfaceAction::List => {
|
||||
for i in iface::list_all()? {
|
||||
let tag = if i.phy.is_some() { "wifi" } else { "—" };
|
||||
println!(
|
||||
"{:<16} {:<18} {:<6} {:<10} {}",
|
||||
i.name,
|
||||
i.mac,
|
||||
tag,
|
||||
format!("{:?}", i.mode),
|
||||
if i.up { "up" } else { "down" }
|
||||
);
|
||||
}
|
||||
}
|
||||
IfaceAction::Monitor { iface: name, state } => {
|
||||
let enable = matches!(state, OnOff::On);
|
||||
iface::get(&name)?; // valida que é Wi-Fi antes de tocar nela
|
||||
netlink::set_monitor_mode(&name, enable)?;
|
||||
let mut log = AuditLog::open(audit_path())?;
|
||||
log.record(
|
||||
"monitor_mode",
|
||||
&name,
|
||||
&format!("{{\"enable\": {enable}}}"),
|
||||
)?;
|
||||
println!("{name}: modo monitor {}", if enable { "ativado" } else { "desativado" });
|
||||
}
|
||||
},
|
||||
|
||||
Cmd::Scan { iface: name, channel, seconds, out } => {
|
||||
let info = iface::get(&name)?;
|
||||
if info.mode != iface::Mode::Monitor {
|
||||
eprintln!("aviso: {name} não está em modo monitor (sabia iface monitor {name} on)");
|
||||
}
|
||||
|
||||
let mut log = AuditLog::open(audit_path())?;
|
||||
log.record("scan_start", &name, &format!("{{\"channel\": {channel:?}}}"))?;
|
||||
|
||||
let cap = RawCapture::open(&name)?;
|
||||
cap.set_read_timeout(Duration::from_millis(500))?;
|
||||
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
let r = running.clone();
|
||||
ctrlc::set_handler(move || r.store(false, Ordering::SeqCst))
|
||||
.expect("falha ao instalar handler de Ctrl+C");
|
||||
|
||||
let mut table = ScanTable::new();
|
||||
let mut writer = match &out {
|
||||
Some(p) => Some(PcapWriter::create(p)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let channels: Vec<u32> = match channel {
|
||||
Some(c) => vec![c],
|
||||
None => netlink::supported_channels(&name).unwrap_or_default(),
|
||||
};
|
||||
let mut chan_idx = 0usize;
|
||||
let mut last_hop = std::time::Instant::now();
|
||||
|
||||
let deadline = seconds.map(|s| std::time::Instant::now() + Duration::from_secs(s));
|
||||
let mut buf = [0u8; 65535];
|
||||
|
||||
println!("capturando em {name}... (Ctrl+C para parar)");
|
||||
while running.load(Ordering::SeqCst) {
|
||||
if let Some(d) = deadline {
|
||||
if std::time::Instant::now() >= d {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if channel.is_none() && !channels.is_empty() && last_hop.elapsed() > Duration::from_millis(300) {
|
||||
chan_idx = (chan_idx + 1) % channels.len();
|
||||
let _ = netlink::set_channel(&name, channels[chan_idx]);
|
||||
last_hop = std::time::Instant::now();
|
||||
}
|
||||
|
||||
match cap.recv(&mut buf)? {
|
||||
Some(n) => {
|
||||
let frame = &buf[..n];
|
||||
let had_eapol = table.ingest(frame);
|
||||
if had_eapol {
|
||||
eprintln!("[!] handshake EAPOL visto");
|
||||
}
|
||||
if let Some(w) = writer.as_mut() {
|
||||
w.write_frame(frame)?;
|
||||
}
|
||||
}
|
||||
None => continue, // timeout, sem quadro
|
||||
}
|
||||
}
|
||||
if let Some(w) = writer.as_mut() {
|
||||
w.flush()?;
|
||||
}
|
||||
|
||||
log.record("scan_stop", &name, &format!("{{\"aps_found\": {}}}", table.access_points().len()))?;
|
||||
|
||||
println!("\n{:<18} {:<24} {:<8} {:<8} {:<6} {}", "BSSID", "SSID", "CANAL", "SINAL", "SEG", "CLIENTES");
|
||||
for ap in table.access_points() {
|
||||
println!(
|
||||
"{:<18} {:<24} {:<8} {:<8} {:<6} {}",
|
||||
ap.bssid,
|
||||
ap.ssid.unwrap_or_default(),
|
||||
ap.channel.map(|c| c.to_string()).unwrap_or_default(),
|
||||
ap.last_signal_dbm.map(|s| format!("{s}dBm")).unwrap_or_default(),
|
||||
ap.security.unwrap_or_default(),
|
||||
ap.clients.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::Update { apply } => {
|
||||
let current = env!("CARGO_PKG_VERSION");
|
||||
if apply {
|
||||
let exe = std::env::current_exe()?;
|
||||
let version = sabia_core::update::apply(&exe)?;
|
||||
println!("atualizado para {version}");
|
||||
} else {
|
||||
let status = sabia_core::update::check(current)?;
|
||||
if status.update_available {
|
||||
println!(
|
||||
"atualização disponível: {} -> {} (rode `sabia update --apply`)",
|
||||
status.current_version,
|
||||
status.latest_version.unwrap_or_default()
|
||||
);
|
||||
} else {
|
||||
println!("já na última versão ({})", status.current_version);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::AuditVerify { path } => match sabia_core::audit::AuditLog::verify(&path)? {
|
||||
None => println!("cadeia de auditoria íntegra"),
|
||||
Some(idx) => println!("cadeia ROMPIDA na entrada {idx}"),
|
||||
},
|
||||
|
||||
Cmd::Ui { port } => {
|
||||
web::serve(port)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sabiá</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f1115; --panel: #171a21; --line: #262b35; --text: #e7e9ee;
|
||||
--muted: #8b93a3; --accent: #3ddc97; --warn: #e2b23d; --danger: #e2543d;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; background:var(--bg); color:var(--text); font: 14px/1.4 system-ui, sans-serif; }
|
||||
header { display:flex; align-items:center; gap:.75rem; padding:1rem 1.25rem; border-bottom:1px solid var(--line); }
|
||||
header h1 { font-size:1.1rem; margin:0; font-weight:600; letter-spacing:.02em; }
|
||||
header h1 span { color:var(--accent); }
|
||||
main { padding:1.25rem; max-width:1080px; margin:0 auto; }
|
||||
.panel { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:1rem 1.1rem; margin-bottom:1.1rem; }
|
||||
.row { display:flex; align-items:center; gap:.6rem; flex-wrap:wrap; }
|
||||
select, button { background:#1e222b; color:var(--text); border:1px solid var(--line); border-radius:7px; padding:.5rem .7rem; font-size:.9rem; }
|
||||
button { cursor:pointer; }
|
||||
button.primary { background:var(--accent); color:#06231a; border-color:var(--accent); font-weight:600; }
|
||||
button.danger { background:var(--danger); color:#2a0b06; border-color:var(--danger); }
|
||||
button:disabled { opacity:.45; cursor:not-allowed; }
|
||||
.pill { font-size:.75rem; padding:.15rem .55rem; border-radius:999px; border:1px solid var(--line); color:var(--muted); }
|
||||
.pill.on { color:var(--accent); border-color:var(--accent); }
|
||||
table { width:100%; border-collapse:collapse; margin-top:.5rem; }
|
||||
th, td { text-align:left; padding:.45rem .5rem; border-bottom:1px solid var(--line); font-size:.85rem; }
|
||||
th { color:var(--muted); font-weight:500; text-transform:uppercase; font-size:.7rem; letter-spacing:.04em; }
|
||||
td.sec-open { color:var(--danger); } td.sec-wep { color:var(--warn); } td.sec-wpa, td.sec-wpa2, td.sec-wpa3 { color:var(--accent); }
|
||||
.muted { color:var(--muted); }
|
||||
.warning { background:#241a10; border:1px solid #4a3414; color:#e2b23d; border-radius:8px; padding:.6rem .8rem; font-size:.82rem; margin-bottom:1rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Sabi<span>á</span></h1>
|
||||
<span class="muted">auditoria de redes Wi-Fi</span>
|
||||
</header>
|
||||
<main>
|
||||
<div class="warning">Use somente em redes próprias ou com autorização explícita do responsável. Toda ativação de modo monitor e sessão de captura é registrada na trilha de auditoria local.</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="row">
|
||||
<label class="muted">Interface</label>
|
||||
<select id="iface"></select>
|
||||
<span id="mode" class="pill">—</span>
|
||||
<button id="btnMonitor">ativar modo monitor</button>
|
||||
<button id="btnScan" class="primary" disabled>iniciar captura</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="row" style="justify-content:space-between">
|
||||
<strong>Redes vistas</strong>
|
||||
<span id="count" class="muted">0</span>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>BSSID</th><th>SSID</th><th>Canal</th><th>Sinal</th><th>Segurança</th><th>Clientes</th></tr></thead>
|
||||
<tbody id="rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
const params = new URLSearchParams(location.search);
|
||||
const token = params.get('token');
|
||||
let currentIface = null;
|
||||
let monitorOn = false;
|
||||
let scanning = false;
|
||||
|
||||
async function api(path, opts) {
|
||||
const url = new URL(path, location.origin);
|
||||
url.searchParams.set('token', token);
|
||||
const r = await fetch(url, opts);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
async function loadIfaces() {
|
||||
const list = await api('/api/ifaces');
|
||||
const sel = document.getElementById('iface');
|
||||
sel.innerHTML = '';
|
||||
for (const i of list) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = i.name;
|
||||
opt.textContent = `${i.name} (${i.mac})`;
|
||||
opt.dataset.mode = i.mode;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
if (list.length) onIfaceChange();
|
||||
}
|
||||
|
||||
function onIfaceChange() {
|
||||
const sel = document.getElementById('iface');
|
||||
const opt = sel.selectedOptions[0];
|
||||
currentIface = sel.value;
|
||||
monitorOn = opt && opt.dataset.mode === 'Monitor';
|
||||
refreshModeUi();
|
||||
}
|
||||
|
||||
function refreshModeUi() {
|
||||
const pill = document.getElementById('mode');
|
||||
pill.textContent = monitorOn ? 'modo monitor' : 'modo managed';
|
||||
pill.className = 'pill' + (monitorOn ? ' on' : '');
|
||||
document.getElementById('btnMonitor').textContent = monitorOn ? 'desativar modo monitor' : 'ativar modo monitor';
|
||||
document.getElementById('btnScan').disabled = !monitorOn;
|
||||
}
|
||||
|
||||
document.getElementById('iface').addEventListener('change', onIfaceChange);
|
||||
|
||||
document.getElementById('btnMonitor').addEventListener('click', async () => {
|
||||
await api(`/api/monitor&iface=${encodeURIComponent(currentIface)}&enable=${!monitorOn}`.replace('&', '?'), { method: 'POST' });
|
||||
monitorOn = !monitorOn;
|
||||
refreshModeUi();
|
||||
});
|
||||
|
||||
document.getElementById('btnScan').addEventListener('click', async () => {
|
||||
const btn = document.getElementById('btnScan');
|
||||
if (!scanning) {
|
||||
await api(`/api/scan/start?iface=${encodeURIComponent(currentIface)}`, { method: 'POST' });
|
||||
scanning = true;
|
||||
btn.textContent = 'parar captura';
|
||||
btn.classList.add('danger');
|
||||
} else {
|
||||
await api(`/api/scan/stop?iface=${encodeURIComponent(currentIface)}`, { method: 'POST' });
|
||||
scanning = false;
|
||||
btn.textContent = 'iniciar captura';
|
||||
btn.classList.remove('danger');
|
||||
}
|
||||
});
|
||||
|
||||
function secClass(sec) {
|
||||
if (!sec) return '';
|
||||
return 'sec-' + sec.toLowerCase().replace('á', 'a');
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
if (scanning && currentIface) {
|
||||
const aps = await api(`/api/scan?iface=${encodeURIComponent(currentIface)}`);
|
||||
const rows = document.getElementById('rows');
|
||||
rows.innerHTML = '';
|
||||
document.getElementById('count').textContent = aps.length;
|
||||
for (const ap of aps) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `<td>${ap.bssid}</td><td>${ap.ssid || '<em class="muted">oculto</em>'}</td>` +
|
||||
`<td>${ap.channel ?? '—'}</td><td>${ap.last_signal_dbm != null ? ap.last_signal_dbm + ' dBm' : '—'}</td>` +
|
||||
`<td class="${secClass(ap.security)}">${ap.security || '—'}</td><td>${ap.clients.length}</td>`;
|
||||
rows.appendChild(tr);
|
||||
}
|
||||
}
|
||||
setTimeout(poll, 1000);
|
||||
}
|
||||
|
||||
loadIfaces();
|
||||
poll();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user