App desktop (Tauri), GitHub Actions de CI/release, elevação automática
- Move a UI de crates/sabia-cli/src/web/ para apps/gui/ui/: fonte única servida tanto pelo `sabia ui` quanto pelo app desktop. - sabia-core::engine::Engine centraliza sessões de scan/monitor/update — usado pelo servidor local da CLI e pelo app Tauri, sem duplicar lógica. - apps/gui/src-tauri: app desktop Tauri 2, mesma interface, comandos IPC espelhando a API HTTP. Pede elevação de administrador/root automaticamente ao abrir (modo monitor e captura crua exigem): manifesto requireAdministrator no Windows (UAC antes mesmo do app abrir), pkexec no Linux e osascript no macOS, com reinício automático e aviso + botão de nova tentativa na interface se for negada. - Ícone gerado a partir do logo (apps/gui/src-tauri/icons/source.png). - .github/workflows/ci.yml: fmt, clippy, testes (Linux/Windows/macOS), auditoria de dependências, compilação do app desktop. - .github/workflows/release.yml: ao empurrar uma tag vX.Y.Z, builda a CLI (Linux x86_64/aarch64, macOS x86_64/aarch64, Windows x86_64) e o app desktop pras mesmas plataformas, publica tudo numa release do GitHub com checksums SHA-256. - sabia-core::update: corrige o nome do asset esperado (casava pelo nome fixo, mas o release empacota com a versão no nome) e a extração (tar.gz no Linux/macOS, zip no Windows). - LICENSE-MIT, LICENSE-APACHE, CHANGELOG.md com a entrada 0.1.0. - cargo fmt + clippy (-D warnings) limpos em todo o workspace.
This commit is contained in:
1 parent
fd3ef5240e
commit
df27974359
29 files changed
+1251
-174
No files matched your search
@@ -0,0 +1,71 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: -D warnings
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Formatação e clippy
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo fmt --all -- --check
|
||||
- run: cargo clippy --workspace --all-targets
|
||||
|
||||
test:
|
||||
name: Testes (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-24.04, windows-latest, macos-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo test --workspace
|
||||
|
||||
audit:
|
||||
name: Auditoria de dependências
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: rustsec/audit-check@v2
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
gui:
|
||||
name: Compila o app desktop (Linux)
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: apps/gui/src-tauri
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Dependências do Tauri
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||
- name: Gera os ícones
|
||||
run: npx --yes @tauri-apps/cli@2 icon apps/gui/src-tauri/icons/source.png -o apps/gui/src-tauri/icons
|
||||
- run: cargo check --manifest-path apps/gui/src-tauri/Cargo.toml
|
||||
env:
|
||||
RUSTFLAGS: ""
|
||||
@@ -0,0 +1,146 @@
|
||||
name: Release
|
||||
|
||||
# Publica uma release pública ao empurrar uma tag vX.Y.Z (ex.: git tag v0.1.0 && git push --tags)
|
||||
on:
|
||||
push:
|
||||
tags: ["v*.*.*"]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
name: Cria a release
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
release_id: ${{ steps.rel.outputs.result }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Notas da versão (do CHANGELOG)
|
||||
id: notes
|
||||
run: |
|
||||
v="${GITHUB_REF_NAME#v}"
|
||||
v="${v%%-*}"
|
||||
awk -v v="$v" '$0 ~ "^## \\[" v "\\]" {f=1; next} /^## \[/ {f=0} f' CHANGELOG.md > notes.md
|
||||
[ -s notes.md ] || echo "Veja o CHANGELOG." > notes.md
|
||||
- uses: actions/github-script@v9
|
||||
id: rel
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const tag = context.ref.replace('refs/tags/', '');
|
||||
const r = await github.rest.repos.createRelease({
|
||||
owner: context.repo.owner, repo: context.repo.repo, tag_name: tag,
|
||||
name: `Sabiá ${tag}`, body: fs.readFileSync('notes.md', 'utf8'),
|
||||
draft: true, prerelease: tag.includes('-'),
|
||||
});
|
||||
return r.data.id;
|
||||
|
||||
cli:
|
||||
name: CLI (${{ matrix.name }})
|
||||
needs: create-release
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { name: linux-x86_64, os: ubuntu-24.04, target: x86_64-unknown-linux-gnu }
|
||||
- { name: linux-aarch64, os: ubuntu-24.04-arm, target: aarch64-unknown-linux-gnu }
|
||||
- { name: macos-aarch64, os: macos-latest, target: aarch64-apple-darwin }
|
||||
- { name: macos-x86_64, os: macos-latest, target: x86_64-apple-darwin }
|
||||
- { name: windows-x86_64, os: windows-latest, target: x86_64-pc-windows-msvc }
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo build --release --locked -p sabia-cli --target ${{ matrix.target }}
|
||||
- name: Empacota
|
||||
shell: bash
|
||||
run: |
|
||||
name="sabia-${GITHUB_REF_NAME}-${{ matrix.name }}"
|
||||
mkdir "$name"
|
||||
if [ "$RUNNER_OS" = "Windows" ]; then
|
||||
cp target/${{ matrix.target }}/release/sabia.exe "$name/"
|
||||
else
|
||||
cp target/${{ matrix.target }}/release/sabia "$name/"
|
||||
fi
|
||||
cp README.md LICENSE-MIT LICENSE-APACHE "$name/"
|
||||
if [ "$RUNNER_OS" = "Windows" ]; then
|
||||
7z a "$name.zip" "$name" >/dev/null; asset="$name.zip"
|
||||
else
|
||||
tar czf "$name.tar.gz" "$name"; asset="$name.tar.gz"
|
||||
fi
|
||||
if command -v sha256sum >/dev/null; then sha256sum "$asset" > "$asset.sha256"; else shasum -a 256 "$asset" > "$asset.sha256"; fi
|
||||
echo "ASSET=$asset" >> "$GITHUB_ENV"
|
||||
- name: Envia para a release
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh release upload "$GITHUB_REF_NAME" "$ASSET" "$ASSET.sha256" --clobber
|
||||
|
||||
gui:
|
||||
name: App desktop (${{ matrix.name }})
|
||||
needs: create-release
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { name: linux-x86_64, os: ubuntu-24.04, args: "" }
|
||||
- { name: macos-aarch64, os: macos-latest, args: "--target aarch64-apple-darwin" }
|
||||
- { name: macos-x86_64, os: macos-latest, args: "--target x86_64-apple-darwin" }
|
||||
- { name: windows-x86_64, os: windows-latest, args: "" }
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ contains(matrix.args, 'apple') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: apps/gui/src-tauri
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Dependências do Tauri (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||
- name: Gera os ícones
|
||||
run: npx --yes @tauri-apps/cli@2 icon apps/gui/src-tauri/icons/source.png -o apps/gui/src-tauri/icons
|
||||
- uses: tauri-apps/tauri-action@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
projectPath: apps/gui/src-tauri
|
||||
releaseId: ${{ needs.create-release.outputs.release_id }}
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
publish:
|
||||
name: Publica a release
|
||||
needs: [create-release, cli, gui]
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Notas da versão (do CHANGELOG)
|
||||
run: |
|
||||
v="${GITHUB_REF_NAME#v}"
|
||||
v="${v%%-*}"
|
||||
awk -v v="$v" '$0 ~ "^## \\[" v "\\]" {f=1; next} /^## \[/ {f=0} f' CHANGELOG.md > notes.md
|
||||
[ -s notes.md ] || echo "Veja o CHANGELOG." > notes.md
|
||||
- uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
await github.rest.repos.updateRelease({
|
||||
owner: context.repo.owner, repo: context.repo.repo,
|
||||
release_id: ${{ needs.create-release.outputs.release_id }},
|
||||
name: `Sabiá ${context.ref.replace('refs/tags/', '')}`,
|
||||
body: fs.readFileSync('notes.md', 'utf8'),
|
||||
draft: false,
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
# Changelog
|
||||
|
||||
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow SemVer.
|
||||
|
||||
## [0.1.0] - 2026-10-23
|
||||
|
||||
Primeira versão: CLI, app desktop e núcleo compartilhado.
|
||||
|
||||
### Added
|
||||
- `sabia-core`: descoberta de interfaces Wi-Fi, controle de modo monitor e
|
||||
canal (via `iw`/`ip`), captura crua de quadros 802.11 por AF_PACKET (sem
|
||||
libpcap), parsing de radiotap e de quadros de gerenciamento (beacon,
|
||||
probe, EAPOL), agregação de APs e clientes vistos, escrita de `.pcap`,
|
||||
trilha de auditoria local encadeada por hash, auto-atualização por
|
||||
releases do GitHub.
|
||||
- `sabia`: CLI com `iface`, `scan`, `update`, `audit-verify` e `ui`
|
||||
(servidor local da interface gráfica).
|
||||
- App desktop (Tauri) com a mesma interface servida pelo `sabia ui`,
|
||||
pedindo elevação de administrador automaticamente ao abrir — modo
|
||||
monitor e captura crua exigem root/admin.
|
||||
- Interface gráfica: sidebar de interfaces, painel de captura com linha
|
||||
de sinal animada, tabela de redes com barras de sinal e badges de
|
||||
segurança por cor.
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Sabiá contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -17,8 +17,8 @@ crates/sabia-core biblioteca: netlink (iw), radiotap, parsing 802.11,
|
||||
captura crua (AF_PACKET), auditoria, auto-atualização
|
||||
crates/sabia-cli binário `sabia`: comandos de terminal + servidor
|
||||
local da interface (`sabia ui`)
|
||||
apps/gui/ui mesma página HTML/JS servida pelo `sabia ui`
|
||||
apps/gui/src-tauri shell desktop (Tauri) — esqueleto
|
||||
apps/gui/ui mesma página HTML/JS servida pelo `sabia ui` e pelo app desktop
|
||||
apps/gui/src-tauri app desktop (Tauri) — pede administrador/root ao abrir
|
||||
```
|
||||
|
||||
## Uso
|
||||
@@ -32,8 +32,12 @@ sabia update # verifica atualização
|
||||
sabia update --apply # baixa e aplica a atualização
|
||||
```
|
||||
|
||||
Capturar quadros 802.11 exige `CAP_NET_RAW`/`CAP_NET_ADMIN` — normalmente
|
||||
root (`sudo sabia ...`).
|
||||
Ou baixe o app desktop em [Releases](https://github.com/lucasrguerra/sabia/releases)
|
||||
— ele pede elevação de administrador/root automaticamente ao abrir, já que
|
||||
modo monitor e captura crua exigem esse privilégio em qualquer sistema.
|
||||
|
||||
Pela CLI, capturar quadros 802.11 exige `CAP_NET_RAW`/`CAP_NET_ADMIN` —
|
||||
normalmente root (`sudo sabia ...`).
|
||||
|
||||
## Dependências externas
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# App desktop (Tauri 2). Fica fora do workspace principal porque exige
|
||||
# as bibliotecas de WebView do sistema (WebKitGTK no Linux).
|
||||
[package]
|
||||
name = "sabia-gui"
|
||||
description = "Sabiá: interface gráfica de auditoria de Wi-Fi"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/lucasrguerra/sabia"
|
||||
rust-version = "1.80"
|
||||
|
||||
[workspace]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
serde_json = "1"
|
||||
sabia-core = { path = "../../../crates/sabia-core" }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
@@ -0,0 +1,13 @@
|
||||
fn main() {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// Modo monitor e captura crua exigem administrador no Windows: o
|
||||
// manifesto pede elevação (UAC) assim que o app abre.
|
||||
let manifest = include_str!("windows-app-manifest.xml");
|
||||
let attrs = tauri_build::Attributes::new()
|
||||
.windows_attributes(tauri_build::WindowsAttributes::new().app_manifest(manifest));
|
||||
tauri_build::try_build(attrs).expect("falha no tauri-build");
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
tauri_build::build();
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
@@ -0,0 +1,190 @@
|
||||
// Evita a janela de console extra no Windows em release.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
use sabia_core::engine::Engine;
|
||||
use std::sync::Arc;
|
||||
|
||||
struct State(Arc<Engine>);
|
||||
|
||||
#[tauri::command]
|
||||
fn version(state: tauri::State<'_, State>) -> String {
|
||||
state.0.version().to_string()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn list_ifaces(state: tauri::State<'_, State>) -> Result<serde_json::Value, String> {
|
||||
let list = state.0.list_ifaces().map_err(|e| e.to_string())?;
|
||||
serde_json::to_value(list).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_monitor(state: tauri::State<'_, State>, iface: String, enable: bool) -> Result<(), String> {
|
||||
state.0.set_monitor(&iface, enable).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn start_scan(state: tauri::State<'_, State>, iface: String) -> Result<(), String> {
|
||||
state.0.start_scan(&iface).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn stop_scan(state: tauri::State<'_, State>, iface: String) {
|
||||
state.0.stop_scan(&iface);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn scan_results(state: tauri::State<'_, State>, iface: String) -> Result<serde_json::Value, String> {
|
||||
serde_json::to_value(state.0.scan_results(&iface)).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn update_check(state: tauri::State<'_, State>) -> Result<serde_json::Value, String> {
|
||||
let status = state.0.update_check().map_err(|e| e.to_string())?;
|
||||
serde_json::to_value(status).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn update_apply(state: tauri::State<'_, State>) -> Result<String, String> {
|
||||
state.0.update_apply().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// `true` quando o processo já tem privilégio suficiente pra tudo que o
|
||||
/// Sabiá faz (modo monitor, captura crua). No Windows o manifesto já
|
||||
/// exige administrador antes mesmo do app abrir, então aqui é sempre `true`.
|
||||
#[tauri::command]
|
||||
fn is_elevated() -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
unsafe { libc::geteuid() == 0 }
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Reabre o app com privilégios de administrador (Linux/macOS). O app novo
|
||||
/// encerra este quando abre; se a autorização falhar ou for cancelada, a
|
||||
/// janela continua aqui e a interface mostra o motivo.
|
||||
#[tauri::command]
|
||||
fn relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
{
|
||||
do_relaunch_elevated(app)
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = app;
|
||||
Err("o Sabiá já roda como administrador no Windows".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
|
||||
use tauri::Emitter;
|
||||
let exe = elevated_exe()?;
|
||||
let log = std::env::temp_dir().join(format!("sabia-elevate-{}.log", std::process::id()));
|
||||
let _ = std::fs::write(&log, b"");
|
||||
let out = std::fs::OpenOptions::new().append(true).open(&log).map_err(|e| e.to_string())?;
|
||||
let err = out.try_clone().map_err(|e| e.to_string())?;
|
||||
let pid = std::process::id().to_string();
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
let mut cmd = {
|
||||
let mut c = std::process::Command::new("pkexec");
|
||||
c.arg("/usr/bin/env");
|
||||
for k in ["DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", "GDK_BACKEND"] {
|
||||
if let Ok(v) = std::env::var(k) {
|
||||
c.arg(format!("{k}={v}"));
|
||||
}
|
||||
}
|
||||
c.arg(format!("SABIA_REPLACE_PID={pid}"));
|
||||
c.arg(&exe);
|
||||
c
|
||||
};
|
||||
#[cfg(target_os = "macos")]
|
||||
let mut cmd = {
|
||||
let shell = format!("SABIA_REPLACE_PID={} '{}' >/dev/null 2>&1 &", pid, exe.display());
|
||||
let script = format!("do shell script \"{}\" with administrator privileges", shell.replace('\\', "\\\\").replace('"', "\\\""));
|
||||
let mut c = std::process::Command::new("osascript");
|
||||
c.args(["-e", &script]);
|
||||
c
|
||||
};
|
||||
cmd.stdout(out).stderr(err);
|
||||
let mut child = cmd.spawn().map_err(|e| format!("não foi possível pedir privilégios: {e}"))?;
|
||||
std::thread::spawn(move || {
|
||||
let status = child.wait();
|
||||
if matches!(&status, Ok(s) if s.success()) {
|
||||
let _ = std::fs::remove_file(&log);
|
||||
return;
|
||||
}
|
||||
let detail = std::fs::read_to_string(&log).unwrap_or_default();
|
||||
let msg = match status.ok().and_then(|s| s.code()) {
|
||||
Some(126) => "Autorização cancelada.".to_string(),
|
||||
Some(127) => "Não foi possível autenticar como administrador.".to_string(),
|
||||
_ => format!("Não foi possível reiniciar como administrador. {}", detail.lines().last().unwrap_or("").trim()),
|
||||
};
|
||||
let _ = app.emit("elevate-failed", msg);
|
||||
let _ = std::fs::remove_file(&log);
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Caminho que o root consegue executar. No AppImage, `current_exe` aponta
|
||||
/// pra dentro de um ponto de montagem FUSE do usuário, inacessível ao root:
|
||||
/// usa o próprio arquivo `.AppImage` (variável `APPIMAGE`).
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
fn elevated_exe() -> Result<std::path::PathBuf, String> {
|
||||
if let Some(p) = std::env::var_os("APPIMAGE").map(std::path::PathBuf::from).filter(|p| p.is_file()) {
|
||||
return Ok(p);
|
||||
}
|
||||
std::env::current_exe().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let engine = Arc::new(Engine::new());
|
||||
tauri::Builder::default()
|
||||
.manage(State(engine))
|
||||
.setup(|app| {
|
||||
// Quando abrimos elevados a pedido do app normal, ele fecha o app antigo.
|
||||
#[cfg(unix)]
|
||||
if let Some(pid) = std::env::var("SABIA_REPLACE_PID").ok().and_then(|p| p.parse::<i32>().ok()) {
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(1200));
|
||||
// SAFETY: só envia SIGTERM ao processo anterior do próprio usuário.
|
||||
unsafe {
|
||||
if libc::geteuid() == 0 {
|
||||
libc::kill(pid, libc::SIGTERM);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Captura crua e modo monitor exigem root: pede elevação assim
|
||||
// que o app abre, sem esperar o usuário procurar um botão. Se
|
||||
// a autorização falhar, a janela continua aberta e a interface
|
||||
// mostra o aviso (com um botão pra tentar de novo).
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
if unsafe { libc::geteuid() != 0 } && std::env::var("SABIA_REPLACE_PID").is_err() {
|
||||
let handle = app.handle().clone();
|
||||
std::thread::spawn(move || {
|
||||
let _ = do_relaunch_elevated(handle);
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
version,
|
||||
list_ifaces,
|
||||
set_monitor,
|
||||
start_scan,
|
||||
stop_scan,
|
||||
scan_results,
|
||||
update_check,
|
||||
update_apply,
|
||||
is_elevated,
|
||||
relaunch_elevated,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("erro ao iniciar o Sabiá");
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Sabiá",
|
||||
"version": "0.1.0",
|
||||
"identifier": "dev.sabia.app",
|
||||
"build": {
|
||||
"frontendDist": "../ui"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"title": "Sabiá",
|
||||
"width": 1180,
|
||||
"height": 780,
|
||||
"minWidth": 420,
|
||||
"minHeight": 560,
|
||||
"resizable": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self'; frame-src 'none'; object-src 'none'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"category": "Utility",
|
||||
"shortDescription": "Auditoria de Wi-Fi",
|
||||
"longDescription": "Sabiá audita, monitora e testa a segurança de redes Wi-Fi — modo monitor, captura de quadros e handshakes, sempre com trilha de auditoria.",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*" />
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<security>
|
||||
<requestedPrivileges>
|
||||
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
</assembly>
|
||||
File renamed without changes.
@@ -1,5 +1,6 @@
|
||||
const params = new URLSearchParams(location.search);
|
||||
const token = params.get('token');
|
||||
const tauri = window.__TAURI__;
|
||||
|
||||
const els = {
|
||||
list: document.getElementById('ifaceList'),
|
||||
@@ -24,12 +25,46 @@ const ICON_WIFI = `<svg width="17" height="17" viewBox="0 0 24 24" fill="none" s
|
||||
const ICON_EMPTY = `<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1"/></svg>`;
|
||||
|
||||
async function api(path, opts) {
|
||||
if (tauri) return tauriApi(path);
|
||||
const url = new URL(path, location.origin);
|
||||
url.searchParams.set('token', token);
|
||||
const r = await fetch(url, opts);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
// No app desktop não existe servidor HTTP: as mesmas rotas viram chamadas
|
||||
// IPC diretas pro núcleo, pelos comandos registrados em src-tauri/src/main.rs.
|
||||
async function tauriApi(path) {
|
||||
const [p, qs] = path.split('?');
|
||||
const q = new URLSearchParams(qs || '');
|
||||
const invoke = tauri.core.invoke;
|
||||
try {
|
||||
switch (p) {
|
||||
case '/api/version':
|
||||
return { version: await invoke('version') };
|
||||
case '/api/ifaces':
|
||||
return await invoke('list_ifaces');
|
||||
case '/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') });
|
||||
return { ok: true };
|
||||
case '/api/scan/stop':
|
||||
await invoke('stop_scan', { iface: q.get('iface') });
|
||||
return { ok: true };
|
||||
case '/api/scan':
|
||||
return await invoke('scan_results', { iface: q.get('iface') });
|
||||
case '/api/update/check':
|
||||
return await invoke('update_check');
|
||||
default:
|
||||
return { error: 'rota desconhecida' };
|
||||
}
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
function renderEmptyResult(message, hint) {
|
||||
els.resultArea.innerHTML = `<div class="ap-card empty-state">${ICON_EMPTY}<h2>${message}</h2><p>${hint}</p></div>`;
|
||||
}
|
||||
@@ -185,7 +220,35 @@ async function poll() {
|
||||
setTimeout(poll, 1000);
|
||||
}
|
||||
|
||||
async function checkElevation() {
|
||||
if (!tauri) return; // via `sabia ui` a elevação é responsabilidade do terminal (sudo)
|
||||
const notice = document.getElementById('elevateNotice');
|
||||
const msg = document.getElementById('elevateMsg');
|
||||
const retry = document.getElementById('btnElevateRetry');
|
||||
const elevated = await tauri.core.invoke('is_elevated').catch(() => true);
|
||||
if (elevated) return;
|
||||
|
||||
notice.hidden = false;
|
||||
msg.textContent = 'Pedindo privilégio de administrador — necessário pra modo monitor e captura…';
|
||||
retry.hidden = true;
|
||||
|
||||
retry.addEventListener('click', async () => {
|
||||
msg.textContent = 'Pedindo privilégio de administrador — necessário pra modo monitor e captura…';
|
||||
retry.hidden = true;
|
||||
await tauri.core.invoke('relaunch_elevated').catch((e) => {
|
||||
msg.textContent = String(e);
|
||||
retry.hidden = false;
|
||||
});
|
||||
});
|
||||
|
||||
tauri.event?.listen?.('elevate-failed', (e) => {
|
||||
msg.textContent = String(e.payload || 'Não foi possível obter privilégio de administrador.');
|
||||
retry.hidden = false;
|
||||
});
|
||||
}
|
||||
|
||||
(async function init() {
|
||||
checkElevation();
|
||||
const status = await api('/api/version');
|
||||
els.verInfo.textContent = `v${status.version}`;
|
||||
await loadIfaces();
|
||||
@@ -42,6 +42,12 @@
|
||||
<path class="live" id="waveLive" d="M0 17 L600 17"/>
|
||||
</svg>
|
||||
|
||||
<div class="notice" id="elevateNotice" hidden>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
||||
<span id="elevateMsg">Pedindo privilégio de administrador — necessário pra modo monitor e captura…</span>
|
||||
<button id="btnElevateRetry" class="btn" style="margin-left:auto" hidden>tentar de novo</button>
|
||||
</div>
|
||||
|
||||
<div class="notice">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
||||
<span>Use somente em redes próprias ou com autorização explícita do responsável. Toda ativação de modo monitor e sessão de captura fica registrada na trilha de auditoria local.</span>
|
||||
File renamed without changes.
@@ -1,14 +1,20 @@
|
||||
mod web;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use sabia_core::{audit::AuditLog, capture::RawCapture, iface, netlink, pcapwriter::PcapWriter, scan::ScanTable};
|
||||
use sabia_core::{
|
||||
audit::AuditLog, capture::RawCapture, iface, netlink, pcapwriter::PcapWriter, scan::ScanTable,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "sabia", about = "Sabiá — auditoria e monitoramento de redes Wi-Fi", version)]
|
||||
#[command(
|
||||
name = "sabia",
|
||||
about = "Sabiá — auditoria e monitoramento de redes Wi-Fi",
|
||||
version
|
||||
)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
cmd: Cmd,
|
||||
@@ -52,7 +58,11 @@ enum Cmd {
|
||||
enum IfaceAction {
|
||||
List,
|
||||
/// Liga/desliga modo monitor numa interface Wi-Fi.
|
||||
Monitor { iface: String, #[arg(value_enum)] state: OnOff },
|
||||
Monitor {
|
||||
iface: String,
|
||||
#[arg(value_enum)]
|
||||
state: OnOff,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, clap::ValueEnum)]
|
||||
@@ -94,23 +104,31 @@ fn run(cli: Cli) -> sabia_core::Result<()> {
|
||||
iface::get(&name)?; // valida que é Wi-Fi antes de tocar nela
|
||||
netlink::set_monitor_mode(&name, enable)?;
|
||||
let mut log = AuditLog::open(audit_path())?;
|
||||
log.record(
|
||||
"monitor_mode",
|
||||
&name,
|
||||
&format!("{{\"enable\": {enable}}}"),
|
||||
)?;
|
||||
println!("{name}: modo monitor {}", if enable { "ativado" } else { "desativado" });
|
||||
log.record("monitor_mode", &name, &format!("{{\"enable\": {enable}}}"))?;
|
||||
println!(
|
||||
"{name}: modo monitor {}",
|
||||
if enable { "ativado" } else { "desativado" }
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
Cmd::Scan { iface: name, channel, seconds, out } => {
|
||||
Cmd::Scan {
|
||||
iface: name,
|
||||
channel,
|
||||
seconds,
|
||||
out,
|
||||
} => {
|
||||
let info = iface::get(&name)?;
|
||||
if info.mode != iface::Mode::Monitor {
|
||||
eprintln!("aviso: {name} não está em modo monitor (sabia iface monitor {name} on)");
|
||||
}
|
||||
|
||||
let mut log = AuditLog::open(audit_path())?;
|
||||
log.record("scan_start", &name, &format!("{{\"channel\": {channel:?}}}"))?;
|
||||
log.record(
|
||||
"scan_start",
|
||||
&name,
|
||||
&format!("{{\"channel\": {channel:?}}}"),
|
||||
)?;
|
||||
|
||||
let cap = RawCapture::open(&name)?;
|
||||
cap.set_read_timeout(Duration::from_millis(500))?;
|
||||
@@ -143,7 +161,10 @@ fn run(cli: Cli) -> sabia_core::Result<()> {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if channel.is_none() && !channels.is_empty() && last_hop.elapsed() > Duration::from_millis(300) {
|
||||
if channel.is_none()
|
||||
&& !channels.is_empty()
|
||||
&& last_hop.elapsed() > Duration::from_millis(300)
|
||||
{
|
||||
chan_idx = (chan_idx + 1) % channels.len();
|
||||
let _ = netlink::set_channel(&name, channels[chan_idx]);
|
||||
last_hop = std::time::Instant::now();
|
||||
@@ -167,16 +188,25 @@ fn run(cli: Cli) -> sabia_core::Result<()> {
|
||||
w.flush()?;
|
||||
}
|
||||
|
||||
log.record("scan_stop", &name, &format!("{{\"aps_found\": {}}}", table.access_points().len()))?;
|
||||
log.record(
|
||||
"scan_stop",
|
||||
&name,
|
||||
&format!("{{\"aps_found\": {}}}", table.access_points().len()),
|
||||
)?;
|
||||
|
||||
println!("\n{:<18} {:<24} {:<8} {:<8} {:<6} {}", "BSSID", "SSID", "CANAL", "SINAL", "SEG", "CLIENTES");
|
||||
println!(
|
||||
"\n{:<18} {:<24} {:<8} {:<8} {:<6} CLIENTES",
|
||||
"BSSID", "SSID", "CANAL", "SINAL", "SEG"
|
||||
);
|
||||
for ap in table.access_points() {
|
||||
println!(
|
||||
"{:<18} {:<24} {:<8} {:<8} {:<6} {}",
|
||||
ap.bssid,
|
||||
ap.ssid.unwrap_or_default(),
|
||||
ap.channel.map(|c| c.to_string()).unwrap_or_default(),
|
||||
ap.last_signal_dbm.map(|s| format!("{s}dBm")).unwrap_or_default(),
|
||||
ap.last_signal_dbm
|
||||
.map(|s| format!("{s}dBm"))
|
||||
.unwrap_or_default(),
|
||||
ap.security.unwrap_or_default(),
|
||||
ap.clients.len()
|
||||
);
|
||||
|
||||
+95
-111
@@ -1,36 +1,24 @@
|
||||
//! Servidor local da interface: a mesma filosofia do Guará (`guara ui`) —
|
||||
//! uma única página HTML/JS servida em `127.0.0.1`, protegida por um token
|
||||
//! gerado na hora, falando com o núcleo por uma API JSON simples. A mesma
|
||||
//! página é o que o shell desktop (Tauri, em `apps/gui`) vai carregar depois.
|
||||
//! Servidor local da interface: uma única página HTML/JS servida em
|
||||
//! `127.0.0.1`, protegida por um token gerado na hora, falando com o
|
||||
//! núcleo por uma API JSON simples. A mesma página em `apps/gui/ui` é o
|
||||
//! que o app desktop (Tauri) carrega — só muda o transporte.
|
||||
|
||||
use sabia_core::{audit::AuditLog, capture::RawCapture, iface, netlink, scan::ScanTable};
|
||||
use sabia_core::engine::Engine;
|
||||
use std::collections::HashMap;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
const INDEX_HTML: &str = include_str!("index.html");
|
||||
const APP_CSS: &str = include_str!("app.css");
|
||||
const APP_JS: &str = include_str!("app.js");
|
||||
const LOGO_SVG: &str = include_str!("sabia-logo.svg");
|
||||
|
||||
struct Session {
|
||||
table: Arc<Mutex<ScanTable>>,
|
||||
running: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct AppState {
|
||||
sessions: Mutex<HashMap<String, Session>>,
|
||||
}
|
||||
const INDEX_HTML: &str = include_str!("../../../../apps/gui/ui/index.html");
|
||||
const APP_CSS: &str = include_str!("../../../../apps/gui/ui/app.css");
|
||||
const APP_JS: &str = include_str!("../../../../apps/gui/ui/app.js");
|
||||
const LOGO_SVG: &str = include_str!("../../../../apps/gui/ui/sabia-logo.svg");
|
||||
|
||||
pub fn serve(port: u16) -> sabia_core::Result<()> {
|
||||
let token = gen_token();
|
||||
let listener = TcpListener::bind(("127.0.0.1", port))?;
|
||||
let state = Arc::new(AppState::default());
|
||||
let engine = Arc::new(Engine::new());
|
||||
|
||||
println!("Sabiá UI em http://127.0.0.1:{port}/?token={token}");
|
||||
let _ = open_browser(&format!("http://127.0.0.1:{port}/?token={token}"));
|
||||
@@ -40,10 +28,10 @@ pub fn serve(port: u16) -> sabia_core::Result<()> {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let state = state.clone();
|
||||
let engine = engine.clone();
|
||||
let token = token.clone();
|
||||
thread::spawn(move || {
|
||||
let _ = handle(stream, &state, &token);
|
||||
let _ = handle(stream, &engine, &token);
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
@@ -51,12 +39,35 @@ pub fn serve(port: u16) -> sabia_core::Result<()> {
|
||||
|
||||
fn gen_token() -> String {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let nanos = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
format!("{nanos:x}")
|
||||
}
|
||||
|
||||
fn open_browser(url: &str) -> std::io::Result<()> {
|
||||
std::process::Command::new("xdg-open").arg(url).spawn().map(|_| ())
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
std::process::Command::new("xdg-open")
|
||||
.arg(url)
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
std::process::Command::new("open")
|
||||
.arg(url)
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
std::process::Command::new("cmd")
|
||||
.args(["/C", "start", "", url])
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
struct Request {
|
||||
@@ -74,7 +85,6 @@ fn parse_request(stream: &mut TcpStream) -> std::io::Result<Request> {
|
||||
let method = parts.next().unwrap_or("GET").to_string();
|
||||
let full_path = parts.next().unwrap_or("/").to_string();
|
||||
|
||||
// consome o resto dos headers
|
||||
loop {
|
||||
let mut l = String::new();
|
||||
if reader.read_line(&mut l)? == 0 || l == "\r\n" {
|
||||
@@ -93,28 +103,54 @@ fn parse_request(stream: &mut TcpStream) -> std::io::Result<Request> {
|
||||
}
|
||||
}
|
||||
let token = query.get("token").cloned();
|
||||
Ok(Request { method, path, query, token })
|
||||
Ok(Request {
|
||||
method,
|
||||
path,
|
||||
query,
|
||||
token,
|
||||
})
|
||||
}
|
||||
|
||||
fn urldecode(s: &str) -> String {
|
||||
s.replace('+', " ").replace("%3A", ":").replace("%2F", "/").replace("%20", " ")
|
||||
s.replace('+', " ")
|
||||
.replace("%3A", ":")
|
||||
.replace("%2F", "/")
|
||||
.replace("%20", " ")
|
||||
}
|
||||
|
||||
fn handle(mut stream: TcpStream, state: &Arc<AppState>, expected_token: &str) -> std::io::Result<()> {
|
||||
fn handle(
|
||||
mut stream: TcpStream,
|
||||
engine: &Arc<Engine>,
|
||||
expected_token: &str,
|
||||
) -> std::io::Result<()> {
|
||||
let req = parse_request(&mut stream)?;
|
||||
|
||||
if req.path == "/" {
|
||||
if req.token.as_deref() != Some(expected_token) {
|
||||
return respond(&mut stream, 401, "text/plain", b"token invalido");
|
||||
}
|
||||
return respond(&mut stream, 200, "text/html; charset=utf-8", INDEX_HTML.as_bytes());
|
||||
return respond(
|
||||
&mut stream,
|
||||
200,
|
||||
"text/html; charset=utf-8",
|
||||
INDEX_HTML.as_bytes(),
|
||||
);
|
||||
}
|
||||
|
||||
// Assets estáticos: não carregam dado nenhum, então não exigem token.
|
||||
match req.path.as_str() {
|
||||
"/app.css" => return respond(&mut stream, 200, "text/css", APP_CSS.as_bytes()),
|
||||
"/app.js" => return respond(&mut stream, 200, "application/javascript", APP_JS.as_bytes()),
|
||||
"/sabia-logo.svg" => return respond(&mut stream, 200, "image/svg+xml", LOGO_SVG.as_bytes()),
|
||||
"/app.js" => {
|
||||
return respond(
|
||||
&mut stream,
|
||||
200,
|
||||
"application/javascript",
|
||||
APP_JS.as_bytes(),
|
||||
)
|
||||
}
|
||||
"/sabia-logo.svg" => {
|
||||
return respond(&mut stream, 200, "image/svg+xml", LOGO_SVG.as_bytes())
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -122,10 +158,15 @@ fn handle(mut stream: TcpStream, state: &Arc<AppState>, expected_token: &str) ->
|
||||
return respond(&mut stream, 404, "text/plain", b"not found");
|
||||
}
|
||||
if req.token.as_deref() != Some(expected_token) {
|
||||
return respond(&mut stream, 401, "application/json", b"{\"error\":\"token invalido\"}");
|
||||
return respond(
|
||||
&mut stream,
|
||||
401,
|
||||
"application/json",
|
||||
b"{\"error\":\"token invalido\"}",
|
||||
);
|
||||
}
|
||||
|
||||
let body = api(state, &req);
|
||||
let body = api(engine, &req);
|
||||
respond(&mut stream, 200, "application/json", body.as_bytes())
|
||||
}
|
||||
|
||||
@@ -141,102 +182,45 @@ fn respond(stream: &mut TcpStream, code: u16, ctype: &str, body: &[u8]) -> std::
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn api(state: &Arc<AppState>, req: &Request) -> String {
|
||||
fn api(engine: &Arc<Engine>, req: &Request) -> String {
|
||||
match (req.method.as_str(), req.path.as_str()) {
|
||||
("GET", "/api/version") => {
|
||||
format!("{{\"version\":{:?}}}", env!("CARGO_PKG_VERSION"))
|
||||
}
|
||||
("GET", "/api/update/check") => {
|
||||
match sabia_core::update::check(env!("CARGO_PKG_VERSION")) {
|
||||
("GET", "/api/version") => format!("{{\"version\":{:?}}}", engine.version()),
|
||||
("GET", "/api/update/check") => match engine.update_check() {
|
||||
Ok(status) => serde_json::to_string(&status).unwrap_or_else(|_| "{}".into()),
|
||||
Err(e) => format!("{{\"error\":{:?}}}", e.to_string()),
|
||||
}
|
||||
}
|
||||
},
|
||||
("GET", "/api/ifaces") => {
|
||||
let list = iface::list_wireless().unwrap_or_default();
|
||||
let list = engine.list_ifaces().unwrap_or_default();
|
||||
serde_json::to_string(&list).unwrap_or_else(|_| "[]".into())
|
||||
}
|
||||
("POST", "/api/monitor") => {
|
||||
let name = req.query.get("iface").cloned().unwrap_or_default();
|
||||
let enable = req.query.get("enable").map(|v| v == "true").unwrap_or(false);
|
||||
match netlink::set_monitor_mode(&name, enable) {
|
||||
Ok(()) => {
|
||||
if let Ok(mut log) = AuditLog::open(".sabia-audit.log") {
|
||||
let _ = log.record("monitor_mode", &name, &format!("{{\"enable\":{enable}}}"));
|
||||
}
|
||||
"{\"ok\":true}".into()
|
||||
}
|
||||
let enable = req
|
||||
.query
|
||||
.get("enable")
|
||||
.map(|v| v == "true")
|
||||
.unwrap_or(false);
|
||||
match engine.set_monitor(&name, enable) {
|
||||
Ok(()) => "{\"ok\":true}".into(),
|
||||
Err(e) => format!("{{\"ok\":false,\"error\":{:?}}}", e.to_string()),
|
||||
}
|
||||
}
|
||||
("POST", "/api/scan/start") => {
|
||||
let name = req.query.get("iface").cloned().unwrap_or_default();
|
||||
start_scan(state, name)
|
||||
match engine.start_scan(&name) {
|
||||
Ok(()) => "{\"ok\":true}".into(),
|
||||
Err(e) => format!("{{\"ok\":false,\"error\":{:?}}}", e.to_string()),
|
||||
}
|
||||
}
|
||||
("POST", "/api/scan/stop") => {
|
||||
let name = req.query.get("iface").cloned().unwrap_or_default();
|
||||
stop_scan(state, &name);
|
||||
engine.stop_scan(&name);
|
||||
"{\"ok\":true}".into()
|
||||
}
|
||||
("GET", "/api/scan") => {
|
||||
let name = req.query.get("iface").cloned().unwrap_or_default();
|
||||
let sessions = state.sessions.lock().unwrap();
|
||||
match sessions.get(&name) {
|
||||
Some(s) => {
|
||||
let table = s.table.lock().unwrap();
|
||||
serde_json::to_string(&table.access_points()).unwrap_or_else(|_| "[]".into())
|
||||
}
|
||||
None => "[]".into(),
|
||||
}
|
||||
serde_json::to_string(&engine.scan_results(&name)).unwrap_or_else(|_| "[]".into())
|
||||
}
|
||||
_ => "{\"error\":\"rota desconhecida\"}".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn start_scan(state: &Arc<AppState>, name: String) -> String {
|
||||
let mut sessions = state.sessions.lock().unwrap();
|
||||
if sessions.contains_key(&name) {
|
||||
return "{\"ok\":true,\"already_running\":true}".into();
|
||||
}
|
||||
let table = Arc::new(Mutex::new(ScanTable::new()));
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
|
||||
let t = table.clone();
|
||||
let r = running.clone();
|
||||
let iface_name = name.clone();
|
||||
thread::spawn(move || {
|
||||
let cap = match RawCapture::open(&iface_name) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("falha ao abrir captura em {iface_name}: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = cap.set_read_timeout(Duration::from_millis(500));
|
||||
let channels = netlink::supported_channels(&iface_name).unwrap_or_default();
|
||||
let mut idx = 0usize;
|
||||
let mut last_hop = std::time::Instant::now();
|
||||
let mut buf = [0u8; 65535];
|
||||
while r.load(Ordering::SeqCst) {
|
||||
if !channels.is_empty() && last_hop.elapsed() > Duration::from_millis(300) {
|
||||
idx = (idx + 1) % channels.len();
|
||||
let _ = netlink::set_channel(&iface_name, channels[idx]);
|
||||
last_hop = std::time::Instant::now();
|
||||
}
|
||||
if let Ok(Some(n)) = cap.recv(&mut buf) {
|
||||
let mut table = t.lock().unwrap();
|
||||
table.ingest(&buf[..n]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
sessions.insert(name, Session { table, running });
|
||||
"{\"ok\":true}".into()
|
||||
}
|
||||
|
||||
fn stop_scan(state: &Arc<AppState>, name: &str) {
|
||||
let mut sessions = state.sessions.lock().unwrap();
|
||||
if let Some(s) = sessions.remove(name) {
|
||||
s.running.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,10 @@ impl AuditLog {
|
||||
hash: hash.clone(),
|
||||
};
|
||||
|
||||
let mut file = OpenOptions::new().create(true).append(true).open(&self.path)?;
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&self.path)?;
|
||||
writeln!(file, "{}", serde_json::to_string(&entry)?)?;
|
||||
|
||||
self.last_hash = hash;
|
||||
|
||||
@@ -38,7 +38,7 @@ impl RawCapture {
|
||||
|
||||
let mut sll: libc::sockaddr_ll = unsafe { mem::zeroed() };
|
||||
sll.sll_family = libc::AF_PACKET as u16;
|
||||
sll.sll_protocol = (ETH_P_ALL as u16).to_be();
|
||||
sll.sll_protocol = ETH_P_ALL.to_be();
|
||||
sll.sll_ifindex = ifindex;
|
||||
|
||||
let ret = unsafe {
|
||||
|
||||
@@ -30,7 +30,10 @@ pub struct ManagementInfo {
|
||||
}
|
||||
|
||||
pub fn mac_to_string(mac: &[u8]) -> String {
|
||||
format!("{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5])
|
||||
format!(
|
||||
"{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
|
||||
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]
|
||||
)
|
||||
}
|
||||
|
||||
/// Quadro 802.11 já sem o cabeçalho radiotap.
|
||||
@@ -66,7 +69,14 @@ pub fn parse(buf: &[u8]) -> Option<Frame<'_>> {
|
||||
|
||||
if buf.len() < 24 {
|
||||
// sem addr1/2/3 completos (comum em alguns quadros de controle)
|
||||
return Some(Frame { kind, bssid: None, src: None, dst: None, body: buf, ie_offset: 0 });
|
||||
return Some(Frame {
|
||||
kind,
|
||||
bssid: None,
|
||||
src: None,
|
||||
dst: None,
|
||||
body: buf,
|
||||
ie_offset: 0,
|
||||
});
|
||||
}
|
||||
|
||||
let addr1: [u8; 6] = buf[4..10].try_into().ok()?;
|
||||
@@ -77,9 +87,20 @@ pub fn parse(buf: &[u8]) -> Option<Frame<'_>> {
|
||||
|
||||
// Para beacon/probe response, corpo fixo de 12 bytes (timestamp +
|
||||
// interval + capability) antecede os information elements.
|
||||
let ie_offset = if matches!(kind, FrameKind::Beacon | FrameKind::ProbeResponse) { 24 + 12 } else { 24 };
|
||||
let ie_offset = if matches!(kind, FrameKind::Beacon | FrameKind::ProbeResponse) {
|
||||
24 + 12
|
||||
} else {
|
||||
24
|
||||
};
|
||||
|
||||
Some(Frame { kind, bssid: Some(bssid), src: Some(src), dst: Some(dst), body: buf, ie_offset })
|
||||
Some(Frame {
|
||||
kind,
|
||||
bssid: Some(bssid),
|
||||
src: Some(src),
|
||||
dst: Some(dst),
|
||||
body: buf,
|
||||
ie_offset,
|
||||
})
|
||||
}
|
||||
|
||||
impl<'a> Frame<'a> {
|
||||
@@ -137,12 +158,10 @@ impl<'a> Frame<'a> {
|
||||
}
|
||||
}
|
||||
48 => rsn_present = true, // RSN IE = WPA2/WPA3
|
||||
221 => {
|
||||
// vendor-specific; OUI 00:50:F2 tipo 1 = WPA1
|
||||
if len >= 4 && val[0..3] == [0x00, 0x50, 0xF2] && val[3] == 1 {
|
||||
221 if len >= 4 && val[0..3] == [0x00, 0x50, 0xF2] && val[3] == 1 => {
|
||||
wpa1_present = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
i = start + len;
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
//! Ponte única entre o núcleo e as duas interfaces (servidor local da CLI e
|
||||
//! o app desktop Tauri): ambos chamam exatamente os mesmos métodos, então
|
||||
//! qualquer coisa que funcione em um funciona idêntico no outro.
|
||||
|
||||
use crate::audit::AuditLog;
|
||||
use crate::error::Result;
|
||||
use crate::iface::{self, Interface};
|
||||
use crate::netlink;
|
||||
use crate::scan::{AccessPoint, ScanTable};
|
||||
use crate::update::{self, UpdateStatus};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
struct Session {
|
||||
table: Arc<Mutex<ScanTable>>,
|
||||
running: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
pub struct Engine {
|
||||
sessions: Mutex<HashMap<String, Session>>,
|
||||
audit_path: PathBuf,
|
||||
}
|
||||
|
||||
impl Default for Engine {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Engine {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sessions: Mutex::new(HashMap::new()),
|
||||
audit_path: PathBuf::from(".sabia-audit.log"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn version(&self) -> &'static str {
|
||||
env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
|
||||
pub fn list_ifaces(&self) -> Result<Vec<Interface>> {
|
||||
iface::list_wireless()
|
||||
}
|
||||
|
||||
pub fn set_monitor(&self, name: &str, enable: bool) -> Result<()> {
|
||||
netlink::set_monitor_mode(name, enable)?;
|
||||
if let Ok(mut log) = AuditLog::open(&self.audit_path) {
|
||||
let _ = log.record("monitor_mode", name, &format!("{{\"enable\":{enable}}}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn start_scan(&self, name: &str) -> Result<()> {
|
||||
use crate::capture::RawCapture;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
let mut sessions = self.sessions.lock().unwrap();
|
||||
if sessions.contains_key(name) {
|
||||
return Ok(());
|
||||
}
|
||||
let table = Arc::new(Mutex::new(ScanTable::new()));
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
|
||||
let t = table.clone();
|
||||
let r = running.clone();
|
||||
let iface_name = name.to_string();
|
||||
thread::spawn(move || {
|
||||
let cap = match RawCapture::open(&iface_name) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("falha ao abrir captura em {iface_name}: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = cap.set_read_timeout(Duration::from_millis(500));
|
||||
let channels = netlink::supported_channels(&iface_name).unwrap_or_default();
|
||||
let mut idx = 0usize;
|
||||
let mut last_hop = std::time::Instant::now();
|
||||
let mut buf = [0u8; 65535];
|
||||
while r.load(Ordering::SeqCst) {
|
||||
if !channels.is_empty() && last_hop.elapsed() > Duration::from_millis(300) {
|
||||
idx = (idx + 1) % channels.len();
|
||||
let _ = netlink::set_channel(&iface_name, channels[idx]);
|
||||
last_hop = std::time::Instant::now();
|
||||
}
|
||||
if let Ok(Some(n)) = cap.recv(&mut buf) {
|
||||
let mut table = t.lock().unwrap();
|
||||
table.ingest(&buf[..n]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if let Ok(mut log) = AuditLog::open(&self.audit_path) {
|
||||
let _ = log.record("scan_start", name, "{}");
|
||||
}
|
||||
sessions.insert(name.to_string(), Session { table, running });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Captura crua (AF_PACKET) só existe no Linux (ver `crate::capture`).
|
||||
/// Em macOS/Windows o app compila e roda, mas a captura ainda não tem
|
||||
/// backend nativo — próximo passo natural do projeto.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn start_scan(&self, _name: &str) -> Result<()> {
|
||||
Err(crate::error::SabiaError::UnsupportedPlatform)
|
||||
}
|
||||
|
||||
pub fn stop_scan(&self, name: &str) {
|
||||
let mut sessions = self.sessions.lock().unwrap();
|
||||
if let Some(s) = sessions.remove(name) {
|
||||
s.running.store(false, Ordering::SeqCst);
|
||||
if let Ok(mut log) = AuditLog::open(&self.audit_path) {
|
||||
let _ = log.record("scan_stop", name, "{}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scan_results(&self, name: &str) -> Vec<AccessPoint> {
|
||||
let sessions = self.sessions.lock().unwrap();
|
||||
match sessions.get(name) {
|
||||
Some(s) => s.table.lock().unwrap().access_points(),
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_check(&self) -> Result<UpdateStatus> {
|
||||
update::check(self.version())
|
||||
}
|
||||
|
||||
pub fn update_apply(&self) -> Result<String> {
|
||||
let exe = std::env::current_exe()?;
|
||||
update::apply(&exe)
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,10 @@ pub fn list_all() -> Result<Vec<Interface>> {
|
||||
|
||||
/// Lista apenas interfaces com um `phy80211` (ou seja, rádios Wi-Fi).
|
||||
pub fn list_wireless() -> Result<Vec<Interface>> {
|
||||
Ok(list_all()?.into_iter().filter(|i| i.phy.is_some()).collect())
|
||||
Ok(list_all()?
|
||||
.into_iter()
|
||||
.filter(|i| i.phy.is_some())
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn get(name: &str) -> Result<Interface> {
|
||||
@@ -94,7 +97,13 @@ fn read_interface(name: &str) -> Result<Interface> {
|
||||
Mode::Unknown
|
||||
};
|
||||
|
||||
Ok(Interface { name: name.to_string(), mac, phy, mode, up })
|
||||
Ok(Interface {
|
||||
name: name.to_string(),
|
||||
mac,
|
||||
phy,
|
||||
mode,
|
||||
up,
|
||||
})
|
||||
}
|
||||
|
||||
/// Lê o modo (`managed`/`monitor`) via `/sys/class/net/<if>/type` + `uevent`
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
pub mod audit;
|
||||
pub mod dot11;
|
||||
pub mod engine;
|
||||
pub mod error;
|
||||
pub mod iface;
|
||||
pub mod netlink;
|
||||
|
||||
@@ -11,7 +11,10 @@ use crate::iface::Mode;
|
||||
use std::process::Command;
|
||||
|
||||
fn run(cmd: &str, args: &[&str]) -> Result<String> {
|
||||
let out = Command::new(cmd).args(args).output().map_err(|e| SabiaError::CommandFailed {
|
||||
let out = Command::new(cmd)
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|e| SabiaError::CommandFailed {
|
||||
cmd: format!("{cmd} {}", args.join(" ")),
|
||||
detail: e.to_string(),
|
||||
})?;
|
||||
@@ -90,6 +93,9 @@ pub fn set_monitor_mode(iface: &str, enable: bool) -> Result<()> {
|
||||
|
||||
/// Troca o canal atual da interface (precisa já estar em modo monitor).
|
||||
pub fn set_channel(iface: &str, channel: u32) -> Result<()> {
|
||||
run("iw", &["dev", iface, "set", "channel", &channel.to_string()])?;
|
||||
run(
|
||||
"iw",
|
||||
&["dev", iface, "set", "channel", &channel.to_string()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -31,7 +31,9 @@ impl PcapWriter {
|
||||
|
||||
/// Grava um quadro crú (radiotap + 802.11), com timestamp de agora.
|
||||
pub fn write_frame(&mut self, raw: &[u8]) -> Result<()> {
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default();
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default();
|
||||
self.out.write_all(&(now.as_secs() as u32).to_le_bytes())?;
|
||||
self.out.write_all(&(now.subsec_micros()).to_le_bytes())?;
|
||||
self.out.write_all(&(raw.len() as u32).to_le_bytes())?; // caplen
|
||||
|
||||
@@ -33,7 +33,12 @@ pub fn parse(buf: &[u8]) -> Option<RadiotapInfo> {
|
||||
if offset + 4 > buf.len() {
|
||||
return None;
|
||||
}
|
||||
let word = u32::from_le_bytes([buf[offset], buf[offset + 1], buf[offset + 2], buf[offset + 3]]);
|
||||
let word = u32::from_le_bytes([
|
||||
buf[offset],
|
||||
buf[offset + 1],
|
||||
buf[offset + 2],
|
||||
buf[offset + 3],
|
||||
]);
|
||||
present_words.push(word);
|
||||
offset += 4;
|
||||
if word & (1 << 31) == 0 {
|
||||
@@ -42,7 +47,10 @@ pub fn parse(buf: &[u8]) -> Option<RadiotapInfo> {
|
||||
}
|
||||
let present = present_words[0]; // só o primeiro conjunto de campos padrão nos interessa
|
||||
|
||||
let mut info = RadiotapInfo { header_len, ..Default::default() };
|
||||
let mut info = RadiotapInfo {
|
||||
header_len,
|
||||
..Default::default()
|
||||
};
|
||||
let mut cursor = offset;
|
||||
|
||||
// TSFT (u64, align 8) viria antes de Flags; para o nosso subconjunto de
|
||||
@@ -63,11 +71,9 @@ pub fn parse(buf: &[u8]) -> Option<RadiotapInfo> {
|
||||
cursor += 8; // TSFT
|
||||
}
|
||||
if present & BIT_FLAGS != 0 {
|
||||
align!(1);
|
||||
cursor += 1;
|
||||
}
|
||||
if present & BIT_RATE != 0 {
|
||||
align!(1);
|
||||
cursor += 1;
|
||||
}
|
||||
if present & BIT_CHANNEL != 0 {
|
||||
@@ -83,12 +89,9 @@ pub fn parse(buf: &[u8]) -> Option<RadiotapInfo> {
|
||||
align!(2);
|
||||
cursor += 2;
|
||||
}
|
||||
if present & BIT_ANTSIGNAL != 0 {
|
||||
align!(1);
|
||||
if cursor < buf.len() {
|
||||
if present & BIT_ANTSIGNAL != 0 && cursor < buf.len() {
|
||||
info.signal_dbm = Some(buf[cursor] as i8);
|
||||
}
|
||||
}
|
||||
|
||||
Some(info)
|
||||
}
|
||||
|
||||
@@ -47,15 +47,22 @@ impl ScanTable {
|
||||
None => return false,
|
||||
};
|
||||
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
match frame.kind {
|
||||
FrameKind::Beacon | FrameKind::ProbeResponse => {
|
||||
if let Some(bssid) = frame.bssid_str() {
|
||||
let info = frame.management_info();
|
||||
let channel =
|
||||
info.channel_ie.or_else(|| rt.freq_mhz.and_then(radiotap::freq_to_channel));
|
||||
let entry = self.aps.entry(bssid.clone()).or_insert_with(|| AccessPoint {
|
||||
let channel = info
|
||||
.channel_ie
|
||||
.or_else(|| rt.freq_mhz.and_then(radiotap::freq_to_channel));
|
||||
let entry = self
|
||||
.aps
|
||||
.entry(bssid.clone())
|
||||
.or_insert_with(|| AccessPoint {
|
||||
bssid,
|
||||
ssid: None,
|
||||
channel: None,
|
||||
@@ -96,7 +103,7 @@ impl ScanTable {
|
||||
|
||||
pub fn access_points(&self) -> Vec<AccessPoint> {
|
||||
let mut v: Vec<_> = self.aps.values().cloned().collect();
|
||||
v.sort_by(|a, b| b.beacons_seen.cmp(&a.beacons_seen));
|
||||
v.sort_by_key(|a| std::cmp::Reverse(a.beacons_seen));
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,10 @@ fn curl_text(url: &str) -> Result<String> {
|
||||
let out = Command::new("curl")
|
||||
.args(["-fsSL", "-H", "User-Agent: sabia-updater", url])
|
||||
.output()
|
||||
.map_err(|e| SabiaError::CommandFailed { cmd: format!("curl {url}"), detail: e.to_string() })?;
|
||||
.map_err(|e| SabiaError::CommandFailed {
|
||||
cmd: format!("curl {url}"),
|
||||
detail: e.to_string(),
|
||||
})?;
|
||||
if !out.status.success() {
|
||||
return Err(SabiaError::CommandFailed {
|
||||
cmd: format!("curl {url}"),
|
||||
@@ -53,14 +56,23 @@ fn curl_download(url: &str, dest: &PathBuf) -> Result<()> {
|
||||
.arg(dest)
|
||||
.arg(url)
|
||||
.status()
|
||||
.map_err(|e| SabiaError::CommandFailed { cmd: format!("curl {url}"), detail: e.to_string() })?;
|
||||
.map_err(|e| SabiaError::CommandFailed {
|
||||
cmd: format!("curl {url}"),
|
||||
detail: e.to_string(),
|
||||
})?;
|
||||
if !status.success() {
|
||||
return Err(SabiaError::CommandFailed { cmd: format!("curl {url}"), detail: "download falhou".into() });
|
||||
return Err(SabiaError::CommandFailed {
|
||||
cmd: format!("curl {url}"),
|
||||
detail: "download falhou".into(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn asset_name_for_platform() -> String {
|
||||
/// Sufixo que identifica o pacote certo entre os assets da release — o
|
||||
/// nome completo inclui a tag (ex.: `sabia-v0.1.0-linux-x86_64.tar.gz`),
|
||||
/// então casamos pelo fim em vez do nome inteiro.
|
||||
fn asset_suffix_for_platform() -> String {
|
||||
let os = if cfg!(target_os = "linux") {
|
||||
"linux"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
@@ -68,8 +80,13 @@ fn asset_name_for_platform() -> String {
|
||||
} else {
|
||||
"windows"
|
||||
};
|
||||
let arch = if cfg!(target_arch = "x86_64") { "x86_64" } else { "aarch64" };
|
||||
format!("sabia-{os}-{arch}.tar.gz")
|
||||
let arch = if cfg!(target_arch = "x86_64") {
|
||||
"x86_64"
|
||||
} else {
|
||||
"aarch64"
|
||||
};
|
||||
let ext = if cfg!(windows) { "zip" } else { "tar.gz" };
|
||||
format!("-{os}-{arch}.{ext}")
|
||||
}
|
||||
|
||||
/// Consulta a última release publicada e compara com a versão atual
|
||||
@@ -105,16 +122,22 @@ pub fn apply(current_exe: &PathBuf) -> Result<String> {
|
||||
let body = curl_text(&url)?;
|
||||
let release: Release = serde_json::from_str(&body)?;
|
||||
|
||||
let wanted = asset_name_for_platform();
|
||||
let suffix = asset_suffix_for_platform();
|
||||
let asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.name == wanted)
|
||||
.find(|a| a.name.ends_with(&suffix))
|
||||
.ok_or_else(|| SabiaError::CommandFailed {
|
||||
cmd: "update".into(),
|
||||
detail: format!("nenhum asset `{wanted}` na release {}", release.tag_name),
|
||||
detail: format!(
|
||||
"nenhum asset terminado em `{suffix}` na release {}",
|
||||
release.tag_name
|
||||
),
|
||||
})?;
|
||||
let checksum_asset = release.assets.iter().find(|a| a.name == format!("{}.sha256", asset.name));
|
||||
let checksum_asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.name == format!("{}.sha256", asset.name));
|
||||
|
||||
let tmp_dir = std::env::temp_dir().join("sabia-update");
|
||||
fs::create_dir_all(&tmp_dir)?;
|
||||
@@ -123,7 +146,11 @@ pub fn apply(current_exe: &PathBuf) -> Result<String> {
|
||||
|
||||
if let Some(checksum_asset) = checksum_asset {
|
||||
let expected = curl_text(&checksum_asset.browser_download_url)?;
|
||||
let expected_hash = expected.split_whitespace().next().unwrap_or("").to_lowercase();
|
||||
let expected_hash = expected
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
let data = fs::read(&archive_path)?;
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&data);
|
||||
@@ -136,13 +163,24 @@ pub fn apply(current_exe: &PathBuf) -> Result<String> {
|
||||
}
|
||||
}
|
||||
|
||||
// Extrai o tar.gz e substitui o binário atual atomicamente.
|
||||
// Extrai o pacote (tar.gz no Linux/macOS, zip no Windows — `tar xf` sem
|
||||
// flag de compressão detecta os dois formatos nos `tar` modernos,
|
||||
// incluindo o bsdtar que o Windows já traz por padrão) e substitui o
|
||||
// binário atual atomicamente.
|
||||
let extract_dir = tmp_dir.join("extracted");
|
||||
let _ = fs::remove_dir_all(&extract_dir);
|
||||
fs::create_dir_all(&extract_dir)?;
|
||||
let status = Command::new("tar").args(["xzf"]).arg(&archive_path).arg("-C").arg(&extract_dir).status()?;
|
||||
let status = Command::new("tar")
|
||||
.arg("xf")
|
||||
.arg(&archive_path)
|
||||
.arg("-C")
|
||||
.arg(&extract_dir)
|
||||
.status()?;
|
||||
if !status.success() {
|
||||
return Err(SabiaError::CommandFailed { cmd: "tar xzf".into(), detail: "extração falhou".into() });
|
||||
return Err(SabiaError::CommandFailed {
|
||||
cmd: "tar xf".into(),
|
||||
detail: "extração falhou".into(),
|
||||
});
|
||||
}
|
||||
|
||||
let new_binary = find_binary(&extract_dir)?;
|
||||
@@ -161,13 +199,29 @@ pub fn apply(current_exe: &PathBuf) -> Result<String> {
|
||||
Ok(release.tag_name)
|
||||
}
|
||||
|
||||
/// O binário vem dentro de uma subpasta (o CI empacota
|
||||
/// `sabia-vX.Y.Z-<plataforma>/sabia`), então procura até um nível de
|
||||
/// profundidade abaixo do diretório extraído.
|
||||
fn find_binary(dir: &PathBuf) -> Result<PathBuf> {
|
||||
fn is_binary(name: &std::ffi::OsStr) -> bool {
|
||||
name == "sabia" || name == "sabia.exe"
|
||||
}
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name();
|
||||
if name == "sabia" || name == "sabia.exe" {
|
||||
if is_binary(&entry.file_name()) {
|
||||
return Ok(entry.path());
|
||||
}
|
||||
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
|
||||
for inner in fs::read_dir(entry.path())? {
|
||||
let inner = inner?;
|
||||
if is_binary(&inner.file_name()) {
|
||||
return Ok(inner.path());
|
||||
}
|
||||
Err(SabiaError::CommandFailed { cmd: "update".into(), detail: "binário não encontrado no pacote baixado".into() })
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(SabiaError::CommandFailed {
|
||||
cmd: "update".into(),
|
||||
detail: "binário não encontrado no pacote baixado".into(),
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user