diff --git a/crates/sabia-cli/src/web/app.css b/crates/sabia-cli/src/web/app.css new file mode 100644 index 0000000..4e7bae4 --- /dev/null +++ b/crates/sabia-cli/src/web/app.css @@ -0,0 +1,187 @@ +: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; } +} diff --git a/crates/sabia-cli/src/web/app.js b/crates/sabia-cli/src/web/app.js new file mode 100644 index 0000000..e20fdba --- /dev/null +++ b/crates/sabia-cli/src/web/app.js @@ -0,0 +1,194 @@ +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 = ``; + +const ICON_EMPTY = ``; + +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 = `
${ICON_EMPTY}

${message}

${hint}

`; +} + +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 = `${ICON_WIFI}${i.name}${i.mac}`; + 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 += ``; + } + return `${bars}`; +} + +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) => ` + + ${ap.ssid ? escapeHtml(ap.ssid) : 'oculta'} + ${ap.bssid} + ${ap.channel ?? '—'} + ${signalBars(ap.last_signal_dbm)}${ap.last_signal_dbm != null ? ap.last_signal_dbm + ' dBm' : '—'} + ${ap.security || '—'} + ${ap.clients.length} + `).join(''); + els.resultArea.innerHTML = ` +
+ + + ${rows} +
RedeBSSIDCanalSinalSegurançaClientes
+
`; +} + +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(); +})(); diff --git a/crates/sabia-cli/src/web/index.html b/crates/sabia-cli/src/web/index.html index fe1df0b..a6674ba 100644 --- a/crates/sabia-cli/src/web/index.html +++ b/crates/sabia-cli/src/web/index.html @@ -4,154 +4,67 @@ Sabiá - + + -
-

Sabiá

- auditoria de redes Wi-Fi -
-
-
Use somente em redes próprias ou com autorização explícita do responsável. Toda ativação de modo monitor e sessão de captura é registrada na trilha de auditoria local.
- -
-
- - - — - - +
+
-
-
- Redes vistas - 0 +
Interfaces
+
    + +
    + — +
    - - - -
    BSSIDSSIDCanalSinalSegurançaClientes
    -
    -
    - +
    + + + + diff --git a/crates/sabia-cli/src/web/mod.rs b/crates/sabia-cli/src/web/mod.rs index 807195b..bf26188 100644 --- a/crates/sabia-cli/src/web/mod.rs +++ b/crates/sabia-cli/src/web/mod.rs @@ -13,6 +13,9 @@ 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>, @@ -107,6 +110,14 @@ fn handle(mut stream: TcpStream, state: &Arc, expected_token: &str) -> 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()), + _ => {} + } + if !req.path.starts_with("/api/") { return respond(&mut stream, 404, "text/plain", b"not found"); } @@ -132,6 +143,15 @@ fn respond(stream: &mut TcpStream, code: u16, ctype: &str, body: &[u8]) -> std:: fn api(state: &Arc, 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/ifaces") => { let list = iface::list_wireless().unwrap_or_default(); serde_json::to_string(&list).unwrap_or_else(|_| "[]".into()) diff --git a/crates/sabia-cli/src/web/sabia-logo.svg b/crates/sabia-cli/src/web/sabia-logo.svg new file mode 100644 index 0000000..fabdb8a --- /dev/null +++ b/crates/sabia-cli/src/web/sabia-logo.svg @@ -0,0 +1 @@ +