delete agent flow
This commit is contained in:
@@ -50,6 +50,12 @@ pub struct RevokeEnrollTokenResponse {
|
||||
pub revoked: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RevokeAgentResponse {
|
||||
pub agent_id: String,
|
||||
pub revoked: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct StateStatsResponse {
|
||||
pub db_path: String,
|
||||
@@ -275,6 +281,56 @@ pub async fn revoke_enroll_token(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn revoke_agent(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(agent_id): AxumPath<String>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||
match state.store.revoke_agent(&agent_id).await {
|
||||
Ok(revoked) => {
|
||||
if revoked {
|
||||
// Remove any active session so this credential is also cut off at runtime.
|
||||
state.sessions.write().await.remove(&agent_id);
|
||||
}
|
||||
|
||||
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: None,
|
||||
event_type: "agent_revoke".into(),
|
||||
outcome: if revoked {
|
||||
"ok".into()
|
||||
} else {
|
||||
"not_found".into()
|
||||
},
|
||||
latency_ms: None,
|
||||
message: if revoked {
|
||||
"revoked agent credentials".into()
|
||||
} else {
|
||||
"agent credentials not found".into()
|
||||
},
|
||||
metadata: serde_json::json!({ "agent_id": agent_id }),
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!(error = %err, "failed to append audit event for agent revoke");
|
||||
}
|
||||
|
||||
Ok((StatusCode::OK, Json(RevokeAgentResponse { agent_id, revoked })))
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(error = %err, "failed to revoke agent credentials");
|
||||
Err(json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"revoke_agent_failed",
|
||||
&err.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn state_stats(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||
|
||||
@@ -10,8 +10,9 @@ pub use alerts::{active_alerts, alert_history, alerts_stream};
|
||||
pub use audit::list_audit_events;
|
||||
pub use commands::{list_agents, run_command};
|
||||
pub use control::{
|
||||
EnrollTokenStatus, IssueEnrollTokenResponse, RevokeEnrollTokenResponse, StateStatsResponse,
|
||||
enroll, healthz, issue_enroll_token, list_enroll_tokens, revoke_enroll_token, state_stats,
|
||||
EnrollTokenStatus, IssueEnrollTokenResponse, RevokeAgentResponse, RevokeEnrollTokenResponse,
|
||||
StateStatsResponse, enroll, healthz, issue_enroll_token, list_enroll_tokens, revoke_agent,
|
||||
revoke_enroll_token, state_stats,
|
||||
};
|
||||
|
||||
pub fn json_error(
|
||||
|
||||
@@ -39,6 +39,8 @@ pub enum Command {
|
||||
ListEnrollTokens(ListEnrollTokensArgs),
|
||||
/// Revoke a specific enroll token.
|
||||
RevokeEnrollToken(RevokeEnrollTokenArgs),
|
||||
/// Revoke an enrolled agent's persistent credentials.
|
||||
RevokeAgent(RevokeAgentArgs),
|
||||
/// Print state backend stats.
|
||||
StateStats(StateStatsArgs),
|
||||
/// Send SIGHUP to an already-running daemon.
|
||||
@@ -189,6 +191,27 @@ pub struct RevokeEnrollTokenArgs {
|
||||
pub target: AdminTargetArgs,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct RevokeAgentArgs {
|
||||
#[arg(long, default_value = DEFAULT_CONFIG_FILE)]
|
||||
pub config_file: PathBuf,
|
||||
|
||||
#[arg(long)]
|
||||
pub state_file: Option<PathBuf>,
|
||||
|
||||
#[arg(long)]
|
||||
pub data_dir: Option<PathBuf>,
|
||||
|
||||
#[arg(long)]
|
||||
pub public_url: Option<String>,
|
||||
|
||||
#[arg(long)]
|
||||
pub agent_id: String,
|
||||
|
||||
#[command(flatten)]
|
||||
pub target: AdminTargetArgs,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct StateStatsArgs {
|
||||
#[arg(long, default_value = DEFAULT_CONFIG_FILE)]
|
||||
|
||||
@@ -5,6 +5,7 @@ mod types;
|
||||
pub use init::{bootstrap_config_if_missing, write_init_config};
|
||||
pub use resolve::{
|
||||
issue_token_endpoint, resolve_issue_token_settings, resolve_list_enroll_token_settings,
|
||||
resolve_revoke_enroll_token_settings, resolve_state_stats_settings,
|
||||
resolve_revoke_agent_settings, resolve_revoke_enroll_token_settings,
|
||||
resolve_state_stats_settings,
|
||||
};
|
||||
pub use types::{DaemonConfig, TelemetryConfig};
|
||||
|
||||
@@ -5,8 +5,8 @@ use std::time::Duration;
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::cli::{
|
||||
AdminTargetArgs, IssueEnrollTokenArgs, ListEnrollTokensArgs, RevokeEnrollTokenArgs, ServeArgs,
|
||||
StateStatsArgs,
|
||||
AdminTargetArgs, IssueEnrollTokenArgs, ListEnrollTokensArgs, RevokeAgentArgs,
|
||||
RevokeEnrollTokenArgs, ServeArgs, StateStatsArgs,
|
||||
};
|
||||
use crate::config::types::{
|
||||
DaemonConfig, FileConfig, FileTelemetryConfig, IssueTokenSettings, StateAccessSettings,
|
||||
@@ -163,6 +163,16 @@ pub fn resolve_revoke_enroll_token_settings(
|
||||
)
|
||||
}
|
||||
|
||||
pub fn resolve_revoke_agent_settings(args: &RevokeAgentArgs) -> Result<StateAccessSettings> {
|
||||
resolve_state_access(
|
||||
&args.config_file,
|
||||
args.data_dir.clone(),
|
||||
args.state_file.clone(),
|
||||
args.public_url.clone(),
|
||||
&args.target,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn resolve_state_stats_settings(args: &StateStatsArgs) -> Result<StateAccessSettings> {
|
||||
resolve_state_access(
|
||||
&args.config_file,
|
||||
|
||||
@@ -51,6 +51,10 @@ async fn main() -> Result<()> {
|
||||
tracing::init(cli.verbose, &config::TelemetryConfig::default())?;
|
||||
runtime::revoke_enroll_token(args).await
|
||||
}
|
||||
Command::RevokeAgent(args) => {
|
||||
tracing::init(cli.verbose, &config::TelemetryConfig::default())?;
|
||||
runtime::revoke_agent(args).await
|
||||
}
|
||||
Command::StateStats(args) => {
|
||||
tracing::init(cli.verbose, &config::TelemetryConfig::default())?;
|
||||
runtime::state_stats(args).await
|
||||
|
||||
@@ -4,7 +4,8 @@ use anyhow::{Context, Result};
|
||||
|
||||
use crate::api;
|
||||
use crate::cli::{
|
||||
IssueEnrollTokenArgs, ListEnrollTokensArgs, RevokeEnrollTokenArgs, StateStatsArgs,
|
||||
IssueEnrollTokenArgs, ListEnrollTokensArgs, RevokeAgentArgs, RevokeEnrollTokenArgs,
|
||||
StateStatsArgs,
|
||||
};
|
||||
use crate::config;
|
||||
use crate::state;
|
||||
@@ -174,6 +175,46 @@ pub async fn revoke_enroll_token(args: RevokeEnrollTokenArgs) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn revoke_agent(args: RevokeAgentArgs) -> Result<()> {
|
||||
let settings = config::resolve_revoke_agent_settings(&args)?;
|
||||
if let Some(base) = settings.public_url.as_deref() {
|
||||
let url = format!("{}/api/v1/control/agents/{}", base, args.agent_id);
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.delete(&url)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("failed to call {url}"))?;
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "<unreadable error body>".to_string());
|
||||
anyhow::bail!("live revoke-agent failed with {status}: {body}");
|
||||
}
|
||||
let body: api::RevokeAgentResponse = response
|
||||
.json()
|
||||
.await
|
||||
.context("failed to decode revoke-agent response")?;
|
||||
println!("agent_id={} revoked={}", body.agent_id, body.revoked);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let store =
|
||||
state::Store::load_or_init(&settings.state_file, Vec::new(), Duration::from_secs(1))
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to initialize store {}",
|
||||
settings.state_file.display()
|
||||
)
|
||||
})?;
|
||||
let removed = store.revoke_agent(&args.agent_id).await?;
|
||||
println!("agent_id={} revoked={}", args.agent_id, removed);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn state_stats(args: StateStatsArgs) -> Result<()> {
|
||||
let settings = config::resolve_state_stats_settings(&args)?;
|
||||
if let Some(base) = settings.public_url.as_deref() {
|
||||
|
||||
@@ -26,6 +26,7 @@ use crate::ws;
|
||||
mod admin;
|
||||
mod process;
|
||||
pub use admin::{issue_enroll_token, list_enroll_tokens, revoke_enroll_token, state_stats};
|
||||
pub use admin::revoke_agent;
|
||||
pub use process::reload_daemon;
|
||||
use process::{remove_pid_file, write_pid_file};
|
||||
|
||||
@@ -85,6 +86,10 @@ fn control_api_routes() -> Router<AppState> {
|
||||
.route("/api/v1/control/alerts/history", get(api::alert_history))
|
||||
.route("/api/v1/control/alerts/ws", get(api::alerts_stream))
|
||||
.route("/api/v1/control/agents", get(api::list_agents))
|
||||
.route(
|
||||
"/api/v1/control/agents/{agent_id}",
|
||||
axum::routing::delete(api::revoke_agent),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/control/agents/{agent_id}/command",
|
||||
post(api::run_command),
|
||||
|
||||
@@ -187,6 +187,19 @@ impl Store {
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
pub async fn revoke_agent(&self, agent_id: &str) -> Result<bool> {
|
||||
let removed = self
|
||||
.agents
|
||||
.remove(agent_id.as_bytes())
|
||||
.context("failed removing agent credentials")?
|
||||
.is_some();
|
||||
if removed {
|
||||
self.flush()
|
||||
.context("failed flushing db after agent revoke")?;
|
||||
}
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
pub async fn stats(&self) -> Result<StateStats> {
|
||||
let now = now_unix();
|
||||
let mut enroll_token_count = 0usize;
|
||||
@@ -694,6 +707,46 @@ mod tests {
|
||||
cleanup_dir(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn revoke_agent_removes_credentials() {
|
||||
let (store, dir) = make_store().await;
|
||||
|
||||
store
|
||||
.enroll_tokens
|
||||
.insert(b"enr-revoke-agent-test", &(u64::MAX - 10).to_le_bytes())
|
||||
.expect("insert should succeed");
|
||||
|
||||
let issued = store
|
||||
.enroll("enr-revoke-agent-test")
|
||||
.await
|
||||
.expect("enroll should succeed");
|
||||
|
||||
assert!(
|
||||
store
|
||||
.verify_agent_token(&issued.agent_id, &issued.agent_token)
|
||||
.await
|
||||
);
|
||||
|
||||
let removed = store
|
||||
.revoke_agent(&issued.agent_id)
|
||||
.await
|
||||
.expect("revoke should succeed");
|
||||
assert!(removed);
|
||||
assert!(
|
||||
!store
|
||||
.verify_agent_token(&issued.agent_id, &issued.agent_token)
|
||||
.await
|
||||
);
|
||||
|
||||
let removed_again = store
|
||||
.revoke_agent(&issued.agent_id)
|
||||
.await
|
||||
.expect("second revoke should succeed");
|
||||
assert!(!removed_again);
|
||||
|
||||
cleanup_dir(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn audit_events_append_and_filter() {
|
||||
let (store, dir) = make_store().await;
|
||||
|
||||
Reference in New Issue
Block a user