This commit is contained in:
lda
2026-04-12 00:45:32 +07:00 Unverified
parent b8b8d951f1
commit ac54ec7c74
12 changed files with 634 additions and 5 deletions
@@ -0,0 +1,57 @@
## Plan: Audit, Alerts, UI, and Safe Edge Exposure
Recommended approach: ship in this order audit first, alerts second, UI third, then edge hardening and soak. This gives you observability truth before you build subscriptions and screens, and keeps risky control APIs private behind Cloudflare Access at Caddy.
**Steps**
1. Phase A: Lock trust boundaries and endpoint classes.
2. Public Agent API stays exposed: /api/v1/agents/enroll, /api/v1/agent/ws, /healthz.
3. Private Control API stays protected: /api/v1/control/* and /ui/*.
4. Phase A: Define AuditEvent schema and retention defaults.
5. Phase B: Add audit persistence in sled and emit at key points.
6. Emit audit on token issue/list/revoke, ws auth/disconnect, command dispatch/result/timeout/error, and reload/config operations.
7. Phase B: Add audit query API with filters and pagination.
8. Phase C: Implement deterministic alert rules and evaluator loop.
9. Start with offline agent threshold, timeout-rate threshold, auth-failure spike, token misuse attempts.
10. Add dedupe and cooldown so alerts do not flap.
11. Phase C: Add alert delivery APIs.
12. Poll-first endpoint for active alerts and recent transitions, websocket stream optional after rules stabilize.
13. Phase D: Build same-domain UI app shell at /ui with origin-relative API client.
14. Phase D: Build pages: Agent Health, Command Runner, Audit Timeline, Alerts Panel.
15. Phase E: Add Caddy deployment template with Cloudflare Access policy boundaries and websocket support.
16. Phase E: Run end-to-end drills and 48-72h soak.
**Relevant files**
- [wakey-control-plane/src/runtime/mod.rs](wakey-control-plane/src/runtime/mod.rs)
- [wakey-control-plane/src/api/commands.rs](wakey-control-plane/src/api/commands.rs)
- [wakey-control-plane/src/api/control.rs](wakey-control-plane/src/api/control.rs)
- [wakey-control-plane/src/ws.rs](wakey-control-plane/src/ws.rs)
- [wakey-control-plane/src/state/store.rs](wakey-control-plane/src/state/store.rs)
- [wakey-control-plane/src/state/types.rs](wakey-control-plane/src/state/types.rs)
- [wakey-control-plane/src/config/types.rs](wakey-control-plane/src/config/types.rs)
- [wakey-control-plane/src/config/resolve.rs](wakey-control-plane/src/config/resolve.rs)
- [wakey-control-plane/src/cli.rs](wakey-control-plane/src/cli.rs)
- [README.md](README.md)
- [scripts/init/openwrt/wakey](scripts/init/openwrt/wakey)
- [.github/plan-controlPlaneAppV1.prompt.md](.github/plan-controlPlaneAppV1.prompt.md)
**Verification**
1. Unit tests for audit append/query, pagination, retention pruning.
2. Unit tests for alert rule evaluation, dedupe, cooldown.
3. Contract tests for request_id correlation across command result and timeout/error audit records.
4. Integration tests for ws auth/disconnect audit events and command timeout event emission.
5. API tests for audit and alert endpoints.
6. Edge security tests that unauthenticated /api/v1/control/* and /ui/* are denied.
7. Soak tests for reconnect churn and audit growth stability.
**Decisions captured**
- Admin auth default: Cloudflare Access only, enforced at Caddy.
- UI host: same domain path deployment.
- Shell bridge: excluded from v1 due high risk and low break-glass value during hard router failures.
- Alert delivery: poll-first in v1, websocket stream optional.
**Caddy policy shape for this plan**
1. Route /api/v1/agents/enroll and /api/v1/agent/ws to control-plane upstream without Cloudflare Access gate.
2. Route /api/v1/control/* and /ui/* only when Cloudflare Access authentication is valid.
3. Preserve websocket upgrade headers on /api/v1/agent/ws.
4. Keep control-plane process bound to private interface or localhost behind Caddy.
5. Deny direct exposure of /api/v1/control/* from origin network paths.
+16
View File
@@ -190,10 +190,26 @@ Control-plane admin API includes token management endpoints:
- `POST /api/v1/control/enroll-token?ttl_seconds=<n>` - `POST /api/v1/control/enroll-token?ttl_seconds=<n>`
- `GET /api/v1/control/enroll-tokens?include_expired=true|false` - `GET /api/v1/control/enroll-tokens?include_expired=true|false`
- `DELETE /api/v1/control/enroll-tokens/{token}` - `DELETE /api/v1/control/enroll-tokens/{token}`
- `GET /api/v1/control/audit/events?agent_id=<id>&event_type=<type>&limit=<n>`
If commands still appear silent, verify both processes are running with `-v` If commands still appear silent, verify both processes are running with `-v`
and that `RUST_LOG` is not overriding to a stricter level. and that `RUST_LOG` is not overriding to a stricter level.
## Edge Exposure (Caddy + Cloudflare Access)
Control-plane is intended to run behind a reverse proxy with TLS termination.
Use Cloudflare Access to protect `/ui/*` and `/api/v1/control/*`, while keeping
agent enrollment and websocket endpoints reachable.
An example Caddy config is provided at:
- `deploy/Caddyfile.control-plane.example`
Expected exposure model:
- Public: `/healthz`, `/api/v1/agents/enroll`, `/api/v1/agent/ws`
- Private (Cloudflare Access): `/ui/*`, `/api/v1/control/*`
## CLI ## CLI
`wakey` is usable as a local/operator CLI. `wakey` is usable as a local/operator CLI.
+31
View File
@@ -0,0 +1,31 @@
# Caddy template for wakey-control-plane with Cloudflare Access.
#
# Security model:
# - Public endpoints for agents: /healthz, /api/v1/agents/enroll, /api/v1/agent/ws
# - Private admin surface: /ui/* and /api/v1/control/* (requires CF Access headers)
#
# Replace cp.example.com with your public control-plane domain.
cp.example.com {
encode zstd gzip
# Public agent-facing endpoints.
@public path /healthz /api/v1/agents/enroll /api/v1/agent/ws
handle @public {
reverse_proxy 127.0.0.1:8787
}
# Admin surface requires Cloudflare Access headers.
@admin path /ui* /api/v1/control/*
@cf_access header_regexp CFJWT Cf-Access-Jwt-Assertion .+
handle @admin {
handle @cf_access {
reverse_proxy 127.0.0.1:8787
}
respond "forbidden" 403
}
# Deny unknown paths by default.
respond "not found" 404
}
+81
View File
@@ -0,0 +1,81 @@
use axum::Json;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::api::json_error;
use crate::runtime::AppState;
use crate::state::AuditEventFilter;
#[derive(Debug, Deserialize)]
pub struct ListAuditEventsQuery {
pub agent_id: Option<String>,
pub request_id: Option<String>,
pub event_type: Option<String>,
pub outcome: Option<String>,
pub since_unix: Option<u64>,
pub until_unix: Option<u64>,
pub limit: Option<usize>,
}
#[derive(Debug, Serialize)]
pub struct AuditEventResponse {
pub event_id: String,
pub ts_unix: u64,
pub actor_type: String,
pub actor_id: Option<String>,
pub agent_id: Option<String>,
pub request_id: Option<String>,
pub event_type: String,
pub outcome: String,
pub latency_ms: Option<u64>,
pub message: String,
pub metadata: serde_json::Value,
}
pub async fn list_audit_events(
State(state): State<AppState>,
Query(query): Query<ListAuditEventsQuery>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let filter = AuditEventFilter {
agent_id: query.agent_id,
request_id: query.request_id,
event_type: query.event_type,
outcome: query.outcome,
since_unix: query.since_unix,
until_unix: query.until_unix,
limit: query.limit.unwrap_or(100),
};
match state.store.list_audit_events(filter).await {
Ok(events) => {
let body = events
.into_iter()
.map(|event| AuditEventResponse {
event_id: event.event_id,
ts_unix: event.ts_unix,
actor_type: event.actor_type,
actor_id: event.actor_id,
agent_id: event.agent_id,
request_id: event.request_id,
event_type: event.event_type,
outcome: event.outcome,
latency_ms: event.latency_ms,
message: event.message,
metadata: event.metadata,
})
.collect::<Vec<_>>();
Ok((StatusCode::OK, Json(body)))
}
Err(err) => {
warn!(error = %err, "failed to list audit events");
Err(json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"list_audit_events_failed",
&err.to_string(),
))
}
}
}
+105
View File
@@ -3,12 +3,14 @@ use axum::extract::{Path as AxumPath, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::IntoResponse; use axum::response::IntoResponse;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::time::Instant;
use tracing::{info, info_span, warn}; use tracing::{info, info_span, warn};
use uuid::Uuid; use uuid::Uuid;
use wakey_agent::protocol::{AgentCommand, ErrorPayload, RequestId, ServerMessage}; use wakey_agent::protocol::{AgentCommand, ErrorPayload, RequestId, ServerMessage};
use crate::api::json_error; use crate::api::json_error;
use crate::runtime::{AgentReply, AppState}; use crate::runtime::{AgentReply, AppState};
use crate::state::AuditEventInput;
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct AgentStatus { pub struct AgentStatus {
@@ -56,6 +58,7 @@ pub async fn run_command(
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> { ) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let request_id_string = format!("req-{}", Uuid::new_v4()); let request_id_string = format!("req-{}", Uuid::new_v4());
let command = command_kind(&req.command); let command = command_kind(&req.command);
let started = Instant::now();
let span = info_span!( let span = info_span!(
"relay_command", "relay_command",
agent_id = %agent_id, agent_id = %agent_id,
@@ -93,6 +96,23 @@ pub async fn run_command(
.insert(request_id_string.clone(), pending_tx); .insert(request_id_string.clone(), pending_tx);
info!("dispatching command to agent"); info!("dispatching command to agent");
if let Err(err) = state
.store
.append_audit_event(AuditEventInput {
actor_type: "admin_api".into(),
actor_id: None,
agent_id: Some(agent_id.clone()),
request_id: Some(request_id_string.clone()),
event_type: "command_dispatch".into(),
outcome: "sent".into(),
latency_ms: None,
message: "dispatched command to connected agent".into(),
metadata: serde_json::json!({ "command": command }),
})
.await
{
warn!(error = %err, "failed to append audit event for command dispatch");
}
if let Err(err) = tx.send(ServerMessage::Command { if let Err(err) = tx.send(ServerMessage::Command {
request_id, request_id,
@@ -100,6 +120,23 @@ pub async fn run_command(
}) { }) {
state.pending.lock().await.remove(&request_id_string); state.pending.lock().await.remove(&request_id_string);
warn!(error = %err, "failed sending command to agent session"); warn!(error = %err, "failed sending command to agent session");
if let Err(audit_err) = state
.store
.append_audit_event(AuditEventInput {
actor_type: "admin_api".into(),
actor_id: None,
agent_id: Some(agent_id.clone()),
request_id: Some(request_id_string.clone()),
event_type: "command_dispatch".into(),
outcome: "send_failed".into(),
latency_ms: Some(started.elapsed().as_millis() as u64),
message: err.to_string(),
metadata: serde_json::json!({ "command": command }),
})
.await
{
warn!(error = %audit_err, "failed to append audit event for command send failure");
}
return Err(json_error( return Err(json_error(
StatusCode::BAD_GATEWAY, StatusCode::BAD_GATEWAY,
"agent_send_failed", "agent_send_failed",
@@ -116,6 +153,23 @@ pub async fn run_command(
let response = match outcome { let response = match outcome {
Ok(Ok(AgentReply::Result(result))) => { Ok(Ok(AgentReply::Result(result))) => {
info!("agent command completed"); info!("agent command completed");
if let Err(err) = state
.store
.append_audit_event(AuditEventInput {
actor_type: "admin_api".into(),
actor_id: None,
agent_id: Some(agent_id.clone()),
request_id: Some(request_id_string.clone()),
event_type: "command_result".into(),
outcome: "ok".into(),
latency_ms: Some(started.elapsed().as_millis() as u64),
message: "agent command completed".into(),
metadata: serde_json::json!({ "command": command }),
})
.await
{
warn!(error = %err, "failed to append audit event for command success");
}
RelayCommandResponse { RelayCommandResponse {
request_id: request_id_string, request_id: request_id_string,
status: "ok".into(), status: "ok".into(),
@@ -125,6 +179,23 @@ pub async fn run_command(
} }
Ok(Ok(AgentReply::Error(error))) => { Ok(Ok(AgentReply::Error(error))) => {
warn!(code = %error.code, "agent command returned error"); warn!(code = %error.code, "agent command returned error");
if let Err(err) = state
.store
.append_audit_event(AuditEventInput {
actor_type: "admin_api".into(),
actor_id: None,
agent_id: Some(agent_id.clone()),
request_id: Some(request_id_string.clone()),
event_type: "command_result".into(),
outcome: "error".into(),
latency_ms: Some(started.elapsed().as_millis() as u64),
message: error.message.clone(),
metadata: serde_json::json!({ "command": command, "code": error.code }),
})
.await
{
warn!(error = %err, "failed to append audit event for command error result");
}
RelayCommandResponse { RelayCommandResponse {
request_id: request_id_string, request_id: request_id_string,
status: "error".into(), status: "error".into(),
@@ -134,6 +205,23 @@ pub async fn run_command(
} }
Ok(Err(_)) => { Ok(Err(_)) => {
warn!("agent response channel dropped"); warn!("agent response channel dropped");
if let Err(err) = state
.store
.append_audit_event(AuditEventInput {
actor_type: "admin_api".into(),
actor_id: None,
agent_id: Some(agent_id.clone()),
request_id: Some(request_id_string.clone()),
event_type: "command_result".into(),
outcome: "response_dropped".into(),
latency_ms: Some(started.elapsed().as_millis() as u64),
message: "agent response channel dropped".into(),
metadata: serde_json::json!({ "command": command }),
})
.await
{
warn!(error = %err, "failed to append audit event for dropped response");
}
return Err(json_error( return Err(json_error(
StatusCode::BAD_GATEWAY, StatusCode::BAD_GATEWAY,
"agent_response_dropped", "agent_response_dropped",
@@ -143,6 +231,23 @@ pub async fn run_command(
Err(_) => { Err(_) => {
state.pending.lock().await.remove(&request_id_string); state.pending.lock().await.remove(&request_id_string);
warn!(timeout_ms = timeout.as_millis() as u64, "agent command timed out"); warn!(timeout_ms = timeout.as_millis() as u64, "agent command timed out");
if let Err(err) = state
.store
.append_audit_event(AuditEventInput {
actor_type: "admin_api".into(),
actor_id: None,
agent_id: Some(agent_id.clone()),
request_id: Some(request_id_string.clone()),
event_type: "command_result".into(),
outcome: "timeout".into(),
latency_ms: Some(started.elapsed().as_millis() as u64),
message: "agent command timed out".into(),
metadata: serde_json::json!({ "command": command, "timeout_ms": timeout.as_millis() as u64 }),
})
.await
{
warn!(error = %err, "failed to append audit event for timeout");
}
return Err(json_error( return Err(json_error(
StatusCode::GATEWAY_TIMEOUT, StatusCode::GATEWAY_TIMEOUT,
"agent_timeout", "agent_timeout",
+100 -2
View File
@@ -7,6 +7,7 @@ use tracing::{info, warn};
use crate::api::json_error; use crate::api::json_error;
use crate::runtime::AppState; use crate::runtime::AppState;
use crate::state::AuditEventInput;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct EnrollRequest { pub struct EnrollRequest {
@@ -69,6 +70,23 @@ pub async fn enroll(
match state.store.enroll(&req.enroll_token).await { match state.store.enroll(&req.enroll_token).await {
Ok(issued) => { Ok(issued) => {
info!(agent_id = %issued.agent_id, "agent enrollment accepted"); info!(agent_id = %issued.agent_id, "agent enrollment accepted");
if let Err(err) = state
.store
.append_audit_event(AuditEventInput {
actor_type: "agent".into(),
actor_id: Some(issued.agent_id.clone()),
agent_id: Some(issued.agent_id.clone()),
request_id: None,
event_type: "agent_enroll".into(),
outcome: "ok".into(),
latency_ms: None,
message: "agent enrollment accepted".into(),
metadata: serde_json::json!({}),
})
.await
{
warn!(error = %err, "failed to append audit event for enroll success");
}
Ok(( Ok((
StatusCode::OK, StatusCode::OK,
Json(EnrollResponse { Json(EnrollResponse {
@@ -80,6 +98,23 @@ pub async fn enroll(
} }
Err(err) => { Err(err) => {
warn!(error = %err, "agent enrollment rejected"); warn!(error = %err, "agent enrollment rejected");
if let Err(audit_err) = state
.store
.append_audit_event(AuditEventInput {
actor_type: "agent".into(),
actor_id: None,
agent_id: None,
request_id: None,
event_type: "agent_enroll".into(),
outcome: "rejected".into(),
latency_ms: None,
message: err.to_string(),
metadata: serde_json::json!({}),
})
.await
{
warn!(error = %audit_err, "failed to append audit event for enroll rejection");
}
Err(json_error( Err(json_error(
StatusCode::UNAUTHORIZED, StatusCode::UNAUTHORIZED,
"enrollment_rejected", "enrollment_rejected",
@@ -106,6 +141,26 @@ pub async fn issue_enroll_token(
expires_at_unix = issued.expires_at_unix, expires_at_unix = issued.expires_at_unix,
"issued enroll token" "issued enroll token"
); );
if let Err(err) = state
.store
.append_audit_event(AuditEventInput {
actor_type: "admin_api".into(),
actor_id: None,
agent_id: None,
request_id: None,
event_type: "enroll_token_issue".into(),
outcome: "ok".into(),
latency_ms: None,
message: "issued enroll token".into(),
metadata: serde_json::json!({
"ttl_seconds": ttl.as_secs(),
"expires_at_unix": issued.expires_at_unix,
}),
})
.await
{
warn!(error = %err, "failed to append audit event for token issuance");
}
Ok(( Ok((
StatusCode::OK, StatusCode::OK,
Json(IssueEnrollTokenResponse { Json(IssueEnrollTokenResponse {
@@ -132,6 +187,26 @@ pub async fn list_enroll_tokens(
let include_expired = query.include_expired.unwrap_or(false); let include_expired = query.include_expired.unwrap_or(false);
match state.store.list_enroll_tokens(include_expired).await { match state.store.list_enroll_tokens(include_expired).await {
Ok(tokens) => { Ok(tokens) => {
if let Err(err) = state
.store
.append_audit_event(AuditEventInput {
actor_type: "admin_api".into(),
actor_id: None,
agent_id: None,
request_id: None,
event_type: "enroll_token_list".into(),
outcome: "ok".into(),
latency_ms: None,
message: "listed enroll tokens".into(),
metadata: serde_json::json!({
"include_expired": include_expired,
"count": tokens.len(),
}),
})
.await
{
warn!(error = %err, "failed to append audit event for token listing");
}
let body = tokens let body = tokens
.into_iter() .into_iter()
.map(|t| EnrollTokenStatus { .map(|t| EnrollTokenStatus {
@@ -158,10 +233,33 @@ pub async fn revoke_enroll_token(
AxumPath(token): AxumPath<String>, AxumPath(token): AxumPath<String>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> { ) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
match state.store.revoke_enroll_token(&token).await { match state.store.revoke_enroll_token(&token).await {
Ok(revoked) => Ok(( Ok(revoked) => {
if let Err(err) = state
.store
.append_audit_event(AuditEventInput {
actor_type: "admin_api".into(),
actor_id: None,
agent_id: None,
request_id: None,
event_type: "enroll_token_revoke".into(),
outcome: if revoked { "ok".into() } else { "not_found".into() },
latency_ms: None,
message: if revoked {
"revoked enroll token".into()
} else {
"enroll token not found".into()
},
metadata: serde_json::json!({ "token": token }),
})
.await
{
warn!(error = %err, "failed to append audit event for token revoke");
}
Ok((
StatusCode::OK, StatusCode::OK,
Json(RevokeEnrollTokenResponse { token, revoked }), Json(RevokeEnrollTokenResponse { token, revoked }),
)), ))
}
Err(err) => { Err(err) => {
warn!(error = %err, "failed to revoke enroll token"); warn!(error = %err, "failed to revoke enroll token");
Err(json_error( Err(json_error(
+2
View File
@@ -3,8 +3,10 @@ use axum::http::StatusCode;
mod commands; mod commands;
mod control; mod control;
mod audit;
pub use commands::{list_agents, run_command}; pub use commands::{list_agents, run_command};
pub use audit::list_audit_events;
pub use control::{ pub use control::{
EnrollTokenStatus, IssueEnrollTokenResponse, RevokeEnrollTokenResponse, StateStatsResponse, EnrollTokenStatus, IssueEnrollTokenResponse, RevokeEnrollTokenResponse, StateStatsResponse,
enroll, healthz, issue_enroll_token, list_enroll_tokens, revoke_enroll_token, state_stats, enroll, healthz, issue_enroll_token, list_enroll_tokens, revoke_enroll_token, state_stats,
+1
View File
@@ -72,6 +72,7 @@ pub async fn serve(daemon: config::DaemonConfig) -> Result<()> {
axum::routing::delete(api::revoke_enroll_token), axum::routing::delete(api::revoke_enroll_token),
) )
.route("/api/v1/control/state-stats", get(api::state_stats)) .route("/api/v1/control/state-stats", get(api::state_stats))
.route("/api/v1/control/audit/events", get(api::list_audit_events))
.route("/api/v1/agent/ws", get(ws::agent_ws)) .route("/api/v1/agent/ws", get(ws::agent_ws))
.route("/api/v1/control/agents", get(api::list_agents)) .route("/api/v1/control/agents", get(api::list_agents))
.route( .route(
+1
View File
@@ -2,3 +2,4 @@ mod store;
mod types; mod types;
pub use store::Store; pub use store::Store;
pub use types::{AuditEventFilter, AuditEventInput};
+146 -1
View File
@@ -5,13 +5,17 @@ use anyhow::{Context, Result};
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
use uuid::Uuid; use uuid::Uuid;
use crate::state::types::{EnrollTokenInfo, IssuedAgent, IssuedEnrollToken, StateStats}; use crate::state::types::{
AuditEvent, AuditEventFilter, AuditEventInput, EnrollTokenInfo, IssuedAgent, IssuedEnrollToken,
StateStats,
};
pub struct Store { pub struct Store {
db_path: PathBuf, db_path: PathBuf,
meta: sled::Tree, meta: sled::Tree,
enroll_tokens: sled::Tree, enroll_tokens: sled::Tree,
agents: sled::Tree, agents: sled::Tree,
audit_events: sled::Tree,
} }
const SCHEMA_VERSION_KEY: &[u8] = b"schema_version"; const SCHEMA_VERSION_KEY: &[u8] = b"schema_version";
@@ -32,12 +36,16 @@ impl Store {
.open_tree("enroll_tokens") .open_tree("enroll_tokens")
.context("failed to open enroll_tokens tree")?; .context("failed to open enroll_tokens tree")?;
let agents_tree = db.open_tree("agents").context("failed to open agents tree")?; let agents_tree = db.open_tree("agents").context("failed to open agents tree")?;
let audit_events_tree = db
.open_tree("audit_events")
.context("failed to open audit_events tree")?;
let store = Self { let store = Self {
db_path, db_path,
meta: meta_tree, meta: meta_tree,
enroll_tokens: enroll_tree, enroll_tokens: enroll_tree,
agents: agents_tree, agents: agents_tree,
audit_events: audit_events_tree,
}; };
store.ensure_schema_version()?; store.ensure_schema_version()?;
@@ -62,10 +70,12 @@ impl Store {
let enroll_tokens = store.enroll_tokens.iter().count(); let enroll_tokens = store.enroll_tokens.iter().count();
let agents = store.agents.iter().count(); let agents = store.agents.iter().count();
let audit_events = store.audit_events.iter().count();
info!( info!(
path = %store.db_path.display(), path = %store.db_path.display(),
enroll_tokens, enroll_tokens,
agents, agents,
audit_events,
"control-plane store ready" "control-plane store ready"
); );
Ok(store) Ok(store)
@@ -218,6 +228,53 @@ impl Store {
out out
} }
pub async fn append_audit_event(&self, input: AuditEventInput) -> Result<AuditEvent> {
let event = AuditEvent {
event_id: format!("evt-{}", Uuid::new_v4()),
ts_unix: now_unix(),
actor_type: input.actor_type,
actor_id: input.actor_id,
agent_id: input.agent_id,
request_id: input.request_id,
event_type: input.event_type,
outcome: input.outcome,
latency_ms: input.latency_ms,
message: input.message,
metadata: input.metadata,
};
let key = format!("{:020}:{}", event.ts_unix, event.event_id);
let value = serde_json::to_vec(&event).context("failed to encode audit event")?;
self.audit_events
.insert(key.as_bytes(), value)
.context("failed persisting audit event")?;
self.flush()
.context("failed flushing state db after audit append")?;
Ok(event)
}
pub async fn list_audit_events(&self, filter: AuditEventFilter) -> Result<Vec<AuditEvent>> {
let limit = filter.limit.clamp(1, 500);
let mut out = Vec::new();
for item in self.audit_events.iter().rev() {
let (_, raw) = item.context("failed iterating audit event tree")?;
let event: AuditEvent =
serde_json::from_slice(raw.as_ref()).context("failed decoding audit event")?;
if !matches_audit_filter(&event, &filter) {
continue;
}
out.push(event);
if out.len() >= limit {
break;
}
}
Ok(out)
}
fn flush(&self) -> Result<()> { fn flush(&self) -> Result<()> {
self.enroll_tokens self.enroll_tokens
.flush() .flush()
@@ -225,6 +282,9 @@ impl Store {
self.agents self.agents
.flush() .flush()
.context("failed to flush agents tree")?; .context("failed to flush agents tree")?;
self.audit_events
.flush()
.context("failed to flush audit event tree")?;
debug!(path = %self.db_path.display(), "flushed sled state db"); debug!(path = %self.db_path.display(), "flushed sled state db");
Ok(()) Ok(())
} }
@@ -313,6 +373,33 @@ fn decode_schema(raw: &[u8]) -> Result<u32> {
Ok(u32::from_le_bytes(arr)) Ok(u32::from_le_bytes(arr))
} }
fn matches_audit_filter(event: &AuditEvent, filter: &AuditEventFilter) -> bool {
if let Some(agent_id) = filter.agent_id.as_deref()
&& event.agent_id.as_deref() != Some(agent_id)
{
return false;
}
if let Some(request_id) = filter.request_id.as_deref()
&& event.request_id.as_deref() != Some(request_id)
{
return false;
}
if let Some(event_type) = filter.event_type.as_deref() && event.event_type != event_type {
return false;
}
if let Some(outcome) = filter.outcome.as_deref() && event.outcome != outcome {
return false;
}
if let Some(since_unix) = filter.since_unix && event.ts_unix < since_unix {
return false;
}
if let Some(until_unix) = filter.until_unix && event.ts_unix > until_unix {
return false;
}
true
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::fs; use std::fs;
@@ -416,4 +503,62 @@ mod tests {
assert_eq!(stats.expired_enroll_token_count, 1); assert_eq!(stats.expired_enroll_token_count, 1);
cleanup_dir(&dir); cleanup_dir(&dir);
} }
#[tokio::test]
async fn audit_events_append_and_filter() {
let (store, dir) = make_store().await;
store
.append_audit_event(crate::state::AuditEventInput {
actor_type: "admin_api".into(),
actor_id: None,
agent_id: Some("agent-1".into()),
request_id: Some("req-1".into()),
event_type: "command_result".into(),
outcome: "ok".into(),
latency_ms: Some(12),
message: "command completed".into(),
metadata: serde_json::json!({"command":"devs"}),
})
.await
.expect("append first event should succeed");
store
.append_audit_event(crate::state::AuditEventInput {
actor_type: "agent".into(),
actor_id: Some("agent-2".into()),
agent_id: Some("agent-2".into()),
request_id: Some("req-2".into()),
event_type: "agent_ws_auth".into(),
outcome: "rejected".into(),
latency_ms: None,
message: "auth rejected".into(),
metadata: serde_json::json!({}),
})
.await
.expect("append second event should succeed");
let all = store
.list_audit_events(crate::state::AuditEventFilter {
limit: 10,
..Default::default()
})
.await
.expect("list all should succeed");
assert_eq!(all.len(), 2);
let filtered = store
.list_audit_events(crate::state::AuditEventFilter {
agent_id: Some("agent-1".into()),
event_type: Some("command_result".into()),
outcome: Some("ok".into()),
limit: 10,
..Default::default()
})
.await
.expect("filtered list should succeed");
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].request_id.as_deref(), Some("req-1"));
cleanup_dir(&dir);
}
} }
+40
View File
@@ -29,3 +29,43 @@ pub struct StateStats {
pub enroll_token_count: usize, pub enroll_token_count: usize,
pub expired_enroll_token_count: usize, pub expired_enroll_token_count: usize,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEvent {
pub event_id: String,
pub ts_unix: u64,
pub actor_type: String,
pub actor_id: Option<String>,
pub agent_id: Option<String>,
pub request_id: Option<String>,
pub event_type: String,
pub outcome: String,
pub latency_ms: Option<u64>,
pub message: String,
#[serde(default)]
pub metadata: serde_json::Value,
}
#[derive(Debug, Clone)]
pub struct AuditEventInput {
pub actor_type: String,
pub actor_id: Option<String>,
pub agent_id: Option<String>,
pub request_id: Option<String>,
pub event_type: String,
pub outcome: String,
pub latency_ms: Option<u64>,
pub message: String,
pub metadata: serde_json::Value,
}
#[derive(Debug, Clone, Default)]
pub struct AuditEventFilter {
pub agent_id: Option<String>,
pub request_id: Option<String>,
pub event_type: Option<String>,
pub outcome: Option<String>,
pub since_unix: Option<u64>,
pub until_unix: Option<u64>,
pub limit: usize,
}
+52
View File
@@ -10,6 +10,7 @@ use uuid::Uuid;
use wakey_agent::protocol::{ErrorPayload, RequestId, ServerMessage}; use wakey_agent::protocol::{ErrorPayload, RequestId, ServerMessage};
use crate::runtime::{AgentReply, AppState}; use crate::runtime::{AgentReply, AppState};
use crate::state::AuditEventInput;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
@@ -103,6 +104,23 @@ async fn handle_agent_socket(state: AppState, socket: WebSocket) {
if let Some(agent_id) = authed_agent_id { if let Some(agent_id) = authed_agent_id {
info!(agent_id = %agent_id, "agent disconnected"); info!(agent_id = %agent_id, "agent disconnected");
state.sessions.write().await.remove(&agent_id); state.sessions.write().await.remove(&agent_id);
if let Err(err) = state
.store
.append_audit_event(AuditEventInput {
actor_type: "agent".into(),
actor_id: Some(agent_id.clone()),
agent_id: Some(agent_id),
request_id: None,
event_type: "agent_ws_disconnect".into(),
outcome: "ok".into(),
latency_ms: None,
message: "agent websocket disconnected".into(),
metadata: serde_json::json!({}),
})
.await
{
warn!(error = %err, "failed to append audit event for ws disconnect");
}
} }
writer.abort(); writer.abort();
@@ -132,6 +150,23 @@ async fn process_agent_text(
.await .await
{ {
warn!(agent_id = %agent_id, "agent auth rejected"); warn!(agent_id = %agent_id, "agent auth rejected");
if let Err(err) = state
.store
.append_audit_event(AuditEventInput {
actor_type: "agent".into(),
actor_id: Some(agent_id.clone()),
agent_id: Some(agent_id),
request_id: None,
event_type: "agent_ws_auth".into(),
outcome: "rejected".into(),
latency_ms: None,
message: "agent auth rejected".into(),
metadata: serde_json::json!({}),
})
.await
{
warn!(error = %err, "failed to append audit event for auth rejection");
}
anyhow::bail!("agent auth rejected"); anyhow::bail!("agent auth rejected");
} }
state state
@@ -141,6 +176,23 @@ async fn process_agent_text(
.insert(agent_id.clone(), tx.clone()); .insert(agent_id.clone(), tx.clone());
*authed_agent_id = Some(agent_id.clone()); *authed_agent_id = Some(agent_id.clone());
info!(agent_id = %agent_id, "agent authenticated"); info!(agent_id = %agent_id, "agent authenticated");
if let Err(err) = state
.store
.append_audit_event(AuditEventInput {
actor_type: "agent".into(),
actor_id: Some(agent_id.clone()),
agent_id: Some(agent_id),
request_id: None,
event_type: "agent_ws_auth".into(),
outcome: "ok".into(),
latency_ms: None,
message: "agent websocket authenticated".into(),
metadata: serde_json::json!({}),
})
.await
{
warn!(error = %err, "failed to append audit event for auth success");
}
} }
IncomingClientMessage::Heartbeat { agent_id } => { IncomingClientMessage::Heartbeat { agent_id } => {
if authed_agent_id.as_deref() != Some(agent_id.as_str()) { if authed_agent_id.as_deref() != Some(agent_id.as_str()) {