From 4cf35c99d0fe56f3b2f249071caec85f3e0140fd Mon Sep 17 00:00:00 2001 From: Till Wegmueller Date: Sat, 14 Feb 2026 23:05:28 +0100 Subject: [PATCH] Generate per-VM Ed25519 SSH keypairs instead of requiring user keys libssh2 cannot handle all OpenSSH private key formats (e.g. passphrase- protected or newer ed25519 keys), causing auth failures. Instead of referencing the user's ~/.ssh keys, generate a fresh Ed25519 keypair at resolve time when the VMFile omits ssh-key and private-key. The public key is injected into cloud-init and the private PEM is persisted to the VM's work directory so that provision, reload, and ssh commands can reuse it. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 454 ++++++++++++++++++++- Cargo.toml | 1 + VMFile.kdl | 2 - crates/vm-manager/Cargo.toml | 1 + crates/vm-manager/src/error.rs | 7 + crates/vm-manager/src/vmfile.rs | 199 ++++++--- crates/vmctl/src/commands/mod.rs | 59 +++ crates/vmctl/src/commands/provision_cmd.rs | 10 +- crates/vmctl/src/commands/reload.rs | 11 +- crates/vmctl/src/commands/ssh.rs | 18 +- crates/vmctl/src/commands/up.rs | 11 +- 11 files changed, 670 insertions(+), 103 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7afc796..6379d23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -127,12 +127,24 @@ dependencies = [ "backtrace", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "1.3.2" @@ -209,6 +221,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.5.58" @@ -255,6 +277,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "core-foundation" version = "0.10.1" @@ -289,6 +317,18 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -299,12 +339,48 @@ dependencies = [ "typenum", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "data-encoding" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -312,7 +388,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", + "subtle", ] [[package]] @@ -347,6 +425,60 @@ dependencies = [ "syn", ] +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "sha2", + "subtle", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -381,6 +513,22 @@ dependencies = [ "log", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -471,6 +619,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -519,6 +668,17 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -540,6 +700,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.4.0" @@ -784,6 +953,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -867,6 +1045,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "leb128fmt" @@ -880,6 +1061,12 @@ version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.12" @@ -1047,6 +1234,22 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + [[package]] name = "num-complex" version = "0.4.6" @@ -1094,6 +1297,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1147,6 +1351,44 @@ version = "4.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core 0.6.4", + "sha2", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1170,6 +1412,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1188,6 +1439,27 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -1222,6 +1494,15 @@ dependencies = [ "syn", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1260,7 +1541,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand", + "rand 0.9.2", "ring", "rustc-hash", "rustls", @@ -1301,14 +1582,34 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ - "rand_chacha", - "rand_core", + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", ] [[package]] @@ -1318,7 +1619,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -1420,6 +1730,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -1434,6 +1754,27 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "sha2", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rustc-demangle" version = "0.1.27" @@ -1446,6 +1787,15 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.3" @@ -1533,6 +1883,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.6.0" @@ -1628,6 +1992,17 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1653,6 +2028,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "slab" version = "0.4.12" @@ -1675,6 +2060,64 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "ssh-cipher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +dependencies = [ + "cipher", + "ssh-encoding", +] + +[[package]] +name = "ssh-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +dependencies = [ + "base64ct", + "pem-rfc7468", + "sha2", +] + +[[package]] +name = "ssh-key" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" +dependencies = [ + "ed25519-dalek", + "p256", + "p384", + "p521", + "rand_core 0.6.4", + "rsa", + "sec1", + "sha2", + "signature", + "ssh-cipher", + "ssh-encoding", + "subtle", + "zeroize", +] + [[package]] name = "ssh2" version = "0.9.5" @@ -2029,7 +2472,7 @@ dependencies = [ "http", "httparse", "log", - "rand", + "rand 0.9.2", "sha1", "thiserror", "utf-8", @@ -2150,6 +2593,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "ssh-key", "ssh2", "tempfile", "thiserror", diff --git a/Cargo.toml b/Cargo.toml index ed471eb..54aeb00 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,3 +36,4 @@ futures-util = "0.3" zstd = "0.13" dirs = "6" kdl = "6" +ssh-key = { version = "0.6", features = ["ed25519", "rand_core", "getrandom"] } diff --git a/VMFile.kdl b/VMFile.kdl index a9597a2..7eb8378 100644 --- a/VMFile.kdl +++ b/VMFile.kdl @@ -6,12 +6,10 @@ vm "omnios-builder" { cloud-init { hostname "omnios-builder" - ssh-key "~/.ssh/id_ed25519.pub" } ssh { user "smithy" - private-key "~/.ssh/id_ed25519" } // Stage 1: System packages and Rust toolchain diff --git a/crates/vm-manager/Cargo.toml b/crates/vm-manager/Cargo.toml index f0565c8..78a7ffb 100644 --- a/crates/vm-manager/Cargo.toml +++ b/crates/vm-manager/Cargo.toml @@ -29,6 +29,7 @@ isobemak = { version = "0.2", optional = true } # SSH ssh2 = "0.9" +ssh-key.workspace = true [target.'cfg(target_os = "linux")'.dependencies] libc = "0.2" diff --git a/crates/vm-manager/src/error.rs b/crates/vm-manager/src/error.rs index 9205d0c..528d24d 100644 --- a/crates/vm-manager/src/error.rs +++ b/crates/vm-manager/src/error.rs @@ -78,6 +78,13 @@ pub enum VmError { )] SshFailed { detail: String }, + #[error("failed to generate SSH keypair: {detail}")] + #[diagnostic( + code(vm_manager::ssh::keygen_failed), + help("this is an internal error in Ed25519 key generation — please report it") + )] + SshKeygenFailed { detail: String }, + #[error("failed to download image from {url}: {detail}")] #[diagnostic( code(vm_manager::image::download_failed), diff --git a/crates/vm-manager/src/vmfile.rs b/crates/vm-manager/src/vmfile.rs index e8ab242..efca51d 100644 --- a/crates/vm-manager/src/vmfile.rs +++ b/crates/vm-manager/src/vmfile.rs @@ -66,7 +66,9 @@ pub struct CloudInitDef { #[derive(Debug, Clone)] pub struct SshDef { pub user: String, - pub private_key: String, + /// Path to an existing private key file. When `None`, a per-VM Ed25519 + /// keypair is generated at resolve time and used via in-memory PEM. + pub private_key: Option, } /// A provisioning step. @@ -320,7 +322,7 @@ fn parse_vm_def(name: &str, doc: &KdlDocument) -> Result { let ssh_doc = ssh_node.children().ok_or_else(|| VmError::VmFileValidation { vm: name.into(), detail: "ssh block must have a body".into(), - hint: "add user and private-key inside: ssh { user \"vm\"; private-key \"~/.ssh/id_ed25519\" }".into(), + hint: "add at least a user: ssh { user \"vm\" }".into(), })?; let user = ssh_doc .get_arg("user") @@ -330,12 +332,7 @@ fn parse_vm_def(name: &str, doc: &KdlDocument) -> Result { let private_key = ssh_doc .get_arg("private-key") .and_then(|v| v.as_string()) - .ok_or_else(|| VmError::VmFileValidation { - vm: name.into(), - detail: "ssh block requires private-key".into(), - hint: "add: private-key \"~/.ssh/id_ed25519\"".into(), - })? - .to_string(); + .map(String::from); Some(SshDef { user, private_key }) } else { None @@ -465,60 +462,8 @@ pub async fn resolve(def: &VmDef, base_dir: &Path) -> Result { NetworkDef::None => NetworkConfig::None, }; - // Cloud-init - let cloud_init = if let Some(ci) = &def.cloud_init { - if let Some(raw_path) = &ci.user_data { - // Raw user-data file - let p = resolve_path(raw_path, base_dir); - let data = tokio::fs::read(&p) - .await - .map_err(|e| VmError::VmFileValidation { - vm: def.name.clone(), - detail: format!("cannot read user-data at {}: {e}", p.display()), - hint: "check the user-data path".into(), - })?; - Some(CloudInitConfig { - user_data: data, - instance_id: Some(def.name.clone()), - hostname: ci.hostname.clone().or_else(|| Some(def.name.clone())), - }) - } else if let Some(key_raw) = &ci.ssh_key { - // Build cloud-config from SSH key - let key_path = resolve_path(key_raw, base_dir); - let pubkey = tokio::fs::read_to_string(&key_path).await.map_err(|e| { - VmError::VmFileValidation { - vm: def.name.clone(), - detail: format!("cannot read ssh-key at {}: {e}", key_path.display()), - hint: "check the ssh-key path".into(), - } - })?; - let hostname = ci.hostname.as_deref().unwrap_or(&def.name); - let ssh_user = def.ssh.as_ref().map(|s| s.user.as_str()).unwrap_or("vm"); - let (user_data, _meta) = - build_cloud_config(ssh_user, pubkey.trim(), &def.name, hostname); - Some(CloudInitConfig { - user_data, - instance_id: Some(def.name.clone()), - hostname: Some(hostname.to_string()), - }) - } else { - // cloud-init block with only hostname, no keys or user-data - None - } - } else { - None - }; - - // SSH config - let ssh = def.ssh.as_ref().map(|s| { - let key_path = resolve_path(&s.private_key, base_dir); - SshConfig { - user: s.user.clone(), - public_key: None, - private_key_path: Some(key_path), - private_key_pem: None, - } - }); + // Cloud-init + SSH config (resolved together because key generation affects both) + let (cloud_init, ssh) = resolve_cloud_init_and_ssh(def, base_dir).await?; Ok(VmSpec { name: def.name.clone(), @@ -532,6 +477,134 @@ pub async fn resolve(def: &VmDef, base_dir: &Path) -> Result { }) } +/// Generate an Ed25519 SSH keypair and return `(public_key_openssh, private_key_pem)`. +fn generate_ssh_keypair(vm_name: &str) -> Result<(String, String)> { + use ssh_key::{Algorithm, LineEnding, PrivateKey, rand_core::OsRng}; + + let sk = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).map_err(|e| { + VmError::SshKeygenFailed { + detail: format!("Ed25519 key generation for VM '{vm_name}': {e}"), + } + })?; + + let pub_openssh = sk.public_key().to_openssh().map_err(|e| { + VmError::SshKeygenFailed { + detail: format!("serialize public key: {e}"), + } + })?; + + let priv_pem = sk.to_openssh(LineEnding::LF).map_err(|e| { + VmError::SshKeygenFailed { + detail: format!("serialize private key: {e}"), + } + })?; + + Ok((pub_openssh, priv_pem.to_string())) +} + +/// Resolve cloud-init and SSH config together. +/// +/// When the VMFile provides a `cloud-init` block but no `ssh-key` (and no `user-data`), and the +/// `ssh` block omits `private-key`, we generate a per-VM Ed25519 keypair: the public key is +/// injected into cloud-config and the private key PEM is used for SSH auth. +async fn resolve_cloud_init_and_ssh( + def: &VmDef, + base_dir: &Path, +) -> Result<(Option, Option)> { + let ssh_user = def.ssh.as_ref().map(|s| s.user.as_str()).unwrap_or("vm"); + let hostname = def + .cloud_init + .as_ref() + .and_then(|ci| ci.hostname.as_deref()) + .unwrap_or(&def.name); + + // --- Cloud-init: raw user-data file --- + if let Some(ci) = &def.cloud_init { + if let Some(raw_path) = &ci.user_data { + let p = resolve_path(raw_path, base_dir); + let data = + tokio::fs::read(&p) + .await + .map_err(|e| VmError::VmFileValidation { + vm: def.name.clone(), + detail: format!("cannot read user-data at {}: {e}", p.display()), + hint: "check the user-data path".into(), + })?; + let cloud_init = Some(CloudInitConfig { + user_data: data, + instance_id: Some(def.name.clone()), + hostname: ci.hostname.clone().or_else(|| Some(def.name.clone())), + }); + // SSH config from explicit key (if any) + let ssh = resolve_ssh_config_from_def(def, base_dir); + return Ok((cloud_init, ssh)); + } + } + + // --- Cloud-init: explicit ssh-key file --- + if let Some(ci) = &def.cloud_init { + if let Some(key_raw) = &ci.ssh_key { + let key_path = resolve_path(key_raw, base_dir); + let pubkey = + tokio::fs::read_to_string(&key_path) + .await + .map_err(|e| VmError::VmFileValidation { + vm: def.name.clone(), + detail: format!("cannot read ssh-key at {}: {e}", key_path.display()), + hint: "check the ssh-key path".into(), + })?; + let (user_data, _meta) = + build_cloud_config(ssh_user, pubkey.trim(), &def.name, hostname); + let cloud_init = Some(CloudInitConfig { + user_data, + instance_id: Some(def.name.clone()), + hostname: Some(hostname.to_string()), + }); + let ssh = resolve_ssh_config_from_def(def, base_dir); + return Ok((cloud_init, ssh)); + } + } + + // --- Cloud-init block present but no ssh-key / no user-data → generate keypair --- + if def.cloud_init.is_some() { + info!(vm = %def.name, "generating Ed25519 SSH keypair for cloud-init"); + let (pub_openssh, priv_pem) = generate_ssh_keypair(&def.name)?; + + let (user_data, _meta) = + build_cloud_config(ssh_user, &pub_openssh, &def.name, hostname); + let cloud_init = Some(CloudInitConfig { + user_data, + instance_id: Some(def.name.clone()), + hostname: Some(hostname.to_string()), + }); + let ssh = Some(SshConfig { + user: ssh_user.to_string(), + public_key: Some(pub_openssh), + private_key_path: None, + private_key_pem: Some(priv_pem), + }); + return Ok((cloud_init, ssh)); + } + + // --- No cloud-init at all --- + let ssh = resolve_ssh_config_from_def(def, base_dir); + Ok((None, ssh)) +} + +/// Build an `SshConfig` from an explicit `private-key` path in the SSH block. +/// Returns `None` if there is no ssh block or no private-key specified. +fn resolve_ssh_config_from_def(def: &VmDef, base_dir: &Path) -> Option { + def.ssh.as_ref().and_then(|s| { + let key_path = s.private_key.as_ref()?; + Some(SshConfig { + user: s.user.clone(), + public_key: None, + private_key_path: Some(resolve_path(key_path, base_dir)), + private_key_pem: None, + }) + }) +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -611,7 +684,7 @@ vm "web" { let ssh = vm.ssh.as_ref().unwrap(); assert_eq!(ssh.user, "admin"); - assert_eq!(ssh.private_key, "~/.ssh/id_ed25519"); + assert_eq!(ssh.private_key.as_deref(), Some("~/.ssh/id_ed25519")); assert_eq!(vm.provisions.len(), 2); assert!( diff --git a/crates/vmctl/src/commands/mod.rs b/crates/vmctl/src/commands/mod.rs index a952b98..132e505 100644 --- a/crates/vmctl/src/commands/mod.rs +++ b/crates/vmctl/src/commands/mod.rs @@ -88,3 +88,62 @@ fn ssh_port_for_handle(handle: &VmHandle) -> u16 { _ => 22, } } + +/// Well-known filename for a generated SSH private key, stored in the VM's work directory. +const GENERATED_KEY_FILE: &str = "id_ed25519_generated"; + +/// Persist a generated SSH private key PEM to the VM's work directory (if present). +async fn save_generated_ssh_key( + spec: &vm_manager::VmSpec, + handle: &VmHandle, +) -> miette::Result<()> { + if let Some(ref ssh) = spec.ssh { + if let Some(ref pem) = ssh.private_key_pem { + let key_path = handle.work_dir.join(GENERATED_KEY_FILE); + tokio::fs::write(&key_path, pem) + .await + .map_err(|e| miette::miette!("failed to save generated SSH key: {e}"))?; + } + } + Ok(()) +} + +/// Build an `SshConfig` from a VMFile ssh block and (optionally) a persisted generated key. +/// +/// If the ssh block specifies `private-key`, use that file. Otherwise, look for a previously +/// generated key in the VM's work directory (written during `vmctl up`). +fn build_ssh_config( + ssh_def: &vm_manager::vmfile::SshDef, + base_dir: &std::path::Path, + handle: &VmHandle, +) -> miette::Result { + if let Some(ref key_path) = ssh_def.private_key { + return Ok(vm_manager::SshConfig { + user: ssh_def.user.clone(), + public_key: None, + private_key_path: Some(vm_manager::vmfile::resolve_path(key_path, base_dir)), + private_key_pem: None, + }); + } + + // Look for a generated key in the VM's work directory + let gen_key_path = handle.work_dir.join(GENERATED_KEY_FILE); + if gen_key_path.exists() { + let pem = std::fs::read_to_string(&gen_key_path).map_err(|e| { + miette::miette!( + "cannot read generated SSH key at {}: {e}", + gen_key_path.display() + ) + })?; + Ok(vm_manager::SshConfig { + user: ssh_def.user.clone(), + public_key: None, + private_key_path: None, + private_key_pem: Some(pem), + }) + } else { + Err(miette::miette!( + "no SSH private-key configured and no generated key found for VM — run `vmctl up` first" + )) + } +} diff --git a/crates/vmctl/src/commands/provision_cmd.rs b/crates/vmctl/src/commands/provision_cmd.rs index c79473b..c5a0ced 100644 --- a/crates/vmctl/src/commands/provision_cmd.rs +++ b/crates/vmctl/src/commands/provision_cmd.rs @@ -62,15 +62,7 @@ pub async fn run(args: ProvisionArgs) -> Result<()> { let ip = hv.guest_ip(handle).await.into_diagnostic()?; let port = super::ssh_port_for_handle(handle); - let config = vm_manager::SshConfig { - user: ssh_def.user.clone(), - public_key: None, - private_key_path: Some(vm_manager::vmfile::resolve_path( - &ssh_def.private_key, - &vmfile.base_dir, - )), - private_key_pem: None, - }; + let config = super::build_ssh_config(ssh_def, &vmfile.base_dir, handle)?; println!("Provisioning VM '{}'...", def.name); let sess = diff --git a/crates/vmctl/src/commands/reload.rs b/crates/vmctl/src/commands/reload.rs index 1b381bd..1467f09 100644 --- a/crates/vmctl/src/commands/reload.rs +++ b/crates/vmctl/src/commands/reload.rs @@ -52,6 +52,7 @@ pub async fn run(args: ReloadArgs) -> Result<()> { .into_diagnostic()?; let handle = hv.prepare(&spec).await.into_diagnostic()?; + super::save_generated_ssh_key(&spec, &handle).await?; store.insert(def.name.clone(), handle.clone()); state::save_store(&store).await?; @@ -98,15 +99,7 @@ async fn run_provision_for_vm( let ip = hv.guest_ip(handle).await.into_diagnostic()?; let port = super::ssh_port_for_handle(handle); - let config = vm_manager::SshConfig { - user: ssh_def.user.clone(), - public_key: None, - private_key_path: Some(vm_manager::vmfile::resolve_path( - &ssh_def.private_key, - base_dir, - )), - private_key_pem: None, - }; + let config = super::build_ssh_config(ssh_def, base_dir, handle)?; println!("Provisioning VM '{vm_name}'..."); let sess = vm_manager::ssh::connect_with_retry(&ip, port, &config, Duration::from_secs(120)) diff --git a/crates/vmctl/src/commands/ssh.rs b/crates/vmctl/src/commands/ssh.rs index 3777e1b..599db65 100644 --- a/crates/vmctl/src/commands/ssh.rs +++ b/crates/vmctl/src/commands/ssh.rs @@ -53,12 +53,18 @@ pub async fn run(args: SshArgs) -> Result<()> { _ => 22, }; - let key_path = args.key.or_else(find_ssh_key).ok_or_else(|| { - miette::miette!( - "no SSH key found — provide one with --key or ensure ~/.ssh/id_ed25519, \ - ~/.ssh/id_ecdsa, or ~/.ssh/id_rsa exists" - ) - })?; + // Check for a generated key in the VM's work directory first, then user keys + let generated_key = handle.work_dir.join(super::GENERATED_KEY_FILE); + let key_path = args + .key + .or_else(|| generated_key.exists().then_some(generated_key)) + .or_else(find_ssh_key) + .ok_or_else(|| { + miette::miette!( + "no SSH key found — provide one with --key or ensure ~/.ssh/id_ed25519, \ + ~/.ssh/id_ecdsa, or ~/.ssh/id_rsa exists" + ) + })?; let config = SshConfig { user: args.user.clone(), diff --git a/crates/vmctl/src/commands/up.rs b/crates/vmctl/src/commands/up.rs index 060f32f..ff52383 100644 --- a/crates/vmctl/src/commands/up.rs +++ b/crates/vmctl/src/commands/up.rs @@ -74,6 +74,7 @@ pub async fn run(args: UpArgs) -> Result<()> { .into_diagnostic()?; let handle = hv.prepare(&spec).await.into_diagnostic()?; + super::save_generated_ssh_key(&spec, &handle).await?; store.insert(def.name.clone(), handle.clone()); state::save_store(&store).await?; @@ -119,15 +120,7 @@ async fn run_provision_for_vm( let ip = hv.guest_ip(handle).await.into_diagnostic()?; let port = super::ssh_port_for_handle(handle); - let config = vm_manager::SshConfig { - user: ssh_def.user.clone(), - public_key: None, - private_key_path: Some(vm_manager::vmfile::resolve_path( - &ssh_def.private_key, - base_dir, - )), - private_key_pem: None, - }; + let config = super::build_ssh_config(ssh_def, base_dir, handle)?; println!("Provisioning VM '{vm_name}'..."); let sess = vm_manager::ssh::connect_with_retry(&ip, port, &config, Duration::from_secs(120))