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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "sabia-core"
|
||||
description = "Sabiá: núcleo de captura, análise e auditoria de redes Wi-Fi"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
authors.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
libc = "0.2"
|
||||
thiserror = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
time = { version = "0.3", features = ["formatting", "macros"] }
|
||||
sha2 = "0.10"
|
||||
|
||||
[target.'cfg(not(target_os = "linux"))'.dependencies]
|
||||
# captura crua (AF_PACKET) só existe no Linux; outras plataformas compilam
|
||||
# a crate mas os módulos de captura ficam indisponíveis em tempo de execução.
|
||||
@@ -0,0 +1,114 @@
|
||||
//! Trilha de auditoria encadeada por hash de toda ação sensível (ligar
|
||||
//! modo monitor, iniciar captura, trocar canal, etc.) — mesmo princípio do
|
||||
//! módulo `audit` do Guará: cada evento guarda o hash do anterior, então
|
||||
//! qualquer edição retroativa do log quebra a cadeia e é detectável.
|
||||
//!
|
||||
//! Isso existe para que o uso do Sabiá em um engajamento de pentest tenha
|
||||
//! evidência de o quê foi feito, quando e contra qual rede/interface —
|
||||
//! não para decidir sozinho se uma ação é autorizada (isso é sempre do
|
||||
//! operador).
|
||||
|
||||
use crate::error::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::Path;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuditEntry {
|
||||
pub timestamp: String,
|
||||
pub action: String,
|
||||
pub target: String,
|
||||
pub detail: String,
|
||||
pub prev_hash: String,
|
||||
pub hash: String,
|
||||
}
|
||||
|
||||
pub struct AuditLog {
|
||||
path: std::path::PathBuf,
|
||||
last_hash: String,
|
||||
}
|
||||
|
||||
const GENESIS: &str = "0000000000000000000000000000000000000000000000000000000000000000";
|
||||
|
||||
impl AuditLog {
|
||||
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
|
||||
let path = path.as_ref().to_path_buf();
|
||||
let last_hash = if path.exists() {
|
||||
let file = OpenOptions::new().read(true).open(&path)?;
|
||||
let reader = BufReader::new(file);
|
||||
let mut last = GENESIS.to_string();
|
||||
for line in reader.lines() {
|
||||
let line = line?;
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(entry) = serde_json::from_str::<AuditEntry>(&line) {
|
||||
last = entry.hash;
|
||||
}
|
||||
}
|
||||
last
|
||||
} else {
|
||||
GENESIS.to_string()
|
||||
};
|
||||
Ok(Self { path, last_hash })
|
||||
}
|
||||
|
||||
pub fn record(&mut self, action: &str, target: &str, detail: &str) -> Result<AuditEntry> {
|
||||
let timestamp = OffsetDateTime::now_utc().to_string();
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(self.last_hash.as_bytes());
|
||||
hasher.update(timestamp.as_bytes());
|
||||
hasher.update(action.as_bytes());
|
||||
hasher.update(target.as_bytes());
|
||||
hasher.update(detail.as_bytes());
|
||||
let hash = format!("{:x}", hasher.finalize());
|
||||
|
||||
let entry = AuditEntry {
|
||||
timestamp,
|
||||
action: action.to_string(),
|
||||
target: target.to_string(),
|
||||
detail: detail.to_string(),
|
||||
prev_hash: self.last_hash.clone(),
|
||||
hash: hash.clone(),
|
||||
};
|
||||
|
||||
let mut file = OpenOptions::new().create(true).append(true).open(&self.path)?;
|
||||
writeln!(file, "{}", serde_json::to_string(&entry)?)?;
|
||||
|
||||
self.last_hash = hash;
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
/// Verifica a integridade de toda a cadeia; retorna o índice da
|
||||
/// primeira entrada inválida, se houver.
|
||||
pub fn verify(path: impl AsRef<Path>) -> Result<Option<usize>> {
|
||||
let file = OpenOptions::new().read(true).open(path)?;
|
||||
let reader = BufReader::new(file);
|
||||
let mut prev = GENESIS.to_string();
|
||||
for (i, line) in reader.lines().enumerate() {
|
||||
let line = line?;
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let entry: AuditEntry = serde_json::from_str(&line)?;
|
||||
if entry.prev_hash != prev {
|
||||
return Ok(Some(i));
|
||||
}
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(entry.prev_hash.as_bytes());
|
||||
hasher.update(entry.timestamp.as_bytes());
|
||||
hasher.update(entry.action.as_bytes());
|
||||
hasher.update(entry.target.as_bytes());
|
||||
hasher.update(entry.detail.as_bytes());
|
||||
let expected = format!("{:x}", hasher.finalize());
|
||||
if expected != entry.hash {
|
||||
return Ok(Some(i));
|
||||
}
|
||||
prev = entry.hash;
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Captura crua de quadros 802.11 via `AF_PACKET`, sem depender de libpcap.
|
||||
//!
|
||||
//! A interface já precisa estar em modo monitor (ver [`crate::netlink`]).
|
||||
//! Isso só funciona em Linux — é exatamente o que o libpcap faz por baixo
|
||||
//! quando usado em modo monitor, então reimplementar aqui é pouco código e
|
||||
//! remove uma dependência de sistema.
|
||||
|
||||
#![cfg(target_os = "linux")]
|
||||
|
||||
use crate::error::{Result, SabiaError};
|
||||
use std::ffi::CString;
|
||||
use std::io;
|
||||
use std::mem;
|
||||
use std::os::fd::{AsRawFd, RawFd};
|
||||
use std::time::Duration;
|
||||
|
||||
const ETH_P_ALL: u16 = 0x0003;
|
||||
|
||||
pub struct RawCapture {
|
||||
fd: RawFd,
|
||||
}
|
||||
|
||||
impl RawCapture {
|
||||
/// Abre um socket raw ligado à interface `iface` (já em modo monitor).
|
||||
/// Exige `CAP_NET_RAW` (tipicamente root).
|
||||
pub fn open(iface: &str) -> Result<Self> {
|
||||
let fd = unsafe { libc::socket(libc::AF_PACKET, libc::SOCK_RAW, ETH_P_ALL as i32) };
|
||||
if fd < 0 {
|
||||
let err = io::Error::last_os_error();
|
||||
return Err(if err.raw_os_error() == Some(libc::EPERM) {
|
||||
SabiaError::PermissionDenied
|
||||
} else {
|
||||
SabiaError::Io(err)
|
||||
});
|
||||
}
|
||||
|
||||
let ifindex = if_index(iface)?;
|
||||
|
||||
let mut sll: libc::sockaddr_ll = unsafe { mem::zeroed() };
|
||||
sll.sll_family = libc::AF_PACKET as u16;
|
||||
sll.sll_protocol = (ETH_P_ALL as u16).to_be();
|
||||
sll.sll_ifindex = ifindex;
|
||||
|
||||
let ret = unsafe {
|
||||
libc::bind(
|
||||
fd,
|
||||
&sll as *const libc::sockaddr_ll as *const libc::sockaddr,
|
||||
mem::size_of::<libc::sockaddr_ll>() as u32,
|
||||
)
|
||||
};
|
||||
if ret < 0 {
|
||||
let err = io::Error::last_os_error();
|
||||
unsafe { libc::close(fd) };
|
||||
return Err(SabiaError::Io(err));
|
||||
}
|
||||
|
||||
Ok(Self { fd })
|
||||
}
|
||||
|
||||
/// Define um timeout de recepção, para o loop de captura poder checar
|
||||
/// periodicamente um sinal de parada sem travar para sempre em `recv`.
|
||||
pub fn set_read_timeout(&self, timeout: Duration) -> Result<()> {
|
||||
let tv = libc::timeval {
|
||||
tv_sec: timeout.as_secs() as libc::time_t,
|
||||
tv_usec: timeout.subsec_micros() as libc::suseconds_t,
|
||||
};
|
||||
let ret = unsafe {
|
||||
libc::setsockopt(
|
||||
self.fd,
|
||||
libc::SOL_SOCKET,
|
||||
libc::SO_RCVTIMEO,
|
||||
&tv as *const _ as *const libc::c_void,
|
||||
mem::size_of::<libc::timeval>() as u32,
|
||||
)
|
||||
};
|
||||
if ret < 0 {
|
||||
return Err(SabiaError::Io(io::Error::last_os_error()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Lê um quadro. Retorna `Ok(None)` em timeout (ver `set_read_timeout`).
|
||||
pub fn recv(&self, buf: &mut [u8]) -> Result<Option<usize>> {
|
||||
let n = unsafe { libc::recv(self.fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len(), 0) };
|
||||
if n < 0 {
|
||||
let err = io::Error::last_os_error();
|
||||
if matches!(err.raw_os_error(), Some(libc::EAGAIN)) {
|
||||
return Ok(None);
|
||||
}
|
||||
return Err(SabiaError::Io(err));
|
||||
}
|
||||
Ok(Some(n as usize))
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRawFd for RawCapture {
|
||||
fn as_raw_fd(&self) -> RawFd {
|
||||
self.fd
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RawCapture {
|
||||
fn drop(&mut self) {
|
||||
unsafe { libc::close(self.fd) };
|
||||
}
|
||||
}
|
||||
|
||||
fn if_index(name: &str) -> Result<i32> {
|
||||
let cname = CString::new(name).map_err(|_| SabiaError::InterfaceNotFound(name.to_string()))?;
|
||||
let idx = unsafe { libc::if_nametoindex(cname.as_ptr()) };
|
||||
if idx == 0 {
|
||||
return Err(SabiaError::InterfaceNotFound(name.to_string()));
|
||||
}
|
||||
Ok(idx as i32)
|
||||
}
|
||||
|
||||
/// Garante, de forma best-effort, que o processo tem privilégio para abrir
|
||||
/// sockets raw — útil para dar um erro claro antes de tentar o resto.
|
||||
pub fn check_capabilities() -> bool {
|
||||
let euid = unsafe { libc::geteuid() };
|
||||
euid == 0
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Parser mínimo de quadros 802.11 de gerenciamento (beacon, probe
|
||||
//! request/response) e detecção de EAPOL (handshake WPA/WPA2).
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FrameKind {
|
||||
Beacon,
|
||||
ProbeRequest,
|
||||
ProbeResponse,
|
||||
AssociationRequest,
|
||||
Deauth,
|
||||
Disassoc,
|
||||
Data,
|
||||
Other,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Security {
|
||||
Open,
|
||||
Wep,
|
||||
Wpa,
|
||||
Wpa2,
|
||||
Wpa3,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ManagementInfo {
|
||||
pub ssid: Option<String>,
|
||||
pub security: Option<Security>,
|
||||
pub channel_ie: Option<u32>,
|
||||
}
|
||||
|
||||
pub fn mac_to_string(mac: &[u8]) -> String {
|
||||
format!("{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5])
|
||||
}
|
||||
|
||||
/// Quadro 802.11 já sem o cabeçalho radiotap.
|
||||
pub struct Frame<'a> {
|
||||
pub kind: FrameKind,
|
||||
pub bssid: Option<[u8; 6]>,
|
||||
pub src: Option<[u8; 6]>,
|
||||
pub dst: Option<[u8; 6]>,
|
||||
pub body: &'a [u8],
|
||||
/// offset, dentro de `body` completo original, de onde começam os
|
||||
/// "information elements" (só preenchido para beacon/probe response).
|
||||
ie_offset: usize,
|
||||
}
|
||||
|
||||
pub fn parse(buf: &[u8]) -> Option<Frame<'_>> {
|
||||
if buf.len() < 10 {
|
||||
return None;
|
||||
}
|
||||
let fc = u16::from_le_bytes([buf[0], buf[1]]);
|
||||
let frame_type = (fc >> 2) & 0b11;
|
||||
let subtype = (fc >> 4) & 0b1111;
|
||||
|
||||
let kind = match (frame_type, subtype) {
|
||||
(0, 8) => FrameKind::Beacon,
|
||||
(0, 4) => FrameKind::ProbeRequest,
|
||||
(0, 5) => FrameKind::ProbeResponse,
|
||||
(0, 0) => FrameKind::AssociationRequest,
|
||||
(0, 12) => FrameKind::Deauth,
|
||||
(0, 10) => FrameKind::Disassoc,
|
||||
(2, _) => FrameKind::Data,
|
||||
_ => FrameKind::Other,
|
||||
};
|
||||
|
||||
if buf.len() < 24 {
|
||||
// sem addr1/2/3 completos (comum em alguns quadros de controle)
|
||||
return Some(Frame { kind, bssid: None, src: None, dst: None, body: buf, ie_offset: 0 });
|
||||
}
|
||||
|
||||
let addr1: [u8; 6] = buf[4..10].try_into().ok()?;
|
||||
let addr2: [u8; 6] = buf[10..16].try_into().ok()?;
|
||||
let addr3: [u8; 6] = buf[16..22].try_into().ok()?;
|
||||
|
||||
let (dst, src, bssid) = (addr1, addr2, addr3);
|
||||
|
||||
// Para beacon/probe response, corpo fixo de 12 bytes (timestamp +
|
||||
// interval + capability) antecede os information elements.
|
||||
let ie_offset = if matches!(kind, FrameKind::Beacon | FrameKind::ProbeResponse) { 24 + 12 } else { 24 };
|
||||
|
||||
Some(Frame { kind, bssid: Some(bssid), src: Some(src), dst: Some(dst), body: buf, ie_offset })
|
||||
}
|
||||
|
||||
impl<'a> Frame<'a> {
|
||||
pub fn bssid_str(&self) -> Option<String> {
|
||||
self.bssid.map(|m| mac_to_string(&m))
|
||||
}
|
||||
|
||||
pub fn src_str(&self) -> Option<String> {
|
||||
self.src.map(|m| mac_to_string(&m))
|
||||
}
|
||||
|
||||
/// Faz parsing dos "information elements" (TLV) de um beacon/probe
|
||||
/// response, extraindo SSID, canal e indício de cifragem.
|
||||
pub fn management_info(&self) -> ManagementInfo {
|
||||
let mut info = ManagementInfo::default();
|
||||
if self.ie_offset == 0 || self.ie_offset >= self.body.len() {
|
||||
return info;
|
||||
}
|
||||
let privacy = {
|
||||
// bit "Privacy" no campo capability, 2 bytes antes do fim do
|
||||
// cabeçalho fixo (offset 24+10, 2 bytes).
|
||||
if self.body.len() >= 24 + 12 {
|
||||
let cap = u16::from_le_bytes([self.body[24 + 10], self.body[24 + 11]]);
|
||||
cap & 0x0010 != 0
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
let mut rsn_present = false;
|
||||
let mut wpa1_present = false;
|
||||
|
||||
let ies = &self.body[self.ie_offset..];
|
||||
let mut i = 0;
|
||||
while i + 2 <= ies.len() {
|
||||
let tag = ies[i];
|
||||
let len = ies[i + 1] as usize;
|
||||
let start = i + 2;
|
||||
if start + len > ies.len() {
|
||||
break;
|
||||
}
|
||||
let val = &ies[start..start + len];
|
||||
match tag {
|
||||
0 => {
|
||||
// SSID; pode ser "oculto" (len 0 ou só zeros)
|
||||
if len > 0 && val.iter().any(|&b| b != 0) {
|
||||
info.ssid = Some(String::from_utf8_lossy(val).into_owned());
|
||||
} else {
|
||||
info.ssid = Some(String::new());
|
||||
}
|
||||
}
|
||||
3 => {
|
||||
if len == 1 {
|
||||
info.channel_ie = Some(val[0] as u32);
|
||||
}
|
||||
}
|
||||
48 => rsn_present = true, // RSN IE = WPA2/WPA3
|
||||
221 => {
|
||||
// vendor-specific; OUI 00:50:F2 tipo 1 = WPA1
|
||||
if len >= 4 && val[0..3] == [0x00, 0x50, 0xF2] && val[3] == 1 {
|
||||
wpa1_present = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
i = start + len;
|
||||
}
|
||||
|
||||
info.security = Some(if rsn_present {
|
||||
Security::Wpa2
|
||||
} else if wpa1_present {
|
||||
Security::Wpa
|
||||
} else if privacy {
|
||||
Security::Wep
|
||||
} else {
|
||||
Security::Open
|
||||
});
|
||||
|
||||
info
|
||||
}
|
||||
}
|
||||
|
||||
/// Detecta se um quadro de dados carrega EAPOL (handshake 4-way), olhando o
|
||||
/// cabeçalho LLC/SNAP (ethertype 0x888E) após o cabeçalho 802.11 de dados.
|
||||
pub fn is_eapol(body: &[u8]) -> bool {
|
||||
// Cabeçalho de dados pode ter 24 ou 26 bytes (QoS); LLC/SNAP tem 8
|
||||
// bytes (AA AA 03 00 00 00) + ethertype de 2 bytes.
|
||||
for hdr_len in [24usize, 26] {
|
||||
if body.len() < hdr_len + 8 {
|
||||
continue;
|
||||
}
|
||||
let llc = &body[hdr_len..hdr_len + 8];
|
||||
if llc[0] == 0xAA && llc[1] == 0xAA && llc[2] == 0x03 {
|
||||
let ethertype = u16::from_be_bytes([llc[6], llc[7]]);
|
||||
if ethertype == 0x888E {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
use std::io;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SabiaError {
|
||||
#[error("interface `{0}` não encontrada")]
|
||||
InterfaceNotFound(String),
|
||||
|
||||
#[error("interface `{0}` não é uma interface Wi-Fi (sem phy80211)")]
|
||||
NotWireless(String),
|
||||
|
||||
#[error("falha ao executar `{cmd}`: {detail}")]
|
||||
CommandFailed { cmd: String, detail: String },
|
||||
|
||||
#[error("permissão insuficiente — operações de captura e modo monitor exigem root (CAP_NET_ADMIN/CAP_NET_RAW)")]
|
||||
PermissionDenied,
|
||||
|
||||
#[error("captura crua só é suportada no Linux (AF_PACKET)")]
|
||||
UnsupportedPlatform,
|
||||
|
||||
#[error("erro de E/S: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
|
||||
#[error("erro ao (de)serializar: {0}")]
|
||||
Serde(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, SabiaError>;
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Descoberta de interfaces de rede e identificação das que são Wi-Fi.
|
||||
//!
|
||||
//! Não depende de `iw`: lê direto de `/sys/class/net`, do mesmo jeito que o
|
||||
//! próprio `iw`/`ip` fazem por baixo.
|
||||
|
||||
use crate::error::{Result, SabiaError};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct Interface {
|
||||
pub name: String,
|
||||
pub mac: String,
|
||||
/// Caminho do phy (ex.: `phy0`), presente só em interfaces 802.11.
|
||||
pub phy: Option<String>,
|
||||
pub mode: Mode,
|
||||
pub up: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
||||
pub enum Mode {
|
||||
Managed,
|
||||
Monitor,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
fn net_root() -> &'static Path {
|
||||
Path::new("/sys/class/net")
|
||||
}
|
||||
|
||||
/// Lista todas as interfaces de rede, Wi-Fi ou não (chamador filtra por `phy`).
|
||||
pub fn list_all() -> Result<Vec<Interface>> {
|
||||
let mut out = Vec::new();
|
||||
for entry in fs::read_dir(net_root())? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if name == "lo" {
|
||||
continue;
|
||||
}
|
||||
out.push(read_interface(&name)?);
|
||||
}
|
||||
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Lista apenas interfaces com um `phy80211` (ou seja, rádios Wi-Fi).
|
||||
pub fn list_wireless() -> Result<Vec<Interface>> {
|
||||
Ok(list_all()?.into_iter().filter(|i| i.phy.is_some()).collect())
|
||||
}
|
||||
|
||||
pub fn get(name: &str) -> Result<Interface> {
|
||||
let iface = read_interface(name)?;
|
||||
if iface.phy.is_none() {
|
||||
return Err(SabiaError::NotWireless(name.to_string()));
|
||||
}
|
||||
Ok(iface)
|
||||
}
|
||||
|
||||
fn read_interface(name: &str) -> Result<Interface> {
|
||||
let base: PathBuf = net_root().join(name);
|
||||
if !base.exists() {
|
||||
return Err(SabiaError::InterfaceNotFound(name.to_string()));
|
||||
}
|
||||
|
||||
let mac = fs::read_to_string(base.join("address"))
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let up = fs::read_to_string(base.join("operstate"))
|
||||
.map(|s| s.trim() == "up")
|
||||
.unwrap_or(false);
|
||||
|
||||
let phy80211 = base.join("phy80211");
|
||||
let phy = if phy80211.is_dir() {
|
||||
fs::read_link(&phy80211)
|
||||
.ok()
|
||||
.and_then(|p| p.file_name().map(|f| f.to_string_lossy().into_owned()))
|
||||
.or_else(|| {
|
||||
// em alguns kernels phy80211 é um diretório real, não symlink;
|
||||
// o nome do phy então vem do subdiretório "phyNN" dentro dele.
|
||||
fs::read_dir(&phy80211)
|
||||
.ok()
|
||||
.and_then(|mut rd| rd.next())
|
||||
.and_then(|e| e.ok())
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mode = if phy.is_some() {
|
||||
read_wireless_mode(name)
|
||||
} else {
|
||||
Mode::Unknown
|
||||
};
|
||||
|
||||
Ok(Interface { name: name.to_string(), mac, phy, mode, up })
|
||||
}
|
||||
|
||||
/// Lê o modo (`managed`/`monitor`) via `/sys/class/net/<if>/type` + `uevent`
|
||||
/// não é suficiente (type é sempre ARPHRD_ETHER para Wi-Fi); a forma estável
|
||||
/// sem libnl é delegar a leitura real ao `iw dev <if> info` quando disponível,
|
||||
/// mantendo aqui um melhor-esforço local para não depender de root.
|
||||
fn read_wireless_mode(name: &str) -> Mode {
|
||||
match crate::netlink::iw_dev_type(name) {
|
||||
Some(Mode::Monitor) => Mode::Monitor,
|
||||
Some(Mode::Managed) => Mode::Managed,
|
||||
_ => Mode::Unknown,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! `sabia-core`: tudo que fala com o rádio Wi-Fi, interpreta quadros 802.11
|
||||
//! e mantém a trilha de auditoria. A CLI e a interface gráfica são só
|
||||
//! consumidoras disto — a parte complexa (netlink, radiotap, parsing de
|
||||
//! frame) fica inteira aqui dentro.
|
||||
|
||||
pub mod audit;
|
||||
pub mod dot11;
|
||||
pub mod error;
|
||||
pub mod iface;
|
||||
pub mod netlink;
|
||||
pub mod pcapwriter;
|
||||
pub mod radiotap;
|
||||
pub mod scan;
|
||||
pub mod update;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod capture;
|
||||
|
||||
pub use error::{Result, SabiaError};
|
||||
@@ -0,0 +1,95 @@
|
||||
//! Controle de modo (managed/monitor) e canal via `iw`.
|
||||
//!
|
||||
//! Isso é uma ponte deliberada: o protocolo real é nl80211 por netlink, e
|
||||
//! trocar este módulo por uma implementação nativa (sem depender do binário
|
||||
//! `iw` instalado) é o próximo passo natural. Por ora, `iw` é amplamente
|
||||
//! disponível, testado e evita reimplementar genl/nl80211 antes de validar
|
||||
//! o resto do pipeline com o adaptador real.
|
||||
|
||||
use crate::error::{Result, SabiaError};
|
||||
use crate::iface::Mode;
|
||||
use std::process::Command;
|
||||
|
||||
fn run(cmd: &str, args: &[&str]) -> Result<String> {
|
||||
let out = Command::new(cmd).args(args).output().map_err(|e| SabiaError::CommandFailed {
|
||||
cmd: format!("{cmd} {}", args.join(" ")),
|
||||
detail: e.to_string(),
|
||||
})?;
|
||||
if !out.status.success() {
|
||||
let detail = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||
let detail = if detail.is_empty() {
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
} else {
|
||||
detail
|
||||
};
|
||||
if detail.contains("Operation not permitted") || detail.contains("nl80211 not found") {
|
||||
return Err(SabiaError::PermissionDenied);
|
||||
}
|
||||
return Err(SabiaError::CommandFailed {
|
||||
cmd: format!("{cmd} {}", args.join(" ")),
|
||||
detail,
|
||||
});
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
}
|
||||
|
||||
pub fn iw_dev_type(iface: &str) -> Option<Mode> {
|
||||
let out = run("iw", &["dev", iface, "info"]).ok()?;
|
||||
for line in out.lines() {
|
||||
let line = line.trim();
|
||||
if let Some(t) = line.strip_prefix("type ") {
|
||||
return Some(match t.trim() {
|
||||
"monitor" => Mode::Monitor,
|
||||
"managed" => Mode::Managed,
|
||||
_ => Mode::Unknown,
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Lista (canal, frequência) suportados pelo phy da interface.
|
||||
pub fn supported_channels(iface: &str) -> Result<Vec<u32>> {
|
||||
let out = run("iw", &["dev", iface, "info"])?;
|
||||
let phy = out
|
||||
.lines()
|
||||
.find_map(|l| l.trim().strip_prefix("wiphy "))
|
||||
.map(|n| format!("phy{n}"))
|
||||
.ok_or_else(|| SabiaError::CommandFailed {
|
||||
cmd: "iw dev info".into(),
|
||||
detail: "wiphy não encontrado na saída".into(),
|
||||
})?;
|
||||
let info = run("iw", &["phy", &phy, "info"])?;
|
||||
let mut channels = Vec::new();
|
||||
for line in info.lines() {
|
||||
let line = line.trim();
|
||||
// formato: "* 2412 MHz [1] (20.0 dBm)"
|
||||
if let Some(rest) = line.strip_prefix("* ") {
|
||||
if let Some(start) = rest.find('[') {
|
||||
if let Some(end) = rest[start..].find(']') {
|
||||
if let Ok(ch) = rest[start + 1..start + end].parse::<u32>() {
|
||||
channels.push(ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
channels.sort_unstable();
|
||||
channels.dedup();
|
||||
Ok(channels)
|
||||
}
|
||||
|
||||
/// Põe a interface em modo monitor. Requer `CAP_NET_ADMIN` (normalmente root).
|
||||
pub fn set_monitor_mode(iface: &str, enable: bool) -> Result<()> {
|
||||
run("ip", &["link", "set", iface, "down"])?;
|
||||
let target = if enable { "monitor" } else { "managed" };
|
||||
run("iw", &["dev", iface, "set", "type", target])?;
|
||||
run("ip", &["link", "set", iface, "up"])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Troca o canal atual da interface (precisa já estar em modo monitor).
|
||||
pub fn set_channel(iface: &str, channel: u32) -> Result<()> {
|
||||
run("iw", &["dev", iface, "set", "channel", &channel.to_string()])?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//! Escritor de arquivos `.pcap` (formato clássico, link-type 127 =
|
||||
//! IEEE802_11_RADIOTAP) para exportar capturas — compatível com
|
||||
//! Wireshark, aircrack-ng e qualquer outra ferramenta do ecossistema.
|
||||
|
||||
use crate::error::Result;
|
||||
use std::fs::File;
|
||||
use std::io::{BufWriter, Write};
|
||||
use std::path::Path;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
const LINKTYPE_IEEE802_11_RADIOTAP: u32 = 127;
|
||||
|
||||
pub struct PcapWriter {
|
||||
out: BufWriter<File>,
|
||||
}
|
||||
|
||||
impl PcapWriter {
|
||||
pub fn create(path: impl AsRef<Path>) -> Result<Self> {
|
||||
let file = File::create(path)?;
|
||||
let mut out = BufWriter::new(file);
|
||||
// Cabeçalho global do pcap clássico.
|
||||
out.write_all(&0xa1b2c3d4u32.to_le_bytes())?; // magic
|
||||
out.write_all(&2u16.to_le_bytes())?; // version major
|
||||
out.write_all(&4u16.to_le_bytes())?; // version minor
|
||||
out.write_all(&0i32.to_le_bytes())?; // thiszone
|
||||
out.write_all(&0u32.to_le_bytes())?; // sigfigs
|
||||
out.write_all(&65535u32.to_le_bytes())?; // snaplen
|
||||
out.write_all(&LINKTYPE_IEEE802_11_RADIOTAP.to_le_bytes())?;
|
||||
Ok(Self { out })
|
||||
}
|
||||
|
||||
/// Grava um quadro crú (radiotap + 802.11), com timestamp de agora.
|
||||
pub fn write_frame(&mut self, raw: &[u8]) -> Result<()> {
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default();
|
||||
self.out.write_all(&(now.as_secs() as u32).to_le_bytes())?;
|
||||
self.out.write_all(&(now.subsec_micros()).to_le_bytes())?;
|
||||
self.out.write_all(&(raw.len() as u32).to_le_bytes())?; // caplen
|
||||
self.out.write_all(&(raw.len() as u32).to_le_bytes())?; // len original
|
||||
self.out.write_all(raw)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn flush(&mut self) -> Result<()> {
|
||||
self.out.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! Parser mínimo de cabeçalho Radiotap — só os campos que usamos
|
||||
//! (canal/frequência e força de sinal). Especificação:
|
||||
//! <https://www.radiotap.org/>
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct RadiotapInfo {
|
||||
pub freq_mhz: Option<u16>,
|
||||
pub signal_dbm: Option<i8>,
|
||||
pub header_len: usize,
|
||||
}
|
||||
|
||||
// Índices de bit no bitmap "present" que nos interessam.
|
||||
const BIT_FLAGS: u32 = 1 << 1;
|
||||
const BIT_RATE: u32 = 1 << 2;
|
||||
const BIT_CHANNEL: u32 = 1 << 3;
|
||||
const BIT_ANTSIGNAL: u32 = 1 << 5;
|
||||
|
||||
/// Retorna as infos extraídas e quantos bytes o cabeçalho ocupa (para o
|
||||
/// chamador saber onde o frame 802.11 propriamente começa).
|
||||
pub fn parse(buf: &[u8]) -> Option<RadiotapInfo> {
|
||||
if buf.len() < 8 || buf[0] != 0 {
|
||||
return None; // version deve ser 0
|
||||
}
|
||||
let header_len = u16::from_le_bytes([buf[2], buf[3]]) as usize;
|
||||
if header_len > buf.len() || header_len < 8 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Pode haver múltiplas palavras de "present" (bit 31 = continua).
|
||||
let mut present_words = Vec::new();
|
||||
let mut offset = 4;
|
||||
loop {
|
||||
if offset + 4 > buf.len() {
|
||||
return None;
|
||||
}
|
||||
let word = u32::from_le_bytes([buf[offset], buf[offset + 1], buf[offset + 2], buf[offset + 3]]);
|
||||
present_words.push(word);
|
||||
offset += 4;
|
||||
if word & (1 << 31) == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let present = present_words[0]; // só o primeiro conjunto de campos padrão nos interessa
|
||||
|
||||
let mut info = RadiotapInfo { header_len, ..Default::default() };
|
||||
let mut cursor = offset;
|
||||
|
||||
// TSFT (u64, align 8) viria antes de Flags; para o nosso subconjunto de
|
||||
// campos simplificamos assumindo captura via mac80211 (ordem padrão:
|
||||
// TSFT, Flags, Rate, Channel, ..., AntSignal). Avançamos campo a campo
|
||||
// respeitando alinhamento, sem travar se algum bit não estiver setado.
|
||||
macro_rules! align {
|
||||
($a:expr) => {{
|
||||
let rem = cursor % $a;
|
||||
if rem != 0 {
|
||||
cursor += $a - rem;
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
if present & (1 << 0) != 0 {
|
||||
align!(8);
|
||||
cursor += 8; // TSFT
|
||||
}
|
||||
if present & BIT_FLAGS != 0 {
|
||||
align!(1);
|
||||
cursor += 1;
|
||||
}
|
||||
if present & BIT_RATE != 0 {
|
||||
align!(1);
|
||||
cursor += 1;
|
||||
}
|
||||
if present & BIT_CHANNEL != 0 {
|
||||
align!(2);
|
||||
if cursor + 4 <= buf.len() {
|
||||
let freq = u16::from_le_bytes([buf[cursor], buf[cursor + 1]]);
|
||||
info.freq_mhz = Some(freq);
|
||||
}
|
||||
cursor += 4;
|
||||
}
|
||||
// FHSS (bit 4) — raro, ignorado.
|
||||
if present & (1 << 4) != 0 {
|
||||
align!(2);
|
||||
cursor += 2;
|
||||
}
|
||||
if present & BIT_ANTSIGNAL != 0 {
|
||||
align!(1);
|
||||
if cursor < buf.len() {
|
||||
info.signal_dbm = Some(buf[cursor] as i8);
|
||||
}
|
||||
}
|
||||
|
||||
Some(info)
|
||||
}
|
||||
|
||||
pub fn freq_to_channel(freq_mhz: u16) -> Option<u32> {
|
||||
match freq_mhz {
|
||||
2412..=2472 => Some(((freq_mhz - 2412) / 5 + 1) as u32),
|
||||
2484 => Some(14),
|
||||
5000..=5900 => Some(((freq_mhz - 5000) / 5) as u32),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Agregação de quadros capturados em uma tabela de APs e clientes vistos,
|
||||
//! no espírito do `airodump-ng` — mas como uma struct reaproveitável tanto
|
||||
//! pela CLI quanto pela interface gráfica.
|
||||
|
||||
use crate::dot11::{self, FrameKind, Security};
|
||||
use crate::radiotap;
|
||||
use std::collections::HashMap;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct AccessPoint {
|
||||
pub bssid: String,
|
||||
pub ssid: Option<String>,
|
||||
pub channel: Option<u32>,
|
||||
pub security: Option<String>,
|
||||
pub last_signal_dbm: Option<i8>,
|
||||
pub beacons_seen: u64,
|
||||
pub clients: Vec<String>,
|
||||
pub last_seen_unix: u64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ScanTable {
|
||||
aps: HashMap<String, AccessPoint>,
|
||||
}
|
||||
|
||||
impl ScanTable {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Processa um quadro radiotap+802.11 crú, atualizando a tabela.
|
||||
/// Retorna `true` se o quadro continha um pacote EAPOL (indício de
|
||||
/// handshake WPA em andamento — útil para o chamador decidir salvar em
|
||||
/// pcap).
|
||||
pub fn ingest(&mut self, raw: &[u8]) -> bool {
|
||||
let rt = match radiotap::parse(raw) {
|
||||
Some(rt) => rt,
|
||||
None => return false,
|
||||
};
|
||||
if rt.header_len >= raw.len() {
|
||||
return false;
|
||||
}
|
||||
let dot11_buf = &raw[rt.header_len..];
|
||||
let frame = match dot11::parse(dot11_buf) {
|
||||
Some(f) => f,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
|
||||
|
||||
match frame.kind {
|
||||
FrameKind::Beacon | FrameKind::ProbeResponse => {
|
||||
if let Some(bssid) = frame.bssid_str() {
|
||||
let info = frame.management_info();
|
||||
let channel =
|
||||
info.channel_ie.or_else(|| rt.freq_mhz.and_then(radiotap::freq_to_channel));
|
||||
let entry = self.aps.entry(bssid.clone()).or_insert_with(|| AccessPoint {
|
||||
bssid,
|
||||
ssid: None,
|
||||
channel: None,
|
||||
security: None,
|
||||
last_signal_dbm: None,
|
||||
beacons_seen: 0,
|
||||
clients: Vec::new(),
|
||||
last_seen_unix: now,
|
||||
});
|
||||
if let Some(ssid) = info.ssid {
|
||||
entry.ssid = Some(ssid);
|
||||
}
|
||||
if channel.is_some() {
|
||||
entry.channel = channel;
|
||||
}
|
||||
entry.security = info.security.map(security_label);
|
||||
entry.last_signal_dbm = rt.signal_dbm;
|
||||
entry.beacons_seen += 1;
|
||||
entry.last_seen_unix = now;
|
||||
}
|
||||
}
|
||||
FrameKind::Data => {
|
||||
// Associa o cliente (endereço de origem) ao BSSID observado,
|
||||
// quando a direção do quadro indicar que vem de uma estação.
|
||||
if let (Some(bssid), Some(src)) = (frame.bssid_str(), frame.src_str()) {
|
||||
if let Some(ap) = self.aps.get_mut(&bssid) {
|
||||
if src != bssid && !ap.clients.contains(&src) {
|
||||
ap.clients.push(src);
|
||||
}
|
||||
}
|
||||
}
|
||||
return dot11::is_eapol(frame.body);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn access_points(&self) -> Vec<AccessPoint> {
|
||||
let mut v: Vec<_> = self.aps.values().cloned().collect();
|
||||
v.sort_by(|a, b| b.beacons_seen.cmp(&a.beacons_seen));
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
fn security_label(s: Security) -> String {
|
||||
match s {
|
||||
Security::Open => "Aberta".into(),
|
||||
Security::Wep => "WEP".into(),
|
||||
Security::Wpa => "WPA".into(),
|
||||
Security::Wpa2 => "WPA2".into(),
|
||||
Security::Wpa3 => "WPA3".into(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Auto-atualização simples: verifica a última release no GitHub, baixa o
|
||||
//! binário certo para a plataforma atual, confere o checksum e troca o
|
||||
//! executável em uso — sem o usuário precisar baixar nada manualmente.
|
||||
//!
|
||||
//! Usa `curl` (quase universal em Linux/macOS) em vez de trazer uma stack
|
||||
//! HTTP/TLS inteira como dependência só para isso.
|
||||
|
||||
use crate::error::{Result, SabiaError};
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
const REPO: &str = "lucasrguerra/sabia";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Release {
|
||||
tag_name: String,
|
||||
assets: Vec<Asset>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Asset {
|
||||
name: String,
|
||||
browser_download_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct UpdateStatus {
|
||||
pub current_version: String,
|
||||
pub latest_version: Option<String>,
|
||||
pub update_available: bool,
|
||||
}
|
||||
|
||||
fn curl_text(url: &str) -> Result<String> {
|
||||
let out = Command::new("curl")
|
||||
.args(["-fsSL", "-H", "User-Agent: sabia-updater", url])
|
||||
.output()
|
||||
.map_err(|e| SabiaError::CommandFailed { cmd: format!("curl {url}"), detail: e.to_string() })?;
|
||||
if !out.status.success() {
|
||||
return Err(SabiaError::CommandFailed {
|
||||
cmd: format!("curl {url}"),
|
||||
detail: String::from_utf8_lossy(&out.stderr).into_owned(),
|
||||
});
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
}
|
||||
|
||||
fn curl_download(url: &str, dest: &PathBuf) -> Result<()> {
|
||||
let status = Command::new("curl")
|
||||
.args(["-fsSL", "-H", "User-Agent: sabia-updater", "-o"])
|
||||
.arg(dest)
|
||||
.arg(url)
|
||||
.status()
|
||||
.map_err(|e| SabiaError::CommandFailed { cmd: format!("curl {url}"), detail: e.to_string() })?;
|
||||
if !status.success() {
|
||||
return Err(SabiaError::CommandFailed { cmd: format!("curl {url}"), detail: "download falhou".into() });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn asset_name_for_platform() -> String {
|
||||
let os = if cfg!(target_os = "linux") {
|
||||
"linux"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"macos"
|
||||
} else {
|
||||
"windows"
|
||||
};
|
||||
let arch = if cfg!(target_arch = "x86_64") { "x86_64" } else { "aarch64" };
|
||||
format!("sabia-{os}-{arch}.tar.gz")
|
||||
}
|
||||
|
||||
/// Consulta a última release publicada e compara com a versão atual
|
||||
/// (`CARGO_PKG_VERSION` do binário chamador).
|
||||
pub fn check(current_version: &str) -> Result<UpdateStatus> {
|
||||
let url = format!("https://api.github.com/repos/{REPO}/releases/latest");
|
||||
let body = match curl_text(&url) {
|
||||
Ok(b) => b,
|
||||
Err(_) => {
|
||||
// Sem rede, ou repositório ainda sem releases publicadas: não é
|
||||
// um erro fatal, só significa "sem atualização disponível".
|
||||
return Ok(UpdateStatus {
|
||||
current_version: current_version.to_string(),
|
||||
latest_version: None,
|
||||
update_available: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
let release: Release = serde_json::from_str(&body)?;
|
||||
let latest = release.tag_name.trim_start_matches('v').to_string();
|
||||
let update_available = latest != current_version;
|
||||
Ok(UpdateStatus {
|
||||
current_version: current_version.to_string(),
|
||||
latest_version: Some(latest),
|
||||
update_available,
|
||||
})
|
||||
}
|
||||
|
||||
/// Baixa e aplica a atualização mais recente, substituindo o binário atual.
|
||||
/// Retorna a versão instalada.
|
||||
pub fn apply(current_exe: &PathBuf) -> Result<String> {
|
||||
let url = format!("https://api.github.com/repos/{REPO}/releases/latest");
|
||||
let body = curl_text(&url)?;
|
||||
let release: Release = serde_json::from_str(&body)?;
|
||||
|
||||
let wanted = asset_name_for_platform();
|
||||
let asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.name == wanted)
|
||||
.ok_or_else(|| SabiaError::CommandFailed {
|
||||
cmd: "update".into(),
|
||||
detail: format!("nenhum asset `{wanted}` na release {}", release.tag_name),
|
||||
})?;
|
||||
let checksum_asset = release.assets.iter().find(|a| a.name == format!("{}.sha256", asset.name));
|
||||
|
||||
let tmp_dir = std::env::temp_dir().join("sabia-update");
|
||||
fs::create_dir_all(&tmp_dir)?;
|
||||
let archive_path = tmp_dir.join(&asset.name);
|
||||
curl_download(&asset.browser_download_url, &archive_path)?;
|
||||
|
||||
if let Some(checksum_asset) = checksum_asset {
|
||||
let expected = curl_text(&checksum_asset.browser_download_url)?;
|
||||
let expected_hash = expected.split_whitespace().next().unwrap_or("").to_lowercase();
|
||||
let data = fs::read(&archive_path)?;
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&data);
|
||||
let actual_hash = format!("{:x}", hasher.finalize());
|
||||
if actual_hash != expected_hash {
|
||||
return Err(SabiaError::CommandFailed {
|
||||
cmd: "update".into(),
|
||||
detail: "checksum SHA-256 não confere — atualização descartada".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Extrai o tar.gz e substitui o binário atual atomicamente.
|
||||
let extract_dir = tmp_dir.join("extracted");
|
||||
let _ = fs::remove_dir_all(&extract_dir);
|
||||
fs::create_dir_all(&extract_dir)?;
|
||||
let status = Command::new("tar").args(["xzf"]).arg(&archive_path).arg("-C").arg(&extract_dir).status()?;
|
||||
if !status.success() {
|
||||
return Err(SabiaError::CommandFailed { cmd: "tar xzf".into(), detail: "extração falhou".into() });
|
||||
}
|
||||
|
||||
let new_binary = find_binary(&extract_dir)?;
|
||||
let backup = current_exe.with_extension("bak");
|
||||
let _ = fs::remove_file(&backup);
|
||||
fs::rename(current_exe, &backup)?;
|
||||
fs::copy(&new_binary, current_exe)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = fs::metadata(current_exe)?.permissions();
|
||||
perms.set_mode(0o755);
|
||||
fs::set_permissions(current_exe, perms)?;
|
||||
}
|
||||
|
||||
Ok(release.tag_name)
|
||||
}
|
||||
|
||||
fn find_binary(dir: &PathBuf) -> Result<PathBuf> {
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name();
|
||||
if name == "sabia" || name == "sabia.exe" {
|
||||
return Ok(entry.path());
|
||||
}
|
||||
}
|
||||
Err(SabiaError::CommandFailed { cmd: "update".into(), detail: "binário não encontrado no pacote baixado".into() })
|
||||
}
|
||||
Reference in new issue
Block a user