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 = ``; const warnSvg = ``; 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'); default: return { error: 'rota desconhecida' }; } } catch (e) { return { ok: false, error: String(e) }; } } function renderEmptyResult(message, hint) { els.resultArea._h = null; els.resultArea.innerHTML = `
${ICON_EMPTY}

${message}

${hint}

`; } // 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 = ``; sel.disabled = true; return; } sel.disabled = false; sel.innerHTML = ifaces .map((i) => ``) .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'); } }); els.btnUpdate.addEventListener('click', async () => { els.btnUpdate.textContent = 'checando…'; const status = await api('/api/update/check'); els.btnUpdate.textContent = status.update_available ? `atualizar para ${status.latest_version}` : 'já atualizado'; }); 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 += ``; } return `${bars}`; } // 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('WPS'); if (ap.pmf) flags.push('PMF'); if (ap.cipher) flags.push(`${escapeHtml(ap.cipher)}`); 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 `
${ap.bssid} ${meta} ${sig != null ? signalBars(sig) + sig + ' dBm' : '—'} ${ap.security || '—'} ${clientLabel} ${flags.join('')}
`; } function renderGroup(g) { const open = openGroups.has(g.key); const name = g.hidden ? 'oculta' : escapeHtml(g.name); const apLabel = g.aps.length === 1 ? '1 AP' : `${g.aps.length} APs`; const flags = [`${apLabel}`]; if (g.hidden) flags.push(`${g.aps[0].bssid}`); if (g.wps) flags.push('WPS'); if (g.pmf === 'all') flags.push('PMF'); else if (g.pmf === 'some') flags.push('PMF parcial'); const secs = g.securities.length ? g.securities.map((s) => `${s}`).join('') : '—'; const chans = g.channels.length ? g.channels.join(' · ') : '—'; const bands = g.bands.length ? `${g.bands.join(' · ')}` : ''; 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 ` ${flags.join('')} ${secs} ${chans}${bands} ${g.best != null ? signalBars(g.best) + g.best + ' dBm' : '—'} ${g.clients.size}
${details}
`; } 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 `
${shown .map((f) => { const tone = f.severity === 'bad' ? 'high' : f.severity === 'warn' ? 'warn' : 'low'; return ``; }) .join('')}${more > 0 ? `+${more}` : ''}
`; } 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(``); grid.push(`${dbm}`); } 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 ? `${escapeHtml(ap.ssid.slice(0, 16))}` : ''; return `${escapeAttr(`${ap.ssid || ap.bssid} · canal ${ap.channel} · ${sig} dBm`)} ${label}`; }) .join(''); const ticks = band.candidates .map((c) => `${c.channel}`) .join(''); return ` ${grid.join('')} ${lobes}${ticks} `; } 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 ? '' : `
${label}${value}
`; const tiles = [ `
Estado${CHAN_TONE_WORD[health.tone]}
`, tile('Canal recomendado', `${band.recommended ?? '—'}${rec?.dfs ? ' DFS' : ''}`), tile('Canais ocupados', `${band.used_channels.length}`), tile('APs na banda', `${aps.length}`), worst ? tile('Pior canal', `${worst.channel} ${worst.score.toFixed(2)}`, toneOf(worst.score, worst.dfs) !== 'ok' ? toneOf(worst.score, worst.dfs) : '') : '', dfsCount ? tile('Canais DFS', `${dfsCount}`) : '', ].join(''); const alert = health.tone !== 'ok' ? `
${warnSvg}
${escapeHtml(health.label)}
${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.'}
` : ''; const rows = band.candidates.map((c) => { const tone = toneOf(c.score, c.dfs); const best = band.recommended === c.channel; return ` ${c.channel}${c.dfs ? 'DFS' : ''} ${c.score.toFixed(2)} ${c.aps} AP(s) ${best ? 'Recomendado' : ''} `; }).join(''); const switches = `
${bands .map((b) => ``) .join('')}
`; const legend = `
WPA2WPA3aberta/WEPWPA
`; const wpa3 = analysis?.wpa3?.length ? `

${analysis.wpa3.length} rede(s) WPA3 analisada(s) — detalhes na aba do alvo e no relatório.

` : ''; setResultsHtml(`${alertsHtml()}${alert}
${switches}${legend}
${tiles}
${spectrumSvg(band)}
Canais detalhados (${band.candidates.length}) ${rows}
CanalPontuaçãoAPs
${wpa3}
`); } 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()}
${groups.map(renderGroup).join('')}
RedeSegurançaCanaisSinalClientes
`); } 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 `${escapeHtml(String(value))}`; } function kv(pairs) { return pairs .filter(([, value]) => value != null && value !== '') .map(([label, value]) => `
${escapeHtml(label)}
${value}
`) .join(''); } function securityTag(sec) { return `${escapeHtml(sec || '—')}`; } function hsBadges(hs) { const out = []; if (hs.complete) out.push('completo'); if (hs.crackable) out.push('quebrável'); if (hs.password) out.push(`senha: ${escapeHtml(hs.password)}`); if (hs.pmkid) out.push('PMKID'); return out.join(''); } function hsListHtml(hs) { if (!hs.length) return '

Nenhum handshake capturado para este BSSID.

'; return `
${hs .map( (h) => `
${escapeHtml(h.station)} ${escapeHtml(h.kind)} · mensagens ${escapeHtml(h.messages || '—')}
${hsBadges(h)}
`, ) .join('')}
`; } function riskHtml(ap) { const notes = []; if (ap.security === 'Open') { notes.push( '
Rede aberta. O tráfego não é cifrado pelo Wi-Fi; qualquer cliente pode se associar.
', ); } if (ap.security === 'WEP') { notes.push( '
WEP. A cifra é quebrável; capture IVs na aba Ataques.
', ); } if (ap.wps) { notes.push( `
WPS ativo${ap.wps_version ? ` (${escapeHtml(ap.wps_version)})` : ''}. ${ap.wps_locked ? 'O AP anuncia configuração travada.' : 'PIN pode estar acessível; verifique o lock.'}
`, ); } if (ap.pmf_required) { notes.push( '
PMF exigido. Desautenticação e captura de handshake podem ser ignoradas pelos clientes.
', ); } else if (ap.security?.startsWith('WPA2') || ap.security?.startsWith('WPA3') || ap.security === 'WPA') { notes.push( '
Sem PMF obrigatório. Desautenticação tende a funcionar e facilita a captura do handshake.
', ); } 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)}

Identificação

${kv([ ['SSID', ap.ssid ? escapeHtml(ap.ssid) : 'oculta'], ['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)], ])}

Rádio

${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)], ])}

Segurança

${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) : '—'], ])}

Clientes e handshake

${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) : '—'], ])}
${ap.wps ? `

WPS

${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) : '—'], ])}
` : ''}`; } 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 '

