Files
Lucas RayanandClaude Sonnet 5 6191c7bbc6 feat(update): one-click desktop self-update (AppImage, .deb, Windows)
Same change as Guará: update_apply used to flatly refuse to run for
the GUI binary and point the user to a manual download instead. Since
the desktop app already elevates to root before showing its window,
the process applying the update already has everything it needs — no
second authentication round is required.

- New sabia_core::update::apply_desktop[_with_progress], alongside the
  existing CLI-only apply() (left untouched): detects the install kind
  at runtime (AppImage via the APPIMAGE env var, same FUSE-mount issue
  elevated_exe() already works around and this reuses; Deb otherwise
  on Linux; WindowsInstaller on Windows), matches the right release
  asset by the GUI bundle naming (_amd64/_aarch64.AppImage,
  _amd64/_arm64.deb, _x64-setup.exe), verifies SHA-256 when a sidecar
  is published, and installs:
  - AppImage: downloads to a temp file in the same directory, renames
    over the running file, execs it in place (same PID, already root).
  - .deb: runs dpkg -i directly (already root), execs current_exe()
    again now that dpkg replaced it on disk.
  - Windows: runs the installer silently and detached, exits
    immediately to release the file lock. Does not claim to relaunch
    the app afterwards — no documented NSIS or Tauri flag does that;
    needs a real Windows machine to confirm actual post-install
    behavior.
- Engine::update_apply_desktop wires it into the Tauri update_apply
  command (now takes AppHandle, emits update-progress events).
- Fixed a real gap: the existing update button handler changed its own
  label when an update was available but never actually called apply
  — wired it for real, with per-phase progress feedback in the
  interface's existing language (download/verify/install/restart).
- scripts/build-gui.sh: generates a checksum file next to every file
  in dist-gui/, matching the CLI release archive convention.
- 3 new unit tests: install-kind detection, GUI asset suffix matching
  against real release naming, existing CLI-refusal test unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
2026-10-31 10:18:31 -03:00

2486 lines
109 KiB
JavaScript

