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:
1 parent
fd3ef5240e
commit
df27974359
29 files changed
+1268
-191
No files matched your search
@@ -1,14 +1,20 @@
|
||||
mod web;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use sabia_core::{audit::AuditLog, capture::RawCapture, iface, netlink, pcapwriter::PcapWriter, scan::ScanTable};
|
||||
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)]
|
||||
#[command(
|
||||
name = "sabia",
|
||||
about = "Sabiá — auditoria e monitoramento de redes Wi-Fi",
|
||||
version
|
||||
)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
cmd: Cmd,
|
||||
@@ -52,7 +58,11 @@ enum Cmd {
|
||||
enum IfaceAction {
|
||||
List,
|
||||
/// Liga/desliga modo monitor numa interface Wi-Fi.
|
||||
Monitor { iface: String, #[arg(value_enum)] state: OnOff },
|
||||
Monitor {
|
||||
iface: String,
|
||||
#[arg(value_enum)]
|
||||
state: OnOff,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, clap::ValueEnum)]
|
||||
@@ -94,23 +104,31 @@ fn run(cli: Cli) -> sabia_core::Result<()> {
|
||||
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" });
|
||||
log.record("monitor_mode", &name, &format!("{{\"enable\": {enable}}}"))?;
|
||||
println!(
|
||||
"{name}: modo monitor {}",
|
||||
if enable { "ativado" } else { "desativado" }
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
Cmd::Scan { iface: name, channel, seconds, out } => {
|
||||
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:?}}}"))?;
|
||||
log.record(
|
||||
"scan_start",
|
||||
&name,
|
||||
&format!("{{\"channel\": {channel:?}}}"),
|
||||
)?;
|
||||
|
||||
let cap = RawCapture::open(&name)?;
|
||||
cap.set_read_timeout(Duration::from_millis(500))?;
|
||||
@@ -143,7 +161,10 @@ fn run(cli: Cli) -> sabia_core::Result<()> {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if channel.is_none() && !channels.is_empty() && last_hop.elapsed() > Duration::from_millis(300) {
|
||||
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();
|
||||
@@ -167,16 +188,25 @@ fn run(cli: Cli) -> sabia_core::Result<()> {
|
||||
w.flush()?;
|
||||
}
|
||||
|
||||
log.record("scan_stop", &name, &format!("{{\"aps_found\": {}}}", table.access_points().len()))?;
|
||||
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");
|
||||
println!(
|
||||
"\n{:<18} {:<24} {:<8} {:<8} {:<6} CLIENTES",
|
||||
"BSSID", "SSID", "CANAL", "SINAL", "SEG"
|
||||
);
|
||||
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.last_signal_dbm
|
||||
.map(|s| format!("{s}dBm"))
|
||||
.unwrap_or_default(),
|
||||
ap.security.unwrap_or_default(),
|
||||
ap.clients.len()
|
||||
);
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
:root {
|
||||
--bg: #f5f5f7;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #fafafc;
|
||||
--side: rgba(246, 246, 248, 0.82);
|
||||
--text: #1d1d1f;
|
||||
--text-2: #6e6e73;
|
||||
--text-3: #8e8e93;
|
||||
--line: rgba(0, 0, 0, 0.09);
|
||||
|
||||
/* "canto" — cobre quente, o peito do sabiá-laranjeira */
|
||||
--accent: #b45309;
|
||||
--accent-ink: #ffffff;
|
||||
--accent-soft: rgba(180, 83, 9, 0.11);
|
||||
/* "sinal" — ciano, a onda que o rádio enxerga */
|
||||
--signal: #0891b2;
|
||||
--signal-soft: rgba(8, 145, 178, 0.12);
|
||||
|
||||
--ok: #1a8f3c;
|
||||
--warn: #b26a00;
|
||||
--bad: #d12b2b;
|
||||
--ok-soft: rgba(26, 143, 60, 0.12);
|
||||
--warn-soft: rgba(178, 106, 0, 0.13);
|
||||
--bad-soft: rgba(209, 43, 43, 0.11);
|
||||
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 8px 24px rgba(0, 0, 0, 0.06);
|
||||
--radius: 16px;
|
||||
--font: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", "Segoe UI Variable", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
|
||||
--side-w: 280px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--bg: #000000;
|
||||
--surface: #1c1c1e;
|
||||
--surface-2: #232326;
|
||||
--side: rgba(28, 28, 30, 0.8);
|
||||
--text: #f5f5f7;
|
||||
--text-2: #a1a1a6;
|
||||
--text-3: #7d7d82;
|
||||
--line: rgba(255, 255, 255, 0.1);
|
||||
--accent: #f59e0b;
|
||||
--accent-ink: #1f1300;
|
||||
--accent-soft: rgba(245, 158, 11, 0.15);
|
||||
--signal: #22d3ee;
|
||||
--signal-soft: rgba(34, 211, 238, 0.14);
|
||||
--ok: #32d74b;
|
||||
--warn: #ffb340;
|
||||
--bad: #ff6961;
|
||||
--ok-soft: rgba(50, 215, 75, 0.15);
|
||||
--warn-soft: rgba(255, 179, 64, 0.15);
|
||||
--bad-soft: rgba(255, 105, 97, 0.15);
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #000000; --surface: #1c1c1e; --surface-2: #232326; --side: rgba(28, 28, 30, 0.8);
|
||||
--text: #f5f5f7; --text-2: #a1a1a6; --text-3: #7d7d82; --line: rgba(255, 255, 255, 0.1);
|
||||
--accent: #f59e0b; --accent-ink: #1f1300; --accent-soft: rgba(245, 158, 11, 0.15);
|
||||
--signal: #22d3ee; --signal-soft: rgba(34, 211, 238, 0.14);
|
||||
--ok: #32d74b; --warn: #ffb340; --bad: #ff6961;
|
||||
--ok-soft: rgba(50, 215, 75, 0.15); --warn-soft: rgba(255, 179, 64, 0.15); --bad-soft: rgba(255, 105, 97, 0.15);
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
:root { color-scheme: light dark; }
|
||||
:root[data-theme="light"] { color-scheme: light; }
|
||||
:root[data-theme="dark"] { color-scheme: dark; }
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
margin: 0; background: var(--bg); color: var(--text);
|
||||
font: 15px/1.45 var(--font);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
button { font: inherit; color: inherit; }
|
||||
:focus-visible { outline: 3px solid color-mix(in srgb, var(--signal) 55%, transparent); outline-offset: 2px; border-radius: 8px; }
|
||||
h1, h2, h3 { margin: 0; letter-spacing: -0.02em; }
|
||||
code, .mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* ------------------------------------------------------------ layout */
|
||||
.app { display: grid; grid-template-columns: var(--side-w) 1fr; height: 100vh; height: 100dvh; }
|
||||
.sidebar {
|
||||
background: var(--side);
|
||||
backdrop-filter: saturate(180%) blur(24px);
|
||||
-webkit-backdrop-filter: saturate(180%) blur(24px);
|
||||
border-right: 1px solid var(--line);
|
||||
display: flex; flex-direction: column; min-height: 0; overflow-y: auto;
|
||||
padding: 18px 12px 12px;
|
||||
}
|
||||
.brand { display: flex; gap: 10px; align-items: center; padding: 4px 10px 20px; }
|
||||
.brand img { width: 34px; height: 34px; }
|
||||
.brand strong { display: block; font-size: 17px; letter-spacing: -0.02em; }
|
||||
.brand small { color: var(--text-2); font-size: 12px; }
|
||||
|
||||
.side-title { padding: 14px 10px 6px; font-size: 11px; font-weight: 600; color: var(--text-3); text-transform: uppercase; letter-spacing: .07em; }
|
||||
.list { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; }
|
||||
.list:empty::after { content: attr(data-empty); display: block; padding: 6px 10px; color: var(--text-3); font-size: 13px; }
|
||||
.dev {
|
||||
width: 100%; text-align: left; border: 0; background: transparent; cursor: pointer;
|
||||
display: grid; grid-template-columns: 34px 1fr; gap: 10px; align-items: center;
|
||||
padding: 9px 10px; border-radius: 12px; transition: background .15s;
|
||||
}
|
||||
.dev:hover { background: color-mix(in srgb, var(--text) 6%, transparent); }
|
||||
.dev[aria-current="true"] { background: var(--accent-soft); }
|
||||
.dev .ico { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center; background: var(--surface); box-shadow: 0 0 0 1px var(--line); color: var(--text-2); }
|
||||
.dev[aria-current="true"] .ico { color: var(--accent); }
|
||||
.dev .t { display: block; font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.dev .s { display: block; font-size: 11.5px; color: var(--text-2); font-family: var(--mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.side-foot { margin-top: auto; padding: 14px 10px 4px; font-size: 11.5px; color: var(--text-3); display: flex; justify-content: space-between; gap: 8px; }
|
||||
.side-foot button { background: transparent; border: 0; color: var(--text-3); cursor: pointer; text-decoration: underline; text-underline-offset: 2px; }
|
||||
.side-foot button:hover { color: var(--text-2); }
|
||||
|
||||
.main { overflow-y: auto; min-width: 0; }
|
||||
.page { max-width: 1040px; margin: 0 auto; padding: 30px 36px 100px; animation: rise .35s cubic-bezier(.2, .8, .2, 1); }
|
||||
@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
||||
|
||||
/* -------------------------------------------------------- topo + linha de sinal */
|
||||
.topbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.topbar h1 { font-size: 21px; }
|
||||
.topbar .sub { color: var(--text-2); font-size: 13px; margin-top: 2px; }
|
||||
.wave { width: 100%; height: 34px; margin: 18px 0 26px; }
|
||||
.wave path { fill: none; stroke: var(--line); stroke-width: 1.6; }
|
||||
.wave path.live { stroke: var(--signal); opacity: 0; transition: opacity .25s; }
|
||||
.wave.scanning path.live { opacity: 1; }
|
||||
.wave.scanning path.flat { opacity: 0; }
|
||||
|
||||
/* ------------------------------------------------------------ aviso */
|
||||
.notice { display: flex; gap: 10px; background: var(--surface); border-radius: 12px; padding: 12px 14px; box-shadow: var(--shadow); font-size: 13px; color: var(--text-2); margin-bottom: 20px; align-items: flex-start; }
|
||||
.notice svg { flex: none; margin-top: 2px; color: var(--warn); }
|
||||
|
||||
/* ------------------------------------------------------------ controles */
|
||||
.controls { background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow); padding: 16px 18px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 22px; }
|
||||
.controls .group { display: flex; align-items: center; gap: 8px; }
|
||||
.pill { display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; background: color-mix(in srgb, var(--text) 8%, transparent); color: var(--text-2); }
|
||||
.pill.on { background: var(--signal-soft); color: var(--signal); }
|
||||
.pill .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||
.pill.on .dot { animation: pulse 1.6s ease-in-out infinite; }
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
|
||||
|
||||
.btn { border: 0; border-radius: 999px; padding: 9px 18px; font-weight: 600; font-size: 13.5px; cursor: pointer; background: color-mix(in srgb, var(--text) 9%, transparent); transition: transform .12s, filter .15s, background .15s; display: inline-flex; gap: 8px; align-items: center; }
|
||||
.btn:hover { background: color-mix(in srgb, var(--text) 14%, transparent); }
|
||||
.btn:active { transform: scale(.97); }
|
||||
.btn:disabled { opacity: .4; cursor: not-allowed; }
|
||||
.btn.primary { background: var(--accent); color: var(--accent-ink); }
|
||||
.btn.primary:hover { filter: brightness(1.06); }
|
||||
.btn.live { background: var(--bad); color: #fff; }
|
||||
.btn.live:hover { filter: brightness(1.06); }
|
||||
|
||||
/* ------------------------------------------------------------ tabela */
|
||||
.result-head { display: flex; align-items: baseline; justify-content: space-between; margin: 4px 2px 10px; }
|
||||
.result-head strong { font-size: 15px; }
|
||||
.result-head span { color: var(--text-3); font-size: 12.5px; }
|
||||
|
||||
.ap-card { background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
thead th { text-align: left; padding: 11px 14px; font-size: 10.5px; color: var(--text-3); text-transform: uppercase; letter-spacing: .06em; font-weight: 600; border-bottom: 1px solid var(--line); }
|
||||
tbody td { padding: 10px 14px; font-size: 13px; border-bottom: 1px solid var(--line); vertical-align: middle; }
|
||||
tbody tr:last-child td { border-bottom: 0; }
|
||||
tbody tr:hover { background: var(--surface-2); }
|
||||
td.bssid { font-family: var(--mono); font-size: 12px; color: var(--text-2); }
|
||||
td.ssid { font-weight: 600; }
|
||||
td.ssid em { color: var(--text-3); font-weight: 400; font-style: normal; }
|
||||
.sec-tag { font-size: 11.5px; font-weight: 600; padding: 2px 8px; border-radius: 999px; }
|
||||
.sec-aberta { background: var(--bad-soft); color: var(--bad); }
|
||||
.sec-wep { background: var(--warn-soft); color: var(--warn); }
|
||||
.sec-wpa, .sec-wpa2, .sec-wpa3 { background: var(--ok-soft); color: var(--ok); }
|
||||
.signal-bars { display: inline-flex; align-items: flex-end; gap: 1.5px; height: 14px; vertical-align: middle; margin-right: 6px; }
|
||||
.signal-bars i { width: 3px; background: var(--line); border-radius: 1px; display: block; }
|
||||
.signal-bars i.on { background: var(--signal); }
|
||||
|
||||
.empty-state { text-align: center; padding: 70px 20px; color: var(--text-2); }
|
||||
.empty-state svg { color: var(--text-3); margin-bottom: 14px; }
|
||||
.empty-state h2 { font-size: 17px; color: var(--text); margin-bottom: 6px; }
|
||||
.empty-state p { font-size: 13.5px; max-width: 360px; margin: 0 auto; }
|
||||
|
||||
/* ------------------------------------------------------------ responsivo */
|
||||
@media (max-width: 760px) {
|
||||
.app { grid-template-columns: 1fr; grid-template-rows: auto 1fr; }
|
||||
.sidebar { border-right: 0; border-bottom: 1px solid var(--line); flex-direction: row; align-items: center; overflow-x: auto; }
|
||||
.brand { padding: 4px 10px; }
|
||||
.side-title, .side-foot { display: none; }
|
||||
.list { display: flex; }
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
const params = new URLSearchParams(location.search);
|
||||
const token = params.get('token');
|
||||
|
||||
const els = {
|
||||
list: document.getElementById('ifaceList'),
|
||||
ifaceSub: document.getElementById('ifaceSub'),
|
||||
modePill: document.getElementById('modePill'),
|
||||
modeLabel: document.getElementById('modeLabel'),
|
||||
btnMonitor: document.getElementById('btnMonitor'),
|
||||
btnScan: document.getElementById('btnScan'),
|
||||
resultArea: document.getElementById('resultArea'),
|
||||
count: document.getElementById('count'),
|
||||
wave: document.getElementById('wave'),
|
||||
verInfo: document.getElementById('verInfo'),
|
||||
btnUpdate: document.getElementById('btnUpdate'),
|
||||
};
|
||||
|
||||
let ifaces = [];
|
||||
let current = null;
|
||||
let scanning = false;
|
||||
|
||||
const ICON_WIFI = `<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1"/></svg>`;
|
||||
|
||||
const ICON_EMPTY = `<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1"/></svg>`;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
function renderEmptyResult(message, hint) {
|
||||
els.resultArea.innerHTML = `<div class="ap-card empty-state">${ICON_EMPTY}<h2>${message}</h2><p>${hint}</p></div>`;
|
||||
}
|
||||
|
||||
function renderIfaceList() {
|
||||
els.list.innerHTML = '';
|
||||
for (const i of ifaces) {
|
||||
const li = document.createElement('li');
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'dev';
|
||||
btn.setAttribute('aria-current', String(i.name === current));
|
||||
btn.innerHTML = `<span class="ico">${ICON_WIFI}</span><span><span class="t">${i.name}</span><span class="s">${i.mac}</span></span>`;
|
||||
btn.addEventListener('click', () => selectIface(i.name));
|
||||
li.appendChild(btn);
|
||||
els.list.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
function selectIface(name) {
|
||||
current = name;
|
||||
scanning = false;
|
||||
renderIfaceList();
|
||||
refreshModeUi();
|
||||
renderEmptyResult('Pronto pra capturar', 'Ative o modo monitor e inicie a captura pra ver as redes no ar.');
|
||||
}
|
||||
|
||||
function refreshModeUi() {
|
||||
const iface = ifaces.find((i) => i.name === current);
|
||||
const on = !!iface && iface.mode === 'Monitor';
|
||||
els.ifaceSub.textContent = current ? `${current} · ${iface?.mac ?? ''}` : 'selecione uma interface';
|
||||
els.modeLabel.textContent = on ? 'modo monitor' : 'modo gerenciado';
|
||||
els.modePill.className = 'pill' + (on ? ' on' : '');
|
||||
els.btnMonitor.disabled = !current;
|
||||
els.btnMonitor.textContent = on ? 'desativar modo monitor' : 'ativar modo monitor';
|
||||
els.btnScan.disabled = !on;
|
||||
}
|
||||
|
||||
async function loadIfaces() {
|
||||
ifaces = await api('/api/ifaces');
|
||||
if (!current && ifaces.length) current = ifaces[0].name;
|
||||
renderIfaceList();
|
||||
refreshModeUi();
|
||||
if (current) renderEmptyResult('Pronto pra capturar', 'Ative o modo monitor e inicie a captura pra ver as redes no ar.');
|
||||
else renderEmptyResult('Nenhuma interface Wi-Fi', 'Conecte um adaptador compatível com modo monitor e recarregue.');
|
||||
}
|
||||
|
||||
els.btnMonitor.addEventListener('click', async () => {
|
||||
const iface = ifaces.find((i) => i.name === current);
|
||||
const enable = !(iface && iface.mode === 'Monitor');
|
||||
els.btnMonitor.disabled = true;
|
||||
const res = await api(`/api/monitor?iface=${encodeURIComponent(current)}&enable=${enable}`, { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
alert('Não foi possível trocar o modo: ' + (res.error || 'erro desconhecido'));
|
||||
}
|
||||
await loadIfaces();
|
||||
});
|
||||
|
||||
els.btnScan.addEventListener('click', async () => {
|
||||
if (!scanning) {
|
||||
await api(`/api/scan/start?iface=${encodeURIComponent(current)}`, { method: 'POST' });
|
||||
scanning = true;
|
||||
els.btnScan.textContent = 'parar captura';
|
||||
els.btnScan.classList.add('live');
|
||||
els.wave.classList.add('scanning');
|
||||
} else {
|
||||
await api(`/api/scan/stop?iface=${encodeURIComponent(current)}`, { method: 'POST' });
|
||||
scanning = false;
|
||||
els.btnScan.textContent = 'iniciar captura';
|
||||
els.btnScan.classList.remove('live');
|
||||
els.wave.classList.remove('scanning');
|
||||
}
|
||||
});
|
||||
|
||||
els.btnUpdate.addEventListener('click', async () => {
|
||||
els.btnUpdate.textContent = 'checando…';
|
||||
const status = await api('/api/update/check');
|
||||
els.btnUpdate.textContent = status.update_available
|
||||
? `atualizar para ${status.latest_version}`
|
||||
: 'já atualizado';
|
||||
});
|
||||
|
||||
function secClass(sec) {
|
||||
if (!sec) return '';
|
||||
return 'sec-' + sec.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
|
||||
}
|
||||
|
||||
function signalBars(dbm) {
|
||||
// dBm tipicamente entre -30 (ótimo) e -90 (péssimo)
|
||||
let level = 0;
|
||||
if (dbm != null) {
|
||||
if (dbm >= -55) level = 4;
|
||||
else if (dbm >= -65) level = 3;
|
||||
else if (dbm >= -75) level = 2;
|
||||
else level = 1;
|
||||
}
|
||||
const heights = [5, 8, 11, 14];
|
||||
let bars = '';
|
||||
for (let i = 0; i < 4; i++) {
|
||||
bars += `<i class="${i < level ? 'on' : ''}" style="height:${heights[i]}px"></i>`;
|
||||
}
|
||||
return `<span class="signal-bars">${bars}</span>`;
|
||||
}
|
||||
|
||||
function renderResults(aps) {
|
||||
els.count.textContent = aps.length;
|
||||
if (!aps.length) {
|
||||
renderEmptyResult('Escutando o ar…', 'Nenhuma rede vista ainda. Isso pode levar alguns segundos enquanto os canais são varridos.');
|
||||
return;
|
||||
}
|
||||
const rows = aps.map((ap) => `
|
||||
<tr>
|
||||
<td class="ssid">${ap.ssid ? escapeHtml(ap.ssid) : '<em>oculta</em>'}</td>
|
||||
<td class="bssid">${ap.bssid}</td>
|
||||
<td>${ap.channel ?? '—'}</td>
|
||||
<td>${signalBars(ap.last_signal_dbm)}${ap.last_signal_dbm != null ? ap.last_signal_dbm + ' dBm' : '—'}</td>
|
||||
<td><span class="sec-tag ${secClass(ap.security)}">${ap.security || '—'}</span></td>
|
||||
<td>${ap.clients.length}</td>
|
||||
</tr>`).join('');
|
||||
els.resultArea.innerHTML = `
|
||||
<div class="ap-card">
|
||||
<table>
|
||||
<thead><tr><th>Rede</th><th>BSSID</th><th>Canal</th><th>Sinal</th><th>Segurança</th><th>Clientes</th></tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
let waveT = 0;
|
||||
function animateWave() {
|
||||
waveT += 0.12;
|
||||
if (scanning) {
|
||||
let d = 'M0 17 ';
|
||||
for (let x = 0; x <= 600; x += 12) {
|
||||
const y = 17 + Math.sin(x * 0.045 + waveT) * 9 * Math.sin(waveT * 0.6 + x * 0.002);
|
||||
d += `L${x} ${y.toFixed(1)} `;
|
||||
}
|
||||
document.getElementById('waveLive').setAttribute('d', d);
|
||||
}
|
||||
requestAnimationFrame(animateWave);
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
if (scanning && current) {
|
||||
const aps = await api(`/api/scan?iface=${encodeURIComponent(current)}`);
|
||||
renderResults(aps);
|
||||
}
|
||||
setTimeout(poll, 1000);
|
||||
}
|
||||
|
||||
(async function init() {
|
||||
const status = await api('/api/version');
|
||||
els.verInfo.textContent = `v${status.version}`;
|
||||
await loadIfaces();
|
||||
animateWave();
|
||||
poll();
|
||||
})();
|
||||
@@ -1,70 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sabiá</title>
|
||||
<link rel="icon" href="/sabia-logo.svg">
|
||||
<link rel="stylesheet" href="/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<img src="/sabia-logo.svg" alt="">
|
||||
<div>
|
||||
<strong>Sabiá</strong>
|
||||
<small>auditoria de Wi-Fi</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="side-title">Interfaces</div>
|
||||
<ul class="list" id="ifaceList" data-empty="nenhuma interface Wi-Fi encontrada"></ul>
|
||||
|
||||
<div class="side-foot">
|
||||
<span id="verInfo" class="mono">—</span>
|
||||
<button id="btnUpdate" type="button">checar atualização</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<div class="page">
|
||||
<div class="topbar">
|
||||
<div>
|
||||
<h1>Painel de captura</h1>
|
||||
<div class="sub" id="ifaceSub">selecione uma interface</div>
|
||||
</div>
|
||||
<span id="modePill" class="pill"><span class="dot"></span><span id="modeLabel">—</span></span>
|
||||
</div>
|
||||
|
||||
<svg class="wave" id="wave" viewBox="0 0 600 34" preserveAspectRatio="none">
|
||||
<path class="flat" d="M0 17 L600 17"/>
|
||||
<path class="live" id="waveLive" d="M0 17 L600 17"/>
|
||||
</svg>
|
||||
|
||||
<div class="notice">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
||||
<span>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 fica registrada na trilha de auditoria local.</span>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<div class="group">
|
||||
<button id="btnMonitor" class="btn" disabled>ativar modo monitor</button>
|
||||
</div>
|
||||
<div class="group">
|
||||
<button id="btnScan" class="btn primary" disabled>iniciar captura</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="result-head">
|
||||
<strong>Redes vistas</strong>
|
||||
<span id="count">0</span>
|
||||
</div>
|
||||
|
||||
<div id="resultArea"></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+97
-113
@@ -1,36 +1,24 @@
|
||||
//! 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.
|
||||
//! Servidor local da interface: 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 em `apps/gui/ui` é o
|
||||
//! que o app desktop (Tauri) carrega — só muda o transporte.
|
||||
|
||||
use sabia_core::{audit::AuditLog, capture::RawCapture, iface, netlink, scan::ScanTable};
|
||||
use sabia_core::engine::Engine;
|
||||
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::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
const INDEX_HTML: &str = include_str!("index.html");
|
||||
const APP_CSS: &str = include_str!("app.css");
|
||||
const APP_JS: &str = include_str!("app.js");
|
||||
const LOGO_SVG: &str = include_str!("sabia-logo.svg");
|
||||
|
||||
struct Session {
|
||||
table: Arc<Mutex<ScanTable>>,
|
||||
running: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct AppState {
|
||||
sessions: Mutex<HashMap<String, Session>>,
|
||||
}
|
||||
const INDEX_HTML: &str = include_str!("../../../../apps/gui/ui/index.html");
|
||||
const APP_CSS: &str = include_str!("../../../../apps/gui/ui/app.css");
|
||||
const APP_JS: &str = include_str!("../../../../apps/gui/ui/app.js");
|
||||
const LOGO_SVG: &str = include_str!("../../../../apps/gui/ui/sabia-logo.svg");
|
||||
|
||||
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());
|
||||
let engine = Arc::new(Engine::new());
|
||||
|
||||
println!("Sabiá UI em http://127.0.0.1:{port}/?token={token}");
|
||||
let _ = open_browser(&format!("http://127.0.0.1:{port}/?token={token}"));
|
||||
@@ -40,10 +28,10 @@ pub fn serve(port: u16) -> sabia_core::Result<()> {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let state = state.clone();
|
||||
let engine = engine.clone();
|
||||
let token = token.clone();
|
||||
thread::spawn(move || {
|
||||
let _ = handle(stream, &state, &token);
|
||||
let _ = handle(stream, &engine, &token);
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
@@ -51,12 +39,35 @@ pub fn serve(port: u16) -> sabia_core::Result<()> {
|
||||
|
||||
fn gen_token() -> String {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let nanos = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
|
||||
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(|_| ())
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
std::process::Command::new("xdg-open")
|
||||
.arg(url)
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
std::process::Command::new("open")
|
||||
.arg(url)
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
std::process::Command::new("cmd")
|
||||
.args(["/C", "start", "", url])
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
struct Request {
|
||||
@@ -74,7 +85,6 @@ fn parse_request(stream: &mut TcpStream) -> std::io::Result<Request> {
|
||||
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" {
|
||||
@@ -93,28 +103,54 @@ fn parse_request(stream: &mut TcpStream) -> std::io::Result<Request> {
|
||||
}
|
||||
}
|
||||
let token = query.get("token").cloned();
|
||||
Ok(Request { method, path, query, token })
|
||||
Ok(Request {
|
||||
method,
|
||||
path,
|
||||
query,
|
||||
token,
|
||||
})
|
||||
}
|
||||
|
||||
fn urldecode(s: &str) -> String {
|
||||
s.replace('+', " ").replace("%3A", ":").replace("%2F", "/").replace("%20", " ")
|
||||
s.replace('+', " ")
|
||||
.replace("%3A", ":")
|
||||
.replace("%2F", "/")
|
||||
.replace("%20", " ")
|
||||
}
|
||||
|
||||
fn handle(mut stream: TcpStream, state: &Arc<AppState>, expected_token: &str) -> std::io::Result<()> {
|
||||
fn handle(
|
||||
mut stream: TcpStream,
|
||||
engine: &Arc<Engine>,
|
||||
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());
|
||||
return respond(
|
||||
&mut stream,
|
||||
200,
|
||||
"text/html; charset=utf-8",
|
||||
INDEX_HTML.as_bytes(),
|
||||
);
|
||||
}
|
||||
|
||||
// Assets estáticos: não carregam dado nenhum, então não exigem token.
|
||||
match req.path.as_str() {
|
||||
"/app.css" => return respond(&mut stream, 200, "text/css", APP_CSS.as_bytes()),
|
||||
"/app.js" => return respond(&mut stream, 200, "application/javascript", APP_JS.as_bytes()),
|
||||
"/sabia-logo.svg" => return respond(&mut stream, 200, "image/svg+xml", LOGO_SVG.as_bytes()),
|
||||
"/app.js" => {
|
||||
return respond(
|
||||
&mut stream,
|
||||
200,
|
||||
"application/javascript",
|
||||
APP_JS.as_bytes(),
|
||||
)
|
||||
}
|
||||
"/sabia-logo.svg" => {
|
||||
return respond(&mut stream, 200, "image/svg+xml", LOGO_SVG.as_bytes())
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -122,10 +158,15 @@ fn handle(mut stream: TcpStream, state: &Arc<AppState>, expected_token: &str) ->
|
||||
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\"}");
|
||||
return respond(
|
||||
&mut stream,
|
||||
401,
|
||||
"application/json",
|
||||
b"{\"error\":\"token invalido\"}",
|
||||
);
|
||||
}
|
||||
|
||||
let body = api(state, &req);
|
||||
let body = api(engine, &req);
|
||||
respond(&mut stream, 200, "application/json", body.as_bytes())
|
||||
}
|
||||
|
||||
@@ -141,102 +182,45 @@ fn respond(stream: &mut TcpStream, code: u16, ctype: &str, body: &[u8]) -> std::
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn api(state: &Arc<AppState>, req: &Request) -> String {
|
||||
fn api(engine: &Arc<Engine>, req: &Request) -> String {
|
||||
match (req.method.as_str(), req.path.as_str()) {
|
||||
("GET", "/api/version") => {
|
||||
format!("{{\"version\":{:?}}}", env!("CARGO_PKG_VERSION"))
|
||||
}
|
||||
("GET", "/api/update/check") => {
|
||||
match sabia_core::update::check(env!("CARGO_PKG_VERSION")) {
|
||||
Ok(status) => serde_json::to_string(&status).unwrap_or_else(|_| "{}".into()),
|
||||
Err(e) => format!("{{\"error\":{:?}}}", e.to_string()),
|
||||
}
|
||||
}
|
||||
("GET", "/api/version") => format!("{{\"version\":{:?}}}", engine.version()),
|
||||
("GET", "/api/update/check") => match engine.update_check() {
|
||||
Ok(status) => serde_json::to_string(&status).unwrap_or_else(|_| "{}".into()),
|
||||
Err(e) => format!("{{\"error\":{:?}}}", e.to_string()),
|
||||
},
|
||||
("GET", "/api/ifaces") => {
|
||||
let list = iface::list_wireless().unwrap_or_default();
|
||||
let list = engine.list_ifaces().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()
|
||||
}
|
||||
let enable = req
|
||||
.query
|
||||
.get("enable")
|
||||
.map(|v| v == "true")
|
||||
.unwrap_or(false);
|
||||
match engine.set_monitor(&name, enable) {
|
||||
Ok(()) => "{\"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)
|
||||
match engine.start_scan(&name) {
|
||||
Ok(()) => "{\"ok\":true}".into(),
|
||||
Err(e) => format!("{{\"ok\":false,\"error\":{:?}}}", e.to_string()),
|
||||
}
|
||||
}
|
||||
("POST", "/api/scan/stop") => {
|
||||
let name = req.query.get("iface").cloned().unwrap_or_default();
|
||||
stop_scan(state, &name);
|
||||
engine.stop_scan(&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(),
|
||||
}
|
||||
serde_json::to_string(&engine.scan_results(&name)).unwrap_or_else(|_| "[]".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);
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" role="img" aria-label="Sabiá"><style>.a{stroke:#b45309}.b{stroke:#0891b2}.t{fill:#1d1d1f}@media (prefers-color-scheme:dark){.a{stroke:#f59e0b}.b{stroke:#22d3ee}.t{fill:#f5f5f7}}</style><defs><clipPath id="s-bird"><path d="M78 14 C70 8 60 6 52 10 C46 6 38 6 32 10 C20 16 12 28 12 42 C12 54 18 64 28 70 C26 76 28 82 34 85 C33 80 35 76 39 74 C46 78 55 79 63 76 C73 72 80 62 81 50 C88 48 93 42 94 34 C87 36 81 34 77 29 C81 25 83 19 78 14 Z"/></clipPath></defs><g clip-path="url(#s-bird)" fill="none" stroke-width="3.6"><path class="a" d="M78 14A6 6 0 0 1 78 26"/><path class="b" d="M78 14A6 6 0 0 0 78 26" stroke-dasharray="3 3"/><path class="a" d="M78 7A13 13 0 0 1 78 33"/><path class="b" d="M78 7A13 13 0 0 0 78 33" stroke-dasharray="6 4" stroke-dashoffset="3"/><path class="a" d="M78 0A20 20 0 0 1 78 40"/><path class="b" d="M78 0A20 20 0 0 0 78 40" stroke-dasharray="9 5" stroke-dashoffset="6"/><path class="a" d="M78 -7A27 27 0 0 1 78 47"/><path class="b" d="M78 -7A27 27 0 0 0 78 47" stroke-dasharray="3 6" stroke-dashoffset="9"/><path class="a" d="M78 -14A34 34 0 0 1 78 54"/><path class="b" d="M78 -14A34 34 0 0 0 78 54" stroke-dasharray="6 3" stroke-dashoffset="12"/><path class="a" d="M78 -21A41 41 0 0 1 78 61"/><path class="b" d="M78 -21A41 41 0 0 0 78 61" stroke-dasharray="9 4" stroke-dashoffset="15"/><path class="a" d="M78 -28A48 48 0 0 1 78 68"/><path class="b" d="M78 -28A48 48 0 0 0 78 68" stroke-dasharray="3 5" stroke-dashoffset="18"/><path class="a" d="M78 -35A55 55 0 0 1 78 75"/><path class="b" d="M78 -35A55 55 0 0 0 78 75" stroke-dasharray="6 6" stroke-dashoffset="21"/><path class="a" d="M78 -42A62 62 0 0 1 78 82"/><path class="b" d="M78 -42A62 62 0 0 0 78 82" stroke-dasharray="9 3" stroke-dashoffset="24"/><path class="a" d="M78 -49A69 69 0 0 1 78 89"/><path class="b" d="M78 -49A69 69 0 0 0 78 89" stroke-dasharray="3 4" stroke-dashoffset="27"/><path class="a" d="M78 -56A76 76 0 0 1 78 96"/><path class="b" d="M78 -56A76 76 0 0 0 78 96" stroke-dasharray="6 5" stroke-dashoffset="30"/><path class="a" d="M78 -63A83 83 0 0 1 78 103"/><path class="b" d="M78 -63A83 83 0 0 0 78 103" stroke-dasharray="9 6" stroke-dashoffset="33"/><path class="a" d="M78 -70A90 90 0 0 1 78 110"/><path class="b" d="M78 -70A90 90 0 0 0 78 110" stroke-dasharray="3 3" stroke-dashoffset="36"/><rect class="t" stroke="none" x="20" y="60" width="64" height="40"/></g><circle class="t" cx="40" cy="36" r="2.6"/></svg>
|
||||
|
Before Width: | Height: | Size: 2.4 KiB |
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
pub mod audit;
|
||||
pub mod dot11;
|
||||
pub mod engine;
|
||||
pub mod error;
|
||||
pub mod iface;
|
||||
pub mod netlink;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user