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

+4 -530
View File
@@ -1,25 +1,13 @@
use crate::error::{AcauaError, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::ffi::OsStr;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
const LATEST_RELEASE_URL: &str =
"https://git.lucasrguerra.dev.br/api/v1/repos/ciberfauna/acaua/releases/latest";
const RELEASES_PAGE_URL: &str = "https://git.lucasrguerra.dev.br/ciberfauna/acaua/releases";
#[derive(Debug, Deserialize)]
struct Release {
tag_name: String,
assets: Vec<Asset>,
}
#[derive(Debug, Deserialize)]
struct Asset {
name: String,
browser_download_url: String,
}
#[derive(Debug, Clone, Serialize)]
@@ -46,42 +34,6 @@ fn curl_text(url: &str) -> Result<String> {
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn curl_download(url: &str, destination: &Path) -> Result<()> {
let status = Command::new("curl")
.args(["-fsSL", "-H", "User-Agent: acaua-updater", "-o"])
.arg(destination)
.arg(url)
.status()
.map_err(|error| AcauaError::CommandFailed {
cmd: format!("curl {url}"),
detail: error.to_string(),
})?;
if !status.success() {
return Err(AcauaError::CommandFailed {
cmd: format!("curl {url}"),
detail: "download failed".to_string(),
});
}
Ok(())
}
fn asset_suffix_for_platform() -> String {
let os = if cfg!(target_os = "linux") {
"linux"
} else if cfg!(target_os = "macos") {
"macos"
} else {
"windows"
};
let arch = if cfg!(target_arch = "x86_64") {
"x86_64"
} else {
"aarch64"
};
let extension = if cfg!(windows) { "zip" } else { "tar.gz" };
format!("-{os}-{arch}.{extension}")
}
pub fn check(current_version: &str) -> Result<UpdateStatus> {
let body = match curl_text(LATEST_RELEASE_URL) {
Ok(body) => body,
@@ -103,492 +55,14 @@ pub fn check(current_version: &str) -> Result<UpdateStatus> {
})
}
pub fn apply(current_exe: &Path) -> Result<String> {
let is_cli = current_exe
.file_stem()
.map(|stem| stem == "acaua")
.unwrap_or(false);
if !is_cli {
return Err(AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: format!(
"automatic updates only replace the acaua CLI; download the desktop installer from {RELEASES_PAGE_URL}"
),
});
}
let body = curl_text(LATEST_RELEASE_URL)?;
let release: Release = serde_json::from_str(&body)?;
let suffix = asset_suffix_for_platform();
let asset = release
.assets
.iter()
.find(|asset| asset.name.ends_with(&suffix))
.ok_or_else(|| AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: format!(
"no asset ending in `{suffix}` in release {}",
release.tag_name
),
})?;
let checksum_asset = release
.assets
.iter()
.find(|candidate| candidate.name == format!("{}.sha256", asset.name));
let temp_dir = std::env::temp_dir().join("acaua-update");
fs::create_dir_all(&temp_dir)?;
let archive_path = temp_dir.join(&asset.name);
curl_download(&asset.browser_download_url, &archive_path)?;
if let Some(checksum_asset) = checksum_asset {
let expected = curl_text(&checksum_asset.browser_download_url)?;
let expected_hash = expected
.split_whitespace()
.next()
.unwrap_or("")
.to_lowercase();
let data = fs::read(&archive_path)?;
let mut hasher = Sha256::new();
hasher.update(&data);
let actual_hash = format!("{:x}", hasher.finalize());
if actual_hash != expected_hash {
return Err(AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: "SHA-256 checksum mismatch; update discarded".to_string(),
});
}
}
let extract_dir = temp_dir.join("extracted");
let _ = fs::remove_dir_all(&extract_dir);
fs::create_dir_all(&extract_dir)?;
let status = Command::new("tar")
.arg("xf")
.arg(&archive_path)
.arg("-C")
.arg(&extract_dir)
.status()
.map_err(|error| AcauaError::CommandFailed {
cmd: "tar xf".to_string(),
detail: error.to_string(),
})?;
if !status.success() {
return Err(AcauaError::CommandFailed {
cmd: "tar xf".to_string(),
detail: "extraction failed".to_string(),
});
}
let new_binary = find_binary(&extract_dir)?;
let backup = current_exe.with_extension("bak");
let _ = fs::remove_file(&backup);
fs::rename(current_exe, &backup)?;
fs::copy(&new_binary, current_exe)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut permissions = fs::metadata(current_exe)?.permissions();
permissions.set_mode(0o755);
fs::set_permissions(current_exe, permissions)?;
}
Ok(release.tag_name)
}
fn find_binary(directory: &Path) -> Result<PathBuf> {
fn is_binary(name: &OsStr) -> bool {
name == "acaua" || name == "acaua.exe"
}
for entry in fs::read_dir(directory)? {
let entry = entry?;
if is_binary(&entry.file_name()) {
return Ok(entry.path());
}
if entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false) {
for inner in fs::read_dir(entry.path())? {
let inner = inner?;
if is_binary(&inner.file_name()) {
return Ok(inner.path());
}
}
}
}
Err(AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: "binary not found in the downloaded package".to_string(),
})
}
/// Desktop GUI installation type detected at runtime.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DesktopInstall {
/// Linux AppImage: real filesystem path of the .AppImage file (from APPIMAGE env var).
AppImage(PathBuf),
/// Linux Debian package installed via dpkg: `current_exe` is reliable here.
Deb,
/// Windows NSIS installer: downloaded setup executable run with `/S`
/// (silent). Does not relaunch the app on its own — see the comment on
/// the `WindowsInstaller` apply path.
WindowsInstaller,
}
/// Detects the desktop installation type given an optional `APPIMAGE` environment
/// variable value and the current executable path.
pub fn detect_desktop_install_from(
appimage_env: Option<&std::ffi::OsStr>,
current_exe: &Path,
) -> Result<DesktopInstall> {
#[cfg(windows)]
{
let _ = (appimage_env, current_exe);
Ok(DesktopInstall::WindowsInstaller)
}
#[cfg(target_os = "linux")]
{
if let Some(val) = appimage_env {
let path = PathBuf::from(val);
if path.is_file() {
return Ok(DesktopInstall::AppImage(path));
}
}
if current_exe.is_file()
&& current_exe
.extension()
.and_then(|e| e.to_str())
.map(|e| e.eq_ignore_ascii_case("appimage"))
.unwrap_or(false)
{
return Ok(DesktopInstall::AppImage(current_exe.to_path_buf()));
}
Ok(DesktopInstall::Deb)
}
#[cfg(not(any(windows, target_os = "linux")))]
{
let _ = (appimage_env, current_exe);
Err(AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: format!(
"automatic self-update is not supported on this platform; download manually from {RELEASES_PAGE_URL}"
),
})
}
}
/// Detects the desktop installation type using the live environment.
pub fn detect_desktop_install() -> Result<DesktopInstall> {
let exe = std::env::current_exe()?;
detect_desktop_install_from(std::env::var_os("APPIMAGE").as_deref(), &exe)
}
/// Returns the expected release asset suffix for a desktop installation and CPU architecture.
pub fn gui_asset_suffix_for(install: &DesktopInstall, arch: &str) -> Result<&'static str> {
match install {
DesktopInstall::AppImage(_) => match arch {
"x86_64" => Ok("_amd64.AppImage"),
"aarch64" => Ok("_aarch64.AppImage"),
other => Err(AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: format!("unsupported architecture for AppImage: {other}"),
}),
},
DesktopInstall::Deb => match arch {
"x86_64" => Ok("_amd64.deb"),
"aarch64" => Ok("_arm64.deb"),
other => Err(AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: format!("unsupported architecture for deb package: {other}"),
}),
},
DesktopInstall::WindowsInstaller => match arch {
"x86_64" => Ok("_x64-setup.exe"),
other => Err(AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: format!("unsupported architecture for Windows installer: {other}"),
}),
},
}
}
/// Returns the expected release asset suffix for the current architecture.
pub fn gui_asset_suffix(install: &DesktopInstall) -> Result<&'static str> {
gui_asset_suffix_for(install, std::env::consts::ARCH)
}
/// Downloads, verifies, and installs the latest desktop GUI release in place.
pub fn apply_desktop(current_exe: &Path) -> Result<String> {
apply_desktop_with_progress(current_exe, |_| {})
}
/// Downloads, verifies, and installs the latest desktop GUI release in place,
/// notifying the progress callback of phase transitions (`"downloading"`,
/// `"verifying"`, `"installing"`, `"restarting"`).
pub fn apply_desktop_with_progress<F>(current_exe: &Path, on_progress: F) -> Result<String>
where
F: Fn(&str),
{
let install =
detect_desktop_install_from(std::env::var_os("APPIMAGE").as_deref(), current_exe)?;
let body = curl_text(LATEST_RELEASE_URL)?;
let release: Release = serde_json::from_str(&body)?;
let suffix = gui_asset_suffix(&install)?;
let asset = release
.assets
.iter()
.find(|asset| asset.name.ends_with(suffix))
.ok_or_else(|| AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: format!(
"no asset ending with `{suffix}` in release {}",
release.tag_name
),
})?;
let checksum_asset = release
.assets
.iter()
.find(|asset| asset.name == format!("{}.sha256", asset.name));
let tmp_dir = std::env::temp_dir().join("acaua-desktop-update");
fs::create_dir_all(&tmp_dir)?;
let download_path = tmp_dir.join(&asset.name);
on_progress("downloading");
curl_download(&asset.browser_download_url, &download_path)?;
if let Some(checksum_asset) = checksum_asset {
on_progress("verifying");
let expected = curl_text(&checksum_asset.browser_download_url)?;
let expected_hash = expected
.split_whitespace()
.next()
.unwrap_or("")
.to_lowercase();
let data = fs::read(&download_path)?;
let mut hasher = Sha256::new();
hasher.update(&data);
let actual_hash: String = hasher
.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
if actual_hash != expected_hash {
return Err(AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: "SHA-256 checksum mismatch — update discarded".to_string(),
});
}
}
on_progress("installing");
match install {
DesktopInstall::AppImage(target_appimage) => {
#[cfg(target_os = "linux")]
{
let parent_dir = target_appimage.parent().unwrap_or_else(|| Path::new("."));
let temp_name = format!(
".{}.tmp-{}",
target_appimage
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("appimage"),
std::process::id()
);
let temp_file = parent_dir.join(temp_name);
fs::copy(&download_path, &temp_file)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&temp_file)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(&temp_file, perms)?;
}
fs::rename(&temp_file, &target_appimage)?;
let _ = fs::remove_file(&download_path);
on_progress("restarting");
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
let err = Command::new(&target_appimage)
.args(std::env::args().skip(1))
.exec();
return Err(AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: format!("failed to relaunch AppImage: {err}"),
});
}
}
#[cfg(not(target_os = "linux"))]
{
let _ = (&target_appimage, &download_path);
return Err(AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: "AppImage updates are only supported on Linux".to_string(),
});
}
}
DesktopInstall::Deb => {
#[cfg(target_os = "linux")]
{
let status = Command::new("dpkg")
.args(["-i"])
.arg(&download_path)
.status()
.map_err(|e| AcauaError::CommandFailed {
cmd: "dpkg -i".to_string(),
detail: e.to_string(),
})?;
if !status.success() {
return Err(AcauaError::CommandFailed {
cmd: "dpkg -i".to_string(),
detail: format!("dpkg -i failed with exit code {:?}", status.code()),
});
}
let _ = fs::remove_file(&download_path);
on_progress("restarting");
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
let err = Command::new(current_exe)
.args(std::env::args().skip(1))
.exec();
return Err(AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: format!("failed to relaunch updated app: {err}"),
});
}
}
#[cfg(not(target_os = "linux"))]
{
let _ = (&download_path, current_exe);
return Err(AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: "deb updates are only supported on Linux".to_string(),
});
}
}
DesktopInstall::WindowsInstaller => {
#[cfg(windows)]
{
// `/S` is the one NSIS silent-install switch with a documented,
// stable meaning. There is no standard NSIS or Tauri flag that
// makes a silent install relaunch the app afterwards — that was
// claimed in an earlier draft of this code and could not be
// verified against NSIS's or Tauri's documentation; passing an
// invented flag to a stock NSIS installer is silently ignored,
// not harmful, but also not a relaunch mechanism. Spawning
// detached and exiting immediately (instead of waiting on the
// child) also avoids a self-deadlock: if the installer's script
// waits for this process to close before it can overwrite the
// running .exe, blocking here on the installer's exit status
// would hang forever. The practical effect: the update installs
// silently, but the app does not reopen itself — the user needs
// to start it again. This needs real-world confirmation on
// Windows before relying on it; if testing shows the bundled
// NSIS template does support an explicit relaunch flag or step,
// wire it in here instead of this comment.
on_progress("restarting");
Command::new(&download_path)
.arg("/S")
.spawn()
.map_err(|e| AcauaError::CommandFailed {
cmd: "installer".to_string(),
detail: format!("failed to launch installer: {e}"),
})?;
std::process::exit(0);
}
#[cfg(not(windows))]
{
let _ = &download_path;
return Err(AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: "Windows installer updates are only supported on Windows".to_string(),
});
}
}
}
#[allow(unreachable_code)]
Ok(release.tag_name)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn apply_refuses_to_overwrite_the_desktop_app() {
let error = apply(Path::new("/opt/acaua/acaua-gui")).unwrap_err();
assert!(error.to_string().contains("CLI"), "{error}");
assert!(error.to_string().contains(RELEASES_PAGE_URL), "{error}");
}
#[test]
fn detect_desktop_install_variants() {
#[cfg(target_os = "linux")]
{
let temp_appimage =
std::env::temp_dir().join(format!("test-acaua-{}.AppImage", std::process::id()));
let _ = fs::write(&temp_appimage, b"dummy appimage");
let res = detect_desktop_install_from(
Some(temp_appimage.as_os_str()),
Path::new("/usr/bin/acaua-gui"),
)
.unwrap();
assert_eq!(res, DesktopInstall::AppImage(temp_appimage.clone()));
let res_fallback = detect_desktop_install_from(None, &temp_appimage).unwrap();
assert_eq!(
res_fallback,
DesktopInstall::AppImage(temp_appimage.clone())
);
let _ = fs::remove_file(&temp_appimage);
let res_deb =
detect_desktop_install_from(None, Path::new("/usr/bin/acaua-gui")).unwrap();
assert_eq!(res_deb, DesktopInstall::Deb);
}
#[cfg(windows)]
{
let res = detect_desktop_install_from(
None,
Path::new("C:/Program Files/Acaua/acaua-gui.exe"),
)
.unwrap();
assert_eq!(res, DesktopInstall::WindowsInstaller);
}
}
#[test]
fn gui_asset_suffix_matches_known_release_naming() {
let appimage = DesktopInstall::AppImage(PathBuf::from("/tmp/Acaua.AppImage"));
assert_eq!(
gui_asset_suffix_for(&appimage, "x86_64").unwrap(),
"_amd64.AppImage"
);
assert_eq!(
gui_asset_suffix_for(&appimage, "aarch64").unwrap(),
"_aarch64.AppImage"
);
let deb = DesktopInstall::Deb;
assert_eq!(gui_asset_suffix_for(&deb, "x86_64").unwrap(), "_amd64.deb");
assert_eq!(gui_asset_suffix_for(&deb, "aarch64").unwrap(), "_arm64.deb");
let win = DesktopInstall::WindowsInstaller;
assert_eq!(
gui_asset_suffix_for(&win, "x86_64").unwrap(),
"_x64-setup.exe"
);
assert!(gui_asset_suffix_for(&appimage, "mips").is_err());
assert!(gui_asset_suffix_for(&deb, "riscv64").is_err());
assert!(gui_asset_suffix_for(&win, "arm").is_err());
fn parses_latest_release_tag() {
let json = r#"{"tag_name": "v0.2.0"}"#;
let release: Release = serde_json::from_str(json).unwrap();
assert_eq!(release.tag_name, "v0.2.0");
}
}