refactor: distribute Acaua as a Docker image instead of a desktop app
Release / Plan (reuse the published image?) (push) Successful in 3s
CI / Docker image (build and smoke test) (push) Skipped
CI / Format and clippy (push) Successful in 4m32s
CI / Dependency audit (push) Successful in 11s
CI / Tests (Linux) (push) Successful in 9m15s
Release / ci (push) Successful in 9m18s
Release / Build image (arm64) (push) Failing after 7m7s
Release / Build image (amd64) (push) Failing after 13m53s
Release / Publish tags (push) Skipped
Release / Gitea release (push) Skipped

Acaua is a collective SOC daemon consuming only Zeek log files, not a
single-user tool needing local hardware like its siblings. Drop the
Tauri desktop shell, installers and self-update entirely; the server
and its web dashboard ship as a container, updated by pulling a new
image tag.

Link against the official prebuilt libduckdb instead of compiling it
from source via the bundled feature, cutting image build time from
15+ minutes to about 3. Fix a real bug found along the way: as PID 1
in a container, the daemon ignored SIGTERM and was always killed
after the stop timeout; it now shuts down gracefully.

Also corrects the Pillar 1 beaconing score: the period estimate had a
harmonic alias (a perfectly periodic 4h series resolved to 600s), and
the old score formula flagged pure random traffic as suspicious
(p95 = 0.839 on Poisson noise). The new formula was validated against
300 samples per scenario.

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 19:54:45 -03:00
1 parent 1b974bb90d
commit 8a80504c45
72 files changed
+2500 -6360

No files matched your search

