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

+21
View File
@@ -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.
+114
View File
@@ -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)
}
}
+122
View File
@@ -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
}
+183
View File
@@ -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
}
+27
View File
@@ -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>;
+110
View File
@@ -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,
}
}
+19
View File
@@ -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};
+95
View File
@@ -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(())
}
+47
View File
@@ -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(())
}
}
+103
View File
@@ -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,
}
}
+112
View File
@@ -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(),
}
}
+173
View File
@@ -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() })
}