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
+2524 -6384

No files matched your search

+2 -1
View File
@@ -1,8 +1,8 @@
.git .git
target target
apps/gui/src-tauri
docs docs
scripts scripts
!scripts/fetch-libduckdb.sh
research research
data data
.acaua .acaua
@@ -11,3 +11,4 @@ data
.claude .claude
.wolf .wolf
.superpowers .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 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: on:
# Documentation-only or local agent state changes do not trigger CI jobs. # 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: 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: concurrency:
group: ci-${{ gitea.ref }} group: ci-${{ gitea.ref }}
@@ -22,6 +20,11 @@ env:
CARGO_HOME: /opt/hostedtoolcache/cargo CARGO_HOME: /opt/hostedtoolcache/cargo
RUSTUP_HOME: /opt/hostedtoolcache/rustup RUSTUP_HOME: /opt/hostedtoolcache/rustup
NPM_CONFIG_CACHE: /opt/hostedtoolcache/npm-cache 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: jobs:
lint: lint:
@@ -39,6 +42,10 @@ jobs:
node-version: 22 node-version: 22
- name: UI translation coverage - name: UI translation coverage
run: node scripts/check-ui-i18n.mjs 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 fmt --all -- --check
- run: cargo clippy --workspace --all-targets - run: cargo clippy --workspace --all-targets
@@ -51,6 +58,10 @@ jobs:
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable - 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 - run: cargo test --workspace
audit: audit:
@@ -67,22 +78,16 @@ jobs:
command -v cargo-audit >/dev/null || cargo install cargo-audit --locked command -v cargo-audit >/dev/null || cargo install cargo-audit --locked
cargo audit cargo audit
gui: image:
name: Check desktop app (Linux) name: Docker image (build and smoke test)
if: ${{ gitea.event_name == 'pull_request' }}
runs-on: linux-arm64 runs-on: linux-arm64
env:
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/acaua-gui
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable - name: Build
- uses: actions/setup-node@v7 run: docker build -t "acaua-ci:${{ gitea.run_id }}" .
with: - name: Smoke test
node-version: 22 run: scripts/smoke-image.sh "acaua-ci:${{ gitea.run_id }}"
- name: Tauri system dependencies - name: Clean up
timeout-minutes: 25 if: always()
run: scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-native libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf run: docker rmi -f "acaua-ci:${{ gitea.run_id }}" >/dev/null 2>&1 || true
- 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: ""
+136 -99
View File
@@ -1,25 +1,146 @@
name: Release name: Release
# Triggered when a v* tag is pushed: creates the Gitea Release entry with notes # Publishes the multi-arch Acauã image (linux/amd64 + linux/arm64) to the Gitea
# from CHANGELOG.md and uploads the compiled CLI (scripts/package-cli.sh) with # container registry. A merge into main publishes `edge`; a v* tag re-points the
# a .sha256 checksum alongside each archive, plus the desktop bundles (Linux # image already built for the same inputs to X.Y.Z, X.Y and latest (no rebuild)
# aarch64, Linux x86_64, and Windows via cargo-xwin). # and creates the Gitea Release. Needs the secret REGISTRY_TOKEN (Gitea token
# with write:package) visible to this repository.
on: on:
push: push:
branches: [main]
tags: ['v*'] tags: ['v*']
workflow_dispatch:
concurrency:
group: release-${{ gitea.ref }}
cancel-in-progress: false
env: env:
CARGO_TERM_COLOR: always REGISTRY: git.lucasrguerra.dev.br
# Rust toolchains, cargo registry, and caches live on the persistent runner volume.
CARGO_HOME: /opt/hostedtoolcache/cargo
RUSTUP_HOME: /opt/hostedtoolcache/rustup
NPM_CONFIG_CACHE: /opt/hostedtoolcache/npm-cache
XWIN_CACHE_DIR: /opt/hostedtoolcache/xwin-cache
jobs: jobs:
release: # The image is built only from these paths, so their tree hashes identify it.
name: Create release # When inputs-<hash> already exists (docs-only merge, workflow change, or a tag
runs-on: ubuntu-latest # 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: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with: with:
@@ -29,98 +150,14 @@ jobs:
TAG: ${{ gitea.ref_name }} TAG: ${{ gitea.ref_name }}
TOKEN: ${{ gitea.token }} TOKEN: ${{ gitea.token }}
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }} API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
IMAGE: ${{ needs.plan.outputs.image }}
run: | 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 if curl -fsS -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" >/dev/null 2>&1; then
echo "release $TAG already exists"; exit 0 echo "release $TAG already exists"; exit 0
fi 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 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" \ 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 '{tag_name:$tag, name:$name, body:$body, prerelease:$pre}' > release.json
curl -fsS -X POST "$API/releases" -H "Authorization: token $TOKEN" \ curl -fsS -X POST "$API/releases" -H "Authorization: token $TOKEN" \
-H 'Content-Type: application/json' -d @release.json >/dev/null -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 # Build artifacts
target/ target/
apps/gui/src-tauri/target/
apps/gui/src-tauri/gen/
node_modules/ node_modules/
dist-gui/
/acaua-v*
*.rs.bk *.rs.bk
# Runtime data and local datasets # Runtime data and local datasets
@@ -37,6 +33,3 @@ research/.ipynb_checkpoints/
CLAUDE.md CLAUDE.md
AGENTS.md AGENTS.md
GEMINI.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] ## [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 ## [0.1.0] - 2026-10-31
### Added ### Added
+13
View File
@@ -6,6 +6,19 @@ Formato baseado no [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/);
## [Unreleased] ## [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 ## [0.1.0] - 2026-10-31
### Adicionado ### Adicionado
+11 -27
View File
@@ -2,8 +2,8 @@
**English** · [Português](CONTRIBUTING.pt-BR.md) **English** · [Português](CONTRIBUTING.pt-BR.md)
Thanks for helping. The project is a pre-alpha skeleton, so the most useful Thanks for helping. The project is an NDR engine built around deterministic evidence.
contributions right now are tests, fixtures, documentation and small, The most useful contributions right now are tests, fixtures, documentation and small,
well-scoped design corrections. well-scoped design corrections.
1. **Authorized monitoring only.** Acauã is defensive software for networks you 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 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. 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 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`). through `i18n-en.js`).
## Workflow ## Workflow
```bash ```bash
scripts/install-hooks.sh 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
scripts/check.sh --full scripts/check.sh --full
``` ```
`check.sh` runs formatting, clippy with warnings as errors, UI translation `check.sh` runs formatting, clippy with warnings as errors, UI translation
coverage, shell syntax, workflow YAML, SemVer consistency and the desktop check. coverage, shell syntax, workflow YAML, SemVer consistency, DuckDB version pin
`--full` adds workspace tests and `cargo audit`. verification and forbidden terms inspection. `--full` adds workspace tests and `cargo audit`.
The optional DuckDB feature is not part of the default check:
```bash
cargo check -p acaua-core --features duckdb
```
## Research ## Research
@@ -42,18 +38,6 @@ cargo check -p acaua-core --features duckdb
use astropy, scipy, networkx, pandas and notebooks. It must never be imported by 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. 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 ## Interface and translation
The interface source text is Portuguese. English comes from 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:`, Commits follow Conventional Commits 1.0.0 in English (`feat:`, `fix:`, `chore:`,
`ci:`, `docs:`, `refactor:`, `test:`, `perf:`, `build:`). Versions follow SemVer `ci:`, `docs:`, `refactor:`, `test:`, `perf:`, `build:`). Versions follow SemVer
2.0.0 and are synchronized across the workspace, the desktop manifest and the 2.0.0.
Tauri config.
Pushing a `v*` tag creates a Gitea Release from the matching `CHANGELOG.md` Releases are published as multi-arch container images (`linux/amd64` and `linux/arm64`)
section and attaches the Linux CLI packages plus the desktop bundles. 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 ## License
+37 -54
View File
@@ -2,83 +2,66 @@
[English](CONTRIBUTING.md) · **Português** [English](CONTRIBUTING.md) · **Português**
Obrigado por ajudar. O projeto é um esqueleto pré-alfa, então as contribuições Obrigado por ajudar. O projeto é um motor de NDR construído em torno de evidência determinística.
mais úteis agora são testes, fixtures, documentação e correções de projeto As contribuições mais úteis agora são testes, fixtures, documentação e correções
pequenas e bem delimitadas. arquiteturais pontuais.
1. **Só monitoramento autorizado.** O Acauã é software defensivo para redes 1. **Apenas monitoramento autorizado.** O Acauã é software defensivo para redes
próprias ou com autorização explícita. Não adicione caminhos de coleta que próprias ou redes que você tem autorização expressa para observar. Não adicione
escondam escopo, retenção ou evidência. mecanismos de coleta que ocultem escopo, retenção ou evidências.
2. **Evidência faz parte do recurso.** Uma mudança de detecção precisa declarar 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. Pontuação sem entradas, reduções em ordem fixa e valores intermediários. Não são aceitas
registro reproduzível de evidência não é aceita. pontuações sem registro reproduzível de evidência.
3. **Seja honesto sobre o que funciona.** Se um recurso é stub, só roda no Linux 3. **Seja transparente sobre o que funciona.** Se um recurso for um stub, limitado
ou cobre apenas um log Zeek, diga isso no README, no `--help` e no teste. a Linux ou restrito a um log Zeek específico, documente no README, na ajuda da
4. **Código 100% em inglês.** Código-fonte, comentários, scripts, workflows e CLI e nos testes.
mensagens de commit são em inglês. A GUI e a CLI continuam bilíngues (PT-BR e 4. **Base de código 100% em inglês.** Código-fonte, comentários, scripts, workflows
EN via `i18n-en.js`). 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 ```bash
scripts/install-hooks.sh 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
scripts/check.sh --full scripts/check.sh --full
``` ```
O `check.sh` roda formatação, clippy com warnings como erro, cobertura de `check.sh` executa 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 tradução da UI, sintaxe de shell, YAML de workflows, consistência de SemVer,
checagem do desktop. O `--full` inclui testes do workspace e `cargo audit`. validação de pin da DuckDB e termos proibidos. `--full` inclui os testes do
workspace e `cargo audit`.
A feature opcional DuckDB não entra na checagem padrão:
```bash
cargo check -p acaua-core --features duckdb
```
## Pesquisa ## Pesquisa
`research/` é um espaço Python isolado para validação científica. Ele pode usar `research/` é um espaço Python isolado para validação científica. Pode utilizar
astropy, scipy, networkx, pandas e notebooks. Ele nunca deve ser importado pelo astropy, scipy, networkx, pandas e notebooks. Nunca deve ser importado pelo runtime
runtime Rust, copiado para um release ou exigido para compilar o produto. em Rust, copiado para uma release ou exigido para compilar o produto.
## Regras de detecção
- Mantenha reduções em ordem fixa quando resultados de ponto flutuante entram em
um alerta.
- Use um valor sentinela documentado em vez de `NaN` ou panic para entradas
degeneradas.
- Use Jensen-Shannon, não KL puro, quando uma distribuição pode ter probabilidade
zero.
- Não use JA4S/JA4H/JA4X/JA4SSH/JA4L por padrão: essas variantes têm licença
não-OSI. O JA4 de impressão digital de cliente TLS é o único padrão.
- Conexões longas precisam de detector próprio; elas não produzem os intervalos
curtos que uma pontuação de beaconing espera.
## Interface e tradução ## Interface e tradução
O texto-fonte da interface é em português. O inglês vem de O texto original da interface é em português. O inglês vem de `apps/gui/ui/i18n-en.js`,
`apps/gui/ui/i18n-en.js`, aplicado em tempo de execução por `i18n.js`. Ao aplicado em runtime por `i18n.js`. Ao adicionar texto visível, execute
adicionar texto visível, rode `node scripts/check-ui-i18n.mjs` e inclua o par em `node scripts/check-ui-i18n.mjs` e forneça o par correspondente em inglês.
inglês.
## Documentação ## Documentação
Mantenha o par: todo arquivo `*.md` e `docs/*` tem versão em inglês e em Mantenha os pares: todo arquivo `*.md` e `docs/*` possui uma versão em inglês e
português (`*.pt-BR.md`, `docs/pt-BR/`). Nomes de arquivo só são traduzidos uma em português (`*.pt-BR.md`, `docs/pt-BR/`). Nomes de arquivo são traduzidos
quando a palavra muda de verdade, como `architecture.md` → `arquitetura.md`. apenas quando a palavra de fato muda, como em `architecture.md` → `arquitetura.md`.
## Commits e releases ## Commits e releases
Os commits seguem Conventional Commits 1.0.0 em inglês (`feat:`, `fix:`, Commits seguem Conventional Commits 1.0.0 em inglês (`feat:`, `fix:`, `chore:`,
`chore:`, `ci:`, `docs:`, `refactor:`, `test:`, `perf:`, `build:`). As versões `ci:`, `docs:`, `refactor:`, `test:`, `perf:`, `build:`). Versões seguem SemVer 2.0.0.
seguem SemVer 2.0.0 e ficam sincronizadas entre o workspace, o manifesto do
desktop e a configuração do Tauri.
Enviar uma tag `v*` cria uma Release no Gitea a partir da seção correspondente do Releases são publicadas como imagens de container multi-arquitetura (`linux/amd64`
`CHANGELOG.md` e anexa os pacotes Linux da CLI e os bundles do desktop. 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 ## Licença
Ao contribuir, você concorda em licenciar sob [MIT](LICENSE-MIT) **ou** 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. # This file is automatically @generated by Cargo.
# It is not intended for manual editing. # It is not intended for manual editing.
version = 3 version = 4
[[package]] [[package]]
name = "acaua-cli" name = "acaua-cli"
@@ -23,6 +23,7 @@ dependencies = [
name = "acaua-core" name = "acaua-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"chrono",
"duckdb", "duckdb",
"serde", "serde",
"serde_json", "serde_json",
@@ -122,9 +123,9 @@ dependencies = [
[[package]] [[package]]
name = "arrow" name = "arrow"
version = "60.1.0" version = "58.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d965156f2220e94dcf676e04322b7b0b41c97e85cb3e1c365b8e56d0f70be999" checksum = "c943b9bec582e47f8394e44713c3cbfbe645fd049e3a7771585f54849c710fe6"
dependencies = [ dependencies = [
"arrow-arith", "arrow-arith",
"arrow-array", "arrow-array",
@@ -140,9 +141,9 @@ dependencies = [
[[package]] [[package]]
name = "arrow-arith" name = "arrow-arith"
version = "60.1.0" version = "58.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca16fdd1daeb1eccefb42df52ec65448ef33eec72488598675d46dfa9ce58923" checksum = "8b13b85a6a547a1669114e418f6ab3c3e39c2edb1b8be5ab2889e5739a433ae9"
dependencies = [ dependencies = [
"arrow-array", "arrow-array",
"arrow-buffer", "arrow-buffer",
@@ -154,9 +155,9 @@ dependencies = [
[[package]] [[package]]
name = "arrow-array" name = "arrow-array"
version = "60.1.0" version = "58.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f3d22a41d3df78b71df0b685af219ae3a94188ab98d6fb29c958eb55c3247f4" checksum = "7b11eb61b95868592ad6edbd537465a6940eb3c81a875addecec24283301b267"
dependencies = [ dependencies = [
"ahash", "ahash",
"arrow-buffer", "arrow-buffer",
@@ -165,7 +166,6 @@ dependencies = [
"chrono", "chrono",
"half", "half",
"hashbrown 0.17.1", "hashbrown 0.17.1",
"libc",
"num-complex", "num-complex",
"num-integer", "num-integer",
"num-traits", "num-traits",
@@ -173,9 +173,9 @@ dependencies = [
[[package]] [[package]]
name = "arrow-buffer" name = "arrow-buffer"
version = "60.1.0" version = "58.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7bd46a530bc1a8e763102fc36c1dc0f51f3ffa150c78e7f5658ccb54b1458ade" checksum = "53ee609a07eb721c2e4a7669d7bb88e2106c560e7dec1219096fecf5eae356d1"
dependencies = [ dependencies = [
"bytes", "bytes",
"half", "half",
@@ -185,9 +185,9 @@ dependencies = [
[[package]] [[package]]
name = "arrow-cast" name = "arrow-cast"
version = "60.1.0" version = "58.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baa83a0752814a1922d5ee75032524ca9bbfb7f60ee35a67ed4ab8b275260aaf" checksum = "9575323ffeb4bc6955f39bf8716f734671a32780f1fac9ba6c2891048fc0ea1f"
dependencies = [ dependencies = [
"arrow-array", "arrow-array",
"arrow-buffer", "arrow-buffer",
@@ -196,7 +196,7 @@ dependencies = [
"arrow-schema", "arrow-schema",
"arrow-select", "arrow-select",
"atoi", "atoi",
"base64", "base64 0.22.1",
"chrono", "chrono",
"comfy-table", "comfy-table",
"half", "half",
@@ -205,22 +205,11 @@ dependencies = [
"ryu", "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]] [[package]]
name = "arrow-data" name = "arrow-data"
version = "60.1.0" version = "58.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "415b7fbc4cf56b080bb5677a4ae74211b5c79e571ed37a1d547633ec07f3b43b" checksum = "10791765e6927cdd8ace5bf0e51a69f38a1c3aded2db93561b003c332d02ee2a"
dependencies = [ dependencies = [
"arrow-buffer", "arrow-buffer",
"arrow-schema", "arrow-schema",
@@ -231,13 +220,12 @@ dependencies = [
[[package]] [[package]]
name = "arrow-ord" name = "arrow-ord"
version = "60.1.0" version = "58.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c58f2ae6dd1480fc413f7320a547e39dcc499c23d4f36ca19572b685811e6e4" checksum = "908a1b5aeefce5c6eb2a0a4e98f4ccacbd441fe0ed23fb799e634cd0135821e5"
dependencies = [ dependencies = [
"arrow-array", "arrow-array",
"arrow-buffer", "arrow-buffer",
"arrow-cmp",
"arrow-data", "arrow-data",
"arrow-schema", "arrow-schema",
"arrow-select", "arrow-select",
@@ -245,9 +233,9 @@ dependencies = [
[[package]] [[package]]
name = "arrow-row" name = "arrow-row"
version = "60.1.0" version = "58.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68f9db8345b0638416d3b56d117d262d13070fdef788c6d75b53a180215c477e" checksum = "5383b1676492a509032f06fa4ca9522c886036aec4ebe8261bebfbce904af667"
dependencies = [ dependencies = [
"arrow-array", "arrow-array",
"arrow-buffer", "arrow-buffer",
@@ -258,23 +246,22 @@ dependencies = [
[[package]] [[package]]
name = "arrow-schema" name = "arrow-schema"
version = "60.1.0" version = "58.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de2c8e714b466404cdc1189e89f892426efea614d938e16c15518c340cd43ba3" checksum = "8506ad465ebb9483bb6efa1e2e78a313549122c6dd67dc2f7087297b762eaede"
dependencies = [ dependencies = [
"bitflags", "bitflags",
] ]
[[package]] [[package]]
name = "arrow-select" name = "arrow-select"
version = "60.1.0" version = "58.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5af3f6656c5f2c6944f93d9a357a7f204c93d5f349385a44cf46c4a26fbcf30" checksum = "fd0f0a0d641356ae2df829af5d635ed9fb93fbeb0d39f3239487cb5fdb37a6ef"
dependencies = [ dependencies = [
"ahash", "ahash",
"arrow-array", "arrow-array",
"arrow-buffer", "arrow-buffer",
"arrow-cmp",
"arrow-data", "arrow-data",
"arrow-schema", "arrow-schema",
"num-traits", "num-traits",
@@ -282,9 +269,9 @@ dependencies = [
[[package]] [[package]]
name = "arrow-string" name = "arrow-string"
version = "60.1.0" version = "58.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab1be9306b9a413b5b004e2a5a19caac728870d76e8891b351f040d554c93862" checksum = "bd245a358d52e77bfcb99dc3e21f04b947383271b7a0b9ca8feec43e9039234d"
dependencies = [ dependencies = [
"arrow-array", "arrow-array",
"arrow-buffer", "arrow-buffer",
@@ -311,9 +298,9 @@ dependencies = [
[[package]] [[package]]
name = "atoi" name = "atoi"
version = "3.1.1" version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d12c37257553c19e9aee6cba77814fdc9e07844abfc8b3a2c377cd920ee5a993" checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"
dependencies = [ dependencies = [
"num-traits", "num-traits",
] ]
@@ -382,6 +369,12 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.23.1" version = "0.23.1"
@@ -437,8 +430,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"jobserver",
"libc",
"shlex", "shlex",
] ]
@@ -448,6 +439,12 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.45" version = "0.4.45"
@@ -507,10 +504,11 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]] [[package]]
name = "comfy-table" name = "comfy-table"
version = "8.0.1" version = "7.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b96a46ad395a5e00676eddaca4a45ed78b29c08be5a878d35e4b071c625637dd" checksum = "4a65ebfec4fb190b6f90e944a817d60499ee0744e582530e2c9900a22e591d9a"
dependencies = [ dependencies = [
"crossterm",
"unicode-segmentation", "unicode-segmentation",
"unicode-width", "unicode-width",
] ]
@@ -591,6 +589,28 @@ dependencies = [
"cfg-if", "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]] [[package]]
name = "crunchy" name = "crunchy"
version = "0.2.4" version = "0.2.4"
@@ -645,12 +665,13 @@ dependencies = [
[[package]] [[package]]
name = "duckdb" name = "duckdb"
version = "1.20000.1" version = "1.10506.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d807ca47d4fa206f102ff568046e018fb84518eb0a0b5e65a165a2eb7bd4a7c2" checksum = "1a46cc5f64f534993bae616668a94b1b8e58295c2bd39f85afef7c44297817da"
dependencies = [ dependencies = [
"arrow", "arrow",
"cast", "cast",
"comfy-table",
"fallible-iterator", "fallible-iterator",
"fallible-streaming-iterator", "fallible-streaming-iterator",
"hashlink", "hashlink",
@@ -659,6 +680,12 @@ dependencies = [
"strum", "strum",
] ]
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]] [[package]]
name = "errno" name = "errno"
version = "0.3.14" version = "0.3.14"
@@ -799,8 +826,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi 6.0.0", "r-efi 6.0.0",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -957,6 +986,16 @@ dependencies = [
"cc", "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]] [[package]]
name = "is_terminal_polyfill" name = "is_terminal_polyfill"
version = "1.70.2" version = "1.70.2"
@@ -969,16 +1008,6 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" 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]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.107" version = "0.3.107"
@@ -1061,19 +1090,18 @@ checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
[[package]] [[package]]
name = "libduckdb-sys" name = "libduckdb-sys"
version = "1.20000.1" version = "1.10506.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0140448c472968ee5cf5ba697435deebe558b366ef1e5c080d24d9696b600725" checksum = "113d1c26c9f518405a17b5729bb6ab6abf029fdc46626406922cf9a8dc9b4609"
dependencies = [ dependencies = [
"cc",
"flate2", "flate2",
"pkg-config", "pkg-config",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.11.1",
"tar", "tar",
"ureq", "ureq",
"vcpkg", "vcpkg",
"zip",
] ]
[[package]] [[package]]
@@ -1082,12 +1110,27 @@ version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.12.1" version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" 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]] [[package]]
name = "log" name = "log"
version = "0.4.35" version = "0.4.35"
@@ -1143,6 +1186,18 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@@ -1154,9 +1209,9 @@ dependencies = [
[[package]] [[package]]
name = "num-bigint" name = "num-bigint"
version = "0.5.1" version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [ dependencies = [
"num-integer", "num-integer",
"num-traits", "num-traits",
@@ -1208,6 +1263,29 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" 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]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"
@@ -1262,6 +1340,15 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" 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]] [[package]]
name = "regex" name = "regex"
version = "1.13.1" version = "1.13.1"
@@ -1340,6 +1427,19 @@ dependencies = [
"walkdir", "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]] [[package]]
name = "rustix" name = "rustix"
version = "1.1.6" version = "1.1.6"
@@ -1349,7 +1449,7 @@ dependencies = [
"bitflags", "bitflags",
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "linux-raw-sys 0.12.1",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@@ -1409,6 +1509,12 @@ dependencies = [
"winapi-util", "winapi-util",
] ]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.229" version = "1.0.229"
@@ -1631,7 +1737,7 @@ dependencies = [
"fastrand", "fastrand",
"getrandom 0.4.3", "getrandom 0.4.3",
"once_cell", "once_cell",
"rustix", "rustix 1.1.6",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@@ -1848,6 +1954,12 @@ dependencies = [
"tracing-log", "tracing-log",
] ]
[[package]]
name = "typed-path"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
[[package]] [[package]]
name = "typenum" name = "typenum"
version = "1.20.1" version = "1.20.1"
@@ -1890,7 +2002,7 @@ version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b" checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b"
dependencies = [ dependencies = [
"base64", "base64 0.23.1",
"log", "log",
"percent-encoding", "percent-encoding",
"rustls", "rustls",
@@ -1906,7 +2018,7 @@ version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922"
dependencies = [ dependencies = [
"base64", "base64 0.23.1",
"http", "http",
"httparse", "httparse",
"log", "log",
@@ -2020,6 +2132,22 @@ dependencies = [
"rustls-pki-types", "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]] [[package]]
name = "winapi-util" name = "winapi-util"
version = "0.1.11" version = "0.1.11"
@@ -2029,6 +2157,12 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.62.2" version = "0.62.2"
@@ -2183,7 +2317,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "963d6fab92d5504b971dc8bd90ca743c2575e590bc8071ea5bc571598667ae08" checksum = "963d6fab92d5504b971dc8bd90ca743c2575e590bc8071ea5bc571598667ae08"
dependencies = [ dependencies = [
"libc", "libc",
"rustix", "rustix 1.1.6",
] ]
[[package]] [[package]]
@@ -2212,6 +2346,23 @@ version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" 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]] [[package]]
name = "zlib-rs" name = "zlib-rs"
version = "0.6.8" version = "0.6.8"
@@ -2223,3 +2374,15 @@ name = "zmij"
version = "1.0.23" version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" 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] [workspace]
resolver = "2" resolver = "2"
members = ["crates/acaua-core", "crates/acaua-cli"] members = ["crates/acaua-core", "crates/acaua-cli"]
exclude = ["apps/gui/src-tauri", "research"] exclude = ["research"]
[workspace.package] [workspace.package]
version = "0.1.0" version = "0.1.0"
@@ -9,7 +9,7 @@ edition = "2021"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
repository = "https://git.lucasrguerra.dev.br/ciberfauna/acaua" repository = "https://git.lucasrguerra.dev.br/ciberfauna/acaua"
authors = ["Acauã contributors"] authors = ["Acauã contributors"]
rust-version = "1.80" rust-version = "1.88"
[profile.release] [profile.release]
lto = "thin" lto = "thin"
+34 -6
View File
@@ -1,23 +1,51 @@
# syntax=docker/dockerfile:1 # 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 WORKDIR /src
COPY Cargo.toml Cargo.lock ./ COPY Cargo.toml Cargo.lock ./
COPY crates crates COPY crates crates
COPY apps/gui/ui apps/gui/ui COPY apps/gui/ui apps/gui/ui
COPY logo.png logo.png 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 \ RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl tar \ && apt-get install -y --no-install-recommends ca-certificates curl libstdc++6 \
&& rm -rf /var/lib/apt/lists/* && 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 COPY --from=build /src/target/release/acaua /usr/local/bin/acaua
LABEL org.opencontainers.image.title="Acauã" \ LABEL org.opencontainers.image.title="Acauã" \
org.opencontainers.image.description="Network threat hunting with deterministic evidence" \ 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" org.opencontainers.image.licenses="MIT OR Apache-2.0"
ENV ACAUA_RUNTIME=container
USER acaua
WORKDIR /data WORKDIR /data
EXPOSE 8787 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"] ENTRYPOINT ["acaua"]
CMD ["serve", "--bind", "0.0.0.0:8787", "--data-dir", "/data/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 for identity and lateral-movement analysis. NetFlow/IPFIX is a documented
degraded mode, never presented as equivalent. Suricata EVE JSON is an optional degraded mode, never presented as equivalent. Suricata EVE JSON is an optional
secondary input. secondary input.
- **Daemon and desktop:** `acaua serve` runs continuous ingestion, scheduling, - **Server daemon and web dashboard:** `acaua serve` runs continuous ingestion,
the API and the embedded UI; the Tauri app opens a local dataset or talks to a scheduling, the API and the embedded web UI, distributed 100% via Docker.
remote `serve`.
- **Research stays outside the product:** `research/` is an isolated Python - **Research stays outside the product:** `research/` is an isolated Python
workspace for scientific validation and fixtures. It is never compiled, workspace for scientific validation and fixtures. It is never compiled,
packaged or released. 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 | | Rust workspace and CLI stubs | ✅ compiles |
| Axum API with health, status, alerts and analyze routes | ✅ stub | | 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 | ✅ | | Documentation and CI/release workflows | ✅ |
| Docker multi-arch image and compose deployment | ✅ |
| DuckDB analytical store | ✅ official prebuilt library linked |
| Four detection pillars | 🚧 under development | | Four detection pillars | 🚧 under development |
| Zeek log parser | 🚧 stub | | Zeek log parser | 🚧 stub |
| DuckDB/Parquet storage and 7-day TTL | 🚧 reserved, feature disabled by default |
| Alert evidence and scoring | 🚧 stub | | Alert evidence and scoring | 🚧 stub |
| Published releases | ❌ none yet | | 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 to monitor. Capturing or analyzing third-party traffic without permission may
be illegal. The operator is responsible for scope, privacy and retention. 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 The daemon binds to `127.0.0.1` by default. The Docker Compose deployment
documented for lab and research use. isolates the daemon in an unprivileged, read-only container with dropped capabilities.
## Install ## Install
There are no published releases yet. Build from source: Run via Docker Compose (recommended for operators):
```bash ```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 cargo build --release -p acaua-cli
./target/release/acaua --help ./target/release/acaua --help
``` ```
Rust 1.80+ is required. The desktop app needs the Tauri system dependencies for Supported platform matrix for published container images: `linux/amd64` and `linux/arm64`.
your platform.
## Getting started ## Getting started
@@ -95,39 +109,29 @@ acaua update
``` ```
`analyze` currently validates the directory and returns a skeleton report; it `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. does not parse Zeek logs yet. `serve` exposes the stub API and the embedded web UI.
`update` uses the same Gitea Releases + SHA-256 model as Guará and Sabiá. `update` checks for new versions and guides container image updates.
## Desktop app
The Tauri app is a shell for the same UI used by `serve`. It has a dashboard for
the four pillars, a dataset path field, an alerts panel and settings for language
and theme. It does **not** render detection results yet. See
**[docs/interface.md](docs/interface.md)**.
## Development ## Development
```bash ```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 test --workspace
cargo clippy --workspace --all-targets cargo clippy --workspace --all-targets
node scripts/check-ui-i18n.mjs 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 See **[CONTRIBUTING.md](CONTRIBUTING.md)** and **[docs/architecture.md](docs/architecture.md)**.
by default so the skeleton stays fast to check. See
**[CONTRIBUTING.md](CONTRIBUTING.md)** and
**[docs/architecture.md](docs/architecture.md)**.
## Known limits ## Known limits
- The detection engine, Zeek parser and storage layer are stubs. - The detection engine, Zeek parser and storage layer are stubs.
- There is no published binary, installer or release asset. - There is no published release image yet.
- The GUI has no real screenshots because there is nothing to render yet.
- NetFlow/IPFIX is a degraded input mode by design; it cannot replace the Zeek - NetFlow/IPFIX is a degraded input mode by design; it cannot replace the Zeek
protocol logs for every pillar. protocol logs for every pillar.
- The project targets Linux for the daemon and the CI release pipeline. The - The project targets Linux container environments (`linux/amd64`, `linux/arm64`).
desktop app follows the same Tauri platform matrix as the sibling projects.
## Where it's going ## 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 | | **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 | | **Not planned** | Opaque AI scoring, a required cloud service, Python in the shipped runtime |
## License ## License
+34 -29
View File
@@ -27,9 +27,8 @@ produziram esta pontuação".
protocolo necessários para identidade e movimento lateral. NetFlow/IPFIX é um protocolo necessários para identidade e movimento lateral. NetFlow/IPFIX é um
modo degradado documentado, nunca apresentado como equivalente. Suricata EVE modo degradado documentado, nunca apresentado como equivalente. Suricata EVE
JSON é uma entrada secundária opcional. JSON é uma entrada secundária opcional.
- **Daemon e desktop:** `acaua serve` roda ingestão contínua, agendador, API e a - **Daemon servidor e dashboard web:** `acaua serve` roda ingestão contínua,
UI embutida; o app Tauri abre um dataset local ou conversa com um `serve` agendador, API e o dashboard web embutido, distribuído 100% via Docker.
remoto.
- **Pesquisa fora do produto:** `research/` é um espaço Python isolado para - **Pesquisa fora do produto:** `research/` é um espaço Python isolado para
validação científica e fixtures. Ele nunca é compilado, empacotado ou validação científica e fixtures. Ele nunca é compilado, empacotado ou
distribuído. distribuído.
@@ -40,11 +39,12 @@ produziram esta pontuação".
|---|---| |---|---|
| Workspace Rust e stubs da CLI | ✅ compila | | Workspace Rust e stubs da CLI | ✅ compila |
| API Axum com rotas de health, status, alertas e análise | ✅ stub | | 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 | ✅ | | 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 | | Quatro pilares de detecção | 🚧 em desenvolvimento |
| Parser de logs Zeek | 🚧 stub | | 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 | | Evidência e pontuação de alertas | 🚧 stub |
| Releases publicadas | ❌ nenhuma ainda | | 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 sem permissão pode ser ilegal. O operador é responsável por escopo, privacidade e
retenção. retenção.
O daemon escuta em `127.0.0.1` por padrão. A implantação opcional com Docker é O daemon escuta em `127.0.0.1` por padrão. A implantação via Docker Compose
documentada para laboratório e pesquisa. isola o daemon em um container não privilegiado, somente leitura e sem capabilities.
## Instalação ## Instalação
Ainda não há releases publicadas. Compile do código: Executar via Docker Compose (recomendado para operadores):
```bash ```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 cargo build --release -p acaua-cli
./target/release/acaua --help ./target/release/acaua --help
``` ```
É preciso Rust 1.80+. O app desktop precisa das dependências de sistema do Tauri Matriz de plataformas suportadas para as imagens publicadas: `linux/amd64` e `linux/arm64`.
para a sua plataforma.
## Começando ## Começando
@@ -97,39 +111,30 @@ acaua update
``` ```
O `analyze` hoje valida o diretório e devolve um relatório de esqueleto; ele 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` ainda não lê logs Zeek. O `serve` expõe a API stub e o dashboard web embutido.
usa o mesmo modelo de Gitea Releases + SHA-256 do Guará e do Sabiá. O `update` checa por versões novas e orienta a atualização da imagem do container.
## App desktop
O app Tauri é um shell para a mesma UI usada pelo `serve`. Ele tem um painel dos
quatro pilares, um campo de caminho de dataset, um painel de alertas e
configurações de idioma e tema. Ele **ainda não** renderiza resultados de
detecção. Veja **[docs/pt-BR/interface.md](docs/pt-BR/interface.md)**.
## Desenvolvimento ## Desenvolvimento
```bash ```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 test --workspace
cargo clippy --workspace --all-targets cargo clippy --workspace --all-targets
node scripts/check-ui-i18n.mjs node scripts/check-ui-i18n.mjs
scripts/check.sh scripts/check.sh --full
``` ```
O backend DuckDB opcional é compilado com `--features duckdb`; ele fica desligado Veja **[CONTRIBUTING.pt-BR.md](CONTRIBUTING.pt-BR.md)** e
por padrão para o esqueleto continuar rápido de verificar. Veja
**[CONTRIBUTING.pt-BR.md](CONTRIBUTING.pt-BR.md)** e
**[docs/pt-BR/arquitetura.md](docs/pt-BR/arquitetura.md)**. **[docs/pt-BR/arquitetura.md](docs/pt-BR/arquitetura.md)**.
## Limites conhecidos ## Limites conhecidos
- O motor de detecção, o parser Zeek e a camada de armazenamento são stubs. - 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. - Não há imagem de release publicada ainda.
- A GUI não tem screenshots reais porque ainda não há nada para renderizar.
- NetFlow/IPFIX é um modo degradado por projeto; ele não substitui os logs de - NetFlow/IPFIX é um modo degradado por projeto; ele não substitui os logs de
protocolo do Zeek em todos os pilares. protocolo do Zeek em todos os pilares.
- O projeto mira Linux para o daemon e para o pipeline de release do CI. O app - O projeto mira ambientes de container Linux (`linux/amd64`, `linux/arm64`).
desktop segue a mesma matriz Tauri dos projetos irmãos.
## Para onde vai ## 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 | | **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 | | **Fora de plano** | Pontuação por IA opaca, serviço de nuvem obrigatório, Python no runtime distribuído |
## Licença ## Licença
+10 -18
View File
@@ -3,23 +3,22 @@
**English** · [Português](ROADMAP.pt-BR.md) **English** · [Português](ROADMAP.pt-BR.md)
This roadmap separates what exists from what is planned. The four pillars are 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 ## Done
- Rust workspace with `acaua-core` and `acaua-cli`. - Rust workspace with `acaua-core` and `acaua-cli`.
- CLI skeleton, Axum API skeleton and embedded vanilla UI. - CLI commands, Axum API and embedded web dashboard.
- Tauri desktop shell with language and theme settings. - 100% Docker containerization, compose deployment, non-root hardened profile.
- Stubs for the four pillars, evidence fusion, storage, Zeek ingestion and the - Prebuilt DuckDB linking and Parquet storage foundations.
Gitea-based updater. - Stubs for the four pillars, evidence fusion, and Zeek ingestion.
- English/Portuguese documentation, CI/release workflows and `scripts/check.sh`. - 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 ## Next
1. **Pillar 1 — beaconing.** Implement the Rayleigh/Schuster periodogram on 1. **Pillar 1 — beaconing.** Implement the Rayleigh/Schuster periodogram on
event timestamps, add generalized Lomb–Scargle only when a real `y` exists, event timestamps, add harmonic resistance, and verify with deterministic fixtures.
and ship golden fixtures with fixed expected values.
2. **Pillar 2 — persistent sessions.** Detect long connections that never 2. **Pillar 2 — persistent sessions.** Detect long connections that never
produce a short interval, with explicit thresholds and no overlap with produce a short interval, with explicit thresholds and no overlap with
pillar 1. 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 - Pillar 3 — deterministic protocol anomalies, including identity and
lateral-movement features. lateral-movement features.
- Pillar 4 — bounded, symmetric fusion with reproducible weights. - Pillar 4 — bounded, symmetric fusion with reproducible weights.
- DuckDB/Parquet storage behind the optional `duckdb` feature, with a seven-day - Analytical store optimizations, daily Parquet partitioning and TTL pruning.
TTL and an export command. - Scheduled ingestion and automated log rotation handling in daemon mode.
- Desktop dataset view, alert detail with evidence, and scheduled `serve` runs.
- NetFlow/IPFIX as a documented degraded mode and Suricata EVE JSON as an - NetFlow/IPFIX as a documented degraded mode and Suricata EVE JSON as an
optional secondary input. optional secondary input.
- Signed release metadata, SBOM and reproducible builds. - 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. - A required cloud service or telemetry pipeline.
- Python in the shipped runtime. - Python in the shipped runtime.
- Treating flow records as equivalent to full Zeek protocol logs. - Treating flow records as equivalent to full Zeek protocol logs.
- Automatic desktop self-update. - Native desktop shell or binary self-mutation.
## 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.
+24 -36
View File
@@ -2,56 +2,44 @@
[English](ROADMAP.md) · **Português** [English](ROADMAP.md) · **Português**
Este roadmap separa o que existe do que está planejado. Os quatro pilares **não Este roadmap separa o que existe do que está planejado. Os quatro pilares estão
estão implementados** em `0.1.0`; eles são o próximo bloco de trabalho. **em desenvolvimento**; eles representam o trabalho analítico principal.
## Feito ## Feito
- Workspace Rust com `acaua-core` e `acaua-cli`. - Workspace Rust com `acaua-core` e `acaua-cli`.
- Esqueleto da CLI, esqueleto da API Axum e UI vanilla embutida. - Comandos CLI, API Axum e dashboard web embutido.
- Shell desktop Tauri com configurações de idioma e tema. - Containerização 100% Docker, implantação Compose, perfil endurecido não-root.
- Stubs dos quatro pilares, fusão de evidências, armazenamento, ingestão Zeek e - Linkagem da DuckDB pré-compilada e fundações de armazenamento Parquet.
updater baseado em Gitea. - 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`. - 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 ## Próximo
1. **Pilar 1 — beaconing.** Implementar o periodograma de Rayleigh/Schuster 1. **Pilar 1 — beaconing.** Implementar o periodograma de Rayleigh/Schuster sobre
sobre timestamps de eventos, adicionar Lomb–Scargle generalizado só quando timestamps de eventos, resistência a harmônicos e validação com fixtures determinísticas.
existir um `y` real e entregar fixtures douradas com valores esperados fixos.
2. **Pilar 2 — sessões persistentes.** Detectar conexões longas que nunca 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 produzem intervalos curtos, com limiares explícitos sem sobreposição ao pilar 1.
pilar 1. 3. **Parser Zeek real.** Ler `conn.log`, `dns.log`, `ssl.log` e logs de protocolo
3. **Parser Zeek real.** Ler `conn.log`, `dns.log`, `ssl.log` e logs de com memória delimitada, confinamento de caminhos e ordem determinística.
protocolo com memória limitada, confinamento de caminho e ordem determinística. 4. **Registro de evidência.** Emitir registro JSON por alerta contendo entradas,
4. **Registro de evidência.** Emitir um JSON por alerta com entradas, valores valores intermediários, versão do código e hash do dataset.
intermediários, versão do código e hash do dataset.
## Depois ## Depois
- Pilar 3 — anomalias determinísticas de protocolo, incluindo identidade e - Pilar 3 — anomalias determinísticas de protocolo, incluindo identidade e
movimento lateral. movimentação lateral.
- Pilar 4 — fusão limitada e simétrica, com pesos reproduzíveis. - Pilar 4 — fusão limitada e simétrica com pesos reproduzíveis.
- Armazenamento DuckDB/Parquet atrás da feature opcional `duckdb`, com TTL de - Otimizações no store analítico, particionamento diário Parquet e limpeza por TTL.
sete dias e comando de exportação. - Ingestão agendada e tratamento automático de rotação de logs em modo daemon.
- Visão de dataset no desktop, detalhe de alerta com evidência e execuções - NetFlow/IPFIX como modo degradado documentado e Suricata EVE JSON como entrada secundária.
agendadas do `serve`. - Metadados assinados de release, SBOM e builds reproduzíveis.
- NetFlow/IPFIX como modo degradado documentado e Suricata EVE JSON como entrada
secundária opcional.
- Metadados de release assinados, SBOM e builds reproduzíveis.
## Fora de plano ## Fora de plano
- Pontuação por IA opaca que não explica um alerta. - Pontuação por IA opaca que não consiga explicar um alerta.
- Serviço de nuvem obrigatório ou pipeline de telemetria. - Serviço em nuvem obrigatório ou pipeline de telemetria.
- Python no runtime distribuído. - Python no runtime distribuído.
- Tratar registros de fluxo como equivalentes a logs completos de protocolo Zeek. - Tratar registros de fluxo como equivalentes a logs Zeek completos.
- Auto-atualização automática do desktop. - Shell desktop nativo ou auto-modificação de binário.
## 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.
+8 -5
View File
@@ -15,6 +15,9 @@ reproduction when a private channel is available.
## Scope and threat model ## 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 - **Input parsing:** a malformed log line must not panic the daemon, exhaust
memory or escape the configured data directory. memory or escape the configured data directory.
- **Path handling:** dataset paths are resolved and confined to the configured - **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 - **Evidence integrity:** alert evidence must be reproducible from the same
input and code version. Silent nondeterminism is a correctness and security input and code version. Silent nondeterminism is a correctness and security
issue. issue.
- **Updates:** the CLI checks Gitea Releases, downloads over HTTPS and verifies - **Updates:** updates are delivered as verified multi-arch container images.
SHA-256 before replacing the binary. The desktop app does not update itself. The daemon terminates gracefully upon receiving `SIGTERM` without data corruption.
- **Privacy:** there is no telemetry. Retention defaults to seven days of - **Privacy:** there is no telemetry. Retention defaults to seven days of
normalized events; the operator is responsible for local law and policy. normalized events; the operator is responsible for local law and policy.
## Out of scope ## Out of scope
- Vulnerabilities in third-party Zeek, Suricata, Tauri or operating-system - Vulnerabilities in third-party Zeek, DuckDB or operating-system packages
packages that are already fixed upstream. that are already fixed upstream.
- Attacks that require the operator to disable the documented security controls - Attacks that require the operator to disable the documented security controls
(for example, running `serve` as root on a public interface). (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 ## Supported versions
+32 -29
View File
@@ -2,44 +2,47 @@
[English](SECURITY.md) · **Português** [English](SECURITY.md) · **Português**
O Acauã é software defensivo, mas processa entrada hostil: capturas de pacotes, O Acauã é software defensivo, mas processa entradas hostis: capturas de pacotes,
logs Zeek e registros de fluxo podem estar malformados de propósito. Trate todo logs Zeek e registros de fluxo podem ser intencionalmente malformados. Trate todo
dataset como não confiável. 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 Não abra uma issue pública com detalhes de exploração. Abra uma issue mínima
um canal privado, ou contate o mantenedor pelo perfil no Gitea indicado no solicitando canal privado ou contate o mantenedor pelo perfil do Gitea listado
repositório. Inclua a versão afetada, o formato da entrada e uma reprodução no repositório. Inclua a versão afetada, o formato da entrada e uma reprodução
mínima quando houver canal privado. 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, - **Endurecimento do container:** o container executa com usuário não privilegiado
esgotar memória ou escapar do diretório de dados configurado. (`acaua`, UID 10001), filesystem raiz somente leitura (`read_only: true`), todas
- **Caminhos:** caminhos de dataset são resolvidos e confinados à raiz as capabilities revogadas (`cap_drop: [ALL]`), e endurecimento de segurança (`no-new-privileges:true`).
configurada; links simbólicos não são seguidos para fora dela. - **Parsing de entradas:** uma linha de log malformada não deve causar panic no
- **Exposição da API:** o `serve` escuta em `127.0.0.1` por padrão. Expor em uma daemon, exaurir memória ou escapar do diretório de dados configurado.
interface de rede é decisão explícita do operador e exige proxy reverso com - **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. autenticação.
- **Integridade da evidência:** a evidência de um alerta precisa ser reproduzível - **Integridade da evidência:** a evidência de um alerta deve ser reproduzível a
com a mesma entrada e a mesma versão do código. Não determinismo silencioso é partir da mesma entrada e versão de código. Nãodeterminismo silencioso é falha de
problema de correção e de segurança. corretude e segurança.
- **Atualizações:** a CLI consulta Gitea Releases, baixa por HTTPS e verifica - **Atualizações:** atualizações são distribuídas como imagens multi-arch de
SHA-256 antes de substituir o binário. O app desktop não se atualiza sozinho. container verificadas. O daemon finaliza de forma limpa ao receber `SIGTERM`
- **Privacidade:** não há telemetria. A retenção padrão é de sete dias de eventos sem corrupção de dados.
normalizados; o operador responde pela lei e pela política local. - **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 ## Fora de escopo
- Vulnerabilidades em pacotes de terceiros (Zeek, Suricata, Tauri ou sistema - Vulnerabilidades em pacotes upstream do Zeek, DuckDB ou do sistema operacional
operacional) já corrigidas upstream. já corrigidas nas fontes.
- Ataques que exigem desligar controles documentados (por exemplo, rodar `serve` - Ataques que dependam de desativar os controles de segurança documentados
como root em interface pública). (como executar `serve` como root em interface pública).
- Alegações de qualidade de detecção: os pilares não estão implementados neste - Alegações de qualidade de detecção: os pilares são heurísticas defensivas, não garantias absolutas.
esqueleto.
## Versões suportadas ## Versões suportadas
Só a linha `0.1.x` mais recente é suportada enquanto o projeto é pré-alfa. Ainda Apenas a linha `0.1.x` mais recente tem suporte durante a fase pré-alfa. Não há
não existe branch de suporte de longo prazo. 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'; import { setLang, tr } from './i18n.js';
const $ = (selector) => document.querySelector(selector); const $ = (selector) => document.querySelector(selector);
const tauriInvoke = window.__TAURI__?.core?.invoke;
const connection = $('#connection'); const connection = $('#connection');
const connectionText = $('#connectionText'); const connectionText = $('#connectionText');
@@ -30,7 +29,6 @@ async function fetchJson(path, options = {}) {
} }
async function call(command, args = {}) { async function call(command, args = {}) {
if (tauriInvoke) return tauriInvoke(command, args);
switch (command) { switch (command) {
case 'app_info': case 'app_info':
return fetchJson('/api/status'); return fetchJson('/api/status');
+33 -26
View File
@@ -25,6 +25,8 @@ enum Command {
}, },
Analyze { Analyze {
directory: PathBuf, directory: PathBuf,
#[arg(long, default_value = ".acaua")]
data_dir: PathBuf,
#[arg(long)] #[arg(long)]
json: bool, json: bool,
}, },
@@ -32,10 +34,7 @@ enum Command {
#[arg(long)] #[arg(long)]
json: bool, json: bool,
}, },
Update { Update,
#[arg(long)]
apply: bool,
},
} }
#[tokio::main] #[tokio::main]
@@ -50,8 +49,12 @@ async fn main() {
async fn run(cli: Cli) -> acaua_core::Result<()> { async fn run(cli: Cli) -> acaua_core::Result<()> {
match cli.command { match cli.command {
Command::Serve { bind, data_dir } => web::serve(&bind, data_dir).await, Command::Serve { bind, data_dir } => web::serve(&bind, data_dir).await,
Command::Analyze { directory, json } => { Command::Analyze {
let report = zeek::analyze_dir(&directory)?; directory,
data_dir,
json,
} => {
let report = zeek::analyze_dir(&directory, &data_dir)?;
if json { if json {
println!("{}", serde_json::to_string_pretty(&report)?); println!("{}", serde_json::to_string_pretty(&report)?);
} else { } else {
@@ -59,6 +62,9 @@ async fn run(cli: Cli) -> acaua_core::Result<()> {
println!("logs lidos: {}", report.logs_seen); println!("logs lidos: {}", report.logs_seen);
println!("registros: {}", report.records); println!("registros: {}", report.records);
println!("alertas: {}", report.alerts.len()); println!("alertas: {}", report.alerts.len());
for alert in &report.alerts {
println!(" [{:.3}] {}", alert.score, alert.summary);
}
println!("estado: {}", report.status); println!("estado: {}", report.status);
} }
Ok(()) Ok(())
@@ -80,26 +86,27 @@ async fn run(cli: Cli) -> acaua_core::Result<()> {
} }
Ok(()) Ok(())
} }
Command::Update { apply } => { Command::Update => {
if apply { let status = update::check(env!("CARGO_PKG_VERSION"))?;
let executable = std::env::current_exe()?; println!(
let tag = update::apply(&executable)?; "versão atual: {} | última: {}",
println!("atualizado para {tag}"); status.current_version,
} else { status.latest_version.as_deref().unwrap_or("desconhecida")
let status = update::check(env!("CARGO_PKG_VERSION"))?; );
println!( println!(
"versão atual: {} | última: {}", "atualização disponível: {}",
status.current_version, if status.update_available {
status.latest_version.as_deref().unwrap_or("desconhecida") "sim"
); } else {
println!( "não"
"atualização disponível: {}", }
if status.update_available { );
"sim" if status.update_available {
} else { if std::env::var("ACAUA_RUNTIME").as_deref() == Ok("container") {
"não" 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(()) Ok(())
} }
+31 -20
View File
@@ -75,16 +75,19 @@ async fn alerts() -> Json<Vec<Alert>> {
} }
async fn analyze( async fn analyze(
State(state): State<AppState>,
Json(request): Json<AnalyzeRequest>, Json(request): Json<AnalyzeRequest>,
) -> std::result::Result<Json<AnalysisReport>, (StatusCode, Json<ErrorBody>)> { ) -> 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)
StatusCode::BAD_REQUEST, .map_err(|error| {
Json(ErrorBody { (
error: error.to_string(), StatusCode::BAD_REQUEST,
}), Json(ErrorBody {
) error: error.to_string(),
}) }),
)
})
} }
async fn update_check() -> std::result::Result<Json<UpdateStatus>, (StatusCode, Json<ErrorBody>)> { async fn update_check() -> std::result::Result<Json<UpdateStatus>, (StatusCode, 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 { async fn logo() -> impl IntoResponse {
([(header::CONTENT_TYPE, "image/png")], Body::from(LOGO)) ([(header::CONTENT_TYPE, "image/png")], Body::from(LOGO))
} }
@@ -129,21 +123,38 @@ async fn asset(uri: Uri) -> Response {
} }
async fn shutdown_signal() { async fn shutdown_signal() {
let _ = tokio::signal::ctrl_c().await; 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<()> { pub async fn serve(bind: &str, data_dir: PathBuf) -> Result<()> {
let _ = tracing_subscriber::fmt().try_init(); let _ = tracing_subscriber::fmt().try_init();
std::fs::create_dir_all(&data_dir)?; std::fs::create_dir_all(&data_dir)?;
let state = AppState { let state = AppState {
storage: Arc::new(Storage::open(&data_dir)), storage: Arc::new(Storage::open(&data_dir)?),
}; };
let app = Router::new() let app = Router::new()
.route("/api/health", get(health)) .route("/api/health", get(health))
.route("/api/status", get(status)) .route("/api/status", get(status))
.route("/api/alerts", get(alerts)) .route("/api/alerts", get(alerts))
.route("/api/analyze", post(analyze)) .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)) .route("/logo.png", get(logo))
.fallback(asset) .fallback(asset)
.with_state(state) .with_state(state)
+5 -5
View File
@@ -8,17 +8,17 @@ repository.workspace = true
authors.workspace = true authors.workspace = true
rust-version.workspace = true rust-version.workspace = true
[features]
default = []
duckdb = ["dep:duckdb"]
[dependencies] [dependencies]
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
sha2 = "0.10" sha2 = "0.10"
thiserror = "1" thiserror = "1"
time = { version = "0.3", features = ["formatting", "macros"] } 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] [dev-dependencies]
tempfile = "3" tempfile = "3"
+60 -21
View File
@@ -14,40 +14,79 @@ pub struct DoctorReport {
pub status: &'static str, pub status: &'static str,
} }
fn command_available(name: &str, arg: &str) -> Check { fn zeek_check() -> Check {
match Command::new(name).arg(arg).output() { if std::env::var("ACAUA_RUNTIME").as_deref() == Ok("container") {
Ok(output) if output.status.success() => Check { Check {
name: if name == "zeek" { "zeek" } else { "runtime" }, name: "zeek",
ok: true, 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: "zeek",
ok: false,
detail: "zeek returned an error".to_string(),
},
Err(error) => Check {
name: "zeek",
ok: false,
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 { Ok(_) => Check {
name: if name == "zeek" { "zeek" } else { "runtime" }, name: "curl",
ok: false, ok: false,
detail: format!("{name} returned an error"), detail: "curl returned an error".to_string(),
}, },
Err(error) => Check { Err(error) => Check {
name: if name == "zeek" { "zeek" } else { "runtime" }, name: "curl",
ok: false, ok: false,
detail: format!("{name} not found: {error}"), 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 { pub fn run() -> DoctorReport {
let checks = vec![ let checks = vec![
command_available("zeek", "--version"), zeek_check(),
command_available("curl", "--version"), curl_check(),
command_available("tar", "--version"), duckdb_check(),
Check {
name: "duckdb",
ok: cfg!(feature = "duckdb"),
detail: if cfg!(feature = "duckdb") {
"bundled DuckDB feature is enabled".to_string()
} else {
"bundled DuckDB feature is disabled in this build".to_string()
},
},
Check { Check {
name: "research", name: "research",
ok: true, ok: true,
+3
View File
@@ -16,6 +16,9 @@ pub enum AcauaError {
#[error("serialization error: {0}")] #[error("serialization error: {0}")]
Serde(#[from] serde_json::Error), Serde(#[from] serde_json::Error),
#[error("database error: {0}")]
Database(#[from] duckdb::Error),
} }
pub type Result<T> = std::result::Result<T, AcauaError>; pub type Result<T> = std::result::Result<T, AcauaError>;
+356 -5
View File
@@ -6,19 +6,370 @@ pub struct BeaconingInput {
pub timestamps: Vec<f64>, 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 struct BeaconingScore {
pub score: f64, pub score: f64,
pub observations: usize, 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 method: &'static str,
pub grid: GridInfo,
pub weights: WeightsInfo,
pub formula_version: &'static str,
pub status: &'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> { pub fn score(input: &BeaconingInput) -> Result<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: 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 { Ok(BeaconingScore {
score: 0.0, score,
observations: input.timestamps.len(), observations: n,
method: "rayleigh-schuster-stub", period_detected_seconds: detected_period,
status: "skeleton", 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 serde::Serialize;
use sha2::{Digest, Sha256};
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
@@ -18,32 +22,669 @@ impl DatasetSummary {
partitions: 0, partitions: 0,
ttl_days: 7, ttl_days: 7,
backend: "duckdb-parquet", 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)] #[derive(Debug, Clone)]
pub struct Storage { pub struct Storage {
root: PathBuf, root: PathBuf,
} }
#[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 { impl Storage {
pub fn open(root: impl AsRef<Path>) -> Self { pub fn open(root: impl AsRef<Path>) -> Result<Self> {
Self { let root = root.as_ref();
root: root.as_ref().to_path_buf(), 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 { pub fn root(&self) -> &Path {
&self.root &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> { 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> { 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 crate::error::{AcauaError, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::ffi::OsStr;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
const LATEST_RELEASE_URL: &str = const LATEST_RELEASE_URL: &str =
"https://git.lucasrguerra.dev.br/api/v1/repos/ciberfauna/acaua/releases/latest"; "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)] #[derive(Debug, Deserialize)]
struct Release { struct Release {
tag_name: String, tag_name: String,
assets: Vec<Asset>,
}
#[derive(Debug, Deserialize)]
struct Asset {
name: String,
browser_download_url: String,
} }
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
@@ -46,42 +34,6 @@ fn curl_text(url: &str) -> Result<String> {
Ok(String::from_utf8_lossy(&output.stdout).into_owned()) Ok(String::from_utf8_lossy(&output.stdout).into_owned())
} }
fn curl_download(url: &str, destination: &Path) -> Result<()> {
let status = Command::new("curl")
.args(["-fsSL", "-H", "User-Agent: acaua-updater", "-o"])
.arg(destination)
.arg(url)
.status()
.map_err(|error| AcauaError::CommandFailed {
cmd: format!("curl {url}"),
detail: error.to_string(),
})?;
if !status.success() {
return Err(AcauaError::CommandFailed {
cmd: format!("curl {url}"),
detail: "download failed".to_string(),
});
}
Ok(())
}
fn asset_suffix_for_platform() -> String {
let os = if cfg!(target_os = "linux") {
"linux"
} else if cfg!(target_os = "macos") {
"macos"
} else {
"windows"
};
let arch = if cfg!(target_arch = "x86_64") {
"x86_64"
} else {
"aarch64"
};
let extension = if cfg!(windows) { "zip" } else { "tar.gz" };
format!("-{os}-{arch}.{extension}")
}
pub fn check(current_version: &str) -> Result<UpdateStatus> { pub fn check(current_version: &str) -> Result<UpdateStatus> {
let body = match curl_text(LATEST_RELEASE_URL) { let body = match curl_text(LATEST_RELEASE_URL) {
Ok(body) => body, 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test] #[test]
fn apply_refuses_to_overwrite_the_desktop_app() { fn parses_latest_release_tag() {
let error = apply(Path::new("/opt/acaua/acaua-gui")).unwrap_err(); let json = r#"{"tag_name": "v0.2.0"}"#;
assert!(error.to_string().contains("CLI"), "{error}"); let release: Release = serde_json::from_str(json).unwrap();
assert!(error.to_string().contains(RELEASES_PAGE_URL), "{error}"); assert_eq!(release.tag_name, "v0.2.0");
}
#[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());
} }
} }
+78 -5
View File
@@ -1,7 +1,11 @@
use crate::error::{AcauaError, Result}; use crate::error::{AcauaError, Result};
use crate::fusion::Alert; use crate::fusion::Alert;
use crate::pillar1::{self, BeaconingInput};
use crate::storage::Storage;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::path::Path; use std::path::Path;
use time::OffsetDateTime;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ZeekLogKind { pub enum ZeekLogKind {
@@ -26,7 +30,10 @@ pub struct AnalysisReport {
pub status: &'static str, 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(); let directory = directory.as_ref();
if !directory.is_dir() { if !directory.is_dir() {
return Err(AcauaError::InvalidInput(format!( return Err(AcauaError::InvalidInput(format!(
@@ -34,12 +41,78 @@ pub fn analyze_dir(directory: impl AsRef<Path>) -> Result<AnalysisReport> {
directory.display() 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 { Ok(AnalysisReport {
directory: directory.display().to_string(), directory: directory.display().to_string(),
logs_seen: 0, logs_seen: stats.files_seen,
records: 0, records: stats.records_ingested,
alerts: Vec::new(), alerts,
status: "skeleton", 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(); let score = pillar1::score(&input).unwrap();
assert_eq!(score.observations, 3); assert_eq!(score.observations, 3);
assert_eq!(score.status, "skeleton"); assert_eq!(score.status, "insufficient_data");
} }
#[test] #[test]
@@ -44,7 +44,7 @@ fn fusion_returns_no_alerts_yet() {
#[test] #[test]
fn storage_reports_the_reserved_backend() { 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(); let summary = store.summary().unwrap();
assert_eq!(summary.ttl_days, 7); assert_eq!(summary.ttl_days, 7);
assert_eq!(summary.records, 0); assert_eq!(summary.records, 0);
@@ -52,7 +52,8 @@ fn storage_reports_the_reserved_backend() {
#[test] #[test]
fn zeek_rejects_a_missing_directory() { 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(_))); 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: services:
zeek: zeek:
image: zeek/zeek:latest image: ${ZEEK_IMAGE:-zeek/zeek:9.0.1}
network_mode: host network_mode: host
cap_add: cap_drop: [ALL]
- NET_RAW cap_add: [NET_RAW, NET_ADMIN]
- NET_ADMIN working_dir: /logs
environment: command:
ZEEK_INTERFACE: ${ZEEK_INTERFACE:-eth0} - zeek
command: ["zeek", "-i", "${ZEEK_INTERFACE:-eth0}", "Log::default_logdir=/logs"] - -i
- ${ZEEK_INTERFACE:-eth0}
- Log::default_logdir=/logs
- Log::default_rotation_interval=1hr
- Log::default_rotation_dir=/logs/archive
volumes: volumes:
- ./data/zeek:/logs - zeek-logs:/logs
restart: unless-stopped restart: unless-stopped
acaua: acaua:
image: ${ACAUA_IMAGE:-git.lucasrguerra.dev.br/ciberfauna/acaua}:${ACAUA_VERSION:-latest}
build: . build: .
command: ["serve", "--bind", "0.0.0.0:8787", "--data-dir", "/data/acaua"]
ports: ports:
- "127.0.0.1:8787:8787" - "${ACAUA_PUBLISH:-127.0.0.1:8787}:8787"
volumes: volumes:
- ./data/zeek:/data/zeek:ro - zeek-logs:/data/zeek:ro
- ./data/acaua:/data/acaua - acaua-data:/data/acaua
read_only: true
tmpfs:
- /tmp
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
restart: unless-stopped restart: unless-stopped
volumes:
zeek-logs:
acaua-data:
+75 -90
View File
@@ -2,8 +2,8 @@
**English** · [Português](pt-BR/arquitetura.md) **English** · [Português](pt-BR/arquitetura.md)
Acauã is a single Rust product: one daemon/CLI, one shared core, one embedded Acauã is a single Rust product: one daemon/CLI, one shared core, and one
web UI and one Tauri shell. The shipped runtime has no Python and no external embedded web dashboard. The shipped runtime has no Python and no external
database. This document records the structural decisions and the six database. This document records the structural decisions and the six
corrections applied to the original blueprint. corrections applied to the original blueprint.
@@ -11,19 +11,14 @@ corrections applied to the original blueprint.
| Layer | Choice | Why | | 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 | | 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 | | Web Dashboard | Vanilla HTML/CSS/JS | No frontend build step, assets embedded in the binary via `rust-embed` |
| Desktop | Tauri 2 | Native window over the same UI | | Analytical store | DuckDB + Parquet | Mandatory embedded columnar store, linked to official prebuilt `libduckdb.so` |
| Analytical store | DuckDB + Parquet (optional `duckdb` feature) | Embedded, columnar, local-first | | 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 | | 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 The workspace has two crates: `acaua-core` and `acaua-cli`. Both use `version.workspace = true`.
`apps/gui`, **outside** the Cargo workspace, as in Guará and Sabiá. The spec
mentioned `version.workspace = true` for the desktop manifest; keeping the GUI
outside the workspace makes that impossible, so `check.sh` enforces SemVer
equality across `Cargo.toml`, `acaua-cli/Cargo.toml`, the desktop manifest and
`tauri.conf.json` instead. This is a deliberate deviation, not an oversight.
## Components ## Components
@@ -35,20 +30,20 @@ Zeek logs ──► acaua-core::zeek ──► normalized events ──► stora
├──► pillar3 (protocol anomalies) ├──► pillar3 (protocol anomalies)
└──► pillar4 (fusion) ──► alerts + evidence └──► 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 - `zeek`: bounded parser and path confinement. It must never follow a symlink
outside the dataset root. outside the dataset root.
- `storage`: DuckDB/Parquet writer with a seven-day TTL. Disabled by default so - `storage`: DuckDB in-memory engine over daily partitioned Parquet files with a
the skeleton does not require a C++ build. seven-day TTL.
- `pillar1`–`pillar4`: pure functions over normalized events. They return - `pillar1`–`pillar4`: pure functions over normalized events. They return
scores plus evidence, never only a float. scores plus evidence, never only a float.
- `fusion`: fixed-order, bounded combination. It must be symmetric and - `fusion`: fixed-order, bounded combination. It must be symmetric and
reproducible. reproducible.
- `doctor`: local environment checks; no network calls except an explicit - `doctor`: local environment checks; reports prebuilt DuckDB linkage and
update check. containerized Zeek status.
- `update`: Gitea Releases client for the CLI only. - `update`: checks for updates against the Gitea Releases API.
## The six corrections ## 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: fit. The primary periodogram is therefore Rayleigh/Schuster:
```text ```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). Generalized Lomb–Scargle is reserved for the future extension that evaluates a
This avoids inventing a dependent variable and keeps the null distribution real response attribute (such as payload size or duration) against time.
honest.
### 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; A synthetic or unjittered beacon produces equal intervals. The median absolute
the implementation returns a documented sentinel (`0.0` with an deviation from the median (MADM) is then `0.0`. Dividing by `0.0` or treating it
`degenerate_quartiles` evidence flag) instead of `NaN`, infinity or a panic. The as a missing value is wrong: zero dispersion is the most suspicious outcome. The
sentinel is part of the alert record, not a silent fallback. 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 Sample skewness based on the third central moment is sensitive to single
distribution does not. Fusion uses Jensen–Shannon, which is symmetric, bounded outliers and can diverge. Bowley skewness uses quartiles:
and defined for zero-probability bins with a small documented smoothing floor.
This makes the score comparable across pillars and safe for sparse protocol
histograms.
### 4. Persistent sessions get their own detector ```text
B = (Q3 + Q1 - 2*Q2) / (Q3 - Q1)
```
A connection that stays open for hours never produces a short inter-arrival It is bounded in `[-1.0, 1.0]` by definition and resistant to extreme outliers.
time, so a beaconing detector can miss it entirely. Pillar 2 scores duration, When `Q3 == Q1` (concentrated data), the implementation assigns `0.0` with a
idle gaps and repeated long-lived flows directly. The two pillars may both fire; sentinel flag.
fusion treats that as separate evidence, not double counting of the same signal.
### 5. JA4 license caution ### 4. Isolate the Python research workspace
The client TLS JA4 fingerprint is usable. JA4S, JA4H, JA4X, JA4SSH and JA4L `research/` has its own virtual environment and dependencies. It is excluded
carry a non-OSI license and are **not** enabled by default. Any future use needs from the Cargo workspace, ignored by Docker builds and CI releases, and tested
an explicit licensing decision and a documented opt-in. The default feature set only when research validation scripts are invoked directly.
must remain redistributable under MIT/Apache-2.0.
### 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, NetFlow v9 and IPFIX do not carry DNS query names, TLS SNI, certificate chains
intermediate values, sentinels, weights and final score. Reductions run in a or HTTP headers. Any analysis that depends on protocol features is unavailable.
fixed order; no hash-map iteration or thread scheduling may change a score. Acauã documents this as a degraded mode and never claims parity between flow
Given the same input and version, the evidence must be byte-identical. records and full Zeek logs.
## Data flow ### 6. Single HTTP and RPC boundary
1. `acaua analyze <dir>` validates the directory, reads supported logs and The daemon exposes an Axum API on loopback by default. The embedded web UI
normalizes events. In this skeleton it validates and returns a stub report. communicates with the daemon exclusively via standard HTTP calls.
2. Normalized events are written to the local store (when the `duckdb` feature
is enabled) with a seven-day TTL.
3. Each pillar runs over a deterministic window and returns candidate scores
with evidence.
4. Fusion combines candidates, applies thresholds and writes alerts.
5. `serve` exposes the API and the embedded UI; the Tauri shell can open the same
UI against a local or remote daemon.
## API ## API
| Method | Path | Purpose | The HTTP API is served by `acaua serve`:
| Method | Path | Meaning |
|---|---|---| |---|---|---|
| GET | `/api/health` | Liveness and version | | 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 | | GET | `/api/alerts` | Paginated alerts with evidence |
| POST | `/api/analyze` | Analyze a dataset path | | POST | `/api/analyze` | Ingest and analyze a dataset directory |
| POST | `/api/update` | Check for a CLI update | | GET | `/api/update` | Check for newer releases |
| GET | `/logo.png` | Embedded brand asset | | 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 `serve` binds to `127.0.0.1:8787` by default. Exposing it on an external network
the skeleton; exposing it on a network interface requires an authenticating requires an authenticating reverse proxy.
reverse proxy.
## Storage ## Storage
DuckDB is an **optional** dependency (`features = ["duckdb"]`, `bundled`). The DuckDB is a mandatory dependency linked dynamically to the official prebuilt
default skeleton does not compile the C++ backend. When enabled, the schema is `libduckdb.so` (version 1.5.6 pinned with SHA-256 in `scripts/fetch-libduckdb.sh`).
append-only normalized events plus an alerts table, with Parquet export and a Parquet, JSON and ICU extensions are statically built into the official library.
seven-day retention job. The database file lives under the configured data The schema consists of daily partitioned Parquet files under the configured
directory and never outside it. data directory, with in-memory DuckDB querying and a seven-day TTL.
## Updates ## Updates
`update.rs` queries the Gitea Releases API for the `acaua` repository, selects Updates for Acauã are delivered via container images (`docker compose pull && docker compose up -d`).
the asset for the current platform, downloads over HTTPS with `curl`, verifies The `acaua update` command queries the Gitea Releases API for `ciberfauna/acaua` and
SHA-256 and replaces the CLI binary. It refuses to update a running GUI notifies operators when a newer version tag is available. Binary self-overwrite
automatically; the desktop only reports that a new version exists. This mirrors (`update --apply`) is removed: in a collective SOC daemon running under container
the Sabiá updater and avoids a second update mechanism. orchestration, updates must be controlled by image pulls rather than self-mutating
processes.
The Tauri updater plugin, its `latest.json` manifest, `updater.pubkey` and
`createUpdaterArtifacts` are intentionally not used: they would add a second
signing and distribution path, and the desktop must not self-update. The single
CLI path is easier to audit and keeps release assets to tarballs plus SHA-256.
## Rejected alternatives ## Rejected alternatives
An earlier blueprint proposed a Python service with FastAPI and ClickHouse. 1. **Python service with FastAPI/ClickHouse:** Rejected. A Python runtime introduces
Both are rejected. A Python service would add a second runtime, packaging burden heavy container footprints and unnecessary memory boundaries; ClickHouse adds an
and a network boundary inside a local-first tool; ClickHouse would add an external server operational burden for datasets that fit an embedded columnar store.
external server and operational cost for datasets that fit an embedded columnar The product is 100% Rust.
store. The product is 100% Rust, with Python confined to `research/`. 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 ## Deployment
- **Native:** `cargo build --release -p acaua-cli`, then `acaua serve`. - **Docker Compose (Official):** Multi-arch container image (`git.lucasrguerra.dev.br/ciberfauna/acaua`),
- **Desktop:** Tauri bundles for Linux and Windows, built in CI. running as unprivileged user `acaua` (UID 10001), read-only root filesystem, dropped capabilities.
- **Docker:** a slim runtime image for lab use; see [docker.md](docker.md). - **Native compilation:** `scripts/fetch-libduckdb.sh ~/.cache/acaua/duckdb` followed by `cargo build --release -p acaua-cli`.
## Failure behavior ## Failure behavior
Malformed input is skipped with a counted, bounded diagnostic. A missing log is 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 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 ## Versioning and compatibility
+21 -46
View File
@@ -2,23 +2,22 @@
**English** · [Português](pt-BR/cli.md) **English** · [Português](pt-BR/cli.md)
The `acaua` binary is both the CLI and the daemon. This page describes the The `acaua` binary serves both as the CLI command runner and the background daemon.
commands that exist in the `0.1.0` skeleton. Detection is not implemented yet; This page describes the commands available in the engine.
`analyze` validates input and returns a stub report.
```text ```text
acaua <command> [options] acaua <command> [options]
Commands: Commands:
serve Start the API and embedded UI serve Start the API and embedded web dashboard
analyze Analyze a Zeek log directory analyze Analyze a Zeek log directory
doctor Check the local environment doctor Check the local environment
update Check or apply a CLI update update Check for available releases
``` ```
## `acaua serve` ## `acaua serve`
Starts the HTTP API and the embedded UI. Starts the HTTP API and the embedded web dashboard.
```bash ```bash
acaua serve --bind 127.0.0.1:8787 --data-dir .acaua 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 acaua analyze /var/log/zeek/current --json
``` ```
The directory is validated and a report is printed. In this skeleton the parser Validates the target directory, ingests `conn.log` traffic into Parquet partitions,
does not read Zeek records yet: `logs seen` and `records` are placeholders, and and runs threat detection algorithms.
`alerts` is empty. The command is still useful to check path handling and the
JSON shape.
The future parser will support `conn.log`, `dns.log`, `ssl.log` and the protocol
logs needed for identity and lateral-movement features. NetFlow/IPFIX is a
degraded mode; it is not equivalent to full protocol logs.
## `acaua doctor` ## `acaua doctor`
@@ -56,52 +49,34 @@ acaua doctor
acaua doctor --json acaua doctor --json
``` ```
Prints checks for Zeek, `curl`, `tar`, the optional DuckDB feature and the Runs environment checks:
isolation of `research/`, followed by the status (`skeleton` in `0.1.0`). A - `zeek`: verifies `zeek --version` or detects containerized execution (`ACAUA_RUNTIME=container`).
missing optional dependency is reported as `falha` but does not abort the - `curl`: confirms HTTP download availability.
command. It does not send telemetry. - `duckdb`: confirms dynamic linkage against `libduckdb.so` in runtime.
- `research`: verifies that the research workspace remains isolated.
## `acaua update` ## `acaua update`
```bash ```bash
acaua update acaua update
acaua update --apply
``` ```
`update` queries the latest Gitea Release, compares the tag with the running Queries the Gitea Releases API, compares the latest tag against the running
version and prints whether an update exists. `--apply` downloads the archive for version, and prints upgrade instructions (`docker compose pull && docker compose up -d`
the current platform, verifies its SHA-256, extracts it and replaces the when running inside a container).
`acaua` binary.
Requirements: `curl` and `tar` on `PATH`, and a writable binary location. The
updater refuses to replace any executable whose name is not `acaua`; the desktop
app must be updated through its installer. A failed checksum leaves the current
binary untouched.
## Exit codes ## Exit codes
| Code | Meaning | | Code | Meaning |
|---|---| |---|---|
| `0` | Success | | `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) | | `2` | Invalid command-line usage (clap) |
## Environment ## Environment
The skeleton has no required environment variable. `RUST_LOG` is reserved for | Variable | Meaning |
the future tracing layer. The default data directory is relative to the current |---|---|
working directory; use an absolute path for a system service. | `ACAUA_RUNTIME` | Set to `container` inside Docker images to adapt service checks |
| `DUCKDB_LIB_DIR` | Path to directory containing `libduckdb.so` when compiling natively |
## Examples | `LD_LIBRARY_PATH` | Shared library load path for `libduckdb.so` outside standard paths |
```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.
+57 -45
View File
@@ -2,66 +2,78 @@
**English** · [Português](pt-BR/docker.md) **English** · [Português](pt-BR/docker.md)
The image is for lab and research use. It runs the same `acaua` binary as a Acauã is distributed as an official container image:
native install, with the embedded UI. The skeleton does not enable the DuckDB `git.lucasrguerra.dev.br/ciberfauna/acaua`.
feature in the image; the store remains reserved for a later release.
## 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 ```bash
docker build -t acaua . cp .env.example .env # configure ZEEK_INTERFACE (authorized networks only)
docker run --rm -p 127.0.0.1:8787:8787 -v "$PWD/data/acaua:/data/acaua" acaua docker compose pull && docker compose up -d
``` ```
The build stage uses `rust:1-slim-bookworm`; the runtime stage uses To build and run locally from source:
`debian:bookworm-slim` and installs only `ca-certificates`, `curl` and `tar`
(the updater needs `curl` and `tar`).
## Compose
```bash ```bash
cp .env.example .env # optional: set ZEEK_INTERFACE docker compose up -d --build
docker compose up --build
``` ```
The Compose file starts two services: The stack coordinates two services:
| Service | Purpose | Notes | | Service | Image | Purpose | Security profile |
|---|---|---| |---|---|---|---|
| `zeek` | Capture and write Zeek logs to `./data/zeek` | Host network and `NET_RAW`/`NET_ADMIN` | | `zeek` | `zeek/zeek:9.0.1` | Network packet capture and hourly log rotation | Host network, `NET_RAW` and `NET_ADMIN` capabilities |
| `acaua` | Read logs and serve the UI/API | Host port bound to `127.0.0.1:8787` | | `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` ## Volumes and data layout
read-write at `/data/acaua`. The daemon inside the container binds
`0.0.0.0:8787`, but the published port is loopback-only on the host.
## Data layout The stack uses named volumes to manage ownership and permissions cleanly:
```text - `zeek-logs`: Shared volume mounted at `/logs` in the Zeek service and mounted
data/ **read-only** at `/data/zeek:ro` in the Acauã service.
zeek/ # Zeek logs, written by the zeek service - `acaua-data`: Dedicated read-write volume mounted at `/data/acaua` for DuckDB
acaua/ # store, exports and runtime files 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. Or run a one-off inspection container:
The parser is expected to confine paths to the dataset root; the skeleton does
not parse records yet. ```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 ## Security notes
- The `zeek` service needs packet capture privileges. Run it only on an - The `zeek` service requires packet capture privileges (`NET_RAW`, `NET_ADMIN`).
interface and network you are authorized to monitor. Run it only on interfaces and networks you are explicitly authorized to monitor.
- Do not change the host port mapping to `0.0.0.0:8787` without an - The default dashboard binding is loopback-only (`ACAUA_PUBLISH=127.0.0.1:8787`).
authenticating reverse proxy. Do not expose the service on external interfaces without an authenticating reverse proxy.
- The image has no telemetry and no outbound connection except an explicit - The image contains no telemetry. Outbound traffic is restricted to operator-initiated
`acaua update`. update checks against the Gitea Releases API.
- 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.
+20 -47
View File
@@ -2,66 +2,39 @@
**English** · [Português](pt-BR/interface.md) **English** · [Português](pt-BR/interface.md)
The interface is a vanilla HTML/CSS/JS app embedded in the `acaua` binary and The interface is a vanilla HTML/CSS/JS dashboard embedded directly into the
loaded by the Tauri shell. There is no frontend build step and no React/Next.js `acaua` binary via `rust-embed` and served by `acaua serve`. There is no
runtime. This page describes the `0.1.0` shell; it is not a screenshot tour, frontend build step and no heavy framework runtime.
because the detection views have nothing real to show yet.
## Layout ## Layout
- **Sidebar:** brand, navigation, connection state, version and update check. - **Sidebar:** brand logo, navigation, daemon connection indicator, version and update check.
- **Overview:** scope notice, authorized-use notice and the four pillar cards. - **Overview:** monitoring scope notice, authorized-use warning and four detection pillar cards.
- **Analysis:** dataset path field, analyze button, refresh and raw JSON output. - **Analysis:** dataset path entry, analyze button, refresh and raw JSON output inspection.
- **Alerts:** empty state explaining that the detection engine will arrive in a - **Alerts:** list of alerts with deterministic evidence metadata.
later spec. - **Settings:** language and theme preferences, stored in browser local storage.
- **Settings:** language and theme, stored locally.
## Language ## Language
The source text is Portuguese. `i18n.js` applies English strings from The source text is Portuguese. `i18n.js` applies English translations from
`i18n-en.js` at runtime and persists the choice in `localStorage`. `Auto` `i18n-en.js` at runtime and persists the selection in `localStorage`. `Auto`
follows the browser/system language. The check `node scripts/check-ui-i18n.mjs` detects the browser language. The automated test `node scripts/check-ui-i18n.mjs`
fails when a visible Portuguese string has no English pair. guarantees that every visible Portuguese string has an exact English pair.
## Theme ## Theme
`Auto`, light and dark are supported. `Auto` follows `prefers-color-scheme`. The Light, dark and `Auto` modes are supported. `Auto` reacts to system
choice is stored locally; no preference leaves the machine. `prefers-color-scheme`. Preferences stay local; no telemetry is sent.
## Connection state ## 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. - **Connecting:** initial connection in progress.
- **Local service online:** `/api/health` answered. - **Local service online:** daemon is responding.
- **Service unavailable:** the request failed; the shell remains usable for settings. - **Service unavailable:** connection to daemon failed; interface remains accessible for offline settings.
The Tauri shell can point at a local daemon or a remote `serve` in a future
version. In `0.1.0` it uses the local API.
## Analysis view ## Analysis view
`Analyze` posts the path to `/api/analyze` and prints the JSON response. The Posting a directory path to `/api/analyze` triggers the Zeek ingestion and
skeleton validates the path and returns a stub report. It does not parse Zeek analytical detection pipeline.
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.
+100 -115
View File
@@ -2,176 +2,161 @@
[English](../architecture.md) · **Português** [English](../architecture.md) · **Português**
O Acauã é um único produto Rust: um daemon/CLI, um núcleo compartilhado, uma UI O Acauã é um produto único em Rust: um daemon/CLI, um núcleo compartilhado e um
web embutida e um shell Tauri. O runtime distribuído não tem Python nem banco dashboard web embutido. O runtime distribuído não tem Python nem banco externo.
externo. Este documento registra as decisões estruturais e as seis correções Este documento registra as decisões estruturais e as seis correções aplicadas ao
aplicadas ao blueprint original. blueprint original.
## Stack ## 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 | | 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 async pequena e fácil de embutir | | API HTTP | Axum + Tokio + Tower HTTP | Superfície assíncrona enxuta, fácil de embutir |
| UI | HTML/CSS/JS vanilla | Sem etapa de build de frontend, assets embutidos no binário | | Dashboard Web | HTML/CSS/JS vanilla | Sem etapa de build de frontend, assets embutidos no binário via `rust-embed` |
| Desktop | Tauri 2 | Janela nativa sobre a mesma UI | | Armazenamento analítico | DuckDB + Parquet | Armazenamento colunar embutido obrigatório, linkado à `libduckdb.so` oficial pré-compilada |
| Armazenamento analítico | DuckDB + Parquet (feature opcional `duckdb`) | Embutido, colunar, local-first | | 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 só em `research/` | astropy/scipy/networkx para fixtures, nunca distribuído | | 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 O workspace possui dois crates: `acaua-core` e `acaua-cli`. Ambos utilizam `version.workspace = true`.
`apps/gui`, **fora** do workspace Cargo, como no Guará e no Sabiá. A spec citou
`version.workspace = true` para o manifesto do desktop; manter a GUI fora do
workspace torna isso impossível, então o `check.sh` impõe igualdade de SemVer
entre `Cargo.toml`, `acaua-cli/Cargo.toml`, o manifesto do desktop e
`tauri.conf.json`. É um desvio deliberado, não um esquecimento.
## Componentes ## Componentes
``` ```
Logs Zeek ──► acaua-core::zeek ──► eventos normalizados ──► armazenamento Logs Zeek ──► acaua-core::zeek ──► eventos normalizados ──► storage
│ │
├──► pilar1 (beaconing) ├──► pillar1 (beaconing)
├──► pilar2 (sessões persistentes) ├──► pillar2 (sessões persistentes)
├──► pilar3 (anomalias de protocolo) ├──► pillar3 (anomalias de protocolo)
└──► pilar4 (fusão) ──► alertas + evidência └──► 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 - `zeek`: parser delimitado e confinamento de caminhos. Nunca segue symlink fora
para fora da raiz do dataset. da raiz do dataset.
- `storage`: escritor DuckDB/Parquet com TTL de sete dias. Desligado por padrão - `storage`: motor DuckDB em memória sobre arquivos Parquet particionados por
para o esqueleto não exigir build C++. dia com TTL de sete dias.
- `pillar1`–`pillar4`: funções puras sobre eventos normalizados. Devolvem - `pillar1`–`pillar4`: funções puras sobre eventos normalizados. Retornam scores
pontuação e evidência, nunca só um float. e evidência, nunca apenas um float.
- `fusion`: combinação limitada e em ordem fixa. Precisa ser simétrica e - `fusion`: combinação limitada em ordem fixa. Deve ser simétrica e reproduzível.
reproduzível. - `doctor`: checagens do ambiente local; reporta linkagem da DuckDB pré-compilada
- `doctor`: checagens do ambiente local; sem chamadas de rede exceto a checagem e status do Zeek em container.
explícita de atualização. - `update`: checa novas versões consultando a API de Releases do Gitea.
- `update`: cliente de Gitea Releases só para a CLI.
## As seis correções ## As seis correções
### 1. Rayleigh/Schuster, não Lomb–Scargle puro ### 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: para ajustar. O periodograma primário é, portanto, Rayleigh/Schuster:
```text ```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, Lomb–Scargle generalizado fica reservado para a extensão futura que avaliará um
bytes por bucket). Isso evita inventar uma variável dependente e mantém a atributo real de resposta (como tamanho de payload ou duração) contra o tempo.
distribuição nula honesta.
### 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 Um beacon sintético ou sem jitter produz intervalos perfeitamente iguais. O
implementação devolve um sentinela documentado (`0.0` com a flag de evidência desvio absoluto mediano em relação à mediana (MADM) é então `0.0`. Dividir por
`degenerate_quartiles`) em vez de `NaN`, infinito ou panic. O sentinela faz parte `0.0` ou tratá-lo como dado ausente é incorreto: dispersão zero é o resultado mais
do registro do alerta, não é fallback silencioso. 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 A assimetria amostral baseada no terceiro momento central é sensível a outliers
tem. A fusão usa Jensen–Shannon, que é simétrica, limitada e definida para bins individuais e pode divergir. A assimetria de Bowley utiliza quartis:
com probabilidade zero com um piso de suavização documentado. Isso torna a
pontuação comparável entre pilares e segura para histogramas esparsos.
### 4. Sessões persistentes têm detector próprio ```text
B = (Q3 + Q1 - 2*Q2) / (Q3 - Q1)
```
Uma conexão aberta por horas nunca produz um intervalo curto, então um detector Ela é limitada em `[-1.0, 1.0]` por definição e resistente a outliers extremos.
de beaconing pode ignorá-la. O pilar 2 pontua duração, lacunas ociosas e fluxos Quando `Q3 == Q1` (dados concentrados), a implementação atribui `0.0` com flag
longos repetidos diretamente. Os dois pilares podem disparar; a fusão trata isso sentinela.
como evidências separadas, não como contagem dupla do mesmo sinal.
### 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 `research/` tem seu próprio ambiente virtual e dependências. Ele é excluído do
e JA4L têm licença não-OSI e **não** são habilitadas por padrão. Qualquer uso workspace Cargo, ignorado em builds Docker e releases de CI, e testado apenas
futuro exige decisão explícita de licenciamento e opt-in documentado. O conjunto quando scripts de validação de pesquisa forem invocados diretamente.
padrão precisa continuar redistribuível sob MIT/Apache-2.0.
### 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, NetFlow v9 e IPFIX não carregam nomes de consulta DNS, SNI TLS, cadeias de
entradas, valores intermediários, sentinelas, pesos e pontuação final. As certificados ou cabeçalhos HTTP. Qualquer análise que dependa de recursos de
reduções rodam em ordem fixa; iteração de hash map ou agendamento de threads não protocolo fica indisponível. O Acauã documenta isso como modo degradado e nunca
pode mudar uma pontuação. Com a mesma entrada e versão, a evidência precisa ser alega paridade entre registros de fluxo e logs Zeek completos.
byte a byte idêntica.
## Fluxo de dados ### 6. Fronteira única HTTP e RPC
1. `acaua analyze <dir>` valida o diretório, lê logs suportados e normaliza O daemon expõe uma API Axum em loopback por padrão. A UI web embutida se
eventos. Neste esqueleto ele valida e devolve um relatório stub. comunica com o daemon exclusivamente por chamadas HTTP padrão.
2. Eventos normalizados vão para o armazenamento local (quando a feature
`duckdb` está ligada) com TTL de sete dias.
3. Cada pilar roda sobre uma janela determinística e devolve candidatos com
evidência.
4. A fusão combina candidatos, aplica limiares e grava alertas.
5. `serve` expõe a API e a UI embutida; o shell Tauri abre a mesma UI contra um
daemon local ou remoto.
## API ## 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/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 | | GET | `/api/alerts` | Alertas paginados com evidência |
| POST | `/api/analyze` | Analisar um caminho de dataset | | POST | `/api/analyze` | Ingestão e análise de diretório de dataset |
| POST | `/api/update` | Checar atualização da CLI | | GET | `/api/update` | Checagem de novas releases |
| GET | `/logo.png` | Asset de marca embutido | | 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 `serve` escuta em `127.0.0.1:8787` por padrão. Expô-lo em uma rede externa exige
no esqueleto; expor em interface de rede exige proxy reverso com autenticação. um proxy reverso com autenticação.
## Armazenamento ## Armazenamento
DuckDB é dependência **opcional** (`features = ["duckdb"]`, `bundled`). O DuckDB é uma dependência obrigatória linkada dinamicamente à `libduckdb.so`
esqueleto padrão não compila o backend C++. Quando ligado, o schema é de eventos oficial pré-compilada (versão 1.5.6 fixada com SHA-256 em `scripts/fetch-libduckdb.sh`).
normalizados append-only mais uma tabela de alertas, com exportação Parquet e Extensões Parquet, JSON e ICU já vêm compiladas estaticamente na biblioteca oficial.
retenção de sete dias. O arquivo do banco fica sob o diretório de dados O esquema consiste em arquivos Parquet particionados por dia sob o diretório de
configurado e nunca fora dele. dados configurado, com consultas DuckDB em memória e TTL de sete dias.
## Atualizações ## Atualizações
`update.rs` consulta a API de Gitea Releases do repositório `acaua`, escolhe o Atualizações do Acauã são distribuídas por imagens de container (`docker compose pull && docker compose up -d`).
asset da plataforma atual, baixa por HTTPS com `curl`, verifica SHA-256 e O comando `acaua update` consulta a API de Releases do Gitea para `ciberfauna/acaua` e
substitui o binário da CLI. Ele se recusa a atualizar automaticamente uma GUI em notifica os operadores quando uma tag de versão mais nova estiver disponível. A
execução; o desktop só informa que existe versão nova. Isso espelha o updater do sobrescrita binária em disco (`update --apply`) foi removida: em um daemon de SOC
Sabiá e evita um segundo mecanismo de atualização. coletivo orquestrado via container, atualizações devem ser controladas por puxar
imagens novas em vez de processos que alteram o próprio executável.
O plugin updater do Tauri e seu fluxo de manifesto e assinatura não são usados
de propósito: adicionariam um segundo caminho de assinatura e distribuição, e o
desktop não deve se auto-atualizar. O caminho único da CLI é mais fácil de
auditar e mantém os assets de release em tarballs mais SHA-256.
## Alternativas rejeitadas ## Alternativas rejeitadas
Um blueprint anterior propunha um serviço Python e um banco colunar externo. Os 1. **Serviço Python com FastAPI/ClickHouse:** Rejeitado. Um runtime Python
dois são rejeitados. Um serviço Python adicionaria um segundo runtime, custo de introduziria imagens pesadas e fronteiras de memória desnecessárias; o
empacotamento e uma fronteira de rede dentro de uma ferramenta local-first; um ClickHouse adicionaria um servidor externo e custo operacional para datasets
banco externo adicionaria servidor e custo operacional para datasets que cabem em que cabem perfeitamente em um armazenamento colunar embutido. O produto é 100% Rust.
um armazenamento colunar embutido. O produto é 100% Rust, com Python confinado a 2. **Aplicativo desktop (Tauri / AppImage / NSIS):** Rejeitado. Diferente do Guará
`research/`. (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 ## Implantação
- **Nativa:** `cargo build --release -p acaua-cli`, depois `acaua serve`. - **Docker Compose (Oficial):** Imagem multi-arch (`git.lucasrguerra.dev.br/ciberfauna/acaua`),
- **Desktop:** bundles Tauri para Linux e Windows, construídos no CI. rodando com usuário não-privilegiado `acaua` (UID 10001), filesystem somente leitura e capabilities revogadas.
- **Docker:** imagem enxuta de runtime para laboratório; veja [docker.md](docker.md). - **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 Entradas malformadas são ignoradas com diagnóstico delimitado e contado. Um log
é fatal. Store corrompido é reportado pelo `doctor` e nunca recriado por cima de ausente não é fatal. Um banco corrompido é reportado pelo `doctor` e nunca
dados existentes. Atualização falha mantém o binário atual. recriado silenciosamente sobre dados existentes.
## Versionamento e compatibilidade ## Versionamento e compatibilidade
SemVer 2.0.0. O JSON de evidência carrega versão de schema. Qualquer mudança em SemVer 2.0.0. O JSON de evidência carrega uma versão de esquema. Qualquer
pontuação ou no formato da evidência é quebra para versões menores `0.x` e alteração em uma pontuação ou no formato de evidência é breaking change para
precisa constar no changelog. versões minor `0.x` e deve constar no changelog.
+28 -52
View File
@@ -2,23 +2,22 @@
[English](../cli.md) · **Português** [English](../cli.md) · **Português**
O binário `acaua` é ao mesmo tempo CLI e daemon. Esta página descreve os comandos O binário `acaua` atua tanto como utilitário de linha de comando quanto como daemon de segundo plano.
que existem no esqueleto `0.1.0`. A detecção ainda não está implementada; o Esta página descreve os comandos disponíveis no motor.
`analyze` valida a entrada e devolve um relatório stub.
```text ```text
acaua <comando> [opções] acaua <command> [options]
Comandos: Comandos:
serve Sobe a API e a UI embutida serve Inicia a API e o dashboard web embutido
analyze Analisa um diretório de logs Zeek analyze Analisa um diretório de logs do Zeek
doctor Checa o ambiente local doctor Verifica o ambiente local
update Checa ou aplica atualização da CLI update Checa por releases disponíveis
``` ```
## `acaua serve` ## `acaua serve`
Sobe a API HTTP e a UI embutida. Inicia a API HTTP e o dashboard web embutido.
```bash ```bash
acaua serve --bind 127.0.0.1:8787 --data-dir .acaua 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 | | Opção | Padrão | Significado |
|---|---|---| |---|---|---|
| `--bind` | `127.0.0.1:8787` | Endereço de escuta | | `--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 O bind padrão restringe-se a loopback. Expô-lo em uma rede exige um proxy
autenticação. A API está documentada em reverso com autenticação. A API está documentada em
[arquitetura.md](arquitetura.md#api). [arquitetura.md](arquitetura.md#api).
## `acaua analyze` ## `acaua analyze`
@@ -40,14 +39,8 @@ acaua analyze /var/log/zeek/current
acaua analyze /var/log/zeek/current --json acaua analyze /var/log/zeek/current --json
``` ```
O diretório é validado e um relatório é impresso. Neste esqueleto o parser ainda Valida o diretório alvo, ingere tráfego do `conn.log` em partições Parquet e
não lê registros Zeek: `logs lidos` e `registros` são placeholders, e `alertas` executa os algoritmos de detecção de ameaças.
vem vazio. O comando continua útil para conferir tratamento de caminho e o
formato JSON.
O parser futuro vai suportar `conn.log`, `dns.log`, `ssl.log` e os logs de
protocolo necessários para identidade e movimento lateral. NetFlow/IPFIX é modo
degradado; não equivale a logs completos de protocolo.
## `acaua doctor` ## `acaua doctor`
@@ -56,51 +49,34 @@ acaua doctor
acaua doctor --json acaua doctor --json
``` ```
Imprime checagens de Zeek, `curl`, `tar`, da feature opcional DuckDB e do Executa checagens de ambiente:
isolamento de `research/`, seguida do estado (`skeleton` em `0.1.0`). Dependência - `zeek`: verifica `zeek --version` ou detecta execução em container (`ACAUA_RUNTIME=container`).
opcional ausente aparece como `falha`, mas não aborta o comando. Não envia - `curl`: confirma disponibilidade de download HTTP.
telemetria. - `duckdb`: confirma linkagem dinâmica contra a `libduckdb.so` em tempo de execução.
- `research`: valida o isolamento do workspace de pesquisa.
## `acaua update` ## `acaua update`
```bash ```bash
acaua update acaua update
acaua update --apply
``` ```
O `update` consulta a última Release do Gitea, compara a tag com a versão em Consulta a API de Releases do Gitea, compara a última tag com a versão em
execução e informa se existe atualização. O `--apply` baixa o arquivo da execução e imprime instruções de atualização (`docker compose pull && docker compose up -d`
plataforma atual, verifica o SHA-256, extrai e substitui o binário `acaua`. quando em container).
Requisitos: `curl` e `tar` no `PATH`, e local de binário com permissão de escrita.
O updater se recusa a substituir qualquer executável cujo nome não seja `acaua`;
o app desktop deve ser atualizado pelo instalador. Checksum inválido mantém o
binário atual intacto.
## Códigos de saída ## Códigos de saída
| Código | Significado | | Código | Significado |
|---|---| |---|---|
| `0` | Sucesso | | `0` | Sucesso |
| `1` | Erro de execução (caminho inválido, update falhou, erro de I/O) | | `1` | Erro em tempo de execução (caminho inválido, erro de consulta, I/O) |
| `2` | Uso inválido da linha de comando (clap) | | `2` | Uso inválido de linha de comando (clap) |
## Ambiente ## Ambiente
O esqueleto não exige variável de ambiente. `RUST_LOG` está reservado para a | Variável | Significado |
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. | `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 |
## Exemplos | `LD_LIBRARY_PATH` | Caminho de busca de bibliotecas compartilhadas para `libduckdb.so` |
```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.
+58 -44
View File
@@ -2,65 +2,79 @@
[English](../docker.md) · **Português** [English](../docker.md) · **Português**
A imagem é para laboratório e pesquisa. Ela roda o mesmo binário `acaua` de uma O Acauã é distribuído como uma imagem de container oficial:
instalação nativa, com a UI embutida. O esqueleto não liga a feature DuckDB na `git.lucasrguerra.dev.br/ciberfauna/acaua`.
imagem; o armazenamento continua reservado para uma release futura.
## 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 ```bash
docker build -t acaua . cp .env.example .env # configure ZEEK_INTERFACE (apenas redes autorizadas)
docker run --rm -p 127.0.0.1:8787:8787 -v "$PWD/data/acaua:/data/acaua" acaua docker compose pull && docker compose up -d
``` ```
O estágio de build usa `rust:1-slim-bookworm`; o de runtime usa Para compilar e rodar localmente a partir do código:
`debian:bookworm-slim` e instala só `ca-certificates`, `curl` e `tar` (o updater
precisa de `curl` e `tar`).
## Compose
```bash ```bash
cp .env.example .env # opcional: defina ZEEK_INTERFACE docker compose up -d --build
docker compose up --build
``` ```
O Compose sobe dois serviços: A stack coordena dois serviços:
| Serviço | Função | Notas | | Serviço | Imagem | Finalidade | Perfil de segurança |
|---|---|---| |---|---|---|---|
| `zeek` | Captura e grava logs Zeek em `./data/zeek` | Rede do host e `NET_RAW`/`NET_ADMIN` | | `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` | Lê logs e serve UI/API | Porta do host presa a `127.0.0.1:8787` | | `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` ## Volumes e organização de dados
leitura-escrita em `/data/acaua`. O daemon dentro do contêiner escuta
`0.0.0.0:8787`, mas a porta publicada é só loopback no host.
## Layout de dados A stack utiliza volumes nomeados para isolamento limpo de permissões e propriedade:
```text - `zeek-logs`: Volume compartilhado montado em `/logs` no serviço Zeek e montado
data/ como **somente leitura** em `/data/zeek:ro` no serviço Acauã.
zeek/ # logs Zeek, escritos pelo serviço zeek - `acaua-data`: Volume dedicado de leitura e escrita montado em `/data/acaua` para o
acaua/ # store, exportações e arquivos de runtime 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 Ou executar um container efêmero de inspeção:
parser deve confinar caminhos à raiz do dataset; o esqueleto ainda não lê
registros. ```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 ## Notas de segurança
- O serviço `zeek` precisa de privilégio de captura. Rode só em interface e rede - O serviço `zeek` requer privilégios de captura de pacotes (`NET_RAW`, `NET_ADMIN`).
que você tem autorização para monitorar. Execute-o apenas em interfaces e redes que você tem autorização explícita para monitorar.
- Não mude o mapeamento de porta do host para `0.0.0.0:8787` sem proxy reverso - A publicação padrão do dashboard é restrita a loopback (`ACAUA_PUBLISH=127.0.0.1:8787`).
com autenticação. Não exponha o serviço em interfaces externas sem um proxy reverso com autenticação.
- A imagem não tem telemetria e não faz conexão de saída exceto um - A imagem não possui telemetria. Tráfego de saída restringe-se a checagens de
`acaua update` explícito. versão iniciadas pelo operador contra a API de Releases do Gitea.
- 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.
+22 -54
View File
@@ -2,71 +2,39 @@
[English](../interface.md) · **Português** [English](../interface.md) · **Português**
A interface é um app HTML/CSS/JS vanilla embutido no binário `acaua` e carregado A interface é um dashboard vanilla em HTML/CSS/JS embutido diretamente no binário
pelo shell Tauri. Não há etapa de build de frontend nem runtime React/Next.js. `acaua` via `rust-embed` e servido por `acaua serve`. Não há etapa de build de
Esta página descreve o shell `0.1.0`; não é um tour de screenshots, porque as frontend nem frameworks pesados em runtime.
telas de detecção ainda não têm nada real para mostrar.
## Layout ## Estrutura
- **Barra lateral:** marca, navegação, estado da conexão, versão e checagem de - **Barra lateral:** logo da marca, navegação, indicador de conexão ao daemon, versão e checagem de atualizações.
atualização. - **Visão geral:** aviso de escopo de monitoramento, alerta de uso autorizado e cartões dos quatro pilares de detecção.
- **Visão geral:** aviso de escopo, aviso de uso autorizado e os quatro cards dos - **Análise:** campo de caminho de dataset, botão de análise, atualização e inspeção de saída JSON.
pilares. - **Alertas:** listagem de alertas com metadados de evidência determinística.
- **Análise:** campo de caminho do dataset, botão analisar, atualizar e saída - **Configurações:** preferências de idioma e tema, salvas no armazenamento local do navegador.
JSON bruta.
- **Alertas:** estado vazio explicando que o motor de detecção chega em spec
futura.
- **Configurações:** idioma e tema, guardados localmente.
## Idioma ## Idioma
O texto-fonte é em português. O `i18n.js` aplica as strings em inglês de 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 persiste a escolha no `localStorage`. `Auto` `i18n-en.js` em tempo de execução e salva a escolha no `localStorage`. `Auto`
segue o idioma do navegador/sistema. A checagem detecta o idioma do navegador. O teste automatizado `node scripts/check-ui-i18n.mjs`
`node scripts/check-ui-i18n.mjs` falha quando uma string visível em português não garante que toda string visível em português tenha seu par exato em inglês.
tem par em inglês.
## Tema ## Tema
`Auto`, claro e escuro. `Auto` segue `prefers-color-scheme`. A escolha fica Modos claro, escuro e `Auto` são suportados. `Auto` responde ao `prefers-color-scheme`
guardada localmente; nenhuma preferência sai da máquina. 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. - **Conectando:** conexão inicial em andamento.
- **Serviço local ativo:** `/api/health` respondeu. - **Serviço local ativo:** o daemon está respondendo.
- **Serviço indisponível:** a requisição falhou; o shell continua utilizável para - **Serviço indisponível:** falha na conexão com o daemon; a interface continua acessível para ajustes locais.
configurações.
O shell Tauri poderá apontar para um daemon local ou um `serve` remoto em versão
futura. Em `0.1.0` ele usa a API local.
## Tela de análise ## Tela de análise
`Analisar` envia o caminho para `/api/analyze` e imprime a resposta JSON. O Enviar um caminho de diretório para `/api/analyze` dispara a ingestão do Zeek e o
esqueleto valida o caminho e devolve um relatório stub. Ele não lê registros Zeek pipeline analítico de detecção.
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.
+19 -29
View File
@@ -2,46 +2,36 @@
[English](../support.md) · **Português** [English](../support.md) · **Português**
O Acauã é pré-alfa. Não há contrato de suporte nem binário publicado ainda. O O Acauã é distribuído como um motor de NDR containerizado. A forma mais rápida de
caminho mais rápido para obter ajuda é uma issue objetiva no repositório Gitea. obter assistência é abrir uma issue no repositório do Gitea.
## Antes de abrir uma issue ## 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`. 2. Confirme a versão com `acaua --version`.
3. Tente a menor entrada que reproduz o problema. 3. Tente reproduzir o problema com a menor captura de logs Zeek possível.
4. Leia os [limites conhecidos](../../README.pt-BR.md#limites-conhecidos) e a 4. Revise os [limites conhecidos](../../README.pt-BR.md#limites-conhecidos) e a [arquitetura](arquitetura.md).
[arquitetura](arquitetura.md).
## O que incluir ## O que incluir
- Sistema operacional, arquitetura e versão do Acauã. - Sistema operacional do host, arquitetura (`uname -m`) e versão/tag do Acauã.
- O comando exato e a saída completa do terminal. - O comando exato ou definição do Docker Compose.
- Se a entrada é diretório de logs Zeek, fixture sintética ou captura. - Logs completos do container (`docker compose logs acaua`).
- Para problema de UI: build de navegador ou desktop, idioma e tema. - Amostras anonimizadas de logs Zeek que demonstrem a falha.
- Para problema de atualização: saída de `acaua update` e se `curl` e `tar` estão
no `PATH`.
Não anexe capturas de produção nem credenciais. Redija endereços, hostnames e Não anexe capturas de tráfego de produção nem credenciais. Anonimize endereços IP,
payloads antes de compartilhar. 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 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 peça um canal privado primeiro. [SECURITY.pt-BR.md](../../SECURITY.pt-BR.md) e solicite um canal privado de comunicação.
## Situações comuns ## Situações comuns
| Sintoma | O que checar | | Sintoma | O que verificar |
|---|---| |---|---|
| `analyze` devolve zero registros | Esperado em `0.1.0`; o parser é stub | | Container não sobe | Confirme linkagem de `libduckdb.so` via `docker logs` e verifique permissões nos volumes nomeados |
| `serve` inacessível de outra máquina | Ele escuta em loopback por padrão | | `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 | `curl`, `tar`, permissão de escrita e assets da release | | `update` falha | Verifique conectividade de rede com a API de Releases do Gitea ou permissões de pull do registry |
| Build do desktop falha | Dependências de sistema do Tauri para a plataforma | | UI web exibe português | `Auto` segue o idioma do navegador; escolha inglês nas configurações |
| 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.
+16 -26
View File
@@ -2,46 +2,36 @@
**English** · [Português](pt-BR/suporte.md) **English** · [Português](pt-BR/suporte.md)
Acauã is pre-alpha. There is no support contract and no published binary yet. Acauã is distributed as a containerized NDR engine. The fastest way to get help
The fastest way to get help is a focused issue in the Gitea repository. is opening an issue in the Gitea repository.
## Before opening an issue ## 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`. 2. Confirm the version with `acaua --version`.
3. Try the smallest input that reproduces the problem. 3. Try reproducing with the smallest possible Zeek log capture.
4. Read the [known limits](../README.md#known-limits) and 4. Review the [known limits](../README.md#known-limits) and [architecture](architecture.md).
[architecture](architecture.md).
## What to include ## What to include
- Operating system, architecture and Acauã version. - Host operating system, architecture (`uname -m`) and Acauã version/tag.
- The exact command and the full terminal output. - The exact command or Docker Compose definition.
- Whether the input is a Zeek log directory, a synthetic fixture or a capture. - Full container logs (`docker compose logs acaua`).
- For a UI problem: browser or desktop build, language and theme. - Redacted Zeek log samples demonstrating the issue.
- For an update problem: `acaua update` output and whether `curl` and `tar` are
on `PATH`.
Do not attach production captures or credentials. Redact addresses, hostnames Do not attach production captures or credentials. Redact IP addresses, hostnames
and payloads before sharing. and sensitive payloads before sharing.
## Security issues ## Security issues
Do not include exploit details in a public issue. Follow 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 ## Common situations
| Symptom | What to check | | Symptom | What to check |
|---|---| |---|---|
| `analyze` returns zero records | Expected in `0.1.0`; the parser is a stub | | Container fails to start | Confirm `libduckdb.so` linkage via `docker logs` and verify permissions on named volumes |
| `serve` is unreachable from another machine | It binds to loopback by default | | `serve` is unreachable from another host | Service binds to loopback (`127.0.0.1:8787`) by default; verify reverse proxy configuration |
| `update` fails | `curl`, `tar`, write permission and release assets | | `update` fails | Check network connectivity to Gitea Releases API or verify registry pull permissions |
| Desktop build fails | Tauri system dependencies for the platform | | Web UI shows Portuguese | `Auto` follows the browser language; select English in settings |
| 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.
-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 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 step "formatting (cargo fmt)" cargo fmt --all -- --check
# Same flags as CI: warnings are treated as errors across all targets, including tests # 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 step "clippy (workspace, all targets)" env RUSTFLAGS="-D warnings" cargo clippy --workspace --all-targets
@@ -32,9 +36,16 @@ terms = [
'updater' + '.pubkey', 'updater' + '.pubkey',
'createUpdater' + 'Artifacts', 'createUpdater' + 'Artifacts',
'plugin' + '-updater', 'plugin' + '-updater',
'Tau' + 'ri',
'App' + 'Image',
] ]
skip_dirs = {'.git', 'target', '.wolf', '.claude', 'dist-gui'} skip_dirs = {'.git', 'target', '.wolf', '.claude'}
allowed = {Path('docs/architecture.md')} allowed = {
Path('docs/architecture.md'),
Path('docs/pt-BR/arquitetura.md'),
Path('CHANGELOG.md'),
Path('CHANGELOG.pt-BR.md'),
}
hits = [] hits = []
for path in Path('.').rglob('*'): for path in Path('.').rglob('*'):
if not path.is_file() or any(part in skip_dirs for part in path.parts): 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): for f in glob.glob('.gitea/**/*.yml', recursive=True):
yaml.safe_load(open(f)) 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 " step "consistent Semantic Versioning 2.0.0" python3 -c "
import json, re, sys import re, sys
from pathlib import Path from pathlib import Path
semver_re = re.compile( semver_re = re.compile(
@@ -78,22 +89,33 @@ def cargo_version(path):
sys.exit(f'version not found in {path}') sys.exit(f'version not found in {path}')
return m.group(1) return m.group(1)
versions = [ version = cargo_version('Cargo.toml')
cargo_version('Cargo.toml'), if not semver_re.match(version):
cargo_version('apps/gui/src-tauri/Cargo.toml'), sys.exit(f'invalid SemVer 2.0.0 version: {version}')
json.load(open('apps/gui/src-tauri/tauri.conf.json'))['version'], print(version)
]
if len(set(versions)) != 1:
sys.exit(f'version mismatch: Cargo.toml={versions[0]}, src-tauri={versions[1]}, tauri.conf={versions[2]}')
if not semver_re.match(versions[0]):
sys.exit(f'invalid SemVer 2.0.0 version: {versions[0]}')
print(versions[0])
" "
step "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 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 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 fi
if [ "$full" = 1 ]; then 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"