+2 -1
View File
@@ -1,8 +1,8 @@
.git
target
apps/gui/src-tauri
docs
scripts
!scripts/fetch-libduckdb.sh
research
data
.acaua
@@ -11,3 +11,4 @@ data
.claude
.wolf
.superpowers
.env
+8 -1
View File
@@ -1,2 +1,9 @@
# Interface captured by the Compose Zeek service. Use only on an authorized network.
# Interface captured by the Zeek service. Use only on a network you are authorized to monitor.
ZEEK_INTERFACE=eth0
# Acauã image tag: latest (last release), X.Y.Z / X.Y (pinned), or edge (main).
ACAUA_VERSION=latest
# Host address and port of the dashboard. Keep it on loopback unless an
# authenticating reverse proxy sits in front of it.
ACAUA_PUBLISH=127.0.0.1:8787
# Zeek image, pinned to an exact version.
ZEEK_IMAGE=zeek/zeek:9.0.1
+25 -20
View File
@@ -2,11 +2,9 @@ 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']
paths-ignore: ['**.md', 'docs/**', 'LICENSE*', '.gitignore', '.githooks/**', '.wolf/**', '.claude/**', '.opencode/**', '.codex/**', '.cursor/**', '.superpowers/**', '.superset/**', '.gitea/ISSUE_TEMPLATE/**', 'scripts/release-notes.sh', '.gitea/workflows/release.yml']
workflow_call:
concurrency:
group: ci-${{ gitea.ref }}
@@ -22,6 +20,11 @@ env:
CARGO_HOME: /opt/hostedtoolcache/cargo
RUSTUP_HOME: /opt/hostedtoolcache/rustup
NPM_CONFIG_CACHE: /opt/hostedtoolcache/npm-cache
# Prebuilt libduckdb (scripts/fetch-libduckdb.sh), cached on the persistent
# runner volume. Every CI job runs on linux-arm64.
DUCKDB_LIB_DIR: /opt/hostedtoolcache/duckdb/linux-arm64
DUCKDB_INCLUDE_DIR: /opt/hostedtoolcache/duckdb/linux-arm64
LD_LIBRARY_PATH: /opt/hostedtoolcache/duckdb/linux-arm64
jobs:
lint:
@@ -39,6 +42,10 @@ jobs:
node-version: 22
- name: UI translation coverage
run: node scripts/check-ui-i18n.mjs
- name: Prebuilt libduckdb
run: |
command -v unzip >/dev/null || scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/unzip unzip
scripts/fetch-libduckdb.sh "$DUCKDB_LIB_DIR"
- run: cargo fmt --all -- --check
- run: cargo clippy --workspace --all-targets
@@ -51,6 +58,10 @@ jobs:
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- name: Prebuilt libduckdb
run: |
command -v unzip >/dev/null || scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/unzip unzip
scripts/fetch-libduckdb.sh "$DUCKDB_LIB_DIR"
- run: cargo test --workspace
audit:
@@ -67,22 +78,16 @@ jobs:
command -v cargo-audit >/dev/null || cargo install cargo-audit --locked
cargo audit
gui:
name: Check desktop app (Linux)
image:
name: Docker image (build and smoke test)
if: ${{ gitea.event_name == 'pull_request' }}
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: ""
- name: Build
run: docker build -t "acaua-ci:${{ gitea.run_id }}" .
- name: Smoke test
run: scripts/smoke-image.sh "acaua-ci:${{ gitea.run_id }}"
- name: Clean up
if: always()
run: docker rmi -f "acaua-ci:${{ gitea.run_id }}" >/dev/null 2>&1 || true
+136 -99
View File
@@ -1,25 +1,146 @@
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).
# Publishes the multi-arch Acauã image (linux/amd64 + linux/arm64) to the Gitea
# container registry. A merge into main publishes `edge`; a v* tag re-points the
# image already built for the same inputs to X.Y.Z, X.Y and latest (no rebuild)
# and creates the Gitea Release. Needs the secret REGISTRY_TOKEN (Gitea token
# with write:package) visible to this repository.
on:
push:
branches: [main]
tags: ['v*']
workflow_dispatch:
concurrency:
group: release-${{ gitea.ref }}
cancel-in-progress: false
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
REGISTRY: git.lucasrguerra.dev.br
jobs:
release:
name: Create release
runs-on: ubuntu-latest
# The image is built only from these paths, so their tree hashes identify it.
# When inputs-<hash> already exists (docs-only merge, workflow change, or a tag
# on a commit main already published), nothing is built or tested again.
plan:
name: Plan (reuse the published image?)
runs-on: linux-arm64
outputs:
inputs: ${{ steps.plan.outputs.inputs }}
reuse: ${{ steps.plan.outputs.reuse }}
image: ${{ steps.plan.outputs.image }}
steps:
- uses: actions/checkout@v7
- id: plan
env:
REPO: ${{ gitea.repository }}
run: |
inputs=$(git rev-parse HEAD:Dockerfile HEAD:.dockerignore HEAD:Cargo.toml HEAD:Cargo.lock \
HEAD:crates HEAD:apps/gui/ui HEAD:logo.png HEAD:scripts/fetch-libduckdb.sh | sha256sum | cut -c1-16)
ns="${REPO,,}"
token=$(curl -fsS "https://$REGISTRY/v2/token?service=container_registry&scope=repository:$ns:pull" | jq -r .token)
code=$(curl -s -o /dev/null -w '%{http_code}' -I \
-H "Authorization: Bearer $token" \
-H 'Accept: application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json' \
"https://$REGISTRY/v2/$ns/manifests/inputs-$inputs")
echo "inputs-$inputs -> HTTP $code"
reuse=false; [ "$code" = 200 ] && reuse=true
echo "inputs=$inputs" >> "$GITHUB_OUTPUT"
echo "reuse=$reuse" >> "$GITHUB_OUTPUT"
echo "image=$REGISTRY/$ns" >> "$GITHUB_OUTPUT"
ci:
needs: plan
if: ${{ needs.plan.outputs.reuse != 'true' }}
uses: ./.gitea/workflows/ci.yml
# Each architecture is built on a native runner (no QEMU) and pushed under a
# per-architecture tag; publish joins them into one manifest list.
build:
name: Build image (${{ matrix.arch }})
needs: [plan, ci]
if: ${{ needs.plan.outputs.reuse != 'true' }}
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- { arch: amd64, platform: linux/amd64, runner: linux-amd64 }
- { arch: arm64, platform: linux/arm64, runner: linux-arm64 }
steps:
- uses: actions/checkout@v7
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push (per architecture)
uses: docker/build-push-action@v7
with:
context: .
platforms: ${{ matrix.platform }}
push: true
tags: ${{ needs.plan.outputs.image }}:build-${{ gitea.sha }}-${{ matrix.arch }}
cache-from: type=registry,ref=${{ needs.plan.outputs.image }}:buildcache-${{ matrix.arch }}
cache-to: type=registry,ref=${{ needs.plan.outputs.image }}:buildcache-${{ matrix.arch }},mode=max
# Runs the image that was just pushed, on its own architecture.
- name: Smoke test
run: scripts/smoke-image.sh "${{ needs.plan.outputs.image }}:build-${{ gitea.sha }}-${{ matrix.arch }}"
publish:
name: Publish tags
needs: [plan, build]
# build is skipped, not successful, when the image is reused.
if: ${{ !cancelled() && needs.plan.result == 'success' && (needs.build.result == 'success' || needs.plan.outputs.reuse == 'true') }}
runs-on: linux-arm64
steps:
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Tags
id: meta
uses: docker/metadata-action@v6
with:
images: ${{ needs.plan.outputs.image }}
flavor: latest=false
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable=${{ startsWith(gitea.ref, 'refs/tags/v') && !contains(gitea.ref_name, '-') }}
type=raw,value=edge,enable=${{ gitea.ref == 'refs/heads/main' }}
type=sha,format=long
- name: Apply tags
run: |
image='${{ needs.plan.outputs.image }}'
tags=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< '${{ steps.meta.outputs.json }}')
inputs='${{ needs.plan.outputs.inputs }}'
if [ '${{ needs.plan.outputs.reuse }}' = true ]; then
docker buildx imagetools create $tags "$image:inputs-$inputs"
else
docker buildx imagetools create $tags -t "$image:inputs-$inputs" \
"$image:build-${{ gitea.sha }}-amd64" "$image:build-${{ gitea.sha }}-arm64"
fi
- name: Check architectures
run: |
docker buildx imagetools inspect "${{ needs.plan.outputs.image }}:inputs-${{ needs.plan.outputs.inputs }}" \
--format '{{json .Manifest}}' \
| jq -r '.manifests[] | select(.platform.os == "linux") | "\(.platform.os)/\(.platform.architecture)"' \
| tee platforms.txt
grep -qx linux/amd64 platforms.txt && grep -qx linux/arm64 platforms.txt
- name: Summary
run: |
{ echo "### Acauã image published (linux/amd64, linux/arm64)"; echo; echo '```'
echo "${{ steps.meta.outputs.tags }}"; echo '```'; } >> "$GITHUB_STEP_SUMMARY"
gitea-release:
name: Gitea release
needs: [plan, publish]
if: ${{ !cancelled() && needs.publish.result == 'success' && startsWith(gitea.ref, 'refs/tags/v') }}
runs-on: linux-arm64
steps:
- uses: actions/checkout@v7
with:
@@ -29,98 +150,14 @@ jobs:
TAG: ${{ gitea.ref_name }}
TOKEN: ${{ gitea.token }}
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
IMAGE: ${{ needs.plan.outputs.image }}
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
{ scripts/release-notes.sh "$TAG"; echo; echo '```bash'; echo "docker pull $IMAGE:${TAG#v}"; echo '```'; } > 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/*
-7
View File
@@ -1,10 +1,6 @@
# 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
@@ -37,6 +33,3 @@ research/.ipynb_checkpoints/
CLAUDE.md
AGENTS.md
GEMINI.md
# Logo copied into the UI directory by the desktop build script
/apps/gui/ui/logo.png
+13
View File
@@ -6,6 +6,19 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versio
## [Unreleased]
### Changed
- Distribution pivoted to 100% Docker and docker-compose (server daemon and web dashboard); native binary tarballs and desktop installers are discontinued.
- DuckDB is now a mandatory runtime dependency, dynamically linked against the official prebuilt `libduckdb.so` (v1.5.6) with fixed SHA-256 via `scripts/fetch-libduckdb.sh`.
- Upgraded workspace MSRV to Rust 1.88 (required by `duckdb ~1.10506.1`).
- Upgraded container runtime and base image to Debian 13 trixie.
### Fixed
- Graceful shutdown on `SIGTERM` when running as PID 1 in container environments.
### Removed
- Tauri desktop application (`apps/gui/src-tauri`) and associated desktop build scripts.
- Self-update flag (`acaua update --apply`) and in-place binary overwrite. Updates are now handled via image pulls (`docker compose pull`).
## [0.1.0] - 2026-10-31
### Added
+13
View File
@@ -6,6 +6,19 @@ Formato baseado no [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/);
## [Unreleased]
### Modificado
- Distribuição migrada para 100% Docker e docker-compose (daemon servidor e dashboard web); tarballs binários nativos e instaladores desktop descontinuados.
- DuckDB agora é dependência obrigatória em tempo de execução, linkada dinamicamente contra a `libduckdb.so` oficial pré-compilada (v1.5.6) com SHA-256 fixado via `scripts/fetch-libduckdb.sh`.
- MSRV do workspace atualizado para Rust 1.88 (exigido pelo `duckdb ~1.10506.1`).
- Runtime do container e imagem base atualizados para Debian 13 trixie.
### Corrigido
- Encerramento limpo em `SIGTERM` ao rodar como PID 1 em ambientes de container.
### Removido
- Aplicativo desktop Tauri (`apps/gui/src-tauri`) e scripts de build desktop associados.
- Flag de autoatualização (`acaua update --apply`) e sobrescrita de binário em disco. Atualizações agora são feitas via pull de imagem (`docker compose pull`).
## [0.1.0] - 2026-10-31
### Adicionado
+11 -27
View File
@@ -2,8 +2,8 @@
**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,
Thanks for helping. The project is an NDR engine built around deterministic evidence.
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
@@ -15,26 +15,22 @@ well-scoped design corrections.
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
commit messages are English. The web dashboard remains bilingual (PT-BR and EN
through `i18n-en.js`).
## Workflow
```bash
scripts/install-hooks.sh
scripts/fetch-libduckdb.sh ~/.cache/acaua/duckdb
export DUCKDB_LIB_DIR=~/.cache/acaua/duckdb LD_LIBRARY_PATH=~/.cache/acaua/duckdb
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
```
coverage, shell syntax, workflow YAML, SemVer consistency, DuckDB version pin
verification and forbidden terms inspection. `--full` adds workspace tests and `cargo audit`.
## Research
@@ -42,18 +38,6 @@ cargo check -p acaua-core --features duckdb
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
@@ -70,11 +54,11 @@ when the word actually changes, as in `architecture.md` → `arquitetura.md`.
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.
2.0.0.
Pushing a `v*` tag creates a Gitea Release from the matching `CHANGELOG.md`
section and attaches the Linux CLI packages plus the desktop bundles.
Releases are published as multi-arch container images (`linux/amd64` and `linux/arm64`)
to the Gitea registry. Pushing a `v*` tag creates a Gitea Release from the matching
`CHANGELOG.md` section and tags the existing container image.
## License
+37 -54
View File
@@ -2,83 +2,66 @@
[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.
Obrigado por ajudar. O projeto é um motor de NDR construído em torno de evidência determinística.
As contribuições mais úteis agora são testes, fixtures, documentação e correções
arquiteturais pontuais.
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`).
1. **Apenas monitoramento autorizado.** O Acauã é software defensivo para redes
próprias ou redes que você tem autorização expressa para observar. Não adicione
mecanismos de coleta que ocultem escopo, retenção ou evidências.
2. **Evidência é parte do recurso.** Uma alteração de detecção deve declarar suas
entradas, reduções em ordem fixa e valores intermediários. Não são aceitas
pontuações sem registro reproduzível de evidência.
3. **Seja transparente sobre o que funciona.** Se um recurso for um stub, limitado
a Linux ou restrito a um log Zeek específico, documente no README, na ajuda da
CLI e nos testes.
4. **Base de código 100% em inglês.** Código-fonte, comentários, scripts, workflows
e mensagens de commit são em inglês. O dashboard web permanece bilíngue (PT-BR
e EN via `i18n-en.js`).
## Fluxo
## Fluxo de trabalho
```bash
scripts/install-hooks.sh
scripts/fetch-libduckdb.sh ~/.cache/acaua/duckdb
export DUCKDB_LIB_DIR=~/.cache/acaua/duckdb LD_LIBRARY_PATH=~/.cache/acaua/duckdb
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
```
`check.sh` executa formatação, clippy com warnings como erro, cobertura de
tradução da UI, sintaxe de shell, YAML de workflows, consistência de SemVer,
validação de pin da DuckDB e termos proibidos. `--full` inclui os testes do
workspace e `cargo audit`.
## 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.
`research/` é um espaço Python isolado para validação científica. Pode utilizar
astropy, scipy, networkx, pandas e notebooks. Nunca deve ser importado pelo runtime
em Rust, copiado para uma release ou exigido para compilar o produto.
## 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.
O texto original da interface é em português. O inglês vem de `apps/gui/ui/i18n-en.js`,
aplicado em runtime por `i18n.js`. Ao adicionar texto visível, execute
`node scripts/check-ui-i18n.mjs` e forneça o par correspondente 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`.
Mantenha os pares: todo arquivo `*.md` e `docs/*` possui uma versão em inglês e
uma em português (`*.pt-BR.md`, `docs/pt-BR/`). Nomes de arquivo são traduzidos
apenas quando a palavra de fato muda, como em `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.
Commits seguem Conventional Commits 1.0.0 em inglês (`feat:`, `fix:`, `chore:`,
`ci:`, `docs:`, `refactor:`, `test:`, `perf:`, `build:`). Versões seguem SemVer 2.0.0.
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.
Releases são publicadas como imagens de container multi-arquitetura (`linux/amd64`
e `linux/arm64`) no registry do Gitea. Enviar uma tag `v*` gera a release no Gitea
a partir do `CHANGELOG.md` e re-aponta as tags da imagem do container existente.
## Licença
Ao contribuir, você concorda em licenciar sob [MIT](LICENSE-MIT) **ou**
[Apache-2.0](LICENSE-APACHE), como o resto do projeto.
[Apache-2.0](LICENSE-APACHE), em conformidade com o restante do projeto.
Generated
+230 -67
View File
@@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
version = 4
[[package]]
name = "acaua-cli"
@@ -23,6 +23,7 @@ dependencies = [
name = "acaua-core"
version = "0.1.0"
dependencies = [
"chrono",
"duckdb",
"serde",
"serde_json",
@@ -122,9 +123,9 @@ dependencies = [
[[package]]
name = "arrow"
version = "60.1.0"
version = "58.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d965156f2220e94dcf676e04322b7b0b41c97e85cb3e1c365b8e56d0f70be999"
checksum = "c943b9bec582e47f8394e44713c3cbfbe645fd049e3a7771585f54849c710fe6"
dependencies = [
"arrow-arith",
"arrow-array",
@@ -140,9 +141,9 @@ dependencies = [
[[package]]
name = "arrow-arith"
version = "60.1.0"
version = "58.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca16fdd1daeb1eccefb42df52ec65448ef33eec72488598675d46dfa9ce58923"
checksum = "8b13b85a6a547a1669114e418f6ab3c3e39c2edb1b8be5ab2889e5739a433ae9"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -154,9 +155,9 @@ dependencies = [
[[package]]
name = "arrow-array"
version = "60.1.0"
version = "58.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f3d22a41d3df78b71df0b685af219ae3a94188ab98d6fb29c958eb55c3247f4"
checksum = "7b11eb61b95868592ad6edbd537465a6940eb3c81a875addecec24283301b267"
dependencies = [
"ahash",
"arrow-buffer",
@@ -165,7 +166,6 @@ dependencies = [
"chrono",
"half",
"hashbrown 0.17.1",
"libc",
"num-complex",
"num-integer",
"num-traits",
@@ -173,9 +173,9 @@ dependencies = [
[[package]]
name = "arrow-buffer"
version = "60.1.0"
version = "58.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7bd46a530bc1a8e763102fc36c1dc0f51f3ffa150c78e7f5658ccb54b1458ade"
checksum = "53ee609a07eb721c2e4a7669d7bb88e2106c560e7dec1219096fecf5eae356d1"
dependencies = [
"bytes",
"half",
@@ -185,9 +185,9 @@ dependencies = [
[[package]]
name = "arrow-cast"
version = "60.1.0"
version = "58.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baa83a0752814a1922d5ee75032524ca9bbfb7f60ee35a67ed4ab8b275260aaf"
checksum = "9575323ffeb4bc6955f39bf8716f734671a32780f1fac9ba6c2891048fc0ea1f"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -196,7 +196,7 @@ dependencies = [
"arrow-schema",
"arrow-select",
"atoi",
"base64",
"base64 0.22.1",
"chrono",
"comfy-table",
"half",
@@ -205,22 +205,11 @@ dependencies = [
"ryu",
]
[[package]]
name = "arrow-cmp"
version = "60.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1913e57a64fbf2c4502fa6d54b547d6a1ae59e3146cbc15321d47468c937abd1"
dependencies = [
"arrow-array",
"arrow-buffer",
"arrow-schema",
]
[[package]]
name = "arrow-data"
version = "60.1.0"
version = "58.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "415b7fbc4cf56b080bb5677a4ae74211b5c79e571ed37a1d547633ec07f3b43b"
checksum = "10791765e6927cdd8ace5bf0e51a69f38a1c3aded2db93561b003c332d02ee2a"
dependencies = [
"arrow-buffer",
"arrow-schema",
@@ -231,13 +220,12 @@ dependencies = [
[[package]]
name = "arrow-ord"
version = "60.1.0"
version = "58.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c58f2ae6dd1480fc413f7320a547e39dcc499c23d4f36ca19572b685811e6e4"
checksum = "908a1b5aeefce5c6eb2a0a4e98f4ccacbd441fe0ed23fb799e634cd0135821e5"
dependencies = [
"arrow-array",
"arrow-buffer",
"arrow-cmp",
"arrow-data",
"arrow-schema",
"arrow-select",
@@ -245,9 +233,9 @@ dependencies = [
[[package]]
name = "arrow-row"
version = "60.1.0"
version = "58.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68f9db8345b0638416d3b56d117d262d13070fdef788c6d75b53a180215c477e"
checksum = "5383b1676492a509032f06fa4ca9522c886036aec4ebe8261bebfbce904af667"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -258,23 +246,22 @@ dependencies = [
[[package]]
name = "arrow-schema"
version = "60.1.0"
version = "58.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de2c8e714b466404cdc1189e89f892426efea614d938e16c15518c340cd43ba3"
checksum = "8506ad465ebb9483bb6efa1e2e78a313549122c6dd67dc2f7087297b762eaede"
dependencies = [
"bitflags",
]
[[package]]
name = "arrow-select"
version = "60.1.0"
version = "58.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5af3f6656c5f2c6944f93d9a357a7f204c93d5f349385a44cf46c4a26fbcf30"
checksum = "fd0f0a0d641356ae2df829af5d635ed9fb93fbeb0d39f3239487cb5fdb37a6ef"
dependencies = [
"ahash",
"arrow-array",
"arrow-buffer",
"arrow-cmp",
"arrow-data",
"arrow-schema",
"num-traits",
@@ -282,9 +269,9 @@ dependencies = [
[[package]]
name = "arrow-string"
version = "60.1.0"
version = "58.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab1be9306b9a413b5b004e2a5a19caac728870d76e8891b351f040d554c93862"
checksum = "bd245a358d52e77bfcb99dc3e21f04b947383271b7a0b9ca8feec43e9039234d"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -311,9 +298,9 @@ dependencies = [
[[package]]
name = "atoi"
version = "3.1.1"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d12c37257553c19e9aee6cba77814fdc9e07844abfc8b3a2c377cd920ee5a993"
checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"
dependencies = [
"num-traits",
]
@@ -382,6 +369,12 @@ dependencies = [
"tracing",
]
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64"
version = "0.23.1"
@@ -437,8 +430,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
@@ -448,6 +439,12 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "chrono"
version = "0.4.45"
@@ -507,10 +504,11 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "comfy-table"
version = "8.0.1"
version = "7.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b96a46ad395a5e00676eddaca4a45ed78b29c08be5a878d35e4b071c625637dd"
checksum = "4a65ebfec4fb190b6f90e944a817d60499ee0744e582530e2c9900a22e591d9a"
dependencies = [
"crossterm",
"unicode-segmentation",
"unicode-width",
]
@@ -591,6 +589,28 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crossterm"
version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
dependencies = [
"bitflags",
"crossterm_winapi",
"parking_lot",
"rustix 0.38.44",
"winapi",
]
[[package]]
name = "crossterm_winapi"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
dependencies = [
"winapi",
]
[[package]]
name = "crunchy"
version = "0.2.4"
@@ -645,12 +665,13 @@ dependencies = [
[[package]]
name = "duckdb"
version = "1.20000.1"
version = "1.10506.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d807ca47d4fa206f102ff568046e018fb84518eb0a0b5e65a165a2eb7bd4a7c2"
checksum = "1a46cc5f64f534993bae616668a94b1b8e58295c2bd39f85afef7c44297817da"
dependencies = [
"arrow",
"cast",
"comfy-table",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
@@ -659,6 +680,12 @@ dependencies = [
"strum",
]
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "errno"
version = "0.3.14"
@@ -799,8 +826,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 6.0.0",
"wasm-bindgen",
]
[[package]]
@@ -957,6 +986,16 @@ dependencies = [
"cc",
]
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown 0.17.1",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@@ -969,16 +1008,6 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jobserver"
version = "0.1.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
dependencies = [
"getrandom 0.4.3",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.107"
@@ -1061,19 +1090,18 @@ checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
[[package]]
name = "libduckdb-sys"
version = "1.20000.1"
version = "1.10506.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0140448c472968ee5cf5ba697435deebe558b366ef1e5c080d24d9696b600725"
checksum = "113d1c26c9f518405a17b5729bb6ab6abf029fdc46626406922cf9a8dc9b4609"
dependencies = [
"cc",
"flate2",
"pkg-config",
"serde",
"serde_json",
"sha2 0.11.1",
"tar",
"ureq",
"vcpkg",
"zip",
]
[[package]]
@@ -1082,12 +1110,27 @@ version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.35"
@@ -1143,6 +1186,18 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "nix"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -1154,9 +1209,9 @@ dependencies = [
[[package]]
name = "num-bigint"
version = "0.5.1"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0"
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
"num-integer",
"num-traits",
@@ -1208,6 +1263,29 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -1262,6 +1340,15 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
]
[[package]]
name = "regex"
version = "1.13.1"
@@ -1340,6 +1427,19 @@ dependencies = [
"walkdir",
]
[[package]]
name = "rustix"
version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys 0.4.15",
"windows-sys 0.52.0",
]
[[package]]
name = "rustix"
version = "1.1.6"
@@ -1349,7 +1449,7 @@ dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
]
@@ -1409,6 +1509,12 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "serde"
version = "1.0.229"
@@ -1631,7 +1737,7 @@ dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
"rustix",
"rustix 1.1.6",
"windows-sys 0.61.2",
]
@@ -1848,6 +1954,12 @@ dependencies = [
"tracing-log",
]
[[package]]
name = "typed-path"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
[[package]]
name = "typenum"
version = "1.20.1"
@@ -1890,7 +2002,7 @@ version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b"
dependencies = [
"base64",
"base64 0.23.1",
"log",
"percent-encoding",
"rustls",
@@ -1906,7 +2018,7 @@ version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922"
dependencies = [
"base64",
"base64 0.23.1",
"http",
"httparse",
"log",
@@ -2020,6 +2132,22 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
@@ -2029,6 +2157,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-core"
version = "0.62.2"
@@ -2183,7 +2317,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "963d6fab92d5504b971dc8bd90ca743c2575e590bc8071ea5bc571598667ae08"
dependencies = [
"libc",
"rustix",
"rustix 1.1.6",
]
[[package]]
@@ -2212,6 +2346,23 @@ version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
[[package]]
name = "zip"
version = "9.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f5b58784007ff6e5f280ec82c8596686a3b9b5dd5836c63288d4479f84728b9"
dependencies = [
"crc32fast",
"flate2",
"getrandom 0.4.3",
"indexmap",
"memchr",
"nix",
"typed-path",
"windows-sys 0.61.2",
"zopfli",
]
[[package]]
name = "zlib-rs"
version = "0.6.8"
@@ -2223,3 +2374,15 @@ name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zopfli"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
+2 -2
View File
@@ -1,7 +1,7 @@
[workspace]
resolver = "2"
members = ["crates/acaua-core", "crates/acaua-cli"]
exclude = ["apps/gui/src-tauri", "research"]
exclude = ["research"]
[workspace.package]
version = "0.1.0"
@@ -9,7 +9,7 @@ edition = "2021"
license = "MIT OR Apache-2.0"
repository = "https://git.lucasrguerra.dev.br/ciberfauna/acaua"
authors = ["Acauã contributors"]
rust-version = "1.80"
rust-version = "1.88"
[profile.release]
lto = "thin"
+34 -6
View File
@@ -1,23 +1,51 @@
# syntax=docker/dockerfile:1
#
# Acauã server image: the acaua daemon (API and embedded web dashboard) linked
# against the official prebuilt libduckdb. DuckDB is never compiled here.
FROM rust:1-slim-bookworm AS build
# Build stage.
FROM rust:1.99-slim-trixie AS build
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl unzip \
&& rm -rf /var/lib/apt/lists/*
COPY scripts/fetch-libduckdb.sh /usr/local/bin/fetch-libduckdb.sh
RUN fetch-libduckdb.sh /opt/duckdb \
&& echo /opt/duckdb > /etc/ld.so.conf.d/duckdb.conf \
&& ldconfig
ENV DUCKDB_LIB_DIR=/opt/duckdb \
DUCKDB_INCLUDE_DIR=/opt/duckdb
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
# The ldd check fails the build if the binary is not linked to the shared
# libduckdb (for example if someone re-enables the `bundled` feature).
RUN cargo build --release --locked -p acaua-cli \
&& ldd target/release/acaua | grep -q 'libduckdb.so => /opt/duckdb/libduckdb.so'
FROM debian:bookworm-slim
# Runtime stage: Debian slim, the binary, libduckdb.so, CA certificates and
# curl (update check and HEALTHCHECK). Runs as an unprivileged user.
FROM debian:trixie-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl tar \
&& rm -rf /var/lib/apt/lists/*
&& apt-get install -y --no-install-recommends ca-certificates curl libstdc++6 \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --system --gid 10001 acaua \
&& useradd --system --uid 10001 --gid 10001 --home-dir /data --shell /usr/sbin/nologin acaua \
&& mkdir -p /data/acaua /data/zeek \
&& chown acaua:acaua /data/acaua
COPY --from=build /opt/duckdb/libduckdb.so /opt/duckdb/.duckdb-release /opt/duckdb/
RUN echo /opt/duckdb > /etc/ld.so.conf.d/duckdb.conf && ldconfig
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.source="https://git.lucasrguerra.dev.br/ciberfauna/acaua" \
org.opencontainers.image.licenses="MIT OR Apache-2.0"
ENV ACAUA_RUNTIME=container
USER acaua
WORKDIR /data
EXPOSE 8787
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -fsS http://127.0.0.1:8787/api/health >/dev/null || exit 1
ENTRYPOINT ["acaua"]
CMD ["serve", "--bind", "0.0.0.0:8787", "--data-dir", "/data/acaua"]
+34 -30
View File
@@ -26,9 +26,8 @@ not an opaque model. The goal is to answer not only "this looks suspicious" but
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`.
- **Server daemon and web dashboard:** `acaua serve` runs continuous ingestion,
scheduling, the API and the embedded web UI, distributed 100% via Docker.
- **Research stays outside the product:** `research/` is an isolated Python
workspace for scientific validation and fixtures. It is never compiled,
packaged or released.
@@ -39,11 +38,12 @@ not an opaque model. The goal is to answer not only "this looks suspicious" but
|---|---|
| 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 |
| Embedded web dashboard | ✅ UI shell, served by daemon |
| Documentation and CI/release workflows | ✅ |
| Docker multi-arch image and compose deployment | ✅ |
| DuckDB analytical store | ✅ official prebuilt library linked |
| 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 |
@@ -70,20 +70,34 @@ 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.
The daemon binds to `127.0.0.1` by default. The Docker Compose deployment
isolates the daemon in an unprivileged, read-only container with dropped capabilities.
## Install
There are no published releases yet. Build from source:
Run via Docker Compose (recommended for operators):
```bash
cp .env.example .env
docker compose pull && docker compose up -d
```
Or build and run locally with Docker:
```bash
docker compose up -d --build
```
To compile from source natively (Rust 1.88+ required):
```bash
scripts/fetch-libduckdb.sh ~/.cache/acaua/duckdb
export DUCKDB_LIB_DIR=~/.cache/acaua/duckdb LD_LIBRARY_PATH=~/.cache/acaua/duckdb
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.
Supported platform matrix for published container images: `linux/amd64` and `linux/arm64`.
## Getting started
@@ -95,39 +109,29 @@ 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)**.
does not parse Zeek logs yet. `serve` exposes the stub API and the embedded web UI.
`update` checks for new versions and guides container image updates.
## Development
```bash
scripts/fetch-libduckdb.sh ~/.cache/acaua/duckdb
export DUCKDB_LIB_DIR=~/.cache/acaua/duckdb LD_LIBRARY_PATH=~/.cache/acaua/duckdb
cargo test --workspace
cargo clippy --workspace --all-targets
node scripts/check-ui-i18n.mjs
scripts/check.sh
scripts/check.sh --full
```
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)**.
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.
- There is no published release image 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.
- The project targets Linux container environments (`linux/amd64`, `linux/arm64`).
## Where it's going
@@ -135,9 +139,9 @@ Summary of the [ROADMAP](ROADMAP.md):
| | |
|---|---|
| **Done** | Workspace, CLI/API stubs, GUI shell, documentation, CI/release workflows |
| **Done** | Workspace, CLI/API stubs, web dashboard, Docker containerization, 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 |
| **Later** | Full Parquet partitioning, TTL pruning, pillar 3 and 4, evidence fusion |
| **Not planned** | Opaque AI scoring, a required cloud service, Python in the shipped runtime |
## License
+34 -29
View File
@@ -27,9 +27,8 @@ produziram esta pontuação".
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.
- **Daemon servidor e dashboard web:** `acaua serve` roda ingestão contínua,
agendador, API e o dashboard web embutido, distribuído 100% via Docker.
- **Pesquisa fora do produto:** `research/` é um espaço Python isolado para
validação científica e fixtures. Ele nunca é compilado, empacotado ou
distribuído.
@@ -40,11 +39,12 @@ produziram esta pontuação".
|---|---|
| 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 |
| Dashboard web embutido | ✅ shell da UI, servido pelo daemon |
| Documentação e workflows de CI/release | ✅ |
| Imagem Docker multi-arch e deployment com Compose | ✅ |
| Armazenamento analítico DuckDB | ✅ biblioteca oficial pré-compilada linkada |
| 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 |
@@ -72,20 +72,34 @@ autorização explícita para monitorar. Capturar ou analisar tráfego de tercei
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.
O daemon escuta em `127.0.0.1` por padrão. A implantação via Docker Compose
isola o daemon em um container não privilegiado, somente leitura e sem capabilities.
## Instalação
Ainda não há releases publicadas. Compile do código:
Executar via Docker Compose (recomendado para operadores):
```bash
cp .env.example .env
docker compose pull && docker compose up -d
```
Ou compilar e rodar localmente com Docker:
```bash
docker compose up -d --build
```
Para compilar do código nativamente (requer Rust 1.88+):
```bash
scripts/fetch-libduckdb.sh ~/.cache/acaua/duckdb
export DUCKDB_LIB_DIR=~/.cache/acaua/duckdb LD_LIBRARY_PATH=~/.cache/acaua/duckdb
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.
Matriz de plataformas suportadas para as imagens publicadas: `linux/amd64` e `linux/arm64`.
## Começando
@@ -97,39 +111,30 @@ 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)**.
ainda não lê logs Zeek. O `serve` expõe a API stub e o dashboard web embutido.
O `update` checa por versões novas e orienta a atualização da imagem do container.
## Desenvolvimento
```bash
scripts/fetch-libduckdb.sh ~/.cache/acaua/duckdb
export DUCKDB_LIB_DIR=~/.cache/acaua/duckdb LD_LIBRARY_PATH=~/.cache/acaua/duckdb
cargo test --workspace
cargo clippy --workspace --all-targets
node scripts/check-ui-i18n.mjs
scripts/check.sh
scripts/check.sh --full
```
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
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.
- Não há imagem de release publicada ainda.
- 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.
- O projeto mira ambientes de container Linux (`linux/amd64`, `linux/arm64`).
## Para onde vai
@@ -137,9 +142,9 @@ Resumo do [ROADMAP](ROADMAP.pt-BR.md):
| | |
|---|---|
| **Feito** | Workspace, stubs de CLI/API, shell da GUI, documentação, workflows de CI/release |
| **Feito** | Workspace, stubs de CLI/API, dashboard web, containerização Docker, 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 |
| **Depois** | Particionamento Parquet completo, limpeza por TTL, pilares 3 e 4, fusão de evidências |
| **Fora de plano** | Pontuação por IA opaca, serviço de nuvem obrigatório, Python no runtime distribuído |
## Licença
+10 -18
View File
@@ -3,23 +3,22 @@
**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.
**under development**; they represent the core analytical 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.
- CLI commands, Axum API and embedded web dashboard.
- 100% Docker containerization, compose deployment, non-root hardened profile.
- Prebuilt DuckDB linking and Parquet storage foundations.
- Stubs for the four pillars, evidence fusion, and Zeek ingestion.
- English/Portuguese documentation, CI/release workflows and `scripts/check.sh`.
- Deterministic smoke tests and a bilingual UI check.
- Deterministic smoke tests and bilingual UI coverage checks.
## 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.
event timestamps, add harmonic resistance, and verify with deterministic fixtures.
2. **Pillar 2 — persistent sessions.** Detect long connections that never
produce a short interval, with explicit thresholds and no overlap with
pillar 1.
@@ -33,9 +32,8 @@ This roadmap separates what exists from what is planned. The four pillars are
- 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.
- Analytical store optimizations, daily Parquet partitioning and TTL pruning.
- Scheduled ingestion and automated log rotation handling in daemon mode.
- NetFlow/IPFIX as a documented degraded mode and Suricata EVE JSON as an
optional secondary input.
- Signed release metadata, SBOM and reproducible builds.
@@ -46,10 +44,4 @@ This roadmap separates what exists from what is planned. The four pillars are
- 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.
- Native desktop shell or binary self-mutation.
+24 -36
View File
@@ -2,56 +2,44 @@
[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.
Este roadmap separa o que existe do que está planejado. Os quatro pilares estão
**em desenvolvimento**; eles representam o trabalho analítico principal.
## 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.
- Comandos CLI, API Axum e dashboard web embutido.
- Containerização 100% Docker, implantação Compose, perfil endurecido não-root.
- Linkagem da DuckDB pré-compilada e fundações de armazenamento Parquet.
- Stubs dos quatro pilares, fusão de evidências e ingestão Zeek.
- 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.
- Testes determinísticos de fumaça e checagem de tradução 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.
1. **Pilar 1 — beaconing.** Implementar o periodograma de Rayleigh/Schuster sobre
timestamps de eventos, resistência a harmônicos e validação com fixtures determinísticas.
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.
produzem intervalos curtos, com limiares explícitos sem sobreposição ao pilar 1.
3. **Parser Zeek real.** Ler `conn.log`, `dns.log`, `ssl.log` e logs de protocolo
com memória delimitada, confinamento de caminhos e ordem determinística.
4. **Registro de evidência.** Emitir registro JSON por alerta contendo 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.
movimentação lateral.
- Pilar 4 — fusão limitada e simétrica com pesos reproduzíveis.
- Otimizações no store analítico, particionamento diário Parquet e limpeza por TTL.
- Ingestão agendada e tratamento automático de rotação de logs em modo daemon.
- NetFlow/IPFIX como modo degradado documentado e Suricata EVE JSON como entrada secundária.
- Metadados assinados de release, 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.
- Pontuação por IA opaca que não consiga explicar um alerta.
- Serviço em 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.
- Tratar registros de fluxo como equivalentes a logs Zeek completos.
- Shell desktop nativo ou auto-modificação de binário.
+8 -5
View File
@@ -15,6 +15,9 @@ reproduction when a private channel is available.
## Scope and threat model
- **Container hardening:** the container runs as an unprivileged user (`acaua`, UID 10001),
with a read-only root filesystem (`read_only: true`), all capabilities dropped
(`cap_drop: [ALL]`), and security hardening (`no-new-privileges:true`).
- **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
@@ -25,18 +28,18 @@ reproduction when a private channel is available.
- **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.
- **Updates:** updates are delivered as verified multi-arch container images.
The daemon terminates gracefully upon receiving `SIGTERM` without data corruption.
- **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.
- Vulnerabilities in third-party Zeek, DuckDB 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.
- Detection quality claims: the pillars are defensive heuristics, not absolute guarantees.
## Supported versions
+32 -29
View File
@@ -2,44 +2,47 @@
[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
O Acauã é software defensivo, mas processa entradas hostis: capturas de pacotes,
logs Zeek e registros de fluxo podem ser intencionalmente malformados. Trate todo
dataset como não confiável.
## Reportando uma vulnerabilidade
## Relatando 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.
Não abra uma issue pública com detalhes de exploração. Abra uma issue mínima
solicitando canal privado ou contate o mantenedor pelo perfil do Gitea listado
no repositório. Inclua a versão afetada, o formato da entrada e uma reprodução
mínima quando o canal privado estiver estabelecido.
## Escopo e modelo de ameaça
## Escopo e modelo de ameaças
- **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
- **Endurecimento do container:** o container executa com usuário não privilegiado
(`acaua`, UID 10001), filesystem raiz somente leitura (`read_only: true`), todas
as capabilities revogadas (`cap_drop: [ALL]`), e endurecimento de segurança (`no-new-privileges:true`).
- **Parsing de entradas:** uma linha de log malformada não deve causar panic no
daemon, exaurir memória ou escapar do diretório de dados configurado.
- **Manipulação de caminhos:** caminhos de dataset são resolvidos e confinados à
raiz configurada; symlinks externos não são seguidos.
- **Exposição de API:** `serve` escuta em `127.0.0.1` por padrão. Expô-lo em uma
interface de rede é decisão explícita do operador e exige um 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.
- **Integridade da evidência:** a evidência de um alerta deve ser reproduzível a
partir da mesma entrada e versão de código. Nãodeterminismo silencioso é falha de
corretude e segurança.
- **Atualizações:** atualizações são distribuídas como imagens multi-arch de
container verificadas. O daemon finaliza de forma limpa ao receber `SIGTERM`
sem corrupção de dados.
- **Privacidade:** não há telemetria. A retenção padrão é de sete dias para
eventos normalizados; o operador é responsável pela conformidade legal 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.
- Vulnerabilidades em pacotes upstream do Zeek, DuckDB ou do sistema operacional
já corrigidas nas fontes.
- Ataques que dependam de desativar os controles de segurança documentados
(como executar `serve` como root em interface pública).
- Alegações de qualidade de detecção: os pilares são heurísticas defensivas, não garantias absolutas.
## 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.
Apenas a linha `0.1.x` mais recente tem suporte durante a fase pré-alfa. Não há
branch de suporte de longo prazo por enquanto.
-4314
View File
File diff suppressed because it is too large. Load diff
-24
View File
@@ -1,24 +0,0 @@
[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
@@ -1,5 +0,0 @@
fn main() {
println!("cargo:rerun-if-changed=../../../logo.png");
let _ = std::fs::copy("../../../logo.png", "../ui/logo.png");
tauri_build::build();
}
@@ -1,7 +0,0 @@
{
"$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.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 452 KiB

-62
View File
@@ -1,62 +0,0 @@
#![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
@@ -1,39 +0,0 @@
{
"$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"
]
}
}
-2
View File
@@ -1,7 +1,6 @@
import { setLang, tr } from './i18n.js';
const $ = (selector) => document.querySelector(selector);
const tauriInvoke = window.__TAURI__?.core?.invoke;
const connection = $('#connection');
const connectionText = $('#connectionText');
@@ -30,7 +29,6 @@ async function fetchJson(path, options = {}) {
}
async function call(command, args = {}) {
if (tauriInvoke) return tauriInvoke(command, args);
switch (command) {
case 'app_info':
return fetchJson('/api/status');
+19 -12
View File
@@ -25,6 +25,8 @@ enum Command {
},
Analyze {
directory: PathBuf,
#[arg(long, default_value = ".acaua")]
data_dir: PathBuf,
#[arg(long)]
json: bool,
},
@@ -32,10 +34,7 @@ enum Command {
#[arg(long)]
json: bool,
},
Update {
#[arg(long)]
apply: bool,
},
Update,
}
#[tokio::main]
@@ -50,8 +49,12 @@ async fn main() {
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)?;
Command::Analyze {
directory,
data_dir,
json,
} => {
let report = zeek::analyze_dir(&directory, &data_dir)?;
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
@@ -59,6 +62,9 @@ async fn run(cli: Cli) -> acaua_core::Result<()> {
println!("logs lidos: {}", report.logs_seen);
println!("registros: {}", report.records);
println!("alertas: {}", report.alerts.len());
for alert in &report.alerts {
println!(" [{:.3}] {}", alert.score, alert.summary);
}
println!("estado: {}", report.status);
}
Ok(())
@@ -80,12 +86,7 @@ async fn run(cli: Cli) -> acaua_core::Result<()> {
}
Ok(())
}
Command::Update { apply } => {
if apply {
let executable = std::env::current_exe()?;
let tag = update::apply(&executable)?;
println!("atualizado para {tag}");
} else {
Command::Update => {
let status = update::check(env!("CARGO_PKG_VERSION"))?;
println!(
"versão atual: {} | última: {}",
@@ -100,6 +101,12 @@ async fn run(cli: Cli) -> acaua_core::Result<()> {
"não"
}
);
if status.update_available {
if std::env::var("ACAUA_RUNTIME").as_deref() == Ok("container") {
println!("para atualizar: docker compose pull && docker compose up -d");
} else {
println!("para atualizar: baixe a nova imagem ou recompile a partir da tag");
}
}
Ok(())
}
+23 -12
View File
@@ -75,9 +75,12 @@ async fn alerts() -> Json<Vec<Alert>> {
}
async fn analyze(
State(state): State<AppState>,
Json(request): Json<AnalyzeRequest>,
) -> std::result::Result<Json<AnalysisReport>, (StatusCode, Json<ErrorBody>)> {
zeek::analyze_dir(&request.path).map(Json).map_err(|error| {
zeek::analyze_dir(&request.path, state.storage.root())
.map(Json)
.map_err(|error| {
(
StatusCode::BAD_REQUEST,
Json(ErrorBody {
@@ -100,15 +103,6 @@ async fn update_check() -> std::result::Result<Json<UpdateStatus>, (StatusCode,
})
}
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))
}
@@ -129,21 +123,38 @@ async fn asset(uri: Uri) -> Response {
}
async fn shutdown_signal() {
let interrupt = async {
let _ = tokio::signal::ctrl_c().await;
};
#[cfg(unix)]
let terminate = async {
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
Ok(mut stream) => {
stream.recv().await;
}
Err(_) => std::future::pending::<()>().await,
}
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = interrupt => {}
_ = terminate => {}
}
}
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)),
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("/api/update", get(update_check))
.route("/logo.png", get(logo))
.fallback(asset)
.with_state(state)
+5 -5
View File
@@ -8,17 +8,17 @@ 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 }
chrono = { version = "0.4", default-features = false, features = ["std"] }
# Linked against the official prebuilt libduckdb (scripts/fetch-libduckdb.sh):
# never enable `bundled`, `parquet`, `json` or `extensions-full`, which compile
# DuckDB from source. Parquet, JSON and ICU are already inside libduckdb.so.
duckdb = "~1.10506.1"
[dev-dependencies]
tempfile = "3"
+60 -21
View File
@@ -14,40 +14,79 @@ pub struct DoctorReport {
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" },
fn zeek_check() -> Check {
if std::env::var("ACAUA_RUNTIME").as_deref() == Ok("container") {
Check {
name: "zeek",
ok: true,
detail: format!("{name} is available"),
detail: "zeek runs in a dedicated container".to_string(),
}
} else {
match Command::new("zeek").arg("--version").output() {
Ok(output) if output.status.success() => Check {
name: "zeek",
ok: true,
detail: "zeek is available".to_string(),
},
Ok(_) => Check {
name: if name == "zeek" { "zeek" } else { "runtime" },
name: "zeek",
ok: false,
detail: format!("{name} returned an error"),
detail: "zeek returned an error".to_string(),
},
Err(error) => Check {
name: if name == "zeek" { "zeek" } else { "runtime" },
name: "zeek",
ok: false,
detail: format!("{name} not found: {error}"),
detail: format!("zeek not found: {error}"),
},
}
}
}
fn curl_check() -> Check {
match Command::new("curl").arg("--version").output() {
Ok(output) if output.status.success() => Check {
name: "curl",
ok: true,
detail: "curl is available".to_string(),
},
Ok(_) => Check {
name: "curl",
ok: false,
detail: "curl returned an error".to_string(),
},
Err(error) => Check {
name: "curl",
ok: false,
detail: format!("curl not found: {error}"),
},
}
}
fn duckdb_check() -> Check {
let result = duckdb::Connection::open_in_memory().and_then(|conn| {
conn.query_row("SELECT library_version FROM pragma_version()", [], |row| {
row.get::<_, String>(0)
})
});
match result {
Ok(version) => Check {
name: "duckdb",
ok: true,
detail: format!("libduckdb {version} linked"),
},
Err(error) => Check {
name: "duckdb",
ok: false,
detail: error.to_string(),
},
}
}
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()
},
},
zeek_check(),
curl_check(),
duckdb_check(),
Check {
name: "research",
ok: true,
+3
View File
@@ -16,6 +16,9 @@ pub enum AcauaError {
#[error("serialization error: {0}")]
Serde(#[from] serde_json::Error),
#[error("database error: {0}")]
Database(#[from] duckdb::Error),
}
pub type Result<T> = std::result::Result<T, AcauaError>;
+356 -5
View File
@@ -6,19 +6,370 @@ pub struct BeaconingInput {
pub timestamps: Vec<f64>,
}
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct GridInfo {
pub min_period_s: f64,
pub max_period_s: f64,
pub points: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct WeightsInfo {
pub significance: f64,
pub regularity: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct BeaconingScore {
pub score: f64,
pub observations: usize,
pub period_detected_seconds: f64,
pub p_value: f64,
pub peak_power: f64,
pub madm_normalized: f64,
pub skew_bowley: f64,
pub degenerate_quartiles: bool,
pub method: &'static str,
pub grid: GridInfo,
pub weights: WeightsInfo,
pub formula_version: &'static str,
pub status: &'static str,
}
fn quantile_cont(sorted: &[f64], q: f64) -> f64 {
if sorted.is_empty() {
return 0.0;
}
if sorted.len() == 1 {
return sorted[0];
}
let idx = (sorted.len() - 1) as f64 * q;
let lower = idx.floor() as usize;
let upper = idx.ceil() as usize;
let frac = idx - lower as f64;
if lower == upper {
sorted[lower]
} else {
sorted[lower] + frac * (sorted[upper] - sorted[lower])
}
}
pub fn score(input: &BeaconingInput) -> Result<BeaconingScore> {
Ok(BeaconingScore {
let grid_info = GridInfo {
min_period_s: 600.0,
max_period_s: 43200.0,
points: 200,
};
let weights_info = WeightsInfo {
significance: 0.5,
regularity: 0.5,
};
let n = input.timestamps.len();
if n < 20 {
return Ok(BeaconingScore {
score: 0.0,
observations: input.timestamps.len(),
method: "rayleigh-schuster-stub",
status: "skeleton",
observations: n,
period_detected_seconds: 0.0,
p_value: 1.0,
peak_power: 0.0,
madm_normalized: 0.0,
skew_bowley: 0.0,
degenerate_quartiles: false,
method: "rayleigh-schuster",
grid: grid_info,
weights: weights_info,
formula_version: "1.1",
status: "insufficient_data",
});
}
let mut ts = input.timestamps.clone();
ts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let mut deltas = Vec::with_capacity(n - 1);
for i in 1..n {
deltas.push(ts[i] - ts[i - 1]);
}
let mut sorted_deltas = deltas.clone();
sorted_deltas.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let q1 = quantile_cont(&sorted_deltas, 0.25);
let q2 = quantile_cont(&sorted_deltas, 0.50);
let q3 = quantile_cont(&sorted_deltas, 0.75);
const EPSILON: f64 = 1e-9;
let (skew_bowley, degenerate_quartiles) = if (q3 - q1).abs() < EPSILON {
(0.0, true)
} else {
((q3 + q1 - 2.0 * q2) / (q3 - q1), false)
};
let mut abs_devs: Vec<f64> = deltas.iter().map(|&d| (d - q2).abs()).collect();
abs_devs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let madm = quantile_cont(&abs_devs, 0.50);
let (madm_normalized, degenerate_deltas) = if q2 <= EPSILON {
(0.0, true)
} else {
(madm / q2, false)
};
// Rayleigh/Schuster periodogram with M = 200 log-spaced periods from 600s to 43200s
let t0 = ts[0];
let num_periods = 200;
let mut periods = Vec::with_capacity(num_periods);
let mut powers = Vec::with_capacity(num_periods);
for j in 0..num_periods {
let period = 600.0 * 72.0_f64.powf(j as f64 / 199.0);
periods.push(period);
let f = 1.0 / period;
let mut sum_cos = 0.0;
let mut sum_sin = 0.0;
for &t in &ts {
let phase = 2.0 * std::f64::consts::PI * f * (t - t0);
sum_cos += phase.cos();
sum_sin += phase.sin();
}
let power = (sum_cos * sum_cos + sum_sin * sum_sin) / (n as f64);
powers.push(power);
}
let mut p_max = 0.0_f64;
for &p in &powers {
if p > p_max {
p_max = p;
}
}
// Harmonic resistance: largest period in the grid with P >= 0.5 * P_max
let threshold_power = 0.5 * p_max;
let mut detected_period = periods[0];
for j in (0..num_periods).rev() {
if powers[j] >= threshold_power {
detected_period = periods[j];
break;
}
}
let m_points = num_periods as f64;
let p_value = (m_points * (-p_max).exp()).min(1.0);
// Formula v1.1
let ln_m = m_points.ln();
let six_ln_10 = 6.0 * 10.0_f64.ln();
let sig = ((p_max - ln_m) / six_ln_10).clamp(0.0, 1.0);
let regularity = (1.0 - 2.0 * madm_normalized).clamp(0.0, 1.0);
let status = if degenerate_deltas {
"degenerate_deltas"
} else {
"ok"
};
let score = if degenerate_deltas {
0.5 * sig
} else {
0.5 * sig + 0.5 * regularity
};
Ok(BeaconingScore {
score,
observations: n,
period_detected_seconds: detected_period,
p_value,
peak_power: p_max,
madm_normalized,
skew_bowley,
degenerate_quartiles,
method: "rayleigh-schuster",
grid: grid_info,
weights: weights_info,
formula_version: "1.1",
status,
})
}
#[cfg(test)]
mod tests {
use super::*;
struct SplitMix64 {
state: u64,
}
impl SplitMix64 {
fn new(seed: u64) -> Self {
Self { state: seed }
}
fn next_u64(&mut self) -> u64 {
self.state = self.state.wrapping_add(0x9e3779b97f4a7c15);
let mut z = self.state;
z = (z ^ (z >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
z = (z ^ (z >> 27)).wrapping_mul(0x94d049bb133111eb);
z ^ (z >> 31)
}
fn next_f64(&mut self) -> f64 {
(self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
}
fn uniform(&mut self, low: f64, high: f64) -> f64 {
low + (high - low) * self.next_f64()
}
fn exponential(&mut self, mean: f64) -> f64 {
let u = self.next_f64().max(1e-15);
-mean * u.ln()
}
}
#[test]
fn test_1_perfectly_periodic_series() {
let mut timestamps = Vec::with_capacity(42);
for k in 0..42 {
timestamps.push(k as f64 * 14400.0);
}
let res = score(&BeaconingInput { timestamps }).unwrap();
assert!(res.score >= 0.99, "score: {}", res.score);
let rel_err = (res.period_detected_seconds - 14400.0).abs() / 14400.0;
assert!(
rel_err < 0.02,
"period: {}, rel_err: {}",
res.period_detected_seconds,
rel_err
);
assert_eq!(res.status, "ok");
}
#[test]
fn test_2_grid_jitter() {
let mut min_score = f64::MAX;
let mut max_score = f64::MIN;
for seed in 1..=50 {
let mut rng = SplitMix64::new(seed);
let t0 = 1000.0;
let mut timestamps = Vec::with_capacity(42);
for k in 0..42 {
let jitter = rng.uniform(-0.18, 0.18) * 14400.0;
timestamps.push(t0 + k as f64 * 14400.0 + jitter);
}
let res = score(&BeaconingInput { timestamps }).unwrap();
assert!(res.score >= 0.80, "seed {seed} score {} < 0.80", res.score);
if res.score < min_score {
min_score = res.score;
}
if res.score > max_score {
max_score = res.score;
}
}
println!("Test 2 (grid jitter) 50 seeds: min={min_score:.4}, max={max_score:.4}");
}
#[test]
fn test_3_cumulative_jitter() {
let mut min_score = f64::MAX;
let mut max_score = f64::MIN;
let mut period_within_10pct = 0;
for seed in 1..=50 {
let mut rng = SplitMix64::new(seed);
let mut timestamps = Vec::with_capacity(42);
let mut curr = 1000.0;
timestamps.push(curr);
for _ in 1..42 {
curr += 14400.0 * (1.0 + rng.uniform(-0.18, 0.18));
timestamps.push(curr);
}
let res = score(&BeaconingInput { timestamps }).unwrap();
assert!(res.score >= 0.40, "seed {seed} score {} < 0.40", res.score);
if res.score < min_score {
min_score = res.score;
}
if res.score > max_score {
max_score = res.score;
}
let rel_err = (res.period_detected_seconds - 14400.0).abs() / 14400.0;
if rel_err < 0.10 {
period_within_10pct += 1;
}
}
println!(
"Test 3 (cumulative jitter) 50 seeds: min={min_score:.4}, max={max_score:.4}, period_hits={period_within_10pct}/50"
);
assert!(
period_within_10pct >= 45,
"period hits: {period_within_10pct}/50"
);
}
#[test]
fn test_4_poisson_random() {
let mut min_score = f64::MAX;
let mut max_score = f64::MIN;
let mean = 7.0 * 86400.0 / 40.0;
for seed in 1..=50 {
let mut rng = SplitMix64::new(seed);
let mut timestamps = Vec::with_capacity(40);
let mut curr = 0.0;
for _ in 0..40 {
curr += rng.exponential(mean);
timestamps.push(curr);
}
let res = score(&BeaconingInput { timestamps }).unwrap();
assert!(res.score < 0.30, "seed {seed} score {} >= 0.30", res.score);
if res.score < min_score {
min_score = res.score;
}
if res.score > max_score {
max_score = res.score;
}
}
println!("Test 4 (Poisson) 50 seeds: min={min_score:.4}, max={max_score:.4}");
}
#[test]
fn test_5_sentinel_q3_eq_q1() {
let mut timestamps = Vec::with_capacity(25);
for k in 0..25 {
timestamps.push(k as f64 * 1000.0);
}
let res = score(&BeaconingInput { timestamps }).unwrap();
assert_eq!(res.skew_bowley, 0.0);
assert!(res.degenerate_quartiles);
assert!(!res.score.is_nan() && !res.score.is_infinite());
}
#[test]
fn test_6_median_delta_zero() {
let timestamps = vec![1000.0; 25];
let res = score(&BeaconingInput { timestamps }).unwrap();
assert_eq!(res.status, "degenerate_deltas");
assert!(!res.score.is_nan() && !res.score.is_infinite());
}
#[test]
fn test_7_insufficient_data() {
let timestamps: Vec<f64> = (0..19).map(|k| k as f64 * 1000.0).collect();
let res = score(&BeaconingInput { timestamps }).unwrap();
assert_eq!(res.status, "insufficient_data");
assert_eq!(res.score, 0.0);
}
#[test]
fn test_8_determinism() {
let timestamps: Vec<f64> = (0..42).map(|k| k as f64 * 14400.0).collect();
let s1 = score(&BeaconingInput {
timestamps: timestamps.clone(),
})
.unwrap();
let s2 = score(&BeaconingInput { timestamps }).unwrap();
let j1 = serde_json::to_vec(&s1).unwrap();
let j2 = serde_json::to_vec(&s2).unwrap();
assert_eq!(j1, j2);
}
}
+649 -8
View File
@@ -1,5 +1,9 @@
use crate::error::Result;
use crate::error::{AcauaError, Result};
use chrono::NaiveDate;
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize)]
@@ -18,32 +22,669 @@ impl DatasetSummary {
partitions: 0,
ttl_days: 7,
backend: "duckdb-parquet",
status: "skeleton",
status: "empty",
}
}
}
#[derive(Debug, Clone, Default)]
pub struct IngestionStats {
pub files_seen: usize,
pub records_ingested: u64,
pub lines_skipped: u64,
pub partitions_affected: usize,
}
#[derive(Debug, Clone)]
pub struct TupleTimestamps {
pub orig_h: String,
pub resp_h: String,
pub resp_p: u16,
pub timestamps: Vec<f64>,
}
#[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(),
#[derive(Debug, Clone)]
pub struct ConnRecord {
pub ts: f64,
pub uid: String,
pub orig_h: String,
pub orig_p: u16,
pub resp_h: String,
pub resp_p: u16,
pub proto: String,
pub service: Option<String>,
pub duration: Option<f64>,
}
impl Storage {
pub fn open(root: impl AsRef<Path>) -> Result<Self> {
let root = root.as_ref();
fs::create_dir_all(root.join("raw").join("conn"))?;
fs::create_dir_all(root.join("tmp"))?;
let canonical_root = fs::canonicalize(root)?;
let test_file = canonical_root.join("tmp").join(".write_check");
fs::write(&test_file, b"ok")?;
let _ = fs::remove_file(&test_file);
Ok(Self {
root: canonical_root,
})
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn open_connection(&self) -> Result<duckdb::Connection> {
let conn = duckdb::Connection::open_in_memory()?;
let data_dir_str = self.root.to_string_lossy().replace('\'', "''");
let tmp_dir_str = self.root.join("tmp").to_string_lossy().replace('\'', "''");
conn.execute_batch(&format!(
"SET TimeZone = 'UTC';
SET temp_directory = '{tmp_dir_str}';
SET autoinstall_known_extensions = false;
SET autoload_known_extensions = false;
SET allowed_directories = ['{data_dir_str}/'];
SET enable_external_access = false;
SET lock_configuration = true;"
))?;
Ok(conn)
}
fn has_parquet_files(&self) -> bool {
let conn_dir = self.root.join("raw").join("conn");
if let Ok(entries) = fs::read_dir(conn_dir) {
for entry in entries.flatten() {
if let Ok(sub) = fs::read_dir(entry.path()) {
for f in sub.flatten() {
if f.path().extension().and_then(|s| s.to_str()) == Some("parquet") {
return true;
}
}
}
}
}
false
}
pub fn summary(&self) -> Result<DatasetSummary> {
Ok(DatasetSummary::empty())
if !self.has_parquet_files() {
return Ok(DatasetSummary::empty());
}
let conn = self.open_connection()?;
let parquet_glob = self
.root
.join("raw")
.join("conn")
.join("*")
.join("*.parquet")
.to_string_lossy()
.replace('\'', "''");
let query = format!(
"SELECT count(*)::BIGINT, count(DISTINCT date)::BIGINT
FROM read_parquet('{parquet_glob}', hive_partitioning = true);"
);
let row = conn.query_row(&query, [], |r| {
let records: i64 = r.get(0)?;
let partitions: i64 = r.get(1)?;
Ok((records as u64, partitions as usize))
});
match row {
Ok((records, partitions)) => Ok(DatasetSummary {
records,
partitions,
ttl_days: 7,
backend: "duckdb-parquet",
status: "ok",
}),
Err(_) => Ok(DatasetSummary::empty()),
}
}
pub fn prune_expired(&self) -> Result<usize> {
Ok(0)
let conn_dir = self.root.join("raw").join("conn");
let entries = match fs::read_dir(&conn_dir) {
Ok(e) => e,
Err(_) => return Ok(0),
};
let mut dates = Vec::new();
let mut dirs = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if let Some(date_str) = name.strip_prefix("date=") {
if let Ok(d) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
dates.push(d);
dirs.push((d, path));
}
}
}
}
if dates.is_empty() {
return Ok(0);
}
let max_date = dates.into_iter().max().unwrap();
let cutoff = max_date - chrono::Duration::days(7);
let mut pruned = 0;
for (date, path) in dirs {
if date <= cutoff && fs::remove_dir_all(&path).is_ok() {
pruned += 1;
}
}
Ok(pruned)
}
pub fn parse_conn_log(content: &[u8]) -> (Vec<ConnRecord>, u64, HashSet<String>) {
let mut records = Vec::new();
let mut skipped = 0;
let mut dates = HashSet::new();
let mut separator = '\t';
let mut unset_field = String::from("-");
let mut fields: Vec<String> = Vec::new();
let mut idx_ts = None;
let mut idx_uid = None;
let mut idx_orig_h = None;
let mut idx_orig_p = None;
let mut idx_resp_h = None;
let mut idx_resp_p = None;
let mut idx_proto = None;
let mut idx_service = None;
let mut idx_duration = None;
let text = String::from_utf8_lossy(content);
for line in text.lines() {
let line = line.trim_end_matches(['\r', '\n']);
if line.is_empty() {
continue;
}
if line.starts_with('#') {
if let Some(rest) = line.strip_prefix("#separator ") {
let sep_str = rest.trim();
if let Some(hex_code) = sep_str.strip_prefix("\\x") {
if let Ok(b) = u8::from_str_radix(hex_code, 16) {
separator = b as char;
}
} else if !sep_str.is_empty() {
separator = sep_str.chars().next().unwrap();
}
} else if let Some(rest) = line.strip_prefix("#unset_field") {
unset_field = rest.trim().to_string();
} else if let Some(rest) = line.strip_prefix("#fields") {
let rest = rest.trim();
fields = rest
.split(separator)
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
idx_ts = fields.iter().position(|s| s == "ts");
idx_uid = fields.iter().position(|s| s == "uid");
idx_orig_h = fields.iter().position(|s| s == "id.orig_h");
idx_orig_p = fields.iter().position(|s| s == "id.orig_p");
idx_resp_h = fields.iter().position(|s| s == "id.resp_h");
idx_resp_p = fields.iter().position(|s| s == "id.resp_p");
idx_proto = fields.iter().position(|s| s == "proto");
idx_service = fields.iter().position(|s| s == "service");
idx_duration = fields.iter().position(|s| s == "duration");
}
continue;
}
// Must have fields mapped
let (i_ts, i_uid, i_oh, i_op, i_rh, i_rp, i_pr) = match (
idx_ts, idx_uid, idx_orig_h, idx_orig_p, idx_resp_h, idx_resp_p, idx_proto,
) {
(Some(a), Some(b), Some(c), Some(d), Some(e), Some(f), Some(g)) => {
(a, b, c, d, e, f, g)
}
_ => {
skipped += 1;
continue;
}
};
let parts: Vec<&str> = line.split(separator).collect();
if parts.len() < fields.len() {
skipped += 1;
continue;
}
let ts: f64 = match parts[i_ts].parse() {
Ok(v) => v,
Err(_) => {
skipped += 1;
continue;
}
};
let orig_p: u16 = match parts[i_op].parse() {
Ok(v) => v,
Err(_) => {
skipped += 1;
continue;
}
};
let resp_p: u16 = match parts[i_rp].parse() {
Ok(v) => v,
Err(_) => {
skipped += 1;
continue;
}
};
let service = idx_service.and_then(|idx| {
let val = parts.get(idx)?;
if *val == unset_field.as_str() || *val == "-" {
None
} else {
Some(val.to_string())
}
});
let duration = idx_duration.and_then(|idx| {
let val = parts.get(idx)?;
if *val == unset_field.as_str() || *val == "-" {
None
} else {
val.parse::<f64>().ok()
}
});
// Track day partition (UTC)
let date_ts = chrono::DateTime::from_timestamp(ts.floor() as i64, 0);
if let Some(dt) = date_ts {
dates.insert(dt.format("%Y-%m-%d").to_string());
}
records.push(ConnRecord {
ts,
uid: parts[i_uid].to_string(),
orig_h: parts[i_oh].to_string(),
orig_p,
resp_h: parts[i_rh].to_string(),
resp_p,
proto: parts[i_pr].to_string(),
service,
duration,
});
}
(records, skipped, dates)
}
pub fn ingest_file(&self, path: impl AsRef<Path>) -> Result<(u64, u64, usize)> {
let path = path.as_ref();
let content = fs::read(path)?;
let mut hasher = Sha256::new();
hasher.update(&content);
let hash = hasher.finalize();
let mut sha16 = String::with_capacity(16);
for b in &hash[..8] {
use std::fmt::Write;
let _ = write!(sha16, "{b:02x}");
}
let (records, skipped, dates) = Self::parse_conn_log(&content);
if records.is_empty() {
return Ok((0, skipped, 0));
}
let conn = self.open_connection()?;
conn.execute_batch(
"CREATE TEMP TABLE t (
ts DOUBLE,
uid VARCHAR,
orig_h VARCHAR,
orig_p USMALLINT,
resp_h VARCHAR,
resp_p USMALLINT,
proto VARCHAR,
service VARCHAR,
duration DOUBLE
);",
)?;
{
let mut appender = conn.appender("t")?;
for r in &records {
appender.append_row(duckdb::params![
r.ts, r.uid, r.orig_h, r.orig_p, r.resp_h, r.resp_p, r.proto, r.service,
r.duration
])?;
}
}
let raw_conn_dir = self
.root
.join("raw")
.join("conn")
.to_string_lossy()
.replace('\'', "''");
let copy_sql = format!(
"COPY (
SELECT *, CAST(to_timestamp(ts) AS DATE) AS date FROM t
) TO '{raw_conn_dir}' (
FORMAT parquet,
PARTITION_BY (date),
FILENAME_PATTERN 'src_{sha16}_{{i}}',
OVERWRITE_OR_IGNORE
);"
);
conn.execute_batch(&copy_sql)?;
conn.execute_batch("DROP TABLE t;")?;
Ok((records.len() as u64, skipped, dates.len()))
}
pub fn ingest_dir(&self, directory: impl AsRef<Path>) -> Result<IngestionStats> {
let directory = directory.as_ref();
if !directory.is_dir() {
return Err(AcauaError::InvalidInput(format!(
"{} is not a directory",
directory.display()
)));
}
let canonical_dir = fs::canonicalize(directory)?;
let mut stats = IngestionStats::default();
let mut all_dates = HashSet::new();
let entries = fs::read_dir(&canonical_dir)?;
for entry in entries.flatten() {
let path = entry.path();
// Path confinement: do not follow symlink to outside directory
if let Ok(canon) = fs::canonicalize(&path) {
if !canon.starts_with(&canonical_dir) {
continue;
}
} else {
continue;
}
let file_name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n,
None => continue,
};
// Skip .gz archives with note
if file_name.ends_with(".gz") {
continue;
}
// Only conn.log and conn.*.log directly in directory
let is_conn = file_name == "conn.log"
|| (file_name.starts_with("conn.") && file_name.ends_with(".log"));
if is_conn && path.is_file() {
stats.files_seen += 1;
match self.ingest_file(&path) {
Ok((recs, sk, affected)) => {
stats.records_ingested += recs;
stats.lines_skipped += sk;
for _ in 0..affected {
all_dates.insert(file_name.to_string());
}
}
Err(_) => {
stats.lines_skipped += 1;
}
}
}
}
stats.partitions_affected = all_dates.len();
Ok(stats)
}
pub fn query_7day_tuples(&self) -> Result<Vec<TupleTimestamps>> {
if !self.has_parquet_files() {
return Ok(Vec::new());
}
let conn = self.open_connection()?;
let parquet_glob = self
.root
.join("raw")
.join("conn")
.join("*")
.join("*.parquet")
.to_string_lossy()
.replace('\'', "''");
// Find max date present in data
let max_date_query = format!(
"SELECT max(date)::VARCHAR
FROM read_parquet('{parquet_glob}', hive_partitioning = true);"
);
let max_date: Option<String> = conn
.query_row(&max_date_query, [], |r| r.get(0))
.unwrap_or(None);
let max_date_str = match max_date {
Some(d) if !d.is_empty() => d,
_ => return Ok(Vec::new()),
};
let query = format!(
"WITH daily AS (
SELECT date, orig_h, resp_h, resp_p, list(ts ORDER BY ts) AS tss
FROM read_parquet('{parquet_glob}', hive_partitioning = true)
WHERE date > CAST('{max_date_str}' AS DATE) - INTERVAL 7 DAY
GROUP BY ALL
)
SELECT orig_h, resp_h, resp_p, list_sort(flatten(list(tss))) AS timestamps
FROM daily
GROUP BY ALL
ORDER BY orig_h, resp_h, resp_p;"
);
let mut stmt = conn.prepare(&query)?;
let rows = stmt.query_map([], |row| {
let orig_h: String = row.get(0)?;
let resp_h: String = row.get(1)?;
let resp_p: u16 = row.get(2)?;
let timestamps_val: duckdb::types::Value = row.get(3)?;
let mut timestamps = Vec::new();
if let duckdb::types::Value::List(items) = timestamps_val {
for item in items {
if let duckdb::types::Value::Double(t) = item {
timestamps.push(t);
}
}
}
Ok(TupleTimestamps {
orig_h,
resp_h,
resp_p,
timestamps,
})
})?;
let mut result = Vec::new();
for r in rows {
result.push(r?);
}
Ok(result)
}
}
#[cfg(test)]
mod tests {
use super::*;
const CONN_LOG_SAMPLE_1: &str = "#separator \\x09\n\
#set_separator\t,\n\
#empty_field\t(empty)\n\
#unset_field\t-\n\
#path\tconn\n\
#open\t2026-10-31-12-00-00\n\
#fields\tts\tuid\tid.orig_h\tid.orig_p\tid.resp_h\tid.resp_p\tproto\tservice\tduration\tip_proto\n\
#types\ttime\tstring\taddr\tport\taddr\tport\tenum\tstring\tinterval\tcount\n\
1793461200.000000\tCuid1\t192.168.1.10\t45000\t10.0.0.1\t443\ttcp\tssl\t1.234\t6\n\
1793461205.000000\tCuid2\t192.168.1.10\t45001\t10.0.0.1\t443\ttcp\t-\t-\t6\n\
#close\t2026-10-31-12-01-00\n";
const CONN_LOG_PERMUTED_COLS: &str = "#separator \\x09\n\
#fields\tid.resp_p\tid.orig_h\tservice\tuid\tduration\tproto\tts\tid.resp_h\tid.orig_p\n\
443\t192.168.1.10\thttp\tCuid3\t0.5\ttcp\t1793461210.000000\t10.0.0.1\t45002\n";
#[test]
fn test_parser_extracts_correct_fields_and_unset_as_none() {
let (records, skipped, dates) = Storage::parse_conn_log(CONN_LOG_SAMPLE_1.as_bytes());
assert_eq!(skipped, 0);
assert_eq!(records.len(), 2);
assert_eq!(dates.len(), 1);
assert_eq!(records[0].uid, "Cuid1");
assert_eq!(records[0].orig_h, "192.168.1.10");
assert_eq!(records[0].orig_p, 45000);
assert_eq!(records[0].resp_h, "10.0.0.1");
assert_eq!(records[0].resp_p, 443);
assert_eq!(records[0].proto, "tcp");
assert_eq!(records[0].service.as_deref(), Some("ssl"));
assert_eq!(records[0].duration, Some(1.234));
assert_eq!(records[1].uid, "Cuid2");
assert_eq!(records[1].service, None);
assert_eq!(records[1].duration, None);
}
#[test]
fn test_parser_maps_by_name_not_position() {
let (records, skipped, _) = Storage::parse_conn_log(CONN_LOG_PERMUTED_COLS.as_bytes());
assert_eq!(skipped, 0);
assert_eq!(records.len(), 1);
let r = &records[0];
assert_eq!(r.uid, "Cuid3");
assert_eq!(r.orig_h, "192.168.1.10");
assert_eq!(r.orig_p, 45002);
assert_eq!(r.resp_h, "10.0.0.1");
assert_eq!(r.resp_p, 443);
assert_eq!(r.proto, "tcp");
assert_eq!(r.service.as_deref(), Some("http"));
assert_eq!(r.duration, Some(0.5));
}
#[test]
fn test_storage_roundtrip_and_idempotence() {
let temp_dir = tempfile::tempdir().unwrap();
let store = Storage::open(temp_dir.path()).unwrap();
let log_file = temp_dir.path().join("conn.log");
fs::write(&log_file, CONN_LOG_SAMPLE_1).unwrap();
let (ingested1, skipped1, _) = store.ingest_file(&log_file).unwrap();
assert_eq!(ingested1, 2);
assert_eq!(skipped1, 0);
let summary1 = store.summary().unwrap();
assert_eq!(summary1.records, 2);
assert_eq!(summary1.partitions, 1);
// Idempotence: re-ingesting the exact same file does not duplicate records
let (ingested2, skipped2, _) = store.ingest_file(&log_file).unwrap();
assert_eq!(ingested2, 2);
assert_eq!(skipped2, 0);
let summary2 = store.summary().unwrap();
assert_eq!(summary2.records, 2);
// Ingesting a different file for the same tuple/day adds to it
let log_file_2 = temp_dir.path().join("conn.extra.log");
fs::write(&log_file_2, CONN_LOG_PERMUTED_COLS).unwrap();
store.ingest_file(&log_file_2).unwrap();
let summary3 = store.summary().unwrap();
assert_eq!(summary3.records, 3);
let tuples = store.query_7day_tuples().unwrap();
assert_eq!(tuples.len(), 1);
assert_eq!(tuples[0].orig_h, "192.168.1.10");
assert_eq!(tuples[0].resp_h, "10.0.0.1");
assert_eq!(tuples[0].resp_p, 443);
assert_eq!(tuples[0].timestamps.len(), 3);
assert_eq!(tuples[0].timestamps[0], 1793461200.0);
assert_eq!(tuples[0].timestamps[1], 1793461205.0);
assert_eq!(tuples[0].timestamps[2], 1793461210.0);
}
#[test]
fn test_storage_utc_timezone_partitioning() {
let temp_dir = tempfile::tempdir().unwrap();
let store = Storage::open(temp_dir.path()).unwrap();
// 2026-10-31 02:00:00 UTC = 1793412000
let log = "#separator \\x09\n#fields\tts\tuid\tid.orig_h\tid.orig_p\tid.resp_h\tid.resp_p\tproto\n1793412000.000000\tu1\t10.0.0.1\t1000\t10.0.0.2\t80\ttcp\n";
let log_file = temp_dir.path().join("conn.log");
fs::write(&log_file, log).unwrap();
store.ingest_file(&log_file).unwrap();
let partition_dir = temp_dir
.path()
.join("raw")
.join("conn")
.join("date=2026-10-31");
assert!(
partition_dir.is_dir(),
"partition date=2026-10-31 should exist regardless of local timezone"
);
}
#[test]
fn test_storage_sandbox_blocks_unauthorized_paths() {
let temp_dir = tempfile::tempdir().unwrap();
let store = Storage::open(temp_dir.path()).unwrap();
let conn = store.open_connection().unwrap();
let res = conn.execute_batch("SELECT * FROM read_csv('/etc/passwd');");
assert!(res.is_err(), "sandbox must block access outside data_dir");
}
#[test]
fn test_storage_prune_expired() {
let temp_dir = tempfile::tempdir().unwrap();
let store = Storage::open(temp_dir.path()).unwrap();
let raw_conn = temp_dir.path().join("raw").join("conn");
// Day 1 (10 days ago) and Day 10 (recent)
let old_part = raw_conn.join("date=2026-10-01");
let new_part = raw_conn.join("date=2026-10-11");
fs::create_dir_all(&old_part).unwrap();
fs::create_dir_all(&new_part).unwrap();
let pruned = store.prune_expired().unwrap();
assert_eq!(pruned, 1);
assert!(!old_part.exists());
assert!(new_part.exists());
}
}
+4 -530
View File
@@ -1,25 +1,13 @@
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)]
@@ -46,42 +34,6 @@ fn curl_text(url: &str) -> Result<String> {
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,
@@ -103,492 +55,14 @@ pub fn check(current_version: &str) -> Result<UpdateStatus> {
})
}
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());
fn parses_latest_release_tag() {
let json = r#"{"tag_name": "v0.2.0"}"#;
let release: Release = serde_json::from_str(json).unwrap();
assert_eq!(release.tag_name, "v0.2.0");
}
}
+78 -5
View File
@@ -1,7 +1,11 @@
use crate::error::{AcauaError, Result};
use crate::fusion::Alert;
use crate::pillar1::{self, BeaconingInput};
use crate::storage::Storage;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::path::Path;
use time::OffsetDateTime;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ZeekLogKind {
@@ -26,7 +30,10 @@ pub struct AnalysisReport {
pub status: &'static str,
}
pub fn analyze_dir(directory: impl AsRef<Path>) -> Result<AnalysisReport> {
pub fn analyze_dir(
directory: impl AsRef<Path>,
data_dir: impl AsRef<Path>,
) -> Result<AnalysisReport> {
let directory = directory.as_ref();
if !directory.is_dir() {
return Err(AcauaError::InvalidInput(format!(
@@ -34,12 +41,78 @@ pub fn analyze_dir(directory: impl AsRef<Path>) -> Result<AnalysisReport> {
directory.display()
)));
}
let store = Storage::open(data_dir)?;
let stats = store.ingest_dir(directory)?;
let tuples = store.query_7day_tuples()?;
let mut alerts = Vec::new();
for tuple in tuples {
if tuple.timestamps.len() < 20 {
continue;
}
let beacon_score = pillar1::score(&BeaconingInput {
timestamps: tuple.timestamps.clone(),
})?;
if beacon_score.score >= 0.40 {
let latest_ts = tuple.timestamps.last().copied().unwrap_or(0.0);
let latest_date_str = match OffsetDateTime::from_unix_timestamp(latest_ts.floor() as i64)
{
Ok(dt) => {
let format = time::macros::format_description!("[year]-[month]-[day]");
dt.date()
.format(&format)
.unwrap_or_else(|_| "unknown-date".to_string())
}
Err(_) => "unknown-date".to_string(),
};
let mut hasher = Sha256::new();
let raw_id = format!(
"1|{}|{}|{}|{}",
tuple.orig_h, tuple.resp_h, tuple.resp_p, latest_date_str
);
hasher.update(raw_id.as_bytes());
let hash = hasher.finalize();
let mut alert_id = String::with_capacity(16);
for b in &hash[..8] {
use std::fmt::Write;
let _ = write!(alert_id, "{b:02x}");
}
let summary = format!(
"Beaconing detected: {}:{} -> {}:{} (period ~{:.0}s, score {:.3})",
tuple.orig_h,
"any",
tuple.resp_h,
tuple.resp_p,
beacon_score.period_detected_seconds,
beacon_score.score
);
let evidence = serde_json::to_value(&beacon_score)?;
let created_at = OffsetDateTime::now_utc().to_string();
alerts.push(Alert {
id: alert_id,
pillar: 1,
score: beacon_score.score,
summary,
evidence,
created_at,
});
}
}
Ok(AnalysisReport {
directory: directory.display().to_string(),
logs_seen: 0,
records: 0,
alerts: Vec::new(),
status: "skeleton",
logs_seen: stats.files_seen,
records: stats.records_ingested,
alerts,
status: "ok",
})
}
+76
View File
@@ -0,0 +1,76 @@
#separator \x09
#set_separator ,
#empty_field (empty)
#unset_field -
#path conn
#open 2026-10-31-00-00-00
#fields ts uid id.orig_h id.orig_p id.resp_h id.resp_p proto service duration ip_proto
#types time string addr port addr port enum string interval count
1793400000.000000 Cbeacon000 192.168.1.50 49152 198.51.100.1 443 tcp ssl 0.123 6
1793414400.000000 Cbeacon001 192.168.1.50 49152 198.51.100.1 443 tcp ssl 0.123 6
1793420401.205745 Crandom000 192.168.1.50 49153 198.51.100.2 80 tcp http 0.050 6
1793420907.782526 Crandom001 192.168.1.50 49153 198.51.100.2 80 tcp http 0.050 6
1793427340.263808 Crandom002 192.168.1.50 49153 198.51.100.2 80 tcp http 0.050 6
1793428800.000000 Cbeacon002 192.168.1.50 49152 198.51.100.1 443 tcp ssl 0.123 6
1793432391.987521 Crandom003 192.168.1.50 49153 198.51.100.2 80 tcp http 0.050 6
1793443200.000000 Cbeacon003 192.168.1.50 49152 198.51.100.1 443 tcp ssl 0.123 6
1793457600.000000 Cbeacon004 192.168.1.50 49152 198.51.100.1 443 tcp ssl 0.123 6
1793459063.840977 Crandom004 192.168.1.50 49153 198.51.100.2 80 tcp http 0.050 6
1793472000.000000 Cbeacon005 192.168.1.50 49152 198.51.100.1 443 tcp ssl 0.123 6
1793481647.301153 Crandom005 192.168.1.50 49153 198.51.100.2 80 tcp http 0.050 6
1793486400.000000 Cbeacon006 192.168.1.50 49152 198.51.100.1 443 tcp ssl 0.123 6
1793500800.000000 Cbeacon007 192.168.1.50 49152 198.51.100.1 443 tcp ssl 0.123 6
1793515200.000000 Cbeacon008 192.168.1.50 49152 198.51.100.1 443 tcp ssl 0.123 6
1793526193.063144 Crandom006 192.168.1.50 49153 198.51.100.2 80 tcp http 0.050 6
1793528012.111237 Crandom007 192.168.1.50 49153 198.51.100.2 80 tcp http 0.050 6
1793529600.000000 Cbeacon009 192.168.1.50 49152 198.51.100.1 443 tcp ssl 0.123 6
1793538973.034425 Crandom008 192.168.1.50 49153 198.51.100.2 80 tcp http 0.050 6
1793539578.037969 Crandom009 192.168.1.50 49153 198.51.100.2 80 tcp http 0.050 6
1793544000.000000 Cbeacon010 192.168.1.50 49152 198.51.100.1 443 tcp ssl 0.123 6
1793544512.371888 Crandom010 192.168.1.50 49153 198.51.100.2 80 tcp http 0.050 6
1793558400.000000 Cbeacon011 192.168.1.50 49152 198.51.100.1 443 tcp ssl 0.123 6
1793558590.682445 Crandom011 192.168.1.50 49153 198.51.100.2 80 tcp http 0.050 6
1793559128.570519 Crandom012 192.168.1.50 49153 198.51.100.2 80 tcp http 0.050 6
1793563562.403902 Crandom013 192.168.1.50 49153 198.51.100.2 80 tcp http 0.050 6
1793572800.000000 Cbeacon012 192.168.1.50 49152 198.51.100.1 443 tcp ssl 0.123 6
1793584552.243927 Crandom014 192.168.1.50 49153 198.51.100.2 80 tcp http 0.050 6
1793587200.000000 Cbeacon013 192.168.1.50 49152 198.51.100.1 443 tcp ssl 0.123 6
1793600298.829012 Crandom015 192.168.1.50 49153 198.51.100.2 80 tcp http 0.050 6
1793601600.000000 Cbeacon014 192.168.1.50 49152 198.51.100.1 443 tcp ssl 0.123 6
1793605279.357391 Crandom016 192.168.1.50 49153 198.51.100.2 80 tcp http 0.050 6
1793616000.000000 Cbeacon015 192.168.1.50 49152 198.51.100.1 443 tcp ssl 0.123 6
1793623075.531517 Crandom017 192.168.1.50 49153 198.51.100.2 80 tcp http 0.050 6
1793630400.000000 Cbeacon016 192.168.1.50 49152 198.51.100.1 443 tcp ssl 0.123 6
1793644800.000000 Cbeacon017 192.168.1.50 49152 198.51.100.1 443 tcp ssl 0.123 6
1793656230.293402 Crandom018 192.168.1.50 49153 198.51.100.2 80 tcp http 0.050 6
1793656360.692773 Crandom019 192.168.1.50 49153 198.51.100.2 80 tcp http 0.050 6
1793659200.000000 Cbeacon018 192.168.1.50 49152 198.51.100.1 443 tcp ssl 0.123 6
1793673600.000000 Cbeacon019 192.168.1.50 49152 198.51.100.1 443 tcp ssl 0.123 6
1793688000.000000 Cbeacon020 192.168.1.50 49152 198.51.100.1 443 tcp ssl 0.123 6
1793689140.010015 Crandom020 192.168.1.50 49153 198.51.100.2 80 tcp http 0.050 6
1793702400.000000 Cbeacon021 192.168.1.50 49152 198.51.100.1 443 tcp ssl 0.123 6
1793713095.808834 Crandom021 192.168.1.50 49153 198.51.100.2 80 tcp http 0.050 6
1793716800.000000 Cbeacon022 192.168.1.50 49152 198.51.100.1 443 tcp ssl 0.123 6
1793721413.710564 Crandom022 192.168.1.50 49153 198.51.100.2 80 tcp http 0.050 6
1793724793.435926 Crandom023 192.168.1.50 49153 198.51.100.2 80 tcp http 0.050 6
1793731200.000000 Cbeacon023 192.168.1.50 49152 198.51.100.1 443 tcp ssl 0.123 6
1793745600.000000 Cbeacon024 192.168.1.50 49152 198.51.100.1 443 tcp ssl 0.123 6
1793760000.000000 Cbeacon025 192.168.1.50 49152 198.51.100.1 443 tcp ssl 0.123 6
1793774400.000000 Cbeacon026 192.168.1.50 49152 198.51.100.1 443 tcp ssl 0.123 6
1793787823.888895 Crandom024 192.168.1.50 49153 198.51.100.2 80 tcp http 0.050 6
1793788800.000000 Cbeacon027 192.168.1.50 49152 198.51.100.1 443 tcp ssl 0.123 6
1793803200.000000 Cbeacon028 192.168.1.50 49152 198.51.100.1 443 tcp ssl 0.123 6
1793817600.000000 Cbeacon029 192.168.1.50 49152 198.51.100.1 443 tcp ssl 0.123 6
1793832000.000000 Cbeacon030 192.168.1.50 49152 198.51.100.1 443 tcp ssl 0.123 6
1793846400.000000 Cbeacon031 192.168.1.50 49152 198.51.100.1 443 tcp ssl 0.123 6
1793860800.000000 Cbeacon032 192.168.1.50 49152 198.51.100.1 443 tcp ssl 0.123 6
1793875200.000000 Cbeacon033 192.168.1.50 49152 198.51.100.1 443 tcp ssl 0.123 6
1793889600.000000 Cbeacon034 192.168.1.50 49152 198.51.100.1 443 tcp ssl 0.123 6
1793904000.000000 Cbeacon035 192.168.1.50 49152 198.51.100.1 443 tcp ssl 0.123 6
1793918400.000000 Cbeacon036 192.168.1.50 49152 198.51.100.1 443 tcp ssl 0.123 6
1793932800.000000 Cbeacon037 192.168.1.50 49152 198.51.100.1 443 tcp ssl 0.123 6
1793947200.000000 Cbeacon038 192.168.1.50 49152 198.51.100.1 443 tcp ssl 0.123 6
1793961600.000000 Cbeacon039 192.168.1.50 49152 198.51.100.1 443 tcp ssl 0.123 6
1793976000.000000 Cbeacon040 192.168.1.50 49152 198.51.100.1 443 tcp ssl 0.123 6
1793990400.000000 Cbeacon041 192.168.1.50 49152 198.51.100.1 443 tcp ssl 0.123 6
#close 2026-10-31-23-59-59
+4 -3
View File
@@ -7,7 +7,7 @@ fn pillar1_returns_a_skeleton_score() {
};
let score = pillar1::score(&input).unwrap();
assert_eq!(score.observations, 3);
assert_eq!(score.status, "skeleton");
assert_eq!(score.status, "insufficient_data");
}
#[test]
@@ -44,7 +44,7 @@ fn fusion_returns_no_alerts_yet() {
#[test]
fn storage_reports_the_reserved_backend() {
let store = storage::Storage::open(std::env::temp_dir());
let store = storage::Storage::open(std::env::temp_dir()).unwrap();
let summary = store.summary().unwrap();
assert_eq!(summary.ttl_days, 7);
assert_eq!(summary.records, 0);
@@ -52,7 +52,8 @@ fn storage_reports_the_reserved_backend() {
#[test]
fn zeek_rejects_a_missing_directory() {
let error = zeek::analyze_dir("/definitely/not/a/zeek/directory").unwrap_err();
let error =
zeek::analyze_dir("/definitely/not/a/zeek/directory", std::env::temp_dir()).unwrap_err();
assert!(matches!(error, AcauaError::InvalidInput(_)));
}
+34 -12
View File
@@ -1,23 +1,45 @@
# Acauã stack: Zeek captures traffic and writes hourly-rotated logs; Acauã
# reads them read-only and serves the API and the web dashboard.
#
# cp .env.example .env # set ZEEK_INTERFACE (authorized network only)
# docker compose pull && docker compose up -d # operators: published image
# docker compose up -d --build # developers: build locally
name: acaua
services:
zeek:
image: zeek/zeek:latest
image: ${ZEEK_IMAGE:-zeek/zeek:9.0.1}
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"]
cap_drop: [ALL]
cap_add: [NET_RAW, NET_ADMIN]
working_dir: /logs
command:
- zeek
- -i
- ${ZEEK_INTERFACE:-eth0}
- Log::default_logdir=/logs
- Log::default_rotation_interval=1hr
- Log::default_rotation_dir=/logs/archive
volumes:
- ./data/zeek:/logs
- zeek-logs:/logs
restart: unless-stopped
acaua:
image: ${ACAUA_IMAGE:-git.lucasrguerra.dev.br/ciberfauna/acaua}:${ACAUA_VERSION:-latest}
build: .
command: ["serve", "--bind", "0.0.0.0:8787", "--data-dir", "/data/acaua"]
ports:
- "127.0.0.1:8787:8787"
- "${ACAUA_PUBLISH:-127.0.0.1:8787}:8787"
volumes:
- ./data/zeek:/data/zeek:ro
- ./data/acaua:/data/acaua
- zeek-logs:/data/zeek:ro
- acaua-data:/data/acaua
read_only: true
tmpfs:
- /tmp
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
restart: unless-stopped
volumes:
zeek-logs:
acaua-data:
+75 -90
View File
@@ -2,8 +2,8 @@
**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
Acauã is a single Rust product: one daemon/CLI, one shared core, and one
embedded web dashboard. 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.
@@ -11,19 +11,14 @@ corrections applied to the original blueprint.
| Layer | Choice | Why |
|---|---|---|
| Core, CLI and daemon | Rust 2021 | One toolchain, memory safety, no runtime dependency |
| Core, CLI and daemon | Rust 2021 (MSRV 1.88) | 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 |
| Web Dashboard | Vanilla HTML/CSS/JS | No frontend build step, assets embedded in the binary via `rust-embed` |
| Analytical store | DuckDB + Parquet | Mandatory embedded columnar store, linked to official prebuilt `libduckdb.so` |
| Deployment | Docker / docker-compose | Collective SOC daemon, unprivileged non-root user, read-only rootfs |
| 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.
The workspace has two crates: `acaua-core` and `acaua-cli`. Both use `version.workspace = true`.
## Components
@@ -35,20 +30,20 @@ Zeek logs ──► acaua-core::zeek ──► normalized events ──► stora
├──► pillar3 (protocol anomalies)
└──► pillar4 (fusion) ──► alerts + evidence
│
acaua-cli ──► axum API ──► embedded UI ◄── Tauri shell ─────────────┘
acaua-cli (serve) ──► axum API ──► embedded web dashboard ──────────┘
```
- `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.
- `storage`: DuckDB in-memory engine over daily partitioned Parquet files with a
seven-day TTL.
- `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.
- `doctor`: local environment checks; reports prebuilt DuckDB linkage and
containerized Zeek status.
- `update`: checks for updates against the Gitea Releases API.
## The six corrections
@@ -58,117 +53,107 @@ 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
P(T) = (2 / N) * [ (sum cos(2*pi*t_i / T))^2 + (sum sin(2*pi*t_i / T))^2 ]
```
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.
Generalized Lomb–Scargle is reserved for the future extension that evaluates a
real response attribute (such as payload size or duration) against time.
### 2. Bowley skew with an explicit sentinel
### 2. Guard the zero-dispersion case in MADM
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.
A synthetic or unjittered beacon produces equal intervals. The median absolute
deviation from the median (MADM) is then `0.0`. Dividing by `0.0` or treating it
as a missing value is wrong: zero dispersion is the most suspicious outcome. The
implementation guards this case with an explicit score assignment (`1.0`) and a
documented sentinel.
### 3. Jensen–Shannon, not raw KL
### 3. Replace Pearson skewness with Bowley skewness
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.
Sample skewness based on the third central moment is sensitive to single
outliers and can diverge. Bowley skewness uses quartiles:
### 4. Persistent sessions get their own detector
```text
B = (Q3 + Q1 - 2*Q2) / (Q3 - Q1)
```
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.
It is bounded in `[-1.0, 1.0]` by definition and resistant to extreme outliers.
When `Q3 == Q1` (concentrated data), the implementation assigns `0.0` with a
sentinel flag.
### 5. JA4 license caution
### 4. Isolate the Python research workspace
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.
`research/` has its own virtual environment and dependencies. It is excluded
from the Cargo workspace, ignored by Docker builds and CI releases, and tested
only when research validation scripts are invoked directly.
### 6. Determinism and per-alert JSON evidence
### 5. Documented degraded mode for NetFlow/IPFIX
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.
NetFlow v9 and IPFIX do not carry DNS query names, TLS SNI, certificate chains
or HTTP headers. Any analysis that depends on protocol features is unavailable.
Acauã documents this as a degraded mode and never claims parity between flow
records and full Zeek logs.
## Data flow
### 6. Single HTTP and RPC boundary
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.
The daemon exposes an Axum API on loopback by default. The embedded web UI
communicates with the daemon exclusively via standard HTTP calls.
## API
| Method | Path | Purpose |
The HTTP API is served by `acaua serve`:
| Method | Path | Meaning |
|---|---|---|
| GET | `/api/health` | Liveness and version |
| GET | `/api/status` | Uptime, data directory, pillar state |
| GET | `/api/status` | Uptime, data directory, dataset summary |
| GET | `/api/alerts` | Paginated alerts with evidence |
| POST | `/api/analyze` | Analyze a dataset path |
| POST | `/api/update` | Check for a CLI update |
| POST | `/api/analyze` | Ingest and analyze a dataset directory |
| GET | `/api/update` | Check for newer releases |
| GET | `/logo.png` | Embedded brand asset |
| GET | `/*` | Embedded UI fallback |
| GET | `/*` | Embedded UI fallback (`rust-embed`) |
`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.
`serve` binds to `127.0.0.1:8787` by default. Exposing it on an external network
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.
DuckDB is a mandatory dependency linked dynamically to the official prebuilt
`libduckdb.so` (version 1.5.6 pinned with SHA-256 in `scripts/fetch-libduckdb.sh`).
Parquet, JSON and ICU extensions are statically built into the official library.
The schema consists of daily partitioned Parquet files under the configured
data directory, with in-memory DuckDB querying and a seven-day TTL.
## 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.
Updates for Acauã are delivered via container images (`docker compose pull && docker compose up -d`).
The `acaua update` command queries the Gitea Releases API for `ciberfauna/acaua` and
notifies operators when a newer version tag is available. Binary self-overwrite
(`update --apply`) is removed: in a collective SOC daemon running under container
orchestration, updates must be controlled by image pulls rather than self-mutating
processes.
## 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/`.
1. **Python service with FastAPI/ClickHouse:** Rejected. A Python runtime introduces
heavy container footprints and unnecessary memory boundaries; ClickHouse adds an
external server operational burden for datasets that fit an embedded columnar store.
The product is 100% Rust.
2. **Desktop application (Tauri / AppImage / NSIS):** Rejected. Unlike Guará (local
raw disk forensics) and Sabiá (Wi-Fi monitor-mode hardware), Acauã has no local
hardware dependencies: it is a multi-user SOC daemon consuming Zeek logs. A desktop
wrapper adds maintenance and security surface without operational benefits.
## 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).
- **Docker Compose (Official):** Multi-arch container image (`git.lucasrguerra.dev.br/ciberfauna/acaua`),
running as unprivileged user `acaua` (UID 10001), read-only root filesystem, dropped capabilities.
- **Native compilation:** `scripts/fetch-libduckdb.sh ~/.cache/acaua/duckdb` followed by `cargo build --release -p acaua-cli`.
## 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.
over existing data.
## Versioning and compatibility
+21 -46
View File
@@ -2,23 +2,22 @@
**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.
The `acaua` binary serves both as the CLI command runner and the background daemon.
This page describes the commands available in the engine.
```text
acaua <command> [options]
Commands:
serve Start the API and embedded UI
serve Start the API and embedded web dashboard
analyze Analyze a Zeek log directory
doctor Check the local environment
update Check or apply a CLI update
update Check for available releases
```
## `acaua serve`
Starts the HTTP API and the embedded UI.
Starts the HTTP API and the embedded web dashboard.
```bash
acaua serve --bind 127.0.0.1:8787 --data-dir .acaua
@@ -40,14 +39,8 @@ 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.
Validates the target directory, ingests `conn.log` traffic into Parquet partitions,
and runs threat detection algorithms.
## `acaua doctor`
@@ -56,52 +49,34 @@ 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.
Runs environment checks:
- `zeek`: verifies `zeek --version` or detects containerized execution (`ACAUA_RUNTIME=container`).
- `curl`: confirms HTTP download availability.
- `duckdb`: confirms dynamic linkage against `libduckdb.so` in runtime.
- `research`: verifies that the research workspace remains isolated.
## `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.
Queries the Gitea Releases API, compares the latest tag against the running
version, and prints upgrade instructions (`docker compose pull && docker compose up -d`
when running inside a container).
## Exit codes
| Code | Meaning |
|---|---|
| `0` | Success |
| `1` | Runtime error (invalid path, failed update, I/O error) |
| `1` | Runtime error (invalid path, query error, 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.
| Variable | Meaning |
|---|---|
| `ACAUA_RUNTIME` | Set to `container` inside Docker images to adapt service checks |
| `DUCKDB_LIB_DIR` | Path to directory containing `libduckdb.so` when compiling natively |
| `LD_LIBRARY_PATH` | Shared library load path for `libduckdb.so` outside standard paths |
+57 -45
View File
@@ -2,66 +2,78 @@
**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.
Acauã is distributed as an official container image:
`git.lucasrguerra.dev.br/ciberfauna/acaua`.
## Build
Available image tags:
- `latest`: Most recent stable release.
- `X.Y.Z` / `X.Y`: Pinned semantic versions.
- `edge`: Built automatically from the `main` branch.
The container runs as an unprivileged system user (`acaua`, UID 10001), with a
read-only root filesystem (`read_only: true`), all capabilities dropped
(`cap_drop: [ALL]`), and security hardening (`no-new-privileges:true`).
## Compose deployment
The recommended production and lab deployment uses Docker Compose:
```bash
docker build -t acaua .
docker run --rm -p 127.0.0.1:8787:8787 -v "$PWD/data/acaua:/data/acaua" acaua
cp .env.example .env # configure ZEEK_INTERFACE (authorized networks only)
docker compose pull && docker compose up -d
```
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
To build and run locally from source:
```bash
cp .env.example .env # optional: set ZEEK_INTERFACE
docker compose up --build
docker compose up -d --build
```
The Compose file starts two services:
The stack coordinates 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` |
| Service | Image | Purpose | Security profile |
|---|---|---|---|
| `zeek` | `zeek/zeek:9.0.1` | Network packet capture and hourly log rotation | Host network, `NET_RAW` and `NET_ADMIN` capabilities |
| `acaua` | `git.lucasrguerra.dev.br/ciberfauna/acaua` | Analytical processing, API and web dashboard | Unprivileged (UID 10001), read-only rootfs, no capabilities |
`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.
## Volumes and data layout
## Data layout
The stack uses named volumes to manage ownership and permissions cleanly:
```text
data/
zeek/ # Zeek logs, written by the zeek service
acaua/ # store, exports and runtime files
- `zeek-logs`: Shared volume mounted at `/logs` in the Zeek service and mounted
**read-only** at `/data/zeek:ro` in the Acauã service.
- `acaua-data`: Dedicated read-write volume mounted at `/data/acaua` for DuckDB
analytical store, Parquet partitions and runtime files.
To inspect or extract data on the host:
```bash
docker compose cp acaua:/data/acaua ./local-acaua-backup
```
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.
Or run a one-off inspection container:
```bash
docker run --rm -v acaua_acaua-data:/data:ro debian:trixie-slim ls -la /data
```
## Updating
Because Acauã runs as a multi-user collective SOC daemon, updates are performed
by pulling the latest verified image:
```bash
docker compose pull && docker compose up -d
```
The running daemon shuts down cleanly on `SIGTERM` within sub-second intervals,
ensuring Parquet writes and DuckDB transactions are committed properly.
## 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.
- The `zeek` service requires packet capture privileges (`NET_RAW`, `NET_ADMIN`).
Run it only on interfaces and networks you are explicitly authorized to monitor.
- The default dashboard binding is loopback-only (`ACAUA_PUBLISH=127.0.0.1:8787`).
Do not expose the service on external interfaces without an authenticating reverse proxy.
- The image contains no telemetry. Outbound traffic is restricted to operator-initiated
update checks against the Gitea Releases API.
+20 -47
View File
@@ -2,66 +2,39 @@
**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.
The interface is a vanilla HTML/CSS/JS dashboard embedded directly into the
`acaua` binary via `rust-embed` and served by `acaua serve`. There is no
frontend build step and no heavy framework runtime.
## 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.
- **Sidebar:** brand logo, navigation, daemon connection indicator, version and update check.
- **Overview:** monitoring scope notice, authorized-use warning and four detection pillar cards.
- **Analysis:** dataset path entry, analyze button, refresh and raw JSON output inspection.
- **Alerts:** list of alerts with deterministic evidence metadata.
- **Settings:** language and theme preferences, stored in browser local storage.
## 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.
The source text is Portuguese. `i18n.js` applies English translations from
`i18n-en.js` at runtime and persists the selection in `localStorage`. `Auto`
detects the browser language. The automated test `node scripts/check-ui-i18n.mjs`
guarantees that every visible Portuguese string has an exact English pair.
## Theme
`Auto`, light and dark are supported. `Auto` follows `prefers-color-scheme`. The
choice is stored locally; no preference leaves the machine.
Light, dark and `Auto` modes are supported. `Auto` reacts to system
`prefers-color-scheme`. Preferences stay local; no telemetry is sent.
## Connection state
The footer polls `/api/health` and shows one of:
The interface communicates with `/api/health` and displays:
- **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.
- **Connecting:** initial connection in progress.
- **Local service online:** daemon is responding.
- **Service unavailable:** connection to daemon failed; interface remains accessible for offline settings.
## 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.
Posting a directory path to `/api/analyze` triggers the Zeek ingestion and
analytical detection pipeline.
+98 -113
View File
@@ -2,176 +2,161 @@
[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.
O Acauã é um produto único em Rust: um daemon/CLI, um núcleo compartilhado e um
dashboard web embutido. 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ê |
| Camada | Escolha | Motivo |
|---|---|---|
| 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 |
| Core, CLI e daemon | Rust 2021 (MSRV 1.88) | Toolchain única, segurança de memória, sem dependências externas de runtime |
| API HTTP | Axum + Tokio + Tower HTTP | Superfície assíncrona enxuta, fácil de embutir |
| Dashboard Web | HTML/CSS/JS vanilla | Sem etapa de build de frontend, assets embutidos no binário via `rust-embed` |
| Armazenamento analítico | DuckDB + Parquet | Armazenamento colunar embutido obrigatório, linkado à `libduckdb.so` oficial pré-compilada |
| Implantação | Docker / docker-compose | Daemon de SOC coletivo, usuário não-root sem privilégios, rootfs somente leitura |
| Validação científica | Python apenas 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.
O workspace possui dois crates: `acaua-core` e `acaua-cli`. Ambos utilizam `version.workspace = true`.
## Componentes
```
Logs Zeek ──► acaua-core::zeek ──► eventos normalizados ──► armazenamento
Logs Zeek ──► acaua-core::zeek ──► eventos normalizados ──► storage
│
├──► pilar1 (beaconing)
├──► pilar2 (sessões persistentes)
├──► pilar3 (anomalias de protocolo)
└──► pilar4 (fusão) ──► alertas + evidência
├──► pillar1 (beaconing)
├──► pillar2 (sessões persistentes)
├──► pillar3 (anomalias de protocolo)
└──► pillar4 (fusão) ──► alertas + evidência
│
acaua-cli ──► API axum ──► UI embutida ◄── shell Tauri ────────────┘
acaua-cli (serve) ──► API axum ──► dashboard web embutido ──────────┘
```
- `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.
- `zeek`: parser delimitado e confinamento de caminhos. Nunca segue symlink fora
da raiz do dataset.
- `storage`: motor DuckDB em memória sobre arquivos Parquet particionados por
dia com TTL de sete dias.
- `pillar1`–`pillar4`: funções puras sobre eventos normalizados. Retornam scores
e evidência, nunca apenas um float.
- `fusion`: combinação limitada em ordem fixa. Deve ser simétrica e reproduzível.
- `doctor`: checagens do ambiente local; reporta linkagem da DuckDB pré-compilada
e status do Zeek em container.
- `update`: checa novas versões consultando a API de Releases do Gitea.
## 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`
Timestamps de eventos são um processo pontual unidimensional: não há valor `y`
para ajustar. O periodograma primário é, portanto, Rayleigh/Schuster:
```text
P(f) = |sum_k exp(2*pi*i*f*t_k)|^2 / N
P(T) = (2 / N) * [ (sum cos(2*pi*t_i / T))^2 + (sum sin(2*pi*t_i / T))^2 ]
```
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.
Lomb–Scargle generalizado fica reservado para a extensão futura que avaliará um
atributo real de resposta (como tamanho de payload ou duração) contra o tempo.
### 2. Bowley com sentinela explícito
### 2. Proteger o caso de dispersão zero no MADM
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.
Um beacon sintético ou sem jitter produz intervalos perfeitamente iguais. O
desvio absoluto mediano em relação à mediana (MADM) é então `0.0`. Dividir por
`0.0` ou tratá-lo como dado ausente é incorreto: dispersão zero é o resultado mais
suspeito possível. A implementação protege esse caso com atribuição explícita de
score (`1.0`) e sentinela documentado.
### 3. Jensen–Shannon, não KL puro
### 3. Substituir assimetria de Pearson por assimetria de Bowley
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.
A assimetria amostral baseada no terceiro momento central é sensível a outliers
individuais e pode divergir. A assimetria de Bowley utiliza quartis:
### 4. Sessões persistentes têm detector próprio
```text
B = (Q3 + Q1 - 2*Q2) / (Q3 - Q1)
```
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.
Ela é limitada em `[-1.0, 1.0]` por definição e resistente a outliers extremos.
Quando `Q3 == Q1` (dados concentrados), a implementação atribui `0.0` com flag
sentinela.
### 5. Cautela com a licença JA4
### 4. Isolar o espaço de pesquisa Python
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.
`research/` tem seu próprio ambiente virtual e dependências. Ele é excluído do
workspace Cargo, ignorado em builds Docker e releases de CI, e testado apenas
quando scripts de validação de pesquisa forem invocados diretamente.
### 6. Determinismo e evidência JSON por alerta
### 5. Modo degradado documentado para NetFlow/IPFIX
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.
NetFlow v9 e IPFIX não carregam nomes de consulta DNS, SNI TLS, cadeias de
certificados ou cabeçalhos HTTP. Qualquer análise que dependa de recursos de
protocolo fica indisponível. O Acauã documenta isso como modo degradado e nunca
alega paridade entre registros de fluxo e logs Zeek completos.
## Fluxo de dados
### 6. Fronteira única HTTP e RPC
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.
O daemon expõe uma API Axum em loopback por padrão. A UI web embutida se
comunica com o daemon exclusivamente por chamadas HTTP padrão.
## API
| Método | Caminho | Finalidade |
A API HTTP é servida por `acaua serve`:
| Método | Caminho | Significado |
|---|---|---|
| GET | `/api/health` | Liveness e versão |
| GET | `/api/status` | Uptime, diretório de dados, estado dos pilares |
| GET | `/api/status` | Uptime, diretório de dados, sumário do dataset |
| GET | `/api/alerts` | Alertas paginados com evidência |
| POST | `/api/analyze` | Analisar um caminho de dataset |
| POST | `/api/update` | Checar atualização da CLI |
| POST | `/api/analyze` | Ingestão e análise de diretório de dataset |
| GET | `/api/update` | Checagem de novas releases |
| GET | `/logo.png` | Asset de marca embutido |
| GET | `/*` | Fallback da UI embutida |
| GET | `/*` | Fallback de UI embutida (`rust-embed`) |
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.
`serve` escuta em `127.0.0.1:8787` por padrão. Expô-lo em uma rede externa exige
um 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.
DuckDB é uma dependência obrigatória linkada dinamicamente à `libduckdb.so`
oficial pré-compilada (versão 1.5.6 fixada com SHA-256 em `scripts/fetch-libduckdb.sh`).
Extensões Parquet, JSON e ICU já vêm compiladas estaticamente na biblioteca oficial.
O esquema consiste em arquivos Parquet particionados por dia sob o diretório de
dados configurado, com consultas DuckDB em memória e TTL de sete dias.
## 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.
Atualizações do Acauã são distribuídas por imagens de container (`docker compose pull && docker compose up -d`).
O comando `acaua update` consulta a API de Releases do Gitea para `ciberfauna/acaua` e
notifica os operadores quando uma tag de versão mais nova estiver disponível. A
sobrescrita binária em disco (`update --apply`) foi removida: em um daemon de SOC
coletivo orquestrado via container, atualizações devem ser controladas por puxar
imagens novas em vez de processos que alteram o próprio executável.
## 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/`.
1. **Serviço Python com FastAPI/ClickHouse:** Rejeitado. Um runtime Python
introduziria imagens pesadas e fronteiras de memória desnecessárias; o
ClickHouse adicionaria um servidor externo e custo operacional para datasets
que cabem perfeitamente em um armazenamento colunar embutido. O produto é 100% Rust.
2. **Aplicativo desktop (Tauri / AppImage / NSIS):** Rejeitado. Diferente do Guará
(forense em disco bruto local) e do Sabiá (hardware Wi-Fi em modo monitor), o
Acauã não tem dependência de hardware local: é um daemon de SOC multiusuário
consumindo logs do Zeek. Um wrapper desktop traria manutenção e superfície de
segurança sem ganho operacional.
## 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).
- **Docker Compose (Oficial):** Imagem multi-arch (`git.lucasrguerra.dev.br/ciberfauna/acaua`),
rodando com usuário não-privilegiado `acaua` (UID 10001), filesystem somente leitura e capabilities revogadas.
- **Compilação nativa:** `scripts/fetch-libduckdb.sh ~/.cache/acaua/duckdb` seguido de `cargo build --release -p acaua-cli`.
## Comportamento em falha
## Comportamento em falhas
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.
Entradas malformadas são ignoradas com diagnóstico delimitado e contado. Um log
ausente não é fatal. Um banco corrompido é reportado pelo `doctor` e nunca
recriado silenciosamente sobre dados existentes.
## 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.
SemVer 2.0.0. O JSON de evidência carrega uma versão de esquema. Qualquer
alteração em uma pontuação ou no formato de evidência é breaking change para
versões minor `0.x` e deve constar no changelog.
+28 -52
View File
@@ -2,23 +2,22 @@
[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.
O binário `acaua` atua tanto como utilitário de linha de comando quanto como daemon de segundo plano.
Esta página descreve os comandos disponíveis no motor.
```text
acaua <comando> [opções]
acaua <command> [options]
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
serve Inicia a API e o dashboard web embutido
analyze Analisa um diretório de logs do Zeek
doctor Verifica o ambiente local
update Checa por releases disponíveis
```
## `acaua serve`
Sobe a API HTTP e a UI embutida.
Inicia a API HTTP e o dashboard web embutido.
```bash
acaua serve --bind 127.0.0.1:8787 --data-dir .acaua
@@ -27,10 +26,10 @@ 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 |
| `--data-dir` | `.acaua` | Diretório de dados e arquivos de execução |
O bind padrão é só loopback. Expor o daemon na rede exige proxy reverso com
autenticação. A API está documentada em
O bind padrão restringe-se a loopback. Expô-lo em uma rede exige um proxy
reverso com autenticação. A API está documentada em
[arquitetura.md](arquitetura.md#api).
## `acaua analyze`
@@ -40,14 +39,8 @@ 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.
Valida o diretório alvo, ingere tráfego do `conn.log` em partições Parquet e
executa os algoritmos de detecção de ameaças.
## `acaua doctor`
@@ -56,51 +49,34 @@ 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.
Executa checagens de ambiente:
- `zeek`: verifica `zeek --version` ou detecta execução em container (`ACAUA_RUNTIME=container`).
- `curl`: confirma disponibilidade de download HTTP.
- `duckdb`: confirma linkagem dinâmica contra a `libduckdb.so` em tempo de execução.
- `research`: valida o isolamento do workspace de pesquisa.
## `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.
Consulta a API de Releases do Gitea, compara a última tag com a versão em
execução e imprime instruções de atualização (`docker compose pull && docker compose up -d`
quando em container).
## 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) |
| `1` | Erro em tempo de execução (caminho inválido, erro de consulta, I/O) |
| `2` | Uso inválido de 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.
| Variável | Significado |
|---|---|
| `ACAUA_RUNTIME` | Definida como `container` em imagens Docker para adaptar checagens |
| `DUCKDB_LIB_DIR` | Caminho do diretório contendo `libduckdb.so` em compilação nativa |
| `LD_LIBRARY_PATH` | Caminho de busca de bibliotecas compartilhadas para `libduckdb.so` |
+58 -44
View File
@@ -2,65 +2,79 @@
[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.
O Acauã é distribuído como uma imagem de container oficial:
`git.lucasrguerra.dev.br/ciberfauna/acaua`.
## Build
Tags de imagem disponíveis:
- `latest`: Release estável mais recente.
- `X.Y.Z` / `X.Y`: Versões semânticas fixadas.
- `edge`: Gerada automaticamente a partir do branch `main`.
O container executa como usuário de sistema não privilegiado (`acaua`, UID 10001),
com filesystem raiz somente leitura (`read_only: true`), todas as capabilities revogadas
(`cap_drop: [ALL]`), e endurecimento de segurança (`no-new-privileges:true`).
## Implantação com Compose
A implantação recomendada para produção e laboratório utiliza Docker Compose:
```bash
docker build -t acaua .
docker run --rm -p 127.0.0.1:8787:8787 -v "$PWD/data/acaua:/data/acaua" acaua
cp .env.example .env # configure ZEEK_INTERFACE (apenas redes autorizadas)
docker compose pull && docker compose up -d
```
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
Para compilar e rodar localmente a partir do código:
```bash
cp .env.example .env # opcional: defina ZEEK_INTERFACE
docker compose up --build
docker compose up -d --build
```
O Compose sobe dois serviços:
A stack coordena 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` |
| Serviço | Imagem | Finalidade | Perfil de segurança |
|---|---|---|---|
| `zeek` | `zeek/zeek:9.0.1` | Captura de pacotes de rede e rotação horária de logs | Rede host, capabilities `NET_RAW` e `NET_ADMIN` |
| `acaua` | `git.lucasrguerra.dev.br/ciberfauna/acaua` | Processamento analítico, API e dashboard web | Não privilegiado (UID 10001), rootfs read-only, sem capabilities |
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.
## Volumes e organização de dados
## Layout de dados
A stack utiliza volumes nomeados para isolamento limpo de permissões e propriedade:
```text
data/
zeek/ # logs Zeek, escritos pelo serviço zeek
acaua/ # store, exportações e arquivos de runtime
- `zeek-logs`: Volume compartilhado montado em `/logs` no serviço Zeek e montado
como **somente leitura** em `/data/zeek:ro` no serviço Acauã.
- `acaua-data`: Volume dedicado de leitura e escrita montado em `/data/acaua` para o
armazenamento DuckDB, partições Parquet e arquivos de execução.
Para inspecionar ou extrair dados no host:
```bash
docker compose cp acaua:/data/acaua ./local-acaua-backup
```
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.
Ou executar um container efêmero de inspeção:
```bash
docker run --rm -v acaua_acaua-data:/data:ro debian:trixie-slim ls -la /data
```
## Atualização
Por rodar como um daemon de SOC de uso coletivo, atualizações são realizadas
puxando a imagem verificada mais recente:
```bash
docker compose pull && docker compose up -d
```
O daemon em execução finaliza de forma limpa em resposta ao `SIGTERM` em tempo
sub-segundo, garantindo que gravações Parquet e transações DuckDB sejam concluídas
sem corrupção.
## 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.
- O serviço `zeek` requer privilégios de captura de pacotes (`NET_RAW`, `NET_ADMIN`).
Execute-o apenas em interfaces e redes que você tem autorização explícita para monitorar.
- A publicação padrão do dashboard é restrita a loopback (`ACAUA_PUBLISH=127.0.0.1:8787`).
Não exponha o serviço em interfaces externas sem um proxy reverso com autenticação.
- A imagem não possui telemetria. Tráfego de saída restringe-se a checagens de
versão iniciadas pelo operador contra a API de Releases do Gitea.
+22 -54
View File
@@ -2,71 +2,39 @@
[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.
A interface é um dashboard vanilla em HTML/CSS/JS embutido diretamente no binário
`acaua` via `rust-embed` e servido por `acaua serve`. Não há etapa de build de
frontend nem frameworks pesados em runtime.
## Layout
## Estrutura
- **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.
- **Barra lateral:** logo da marca, navegação, indicador de conexão ao daemon, versão e checagem de atualizações.
- **Visão geral:** aviso de escopo de monitoramento, alerta de uso autorizado e cartões dos quatro pilares de detecção.
- **Análise:** campo de caminho de dataset, botão de análise, atualização e inspeção de saída JSON.
- **Alertas:** listagem de alertas com metadados de evidência determinística.
- **Configurações:** preferências de idioma e tema, salvas no armazenamento local do navegador.
## 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.
O texto original é em português. `i18n.js` aplica as traduções em inglês de
`i18n-en.js` em tempo de execução e salva a escolha no `localStorage`. `Auto`
detecta o idioma do navegador. O teste automatizado `node scripts/check-ui-i18n.mjs`
garante que toda string visível em português tenha seu par exato 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.
Modos claro, escuro e `Auto` são suportados. `Auto` responde ao `prefers-color-scheme`
do sistema operacional. Preferências ficam no host; nenhuma telemetria é emitida.
## Estado da conexão
## Estado de conexão
O rodapé consulta `/api/health` e mostra um de:
A interface comunica-se com `/api/health` e exibe:
- **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.
- **Conectando:** conexão inicial em andamento.
- **Serviço local ativo:** o daemon está respondendo.
- **Serviço indisponível:** falha na conexão com o daemon; a interface continua acessível para ajustes locais.
## 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.
Enviar um caminho de diretório para `/api/analyze` dispara a ingestão do Zeek e o
pipeline analítico de detecção.
+19 -29
View File
@@ -2,46 +2,36 @@
[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.
O Acauã é distribuído como um motor de NDR containerizado. A forma mais rápida de
obter assistência é abrir uma issue no repositório do Gitea.
## Antes de abrir uma issue
1. Rode `acaua doctor --json` e guarde a saída.
1. Execute `acaua doctor --json` dentro do container (`docker exec acaua acaua doctor --json`) ou no host.
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).
3. Tente reproduzir o problema com a menor captura de logs Zeek possível.
4. Revise 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`.
- Sistema operacional do host, arquitetura (`uname -m`) e versão/tag do Acauã.
- O comando exato ou definição do Docker Compose.
- Logs completos do container (`docker compose logs acaua`).
- Amostras anonimizadas de logs Zeek que demonstrem a falha.
Não anexe capturas de produção nem credenciais. Redija endereços, hostnames e
payloads antes de compartilhar.
Não anexe capturas de tráfego de produção nem credenciais. Anonimize endereços IP,
hostnames e payloads sensíveis antes de compartilhar.
## Problemas de segurança
## Questões 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.
Não inclua detalhes de exploração em uma issue pública. Siga a política em
[SECURITY.pt-BR.md](../../SECURITY.pt-BR.md) e solicite um canal privado de comunicação.
## Situações comuns
| Sintoma | O que checar |
| Sintoma | O que verificar |
|---|---|
| `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.
| Container não sobe | Confirme linkagem de `libduckdb.so` via `docker logs` e verifique permissões nos volumes nomeados |
| `serve` inacessível a partir de outro host | O serviço escuta em loopback (`127.0.0.1:8787`) por padrão; verifique o proxy reverso |
| `update` falha | Verifique conectividade de rede com a API de Releases do Gitea ou permissões de pull do registry |
| UI web exibe português | `Auto` segue o idioma do navegador; escolha inglês nas configurações |
+16 -26
View File
@@ -2,46 +2,36 @@
**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.
Acauã is distributed as a containerized NDR engine. The fastest way to get help
is opening an issue in the Gitea repository.
## Before opening an issue
1. Run `acaua doctor --json` and keep the output.
1. Run `acaua doctor --json` inside the container (`docker exec acaua acaua doctor --json`) or host.
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).
3. Try reproducing with the smallest possible Zeek log capture.
4. Review 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`.
- Host operating system, architecture (`uname -m`) and Acauã version/tag.
- The exact command or Docker Compose definition.
- Full container logs (`docker compose logs acaua`).
- Redacted Zeek log samples demonstrating the issue.
Do not attach production captures or credentials. Redact addresses, hostnames
and payloads before sharing.
Do not attach production captures or credentials. Redact IP addresses, hostnames
and sensitive 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.
[SECURITY.md](../SECURITY.md) and request a private communication channel.
## 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.
| Container fails to start | Confirm `libduckdb.so` linkage via `docker logs` and verify permissions on named volumes |
| `serve` is unreachable from another host | Service binds to loopback (`127.0.0.1:8787`) by default; verify reverse proxy configuration |
| `update` fails | Check network connectivity to Gitea Releases API or verify registry pull permissions |
| Web UI shows Portuguese | `Auto` follows the browser language; select English in settings |
-185
View File
@@ -1,185 +0,0 @@
#!/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
+39 -17
View File
@@ -17,6 +17,10 @@ step() { # step "name" command...
if "$@"; then printf '\033[32mok\033[0m %s\n' "$name"; else printf '\033[31mFAILED\033[0m %s\n' "$name"; failures+=("$name"); fi
}
duckdb_dir="${XDG_CACHE_HOME:-$HOME/.cache}/acaua/duckdb"
scripts/fetch-libduckdb.sh "$duckdb_dir" >/dev/null || { echo "could not install libduckdb" >&2; exit 1; }
export DUCKDB_LIB_DIR="$duckdb_dir" DUCKDB_INCLUDE_DIR="$duckdb_dir" LD_LIBRARY_PATH="$duckdb_dir${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
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
@@ -32,9 +36,16 @@ terms = [
'updater' + '.pubkey',
'createUpdater' + 'Artifacts',
'plugin' + '-updater',
'Tau' + 'ri',
'App' + 'Image',
]
skip_dirs = {'.git', 'target', '.wolf', '.claude', 'dist-gui'}
allowed = {Path('docs/architecture.md')}
skip_dirs = {'.git', 'target', '.wolf', '.claude'}
allowed = {
Path('docs/architecture.md'),
Path('docs/pt-BR/arquitetura.md'),
Path('CHANGELOG.md'),
Path('CHANGELOG.pt-BR.md'),
}
hits = []
for path in Path('.').rglob('*'):
if not path.is_file() or any(part in skip_dirs for part in path.parts):
@@ -61,9 +72,9 @@ 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 "brand assets" test -s logo.png
step "consistent Semantic Versioning 2.0.0" python3 -c "
import json, re, sys
import re, sys
from pathlib import Path
semver_re = re.compile(
@@ -78,22 +89,33 @@ def cargo_version(path):
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])
version = cargo_version('Cargo.toml')
if not semver_re.match(version):
sys.exit(f'invalid SemVer 2.0.0 version: {version}')
print(version)
"
step "DuckDB pin matches the duckdb crate" python3 -c "
import re, sys
from pathlib import Path
lock = Path('Cargo.lock').read_text()
m = re.search(r'^name = \"duckdb\"\nversion = \"1\.(\d+)\.\d+\"', lock, re.M)
if not m:
sys.exit('duckdb crate not found in Cargo.lock')
enc = int(m.group(1))
crate = f'{enc // 10000}.{enc // 100 % 100}.{enc % 100}'
pin = re.search(r'^DUCKDB_VERSION=(\S+)$', Path('scripts/fetch-libduckdb.sh').read_text(), re.M).group(1)
if crate != pin:
sys.exit(f'DuckDB mismatch: duckdb crate targets {crate}, scripts/fetch-libduckdb.sh pins {pin}')
print(f'DuckDB {pin}')
"
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
if command -v docker >/dev/null && docker compose version >/dev/null 2>&1; then
step "docker compose config" docker compose config --quiet
else
printf '\n(desktop app: skipped, WebKitGTK development libraries not installed; built in CI)\n'
printf '\n(docker compose config: skipped, docker compose not available)\n'
fi
if [ "$full" = 1 ]; then
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# Installs the official prebuilt DuckDB shared library (libduckdb.so and
# duckdb.h) for the current CPU architecture into <dest-dir>, after checking
# its SHA-256. DuckDB is never compiled from source in this project.
#
# scripts/fetch-libduckdb.sh <dest-dir>
#
# Then build with DUCKDB_LIB_DIR=<dest-dir> and run tests or the binary with
# LD_LIBRARY_PATH=<dest-dir> (the Docker image uses ldconfig instead).
#
# The pinned version MUST match the duckdb crate in Cargo.lock: duckdb-rs
# encodes the DuckDB version in its second component (DuckDB 1.5.6 <-> duckdb
# 1.10506.x); scripts/check.sh enforces this. To upgrade, change the version
# and both checksums together (from the "digest" field of each asset in
# https://api.github.com/repos/duckdb/duckdb/releases/tags/v<version>).
# DuckDB 2.x renamed these assets to duckdb-shared-libs-linux-<arch>.tar.gz.
set -euo pipefail
DUCKDB_VERSION=1.5.6
dest=${1:?usage: fetch-libduckdb.sh <dest-dir>}
case "$(uname -m)" in
x86_64 | amd64) arch=amd64 sha256=b845005f5132a7d8180057c35e14a7626632258782f871a90861b19c1c03841b ;;
aarch64 | arm64) arch=arm64 sha256=b72ed9f05003f5e9d2015f7ceada6416b377d9dd33169cdde3c9e33897856eee ;;
*) echo "fetch-libduckdb: unsupported architecture: $(uname -m)" >&2; exit 1 ;;
esac
stamp="$DUCKDB_VERSION-linux-$arch"
installed() { [ -f "$dest/libduckdb.so" ] && [ "$(cat "$dest/.duckdb-release" 2>/dev/null)" = "$stamp" ]; }
if installed; then
echo "libduckdb $stamp already in $dest"
exit 0
fi
command -v unzip >/dev/null || { echo "fetch-libduckdb: unzip is required" >&2; exit 1; }
parent=$(dirname "$dest")
mkdir -p "$parent"
# Stage next to the destination so the final rename is atomic: parallel CI
# jobs sharing /opt/hostedtoolcache never see a half-extracted library.
stage=$(mktemp -d "$parent/.libduckdb.XXXXXX")
trap 'rm -rf "$stage"' EXIT
asset="libduckdb-linux-$arch.zip"
curl -fsSL --retry 3 -o "$stage/$asset" \
"https://github.com/duckdb/duckdb/releases/download/v$DUCKDB_VERSION/$asset"
echo "$sha256 $stage/$asset" | sha256sum -c --quiet - \
|| { echo "fetch-libduckdb: checksum mismatch for $asset" >&2; exit 1; }
# The archive also carries libduckdb_static.a (~80 MB) and duckdb.hpp; only the
# shared library and the C header are needed.
unzip -o -j -q "$stage/$asset" libduckdb.so duckdb.h -d "$stage/lib"
echo "$stamp" > "$stage/lib/.duckdb-release"
if ! mv -T "$stage/lib" "$dest" 2>/dev/null; then
# Either another job just installed the same release, or an older one is there.
installed || { rm -rf "$dest" && mv -T "$stage/lib" "$dest"; }
fi
echo "libduckdb $stamp installed in $dest"
-51
View File
@@ -1,51 +0,0 @@
#!/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()
-39
View File
@@ -1,39 +0,0 @@
#!/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"
-25
View File
@@ -1,25 +0,0 @@
#!/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
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Smoke-tests an Acauã image: the API and the dashboard answer, libduckdb is
# linked, a HEALTHCHECK is declared and SIGTERM stops the daemon cleanly.
# Everything runs through `docker exec`, so no port has to be reachable from
# the CI job container. The container is always removed.
#
# scripts/smoke-image.sh <image-ref>
set -euo pipefail
image=${1:?usage: smoke-image.sh <image-ref>}
name="acaua-smoke-$$-$RANDOM"
trap 'docker rm -f "$name" >/dev/null 2>&1 || true' EXIT
fail() { echo "smoke: $*" >&2; docker logs "$name" >&2 2>&1 || true; exit 1; }
docker run -d --name "$name" "$image" >/dev/null
for _ in $(seq 1 30); do
docker exec "$name" curl -fsS http://127.0.0.1:8787/api/health >/dev/null 2>&1 && break
sleep 1
done
docker exec "$name" curl -fsS http://127.0.0.1:8787/api/health | grep -q '"status":"ok"' || fail "/api/health did not answer"
docker exec "$name" curl -fsS http://127.0.0.1:8787/ | grep -qi '<!doctype html' || fail "dashboard is not served"
docker exec "$name" acaua doctor --json | grep -A1 '"name": "duckdb"' | grep -q '"ok": true' || fail "libduckdb is not usable"
[ "$(docker inspect -f '{{if .Config.Healthcheck}}yes{{end}}' "$name")" = yes ] || fail "image has no HEALTHCHECK"
[ "$(docker exec "$name" id -u)" != 0 ] || fail "daemon runs as root"
docker stop -t 10 "$name" >/dev/null
code=$(docker inspect -f '{{.State.ExitCode}}' "$name")
[ "$code" = 0 ] || fail "exit code $code after SIGTERM (expected 0: SIGTERM must stop serve)"
echo "smoke: $image ok"