App desktop (Tauri), GitHub Actions de CI/release, elevação automática

- Move a UI de crates/sabia-cli/src/web/ para apps/gui/ui/: fonte única
  servida tanto pelo `sabia ui` quanto pelo app desktop.
- sabia-core::engine::Engine centraliza sessões de scan/monitor/update —
  usado pelo servidor local da CLI e pelo app Tauri, sem duplicar lógica.
- apps/gui/src-tauri: app desktop Tauri 2, mesma interface, comandos IPC
  espelhando a API HTTP. Pede elevação de administrador/root
  automaticamente ao abrir (modo monitor e captura crua exigem):
  manifesto requireAdministrator no Windows (UAC antes mesmo do app
  abrir), pkexec no Linux e osascript no macOS, com reinício automático
  e aviso + botão de nova tentativa na interface se for negada.
- Ícone gerado a partir do logo (apps/gui/src-tauri/icons/source.png).
- .github/workflows/ci.yml: fmt, clippy, testes (Linux/Windows/macOS),
  auditoria de dependências, compilação do app desktop.
- .github/workflows/release.yml: ao empurrar uma tag vX.Y.Z, builda a
  CLI (Linux x86_64/aarch64, macOS x86_64/aarch64, Windows x86_64) e o
  app desktop pras mesmas plataformas, publica tudo numa release do
  GitHub com checksums SHA-256.
- sabia-core::update: corrige o nome do asset esperado (casava pelo
  nome fixo, mas o release empacota com a versão no nome) e a extração
  (tar.gz no Linux/macOS, zip no Windows).
- LICENSE-MIT, LICENSE-APACHE, CHANGELOG.md com a entrada 0.1.0.
- cargo fmt + clippy (-D warnings) limpos em todo o workspace.
This commit is contained in:
Lucas committed 2026-10-23 08:56:08 -03:00
1 parent fd3ef5240e
commit df27974359
29 files changed
+1268 -191

No files matched your search

