chore: initial project skeleton

Acauã: Network Detection & Threat Hunting, heuristic analysis over a
rolling 7-day window using deterministic math (time series, graph
theory, information theory) instead of opaque ML models. Third
product in the Ciberfauna ecosystem, alongside Guará and Sabiá.

Follows the ecosystem's established conventions: OpenWolf project
memory, bilingual PT-BR/EN docs and UI, Gitea Actions CI/CD on the
same self-hosted runners, Conventional Commits, SemVer, logo and
productName handling, MIT OR Apache-2.0 licensing.

Architecture decision (made with the project owner after a dedicated
consultation, not the original blueprint's suggested stack): Rust
throughout, not Python/FastAPI/React. Most of the statistics (Bowley
skew, MADM, Shannon entropy, PCR, Z-score, novel-edge detection,
pivot chains) reduces to SQL over an embedded DuckDB/Parquet store;
only the spectral analysis and graph centrality need host-language
code, and both have workable Rust crates. Documented in full, including
six corrections to the original blueprint's math (Rayleigh/Schuster
instead of a degenerate Lomb-Scargle over bare timestamps, an explicit
sentinel for Bowley skew's Q3=Q1 case, Jensen-Shannon instead of
unbounded KL, a dedicated long-connection detector, JA4 vs. JA4+
licensing caution, deterministic per-alert evidence), in
docs/architecture.md.

Skeleton only: crates/acaua-core has the four detection pillars,
fusion, storage, zeek log parsing and the updater as typed stubs that
compile and return a clear "skeleton" status rather than real
detection logic — that is separate, future work. crates/acaua-cli has
serve/analyze/doctor/update. apps/gui is a minimal Tauri shell. Also
already includes the one-click desktop self-update mechanism shared
with Guará and Sabiá (AppImage/.deb/Windows installer), even with no
release published yet, for consistency across the ecosystem.

