From fba9b09d3e41202f9eb11311b38bbc769b9eddc3 Mon Sep 17 00:00:00 2001 From: Huskies Agent Date: Sat, 18 Jul 2026 00:57:45 +0000 Subject: [PATCH] huskies: merge 1206 story fleet_identity MCP tool: read sled pins vs live signed identity, and re-pin via TOFU --- server/src/http/gateway/mcp.rs | 137 ++++++++ server/src/service/gateway/aggregation.rs | 138 ++++++++ server/src/service/gateway/identity.rs | 132 ++++++++ server/src/service/gateway/io.rs | 365 ++++++++++++++++++++++ server/src/service/gateway/mod.rs | 186 ++++++++++- 5 files changed, 956 insertions(+), 2 deletions(-) diff --git a/server/src/http/gateway/mcp.rs b/server/src/http/gateway/mcp.rs index f2c09a73..bd315272 100644 --- a/server/src/http/gateway/mcp.rs +++ b/server/src/http/gateway/mcp.rs @@ -29,6 +29,8 @@ const GATEWAY_TOOLS: &[&str] = &[ "prompt_permission", // One-shot container rebuild: build fresh image, swap container, preserve state. "project_rebuild", + // Read sled identity pins vs. live signed identity, and TOFU re-pin. + "fleet_identity", ]; /// Gateway tool definitions. @@ -154,6 +156,24 @@ pub(crate) fn gateway_tool_definitions() -> Vec { "required": ["name"] } }), + json!({ + "name": "fleet_identity", + "description": "Read mode (default): for every registered sled, report project, url, connected, the recorded pin (expected_node_id), the live signature-verified node_id from a signed challenge-response (never the unsigned /identity display field), and whether they match. Repin mode: capture a sled's live verified identity via TOFU and persist it as the new pin, refusing when the signature is missing or does not verify.", + "inputSchema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["read", "repin"], + "description": "\"read\" (default) reports every sled's pin vs. live identity. \"repin\" re-pins one sled via TOFU; requires `project`." + }, + "project": { + "type": "string", + "description": "Required when action is \"repin\": the project/sled name to re-pin." + } + } + } + }), ] } @@ -420,6 +440,7 @@ async fn handle_gateway_tool( "agents.list" => handle_agents_list_tool(id), "prompt_permission" => handle_prompt_permission_tool(params, state, id).await, "project_rebuild" => handle_project_rebuild_tool(params, state, id).await, + "fleet_identity" => handle_fleet_identity_tool(params, state, id).await, _ => JsonRpcResponse::error(id, -32601, format!("Unknown gateway tool: {tool_name}")), } } @@ -922,6 +943,67 @@ async fn handle_project_rebuild_tool( ) } +/// Handle the `fleet_identity` gateway tool. +/// +/// Dispatches on the `action` argument: `"read"` (default) reports every +/// sled's pin vs. live signed identity; `"repin"` captures and persists a +/// single sled's live verified identity via TOFU. +async fn handle_fleet_identity_tool( + params: &Value, + state: &GatewayState, + id: Option, +) -> JsonRpcResponse { + let args = params.get("arguments").unwrap_or(params); + let action = args + .get("action") + .and_then(|v| v.as_str()) + .unwrap_or("read"); + + match action { + "read" => { + let reports = gateway::fleet_identity_read(state).await; + let text = gateway::format_identity_reports(&reports); + let reports_json = serde_json::to_value(&reports).unwrap_or(json!([])); + JsonRpcResponse::success( + id, + json!({ + "content": [{ "type": "text", "text": text }], + "reports": reports_json, + }), + ) + } + "repin" => { + let project = args.get("project").and_then(|v| v.as_str()).unwrap_or(""); + if project.is_empty() { + return JsonRpcResponse::error( + id, + -32602, + "missing required parameter for action=\"repin\": project".into(), + ); + } + match gateway::fleet_identity_repin(state, project).await { + Ok(node_id) => JsonRpcResponse::success( + id, + json!({ + "content": [{ + "type": "text", + "text": format!( + "Re-pinned `{project}` to verified node_id `{node_id}`." + ) + }] + }), + ), + Err(e) => JsonRpcResponse::error(id, -32602, e.to_string()), + } + } + other => JsonRpcResponse::error( + id, + -32602, + format!("unknown fleet_identity action \"{other}\"; expected \"read\" or \"repin\""), + ), + } +} + /// Handle the `pipeline.get` read-RPC — returns per-project item lists in the /// shape expected by the gateway web UI: /// `{ "active": "...", "projects": { "name": { "active": [...], "backlog_count": N } } }`. @@ -1111,4 +1193,59 @@ mod tests { "MCP path should report 'not a directory', got: {mcp_msg}" ); } + + // ── fleet_identity tool (story 1206) ───────────────────────────────────── + + #[tokio::test] + async fn fleet_identity_read_default_action_lists_projects() { + let dir = tempfile::tempdir().unwrap(); + let state = make_test_state(dir.path()); + let params = json!({ "arguments": {} }); + let resp = handle_fleet_identity_tool(¶ms, &state, Some(json!(1))).await; + assert!(resp.error.is_none(), "expected success: {:?}", resp.error); + let text = resp.result.unwrap()["content"][0]["text"] + .as_str() + .unwrap() + .to_string(); + assert!(text.contains("test-project")); + } + + #[tokio::test] + async fn fleet_identity_repin_missing_project_returns_error() { + let dir = tempfile::tempdir().unwrap(); + let state = make_test_state(dir.path()); + let params = json!({ "arguments": { "action": "repin" } }); + let resp = handle_fleet_identity_tool(¶ms, &state, Some(json!(1))).await; + assert!(resp.error.is_some(), "expected error for missing project"); + let msg = resp.error.unwrap().message; + assert!( + msg.contains("project"), + "expected 'project' in error, got: {msg}" + ); + } + + #[tokio::test] + async fn fleet_identity_repin_unknown_project_returns_error() { + let dir = tempfile::tempdir().unwrap(); + let state = make_test_state(dir.path()); + let params = json!({ "arguments": { "action": "repin", "project": "nonexistent" } }); + let resp = handle_fleet_identity_tool(¶ms, &state, Some(json!(1))).await; + assert!(resp.error.is_some(), "expected error for unknown project"); + } + + #[tokio::test] + async fn fleet_identity_unknown_action_returns_error() { + let dir = tempfile::tempdir().unwrap(); + let state = make_test_state(dir.path()); + let params = json!({ "arguments": { "action": "bogus" } }); + let resp = handle_fleet_identity_tool(¶ms, &state, Some(json!(1))).await; + assert!(resp.error.is_some(), "expected error for unknown action"); + let msg = resp.error.unwrap().message; + assert!(msg.contains("unknown fleet_identity action"), "got: {msg}"); + } + + #[test] + fn fleet_identity_is_in_gateway_tools() { + assert!(GATEWAY_TOOLS.contains(&"fleet_identity")); + } } diff --git a/server/src/service/gateway/aggregation.rs b/server/src/service/gateway/aggregation.rs index f609e769..34d30eca 100644 --- a/server/src/service/gateway/aggregation.rs +++ b/server/src/service/gateway/aggregation.rs @@ -4,6 +4,7 @@ //! transports (Matrix, Slack). Uses `service::pipeline::aggregate_pipeline_counts` //! for per-project parsing. +use super::identity::SledIdentityReport; use serde_json::Value; use std::collections::BTreeMap; @@ -125,6 +126,54 @@ pub fn format_overview_compact(items_by_project: &BTreeMap) -> St format!("**Overview: Active Work**\n\n{}", sections.join("\n\n")) } +/// Format `fleet_identity` read-mode reports as Markdown, one line per sled. +/// +/// Matches, first-contacts (no pin recorded yet), and unreachable sleds get a +/// plain status line. A verified mismatch is called out with the same +/// wording as the `upgrade` command's identity check +/// (`chat::transport::matrix::sled_upgrade::verify_sled_identity`) so an +/// operator sees one consistent message for "wrong container answered" +/// regardless of which command surfaced it — including naming the container +/// as `huskies-{project}`. +pub fn format_identity_reports(reports: &[SledIdentityReport]) -> String { + if reports.is_empty() { + return "No projects registered.".to_string(); + } + + let lines: Vec = reports + .iter() + .map(|r| { + let container_name = format!("huskies-{}", r.project); + let url = r.url.as_deref().unwrap_or("(no url configured)"); + + if !r.connected { + return format!("\u{1F534} **{}** — unreachable at `{url}`", r.project); + } + + match (&r.expected_pin, &r.live_node_id, r.matched) { + (_, _, true) => format!("\u{1F7E2} **{}** — matches pin `{url}`", r.project), + (Some(expected), Some(live), false) => format!( + "\u{1F7E0} **identity mismatch** for `{container_name}` at `{url}`: expected \ + node_id `{expected}`, but the container that answered identified as \ + `{live}`. Refusing to proceed — this may not be the sled you expect." + ), + (None, Some(live), false) => format!( + "\u{1F7E1} **{}** — no pin recorded yet at `{url}`; live node_id `{live}` \ + (re-pin to trust it)", + r.project + ), + (_, None, false) => format!( + "\u{1F534} **identity verification failed** for `{container_name}` at `{url}`: \ + the `/identity` response's signature is missing or did not verify. \ + Refusing to proceed — the container's identity cannot be trusted." + ), + } + }) + .collect(); + + format!("**Fleet Identity**\n\n{}", lines.join("\n")) +} + /// Find the registered project whose pipeline contains a story with the /// given numeric ID prefix, searching `active`, `backlog`, and `archived` /// alike so gateway `status ` resolves regardless of which project is @@ -313,6 +362,95 @@ mod tests { assert!(output.find("alpha").unwrap() < output.find("beta").unwrap()); } + // ── format_identity_reports (story 1206 AC3) ─────────────────────────── + + #[test] + fn identity_report_mismatch_names_container_consistent_with_upgrade_sweep() { + let reports = vec![SledIdentityReport { + project: "myapp".to_string(), + url: Some("http://sled:3001".to_string()), + connected: true, + expected_pin: Some("expected-id".to_string()), + live_node_id: Some("different-id".to_string()), + matched: false, + }]; + let output = format_identity_reports(&reports); + assert!(output.contains("**identity mismatch**")); + assert!( + output.contains("`huskies-myapp`"), + "must name the container as huskies-: {output}" + ); + assert!(output.contains("expected node_id `expected-id`")); + assert!(output.contains("identified as `different-id`")); + assert!(output.contains("Refusing to proceed")); + } + + #[test] + fn identity_report_match_is_a_plain_status_line() { + let reports = vec![SledIdentityReport { + project: "myapp".to_string(), + url: Some("http://sled:3001".to_string()), + connected: true, + expected_pin: Some("abc".to_string()), + live_node_id: Some("abc".to_string()), + matched: true, + }]; + let output = format_identity_reports(&reports); + assert!(output.contains("myapp")); + assert!(output.contains("matches pin")); + assert!(!output.contains("mismatch")); + } + + #[test] + fn identity_report_unreachable_sled_is_marked() { + let reports = vec![SledIdentityReport { + project: "myapp".to_string(), + url: Some("http://sled:3001".to_string()), + connected: false, + expected_pin: Some("abc".to_string()), + live_node_id: None, + matched: false, + }]; + let output = format_identity_reports(&reports); + assert!(output.contains("unreachable")); + } + + #[test] + fn identity_report_no_pin_recorded_invites_a_repin() { + let reports = vec![SledIdentityReport { + project: "myapp".to_string(), + url: Some("http://sled:3001".to_string()), + connected: true, + expected_pin: None, + live_node_id: Some("some-id".to_string()), + matched: false, + }]; + let output = format_identity_reports(&reports); + assert!(output.contains("no pin recorded")); + assert!(output.contains("some-id")); + } + + #[test] + fn identity_report_invalid_signature_is_flagged() { + let reports = vec![SledIdentityReport { + project: "myapp".to_string(), + url: Some("http://sled:3001".to_string()), + connected: true, + expected_pin: Some("abc".to_string()), + live_node_id: None, + matched: false, + }]; + let output = format_identity_reports(&reports); + assert!(output.contains("**identity verification failed**")); + assert!(output.contains("`huskies-myapp`")); + } + + #[test] + fn identity_report_empty_list() { + let output = format_identity_reports(&[]); + assert_eq!(output, "No projects registered."); + } + // ── find_project_containing_story ────────────────────────────────────── #[test] diff --git a/server/src/service/gateway/identity.rs b/server/src/service/gateway/identity.rs index 2824c2b5..f4e729ee 100644 --- a/server/src/service/gateway/identity.rs +++ b/server/src/service/gateway/identity.rs @@ -83,6 +83,63 @@ pub fn check_identity( } } +// ── Fleet identity read report (story 1206) ──────────────────────────────── + +/// Per-sled identity report returned by the `fleet_identity` MCP tool's read +/// mode: the recorded pin next to the live, cryptographically-verified +/// identity. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct SledIdentityReport { + /// The project/sled name. + pub project: String, + /// The sled's configured base URL, if any. + pub url: Option, + /// Whether the `/identity` probe reached the sled at all. + pub connected: bool, + /// The pinned `expected_node_id` recorded in `projects.toml`, if any. + pub expected_pin: Option, + /// The node ID from a signature-verified challenge-response, if the + /// signature verified. Never populated from the unsigned `node_id` + /// display field alone — an invalid or missing signature leaves this + /// `None`. + pub live_node_id: Option, + /// `true` only when the sled is connected, its signature verified, and + /// the verified node ID equals `expected_pin`. + pub matched: bool, +} + +/// Build a [`SledIdentityReport`] from the outcome of probing one sled. +/// +/// `check` is `None` when the sled was unreachable (the probe never got a +/// response). Otherwise it's the result of running [`check_identity`] against +/// whatever response was received. +pub fn build_identity_report( + project: &str, + url: Option<&str>, + expected_node_id: Option<&str>, + check: Option, +) -> SledIdentityReport { + let connected = check.is_some(); + let (live_node_id, matched) = match &check { + Some(IdentityCheck::Match) => (expected_node_id.map(str::to_string), true), + Some(IdentityCheck::Mismatch { responder_node_id }) => { + (Some(responder_node_id.clone()), false) + } + Some(IdentityCheck::FirstContact { node_id }) => (Some(node_id.clone()), false), + Some(IdentityCheck::InvalidSignature) | Some(IdentityCheck::MissingSignature) | None => { + (None, false) + } + }; + SledIdentityReport { + project: project.to_string(), + url: url.map(str::to_string), + connected, + expected_pin: expected_node_id.map(str::to_string), + live_node_id, + matched, + } +} + // ── Tests ──────────────────────────────────────────────────────────────────── #[cfg(test)] @@ -172,4 +229,79 @@ mod tests { let resp: IdentityProbeResponse = serde_json::from_str(json).unwrap(); assert!(resp.signature.is_none()); } + + // ── build_identity_report (story 1206) ───────────────────────────────── + + #[test] + fn report_matching_pin_is_connected_and_matched() { + let report = build_identity_report( + "myapp", + Some("http://sled:3001"), + Some("abc123"), + Some(IdentityCheck::Match), + ); + assert!(report.connected); + assert!(report.matched); + assert_eq!(report.expected_pin.as_deref(), Some("abc123")); + assert_eq!(report.live_node_id.as_deref(), Some("abc123")); + } + + #[test] + fn report_mismatch_names_the_responder_node_id_and_is_not_matched() { + let report = build_identity_report( + "myapp", + Some("http://sled:3001"), + Some("expected-id"), + Some(IdentityCheck::Mismatch { + responder_node_id: "different-id".to_string(), + }), + ); + assert!(report.connected); + assert!(!report.matched); + assert_eq!(report.expected_pin.as_deref(), Some("expected-id")); + assert_eq!(report.live_node_id.as_deref(), Some("different-id")); + } + + #[test] + fn report_no_pin_recorded_is_never_matched_even_on_first_contact() { + let report = build_identity_report( + "myapp", + Some("http://sled:3001"), + None, + Some(IdentityCheck::FirstContact { + node_id: "some-id".to_string(), + }), + ); + assert!(report.connected); + assert!(report.expected_pin.is_none()); + assert_eq!(report.live_node_id.as_deref(), Some("some-id")); + assert!( + !report.matched, + "no pin recorded yet means there is nothing to match against" + ); + } + + #[test] + fn report_unreachable_sled_is_not_connected() { + let report = build_identity_report("myapp", Some("http://sled:3001"), Some("abc123"), None); + assert!(!report.connected); + assert!(!report.matched); + assert!(report.live_node_id.is_none()); + } + + #[test] + fn report_invalid_signature_is_connected_but_no_live_node_id() { + let report = build_identity_report( + "myapp", + Some("http://sled:3001"), + Some("abc123"), + Some(IdentityCheck::InvalidSignature), + ); + assert!(report.connected, "the probe did reach the sled"); + assert!(!report.matched); + assert!( + report.live_node_id.is_none(), + "an unverified signature must never populate live_node_id" + ); + } } diff --git a/server/src/service/gateway/io.rs b/server/src/service/gateway/io.rs index 0a89608f..1b6e9310 100644 --- a/server/src/service/gateway/io.rs +++ b/server/src/service/gateway/io.rs @@ -128,6 +128,98 @@ pub async fn probe_identity( .ok() } +// ── Fleet identity I/O (story 1206) ───────────────────────────────────────── + +/// Probe one sled's `/identity` endpoint and build its +/// [`super::identity::SledIdentityReport`]. +/// +/// Generates a fresh challenge nonce, probes `entry.url`, and compares the +/// (signature-verified) response against `entry.expected_node_id`. When +/// `entry.url` is unset the sled is reported unreachable without a network +/// call. +pub async fn probe_sled_identity_report( + project: &str, + entry: &ProjectEntry, + client: &Client, +) -> super::identity::SledIdentityReport { + let Some(url) = entry.url.as_deref() else { + return super::identity::build_identity_report( + project, + None, + entry.expected_node_id.as_deref(), + None, + ); + }; + + let nonce = crate::node_identity::generate_challenge(); + let check = probe_identity(client, url, &nonce).await.map(|response| { + super::check_identity(entry.expected_node_id.as_deref(), &nonce, &response) + }); + + super::identity::build_identity_report( + project, + Some(url), + entry.expected_node_id.as_deref(), + check, + ) +} + +/// Re-pin a sled's expected identity via TOFU (trust-on-first-use). +/// +/// Probes `sled_url`, and — only when the response's signature verifies — +/// captures the verified node ID as the new pin, overwriting whatever was +/// previously recorded (including a mismatched one; that's the point of an +/// explicit re-pin). Persists the change to both the live `projects_store` +/// and `projects.toml` at `config_dir`. +/// +/// Refuses (returns `Err`) when the sled is unreachable, its response has no +/// signature (a legacy sled), or the signature fails to verify — in none of +/// those cases is a responder's claimed identity trustworthy enough to pin. +pub async fn repin_sled_identity( + project: &str, + sled_url: &str, + projects_store: &std::sync::Arc>>, + config_dir: &Path, + client: &Client, +) -> Result { + let nonce = crate::node_identity::generate_challenge(); + let Some(response) = probe_identity(client, sled_url, &nonce).await else { + return Err(format!( + "cannot re-pin `{project}` at `{sled_url}`: the sled is unreachable" + )); + }; + + // Compare against `None` regardless of any existing pin — an explicit + // re-pin always (re-)captures on a verified signature rather than + // reporting a `Mismatch` against the old pin. + match super::check_identity(None, &nonce, &response) { + super::IdentityCheck::FirstContact { node_id } => { + { + let mut projects = projects_store.write().await; + if let Some(entry) = projects.get_mut(project) { + entry.expected_node_id = Some(node_id.clone()); + } + } + let snapshot = projects_store.read().await.clone(); + save_config(&snapshot, config_dir).await; + Ok(node_id) + } + super::IdentityCheck::InvalidSignature => Err(format!( + "refusing to pin `{project}` at `{sled_url}`: the `/identity` response signature \ + did not verify — the responder's identity cannot be trusted" + )), + super::IdentityCheck::MissingSignature => Err(format!( + "refusing to pin `{project}` at `{sled_url}`: the response had no signature \ + (a legacy sled predating signed identity) — identity cannot be verified" + )), + super::IdentityCheck::Match | super::IdentityCheck::Mismatch { .. } => { + unreachable!( + "check_identity(None, ..) only returns FirstContact/InvalidSignature/MissingSignature" + ) + } + } +} + // ── Release channel I/O (story 1169) ──────────────────────────────────────── /// Fetch `{base_url}/manifest.json` and parse it into a @@ -928,4 +1020,277 @@ mod tests { }; assert_eq!(dedupe_key(&transition), None); } + + // ── fleet identity (story 1206) ────────────────────────────────────────── + + /// Spawn a one-shot TCP listener that answers a single `GET + /// /identity?nonce=` request with a JSON body signed by `kp` over + /// whatever nonce the caller actually sent. Mirrors the responder used in + /// `chat::transport::matrix::sled_upgrade`'s tests. + fn spawn_identity_responder( + listener: tokio::net::TcpListener, + kp: bft_json_crdt::keypair::Ed25519KeyPair, + ) { + tokio::spawn(async move { + if let Ok((mut stream, _)) = listener.accept().await { + 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]); + let nonce = req + .lines() + .next() + .unwrap_or("") + .split("nonce=") + .nth(1) + .and_then(|s| s.split_whitespace().next()) + .unwrap_or("") + .to_string(); + let pubkey = crate::node_identity::public_key_hex(&kp); + let sig = crate::node_identity::sign_challenge(&kp, &nonce); + let body = serde_json::json!({ + "node_id": pubkey, + "pubkey": pubkey, + "signature": sig, + }) + .to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(response.as_bytes()).await; + } + }); + } + + /// Spawn a one-shot TCP listener that answers `GET /identity` with an + /// unsigned body (no `signature` field) — a legacy sled. + fn spawn_unsigned_identity_responder(listener: tokio::net::TcpListener) { + tokio::spawn(async move { + if let Ok((mut stream, _)) = listener.accept().await { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let mut buf = [0u8; 4096]; + let _ = stream.read(&mut buf).await; + let body = serde_json::json!({ + "node_id": "legacy-node-id", + "pubkey": "legacy-node-id", + }) + .to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(response.as_bytes()).await; + } + }); + } + + #[tokio::test] + async fn probe_sled_identity_report_matches_recorded_pin() { + let kp = bft_json_crdt::keypair::make_keypair(); + let pubkey = crate::node_identity::public_key_hex(&kp); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + spawn_identity_responder(listener, kp); + + let entry = ProjectEntry { + url: Some(format!("http://127.0.0.1:{port}")), + auth_token: None, + ssh_port: None, + host_path: None, + expected_node_id: Some(pubkey.clone()), + }; + let client = reqwest::Client::new(); + let report = probe_sled_identity_report("myapp", &entry, &client).await; + + assert!(report.connected); + assert!(report.matched); + assert_eq!(report.live_node_id.as_deref(), Some(pubkey.as_str())); + } + + #[tokio::test] + async fn probe_sled_identity_report_no_url_is_unreachable() { + let entry = ProjectEntry { + url: None, + auth_token: None, + ssh_port: None, + host_path: None, + expected_node_id: Some("abc123".to_string()), + }; + let client = reqwest::Client::new(); + let report = probe_sled_identity_report("myapp", &entry, &client).await; + assert!(!report.connected); + assert!(!report.matched); + } + + #[tokio::test] + async fn repin_sled_identity_captures_verified_node_id_and_persists() { + let kp = bft_json_crdt::keypair::make_keypair(); + let pubkey = crate::node_identity::public_key_hex(&kp); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + spawn_identity_responder(listener, kp); + + let sled_url = format!("http://127.0.0.1:{port}"); + let mut map = BTreeMap::new(); + map.insert( + "myapp".to_string(), + ProjectEntry { + url: Some(sled_url.clone()), + auth_token: None, + ssh_port: None, + host_path: None, + // A stale/mismatched pin — re-pin must overwrite it. + expected_node_id: Some("stale-node-id".to_string()), + }, + ); + let store = std::sync::Arc::new(tokio::sync::RwLock::new(map)); + let config_dir = tempfile::tempdir().unwrap(); + let client = reqwest::Client::new(); + + let result = + repin_sled_identity("myapp", &sled_url, &store, config_dir.path(), &client).await; + assert_eq!(result, Ok(pubkey.clone())); + + let captured = store + .read() + .await + .get("myapp") + .and_then(|e| e.expected_node_id.clone()); + assert_eq!(captured, Some(pubkey.clone())); + + let toml_content = tokio::fs::read_to_string(config_dir.path().join("projects.toml")).await; + assert!( + toml_content.unwrap_or_default().contains(&pubkey), + "re-pinned node_id should be persisted to projects.toml" + ); + } + + #[tokio::test] + async fn repin_sled_identity_refuses_invalid_signature() { + // A responder that signs with a different key than it claims — an + // untrustworthy identity that must never be pinned. + let kp = bft_json_crdt::keypair::make_keypair(); + let other_kp = bft_json_crdt::keypair::make_keypair(); + let claimed_pubkey = crate::node_identity::public_key_hex(&other_kp); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + if let Ok((mut stream, _)) = listener.accept().await { + 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]); + let nonce = req + .lines() + .next() + .unwrap_or("") + .split("nonce=") + .nth(1) + .and_then(|s| s.split_whitespace().next()) + .unwrap_or("") + .to_string(); + // Sign with `kp` but claim `other_kp`'s pubkey — verification must fail. + let sig = crate::node_identity::sign_challenge(&kp, &nonce); + let body = serde_json::json!({ + "node_id": claimed_pubkey, + "pubkey": claimed_pubkey, + "signature": sig, + }) + .to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(response.as_bytes()).await; + } + }); + + let sled_url = format!("http://127.0.0.1:{port}"); + let mut map = BTreeMap::new(); + map.insert( + "myapp".to_string(), + ProjectEntry { + url: Some(sled_url.clone()), + auth_token: None, + ssh_port: None, + host_path: None, + expected_node_id: None, + }, + ); + let store = std::sync::Arc::new(tokio::sync::RwLock::new(map)); + let config_dir = tempfile::tempdir().unwrap(); + let client = reqwest::Client::new(); + + let result = + repin_sled_identity("myapp", &sled_url, &store, config_dir.path(), &client).await; + let err = result.expect_err("invalid signature must refuse to pin"); + assert!(err.contains("refusing to pin"), "error: {err}"); + assert!( + err.contains("myapp"), + "error should name the project: {err}" + ); + + let captured = store + .read() + .await + .get("myapp") + .and_then(|e| e.expected_node_id.clone()); + assert!(captured.is_none(), "no pin should be captured on refusal"); + } + + #[tokio::test] + async fn repin_sled_identity_refuses_missing_signature() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + spawn_unsigned_identity_responder(listener); + + let sled_url = format!("http://127.0.0.1:{port}"); + let mut map = BTreeMap::new(); + map.insert( + "myapp".to_string(), + ProjectEntry { + url: Some(sled_url.clone()), + auth_token: None, + ssh_port: None, + host_path: None, + expected_node_id: None, + }, + ); + let store = std::sync::Arc::new(tokio::sync::RwLock::new(map)); + let config_dir = tempfile::tempdir().unwrap(); + let client = reqwest::Client::new(); + + let result = + repin_sled_identity("myapp", &sled_url, &store, config_dir.path(), &client).await; + let err = result.expect_err("missing signature must refuse to pin"); + assert!(err.contains("refusing to pin"), "error: {err}"); + } + + #[tokio::test] + async fn repin_sled_identity_refuses_unreachable_sled() { + let store = std::sync::Arc::new(tokio::sync::RwLock::new(BTreeMap::new())); + let config_dir = tempfile::tempdir().unwrap(); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_millis(200)) + .build() + .unwrap(); + + let result = repin_sled_identity( + "myapp", + "http://127.0.0.1:1", + &store, + config_dir.path(), + &client, + ) + .await; + let err = result.expect_err("unreachable sled must refuse to pin"); + assert!(err.contains("unreachable"), "error: {err}"); + } } diff --git a/server/src/service/gateway/mod.rs b/server/src/service/gateway/mod.rs index d855d3c1..8942b481 100644 --- a/server/src/service/gateway/mod.rs +++ b/server/src/service/gateway/mod.rs @@ -20,10 +20,11 @@ pub mod polling; pub mod release_manifest; pub use aggregation::{ - find_project_containing_story, format_aggregate_status_compact, format_overview_compact, + find_project_containing_story, format_aggregate_status_compact, format_identity_reports, + format_overview_compact, }; pub use config::{GatewayConfig, ProjectEntry}; -pub use identity::{IdentityCheck, check_identity}; +pub use identity::{IdentityCheck, SledIdentityReport, check_identity}; pub use io::{ fetch_all_project_pipeline_items, fetch_all_project_pipeline_statuses, probe_identity, spawn_gateway_broadcaster_forwarder, @@ -697,6 +698,59 @@ pub fn subscribe_status_events( state.event_tx.subscribe() } +// ── Fleet identity (story 1206) ───────────────────────────────────────────── + +/// Read mode for the `fleet_identity` MCP tool: probe every registered +/// project's `/identity` endpoint and report the recorded pin next to the +/// live, signature-verified identity. +/// +/// Projects are probed concurrently (bounded by however many are +/// registered — gateways register at most a handful of sleds). +pub async fn fleet_identity_read(state: &GatewayState) -> Vec { + let projects = state.projects.read().await.clone(); + let probes = projects + .iter() + .map(|(name, entry)| io::probe_sled_identity_report(name, entry, &state.client)); + futures::future::join_all(probes).await +} + +/// Re-pin action for the `fleet_identity` MCP tool: capture `project`'s +/// live, cryptographically-verified identity via TOFU and persist it as the +/// new expected pin. +/// +/// **Does this need a gateway restart?** No (verified against +/// [`GatewayState`]'s design, AC 4). `GatewayState::projects` is the single +/// `Arc>>` that every gateway code path +/// reads fresh on each call — [`GatewayState::active_url`], the MCP proxy, +/// `/health` polling, and the `upgrade` command's own identity check +/// (`verify_sled_identity`) all call `.read().await` on it rather than +/// caching a snapshot at startup. [`io::repin_sled_identity`] writes through +/// this same `Arc` and persists to `projects.toml`, so the next call from any +/// of those paths — with no restart — sees the new pin. This mirrors how +/// `verify_sled_identity`'s first-contact capture already updates the pin +/// live during an `upgrade` without requiring a restart. +pub async fn fleet_identity_repin(state: &GatewayState, project: &str) -> Result { + let url = { + let projects = state.projects.read().await; + config::validate_project_exists(&projects, project).map_err(Error::ProjectNotFound)? + }; + if url.is_empty() { + return Err(Error::Config(format!( + "project '{project}' has no URL configured; cannot probe it for re-pinning" + ))); + } + + io::repin_sled_identity( + project, + &url, + &state.projects, + &state.config_dir, + &state.client, + ) + .await + .map_err(Error::Upstream) +} + /// Save bot config and restart the bot. pub async fn save_bot_config_and_restart(state: &GatewayState, content: &str) -> Result<(), Error> { io::write_bot_config(&state.config_dir, content).map_err(Error::Config)?; @@ -958,4 +1012,132 @@ mod tests { "Per-project auth_token must be in reversed sled_tokens map" ); } + + // ── fleet_identity (story 1206) ───────────────────────────────────────── + + #[tokio::test] + async fn fleet_identity_read_reports_unreachable_project_with_no_url() { + let mut projects = BTreeMap::new(); + projects.insert( + "myapp".to_string(), + ProjectEntry { + url: None, + auth_token: None, + ssh_port: None, + host_path: None, + expected_node_id: Some("abc".to_string()), + }, + ); + let config = GatewayConfig { + projects, + sled_tokens: BTreeMap::new(), + release_channels: BTreeMap::new(), + }; + let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap(); + let reports = fleet_identity_read(&state).await; + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].project, "myapp"); + assert!(!reports[0].connected); + } + + #[tokio::test] + async fn fleet_identity_repin_unknown_project_fails() { + let config = make_config(&[("alpha", "http://a:3001")]); + let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap(); + let result = fleet_identity_repin(&state, "nonexistent").await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn fleet_identity_repin_project_without_url_fails() { + let mut projects = BTreeMap::new(); + projects.insert( + "ws-only".to_string(), + ProjectEntry { + url: None, + auth_token: Some("tok".into()), + ssh_port: None, + host_path: None, + expected_node_id: None, + }, + ); + let config = GatewayConfig { + projects, + sled_tokens: BTreeMap::new(), + release_channels: BTreeMap::new(), + }; + let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap(); + let result = fleet_identity_repin(&state, "ws-only").await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn fleet_identity_repin_updates_live_state_without_restart() { + // Regression test for AC 4: re-pinning must be visible to a *second*, + // independent read through the same `GatewayState` without any + // restart step in between — proving the pin lives in the shared + // in-memory `Arc>`, not some snapshot taken at startup. + let kp = bft_json_crdt::keypair::make_keypair(); + let pubkey = crate::node_identity::public_key_hex(&kp); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + if let Ok((mut stream, _)) = listener.accept().await { + 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]); + let nonce = req + .lines() + .next() + .unwrap_or("") + .split("nonce=") + .nth(1) + .and_then(|s| s.split_whitespace().next()) + .unwrap_or("") + .to_string(); + let pubkey = crate::node_identity::public_key_hex(&kp); + let sig = crate::node_identity::sign_challenge(&kp, &nonce); + let body = serde_json::json!({ + "node_id": pubkey, + "pubkey": pubkey, + "signature": sig, + }) + .to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(response.as_bytes()).await; + } + }); + + let sled_url = format!("http://127.0.0.1:{port}"); + let mut projects = BTreeMap::new(); + projects.insert("myapp".to_string(), ProjectEntry::with_url(&sled_url)); + let config = GatewayConfig { + projects, + sled_tokens: BTreeMap::new(), + release_channels: BTreeMap::new(), + }; + let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap(); + + let repinned = fleet_identity_repin(&state, "myapp").await.unwrap(); + assert_eq!(repinned, pubkey); + + // No restart, no new GatewayState — read straight off the same `state`. + let pin = state + .projects + .read() + .await + .get("myapp") + .and_then(|e| e.expected_node_id.clone()); + assert_eq!( + pin, + Some(pubkey), + "re-pinned identity must be visible immediately on the same live state" + ); + } }