feat(host-agent): Phase 3a signed self-update (minisign) + CI signing gate
Some checks failed
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 1m27s
CI / integration (push) Successful in 21s
Build Host Agent (Rust) / build (push) Failing after 1m33s

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>
This commit is contained in:
Vantz Stockwell
2026-06-11 20:00:36 -04:00
parent 7c84912ff5
commit 6b3e805ac2
11 changed files with 751 additions and 29 deletions

View File

@@ -13,11 +13,15 @@ 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<()> {
@@ -55,20 +59,46 @@ async fn handle(agent: Arc<Agent>, msg: async_nats::Message) {
return;
};
let response = match serde_json::from_slice::<HostCommand>(&msg.payload) {
Ok(cmd) => dispatch(&agent, &cmd.func).await,
Err(e) => json!({ "status": "error", "message": format!("invalid command payload: {e}") }),
};
let bytes = match serde_json::to_vec(&response) {
Ok(b) => b,
let cmd = match serde_json::from_slice::<HostCommand>(&msg.payload) {
Ok(cmd) => cmd,
Err(e) => {
tracing::error!("response serialize failed: {e}");
publish(&agent, &reply, json!({ "status": "error", "message": format!("invalid command payload: {e}") })).await;
return;
}
};
if let Err(e) = agent.nats.publish(reply, bytes.into()).await {
tracing::warn!("response publish failed: {e}");
// 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}"),
}
}