chore: initial project skeleton
Acauã: Network Detection & Threat Hunting, heuristic analysis over a rolling 7-day window using deterministic math (time series, graph theory, information theory) instead of opaque ML models. Third product in the Ciberfauna ecosystem, alongside Guará and Sabiá. Follows the ecosystem's established conventions: OpenWolf project memory, bilingual PT-BR/EN docs and UI, Gitea Actions CI/CD on the same self-hosted runners, Conventional Commits, SemVer, logo and productName handling, MIT OR Apache-2.0 licensing. Architecture decision (made with the project owner after a dedicated consultation, not the original blueprint's suggested stack): Rust throughout, not Python/FastAPI/React. Most of the statistics (Bowley skew, MADM, Shannon entropy, PCR, Z-score, novel-edge detection, pivot chains) reduces to SQL over an embedded DuckDB/Parquet store; only the spectral analysis and graph centrality need host-language code, and both have workable Rust crates. Documented in full, including six corrections to the original blueprint's math (Rayleigh/Schuster instead of a degenerate Lomb-Scargle over bare timestamps, an explicit sentinel for Bowley skew's Q3=Q1 case, Jensen-Shannon instead of unbounded KL, a dedicated long-connection detector, JA4 vs. JA4+ licensing caution, deterministic per-alert evidence), in docs/architecture.md. Skeleton only: crates/acaua-core has the four detection pillars, fusion, storage, zeek log parsing and the updater as typed stubs that compile and return a clear "skeleton" status rather than real detection logic — that is separate, future work. crates/acaua-cli has serve/analyze/doctor/update. apps/gui is a minimal Tauri shell. Also already includes the one-click desktop self-update mechanism shared with Guará and Sabiá (AppImage/.deb/Windows installer), even with no release published yet, for consistency across the ecosystem. No Gitea repository exists for this project yet; this is a local-only commit. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
This commit is contained in:
commit
1b974bb90d
95 files changed
+12016
No files matched your search
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "acaua-core"
|
||||
description = "Acauã: deterministic network threat hunting core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
authors.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
duckdb = ["dep:duckdb"]
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
thiserror = "1"
|
||||
time = { version = "0.3", features = ["formatting", "macros"] }
|
||||
duckdb = { version = "1", features = ["bundled"], optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,61 @@
|
||||
use serde::Serialize;
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Check {
|
||||
pub name: &'static str,
|
||||
pub ok: bool,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DoctorReport {
|
||||
pub checks: Vec<Check>,
|
||||
pub status: &'static str,
|
||||
}
|
||||
|
||||
fn command_available(name: &str, arg: &str) -> Check {
|
||||
match Command::new(name).arg(arg).output() {
|
||||
Ok(output) if output.status.success() => Check {
|
||||
name: if name == "zeek" { "zeek" } else { "runtime" },
|
||||
ok: true,
|
||||
detail: format!("{name} is available"),
|
||||
},
|
||||
Ok(_) => Check {
|
||||
name: if name == "zeek" { "zeek" } else { "runtime" },
|
||||
ok: false,
|
||||
detail: format!("{name} returned an error"),
|
||||
},
|
||||
Err(error) => Check {
|
||||
name: if name == "zeek" { "zeek" } else { "runtime" },
|
||||
ok: false,
|
||||
detail: format!("{name} not found: {error}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run() -> DoctorReport {
|
||||
let checks = vec![
|
||||
command_available("zeek", "--version"),
|
||||
command_available("curl", "--version"),
|
||||
command_available("tar", "--version"),
|
||||
Check {
|
||||
name: "duckdb",
|
||||
ok: cfg!(feature = "duckdb"),
|
||||
detail: if cfg!(feature = "duckdb") {
|
||||
"bundled DuckDB feature is enabled".to_string()
|
||||
} else {
|
||||
"bundled DuckDB feature is disabled in this build".to_string()
|
||||
},
|
||||
},
|
||||
Check {
|
||||
name: "research",
|
||||
ok: true,
|
||||
detail: "research workspace is isolated from the product".to_string(),
|
||||
},
|
||||
];
|
||||
DoctorReport {
|
||||
checks,
|
||||
status: "skeleton",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
use std::io;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AcauaError {
|
||||
#[error("invalid input: {0}")]
|
||||
InvalidInput(String),
|
||||
|
||||
#[error("command failed: {cmd}: {detail}")]
|
||||
CommandFailed { cmd: String, detail: String },
|
||||
|
||||
#[error("feature not implemented yet: {0}")]
|
||||
NotImplemented(&'static str),
|
||||
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
|
||||
#[error("serialization error: {0}")]
|
||||
Serde(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, AcauaError>;
|
||||
@@ -0,0 +1,25 @@
|
||||
use crate::error::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Alert {
|
||||
pub id: String,
|
||||
pub pillar: u8,
|
||||
pub score: f64,
|
||||
pub summary: String,
|
||||
pub evidence: serde_json::Value,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct FusionReport {
|
||||
pub alerts: Vec<Alert>,
|
||||
pub status: &'static str,
|
||||
}
|
||||
|
||||
pub fn fuse(_scores: &[f64]) -> Result<FusionReport> {
|
||||
Ok(FusionReport {
|
||||
alerts: Vec::new(),
|
||||
status: "skeleton",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
pub mod doctor;
|
||||
pub mod error;
|
||||
pub mod fusion;
|
||||
pub mod pillar1;
|
||||
pub mod pillar2;
|
||||
pub mod pillar3;
|
||||
pub mod pillar4;
|
||||
pub mod storage;
|
||||
pub mod update;
|
||||
pub mod zeek;
|
||||
|
||||
pub use error::{AcauaError, Result};
|
||||
@@ -0,0 +1,24 @@
|
||||
use crate::error::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct BeaconingInput {
|
||||
pub timestamps: Vec<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct BeaconingScore {
|
||||
pub score: f64,
|
||||
pub observations: usize,
|
||||
pub method: &'static str,
|
||||
pub status: &'static str,
|
||||
}
|
||||
|
||||
pub fn score(input: &BeaconingInput) -> Result<BeaconingScore> {
|
||||
Ok(BeaconingScore {
|
||||
score: 0.0,
|
||||
observations: input.timestamps.len(),
|
||||
method: "rayleigh-schuster-stub",
|
||||
status: "skeleton",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
use crate::error::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct SessionInput {
|
||||
pub durations_secs: Vec<f64>,
|
||||
pub bytes: Vec<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SessionScore {
|
||||
pub score: f64,
|
||||
pub observations: usize,
|
||||
pub long_connections: usize,
|
||||
pub bowley_sentinel: Option<f64>,
|
||||
pub method: &'static str,
|
||||
pub status: &'static str,
|
||||
}
|
||||
|
||||
pub fn score(input: &SessionInput) -> Result<SessionScore> {
|
||||
Ok(SessionScore {
|
||||
score: 0.0,
|
||||
observations: input.durations_secs.len(),
|
||||
long_connections: input
|
||||
.durations_secs
|
||||
.iter()
|
||||
.filter(|duration| **duration >= 300.0)
|
||||
.count(),
|
||||
bowley_sentinel: None,
|
||||
method: "long-connection-stub",
|
||||
status: "skeleton",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
use crate::error::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ProtocolInput {
|
||||
pub events: usize,
|
||||
pub protocols: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ProtocolScore {
|
||||
pub score: f64,
|
||||
pub observations: usize,
|
||||
pub distinct_protocols: usize,
|
||||
pub method: &'static str,
|
||||
pub status: &'static str,
|
||||
}
|
||||
|
||||
pub fn score(input: &ProtocolInput) -> Result<ProtocolScore> {
|
||||
let mut protocols = input.protocols.clone();
|
||||
protocols.sort();
|
||||
protocols.dedup();
|
||||
Ok(ProtocolScore {
|
||||
score: 0.0,
|
||||
observations: input.events,
|
||||
distinct_protocols: protocols.len(),
|
||||
method: "jensen-shannon-stub",
|
||||
status: "skeleton",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
use crate::error::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct FusionInput {
|
||||
pub beaconing: f64,
|
||||
pub sessions: f64,
|
||||
pub protocol: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct FusionScore {
|
||||
pub score: f64,
|
||||
pub inputs: usize,
|
||||
pub method: &'static str,
|
||||
pub status: &'static str,
|
||||
}
|
||||
|
||||
pub fn score(input: &FusionInput) -> Result<FusionScore> {
|
||||
Ok(FusionScore {
|
||||
score: 0.0,
|
||||
inputs: [input.beaconing, input.sessions, input.protocol].len(),
|
||||
method: "deterministic-fusion-stub",
|
||||
status: "skeleton",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
use crate::error::Result;
|
||||
use serde::Serialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DatasetSummary {
|
||||
pub records: u64,
|
||||
pub partitions: usize,
|
||||
pub ttl_days: u32,
|
||||
pub backend: &'static str,
|
||||
pub status: &'static str,
|
||||
}
|
||||
|
||||
impl DatasetSummary {
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
records: 0,
|
||||
partitions: 0,
|
||||
ttl_days: 7,
|
||||
backend: "duckdb-parquet",
|
||||
status: "skeleton",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Storage {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl Storage {
|
||||
pub fn open(root: impl AsRef<Path>) -> Self {
|
||||
Self {
|
||||
root: root.as_ref().to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
pub fn summary(&self) -> Result<DatasetSummary> {
|
||||
Ok(DatasetSummary::empty())
|
||||
}
|
||||
|
||||
pub fn prune_expired(&self) -> Result<usize> {
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
use crate::error::{AcauaError, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::ffi::OsStr;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
const LATEST_RELEASE_URL: &str =
|
||||
"https://git.lucasrguerra.dev.br/api/v1/repos/ciberfauna/acaua/releases/latest";
|
||||
const RELEASES_PAGE_URL: &str = "https://git.lucasrguerra.dev.br/ciberfauna/acaua/releases";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Release {
|
||||
tag_name: String,
|
||||
assets: Vec<Asset>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Asset {
|
||||
name: String,
|
||||
browser_download_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct UpdateStatus {
|
||||
pub current_version: String,
|
||||
pub latest_version: Option<String>,
|
||||
pub update_available: bool,
|
||||
}
|
||||
|
||||
fn curl_text(url: &str) -> Result<String> {
|
||||
let output = Command::new("curl")
|
||||
.args(["-fsSL", "-H", "User-Agent: acaua-updater", url])
|
||||
.output()
|
||||
.map_err(|error| AcauaError::CommandFailed {
|
||||
cmd: format!("curl {url}"),
|
||||
detail: error.to_string(),
|
||||
})?;
|
||||
if !output.status.success() {
|
||||
return Err(AcauaError::CommandFailed {
|
||||
cmd: format!("curl {url}"),
|
||||
detail: String::from_utf8_lossy(&output.stderr).into_owned(),
|
||||
});
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
|
||||
}
|
||||
|
||||
fn curl_download(url: &str, destination: &Path) -> Result<()> {
|
||||
let status = Command::new("curl")
|
||||
.args(["-fsSL", "-H", "User-Agent: acaua-updater", "-o"])
|
||||
.arg(destination)
|
||||
.arg(url)
|
||||
.status()
|
||||
.map_err(|error| AcauaError::CommandFailed {
|
||||
cmd: format!("curl {url}"),
|
||||
detail: error.to_string(),
|
||||
})?;
|
||||
if !status.success() {
|
||||
return Err(AcauaError::CommandFailed {
|
||||
cmd: format!("curl {url}"),
|
||||
detail: "download failed".to_string(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn asset_suffix_for_platform() -> String {
|
||||
let os = if cfg!(target_os = "linux") {
|
||||
"linux"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"macos"
|
||||
} else {
|
||||
"windows"
|
||||
};
|
||||
let arch = if cfg!(target_arch = "x86_64") {
|
||||
"x86_64"
|
||||
} else {
|
||||
"aarch64"
|
||||
};
|
||||
let extension = if cfg!(windows) { "zip" } else { "tar.gz" };
|
||||
format!("-{os}-{arch}.{extension}")
|
||||
}
|
||||
|
||||
pub fn check(current_version: &str) -> Result<UpdateStatus> {
|
||||
let body = match curl_text(LATEST_RELEASE_URL) {
|
||||
Ok(body) => body,
|
||||
Err(_) => {
|
||||
return Ok(UpdateStatus {
|
||||
current_version: current_version.to_string(),
|
||||
latest_version: None,
|
||||
update_available: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
let release: Release = serde_json::from_str(&body)?;
|
||||
let latest = release.tag_name.trim_start_matches('v').to_string();
|
||||
let update_available = latest != current_version;
|
||||
Ok(UpdateStatus {
|
||||
current_version: current_version.to_string(),
|
||||
latest_version: Some(latest),
|
||||
update_available,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn apply(current_exe: &Path) -> Result<String> {
|
||||
let is_cli = current_exe
|
||||
.file_stem()
|
||||
.map(|stem| stem == "acaua")
|
||||
.unwrap_or(false);
|
||||
if !is_cli {
|
||||
return Err(AcauaError::CommandFailed {
|
||||
cmd: "update".to_string(),
|
||||
detail: format!(
|
||||
"automatic updates only replace the acaua CLI; download the desktop installer from {RELEASES_PAGE_URL}"
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let body = curl_text(LATEST_RELEASE_URL)?;
|
||||
let release: Release = serde_json::from_str(&body)?;
|
||||
let suffix = asset_suffix_for_platform();
|
||||
let asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|asset| asset.name.ends_with(&suffix))
|
||||
.ok_or_else(|| AcauaError::CommandFailed {
|
||||
cmd: "update".to_string(),
|
||||
detail: format!(
|
||||
"no asset ending in `{suffix}` in release {}",
|
||||
release.tag_name
|
||||
),
|
||||
})?;
|
||||
let checksum_asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|candidate| candidate.name == format!("{}.sha256", asset.name));
|
||||
|
||||
let temp_dir = std::env::temp_dir().join("acaua-update");
|
||||
fs::create_dir_all(&temp_dir)?;
|
||||
let archive_path = temp_dir.join(&asset.name);
|
||||
curl_download(&asset.browser_download_url, &archive_path)?;
|
||||
|
||||
if let Some(checksum_asset) = checksum_asset {
|
||||
let expected = curl_text(&checksum_asset.browser_download_url)?;
|
||||
let expected_hash = expected
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
let data = fs::read(&archive_path)?;
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&data);
|
||||
let actual_hash = format!("{:x}", hasher.finalize());
|
||||
if actual_hash != expected_hash {
|
||||
return Err(AcauaError::CommandFailed {
|
||||
cmd: "update".to_string(),
|
||||
detail: "SHA-256 checksum mismatch; update discarded".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let extract_dir = temp_dir.join("extracted");
|
||||
let _ = fs::remove_dir_all(&extract_dir);
|
||||
fs::create_dir_all(&extract_dir)?;
|
||||
let status = Command::new("tar")
|
||||
.arg("xf")
|
||||
.arg(&archive_path)
|
||||
.arg("-C")
|
||||
.arg(&extract_dir)
|
||||
.status()
|
||||
.map_err(|error| AcauaError::CommandFailed {
|
||||
cmd: "tar xf".to_string(),
|
||||
detail: error.to_string(),
|
||||
})?;
|
||||
if !status.success() {
|
||||
return Err(AcauaError::CommandFailed {
|
||||
cmd: "tar xf".to_string(),
|
||||
detail: "extraction failed".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let new_binary = find_binary(&extract_dir)?;
|
||||
let backup = current_exe.with_extension("bak");
|
||||
let _ = fs::remove_file(&backup);
|
||||
fs::rename(current_exe, &backup)?;
|
||||
fs::copy(&new_binary, current_exe)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut permissions = fs::metadata(current_exe)?.permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(current_exe, permissions)?;
|
||||
}
|
||||
|
||||
Ok(release.tag_name)
|
||||
}
|
||||
|
||||
fn find_binary(directory: &Path) -> Result<PathBuf> {
|
||||
fn is_binary(name: &OsStr) -> bool {
|
||||
name == "acaua" || name == "acaua.exe"
|
||||
}
|
||||
|
||||
for entry in fs::read_dir(directory)? {
|
||||
let entry = entry?;
|
||||
if is_binary(&entry.file_name()) {
|
||||
return Ok(entry.path());
|
||||
}
|
||||
if entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false) {
|
||||
for inner in fs::read_dir(entry.path())? {
|
||||
let inner = inner?;
|
||||
if is_binary(&inner.file_name()) {
|
||||
return Ok(inner.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(AcauaError::CommandFailed {
|
||||
cmd: "update".to_string(),
|
||||
detail: "binary not found in the downloaded package".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Desktop GUI installation type detected at runtime.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum DesktopInstall {
|
||||
/// Linux AppImage: real filesystem path of the .AppImage file (from APPIMAGE env var).
|
||||
AppImage(PathBuf),
|
||||
/// Linux Debian package installed via dpkg: `current_exe` is reliable here.
|
||||
Deb,
|
||||
/// Windows NSIS installer: downloaded setup executable run with `/S`
|
||||
/// (silent). Does not relaunch the app on its own — see the comment on
|
||||
/// the `WindowsInstaller` apply path.
|
||||
WindowsInstaller,
|
||||
}
|
||||
|
||||
/// Detects the desktop installation type given an optional `APPIMAGE` environment
|
||||
/// variable value and the current executable path.
|
||||
pub fn detect_desktop_install_from(
|
||||
appimage_env: Option<&std::ffi::OsStr>,
|
||||
current_exe: &Path,
|
||||
) -> Result<DesktopInstall> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = (appimage_env, current_exe);
|
||||
Ok(DesktopInstall::WindowsInstaller)
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Some(val) = appimage_env {
|
||||
let path = PathBuf::from(val);
|
||||
if path.is_file() {
|
||||
return Ok(DesktopInstall::AppImage(path));
|
||||
}
|
||||
}
|
||||
if current_exe.is_file()
|
||||
&& current_exe
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.eq_ignore_ascii_case("appimage"))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Ok(DesktopInstall::AppImage(current_exe.to_path_buf()));
|
||||
}
|
||||
Ok(DesktopInstall::Deb)
|
||||
}
|
||||
#[cfg(not(any(windows, target_os = "linux")))]
|
||||
{
|
||||
let _ = (appimage_env, current_exe);
|
||||
Err(AcauaError::CommandFailed {
|
||||
cmd: "update".to_string(),
|
||||
detail: format!(
|
||||
"automatic self-update is not supported on this platform; download manually from {RELEASES_PAGE_URL}"
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Detects the desktop installation type using the live environment.
|
||||
pub fn detect_desktop_install() -> Result<DesktopInstall> {
|
||||
let exe = std::env::current_exe()?;
|
||||
detect_desktop_install_from(std::env::var_os("APPIMAGE").as_deref(), &exe)
|
||||
}
|
||||
|
||||
/// Returns the expected release asset suffix for a desktop installation and CPU architecture.
|
||||
pub fn gui_asset_suffix_for(install: &DesktopInstall, arch: &str) -> Result<&'static str> {
|
||||
match install {
|
||||
DesktopInstall::AppImage(_) => match arch {
|
||||
"x86_64" => Ok("_amd64.AppImage"),
|
||||
"aarch64" => Ok("_aarch64.AppImage"),
|
||||
other => Err(AcauaError::CommandFailed {
|
||||
cmd: "update".to_string(),
|
||||
detail: format!("unsupported architecture for AppImage: {other}"),
|
||||
}),
|
||||
},
|
||||
DesktopInstall::Deb => match arch {
|
||||
"x86_64" => Ok("_amd64.deb"),
|
||||
"aarch64" => Ok("_arm64.deb"),
|
||||
other => Err(AcauaError::CommandFailed {
|
||||
cmd: "update".to_string(),
|
||||
detail: format!("unsupported architecture for deb package: {other}"),
|
||||
}),
|
||||
},
|
||||
DesktopInstall::WindowsInstaller => match arch {
|
||||
"x86_64" => Ok("_x64-setup.exe"),
|
||||
other => Err(AcauaError::CommandFailed {
|
||||
cmd: "update".to_string(),
|
||||
detail: format!("unsupported architecture for Windows installer: {other}"),
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the expected release asset suffix for the current architecture.
|
||||
pub fn gui_asset_suffix(install: &DesktopInstall) -> Result<&'static str> {
|
||||
gui_asset_suffix_for(install, std::env::consts::ARCH)
|
||||
}
|
||||
|
||||
/// Downloads, verifies, and installs the latest desktop GUI release in place.
|
||||
pub fn apply_desktop(current_exe: &Path) -> Result<String> {
|
||||
apply_desktop_with_progress(current_exe, |_| {})
|
||||
}
|
||||
|
||||
/// Downloads, verifies, and installs the latest desktop GUI release in place,
|
||||
/// notifying the progress callback of phase transitions (`"downloading"`,
|
||||
/// `"verifying"`, `"installing"`, `"restarting"`).
|
||||
pub fn apply_desktop_with_progress<F>(current_exe: &Path, on_progress: F) -> Result<String>
|
||||
where
|
||||
F: Fn(&str),
|
||||
{
|
||||
let install =
|
||||
detect_desktop_install_from(std::env::var_os("APPIMAGE").as_deref(), current_exe)?;
|
||||
let body = curl_text(LATEST_RELEASE_URL)?;
|
||||
let release: Release = serde_json::from_str(&body)?;
|
||||
|
||||
let suffix = gui_asset_suffix(&install)?;
|
||||
let asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|asset| asset.name.ends_with(suffix))
|
||||
.ok_or_else(|| AcauaError::CommandFailed {
|
||||
cmd: "update".to_string(),
|
||||
detail: format!(
|
||||
"no asset ending with `{suffix}` in release {}",
|
||||
release.tag_name
|
||||
),
|
||||
})?;
|
||||
let checksum_asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|asset| asset.name == format!("{}.sha256", asset.name));
|
||||
|
||||
let tmp_dir = std::env::temp_dir().join("acaua-desktop-update");
|
||||
fs::create_dir_all(&tmp_dir)?;
|
||||
let download_path = tmp_dir.join(&asset.name);
|
||||
|
||||
on_progress("downloading");
|
||||
curl_download(&asset.browser_download_url, &download_path)?;
|
||||
|
||||
if let Some(checksum_asset) = checksum_asset {
|
||||
on_progress("verifying");
|
||||
let expected = curl_text(&checksum_asset.browser_download_url)?;
|
||||
let expected_hash = expected
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
let data = fs::read(&download_path)?;
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&data);
|
||||
let actual_hash: String = hasher
|
||||
.finalize()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect();
|
||||
if actual_hash != expected_hash {
|
||||
return Err(AcauaError::CommandFailed {
|
||||
cmd: "update".to_string(),
|
||||
detail: "SHA-256 checksum mismatch — update discarded".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
on_progress("installing");
|
||||
match install {
|
||||
DesktopInstall::AppImage(target_appimage) => {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let parent_dir = target_appimage.parent().unwrap_or_else(|| Path::new("."));
|
||||
let temp_name = format!(
|
||||
".{}.tmp-{}",
|
||||
target_appimage
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("appimage"),
|
||||
std::process::id()
|
||||
);
|
||||
let temp_file = parent_dir.join(temp_name);
|
||||
fs::copy(&download_path, &temp_file)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = fs::metadata(&temp_file)?.permissions();
|
||||
perms.set_mode(0o755);
|
||||
fs::set_permissions(&temp_file, perms)?;
|
||||
}
|
||||
fs::rename(&temp_file, &target_appimage)?;
|
||||
let _ = fs::remove_file(&download_path);
|
||||
|
||||
on_progress("restarting");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
let err = Command::new(&target_appimage)
|
||||
.args(std::env::args().skip(1))
|
||||
.exec();
|
||||
return Err(AcauaError::CommandFailed {
|
||||
cmd: "update".to_string(),
|
||||
detail: format!("failed to relaunch AppImage: {err}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = (&target_appimage, &download_path);
|
||||
return Err(AcauaError::CommandFailed {
|
||||
cmd: "update".to_string(),
|
||||
detail: "AppImage updates are only supported on Linux".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
DesktopInstall::Deb => {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let status = Command::new("dpkg")
|
||||
.args(["-i"])
|
||||
.arg(&download_path)
|
||||
.status()
|
||||
.map_err(|e| AcauaError::CommandFailed {
|
||||
cmd: "dpkg -i".to_string(),
|
||||
detail: e.to_string(),
|
||||
})?;
|
||||
if !status.success() {
|
||||
return Err(AcauaError::CommandFailed {
|
||||
cmd: "dpkg -i".to_string(),
|
||||
detail: format!("dpkg -i failed with exit code {:?}", status.code()),
|
||||
});
|
||||
}
|
||||
let _ = fs::remove_file(&download_path);
|
||||
|
||||
on_progress("restarting");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
let err = Command::new(current_exe)
|
||||
.args(std::env::args().skip(1))
|
||||
.exec();
|
||||
return Err(AcauaError::CommandFailed {
|
||||
cmd: "update".to_string(),
|
||||
detail: format!("failed to relaunch updated app: {err}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = (&download_path, current_exe);
|
||||
return Err(AcauaError::CommandFailed {
|
||||
cmd: "update".to_string(),
|
||||
detail: "deb updates are only supported on Linux".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
DesktopInstall::WindowsInstaller => {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// `/S` is the one NSIS silent-install switch with a documented,
|
||||
// stable meaning. There is no standard NSIS or Tauri flag that
|
||||
// makes a silent install relaunch the app afterwards — that was
|
||||
// claimed in an earlier draft of this code and could not be
|
||||
// verified against NSIS's or Tauri's documentation; passing an
|
||||
// invented flag to a stock NSIS installer is silently ignored,
|
||||
// not harmful, but also not a relaunch mechanism. Spawning
|
||||
// detached and exiting immediately (instead of waiting on the
|
||||
// child) also avoids a self-deadlock: if the installer's script
|
||||
// waits for this process to close before it can overwrite the
|
||||
// running .exe, blocking here on the installer's exit status
|
||||
// would hang forever. The practical effect: the update installs
|
||||
// silently, but the app does not reopen itself — the user needs
|
||||
// to start it again. This needs real-world confirmation on
|
||||
// Windows before relying on it; if testing shows the bundled
|
||||
// NSIS template does support an explicit relaunch flag or step,
|
||||
// wire it in here instead of this comment.
|
||||
on_progress("restarting");
|
||||
Command::new(&download_path)
|
||||
.arg("/S")
|
||||
.spawn()
|
||||
.map_err(|e| AcauaError::CommandFailed {
|
||||
cmd: "installer".to_string(),
|
||||
detail: format!("failed to launch installer: {e}"),
|
||||
})?;
|
||||
std::process::exit(0);
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let _ = &download_path;
|
||||
return Err(AcauaError::CommandFailed {
|
||||
cmd: "update".to_string(),
|
||||
detail: "Windows installer updates are only supported on Windows".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unreachable_code)]
|
||||
Ok(release.tag_name)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn apply_refuses_to_overwrite_the_desktop_app() {
|
||||
let error = apply(Path::new("/opt/acaua/acaua-gui")).unwrap_err();
|
||||
assert!(error.to_string().contains("CLI"), "{error}");
|
||||
assert!(error.to_string().contains(RELEASES_PAGE_URL), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_desktop_install_variants() {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let temp_appimage =
|
||||
std::env::temp_dir().join(format!("test-acaua-{}.AppImage", std::process::id()));
|
||||
let _ = fs::write(&temp_appimage, b"dummy appimage");
|
||||
|
||||
let res = detect_desktop_install_from(
|
||||
Some(temp_appimage.as_os_str()),
|
||||
Path::new("/usr/bin/acaua-gui"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(res, DesktopInstall::AppImage(temp_appimage.clone()));
|
||||
|
||||
let res_fallback = detect_desktop_install_from(None, &temp_appimage).unwrap();
|
||||
assert_eq!(
|
||||
res_fallback,
|
||||
DesktopInstall::AppImage(temp_appimage.clone())
|
||||
);
|
||||
|
||||
let _ = fs::remove_file(&temp_appimage);
|
||||
|
||||
let res_deb =
|
||||
detect_desktop_install_from(None, Path::new("/usr/bin/acaua-gui")).unwrap();
|
||||
assert_eq!(res_deb, DesktopInstall::Deb);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let res = detect_desktop_install_from(
|
||||
None,
|
||||
Path::new("C:/Program Files/Acaua/acaua-gui.exe"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(res, DesktopInstall::WindowsInstaller);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gui_asset_suffix_matches_known_release_naming() {
|
||||
let appimage = DesktopInstall::AppImage(PathBuf::from("/tmp/Acaua.AppImage"));
|
||||
assert_eq!(
|
||||
gui_asset_suffix_for(&appimage, "x86_64").unwrap(),
|
||||
"_amd64.AppImage"
|
||||
);
|
||||
assert_eq!(
|
||||
gui_asset_suffix_for(&appimage, "aarch64").unwrap(),
|
||||
"_aarch64.AppImage"
|
||||
);
|
||||
|
||||
let deb = DesktopInstall::Deb;
|
||||
assert_eq!(gui_asset_suffix_for(&deb, "x86_64").unwrap(), "_amd64.deb");
|
||||
assert_eq!(gui_asset_suffix_for(&deb, "aarch64").unwrap(), "_arm64.deb");
|
||||
|
||||
let win = DesktopInstall::WindowsInstaller;
|
||||
assert_eq!(
|
||||
gui_asset_suffix_for(&win, "x86_64").unwrap(),
|
||||
"_x64-setup.exe"
|
||||
);
|
||||
|
||||
assert!(gui_asset_suffix_for(&appimage, "mips").is_err());
|
||||
assert!(gui_asset_suffix_for(&deb, "riscv64").is_err());
|
||||
assert!(gui_asset_suffix_for(&win, "arm").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
use crate::error::{AcauaError, Result};
|
||||
use crate::fusion::Alert;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ZeekLogKind {
|
||||
Conn,
|
||||
Dns,
|
||||
Ssl,
|
||||
SmbMapping,
|
||||
DceRpc,
|
||||
Rdp,
|
||||
Ssh,
|
||||
Kerberos,
|
||||
Ntlm,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AnalysisReport {
|
||||
pub directory: String,
|
||||
pub logs_seen: usize,
|
||||
pub records: u64,
|
||||
pub alerts: Vec<Alert>,
|
||||
pub status: &'static str,
|
||||
}
|
||||
|
||||
pub fn analyze_dir(directory: impl AsRef<Path>) -> Result<AnalysisReport> {
|
||||
let directory = directory.as_ref();
|
||||
if !directory.is_dir() {
|
||||
return Err(AcauaError::InvalidInput(format!(
|
||||
"{} is not a directory",
|
||||
directory.display()
|
||||
)));
|
||||
}
|
||||
Ok(AnalysisReport {
|
||||
directory: directory.display().to_string(),
|
||||
logs_seen: 0,
|
||||
records: 0,
|
||||
alerts: Vec::new(),
|
||||
status: "skeleton",
|
||||
})
|
||||
}
|
||||
|
||||
pub fn classify_log(name: &str) -> ZeekLogKind {
|
||||
match name {
|
||||
"conn.log" => ZeekLogKind::Conn,
|
||||
"dns.log" => ZeekLogKind::Dns,
|
||||
"ssl.log" => ZeekLogKind::Ssl,
|
||||
"smb_mapping.log" => ZeekLogKind::SmbMapping,
|
||||
"dce_rpc.log" => ZeekLogKind::DceRpc,
|
||||
"rdp.log" => ZeekLogKind::Rdp,
|
||||
"ssh.log" => ZeekLogKind::Ssh,
|
||||
"kerberos.log" => ZeekLogKind::Kerberos,
|
||||
"ntlm.log" => ZeekLogKind::Ntlm,
|
||||
_ => ZeekLogKind::Unknown,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
use acaua_core::{doctor, fusion, pillar1, pillar2, pillar3, pillar4, storage, zeek, AcauaError};
|
||||
|
||||
#[test]
|
||||
fn pillar1_returns_a_skeleton_score() {
|
||||
let input = pillar1::BeaconingInput {
|
||||
timestamps: vec![1.0, 2.0, 3.0],
|
||||
};
|
||||
let score = pillar1::score(&input).unwrap();
|
||||
assert_eq!(score.observations, 3);
|
||||
assert_eq!(score.status, "skeleton");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pillar2_counts_long_connections_without_panicking() {
|
||||
let input = pillar2::SessionInput {
|
||||
durations_secs: vec![1.0, 400.0],
|
||||
bytes: vec![10, 20],
|
||||
};
|
||||
let score = pillar2::score(&input).unwrap();
|
||||
assert_eq!(score.long_connections, 1);
|
||||
assert_eq!(score.status, "skeleton");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pillar3_handles_an_empty_protocol_list() {
|
||||
let score = pillar3::score(&pillar3::ProtocolInput::default()).unwrap();
|
||||
assert_eq!(score.distinct_protocols, 0);
|
||||
assert_eq!(score.status, "skeleton");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pillar4_fuses_three_inputs() {
|
||||
let score = pillar4::score(&pillar4::FusionInput::default()).unwrap();
|
||||
assert_eq!(score.inputs, 3);
|
||||
assert_eq!(score.status, "skeleton");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fusion_returns_no_alerts_yet() {
|
||||
let report = fusion::fuse(&[0.1, 0.2]).unwrap();
|
||||
assert!(report.alerts.is_empty());
|
||||
assert_eq!(report.status, "skeleton");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_reports_the_reserved_backend() {
|
||||
let store = storage::Storage::open(std::env::temp_dir());
|
||||
let summary = store.summary().unwrap();
|
||||
assert_eq!(summary.ttl_days, 7);
|
||||
assert_eq!(summary.records, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zeek_rejects_a_missing_directory() {
|
||||
let error = zeek::analyze_dir("/definitely/not/a/zeek/directory").unwrap_err();
|
||||
assert!(matches!(error, AcauaError::InvalidInput(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zeek_classifies_known_logs() {
|
||||
assert_eq!(zeek::classify_log("dns.log"), zeek::ZeekLogKind::Dns);
|
||||
assert_eq!(zeek::classify_log("other.log"), zeek::ZeekLogKind::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doctor_reports_checks() {
|
||||
let report = doctor::run();
|
||||
assert!(!report.checks.is_empty());
|
||||
assert_eq!(report.status, "skeleton");
|
||||
}
|
||||
Reference in new issue
Block a user