device page, fmt;

This commit is contained in:
lda
2026-04-12 21:47:23 +07:00 Unverified
parent bb813170d1
commit b576ba9a01
19 changed files with 356 additions and 162 deletions
+1 -4
View File
@@ -85,10 +85,7 @@ pub async fn get_with_handle(
.or_default() .or_default()
.push(AddrInfo { .push(AddrInfo {
family, family,
cidr: InterfaceCidr { cidr: InterfaceCidr { local, prefixlen },
local,
prefixlen,
},
broadcast, broadcast,
scope, scope,
label, label,
+10 -7
View File
@@ -25,12 +25,7 @@ pub fn render_leases_table(leases: &[DhcpLeaseWithState]) -> Table {
Cell::new(lease.lease_line.ip.to_string()), Cell::new(lease.lease_line.ip.to_string()),
Cell::new(lease.lease_line.mac.to_string()), Cell::new(lease.lease_line.mac.to_string()),
Cell::new(lease.lease_line.name.clone().unwrap_or_default()), Cell::new(lease.lease_line.name.clone().unwrap_or_default()),
Cell::new( Cell::new(lease.nud_state.map(|v| v.to_string()).unwrap_or_default()),
lease
.nud_state
.map(|v| v.to_string())
.unwrap_or_default(),
),
]); ]);
} }
table table
@@ -51,7 +46,15 @@ pub fn render_wake_table(result: &WakeResult) -> Table {
pub fn render_devs_table(devs: &[InterfaceSummary]) -> Table { pub fn render_devs_table(devs: &[InterfaceSummary]) -> Table {
let mut table = base_table(); let mut table = base_table();
table.set_header(vec!["Ifname", "State", "MAC", "Family", "CIDR", "Broadcast", "Scope/Label"]); table.set_header(vec![
"Ifname",
"State",
"MAC",
"Family",
"CIDR",
"Broadcast",
"Scope/Label",
]);
for dev in devs { for dev in devs {
if dev.addrs.is_empty() { if dev.addrs.is_empty() {
+5 -1
View File
@@ -20,7 +20,11 @@ pub async fn get_status(query: DeviceQuery) -> Result<StatusResponse> {
.iter() .iter()
.flat_map(device_to_status_rows) .flat_map(device_to_status_rows)
.collect(); .collect();
debug!(rows = table.len(), devices = inventory.devices.len(), "built status response"); debug!(
rows = table.len(),
devices = inventory.devices.len(),
"built status response"
);
Ok(Status { Ok(Status {
name: query.name, name: query.name,
table, table,
+5 -1
View File
@@ -89,7 +89,11 @@ fn broadcast_wake_targets_from_interfaces(
anyhow::bail!("no broadcast-capable interfaces found"); anyhow::bail!("no broadcast-capable interfaces found");
} }
debug!(targets = targets.len(), interfaces = interfaces.len(), "built broadcast wake targets"); debug!(
targets = targets.len(),
interfaces = interfaces.len(),
"built broadcast wake targets"
);
Ok(targets) Ok(targets)
} }
+19 -17
View File
@@ -120,6 +120,12 @@ function chooseWakeTarget(device: DeviceRow): string {
: (device.macs[0] || device.ips[0] || ""); : (device.macs[0] || device.ips[0] || "");
} }
function summarize(values: string[]): string {
if (!values.length) return "-";
if (values.length === 1) return values[0];
return `${values[0]} (+${values.length - 1})`;
}
function loadHistory(): WakeEvent[] { function loadHistory(): WakeEvent[] {
try { try {
const raw = window.localStorage.getItem(WAKE_HISTORY_KEY); const raw = window.localStorage.getItem(WAKE_HISTORY_KEY);
@@ -337,46 +343,42 @@ export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWak
<p className="muted">Showing {filtered.length} of {rows.length}</p> <p className="muted">Showing {filtered.length} of {rows.length}</p>
{error && <pre className="error">{error}</pre>} {error && <pre className="error">{error}</pre>}
<div className="list device-list"> <div className="list device-list">
<div className="row plain device-row device-header" style={{ fontWeight: 600 }}> <div className="row plain device-row device-header">
<span <span
className="sortable-col" className="sortable-col device-cell"
style={{ cursor: "pointer" }}
onClick={() => setSort((s) => ({ key: "name", dir: s.key === "name" && s.dir === "asc" ? "desc" : "asc" }))} onClick={() => setSort((s) => ({ key: "name", dir: s.key === "name" && s.dir === "asc" ? "desc" : "asc" }))}
> >
Name {sort.key === "name" ? (sort.dir === "asc" ? "▲" : "▼") : ""} Name {sort.key === "name" ? (sort.dir === "asc" ? "▲" : "▼") : ""}
</span> </span>
<span <span
className="sortable-col" className="sortable-col device-cell"
style={{ cursor: "pointer" }}
onClick={() => setSort((s) => ({ key: "ip", dir: s.key === "ip" && s.dir === "asc" ? "desc" : "asc" }))} onClick={() => setSort((s) => ({ key: "ip", dir: s.key === "ip" && s.dir === "asc" ? "desc" : "asc" }))}
> >
IP {sort.key === "ip" ? (sort.dir === "asc" ? "▲" : "▼") : ""} IP {sort.key === "ip" ? (sort.dir === "asc" ? "▲" : "▼") : ""}
</span> </span>
<span <span
className="sortable-col" className="sortable-col device-cell"
style={{ cursor: "pointer" }}
onClick={() => setSort((s) => ({ key: "mac", dir: s.key === "mac" && s.dir === "asc" ? "desc" : "asc" }))} onClick={() => setSort((s) => ({ key: "mac", dir: s.key === "mac" && s.dir === "asc" ? "desc" : "asc" }))}
> >
MAC {sort.key === "mac" ? (sort.dir === "asc" ? "▲" : "▼") : ""} MAC {sort.key === "mac" ? (sort.dir === "asc" ? "▲" : "▼") : ""}
</span> </span>
<span <span
className="sortable-col" className="sortable-col device-cell"
style={{ cursor: "pointer" }}
onClick={() => setSort((s) => ({ key: "presence", dir: s.key === "presence" && s.dir === "asc" ? "desc" : "asc" }))} onClick={() => setSort((s) => ({ key: "presence", dir: s.key === "presence" && s.dir === "asc" ? "desc" : "asc" }))}
> >
Presence {sort.key === "presence" ? (sort.dir === "asc" ? "▲" : "▼") : ""} Presence {sort.key === "presence" ? (sort.dir === "asc" ? "▲" : "▼") : ""}
</span> </span>
<span>Interfaces</span> <span className="device-cell">Interfaces</span>
<span></span> <span className="device-cell device-action"></span>
</div> </div>
{filtered.map((row) => ( {filtered.map((row) => (
<div className="row plain device-row" key={row.id}> <div className="row plain device-row" key={row.id}>
<span>{row.name}</span> <span className="device-cell" data-label="Name" title={row.name}>{row.name}</span>
<span className="muted">{row.ips.join(", ") || "-"}</span> <span className="device-cell muted" data-label="IP" title={row.ips.join(", ") || "-"}>{summarize(row.ips)}</span>
<span className="muted">{row.macs.join(", ") || "-"}</span> <span className="device-cell muted" data-label="MAC" title={row.macs.join(", ") || "-"}>{summarize(row.macs)}</span>
<span className="pill">{row.presence}</span> <span className="device-cell" data-label="Presence"><span className="pill">{row.presence}</span></span>
<span>{row.interfaces.join(", ") || "-"}</span> <span className="device-cell" data-label="Interfaces" title={row.interfaces.join(", ") || "-"}>{summarize(row.interfaces)}</span>
<span> <span className="device-cell device-action" data-label="">
<button onClick={() => void wakeDevice(row)} disabled={wakeBusyId === row.id || !selectedAgentId}> <button onClick={() => void wakeDevice(row)} disabled={wakeBusyId === row.id || !selectedAgentId}>
{wakeBusyId === row.id ? "Waking..." : "Wake"} {wakeBusyId === row.id ? "Waking..." : "Wake"}
</button> </button>
+79 -21
View File
@@ -168,33 +168,91 @@ input, select { width: 100%; padding: 0.45rem 0.55rem; }
} }
.device-row { .device-row {
align-items: stretch; display: grid;
} grid-template-columns: minmax(110px, 1.1fr) minmax(160px, 2fr) minmax(130px, 1.5fr) minmax(110px, 0.9fr) minmax(120px, 1fr) auto;
.device-main {
min-width: 0;
}
.device-main strong {
display: inline-block;
margin-bottom: 0.25rem;
}
.device-meta {
display: flex;
align-items: center; align-items: center;
gap: 0.45rem; gap: 0.55rem;
flex-wrap: wrap; }
justify-content: flex-end;
.device-header {
font-weight: 600;
position: sticky;
top: 0;
z-index: 2;
background: #091429;
}
.device-cell {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sortable-col {
cursor: pointer;
user-select: none;
}
.device-action {
justify-self: end;
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.device-row { .device-header {
flex-direction: column; display: none;
} }
.device-meta { .device-row {
justify-content: flex-start; grid-template-columns: 1fr auto;
grid-template-areas:
"name action"
"presence presence"
"ip ip"
"mac mac"
"interfaces interfaces";
align-items: start;
}
.device-row .device-cell {
white-space: normal;
overflow-wrap: anywhere;
text-overflow: clip;
}
.device-row .device-cell[data-label]::before {
content: attr(data-label);
display: block;
font-size: 0.72rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.03em;
margin-bottom: 0.1rem;
}
.device-row .device-cell[data-label="Name"] {
grid-area: name;
}
.device-row .device-cell[data-label="Presence"] {
grid-area: presence;
}
.device-row .device-cell[data-label="IP"] {
grid-area: ip;
}
.device-row .device-cell[data-label="MAC"] {
grid-area: mac;
}
.device-row .device-cell[data-label="Interfaces"] {
grid-area: interfaces;
}
.device-row .device-action {
grid-area: action;
justify-self: end;
} }
.quick-wake { .quick-wake {
+18 -4
View File
@@ -40,26 +40,40 @@ async fn dispatch_leases(req: LeasesRequest) -> Result<CommandResult> {
include_state: req.include_state, include_state: req.include_state,
}) })
.await?; .await?;
debug!(rows = leases.len(), include_state = req.include_state, "dispatched leases command"); debug!(
rows = leases.len(),
include_state = req.include_state,
"dispatched leases command"
);
Ok(CommandResult::Leases { rows: leases }) Ok(CommandResult::Leases { rows: leases })
} }
async fn dispatch_devs(req: DevsRequest) -> Result<CommandResult> { async fn dispatch_devs(req: DevsRequest) -> Result<CommandResult> {
let mut devs = if let Some(name) = &req.dev { let mut devs = if let Some(name) = &req.dev {
wakey::get_interface_summary(name).await?.into_iter().collect() wakey::get_interface_summary(name)
.await?
.into_iter()
.collect()
} else { } else {
wakey::get_interface_summaries().await? wakey::get_interface_summaries().await?
}; };
if req.up_only { if req.up_only {
devs.retain(|dev| dev.operstate == "up"); devs.retain(|dev| dev.operstate == "up");
} }
debug!(rows = devs.len(), up_only = req.up_only, "dispatched devs command"); debug!(
rows = devs.len(),
up_only = req.up_only,
"dispatched devs command"
);
Ok(CommandResult::Devs { rows: devs }) Ok(CommandResult::Devs { rows: devs })
} }
async fn dispatch_inventory(req: InventoryRequest) -> Result<CommandResult> { async fn dispatch_inventory(req: InventoryRequest) -> Result<CommandResult> {
let inventory = wakey::inventory(req.into_device_query()).await?; let inventory = wakey::inventory(req.into_device_query()).await?;
debug!(rows = inventory.devices.len(), "dispatched inventory command"); debug!(
rows = inventory.devices.len(),
"dispatched inventory command"
);
Ok(CommandResult::Inventory(inventory)) Ok(CommandResult::Inventory(inventory))
} }
+13 -3
View File
@@ -18,7 +18,11 @@ struct EnrollResponse {
server_url: Option<String>, server_url: Option<String>,
} }
pub async fn enroll(server_url: &str, enroll_token: &str, config_path: &Path) -> Result<AgentConfig> { pub async fn enroll(
server_url: &str,
enroll_token: &str,
config_path: &Path,
) -> Result<AgentConfig> {
let server_url = normalize_server_url(server_url); let server_url = normalize_server_url(server_url);
let endpoint = format!("{server_url}/api/v1/agents/enroll"); let endpoint = format!("{server_url}/api/v1/agents/enroll");
info!(endpoint = %endpoint, config_path = %config_path.display(), "starting agent enrollment"); info!(endpoint = %endpoint, config_path = %config_path.display(), "starting agent enrollment");
@@ -68,7 +72,10 @@ mod tests {
use std::net::{SocketAddr, TcpListener}; use std::net::{SocketAddr, TcpListener};
use std::thread; use std::thread;
fn spawn_enroll_server(response_body: &'static str, status: &'static str) -> (String, thread::JoinHandle<()>) { fn spawn_enroll_server(
response_body: &'static str,
status: &'static str,
) -> (String, thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind test listener"); let listener = TcpListener::bind("127.0.0.1:0").expect("bind test listener");
let addr: SocketAddr = listener.local_addr().expect("local addr"); let addr: SocketAddr = listener.local_addr().expect("local addr");
let handle = thread::spawn(move || { let handle = thread::spawn(move || {
@@ -103,7 +110,10 @@ mod tests {
#[test] #[test]
fn normalize_server_url_trims_slash() { fn normalize_server_url_trims_slash() {
assert_eq!(normalize_server_url("https://example.com/"), "https://example.com"); assert_eq!(
normalize_server_url("https://example.com/"),
"https://example.com"
);
} }
#[tokio::test] #[tokio::test]
+7 -3
View File
@@ -3,8 +3,8 @@ mod config;
mod dispatch; mod dispatch;
mod enroll; mod enroll;
mod protocol; mod protocol;
mod session;
mod serve; mod serve;
mod session;
mod tracing; mod tracing;
use anyhow::Result; use anyhow::Result;
@@ -29,7 +29,9 @@ async fn main() -> Result<()> {
if args.reload_running { if args.reload_running {
match serve::reload_daemon(&args.pid_file) { match serve::reload_daemon(&args.pid_file) {
Ok(()) => println!("reload=signaled pid_file={}", args.pid_file.display()), Ok(()) => println!("reload=signaled pid_file={}", args.pid_file.display()),
Err(err) => ::tracing::warn!(error = %err, pid_file = %args.pid_file.display(), "enroll completed but daemon reload failed"), Err(err) => {
::tracing::warn!(error = %err, pid_file = %args.pid_file.display(), "enroll completed but daemon reload failed")
}
} }
} }
} }
@@ -58,7 +60,9 @@ fn init_config(args: InitConfigArgs) -> Result<()> {
server_url: args server_url: args
.server_url .server_url
.unwrap_or_else(|| "https://control-plane.example.com".to_string()), .unwrap_or_else(|| "https://control-plane.example.com".to_string()),
agent_id: args.agent_id.unwrap_or_else(|| "REPLACE_ME_AGENT_ID".to_string()), agent_id: args
.agent_id
.unwrap_or_else(|| "REPLACE_ME_AGENT_ID".to_string()),
agent_token: args agent_token: args
.agent_token .agent_token
.unwrap_or_else(|| "REPLACE_ME_AGENT_TOKEN".to_string()), .unwrap_or_else(|| "REPLACE_ME_AGENT_TOKEN".to_string()),
+4 -4
View File
@@ -2,11 +2,11 @@ use macaddr::MacAddr;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fmt; use std::fmt;
use std::net::IpAddr; use std::net::IpAddr;
use wakey_core::{
DeviceFilters, DeviceInventory, DeviceQuery, DhcpLeaseWithState, InterfaceSummary, NeighborEntry,
Status, WakeResult,
};
use wakey_core::parse::mac; use wakey_core::parse::mac;
use wakey_core::{
DeviceFilters, DeviceInventory, DeviceQuery, DhcpLeaseWithState, InterfaceSummary,
NeighborEntry, Status, WakeResult,
};
#[derive(Debug, Clone, PartialEq, Eq, Hash)] #[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RequestId(String); pub struct RequestId(String);
+24 -11
View File
@@ -67,9 +67,12 @@ async fn run_once(config: &AgentConfig) -> Result<()> {
} }
let (mut sink, mut source) = stream.split(); let (mut sink, mut source) = stream.split();
send_json(&mut sink, &ClientMessage::Hello { send_json(
agent_id: config.agent_id.clone(), &mut sink,
}) &ClientMessage::Hello {
agent_id: config.agent_id.clone(),
},
)
.await?; .await?;
send_json( send_json(
&mut sink, &mut sink,
@@ -223,7 +226,8 @@ where
S: SinkExt<Message> + Unpin, S: SinkExt<Message> + Unpin,
<S as futures_util::Sink<Message>>::Error: std::error::Error + Send + Sync + 'static, <S as futures_util::Sink<Message>>::Error: std::error::Error + Send + Sync + 'static,
{ {
let payload = serde_json::to_string(message).context("failed to serialize websocket message")?; let payload =
serde_json::to_string(message).context("failed to serialize websocket message")?;
debug!(message_type = %client_message_kind(message), "sending websocket message"); debug!(message_type = %client_message_kind(message), "sending websocket message");
sink.send(Message::Text(payload)) sink.send(Message::Text(payload))
.await .await
@@ -318,7 +322,10 @@ mod tests {
impl Sink<Message> for RecordingSink { impl Sink<Message> for RecordingSink {
type Error = io::Error; type Error = io::Error;
fn poll_ready(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> { fn poll_ready(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,
) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(())) Poll::Ready(Ok(()))
} }
@@ -331,11 +338,17 @@ mod tests {
Ok(()) Ok(())
} }
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> { fn poll_flush(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,
) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(())) Poll::Ready(Ok(()))
} }
fn poll_close(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> { fn poll_close(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,
) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(())) Poll::Ready(Ok(()))
} }
} }
@@ -369,8 +382,8 @@ mod tests {
"wake", "wake",
CommandResult::Wake(wakey_core::WakeResult { result: vec![] }), CommandResult::Wake(wakey_core::WakeResult { result: vec![] }),
) )
.await .await
.expect("send should succeed"); .expect("send should succeed");
assert_eq!(sink.sent.len(), 1); assert_eq!(sink.sent.len(), 1);
let Some(Message::Text(payload)) = sink.sent.pop_front() else { let Some(Message::Text(payload)) = sink.sent.pop_front() else {
@@ -391,8 +404,8 @@ mod tests {
"devs", "devs",
CommandResult::Devs { rows: vec![] }, CommandResult::Devs { rows: vec![] },
) )
.await .await
.expect("fallback error frame should be sent"); .expect("fallback error frame should be sent");
assert_eq!(sink.sent.len(), 1); assert_eq!(sink.sent.len(), 1);
let Some(Message::Text(payload)) = sink.sent.pop_front() else { let Some(Message::Text(payload)) = sink.sent.pop_front() else {
+4 -1
View File
@@ -230,7 +230,10 @@ 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 if let Err(err) = state
.store .store
.append_audit_event(AuditEventInput { .append_audit_event(AuditEventInput {
+5 -1
View File
@@ -242,7 +242,11 @@ pub async fn revoke_enroll_token(
agent_id: None, agent_id: None,
request_id: None, request_id: None,
event_type: "enroll_token_revoke".into(), event_type: "enroll_token_revoke".into(),
outcome: if revoked { "ok".into() } else { "not_found".into() }, outcome: if revoked {
"ok".into()
} else {
"not_found".into()
},
latency_ms: None, latency_ms: None,
message: if revoked { message: if revoked {
"revoked enroll token".into() "revoked enroll token".into()
+3 -3
View File
@@ -1,14 +1,14 @@
use axum::Json; use axum::Json;
use axum::http::StatusCode; use axum::http::StatusCode;
mod alerts;
mod audit;
mod commands; mod commands;
mod control; mod control;
mod audit;
mod alerts;
pub use commands::{list_agents, run_command};
pub use alerts::{active_alerts, alert_history, alerts_stream}; pub use alerts::{active_alerts, alert_history, alerts_stream};
pub use audit::list_audit_events; pub use audit::list_audit_events;
pub use commands::{list_agents, run_command};
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,
+49 -16
View File
@@ -3,7 +3,9 @@ use std::time::Duration;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use crate::api; use crate::api;
use crate::cli::{IssueEnrollTokenArgs, ListEnrollTokensArgs, RevokeEnrollTokenArgs, StateStatsArgs}; use crate::cli::{
IssueEnrollTokenArgs, ListEnrollTokensArgs, RevokeEnrollTokenArgs, StateStatsArgs,
};
use crate::config; use crate::config;
use crate::state; use crate::state;
@@ -13,7 +15,10 @@ pub async fn issue_enroll_token(args: IssueEnrollTokenArgs) -> Result<()> {
if let Some(url) = args.public_url { if let Some(url) = args.public_url {
let base = config::normalize_public_url(&url); let base = config::normalize_public_url(&url);
let ttl_seconds = settings.ttl.as_secs().max(1); let ttl_seconds = settings.ttl.as_secs().max(1);
let endpoint = format!("{}?ttl_seconds={ttl_seconds}", config::issue_token_endpoint(&base)); let endpoint = format!(
"{}?ttl_seconds={ttl_seconds}",
config::issue_token_endpoint(&base)
);
tracing::info!(endpoint = %endpoint, "requesting live enroll token from running control-plane daemon"); tracing::info!(endpoint = %endpoint, "requesting live enroll token from running control-plane daemon");
let client = reqwest::Client::new(); let client = reqwest::Client::new();
@@ -50,7 +55,12 @@ pub async fn issue_enroll_token(args: IssueEnrollTokenArgs) -> Result<()> {
tracing::info!(data_dir = %settings.data_dir.display(), state_file = %settings.state_file.display(), ttl_seconds = settings.ttl.as_secs(), "issuing enroll token via offline state file fallback"); tracing::info!(data_dir = %settings.data_dir.display(), state_file = %settings.state_file.display(), ttl_seconds = settings.ttl.as_secs(), "issuing enroll token via offline state file fallback");
let store = state::Store::load_or_init(&settings.state_file, args.enroll_tokens, settings.ttl) let store = state::Store::load_or_init(&settings.state_file, args.enroll_tokens, settings.ttl)
.await .await
.with_context(|| format!("failed to initialize store {}", settings.state_file.display()))?; .with_context(|| {
format!(
"failed to initialize store {}",
settings.state_file.display()
)
})?;
let issued = store.issue_enroll_token(settings.ttl).await?; let issued = store.issue_enroll_token(settings.ttl).await?;
println!("enroll_token={}", issued.enroll_token); println!("enroll_token={}", issued.enroll_token);
println!("expires_at_unix={}", issued.expires_at_unix); println!("expires_at_unix={}", issued.expires_at_unix);
@@ -66,8 +76,7 @@ pub async fn list_enroll_tokens(args: ListEnrollTokensArgs) -> Result<()> {
if let Some(base) = settings.public_url.as_deref() { if let Some(base) = settings.public_url.as_deref() {
let url = format!( let url = format!(
"{}/api/v1/control/enroll-tokens?include_expired={}", "{}/api/v1/control/enroll-tokens?include_expired={}",
base, base, args.include_expired
args.include_expired
); );
let response = reqwest::get(&url) let response = reqwest::get(&url)
.await .await
@@ -100,9 +109,15 @@ pub async fn list_enroll_tokens(args: ListEnrollTokensArgs) -> Result<()> {
return Ok(()); return Ok(());
} }
let store = state::Store::load_or_init(&settings.state_file, Vec::new(), Duration::from_secs(1)) let store =
.await state::Store::load_or_init(&settings.state_file, Vec::new(), Duration::from_secs(1))
.with_context(|| format!("failed to initialize store {}", settings.state_file.display()))?; .await
.with_context(|| {
format!(
"failed to initialize store {}",
settings.state_file.display()
)
})?;
let tokens = store.list_enroll_tokens(args.include_expired).await?; let tokens = store.list_enroll_tokens(args.include_expired).await?;
if args.json { if args.json {
println!( println!(
@@ -146,9 +161,15 @@ pub async fn revoke_enroll_token(args: RevokeEnrollTokenArgs) -> Result<()> {
return Ok(()); return Ok(());
} }
let store = state::Store::load_or_init(&settings.state_file, Vec::new(), Duration::from_secs(1)) let store =
.await state::Store::load_or_init(&settings.state_file, Vec::new(), Duration::from_secs(1))
.with_context(|| format!("failed to initialize store {}", settings.state_file.display()))?; .await
.with_context(|| {
format!(
"failed to initialize store {}",
settings.state_file.display()
)
})?;
let removed = store.revoke_enroll_token(&args.token).await?; let removed = store.revoke_enroll_token(&args.token).await?;
println!("token={} revoked={}", args.token, removed); println!("token={} revoked={}", args.token, removed);
Ok(()) Ok(())
@@ -184,13 +205,22 @@ pub async fn state_stats(args: StateStatsArgs) -> Result<()> {
println!("schema_version={}", body.schema_version); println!("schema_version={}", body.schema_version);
println!("agent_count={}", body.agent_count); println!("agent_count={}", body.agent_count);
println!("enroll_token_count={}", body.enroll_token_count); println!("enroll_token_count={}", body.enroll_token_count);
println!("expired_enroll_token_count={}", body.expired_enroll_token_count); println!(
"expired_enroll_token_count={}",
body.expired_enroll_token_count
);
return Ok(()); return Ok(());
} }
let store = state::Store::load_or_init(&settings.state_file, Vec::new(), Duration::from_secs(1)) let store =
.await state::Store::load_or_init(&settings.state_file, Vec::new(), Duration::from_secs(1))
.with_context(|| format!("failed to initialize store {}", settings.state_file.display()))?; .await
.with_context(|| {
format!(
"failed to initialize store {}",
settings.state_file.display()
)
})?;
let stats = store.stats().await?; let stats = store.stats().await?;
if args.json { if args.json {
println!( println!(
@@ -203,6 +233,9 @@ pub async fn state_stats(args: StateStatsArgs) -> Result<()> {
println!("schema_version={}", stats.schema_version); println!("schema_version={}", stats.schema_version);
println!("agent_count={}", stats.agent_count); println!("agent_count={}", stats.agent_count);
println!("enroll_token_count={}", stats.enroll_token_count); println!("enroll_token_count={}", stats.enroll_token_count);
println!("expired_enroll_token_count={}", stats.expired_enroll_token_count); println!(
"expired_enroll_token_count={}",
stats.expired_enroll_token_count
);
Ok(()) Ok(())
} }
+12 -7
View File
@@ -5,8 +5,8 @@ use std::time::Duration;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use axum::Router; use axum::Router;
use axum::response::Redirect; use axum::response::Redirect;
use axum::routing::{get, post};
use axum::routing::get_service; use axum::routing::get_service;
use axum::routing::{get, post};
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tokio::sync::{Mutex, RwLock, mpsc, oneshot}; use tokio::sync::{Mutex, RwLock, mpsc, oneshot};
#[cfg(unix)] #[cfg(unix)]
@@ -25,9 +25,7 @@ use crate::ws;
mod admin; mod admin;
mod process; mod process;
pub use admin::{ pub use admin::{issue_enroll_token, list_enroll_tokens, revoke_enroll_token, state_stats};
issue_enroll_token, list_enroll_tokens, revoke_enroll_token, state_stats,
};
pub use process::reload_daemon; pub use process::reload_daemon;
use process::{remove_pid_file, write_pid_file}; use process::{remove_pid_file, write_pid_file};
@@ -55,7 +53,7 @@ pub enum AgentReply {
fn public_api_routes() -> Router<AppState> { fn public_api_routes() -> Router<AppState> {
Router::new() Router::new()
.route("/ui", get(|| async { Redirect::temporary("/ui/") })) .route("/ui", get(|| async { Redirect::temporary("/ui/") }))
.nest_service( .nest_service(
"/ui/", "/ui/",
get_service( get_service(
@@ -69,8 +67,14 @@ fn public_api_routes() -> Router<AppState> {
fn control_api_routes() -> Router<AppState> { fn control_api_routes() -> Router<AppState> {
Router::new() Router::new()
.route("/api/v1/control/enroll-token", post(api::issue_enroll_token)) .route(
.route("/api/v1/control/enroll-tokens", get(api::list_enroll_tokens)) "/api/v1/control/enroll-token",
post(api::issue_enroll_token),
)
.route(
"/api/v1/control/enroll-tokens",
get(api::list_enroll_tokens),
)
.route( .route(
"/api/v1/control/enroll-tokens/{token}", "/api/v1/control/enroll-tokens/{token}",
axum::routing::delete(api::revoke_enroll_token), axum::routing::delete(api::revoke_enroll_token),
@@ -128,6 +132,7 @@ pub async fn serve(daemon: config::DaemonConfig) -> Result<()> {
#[cfg(unix)] #[cfg(unix)]
{ {
use tokio::signal::unix::{SignalKind, signal}; use tokio::signal::unix::{SignalKind, signal};
let mut server = server;
let mut hup = signal(SignalKind::hangup()).context("failed to install SIGHUP handler")?; let mut hup = signal(SignalKind::hangup()).context("failed to install SIGHUP handler")?;
let mut gc_tick = tokio::time::interval(Duration::from_secs(300)); let mut gc_tick = tokio::time::interval(Duration::from_secs(300));
gc_tick.set_missed_tick_behavior(MissedTickBehavior::Skip); gc_tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
+75 -35
View File
@@ -25,7 +25,11 @@ const SEEDED_ENROLL_TOKEN_PREFIX: &[u8] = b"seeded_enroll_token:";
const SCHEMA_VERSION: u32 = 1; const SCHEMA_VERSION: u32 = 1;
impl Store { impl Store {
pub async fn load_or_init(path: &Path, enroll_tokens: Vec<String>, seed_ttl: Duration) -> Result<Self> { pub async fn load_or_init(
path: &Path,
enroll_tokens: Vec<String>,
seed_ttl: Duration,
) -> Result<Self> {
let db_path = path.to_path_buf(); let db_path = path.to_path_buf();
if let Some(parent) = db_path.parent() { if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent) std::fs::create_dir_all(parent)
@@ -38,7 +42,9 @@ impl Store {
let enroll_tree = db let enroll_tree = db
.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 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")?;
@@ -92,13 +98,17 @@ impl Store {
anyhow::bail!("invalid or already-used enroll token"); anyhow::bail!("invalid or already-used enroll token");
}; };
let expires_at_unix = decode_expiry(raw_expiry.as_ref()) let expires_at_unix =
.context("failed decoding enroll token expiry")?; decode_expiry(raw_expiry.as_ref()).context("failed decoding enroll token expiry")?;
let now = now_unix(); let now = now_unix();
if expires_at_unix <= now { if expires_at_unix <= now {
let _ = self.enroll_tokens.remove(enroll_token.as_bytes()); let _ = self.enroll_tokens.remove(enroll_token.as_bytes());
self.flush().ok(); self.flush().ok();
warn!(expires_at_unix, now_unix = now, "rejecting expired enroll token"); warn!(
expires_at_unix,
now_unix = now,
"rejecting expired enroll token"
);
anyhow::bail!("enroll token has expired"); anyhow::bail!("enroll token has expired");
} }
@@ -112,7 +122,8 @@ impl Store {
self.agents self.agents
.insert(agent_id.as_bytes(), agent_token.as_bytes()) .insert(agent_id.as_bytes(), agent_token.as_bytes())
.context("failed persisting agent credentials")?; .context("failed persisting agent credentials")?;
self.flush().context("failed flushing state db after enroll")?; self.flush()
.context("failed flushing state db after enroll")?;
info!(agent_id = %agent_id, "issued persistent agent credentials"); info!(agent_id = %agent_id, "issued persistent agent credentials");
Ok(IssuedAgent { Ok(IssuedAgent {
@@ -141,7 +152,8 @@ impl Store {
let mut out = Vec::new(); let mut out = Vec::new();
for item in self.enroll_tokens.iter() { for item in self.enroll_tokens.iter() {
let (token, value) = item.context("failed iterating enroll token tree")?; let (token, value) = item.context("failed iterating enroll token tree")?;
let expires_at_unix = decode_expiry(value.as_ref()).context("failed decoding token expiry")?; let expires_at_unix =
decode_expiry(value.as_ref()).context("failed decoding token expiry")?;
let expired = expires_at_unix <= now; let expired = expires_at_unix <= now;
if !include_expired && expired { if !include_expired && expired {
continue; continue;
@@ -169,7 +181,8 @@ impl Store {
.context("failed removing enroll token")? .context("failed removing enroll token")?
.is_some(); .is_some();
if removed { if removed {
self.flush().context("failed flushing db after enroll token revoke")?; self.flush()
.context("failed flushing db after enroll token revoke")?;
} }
Ok(removed) Ok(removed)
} }
@@ -180,8 +193,8 @@ impl Store {
let mut expired_enroll_token_count = 0usize; let mut expired_enroll_token_count = 0usize;
for item in self.enroll_tokens.iter() { for item in self.enroll_tokens.iter() {
let (_, value) = item.context("failed iterating enroll token tree")?; let (_, value) = item.context("failed iterating enroll token tree")?;
let expires_at = let expires_at = decode_expiry(value.as_ref())
decode_expiry(value.as_ref()).context("failed decoding token expiry during stats")?; .context("failed decoding token expiry during stats")?;
enroll_token_count = enroll_token_count.saturating_add(1); enroll_token_count = enroll_token_count.saturating_add(1);
if expires_at <= now { if expires_at <= now {
expired_enroll_token_count = expired_enroll_token_count.saturating_add(1); expired_enroll_token_count = expired_enroll_token_count.saturating_add(1);
@@ -372,7 +385,9 @@ impl Store {
let (_, raw) = item.context("failed iterating alert transition tree")?; let (_, raw) = item.context("failed iterating alert transition tree")?;
let transition: AlertTransition = let transition: AlertTransition =
serde_json::from_slice(raw.as_ref()).context("failed decoding alert transition")?; serde_json::from_slice(raw.as_ref()).context("failed decoding alert transition")?;
if let Some(since) = since_unix && transition.ts_unix < since { if let Some(since) = since_unix
&& transition.ts_unix < since
{
continue; continue;
} }
out.push(transition); out.push(transition);
@@ -384,15 +399,11 @@ impl Store {
} }
fn flush(&self) -> Result<()> { fn flush(&self) -> Result<()> {
self.meta self.meta.flush().context("failed to flush meta tree")?;
.flush()
.context("failed to flush meta tree")?;
self.enroll_tokens self.enroll_tokens
.flush() .flush()
.context("failed to flush enroll token tree")?; .context("failed to flush enroll token tree")?;
self.agents self.agents.flush().context("failed to flush agents tree")?;
.flush()
.context("failed to flush agents tree")?;
self.audit_events self.audit_events
.flush() .flush()
.context("failed to flush audit event tree")?; .context("failed to flush audit event tree")?;
@@ -406,7 +417,11 @@ impl Store {
Ok(()) Ok(())
} }
fn seed_bootstrap_enroll_tokens(&self, enroll_tokens: &[String], seed_ttl: Duration) -> Result<()> { fn seed_bootstrap_enroll_tokens(
&self,
enroll_tokens: &[String],
seed_ttl: Duration,
) -> Result<()> {
for token in enroll_tokens { for token in enroll_tokens {
let token = token.trim(); let token = token.trim();
if token.is_empty() { if token.is_empty() {
@@ -414,21 +429,32 @@ impl Store {
} }
let marker_key = seeded_enroll_token_key(token); let marker_key = seeded_enroll_token_key(token);
if self if self.meta.contains_key(&marker_key).with_context(|| {
.meta format!(
.contains_key(&marker_key) "failed reading bootstrap marker in {}",
.with_context(|| format!("failed reading bootstrap marker in {}", self.db_path.display()))? self.db_path.display()
{ )
})? {
continue; continue;
} }
let expires_at = now_unix().saturating_add(seed_ttl.as_secs().max(1)); let expires_at = now_unix().saturating_add(seed_ttl.as_secs().max(1));
self.enroll_tokens self.enroll_tokens
.insert(token.as_bytes(), &expires_at.to_le_bytes()) .insert(token.as_bytes(), &expires_at.to_le_bytes())
.with_context(|| format!("failed to seed enroll token into {}", self.db_path.display()))?; .with_context(|| {
format!(
"failed to seed enroll token into {}",
self.db_path.display()
)
})?;
self.meta self.meta
.insert(marker_key, &expires_at.to_le_bytes()) .insert(marker_key, &expires_at.to_le_bytes())
.with_context(|| format!("failed to persist bootstrap marker into {}", self.db_path.display()))?; .with_context(|| {
format!(
"failed to persist bootstrap marker into {}",
self.db_path.display()
)
})?;
} }
Ok(()) Ok(())
} }
@@ -475,8 +501,12 @@ impl Store {
self.meta self.meta
.insert(SCHEMA_VERSION_KEY, &SCHEMA_VERSION.to_le_bytes()) .insert(SCHEMA_VERSION_KEY, &SCHEMA_VERSION.to_le_bytes())
.context("failed writing schema version")?; .context("failed writing schema version")?;
self.flush().context("failed flushing db after schema init")?; self.flush()
info!(schema_version = SCHEMA_VERSION, "initialized state schema version"); .context("failed flushing db after schema init")?;
info!(
schema_version = SCHEMA_VERSION,
"initialized state schema version"
);
} }
} }
Ok(()) Ok(())
@@ -535,16 +565,24 @@ fn matches_audit_filter(event: &AuditEvent, filter: &AuditEventFilter) -> bool {
{ {
return false; return false;
} }
if let Some(event_type) = filter.event_type.as_deref() && event.event_type != event_type { if let Some(event_type) = filter.event_type.as_deref()
&& event.event_type != event_type
{
return false; return false;
} }
if let Some(outcome) = filter.outcome.as_deref() && event.outcome != outcome { if let Some(outcome) = filter.outcome.as_deref()
&& event.outcome != outcome
{
return false; return false;
} }
if let Some(since_unix) = filter.since_unix && event.ts_unix < since_unix { if let Some(since_unix) = filter.since_unix
&& event.ts_unix < since_unix
{
return false; return false;
} }
if let Some(until_unix) = filter.until_unix && event.ts_unix > until_unix { if let Some(until_unix) = filter.until_unix
&& event.ts_unix > until_unix
{
return false; return false;
} }
@@ -559,7 +597,8 @@ mod tests {
use super::Store; use super::Store;
async fn make_store() -> (Store, std::path::PathBuf) { async fn make_store() -> (Store, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!("wakey-cp-store-test-{}", uuid::Uuid::new_v4())); let dir =
std::env::temp_dir().join(format!("wakey-cp-store-test-{}", uuid::Uuid::new_v4()));
let db_path = dir.join("state.db"); let db_path = dir.join("state.db");
let store = Store::load_or_init(&db_path, Vec::new(), Duration::from_secs(60)) let store = Store::load_or_init(&db_path, Vec::new(), Duration::from_secs(60))
.await .await
@@ -785,9 +824,10 @@ mod tests {
.enroll("enr-bootstrap-once") .enroll("enr-bootstrap-once")
.await .await
.expect_err("bootstrap token should not resurrect after restart"); .expect_err("bootstrap token should not resurrect after restart");
assert!(err assert!(
.to_string() err.to_string()
.contains("invalid or already-used enroll token")); .contains("invalid or already-used enroll token")
);
cleanup_dir(&dir); cleanup_dir(&dir);
} }
+11 -11
View File
@@ -1,6 +1,6 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use opentelemetry::trace::TracerProvider as _;
use opentelemetry::global; use opentelemetry::global;
use opentelemetry::trace::TracerProvider as _;
use opentelemetry_otlp::WithExportConfig; use opentelemetry_otlp::WithExportConfig;
use opentelemetry_sdk::Resource; use opentelemetry_sdk::Resource;
use opentelemetry_sdk::trace::{SdkTracerProvider, Tracer}; use opentelemetry_sdk::trace::{SdkTracerProvider, Tracer};
@@ -28,7 +28,10 @@ pub fn init(verbose: u8, telemetry: &TelemetryConfig) -> Result<()> {
.with(filter) .with(filter)
.with(fmt::layer().json()) .with(fmt::layer().json())
.init(); .init();
tracing::info!(json_logs = telemetry.json_logs, "tracing initialized without otlp exporter"); tracing::info!(
json_logs = telemetry.json_logs,
"tracing initialized without otlp exporter"
);
} }
} else if let Some(otel_layer) = otel { } else if let Some(otel_layer) = otel {
tracing_subscriber::registry() tracing_subscriber::registry()
@@ -42,7 +45,10 @@ pub fn init(verbose: u8, telemetry: &TelemetryConfig) -> Result<()> {
.with(filter) .with(filter)
.with(fmt::layer()) .with(fmt::layer())
.init(); .init();
tracing::info!(json_logs = telemetry.json_logs, "tracing initialized without otlp exporter"); tracing::info!(
json_logs = telemetry.json_logs,
"tracing initialized without otlp exporter"
);
} }
Ok(()) Ok(())
@@ -50,14 +56,8 @@ pub fn init(verbose: u8, telemetry: &TelemetryConfig) -> Result<()> {
fn build_otel_layer( fn build_otel_layer(
telemetry: &TelemetryConfig, telemetry: &TelemetryConfig,
) -> Result< ) -> Result<Option<tracing_opentelemetry::OpenTelemetryLayer<tracing_subscriber::Registry, Tracer>>>
Option< {
tracing_opentelemetry::OpenTelemetryLayer<
tracing_subscriber::Registry,
Tracer,
>,
>,
> {
let Some(endpoint) = telemetry.otlp_endpoint.as_deref() else { let Some(endpoint) = telemetry.otlp_endpoint.as_deref() else {
return Ok(None); return Ok(None);
}; };
+12 -12
View File
@@ -201,17 +201,13 @@ async fn process_agent_text(
} }
anyhow::bail!("agent auth rejected"); anyhow::bail!("agent auth rejected");
} }
state state.sessions.write().await.insert(
.sessions agent_id.clone(),
.write() AgentSession {
.await connection_id: connection_id.to_string(),
.insert( tx: tx.clone(),
agent_id.clone(), },
AgentSession { );
connection_id: connection_id.to_string(),
tx: tx.clone(),
},
);
*authed_agent_id = Some(agent_id.clone()); *authed_agent_id = Some(agent_id.clone());
info!(agent_id = %agent_id, connect_to_auth_ms, hello_to_auth_ms = hello_to_auth_ms.unwrap_or(0), "agent authenticated"); info!(agent_id = %agent_id, connect_to_auth_ms, hello_to_auth_ms = hello_to_auth_ms.unwrap_or(0), "agent authenticated");
if let Err(err) = state if let Err(err) = state
@@ -275,7 +271,11 @@ fn now_duration_ms(duration: std::time::Duration) -> u64 {
duration.as_millis() as u64 duration.as_millis() as u64
} }
async fn ensure_current_session(state: &AppState, agent_id: &str, connection_id: &str) -> Result<()> { async fn ensure_current_session(
state: &AppState,
agent_id: &str,
connection_id: &str,
) -> Result<()> {
let sessions = state.sessions.read().await; let sessions = state.sessions.read().await;
if is_current_session(&sessions, agent_id, connection_id) { if is_current_session(&sessions, agent_id, connection_id) {
Ok(()) Ok(())