This commit is contained in:
lda
2026-04-14 06:03:06 +07:00 Unverified
parent 8948171ae7
commit f034fc5afd
11 changed files with 303 additions and 18 deletions
+1
View File
@@ -196,6 +196,7 @@ Control-plane admin API includes token management endpoints:
- `GET /api/v1/control/alerts/history?since_unix=<ts>&limit=<n>` - `GET /api/v1/control/alerts/history?since_unix=<ts>&limit=<n>`
- `GET /api/v1/control/alerts/ws` (websocket snapshots + recent transitions) - `GET /api/v1/control/alerts/ws` (websocket snapshots + recent transitions)
- `DELETE /api/v1/control/agents/{agent_id}` - `DELETE /api/v1/control/agents/{agent_id}`
- `PATCH /api/v1/control/agents/{agent_id}/nickname`
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.
+11
View File
@@ -11,6 +11,7 @@ import {
fetchAlerts, fetchAlerts,
fetchAudit, fetchAudit,
revokeAgent, revokeAgent,
setAgentNickname,
} from "@/api"; } from "@/api";
import { AppLayout } from "@/layout/AppLayout"; import { AppLayout } from "@/layout/AppLayout";
import { AgentsPage } from "@/pages/AgentsPage"; import { AgentsPage } from "@/pages/AgentsPage";
@@ -73,6 +74,15 @@ export function App() {
return result.revoked; return result.revoked;
} }
async function onSetAgentNickname(
agentId: string,
nickname: string | null,
): Promise<boolean> {
const result = await setAgentNickname(agentId, nickname);
await loadAll();
return result.updated;
}
async function refreshAlertsAndHistory() { async function refreshAlertsAndHistory() {
const [nextAlerts, nextHistory] = await Promise.all([ const [nextAlerts, nextHistory] = await Promise.all([
fetchAlerts(), fetchAlerts(),
@@ -149,6 +159,7 @@ export function App() {
selectedAgentId={selectedAgentId} selectedAgentId={selectedAgentId}
onSelectAgent={setSelectedAgentId} onSelectAgent={setSelectedAgentId}
onRevokeAgent={onRevokeAgent} onRevokeAgent={onRevokeAgent}
onSetAgentNickname={onSetAgentNickname}
/> />
} }
/> />
+25 -1
View File
@@ -1,4 +1,8 @@
export type Agent = { agent_id: string; connected: boolean }; export type Agent = {
agent_id: string;
connected: boolean;
nickname?: string | null;
};
export type Alert = { export type Alert = {
alert_id: string; alert_id: string;
@@ -62,6 +66,12 @@ export type RevokeAgentResponse = {
revoked: boolean; revoked: boolean;
}; };
export type SetAgentNicknameResponse = {
agent_id: string;
nickname: string | null;
updated: boolean;
};
async function request<T>(path: string, init?: RequestInit): Promise<T> { async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, { const res = await fetch(path, {
...init, ...init,
@@ -134,6 +144,20 @@ export function revokeAgent(agentId: string): Promise<RevokeAgentResponse> {
); );
} }
export function setAgentNickname(
agentId: string,
nickname: string | null,
): Promise<SetAgentNicknameResponse> {
const normalized = nickname?.trim() ?? "";
return request<SetAgentNicknameResponse>(
`/api/v1/control/agents/${encodeURIComponent(agentId)}/nickname`,
{
method: "PATCH",
body: JSON.stringify({ nickname: normalized ? normalized : null }),
},
);
}
export function runCommand( export function runCommand(
agentId: string, agentId: string,
kind: CommandKind, kind: CommandKind,
+65 -11
View File
@@ -1,6 +1,7 @@
import type { Agent } from "@/api"; import type { Agent } from "@/api";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { useState } from "react"; import { useState } from "react";
type Props = { type Props = {
@@ -8,6 +9,10 @@ type Props = {
selectedAgentId: string; selectedAgentId: string;
onSelectAgent: (agentId: string) => void; onSelectAgent: (agentId: string) => void;
onRevokeAgent: (agentId: string) => Promise<boolean>; onRevokeAgent: (agentId: string) => Promise<boolean>;
onSetAgentNickname: (
agentId: string,
nickname: string | null,
) => Promise<boolean>;
}; };
export function AgentsPage({ export function AgentsPage({
@@ -15,10 +20,17 @@ export function AgentsPage({
selectedAgentId, selectedAgentId,
onSelectAgent, onSelectAgent,
onRevokeAgent, onRevokeAgent,
onSetAgentNickname,
}: Props) { }: Props) {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [status, setStatus] = useState(""); const [status, setStatus] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [drafts, setDrafts] = useState<Record<string, string>>({});
function displayLabel(agent: Agent): string {
const nickname = agent.nickname?.trim();
return nickname ? nickname : agent.agent_id;
}
async function onRevoke(agentId: string) { async function onRevoke(agentId: string) {
if (!window.confirm(`Revoke agent credentials for ${agentId}?`)) { if (!window.confirm(`Revoke agent credentials for ${agentId}?`)) {
@@ -39,6 +51,25 @@ export function AgentsPage({
} }
} }
async function onSaveNickname(agent: Agent) {
const draft = drafts[agent.agent_id] ?? agent.nickname ?? "";
setBusy(true);
setStatus("");
setError("");
try {
const updated = await onSetAgentNickname(agent.agent_id, draft);
setStatus(
updated
? `Updated nickname for ${agent.agent_id}`
: `${agent.agent_id} not found`,
);
} catch (err) {
setError(String(err));
} finally {
setBusy(false);
}
}
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@@ -55,24 +86,47 @@ export function AgentsPage({
variant={ variant={
selectedAgentId === agent.agent_id ? "secondary" : "outline" selectedAgentId === agent.agent_id ? "secondary" : "outline"
} }
className="flex h-auto flex-1 items-center justify-between px-3 py-2 text-left" className="flex h-auto flex-1 items-center justify-between gap-3 px-3 py-2 text-left"
onClick={() => onSelectAgent(agent.agent_id)} onClick={() => onSelectAgent(agent.agent_id)}
disabled={busy} disabled={busy}
> >
<span>{agent.agent_id}</span> <span className="min-w-0 truncate">{displayLabel(agent)}</span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{agent.connected ? "connected" : "offline"} {agent.connected ? "connected" : "offline"}
</span> </span>
</Button> </Button>
<Button <div className="grid min-w-52 gap-2">
variant="outline" <Input
size="sm" value={drafts[agent.agent_id] ?? agent.nickname ?? ""}
className="shrink-0 border-destructive/50 text-destructive hover:bg-destructive/10" onChange={(e) =>
onClick={() => void onRevoke(agent.agent_id)} setDrafts((prev) => ({
disabled={busy} ...prev,
> [agent.agent_id]: e.target.value,
Revoke }))
</Button> }
placeholder="nickname (optional)"
disabled={busy}
/>
<div className="flex items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => void onSaveNickname(agent)}
disabled={busy}
>
Save Name
</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>
</div>
</div> </div>
))} ))}
{!agents.length && ( {!agents.length && (
+8 -1
View File
@@ -26,6 +26,11 @@ export function CommandsPage({
onSelectAgent, onSelectAgent,
onAfterCommand, onAfterCommand,
}: Props) { }: Props) {
function displayAgentLabel(agent: Agent): string {
const nickname = agent.nickname?.trim();
return nickname ? nickname : agent.agent_id;
}
const [kind, setKind] = useState<CommandKind>("devs"); const [kind, setKind] = useState<CommandKind>("devs");
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [output, setOutput] = useState("Select agent and run a command"); const [output, setOutput] = useState("Select agent and run a command");
@@ -77,7 +82,9 @@ export function CommandsPage({
className={`size-2 shrink-0 rounded-full ${agent.connected ? "bg-emerald-500" : "bg-zinc-400"}`} className={`size-2 shrink-0 rounded-full ${agent.connected ? "bg-emerald-500" : "bg-zinc-400"}`}
aria-hidden aria-hidden
/> />
<span className="min-w-0 truncate">{agent.agent_id}</span> <span className="min-w-0 truncate">
{displayAgentLabel(agent)}
</span>
<span className="shrink-0 text-xs text-muted-foreground"> <span className="shrink-0 text-xs text-muted-foreground">
{agent.connected ? "connected" : "offline"} {agent.connected ? "connected" : "offline"}
</span> </span>
+9 -2
View File
@@ -44,6 +44,11 @@ export function DevicesPage({
onSelectAgent, onSelectAgent,
onAfterWake, onAfterWake,
}: Props) { }: Props) {
function displayAgentLabel(agent: Agent): string {
const nickname = agent.nickname?.trim();
return nickname ? nickname : agent.agent_id;
}
const selectedAgent = agents.find( const selectedAgent = agents.find(
(agent) => agent.agent_id === selectedAgentId, (agent) => agent.agent_id === selectedAgentId,
); );
@@ -351,7 +356,9 @@ export function DevicesPage({
) : null} ) : null}
<span className="min-w-0 truncate"> <span className="min-w-0 truncate">
{selectedAgentId {selectedAgentId
? selectedAgentId ? selectedAgent
? displayAgentLabel(selectedAgent)
: selectedAgentId
: "Select connected agent"} : "Select connected agent"}
</span> </span>
{selectedAgent ? ( {selectedAgent ? (
@@ -378,7 +385,7 @@ export function DevicesPage({
aria-hidden aria-hidden
/> />
<span className="min-w-0 truncate"> <span className="min-w-0 truncate">
{agent.agent_id} {displayAgentLabel(agent)}
</span> </span>
<span className="shrink-0 text-xs text-muted-foreground"> <span className="shrink-0 text-xs text-muted-foreground">
{agent.connected ? "connected" : "offline"} {agent.connected ? "connected" : "offline"}
+4 -2
View File
@@ -16,6 +16,7 @@ use crate::state::AuditEventInput;
pub struct AgentStatus { pub struct AgentStatus {
pub agent_id: String, pub agent_id: String,
pub connected: bool, pub connected: bool,
pub nickname: Option<String>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -37,14 +38,15 @@ pub struct RelayCommandResponse {
pub async fn list_agents( pub async fn list_agents(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> { ) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let enrolled = state.store.list_agents().await; let enrolled = state.store.list_agents_with_nicknames().await;
let sessions = state.sessions.read().await; let sessions = state.sessions.read().await;
let agents = enrolled let agents = enrolled
.into_iter() .into_iter()
.map(|agent_id| AgentStatus { .map(|(agent_id, nickname)| AgentStatus {
connected: sessions.contains_key(&agent_id), connected: sessions.contains_key(&agent_id),
agent_id, agent_id,
nickname,
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
+79
View File
@@ -56,6 +56,18 @@ pub struct RevokeAgentResponse {
pub revoked: bool, pub revoked: bool,
} }
#[derive(Debug, Deserialize)]
pub struct SetAgentNicknameRequest {
pub nickname: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SetAgentNicknameResponse {
pub agent_id: String,
pub nickname: Option<String>,
pub updated: bool,
}
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct StateStatsResponse { pub struct StateStatsResponse {
pub db_path: String, pub db_path: String,
@@ -336,6 +348,73 @@ pub async fn revoke_agent(
} }
} }
pub async fn set_agent_nickname(
State(state): State<AppState>,
AxumPath(agent_id): AxumPath<String>,
Json(req): Json<SetAgentNicknameRequest>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let normalized = req
.nickname
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
.map(ToOwned::to_owned);
match state
.store
.set_agent_nickname(&agent_id, normalized.as_deref())
.await
{
Ok(updated) => {
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_nickname_set".into(),
outcome: if updated {
"ok".into()
} else {
"not_found".into()
},
latency_ms: None,
message: if updated {
"updated agent nickname".into()
} else {
"agent not found for nickname update".into()
},
metadata: serde_json::json!({
"agent_id": agent_id,
"nickname": normalized,
}),
})
.await
{
warn!(error = %err, "failed to append audit event for nickname set");
}
Ok((
StatusCode::OK,
Json(SetAgentNicknameResponse {
agent_id,
nickname: normalized,
updated,
}),
))
}
Err(err) => {
warn!(error = %err, "failed to update agent nickname");
Err(json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"set_agent_nickname_failed",
&err.to_string(),
))
}
}
}
pub async fn state_stats( pub async fn state_stats(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> { ) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
+1 -1
View File
@@ -12,7 +12,7 @@ pub use commands::{list_agents, run_command};
pub use control::{ pub use control::{
EnrollTokenStatus, IssueEnrollTokenResponse, RevokeAgentResponse, RevokeEnrollTokenResponse, EnrollTokenStatus, IssueEnrollTokenResponse, RevokeAgentResponse, RevokeEnrollTokenResponse,
StateStatsResponse, enroll, healthz, issue_enroll_token, list_enroll_tokens, revoke_agent, StateStatsResponse, enroll, healthz, issue_enroll_token, list_enroll_tokens, revoke_agent,
revoke_enroll_token, state_stats, revoke_enroll_token, set_agent_nickname, state_stats,
}; };
pub fn json_error( pub fn json_error(
+4
View File
@@ -95,6 +95,10 @@ fn control_api_routes() -> Router<AppState> {
"/api/v1/control/agents/{agent_id}", "/api/v1/control/agents/{agent_id}",
axum::routing::delete(api::revoke_agent), axum::routing::delete(api::revoke_agent),
) )
.route(
"/api/v1/control/agents/{agent_id}/nickname",
axum::routing::patch(api::set_agent_nickname),
)
.route( .route(
"/api/v1/control/agents/{agent_id}/command", "/api/v1/control/agents/{agent_id}/command",
post(api::run_command), post(api::run_command),
+96
View File
@@ -15,6 +15,7 @@ pub struct Store {
meta: sled::Tree, meta: sled::Tree,
enroll_tokens: sled::Tree, enroll_tokens: sled::Tree,
agents: sled::Tree, agents: sled::Tree,
agent_meta: sled::Tree,
audit_events: sled::Tree, audit_events: sled::Tree,
active_alerts: sled::Tree, active_alerts: sled::Tree,
alert_transitions: sled::Tree, alert_transitions: sled::Tree,
@@ -45,6 +46,9 @@ impl Store {
let agents_tree = db let agents_tree = db
.open_tree("agents") .open_tree("agents")
.context("failed to open agents tree")?; .context("failed to open agents tree")?;
let agent_meta_tree = db
.open_tree("agent_meta")
.context("failed to open agent_meta tree")?;
let audit_events_tree = db let audit_events_tree = db
.open_tree("audit_events") .open_tree("audit_events")
.context("failed to open audit_events tree")?; .context("failed to open audit_events tree")?;
@@ -60,6 +64,7 @@ impl Store {
meta: meta_tree, meta: meta_tree,
enroll_tokens: enroll_tree, enroll_tokens: enroll_tree,
agents: agents_tree, agents: agents_tree,
agent_meta: agent_meta_tree,
audit_events: audit_events_tree, audit_events: audit_events_tree,
active_alerts: active_alerts_tree, active_alerts: active_alerts_tree,
alert_transitions: alert_transitions_tree, alert_transitions: alert_transitions_tree,
@@ -194,12 +199,39 @@ impl Store {
.context("failed removing agent credentials")? .context("failed removing agent credentials")?
.is_some(); .is_some();
if removed { if removed {
let _ = self.agent_meta.remove(agent_id.as_bytes());
self.flush() self.flush()
.context("failed flushing db after agent revoke")?; .context("failed flushing db after agent revoke")?;
} }
Ok(removed) Ok(removed)
} }
pub async fn set_agent_nickname(&self, agent_id: &str, nickname: Option<&str>) -> Result<bool> {
if !self
.agents
.contains_key(agent_id.as_bytes())
.context("failed checking agent existence")?
{
return Ok(false);
}
let normalized = nickname.map(str::trim).filter(|v| !v.is_empty());
if let Some(value) = normalized {
self.agent_meta
.insert(agent_id.as_bytes(), value.as_bytes())
.context("failed persisting agent nickname")?;
} else {
let _ = self
.agent_meta
.remove(agent_id.as_bytes())
.context("failed clearing agent nickname")?;
}
self.flush()
.context("failed flushing db after nickname update")?;
Ok(true)
}
pub async fn stats(&self) -> Result<StateStats> { pub async fn stats(&self) -> Result<StateStats> {
let now = now_unix(); let now = now_unix();
let mut enroll_token_count = 0usize; let mut enroll_token_count = 0usize;
@@ -257,6 +289,28 @@ impl Store {
out out
} }
pub async fn list_agents_with_nicknames(&self) -> Vec<(String, Option<String>)> {
let mut out = self
.agents
.iter()
.filter_map(|item| item.ok())
.filter_map(|(key, _)| String::from_utf8(key.to_vec()).ok())
.map(|agent_id| {
let nickname = self
.agent_meta
.get(agent_id.as_bytes())
.ok()
.flatten()
.and_then(|v| String::from_utf8(v.to_vec()).ok())
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty());
(agent_id, nickname)
})
.collect::<Vec<_>>();
out.sort_by(|a, b| a.0.cmp(&b.0));
out
}
pub async fn append_audit_event(&self, input: AuditEventInput) -> Result<AuditEvent> { pub async fn append_audit_event(&self, input: AuditEventInput) -> Result<AuditEvent> {
let event = AuditEvent { let event = AuditEvent {
event_id: format!("evt-{}", Uuid::new_v4()), event_id: format!("evt-{}", Uuid::new_v4()),
@@ -417,6 +471,9 @@ impl Store {
.flush() .flush()
.context("failed to flush enroll token tree")?; .context("failed to flush enroll token tree")?;
self.agents.flush().context("failed to flush agents tree")?; self.agents.flush().context("failed to flush agents tree")?;
self.agent_meta
.flush()
.context("failed to flush agent_meta tree")?;
self.audit_events self.audit_events
.flush() .flush()
.context("failed to flush audit event tree")?; .context("failed to flush audit event tree")?;
@@ -747,6 +804,45 @@ mod tests {
cleanup_dir(&dir); cleanup_dir(&dir);
} }
#[tokio::test]
async fn nickname_set_and_clear_roundtrip() {
let (store, dir) = make_store().await;
store
.enroll_tokens
.insert(b"enr-nickname-test", &(u64::MAX - 10).to_le_bytes())
.expect("insert should succeed");
let issued = store
.enroll("enr-nickname-test")
.await
.expect("enroll should succeed");
let updated = store
.set_agent_nickname(&issued.agent_id, Some("kitchen-router"))
.await
.expect("nickname set should succeed");
assert!(updated);
let listed = store.list_agents_with_nicknames().await;
assert!(listed.iter().any(|(id, name)| {
id == &issued.agent_id && name.as_deref() == Some("kitchen-router")
}));
let cleared = store
.set_agent_nickname(&issued.agent_id, None)
.await
.expect("nickname clear should succeed");
assert!(cleared);
let listed = store.list_agents_with_nicknames().await;
assert!(listed
.iter()
.any(|(id, name)| id == &issued.agent_id && name.is_none()));
cleanup_dir(&dir);
}
#[tokio::test] #[tokio::test]
async fn audit_events_append_and_filter() { async fn audit_events_append_and_filter() {
let (store, dir) = make_store().await; let (store, dir) = make_store().await;