+4 -1
View File
@@ -75,7 +75,10 @@ impl AuditLog {
hash: hash.clone(),
};
let mut file = OpenOptions::new().create(true).append(true).open(&self.path)?;
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)?;
writeln!(file, "{}", serde_json::to_string(&entry)?)?;
self.last_hash = hash;
+1 -1
View File
@@ -38,7 +38,7 @@ impl RawCapture {
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_protocol = ETH_P_ALL.to_be();
sll.sll_ifindex = ifindex;
let ret = unsafe {
+28 -9
View File
@@ -30,7 +30,10 @@ pub struct ManagementInfo {
}
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])
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.
@@ -66,7 +69,14 @@ pub fn parse(buf: &[u8]) -> Option<Frame<'_>> {
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 });
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()?;
@@ -77,9 +87,20 @@ pub fn parse(buf: &[u8]) -> Option<Frame<'_>> {
// 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 };
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 })
Some(Frame {
kind,
bssid: Some(bssid),
src: Some(src),
dst: Some(dst),
body: buf,
ie_offset,
})
}
impl<'a> Frame<'a> {
@@ -137,11 +158,9 @@ impl<'a> Frame<'a> {
}
}
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;
}
// vendor-specific; OUI 00:50:F2 tipo 1 = WPA1
221 if len >= 4 && val[0..3] == [0x00, 0x50, 0xF2] && val[3] == 1 => {
wpa1_present = true;
}
_ => {}
}
+139
View File
@@ -0,0 +1,139 @@
//! Ponte única entre o núcleo e as duas interfaces (servidor local da CLI e
//! o app desktop Tauri): ambos chamam exatamente os mesmos métodos, então
//! qualquer coisa que funcione em um funciona idêntico no outro.
use crate::audit::AuditLog;
use crate::error::Result;
use crate::iface::{self, Interface};
use crate::netlink;
use crate::scan::{AccessPoint, ScanTable};
use crate::update::{self, UpdateStatus};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
struct Session {
table: Arc<Mutex<ScanTable>>,
running: Arc<AtomicBool>,
}
pub struct Engine {
sessions: Mutex<HashMap<String, Session>>,
audit_path: PathBuf,
}
impl Default for Engine {
fn default() -> Self {
Self::new()
}
}
impl Engine {
pub fn new() -> Self {
Self {
sessions: Mutex::new(HashMap::new()),
audit_path: PathBuf::from(".sabia-audit.log"),
}
}
pub fn version(&self) -> &'static str {
env!("CARGO_PKG_VERSION")
}
pub fn list_ifaces(&self) -> Result<Vec<Interface>> {
iface::list_wireless()
}
pub fn set_monitor(&self, name: &str, enable: bool) -> Result<()> {
netlink::set_monitor_mode(name, enable)?;
if let Ok(mut log) = AuditLog::open(&self.audit_path) {
let _ = log.record("monitor_mode", name, &format!("{{\"enable\":{enable}}}"));
}
Ok(())
}
#[cfg(target_os = "linux")]
pub fn start_scan(&self, name: &str) -> Result<()> {
use crate::capture::RawCapture;
use std::thread;
use std::time::Duration;
let mut sessions = self.sessions.lock().unwrap();
if sessions.contains_key(name) {
return Ok(());
}
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.to_string();
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]);
}
}
});
if let Ok(mut log) = AuditLog::open(&self.audit_path) {
let _ = log.record("scan_start", name, "{}");
}
sessions.insert(name.to_string(), Session { table, running });
Ok(())
}
/// Captura crua (AF_PACKET) só existe no Linux (ver `crate::capture`).
/// Em macOS/Windows o app compila e roda, mas a captura ainda não tem
/// backend nativo — próximo passo natural do projeto.
#[cfg(not(target_os = "linux"))]
pub fn start_scan(&self, _name: &str) -> Result<()> {
Err(crate::error::SabiaError::UnsupportedPlatform)
}
pub fn stop_scan(&self, name: &str) {
let mut sessions = self.sessions.lock().unwrap();
if let Some(s) = sessions.remove(name) {
s.running.store(false, Ordering::SeqCst);
if let Ok(mut log) = AuditLog::open(&self.audit_path) {
let _ = log.record("scan_stop", name, "{}");
}
}
}
pub fn scan_results(&self, name: &str) -> Vec<AccessPoint> {
let sessions = self.sessions.lock().unwrap();
match sessions.get(name) {
Some(s) => s.table.lock().unwrap().access_points(),
None => Vec::new(),
}
}
pub fn update_check(&self) -> Result<UpdateStatus> {
update::check(self.version())
}
pub fn update_apply(&self) -> Result<String> {
let exe = std::env::current_exe()?;
update::apply(&exe)
}
}
+11 -2
View File
@@ -45,7 +45,10 @@ pub fn list_all() -> Result<Vec<Interface>> {
/// 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())
Ok(list_all()?
.into_iter()
.filter(|i| i.phy.is_some())
.collect())
}
pub fn get(name: &str) -> Result<Interface> {
@@ -94,7 +97,13 @@ fn read_interface(name: &str) -> Result<Interface> {
Mode::Unknown
};
Ok(Interface { name: name.to_string(), mac, phy, mode, up })
Ok(Interface {
name: name.to_string(),
mac,
phy,
mode,
up,
})
}
/// Lê o modo (`managed`/`monitor`) via `/sys/class/net/<if>/type` + `uevent`
+1
View File
@@ -5,6 +5,7 @@
pub mod audit;
pub mod dot11;
pub mod engine;
pub mod error;
pub mod iface;
pub mod netlink;
+11 -5
View File
@@ -11,10 +11,13 @@ 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(),
})?;
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() {
@@ -90,6 +93,9 @@ pub fn set_monitor_mode(iface: &str, enable: bool) -> Result<()> {
/// 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()])?;
run(
"iw",
&["dev", iface, "set", "channel", &channel.to_string()],
)?;
Ok(())
}
+3 -1
View File
@@ -31,7 +31,9 @@ impl PcapWriter {
/// 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();
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
+12 -9
View File
@@ -33,7 +33,12 @@ pub fn parse(buf: &[u8]) -> Option<RadiotapInfo> {
if offset + 4 > buf.len() {
return None;
}
let word = u32::from_le_bytes([buf[offset], buf[offset + 1], buf[offset + 2], buf[offset + 3]]);
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 {
@@ -42,7 +47,10 @@ pub fn parse(buf: &[u8]) -> Option<RadiotapInfo> {
}
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 info = RadiotapInfo {
header_len,
..Default::default()
};
let mut cursor = offset;
// TSFT (u64, align 8) viria antes de Flags; para o nosso subconjunto de
@@ -63,11 +71,9 @@ pub fn parse(buf: &[u8]) -> Option<RadiotapInfo> {
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 {
@@ -83,11 +89,8 @@ pub fn parse(buf: &[u8]) -> Option<RadiotapInfo> {
align!(2);
cursor += 2;
}
if present & BIT_ANTSIGNAL != 0 {
align!(1);
if cursor < buf.len() {
info.signal_dbm = Some(buf[cursor] as i8);
}
if present & BIT_ANTSIGNAL != 0 && cursor < buf.len() {
info.signal_dbm = Some(buf[cursor] as i8);
}
Some(info)
+21 -14
View File
@@ -47,24 +47,31 @@ impl ScanTable {
None => return false,
};
let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
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,
});
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);
}
@@ -96,7 +103,7 @@ impl ScanTable {
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.sort_by_key(|a| std::cmp::Reverse(a.beacons_seen));
v
}
}
+71 -17
View File
@@ -37,7 +37,10 @@ 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() })?;
.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}"),
@@ -53,14 +56,23 @@ fn curl_download(url: &str, dest: &PathBuf) -> Result<()> {
.arg(dest)
.arg(url)
.status()
.map_err(|e| SabiaError::CommandFailed { cmd: format!("curl {url}"), detail: e.to_string() })?;
.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() });
return Err(SabiaError::CommandFailed {
cmd: format!("curl {url}"),
detail: "download falhou".into(),
});
}
Ok(())
}
fn asset_name_for_platform() -> String {
/// Sufixo que identifica o pacote certo entre os assets da release — o
/// nome completo inclui a tag (ex.: `sabia-v0.1.0-linux-x86_64.tar.gz`),
/// então casamos pelo fim em vez do nome inteiro.
fn asset_suffix_for_platform() -> String {
let os = if cfg!(target_os = "linux") {
"linux"
} else if cfg!(target_os = "macos") {
@@ -68,8 +80,13 @@ fn asset_name_for_platform() -> String {
} else {
"windows"
};
let arch = if cfg!(target_arch = "x86_64") { "x86_64" } else { "aarch64" };
format!("sabia-{os}-{arch}.tar.gz")
let arch = if cfg!(target_arch = "x86_64") {
"x86_64"
} else {
"aarch64"
};
let ext = if cfg!(windows) { "zip" } else { "tar.gz" };
format!("-{os}-{arch}.{ext}")
}
/// Consulta a última release publicada e compara com a versão atual
@@ -105,16 +122,22 @@ pub fn apply(current_exe: &PathBuf) -> Result<String> {
let body = curl_text(&url)?;
let release: Release = serde_json::from_str(&body)?;
let wanted = asset_name_for_platform();
let suffix = asset_suffix_for_platform();
let asset = release
.assets
.iter()
.find(|a| a.name == wanted)
.find(|a| a.name.ends_with(&suffix))
.ok_or_else(|| SabiaError::CommandFailed {
cmd: "update".into(),
detail: format!("nenhum asset `{wanted}` na release {}", release.tag_name),
detail: format!(
"nenhum asset terminado em `{suffix}` na release {}",
release.tag_name
),
})?;
let checksum_asset = release.assets.iter().find(|a| a.name == format!("{}.sha256", asset.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)?;
@@ -123,7 +146,11 @@ pub fn apply(current_exe: &PathBuf) -> Result<String> {
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 expected_hash = expected
.split_whitespace()
.next()
.unwrap_or("")
.to_lowercase();
let data = fs::read(&archive_path)?;
let mut hasher = Sha256::new();
hasher.update(&data);
@@ -136,13 +163,24 @@ pub fn apply(current_exe: &PathBuf) -> Result<String> {
}
}
// Extrai o tar.gz e substitui o binário atual atomicamente.
// Extrai o pacote (tar.gz no Linux/macOS, zip no Windows — `tar xf` sem
// flag de compressão detecta os dois formatos nos `tar` modernos,
// incluindo o bsdtar que o Windows já traz por padrão) 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()?;
let status = Command::new("tar")
.arg("xf")
.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() });
return Err(SabiaError::CommandFailed {
cmd: "tar xf".into(),
detail: "extração falhou".into(),
});
}
let new_binary = find_binary(&extract_dir)?;
@@ -161,13 +199,29 @@ pub fn apply(current_exe: &PathBuf) -> Result<String> {
Ok(release.tag_name)
}
/// O binário vem dentro de uma subpasta (o CI empacota
/// `sabia-vX.Y.Z-<plataforma>/sabia`), então procura até um nível de
/// profundidade abaixo do diretório extraído.
fn find_binary(dir: &PathBuf) -> Result<PathBuf> {
fn is_binary(name: &std::ffi::OsStr) -> bool {
name == "sabia" || name == "sabia.exe"
}
for entry in fs::read_dir(dir)? {
let entry = entry?;
let name = entry.file_name();
if name == "sabia" || name == "sabia.exe" {
if is_binary(&entry.file_name()) {
return Ok(entry.path());
}
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
for inner in fs::read_dir(entry.path())? {
let inner = inner?;
if is_binary(&inner.file_name()) {
return Ok(inner.path());
}
}
}
}
Err(SabiaError::CommandFailed { cmd: "update".into(), detail: "binário não encontrado no pacote baixado".into() })
Err(SabiaError::CommandFailed {
cmd: "update".into(),
detail: "binário não encontrado no pacote baixado".into(),
})
}