delete agent flow

This commit is contained in:
lda
2026-04-14 05:34:56 +07:00 Unverified
parent 3053009a7e
commit 28dda76ee8
13 changed files with 295 additions and 20 deletions
+2
View File
@@ -102,6 +102,7 @@ List/revoke enroll tokens from CLI:
```sh
wakey-control-plane list-enroll-tokens --include-expired
wakey-control-plane revoke-enroll-token --token enr-...
wakey-control-plane revoke-agent --agent-id agent-...
```
Machine-readable output is available:
@@ -194,6 +195,7 @@ Control-plane admin API includes token management endpoints:
- `GET /api/v1/control/alerts?lookback_seconds=900`
- `GET /api/v1/control/alerts/history?since_unix=<ts>&limit=<n>`
- `GET /api/v1/control/alerts/ws` (websocket snapshots + recent transitions)
- `DELETE /api/v1/control/agents/{agent_id}`
If commands still appear silent, verify both processes are running with `-v`
and that `RUST_LOG` is not overriding to a stricter level.
+14 -1
View File
@@ -10,6 +10,7 @@ import {
fetchAlertHistory,
fetchAlerts,
fetchAudit,
revokeAgent,
} from "@/api";
import { AppLayout } from "@/layout/AppLayout";
import { AgentsPage } from "@/pages/AgentsPage";
@@ -47,7 +48,12 @@ export function App() {
setAlerts(nextAlerts);
setHistory(nextHistory);
setAudit(nextAudit);
if (!selectedAgentId && nextAgents[0]) {
if (!nextAgents.length) {
setSelectedAgentId("");
} else if (
!selectedAgentId ||
!nextAgents.some((agent) => agent.agent_id === selectedAgentId)
) {
setSelectedAgentId(nextAgents[0].agent_id);
}
setState("ready");
@@ -57,6 +63,12 @@ export function App() {
}
}
async function onRevokeAgent(agentId: string): Promise<boolean> {
const result = await revokeAgent(agentId);
await loadAll();
return result.revoked;
}
async function refreshAlertsAndHistory() {
const [nextAlerts, nextHistory] = await Promise.all([
fetchAlerts(),
@@ -132,6 +144,7 @@ export function App() {
agents={agents}
selectedAgentId={selectedAgentId}
onSelectAgent={setSelectedAgentId}
onRevokeAgent={onRevokeAgent}
/>
}
/>
+12
View File
@@ -57,6 +57,11 @@ export type RevokeEnrollTokenResponse = {
revoked: boolean;
};
export type RevokeAgentResponse = {
agent_id: string;
revoked: boolean;
};
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, {
...init,
@@ -123,6 +128,13 @@ export function revokeEnrollToken(
);
}
export function revokeAgent(agentId: string): Promise<RevokeAgentResponse> {
return request<RevokeAgentResponse>(
`/api/v1/control/agents/${encodeURIComponent(agentId)}`,
{ method: "DELETE" },
);
}
export function runCommand(
agentId: string,
kind: CommandKind,
+58 -4
View File
@@ -1,35 +1,79 @@
import type { Agent } from "@/api";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useState } from "react";
type Props = {
agents: Agent[];
selectedAgentId: string;
onSelectAgent: (agentId: string) => void;
onRevokeAgent: (agentId: string) => Promise<boolean>;
};
export function AgentsPage({ agents, selectedAgentId, onSelectAgent }: Props) {
export function AgentsPage({
agents,
selectedAgentId,
onSelectAgent,
onRevokeAgent,
}: Props) {
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState("");
const [error, setError] = useState("");
async function onRevoke(agentId: string) {
if (!window.confirm(`Revoke agent credentials for ${agentId}?`)) {
return;
}
setBusy(true);
setStatus("");
setError("");
try {
const revoked = await onRevokeAgent(agentId);
setStatus(
revoked ? `Revoked ${agentId}` : `${agentId} was already absent`,
);
} catch (err) {
setError(String(err));
} finally {
setBusy(false);
}
}
return (
<Card>
<CardHeader>
<CardTitle>Agents</CardTitle>
</CardHeader>
<CardContent>
<CardContent className="space-y-3">
<div className="grid gap-2">
{agents.map((agent) => (
<Button
<div
className="flex items-start justify-between gap-3 rounded-md border bg-card px-3 py-2"
key={agent.agent_id}
>
<Button
variant={
selectedAgentId === agent.agent_id ? "secondary" : "outline"
}
className="flex h-auto w-full items-center justify-between px-3 py-2 text-left"
className="flex h-auto flex-1 items-center justify-between px-3 py-2 text-left"
onClick={() => onSelectAgent(agent.agent_id)}
disabled={busy}
>
<span>{agent.agent_id}</span>
<span className="text-xs text-muted-foreground">
{agent.connected ? "connected" : "offline"}
</span>
</Button>
<Button
variant="outline"
size="sm"
className="shrink-0 border-destructive/50 text-destructive hover:bg-destructive/10"
onClick={() => void onRevoke(agent.agent_id)}
disabled={busy}
>
Revoke
</Button>
</div>
))}
{!agents.length && (
<div className="px-1 py-2 text-sm text-muted-foreground">
@@ -37,6 +81,16 @@ export function AgentsPage({ agents, selectedAgentId, onSelectAgent }: Props) {
</div>
)}
</div>
{status && (
<pre className="max-h-80 overflow-auto rounded-md border bg-muted/40 p-3 text-xs">
{status}
</pre>
)}
{error && (
<pre className="max-h-80 overflow-auto rounded-md border border-destructive/60 bg-destructive/10 p-3 text-xs text-destructive">
{error}
</pre>
)}
</CardContent>
</Card>
);
+56
View File
@@ -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>)> {
+3 -2
View File
@@ -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(
+23
View File
@@ -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)]
+2 -1
View 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};
+12 -2
View File
@@ -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,
+4
View 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
+42 -1
View File
@@ -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() {
+5
View File
@@ -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),
+53
View File
@@ -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;