- app.js/app.css/index.html: telas novas para Evil Twin (PSK e Enterprise), regras de mutação/máscara na quebra de senha, WPS, indicador de modo (monitor/gerenciado) movido para baixo do seletor de interface, e aviso específico quando a placa não entrega captura de verdade (ver bug de ScanSnapshot.warning no engine). - Lista de redes/BSSIDs não reordena mais a cada poll: ordem de chegada como critério de desempate e sinal arredondado em buckets de 4 dBm, porque reordenar pelo sinal instantâneo fazia as linhas flutuarem de posição debaixo do cursor a cada atualização. - "Forçar handshake" religa a captura sozinho se ela tiver sido parada (esperando o AP reaparecer no canal certo antes de disparar o job, e travando o alvo numa variável local pra não perder o BSSID se o diálogo fechar no meio da espera). - Grid da aba Ataques trocado de CSS grid pra colunas (alvenaria): cards com alturas muito diferentes na mesma linha deixavam buracos vazios enormes com align-items:start. - check-ui-i18n.mjs: ajustes de cobertura para as novas telas.
2444 lines
108 KiB
JavaScript
2444 lines
108 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 { /* sem armazenamento */ }
|
|
}
|
|
|
|
let themePref = 'auto';
|
|
try { themePref = localStorage.getItem('sabia-theme') || 'auto'; } catch { /* sem armazenamento */ }
|
|
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 { /* sem armazenamento */ }
|
|
|
|
els.settingsBtn.addEventListener('click', () => {
|
|
if (!els.settingsDlg.open) els.settingsDlg.showModal();
|
|
});
|
|
|
|
function applyLangPref() {
|
|
let pref = 'auto';
|
|
try { pref = localStorage.getItem('sabia-lang') || 'auto'; } catch { /* ignora */ }
|
|
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 { /* ignora */ }
|
|
setLang(pref === 'auto' ? undefined : pref);
|
|
});
|
|
|
|
let ifaces = [];
|
|
let current = null;
|
|
let scanning = false;
|
|
|
|
// Ordem de chegada estável por rede/BSSID: sem isso, cada poll reordenava a
|
|
// lista pelo sinal instantâneo e as linhas ficavam "flutuando" de posição a
|
|
// cada atualização, tornando impossível clicar no alvo certo. A ordem de
|
|
// chegada serve de critério de desempate (e de ordenação padrão), então um
|
|
// item só muda de posição se o usuário trocar o modo de ordenação de
|
|
// verdade — não por uma flutuação de -1 dBm entre um poll e outro.
|
|
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();
|
|
}
|
|
|
|
// No app desktop não existe servidor HTTP: as mesmas rotas viram chamadas
|
|
// IPC diretas pro núcleo, pelos comandos registrados em 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 = `<div class="ap-card empty-state">${ICON_EMPTY}<h2>${message}</h2><p>${hint}</p></div>`;
|
|
}
|
|
|
|
// Só troca o HTML quando muda de verdade: o poll de 1 s não deve
|
|
// reconstruir a tabela (e perder foco/hover) à toa.
|
|
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) {
|
|
// Sem isso, uma falha ao abrir a captura (permissão, interface saiu do
|
|
// modo monitor, etc.) ficava muda: o botão virava "parar captura" e a
|
|
// tela mostrava "escutando o ar" pra sempre, sem nenhuma rede chegar.
|
|
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 tipicamente entre -30 (ótimo) e -90 (péssimo)
|
|
let level = 0;
|
|
if (dbm != null) {
|
|
if (dbm >= -55) level = 4;
|
|
else if (dbm >= -65) level = 3;
|
|
else if (dbm >= -75) level = 2;
|
|
else level = 1;
|
|
}
|
|
const heights = [5, 8, 11, 14];
|
|
let bars = '';
|
|
for (let i = 0; i < 4; i++) {
|
|
bars += `<i class="${i < level ? 'on' : ''}" style="height:${heights[i]}px"></i>`;
|
|
}
|
|
return `<span class="signal-bars">${bars}</span>`;
|
|
}
|
|
|
|
// Agrupa por ESSID: uma rede pode ter vários BSSIDs (rádios) e todos
|
|
// pertencem ao mesmo alvo. Redes ocultas ficam separadas, pois sem SSID
|
|
// não há como afirmar que dois BSSIDs são a mesma rede.
|
|
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)));
|
|
}
|
|
|
|
// Dessensibiliza o sinal antes de comparar: arredondar pro m\u00faltiplo de 4
|
|
// dBm mais pr\u00f3ximo evita que uma oscila\u00e7\u00e3o de 1-2 dBm entre dois polls
|
|
// inverta a ordem de dois alvos com sinal parecido a cada atualiza\u00e7\u00e3o.
|
|
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 { /* sem armazenamento */ }
|
|
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' };
|
|
|
|
/** Estado geral da banda: mesma lógica de "saúde" usada nos tons por canal,
|
|
* só que aplicada ao canal recomendado — é o que decide o tom do card inteiro. */
|
|
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 { /* ignora */ }
|
|
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 { /* sem armazenamento */ }
|
|
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);
|
|
});
|
|
|
|
// ---------------------------------------------------------------- alvo
|
|
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',
|
|
};
|
|
|
|
/** Sparkline em tamanho real (não o mini de 62px da tabela), pra caber bem
|
|
* sozinho dentro do card de sinal do detalhe do cliente. */
|
|
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>`;
|
|
}
|
|
|
|
/** Visão completa de um cliente: tudo que o Sabiá já capturou sobre ele —
|
|
* identificação, rede, sinal e a linha do tempo de eventos — num painel só,
|
|
* em vez de ter que adivinhar olhando a linha compacta da tabela. */
|
|
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);
|
|
}
|
|
}
|
|
|
|
// Enquanto startDeauthJob está religando a captura e esperando o AP
|
|
// reaparecer na varredura, a sessão nova responde vazia por alguns ciclos
|
|
// de poll — sem essa trava, renderTarget() interpreta "AP não visto ainda"
|
|
// como "AP não existe mais" e fecha o diálogo debaixo do usuário no meio
|
|
// da própria ação que ele acabou de pedir.
|
|
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) {
|
|
// Fixa o alvo no início: o resto desta função faz esperas longas
|
|
// (religar a captura, aguardar a rede reaparecer na varredura), e nesse
|
|
// meio tempo o poll global pode não achar mais o AP na primeira resposta
|
|
// vazia da sessão recém-reiniciada e fechar o diálogo, zerando
|
|
// `selectedBssid`. Sem essa cópia local, o job acabava sendo disparado
|
|
// com bssid=null ("MAC inválido: null") mesmo depois de localizar a rede
|
|
// com sucesso.
|
|
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;
|
|
}
|
|
// Essa ação exige uma sessão de captura ativa (é o que guarda o handshake
|
|
// montado); sem isso o backend recusa com "inicie a captura antes do
|
|
// job". Em vez de obrigar o usuário a voltar e religar manualmente, retoma
|
|
// a captura aqui — do jeito que ele descreveu querer.
|
|
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');
|
|
// A captura acabou de reiniciar: a sessão ainda não sabe em qual canal
|
|
// este BSSID está, e o job travaria o canal errado (o canal em que o
|
|
// scanner por acaso já estava saltando) em vez do canal real do AP —
|
|
// os quadros de deauth saem no vácuo e o job termina sem efeito. Espera
|
|
// o AP reaparecer no snapshot (ele já esteve no ar, é questão de
|
|
// segundos de varredura) antes de disparar o 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) {
|
|
// A sessão existe e respondeu normal (200), mas o próprio engine
|
|
// marcou running=false: a thread de captura morreu sozinha (erro de
|
|
// I/O, adaptador caiu da USB, etc.). Sem isso, a UI continuava achando
|
|
// que ainda estava capturando e nunca contava o motivo real.
|
|
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) {
|
|
// A captura morreu no meio (interface saiu do monitor, processo externo
|
|
// tomou o adaptador, etc.). Antes disso caía no fallback de baixo, que
|
|
// sempre responde 200 com lista vazia — a UI continuava "escutando o
|
|
// ar" indefinidamente sem nenhuma rede aparecer e sem avisar por quê.
|
|
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; // via `sabia ui` a elevação é responsabilidade do 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();
|
|
})();
|