diff --git a/API_CHANGELOG.md b/API_CHANGELOG.md index 9fa01716..bcc84c66 100644 --- a/API_CHANGELOG.md +++ b/API_CHANGELOG.md @@ -24,6 +24,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - **Contact form hardens email header values** — `POST /api/v1/contact` strips control characters from the sender name and subject (and caps the subject) before placing them in outgoing mail headers. +### Added + +- **`host_ssh_keys` on VM status** (issue #154) — `GET /api/v1/vm/{id}` and `GET /api/v1/vm` now return the VM's own SSH host keys as `[{ key_type, public_key, fingerprint_sha256 }]`, so a client can verify the host on first connect instead of accepting whatever key answers. The list is empty until the keys are captured (scanned from the host once the VM is running, public keys only) and is re-captured after a reinstall, which regenerates them. Additive: no existing field changed. Not to be confused with `ssh_key`, which is the customer's authorized key. + ### Fixed - **`GET /api/v1/payment/{id}` now works for every subscription type** — it previously resolved the payer through the VM back-reference, so a payment for a managed app, IP range or other non-VPS subscription errored instead of returning. Ownership is now asserted on the subscription and the embedded `vm_id` is `0` for non-VPS payments (the endpoint remains deprecated in favour of `GET /api/v1/subscriptions/{id}/payments/{payment_id}`). diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md index 4b5cb404..d2436120 100644 --- a/API_DOCUMENTATION.md +++ b/API_DOCUMENTATION.md @@ -256,6 +256,13 @@ interface VmStatus { host_sunset_date?: string; // ISO 8601 datetime — set when the VM's host is being decommissioned; migrate before this date. Renewals are blocked once expires reaches it. Omitted when the host is not being sunset max_prepay_days: number; // Max days this VM may be prepaid/renewed in advance. A renewal is rejected once it would push `expires` beyond now + max_prepay_days; cap the renewal interval selector accordingly cpu_arch?: string; // CPU architecture of the host this VM runs on ("x86_64" | "arm64"), from the host record. Unlike template.cpu_arch (an optional constraint) this is present whenever the host arch is known; use it to always pass ?arch= when listing OS images for a reinstall. Omitted when unknown + host_ssh_keys: VmHostKey[]; // The VM's own SSH host keys, for verifying the host on first connect. Empty until captured after first boot; re-captured after a reinstall. Not the customer's authorized key (that is ssh_key) +} + +interface VmHostKey { + key_type: string; // "ssh-ed25519" | "ssh-rsa" | "ecdsa-sha2-nistp256" | "ecdsa-sha2-nistp384" | "ecdsa-sha2-nistp521" + public_key: string; // base64 key blob, as in the third field of a known_hosts line + fingerprint_sha256: string; // "SHA256:…", matching `ssh-keygen -lf` and the banner ssh prints for an unknown host } interface VmRunningState { diff --git a/lnvps_api/src/api/routes.rs b/lnvps_api/src/api/routes.rs index 137f0591..e74110a6 100644 --- a/lnvps_api/src/api/routes.rs +++ b/lnvps_api/src/api/routes.rs @@ -2991,6 +2991,7 @@ mod tests { ssh_key_id: Some(1), disk_id: 1, mac_address: "ff:ff:ff:ff:ff:ff".to_string(), + ssh_host_keys: None, deleted: false, ref_code: None, disabled: false, diff --git a/lnvps_api/src/host/mod.rs b/lnvps_api/src/host/mod.rs index 2525edb5..e845123d 100644 --- a/lnvps_api/src/host/mod.rs +++ b/lnvps_api/src/host/mod.rs @@ -384,6 +384,7 @@ mod tests { ssh_key_id: Some(1), disk_id: 1, mac_address: "ff:ff:ff:ff:ff:fe".to_string(), + ssh_host_keys: None, deleted: false, ref_code: None, disabled: false, diff --git a/lnvps_api/src/provisioner/integration_retry_tests.rs b/lnvps_api/src/provisioner/integration_retry_tests.rs index c291d45f..6b49a5df 100644 --- a/lnvps_api/src/provisioner/integration_retry_tests.rs +++ b/lnvps_api/src/provisioner/integration_retry_tests.rs @@ -198,6 +198,7 @@ mod tests { ssh_key_id: Some(ssh_key.id), disk_id: 1, mac_address: "bc:24:11:00:00:01".to_string(), + ssh_host_keys: None, deleted: false, ref_code: None, disabled: false, @@ -245,6 +246,7 @@ mod tests { ssh_key_id: Some(ssh_key.id), disk_id: 1, mac_address: "bc:24:11:00:00:01".to_string(), + ssh_host_keys: None, deleted: false, ref_code: None, disabled: false, @@ -287,6 +289,7 @@ mod tests { ssh_key_id: Some(ssh_key.id), disk_id: 1, mac_address: "bc:24:11:00:00:01".to_string(), + ssh_host_keys: None, deleted: false, ref_code: None, disabled: false, diff --git a/lnvps_api/src/provisioner/retry_tests.rs b/lnvps_api/src/provisioner/retry_tests.rs index a6a0c88e..4d39fd83 100644 --- a/lnvps_api/src/provisioner/retry_tests.rs +++ b/lnvps_api/src/provisioner/retry_tests.rs @@ -217,6 +217,7 @@ mod tests { ssh_key_id: Some(ssh_key.id), disk_id: 1, mac_address: "bc:24:11:00:00:01".to_string(), + ssh_host_keys: None, deleted: false, ref_code: None, disabled: false, diff --git a/lnvps_api/src/provisioner/rollback_tests.rs b/lnvps_api/src/provisioner/rollback_tests.rs index 1e7211f5..f437b91e 100644 --- a/lnvps_api/src/provisioner/rollback_tests.rs +++ b/lnvps_api/src/provisioner/rollback_tests.rs @@ -684,6 +684,7 @@ mod tests { subscription_line_item_id: 0, disk_id: 1, mac_address: "02:00:00:00:00:01".to_string(), // A valid MAC + ssh_host_keys: None, ref_code: None, deleted: false, disabled: false, diff --git a/lnvps_api/src/provisioner/vm.rs b/lnvps_api/src/provisioner/vm.rs index 0fbf7df1..5446b579 100644 --- a/lnvps_api/src/provisioner/vm.rs +++ b/lnvps_api/src/provisioner/vm.rs @@ -191,6 +191,7 @@ impl VmProvisioner { ssh_key_id: Some(ssh_key.id), disk_id: pick_disk.disk.id, mac_address: "ff:ff:ff:ff:ff:ff".to_string(), + ssh_host_keys: None, deleted: false, ref_code, disabled: false, @@ -327,6 +328,7 @@ impl VmProvisioner { ssh_key_id: Some(ssh_key.id), disk_id: pick_disk.disk.id, mac_address: "ff:ff:ff:ff:ff:ff".to_string(), + ssh_host_keys: None, deleted: false, ref_code, disabled: false, @@ -504,6 +506,7 @@ impl VmProvisioner { .mac_address .clone() .unwrap_or_else(|| "ff:ff:ff:ff:ff:ff".to_string()), + ssh_host_keys: None, deleted: false, ref_code: None, disabled: false, diff --git a/lnvps_api/src/worker.rs b/lnvps_api/src/worker.rs index d5b023a0..ace10ce9 100644 --- a/lnvps_api/src/worker.rs +++ b/lnvps_api/src/worker.rs @@ -10,8 +10,9 @@ use hickory_resolver::TokioResolver; use lnvps_api_common::{ BlackholeWorkFeedback, ChannelWorkCommander, InMemoryKeyValueStore, JobFeedback, KeyValueStore, NetworkProvisioner, RedisConfig, RedisKeyValueStore, RedisWorkCommander, RedisWorkFeedback, - UpgradeConfig, VmHistoryLogger, VmRunningState, VmStateCache, WorkCommander, WorkFeedback, - WorkJob, WorkJobMessage, op_fatal, + SCANNED_KEY_FAMILIES, UpgradeConfig, VmHistoryLogger, VmRunningState, VmRunningStates, + VmStateCache, WorkCommander, WorkFeedback, WorkJob, WorkJobMessage, capture_is_complete, + merge_ssh_host_keys, op_fatal, parse_ssh_host_keys, retry::{OpError, Pipeline, RetryPolicy}, }; use lnvps_db::{ @@ -69,6 +70,15 @@ fn payment_blocks_unpaid_vm_deletion(p: &SubscriptionPayment, now: DateTime || (p.payment_method == PaymentMethod::OnChain && p.external_id.is_some())) } +/// How long to wait before scanning a VM's host keys again while the capture is +/// still missing keys. +const HOST_KEY_SCAN_RETRY_SECS: u64 = 3600; + +/// Key holding when a VM's host keys were last scanned. +fn host_key_attempt_key(vm_id: u64) -> String { + format!("worker-host-keys-attempt-{vm_id}") +} + /// Extract hostname/IP from a URL or return the input if it's already a plain host /// e.g. "https://192.168.1.1:8006/" -> "192.168.1.1" /// "192.168.1.1" -> "192.168.1.1" @@ -894,9 +904,146 @@ impl Worker { ) .await?; self.reconcile_vm_dns(vm).await; + self.capture_vm_ssh_host_keys(vm).await; Ok(()) } + /// Best-effort capture of a VM's SSH host keys, so a customer can verify + /// the host on first connect instead of trusting whatever key answers. + /// + /// Scanned from the Proxmox node rather than from here: the VM's address is + /// often not routable from the API, and the node is already trusted with + /// the VM. Only public keys are read — nothing runs inside the guest. + /// + /// Runs on the periodic VM check rather than at spawn: the keys do not + /// exist until cloud-init has generated them and sshd is up, and a VM whose + /// keys were never captured (or were cleared by a reinstall) self-heals on + /// the next pass. A VM whose capture already covers every algorithm is + /// skipped, so this costs nothing for a healthy VM. + async fn capture_vm_ssh_host_keys(&self, vm: &Vm) { + if vm.deleted { + return; + } + let captured = vm + .ssh_host_keys + .as_deref() + .map(parse_ssh_host_keys) + .unwrap_or_default(); + if capture_is_complete(&captured) { + return; + } + if !matches!( + self.vm_state_cache.get_state(vm.id).await.map(|s| s.state), + Some(VmRunningStates::Running) + ) { + return; + } + let Some(ip) = self.vm_scan_address(vm).await else { + return; + }; + // A guest that blocks port 22, never runs sshd, or offers fewer + // algorithms than a scan asks for would otherwise be scanned on every + // check for the life of the VM. One attempt per hour still captures + // the keys within an hour of the guest becoming reachable. + let attempt_key = host_key_attempt_key(vm.id); + if let Ok(Some(v)) = self.kv.get(&attempt_key).await + && v.len() == 8 + { + let last = u64::from_le_bytes(v.as_slice().try_into().unwrap_or_default()); + // A clock stepped backwards leaves a stamp in the future; wait it + // out rather than panic. + let waited = (Utc::now().timestamp() as u64).saturating_sub(last); + if waited < HOST_KEY_SCAN_RETRY_SECS { + return; + } + } + let now = Utc::now().timestamp() as u64; + if let Err(e) = self.kv.store(&attempt_key, &now.to_le_bytes()).await { + warn!("[host-keys] vm {}: failed to record attempt: {}", vm.id, e); + } + let host = match self.db.get_host(vm.host_id).await { + Ok(h) => h, + Err(e) => { + warn!("[host-keys] vm {}: host lookup failed: {}", vm.id, e); + return; + } + }; + let Some(ssh_key) = host.ssh_key.as_ref() else { + return; + }; + let ssh_user = host.ssh_user.as_deref().unwrap_or("root"); + + let mut ssh = match SshClient::new() { + Ok(c) => c, + Err(e) => { + warn!("[host-keys] vm {}: ssh client failed: {}", vm.id, e); + return; + } + }; + let ssh_host = extract_host_from_url(&host.ip); + if let Err(e) = ssh + .connect_with_key((ssh_host.as_str(), 22), ssh_user, ssh_key.as_str()) + .await + { + warn!( + "[host-keys] vm {}: connect to {} failed: {}", + vm.id, host.name, e + ); + return; + } + // Bounded so an unreachable or half-open guest cannot hold the check. + let scan = match ssh + .execute(&format!( + "ssh-keyscan -T 5 -t {} {ip}", + SCANNED_KEY_FAMILIES.join(",") + )) + .await + { + Ok((_, out)) => out, + Err(e) => { + warn!("[host-keys] vm {}: keyscan failed: {}", vm.id, e); + return; + } + }; + // A non-zero exit still prints the keys it did get, so the output is + // what decides. A scan opens one connection per algorithm and can time + // out on some of them, so what came back is merged into what is stored + // rather than replacing it, and a short capture is scanned again later. + if parse_ssh_host_keys(&scan).is_empty() { + debug!("[host-keys] vm {}: no keys in scan of {}", vm.id, ip); + return; + } + let merged = merge_ssh_host_keys(&ip, vm.ssh_host_keys.as_deref(), &scan); + if Some(merged.as_str()) == vm.ssh_host_keys.as_deref() { + return; + } + if let Err(e) = self.db.set_vm_ssh_host_keys(vm.id, Some(&merged)).await { + warn!("[host-keys] vm {}: failed to store keys: {}", vm.id, e); + } + } + + /// The address to scan a VM's host keys on: its first assigned IP, v4 + /// preferred because a node without IPv6 egress cannot reach the other. + async fn vm_scan_address(&self, vm: &Vm) -> Option { + // Assignments are stored as CIDR; ssh-keyscan wants the bare address. + // Parsed rather than trimmed: the result is interpolated into a command + // on the host, so only something that is definitely an address goes in. + let addrs: Vec = self + .db + .list_vm_ip_assignments(vm.id) + .await + .ok()? + .into_iter() + .filter(|i| !i.deleted) + .filter_map(|i| i.ip.split('/').next()?.parse().ok()) + .collect(); + addrs + .iter() + .find(|a| a.is_ipv4()) + .or_else(|| addrs.first()) + .map(|a| a.to_string()) + } + /// Best-effort reconciliation of missing DNS records for a VM's IPs. /// /// DNS is best-effort during spawn (a failed forward/reverse record must not @@ -2364,6 +2511,28 @@ impl Worker { let feedback = match &result { Ok(()) => { + // The guest generates fresh host keys on reinstall, so + // the stored ones now belong to an image that is gone; + // clearing them makes the next check re-capture. The + // scan-attempt stamp goes with them, or the customer + // would be shown no keys until the retry window passed + // — exactly when they are looking for the fingerprint. + if let Err(e) = self.db.set_vm_ssh_host_keys(*vm_id, None).await { + warn!( + "Failed to clear ssh host keys after reinstall of VM {}: {}", + vm_id, e + ); + } + if let Err(e) = self + .kv + .store(&host_key_attempt_key(*vm_id), &0u64.to_le_bytes()) + .await + { + warn!( + "Failed to reset ssh host key scan stamp for VM {}: {}", + vm_id, e + ); + } // Record history + refresh cached state only on success. self.vm_history_logger .log_vm_reinstalled( diff --git a/lnvps_api_admin/src/bin/generate_demo_data.rs b/lnvps_api_admin/src/bin/generate_demo_data.rs index fb00bdf8..8a29cf58 100644 --- a/lnvps_api_admin/src/bin/generate_demo_data.rs +++ b/lnvps_api_admin/src/bin/generate_demo_data.rs @@ -1206,6 +1206,7 @@ async fn create_vms( ssh_key_id: Some(ssh_key.id), disk_id: disk.id, mac_address: mac_address.clone(), + ssh_host_keys: None, deleted: false, ref_code: ref_code.clone(), disabled: false, @@ -1253,6 +1254,7 @@ async fn create_vms( ssh_key_id: Some(ssh_key.id), disk_id: disk.id, mac_address: mac_address.clone(), + ssh_host_keys: None, deleted: false, ref_code: None, disabled: false, diff --git a/lnvps_api_common/src/lib.rs b/lnvps_api_common/src/lib.rs index 3a64c6e5..67abe7d2 100644 --- a/lnvps_api_common/src/lib.rs +++ b/lnvps_api_common/src/lib.rs @@ -19,6 +19,7 @@ pub mod retry; mod routes; mod session; pub mod shasum; +mod ssh_host_key; mod status; mod vat; mod vm_history; @@ -43,6 +44,7 @@ pub use registry::*; pub use routes::*; use serde::{Deserialize, Deserializer}; pub use session::*; +pub use ssh_host_key::*; pub use status::*; pub use vat::*; pub use vm_history::*; diff --git a/lnvps_api_common/src/mock.rs b/lnvps_api_common/src/mock.rs index 1567040b..b537ad6a 100644 --- a/lnvps_api_common/src/mock.rs +++ b/lnvps_api_common/src/mock.rs @@ -163,6 +163,7 @@ impl MockDb { ssh_key_id: Some(1), disk_id: 1, mac_address: "ff:ff:ff:ff:ff:ff".to_string(), + ssh_host_keys: None, deleted: false, ref_code: None, disabled: false, @@ -1275,6 +1276,14 @@ impl LNVpsDbBase for MockDb { Ok(()) } + async fn set_vm_ssh_host_keys(&self, vm_id: u64, keys: Option<&str>) -> DbResult<()> { + let mut vms = self.vms.lock().await; + if let Some(v) = vms.get_mut(&vm_id) { + v.ssh_host_keys = keys.map(|k| k.to_string()); + } + Ok(()) + } + async fn get_vm_by_line_item(&self, line_item_id: u64) -> DbResult { let vms = self.vms.lock().await; vms.values() @@ -5484,6 +5493,50 @@ mod tests { assert!(res.is_err(), "expected error, not a panic"); } + /// A VM's captured host keys reach the customer parsed, and a VM with none + /// captured reports an empty list rather than a missing field. + #[tokio::test] + async fn test_vm_to_status_exposes_captured_host_keys() { + use crate::model::vm_to_status; + use lnvps_db::{LNVpsDb, UserSshKey}; + + let db = MockDb::default(); + db.vms.lock().await.insert(1, MockDb::mock_vm()); + db.insert_user_ssh_key(&UserSshKey { + id: 0, + name: "k".to_string(), + user_id: 1, + ..Default::default() + }) + .await + .unwrap(); + + let db: std::sync::Arc = std::sync::Arc::new(db); + let vm = db.get_vm(1).await.unwrap(); + let host = db.get_host(vm.host_id).await.ok(); + let status = vm_to_status(&db, vm, host.clone(), None, 0, 365) + .await + .unwrap(); + assert!(status.host_ssh_keys.is_empty(), "nothing captured yet"); + + db.set_vm_ssh_host_keys( + 1, + Some( + "10.0.0.5 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIxcwoVKDYPNmQud4AV/iPBbNVYPSr4X0E31b3FQxS/B\n", + ), + ) + .await + .unwrap(); + let vm = db.get_vm(1).await.unwrap(); + let status = vm_to_status(&db, vm, host, None, 0, 365).await.unwrap(); + assert_eq!(status.host_ssh_keys.len(), 1); + assert_eq!(status.host_ssh_keys[0].key_type, "ssh-ed25519"); + assert_eq!( + status.host_ssh_keys[0].fingerprint_sha256, + "SHA256:XXJM8fNyKu1oxISUmJkU3eTS4F4FcyW69THWriTri6M" + ); + } + /// vm_to_status surfaces the host's sunset date on VMs whose host is being /// decommissioned, and omits it otherwise. #[tokio::test] diff --git a/lnvps_api_common/src/model.rs b/lnvps_api_common/src/model.rs index e5015a69..92d102e7 100644 --- a/lnvps_api_common/src/model.rs +++ b/lnvps_api_common/src/model.rs @@ -1,5 +1,6 @@ use crate::VmRunningState; use crate::pricing::PricingEngine; +use crate::ssh_host_key::{ApiVmHostKey, parse_ssh_host_keys}; use anyhow::{Result, anyhow, bail}; use chrono::{DateTime, Days, Utc}; use futures::future::join_all; @@ -260,6 +261,12 @@ pub struct ApiVmStatus { /// for a reinstall. `None`/omitted when the host arch is unknown. #[serde(skip_serializing_if = "Option::is_none")] pub cpu_arch: Option, + /// The VM's own SSH host keys, captured from the guest after it booted, for + /// verifying the host on first connect instead of trusting the key it + /// presents. Empty until the capture succeeds, and re-captured after a + /// reinstall (which regenerates them). Not to be confused with `ssh_key`, + /// which is the customer's authorized key. + pub host_ssh_keys: Vec, } /// Grace period (days) for a subscription, tiered by how long the subscription @@ -356,6 +363,12 @@ pub async fn vm_to_status( Err(_) => (None, Utc::now(), None, false, None, max_prepay_days_default), }; + let host_ssh_keys = vm + .ssh_host_keys + .as_deref() + .map(parse_ssh_host_keys) + .unwrap_or_default(); + Ok(ApiVmStatus { id: vm.id, created: sub_created, @@ -386,6 +399,7 @@ pub async fn vm_to_status( arch => Some(arch.to_string()), }), max_prepay_days, + host_ssh_keys, }) } diff --git a/lnvps_api_common/src/ssh_host_key.rs b/lnvps_api_common/src/ssh_host_key.rs new file mode 100644 index 00000000..cea2d214 --- /dev/null +++ b/lnvps_api_common/src/ssh_host_key.rs @@ -0,0 +1,203 @@ +use base64::Engine; +use base64::prelude::BASE64_STANDARD; +use serde::Serialize; +use sha2::{Digest, Sha256}; + +/// One SSH host key of a VM, as a client needs it to verify the host on first +/// connect: the algorithm, the base64 key blob (`known_hosts` third field) and +/// the fingerprint `ssh` prints. +#[derive(Serialize, Clone, Debug, PartialEq, Eq)] +pub struct ApiVmHostKey { + /// Key algorithm, e.g. `ssh-ed25519`. + pub key_type: String, + /// Base64 key blob, without the algorithm prefix or any comment. + pub public_key: String, + /// `SHA256:…` fingerprint over the decoded key blob, matching + /// `ssh-keygen -lf` and the banner OpenSSH prints on an unknown host. + pub fingerprint_sha256: String, +} + +/// Algorithms worth storing. Anything else (including `ssh-dss`, which no +/// current OpenSSH offers) is dropped rather than surfaced as a key a client +/// might pin. +const ACCEPTED_KEY_TYPES: [&str; 5] = [ + "ssh-ed25519", + "ssh-rsa", + "ecdsa-sha2-nistp256", + "ecdsa-sha2-nistp384", + "ecdsa-sha2-nistp521", +]; + +/// Key families a capture asks the guest for. A guest normally offers one key +/// per family; anything missing means the scan did not get everything. +pub const SCANNED_KEY_FAMILIES: [&str; 3] = ["ed25519", "rsa", "ecdsa"]; + +/// The family a key algorithm belongs to, collapsing the ECDSA curves. +pub fn key_family(key_type: &str) -> &str { + if key_type.starts_with("ecdsa-") { + "ecdsa" + } else { + key_type.strip_prefix("ssh-").unwrap_or(key_type) + } +} + +/// Whether a capture holds a key from every family a scan asks for. +/// +/// A scan opens one connection per family and can time out on some of them, so +/// a capture short of this is treated as unfinished and scanned again rather +/// than pinning the VM to whichever subset answered first. +pub fn capture_is_complete(keys: &[ApiVmHostKey]) -> bool { + SCANNED_KEY_FAMILIES + .iter() + .all(|f| keys.iter().any(|k| key_family(&k.key_type) == *f)) +} + +/// Merge a fresh scan into what was already captured, newest key winning per +/// algorithm, and render it back as `known_hosts` lines for `host`. +/// +/// Merged rather than replaced so a scan that times out on one family does not +/// drop a key an earlier scan already got. +pub fn merge_ssh_host_keys(host: &str, stored: Option<&str>, scan: &str) -> String { + let mut merged: Vec = stored.map(parse_ssh_host_keys).unwrap_or_default(); + for key in parse_ssh_host_keys(scan) { + match merged.iter_mut().find(|k| k.key_type == key.key_type) { + Some(existing) => *existing = key, + None => merged.push(key), + } + } + merged.sort_by(|a, b| a.key_type.cmp(&b.key_type)); + merged + .iter() + .map(|k| format!("{host} {} {}\n", k.key_type, k.public_key)) + .collect() +} + +/// Parse `ssh-keyscan` output (or any `known_hosts` fragment) into host keys. +/// +/// Lines are `host keytype base64 [comment]`; `ssh-keyscan` also emits `#` +/// comment lines on stderr and, depending on version, on stdout. Anything that +/// is not a well-formed line of an accepted algorithm is dropped: this parses +/// output from a host that could be anything from a stale OpenSSH to a timeout +/// message, and a half-understood line is not a key to pin. +pub fn parse_ssh_host_keys(scan: &str) -> Vec { + let mut keys = Vec::new(); + for line in scan.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let mut fields = line.split_whitespace(); + let (Some(_host), Some(key_type), Some(public_key)) = + (fields.next(), fields.next(), fields.next()) + else { + continue; + }; + if !ACCEPTED_KEY_TYPES.contains(&key_type) { + continue; + } + let Ok(blob) = BASE64_STANDARD.decode(public_key) else { + continue; + }; + let digest = Sha256::digest(&blob); + keys.push(ApiVmHostKey { + key_type: key_type.to_string(), + public_key: public_key.to_string(), + fingerprint_sha256: format!( + "SHA256:{}", + base64::engine::general_purpose::STANDARD_NO_PAD.encode(digest) + ), + }); + } + keys.sort_by(|a, b| a.key_type.cmp(&b.key_type)); + keys.dedup(); + keys +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A real `ssh-keyscan` capture: comment lines, two algorithms, and a + /// trailing comment field on one line. + const SCAN: &str = "\ +# 10.0.0.5:22 SSH-2.0-OpenSSH_9.2p1 +10.0.0.5 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIxcwoVKDYPNmQud4AV/iPBbNVYPSr4X0E31b3FQxS/B +# 10.0.0.5:22 SSH-2.0-OpenSSH_9.2p1 +10.0.0.5 ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBLzjP6wKPgJb/zLHyqRA0WZyGbOXVjkB1x/mD8vGw2v88q6+0opgrCYFTsZ3iAMztSDmaJzAf8DipD5cgPVdqfk= root@vm +"; + + #[test] + fn parses_accepted_algorithms_and_fingerprints_them() { + let keys = parse_ssh_host_keys(SCAN); + assert_eq!(keys.len(), 2, "{keys:?}"); + let ed = keys.iter().find(|k| k.key_type == "ssh-ed25519").unwrap(); + assert_eq!( + ed.public_key, + "AAAAC3NzaC1lZDI1NTE5AAAAIIxcwoVKDYPNmQud4AV/iPBbNVYPSr4X0E31b3FQxS/B" + ); + // Pinned against `ssh-keygen -lf` for the same key, so a change in + // digest or encoding fails here rather than shipping a fingerprint that + // does not match what ssh shows the customer. + assert_eq!( + ed.fingerprint_sha256, + "SHA256:XXJM8fNyKu1oxISUmJkU3eTS4F4FcyW69THWriTri6M" + ); + assert!(!ed.fingerprint_sha256.ends_with('='), "no base64 padding"); + } + + /// Everything that is not a key line a client could pin is dropped rather + /// than surfaced: unknown algorithms, malformed base64 and truncated lines. + #[test] + fn drops_anything_not_a_well_formed_key() { + let scan = "\ +# comment only +10.0.0.5 ssh-dss AAAAB3NzaC1kc3MAAACBAKQ1 +10.0.0.5 ssh-ed25519 not-base64!! +10.0.0.5 ssh-ed25519 + +"; + assert!(parse_ssh_host_keys(scan).is_empty()); + } + + /// A scan that timed out on one family adds to what is already stored + /// rather than replacing it, and the result is only complete once every + /// family answered. + #[test] + fn a_later_scan_fills_in_what_an_earlier_one_missed() { + let ed = "10.0.0.5 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIxcwoVKDYPNmQud4AV/iPBbNVYPSr4X0E31b3FQxS/B\n"; + let ecdsa = "10.0.0.5 ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBLzjP6wKPgJb/zLHyqRA0WZyGbOXVjkB1x/mD8vGw2v88q6+0opgrCYFTsZ3iAMztSDmaJzAf8DipD5cgPVdqfk=\n"; + + assert!( + !capture_is_complete(&parse_ssh_host_keys(ed)), + "rsa missing" + ); + + let merged = merge_ssh_host_keys("10.0.0.5", Some(ed), ecdsa); + let keys = parse_ssh_host_keys(&merged); + assert_eq!(keys.len(), 2, "the earlier key survives the second scan"); + assert!(!capture_is_complete(&keys), "still no rsa"); + + // Re-scanning what is already stored changes nothing. + assert_eq!( + merge_ssh_host_keys("10.0.0.5", Some(&merged), ecdsa), + merged + ); + } + + /// Every ECDSA curve is one family, so a guest offering nistp384 is not + /// re-scanned forever waiting for nistp256. + #[test] + fn ecdsa_curves_are_one_family() { + assert_eq!(key_family("ecdsa-sha2-nistp384"), "ecdsa"); + assert_eq!(key_family("ssh-ed25519"), "ed25519"); + assert_eq!(key_family("ssh-rsa"), "rsa"); + } + + /// `ssh-keyscan` reports every listening address, so the same key can + /// appear once per IP; a client wants the key once. + #[test] + fn repeated_keys_collapse() { + let repeated = format!("{SCAN}{SCAN}"); + assert_eq!(parse_ssh_host_keys(&repeated).len(), 2); + } +} diff --git a/lnvps_api_common/src/vm_history.rs b/lnvps_api_common/src/vm_history.rs index f6a66db1..fcddfccf 100644 --- a/lnvps_api_common/src/vm_history.rs +++ b/lnvps_api_common/src/vm_history.rs @@ -557,6 +557,7 @@ mod tests { ssh_key_id: Some(1), disk_id: 0, mac_address: "aa:bb:cc:dd:ee:ff".to_string(), + ssh_host_keys: None, deleted: false, ref_code: None, disabled: false, @@ -762,6 +763,7 @@ mod tests { ssh_key_id: Some(1), disk_id: 1, mac_address: "aa:bb:cc:dd:ee:ff".to_string(), + ssh_host_keys: None, deleted: false, ref_code: None, disabled: false, diff --git a/lnvps_db/migrations/20260729120000_vm_ssh_host_keys.sql b/lnvps_db/migrations/20260729120000_vm_ssh_host_keys.sql new file mode 100644 index 00000000..e799d93c --- /dev/null +++ b/lnvps_db/migrations/20260729120000_vm_ssh_host_keys.sql @@ -0,0 +1,13 @@ +-- SSH host keys a VM presented after first boot, as captured `ssh-keyscan` +-- lines (`host keytype base64`). +-- +-- Stored as the raw scan rather than a row per key: the set is written and read +-- whole, is replaced outright when the guest regenerates its keys on reinstall, +-- and is never queried by key. Public key material only — nothing here is a +-- secret, and the guest's private keys are never read. +-- +-- NULL means "not captured yet", which is the honest state for a VM that has +-- not booted, whose IP is unreachable, or that predates this column: an empty +-- capture must not read as "this host has no keys". +ALTER TABLE vm + ADD COLUMN ssh_host_keys TEXT NULL AFTER mac_address; diff --git a/lnvps_db/src/lib.rs b/lnvps_db/src/lib.rs index f183bb23..d8689b8b 100644 --- a/lnvps_db/src/lib.rs +++ b/lnvps_db/src/lib.rs @@ -420,6 +420,13 @@ pub trait LNVpsDbBase: Send + Sync { /// Update a VM async fn update_vm(&self, vm: &Vm) -> DbResult<()>; + /// Replace a VM's captured SSH host keys (`None` clears them). + /// + /// Separate from [`update_vm`] on purpose: the keys are written by the + /// worker after boot, so folding them into the full-row update would let + /// any caller holding a VM loaded before the capture wipe them. + async fn set_vm_ssh_host_keys(&self, vm_id: u64, keys: Option<&str>) -> DbResult<()>; + /// Get a VM by its subscription line item ID async fn get_vm_by_line_item(&self, line_item_id: u64) -> DbResult; diff --git a/lnvps_db/src/model.rs b/lnvps_db/src/model.rs index 4af6fbc9..22cbd713 100644 --- a/lnvps_db/src/model.rs +++ b/lnvps_db/src/model.rs @@ -1304,6 +1304,9 @@ pub struct Vm { pub disk_id: u64, /// Network MAC address pub mac_address: String, + /// SSH host keys captured from the guest after boot, as `ssh-keyscan` + /// lines. `None` until a capture succeeds. + pub ssh_host_keys: Option, /// Is the VM deleted pub deleted: bool, /// Referral code (recorded during ordering) diff --git a/lnvps_db/src/mysql.rs b/lnvps_db/src/mysql.rs index f1130038..4b1ffb9b 100644 --- a/lnvps_db/src/mysql.rs +++ b/lnvps_db/src/mysql.rs @@ -1217,6 +1217,15 @@ impl LNVpsDbBase for LNVpsDbMysql { Ok(()) } + async fn set_vm_ssh_host_keys(&self, vm_id: u64, keys: Option<&str>) -> DbResult<()> { + sqlx::query("update vm set ssh_host_keys=? where id=?") + .bind(keys) + .bind(vm_id) + .execute(&self.db) + .await?; + Ok(()) + } + async fn get_vm_by_line_item(&self, line_item_id: u64) -> DbResult { Ok( sqlx::query_as("SELECT * FROM vm WHERE subscription_line_item_id = ? AND deleted = 0") diff --git a/lnvps_e2e/src/db.rs b/lnvps_e2e/src/db.rs index b4b48ce6..ab0609c0 100644 --- a/lnvps_e2e/src/db.rs +++ b/lnvps_e2e/src/db.rs @@ -342,6 +342,21 @@ pub async fn hard_delete_company(pool: &MySqlPool, company_id: u64) -> anyhow::R Ok(()) } +/// Write a VM's captured SSH host keys directly, standing in for the worker's +/// scan of the guest (which needs a real booted VM). +pub async fn set_vm_ssh_host_keys( + pool: &MySqlPool, + vm_id: u64, + keys: &str, +) -> anyhow::Result<()> { + sqlx::query("UPDATE vm SET ssh_host_keys = ? WHERE id = ?") + .bind(keys) + .bind(vm_id) + .execute(pool) + .await?; + Ok(()) +} + /// Backdate `subscription.created` by the given number of hours so that `check_vms` /// considers the VM eligible for unpaid-VM cleanup (threshold: 1 hour). pub async fn backdate_vm_created(pool: &MySqlPool, vm_id: u64, hours: u32) -> anyhow::Result<()> { diff --git a/lnvps_e2e/src/lifecycle.rs b/lnvps_e2e/src/lifecycle.rs index b9e06730..d120a120 100644 --- a/lnvps_e2e/src/lifecycle.rs +++ b/lnvps_e2e/src/lifecycle.rs @@ -511,6 +511,34 @@ mod tests { let expires_str = vm_after_pay["data"]["expires"].as_str().unwrap(); eprintln!("VM {vm_id} expires: {expires_str}"); + // ---------------------------------------------------------------- + // 14a-2. SSH host keys: empty until the worker captures them, and + // surfaced parsed once a capture exists. + // ---------------------------------------------------------------- + assert_eq!( + vm_after_pay["data"]["host_ssh_keys"].as_array(), + Some(&vec![]), + "host keys are empty, not absent, before capture" + ); + let pool = crate::db::connect().await.unwrap(); + crate::db::set_vm_ssh_host_keys( + &pool, + vm_id, + "10.0.0.5 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIxcwoVKDYPNmQud4AV/iPBbNVYPSr4X0E31b3FQxS/B\n", + ) + .await + .unwrap(); + let with_keys = + json_ok(user.get_auth(&format!("/api/v1/vm/{vm_id}")).await.unwrap()).await; + let keys = with_keys["data"]["host_ssh_keys"].as_array().unwrap(); + assert_eq!(keys.len(), 1, "{keys:?}"); + assert_eq!(keys[0]["key_type"].as_str(), Some("ssh-ed25519")); + assert_eq!( + keys[0]["fingerprint_sha256"].as_str(), + Some("SHA256:XXJM8fNyKu1oxISUmJkU3eTS4F4FcyW69THWriTri6M") + ); + crate::db::set_vm_ssh_host_keys(&pool, vm_id, "").await.unwrap(); + // ---------------------------------------------------------------- // 14b. Verify subscription state after first payment // is_setup should now be true; expires should be set.