Sem amostras de sinal suficientes ainda.

'; 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 ``; } /** 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) => `
  • ${fmtTime(e.t)}${escapeHtml(EVENT_LABEL[e.kind] || e.kind)}${escapeHtml(e.detail || '')}
  • `).join('') : '
  • Ainda sem eventos além do primeiro avistamento.
  • '; const sig = signalOf(client); const yesNo = (v) => (v ? 'sim' : 'não'); return `

    Cliente · ${escapeHtml(clientName(client))} ${escapeHtml(client.mac)}

    Identificação

    ${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)], ])}

    Rede

    ${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 ? 'associado' : 'visto'], ['Pacotes', String(client.packets ?? 0)], ['Probes', (client.probes || []).length ? escapeHtml(client.probes.join(', ')) : null], ])}

    Sinal

    ${kv([ ['Atual', sig != null ? `${signalBars(sig)}${sig} dBm` : '—'], ['Máximo', client.max_signal_dbm != null ? `${client.max_signal_dbm} dBm` : '—'], ])}
    ${bigSparkline(client.signal_history)}

    Linha do tempo · eventos mais recentes primeiro.

      ${rows}
    `; } 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 `
    Nenhum cliente visto.

    Associe ou gere tráfego na rede para que o Sabiá identifique estações.

    `; } 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 ? 'associado' : probing ? 'sondando' : 'visto'; 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 `
    ${escapeHtml(name)}
    ${escapeHtml(details.join(' · '))}
    ${escapeHtml(device)} ${sig != null ? `${signalBars(sig)}${sig} dBm` : '—'} ${c.packets ?? 0} ${state} ${probes} `; }) .join(''); return `
    ${rows}
    ClienteDispositivoSinalPacotesEstadoProbesAções
    ${renderClientDetail(clients)}`; } function crackHtml() { const info = attack.crackInfo; if (!info) return ''; if (info.status === 'running') { return `
    quebrando${info.tried ?? 0} senhas testadas
    `; } if (info.status === 'error' || info.error) { return `
    erro ${escapeHtml(info.error || 'falha ao quebrar')}
    `; } const report = info.report || info; if (report.password) { return `
    senha encontrada ${escapeHtml(report.password)}${report.tried ?? info.tried ?? 0} tentativas · ${report.elapsed_ms ?? 0} ms
    `; } return `
    concluído Senha não encontrada no dicionário (${report.tried ?? info.tried ?? 0} tentativas).
    `; } 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 ''; return tools .map((t) => ``) .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 `

    WPS PIN

    Orquestra reaver/bully na interface monitor. O canal do AP é usado automaticamente; o processo pode ser interrompido a qualquer momento.

    ${escapeHtml(result)}

    ${escapeHtml(output || 'A saída do reaver/bully aparece aqui.')}
    `; } 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 `

    Evil Twin + portal cativo

    Exige um handshake quebrável já capturado deste AP (aba Handshake). Precisa de hostapd instalado e de uma segunda interface Wi-Fi em modo AP, diferente da que está capturando.

    ${escapeHtml(result)}

    `; } 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 `

    Evil Twin Enterprise (802.1X)

    Rede Enterprise detectada (AKM 802.1X) — aqui a credencial é enviada automaticamente pelo cliente durante o handshake EAP, não precisa de portal. Exige hostapd-wpe instalado (não vem com o Sabiá: é um patch no build do hostapd — github.com/aircrack-ng/hostapd-wpe) e uma segunda interface Wi-Fi em modo AP.

    ${escapeHtml(result)}

    ${job && job.captured ? `

    Log de credenciais (p/ john --format=netntlm ou reformatar pro hashcat -m 5500): ${escapeHtml(job.log_path)}

    ` : ''}

    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.

    `; } 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) => ``) .join(''); const hsOptions = hs .map((h) => ``) .join(''); const pmfStation = attack.station || clients[0]?.mac || ''; return `
    Somente em redes próprias ou com autorização escrita. Cada ação fica registrada na trilha de auditoria local, com interface, BSSID e estação.

    Desautenticação

    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.

    ${attack.station ? 'Somente o cliente selecionado será desautenticado.' : 'Todos os clientes associados (broadcast).'}

    ${escapeHtml(deauthLiveText())}

    PMF (802.11w)

    Dispara deauth nas duas direções e observa se a estação reassocia. Resultado prático, não prova criptográfica.

    PMKID clientless

    Forja um MAC local e envia association request com PMKID para tentar a mensagem 1. Muitos APs só expõem PMKID em roaming real.

    Handshake

    ${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.'}

    ${hsListHtml(hs)}

    ${crackHtml()}

    Força bruta por máscara — sem dicionário, testa toda combinação do padrão. ?d dígito, ?l minúscula, ?u maiúscula, ?s símbolo, ?a qualquer um; o resto é literal.

    ${wpsHtml()} ${eviltwinHtml()} ${enterpriseTwinHtml(ap)} ${ap.security === 'WEP' ? `

    Captura WEP

    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.

    Depois de coletar IVs suficientes: aircrack-ng ${escapeHtml(attack.wepOut || 'captura.pcap')}

    ` : `

    WEP

    Esta rede não usa WEP.

    `}
    `; } 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 ? `` : ''; els.targetSub.innerHTML = `${sub.join(' · ')}${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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', }[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(); })();