Agent only ever runs a binary whose minisign signature verifies against
the EMBEDDED public key. NATS host.cmd func 'update' {url}: download
binary + .minisig from the CDN -> verify against embedded pubkey ->
atomic swap (.old rollback) -> relaunch. URL allowlist (https + cdn.
corrosionmgmt.com only, rejects userinfo-bypass), 100MiB cap. Closes the
supply-chain hole: even a malicious CDN upload can't run unsigned.
CI: build-host-agent.yml signs every artifact with MINISIGN_SECRET_KEY
(Gitea secret) and publishes .minisig alongside; the step FAILS the
build if the secret is absent (refuses to ship unsigned). Bumped to
alpha.6.
6 deterministic tests (accept valid / reject tampered+garbage+empty sig,
URL allowlist incl userinfo-bypass, atomic swap+rollback). Fixtures
signed with the real release key so tests need no key at runtime. Full
suite 50/50 green; musl + native build clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
146 lines
5.0 KiB
Rust
146 lines
5.0 KiB
Rust
//! Host-level command handler: request-reply on `corrosion.{license}.host.cmd`.
|
|
//!
|
|
//! One subscriber; each message handled in its own task so a slow command
|
|
//! never blocks the dispatch loop. Phase 0 commands: ping, probe, sysinfo.
|
|
|
|
use futures::StreamExt;
|
|
use serde::Deserialize;
|
|
use serde_json::json;
|
|
use std::sync::Arc;
|
|
use sysinfo::System;
|
|
|
|
use crate::agent::Agent;
|
|
use crate::prober;
|
|
use crate::subjects;
|
|
use crate::telemetry;
|
|
use crate::update;
|
|
use crate::version;
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct HostCommand {
|
|
func: String,
|
|
/// Signed-update artifact URL (for func = "update").
|
|
#[serde(default)]
|
|
url: Option<String>,
|
|
}
|
|
|
|
pub async fn run(agent: Arc<Agent>) -> anyhow::Result<()> {
|
|
let subject = subjects::host_cmd(&agent.cfg.license_id);
|
|
let mut sub = agent.nats.subscribe(subject.clone()).await?;
|
|
tracing::info!("host command handler listening on {subject}");
|
|
|
|
let cancel = agent.shutdown.clone();
|
|
loop {
|
|
tokio::select! {
|
|
msg = sub.next() => {
|
|
match msg {
|
|
Some(msg) => {
|
|
let agent = agent.clone();
|
|
tokio::spawn(async move { handle(agent, msg).await });
|
|
}
|
|
None => {
|
|
tracing::warn!("host command subscription ended");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
_ = cancel.cancelled() => {
|
|
tracing::info!("host command handler stopping");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle(agent: Arc<Agent>, msg: async_nats::Message) {
|
|
let Some(reply) = msg.reply.clone() else {
|
|
tracing::warn!("host command without reply subject ignored");
|
|
return;
|
|
};
|
|
|
|
let cmd = match serde_json::from_slice::<HostCommand>(&msg.payload) {
|
|
Ok(cmd) => cmd,
|
|
Err(e) => {
|
|
publish(&agent, &reply, json!({ "status": "error", "message": format!("invalid command payload: {e}") })).await;
|
|
return;
|
|
}
|
|
};
|
|
|
|
// Self-update is special: it must reply BEFORE relaunching, because the
|
|
// relaunch replaces this process and nothing after it would run.
|
|
if cmd.func == "update" {
|
|
let Some(url) = cmd.url else {
|
|
publish(&agent, &reply, json!({ "status": "error", "message": "update requires a 'url'" })).await;
|
|
return;
|
|
};
|
|
match update::download_verify_swap(&url).await {
|
|
Ok(_) => {
|
|
publish(&agent, &reply, json!({ "status": "success", "func": "update", "message": "verified and swapped; relaunching" })).await;
|
|
let _ = agent.nats.flush().await;
|
|
update::relaunch_and_exit();
|
|
}
|
|
Err(e) => {
|
|
publish(&agent, &reply, json!({ "status": "error", "func": "update", "message": format!("{e:#}") })).await;
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
let response = dispatch(&agent, &cmd.func).await;
|
|
publish(&agent, &reply, response).await;
|
|
}
|
|
|
|
async fn publish(agent: &Arc<Agent>, reply: &async_nats::Subject, value: serde_json::Value) {
|
|
match serde_json::to_vec(&value) {
|
|
Ok(bytes) => {
|
|
if let Err(e) = agent.nats.publish(reply.clone(), bytes.into()).await {
|
|
tracing::warn!("response publish failed: {e}");
|
|
}
|
|
}
|
|
Err(e) => tracing::error!("response serialize failed: {e}"),
|
|
}
|
|
}
|
|
|
|
async fn dispatch(agent: &Arc<Agent>, func: &str) -> serde_json::Value {
|
|
match func {
|
|
"ping" => json!({
|
|
"status": "success",
|
|
"func": "ping",
|
|
"version": version::VERSION,
|
|
"commit": version::GIT_HASH,
|
|
"uptime_seconds": agent.started.elapsed().as_secs(),
|
|
}),
|
|
"probe" => {
|
|
let report = prober::run_probe(&agent.cfg.probe_targets).await;
|
|
*agent.last_probe.write().await = Some(report.clone());
|
|
match serde_json::to_value(&report) {
|
|
Ok(report_json) => json!({
|
|
"status": "success",
|
|
"func": "probe",
|
|
"report": report_json,
|
|
}),
|
|
Err(e) => json!({ "status": "error", "message": format!("probe serialize: {e}") }),
|
|
}
|
|
}
|
|
"sysinfo" => {
|
|
let mut sys = System::new();
|
|
sys.refresh_cpu_usage();
|
|
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
|
let payload = telemetry::collect(agent, &mut sys).await;
|
|
match serde_json::to_value(&payload) {
|
|
Ok(snapshot) => json!({
|
|
"status": "success",
|
|
"func": "sysinfo",
|
|
"snapshot": snapshot,
|
|
}),
|
|
Err(e) => json!({ "status": "error", "message": format!("sysinfo serialize: {e}") }),
|
|
}
|
|
}
|
|
other => json!({
|
|
"status": "error",
|
|
"message": format!("unknown func '{other}' (supported: ping, probe, sysinfo)"),
|
|
}),
|
|
}
|
|
}
|