No Gitea repository exists for this project yet; this is a local-only
commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
This commit is contained in:
Lucas RayanandClaude Sonnet 5 committed 2026-10-31 10:20:03 -03:00
commit 1b974bb90d
95 files changed
+12016

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
.git
target
apps/gui/src-tauri
docs
scripts
research
data
.acaua
*.parquet
*.duckdb
.claude
.wolf
.superpowers
+2
View File
@@ -0,0 +1,2 @@
# Interface captured by the Compose Zeek service. Use only on an authorized network.
ZEEK_INTERFACE=eth0
+17
View File
@@ -0,0 +1,17 @@
name: Report a bug
description: Something did not work as expected
labels: [bug]
body:
- type: textarea
id: what-happened
attributes: { label: "What happened?", description: "Steps to reproduce, expected behavior, and actual behavior." }
validations: { required: true }
- type: input
id: system
attributes: { label: "Operating system and Acauã version", placeholder: "Ubuntu 24.04 / acaua 0.1.0" }
- type: input
id: input
attributes: { label: "Dataset or input", placeholder: "Zeek log directory, pcap-derived dataset, or synthetic fixture" }
- type: textarea
id: log
attributes: { label: "Terminal output (acaua doctor / analyze)", render: shell }
+9
View File
@@ -0,0 +1,9 @@
## What changes and why
## Checklist
- [ ] `cargo fmt --all` and `cargo clippy --workspace --all-targets` pass without warnings
- [ ] `cargo test --workspace` passes
- [ ] Detection changes carry deterministic evidence and fixed-order reductions
- [ ] Commits follow Conventional Commits in English and code/comments are in English
- [ ] CHANGELOG updated (if there is a user-visible change)
- [ ] Documentation kept in the English/Portuguese pair
+88
View File
@@ -0,0 +1,88 @@
name: CI
on:
# Documentation-only or local agent state changes do not trigger CI jobs.
push:
branches: [main]
paths-ignore: ['**.md', 'docs/**', 'LICENSE*', '.gitignore', '.githooks/**', '.wolf/**', '.claude/**', '.opencode/**', '.codex/**', '.cursor/**', '.superpowers/**', '.superset/**', '.gitea/ISSUE_TEMPLATE/**', 'scripts/release-notes.sh', 'scripts/release-upload.sh', '.gitea/workflows/release.yml']
pull_request:
paths-ignore: ['**.md', 'docs/**', 'LICENSE*', '.gitignore', '.githooks/**', '.wolf/**', '.claude/**', '.opencode/**', '.codex/**', '.cursor/**', '.superpowers/**', '.superset/**', '.gitea/ISSUE_TEMPLATE/**', 'scripts/release-notes.sh', 'scripts/release-upload.sh', '.gitea/workflows/release.yml']
concurrency:
group: ci-${{ gitea.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: -D warnings
# The runner persists /opt/hostedtoolcache across jobs: rustup toolchains,
# cargo registry, npm/apt caches, and target directories live there so nothing
# is re-downloaded or rebuilt from scratch on every run. Each job uses its own
# target directory so differing RUSTFLAGS do not invalidate another job's cache.
CARGO_HOME: /opt/hostedtoolcache/cargo
RUSTUP_HOME: /opt/hostedtoolcache/rustup
NPM_CONFIG_CACHE: /opt/hostedtoolcache/npm-cache
jobs:
lint:
name: Format and clippy
runs-on: linux-arm64
env:
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/acaua-lint
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: actions/setup-node@v7
with:
node-version: 22
- name: UI translation coverage
run: node scripts/check-ui-i18n.mjs
- run: cargo fmt --all -- --check
- run: cargo clippy --workspace --all-targets
# Linux-only runner (ARM64).
test:
name: Tests (Linux)
runs-on: linux-arm64
env:
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/acaua-test
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- run: cargo test --workspace
audit:
name: Dependency audit
runs-on: linux-arm64
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
# rustsec/audit-check depends on GitHub's checks API. cargo-audit itself
# needs nothing but the advisory database, and installing it once into the
# persistent CARGO_HOME makes every later run skip compilation.
- name: cargo audit
run: |
command -v cargo-audit >/dev/null || cargo install cargo-audit --locked
cargo audit
gui:
name: Check desktop app (Linux)
runs-on: linux-arm64
env:
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/acaua-gui
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: actions/setup-node@v7
with:
node-version: 22
- name: Tauri system dependencies
timeout-minutes: 25
run: scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-native libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Generate icons
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: ""
+126
View File
@@ -0,0 +1,126 @@
name: Release
# Triggered when a v* tag is pushed: creates the Gitea Release entry with notes
# from CHANGELOG.md and uploads the compiled CLI (scripts/package-cli.sh) with
# a .sha256 checksum alongside each archive, plus the desktop bundles (Linux
# aarch64, Linux x86_64, and Windows via cargo-xwin).
on:
push:
tags: ['v*']
env:
CARGO_TERM_COLOR: always
# Rust toolchains, cargo registry, and caches live on the persistent runner volume.
CARGO_HOME: /opt/hostedtoolcache/cargo
RUSTUP_HOME: /opt/hostedtoolcache/rustup
NPM_CONFIG_CACHE: /opt/hostedtoolcache/npm-cache
XWIN_CACHE_DIR: /opt/hostedtoolcache/xwin-cache
jobs:
release:
name: Create release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Release
env:
TAG: ${{ gitea.ref_name }}
TOKEN: ${{ gitea.token }}
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
run: |
# Re-running the workflow does not recreate an existing release.
if curl -fsS -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" >/dev/null 2>&1; then
echo "release $TAG already exists"; exit 0
fi
scripts/release-notes.sh "$TAG" > notes.md
pre=false; case "$TAG" in *-*) pre=true ;; esac
jq -n --arg tag "$TAG" --arg name "Acauã ${TAG#v}" --rawfile body notes.md --argjson pre "$pre" \
'{tag_name:$tag, name:$name, body:$body, prerelease:$pre}' > release.json
curl -fsS -X POST "$API/releases" -H "Authorization: token $TOKEN" \
-H 'Content-Type: application/json' -d @release.json >/dev/null
cli:
name: CLI (${{ matrix.platform }})
needs: release
runs-on: ${{ matrix.runner }}
env:
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/acaua-release-cli-${{ matrix.platform }}
strategy:
fail-fast: false
matrix:
include:
- { platform: linux-aarch64, target: aarch64-unknown-linux-gnu, apt: "", runner: linux-arm64 }
- { platform: linux-x86_64, target: x86_64-unknown-linux-gnu, apt: "gcc-x86-64-linux-gnu", runner: linux-amd64 }
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Platform cross-linker
if: ${{ matrix.apt != '' && matrix.runner != 'linux-amd64' }}
run: scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/cli-x86_64 ${{ matrix.apt }}
- name: Build and package
run: scripts/package-cli.sh "${{ gitea.ref_name }}" "${{ matrix.platform }}" "${{ matrix.target }}" | tee packages.txt
- name: Upload to release
env:
TOKEN: ${{ gitea.token }}
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
run: scripts/release-upload.sh "${{ gitea.ref_name }}" $(cat packages.txt)
gui:
name: Desktop app (${{ matrix.platform }})
needs: release
runs-on: ${{ matrix.runner }}
env:
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/acaua-release-gui-${{ matrix.platform }}
strategy:
fail-fast: false
matrix:
include:
- { platform: linux-aarch64, runner: linux-arm64 }
- { platform: linux-x86_64, runner: linux-amd64 }
- { platform: windows, runner: linux-amd64 }
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: actions/setup-node@v7
with:
node-version: 22
- name: Tauri dependencies (Linux aarch64)
if: matrix.platform == 'linux-aarch64'
timeout-minutes: 25
run: scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-native libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Tauri dependencies (Linux x86_64)
if: matrix.platform == 'linux-x86_64'
timeout-minutes: 25
run: |
if [ "$(uname -m)" = aarch64 ]; then
dpkg --add-architecture amd64
. /etc/os-release
if [ "${ID:-}" = ubuntu ]; then
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
grep -q '^Architectures:' /etc/apt/sources.list.d/ubuntu.sources || sed -i '/^Types: deb/a Architectures: arm64' /etc/apt/sources.list.d/ubuntu.sources
printf 'Types: deb\nURIs: http://archive.ubuntu.com/ubuntu/\nSuites: %s %s-updates %s-security\nComponents: main universe\nArchitectures: amd64\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n' "$VERSION_CODENAME" "$VERSION_CODENAME" "$VERSION_CODENAME" >/etc/apt/sources.list.d/amd64.sources
elif [ -f /etc/apt/sources.list ]; then
sed -i 's/^deb http/deb [arch=arm64] http/' /etc/apt/sources.list
printf 'deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ %s main universe\ndeb [arch=amd64] http://archive.ubuntu.com/ubuntu/ %s-updates main universe\ndeb [arch=amd64] http://archive.ubuntu.com/ubuntu/ %s-security main universe\n' "$VERSION_CODENAME" "$VERSION_CODENAME" "$VERSION_CODENAME" >/etc/apt/sources.list.d/amd64.list
fi
fi
scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-amd64 gcc-x86-64-linux-gnu g++-x86-64-linux-gnu pkg-config patchelf file squashfs-tools qemu-user libglib2.0-bin libwebkit2gtk-4.1-dev:amd64 libayatana-appindicator3-dev:amd64 librsvg2-dev:amd64
else
scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-native libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
fi
- name: Cross-compilation tools (Windows)
if: matrix.platform == 'windows'
run: |
scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-win nsis lld llvm clang
command -v cargo-xwin >/dev/null || cargo install cargo-xwin --locked
- name: Build and package
run: scripts/build-gui.sh "${{ matrix.platform }}"
- name: Upload to release
env:
TOKEN: ${{ gitea.token }}
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
run: scripts/release-upload.sh "${{ gitea.ref_name }}" dist-gui/*
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Enforces Conventional Commits 1.0.0 and English-only commit messages.
set -euo pipefail
msg_file="${1:?usage: commit-msg <file>}"
python3 - "$msg_file" <<'PY'
import re, sys
from pathlib import Path
raw = Path(sys.argv[1]).read_text(encoding="utf-8")
lines = [l for l in raw.splitlines() if not l.startswith("#")]
while lines and not lines[0].strip():
lines.pop(0)
if not lines:
sys.exit("commit-msg: empty commit message")
subject = lines[0].strip()
if subject.startswith(("Merge ", "Revert \"")):
sys.exit(0)
conv_re = re.compile(
r"^(feat|fix|chore|ci|docs|refactor|test|perf|build|style|revert)(\([a-z0-9_./-]+\))?!?: .+$"
)
if not conv_re.match(subject):
sys.exit(
f"commit-msg: invalid Conventional Commit header: {subject!r}\n"
"Expected: <type>[optional scope]: <description in English>\n"
"Allowed types: feat, fix, chore, ci, docs, refactor, test, perf, build, style, revert"
)
body = "\n".join(lines)
cleaned = re.sub(r"\b(Guará|Sabiá|Acauã|Ciberfauna)\b", "", body)
pt_accents = re.search(r"[áàâãéêíóôõúçÁÀÂÃÉÊÍÓÔÕÚÇ]", cleaned)
pt_words = re.search(
r"\b(não|para|como|quando|então|pelos|pelas|pelo|pela|estão|está|também|versão|"
r"corrige|adiciona|atualiza|remove|padroniza|implementa|ajusta|arquivo|arquivos)\b",
cleaned,
re.I,
)
if pt_accents or pt_words:
sys.exit("commit-msg: commit messages must be written 100% in English.")
PY
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env bash
set -euo pipefail
exec "$(git rev-parse --show-toplevel)/scripts/check.sh"
+42
View File
@@ -0,0 +1,42 @@
# Build artifacts
target/
apps/gui/src-tauri/target/
apps/gui/src-tauri/gen/
node_modules/
dist-gui/
/acaua-v*
*.rs.bk
# Runtime data and local datasets
.acaua/
data/
*.parquet
*.duckdb
*.wal
# Python research environment
research/.venv/
research/**/__pycache__/
research/**/*.pyc
research/.ipynb_checkpoints/
# Local environment and editor state
.env
.env.*
!.env.example
.DS_Store
# Local AI agent configuration and state
.claude/
.wolf/
.opencode/
.codex/
.cursor/
.superpowers/
.superset/
CLAUDE.md
AGENTS.md
GEMINI.md
# Logo copied into the UI directory by the desktop build script
/apps/gui/ui/logo.png
+22
View File
@@ -0,0 +1,22 @@
# Changelog
**English** · [Português](CHANGELOG.pt-BR.md)
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow SemVer.
## [Unreleased]
## [0.1.0] - 2026-10-31
### Added
- Rust workspace with `acaua-core` and `acaua-cli`.
- CLI skeleton with `serve`, `analyze`, `doctor` and `update [--apply]`.
- Axum API skeleton with health, status, alerts, analyze and update-check routes.
- Embedded vanilla UI and Tauri desktop shell.
- Deterministic pillar, fusion, storage, Zeek and updater stubs with smoke tests.
- English/Portuguese documentation, CI/release workflows and local check script.
### Notes
- The four mathematical pillars are not implemented.
- The Zeek parser and DuckDB storage are stubs.
- No release artifact has been published.
+22
View File
@@ -0,0 +1,22 @@
# Changelog
[English](CHANGELOG.md) · **Português**
Formato baseado no [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer.
## [Unreleased]
## [0.1.0] - 2026-10-31
### Adicionado
- Workspace Rust com `acaua-core` e `acaua-cli`.
- Esqueleto da CLI com `serve`, `analyze`, `doctor` e `update [--apply]`.
- Esqueleto da API Axum com rotas de health, status, alertas, análise e checagem de atualização.
- UI vanilla embutida e shell desktop Tauri.
- Stubs determinísticos de pilares, fusão, armazenamento, Zeek e updater com testes de fumaça.
- Documentação em inglês/português, workflows de CI/release e script local de verificação.
### Notas
- Os quatro pilares matemáticos não estão implementados.
- O parser Zeek e o armazenamento DuckDB são stubs.
- Nenhum artefato de release foi publicado.
+82
View File
@@ -0,0 +1,82 @@
# Contributing to Acauã
**English** · [Português](CONTRIBUTING.pt-BR.md)
Thanks for helping. The project is a pre-alpha skeleton, so the most useful
contributions right now are tests, fixtures, documentation and small,
well-scoped design corrections.
1. **Authorized monitoring only.** Acauã is defensive software for networks you
own or are explicitly authorized to observe. Do not add collection paths that
hide scope, retention or evidence.
2. **Evidence is part of the feature.** A detection change must state its
inputs, fixed-order reductions and intermediate values. A score without a
reproducible evidence record is not accepted.
3. **Be honest about what works.** If a feature is a stub, Linux-only or limited
to one Zeek log, say so in the README, the CLI help and the test.
4. **100% English codebase.** Source code, comments, scripts, workflows and
commit messages are English. The GUI and CLI remain bilingual (PT-BR and EN
through `i18n-en.js`).
## Workflow
```bash
scripts/install-hooks.sh
scripts/check.sh
scripts/check.sh --full
```
`check.sh` runs formatting, clippy with warnings as errors, UI translation
coverage, shell syntax, workflow YAML, SemVer consistency and the desktop check.
`--full` adds workspace tests and `cargo audit`.
The optional DuckDB feature is not part of the default check:
```bash
cargo check -p acaua-core --features duckdb
```
## Research
`research/` is an isolated Python workspace for scientific validation. It may
use astropy, scipy, networkx, pandas and notebooks. It must never be imported by
the Rust runtime, copied into a release or required to build the product.
## Detection rules
- Keep reductions in a fixed order when floating-point results are part of an
alert.
- Use a documented sentinel instead of `NaN` or a panic for degenerate inputs.
- Use Jensen-Shannon, not raw KL, when a distribution can contain zero
probability.
- Do not use JA4S/JA4H/JA4X/JA4SSH/JA4L by default: those variants carry a
non-OSI license. The client TLS JA4 fingerprint is the only default.
- Long connections need their own detector; they do not produce the short
intervals a beaconing score expects.
## Interface and translation
The interface source text is Portuguese. English comes from
`apps/gui/ui/i18n-en.js`, applied at runtime by `i18n.js`. When adding visible
text, run `node scripts/check-ui-i18n.mjs` and include the English pair.
## Documentation
Keep the pair: every `*.md` and `docs/*` file has an English version and a
Portuguese version (`*.pt-BR.md`, `docs/pt-BR/`). File names are translated only
when the word actually changes, as in `architecture.md` → `arquitetura.md`.
## Commits and releases
Commits follow Conventional Commits 1.0.0 in English (`feat:`, `fix:`, `chore:`,
`ci:`, `docs:`, `refactor:`, `test:`, `perf:`, `build:`). Versions follow SemVer
2.0.0 and are synchronized across the workspace, the desktop manifest and the
Tauri config.
Pushing a `v*` tag creates a Gitea Release from the matching `CHANGELOG.md`
section and attaches the Linux CLI packages plus the desktop bundles.
## License
By contributing, you agree to license under [MIT](LICENSE-MIT) **or**
[Apache-2.0](LICENSE-APACHE), like the rest of the project.
+84
View File
@@ -0,0 +1,84 @@
# Contribuindo com o Acauã
[English](CONTRIBUTING.md) · **Português**
Obrigado por ajudar. O projeto é um esqueleto pré-alfa, então as contribuições
mais úteis agora são testes, fixtures, documentação e correções de projeto
pequenas e bem delimitadas.
1. **Só monitoramento autorizado.** O Acauã é software defensivo para redes
próprias ou com autorização explícita. Não adicione caminhos de coleta que
escondam escopo, retenção ou evidência.
2. **Evidência faz parte do recurso.** Uma mudança de detecção precisa declarar
entradas, reduções em ordem fixa e valores intermediários. Pontuação sem
registro reproduzível de evidência não é aceita.
3. **Seja honesto sobre o que funciona.** Se um recurso é stub, só roda no Linux
ou cobre apenas um log Zeek, diga isso no README, no `--help` e no teste.
4. **Código 100% em inglês.** Código-fonte, comentários, scripts, workflows e
mensagens de commit são em inglês. A GUI e a CLI continuam bilíngues (PT-BR e
EN via `i18n-en.js`).
## Fluxo
```bash
scripts/install-hooks.sh
scripts/check.sh
scripts/check.sh --full
```
O `check.sh` roda formatação, clippy com warnings como erro, cobertura de
tradução da UI, sintaxe de shell, YAML dos workflows, consistência de SemVer e a
checagem do desktop. O `--full` inclui testes do workspace e `cargo audit`.
A feature opcional DuckDB não entra na checagem padrão:
```bash
cargo check -p acaua-core --features duckdb
```
## Pesquisa
`research/` é um espaço Python isolado para validação científica. Ele pode usar
astropy, scipy, networkx, pandas e notebooks. Ele nunca deve ser importado pelo
runtime Rust, copiado para um release ou exigido para compilar o produto.
## Regras de detecção
- Mantenha reduções em ordem fixa quando resultados de ponto flutuante entram em
um alerta.
- Use um valor sentinela documentado em vez de `NaN` ou panic para entradas
degeneradas.
- Use Jensen-Shannon, não KL puro, quando uma distribuição pode ter probabilidade
zero.
- Não use JA4S/JA4H/JA4X/JA4SSH/JA4L por padrão: essas variantes têm licença
não-OSI. O JA4 de impressão digital de cliente TLS é o único padrão.
- Conexões longas precisam de detector próprio; elas não produzem os intervalos
curtos que uma pontuação de beaconing espera.
## Interface e tradução
O texto-fonte da interface é em português. O inglês vem de
`apps/gui/ui/i18n-en.js`, aplicado em tempo de execução por `i18n.js`. Ao
adicionar texto visível, rode `node scripts/check-ui-i18n.mjs` e inclua o par em
inglês.
## Documentação
Mantenha o par: todo arquivo `*.md` e `docs/*` tem versão em inglês e em
português (`*.pt-BR.md`, `docs/pt-BR/`). Nomes de arquivo só são traduzidos
quando a palavra muda de verdade, como `architecture.md` → `arquitetura.md`.
## Commits e releases
Os commits seguem Conventional Commits 1.0.0 em inglês (`feat:`, `fix:`,
`chore:`, `ci:`, `docs:`, `refactor:`, `test:`, `perf:`, `build:`). As versões
seguem SemVer 2.0.0 e ficam sincronizadas entre o workspace, o manifesto do
desktop e a configuração do Tauri.
Enviar uma tag `v*` cria uma Release no Gitea a partir da seção correspondente do
`CHANGELOG.md` e anexa os pacotes Linux da CLI e os bundles do desktop.
## Licença
Ao contribuir, você concorda em licenciar sob [MIT](LICENSE-MIT) **ou**
[Apache-2.0](LICENSE-APACHE), como o resto do projeto.
Generated
+2225
View File
File diff suppressed because it is too large. Load diff
+22
View File
@@ -0,0 +1,22 @@
[workspace]
resolver = "2"
members = ["crates/acaua-core", "crates/acaua-cli"]
exclude = ["apps/gui/src-tauri", "research"]
[workspace.package]
version = "0.1.0"
edition = "2021"
license = "MIT OR Apache-2.0"
repository = "https://git.lucasrguerra.dev.br/ciberfauna/acaua"
authors = ["Acauã contributors"]
rust-version = "1.80"
[profile.release]
lto = "thin"
codegen-units = 1
opt-level = 3
strip = true
panic = "abort"
[profile.dev.package."*"]
opt-level = 3
+23
View File
@@ -0,0 +1,23 @@
# syntax=docker/dockerfile:1
FROM rust:1-slim-bookworm AS build
WORKDIR /src
COPY Cargo.toml Cargo.lock ./
COPY crates crates
COPY apps/gui/ui apps/gui/ui
COPY logo.png logo.png
RUN cargo build --release --locked -p acaua-cli
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl tar \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /src/target/release/acaua /usr/local/bin/acaua
LABEL org.opencontainers.image.title="Acauã" \
org.opencontainers.image.description="Network threat hunting with deterministic evidence" \
org.opencontainers.image.source="https://git.lucasrguerra.dev.br/lucasrguerra/acaua" \
org.opencontainers.image.licenses="MIT OR Apache-2.0"
WORKDIR /data
EXPOSE 8787
ENTRYPOINT ["acaua"]
CMD ["serve", "--bind", "0.0.0.0:8787", "--data-dir", "/data/acaua"]
+202
View File
@@ -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
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Acauã 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.
+145
View File
@@ -0,0 +1,145 @@
<p align="center"><img src="logo.png" width="300" alt="Acauã"></p>
<h1 align="center">Acauã</h1>
<p align="center">Deterministic network threat hunting.<br>Zeek-first NDR research · local-first · authorized use.</p>
<p align="center"><a href="docs/cli.md">Terminal</a> · <a href="docs/interface.md">Interface</a> · <a href="docs/architecture.md">Architecture</a> · <a href="docs/docker.md">Docker</a> · <a href="docs/support.md">Support</a> · <a href="ROADMAP.md">Roadmap</a> · <a href="CHANGELOG.md">Changelog</a></p>
<p align="center"><b>English</b> · <a href="README.pt-BR.md">Português</a></p>
---
**Status: pre-alpha skeleton.** The project structure, documentation, CI and
compiling stubs exist. The four mathematical detection pillars, the real Zeek
parser, DuckDB storage and the alert engine are **under development** and are
not shipped yet. Nothing on this page should be read as a claim that detection
already works.
Acauã is a network threat hunting engine built around deterministic evidence,
not an opaque model. The goal is to answer not only "this looks suspicious" but
"these exact inputs, with these intermediate values, produced this score".
- **Deterministic by design:** fixed-order reductions, documented sentinels and
a JSON evidence record for every future alert.
- **Local-first:** the analytical store is embedded in the process; no external
database and no telemetry by default.
- **Zeek-first:** `dns.log`, `ssl.log`, `conn.log` and the protocol logs needed
for identity and lateral-movement analysis. NetFlow/IPFIX is a documented
degraded mode, never presented as equivalent. Suricata EVE JSON is an optional
secondary input.
- **Daemon and desktop:** `acaua serve` runs continuous ingestion, scheduling,
the API and the embedded UI; the Tauri app opens a local dataset or talks to a
remote `serve`.
- **Research stays outside the product:** `research/` is an isolated Python
workspace for scientific validation and fixtures. It is never compiled,
packaged or released.
## What exists today
| Area | State |
|---|---|
| Rust workspace and CLI stubs | ✅ compiles |
| Axum API with health, status, alerts and analyze routes | ✅ stub |
| Desktop shell and vanilla UI | ✅ shell, no real screenshots |
| Documentation and CI/release workflows | ✅ |
| Four detection pillars | 🚧 under development |
| Zeek log parser | 🚧 stub |
| DuckDB/Parquet storage and 7-day TTL | 🚧 reserved, feature disabled by default |
| Alert evidence and scoring | 🚧 stub |
| Published releases | ❌ none yet |
## The four pillars
All four are **under development**. The roadmap keeps them as future work, not
as implemented features.
1. **Beaconing:** periodicity on event timestamps with a Rayleigh/Schuster
periodogram, and generalized Lomb–Scargle only where a real `y` exists.
2. **Persistent sessions:** long connections that never produce a short `Δt`
and would otherwise stay invisible to a beaconing detector.
3. **Protocol anomalies:** deterministic features over Zeek protocol logs,
including identity and lateral-movement signals.
4. **Evidence fusion:** bounded, symmetric combination of pillar scores with a
reproducible evidence record per alert.
The design decisions behind these pillars, including the six corrections to the
original blueprint, are in **[docs/architecture.md](docs/architecture.md)**.
## Authorized use
Acauã is defensive software for networks you own or are explicitly authorized
to monitor. Capturing or analyzing third-party traffic without permission may
be illegal. The operator is responsible for scope, privacy and retention.
The daemon binds to `127.0.0.1` by default. The optional Docker deployment is
documented for lab and research use.
## Install
There are no published releases yet. Build from source:
```bash
cargo build --release -p acaua-cli
./target/release/acaua --help
```
Rust 1.80+ is required. The desktop app needs the Tauri system dependencies for
your platform.
## Getting started
```bash
acaua doctor
acaua serve --bind 127.0.0.1:8787 --data-dir .acaua
acaua analyze /var/log/zeek/current --json
acaua update
```
`analyze` currently validates the directory and returns a skeleton report; it
does not parse Zeek logs yet. `serve` exposes the stub API and the embedded UI.
`update` uses the same Gitea Releases + SHA-256 model as Guará and Sabiá.
## Desktop app
The Tauri app is a shell for the same UI used by `serve`. It has a dashboard for
the four pillars, a dataset path field, an alerts panel and settings for language
and theme. It does **not** render detection results yet. See
**[docs/interface.md](docs/interface.md)**.
## Development
```bash
cargo test --workspace
cargo clippy --workspace --all-targets
node scripts/check-ui-i18n.mjs
scripts/check.sh
```
The optional DuckDB backend is compiled with `--features duckdb`; it is disabled
by default so the skeleton stays fast to check. See
**[CONTRIBUTING.md](CONTRIBUTING.md)** and
**[docs/architecture.md](docs/architecture.md)**.
## Known limits
- The detection engine, Zeek parser and storage layer are stubs.
- There is no published binary, installer or release asset.
- The GUI has no real screenshots because there is nothing to render yet.
- NetFlow/IPFIX is a degraded input mode by design; it cannot replace the Zeek
protocol logs for every pillar.
- The project targets Linux for the daemon and the CI release pipeline. The
desktop app follows the same Tauri platform matrix as the sibling projects.
## Where it's going
Summary of the [ROADMAP](ROADMAP.md):
| | |
|---|---|
| **Done** | Workspace, CLI/API stubs, GUI shell, documentation, CI/release workflows |
| **Next** | Pillar 1 and 2 implementations with deterministic fixtures; real Zeek parser |
| **Later** | DuckDB/Parquet storage, TTL, pillar 3 and 4, evidence fusion, desktop dataset view |
| **Not planned** | Opaque AI scoring, a required cloud service, Python in the shipped runtime |
## License
[MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), your choice.
+147
View File
@@ -0,0 +1,147 @@
<p align="center"><img src="logo.png" width="300" alt="Acauã"></p>
<h1 align="center">Acauã</h1>
<p align="center">Caça determinística a ameaças em rede.<br>NDR com Zeek como entrada principal · local-first · uso autorizado.</p>
<p align="center"><a href="docs/pt-BR/cli.md">Terminal</a> · <a href="docs/pt-BR/interface.md">Interface</a> · <a href="docs/pt-BR/arquitetura.md">Arquitetura</a> · <a href="docs/pt-BR/docker.md">Docker</a> · <a href="docs/pt-BR/suporte.md">Suporte</a> · <a href="ROADMAP.pt-BR.md">Roadmap</a> · <a href="CHANGELOG.pt-BR.md">Changelog</a></p>
<p align="center"><a href="README.md">English</a> · <b>Português</b></p>
---
**Estado: esqueleto pré-alfa.** A estrutura do projeto, a documentação, o CI e
os stubs que compilam existem. Os quatro pilares matemáticos de detecção, o
parser real de logs Zeek, o armazenamento DuckDB e o motor de alertas estão
**em desenvolvimento** e ainda não são distribuídos. Nada nesta página deve ser
lido como promessa de que a detecção já funciona.
O Acauã é um motor de caça a ameaças em rede construído em torno de evidência
determinística, não de um modelo opaco. O objetivo é responder não só "isto
parece suspeito", mas "estas entradas exatas, com estes valores intermediários,
produziram esta pontuação".
- **Determinístico por projeto:** reduções em ordem fixa, sentinelas
documentados e um registro JSON de evidência para cada alerta futuro.
- **Local-first:** o armazenamento analítico mora dentro do processo; nenhum
banco externo e nenhuma telemetria por padrão.
- **Zeek como entrada principal:** `dns.log`, `ssl.log`, `conn.log` e os logs de
protocolo necessários para identidade e movimento lateral. NetFlow/IPFIX é um
modo degradado documentado, nunca apresentado como equivalente. Suricata EVE
JSON é uma entrada secundária opcional.
- **Daemon e desktop:** `acaua serve` roda ingestão contínua, agendador, API e a
UI embutida; o app Tauri abre um dataset local ou conversa com um `serve`
remoto.
- **Pesquisa fora do produto:** `research/` é um espaço Python isolado para
validação científica e fixtures. Ele nunca é compilado, empacotado ou
distribuído.
## O que existe hoje
| Área | Estado |
|---|---|
| Workspace Rust e stubs da CLI | ✅ compila |
| API Axum com rotas de health, status, alertas e análise | ✅ stub |
| Shell desktop e UI vanilla | ✅ shell, sem screenshots reais |
| Documentação e workflows de CI/release | ✅ |
| Quatro pilares de detecção | 🚧 em desenvolvimento |
| Parser de logs Zeek | 🚧 stub |
| Armazenamento DuckDB/Parquet e TTL de 7 dias | 🚧 reservado, feature desligada por padrão |
| Evidência e pontuação de alertas | 🚧 stub |
| Releases publicadas | ❌ nenhuma ainda |
## Os quatro pilares
Todos os quatro estão **em desenvolvimento**. O roadmap os mantém como trabalho
futuro, não como recurso implementado.
1. **Beaconing:** periodicidade sobre timestamps de eventos com periodograma de
Rayleigh/Schuster, e Lomb–Scargle generalizado só onde existe um `y` real.
2. **Sessões persistentes:** conexões longas que nunca produzem um `Δt` curto e
ficariam invisíveis para um detector de beaconing.
3. **Anomalias de protocolo:** features determinísticas sobre logs de protocolo
do Zeek, incluindo sinais de identidade e movimento lateral.
4. **Fusão de evidências:** combinação limitada e simétrica das pontuações dos
pilares, com um registro de evidência reproduzível por alerta.
As decisões de projeto por trás desses pilares, incluindo as seis correções ao
blueprint original, estão em **[docs/pt-BR/arquitetura.md](docs/pt-BR/arquitetura.md)**.
## Uso autorizado
O Acauã é software defensivo para redes próprias ou para redes que você tem
autorização explícita para monitorar. Capturar ou analisar tráfego de terceiros
sem permissão pode ser ilegal. O operador é responsável por escopo, privacidade e
retenção.
O daemon escuta em `127.0.0.1` por padrão. A implantação opcional com Docker é
documentada para laboratório e pesquisa.
## Instalação
Ainda não há releases publicadas. Compile do código:
```bash
cargo build --release -p acaua-cli
./target/release/acaua --help
```
É preciso Rust 1.80+. O app desktop precisa das dependências de sistema do Tauri
para a sua plataforma.
## Começando
```bash
acaua doctor
acaua serve --bind 127.0.0.1:8787 --data-dir .acaua
acaua analyze /var/log/zeek/current --json
acaua update
```
O `analyze` hoje valida o diretório e devolve um relatório de esqueleto; ele
ainda não lê logs Zeek. O `serve` expõe a API stub e a UI embutida. O `update`
usa o mesmo modelo de Gitea Releases + SHA-256 do Guará e do Sabiá.
## App desktop
O app Tauri é um shell para a mesma UI usada pelo `serve`. Ele tem um painel dos
quatro pilares, um campo de caminho de dataset, um painel de alertas e
configurações de idioma e tema. Ele **ainda não** renderiza resultados de
detecção. Veja **[docs/pt-BR/interface.md](docs/pt-BR/interface.md)**.
## Desenvolvimento
```bash
cargo test --workspace
cargo clippy --workspace --all-targets
node scripts/check-ui-i18n.mjs
scripts/check.sh
```
O backend DuckDB opcional é compilado com `--features duckdb`; ele fica desligado
por padrão para o esqueleto continuar rápido de verificar. Veja
**[CONTRIBUTING.pt-BR.md](CONTRIBUTING.pt-BR.md)** e
**[docs/pt-BR/arquitetura.md](docs/pt-BR/arquitetura.md)**.
## Limites conhecidos
- O motor de detecção, o parser Zeek e a camada de armazenamento são stubs.
- Não há binário, instalador ou asset de release publicado.
- A GUI não tem screenshots reais porque ainda não há nada para renderizar.
- NetFlow/IPFIX é um modo degradado por projeto; ele não substitui os logs de
protocolo do Zeek em todos os pilares.
- O projeto mira Linux para o daemon e para o pipeline de release do CI. O app
desktop segue a mesma matriz Tauri dos projetos irmãos.
## Para onde vai
Resumo do [ROADMAP](ROADMAP.pt-BR.md):
| | |
|---|---|
| **Feito** | Workspace, stubs de CLI/API, shell da GUI, documentação, workflows de CI/release |
| **Próximo** | Implementação dos pilares 1 e 2 com fixtures determinísticas; parser Zeek real |
| **Depois** | Armazenamento DuckDB/Parquet, TTL, pilares 3 e 4, fusão de evidências, visão de dataset no desktop |
| **Fora de plano** | Pontuação por IA opaca, serviço de nuvem obrigatório, Python no runtime distribuído |
## Licença
[MIT](LICENSE-MIT) ou [Apache-2.0](LICENSE-APACHE), à sua escolha.
+55
View File
@@ -0,0 +1,55 @@
# Roadmap
**English** · [Português](ROADMAP.pt-BR.md)
This roadmap separates what exists from what is planned. The four pillars are
**not implemented** in `0.1.0`; they are the next body of work.
## Done
- Rust workspace with `acaua-core` and `acaua-cli`.
- CLI skeleton, Axum API skeleton and embedded vanilla UI.
- Tauri desktop shell with language and theme settings.
- Stubs for the four pillars, evidence fusion, storage, Zeek ingestion and the
Gitea-based updater.
- English/Portuguese documentation, CI/release workflows and `scripts/check.sh`.
- Deterministic smoke tests and a bilingual UI check.
## Next
1. **Pillar 1 — beaconing.** Implement the Rayleigh/Schuster periodogram on
event timestamps, add generalized Lomb–Scargle only when a real `y` exists,
and ship golden fixtures with fixed expected values.
2. **Pillar 2 — persistent sessions.** Detect long connections that never
produce a short interval, with explicit thresholds and no overlap with
pillar 1.
3. **Real Zeek parser.** Read `conn.log`, `dns.log`, `ssl.log` and protocol logs
with bounded memory, path confinement and deterministic ordering.
4. **Evidence record.** Emit a JSON record per alert with inputs, intermediate
values, code version and dataset hash.
## Later
- Pillar 3 — deterministic protocol anomalies, including identity and
lateral-movement features.
- Pillar 4 — bounded, symmetric fusion with reproducible weights.
- DuckDB/Parquet storage behind the optional `duckdb` feature, with a seven-day
TTL and an export command.
- Desktop dataset view, alert detail with evidence, and scheduled `serve` runs.
- NetFlow/IPFIX as a documented degraded mode and Suricata EVE JSON as an
optional secondary input.
- Signed release metadata, SBOM and reproducible builds.
## Non-goals
- Opaque AI scoring that cannot explain an alert.
- A required cloud service or telemetry pipeline.
- Python in the shipped runtime.
- Treating flow records as equivalent to full Zeek protocol logs.
- Automatic desktop self-update.
## Versioning
The project follows SemVer 2.0.0. `0.x` means the API and detection output may
change between minor versions. A `1.0.0` release will require stable evidence
format, documented thresholds and a migration path for stored data.
+57
View File
@@ -0,0 +1,57 @@
# Roadmap
[English](ROADMAP.md) · **Português**
Este roadmap separa o que existe do que está planejado. Os quatro pilares **não
estão implementados** em `0.1.0`; eles são o próximo bloco de trabalho.
## Feito
- Workspace Rust com `acaua-core` e `acaua-cli`.
- Esqueleto da CLI, esqueleto da API Axum e UI vanilla embutida.
- Shell desktop Tauri com configurações de idioma e tema.
- Stubs dos quatro pilares, fusão de evidências, armazenamento, ingestão Zeek e
updater baseado em Gitea.
- Documentação em inglês/português, workflows de CI/release e `scripts/check.sh`.
- Testes de fumaça determinísticos e checagem bilíngue da UI.
## Próximo
1. **Pilar 1 — beaconing.** Implementar o periodograma de Rayleigh/Schuster
sobre timestamps de eventos, adicionar Lomb–Scargle generalizado só quando
existir um `y` real e entregar fixtures douradas com valores esperados fixos.
2. **Pilar 2 — sessões persistentes.** Detectar conexões longas que nunca
produzem um intervalo curto, com limiares explícitos e sem sobreposição com o
pilar 1.
3. **Parser Zeek real.** Ler `conn.log`, `dns.log`, `ssl.log` e logs de
protocolo com memória limitada, confinamento de caminho e ordem determinística.
4. **Registro de evidência.** Emitir um JSON por alerta com entradas, valores
intermediários, versão do código e hash do dataset.
## Depois
- Pilar 3 — anomalias determinísticas de protocolo, incluindo identidade e
movimento lateral.
- Pilar 4 — fusão limitada e simétrica, com pesos reproduzíveis.
- Armazenamento DuckDB/Parquet atrás da feature opcional `duckdb`, com TTL de
sete dias e comando de exportação.
- Visão de dataset no desktop, detalhe de alerta com evidência e execuções
agendadas do `serve`.
- NetFlow/IPFIX como modo degradado documentado e Suricata EVE JSON como entrada
secundária opcional.
- Metadados de release assinados, SBOM e builds reproduzíveis.
## Fora de plano
- Pontuação por IA opaca que não explica um alerta.
- Serviço de nuvem obrigatório ou pipeline de telemetria.
- Python no runtime distribuído.
- Tratar registros de fluxo como equivalentes a logs completos de protocolo Zeek.
- Auto-atualização automática do desktop.
## Versionamento
O projeto segue SemVer 2.0.0. `0.x` significa que a API e a saída de detecção
podem mudar entre versões menores. Uma versão `1.0.0` vai exigir formato de
evidência estável, limiares documentados e caminho de migração para dados
armazenados.
+44
View File
@@ -0,0 +1,44 @@
# Security policy
**English** · [Português](SECURITY.pt-BR.md)
Acauã is defensive software, but it processes hostile input: packet captures,
Zeek logs and flow records may be malformed on purpose. Treat every dataset as
untrusted.
## Reporting a vulnerability
Do not open a public issue with exploit details. Open a minimal issue asking for
a private channel, or contact the maintainer through the Gitea profile listed on
the repository. Include the affected version, the input shape and a minimal
reproduction when a private channel is available.
## Scope and threat model
- **Input parsing:** a malformed log line must not panic the daemon, exhaust
memory or escape the configured data directory.
- **Path handling:** dataset paths are resolved and confined to the configured
root; symbolic links are not followed outside it.
- **API exposure:** `serve` binds to `127.0.0.1` by default. Exposing it on a
network interface is an explicit operator decision and needs a reverse proxy
with authentication.
- **Evidence integrity:** alert evidence must be reproducible from the same
input and code version. Silent nondeterminism is a correctness and security
issue.
- **Updates:** the CLI checks Gitea Releases, downloads over HTTPS and verifies
SHA-256 before replacing the binary. The desktop app does not update itself.
- **Privacy:** there is no telemetry. Retention defaults to seven days of
normalized events; the operator is responsible for local law and policy.
## Out of scope
- Vulnerabilities in third-party Zeek, Suricata, Tauri or operating-system
packages that are already fixed upstream.
- Attacks that require the operator to disable the documented security controls
(for example, running `serve` as root on a public interface).
- Detection quality claims: the pillars are not implemented in this skeleton.
## Supported versions
Only the latest `0.1.x` line is supported while the project is pre-alpha. There
is no long-term support branch yet.
+45
View File
@@ -0,0 +1,45 @@
# Política de segurança
[English](SECURITY.md) · **Português**
O Acauã é software defensivo, mas processa entrada hostil: capturas de pacotes,
logs Zeek e registros de fluxo podem estar malformados de propósito. Trate todo
dataset como não confiável.
## Reportando uma vulnerabilidade
Não abra issue pública com detalhes de exploração. Abra uma issue mínima pedindo
um canal privado, ou contate o mantenedor pelo perfil no Gitea indicado no
repositório. Inclua a versão afetada, o formato da entrada e uma reprodução
mínima quando houver canal privado.
## Escopo e modelo de ameaça
- **Parsing de entrada:** uma linha malformada não pode derrubar o daemon,
esgotar memória ou escapar do diretório de dados configurado.
- **Caminhos:** caminhos de dataset são resolvidos e confinados à raiz
configurada; links simbólicos não são seguidos para fora dela.
- **Exposição da API:** o `serve` escuta em `127.0.0.1` por padrão. Expor em uma
interface de rede é decisão explícita do operador e exige proxy reverso com
autenticação.
- **Integridade da evidência:** a evidência de um alerta precisa ser reproduzível
com a mesma entrada e a mesma versão do código. Não determinismo silencioso é
problema de correção e de segurança.
- **Atualizações:** a CLI consulta Gitea Releases, baixa por HTTPS e verifica
SHA-256 antes de substituir o binário. O app desktop não se atualiza sozinho.
- **Privacidade:** não há telemetria. A retenção padrão é de sete dias de eventos
normalizados; o operador responde pela lei e pela política local.
## Fora de escopo
- Vulnerabilidades em pacotes de terceiros (Zeek, Suricata, Tauri ou sistema
operacional) já corrigidas upstream.
- Ataques que exigem desligar controles documentados (por exemplo, rodar `serve`
como root em interface pública).
- Alegações de qualidade de detecção: os pilares não estão implementados neste
esqueleto.
## Versões suportadas
Só a linha `0.1.x` mais recente é suportada enquanto o projeto é pré-alfa. Ainda
não existe branch de suporte de longo prazo.
+4314
View File
File diff suppressed because it is too large. Load diff
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "acaua-gui"
description = "Acauã: graphical network threat hunting interface"
version = "0.1.0"
edition = "2021"
license = "MIT OR Apache-2.0"
authors = ["Acauã contributors"]
repository = "https://git.lucasrguerra.dev.br/ciberfauna/acaua"
rust-version = "1.80"
[workspace]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
acaua-core = { path = "../../../crates/acaua-core" }
serde_json = "1"
tauri = { version = "2", features = [] }
[profile.release]
lto = "thin"
codegen-units = 1
strip = true
+5
View File
@@ -0,0 +1,5 @@
fn main() {
println!("cargo:rerun-if-changed=../../../logo.png");
let _ = std::fs::copy("../../../logo.png", "../ui/logo.png");
tauri_build::build();
}
@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Main window permissions",
"windows": ["main"],
"permissions": ["core:default", "core:window:allow-destroy", "core:window:allow-close"]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 KiB

+62
View File
@@ -0,0 +1,62 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use acaua_core::{doctor, update, zeek};
#[tauri::command]
fn app_info() -> serde_json::Value {
serde_json::json!({
"version": env!("CARGO_PKG_VERSION"),
"mode": "desktop",
"status": "skeleton"
})
}
#[tauri::command]
fn doctor_report() -> Result<serde_json::Value, String> {
serde_json::to_value(doctor::run()).map_err(|error| error.to_string())
}
#[tauri::command]
fn analyze_dataset(path: String) -> Result<serde_json::Value, String> {
let report = zeek::analyze_dir(&path).map_err(|error| error.to_string())?;
serde_json::to_value(report).map_err(|error| error.to_string())
}
#[tauri::command]
fn list_alerts() -> serde_json::Value {
serde_json::json!([])
}
#[tauri::command]
fn update_check() -> Result<serde_json::Value, String> {
let status = update::check(env!("CARGO_PKG_VERSION")).map_err(|error| error.to_string())?;
serde_json::to_value(status).map_err(|error| error.to_string())
}
#[tauri::command]
fn update_apply(app: tauri::AppHandle) -> Result<String, String> {
use tauri::Emitter;
let exe = std::env::var_os("APPIMAGE")
.map(std::path::PathBuf::from)
.filter(|p| p.is_file())
.or_else(|| std::env::current_exe().ok())
.unwrap_or_else(|| std::path::PathBuf::from("acaua-gui"));
update::apply_desktop_with_progress(&exe, move |phase| {
let _ = app.emit("update-progress", serde_json::json!({ "phase": phase }));
})
.map_err(|error| error.to_string())
}
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
app_info,
doctor_report,
analyze_dataset,
list_alerts,
update_check,
update_apply
])
.run(tauri::generate_context!())
.expect("failed to run Acauã");
}
+39
View File
@@ -0,0 +1,39 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Acauã",
"version": "0.1.0",
"identifier": "dev.acaua.app",
"build": {
"frontendDist": "../ui"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"title": "Acauã",
"width": 1280,
"height": 820,
"minWidth": 460,
"minHeight": 600,
"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'; object-src 'none'"
}
},
"bundle": {
"active": true,
"targets": "all",
"category": "Utility",
"shortDescription": "Caça a ameaças em rede",
"longDescription": "Acauã é uma ferramenta de caça a ameaças em rede com evidência determinística, processamento local e sem IA opaca.",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
+304
View File
@@ -0,0 +1,304 @@
:root {
color-scheme: dark;
--bg: #071018;
--bg-2: #0b1722;
--panel: rgba(15, 30, 43, 0.78);
--panel-solid: #0f1e2b;
--line: rgba(148, 188, 205, 0.18);
--line-strong: rgba(148, 188, 205, 0.34);
--text: #e8f2f4;
--muted: #8fa8b3;
--accent: #45d6b0;
--accent-2: #f0b35b;
--danger: #f27878;
--shadow: 0 24px 60px rgba(0, 0, 0, 0.32);
--radius: 16px;
--mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
--sans: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
}
html[data-theme="light"] {
color-scheme: light;
--bg: #eef4f3;
--bg-2: #e2ece9;
--panel: rgba(255, 255, 255, 0.84);
--panel-solid: #ffffff;
--line: rgba(20, 58, 64, 0.14);
--line-strong: rgba(20, 58, 64, 0.28);
--text: #10262b;
--muted: #5d7479;
--accent: #087f68;
--accent-2: #a05b09;
--danger: #b42323;
--shadow: 0 20px 50px rgba(20, 58, 64, 0.12);
}
* { box-sizing: border-box; }
html, body { min-height: 100%; }
body {
margin: 0;
font-family: var(--sans);
color: var(--text);
background:
radial-gradient(900px 520px at 12% -8%, rgba(69, 214, 176, 0.15), transparent 60%),
radial-gradient(760px 480px at 95% 0%, rgba(240, 179, 91, 0.12), transparent 58%),
linear-gradient(150deg, var(--bg), var(--bg-2));
background-attachment: fixed;
}
button, input, select { font: inherit; color: inherit; }
button { cursor: pointer; }
h1, h2, p { margin: 0; }
.app {
display: grid;
grid-template-columns: 286px minmax(0, 1fr);
min-height: 100vh;
}
.sidebar {
display: flex;
flex-direction: column;
gap: 22px;
padding: 28px 22px;
border-right: 1px solid var(--line);
background: linear-gradient(180deg, rgba(8, 19, 28, 0.72), rgba(8, 19, 28, 0.36));
backdrop-filter: blur(18px);
position: sticky;
top: 0;
height: 100vh;
}
html[data-theme="light"] .sidebar {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.4));
}
.brand { display: flex; align-items: center; gap: 12px; }
.logo { width: 46px; height: 46px; object-fit: contain; filter: drop-shadow(0 8px 18px rgba(0, 0, 0, 0.25)); }
.brand strong { display: block; font-size: 1.18rem; letter-spacing: 0.01em; }
.brand small { display: block; color: var(--muted); font-size: 0.78rem; margin-top: 2px; }
.side-title {
color: var(--muted);
font-size: 0.7rem;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.nav { display: grid; gap: 6px; }
.nav-item {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 11px 12px;
border: 1px solid transparent;
border-radius: 12px;
background: transparent;
text-align: left;
color: var(--muted);
transition: 160ms ease;
}
.nav-item:hover { border-color: var(--line); background: rgba(69, 214, 176, 0.06); color: var(--text); }
.nav-item.active { border-color: var(--line-strong); background: rgba(69, 214, 176, 0.1); color: var(--text); }
.nav-icon { width: 20px; color: var(--accent); text-align: center; }
.nav-label { display: block; font-size: 0.92rem; font-weight: 600; }
.nav-sub { display: block; font-size: 0.72rem; color: var(--muted); margin-top: 2px; }
.side-foot { margin-top: auto; display: grid; gap: 12px; }
.status-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.pill {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 5px 9px;
border: 1px solid var(--line);
border-radius: 999px;
font-size: 0.72rem;
color: var(--text);
background: rgba(255, 255, 255, 0.03);
}
.pill.muted { color: var(--muted); }
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 12px var(--accent); }
.pill.offline .dot { background: var(--danger); box-shadow: 0 0 12px var(--danger); }
.mono { font-family: var(--mono); font-size: 0.75rem; color: var(--muted); }
.main { padding: 38px clamp(22px, 4vw, 56px) 42px; min-width: 0; }
.topbar { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 26px; }
.eyebrow { color: var(--accent); font-size: 0.72rem; letter-spacing: 0.2em; text-transform: uppercase; margin-bottom: 8px; }
h1 { font-size: clamp(1.9rem, 3.3vw, 3.1rem); line-height: 1.04; letter-spacing: -0.035em; }
.sub { color: var(--muted); margin-top: 10px; font-size: 0.98rem; }
h2 { font-size: 1.08rem; letter-spacing: -0.01em; }
.icon-btn {
display: inline-grid;
place-items: center;
width: 40px;
height: 40px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--panel);
color: var(--text);
font-size: 1.05rem;
}
.icon-btn:hover { border-color: var(--line-strong); }
.notice {
display: flex;
gap: 10px;
align-items: baseline;
padding: 14px 16px;
margin-bottom: 14px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--panel);
color: var(--muted);
font-size: 0.86rem;
line-height: 1.5;
}
.notice strong { color: var(--text); white-space: nowrap; }
.notice-warn { border-color: rgba(240, 179, 91, 0.34); background: rgba(240, 179, 91, 0.07); }
.pillars {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
margin: 24px 0;
}
.card {
display: flex;
flex-direction: column;
gap: 9px;
min-height: 178px;
padding: 20px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: linear-gradient(160deg, var(--panel), rgba(15, 30, 43, 0.36));
box-shadow: var(--shadow);
}
html[data-theme="light"] .card { background: linear-gradient(160deg, var(--panel), rgba(255, 255, 255, 0.5)); }
.card-kicker { color: var(--accent); font-size: 0.68rem; letter-spacing: 0.18em; text-transform: uppercase; }
.card h2 { font-size: 1.02rem; }
.card p { color: var(--muted); font-size: 0.83rem; line-height: 1.5; }
.tag { margin-top: auto; align-self: flex-start; color: var(--accent-2); font-size: 0.68rem; letter-spacing: 0.08em; text-transform: uppercase; }
.panel {
margin-top: 16px;
padding: 22px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel);
box-shadow: var(--shadow);
scroll-margin-top: 20px;
}
.panel-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 18px; }
.hint { color: var(--muted); font-size: 0.84rem; line-height: 1.5; margin-top: 5px; }
.field-row { display: flex; align-items: flex-end; gap: 10px; }
.field { display: grid; gap: 7px; flex: 1; }
.field > span { color: var(--muted); font-size: 0.74rem; letter-spacing: 0.08em; text-transform: uppercase; }
input, select {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--line-strong);
border-radius: 10px;
background: rgba(0, 0, 0, 0.16);
outline: none;
}
html[data-theme="light"] input, html[data-theme="light"] select { background: rgba(255, 255, 255, 0.7); }
input:focus, select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(69, 214, 176, 0.12); }
.btn {
padding: 10px 14px;
border: 1px solid var(--line-strong);
border-radius: 10px;
background: rgba(255, 255, 255, 0.04);
color: var(--text);
font-size: 0.84rem;
font-weight: 600;
white-space: nowrap;
}
.btn:hover { border-color: var(--accent); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #06211b; }
.btn.primary:hover { filter: brightness(1.06); }
.btn:disabled { opacity: 0.5; cursor: progress; }
.link-btn { border: 0; background: transparent; color: var(--accent); font-size: 0.75rem; padding: 0; }
.link-btn:hover { text-decoration: underline; }
.output {
min-height: 92px;
margin: 16px 0 0;
padding: 14px;
overflow: auto;
border: 1px solid var(--line);
border-radius: 10px;
background: rgba(0, 0, 0, 0.2);
color: var(--muted);
font-family: var(--mono);
font-size: 0.76rem;
line-height: 1.55;
white-space: pre-wrap;
}
html[data-theme="light"] .output { background: rgba(20, 58, 64, 0.04); }
.alert-list { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
.alert-list li {
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: 10px;
background: rgba(255, 255, 255, 0.02);
font-size: 0.84rem;
}
.alert-list .empty { color: var(--muted); border-style: dashed; }
.alert-meta { display: flex; justify-content: space-between; gap: 12px; margin-bottom: 4px; }
.alert-meta strong { font-size: 0.78rem; color: var(--accent-2); }
.alert-summary { color: var(--muted); line-height: 1.5; }
.foot {
display: flex;
justify-content: space-between;
gap: 16px;
margin-top: 26px;
color: var(--muted);
font-size: 0.74rem;
}
dialog {
width: min(460px, calc(100vw - 32px));
padding: 22px;
border: 1px solid var(--line-strong);
border-radius: var(--radius);
background: var(--panel-solid);
color: var(--text);
box-shadow: var(--shadow);
}
dialog::backdrop { background: rgba(2, 8, 12, 0.62); backdrop-filter: blur(3px); }
.dialog-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; }
dialog .field { margin-bottom: 14px; }
dialog menu { display: flex; justify-content: flex-end; gap: 8px; margin: 20px 0 0; padding: 0; }
.segmented { display: flex; gap: 4px; padding: 3px; border: 1px solid var(--line); border-radius: 10px; }
.segmented button { flex: 1; padding: 7px 8px; border: 0; border-radius: 7px; background: transparent; color: var(--muted); font-size: 0.78rem; }
.segmented button.active { background: rgba(69, 214, 176, 0.16); color: var(--text); }
@media (max-width: 1120px) {
.pillars { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 820px) {
.app { grid-template-columns: 1fr; }
.sidebar { position: static; height: auto; border-right: 0; border-bottom: 1px solid var(--line); }
.nav { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.side-foot { margin-top: 0; }
.main { padding-top: 26px; }
}
@media (max-width: 560px) {
.pillars { grid-template-columns: 1fr; }
.field-row { flex-direction: column; align-items: stretch; }
.notice { flex-direction: column; gap: 4px; }
.panel-head { flex-direction: column; }
.foot { flex-direction: column; }
}
+222
View File
@@ -0,0 +1,222 @@
import { setLang, tr } from './i18n.js';
const $ = (selector) => document.querySelector(selector);
const tauriInvoke = window.__TAURI__?.core?.invoke;
const connection = $('#connection');
const connectionText = $('#connectionText');
const version = $('#version');
const updateBtn = $('#updateBtn');
const datasetPath = $('#datasetPath');
const analyzeBtn = $('#analyzeBtn');
const refreshBtn = $('#refreshBtn');
const output = $('#output');
const alertList = $('#alertList');
const settingsDlg = $('#settingsDlg');
const settingsBtn = $('#settingsBtn');
const closeSettings = $('#closeSettings');
const doneSettings = $('#doneSettings');
const langSel = $('#langSel');
const themePick = $('#themePick');
let lastAlerts = [];
let themePreference = localStorage.getItem('acaua-theme') || 'auto';
async function fetchJson(path, options = {}) {
const response = await fetch(path, options);
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.error || response.statusText);
return body;
}
async function call(command, args = {}) {
if (tauriInvoke) return tauriInvoke(command, args);
switch (command) {
case 'app_info':
return fetchJson('/api/status');
case 'analyze_dataset':
return fetchJson('/api/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(args),
});
case 'list_alerts':
return fetchJson('/api/alerts');
case 'update_check':
return fetchJson('/api/update');
case 'doctor_report':
return fetchJson('/api/health');
default:
throw new Error(`unsupported command: ${command}`);
}
}
function message(template, ...values) {
let index = 0;
return tr(template).replace(/\{n\}/g, () => values[index++] ?? '');
}
function renderAlerts(alerts) {
lastAlerts = Array.isArray(alerts) ? alerts : [];
alertList.replaceChildren();
if (!lastAlerts.length) {
const empty = document.createElement('li');
empty.className = 'empty';
empty.textContent = tr('Nenhum alerta ainda.');
alertList.append(empty);
return;
}
for (const alert of lastAlerts) {
const item = document.createElement('li');
const meta = document.createElement('div');
meta.className = 'alert-meta';
const pillar = document.createElement('strong');
pillar.textContent = message('Pilar {n}', alert.pillar);
const score = document.createElement('span');
score.className = 'mono';
score.textContent = Number(alert.score).toFixed(3);
meta.append(pillar, score);
const summary = document.createElement('div');
summary.className = 'alert-summary';
summary.setAttribute('data-i18n-skip', '');
summary.textContent = alert.summary;
item.append(meta, summary);
alertList.append(item);
}
}
async function refreshStatus() {
try {
const info = await call('app_info');
version.textContent = `v${info.version}`;
connectionText.textContent = tr('Serviço local ativo');
connection.classList.remove('offline');
} catch (error) {
connectionText.textContent = tr('Serviço indisponível');
connection.classList.add('offline');
version.textContent = '—';
console.error(error);
}
}
async function refreshAlerts() {
try {
renderAlerts(await call('list_alerts'));
} catch (error) {
console.error(error);
}
}
async function analyzeDataset() {
const path = datasetPath.value.trim();
if (!path) {
output.textContent = tr('Nenhum dataset selecionado.');
return;
}
analyzeBtn.disabled = true;
output.textContent = tr('Analisando…');
try {
const report = await call('analyze_dataset', { path });
output.textContent = message(
'Análise concluída: {n} registros e {n} alertas.',
report.records,
report.alerts.length,
);
await refreshAlerts();
} catch (error) {
output.textContent = message('Falha na análise: {n}', error.message);
} finally {
analyzeBtn.disabled = false;
}
}
async function checkUpdate() {
updateBtn.disabled = true;
updateBtn.textContent = tr('Verificando atualização…');
try {
const status = await call('update_check');
updateBtn.textContent = status.update_available
? message('Atualização {n} disponível.', status.latest_version)
: message('Versão {n} é a mais recente.', status.current_version);
} catch (error) {
updateBtn.title = message('Não foi possível verificar a atualização: {n}', error.message);
updateBtn.textContent = tr('checar atualização');
} finally {
updateBtn.disabled = false;
window.setTimeout(() => {
updateBtn.textContent = tr('checar atualização');
updateBtn.title = '';
}, 6000);
}
}
function applyTheme(preference) {
themePreference = preference;
localStorage.setItem('acaua-theme', preference);
const light = window.matchMedia('(prefers-color-scheme: light)').matches;
const effective = preference === 'auto' ? (light ? 'light' : 'dark') : preference;
document.documentElement.dataset.theme = effective;
themePick.querySelectorAll('[data-theme-pick]').forEach((button) => {
button.classList.toggle('active', button.dataset.themePick === preference);
});
}
function openSettings() {
langSel.value = localStorage.getItem('acaua-lang') || 'auto';
settingsDlg.showModal();
}
function closeDialog() {
settingsDlg.close();
}
function setActiveNav(target) {
document.querySelectorAll('.nav-item').forEach((button) => {
button.classList.toggle('active', button.dataset.target === target);
});
}
function init() {
applyTheme(themePreference);
langSel.value = localStorage.getItem('acaua-lang') || 'auto';
setLang(langSel.value);
refreshStatus();
refreshAlerts();
analyzeBtn.addEventListener('click', analyzeDataset);
datasetPath.addEventListener('keydown', (event) => {
if (event.key === 'Enter') analyzeDataset();
});
refreshBtn.addEventListener('click', async () => {
await refreshStatus();
await refreshAlerts();
});
updateBtn.addEventListener('click', checkUpdate);
settingsBtn.addEventListener('click', openSettings);
closeSettings.addEventListener('click', closeDialog);
doneSettings.addEventListener('click', closeDialog);
langSel.addEventListener('change', () => {
localStorage.setItem('acaua-lang', langSel.value);
setLang(langSel.value);
refreshStatus();
renderAlerts(lastAlerts);
updateBtn.textContent = tr('checar atualização');
});
themePick.addEventListener('click', (event) => {
const button = event.target.closest('[data-theme-pick]');
if (button) applyTheme(button.dataset.themePick);
});
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => {
if (themePreference === 'auto') applyTheme('auto');
});
document.querySelectorAll('.nav-item').forEach((button) => {
button.addEventListener('click', () => {
const target = button.dataset.target;
setActiveNav(target);
if (target === 'settings') openSettings();
else document.getElementById(target)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
});
}
init();
+66
View File
@@ -0,0 +1,66 @@
export const EN = {
'Acauã': 'Acauã',
'caça a ameaças em rede': 'network threat hunting',
'Navegação': 'Navigation',
'Visão geral': 'Overview',
'painel e estado': 'dashboard and status',
'Alertas': 'Alerts',
'evidências recentes': 'recent evidence',
'Análise': 'Analysis',
'dataset Zeek': 'Zeek dataset',
'Configurações': 'Settings',
'idioma e tema': 'language and theme',
'Conectando…': 'Connecting…',
'esqueleto': 'skeleton',
'checar atualização': 'check for updates',
'Caça a ameaças em rede': 'Network threat hunting',
'Pesquisa determinística, sem IA opaca': 'Deterministic research, no opaque AI',
'Aviso de escopo.': 'Scope notice.',
'Este é um esqueleto pré-implementação. Os quatro pilares matemáticos, o parser Zeek e o armazenamento DuckDB ainda não estão implementados.': 'This is a pre-implementation skeleton. The four mathematical pillars, the Zeek parser and the DuckDB storage are not implemented yet.',
'Uso autorizado.': 'Authorized use.',
'Use somente em redes próprias ou com autorização explícita. Nenhum pacote é enviado para fora.': 'Use only on your own networks or with explicit authorization. No packet leaves the machine.',
'Pilares de detecção': 'Detection pillars',
'Pilar {n}': 'Pillar {n}',
'Pilar 1': 'Pillar 1',
'Periodograma de Rayleigh/Schuster. Em desenvolvimento.': 'Rayleigh/Schuster periodogram. Under development.',
'em desenvolvimento': 'under development',
'Pilar 2': 'Pillar 2',
'Sessões persistentes': 'Persistent sessions',
'Detector de conexões longas. Em desenvolvimento.': 'Long-connection detector. Under development.',
'Pilar 3': 'Pillar 3',
'Anomalias de protocolo': 'Protocol anomalies',
'Features determinísticas por protocolo. Em desenvolvimento.': 'Deterministic per-protocol features. Under development.',
'Pilar 4': 'Pillar 4',
'Fusão de evidências': 'Evidence fusion',
'Jensen-Shannon e prova por alerta. Em desenvolvimento.': 'Jensen-Shannon and per-alert proof. Under development.',
'Analisar dataset': 'Analyze dataset',
'Aponte para um diretório de logs Zeek ou para um dataset local.': 'Point to a Zeek log directory or a local dataset.',
'Diretório de logs': 'Log directory',
'Analisar': 'Analyze',
'Atualizar status': 'Refresh status',
'Nenhuma análise executada.': 'No analysis has run yet.',
'Alertas recentes': 'Recent alerts',
'Cada alerta futuro carregará a evidência determinística que o sustenta.': 'Every future alert will carry the deterministic evidence behind it.',
'Nenhum alerta ainda. O motor de detecção será ligado nas próximas specs.': 'No alerts yet. The detection engine will be enabled in upcoming specs.',
'Acauã · esqueleto pré-implementação': 'Acauã · pre-implementation skeleton',
'Fechar configurações': 'Close settings',
'Fechar': 'Close',
'Idioma': 'Language',
'Tema': 'Theme',
'Claro': 'Light',
'Escuro': 'Dark',
'Navegação principal': 'Main navigation',
'Serviço local ativo': 'Local service online',
'Serviço indisponível': 'Service unavailable',
'Nenhum dataset selecionado.': 'No dataset selected.',
'Analisando…': 'Analyzing…',
'Análise concluída: {n} registros e {n} alertas.': 'Analysis complete: {n} records and {n} alerts.',
'Falha na análise: {n}': 'Analysis failed: {n}',
'Verificando atualização…': 'Checking for updates…',
'Versão {n} é a mais recente.': 'Version {n} is the latest.',
'Atualização {n} disponível.': 'Update {n} available.',
'Não foi possível verificar a atualização: {n}': 'Could not check for updates: {n}',
'Nenhum alerta ainda.': 'No alerts yet.',
'Alertas carregados: {n}': 'Alerts loaded: {n}',
'versão {n}': 'version {n}',
};
+145
View File
@@ -0,0 +1,145 @@
import { EN } from './i18n-en.js';
let lang = 'pt';
export const isEn = () => lang === 'en';
export const currentLang = () => lang;
export const LOC = () => (lang === 'en' ? 'en-US' : 'pt-BR');
export function resolveLang(pref) {
if (pref === 'pt' || pref === 'en') return pref;
return (navigator.language || 'pt').toLowerCase().startsWith('pt') ? 'pt' : 'en';
}
const ATTRS = ['placeholder', 'title', 'aria-label', 'alt', 'data-empty'];
const SKIP = '.ssid, .bssid, .mono, pre, code, input, textarea, script, style, [data-i18n-skip]';
const exact = new Map(Object.entries(EN).filter(([key]) => !key.includes('{n}')));
const patterns = Object.entries(EN)
.filter(([key]) => key.includes('{n}'))
.map(([key, value]) => {
const parts = key.split('{n}').map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
return { re: new RegExp('^' + parts.join('(.+?)') + '$', 's'), to: value };
})
.sort((a, b) => b.re.source.length - a.re.source.length);
const cache = new Map();
export function tr(text) {
if (!isEn()) return text;
const lead = text.match(/^\s*/)[0];
const trail = text.match(/\s*$/)[0];
const core = text.trim();
if (!core) return text;
if (cache.has(core)) return lead + cache.get(core) + trail;
let out = exact.get(core);
if (out === undefined) {
for (const pattern of patterns) {
const match = core.match(pattern.re);
if (match) {
let index = 0;
out = pattern.to.replace(/\{n\}/g, () => tr(match[++index] ?? ''));
break;
}
}
}
if (out === undefined) out = core;
if (cache.size > 5000) cache.clear();
cache.set(core, out);
return lead + out + trail;
}
const original = new WeakMap();
let observer = null;
function translateNode(node) {
if (node.nodeType === 3) {
if (node.parentElement?.closest(SKIP)) return;
const source = original.get(node) ?? node.nodeValue;
const translated = tr(source);
if (translated !== node.nodeValue) {
if (!original.has(node)) original.set(node, source);
node.nodeValue = translated;
}
} else if (node.nodeType === 1) {
if (node.matches?.(SKIP) && node.tagName !== 'INPUT') return;
for (const attribute of ATTRS) {
if (node.hasAttribute?.(attribute)) {
const key = `i18n-${attribute}`;
const source = node[key] ?? node.getAttribute(attribute);
const translated = tr(source);
if (translated !== node.getAttribute(attribute)) {
node[key] = source;
node.setAttribute(attribute, translated);
}
}
}
for (const child of node.childNodes) translateNode(child);
}
}
function restoreAll(root) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let node;
while ((node = walker.nextNode())) {
if (original.has(node)) {
node.nodeValue = original.get(node);
original.delete(node);
}
}
root.querySelectorAll('*').forEach((element) => {
for (const attribute of ATTRS) {
const key = `i18n-${attribute}`;
if (element[key] !== undefined) {
element.setAttribute(attribute, element[key]);
delete element[key];
}
}
});
}
function start() {
translateNode(document.body);
document.title = tr(document.title);
if (observer) return;
observer = new MutationObserver((mutations) => {
observer.disconnect();
for (const mutation of mutations) {
if (mutation.type === 'childList') mutation.addedNodes.forEach(translateNode);
else if (mutation.type === 'characterData') {
original.delete(mutation.target);
translateNode(mutation.target);
} else if (mutation.type === 'attributes') {
delete mutation.target[`i18n-${mutation.attributeName}`];
translateNode(mutation.target);
}
}
observer.observe(document.body, {
childList: true,
subtree: true,
characterData: true,
attributes: true,
attributeFilter: ATTRS,
});
});
observer.observe(document.body, {
childList: true,
subtree: true,
characterData: true,
attributes: true,
attributeFilter: ATTRS,
});
}
function stop() {
observer?.disconnect();
observer = null;
restoreAll(document.body);
}
export function setLang(pref) {
const next = resolveLang(pref);
const changed = next !== lang;
lang = next;
document.documentElement.lang = lang === 'en' ? 'en' : 'pt-BR';
if (lang === 'en') start();
else if (changed || observer) stop();
}
+167
View File
@@ -0,0 +1,167 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Acauã</title>
<link rel="icon" href="/logo.png">
<link rel="stylesheet" href="/app.css">
</head>
<body>
<div class="app">
<aside class="sidebar">
<div class="brand">
<img class="logo" src="/logo.png" alt="">
<div>
<strong>Acauã</strong>
<small>caça a ameaças em rede</small>
</div>
</div>
<div class="side-title">Navegação</div>
<nav class="nav" aria-label="Navegação principal">
<button type="button" class="nav-item active" data-target="overview">
<span class="nav-icon">◈</span>
<span><span class="nav-label">Visão geral</span><span class="nav-sub">painel e estado</span></span>
</button>
<button type="button" class="nav-item" data-target="alerts">
<span class="nav-icon">◎</span>
<span><span class="nav-label">Alertas</span><span class="nav-sub">evidências recentes</span></span>
</button>
<button type="button" class="nav-item" data-target="analysis">
<span class="nav-icon">⌁</span>
<span><span class="nav-label">Análise</span><span class="nav-sub">dataset Zeek</span></span>
</button>
<button type="button" class="nav-item" data-target="settings">
<span class="nav-icon">⚙</span>
<span><span class="nav-label">Configurações</span><span class="nav-sub">idioma e tema</span></span>
</button>
</nav>
<div class="side-foot">
<div class="status-row">
<span id="connection" class="pill"><span class="dot"></span><span id="connectionText">Conectando…</span></span>
<span class="pill muted">esqueleto</span>
</div>
<div class="status-row">
<span id="version" class="mono" data-i18n-skip>—</span>
<button id="updateBtn" type="button" class="link-btn">checar atualização</button>
</div>
</div>
</aside>
<main class="main">
<header class="topbar" id="overview">
<div>
<p class="eyebrow">Acauã</p>
<h1>Caça a ameaças em rede</h1>
<p class="sub">Pesquisa determinística, sem IA opaca</p>
</div>
<button id="settingsBtn" type="button" class="icon-btn" aria-label="Configurações" title="Configurações">⚙</button>
</header>
<section class="notice notice-warn">
<strong>Aviso de escopo.</strong>
<span>Este é um esqueleto pré-implementação. Os quatro pilares matemáticos, o parser Zeek e o armazenamento DuckDB ainda não estão implementados.</span>
</section>
<section class="notice">
<strong>Uso autorizado.</strong>
<span>Use somente em redes próprias ou com autorização explícita. Nenhum pacote é enviado para fora.</span>
</section>
<section class="pillars" aria-label="Pilares de detecção">
<article class="card">
<span class="card-kicker">Pilar 1</span>
<h2>Beaconing</h2>
<p>Periodograma de Rayleigh/Schuster. Em desenvolvimento.</p>
<span class="tag">em desenvolvimento</span>
</article>
<article class="card">
<span class="card-kicker">Pilar 2</span>
<h2>Sessões persistentes</h2>
<p>Detector de conexões longas. Em desenvolvimento.</p>
<span class="tag">em desenvolvimento</span>
</article>
<article class="card">
<span class="card-kicker">Pilar 3</span>
<h2>Anomalias de protocolo</h2>
<p>Features determinísticas por protocolo. Em desenvolvimento.</p>
<span class="tag">em desenvolvimento</span>
</article>
<article class="card">
<span class="card-kicker">Pilar 4</span>
<h2>Fusão de evidências</h2>
<p>Jensen-Shannon e prova por alerta. Em desenvolvimento.</p>
<span class="tag">em desenvolvimento</span>
</article>
</section>
<section class="panel" id="analysis">
<div class="panel-head">
<div>
<h2>Analisar dataset</h2>
<p class="hint">Aponte para um diretório de logs Zeek ou para um dataset local.</p>
</div>
<button id="refreshBtn" type="button" class="btn">Atualizar status</button>
</div>
<div class="field-row">
<label class="field">
<span>Diretório de logs</span>
<input id="datasetPath" type="text" placeholder="/var/log/zeek/current" autocomplete="off" spellcheck="false">
</label>
<button id="analyzeBtn" type="button" class="btn primary">Analisar</button>
</div>
<pre id="output" class="output">Nenhuma análise executada.</pre>
</section>
<section class="panel" id="alerts">
<div class="panel-head">
<div>
<h2>Alertas recentes</h2>
<p class="hint">Cada alerta futuro carregará a evidência determinística que o sustenta.</p>
</div>
</div>
<ul id="alertList" class="alert-list">
<li class="empty">Nenhum alerta ainda. O motor de detecção será ligado nas próximas specs.</li>
</ul>
</section>
<footer class="foot">
<span>Acauã · esqueleto pré-implementação</span>
<span class="mono">MIT OR Apache-2.0</span>
</footer>
</main>
</div>
<dialog id="settingsDlg" aria-labelledby="settingsTitle">
<form method="dialog">
<div class="dialog-head">
<h2 id="settingsTitle">Configurações</h2>
<button type="button" id="closeSettings" class="icon-btn" aria-label="Fechar configurações" title="Fechar">×</button>
</div>
<label class="field">
<span>Idioma</span>
<select id="langSel">
<option value="auto">Auto</option>
<option value="pt">Português</option>
<option value="en">English</option>
</select>
</label>
<div class="field">
<span>Tema</span>
<div class="segmented" id="themePick">
<button type="button" data-theme-pick="auto">Auto</button>
<button type="button" data-theme-pick="light">Claro</button>
<button type="button" data-theme-pick="dark">Escuro</button>
</div>
</div>
<menu>
<button type="button" id="doneSettings" class="btn primary">Fechar</button>
</menu>
</form>
</dialog>
<script type="module" src="/app.js"></script>
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
# Vendor
Third-party browser assets live here and are copied into the application at
build time. Do not add a frontend framework or a package manager dependency for
the interface: the UI stays vanilla HTML, CSS and JavaScript, like Guará and
Sabiá.
Document every vendored file with its project URL, version and license.
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "acaua-cli"
description = "Acauã: network threat hunting daemon and CLI"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
authors.workspace = true
rust-version.workspace = true
[[bin]]
name = "acaua"
path = "src/main.rs"
[dependencies]
acaua-core = { path = "../acaua-core" }
axum = "0.8"
clap = { version = "4", features = ["derive"] }
mime_guess = "2"
rust-embed = "8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal"] }
tower-http = { version = "0.6", features = ["compression-gzip", "cors", "trace"] }
tracing = "0.1"
tracing-subscriber = "0.3"
+107
View File
@@ -0,0 +1,107 @@
mod web;
use acaua_core::{doctor, update, zeek};
use clap::{Parser, Subcommand};
use std::path::PathBuf;
#[derive(Parser)]
#[command(
name = "acaua",
about = "Acauã — caça determinística a ameaças em rede",
version
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Serve {
#[arg(long, default_value = "127.0.0.1:8787")]
bind: String,
#[arg(long, default_value = ".acaua")]
data_dir: PathBuf,
},
Analyze {
directory: PathBuf,
#[arg(long)]
json: bool,
},
Doctor {
#[arg(long)]
json: bool,
},
Update {
#[arg(long)]
apply: bool,
},
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
if let Err(error) = run(cli).await {
eprintln!("erro: {error}");
std::process::exit(1);
}
}
async fn run(cli: Cli) -> acaua_core::Result<()> {
match cli.command {
Command::Serve { bind, data_dir } => web::serve(&bind, data_dir).await,
Command::Analyze { directory, json } => {
let report = zeek::analyze_dir(&directory)?;
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
println!("diretório: {}", report.directory);
println!("logs lidos: {}", report.logs_seen);
println!("registros: {}", report.records);
println!("alertas: {}", report.alerts.len());
println!("estado: {}", report.status);
}
Ok(())
}
Command::Doctor { json } => {
let report = doctor::run();
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
for check in &report.checks {
println!(
"{:<10} {:<5} {}",
check.name,
if check.ok { "ok" } else { "falha" },
check.detail
);
}
println!("estado: {}", report.status);
}
Ok(())
}
Command::Update { apply } => {
if apply {
let executable = std::env::current_exe()?;
let tag = update::apply(&executable)?;
println!("atualizado para {tag}");
} else {
let status = update::check(env!("CARGO_PKG_VERSION"))?;
println!(
"versão atual: {} | última: {}",
status.current_version,
status.latest_version.as_deref().unwrap_or("desconhecida")
);
println!(
"atualização disponível: {}",
if status.update_available {
"sim"
} else {
"não"
}
);
}
Ok(())
}
}
}
+159
View File
@@ -0,0 +1,159 @@
use acaua_core::fusion::Alert;
use acaua_core::storage::{DatasetSummary, Storage};
use acaua_core::update::UpdateStatus;
use acaua_core::zeek::{self, AnalysisReport};
use acaua_core::{update, Result};
use axum::body::Body;
use axum::extract::State;
use axum::http::{header, StatusCode, Uri};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use rust_embed::RustEmbed;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::net::TcpListener;
use tower_http::compression::CompressionLayer;
use tower_http::cors::CorsLayer;
use tower_http::trace::TraceLayer;
const LOGO: &[u8] = include_bytes!("../../../logo.png");
#[derive(RustEmbed)]
#[folder = "../../apps/gui/ui/"]
struct Assets;
#[derive(Clone)]
struct AppState {
storage: Arc<Storage>,
}
#[derive(Serialize)]
struct Health {
status: &'static str,
version: &'static str,
}
#[derive(Serialize)]
struct Status {
version: &'static str,
mode: &'static str,
dataset: DatasetSummary,
}
#[derive(Serialize)]
struct ErrorBody {
error: String,
}
#[derive(Deserialize)]
struct AnalyzeRequest {
path: String,
}
async fn health() -> Json<Health> {
Json(Health {
status: "ok",
version: env!("CARGO_PKG_VERSION"),
})
}
async fn status(State(state): State<AppState>) -> Json<Status> {
Json(Status {
version: env!("CARGO_PKG_VERSION"),
mode: "serve",
dataset: state
.storage
.summary()
.unwrap_or_else(|_| DatasetSummary::empty()),
})
}
async fn alerts() -> Json<Vec<Alert>> {
Json(Vec::new())
}
async fn analyze(
Json(request): Json<AnalyzeRequest>,
) -> std::result::Result<Json<AnalysisReport>, (StatusCode, Json<ErrorBody>)> {
zeek::analyze_dir(&request.path).map(Json).map_err(|error| {
(
StatusCode::BAD_REQUEST,
Json(ErrorBody {
error: error.to_string(),
}),
)
})
}
async fn update_check() -> std::result::Result<Json<UpdateStatus>, (StatusCode, Json<ErrorBody>)> {
update::check(env!("CARGO_PKG_VERSION"))
.map(Json)
.map_err(|error| {
(
StatusCode::BAD_GATEWAY,
Json(ErrorBody {
error: error.to_string(),
}),
)
})
}
async fn update_apply() -> (StatusCode, Json<ErrorBody>) {
(
StatusCode::NOT_IMPLEMENTED,
Json(ErrorBody {
error: "update --apply is only available through the acaua CLI".to_string(),
}),
)
}
async fn logo() -> impl IntoResponse {
([(header::CONTENT_TYPE, "image/png")], Body::from(LOGO))
}
async fn asset(uri: Uri) -> Response {
let path = uri.path().trim_start_matches('/');
let path = if path.is_empty() { "index.html" } else { path };
match Assets::get(path) {
Some(file) => {
let mime = mime_guess::from_path(path).first_or_octet_stream();
Response::builder()
.header(header::CONTENT_TYPE, mime.as_ref())
.body(Body::from(file.data.into_owned()))
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
}
None => (StatusCode::NOT_FOUND, "not found").into_response(),
}
}
async fn shutdown_signal() {
let _ = tokio::signal::ctrl_c().await;
}
pub async fn serve(bind: &str, data_dir: PathBuf) -> Result<()> {
let _ = tracing_subscriber::fmt().try_init();
std::fs::create_dir_all(&data_dir)?;
let state = AppState {
storage: Arc::new(Storage::open(&data_dir)),
};
let app = Router::new()
.route("/api/health", get(health))
.route("/api/status", get(status))
.route("/api/alerts", get(alerts))
.route("/api/analyze", post(analyze))
.route("/api/update", get(update_check).post(update_apply))
.route("/logo.png", get(logo))
.fallback(asset)
.with_state(state)
.layer(CompressionLayer::new())
.layer(CorsLayer::new())
.layer(TraceLayer::new_for_http());
let listener = TcpListener::bind(bind).await?;
println!("Acauã listening on http://{bind}");
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
Ok(())
}
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "acaua-core"
description = "Acauã: deterministic network threat hunting core"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
authors.workspace = true
rust-version.workspace = true
[features]
default = []
duckdb = ["dep:duckdb"]
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
thiserror = "1"
time = { version = "0.3", features = ["formatting", "macros"] }
duckdb = { version = "1", features = ["bundled"], optional = true }
[dev-dependencies]
tempfile = "3"
+61
View File
@@ -0,0 +1,61 @@
use serde::Serialize;
use std::process::Command;
#[derive(Debug, Clone, Serialize)]
pub struct Check {
pub name: &'static str,
pub ok: bool,
pub detail: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct DoctorReport {
pub checks: Vec<Check>,
pub status: &'static str,
}
fn command_available(name: &str, arg: &str) -> Check {
match Command::new(name).arg(arg).output() {
Ok(output) if output.status.success() => Check {
name: if name == "zeek" { "zeek" } else { "runtime" },
ok: true,
detail: format!("{name} is available"),
},
Ok(_) => Check {
name: if name == "zeek" { "zeek" } else { "runtime" },
ok: false,
detail: format!("{name} returned an error"),
},
Err(error) => Check {
name: if name == "zeek" { "zeek" } else { "runtime" },
ok: false,
detail: format!("{name} not found: {error}"),
},
}
}
pub fn run() -> DoctorReport {
let checks = vec![
command_available("zeek", "--version"),
command_available("curl", "--version"),
command_available("tar", "--version"),
Check {
name: "duckdb",
ok: cfg!(feature = "duckdb"),
detail: if cfg!(feature = "duckdb") {
"bundled DuckDB feature is enabled".to_string()
} else {
"bundled DuckDB feature is disabled in this build".to_string()
},
},
Check {
name: "research",
ok: true,
detail: "research workspace is isolated from the product".to_string(),
},
];
DoctorReport {
checks,
status: "skeleton",
}
}
+21
View File
@@ -0,0 +1,21 @@
use std::io;
#[derive(Debug, thiserror::Error)]
pub enum AcauaError {
#[error("invalid input: {0}")]
InvalidInput(String),
#[error("command failed: {cmd}: {detail}")]
CommandFailed { cmd: String, detail: String },
#[error("feature not implemented yet: {0}")]
NotImplemented(&'static str),
#[error("I/O error: {0}")]
Io(#[from] io::Error),
#[error("serialization error: {0}")]
Serde(#[from] serde_json::Error),
}
pub type Result<T> = std::result::Result<T, AcauaError>;
+25
View File
@@ -0,0 +1,25 @@
use crate::error::Result;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Alert {
pub id: String,
pub pillar: u8,
pub score: f64,
pub summary: String,
pub evidence: serde_json::Value,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct FusionReport {
pub alerts: Vec<Alert>,
pub status: &'static str,
}
pub fn fuse(_scores: &[f64]) -> Result<FusionReport> {
Ok(FusionReport {
alerts: Vec::new(),
status: "skeleton",
})
}
+12
View File
@@ -0,0 +1,12 @@
pub mod doctor;
pub mod error;
pub mod fusion;
pub mod pillar1;
pub mod pillar2;
pub mod pillar3;
pub mod pillar4;
pub mod storage;
pub mod update;
pub mod zeek;
pub use error::{AcauaError, Result};
+24
View File
@@ -0,0 +1,24 @@
use crate::error::Result;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BeaconingInput {
pub timestamps: Vec<f64>,
}
#[derive(Debug, Clone, Serialize)]
pub struct BeaconingScore {
pub score: f64,
pub observations: usize,
pub method: &'static str,
pub status: &'static str,
}
pub fn score(input: &BeaconingInput) -> Result<BeaconingScore> {
Ok(BeaconingScore {
score: 0.0,
observations: input.timestamps.len(),
method: "rayleigh-schuster-stub",
status: "skeleton",
})
}
+33
View File
@@ -0,0 +1,33 @@
use crate::error::Result;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SessionInput {
pub durations_secs: Vec<f64>,
pub bytes: Vec<u64>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SessionScore {
pub score: f64,
pub observations: usize,
pub long_connections: usize,
pub bowley_sentinel: Option<f64>,
pub method: &'static str,
pub status: &'static str,
}
pub fn score(input: &SessionInput) -> Result<SessionScore> {
Ok(SessionScore {
score: 0.0,
observations: input.durations_secs.len(),
long_connections: input
.durations_secs
.iter()
.filter(|duration| **duration >= 300.0)
.count(),
bowley_sentinel: None,
method: "long-connection-stub",
status: "skeleton",
})
}
+30
View File
@@ -0,0 +1,30 @@
use crate::error::Result;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProtocolInput {
pub events: usize,
pub protocols: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ProtocolScore {
pub score: f64,
pub observations: usize,
pub distinct_protocols: usize,
pub method: &'static str,
pub status: &'static str,
}
pub fn score(input: &ProtocolInput) -> Result<ProtocolScore> {
let mut protocols = input.protocols.clone();
protocols.sort();
protocols.dedup();
Ok(ProtocolScore {
score: 0.0,
observations: input.events,
distinct_protocols: protocols.len(),
method: "jensen-shannon-stub",
status: "skeleton",
})
}
+26
View File
@@ -0,0 +1,26 @@
use crate::error::Result;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FusionInput {
pub beaconing: f64,
pub sessions: f64,
pub protocol: f64,
}
#[derive(Debug, Clone, Serialize)]
pub struct FusionScore {
pub score: f64,
pub inputs: usize,
pub method: &'static str,
pub status: &'static str,
}
pub fn score(input: &FusionInput) -> Result<FusionScore> {
Ok(FusionScore {
score: 0.0,
inputs: [input.beaconing, input.sessions, input.protocol].len(),
method: "deterministic-fusion-stub",
status: "skeleton",
})
}
+49
View File
@@ -0,0 +1,49 @@
use crate::error::Result;
use serde::Serialize;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize)]
pub struct DatasetSummary {
pub records: u64,
pub partitions: usize,
pub ttl_days: u32,
pub backend: &'static str,
pub status: &'static str,
}
impl DatasetSummary {
pub fn empty() -> Self {
Self {
records: 0,
partitions: 0,
ttl_days: 7,
backend: "duckdb-parquet",
status: "skeleton",
}
}
}
#[derive(Debug, Clone)]
pub struct Storage {
root: PathBuf,
}
impl Storage {
pub fn open(root: impl AsRef<Path>) -> Self {
Self {
root: root.as_ref().to_path_buf(),
}
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn summary(&self) -> Result<DatasetSummary> {
Ok(DatasetSummary::empty())
}
pub fn prune_expired(&self) -> Result<usize> {
Ok(0)
}
}
+594
View File
@@ -0,0 +1,594 @@
use crate::error::{AcauaError, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::ffi::OsStr;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
const LATEST_RELEASE_URL: &str =
"https://git.lucasrguerra.dev.br/api/v1/repos/ciberfauna/acaua/releases/latest";
const RELEASES_PAGE_URL: &str = "https://git.lucasrguerra.dev.br/ciberfauna/acaua/releases";
#[derive(Debug, Deserialize)]
struct Release {
tag_name: String,
assets: Vec<Asset>,
}
#[derive(Debug, Deserialize)]
struct Asset {
name: String,
browser_download_url: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct UpdateStatus {
pub current_version: String,
pub latest_version: Option<String>,
pub update_available: bool,
}
fn curl_text(url: &str) -> Result<String> {
let output = Command::new("curl")
.args(["-fsSL", "-H", "User-Agent: acaua-updater", url])
.output()
.map_err(|error| AcauaError::CommandFailed {
cmd: format!("curl {url}"),
detail: error.to_string(),
})?;
if !output.status.success() {
return Err(AcauaError::CommandFailed {
cmd: format!("curl {url}"),
detail: String::from_utf8_lossy(&output.stderr).into_owned(),
});
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn curl_download(url: &str, destination: &Path) -> Result<()> {
let status = Command::new("curl")
.args(["-fsSL", "-H", "User-Agent: acaua-updater", "-o"])
.arg(destination)
.arg(url)
.status()
.map_err(|error| AcauaError::CommandFailed {
cmd: format!("curl {url}"),
detail: error.to_string(),
})?;
if !status.success() {
return Err(AcauaError::CommandFailed {
cmd: format!("curl {url}"),
detail: "download failed".to_string(),
});
}
Ok(())
}
fn asset_suffix_for_platform() -> String {
let os = if cfg!(target_os = "linux") {
"linux"
} else if cfg!(target_os = "macos") {
"macos"
} else {
"windows"
};
let arch = if cfg!(target_arch = "x86_64") {
"x86_64"
} else {
"aarch64"
};
let extension = if cfg!(windows) { "zip" } else { "tar.gz" };
format!("-{os}-{arch}.{extension}")
}
pub fn check(current_version: &str) -> Result<UpdateStatus> {
let body = match curl_text(LATEST_RELEASE_URL) {
Ok(body) => body,
Err(_) => {
return Ok(UpdateStatus {
current_version: current_version.to_string(),
latest_version: None,
update_available: false,
});
}
};
let release: Release = serde_json::from_str(&body)?;
let latest = release.tag_name.trim_start_matches('v').to_string();
let update_available = latest != current_version;
Ok(UpdateStatus {
current_version: current_version.to_string(),
latest_version: Some(latest),
update_available,
})
}
pub fn apply(current_exe: &Path) -> Result<String> {
let is_cli = current_exe
.file_stem()
.map(|stem| stem == "acaua")
.unwrap_or(false);
if !is_cli {
return Err(AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: format!(
"automatic updates only replace the acaua CLI; download the desktop installer from {RELEASES_PAGE_URL}"
),
});
}
let body = curl_text(LATEST_RELEASE_URL)?;
let release: Release = serde_json::from_str(&body)?;
let suffix = asset_suffix_for_platform();
let asset = release
.assets
.iter()
.find(|asset| asset.name.ends_with(&suffix))
.ok_or_else(|| AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: format!(
"no asset ending in `{suffix}` in release {}",
release.tag_name
),
})?;
let checksum_asset = release
.assets
.iter()
.find(|candidate| candidate.name == format!("{}.sha256", asset.name));
let temp_dir = std::env::temp_dir().join("acaua-update");
fs::create_dir_all(&temp_dir)?;
let archive_path = temp_dir.join(&asset.name);
curl_download(&asset.browser_download_url, &archive_path)?;
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 data = fs::read(&archive_path)?;
let mut hasher = Sha256::new();
hasher.update(&data);
let actual_hash = format!("{:x}", hasher.finalize());
if actual_hash != expected_hash {
return Err(AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: "SHA-256 checksum mismatch; update discarded".to_string(),
});
}
}
let extract_dir = temp_dir.join("extracted");
let _ = fs::remove_dir_all(&extract_dir);
fs::create_dir_all(&extract_dir)?;
let status = Command::new("tar")
.arg("xf")
.arg(&archive_path)
.arg("-C")
.arg(&extract_dir)
.status()
.map_err(|error| AcauaError::CommandFailed {
cmd: "tar xf".to_string(),
detail: error.to_string(),
})?;
if !status.success() {
return Err(AcauaError::CommandFailed {
cmd: "tar xf".to_string(),
detail: "extraction failed".to_string(),
});
}
let new_binary = find_binary(&extract_dir)?;
let backup = current_exe.with_extension("bak");
let _ = fs::remove_file(&backup);
fs::rename(current_exe, &backup)?;
fs::copy(&new_binary, current_exe)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut permissions = fs::metadata(current_exe)?.permissions();
permissions.set_mode(0o755);
fs::set_permissions(current_exe, permissions)?;
}
Ok(release.tag_name)
}
fn find_binary(directory: &Path) -> Result<PathBuf> {
fn is_binary(name: &OsStr) -> bool {
name == "acaua" || name == "acaua.exe"
}
for entry in fs::read_dir(directory)? {
let entry = entry?;
if is_binary(&entry.file_name()) {
return Ok(entry.path());
}
if entry.file_type().map(|kind| kind.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(AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: "binary not found in the downloaded package".to_string(),
})
}
/// Desktop GUI installation type detected at runtime.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DesktopInstall {
/// Linux AppImage: real filesystem path of the .AppImage file (from APPIMAGE env var).
AppImage(PathBuf),
/// Linux Debian package installed via dpkg: `current_exe` is reliable here.
Deb,
/// Windows NSIS installer: downloaded setup executable run with `/S`
/// (silent). Does not relaunch the app on its own — see the comment on
/// the `WindowsInstaller` apply path.
WindowsInstaller,
}
/// Detects the desktop installation type given an optional `APPIMAGE` environment
/// variable value and the current executable path.
pub fn detect_desktop_install_from(
appimage_env: Option<&std::ffi::OsStr>,
current_exe: &Path,
) -> Result<DesktopInstall> {
#[cfg(windows)]
{
let _ = (appimage_env, current_exe);
Ok(DesktopInstall::WindowsInstaller)
}
#[cfg(target_os = "linux")]
{
if let Some(val) = appimage_env {
let path = PathBuf::from(val);
if path.is_file() {
return Ok(DesktopInstall::AppImage(path));
}
}
if current_exe.is_file()
&& current_exe
.extension()
.and_then(|e| e.to_str())
.map(|e| e.eq_ignore_ascii_case("appimage"))
.unwrap_or(false)
{
return Ok(DesktopInstall::AppImage(current_exe.to_path_buf()));
}
Ok(DesktopInstall::Deb)
}
#[cfg(not(any(windows, target_os = "linux")))]
{
let _ = (appimage_env, current_exe);
Err(AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: format!(
"automatic self-update is not supported on this platform; download manually from {RELEASES_PAGE_URL}"
),
})
}
}
/// Detects the desktop installation type using the live environment.
pub fn detect_desktop_install() -> Result<DesktopInstall> {
let exe = std::env::current_exe()?;
detect_desktop_install_from(std::env::var_os("APPIMAGE").as_deref(), &exe)
}
/// Returns the expected release asset suffix for a desktop installation and CPU architecture.
pub fn gui_asset_suffix_for(install: &DesktopInstall, arch: &str) -> Result<&'static str> {
match install {
DesktopInstall::AppImage(_) => match arch {
"x86_64" => Ok("_amd64.AppImage"),
"aarch64" => Ok("_aarch64.AppImage"),
other => Err(AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: format!("unsupported architecture for AppImage: {other}"),
}),
},
DesktopInstall::Deb => match arch {
"x86_64" => Ok("_amd64.deb"),
"aarch64" => Ok("_arm64.deb"),
other => Err(AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: format!("unsupported architecture for deb package: {other}"),
}),
},
DesktopInstall::WindowsInstaller => match arch {
"x86_64" => Ok("_x64-setup.exe"),
other => Err(AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: format!("unsupported architecture for Windows installer: {other}"),
}),
},
}
}
/// Returns the expected release asset suffix for the current architecture.
pub fn gui_asset_suffix(install: &DesktopInstall) -> Result<&'static str> {
gui_asset_suffix_for(install, std::env::consts::ARCH)
}
/// Downloads, verifies, and installs the latest desktop GUI release in place.
pub fn apply_desktop(current_exe: &Path) -> Result<String> {
apply_desktop_with_progress(current_exe, |_| {})
}
/// Downloads, verifies, and installs the latest desktop GUI release in place,
/// notifying the progress callback of phase transitions (`"downloading"`,
/// `"verifying"`, `"installing"`, `"restarting"`).
pub fn apply_desktop_with_progress<F>(current_exe: &Path, on_progress: F) -> Result<String>
where
F: Fn(&str),
{
let install =
detect_desktop_install_from(std::env::var_os("APPIMAGE").as_deref(), current_exe)?;
let body = curl_text(LATEST_RELEASE_URL)?;
let release: Release = serde_json::from_str(&body)?;
let suffix = gui_asset_suffix(&install)?;
let asset = release
.assets
.iter()
.find(|asset| asset.name.ends_with(suffix))
.ok_or_else(|| AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: format!(
"no asset ending with `{suffix}` in release {}",
release.tag_name
),
})?;
let checksum_asset = release
.assets
.iter()
.find(|asset| asset.name == format!("{}.sha256", asset.name));
let tmp_dir = std::env::temp_dir().join("acaua-desktop-update");
fs::create_dir_all(&tmp_dir)?;
let download_path = tmp_dir.join(&asset.name);
on_progress("downloading");
curl_download(&asset.browser_download_url, &download_path)?;
if let Some(checksum_asset) = checksum_asset {
on_progress("verifying");
let expected = curl_text(&checksum_asset.browser_download_url)?;
let expected_hash = expected
.split_whitespace()
.next()
.unwrap_or("")
.to_lowercase();
let data = fs::read(&download_path)?;
let mut hasher = Sha256::new();
hasher.update(&data);
let actual_hash: String = hasher
.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
if actual_hash != expected_hash {
return Err(AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: "SHA-256 checksum mismatch — update discarded".to_string(),
});
}
}
on_progress("installing");
match install {
DesktopInstall::AppImage(target_appimage) => {
#[cfg(target_os = "linux")]
{
let parent_dir = target_appimage.parent().unwrap_or_else(|| Path::new("."));
let temp_name = format!(
".{}.tmp-{}",
target_appimage
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("appimage"),
std::process::id()
);
let temp_file = parent_dir.join(temp_name);
fs::copy(&download_path, &temp_file)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&temp_file)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(&temp_file, perms)?;
}
fs::rename(&temp_file, &target_appimage)?;
let _ = fs::remove_file(&download_path);
on_progress("restarting");
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
let err = Command::new(&target_appimage)
.args(std::env::args().skip(1))
.exec();
return Err(AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: format!("failed to relaunch AppImage: {err}"),
});
}
}
#[cfg(not(target_os = "linux"))]
{
let _ = (&target_appimage, &download_path);
return Err(AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: "AppImage updates are only supported on Linux".to_string(),
});
}
}
DesktopInstall::Deb => {
#[cfg(target_os = "linux")]
{
let status = Command::new("dpkg")
.args(["-i"])
.arg(&download_path)
.status()
.map_err(|e| AcauaError::CommandFailed {
cmd: "dpkg -i".to_string(),
detail: e.to_string(),
})?;
if !status.success() {
return Err(AcauaError::CommandFailed {
cmd: "dpkg -i".to_string(),
detail: format!("dpkg -i failed with exit code {:?}", status.code()),
});
}
let _ = fs::remove_file(&download_path);
on_progress("restarting");
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
let err = Command::new(current_exe)
.args(std::env::args().skip(1))
.exec();
return Err(AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: format!("failed to relaunch updated app: {err}"),
});
}
}
#[cfg(not(target_os = "linux"))]
{
let _ = (&download_path, current_exe);
return Err(AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: "deb updates are only supported on Linux".to_string(),
});
}
}
DesktopInstall::WindowsInstaller => {
#[cfg(windows)]
{
// `/S` is the one NSIS silent-install switch with a documented,
// stable meaning. There is no standard NSIS or Tauri flag that
// makes a silent install relaunch the app afterwards — that was
// claimed in an earlier draft of this code and could not be
// verified against NSIS's or Tauri's documentation; passing an
// invented flag to a stock NSIS installer is silently ignored,
// not harmful, but also not a relaunch mechanism. Spawning
// detached and exiting immediately (instead of waiting on the
// child) also avoids a self-deadlock: if the installer's script
// waits for this process to close before it can overwrite the
// running .exe, blocking here on the installer's exit status
// would hang forever. The practical effect: the update installs
// silently, but the app does not reopen itself — the user needs
// to start it again. This needs real-world confirmation on
// Windows before relying on it; if testing shows the bundled
// NSIS template does support an explicit relaunch flag or step,
// wire it in here instead of this comment.
on_progress("restarting");
Command::new(&download_path)
.arg("/S")
.spawn()
.map_err(|e| AcauaError::CommandFailed {
cmd: "installer".to_string(),
detail: format!("failed to launch installer: {e}"),
})?;
std::process::exit(0);
}
#[cfg(not(windows))]
{
let _ = &download_path;
return Err(AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: "Windows installer updates are only supported on Windows".to_string(),
});
}
}
}
#[allow(unreachable_code)]
Ok(release.tag_name)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn apply_refuses_to_overwrite_the_desktop_app() {
let error = apply(Path::new("/opt/acaua/acaua-gui")).unwrap_err();
assert!(error.to_string().contains("CLI"), "{error}");
assert!(error.to_string().contains(RELEASES_PAGE_URL), "{error}");
}
#[test]
fn detect_desktop_install_variants() {
#[cfg(target_os = "linux")]
{
let temp_appimage =
std::env::temp_dir().join(format!("test-acaua-{}.AppImage", std::process::id()));
let _ = fs::write(&temp_appimage, b"dummy appimage");
let res = detect_desktop_install_from(
Some(temp_appimage.as_os_str()),
Path::new("/usr/bin/acaua-gui"),
)
.unwrap();
assert_eq!(res, DesktopInstall::AppImage(temp_appimage.clone()));
let res_fallback = detect_desktop_install_from(None, &temp_appimage).unwrap();
assert_eq!(
res_fallback,
DesktopInstall::AppImage(temp_appimage.clone())
);
let _ = fs::remove_file(&temp_appimage);
let res_deb =
detect_desktop_install_from(None, Path::new("/usr/bin/acaua-gui")).unwrap();
assert_eq!(res_deb, DesktopInstall::Deb);
}
#[cfg(windows)]
{
let res = detect_desktop_install_from(
None,
Path::new("C:/Program Files/Acaua/acaua-gui.exe"),
)
.unwrap();
assert_eq!(res, DesktopInstall::WindowsInstaller);
}
}
#[test]
fn gui_asset_suffix_matches_known_release_naming() {
let appimage = DesktopInstall::AppImage(PathBuf::from("/tmp/Acaua.AppImage"));
assert_eq!(
gui_asset_suffix_for(&appimage, "x86_64").unwrap(),
"_amd64.AppImage"
);
assert_eq!(
gui_asset_suffix_for(&appimage, "aarch64").unwrap(),
"_aarch64.AppImage"
);
let deb = DesktopInstall::Deb;
assert_eq!(gui_asset_suffix_for(&deb, "x86_64").unwrap(), "_amd64.deb");
assert_eq!(gui_asset_suffix_for(&deb, "aarch64").unwrap(), "_arm64.deb");
let win = DesktopInstall::WindowsInstaller;
assert_eq!(
gui_asset_suffix_for(&win, "x86_64").unwrap(),
"_x64-setup.exe"
);
assert!(gui_asset_suffix_for(&appimage, "mips").is_err());
assert!(gui_asset_suffix_for(&deb, "riscv64").is_err());
assert!(gui_asset_suffix_for(&win, "arm").is_err());
}
}
+59
View File
@@ -0,0 +1,59 @@
use crate::error::{AcauaError, Result};
use crate::fusion::Alert;
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ZeekLogKind {
Conn,
Dns,
Ssl,
SmbMapping,
DceRpc,
Rdp,
Ssh,
Kerberos,
Ntlm,
Unknown,
}
#[derive(Debug, Clone, Serialize)]
pub struct AnalysisReport {
pub directory: String,
pub logs_seen: usize,
pub records: u64,
pub alerts: Vec<Alert>,
pub status: &'static str,
}
pub fn analyze_dir(directory: impl AsRef<Path>) -> Result<AnalysisReport> {
let directory = directory.as_ref();
if !directory.is_dir() {
return Err(AcauaError::InvalidInput(format!(
"{} is not a directory",
directory.display()
)));
}
Ok(AnalysisReport {
directory: directory.display().to_string(),
logs_seen: 0,
records: 0,
alerts: Vec::new(),
status: "skeleton",
})
}
pub fn classify_log(name: &str) -> ZeekLogKind {
match name {
"conn.log" => ZeekLogKind::Conn,
"dns.log" => ZeekLogKind::Dns,
"ssl.log" => ZeekLogKind::Ssl,
"smb_mapping.log" => ZeekLogKind::SmbMapping,
"dce_rpc.log" => ZeekLogKind::DceRpc,
"rdp.log" => ZeekLogKind::Rdp,
"ssh.log" => ZeekLogKind::Ssh,
"kerberos.log" => ZeekLogKind::Kerberos,
"ntlm.log" => ZeekLogKind::Ntlm,
_ => ZeekLogKind::Unknown,
}
}
+70
View File
@@ -0,0 +1,70 @@
use acaua_core::{doctor, fusion, pillar1, pillar2, pillar3, pillar4, storage, zeek, AcauaError};
#[test]
fn pillar1_returns_a_skeleton_score() {
let input = pillar1::BeaconingInput {
timestamps: vec![1.0, 2.0, 3.0],
};
let score = pillar1::score(&input).unwrap();
assert_eq!(score.observations, 3);
assert_eq!(score.status, "skeleton");
}
#[test]
fn pillar2_counts_long_connections_without_panicking() {
let input = pillar2::SessionInput {
durations_secs: vec![1.0, 400.0],
bytes: vec![10, 20],
};
let score = pillar2::score(&input).unwrap();
assert_eq!(score.long_connections, 1);
assert_eq!(score.status, "skeleton");
}
#[test]
fn pillar3_handles_an_empty_protocol_list() {
let score = pillar3::score(&pillar3::ProtocolInput::default()).unwrap();
assert_eq!(score.distinct_protocols, 0);
assert_eq!(score.status, "skeleton");
}
#[test]
fn pillar4_fuses_three_inputs() {
let score = pillar4::score(&pillar4::FusionInput::default()).unwrap();
assert_eq!(score.inputs, 3);
assert_eq!(score.status, "skeleton");
}
#[test]
fn fusion_returns_no_alerts_yet() {
let report = fusion::fuse(&[0.1, 0.2]).unwrap();
assert!(report.alerts.is_empty());
assert_eq!(report.status, "skeleton");
}
#[test]
fn storage_reports_the_reserved_backend() {
let store = storage::Storage::open(std::env::temp_dir());
let summary = store.summary().unwrap();
assert_eq!(summary.ttl_days, 7);
assert_eq!(summary.records, 0);
}
#[test]
fn zeek_rejects_a_missing_directory() {
let error = zeek::analyze_dir("/definitely/not/a/zeek/directory").unwrap_err();
assert!(matches!(error, AcauaError::InvalidInput(_)));
}
#[test]
fn zeek_classifies_known_logs() {
assert_eq!(zeek::classify_log("dns.log"), zeek::ZeekLogKind::Dns);
assert_eq!(zeek::classify_log("other.log"), zeek::ZeekLogKind::Unknown);
}
#[test]
fn doctor_reports_checks() {
let report = doctor::run();
assert!(!report.checks.is_empty());
assert_eq!(report.status, "skeleton");
}
+23
View File
@@ -0,0 +1,23 @@
services:
zeek:
image: zeek/zeek:latest
network_mode: host
cap_add:
- NET_RAW
- NET_ADMIN
environment:
ZEEK_INTERFACE: ${ZEEK_INTERFACE:-eth0}
command: ["zeek", "-i", "${ZEEK_INTERFACE:-eth0}", "Log::default_logdir=/logs"]
volumes:
- ./data/zeek:/logs
restart: unless-stopped
acaua:
build: .
command: ["serve", "--bind", "0.0.0.0:8787", "--data-dir", "/data/acaua"]
ports:
- "127.0.0.1:8787:8787"
volumes:
- ./data/zeek:/data/zeek:ro
- ./data/acaua:/data/acaua
restart: unless-stopped
+177
View File
@@ -0,0 +1,177 @@
# Architecture
**English** · [Português](pt-BR/arquitetura.md)
Acauã is a single Rust product: one daemon/CLI, one shared core, one embedded
web UI and one Tauri shell. The shipped runtime has no Python and no external
database. This document records the structural decisions and the six
corrections applied to the original blueprint.
## Stack
| Layer | Choice | Why |
|---|---|---|
| Core, CLI and daemon | Rust 2021 | One toolchain, memory safety, no runtime dependency |
| HTTP API | Axum + Tokio + Tower HTTP | Small async surface, easy to embed |
| UI | Vanilla HTML/CSS/JS | No frontend build step, assets embedded in the binary |
| Desktop | Tauri 2 | Native window over the same UI |
| Analytical store | DuckDB + Parquet (optional `duckdb` feature) | Embedded, columnar, local-first |
| Scientific validation | Python in `research/` only | astropy/scipy/networkx for fixtures, never shipped |
The workspace has two crates: `acaua-core` and `acaua-cli`. The GUI lives in
`apps/gui`, **outside** the Cargo workspace, as in Guará and Sabiá. The spec
mentioned `version.workspace = true` for the desktop manifest; keeping the GUI
outside the workspace makes that impossible, so `check.sh` enforces SemVer
equality across `Cargo.toml`, `acaua-cli/Cargo.toml`, the desktop manifest and
`tauri.conf.json` instead. This is a deliberate deviation, not an oversight.
## Components
```
Zeek logs ──► acaua-core::zeek ──► normalized events ──► storage
│
├──► pillar1 (beaconing)
├──► pillar2 (persistent sessions)
├──► pillar3 (protocol anomalies)
└──► pillar4 (fusion) ──► alerts + evidence
│
acaua-cli ──► axum API ──► embedded UI ◄── Tauri shell ─────────────┘
```
- `zeek`: bounded parser and path confinement. It must never follow a symlink
outside the dataset root.
- `storage`: DuckDB/Parquet writer with a seven-day TTL. Disabled by default so
the skeleton does not require a C++ build.
- `pillar1`–`pillar4`: pure functions over normalized events. They return
scores plus evidence, never only a float.
- `fusion`: fixed-order, bounded combination. It must be symmetric and
reproducible.
- `doctor`: local environment checks; no network calls except an explicit
update check.
- `update`: Gitea Releases client for the CLI only.
## The six corrections
### 1. Rayleigh/Schuster, not pure Lomb–Scargle
Event timestamps are a one-dimensional point process: there is no `y` value to
fit. The primary periodogram is therefore Rayleigh/Schuster:
```text
P(f) = |sum_k exp(2*pi*i*f*t_k)|^2 / N
```
Generalized Lomb–Scargle is used only when a real `y` exists (for example bytes per bucket).
This avoids inventing a dependent variable and keeps the null distribution
honest.
### 2. Bowley skew with an explicit sentinel
The Bowley coefficient uses quartiles. When `Q3 == Q1`, the denominator is zero;
the implementation returns a documented sentinel (`0.0` with an
`degenerate_quartiles` evidence flag) instead of `NaN`, infinity or a panic. The
sentinel is part of the alert record, not a silent fallback.
### 3. Jensen–Shannon, not raw KL
KL diverges when the reference distribution has a zero where the observed
distribution does not. Fusion uses Jensen–Shannon, which is symmetric, bounded
and defined for zero-probability bins with a small documented smoothing floor.
This makes the score comparable across pillars and safe for sparse protocol
histograms.
### 4. Persistent sessions get their own detector
A connection that stays open for hours never produces a short inter-arrival
time, so a beaconing detector can miss it entirely. Pillar 2 scores duration,
idle gaps and repeated long-lived flows directly. The two pillars may both fire;
fusion treats that as separate evidence, not double counting of the same signal.
### 5. JA4 license caution
The client TLS JA4 fingerprint is usable. JA4S, JA4H, JA4X, JA4SSH and JA4L
carry a non-OSI license and are **not** enabled by default. Any future use needs
an explicit licensing decision and a documented opt-in. The default feature set
must remain redistributable under MIT/Apache-2.0.
### 6. Determinism and per-alert JSON evidence
Every alert carries a JSON record: dataset hash, code version, pillar, inputs,
intermediate values, sentinels, weights and final score. Reductions run in a
fixed order; no hash-map iteration or thread scheduling may change a score.
Given the same input and version, the evidence must be byte-identical.
## Data flow
1. `acaua analyze <dir>` validates the directory, reads supported logs and
normalizes events. In this skeleton it validates and returns a stub report.
2. Normalized events are written to the local store (when the `duckdb` feature
is enabled) with a seven-day TTL.
3. Each pillar runs over a deterministic window and returns candidate scores
with evidence.
4. Fusion combines candidates, applies thresholds and writes alerts.
5. `serve` exposes the API and the embedded UI; the Tauri shell can open the same
UI against a local or remote daemon.
## API
| Method | Path | Purpose |
|---|---|---|
| GET | `/api/health` | Liveness and version |
| GET | `/api/status` | Uptime, data directory, pillar state |
| GET | `/api/alerts` | Paginated alerts with evidence |
| POST | `/api/analyze` | Analyze a dataset path |
| POST | `/api/update` | Check for a CLI update |
| GET | `/logo.png` | Embedded brand asset |
| GET | `/*` | Embedded UI fallback |
`serve` binds to `127.0.0.1:8787` by default. There is no authentication layer in
the skeleton; exposing it on a network interface requires an authenticating
reverse proxy.
## Storage
DuckDB is an **optional** dependency (`features = ["duckdb"]`, `bundled`). The
default skeleton does not compile the C++ backend. When enabled, the schema is
append-only normalized events plus an alerts table, with Parquet export and a
seven-day retention job. The database file lives under the configured data
directory and never outside it.
## Updates
`update.rs` queries the Gitea Releases API for the `acaua` repository, selects
the asset for the current platform, downloads over HTTPS with `curl`, verifies
SHA-256 and replaces the CLI binary. It refuses to update a running GUI
automatically; the desktop only reports that a new version exists. This mirrors
the Sabiá updater and avoids a second update mechanism.
The Tauri updater plugin, its `latest.json` manifest, `updater.pubkey` and
`createUpdaterArtifacts` are intentionally not used: they would add a second
signing and distribution path, and the desktop must not self-update. The single
CLI path is easier to audit and keeps release assets to tarballs plus SHA-256.
## Rejected alternatives
An earlier blueprint proposed a Python service with FastAPI and ClickHouse.
Both are rejected. A Python service would add a second runtime, packaging burden
and a network boundary inside a local-first tool; ClickHouse would add an
external server and operational cost for datasets that fit an embedded columnar
store. The product is 100% Rust, with Python confined to `research/`.
## Deployment
- **Native:** `cargo build --release -p acaua-cli`, then `acaua serve`.
- **Desktop:** Tauri bundles for Linux and Windows, built in CI.
- **Docker:** a slim runtime image for lab use; see [docker.md](docker.md).
## Failure behavior
Malformed input is skipped with a counted, bounded diagnostic. A missing log is
not fatal. A corrupt store is reported by `doctor` and never silently recreated
over existing data. A failed update leaves the current binary in place.
## Versioning and compatibility
SemVer 2.0.0. Evidence JSON carries a schema version. Any change to a score or
to the evidence shape is a breaking change for `0.x` minor versions and must be
recorded in the changelog.
+107
View File
@@ -0,0 +1,107 @@
# Terminal
**English** · [Português](pt-BR/cli.md)
The `acaua` binary is both the CLI and the daemon. This page describes the
commands that exist in the `0.1.0` skeleton. Detection is not implemented yet;
`analyze` validates input and returns a stub report.
```text
acaua <command> [options]
Commands:
serve Start the API and embedded UI
analyze Analyze a Zeek log directory
doctor Check the local environment
update Check or apply a CLI update
```
## `acaua serve`
Starts the HTTP API and the embedded UI.
```bash
acaua serve --bind 127.0.0.1:8787 --data-dir .acaua
```
| Option | Default | Meaning |
|---|---|---|
| `--bind` | `127.0.0.1:8787` | Listen address |
| `--data-dir` | `.acaua` | Store and runtime files |
The default bind is loopback-only. Exposing the daemon on a network requires a
reverse proxy with authentication. The API is documented in
[architecture.md](architecture.md#api).
## `acaua analyze`
```bash
acaua analyze /var/log/zeek/current
acaua analyze /var/log/zeek/current --json
```
The directory is validated and a report is printed. In this skeleton the parser
does not read Zeek records yet: `logs seen` and `records` are placeholders, and
`alerts` is empty. The command is still useful to check path handling and the
JSON shape.
The future parser will support `conn.log`, `dns.log`, `ssl.log` and the protocol
logs needed for identity and lateral-movement features. NetFlow/IPFIX is a
degraded mode; it is not equivalent to full protocol logs.
## `acaua doctor`
```bash
acaua doctor
acaua doctor --json
```
Prints checks for Zeek, `curl`, `tar`, the optional DuckDB feature and the
isolation of `research/`, followed by the status (`skeleton` in `0.1.0`). A
missing optional dependency is reported as `falha` but does not abort the
command. It does not send telemetry.
## `acaua update`
```bash
acaua update
acaua update --apply
```
`update` queries the latest Gitea Release, compares the tag with the running
version and prints whether an update exists. `--apply` downloads the archive for
the current platform, verifies its SHA-256, extracts it and replaces the
`acaua` binary.
Requirements: `curl` and `tar` on `PATH`, and a writable binary location. The
updater refuses to replace any executable whose name is not `acaua`; the desktop
app must be updated through its installer. A failed checksum leaves the current
binary untouched.
## Exit codes
| Code | Meaning |
|---|---|
| `0` | Success |
| `1` | Runtime error (invalid path, failed update, I/O error) |
| `2` | Invalid command-line usage (clap) |
## Environment
The skeleton has no required environment variable. `RUST_LOG` is reserved for
the future tracing layer. The default data directory is relative to the current
working directory; use an absolute path for a system service.
## Examples
```bash
# Local lab
acaua doctor
acaua analyze ./fixtures/zeek --json
acaua serve --bind 127.0.0.1:8787 --data-dir ./.acaua
# Container
docker run --rm -p 127.0.0.1:8787:8787 -v "$PWD/zeek:/data:ro" acaua serve
```
See [docker.md](docker.md) for the image and volume layout.
+67
View File
@@ -0,0 +1,67 @@
# Docker
**English** · [Português](pt-BR/docker.md)
The image is for lab and research use. It runs the same `acaua` binary as a
native install, with the embedded UI. The skeleton does not enable the DuckDB
feature in the image; the store remains reserved for a later release.
## Build
```bash
docker build -t acaua .
docker run --rm -p 127.0.0.1:8787:8787 -v "$PWD/data/acaua:/data/acaua" acaua
```
The build stage uses `rust:1-slim-bookworm`; the runtime stage uses
`debian:bookworm-slim` and installs only `ca-certificates`, `curl` and `tar`
(the updater needs `curl` and `tar`).
## Compose
```bash
cp .env.example .env # optional: set ZEEK_INTERFACE
docker compose up --build
```
The Compose file starts two services:
| Service | Purpose | Notes |
|---|---|---|
| `zeek` | Capture and write Zeek logs to `./data/zeek` | Host network and `NET_RAW`/`NET_ADMIN` |
| `acaua` | Read logs and serve the UI/API | Host port bound to `127.0.0.1:8787` |
`acaua` mounts `./data/zeek` read-only at `/data/zeek` and `./data/acaua`
read-write at `/data/acaua`. The daemon inside the container binds
`0.0.0.0:8787`, but the published port is loopback-only on the host.
## Data layout
```text
data/
zeek/ # Zeek logs, written by the zeek service
acaua/ # store, exports and runtime files
```
Do not mount the host root or a directory containing unrelated sensitive files.
The parser is expected to confine paths to the dataset root; the skeleton does
not parse records yet.
## Security notes
- The `zeek` service needs packet capture privileges. Run it only on an
interface and network you are authorized to monitor.
- Do not change the host port mapping to `0.0.0.0:8787` without an
authenticating reverse proxy.
- The image has no telemetry and no outbound connection except an explicit
`acaua update`.
- The container is not a sandbox for hostile datasets. Treat a malformed capture
as hostile input and keep the container unprivileged.
## Limitations
- No DuckDB build in the image; the analytical store is not active.
- The UI is a shell and `analyze` returns a stub report.
- The image does not include Zeek; the Compose file uses the upstream Zeek
image.
- There is no published image registry yet. Build locally.
+67
View File
@@ -0,0 +1,67 @@
# Interface
**English** · [Português](pt-BR/interface.md)
The interface is a vanilla HTML/CSS/JS app embedded in the `acaua` binary and
loaded by the Tauri shell. There is no frontend build step and no React/Next.js
runtime. This page describes the `0.1.0` shell; it is not a screenshot tour,
because the detection views have nothing real to show yet.
## Layout
- **Sidebar:** brand, navigation, connection state, version and update check.
- **Overview:** scope notice, authorized-use notice and the four pillar cards.
- **Analysis:** dataset path field, analyze button, refresh and raw JSON output.
- **Alerts:** empty state explaining that the detection engine will arrive in a
later spec.
- **Settings:** language and theme, stored locally.
## Language
The source text is Portuguese. `i18n.js` applies English strings from
`i18n-en.js` at runtime and persists the choice in `localStorage`. `Auto`
follows the browser/system language. The check `node scripts/check-ui-i18n.mjs`
fails when a visible Portuguese string has no English pair.
## Theme
`Auto`, light and dark are supported. `Auto` follows `prefers-color-scheme`. The
choice is stored locally; no preference leaves the machine.
## Connection state
The footer polls `/api/health` and shows one of:
- **Connecting:** the first request has not finished.
- **Local service online:** `/api/health` answered.
- **Service unavailable:** the request failed; the shell remains usable for settings.
The Tauri shell can point at a local daemon or a remote `serve` in a future
version. In `0.1.0` it uses the local API.
## Analysis view
`Analyze` posts the path to `/api/analyze` and prints the JSON response. The
skeleton validates the path and returns a stub report. It does not parse Zeek
records and does not render scores. Failures are shown as text, never as a
silent empty state.
## Alerts view
The list is intentionally empty. A future alert row will show the pillar, score,
timestamp and a link to the evidence record. The evidence must include inputs,
intermediate values, sentinels, weights and the code version; a score alone is
not enough.
## Accessibility
- Navigation uses real buttons and an `aria-label`.
- The settings dialog uses `<dialog>` with a labelled title.
- Status changes use text, not color alone.
## Limitations
- No real screenshots: the views are shells.
- No alert detail, no charts and no dataset browser yet.
- No authentication: the UI assumes a loopback daemon.
- The desktop app does not self-update; it only reports an available version.
+177
View File
@@ -0,0 +1,177 @@
# Arquitetura
[English](../architecture.md) · **Português**
O Acauã é um único produto Rust: um daemon/CLI, um núcleo compartilhado, uma UI
web embutida e um shell Tauri. O runtime distribuído não tem Python nem banco
externo. Este documento registra as decisões estruturais e as seis correções
aplicadas ao blueprint original.
## Stack
| Camada | Escolha | Por quê |
|---|---|---|
| Núcleo, CLI e daemon | Rust 2021 | Uma toolchain, segurança de memória, sem dependência de runtime |
| API HTTP | Axum + Tokio + Tower HTTP | Superfície async pequena e fácil de embutir |
| UI | HTML/CSS/JS vanilla | Sem etapa de build de frontend, assets embutidos no binário |
| Desktop | Tauri 2 | Janela nativa sobre a mesma UI |
| Armazenamento analítico | DuckDB + Parquet (feature opcional `duckdb`) | Embutido, colunar, local-first |
| Validação científica | Python só em `research/` | astropy/scipy/networkx para fixtures, nunca distribuído |
O workspace tem dois crates: `acaua-core` e `acaua-cli`. A GUI fica em
`apps/gui`, **fora** do workspace Cargo, como no Guará e no Sabiá. A spec citou
`version.workspace = true` para o manifesto do desktop; manter a GUI fora do
workspace torna isso impossível, então o `check.sh` impõe igualdade de SemVer
entre `Cargo.toml`, `acaua-cli/Cargo.toml`, o manifesto do desktop e
`tauri.conf.json`. É um desvio deliberado, não um esquecimento.
## Componentes
```
Logs Zeek ──► acaua-core::zeek ──► eventos normalizados ──► armazenamento
│
├──► pilar1 (beaconing)
├──► pilar2 (sessões persistentes)
├──► pilar3 (anomalias de protocolo)
└──► pilar4 (fusão) ──► alertas + evidência
│
acaua-cli ──► API axum ──► UI embutida ◄── shell Tauri ────────────┘
```
- `zeek`: parser limitado e confinamento de caminho. Nunca segue link simbólico
para fora da raiz do dataset.
- `storage`: escritor DuckDB/Parquet com TTL de sete dias. Desligado por padrão
para o esqueleto não exigir build C++.
- `pillar1`–`pillar4`: funções puras sobre eventos normalizados. Devolvem
pontuação e evidência, nunca só um float.
- `fusion`: combinação limitada e em ordem fixa. Precisa ser simétrica e
reproduzível.
- `doctor`: checagens do ambiente local; sem chamadas de rede exceto a checagem
explícita de atualização.
- `update`: cliente de Gitea Releases só para a CLI.
## As seis correções
### 1. Rayleigh/Schuster, não Lomb–Scargle puro
Timestamps de eventos são um processo pontual unidimensional: não existe um `y`
para ajustar. O periodograma primário é, portanto, Rayleigh/Schuster:
```text
P(f) = |sum_k exp(2*pi*i*f*t_k)|^2 / N
```
O Lomb–Scargle generalizado só é usado quando existe um `y` real (por exemplo,
bytes por bucket). Isso evita inventar uma variável dependente e mantém a
distribuição nula honesta.
### 2. Bowley com sentinela explícito
O coeficiente de Bowley usa quartis. Quando `Q3 == Q1`, o denominador é zero; a
implementação devolve um sentinela documentado (`0.0` com a flag de evidência
`degenerate_quartiles`) em vez de `NaN`, infinito ou panic. O sentinela faz parte
do registro do alerta, não é fallback silencioso.
### 3. Jensen–Shannon, não KL puro
KL diverge quando a distribuição de referência tem zero onde a observada não
tem. A fusão usa Jensen–Shannon, que é simétrica, limitada e definida para bins
com probabilidade zero com um piso de suavização documentado. Isso torna a
pontuação comparável entre pilares e segura para histogramas esparsos.
### 4. Sessões persistentes têm detector próprio
Uma conexão aberta por horas nunca produz um intervalo curto, então um detector
de beaconing pode ignorá-la. O pilar 2 pontua duração, lacunas ociosas e fluxos
longos repetidos diretamente. Os dois pilares podem disparar; a fusão trata isso
como evidências separadas, não como contagem dupla do mesmo sinal.
### 5. Cautela com a licença JA4
A impressão digital JA4 de cliente TLS pode ser usada. JA4S, JA4H, JA4X, JA4SSH
e JA4L têm licença não-OSI e **não** são habilitadas por padrão. Qualquer uso
futuro exige decisão explícita de licenciamento e opt-in documentado. O conjunto
padrão precisa continuar redistribuível sob MIT/Apache-2.0.
### 6. Determinismo e evidência JSON por alerta
Todo alerta carrega um registro JSON: hash do dataset, versão do código, pilar,
entradas, valores intermediários, sentinelas, pesos e pontuação final. As
reduções rodam em ordem fixa; iteração de hash map ou agendamento de threads não
pode mudar uma pontuação. Com a mesma entrada e versão, a evidência precisa ser
byte a byte idêntica.
## Fluxo de dados
1. `acaua analyze <dir>` valida o diretório, lê logs suportados e normaliza
eventos. Neste esqueleto ele valida e devolve um relatório stub.
2. Eventos normalizados vão para o armazenamento local (quando a feature
`duckdb` está ligada) com TTL de sete dias.
3. Cada pilar roda sobre uma janela determinística e devolve candidatos com
evidência.
4. A fusão combina candidatos, aplica limiares e grava alertas.
5. `serve` expõe a API e a UI embutida; o shell Tauri abre a mesma UI contra um
daemon local ou remoto.
## API
| Método | Caminho | Finalidade |
|---|---|---|
| GET | `/api/health` | Liveness e versão |
| GET | `/api/status` | Uptime, diretório de dados, estado dos pilares |
| GET | `/api/alerts` | Alertas paginados com evidência |
| POST | `/api/analyze` | Analisar um caminho de dataset |
| POST | `/api/update` | Checar atualização da CLI |
| GET | `/logo.png` | Asset de marca embutido |
| GET | `/*` | Fallback da UI embutida |
O `serve` escuta em `127.0.0.1:8787` por padrão. Não há camada de autenticação
no esqueleto; expor em interface de rede exige proxy reverso com autenticação.
## Armazenamento
DuckDB é dependência **opcional** (`features = ["duckdb"]`, `bundled`). O
esqueleto padrão não compila o backend C++. Quando ligado, o schema é de eventos
normalizados append-only mais uma tabela de alertas, com exportação Parquet e
retenção de sete dias. O arquivo do banco fica sob o diretório de dados
configurado e nunca fora dele.
## Atualizações
`update.rs` consulta a API de Gitea Releases do repositório `acaua`, escolhe o
asset da plataforma atual, baixa por HTTPS com `curl`, verifica SHA-256 e
substitui o binário da CLI. Ele se recusa a atualizar automaticamente uma GUI em
execução; o desktop só informa que existe versão nova. Isso espelha o updater do
Sabiá e evita um segundo mecanismo de atualização.
O plugin updater do Tauri e seu fluxo de manifesto e assinatura não são usados
de propósito: adicionariam um segundo caminho de assinatura e distribuição, e o
desktop não deve se auto-atualizar. O caminho único da CLI é mais fácil de
auditar e mantém os assets de release em tarballs mais SHA-256.
## Alternativas rejeitadas
Um blueprint anterior propunha um serviço Python e um banco colunar externo. Os
dois são rejeitados. Um serviço Python adicionaria um segundo runtime, custo de
empacotamento e uma fronteira de rede dentro de uma ferramenta local-first; um
banco externo adicionaria servidor e custo operacional para datasets que cabem em
um armazenamento colunar embutido. O produto é 100% Rust, com Python confinado a
`research/`.
## Implantação
- **Nativa:** `cargo build --release -p acaua-cli`, depois `acaua serve`.
- **Desktop:** bundles Tauri para Linux e Windows, construídos no CI.
- **Docker:** imagem enxuta de runtime para laboratório; veja [docker.md](docker.md).
## Comportamento em falha
Entrada malformada é ignorada com diagnóstico contado e limitado. Log ausente não
é fatal. Store corrompido é reportado pelo `doctor` e nunca recriado por cima de
dados existentes. Atualização falha mantém o binário atual.
## Versionamento e compatibilidade
SemVer 2.0.0. O JSON de evidência carrega versão de schema. Qualquer mudança em
pontuação ou no formato da evidência é quebra para versões menores `0.x` e
precisa constar no changelog.
+106
View File
@@ -0,0 +1,106 @@
# Terminal
[English](../cli.md) · **Português**
O binário `acaua` é ao mesmo tempo CLI e daemon. Esta página descreve os comandos
que existem no esqueleto `0.1.0`. A detecção ainda não está implementada; o
`analyze` valida a entrada e devolve um relatório stub.
```text
acaua <comando> [opções]
Comandos:
serve Sobe a API e a UI embutida
analyze Analisa um diretório de logs Zeek
doctor Checa o ambiente local
update Checa ou aplica atualização da CLI
```
## `acaua serve`
Sobe a API HTTP e a UI embutida.
```bash
acaua serve --bind 127.0.0.1:8787 --data-dir .acaua
```
| Opção | Padrão | Significado |
|---|---|---|
| `--bind` | `127.0.0.1:8787` | Endereço de escuta |
| `--data-dir` | `.acaua` | Store e arquivos de runtime |
O bind padrão é só loopback. Expor o daemon na rede exige proxy reverso com
autenticação. A API está documentada em
[arquitetura.md](arquitetura.md#api).
## `acaua analyze`
```bash
acaua analyze /var/log/zeek/current
acaua analyze /var/log/zeek/current --json
```
O diretório é validado e um relatório é impresso. Neste esqueleto o parser ainda
não lê registros Zeek: `logs lidos` e `registros` são placeholders, e `alertas`
vem vazio. O comando continua útil para conferir tratamento de caminho e o
formato JSON.
O parser futuro vai suportar `conn.log`, `dns.log`, `ssl.log` e os logs de
protocolo necessários para identidade e movimento lateral. NetFlow/IPFIX é modo
degradado; não equivale a logs completos de protocolo.
## `acaua doctor`
```bash
acaua doctor
acaua doctor --json
```
Imprime checagens de Zeek, `curl`, `tar`, da feature opcional DuckDB e do
isolamento de `research/`, seguida do estado (`skeleton` em `0.1.0`). Dependência
opcional ausente aparece como `falha`, mas não aborta o comando. Não envia
telemetria.
## `acaua update`
```bash
acaua update
acaua update --apply
```
O `update` consulta a última Release do Gitea, compara a tag com a versão em
execução e informa se existe atualização. O `--apply` baixa o arquivo da
plataforma atual, verifica o SHA-256, extrai e substitui o binário `acaua`.
Requisitos: `curl` e `tar` no `PATH`, e local de binário com permissão de escrita.
O updater se recusa a substituir qualquer executável cujo nome não seja `acaua`;
o app desktop deve ser atualizado pelo instalador. Checksum inválido mantém o
binário atual intacto.
## Códigos de saída
| Código | Significado |
|---|---|
| `0` | Sucesso |
| `1` | Erro de execução (caminho inválido, update falhou, erro de I/O) |
| `2` | Uso inválido da linha de comando (clap) |
## Ambiente
O esqueleto não exige variável de ambiente. `RUST_LOG` está reservado para a
camada de tracing futura. O diretório de dados padrão é relativo ao diretório de
trabalho; use caminho absoluto em serviço de sistema.
## Exemplos
```bash
# Laboratório local
acaua doctor
acaua analyze ./fixtures/zeek --json
acaua serve --bind 127.0.0.1:8787 --data-dir ./.acaua
# Contêiner
docker run --rm -p 127.0.0.1:8787:8787 -v "$PWD/zeek:/data:ro" acaua serve
```
Veja [docker.md](docker.md) para a imagem e o layout de volumes.
+66
View File
@@ -0,0 +1,66 @@
# Docker
[English](../docker.md) · **Português**
A imagem é para laboratório e pesquisa. Ela roda o mesmo binário `acaua` de uma
instalação nativa, com a UI embutida. O esqueleto não liga a feature DuckDB na
imagem; o armazenamento continua reservado para uma release futura.
## Build
```bash
docker build -t acaua .
docker run --rm -p 127.0.0.1:8787:8787 -v "$PWD/data/acaua:/data/acaua" acaua
```
O estágio de build usa `rust:1-slim-bookworm`; o de runtime usa
`debian:bookworm-slim` e instala só `ca-certificates`, `curl` e `tar` (o updater
precisa de `curl` e `tar`).
## Compose
```bash
cp .env.example .env # opcional: defina ZEEK_INTERFACE
docker compose up --build
```
O Compose sobe dois serviços:
| Serviço | Função | Notas |
|---|---|---|
| `zeek` | Captura e grava logs Zeek em `./data/zeek` | Rede do host e `NET_RAW`/`NET_ADMIN` |
| `acaua` | Lê logs e serve UI/API | Porta do host presa a `127.0.0.1:8787` |
O `acaua` monta `./data/zeek` somente leitura em `/data/zeek` e `./data/acaua`
leitura-escrita em `/data/acaua`. O daemon dentro do contêiner escuta
`0.0.0.0:8787`, mas a porta publicada é só loopback no host.
## Layout de dados
```text
data/
zeek/ # logs Zeek, escritos pelo serviço zeek
acaua/ # store, exportações e arquivos de runtime
```
Não monte a raiz do host nem um diretório com arquivos sensíveis sem relação. O
parser deve confinar caminhos à raiz do dataset; o esqueleto ainda não lê
registros.
## Notas de segurança
- O serviço `zeek` precisa de privilégio de captura. Rode só em interface e rede
que você tem autorização para monitorar.
- Não mude o mapeamento de porta do host para `0.0.0.0:8787` sem proxy reverso
com autenticação.
- A imagem não tem telemetria e não faz conexão de saída exceto um
`acaua update` explícito.
- O contêiner não é sandbox para dataset hostil. Trate captura malformada como
entrada hostil e mantenha o contêiner sem privilégios.
## Limitações
- Sem build DuckDB na imagem; o armazenamento analítico não está ativo.
- A UI é um shell e o `analyze` devolve relatório stub.
- A imagem não inclui Zeek; o Compose usa a imagem upstream do Zeek.
- Ainda não há imagem publicada em registry. Compile localmente.
+72
View File
@@ -0,0 +1,72 @@
# Interface
[English](../interface.md) · **Português**
A interface é um app HTML/CSS/JS vanilla embutido no binário `acaua` e carregado
pelo shell Tauri. Não há etapa de build de frontend nem runtime React/Next.js.
Esta página descreve o shell `0.1.0`; não é um tour de screenshots, porque as
telas de detecção ainda não têm nada real para mostrar.
## Layout
- **Barra lateral:** marca, navegação, estado da conexão, versão e checagem de
atualização.
- **Visão geral:** aviso de escopo, aviso de uso autorizado e os quatro cards dos
pilares.
- **Análise:** campo de caminho do dataset, botão analisar, atualizar e saída
JSON bruta.
- **Alertas:** estado vazio explicando que o motor de detecção chega em spec
futura.
- **Configurações:** idioma e tema, guardados localmente.
## Idioma
O texto-fonte é em português. O `i18n.js` aplica as strings em inglês de
`i18n-en.js` em tempo de execução e persiste a escolha no `localStorage`. `Auto`
segue o idioma do navegador/sistema. A checagem
`node scripts/check-ui-i18n.mjs` falha quando uma string visível em português não
tem par em inglês.
## Tema
`Auto`, claro e escuro. `Auto` segue `prefers-color-scheme`. A escolha fica
guardada localmente; nenhuma preferência sai da máquina.
## Estado da conexão
O rodapé consulta `/api/health` e mostra um de:
- **Conectando…:** a primeira requisição ainda não terminou.
- **Serviço local ativo:** `/api/health` respondeu.
- **Serviço indisponível:** a requisição falhou; o shell continua utilizável para
configurações.
O shell Tauri poderá apontar para um daemon local ou um `serve` remoto em versão
futura. Em `0.1.0` ele usa a API local.
## Tela de análise
`Analisar` envia o caminho para `/api/analyze` e imprime a resposta JSON. O
esqueleto valida o caminho e devolve um relatório stub. Ele não lê registros Zeek
e não renderiza pontuações. Falhas aparecem como texto, nunca como estado vazio
silencioso.
## Tela de alertas
A lista está vazia de propósito. Uma linha futura de alerta vai mostrar pilar,
pontuação, timestamp e link para o registro de evidência. A evidência precisa
incluir entradas, valores intermediários, sentinelas, pesos e versão do código;
pontuação sozinha não basta.
## Acessibilidade
- A navegação usa botões reais e `aria-label`.
- O diálogo de configurações usa `<dialog>` com título rotulado.
- Mudanças de estado usam texto, não só cor.
## Limitações
- Sem screenshots reais: as telas são shells.
- Sem detalhe de alerta, gráficos ou navegador de dataset ainda.
- Sem autenticação: a UI assume daemon em loopback.
- O app desktop não se auto-atualiza; ele só informa a versão disponível.
+47
View File
@@ -0,0 +1,47 @@
# Suporte
[English](../support.md) · **Português**
O Acauã é pré-alfa. Não há contrato de suporte nem binário publicado ainda. O
caminho mais rápido para obter ajuda é uma issue objetiva no repositório Gitea.
## Antes de abrir uma issue
1. Rode `acaua doctor --json` e guarde a saída.
2. Confirme a versão com `acaua --version`.
3. Tente a menor entrada que reproduz o problema.
4. Leia os [limites conhecidos](../../README.pt-BR.md#limites-conhecidos) e a
[arquitetura](arquitetura.md).
## O que incluir
- Sistema operacional, arquitetura e versão do Acauã.
- O comando exato e a saída completa do terminal.
- Se a entrada é diretório de logs Zeek, fixture sintética ou captura.
- Para problema de UI: build de navegador ou desktop, idioma e tema.
- Para problema de atualização: saída de `acaua update` e se `curl` e `tar` estão
no `PATH`.
Não anexe capturas de produção nem credenciais. Redija endereços, hostnames e
payloads antes de compartilhar.
## Problemas de segurança
Não inclua detalhes de exploração em issue pública. Siga o
[SECURITY.pt-BR.md](../../SECURITY.pt-BR.md) e peça um canal privado primeiro.
## Situações comuns
| Sintoma | O que checar |
|---|---|
| `analyze` devolve zero registros | Esperado em `0.1.0`; o parser é stub |
| `serve` inacessível de outra máquina | Ele escuta em loopback por padrão |
| `update` falha | `curl`, `tar`, permissão de escrita e assets da release |
| Build do desktop falha | Dependências de sistema do Tauri para a plataforma |
| UI aparece em português | `Auto` segue o idioma do sistema; escolha English nas configurações |
## Escopo
O suporte cobre o código e a documentação do Acauã. Não cobre configuração de
Zeek ou Suricata, legalidade de captura de rede ou pacotes de distribuição de
terceiros. Use somente em redes próprias ou com autorização para monitorar.
+47
View File
@@ -0,0 +1,47 @@
# Support
**English** · [Português](pt-BR/suporte.md)
Acauã is pre-alpha. There is no support contract and no published binary yet.
The fastest way to get help is a focused issue in the Gitea repository.
## Before opening an issue
1. Run `acaua doctor --json` and keep the output.
2. Confirm the version with `acaua --version`.
3. Try the smallest input that reproduces the problem.
4. Read the [known limits](../README.md#known-limits) and
[architecture](architecture.md).
## What to include
- Operating system, architecture and Acauã version.
- The exact command and the full terminal output.
- Whether the input is a Zeek log directory, a synthetic fixture or a capture.
- For a UI problem: browser or desktop build, language and theme.
- For an update problem: `acaua update` output and whether `curl` and `tar` are
on `PATH`.
Do not attach production captures or credentials. Redact addresses, hostnames
and payloads before sharing.
## Security issues
Do not include exploit details in a public issue. Follow
[SECURITY.md](../SECURITY.md) and ask for a private channel first.
## Common situations
| Symptom | What to check |
|---|---|
| `analyze` returns zero records | Expected in `0.1.0`; the parser is a stub |
| `serve` is unreachable from another machine | It binds to loopback by default |
| `update` fails | `curl`, `tar`, write permission and release assets |
| Desktop build fails | Tauri system dependencies for the platform |
| UI shows Portuguese | `Auto` follows the system language; choose English in settings |
## Scope
Support covers the Acauã code and documentation. It does not cover Zeek or
Suricata configuration, network capture legality, or third-party distribution
packages. Use only on networks you own or are authorized to monitor.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1013 KiB

+21
View File
@@ -0,0 +1,21 @@
# Research workspace
**English** · [Português](README.pt-BR.md)
This directory is for scientific validation only. It is not part of the Acauã
product, is not compiled by Cargo, and is never included in a release artifact.
Use it to generate reproducible fixtures, compare implementations against
reference libraries, and run notebooks for the four detection pillars. The Rust
engine must remain the only runtime used in production.
```bash
cd research
python3 -m venv .venv
. .venv/bin/activate
pip install -e .
jupyter lab
```
The Python environment is isolated from the workspace. Adding a dependency here
does not add it to the shipped binary.
+21
View File
@@ -0,0 +1,21 @@
# Espaço de pesquisa
[English](README.md) · **Português**
Este diretório serve só para validação científica. Ele não faz parte do produto
Acauã, não é compilado pelo Cargo e nunca entra em um artefato de release.
Use-o para gerar fixtures reproduzíveis, comparar implementações com bibliotecas
de referência e rodar notebooks dos quatro pilares de detecção. O motor Rust
continua sendo o único runtime usado em produção.
```bash
cd research
python3 -m venv .venv
. .venv/bin/activate
pip install -e .
jupyter lab
```
O ambiente Python é isolado do workspace. Adicionar uma dependência aqui não a
adiciona ao binário distribuído.
View File
Whitespace-only changes.
+17
View File
@@ -0,0 +1,17 @@
[project]
name = "acaua-research"
version = "0.1.0"
description = "Scientific validation fixtures and notebooks for Acauã"
requires-python = ">=3.11"
dependencies = [
"astropy>=6.0",
"scipy>=1.13",
"networkx>=3.3",
"pandas>=2.2",
"matplotlib>=3.9",
"jupyterlab>=4.2",
]
[build-system]
requires = ["setuptools>=70"]
build-backend = "setuptools.build_meta"
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Installs apt packages in CI without depending on fast mirrors: reuses cached .deb
# archives when present and retries with per-attempt timeouts if apt stalls or fails.
#
# Usage: scripts/apt-install.sh <cache-dir> <package>...
set -euo pipefail
[ $# -ge 2 ] || { echo "usage: $0 <cache-dir> <package>..." >&2; exit 2; }
cache="$1"; shift
pkgs=("$@")
mkdir -p "$cache/partial"
# apt runs as root and leaves files (lock, .deb) unreadable by non-root runner users;
# restore ownership on exit so cache directories remain accessible.
trap 'sudo chown -R "$(id -u):$(id -g)" "$cache" 2>/dev/null || true; rm -f "$cache/lock"' EXIT
opts=(-o "Dir::Cache::archives=$cache" -y)
# 1) With cached .deb files and package index present: install directly without updating apt lists
if [ -z "$(dpkg --print-foreign-architectures)" ] && ls "$cache"/*.deb >/dev/null 2>&1 && ls /var/lib/apt/lists/*Packages* >/dev/null 2>&1 && timeout 300 sudo apt-get "${opts[@]}" install "${pkgs[@]}"; then
echo "installed from cache"
exit 0
fi
# 2) Cache miss (or multiarch / incomplete): update and install, up to 3 attempts with timeout
for attempt in 1 2 3; do
if timeout 240 sudo apt-get update && { [ -z "$(dpkg --print-foreign-architectures)" ] || timeout 600 sudo apt-get "${opts[@]}" upgrade; } && timeout 600 sudo apt-get "${opts[@]}" install "${pkgs[@]}"; then
echo "installed (attempt $attempt)"
exit 0
fi
echo "apt failed or timed out (attempt $attempt of 3)" >&2
sleep $((attempt * 10))
done
echo "failed to install: ${pkgs[*]}" >&2
exit 1
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env bash
# Builds the desktop app (Tauri) and places the resulting bundles in dist-gui/.
#
# scripts/build-gui.sh linux # AppImage and .deb (native architecture)
# scripts/build-gui.sh linux-aarch64 # AppImage and .deb for aarch64
# scripts/build-gui.sh linux-x86_64 # AppImage and .deb for x86_64 (native or cross-built from aarch64)
# scripts/build-gui.sh windows # NSIS installer, cross-built from Linux via cargo-xwin
# scripts/build-gui.sh windows-docker # Same as above inside an isolated container
#
# Windows builds use cargo-xwin and NSIS (requires: nsis, lld, llvm, clang, and
# `cargo install cargo-xwin`).
set -euo pipefail
cd "$(dirname "$0")/.."
platform=${1:?usage: build-gui.sh <linux|linux-x86_64|linux-aarch64|windows|windows-docker>}
tauri() { npx --yes @tauri-apps/cli@2 "$@"; }
if [ "$platform" = windows-docker ]; then
docker volume create acaua-cargo >/dev/null
docker volume create acaua-rustup >/dev/null
mkdir -p dist-gui
exec docker run --rm -v "$PWD":/src -v acaua-cargo:/root/.cargo -v acaua-rustup:/root/.rustup \
-w /src -e HOST_UID="$(id -u)" -e HOST_GID="$(id -g)" \
ubuntu:24.04 bash -c '
set -e
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq curl ca-certificates build-essential pkg-config nsis lld llvm clang nodejs npm unzip >/dev/null
[ -x /root/.cargo/bin/rustup ] || curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal >/dev/null
export PATH=/root/.cargo/bin:$PATH
command -v cargo-xwin >/dev/null || cargo install cargo-xwin --locked
scripts/build-gui.sh windows
chown -R "$HOST_UID:$HOST_GID" dist-gui apps/gui/src-tauri/target 2>/dev/null || true'
fi
rm -rf dist-gui
mkdir -p dist-gui
cd apps/gui/src-tauri
# Generate derived icons if missing.
[ -f icons/icon.ico ] || tauri icon icons/source.png -o icons
tauri_blob_cache="$HOME/.cache/tauri"
if [ -d /opt/hostedtoolcache ] && [ -w /opt/hostedtoolcache ]; then
tauri_blob_cache="/opt/hostedtoolcache/tauri-tools"
fi
mkdir -p "$tauri_blob_cache" "$HOME/.cache/tauri"
fetch_cached_tool() {
local name=$1 url=$2 patch_elf=${3:-0}
local cached="$tauri_blob_cache/$name"
if [ ! -s "$cached" ]; then
local tmp="$cached.tmp.$$"
curl -fsSL "$url" -o "$tmp"
if [ "$patch_elf" = 1 ]; then
dd if=/dev/zero of="$tmp" bs=1 count=3 seek=8 conv=notrunc 2>/dev/null
fi
chmod +x "$tmp"
mv -f "$tmp" "$cached"
fi
if [ "$cached" != "$HOME/.cache/tauri/$name" ]; then
cp -f "$cached" "$HOME/.cache/tauri/$name"
chmod +x "$HOME/.cache/tauri/$name"
fi
}
case "$platform" in
linux|linux-aarch64)
bundle="${CARGO_TARGET_DIR:-target}/release/bundle"
rm -rf "$bundle/appimage" "$bundle/deb"
tauri build --bundles appimage,deb
cp "$bundle"/appimage/*.AppImage "$bundle"/deb/*.deb ../../../dist-gui/
;;
linux-x86_64)
if [ "$(uname -m)" = x86_64 ]; then
bundle="${CARGO_TARGET_DIR:-target}/release/bundle"
rm -rf "$bundle/appimage" "$bundle/deb"
tauri build --bundles appimage,deb
else
rustup target add x86_64-unknown-linux-gnu >/dev/null
mkdir -p /tmp/x86_64-cross-tools "$HOME/.cache/tauri"
cat >/tmp/x86_64-cross-tools/ldd <<'EOF'
#!/usr/bin/env bash
exec qemu-x86_64 /lib64/ld-linux-x86-64.so.2 --list "$@"
EOF
chmod +x /tmp/x86_64-cross-tools/ldd
ln -sf /usr/bin/x86_64-linux-gnu-strip /tmp/x86_64-cross-tools/strip
for tool_rel in glib-2.0/gio-querymodules gdk-pixbuf-2.0/gdk-pixbuf-query-loaders libgtk-3-0t64/gtk-query-immodules-3.0 libgtk-3-0/gtk-query-immodules-3.0; do
if [ -x "/usr/lib/x86_64-linux-gnu/$tool_rel" ]; then
tname=$(basename "$tool_rel")
printf '#!/usr/bin/env bash\nexec qemu-x86_64 "/usr/lib/x86_64-linux-gnu/%s" "$@"\n' "$tool_rel" >"/tmp/x86_64-cross-tools/$tname"
chmod +x "/tmp/x86_64-cross-tools/$tname"
fi
done
fetch_cached_tool "linuxdeploy-07333c6-aarch64.AppImage" \
"https://github.com/tauri-apps/binary-releases/releases/download/linuxdeploy-07333c6/linuxdeploy-aarch64.AppImage" 1
# tauri-bundler looks for this exact cache name (no arch suffix); the
# architecture only appears in the download URL.
fetch_cached_tool "linuxdeploy-plugin-appimage.AppImage" \
"https://github.com/linuxdeploy/linuxdeploy-plugin-appimage/releases/download/continuous/linuxdeploy-plugin-appimage-aarch64.AppImage" 1
fetch_cached_tool "AppRun-x86_64" \
"https://github.com/tauri-apps/binary-releases/releases/download/apprun-old/AppRun-x86_64" 0
fetch_cached_tool "runtime-x86_64" \
"https://github.com/AppImage/type2-runtime/releases/download/continuous/runtime-x86_64" 0
# tauri-bundler runs `dd ... seek=8` on this path to hide linuxdeploy from
# AppImage integration tools. A shell-script wrapper loses its shebang and
# then fails with ENOENT, so use a native ELF launcher: the dd writes into
# ELF padding and the launcher stays executable.
launcher_src=/tmp/x86_64-cross-tools/linuxdeploy-x86_64.c
launcher_bin="$HOME/.cache/tauri/linuxdeploy-07333c6-x86_64.AppImage"
cat >"$launcher_src" <<'EOF'
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(int argc, char **argv) {
const char *home = getenv("HOME");
if (home == NULL) { fputs("HOME is not set\n", stderr); return 127; }
char target[4096];
char runtime[4096];
char path[8192];
const char *old_path = getenv("PATH");
snprintf(target, sizeof target, "%s/.cache/tauri/linuxdeploy-07333c6-aarch64.AppImage", home);
snprintf(runtime, sizeof runtime, "%s/.cache/tauri/runtime-x86_64", home);
snprintf(path, sizeof path, "/tmp/x86_64-cross-tools:%s",
old_path ? old_path : "/usr/local/bin:/usr/bin:/bin");
if (setenv("ARCH", "x86_64", 1) ||
setenv("NO_STRIP", "true", 1) ||
setenv("APPIMAGE_EXTRACT_AND_RUN", "1", 1) ||
setenv("LDAI_RUNTIME_FILE", runtime, 1) ||
setenv("LD_GTK_LIBRARY_PATH", "/usr/lib/x86_64-linux-gnu", 1) ||
setenv("DEB_HOST_MULTIARCH", "x86_64-linux-gnu", 1) ||
setenv("PKG_CONFIG_ALLOW_CROSS", "1", 1) ||
setenv("PKG_CONFIG_LIBDIR", "/usr/lib/x86_64-linux-gnu/pkgconfig:/usr/share/pkgconfig", 1) ||
setenv("PATH", path, 1)) {
perror("setenv");
return 127;
}
char **args = calloc((size_t)argc + 1, sizeof *args);
if (args == NULL) { perror("calloc"); return 127; }
args[0] = target;
for (int i = 1; i < argc; i++) args[i] = argv[i];
execv(target, args);
perror(target);
return 127;
}
EOF
cc -O2 -Wall -Wextra -o "$launcher_bin" "$launcher_src"
export PKG_CONFIG_ALLOW_CROSS=1
export PKG_CONFIG_LIBDIR=/usr/lib/x86_64-linux-gnu/pkgconfig:/usr/share/pkgconfig
export CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=x86_64-linux-gnu-gcc
export CC_x86_64_unknown_linux_gnu=x86_64-linux-gnu-gcc
export CXX_x86_64_unknown_linux_gnu=x86_64-linux-gnu-g++
bundle="${CARGO_TARGET_DIR:-target}/x86_64-unknown-linux-gnu/release/bundle"
rm -rf "$bundle/appimage" "$bundle/deb"
tauri build --target x86_64-unknown-linux-gnu --bundles appimage,deb
fi
cp "$bundle"/appimage/*.AppImage "$bundle"/deb/*.deb ../../../dist-gui/
;;
windows)
rustup target add x86_64-pc-windows-msvc >/dev/null
bundle="${CARGO_TARGET_DIR:-target}/x86_64-pc-windows-msvc/release/bundle/nsis"
rm -rf "$bundle"
tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis
cp "$bundle"/*-setup.exe ../../../dist-gui/
;;
*) echo "unknown platform: $platform" >&2; exit 2 ;;
esac
(
cd ../../../dist-gui
for asset in *; do
[ -f "$asset" ] || continue
case "$asset" in
*.sha256) ;;
*)
sha256sum "$asset" > "$asset.sha256"
;;
esac
done
)
ls -1 ../../../dist-gui
+304
View File
@@ -0,0 +1,304 @@
#!/usr/bin/env node
// Verifies that the English dictionary covers all user-facing interface strings.
//
// Static zero-dependency scanner: reads the UI files (JS and HTML) and the
// pt-BR messages emitted by the Rust core (errors, diagnostics, engine status),
// failing whenever a string looks like Portuguese and lacks a translation. Also
// rejects duplicate keys in the dictionary (in a JS object literal the last key wins silently).
//
// Usage: node scripts/check-ui-i18n.mjs
import { readFileSync, readdirSync } from 'node:fs';
import { join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(fileURLToPath(new URL('.', import.meta.url)), '..');
const rel = (p) => relative(root, p).replaceAll('\\', '/');
// ---------------------------------------------------------------- dictionary
const dictPath = join(root, 'apps/gui/ui/i18n-en.js');
const dictSource = readFileSync(dictPath, 'utf8');
const keyRe = /^\s*(['"])((?:(?!\1).)*)\1\s*:/gm;
const seen = new Map();
const duplicates = [];
let m;
while ((m = keyRe.exec(dictSource))) {
const key = m[2];
const line = dictSource.slice(0, m.index).split('\n').length;
if (seen.has(key)) duplicates.push({ key, first: seen.get(key), again: line });
else seen.set(key, line);
}
if (duplicates.length) {
console.error('Duplicate keys in apps/gui/ui/i18n-en.js:');
for (const d of duplicates) console.error(` ${JSON.stringify(d.key)} (lines ${d.first} and ${d.again})`);
process.exitCode = 1;
}
const { EN } = await import(new URL('../apps/gui/ui/i18n-en.js', import.meta.url).href);
const exact = new Set(Object.keys(EN).filter((k) => !k.includes('{n}')));
const patterns = Object.keys(EN)
.filter((k) => k.includes('{n}'))
.map((k) => {
const parts = k.split('{n}').map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
return new RegExp('^' + parts.join('(.+?)') + '$', 's');
});
function translated(text) {
if (exact.has(text)) return true;
return patterns.some((re) => re.test(text));
}
// ---------------------------------------------------------------- heuristic
const ACCENT = /[áàâãäéèêëíìîïóòôõöúùûüçÁÀÂÃÄÉÈÊËÍÌÎÏÓÒÔÕÖÚÙÛÜÇ]/;
const WORDS = [
'pilar', 'pilares', 'ameaças', 'escopo', 'autorizado', 'tema', 'idioma', 'esqueleto', 'analisar',
'atualizar', 'status', 'alerta', 'alertas', 'registros', 'detector', 'protocolo', 'anomalias',
'fusão', 'evidências', 'conexões', 'sessões', 'persistentes', 'desenvolvimento', 'prova', 'opaca',
'pesquisa', 'determinística', 'armazenamento', 'implementados', 'matemáticos', 'quatro', 'pacote',
'enviado', 'fora', 'próprias', 'autorização', 'explícita', 'serviço', 'indisponível', 'selecionado',
'analisando', 'concluída', 'falha', 'verificando', 'versão', 'recente', 'disponível', 'possível',
'carregados', 'navegação', 'principal', 'recentes', 'futuro', 'carregará', 'sustenta', 'motor',
'detecção', 'próximas', 'specs', 'fechar', 'claro', 'escuro', 'rede', 'redes', 'interface', 'interfaces', 'captura', 'capturar', 'modo', 'ativar', 'ativa',
'desativar', 'iniciar', 'parar', 'nenhum', 'nenhuma', 'selecione', 'selecionada', 'canal',
'sinal', 'segurança', 'clientes', 'cliente', 'oculta', 'oculto', 'privilégio', 'administrador',
'necessário', 'pra', 'própria', 'próprias', 'autorização', 'responsável', 'sessão', 'trilha',
'auditoria', 'registrada', 'registrado', 'local', 'adaptador', 'compatível', 'recarregue',
'escutando', 'levar', 'segundos', 'enquanto', 'canais', 'varridos', 'erro', 'desconhecido',
'desconhecida', 'não', 'sim', 'mais', 'menos', 'uma', 'uns', 'umas', 'aqui', 'onde', 'quando',
'como', 'qual', 'para', 'com', 'sem', 'dos', 'das', 'por', 'que', 'checando', 'checar',
'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', '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 = /Acauã|Português|English/gi;
const isPortuguese = (raw) => {
const s = raw.replace(BRAND, ' ');
return ACCENT.test(s) || WORDS.some((w) => new RegExp(`(^|[^a-zà-ú])${w}([^a-zà-ú]|$)`, 'i').test(s));
};
const ALLOW = new Set([
'Acauã', 'Português', 'English', 'Auto', 'Wi-Fi', 'Zeek', 'DuckDB', 'API', 'JSON', 'CLI',
'MIT', 'Apache-2.0', 'OK',
]);
const ALLOW_PREFIX = ['http://', 'https://', 'data:', 'file:', '#', '.', '/'];
const ALLOW_EXACT = new Set(['—', '-', '·']);
function suspicious(text) {
const s = text.replace(/\s+/g, ' ').trim();
if (!s || s.length < 2) return false;
if (ALLOW.has(s) || ALLOW_EXACT.has(s)) return false;
if (ALLOW_PREFIX.some((p) => s.startsWith(p))) return false;
if (!isPortuguese(s)) return false;
// Technical tokens: CSS selectors, classes, extensions, expressions
if (/^[.#\w-]+(\s*[,>+~]\s*[.#\w-]+)+$/.test(s)) return false;
if (/^[a-z0-9_.:/@%{}$-]+$/i.test(s) && !ACCENT.test(s)) return false;
return true;
}
// ---------------------------------------------------------------- extractors
function jsStrings(source, file, baseLine = 1) {
const out = [];
let i = 0;
let line = baseLine;
let prev = '';
const skipRegex = () => {
i++;
let inClass = false;
while (i < source.length) {
const ch = source[i];
if (ch === '\\') { i += 2; continue; }
if (ch === '[') inClass = true;
else if (ch === ']') inClass = false;
else if (ch === '/' && !inClass) { i++; break; }
else if (ch === '\n') line++;
i++;
}
while (i < source.length && /[a-z]/i.test(source[i])) i++;
};
while (i < source.length) {
const c = source[i];
if (c === '\n') { line++; i++; prev = '\n'; continue; }
if (c === ' ' || c === '\t' || c === '\r') { i++; continue; }
if (c === '/' && source[i + 1] === '/') {
while (i < source.length && source[i] !== '\n') i++;
continue;
}
if (c === '/' && source[i + 1] === '*') {
i += 2;
while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) { if (source[i] === '\n') line++; i++; }
i += 2;
continue;
}
if (c === '/' && (/[=(:,[!&|?{};+\-*%^~<>]/.test(prev) || prev === 'return')) {
skipRegex();
prev = ')';
continue;
}
if (c === "'" || c === '"') {
const quote = c;
const startLine = line;
let value = '';
i++;
while (i < source.length) {
const ch = source[i];
if (ch === '\\') {
const n = source[i + 1];
if (n === 'n') value += '\n';
else if (n === 't') value += '\t';
else if (n === 'r') value += '\r';
else if (n === '\n') line++;
else value += n ?? '';
i += 2;
continue;
}
if (ch === quote) { i++; break; }
if (ch === '\n') line++;
value += ch;
i++;
}
out.push({ text: value, file, line: startLine });
prev = ')';
continue;
}
if (c === '`') {
const startLine = line;
let value = '';
i++;
while (i < source.length) {
const ch = source[i];
if (ch === '\\') {
const n = source[i + 1];
if (n === 'n') value += '\n';
else if (n === 't') value += '\t';
else if (n === '\n') line++;
else value += n ?? '';
i += 2;
continue;
}
if (ch === '`') { i++; break; }
if (ch === '$' && source[i + 1] === '{') {
value += '{n}';
i += 2;
const exprStart = i;
const exprLine = line;
let depth = 1;
while (i < source.length && depth > 0) {
if (source[i] === '{') depth++;
else if (source[i] === '}') depth--;
else if (source[i] === '\n') line++;
i++;
}
out.push(...jsStrings(source.slice(exprStart, i - 1), file, exprLine));
continue;
}
if (ch === '\n') line++;
value += ch;
i++;
}
out.push({ text: value, file, line: startLine });
prev = ')';
continue;
}
prev = c;
i++;
}
return out;
}
function htmlStrings(source, file, baseLine = 1) {
const out = [];
const lineAt = (idx) => baseLine + source.slice(0, idx).split('\n').length - 1;
// Text between tags (including leading and trailing text nodes)
let last = 0;
for (const m of source.matchAll(/<[^>]*>/g)) {
out.push({ text: source.slice(last, m.index), file, line: lineAt(last) });
last = m.index + m[0].length;
}
out.push({ text: source.slice(last), file, line: lineAt(last) });
// Translatable attributes (single or double quotes)
for (const m of source.matchAll(/\b(placeholder|title|aria-label|alt|data-empty)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) {
out.push({ text: m[2] ?? m[3], file, line: lineAt(m.index) });
}
return out;
}
// Extracts pt-BR messages from the Rust core: #[error("...")], .push("...".into()),
// "...".into(), and format!("...", ...). Placeholders ({name}, {0}, {} etc.) are
// normalized to {n}, matching the dictionary format.
function rustLabels(source, file) {
const out = [];
const lineAt = (idx) => source.slice(0, idx).split('\n').length;
const norm = (s) => s.replace(/\{[^}]*\}/g, '{n}');
const grab = (re) => {
for (const m of source.matchAll(re)) {
out.push({ text: norm(m[1]), file, line: lineAt(m.index) });
}
};
grab(/#\[error\(\s*"((?:[^"\\]|\\.)*)"\s*\)\]/g);
grab(/"((?:[^"\\]|\\.)*)"\s*\.into\(\)/g);
grab(/format!\(\s*"((?:[^"\\]|\\.)*)"/g);
return out;
}
// ---------------------------------------------------------------- collection
const uiFiles = readdirSync(join(root, 'apps/gui/ui'))
.filter((f) => f.endsWith('.js') && f !== 'i18n-en.js' && f !== 'vendor')
.map((f) => join(root, 'apps/gui/ui', f));
uiFiles.push(join(root, 'apps/gui/ui/index.html'));
const rustFiles = [
'crates/acaua-core/src/error.rs',
'crates/acaua-core/src/doctor.rs',
'crates/acaua-core/src/storage.rs',
'crates/acaua-core/src/zeek.rs',
'crates/acaua-core/src/update.rs',
].map((f) => join(root, f));
const found = [];
for (const file of uiFiles) {
const source = readFileSync(file, 'utf8');
if (file.endsWith('.html')) found.push(...htmlStrings(source, file));
else {
for (const str of jsStrings(source, file)) {
if (/<[a-z!/][^>]*>/i.test(str.text)) found.push(...htmlStrings(str.text, file, str.line));
else found.push(str);
}
}
}
for (const file of rustFiles) found.push(...rustLabels(readFileSync(file, 'utf8'), file));
const missing = [];
const seenMissing = new Set();
for (const { text, file, line } of found) {
if (!suspicious(text)) continue;
const key = text.replace(/\s+/g, ' ').trim();
if (translated(key)) continue;
const id = `${key}\u0000${rel(file)}\u0000${line}`;
if (seenMissing.has(id)) continue;
seenMissing.add(id);
missing.push({ text: key, file: rel(file), line });
}
if (missing.length) {
console.error('Untranslated Portuguese strings in apps/gui/ui/i18n-en.js:\n');
for (const x of missing) console.error(` ${x.file}:${x.line} ${JSON.stringify(x.text)}`);
console.error(`\n${missing.length} untranslated string(s).`);
process.exitCode = 1;
}
if (!process.exitCode) {
console.log(`i18n ok: ${found.length} strings checked, ${Object.keys(EN).length} dictionary keys.`);
}
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env bash
# Local verification: runs the same fast checks as CI before pushing.
#
# scripts/check.sh fast (~1 min): fmt, clippy, i18n, scripts, workflows, semver
# scripts/check.sh --full also runs workspace tests and cargo audit
#
# CI takes 5 to 30 minutes per run; almost everything that breaks CI is caught
# here in seconds.
set -uo pipefail
cd "$(dirname "$0")/.."
full=0
[ "${1:-}" = "--full" ] || [ "${1:-}" = "--completo" ] && full=1
failures=()
step() { # step "name" command...
local name="$1"; shift
printf '\n\033[1m== %s\033[0m\n' "$name"
if "$@"; then printf '\033[32mok\033[0m %s\n' "$name"; else printf '\033[31mFAILED\033[0m %s\n' "$name"; failures+=("$name"); fi
}
step "formatting (cargo fmt)" cargo fmt --all -- --check
# Same flags as CI: warnings are treated as errors across all targets, including tests
step "clippy (workspace, all targets)" env RUSTFLAGS="-D warnings" cargo clippy --workspace --all-targets
check_forbidden_terms() {
python3 - <<'PY'
from pathlib import Path
terms = [
'Fast' + 'API',
'Click' + 'House',
'latest' + '.json',
'updater' + '.pubkey',
'createUpdater' + 'Artifacts',
'plugin' + '-updater',
]
skip_dirs = {'.git', 'target', '.wolf', '.claude', 'dist-gui'}
allowed = {Path('docs/architecture.md')}
hits = []
for path in Path('.').rglob('*'):
if not path.is_file() or any(part in skip_dirs for part in path.parts):
continue
if path in allowed:
continue
try:
text = path.read_text(errors='ignore')
except OSError:
continue
for term in terms:
if term in text:
hits.append(f'{path}: {term}')
if hits:
print('\n'.join(hits))
raise SystemExit(1)
PY
}
step "UI translation coverage" node scripts/check-ui-i18n.mjs
step "bash script syntax" bash -c 'for f in scripts/*.sh .githooks/*; do bash -n "$f" || exit 1; done'
step "workflows and templates (valid YAML)" python3 -c "
import glob, yaml
for f in glob.glob('.gitea/**/*.yml', recursive=True):
yaml.safe_load(open(f))
"
step "brand assets" test -s logo.png -a -s apps/gui/src-tauri/icons/source.png -a -s apps/gui/src-tauri/icons/icon.ico
step "consistent Semantic Versioning 2.0.0" python3 -c "
import json, re, sys
from pathlib import Path
semver_re = re.compile(
r'^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)'
r'(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?'
r'(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$'
)
def cargo_version(path):
m = re.search(r'^version\s*=\s*\"([^\"]+)\"', Path(path).read_text(), re.M)
if not m:
sys.exit(f'version not found in {path}')
return m.group(1)
versions = [
cargo_version('Cargo.toml'),
cargo_version('apps/gui/src-tauri/Cargo.toml'),
json.load(open('apps/gui/src-tauri/tauri.conf.json'))['version'],
]
if len(set(versions)) != 1:
sys.exit(f'version mismatch: Cargo.toml={versions[0]}, src-tauri={versions[1]}, tauri.conf={versions[2]}')
if not semver_re.match(versions[0]):
sys.exit(f'invalid SemVer 2.0.0 version: {versions[0]}')
print(versions[0])
"
step "forbidden stack terms" check_forbidden_terms
if [ -f apps/gui/src-tauri/Cargo.toml ] && pkg-config --exists webkit2gtk-4.1 2>/dev/null; then
step "desktop app (cargo check)" env RUSTFLAGS="" cargo check --manifest-path apps/gui/src-tauri/Cargo.toml
else
printf '\n(desktop app: skipped, WebKitGTK development libraries not installed; built in CI)\n'
fi
if [ "$full" = 1 ]; then
step "tests (cargo test --workspace)" cargo test --workspace
step "dependency audit (cargo audit)" cargo audit
fi
printf '\n'
if [ ${#failures[@]} -eq 0 ]; then
printf '\033[32mall checks passed\033[0m: ready to push.\n'
else
printf '\033[31mfailed:\033[0m %s\n' "${failures[*]}"
exit 1
fi
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Generates the base Tauri icon from the official project logo.
The official logo is `logo.png` in the repository root (RGBA, transparent
background, suitable for both light and dark themes). It is not modified here.
Output:
apps/gui/src-tauri/icons/source.png 1024 px artwork centered over a rounded
dark background; CI and `npx @tauri-apps/cli icon`
derive all remaining icon formats from it.
Usage: python3 scripts/generate-logo.py
"""
import os
from PIL import Image, ImageDraw
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
ICONS_DIR = os.path.join(ROOT, "apps/gui/src-tauri/icons")
ICON_BG = (10, 16, 28) # dark navy background matching the original artwork
def crop_square(im, margin=0.04):
"""Crops to visible pixels (opacity > 16, ignoring faint artifacts) and centers in a square."""
box = im.getchannel("A").point(lambda v: 255 if v > 16 else 0).getbbox()
im = im.crop(box)
w, h = im.size
side = round(max(w, h) * (1 + 2 * margin))
canvas = Image.new("RGBA", (side, side), (0, 0, 0, 0))
canvas.paste(im, ((side - w) // 2, (side - h) // 2), im)
return canvas
def main():
logo = crop_square(Image.open(os.path.join(ROOT, "logo.png")).convert("RGBA"))
side = 1024
bg = Image.new("RGBA", (side, side), ICON_BG + (255,))
art = logo.resize((round(side * 0.82),) * 2, Image.LANCZOS)
bg.paste(art, ((side - art.width) // 2, (side - art.height) // 2), art)
mask = Image.new("L", (side, side), 0)
ImageDraw.Draw(mask).rounded_rectangle((0, 0, side - 1, side - 1), radius=round(side * 0.2237), fill=255)
icon = Image.new("RGBA", (side, side), (0, 0, 0, 0))
icon.paste(bg, (0, 0), mask)
os.makedirs(ICONS_DIR, exist_ok=True)
dest = os.path.join(ICONS_DIR, "source.png")
icon.save(dest, optimize=True)
os.chmod(dest, 0o644)
print("icons/source.png")
if __name__ == "__main__":
main()
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# Enables the repository Git hooks (.githooks/pre-push and .githooks/commit-msg).
set -euo pipefail
cd "$(dirname "$0")/.."
git config core.hooksPath .githooks
echo "hooks enabled: git commit validates Conventional Commits in English and git push runs scripts/check.sh (disable with: git config --unset core.hooksPath)"
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Builds the CLI for a target platform and packages it as expected by the updater:
# acaua-<tag>-<platform>.tar.gz alongside a .sha256 checksum file.
#
# scripts/package-cli.sh v1.0.3 linux-x86_64 x86_64-unknown-linux-gnu
#
# Supported platforms: linux-x86_64 and linux-aarch64. When cross-compiling
# from a non-x86_64 host, uses gcc-x86-64-linux-gnu. Writes the archives to the
# repository root and prints their filenames.
set -euo pipefail
cd "$(dirname "$0")/.."
tag=${1:?usage: package-cli.sh <tag> <platform> <rust-target>}
platform=${2:?usage: package-cli.sh <tag> <platform> <rust-target>}
target=${3:?usage: package-cli.sh <tag> <platform> <rust-target>}
case "$target" in
x86_64-unknown-linux-gnu)
if [ "$(uname -m)" != x86_64 ]; then
export CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=x86_64-linux-gnu-gcc
export CC_x86_64_unknown_linux_gnu=x86_64-linux-gnu-gcc
fi ;;
esac
rustup target add "$target" >/dev/null 2>&1 || true
cargo build --release --locked -p acaua-cli --target "$target"
name="acaua-${tag}-${platform}"
rm -rf "$name"; mkdir "$name"
cp "${CARGO_TARGET_DIR:-target}/${target}/release/acaua" "$name/"
cp README.md LICENSE-MIT LICENSE-APACHE "$name/"
tar czf "$name.tar.gz" "$name"
asset="$name.tar.gz"
rm -rf "$name"
sha256sum "$asset" > "$asset.sha256"
echo "$asset"
echo "$asset.sha256"
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Prints release notes for a given tag: extracts the matching CHANGELOG.md
# section, or falls back to the commit log since the previous tag.
#
# scripts/release-notes.sh v1.2.3
set -euo pipefail
tag=${1:?usage: release-notes.sh <tag>}
version=${tag#v}
section=""
if [ -f CHANGELOG.md ]; then
section=$(awk -v head="## [$version]" '
index($0, head) == 1 { found = 1; next }
found && /^## \[/ { exit }
found && /^\[[^]]+\]: / { next }
found { print }
' CHANGELOG.md)
fi
if [ -n "$(printf '%s' "$section" | tr -d '[:space:]')" ]; then
printf '%s\n' "$section"
else
previous=$(git describe --tags --abbrev=0 "$tag^" 2>/dev/null || true)
echo "## Changes${previous:+ since $previous}"
echo
git log --no-merges --format='- %s (%h)' "${previous:+$previous..}$tag"
fi
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Uploads assets to a Gitea release, replacing any existing asset with the same
# filename so re-running a CI job never fails on duplicate attachments.
#
# API=https://git.example.com/api/v1/repos/owner/repo TOKEN=... \
# scripts/release-upload.sh v1.2.3 file1 file2
set -euo pipefail
: "${API:?set API (Gitea repository API URL)}"
: "${TOKEN:?set TOKEN}"
tag=${1:?usage: release-upload.sh <tag> <file>...}
shift
auth=(-H "Authorization: token $TOKEN")
id=$(curl -fsS "${auth[@]}" "$API/releases/tags/$tag" | jq -r .id)
for file in "$@"; do
name=$(basename "$file")
encoded=$(jq -rn --arg n "$name" '$n|@uri')
for old in $(curl -fsS "${auth[@]}" "$API/releases/$id/assets" | jq -r --arg n "$name" '.[] | select(.name == $n) | .id'); do
curl -fsS -X DELETE "${auth[@]}" "$API/releases/$id/assets/$old"
done
curl -fsS -X POST "${auth[@]}" -F "attachment=@$file" "$API/releases/$id/assets?name=$encoded" >/dev/null
echo "uploaded: $name"
done