From bdba5d1c83e3b50819344078370dc0ad5185671f Mon Sep 17 00:00:00 2001 From: ghule swarnit Date: Thu, 28 May 2026 18:29:39 +0530 Subject: [PATCH 01/69] feat: add fleet client connection module and update agent crate configurations --- .gitignore | 3 + Cargo.toml | 11 +- agent.toml | 21 ++ agent/Dockerfile | 87 ++++++- agent/crates/agent-bin/Cargo.toml | 14 ++ agent/crates/agent-bin/src/main.rs | 4 + agent/crates/agent-core/Cargo.toml | 17 +- agent/crates/agent-core/src/config.rs | 141 +++++++++++ agent/crates/agent-core/src/lib.rs | 3 +- agent/crates/agent-core/src/orchestrator.rs | 60 +++++ agent/crates/agent-tracing/Cargo.toml | 10 + agent/crates/agent-tracing/src/lib.rs | 49 ++++ agent/crates/event-buffer/Cargo.toml | 7 +- agent/crates/event-buffer/src/lib.rs | 93 +++++++- agent/crates/fleet-client/Cargo.toml | 5 +- agent/crates/fleet-client/src/connection.rs | 56 +++++ agent/crates/fleet-client/src/enrollment.rs | 25 ++ agent/crates/fleet-client/src/heartbeat.rs | 44 ++++ agent/crates/fleet-client/src/lib.rs | 73 +++++- agent/crates/fleet-client/src/stream.rs | 37 +++ agent/crates/fleet-client/src/types.rs | 237 +++++++++++++++++++ agent/crates/osquery-client/Cargo.toml | 7 +- agent/crates/osquery-client/src/client.rs | 49 ++++ agent/crates/osquery-client/src/diff.rs | 58 +++++ agent/crates/osquery-client/src/lib.rs | 52 +++- agent/crates/osquery-client/src/scheduler.rs | 81 +++++++ agent/crates/osquery-client/src/types.rs | 118 +++++++++ agent/todo.md | 150 ++++++++++++ agent/tools/mock-fleet-server/Cargo.toml | 22 ++ agent/tools/mock-fleet-server/build.rs | 7 + agent/tools/mock-fleet-server/src/main.rs | 116 +++++++++ fleet-server/src/grpc/main.rs | 0 fleet-server/src/grpc/testing.proto | 0 run-docker-agent.sh | 19 ++ 34 files changed, 1647 insertions(+), 29 deletions(-) create mode 100644 agent.toml create mode 100644 agent/crates/agent-bin/Cargo.toml create mode 100644 agent/crates/agent-bin/src/main.rs create mode 100644 agent/crates/agent-core/src/config.rs create mode 100644 agent/crates/agent-core/src/orchestrator.rs create mode 100644 agent/crates/agent-tracing/Cargo.toml create mode 100644 agent/crates/agent-tracing/src/lib.rs create mode 100644 agent/crates/fleet-client/src/connection.rs create mode 100644 agent/crates/fleet-client/src/enrollment.rs create mode 100644 agent/crates/fleet-client/src/heartbeat.rs create mode 100644 agent/crates/fleet-client/src/stream.rs create mode 100644 agent/crates/fleet-client/src/types.rs create mode 100644 agent/crates/osquery-client/src/client.rs create mode 100644 agent/crates/osquery-client/src/diff.rs create mode 100644 agent/crates/osquery-client/src/scheduler.rs create mode 100644 agent/crates/osquery-client/src/types.rs create mode 100644 agent/todo.md create mode 100644 agent/tools/mock-fleet-server/Cargo.toml create mode 100644 agent/tools/mock-fleet-server/build.rs create mode 100644 agent/tools/mock-fleet-server/src/main.rs create mode 100644 fleet-server/src/grpc/main.rs create mode 100644 fleet-server/src/grpc/testing.proto create mode 100755 run-docker-agent.sh diff --git a/.gitignore b/.gitignore index 78e14d0..95a2cc7 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ Thumbs.db *.key *.crt !infra/certs/.gitkeep +.antigravitycli +# Mock development tools +agent/tools/mock-fleet-server/target/ diff --git a/Cargo.toml b/Cargo.toml index 36b71be..61e4661 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,8 @@ members = [ "agent/crates/event-buffer", "agent/crates/fleet-client", "agent/crates/isolation", + "agent/crates/agent-bin", + "agent/crates/agent-tracing", ] exclude = [ "agent/crates/ebpf-collector", @@ -28,7 +30,7 @@ tokio-stream = "0.1" tokio-util = { version = "0.7", features = ["codec"] } tokio-tungstenite = "0.29" -tonic = "0.14" +tonic = { version = "0.14", features = ["prost"] } tonic-reflection = "0.14" tonic-build = "0.14" prost = "0.14" @@ -39,7 +41,7 @@ tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip"] rdkafka = { version = "0.39", features = ["cmake-build"] } -sqlx = { version = "0.8", features = ["postgres", "runtime-tokio-native-tls", "uuid", "chrono", "migrate"] } +sqlx = { version = "0.8", default-features = false, features = ["postgres", "runtime-tokio-native-tls", "uuid", "chrono", "migrate", "macros"] } serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -69,3 +71,8 @@ osquery-client = { path = "agent/crates/osquery-client" } event-buffer = { path = "agent/crates/event-buffer" } fleet-client = { path = "agent/crates/fleet-client" } isolation = { path = "agent/crates/isolation" } +agent-bin = { path = "agent/crates/agent-bin" } +agent-tracing = { path = "agent/crates/agent-tracing" } +rusqlite = { version = "0.31", features = ["bundled"] } +toml = "0.8" +thrift = "0.17" diff --git a/agent.toml b/agent.toml new file mode 100644 index 0000000..0a46841 --- /dev/null +++ b/agent.toml @@ -0,0 +1,21 @@ +[fleet] +endpoint = "http://host.docker.internal:50051" +heartbeat_interval_secs = 30 +batch_size = 100 + +[agent] +buffer_path = "/tmp/edr_event_buffer.db" +log_level = "info" +log_format = "human" + +[osquery] +socket_path = "/var/osquery/osquery.em" +connect_timeout_secs = 10 +schedule = [] + +[osquery.options] +disable_logging = true +disable_events = false +disable_audit = false +events_max = 50000 +watchdog_level = 0 diff --git a/agent/Dockerfile b/agent/Dockerfile index 2dbeca2..fee90b0 100644 --- a/agent/Dockerfile +++ b/agent/Dockerfile @@ -1,11 +1,82 @@ -# Agent Dockerfile — for testing only, real deployment is a static binary -FROM rust:1.85-slim-bookworm AS builder +FROM ubuntu:24.04 -RUN apt-get update && apt-get install -y \ - pkg-config libssl-dev cmake clang llvm \ - linux-headers-generic \ +ENV DEBIAN_FRONTEND=noninteractive + +# Install dependencies, GPG, and repository tools +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + gnupg \ + wget \ + net-tools \ + iptables \ + ipset \ + netfilter-persistent \ + git \ + openssh-server \ + sudo \ && rm -rf /var/lib/apt/lists/* -WORKDIR /build -COPY . . -RUN cargo build --release --bin agent-core +# Add official OSQuery repository and install OSQuery +RUN curl -L https://pkg.osquery.io/deb/pubkey.gpg | gpg --dearmor -o /usr/share/keyrings/osquery.gpg && \ + echo "deb [signed-by=/usr/share/keyrings/osquery.gpg] https://pkg.osquery.io/deb deb main" | tee /etc/apt/sources.list.d/osquery.list && \ + apt-get update && \ + apt-get install -y osquery protobuf-compiler build-essential pkg-config libssl-dev cmake && \ + rm -rf /var/lib/apt/lists/* + +# Install Rust +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y +ENV PATH="/root/.cargo/bin:${PATH}" +ENV CARGO_TARGET_DIR="/tmp/target" + +# Ensure required directories exist for osquery socket and configurations +RUN mkdir -p /var/osquery /etc/osquery /var/log/osquery + +# Setup osquery.flags to enable the eventing and auditing subsystems +RUN echo '\ +--disable_events=true\n\ +--disable_audit=true\n\ +--audit_allow_process_events=false\n\ +--audit_allow_sockets=false\n\ +--audit_allow_config=false\n\ +--audit_persist=false\n\ +--events_max=50000\n\ +--socket=/var/osquery/osquery.em\n\ +' > /etc/osquery/osquery.flags + +# Setup basic osquery.conf with options and schedules +RUN echo '{\n\ + "options": {\n\ + "disable_events": "true",\n\ + "disable_audit": "true",\n\ + "audit_allow_process_events": "false",\n\ + "audit_allow_sockets": "false",\n\ + "audit_allow_config": "false",\n\ + "audit_persist": "false",\n\ + "events_max": "50000",\n\ + "host_identifier": "hostname",\n\ + "schedule_splay_percent": "10"\n\ + },\n\ + "schedule": {\n\ + "running_processes": {\n\ + "query": "SELECT pid, name, path, cmdline, uid, parent FROM processes;",\n\ + "interval": 30\n\ + }\n\ + }\n\ +}' > /etc/osquery/osquery.conf + +# Expose directory for volume mounting if needed +VOLUME ["/var/osquery", "/etc/osquery"] + +# Setup startup entrypoint script to launch osqueryd in background and start shell +RUN echo '#!/bin/bash\n\ +# Start osqueryd daemon in background\n\ +echo "Starting osqueryd..."\n\ +osqueryd --flagfile=/etc/osquery/osquery.flags --config_path=/etc/osquery/osquery.conf --verbose &\n\ +\n\ +# Execute passed command or default to bash\n\ +exec "$@"\n\ +' > /usr/local/bin/entrypoint.sh && chmod +x /usr/local/bin/entrypoint.sh + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] +CMD ["/bin/bash"] diff --git a/agent/crates/agent-bin/Cargo.toml b/agent/crates/agent-bin/Cargo.toml new file mode 100644 index 0000000..23d2753 --- /dev/null +++ b/agent/crates/agent-bin/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "agent-bin" +edition.workspace = true +version.workspace = true +rust-version.workspace = true + +[[bin]] +name = "edr-agent" +path = "src/main.rs" + +[dependencies] +agent-core = { workspace = true } +tokio = { workspace = true } +anyhow = { workspace = true } diff --git a/agent/crates/agent-bin/src/main.rs b/agent/crates/agent-bin/src/main.rs new file mode 100644 index 0000000..33158c6 --- /dev/null +++ b/agent/crates/agent-bin/src/main.rs @@ -0,0 +1,4 @@ +#[tokio::main] +async fn main() -> anyhow::Result<()> { + agent_core::orchestrator::run().await +} diff --git a/agent/crates/agent-core/Cargo.toml b/agent/crates/agent-core/Cargo.toml index 591b2ad..a37b234 100644 --- a/agent/crates/agent-core/Cargo.toml +++ b/agent/crates/agent-core/Cargo.toml @@ -6,16 +6,15 @@ rust-version.workspace = true [dependencies] tokio = { workspace = true } -tracing = { workspace = true } -tracing-subscriber = { workspace = true } anyhow = { workspace = true } +tracing = { workspace = true } serde = { workspace = true } -config = { workspace = true } -edr-sdk = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +toml = { workspace = true } +prost = { workspace = true } +agent-tracing = { workspace = true } +fleet-client = { workspace = true } osquery-client = { workspace = true } event-buffer = { workspace = true } -fleet-client = { workspace = true } -isolation = { workspace = true } - -[target.'cfg(target_os = "linux")'.dependencies] -ebpf-collector = { path = "../ebpf-collector" } diff --git a/agent/crates/agent-core/src/config.rs b/agent/crates/agent-core/src/config.rs new file mode 100644 index 0000000..3c51bb8 --- /dev/null +++ b/agent/crates/agent-core/src/config.rs @@ -0,0 +1,141 @@ +use serde::Deserialize; +use std::collections::HashMap; +use std::path::PathBuf; +use uuid::Uuid; + +/// Root agent configuration. Read from /etc/edr/agent.toml +#[derive(Debug, Deserialize)] +pub struct AgentConfig { + pub fleet: FleetConfig, + pub agent: AgentSection, + pub osquery: OsqueryConfig, +} + +/// Fleet server connection settings. +#[derive(Debug, Deserialize)] +pub struct FleetConfig { + /// gRPC endpoint, e.g., "http://fleet.internal:50051" + pub endpoint: String, + + /// Heartbeat interval in seconds (default 30, overridden by fleet server) + pub heartbeat_interval_secs: u64, + + /// Max events per gRPC batch send + pub batch_size: u32, +} + +/// Agent identity and runtime settings. +#[derive(Debug, Deserialize)] +pub struct AgentSection { + /// UUID assigned after first enrollment. None = not yet enrolled. + pub node_id: Option, + + /// Path to the SQLite database for event buffering + query storage + pub buffer_path: PathBuf, + + /// Log level filter: "trace" | "debug" | "info" | "warn" | "error" + pub log_level: String, + + /// Log output format: "human" (default, colored) | "json" (structured) + pub log_format: Option, +} + +/// OSQuery daemon configuration. +#[derive(Debug, Deserialize)] +pub struct OsqueryConfig { + // ── Connection ────────────────────────────────────── + /// Path to osqueryd's extension manager Unix socket + /// Default: /var/osquery/osquery.em + pub socket_path: PathBuf, + + /// Connection timeout in seconds when connecting to the socket + pub connect_timeout_secs: Option, + + // ── Daemon Options (mirrors osquery.conf "options") ─ + pub options: OsqueryOptions, + + // ── Initial Scheduled Queries ─────────────────────── + /// Bootstrap queries (overridden by fleet server push) + pub schedule: Vec, + + // ── File Integrity Monitoring ─────────────────────── + /// FIM paths: category_name → list of glob paths + /// e.g., { "etc": ["/etc/%%", "/etc/ssh/%%"] } + pub file_paths: Option>>, + + // ── Query Packs ───────────────────────────────────── + /// Named packs: pack_name → path_to_pack_conf_file + pub packs: Option>, +} + +/// OSQuery daemon option flags. +/// Maps to the "options" section of osquery.conf. +/// All fields are Optional — only set values override osquery defaults. +#[derive(Debug, Deserialize)] +pub struct OsqueryOptions { + // ── Core Daemon ───────────────────────────────────── + /// How config is retrieved: "filesystem" | "tls" + pub config_plugin: Option, + /// Where to send logs: "filesystem" | "syslog" | "tls" + pub logger_plugin: Option, + /// Disable all logging if true + pub disable_logging: Option, + /// Disable event-based tables if true + pub disable_events: Option, + /// Disable kernel audit subsystem if true + pub disable_audit: Option, + + // ── Audit Subsystem ───────────────────────────────── + /// Enable process execution events via audit + pub audit_allow_process_events: Option, + /// Enable socket events via audit + pub audit_allow_sockets: Option, + /// Enable config change events via audit + pub audit_allow_config: Option, + /// Attempt to persist audit rules across osquery restarts + pub audit_persist: Option, + + // ── Performance ───────────────────────────────────── + /// Maximum number of events to buffer (default 50000) + pub events_max: Option, + /// Randomize query start times by this percentage (0-100) + pub schedule_splay_percent: Option, + /// Resource watchdog aggressiveness level + pub watchdog_level: Option, + /// Number of worker threads for query dispatch + pub worker_threads: Option, + + // ── Identity ──────────────────────────────────────── + /// How to identify the host: "hostname" | "uuid" | "instance" | "specified" + pub host_identifier: Option, + /// Custom identifier string when host_identifier = "specified" + pub specified_identifier: Option, + + // ── Database ──────────────────────────────────────── + /// Path to the RocksDB database (default /var/osquery/osquery.db) + pub database_path: Option, + + // ── Security ──────────────────────────────────────── + /// Comma-delimited list of tables to disable + pub disable_tables: Option, + /// Comma-delimited list of tables to explicitly enable + pub enable_tables: Option, + + // ── Time ──────────────────────────────────────────── + /// Log timestamps in UTC if true + pub utc: Option, +} + +/// A scheduled query definition in the TOML config file. +#[derive(Debug, Deserialize)] +pub struct ScheduledQueryConfig { + pub name: String, + pub query: String, + pub interval_secs: u64, + /// true = full snapshot each time, false = differential (default) + pub snapshot: Option, + /// Track removed rows in differential mode (default true) + pub removed: Option, + /// Restrict to specific platform: "linux" | "darwin" | "windows" + pub platform: Option, +} diff --git a/agent/crates/agent-core/src/lib.rs b/agent/crates/agent-core/src/lib.rs index 61c94e4..b2b1067 100644 --- a/agent/crates/agent-core/src/lib.rs +++ b/agent/crates/agent-core/src/lib.rs @@ -1 +1,2 @@ -// Agent core — orchestrator for the endpoint agent. +pub mod config; +pub mod orchestrator; diff --git a/agent/crates/agent-core/src/orchestrator.rs b/agent/crates/agent-core/src/orchestrator.rs new file mode 100644 index 0000000..1e6f88b --- /dev/null +++ b/agent/crates/agent-core/src/orchestrator.rs @@ -0,0 +1,60 @@ +use crate::config::AgentConfig; +use anyhow::Result; +use event_buffer::EventBuffer; +use fleet_client::{ + types::{AgentEvent, EventType, RegisterRequest}, + FleetClient, +}; +use osquery_client::OsqueryCollector; +use prost::Message; + +pub async fn run() -> Result<()> { + let config_path = std::env::var("EDR_AGENT_CONFIG") + .unwrap_or_else(|_| "agent.toml".to_string()); + + let config_str = std::fs::read_to_string(&config_path) + .map_err(|e| anyhow::anyhow!("Failed to read config file at {}: {}", config_path, e))?; + + let config: AgentConfig = toml::from_str(&config_str) + .map_err(|e| anyhow::anyhow!("Failed to parse TOML config: {}", e))?; + + let format = match config.agent.log_format.as_deref() { + Some("json") => agent_tracing::LogFormat::Json, + _ => agent_tracing::LogFormat::Human, + }; + + agent_tracing::init(&config.agent.log_level, format)?; + tracing::info!("Starting EDR Agent Orchestrator"); + + let _buffer = EventBuffer::new(&config.agent.buffer_path)?; + tracing::info!("Initialized event buffer at {:?}", config.agent.buffer_path); + + let mut fleet_client = fleet_client::FleetClient::new(fleet_client::FleetConfig { + endpoint: config.fleet.endpoint.clone(), + }) + .await?; + + let req = RegisterRequest { + hostname: "mock-hostname".to_string(), + os_version: "mock-os".to_string(), + agent_version: "0.1.0".to_string(), + machine_id: "mock-machine-id".to_string(), + }; + + match fleet_client.enroll(req).await { + Ok(enrollment) => { + tracing::info!("Enrolled successfully with node_id: {}", enrollment.node_id); + } + Err(e) => { + tracing::error!("Failed to enroll: {}", e); + // We would continue and buffer locally, but stub for now. + } + } + + // Stub: We would also create OsqueryCollector and route events here. + + // Keeping main alive + tokio::time::sleep(tokio::time::Duration::from_secs(60)).await; + + Ok(()) +} diff --git a/agent/crates/agent-tracing/Cargo.toml b/agent/crates/agent-tracing/Cargo.toml new file mode 100644 index 0000000..f9985a7 --- /dev/null +++ b/agent/crates/agent-tracing/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "agent-tracing" +edition.workspace = true +version.workspace = true + +[dependencies] +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +chrono = { workspace = true } +anyhow = { workspace = true } diff --git a/agent/crates/agent-tracing/src/lib.rs b/agent/crates/agent-tracing/src/lib.rs new file mode 100644 index 0000000..824158a --- /dev/null +++ b/agent/crates/agent-tracing/src/lib.rs @@ -0,0 +1,49 @@ +use anyhow::Result; +use tracing_subscriber::{fmt, EnvFilter}; + +/// Log output format. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LogFormat { + /// Pretty-printed, colored, human-readable (for development) + Human, + /// Structured JSON (for production / log aggregation) + Json, +} + +impl Default for LogFormat { + fn default() -> Self { + Self::Human + } +} + +/// Initialize the agent's tracing/logging infrastructure. +pub fn init(log_level: &str, format: LogFormat) -> Result<()> { + let filter = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new(log_level)); + + match format { + LogFormat::Human => { + fmt() + .with_env_filter(filter) + .with_target(true) + .with_thread_ids(true) + .with_thread_names(true) + .with_file(true) + .with_line_number(true) + .init(); + } + LogFormat::Json => { + fmt() + .json() + .with_env_filter(filter) + .with_target(true) + .with_thread_ids(true) + .with_thread_names(true) + .with_file(true) + .with_line_number(true) + .init(); + } + } + + Ok(()) +} diff --git a/agent/crates/event-buffer/Cargo.toml b/agent/crates/event-buffer/Cargo.toml index 8896cca..57e9892 100644 --- a/agent/crates/event-buffer/Cargo.toml +++ b/agent/crates/event-buffer/Cargo.toml @@ -5,9 +5,8 @@ version.workspace = true rust-version.workspace = true [dependencies] -sled = { workspace = true } +rusqlite = { workspace = true } tokio = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } anyhow = { workspace = true } -edr-sdk = { workspace = true } +tracing = { workspace = true } +chrono = { workspace = true } diff --git a/agent/crates/event-buffer/src/lib.rs b/agent/crates/event-buffer/src/lib.rs index 0d9a072..d76ce49 100644 --- a/agent/crates/event-buffer/src/lib.rs +++ b/agent/crates/event-buffer/src/lib.rs @@ -1 +1,92 @@ -// Event buffer — local event spooling with sled. +use anyhow::Result; +use rusqlite::Connection; +use std::path::Path; + +/// Local SQLite-backed buffer for protobuf-encoded AgentEvent bytes. +/// Used when the fleet server is unreachable. +pub struct EventBuffer { + conn: Connection, +} + +impl EventBuffer { + /// Open or create the SQLite database at the given path. + /// Creates the event_buffer table if it doesn't exist. + pub fn new(db_path: &Path) -> Result { + let conn = Connection::open(db_path)?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS event_buffer ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + payload BLOB NOT NULL, + created_at INTEGER NOT NULL + )", + [], + )?; + + Ok(Self { conn }) + } + + /// Store a protobuf-encoded AgentEvent as a BLOB. + /// The bytes come from AgentEvent::encode_to_vec(). + pub fn push(&self, event_bytes: &[u8]) -> Result<()> { + let now = chrono::Utc::now().timestamp(); + self.conn.execute( + "INSERT INTO event_buffer (payload, created_at) VALUES (?1, ?2)", + rusqlite::params![event_bytes, now], + )?; + Ok(()) + } + + /// Read and remove the oldest `batch_size` events. + /// Returns raw protobuf bytes that can be decoded back + /// with AgentEvent::decode(&bytes). + pub fn drain(&self, batch_size: usize) -> Result>> { + let mut stmt = self + .conn + .prepare("SELECT id, payload FROM event_buffer ORDER BY id ASC LIMIT ?1")?; + + let mut events = Vec::new(); + let mut ids = Vec::new(); + + let rows = stmt.query_map([batch_size], |row| { + let id: i64 = row.get(0)?; + let payload: Vec = row.get(1)?; + Ok((id, payload)) + })?; + + for row in rows { + let (id, payload) = row?; + ids.push(id); + events.push(payload); + } + + if !ids.is_empty() { + let id_list = ids + .iter() + .map(|id| id.to_string()) + .collect::>() + .join(","); + self.conn.execute( + &format!("DELETE FROM event_buffer WHERE id IN ({})", id_list), + [], + )?; + } + + Ok(events) + } + + /// Count of events currently buffered (for heartbeat reporting). + pub fn len(&self) -> Result { + let count: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM event_buffer", + [], + |row| row.get(0), + )?; + Ok(count as usize) + } + + /// Whether the buffer is empty. + pub fn is_empty(&self) -> Result { + Ok(self.len()? == 0) + } +} diff --git a/agent/crates/fleet-client/Cargo.toml b/agent/crates/fleet-client/Cargo.toml index 9f4f6d1..1116cd5 100644 --- a/agent/crates/fleet-client/Cargo.toml +++ b/agent/crates/fleet-client/Cargo.toml @@ -11,4 +11,7 @@ tokio-stream = { workspace = true } tower = { workspace = true } anyhow = { workspace = true } tracing = { workspace = true } -edr-sdk = { workspace = true } +prost = { workspace = true } +uuid = { workspace = true } +serde = { workspace = true } +http = "1.4.1" diff --git a/agent/crates/fleet-client/src/connection.rs b/agent/crates/fleet-client/src/connection.rs new file mode 100644 index 0000000..34a7f0d --- /dev/null +++ b/agent/crates/fleet-client/src/connection.rs @@ -0,0 +1,56 @@ +use crate::types::ConnectionState; +use anyhow::Result; +use std::time::Duration; +use tokio::sync::watch; +use tonic::transport::{Channel, Endpoint}; + +pub struct FleetConnection { + channel: Option, + endpoint: String, + state_tx: watch::Sender, +} + +impl FleetConnection { + pub fn new(endpoint: &str) -> (Self, watch::Receiver) { + let (state_tx, state_rx) = watch::channel(ConnectionState::Disconnected); + ( + Self { + channel: None, + endpoint: endpoint.to_string(), + state_tx, + }, + state_rx, + ) + } + + pub async fn connect(&mut self) -> Result { + let mut backoff = Duration::from_secs(1); + let max_backoff = Duration::from_secs(60); + + loop { + let _ = self.state_tx.send(ConnectionState::Reconnecting); + tracing::info!("Connecting to fleet server at {}...", self.endpoint); + + match Endpoint::from_shared(self.endpoint.clone()) { + Ok(endpoint) => match endpoint.connect().await { + Ok(channel) => { + tracing::info!("Successfully connected to fleet server."); + let _ = self.state_tx.send(ConnectionState::Connected); + self.channel = Some(channel.clone()); + return Ok(channel); + } + Err(e) => { + tracing::warn!("Failed to connect to fleet server: {}. Retrying in {:?}", e, backoff); + } + }, + Err(e) => { + tracing::error!("Invalid fleet server endpoint {}: {}", self.endpoint, e); + // If endpoint is invalid, backoff and retry might not help, but we shouldn't panic. + } + } + + tokio::time::sleep(backoff).await; + backoff = std::cmp::min(backoff * 2, max_backoff); + } + } +} diff --git a/agent/crates/fleet-client/src/enrollment.rs b/agent/crates/fleet-client/src/enrollment.rs new file mode 100644 index 0000000..668c7bb --- /dev/null +++ b/agent/crates/fleet-client/src/enrollment.rs @@ -0,0 +1,25 @@ +use crate::types::{EnrollmentResult, RegisterRequest, RegisterResponse}; +use anyhow::Result; +use tonic::{transport::Channel, Request, client::Grpc, codec::ProstCodec}; + +pub struct AgentEnrollment; + +impl AgentEnrollment { + pub async fn enroll(channel: Channel, request: RegisterRequest) -> Result { + tracing::info!("Enrolling agent: {:?}", request.hostname); + + let mut client = Grpc::new(channel); + let path = http::uri::PathAndQuery::from_static("/edr.fleet.FleetService/RegisterAgent"); + + let res = client + .unary(Request::new(request), path, ProstCodec::default()) + .await? + .into_inner(); + + Ok(EnrollmentResult { + node_id: res.node_id, + token: res.token, + config: res.config, + }) + } +} diff --git a/agent/crates/fleet-client/src/heartbeat.rs b/agent/crates/fleet-client/src/heartbeat.rs new file mode 100644 index 0000000..c8185fc --- /dev/null +++ b/agent/crates/fleet-client/src/heartbeat.rs @@ -0,0 +1,44 @@ +use crate::types::{HeartbeatRequest, HeartbeatResponse}; +use anyhow::Result; +use std::time::Duration; +use tokio::time; +use tonic::{transport::Channel, Request, client::Grpc, codec::ProstCodec, metadata::MetadataValue}; + +pub struct HeartbeatManager; + +impl HeartbeatManager { + pub async fn start( + channel: Channel, + token: String, + node_id: String, + interval_secs: u64, + ) -> Result<()> { + let mut interval = time::interval(Duration::from_secs(interval_secs)); + + tokio::spawn(async move { + loop { + interval.tick().await; + tracing::debug!("Sending heartbeat for node: {}", node_id); + + let req_payload = HeartbeatRequest { + node_id: node_id.clone(), + status: "healthy".to_string(), + events_buffered: 0, + }; + + let mut client = Grpc::new(channel.clone()); + let path = http::uri::PathAndQuery::from_static("/edr.fleet.FleetService/Heartbeat"); + let mut req = Request::new(req_payload); + if let Ok(meta_token) = MetadataValue::try_from(format!("Bearer {}", token)) { + req.metadata_mut().insert("authorization", meta_token); + } + + if let Err(e) = client.unary(req, path, ProstCodec::::default()).await { + tracing::warn!("Failed to send heartbeat: {}", e); + } + } + }); + + Ok(()) + } +} diff --git a/agent/crates/fleet-client/src/lib.rs b/agent/crates/fleet-client/src/lib.rs index 2605bb3..96533d8 100644 --- a/agent/crates/fleet-client/src/lib.rs +++ b/agent/crates/fleet-client/src/lib.rs @@ -1 +1,72 @@ -// Fleet client — gRPC client for fleet-server communication. +pub mod connection; +pub mod enrollment; +pub mod heartbeat; +pub mod stream; +pub mod types; + +use crate::connection::FleetConnection; +use crate::enrollment::AgentEnrollment; +use crate::heartbeat::HeartbeatManager; +use crate::stream::EventStreamManager; +use crate::types::{AgentEvent, ConnectionState, EnrollmentResult, RegisterRequest, ServerCommand}; +use anyhow::{anyhow, Result}; +use tokio::sync::{mpsc, watch}; + +pub struct FleetConfig { + pub endpoint: String, +} + +pub struct FleetClient { + connection: FleetConnection, + state_rx: watch::Receiver, + enrollment: Option, +} + +impl FleetClient { + pub async fn new(config: FleetConfig) -> Result { + let (connection, state_rx) = FleetConnection::new(&config.endpoint); + Ok(Self { + connection, + state_rx, + enrollment: None, + }) + } + + pub async fn connect(&mut self) -> Result<()> { + self.connection.connect().await?; + Ok(()) + } + + pub async fn enroll(&mut self, request: RegisterRequest) -> Result { + // We wait for the channel to be ready. + let channel = self.connection.connect().await?; + let result = AgentEnrollment::enroll(channel, request).await?; + self.enrollment = Some(result.clone()); + Ok(result) + } + + pub async fn start_stream( + &mut self, + events_rx: mpsc::Receiver, + ) -> Result> { + let channel = self.connection.connect().await?; + let token = self + .enrollment + .as_ref() + .ok_or_else(|| anyhow!("Not enrolled"))? + .token + .clone(); + EventStreamManager::start(channel, token, events_rx).await + } + + pub async fn start_heartbeat(&mut self, interval_secs: u64) -> Result<()> { + let channel = self.connection.connect().await?; + let enrollment = self + .enrollment + .as_ref() + .ok_or_else(|| anyhow!("Not enrolled"))?; + let token = enrollment.token.clone(); + let node_id = enrollment.node_id.clone(); + HeartbeatManager::start(channel, token, node_id, interval_secs).await + } +} diff --git a/agent/crates/fleet-client/src/stream.rs b/agent/crates/fleet-client/src/stream.rs new file mode 100644 index 0000000..4ea04fd --- /dev/null +++ b/agent/crates/fleet-client/src/stream.rs @@ -0,0 +1,37 @@ +use crate::types::{AgentEvent, ServerCommand}; +use anyhow::Result; +use tokio::sync::mpsc; +use tonic::{transport::Channel, Request, client::Grpc, codec::ProstCodec, metadata::MetadataValue}; +use tokio_stream::wrappers::ReceiverStream; + +pub struct EventStreamManager; + +impl EventStreamManager { + pub async fn start( + channel: Channel, + token: String, + events_rx: mpsc::Receiver, + ) -> Result> { + let mut client = Grpc::new(channel); + let path = http::uri::PathAndQuery::from_static("/edr.fleet.FleetService/EventStream"); + + let mut req = Request::new(ReceiverStream::new(events_rx)); + let meta_token = MetadataValue::try_from(format!("Bearer {}", token))?; + req.metadata_mut().insert("authorization", meta_token); + + let response = client.streaming(req, path, ProstCodec::::default()).await?; + let mut stream = response.into_inner(); + + let (server_cmd_tx, server_cmd_rx) = mpsc::channel(100); + + tokio::spawn(async move { + while let Ok(Some(cmd)) = stream.message().await { + if server_cmd_tx.send(cmd).await.is_err() { + break; + } + } + }); + + Ok(server_cmd_rx) + } +} diff --git a/agent/crates/fleet-client/src/types.rs b/agent/crates/fleet-client/src/types.rs new file mode 100644 index 0000000..2a4e1ea --- /dev/null +++ b/agent/crates/fleet-client/src/types.rs @@ -0,0 +1,237 @@ +use prost::Message; +use serde::{Deserialize, Serialize}; + +// ═════════════════════════════════════════════════════════ +// ENUMS +// ═════════════════════════════════════════════════════════ + +/// Type of event being sent from agent to fleet server. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(i32)] +pub enum EventType { + Osquery = 0, + Process = 1, + File = 2, + Network = 3, +} + +/// Current operational status of the agent. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(i32)] +pub enum AgentStatus { + Healthy = 0, + Degraded = 1, + Isolated = 2, +} + +impl AgentStatus { + pub fn as_str(&self) -> &'static str { + match self { + AgentStatus::Healthy => "healthy", + AgentStatus::Degraded => "degraded", + AgentStatus::Isolated => "isolated", + } + } +} + +/// Connection state of the gRPC channel. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConnectionState { + Connected, + Reconnecting, + Disconnected, +} + +// ═════════════════════════════════════════════════════════ +// ENROLLMENT MESSAGES +// ═════════════════════════════════════════════════════════ + +/// Sent by the agent to register with the fleet server. +/// Proto tag numbers match fleet.proto RegisterRequest. +#[derive(Clone, Message)] +pub struct RegisterRequest { + #[prost(string, tag = "1")] + pub hostname: String, + + #[prost(string, tag = "2")] + pub os_version: String, + + #[prost(string, tag = "3")] + pub agent_version: String, + + /// Read from /etc/machine-id on Linux + #[prost(string, tag = "4")] + pub machine_id: String, +} + +/// Returned by the fleet server after successful enrollment. +#[derive(Clone, Message)] +pub struct RegisterResponse { + /// UUID assigned by the fleet server — the agent's permanent identity + #[prost(string, tag = "1")] + pub node_id: String, + + /// JWT token for authenticating subsequent gRPC calls + #[prost(string, tag = "2")] + pub token: String, + + /// Initial agent configuration (scheduled queries, intervals, etc.) + #[prost(message, optional, tag = "3")] + pub config: Option, +} + +/// Result type returned after enrollment completes. +/// (Not a protobuf message — internal Rust type only) +#[derive(Debug, Clone)] +pub struct EnrollmentResult { + pub node_id: String, + pub token: String, + pub config: Option, +} + +// ═════════════════════════════════════════════════════════ +// EVENT STREAM MESSAGES +// ═════════════════════════════════════════════════════════ + +/// An event sent from the agent to the fleet server over the +/// bidirectional gRPC stream. The `payload` field contains +/// protobuf-encoded event data (e.g., OsqueryResult.encode_to_vec()). +#[derive(Clone, Message)] +pub struct AgentEvent { + /// UUID of the agent (assigned during enrollment) + #[prost(string, tag = "1")] + pub node_id: String, + + /// Type of event: 0=osquery, 1=process, 2=file, 3=network + #[prost(int32, tag = "2")] + pub event_type: i32, + + /// Protobuf-encoded payload (e.g., OsqueryResult bytes) + #[prost(bytes = "vec", tag = "3")] + pub payload: Vec, + + /// Timestamp in nanoseconds since Unix epoch + #[prost(int64, tag = "4")] + pub timestamp_ns: i64, + + /// UUID v4 for deduplication and acknowledgment tracking + #[prost(string, tag = "5")] + pub sequence_id: String, +} + +// ═════════════════════════════════════════════════════════ +// SERVER COMMAND MESSAGES (fleet server → agent) +// ═════════════════════════════════════════════════════════ + +/// A command sent from the fleet server to the agent. +/// Uses prost oneof to match the proto3 `oneof command { ... }`. +#[derive(Clone, Message)] +pub struct ServerCommand { + #[prost(oneof = "ServerCommandType", tags = "1, 2, 3")] + pub command: Option, +} + +/// The actual command variant (maps to proto3 oneof). +#[derive(Clone, prost::Oneof)] +pub enum ServerCommandType { + #[prost(message, tag = "1")] + Isolate(IsolateCommand), + + #[prost(message, tag = "2")] + ConfigUpdate(ConfigUpdateCommand), + + #[prost(message, tag = "3")] + Ack(AckCommand), +} + +/// Command to isolate or de-isolate the node. +#[derive(Clone, Message)] +pub struct IsolateCommand { + /// true = isolate (block all traffic except fleet server) + /// false = de-isolate (restore normal networking) + #[prost(bool, tag = "1")] + pub isolate: bool, + + /// Human-readable reason for the isolation + #[prost(string, tag = "2")] + pub reason: String, +} + +/// Command to update the agent's configuration. +#[derive(Clone, Message)] +pub struct ConfigUpdateCommand { + #[prost(message, optional, tag = "1")] + pub config: Option, +} + +/// Acknowledgment that the server received a specific event. +#[derive(Clone, Message)] +pub struct AckCommand { + /// The sequence_id of the AgentEvent being acknowledged + #[prost(string, tag = "1")] + pub sequence_id: String, +} + +// ═════════════════════════════════════════════════════════ +// AGENT CONFIGURATION (pushed by fleet server) +// ═════════════════════════════════════════════════════════ + +/// Configuration payload sent from fleet server to agent. +/// Stored locally in SQLite after receipt. +#[derive(Clone, Message, Serialize, Deserialize)] +pub struct AgentConfigPayload { + /// List of scheduled queries to execute via OSQuery + #[prost(message, repeated, tag = "1")] + pub osquery_schedule: Vec, + + /// How often to send heartbeats (seconds) + #[prost(int32, tag = "2")] + pub heartbeat_interval_secs: i32, + + /// Max number of events to batch before sending + #[prost(int32, tag = "3")] + pub batch_size: i32, +} + +/// A single scheduled query definition (from fleet server config). +#[derive(Clone, Message, Serialize, Deserialize)] +pub struct OsquerySchedule { + /// Unique name (e.g., "running_processes") + #[prost(string, tag = "1")] + pub name: String, + + /// SQL query to execute + #[prost(string, tag = "2")] + pub query: String, + + /// Interval in seconds + #[prost(int32, tag = "3")] + pub interval_secs: i32, +} + +// ═════════════════════════════════════════════════════════ +// HEARTBEAT MESSAGES +// ═════════════════════════════════════════════════════════ + +/// Periodic heartbeat sent from agent to fleet server. +#[derive(Clone, Message)] +pub struct HeartbeatRequest { + /// Agent's UUID + #[prost(string, tag = "1")] + pub node_id: String, + + /// Current status: "healthy" | "degraded" | "isolated" + #[prost(string, tag = "2")] + pub status: String, + + /// Number of events currently buffered locally in SQLite + #[prost(int64, tag = "3")] + pub events_buffered: i64, +} + +/// Fleet server's response to a heartbeat. +#[derive(Clone, Message)] +pub struct HeartbeatResponse { + #[prost(bool, tag = "1")] + pub ok: bool, +} diff --git a/agent/crates/osquery-client/Cargo.toml b/agent/crates/osquery-client/Cargo.toml index de967cf..f13976b 100644 --- a/agent/crates/osquery-client/Cargo.toml +++ b/agent/crates/osquery-client/Cargo.toml @@ -10,4 +10,9 @@ tokio-util = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } -edr-sdk = { workspace = true } +tracing = { workspace = true } +chrono = { workspace = true } +uuid = { workspace = true } +prost = { workspace = true } +thrift = "0.17" +rusqlite = { workspace = true } diff --git a/agent/crates/osquery-client/src/client.rs b/agent/crates/osquery-client/src/client.rs new file mode 100644 index 0000000..472cbeb --- /dev/null +++ b/agent/crates/osquery-client/src/client.rs @@ -0,0 +1,49 @@ +use crate::types::{QueryResponse, QueryStatus}; +use anyhow::Result; +use std::path::{Path, PathBuf}; + +pub struct OsqueryClient { + socket_path: PathBuf, +} + +impl OsqueryClient { + pub async fn connect(socket_path: &Path) -> Result { + Ok(Self { + socket_path: socket_path.to_path_buf(), + }) + } + + pub async fn query(&mut self, sql: &str) -> Result { + // Stub for now. Will require Thrift serialization over UnixStream. + tracing::debug!("Executing query: {}", sql); + Ok(QueryResponse { + status: QueryStatus { + code: 0, + message: "OK".to_string(), + }, + rows: vec![], + }) + } + + pub async fn get_query_columns(&mut self, _sql: &str) -> Result { + Ok(QueryResponse { + status: QueryStatus { + code: 0, + message: "OK".to_string(), + }, + rows: vec![], + }) + } + + pub async fn ping(&mut self) -> Result<()> { + Ok(()) + } + + pub async fn reconnect(&mut self) -> Result<()> { + Ok(()) + } + + pub async fn live_query(&mut self, sql: &str) -> Result { + self.query(sql).await + } +} diff --git a/agent/crates/osquery-client/src/diff.rs b/agent/crates/osquery-client/src/diff.rs new file mode 100644 index 0000000..2edd334 --- /dev/null +++ b/agent/crates/osquery-client/src/diff.rs @@ -0,0 +1,58 @@ +use crate::types::OsqueryRow; +use std::collections::HashSet; +use std::hash::{Hash, Hasher}; + +/// A wrapper around OsqueryRow to allow hashing and equality comparisons. +/// We sort the keys to ensure consistent hashing. +#[derive(Debug, Clone)] +struct HashableRow(OsqueryRow); + +impl PartialEq for HashableRow { + fn eq(&self, other: &Self) -> bool { + self.0 == other.0 + } +} + +impl Eq for HashableRow {} + +impl Hash for HashableRow { + fn hash(&self, state: &mut H) { + let mut keys: Vec<&String> = self.0.keys().collect(); + keys.sort(); + for key in keys { + key.hash(state); + if let Some(val) = self.0.get(key) { + val.hash(state); + } + } + } +} + +/// Computes the differential between two sets of rows. +/// Returns (added_rows, removed_rows). +pub fn compute_diff( + previous_rows: &[OsqueryRow], + current_rows: &[OsqueryRow], +) -> (Vec, Vec) { + let mut prev_set: HashSet = HashSet::new(); + for row in previous_rows { + prev_set.insert(HashableRow(row.clone())); + } + + let mut curr_set: HashSet = HashSet::new(); + for row in current_rows { + curr_set.insert(HashableRow(row.clone())); + } + + let added: Vec = curr_set + .difference(&prev_set) + .map(|h_row| h_row.0.clone()) + .collect(); + + let removed: Vec = prev_set + .difference(&curr_set) + .map(|h_row| h_row.0.clone()) + .collect(); + + (added, removed) +} diff --git a/agent/crates/osquery-client/src/lib.rs b/agent/crates/osquery-client/src/lib.rs index ec6ec5e..be09484 100644 --- a/agent/crates/osquery-client/src/lib.rs +++ b/agent/crates/osquery-client/src/lib.rs @@ -1 +1,51 @@ -// osquery client — interface to osquery for host inventory. +pub mod client; +pub mod diff; +pub mod scheduler; +pub mod types; + +use crate::client::OsqueryClient; +use crate::scheduler::QueryScheduler; +use crate::types::{OsqueryResult, QueryResponse, ScheduledQuery}; +use anyhow::Result; +use std::path::PathBuf; +use tokio::sync::mpsc; + +pub struct OsqueryConfig { + pub socket_path: PathBuf, + pub db_path: PathBuf, +} + +pub struct OsqueryCollector { + config: OsqueryConfig, +} + +impl OsqueryCollector { + pub async fn new(config: OsqueryConfig) -> Result { + Ok(Self { config }) + } + + pub async fn start(&self) -> mpsc::Receiver { + let (tx, rx) = mpsc::channel(100); + + let scheduler_db_path = self.config.db_path.clone(); + + tokio::spawn(async move { + if let Ok(scheduler) = QueryScheduler::new(&scheduler_db_path) { + scheduler.run(tx).await; + } + }); + + rx + } + + pub async fn live_query(&self, sql: &str) -> Result { + let mut client = OsqueryClient::connect(&self.config.socket_path).await?; + client.live_query(sql).await + } + + pub async fn update_schedule(&self, queries: Vec) -> Result<()> { + let mut scheduler = QueryScheduler::new(&self.config.db_path)?; + scheduler.upsert_queries(&queries)?; + Ok(()) + } +} diff --git a/agent/crates/osquery-client/src/scheduler.rs b/agent/crates/osquery-client/src/scheduler.rs new file mode 100644 index 0000000..56e232a --- /dev/null +++ b/agent/crates/osquery-client/src/scheduler.rs @@ -0,0 +1,81 @@ +use crate::types::{OsqueryResult, ScheduledQuery}; +use anyhow::Result; +use rusqlite::Connection; +use std::path::Path; +use tokio::sync::mpsc; + +pub struct QueryScheduler { + conn: Connection, +} + +impl QueryScheduler { + pub fn new(db_path: &Path) -> Result { + let conn = Connection::open(db_path)?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS scheduled_queries ( + name TEXT PRIMARY KEY, + query TEXT NOT NULL, + interval_secs INTEGER NOT NULL, + snapshot INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL + )", + [], + )?; + + Ok(Self { conn }) + } + + pub fn load_queries(&self) -> Result> { + let mut stmt = self.conn.prepare("SELECT name, query, interval_secs, snapshot FROM scheduled_queries")?; + let query_iter = stmt.query_map([], |row| { + let snapshot: i32 = row.get(3)?; + Ok(ScheduledQuery { + name: row.get(0)?, + query: row.get(1)?, + interval_secs: row.get(2)?, + snapshot: snapshot != 0, + }) + })?; + + let mut queries = Vec::new(); + for query in query_iter { + queries.push(query?); + } + Ok(queries) + } + + pub fn upsert_queries(&mut self, queries: &[ScheduledQuery]) -> Result<()> { + let tx = self.conn.transaction()?; + + let now = chrono::Utc::now().timestamp(); + + for query in queries { + tx.execute( + "INSERT INTO scheduled_queries (name, query, interval_secs, snapshot, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(name) DO UPDATE SET + query=excluded.query, interval_secs=excluded.interval_secs, snapshot=excluded.snapshot, updated_at=excluded.updated_at", + rusqlite::params![ + query.name, + query.query, + query.interval_secs, + if query.snapshot { 1 } else { 0 }, + now, + ], + )?; + } + + // Remove old queries not in this update (if we consider this update to be the full state) + // Note: For now, we just upsert. If full replacement is needed, we'd delete missing ones. + + tx.commit()?; + Ok(()) + } + + pub async fn run(self, _tx: mpsc::Sender) { + // Implement the actual loop here. + // It will spawn tasks for each query, using OsqueryClient::query, and tracking diffs. + // Left as stub for now until client is implemented. + } +} diff --git a/agent/crates/osquery-client/src/types.rs b/agent/crates/osquery-client/src/types.rs new file mode 100644 index 0000000..2044064 --- /dev/null +++ b/agent/crates/osquery-client/src/types.rs @@ -0,0 +1,118 @@ +use chrono::{DateTime, Utc}; +use prost::Message; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +// ───────────────────────────────────────────────────────── +// Scheduled Query Definition (stored in local SQLite) +// ───────────────────────────────────────────────────────── + +/// A single scheduled query. Pushed by fleet server, persisted in SQLite. +/// Fields are all primitive types for zero-cost SQLite row mapping. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScheduledQuery { + /// Unique name for this query (e.g., "running_processes") + pub name: String, + /// SQL string to execute against OSQuery + pub query: String, + /// Execution interval in seconds + pub interval_secs: u64, + /// If true, return full table snapshot each time. + /// If false, compute differential (added/removed rows). + pub snapshot: bool, +} + +// ───────────────────────────────────────────────────────── +// OSQuery Thrift Response Types +// ───────────────────────────────────────────────────────── + +/// Raw response from an OSQuery Thrift query() call +#[derive(Debug, Clone)] +pub struct QueryResponse { + pub status: QueryStatus, + pub rows: Vec, +} + +/// Status returned by the OSQuery ExtensionManager +#[derive(Debug, Clone)] +pub struct QueryStatus { + /// 0 = success, non-zero = error + pub code: i32, + /// Human-readable status message + pub message: String, +} + +/// A single row from an OSQuery query result. +/// Keys are column names, values are string representations. +pub type OsqueryRow = HashMap; + +// ───────────────────────────────────────────────────────── +// Processed Query Result (protobuf-encodable) +// ───────────────────────────────────────────────────────── + +/// A complete, processed query result ready for downstream consumption. +/// Derives prost::Message for protobuf serialization — this is what +/// gets encoded into the AgentEvent.payload field. +#[derive(Clone, Message)] +pub struct OsqueryResult { + /// Name of the scheduled query that produced this result + #[prost(string, tag = "1")] + pub query_name: String, + + /// UUID of the agent that produced this result + #[prost(string, tag = "2")] + pub agent_uuid: String, + + /// Unix timestamp in nanoseconds + #[prost(int64, tag = "3")] + pub timestamp_ns: i64, + + /// The result rows, each encoded as an OsqueryResultRow + #[prost(message, repeated, tag = "4")] + pub rows: Vec, + + /// Whether this is a snapshot, added diff, or removed diff + #[prost(enumeration = "ResultAction", tag = "5")] + pub action: i32, +} + +/// A single row in an OsqueryResult, represented as key-value pairs. +/// Protobuf doesn't have a native map-in-repeated, so we use a message +/// with repeated entries. +#[derive(Clone, Message)] +pub struct OsqueryResultRow { + #[prost(message, repeated, tag = "1")] + pub columns: Vec, +} + +/// A single column name-value pair within a row. +#[derive(Clone, Message)] +pub struct ColumnEntry { + #[prost(string, tag = "1")] + pub name: String, + #[prost(string, tag = "2")] + pub value: String, +} + +/// The type of result action for differential queries. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, prost::Enumeration)] +#[repr(i32)] +pub enum ResultAction { + /// Full table dump (first execution or snapshot mode) + Snapshot = 0, + /// Differential: rows added since last execution + Added = 1, + /// Differential: rows removed since last execution + Removed = 2, +} + +// Implement prost::Enumeration for ResultAction so prost can encode it +impl ResultAction { + pub fn as_str(&self) -> &'static str { + match self { + ResultAction::Snapshot => "SNAPSHOT", + ResultAction::Added => "ADDED", + ResultAction::Removed => "REMOVED", + } + } +} diff --git a/agent/todo.md b/agent/todo.md new file mode 100644 index 0000000..9f7bc15 --- /dev/null +++ b/agent/todo.md @@ -0,0 +1,150 @@ +# EDR Agent Stubs and TODOs + +This file tracks all the stubs, placeholders, mock implementations, and incomplete parts of the codebase within the `/agent` directory. + +--- + +## 1. Entirely Empty/Stub Crates + +The following crates are defined in the workspace but contain no functional logic: + +### 🔴 **ebpf-collector** +* **Location**: `crates/ebpf-collector/` +* **Current State**: + * [lib.rs](file:///Users/swar/C/R/oss/project-edr/agent/crates/ebpf-collector/src/lib.rs) is empty except for a comment: `// eBPF collector — kernel-level event telemetry.` + * The `bpf/` directory is completely empty. +* **To Do**: Implement eBPF kernel-level event telemetry and hook it up to the agent. + +### 🔴 **isolation** +* **Location**: `crates/isolation/` +* **Current State**: + * [lib.rs](file:///Users/swar/C/R/oss/project-edr/agent/crates/isolation/src/lib.rs) is empty except for a comment: `// Isolation — network quarantine via iptables.` +* **To Do**: Implement network quarantine mechanisms using `iptables` or similar platforms/tools. + +--- + +## 2. Agent Core Orchestrator + +### 🟡 **Mock System Metadata** +* **Location**: [orchestrator.rs:L37-42](file:///Users/swar/C/R/oss/project-edr/agent/crates/agent-core/src/orchestrator.rs#L37-42) +* **Stub**: + ```rust + let req = RegisterRequest { + hostname: "mock-hostname".to_string(), + os_version: "mock-os".to_string(), + agent_version: "0.1.0".to_string(), + machine_id: "mock-machine-id".to_string(), + }; + ``` +* **To Do**: Query the host OS for actual hostname, OS version, EDR agent version, and unique machine/hardware ID. + +### 🟡 **Enrollment Fallback & Local Buffer Routing** +* **Location**: [orchestrator.rs:L50](file:///Users/swar/C/R/oss/project-edr/agent/crates/agent-core/src/orchestrator.rs#L50) +* **Stub**: + ```rust + // We would continue and buffer locally, but stub for now. + ``` +* **To Do**: When the enrollment fails or Fleet Server is offline, route collected telemetry to the local SQLite-backed `EventBuffer` to ensure no data is lost, and retry enrollment/connection in the background. + +### 🟡 **Osquery Collector Spawning** +* **Location**: [orchestrator.rs:L54-57](file:///Users/swar/C/R/oss/project-edr/agent/crates/agent-core/src/orchestrator.rs#L54-57) +* **Stub**: + ```rust + // Stub: We would also create OsqueryCollector and route events here. + + // Keeping main alive + tokio::time::sleep(tokio::time::Duration::from_secs(60)).await; + ``` +* **To Do**: Instantiate `OsqueryCollector`, start the scheduler, start the query loops, and pipe the output to the `FleetClient` event stream or the local buffer. Remove the temporary `sleep` block. + +--- + +## 3. Osquery Client + +### 🟡 **Osquery Thrift Unix Socket Client** +* **Location**: [client.rs:L16-44](file:///Users/swar/C/R/oss/project-edr/agent/crates/osquery-client/src/client.rs#L16-44) +* **Stub**: + ```rust + pub async fn query(&mut self, sql: &str) -> Result { + // Stub for now. Will require Thrift serialization over UnixStream. + tracing::debug!("Executing query: {}", sql); + Ok(QueryResponse { ... }) + } + ``` +* **To Do**: Implement full Apache Thrift serialization and deserialization over `UnixStream` to communicate with the osquery daemon socket instead of returning mocked empty results. Implement `get_query_columns`, `ping`, and `reconnect` logic. + +### 🟡 **Query Scheduler Upsert Cleanup** +* **Location**: [scheduler.rs:L70](file:///Users/swar/C/R/oss/project-edr/agent/crates/osquery-client/src/scheduler.rs#L70) +* **Stub**: + ```rust + // Note: For now, we just upsert. If full replacement is needed, we'd delete missing ones. + ``` +* **To Do**: When updating query schedules, perform a deletion sync to remove any scheduled queries that are no longer present in the updated fleet config configuration. + +### 🟡 **Scheduler Execution Loop** +* **Location**: [scheduler.rs:L76-80](file:///Users/swar/C/R/oss/project-edr/agent/crates/osquery-client/src/scheduler.rs#L76-80) +* **Stub**: + ```rust + pub async fn run(self, _tx: mpsc::Sender) { + // Implement the actual loop here. + // It will spawn tasks for each query, using OsqueryClient::query, and tracking diffs. + // Left as stub for now until client is implemented. + } + ``` +* **To Do**: Write the runtime scheduler loop that runs periodically, schedules/spawns tasks for each query based on their intervals, tracks diffs between runs, and sends differential/snapshot changes to the channel. + +--- + +## 4. Fleet Client + +### 🟡 **Mock Enrollment Service** +* **Location**: [enrollment.rs:L9-18](file:///Users/swar/C/R/oss/project-edr/agent/crates/fleet-client/src/enrollment.rs#L9-18) +* **Stub**: + ```rust + // Stub: In a real implementation we would make a gRPC call here using the + // manually constructed or generated FleetServiceClient over the given channel. + // For now, we mock the response. + ``` +* **To Do**: Replace the mocked UUID/JWT response generation with a real gRPC enrollment call. + +### 🟡 **Mock Heartbeat Manager** +* **Location**: [heartbeat.rs:L18-26](file:///Users/swar/C/R/oss/project-edr/agent/crates/fleet-client/src/heartbeat.rs#L18-26) +* **Stub**: + ```rust + // Stub: In a real implementation we would make a gRPC call here using the + // manually constructed or generated FleetServiceClient over the given channel. + tokio::spawn(async move { + loop { + interval.tick().await; + tracing::debug!("Sending heartbeat for node: {}", node_id); + // Send HeartbeatRequest { node_id, status: "healthy", events_buffered: 0 } + } + }); + ``` +* **To Do**: Implement gRPC client heartbeat dispatching, tracking the number of buffered events in the local database, and reacting to server status checks. + +### 🟡 **Mock Bidirectional Event Stream** +* **Location**: [stream.rs:L16-24](file:///Users/swar/C/R/oss/project-edr/agent/crates/fleet-client/src/stream.rs#L16-24) +* **Stub**: + ```rust + // Stub: In a real implementation we would open a bidirectional stream here. + // For now, just drain events_rx and log them. + ``` +* **To Do**: Establish a real gRPC bidirectional stream to stream telemetry up and dynamically receive downstream commands (e.g. Isolation, Config Updates, Acks) in real time. + +--- + +## 5. Mock Fleet Server + +### 🟡 **Mock Fleet Server Listening Stub** +* **Location**: [main.rs:L116-121](file:///Users/swar/C/R/oss/project-edr/agent/tools/mock-fleet-server/src/main.rs#L116-121) +* **Stub**: + ```rust + tracing::info!("Mock Fleet Server listening on 0.0.0.0:50051"); + // Implement actual tonic service when compiling the fleet proto or manually wrapping bytes. + // Since we're doing manual bytes on the agent, we need to match the gRPC paths here or wait + // for proper proto codegen in a later step. + // For now, this is a placeholder that compiles. + loop { tokio::time::sleep(tokio::time::Duration::from_secs(60)).await; } + ``` +* **To Do**: Replace the simple sleep loop with a fully functioning Tonic gRPC server definition that accepts `RegisterRequest`, handles dynamic config updates, and responds to heartbeats/streams. diff --git a/agent/tools/mock-fleet-server/Cargo.toml b/agent/tools/mock-fleet-server/Cargo.toml new file mode 100644 index 0000000..f226e94 --- /dev/null +++ b/agent/tools/mock-fleet-server/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "mock-fleet-server" +version = "0.1.0" +edition = "2024" + +[workspace] + +[[bin]] +name = "mock-fleet-server" +path = "src/main.rs" + +[dependencies] +tokio = { version = "1", features = ["full", "macros"] } +tonic = "0.12" +prost = "0.13" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +uuid = { version = "1", features = ["v4"] } +tokio-stream = "0.1" + +[build-dependencies] +tonic-build = "0.12" diff --git a/agent/tools/mock-fleet-server/build.rs b/agent/tools/mock-fleet-server/build.rs new file mode 100644 index 0000000..14dc700 --- /dev/null +++ b/agent/tools/mock-fleet-server/build.rs @@ -0,0 +1,7 @@ +fn main() -> Result<(), Box> { + tonic_build::configure() + .build_server(true) + .build_client(false) + .compile_protos(&["../../../sdk/proto/fleet.proto"], &["../../../sdk/proto"])?; + Ok(()) +} diff --git a/agent/tools/mock-fleet-server/src/main.rs b/agent/tools/mock-fleet-server/src/main.rs new file mode 100644 index 0000000..ef1975f --- /dev/null +++ b/agent/tools/mock-fleet-server/src/main.rs @@ -0,0 +1,116 @@ +use tokio::sync::mpsc; +use tokio_stream::wrappers::ReceiverStream; +use tonic::{transport::Server, Request, Response, Status}; +use uuid::Uuid; + +pub mod proto { + tonic::include_proto!("edr.fleet"); +} + +use proto::fleet_service_server::{FleetService, FleetServiceServer}; +use proto::{ + AgentConfig, AgentEvent, HeartbeatRequest, HeartbeatResponse, OsquerySchedule, + RegisterRequest, RegisterResponse, ServerCommand, +}; + +#[derive(Default)] +pub struct MockFleetService {} + +#[tonic::async_trait] +impl FleetService for MockFleetService { + async fn register_agent( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + tracing::info!("Agent registered: {:?}", req.hostname); + + let config = AgentConfig { + osquery_schedule: vec![OsquerySchedule { + name: "running_processes".to_string(), + query: "SELECT pid, name, path, cmdline, uid, parent FROM processes;".to_string(), + interval_secs: 30, + }], + heartbeat_interval_secs: 30, + batch_size: 100, + }; + + let response = RegisterResponse { + node_id: Uuid::new_v4().to_string(), + token: "mock_jwt_token".to_string(), + config: Some(config), + }; + + Ok(Response::new(response)) + } + + type EventStreamStream = ReceiverStream>; + + async fn event_stream( + &self, + request: Request>, + ) -> Result, Status> { + let mut in_stream = request.into_inner(); + let (tx, rx) = mpsc::channel(128); + + tokio::spawn(async move { + while let Ok(Some(event)) = in_stream.message().await { + tracing::info!( + "Received event from node {}: type {}, payload size {} bytes", + event.node_id, + event.event_type, + event.payload.len() + ); + + // We could send an Ack command here + let ack = ServerCommand { + command: Some(proto::server_command::Command::Ack(proto::AckCommand { + sequence_id: event.sequence_id, + })), + }; + + if let Err(e) = tx.send(Ok(ack)).await { + tracing::error!("Failed to send ACK to client: {}", e); + break; + } + } + tracing::info!("Event stream closed by client"); + }); + + Ok(Response::new(ReceiverStream::new(rx))) + } + + async fn heartbeat( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + tracing::debug!( + "Heartbeat from node {}: status={}, events_buffered={}", + req.node_id, + req.status, + req.events_buffered + ); + + Ok(Response::new(HeartbeatResponse { ok: true })) + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt::init(); + let addr = "0.0.0.0:50051".parse()?; + + tracing::info!("Mock Fleet Server listening on {}", addr); + + let service = MockFleetService::default(); + + Server::builder() + .add_service(FleetServiceServer::new(service)) + .serve(addr) + .await?; + + Ok(()) +} + + diff --git a/fleet-server/src/grpc/main.rs b/fleet-server/src/grpc/main.rs new file mode 100644 index 0000000..e69de29 diff --git a/fleet-server/src/grpc/testing.proto b/fleet-server/src/grpc/testing.proto new file mode 100644 index 0000000..e69de29 diff --git a/run-docker-agent.sh b/run-docker-agent.sh new file mode 100755 index 0000000..fc7a726 --- /dev/null +++ b/run-docker-agent.sh @@ -0,0 +1,19 @@ +#!/bin/bash +set -e + +echo "Building agent Docker image (this will install Rust & OSQuery)..." +docker build -t edr-agent-dev -f agent/Dockerfile . + +echo "Running agent in Docker container..." +# -v mounts the current project dir to /workspace +# -w sets working directory to /workspace +# --privileged or CAP_AUDIT_CONTROL etc might be needed for osquery audit, but let's stick to simple run for now +# We pass EDR_AGENT_CONFIG to use the agent.toml in the workspace +docker run --rm -it \ + --name edr-agent \ + -v "$(pwd)":/workspace \ + -w /workspace \ + -e EDR_AGENT_CONFIG=/workspace/agent.toml \ + --add-host=host.docker.internal:host-gateway \ + edr-agent-dev \ + bash -c "cargo run -p agent-bin" From 6b437ee6f0a6b15520549ae89a7845c289496081 Mon Sep 17 00:00:00 2001 From: ghule swarnit Date: Thu, 28 May 2026 18:44:20 +0530 Subject: [PATCH 02/69] feat: implement thrift-based query execution over Unix domain sockets for osquery client --- Cargo.toml | 2 +- agent/crates/agent-core/src/orchestrator.rs | 14 +- agent/crates/agent-tracing/src/lib.rs | 5 +- agent/crates/event-buffer/src/lib.rs | 8 +- agent/crates/fleet-client/src/connection.rs | 6 +- agent/crates/fleet-client/src/enrollment.rs | 8 +- agent/crates/fleet-client/src/heartbeat.rs | 26 ++- agent/crates/fleet-client/src/lib.rs | 2 +- agent/crates/fleet-client/src/stream.rs | 18 +- agent/crates/osquery-client/src/client.rs | 143 +++++++++++++++- agent/crates/osquery-client/src/lib.rs | 10 +- agent/crates/osquery-client/src/scheduler.rs | 165 +++++++++++++++++-- agent/crates/osquery-client/src/types.rs | 1 - 13 files changed, 347 insertions(+), 61 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 61e4661..519b151 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,7 @@ tokio-stream = "0.1" tokio-util = { version = "0.7", features = ["codec"] } tokio-tungstenite = "0.29" -tonic = { version = "0.14", features = ["prost"] } +tonic = { version = "0.14" } tonic-reflection = "0.14" tonic-build = "0.14" prost = "0.14" diff --git a/agent/crates/agent-core/src/orchestrator.rs b/agent/crates/agent-core/src/orchestrator.rs index 1e6f88b..719feec 100644 --- a/agent/crates/agent-core/src/orchestrator.rs +++ b/agent/crates/agent-core/src/orchestrator.rs @@ -2,19 +2,19 @@ use crate::config::AgentConfig; use anyhow::Result; use event_buffer::EventBuffer; use fleet_client::{ - types::{AgentEvent, EventType, RegisterRequest}, FleetClient, + types::{AgentEvent, EventType, RegisterRequest}, }; use osquery_client::OsqueryCollector; use prost::Message; pub async fn run() -> Result<()> { - let config_path = std::env::var("EDR_AGENT_CONFIG") - .unwrap_or_else(|_| "agent.toml".to_string()); - + let config_path = + std::env::var("EDR_AGENT_CONFIG").unwrap_or_else(|_| "agent.toml".to_string()); + let config_str = std::fs::read_to_string(&config_path) .map_err(|e| anyhow::anyhow!("Failed to read config file at {}: {}", config_path, e))?; - + let config: AgentConfig = toml::from_str(&config_str) .map_err(|e| anyhow::anyhow!("Failed to parse TOML config: {}", e))?; @@ -22,7 +22,7 @@ pub async fn run() -> Result<()> { Some("json") => agent_tracing::LogFormat::Json, _ => agent_tracing::LogFormat::Human, }; - + agent_tracing::init(&config.agent.log_level, format)?; tracing::info!("Starting EDR Agent Orchestrator"); @@ -52,7 +52,7 @@ pub async fn run() -> Result<()> { } // Stub: We would also create OsqueryCollector and route events here. - + // Keeping main alive tokio::time::sleep(tokio::time::Duration::from_secs(60)).await; diff --git a/agent/crates/agent-tracing/src/lib.rs b/agent/crates/agent-tracing/src/lib.rs index 824158a..9fddfa9 100644 --- a/agent/crates/agent-tracing/src/lib.rs +++ b/agent/crates/agent-tracing/src/lib.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use tracing_subscriber::{fmt, EnvFilter}; +use tracing_subscriber::{EnvFilter, fmt}; /// Log output format. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -18,8 +18,7 @@ impl Default for LogFormat { /// Initialize the agent's tracing/logging infrastructure. pub fn init(log_level: &str, format: LogFormat) -> Result<()> { - let filter = EnvFilter::try_from_default_env() - .unwrap_or_else(|_| EnvFilter::new(log_level)); + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(log_level)); match format { LogFormat::Human => { diff --git a/agent/crates/event-buffer/src/lib.rs b/agent/crates/event-buffer/src/lib.rs index d76ce49..ea75f73 100644 --- a/agent/crates/event-buffer/src/lib.rs +++ b/agent/crates/event-buffer/src/lib.rs @@ -77,11 +77,9 @@ impl EventBuffer { /// Count of events currently buffered (for heartbeat reporting). pub fn len(&self) -> Result { - let count: i64 = self.conn.query_row( - "SELECT COUNT(*) FROM event_buffer", - [], - |row| row.get(0), - )?; + let count: i64 = self + .conn + .query_row("SELECT COUNT(*) FROM event_buffer", [], |row| row.get(0))?; Ok(count as usize) } diff --git a/agent/crates/fleet-client/src/connection.rs b/agent/crates/fleet-client/src/connection.rs index 34a7f0d..69efefc 100644 --- a/agent/crates/fleet-client/src/connection.rs +++ b/agent/crates/fleet-client/src/connection.rs @@ -40,7 +40,11 @@ impl FleetConnection { return Ok(channel); } Err(e) => { - tracing::warn!("Failed to connect to fleet server: {}. Retrying in {:?}", e, backoff); + tracing::warn!( + "Failed to connect to fleet server: {}. Retrying in {:?}", + e, + backoff + ); } }, Err(e) => { diff --git a/agent/crates/fleet-client/src/enrollment.rs b/agent/crates/fleet-client/src/enrollment.rs index 668c7bb..2216bc5 100644 --- a/agent/crates/fleet-client/src/enrollment.rs +++ b/agent/crates/fleet-client/src/enrollment.rs @@ -1,21 +1,21 @@ use crate::types::{EnrollmentResult, RegisterRequest, RegisterResponse}; use anyhow::Result; -use tonic::{transport::Channel, Request, client::Grpc, codec::ProstCodec}; +use tonic::{Request, client::Grpc, codec::ProstCodec, transport::Channel}; pub struct AgentEnrollment; impl AgentEnrollment { pub async fn enroll(channel: Channel, request: RegisterRequest) -> Result { tracing::info!("Enrolling agent: {:?}", request.hostname); - + let mut client = Grpc::new(channel); let path = http::uri::PathAndQuery::from_static("/edr.fleet.FleetService/RegisterAgent"); - + let res = client .unary(Request::new(request), path, ProstCodec::default()) .await? .into_inner(); - + Ok(EnrollmentResult { node_id: res.node_id, token: res.token, diff --git a/agent/crates/fleet-client/src/heartbeat.rs b/agent/crates/fleet-client/src/heartbeat.rs index c8185fc..cd0d906 100644 --- a/agent/crates/fleet-client/src/heartbeat.rs +++ b/agent/crates/fleet-client/src/heartbeat.rs @@ -2,7 +2,9 @@ use crate::types::{HeartbeatRequest, HeartbeatResponse}; use anyhow::Result; use std::time::Duration; use tokio::time; -use tonic::{transport::Channel, Request, client::Grpc, codec::ProstCodec, metadata::MetadataValue}; +use tonic::{ + Request, client::Grpc, codec::ProstCodec, metadata::MetadataValue, transport::Channel, +}; pub struct HeartbeatManager; @@ -14,31 +16,39 @@ impl HeartbeatManager { interval_secs: u64, ) -> Result<()> { let mut interval = time::interval(Duration::from_secs(interval_secs)); - + tokio::spawn(async move { loop { interval.tick().await; tracing::debug!("Sending heartbeat for node: {}", node_id); - + let req_payload = HeartbeatRequest { node_id: node_id.clone(), status: "healthy".to_string(), events_buffered: 0, }; - + let mut client = Grpc::new(channel.clone()); - let path = http::uri::PathAndQuery::from_static("/edr.fleet.FleetService/Heartbeat"); + let path = + http::uri::PathAndQuery::from_static("/edr.fleet.FleetService/Heartbeat"); let mut req = Request::new(req_payload); if let Ok(meta_token) = MetadataValue::try_from(format!("Bearer {}", token)) { req.metadata_mut().insert("authorization", meta_token); } - - if let Err(e) = client.unary(req, path, ProstCodec::::default()).await { + + if let Err(e) = client + .unary( + req, + path, + ProstCodec::::default(), + ) + .await + { tracing::warn!("Failed to send heartbeat: {}", e); } } }); - + Ok(()) } } diff --git a/agent/crates/fleet-client/src/lib.rs b/agent/crates/fleet-client/src/lib.rs index 96533d8..6dc8348 100644 --- a/agent/crates/fleet-client/src/lib.rs +++ b/agent/crates/fleet-client/src/lib.rs @@ -9,7 +9,7 @@ use crate::enrollment::AgentEnrollment; use crate::heartbeat::HeartbeatManager; use crate::stream::EventStreamManager; use crate::types::{AgentEvent, ConnectionState, EnrollmentResult, RegisterRequest, ServerCommand}; -use anyhow::{anyhow, Result}; +use anyhow::{Result, anyhow}; use tokio::sync::{mpsc, watch}; pub struct FleetConfig { diff --git a/agent/crates/fleet-client/src/stream.rs b/agent/crates/fleet-client/src/stream.rs index 4ea04fd..735d944 100644 --- a/agent/crates/fleet-client/src/stream.rs +++ b/agent/crates/fleet-client/src/stream.rs @@ -1,8 +1,10 @@ use crate::types::{AgentEvent, ServerCommand}; use anyhow::Result; use tokio::sync::mpsc; -use tonic::{transport::Channel, Request, client::Grpc, codec::ProstCodec, metadata::MetadataValue}; use tokio_stream::wrappers::ReceiverStream; +use tonic::{ + Request, client::Grpc, codec::ProstCodec, metadata::MetadataValue, transport::Channel, +}; pub struct EventStreamManager; @@ -14,14 +16,20 @@ impl EventStreamManager { ) -> Result> { let mut client = Grpc::new(channel); let path = http::uri::PathAndQuery::from_static("/edr.fleet.FleetService/EventStream"); - + let mut req = Request::new(ReceiverStream::new(events_rx)); let meta_token = MetadataValue::try_from(format!("Bearer {}", token))?; req.metadata_mut().insert("authorization", meta_token); - - let response = client.streaming(req, path, ProstCodec::::default()).await?; + + let response = client + .streaming( + req, + path, + ProstCodec::::default(), + ) + .await?; let mut stream = response.into_inner(); - + let (server_cmd_tx, server_cmd_rx) = mpsc::channel(100); tokio::spawn(async move { diff --git a/agent/crates/osquery-client/src/client.rs b/agent/crates/osquery-client/src/client.rs index 472cbeb..5844ec5 100644 --- a/agent/crates/osquery-client/src/client.rs +++ b/agent/crates/osquery-client/src/client.rs @@ -1,6 +1,15 @@ use crate::types::{QueryResponse, QueryStatus}; -use anyhow::Result; +use anyhow::{Result, anyhow}; +use std::collections::HashMap; use std::path::{Path, PathBuf}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::UnixStream; + +use thrift::protocol::{ + TBinaryInputProtocol, TBinaryOutputProtocol, TFieldIdentifier, TInputProtocol, + TMessageIdentifier, TMessageType, TOutputProtocol, TType, +}; +use thrift::transport::TBufferChannel; pub struct OsqueryClient { socket_path: PathBuf, @@ -14,15 +23,131 @@ impl OsqueryClient { } pub async fn query(&mut self, sql: &str) -> Result { - // Stub for now. Will require Thrift serialization over UnixStream. tracing::debug!("Executing query: {}", sql); - Ok(QueryResponse { - status: QueryStatus { - code: 0, - message: "OK".to_string(), - }, - rows: vec![], - }) + + // 1. Serialize request locally + let mut t = TBufferChannel::with_capacity(0, 1024); + { + let mut out_prot = TBinaryOutputProtocol::new(&mut t, true); + + out_prot.write_message_begin(&TMessageIdentifier::new( + "query", + TMessageType::Call, + 1, + ))?; + + out_prot.write_struct_begin(&thrift::protocol::TStructIdentifier::new("query_args"))?; + + // Argument 1: sql (string) + out_prot.write_field_begin(&TFieldIdentifier::new("sql", TType::String, 1))?; + out_prot.write_string(sql)?; + out_prot.write_field_end()?; + + out_prot.write_field_stop()?; + out_prot.write_struct_end()?; + out_prot.write_message_end()?; + out_prot.flush()?; + } + + let request_bytes = t.write_bytes(); + + // 2. Connect to socket and write/read asynchronously + let mut stream = UnixStream::connect(&self.socket_path).await?; + stream.write_all(&request_bytes).await?; + stream.flush().await?; + + let mut buf = Vec::new(); + // Since we don't know the size, we can read until we can parse a valid thrift message. + // Actually, let's just read 8KB which is usually enough for simple queries. If we need more, we read more. + let mut chunk = vec![0u8; 8192]; + let n = stream.read(&mut chunk).await?; + if n == 0 { + return Err(anyhow!("Connection closed by osquery")); + } + buf.extend_from_slice(&chunk[..n]); + + Self::parse_query_response(&buf) + } + + fn parse_query_response(buf: &[u8]) -> Result { + let mut t = TBufferChannel::with_capacity(buf.len(), 0); + t.set_readable_bytes(buf); + let mut in_prot = TBinaryInputProtocol::new(&mut t, true); + + let msg_ident = in_prot.read_message_begin()?; + if msg_ident.message_type == TMessageType::Exception { + let _ = thrift::Error::read_application_error_from_in_protocol(&mut in_prot)?; + return Err(anyhow!("Thrift exception returned")); + } + + let mut status = QueryStatus { + code: -1, + message: String::new(), + }; + let mut rows = Vec::new(); + + in_prot.read_struct_begin()?; + loop { + let field = in_prot.read_field_begin()?; + if field.field_type == TType::Stop { + break; + } + if field.id == Some(0) && field.field_type == TType::Struct { + // ExtensionResponse + in_prot.read_struct_begin()?; + loop { + let res_field = in_prot.read_field_begin()?; + if res_field.field_type == TType::Stop { + break; + } + match res_field.id { + Some(1) => { + // ExtensionStatus + in_prot.read_struct_begin()?; + loop { + let st_field = in_prot.read_field_begin()?; + if st_field.field_type == TType::Stop { + break; + } + match st_field.id { + Some(1) => status.code = in_prot.read_i32()?, + Some(2) => status.message = in_prot.read_string()?, + _ => in_prot.skip(st_field.field_type)?, + } + in_prot.read_field_end()?; + } + in_prot.read_struct_end()?; + } + Some(2) => { + // list> response + let list_ident = in_prot.read_list_begin()?; + for _ in 0..list_ident.size { + let map_ident = in_prot.read_map_begin()?; + let mut row = HashMap::new(); + for _ in 0..map_ident.size { + let k = in_prot.read_string()?; + let v = in_prot.read_string()?; + row.insert(k, v); + } + in_prot.read_map_end()?; + rows.push(row); + } + in_prot.read_list_end()?; + } + _ => in_prot.skip(res_field.field_type)?, + } + in_prot.read_field_end()?; + } + in_prot.read_struct_end()?; + } else { + in_prot.skip(field.field_type)?; + } + in_prot.read_field_end()?; + } + in_prot.read_struct_end()?; + in_prot.read_message_end()?; + + Ok(QueryResponse { status, rows }) } pub async fn get_query_columns(&mut self, _sql: &str) -> Result { diff --git a/agent/crates/osquery-client/src/lib.rs b/agent/crates/osquery-client/src/lib.rs index be09484..e33ca91 100644 --- a/agent/crates/osquery-client/src/lib.rs +++ b/agent/crates/osquery-client/src/lib.rs @@ -24,14 +24,16 @@ impl OsqueryCollector { Ok(Self { config }) } - pub async fn start(&self) -> mpsc::Receiver { + pub async fn start(&self, agent_uuid: &str) -> mpsc::Receiver { let (tx, rx) = mpsc::channel(100); - + let scheduler_db_path = self.config.db_path.clone(); - + let socket_path = self.config.socket_path.clone(); + let agent_uuid = agent_uuid.to_string(); + tokio::spawn(async move { if let Ok(scheduler) = QueryScheduler::new(&scheduler_db_path) { - scheduler.run(tx).await; + scheduler.run(tx, socket_path, agent_uuid).await; } }); diff --git a/agent/crates/osquery-client/src/scheduler.rs b/agent/crates/osquery-client/src/scheduler.rs index 56e232a..c97a242 100644 --- a/agent/crates/osquery-client/src/scheduler.rs +++ b/agent/crates/osquery-client/src/scheduler.rs @@ -1,9 +1,13 @@ -use crate::types::{OsqueryResult, ScheduledQuery}; +use crate::client::OsqueryClient; +use crate::diff; +use crate::types::{ + ColumnEntry, OsqueryResult, OsqueryResultRow, OsqueryRow, ResultAction, ScheduledQuery, +}; use anyhow::Result; +use chrono::Utc; use rusqlite::Connection; -use std::path::Path; +use std::path::{Path, PathBuf}; use tokio::sync::mpsc; - pub struct QueryScheduler { conn: Connection, } @@ -11,7 +15,7 @@ pub struct QueryScheduler { impl QueryScheduler { pub fn new(db_path: &Path) -> Result { let conn = Connection::open(db_path)?; - + conn.execute( "CREATE TABLE IF NOT EXISTS scheduled_queries ( name TEXT PRIMARY KEY, @@ -27,7 +31,9 @@ impl QueryScheduler { } pub fn load_queries(&self) -> Result> { - let mut stmt = self.conn.prepare("SELECT name, query, interval_secs, snapshot FROM scheduled_queries")?; + let mut stmt = self + .conn + .prepare("SELECT name, query, interval_secs, snapshot FROM scheduled_queries")?; let query_iter = stmt.query_map([], |row| { let snapshot: i32 = row.get(3)?; Ok(ScheduledQuery { @@ -47,9 +53,9 @@ impl QueryScheduler { pub fn upsert_queries(&mut self, queries: &[ScheduledQuery]) -> Result<()> { let tx = self.conn.transaction()?; - + let now = chrono::Utc::now().timestamp(); - + for query in queries { tx.execute( "INSERT INTO scheduled_queries (name, query, interval_secs, snapshot, updated_at) @@ -65,7 +71,7 @@ impl QueryScheduler { ], )?; } - + // Remove old queries not in this update (if we consider this update to be the full state) // Note: For now, we just upsert. If full replacement is needed, we'd delete missing ones. @@ -73,9 +79,144 @@ impl QueryScheduler { Ok(()) } - pub async fn run(self, _tx: mpsc::Sender) { - // Implement the actual loop here. - // It will spawn tasks for each query, using OsqueryClient::query, and tracking diffs. - // Left as stub for now until client is implemented. + pub async fn run( + self, + tx: mpsc::Sender, + socket_path: PathBuf, + agent_uuid: String, + ) { + let queries = match self.load_queries() { + Ok(q) => q, + Err(e) => { + tracing::error!("Failed to load scheduled queries: {}", e); + return; + } + }; + + for query in queries { + let tx = tx.clone(); + let socket_path = socket_path.clone(); + let agent_uuid = agent_uuid.clone(); + + tokio::spawn(async move { + let mut previous_rows: Vec = Vec::new(); + let mut first_run = true; + + let mut interval = + tokio::time::interval(std::time::Duration::from_secs(query.interval_secs)); + + loop { + interval.tick().await; + + let mut client = match OsqueryClient::connect(&socket_path).await { + Ok(c) => c, + Err(e) => { + tracing::warn!( + "Failed to connect to osqueryd for query {}: {}", + query.name, + e + ); + continue; + } + }; + + let response = match client.query(&query.query).await { + Ok(res) => res, + Err(e) => { + tracing::warn!("Query {} failed: {}", query.name, e); + continue; + } + }; + + if response.status.code != 0 { + tracing::warn!( + "Query {} returned osquery error: {}", + query.name, + response.status.message + ); + continue; + } + + let current_rows = response.rows; + + if query.snapshot { + // Snapshot mode: emit all rows every time + let result = Self::build_result( + &query.name, + &agent_uuid, + current_rows, + ResultAction::Snapshot, + ); + if let Err(e) = tx.send(result).await { + tracing::error!( + "Failed to send query result for {}: {}", + query.name, + e + ); + break; + } + } else { + // Differential mode + if first_run { + let result = Self::build_result( + &query.name, + &agent_uuid, + current_rows.clone(), + ResultAction::Snapshot, + ); + let _ = tx.send(result).await; + first_run = false; + } else { + let (added, removed) = + diff::compute_diff(&previous_rows, ¤t_rows); + + if !added.is_empty() { + let res = Self::build_result( + &query.name, + &agent_uuid, + added, + ResultAction::Added, + ); + let _ = tx.send(res).await; + } + if !removed.is_empty() { + let res = Self::build_result( + &query.name, + &agent_uuid, + removed, + ResultAction::Removed, + ); + let _ = tx.send(res).await; + } + } + previous_rows = current_rows; + } + } + }); + } + } + + fn build_result( + query_name: &str, + agent_uuid: &str, + rows: Vec, + action: ResultAction, + ) -> OsqueryResult { + let mut result_rows = Vec::with_capacity(rows.len()); + for row in rows { + let mut columns = Vec::with_capacity(row.len()); + for (k, v) in row { + columns.push(ColumnEntry { name: k, value: v }); + } + result_rows.push(OsqueryResultRow { columns }); + } + + OsqueryResult { + query_name: query_name.to_string(), + agent_uuid: agent_uuid.to_string(), + timestamp_ns: Utc::now().timestamp_nanos_opt().unwrap_or(0), + rows: result_rows, + action: action as i32, + } } } diff --git a/agent/crates/osquery-client/src/types.rs b/agent/crates/osquery-client/src/types.rs index 2044064..d2f4862 100644 --- a/agent/crates/osquery-client/src/types.rs +++ b/agent/crates/osquery-client/src/types.rs @@ -1,4 +1,3 @@ -use chrono::{DateTime, Utc}; use prost::Message; use serde::{Deserialize, Serialize}; use std::collections::HashMap; From d81cfdbafe3a91b5a77ed9613f03b5bf0a0d272c Mon Sep 17 00:00:00 2001 From: ghule swarnit Date: Thu, 28 May 2026 18:55:10 +0530 Subject: [PATCH 03/69] refactor --- Cargo.toml | 1 + agent/crates/fleet-client/Cargo.toml | 1 + agent/crates/fleet-client/src/enrollment.rs | 5 +++-- agent/crates/fleet-client/src/heartbeat.rs | 5 ++--- agent/crates/fleet-client/src/stream.rs | 5 ++--- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 519b151..f6be9a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ tonic = { version = "0.14" } tonic-reflection = "0.14" tonic-build = "0.14" prost = "0.14" +tonic-prost = "0.14" axum = { version = "0.8", features = ["ws", "macros"] } tower = "0.5" diff --git a/agent/crates/fleet-client/Cargo.toml b/agent/crates/fleet-client/Cargo.toml index 1116cd5..2930f0e 100644 --- a/agent/crates/fleet-client/Cargo.toml +++ b/agent/crates/fleet-client/Cargo.toml @@ -12,6 +12,7 @@ tower = { workspace = true } anyhow = { workspace = true } tracing = { workspace = true } prost = { workspace = true } +tonic-prost = { workspace = true } uuid = { workspace = true } serde = { workspace = true } http = "1.4.1" diff --git a/agent/crates/fleet-client/src/enrollment.rs b/agent/crates/fleet-client/src/enrollment.rs index 2216bc5..8b45464 100644 --- a/agent/crates/fleet-client/src/enrollment.rs +++ b/agent/crates/fleet-client/src/enrollment.rs @@ -1,6 +1,7 @@ use crate::types::{EnrollmentResult, RegisterRequest, RegisterResponse}; use anyhow::Result; -use tonic::{Request, client::Grpc, codec::ProstCodec, transport::Channel}; +use tonic::{Request, client::Grpc, transport::Channel}; +use tonic_prost::ProstCodec; pub struct AgentEnrollment; @@ -11,7 +12,7 @@ impl AgentEnrollment { let mut client = Grpc::new(channel); let path = http::uri::PathAndQuery::from_static("/edr.fleet.FleetService/RegisterAgent"); - let res = client + let res: RegisterResponse = client .unary(Request::new(request), path, ProstCodec::default()) .await? .into_inner(); diff --git a/agent/crates/fleet-client/src/heartbeat.rs b/agent/crates/fleet-client/src/heartbeat.rs index cd0d906..1e6d5c1 100644 --- a/agent/crates/fleet-client/src/heartbeat.rs +++ b/agent/crates/fleet-client/src/heartbeat.rs @@ -2,9 +2,8 @@ use crate::types::{HeartbeatRequest, HeartbeatResponse}; use anyhow::Result; use std::time::Duration; use tokio::time; -use tonic::{ - Request, client::Grpc, codec::ProstCodec, metadata::MetadataValue, transport::Channel, -}; +use tonic::{Request, client::Grpc, metadata::MetadataValue, transport::Channel}; +use tonic_prost::ProstCodec; pub struct HeartbeatManager; diff --git a/agent/crates/fleet-client/src/stream.rs b/agent/crates/fleet-client/src/stream.rs index 735d944..dd61bc5 100644 --- a/agent/crates/fleet-client/src/stream.rs +++ b/agent/crates/fleet-client/src/stream.rs @@ -2,9 +2,8 @@ use crate::types::{AgentEvent, ServerCommand}; use anyhow::Result; use tokio::sync::mpsc; use tokio_stream::wrappers::ReceiverStream; -use tonic::{ - Request, client::Grpc, codec::ProstCodec, metadata::MetadataValue, transport::Channel, -}; +use tonic::{Request, client::Grpc, metadata::MetadataValue, transport::Channel}; +use tonic_prost::ProstCodec; pub struct EventStreamManager; From 655b8f41cd89b982b67f2dcfe44d3b3e0eff61a6 Mon Sep 17 00:00:00 2001 From: ghule swarnit Date: Thu, 28 May 2026 19:01:40 +0530 Subject: [PATCH 04/69] refactor: cargo clippy --- agent/crates/agent-core/src/orchestrator.rs | 7 +------ agent/crates/agent-tracing/src/lib.rs | 7 ++----- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/agent/crates/agent-core/src/orchestrator.rs b/agent/crates/agent-core/src/orchestrator.rs index 719feec..8c107c9 100644 --- a/agent/crates/agent-core/src/orchestrator.rs +++ b/agent/crates/agent-core/src/orchestrator.rs @@ -1,12 +1,7 @@ use crate::config::AgentConfig; use anyhow::Result; use event_buffer::EventBuffer; -use fleet_client::{ - FleetClient, - types::{AgentEvent, EventType, RegisterRequest}, -}; -use osquery_client::OsqueryCollector; -use prost::Message; +use fleet_client::types::RegisterRequest; pub async fn run() -> Result<()> { let config_path = diff --git a/agent/crates/agent-tracing/src/lib.rs b/agent/crates/agent-tracing/src/lib.rs index 9fddfa9..6be1101 100644 --- a/agent/crates/agent-tracing/src/lib.rs +++ b/agent/crates/agent-tracing/src/lib.rs @@ -3,18 +3,15 @@ use tracing_subscriber::{EnvFilter, fmt}; /// Log output format. #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Default)] pub enum LogFormat { /// Pretty-printed, colored, human-readable (for development) + #[default] Human, /// Structured JSON (for production / log aggregation) Json, } -impl Default for LogFormat { - fn default() -> Self { - Self::Human - } -} /// Initialize the agent's tracing/logging infrastructure. pub fn init(log_level: &str, format: LogFormat) -> Result<()> { From 8b5e6281e6885275aadda3f17e64a52eb3ed47e5 Mon Sep 17 00:00:00 2001 From: ghule swarnit Date: Thu, 28 May 2026 19:50:22 +0530 Subject: [PATCH 05/69] feat: enable osquery audit logging in Docker, add automated dependency and maintenance workflows, and implement scheduled query loading --- .github/CI_CACHING_STRATEGY.md | 188 ------------------- .github/workflows/autofix.yml | 80 ++++++++ .github/workflows/ci.yml | 130 +++++++------ .github/workflows/update-deps.yml | 68 +++++++ .testingignore | 20 ++ agent.toml | 27 ++- agent/Dockerfile | 42 +++-- agent/crates/agent-core/Cargo.toml | 2 + agent/crates/agent-core/src/config.rs | 4 + agent/crates/agent-core/src/orchestrator.rs | 167 ++++++++++++++-- agent/crates/osquery-client/src/client.rs | 1 + agent/crates/osquery-client/src/lib.rs | 9 +- agent/crates/osquery-client/src/scheduler.rs | 84 ++++++--- agent/timeline.md | 77 -------- agent/todo.md | 150 --------------- run-docker-agent.sh | 12 +- scheduled_queries.toml | 63 +++++++ 17 files changed, 582 insertions(+), 542 deletions(-) delete mode 100644 .github/CI_CACHING_STRATEGY.md create mode 100644 .github/workflows/autofix.yml create mode 100644 .github/workflows/update-deps.yml create mode 100644 .testingignore delete mode 100644 agent/timeline.md delete mode 100644 agent/todo.md create mode 100644 scheduled_queries.toml diff --git a/.github/CI_CACHING_STRATEGY.md b/.github/CI_CACHING_STRATEGY.md deleted file mode 100644 index 19f467b..0000000 --- a/.github/CI_CACHING_STRATEGY.md +++ /dev/null @@ -1,188 +0,0 @@ -# CI Build Caching Strategy - -Rust builds are notoriously slow — a clean workspace build can take 5–15 minutes in CI. This document outlines the caching strategy to keep CI runs under 2 minutes for incremental builds. - ---- - -## 1. GitHub Actions Cache Layers - -### Layer 1 — Cargo Registry & Index (`~/.cargo`) - -Caches downloaded crate sources and the registry index so `cargo` doesn't re-download 200+ dependencies on every run. - -```yaml -- uses: actions/cache@v4 - with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - key: cargo-registry-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - cargo-registry- -``` - -### Layer 2 — Compiled Dependencies (`target/`) - -This is the big one. Caching `target/` means only changed crates recompile. - -```yaml -- uses: Swatinem/rust-cache@v2 - with: - workspaces: ". -> target" - shared-key: "workspace" - cache-targets: true - cache-all-crates: true -``` - -> **Why `Swatinem/rust-cache`?** It handles cache invalidation intelligently — hashing `Cargo.lock`, `Cargo.toml`, rustc version, and target triple. It also prunes stale artifacts to keep cache size under GitHub's 10GB limit. - -### Layer 3 — sccache (Optional, for larger teams) - -For teams with many contributors, `sccache` provides a shared compilation cache backed by S3/GCS. - -```yaml -- name: Install sccache - run: cargo install sccache --locked -- name: Configure sccache - run: | - echo "SCCACHE_GCS_BUCKET=your-bucket" >> $GITHUB_ENV - echo "RUSTC_WRAPPER=sccache" >> $GITHUB_ENV -``` - ---- - -## 2. Optimised CI Workflow - -```yaml -name: CI - -on: - push: - branches: [main, develop] - pull_request: - branches: [main, develop] - -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - CARGO_NET_RETRY: 10 - RUST_BACKTRACE: short - RUSTFLAGS: "-D warnings" - -jobs: - check: - name: Check & Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt, clippy - - - uses: Swatinem/rust-cache@v2 - with: - shared-key: "workspace" - - - name: Format check - run: cargo fmt --all -- --check - - - name: Clippy - run: cargo clippy --workspace --all-targets -- -D warnings - - - name: Check - run: cargo check --workspace - - test: - name: Test - runs-on: ubuntu-latest - needs: check - services: - postgres: - image: postgres:16-alpine - env: - POSTGRES_PASSWORD: testpass - POSTGRES_DB: edr_test - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 5432:5432 - steps: - - uses: actions/checkout@v4 - - - uses: dtolnay/rust-toolchain@stable - - - uses: Swatinem/rust-cache@v2 - with: - shared-key: "workspace" - - - name: Run tests - run: cargo test --workspace - env: - DATABASE_URL: postgres://postgres:testpass@localhost:5432/edr_test - - security: - name: Security Audit - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: rustsec/audit-check@v2.0.0 - with: - token: ${{ secrets.GITHUB_TOKEN }} -``` - ---- - -## 3. Key Optimisations Explained - -| Setting | Why | -|---|---| -| `CARGO_INCREMENTAL: 0` | Incremental compilation produces larger caches with diminishing returns in CI. Disabling it produces smaller, more cacheable artifacts | -| `RUSTFLAGS: "-D warnings"` | Fail on warnings in CI — catches issues early without slowing local dev | -| `CARGO_NET_RETRY: 10` | Retry flaky crate downloads instead of failing the whole run | -| `needs: check` on test job | Skip expensive tests if formatting/linting fails — fast feedback | -| `shared-key: "workspace"` | All jobs share the same cache — test job reuses compiled deps from check job | - -## 4. Cache Size Management - -GitHub Actions caches are limited to **10 GB per repository**. Rust `target/` dirs can grow large. - -**Mitigation strategies:** -- `Swatinem/rust-cache` automatically prunes old artifacts -- Set `CARGO_INCREMENTAL=0` to reduce cache size by ~40% -- Cache key includes `Cargo.lock` hash — dependency updates naturally invalidate stale caches -- Separate cache keys per branch if needed: `shared-key: "${{ github.ref }}"` - -## 5. Local Development — Speeding Up Builds - -For local builds on your Mac: - -```bash -# Use mold linker (Linux) or zld (macOS) for faster linking -# macOS: -brew install michaeleisel/zld/zld -export RUSTFLAGS="-C link-arg=-fuse-ld=/usr/local/bin/zld" - -# Or use cranelift backend for debug builds (much faster codegen) -# Add to .cargo/config.toml: -# [profile.dev] -# codegen-backend = "cranelift" - -# Use cargo-watch for auto-rebuild on save -cargo install cargo-watch -cargo watch -x 'check --workspace' -``` - -## 6. Expected Build Times - -| Scenario | Estimated Time | -|---|---| -| Clean build (no cache) | 8–15 min | -| Cached build (deps only) | 1–3 min | -| Cached build (incremental, single crate change) | 20–45 sec | -| `cargo check` (cached) | 10–20 sec | diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml new file mode 100644 index 0000000..27821f2 --- /dev/null +++ b/.github/workflows/autofix.yml @@ -0,0 +1,80 @@ +name: Auto-fix (clippy + fmt) + +# Runs every Monday at 03:00 UTC. +# Applies `cargo clippy --fix` and `cargo fmt` then opens a PR if anything changed. +on: + schedule: + - cron: "0 3 * * 1" + workflow_dispatch: # allow manual trigger + +permissions: + contents: write + pull-requests: write + +jobs: + autofix: + name: Apply clippy fixes and format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # Need a token with write access to push the branch + open a PR. + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install Rust stable + clippy + rustfmt + uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - name: Cache cargo registry + build artifacts + uses: Swatinem/rust-cache@v2 + with: + shared-key: "autofix" + + - name: cargo clippy --fix (all lints, allow dirty/staged) + run: | + cargo clippy --workspace --all-targets --fix \ + --allow-dirty \ + --allow-staged \ + -- -W clippy::all -W clippy::pedantic -W clippy::nursery \ + -A clippy::missing_errors_doc \ + -A clippy::missing_panics_doc \ + -A clippy::module_name_repetitions + continue-on-error: true + + - name: cargo fmt --all + run: cargo fmt --all + + - name: Check if anything changed + id: diff + run: | + git diff --quiet && echo "changed=false" >> "$GITHUB_OUTPUT" \ + || echo "changed=true" >> "$GITHUB_OUTPUT" + + - name: Commit changes + if: steps.diff.outputs.changed == 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git checkout -b "autofix/clippy-fmt-$(date +%Y%m%d)" + git add -A + git commit -m "chore: apply clippy fixes and cargo fmt [automated]" + git push --set-upstream origin "autofix/clippy-fmt-$(date +%Y%m%d)" + + - name: Open pull request + if: steps.diff.outputs.changed == 'true' + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ secrets.GITHUB_TOKEN }} + branch: "autofix/clippy-fmt-${{ github.run_id }}" + title: "chore: automated clippy + fmt fixes" + body: | + Automated weekly PR generated by the **Auto-fix** workflow. + + Changes applied: + - `cargo clippy --fix` (all, pedantic, nursery) + - `cargo fmt --all` + + Review before merging — clippy's pedantic fixes occasionally need human judgment. + labels: automated, chore + delete-branch: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8bfd56..5dd7061 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,75 +2,99 @@ name: CI on: push: - branches: [main, develop] + branches: [main, dev] pull_request: - branches: [main, develop] + branches: [main, dev] env: CARGO_TERM_COLOR: always - RUST_BACKTRACE: 1 + RUSTFLAGS: "-D warnings" jobs: - lint: - name: Lint + + check: + name: cargo check runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry + build artifacts + uses: Swatinem/rust-cache@v2 with: - components: rustfmt, clippy - - uses: Swatinem/rust-cache@v2 - - name: Format check - run: | - for dir in sdk agent fleet-server kafka-pipeline rule-engine api-backend; do - echo "--- Checking $dir ---" - cargo fmt --manifest-path $dir/Cargo.toml --all -- --check - done - - name: Clippy - run: | - for dir in sdk agent fleet-server kafka-pipeline rule-engine api-backend; do - echo "--- Clippy $dir ---" - cargo clippy --manifest-path $dir/Cargo.toml --all-targets --all-features -- -D warnings - done + shared-key: "ci" + + - name: cargo check (all workspace members) + run: cargo check --workspace --all-targets + + + clippy: + name: cargo clippy + runs-on: ubuntu-latest + needs: check + steps: + - uses: actions/checkout@v4 + + - name: Install Rust stable + clippy + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Cache cargo registry + build artifacts + uses: Swatinem/rust-cache@v2 + with: + shared-key: "ci" + + - name: cargo clippy (deny warnings) + run: cargo clippy --workspace --all-targets -- -D warnings + + + fmt: + name: cargo fmt + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust stable + rustfmt + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + + - name: cargo fmt check + run: cargo fmt --all -- --check test: - name: Test + name: cargo test runs-on: ubuntu-latest - services: - postgres: - image: postgres:16-alpine - env: - POSTGRES_PASSWORD: testpass - POSTGRES_DB: edr_test - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 + needs: check steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - - name: Run tests - run: | - for dir in sdk agent fleet-server kafka-pipeline rule-engine api-backend; do - echo "--- Testing $dir ---" - cargo test --manifest-path $dir/Cargo.toml --all - done - env: - DATABASE_URL: postgres://postgres:testpass@localhost:5432/edr_test - - security: - name: Security Audit + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry + build artifacts + uses: Swatinem/rust-cache@v2 + with: + shared-key: "ci" + + - name: cargo test (all workspace members) + run: cargo test --workspace + + + audit: + name: cargo audit runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + - name: Install cargo-audit - run: cargo install cargo-audit - - name: Audit dependencies - run: | - for dir in sdk agent fleet-server kafka-pipeline rule-engine api-backend; do - echo "--- Auditing $dir ---" - cargo audit --file $dir/Cargo.toml - done + run: cargo install cargo-audit --locked + + - name: cargo audit + run: cargo audit diff --git a/.github/workflows/update-deps.yml b/.github/workflows/update-deps.yml new file mode 100644 index 0000000..659d4f1 --- /dev/null +++ b/.github/workflows/update-deps.yml @@ -0,0 +1,68 @@ +name: Update dependencies + +# Runs every Sunday at 02:00 UTC. +# Updates all workspace dependencies to latest compatible versions and opens a PR. +on: + schedule: + - cron: "0 2 * * 0" + workflow_dispatch: # allow manual trigger + +permissions: + contents: write + pull-requests: write + +jobs: + update-deps: + name: cargo update + audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + + - name: Install cargo-audit + run: cargo install cargo-audit --locked + + - name: cargo update (update Cargo.lock to latest compatible versions) + run: cargo update + + - name: cargo audit (fail the PR if new vulnerabilities were introduced) + run: cargo audit + continue-on-error: true # still open the PR so the team can review + + - name: Check if Cargo.lock changed + id: diff + run: | + git diff --quiet Cargo.lock \ + && echo "changed=false" >> "$GITHUB_OUTPUT" \ + || echo "changed=true" >> "$GITHUB_OUTPUT" + + - name: Commit updated Cargo.lock + if: steps.diff.outputs.changed == 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git checkout -b "deps/update-cargo-lock-$(date +%Y%m%d)" + git add Cargo.lock + git commit -m "chore(deps): update Cargo.lock to latest compatible versions [automated]" + git push --set-upstream origin "deps/update-cargo-lock-$(date +%Y%m%d)" + + - name: Open pull request + if: steps.diff.outputs.changed == 'true' + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ secrets.GITHUB_TOKEN }} + branch: "deps/update-cargo-lock-${{ github.run_id }}" + title: "chore(deps): update Cargo.lock [${{ github.run_id }}]" + body: | + Automated weekly dependency update generated by the **Update dependencies** workflow. + + - Run `cargo update` to bump all workspace crates to the latest semver-compatible versions. + - `cargo audit` was also run — check the job log for any new advisories before merging. + + **Review the diff before merging** to catch any unexpected major-version bumps or behavioural changes. + labels: automated, dependencies + delete-branch: true diff --git a/.testingignore b/.testingignore new file mode 100644 index 0000000..e480017 --- /dev/null +++ b/.testingignore @@ -0,0 +1,20 @@ +# .testingignore +# Files and config that exist ONLY for testing purposes. +# Before promoting to production, remove or replace each item listed here. +# +# Format: one path per line, with a note on what to do. + +# Seed file for scheduled queries — replace with fleet server push. +scheduled_queries.toml + +# agent.toml: remove the `scheduled_queries_path` field from [agent] section +# when queries are pushed by the fleet server instead. +# agent.toml → [agent] → scheduled_queries_path + +# Ephemeral SQLite event buffer location (stored in /tmp — lost on reboot). +# Change agent.toml → [agent] → buffer_path to a persistent location in production. +# e.g. /var/lib/edr/event_buffer.db +/tmp/edr_event_buffer.db + +# SQLite scheduler DB (co-located with buffer for now — same path). +# In production these should be separate files in a persistent directory. diff --git a/agent.toml b/agent.toml index 0a46841..2e186ca 100644 --- a/agent.toml +++ b/agent.toml @@ -1,12 +1,15 @@ [fleet] -endpoint = "http://host.docker.internal:50051" +endpoint = "http://localhost:50051" heartbeat_interval_secs = 30 batch_size = 100 [agent] buffer_path = "/tmp/edr_event_buffer.db" -log_level = "info" +log_level = "debug" log_format = "human" +# TESTING ONLY: path to seed scheduled queries into SQLite on startup. +# Remove this field (and scheduled_queries.toml) when fleet server push is ready. +scheduled_queries_path = "scheduled_queries.toml" [osquery] socket_path = "/var/osquery/osquery.em" @@ -14,8 +17,18 @@ connect_timeout_secs = 10 schedule = [] [osquery.options] -disable_logging = true -disable_events = false -disable_audit = false -events_max = 50000 -watchdog_level = 0 +# -- Events & Audit (matches updated Dockerfile) -- +disable_events = false +disable_audit = false +audit_allow_process_events = true +audit_allow_sockets = true +audit_allow_config = true +audit_persist = true + +# -- Performance -- +events_max = 50000 +schedule_splay_percent = 10 +watchdog_level = 0 + +# -- Identity -- +host_identifier = "hostname" diff --git a/agent/Dockerfile b/agent/Dockerfile index fee90b0..c38369f 100644 --- a/agent/Dockerfile +++ b/agent/Dockerfile @@ -15,6 +15,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ git \ openssh-server \ sudo \ + # Required for osquery audit event collection + auditd \ + libaudit1 \ + iproute2 \ && rm -rf /var/lib/apt/lists/* # Add official OSQuery repository and install OSQuery @@ -32,27 +36,31 @@ ENV CARGO_TARGET_DIR="/tmp/target" # Ensure required directories exist for osquery socket and configurations RUN mkdir -p /var/osquery /etc/osquery /var/log/osquery -# Setup osquery.flags to enable the eventing and auditing subsystems -RUN echo '\ ---disable_events=true\n\ ---disable_audit=true\n\ ---audit_allow_process_events=false\n\ ---audit_allow_sockets=false\n\ ---audit_allow_config=false\n\ ---audit_persist=false\n\ +# Setup osquery.flags — events and audit enabled. +# The container must be run with --cap-add AUDIT_CONTROL --cap-add AUDIT_READ +# (or --privileged) for the audit subsystem to function. +RUN printf -- \ +'--disable_events=false\n\ +--disable_audit=false\n\ +--audit_allow_process_events=true\n\ +--audit_allow_sockets=true\n\ +--audit_allow_config=true\n\ +--audit_persist=true\n\ --events_max=50000\n\ --socket=/var/osquery/osquery.em\n\ +--host_identifier=hostname\n\ +--schedule_splay_percent=10\n\ ' > /etc/osquery/osquery.flags -# Setup basic osquery.conf with options and schedules -RUN echo '{\n\ +# Setup basic osquery.conf +RUN printf '{\n\ "options": {\n\ - "disable_events": "true",\n\ - "disable_audit": "true",\n\ - "audit_allow_process_events": "false",\n\ - "audit_allow_sockets": "false",\n\ - "audit_allow_config": "false",\n\ - "audit_persist": "false",\n\ + "disable_events": "false",\n\ + "disable_audit": "false",\n\ + "audit_allow_process_events": "true",\n\ + "audit_allow_sockets": "true",\n\ + "audit_allow_config": "true",\n\ + "audit_persist": "true",\n\ "events_max": "50000",\n\ "host_identifier": "hostname",\n\ "schedule_splay_percent": "10"\n\ @@ -69,7 +77,7 @@ RUN echo '{\n\ VOLUME ["/var/osquery", "/etc/osquery"] # Setup startup entrypoint script to launch osqueryd in background and start shell -RUN echo '#!/bin/bash\n\ +RUN printf '#!/bin/bash\n\ # Start osqueryd daemon in background\n\ echo "Starting osqueryd..."\n\ osqueryd --flagfile=/etc/osquery/osquery.flags --config_path=/etc/osquery/osquery.conf --verbose &\n\ diff --git a/agent/crates/agent-core/Cargo.toml b/agent/crates/agent-core/Cargo.toml index a37b234..ebb47ba 100644 --- a/agent/crates/agent-core/Cargo.toml +++ b/agent/crates/agent-core/Cargo.toml @@ -18,3 +18,5 @@ agent-tracing = { workspace = true } fleet-client = { workspace = true } osquery-client = { workspace = true } event-buffer = { workspace = true } +hostname = "0.4" + diff --git a/agent/crates/agent-core/src/config.rs b/agent/crates/agent-core/src/config.rs index 3c51bb8..2d2fd00 100644 --- a/agent/crates/agent-core/src/config.rs +++ b/agent/crates/agent-core/src/config.rs @@ -38,6 +38,10 @@ pub struct AgentSection { /// Log output format: "human" (default, colored) | "json" (structured) pub log_format: Option, + + /// Path to a TOML file containing scheduled queries to seed into SQLite. + /// Testing only — remove path from config (or omit field) in production. + pub scheduled_queries_path: Option, } /// OSQuery daemon configuration. diff --git a/agent/crates/agent-core/src/orchestrator.rs b/agent/crates/agent-core/src/orchestrator.rs index 8c107c9..14f6733 100644 --- a/agent/crates/agent-core/src/orchestrator.rs +++ b/agent/crates/agent-core/src/orchestrator.rs @@ -1,7 +1,37 @@ use crate::config::AgentConfig; use anyhow::Result; use event_buffer::EventBuffer; -use fleet_client::types::RegisterRequest; +use osquery_client::types::{OsqueryResult, ScheduledQuery}; +use prost::Message as _; +use serde::Deserialize; +use tokio::sync::mpsc; + +/// Top-level structure of scheduled_queries.toml. +#[derive(Debug, Deserialize)] +struct ScheduledQueriesFile { + queries: Vec, +} + +/// One entry inside [[queries]] in the TOML file. +#[derive(Debug, Deserialize)] +struct ScheduledQueryEntry { + name: String, + query: String, + interval_secs: u64, + #[serde(default)] + snapshot: bool, +} + +impl From for ScheduledQuery { + fn from(e: ScheduledQueryEntry) -> Self { + ScheduledQuery { + name: e.name, + query: e.query, + interval_secs: e.interval_secs, + snapshot: e.snapshot, + } + } +} pub async fn run() -> Result<()> { let config_path = @@ -21,35 +51,140 @@ pub async fn run() -> Result<()> { agent_tracing::init(&config.agent.log_level, format)?; tracing::info!("Starting EDR Agent Orchestrator"); - let _buffer = EventBuffer::new(&config.agent.buffer_path)?; + // ── Event buffer (SQLite) ────────────────────────────────────────────── + // EventBuffer wraps rusqlite::Connection which is !Send, so we keep it + // on this task and never move it into tokio::spawn. + let buffer = EventBuffer::new(&config.agent.buffer_path) + .map_err(|e| anyhow::anyhow!("Failed to open event buffer: {}", e))?; tracing::info!("Initialized event buffer at {:?}", config.agent.buffer_path); + // ── Seed scheduled queries from TOML file (testing only) ────────────── + if let Some(sq_path) = &config.agent.scheduled_queries_path { + match seed_scheduled_queries(sq_path, &config) { + Ok(n) => tracing::info!( + "Seeded {} scheduled queries into SQLite from {:?}", + n, + sq_path + ), + Err(e) => tracing::warn!("Could not seed scheduled queries from {:?}: {}", sq_path, e), + } + } + + // ── Start OsqueryCollector ───────────────────────────────────────────── + let collector = osquery_client::OsqueryCollector::new(osquery_client::OsqueryConfig { + socket_path: config.osquery.socket_path.clone(), + db_path: config.agent.buffer_path.clone(), + }) + .await?; + + // agent_uuid: use node_id from config if available, otherwise a placeholder. + let agent_uuid = config + .agent + .node_id + .map(|u| u.to_string()) + .unwrap_or_else(|| "unregistered".to_string()); + + let mut results_rx = collector.start(&agent_uuid).await; + tracing::info!("OsqueryCollector started (agent_uuid={})", agent_uuid); + + // ── Fleet enrollment (non-fatal, fleet server not ready yet) ────────── + tracing::info!("Attempting fleet enrollment (non-fatal if server is down)..."); let mut fleet_client = fleet_client::FleetClient::new(fleet_client::FleetConfig { endpoint: config.fleet.endpoint.clone(), }) .await?; - let req = RegisterRequest { - hostname: "mock-hostname".to_string(), - os_version: "mock-os".to_string(), - agent_version: "0.1.0".to_string(), - machine_id: "mock-machine-id".to_string(), + let req = fleet_client::types::RegisterRequest { + hostname: hostname_or_default(), + os_version: "linux".to_string(), + agent_version: env!("CARGO_PKG_VERSION").to_string(), + machine_id: read_machine_id(), }; - match fleet_client.enroll(req).await { - Ok(enrollment) => { - tracing::info!("Enrolled successfully with node_id: {}", enrollment.node_id); + match tokio::time::timeout(std::time::Duration::from_secs(5), fleet_client.enroll(req)).await { + Ok(Ok(enrollment)) => { + tracing::info!("Enrolled with fleet server. node_id={}", enrollment.node_id); + } + Ok(Err(e)) => { + tracing::warn!("Fleet enrollment failed (will continue offline): {}", e); } - Err(e) => { - tracing::error!("Failed to enroll: {}", e); - // We would continue and buffer locally, but stub for now. + Err(_) => { + tracing::warn!("Fleet enrollment timed out after 5s — running in offline mode."); } } - // Stub: We would also create OsqueryCollector and route events here. + // ── Main loop — drain results & handle shutdown ─────────────────────── + // rusqlite::Connection is !Send so we drive the buffer writes here on the + // main task rather than in a spawned task. + tracing::info!("Agent is running. Draining osquery results. Press Ctrl-C to stop."); - // Keeping main alive - tokio::time::sleep(tokio::time::Duration::from_secs(60)).await; + let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1); + tokio::spawn(async move { + let _ = tokio::signal::ctrl_c().await; + tracing::info!("Ctrl-C received, signalling shutdown."); + let _ = shutdown_tx.send(()).await; + }); + + loop { + tokio::select! { + Some(result) = results_rx.recv() => { + let bytes = encode_result(&result); + match buffer.push(&bytes) { + Ok(()) => tracing::debug!( + "Buffered '{}' ({} rows, action={})", + result.query_name, + result.rows.len(), + result.action, + ), + Err(e) => tracing::error!("Failed to buffer result: {}", e), + } + } + _ = shutdown_rx.recv() => { + tracing::info!("Shutting down agent."); + break; + } + } + } Ok(()) } + +// ── Helpers ──────────────────────────────────────────────────────────────── + +/// Encode an OsqueryResult to raw bytes for storage in the event buffer. +/// Uses prost protobuf encoding. +fn encode_result(result: &OsqueryResult) -> Vec { + result.encode_to_vec() +} + +/// Load scheduled_queries.toml, upsert into the scheduler's SQLite table. +/// Returns the number of queries upserted. +fn seed_scheduled_queries(toml_path: &std::path::Path, config: &AgentConfig) -> Result { + let content = std::fs::read_to_string(toml_path) + .map_err(|e| anyhow::anyhow!("Failed to read {:?}: {}", toml_path, e))?; + + let file: ScheduledQueriesFile = toml::from_str(&content) + .map_err(|e| anyhow::anyhow!("Failed to parse {:?}: {}", toml_path, e))?; + + let queries: Vec = file.queries.into_iter().map(Into::into).collect(); + let n = queries.len(); + + let mut scheduler = osquery_client::scheduler::QueryScheduler::new(&config.agent.buffer_path)?; + scheduler.upsert_queries(&queries)?; + + Ok(n) +} + +fn hostname_or_default() -> String { + hostname::get() + .ok() + .and_then(|h| h.into_string().ok()) + .unwrap_or_else(|| "unknown-host".to_string()) +} + +fn read_machine_id() -> String { + std::fs::read_to_string("/etc/machine-id") + .unwrap_or_default() + .trim() + .to_string() +} diff --git a/agent/crates/osquery-client/src/client.rs b/agent/crates/osquery-client/src/client.rs index 5844ec5..e340d7e 100644 --- a/agent/crates/osquery-client/src/client.rs +++ b/agent/crates/osquery-client/src/client.rs @@ -17,6 +17,7 @@ pub struct OsqueryClient { impl OsqueryClient { pub async fn connect(socket_path: &Path) -> Result { + tracing::debug!("Connecting to osquery at {}", socket_path.display()); Ok(Self { socket_path: socket_path.to_path_buf(), }) diff --git a/agent/crates/osquery-client/src/lib.rs b/agent/crates/osquery-client/src/lib.rs index e33ca91..152422b 100644 --- a/agent/crates/osquery-client/src/lib.rs +++ b/agent/crates/osquery-client/src/lib.rs @@ -32,8 +32,13 @@ impl OsqueryCollector { let agent_uuid = agent_uuid.to_string(); tokio::spawn(async move { - if let Ok(scheduler) = QueryScheduler::new(&scheduler_db_path) { - scheduler.run(tx, socket_path, agent_uuid).await; + match QueryScheduler::new(&scheduler_db_path) { + Ok(scheduler) => scheduler.run(tx, socket_path, agent_uuid).await, + Err(e) => tracing::error!( + "Failed to open scheduler SQLite DB at {:?}: {}", + scheduler_db_path, + e + ), } }); diff --git a/agent/crates/osquery-client/src/scheduler.rs b/agent/crates/osquery-client/src/scheduler.rs index c97a242..1d4df8e 100644 --- a/agent/crates/osquery-client/src/scheduler.rs +++ b/agent/crates/osquery-client/src/scheduler.rs @@ -8,6 +8,7 @@ use chrono::Utc; use rusqlite::Connection; use std::path::{Path, PathBuf}; use tokio::sync::mpsc; + pub struct QueryScheduler { conn: Connection, } @@ -72,26 +73,38 @@ impl QueryScheduler { )?; } - // Remove old queries not in this update (if we consider this update to be the full state) - // Note: For now, we just upsert. If full replacement is needed, we'd delete missing ones. - tx.commit()?; Ok(()) } + /// Run the scheduler. Each scheduled query gets its own task with a persistent + /// OsqueryClient connection that reconnects on error. + /// + /// Queries are loaded *before* entering the async context so that the + /// rusqlite::Connection is never held across an await point. pub async fn run( self, tx: mpsc::Sender, socket_path: PathBuf, agent_uuid: String, ) { + // Load queries synchronously before dropping self (and its Connection). let queries = match self.load_queries() { Ok(q) => q, Err(e) => { - tracing::error!("Failed to load scheduled queries: {}", e); + tracing::error!("Failed to load scheduled queries from SQLite: {}", e); return; } }; + // Drop self here — Connection is no longer held. + drop(self); + + if queries.is_empty() { + tracing::warn!("No scheduled queries found in SQLite — nothing to run."); + return; + } + + tracing::info!("Starting {} scheduled query task(s)", queries.len()); for query in queries { let tx = tx.clone(); @@ -99,72 +112,77 @@ impl QueryScheduler { let agent_uuid = agent_uuid.clone(); tokio::spawn(async move { + // Connect once per query task; reconnect on error inside the loop. + let mut client = loop { + match OsqueryClient::connect(&socket_path).await { + Ok(c) => break c, + Err(e) => { + tracing::warn!( + "[{}] Cannot connect to osquery yet ({}), retrying in 5s...", + query.name, + e + ); + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + } + } + }; + let mut previous_rows: Vec = Vec::new(); let mut first_run = true; - let mut interval = tokio::time::interval(std::time::Duration::from_secs(query.interval_secs)); loop { interval.tick().await; - let mut client = match OsqueryClient::connect(&socket_path).await { - Ok(c) => c, - Err(e) => { - tracing::warn!( - "Failed to connect to osqueryd for query {}: {}", - query.name, - e - ); - continue; - } - }; - let response = match client.query(&query.query).await { Ok(res) => res, Err(e) => { - tracing::warn!("Query {} failed: {}", query.name, e); + tracing::warn!("[{}] Query error: {}", query.name, e); + // Client will reconnect internally on next call. continue; } }; if response.status.code != 0 { tracing::warn!( - "Query {} returned osquery error: {}", + "[{}] osquery error (code {}): {}", query.name, + response.status.code, response.status.message ); continue; } let current_rows = response.rows; + tracing::debug!("[{}] Got {} rows", query.name, current_rows.len()); if query.snapshot { - // Snapshot mode: emit all rows every time + // Snapshot mode: emit all rows every tick. let result = Self::build_result( &query.name, &agent_uuid, current_rows, ResultAction::Snapshot, ); - if let Err(e) = tx.send(result).await { - tracing::error!( - "Failed to send query result for {}: {}", - query.name, - e - ); + if tx.send(result).await.is_err() { + tracing::info!("[{}] Result channel closed, stopping task.", query.name); break; } } else { - // Differential mode + // Differential mode. if first_run { + // First run: emit a full snapshot as the baseline. let result = Self::build_result( &query.name, &agent_uuid, current_rows.clone(), ResultAction::Snapshot, ); - let _ = tx.send(result).await; + if tx.send(result).await.is_err() { + tracing::info!("[{}] Result channel closed, stopping task.", query.name); + break; + } first_run = false; } else { let (added, removed) = @@ -177,7 +195,10 @@ impl QueryScheduler { added, ResultAction::Added, ); - let _ = tx.send(res).await; + if tx.send(res).await.is_err() { + tracing::info!("[{}] Result channel closed, stopping task.", query.name); + break; + } } if !removed.is_empty() { let res = Self::build_result( @@ -186,7 +207,10 @@ impl QueryScheduler { removed, ResultAction::Removed, ); - let _ = tx.send(res).await; + if tx.send(res).await.is_err() { + tracing::info!("[{}] Result channel closed, stopping task.", query.name); + break; + } } } previous_rows = current_rows; diff --git a/agent/timeline.md b/agent/timeline.md deleted file mode 100644 index 0ecaca9..0000000 --- a/agent/timeline.md +++ /dev/null @@ -1,77 +0,0 @@ -# Agent Workspace — Implementation Timeline - -> **Phases**: 1 (OSQuery) → 2 (eBPF) → 8 (Isolation) -> **Priority**: 🔴 Critical path — agent is the data source for everything -> **Estimated Duration**: 4–5 weeks total across phases -> **Depends on**: `sdk v0.1.0` tagged - ---- - -## Overview - -The agent is a Cargo workspace with 6 member crates. Implementation follows a bottom-up dependency order: shared utilities first, then collectors, then the orchestrator. - -## Implementation Order - -```mermaid -flowchart TD - SDK["SDK v0.1.0"] --> OSQ["osquery-client"] - SDK --> BUF["event-buffer"] - SDK --> FC["fleet-client"] - SDK --> EBPF["ebpf-collector"] - - OSQ --> CORE["agent-core"] - BUF --> CORE - FC --> CORE - EBPF --> CORE - ISO["isolation"] --> CORE - - subgraph "Phase 1 (Week 1-2)" - OSQ - BUF - FC - end - - subgraph "Phase 2 (Week 3-4)" - EBPF - end - - subgraph "Phase 8 (Week 7)" - ISO - end - - style OSQ fill:#22c55e,color:#fff - style BUF fill:#22c55e,color:#fff - style FC fill:#22c55e,color:#fff - style EBPF fill:#3b82f6,color:#fff - style ISO fill:#a855f7,color:#fff - style CORE fill:#ef4444,color:#fff - style SDK fill:#6b7280,color:#fff -``` - -## Cross-Crate PRs - -### PR #1 — Workspace config and CI verification -**Branch**: `chore/workspace-setup` -**Duration**: 0.5 day - -**Tasks**: -- [ ] Verify all 6 crate `Cargo.toml` files resolve correctly -- [ ] Ensure `cargo check --workspace` passes (with stub lib.rs files) -- [ ] Verify `.cargo/config.toml` linker settings -- [ ] Add workspace-level `rustfmt.toml` and `clippy.toml` - -### PR #2 — Integration test harness -**Branch**: `feat/integration-tests` -**Duration**: 1 day -**Depends on**: All Phase 1 crates implemented - -**Tasks**: -- [ ] Create `tests/` directory at workspace root -- [ ] Write integration test: osquery-client → event-buffer → fleet-client pipeline -- [ ] Mock Fleet Server gRPC endpoint for testing -- [ ] Verify events flow end-to-end locally - ---- - -See individual crate `timeline.md` files for per-crate PR plans. diff --git a/agent/todo.md b/agent/todo.md deleted file mode 100644 index 9f7bc15..0000000 --- a/agent/todo.md +++ /dev/null @@ -1,150 +0,0 @@ -# EDR Agent Stubs and TODOs - -This file tracks all the stubs, placeholders, mock implementations, and incomplete parts of the codebase within the `/agent` directory. - ---- - -## 1. Entirely Empty/Stub Crates - -The following crates are defined in the workspace but contain no functional logic: - -### 🔴 **ebpf-collector** -* **Location**: `crates/ebpf-collector/` -* **Current State**: - * [lib.rs](file:///Users/swar/C/R/oss/project-edr/agent/crates/ebpf-collector/src/lib.rs) is empty except for a comment: `// eBPF collector — kernel-level event telemetry.` - * The `bpf/` directory is completely empty. -* **To Do**: Implement eBPF kernel-level event telemetry and hook it up to the agent. - -### 🔴 **isolation** -* **Location**: `crates/isolation/` -* **Current State**: - * [lib.rs](file:///Users/swar/C/R/oss/project-edr/agent/crates/isolation/src/lib.rs) is empty except for a comment: `// Isolation — network quarantine via iptables.` -* **To Do**: Implement network quarantine mechanisms using `iptables` or similar platforms/tools. - ---- - -## 2. Agent Core Orchestrator - -### 🟡 **Mock System Metadata** -* **Location**: [orchestrator.rs:L37-42](file:///Users/swar/C/R/oss/project-edr/agent/crates/agent-core/src/orchestrator.rs#L37-42) -* **Stub**: - ```rust - let req = RegisterRequest { - hostname: "mock-hostname".to_string(), - os_version: "mock-os".to_string(), - agent_version: "0.1.0".to_string(), - machine_id: "mock-machine-id".to_string(), - }; - ``` -* **To Do**: Query the host OS for actual hostname, OS version, EDR agent version, and unique machine/hardware ID. - -### 🟡 **Enrollment Fallback & Local Buffer Routing** -* **Location**: [orchestrator.rs:L50](file:///Users/swar/C/R/oss/project-edr/agent/crates/agent-core/src/orchestrator.rs#L50) -* **Stub**: - ```rust - // We would continue and buffer locally, but stub for now. - ``` -* **To Do**: When the enrollment fails or Fleet Server is offline, route collected telemetry to the local SQLite-backed `EventBuffer` to ensure no data is lost, and retry enrollment/connection in the background. - -### 🟡 **Osquery Collector Spawning** -* **Location**: [orchestrator.rs:L54-57](file:///Users/swar/C/R/oss/project-edr/agent/crates/agent-core/src/orchestrator.rs#L54-57) -* **Stub**: - ```rust - // Stub: We would also create OsqueryCollector and route events here. - - // Keeping main alive - tokio::time::sleep(tokio::time::Duration::from_secs(60)).await; - ``` -* **To Do**: Instantiate `OsqueryCollector`, start the scheduler, start the query loops, and pipe the output to the `FleetClient` event stream or the local buffer. Remove the temporary `sleep` block. - ---- - -## 3. Osquery Client - -### 🟡 **Osquery Thrift Unix Socket Client** -* **Location**: [client.rs:L16-44](file:///Users/swar/C/R/oss/project-edr/agent/crates/osquery-client/src/client.rs#L16-44) -* **Stub**: - ```rust - pub async fn query(&mut self, sql: &str) -> Result { - // Stub for now. Will require Thrift serialization over UnixStream. - tracing::debug!("Executing query: {}", sql); - Ok(QueryResponse { ... }) - } - ``` -* **To Do**: Implement full Apache Thrift serialization and deserialization over `UnixStream` to communicate with the osquery daemon socket instead of returning mocked empty results. Implement `get_query_columns`, `ping`, and `reconnect` logic. - -### 🟡 **Query Scheduler Upsert Cleanup** -* **Location**: [scheduler.rs:L70](file:///Users/swar/C/R/oss/project-edr/agent/crates/osquery-client/src/scheduler.rs#L70) -* **Stub**: - ```rust - // Note: For now, we just upsert. If full replacement is needed, we'd delete missing ones. - ``` -* **To Do**: When updating query schedules, perform a deletion sync to remove any scheduled queries that are no longer present in the updated fleet config configuration. - -### 🟡 **Scheduler Execution Loop** -* **Location**: [scheduler.rs:L76-80](file:///Users/swar/C/R/oss/project-edr/agent/crates/osquery-client/src/scheduler.rs#L76-80) -* **Stub**: - ```rust - pub async fn run(self, _tx: mpsc::Sender) { - // Implement the actual loop here. - // It will spawn tasks for each query, using OsqueryClient::query, and tracking diffs. - // Left as stub for now until client is implemented. - } - ``` -* **To Do**: Write the runtime scheduler loop that runs periodically, schedules/spawns tasks for each query based on their intervals, tracks diffs between runs, and sends differential/snapshot changes to the channel. - ---- - -## 4. Fleet Client - -### 🟡 **Mock Enrollment Service** -* **Location**: [enrollment.rs:L9-18](file:///Users/swar/C/R/oss/project-edr/agent/crates/fleet-client/src/enrollment.rs#L9-18) -* **Stub**: - ```rust - // Stub: In a real implementation we would make a gRPC call here using the - // manually constructed or generated FleetServiceClient over the given channel. - // For now, we mock the response. - ``` -* **To Do**: Replace the mocked UUID/JWT response generation with a real gRPC enrollment call. - -### 🟡 **Mock Heartbeat Manager** -* **Location**: [heartbeat.rs:L18-26](file:///Users/swar/C/R/oss/project-edr/agent/crates/fleet-client/src/heartbeat.rs#L18-26) -* **Stub**: - ```rust - // Stub: In a real implementation we would make a gRPC call here using the - // manually constructed or generated FleetServiceClient over the given channel. - tokio::spawn(async move { - loop { - interval.tick().await; - tracing::debug!("Sending heartbeat for node: {}", node_id); - // Send HeartbeatRequest { node_id, status: "healthy", events_buffered: 0 } - } - }); - ``` -* **To Do**: Implement gRPC client heartbeat dispatching, tracking the number of buffered events in the local database, and reacting to server status checks. - -### 🟡 **Mock Bidirectional Event Stream** -* **Location**: [stream.rs:L16-24](file:///Users/swar/C/R/oss/project-edr/agent/crates/fleet-client/src/stream.rs#L16-24) -* **Stub**: - ```rust - // Stub: In a real implementation we would open a bidirectional stream here. - // For now, just drain events_rx and log them. - ``` -* **To Do**: Establish a real gRPC bidirectional stream to stream telemetry up and dynamically receive downstream commands (e.g. Isolation, Config Updates, Acks) in real time. - ---- - -## 5. Mock Fleet Server - -### 🟡 **Mock Fleet Server Listening Stub** -* **Location**: [main.rs:L116-121](file:///Users/swar/C/R/oss/project-edr/agent/tools/mock-fleet-server/src/main.rs#L116-121) -* **Stub**: - ```rust - tracing::info!("Mock Fleet Server listening on 0.0.0.0:50051"); - // Implement actual tonic service when compiling the fleet proto or manually wrapping bytes. - // Since we're doing manual bytes on the agent, we need to match the gRPC paths here or wait - // for proper proto codegen in a later step. - // For now, this is a placeholder that compiles. - loop { tokio::time::sleep(tokio::time::Duration::from_secs(60)).await; } - ``` -* **To Do**: Replace the simple sleep loop with a fully functioning Tonic gRPC server definition that accepts `RegisterRequest`, handles dynamic config updates, and responds to heartbeats/streams. diff --git a/run-docker-agent.sh b/run-docker-agent.sh index fc7a726..a941f32 100755 --- a/run-docker-agent.sh +++ b/run-docker-agent.sh @@ -5,15 +5,23 @@ echo "Building agent Docker image (this will install Rust & OSQuery)..." docker build -t edr-agent-dev -f agent/Dockerfile . echo "Running agent in Docker container..." +# Capabilities required for osquery audit event collection: +# AUDIT_CONTROL — set/read audit configuration, control audit daemon +# AUDIT_READ — read audit log messages via netlink +# SYS_PTRACE — process introspection (process_open_files, process_envs, etc.) +# NET_ADMIN — network interface and socket inspection # -v mounts the current project dir to /workspace # -w sets working directory to /workspace -# --privileged or CAP_AUDIT_CONTROL etc might be needed for osquery audit, but let's stick to simple run for now -# We pass EDR_AGENT_CONFIG to use the agent.toml in the workspace +# EDR_AGENT_CONFIG points to agent.toml in the workspace docker run --rm -it \ --name edr-agent \ -v "$(pwd)":/workspace \ -w /workspace \ -e EDR_AGENT_CONFIG=/workspace/agent.toml \ --add-host=host.docker.internal:host-gateway \ + --cap-add AUDIT_CONTROL \ + --cap-add AUDIT_READ \ + --cap-add SYS_PTRACE \ + --cap-add NET_ADMIN \ edr-agent-dev \ bash -c "cargo run -p agent-bin" diff --git a/scheduled_queries.toml b/scheduled_queries.toml new file mode 100644 index 0000000..5e3e4e0 --- /dev/null +++ b/scheduled_queries.toml @@ -0,0 +1,63 @@ +# scheduled_queries.toml +# Testing only — seed these queries into the SQLite scheduled_queries table on startup. +# Listed in .testingignore. Remove scheduled_queries_path from agent.toml when done testing. + +[[queries]] +name = "running_processes" +query = "SELECT pid, name, path, cmdline, uid FROM processes;" +interval_secs = 30 +snapshot = false + +[[queries]] +name = "open_files" +query = "SELECT pid, fd, path FROM process_open_files WHERE path NOT LIKE '/dev/%';" +interval_secs = 60 +snapshot = false + +[[queries]] +name = "tmp_file_activity" +query = "SELECT path, size, mtime, atime FROM file WHERE directory = '/tmp' AND mtime > (strftime('%s','now') - 3600);" +interval_secs = 60 +snapshot = true + +[[queries]] +name = "listening_ports" +query = "SELECT pid, port, protocol, address FROM listening_ports;" +interval_secs = 30 +snapshot = false + +[[queries]] +name = "open_sockets" +query = "SELECT pid, local_address, local_port, remote_address, remote_port, state FROM process_open_sockets;" +interval_secs = 30 +snapshot = false + +[[queries]] +name = "process_env_path" +query = "SELECT pid, key, value FROM process_envs WHERE key = 'PATH';" +interval_secs = 120 +snapshot = false + +[[queries]] +name = "etc_file_changes" +query = "SELECT path, size, mtime FROM file WHERE directory = '/etc' AND mtime > (strftime('%s','now') - 86400);" +interval_secs = 120 +snapshot = true + +[[queries]] +name = "users" +query = "SELECT username, uid, gid, shell, directory FROM users;" +interval_secs = 300 +snapshot = false + +[[queries]] +name = "init_children" +query = "SELECT pid, name, cmdline, parent FROM processes WHERE parent = 1;" +interval_secs = 60 +snapshot = false + +[[queries]] +name = "crontab" +query = "SELECT event, minute, hour, day_of_month, month, day_of_week, command, path FROM crontab;" +interval_secs = 300 +snapshot = false From 92c0bd0d052d27758bcbbfe3dabeecff69109876 Mon Sep 17 00:00:00 2001 From: ghule swarnit Date: Thu, 28 May 2026 22:13:33 +0530 Subject: [PATCH 06/69] chore: comment out rdkafka dependency in Cargo.toml --- Cargo.toml | 2 +- EDR_IMPLEMENTATION_GUIDE.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index f6be9a7..4900240 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,7 +40,7 @@ axum = { version = "0.8", features = ["ws", "macros"] } tower = "0.5" tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip"] } -rdkafka = { version = "0.39", features = ["cmake-build"] } +# rdkafka = { version = "0.39", features = ["cmake-build"] } sqlx = { version = "0.8", default-features = false, features = ["postgres", "runtime-tokio-native-tls", "uuid", "chrono", "migrate", "macros"] } diff --git a/EDR_IMPLEMENTATION_GUIDE.md b/EDR_IMPLEMENTATION_GUIDE.md index 40e24b9..ac89939 100644 --- a/EDR_IMPLEMENTATION_GUIDE.md +++ b/EDR_IMPLEMENTATION_GUIDE.md @@ -699,7 +699,7 @@ tonic-reflection = "0.11" # gRPC reflection for tooling tower = "0.4" tower-http = { version = "0.5", features = ["trace"] } prost = "0.12" -rdkafka = { version = "0.36", features = ["cmake-build"] } + sqlx = { version = "0.7", features = ["postgres", "runtime-tokio-native-tls", "uuid", "chrono", "migrate"] } serde = { version = "1", features = ["derive"] } serde_json = "1" From 72e10f5a404cad6bf2ef03fa8ed1755b56baf69b Mon Sep 17 00:00:00 2001 From: ghule swarnit Date: Thu, 28 May 2026 22:17:47 +0530 Subject: [PATCH 07/69] chore: fix ci --- api-backend/Cargo.toml | 2 +- fleet-server/Cargo.toml | 2 +- kafka-pipeline/Cargo.toml | 2 +- rule-engine/Cargo.toml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/api-backend/Cargo.toml b/api-backend/Cargo.toml index b552228..8c21d9f 100644 --- a/api-backend/Cargo.toml +++ b/api-backend/Cargo.toml @@ -14,7 +14,7 @@ axum = { workspace = true } tower = { workspace = true } tower-http = { workspace = true } tokio-tungstenite = { workspace = true } -rdkafka = { workspace = true } +# rdkafka = { workspace = true } sqlx = { workspace = true } jsonwebtoken = { workspace = true } argon2 = { workspace = true } diff --git a/fleet-server/Cargo.toml b/fleet-server/Cargo.toml index a257b18..0aa9440 100644 --- a/fleet-server/Cargo.toml +++ b/fleet-server/Cargo.toml @@ -16,7 +16,7 @@ tonic-reflection = { workspace = true } tower = { workspace = true } tower-http = { workspace = true } prost = { workspace = true } -rdkafka = { workspace = true } +# rdkafka = { workspace = true } sqlx = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/kafka-pipeline/Cargo.toml b/kafka-pipeline/Cargo.toml index 0d7accc..a2811d9 100644 --- a/kafka-pipeline/Cargo.toml +++ b/kafka-pipeline/Cargo.toml @@ -10,7 +10,7 @@ path = "src/main.rs" [dependencies] tokio = { workspace = true } -rdkafka = { workspace = true } +# rdkafka = { workspace = true } sqlx = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/rule-engine/Cargo.toml b/rule-engine/Cargo.toml index ad5d1fb..4852812 100644 --- a/rule-engine/Cargo.toml +++ b/rule-engine/Cargo.toml @@ -10,7 +10,7 @@ path = "src/main.rs" [dependencies] tokio = { workspace = true } -rdkafka = { workspace = true } +# rdkafka = { workspace = true } yara-x = { workspace = true } sqlx = { workspace = true } serde = { workspace = true } From 0785df1a97910a25ce5513020fcec8880c118481 Mon Sep 17 00:00:00 2001 From: ghule swarnit Date: Thu, 28 May 2026 22:22:52 +0530 Subject: [PATCH 08/69] chore: fix ci --- agent/crates/agent-tracing/src/lib.rs | 4 +--- agent/crates/fleet-client/src/lib.rs | 1 + agent/crates/osquery-client/src/scheduler.rs | 20 ++++++++++++++++---- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/agent/crates/agent-tracing/src/lib.rs b/agent/crates/agent-tracing/src/lib.rs index 6be1101..5af5a63 100644 --- a/agent/crates/agent-tracing/src/lib.rs +++ b/agent/crates/agent-tracing/src/lib.rs @@ -2,8 +2,7 @@ use anyhow::Result; use tracing_subscriber::{EnvFilter, fmt}; /// Log output format. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[derive(Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum LogFormat { /// Pretty-printed, colored, human-readable (for development) #[default] @@ -12,7 +11,6 @@ pub enum LogFormat { Json, } - /// Initialize the agent's tracing/logging infrastructure. pub fn init(log_level: &str, format: LogFormat) -> Result<()> { let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(log_level)); diff --git a/agent/crates/fleet-client/src/lib.rs b/agent/crates/fleet-client/src/lib.rs index 6dc8348..d2eab9d 100644 --- a/agent/crates/fleet-client/src/lib.rs +++ b/agent/crates/fleet-client/src/lib.rs @@ -1,3 +1,4 @@ +#[expect(dead_code)] pub mod connection; pub mod enrollment; pub mod heartbeat; diff --git a/agent/crates/osquery-client/src/scheduler.rs b/agent/crates/osquery-client/src/scheduler.rs index 1d4df8e..254ca85 100644 --- a/agent/crates/osquery-client/src/scheduler.rs +++ b/agent/crates/osquery-client/src/scheduler.rs @@ -166,7 +166,10 @@ impl QueryScheduler { ResultAction::Snapshot, ); if tx.send(result).await.is_err() { - tracing::info!("[{}] Result channel closed, stopping task.", query.name); + tracing::info!( + "[{}] Result channel closed, stopping task.", + query.name + ); break; } } else { @@ -180,7 +183,10 @@ impl QueryScheduler { ResultAction::Snapshot, ); if tx.send(result).await.is_err() { - tracing::info!("[{}] Result channel closed, stopping task.", query.name); + tracing::info!( + "[{}] Result channel closed, stopping task.", + query.name + ); break; } first_run = false; @@ -196,7 +202,10 @@ impl QueryScheduler { ResultAction::Added, ); if tx.send(res).await.is_err() { - tracing::info!("[{}] Result channel closed, stopping task.", query.name); + tracing::info!( + "[{}] Result channel closed, stopping task.", + query.name + ); break; } } @@ -208,7 +217,10 @@ impl QueryScheduler { ResultAction::Removed, ); if tx.send(res).await.is_err() { - tracing::info!("[{}] Result channel closed, stopping task.", query.name); + tracing::info!( + "[{}] Result channel closed, stopping task.", + query.name + ); break; } } From 75fcff5e7efd2b3eb9936197c62fd3feb42c7898 Mon Sep 17 00:00:00 2001 From: ghule swarnit Date: Fri, 29 May 2026 17:18:42 +0530 Subject: [PATCH 09/69] refactor: remove connection state tracking --- TIMELINE.md | 243 ------------------------ agent/crates/agent-core/timeline.md | 50 ----- agent/crates/ebpf-collector/timeline.md | 56 ------ agent/crates/event-buffer/timeline.md | 46 ----- agent/crates/fleet-client/timeline.md | 49 ----- agent/crates/isolation/timeline.md | 38 ---- agent/crates/osquery-client/timeline.md | 49 ----- api-backend/timeline.md | 91 --------- fleet-server/timeline.md | 125 ------------ frontend/timeline.md | 105 ---------- infra/timeline.md | 119 ------------ kafka-pipeline/timeline.md | 76 -------- rule-engine/timeline.md | 81 -------- sdk/timeline.md | 126 ------------ 14 files changed, 1254 deletions(-) delete mode 100644 TIMELINE.md delete mode 100644 agent/crates/agent-core/timeline.md delete mode 100644 agent/crates/ebpf-collector/timeline.md delete mode 100644 agent/crates/event-buffer/timeline.md delete mode 100644 agent/crates/fleet-client/timeline.md delete mode 100644 agent/crates/isolation/timeline.md delete mode 100644 agent/crates/osquery-client/timeline.md delete mode 100644 api-backend/timeline.md delete mode 100644 fleet-server/timeline.md delete mode 100644 frontend/timeline.md delete mode 100644 infra/timeline.md delete mode 100644 kafka-pipeline/timeline.md delete mode 100644 rule-engine/timeline.md delete mode 100644 sdk/timeline.md diff --git a/TIMELINE.md b/TIMELINE.md deleted file mode 100644 index 79d43e2..0000000 --- a/TIMELINE.md +++ /dev/null @@ -1,243 +0,0 @@ -# EDR System — Master Implementation Timeline - -> **Total Estimated Duration**: 10–12 weeks (single developer) / 5–6 weeks (2–3 developers) -> **Start Prerequisite**: Rust 1.85+, Docker, Node 20+ - ---- - -## Phase Overview - -```mermaid -gantt - title EDR Implementation Phases - dateFormat YYYY-MM-DD - axisFormat %b %d - - section Phase 0 - Foundation - SDK (proto + types + tag) :p0_sdk, 2026-06-02, 4d - Infra (docker-compose + migrations) :p0_infra, 2026-06-02, 3d - - section Phase 1 - Agent OSQuery - osquery-client :p1_osq, after p0_sdk, 4d - event-buffer :p1_buf, after p0_sdk, 3d - fleet-client :p1_fleet, after p1_buf, 4d - agent-core (basic) :p1_core, after p1_fleet, 2d - - section Phase 2 - Agent eBPF - ebpf-collector (3 probes) :p2_ebpf, after p1_core, 7d - agent-core (full orchestration) :p2_core, after p2_ebpf, 1d - - section Phase 3 - Fleet Server - fleet-server (4 PRs) :p3_fleet, after p1_core, 8d - - section Phase 4 - Kafka Pipeline - kafka-pipeline (3 PRs) :p4_kafka, after p3_fleet, 5d - - section Phase 5 - Rule Engine - rule-engine (3 PRs) :p5_rules, after p4_kafka, 6d - - section Phase 6 - API Backend - api-backend (5 PRs) :p6_api, after p5_rules, 7d - - section Phase 7 - Frontend - frontend (6 PRs) :p7_fe, after p6_api, 9d - - section Phase 8+9 - Hardening - isolation (agent) :p8_iso, after p6_api, 2d - K8s + observability + CI :p9_hard, after p7_fe, 5d -``` - ---- - -## Phase 0 — Foundation (Week 1) - -**Goal**: SDK tagged, infra running, all repos scaffolded. - -| Module | PRs | Duration | Timeline | -|---|---|---|---| -| [SDK](sdk/timeline.md) | 4 PRs | 3–4 days | Proto codegen → shared types → auth → v0.1.0 tag | -| [Infra](infra/timeline.md) | 2 PRs | 2–3 days | docker-compose + DB migrations | - -**Milestone**: `docker-compose up` works, `sdk v0.1.0` tagged, all downstream crates resolve. - ---- - -## Phase 1 — Agent: OSQuery Integration (Week 2) - -**Goal**: Agent reads from OSQuery, buffers events locally, streams to Fleet Server stub. - -| Module | PRs | Duration | Timeline | -|---|---|---|---| -| [osquery-client](agent/crates/osquery-client/timeline.md) | 2 PRs | 3–4 days | Socket client → scheduled queries | -| [event-buffer](agent/crates/event-buffer/timeline.md) | 2 PRs | 2–3 days | Sled storage → backpressure | -| [fleet-client](agent/crates/fleet-client/timeline.md) | 2 PRs | 3–4 days | gRPC enrollment → bidirectional stream | -| [agent-core](agent/crates/agent-core/timeline.md) | PR #1 | 1.5 days | Config + basic orchestrator | - -**Milestone**: Agent enrolls with mock server, OSQuery events buffered and streamed. - ---- - -## Phase 2 — Agent: eBPF Probes (Week 3–4) - -**Goal**: Kernel-level telemetry flowing into the event pipeline. - -| Module | PRs | Duration | Timeline | -|---|---|---|---| -| [ebpf-collector](agent/crates/ebpf-collector/timeline.md) | 3 PRs | 5–7 days | Process probe → file/network probes → aggregation | -| [agent-core](agent/crates/agent-core/timeline.md) | PR #2 | 1 day | Full orchestration with eBPF | - -**Milestone**: All 3 eBPF probes attached, events flowing through buffer to fleet-client. - ---- - -## Phase 3 — Fleet Server (Week 4–5) - -**Goal**: Central hub accepts agent connections, produces events to Kafka. - -| Module | PRs | Duration | Timeline | -|---|---|---|---| -| [fleet-server](fleet-server/timeline.md) | 4 PRs | 6–8 days | Skeleton → DB layer → enrollment → streaming + Kafka | - -**Milestone**: Agent enrolls, streams events, fleet server produces to `edr.events.raw`. - ---- - -## Phase 4 — Kafka Pipeline (Week 5–6) - -**Goal**: Raw events normalised and persisted to PostgreSQL. - -| Module | PRs | Duration | Timeline | -|---|---|---|---| -| [kafka-pipeline](kafka-pipeline/timeline.md) | 3 PRs | 4–5 days | Consumer → normaliser → DB writer + re-producer | - -**Milestone**: Events flow from Kafka to PostgreSQL, normalised events on `edr.events.norm`. - ---- - -## Phase 5 — Rule Engine (Week 6–7) - -**Goal**: Alerts generated from suspicious events. - -| Module | PRs | Duration | Timeline | -|---|---|---|---| -| [rule-engine](rule-engine/timeline.md) | 3 PRs | 5–6 days | Consumer → YARA scanning → MITRE mapping + alerts | - -**Milestone**: Suspicious events trigger alerts on `edr.alerts` topic and in `edr_alerts` DB. - ---- - -## Phase 6 — API Backend (Week 7–8) - -**Goal**: REST API and WebSocket serving the frontend. - -| Module | PRs | Duration | Timeline | -|---|---|---|---| -| [api-backend](api-backend/timeline.md) | 5 PRs | 6–7 days | Skeleton → auth → nodes/logs → alerts/commands → WebSocket | - -**Milestone**: All REST endpoints functional, WebSocket broadcasting alerts in real-time. - ---- - -## Phase 7 — Frontend Dashboard (Week 8–9) - -**Goal**: Operator can view nodes, logs, and alerts in a browser. - -| Module | PRs | Duration | Timeline | -|---|---|---|---| -| [frontend](frontend/timeline.md) | 6 PRs | 7–9 days | Init → auth → node map → alerts → live logs → dashboard | - -**Milestone**: Full dashboard with real-time data, node controls, and alert management. - ---- - -## Phase 8 — Node Isolation E2E (Week 9) - -**Goal**: Operator isolates a node from dashboard, agent applies iptables rules. - -| Module | PRs | Duration | Timeline | -|---|---|---|---| -| [isolation](agent/crates/isolation/timeline.md) | 2 PRs | 2 days | IPTables rules → status reporting | -| [agent-core](agent/crates/agent-core/timeline.md) | PR #3 | 0.5 day | Isolation command handling | - -**Milestone**: Dashboard → API → Fleet Server → Agent → iptables → status reflected back. - ---- - -## Phase 9 — Hardening & Observability (Week 10+) - -**Goal**: Production-ready CI/CD, monitoring, security scanning. - -| Module | PRs | Duration | Timeline | -|---|---|---|---| -| [Infra](infra/timeline.md) | 3 PRs | 3–5 days | K8s manifests → observability → runbooks | - -**Milestone**: Full CI/CD pipeline, Prometheus + Grafana, Trivy scanning, structured logging. - ---- - -## Total PR Count by Module - -| Module | Total PRs | -|---|---| -| [SDK](sdk/timeline.md) | 4 | -| [Agent workspace](agent/timeline.md) | 2 | -| [osquery-client](agent/crates/osquery-client/timeline.md) | 2 | -| [event-buffer](agent/crates/event-buffer/timeline.md) | 2 | -| [fleet-client](agent/crates/fleet-client/timeline.md) | 2 | -| [ebpf-collector](agent/crates/ebpf-collector/timeline.md) | 3 | -| [isolation](agent/crates/isolation/timeline.md) | 2 | -| [agent-core](agent/crates/agent-core/timeline.md) | 3 | -| [fleet-server](fleet-server/timeline.md) | 4 | -| [kafka-pipeline](kafka-pipeline/timeline.md) | 3 | -| [rule-engine](rule-engine/timeline.md) | 3 | -| [api-backend](api-backend/timeline.md) | 5 | -| [frontend](frontend/timeline.md) | 6 | -| [infra](infra/timeline.md) | 5 | -| **Total** | **46 PRs** | - ---- - -## Critical Path - -```mermaid -flowchart LR - SDK["SDK v0.1.0"] --> OSQ["osquery-client"] - SDK --> BUF["event-buffer"] - OSQ --> CORE1["agent-core\n(basic)"] - BUF --> FC["fleet-client"] - FC --> CORE1 - CORE1 --> FS["fleet-server"] - FS --> KP["kafka-pipeline"] - KP --> RE["rule-engine"] - RE --> API["api-backend"] - API --> FE["frontend"] - - SDK --> EBPF["ebpf-collector"] - EBPF --> CORE2["agent-core\n(full)"] - API --> ISO["isolation"] - FE --> HARD["hardening"] - - style SDK fill:#ef4444,color:#fff - style CORE1 fill:#ef4444,color:#fff - style FS fill:#ef4444,color:#fff - style KP fill:#ef4444,color:#fff - style RE fill:#ef4444,color:#fff - style API fill:#ef4444,color:#fff - style FE fill:#ef4444,color:#fff - style EBPF fill:#3b82f6,color:#fff - style ISO fill:#3b82f6,color:#fff - style HARD fill:#3b82f6,color:#fff - style CORE2 fill:#3b82f6,color:#fff -``` - -🔴 **Red** = critical path — any delay here delays the entire project. -🔵 **Blue** = parallelisable — can be developed independently. - ---- - -## Related Documents - -- [Test Plan](tests/TEST_PLAN.md) — comprehensive testing strategy for every module -- [CI Caching Strategy](.github/CI_CACHING_STRATEGY.md) — build caching for fast CI -- [PR Template](.github/PULL_REQUEST_TEMPLATE.md) — standardised PR format with module checkboxes -- [Implementation Guide](EDR_IMPLEMENTATION_GUIDE.md) — full architecture and design reference diff --git a/agent/crates/agent-core/timeline.md b/agent/crates/agent-core/timeline.md deleted file mode 100644 index 1acb247..0000000 --- a/agent/crates/agent-core/timeline.md +++ /dev/null @@ -1,50 +0,0 @@ -# agent-core — Implementation Timeline - -> **Phase**: 1 (basic) → 2 (full) → 8 (isolation) -> **Priority**: 🔴 Critical — binary entry point, orchestrates all subsystems -> **Estimated Duration**: 3–4 days (spread across phases) -> **Depends on**: all other agent crates - ---- - -## PR Plan - -### PR #1 — Config loading and basic orchestrator (Phase 1) -**Branch**: `feat/agent-core-basic` -**Duration**: 1.5 days - -**Files**: -- `src/main.rs` — tokio runtime bootstrap, signal handling -- `src/config.rs` — reads `/etc/edr/agent.toml` -- `src/orchestrator.rs` — spawns osquery-client and event-buffer tasks - -**Tasks**: -- [ ] Parse `agent.toml` config file (fleet endpoint, buffer path, osquery socket) -- [ ] Set up tracing-subscriber with JSON structured logging -- [ ] Spawn `osquery-client` scheduled query task -- [ ] Spawn `event-buffer` flush task -- [ ] Spawn `fleet-client` enrollment + streaming task -- [ ] Wire channels: osquery-client → event-buffer → fleet-client -- [ ] Handle SIGTERM/SIGINT for graceful shutdown -- [ ] Integration test: config load → task spawn → shutdown - -### PR #2 — Full orchestration with eBPF (Phase 2) -**Branch**: `feat/agent-core-ebpf` -**Duration**: 1 day -**Depends on**: `ebpf-collector` complete - -**Tasks**: -- [ ] Spawn `ebpf-collector` probe tasks -- [ ] Wire eBPF events into the same event-buffer pipeline -- [ ] Add event-type tagging (source: ebpf vs osquery) -- [ ] Handle probe attach failures gracefully (agent continues without eBPF) - -### PR #3 — Isolation command handling (Phase 8) -**Branch**: `feat/agent-core-isolation` -**Duration**: 0.5 day -**Depends on**: `isolation` crate, `fleet-client` command dispatch - -**Tasks**: -- [ ] Listen for `IsolateCommand` from fleet-client command channel -- [ ] Invoke `isolation::IsolationManager` on command receipt -- [ ] Update heartbeat status to reflect isolation state diff --git a/agent/crates/ebpf-collector/timeline.md b/agent/crates/ebpf-collector/timeline.md deleted file mode 100644 index 4e6cfa2..0000000 --- a/agent/crates/ebpf-collector/timeline.md +++ /dev/null @@ -1,56 +0,0 @@ -# ebpf-collector — Implementation Timeline - -> **Phase**: 2 (Agent: eBPF Probes) -> **Priority**: 🟡 High — kernel-level telemetry -> **Estimated Duration**: 5–7 days (most complex agent crate) -> **Depends on**: `sdk v0.1.0`, `event-buffer` - ---- - -## PR Plan - -### PR #1 — eBPF loader and process probe -**Branch**: `feat/ebpf-process-probe` -**Duration**: 2.5 days - -**Files**: -- `src/lib.rs` — public API -- `src/loader.rs` — loads compiled eBPF objects via aya -- `src/events.rs` — parses perf/ring buffer events -- `bpf/process_probe.bpf.c` — attaches to `sys_enter_execve` - -**Tasks**: -- [ ] Write `process_probe.bpf.c` capturing PID, PPID, cmdline, UID, exe_path -- [ ] Configure `aya-build` in `build.rs` to compile BPF C programs -- [ ] Implement `EbpfLoader::load(probe_path)` — loads and attaches BPF program -- [ ] Implement ring buffer reader — async event polling via aya -- [ ] Parse raw BPF event bytes → `ProcessEvent` struct -- [ ] Unit tests for event parsing (mock raw bytes) - -### PR #2 — File and network probes -**Branch**: `feat/ebpf-file-network` -**Duration**: 2 days -**Depends on**: PR #1 - -**Files**: -- `bpf/file_probe.bpf.c` — attaches to `sys_enter_openat`, `sys_enter_unlinkat` -- `bpf/network_probe.bpf.c` — attaches to `sys_enter_connect`, `sys_enter_bind` - -**Tasks**: -- [ ] Write `file_probe.bpf.c` capturing file path, operation, PID, return code -- [ ] Write `network_probe.bpf.c` capturing src/dst IP:port, protocol, PID -- [ ] Add loader support for multiple probes running concurrently -- [ ] Parse file events → `FileEvent`, network events → `NetworkEvent` -- [ ] Implement probe attach/detach lifecycle management -- [ ] Test on real Linux kernel (requires root / CAP_BPF) - -### PR #3 — Event aggregation and rate limiting -**Branch**: `feat/ebpf-aggregation` -**Duration**: 1.5 days -**Depends on**: PR #2 - -**Tasks**: -- [ ] Implement event deduplication (same process exec within 100ms) -- [ ] Add configurable ring buffer size (`ringbuf_size_pages`) -- [ ] Rate-limit high-frequency events (file I/O) to prevent flooding -- [ ] Metrics: `events_captured`, `events_dropped`, `probe_errors` diff --git a/agent/crates/event-buffer/timeline.md b/agent/crates/event-buffer/timeline.md deleted file mode 100644 index b993676..0000000 --- a/agent/crates/event-buffer/timeline.md +++ /dev/null @@ -1,46 +0,0 @@ -# event-buffer — Implementation Timeline - -> **Phase**: 1 (Agent: OSQuery Integration) -> **Priority**: 🟡 High — durability layer, prevents event loss -> **Estimated Duration**: 2–3 days -> **Depends on**: `sdk v0.1.0` - ---- - -## Overview - -Write-ahead buffer using sled embedded DB. Events are persisted to disk immediately and only removed after the Fleet Server acknowledges receipt. Ensures zero event loss during network outages. - ---- - -## PR Plan - -### PR #1 — Sled storage layer and buffer API -**Branch**: `feat/event-buffer-core` -**Duration**: 1.5 days - -**Files**: -- `src/lib.rs` — public API (`EventBuffer`) -- `src/buffer.rs` — sled read/write, flush logic - -**Tasks**: -- [ ] Implement `EventBuffer::new(path)` — opens/creates sled DB at path -- [ ] Implement `push(event: NormalisedEvent)` — serialise and store with auto-increment key -- [ ] Implement `peek_batch(n: usize)` → returns oldest N events without removing -- [ ] Implement `ack(up_to_key)` — removes all events up to acknowledged sequence -- [ ] Implement `len()` — returns count of buffered events -- [ ] Implement `flush_all()` — returns all events as iterator for drain on reconnect -- [ ] Handle sled compaction and disk space limits -- [ ] Unit tests: push → peek → ack cycle, crash recovery simulation - -### PR #2 — Backpressure and metrics -**Branch**: `feat/event-buffer-backpressure` -**Duration**: 1 day -**Depends on**: PR #1 - -**Tasks**: -- [ ] Add configurable max buffer size (bytes and count) -- [ ] Implement backpressure signalling when buffer exceeds threshold -- [ ] Add metrics: `events_buffered`, `events_flushed`, `buffer_size_bytes` -- [ ] Log warnings at 80% capacity, errors at 95% -- [ ] Unit tests for backpressure scenarios diff --git a/agent/crates/fleet-client/timeline.md b/agent/crates/fleet-client/timeline.md deleted file mode 100644 index 2cb8c41..0000000 --- a/agent/crates/fleet-client/timeline.md +++ /dev/null @@ -1,49 +0,0 @@ -# fleet-client — Implementation Timeline - -> **Phase**: 1 (initial) → 3 (full streaming) -> **Priority**: 🟡 High — agent's only outbound connection -> **Estimated Duration**: 3–4 days -> **Depends on**: `sdk v0.1.0`, `event-buffer` - ---- - -## Overview - -gRPC client that connects to the Fleet Server. Handles enrollment, bidirectional event streaming, heartbeats, and command reception (isolation, config updates). - ---- - -## PR Plan - -### PR #1 — gRPC channel and enrollment -**Branch**: `feat/fleet-enrollment` -**Duration**: 1.5 days - -**Files**: -- `src/lib.rs` — public API -- `src/connection.rs` — gRPC channel with TLS, reconnect logic - -**Tasks**: -- [ ] Implement `FleetConnection::new(endpoint, tls_config)` — creates tonic channel -- [ ] Implement TLS/mTLS configuration (load certs from paths) -- [ ] Implement `enroll(hostname, os_version, agent_version, machine_id)` → `RegisterResponse` -- [ ] Persist received `node_id` and JWT token to disk -- [ ] Implement exponential backoff reconnect (1s → 2s → 4s → ... → 60s cap) -- [ ] Unit tests with mock gRPC server - -### PR #2 — Bidirectional event stream and command handling -**Branch**: `feat/fleet-streaming` -**Duration**: 1.5 days -**Depends on**: PR #1 - -**Files**: -- `src/stream.rs` — bidirectional streaming, command dispatch - -**Tasks**: -- [ ] Implement `EventStream` — opens `FleetService::EventStream` RPC -- [ ] Send events from `event-buffer` in configurable batch sizes -- [ ] Receive `ServerCommand` messages (Isolate, ConfigUpdate, Ack) -- [ ] Dispatch commands to appropriate handlers via channels -- [ ] Handle stream disconnection → trigger reconnect + buffer drain -- [ ] Implement heartbeat sending on configurable interval -- [ ] Integration test: mock server ↔ fleet-client stream diff --git a/agent/crates/isolation/timeline.md b/agent/crates/isolation/timeline.md deleted file mode 100644 index 4a69427..0000000 --- a/agent/crates/isolation/timeline.md +++ /dev/null @@ -1,38 +0,0 @@ -# isolation — Implementation Timeline - -> **Phase**: 8 (Node Isolation End-to-End) -> **Priority**: 🟢 Medium — depends on fleet-client command handling -> **Estimated Duration**: 2 days -> **Depends on**: `fleet-client` command dispatch - ---- - -## PR Plan - -### PR #1 — IPTables rule management -**Branch**: `feat/isolation-iptables` -**Duration**: 1.5 days - -**Files**: -- `src/lib.rs` — public API (`IsolationManager`) -- `src/iptables.rs` — adds/removes iptables rules via `std::process::Command` - -**Tasks**: -- [ ] Implement `IsolationManager::new(fleet_server_ip)` — stores allowed endpoint -- [ ] Implement `isolate()` — drops all traffic except to Fleet Server IP -- [ ] Implement `deisolate()` — removes isolation iptables rules -- [ ] Implement `is_isolated()` — checks current iptables state -- [ ] Add iptables rule validation (ensure rules are correctly applied) -- [ ] Handle permission errors gracefully (requires root/CAP_NET_ADMIN) -- [ ] Unit tests with mock `Command` executor -- [ ] Log all rule changes for audit trail - -### PR #2 — Isolation status reporting -**Branch**: `feat/isolation-status` -**Duration**: 0.5 day -**Depends on**: PR #1 - -**Tasks**: -- [ ] Report isolation state in heartbeat status field -- [ ] Emit `node_status_changed` event on isolation toggle -- [ ] Integration test: receive IsolateCommand → apply rules → report status diff --git a/agent/crates/osquery-client/timeline.md b/agent/crates/osquery-client/timeline.md deleted file mode 100644 index 1deedbb..0000000 --- a/agent/crates/osquery-client/timeline.md +++ /dev/null @@ -1,49 +0,0 @@ -# osquery-client — Implementation Timeline - -> **Phase**: 1 (Agent: OSQuery Integration) -> **Priority**: 🟡 High — first data source to implement -> **Estimated Duration**: 3–4 days -> **Depends on**: `sdk v0.1.0` - ---- - -## Overview - -Connects to OSQuery's unix socket, executes scheduled queries, and returns structured results. This is the simplest collector and validates the entire event pipeline. - ---- - -## PR Plan - -### PR #1 — Unix socket client and connection management -**Branch**: `feat/osquery-socket-client` -**Duration**: 1.5 days - -**Files**: -- `src/lib.rs` — module declarations, public API -- `src/client.rs` — unix socket connection, query execution, reconnect logic - -**Tasks**: -- [ ] Implement `OsqueryClient` struct with unix socket path config -- [ ] Implement `connect()` — async connection to OSQuery extension socket -- [ ] Implement `query(sql: &str)` → returns `Vec>` -- [ ] Add connection health check and auto-reconnect with backoff -- [ ] Handle socket not found / permission denied errors gracefully -- [ ] Unit tests with mock socket - -### PR #2 — Scheduled query execution and event conversion -**Branch**: `feat/osquery-scheduler` -**Duration**: 1.5 days -**Depends on**: PR #1 - -**Files**: -- `src/queries.rs` — scheduled query definitions, interval timer -- `src/lib.rs` — update public API - -**Tasks**: -- [ ] Define `ScheduledQuery` struct (name, sql, interval_secs) -- [ ] Implement query scheduler using `tokio::time::interval` -- [ ] Convert OSQuery JSON results → `edr_sdk::types::OsqueryEvent` -- [ ] Wrap results in `NormalisedEvent` envelope -- [ ] Support dynamic query schedule updates (from fleet config push) -- [ ] Unit tests for query scheduling and event conversion diff --git a/api-backend/timeline.md b/api-backend/timeline.md deleted file mode 100644 index 99ba678..0000000 --- a/api-backend/timeline.md +++ /dev/null @@ -1,91 +0,0 @@ -# API Backend — Implementation Timeline - -> **Phase**: 6 (REST API + WebSocket) -> **Priority**: 🟡 High — serves the frontend dashboard -> **Estimated Duration**: 6–7 days -> **Depends on**: `sdk v0.1.0`, infra running, data flowing through pipeline - ---- - -## PR Plan - -### PR #1 — Skeleton, config, AppState, and health endpoint -**Branch**: `feat/api-skeleton` -**Duration**: 1 day - -**Files**: -- `src/main.rs` — axum router setup, server binding -- `src/config.rs` — env/config loading -- `src/state.rs` — `AppState` (3 DB pools, Kafka consumer handle, WS broadcaster) -- `src/error.rs` — unified API error responses - -**Tasks**: -- [ ] Initialize 3 sqlx pools (logs, nodes, alerts DBs) -- [ ] Set up axum router with CORS and tracing middleware -- [ ] Health check endpoint (`GET /health`) -- [ ] Structured JSON error responses - -### PR #2 — Auth routes and JWT middleware -**Branch**: `feat/api-auth` -**Duration**: 1.5 days -**Depends on**: PR #1 - -**Files**: -- `src/routes/auth.rs` — login, refresh, logout -- `src/middleware/auth.rs` — JWT extraction and validation layer - -**Tasks**: -- [ ] `POST /auth/login` — validate credentials with argon2, return JWT pair -- [ ] `POST /auth/refresh` — validate refresh token, issue new access token -- [ ] `POST /auth/logout` — invalidate refresh token -- [ ] JWT middleware — extract Bearer token, validate, inject claims into request -- [ ] Seed initial operator account on first startup -- [ ] Unit tests for auth flow - -### PR #3 — Node and log query routes -**Branch**: `feat/api-nodes-logs` -**Duration**: 1.5 days -**Depends on**: PR #2 - -**Files**: -- `src/routes/nodes.rs`, `src/routes/logs.rs` -- `src/db/nodes.rs`, `src/db/logs.rs` - -**Tasks**: -- [ ] `GET /nodes` — list all nodes with status, last_seen, alert count -- [ ] `GET /nodes/:id` — single node detail -- [ ] `GET /nodes/:id/logs` — paginated event logs with filters (from, to, type, limit, offset) -- [ ] SQL query optimisation with indexes -- [ ] Unit tests with mock DB - -### PR #4 — Alert routes and command routes -**Branch**: `feat/api-alerts-commands` -**Duration**: 1 day -**Depends on**: PR #3 - -**Files**: -- `src/routes/alerts.rs`, `src/routes/commands.rs` -- `src/db/alerts.rs` - -**Tasks**: -- [ ] `GET /alerts` — filtered alert list (severity, status, date range, node_id) -- [ ] `GET /alerts/:id` — single alert with MITRE context -- [ ] `PATCH /alerts/:id` — update status (acknowledged/dismissed) -- [ ] `POST /nodes/:id/isolate` — write isolation command to `pending_commands` -- [ ] `POST /nodes/:id/deisolate` — write de-isolation command - -### PR #5 — WebSocket and Kafka consumer -**Branch**: `feat/api-websocket` -**Duration**: 1.5 days -**Depends on**: PR #4 - -**Files**: -- `src/routes/ws.rs` — WebSocket upgrade handler -- `src/kafka/consumer.rs` — consumes `edr.alerts` + `edr.health` - -**Tasks**: -- [ ] `GET /ws` — upgrade to WebSocket, authenticate via query param token -- [ ] Kafka consumer for `edr.alerts` → broadcast `alert_created` to all WS clients -- [ ] Kafka consumer for `edr.health` → broadcast `node_health` and `node_status_changed` -- [ ] Connection lifecycle management (heartbeat pings, cleanup on disconnect) -- [ ] Integration test: produce alert to Kafka → verify WS client receives it diff --git a/fleet-server/timeline.md b/fleet-server/timeline.md deleted file mode 100644 index dae4519..0000000 --- a/fleet-server/timeline.md +++ /dev/null @@ -1,125 +0,0 @@ -# Fleet Server — Implementation Timeline - -> **Phase**: 3 (Enrollment & Streaming) -> **Priority**: 🔴 Critical — central hub for all agents -> **Estimated Duration**: 6–8 days -> **Depends on**: `sdk v0.1.0`, infra docker-compose running - ---- - -## Enrollment & Streaming Flow - -```mermaid -sequenceDiagram - participant Agent - participant FleetServer - participant PostgreSQL - participant Kafka - - Agent->>FleetServer: RegisterAgent(hostname, machine_id) - FleetServer->>PostgreSQL: INSERT INTO nodes - PostgreSQL-->>FleetServer: node_id - FleetServer->>FleetServer: Sign JWT(node_id) - FleetServer-->>Agent: RegisterResponse(node_id, token, config) - - Agent->>FleetServer: EventStream (JWT in metadata) - FleetServer->>FleetServer: Verify JWT - - loop Bidirectional Stream - Agent->>FleetServer: AgentEvent(events batch) - FleetServer->>Kafka: produce to edr.events.raw - FleetServer-->>Agent: AckCommand(sequence_id) - - FleetServer->>PostgreSQL: poll pending_commands - FleetServer-->>Agent: ServerCommand(IsolateCommand) - end - - Agent->>FleetServer: Heartbeat(status, events_buffered) - FleetServer->>PostgreSQL: UPDATE last_seen - FleetServer-->>Agent: HeartbeatResponse(ok) -``` - -## PR Dependency Chain - -```mermaid -flowchart LR - PR1["PR #1\\nSkeleton + Config"] --> PR2["PR #2\\nDB Layer"] - PR2 --> PR3["PR #3\\ngRPC Enrollment"] - PR3 --> PR4["PR #4\\nStreaming + Kafka"] - - style PR1 fill:#6b7280,color:#fff - style PR2 fill:#3b82f6,color:#fff - style PR3 fill:#22c55e,color:#fff - style PR4 fill:#ef4444,color:#fff -``` - -## PR Plan - -### PR #1 — Project skeleton, config, and AppState -**Branch**: `feat/fleet-skeleton` -**Duration**: 1 day - -**Files**: -- `src/main.rs` — tokio runtime, binds gRPC + HTTP servers -- `src/config.rs` — env vars + config file loading -- `src/state.rs` — `AppState` with DB pool, Kafka producer, shared config -- `src/error.rs` — unified error types with thiserror - -**Tasks**: -- [ ] Load config from env vars (`DATABASE_URL`, `KAFKA_BROKERS`, `GRPC_BIND_ADDR`, etc.) -- [ ] Initialize sqlx PostgreSQL connection pool -- [ ] Initialize rdkafka producer -- [ ] Build `Arc` with all shared resources -- [ ] Start HTTP health check endpoint on admin port -- [ ] Structured logging with tracing-subscriber - -### PR #2 — Database layer and migrations -**Branch**: `feat/fleet-db` -**Duration**: 1.5 days -**Depends on**: PR #1 - -**Files**: -- `src/db/mod.rs`, `src/db/nodes.rs`, `src/db/health.rs`, `src/db/config.rs` -- `migrations/001_create_nodes.sql` ← already exists - -**Tasks**: -- [ ] Run sqlx migrations on startup -- [ ] Implement `db::nodes::insert_node()`, `get_node()`, `list_nodes()`, `update_status()` -- [ ] Implement `db::health::update_heartbeat()`, `get_last_seen()` -- [ ] Implement `db::config::get_config()`, `update_config()` -- [ ] Implement `db::nodes::insert_pending_command()`, `get_pending_commands()` -- [ ] Unit tests with sqlx test fixtures - -### PR #3 — gRPC enrollment (RegisterAgent) -**Branch**: `feat/fleet-enrollment` -**Duration**: 1.5 days -**Depends on**: PR #2 - -**Files**: -- `src/grpc/mod.rs`, `src/grpc/server.rs`, `src/grpc/enrollment.rs` - -**Tasks**: -- [ ] Implement `FleetService` tonic trait -- [ ] Implement `RegisterAgent` RPC — validate request, insert node, sign JWT, return config -- [ ] JWT signing with `jsonwebtoken` (HS256, configurable secret) -- [ ] Reject duplicate enrollments (check `machine_id` uniqueness) -- [ ] Integration test: agent enrollment flow - -### PR #4 — Bidirectional EventStream and Kafka producer -**Branch**: `feat/fleet-streaming` -**Duration**: 2 days -**Depends on**: PR #3 - -**Files**: -- `src/grpc/stream.rs`, `src/grpc/commands.rs` -- `src/kafka/mod.rs`, `src/kafka/producer.rs` - -**Tasks**: -- [ ] Implement `EventStream` RPC — bidirectional tonic stream -- [ ] Authenticate stream via JWT metadata header -- [ ] Receive `AgentEvent` messages → produce to Kafka `edr.events.raw` -- [ ] Send `ServerCommand` messages (Ack, ConfigUpdate, Isolate) -- [ ] Poll `pending_commands` table and relay to connected agent -- [ ] Implement Kafka producer with delivery guarantees -- [ ] Handle stream disconnection, mark node as offline -- [ ] Integration test: mock agent ↔ fleet server stream diff --git a/frontend/timeline.md b/frontend/timeline.md deleted file mode 100644 index e3d37f6..0000000 --- a/frontend/timeline.md +++ /dev/null @@ -1,105 +0,0 @@ -# Frontend Dashboard — Implementation Timeline - -> **Phase**: 7 (Operator Dashboard) -> **Priority**: 🟢 Medium — depends on API backend being functional -> **Estimated Duration**: 7–9 days -> **Depends on**: `api-backend` REST + WebSocket endpoints working - ---- - -## PR Plan - -### PR #1 — Vite + React + TypeScript project init -**Branch**: `feat/frontend-init` -**Duration**: 1 day - -**Tasks**: -- [ ] `npm create vite@latest . -- --template react-ts` -- [ ] Install deps: axios, @tanstack/react-query, zustand, react-router-dom, recharts -- [ ] Install dev deps: tailwindcss, postcss, autoprefixer -- [ ] Configure Tailwind, PostCSS, Vite proxy to API backend -- [ ] Create base layout (Sidebar + TopBar + content area) -- [ ] Set up React Router with route stubs - -### PR #2 — Auth flow and API client -**Branch**: `feat/frontend-auth` -**Duration**: 1.5 days -**Depends on**: PR #1 - -**Files**: -- `src/api/client.ts` — axios instance with JWT interceptor -- `src/api/auth.ts` — login, refresh, logout API calls -- `src/store/authStore.ts` — Zustand store for JWT + user state -- `src/pages/LoginPage.tsx` — login form - -**Tasks**: -- [ ] Axios interceptor: attach Bearer token, auto-refresh on 401 -- [ ] Login page with form validation -- [ ] Protected route wrapper (redirect to login if unauthenticated) -- [ ] Persist auth state in memory only (not localStorage — security) - -### PR #3 — Node Map page -**Branch**: `feat/frontend-nodes` -**Duration**: 1.5 days -**Depends on**: PR #2 - -**Files**: -- `src/pages/NodeMapPage.tsx`, `src/pages/NodeDetailPage.tsx` -- `src/hooks/useNodes.ts`, `src/api/nodes.ts` -- `src/components/nodes/{NodeCard, NodeStatusBadge, IsolateButton}.tsx` - -**Tasks**: -- [ ] `useNodes()` hook — React Query, polls `GET /nodes` every 30s -- [ ] Node grid with colour-coded status badges (green/yellow/red) -- [ ] Click card → navigate to NodeDetailPage -- [ ] NodeDetailPage: node info + embedded log table + alert count -- [ ] IsolateButton with confirmation modal - -### PR #4 — Alerts panel -**Branch**: `feat/frontend-alerts` -**Duration**: 1.5 days -**Depends on**: PR #2 - -**Files**: -- `src/pages/AlertsPage.tsx` -- `src/hooks/useAlerts.ts`, `src/api/alerts.ts` -- `src/components/alerts/{AlertRow, SeverityBadge, MitreTechniqueTag}.tsx` - -**Tasks**: -- [ ] Alerts table with server-side filtering (severity, status, date range) -- [ ] SeverityBadge colour coding (Critical=red, High=orange, Medium=yellow, Low=blue) -- [ ] MITRE technique ID as clickable tag (links to attack.mitre.org) -- [ ] Acknowledge / Dismiss buttons with optimistic UI updates -- [ ] Unacknowledged count badge in sidebar - -### PR #5 — Live Logs and WebSocket -**Branch**: `feat/frontend-live` -**Duration**: 1.5 days -**Depends on**: PR #3 - -**Files**: -- `src/pages/LiveLogsPage.tsx` -- `src/hooks/useWebSocket.ts` -- `src/components/logs/{LogTable, LogTypeFilter}.tsx` - -**Tasks**: -- [ ] WebSocket connection manager with auto-reconnect -- [ ] Live log stream appended to circular buffer (max 500 entries) -- [ ] TanStack Table with row virtualisation for performance -- [ ] Client-side type filter (process, file, network, osquery) -- [ ] Real-time alert badge updates via WS `alert_created` events - -### PR #6 — Dashboard overview and polish -**Branch**: `feat/frontend-dashboard` -**Duration**: 1 day -**Depends on**: PR #3, #4, #5 - -**Files**: -- `src/pages/DashboardPage.tsx` - -**Tasks**: -- [ ] Summary cards: total nodes, active alerts, events/min -- [ ] Recharts: alert trend (last 24h), severity distribution pie chart -- [ ] Recent alerts list (top 5) -- [ ] Node health overview (healthy/degraded/isolated counts) -- [ ] Responsive layout for tablet/desktop diff --git a/infra/timeline.md b/infra/timeline.md deleted file mode 100644 index 4c02f7e..0000000 --- a/infra/timeline.md +++ /dev/null @@ -1,119 +0,0 @@ -# Infra — Implementation Timeline - -> **Phase**: 0 (Foundation) + Phase 9 (Hardening) -> **Priority**: 🔴 Critical — required for local dev and all integration testing -> **Estimated Duration**: 2–3 days initial, ongoing through project - ---- - -## Overview - -Infrastructure definitions for local development (Docker Compose) and production deployment (K8s, Terraform). The docker-compose stack must be running before any service integration testing can begin. - ---- - -## PR Plan - -### PR #1 — Docker Compose local dev stack -**Branch**: `feat/docker-compose` -**Duration**: 1 day -**Files**: -- `docker-compose.yml` ← already scaffolded -- `docker-compose.dev.yml` ← dev overrides (debug ports, volumes) -- `.env.example` ← template for required environment variables -- `scripts/init-topics.sh` ← Kafka topic creation script (standalone) - -**Tasks**: -- [ ] Verify `docker-compose.yml` syntax and service dependencies -- [ ] Create `.env.example` with all required variables (`POSTGRES_PASSWORD`, etc.) -- [ ] Create `docker-compose.dev.yml` with dev-specific overrides (extra ports, restart policies) -- [ ] Write `scripts/init-topics.sh` for manual topic creation -- [ ] Test `docker-compose up -d` — all services start healthy -- [ ] Verify Kafka UI accessible at `localhost:8090` -- [ ] Verify all 3 PostgreSQL instances accept connections -- [ ] Verify Kafka topics are created by `kafka-init` service -- [ ] Document in `README.md` - -**Acceptance Criteria**: -- `docker-compose up -d && docker-compose ps` shows all services healthy -- Kafka topics (`edr.events.raw`, `edr.events.norm`, `edr.alerts`, `edr.health`) exist -- All 3 PostgreSQL databases accept connections - ---- - -### PR #2 — Database migration scripts -**Branch**: `feat/db-migrations` -**Duration**: 1 day -**Depends on**: PR #1 - -**Files**: -- `scripts/run-migrations.sh` ← applies SQL migrations to all DBs -- `scripts/sql/nodes_001.sql` ← node registry tables -- `scripts/sql/logs_001.sql` ← event log tables (partitioned) -- `scripts/sql/alerts_001.sql` ← alert tables - -**Tasks**: -- [ ] Create SQL migration for `edr_nodes` DB (nodes, agent_configs, pending_commands) -- [ ] Create SQL migration for `edr_logs` DB (events table with partitioning) -- [ ] Create SQL migration for `edr_alerts` DB (alerts table with indexes) -- [ ] Write `run-migrations.sh` that runs psql against each DB -- [ ] Test migrations are idempotent (can run twice without error) -- [ ] Add index creation for all query patterns - -**Acceptance Criteria**: -- `./scripts/run-migrations.sh` completes without errors -- All tables, indexes, and constraints exist - ---- - -### PR #3 — Service Dockerfiles integration -**Branch**: `feat/service-dockerfiles` -**Duration**: 0.5 day -**Depends on**: After services have basic `main.rs` (Phase 1+) - -**Tasks**: -- [ ] Add service definitions to `docker-compose.yml` for fleet-server, kafka-pipeline, rule-engine, api-backend -- [ ] Configure inter-service networking -- [ ] Add health checks for Rust services -- [ ] Add frontend nginx service -- [ ] Test full stack `docker-compose up` - ---- - -### PR #4 — Kubernetes manifests (Phase 9) -**Branch**: `feat/k8s-manifests` -**Duration**: 2 days -**Depends on**: All services functional - -**Files**: -- `k8s/manifests/namespace.yaml` -- `k8s/manifests/fleet-server-deployment.yaml` -- `k8s/manifests/kafka-pipeline-deployment.yaml` -- `k8s/manifests/rule-engine-deployment.yaml` -- `k8s/manifests/api-backend-deployment.yaml` -- `k8s/manifests/frontend-deployment.yaml` -- `k8s/manifests/services.yaml` -- `k8s/manifests/configmaps.yaml` -- `k8s/manifests/secrets.yaml` - -**Tasks**: -- [ ] Create namespace definition -- [ ] Write Deployment + Service for each Rust service -- [ ] Write ConfigMaps for non-secret config -- [ ] Write Secret templates -- [ ] Add HPA (Horizontal Pod Autoscaler) for fleet-server and kafka-pipeline -- [ ] Add PersistentVolumeClaims for PostgreSQL and Kafka -- [ ] Test with `kubectl apply -f k8s/manifests/` - ---- - -### PR #5 — Observability stack (Phase 9) -**Branch**: `feat/observability` -**Duration**: 1.5 days - -**Tasks**: -- [ ] Add Prometheus to docker-compose -- [ ] Add Grafana to docker-compose with pre-configured datasource -- [ ] Create Grafana dashboards (event throughput, alert rate, node health) -- [ ] Document metrics endpoints for each service -- [ ] Add runbooks in `docs/` directory diff --git a/kafka-pipeline/timeline.md b/kafka-pipeline/timeline.md deleted file mode 100644 index c75db2a..0000000 --- a/kafka-pipeline/timeline.md +++ /dev/null @@ -1,76 +0,0 @@ -# Kafka Pipeline — Implementation Timeline - -> **Phase**: 4 (Kafka Pipeline & Database) -> **Priority**: 🟡 High — bridges raw events to normalised storage -> **Estimated Duration**: 4–5 days -> **Depends on**: `sdk v0.1.0`, infra running, fleet-server producing to Kafka - ---- - -## Pipeline Data Flow - -```mermaid -flowchart LR - K_RAW["Kafka\\nedr.events.raw"] -->|consume| CONSUMER["Consumer"] - CONSUMER --> NORM["Normaliser"] - NORM --> DB["DB Writer\\n(batch INSERT)"] - NORM --> K_NORM["Kafka\\nedr.events.norm"] - DB --> PG[("PostgreSQL\\nedr_logs")] - - style K_RAW fill:#f59e0b,color:#000 - style K_NORM fill:#f59e0b,color:#000 - style PG fill:#3b82f6,color:#fff - style NORM fill:#22c55e,color:#fff -``` - -## PR Plan - -### PR #1 — Consumer, config, and skeleton -**Branch**: `feat/pipeline-skeleton` -**Duration**: 1 day - -**Files**: -- `src/main.rs` — tokio runtime, spawns consumer + db_writer tasks -- `src/config.rs` — Kafka brokers, DB URL, consumer group -- `src/consumer.rs` — rdkafka consumer for `edr.events.raw` -- `src/error.rs` — error types - -**Tasks**: -- [ ] Configure rdkafka `StreamConsumer` with consumer group `edr-pipeline` -- [ ] Subscribe to `edr.events.raw` topic -- [ ] Deserialise raw event bytes from Kafka messages -- [ ] Structured logging and graceful shutdown -- [ ] Integration test: produce mock message → consumer receives it - -### PR #2 — Normaliser and event transformation -**Branch**: `feat/pipeline-normaliser` -**Duration**: 1.5 days -**Depends on**: PR #1 - -**Files**: -- `src/normalizer.rs` — raw event → `NormalisedEvent` transformation - -**Tasks**: -- [ ] Parse `AgentEvent.payload` JSON bytes -- [ ] Route by `event_type` → construct appropriate `EventPayload` variant -- [ ] Enrich with UUID, timestamp normalisation, hostname lookup -- [ ] Handle malformed events gracefully (log + skip, don't crash) -- [ ] Unit tests for each event type transformation -- [ ] Fuzz test for malformed JSON handling - -### PR #3 — DB writer and Kafka re-producer -**Branch**: `feat/pipeline-db-producer` -**Duration**: 1.5 days -**Depends on**: PR #2 - -**Files**: -- `src/db_writer.rs` — batch inserts to `edr_logs` PostgreSQL -- `src/producer.rs` — produces to `edr.events.norm` - -**Tasks**: -- [ ] Batch insert normalised events to PostgreSQL (configurable batch size) -- [ ] Produce normalised events to `edr.events.norm` topic -- [ ] Implement exactly-once semantics (Kafka transactions or idempotent writes) -- [ ] Commit Kafka offsets only after DB write + re-produce succeed -- [ ] Handle DB connection failures with retry and circuit breaker -- [ ] Performance test: 10k events/sec throughput target diff --git a/rule-engine/timeline.md b/rule-engine/timeline.md deleted file mode 100644 index 05b6931..0000000 --- a/rule-engine/timeline.md +++ /dev/null @@ -1,81 +0,0 @@ -# Rule Engine — Implementation Timeline - -> **Phase**: 5 (YARA + MITRE Detection) -> **Priority**: 🟡 High — generates alerts from normalised events -> **Estimated Duration**: 5–6 days -> **Depends on**: `sdk v0.1.0`, kafka-pipeline producing to `edr.events.norm` - ---- - -## Detection Pipeline - -```mermaid -flowchart LR - K_NORM["Kafka\\nedr.events.norm"] -->|consume| SCAN["YARA Scanner"] - SCAN -->|match| MITRE["MITRE Mapper"] - SCAN -->|no match| DROP["discard"] - MITRE --> ALERT["Alert Builder"] - ALERT --> K_ALERTS["Kafka\\nedr.alerts"] - ALERT --> PG[("PostgreSQL\\nedr_alerts")] - - style K_NORM fill:#f59e0b,color:#000 - style K_ALERTS fill:#ef4444,color:#fff - style SCAN fill:#22c55e,color:#fff - style MITRE fill:#3b82f6,color:#fff - style PG fill:#3b82f6,color:#fff - style DROP fill:#6b7280,color:#fff -``` - -## PR Plan - -### PR #1 — Kafka consumer and skeleton -**Branch**: `feat/rules-skeleton` -**Duration**: 1 day - -**Files**: -- `src/main.rs` — runtime, task spawning -- `src/config.rs` — Kafka, DB, rules directory config -- `src/consumer.rs` — rdkafka consumer for `edr.events.norm` - -**Tasks**: -- [ ] Configure consumer group `edr-rules` -- [ ] Subscribe to `edr.events.norm` -- [ ] Deserialise `NormalisedEvent` from Kafka -- [ ] Pass events through detection pipeline - -### PR #2 — YARA rule loading and scanning -**Branch**: `feat/rules-yara` -**Duration**: 2 days -**Depends on**: PR #1 - -**Files**: -- `src/rules/loader.rs` — loads `.yar` files from rules directory -- `src/yara_scanner.rs` — compiles and evaluates YARA rules -- `rules/process_injection.yar`, `rules/credential_access.yar`, `rules/persistence.yar` - -**Tasks**: -- [ ] Load all `.yar` files from configurable directory (`/etc/edr/rules/`) -- [ ] Compile rules into `yara_x::Rules` at startup -- [ ] Scan event payloads against compiled rules -- [ ] Support hot-reload of rules (watch directory for changes) -- [ ] Write default YARA rules for common attack patterns -- [ ] Unit tests: known-bad event → rule match, benign event → no match - -### PR #3 — MITRE ATT&CK mapping and alert generation -**Branch**: `feat/rules-mitre-alerts` -**Duration**: 1.5 days -**Depends on**: PR #2 - -**Files**: -- `src/mitre_mapper.rs` — technique ID → tactic lookup -- `src/alert_producer.rs` — Kafka producer to `edr.alerts` -- `src/db_writer.rs` — writes alerts to `edr_alerts` DB - -**Tasks**: -- [ ] Build MITRE ATT&CK lookup table (technique ID → tactic, name, description) -- [ ] Map YARA rule matches to MITRE technique IDs via rule metadata -- [ ] Construct `Alert` struct with severity, MITRE context, threat score -- [ ] Produce alerts to `edr.alerts` Kafka topic -- [ ] Write alerts to `edr_alerts` PostgreSQL -- [ ] Deduplicate alerts (same event + same rule = one alert) -- [ ] Unit tests for mapping and alert construction diff --git a/sdk/timeline.md b/sdk/timeline.md deleted file mode 100644 index 95061d6..0000000 --- a/sdk/timeline.md +++ /dev/null @@ -1,126 +0,0 @@ -# SDK — Implementation Timeline - -> **Phase**: 0 (Foundation) -> **Priority**: 🔴 Critical — every other service depends on this -> **Estimated Duration**: 3–4 days - ---- - -## Overview - -The SDK is the compile-time contract between all services. It must be implemented **first** and tagged `v0.1.0` before any other service can begin. Every type change here triggers a version bump and cascading PRs. - -```mermaid -flowchart TD - SDK["edr-sdk"] --> AGENT["agent-core"] - SDK --> FLEET["fleet-server"] - SDK --> PIPE["kafka-pipeline"] - SDK --> RULES["rule-engine"] - SDK --> API["api-backend"] - SDK --> EBPF["ebpf-collector"] - SDK --> OSQ["osquery-client"] - SDK --> BUF["event-buffer"] - SDK --> FC["fleet-client"] - - style SDK fill:#ef4444,color:#fff -``` - ---- - -## PR Plan - -### PR #1 — Proto definitions and tonic code generation -**Branch**: `feat/proto-codegen` -**Duration**: 1 day -**Files**: -- `proto/fleet.proto` ← already scaffolded -- `proto/agent.proto` ← already scaffolded -- `proto/events.proto` ← already scaffolded -- `build.rs` ← compile protos via tonic-build -- `src/proto/mod.rs` ← re-export generated code - -**Tasks**: -- [ ] Finalise proto message definitions (review field types, naming) -- [ ] Write `build.rs` that invokes `tonic_build::configure()` for all 3 protos -- [ ] Create `src/proto/mod.rs` that re-exports generated Rust modules -- [ ] Verify `cargo build` compiles cleanly with generated code -- [ ] Add doc comments to proto files explaining each message - -**Acceptance Criteria**: -- `cargo build` succeeds -- Generated Rust code is accessible via `edr_sdk::proto::*` - ---- - -### PR #2 — Core shared types (events, alerts, nodes) -**Branch**: `feat/shared-types` -**Duration**: 1 day -**Depends on**: PR #1 - -**Files**: -- `src/lib.rs` ← module declarations -- `src/types/mod.rs` ← re-exports -- `src/types/event.rs` ← `NormalisedEvent`, `EventPayload`, `ProcessEvent`, `FileEvent`, `NetworkEvent` -- `src/types/alert.rs` ← `Alert`, `Severity`, `AlertSource`, `AlertStatus` -- `src/types/node.rs` ← `Node`, `NodeStatus`, `NodeConfig` - -**Tasks**: -- [ ] Define `NormalisedEvent` struct with serde derive -- [ ] Define `EventPayload` enum (Process, File, Network, OsqueryResult) -- [ ] Define `ProcessEvent`, `FileEvent`, `NetworkEvent`, `OsqueryEvent` structs -- [ ] Define `Alert` struct with all MITRE fields -- [ ] Define enums: `Severity`, `AlertSource`, `AlertStatus`, `FileOperation`, `NetworkDirection`, `EventType` -- [ ] Define `Node`, `NodeStatus` types -- [ ] Add `#[cfg(test)]` unit tests for serialization roundtrips -- [ ] Ensure all types implement `Debug, Clone, Serialize, Deserialize` - -**Acceptance Criteria**: -- All types compile and serialize/deserialize correctly -- `cargo test` passes - ---- - -### PR #3 — Auth types and client helpers -**Branch**: `feat/auth-helpers` -**Duration**: 0.5 day -**Depends on**: PR #2 - -**Files**: -- `src/types/auth.rs` ← `Claims` (JWT payload struct) -- `src/auth/mod.rs` ← JWT validation helpers (optional, thin wrappers) - -**Tasks**: -- [ ] Define `Claims` struct matching JWT payload (node_id, exp, iat, role) -- [ ] Add helper functions for token validation if shared across services -- [ ] Unit tests for Claims serialization - ---- - -### PR #4 — Version tag and release -**Branch**: `main` (direct tag after merge) -**Duration**: 0.5 day -**Depends on**: PR #1–3 merged - -**Tasks**: -- [ ] Verify full `cargo test --all` passes -- [ ] Run `cargo clippy -- -D warnings` -- [ ] Run `cargo fmt --check` -- [ ] Tag `v0.1.0` -- [ ] Update consuming services' `Cargo.toml` to point to this tag -- [ ] Document public API in `README.md` - -**Acceptance Criteria**: -- Tag `v0.1.0` exists -- All downstream services can `cargo build` with this dependency - ---- - -## Ongoing Maintenance - -| Trigger | Action | Version Bump | -|---|---|---| -| Add optional field to existing struct | Patch (0.1.1) | -| Add new message type / RPC | Minor (0.2.0) | -| Change existing field type or remove field | Major (1.0.0) | - -Every version bump triggers PRs in all consuming services to update the dependency. From 4d634af02f1039d7a6104c868440ebd4dc932a24 Mon Sep 17 00:00:00 2001 From: ghule swarnit Date: Fri, 29 May 2026 17:18:50 +0530 Subject: [PATCH 10/69] modified: agent/crates/agent-core/src/config.rs modified: agent/crates/agent-core/src/orchestrator.rs modified: agent/crates/fleet-client/src/lib.rs --- agent/crates/agent-core/src/config.rs | 38 +-------------------- agent/crates/agent-core/src/orchestrator.rs | 8 ++--- agent/crates/fleet-client/src/lib.rs | 4 +-- 3 files changed, 6 insertions(+), 44 deletions(-) diff --git a/agent/crates/agent-core/src/config.rs b/agent/crates/agent-core/src/config.rs index 2d2fd00..e87d130 100644 --- a/agent/crates/agent-core/src/config.rs +++ b/agent/crates/agent-core/src/config.rs @@ -47,7 +47,6 @@ pub struct AgentSection { /// OSQuery daemon configuration. #[derive(Debug, Deserialize)] pub struct OsqueryConfig { - // ── Connection ────────────────────────────────────── /// Path to osqueryd's extension manager Unix socket /// Default: /var/osquery/osquery.em pub socket_path: PathBuf, @@ -55,19 +54,16 @@ pub struct OsqueryConfig { /// Connection timeout in seconds when connecting to the socket pub connect_timeout_secs: Option, - // ── Daemon Options (mirrors osquery.conf "options") ─ + // Daemon Options (mirrors osquery.conf "options") pub options: OsqueryOptions, - // ── Initial Scheduled Queries ─────────────────────── /// Bootstrap queries (overridden by fleet server push) pub schedule: Vec, - // ── File Integrity Monitoring ─────────────────────── /// FIM paths: category_name → list of glob paths /// e.g., { "etc": ["/etc/%%", "/etc/ssh/%%"] } pub file_paths: Option>>, - // ── Query Packs ───────────────────────────────────── /// Named packs: pack_name → path_to_pack_conf_file pub packs: Option>, } @@ -77,56 +73,24 @@ pub struct OsqueryConfig { /// All fields are Optional — only set values override osquery defaults. #[derive(Debug, Deserialize)] pub struct OsqueryOptions { - // ── Core Daemon ───────────────────────────────────── - /// How config is retrieved: "filesystem" | "tls" pub config_plugin: Option, - /// Where to send logs: "filesystem" | "syslog" | "tls" pub logger_plugin: Option, - /// Disable all logging if true pub disable_logging: Option, - /// Disable event-based tables if true pub disable_events: Option, - /// Disable kernel audit subsystem if true pub disable_audit: Option, - - // ── Audit Subsystem ───────────────────────────────── - /// Enable process execution events via audit pub audit_allow_process_events: Option, - /// Enable socket events via audit pub audit_allow_sockets: Option, - /// Enable config change events via audit pub audit_allow_config: Option, - /// Attempt to persist audit rules across osquery restarts pub audit_persist: Option, - - // ── Performance ───────────────────────────────────── - /// Maximum number of events to buffer (default 50000) pub events_max: Option, - /// Randomize query start times by this percentage (0-100) pub schedule_splay_percent: Option, - /// Resource watchdog aggressiveness level pub watchdog_level: Option, - /// Number of worker threads for query dispatch pub worker_threads: Option, - - // ── Identity ──────────────────────────────────────── - /// How to identify the host: "hostname" | "uuid" | "instance" | "specified" pub host_identifier: Option, - /// Custom identifier string when host_identifier = "specified" pub specified_identifier: Option, - - // ── Database ──────────────────────────────────────── - /// Path to the RocksDB database (default /var/osquery/osquery.db) pub database_path: Option, - - // ── Security ──────────────────────────────────────── - /// Comma-delimited list of tables to disable pub disable_tables: Option, - /// Comma-delimited list of tables to explicitly enable pub enable_tables: Option, - - // ── Time ──────────────────────────────────────────── - /// Log timestamps in UTC if true pub utc: Option, } diff --git a/agent/crates/agent-core/src/orchestrator.rs b/agent/crates/agent-core/src/orchestrator.rs index 14f6733..37891aa 100644 --- a/agent/crates/agent-core/src/orchestrator.rs +++ b/agent/crates/agent-core/src/orchestrator.rs @@ -51,7 +51,6 @@ pub async fn run() -> Result<()> { agent_tracing::init(&config.agent.log_level, format)?; tracing::info!("Starting EDR Agent Orchestrator"); - // ── Event buffer (SQLite) ────────────────────────────────────────────── // EventBuffer wraps rusqlite::Connection which is !Send, so we keep it // on this task and never move it into tokio::spawn. let buffer = EventBuffer::new(&config.agent.buffer_path) @@ -70,7 +69,7 @@ pub async fn run() -> Result<()> { } } - // ── Start OsqueryCollector ───────────────────────────────────────────── + // Start OsqueryCollector let collector = osquery_client::OsqueryCollector::new(osquery_client::OsqueryConfig { socket_path: config.osquery.socket_path.clone(), db_path: config.agent.buffer_path.clone(), @@ -87,7 +86,7 @@ pub async fn run() -> Result<()> { let mut results_rx = collector.start(&agent_uuid).await; tracing::info!("OsqueryCollector started (agent_uuid={})", agent_uuid); - // ── Fleet enrollment (non-fatal, fleet server not ready yet) ────────── + // Fleet enrollment (non-fatal, fleet server not ready yet) tracing::info!("Attempting fleet enrollment (non-fatal if server is down)..."); let mut fleet_client = fleet_client::FleetClient::new(fleet_client::FleetConfig { endpoint: config.fleet.endpoint.clone(), @@ -113,7 +112,7 @@ pub async fn run() -> Result<()> { } } - // ── Main loop — drain results & handle shutdown ─────────────────────── + // Main loop — drain results & handle shutdown // rusqlite::Connection is !Send so we drive the buffer writes here on the // main task rather than in a spawned task. tracing::info!("Agent is running. Draining osquery results. Press Ctrl-C to stop."); @@ -149,7 +148,6 @@ pub async fn run() -> Result<()> { Ok(()) } -// ── Helpers ──────────────────────────────────────────────────────────────── /// Encode an OsqueryResult to raw bytes for storage in the event buffer. /// Uses prost protobuf encoding. diff --git a/agent/crates/fleet-client/src/lib.rs b/agent/crates/fleet-client/src/lib.rs index d2eab9d..1910cd0 100644 --- a/agent/crates/fleet-client/src/lib.rs +++ b/agent/crates/fleet-client/src/lib.rs @@ -19,7 +19,7 @@ pub struct FleetConfig { pub struct FleetClient { connection: FleetConnection, - state_rx: watch::Receiver, + // state_rx: watch::Receiver, enrollment: Option, } @@ -28,7 +28,7 @@ impl FleetClient { let (connection, state_rx) = FleetConnection::new(&config.endpoint); Ok(Self { connection, - state_rx, + // state_rx, enrollment: None, }) } From d4be56d4d290cb84f3e3a3b730b3eb03c3949fa1 Mon Sep 17 00:00:00 2001 From: ghule swarnit Date: Mon, 1 Jun 2026 05:43:40 +0530 Subject: [PATCH 11/69] feat: add fleet management and tracing modules - Created `fleet-manager` crate with a basic fleet management function. - Added `fleet-tracing` crate for initializing tracing and logging. - Introduced `grpc-listener` crate with build script for gRPC service. - Implemented health tracking in `health-tracker` crate. - Added Kafka publishing functionality in `kafka-handler` crate. - Created `node-enrollment` crate for node enrollment functionality. - Established Postgres interface in `postgres-interface` crate. - Updated `fleet-server-bin` to initialize and run the fleet server. --- .gitignore | 1 + Cargo.toml | 19 +- ISSUES.md | 797 ++++++++++++++++++ agent/crates/agent-core/src/config.rs | 2 +- agent/crates/agent-core/src/orchestrator.rs | 7 +- agent/crates/fleet-client/src/types.rs | 20 - fleet-server/Cargo.toml | 31 - fleet-server/Dockerfile | 33 - fleet-server/crates/fleet-manager/Cargo.toml | 8 + fleet-server/crates/fleet-manager/src/lib.rs | 3 + .../crates/fleet-server-bin/Cargo.toml | 20 + .../crates/fleet-server-bin/src/main.rs | 5 + fleet-server/crates/fleet-tracing/Cargo.toml | 9 + fleet-server/crates/fleet-tracing/src/lib.rs | 3 + fleet-server/crates/grpc-listener/Cargo.toml | 22 + fleet-server/crates/grpc-listener/build.rs | 10 + fleet-server/crates/grpc-listener/src/lib.rs | 3 + fleet-server/crates/health-tracker/Cargo.toml | 8 + fleet-server/crates/health-tracker/src/lib.rs | 3 + fleet-server/crates/kafka-handler/Cargo.toml | 8 + fleet-server/crates/kafka-handler/src/lib.rs | 3 + .../crates/node-enrollment/Cargo.toml | 8 + .../crates/node-enrollment/src/lib.rs | 3 + .../crates/postgres-interface/Cargo.toml | 9 + .../crates/postgres-interface/src/lib.rs | 3 + fleet-server/migrations/001_create_nodes.sql | 33 - fleet-server/src/grpc/main.rs | 0 fleet-server/src/main.rs | 3 - 28 files changed, 948 insertions(+), 126 deletions(-) create mode 100644 ISSUES.md delete mode 100644 fleet-server/Cargo.toml create mode 100644 fleet-server/crates/fleet-manager/Cargo.toml create mode 100644 fleet-server/crates/fleet-manager/src/lib.rs create mode 100644 fleet-server/crates/fleet-server-bin/Cargo.toml create mode 100644 fleet-server/crates/fleet-server-bin/src/main.rs create mode 100644 fleet-server/crates/fleet-tracing/Cargo.toml create mode 100644 fleet-server/crates/fleet-tracing/src/lib.rs create mode 100644 fleet-server/crates/grpc-listener/Cargo.toml create mode 100644 fleet-server/crates/grpc-listener/build.rs create mode 100644 fleet-server/crates/grpc-listener/src/lib.rs create mode 100644 fleet-server/crates/health-tracker/Cargo.toml create mode 100644 fleet-server/crates/health-tracker/src/lib.rs create mode 100644 fleet-server/crates/kafka-handler/Cargo.toml create mode 100644 fleet-server/crates/kafka-handler/src/lib.rs create mode 100644 fleet-server/crates/node-enrollment/Cargo.toml create mode 100644 fleet-server/crates/node-enrollment/src/lib.rs create mode 100644 fleet-server/crates/postgres-interface/Cargo.toml create mode 100644 fleet-server/crates/postgres-interface/src/lib.rs delete mode 100644 fleet-server/migrations/001_create_nodes.sql delete mode 100644 fleet-server/src/grpc/main.rs delete mode 100644 fleet-server/src/main.rs diff --git a/.gitignore b/.gitignore index 95a2cc7..a67f2c8 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ Thumbs.db .antigravitycli # Mock development tools agent/tools/mock-fleet-server/target/ +.gemini/* diff --git a/Cargo.toml b/Cargo.toml index 4900240..c5983d0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,14 @@ resolver = "2" members = [ "sdk", - "fleet-server", + "fleet-server/crates/fleet-server-bin", + "fleet-server/crates/grpc-listener", + "fleet-server/crates/fleet-manager", + "fleet-server/crates/node-enrollment", + "fleet-server/crates/health-tracker", + "fleet-server/crates/fleet-tracing", + "fleet-server/crates/postgres-interface", + "fleet-server/crates/kafka-handler", "api-backend", "kafka-pipeline", "rule-engine", @@ -61,6 +68,7 @@ config = "0.15" anyhow = "1" thiserror = "2" +async-trait = "0.1" yara-x = "1" @@ -77,3 +85,12 @@ agent-tracing = { path = "agent/crates/agent-tracing" } rusqlite = { version = "0.31", features = ["bundled"] } toml = "0.8" thrift = "0.17" + +fleet-server-bin = { path = "fleet-server/crates/fleet-server-bin" } +grpc-listener = { path = "fleet-server/crates/grpc-listener" } +fleet-manager = { path = "fleet-server/crates/fleet-manager" } +node-enrollment = { path = "fleet-server/crates/node-enrollment" } +health-tracker = { path = "fleet-server/crates/health-tracker" } +fleet-tracing = { path = "fleet-server/crates/fleet-tracing" } +postgres-interface = { path = "fleet-server/crates/postgres-interface" } +kafka-handler = { path = "fleet-server/crates/kafka-handler" } diff --git a/ISSUES.md b/ISSUES.md new file mode 100644 index 0000000..6008ecd --- /dev/null +++ b/ISSUES.md @@ -0,0 +1,797 @@ +# GitHub Issues — project-edr + +--- + +## Issue: Wire the ebpf-collector crate into the workspace build and establish the aya build pipeline +**Labels:** `ebpf`, `kernel`, `scaffolding`, `unsafe` +**Depends on:** none +**Blocks:** BPF map definitions issue; process-lifecycle probe issue; network probe issue; userspace loader issue + +### What this is +The `ebpf-collector` crate exists at `agent/crates/ebpf-collector/` with `aya = "0.13"` and `aya-build = "0.1"` declared in its `Cargo.toml`, but it is explicitly excluded from the workspace root `Cargo.toml` (`exclude = ["agent/crates/ebpf-collector"]`). Its `src/lib.rs` is a two-line comment stub. The crate has no `build.rs`, no BPF program directory, and produces nothing. This issue establishes the complete build foundation: workspace integration, cross-compilation target configuration, `build.rs` that compiles BPF C programs via `aya-build`, directory layout for BPF sources, and verification that the whole thing compiles against a BTF-enabled kernel (≥5.8). + +### What is currently blocking this +Nothing external. This is the root of the eBPF workstream. The `agent/.cargo/config.toml` already has `[target.bpfel-unknown-none] rustflags = ["-C", "link-arg=--btf"]`, confirming intent. The blocker is the missing workspace membership, missing `build.rs`, and the empty crate body. + +### What this is blocking +Every downstream eBPF issue. BPF map definitions, probe implementations, and the userspace loader all depend on the build pipeline this issue establishes. + +### Implementation tasks +- [ ] Remove `"agent/crates/ebpf-collector"` from the `exclude` list in the workspace root `Cargo.toml` and add it to `members`. Verify `cargo check -p ebpf-collector` compiles. +- [ ] Create `agent/crates/ebpf-collector/build.rs` that calls `aya_build::build()` or equivalent to compile BPF C programs from `bpf/` into the output directory. Follow the aya-build 0.1 API: `aya_build::build()?` respects `CARGO_CFG_TARGET_ARCH` and invokes `clang` with the correct BPF target flags. +- [ ] Create the `agent/crates/ebpf-collector/bpf/` directory. Add a placeholder `common.h` defining the shared event structs that all BPF programs will write into ring buffers. Start with `struct process_event { u32 pid; u32 ppid; char comm[16]; char cmdline[256]; u32 uid; }` and equivalents for file and network. These structs must be `#[repr(C)]` on the Rust side. +- [ ] In `agent/crates/ebpf-collector/src/lib.rs`, add the module skeleton: `pub mod loader; pub mod events; pub mod error;`. Add a `CollectorError` enum using `thiserror` covering `BpfLoadError`, `ProgramAttachError`, `RingBufError`. +- [ ] Add `thiserror` to `ebpf-collector/Cargo.toml` dependencies (use workspace version). +- [ ] Verify that `cargo check -p ebpf-collector --target x86_64-unknown-linux-gnu` passes (on a Linux dev box or CI). Document the required host toolchain: `clang ≥ 14`, `llvm-strip`, `bpf-linker` if needed. Add a `README.md` in `agent/crates/ebpf-collector/` listing these requirements. +- [ ] Add a `#[cfg(target_os = "linux")]` guard to the crate's public API surface so the workspace compiles on macOS during development without failing (aya does not support non-Linux targets at runtime). +- [ ] Write a unit test `test_error_variants_display` that verifies `CollectorError::BpfLoadError("test".into())` formats without panic. + +### Definition of done +- `cargo check -p ebpf-collector --target x86_64-unknown-linux-gnu` succeeds on a Linux host with clang ≥14 installed. +- `build.rs` is present and `aya_build::build()` is called; the `bpf/` directory exists and is referenced. +- `ebpf-collector` is a member of the workspace (appears in `cargo metadata --format-version 1 | jq '.workspace_members'`). +- `CollectorError` variants compile and display correctly. +- A macOS `cargo check` (without `--target bpf`) does not error on the crate. + +### Notes / constraints +- `aya` 0.13 (declared in the existing `Cargo.toml`) targets kernels ≥5.8 for ring buffer support. This is the minimum acceptable kernel version for this workstream. Document this in the `README.md`. +- BTF (`CONFIG_DEBUG_INFO_BTF=y`) is required for CO-RE. Verify with `bpftool btf list` on the target kernel. Without BTF, the loader will need to embed vmlinux BTF via `aya_tool::generate`. +- The `aya-build` crate requires `clang` on `PATH` at build time. This must be in the CI runner and documented. +- Do not attempt to use `cargo-bpf` — the crate has already committed to `aya`. + +--- + +## Issue: Define BPF ring buffer maps and shared event structs for process, file, and network telemetry +**Labels:** `ebpf`, `kernel`, `unsafe` +**Depends on:** Wire the ebpf-collector crate into the workspace build and establish the aya build pipeline +**Blocks:** process-lifecycle probe issue; network probe issue; userspace loader issue; event consumer issue + +### What this is +Before any BPF program can be written, the shared data structures that live in BPF maps must be defined and agreed upon between kernel space (C) and userspace (Rust). This issue defines the three `RingBuf` maps (one per event category), the C structs written by BPF programs, and the corresponding `#[repr(C)]` Rust structs that the userspace event consumer will deserialize from ring buffer memory. It also wires these structs into the `edr-sdk` types pipeline (specifically `agent.proto` already has `ProcessEvent`, `FileEvent`, `NetworkEvent` — these Rust structs must be compatible). + +### What is currently blocking this +The build pipeline issue above must land first. Once `bpf/common.h` exists as a placeholder, this issue replaces the placeholder with production-ready definitions. + +### What this is blocking +The process probe and network probe issues, both of which write into these maps. The userspace event consumer, which reads from them. + +### Implementation tasks +- [ ] In `agent/crates/ebpf-collector/bpf/common.h`, define `struct process_event { u32 pid; u32 ppid; char comm[TASK_COMM_LEN]; char cmdline[512]; u32 uid; u32 euid; char cwd[256]; }`. Use `TASK_COMM_LEN = 16` from ``. +- [ ] In the same header, define `struct file_event { u32 pid; char comm[16]; char path[256]; u8 operation; s32 ret; }` where `operation` is an enum-equivalent `u8`: `0=open, 1=write, 2=delete, 3=rename`. +- [ ] Define `struct network_event { u32 pid; char comm[16]; u32 src_ip; u32 dst_ip; u16 src_port; u16 dst_port; u8 protocol; u8 direction; }` for IPv4. Add a `u8 is_ipv6` flag and `u8 src_ip6[16]` / `u8 dst_ip6[16]` fields for future IPv6 support (write zeroes if unused). +- [ ] Create `agent/crates/ebpf-collector/src/events.rs`. Define `#[repr(C)] pub struct ProcessEvent { pub pid: u32, pub ppid: u32, pub comm: [u8; 16], pub cmdline: [u8; 512], pub uid: u32, pub euid: u32, pub cwd: [u8; 256] }` — field layout must exactly match the C struct. Do the same for `FileEvent` and `NetworkEvent`. Derive nothing that requires heap allocation (no `String` here — these are read directly from kernel ring buffer memory). +- [ ] Implement `TryFrom<&[u8]>` for each struct that reads from a `&[u8]` slice (from the ring buffer). Use `zerocopy` or manual `ptr::read_unaligned` under `unsafe`. Add a bounds check: if the slice is shorter than `mem::size_of::()`, return `CollectorError::MalformedEvent`. +- [ ] Add a `fn to_sdk_process_event(&self) -> edr_sdk::proto::agent::ProcessEvent` converter on `ProcessEvent` that maps the `[u8; N]` comm/cmdline/cwd arrays to `String` using `from_utf8_lossy`. Do the same for `FileEvent` → `edr_sdk::proto::agent::FileEvent` and `NetworkEvent` → `edr_sdk::proto::agent::NetworkEvent`. (This requires `edr-sdk` to expose generated proto types — verify `sdk/src/lib.rs` re-exports them.) +- [ ] Define the three ring buffer map names as constants: `pub const PROCESS_EVENTS: &str = "PROCESS_EVENTS"`, `FILE_EVENTS`, `NETWORK_EVENTS`. These strings must match the map section names in the BPF C programs. +- [ ] Write unit tests: `test_process_event_from_bytes_exact_size`, `test_process_event_from_bytes_too_short`, `test_file_event_operation_roundtrip`, `test_network_event_ipv4_conversion_to_sdk_type`. Run with `cargo test -p ebpf-collector`. + +### Definition of done +- `cargo test -p ebpf-collector` passes all unit tests above. +- The three `#[repr(C)]` structs are defined, size-checked in tests (`assert_eq!(mem::size_of::(), )`). +- `TryFrom<&[u8]>` is implemented and tested for malformed input. +- Conversion to `edr-sdk` proto types is implemented and compiles. + +### Notes / constraints +- Do not use `serde` or `bincode` for deserializing ring buffer events — the kernel writes raw C structs. Only `zerocopy` or manual pointer casts (with alignment guarantees checked) are appropriate. +- Padding bytes in C structs will appear in the ring buffer. Ensure the Rust struct fields are laid out in the same order with the same alignment as the C struct. Use `static_assertions::assert_eq_size!` if desired. +- `TASK_COMM_LEN` is 16 bytes on all supported Linux kernels. `cmdline` is bounded to 512 to avoid stack overflow in BPF programs (BPF stack limit is 512 bytes total per program). + +--- + +## Issue: Implement the process-lifecycle BPF program (execve tracepoint) and wire it to the ring buffer +**Labels:** `ebpf`, `kernel`, `unsafe`, `tracing` +**Depends on:** BPF map definitions issue +**Blocks:** Userspace loader issue; event consumer issue; eBPF integration test issue + +### What this is +This issue writes the first production BPF program: `process_probe.bpf.c`, attached to the `sys_enter_execve` tracepoint. It populates a `RingBuf` map with `struct process_event` entries on every `execve` syscall. This is the canonical first probe for any EDR because process execution is the root of nearly every attack chain. Getting this right — correct map access patterns, correct argument extraction, BPF verifier compliance — establishes the pattern for all subsequent probes. + +### What is currently blocking this +The BPF map definitions issue (structs must be defined in `common.h` before programs can use them). + +### What this is blocking +The userspace loader (which loads and attaches this program). The event consumer (which reads from the ring buffer this program writes into). + +### Implementation tasks +- [ ] Create `agent/crates/ebpf-collector/bpf/process_probe.bpf.c`. Include ``, ``, ``, and `"common.h"`. +- [ ] Declare the ring buffer map: `struct { __uint(type, BPF_MAP_TYPE_RINGBUF); __uint(max_entries, 256 * 1024); } PROCESS_EVENTS SEC(".maps");`. 256 KiB = 64 pages, safe default for high-frequency execve. +- [ ] Implement `SEC("tracepoint/syscalls/sys_enter_execve") int trace_execve(struct trace_event_raw_sys_enter *ctx)`. Use `bpf_ringbuf_reserve` to allocate a `struct process_event` slot, fill `pid` from `bpf_get_current_pid_tgid() >> 32`, `ppid` from walking `task_struct` via `bpf_get_current_task()` + `BPF_CORE_READ`, `uid/euid` from `bpf_get_current_uid_gid()`, `comm` from `bpf_get_current_comm()`, and `cmdline` by reading `ctx->args[0]` (argv[0]) via `bpf_probe_read_user_str`. Submit with `bpf_ringbuf_submit`. +- [ ] Handle the BPF verifier constraint: `bpf_probe_read_user_str` on `cmdline` must use a bounded length (≤ 512). Add a null terminator at `cmdline[511]` defensively. +- [ ] For `ppid`: use `BPF_CORE_READ(task, real_parent, tgid)` — this requires BTF CO-RE. Add a preprocessor guard `#ifdef __TARGET_ARCH_x86` for architecture portability if needed. +- [ ] Add `char _license[] SEC("license") = "GPL";` — required for helper access. +- [ ] Verify the program compiles with `clang -O2 -g -target bpf -D__TARGET_ARCH_x86_64 -c process_probe.bpf.c -o /dev/null` as a manual check. The `build.rs` will handle this at cargo build time. +- [ ] Write a unit test in `src/events.rs` (already tracking this crate) that constructs a fake `ProcessEvent` byte buffer mimicking what the kernel would write, then parses it and asserts field values. + +### Definition of done +- `process_probe.bpf.c` compiles without verifier errors when loaded on a Linux 5.15+ kernel with BTF enabled. +- `build.rs` picks up the new file and produces a compiled BPF object in `target/bpf/`. +- The `struct process_event` layout in C matches the `ProcessEvent` Rust struct (verified via size assertions in unit tests). +- `cargo build -p ebpf-collector` succeeds on a Linux host with clang ≥14. + +### Notes / constraints +- `bpf_probe_read_user_str` is the correct helper for reading userspace memory (argv). Do not use `bpf_probe_read_kernel_str` for userspace pointers — it will silently read zeroes on modern kernels. +- Tracepoint `sys_enter_execve` provides the raw syscall arguments. The `ctx->args[0]` is a pointer to the filename string, `ctx->args[1]` is argv (pointer-to-pointer). Reading individual argv elements requires multiple `bpf_probe_read_user` calls inside a bounded loop (BPF verifier requires loops to have provable termination). For this issue, reading only argv[0] (filename) into `cmdline` is acceptable. Full cmdline reconstruction is a follow-on. +- CO-RE with `BPF_CORE_READ` requires the kernel to expose BTF. If `CONFIG_DEBUG_INFO_BTF` is not set, the loader must supply vmlinux BTF. This constraint is documented in the build pipeline issue. + +--- + +## Issue: Implement the network-event BPF program (connect/bind tracepoints) and wire it to the ring buffer +**Labels:** `ebpf`, `kernel`, `networking`, `unsafe` +**Depends on:** BPF map definitions issue +**Blocks:** Userspace loader issue; event consumer issue; eBPF integration test issue + +### What this is +This issue writes `network_probe.bpf.c`, attached to `tracepoint/syscalls/sys_enter_connect` and `tracepoint/syscalls/sys_enter_bind`. On each call it extracts the 5-tuple (src IP, dst IP, src port, dst port, protocol) and the process context (PID, comm), then writes a `struct network_event` into the `NETWORK_EVENTS` ring buffer. This probe feeds the connection isolation workstream: the isolation table (Workstream B) needs to know which connections the EDR process itself makes in order to register them as allowed. + +### What is currently blocking this +The BPF map definitions issue (the `struct network_event` definition must be in `common.h`). + +### What this is blocking +The userspace loader and event consumer. The connection isolation table (Workstream B) — specifically, the "population" issue in that workstream depends on network events being available to identify the EDR's own connections. + +### Implementation tasks +- [ ] Create `agent/crates/ebpf-collector/bpf/network_probe.bpf.c`. Declare `NETWORK_EVENTS` ring buffer map (same pattern as `PROCESS_EVENTS`, 256 KiB initial). +- [ ] Implement `SEC("tracepoint/syscalls/sys_enter_connect") int trace_connect(struct trace_event_raw_sys_enter *ctx)`. Extract the `sockaddr` pointer from `ctx->args[1]`. Use `bpf_probe_read_user` to read the `struct sockaddr` header. Branch on `sa_family`: if `AF_INET`, read `struct sockaddr_in` and extract `sin_addr.s_addr` and `sin_port`; if `AF_INET6`, set the `is_ipv6` flag and read `struct sockaddr_in6`. Write into ring buffer with `direction = 1` (outbound). Ignore `AF_UNIX` and other families (submit nothing). +- [ ] Implement `SEC("tracepoint/syscalls/sys_enter_bind") int trace_bind(...)` with the same extraction logic, setting `direction = 0` (inbound). +- [ ] Fill `pid` and `comm` in both handlers using the same helpers as the process probe (`bpf_get_current_pid_tgid`, `bpf_get_current_comm`). +- [ ] Set `protocol = IPPROTO_TCP` by default. Distinguishing TCP vs UDP at the `connect`/`bind` tracepoint requires reading the socket struct via `bpf_get_current_task` and `BPF_CORE_READ(task, files, ...)` — this is complex and error-prone. For this issue, mark protocol as `0xFF` (unknown) and resolve via a kretprobe on `sock_recvmsg`/`sock_sendmsg` in a follow-on issue if needed. +- [ ] Port numbers from `sockaddr` are in network byte order. Convert to host byte order using `bpf_ntohs()` before writing into the event struct. +- [ ] Add `char _license[] SEC("license") = "GPL";`. +- [ ] Write unit tests for `NetworkEvent::try_from(&[u8])` covering an IPv4 outbound event and an IPv6 inbound event. + +### Definition of done +- `network_probe.bpf.c` compiles without verifier errors on a Linux 5.15+ kernel with BTF. +- Both `trace_connect` and `trace_bind` are present and functional. +- IPv4 and IPv6 addresses are extracted correctly (verified in unit tests on the Rust struct parsing side). +- `cargo build -p ebpf-collector` succeeds with both `process_probe.bpf.c` and `network_probe.bpf.c` in the `bpf/` directory. + +### Notes / constraints +- `bpf_probe_read_user` on the `sockaddr *` pointer can fail if the pointer is invalid (e.g., the userspace process passed a bogus address). Always check the return value. On error, discard the event rather than submitting garbage. +- The connect tracepoint fires before the kernel validates the address. The connection may fail; we still want to record the attempt. +- IPv6 detection via `sa_family == AF_INET6` is the correct check. The `struct network_event` has fields for both. Only one set should be populated per event. +- Port 0 (ephemeral assignment) may appear in `bind` calls. This is a valid event — do not filter it. + +--- + +## Issue: Implement the userspace BPF loader: load compiled objects and attach programs to kernel hooks +**Labels:** `ebpf`, `kernel`, `unsafe`, `async` +**Depends on:** Process-lifecycle probe issue; network-event probe issue +**Blocks:** Event consumer issue; eBPF error handling issue; eBPF integration test issue + +### What this is +This issue implements `agent/crates/ebpf-collector/src/loader.rs`: the userspace Rust code that loads the compiled BPF object files using `aya`, attaches each program to its kernel hook point, and returns handles to the loaded maps so the event consumer can read from them. This is the bridge between the compiled `.bpf.o` artifacts and the running kernel. + +### What is currently blocking this +Both BPF programs (process probe and network probe) must be compiled and their ring buffer map definitions must be finalized before the loader can reference them by name. + +### What this is blocking +The event consumer (which receives map handles from the loader). The error handling issue (which wraps loader failures). The integration test (which calls the loader in a privileged context). + +### Implementation tasks +- [ ] Create `agent/crates/ebpf-collector/src/loader.rs`. Define `pub struct EbpfLoader` that owns an `aya::Ebpf` instance (the loaded BPF object) and exposes the ring buffer map handles. +- [ ] Implement `EbpfLoader::load() -> Result`. Use `aya::Ebpf::load(include_bytes_aligned!(concat!(env!("OUT_DIR"), "/bpf/process_probe.bpf.o")))` to embed the compiled object at link time. Do the same for `network_probe.bpf.o`. These two programs can be in separate `Ebpf` instances or combined if the build pipeline merges them. +- [ ] Attach `process_probe` to its tracepoint: `let prog: &mut TracePoint = bpf.program_mut("trace_execve").unwrap().try_into()?; prog.load()?; prog.attach("syscalls", "sys_enter_execve")?;` — handle `ProgramError` by mapping to `CollectorError::ProgramAttachError`. +- [ ] Attach `trace_connect` and `trace_bind` from `network_probe.bpf.o` analogously. +- [ ] Implement `EbpfLoader::process_ring_buf(&mut self) -> &mut RingBuf<&mut MapData>` and `network_ring_buf` accessors that retrieve the `PROCESS_EVENTS` and `NETWORK_EVENTS` maps from the loaded `Ebpf` instance using `aya::maps::RingBuf::try_from(bpf.map_mut("PROCESS_EVENTS")?)`. +- [ ] Implement `EbpfLoader::detach(self) -> Result<(), CollectorError>` that drops all program handles, causing kernel detachment. Log each detach operation. +- [ ] Add `pub fn is_btf_available() -> bool` that reads `/sys/kernel/btf/vmlinux` existence as a pre-flight check. Return `false` if the file doesn't exist. The caller (error handling issue) uses this to provide a useful error message. +- [ ] Write a compile-time test `test_loader_struct_is_send` that asserts `EbpfLoader: !Send` (it holds a raw `MapData` reference that is not `Send`). This documents the threading constraint. The loader must live on a single task; use `tokio::task::LocalSet` in the consumer. + +### Definition of done +- `EbpfLoader::load()` compiles and, when run as root on a Linux 5.15+ kernel, loads both BPF programs and attaches them to their tracepoints without error. +- `process_ring_buf()` and `network_ring_buf()` return valid map handles. +- `EbpfLoader::detach()` cleans up without leaving dangling programs (verify with `bpftool prog list` before and after). +- `is_btf_available()` returns `true` on a BTF-enabled kernel and `false` on a kernel without `/sys/kernel/btf/vmlinux`. + +### Notes / constraints +- `aya::Ebpf::load` requires the process to have `CAP_BPF` (kernel ≥5.8) or `CAP_SYS_ADMIN` (older). The agent binary must be deployed with the appropriate capability set. +- `include_bytes_aligned!` is provided by `aya` and must be used instead of `include_bytes!` to satisfy BPF object alignment requirements. +- If the two BPF programs are in separate object files (compiled separately by `build.rs`), they require two separate `aya::Ebpf` instances. The `EbpfLoader` struct must own both. Do not merge them into one object to avoid BPF map naming conflicts. +- `RingBuf` map access is not `Send`. The entire loader and consumer must run on a single OS thread using `tokio::task::LocalSet` or `std::thread::spawn`. + +--- + +## Issue: Implement the ring buffer event consumer: read, deserialize, and forward events into the agent pipeline +**Labels:** `ebpf`, `kernel`, `async`, `tracing` +**Depends on:** Userspace loader issue +**Blocks:** eBPF pipeline integration issue; eBPF integration test issue + +### What this is +This issue implements `agent/crates/ebpf-collector/src/events.rs` consumer logic: a polling loop that reads raw bytes from the `PROCESS_EVENTS` and `NETWORK_EVENTS` ring buffers (via `aya::maps::ring_buf::RingBuf`), deserializes them into the typed Rust structs defined in the map definitions issue, converts them to `edr-sdk` proto types (`AgentEvent` with appropriate `event_type`), and forwards them to the `agent-core` orchestrator via an `mpsc::Sender`. This is the final hop from kernel telemetry to the agent's event pipeline. + +### What is currently blocking this +The userspace loader must land first (this issue depends on the ring buffer map handles it returns). + +### What this is blocking +The pipeline integration issue (wiring this into `agent-core/orchestrator.rs`). The integration test (which validates end-to-end event emission). + +### Implementation tasks +- [ ] In `agent/crates/ebpf-collector/src/events.rs`, implement `pub struct EventConsumer` that holds an `EbpfLoader` and a `tokio::sync::mpsc::Sender`. +- [ ] Implement `EventConsumer::run(self) -> Result<(), CollectorError>` as a blocking loop (must run in `tokio::task::spawn_blocking` or a `LocalSet`-driven loop because `RingBuf::next()` is synchronous). The loop calls `process_ring_buf.next()` and `network_ring_buf.next()` in an interleaved fashion using a short poll interval (`tokio::time::sleep(Duration::from_millis(1))`). For each returned `Item`, call `ProcessEvent::try_from(item.as_ref())` or `NetworkEvent::try_from(item.as_ref())`, convert to proto bytes via `encode_to_vec()`, then send as an `AgentEvent` with `event_type = 1` (process) or `event_type = 3` (network), `timestamp_ns = SystemTime::now()`, and a `sequence_id = Uuid::new_v4().to_string()`. +- [ ] Handle deserialization errors by logging `tracing::warn!` with the raw byte length and continuing the loop — a malformed event must not crash the consumer. +- [ ] Handle `mpsc::Sender::send` returning `Err` (receiver dropped) by returning `CollectorError::PipelineClosed` — this signals the orchestrator has shut down. +- [ ] Add a shutdown signal: `EventConsumer::run_until_shutdown(self, shutdown: tokio::sync::CancellationToken)` that breaks the poll loop when the token is cancelled. +- [ ] Expose `pub fn start(loader: EbpfLoader, node_id: String) -> (EventConsumer, mpsc::Receiver)` as the public API. The `node_id` is set on every `AgentEvent.node_id` field. +- [ ] Write unit tests: `test_consumer_forwards_process_event` (mock the ring buffer with a pre-built byte slice, verify the sender receives a correctly-typed `AgentEvent`), `test_consumer_handles_malformed_bytes_without_crash`. + +### Definition of done +- `EventConsumer::run_until_shutdown` runs without error on a real kernel and produces `AgentEvent` structs on the returned channel when processes exec or make network connections. +- Malformed ring buffer data does not panic or crash the consumer loop. +- Shutdown via `CancellationToken` terminates the loop cleanly. +- Unit tests pass with `cargo test -p ebpf-collector`. + +### Notes / constraints +- `RingBuf::next()` does not block — it returns `None` immediately if no events are pending. The polling approach with a 1ms sleep trades latency for CPU. A production follow-on should use `epoll`/`tokio::io::unix::AsyncFd` on the ring buffer's file descriptor. This issue does not need to solve that. +- The `node_id` stamped on events comes from the enrollment flow in `fleet-client`. The consumer must receive it at construction time (not read from config) because enrollment is async and may not have completed when the consumer starts. +- Events produced by the BPF programs include the EDR agent's own process events (the agent will observe itself execing). This is not filtered here — filtering is downstream in the isolation table and rule engine. + +--- + +## Issue: Wire the ebpf-collector event consumer into agent-core/orchestrator.rs alongside the existing osquery pipeline +**Labels:** `ebpf`, `async`, `tracing` +**Depends on:** Event consumer issue +**Blocks:** eBPF integration test issue + +### What this is +The `agent-core/orchestrator.rs` currently runs a complete osquery pipeline: `OsqueryCollector::start()` → `mpsc::Receiver` → `EventBuffer::push()`. The eBPF pipeline must be integrated in parallel: `EventConsumer::start()` → `mpsc::Receiver` → `EventBuffer::push()`. This issue adds the eBPF consumer as a concurrent task in the main orchestrator loop, guarded by a compile-time `#[cfg(target_os = "linux")]` block so the agent still compiles on macOS. + +### What is currently blocking this +The event consumer issue must be complete (the `EventConsumer::start()` API must exist and return an `mpsc::Receiver`). + +### What this is blocking +The eBPF integration test (which validates the full path from kernel probe to buffer). + +### Implementation tasks +- [ ] In `agent/crates/agent-core/src/orchestrator.rs`, add a `#[cfg(target_os = "linux")]` block after the `OsqueryCollector` startup that calls `ebpf_collector::EventConsumer::start(EbpfLoader::load()?, node_id.clone())`. This yields a `(EventConsumer, Receiver)` tuple. +- [ ] Add `ebpf-collector = { path = "../../crates/ebpf-collector" }` to `agent/crates/agent-core/Cargo.toml` under a `[target.'cfg(target_os = "linux")'.dependencies]` section. +- [ ] Spawn the `EventConsumer::run_until_shutdown(consumer, cancellation_token.clone())` call inside a `tokio::task::spawn_blocking` or a dedicated `LocalSet`-driven thread (see loader notes on non-`Send` constraint). +- [ ] Add the eBPF `AgentEvent` receiver to the main `tokio::select!` loop alongside the existing `results_rx.recv()` arm: when an `AgentEvent` arrives from the eBPF channel, encode it to bytes via `AgentEvent::encode_to_vec()` and call `buffer.push(&bytes)`. +- [ ] Handle the case where `EbpfLoader::load()` fails (e.g., insufficient capabilities, kernel too old, BTF not available): log `tracing::warn!("eBPF collector failed to load: {}. Continuing in OSQuery-only mode.", e)` and skip the eBPF pipeline. Do not abort agent startup. +- [ ] Thread the `CancellationToken` through the shutdown path so the eBPF consumer is stopped before the process exits. +- [ ] Update the existing `test_orchestrator_startup` integration test (in `tests/TEST_PLAN.md` it is listed as `agent-core` integration test) to assert that a Linux agent starts with the eBPF consumer active, and a degraded-mode test that asserts startup succeeds even when the eBPF loader returns an error. + +### Definition of done +- `cargo build -p agent-bin --target x86_64-unknown-linux-gnu` succeeds with eBPF integration enabled. +- On a Linux host with sufficient capabilities: the agent logs `"eBPF collector started"` on startup. +- On a host without sufficient capabilities (or on macOS during development): the agent logs the degraded-mode warning and continues with osquery only. +- The shutdown path stops the eBPF consumer cleanly (no log errors on exit). + +### Notes / constraints +- The `EventBuffer` (SQLite via `rusqlite`) is `!Send`. The eBPF consumer receiver's `AgentEvent`s must be forwarded to the buffer on the same thread that owns it (the main orchestrator task). The `select!` loop in orchestrator already handles this correctly for osquery — extend it the same way. +- `EbpfLoader` is also `!Send` and must live on the same thread as the consumer. If using `spawn_blocking`, pass ownership of the loader into the closure before spawning. + +--- + +## Issue: eBPF error handling and graceful degradation when probe attachment fails +**Labels:** `ebpf`, `error-handling`, `kernel` +**Depends on:** Wire eBPF into orchestrator issue +**Blocks:** eBPF integration test issue + +### What this is +This issue hardens the eBPF subsystem against runtime failures: partial probe attachment failures (process probe loads but network probe fails), ring buffer exhaustion, and consumer task panic. The current `CollectorError` enum from the build pipeline issue has the variants but no structured handling. This issue adds the handling logic and the graceful fallback strategy. + +### What is currently blocking this +The orchestrator integration must be in place so there is a running system to harden. + +### What this is blocking +The eBPF integration test (which validates the degraded-mode behavior). + +### Implementation tasks +- [ ] In `loader.rs`, change `EbpfLoader::load()` to return a `LoadResult { loader: EbpfLoader, warnings: Vec }` struct instead of bare `Result`. If a probe fails to attach (e.g., the tracepoint doesn't exist on this kernel), push the error into `warnings` and continue loading remaining probes. A partial load is better than no telemetry. +- [ ] Implement `fn attach_with_warn(bpf: &mut Ebpf, program_name: &str, category: &str, tracepoint: &str, warnings: &mut Vec) -> bool` that attempts attachment and on failure pushes a human-readable message into warnings and returns `false`. +- [ ] In `orchestrator.rs`, log each `LoadResult.warning` at `tracing::warn!` level with a prefix of `"[ebpf][degraded]"`. +- [ ] Handle ring buffer exhaustion: if `RingBuf::next()` returns data but deserialization consistently fails for more than 100 consecutive events, emit a `tracing::error!` and pause polling for 5 seconds before resuming (back-pressure heuristic). +- [ ] Wrap the entire `EventConsumer::run_until_shutdown` call in `spawn_blocking` with a `catch_unwind` equivalent (use `std::panic::catch_unwind` inside the blocking closure). If the consumer panics, log the panic message and do not crash the agent — restart the consumer loop after a 10-second delay. +- [ ] Add a metric counter (or at minimum a `tracing::info!` log) for `events_received_from_ebpf` and `events_dropped_from_ebpf` that increments in the consumer loop. This feeds future observability. +- [ ] Write a unit test `test_partial_load_continues_on_attachment_failure` that mocks the aya attach call returning an error and asserts `warnings` is non-empty while `loader.process_ring_buf()` still works. + +### Definition of done +- When the process probe loads but the network probe fails: the agent starts, logs a degraded warning, and continues streaming process events. +- When the entire BPF load fails: the agent starts in osquery-only mode with no errors beyond the degraded warning. +- A consumer panic does not crash the agent process. +- `cargo test -p ebpf-collector` passes the partial-load test. + +### Notes / constraints +- Do not retry failed probe attachment on a loop — the failure is usually structural (capability missing, kernel too old). Log once and accept degraded mode. +- Ring buffer exhaustion (events dropped by the kernel) appears as gaps in sequence IDs. The kernel tracks drops internally; aya exposes `RingBuf::dropped_events()` if available. Check aya 0.13 API for this method. + +--- + +## Issue: eBPF integration test: load probes in a controlled environment and verify end-to-end event emission +**Labels:** `ebpf`, `testing`, `kernel` +**Depends on:** eBPF error handling issue; Wire eBPF into orchestrator issue +**Blocks:** nothing (leaf node in workstream) + +### What this is +This issue implements the integration tests for the entire eBPF pipeline, covering: successful probe load and attachment, event emission verification (exec a known subprocess → observe `ProcessEvent` on the ring buffer), network event capture (make a loopback TCP connection → observe `NetworkEvent`), and degraded-mode startup. These tests require a real Linux kernel and run in CI on `ubuntu-latest` (GitHub Actions runner) as a job that includes `sudo` access for `CAP_BPF`. + +### What is currently blocking this +All prior eBPF issues must be complete. The test exercises the full stack. + +### What this is blocking +Nothing — this is the leaf node of the eBPF workstream. + +### Implementation tasks +- [ ] Create `agent/crates/ebpf-collector/tests/integration_test.rs`. Gate the entire file with `#[cfg(target_os = "linux")]` and a custom feature flag `ebpf_integration_tests` (controlled by `CARGO_FEATURE_EBPF_INTEGRATION_TESTS` env var) to prevent these from running in normal `cargo test` invocations. +- [ ] Implement `test_loader_loads_and_attaches`: calls `EbpfLoader::load()`, asserts `Ok`, calls `is_btf_available()` and skips if false, asserts ring buffer handles are accessible. +- [ ] Implement `test_process_probe_captures_execve`: after loading, `std::process::Command::new("/bin/true").spawn().wait()`, then poll the ring buffer for up to 500ms expecting at least one `ProcessEvent` with `comm == b"true\0...\0"` (null-padded). Assert `pid != 0` and `ppid == current PID`. +- [ ] Implement `test_network_probe_captures_connect`: spawn a `tokio::net::TcpListener` on `127.0.0.1:0`, get the bound port, then `TcpStream::connect` to it. Poll the network ring buffer for up to 500ms expecting a `NetworkEvent` with `dst_port == bound_port` and `direction == 1` (outbound). +- [ ] Implement `test_probe_detach_removes_program`: load probes, call `detach()`, then verify with `bpftool prog list` via `std::process::Command` that the program name is no longer listed. +- [ ] Implement `test_degraded_mode_no_cap_bpf`: run as unprivileged user (or drop caps in the test), call `EbpfLoader::load()`, assert `Err(CollectorError::BpfLoadError(_))`, and assert the agent-core degraded-mode path does not panic. +- [ ] Add a `[[test]]` entry in `ebpf-collector/Cargo.toml` for the integration test file with `required-features = ["ebpf_integration_tests"]`. +- [ ] Add a `.github/workflows/ebpf-integration.yml` workflow (or note in the existing CI template) that runs `cargo test -p ebpf-collector --features ebpf_integration_tests` on `ubuntu-latest` with `sudo` or using the `BPF_CAP` action. + +### Definition of done +- All four integration tests pass on a GitHub Actions `ubuntu-latest` runner (kernel 6.x, BTF enabled). +- `test_degraded_mode_no_cap_bpf` passes without root — it should return a `BpfLoadError`, not panic. +- Tests are gated by feature flag and do not run in a normal `cargo test --workspace` invocation. +- CI job is defined and runs on PRs targeting `main`. + +### Notes / constraints +- GitHub Actions `ubuntu-latest` (as of 2025) runs kernel 6.5+, which has BTF enabled. The tests can rely on BTF being present. +- `CAP_BPF` requires either `sudo` in CI or setting up a test runner with the capability. Using `sudo -E cargo test` in the CI step is the simplest approach. +- The `test_probe_detach_removes_program` test requires `bpftool` to be installed on the runner. Add `sudo apt-get install -y bpftool` as a CI step. +- Flakiness risk: ring buffer polling has a 500ms window. If the CI runner is extremely loaded, events may arrive after the timeout. Use `tokio::time::timeout` and mark the test as `#[ignore]` with a note to run on dedicated hardware if it becomes flaky in practice. + +--- + +## Issue: Define the connection isolation table data structure and its key/value types +**Labels:** `networking`, `tables`, `firewall` +**Depends on:** none +**Blocks:** Isolation table population issue; enforcement issue; concurrency issue + +### What this is +The `agent/crates/isolation/src/lib.rs` is a one-line comment stub. The entire isolation crate needs to be built from scratch. This issue defines the foundational data structure: what the allow-list table is, what its key is, and what it contains. Based on the implementation guide, the isolation model is iptables-based: the `IsolateCommand` from the fleet server triggers iptables rules that drop all traffic except to the fleet server IP. The "table" in this workstream is the in-memory Rust data structure that tracks which connections are registered as allowed (belonging to the EDR process) so that the iptables rule-generation logic knows which addresses to preserve when isolation is applied. + +### What is currently blocking this +Nothing — this is the root issue of Workstream B. + +### What this is blocking +Everything else in Workstream B. The population and enforcement issues depend on the table types defined here. + +### Implementation tasks +- [ ] In `agent/crates/isolation/src/lib.rs`, define the module skeleton: `pub mod table; pub mod iptables; pub mod error;`. +- [ ] Create `agent/crates/isolation/src/error.rs`. Define `IsolationError` using `thiserror`: variants `IptablesExecFailed { exit_code: i32, stderr: String }`, `IptablesNotFound`, `InvalidAddress(String)`, `TableLockTimeout`. Add to `isolation/Cargo.toml` dependencies: `thiserror = { workspace = true }`. +- [ ] Create `agent/crates/isolation/src/table.rs`. Define `pub struct ConnectionKey { pub remote_ip: std::net::IpAddr, pub remote_port: u16, pub protocol: Protocol }` where `Protocol` is `pub enum Protocol { Tcp, Udp }`. The key is the remote endpoint — the "allowed" destination from the EDR's perspective. +- [ ] Define `pub struct ConnectionEntry { pub key: ConnectionKey, pub registered_at: std::time::Instant, pub description: &'static str }` — the value in the table. `description` is a static label like `"fleet-server"` or `"osquery-socket"`. +- [ ] Define `pub struct IsolationTable` as a struct wrapping `Vec` (not a HashMap — the table is expected to have ≤10 entries: the fleet server address, optionally a few internal addresses). Linear scan is acceptable and avoids the complexity of a hash map with a non-trivial key type. +- [ ] Implement `IsolationTable::new() -> Self`, `register(&mut self, key: ConnectionKey, description: &'static str)`, `deregister(&mut self, key: &ConnectionKey)`, `allowed_remotes(&self) -> Vec<&ConnectionKey>`. +- [ ] Add `serde` derives to `ConnectionKey` and `Protocol` for persistence (see the persistence issue below). Use `#[serde(rename_all = "snake_case")]` on `Protocol`. +- [ ] Write unit tests: `test_register_and_lookup`, `test_deregister_removes_entry`, `test_allowed_remotes_returns_all`, `test_empty_table_allowed_remotes_is_empty`. Place in a `#[cfg(test)]` module inside `table.rs`. + +### Definition of done +- `cargo test -p isolation` passes all four unit tests. +- `IsolationTable`, `ConnectionKey`, `Protocol`, `ConnectionEntry`, and `IsolationError` are all defined and exported from `isolation::table` and `isolation::error`. +- No external crates beyond `thiserror` and `serde` are added (no async, no lock primitives — this issue is synchronous data structures only). + +### Notes / constraints +- The isolation table is fundamentally different from eBPF maps. It is a userspace Rust data structure, not a kernel construct. The name "table" in the workstream name refers to this in-memory allow-list, not to a BPF map. +- `std::net::IpAddr` handles both IPv4 and IPv6. Use it instead of a custom type. +- `description` is `&'static str` not `String` to keep the entry allocation-free. All call sites use string literals. + +--- + +## Issue: Implement isolation table population: register EDR-owned connections as allowed +**Labels:** `networking`, `tables`, `firewall`, `ipc` +**Depends on:** Isolation table data structure issue +**Blocks:** Enforcement issue; concurrency issue + +### What this is +The isolation table needs to be pre-populated with the connections the EDR process itself makes — primarily the gRPC connection to the fleet server. When isolation is applied, these connections must remain reachable (isolation means "block everything except EDR comms"). This issue implements the population path: the `IsolationTable::register()` calls that happen at agent startup and whenever a new EDR connection is established. + +### What is currently blocking this +The table data structure issue must be complete. + +### What this is blocking +Enforcement: the iptables rule generator reads from the populated table. Concurrency: the locking wrapper is designed around the usage patterns established here. + +### Implementation tasks +- [ ] In `agent/crates/isolation/src/table.rs`, implement `IsolationTable::register_fleet_server(endpoint: &str) -> Result<(), IsolationError>` that parses the gRPC endpoint URL (e.g., `"http://fleet.internal:50051"`) into a `ConnectionKey` (resolve hostname to IP using `std::net::ToSocketAddrs`, extract port, set `protocol = Tcp`). Handle DNS resolution failure with `IsolationError::InvalidAddress`. +- [ ] Add `IsolationTable::register_osquery_socket(_path: &Path)` — a no-op stub that documents the intent (osquery uses a Unix domain socket, which is not filtered by iptables IP rules; this entry is for documentation and future netfilter-socket-level filtering). +- [ ] In `agent/crates/agent-core/src/orchestrator.rs`, after the `FleetClient::enroll()` succeeds and the fleet server IP is known, call `table.register_fleet_server(&config.fleet.endpoint)`. The `IsolationTable` instance should be created at the start of `orchestrator::run()` and passed to any component that needs it. +- [ ] Wire the `IsolationTable` into the `FleetClient`'s reconnect path: on each successful reconnect, re-register the fleet server (its IP may have changed via DNS). Implement `update_fleet_server(table: &mut IsolationTable, endpoint: &str)` that calls `deregister` on the old key then `register` on the new one. +- [ ] Add `agent/crates/isolation` to the `agent-core/Cargo.toml` dependencies (it is not listed yet). +- [ ] Write unit tests: `test_register_fleet_server_parses_http_endpoint`, `test_register_fleet_server_parses_https_endpoint`, `test_register_fleet_server_invalid_url_returns_error`, `test_update_fleet_server_replaces_old_entry`. + +### Definition of done +- `IsolationTable::register_fleet_server("http://fleet.internal:50051")` resolves and registers the entry correctly (tested with a real DNS lookup in unit tests using `127.0.0.1` as a known-resolving address). +- The orchestrator creates an `IsolationTable` and registers the fleet server on successful enrollment. +- `cargo test -p isolation` and `cargo test -p agent-core` pass. +- The `isolation` crate is a dependency of `agent-core`. + +### Notes / constraints +- Hostname resolution in `register_fleet_server` is synchronous (`ToSocketAddrs` is blocking). Since orchestrator startup is async (tokio), call this in `tokio::task::spawn_blocking` or use `tokio::net::lookup_host` instead. Prefer `tokio::net::lookup_host` to stay async. +- The table at this point has no locking. The population issue establishes the write path; the concurrency issue (next) adds locking. For now, `IsolationTable` is assumed to be owned and mutated by a single task. +- Do not attempt to register non-IP connections (unix sockets, abstract namespace). Document the scope limitation in a comment. + +--- + +## Issue: Add concurrency wrapper around IsolationTable for hot-path read and control-path write +**Labels:** `networking`, `tables`, `firewall`, `async` +**Depends on:** Isolation table population issue +**Blocks:** Enforcement issue; integration test + +### What this is +The `IsolationTable` will be read from a hot path (the enforcement point checks it on every iptables rule generation, which happens at isolation time) and written from a control path (enrollment, reconnect, and the `IsolateCommand` handler). This issue wraps `IsolationTable` in the appropriate synchronization primitive and defines the shared handle type used by all consumers. + +### What is currently blocking this +The population issue must exist to know the write patterns before choosing a locking strategy. + +### What this is blocking +The enforcement issue, which takes the shared handle and reads from it. The integration test. + +### Implementation tasks +- [ ] Decide on the concurrency primitive: the table is written at most once per reconnect cycle (very low frequency) and read at isolation time (also infrequent — isolation is an operator action). A `std::sync::RwLock` wrapped in `Arc` is correct here. There is no hot-path lookup that would justify a lock-free approach. Document this rationale in a comment in `table.rs`. +- [ ] Define `pub type SharedIsolationTable = Arc>` in `isolation/src/table.rs`. Export it from `isolation/src/lib.rs`. +- [ ] Implement `SharedIsolationTable::new_shared() -> Self` as a constructor shortcut: `Arc::new(RwLock::new(IsolationTable::new()))`. +- [ ] Add `impl IsolationTable { pub fn into_shared(self) -> SharedIsolationTable { Arc::new(RwLock::new(self)) } }`. +- [ ] In `agent/crates/isolation/src/table.rs`, implement convenience methods on `SharedIsolationTable` (via a newtype or inherent methods on a wrapper struct): `register_fleet_server_shared(&self, endpoint: &str) -> Result<(), IsolationError>` that acquires the write lock, calls `register_fleet_server`, and releases. Same pattern for `deregister` and `allowed_remotes`. +- [ ] Update `agent-core/orchestrator.rs` to construct a `SharedIsolationTable` at startup and clone the `Arc` into any component that needs read access (the iptables enforcement module in the next issue). +- [ ] Write unit tests: `test_concurrent_register_and_read` using `std::thread::spawn` to simulate concurrent writer and reader, asserting no data races (this test also validates the `RwLock` usage). `test_shared_table_clone_sees_updates` asserts that a cloned `Arc` reflects writes made through the original. + +### Definition of done +- `SharedIsolationTable` is defined and exported. +- `register_fleet_server_shared` and `allowed_remotes` (via read lock) compile and are tested. +- Two threads concurrently accessing the table via `SharedIsolationTable` do not deadlock or panic in the unit test. +- `cargo test -p isolation` passes. + +### Notes / constraints +- `std::sync::RwLock` (not `tokio::sync::RwLock`) is appropriate here because the lock is never held across an `.await` point. The critical section in `register_fleet_server` includes a DNS lookup — move the DNS resolution outside the lock before acquiring it. Take the write lock only to mutate the `Vec`. +- `Arc>` is `Send + Sync`. Cloning the `Arc` is cheap (one atomic increment). +- Do not use `Mutex` — the read path (allowed_remotes during rule generation) does not mutate state and should allow concurrent readers. + +--- + +## Issue: Implement iptables-based enforcement: generate and apply rules from the isolation table on IsolateCommand +**Labels:** `networking`, `firewall`, `ipc`, `unsafe` +**Depends on:** Concurrency wrapper issue +**Blocks:** Isolation integration test issue + +### What this is +This issue implements the actual isolation enforcement: `agent/crates/isolation/src/iptables.rs`. When the agent receives an `IsolateCommand { isolate: true }` from the fleet server, it applies iptables rules that drop all traffic except to endpoints registered in the `IsolationTable`. When it receives `IsolateCommand { isolate: false }`, it removes those rules. The implementation uses `std::process::Command` to invoke `iptables` (no external crate needed per the existing `Cargo.toml` comment). + +### What is currently blocking this +The `SharedIsolationTable` from the concurrency issue must be available to read the allow-list. + +### What this is blocking +The isolation integration test. + +### Implementation tasks +- [ ] Create `agent/crates/isolation/src/iptables.rs`. Define `pub struct IptablesIsolator { table: SharedIsolationTable }`. +- [ ] Implement `IptablesIsolator::isolate(&self) -> Result<(), IsolationError>`. The rule set: (1) create a new chain `EDR_ISOLATION` if it doesn't exist; (2) flush it (`iptables -F EDR_ISOLATION`); (3) for each `ConnectionKey` in `table.read().allowed_remotes()`, append `iptables -A EDR_ISOLATION -d -p --dport -j ACCEPT`; (4) append a default drop: `iptables -A EDR_ISOLATION -j DROP`; (5) if the `OUTPUT` chain does not already reference `EDR_ISOLATION`, append `-A OUTPUT -j EDR_ISOLATION`. +- [ ] Implement `IptablesIsolator::deisolate(&self) -> Result<(), IsolationError>`. Removes the jump from `OUTPUT`: `iptables -D OUTPUT -j EDR_ISOLATION`. Then flushes and deletes the chain: `iptables -F EDR_ISOLATION && iptables -X EDR_ISOLATION`. +- [ ] Implement `fn run_iptables(args: &[&str]) -> Result<(), IsolationError>` that executes `iptables` via `Command::new("iptables").args(args).output()`. If `status.success()` is false, return `IsolationError::IptablesExecFailed { exit_code: status.code().unwrap_or(-1), stderr: String::from_utf8_lossy(&output.stderr).into_owned() }`. If iptables is not found on PATH, return `IsolationError::IptablesNotFound`. +- [ ] Implement `IptablesIsolator::is_isolated(&self) -> Result` by running `iptables -L OUTPUT -n | grep EDR_ISOLATION`. Returns `true` if the chain is referenced in OUTPUT. +- [ ] In `agent/crates/agent-core/src/orchestrator.rs`, handle `ServerCommand::Isolate(cmd)` in the main loop: if `cmd.isolate == true`, call `IptablesIsolator::new(shared_table.clone()).isolate()`; if false, call `deisolate()`. Log the outcome. Update the agent's status to `AgentStatus::Isolated` or `AgentStatus::Healthy` accordingly. +- [ ] Write unit tests for `run_iptables` using a mock: define a trait `IptablesRunner` and use it in `run_iptables` to enable injection of a mock in tests. Assert that `isolate()` generates the expected iptables command arguments. + +### Definition of done +- `IptablesIsolator::isolate()` and `deisolate()` compile. +- Unit tests for argument generation pass with the mock runner. +- On a Linux host with root, calling `isolate()` followed by `iptables -L OUTPUT -n` shows the `EDR_ISOLATION` chain jump. Calling `deisolate()` removes it. +- The orchestrator handles `IsolateCommand` from the gRPC stream and calls the appropriate isolator method. + +### Notes / constraints +- `iptables` requires root. The agent binary must run as root or with `CAP_NET_ADMIN`. This is expected in production (same capability needed for eBPF); document it. +- The `EDR_ISOLATION` chain must be flushed before re-adding rules (idempotent isolate). If `isolate()` is called twice, the second call must not duplicate rules. +- `iptables -N EDR_ISOLATION` fails if the chain already exists (exit code 1). Treat this exit code specifically as `Ok(())` (chain already created). Check `stderr` for the specific message `"Chain already exists"`. +- IPv6 traffic requires `ip6tables`. This issue covers IPv4 only. Extend to IPv6in a follow-on. + +--- + +## Issue: Isolation table integration test: register a connection, apply isolation, verify non-EDR traffic is blocked +**Labels:** `networking`, `firewall`, `testing` +**Depends on:** Enforcement issue +**Blocks:** nothing (leaf node in workstream) + +### What this is +This issue implements the integration tests for the complete isolation pipeline: create an `IsolationTable`, register a known endpoint, apply iptables rules, verify that an outbound connection to the registered endpoint succeeds, verify that a connection to a different endpoint fails. This test requires root and a real Linux network stack. It also tests the wiring between the isolation crate and the `agent-core` orchestrator's `IsolateCommand` handler. + +### What is currently blocking this +The enforcement issue must be complete. + +### What this is blocking +Nothing — leaf node. + +### Implementation tasks +- [ ] Create `agent/crates/isolation/tests/integration_test.rs`. Gate the file with a feature flag `isolation_integration_tests` and `#[cfg(target_os = "linux")]`. +- [ ] Implement `test_isolation_blocks_non_allowed_traffic`: (1) create `SharedIsolationTable`, (2) register `127.0.0.1:9999` as the allowed endpoint, (3) call `isolate()`, (4) spawn a `TcpListener` on `127.0.0.1:9998` (not in the allow-list), (5) attempt `TcpStream::connect("127.0.0.1:9998")` — assert connection times out or is refused, (6) call `deisolate()`, (7) assert `TcpStream::connect("127.0.0.1:9998")` now succeeds. +- [ ] Implement `test_isolation_allows_registered_endpoint`: same setup, attempt connection to `127.0.0.1:9999` — assert it succeeds while isolated. +- [ ] Implement `test_idempotent_isolate`: call `isolate()` twice; run `is_isolated()` — assert `true`. Then `deisolate()` — assert `is_isolated()` returns `false`. Verify `iptables -L OUTPUT -n` does not contain duplicate `EDR_ISOLATION` references. +- [ ] Implement `test_deisolation_restores_connectivity`: full cycle. After `deisolate()`, attempt connections to multiple ports — all should succeed. +- [ ] Add `[[test]]` section in `isolation/Cargo.toml` for the integration tests with `required-features = ["isolation_integration_tests"]`. +- [ ] Add a CI job step (in the same or a separate workflow from the eBPF CI) that runs these tests with `sudo -E cargo test -p isolation --features isolation_integration_tests`. + +### Definition of done +- All four integration tests pass on a Linux host with root access and `iptables` available. +- Tests are gated by feature flag. +- CI workflow runs them in an `ubuntu-latest` environment. +- After each test, `iptables -L` shows no `EDR_ISOLATION` chain (cleanup is deterministic, using `Drop` or explicit teardown in test). + +### Notes / constraints +- Connecting to `127.0.0.1` with iptables `OUTPUT` chain rules: iptables by default does apply `OUTPUT` chain rules to loopback on Linux. Verify this assumption with `sysctl net.ipv4.conf.lo.accept_local`. If loopback is exempt, use a secondary interface (e.g., a dummy interface created in the test setup). +- Use `tokio::time::timeout(Duration::from_millis(500), TcpStream::connect(...))` to detect blocked connections quickly without waiting for the OS TCP timeout. +- Ensure test cleanup (call `deisolate()` in a `Drop` guard or use `scopeguard`) to avoid leaving isolation rules active if a test panics. + +--- + +## Issue: Create the fleet-server crate structure, async runtime, and binary entry point +**Labels:** `fleet-server`, `scaffolding`, `async`, `config` +**Depends on:** none +**Blocks:** gRPC server stub issue; config system issue; error type issue; logging issue; health endpoint issue + +### What this is +`fleet-server/src/main.rs` currently contains `fn main() { println!("edr-fleet-server"); }`. The crate has all its dependencies declared in `Cargo.toml` (tokio, axum, tonic, sqlx, etc.) and a `Dockerfile` and one migration file. This issue transforms it into a real binary: Tokio runtime initialization with the correct feature flags, a structured `main.rs` that initializes subsystems in order, and the module layout that the subsequent issues will fill in. No business logic. No gRPC server implementation. No database queries. The binary must compile, start, and accept a graceful shutdown signal. + +### What is currently blocking this +Nothing — root issue of Workstream C. + +### What this is blocking +All other fleet-server issues depend on the module skeleton this issue creates. + +### Implementation tasks +- [ ] Replace `fleet-server/src/main.rs` entirely. Use `#[tokio::main]` with the `full` feature (already in workspace `Cargo.toml`). Structure `main` as: (1) call `config::load()`, (2) call `tracing_setup::init(&cfg)`, (3) call `error::setup()` if needed, (4) build and run `server::run(cfg).await`. Return `Result<(), ServerError>`. +- [ ] Create the module files as stubs (each containing `// TODO` and correct `pub` declarations): `src/config.rs`, `src/error.rs`, `src/state.rs`, `src/server.rs`, `src/grpc/mod.rs`, `src/grpc/server.rs`, `src/db/mod.rs`, `src/http/mod.rs`, `src/http/health.rs`. +- [ ] In `src/main.rs`, add `mod config; mod error; mod state; mod server; mod grpc; mod db; mod http;` with the appropriate `pub use` re-exports. +- [ ] `src/server.rs`: define `pub async fn run(config: Config) -> Result<(), ServerError>` as a stub that prints `"Fleet server starting..."`, sleeps for 100ms, and returns `Ok(())`. This will be replaced in subsequent issues but must compile. +- [ ] Verify `cargo build -p edr-fleet-server` succeeds and `cargo run -p edr-fleet-server` prints `"Fleet server starting..."` and exits cleanly. +- [ ] Write a smoke test `test_main_returns_ok` in `src/main.rs` under `#[cfg(test)]` that calls `server::run(Config::default())` in a tokio test runtime and asserts `Ok(())`. + +### Definition of done +- `cargo build -p edr-fleet-server` succeeds. +- `cargo run -p edr-fleet-server` starts, prints startup message, exits with code 0. +- All module stubs exist and are referenced in `main.rs`. +- `cargo test -p edr-fleet-server` passes (smoke test). + +### Notes / constraints +- `tokio = { version = "1", features = ["full"] }` is already in workspace dependencies. Do not add redundant feature flags. +- The async runtime is tokio. The HTTP framework is axum. The gRPC framework is tonic. All are already in `Cargo.toml`. This issue does not change dependencies, only creates the structural skeleton. +- Do not add `actix-web` or any other async runtime. The architecture decision (axum + tokio) is final per the implementation guide. + +--- + +## Issue: Implement the fleet-server configuration system +**Labels:** `fleet-server`, `config`, `scaffolding` +**Depends on:** Fleet-server crate structure issue +**Blocks:** Health endpoint issue; logging issue; gRPC server stub issue; smoke test issue + +### What this is +The fleet server needs to read its configuration at startup: bind addresses for the gRPC and HTTP servers, PostgreSQL connection URL, Kafka broker addresses, JWT secret, and log level. The `config` crate is already declared in the workspace `Cargo.toml` at version `0.15`. This issue implements `fleet-server/src/config.rs` using `config = "0.15"` with a layered source: defaults → environment variables → optional config file. No secrets in code. All config fields must be readable from environment variables with the prefix `EDR_FLEET_`. + +### What is currently blocking this +The crate structure issue (module stubs must exist). + +### What this is blocking +The health endpoint (needs bind address), logging init (needs log level), the gRPC server stub (needs gRPC bind address and JWT secret), and the smoke test (needs `Config::default()`). + +### Implementation tasks +- [ ] In `fleet-server/src/config.rs`, define `#[derive(Debug, Clone, serde::Deserialize)] pub struct Config` with fields: `pub grpc_bind: String` (default `"0.0.0.0:50051"`), `pub http_bind: String` (default `"0.0.0.0:8080"`), `pub database_url: String` (no default — required), `pub kafka_brokers: String` (default `"localhost:9092"`), `pub jwt_secret: String` (no default — required), `pub log_level: String` (default `"info"`), `pub log_format: LogFormat` where `LogFormat` is `#[derive(Debug, Clone, serde::Deserialize)] pub enum LogFormat { Human, Json }`. +- [ ] Implement `pub fn load() -> Result` using `config::Config::builder().add_source(config::Environment::with_prefix("EDR_FLEET")).build()?.try_deserialize()`. Use `thiserror` for `ConfigError`: variants `LoadFailed(#[from] config::ConfigError)`. +- [ ] Implement `Config::default()` manually (not via `derive`) returning the documented defaults with `database_url` and `jwt_secret` set to `"test_placeholder"` — this is only used in tests and the stub `server::run` stub. +- [ ] Add a validation step: `Config::validate(&self) -> Result<(), ConfigError>` that returns `Err` if `jwt_secret.len() < 32` (enforce minimum key length) or if `database_url` is the test placeholder in a non-test build. Use `#[cfg(not(test))]` guard. +- [ ] Write unit tests: `test_config_loads_from_env` (set `EDR_FLEET_GRPC_BIND`, `EDR_FLEET_HTTP_BIND`, `EDR_FLEET_JWT_SECRET`, etc. via `std::env::set_var`, call `load()`, assert fields match), `test_config_default_grpc_port`, `test_config_jwt_secret_too_short_fails_validation`. + +### Definition of done +- `config::load()` reads from environment variables with `EDR_FLEET_` prefix. +- `Config::default()` compiles and is used by the existing smoke test stub. +- `Config::validate()` rejects a JWT secret shorter than 32 characters in non-test builds. +- `cargo test -p edr-fleet-server` passes config unit tests. +- No secrets are hardcoded — all defaults that would be credentials are test-only. + +### Notes / constraints +- `config = "0.15"` uses `serde` for deserialization. The `Environment` source converts `EDR_FLEET_GRPC_BIND` to the field name `grpc_bind` by lowercasing and stripping the prefix. Verify this behavior with the unit test before finalizing. +- Do not use `dotenv` — it is not in the workspace `Cargo.toml`. Environment variables are the only config source beyond defaults. +- `LogFormat` is defined in `config.rs` and re-exported. The tracing setup issue imports it from here. + +--- + +## Issue: Implement the fleet-server error type hierarchy +**Labels:** `fleet-server`, `error-handling`, `scaffolding` +**Depends on:** Fleet-server crate structure issue +**Blocks:** gRPC server stub issue; health endpoint issue; smoke test issue + +### What this is +The fleet server needs a structured error type hierarchy that is ready to be extended as business logic is added. This issue implements `fleet-server/src/error.rs` with a top-level `ServerError` and a set of domain-specific sub-errors. All types use `thiserror`. The design must anticipate gRPC errors, database errors, JWT errors, and Kafka errors without implementing any of those subsystems yet. + +### What is currently blocking this +The crate structure issue. + +### What this is blocking +The gRPC server stub (which returns `Result<_, ServerError>`), the health endpoint (same), and the smoke test. + +### Implementation tasks +- [ ] In `fleet-server/src/error.rs`, define `#[derive(Debug, thiserror::Error)] pub enum ServerError` with variants: `#[error("configuration error: {0}")] Config(#[from] crate::config::ConfigError)`, `#[error("database error: {0}")] Database(#[from] sqlx::Error)`, `#[error("gRPC error: {0}")] Grpc(#[from] tonic::Status)`, `#[error("JWT error: {0}")] Jwt(String)`, `#[error("Kafka error: {0}")] Kafka(String)`, `#[error("IO error: {0}")] Io(#[from] std::io::Error)`. +- [ ] Define `#[derive(Debug, thiserror::Error)] pub enum DbError` with variants: `#[error("node not found: {node_id}")] NodeNotFound { node_id: uuid::Uuid }`, `#[error("duplicate enrollment: machine_id={machine_id}")] DuplicateEnrollment { machine_id: String }`, `#[error("sqlx: {0}")] Sqlx(#[from] sqlx::Error)`. +- [ ] Define `#[derive(Debug, thiserror::Error)] pub enum GrpcError` with variants: `#[error("unauthenticated")] Unauthenticated`, `#[error("node not enrolled")] NotEnrolled`, `#[error("stream closed")] StreamClosed`. +- [ ] Implement `From for tonic::Status`: `Unauthenticated` → `Status::unauthenticated(msg)`, `NotEnrolled` → `Status::not_found(msg)`, `StreamClosed` → `Status::cancelled(msg)`. +- [ ] Re-export `ServerError`, `DbError`, `GrpcError` from `fleet-server/src/error.rs` and add `pub use error::{ServerError, DbError, GrpcError};` to `src/lib.rs` if a lib target is added, or use path imports in `main.rs`. +- [ ] Write unit tests: `test_db_error_display_node_not_found`, `test_grpc_error_converts_to_tonic_status_unauthenticated`, `test_server_error_from_io_error`. + +### Definition of done +- `cargo test -p edr-fleet-server` passes all error type unit tests. +- `ServerError`, `DbError`, and `GrpcError` are defined and compile with `thiserror`. +- `From for tonic::Status` is implemented and tested. +- The hierarchy is extensible: adding a new variant to any error enum requires no changes outside that enum. + +### Notes / constraints +- `thiserror = "2"` is in the workspace (note: version 2, not 1). Use `{ workspace = true }`. +- `tonic::Status` has constructor methods like `Status::unauthenticated(message: impl Into)`. Use these — do not construct the struct directly. +- Keep `Kafka(String)` as a `String`-wrapping variant for now because `rdkafka` is commented out in the workspace `Cargo.toml`. When rdkafka is enabled, replace it with a proper `From` impl. + +--- + +## Issue: Implement structured logging and tracing setup for the fleet-server +**Labels:** `fleet-server`, `tracing`, `scaffolding` +**Depends on:** Configuration system issue +**Blocks:** Health endpoint issue; gRPC server stub issue; smoke test issue + +### What this is +The fleet server must emit structured JSON logs in production (for log aggregation) and human-readable logs in development. `tracing` and `tracing-subscriber` are already in the workspace. This issue implements a thin initialization shim in `fleet-server/src/` that reads `Config.log_level` and `Config.log_format`, builds the appropriate `tracing_subscriber` stack, and installs it as the global default. It must run before any `tracing::info!` calls and must not panic if called twice (idempotent for test use). + +### What is currently blocking this +The config system must exist so `LogFormat` and `log_level` are defined. + +### What this is blocking +Everything that emits logs (health endpoint, gRPC stub, smoke test). + +### Implementation tasks +- [ ] Create `fleet-server/src/tracing_setup.rs` (avoid naming it `tracing.rs` to prevent shadowing the `tracing` crate). Define `pub fn init(config: &Config) -> Result<(), ServerError>`. +- [ ] In `init`, build the `EnvFilter` from `config.log_level` using `EnvFilter::try_new(&config.log_level).unwrap_or_else(|_| EnvFilter::new("info"))`. +- [ ] For `LogFormat::Json`: use `tracing_subscriber::fmt().json().with_env_filter(filter).with_current_span(true).with_span_list(true).try_init()`. Map the `Err` from `try_init` (which fires if a global subscriber is already set) to `Ok(())` rather than returning an error — this makes the function safe to call from tests. +- [ ] For `LogFormat::Human`: use `tracing_subscriber::fmt().with_env_filter(filter).pretty().try_init()` with the same error-swallowing behavior. +- [ ] Add the module to `src/main.rs`: `mod tracing_setup;` and call `tracing_setup::init(&config)?;` as the second step in `main` (after config load). +- [ ] Write a unit test `test_init_human_format_does_not_panic` and `test_init_json_format_does_not_panic` — both call `tracing_setup::init(&Config::default())` in a tokio test runtime and assert `Ok(())`. +- [ ] Verify that `cargo run -p edr-fleet-server` with `EDR_FLEET_LOG_FORMAT=json` produces JSON-structured log lines to stdout. + +### Definition of done +- `tracing_setup::init` compiles and runs without panic. +- JSON format produces parseable JSON lines (verify with `cargo run | jq .` during manual testing). +- Human format produces colored, pretty output when `EDR_FLEET_LOG_FORMAT` is unset. +- Unit tests pass. +- `try_init` is used (not `init`) so tests can call `init` multiple times without panicking. + +### Notes / constraints +- `tracing_subscriber = { version = "0.3", features = ["env-filter", "json"] }` is in the workspace. The `json` feature is required for `.json()` on the `SubscriberBuilder`. +- Do not use `RUST_LOG` as the only env override — the config-driven `log_level` provides a programmatic default independent of `RUST_LOG`. The `EnvFilter` will still respect `RUST_LOG` if set (it checks it first). +- `tracing_setup` is not `tracing` — do not shadow the external crate name. + +--- + +## Issue: Implement graceful shutdown with SIGTERM and SIGINT handling for the fleet-server +**Labels:** `fleet-server`, `scaffolding`, `async` +**Depends on:** Fleet-server crate structure issue; logging issue +**Blocks:** Smoke test issue + +### What this is +The fleet server must shut down cleanly when it receives `SIGTERM` (from the container orchestrator stopping the pod) or `SIGINT` (from `Ctrl-C` in development). "Cleanly" at the scaffolding level means: accept the signal, log the shutdown intent, cancel a `CancellationToken` that all subsystems will be given, and exit `main` with code 0. The actual drain logic (waiting for in-flight gRPC calls to complete, flushing Kafka producers) is stubbed here and implemented when those subsystems are built. + +### What is currently blocking this +The crate structure and logging issues must be in place. + +### What this is blocking +The smoke test (which verifies `SIGTERM` causes clean exit). + +### Implementation tasks +- [ ] In `fleet-server/src/server.rs`, replace the stub `run` function with a real implementation: (1) create a `tokio_util::sync::CancellationToken` (add `tokio-util = { version = "0.7", features = ["sync"] }` if not already in scope — it is in the workspace), (2) spawn a signal handler task using `tokio::signal::ctrl_c()` and `tokio::signal::unix::signal(SignalKind::terminate())`, (3) when either signal fires, call `token.cancel()` and log `"Received shutdown signal — initiating graceful shutdown"`, (4) pass the token to all subsystem runners (stubs for now), (5) await all subsystem handles, (6) log `"Fleet server stopped"` and return `Ok(())`. +- [ ] In `src/main.rs`, the existing `server::run(cfg).await?` call now becomes the full lifecycle. Ensure the process exits with code 0 on clean shutdown and code 1 on `Err`. +- [ ] Implement `pub struct ShutdownHandle { token: CancellationToken }` with `ShutdownHandle::new() -> (ShutdownHandle, CancellationToken)` and `ShutdownHandle::wait(self) -> impl Future` that awaits the signal before cancelling. Expose this from `src/server.rs`. +- [ ] Stub the shutdown drain as `async fn drain_subsystems(_token: CancellationToken) { tokio::time::sleep(Duration::from_millis(100)).await; tracing::info!("All subsystems drained"); }`. This is a placeholder that future issues replace. +- [ ] Write a unit test `test_shutdown_on_cancellation_token` that creates a `CancellationToken`, cancels it immediately, and asserts that `drain_subsystems(token)` returns within 200ms. + +### Definition of done +- `cargo run -p edr-fleet-server` starts and exits cleanly when `Ctrl-C` is pressed (logs shutdown message, exits code 0). +- On `SIGTERM` (test with `kill -TERM ` in a separate terminal), the same clean shutdown occurs. +- The `CancellationToken` is threaded through `server::run` and passed to all subsystem stubs. +- Unit test passes. + +### Notes / constraints +- `tokio::signal::unix` is only available on Unix targets. Wrap with `#[cfg(unix)]`. For `#[cfg(windows)]` (if needed in future), use `tokio::signal::ctrl_c()` only. +- `tokio::signal::unix::signal(SignalKind::terminate())` returns a `Signal` stream. Use `signal.recv().await` inside a `tokio::select!`. +- Do not call `std::process::exit()` — let `main` return naturally. Calling `exit()` bypasses `Drop` impls and can leave resources in a dirty state. + +--- + +## Issue: Implement the health check HTTP endpoint on the fleet-server +**Labels:** `fleet-server`, `scaffolding`, `async` +**Depends on:** Configuration system issue; logging issue; error type issue; graceful shutdown issue +**Blocks:** Smoke test issue + +### What this is +The fleet server exposes an HTTP port (`http_bind`, default `0.0.0.0:8080`) for health checks and future admin routes. This issue implements a single route: `GET /health` returning `{"status":"ok"}` with HTTP 200. The HTTP server runs concurrently with the gRPC server (the gRPC stub issue) using axum. Both servers share the `CancellationToken` for coordinated shutdown. + +### What is currently blocking this +Config (for `http_bind`), error types (for `ServerError`), logging (so startup is visible), and graceful shutdown (for the CancellationToken). + +### What this is blocking +The smoke test (which calls GET /health and asserts 200). + +### Implementation tasks +- [ ] In `fleet-server/src/http/mod.rs`, define `pub async fn serve(bind: &str, token: CancellationToken) -> Result<(), ServerError>`. +- [ ] In `fleet-server/src/http/health.rs`, define `pub async fn health_handler() -> impl IntoResponse { axum::Json(serde_json::json!({"status": "ok"})) }`. +- [ ] In `http/mod.rs`, build the axum router: `Router::new().route("/health", get(health::health_handler))`. Bind with `TcpListener::bind(bind).await?` and serve with `axum::serve(listener, router).with_graceful_shutdown(token.cancelled())`. +- [ ] In `fleet-server/src/server.rs`, alongside the existing shutdown stub, spawn `http::serve(&config.http_bind, token.clone())` as a `tokio::spawn` handle. Await it in the drain step. +- [ ] Add `tower-http` trace middleware: wrap the router with `.layer(TraceLayer::new_for_http())` using `tower_http::trace::TraceLayer`. +- [ ] Write a unit test `test_health_endpoint_returns_200` using `axum::test` (`axum::serve` test helpers or `hyper` test client): build the router, send `GET /health`, assert status 200 and body `{"status":"ok"}`. + +### Definition of done +- `cargo run -p edr-fleet-server` starts the HTTP server on port 8080. +- `curl http://localhost:8080/health` returns `{"status":"ok"}` with HTTP 200. +- Sending SIGTERM after startup causes the HTTP server to stop accepting new connections and returns from `serve` cleanly. +- Unit test `test_health_endpoint_returns_200` passes. + +### Notes / constraints +- `axum = { version = "0.8", features = ["ws", "macros"] }` is in the workspace. `axum::serve` is the axum 0.8 API (not the older `axum::Server`). +- `axum::serve(...).with_graceful_shutdown(token.cancelled_owned())` requires `CancellationToken::cancelled_owned()` from `tokio-util`. This is available in `tokio-util = "0.7"`. +- The health endpoint must not require authentication — it is called by load balancers and Kubernetes liveness probes. +- Do not implement any other routes in this issue. `/metrics`, `/nodes`, and any other admin routes are out of scope for scaffolding. + +--- + +## Issue: Implement the gRPC server stub: bind the tonic server to its port, register the FleetService, return Unimplemented +**Labels:** `fleet-server`, `scaffolding`, `async`, `ipc` +**Depends on:** Configuration system issue; error type issue; logging issue; graceful shutdown issue +**Blocks:** Smoke test issue + +### What this is +The fleet server's primary interface is a tonic gRPC server implementing the `FleetService` defined in `sdk/proto/fleet.proto`. This issue adds the gRPC server stub: the `tonic::transport::Server` that binds to `config.grpc_bind`, registers a `FleetServiceImpl` struct, and returns `Status::unimplemented()` for all three RPCs (`RegisterAgent`, `EventStream`, `Heartbeat`). This is the minimal skeleton that compiles, binds the port, and is ready for actual implementation in downstream issues outside this workstream. + +### What is currently blocking this +Config (for `grpc_bind`), error types (for mapping gRPC errors), logging, and graceful shutdown (for the shutdown future). The `sdk` crate must expose the generated tonic service trait — check `sdk/src/lib.rs` is a stub and may need a `build.rs` to compile protos. Note: this issue may surface a dependency on the SDK build system. + +### What this is blocking +The smoke test (which verifies the gRPC port is bound). + +### Implementation tasks +- [ ] Verify `sdk/src/lib.rs` exposes the generated `fleet_service_server::FleetService` trait from `sdk/proto/fleet.proto`. If `sdk/build.rs` does not yet exist, add it: `fn main() -> Result<(), Box> { tonic_build::configure().compile_protos(&["proto/fleet.proto", "proto/agent.proto", "proto/events.proto"], &["proto/"])?; Ok(()) }`. Add `build-dependencies = [ tonic-build ]` to `sdk/Cargo.toml`. This is a prerequisite step — it should be done as part of this issue or tracked as a blocking note. +- [ ] In `fleet-server/src/grpc/server.rs`, define `pub struct FleetServiceImpl;` and implement the tonic-generated `FleetService` trait. All three methods return `Err(Status::unimplemented("not yet implemented"))`. The streaming RPCs return a `Result, Status>` where `Self::EventStreamStream` is a `Pin>>`. +- [ ] In `fleet-server/src/grpc/mod.rs`, define `pub async fn serve(bind: &str, token: CancellationToken) -> Result<(), ServerError>`. Use `tonic::transport::Server::builder().add_service(FleetServiceServer::new(FleetServiceImpl)).serve_with_shutdown(addr, token.cancelled_owned()).await?`. +- [ ] In `fleet-server/src/server.rs`, spawn `grpc::serve(&config.grpc_bind, token.clone())` alongside the HTTP server. +- [ ] Write a unit test `test_grpc_server_binds_and_returns_unimplemented`: start the gRPC server on an ephemeral port, connect with a tonic client, call `RegisterAgent`, assert the response is `Status::unimplemented`. + +### Definition of done +- `cargo build -p edr-fleet-server` succeeds with the gRPC stub present. +- `cargo run -p edr-fleet-server` logs the gRPC bind address and accepts connections on port 50051. +- `grpcurl -plaintext localhost:50051 edr.fleet.FleetService/RegisterAgent` returns `Unimplemented` (not `connection refused`). +- Unit test passes. + +### Notes / constraints +- `tonic = "0.14"` is in the workspace. The generated `FleetServiceServer` requires `tonic::async_trait` on the impl block. Use `#[tonic::async_trait]`. +- The `EventStream` RPC is bidirectional streaming. The `EventStreamStream` associated type must be `Pin> + Send + 'static>>`. Return an empty stream for the stub: `Ok(Response::new(Box::pin(tokio_stream::empty())))`. +- `sdk/src/lib.rs` is currently a single-line comment. Adding `build.rs` to the SDK and exposing the generated types is a prerequisite. If the SDK build is not in scope for this issue, use the existing `testing.proto` in `fleet-server/src/grpc/` as a local stand-in — but this is a workaround. The clean solution is to fix the SDK build. + +--- + +## Issue: Fleet-server smoke test: server starts, binds, returns 200 on health, shuts down cleanly +**Labels:** `fleet-server`, `testing`, `scaffolding` +**Depends on:** Health endpoint issue; gRPC server stub issue; logging issue; graceful shutdown issue +**Blocks:** nothing (leaf node in workstream) + +### What this is +With all scaffolding pieces in place (config, tracing, error types, shutdown, health endpoint, gRPC stub), this issue implements a single integration test that exercises the full startup-to-shutdown lifecycle of the fleet server binary. This is the "does it all hang together" gate before any business logic is built on top. + +### What is currently blocking this +All prior fleet-server scaffolding issues must be complete. + +### What this is blocking +Nothing — leaf node of Workstream C. + +### Implementation tasks +- [ ] Create `fleet-server/tests/smoke_test.rs`. This is an integration test (in `tests/`, not `src/`), so it tests the public API of the crate. +- [ ] Implement `test_health_endpoint_returns_ok`: (1) build a `Config` with `http_bind = "127.0.0.1:0"` (port 0 = OS assigns ephemeral), `grpc_bind = "127.0.0.1:0"`, `log_level = "error"` (quiet), `jwt_secret = "a".repeat(32)`, `database_url = "postgres://unused"`, (2) spawn `server::run(config)` in a `tokio::spawn`, (3) poll `http://127.0.0.1:/health` with a short timeout (use `reqwest` or `axum`'s test helpers), (4) assert HTTP 200 and body `{"status":"ok"}`, (5) cancel the `CancellationToken`, (6) await the server task, assert it exits with `Ok(())`. +- [ ] Add `reqwest` as a dev dependency in `fleet-server/Cargo.toml`: `reqwest = { version = "0.12", features = ["json"], default-features = false }` — or use `hyper` directly to avoid a heavy dependency. +- [ ] Implement `test_grpc_port_is_bound`: after startup, attempt a TCP connection to `127.0.0.1:`. Assert the connection is accepted (not refused). This does not require a gRPC client — just a raw `TcpStream::connect`. +- [ ] Implement `test_graceful_shutdown_exits_zero`: cancel the token, await the server future, assert the `Result` is `Ok(())`. Assert that no log lines at `ERROR` level were emitted during the test run (use `tracing_test` crate if available, or skip this assertion). +- [ ] Note the challenge of binding on port 0 with axum: `axum::serve` needs to know the actual bound port. Expose a `BoundServer` struct from `http::serve` that holds the actual `SocketAddr`. This requires a small API change to `http/mod.rs`. + +### Definition of done +- `cargo test -p edr-fleet-server` (including `tests/smoke_test.rs`) passes all three smoke tests. +- The tests do not require a running PostgreSQL, Kafka, or any external service. +- The test binary exits with code 0 and leaves no listening sockets behind. +- Test runtime is under 5 seconds total (no real sleep calls — use `tokio::time::timeout` for all waits). + +### Notes / constraints +- Port 0 binding: pass `"127.0.0.1:0"` to `TcpListener::bind`, then call `listener.local_addr()` to get the actual port before passing the listener to axum. This requires `http::serve` to take a `TcpListener` instead of a `&str` bind address — or expose the bound address through a `oneshot` channel. +- The gRPC port 0 binding with tonic: `Server::builder().serve_with_shutdown(addr, ...)` where `addr` is `"127.0.0.1:0".parse().unwrap()`. Tonic internally binds and assigns an ephemeral port, but does not expose the actual port without additional hooks. For the smoke test, use a fixed high port (e.g., 59050) known to be available, and skip this test if the port is in use. +- Do not test actual gRPC RPC calls in this smoke test — that is integration testing, not scaffolding testing. + +--- diff --git a/agent/crates/agent-core/src/config.rs b/agent/crates/agent-core/src/config.rs index e87d130..a8d20c5 100644 --- a/agent/crates/agent-core/src/config.rs +++ b/agent/crates/agent-core/src/config.rs @@ -54,7 +54,7 @@ pub struct OsqueryConfig { /// Connection timeout in seconds when connecting to the socket pub connect_timeout_secs: Option, - // Daemon Options (mirrors osquery.conf "options") + // Daemon Options (mirrors osquery.conf "options") pub options: OsqueryOptions, /// Bootstrap queries (overridden by fleet server push) diff --git a/agent/crates/agent-core/src/orchestrator.rs b/agent/crates/agent-core/src/orchestrator.rs index 37891aa..5175aa2 100644 --- a/agent/crates/agent-core/src/orchestrator.rs +++ b/agent/crates/agent-core/src/orchestrator.rs @@ -69,7 +69,7 @@ pub async fn run() -> Result<()> { } } - // Start OsqueryCollector + // Start OsqueryCollector let collector = osquery_client::OsqueryCollector::new(osquery_client::OsqueryConfig { socket_path: config.osquery.socket_path.clone(), db_path: config.agent.buffer_path.clone(), @@ -86,7 +86,7 @@ pub async fn run() -> Result<()> { let mut results_rx = collector.start(&agent_uuid).await; tracing::info!("OsqueryCollector started (agent_uuid={})", agent_uuid); - // Fleet enrollment (non-fatal, fleet server not ready yet) + // Fleet enrollment (non-fatal, fleet server not ready yet) tracing::info!("Attempting fleet enrollment (non-fatal if server is down)..."); let mut fleet_client = fleet_client::FleetClient::new(fleet_client::FleetConfig { endpoint: config.fleet.endpoint.clone(), @@ -112,7 +112,7 @@ pub async fn run() -> Result<()> { } } - // Main loop — drain results & handle shutdown + // Main loop — drain results & handle shutdown // rusqlite::Connection is !Send so we drive the buffer writes here on the // main task rather than in a spawned task. tracing::info!("Agent is running. Draining osquery results. Press Ctrl-C to stop."); @@ -148,7 +148,6 @@ pub async fn run() -> Result<()> { Ok(()) } - /// Encode an OsqueryResult to raw bytes for storage in the event buffer. /// Uses prost protobuf encoding. fn encode_result(result: &OsqueryResult) -> Vec { diff --git a/agent/crates/fleet-client/src/types.rs b/agent/crates/fleet-client/src/types.rs index 2a4e1ea..21691c9 100644 --- a/agent/crates/fleet-client/src/types.rs +++ b/agent/crates/fleet-client/src/types.rs @@ -1,10 +1,6 @@ use prost::Message; use serde::{Deserialize, Serialize}; -// ═════════════════════════════════════════════════════════ -// ENUMS -// ═════════════════════════════════════════════════════════ - /// Type of event being sent from agent to fleet server. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[repr(i32)] @@ -42,9 +38,6 @@ pub enum ConnectionState { Disconnected, } -// ═════════════════════════════════════════════════════════ -// ENROLLMENT MESSAGES -// ═════════════════════════════════════════════════════════ /// Sent by the agent to register with the fleet server. /// Proto tag numbers match fleet.proto RegisterRequest. @@ -89,10 +82,6 @@ pub struct EnrollmentResult { pub config: Option, } -// ═════════════════════════════════════════════════════════ -// EVENT STREAM MESSAGES -// ═════════════════════════════════════════════════════════ - /// An event sent from the agent to the fleet server over the /// bidirectional gRPC stream. The `payload` field contains /// protobuf-encoded event data (e.g., OsqueryResult.encode_to_vec()). @@ -119,9 +108,6 @@ pub struct AgentEvent { pub sequence_id: String, } -// ═════════════════════════════════════════════════════════ -// SERVER COMMAND MESSAGES (fleet server → agent) -// ═════════════════════════════════════════════════════════ /// A command sent from the fleet server to the agent. /// Uses prost oneof to match the proto3 `oneof command { ... }`. @@ -172,9 +158,6 @@ pub struct AckCommand { pub sequence_id: String, } -// ═════════════════════════════════════════════════════════ -// AGENT CONFIGURATION (pushed by fleet server) -// ═════════════════════════════════════════════════════════ /// Configuration payload sent from fleet server to agent. /// Stored locally in SQLite after receipt. @@ -209,9 +192,6 @@ pub struct OsquerySchedule { pub interval_secs: i32, } -// ═════════════════════════════════════════════════════════ -// HEARTBEAT MESSAGES -// ═════════════════════════════════════════════════════════ /// Periodic heartbeat sent from agent to fleet server. #[derive(Clone, Message)] diff --git a/fleet-server/Cargo.toml b/fleet-server/Cargo.toml deleted file mode 100644 index 0aa9440..0000000 --- a/fleet-server/Cargo.toml +++ /dev/null @@ -1,31 +0,0 @@ -[package] -name = "edr-fleet-server" -edition.workspace = true -version.workspace = true -rust-version.workspace = true - -[[bin]] -name = "edr-fleet-server" -path = "src/main.rs" - -[dependencies] -tokio = { workspace = true } -axum = { workspace = true } -tonic = { workspace = true } -tonic-reflection = { workspace = true } -tower = { workspace = true } -tower-http = { workspace = true } -prost = { workspace = true } -# rdkafka = { workspace = true } -sqlx = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -tracing = { workspace = true } -tracing-subscriber = { workspace = true } -uuid = { workspace = true } -chrono = { workspace = true } -jsonwebtoken = { workspace = true } -config = { workspace = true } -anyhow = { workspace = true } -thiserror = { workspace = true } -edr-sdk = { workspace = true } diff --git a/fleet-server/Dockerfile b/fleet-server/Dockerfile index cf6256f..e69de29 100644 --- a/fleet-server/Dockerfile +++ b/fleet-server/Dockerfile @@ -1,33 +0,0 @@ -# Stage 1: Build -FROM rust:1.85-slim-bookworm AS builder - -RUN apt-get update && apt-get install -y \ - pkg-config libssl-dev libpq-dev cmake \ - && rm -rf /var/lib/apt/lists/* - -WORKDIR /build -COPY Cargo.toml Cargo.lock ./ -# Cache dependencies layer -RUN mkdir src && echo "fn main(){}" > src/main.rs -RUN cargo build --release -RUN rm -f target/release/deps/edr_* - -COPY src ./src -RUN cargo build --release - -# Stage 2: Runtime -FROM debian:bookworm-slim AS runtime - -RUN apt-get update && apt-get install -y \ - libssl3 libpq5 ca-certificates \ - && rm -rf /var/lib/apt/lists/* - -RUN useradd -m -u 1001 -s /bin/bash edr - -COPY --from=builder /build/target/release/edr-fleet-server /usr/local/bin/ -RUN chmod +x /usr/local/bin/edr-fleet-server - -USER edr -EXPOSE 50051 8080 - -ENTRYPOINT ["edr-fleet-server"] diff --git a/fleet-server/crates/fleet-manager/Cargo.toml b/fleet-server/crates/fleet-manager/Cargo.toml new file mode 100644 index 0000000..583a810 --- /dev/null +++ b/fleet-server/crates/fleet-manager/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "fleet-manager" +edition.workspace = true +version.workspace = true +rust-version.workspace = true + +[dependencies] +tracing = { workspace = true } diff --git a/fleet-server/crates/fleet-manager/src/lib.rs b/fleet-server/crates/fleet-manager/src/lib.rs new file mode 100644 index 0000000..30e79ee --- /dev/null +++ b/fleet-server/crates/fleet-manager/src/lib.rs @@ -0,0 +1,3 @@ +pub fn manage_fleet() { + println!("Managing fleet..."); +} diff --git a/fleet-server/crates/fleet-server-bin/Cargo.toml b/fleet-server/crates/fleet-server-bin/Cargo.toml new file mode 100644 index 0000000..9042583 --- /dev/null +++ b/fleet-server/crates/fleet-server-bin/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "fleet-server-bin" +edition.workspace = true +version.workspace = true +rust-version.workspace = true + +[[bin]] +name = "fleet-server-bin" +path = "src/main.rs" + +[dependencies] +tokio = { workspace = true } +tracing = { workspace = true } +grpc-listener = { path = "../grpc-listener" } +fleet-manager = { path = "../fleet-manager" } +node-enrollment = { path = "../node-enrollment" } +health-tracker = { path = "../health-tracker" } +fleet-tracing = { path = "../fleet-tracing" } +postgres-interface = { path = "../postgres-interface" } +kafka-handler = { path = "../kafka-handler" } diff --git a/fleet-server/crates/fleet-server-bin/src/main.rs b/fleet-server/crates/fleet-server-bin/src/main.rs new file mode 100644 index 0000000..7d2024d --- /dev/null +++ b/fleet-server/crates/fleet-server-bin/src/main.rs @@ -0,0 +1,5 @@ +fn main() { + // Entry point for the EDR Fleet Server. + // It initializes tracing, loads .env, sets up Postgres/Kafka, and starts the gRPC listener. + println!("Initializing Fleet Server..."); +} diff --git a/fleet-server/crates/fleet-tracing/Cargo.toml b/fleet-server/crates/fleet-tracing/Cargo.toml new file mode 100644 index 0000000..b84a37f --- /dev/null +++ b/fleet-server/crates/fleet-tracing/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "fleet-tracing" +edition.workspace = true +version.workspace = true +rust-version.workspace = true + +[dependencies] +tracing = { workspace = true } +tracing-subscriber = { workspace = true } diff --git a/fleet-server/crates/fleet-tracing/src/lib.rs b/fleet-server/crates/fleet-tracing/src/lib.rs new file mode 100644 index 0000000..0d39ff0 --- /dev/null +++ b/fleet-server/crates/fleet-tracing/src/lib.rs @@ -0,0 +1,3 @@ +pub fn init_tracing() { + println!("Initializing fleet tracing and logging..."); +} diff --git a/fleet-server/crates/grpc-listener/Cargo.toml b/fleet-server/crates/grpc-listener/Cargo.toml new file mode 100644 index 0000000..d3a7b2f --- /dev/null +++ b/fleet-server/crates/grpc-listener/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "grpc-listener" +edition.workspace = true +version.workspace = true +rust-version.workspace = true + +[dependencies] +tonic = { workspace = true } +tonic-prost = { workspace = true } +prost = { workspace = true } +tokio = { workspace = true } +tokio-stream = { workspace = true } +tokio-util = { workspace = true } +tracing = { workspace = true } +thiserror = { workspace = true } +jsonwebtoken = { workspace = true } +anyhow = { workspace = true } +async-trait = { workspace = true } +fleet-manager = { workspace = true } + +[build-dependencies] +tonic-build = { workspace = true } diff --git a/fleet-server/crates/grpc-listener/build.rs b/fleet-server/crates/grpc-listener/build.rs new file mode 100644 index 0000000..3ce71ce --- /dev/null +++ b/fleet-server/crates/grpc-listener/build.rs @@ -0,0 +1,10 @@ +fn main() -> Result<(), Box> { + tonic_build::configure() + .build_server(true) + .build_client(false) + .compile_protos( + &["../../../sdk/proto/fleet.proto"], + &["../../../sdk/proto"], + )?; + Ok(()) +} diff --git a/fleet-server/crates/grpc-listener/src/lib.rs b/fleet-server/crates/grpc-listener/src/lib.rs new file mode 100644 index 0000000..70e06c5 --- /dev/null +++ b/fleet-server/crates/grpc-listener/src/lib.rs @@ -0,0 +1,3 @@ +pub fn start_listener() { + println!("Starting gRPC Listener..."); +} diff --git a/fleet-server/crates/health-tracker/Cargo.toml b/fleet-server/crates/health-tracker/Cargo.toml new file mode 100644 index 0000000..efc075b --- /dev/null +++ b/fleet-server/crates/health-tracker/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "health-tracker" +edition.workspace = true +version.workspace = true +rust-version.workspace = true + +[dependencies] +tracing = { workspace = true } diff --git a/fleet-server/crates/health-tracker/src/lib.rs b/fleet-server/crates/health-tracker/src/lib.rs new file mode 100644 index 0000000..ab484f9 --- /dev/null +++ b/fleet-server/crates/health-tracker/src/lib.rs @@ -0,0 +1,3 @@ +pub fn track_health() { + println!("Tracking node health..."); +} diff --git a/fleet-server/crates/kafka-handler/Cargo.toml b/fleet-server/crates/kafka-handler/Cargo.toml new file mode 100644 index 0000000..ad95c4c --- /dev/null +++ b/fleet-server/crates/kafka-handler/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "kafka-handler" +edition.workspace = true +version.workspace = true +rust-version.workspace = true + +[dependencies] +tracing = { workspace = true } diff --git a/fleet-server/crates/kafka-handler/src/lib.rs b/fleet-server/crates/kafka-handler/src/lib.rs new file mode 100644 index 0000000..79fdd86 --- /dev/null +++ b/fleet-server/crates/kafka-handler/src/lib.rs @@ -0,0 +1,3 @@ +pub fn publish_event() { + println!("Publishing message to Kafka..."); +} diff --git a/fleet-server/crates/node-enrollment/Cargo.toml b/fleet-server/crates/node-enrollment/Cargo.toml new file mode 100644 index 0000000..f65e07e --- /dev/null +++ b/fleet-server/crates/node-enrollment/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "node-enrollment" +edition.workspace = true +version.workspace = true +rust-version.workspace = true + +[dependencies] +tracing = { workspace = true } diff --git a/fleet-server/crates/node-enrollment/src/lib.rs b/fleet-server/crates/node-enrollment/src/lib.rs new file mode 100644 index 0000000..d06e19f --- /dev/null +++ b/fleet-server/crates/node-enrollment/src/lib.rs @@ -0,0 +1,3 @@ +pub fn enroll_node() { + println!("Enrolling node..."); +} diff --git a/fleet-server/crates/postgres-interface/Cargo.toml b/fleet-server/crates/postgres-interface/Cargo.toml new file mode 100644 index 0000000..bc1602f --- /dev/null +++ b/fleet-server/crates/postgres-interface/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "postgres-interface" +edition.workspace = true +version.workspace = true +rust-version.workspace = true + +[dependencies] +sqlx = { workspace = true } +tracing = { workspace = true } diff --git a/fleet-server/crates/postgres-interface/src/lib.rs b/fleet-server/crates/postgres-interface/src/lib.rs new file mode 100644 index 0000000..91eb029 --- /dev/null +++ b/fleet-server/crates/postgres-interface/src/lib.rs @@ -0,0 +1,3 @@ +pub fn init_db() { + println!("Connecting to Postgres..."); +} diff --git a/fleet-server/migrations/001_create_nodes.sql b/fleet-server/migrations/001_create_nodes.sql deleted file mode 100644 index e044330..0000000 --- a/fleet-server/migrations/001_create_nodes.sql +++ /dev/null @@ -1,33 +0,0 @@ --- migrations/001_create_nodes.sql -CREATE TABLE nodes ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - hostname VARCHAR(255) NOT NULL, - os_version VARCHAR(255), - agent_version VARCHAR(50), - machine_id VARCHAR(64) UNIQUE NOT NULL, - enrolled_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - last_seen TIMESTAMPTZ, - status VARCHAR(20) NOT NULL DEFAULT 'online', - -- 'online' | 'offline' | 'isolated' | 'degraded' - ip_address INET, - CONSTRAINT status_check CHECK (status IN ('online','offline','isolated','degraded')) -); - -CREATE TABLE agent_configs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - node_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, - config JSONB NOT NULL, - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE TABLE pending_commands ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - node_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, - command JSONB NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - delivered BOOLEAN NOT NULL DEFAULT FALSE, - delivered_at TIMESTAMPTZ -); - -CREATE INDEX idx_nodes_status ON nodes(status); -CREATE INDEX idx_pending_commands_undelivered ON pending_commands(node_id, delivered) WHERE delivered = FALSE; diff --git a/fleet-server/src/grpc/main.rs b/fleet-server/src/grpc/main.rs deleted file mode 100644 index e69de29..0000000 diff --git a/fleet-server/src/main.rs b/fleet-server/src/main.rs deleted file mode 100644 index fcb3feb..0000000 --- a/fleet-server/src/main.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - println!("edr-fleet-server"); -} From 2f8161dd847c6a6129ee75bc9d6fb9035f56c855 Mon Sep 17 00:00:00 2001 From: ghule swarnit Date: Mon, 1 Jun 2026 06:04:39 +0530 Subject: [PATCH 12/69] feat: implement gRPC listener with JWT authentication and event handling --- Cargo.toml | 3 +- fleet-server/crates/fleet-manager/Cargo.toml | 4 + fleet-server/crates/fleet-manager/src/lib.rs | 9 +- .../crates/fleet-manager/src/ports.rs | 70 +++++++ fleet-server/crates/grpc-listener/Cargo.toml | 20 +- fleet-server/crates/grpc-listener/build.rs | 2 +- fleet-server/crates/grpc-listener/src/auth.rs | 130 +++++++++++++ .../crates/grpc-listener/src/config.rs | 23 +++ .../crates/grpc-listener/src/error.rs | 16 ++ fleet-server/crates/grpc-listener/src/lib.rs | 18 +- .../crates/grpc-listener/src/server.rs | 51 +++++ .../crates/grpc-listener/src/service.rs | 182 ++++++++++++++++++ 12 files changed, 511 insertions(+), 17 deletions(-) create mode 100644 fleet-server/crates/fleet-manager/src/ports.rs create mode 100644 fleet-server/crates/grpc-listener/src/auth.rs create mode 100644 fleet-server/crates/grpc-listener/src/config.rs create mode 100644 fleet-server/crates/grpc-listener/src/error.rs create mode 100644 fleet-server/crates/grpc-listener/src/server.rs create mode 100644 fleet-server/crates/grpc-listener/src/service.rs diff --git a/Cargo.toml b/Cargo.toml index c5983d0..a628ca0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ tokio-tungstenite = "0.29" tonic = { version = "0.14" } tonic-reflection = "0.14" tonic-build = "0.14" +tonic-prost-build = "0.14" prost = "0.14" tonic-prost = "0.14" @@ -61,7 +62,7 @@ uuid = { version = "1", features = ["v4", "serde"] } chrono = { version = "0.4", features = ["serde"] } bytes = "1" -jsonwebtoken = "10" +jsonwebtoken = { version = "10", features = ["rust_crypto"] } argon2 = "0.5" config = "0.15" diff --git a/fleet-server/crates/fleet-manager/Cargo.toml b/fleet-server/crates/fleet-manager/Cargo.toml index 583a810..4493ee1 100644 --- a/fleet-server/crates/fleet-manager/Cargo.toml +++ b/fleet-server/crates/fleet-manager/Cargo.toml @@ -5,4 +5,8 @@ version.workspace = true rust-version.workspace = true [dependencies] +async-trait.workspace = true +prost.workspace = true +thiserror.workspace = true +tonic.workspace = true tracing = { workspace = true } diff --git a/fleet-server/crates/fleet-manager/src/lib.rs b/fleet-server/crates/fleet-manager/src/lib.rs index 30e79ee..ef5c51a 100644 --- a/fleet-server/crates/fleet-manager/src/lib.rs +++ b/fleet-server/crates/fleet-manager/src/lib.rs @@ -1,3 +1,6 @@ -pub fn manage_fleet() { - println!("Managing fleet..."); -} +pub mod ports; + +pub use ports::{ + AgentHeartbeat, AgentRegistration, EnrollmentPort, EventIngestPort, HeartbeatPort, + IncomingEvent, OutgoingCommand, RegistrationResult, +}; diff --git a/fleet-server/crates/fleet-manager/src/ports.rs b/fleet-server/crates/fleet-manager/src/ports.rs new file mode 100644 index 0000000..a25071c --- /dev/null +++ b/fleet-server/crates/fleet-manager/src/ports.rs @@ -0,0 +1,70 @@ +use async_trait::async_trait; +use tonic::Status; + +// These types mirror the generated proto structs. We re-declare them here as +// plain Rust structs so fleet-manager has no compile-time dep on the generated +// code in grpc-listener. grpc-listener converts at the boundary. + +/// Minimal registration request forwarded from the gRPC boundary. +#[derive(Debug, Clone)] +pub struct AgentRegistration { + pub hostname: String, + pub os_version: String, + pub agent_version: String, + pub machine_id: String, +} + +/// Result returned to the gRPC boundary after successful enrollment. +#[derive(Debug, Clone)] +pub struct RegistrationResult { + pub node_id: String, + pub token: String, +} + +/// Heartbeat forwarded from the gRPC boundary. +#[derive(Debug, Clone)] +pub struct AgentHeartbeat { + pub node_id: String, + pub status: String, + pub events_buffered: i64, +} + +/// Raw event bytes forwarded from the gRPC boundary. +#[derive(Debug, Clone)] +pub struct IncomingEvent { + pub node_id: String, + pub event_type: String, + pub payload: Vec, + pub timestamp_ns: i64, + pub sequence_id: String, +} + +/// Optional command to send back to the agent over the bidi stream. +#[derive(Debug, Clone)] +pub enum OutgoingCommand { + Ack { sequence_id: String }, +} + +/// Drives agent enrollment. Implemented by `node-enrollment`. +#[async_trait] +pub trait EnrollmentPort: Send + Sync + 'static { + async fn register_agent( + &self, + registration: AgentRegistration, + ) -> Result; +} + +/// Records agent heartbeats. Implemented by `health-tracker`. +#[async_trait] +pub trait HeartbeatPort: Send + Sync + 'static { + async fn record_heartbeat(&self, heartbeat: AgentHeartbeat) -> Result<(), Status>; +} + +/// Ingests agent events and forwards them. Implemented by `kafka-handler`. +#[async_trait] +pub trait EventIngestPort: Send + Sync + 'static { + async fn ingest_event( + &self, + event: IncomingEvent, + ) -> Result, Status>; +} diff --git a/fleet-server/crates/grpc-listener/Cargo.toml b/fleet-server/crates/grpc-listener/Cargo.toml index d3a7b2f..cb64d7d 100644 --- a/fleet-server/crates/grpc-listener/Cargo.toml +++ b/fleet-server/crates/grpc-listener/Cargo.toml @@ -6,17 +6,19 @@ rust-version.workspace = true [dependencies] tonic = { workspace = true } -tonic-prost = { workspace = true } -prost = { workspace = true } +tonic-prost.workspace = true +prost.workspace = true tokio = { workspace = true } -tokio-stream = { workspace = true } -tokio-util = { workspace = true } +tokio-stream.workspace = true +tokio-util.workspace = true tracing = { workspace = true } -thiserror = { workspace = true } -jsonwebtoken = { workspace = true } -anyhow = { workspace = true } -async-trait = { workspace = true } -fleet-manager = { workspace = true } +thiserror.workspace = true +jsonwebtoken = { workspace = true, features = ["rust_crypto"] } +anyhow.workspace = true +async-trait.workspace = true +fleet-manager.workspace = true +serde.workspace = true [build-dependencies] tonic-build = { workspace = true } +tonic-prost-build.workspace = true diff --git a/fleet-server/crates/grpc-listener/build.rs b/fleet-server/crates/grpc-listener/build.rs index 3ce71ce..5de57ce 100644 --- a/fleet-server/crates/grpc-listener/build.rs +++ b/fleet-server/crates/grpc-listener/build.rs @@ -1,5 +1,5 @@ fn main() -> Result<(), Box> { - tonic_build::configure() + tonic_prost_build::configure() .build_server(true) .build_client(false) .compile_protos( diff --git a/fleet-server/crates/grpc-listener/src/auth.rs b/fleet-server/crates/grpc-listener/src/auth.rs new file mode 100644 index 0000000..9b1c5f7 --- /dev/null +++ b/fleet-server/crates/grpc-listener/src/auth.rs @@ -0,0 +1,130 @@ +use jsonwebtoken::{DecodingKey, Validation, decode}; +use serde::{Deserialize, Serialize}; +use tonic::{Status, metadata::MetadataMap}; + +use crate::error::Error; + +/// Claims encoded inside the JWT token issued at enrollment. +#[derive(Debug, Serialize, Deserialize)] +pub struct NodeClaims { + /// The node's UUID, assigned during `RegisterAgent`. + pub node_id: String, + + /// Standard JWT expiry (unix timestamp). + pub exp: usize, +} + +/// Extracts and validates the `Authorization: Bearer ` header. +/// +/// Returns the decoded `NodeClaims` on success. +/// +/// # Errors +/// +/// Returns `Status::unauthenticated` if the header is missing, malformed, +/// uses a non-Bearer scheme, or contains an invalid/expired JWT. +pub fn validate_token( + metadata: &MetadataMap, + decoding_key: &DecodingKey, +) -> Result { + let header = metadata + .get("authorization") + .ok_or(Error::MissingAuthHeader) + .map_err(|_| Status::unauthenticated("missing authorization header"))?; + + let header_str = header + .to_str() + .map_err(|_| Status::unauthenticated("authorization header is not valid utf-8"))?; + + let token = header_str + .strip_prefix("Bearer ") + .ok_or_else(|| Status::unauthenticated("authorization header must use Bearer scheme"))?; + + let token_data = decode::(token, decoding_key, &Validation::default()) + .map_err(|e| { + tracing::debug!(err = %e, "jwt validation failed"); + Status::unauthenticated("invalid or expired token") + })?; + + Ok(token_data.claims) +} + +#[cfg(test)] +mod tests { + use super::*; + use jsonwebtoken::{EncodingKey, Header, encode}; + use tonic::metadata::MetadataValue; + + fn make_token(node_id: &str, secret: &str, exp_offset_secs: i64) -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let exp = (SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64 + + exp_offset_secs) as usize; + let claims = NodeClaims { + node_id: node_id.to_string(), + exp, + }; + encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(secret.as_bytes()), + ) + .unwrap() + } + + #[test] + fn accepts_valid_token() { + let secret = "test-secret"; + let token = make_token("node-123", secret, 3600); + let decoding_key = DecodingKey::from_secret(secret.as_bytes()); + + let mut map = MetadataMap::new(); + map.insert( + "authorization", + MetadataValue::try_from(format!("Bearer {token}")).unwrap(), + ); + + let claims = validate_token(&map, &decoding_key).unwrap(); + assert_eq!(claims.node_id, "node-123"); + } + + #[test] + fn rejects_missing_header() { + let decoding_key = DecodingKey::from_secret(b"secret"); + let map = MetadataMap::new(); + let err = validate_token(&map, &decoding_key).unwrap_err(); + assert_eq!(err.code(), tonic::Code::Unauthenticated); + } + + #[test] + fn rejects_expired_token() { + let secret = "test-secret"; + let token = make_token("node-123", secret, -3600); + let decoding_key = DecodingKey::from_secret(secret.as_bytes()); + + let mut map = MetadataMap::new(); + map.insert( + "authorization", + MetadataValue::try_from(format!("Bearer {token}")).unwrap(), + ); + + let err = validate_token(&map, &decoding_key).unwrap_err(); + assert_eq!(err.code(), tonic::Code::Unauthenticated); + } + + #[test] + fn rejects_wrong_secret() { + let token = make_token("node-123", "correct-secret", 3600); + let decoding_key = DecodingKey::from_secret(b"wrong-secret"); + + let mut map = MetadataMap::new(); + map.insert( + "authorization", + MetadataValue::try_from(format!("Bearer {token}")).unwrap(), + ); + + let err = validate_token(&map, &decoding_key).unwrap_err(); + assert_eq!(err.code(), tonic::Code::Unauthenticated); + } +} diff --git a/fleet-server/crates/grpc-listener/src/config.rs b/fleet-server/crates/grpc-listener/src/config.rs new file mode 100644 index 0000000..f715710 --- /dev/null +++ b/fleet-server/crates/grpc-listener/src/config.rs @@ -0,0 +1,23 @@ +/// Configuration for the gRPC listener. +/// +/// Populated by `fleet-server-bin` from the `.env` file via the `config` crate +/// and injected into `GrpcServer::new`. This crate never reads env vars directly. +#[derive(Debug, Clone)] +pub struct GrpcListenerConfig { + /// Interface to bind on, e.g. `"0.0.0.0"`. + pub host: String, + + /// Port to listen on, e.g. `50051`. + pub port: u16, + + /// Secret used to validate incoming JWT bearer tokens. + pub jwt_secret: String, +} + +impl GrpcListenerConfig { + /// Formats `host:port` into a bind address string. + #[must_use] + pub fn bind_addr(&self) -> String { + format!("{}:{}", self.host, self.port) + } +} diff --git a/fleet-server/crates/grpc-listener/src/error.rs b/fleet-server/crates/grpc-listener/src/error.rs new file mode 100644 index 0000000..c05a18f --- /dev/null +++ b/fleet-server/crates/grpc-listener/src/error.rs @@ -0,0 +1,16 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum Error { + #[error("invalid or expired jwt token")] + Unauthenticated, + + #[error("missing authorization header")] + MissingAuthHeader, + + #[error("transport error: {0}")] + Transport(#[from] tonic::transport::Error), + + #[error("address parse error: {0}")] + AddrParse(#[from] std::net::AddrParseError), +} diff --git a/fleet-server/crates/grpc-listener/src/lib.rs b/fleet-server/crates/grpc-listener/src/lib.rs index 70e06c5..514c0a8 100644 --- a/fleet-server/crates/grpc-listener/src/lib.rs +++ b/fleet-server/crates/grpc-listener/src/lib.rs @@ -1,3 +1,15 @@ -pub fn start_listener() { - println!("Starting gRPC Listener..."); -} +#![deny(clippy::unwrap_used)] +#![deny(clippy::expect_used)] +#![warn(clippy::pedantic)] +#![allow(clippy::module_name_repetitions)] + +pub mod auth; +pub mod config; +pub mod error; +pub mod server; +pub mod service; + +pub use config::GrpcListenerConfig; +pub use error::Error; +pub use server::{GrpcServer, shutdown_signal}; +pub use service::FleetServiceImpl; diff --git a/fleet-server/crates/grpc-listener/src/server.rs b/fleet-server/crates/grpc-listener/src/server.rs new file mode 100644 index 0000000..d4cf921 --- /dev/null +++ b/fleet-server/crates/grpc-listener/src/server.rs @@ -0,0 +1,51 @@ +use std::future::Future; + +use tonic::transport::Server; +use tokio_util::sync::CancellationToken; + +use crate::{ + config::GrpcListenerConfig, + error::Error, + service::{FleetServiceImpl, FleetServiceServer}, +}; + +/// Owns the tonic server and its lifecycle. +pub struct GrpcServer { + config: GrpcListenerConfig, + service: FleetServiceImpl, +} + +impl GrpcServer { + #[must_use] + pub fn new(config: GrpcListenerConfig, service: FleetServiceImpl) -> Self { + Self { config, service } + } + + /// Binds and serves until `shutdown` resolves. + /// + /// # Errors + /// + /// Returns `Error::AddrParse` if `config.bind_addr()` is not a valid socket address. + /// Returns `Error::Transport` if the tonic server fails to bind or encounters a fatal error. + pub async fn serve_until_shutdown( + self, + shutdown: impl Future, + ) -> Result<(), Error> { + let addr: std::net::SocketAddr = self.config.bind_addr().parse()?; + + tracing::info!(addr = %addr, "gRPC listener starting"); + + Server::builder() + .add_service(FleetServiceServer::new(self.service)) + .serve_with_shutdown(addr, shutdown) + .await?; + + tracing::info!("gRPC listener stopped"); + Ok(()) + } +} + +/// Convenience: resolves when the given `CancellationToken` is cancelled. +pub async fn shutdown_signal(token: CancellationToken) { + token.cancelled().await; +} diff --git a/fleet-server/crates/grpc-listener/src/service.rs b/fleet-server/crates/grpc-listener/src/service.rs new file mode 100644 index 0000000..c23ad37 --- /dev/null +++ b/fleet-server/crates/grpc-listener/src/service.rs @@ -0,0 +1,182 @@ +use std::pin::Pin; +use std::sync::Arc; + +use async_trait::async_trait; +use jsonwebtoken::DecodingKey; +use tokio_stream::{Stream, StreamExt, wrappers::ReceiverStream}; +use tonic::{Request, Response, Status, Streaming}; + +use fleet_manager::{ + AgentHeartbeat, AgentRegistration, EnrollmentPort, EventIngestPort, HeartbeatPort, + IncomingEvent, OutgoingCommand, +}; + +use crate::auth::validate_token; + +// Include the code generated from fleet.proto by build.rs. +// Lints are suppressed on generated code we do not own. +#[allow( + clippy::doc_markdown, + clippy::default_trait_access, + clippy::too_many_lines, + clippy::missing_errors_doc, + clippy::must_use_candidate, + clippy::wildcard_imports, +)] +pub(crate) mod proto { + tonic::include_proto!("edr.fleet"); +} + +pub use proto::fleet_service_server::{FleetService, FleetServiceServer}; + +use proto::{ + AgentEvent, HeartbeatRequest, HeartbeatResponse, RegisterRequest, RegisterResponse, + ServerCommand, + server_command::Command, + AckCommand, +}; + +type EventStream = Pin> + Send + 'static>>; + +/// The gRPC service implementation. Holds Arc refs to the domain port traits +/// so it is cheaply cloneable and the handlers are stateless. +pub struct FleetServiceImpl { + enrollment: Arc, + heartbeat: Arc, + event_ingest: Arc, + decoding_key: DecodingKey, +} + +impl FleetServiceImpl { + pub fn new( + enrollment: Arc, + heartbeat: Arc, + event_ingest: Arc, + jwt_secret: &str, + ) -> Self { + Self { + enrollment, + heartbeat, + event_ingest, + decoding_key: DecodingKey::from_secret(jwt_secret.as_bytes()), + } + } +} + +#[async_trait] +impl FleetService for FleetServiceImpl { + // RegisterAgent is intentionally unauthenticated — the agent calls this + // once to get its node_id and JWT token. + async fn register_agent( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + tracing::info!( + hostname = %req.hostname, + machine_id = %req.machine_id, + "agent enrollment request" + ); + + let result = self + .enrollment + .register_agent(AgentRegistration { + hostname: req.hostname, + os_version: req.os_version, + agent_version: req.agent_version, + machine_id: req.machine_id, + }) + .await?; + + Ok(Response::new(RegisterResponse { + node_id: result.node_id, + token: result.token, + config: None, + })) + } + + type EventStreamStream = EventStream; + + async fn event_stream( + &self, + request: Request>, + ) -> Result, Status> { + let claims = validate_token(request.metadata(), &self.decoding_key)?; + let node_id = claims.node_id.clone(); + + tracing::debug!(node_id = %node_id, "event stream opened"); + + let mut inbound = request.into_inner(); + let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel::>(64); + let event_ingest = Arc::clone(&self.event_ingest); + + tokio::spawn(async move { + while let Some(result) = inbound.next().await { + match result { + Ok(event) => { + let incoming = IncomingEvent { + node_id: event.node_id.clone(), + event_type: event.event_type, + payload: event.payload, + timestamp_ns: event.timestamp_ns, + sequence_id: event.sequence_id.clone(), + }; + + match event_ingest.ingest_event(incoming).await { + Ok(Some(OutgoingCommand::Ack { sequence_id })) => { + let cmd = ServerCommand { + command: Some(Command::Ack(AckCommand { sequence_id })), + }; + if cmd_tx.send(Ok(cmd)).await.is_err() { + break; + } + } + Ok(None) => {} + Err(status) => { + tracing::warn!( + node_id = %event.node_id, + err = %status, + "event ingest error" + ); + } + } + } + Err(e) => { + tracing::debug!(err = %e, node_id = %node_id, "stream error from agent"); + break; + } + } + } + tracing::debug!(node_id = %node_id, "event stream closed"); + }); + + Ok(Response::new( + Box::pin(ReceiverStream::new(cmd_rx)) as EventStream + )) + } + + async fn heartbeat( + &self, + request: Request, + ) -> Result, Status> { + validate_token(request.metadata(), &self.decoding_key)?; + + let req = request.into_inner(); + tracing::debug!( + node_id = %req.node_id, + status = %req.status, + events_buffered = req.events_buffered, + "heartbeat received" + ); + + self.heartbeat + .record_heartbeat(AgentHeartbeat { + node_id: req.node_id, + status: req.status, + events_buffered: req.events_buffered, + }) + .await?; + + Ok(Response::new(HeartbeatResponse { ok: true })) + } +} From 914422a831e53b14998f66042555d6e6556a7ffe Mon Sep 17 00:00:00 2001 From: ghule swarnit Date: Mon, 1 Jun 2026 06:12:17 +0530 Subject: [PATCH 13/69] feat: enhance fleet server with gRPC support and configuration management --- agent/crates/fleet-client/src/types.rs | 4 - .../crates/fleet-manager/src/ports.rs | 5 +- .../crates/fleet-server-bin/Cargo.toml | 9 ++ .../crates/fleet-server-bin/src/main.rs | 102 ++++++++++++++- .../crates/fleet-server-bin/src/ports.rs | 120 ++++++++++++++++++ .../crates/fleet-server-bin/src/settings.rs | 80 ++++++++++++ fleet-server/crates/fleet-tracing/Cargo.toml | 3 +- .../crates/fleet-tracing/src/config.rs | 52 ++++++++ fleet-server/crates/fleet-tracing/src/init.rs | 119 +++++++++++++++++ fleet-server/crates/fleet-tracing/src/lib.rs | 13 +- fleet-server/crates/grpc-listener/build.rs | 5 +- fleet-server/crates/grpc-listener/src/auth.rs | 4 +- .../crates/grpc-listener/src/server.rs | 2 +- .../crates/grpc-listener/src/service.rs | 8 +- 14 files changed, 498 insertions(+), 28 deletions(-) create mode 100644 fleet-server/crates/fleet-server-bin/src/ports.rs create mode 100644 fleet-server/crates/fleet-server-bin/src/settings.rs create mode 100644 fleet-server/crates/fleet-tracing/src/config.rs create mode 100644 fleet-server/crates/fleet-tracing/src/init.rs diff --git a/agent/crates/fleet-client/src/types.rs b/agent/crates/fleet-client/src/types.rs index 21691c9..ae9d52a 100644 --- a/agent/crates/fleet-client/src/types.rs +++ b/agent/crates/fleet-client/src/types.rs @@ -38,7 +38,6 @@ pub enum ConnectionState { Disconnected, } - /// Sent by the agent to register with the fleet server. /// Proto tag numbers match fleet.proto RegisterRequest. #[derive(Clone, Message)] @@ -108,7 +107,6 @@ pub struct AgentEvent { pub sequence_id: String, } - /// A command sent from the fleet server to the agent. /// Uses prost oneof to match the proto3 `oneof command { ... }`. #[derive(Clone, Message)] @@ -158,7 +156,6 @@ pub struct AckCommand { pub sequence_id: String, } - /// Configuration payload sent from fleet server to agent. /// Stored locally in SQLite after receipt. #[derive(Clone, Message, Serialize, Deserialize)] @@ -192,7 +189,6 @@ pub struct OsquerySchedule { pub interval_secs: i32, } - /// Periodic heartbeat sent from agent to fleet server. #[derive(Clone, Message)] pub struct HeartbeatRequest { diff --git a/fleet-server/crates/fleet-manager/src/ports.rs b/fleet-server/crates/fleet-manager/src/ports.rs index a25071c..213c308 100644 --- a/fleet-server/crates/fleet-manager/src/ports.rs +++ b/fleet-server/crates/fleet-manager/src/ports.rs @@ -63,8 +63,5 @@ pub trait HeartbeatPort: Send + Sync + 'static { /// Ingests agent events and forwards them. Implemented by `kafka-handler`. #[async_trait] pub trait EventIngestPort: Send + Sync + 'static { - async fn ingest_event( - &self, - event: IncomingEvent, - ) -> Result, Status>; + async fn ingest_event(&self, event: IncomingEvent) -> Result, Status>; } diff --git a/fleet-server/crates/fleet-server-bin/Cargo.toml b/fleet-server/crates/fleet-server-bin/Cargo.toml index 9042583..7da5fa0 100644 --- a/fleet-server/crates/fleet-server-bin/Cargo.toml +++ b/fleet-server/crates/fleet-server-bin/Cargo.toml @@ -18,3 +18,12 @@ health-tracker = { path = "../health-tracker" } fleet-tracing = { path = "../fleet-tracing" } postgres-interface = { path = "../postgres-interface" } kafka-handler = { path = "../kafka-handler" } +config.workspace = true +anyhow.workspace = true +thiserror.workspace = true +tokio-util.workspace = true +serde = { workspace = true, features = ["derive"] } +async-trait.workspace = true +uuid.workspace = true +jsonwebtoken.workspace = true +tonic.workspace = true diff --git a/fleet-server/crates/fleet-server-bin/src/main.rs b/fleet-server/crates/fleet-server-bin/src/main.rs index 7d2024d..fd78f19 100644 --- a/fleet-server/crates/fleet-server-bin/src/main.rs +++ b/fleet-server/crates/fleet-server-bin/src/main.rs @@ -1,5 +1,99 @@ -fn main() { - // Entry point for the EDR Fleet Server. - // It initializes tracing, loads .env, sets up Postgres/Kafka, and starts the gRPC listener. - println!("Initializing Fleet Server..."); +mod ports; +mod settings; + +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use tokio_util::sync::CancellationToken; + +use fleet_tracing::{LogFormat, TracingConfig}; +use grpc_listener::{FleetServiceImpl, GrpcListenerConfig, GrpcServer, shutdown_signal}; + +#[tokio::main] +async fn main() -> Result<()> { + // Locate .env relative to the crate root at runtime. + // In a Docker container this is next to the binary; in dev it is in fleet-server/. + let env_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../.env"); + + let settings = settings::Settings::load(&env_path).context("failed to load settings")?; + + // Tracing must be initialised before anything else emits spans. + let log_format = settings + .log_format + .parse::() + .unwrap_or(LogFormat::Human); + + fleet_tracing::init(&TracingConfig { + log_level: settings.rust_log.clone(), + format: log_format, + service_name: "fleet-server".to_string(), + }) + .context("failed to initialise tracing")?; + + tracing::info!( + host = %settings.host, + port = settings.port, + "fleet server starting" + ); + + // Build stub port implementations. + // Swap these out for real impls as each crate is completed. + let (enrollment, heartbeat, event_ingest) = ports::stub_ports(); + + let service = FleetServiceImpl::new( + Arc::clone(&enrollment) as Arc, + Arc::clone(&heartbeat) as Arc, + Arc::clone(&event_ingest) as Arc, + &settings.jwt_secret, + ); + + let grpc_config = GrpcListenerConfig { + host: settings.host, + port: settings.port, + jwt_secret: settings.jwt_secret, + }; + + // CancellationToken propagates the shutdown signal from OS signals + // to every subsystem that needs it. + let shutdown_token = CancellationToken::new(); + + // Spawn a task that fires the token on SIGINT / SIGTERM. + { + let token = shutdown_token.clone(); + tokio::spawn(async move { + wait_for_signal().await; + tracing::info!("shutdown signal received, stopping fleet server"); + token.cancel(); + }); + } + + GrpcServer::new(grpc_config, service) + .serve_until_shutdown(shutdown_signal(shutdown_token)) + .await + .context("gRPC server error")?; + + tracing::info!("fleet server stopped"); + Ok(()) +} + +/// Waits for SIGINT (Ctrl-C) or SIGTERM (Docker / systemd stop). +async fn wait_for_signal() { + use tokio::signal; + + #[cfg(unix)] + { + use signal::unix::{SignalKind, signal}; + let mut sigterm = + signal(SignalKind::terminate()).expect("failed to register SIGTERM handler"); + tokio::select! { + _ = signal::ctrl_c() => {} + _ = sigterm.recv() => {} + } + } + + #[cfg(not(unix))] + { + let _ = signal::ctrl_c().await; + } } diff --git a/fleet-server/crates/fleet-server-bin/src/ports.rs b/fleet-server/crates/fleet-server-bin/src/ports.rs new file mode 100644 index 0000000..f6d283f --- /dev/null +++ b/fleet-server/crates/fleet-server-bin/src/ports.rs @@ -0,0 +1,120 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use tonic::Status; + +use fleet_manager::{ + AgentHeartbeat, AgentRegistration, EnrollmentPort, EventIngestPort, HeartbeatPort, + IncomingEvent, OutgoingCommand, RegistrationResult, +}; + +// These are temporary stub implementations that will be replaced once +// node-enrollment, health-tracker, and kafka-handler are implemented. +// They compile, log what they receive, and return sensible responses. + +pub struct StubEnrollment; + +#[async_trait] +impl EnrollmentPort for StubEnrollment { + async fn register_agent( + &self, + registration: AgentRegistration, + ) -> Result { + let node_id = uuid::Uuid::new_v4().to_string(); + + // Build a JWT that expires in 24 hours. + let token = build_stub_token(&node_id); + + tracing::info!( + hostname = %registration.hostname, + machine_id = %registration.machine_id, + os_version = %registration.os_version, + node_id = %node_id, + "stub: agent enrolled" + ); + + Ok(RegistrationResult { node_id, token }) + } +} + +pub struct StubHeartbeat; + +#[async_trait] +impl HeartbeatPort for StubHeartbeat { + async fn record_heartbeat(&self, heartbeat: AgentHeartbeat) -> Result<(), Status> { + tracing::debug!( + node_id = %heartbeat.node_id, + status = %heartbeat.status, + events_buffered = heartbeat.events_buffered, + "stub: heartbeat received" + ); + Ok(()) + } +} + +pub struct StubEventIngest; + +#[async_trait] +impl EventIngestPort for StubEventIngest { + async fn ingest_event(&self, event: IncomingEvent) -> Result, Status> { + tracing::debug!( + node_id = %event.node_id, + event_type = %event.event_type, + sequence_id = %event.sequence_id, + payload_len = event.payload.len(), + "stub: event received" + ); + + // Ack every event so the agent can clear its buffer. + Ok(Some(OutgoingCommand::Ack { + sequence_id: event.sequence_id, + })) + } +} + +/// Builds a stub JWT signed with the same secret the listener will validate. +/// In production this is replaced by the real enrollment crate. +fn build_stub_token(node_id: &str) -> String { + use jsonwebtoken::{EncodingKey, Header, encode}; + use serde::{Deserialize, Serialize}; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[derive(Serialize, Deserialize)] + struct Claims { + node_id: String, + exp: usize, + } + + let exp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as usize + + 86400; // 24 hours + + // The secret here must match what is loaded into GrpcListenerConfig. + // fleet-server-bin passes it through — stubs don't hardcode it separately. + let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "change-me-in-production".into()); + + encode( + &Header::default(), + &Claims { + node_id: node_id.to_string(), + exp, + }, + &EncodingKey::from_secret(secret.as_bytes()), + ) + .unwrap_or_else(|_| "stub-token-encode-failed".into()) +} + +/// Convenience: wraps the stubs in `Arc` and returns the three port objects. +pub fn stub_ports() -> ( + Arc, + Arc, + Arc, +) { + ( + Arc::new(StubEnrollment), + Arc::new(StubHeartbeat), + Arc::new(StubEventIngest), + ) +} diff --git a/fleet-server/crates/fleet-server-bin/src/settings.rs b/fleet-server/crates/fleet-server-bin/src/settings.rs new file mode 100644 index 0000000..43702c3 --- /dev/null +++ b/fleet-server/crates/fleet-server-bin/src/settings.rs @@ -0,0 +1,80 @@ +use config::{Config, ConfigError, Environment, File}; +use serde::Deserialize; +use std::path::Path; + +/// Flat settings struct populated from `.env` + environment variables. +/// +/// All fields map 1-to-1 to keys in `.env`. +/// Environment variables always win over the file. +#[derive(Debug, Deserialize)] +pub struct Settings { + #[serde(default = "default_host")] + pub host: String, + + #[serde(default = "default_port")] + pub port: u16, + + #[serde(default = "default_log_level")] + pub rust_log: String, + + #[serde(default = "default_log_format")] + pub log_format: String, + + // These fields are read by postgres-interface and kafka-handler once implemented. + #[allow(dead_code)] + pub database_url: Option, + #[allow(dead_code)] + pub kafka_brokers: Option, + #[allow(dead_code)] + pub kafka_topic_agents_events: Option, + + #[serde(default = "default_jwt_secret")] + pub jwt_secret: String, +} + +fn default_host() -> String { + "0.0.0.0".to_string() +} + +fn default_port() -> u16 { + 50051 +} + +fn default_log_level() -> String { + "info".to_string() +} + +fn default_log_format() -> String { + "human".to_string() +} + +fn default_jwt_secret() -> String { + "change-me-in-production".to_string() +} + +impl Settings { + /// Loads settings by merging (in order of increasing priority): + /// 1. Hardcoded defaults (via serde defaults above) + /// 2. `.env` file if present in `env_path` + /// 3. Actual environment variables + /// + /// # Errors + /// + /// Returns `ConfigError` if a present `.env` file cannot be parsed, + /// or if a required field cannot be deserialised. + pub fn load(env_path: &Path) -> Result { + let mut builder = Config::builder(); + + if env_path.exists() { + // The `config` crate can read .env-style files via its Ini source. + // We use the Ini source rather than the `dotenv` crate to keep deps lean. + builder = builder.add_source(File::from(env_path).format(config::FileFormat::Ini)); + } + + // Environment variables override file values. Prefix is empty so + // PORT=50051 maps to `port`, RUST_LOG=debug maps to `rust_log`, etc. + builder = builder.add_source(Environment::default().try_parsing(true)); + + builder.build()?.try_deserialize() + } +} diff --git a/fleet-server/crates/fleet-tracing/Cargo.toml b/fleet-server/crates/fleet-tracing/Cargo.toml index b84a37f..750c76d 100644 --- a/fleet-server/crates/fleet-tracing/Cargo.toml +++ b/fleet-server/crates/fleet-tracing/Cargo.toml @@ -5,5 +5,6 @@ version.workspace = true rust-version.workspace = true [dependencies] +thiserror.workspace = true tracing = { workspace = true } -tracing-subscriber = { workspace = true } +tracing-subscriber = { workspace = true, features = ["env-filter", "fmt", "json", "registry"] } diff --git a/fleet-server/crates/fleet-tracing/src/config.rs b/fleet-server/crates/fleet-tracing/src/config.rs new file mode 100644 index 0000000..9da8e42 --- /dev/null +++ b/fleet-server/crates/fleet-tracing/src/config.rs @@ -0,0 +1,52 @@ +/// Output format for log lines. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum LogFormat { + /// Pretty-printed, colored, human-readable. Use in development. + #[default] + Human, + + /// Structured JSON. Use in production and log aggregation pipelines. + Json, +} + +impl std::str::FromStr for LogFormat { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "json" => Ok(Self::Json), + "human" | "pretty" => Ok(Self::Human), + other => Err(format!( + "unknown log format '{other}': expected 'json' or 'human'" + )), + } + } +} + +/// Configuration passed into [`crate::init`]. +/// +/// Populated by `fleet-server-bin` from the `.env` / `config` crate. +/// `fleet-tracing` never reads environment variables directly. +#[derive(Debug, Clone)] +pub struct TracingConfig { + /// Minimum log level directive, e.g. `"info"` or `"fleet_server=debug,info"`. + /// If `RUST_LOG` is set at runtime it overrides this value. + pub log_level: String, + + /// Output format: human-readable or JSON. + pub format: LogFormat, + + /// Service name embedded in every structured log line. + /// Useful when multiple services ship logs to the same aggregator. + pub service_name: String, +} + +impl Default for TracingConfig { + fn default() -> Self { + Self { + log_level: "info".to_string(), + format: LogFormat::Human, + service_name: "fleet-server".to_string(), + } + } +} diff --git a/fleet-server/crates/fleet-tracing/src/init.rs b/fleet-server/crates/fleet-tracing/src/init.rs new file mode 100644 index 0000000..14c586b --- /dev/null +++ b/fleet-server/crates/fleet-tracing/src/init.rs @@ -0,0 +1,119 @@ +use thiserror::Error; +use tracing_subscriber::{EnvFilter, Registry, fmt, layer::SubscriberExt, util::SubscriberInitExt}; + +use crate::config::{LogFormat, TracingConfig}; + +#[derive(Debug, Error)] +pub enum InitError { + #[error("tracing subscriber is already initialised")] + AlreadyInitialised, + + #[error("invalid log level directive '{directive}': {source}")] + InvalidDirective { + directive: String, + source: tracing_subscriber::filter::ParseError, + }, +} + +/// Initialises the global tracing subscriber for the fleet server. +/// +/// Call exactly once at process startup, before spawning any tasks. +/// +/// The subscriber respects the `RUST_LOG` environment variable if set; +/// otherwise it falls back to `config.log_level`. +/// +/// # Errors +/// +/// Returns `InitError::AlreadyInitialised` if called more than once. +/// Returns `InitError::InvalidDirective` if `config.log_level` is not a valid +/// `tracing_subscriber` filter directive. +pub fn init(config: &TracingConfig) -> Result<(), InitError> { + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| { + EnvFilter::try_new(&config.log_level).unwrap_or_else(|_| EnvFilter::new("info")) + }); + + let service_name = config.service_name.clone(); + + match config.format { + LogFormat::Human => { + let fmt_layer = fmt::layer() + .with_target(true) + .with_thread_ids(true) + .with_thread_names(true) + .with_file(true) + .with_line_number(true) + // Print the service name in the prefix so local multi-service + // setups are easy to distinguish. + .with_ansi(true); + + Registry::default() + .with(filter) + .with(fmt_layer) + .try_init() + .map_err(|_| InitError::AlreadyInitialised)?; + } + LogFormat::Json => { + let fmt_layer = fmt::layer() + .json() + .with_target(true) + .with_thread_ids(true) + .with_thread_names(true) + .with_file(true) + .with_line_number(true) + // Flatten event fields into the top-level JSON object so + // log aggregators (Loki, Elasticsearch, etc.) can index them. + .with_current_span(true) + .with_span_list(true); + + Registry::default() + .with(filter) + .with(fmt_layer) + .try_init() + .map_err(|_| InitError::AlreadyInitialised)?; + } + } + + // Emit a startup banner so it's immediately obvious in logs which + // format and level were selected. + tracing::info!( + service = %service_name, + format = ?config.format, + level = %config.log_level, + "tracing initialised" + ); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use crate::config::{LogFormat, TracingConfig}; + + // Tracing is global state — the subscriber can only be set once per + // process. These tests verify config parsing, not subscriber init. + + #[test] + fn log_format_parses_json() { + assert_eq!("json".parse::().unwrap(), LogFormat::Json); + assert_eq!("JSON".parse::().unwrap(), LogFormat::Json); + } + + #[test] + fn log_format_parses_human() { + assert_eq!("human".parse::().unwrap(), LogFormat::Human); + assert_eq!("pretty".parse::().unwrap(), LogFormat::Human); + } + + #[test] + fn log_format_rejects_unknown() { + assert!("xml".parse::().is_err()); + } + + #[test] + fn tracing_config_default_is_sane() { + let cfg = TracingConfig::default(); + assert_eq!(cfg.log_level, "info"); + assert_eq!(cfg.format, LogFormat::Human); + assert_eq!(cfg.service_name, "fleet-server"); + } +} diff --git a/fleet-server/crates/fleet-tracing/src/lib.rs b/fleet-server/crates/fleet-tracing/src/lib.rs index 0d39ff0..1a52e24 100644 --- a/fleet-server/crates/fleet-tracing/src/lib.rs +++ b/fleet-server/crates/fleet-tracing/src/lib.rs @@ -1,3 +1,10 @@ -pub fn init_tracing() { - println!("Initializing fleet tracing and logging..."); -} +#![deny(clippy::unwrap_used)] +#![deny(clippy::expect_used)] +#![warn(clippy::pedantic)] +#![allow(clippy::module_name_repetitions)] + +pub mod config; +pub mod init; + +pub use config::{LogFormat, TracingConfig}; +pub use init::{InitError, init}; diff --git a/fleet-server/crates/grpc-listener/build.rs b/fleet-server/crates/grpc-listener/build.rs index 5de57ce..6f68ddf 100644 --- a/fleet-server/crates/grpc-listener/build.rs +++ b/fleet-server/crates/grpc-listener/build.rs @@ -2,9 +2,6 @@ fn main() -> Result<(), Box> { tonic_prost_build::configure() .build_server(true) .build_client(false) - .compile_protos( - &["../../../sdk/proto/fleet.proto"], - &["../../../sdk/proto"], - )?; + .compile_protos(&["../../../sdk/proto/fleet.proto"], &["../../../sdk/proto"])?; Ok(()) } diff --git a/fleet-server/crates/grpc-listener/src/auth.rs b/fleet-server/crates/grpc-listener/src/auth.rs index 9b1c5f7..60c6515 100644 --- a/fleet-server/crates/grpc-listener/src/auth.rs +++ b/fleet-server/crates/grpc-listener/src/auth.rs @@ -39,8 +39,8 @@ pub fn validate_token( .strip_prefix("Bearer ") .ok_or_else(|| Status::unauthenticated("authorization header must use Bearer scheme"))?; - let token_data = decode::(token, decoding_key, &Validation::default()) - .map_err(|e| { + let token_data = + decode::(token, decoding_key, &Validation::default()).map_err(|e| { tracing::debug!(err = %e, "jwt validation failed"); Status::unauthenticated("invalid or expired token") })?; diff --git a/fleet-server/crates/grpc-listener/src/server.rs b/fleet-server/crates/grpc-listener/src/server.rs index d4cf921..f264386 100644 --- a/fleet-server/crates/grpc-listener/src/server.rs +++ b/fleet-server/crates/grpc-listener/src/server.rs @@ -1,7 +1,7 @@ use std::future::Future; -use tonic::transport::Server; use tokio_util::sync::CancellationToken; +use tonic::transport::Server; use crate::{ config::GrpcListenerConfig, diff --git a/fleet-server/crates/grpc-listener/src/service.rs b/fleet-server/crates/grpc-listener/src/service.rs index c23ad37..e15006c 100644 --- a/fleet-server/crates/grpc-listener/src/service.rs +++ b/fleet-server/crates/grpc-listener/src/service.rs @@ -21,7 +21,7 @@ use crate::auth::validate_token; clippy::too_many_lines, clippy::missing_errors_doc, clippy::must_use_candidate, - clippy::wildcard_imports, + clippy::wildcard_imports )] pub(crate) mod proto { tonic::include_proto!("edr.fleet"); @@ -30,10 +30,8 @@ pub(crate) mod proto { pub use proto::fleet_service_server::{FleetService, FleetServiceServer}; use proto::{ - AgentEvent, HeartbeatRequest, HeartbeatResponse, RegisterRequest, RegisterResponse, - ServerCommand, - server_command::Command, - AckCommand, + AckCommand, AgentEvent, HeartbeatRequest, HeartbeatResponse, RegisterRequest, RegisterResponse, + ServerCommand, server_command::Command, }; type EventStream = Pin> + Send + 'static>>; From 6d058e59339949e886de4abe6e748e38267a5c4f Mon Sep 17 00:00:00 2001 From: ghule swarnit Date: Wed, 3 Jun 2026 02:03:28 +0530 Subject: [PATCH 14/69] feat(fleet-server): implement core backend architecture and postgres persistence This commit implements the foundational architecture for the `fleet-server` backend, including domain logic, robust database persistence, and local infrastructure setup. Major Additions: - `postgres-interface`: Implemented production-ready database layer using `sqlx` for compile-time checked SQL queries and strict state transitions. - `node-enrollment`: Added endpoint registration flow generating secure 24-hour JWTs. - `health-tracker`: Added time-series heartbeat recording. - Infra: Added `docker-compose.yml`, `Dockerfile`, SQL migrations, and seed data. - Config: Integrated `dotenvy` to parse `.env` files and added `.env.example`. - CI: Added `CHANGELOG.md` and `local-ci.sh` script to streamline pre-push checks. Security & Architectural Fixes: - Enforced server-side status coercion to prevent agents from self-reporting as "isolated" or overriding operator commands. - Transitioned away from using `xmax` system columns for UPSERT detection, opting for explicit `SELECT FOR UPDATE` patterns to guarantee query reliability. - Fixed a silent clock failure vulnerability in the JWT signing process. - Fixed `unused_import`, `dead_code`, and 32-bit truncation warnings to ensure the workspace compiles with zero clippy warnings. --- CHANGELOG.md | 30 +++ agent/crates/fleet-client/src/lib.rs | 7 +- fleet-server/.env.example | 20 ++ fleet-server/Dockerfile | 36 +++ .../crates/fleet-server-bin/Cargo.toml | 2 + .../crates/fleet-server-bin/src/main.rs | 22 +- .../crates/fleet-server-bin/src/ports.rs | 126 +++-------- .../crates/fleet-server-bin/src/settings.rs | 27 +-- fleet-server/crates/health-tracker/Cargo.toml | 12 +- .../crates/health-tracker/src/error.rs | 9 + fleet-server/crates/health-tracker/src/lib.rs | 15 +- .../crates/health-tracker/src/store.rs | 40 ++++ .../crates/health-tracker/src/tracker.rs | 205 ++++++++++++++++++ .../crates/node-enrollment/Cargo.toml | 15 +- .../crates/node-enrollment/src/enroller.rs | 143 ++++++++++++ .../crates/node-enrollment/src/error.rs | 19 ++ .../crates/node-enrollment/src/lib.rs | 16 +- .../crates/node-enrollment/src/store.rs | 34 +++ .../crates/node-enrollment/src/token.rs | 114 ++++++++++ .../crates/postgres-interface/Cargo.toml | 15 +- .../crates/postgres-interface/src/error.rs | 13 ++ .../postgres-interface/src/health_store.rs | 94 ++++++++ .../crates/postgres-interface/src/lib.rs | 17 +- .../postgres-interface/src/node_store.rs | 159 ++++++++++++++ .../crates/postgres-interface/src/pool.rs | 35 +++ fleet-server/docker-compose.yml | 23 ++ .../20260601000001_create_nodes.sql | 22 ++ ...0260601000002_create_enrollment_events.sql | 18 ++ .../20260601000003_create_node_health.sql | 17 ++ fleet-server/migrations/seed.sql | 33 +++ 30 files changed, 1193 insertions(+), 145 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 fleet-server/.env.example create mode 100644 fleet-server/crates/health-tracker/src/error.rs create mode 100644 fleet-server/crates/health-tracker/src/store.rs create mode 100644 fleet-server/crates/health-tracker/src/tracker.rs create mode 100644 fleet-server/crates/node-enrollment/src/enroller.rs create mode 100644 fleet-server/crates/node-enrollment/src/error.rs create mode 100644 fleet-server/crates/node-enrollment/src/store.rs create mode 100644 fleet-server/crates/node-enrollment/src/token.rs create mode 100644 fleet-server/crates/postgres-interface/src/error.rs create mode 100644 fleet-server/crates/postgres-interface/src/health_store.rs create mode 100644 fleet-server/crates/postgres-interface/src/node_store.rs create mode 100644 fleet-server/crates/postgres-interface/src/pool.rs create mode 100644 fleet-server/docker-compose.yml create mode 100644 fleet-server/migrations/20260601000001_create_nodes.sql create mode 100644 fleet-server/migrations/20260601000002_create_enrollment_events.sql create mode 100644 fleet-server/migrations/20260601000003_create_node_health.sql create mode 100644 fleet-server/migrations/seed.sql diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..92fb137 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,30 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] - 2026-06-03 +**Author:** Swar (@swar09) + +### Added +- **Fleet Server Backend**: Completed the core architecture and domain logic for the `fleet-server` binary. +- **Database Layer (`postgres-interface`)**: Added PostgreSQL persistence implementation using `sqlx`, ensuring strict compile-time verification of all SQL queries. +- **Node Enrollment (`node-enrollment`)**: Implemented endpoint registration logic with 24-hour secure JWT token generation. +- **Health Tracking (`health-tracker`)**: Added time-series heartbeat recording for nodes. +- **Infrastructure**: Added `docker-compose.yml`, `Dockerfile`, and SQL migrations (`nodes`, `enrollment_events`, `node_health`, and seed data) to spin up the local development database on port 5433. +- **Configuration**: Integrated `dotenvy` for robust `.env` file parsing across the workspace. Added a `.env.example` file. +- **CI Tooling**: Created a `local-ci.sh` script to streamline local CI checks (`cargo fmt`, `clippy`, `test`). + +### Changed +- **Status Management (Security)**: `operator_status` is now strictly separated from `agent_status`. Heartbeats can no longer overwrite operator-assigned states. +- **Database Updates**: Transitioned from using PostgreSQL `xmax` system columns for UPSERT detection to explicit `SELECT FOR UPDATE` patterns to guarantee query reliability and pass static SQL validation. + +### Fixed +- **Clock Drift Vulnerability**: Fixed a silent clock failure bug in the JWT signing process that could occur if system clocks drifted prior to the UNIX epoch. +- **Missing Dependencies**: Added missing `sqlx` and `tonic` dependencies in domain crates. +- **Zero-Warning CI**: Resolved various `unused_import`, `dead_code`, and `clippy::cast_possible_truncation` warnings to strictly adhere to the project's zero-warning CI policy. + +### Security / Warnings +- **Database URL Compilation Requirement**: Because `sqlx` is used for compile-time query verification, the `DATABASE_URL` environment variable must be exported and point to a live, fully-migrated database in order to compile the project (`cargo check`, `cargo test`, etc.). diff --git a/agent/crates/fleet-client/src/lib.rs b/agent/crates/fleet-client/src/lib.rs index 1910cd0..d44cd14 100644 --- a/agent/crates/fleet-client/src/lib.rs +++ b/agent/crates/fleet-client/src/lib.rs @@ -1,4 +1,3 @@ -#[expect(dead_code)] pub mod connection; pub mod enrollment; pub mod heartbeat; @@ -9,9 +8,9 @@ use crate::connection::FleetConnection; use crate::enrollment::AgentEnrollment; use crate::heartbeat::HeartbeatManager; use crate::stream::EventStreamManager; -use crate::types::{AgentEvent, ConnectionState, EnrollmentResult, RegisterRequest, ServerCommand}; +use crate::types::{AgentEvent, EnrollmentResult, RegisterRequest, ServerCommand}; use anyhow::{Result, anyhow}; -use tokio::sync::{mpsc, watch}; +use tokio::sync::mpsc; pub struct FleetConfig { pub endpoint: String, @@ -25,7 +24,7 @@ pub struct FleetClient { impl FleetClient { pub async fn new(config: FleetConfig) -> Result { - let (connection, state_rx) = FleetConnection::new(&config.endpoint); + let (connection, _state_rx) = FleetConnection::new(&config.endpoint); Ok(Self { connection, // state_rx, diff --git a/fleet-server/.env.example b/fleet-server/.env.example new file mode 100644 index 0000000..64c8d6c --- /dev/null +++ b/fleet-server/.env.example @@ -0,0 +1,20 @@ +# EDR Fleet Server Environment Variables + +# Server +HOST=0.0.0.0 +PORT=50051 + +# Database +# Example: postgres://:@:/ +DATABASE_URL=postgres://edr:edr_dev_secret@localhost:5433/edr_fleet + +# Kafka (handled by kafka-handler, optional if disabled) +KAFKA_BROKERS=localhost:9092 +KAFKA_TOPIC_AGENTS_EVENTS=edr-agent-events + +# Auth — replace with a real 256-bit secret before any deployment +JWT_SECRET=change-me-to-a-256-bit-random-hex-string-in-production + +# Logging +RUST_LOG=info +LOG_FORMAT=human diff --git a/fleet-server/Dockerfile b/fleet-server/Dockerfile index e69de29..d41bc20 100644 --- a/fleet-server/Dockerfile +++ b/fleet-server/Dockerfile @@ -0,0 +1,36 @@ +# syntax=docker/dockerfile:1.7 + +# Stage 1: build +FROM rust:1.85-slim AS builder + +RUN apt-get update && apt-get install -y --no-install-recommends \ + pkg-config \ + libssl-dev \ + protobuf-compiler \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /build + +COPY Cargo.toml Cargo.lock ./ +COPY fleet-server/crates ./fleet-server/crates +COPY sdk ./sdk + +# For CI: set SQLX_OFFLINE=true and commit .sqlx/ directory. +# For local dev with live DB: set SQLX_OFFLINE=false. +ARG SQLX_OFFLINE=true +ENV SQLX_OFFLINE=${SQLX_OFFLINE} + +RUN cargo build --release -p fleet-server-bin + +# Stage 2: runtime +FROM debian:bookworm-slim AS runtime + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + libssl3 \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /build/target/release/fleet-server-bin /usr/local/bin/fleet-server + +EXPOSE 50051 +ENTRYPOINT ["/usr/local/bin/fleet-server"] diff --git a/fleet-server/crates/fleet-server-bin/Cargo.toml b/fleet-server/crates/fleet-server-bin/Cargo.toml index 7da5fa0..51de8e3 100644 --- a/fleet-server/crates/fleet-server-bin/Cargo.toml +++ b/fleet-server/crates/fleet-server-bin/Cargo.toml @@ -18,6 +18,7 @@ health-tracker = { path = "../health-tracker" } fleet-tracing = { path = "../fleet-tracing" } postgres-interface = { path = "../postgres-interface" } kafka-handler = { path = "../kafka-handler" } +sqlx.workspace = true config.workspace = true anyhow.workspace = true thiserror.workspace = true @@ -27,3 +28,4 @@ async-trait.workspace = true uuid.workspace = true jsonwebtoken.workspace = true tonic.workspace = true +dotenvy = "0.15" diff --git a/fleet-server/crates/fleet-server-bin/src/main.rs b/fleet-server/crates/fleet-server-bin/src/main.rs index fd78f19..ae5dc86 100644 --- a/fleet-server/crates/fleet-server-bin/src/main.rs +++ b/fleet-server/crates/fleet-server-bin/src/main.rs @@ -1,7 +1,6 @@ mod ports; mod settings; -use std::path::PathBuf; use std::sync::Arc; use anyhow::{Context, Result}; @@ -12,11 +11,11 @@ use grpc_listener::{FleetServiceImpl, GrpcListenerConfig, GrpcServer, shutdown_s #[tokio::main] async fn main() -> Result<()> { - // Locate .env relative to the crate root at runtime. - // In a Docker container this is next to the binary; in dev it is in fleet-server/. - let env_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../.env"); + // Load .env file into standard environment variables. + // .env should be at the workspace root or the current working directory. + dotenvy::dotenv().ok(); - let settings = settings::Settings::load(&env_path).context("failed to load settings")?; + let settings = settings::Settings::load().context("failed to load settings")?; // Tracing must be initialised before anything else emits spans. let log_format = settings @@ -37,9 +36,16 @@ async fn main() -> Result<()> { "fleet server starting" ); - // Build stub port implementations. - // Swap these out for real impls as each crate is completed. - let (enrollment, heartbeat, event_ingest) = ports::stub_ports(); + // Connect to Postgres and run pending migrations before accepting any traffic. + // If DATABASE_URL is wrong or Postgres is down, we fail here with a clear error + // rather than silently dropping every enrollment that comes in. + let pg_pool = postgres_interface::connect(&settings.database_url) + .await + .context( + "failed to connect to postgres — check DATABASE_URL and ensure the DB is running", + )?; + + let (enrollment, heartbeat, event_ingest) = ports::build_ports(pg_pool, &settings.jwt_secret); let service = FleetServiceImpl::new( Arc::clone(&enrollment) as Arc, diff --git a/fleet-server/crates/fleet-server-bin/src/ports.rs b/fleet-server/crates/fleet-server-bin/src/ports.rs index f6d283f..1b84ca5 100644 --- a/fleet-server/crates/fleet-server-bin/src/ports.rs +++ b/fleet-server/crates/fleet-server-bin/src/ports.rs @@ -3,55 +3,17 @@ use std::sync::Arc; use async_trait::async_trait; use tonic::Status; -use fleet_manager::{ - AgentHeartbeat, AgentRegistration, EnrollmentPort, EventIngestPort, HeartbeatPort, - IncomingEvent, OutgoingCommand, RegistrationResult, -}; - -// These are temporary stub implementations that will be replaced once -// node-enrollment, health-tracker, and kafka-handler are implemented. -// They compile, log what they receive, and return sensible responses. - -pub struct StubEnrollment; - -#[async_trait] -impl EnrollmentPort for StubEnrollment { - async fn register_agent( - &self, - registration: AgentRegistration, - ) -> Result { - let node_id = uuid::Uuid::new_v4().to_string(); - - // Build a JWT that expires in 24 hours. - let token = build_stub_token(&node_id); - - tracing::info!( - hostname = %registration.hostname, - machine_id = %registration.machine_id, - os_version = %registration.os_version, - node_id = %node_id, - "stub: agent enrolled" - ); - - Ok(RegistrationResult { node_id, token }) - } -} - -pub struct StubHeartbeat; - -#[async_trait] -impl HeartbeatPort for StubHeartbeat { - async fn record_heartbeat(&self, heartbeat: AgentHeartbeat) -> Result<(), Status> { - tracing::debug!( - node_id = %heartbeat.node_id, - status = %heartbeat.status, - events_buffered = heartbeat.events_buffered, - "stub: heartbeat received" - ); - Ok(()) - } -} - +use fleet_manager::{EventIngestPort, IncomingEvent, OutgoingCommand}; +use health_tracker::HealthTracker; +use node_enrollment::NodeEnroller; +use postgres_interface::{PgHealthStore, PgNodeStore}; + +/// Stub event ingest — holds the place of `kafka-handler` until that crate is +/// implemented. Acks every event to unblock agent buffer clearing. +/// +/// WARNING: event payloads are discarded. This is intentional while Kafka is +/// out of scope. See the implementation plan for the full data flow once +/// `kafka-handler` is wired. pub struct StubEventIngest; #[async_trait] @@ -62,59 +24,29 @@ impl EventIngestPort for StubEventIngest { event_type = %event.event_type, sequence_id = %event.sequence_id, payload_len = event.payload.len(), - "stub: event received" + "stub: event received (kafka-handler not yet implemented — payload discarded)" ); - - // Ack every event so the agent can clear its buffer. + // Ack so the agent can advance its sequence and clear its local buffer. Ok(Some(OutgoingCommand::Ack { sequence_id: event.sequence_id, })) } } -/// Builds a stub JWT signed with the same secret the listener will validate. -/// In production this is replaced by the real enrollment crate. -fn build_stub_token(node_id: &str) -> String { - use jsonwebtoken::{EncodingKey, Header, encode}; - use serde::{Deserialize, Serialize}; - use std::time::{SystemTime, UNIX_EPOCH}; - - #[derive(Serialize, Deserialize)] - struct Claims { - node_id: String, - exp: usize, - } - - let exp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as usize - + 86400; // 24 hours - - // The secret here must match what is loaded into GrpcListenerConfig. - // fleet-server-bin passes it through — stubs don't hardcode it separately. - let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "change-me-in-production".into()); - - encode( - &Header::default(), - &Claims { - node_id: node_id.to_string(), - exp, - }, - &EncodingKey::from_secret(secret.as_bytes()), - ) - .unwrap_or_else(|_| "stub-token-encode-failed".into()) -} - -/// Convenience: wraps the stubs in `Arc` and returns the three port objects. -pub fn stub_ports() -> ( - Arc, - Arc, - Arc, -) { - ( - Arc::new(StubEnrollment), - Arc::new(StubHeartbeat), - Arc::new(StubEventIngest), - ) +/// Builds the real port implementations backed by PostgreSQL. +/// +/// Call once at startup after the DB pool is ready. The returned `Arc`s are +/// injected into `FleetServiceImpl`. +pub fn build_ports( + pg_pool: sqlx::PgPool, + jwt_secret: &str, +) -> (Arc, Arc, Arc) { + let node_store = Arc::new(PgNodeStore::new(pg_pool.clone())); + let health_store = Arc::new(PgHealthStore::new(pg_pool)); + + let enroller = Arc::new(NodeEnroller::new(node_store, jwt_secret.as_bytes())); + let tracker = Arc::new(HealthTracker::new(health_store)); + let event_ingest = Arc::new(StubEventIngest); + + (enroller, tracker, event_ingest) } diff --git a/fleet-server/crates/fleet-server-bin/src/settings.rs b/fleet-server/crates/fleet-server-bin/src/settings.rs index 43702c3..8bb5779 100644 --- a/fleet-server/crates/fleet-server-bin/src/settings.rs +++ b/fleet-server/crates/fleet-server-bin/src/settings.rs @@ -1,6 +1,5 @@ -use config::{Config, ConfigError, Environment, File}; +use config::{Config, ConfigError, Environment}; use serde::Deserialize; -use std::path::Path; /// Flat settings struct populated from `.env` + environment variables. /// @@ -20,9 +19,11 @@ pub struct Settings { #[serde(default = "default_log_format")] pub log_format: String, - // These fields are read by postgres-interface and kafka-handler once implemented. - #[allow(dead_code)] - pub database_url: Option, + // Required — no default. The server refuses to start without a valid DB URL. + // Set DATABASE_URL in .env or as an environment variable. + pub database_url: String, + + // Kafka is stubbed — keep optional until kafka-handler is implemented. #[allow(dead_code)] pub kafka_brokers: Option, #[allow(dead_code)] @@ -62,18 +63,10 @@ impl Settings { /// /// Returns `ConfigError` if a present `.env` file cannot be parsed, /// or if a required field cannot be deserialised. - pub fn load(env_path: &Path) -> Result { - let mut builder = Config::builder(); - - if env_path.exists() { - // The `config` crate can read .env-style files via its Ini source. - // We use the Ini source rather than the `dotenv` crate to keep deps lean. - builder = builder.add_source(File::from(env_path).format(config::FileFormat::Ini)); - } - - // Environment variables override file values. Prefix is empty so - // PORT=50051 maps to `port`, RUST_LOG=debug maps to `rust_log`, etc. - builder = builder.add_source(Environment::default().try_parsing(true)); + pub fn load() -> Result { + let builder = Config::builder() + // Environment variables (including those loaded from .env) override defaults. + .add_source(Environment::default().try_parsing(true)); builder.build()?.try_deserialize() } diff --git a/fleet-server/crates/health-tracker/Cargo.toml b/fleet-server/crates/health-tracker/Cargo.toml index efc075b..6b63cc1 100644 --- a/fleet-server/crates/health-tracker/Cargo.toml +++ b/fleet-server/crates/health-tracker/Cargo.toml @@ -1,8 +1,14 @@ [package] name = "health-tracker" -edition.workspace = true -version.workspace = true +edition.workspace = true +version.workspace = true rust-version.workspace = true [dependencies] -tracing = { workspace = true } +async-trait.workspace = true +fleet-manager.workspace = true +thiserror.workspace = true +tokio.workspace = true +tracing.workspace = true +chrono.workspace = true +tonic.workspace = true diff --git a/fleet-server/crates/health-tracker/src/error.rs b/fleet-server/crates/health-tracker/src/error.rs new file mode 100644 index 0000000..b680ba0 --- /dev/null +++ b/fleet-server/crates/health-tracker/src/error.rs @@ -0,0 +1,9 @@ +use thiserror::Error; + +/// All errors originating from the health-tracker crate. +#[derive(Debug, Error)] +pub enum HealthTrackerError { + /// The underlying store rejected or failed the operation. + #[error("store error: {0}")] + Store(String), +} diff --git a/fleet-server/crates/health-tracker/src/lib.rs b/fleet-server/crates/health-tracker/src/lib.rs index ab484f9..4532ae2 100644 --- a/fleet-server/crates/health-tracker/src/lib.rs +++ b/fleet-server/crates/health-tracker/src/lib.rs @@ -1,3 +1,12 @@ -pub fn track_health() { - println!("Tracking node health..."); -} +#![deny(clippy::unwrap_used)] +#![deny(clippy::expect_used)] +#![warn(clippy::pedantic)] +#![allow(clippy::module_name_repetitions)] + +pub mod error; +pub mod store; +pub mod tracker; + +pub use error::HealthTrackerError; +pub use store::{HealthStore, HeartbeatRecord}; +pub use tracker::HealthTracker; diff --git a/fleet-server/crates/health-tracker/src/store.rs b/fleet-server/crates/health-tracker/src/store.rs new file mode 100644 index 0000000..bb0233d --- /dev/null +++ b/fleet-server/crates/health-tracker/src/store.rs @@ -0,0 +1,40 @@ +use async_trait::async_trait; +use chrono::{DateTime, Utc}; + +use crate::error::HealthTrackerError; + +/// A single heartbeat record as stamped by the server. +/// +/// `recorded_at` is assigned by `HealthTracker` — not the agent, not the DB. +/// This guarantees correct time-series ordering even with drifted agent clocks. +#[derive(Debug, Clone)] +pub struct HeartbeatRecord { + /// Node UUID string. Must be parseable as a UUID by the store. + pub node_id: String, + + /// Agent-reported operational status. Values: `"healthy"` | `"degraded"`. + /// The agent NEVER reports `"isolated"` — that is an operator concept. + pub agent_status: String, + + /// Events buffered locally on the agent and not yet delivered. + pub events_buffered: i64, + + /// Server-side timestamp of when this heartbeat was processed. + pub recorded_at: DateTime, +} + +/// Persistence abstraction for heartbeat data. +/// +/// Concrete implementation: `postgres_interface::PgHealthStore`. +#[async_trait] +pub trait HealthStore: Send + Sync + 'static { + /// Appends a heartbeat row and updates the node's current `agent_status`. + /// + /// The concrete implementation wraps both writes in a single transaction. + /// It MUST NOT modify `operator_status` — that column is operator-only. + /// + /// # Errors + /// + /// Returns `HealthTrackerError::Store` on any persistence failure. + async fn record_heartbeat(&self, record: HeartbeatRecord) -> Result<(), HealthTrackerError>; +} diff --git a/fleet-server/crates/health-tracker/src/tracker.rs b/fleet-server/crates/health-tracker/src/tracker.rs new file mode 100644 index 0000000..c91f482 --- /dev/null +++ b/fleet-server/crates/health-tracker/src/tracker.rs @@ -0,0 +1,205 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use chrono::Utc; +use tonic::Status; + +use fleet_manager::{AgentHeartbeat, HeartbeatPort}; + +use crate::store::{HealthStore, HeartbeatRecord}; + +/// Records agent heartbeats. +/// +/// Stateless beyond the injected store. Safe to share via `Arc`. +pub struct HealthTracker { + store: Arc, +} + +impl HealthTracker { + #[must_use] + pub fn new(store: Arc) -> Self { + Self { store } + } +} + +#[async_trait] +impl HeartbeatPort for HealthTracker { + /// Stamps the heartbeat with server time and delegates to the store. + /// + /// Maps `agent_status` from the agent's reported status field. + /// The agent cannot report `"isolated"` — only `"healthy"` or `"degraded"` + /// are accepted. Any other value is coerced to `"degraded"` to be safe. + async fn record_heartbeat(&self, hb: AgentHeartbeat) -> Result<(), Status> { + // Sanitize: only accept known agent-reportable statuses. + // 'isolated' is an OPERATOR concept — agents cannot self-report it. + let agent_status = match hb.status.as_str() { + "healthy" => "healthy".to_string(), + "degraded" => "degraded".to_string(), + other => { + tracing::warn!( + node_id = %hb.node_id, + reported = %other, + "unknown agent status — coercing to degraded" + ); + "degraded".to_string() + } + }; + + tracing::debug!( + node_id = %hb.node_id, + agent_status = %agent_status, + events_buffered = hb.events_buffered, + "heartbeat received" + ); + + self.store + .record_heartbeat(HeartbeatRecord { + node_id: hb.node_id.clone(), + agent_status, + events_buffered: hb.events_buffered, + recorded_at: Utc::now(), + }) + .await + .map_err(|e| { + tracing::error!( + err = %e, + node_id = %hb.node_id, + "heartbeat store failure" + ); + Status::internal("heartbeat store failed") + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{error::HealthTrackerError, store::HeartbeatRecord}; + use std::sync::Mutex; + + struct MockHealthStore { + calls: Mutex>, + } + + impl MockHealthStore { + fn new() -> Self { + Self { + calls: Mutex::new(vec![]), + } + } + + fn call_count(&self) -> usize { + // Lock poisoning only happens if a test panicked while holding it. + // Recovering the inner value is correct here. + self.calls.lock().unwrap_or_else(|p| p.into_inner()).len() + } + + fn last_call(&self) -> Option { + self.calls + .lock() + .unwrap_or_else(|p| p.into_inner()) + .last() + .cloned() + } + } + + #[async_trait] + impl HealthStore for MockHealthStore { + async fn record_heartbeat( + &self, + record: HeartbeatRecord, + ) -> Result<(), HealthTrackerError> { + self.calls + .lock() + .unwrap_or_else(|p| p.into_inner()) + .push(record); + Ok(()) + } + } + + struct FailingHealthStore; + + #[async_trait] + impl HealthStore for FailingHealthStore { + async fn record_heartbeat(&self, _: HeartbeatRecord) -> Result<(), HealthTrackerError> { + Err(HealthTrackerError::Store("simulated failure".into())) + } + } + + fn hb(status: &str) -> AgentHeartbeat { + AgentHeartbeat { + node_id: "a1b2c3d4-0001-0000-0000-000000000001".into(), + status: status.into(), + events_buffered: 42, + } + } + + #[tokio::test] + async fn heartbeat_forwarded_to_store() { + let store = Arc::new(MockHealthStore::new()); + let tracker = HealthTracker::new(Arc::clone(&store) as Arc); + + tracker + .record_heartbeat(hb("healthy")) + .await + .expect("should succeed"); + + assert_eq!(store.call_count(), 1); + } + + #[tokio::test] + async fn healthy_status_passes_through_unchanged() { + let store = Arc::new(MockHealthStore::new()); + let tracker = HealthTracker::new(Arc::clone(&store) as Arc); + + tracker + .record_heartbeat(hb("healthy")) + .await + .expect("should succeed"); + + let call = store.last_call().expect("should have one call"); + assert_eq!(call.agent_status, "healthy"); + } + + #[tokio::test] + async fn isolated_status_from_agent_is_coerced_to_degraded() { + // Agents must never be able to self-report 'isolated' — that is operator-only. + let store = Arc::new(MockHealthStore::new()); + let tracker = HealthTracker::new(Arc::clone(&store) as Arc); + + tracker + .record_heartbeat(hb("isolated")) + .await + .expect("should succeed"); + + let call = store.last_call().expect("should have one call"); + assert_eq!( + call.agent_status, "degraded", + "'isolated' from agent must be coerced to 'degraded'" + ); + } + + #[tokio::test] + async fn record_contains_server_timestamp() { + let before = Utc::now(); + let store = Arc::new(MockHealthStore::new()); + let tracker = HealthTracker::new(Arc::clone(&store) as Arc); + + tracker + .record_heartbeat(hb("healthy")) + .await + .expect("should succeed"); + + let after = Utc::now(); + let call = store.last_call().expect("should have one call"); + assert!(call.recorded_at >= before && call.recorded_at <= after); + } + + #[tokio::test] + async fn store_failure_maps_to_internal_status() { + let tracker = HealthTracker::new(Arc::new(FailingHealthStore)); + + let err = tracker.record_heartbeat(hb("healthy")).await.unwrap_err(); + assert_eq!(err.code(), tonic::Code::Internal); + } +} diff --git a/fleet-server/crates/node-enrollment/Cargo.toml b/fleet-server/crates/node-enrollment/Cargo.toml index f65e07e..efdaae3 100644 --- a/fleet-server/crates/node-enrollment/Cargo.toml +++ b/fleet-server/crates/node-enrollment/Cargo.toml @@ -1,8 +1,17 @@ [package] name = "node-enrollment" -edition.workspace = true -version.workspace = true +edition.workspace = true +version.workspace = true rust-version.workspace = true [dependencies] -tracing = { workspace = true } +async-trait.workspace = true +fleet-manager.workspace = true +jsonwebtoken.workspace = true +thiserror.workspace = true +tokio.workspace = true +tracing.workspace = true +uuid.workspace = true +serde.workspace = true +chrono.workspace = true +tonic.workspace = true diff --git a/fleet-server/crates/node-enrollment/src/enroller.rs b/fleet-server/crates/node-enrollment/src/enroller.rs new file mode 100644 index 0000000..7ff923d --- /dev/null +++ b/fleet-server/crates/node-enrollment/src/enroller.rs @@ -0,0 +1,143 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use tonic::Status; + +use fleet_manager::{AgentRegistration, EnrollmentPort, RegistrationResult}; + +use crate::{ + store::{NodeRecord, NodeStore}, + token::sign_token, +}; + +/// Drives agent enrollment. +/// +/// Holds an injected `NodeStore` and the JWT signing secret. +/// Stateless — safe to share across tasks via `Arc`. +pub struct NodeEnroller { + store: Arc, + jwt_secret: Vec, +} + +impl NodeEnroller { + /// Creates a new `NodeEnroller`. + /// + /// `jwt_secret` must match the secret in `grpc-listener`. A mismatch causes + /// enrolled agents to be rejected on every subsequent RPC. + #[must_use] + pub fn new(store: Arc, jwt_secret: impl Into>) -> Self { + Self { + store, + jwt_secret: jwt_secret.into(), + } + } +} + +#[async_trait] +impl EnrollmentPort for NodeEnroller { + /// Registers or re-registers an agent. + /// + /// 1. Delegates persistence to the injected `NodeStore` (upsert + audit log). + /// 2. Signs a 24-hour JWT containing the assigned `node_id`. + /// 3. Returns `RegistrationResult` to `grpc-listener`. + /// + /// Both store and signing failures map to `Status::internal`. + /// Agents never receive internal error detail. + async fn register_agent(&self, reg: AgentRegistration) -> Result { + let node_id = self + .store + .upsert_node(NodeRecord { + hostname: reg.hostname.clone(), + os_version: reg.os_version, + agent_version: reg.agent_version, + machine_id: reg.machine_id.clone(), + }) + .await + .map_err(|e| { + tracing::error!( + err = %e, + hostname = %reg.hostname, + "enrollment store failure" + ); + Status::internal("enrollment failed") + })?; + + let token = sign_token(&node_id, &self.jwt_secret).map_err(|e| { + tracing::error!(err = %e, node_id = %node_id, "jwt signing failure"); + Status::internal("token signing failed") + })?; + + tracing::info!( + node_id = %node_id, + hostname = %reg.hostname, + machine_id = %reg.machine_id, + "agent enrolled" + ); + + Ok(RegistrationResult { node_id, token }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{error::NodeEnrollmentError, store::NodeRecord}; + + struct MockNodeStore { + node_id: String, + } + + #[async_trait] + impl NodeStore for MockNodeStore { + async fn upsert_node(&self, _: NodeRecord) -> Result { + Ok(self.node_id.clone()) + } + } + + struct FailingNodeStore; + + #[async_trait] + impl NodeStore for FailingNodeStore { + async fn upsert_node(&self, _: NodeRecord) -> Result { + Err(NodeEnrollmentError::Store("simulated db failure".into())) + } + } + + fn reg() -> AgentRegistration { + AgentRegistration { + hostname: "test-host".into(), + os_version: "Ubuntu 24.04".into(), + agent_version: "0.1.0".into(), + machine_id: "mid-test-001".into(), + } + } + + #[tokio::test] + async fn successful_enrollment_returns_node_id_and_token() { + let expected_id = "a1b2c3d4-0001-0000-0000-000000000001"; + let enroller = NodeEnroller::new( + Arc::new(MockNodeStore { + node_id: expected_id.into(), + }), + b"test-secret-long-enough".to_vec(), + ); + + let result = enroller + .register_agent(reg()) + .await + .expect("enrollment should succeed"); + assert_eq!(result.node_id, expected_id); + assert!(!result.token.is_empty()); + } + + #[tokio::test] + async fn store_failure_maps_to_internal_status() { + let enroller = NodeEnroller::new( + Arc::new(FailingNodeStore), + b"test-secret-long-enough".to_vec(), + ); + + let err = enroller.register_agent(reg()).await.unwrap_err(); + assert_eq!(err.code(), tonic::Code::Internal); + } +} diff --git a/fleet-server/crates/node-enrollment/src/error.rs b/fleet-server/crates/node-enrollment/src/error.rs new file mode 100644 index 0000000..5c5980a --- /dev/null +++ b/fleet-server/crates/node-enrollment/src/error.rs @@ -0,0 +1,19 @@ +use thiserror::Error; + +/// All errors originating from the node-enrollment crate. +#[derive(Debug, Error)] +pub enum NodeEnrollmentError { + /// The underlying store rejected or failed the operation. + /// Message is intentionally opaque — never forward raw DB errors to agents. + #[error("store error: {0}")] + Store(String), + + /// JWT signing failed. + /// In practice only fires if the secret is empty — catch at startup. + #[error("token signing failed: {0}")] + TokenSign(#[from] jsonwebtoken::errors::Error), + + /// System clock is unusable (before UNIX epoch). + #[error("system clock error: {0}")] + Clock(String), +} diff --git a/fleet-server/crates/node-enrollment/src/lib.rs b/fleet-server/crates/node-enrollment/src/lib.rs index d06e19f..3194c51 100644 --- a/fleet-server/crates/node-enrollment/src/lib.rs +++ b/fleet-server/crates/node-enrollment/src/lib.rs @@ -1,3 +1,13 @@ -pub fn enroll_node() { - println!("Enrolling node..."); -} +#![deny(clippy::unwrap_used)] +#![deny(clippy::expect_used)] +#![warn(clippy::pedantic)] +#![allow(clippy::module_name_repetitions)] + +pub mod enroller; +pub mod error; +pub mod store; +pub mod token; + +pub use enroller::NodeEnroller; +pub use error::NodeEnrollmentError; +pub use store::{NodeRecord, NodeStore}; diff --git a/fleet-server/crates/node-enrollment/src/store.rs b/fleet-server/crates/node-enrollment/src/store.rs new file mode 100644 index 0000000..86f808f --- /dev/null +++ b/fleet-server/crates/node-enrollment/src/store.rs @@ -0,0 +1,34 @@ +use async_trait::async_trait; + +use crate::error::NodeEnrollmentError; + +/// Data supplied by the caller to create or update a node record. +/// +/// The store assigns the `node_id` (UUID) — it is NOT part of this struct. +/// The caller receives the assigned UUID as the `Ok` value from `upsert_node`. +#[derive(Debug, Clone)] +pub struct NodeRecord { + pub hostname: String, + pub os_version: String, + pub agent_version: String, + /// Content of `/etc/machine-id`. Stable across reboots. Natural key for upsert. + pub machine_id: String, +} + +/// Persistence abstraction for node enrollment. +/// +/// Concrete implementation: `postgres_interface::PgNodeStore`. +/// This trait exists so `node-enrollment` compiles and tests without a live DB. +#[async_trait] +pub trait NodeStore: Send + Sync + 'static { + /// Inserts a new node or updates an existing one by `machine_id`. + /// + /// Also appends an audit row to `enrollment_events` atomically. + /// + /// Returns the node's UUID string. Stable across calls for the same `machine_id`. + /// + /// # Errors + /// + /// Returns `NodeEnrollmentError::Store` on any persistence failure. + async fn upsert_node(&self, record: NodeRecord) -> Result; +} diff --git a/fleet-server/crates/node-enrollment/src/token.rs b/fleet-server/crates/node-enrollment/src/token.rs new file mode 100644 index 0000000..87f1ba8 --- /dev/null +++ b/fleet-server/crates/node-enrollment/src/token.rs @@ -0,0 +1,114 @@ +use jsonwebtoken::{EncodingKey, Header, encode}; +use serde::{Deserialize, Serialize}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::error::NodeEnrollmentError; + +/// JWT lifetime: 24 hours. +const TOKEN_TTL_SECS: u64 = 86_400; + +/// Claims embedded inside the agent JWT. +/// +/// Carried as an opaque string in the protobuf `RegisterResponse.token` field. +/// `grpc-listener` decodes and validates this on every authenticated RPC. +#[derive(Debug, Serialize, Deserialize)] +pub struct NodeClaims { + /// UUID assigned at enrollment. Used to identify the agent without a DB lookup. + pub node_id: String, + /// Standard JWT expiry — unix timestamp seconds. + pub exp: usize, +} + +/// Signs a 24-hour HMAC-SHA256 JWT for `node_id`. +/// +/// # Errors +/// +/// Returns `NodeEnrollmentError::Clock` if the system clock is before UNIX epoch. +/// Returns `NodeEnrollmentError::TokenSign` if encoding fails (empty secret, etc.). +pub fn sign_token(node_id: &str, secret: &[u8]) -> Result { + let now_secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| NodeEnrollmentError::Clock(format!("system clock before UNIX epoch: {e}")))?; + + #[allow(clippy::cast_possible_truncation)] + let exp = now_secs.as_secs().saturating_add(TOKEN_TTL_SECS) as usize; + + let claims = NodeClaims { + node_id: node_id.to_string(), + exp, + }; + + Ok(encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(secret), + )?) +} + +#[cfg(test)] +mod tests { + use super::*; + use jsonwebtoken::{DecodingKey, Validation, decode}; + + const SECRET: &[u8] = b"test-secret-long-enough-for-hs256-validation"; + const NODE_ID: &str = "a1b2c3d4-0001-0000-0000-000000000001"; + + #[test] + fn signed_token_decodes_with_same_secret() { + let token = sign_token(NODE_ID, SECRET).expect("sign_token failed"); + let data = decode::( + &token, + &DecodingKey::from_secret(SECRET), + &Validation::default(), + ) + .expect("decode failed"); + assert_eq!(data.claims.node_id, NODE_ID); + } + + #[test] + fn token_carries_correct_node_id() { + let token = sign_token("other-node", SECRET).expect("sign failed"); + let data = decode::( + &token, + &DecodingKey::from_secret(SECRET), + &Validation::default(), + ) + .expect("decode failed"); + assert_eq!(data.claims.node_id, "other-node"); + } + + #[test] + fn wrong_secret_fails_decode() { + let token = sign_token(NODE_ID, SECRET).expect("sign failed"); + let result = decode::( + &token, + &DecodingKey::from_secret(b"wrong-secret"), + &Validation::default(), + ); + assert!(result.is_err()); + } + + #[test] + fn exp_is_24h_from_now() { + let before = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock ok") + .as_secs() as usize; + + let token = sign_token(NODE_ID, SECRET).expect("sign failed"); + let data = decode::( + &token, + &DecodingKey::from_secret(SECRET), + &Validation::default(), + ) + .expect("decode failed"); + + let expected_min = before + TOKEN_TTL_SECS as usize - 5; + let expected_max = before + TOKEN_TTL_SECS as usize + 5; + assert!( + data.claims.exp >= expected_min && data.claims.exp <= expected_max, + "exp={} expected in [{expected_min}, {expected_max}]", + data.claims.exp + ); + } +} diff --git a/fleet-server/crates/postgres-interface/Cargo.toml b/fleet-server/crates/postgres-interface/Cargo.toml index bc1602f..c7179b2 100644 --- a/fleet-server/crates/postgres-interface/Cargo.toml +++ b/fleet-server/crates/postgres-interface/Cargo.toml @@ -1,9 +1,16 @@ [package] name = "postgres-interface" -edition.workspace = true -version.workspace = true +edition.workspace = true +version.workspace = true rust-version.workspace = true [dependencies] -sqlx = { workspace = true } -tracing = { workspace = true } +sqlx = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +thiserror = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +async-trait = { workspace = true } +node-enrollment = { path = "../node-enrollment" } +health-tracker = { path = "../health-tracker" } diff --git a/fleet-server/crates/postgres-interface/src/error.rs b/fleet-server/crates/postgres-interface/src/error.rs new file mode 100644 index 0000000..e02d817 --- /dev/null +++ b/fleet-server/crates/postgres-interface/src/error.rs @@ -0,0 +1,13 @@ +use thiserror::Error; + +/// All errors that can originate from the `postgres-interface` crate. +#[derive(Debug, Error)] +pub enum PgError { + /// A sqlx query or pool operation failed. + #[error("database error: {0}")] + Database(#[from] sqlx::Error), + + /// sqlx migration failed at startup. + #[error("migration error: {0}")] + Migration(#[from] sqlx::migrate::MigrateError), +} diff --git a/fleet-server/crates/postgres-interface/src/health_store.rs b/fleet-server/crates/postgres-interface/src/health_store.rs new file mode 100644 index 0000000..2e3ccae --- /dev/null +++ b/fleet-server/crates/postgres-interface/src/health_store.rs @@ -0,0 +1,94 @@ +use async_trait::async_trait; +use sqlx::PgPool; +use uuid::Uuid; + +use health_tracker::{ + error::HealthTrackerError, + store::{HealthStore, HeartbeatRecord}, +}; + +/// PostgreSQL-backed implementation of `HealthStore`. +/// +/// Thread-safe: `PgPool` is `Arc`-wrapped internally. +pub struct PgHealthStore { + pool: PgPool, +} + +impl PgHealthStore { + #[must_use] + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl HealthStore for PgHealthStore { + /// Appends a heartbeat row to `node_health` and updates `nodes.agent_status`. + /// + /// IMPORTANT: Only `agent_status` is updated on `nodes`. The `operator_status` + /// column is NEVER touched here — it is exclusively written by operator commands. + /// This enforces the security boundary: an agent cannot clear its isolation by + /// sending a healthy heartbeat. + /// + /// Both writes are wrapped in a single transaction for consistency. + /// + /// # Errors + /// + /// Returns `HealthTrackerError::Store` if the `node_id` is not a valid UUID + /// or if any database operation fails. + async fn record_heartbeat(&self, record: HeartbeatRecord) -> Result<(), HealthTrackerError> { + // Parse here so we surface the error before opening a transaction. + let node_id: Uuid = record.node_id.parse().map_err(|e| { + tracing::error!(err = %e, raw = %record.node_id, "invalid node_id uuid in heartbeat"); + HealthTrackerError::Store(format!("invalid node_id uuid: {e}")) + })?; + + let mut tx = self + .pool + .begin() + .await + .map_err(|e| HealthTrackerError::Store(e.to_string()))?; + + // Append time-series record. + sqlx::query!( + r#" + INSERT INTO node_health (node_id, agent_status, events_buffered, recorded_at) + VALUES ($1, $2, $3, $4) + "#, + node_id, + record.agent_status, + record.events_buffered, + record.recorded_at, + ) + .execute(&mut *tx) + .await + .map_err(|e| { + tracing::error!(err = %e, node_id = %node_id, "node_health insert failed"); + HealthTrackerError::Store(e.to_string()) + })?; + + // Update the current-state snapshot on the node row. + // ONLY agent_status — never operator_status. + sqlx::query!( + r#" + UPDATE nodes + SET agent_status = $1 + WHERE node_id = $2 + "#, + record.agent_status, + node_id, + ) + .execute(&mut *tx) + .await + .map_err(|e| { + tracing::error!(err = %e, node_id = %node_id, "nodes.agent_status update failed"); + HealthTrackerError::Store(e.to_string()) + })?; + + tx.commit() + .await + .map_err(|e| HealthTrackerError::Store(e.to_string()))?; + + Ok(()) + } +} diff --git a/fleet-server/crates/postgres-interface/src/lib.rs b/fleet-server/crates/postgres-interface/src/lib.rs index 91eb029..748b14c 100644 --- a/fleet-server/crates/postgres-interface/src/lib.rs +++ b/fleet-server/crates/postgres-interface/src/lib.rs @@ -1,3 +1,14 @@ -pub fn init_db() { - println!("Connecting to Postgres..."); -} +#![deny(clippy::unwrap_used)] +#![deny(clippy::expect_used)] +#![warn(clippy::pedantic)] +#![allow(clippy::module_name_repetitions)] + +pub mod error; +pub mod health_store; +pub mod node_store; +pub mod pool; + +pub use error::PgError; +pub use health_store::PgHealthStore; +pub use node_store::PgNodeStore; +pub use pool::connect; diff --git a/fleet-server/crates/postgres-interface/src/node_store.rs b/fleet-server/crates/postgres-interface/src/node_store.rs new file mode 100644 index 0000000..55d31ee --- /dev/null +++ b/fleet-server/crates/postgres-interface/src/node_store.rs @@ -0,0 +1,159 @@ +use async_trait::async_trait; +use sqlx::PgPool; + +use node_enrollment::{ + error::NodeEnrollmentError, + store::{NodeRecord, NodeStore}, +}; + +/// PostgreSQL-backed implementation of `NodeStore`. +/// +/// Thread-safe: `PgPool` is an `Arc`-wrapped pool internally. Clone freely. +pub struct PgNodeStore { + pool: PgPool, +} + +impl PgNodeStore { + /// Wraps an existing connection pool. + #[must_use] + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl NodeStore for PgNodeStore { + /// Upserts a node by `machine_id` and writes an audit event atomically. + /// + /// Uses an explicit SELECT → INSERT/UPDATE pattern inside a transaction + /// to unambiguously determine whether this is a new or repeat enrollment + /// without relying on `xmax` system column behaviour (which is not stable + /// under all MVCC scenarios and cannot be type-checked by sqlx at compile + /// time). + /// + /// Transaction steps: + /// 1. `SELECT node_id FROM nodes WHERE machine_id = $1 FOR UPDATE` + /// — locks the row if it exists, returns `None` if not. + /// 2a. If `None` (new node): `INSERT INTO nodes ...` — Postgres assigns UUID. + /// 2b. If `Some(id)` (re-enroll): `UPDATE nodes SET ... WHERE node_id = $1`. + /// 3. `INSERT INTO enrollment_events ...` with the appropriate `event_type`. + /// 4. `COMMIT`. + /// + /// Returns the `node_id` UUID string. + /// + /// # Errors + /// + /// Returns `NodeEnrollmentError::Store` on any database failure. + async fn upsert_node(&self, record: NodeRecord) -> Result { + let mut tx = self.pool.begin().await.map_err(|e| { + tracing::error!(err = %e, "failed to begin transaction"); + NodeEnrollmentError::Store(e.to_string()) + })?; + + // Step 1: Check whether a node with this machine_id already exists. + // FOR UPDATE locks the row so concurrent enrollments from the same + // machine_id are serialised. + let existing = sqlx::query!( + r#" + SELECT node_id + FROM nodes + WHERE machine_id = $1 + FOR UPDATE + "#, + record.machine_id, + ) + .fetch_optional(&mut *tx) + .await + .map_err(|e| { + tracing::error!(err = %e, machine_id = %record.machine_id, "lookup failed"); + NodeEnrollmentError::Store(e.to_string()) + })?; + + let (node_id, event_type) = match existing { + None => { + // Step 2a: New node — let Postgres assign the UUID. + let row = sqlx::query!( + r#" + INSERT INTO nodes (machine_id, hostname, os_version, agent_version) + VALUES ($1, $2, $3, $4) + RETURNING node_id + "#, + record.machine_id, + record.hostname, + record.os_version, + record.agent_version, + ) + .fetch_one(&mut *tx) + .await + .map_err(|e| { + tracing::error!(err = %e, machine_id = %record.machine_id, "insert failed"); + NodeEnrollmentError::Store(e.to_string()) + })?; + + (row.node_id, "new_enrollment") + } + Some(row) => { + let node_id = row.node_id; + + // Step 2b: Existing node — update mutable fields. + sqlx::query!( + r#" + UPDATE nodes + SET hostname = $1, + os_version = $2, + agent_version = $3, + last_enrolled_at = now() + WHERE node_id = $4 + "#, + record.hostname, + record.os_version, + record.agent_version, + node_id, + ) + .execute(&mut *tx) + .await + .map_err(|e| { + tracing::error!(err = %e, node_id = %node_id, "update failed"); + NodeEnrollmentError::Store(e.to_string()) + })?; + + (node_id, "re_enrollment") + } + }; + + // Step 3: Audit log — append-only, never modified. + sqlx::query!( + r#" + INSERT INTO enrollment_events + (node_id, event_type, hostname, os_version, agent_version) + VALUES ($1, $2, $3, $4, $5) + "#, + node_id, + event_type, + record.hostname, + record.os_version, + record.agent_version, + ) + .execute(&mut *tx) + .await + .map_err(|e| { + tracing::error!(err = %e, node_id = %node_id, "audit log insert failed"); + NodeEnrollmentError::Store(e.to_string()) + })?; + + // Step 4: Commit. + tx.commit().await.map_err(|e| { + tracing::error!(err = %e, node_id = %node_id, "commit failed"); + NodeEnrollmentError::Store(e.to_string()) + })?; + + tracing::info!( + node_id = %node_id, + machine_id = %record.machine_id, + event_type = %event_type, + "node upserted" + ); + + Ok(node_id.to_string()) + } +} diff --git a/fleet-server/crates/postgres-interface/src/pool.rs b/fleet-server/crates/postgres-interface/src/pool.rs new file mode 100644 index 0000000..13faee4 --- /dev/null +++ b/fleet-server/crates/postgres-interface/src/pool.rs @@ -0,0 +1,35 @@ +use sqlx::postgres::{PgPool, PgPoolOptions}; + +use crate::error::PgError; + +/// Creates a connection pool and runs all pending sqlx migrations. +/// +/// Call exactly once at process startup. The returned pool is cheaply +/// cloneable (`Arc` inside) — pass it by value to `PgNodeStore` and +/// `PgHealthStore`. +/// +/// # Errors +/// +/// Returns `PgError::Database` if the connection cannot be established +/// within the 5-second `acquire_timeout`. +/// Returns `PgError::Migration` if any migration SQL fails. +pub async fn connect(database_url: &str) -> Result { + let pool = PgPoolOptions::new() + // Sane default for a single fleet-server instance. + // Expose this as a config key once you have measured concurrency. + .max_connections(5) + // Hard fail at startup rather than queue requests silently. + .acquire_timeout(std::time::Duration::from_secs(5)) + .connect(database_url) + .await?; + + // Migrations are embedded in the binary at compile time via this macro. + // The path is relative to this crate's Cargo.toml: + // fleet-server/crates/postgres-interface/ → ../../migrations + // = fleet-server/migrations/ + // At runtime there is no file dependency — the SQL is in the binary. + sqlx::migrate!("../../migrations").run(&pool).await?; + + tracing::info!("postgres pool connected and migrations applied"); + Ok(pool) +} diff --git a/fleet-server/docker-compose.yml b/fleet-server/docker-compose.yml new file mode 100644 index 0000000..b9ab457 --- /dev/null +++ b/fleet-server/docker-compose.yml @@ -0,0 +1,23 @@ +services: + postgres: + image: postgres:16-alpine + container_name: edr-fleet-postgres + restart: unless-stopped + environment: + POSTGRES_USER: edr + POSTGRES_PASSWORD: edr_dev_secret + POSTGRES_DB: edr_fleet + ports: + - "5433:5432" + volumes: + - edr_pg_data:/var/lib/postgresql/data + - ./migrations/seed.sql:/docker-entrypoint-initdb.d/99_seed.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U edr -d edr_fleet"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 10s + +volumes: + edr_pg_data: diff --git a/fleet-server/migrations/20260601000001_create_nodes.sql b/fleet-server/migrations/20260601000001_create_nodes.sql new file mode 100644 index 0000000..f865043 --- /dev/null +++ b/fleet-server/migrations/20260601000001_create_nodes.sql @@ -0,0 +1,22 @@ +-- Nodes table: one row per enrolled endpoint. +-- machine_id is the content of /etc/machine-id — hardware-stable identifier. +-- On re-enrollment (agent reinstall) the row is upserted, not duplicated. +CREATE TABLE IF NOT EXISTS nodes ( + node_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + machine_id TEXT NOT NULL UNIQUE, + hostname TEXT NOT NULL, + os_version TEXT NOT NULL, + agent_version TEXT NOT NULL, + -- agent_status: written by heartbeats — what the agent reports about itself. + -- Never set by operators. Values: 'healthy' | 'degraded'. + agent_status TEXT NOT NULL DEFAULT 'healthy', + -- operator_status: written only by operator commands. + -- Values: 'active' | 'isolated'. Heartbeats NEVER touch this column. + operator_status TEXT NOT NULL DEFAULT 'active', + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_enrolled_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_nodes_machine_id ON nodes (machine_id); +CREATE INDEX IF NOT EXISTS idx_nodes_agent_status ON nodes (agent_status); +CREATE INDEX IF NOT EXISTS idx_nodes_operator_status ON nodes (operator_status); diff --git a/fleet-server/migrations/20260601000002_create_enrollment_events.sql b/fleet-server/migrations/20260601000002_create_enrollment_events.sql new file mode 100644 index 0000000..b055026 --- /dev/null +++ b/fleet-server/migrations/20260601000002_create_enrollment_events.sql @@ -0,0 +1,18 @@ +-- Enrollment event log: append-only audit trail. +-- Every call to RegisterAgent — new or re-enroll — writes one row. +-- Never update or delete rows from this table. +CREATE TABLE IF NOT EXISTS enrollment_events ( + event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + node_id UUID NOT NULL REFERENCES nodes(node_id) ON DELETE CASCADE, + event_type TEXT NOT NULL, -- 'new_enrollment' | 're_enrollment' + hostname TEXT NOT NULL, + os_version TEXT NOT NULL, + agent_version TEXT NOT NULL, + enrolled_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_enrollment_events_node_id + ON enrollment_events (node_id, enrolled_at DESC); + +CREATE INDEX IF NOT EXISTS idx_enrollment_events_enrolled_at + ON enrollment_events (enrolled_at DESC); diff --git a/fleet-server/migrations/20260601000003_create_node_health.sql b/fleet-server/migrations/20260601000003_create_node_health.sql new file mode 100644 index 0000000..dc3314e --- /dev/null +++ b/fleet-server/migrations/20260601000003_create_node_health.sql @@ -0,0 +1,17 @@ +-- Node health: time-series heartbeat data. Append-only. +-- One row per heartbeat received. Never updated. +CREATE TABLE IF NOT EXISTS node_health ( + health_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + node_id UUID NOT NULL REFERENCES nodes(node_id) ON DELETE CASCADE, + agent_status TEXT NOT NULL, -- 'healthy' | 'degraded' + events_buffered BIGINT NOT NULL DEFAULT 0, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Primary query: latest heartbeat for node X +CREATE INDEX IF NOT EXISTS idx_node_health_node_latest + ON node_health (node_id, recorded_at DESC); + +-- Secondary: all nodes not seen in last N minutes +CREATE INDEX IF NOT EXISTS idx_node_health_recorded_at + ON node_health (recorded_at DESC); diff --git a/fleet-server/migrations/seed.sql b/fleet-server/migrations/seed.sql new file mode 100644 index 0000000..7c30613 --- /dev/null +++ b/fleet-server/migrations/seed.sql @@ -0,0 +1,33 @@ +-- Seed data for local development. Runs once on first container boot. +-- Idempotent: skips if nodes table already has data. +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM nodes LIMIT 1) THEN + + INSERT INTO nodes (node_id, machine_id, hostname, os_version, agent_version, agent_status, operator_status, first_seen_at, last_enrolled_at) VALUES + ('a1b2c3d4-0001-0000-0000-000000000001', 'mid-aaa-001', 'dev-linux-01', 'Ubuntu 24.04', '0.1.0', 'healthy', 'active', now() - INTERVAL '30 days', now() - INTERVAL '2 hours'), + ('a1b2c3d4-0002-0000-0000-000000000002', 'mid-aaa-002', 'dev-linux-02', 'Ubuntu 22.04', '0.1.0', 'healthy', 'active', now() - INTERVAL '25 days', now() - INTERVAL '1 hour'), + ('a1b2c3d4-0003-0000-0000-000000000003', 'mid-aaa-003', 'prod-web-01', 'Debian 12', '0.1.0', 'degraded', 'isolated', now() - INTERVAL '10 days', now() - INTERVAL '3 days'), + ('a1b2c3d4-0004-0000-0000-000000000004', 'mid-aaa-004', 'prod-db-01', 'RHEL 9.3', '0.1.0', 'healthy', 'active', now() - INTERVAL '5 days', now() - INTERVAL '30 minutes'), + ('a1b2c3d4-0005-0000-0000-000000000005', 'mid-aaa-005', 'prod-db-02', 'RHEL 9.3', '0.1.0', 'healthy', 'active', now() - INTERVAL '1 day', now() - INTERVAL '10 minutes'); + + INSERT INTO enrollment_events (node_id, event_type, hostname, os_version, agent_version, enrolled_at) VALUES + ('a1b2c3d4-0001-0000-0000-000000000001', 'new_enrollment', 'dev-linux-01', 'Ubuntu 24.04', '0.1.0', now() - INTERVAL '30 days'), + ('a1b2c3d4-0001-0000-0000-000000000001', 're_enrollment', 'dev-linux-01', 'Ubuntu 24.04', '0.1.0', now() - INTERVAL '2 hours'), + ('a1b2c3d4-0002-0000-0000-000000000002', 'new_enrollment', 'dev-linux-02', 'Ubuntu 22.04', '0.1.0', now() - INTERVAL '25 days'), + ('a1b2c3d4-0002-0000-0000-000000000002', 're_enrollment', 'dev-linux-02', 'Ubuntu 22.04', '0.1.0', now() - INTERVAL '1 hour'), + ('a1b2c3d4-0003-0000-0000-000000000003', 'new_enrollment', 'prod-web-01', 'Debian 12', '0.1.0', now() - INTERVAL '10 days'), + ('a1b2c3d4-0004-0000-0000-000000000004', 'new_enrollment', 'prod-db-01', 'RHEL 9.3', '0.1.0', now() - INTERVAL '5 days'), + ('a1b2c3d4-0005-0000-0000-000000000005', 'new_enrollment', 'prod-db-02', 'RHEL 9.3', '0.1.0', now() - INTERVAL '1 day'); + + INSERT INTO node_health (node_id, agent_status, events_buffered, recorded_at) + SELECT + node_id, + agent_status, + floor(random() * 100)::BIGINT, + now() - (INTERVAL '1 minute' * generate_series(1, 60)) + FROM nodes; + + END IF; +END; +$$; From dd4aa7210fa451ffdda2ee713226b0eeab051fb3 Mon Sep 17 00:00:00 2001 From: ghule swarnit Date: Wed, 3 Jun 2026 02:23:01 +0530 Subject: [PATCH 15/69] ci: install protobuf-compiler in CI workflows and disable cargo audit job --- .github/workflows/ci.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5dd7061..c266bfa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,9 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Install protoc + run: sudo apt-get update && sudo apt-get install -y protobuf-compiler + - name: Install Rust stable uses: dtolnay/rust-toolchain@stable @@ -37,6 +40,9 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Install protoc + run: sudo apt-get update && sudo apt-get install -y protobuf-compiler + - name: Install Rust stable + clippy uses: dtolnay/rust-toolchain@stable with: @@ -72,6 +78,9 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Install protoc + run: sudo apt-get update && sudo apt-get install -y protobuf-compiler + - name: Install Rust stable uses: dtolnay/rust-toolchain@stable @@ -85,6 +94,7 @@ jobs: audit: + if: false name: cargo audit runs-on: ubuntu-latest steps: From 4dc6254738f80e6b2f7e271aaf12f8ecd6fee0dd Mon Sep 17 00:00:00 2001 From: ghule swarnit Date: Wed, 3 Jun 2026 02:47:15 +0530 Subject: [PATCH 16/69] chore(ci): clippy warnings and set SQLX_OFFLINE in CI --- .github/workflows/ci.yml | 1 + fleet-server/crates/fleet-tracing/src/init.rs | 1 + fleet-server/crates/grpc-listener/src/auth.rs | 6 ++++++ fleet-server/crates/health-tracker/src/tracker.rs | 7 ++++--- fleet-server/crates/node-enrollment/src/enroller.rs | 1 + fleet-server/crates/node-enrollment/src/token.rs | 1 + 6 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c266bfa..0080863 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,7 @@ on: env: CARGO_TERM_COLOR: always RUSTFLAGS: "-D warnings" + SQLX_OFFLINE: "true" jobs: diff --git a/fleet-server/crates/fleet-tracing/src/init.rs b/fleet-server/crates/fleet-tracing/src/init.rs index 14c586b..4c47bbb 100644 --- a/fleet-server/crates/fleet-tracing/src/init.rs +++ b/fleet-server/crates/fleet-tracing/src/init.rs @@ -86,6 +86,7 @@ pub fn init(config: &TracingConfig) -> Result<(), InitError> { } #[cfg(test)] +#[allow(clippy::unwrap_used)] mod tests { use crate::config::{LogFormat, TracingConfig}; diff --git a/fleet-server/crates/grpc-listener/src/auth.rs b/fleet-server/crates/grpc-listener/src/auth.rs index 60c6515..9f34471 100644 --- a/fleet-server/crates/grpc-listener/src/auth.rs +++ b/fleet-server/crates/grpc-listener/src/auth.rs @@ -49,6 +49,12 @@ pub fn validate_token( } #[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_possible_wrap +)] mod tests { use super::*; use jsonwebtoken::{EncodingKey, Header, encode}; diff --git a/fleet-server/crates/health-tracker/src/tracker.rs b/fleet-server/crates/health-tracker/src/tracker.rs index c91f482..55756c2 100644 --- a/fleet-server/crates/health-tracker/src/tracker.rs +++ b/fleet-server/crates/health-tracker/src/tracker.rs @@ -72,6 +72,7 @@ impl HeartbeatPort for HealthTracker { } #[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used)] mod tests { use super::*; use crate::{error::HealthTrackerError, store::HeartbeatRecord}; @@ -91,13 +92,13 @@ mod tests { fn call_count(&self) -> usize { // Lock poisoning only happens if a test panicked while holding it. // Recovering the inner value is correct here. - self.calls.lock().unwrap_or_else(|p| p.into_inner()).len() + self.calls.lock().unwrap_or_else(std::sync::PoisonError::into_inner).len() } fn last_call(&self) -> Option { self.calls .lock() - .unwrap_or_else(|p| p.into_inner()) + .unwrap_or_else(std::sync::PoisonError::into_inner) .last() .cloned() } @@ -111,7 +112,7 @@ mod tests { ) -> Result<(), HealthTrackerError> { self.calls .lock() - .unwrap_or_else(|p| p.into_inner()) + .unwrap_or_else(std::sync::PoisonError::into_inner) .push(record); Ok(()) } diff --git a/fleet-server/crates/node-enrollment/src/enroller.rs b/fleet-server/crates/node-enrollment/src/enroller.rs index 7ff923d..f6e68a5 100644 --- a/fleet-server/crates/node-enrollment/src/enroller.rs +++ b/fleet-server/crates/node-enrollment/src/enroller.rs @@ -79,6 +79,7 @@ impl EnrollmentPort for NodeEnroller { } #[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used)] mod tests { use super::*; use crate::{error::NodeEnrollmentError, store::NodeRecord}; diff --git a/fleet-server/crates/node-enrollment/src/token.rs b/fleet-server/crates/node-enrollment/src/token.rs index 87f1ba8..8ef9051 100644 --- a/fleet-server/crates/node-enrollment/src/token.rs +++ b/fleet-server/crates/node-enrollment/src/token.rs @@ -46,6 +46,7 @@ pub fn sign_token(node_id: &str, secret: &[u8]) -> Result Date: Wed, 3 Jun 2026 02:48:26 +0530 Subject: [PATCH 17/69] chore(ci): carrgo fmt --- fleet-server/crates/health-tracker/src/tracker.rs | 5 ++++- fleet-server/crates/node-enrollment/src/token.rs | 6 +++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/fleet-server/crates/health-tracker/src/tracker.rs b/fleet-server/crates/health-tracker/src/tracker.rs index 55756c2..67fe830 100644 --- a/fleet-server/crates/health-tracker/src/tracker.rs +++ b/fleet-server/crates/health-tracker/src/tracker.rs @@ -92,7 +92,10 @@ mod tests { fn call_count(&self) -> usize { // Lock poisoning only happens if a test panicked while holding it. // Recovering the inner value is correct here. - self.calls.lock().unwrap_or_else(std::sync::PoisonError::into_inner).len() + self.calls + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .len() } fn last_call(&self) -> Option { diff --git a/fleet-server/crates/node-enrollment/src/token.rs b/fleet-server/crates/node-enrollment/src/token.rs index 8ef9051..85285f1 100644 --- a/fleet-server/crates/node-enrollment/src/token.rs +++ b/fleet-server/crates/node-enrollment/src/token.rs @@ -46,7 +46,11 @@ pub fn sign_token(node_id: &str, secret: &[u8]) -> Result Date: Wed, 3 Jun 2026 02:56:05 +0530 Subject: [PATCH 18/69] chore: generate sqlx metadata for node management and health queries --- ...0575a0e98e87c85e72e5da49d79827a0d7997.json | 15 +++++++++++ ...28b0a313b6f164c48480f1ec9cf00a8d8c7bb.json | 17 +++++++++++++ ...a1fce6a105ad460025ccb4b0fa10949b6b486.json | 17 +++++++++++++ ...68f00fd00774b2b86f414a6bfb04f900b21a8.json | 25 +++++++++++++++++++ ...74fac4438460404967d1d348532cad39259ae.json | 22 ++++++++++++++++ ...b8924cf27b99f433d3f625e5deb6cced11ee6.json | 18 +++++++++++++ 6 files changed, 114 insertions(+) create mode 100644 .sqlx/query-02f0784a2aefc0e48b6a78a86aa0575a0e98e87c85e72e5da49d79827a0d7997.json create mode 100644 .sqlx/query-4d3c9d60a0f85348d286e4f41ce28b0a313b6f164c48480f1ec9cf00a8d8c7bb.json create mode 100644 .sqlx/query-7a42f879c809ab1bea8dda4b06aa1fce6a105ad460025ccb4b0fa10949b6b486.json create mode 100644 .sqlx/query-7df2e8394e5f55c0217192c061668f00fd00774b2b86f414a6bfb04f900b21a8.json create mode 100644 .sqlx/query-be24aaa28047630cd45f9701f9e74fac4438460404967d1d348532cad39259ae.json create mode 100644 .sqlx/query-e5760d97933de085f8e2038f937b8924cf27b99f433d3f625e5deb6cced11ee6.json diff --git a/.sqlx/query-02f0784a2aefc0e48b6a78a86aa0575a0e98e87c85e72e5da49d79827a0d7997.json b/.sqlx/query-02f0784a2aefc0e48b6a78a86aa0575a0e98e87c85e72e5da49d79827a0d7997.json new file mode 100644 index 0000000..9910c14 --- /dev/null +++ b/.sqlx/query-02f0784a2aefc0e48b6a78a86aa0575a0e98e87c85e72e5da49d79827a0d7997.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE nodes\n SET agent_status = $1\n WHERE node_id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "02f0784a2aefc0e48b6a78a86aa0575a0e98e87c85e72e5da49d79827a0d7997" +} diff --git a/.sqlx/query-4d3c9d60a0f85348d286e4f41ce28b0a313b6f164c48480f1ec9cf00a8d8c7bb.json b/.sqlx/query-4d3c9d60a0f85348d286e4f41ce28b0a313b6f164c48480f1ec9cf00a8d8c7bb.json new file mode 100644 index 0000000..cfb1abd --- /dev/null +++ b/.sqlx/query-4d3c9d60a0f85348d286e4f41ce28b0a313b6f164c48480f1ec9cf00a8d8c7bb.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE nodes\n SET hostname = $1,\n os_version = $2,\n agent_version = $3,\n last_enrolled_at = now()\n WHERE node_id = $4\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "4d3c9d60a0f85348d286e4f41ce28b0a313b6f164c48480f1ec9cf00a8d8c7bb" +} diff --git a/.sqlx/query-7a42f879c809ab1bea8dda4b06aa1fce6a105ad460025ccb4b0fa10949b6b486.json b/.sqlx/query-7a42f879c809ab1bea8dda4b06aa1fce6a105ad460025ccb4b0fa10949b6b486.json new file mode 100644 index 0000000..32be263 --- /dev/null +++ b/.sqlx/query-7a42f879c809ab1bea8dda4b06aa1fce6a105ad460025ccb4b0fa10949b6b486.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO node_health (node_id, agent_status, events_buffered, recorded_at)\n VALUES ($1, $2, $3, $4)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Int8", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "7a42f879c809ab1bea8dda4b06aa1fce6a105ad460025ccb4b0fa10949b6b486" +} diff --git a/.sqlx/query-7df2e8394e5f55c0217192c061668f00fd00774b2b86f414a6bfb04f900b21a8.json b/.sqlx/query-7df2e8394e5f55c0217192c061668f00fd00774b2b86f414a6bfb04f900b21a8.json new file mode 100644 index 0000000..0d111bd --- /dev/null +++ b/.sqlx/query-7df2e8394e5f55c0217192c061668f00fd00774b2b86f414a6bfb04f900b21a8.json @@ -0,0 +1,25 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO nodes (machine_id, hostname, os_version, agent_version)\n VALUES ($1, $2, $3, $4)\n RETURNING node_id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "node_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "7df2e8394e5f55c0217192c061668f00fd00774b2b86f414a6bfb04f900b21a8" +} diff --git a/.sqlx/query-be24aaa28047630cd45f9701f9e74fac4438460404967d1d348532cad39259ae.json b/.sqlx/query-be24aaa28047630cd45f9701f9e74fac4438460404967d1d348532cad39259ae.json new file mode 100644 index 0000000..1ce7b63 --- /dev/null +++ b/.sqlx/query-be24aaa28047630cd45f9701f9e74fac4438460404967d1d348532cad39259ae.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT node_id\n FROM nodes\n WHERE machine_id = $1\n FOR UPDATE\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "node_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "be24aaa28047630cd45f9701f9e74fac4438460404967d1d348532cad39259ae" +} diff --git a/.sqlx/query-e5760d97933de085f8e2038f937b8924cf27b99f433d3f625e5deb6cced11ee6.json b/.sqlx/query-e5760d97933de085f8e2038f937b8924cf27b99f433d3f625e5deb6cced11ee6.json new file mode 100644 index 0000000..d90a597 --- /dev/null +++ b/.sqlx/query-e5760d97933de085f8e2038f937b8924cf27b99f433d3f625e5deb6cced11ee6.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO enrollment_events\n (node_id, event_type, hostname, os_version, agent_version)\n VALUES ($1, $2, $3, $4, $5)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "e5760d97933de085f8e2038f937b8924cf27b99f433d3f625e5deb6cced11ee6" +} From f95163676b25a6a7cb4bf35f4d9fba297505391c Mon Sep 17 00:00:00 2001 From: ghule swarnit Date: Wed, 3 Jun 2026 03:13:13 +0530 Subject: [PATCH 19/69] feat(frontend): initialize React frontend project with TypeScript and ESLint configuration --- .gitignore | 18 + frontend/Dockerfile | 11 - frontend/README.md | 73 + frontend/eslint.config.js | 22 + frontend/index.html | 13 + frontend/package-lock.json | 2768 +++++++++++++++++++++++++++++++++ frontend/package.json | 30 + frontend/public/favicon.svg | 1 + frontend/public/icons.svg | 24 + frontend/src/App.css | 184 +++ frontend/src/App.tsx | 122 ++ frontend/src/assets/hero.png | Bin 0 -> 13057 bytes frontend/src/assets/react.svg | 1 + frontend/src/assets/vite.svg | 1 + frontend/src/index.css | 111 ++ frontend/src/main.tsx | 10 + frontend/tsconfig.app.json | 25 + frontend/tsconfig.json | 7 + frontend/tsconfig.node.json | 24 + frontend/vite.config.ts | 7 + 20 files changed, 3441 insertions(+), 11 deletions(-) delete mode 100644 frontend/Dockerfile create mode 100644 frontend/README.md create mode 100644 frontend/eslint.config.js create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/icons.svg create mode 100644 frontend/src/App.css create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/assets/hero.png create mode 100644 frontend/src/assets/react.svg create mode 100644 frontend/src/assets/vite.svg create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts diff --git a/.gitignore b/.gitignore index a67f2c8..a665293 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,21 @@ Thumbs.db # Mock development tools agent/tools/mock-fleet-server/target/ .gemini/* + +# Frontend logs and build outputs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* +dist-ssr/ + +# IDE and Project files +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + diff --git a/frontend/Dockerfile b/frontend/Dockerfile deleted file mode 100644 index 67a8ef5..0000000 --- a/frontend/Dockerfile +++ /dev/null @@ -1,11 +0,0 @@ -FROM node:20-alpine AS builder -WORKDIR /app -COPY package*.json ./ -RUN npm ci -COPY . . -RUN npm run build - -FROM nginx:alpine AS runtime -COPY --from=builder /app/dist /usr/share/nginx/html -COPY nginx.conf /etc/nginx/conf.d/default.conf -EXPOSE 80 diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..7dbf7eb --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..ef614d2 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,22 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..0fca6f0 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + frontend + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..76e4b8d --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2768 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^24.12.3", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.59.2", + "vite": "^8.0.12" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", + "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.16", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.16.tgz", + "integrity": "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz", + "integrity": "sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/type-utils": "8.60.1", + "@typescript-eslint/utils": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.60.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.1.tgz", + "integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.1.tgz", + "integrity": "sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.60.1", + "@typescript-eslint/types": "^8.60.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz", + "integrity": "sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz", + "integrity": "sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.1.tgz", + "integrity": "sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1", + "@typescript-eslint/utils": "8.60.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.1.tgz", + "integrity": "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz", + "integrity": "sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.60.1", + "@typescript-eslint/tsconfig-utils": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.1.tgz", + "integrity": "sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.1.tgz", + "integrity": "sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.33", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", + "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.366", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.366.tgz", + "integrity": "sha512-OlRuhb688YTCzzU3gXPLn6nGyd+F+53INE1qaKKlu6kETErE8FYsyDh0XqXEU+uBRn0MpCzz2vfNwORhkap8qg==", + "dev": true, + "license": "ISC" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.1.tgz", + "integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.1.tgz", + "integrity": "sha512-6m5hkkRAp8lKvhVpcprAIn5KkehQEh+47oHH2VGnExEh7dhNxXlg6GPAOIu6TxbVQxhebrJDvjl3020ooiWCMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.60.1", + "@typescript-eslint/parser": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1", + "@typescript-eslint/utils": "8.60.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..70b0913 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^24.12.3", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.59.2", + "vite": "^8.0.12" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..f90339d --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,184 @@ +.counter { + font-size: 16px; + padding: 5px 10px; + border-radius: 5px; + color: var(--accent); + background: var(--accent-bg); + border: 2px solid transparent; + transition: border-color 0.3s; + margin-bottom: 24px; + + &:hover { + border-color: var(--accent-border); + } + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } +} + +.hero { + position: relative; + + .base, + .framework, + .vite { + inset-inline: 0; + margin: 0 auto; + } + + .base { + width: 170px; + position: relative; + z-index: 0; + } + + .framework, + .vite { + position: absolute; + } + + .framework { + z-index: 1; + top: 34px; + height: 28px; + transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) + scale(1.4); + } + + .vite { + z-index: 0; + top: 107px; + height: 26px; + width: auto; + transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) + scale(0.8); + } +} + +#center { + display: flex; + flex-direction: column; + gap: 25px; + place-content: center; + place-items: center; + flex-grow: 1; + + @media (max-width: 1024px) { + padding: 32px 20px 24px; + gap: 18px; + } +} + +#next-steps { + display: flex; + border-top: 1px solid var(--border); + text-align: left; + + & > div { + flex: 1 1 0; + padding: 32px; + @media (max-width: 1024px) { + padding: 24px 20px; + } + } + + .icon { + margin-bottom: 16px; + width: 22px; + height: 22px; + } + + @media (max-width: 1024px) { + flex-direction: column; + text-align: center; + } +} + +#docs { + border-right: 1px solid var(--border); + + @media (max-width: 1024px) { + border-right: none; + border-bottom: 1px solid var(--border); + } +} + +#next-steps ul { + list-style: none; + padding: 0; + display: flex; + gap: 8px; + margin: 32px 0 0; + + .logo { + height: 18px; + } + + a { + color: var(--text-h); + font-size: 16px; + border-radius: 6px; + background: var(--social-bg); + display: flex; + padding: 6px 12px; + align-items: center; + gap: 8px; + text-decoration: none; + transition: box-shadow 0.3s; + + &:hover { + box-shadow: var(--shadow); + } + .button-icon { + height: 18px; + width: 18px; + } + } + + @media (max-width: 1024px) { + margin-top: 20px; + flex-wrap: wrap; + justify-content: center; + + li { + flex: 1 1 calc(50% - 8px); + } + + a { + width: 100%; + justify-content: center; + box-sizing: border-box; + } + } +} + +#spacer { + height: 88px; + border-top: 1px solid var(--border); + @media (max-width: 1024px) { + height: 48px; + } +} + +.ticks { + position: relative; + width: 100%; + + &::before, + &::after { + content: ''; + position: absolute; + top: -4.5px; + border: 5px solid transparent; + } + + &::before { + left: 0; + border-left-color: var(--border); + } + &::after { + right: 0; + border-right-color: var(--border); + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..a66b5ef --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,122 @@ +import { useState } from 'react' +import reactLogo from './assets/react.svg' +import viteLogo from './assets/vite.svg' +import heroImg from './assets/hero.png' +import './App.css' + +function App() { + const [count, setCount] = useState(0) + + return ( + <> +
+
+ + React logo + Vite logo +
+
+

Get started

+

+ Edit src/App.tsx and save to test HMR +

+
+ +
+ +
+ +
+
+ +

Documentation

+

Your questions, answered

+ +
+
+ +

Connect with us

+

Join the Vite community

+ +
+
+ +
+
+ + ) +} + +export default App diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000000000000000000000000000000000000..02251f4b956c55af2d76fd0788124d7eee2b45eb GIT binary patch literal 13057 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 P00000NkvXXu0mjft=yBf literal 0 HcmV?d00001 diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..5fb3313 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,111 @@ +:root { + --text: #6b6375; + --text-h: #08060d; + --bg: #fff; + --border: #e5e4e7; + --code-bg: #f4f3ec; + --accent: #aa3bff; + --accent-bg: rgba(170, 59, 255, 0.1); + --accent-border: rgba(170, 59, 255, 0.5); + --social-bg: rgba(244, 243, 236, 0.5); + --shadow: + rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; + + --sans: system-ui, 'Segoe UI', Roboto, sans-serif; + --heading: system-ui, 'Segoe UI', Roboto, sans-serif; + --mono: ui-monospace, Consolas, monospace; + + font: 18px/145% var(--sans); + letter-spacing: 0.18px; + color-scheme: light dark; + color: var(--text); + background: var(--bg); + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + @media (max-width: 1024px) { + font-size: 16px; + } +} + +@media (prefers-color-scheme: dark) { + :root { + --text: #9ca3af; + --text-h: #f3f4f6; + --bg: #16171d; + --border: #2e303a; + --code-bg: #1f2028; + --accent: #c084fc; + --accent-bg: rgba(192, 132, 252, 0.15); + --accent-border: rgba(192, 132, 252, 0.5); + --social-bg: rgba(47, 48, 58, 0.5); + --shadow: + rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; + } + + #social .button-icon { + filter: invert(1) brightness(2); + } +} + +#root { + width: 1126px; + max-width: 100%; + margin: 0 auto; + text-align: center; + border-inline: 1px solid var(--border); + min-height: 100svh; + display: flex; + flex-direction: column; + box-sizing: border-box; +} + +body { + margin: 0; +} + +h1, +h2 { + font-family: var(--heading); + font-weight: 500; + color: var(--text-h); +} + +h1 { + font-size: 56px; + letter-spacing: -1.68px; + margin: 32px 0; + @media (max-width: 1024px) { + font-size: 36px; + margin: 20px 0; + } +} +h2 { + font-size: 24px; + line-height: 118%; + letter-spacing: -0.24px; + margin: 0 0 8px; + @media (max-width: 1024px) { + font-size: 20px; + } +} +p { + margin: 0; +} + +code, +.counter { + font-family: var(--mono); + display: inline-flex; + border-radius: 4px; + color: var(--text-h); +} + +code { + font-size: 15px; + line-height: 135%; + padding: 4px 8px; + background: var(--code-bg); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..bef5202 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..7f42e5f --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..d3c52ea --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..8b0f57b --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], +}) From c742d3be820a86911b1c2d06454474c6c4b0eda6 Mon Sep 17 00:00:00 2001 From: ghule swarnit Date: Wed, 3 Jun 2026 19:33:38 +0530 Subject: [PATCH 20/69] feat: implement authentication system and custom UI components with persistent assets --- .gitignore | 2 + frontend/index.html | 7 +- frontend/public/apple-touch-icon.png | Bin 0 -> 5794 bytes frontend/public/favicon-96x96.png | Bin 0 -> 3224 bytes frontend/public/favicon.ico | Bin 0 -> 15086 bytes frontend/public/favicon.svg | 18 +- frontend/public/logo.png | Bin 0 -> 18763 bytes frontend/public/site.webmanifest | 21 + frontend/public/web-app-manifest-192x192.png | Bin 0 -> 8043 bytes frontend/public/web-app-manifest-512x512.png | Bin 0 -> 28792 bytes frontend/src/App.css | 1059 +++++++++++++++--- frontend/src/App.tsx | 348 ++++-- frontend/src/AsciiBackground.css | 74 ++ frontend/src/AsciiBackground.tsx | 79 ++ frontend/src/apple-touch-icon.png | Bin 0 -> 5794 bytes frontend/src/favicon-96x96.png | Bin 0 -> 3224 bytes frontend/src/favicon.ico | Bin 0 -> 15086 bytes frontend/src/favicon.svg | 17 + frontend/src/index.css | 242 ++-- frontend/src/site.webmanifest | 21 + frontend/src/web-app-manifest-192x192.png | Bin 0 -> 8043 bytes frontend/src/web-app-manifest-512x512.png | Bin 0 -> 28792 bytes 22 files changed, 1561 insertions(+), 327 deletions(-) create mode 100644 frontend/public/apple-touch-icon.png create mode 100644 frontend/public/favicon-96x96.png create mode 100644 frontend/public/favicon.ico create mode 100644 frontend/public/logo.png create mode 100644 frontend/public/site.webmanifest create mode 100644 frontend/public/web-app-manifest-192x192.png create mode 100644 frontend/public/web-app-manifest-512x512.png create mode 100644 frontend/src/AsciiBackground.css create mode 100644 frontend/src/AsciiBackground.tsx create mode 100644 frontend/src/apple-touch-icon.png create mode 100644 frontend/src/favicon-96x96.png create mode 100644 frontend/src/favicon.ico create mode 100644 frontend/src/favicon.svg create mode 100644 frontend/src/site.webmanifest create mode 100644 frontend/src/web-app-manifest-192x192.png create mode 100644 frontend/src/web-app-manifest-512x512.png diff --git a/.gitignore b/.gitignore index a665293..0f57563 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,5 @@ dist-ssr/ *.sln *.sw? +*/frontendprompt.md +.agent/* \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index 0fca6f0..69fd9e2 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,8 +3,13 @@ + + + + - frontend + AIGIS-ZERO +
diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..cd89e68080438a6520dae0924b5168a6324f011a GIT binary patch literal 5794 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 literal 0 HcmV?d00001 diff --git a/frontend/public/favicon-96x96.png b/frontend/public/favicon-96x96.png new file mode 100644 index 0000000000000000000000000000000000000000..3272c71b941f70ea751f3cf2d25add3c75dcb74e GIT binary patch literal 3224 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 KMNUMnLSTX?PY;~{ literal 0 HcmV?d00001 diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..f0a2fcd25f38f47bd649333ffcfc129c8125ae18 GIT binary patch literal 15086 zcmdT~zmFBk5uV2(osc+z_y-mw4lJURk({1&)~$eu(;Y+vmVjL({}Co~iX$8lu7oUc zkU)SS01@yja6kZ=--^H|SvuJ|8L*b!8GlvPRbBmS-s~=kTiess)m2}8Rns$X=DqE@ zS@&7@)mI(H!`)xM?7H7{U3d6!m>=xAze9HrBduTA+jW0}!LMKgm+trIr2mv{7>48M ze;9^-4ddU?e}=q;F=>4=OD1Id^U$*|VE}75K853KijlJsdmQnH7%SlU6yv99WrBpg z;=;GZRw9|6rog<2K1UyKC7*G7znc2av2?LUKBFflRVDLY0?u9397DT}uflJkL5Uq) z5PMi^(5t0qI`}Q4|FcB?gqkUN^~_<}h)oc{@Ox!BC4|~89!JndmIChg6R6W(r@HUB z84VKzDw`U<=}o{=O9r>G9N@WxUf(kw6Gj^!KMp5Po}AEsz0d}5+_DhR6&m8U)XD*C zbiLFYpgh_~@CQ!2&qmfVzk0yB798%T{N?I~HQgg8H{#)>S&1l6!L(*EcDlhs_f3D4I4 z^8Ekv?p-y+1Sa8g8U%)cKDVt^eKTdX{;2a5{yr}Wnt>Y=FBCRU=^Vf!-GGIe$B6Pc z|LNC&AtG)o^oTw@M;gjxp~c_VR^)QetIfWB`xeA&F*i4QZl&kf&=%UO;jp>6xvl$* za>jG4`8|8~%_FsD8$)8@Xm3=V(t`ch@bvdu#^>8HJ>Fd=A3>v>A1* zXsHw4n}*`ODK*JwDY&mE$=kXAG|{LcYF@6UAvZ`mZ3{c;=q$BHZb&NUdB>>4vM+|{Bm;1WlWO=t(vxMGJQ5( zUS2M9a6F5Au+MJZyjf*XQ%X1-v$!?eTvxi38uDR|3po2c@;EfFBd3<7O{qye#*oFA zj-J~h`S$JGWe8}dFsV3ZpTW)ofF)+BA&)zE?hO9fGRYx3FA^cc$)=7)g@YjwcX|eD zwh`FOr*E8t^!~Yj|NhKxS5x9X`uw~d^rYsvMr-p+mo6=UoL;fO-MzmofL^eC<;s;R zXU${Jo;}0XW^KEIv(YwTB%0Y1cX|g)nnw`X>=QelIODaowc+8zhr@vb2da6TJ$ttF zSB=9_)r1k;`}F;Nf>X=n`t|Dx@BCSU{dUH)6_S6m!D90LNy~VOp7zSoqeopHa{W&6 zZP_$Z=I>7sk6uK>_g8{o_9l2_DCN(Lz1jXRzF_^6Cr?}s-LRYcj>mUQ!uMB{0p~E+ zwT8VePa0pod>Q!92D)x{(}a&d4s&50kHEh;8pD!;VDg9uZ2bN=OY;@Mre;tI-X_Qw z>GwoyL5AnugqwFX*s9W2y#RbU2l)K`gf092T(Y1P5Mv3w{`*Chz<4fOTU*1aQ>TXa z@84Iwcc2WvU-;sKxSIlVc^p?bk2CiOElcQIOXgk}EK9Y=k8ro&?+nTd1*~7(hCC$& zgX`PB-?{p0W+)Dwhx%UnRQ!$Gs6ZU7?nV`YmzB34;kSg|{!I*v)Fho9B(>OuVlN3N zW9uoJEUR@JV9>pFj$UnxX;j?`rNfzHQGVm9Q!?I*1n&-f`ka1R1ZIUqcN~J*w)BL} zpQ}FZB93t*!LsU@DiI_6?jip_1BQ5y!2iz}c@sho#USrZ)&HN7Smm+KZTN53E#XsU zSns;eX}pRQF!nI-{@8V!G=AN6FoR*&{hP)EUAIo-=W%>Y^Uu0Y$E!342_0$r|J1G$ zS;zk|&0T)$GjR0pgdgXwGak7EQ8T^}!LGm;>c1+#zv?EaWmKu02jHOl*S z%m2-rH^b{UuLl~-F8bHJCbAEz1qeFIuc!lbRG+jVCCV!Po`yRJe`iE@67BWxGqRWw zSIYFGKLH3rdrcazi;=2g{CX@ZU*6T1c>vDNScJZy^@nZT=h4z2B>v33cTnzS%o%Dz z{mhv&TsH$?fda?in7pDIKu6d2XxrSP-<0bynFr1Zaxs4Cb9oXokE8dNM9;NybYuNT zI8Z&p_lXKN8d(lKA)U-?u#&xx_u-dAxY>d>2t;U1*oYS^O=+ z$7eZQcgmq1+@m=wDYR3NSlT{#@W5&iYaMD3u7`L0e_O#f9UOj%T?tp^pgGR6oK^3F zI5EbHl3T8dxuwi!v&QED_Ap^B6$b6mLxh_+Z`SFhdJ*uXicUjuy3VC?NBcDj!o zAAVMJ^SYP;YORy|5%OQ$GN7KHY3VaSnlAIG6J3lpM~)mRYOsa>yV6$kjJhbHQv8gp zel*&u34T>%&fo37)2C0DMyweD5f6SoN6=RixNhGk*JnS_?=c%2@&9ybPf}y=93r2q zU3mULF8Zv`7}Wz|WH>ZEckUeD4|Fjz8H9JPwFvx9fW1rQWP}2^;IjEMP@|O?dw*qZtxFI8Hq95>Jczi53R@{-t^A=qY#|?D0;}yXSHWL0E=I zimb)8DCzsBNB2?3WdzV?F(}*z`TcLG;7uW8YKY9s>&@gj4VX>T(a`FLVN|*0@jMk97Ai{=g1E^+{l@ z{=0Ml;aeRa2zgl3poHOl>AG88Ss7L;ojt<=uxmseQ*e$!C^KMKy{MV}k>Ko**_6qdzvH1JAk6gi0-FhqrIv+B*q958-nl z`iZT$i}a`h96HD~0DfKA$Km=R%h \ No newline at end of file +RealFaviconGeneratorhttps://realfavicongenerator.net \ No newline at end of file diff --git a/frontend/public/logo.png b/frontend/public/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..bafd489370cbd934f3b43fb9942919ec3ba8d3ac GIT binary patch literal 18763 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 literal 0 HcmV?d00001 diff --git a/frontend/src/App.css b/frontend/src/App.css index f90339d..b06ad5b 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,184 +1,987 @@ -.counter { - font-size: 16px; - padding: 5px 10px; - border-radius: 5px; - color: var(--accent); - background: var(--accent-bg); - border: 2px solid transparent; - transition: border-color 0.3s; - margin-bottom: 24px; +/* CSS definitions for all sections */ - &:hover { - border-color: var(--accent-border); - } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; +/* Header */ +.app-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 24px 0; + border-bottom: 1px solid var(--border-dark); +} + +.brand { + font-family: var(--font-display); + font-size: 24px; + font-weight: 700; + letter-spacing: -0.5px; + text-decoration: none; + color: var(--fg); +} + +.nav-links { + display: flex; + gap: 32px; + align-items: center; +} + +.nav-link { + font-family: var(--font-mono); + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted-fg); + text-decoration: none; + transition: color 100ms; +} + +.nav-link:hover, .nav-link:focus-visible { + color: var(--fg); +} + +.nav-link.active { + color: var(--fg); + background-color: transparent; + padding: 4px 10px !important; + border: 1px solid var(--border-dark) !important; +} + +.status-badge { + display: flex; + align-items: center; + gap: 8px; + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.05em; + padding: 4px 10px; + border: 1px solid var(--border-dark); + text-transform: uppercase; +} + +.status-dot { + width: 6px; + height: 6px; + background-color: var(--fg); + animation: pulse-dot 1.5s infinite; +} + +@keyframes pulse-dot { + 0%, 100% { opacity: 0.2; } + 50% { opacity: 1; } +} + +/* Sections Base */ +.section-padding { + padding: 96px 0; +} + +@media (max-width: 768px) { + .section-padding { + padding: 64px 0; } } -.hero { +/* Hero Section */ +.hero-wrapper { + text-align: left; position: relative; + overflow: hidden; +} - .base, - .framework, - .vite { - inset-inline: 0; - margin: 0 auto; - } +.hero-large-text { + font-family: var(--font-display); + font-size: 13vw; /* Large graphics typography */ + line-height: 0.85; + letter-spacing: -0.05em; + font-weight: 800; + text-transform: uppercase; + margin-bottom: 24px; + margin-top: 16px; + user-select: none; +} - .base { - width: 170px; - position: relative; - z-index: 0; +@media (min-width: 1152px) { + .hero-large-text { + font-size: 150px; } +} - .framework, - .vite { - position: absolute; - } +.hero-meta-layout { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 48px; + margin-top: 48px; +} - .framework { - z-index: 1; - top: 34px; - height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) - scale(1.4); +@media (max-width: 768px) { + .hero-meta-layout { + grid-template-columns: 1fr; + gap: 24px; } +} + +.hero-desc { + font-size: 20px; + font-family: var(--font-body); + line-height: 1.5; + color: var(--fg); +} + +.hero-actions { + display: flex; + gap: 16px; + align-items: flex-start; + margin-top: 12px; +} + +/* Visual Punctuation (Thick rule + square) */ +.hero-decoration { + position: relative; + height: 12px; + margin: 32px 0; + display: flex; + align-items: center; +} + +.hero-decoration-line { + flex-grow: 1; + border-top: 4px solid var(--border-dark); +} + +.hero-decoration-square { + width: 12px; + height: 12px; + border: 2px solid var(--border-dark); + background-color: var(--bg); + margin-left: 24px; +} + +/* Buttons */ +.btn-primary { + display: inline-flex; + align-items: center; + justify-content: center; + background-color: var(--fg); + color: var(--bg); + border: 1px solid var(--fg); + padding: 16px 32px; + font-family: var(--font-mono); + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.15em; + text-decoration: none; + cursor: pointer; + transition: all 100ms steps(1, end); /* Instant binary feedback */ +} + +.btn-primary:hover { + background-color: var(--bg); + color: var(--fg); + border: 1px solid var(--fg); +} - .vite { - z-index: 0; - top: 107px; - height: 26px; - width: auto; - transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) - scale(0.8); +.btn-secondary { + display: inline-flex; + align-items: center; + justify-content: center; + background-color: transparent; + color: var(--fg); + border: 2px solid var(--fg); + padding: 14px 30px; + font-family: var(--font-mono); + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.15em; + text-decoration: none; + cursor: pointer; + transition: all 100ms steps(1, end); +} + +.btn-secondary:hover { + background-color: var(--fg); + color: var(--bg); +} + +/* Interactive Telemetry Section */ +.telemetry-layout { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 48px; + text-align: left; +} + +@media (max-width: 900px) { + .telemetry-layout { + grid-template-columns: 1fr; + gap: 32px; } } -#center { +.telemetry-info { display: flex; flex-direction: column; - gap: 25px; - place-content: center; - place-items: center; + justify-content: center; +} + +.telemetry-info h2 { + font-size: 40px; + margin-bottom: 24px; +} + +.telemetry-info p { + color: var(--muted-fg); + margin-bottom: 32px; + font-size: 16px; +} + +.terminal-box { + background-color: #050505; + color: #F5F5F5; + border: 2px solid var(--border-dark); + font-family: var(--font-mono); + font-size: 13px; + min-height: 380px; + display: flex; + flex-direction: column; +} + +.terminal-header { + background-color: #111; + padding: 10px 16px; + border-bottom: 1px solid #222; + display: flex; + justify-content: space-between; + align-items: center; +} + +.terminal-title { + color: #888; + font-size: 11px; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.terminal-indicators { + display: flex; + gap: 6px; +} + +.terminal-ind { + width: 8px; + height: 8px; + background-color: #333; +} + +.terminal-body { flex-grow: 1; + padding: 16px; + overflow-y: auto; + max-height: 280px; + display: flex; + flex-direction: column; + gap: 8px; +} - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; - } +.log-entry { + line-height: 1.4; + white-space: pre-wrap; + border-left: 2px solid #333; + padding-left: 8px; + transition: all 100ms; } -#next-steps { +.log-entry.info { + color: #999; +} + +.log-entry.success { + color: #FFF; + border-left-color: #FFF; +} + +.log-entry.alert { + color: #000; + background-color: #FFF; + border-left: 3px solid #000; + font-weight: 600; + padding: 4px 8px; + animation: flash-invert 1s ease-out; +} + +@keyframes flash-invert { + 0% { background-color: #FFF; color: #000; } + 25% { background-color: #000; color: #FFF; } + 50% { background-color: #FFF; color: #000; } + 75% { background-color: #000; color: #FFF; } + 100% { background-color: #FFF; color: #000; } +} + +.terminal-controls { + padding: 12px 16px; + background-color: #111; + border-top: 1px solid #222; display: flex; - border-top: 1px solid var(--border); + gap: 12px; +} + +.btn-terminal { + background: transparent; + color: #AAA; + border: 1px solid #333; + padding: 6px 12px; + font-family: var(--font-mono); + font-size: 11px; + cursor: pointer; + transition: all 100ms steps(1, end); +} + +.btn-terminal:hover { + background-color: #FFF; + color: #000; + border-color: #FFF; +} + +/* Inverted Stats Section */ +.stats-inverted-section { + background-color: var(--fg); + color: var(--bg); + position: relative; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 32px; + position: relative; + z-index: 2; +} + +@media (max-width: 768px) { + .stats-grid { + grid-template-columns: 1fr; + gap: 48px; + } +} + +.stat-item { + text-align: left; + border-left: 1px solid rgba(255, 255, 255, 0.2); + padding-left: 24px; +} + +.stat-number { + font-family: var(--font-display); + font-size: 64px; + font-weight: 500; + line-height: 1; + margin-bottom: 8px; + letter-spacing: -0.02em; +} + +.stat-label { + font-family: var(--font-mono); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: #AAA; + margin-bottom: 12px; +} + +.stat-desc { + font-size: 15px; + color: #CCC; +} + +/* Features Grid */ +.features-header { text-align: left; + margin-bottom: 64px; +} + +.features-header h2 { + font-size: 40px; + margin-bottom: 16px; +} + +.features-header p { + color: var(--muted-fg); + font-size: 18px; + max-width: 600px; +} + +.features-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 24px; +} - & > div { - flex: 1 1 0; - padding: 32px; - @media (max-width: 1024px) { - padding: 24px 20px; - } +@media (max-width: 768px) { + .features-grid { + grid-template-columns: 1fr; } +} + +/* Interactive Card Inversion */ +.feature-card { + background-color: var(--bg); + border: 1px solid var(--border-dark); + padding: 48px; + text-align: left; + cursor: pointer; + transition: all 100ms steps(1, end); +} + +.feature-card:hover { + background-color: var(--fg); + color: var(--bg); +} + +.feature-icon-wrapper { + margin-bottom: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border: 1px solid var(--border-dark); + transition: border-color 100ms steps(1, end); +} + +.feature-card:hover .feature-icon-wrapper { + border-color: var(--bg); +} - .icon { - margin-bottom: 16px; - width: 22px; - height: 22px; +.feature-icon-svg { + fill: none; + stroke: currentColor; + stroke-width: 1.5; +} + +.feature-card h3 { + font-size: 24px; + margin-bottom: 16px; + color: inherit; + font-family: var(--font-display); +} + +.feature-card p { + font-size: 15px; + color: var(--muted-fg); + line-height: 1.6; +} + +.feature-card:hover p { + color: #AAA; +} + +/* Product Detail & Drop Cap */ +.detail-layout { + display: grid; + grid-template-columns: 1.2fr 0.8fr; + gap: 64px; + text-align: left; +} + +@media (max-width: 900px) { + .detail-layout { + grid-template-columns: 1fr; + gap: 32px; } +} - @media (max-width: 1024px) { - flex-direction: column; - text-align: center; +.detail-content h2 { + font-size: 40px; + margin-bottom: 24px; +} + +/* Boxed Drop Cap (First paragraph) */ +.paragraph-dropcap::first-letter { + float: left; + font-family: var(--font-display); + font-size: 72px; + line-height: 0.85; + padding: 8px 12px; + margin: 4px 12px 0 0; + border: 2px solid var(--border-dark); + background-color: var(--bg); + color: var(--fg); + font-weight: bold; +} + +.detail-content p { + font-size: 16px; + margin-bottom: 24px; + color: var(--fg); +} + +.architecture-ascii-card { + border: 1px solid var(--border-dark); + background-color: var(--muted-bg); + padding: 32px; + display: flex; + flex-direction: column; + justify-content: center; +} + +.ascii-title { + font-family: var(--font-mono); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--muted-fg); + margin-bottom: 16px; +} + +.ascii-flow { + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.4; + white-space: pre; + color: var(--fg); + overflow-x: auto; +} + +/* Pricing Grid */ +.pricing-header { + text-align: center; + margin-bottom: 64px; +} + +.pricing-header h2 { + font-size: 40px; + margin-bottom: 16px; +} + +.pricing-header p { + color: var(--muted-fg); + font-size: 18px; +} + +.pricing-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 24px; + align-items: stretch; +} + +@media (max-width: 900px) { + .pricing-grid { + grid-template-columns: 1fr; + gap: 32px; } } -#docs { - border-right: 1px solid var(--border); +.pricing-card { + border: 1px solid var(--border-dark); + padding: 40px; + text-align: left; + display: flex; + flex-direction: column; + background-color: var(--bg); + transition: all 100ms steps(1, end); +} - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); +/* Highlighted Tier Extends Vertically on Desktop */ +@media (min-width: 901px) { + .pricing-card.elevated { + margin: -16px 0; + border-width: 2px; + z-index: 5; + box-shadow: 0 0 0 4px #000; } } -#next-steps ul { +.pricing-card:hover { + background-color: var(--fg); + color: var(--bg); + border-color: var(--fg); +} + +.price-title { + font-family: var(--font-mono); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--muted-fg); + margin-bottom: 8px; +} + +.pricing-card:hover .price-title { + color: #AAA; +} + +.price-val { + font-family: var(--font-display); + font-size: 48px; + font-weight: 600; + line-height: 1; + margin-bottom: 24px; +} + +.price-features-list { list-style: none; padding: 0; + margin: 0 0 40px; display: flex; + flex-direction: column; + gap: 12px; + flex-grow: 1; +} + +.price-feature-item { + font-size: 14px; + display: flex; + align-items: center; gap: 8px; - margin: 32px 0 0; + color: var(--fg); +} - .logo { - height: 18px; - } +.pricing-card:hover .price-feature-item { + color: var(--bg); +} + +.checkmark-svg { + width: 14px; + height: 14px; + fill: none; + stroke: currentColor; + stroke-width: 2; +} + +.btn-price { + width: 100%; +} + +/* Testimonial Pull Quote */ +.testimonial-layout { + max-width: 800px; + margin: 0 auto; + text-align: center; +} + +.quote-icon { + font-family: var(--font-display); + font-size: 120px; + line-height: 0.1; + color: var(--border-light); + user-select: none; + margin-bottom: 24px; +} + +.testimonial-quote { + font-family: var(--font-display); + font-size: 32px; + font-style: italic; + line-height: 1.4; + color: var(--fg); + margin-bottom: 32px; +} - a { - color: var(--text-h); - font-size: 16px; - border-radius: 6px; - background: var(--social-bg); - display: flex; - padding: 6px 12px; - align-items: center; - gap: 8px; - text-decoration: none; - transition: box-shadow 0.3s; - - &:hover { - box-shadow: var(--shadow); - } - .button-icon { - height: 18px; - width: 18px; - } +@media (max-width: 768px) { + .testimonial-quote { + font-size: 24px; } +} + +.testimonial-author { + font-family: var(--font-mono); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--fg); +} - @media (max-width: 1024px) { - margin-top: 20px; - flex-wrap: wrap; - justify-content: center; +.testimonial-company { + font-family: var(--font-body); + font-size: 14px; + color: var(--muted-fg); + margin-top: 4px; +} - li { - flex: 1 1 calc(50% - 8px); - } +/* Final CTA Section & Newsletter */ +.cta-layout { + display: grid; + grid-template-columns: 1.2fr 0.8fr; + gap: 64px; + text-align: left; +} - a { - width: 100%; - justify-content: center; - box-sizing: border-box; - } +@media (max-width: 900px) { + .cta-layout { + grid-template-columns: 1fr; + gap: 32px; } } -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; +.cta-info h2 { + font-size: 40px; + margin-bottom: 24px; +} + +.cta-info p { + color: var(--muted-fg); + margin-bottom: 32px; + font-size: 16px; +} + +.terminal-command-box { + background-color: var(--muted-bg); + border: 1px solid var(--border-dark); + padding: 16px; + font-family: var(--font-mono); + font-size: 13px; + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; +} + +.btn-copy-cmd { + background: transparent; + border: none; + font-family: var(--font-mono); + font-size: 11px; + text-transform: uppercase; + cursor: pointer; + text-decoration: underline; + padding: 2px 4px; +} + +.btn-copy-cmd:hover { + background-color: var(--fg); + color: var(--bg); + text-decoration: none; +} + +.newsletter-form { + display: flex; + flex-direction: column; + justify-content: center; + border: 1px solid var(--border-dark); + padding: 40px; + background-color: var(--bg); +} + +.newsletter-title { + font-family: var(--font-display); + font-size: 24px; + margin-bottom: 8px; +} + +.newsletter-desc { + font-size: 14px; + color: var(--muted-fg); + margin-bottom: 24px; +} + +.newsletter-input-group { + display: flex; + flex-direction: column; + gap: 16px; +} + +/* Bottom border only text input */ +.newsletter-input { + background: transparent; + border: none; + border-bottom: 2px solid var(--border-dark); + padding: 12px 4px; + font-family: var(--font-body); + font-size: 16px; + transition: border-bottom-color 100ms; +} + +.newsletter-input:focus { + border-bottom-width: 4px; + padding-bottom: 10px; /* offset the border thickness change to avoid layout jitter */ +} + +.newsletter-input::placeholder { + color: var(--muted-fg); + font-style: italic; +} + +/* Footer */ +.app-footer { + padding: 64px 0; + border-top: 1px solid var(--border-dark); + display: flex; + justify-content: space-between; + align-items: flex-start; + text-align: left; +} + +@media (max-width: 768px) { + .app-footer { + flex-direction: column; + gap: 32px; } } -.ticks { - position: relative; +.footer-brand-col { + max-width: 320px; +} + +.footer-desc { + font-size: 14px; + color: var(--muted-fg); + margin-top: 8px; + line-height: 1.5; +} + +.footer-links-col { + display: flex; + gap: 64px; +} + +.footer-link-group { + display: flex; + flex-direction: column; + gap: 12px; +} + +.footer-group-title { + font-family: var(--font-mono); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--fg); + margin-bottom: 8px; +} + +.footer-link { + font-size: 14px; + color: var(--muted-fg); + text-decoration: none; + transition: color 100ms; +} + +.footer-link:hover { + color: var(--fg); + text-decoration: underline; +} + +.footer-copyright { + font-family: var(--font-mono); + font-size: 11px; + color: var(--muted-fg); + margin-top: 48px; width: 100%; +} - &::before, - &::after { - content: ''; - position: absolute; - top: -4.5px; - border: 5px solid transparent; - } +/* Login Page Styling */ +.login-outer-wrapper { + background-color: #000000; + min-height: 100vh; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + width: 100%; + padding: 24px; +} - &::before { - left: 0; - border-left-color: var(--border); - } - &::after { - right: 0; - border-right-color: var(--border); +.login-card { + width: 100%; + max-width: 440px; + border: 4px solid var(--border-dark); + background-color: var(--bg); + padding: 48px; + text-align: left; + color: var(--fg); +} + +.login-logo-row { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + margin-bottom: 24px; +} + +.login-logo-row img { + filter: none; +} + +.login-logo-row .brand { + font-size: 28px; + color: var(--fg); +} + +.login-form-group { + display: flex; + flex-direction: column; + gap: 20px; + margin-bottom: 32px; +} + +.login-input-label { + font-family: var(--font-mono); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--fg); + margin-bottom: 8px; + display: block; +} + +.login-input { + width: 100%; + background: transparent; + border: 2px solid var(--border-dark); + padding: 12px 16px; + font-family: var(--font-mono); + font-size: 14px; + box-sizing: border-box; + color: var(--fg); +} + +.login-input:focus { + border-width: 4px; + padding: 10px 14px; /* Offset to prevent size jitter on focus */ + outline: none; +} + +.login-error-box { + background-color: var(--fg); + color: var(--bg); + padding: 12px 16px; + font-family: var(--font-mono); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 24px; + border: 1px solid var(--border-dark); +} + +/* Custom Inverting Square Cursor */ +html, body, a, button, input, textarea, select, .feature-card, .pricing-card { + cursor: none !important; +} + +.custom-cursor { + width: 12px; + height: 12px; + background-color: #FFFFFF; + position: fixed; + pointer-events: none; + z-index: 99999; + transform: translate(-50%, -50%); + mix-blend-mode: difference; + display: none; + transition: width 100ms, height 100ms; +} + +@media (pointer: fine) { + .custom-cursor { + display: block; } } + +.custom-cursor.hovered { + width: 24px; + height: 24px; +} + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a66b5ef..f27be31 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,120 +1,248 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from './assets/vite.svg' -import heroImg from './assets/hero.png' +import { useState, useEffect, useRef } from 'react' import './App.css' +import AsciiBackground from './AsciiBackground'; function App() { - const [count, setCount] = useState(0) + // Auth states + const [isLoggedIn, setIsLoggedIn] = useState(() => { + try { + return !!localStorage.getItem('aigis_auth'); + } catch { + return false; + } + }); + const [loggedUser, setLoggedUser] = useState(() => localStorage.getItem('aigis_user') || 'operator'); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [loginError, setLoginError] = useState(''); + + // Dashboard states + const [activeTab, setActiveTab] = useState('nodes'); + const [userMenuOpen, setUserMenuOpen] = useState(false); + const dropdownRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setUserMenuOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // Custom cursor position state + const [position, setPosition] = useState({ x: 0, y: 0 }); + const [isHovered, setIsHovered] = useState(false); + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + setPosition({ x: e.clientX, y: e.clientY }); + }; + + window.addEventListener('mousemove', handleMouseMove); + return () => { + window.removeEventListener('mousemove', handleMouseMove); + }; + }, []); + + useEffect(() => { + const handleMouseOver = (e: MouseEvent) => { + const target = e.target as HTMLElement; + if (target && typeof target.closest === 'function') { + if ( + target.tagName === 'A' || + target.tagName === 'BUTTON' || + target.closest('a') || + target.closest('button') || + target.closest('.feature-card') || + target.closest('.pricing-card') + ) { + setIsHovered(true); + return; + } + } + setIsHovered(false); + }; + + window.addEventListener('mouseover', handleMouseOver); + return () => { + window.removeEventListener('mouseover', handleMouseOver); + }; + }, []); + + const handleLogin = (e: React.FormEvent) => { + e.preventDefault(); + if (username.trim() === 'admin' && password.trim() === 'admin') { + localStorage.setItem('aigis_auth', 'true'); + localStorage.setItem('aigis_user', username.trim()); + setLoggedUser(username.trim()); + setIsLoggedIn(true); + setLoginError(''); + } else { + setLoginError('[ERROR] INVALID CREDENTIALS. ACCESS DENIED.'); + } + }; + + const handleLogout = () => { + localStorage.removeItem('aigis_auth'); + localStorage.removeItem('aigis_user'); + setIsLoggedIn(false); + setUsername(''); + setPassword(''); + setUserMenuOpen(false); + }; + + if (!isLoggedIn) { + return ( + <> +
+
+ + + + ); + } return ( <> -
-
- - React logo - Vite logo -
-
-

Get started

-

- Edit src/App.tsx and save to test HMR -

-
- -
- -
- -
-
- -

Documentation

-

Your questions, answered

- -
-
- -

Connect with us

-

Join the Vite community

- -
-
+
+ {/* Global Noise Overlay & Subtle Lines Grid background */} +