Files
huskies/server/src/chat/transport/matrix/pull.rs
T

611 lines
22 KiB
Rust
Raw Normal View History

//! `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.
///
/// `run_pull` issues its manifest fetch and artifact download
/// sequentially over one shared `reqwest::Client`, whose HTTP/1.1 pool
/// assumes keep-alive by default. Every response here closes its
/// connection after a single request, so we send `Connection: close` to
/// stop the client from ever trying to reuse a connection this server
/// has already torn down (a real source of intermittent "cannot reach"
/// fetch errors that raced ahead of the sha256 check). We also block on
/// a readiness signal fired from inside the accept loop so the returned
/// port is guaranteed to have an active acceptor before any caller
/// connects to it.
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();
let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
tokio::spawn(async move {
let _ = ready_tx.send(());
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\nConnection: close\r\n\r\n",
body.len()
);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.write_all(&body).await;
let _ = stream.shutdown().await;
});
}
});
let _ = ready_rx.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("could not fetch manifest")
&& !msg.contains("could not download artifact"),
"manifest fetch and artifact download must succeed so the sha256 check is reached, \
not preempted by a fetch/download error: {msg}"
);
assert!(msg.contains("Nothing was installed"));
assert!(!dir.path().join("huskies-linux-arm64").exists());
}
/// Stress variant of [`pull_rejects_tampered_artifact_sha256_mismatch`]:
/// repeats the same scenario against many independent mock channels to
/// catch any reintroduction of the fetch-ordering race under load.
#[tokio::test]
async fn pull_rejects_tampered_artifact_sha256_mismatch_stress() {
for _ in 0..50 {
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();
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 on every iteration: {msg}"
);
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"
);
}
}