From 77e039419504d19f9516f9b858c5668728fd196e Mon Sep 17 00:00:00 2001 From: Huskies Agent Date: Thu, 16 Jul 2026 13:55:15 +0000 Subject: [PATCH] huskies: merge 1169 story Gateway pulls signed artifacts from a release channel into its local store --- Cargo.lock | 21 + Cargo.toml | 8 +- crates/release-manifest/Cargo.toml | 8 + crates/release-manifest/src/lib.rs | 126 ++++ crates/release-tool/Cargo.toml | 18 + crates/release-tool/src/main.rs | 311 ++++++++++ server/Cargo.toml | 1 + .../src/chat/transport/matrix/bot/context.rs | 9 + .../matrix/bot/messages/on_room_message.rs | 27 + server/src/chat/transport/matrix/bot/run.rs | 11 + server/src/chat/transport/matrix/health.rs | 1 + server/src/chat/transport/matrix/mod.rs | 14 + server/src/chat/transport/matrix/projects.rs | 2 + server/src/chat/transport/matrix/pull.rs | 539 ++++++++++++++++++ server/src/gateway/mod.rs | 1 + server/src/gateway/tests.rs | 8 + server/src/gateway_relay.rs | 2 + server/src/http/gateway/mcp.rs | 2 + server/src/main.rs | 1 + server/src/service/gateway/config.rs | 42 +- server/src/service/gateway/io.rs | 290 +++++++++- server/src/service/gateway/mod.rs | 13 + .../src/service/gateway/release_manifest.rs | 195 +++++++ 23 files changed, 1643 insertions(+), 7 deletions(-) create mode 100644 crates/release-manifest/Cargo.toml create mode 100644 crates/release-manifest/src/lib.rs create mode 100644 crates/release-tool/Cargo.toml create mode 100644 crates/release-tool/src/main.rs create mode 100644 server/src/chat/transport/matrix/pull.rs create mode 100644 server/src/service/gateway/release_manifest.rs diff --git a/Cargo.lock b/Cargo.lock index 18459f23..954d474e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1953,6 +1953,7 @@ dependencies = [ "pulldown-cmark", "rand 0.10.2", "regex", + "release-manifest", "reqwest", "serde", "serde_json", @@ -3768,6 +3769,26 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "release-manifest" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "release-tool" +version = "0.1.0" +dependencies = [ + "ed25519-dalek 3.0.0", + "rand 0.10.2", + "release-manifest", + "serde_json", + "sha2 0.11.0", + "tempfile", +] + [[package]] name = "reqwest" version = "0.13.4" diff --git a/Cargo.toml b/Cargo.toml index 03076ad9..1f860ad7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,11 @@ [workspace] -members = ["server", "crates/bft-json-crdt", "crates/source-map-gen"] +members = [ + "server", + "crates/bft-json-crdt", + "crates/source-map-gen", + "crates/release-manifest", + "crates/release-tool", +] resolver = "3" [workspace.dependencies] diff --git a/crates/release-manifest/Cargo.toml b/crates/release-manifest/Cargo.toml new file mode 100644 index 00000000..2eb42604 --- /dev/null +++ b/crates/release-manifest/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "release-manifest" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } diff --git a/crates/release-manifest/src/lib.rs b/crates/release-manifest/src/lib.rs new file mode 100644 index 00000000..d6893068 --- /dev/null +++ b/crates/release-manifest/src/lib.rs @@ -0,0 +1,126 @@ +//! Shared release-manifest type for the signed release-channel pull pipeline. +//! +//! The publisher tool (`crates/release-tool`) builds a [`ReleaseManifest`], +//! serializes it to canonical bytes, signs those bytes with the channel's +//! Ed25519 private key, and publishes the resulting [`SignedManifest`] as +//! `manifest.json` on the release channel. The gateway (`huskies-server`) +//! fetches that file, re-serializes the embedded manifest with +//! [`ReleaseManifest::canonical_bytes`], and verifies the signature against +//! its pinned public key before trusting anything in it. +//! +//! Keeping the type in its own dependency-light crate lets both sides agree +//! on the exact byte representation to sign/verify without the server crate +//! ever linking signing code, and without the publisher tool depending on +//! the full `huskies` server crate. + +use serde::{Deserialize, Serialize}; + +/// The signed payload describing one published release artifact. +/// +/// Field order is significant: [`ReleaseManifest::canonical_bytes`] relies on +/// `serde_json`'s struct serialization preserving declaration order, so the +/// signer and verifier always agree on the exact bytes being signed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReleaseManifest { + /// Filename of the artifact within the channel (e.g. `huskies-linux-arm64`). + pub artifact: String, + /// Lowercase hex sha256 digest of the artifact's bytes. + pub sha256: String, + /// Version identifier — the short git commit hash the artifact was built from. + pub version: String, + /// Release channel name this manifest was signed for (e.g. `stable`). + pub channel: String, + /// Unix timestamp (seconds) the manifest was signed at. + /// + /// Used for rollback/replay detection: a pull refuses any manifest whose + /// timestamp is not strictly newer than the currently installed one. + pub timestamp: i64, +} + +impl ReleaseManifest { + /// Serialize this manifest deterministically for signing and verification. + /// + /// Both the publisher and the gateway construct this independently from + /// their own in-memory `ReleaseManifest` value — the manifest.json file's + /// exact on-disk byte layout is never itself the signed payload. + pub fn canonical_bytes(&self) -> Vec { + serde_json::to_vec(self).expect("ReleaseManifest serialization cannot fail") + } +} + +/// A [`ReleaseManifest`] plus its Ed25519 signature (lowercase hex), as +/// published to a release channel's `manifest.json`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SignedManifest { + /// The manifest describing the published artifact. + pub manifest: ReleaseManifest, + /// Hex-encoded Ed25519 signature over `manifest.canonical_bytes()`. + pub signature: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> ReleaseManifest { + ReleaseManifest { + artifact: "huskies-linux-arm64".to_string(), + sha256: "a".repeat(64), + version: "abc1234".to_string(), + channel: "stable".to_string(), + timestamp: 1_700_000_000, + } + } + + #[test] + fn canonical_bytes_is_deterministic() { + let m = sample(); + assert_eq!(m.canonical_bytes(), m.canonical_bytes()); + } + + #[test] + fn canonical_bytes_changes_with_any_field() { + let m1 = sample(); + let mut m2 = sample(); + m2.timestamp += 1; + assert_ne!(m1.canonical_bytes(), m2.canonical_bytes()); + } + + #[test] + fn signed_manifest_roundtrips_through_json() { + let signed = SignedManifest { + manifest: sample(), + signature: "deadbeef".to_string(), + }; + let json = serde_json::to_string(&signed).unwrap(); + let parsed: SignedManifest = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.manifest, signed.manifest); + assert_eq!(parsed.signature, signed.signature); + } + + #[test] + fn manifest_missing_field_fails_to_parse() { + let bad = serde_json::json!({ + "artifact": "huskies-linux-arm64", + "sha256": "a".repeat(64), + "version": "abc1234", + "channel": "stable" + // timestamp missing + }); + let result: Result = serde_json::from_value(bad); + assert!( + result.is_err(), + "manifest missing a field must fail to parse" + ); + } + + #[test] + fn signed_manifest_missing_signature_fails_to_parse() { + let bad = serde_json::json!({ "manifest": sample() }); + let result: Result = serde_json::from_value(bad); + assert!( + result.is_err(), + "signed manifest missing signature must fail to parse" + ); + } +} diff --git a/crates/release-tool/Cargo.toml b/crates/release-tool/Cargo.toml new file mode 100644 index 00000000..9f8214eb --- /dev/null +++ b/crates/release-tool/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "release-tool" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "release-tool" +path = "src/main.rs" + +[dependencies] +release-manifest = { path = "../release-manifest" } +ed25519-dalek = { workspace = true } +sha2 = { workspace = true } +rand = { workspace = true } +serde_json = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/release-tool/src/main.rs b/crates/release-tool/src/main.rs new file mode 100644 index 00000000..2022288b --- /dev/null +++ b/crates/release-tool/src/main.rs @@ -0,0 +1,311 @@ +//! `release-tool` — offline publisher CLI for signed release channels. +//! +//! Generates a release-channel Ed25519 keypair and signs release manifests +//! for a channel's `manifest.json`. This binary is intentionally its own +//! crate, depending only on [`release_manifest`] and `ed25519-dalek` — it +//! never links against the `huskies` server crate, so the running gateway +//! has no code path that can read a channel's private signing key. Run this +//! tool offline (or in a separate publish pipeline) and copy only the +//! resulting public key hex into the gateway's `projects.toml`. +//! +//! Usage: +//! ```text +//! release-tool keygen +//! release-tool sign --key --artifact --version --channel --out [--timestamp ] +//! ``` + +use ed25519_dalek::{Signer, SigningKey}; +use rand::Rng; +use release_manifest::{ReleaseManifest, SignedManifest}; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; + +fn main() { + let args: Vec = std::env::args().collect(); + let result = match args.get(1).map(String::as_str) { + Some("keygen") => run_keygen(&args[2..]), + Some("sign") => run_sign(&args[2..]), + _ => Err( + "usage: release-tool keygen | release-tool sign --key \ + --artifact --version --channel --out [--timestamp ]" + .to_string(), + ), + }; + + if let Err(e) = result { + eprintln!("error: {e}"); + std::process::exit(1); + } +} + +// ── keygen ─────────────────────────────────────────────────────────────────── + +fn run_keygen(args: &[String]) -> Result<(), String> { + let key_path = args.first().ok_or("keygen requires a key-out-path")?; + let signing_key = generate_signing_key(); + write_seed_file(Path::new(key_path), &signing_key)?; + + let pubkey_hex = hex_encode(signing_key.verifying_key().as_bytes()); + println!("Wrote private key seed to {key_path}"); + println!("Pinned release public key (paste into projects.toml as `pubkey`):"); + println!("{pubkey_hex}"); + Ok(()) +} + +fn generate_signing_key() -> SigningKey { + let mut seed = [0u8; 32]; + rand::rng().fill_bytes(&mut seed); + SigningKey::from_bytes(&seed) +} + +fn write_seed_file(path: &Path, signing_key: &SigningKey) -> Result<(), String> { + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + std::fs::create_dir_all(parent) + .map_err(|e| format!("cannot create {}: {e}", parent.display()))?; + } + + #[cfg(unix)] + { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + let mut file = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path) + .map_err(|e| format!("cannot create {}: {e}", path.display()))?; + file.write_all(&signing_key.to_bytes()) + .map_err(|e| format!("cannot write {}: {e}", path.display())) + } + #[cfg(not(unix))] + { + std::fs::write(path, signing_key.to_bytes()) + .map_err(|e| format!("cannot write {}: {e}", path.display())) + } +} + +fn load_seed_file(path: &Path) -> Result { + let bytes = std::fs::read(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?; + let seed: [u8; 32] = bytes + .try_into() + .map_err(|_| format!("{} must contain exactly 32 bytes", path.display()))?; + Ok(SigningKey::from_bytes(&seed)) +} + +// ── sign ───────────────────────────────────────────────────────────────────── + +/// Parsed `sign` subcommand arguments. +struct SignArgs { + key: PathBuf, + artifact: PathBuf, + version: String, + channel: String, + out: PathBuf, + timestamp: Option, +} + +fn parse_sign_args(args: &[String]) -> Result { + let mut key = None; + let mut artifact = None; + let mut version = None; + let mut channel = None; + let mut out = None; + let mut timestamp = None; + + let mut i = 0; + while i < args.len() { + let flag = args[i].as_str(); + let value = args + .get(i + 1) + .ok_or_else(|| format!("missing value for {flag}"))?; + match flag { + "--key" => key = Some(PathBuf::from(value)), + "--artifact" => artifact = Some(PathBuf::from(value)), + "--version" => version = Some(value.clone()), + "--channel" => channel = Some(value.clone()), + "--out" => out = Some(PathBuf::from(value)), + "--timestamp" => { + timestamp = Some( + value + .parse::() + .map_err(|_| format!("--timestamp must be an integer, got `{value}`"))?, + ) + } + other => return Err(format!("unknown flag `{other}`")), + } + i += 2; + } + + Ok(SignArgs { + key: key.ok_or("--key is required")?, + artifact: artifact.ok_or("--artifact is required")?, + version: version.ok_or("--version is required")?, + channel: channel.ok_or("--channel is required")?, + out: out.ok_or("--out is required")?, + timestamp, + }) +} + +fn run_sign(args: &[String]) -> Result<(), String> { + let parsed = parse_sign_args(args)?; + let signing_key = load_seed_file(&parsed.key)?; + let artifact_bytes = std::fs::read(&parsed.artifact) + .map_err(|e| format!("cannot read {}: {e}", parsed.artifact.display()))?; + let artifact_name = parsed + .artifact + .file_name() + .and_then(|n| n.to_str()) + .ok_or("--artifact path has no filename")? + .to_string(); + + let timestamp = match parsed.timestamp { + Some(t) => t, + None => std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| format!("system clock before epoch: {e}"))? + .as_secs() as i64, + }; + + let signed = sign_manifest( + &signing_key, + artifact_name, + &artifact_bytes, + parsed.version, + parsed.channel, + timestamp, + ); + + let json = + serde_json::to_string_pretty(&signed).map_err(|e| format!("serialise manifest: {e}"))?; + std::fs::write(&parsed.out, json) + .map_err(|e| format!("cannot write {}: {e}", parsed.out.display()))?; + println!("Signed manifest written to {}", parsed.out.display()); + Ok(()) +} + +/// Build and sign a [`SignedManifest`] for the given artifact bytes. +/// +/// Pure aside from the signature computation — split out from `run_sign` so +/// tests can exercise it without touching the filesystem. +fn sign_manifest( + signing_key: &SigningKey, + artifact: String, + artifact_bytes: &[u8], + version: String, + channel: String, + timestamp: i64, +) -> SignedManifest { + let mut hasher = Sha256::new(); + hasher.update(artifact_bytes); + let sha256 = hex_encode(&hasher.finalize()); + + let manifest = ReleaseManifest { + artifact, + sha256, + version, + channel, + timestamp, + }; + let signature = hex_encode(&signing_key.sign(&manifest.canonical_bytes()).to_bytes()); + SignedManifest { + manifest, + signature, + } +} + +// ── helpers ────────────────────────────────────────────────────────────────── + +fn hex_encode(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keygen_then_sign_produces_verifiable_signature() { + let tmp = tempfile::tempdir().unwrap(); + let key_path = tmp.path().join("channel.key"); + let signing_key = generate_signing_key(); + write_seed_file(&key_path, &signing_key).unwrap(); + + let loaded = load_seed_file(&key_path).unwrap(); + assert_eq!(loaded.verifying_key(), signing_key.verifying_key()); + + let signed = sign_manifest( + &loaded, + "huskies-linux-arm64".to_string(), + b"fake binary contents", + "abc1234".to_string(), + "stable".to_string(), + 1_700_000_000, + ); + + // Verify with ed25519-dalek directly, mirroring how the gateway verifies. + use ed25519_dalek::Verifier; + let sig_bytes: [u8; 64] = hex_bytes(&signed.signature).try_into().unwrap(); + let sig = ed25519_dalek::Signature::from_bytes(&sig_bytes); + assert!( + signing_key + .verifying_key() + .verify(&signed.manifest.canonical_bytes(), &sig) + .is_ok(), + "signature produced by sign_manifest must verify against the signing key's pubkey" + ); + } + + #[test] + fn sign_manifest_hashes_artifact_bytes() { + let signing_key = generate_signing_key(); + let signed = sign_manifest( + &signing_key, + "art".to_string(), + b"hello world", + "v1".to_string(), + "stable".to_string(), + 1, + ); + let mut hasher = Sha256::new(); + hasher.update(b"hello world"); + let expected = hex_encode(&hasher.finalize()); + assert_eq!(signed.manifest.sha256, expected); + } + + #[test] + fn parse_sign_args_rejects_missing_required_flag() { + let args: Vec = vec!["--key".into(), "k".into()]; + assert!(parse_sign_args(&args).is_err()); + } + + #[test] + fn parse_sign_args_accepts_all_flags() { + let args: Vec = vec![ + "--key".into(), + "k".into(), + "--artifact".into(), + "a".into(), + "--version".into(), + "v1".into(), + "--channel".into(), + "stable".into(), + "--out".into(), + "o".into(), + "--timestamp".into(), + "42".into(), + ]; + let parsed = parse_sign_args(&args).unwrap(); + assert_eq!(parsed.timestamp, Some(42)); + assert_eq!(parsed.channel, "stable"); + } + + fn hex_bytes(s: &str) -> Vec { + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()) + .collect() + } +} diff --git a/server/Cargo.toml b/server/Cargo.toml index 984e4d1b..1a7ce420 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -48,6 +48,7 @@ sqlx = { workspace = true } wait-timeout = "0.2.1" bft-json-crdt = { path = "../crates/bft-json-crdt", default-features = false, features = ["bft"] } source-map-gen = { path = "../crates/source-map-gen" } +release-manifest = { path = "../crates/release-manifest" } ed25519-dalek = { workspace = true } rand = { workspace = true } nutype = { workspace = true } diff --git a/server/src/chat/transport/matrix/bot/context.rs b/server/src/chat/transport/matrix/bot/context.rs index e384fb52..fa56e6f8 100644 --- a/server/src/chat/transport/matrix/bot/context.rs +++ b/server/src/chat/transport/matrix/bot/context.rs @@ -94,6 +94,14 @@ pub struct BotContext { /// The `new project` command writes here so HTTP handlers see the new entry /// immediately without requiring a gateway restart. `None` in standalone mode. pub gateway_projects_store: Option>>>, + /// In gateway mode: shared configured release channels (story 1169). + /// + /// Read by the `pull ` command to resolve a channel's + /// `base_url`, pinned `pubkey`, and optional `bearer_token`. `None` in + /// standalone mode. + pub gateway_channels_store: Option< + Arc>>, + >, /// Bounded FIFO set of already-handled incoming event IDs. /// /// The Matrix sync loop can replay events on reconnect. This set ensures @@ -315,6 +323,7 @@ mod tests { )), gateway_active_project, gateway_projects_store, + gateway_channels_store: None, handled_incoming_event_ids: Arc::new(TokioMutex::new(SeenEventIds::new( SEEN_EVENT_IDS_CAP, ))), diff --git a/server/src/chat/transport/matrix/bot/messages/on_room_message.rs b/server/src/chat/transport/matrix/bot/messages/on_room_message.rs index 812e4ac4..c2476cc7 100644 --- a/server/src/chat/transport/matrix/bot/messages/on_room_message.rs +++ b/server/src/chat/transport/matrix/bot/messages/on_room_message.rs @@ -548,6 +548,33 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message( return; } + // In gateway mode, handle the `pull ` command: fetch, verify, and + // install a signed release-channel artifact into the artifact store. + if ctx.is_gateway() + && let Some(channel) = super::super::super::pull::extract_pull_command( + &user_message, + &ctx.services.bot_name, + ctx.matrix_user_id.as_str(), + ) + { + slog!("[matrix-bot] Handling 'pull {channel}' from {sender}"); + let response = if let Some(ref store) = ctx.gateway_channels_store { + super::super::super::pull::handle_pull(&channel, store).await + } else { + "Gateway release channels unavailable — cannot pull.".to_string() + }; + let html = markdown_to_html(&response); + if let Ok(msg_id) = ctx + .transport + .send_message(&room_id_str, &response, &html) + .await + && let Ok(event_id) = msg_id.parse() + { + ctx.bot_sent_event_ids.lock().await.insert(event_id); + } + return; + } + // In gateway mode, handle the `upgrade []` command to upgrade a // sled's binary in-container, streaming phase markers to the room. if ctx.is_gateway() diff --git a/server/src/chat/transport/matrix/bot/run.rs b/server/src/chat/transport/matrix/bot/run.rs index 82f02054..8ba3b7ff 100644 --- a/server/src/chat/transport/matrix/bot/run.rs +++ b/server/src/chat/transport/matrix/bot/run.rs @@ -40,6 +40,16 @@ pub async fn run_bot( tokio::sync::broadcast::Receiver, >, gateway_port: Option, + gateway_channels_store: Option< + Arc< + RwLock< + std::collections::BTreeMap< + String, + crate::service::gateway::config::ReleaseChannelConfig, + >, + >, + >, + >, ) -> Result<(), String> { let project_root = &services.project_root; let store_path = project_root.join(".huskies").join("matrix_store"); @@ -332,6 +342,7 @@ pub async fn run_bot( timer_store, gateway_active_project, gateway_projects_store, + gateway_channels_store, handled_incoming_event_ids: Arc::new(TokioMutex::new(super::context::SeenEventIds::new( super::context::SEEN_EVENT_IDS_CAP, ))), diff --git a/server/src/chat/transport/matrix/health.rs b/server/src/chat/transport/matrix/health.rs index e812dace..b101e418 100644 --- a/server/src/chat/transport/matrix/health.rs +++ b/server/src/chat/transport/matrix/health.rs @@ -916,6 +916,7 @@ mod tests { )), gateway_active_project: None, gateway_projects_store: None, + gateway_channels_store: None, handled_incoming_event_ids: Arc::new(TokioMutex::new( crate::chat::transport::matrix::bot::context::SeenEventIds::new( crate::chat::transport::matrix::bot::context::SEEN_EVENT_IDS_CAP, diff --git a/server/src/chat/transport/matrix/mod.rs b/server/src/chat/transport/matrix/mod.rs index 31806489..126cc6ff 100644 --- a/server/src/chat/transport/matrix/mod.rs +++ b/server/src/chat/transport/matrix/mod.rs @@ -35,6 +35,9 @@ pub mod new_project; pub mod project_rebuild; /// `projects` chat command — list all registered gateway projects. pub mod projects; +/// `pull ` gateway chat command — fetch, verify, and install a +/// signed release-channel artifact into the gateway's artifact store. +pub mod pull; /// `rebuild gateway` command parsing (gateway self-rebuild). pub mod rebuild; /// `release` gateway chat command — build the sled binary and publish it. @@ -103,6 +106,16 @@ pub fn spawn_bot( tokio::sync::broadcast::Receiver, >, gateway_port: Option, + gateway_channels_store: Option< + Arc< + RwLock< + std::collections::BTreeMap< + String, + crate::service::gateway::config::ReleaseChannelConfig, + >, + >, + >, + >, ) -> Option { let config = match BotConfig::load(project_root) { Some(c) => c, @@ -142,6 +155,7 @@ pub fn spawn_bot( timer_store, gateway_event_rx, gateway_port, + gateway_channels_store, ) .await { diff --git a/server/src/chat/transport/matrix/projects.rs b/server/src/chat/transport/matrix/projects.rs index fbb658c4..9dde1192 100644 --- a/server/src/chat/transport/matrix/projects.rs +++ b/server/src/chat/transport/matrix/projects.rs @@ -90,6 +90,7 @@ mod tests { )), gateway_active_project: Some(active_project), gateway_projects_store: Some(store), + gateway_channels_store: None, handled_incoming_event_ids: Arc::new(TokioMutex::new( crate::chat::transport::matrix::bot::context::SeenEventIds::new( crate::chat::transport::matrix::bot::context::SEEN_EVENT_IDS_CAP, @@ -208,6 +209,7 @@ mod tests { )), gateway_active_project: None, gateway_projects_store: None, + gateway_channels_store: None, handled_incoming_event_ids: Arc::new(TokioMutex::new( crate::chat::transport::matrix::bot::context::SeenEventIds::new( crate::chat::transport::matrix::bot::context::SEEN_EVENT_IDS_CAP, diff --git a/server/src/chat/transport/matrix/pull.rs b/server/src/chat/transport/matrix/pull.rs new file mode 100644 index 00000000..708f30d8 --- /dev/null +++ b/server/src/chat/transport/matrix/pull.rs @@ -0,0 +1,539 @@ +//! `pull ` gateway chat command — fetch a signed release manifest +//! from a configured channel, verify it, download and verify the artifact, +//! and install it atomically into the gateway's artifact store. +//! +//! Usage (gateway mode only): `{bot} pull ` +//! +//! Pipeline: +//! 1. Look up `` in `projects.toml`'s `[release_channels.]` +//! table. Missing channel, missing `base_url`, or missing `pubkey` is a +//! loud chat error — there is no unsigned-pull mode. +//! 2. `GET {base_url}/manifest.json` (with `Authorization: Bearer ` +//! when a `bearer_token` is configured) and verify its Ed25519 signature +//! against the channel's pinned `pubkey`. +//! 3. Refuse the manifest if its signed timestamp is not newer than the +//! currently installed artifact's (rollback/replay protection). +//! 4. `GET {base_url}/{manifest.artifact}` and verify its sha256 against the +//! manifest. +//! 5. Only once every check passes: atomically install the artifact plus its +//! `.hash` and `.manifest.json` sidecars into `~/.huskies/artifacts/` — +//! the same location `release` publishes to, so `upgrade all` picks it up +//! unchanged. +//! +//! Any failure at any step installs nothing and reports a specific chat error. + +use crate::service::gateway::config::ReleaseChannelConfig; +use crate::service::gateway::release_manifest as verify; +use std::collections::BTreeMap; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::RwLock; + +/// Parse a `pull ` command from a raw message body. +/// +/// Returns the channel name when the stripped message is `pull `. +/// Returns `None` for `pull` with no argument or any other command. +pub fn extract_pull_command(message: &str, bot_name: &str, bot_user_id: &str) -> Option { + let stripped = crate::chat::util::strip_bot_mention(message, bot_name, bot_user_id); + let trimmed = stripped + .trim() + .trim_start_matches(|c: char| !c.is_alphanumeric()); + + let (cmd, rest) = match trimmed.split_once(char::is_whitespace) { + Some((c, r)) => (c, r.trim()), + None => (trimmed, ""), + }; + + if !cmd.eq_ignore_ascii_case("pull") || rest.is_empty() { + return None; + } + Some(rest.split_whitespace().next().unwrap_or(rest).to_string()) +} + +/// Fetch, verify, and install the named release channel's latest artifact. +/// +/// Returns a Markdown status message suitable for posting straight to chat. +pub async fn handle_pull( + channel: &str, + channels_store: &Arc>>, +) -> String { + let cfg = { + let channels = channels_store.read().await; + match channels.get(channel).cloned() { + Some(c) => c, + None => { + let available: Vec<&String> = channels.keys().collect(); + return if available.is_empty() { + format!( + "No release channels are configured. Add one under \ + `[release_channels.{channel}]` in projects.toml with `base_url` and `pubkey`." + ) + } else { + format!( + "Unknown channel `{channel}`. Configured channels: {}", + available + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(", ") + ) + }; + } + } + }; + + let Some(base_url) = cfg.base_url.filter(|u| !u.is_empty()) else { + return format!( + "Channel `{channel}` has no `base_url` configured — cannot pull. \ + There is no unsigned-pull mode." + ); + }; + let Some(pubkey) = cfg.pubkey.filter(|p| !p.is_empty()) else { + return format!( + "Channel `{channel}` has no pinned `pubkey` configured — cannot pull. \ + There is no unsigned-pull mode." + ); + }; + + run_pull( + channel, + &base_url, + &pubkey, + cfg.bearer_token.as_deref(), + &crate::http::artifacts_dir(), + crate::http::SLED_ARTIFACT_NAME, + ) + .await +} + +/// Run the pull pipeline against an already-resolved channel config. +/// +/// Split from [`handle_pull`] so tests can point `artifacts_dir` at a temp +/// directory instead of the real `~/.huskies/artifacts/`. +#[allow(clippy::too_many_arguments)] +async fn run_pull( + channel: &str, + base_url: &str, + pubkey: &str, + bearer_token: Option<&str>, + artifacts_dir: &Path, + artifact_name: &str, +) -> String { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(60)) + .build() + .unwrap_or_default(); + + let signed = + match crate::service::gateway::io::fetch_release_manifest(&client, base_url, bearer_token) + .await + { + Ok(s) => s, + Err(e) => { + return format!("Pull from `{channel}` failed: could not fetch manifest — {e}"); + } + }; + + if let Err(e) = verify::verify_manifest_signature(&signed, pubkey) { + return format!("Pull from `{channel}` failed: {e}. Nothing was installed."); + } + + if signed.manifest.channel != channel { + return format!( + "Pull from `{channel}` failed: manifest is signed for channel `{}`, not `{channel}`. \ + Nothing was installed.", + signed.manifest.channel + ); + } + + let installed = + crate::service::gateway::io::read_installed_manifest(artifacts_dir, artifact_name); + if let Err(e) = verify::check_rollback(&signed.manifest, installed.as_ref()) { + return format!("Pull from `{channel}` refused: {e}"); + } + + let bytes = match crate::service::gateway::io::download_channel_artifact( + &client, + base_url, + &signed.manifest.artifact, + bearer_token, + ) + .await + { + Ok(b) => b, + Err(e) => { + return format!( + "Pull from `{channel}` failed: could not download artifact — {e}. Nothing was installed." + ); + } + }; + + if let Err(e) = verify::verify_artifact_sha256(&bytes, &signed.manifest.sha256) { + return format!("Pull from `{channel}` failed: {e}. Nothing was installed."); + } + + if let Err(e) = crate::service::gateway::io::install_pulled_artifact( + artifacts_dir, + artifact_name, + &bytes, + &signed.manifest, + ) { + return format!("Pull from `{channel}` failed while installing: {e}"); + } + + let size_mb = bytes.len() / (1024 * 1024); + format!( + "Pulled **{}** from `{channel}` ({size_mb} MB, verified signature + sha256).\n\ + Say `upgrade all` to roll it out to the fleet.", + signed.manifest.version + ) +} + +// ── Tests ────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use bft_json_crdt::keypair::{make_keypair, sign}; + use release_manifest::{ReleaseManifest, SignedManifest}; + + fn make_store( + entries: Vec<(&str, ReleaseChannelConfig)>, + ) -> Arc>> { + let mut map = BTreeMap::new(); + for (name, cfg) in entries { + map.insert(name.to_string(), cfg); + } + Arc::new(RwLock::new(map)) + } + + // ── extract_pull_command ───────────────────────────────────────────────── + + #[test] + fn extract_pull_basic() { + let cmd = extract_pull_command("Timmy pull stable", "Timmy", "@timmy:home"); + assert_eq!(cmd, Some("stable".to_string())); + } + + #[test] + fn extract_pull_case_insensitive() { + let cmd = extract_pull_command("Timmy PULL stable", "Timmy", "@timmy:home"); + assert_eq!(cmd, Some("stable".to_string())); + } + + #[test] + fn extract_pull_no_arg_is_none() { + assert_eq!( + extract_pull_command("Timmy pull", "Timmy", "@timmy:home"), + None + ); + } + + #[test] + fn extract_non_pull_returns_none() { + assert_eq!( + extract_pull_command("Timmy status", "Timmy", "@timmy:home"), + None + ); + } + + #[test] + fn extract_pull_full_user_id() { + let cmd = extract_pull_command("@timmy:home pull nightly", "Timmy", "@timmy:home"); + assert_eq!(cmd, Some("nightly".to_string())); + } + + // ── handle_pull config validation ──────────────────────────────────────── + + #[tokio::test] + async fn pull_unknown_channel_reports_error() { + let store = make_store(vec![]); + let msg = handle_pull("stable", &store).await; + assert!( + msg.contains("No release channels"), + "empty store should say no channels: {msg}" + ); + } + + #[tokio::test] + async fn pull_unknown_channel_among_configured_lists_them() { + let store = make_store(vec![( + "beta", + ReleaseChannelConfig { + base_url: Some("http://example.com".into()), + pubkey: Some("ab".repeat(32)), + bearer_token: None, + }, + )]); + let msg = handle_pull("stable", &store).await; + assert!(msg.contains("Unknown channel")); + assert!(msg.contains("beta")); + } + + #[tokio::test] + async fn pull_missing_base_url_is_actionable_error() { + let store = make_store(vec![( + "stable", + ReleaseChannelConfig { + base_url: None, + pubkey: Some("ab".repeat(32)), + bearer_token: None, + }, + )]); + let msg = handle_pull("stable", &store).await; + assert!(msg.contains("no `base_url`")); + assert!(msg.contains("no unsigned-pull mode")); + } + + #[tokio::test] + async fn pull_missing_pubkey_is_actionable_error() { + let store = make_store(vec![( + "stable", + ReleaseChannelConfig { + base_url: Some("http://example.com".into()), + pubkey: None, + bearer_token: None, + }, + )]); + let msg = handle_pull("stable", &store).await; + assert!(msg.contains("no pinned `pubkey`")); + assert!(msg.contains("no unsigned-pull mode")); + } + + // ── run_pull end-to-end against a local mock channel ───────────────────── + + /// Spawn a minimal HTTP server serving fixed responses for + /// `/manifest.json` and `/` so `run_pull` can be exercised + /// end-to-end without a real network. + async fn spawn_mock_channel(manifest_body: Vec, artifact_body: Vec) -> u16 { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + loop { + let Ok((mut stream, _)) = listener.accept().await else { + break; + }; + let manifest_body = manifest_body.clone(); + let artifact_body = artifact_body.clone(); + tokio::spawn(async move { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let mut buf = [0u8; 4096]; + let n = stream.read(&mut buf).await.unwrap_or(0); + let req = String::from_utf8_lossy(&buf[..n]).to_string(); + let path = req.split_whitespace().nth(1).unwrap_or(""); + let body = if path.ends_with("manifest.json") { + manifest_body + } else { + artifact_body + }; + let response = + format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", body.len()); + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.write_all(&body).await; + }); + } + }); + port + } + + fn signed( + kp: &bft_json_crdt::keypair::Ed25519KeyPair, + manifest: ReleaseManifest, + ) -> SignedManifest { + let sig = sign(kp, &manifest.canonical_bytes()); + SignedManifest { + manifest, + signature: sig.to_bytes().iter().map(|b| format!("{b:02x}")).collect(), + } + } + + #[tokio::test] + async fn pull_accepts_valid_signature_and_matching_sha256() { + let kp = make_keypair(); + let pubkey = crate::node_identity::public_key_hex(&kp); + + use sha2::{Digest, Sha256}; + let artifact_body = b"a genuinely valid binary".to_vec(); + let mut hasher = Sha256::new(); + hasher.update(&artifact_body); + let sha256: String = hasher + .finalize() + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + + let manifest = ReleaseManifest { + artifact: "huskies-linux-arm64".to_string(), + sha256, + version: "abc1234".to_string(), + channel: "stable".to_string(), + timestamp: 1, + }; + let signed_manifest = signed(&kp, manifest); + let manifest_body = serde_json::to_vec(&signed_manifest).unwrap(); + + let port = spawn_mock_channel(manifest_body, artifact_body).await; + let dir = tempfile::tempdir().unwrap(); + + let msg = run_pull( + "stable", + &format!("http://127.0.0.1:{port}"), + &pubkey, + None, + dir.path(), + "huskies-linux-arm64", + ) + .await; + assert!(msg.contains("Pulled"), "expected success message: {msg}"); + assert!(msg.contains("abc1234")); + assert!(dir.path().join("huskies-linux-arm64").exists()); + assert!(dir.path().join("huskies-linux-arm64.hash").exists()); + } + + #[tokio::test] + async fn pull_rejects_manifest_from_wrong_key() { + let kp = make_keypair(); + let wrong_kp = make_keypair(); + let wrong_pubkey = crate::node_identity::public_key_hex(&wrong_kp); + + let manifest = ReleaseManifest { + artifact: "huskies-linux-arm64".to_string(), + sha256: "a".repeat(64), + version: "abc1234".to_string(), + channel: "stable".to_string(), + timestamp: 1, + }; + let signed_manifest = signed(&kp, manifest); + let manifest_body = serde_json::to_vec(&signed_manifest).unwrap(); + + let port = spawn_mock_channel(manifest_body, b"binary".to_vec()).await; + let dir = tempfile::tempdir().unwrap(); + + let msg = run_pull( + "stable", + &format!("http://127.0.0.1:{port}"), + &wrong_pubkey, + None, + dir.path(), + "huskies-linux-arm64", + ) + .await; + assert!( + msg.contains("signature"), + "expected signature failure: {msg}" + ); + assert!(msg.contains("Nothing was installed")); + assert!(!dir.path().join("huskies-linux-arm64").exists()); + } + + #[tokio::test] + async fn pull_rejects_tampered_artifact_sha256_mismatch() { + let kp = make_keypair(); + let pubkey = crate::node_identity::public_key_hex(&kp); + + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(b"the real bytes"); + let real_sha256: String = hasher + .finalize() + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + + let manifest = ReleaseManifest { + artifact: "huskies-linux-arm64".to_string(), + sha256: real_sha256, + version: "abc1234".to_string(), + channel: "stable".to_string(), + timestamp: 1, + }; + let signed_manifest = signed(&kp, manifest); + let manifest_body = serde_json::to_vec(&signed_manifest).unwrap(); + + // Serve different bytes than what was signed — sha256 mismatch. + let port = spawn_mock_channel(manifest_body, b"tampered bytes".to_vec()).await; + let dir = tempfile::tempdir().unwrap(); + + let msg = run_pull( + "stable", + &format!("http://127.0.0.1:{port}"), + &pubkey, + None, + dir.path(), + "huskies-linux-arm64", + ) + .await; + assert!( + msg.contains("sha256 mismatch"), + "expected sha256 failure: {msg}" + ); + assert!(msg.contains("Nothing was installed")); + assert!(!dir.path().join("huskies-linux-arm64").exists()); + } + + #[tokio::test] + async fn pull_missing_manifest_endpoint_is_loud_error() { + let dir = tempfile::tempdir().unwrap(); + let msg = run_pull( + "stable", + "http://127.0.0.1:1", // nothing listening + &"ab".repeat(32), + None, + dir.path(), + "huskies-linux-arm64", + ) + .await; + assert!(msg.contains("could not fetch manifest")); + } + + #[tokio::test] + async fn pull_refuses_manifest_older_than_installed() { + let kp = make_keypair(); + let pubkey = crate::node_identity::public_key_hex(&kp); + let dir = tempfile::tempdir().unwrap(); + + // Pre-install a manifest with a later timestamp than the one about to be pulled. + let installed = ReleaseManifest { + artifact: "huskies-linux-arm64".to_string(), + sha256: "a".repeat(64), + version: "newer0000".to_string(), + channel: "stable".to_string(), + timestamp: 100, + }; + crate::service::gateway::io::install_pulled_artifact( + dir.path(), + "huskies-linux-arm64", + b"already installed", + &installed, + ) + .unwrap(); + + let older_manifest = ReleaseManifest { + artifact: "huskies-linux-arm64".to_string(), + sha256: "b".repeat(64), + version: "older0000".to_string(), + channel: "stable".to_string(), + timestamp: 50, + }; + let signed_manifest = signed(&kp, older_manifest); + let manifest_body = serde_json::to_vec(&signed_manifest).unwrap(); + let port = spawn_mock_channel(manifest_body, b"stale binary".to_vec()).await; + + let msg = run_pull( + "stable", + &format!("http://127.0.0.1:{port}"), + &pubkey, + None, + dir.path(), + "huskies-linux-arm64", + ) + .await; + assert!(msg.contains("refused"), "expected rollback refusal: {msg}"); + // The previously installed artifact must remain untouched. + assert_eq!( + std::fs::read(dir.path().join("huskies-linux-arm64")).unwrap(), + b"already installed" + ); + } +} diff --git a/server/src/gateway/mod.rs b/server/src/gateway/mod.rs index 8cb84597..a219f734 100644 --- a/server/src/gateway/mod.rs +++ b/server/src/gateway/mod.rs @@ -131,6 +131,7 @@ pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> { port, Some(state_arc.event_tx.clone()), Arc::clone(&state_arc.permission_registry), + Arc::clone(&state_arc.release_channels), ); *state_arc.bot_handle.lock().await = bot_abort; *state_arc.bot_shutdown_tx.lock().await = Some(bot_shutdown_tx); diff --git a/server/src/gateway/tests.rs b/server/src/gateway/tests.rs index 6e505877..7673b5a1 100644 --- a/server/src/gateway/tests.rs +++ b/server/src/gateway/tests.rs @@ -11,6 +11,7 @@ fn make_test_state() -> Arc { let config = GatewayConfig { projects, sled_tokens: BTreeMap::new(), + release_channels: BTreeMap::new(), }; Arc::new(GatewayState::new(config, PathBuf::new(), 3000).unwrap()) } @@ -369,6 +370,7 @@ async fn init_project_registers_in_projects_toml_when_name_and_url_given() { let config = GatewayConfig { projects, sled_tokens: BTreeMap::new(), + release_channels: BTreeMap::new(), }; let state = Arc::new(GatewayState::new(config, config_dir.path().to_path_buf(), 3000).unwrap()); @@ -397,6 +399,7 @@ async fn init_project_duplicate_name_returns_error() { let config = GatewayConfig { projects, sled_tokens: BTreeMap::new(), + release_channels: BTreeMap::new(), }; let state = Arc::new(GatewayState::new(config, PathBuf::new(), 3000).unwrap()); @@ -449,6 +452,7 @@ async fn init_project_then_wizard_status_integration() { let config = GatewayConfig { projects, sled_tokens: BTreeMap::new(), + release_channels: BTreeMap::new(), }; let config_dir = tempfile::tempdir().unwrap(); let state = Arc::new(GatewayState::new(config, config_dir.path().to_path_buf(), 3000).unwrap()); @@ -973,6 +977,7 @@ async fn gateway_mcp_sse_proxy_streams_progress_and_final_response() { let config = GatewayConfig { projects, sled_tokens: BTreeMap::new(), + release_channels: BTreeMap::new(), }; let state = Arc::new(GatewayState::new(config, PathBuf::new(), 3000).unwrap()); @@ -1064,6 +1069,7 @@ async fn gateway_mcp_post_without_sse_returns_plain_json() { let config = GatewayConfig { projects, sled_tokens: BTreeMap::new(), + release_channels: BTreeMap::new(), }; let state = Arc::new(GatewayState::new(config, PathBuf::new(), 3000).unwrap()); @@ -1183,6 +1189,7 @@ async fn ws_only_sled_handles_tools_list_and_tools_call() { let config = GatewayConfig { projects, sled_tokens: BTreeMap::new(), + release_channels: BTreeMap::new(), }; let state = Arc::new(GatewayState::new(config, PathBuf::new(), 3000).unwrap()); @@ -1265,6 +1272,7 @@ async fn two_concurrent_sleds_are_routed_by_active_project() { let config = GatewayConfig { projects, sled_tokens: BTreeMap::new(), + release_channels: BTreeMap::new(), }; let state = Arc::new(GatewayState::new(config, PathBuf::new(), 3000).unwrap()); diff --git a/server/src/gateway_relay.rs b/server/src/gateway_relay.rs index 5d2c84b8..f5f4127e 100644 --- a/server/src/gateway_relay.rs +++ b/server/src/gateway_relay.rs @@ -300,6 +300,7 @@ mod tests { let config = GatewayConfig { projects, sled_tokens: BTreeMap::new(), + release_channels: BTreeMap::new(), }; let state = Arc::new(GatewayState::new(config, PathBuf::new(), 9000).unwrap()); @@ -421,6 +422,7 @@ mod tests { let config = GatewayConfig { projects, sled_tokens: BTreeMap::new(), + release_channels: BTreeMap::new(), }; let state = Arc::new(GatewayState::new(config, PathBuf::new(), 9001).unwrap()); let mut gw_rx = state.event_tx.subscribe(); diff --git a/server/src/http/gateway/mcp.rs b/server/src/http/gateway/mcp.rs index 8f461c76..f2c09a73 100644 --- a/server/src/http/gateway/mcp.rs +++ b/server/src/http/gateway/mcp.rs @@ -958,6 +958,7 @@ mod tests { let config = GatewayConfig { projects, sled_tokens: BTreeMap::new(), + release_channels: BTreeMap::new(), }; Arc::new(GatewayState::new(config, config_dir.to_path_buf(), 3000).unwrap()) } @@ -979,6 +980,7 @@ mod tests { let config = GatewayConfig { projects, sled_tokens: BTreeMap::new(), + release_channels: BTreeMap::new(), }; let state = Arc::new(GatewayState::new(config, dir.path().to_path_buf(), 3000).unwrap()); diff --git a/server/src/main.rs b/server/src/main.rs index 8b857121..7419527b 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -400,6 +400,7 @@ async fn main() -> Result<(), std::io::Error> { timer_store_for_bot, None, None, + None, ); } else { drop(matrix_shutdown_rx); diff --git a/server/src/service/gateway/config.rs b/server/src/service/gateway/config.rs index 4d7c2d6b..a857aff7 100644 --- a/server/src/service/gateway/config.rs +++ b/server/src/service/gateway/config.rs @@ -70,8 +70,34 @@ impl ProjectEntry { } } +/// Configuration for one signed release channel the gateway can `pull` from. +/// +/// All three fields are optional in the TOML shape — a channel can be +/// partially configured while it's being set up — but `pull ` +/// (story 1169) requires both `base_url` and `pubkey` to be present. There is +/// no unsigned-pull mode: a missing pubkey is always a hard error, never a +/// fallback to trusting whatever the channel serves. +#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)] +pub struct ReleaseChannelConfig { + /// Base URL the channel is served from (e.g. `https://releases.example.com/stable`). + /// + /// `pull ` fetches `{base_url}/manifest.json` and, once verified, + /// `{base_url}/{manifest.artifact}`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_url: Option, + /// Pinned Ed25519 public key (hex) the channel's `manifest.json` signature + /// must verify against. Generated by `release-tool keygen` and never + /// derived automatically — an operator must paste it in explicitly. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pubkey: Option, + /// Optional bearer token sent as `Authorization: Bearer ` on both + /// the manifest and artifact requests (e.g. for a private release channel). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bearer_token: Option, +} + /// Top-level `projects.toml` config. -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, Deserialize, Serialize)] pub struct GatewayConfig { /// Map of project name → container configuration. #[serde(default)] @@ -86,6 +112,12 @@ pub struct GatewayConfig { /// `/api/sled-uplink` using the given secret token as a bearer credential. #[serde(default)] pub sled_tokens: BTreeMap, + /// Map of channel name → signed release channel configuration (story 1169). + /// + /// Populated by an operator adding `[release_channels.]` sections to + /// `projects.toml`. Read by the `pull ` gateway chat command. + #[serde(default)] + pub release_channels: BTreeMap, } /// Validate that a gateway config has at least one project. @@ -206,6 +238,7 @@ auth_token = "secret" let config = GatewayConfig { projects: BTreeMap::new(), sled_tokens: BTreeMap::new(), + release_channels: BTreeMap::new(), }; assert!(validate_config(&config).is_err()); } @@ -218,6 +251,7 @@ auth_token = "secret" let config = GatewayConfig { projects, sled_tokens: BTreeMap::new(), + release_channels: BTreeMap::new(), }; assert_eq!(validate_config(&config).unwrap(), "alpha"); } @@ -238,6 +272,7 @@ auth_token = "secret" let config = GatewayConfig { projects, sled_tokens: BTreeMap::new(), + release_channels: BTreeMap::new(), }; assert!(validate_config(&config).is_ok()); } @@ -340,6 +375,7 @@ auth_token = "secret" let config = GatewayConfig { projects, sled_tokens: BTreeMap::new(), + release_channels: BTreeMap::new(), }; let toml_str = toml::to_string_pretty(&config).unwrap(); let parsed: GatewayConfig = toml::from_str(&toml_str).unwrap(); @@ -367,6 +403,7 @@ auth_token = "secret" let config = GatewayConfig { projects, sled_tokens: BTreeMap::new(), + release_channels: BTreeMap::new(), }; let toml_str = toml::to_string_pretty(&config).unwrap(); let parsed: GatewayConfig = toml::from_str(&toml_str).unwrap(); @@ -392,6 +429,7 @@ auth_token = "secret" let config = GatewayConfig { projects, sled_tokens: BTreeMap::new(), + release_channels: BTreeMap::new(), }; let toml_str = toml::to_string_pretty(&config).unwrap(); assert!(toml_str.contains("expected_node_id")); @@ -407,6 +445,7 @@ auth_token = "secret" let config2 = GatewayConfig { projects: projects2, sled_tokens: BTreeMap::new(), + release_channels: BTreeMap::new(), }; let toml_str2 = toml::to_string_pretty(&config2).unwrap(); assert!( @@ -423,6 +462,7 @@ auth_token = "secret" let config = GatewayConfig { projects, sled_tokens: BTreeMap::new(), + release_channels: BTreeMap::new(), }; let toml_str = toml::to_string_pretty(&config).unwrap(); assert!( diff --git a/server/src/service/gateway/io.rs b/server/src/service/gateway/io.rs index 4dc60d71..a5605f9d 100644 --- a/server/src/service/gateway/io.rs +++ b/server/src/service/gateway/io.rs @@ -22,22 +22,30 @@ pub fn load_config(path: &Path) -> Result { /// Persist the current projects map to `/projects.toml`. /// Silently ignores write errors or skips when `config_dir` is empty. /// -/// Existing `[sled_tokens]` entries are preserved so that adding or removing -/// projects via the UI does not wipe the sled authentication tokens. +/// Existing `[sled_tokens]` and `[release_channels]` entries are preserved so +/// that adding or removing projects via the UI does not wipe the sled +/// authentication tokens or configured release channels. pub async fn save_config(projects: &BTreeMap, config_dir: &Path) { if config_dir.as_os_str().is_empty() { return; } let path = config_dir.join("projects.toml"); - let sled_tokens = tokio::fs::read_to_string(&path) + let existing = tokio::fs::read_to_string(&path) .await .ok() - .and_then(|data| toml::from_str::(&data).ok()) - .map(|c| c.sled_tokens) + .and_then(|data| toml::from_str::(&data).ok()); + let sled_tokens = existing + .as_ref() + .map(|c| c.sled_tokens.clone()) + .unwrap_or_default(); + let release_channels = existing + .as_ref() + .map(|c| c.release_channels.clone()) .unwrap_or_default(); let config = GatewayConfig { projects: projects.clone(), sled_tokens, + release_channels, }; if let Ok(data) = toml::to_string_pretty(&config) { let _ = tokio::fs::write(&path, data).await; @@ -120,6 +128,115 @@ pub async fn probe_identity( .ok() } +// ── Release channel I/O (story 1169) ──────────────────────────────────────── + +/// Fetch `{base_url}/manifest.json` and parse it into a +/// [`release_manifest::SignedManifest`]. +/// +/// Sends `Authorization: Bearer ` when `bearer_token` is set. Returns +/// `Err` on any network, HTTP-status, or parse failure — `pull` treats all of +/// these the same as any other loud, install-nothing failure. +pub async fn fetch_release_manifest( + client: &Client, + base_url: &str, + bearer_token: Option<&str>, +) -> Result { + let url = format!("{}/manifest.json", base_url.trim_end_matches('/')); + let mut req = client.get(&url); + if let Some(token) = bearer_token { + req = req.bearer_auth(token); + } + let resp = req + .send() + .await + .map_err(|e| format!("cannot reach {url}: {e}"))?; + if !resp.status().is_success() { + return Err(format!("{url} returned HTTP {}", resp.status())); + } + resp.json() + .await + .map_err(|e| format!("invalid manifest.json at {url}: {e}")) +} + +/// Download a named artifact from a release channel. +/// +/// Sends `Authorization: Bearer ` when `bearer_token` is set. +pub async fn download_channel_artifact( + client: &Client, + base_url: &str, + artifact_name: &str, + bearer_token: Option<&str>, +) -> Result, String> { + let url = format!("{}/{artifact_name}", base_url.trim_end_matches('/')); + let mut req = client.get(&url); + if let Some(token) = bearer_token { + req = req.bearer_auth(token); + } + let resp = req + .send() + .await + .map_err(|e| format!("cannot reach {url}: {e}"))?; + if !resp.status().is_success() { + return Err(format!("{url} returned HTTP {}", resp.status())); + } + resp.bytes() + .await + .map(|b| b.to_vec()) + .map_err(|e| format!("failed reading artifact body from {url}: {e}")) +} + +/// Atomically install a verified artifact plus its `.hash` and +/// `.manifest.json` sidecars into the gateway's artifact store. +/// +/// Mirrors the write-tmp-then-rename pattern `release` uses for +/// `SLED_ARTIFACT_NAME` so a concurrent `/api/artifacts` download never +/// observes a half-written file. The `.hash` sidecar carries `manifest.version` +/// so `upgrade all`'s existing convergence check (`sled_upgrade.rs`) keeps +/// working unmodified. The `.manifest.json` sidecar records the full manifest +/// so the next pull can run [`super::release_manifest::check_rollback`] +/// against it. +pub fn install_pulled_artifact( + artifacts_dir: &Path, + artifact_name: &str, + bytes: &[u8], + manifest: &release_manifest::ReleaseManifest, +) -> Result<(), String> { + std::fs::create_dir_all(artifacts_dir) + .map_err(|e| format!("cannot create {}: {e}", artifacts_dir.display()))?; + + let artifact_path = artifacts_dir.join(artifact_name); + let tmp_path = artifacts_dir.join(".pull.tmp"); + std::fs::write(&tmp_path, bytes).map_err(|e| format!("write tmp artifact failed: {e}"))?; + std::fs::rename(&tmp_path, &artifact_path) + .map_err(|e| format!("rename artifact failed: {e}"))?; + + let hash_path = artifacts_dir.join(format!("{artifact_name}.hash")); + std::fs::write(&hash_path, &manifest.version) + .map_err(|e| format!("write hash sidecar failed: {e}"))?; + + let manifest_path = artifacts_dir.join(format!("{artifact_name}.manifest.json")); + let manifest_json = serde_json::to_string_pretty(manifest) + .map_err(|e| format!("serialise manifest sidecar: {e}"))?; + std::fs::write(&manifest_path, manifest_json) + .map_err(|e| format!("write manifest sidecar failed: {e}"))?; + + Ok(()) +} + +/// Read the `.manifest.json` sidecar for the currently installed artifact, if any. +/// +/// Returns `None` when no artifact has ever been pulled from a channel (e.g. +/// only `release` has published so far, or this is a fresh gateway) — callers +/// treat that as "no rollback information available", not an error. +pub fn read_installed_manifest( + artifacts_dir: &Path, + artifact_name: &str, +) -> Option { + let path = artifacts_dir.join(format!("{artifact_name}.manifest.json")); + let content = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&content).ok() +} + // ── MCP proxy I/O ─────────────────────────────────────────────────────────── /// Proxy a raw MCP request body to the given project URL. @@ -534,6 +651,9 @@ pub fn spawn_gateway_bot( port: u16, gateway_event_tx: Option>, permission_registry: std::sync::Arc, + gateway_channels_store: std::sync::Arc< + tokio::sync::RwLock>, + >, ) -> ( Option, tokio::sync::watch::Sender>, @@ -600,6 +720,7 @@ pub fn spawn_gateway_bot( timer_store, gateway_event_rx, Some(port), + Some(gateway_channels_store), ); (handle, shutdown_tx) } @@ -622,6 +743,8 @@ mod tests { let permission_registry = crate::service::permission_router::ResponderRegistry::new(); let projects_store = std::sync::Arc::new(tokio::sync::RwLock::new(std::collections::BTreeMap::new())); + let channels_store = + std::sync::Arc::new(tokio::sync::RwLock::new(std::collections::BTreeMap::new())); let (handle, shutdown_tx) = spawn_gateway_bot( tmp.path(), active, @@ -629,6 +752,7 @@ mod tests { 3001, Some(event_tx), permission_registry, + channels_store, ); // No bot.toml in tmp → no abort handle spawned. @@ -647,4 +771,160 @@ mod tests { "shutdown receiver must see the Manual reason" ); } + + // ── fetch_release_manifest / download_channel_artifact ────────────────── + + /// Spawn a one-shot TCP listener that captures the raw request text of a + /// single connection and responds with `body`. + fn spawn_capturing_responder( + listener: tokio::net::TcpListener, + body: Vec, + ) -> tokio::sync::oneshot::Receiver { + let (tx, rx) = tokio::sync::oneshot::channel(); + tokio::spawn(async move { + if let Ok((mut stream, _)) = listener.accept().await { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let mut buf = [0u8; 8192]; + let n = stream.read(&mut buf).await.unwrap_or(0); + let request = String::from_utf8_lossy(&buf[..n]).to_string(); + let response = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", body.len()); + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.write_all(&body).await; + let _ = tx.send(request); + } + }); + rx + } + + fn sample_signed_manifest() -> release_manifest::SignedManifest { + release_manifest::SignedManifest { + manifest: release_manifest::ReleaseManifest { + artifact: "huskies-linux-arm64".to_string(), + sha256: "a".repeat(64), + version: "abc1234".to_string(), + channel: "stable".to_string(), + timestamp: 1, + }, + signature: "deadbeef".to_string(), + } + } + + #[tokio::test] + async fn fetch_release_manifest_sends_bearer_auth_header() { + let body = serde_json::to_vec(&sample_signed_manifest()).unwrap(); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let rx = spawn_capturing_responder(listener, body); + + let client = Client::new(); + let base_url = format!("http://127.0.0.1:{port}"); + let fetched = fetch_release_manifest(&client, &base_url, Some("secret-token")) + .await + .unwrap(); + assert_eq!(fetched.manifest.version, "abc1234"); + + let request = rx.await.unwrap(); + assert!( + request + .to_lowercase() + .contains("authorization: bearer secret-token"), + "request must carry the bearer token: {request}" + ); + } + + #[tokio::test] + async fn fetch_release_manifest_without_token_sends_no_auth_header() { + let body = serde_json::to_vec(&sample_signed_manifest()).unwrap(); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let rx = spawn_capturing_responder(listener, body); + + let client = Client::new(); + let base_url = format!("http://127.0.0.1:{port}"); + fetch_release_manifest(&client, &base_url, None) + .await + .unwrap(); + + let request = rx.await.unwrap(); + assert!( + !request.to_lowercase().contains("authorization:"), + "request must not carry an Authorization header when no token is set: {request}" + ); + } + + #[tokio::test] + async fn fetch_release_manifest_unreachable_is_error() { + let client = Client::new(); + let err = fetch_release_manifest(&client, "http://127.0.0.1:1", None) + .await + .unwrap_err(); + assert!(!err.is_empty()); + } + + #[tokio::test] + async fn download_channel_artifact_returns_bytes() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let rx = spawn_capturing_responder(listener, b"binary bytes".to_vec()); + + let client = Client::new(); + let base_url = format!("http://127.0.0.1:{port}"); + let bytes = download_channel_artifact(&client, &base_url, "huskies-linux-arm64", None) + .await + .unwrap(); + assert_eq!(bytes, b"binary bytes"); + rx.await.unwrap(); + } + + #[tokio::test] + async fn download_channel_artifact_sends_bearer_auth_header() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let rx = spawn_capturing_responder(listener, b"bytes".to_vec()); + + let client = Client::new(); + let base_url = format!("http://127.0.0.1:{port}"); + download_channel_artifact(&client, &base_url, "art", Some("tok")) + .await + .unwrap(); + + let request = rx.await.unwrap(); + assert!(request.to_lowercase().contains("authorization: bearer tok")); + } + + // ── install_pulled_artifact / read_installed_manifest ──────────────────── + + #[test] + fn install_pulled_artifact_writes_atomically_with_sidecars() { + let dir = tempfile::tempdir().unwrap(); + let manifest = sample_signed_manifest().manifest; + install_pulled_artifact( + dir.path(), + "huskies-linux-arm64", + b"binary content", + &manifest, + ) + .unwrap(); + + let artifact_path = dir.path().join("huskies-linux-arm64"); + assert_eq!(std::fs::read(&artifact_path).unwrap(), b"binary content"); + assert!( + !dir.path().join(".pull.tmp").exists(), + "tmp file must be renamed away" + ); + + let hash = std::fs::read_to_string(dir.path().join("huskies-linux-arm64.hash")).unwrap(); + assert_eq!(hash, "abc1234"); + + let read_back = read_installed_manifest(dir.path(), "huskies-linux-arm64").unwrap(); + assert_eq!(read_back, manifest); + } + + #[test] + fn read_installed_manifest_missing_sidecar_returns_none() { + let dir = tempfile::tempdir().unwrap(); + assert!(read_installed_manifest(dir.path(), "huskies-linux-arm64").is_none()); + } } diff --git a/server/src/service/gateway/mod.rs b/server/src/service/gateway/mod.rs index 38ced712..a90610c2 100644 --- a/server/src/service/gateway/mod.rs +++ b/server/src/service/gateway/mod.rs @@ -16,6 +16,8 @@ pub mod identity; pub(crate) mod io; /// Notification event polling for gateway-level broadcasts. pub mod polling; +/// Pure signed release-manifest verification (signature, sha256, rollback) — no I/O. +pub mod release_manifest; pub use aggregation::format_aggregate_status_compact; pub use config::{GatewayConfig, ProjectEntry}; @@ -247,6 +249,11 @@ pub struct GatewayState { /// and depopulated when it disconnects. MCP proxy functions check here /// first (WS route), falling back to HTTP when no live connection exists. pub sled_connections: Arc>>, + /// Configured signed release channels (story 1169), keyed by channel name. + /// + /// Read by the `pull ` gateway chat command to resolve a + /// channel's `base_url`, pinned `pubkey`, and optional `bearer_token`. + pub release_channels: Arc>>, } impl GatewayState { @@ -306,6 +313,7 @@ impl GatewayState { permission_registry, sled_tokens, sled_connections: Arc::new(RwLock::new(HashMap::new())), + release_channels: Arc::new(RwLock::new(gateway_config.release_channels)), }) } @@ -700,6 +708,7 @@ pub async fn save_bot_config_and_restart(state: &GatewayState, content: &str) -> state.port, Some(state.event_tx.clone()), Arc::clone(&state.permission_registry), + Arc::clone(&state.release_channels), ); *handle = new_handle; *state.bot_shutdown_tx.lock().await = Some(new_shutdown_tx); @@ -723,6 +732,7 @@ mod tests { GatewayConfig { projects, sled_tokens: BTreeMap::new(), + release_channels: BTreeMap::new(), } } @@ -731,6 +741,7 @@ mod tests { let config = GatewayConfig { projects: BTreeMap::new(), sled_tokens: BTreeMap::new(), + release_channels: BTreeMap::new(), }; assert!(GatewayState::new(config, PathBuf::from("."), 3000).is_err()); } @@ -790,6 +801,7 @@ mod tests { let config = GatewayConfig { projects, sled_tokens: BTreeMap::new(), + release_channels: BTreeMap::new(), }; let state = GatewayState::new(config, PathBuf::from("."), 3000).unwrap(); assert!(state.active_url().await.is_err()); @@ -931,6 +943,7 @@ mod tests { let config = GatewayConfig { projects, sled_tokens: BTreeMap::new(), + release_channels: BTreeMap::new(), }; let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap(); assert_eq!( diff --git a/server/src/service/gateway/release_manifest.rs b/server/src/service/gateway/release_manifest.rs new file mode 100644 index 00000000..9dff613e --- /dev/null +++ b/server/src/service/gateway/release_manifest.rs @@ -0,0 +1,195 @@ +//! Pure verification for signed release-channel manifests — no I/O. +//! +//! Fetching `manifest.json` over HTTP, downloading the artifact, and +//! installing it atomically all live in [`super::io`]; this module only +//! decides whether a fetched [`release_manifest::SignedManifest`] should be +//! trusted and installed. + +use release_manifest::ReleaseManifest; + +/// Verify `signed`'s Ed25519 signature against `pinned_pubkey_hex`. +/// +/// Re-serializes the embedded manifest with +/// [`ReleaseManifest::canonical_bytes`] rather than trusting whatever bytes +/// the channel actually served, so verification depends only on the +/// semantic manifest fields — not on `manifest.json`'s exact on-disk layout. +pub fn verify_manifest_signature( + signed: &release_manifest::SignedManifest, + pinned_pubkey_hex: &str, +) -> Result<(), String> { + let bytes = signed.manifest.canonical_bytes(); + if crate::node_identity::verify_message_strict(pinned_pubkey_hex, &bytes, &signed.signature) { + Ok(()) + } else { + Err("manifest signature verification failed against the pinned channel pubkey".to_string()) + } +} + +/// Verify a downloaded artifact's sha256 against the manifest's pinned hash. +pub fn verify_artifact_sha256(bytes: &[u8], expected_sha256_hex: &str) -> Result<(), String> { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(bytes); + let actual = hex_encode(&hasher.finalize()); + if actual.eq_ignore_ascii_case(expected_sha256_hex) { + Ok(()) + } else { + Err(format!( + "artifact sha256 mismatch: manifest says {expected_sha256_hex}, downloaded bytes hash to {actual}" + )) + } +} + +/// Refuse a manifest whose signed timestamp is not strictly newer than the +/// currently installed artifact's — basic rollback / replay protection. +/// +/// `installed` is `None` when no manifest sidecar exists yet (first pull, or +/// an artifact previously published by the local `release` command rather +/// than a channel pull); in that case any manifest is accepted. +pub fn check_rollback( + new: &ReleaseManifest, + installed: Option<&ReleaseManifest>, +) -> Result<(), String> { + let Some(installed) = installed else { + return Ok(()); + }; + if new.timestamp <= installed.timestamp { + return Err(format!( + "manifest timestamp {} (version {}, channel {}) is not newer than the installed \ + artifact's timestamp {} (version {}, channel {}) — refusing to install a possible \ + rollback or replay", + new.timestamp, + new.version, + new.channel, + installed.timestamp, + installed.version, + installed.channel, + )); + } + Ok(()) +} + +fn hex_encode(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use bft_json_crdt::keypair::{make_keypair, sign}; + + fn sample_manifest() -> ReleaseManifest { + ReleaseManifest { + artifact: "huskies-linux-arm64".to_string(), + sha256: "a".repeat(64), + version: "abc1234".to_string(), + channel: "stable".to_string(), + timestamp: 1_700_000_000, + } + } + + fn signed_by( + kp: &bft_json_crdt::keypair::Ed25519KeyPair, + manifest: ReleaseManifest, + ) -> release_manifest::SignedManifest { + let sig = sign(kp, &manifest.canonical_bytes()); + let signature = hex_encode(&sig.to_bytes()); + release_manifest::SignedManifest { + manifest, + signature, + } + } + + // ── verify_manifest_signature ──────────────────────────────────────────── + + #[test] + fn valid_signature_is_accepted() { + let kp = make_keypair(); + let pubkey = crate::node_identity::public_key_hex(&kp); + let signed = signed_by(&kp, sample_manifest()); + assert!(verify_manifest_signature(&signed, &pubkey).is_ok()); + } + + #[test] + fn signature_from_wrong_key_is_rejected() { + let kp = make_keypair(); + let other_kp = make_keypair(); + let other_pubkey = crate::node_identity::public_key_hex(&other_kp); + let signed = signed_by(&kp, sample_manifest()); + let err = verify_manifest_signature(&signed, &other_pubkey).unwrap_err(); + assert!(err.contains("signature verification failed")); + } + + #[test] + fn tampered_manifest_field_is_rejected() { + let kp = make_keypair(); + let pubkey = crate::node_identity::public_key_hex(&kp); + let mut signed = signed_by(&kp, sample_manifest()); + // Tamper with the sha256 after signing — signature no longer matches. + signed.manifest.sha256 = "b".repeat(64); + assert!(verify_manifest_signature(&signed, &pubkey).is_err()); + } + + #[test] + fn missing_signature_is_rejected() { + let kp = make_keypair(); + let pubkey = crate::node_identity::public_key_hex(&kp); + let signed = release_manifest::SignedManifest { + manifest: sample_manifest(), + signature: String::new(), + }; + assert!(verify_manifest_signature(&signed, &pubkey).is_err()); + } + + // ── verify_artifact_sha256 ─────────────────────────────────────────────── + + #[test] + fn matching_sha256_is_accepted() { + use sha2::{Digest, Sha256}; + let bytes = b"artifact contents"; + let mut hasher = Sha256::new(); + hasher.update(bytes); + let expected = hex_encode(&hasher.finalize()); + assert!(verify_artifact_sha256(bytes, &expected).is_ok()); + } + + #[test] + fn tampered_artifact_sha256_mismatch_is_rejected() { + let err = verify_artifact_sha256(b"tampered bytes", &"a".repeat(64)).unwrap_err(); + assert!(err.contains("sha256 mismatch")); + } + + // ── check_rollback ─────────────────────────────────────────────────────── + + #[test] + fn no_installed_manifest_always_allows() { + assert!(check_rollback(&sample_manifest(), None).is_ok()); + } + + #[test] + fn newer_timestamp_is_allowed() { + let installed = sample_manifest(); + let mut newer = sample_manifest(); + newer.timestamp = installed.timestamp + 1; + assert!(check_rollback(&newer, Some(&installed)).is_ok()); + } + + #[test] + fn older_than_installed_manifest_triggers_rollback_warning() { + let installed = sample_manifest(); + let mut older = sample_manifest(); + older.timestamp = installed.timestamp - 1; + let err = check_rollback(&older, Some(&installed)).unwrap_err(); + assert!(err.contains("rollback or replay")); + } + + #[test] + fn equal_timestamp_is_refused_as_replay() { + let installed = sample_manifest(); + let replay = sample_manifest(); + let err = check_rollback(&replay, Some(&installed)).unwrap_err(); + assert!(err.contains("rollback or replay")); + } +}