diff --git a/apps/gui/ui/app.css b/apps/gui/ui/app.css index 6a99237..8b5e4b5 100644 --- a/apps/gui/ui/app.css +++ b/apps/gui/ui/app.css @@ -68,6 +68,7 @@ :root[data-theme="dark"] { color-scheme: dark; } * { box-sizing: border-box; } +[hidden] { display: none !important; } html, body { height: 100%; } body { margin: 0; background: var(--bg); color: var(--text); @@ -104,17 +105,27 @@ code, .mono { font-family: var(--mono); font-variant-numeric: tabular-nums; } padding: 9px 10px; border-radius: 12px; transition: background .15s; } .dev:hover { background: color-mix(in srgb, var(--text) 6%, transparent); } -.dev[aria-current="true"] { background: var(--accent-soft); } +.dev[aria-current="true"], .dev[aria-pressed="true"] { background: var(--accent-soft); } .dev .ico { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center; background: var(--surface); box-shadow: 0 0 0 1px var(--line); color: var(--text-2); } -.dev[aria-current="true"] .ico { color: var(--accent); } +.dev[aria-current="true"] .ico, .dev[aria-pressed="true"] .ico { color: var(--accent); } .dev .t { display: block; font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .dev .s { display: block; font-size: 11.5px; color: var(--text-2); font-family: var(--mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.side-foot { margin-top: auto; padding: 14px 10px 4px; font-size: 11.5px; color: var(--text-3); display: flex; justify-content: space-between; gap: 8px; } -.side-foot button { background: transparent; border: 0; color: var(--text-3); cursor: pointer; text-decoration: underline; text-underline-offset: 2px; } +.iface-pick { display: flex; flex-direction: column; align-items: stretch; gap: 8px; padding: 0 10px 4px; } +.iface-pick .select { width: 100%; min-width: 0; } +.iface-pick .pill { align-self: flex-start; } + +.side-foot { margin-top: auto; padding: 14px 4px 4px; font-size: 11.5px; color: var(--text-3); display: grid; gap: 8px; } +.foot-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.side-foot button { background: transparent; border: 0; color: var(--text-3); cursor: pointer; } +.side-foot .foot-row > button:not(.icon-btn) { text-decoration: underline; text-underline-offset: 2px; } .side-foot button:hover { color: var(--text-2); } -.lang-sel { background: transparent; border: 0; color: var(--text-3); font-size: 11.5px; cursor: pointer; } -.lang-sel:hover { color: var(--text-2); } +.icon-btn { width: 30px; height: 30px; border-radius: 9px; display: inline-grid; place-items: center; padding: 0; } +.icon-btn:hover { background: color-mix(in srgb, var(--text) 8%, transparent); } + +.theme-switch { display: inline-flex; padding: 2px; border-radius: 999px; background: color-mix(in srgb, var(--text) 8%, transparent); } +.theme-switch button { border: 0; background: transparent; cursor: pointer; padding: 4px 9px; border-radius: 999px; font-size: 11.5px; color: var(--text-2); } +.theme-switch button[aria-pressed="true"] { background: var(--surface); color: var(--text); box-shadow: 0 1px 3px rgba(0, 0, 0, .18); font-weight: 600; } .main { overflow-y: auto; min-width: 0; } .page { max-width: 1040px; margin: 0 auto; padding: 30px 36px 100px; animation: rise .35s cubic-bezier(.2, .8, .2, 1); } @@ -133,6 +144,9 @@ code, .mono { font-family: var(--mono); font-variant-numeric: tabular-nums; } /* ------------------------------------------------------------ aviso */ .notice { display: flex; gap: 10px; background: var(--surface); border-radius: 12px; padding: 12px 14px; box-shadow: var(--shadow); font-size: 13px; color: var(--text-2); margin-bottom: 20px; align-items: flex-start; } .notice svg { flex: none; margin-top: 2px; color: var(--warn); } +.notice.warn { background: var(--warn-soft); } .notice.warn svg { color: var(--warn); } +.notice.bad { background: var(--bad-soft); } .notice.bad svg { color: var(--bad); } +.notice b { color: var(--text); } /* ------------------------------------------------------------ controles */ .controls { background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow); padding: 16px 18px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 22px; } @@ -153,23 +167,57 @@ code, .mono { font-family: var(--mono); font-variant-numeric: tabular-nums; } .btn.live:hover { filter: brightness(1.06); } /* ------------------------------------------------------------ tabela */ -.result-head { display: flex; align-items: baseline; justify-content: space-between; margin: 4px 2px 10px; } +.result-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin: 4px 2px 10px; } +.result-summary { display: flex; align-items: baseline; gap: 10px; min-width: 0; } .result-head strong { font-size: 15px; } -.result-head span { color: var(--text-3); font-size: 12.5px; } +.result-summary span { color: var(--text-3); font-size: 12.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.result-tools { display: flex; align-items: center; gap: 8px; } +.result-tools .filter { border: 0; border-radius: 10px; padding: 7px 12px; width: 210px; background: color-mix(in srgb, var(--text) 7%, transparent); color: var(--text); font: inherit; font-size: 12.5px; } +.result-tools .filter::placeholder { color: var(--text-3); } +.result-tools .select { width: auto; padding: 7px 10px; border-radius: 10px; font-size: 12.5px; } .ap-card { background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; } +.net-card { overflow-x: auto; } table { width: 100%; border-collapse: collapse; } thead th { text-align: left; padding: 11px 14px; font-size: 10.5px; color: var(--text-3); text-transform: uppercase; letter-spacing: .06em; font-weight: 600; border-bottom: 1px solid var(--line); } tbody td { padding: 10px 14px; font-size: 13px; border-bottom: 1px solid var(--line); vertical-align: middle; } tbody tr:last-child td { border-bottom: 0; } tbody tr:hover { background: var(--surface-2); } -td.bssid { font-family: var(--mono); font-size: 12px; color: var(--text-2); } td.ssid { font-weight: 600; } td.ssid em { color: var(--text-3); font-weight: 400; font-style: normal; } -.sec-tag { font-size: 11.5px; font-weight: 600; padding: 2px 8px; border-radius: 999px; } -.sec-aberta { background: var(--bad-soft); color: var(--bad); } -.sec-wep { background: var(--warn-soft); color: var(--warn); } -.sec-wpa, .sec-wpa2, .sec-wpa3 { background: var(--ok-soft); color: var(--ok); } + +/* rede agrupada por ESSID; cada BSSID vira uma linha expansível */ +.caret-cell { width: 34px; padding-left: 10px; padding-right: 0; } +.expander { width: 26px; height: 26px; border: 0; border-radius: 8px; background: transparent; color: var(--text-3); cursor: pointer; display: inline-grid; place-items: center; padding: 0; } +.expander:hover { background: color-mix(in srgb, var(--text) 8%, transparent); color: var(--text); } +.expander svg { transition: transform .18s; } +.net-row.open .expander svg { transform: rotate(90deg); } +.net-row.open > td { background: color-mix(in srgb, var(--text) 3%, transparent); } +.net-name .ssid { display: block; font-weight: 600; line-height: 1.25; } +.net-name .ssid em { color: var(--text-3); font-weight: 400; font-style: normal; } +.net-sub { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 3px; } +.flag { font-size: 10px; font-weight: 700; letter-spacing: .03em; padding: 1px 6px; border-radius: 999px; background: color-mix(in srgb, var(--text) 8%, transparent); color: var(--text-2); white-space: nowrap; } +.flag-warn { background: var(--warn-soft); color: var(--warn); } +.net-sec .sec-tag + .sec-tag { margin-left: 4px; } +.net-chan .chans { font-variant-numeric: tabular-nums; font-weight: 600; } +.net-band { display: block; font-size: 10.5px; color: var(--text-3); } +.net-signal, .net-clients { white-space: nowrap; font-variant-numeric: tabular-nums; } +.net-clients { color: var(--text-2); } + +.detail-row td { padding: 4px 14px 10px 46px; background: var(--surface-2); } +.detail-row:hover { background: transparent; } +.bssid-list { display: grid; gap: 1px; } +.bssid-item { display: grid; grid-template-columns: minmax(150px, 1.4fr) 132px 112px minmax(92px, .8fr) 84px 1fr; gap: 10px; align-items: center; padding: 6px 8px; border-radius: 8px; font-size: 12.5px; } +.bssid-item:hover { background: color-mix(in srgb, var(--text) 4%, transparent); } +.bssid { color: var(--text-2); font-size: 11.5px; } +.bssid-meta, .bssid-clients { color: var(--text-3); font-size: 12px; } +.bssid-flags { display: flex; gap: 4px; justify-content: flex-end; flex-wrap: wrap; } + +.sec-tag { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 999px; white-space: nowrap; } +.sec-open { background: var(--bad-soft); color: var(--bad); } +.sec-wep, .sec-wpa { background: var(--warn-soft); color: var(--warn); } +.sec-wpa2, .sec-wpa3, .sec-owe { background: var(--ok-soft); color: var(--ok); } +.sec-eap { background: var(--signal-soft); color: var(--signal); } .signal-bars { display: inline-flex; align-items: flex-end; gap: 1.5px; height: 14px; vertical-align: middle; margin-right: 6px; } .signal-bars i { width: 3px; background: var(--line); border-radius: 1px; display: block; } .signal-bars i.on { background: var(--signal); } @@ -179,6 +227,115 @@ td.ssid em { color: var(--text-3); font-weight: 400; font-style: normal; } .empty-state h2 { font-size: 17px; color: var(--text); margin-bottom: 6px; } .empty-state p { font-size: 13.5px; max-width: 360px; margin: 0 auto; } +/* ------------------------------------------------------------ configurações */ +dialog { border: 0; border-radius: 20px; padding: 24px; width: min(420px, calc(100vw - 32px)); background: var(--surface); color: var(--text); box-shadow: 0 30px 80px rgba(0, 0, 0, .35); } +dialog::backdrop { background: rgba(0, 0, 0, .35); backdrop-filter: blur(6px); } +dialog h2 { font-size: 20px; margin-bottom: 16px; } +dialog menu { display: flex; gap: 10px; justify-content: flex-end; padding: 0; margin: 22px 0 0; } +.field { display: grid; gap: 6px; margin: 12px 0; } +.field-label { font-size: 12px; color: var(--text-2); font-weight: 600; } +.select { width: 100%; border: 0; border-radius: 12px; padding: 9px 14px; background: color-mix(in srgb, var(--text) 7%, transparent); color: var(--text); font: inherit; cursor: pointer; } +.select option { background-color: var(--surface); color: var(--text); } + + +/* ------------------------------------------------------------ detalhes do alvo */ +.ssid-btn { display: block; border: 0; padding: 0; background: transparent; color: inherit; font: inherit; font-weight: 600; text-align: left; cursor: pointer; line-height: 1.25; } +.ssid-btn:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; } +.bssid-item { cursor: pointer; } +.bssid-item:focus-visible { outline: 2px solid var(--signal); outline-offset: -2px; } +.target-dlg { width: min(980px, calc(100vw - 32px)); max-height: min(90vh, 900px); padding: 0; overflow: hidden; display: flex; flex-direction: column; } +.target-dlg:not([open]) { display: none; } +.target-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 22px 24px 12px; } +.target-head h2 { font-size: 22px; line-height: 1.2; } +.target-sub { display: flex; flex-wrap: wrap; align-items: center; gap: 5px 8px; color: var(--text-2); font-size: 12.5px; margin-top: 5px; } +.target-sub .mono { color: var(--text-2); font-size: 12px; } +.target-sub .sec-tag { margin-left: 2px; } +.dot-sep { color: var(--text-3); } +.bssid-select { width: auto; max-width: 320px; padding: 4px 8px; border-radius: 8px; font-size: 11.5px; } +.tabs { display: flex; gap: 2px; padding: 0 20px; border-bottom: 1px solid var(--line); } +.tabs button { border: 0; background: transparent; color: var(--text-2); padding: 9px 12px 11px; font-size: 13px; font-weight: 600; cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; } +.tabs button:hover { color: var(--text); } +.tabs button[aria-selected="true"] { color: var(--text); border-bottom-color: var(--accent); } +.tab-count { display: inline-block; min-width: 18px; padding: 0 5px; margin-left: 3px; border-radius: 999px; background: color-mix(in srgb, var(--text) 9%, transparent); color: var(--text-2); font-size: 10.5px; line-height: 17px; text-align: center; } +.tab-panel { padding: 18px 24px 26px; overflow-y: auto; min-height: 0; } +.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } +.detail-card { background: var(--surface-2); border: 1px solid var(--line); border-radius: 14px; padding: 14px 16px; } +.detail-card h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .06em; color: var(--text-3); margin-bottom: 10px; } +.detail-wide { margin-top: 12px; } +.kv { display: grid; grid-template-columns: minmax(96px, auto) minmax(0, 1fr); gap: 6px 12px; margin: 0; font-size: 12.5px; } +.kv dt { color: var(--text-3); } +.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; } +.detail-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; } +.btn.small { padding: 6px 12px; font-size: 12px; } +.risk { border-radius: 12px; padding: 10px 13px; font-size: 12.5px; margin-bottom: 12px; border: 1px solid transparent; } +.risk-bad { background: var(--bad-soft); color: var(--bad); } +.risk-warn { background: var(--warn-soft); color: var(--warn); } +.risk-info { background: var(--signal-soft); color: var(--signal); } +.risk strong { font-weight: 700; } +.flag-ok { background: var(--ok-soft); color: var(--ok); } +.flag-bad { background: var(--bad-soft); color: var(--bad); } +.clients-card { margin-top: 2px; overflow-x: auto; } +.mini-table { width: 100%; border-collapse: collapse; } +.mini-table .row-actions { text-align: right; white-space: nowrap; } +.mini-table th { padding: 9px 12px; font-size: 10.5px; text-transform: uppercase; letter-spacing: .05em; color: var(--text-3); text-align: left; border-bottom: 1px solid var(--line); } +.mini-table td { padding: 9px 12px; font-size: 12.5px; border-bottom: 1px solid var(--line); vertical-align: middle; } +.mini-table tr:last-child td { border-bottom: 0; } +.mini-table .nowrap { white-space: nowrap; font-variant-numeric: tabular-nums; } +.mini-table .probes { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-2); } +.mini-table .device-cell { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-2); } +.clients-card .mini-table { table-layout: fixed; } +.clients-card .mini-table th:nth-child(1) { width: 20%; } +.clients-card .mini-table th:nth-child(2) { width: 18%; } +.clients-card .mini-table th:nth-child(3) { width: 12%; } +.clients-card .mini-table th:nth-child(4) { width: 7%; } +.clients-card .mini-table th:nth-child(5) { width: 10%; } +.clients-card .mini-table th:nth-child(6) { width: 12%; } +.clients-card .mini-table th:nth-child(7) { width: 21%; } +.clients-card .row-actions .btn { padding: 6px 10px; } +.clients-card .client-name, .clients-card .client-sub { max-width: 100%; } +.client-name { font-weight: 600; color: var(--text); max-width: 210px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.client-sub { color: var(--text-3); font-size: 10.5px; max-width: 210px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.empty-inline { text-align: center; padding: 46px 20px; color: var(--text-2); background: var(--surface-2); border-radius: 14px; } +.empty-inline strong { color: var(--text); display: block; margin-bottom: 4px; } +.empty-inline p { margin: 0; font-size: 13px; } +.auth-check { display: flex; align-items: center; gap: 8px; font-size: 12.5px; font-weight: 600; margin: 4px 0 14px; } +.auth-check input { width: 16px; height: 16px; accent-color: var(--accent); } +/* Colunas CSS em vez de grid: os cards de ataque têm alturas muito + diferentes entre si (Deauth/Handshake são enormes, PMF/WPS são curtos). + Num grid de 2 colunas comum, o par mais curto de uma linha deixava um + vão vazio enorme embaixo (a altura da linha é a do item mais alto, e + align-items:start não estica o curto pra preencher). Colunas fazem os + cards fluírem como alvenaria: cada um só ocupa a altura que precisa, o + próximo entra logo abaixo, sem buracos. */ +.attack-grid { column-count: 2; column-gap: 12px; } +.attack-card { background: var(--surface-2); border: 1px solid var(--line); border-radius: 14px; padding: 14px 16px; min-width: 0; break-inside: avoid; margin: 0 0 12px; display: inline-block; width: 100%; } +.attack-deauth, .attack-wide { column-span: all; } +.attack-deauth .field-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.attack-deauth .detail-actions { align-items: center; } +.attack-card h3 { font-size: 14px; margin-bottom: 5px; } +.attack-card .hint { margin: 6px 0 10px; } +.attack-card .btn { margin-top: 2px; } +.field-row { display: grid; grid-template-columns: 1fr 1.4fr; gap: 10px; } +.field { display: grid; gap: 5px; margin: 9px 0; } +.field-label { font-size: 11.5px; color: var(--text-2); font-weight: 600; } +.input { width: 100%; border: 1px solid transparent; border-radius: 10px; padding: 8px 11px; background: color-mix(in srgb, var(--text) 7%, transparent); color: var(--text); font: inherit; font-size: 12.5px; } +.input:focus { outline: 2px solid var(--signal); outline-offset: 1px; } +.hint { color: var(--text-3); font-size: 12px; margin: 0; line-height: 1.4; } +.hint.bad { color: var(--bad); } +.hint code { background: color-mix(in srgb, var(--text) 7%, transparent); padding: 1px 5px; border-radius: 5px; font-size: 11.5px; } +.hs-list { display: grid; gap: 6px; margin-bottom: 8px; } +.hs-item { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 8px 10px; font-size: 12px; } +.hs-main { min-width: 0; display: grid; gap: 2px; } +.hs-meta { color: var(--text-3); font-size: 11px; } +.hs-flags { display: flex; flex-wrap: wrap; gap: 4px; justify-content: flex-end; } +.crack-box { margin-top: 10px; padding: 9px 11px; border-radius: 10px; background: var(--surface); border: 1px solid var(--line); font-size: 12px; display: flex; flex-wrap: wrap; align-items: center; gap: 7px; } +.crack-ok { border-color: color-mix(in srgb, var(--ok) 40%, transparent); } +.crack-box .mono { margin-right: 4px; } +.crack-head { display: flex; align-items: center; gap: 8px; width: 100%; color: var(--text-2); } +.crack-bar { width: 100%; height: 4px; border-radius: 999px; background: var(--line); overflow: hidden; } +.crack-bar i { display: block; width: 35%; height: 100%; border-radius: 999px; background: var(--accent); animation: crack-slide 1.1s ease-in-out infinite; } +@keyframes crack-slide { 0% { margin-left: -35%; } 100% { margin-left: 100%; } } + /* ------------------------------------------------------------ responsivo */ @media (max-width: 760px) { .app { grid-template-columns: 1fr; grid-template-rows: auto 1fr; } @@ -186,4 +343,119 @@ td.ssid em { color: var(--text-3); font-weight: 400; font-style: normal; } .brand { padding: 4px 10px; } .side-title, .side-foot { display: none; } .list { display: flex; } + .result-tools { width: 100%; } + .result-tools .filter { flex: 1; width: auto; } + .net-table { min-width: 560px; } + .bssid-item { grid-template-columns: 1fr auto; } + .bssid-meta, .bssid-sig, .bssid-flags { grid-column: 1 / -1; } + .bssid-flags { justify-content: flex-start; } + .target-dlg { width: calc(100vw - 16px); max-height: 94vh; } + .target-head { padding: 16px 16px 10px; } + .tabs { padding: 0 10px; overflow-x: auto; } + .tab-panel { padding: 14px 14px 20px; } + .detail-grid { grid-template-columns: 1fr; } + .attack-grid { column-count: 1; } + .field-row { grid-template-columns: 1fr; } + .detail-card, .attack-card { padding: 12px; } +} + +/* --------------------------------------------------- análise de canais */ +.top-actions { display: flex; align-items: center; gap: 10px; } +.view-switch { display: inline-flex; padding: 2px; border-radius: 999px; background: color-mix(in srgb, var(--text) 7%, transparent); } +.view-switch button { border: 0; border-radius: 999px; padding: 5px 12px; font-size: 12px; font-weight: 600; color: var(--text-2); background: transparent; cursor: pointer; } +.view-switch button[aria-pressed="true"] { background: var(--surface); color: var(--text); box-shadow: 0 1px 3px rgba(0, 0, 0, .12); } +.alert-strip { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin: 0 2px 10px; } +.alert-item { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--line); background: var(--surface); border-radius: 999px; padding: 5px 11px; font-size: 12px; cursor: pointer; } +.alert-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--warn); } +.alert-high .alert-dot { background: var(--bad); } +.alert-low .alert-dot { background: var(--text-3); } +.alert-title { max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.alert-more { color: var(--text-3); font-size: 11.5px; padding: 0 4px; } +.channel-card { padding: 16px; } +.channel-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; } +.band-switch { display: inline-flex; gap: 4px; } +.band-switch button { border: 1px solid transparent; background: color-mix(in srgb, var(--text) 6%, transparent); color: var(--text-2); border-radius: 999px; padding: 5px 12px; font-size: 12px; font-weight: 600; cursor: pointer; } +.band-switch button[aria-pressed="true"] { background: var(--signal-soft); color: var(--signal); border-color: color-mix(in srgb, var(--signal) 35%, transparent); } +.sp-legend { display: flex; flex-wrap: wrap; gap: 10px; color: var(--text-3); font-size: 11px; } +.sp-legend span { display: inline-flex; align-items: center; gap: 5px; } +.sp-legend i { width: 9px; height: 9px; border-radius: 2px; display: inline-block; } +.spectrum { width: 100%; height: auto; display: block; background: color-mix(in srgb, var(--text) 3%, transparent); border-radius: 12px; } +.sp-grid { stroke: var(--line); stroke-width: 1; } +.sp-axis { stroke: color-mix(in srgb, var(--text) 22%, transparent); stroke-width: 1; } +.sp-db, .sp-chan { fill: var(--text-3); font-size: 9px; font-family: var(--mono); } +.sp-name { fill: var(--text-2); font-size: 9px; font-weight: 600; } +.sp-lobe path { transition: fill-opacity .15s; } +.sp-lobe:hover path { fill-opacity: .38; } +.channel-grid { display: grid; grid-template-columns: 190px 1fr; gap: 18px; margin-top: 14px; } +.channel-grid h3 { font-size: 12px; color: var(--text-2); margin-bottom: 8px; } +.chan-best-num { font-size: 44px; font-weight: 700; line-height: 1; color: var(--signal); margin: 0 0 6px; } +.chan-list { display: grid; gap: 6px; } +.chan-list h3 { margin: 0 0 2px; } +.chan-row { display: grid; grid-template-columns: 46px 1fr 48px 128px; align-items: center; gap: 8px; font-size: 12px; } +.chan-row .chan-n { font-weight: 600; display: inline-flex; align-items: center; gap: 4px; } +.chan-bar { height: 7px; border-radius: 999px; background: var(--line); overflow: hidden; } +.chan-bar i { display: block; height: 100%; border-radius: 999px; background: var(--ok); } +.chan-bar i.diff-mid { background: var(--warn); } +.chan-bar i.diff-high { background: var(--bad); } +.chan-score { font-family: var(--mono); font-size: 11px; color: var(--text-2); text-align: right; } +.chan-aps { color: var(--text-3); font-size: 11px; white-space: nowrap; } +.chan-best .chan-n, .chan-best .chan-aps { color: var(--signal); } + +/* ------------------------------------------------- saúde do espectro (SMART) */ +.chan-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); gap: 10px; margin: 2px 0 16px; } +.chan-tile { background: var(--surface-2); border-radius: 14px; padding: 12px 14px; display: flex; flex-direction: column; gap: 2px; box-shadow: 0 0 0 1px var(--line); } +.chan-tile small { color: var(--text-3); font-size: 12px; } +.chan-tile b { font-size: 18px; font-variant-numeric: tabular-nums; } +.chan-tile.status.ok { background: var(--ok-soft); } .chan-tile.status.ok b { color: var(--ok); } +.chan-tile.status.warn { background: var(--warn-soft); } .chan-tile.status.warn b { color: var(--warn); } +.chan-tile.status.bad { background: var(--bad-soft); } .chan-tile.status.bad b { color: var(--bad); } +.chan-tile b span { font-weight: 500; opacity: .8; font-size: 13px; margin-left: 4px; } + +.chan-dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; background: var(--ok); margin-right: 5px; flex: none; } +.chan-dot.warn { background: var(--warn); } +.chan-dot.bad { background: var(--bad); } + +.chan-attrs { margin-top: 4px; } +.chan-attrs summary { cursor: pointer; font-size: 12.5px; font-weight: 600; color: var(--text-2); padding: 6px 0; list-style: none; } +.chan-attrs summary::-webkit-details-marker { display: none; } +.chan-attrs summary::before { content: '▸ '; display: inline-block; width: 12px; } +.chan-attrs[open] summary::before { content: '▾ '; } +.chan-table .dim { color: var(--text-3); font-size: 11.5px; } +.chan-table tr.chan-best-row td { color: var(--signal); font-weight: 600; } +.mini-tag { font-size: 8.5px; padding: 1px 4px; border-radius: 4px; background: var(--warn-soft); color: var(--warn); } + +/* ------------------------------------------------ clientes e linha do tempo */ +.spark { display: inline-block; width: 62px; height: 16px; vertical-align: middle; margin-left: 6px; } +.spark path { fill: none; stroke: var(--signal); stroke-width: 1.4; } +.spark-big { display: block; width: 100%; height: 70px; margin: 10px 0 0; } +.timeline-card { margin-top: 12px; } +.timeline-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 6px; } +.timeline { list-style: none; padding: 0; margin: 8px 0; display: grid; gap: 5px; max-height: 260px; overflow: auto; } +.timeline li { display: grid; grid-template-columns: 84px 112px 1fr; align-items: baseline; gap: 8px; font-size: 12px; border-bottom: 1px solid var(--line); padding: 5px 2px; } +.timeline li > .mono:first-child { white-space: nowrap; } +.tl-kind { font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .03em; color: var(--text-3); } +.tl-deauth { color: var(--bad); } +.tl-associate { color: var(--ok); } +.tl-probe { color: var(--signal); } +.tl-detail { color: var(--text-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* ------------------------------------------------------------- WPS e relatório */ +.term { margin: 10px 0 0; padding: 10px 12px; border-radius: 10px; background: color-mix(in srgb, var(--text) 6%, transparent); color: var(--text-2); font: 11.5px/1.5 var(--mono); white-space: pre-wrap; max-height: 180px; overflow: auto; } +.report-dlg { width: min(1060px, 96vw); height: 92vh; max-height: 92vh; padding: 0; display: flex; flex-direction: column; overflow: hidden; } +.report-dlg:not([open]) { display: none; } +.report-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; padding: 16px 18px 12px; border-bottom: 1px solid var(--line); flex-wrap: wrap; } +.report-head h2 { font-size: 17px; margin: 0 0 2px; } +.report-tools { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } +.report-tools .mono { font-size: 11.5px; color: var(--text-2); min-width: 38px; text-align: center; } +.report-view { flex: 1; overflow: auto; background: color-mix(in srgb, var(--text) 8%, var(--bg)); padding: 16px; } +.report-canvas { margin: 0 auto; transform-origin: top center; background: #fff; box-shadow: 0 6px 30px rgba(0, 0, 0, .18); } +.report-canvas iframe { border: 0; display: block; background: #fff; } + +@media (max-width: 760px) { + .top-actions { gap: 6px; } + .channel-grid { grid-template-columns: 1fr; } + .chan-row { grid-template-columns: 42px 1fr 44px; } + .chan-aps { grid-column: 2 / -1; } + .report-dlg { width: 100vw; height: 100vh; max-height: 100vh; border-radius: 0; } + .report-view { padding: 8px; } } diff --git a/apps/gui/ui/app.js b/apps/gui/ui/app.js index 122b721..7bd671c 100644 --- a/apps/gui/ui/app.js +++ b/apps/gui/ui/app.js @@ -5,7 +5,7 @@ const token = params.get('token'); const tauri = window.__TAURI__; const els = { - list: document.getElementById('ifaceList'), + ifaceSelect: document.getElementById('ifaceSelect'), ifaceSub: document.getElementById('ifaceSub'), modePill: document.getElementById('modePill'), modeLabel: document.getElementById('modeLabel'), @@ -13,12 +13,66 @@ const els = { 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 */ } @@ -36,12 +90,25 @@ let ifaces = []; let current = null; let scanning = false; -const ICON_WIFI = ``; +// 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 = ``; +const warnSvg = ``; async function api(path, opts) { - if (tauri) return tauriApi(path); + if (tauri) return tauriApi(path, opts); const url = new URL(path, location.origin); url.searchParams.set('token', token); const r = await fetch(url, opts); @@ -50,28 +117,155 @@ async function api(path, opts) { // 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) { +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 (p) { - case '/api/version': + switch (`${method} ${p}`) { + case 'GET /api/version': return { version: await invoke('version') }; - case '/api/ifaces': + case 'GET /api/ifaces': return await invoke('list_ifaces'); - case '/api/monitor': + case 'POST /api/monitor': await invoke('set_monitor', { iface: q.get('iface'), enable: q.get('enable') === 'true' }); return { ok: true }; - case '/api/scan/start': - await invoke('start_scan', { iface: q.get('iface') }); + 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 '/api/scan/stop': + case 'POST /api/scan/stop': await invoke('stop_scan', { iface: q.get('iface') }); return { ok: true }; - case '/api/scan': + case 'GET /api/scan': return await invoke('scan_results', { iface: q.get('iface') }); - case '/api/update/check': + 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' }; @@ -82,28 +276,43 @@ async function tauriApi(path) { } function renderEmptyResult(message, hint) { + els.resultArea._h = null; els.resultArea.innerHTML = `
${ICON_EMPTY}

${message}

${hint}

`; } -function renderIfaceList() { - els.list.innerHTML = ''; - for (const i of ifaces) { - const li = document.createElement('li'); - const btn = document.createElement('button'); - btn.className = 'dev'; - btn.setAttribute('aria-current', String(i.name === current)); - btn.innerHTML = `${ICON_WIFI}${i.name}${i.mac}`; - btn.addEventListener('click', () => selectIface(i.name)); - li.appendChild(btn); - els.list.appendChild(li); - } +// 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 = ``; + sel.disabled = true; + return; + } + sel.disabled = false; + sel.innerHTML = ifaces + .map((i) => ``) + .join(''); + sel.value = current || ''; +} + +els.ifaceSelect.addEventListener('change', () => selectIface(els.ifaceSelect.value)); + function selectIface(name) { current = name; scanning = false; + lastSnapshot = null; + lastAps = []; + selectedBssid = null; renderIfaceList(); refreshModeUi(); + updateReportButton(); renderEmptyResult('Pronto pra capturar', 'Ative o modo monitor e inicie a captura pra ver as redes no ar.'); } @@ -116,6 +325,7 @@ function refreshModeUi() { els.btnMonitor.disabled = !current; els.btnMonitor.textContent = on ? 'desativar modo monitor' : 'ativar modo monitor'; els.btnScan.disabled = !on; + updateReportButton(); } async function loadIfaces() { @@ -140,7 +350,14 @@ els.btnMonitor.addEventListener('click', async () => { els.btnScan.addEventListener('click', async () => { if (!scanning) { - await api(`/api/scan/start?iface=${encodeURIComponent(current)}`, { method: 'POST' }); + 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'); @@ -162,9 +379,22 @@ els.btnUpdate.addEventListener('click', async () => { : '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) { - if (!sec) return ''; - return 'sec-' + sec.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, ''); + return SEC_CLASS[sec] || ''; +} + +function signalOf(ap) { + return ap.max_signal_dbm ?? ap.signal_dbm ?? null; } function signalBars(dbm) { @@ -184,36 +414,1944 @@ function signalBars(dbm) { return `${bars}`; } -function renderResults(aps) { - els.count.textContent = aps.length; - if (!aps.length) { - renderEmptyResult('Escutando o ar…', 'Nenhuma rede vista ainda. Isso pode levar alguns segundos enquanto os canais são varridos.'); +// 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('WPS'); + if (ap.pmf) flags.push('PMF'); + if (ap.cipher) flags.push(`${escapeHtml(ap.cipher)}`); + const meta = [ap.channel != null ? `canal ${ap.channel}` : '—', ap.band].filter(Boolean).join(' · '); + const clientLabel = clients.length === 1 ? '1 cliente' : `${clients.length} clientes`; + const clientsTitle = clients.length ? ` title="${escapeAttr(clients.join(', '))}"` : ''; + return `
+ ${ap.bssid} + ${meta} + ${sig != null ? signalBars(sig) + sig + ' dBm' : '—'} + ${ap.security || '—'} + ${clientLabel} + ${flags.join('')} +
`; +} + +function renderGroup(g) { + const open = openGroups.has(g.key); + const name = g.hidden ? 'oculta' : escapeHtml(g.name); + const apLabel = g.aps.length === 1 ? '1 AP' : `${g.aps.length} APs`; + const flags = [`${apLabel}`]; + if (g.hidden) flags.push(`${g.aps[0].bssid}`); + if (g.wps) flags.push('WPS'); + if (g.pmf === 'all') flags.push('PMF'); + else if (g.pmf === 'some') flags.push('PMF parcial'); + const secs = g.securities.length + ? g.securities.map((s) => `${s}`).join('') + : '—'; + const chans = g.channels.length ? g.channels.join(' · ') : '—'; + const bands = g.bands.length ? `${g.bands.join(' · ')}` : ''; + const clientsTitle = g.clients.size ? ` title="${escapeAttr([...g.clients].join(', '))}"` : ''; + const details = g.aps + .slice() + .sort((a, b) => signalBucket(signalOf(b)) - signalBucket(signalOf(a)) || orderOf(`bssid:${a.bssid}`) - orderOf(`bssid:${b.bssid}`)) + .map(renderBssid).join(''); + return ` + + + ${flags.join('')} + ${secs} + ${chans}${bands} + ${g.best != null ? signalBars(g.best) + g.best + ' dBm' : '—'} + ${g.clients.size} + + + +
${details}
+ `; +} + +let lastAps = []; +const openGroups = new Set(); +let viewMode = 'nets'; +try { viewMode = localStorage.getItem('sabia-view') || 'nets'; } catch { /* 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 `
${shown + .map((f) => { + const tone = f.severity === 'bad' ? 'high' : f.severity === 'warn' ? 'warn' : 'low'; + return ``; + }) + .join('')}${more > 0 ? `+${more}` : ''}
`; +} + +const SEC_COLOR = { + Open: '#b8443a', WEP: '#b8443a', WPA: '#c98a2e', + WPA2: '#2f8f70', 'WPA2-EAP': '#2f8f70', 'WPA2/WPA3': '#3b7fae', + OWE: '#3b7fae', WPA3: '#356f9e', 'WPA3-EAP': '#356f9e', +}; + +function freqOf(ap) { + if (ap.freq_mhz) return ap.freq_mhz; + const ch = ap.channel; + if (ch == null) return null; + if (ap.band === '2.4 GHz') return ch === 14 ? 2484 : 2407 + ch * 5; + if (ap.band === '5 GHz') return 5000 + ch * 5; + if (ap.band === '6 GHz') return 5950 + ch * 5; + return null; +} + +function spectrumSvg(band) { + const all = lastAps.filter((a) => a.band === band.band).map((a) => ({ ap: a, freq: freqOf(a) })).filter((x) => x.freq); + const candidateFreqs = band.candidates.map((c) => c.freq_mhz); + const freqs = [...all.map((x) => x.freq), ...candidateFreqs]; + if (!freqs.length) return ''; + let min = Math.min(...freqs) - 12; + let max = Math.max(...freqs) + 12; + if (band.band === '2.4 GHz') { min = Math.min(min, 2397); max = Math.max(max, 2492); } + const W = 920; + const H = 238; + const left = 34; + const right = 12; + const top = 16; + const base = H - 26; + const plotW = W - left - right; + const x = (freq) => left + ((freq - min) / (max - min)) * plotW; + const y = (dbm) => { + const v = Math.max(-96, Math.min(-28, dbm ?? -75)); + return base - ((v + 96) / 68) * (base - top); + }; + const grid = []; + for (let dbm = -30; dbm >= -90; dbm -= 15) { + const yy = y(dbm); + grid.push(``); + grid.push(`${dbm}`); + } + const lobes = all + .slice() + .sort((a, b) => (signalOf(b.ap) ?? -999) - (signalOf(a.ap) ?? -999)) + .map(({ ap, freq }, idx) => { + const sig = signalOf(ap) ?? -75; + const width = (ap.width_mhz || 20) * 1.35; + const x0 = x(freq - width / 2); + const x1 = x(freq + width / 2); + const cx = x(freq); + const yTop = y(sig); + const color = SEC_COLOR[ap.security] || '#2f8f70'; + const label = !ap.hidden && ap.ssid && idx < 7 + ? `${escapeHtml(ap.ssid.slice(0, 16))}` + : ''; + return `${escapeAttr(`${ap.ssid || ap.bssid} · canal ${ap.channel} · ${sig} dBm`)} + + ${label}`; + }) + .join(''); + const ticks = band.candidates + .map((c) => `${c.channel}`) + .join(''); + return ` + ${grid.join('')} + + ${lobes}${ticks} + `; +} + +const CHAN_TONE_WORD = { ok: 'Boa', warn: 'Atenção', bad: 'Saturado' }; + +/** 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; } - const rows = aps.map((ap) => ` - - ${ap.ssid ? escapeHtml(ap.ssid) : 'oculta'} - ${ap.bssid} - ${ap.channel ?? '—'} - ${signalBars(ap.last_signal_dbm)}${ap.last_signal_dbm != null ? ap.last_signal_dbm + ' dBm' : '—'} - ${ap.security || '—'} - ${ap.clients.length} - `).join(''); - els.resultArea.innerHTML = ` -
- - - ${rows} + if (!channelBand || !bands.some((b) => b.band === channelBand)) channelBand = bands[0].band; + const band = bands.find((b) => b.band === channelBand) || bands[0]; + const aps = lastAps.filter((a) => a.band === band.band); + els.count.textContent = `${aps.length} APs · ${band.used_channels.length} canais ocupados`; + + const rec = band.candidates.find((c) => c.channel === band.recommended) || null; + const health = bandHealth(band, rec); + const worst = band.candidates.reduce((a, b2) => (b2.score > (a?.score ?? -1) ? b2 : a), null); + const dfsCount = band.candidates.filter((c) => c.dfs).length; + + const tile = (label, value, cls = '') => value == null ? '' : `
${label}${value}
`; + const tiles = [ + `
Estado${CHAN_TONE_WORD[health.tone]}
`, + tile('Canal recomendado', `${band.recommended ?? '—'}${rec?.dfs ? ' DFS' : ''}`), + tile('Canais ocupados', `${band.used_channels.length}`), + tile('APs na banda', `${aps.length}`), + worst ? tile('Pior canal', `${worst.channel} ${worst.score.toFixed(2)}`, toneOf(worst.score, worst.dfs) !== 'ok' ? toneOf(worst.score, worst.dfs) : '') : '', + dfsCount ? tile('Canais DFS', `${dfsCount}`) : '', + ].join(''); + + const alert = health.tone !== 'ok' + ? `
${warnSvg}
${escapeHtml(health.label)} +
${band.band === '2.4 GHz' + ? 'Nessa banda, prefira sempre 1, 6 ou 11 — canais no meio sobrepõem vizinhos.' + : rec?.dfs + ? 'Canais DFS liberam mais espaço, mas o rádio é obrigado a sair do ar se detectar radar — tenha um canal não-DFS de reserva.' + : 'Considere mudar o AP para o canal recomendado ou mover pra outra banda.'}
` + : ''; + + const rows = band.candidates.map((c) => { + const tone = toneOf(c.score, c.dfs); + const best = band.recommended === c.channel; + return ` + + + + + + `; + }).join(''); + + const switches = `
${bands + .map((b) => ``) + .join('')}
`; + const legend = `
WPA2WPA3aberta/WEPWPA
`; + const wpa3 = analysis?.wpa3?.length ? `

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

` : ''; + + setResultsHtml(`${alertsHtml()}${alert}
+
${switches}${legend}
+
${tiles}
+ ${spectrumSvg(band)} +
Canais detalhados (${band.candidates.length}) +
RedeBSSIDCanalSinalSegurançaClientes
${c.channel}${c.dfs ? 'DFS' : ''}${c.score.toFixed(2)}${c.aps} AP(s)${best ? 'Recomendado' : ''}
+ ${rows}
CanalPontuaçãoAPs
+ + ${wpa3} +
`); +} + +function renderResults(aps) { + lastAps = aps; + updateReportButton(); + if (viewMode === 'channels') { + renderChannels(); + return; + } + if (!aps.length) { + els.count.textContent = '0'; + if (lastSnapshot?.warning) { + renderEmptyResult('A placa não está capturando nada', lastSnapshot.warning); + } else { + renderEmptyResult('Escutando o ar…', 'Nenhuma rede vista ainda. Isso pode levar alguns segundos enquanto os canais são varridos.'); + } + return; + } + const all = buildGroups(aps); + const groups = sortGroups(filterGroups(all)); + const filtering = !!els.searchInput.value.trim(); + els.count.textContent = filtering + ? `${groups.length} de ${all.length} redes` + : `${all.length} redes · ${aps.length} BSSIDs · ${new Set(aps.flatMap((a) => a.clients || [])).size} clientes`; + if (!groups.length) { + renderEmptyResult('Nenhuma rede corresponde ao filtro', 'Ajuste a busca ou limpe o campo de filtro.'); + return; + } + setResultsHtml(`${alertsHtml()} +
+ + + ${groups.map(renderGroup).join('')}
RedeSegurançaCanaisSinalClientes
+
`); +} + +function setView(mode) { + viewMode = mode === 'channels' ? 'channels' : 'nets'; + try { localStorage.setItem('sabia-view', viewMode); } catch { /* 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 `${escapeHtml(String(value))}`; +} + +function kv(pairs) { + return pairs + .filter(([, value]) => value != null && value !== '') + .map(([label, value]) => `
${escapeHtml(label)}
${value}
`) + .join(''); +} + +function securityTag(sec) { + return `${escapeHtml(sec || '—')}`; +} + +function hsBadges(hs) { + const out = []; + if (hs.complete) out.push('completo'); + if (hs.crackable) out.push('quebrável'); + if (hs.password) out.push(`senha: ${escapeHtml(hs.password)}`); + if (hs.pmkid) out.push('PMKID'); + return out.join(''); +} + +function hsListHtml(hs) { + if (!hs.length) return '

Nenhum handshake capturado para este BSSID.

'; + return `
${hs + .map( + (h) => `
+
${escapeHtml(h.station)} + ${escapeHtml(h.kind)} · mensagens ${escapeHtml(h.messages || '—')}
+
${hsBadges(h)}
+
`, + ) + .join('')}
`; +} + +function riskHtml(ap) { + const notes = []; + if (ap.security === 'Open') { + notes.push( + '
Rede aberta. O tráfego não é cifrado pelo Wi-Fi; qualquer cliente pode se associar.
', + ); + } + if (ap.security === 'WEP') { + notes.push( + '
WEP. A cifra é quebrável; capture IVs na aba Ataques.
', + ); + } + if (ap.wps) { + notes.push( + `
WPS ativo${ap.wps_version ? ` (${escapeHtml(ap.wps_version)})` : ''}. ${ap.wps_locked ? 'O AP anuncia configuração travada.' : 'PIN pode estar acessível; verifique o lock.'}
`, + ); + } + if (ap.pmf_required) { + notes.push( + '
PMF exigido. Desautenticação e captura de handshake podem ser ignoradas pelos clientes.
', + ); + } else if (ap.security?.startsWith('WPA2') || ap.security?.startsWith('WPA3') || ap.security === 'WPA') { + notes.push( + '
Sem PMF obrigatório. Desautenticação tende a funcionar e facilita a captura do handshake.
', + ); + } + return notes.join(''); +} + +function renderOverview(ap, clients, hs) { + const group = apsOfGroup(ap); + const assoc = clients.filter((c) => c.associated && (!c.bssid || c.bssid === ap.bssid)); + const crackable = hs.filter((h) => h.crackable); + const channels = [...new Set(group.map((a) => a.channel).filter((v) => v != null))].sort((a, b) => a - b); + const bands = [...new Set(group.map((a) => a.band).filter(Boolean))]; + return ` + ${riskHtml(ap)} +
+
+

Identificação

+
${kv([ + ['SSID', ap.ssid ? escapeHtml(ap.ssid) : 'oculta'], + ['BSSID', mono(ap.bssid)], + ['Fabricante', ap.vendor ? escapeHtml(ap.vendor) : '—'], + ['Oculta', yesNo(ap.hidden || !ap.ssid)], + ['País', ap.country ? escapeHtml(ap.country) : '—'], + ['Primeiro visto', fmtTime(ap.first_seen_unix)], + ['Último visto', fmtTime(ap.last_seen_unix)], + ])}
+
+
+

Rádio

+
${kv([ + [bands.length > 1 ? 'Bandas' : 'Banda', bands.length ? escapeHtml(bands.join(' · ')) : '—'], + ['Frequência', ap.freq_mhz ? `${ap.freq_mhz} MHz` : '—'], + [channels.length > 1 ? 'Canais' : 'Canal', channels.length ? escapeHtml(channels.join(' · ')) : ap.channel != null ? String(ap.channel) : '—'], + ['Largura', ap.width_mhz ? `${ap.width_mhz} MHz` : '—'], + ['Padrão', escapeHtml(phyLabel(ap))], + ['Sinal', signalOf(ap) != null ? `${signalBars(signalOf(ap))}${signalOf(ap)} dBm` : '—'], + ['Sinal máximo', ap.max_signal_dbm != null ? `${ap.max_signal_dbm} dBm` : '—'], + ['Intervalo de beacon', ap.beacon_interval ? `${ap.beacon_interval} TU` : '—'], + ['Beacons', String(ap.beacons ?? 0)], + ['Quadros de dados', String(ap.data_frames ?? 0)], + ])}
+
+
+

Segurança

+
${kv([ + ['Modo', securityTag(ap.security)], + ['Cifra', ap.cipher ? escapeHtml(ap.cipher) : '—'], + ['AKM', ap.akm?.length ? escapeHtml(ap.akm.join(' · ')) : '—'], + ['PMF', pmfLabel(ap)], + ['Privacidade', yesNo(ap.privacy)], + ['WPS', ap.wps ? 'ativo' : 'não anunciado'], + ['Versão WPS', ap.wps_version ? escapeHtml(ap.wps_version) : '—'], + ['WPS travado', ap.wps ? yesNo(ap.wps_locked) : '—'], + ])}
+
+
+

Clientes e handshake

+
${kv([ + ['Associados', String(assoc.length)], + ['Vistos na rede', String(clients.length)], + ['Handshakes', String(hs.length)], + ['Quebráveis', String(crackable.length)], + ['Senha', hs.find((h) => h.password)?.password ? mono(hs.find((h) => h.password).password) : '—'], + ])}
+
+ + +
+
+
+ ${ap.wps ? `

WPS

${kv([ + ['Fabricante', ap.wps_manufacturer ? escapeHtml(ap.wps_manufacturer) : '—'], + ['Modelo', ap.wps_model ? escapeHtml(ap.wps_model) : '—'], + ['Número do modelo', ap.wps_model_number ? escapeHtml(ap.wps_model_number) : '—'], + ['Nome do dispositivo', ap.wps_device_name ? escapeHtml(ap.wps_device_name) : '—'], + ['Serial', ap.wps_serial ? escapeHtml(ap.wps_serial) : '—'], + ])}
` : ''}`; +} + +const EVENT_LABEL = { + first_seen: 'primeiro visto', + probe: 'probe', + associate: 'associou', + deauth: 'desautenticado', +}; + +/** 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 '

Sem amostras de sinal suficientes ainda.

'; + const W = 560; + const H = 70; + const dbms = points.map((p) => p.dbm ?? -90); + const min = Math.min(...dbms, -90); + const max = Math.max(...dbms, -30); + const span = Math.max(1, max - min); + const d = points + .map((p, i) => { + const x = (i / (points.length - 1)) * W; + const y = H - (((p.dbm ?? min) - min) / span) * (H - 6) - 3; + return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)} ${y.toFixed(1)}`; + }) + .join(' '); + return ``; +} + +/** 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) => `
  • ${fmtTime(e.t)}${escapeHtml(EVENT_LABEL[e.kind] || e.kind)}${escapeHtml(e.detail || '')}
  • `).join('') + : '
  • Ainda sem eventos além do primeiro avistamento.
  • '; + const sig = signalOf(client); + const yesNo = (v) => (v ? 'sim' : 'não'); + return `
    +

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

    +
    +
    +
    +

    Identificação

    +
    ${kv([ + ['MAC', mono(client.mac)], + ['Nome exibido', client.display_name ? escapeHtml(client.display_name) : null], + ['Fabricante', client.manufacturer || client.vendor ? escapeHtml(client.manufacturer || client.vendor) : null], + ['Modelo', client.model ? escapeHtml(client.model) : null], + ['Nome do dispositivo', client.device_name ? escapeHtml(client.device_name) : null], + ['Hostname', client.hostname ? escapeHtml(client.hostname) : null], + ['MAC aleatório', yesNo(client.random_mac)], + ['Primeiro visto', fmtTime(client.first_seen_unix)], + ['Último visto', fmtTime(client.last_seen_unix)], + ])}
    +
    +
    +

    Rede

    +
    ${kv([ + ['Dispositivo', client.device ? escapeHtml(client.device) : null], + ['Sistema', client.os ? escapeHtml(client.os) : null], + ['IP', client.ip ? mono(client.ip) : null], + ['Associado a', client.bssid ? mono(client.bssid) : null], + ['Estado', client.associated ? 'associado' : 'visto'], + ['Pacotes', String(client.packets ?? 0)], + ['Probes', (client.probes || []).length ? escapeHtml(client.probes.join(', ')) : null], + ])}
    +
    +
    +

    Sinal

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

    Linha do tempo · eventos mais recentes primeiro.

    + +
    `; +} + +function clientName(c) { + if (c.display_name) return c.display_name; + if (c.hostname) return c.hostname; + if (c.device_name) return c.device_name; + if (c.model) return c.model; + if (c.manufacturer && c.manufacturer !== 'Unknown') return c.manufacturer; + if (c.vendor && c.vendor !== 'Unknown') return c.vendor; + return `Cliente ${String(c.mac || '').slice(-5).replace(':', '')}`; +} + +function clientDeviceText(c) { + const parts = []; + if (c.device) parts.push(c.device); + if (c.os) parts.push(c.os); + if (c.ip) parts.push(c.ip); + if (c.random_mac) parts.push('MAC aleatório'); + return parts.length ? parts.join(' · ') : '—'; +} + +function clientOptionLabel(c) { + const meta = [c.mac]; + if (c.device) meta.push(c.device); + else if (c.vendor) meta.push(c.vendor); + return `${clientName(c)} · ${meta.join(' · ')}`; +} + +function renderClients(ap, clients) { + if (!clients.length) { + return `
    Nenhum cliente visto.

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

    `; + } + const rows = clients + .map((c) => { + const associated = c.associated && (!c.bssid || c.bssid === ap.bssid); + const probing = (c.probes || []).includes(ap.ssid); + const state = associated ? 'associado' : probing ? 'sondando' : 'visto'; + const probes = (c.probes || []).length ? escapeHtml(c.probes.join(', ')) : '—'; + const sig = signalOf(c); + const notAssoc = 'Cliente não associado a este AP.'; + const name = clientName(c); + const details = [c.mac]; + if (c.manufacturer && c.manufacturer !== name) details.push(c.manufacturer); + if (c.model && c.model !== name) details.push(c.model); + const device = clientDeviceText(c); + return ` +
    ${escapeHtml(name)}
    ${escapeHtml(details.join(' · '))}
    + ${escapeHtml(device)} + ${sig != null ? `${signalBars(sig)}${sig} dBm` : '—'} + ${c.packets ?? 0} + ${state} + ${probes} + + `; + }) + .join(''); + return `
    + + ${rows}
    ClienteDispositivoSinalPacotesEstadoProbesAções
    ${renderClientDetail(clients)}`; +} + +function crackHtml() { + const info = attack.crackInfo; + if (!info) return ''; + if (info.status === 'running') { + return `
    quebrando${info.tried ?? 0} senhas testadas
    `; + } + if (info.status === 'error' || info.error) { + return `
    erro ${escapeHtml(info.error || 'falha ao quebrar')}
    `; + } + const report = info.report || info; + if (report.password) { + return `
    senha encontrada ${escapeHtml(report.password)}${report.tried ?? info.tried ?? 0} tentativas · ${report.elapsed_ms ?? 0} ms
    `; + } + return `
    concluído Senha não encontrada no dicionário (${report.tried ?? info.tried ?? 0} tentativas).
    `; +} + +function directionLabel(direction) { + return { ap: 'AP → cliente', sta: 'cliente → AP', both: 'ambas' }[direction] || direction; +} + +function jobStatusLabel(status) { + return { + running: 'em execução', + starting: 'iniciando', + done: 'concluído', + error: 'erro', + stopped: 'parado', + }[status] || status; +} + +function wpsOptions() { + const tools = attack.wpsTools || []; + if (!tools.length) return ''; + return tools + .map((t) => ``) + .join(''); +} + +function wpsHtml() { + const tools = attack.wpsTools || []; + const job = attack.wpsInfo; + const running = !!job && (job.status === 'running' || job.status === 'starting'); + const output = (job?.output || []).slice(-12).join('\n'); + const result = job + ? job.password + ? `Senha encontrada: ${job.password}${job.pin ? ` (PIN ${job.pin})` : ''}` + : job.error + ? `Falha: ${job.error}` + : `Status: ${jobStatusLabel(job.status)}${job.pin ? ` · PIN ${job.pin}` : ''}` + : tools.length + ? 'O reaver embutido usa Pixie Dust sem PIN. O bully aparece se estiver instalado no sistema.' + : 'O reaver embutido não pôde ser extraído nesta execução.'; + return `
    +

    WPS PIN

    +

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

    +
    + + +
    +
    + + +
    +

    ${escapeHtml(result)}

    +
    ${escapeHtml(output || 'A saída do reaver/bully aparece aqui.')}
    +
    `; +} + +function eviltwinHtml() { + const job = attack.eviltwinInfo; + const running = !!job && job.status === 'running'; + const attemptWord = { correct: 'senha certa', wrong: 'senha errada', invalid: 'tamanho inválido' }; + const result = job + ? job.password + ? `Senha confirmada: ${job.password}` + : job.error + ? `Falha: ${job.error}` + : `${jobStatusLabel(job.status)} · ${job.attempts ?? 0} tentativa(s) no portal${job.last_attempt ? ` · última: ${attemptWord[job.last_attempt] || job.last_attempt}` : ''}` + : 'Clona o AP (aberto) numa segunda interface e sobe um portal pedindo a senha de novo. A senha só é aceita se bater de verdade com o handshake já capturado — não é um "aceita qualquer coisa".'; + return `
    +

    Evil Twin + portal cativo

    +

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

    +
    + + +
    + +
    + + +
    +

    ${escapeHtml(result)}

    +
    `; +} + +function enterpriseTwinHtml(ap) { + if (!ap || !(ap.akm || []).includes('802.1X')) return ''; + const job = attack.entTwinInfo; + const running = !!job && job.status === 'running'; + const result = job + ? job.error + ? `Falha: ${job.error}` + : `${jobStatusLabel(job.status)} · ${job.captured ?? 0} credencial(is) capturada(s)${job.last_identity ? ` · última: ${job.last_identity}` : ''}` + : 'Clona o AP numa segunda interface e deixa o hostapd-wpe aceitar a autenticação EAP de qualquer cliente. A credencial sai sozinha, como parte do próprio 802.1X — sem portal, sem ação humana.'; + return `
    +

    Evil Twin Enterprise (802.1X)

    +

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

    +
    + +
    +
    + + +
    +

    ${escapeHtml(result)}

    + ${job && job.captured ? `

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

    ` : ''} +

    Limitação honesta: o certificado TLS do clone é autoassinado. Dispositivo que valida a cadeia de certificado da rede (comum em perfil corporativo/MDM atualizado) vai recusar ou avisar o usuário — essa é a defesa que deveria existir. Funciona contra configuração sem essa validação.

    +
    `; +} + +function deauthLiveText() { + const job = attack.deauthInfo; + if (!job) return 'Pronto. O job contínuo trava a captura no canal do AP e para quando você mandar.'; + if (job.error) return `Falha: ${job.error}`; + const channel = job.channel != null ? ` · canal ${job.channel}` : ''; + if (job.status === 'running' || job.status === 'starting') { + return `Job ${job.id} · ${job.frames_sent ?? 0} quadro(s)${channel}${job.stop_on_handshake ? ' · para sozinho no handshake' : ''}`; + } + if (job.handshake) return `Handshake capturado em ${job.frames_sent ?? 0} quadro(s)${channel}.`; + return `Job ${job.id} ${jobStatusLabel(job.status)} · ${job.frames_sent ?? 0} quadro(s)${channel}.`; +} + +function deauthRunning() { + const status = attack.deauthInfo?.status; + return !!attack.deauthJob && (status === 'running' || status === 'starting'); +} + +function renderAttacks(ap, clients, hs) { + const crackable = hs.filter((h) => h.crackable); + const stationOptions = clients + .map((c) => ``) + .join(''); + const hsOptions = hs + .map((h) => ``) + .join(''); + const pmfStation = attack.station || clients[0]?.mac || ''; + return ` +
    Somente em redes próprias ou com autorização escrita. Cada ação fica registrada na trilha de auditoria local, com interface, BSSID e estação.
    + +
    +
    +

    Desautenticação

    +

    Mantém os quadros saindo em série até você parar (ou até sair o handshake). O canal da captura fica travado no AP durante o job.

    + +

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

    +
    + + +
    +
    + + +
    +
    + + + + +
    +

    ${escapeHtml(deauthLiveText())}

    +
    + +
    +

    PMF (802.11w)

    +

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

    + + +

    +
    + +
    +

    PMKID clientless

    +

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

    + + +

    +
    + +
    +

    Handshake

    +

    ${hs.length ? `${hs.length} handshake(s) visto(s); ${crackable.length} quebrável(is).` : 'Nenhum handshake capturado ainda. Desautentique um cliente para estimular a reassociação.'}

    +
    ${hsListHtml(hs)}
    + + +
    + + + + +
    +

    + + + +
    ${crackHtml()}
    + +

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

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

    Captura WEP

    +

    WEP não tem handshake: o ataque coleta IVs em um .pcap. Reinicie a captura gravando o arquivo e gere tráfego com desautenticação. A captura atual é reiniciada e a tabela começa vazia.

    + +
    + + + +
    +

    +

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

    +
    ` : `

    WEP

    Esta rede não usa WEP.

    `}
    `; } +function updateAuthUi() { + const needsStation = attack.direction !== 'ap' && !attack.station; + els.tabPanel.querySelectorAll('[data-needs-auth]').forEach((b) => { + const deauth = b.dataset.act === 'deauth' || b.dataset.act === 'deauth-handshake' || b.dataset.act === 'deauth-start'; + b.disabled = !attack.authorized || b.dataset.locked === '1' || (deauth && needsStation); + }); + const running = deauthRunning(); + const start = els.tabPanel.querySelector('[data-act="deauth-start"]'); + const stop = els.tabPanel.querySelector('[data-act="deauth-stop"]'); + const burst = els.tabPanel.querySelector('[data-act="deauth"]'); + const handshake = els.tabPanel.querySelector('[data-act="deauth-handshake"]'); + if (start) start.disabled = running || !attack.authorized || needsStation; + if (stop) stop.disabled = !running; + if (burst) burst.disabled = running || !attack.authorized || needsStation; + if (handshake) handshake.disabled = running || !attack.authorized || needsStation; +} + +async function ensureWpsTools() { + if (attack.wpsLoaded) return; + attack.wpsLoaded = true; + const tools = await api('/api/wps/tools'); + attack.wpsTools = Array.isArray(tools) ? tools : []; + if (attack.wpsTools.length && !attack.wpsTools.some((t) => t.name === attack.wpsTool)) { + attack.wpsTool = attack.wpsTools[0].name; + } + if (activeTab === 'attacks') renderTarget(true); +} + +function setResult(id, text, bad) { + const el = document.getElementById(id); + if (!el) return; + el.textContent = text; + el.classList.toggle('bad', !!bad); +} + +function openTarget(bssid) { + const ap = findAp(bssid); + if (!ap) return; + if (attack.deauthJob) void stopDeauthJob(); + selectedBssid = bssid; + activeTab = 'overview'; + attack.deauthInfo = null; + attack.authorized = false; + attack.station = ''; + attack.hsStation = ''; + attack.crackJob = null; + attack.crackInfo = null; + attack.timelineMac = ''; + const slug = bssid.replace(/[^0-9a-z]/gi, ''); + attack.hsOut = `sabia-${slug}.pcap`; + attack.wepOut = `sabia-wep-${slug}.pcap`; + renderTarget(true); + if (!els.targetDlg.open) els.targetDlg.showModal(); + void loadSnapshot(); +} + +async function loadSnapshot() { + if (!current) return; + const snap = await api(`/api/scan/snapshot?iface=${encodeURIComponent(current)}`); + if (snap?.access_points) { + lastSnapshot = snap; + renderResults(snap.access_points); + if (selectedBssid) renderTarget(false); + } +} + +// 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 + ? `` + : ''; + els.targetSub.innerHTML = `${sub.join(' · ')}${selector}`; + els.tabClientsCount.textContent = String(clients.length); + els.tabPanel.dataset.tab = activeTab; + document.querySelectorAll('#targetDlg .tabs [data-tab]').forEach((b) => { + b.setAttribute('aria-selected', String(b.dataset.tab === activeTab)); + }); + if (activeTab === 'attacks' && !force) { + updateAttackLive(ap, hs); + return; + } + els.tabPanel.innerHTML = + activeTab === 'clients' ? renderClients(ap, clients) : activeTab === 'attacks' ? renderAttacks(ap, clients, hs) : renderOverview(ap, clients, hs); + if (activeTab === 'attacks') { + updateAuthUi(); + void ensureWpsTools(); + } + const sel = document.getElementById('bssidSel'); + if (sel) sel.addEventListener('change', () => openTarget(sel.value)); + const deauthSel = document.getElementById('deauthStation'); + if (deauthSel) { + deauthSel.addEventListener('change', () => { + attack.station = deauthSel.value; + const scope = document.getElementById('deauthScope'); + if (scope) { + scope.textContent = attack.direction === 'sta' + ? 'Do cliente para o AP (exige estação selecionada).' + : attack.direction === 'both' + ? 'Nas duas direções (exige estação selecionada).' + : attack.station + ? 'Somente o cliente selecionado será desautenticado.' + : 'Todos os clientes associados (broadcast).'; + } + updateAuthUi(); + }); + } +} + +function updateAttackLive(ap, hs) { + const crackable = hs.filter((h) => h.crackable); + document.querySelectorAll('#tabPanel [data-act="export-hs"], #tabPanel [data-act="export-all"], #tabPanel [data-act="export-hc"], #tabPanel [data-act="export-all-hc"]').forEach((b) => { + if (hs.length) delete b.dataset.locked; + else b.dataset.locked = '1'; + }); + document.querySelectorAll('#tabPanel [data-act="crack"]').forEach((b) => { + if (crackable.length) delete b.dataset.locked; + else b.dataset.locked = '1'; + }); + const summary = document.getElementById('hsSummary'); + if (summary) { + const crackable = hs.filter((h) => h.crackable); + summary.textContent = hs.length + ? `${hs.length} handshake(s) visto(s); ${crackable.length} quebrável(is).` + : 'Nenhum handshake capturado ainda. Desautentique um cliente para estimular a reassociação.'; + } + const list = document.getElementById('hsList'); + if (list) list.innerHTML = hsListHtml(hs); + const progress = document.getElementById('crackProgress'); + if (progress) progress.innerHTML = crackHtml(); + const deauthResult = document.getElementById('deauthResult'); + if (deauthResult) { + deauthResult.textContent = deauthLiveText(); + deauthResult.classList.toggle('bad', !!attack.deauthInfo?.error); + } + updateAuthUi(); +} + +async function pollCrackJob() { + if (!attack.crackJob) return; + const job = await api(`/api/crack?id=${encodeURIComponent(attack.crackJob)}`); + if (!job || job.error) { + attack.crackJob = null; + attack.crackInfo = { status: 'error', error: job?.error || 'job não encontrado' }; + const el = document.getElementById('crackProgress'); + if (el) el.innerHTML = crackHtml(); + return; + } + attack.crackInfo = job; + if (job.status === 'running') { + const el = document.getElementById('crackProgress'); + if (el) el.innerHTML = crackHtml(); + return; + } + attack.crackJob = null; + const el = document.getElementById('crackProgress'); + if (el) el.innerHTML = crackHtml(); + refreshAfterCrack(); +} + +async function refreshAfterCrack() { + if (!selectedBssid || !current) return; + const snap = await api(`/api/scan/snapshot?iface=${encodeURIComponent(current)}`); + if (snap?.access_points) { + lastSnapshot = snap; + renderResults(snap.access_points); + renderTarget(true); + } +} + +function updateDeauthUi() { + const el = document.getElementById('deauthResult'); + if (el) { + el.textContent = deauthLiveText(); + el.classList.toggle('bad', !!attack.deauthInfo?.error); + } + updateAuthUi(); +} + +async function waitForBssidChannel(bssid, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const snap = await api(`/api/scan/snapshot?iface=${encodeURIComponent(current)}`); + const ap = snap?.access_points?.find((a) => a.bssid?.toLowerCase() === bssid?.toLowerCase()); + if (ap?.channel != null) return true; + await new Promise((resolve) => setTimeout(resolve, 500)); + } + return false; +} + +async function startDeauthJob(handshake) { + // 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; @@ -230,9 +2368,39 @@ function animateWave() { async function poll() { if (scanning && current) { - const aps = await api(`/api/scan?iface=${encodeURIComponent(current)}`); - renderResults(aps); + 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); } @@ -265,6 +2433,7 @@ async function checkElevation() { (async function init() { applyLangPref(); + setView(viewMode); checkElevation(); const status = await api('/api/version'); els.verInfo.textContent = `v${status.version}`; diff --git a/apps/gui/ui/i18n-en.js b/apps/gui/ui/i18n-en.js index fb90797..5b152e6 100644 --- a/apps/gui/ui/i18n-en.js +++ b/apps/gui/ui/i18n-en.js @@ -2,8 +2,12 @@ export const EN = { // index.html / sidebar 'auditoria de Wi-Fi': 'Wi-Fi auditing', + 'Interface': 'Interface', + 'Interface Wi-Fi': 'Wi-Fi interface', 'Interfaces': 'Interfaces', 'nenhuma interface Wi-Fi encontrada': 'no Wi-Fi interface found', + 'APs, clientes e ataques': 'APs, clients and attacks', + 'espectro e saúde': 'spectrum and health', 'checar atualização': 'check for update', 'checando…': 'checking…', 'atualizar para {n}': 'update to {n}', @@ -30,7 +34,12 @@ export const EN = { 'parar captura': 'stop capture', // resultados + 'Redes': 'Networks', 'Redes vistas': 'Networks seen', + 'Linha do tempo': 'Timeline', + 'Linha do tempo ·': 'Timeline ·', + 'fechar': 'close', + 'motivo {n}': 'reason {n}', 'Pronto pra capturar': 'Ready to capture', 'Ative o modo monitor e inicie a captura pra ver as redes no ar.': 'Enable monitor mode and start capturing to see the networks on the air.', 'Nenhuma interface Wi-Fi': 'No Wi-Fi interface', @@ -41,16 +50,44 @@ export const EN = { 'oculta': 'hidden', 'Rede': 'Network', 'Canal': 'Channel', + 'Canais': 'Channels', + 'Banda': 'Band', + 'Bandas': 'Bands', + 'Nome': 'Name', 'Sinal': 'Signal', 'Segurança': 'Security', 'Clientes': 'Clients', + 'filtrar por SSID ou BSSID': 'filter by SSID or BSSID', + 'Filtrar redes': 'Filter networks', + 'Ordenar por': 'Sort by', + 'expandir': 'expand', + 'recolher': 'collapse', + 'PMF parcial': 'partial PMF', + 'canal {n}': 'channel {n}', + 'canal {n} · {n}': 'channel {n} · {n}', + '1 cliente': '1 client', + '{n} clientes': '{n} clients', + '{n} de {n} redes': '{n} of {n} networks', + '{n} redes · {n} BSSIDs · {n} clientes': '{n} networks · {n} BSSIDs · {n} clients', + 'Nenhuma rede corresponde ao filtro': 'No network matches the filter', + 'Ajuste a busca ou limpe o campo de filtro.': 'Adjust the search or clear the filter.', // erros de UI 'Não foi possível trocar o modo:': 'Could not change the mode:', + 'Não foi possível iniciar a captura:': 'Could not start the capture:', + 'Não foi possível retomar a captura: {n}': 'Could not resume the capture: {n}', + 'Retomando a captura e localizando a rede…': 'Resuming the capture and locating the network…', + 'A rede não reapareceu na varredura a tempo. Tente de novo.': 'The network did not reappear in the scan in time. Try again.', + 'A captura parou:': 'The capture stopped:', 'erro desconhecido': 'unknown error', 'rota desconhecida': 'unknown route', - // seletor de idioma + // seletor de idioma / configurações + 'Configurações': 'Settings', + 'Tema': 'Theme', + 'Claro': 'Light', + 'Escuro': 'Dark', + 'Fechar': 'Close', 'Idioma': 'Language', 'Auto': 'Auto', 'Português': 'Portuguese', @@ -95,4 +132,434 @@ export const EN = { 'nenhum handshake quebrável para os filtros informados': 'no crackable handshake for the given filters', 'quantidade de quadros deve ficar entre 1 e 256': 'frame count must be between 1 and 256', 'MAC inválido: {n}': 'invalid MAC: {n}', + + // detalhes do alvo e ataques + 'não': 'no', + 'não anunciado': 'not advertised', + 'quebrável': 'crackable', + 'Nenhum handshake capturado para este BSSID.': 'No handshake captured for this BSSID.', + 'Rede aberta.': 'Open network.', + 'O tráfego não é cifrado pelo Wi-Fi; qualquer cliente pode se associar.': 'Traffic is not encrypted over Wi-Fi; any client can associate.', + 'A cifra é quebrável; capture IVs na aba Ataques.': 'The cipher is breakable; capture IVs in the Attacks tab.', + 'O AP anuncia configuração travada.': 'The AP advertises configuration locked.', + 'PIN pode estar acessível; verifique o lock.': 'The PIN may be accessible; check the lock.', + 'Desautenticação e captura de handshake podem ser ignoradas pelos clientes.': 'Deauthentication and handshake capture may be ignored by clients.', + 'Sem PMF obrigatório.': 'No mandatory PMF.', + 'Desautenticação tende a funcionar e facilita a captura do handshake.': 'Deauthentication tends to work and makes handshake capture easier.', + 'País': 'Country', + 'Último visto': 'Last seen', + 'Primeiro visto': 'First seen', + 'Frequência': 'Frequency', + 'Largura': 'Width', + 'Padrão': 'Standard', + 'Sinal máximo': 'Max signal', + 'Intervalo de beacon': 'Beacon interval', + 'Quadros de dados': 'Data frames', + 'Versão WPS': 'WPS version', + 'Vistos na rede': 'Seen on network', + 'Quebráveis': 'Crackable', + 'Número do modelo': 'Model number', + 'Nome do dispositivo': 'Device name', + 'Serial': 'Serial', + 'Identificação': 'Identity', + 'Rádio': 'Radio', + 'sim': 'yes', + 'Nome exibido': 'Display name', + 'Hostname': 'Hostname', + 'Sistema': 'OS', + 'IP': 'IP', + 'Associado a': 'Associated to', + 'Atual': 'Current', + 'Máximo': 'Maximum', + 'Sem amostras de sinal suficientes ainda.': 'Not enough signal samples yet.', + 'Linha do tempo · eventos mais recentes primeiro.': 'Timeline · most recent events first.', + 'Cliente · {n}': 'Client · {n}', + 'A captura parou sozinha:': 'The capture stopped on its own:', + 'A placa não está capturando nada': 'The adapter is not capturing anything', + 'Nenhum quadro recebido depois de alguns segundos saltando canais — esta placa pode aceitar ativar o modo monitor sem conseguir capturar de verdade (comum em Wi-Fi interna de notebook). Tente um adaptador USB dedicado a modo monitor/injeção.': + 'No frame received after several seconds of channel hopping — this adapter may accept turning monitor mode on without actually being able to capture (common with a laptop\'s built-in Wi-Fi). Try a USB adapter dedicated to monitor mode/injection.', + 'motivo desconhecido — confira se o adaptador ainda está conectado.': + 'unknown reason — check whether the adapter is still connected.', + 'Clientes e handshake': 'Clients and handshake', + 'Nenhum cliente visto.': 'No client seen.', + 'Associe ou gere tráfego na rede para que o Sabiá identifique estações.': 'Associate or generate traffic on the network so Sabiá can identify stations.', + 'senha encontrada': 'password found', + 'concluído': 'done', + 'quebrando': 'cracking', + 'senhas testadas': 'passwords tried', + '{n} senhas testadas': '{n} passwords tried', + '{n} tentativas · {n} ms': '{n} attempts · {n} ms', + 'Senha não encontrada no dicionário ({n} tentativas).': 'Password not found in the wordlist ({n} attempts).', + '· quebrável': '· crackable', + 'não especificado': 'unspecified', + 'autenticação expirada': 'authentication expired', + 'saindo': 'leaving', + 'inativo': 'inactive', + 'AP sem recursos': 'AP out of resources', + 'classe 2': 'class 2', + 'classe 3': 'class 3', + '{n} handshake(s) visto(s); {n} quebrável(is).': '{n} handshake(s) seen; {n} crackable.', + 'Nenhum handshake capturado ainda. Desautentique um cliente para estimular a reassociação.': 'No handshake captured yet. Deauthenticate a client to trigger reassociation.', + 'Captura WEP': 'WEP capture', + '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.': 'WEP has no handshake: the attack collects IVs into a .pcap. Restart the capture writing the file and generate traffic with deauthentication. The current capture is restarted and the table starts empty.', + 'Arquivo pcap': 'pcap file', + 'Reiniciar captura com pcap': 'Restart capture to pcap', + 'Gerar tráfego': 'Generate traffic', + 'Copiar comando': 'Copy command', + 'Esta rede não usa WEP.': 'This network does not use WEP.', + 'Somente em redes próprias ou com autorização escrita.': 'Only on your own networks or with written authorization.', + 'Cada ação fica registrada na trilha de auditoria local, com interface, BSSID e estação.': 'Every action is logged in the local audit trail with interface, BSSID and station.', + 'Tenho autorização para testar esta rede': 'I have authorization to test this network', + 'Desautenticação': 'Deauthentication', + 'Expulsa uma estação (ou todas) para forçar reassociação e captura de handshake.': 'Kicks a station (or all) to force reassociation and handshake capture.', + 'Enviar desautenticação': 'Send deauthentication', + 'Forçar handshake': 'Force handshake', + 'Dicionário': 'Wordlist', + 'Rede oculta': 'Hidden network', + 'job não encontrado': 'job not found', + '{n} quadro(s) enviados para {n} (motivo {n}).': '{n} frame(s) sent to {n} (reason {n}).', + 'Selecione um handshake.': 'Select a handshake.', + 'Informe o dicionário.': 'Enter the wordlist.', + 'Informe o caminho do .pcap.': 'Enter the .pcap path.', + 'Força bruta por máscara — sem dicionário, testa toda combinação do padrão.': + 'Mask brute force — no wordlist, tests every combination of the pattern.', + 'dígito,': 'digit,', + 'minúscula,': 'lowercase,', + 'maiúscula,': 'uppercase,', + 'símbolo,': 'symbol,', + 'qualquer um; o resto é literal.': 'any of those; the rest is literal.', + 'Máscara': 'Mask', + 'Força bruta por máscara': 'Mask brute force', + 'Informe uma máscara (ex.: ?d?d?d?d?d?d?d?d).': 'Enter a mask (e.g. ?d?d?d?d?d?d?d?d).', + 'job não iniciado': 'job not started', + 'Captura gravando em {n}. Gere tráfego e aguarde IVs suficientes.': 'Capture writing to {n}. Generate traffic and wait for enough IVs.', + 'Visão geral': 'Overview', + 'Ver detalhes': 'View details', + 'Ver clientes': 'View clients', + 'Abrir ataques': 'Open attacks', + 'Todos (broadcast)': 'All (broadcast)', + 'associado': 'associated', + 'sondando': 'probing', + 'visto': 'seen', + 'Alvo': 'Target', + 'Handshake': 'Handshake', + 'Salvar pcap em': 'Save pcap to', + 'Exportar selecionado': 'Export selected', + 'Exportar todos': 'Export all', + 'Quebrar senha': 'Crack password', + 'completo': 'complete', + 'Ataques': 'Attacks', + 'Cliente alvo': 'Target client', + 'Ações': 'Actions', + 'Atacar': 'Attack', + 'Cliente não associado a este AP.': 'Client not associated with this AP.', + 'Somente o cliente selecionado será desautenticado.': 'Only the selected client will be deauthenticated.', + 'Todos os clientes associados (broadcast).': 'All associated clients (broadcast).', + 'Fabricante': 'Manufacturer', + 'Modelo': 'Model', + 'Oculta': 'Hidden', + 'Modo': 'Mode', + 'Cifra': 'Cipher', + 'Privacidade': 'Privacy', + 'WPS travado': 'WPS locked', + 'Associados': 'Associated', + 'Senha': 'Password', + 'Quadro': 'Frame', + 'Quadros': 'Frames', + 'ambas': 'both', + 'Tentativas': 'Attempts', + 'Testar PMF': 'Test PMF', + 'em execução': 'running', + 'iniciando': 'starting', + 'erro': 'error', + 'parado': 'stopped', + 'Status: {n}': 'Status: {n}', + 'tamanho inválido': 'invalid length', + '· última: {n}': '· last: {n}', + '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".': + 'Clones the AP (open) on a second interface and shows a portal asking for the password again. The password is only accepted if it truly matches the already-captured handshake — it is not an "accept anything" page.', + 'Exige um handshake quebrável já capturado deste AP (aba Handshake). Precisa de': + 'Requires a crackable handshake already captured for this AP (Handshake tab). Needs', + 'instalado e de uma': 'installed and a', + 'interface Wi-Fi em modo AP, diferente da que está capturando.': + 'Wi-Fi interface in AP mode, different from the one capturing.', + 'Iniciar Evil Twin': 'Start Evil Twin', + 'Informe a interface do clone (uma segunda placa).': + 'Enter the clone interface (a second adapter).', + 'a interface do clone precisa ser diferente da que está capturando': + 'the clone interface must be different from the one capturing', + 'esse BSSID ainda não apareceu nesta sessão de captura': + 'that BSSID has not appeared in this capture session yet', + 'rede oculta: sem SSID não dá pra clonar': 'hidden network: no SSID, cannot clone', + 'handshake sem dados suficientes pra conferir senha': + 'handshake does not have enough data to verify a password', + 'hostapd não está instalado (sudo apt install hostapd) — necessário pro Evil Twin': + 'hostapd is not installed (sudo apt install hostapd) — needed for Evil Twin', + '{n} · {n} credencial(is) capturada(s){n}': '{n} · {n} credential(s) captured{n}', + '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.': + 'Clones the AP on a second interface and lets hostapd-wpe accept the EAP authentication from any client. The credential comes out on its own, as part of 802.1X itself — no portal, no human action.', + 'Rede Enterprise detectada (AKM 802.1X) — aqui a credencial é enviada automaticamente pelo cliente durante o handshake EAP, não precisa de portal. Exige': + 'Enterprise network detected (AKM 802.1X) — here the credential is sent automatically by the client during the EAP handshake, no portal needed. Requires', + 'instalado (não vem com o Sabiá: é um patch no build do hostapd —': + 'installed (not bundled with Sabiá: it is a patch on the hostapd build —', + ') e uma': ') and a', + 'interface Wi-Fi em modo AP.': 'Wi-Fi interface in AP mode.', + 'Iniciar Evil Twin Enterprise': 'Start Enterprise Evil Twin', + '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.': + 'Honest limitation: the clone\'s TLS certificate is self-signed. A device that validates the network\'s certificate chain (common on an updated corporate/MDM profile) will refuse or warn the user — that is the defense that is supposed to exist. It works against configurations without that validation.', + 'essa rede não é WPA2/WPA3-Enterprise (802.1X) — pra rede com senha única (PSK), use o Evil Twin com portal cativo': + 'this network is not WPA2/WPA3-Enterprise (802.1X) — for a single-password (PSK) network, use the Evil Twin with captive portal', + 'hostapd-wpe não está instalado — não vem com o Sabiá (é um patch sobre o build do hostapd, não um binário pequeno); instale pelo repositório do Kali ou compile https://github.com/aircrack-ng/hostapd-wpe': + 'hostapd-wpe is not installed — not bundled with Sabiá (it is a patch on the hostapd build, not a small binary); install it from the Kali repository or build https://github.com/aircrack-ng/hostapd-wpe', + '{n} está num modo que o hostapd-wpe não consegue usar': + '{n} is in a mode hostapd-wpe cannot use', + 'não foi possível gerar o certificado TLS (openssl) pro hostapd-wpe': + 'could not generate the TLS certificate (openssl) for hostapd-wpe', + 'hostapd-wpe saiu sozinho ({n}) logo ao subir em {n} — a placa pode não suportar de verdade o canal/banda do AP real. Saída: {n}': + 'hostapd-wpe exited on its own ({n}) right after starting on {n} — the adapter may not actually support the real AP\'s channel/band. Output: {n}', + 'job de Evil Twin Enterprise não encontrado': 'Enterprise Evil Twin job not found', + 'job de Evil Twin não encontrado': 'Evil Twin job not found', + 'hostapd saiu sozinho ({n}) logo ao subir em {n} — a placa pode não suportar de verdade o canal/banda do AP real. Saída: {n}': + 'hostapd exited on its own ({n}) right after starting on {n} — the adapter may not actually support the real AP\'s channel/band. Output: {n}', + '{n} está num modo que o hostapd não consegue usar': '{n} is in a mode hostapd cannot use', + '{n} handshake(s), {n} quadro(s) em {n}': '{n} handshake(s), {n} frame(s) in {n}', + 'Motivo': 'Reason', + 'Pacotes': 'Packets', + 'Estado': 'State', + 'Probes': 'Probes', + 'Cliente': 'Client', + 'WPS ativo': 'WPS active', + 'WPS ativo.': 'WPS active.', + 'WPS ativo ({n}).': 'WPS active ({n}).', + 'PMF exigido.': 'PMF required.', + 'Depois de coletar IVs suficientes:': 'After collecting enough IVs:', + 'mensagens {n}': 'messages {n}', + '{n} · mensagens {n}': '{n} · messages {n}', + 'senha: {n}': 'password: {n}', + '{n} · quebrável': '{n} · crackable', + '{n} · canal {n}': '{n} · channel {n}', + '{n} ({n})': '{n} ({n})', + + // canais, PMKID/PMF, WPS e relatório + 'Ocupação dos canais em {n}': 'Channel occupancy on {n}', + 'Sem dados de canal ainda': 'No channel data yet', + 'Inicie a captura para o Sabiá medir ocupação e sugerir canais.': + 'Start capturing so Sabiá can measure occupancy and suggest channels.', + '{n} APs · {n} canais ocupados': '{n} APs · {n} busy channels', + '{n} AP(s) · recomendado': '{n} AP(s) · recommended', + '· recomendado': '· recommended', + 'aberta/WEP': 'open/WEP', + '{n} rede(s) WPA3 analisada(s) — detalhes na aba do alvo e no relatório.': + '{n} WPA3 network(s) analyzed — details in the target tab and report.', + 'Restrito a 1, 6 e 11 para evitar sobreposição.': + 'Limited to 1, 6 and 11 to avoid overlap.', + 'Menor pontuação de interferência entre os canais observados.': + 'Lowest interference score among observed channels.', + 'Melhor canal': 'Best channel', + 'Espectro e canais': 'Spectrum and channels', + + // saúde do espectro (canais, estilo SMART) + 'Boa': 'Good', + 'Atenção': 'Attention', + 'Saturado': 'Saturated', + 'Boa condição': 'Good condition', + 'Sem canal livre nessa banda': 'No free channel on this band', + 'Até o melhor canal está saturado': 'Even the best channel is saturated', + 'Recomendado é DFS — pode cair com radar por perto': 'The recommended channel is DFS — it can drop off if radar is detected nearby', + 'Espectro congestionado': 'Congested spectrum', + 'Canal recomendado': 'Recommended channel', + 'Canais ocupados': 'Busy channels', + 'APs na banda': 'APs on this band', + 'Pior canal': 'Worst channel', + 'Canais DFS': 'DFS channels', + 'Nessa banda, prefira sempre 1, 6 ou 11 — canais no meio sobrepõem vizinhos.': + 'On this band, always prefer 1, 6 or 11 — channels in between overlap neighbors.', + '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.': + 'DFS channels free up more room, but the radio must go off the air if it detects radar — keep a non-DFS channel as backup.', + 'Considere mudar o AP para o canal recomendado ou mover pra outra banda.': + 'Consider moving the AP to the recommended channel or to another band.', + 'Canais detalhados ({n})': 'Detailed channels ({n})', + 'Pontuação': 'Score', + 'APs': 'APs', + '{n} AP(s)': '{n} AP(s)', + 'Recomendado': 'Recommended', + 'Ainda sem eventos além do primeiro avistamento.': + 'No events beyond the first sighting yet.', + '{n} amostra(s) de sinal. Eventos mais recentes primeiro.': + '{n} signal sample(s). Most recent events first.', + 'nenhuma ferramenta instalada': 'no tool installed', + 'Senha encontrada: {n}{n}': 'Password found: {n}{n}', + 'Sem PIN, o reaver usa Pixie Dust; o bully usa o ataque padrão.': + 'Without a PIN, reaver uses Pixie Dust; bully uses its default attack.', + 'Instale reaver ou bully para habilitar. Ex.: sudo apt install reaver bully': + 'Install reaver or bully to enable. E.g.: sudo apt install reaver bully', + 'A saída do reaver/bully aparece aqui.': 'reaver/bully output appears here.', + 'Orquestra reaver/bully na interface monitor. O canal do AP é usado automaticamente; o processo pode ser interrompido a qualquer momento.': + 'Orchestrates reaver/bully on the monitor interface. The AP channel is used automatically; the process can be stopped at any time.', + 'Ferramenta': 'Tool', + 'PIN conhecido (opcional)': 'Known PIN (optional)', + 'Exportar .hc22000': 'Export .hc22000', + 'Todos .hc22000': 'Export all .hc22000', + 'Iniciar WPS': 'Start WPS', + 'Parar job': 'Stop job', + '8 dígitos': '8 digits', + 'nenhum cliente visto': 'no client seen', + 'Direção': 'Direction', + 'AP → cliente': 'AP → client', + 'cliente → AP': 'client → AP', + 'Dispara deauth nas duas direções e observa se a estação reassocia. Resultado prático, não prova criptográfica.': + 'Sends deauth in both directions and watches whether the station reassociates. Practical result, not cryptographic proof.', + 'Estação': 'Station', + 'Forja um MAC local e envia association request com PMKID para tentar a mensagem 1. Muitos APs só expõem PMKID em roaming real.': + 'Forge a local MAC and send an association request with PMKID to try for message 1. Many APs only expose PMKID during real roaming.', + 'Caçar PMKID': 'Hunt PMKID', + 'Do cliente para o AP (exige estação selecionada).': + 'From client to AP (requires a selected station).', + 'Nas duas direções (exige estação selecionada).': + 'In both directions (requires a selected station).', + '{n} quadro(s) {n} em {n} para {n} (motivo {n}).': + '{n} {n} frame(s) in {n} to {n} (reason {n}).', + 'Aguardando saída do reaver/bully…': 'Waiting for reaver/bully output…', + 'Enviando associação com PMKID…': 'Sending association with PMKID…', + '{n} quadro(s) enviados; PMKID não apareceu. {n}': + '{n} frame(s) sent; PMKID did not appear. {n}', + 'Nenhuma estação disponível.': 'No station available.', + 'Testando por ~4 s…': 'Testing for ~4 s…', + 'PMKID capturado: {n}': 'PMKID captured: {n}', + 'PMF não obrigatório': 'PMF not required', + 'PMF não obrigatório · {n}': 'PMF not required · {n}', + 'Inconclusivo · {n}': 'Inconclusive · {n}', + 'Instale reaver ou bully.': 'Install reaver or bully.', + 'Canal do AP desconhecido.': 'Unknown AP channel.', + 'Montando o relatório…': 'Building the report…', + 'não foi possível gerar o relatório': 'could not generate the report', + 'Pré-visualização A4 · {n} página(s).': 'A4 preview · {n} page(s).', + 'Pré-visualização A4.': 'A4 preview.', + 'Paginando o relatório…': 'Paginating the report…', + 'Não foi possível abrir a impressão; baixe o HTML e abra no navegador.': + 'Could not open printing; download the HTML and open it in the browser.', + 'não foi possível gerar o HTML': 'could not generate the HTML', + 'Relatório': 'Report', + 'Relatório de auditoria': 'Audit report', + 'Imprimir / PDF': 'Print / PDF', + 'Baixar HTML': 'Download HTML', + 'Pré-visualização paginada em A4.': 'Paginated A4 preview.', + 'Visualização': 'View', + 'Relatório paginado': 'Paginated report', + 'handshake sem SSID, PMKID ou mensagem 2 para exportar': + 'handshake without SSID, PMKID or message 2 to export', + 'nenhum handshake com material para .hc22000': + 'no handshake with material for .hc22000', + 'direção inválida: use ap, sta ou both': 'invalid direction: use ap, sta or both', + 'máscara grande demais pra calcular o total': 'mask too large to calculate the total', + 'máscara tem {n} combinações, acima do limite de {n} — restrinja a máscara (ex.: menos posições ?a/?l) antes de rodar': + 'mask has {n} combinations, above the limit of {n} — narrow the mask (e.g. fewer ?a/?l positions) before running', + 'quadro inválido: use deauth ou disassoc': 'invalid frame: use deauth or disassoc', + 'direção sta/both exige a estação alvo': 'sta/both direction requires the target station', + 'inicie a captura nesta interface antes do teste PMKID': + 'start capturing on this interface before the PMKID test', + 'BSSID ainda não visto nesta captura': 'BSSID not seen in this capture yet', + 'SSID oculto: impossível montar o association request': + 'hidden SSID: cannot build the association request', + 'PMKID observado; exporte em .hc22000 e quebre com hashcat ou pelo Sabiá.': + 'PMKID observed; export to .hc22000 and crack with hashcat or Sabiá.', + 'Nenhum PMKID apareceu. O AP pode não usar cache de PMKID ou só responder a roaming real; tente também desautenticar um cliente associado.': + 'No PMKID appeared. The AP may not cache PMKID or may only answer real roaming; also try deauthenticating an associated client.', + 'PIN WPS deve ter 8 dígitos': 'WPS PIN must have 8 digits', + 'a ferramenta terminou sem informar PSK': 'the tool finished without reporting a PSK', + 'job WPS não encontrado': 'WPS job not found', + 'Auditoria Wi-Fi · {n}': 'Wi-Fi audit · {n}', + '{n} está em modo managed; ative o modo monitor antes do teste PMKID': + '{n} is in managed mode; enable monitor mode before the PMKID test', + '{n} não encontrado no PATH; instale reaver ou bully': + '{n} not found in PATH; install reaver or bully', + '{n} está em modo managed; ative o modo monitor antes do ataque WPS': + '{n} is in managed mode; enable monitor mode before the WPS attack', + 'não foi possível executar {n}: {n}': 'could not run {n}: {n}', + '{n} terminou com código {n}': '{n} exited with code {n}', + + // achados do WIDS (analysis.rs) + 'SSID “{n}” com seguranças diferentes': 'SSID “{n}” with different security levels', + 'O mesmo nome aparece em BSSIDs com níveis de segurança distintos: {n}. Pode ser um evil twin ou um AP mal configurado.': + 'The same name appears on BSSIDs with different security levels: {n}. It may be an evil twin or a misconfigured AP.', + 'Rajada de desautenticação': 'Deauthentication burst', + '{n} quadros de desautenticação/desassociação no último minuto. Pode ser ataque de desautenticação ou um AP instável.': + '{n} deauthentication/disassociation frames in the last minute. It may be a deauth attack or an unstable AP.', + 'Muitos BSSIDs novos': 'Many new BSSIDs', + '{n} BSSIDs apareceram no último minuto — possível beacon flood ou ambiente muito denso.': + '{n} BSSIDs appeared in the last minute — possible beacon flood or a very dense environment.', + 'Rede aberta: {n}': 'Open network: {n}', + 'Sem cifra no Wi-Fi; o tráfego fica exposto a qualquer estação no alcance.': + 'No Wi-Fi encryption; traffic is exposed to any station in range.', + 'WEP em {n}': 'WEP on {n}', + 'WEP é quebrável em minutos; trate a rede como aberta.': + 'WEP can be cracked in minutes; treat the network as open.', + 'WPA1 (TKIP) em {n}': 'WPA1 (TKIP) on {n}', + 'WPA1/TKIP tem fraquezas conhecidas; migre para WPA2/CCMP ou WPA3.': + 'WPA1/TKIP has known weaknesses; migrate to WPA2/CCMP or WPA3.', + 'WPS ativo em {n}': 'WPS active on {n}', + 'O AP anuncia WPS com configuração travada.': 'The AP advertises WPS with configuration locked.', + 'WPS sem lock explícito; PIN pode ser testado com reaver/bully.': + 'WPS without an explicit lock; the PIN can be tested with reaver/bully.', + 'Sem PMF em {n}': 'No PMF on {n}', + 'Desautenticação e desassociação não são protegidas; clientes podem ser expulsos para facilitar a captura de handshake.': + 'Deauthentication and disassociation are unprotected; clients can be expelled to make handshake capture easier.', + 'PMF inconsistente em {n}': 'Inconsistent PMF on {n}', + 'O AP anuncia WPA3, mas não anuncia PMF; verifique a configuração.': + 'The AP advertises WPA3 but not PMF; check the configuration.', + 'WPA3 com WPS em {n}': 'WPA3 with WPS on {n}', + 'WPS não faz parte do WPA3 e abre uma via mais fraca de associação.': + 'WPS is not part of WPA3 and opens a weaker association path.', + 'primeiro visto': 'first seen', + 'associou': 'associated', + 'desautenticado': 'deauthenticated', + + 'A estação voltou a se associar.': 'The station reassociated.', + 'O AP pode não usar cache de PMKID.': 'The AP may not cache PMKID.', + + 'A estação voltou a se associar após a desautenticação: PMF não está obrigatório (ou o cliente não o usa).': + 'The station reassociated after deauthentication: PMF is not required (or the client does not use it).', + 'A estação continuou trocando quadros depois da desautenticação; o quadro de gerenciamento provavelmente foi aceito.': + 'The station kept exchanging frames after deauthentication; the management frame was probably accepted.', + 'A estação não voltou a associar na janela observada. PMF pode estar ativo, ou o cliente simplesmente ficou inativo.': + 'The station did not reassociate in the observed window. PMF may be active, or the client simply stayed idle.', + 'Inconclusivo': 'Inconclusive', + + // deauth contínuo e identificação de clientes + '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.': + 'Keeps frames going until you stop (or a handshake appears). The capture channel stays locked to the AP during the job.', + 'Iniciar contínuo': 'Start continuous', + 'Parar': 'Stop', + 'Rajada ({n})': 'Burst ({n})', + 'Pronto. O job contínuo trava a captura no canal do AP e para quando você mandar.': + 'Ready. The continuous job locks capture to the AP channel and stops when you say so.', + 'Job {n} · {n} quadro(s){n}{n}': 'Job {n} · {n} frame(s){n}{n}', + 'Handshake capturado em {n} quadro(s){n}.': 'Handshake captured in {n} frame(s){n}.', + 'Job {n} {n} · {n} quadro(s){n}.': 'Job {n} {n} · {n} frame(s){n}.', + 'Escolha um cliente para desautenticar nas direções sta/both.': + 'Choose a client to deauthenticate in the sta/both directions.', + 'Forçando reassociação e handshake…': 'Forcing reassociation and handshake…', + 'Desautenticação contínua iniciada.': 'Continuous deauthentication started.', + 'Nenhuma ferramenta WPS disponível.': 'No WPS tool available.', + 'O reaver embutido usa Pixie Dust sem PIN. O bully aparece se estiver instalado no sistema.': + 'The bundled reaver uses Pixie Dust without a PIN. bully appears if it is installed on the system.', + 'O reaver embutido não pôde ser extraído nesta execução.': + 'The bundled reaver could not be extracted in this run.', + 'Dicionário (vazio = embutido, 9,7 milhões de senhas)': 'Wordlist (empty = bundled, 9.7 million passwords)', + 'dicionário próprio, opcional': 'custom wordlist, optional', + 'Dispositivo': 'Device', + 'MAC aleatório': 'Random MAC', + 'Cliente {n}': 'Client {n}', + 'Linha do tempo · {n}': 'Timeline · {n}', + 'nenhuma ferramenta disponível': 'no tool available', + '· canal {n}': '· channel {n}', + '· para sozinho no handshake': '· stops on handshake', + 'job de deauth não encontrado': 'deauth job not found', + 'já existe um job de desautenticação em execução em {n}': + 'a deauthentication job is already running on {n}', + 'inicie a captura em {n} antes de iniciar o job': + 'start capture on {n} before starting the job', + 'a captura em {n} não está ativa': 'capture on {n} is not active', + '{n} não está disponível; o reaver integrado não pôde ser extraído': + '{n} is not available; the bundled reaver could not be extracted', }; diff --git a/apps/gui/ui/index.html b/apps/gui/ui/index.html index 5f5e801..d832af9 100644 --- a/apps/gui/ui/index.html +++ b/apps/gui/ui/index.html @@ -18,17 +18,39 @@ -
    Interfaces
    - +
    Interface
    +
    + + — +
    + +
    Visualização
    +
    - — - - +
    + +
    + + + +
    +
    +
    + — + +
    @@ -39,7 +61,9 @@

    Painel de captura

    selecione uma interface
    - — +
    + +
    @@ -68,14 +92,79 @@
    - Redes vistas - 0 +
    + Redes vistas + 0 +
    +
    + + +
    + + +
    +
    +

    —

    +
    +
    + +
    +
    + + + +
    +
    +
    + + +
    +
    +

    Relatório de auditoria

    +

    Pré-visualização paginada em A4.

    +
    +
    + + 100% + + + + +
    +
    +
    +
    + + +
    +

    Configurações

    +
    + Idioma + +
    + +
    +
    + diff --git a/scripts/check-ui-i18n.mjs b/scripts/check-ui-i18n.mjs index 5ace049..ebefb92 100644 --- a/scripts/check-ui-i18n.mjs +++ b/scripts/check-ui-i18n.mjs @@ -68,7 +68,11 @@ const WORDS = [ 'atualização', 'atualizado', 'trocar', 'possível', 'quebrável', 'quebrado', 'rompida', 'corrompida', 'inválido', 'inválida', 'entrada', 'dicionário', 'quadros', 'quantidade', 'injetar', 'encontrado', 'encontrada', 'suportada', 'permissão', 'insuficiente', 'instale', - 'root', 'indisponível', 'plataforma', 'criada', 'ainda', + 'root', 'indisponível', 'plataforma', 'criada', 'ainda', 'ferramenta', 'ferramentas', + 'exportar', 'importar', 'imprimir', 'baixar', 'recomendado', 'recomendada', 'aberta', + 'aberto', 'inconclusivo', 'inconclusiva', 'capturado', 'capturada', 'conhecido', + 'conhecida', 'opcional', 'opcionais', 'fechar', 'motivo', 'linha', 'tempo', 'quadro', + 'tentativas', 'ambas', ]; const BRAND = /Sabiá|Português|English/gi; @@ -246,7 +250,7 @@ function rustLabels(source, file) { // ---------------------------------------------------------------- coleta const uiFiles = readdirSync(join(root, 'apps/gui/ui')) - .filter((f) => f.endsWith('.js') && f !== 'i18n-en.js') + .filter((f) => f.endsWith('.js') && f !== 'i18n-en.js' && f !== 'paged.polyfill.min.js') .map((f) => join(root, 'apps/gui/ui', f)); uiFiles.push(join(root, 'apps/gui/ui/index.html'));