delete agent flow
This commit is contained in:
@@ -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
@@ -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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
+67
-13
@@ -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}
|
||||
variant={
|
||||
selectedAgentId === agent.agent_id ? "secondary" : "outline"
|
||||
}
|
||||
className="flex h-auto w-full items-center justify-between px-3 py-2 text-left"
|
||||
onClick={() => onSelectAgent(agent.agent_id)}
|
||||
>
|
||||
<span>{agent.agent_id}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{agent.connected ? "connected" : "offline"}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
selectedAgentId === agent.agent_id ? "secondary" : "outline"
|
||||
}
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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