huskies: merge 1169 story Gateway pulls signed artifacts from a release channel into its local store
This commit is contained in:
Generated
+21
@@ -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"
|
||||
|
||||
+7
-1
@@ -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]
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "release-manifest"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
@@ -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<u8> {
|
||||
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<ReleaseManifest, _> = 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<SignedManifest, _> = serde_json::from_value(bad);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"signed manifest missing signature must fail to parse"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 <key-out-path>
|
||||
//! release-tool sign --key <path> --artifact <path> --version <str> --channel <str> --out <path> [--timestamp <unix-secs>]
|
||||
//! ```
|
||||
|
||||
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<String> = 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 <key-out-path> | release-tool sign --key <path> \
|
||||
--artifact <path> --version <str> --channel <str> --out <path> [--timestamp <unix-secs>]"
|
||||
.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<SigningKey, String> {
|
||||
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<i64>,
|
||||
}
|
||||
|
||||
fn parse_sign_args(args: &[String]) -> Result<SignArgs, String> {
|
||||
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::<i64>()
|
||||
.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<String> = vec!["--key".into(), "k".into()];
|
||||
assert!(parse_sign_args(&args).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_sign_args_accepts_all_flags() {
|
||||
let args: Vec<String> = 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<u8> {
|
||||
(0..s.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<Arc<RwLock<BTreeMap<String, ProjectEntry>>>>,
|
||||
/// In gateway mode: shared configured release channels (story 1169).
|
||||
///
|
||||
/// Read by the `pull <channel>` command to resolve a channel's
|
||||
/// `base_url`, pinned `pubkey`, and optional `bearer_token`. `None` in
|
||||
/// standalone mode.
|
||||
pub gateway_channels_store: Option<
|
||||
Arc<RwLock<BTreeMap<String, crate::service::gateway::config::ReleaseChannelConfig>>>,
|
||||
>,
|
||||
/// 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,
|
||||
))),
|
||||
|
||||
@@ -548,6 +548,33 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
|
||||
return;
|
||||
}
|
||||
|
||||
// In gateway mode, handle the `pull <channel>` 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 [<project>]` command to upgrade a
|
||||
// sled's binary in-container, streaming phase markers to the room.
|
||||
if ctx.is_gateway()
|
||||
|
||||
@@ -40,6 +40,16 @@ pub async fn run_bot(
|
||||
tokio::sync::broadcast::Receiver<crate::service::gateway::GatewayStatusEvent>,
|
||||
>,
|
||||
gateway_port: Option<u16>,
|
||||
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,
|
||||
))),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -35,6 +35,9 @@ pub mod new_project;
|
||||
pub mod project_rebuild;
|
||||
/// `projects` chat command — list all registered gateway projects.
|
||||
pub mod projects;
|
||||
/// `pull <channel>` 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<crate::service::gateway::GatewayStatusEvent>,
|
||||
>,
|
||||
gateway_port: Option<u16>,
|
||||
gateway_channels_store: Option<
|
||||
Arc<
|
||||
RwLock<
|
||||
std::collections::BTreeMap<
|
||||
String,
|
||||
crate::service::gateway::config::ReleaseChannelConfig,
|
||||
>,
|
||||
>,
|
||||
>,
|
||||
>,
|
||||
) -> Option<tokio::task::AbortHandle> {
|
||||
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
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
//! `pull <channel>` 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 <channel>`
|
||||
//!
|
||||
//! Pipeline:
|
||||
//! 1. Look up `<channel>` in `projects.toml`'s `[release_channels.<name>]`
|
||||
//! 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 <token>`
|
||||
//! 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 <channel>` command from a raw message body.
|
||||
///
|
||||
/// Returns the channel name when the stripped message is `pull <name>`.
|
||||
/// 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<String> {
|
||||
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<RwLock<BTreeMap<String, ReleaseChannelConfig>>>,
|
||||
) -> 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::<Vec<_>>()
|
||||
.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<RwLock<BTreeMap<String, ReleaseChannelConfig>>> {
|
||||
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 `/<artifact>` so `run_pull` can be exercised
|
||||
/// end-to-end without a real network.
|
||||
async fn spawn_mock_channel(manifest_body: Vec<u8>, artifact_body: Vec<u8>) -> 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -11,6 +11,7 @@ fn make_test_state() -> Arc<GatewayState> {
|
||||
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());
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -400,6 +400,7 @@ async fn main() -> Result<(), std::io::Error> {
|
||||
timer_store_for_bot,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
} else {
|
||||
drop(matrix_shutdown_rx);
|
||||
|
||||
@@ -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 <channel>`
|
||||
/// (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 <channel>` fetches `{base_url}/manifest.json` and, once verified,
|
||||
/// `{base_url}/{manifest.artifact}`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub base_url: Option<String>,
|
||||
/// 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<String>,
|
||||
/// Optional bearer token sent as `Authorization: Bearer <token>` 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<String>,
|
||||
}
|
||||
|
||||
/// 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<String, String>,
|
||||
/// Map of channel name → signed release channel configuration (story 1169).
|
||||
///
|
||||
/// Populated by an operator adding `[release_channels.<name>]` sections to
|
||||
/// `projects.toml`. Read by the `pull <channel>` gateway chat command.
|
||||
#[serde(default)]
|
||||
pub release_channels: BTreeMap<String, ReleaseChannelConfig>,
|
||||
}
|
||||
|
||||
/// 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!(
|
||||
|
||||
@@ -22,22 +22,30 @@ pub fn load_config(path: &Path) -> Result<GatewayConfig, String> {
|
||||
/// Persist the current projects map to `<config_dir>/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<String, ProjectEntry>, 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::<GatewayConfig>(&data).ok())
|
||||
.map(|c| c.sled_tokens)
|
||||
.and_then(|data| toml::from_str::<GatewayConfig>(&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 <token>` 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<release_manifest::SignedManifest, String> {
|
||||
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 <token>` when `bearer_token` is set.
|
||||
pub async fn download_channel_artifact(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
artifact_name: &str,
|
||||
bearer_token: Option<&str>,
|
||||
) -> Result<Vec<u8>, 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<release_manifest::ReleaseManifest> {
|
||||
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<tokio::sync::broadcast::Sender<super::GatewayStatusEvent>>,
|
||||
permission_registry: std::sync::Arc<crate::service::permission_router::ResponderRegistry>,
|
||||
gateway_channels_store: std::sync::Arc<
|
||||
tokio::sync::RwLock<BTreeMap<String, super::config::ReleaseChannelConfig>>,
|
||||
>,
|
||||
) -> (
|
||||
Option<tokio::task::AbortHandle>,
|
||||
tokio::sync::watch::Sender<Option<crate::rebuild::ShutdownReason>>,
|
||||
@@ -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<u8>,
|
||||
) -> tokio::sync::oneshot::Receiver<String> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<RwLock<HashMap<String, SledConnection>>>,
|
||||
/// Configured signed release channels (story 1169), keyed by channel name.
|
||||
///
|
||||
/// Read by the `pull <channel>` gateway chat command to resolve a
|
||||
/// channel's `base_url`, pinned `pubkey`, and optional `bearer_token`.
|
||||
pub release_channels: Arc<RwLock<BTreeMap<String, config::ReleaseChannelConfig>>>,
|
||||
}
|
||||
|
||||
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!(
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user