import { setLang, currentLang } from './i18n.js';
const params = new URLSearchParams(location.search);
const token = params.get('token');
const tauri = window.__TAURI__;
const els = {
ifaceSelect: document.getElementById('ifaceSelect'),
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'),
searchInput: document.getElementById('searchInput'),
sortSel: document.getElementById('sortSel'),
wave: document.getElementById('wave'),
verInfo: document.getElementById('verInfo'),
btnUpdate: document.getElementById('btnUpdate'),
langSel: document.getElementById('langSel'),
settingsBtn: document.getElementById('settingsBtn'),
settingsDlg: document.getElementById('settingsDlg'),
targetDlg: document.getElementById('targetDlg'),
targetTitle: document.getElementById('targetTitle'),
targetSub: document.getElementById('targetSub'),
targetClose: document.getElementById('targetClose'),
tabPanel: document.getElementById('tabPanel'),
tabClientsCount: document.getElementById('tabClientsCount'),
resultTitle: document.getElementById('resultTitle'),
btnReport: document.getElementById('btnReport'),
reportDlg: document.getElementById('reportDlg'),
reportFrame: document.getElementById('reportFrame'),
reportView: document.getElementById('reportView'),
reportCanvas: document.getElementById('reportCanvas'),
reportStatus: document.getElementById('reportStatus'),
reportZoomLabel: document.getElementById('reportZoomLabel'),
reportZoomIn: document.getElementById('reportZoomIn'),
reportZoomOut: document.getElementById('reportZoomOut'),
reportPrint: document.getElementById('reportPrint'),
reportDownload: document.getElementById('reportDownload'),
reportClose: document.getElementById('reportClose'),
};
function applyTheme(t) {
const root = document.documentElement;
if (t === 'light' || t === 'dark') root.dataset.theme = t;
else delete root.dataset.theme;
document.querySelectorAll('[data-theme-pick]').forEach((b) => {
b.setAttribute('aria-pressed', String(b.dataset.themePick === (t || 'auto')));
});
}
function updateThemePref(t) {
applyTheme(t);
try { localStorage.setItem('sabia-theme', t); } catch { /* storage unavailable */ }
}
let themePref = 'auto';
try { themePref = localStorage.getItem('sabia-theme') || 'auto'; } catch { /* storage unavailable */ }
applyTheme(themePref);
document.querySelectorAll('[data-theme-pick]').forEach((b) => {
b.addEventListener('click', () => updateThemePref(b.dataset.themePick));
});
try {
const sort = localStorage.getItem('sabia-sort');
if (sort && [...els.sortSel.options].some((o) => o.value === sort)) els.sortSel.value = sort;
} catch { /* storage unavailable */ }
els.settingsBtn.addEventListener('click', () => {
if (!els.settingsDlg.open) els.settingsDlg.showModal();
});
function applyLangPref() {
let pref = 'auto';
try { pref = localStorage.getItem('sabia-lang') || 'auto'; } catch { /* ignore */ }
els.langSel.value = pref;
setLang(pref === 'auto' ? undefined : pref);
}
els.langSel.addEventListener('change', () => {
const pref = els.langSel.value;
try { localStorage.setItem('sabia-lang', pref); } catch { /* ignore */ }
setLang(pref === 'auto' ? undefined : pref);
});
let ifaces = [];
let current = null;
let scanning = false;
// Stable arrival order per network/BSSID: without this, each poll resorted the
// list by instantaneous RSSI and rows jumped around on every
// update, making it hard to click a target. Arrival order
// serves as the tiebreaker (and default sort), so an
// item only moves when the user explicitly changes the sort mode
// rather than on a 1 dBm RSSI fluctuation between polls.
const firstSeenOrder = new Map();
let firstSeenSeq = 0;
function orderOf(key) {
if (!firstSeenOrder.has(key)) firstSeenOrder.set(key, firstSeenSeq++);
return firstSeenOrder.get(key);
}
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>`;
const warnSvg = `<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>`;
async function api(path, opts) {
if (tauri) return tauriApi(path, opts);
const url = new URL(path, location.origin);
url.searchParams.set('token', token);
const r = await fetch(url, opts);
return r.json();
}
// In the desktop app there is no HTTP server: the same routes map to direct
// IPC calls into the core via the Tauri commands in src-tauri/src/main.rs.
async function tauriApi(path, opts = {}) {
const [p, qs] = path.split('?');
const q = new URLSearchParams(qs || '');
const invoke = tauri.core.invoke;
const method = (opts.method || 'GET').toUpperCase();
const num = (key) => (q.get(key) == null || q.get(key) === '' ? null : Number(q.get(key)));
try {
switch (`${method} ${p}`) {
case 'GET /api/version':
return { version: await invoke('version') };
case 'GET /api/ifaces':
return await invoke('list_ifaces');
case 'POST /api/monitor':
await invoke('set_monitor', { iface: q.get('iface'), enable: q.get('enable') === 'true' });
return { ok: true };
case 'POST /api/scan/start':
await invoke('start_scan_opts', {
iface: q.get('iface'),
channel: num('channel'),
band: q.get('band') || null,
hopMs: num('hop_ms'),
pcapOut: q.get('pcap_out') || null,
seconds: num('seconds'),
});
return { ok: true };
case 'POST /api/scan/stop':
await invoke('stop_scan', { iface: q.get('iface') });
return { ok: true };
case 'GET /api/scan':
return await invoke('scan_results', { iface: q.get('iface') });
case 'GET /api/scan/snapshot':
return await invoke('scan_snapshot', { iface: q.get('iface') });
case 'GET /api/scan/clients':
return await invoke('scan_clients', { iface: q.get('iface') });
case 'GET /api/handshakes':
return await invoke('handshakes', { iface: q.get('iface') || null });
case 'POST /api/handshakes/export':
return await invoke('export_handshake', {
iface: q.get('iface'), bssid: q.get('bssid'),
station: q.get('station'), out: q.get('out'),
});
case 'POST /api/handshakes/export-all':
return await invoke('export_all_handshakes', { iface: q.get('iface'), out: q.get('out') });
case 'POST /api/crack':
return { ok: true, job: await invoke('start_crack', {
iface: q.get('iface'), wordlist: q.get('wordlist'),
bssid: q.get('bssid') || null, station: q.get('station') || null,
rules: q.get('rules') === 'true',
}) };
case 'POST /api/crack/mask':
return { ok: true, job: await invoke('start_crack_mask', {
iface: q.get('iface'), mask: q.get('mask'),
bssid: q.get('bssid') || null, station: q.get('station') || null,
maxCandidates: num('max_candidates'),
}) };
case 'GET /api/crack':
return await invoke('crack_job', { id: q.get('id') });
case 'GET /api/cracks':
return await invoke('crack_jobs');
case 'POST /api/deauth':
return await invoke('deauth_directional', {
iface: q.get('iface'), bssid: q.get('bssid'),
station: q.get('station') || null,
count: num('count'), reason: num('reason'),
direction: q.get('direction') || 'ap',
frameKind: q.get('frame') || 'deauth',
});
case 'POST /api/deauth/start':
return { ok: true, job: await invoke('start_deauth_job', {
iface: q.get('iface'), bssid: q.get('bssid'),
station: q.get('station') || null,
reason: num('reason') || 7,
direction: q.get('direction') || 'both',
frameKind: q.get('frame') || 'deauth',
intervalMs: num('interval_ms') || 120,
maxSecs: num('max_secs'),
stopOnHandshake: q.get('stop_on_handshake') === 'true',
}) };
case 'POST /api/deauth/stop':
await invoke('stop_deauth_job', { id: q.get('id') });
return { ok: true };
case 'GET /api/deauth/job':
return await invoke('deauth_job', { id: q.get('id') });
case 'GET /api/deauth/jobs':
return await invoke('deauth_jobs', { iface: q.get('iface') || null });
case 'POST /api/handshakes/capture':
return { ok: true, job: await invoke('capture_handshake', {
iface: q.get('iface'), bssid: q.get('bssid'),
station: q.get('station') || null, seconds: num('seconds'),
}) };
case 'GET /api/report':
return await invoke('report', { iface: q.get('iface') });
case 'GET /api/report.html':
return { html: await invoke('report_html', { iface: q.get('iface'), inline: q.get('download') === 'true' }) };
case 'GET /api/wps/tools':
return await invoke('wps_tools');
case 'POST /api/wps':
return { ok: true, job: await invoke('start_wps', {
iface: q.get('iface'), bssid: q.get('bssid'),
channel: num('channel') || 0, tool: q.get('tool') || 'reaver',
pin: q.get('pin') || null,
}) };
case 'GET /api/wps':
return await invoke('wps_job', { id: q.get('id') });
case 'GET /api/wps/jobs':
return await invoke('wps_jobs');
case 'POST /api/wps/stop':
await invoke('stop_wps', { id: q.get('id') });
return { ok: true };
case 'POST /api/eviltwin':
return { ok: true, job: await invoke('start_evil_twin', {
iface: q.get('iface'), apIface: q.get('ap_iface'), bssid: q.get('bssid'),
portalPort: num('portal_port') || 8080, deauth: q.get('deauth') === 'true',
}) };
case 'GET /api/eviltwin':
return await invoke('eviltwin_job', { id: q.get('id') });
case 'GET /api/eviltwins':
return await invoke('eviltwin_jobs');
case 'POST /api/eviltwin/stop':
await invoke('stop_evil_twin', { id: q.get('id') });
return { ok: true };
case 'POST /api/eviltwin-enterprise':
return { ok: true, job: await invoke('start_evil_twin_enterprise', {
iface: q.get('iface'), apIface: q.get('ap_iface'), bssid: q.get('bssid'),
}) };
case 'GET /api/eviltwin-enterprise':
return await invoke('eviltwin_enterprise_job', { id: q.get('id') });
case 'GET /api/eviltwins-enterprise':
return await invoke('eviltwin_enterprise_jobs');
case 'POST /api/eviltwin-enterprise/stop':
await invoke('stop_evil_twin_enterprise', { id: q.get('id') });
return { ok: true };
case 'POST /api/pmkid':
return await invoke('pmkid_hunt', {
iface: q.get('iface'), bssid: q.get('bssid'), attempts: num('attempts') || 5,
});
case 'POST /api/pmf':
return await invoke('pmf_test', {
iface: q.get('iface'), bssid: q.get('bssid'), station: q.get('station'),
reason: num('reason') || 7,
});
case 'POST /api/handshakes/export-hc22000':
return await invoke('export_hc22000', {
iface: q.get('iface'), bssid: q.get('bssid'),
station: q.get('station'), out: q.get('out'),
});
case 'POST /api/handshakes/export-all-hc22000':
return await invoke('export_all_hc22000', { iface: q.get('iface'), out: q.get('out') });
case 'GET /api/update/check':
return await invoke('update_check');
case 'POST /api/update/apply':
return await invoke('update_apply');
default:
return { error: 'rota desconhecida' };
}
} catch (e) {
return { ok: false, error: String(e) };
}
}
function renderEmptyResult(message, hint) {
els.resultArea._h = null;
els.resultArea.innerHTML = `<div class="ap-card empty-state">${ICON_EMPTY}<h2>${message}</h2><p>${hint}</p></div>`;
}
// Only update innerHTML when content actually changes: the 1s poll must not
// rebuild the DOM table (and drop focus/hover) unnecessarily.
function setResultsHtml(html) {
if (els.resultArea._h === html) return;
els.resultArea._h = html;
els.resultArea.innerHTML = html;
}
function renderIfaceList() {
const sel = els.ifaceSelect;
if (!ifaces.length) {
sel.innerHTML = `<option value="">${escapeHtml(sel.dataset.empty || 'nenhuma interface Wi-Fi encontrada')}</option>`;
sel.disabled = true;
return;
}
sel.disabled = false;
sel.innerHTML = ifaces
.map((i) => `<option value="${escapeAttr(i.name)}">${escapeHtml(i.name)} · ${escapeHtml(i.mac)}</option>`)
.join('');
sel.value = current || '';
}
els.ifaceSelect.addEventListener('change', () => selectIface(els.ifaceSelect.value));
function selectIface(name) {
current = name;
scanning = false;
lastSnapshot = null;
lastAps = [];
selectedBssid = null;
renderIfaceList();
refreshModeUi();
updateReportButton();
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;
updateReportButton();
}
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) {
const res = await api(`/api/scan/start?iface=${encodeURIComponent(current)}`, { method: 'POST' });
if (res && res.ok === false) {
// Without this, a failure to start capture (permissions, interface left
// monitor mode, etc.) was silent: the button flipped to "stop capture" and
// the screen showed "listening" forever without any networks arriving.
alert('Não foi possível iniciar a captura: ' + (res.error || 'erro desconhecido'));
return;
}
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');
}
});
let updateStatus = null;
let updateInProgress = false;
els.btnUpdate.addEventListener('click', async () => {
if (updateInProgress) return;
if (updateStatus?.update_available) {
updateInProgress = true;
els.btnUpdate.disabled = true;
els.btnUpdate.textContent = 'baixando…';
let unlisten = null;
if (tauri?.event?.listen) {
try {
unlisten = await tauri.event.listen('update-progress', (event) => {
const phase = event.payload?.phase;
if (phase === 'downloading') els.btnUpdate.textContent = 'baixando…';
else if (phase === 'verifying') els.btnUpdate.textContent = 'verificando…';
else if (phase === 'installing') els.btnUpdate.textContent = 'instalando…';
else if (phase === 'restarting') els.btnUpdate.textContent = 'reiniciando…';
});
} catch (_) {}
}
try {
await api('/api/update/apply', { method: 'POST' });
els.btnUpdate.textContent = 'já atualizado';
} catch (err) {
updateInProgress = false;
els.btnUpdate.disabled = false;
els.btnUpdate.textContent = `atualizar para ${updateStatus.latest_version}`;
alert('Falha na atualização: ' + (err?.message || err));
} finally {
if (unlisten) unlisten();
}
return;
}
els.btnUpdate.textContent = 'checando…';
try {
const status = await api('/api/update/check');
updateStatus = status;
els.btnUpdate.textContent = status?.update_available
? `atualizar para ${status.latest_version}`
: 'já atualizado';
} catch (err) {
els.btnUpdate.textContent = 'checar atualização';
alert('Não foi possível verificar atualizações: ' + (err?.message || err));
}
});
const SEC_RANK = {
Open: 0, WEP: 1, WPA: 2, WPA2: 3, 'WPA2-EAP': 3,
'WPA2/WPA3': 4, OWE: 4, WPA3: 4, 'WPA3-EAP': 4,
};
const SEC_CLASS = {
Open: 'sec-open', WEP: 'sec-wep', WPA: 'sec-wpa',
WPA2: 'sec-wpa2', 'WPA2-EAP': 'sec-eap', OWE: 'sec-owe',
'WPA2/WPA3': 'sec-wpa3', WPA3: 'sec-wpa3', 'WPA3-EAP': 'sec-eap',
};
function secClass(sec) {
return SEC_CLASS[sec] || '';
}
function signalOf(ap) {
return ap.max_signal_dbm ?? ap.signal_dbm ?? null;
}
function signalBars(dbm) {
// dBm typically ranges from -30 (excellent) to -90 (weak)
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>`;
}
// Group by ESSID: a network may have multiple BSSIDs (radios) belonging
// to the same target. Hidden networks stay separate since without an SSID
// two BSSIDs cannot be assumed to belong to the same network.
function buildGroups(aps) {
const map = new Map();
for (const ap of aps) {
const hidden = ap.hidden || !ap.ssid;
const key = hidden ? `hidden:${ap.bssid}` : `ssid:${ap.ssid}`;
let g = map.get(key);
if (!g) {
g = { key, name: hidden ? null : ap.ssid, hidden, aps: [] };
map.set(key, g);
}
g.aps.push(ap);
}
const groups = [...map.values()];
for (const g of groups) {
g.channels = [...new Set(g.aps.map((a) => a.channel).filter((v) => v != null))].sort((a, b) => a - b);
g.bands = [...new Set(g.aps.map((a) => a.band).filter(Boolean))];
g.securities = [...new Set(g.aps.map((a) => a.security).filter(Boolean))]
.sort((a, b) => (SEC_RANK[a] ?? 9) - (SEC_RANK[b] ?? 9));
g.bestAp = g.aps.reduce((m, a) => ((signalOf(a) ?? -999) > (signalOf(m) ?? -999) ? a : m), g.aps[0]);
g.best = g.aps.reduce((m, a) => {
const s = signalOf(a);
return s == null ? m : m == null ? s : Math.max(m, s);
}, null);
const clients = new Set();
for (const a of g.aps) for (const mac of a.clients || []) clients.add(mac);
g.clients = clients;
g.wps = g.aps.some((a) => a.wps);
const pmf = g.aps.filter((a) => a.pmf).length;
g.pmf = pmf === g.aps.length ? 'all' : pmf ? 'some' : null;
g.risk = Math.min(...g.securities.map((s) => SEC_RANK[s] ?? 3));
g.order = orderOf(g.key);
}
return groups;
}
function filterGroups(groups) {
const q = els.searchInput.value.trim().toLowerCase();
if (!q) return groups;
return groups.filter((g) =>
(g.name && g.name.toLowerCase().includes(q)) ||
g.aps.some((a) => a.bssid.toLowerCase().includes(q)));
}
// Quantize RSSI before comparing: rounding to the nearest multiple of 4
// dBm prevents a 1-2 dBm fluctuation between polls from
// flipping the order of two targets with similar signal strength.
function signalBucket(v) {
return v == null ? -1000 : Math.round(v / 4) * 4;
}
function sortGroups(groups) {
const bySignal = (a, b) => signalBucket(b.best) - signalBucket(a.best) || a.order - b.order;
const cmp = {
signal: bySignal,
security: (a, b) => a.risk - b.risk || bySignal(a, b),
clients: (a, b) => b.clients.size - a.clients.size || bySignal(a, b),
name: (a, b) => (a.name ?? '\uffff').localeCompare(b.name ?? '\uffff', 'pt-BR', { sensitivity: 'base' }) || a.order - b.order,
}[els.sortSel.value];
return groups.sort(cmp || bySignal);
}
function renderBssid(ap) {
const sig = signalOf(ap);
const clients = ap.clients || [];
const flags = [];
if (ap.wps) flags.push('<span class="flag flag-warn">WPS</span>');
if (ap.pmf) flags.push('<span class="flag">PMF</span>');
if (ap.cipher) flags.push(`<span class="flag">${escapeHtml(ap.cipher)}</span>`);
const meta = [ap.channel != null ? `canal ${ap.channel}` : '—', ap.band].filter(Boolean).join(' · ');
const clientLabel = clients.length === 1 ? '1 cliente' : `${clients.length} clientes`;
const clientsTitle = clients.length ? ` title="${escapeAttr(clients.join(', '))}"` : '';
return `<div class="bssid-item" data-bssid="${escapeAttr(ap.bssid)}" role="button" tabindex="0" title="Ver detalhes">
<span class="bssid mono">${ap.bssid}</span>
<span class="bssid-meta">${meta}</span>
<span class="bssid-sig">${sig != null ? signalBars(sig) + sig + ' dBm' : '—'}</span>
<span class="bssid-sec"><span class="sec-tag ${secClass(ap.security)}">${ap.security || '—'}</span></span>
<span class="bssid-clients"${clientsTitle}>${clientLabel}</span>
<span class="bssid-flags">${flags.join('')}</span>
</div>`;
}
function renderGroup(g) {
const open = openGroups.has(g.key);
const name = g.hidden ? '<em>oculta</em>' : escapeHtml(g.name);
const apLabel = g.aps.length === 1 ? '1 AP' : `${g.aps.length} APs`;
const flags = [`<span class="flag">${apLabel}</span>`];
if (g.hidden) flags.push(`<span class="flag mono">${g.aps[0].bssid}</span>`);
if (g.wps) flags.push('<span class="flag flag-warn">WPS</span>');
if (g.pmf === 'all') flags.push('<span class="flag">PMF</span>');
else if (g.pmf === 'some') flags.push('<span class="flag">PMF parcial</span>');
const secs = g.securities.length
? g.securities.map((s) => `<span class="sec-tag ${secClass(s)}">${s}</span>`).join('')
: '<span class="sec-tag">—</span>';
const chans = g.channels.length ? g.channels.join(' · ') : '—';
const bands = g.bands.length ? `<span class="net-band">${g.bands.join(' · ')}</span>` : '';
const clientsTitle = g.clients.size ? ` title="${escapeAttr([...g.clients].join(', '))}"` : '';
const details = g.aps
.slice()
.sort((a, b) => signalBucket(signalOf(b)) - signalBucket(signalOf(a)) || orderOf(`bssid:${a.bssid}`) - orderOf(`bssid:${b.bssid}`))
.map(renderBssid).join('');
return `
<tr class="net-row${open ? ' open' : ''}" data-key="${escapeAttr(g.key)}">
<td class="caret-cell"><button type="button" class="expander" aria-expanded="${open}" aria-label="${open ? 'recolher' : 'expandir'}"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6l6 6-6 6"/></svg></button></td>
<td class="net-name"><button type="button" class="ssid ssid-btn" data-bssid="${escapeAttr(g.bestAp.bssid)}" title="Ver detalhes">${name}</button><span class="net-sub">${flags.join('')}</span></td>
<td class="net-sec">${secs}</td>
<td class="net-chan"><span class="chans">${chans}</span>${bands}</td>
<td class="net-signal">${g.best != null ? signalBars(g.best) + g.best + ' dBm' : '—'}</td>
<td class="net-clients"${clientsTitle}>${g.clients.size}</td>
</tr>
<tr class="detail-row"${open ? '' : ' hidden'}>
<td></td>
<td colspan="5"><div class="bssid-list">${details}</div></td>
</tr>`;
}
let lastAps = [];
const openGroups = new Set();
let viewMode = 'nets';
try { viewMode = localStorage.getItem('sabia-view') || 'nets'; } catch { /* storage unavailable */ }
let channelBand = null;
let reportState = { iface: null, pages: 1, zoom: 1, url: null };
let reportMeasureTimer = null;
function updateReportButton() {
els.btnReport.disabled = !current;
}
function alertsHtml() {
const findings = lastSnapshot?.analysis?.findings || [];
if (!findings.length) return '';
const shown = findings.slice(0, 4);
const more = findings.length - shown.length;
return `<div class="alert-strip">${shown
.map((f) => {
const tone = f.severity === 'bad' ? 'high' : f.severity === 'warn' ? 'warn' : 'low';
return `<button type="button" class="alert-item alert-${tone}" data-report-open title="${escapeAttr(f.detail)}">
<span class="alert-dot"></span><span class="alert-title">${escapeHtml(f.title)}</span>
</button>`;
})
.join('')}${more > 0 ? `<span class="alert-more">+${more}</span>` : ''}</div>`;
}
const SEC_COLOR = {
Open: '#b8443a', WEP: '#b8443a', WPA: '#c98a2e',
WPA2: '#2f8f70', 'WPA2-EAP': '#2f8f70', 'WPA2/WPA3': '#3b7fae',
OWE: '#3b7fae', WPA3: '#356f9e', 'WPA3-EAP': '#356f9e',
};
function freqOf(ap) {
if (ap.freq_mhz) return ap.freq_mhz;
const ch = ap.channel;
if (ch == null) return null;
if (ap.band === '2.4 GHz') return ch === 14 ? 2484 : 2407 + ch * 5;
if (ap.band === '5 GHz') return 5000 + ch * 5;
if (ap.band === '6 GHz') return 5950 + ch * 5;
return null;
}
function spectrumSvg(band) {
const all = lastAps.filter((a) => a.band === band.band).map((a) => ({ ap: a, freq: freqOf(a) })).filter((x) => x.freq);
const candidateFreqs = band.candidates.map((c) => c.freq_mhz);
const freqs = [...all.map((x) => x.freq), ...candidateFreqs];
if (!freqs.length) return '';
let min = Math.min(...freqs) - 12;
let max = Math.max(...freqs) + 12;
if (band.band === '2.4 GHz') { min = Math.min(min, 2397); max = Math.max(max, 2492); }
const W = 920;
const H = 238;
const left = 34;
const right = 12;
const top = 16;
const base = H - 26;
const plotW = W - left - right;
const x = (freq) => left + ((freq - min) / (max - min)) * plotW;
const y = (dbm) => {
const v = Math.max(-96, Math.min(-28, dbm ?? -75));
return base - ((v + 96) / 68) * (base - top);
};
const grid = [];
for (let dbm = -30; dbm >= -90; dbm -= 15) {
const yy = y(dbm);
grid.push(`<line class="sp-grid" x1="${left}" y1="${yy.toFixed(1)}" x2="${W - right}" y2="${yy.toFixed(1)}"/>`);
grid.push(`<text class="sp-db" x="${left - 6}" y="${(yy + 3.5).toFixed(1)}" text-anchor="end">${dbm}</text>`);
}
const lobes = all
.slice()
.sort((a, b) => (signalOf(b.ap) ?? -999) - (signalOf(a.ap) ?? -999))
.map(({ ap, freq }, idx) => {
const sig = signalOf(ap) ?? -75;
const width = (ap.width_mhz || 20) * 1.35;
const x0 = x(freq - width / 2);
const x1 = x(freq + width / 2);
const cx = x(freq);
const yTop = y(sig);
const color = SEC_COLOR[ap.security] || '#2f8f70';
const label = !ap.hidden && ap.ssid && idx < 7
? `<text class="sp-name" x="${cx.toFixed(1)}" y="${Math.max(top + 10, yTop - 5).toFixed(1)}" text-anchor="middle">${escapeHtml(ap.ssid.slice(0, 16))}</text>`
: '';
return `<g class="sp-lobe"><title>${escapeAttr(`${ap.ssid || ap.bssid} · canal ${ap.channel} · ${sig} dBm`)}</title>
<path d="M${x0.toFixed(1)} ${base} Q${cx.toFixed(1)} ${yTop.toFixed(1)} ${x1.toFixed(1)} ${base} Z" fill="${color}" fill-opacity=".20" stroke="${color}" stroke-opacity=".65" stroke-width="1.2"/>
${label}</g>`;
})
.join('');
const ticks = band.candidates
.map((c) => `<g><line class="sp-grid sp-tick" x1="${x(c.freq_mhz).toFixed(1)}" y1="${base}" x2="${x(c.freq_mhz).toFixed(1)}" y2="${base + 5}"/><text class="sp-chan" x="${x(c.freq_mhz).toFixed(1)}" y="${base + 18}" text-anchor="middle">${c.channel}</text></g>`)
.join('');
return `<svg class="spectrum" viewBox="0 0 ${W} ${H}" role="img" aria-label="Ocupação dos canais em ${escapeAttr(band.band)}">
${grid.join('')}
<line class="sp-axis" x1="${left}" y1="${base}" x2="${W - right}" y2="${base}"/>
${lobes}${ticks}
</svg>`;
}
const CHAN_TONE_WORD = { ok: 'Boa', warn: 'Atenção', bad: 'Saturado' };
/** Overall band status: applies the per-channel health logic to the
* recommended channel to determine the summary card tone. */
function bandHealth(band, rec) {
if (!rec) return { tone: 'bad', label: 'Sem canal livre nessa banda' };
if (rec.score >= 1.5) return { tone: 'bad', label: 'Até o melhor canal está saturado' };
if (rec.dfs) return { tone: 'warn', label: 'Recomendado é DFS — pode cair com radar por perto' };
if (rec.score >= 0.5) return { tone: 'warn', label: 'Espectro congestionado' };
return { tone: 'ok', label: 'Boa condição' };
}
function toneOf(score, dfs) {
if (score >= 1.5) return 'bad';
if (score >= 0.5 || dfs) return 'warn';
return 'ok';
}
function renderChannels() {
const analysis = lastSnapshot?.analysis;
const bands = analysis?.bands || [];
if (!bands.length) {
els.count.textContent = '0';
renderEmptyResult('Sem dados de canal ainda', 'Inicie a captura para o Sabiá medir ocupação e sugerir canais.');
return;
}
if (!channelBand || !bands.some((b) => b.band === channelBand)) channelBand = bands[0].band;
const band = bands.find((b) => b.band === channelBand) || bands[0];
const aps = lastAps.filter((a) => a.band === band.band);
els.count.textContent = `${aps.length} APs · ${band.used_channels.length} canais ocupados`;
const rec = band.candidates.find((c) => c.channel === band.recommended) || null;
const health = bandHealth(band, rec);
const worst = band.candidates.reduce((a, b2) => (b2.score > (a?.score ?? -1) ? b2 : a), null);
const dfsCount = band.candidates.filter((c) => c.dfs).length;
const tile = (label, value, cls = '') => value == null ? '' : `<div class="chan-tile ${cls}"><small>${label}</small><b>${value}</b></div>`;
const tiles = [
`<div class="chan-tile status ${health.tone}"><small>Estado</small><b>${CHAN_TONE_WORD[health.tone]}</b></div>`,
tile('Canal recomendado', `${band.recommended ?? '—'}${rec?.dfs ? ' <span>DFS</span>' : ''}`),
tile('Canais ocupados', `${band.used_channels.length}`),
tile('APs na banda', `${aps.length}`),
worst ? tile('Pior canal', `${worst.channel} <span>${worst.score.toFixed(2)}</span>`, toneOf(worst.score, worst.dfs) !== 'ok' ? toneOf(worst.score, worst.dfs) : '') : '',
dfsCount ? tile('Canais DFS', `${dfsCount}`) : '',
].join('');
const alert = health.tone !== 'ok'
? `<div class="notice ${health.tone === 'bad' ? 'bad' : 'warn'}">${warnSvg}<div><b>${escapeHtml(health.label)}</b>
<div class="hint" style="margin-top:4px">${band.band === '2.4 GHz'
? 'Nessa banda, prefira sempre 1, 6 ou 11 — canais no meio sobrepõem vizinhos.'
: rec?.dfs
? 'Canais DFS liberam mais espaço, mas o rádio é obrigado a sair do ar se detectar radar — tenha um canal não-DFS de reserva.'
: 'Considere mudar o AP para o canal recomendado ou mover pra outra banda.'}</div></div></div>`
: '';
const rows = band.candidates.map((c) => {
const tone = toneOf(c.score, c.dfs);
const best = band.recommended === c.channel;
return `<tr${best ? ' class="chan-best-row"' : ''}>
<td><span class="chan-dot ${tone === 'ok' ? '' : tone}" title="${CHAN_TONE_WORD[tone]}"></span></td>
<td class="mono">${c.channel}${c.dfs ? '<span class="mini-tag">DFS</span>' : ''}</td>
<td>${c.score.toFixed(2)}</td>
<td>${c.aps} AP(s)</td>
<td class="dim">${best ? 'Recomendado' : ''}</td>
</tr>`;
}).join('');
const switches = `<div class="band-switch" role="group" aria-label="Banda">${bands
.map((b) => `<button type="button" data-band="${escapeAttr(b.band)}" aria-pressed="${b.band === band.band}">${escapeHtml(b.band)}</button>`)
.join('')}</div>`;
const legend = `<div class="sp-legend"><span><i style="background:#2f8f70"></i>WPA2</span><span><i style="background:#356f9e"></i>WPA3</span><span><i style="background:#b8443a"></i>aberta/WEP</span><span><i style="background:#c98a2e"></i>WPA</span></div>`;
const wpa3 = analysis?.wpa3?.length ? `<p class="hint">${analysis.wpa3.length} rede(s) WPA3 analisada(s) — detalhes na aba do alvo e no relatório.</p>` : '';
setResultsHtml(`${alertsHtml()}${alert}<div class="ap-card channel-card">
<div class="channel-head">${switches}${legend}</div>
<div class="chan-tiles">${tiles}</div>
${spectrumSvg(band)}
<details class="chan-attrs" open><summary>Canais detalhados (${band.candidates.length})</summary>
<table class="mini-table chan-table"><thead><tr><th></th><th>Canal</th><th>Pontuação</th><th>APs</th><th></th></tr></thead>
<tbody>${rows}</tbody></table>
</details>
${wpa3}
</div>`);
}
function renderResults(aps) {
lastAps = aps;
updateReportButton();
if (viewMode === 'channels') {
renderChannels();
return;
}
if (!aps.length) {
els.count.textContent = '0';
if (lastSnapshot?.warning) {
renderEmptyResult('A placa não está capturando nada', lastSnapshot.warning);
} else {
renderEmptyResult('Escutando o ar…', 'Nenhuma rede vista ainda. Isso pode levar alguns segundos enquanto os canais são varridos.');
}
return;
}
const all = buildGroups(aps);
const groups = sortGroups(filterGroups(all));
const filtering = !!els.searchInput.value.trim();
els.count.textContent = filtering
? `${groups.length} de ${all.length} redes`
: `${all.length} redes · ${aps.length} BSSIDs · ${new Set(aps.flatMap((a) => a.clients || [])).size} clientes`;
if (!groups.length) {
renderEmptyResult('Nenhuma rede corresponde ao filtro', 'Ajuste a busca ou limpe o campo de filtro.');
return;
}
setResultsHtml(`${alertsHtml()}
<div class="ap-card net-card">
<table class="net-table">
<thead><tr><th class="caret-cell"></th><th>Rede</th><th>Segurança</th><th>Canais</th><th>Sinal</th><th>Clientes</th></tr></thead>
<tbody>${groups.map(renderGroup).join('')}</tbody>
</table>
</div>`);
}
function setView(mode) {
viewMode = mode === 'channels' ? 'channels' : 'nets';
try { localStorage.setItem('sabia-view', viewMode); } catch { /* ignore */ }
document.querySelectorAll('#viewNav [data-view]').forEach((b) => {
b.setAttribute('aria-pressed', String(b.dataset.view === viewMode));
});
els.resultTitle.textContent = viewMode === 'channels' ? 'Espectro e canais' : 'Redes vistas';
els.searchInput.disabled = viewMode === 'channels';
els.sortSel.disabled = viewMode === 'channels';
renderResults(lastAps);
}
document.querySelectorAll('#viewNav [data-view]').forEach((b) => {
b.addEventListener('click', () => setView(b.dataset.view));
});
els.resultArea.addEventListener('click', (e) => {
const band = e.target.closest('[data-band]');
if (band) {
channelBand = band.dataset.band;
renderChannels();
return;
}
if (e.target.closest('[data-report-open]')) {
openReport();
}
});
els.searchInput.addEventListener('input', () => renderResults(lastAps));
els.sortSel.addEventListener('change', () => {
try { localStorage.setItem('sabia-sort', els.sortSel.value); } catch { /* storage unavailable */ }
renderResults(lastAps);
});
els.resultArea.addEventListener('click', (e) => {
const btn = e.target.closest('.expander');
if (btn) {
const key = btn.closest('.net-row')?.dataset.key;
if (key == null) return;
if (openGroups.has(key)) openGroups.delete(key);
else openGroups.add(key);
renderResults(lastAps);
return;
}
const target = e.target.closest('[data-bssid]');
if (target) openTarget(target.dataset.bssid);
});
els.resultArea.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
const target = e.target.closest('.bssid-item[data-bssid]');
if (!target) return;
e.preventDefault();
openTarget(target.dataset.bssid);
});
// ---------------------------------------------------------------- target
let lastSnapshot = null;
let selectedBssid = null;
let activeTab = 'overview';
const attack = {
authorized: false,
station: '',
count: 5,
reason: 7,
hsStation: '',
hsOut: '',
wordlist: '',
crackRules: false,
crackMask: '',
eviltwinApIface: '',
eviltwinPortalPort: 8080,
eviltwinDeauth: false,
eviltwinJob: null,
eviltwinInfo: null,
entTwinApIface: '',
entTwinJob: null,
entTwinInfo: null,
wepOut: '',
crackJob: null,
crackInfo: null,
direction: 'ap',
frameKind: 'deauth',
intervalMs: 200,
deauthJob: null,
deauthInfo: null,
timelineMac: '',
pmkidInfo: null,
pmfInfo: null,
wpsTools: null,
wpsLoaded: false,
wpsTool: 'reaver',
wpsJob: null,
wpsInfo: null,
};
const PHY_LABEL = {
n: 'Wi-Fi 4 (802.11n)',
ac: 'Wi-Fi 5 (802.11ac)',
ax: 'Wi-Fi 6/6E (802.11ax)',
be: 'Wi-Fi 7 (802.11be)',
};
function snapshotAps() {
return lastSnapshot?.access_points?.length ? lastSnapshot.access_points : lastAps;
}
function findAp(bssid) {
return snapshotAps().find((a) => a.bssid === bssid) || null;
}
function apsOfGroup(ap) {
if (!ap) return [];
if (ap.hidden || !ap.ssid) return [ap];
return snapshotAps().filter((a) => !a.hidden && a.ssid === ap.ssid);
}
function clientsFor(ap) {
if (!ap) return [];
const found = new Map();
for (const c of lastSnapshot?.clients || []) {
const probes = c.probes || [];
if (c.bssid === ap.bssid || (ap.ssid && probes.includes(ap.ssid))) found.set(c.mac, c);
}
for (const mac of ap.clients || []) {
if (!found.has(mac)) found.set(mac, { mac, bssid: ap.bssid, associated: true, packets: 0, probes: [] });
}
return [...found.values()].sort((a, b) => {
const aa = a.associated && a.bssid === ap.bssid ? 0 : 1;
const bb = b.associated && b.bssid === ap.bssid ? 0 : 1;
return aa - bb || (signalOf(b) ?? -999) - (signalOf(a) ?? -999);
});
}
function handshakesFor(ap) {
return (lastSnapshot?.handshakes || []).filter((h) => h.bssid === ap?.bssid);
}
function fmtTime(unix) {
if (!unix) return '—';
try {
return new Date(unix * 1000).toLocaleTimeString(currentLang() === 'en' ? 'en-US' : 'pt-BR');
} catch {
return new Date(unix * 1000).toISOString().slice(11, 19);
}
}
function yesNo(v) {
return v ? 'sim' : 'não';
}
function pmfLabel(ap) {
if (ap.pmf_required) return 'exigido';
if (ap.pmf) return 'suportado';
return 'desligado';
}
function phyLabel(ap) {
if (!ap.phy?.length) return 'não anunciado';
return ap.phy.map((p) => PHY_LABEL[p] || p).join(' · ');
}
function mono(value) {
return `<span class="mono">${escapeHtml(String(value))}</span>`;
}
function kv(pairs) {
return pairs
.filter(([, value]) => value != null && value !== '')
.map(([label, value]) => `<dt>${escapeHtml(label)}</dt><dd>${value}</dd>`)
.join('');
}
function securityTag(sec) {
return `<span class="sec-tag ${secClass(sec)}">${escapeHtml(sec || '—')}</span>`;
}
function hsBadges(hs) {
const out = [];
if (hs.complete) out.push('<span class="flag flag-ok">completo</span>');
if (hs.crackable) out.push('<span class="flag">quebrável</span>');
if (hs.password) out.push(`<span class="flag flag-ok">senha: ${escapeHtml(hs.password)}</span>`);
if (hs.pmkid) out.push('<span class="flag">PMKID</span>');
return out.join('');
}
function hsListHtml(hs) {
if (!hs.length) return '<p class="hint">Nenhum handshake capturado para este BSSID.</p>';
return `<div class="hs-list">${hs
.map(
(h) => `<div class="hs-item">
<div class="hs-main"><span class="mono">${escapeHtml(h.station)}</span>
<span class="hs-meta">${escapeHtml(h.kind)} · mensagens ${escapeHtml(h.messages || '—')}</span></div>
<div class="hs-flags">${hsBadges(h)}</div>
</div>`,
)
.join('')}</div>`;
}
function riskHtml(ap) {
const notes = [];
if (ap.security === 'Open') {
notes.push(
'<div class="risk risk-bad"><strong>Rede aberta.</strong> O tráfego não é cifrado pelo Wi-Fi; qualquer cliente pode se associar.</div>',
);
}
if (ap.security === 'WEP') {
notes.push(
'<div class="risk risk-bad"><strong>WEP.</strong> A cifra é quebrável; capture IVs na aba Ataques.</div>',
);
}
if (ap.wps) {
notes.push(
`<div class="risk risk-warn"><strong>WPS ativo${ap.wps_version ? ` (${escapeHtml(ap.wps_version)})` : ''}.</strong> ${ap.wps_locked ? 'O AP anuncia configuração travada.' : 'PIN pode estar acessível; verifique o lock.'}</div>`,
);
}
if (ap.pmf_required) {
notes.push(
'<div class="risk risk-info"><strong>PMF exigido.</strong> Desautenticação e captura de handshake podem ser ignoradas pelos clientes.</div>',
);
} else if (ap.security?.startsWith('WPA2') || ap.security?.startsWith('WPA3') || ap.security === 'WPA') {
notes.push(
'<div class="risk risk-warn"><strong>Sem PMF obrigatório.</strong> Desautenticação tende a funcionar e facilita a captura do handshake.</div>',
);
}
return notes.join('');
}
function renderOverview(ap, clients, hs) {
const group = apsOfGroup(ap);
const assoc = clients.filter((c) => c.associated && (!c.bssid || c.bssid === ap.bssid));
const crackable = hs.filter((h) => h.crackable);
const channels = [...new Set(group.map((a) => a.channel).filter((v) => v != null))].sort((a, b) => a - b);
const bands = [...new Set(group.map((a) => a.band).filter(Boolean))];
return `
${riskHtml(ap)}
<div class="detail-grid">
<section class="detail-card">
<h3>Identificação</h3>
<dl class="kv">${kv([
['SSID', ap.ssid ? escapeHtml(ap.ssid) : '<em>oculta</em>'],
['BSSID', mono(ap.bssid)],
['Fabricante', ap.vendor ? escapeHtml(ap.vendor) : '—'],
['Oculta', yesNo(ap.hidden || !ap.ssid)],
['País', ap.country ? escapeHtml(ap.country) : '—'],
['Primeiro visto', fmtTime(ap.first_seen_unix)],
['Último visto', fmtTime(ap.last_seen_unix)],
])}</dl>
</section>
<section class="detail-card">
<h3>Rádio</h3>
<dl class="kv">${kv([
[bands.length > 1 ? 'Bandas' : 'Banda', bands.length ? escapeHtml(bands.join(' · ')) : '—'],
['Frequência', ap.freq_mhz ? `${ap.freq_mhz} MHz` : '—'],
[channels.length > 1 ? 'Canais' : 'Canal', channels.length ? escapeHtml(channels.join(' · ')) : ap.channel != null ? String(ap.channel) : '—'],
['Largura', ap.width_mhz ? `${ap.width_mhz} MHz` : '—'],
['Padrão', escapeHtml(phyLabel(ap))],
['Sinal', signalOf(ap) != null ? `${signalBars(signalOf(ap))}${signalOf(ap)} dBm` : '—'],
['Sinal máximo', ap.max_signal_dbm != null ? `${ap.max_signal_dbm} dBm` : '—'],
['Intervalo de beacon', ap.beacon_interval ? `${ap.beacon_interval} TU` : '—'],
['Beacons', String(ap.beacons ?? 0)],
['Quadros de dados', String(ap.data_frames ?? 0)],
])}</dl>
</section>
<section class="detail-card">
<h3>Segurança</h3>
<dl class="kv">${kv([
['Modo', securityTag(ap.security)],
['Cifra', ap.cipher ? escapeHtml(ap.cipher) : '—'],
['AKM', ap.akm?.length ? escapeHtml(ap.akm.join(' · ')) : '—'],
['PMF', pmfLabel(ap)],
['Privacidade', yesNo(ap.privacy)],
['WPS', ap.wps ? 'ativo' : 'não anunciado'],
['Versão WPS', ap.wps_version ? escapeHtml(ap.wps_version) : '—'],
['WPS travado', ap.wps ? yesNo(ap.wps_locked) : '—'],
])}</dl>
</section>
<section class="detail-card">
<h3>Clientes e handshake</h3>
<dl class="kv">${kv([
['Associados', String(assoc.length)],
['Vistos na rede', String(clients.length)],
['Handshakes', String(hs.length)],
['Quebráveis', String(crackable.length)],
['Senha', hs.find((h) => h.password)?.password ? mono(hs.find((h) => h.password).password) : '—'],
])}</dl>
<div class="detail-actions">
<button type="button" class="btn small" data-jump="clients">Ver clientes</button>
<button type="button" class="btn small" data-jump="attacks">Abrir ataques</button>
</div>
</section>
</div>
${ap.wps ? `<section class="detail-card detail-wide"><h3>WPS</h3><dl class="kv">${kv([
['Fabricante', ap.wps_manufacturer ? escapeHtml(ap.wps_manufacturer) : '—'],
['Modelo', ap.wps_model ? escapeHtml(ap.wps_model) : '—'],
['Número do modelo', ap.wps_model_number ? escapeHtml(ap.wps_model_number) : '—'],
['Nome do dispositivo', ap.wps_device_name ? escapeHtml(ap.wps_device_name) : '—'],
['Serial', ap.wps_serial ? escapeHtml(ap.wps_serial) : '—'],
])}</dl></section>` : ''}`;
}
const EVENT_LABEL = {
first_seen: 'primeiro visto',
probe: 'probe',
associate: 'associou',
deauth: 'desautenticado',
};
/** Full-size RSSI sparkline (wider than the 62px table mini-chart) for the
* client detail signal card. */
function bigSparkline(history) {
const points = (history || []).slice(-60);
if (points.length < 2) return '<p class="hint">Sem amostras de sinal suficientes ainda.</p>';
const W = 560;
const H = 70;
const dbms = points.map((p) => p.dbm ?? -90);
const min = Math.min(...dbms, -90);
const max = Math.max(...dbms, -30);
const span = Math.max(1, max - min);
const d = points
.map((p, i) => {
const x = (i / (points.length - 1)) * W;
const y = H - (((p.dbm ?? min) - min) / span) * (H - 6) - 3;
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)} ${y.toFixed(1)}`;
})
.join(' ');
return `<svg class="spark spark-big" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" aria-hidden="true"><path d="${d}"/></svg>`;
}
/** Detailed client view: consolidates everything captured about a station —
* identity hints, associated AP, signal history, and event timeline — in one modal. */
function renderClientDetail(clients) {
const client = clients.find((c) => c.mac === attack.timelineMac);
if (!client) return '';
const events = (client.events || []).slice(-12).reverse();
const rows = events.length
? events.map((e) => `<li><span class="mono">${fmtTime(e.t)}</span><span class="tl-kind tl-${escapeAttr(e.kind)}">${escapeHtml(EVENT_LABEL[e.kind] || e.kind)}</span><span class="tl-detail">${escapeHtml(e.detail || '')}</span></li>`).join('')
: '<li class="hint">Ainda sem eventos além do primeiro avistamento.</li>';
const sig = signalOf(client);
const yesNo = (v) => (v ? 'sim' : 'não');
return `<section class="detail-card detail-wide timeline-card">
<div class="timeline-head"><h3>Cliente · ${escapeHtml(clientName(client))} <span class="mono">${escapeHtml(client.mac)}</span></h3>
<button type="button" class="btn small" data-act="timeline-close">fechar</button></div>
<div class="detail-grid">
<section class="detail-card">
<h3>Identificação</h3>
<dl class="kv">${kv([
['MAC', mono(client.mac)],
['Nome exibido', client.display_name ? escapeHtml(client.display_name) : null],
['Fabricante', client.manufacturer || client.vendor ? escapeHtml(client.manufacturer || client.vendor) : null],
['Modelo', client.model ? escapeHtml(client.model) : null],
['Nome do dispositivo', client.device_name ? escapeHtml(client.device_name) : null],
['Hostname', client.hostname ? escapeHtml(client.hostname) : null],
['MAC aleatório', yesNo(client.random_mac)],
['Primeiro visto', fmtTime(client.first_seen_unix)],
['Último visto', fmtTime(client.last_seen_unix)],
])}</dl>
</section>
<section class="detail-card">
<h3>Rede</h3>
<dl class="kv">${kv([
['Dispositivo', client.device ? escapeHtml(client.device) : null],
['Sistema', client.os ? escapeHtml(client.os) : null],
['IP', client.ip ? mono(client.ip) : null],
['Associado a', client.bssid ? mono(client.bssid) : null],
['Estado', client.associated ? '<span class="flag flag-ok">associado</span>' : '<span class="flag">visto</span>'],
['Pacotes', String(client.packets ?? 0)],
['Probes', (client.probes || []).length ? escapeHtml(client.probes.join(', ')) : null],
])}</dl>
</section>
<section class="detail-card detail-wide">
<h3>Sinal</h3>
<dl class="kv">${kv([
['Atual', sig != null ? `${signalBars(sig)}${sig} dBm` : '—'],
['Máximo', client.max_signal_dbm != null ? `${client.max_signal_dbm} dBm` : '—'],
])}</dl>
${bigSparkline(client.signal_history)}
</section>
</div>
<p class="hint" style="margin-top:14px">Linha do tempo · eventos mais recentes primeiro.</p>
<ul class="timeline">${rows}</ul>
</section>`;
}
function clientName(c) {
if (c.display_name) return c.display_name;
if (c.hostname) return c.hostname;
if (c.device_name) return c.device_name;
if (c.model) return c.model;
if (c.manufacturer && c.manufacturer !== 'Unknown') return c.manufacturer;
if (c.vendor && c.vendor !== 'Unknown') return c.vendor;
return `Cliente ${String(c.mac || '').slice(-5).replace(':', '')}`;
}
function clientDeviceText(c) {
const parts = [];
if (c.device) parts.push(c.device);
if (c.os) parts.push(c.os);
if (c.ip) parts.push(c.ip);
if (c.random_mac) parts.push('MAC aleatório');
return parts.length ? parts.join(' · ') : '—';
}
function clientOptionLabel(c) {
const meta = [c.mac];
if (c.device) meta.push(c.device);
else if (c.vendor) meta.push(c.vendor);
return `${clientName(c)} · ${meta.join(' · ')}`;
}
function renderClients(ap, clients) {
if (!clients.length) {
return `<div class="empty-inline"><strong>Nenhum cliente visto.</strong><p>Associe ou gere tráfego na rede para que o Sabiá identifique estações.</p></div>`;
}
const rows = clients
.map((c) => {
const associated = c.associated && (!c.bssid || c.bssid === ap.bssid);
const probing = (c.probes || []).includes(ap.ssid);
const state = associated ? '<span class="flag flag-ok">associado</span>' : probing ? '<span class="flag">sondando</span>' : '<span class="flag">visto</span>';
const probes = (c.probes || []).length ? escapeHtml(c.probes.join(', ')) : '—';
const sig = signalOf(c);
const notAssoc = 'Cliente não associado a este AP.';
const name = clientName(c);
const details = [c.mac];
if (c.manufacturer && c.manufacturer !== name) details.push(c.manufacturer);
if (c.model && c.model !== name) details.push(c.model);
const device = clientDeviceText(c);
return `<tr>
<td><div class="client-name">${escapeHtml(name)}</div><div class="client-sub mono">${escapeHtml(details.join(' · '))}</div></td>
<td class="device-cell" title="${escapeAttr(device)}">${escapeHtml(device)}</td>
<td class="nowrap">${sig != null ? `${signalBars(sig)}${sig} dBm` : '—'}</td>
<td class="nowrap">${c.packets ?? 0}</td>
<td>${state}</td>
<td class="probes" title="${escapeAttr((c.probes || []).join(', '))}">${probes}</td>
<td class="row-actions"><button type="button" class="btn small" data-act="attack-client" data-mac="${escapeAttr(c.mac)}"${associated ? '' : ` disabled title="${notAssoc}"`}>Atacar</button><button type="button" class="btn small" data-act="timeline" data-mac="${escapeAttr(c.mac)}">Detalhes</button></td>
</tr>`;
})
.join('');
return `<div class="ap-card net-card clients-card"><table class="mini-table">
<thead><tr><th>Cliente</th><th>Dispositivo</th><th>Sinal</th><th>Pacotes</th><th>Estado</th><th>Probes</th><th>Ações</th></tr></thead>
<tbody>${rows}</tbody></table></div>${renderClientDetail(clients)}`;
}
function crackHtml() {
const info = attack.crackInfo;
if (!info) return '';
if (info.status === 'running') {
return `<div class="crack-box"><div class="crack-head"><span class="flag flag-warn">quebrando</span><span>${info.tried ?? 0} senhas testadas</span></div><div class="crack-bar"><i></i></div></div>`;
}
if (info.status === 'error' || info.error) {
return `<div class="crack-box"><span class="flag flag-bad">erro</span> ${escapeHtml(info.error || 'falha ao quebrar')}</div>`;
}
const report = info.report || info;
if (report.password) {
return `<div class="crack-box crack-ok"><span class="flag flag-ok">senha encontrada</span> <span class="mono">${escapeHtml(report.password)}</span><span class="hint">${report.tried ?? info.tried ?? 0} tentativas · ${report.elapsed_ms ?? 0} ms</span></div>`;
}
return `<div class="crack-box"><span class="flag">concluído</span> Senha não encontrada no dicionário (${report.tried ?? info.tried ?? 0} tentativas).</div>`;
}
function directionLabel(direction) {
return { ap: 'AP → cliente', sta: 'cliente → AP', both: 'ambas' }[direction] || direction;
}
function jobStatusLabel(status) {
return {
running: 'em execução',
starting: 'iniciando',
done: 'concluído',
error: 'erro',
stopped: 'parado',
}[status] || status;
}
function wpsOptions() {
const tools = attack.wpsTools || [];
if (!tools.length) return '<option value="">nenhuma ferramenta disponível</option>';
return tools
.map((t) => `<option value="${escapeAttr(t.name)}"${attack.wpsTool === t.name ? ' selected' : ''}>${escapeHtml(t.name)} · ${escapeHtml(t.path)}</option>`)
.join('');
}
function wpsHtml() {
const tools = attack.wpsTools || [];
const job = attack.wpsInfo;
const running = !!job && (job.status === 'running' || job.status === 'starting');
const output = (job?.output || []).slice(-12).join('\n');
const result = job
? job.password
? `Senha encontrada: ${job.password}${job.pin ? ` (PIN ${job.pin})` : ''}`
: job.error
? `Falha: ${job.error}`
: `Status: ${jobStatusLabel(job.status)}${job.pin ? ` · PIN ${job.pin}` : ''}`
: tools.length
? 'O reaver embutido usa Pixie Dust sem PIN. O bully aparece se estiver instalado no sistema.'
: 'O reaver embutido não pôde ser extraído nesta execução.';
return `<section class="attack-card wps-card">
<h3>WPS PIN</h3>
<p class="hint">Orquestra reaver/bully na interface monitor. O canal do AP é usado automaticamente; o processo pode ser interrompido a qualquer momento.</p>
<div class="field-row">
<label class="field"><span class="field-label">Ferramenta</span><select id="wpsTool" class="select">${wpsOptions()}</select></label>
<label class="field"><span class="field-label">PIN conhecido (opcional)</span><input id="wpsPin" class="input mono" inputmode="numeric" maxlength="8" placeholder="8 dígitos"></label>
</div>
<div class="detail-actions">
<button type="button" class="btn primary" data-act="wps-start" data-needs-auth ${tools.length && !running ? '' : 'disabled'}>Iniciar WPS</button>
<button type="button" class="btn" data-act="wps-stop" ${running ? '' : 'disabled'}>Parar job</button>
</div>
<p class="hint" id="wpsResult">${escapeHtml(result)}</p>
<pre class="term" id="wpsOutput">${escapeHtml(output || 'A saída do reaver/bully aparece aqui.')}</pre>
</section>`;
}
function eviltwinHtml() {
const job = attack.eviltwinInfo;
const running = !!job && job.status === 'running';
const attemptWord = { correct: 'senha certa', wrong: 'senha errada', invalid: 'tamanho inválido' };
const result = job
? job.password
? `Senha confirmada: ${job.password}`
: job.error
? `Falha: ${job.error}`
: `${jobStatusLabel(job.status)} · ${job.attempts ?? 0} tentativa(s) no portal${job.last_attempt ? ` · última: ${attemptWord[job.last_attempt] || job.last_attempt}` : ''}`
: 'Clona o AP (aberto) numa segunda interface e sobe um portal pedindo a senha de novo. A senha só é aceita se bater de verdade com o handshake já capturado — não é um "aceita qualquer coisa".';
return `<section class="attack-card attack-wide eviltwin-card">
<h3>Evil Twin + portal cativo</h3>
<p class="hint">Exige um handshake quebrável já capturado deste AP (aba Handshake). Precisa de <span class="mono">hostapd</span> instalado e de uma <strong>segunda</strong> interface Wi-Fi em modo AP, diferente da que está capturando.</p>
<div class="field-row">
<label class="field"><span class="field-label">Interface do clone (2ª placa)</span><input id="eviltwinIface" class="input mono" value="${escapeAttr(attack.eviltwinApIface || '')}" placeholder="ex.: wlan1" spellcheck="false"></label>
<label class="field"><span class="field-label">Porta do portal</span><input id="eviltwinPort" class="input" type="number" min="1" max="65535" value="${attack.eviltwinPortalPort || 8080}"></label>
</div>
<label class="auth-check"><input type="checkbox" id="eviltwinDeauth"${attack.eviltwinDeauth ? ' checked' : ''}> Desautenticar o AP real em paralelo (empurra os clientes pro clone)</label>
<div class="detail-actions">
<button type="button" class="btn primary" data-act="eviltwin-start" data-needs-auth ${running ? 'disabled' : ''}>Iniciar Evil Twin</button>
<button type="button" class="btn" data-act="eviltwin-stop" ${running ? '' : 'disabled'}>Parar</button>
</div>
<p class="hint${job?.error ? ' bad' : ''}" id="eviltwinResult">${escapeHtml(result)}</p>
</section>`;
}
function enterpriseTwinHtml(ap) {
if (!ap || !(ap.akm || []).includes('802.1X')) return '';
const job = attack.entTwinInfo;
const running = !!job && job.status === 'running';
const result = job
? job.error
? `Falha: ${job.error}`
: `${jobStatusLabel(job.status)} · ${job.captured ?? 0} credencial(is) capturada(s)${job.last_identity ? ` · última: ${job.last_identity}` : ''}`
: 'Clona o AP numa segunda interface e deixa o hostapd-wpe aceitar a autenticação EAP de qualquer cliente. A credencial sai sozinha, como parte do próprio 802.1X — sem portal, sem ação humana.';
return `<section class="attack-card attack-wide eviltwin-card">
<h3>Evil Twin Enterprise (802.1X)</h3>
<p class="hint">Rede Enterprise detectada (AKM 802.1X) — aqui a credencial é enviada automaticamente pelo cliente durante o handshake EAP, não precisa de portal. Exige <span class="mono">hostapd-wpe</span> instalado (não vem com o Sabiá: é um patch no build do hostapd — <a href="https://github.com/aircrack-ng/hostapd-wpe" target="_blank" rel="noopener">github.com/aircrack-ng/hostapd-wpe</a>) e uma <strong>segunda</strong> interface Wi-Fi em modo AP.</p>
<div class="field-row">
<label class="field"><span class="field-label">Interface do clone (2ª placa)</span><input id="entTwinIface" class="input mono" value="${escapeAttr(attack.entTwinApIface || '')}" placeholder="ex.: wlan1" spellcheck="false"></label>
</div>
<div class="detail-actions">
<button type="button" class="btn primary" data-act="enttwin-start" data-needs-auth ${running ? 'disabled' : ''}>Iniciar Evil Twin Enterprise</button>
<button type="button" class="btn" data-act="enttwin-stop" ${running ? '' : 'disabled'}>Parar</button>
</div>
<p class="hint${job?.error ? ' bad' : ''}" id="entTwinResult">${escapeHtml(result)}</p>
${job && job.captured ? `<p class="hint">Log de credenciais (p/ <span class="mono">john --format=netntlm</span> ou reformatar pro hashcat -m 5500): <span class="mono">${escapeHtml(job.log_path)}</span></p>` : ''}
<p class="hint">Limitação honesta: o certificado TLS do clone é autoassinado. Dispositivo que valida a cadeia de certificado da rede (comum em perfil corporativo/MDM atualizado) vai recusar ou avisar o usuário — essa é a defesa que deveria existir. Funciona contra configuração sem essa validação.</p>
</section>`;
}
function deauthLiveText() {
const job = attack.deauthInfo;
if (!job) return 'Pronto. O job contínuo trava a captura no canal do AP e para quando você mandar.';
if (job.error) return `Falha: ${job.error}`;
const channel = job.channel != null ? ` · canal ${job.channel}` : '';
if (job.status === 'running' || job.status === 'starting') {
return `Job ${job.id} · ${job.frames_sent ?? 0} quadro(s)${channel}${job.stop_on_handshake ? ' · para sozinho no handshake' : ''}`;
}
if (job.handshake) return `Handshake capturado em ${job.frames_sent ?? 0} quadro(s)${channel}.`;
return `Job ${job.id} ${jobStatusLabel(job.status)} · ${job.frames_sent ?? 0} quadro(s)${channel}.`;
}
function deauthRunning() {
const status = attack.deauthInfo?.status;
return !!attack.deauthJob && (status === 'running' || status === 'starting');
}
function renderAttacks(ap, clients, hs) {
const crackable = hs.filter((h) => h.crackable);
const stationOptions = clients
.map((c) => `<option value="${escapeAttr(c.mac)}"${attack.station === c.mac ? ' selected' : ''}>${escapeHtml(clientOptionLabel(c))}</option>`)
.join('');
const hsOptions = hs
.map((h) => `<option value="${escapeAttr(h.station)}"${attack.hsStation === h.station ? ' selected' : ''}>${escapeHtml(h.station)} · ${escapeHtml(h.kind)}${h.crackable ? ' · quebrável' : ''}</option>`)
.join('');
const pmfStation = attack.station || clients[0]?.mac || '';
return `
<div class="risk risk-warn"><strong>Somente em redes próprias ou com autorização escrita.</strong> Cada ação fica registrada na trilha de auditoria local, com interface, BSSID e estação.</div>
<label class="auth-check"><input type="checkbox" id="authChk"${attack.authorized ? ' checked' : ''}> Tenho autorização para testar esta rede</label>
<div class="attack-grid">
<section class="attack-card attack-deauth">
<h3>Desautenticação</h3>
<p class="hint">Mantém os quadros saindo em série até você parar (ou até sair o handshake). O canal da captura fica travado no AP durante o job.</p>
<label class="field"><span class="field-label">Cliente alvo</span>
<select id="deauthStation" class="select">
<option value=""${attack.station === '' ? ' selected' : ''}>Todos (broadcast)</option>
${stationOptions}
</select>
</label>
<p class="hint" id="deauthScope">${attack.station ? 'Somente o cliente selecionado será desautenticado.' : 'Todos os clientes associados (broadcast).'}</p>
<div class="field-row">
<label class="field"><span class="field-label">Direção</span>
<select id="deauthDirection" class="select">
<option value="ap"${attack.direction === 'ap' ? ' selected' : ''}>AP → cliente</option>
<option value="sta"${attack.direction === 'sta' ? ' selected' : ''}>cliente → AP</option>
<option value="both"${attack.direction === 'both' ? ' selected' : ''}>ambas</option>
</select>
</label>
<label class="field"><span class="field-label">Quadro</span>
<select id="deauthFrame" class="select">
<option value="deauth"${attack.frameKind === 'deauth' ? ' selected' : ''}>deauth</option>
<option value="disassoc"${attack.frameKind === 'disassoc' ? ' selected' : ''}>disassoc</option>
</select>
</label>
</div>
<div class="field-row">
<label class="field"><span class="field-label">Intervalo</span>
<select id="deauthInterval" class="select">
${[[100, '100 ms'], [200, '200 ms'], [500, '500 ms'], [1000, '1 s']]
.map(([v, l]) => `<option value="${v}"${attack.intervalMs === v ? ' selected' : ''}>${l}</option>`)
.join('')}
</select>
</label>
<label class="field"><span class="field-label">Motivo</span>
<select id="deauthReason" class="select">
${[[1, 'não especificado'], [2, 'autenticação expirada'], [3, 'saindo'], [4, 'inativo'], [5, 'AP sem recursos'], [6, 'classe 2'], [7, 'classe 3']]
.map(([v, l]) => `<option value="${v}"${attack.reason === v ? ' selected' : ''}>${l} (${v})</option>`)
.join('')}
</select>
</label>
</div>
<div class="detail-actions">
<button type="button" class="btn primary" data-act="deauth-start" data-needs-auth ${deauthRunning() ? 'disabled' : ''}>Iniciar contínuo</button>
<button type="button" class="btn" data-act="deauth-stop" ${deauthRunning() ? '' : 'disabled'}>Parar</button>
<button type="button" class="btn" data-act="deauth-handshake" data-needs-auth ${deauthRunning() ? 'disabled' : ''}>Forçar handshake</button>
<button type="button" class="btn" data-act="deauth" data-needs-auth ${deauthRunning() ? 'disabled' : ''}>Rajada (${attack.count})</button>
</div>
<p class="hint" id="deauthResult">${escapeHtml(deauthLiveText())}</p>
</section>
<section class="attack-card">
<h3>PMF (802.11w)</h3>
<p class="hint">Dispara deauth nas duas direções e observa se a estação reassocia. Resultado prático, não prova criptográfica.</p>
<label class="field"><span class="field-label">Estação</span>
<select id="pmfStation" class="select">${stationOptions || '<option value="">nenhum cliente visto</option>'}</select>
</label>
<button type="button" class="btn primary" data-act="pmf" data-needs-auth ${clients.length ? '' : 'disabled'}>Testar PMF</button>
<p class="hint" id="pmfResult"></p>
</section>
<section class="attack-card">
<h3>PMKID clientless</h3>
<p class="hint">Forja um MAC local e envia association request com PMKID para tentar a mensagem 1. Muitos APs só expõem PMKID em roaming real.</p>
<label class="field"><span class="field-label">Tentativas</span><input id="pmkidAttempts" class="input" type="number" min="1" max="50" value="5"></label>
<button type="button" class="btn primary" data-act="pmkid" data-needs-auth>Caçar PMKID</button>
<p class="hint" id="pmkidResult"></p>
</section>
<section class="attack-card">
<h3>Handshake</h3>
<p class="hint" id="hsSummary">${hs.length ? `${hs.length} handshake(s) visto(s); ${crackable.length} quebrável(is).` : 'Nenhum handshake capturado ainda. Desautentique um cliente para estimular a reassociação.'}</p>
<div id="hsList">${hsListHtml(hs)}</div>
<label class="field"><span class="field-label">Handshake</span>
<select id="hsStation" class="select">${hsOptions || '<option value="">nenhum</option>'}</select>
</label>
<label class="field"><span class="field-label">Salvar pcap em</span><input id="hsOut" class="input mono" value="${escapeAttr(attack.hsOut)}" spellcheck="false"></label>
<div class="detail-actions">
<button type="button" class="btn" data-act="export-hs" data-needs-auth ${hs.length ? '' : 'data-locked="1"'}>Exportar selecionado</button>
<button type="button" class="btn" data-act="export-all" data-needs-auth ${hs.length ? '' : 'data-locked="1"'}>Exportar todos</button>
<button type="button" class="btn" data-act="export-hc" data-needs-auth ${hs.length ? '' : 'data-locked="1"'}>Exportar .hc22000</button>
<button type="button" class="btn" data-act="export-all-hc" data-needs-auth ${hs.length ? '' : 'data-locked="1"'}>Todos .hc22000</button>
</div>
<p class="hint" id="exportResult"></p>
<label class="field"><span class="field-label">Dicionário (vazio = embutido, 9,7 milhões de senhas)</span><input id="wordlist" class="input mono" value="${escapeAttr(attack.wordlist)}" placeholder="dicionário próprio, opcional" spellcheck="false"></label>
<label class="auth-check"><input type="checkbox" id="crackRules"${attack.crackRules ? ' checked' : ''}> Aplicar regras de mutação (maiúscula, sufixos, leet — multiplica o tempo)</label>
<button type="button" class="btn primary" data-act="crack" data-needs-auth ${crackable.length ? '' : 'data-locked="1"'}>Quebrar senha</button>
<div id="crackProgress">${crackHtml()}</div>
<p class="hint" style="margin-top:14px">Força bruta por máscara — sem dicionário, testa toda combinação do padrão. <span class="mono">?d</span> dígito, <span class="mono">?l</span> minúscula, <span class="mono">?u</span> maiúscula, <span class="mono">?s</span> símbolo, <span class="mono">?a</span> qualquer um; o resto é literal.</p>
<label class="field"><span class="field-label">Máscara</span><input id="crackMask" class="input mono" value="${escapeAttr(attack.crackMask || '')}" placeholder="ex.: ?d?d?d?d?d?d?d?d" spellcheck="false"></label>
<button type="button" class="btn primary" data-act="crack-mask" data-needs-auth ${crackable.length ? '' : 'data-locked="1"'}>Força bruta por máscara</button>
</section>
${wpsHtml()}
${eviltwinHtml()}
${enterpriseTwinHtml(ap)}
${ap.security === 'WEP' ? `<section class="attack-card attack-wide">
<h3>Captura WEP</h3>
<p class="hint">WEP não tem handshake: o ataque coleta IVs em um .pcap. Reinicie a captura gravando o arquivo e gere tráfego com desautenticação. A captura atual é reiniciada e a tabela começa vazia.</p>
<label class="field"><span class="field-label">Arquivo pcap</span><input id="wepOut" class="input mono" value="${escapeAttr(attack.wepOut)}" spellcheck="false"></label>
<div class="detail-actions">
<button type="button" class="btn primary" data-act="wep-restart" data-needs-auth>Reiniciar captura com pcap</button>
<button type="button" class="btn" data-act="wep-deauth" data-needs-auth>Gerar tráfego</button>
<button type="button" class="btn" data-act="wep-copy">Copiar comando</button>
</div>
<p class="hint" id="wepResult"></p>
<p class="hint">Depois de coletar IVs suficientes: <code>aircrack-ng ${escapeHtml(attack.wepOut || 'captura.pcap')}</code></p>
</section>` : `<section class="attack-card"><h3>WEP</h3><p class="hint">Esta rede não usa WEP.</p></section>`}
</div>`;
}
function updateAuthUi() {
const needsStation = attack.direction !== 'ap' && !attack.station;
els.tabPanel.querySelectorAll('[data-needs-auth]').forEach((b) => {
const deauth = b.dataset.act === 'deauth' || b.dataset.act === 'deauth-handshake' || b.dataset.act === 'deauth-start';
b.disabled = !attack.authorized || b.dataset.locked === '1' || (deauth && needsStation);
});
const running = deauthRunning();
const start = els.tabPanel.querySelector('[data-act="deauth-start"]');
const stop = els.tabPanel.querySelector('[data-act="deauth-stop"]');
const burst = els.tabPanel.querySelector('[data-act="deauth"]');
const handshake = els.tabPanel.querySelector('[data-act="deauth-handshake"]');
if (start) start.disabled = running || !attack.authorized || needsStation;
if (stop) stop.disabled = !running;
if (burst) burst.disabled = running || !attack.authorized || needsStation;
if (handshake) handshake.disabled = running || !attack.authorized || needsStation;
}
async function ensureWpsTools() {
if (attack.wpsLoaded) return;
attack.wpsLoaded = true;
const tools = await api('/api/wps/tools');
attack.wpsTools = Array.isArray(tools) ? tools : [];
if (attack.wpsTools.length && !attack.wpsTools.some((t) => t.name === attack.wpsTool)) {
attack.wpsTool = attack.wpsTools[0].name;
}
if (activeTab === 'attacks') renderTarget(true);
}
function setResult(id, text, bad) {
const el = document.getElementById(id);
if (!el) return;
el.textContent = text;
el.classList.toggle('bad', !!bad);
}
function openTarget(bssid) {
const ap = findAp(bssid);
if (!ap) return;
if (attack.deauthJob) void stopDeauthJob();
selectedBssid = bssid;
activeTab = 'overview';
attack.deauthInfo = null;
attack.authorized = false;
attack.station = '';
attack.hsStation = '';
attack.crackJob = null;
attack.crackInfo = null;
attack.timelineMac = '';
const slug = bssid.replace(/[^0-9a-z]/gi, '');
attack.hsOut = `sabia-${slug}.pcap`;
attack.wepOut = `sabia-wep-${slug}.pcap`;
renderTarget(true);
if (!els.targetDlg.open) els.targetDlg.showModal();
void loadSnapshot();
}
async function loadSnapshot() {
if (!current) return;
const snap = await api(`/api/scan/snapshot?iface=${encodeURIComponent(current)}`);
if (snap?.access_points) {
lastSnapshot = snap;
renderResults(snap.access_points);
if (selectedBssid) renderTarget(false);
}
}
// While startDeauthJob is restarting capture and waiting for the AP
// to reappear in the scan, the fresh session returns empty for a few poll
// cycles — without this guard, renderTarget() treats "AP not seen yet"
// as "AP gone" and closes the modal underneath the user mid-action.
//
let resumingForBssid = null;
function renderTarget(force) {
const ap = findAp(selectedBssid);
if (!ap) {
if (els.targetDlg.open && selectedBssid !== resumingForBssid) els.targetDlg.close();
return;
}
if (!els.targetDlg.open) return;
const group = apsOfGroup(ap);
const clients = clientsFor(ap);
const hs = handshakesFor(ap);
els.targetTitle.textContent = ap.hidden || !ap.ssid ? 'Rede oculta' : ap.ssid;
const sub = [mono(ap.bssid)];
if (ap.vendor) sub.push(escapeHtml(ap.vendor));
sub.push(ap.channel != null ? `canal ${ap.channel}` : 'canal —');
if (ap.band) sub.push(escapeHtml(ap.band));
sub.push(securityTag(ap.security));
const selector = group.length > 1
? `<select id="bssidSel" class="select compact bssid-select" aria-label="BSSID">${group
.slice()
.sort((a, b) => (signalOf(b) ?? -999) - (signalOf(a) ?? -999))
.map((a) => `<option value="${escapeAttr(a.bssid)}"${a.bssid === ap.bssid ? ' selected' : ''}>${escapeHtml(a.bssid)} · ${a.channel != null ? `canal ${a.channel}` : '—'}</option>`)
.join('')}</select>`
: '';
els.targetSub.innerHTML = `${sub.join(' <span class="dot-sep">·</span> ')}${selector}`;
els.tabClientsCount.textContent = String(clients.length);
els.tabPanel.dataset.tab = activeTab;
document.querySelectorAll('#targetDlg .tabs [data-tab]').forEach((b) => {
b.setAttribute('aria-selected', String(b.dataset.tab === activeTab));
});
if (activeTab === 'attacks' && !force) {
updateAttackLive(ap, hs);
return;
}
els.tabPanel.innerHTML =
activeTab === 'clients' ? renderClients(ap, clients) : activeTab === 'attacks' ? renderAttacks(ap, clients, hs) : renderOverview(ap, clients, hs);
if (activeTab === 'attacks') {
updateAuthUi();
void ensureWpsTools();
}
const sel = document.getElementById('bssidSel');
if (sel) sel.addEventListener('change', () => openTarget(sel.value));
const deauthSel = document.getElementById('deauthStation');
if (deauthSel) {
deauthSel.addEventListener('change', () => {
attack.station = deauthSel.value;
const scope = document.getElementById('deauthScope');
if (scope) {
scope.textContent = attack.direction === 'sta'
? 'Do cliente para o AP (exige estação selecionada).'
: attack.direction === 'both'
? 'Nas duas direções (exige estação selecionada).'
: attack.station
? 'Somente o cliente selecionado será desautenticado.'
: 'Todos os clientes associados (broadcast).';
}
updateAuthUi();
});
}
}
function updateAttackLive(ap, hs) {
const crackable = hs.filter((h) => h.crackable);
document.querySelectorAll('#tabPanel [data-act="export-hs"], #tabPanel [data-act="export-all"], #tabPanel [data-act="export-hc"], #tabPanel [data-act="export-all-hc"]').forEach((b) => {
if (hs.length) delete b.dataset.locked;
else b.dataset.locked = '1';
});
document.querySelectorAll('#tabPanel [data-act="crack"]').forEach((b) => {
if (crackable.length) delete b.dataset.locked;
else b.dataset.locked = '1';
});
const summary = document.getElementById('hsSummary');
if (summary) {
const crackable = hs.filter((h) => h.crackable);
summary.textContent = hs.length
? `${hs.length} handshake(s) visto(s); ${crackable.length} quebrável(is).`
: 'Nenhum handshake capturado ainda. Desautentique um cliente para estimular a reassociação.';
}
const list = document.getElementById('hsList');
if (list) list.innerHTML = hsListHtml(hs);
const progress = document.getElementById('crackProgress');
if (progress) progress.innerHTML = crackHtml();
const deauthResult = document.getElementById('deauthResult');
if (deauthResult) {
deauthResult.textContent = deauthLiveText();
deauthResult.classList.toggle('bad', !!attack.deauthInfo?.error);
}
updateAuthUi();
}
async function pollCrackJob() {
if (!attack.crackJob) return;
const job = await api(`/api/crack?id=${encodeURIComponent(attack.crackJob)}`);
if (!job || job.error) {
attack.crackJob = null;
attack.crackInfo = { status: 'error', error: job?.error || 'job não encontrado' };
const el = document.getElementById('crackProgress');
if (el) el.innerHTML = crackHtml();
return;
}
attack.crackInfo = job;
if (job.status === 'running') {
const el = document.getElementById('crackProgress');
if (el) el.innerHTML = crackHtml();
return;
}
attack.crackJob = null;
const el = document.getElementById('crackProgress');
if (el) el.innerHTML = crackHtml();
refreshAfterCrack();
}
async function refreshAfterCrack() {
if (!selectedBssid || !current) return;
const snap = await api(`/api/scan/snapshot?iface=${encodeURIComponent(current)}`);
if (snap?.access_points) {
lastSnapshot = snap;
renderResults(snap.access_points);
renderTarget(true);
}
}
function updateDeauthUi() {
const el = document.getElementById('deauthResult');
if (el) {
el.textContent = deauthLiveText();
el.classList.toggle('bad', !!attack.deauthInfo?.error);
}
updateAuthUi();
}
async function waitForBssidChannel(bssid, timeoutMs) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const snap = await api(`/api/scan/snapshot?iface=${encodeURIComponent(current)}`);
const ap = snap?.access_points?.find((a) => a.bssid?.toLowerCase() === bssid?.toLowerCase());
if (ap?.channel != null) return true;
await new Promise((resolve) => setTimeout(resolve, 500));
}
return false;
}
async function startDeauthJob(handshake) {
// Snapshot the target BSSID upfront: the rest of this function awaits
// capture restart and AP rediscovery, during which the global poll could
// see an empty initial snapshot and close the modal, clearing
// `selectedBssid`.
//
//
//
const bssid = selectedBssid;
const station = document.getElementById('deauthStation')?.value || '';
const direction = document.getElementById('deauthDirection')?.value || attack.direction;
const frame = document.getElementById('deauthFrame')?.value || attack.frameKind;
const reason = Number(document.getElementById('deauthReason')?.value) || 7;
const interval = Number(document.getElementById('deauthInterval')?.value) || 200;
if (!handshake && direction !== 'ap' && !station) {
setResult('deauthResult', 'Escolha um cliente para desautenticar nas direções sta/both.', true);
return;
}
// This action requires an active capture session (which stores the assembled
// handshake); rather than forcing the user to manually restart capture,
// resume capture automatically here.
//
if (!scanning) {
const scanRes = await api(`/api/scan/start?iface=${encodeURIComponent(current)}`, { method: 'POST' });
if (scanRes && scanRes.ok === false) {
setResult('deauthResult', `Não foi possível retomar a captura: ${scanRes.error || 'erro desconhecido'}`, true);
return;
}
scanning = true;
els.btnScan.textContent = 'parar captura';
els.btnScan.classList.add('live');
els.wave.classList.add('scanning');
// Capture just restarted: the session does not yet know which channel
// this BSSID is on, so starting the job immediately would lock the wrong
// channel. Wait briefly for the AP to reappear in the scan snapshot
// before launching the job.
//
//
setResult('deauthResult', 'Retomando a captura e localizando a rede…');
resumingForBssid = bssid;
let found;
try {
found = await waitForBssidChannel(bssid, 20_000);
} finally {
resumingForBssid = null;
}
if (!found) {
setResult('deauthResult', 'A rede não reapareceu na varredura a tempo. Tente de novo.', true);
return;
}
}
const q = new URLSearchParams({ iface: current, bssid, confirm: 'true' });
if (station) q.set('station', station);
if (handshake) {
q.set('seconds', '30');
} else {
q.set('direction', direction);
q.set('frame', frame);
q.set('reason', String(reason));
q.set('interval_ms', String(interval));
}
const res = await api(`/api/${handshake ? 'handshakes/capture' : 'deauth/start'}?${q}`, { method: 'POST' });
if (res?.error || !res?.job) {
setResult('deauthResult', `Falha: ${res?.error || 'job não iniciado'}`, true);
return;
}
attack.deauthJob = res.job;
attack.deauthInfo = {
id: res.job,
status: 'starting',
frames_sent: 0,
station,
direction: handshake ? 'ap' : direction,
frame_kind: handshake ? 'deauth' : frame,
stop_on_handshake: !!handshake,
};
setResult('deauthResult', handshake ? 'Forçando reassociação e handshake…' : 'Desautenticação contínua iniciada.');
updateDeauthUi();
void pollDeauth(res.job);
}
async function stopDeauthJob() {
const id = attack.deauthJob;
if (!id) return;
attack.deauthJob = null;
const res = await api(`/api/deauth/stop?id=${encodeURIComponent(id)}`, { method: 'POST' });
if (attack.deauthInfo) {
attack.deauthInfo.status = res?.error ? 'error' : 'stopped';
if (res?.error) attack.deauthInfo.error = res.error;
}
updateDeauthUi();
if (selectedBssid) void loadSnapshot();
}
async function pollDeauth(id) {
const job = await api(`/api/deauth/job?id=${encodeURIComponent(id)}`);
if (!job || job.error) {
if (attack.deauthJob !== id) return;
attack.deauthJob = null;
attack.deauthInfo = { id, status: 'error', error: job?.error || 'job não encontrado', frames_sent: 0 };
updateDeauthUi();
return;
}
if (attack.deauthJob !== id) return;
attack.deauthInfo = job;
updateDeauthUi();
if (job.status === 'running' || job.status === 'starting') {
setTimeout(() => void pollDeauth(id), 800);
return;
}
attack.deauthJob = null;
updateDeauthUi();
if (selectedBssid) void loadSnapshot();
}
async function sendDeauth(targetStation, count, reason, resultId) {
const q = new URLSearchParams({
iface: current,
bssid: selectedBssid,
count: String(count),
reason: String(reason),
direction: attack.direction,
frame: attack.frameKind,
confirm: 'true',
});
if (targetStation) q.set('station', targetStation);
const res = await api(`/api/deauth?${q}`, { method: 'POST' });
if (res?.error) setResult(resultId, `Falha: ${res.error}`, true);
else setResult(resultId, `${res.frames_sent ?? count} quadro(s) ${res.frame_kind || attack.frameKind} em ${directionLabel(res.direction || attack.direction)} para ${res.station || 'broadcast'} (motivo ${res.reason ?? reason}).`);
return res;
}
async function pollWps(id) {
const job = await api(`/api/wps?id=${encodeURIComponent(id)}`);
if (!job || job.error) {
attack.wpsInfo = { status: 'error', error: job?.error || 'job não encontrado', output: [] };
} else {
attack.wpsInfo = job;
}
const pre = document.getElementById('wpsOutput');
if (pre) pre.textContent = (attack.wpsInfo.output || []).slice(-12).join('\n') || 'Aguardando saída do reaver/bully…';
const res = document.getElementById('wpsResult');
if (res) {
res.textContent = attack.wpsInfo.password
? `Senha encontrada: ${attack.wpsInfo.password}${attack.wpsInfo.pin ? ` (PIN ${attack.wpsInfo.pin})` : ''}`
: attack.wpsInfo.error
? `Falha: ${attack.wpsInfo.error}`
: `Status: ${jobStatusLabel(attack.wpsInfo.status)}${attack.wpsInfo.pin ? ` · PIN ${attack.wpsInfo.pin}` : ''}`;
res.classList.toggle('bad', !!attack.wpsInfo.error);
}
const stop = document.querySelector('[data-act="wps-stop"]');
const start = document.querySelector('[data-act="wps-start"]');
const running = attack.wpsInfo.status === 'running' || attack.wpsInfo.status === 'starting';
if (stop) stop.disabled = !running;
if (start) start.disabled = !running ? !attack.authorized || !(attack.wpsTools || []).length : true;
if (running) setTimeout(() => void pollWps(id), 1500);
}
async function pollEvilTwin(id) {
const job = await api(`/api/eviltwin?id=${encodeURIComponent(id)}`);
attack.eviltwinInfo = !job || job.error && !job.status
? { status: 'error', error: job?.error || 'job não encontrado' }
: job;
const el = document.getElementById('eviltwinResult');
if (el) {
const attemptWord = { correct: 'senha certa', wrong: 'senha errada', invalid: 'tamanho inválido' };
const info = attack.eviltwinInfo;
el.textContent = info.password
? `Senha confirmada: ${info.password}`
: info.error
? `Falha: ${info.error}`
: `${jobStatusLabel(info.status)} · ${info.attempts ?? 0} tentativa(s) no portal${info.last_attempt ? ` · última: ${attemptWord[info.last_attempt] || info.last_attempt}` : ''}`;
el.classList.toggle('bad', !!info.error);
}
const running = attack.eviltwinInfo.status === 'running';
const start = document.querySelector('[data-act="eviltwin-start"]');
const stop = document.querySelector('[data-act="eviltwin-stop"]');
if (start) start.disabled = running;
if (stop) stop.disabled = !running;
if (running) setTimeout(() => void pollEvilTwin(id), 1500);
}
async function pollEnterpriseTwin(id) {
const job = await api(`/api/eviltwin-enterprise?id=${encodeURIComponent(id)}`);
attack.entTwinInfo = !job || job.error && !job.status
? { status: 'error', error: job?.error || 'job não encontrado' }
: job;
const el = document.getElementById('entTwinResult');
if (el) {
const info = attack.entTwinInfo;
el.textContent = info.error
? `Falha: ${info.error}`
: `${jobStatusLabel(info.status)} · ${info.captured ?? 0} credencial(is) capturada(s)${info.last_identity ? ` · última: ${info.last_identity}` : ''}`;
el.classList.toggle('bad', !!info.error);
}
const running = attack.entTwinInfo.status === 'running';
const start = document.querySelector('[data-act="enttwin-start"]');
const stop = document.querySelector('[data-act="enttwin-stop"]');
if (start) start.disabled = running;
if (stop) stop.disabled = !running;
if (running) setTimeout(() => void pollEnterpriseTwin(id), 1500);
}
els.targetClose.addEventListener('click', () => els.targetDlg.close());
els.targetDlg.addEventListener('close', () => {
if (attack.deauthJob) void stopDeauthJob();
selectedBssid = null;
});
document.querySelectorAll('#targetDlg .tabs [data-tab]').forEach((btn) => {
btn.addEventListener('click', () => {
activeTab = btn.dataset.tab;
renderTarget(true);
});
});
els.tabPanel.addEventListener('click', async (e) => {
const jump = e.target.closest('[data-jump]');
if (jump) {
activeTab = jump.dataset.jump;
renderTarget(true);
return;
}
const timelineBtn = e.target.closest('[data-act="timeline"]');
if (timelineBtn) {
attack.timelineMac = timelineBtn.dataset.mac;
renderTarget(true);
return;
}
if (e.target.closest('[data-act="timeline-close"]')) {
attack.timelineMac = '';
renderTarget(true);
return;
}
const clientBtn = e.target.closest('[data-act="attack-client"]');
if (clientBtn) {
attack.station = clientBtn.dataset.mac;
activeTab = 'attacks';
renderTarget(true);
return;
}
const btn = e.target.closest('button[data-act]');
if (!btn || btn.disabled) return;
if (btn.dataset.needsAuth !== undefined && !attack.authorized) {
return;
}
const act = btn.dataset.act;
const ap = findAp(selectedBssid);
if (!ap) return;
if (act === 'deauth-start' || act === 'deauth-handshake') {
btn.disabled = true;
await startDeauthJob(act === 'deauth-handshake');
return;
}
if (act === 'deauth-stop') {
btn.disabled = true;
await stopDeauthJob();
return;
}
if (act === 'deauth') {
const station = document.getElementById('deauthStation')?.value || '';
const reason = Number(document.getElementById('deauthReason')?.value) || 7;
btn.disabled = true;
await sendDeauth(station, Math.min(256, Math.max(1, attack.count)), reason, 'deauthResult');
btn.disabled = !attack.authorized;
return;
}
if (act === 'export-hs' || act === 'export-all') {
const out = document.getElementById('hsOut')?.value?.trim();
if (!out) return setResult('exportResult', 'Informe o caminho do .pcap.', true);
const station = document.getElementById('hsStation')?.value || '';
const q = new URLSearchParams({ iface: current, out });
if (act === 'export-hs') {
if (!station) return setResult('exportResult', 'Selecione um handshake.', true);
q.set('bssid', selectedBssid);
q.set('station', station);
}
btn.disabled = true;
const res = await api(`/api/handshakes/${act === 'export-all' ? 'export-all' : 'export'}?${q}`, { method: 'POST' });
btn.disabled = !attack.authorized;
if (res?.error) setResult('exportResult', `Falha: ${res.error}`, true);
else setResult('exportResult', `${res.handshakes ?? 0} handshake(s), ${res.frames ?? 0} quadro(s) em ${res.path}`);
return;
}
if (act === 'export-hc' || act === 'export-all-hc') {
const base = document.getElementById('hsOut')?.value?.trim();
if (!base) return setResult('exportResult', 'Informe o caminho base do arquivo.', true);
const out = base.replace(/\.(pcap|pcapng)$/i, '') + '.hc22000';
const station = document.getElementById('hsStation')?.value || '';
const q = new URLSearchParams({ iface: current, out });
if (act === 'export-hc') {
if (!station) return setResult('exportResult', 'Selecione um handshake.', true);
q.set('bssid', selectedBssid);
q.set('station', station);
}
btn.disabled = true;
const res = await api(`/api/handshakes/${act === 'export-hc' ? 'export-hc22000' : 'export-all-hc22000'}?${q}`, { method: 'POST' });
btn.disabled = !attack.authorized;
if (res?.error) setResult('exportResult', `Falha: ${res.error}`, true);
else setResult('exportResult', `${res.hashes ?? 0} hash(es) de ${res.handshakes ?? 0} handshake(s) em ${res.path}`);
return;
}
if (act === 'pmkid') {
const attempts = Math.min(50, Math.max(1, Number(document.getElementById('pmkidAttempts')?.value) || 5));
btn.disabled = true;
setResult('pmkidResult', 'Enviando associação com PMKID…');
const res = await api(`/api/pmkid?iface=${encodeURIComponent(current)}&bssid=${encodeURIComponent(selectedBssid)}&attempts=${attempts}&confirm=true`, { method: 'POST' });
btn.disabled = !attack.authorized;
if (res?.error) return setResult('pmkidResult', `Falha: ${res.error}`, true);
setResult('pmkidResult', res.pmkid
? `PMKID capturado: ${res.pmkid}`
: `${res.frames_sent ?? 0} quadro(s) enviados; PMKID não apareceu. ${res.detail || ''}`);
attack.pmkidInfo = res;
return;
}
if (act === 'pmf') {
const station = document.getElementById('pmfStation')?.value;
if (!station) return setResult('pmfResult', 'Nenhuma estação disponível.', true);
btn.disabled = true;
setResult('pmfResult', 'Testando por ~4 s…');
const res = await api(`/api/pmf?iface=${encodeURIComponent(current)}&bssid=${encodeURIComponent(selectedBssid)}&station=${encodeURIComponent(station)}&reason=7&confirm=true`, { method: 'POST' });
btn.disabled = !attack.authorized;
if (res?.error) return setResult('pmfResult', `Falha: ${res.error}`, true);
const verdict = res.verdict === 'pmf_off' ? 'PMF não obrigatório' : 'Inconclusivo';
setResult('pmfResult', `${verdict} · ${res.detail}`);
attack.pmfInfo = res;
return;
}
if (act === 'wps-start') {
const tool = document.getElementById('wpsTool')?.value;
if (!tool) return setResult('wpsResult', 'Nenhuma ferramenta WPS disponível.', true);
if (ap.channel == null) return setResult('wpsResult', 'Canal do AP desconhecido.', true);
const pin = document.getElementById('wpsPin')?.value?.trim() || '';
const q = new URLSearchParams({ iface: current, bssid: selectedBssid, channel: String(ap.channel), tool, confirm: 'true' });
if (pin) q.set('pin', pin);
btn.disabled = true;
const res = await api(`/api/wps?${q}`, { method: 'POST' });
btn.disabled = !attack.authorized;
if (res?.error || !res?.job) {
setResult('wpsResult', `Falha: ${res?.error || 'job não iniciado'}`, true);
return;
}
attack.wpsJob = res.job;
attack.wpsInfo = { status: 'starting', output: [] };
setResult('wpsResult', 'Job iniciado…');
void pollWps(res.job);
return;
}
if (act === 'wps-stop') {
if (!attack.wpsJob) return;
btn.disabled = true;
const res = await api(`/api/wps/stop?id=${encodeURIComponent(attack.wpsJob)}`, { method: 'POST' });
if (res?.error) setResult('wpsResult', `Falha: ${res.error}`, true);
else setResult('wpsResult', 'Parada solicitada.');
return;
}
if (act === 'eviltwin-start') {
const apIface = document.getElementById('eviltwinIface')?.value?.trim() || '';
if (!apIface) return setResult('eviltwinResult', 'Informe a interface do clone (uma segunda placa).', true);
const port = Math.min(65535, Math.max(1, Number(document.getElementById('eviltwinPort')?.value) || 8080));
const deauth = document.getElementById('eviltwinDeauth')?.checked || false;
attack.eviltwinApIface = apIface;
attack.eviltwinPortalPort = port;
attack.eviltwinDeauth = deauth;
const q = new URLSearchParams({
iface: current, ap_iface: apIface, bssid: selectedBssid,
portal_port: String(port), deauth: String(deauth), confirm: 'true',
});
btn.disabled = true;
const res = await api(`/api/eviltwin?${q}`, { method: 'POST' });
if (res?.error || !res?.job) {
btn.disabled = !attack.authorized;
return setResult('eviltwinResult', `Falha: ${res?.error || 'job não iniciado'}`, true);
}
attack.eviltwinJob = res.job;
attack.eviltwinInfo = { status: 'running', attempts: 0 };
void pollEvilTwin(res.job);
return;
}
if (act === 'eviltwin-stop') {
if (!attack.eviltwinJob) return;
btn.disabled = true;
const res = await api(`/api/eviltwin/stop?id=${encodeURIComponent(attack.eviltwinJob)}`, { method: 'POST' });
if (res?.error) setResult('eviltwinResult', `Falha: ${res.error}`, true);
else setResult('eviltwinResult', 'Parada solicitada.');
return;
}
if (act === 'enttwin-start') {
const apIface = document.getElementById('entTwinIface')?.value?.trim() || '';
if (!apIface) return setResult('entTwinResult', 'Informe a interface do clone (uma segunda placa).', true);
attack.entTwinApIface = apIface;
const q = new URLSearchParams({ iface: current, ap_iface: apIface, bssid: selectedBssid, confirm: 'true' });
btn.disabled = true;
const res = await api(`/api/eviltwin-enterprise?${q}`, { method: 'POST' });
if (res?.error || !res?.job) {
btn.disabled = !attack.authorized;
return setResult('entTwinResult', `Falha: ${res?.error || 'job não iniciado'}`, true);
}
attack.entTwinJob = res.job;
attack.entTwinInfo = { status: 'running', captured: 0 };
void pollEnterpriseTwin(res.job);
return;
}
if (act === 'enttwin-stop') {
if (!attack.entTwinJob) return;
btn.disabled = true;
const res = await api(`/api/eviltwin-enterprise/stop?id=${encodeURIComponent(attack.entTwinJob)}`, { method: 'POST' });
if (res?.error) setResult('entTwinResult', `Falha: ${res.error}`, true);
else setResult('entTwinResult', 'Parada solicitada.');
return;
}
if (act === 'crack') {
const wordlist = document.getElementById('wordlist')?.value?.trim() || '';
const station = document.getElementById('hsStation')?.value || '';
const rules = document.getElementById('crackRules')?.checked || false;
attack.crackRules = rules;
const q = new URLSearchParams({ iface: current, bssid: selectedBssid, rules: String(rules) });
if (wordlist) q.set('wordlist', wordlist);
if (station) q.set('station', station);
btn.disabled = true;
const res = await api(`/api/crack?${q}`, { method: 'POST' });
btn.disabled = !attack.authorized;
if (res?.error || !res?.job) {
setResult('exportResult', `Falha: ${res?.error || 'job não iniciado'}`, true);
} else {
attack.crackJob = res.job;
attack.crackInfo = { status: 'running', tried: 0 };
const progress = document.getElementById('crackProgress');
if (progress) progress.innerHTML = crackHtml();
}
return;
}
if (act === 'crack-mask') {
const mask = document.getElementById('crackMask')?.value?.trim() || '';
if (!mask) return setResult('exportResult', 'Informe uma máscara (ex.: ?d?d?d?d?d?d?d?d).', true);
attack.crackMask = mask;
const station = document.getElementById('hsStation')?.value || '';
const q = new URLSearchParams({ iface: current, bssid: selectedBssid, mask });
if (station) q.set('station', station);
btn.disabled = true;
const res = await api(`/api/crack/mask?${q}`, { method: 'POST' });
btn.disabled = !attack.authorized;
if (res?.error || !res?.job) {
setResult('exportResult', `Falha: ${res?.error || 'job não iniciado'}`, true);
} else {
attack.crackJob = res.job;
attack.crackInfo = { status: 'running', tried: 0 };
const progress = document.getElementById('crackProgress');
if (progress) progress.innerHTML = crackHtml();
}
return;
}
if (act === 'wep-restart') {
const out = document.getElementById('wepOut')?.value?.trim();
if (!out) return setResult('wepResult', 'Informe o caminho do .pcap.', true);
btn.disabled = true;
await api(`/api/scan/stop?iface=${encodeURIComponent(current)}`, { method: 'POST' });
const q = new URLSearchParams({ iface: current, pcap_out: out });
if (ap.channel != null) q.set('channel', String(ap.channel));
const res = await api(`/api/scan/start?${q}`, { method: 'POST' });
if (res?.error) {
setResult('wepResult', `Falha: ${res.error}`, true);
} else {
scanning = true;
lastSnapshot = null;
lastAps = [];
els.btnScan.textContent = 'parar captura';
els.btnScan.classList.add('live');
els.wave.classList.add('scanning');
setResult('wepResult', `Captura gravando em ${out}. Gere tráfego e aguarde IVs suficientes.`);
}
btn.disabled = !attack.authorized;
return;
}
if (act === 'wep-deauth') {
btn.disabled = true;
await sendDeauth('', 10, 7, 'wepResult');
btn.disabled = !attack.authorized;
return;
}
if (act === 'wep-copy') {
const out = document.getElementById('wepOut')?.value?.trim() || 'captura.pcap';
const cmd = `aircrack-ng ${out}`;
try {
await navigator.clipboard.writeText(cmd);
setResult('wepResult', 'Comando copiado.');
} catch {
setResult('wepResult', cmd);
}
}
});
els.tabPanel.addEventListener('input', (e) => {
const t = e.target;
if (t.id === 'deauthCount') attack.count = Number(t.value) || 1;
else if (t.id === 'hsOut') attack.hsOut = t.value;
else if (t.id === 'wordlist') attack.wordlist = t.value;
else if (t.id === 'crackMask') attack.crackMask = t.value;
else if (t.id === 'eviltwinIface') attack.eviltwinApIface = t.value;
else if (t.id === 'eviltwinPort') attack.eviltwinPortalPort = Number(t.value) || 8080;
else if (t.id === 'entTwinIface') attack.entTwinApIface = t.value;
else if (t.id === 'wepOut') attack.wepOut = t.value;
});
els.tabPanel.addEventListener('change', (e) => {
const t = e.target;
if (t.id === 'authChk') {
attack.authorized = t.checked;
updateAuthUi();
} else if (t.id === 'crackRules') attack.crackRules = t.checked;
else if (t.id === 'eviltwinDeauth') attack.eviltwinDeauth = t.checked;
else if (t.id === 'deauthStation') attack.station = t.value;
else if (t.id === 'deauthDirection') {
attack.direction = t.value;
const scope = document.getElementById('deauthScope');
if (scope) {
scope.textContent = attack.direction === 'sta'
? 'Do cliente para o AP (exige estação selecionada).'
: attack.direction === 'both'
? 'Nas duas direções (exige estação selecionada).'
: attack.station
? 'Somente o cliente selecionado será desautenticado.'
: 'Todos os clientes associados (broadcast).';
scope.classList.toggle('bad', attack.direction !== 'ap' && !attack.station);
}
updateAuthUi();
} else if (t.id === 'deauthFrame') attack.frameKind = t.value;
else if (t.id === 'deauthInterval') attack.intervalMs = Number(t.value) || 200;
else if (t.id === 'deauthReason') attack.reason = Number(t.value) || 7;
else if (t.id === 'hsStation') attack.hsStation = t.value;
else if (t.id === 'wpsTool') attack.wpsTool = t.value;
});
function reportUrl(iface, download) {
const url = new URL('/api/report.html', location.origin);
url.searchParams.set('iface', iface);
url.searchParams.set('token', token);
if (download) url.searchParams.set('download', '1');
return url.toString();
}
function reportCanvasSize(pages) {
reportState.pages = pages || 1;
const w = 794;
const h = Math.max(1123, reportState.pages * 1123);
els.reportCanvas.style.width = `${w}px`;
els.reportCanvas.style.height = `${h}px`;
els.reportFrame.style.width = `${w}px`;
els.reportFrame.style.height = `${h}px`;
applyReportZoom();
}
function applyReportZoom() {
els.reportCanvas.style.transform = `scale(${reportState.zoom})`;
els.reportZoomLabel.textContent = `${Math.round(reportState.zoom * 100)}%`;
}
async function openReport() {
if (!current) return;
reportState.iface = current;
els.reportStatus.textContent = 'Montando o relatório…';
if (!els.reportDlg.open) els.reportDlg.showModal();
if (tauri) {
const res = await api(`/api/report.html?iface=${encodeURIComponent(current)}`);
if (res?.error || !res?.html) {
els.reportStatus.textContent = `Falha: ${res?.error || 'não foi possível gerar o relatório'}`;
return;
}
els.reportFrame.removeAttribute('src');
els.reportFrame.srcdoc = res.html;
reportState.url = null;
} else {
const url = reportUrl(current, false);
reportState.url = url;
els.reportFrame.removeAttribute('srcdoc');
els.reportFrame.src = url;
}
reportCanvasSize(1);
}
function measureReport() {
let doc = null;
try { doc = els.reportFrame.contentDocument; } catch { return; }
if (!doc) return;
const pages = doc.querySelectorAll('.pagedjs_page').length;
if (pages) {
reportCanvasSize(pages);
els.reportStatus.textContent = `Pré-visualização A4 · ${pages} página(s).`;
return;
}
const height = Math.max(doc.documentElement.scrollHeight, doc.body?.scrollHeight || 0);
if (height > 400) {
reportCanvasSize(Math.ceil(height / 1123));
els.reportStatus.textContent = 'Pré-visualização A4.';
}
}
function scheduleReportMeasure() {
if (reportMeasureTimer) clearInterval(reportMeasureTimer);
let attempts = 0;
const tick = () => {
attempts++;
measureReport();
if (attempts >= 12) {
clearInterval(reportMeasureTimer);
reportMeasureTimer = null;
}
};
reportMeasureTimer = setInterval(tick, 500);
setTimeout(tick, 300);
}
els.reportFrame.addEventListener('load', () => {
if (reportState.url) els.reportStatus.textContent = 'Paginando o relatório…';
scheduleReportMeasure();
});
window.addEventListener('message', (event) => {
const data = event.data;
if (!data || data.type !== 'sabia-report-ready') return;
reportCanvasSize(Number(data.pages) || 1);
els.reportStatus.textContent = `Pré-visualização A4 · ${reportState.pages} página(s).`;
});
els.btnReport.addEventListener('click', () => void openReport());
els.reportClose.addEventListener('click', () => els.reportDlg.close());
els.reportZoomIn.addEventListener('click', () => {
reportState.zoom = Math.min(1.5, Math.round((reportState.zoom + 0.1) * 10) / 10);
applyReportZoom();
});
els.reportZoomOut.addEventListener('click', () => {
reportState.zoom = Math.max(0.4, Math.round((reportState.zoom - 0.1) * 10) / 10);
applyReportZoom();
});
els.reportPrint.addEventListener('click', () => {
try {
els.reportFrame.contentWindow.focus();
els.reportFrame.contentWindow.print();
} catch {
els.reportStatus.textContent = 'Não foi possível abrir a impressão; baixe o HTML e abra no navegador.';
}
});
els.reportDownload.addEventListener('click', async () => {
if (!current) return;
if (tauri) {
els.reportStatus.textContent = 'Preparando o arquivo…';
const res = await api(`/api/report.html?iface=${encodeURIComponent(current)}&download=true`);
if (!res?.html) {
els.reportStatus.textContent = `Falha: ${res?.error || 'não foi possível gerar o HTML'}`;
return;
}
const blob = new Blob([res.html], { type: 'text/html;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `sabia-${current.replace(/[^0-9a-z]/gi, '')}.html`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 2000);
els.reportStatus.textContent = 'HTML baixado — abre offline e imprime em A4.';
} else {
window.open(reportUrl(current, true), '_blank', 'noopener');
}
});
els.reportDlg.addEventListener('close', () => {
if (reportMeasureTimer) clearInterval(reportMeasureTimer);
reportMeasureTimer = null;
if (!tauri) els.reportFrame.removeAttribute('src');
else els.reportFrame.removeAttribute('srcdoc');
});
function escapeHtml(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function escapeAttr(s) {
return String(s).replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
}
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 snap = await api(`/api/scan/snapshot?iface=${encodeURIComponent(current)}`);
if (snap?.access_points && snap.running === false) {
// The session exists and returned HTTP 200, but the engine marked
// running=false because the capture thread died on its own (I/O error,
// USB adapter unplugged, etc.). Surface the actual reason to the UI.
scanning = false;
els.btnScan.textContent = 'iniciar captura';
els.btnScan.classList.remove('live');
els.wave.classList.remove('scanning');
lastSnapshot = snap;
renderResults(snap.access_points);
alert('A captura parou sozinha: ' + (snap.error || 'motivo desconhecido — confira se o adaptador ainda está conectado.'));
} else if (snap?.access_points) {
lastSnapshot = snap;
renderResults(snap.access_points);
if (selectedBssid) renderTarget(false);
} else if (snap && snap.ok === false) {
// Capture stopped mid-flight (interface left monitor mode, external
// process claimed the adapter, etc.). Without this branch, the fallback
// below returns 200 with an empty list and the UI keeps listening
// indefinitely without any network appearing or explaining why.
scanning = false;
els.btnScan.textContent = 'iniciar captura';
els.btnScan.classList.remove('live');
els.wave.classList.remove('scanning');
alert('A captura parou: ' + (snap.error || 'erro desconhecido'));
} else {
const aps = await api(`/api/scan?iface=${encodeURIComponent(current)}`);
renderResults(aps);
}
}
if (attack.crackJob) pollCrackJob();
setTimeout(poll, 1000);
}
async function checkElevation() {
if (!tauri) return; // in `sabia ui` mode privilege elevation is handled by the terminal (sudo)
const notice = document.getElementById('elevateNotice');
const msg = document.getElementById('elevateMsg');
const retry = document.getElementById('btnElevateRetry');
const elevated = await tauri.core.invoke('is_elevated').catch(() => true);
if (elevated) return;
notice.hidden = false;
msg.textContent = 'Pedindo privilégio de administrador — necessário pra modo monitor e captura…';
retry.hidden = true;
retry.addEventListener('click', async () => {
msg.textContent = 'Pedindo privilégio de administrador — necessário pra modo monitor e captura…';
retry.hidden = true;
await tauri.core.invoke('relaunch_elevated').catch((e) => {
msg.textContent = String(e);
retry.hidden = false;
});
});
tauri.event?.listen?.('elevate-failed', (e) => {
msg.textContent = String(e.payload || 'Não foi possível obter privilégio de administrador.');
retry.hidden = false;
});
}
(async function init() {
applyLangPref();
setView(viewMode);
checkElevation();
const status = await api('/api/version');
els.verInfo.textContent = `v${status.version}`;
await loadIfaces();
animateWave();
poll();
})();