diff --git a/Cargo.lock b/Cargo.lock index a077c1c..4f1b17c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,7 +15,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ "crypto-common 0.1.7", - "generic-array", + "generic-array 0.14.7", ] [[package]] @@ -126,6 +126,18 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "asn1-rs" version = "0.7.2" @@ -281,6 +293,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base64" version = "0.22.1" @@ -306,6 +324,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "bcrypt-pbkdf" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2" +dependencies = [ + "blowfish", + "pbkdf2", + "sha2 0.10.9", +] + [[package]] name = "bergshamra" version = "0.4.0" @@ -527,6 +556,18 @@ name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] [[package]] name = "block-buffer" @@ -534,7 +575,7 @@ version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "generic-array", + "generic-array 0.14.7", ] [[package]] @@ -552,7 +593,7 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" dependencies = [ - "generic-array", + "generic-array 0.14.7", ] [[package]] @@ -636,6 +677,28 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.44" @@ -643,7 +706,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", + "wasm-bindgen", "windows-link", ] @@ -756,6 +821,17 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-models" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0940496e5c83c54f3b753d5317daec82e8edac71c33aaa1f666d76f518de2444" +dependencies = [ + "hax-lib", + "pastey", + "rand 0.9.4", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -783,13 +859,19 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-bigint" version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ - "generic-array", + "generic-array 0.14.7", "rand_core 0.6.4", "subtle", "zeroize", @@ -801,7 +883,7 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "generic-array", + "generic-array 0.14.7", "rand_core 0.6.4", "typenum", ] @@ -883,6 +965,20 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.10.0" @@ -907,6 +1003,17 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +[[package]] +name = "delegate" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "der" version = "0.7.10" @@ -1014,6 +1121,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.0", + "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", ] @@ -1090,11 +1198,11 @@ version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ - "base16ct", + "base16ct 0.2.0", "crypto-bigint", "digest 0.10.7", "ff", - "generic-array", + "generic-array 0.14.7", "group", "hkdf", "pem-rfc7468", @@ -1148,6 +1256,18 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1353,6 +1473,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "generic-array" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb130435a959a8d525e6bca66ff6c40981a300ee96d70e3ef56f046556d614a3" +dependencies = [ + "generic-array 0.14.7", + "rustversion", + "typenum", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1389,6 +1520,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", ] @@ -1426,6 +1558,18 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "gloo-timers" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "482ce8a491a501da4cd806bd190275363d674f2845005c6ddbd5d3e1dd54495d" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "group" version = "0.13.0" @@ -1456,6 +1600,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.5" @@ -1471,6 +1621,43 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +[[package]] +name = "hax-lib" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74d9ba66d1739c68e0219b2b2238b5c4145f491ebf181b9c6ab561a19352ae86" +dependencies = [ + "hax-lib-macros", + "num-bigint", + "num-traits", +] + +[[package]] +name = "hax-lib-macros" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24ba777a231a58d1bce1d68313fa6b6afcc7966adef23d60f45b8a2b9b688bf1" +dependencies = [ + "hax-lib-macros-types", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "hax-lib-macros-types" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "867e19177d7425140b417cd27c2e05320e727ee682e98368f88b7194e80ad515" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "serde_json", + "uuid", +] + [[package]] name = "heck" version = "0.4.1" @@ -1495,6 +1682,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex-literal" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" + [[package]] name = "hkdf" version = "0.12.4" @@ -1522,6 +1715,15 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "hostname" version = "0.4.2" @@ -1851,7 +2053,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ "block-padding", - "generic-array", + "generic-array 0.14.7", ] [[package]] @@ -1866,6 +2068,33 @@ dependencies = [ "tempfile", ] +[[package]] +name = "internal-russh-forked-ssh-key" +version = "0.6.11+upstream-0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0a77eae781ed6a7709fb15b64862fcca13d886b07c7e2786f5ed34e5e2b9187" +dependencies = [ + "argon2", + "bcrypt-pbkdf", + "ecdsa", + "ed25519-dalek", + "hex", + "hmac 0.12.1", + "p256", + "p384", + "p521", + "rand_core 0.6.4", + "rsa", + "sec1", + "sha1", + "sha2 0.10.9", + "signature 2.2.0", + "ssh-cipher", + "ssh-encoding", + "subtle", + "zeroize", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1896,9 +2125,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.95" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ "cfg-if", "futures-util", @@ -2009,9 +2238,75 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.185" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libcrux-intrinsics" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9ee7ef66569dd7516454fe26de4e401c0c62073929803486b96744594b9632" +dependencies = [ + "core-models", + "hax-lib", +] + +[[package]] +name = "libcrux-ml-kem" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6a88086bf11bd2ec90926c749c4a427f2e59841437dbdede8cde8a96334ab" +dependencies = [ + "hax-lib", + "libcrux-intrinsics", + "libcrux-platform", + "libcrux-secrets", + "libcrux-sha3", + "libcrux-traits", + "rand 0.9.4", + "tls_codec", +] + +[[package]] +name = "libcrux-platform" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db82d058aa76ea315a3b2092f69dfbd67ddb0e462038a206e1dcd73f058c0778" +dependencies = [ + "libc", +] + +[[package]] +name = "libcrux-secrets" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4dbbf6bc9f2bc0f20dc3bea3e5c99adff3bdccf6d2a40488963da69e2ec307" +dependencies = [ + "hax-lib", +] + +[[package]] +name = "libcrux-sha3" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2400bec764d1c75b8a496d5747cffe32f1fb864a12577f0aca2f55a92021c962" +dependencies = [ + "hax-lib", + "libcrux-intrinsics", + "libcrux-platform", + "libcrux-traits", +] + +[[package]] +name = "libcrux-traits" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9adfd58e79d860f6b9e40e35127bfae9e5bd3ade33201d1347459011a2add034" +dependencies = [ + "libcrux-secrets", + "rand 0.9.4", +] [[package]] name = "libloading" @@ -2093,6 +2388,12 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + [[package]] name = "memchr" version = "2.8.0" @@ -2225,6 +2526,18 @@ dependencies = [ "nfs3_macros", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "7.1.3" @@ -2261,6 +2574,7 @@ checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", + "rand 0.8.6", ] [[package]] @@ -2355,7 +2669,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openpxe" -version = "0.5.4" +version = "0.5.5" dependencies = [ "anyhow", "axum", @@ -2377,7 +2691,7 @@ dependencies = [ [[package]] name = "openpxe-core" -version = "0.5.4" +version = "0.5.5" dependencies = [ "anyhow", "base64", @@ -2404,7 +2718,7 @@ dependencies = [ [[package]] name = "openpxe-dhcp-proxy" -version = "0.5.4" +version = "0.5.5" dependencies = [ "anyhow", "bytes", @@ -2418,7 +2732,7 @@ dependencies = [ [[package]] name = "openpxe-http-api" -version = "0.5.4" +version = "0.5.5" dependencies = [ "anyhow", "axum", @@ -2454,7 +2768,7 @@ dependencies = [ [[package]] name = "openpxe-ipxe-assets" -version = "0.5.4" +version = "0.5.5" dependencies = [ "openpxe-core", "rust-embed", @@ -2464,7 +2778,7 @@ dependencies = [ [[package]] name = "openpxe-iso-store" -version = "0.5.4" +version = "0.5.5" dependencies = [ "anyhow", "bcrypt", @@ -2477,6 +2791,8 @@ dependencies = [ "nfs3_types", "openpxe-core", "parking_lot", + "russh", + "russh-sftp", "serde", "serde_json", "sha2 0.10.9", @@ -2491,7 +2807,7 @@ dependencies = [ [[package]] name = "openpxe-tftp" -version = "0.5.4" +version = "0.5.5" dependencies = [ "anyhow", "bytes", @@ -2505,7 +2821,7 @@ dependencies = [ [[package]] name = "openpxe-webui" -version = "0.5.4" +version = "0.5.5" [[package]] name = "p256" @@ -2537,7 +2853,7 @@ version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" dependencies = [ - "base16ct", + "base16ct 0.2.0", "ecdsa", "elliptic-curve", "primeorder", @@ -2545,6 +2861,26 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "pageant" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f3a5ae18f65a85c67a77d18d42d3606c07948e3c17c1e5f74852b26589e88a5" +dependencies = [ + "base16ct 1.0.0", + "byteorder", + "bytes", + "delegate", + "futures", + "log", + "rand 0.10.1", + "sha2 0.11.0", + "thiserror 2.0.18", + "tokio", + "windows", + "windows-strings", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -2568,12 +2904,29 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "pbkdf2" version = "0.12.2" @@ -2701,6 +3054,17 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "polyval" version = "0.6.2" @@ -2756,6 +3120,28 @@ dependencies = [ "elliptic-curve", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2902,6 +3288,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20 0.10.0", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -3101,6 +3498,110 @@ dependencies = [ "zeroize", ] +[[package]] +name = "russh" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b4d036bb45d7bbe99dbfef4ec60eaeb614708d22ff107124272f8ef6b54548" +dependencies = [ + "aes", + "bitflags 2.11.1", + "block-padding", + "byteorder", + "bytes", + "cbc", + "ctr", + "curve25519-dalek", + "data-encoding", + "delegate", + "der 0.7.10", + "digest 0.10.7", + "ecdsa", + "ed25519-dalek", + "elliptic-curve", + "enum_dispatch", + "futures", + "generic-array 1.4.2", + "getrandom 0.2.17", + "hex-literal", + "hmac 0.12.1", + "home", + "inout", + "internal-russh-forked-ssh-key", + "libcrux-ml-kem", + "log", + "md5", + "num-bigint", + "p256", + "p384", + "p521", + "pageant", + "pbkdf2", + "pkcs5", + "pkcs8 0.10.2", + "rand 0.8.6", + "rand_core 0.6.4", + "ring", + "russh-cryptovec", + "russh-util", + "sec1", + "sha1", + "sha2 0.10.9", + "signature 2.2.0", + "spki 0.7.3", + "ssh-encoding", + "subtle", + "thiserror 1.0.69", + "tokio", + "typenum", + "zeroize", +] + +[[package]] +name = "russh-cryptovec" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb0ed583ff0f6b4aa44c7867dd7108df01b30571ee9423e250b4cc939f8c6cf" +dependencies = [ + "libc", + "log", + "nix", + "ssh-encoding", + "winapi", +] + +[[package]] +name = "russh-sftp" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed8949eca4163c18a8f59ff96d32cf61e9c13b9735e21ef32b3907f4aafa1a9" +dependencies = [ + "bitflags 2.11.1", + "bytes", + "chrono", + "dashmap", + "gloo-timers", + "log", + "serde", + "serde_bytes", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "wasm-bindgen-futures", +] + +[[package]] +name = "russh-util" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668424a5dde0bcb45b55ba7de8476b93831b4aa2fa6947e145f3b053e22c60b6" +dependencies = [ + "chrono", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", +] + [[package]] name = "rust-embed" version = "8.11.0" @@ -3262,9 +3763,9 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ - "base16ct", + "base16ct 0.2.0", "der 0.7.10", - "generic-array", + "generic-array 0.14.7", "pkcs8 0.10.2", "subtle", "zeroize", @@ -3295,6 +3796,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -3547,6 +4058,35 @@ dependencies = [ "der 0.8.0", ] +[[package]] +name = "ssh-cipher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +dependencies = [ + "aes", + "aes-gcm", + "cbc", + "chacha20 0.9.1", + "cipher", + "ctr", + "poly1305", + "ssh-encoding", + "subtle", +] + +[[package]] +name = "ssh-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +dependencies = [ + "base64ct", + "bytes", + "pem-rfc7468", + "sha2 0.10.9", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3805,6 +4345,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] @@ -4205,9 +4746,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.118" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -4218,9 +4759,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.68" +version = "0.4.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" dependencies = [ "js-sys", "wasm-bindgen", @@ -4228,9 +4769,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.118" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4238,9 +4779,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.118" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", @@ -4251,9 +4792,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.118" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] @@ -4307,9 +4848,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.95" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" dependencies = [ "js-sys", "wasm-bindgen", @@ -4371,6 +4912,27 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -4384,6 +4946,17 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -4412,6 +4985,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -4464,6 +5047,15 @@ dependencies = [ "windows_x86_64_msvc", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index eb7ca7e..70aa85c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ members = [ ] [workspace.package] -version = "0.5.4" +version = "0.5.5" edition = "2021" rust-version = "1.95" license = "MIT OR Apache-2.0" @@ -85,6 +85,36 @@ x509-parser = "0.18" flate2 = "1.1" base64 = "0.22" +# v0.5.5: pure-Rust SSH/SFTP client for reading remote ISO libraries +# over SFTP without a kernel mount. +# +# CRITICAL #1 — crypto backend: `default-features = false` + +# `features = ["ring"]`. russh's *default* backend is `aws-lc-rs`, which +# pulls `aws-lc-sys` (C code, fiddly under musl); the `ring` feature +# instead reuses `ring 0.17` — the exact crate+version already in the +# binary via rustls + bergshamra — so SFTP adds ZERO new C/crypto deps +# and the static-musl build stays OpenSSL-free. +# +# CRITICAL #2 — pinned to EXACTLY 0.55.0, the newest russh that +# coexists with bergshamra-crypto (our SAML core). The RustCrypto +# ecosystem is mid-transition: bergshamra-crypto pins a constellation of +# release-CANDIDATE crates (`pkcs8 =0.11.0-rc.11` and its matching +# pkcs5/spki RCs) that are API-incompatible with the STABLE versions of +# the same crates in the same semver bucket. russh 0.56+ pulls those +# stable crates (`pkcs5 0.8`), which silently replaces bergshamra's RC +# copies and breaks compilation. russh ≤0.55 stays on the previous stable +# generation (`pkcs5 0.7`, `ssh-key 0.6`), which unifies with bergshamra's +# *stable* deps and leaves the RC bucket untouched — verified to compile. +# 0.55 still has the merged `russh::keys` API (keys merged at 0.50). +# IMPORTANT: do NOT bump russh past 0.55 until bergshamra-crypto adopts +# the stable RustCrypto generation; 0.56+ will not compile in this tree. +# +# SCP was deliberately rejected: the protocol is sequential-only (no +# random access → no HTTP Range, unlike SFTP/NFS) and the mature SCP +# crates wrap libssh2 (C + OpenSSL), which would break this build. +russh = { version = "=0.55.0", default-features = false, features = ["ring"] } +russh-sftp = "2.3" + openpxe-core = { path = "crates/core" } openpxe-dhcp-proxy = { path = "crates/dhcp-proxy" } openpxe-tftp = { path = "crates/tftp" } diff --git a/crates/http-api/src/app.rs b/crates/http-api/src/app.rs index 116b5c0..0f54274 100644 --- a/crates/http-api/src/app.rs +++ b/crates/http-api/src/app.rs @@ -37,8 +37,8 @@ use openpxe_core::{ }; use openpxe_ipxe_assets::asset_bytes; use openpxe_iso_store::{ - render_template, IsoCategory, IsoMeta, IsoSource, NfsAddRequest, SmbAddRequest, SmbState, - UnattendedKind, UnattendedMeta, + render_template, IsoCategory, IsoMeta, IsoSource, NfsAddRequest, SftpAddRequest, SmbAddRequest, + SmbState, UnattendedKind, UnattendedMeta, }; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -188,6 +188,15 @@ pub fn build_router(state: AppState) -> Router { ) .route("/api/nfs-shares/:id", delete(api_nfs_shares_remove)) .route("/api/nfs-shares/:id/scan", post(api_nfs_shares_scan)) + // v0.5.5: SFTP-over-SSH share manager (pure-Rust russh client). + // Parallel to SMB/NFS so the UI reuses the same form/error/hint + // rendering. Like NFS, SFTP-sourced ISOs support Range requests. + .route( + "/api/sftp-shares", + get(api_sftp_shares_list).post(api_sftp_shares_add), + ) + .route("/api/sftp-shares/:id", delete(api_sftp_shares_remove)) + .route("/api/sftp-shares/:id/scan", post(api_sftp_shares_scan)) // Phase 4: Network info (read-only) + DNS edit. .route("/api/network", get(api_network).put(api_network_put)) // Phase 4: live-log stream + recent buffer for the Terminal tab. @@ -856,6 +865,57 @@ async fn iso_raw( Err(e) => (StatusCode::BAD_GATEWAY, format!("nfs stream: {e}")).into_response(), } } + IsoSource::Sftp { + share_id, + relative_path, + } => { + // v0.5.5: SFTP sources support Range requests because SFTP + // opens a seekable file handle (seek to offset, then bounded + // reads). Identical handling to the NFS arm above. + let total = meta.size_bytes; + let range = match parse_range(headers.get(header::RANGE), total) { + Some(triple) => triple, + None if headers.get(header::RANGE).is_some() => { + return Response::builder() + .status(StatusCode::RANGE_NOT_SATISFIABLE) + .header(header::CONTENT_RANGE, format!("bytes */{total}")) + .body(Body::empty()) + .unwrap(); + } + // No Range header — serve the whole file. + None => (0, total.saturating_sub(1), false), + }; + let (start, end, partial) = range; + let len = if total == 0 { 0 } else { end - start + 1 }; + let max_len = if total == 0 { None } else { Some(len) }; + match state + .sftp_shares + .stream_iso(share_id, relative_path, start, max_len) + .await + { + Ok(stream) => { + let body = Body::from_stream(stream); + let status = if partial { + StatusCode::PARTIAL_CONTENT + } else { + StatusCode::OK + }; + let mut builder = Response::builder() + .status(status) + .header(header::CONTENT_TYPE, "application/octet-stream") + .header(header::ACCEPT_RANGES, "bytes") + .header(header::CONTENT_LENGTH, len); + if partial { + builder = builder.header( + header::CONTENT_RANGE, + format!("bytes {start}-{end}/{total}"), + ); + } + builder.body(body).unwrap() + } + Err(e) => (StatusCode::BAD_GATEWAY, format!("sftp stream: {e}")).into_response(), + } + } } } @@ -1490,6 +1550,19 @@ async fn api_docs() -> Json { "summary": "Re-list a share for new ISOs."}, ], }, + { + "name": "SFTP shares", + "endpoints": [ + {"method": "GET", "path": "/api/sftp-shares", + "summary": "List configured SFTP-over-SSH shares with connection state and iso counts."}, + {"method": "POST", "path": "/api/sftp-shares", + "summary": "Register an SFTP share. Body: { server, export, username, port?, password? | private_key? + passphrase? }. The server's SSH host key is pinned trust-on-first-use."}, + {"method": "DELETE", "path": "/api/sftp-shares/:id", + "summary": "Forget a share, drop its entries from the ISO store, and scrub its credentials file."}, + {"method": "POST", "path": "/api/sftp-shares/:id/scan", + "summary": "Re-list a share for new ISOs."}, + ], + }, { "name": "Network", "endpoints": [ @@ -1963,6 +2036,8 @@ struct StatusResponse { smb_share_reachable: usize, nfs_share_count: usize, nfs_share_reachable: usize, + sftp_share_count: usize, + sftp_share_reachable: usize, host_bindings: usize, custom_logo: bool, branding: BrandingStatus, @@ -1983,6 +2058,9 @@ async fn api_status(State(state): State) -> Json { let smb_reachable = smb_shares.iter().filter(|m| m.reachable).count(); let nfs_shares = state.nfs_shares.list(); let nfs_reachable = nfs_shares.iter().filter(|m| m.reachable).count(); + // v0.5.5: SFTP shares fold into the same "reachable shares" tile. + let sftp_shares = state.sftp_shares.list(); + let sftp_reachable = sftp_shares.iter().filter(|m| m.reachable).count(); let isos = state.iso_store.list(); let clients = state.clients.list(); let queue_entries = state.queue.list(); @@ -2003,7 +2081,7 @@ async fn api_status(State(state): State) -> Json { .set_queue_counts(queue_entries.len() as u64, imaging as u64); state .metrics - .set_nfs_active((smb_reachable + nfs_reachable) as u64); + .set_nfs_active((smb_reachable + nfs_reachable + sftp_reachable) as u64); state.metrics.record_http(openpxe_core::HttpRoute::Api); let now = time::OffsetDateTime::now_utc(); let uptime_secs = (now - state.started_at).whole_seconds().max(0); @@ -2025,6 +2103,9 @@ async fn api_status(State(state): State) -> Json { // metric works regardless of protocol mix. nfs_share_count: nfs_shares.len(), nfs_share_reachable: nfs_reachable, + // v0.5.5: SFTP share counts, summed into the same dashboard tile. + sftp_share_count: sftp_shares.len(), + sftp_share_reachable: sftp_reachable, host_bindings: state.hosts.len(), custom_logo: state.branding.has_any_web_logo(), branding: BrandingStatus { @@ -2348,6 +2429,48 @@ async fn api_nfs_shares_scan( } } +// ─── SFTP share API (v0.5.5) ─────────────────────────────────────────────── +// +// Parallel to the NFS shares API. The pure-Rust `russh` + `russh-sftp` +// client gives us in-process listing and streaming, no subprocess. Like +// NFS (and unlike SMB), SFTP-sourced ISOs support HTTP Range requests — +// SFTP opens a seekable file handle. Auth is password OR SSH private +// key; the server's host key is pinned trust-on-first-use. + +async fn api_sftp_shares_list(State(state): State) -> Json { + Json(json!({ "shares": state.sftp_shares.list() })) +} + +async fn api_sftp_shares_add( + State(state): State, + Json(req): Json, +) -> Response { + match state.sftp_shares.add(req).await { + Ok(s) => (StatusCode::CREATED, Json(s)).into_response(), + Err(err) => (StatusCode::BAD_REQUEST, Json(err)).into_response(), + } +} + +async fn api_sftp_shares_remove( + State(state): State, + AxumPath(id): AxumPath, +) -> Response { + match state.sftp_shares.remove(&id).await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(), + } +} + +async fn api_sftp_shares_scan( + State(state): State, + AxumPath(id): AxumPath, +) -> Response { + match state.sftp_shares.rescan(&id).await { + Ok(n) => Json(json!({ "ok": true, "iso_count": n })).into_response(), + Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(), + } +} + // ─── Network info API ────────────────────────────────────────────────────── async fn api_network(State(state): State) -> Json { @@ -2715,7 +2838,16 @@ async fn api_metrics(State(state): State) -> Response { .iter() .filter(|m| m.reachable) .count(); - state.metrics.set_nfs_active((smb_ok + nfs_ok) as u64); + // v0.5.5: SFTP shares fold into the same reachable-shares gauge. + let sftp_ok = state + .sftp_shares + .list() + .iter() + .filter(|m| m.reachable) + .count(); + state + .metrics + .set_nfs_active((smb_ok + nfs_ok + sftp_ok) as u64); let now = time::OffsetDateTime::now_utc(); let uptime = (now - state.started_at).whole_seconds().max(0) as u64; diff --git a/crates/http-api/src/state.rs b/crates/http-api/src/state.rs index 69d092f..6784c97 100644 --- a/crates/http-api/src/state.rs +++ b/crates/http-api/src/state.rs @@ -5,7 +5,9 @@ use openpxe_core::{ AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, NotifyStore, SettingsStore, SsoStore, }; -use openpxe_iso_store::{IsoStore, NfsShareManager, SmbManager, SmbShareManager, UnattendedStore}; +use openpxe_iso_store::{ + IsoStore, NfsShareManager, SftpShareManager, SmbManager, SmbShareManager, UnattendedStore, +}; use std::sync::Arc; use time::OffsetDateTime; @@ -67,6 +69,13 @@ pub struct AppState { /// In-process (no subprocess); supports HTTP Range requests on /// NFS-sourced ISOs because NFSv3 READ3 takes an explicit offset. pub nfs_shares: NfsShareManager, + /// v0.5.5: SFTP-over-SSH share manager — pure-Rust userspace + /// consumer via `russh` + `russh-sftp` (ring backend, no OpenSSL). + /// Ships alongside SMB/NFS as the third remote-library protocol. + /// In-process (no subprocess, no kernel mount); supports HTTP Range + /// requests because SFTP opens a seekable file handle. Authenticates + /// the server's SSH host key on a trust-on-first-use basis. + pub sftp_shares: SftpShareManager, /// v0.5.2: uploaded unattended-install answer files (Kickstart / /// Preseed / Autoinstall / Windows answer files). Served on demand to /// booting clients with per-host hostname/IP/MAC templating; lives in diff --git a/crates/http-api/src/terminal.rs b/crates/http-api/src/terminal.rs index c54dfc9..a296391 100644 --- a/crates/http-api/src/terminal.rs +++ b/crates/http-api/src/terminal.rs @@ -96,6 +96,8 @@ async fn dispatch(state: &AppState, argv: &[String]) -> Result { "share" | "smb-share" => smb_share_command(state, tail).await, "smb" => smb_command(state, tail).await, "nfs" => nfs_share_command(state, tail).await, + // v0.5.5: SFTP-over-SSH remote shares (in-process russh client). + "sftp" => sftp_share_command(state, tail).await, "log" => log_command(state, tail), "whoami" => Ok("operator".to_string()), "echo" => Ok(tail.join(" ")), @@ -118,17 +120,21 @@ fn status_text(s: &AppState) -> String { // v0.4.67: NFSv3 sources too. let nfs_shares = s.nfs_shares.list(); let nfs_reachable = nfs_shares.iter().filter(|m| m.reachable).count(); + // v0.5.5: SFTP-over-SSH sources too. + let sftp_shares = s.sftp_shares.list(); + let sftp_reachable = sftp_shares.iter().filter(|m| m.reachable).count(); format!( "OpenPXE {ver}\n\ base url: {base}\n\ interface: {nic}\n\ uptime: {up}\n\ - isos: {n_isos} (local: {n_local}, smb: {n_smb}, nfs: {n_nfs})\n\ + isos: {n_isos} (local: {n_local}, smb: {n_smb}, nfs: {n_nfs}, sftp: {n_sftp})\n\ clients: {n_clients}\n\ queue: {n_entries}\n\ smb server: {smb}\n\ smb shares: {n_smb_total} configured ({n_smb_active} reachable)\n\ - nfs shares: {n_nfs_total} configured ({n_nfs_active} reachable)\n", + nfs shares: {n_nfs_total} configured ({n_nfs_active} reachable)\n\ + sftp shares: {n_sftp_total} configured ({n_sftp_active} reachable)\n", ver = env!("CARGO_PKG_VERSION"), base = s.public_base_url, nic = if s.nic_name.is_empty() { @@ -150,6 +156,10 @@ fn status_text(s: &AppState) -> String { .iter() .filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Nfs { .. })) .count(), + n_sftp = isos + .iter() + .filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Sftp { .. })) + .count(), n_clients = clients.len(), n_entries = queue_entries.len(), smb = smb.map_or_else(|| "(disabled)".into(), |s| format!("{s:?}")), @@ -157,6 +167,8 @@ fn status_text(s: &AppState) -> String { n_smb_active = smb_reachable, n_nfs_total = nfs_shares.len(), n_nfs_active = nfs_reachable, + n_sftp_total = sftp_shares.len(), + n_sftp_active = sftp_reachable, ) } @@ -177,6 +189,8 @@ fn isos_text(s: &AppState) -> String { openpxe_iso_store::IsoSource::Smb { share_id, .. } => format!("smb:{share_id}"), // v0.4.67: NFSv3 via in-process nfs3_client. openpxe_iso_store::IsoSource::Nfs { share_id, .. } => format!("nfs:{share_id}"), + // v0.5.5: SFTP-over-SSH via in-process russh. + openpxe_iso_store::IsoSource::Sftp { share_id, .. } => format!("sftp:{share_id}"), }; let _ = writeln!( out, @@ -321,11 +335,9 @@ async fn smb_share_command(s: &AppState, args: &[String]) -> Result { // share add //server/share [guest|user:password] - let target = args - .get(1) - .ok_or_else(|| { - "usage: share add //server/share [guest|user:password]".to_string() - })?; + let target = args.get(1).ok_or_else(|| { + "usage: share add //server/share [guest|user:password]".to_string() + })?; // Accept either `//server/share` (UNC-style) or // `server:share` (shorter to type). let stripped = target.trim_start_matches('/').trim_start_matches('\\'); @@ -401,11 +413,7 @@ async fn nfs_share_command(s: &AppState, args: &[String]) -> Result Result Result { + match args.first().map(String::as_str) { + None | Some("list") => { + let shares = s.sftp_shares.list(); + if shares.is_empty() { + return Ok("(no SFTP shares configured)".into()); + } + let mut out = String::new(); + let _ = writeln!(out, "{:<24} {:<7} {:<6} TARGET", "ID", "STATUS", "ISOS"); + for m in shares { + let status = if m.reachable { "ok" } else { "down" }; + let _ = writeln!( + out, + "{:<24} {:<7} {:<6} {}@{}:{}", + truncate(&m.id, 24), + status, + m.iso_count, + m.username, + m.server, + m.export, + ); + if let Some(e) = m.last_error { + let _ = writeln!(out, " error: {e}"); + } + if let Some(h) = m.last_hint { + let _ = writeln!(out, " hint: {h}"); + } + } + Ok(out) + } + Some("add") => { + // sftp add @: [port] + let target = args.get(1).ok_or_else(|| { + "usage: sftp add @: [port] \ + (key auth: use the WebUI)" + .to_string() + })?; + let password = args + .get(2) + .ok_or_else(|| "a password is required (key auth: use the WebUI)".to_string())?; + let (user, rest) = target + .split_once('@') + .ok_or_else(|| "target must be 'user@server:/export'".to_string())?; + let (server, export) = rest + .split_once(':') + .ok_or_else(|| "target must be 'user@server:/export'".to_string())?; + let port = args.get(3).and_then(|s| s.parse::().ok()); + let req = openpxe_iso_store::SftpAddRequest { + server: server.to_string(), + export: export.to_string(), + username: Some(user.to_string()), + port, + password: Some(password.clone()), + private_key: None, + passphrase: None, + }; + match s.sftp_shares.add(req).await { + Ok(m) => Ok(format!("added {} ({} isos)", m.id, m.iso_count)), + Err(e) => { + let mut out = format!("add failed: {}", e.error); + if let Some(h) = e.hint { + out.push_str("\nhint: "); + out.push_str(&h); + } + Err(out) + } + } + } + Some("remove") => { + let id = args + .get(1) + .ok_or_else(|| "usage: sftp remove ".to_string())?; + match s.sftp_shares.remove(id).await { + Ok(()) => Ok(format!("removed {id}")), + Err(e) => Err(format!("remove failed: {e}")), + } + } + Some("scan") => { + let id = args + .get(1) + .ok_or_else(|| "usage: sftp scan ".to_string())?; + match s.sftp_shares.rescan(id).await { + Ok(n) => Ok(format!("re-scanned {id}: {n} isos")), + Err(e) => Err(format!("scan failed: {e}")), + } + } + Some(other) => Err(format!( + "unknown sftp subcommand: {other}\ntry: sftp [list|add|remove|scan]" + )), + } +} + // ── smb ──────────────────────────────────────────────────────────────── #[allow(clippy::unused_async)] @@ -622,6 +728,11 @@ OpenPXE terminal — available commands: nfs remove forget an NFS share nfs scan re-list an NFS share for new ISOs + sftp list list configured SFTP-over-SSH shares + sftp add @: [port] add an SFTP share (key auth: WebUI) + sftp remove forget an SFTP share + sftp scan re-list an SFTP share for new ISOs + smb status outbound Samba state (Windows install media) smb start | stop | reload control the outbound smbd diff --git a/crates/http-api/tests/full_flow.rs b/crates/http-api/tests/full_flow.rs index c499e44..d044058 100644 --- a/crates/http-api/tests/full_flow.rs +++ b/crates/http-api/tests/full_flow.rs @@ -14,7 +14,7 @@ use axum::body::Body; use axum::http::{header, Request, StatusCode}; use openpxe_core::{ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, SettingsStore}; use openpxe_http_api::{build_router, AppState}; -use openpxe_iso_store::{IsoStore, NfsShareManager, SmbShareManager}; +use openpxe_iso_store::{IsoStore, NfsShareManager, SftpShareManager, SmbShareManager}; use tempfile::tempdir; use tower::ServiceExt; @@ -96,6 +96,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) { let settings = SettingsStore::load_or_default(dir.path()); let smb_shares = SmbShareManager::new(dir.path(), iso_store.clone()); let nfs_shares = NfsShareManager::new(dir.path(), iso_store.clone()); + let sftp_shares = SftpShareManager::new(dir.path(), iso_store.clone()); let unattended = openpxe_iso_store::UnattendedStore::new(dir.path().join("unattended")); unattended.ensure_dir().await.unwrap(); let log_bus = LogBus::new(64); @@ -124,6 +125,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) { smb: None, smb_shares, nfs_shares, + sftp_shares, unattended, uploads: openpxe_http_api::uploads::UploadSessions::default(), log_bus, diff --git a/crates/iso-store/Cargo.toml b/crates/iso-store/Cargo.toml index d5f6849..626dba2 100644 --- a/crates/iso-store/Cargo.toml +++ b/crates/iso-store/Cargo.toml @@ -39,6 +39,10 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg", # kernel mount. See crates/iso-store/src/nfs_share.rs for usage. nfs3_client = { workspace = true } nfs3_types = { workspace = true } +# v0.5.5: pure-Rust SSH/SFTP client (ring backend) for the SFTP remote +# share path. See crates/iso-store/src/sftp_share.rs for usage. +russh = { workspace = true } +russh-sftp = { workspace = true } # Needed for the Stream trait that wraps the mpsc receiver feeding # NFS read-loop bytes into axum's Body::from_stream. futures = { workspace = true } diff --git a/crates/iso-store/src/lib.rs b/crates/iso-store/src/lib.rs index f947a62..26e3205 100644 --- a/crates/iso-store/src/lib.rs +++ b/crates/iso-store/src/lib.rs @@ -20,6 +20,7 @@ pub mod entry; pub mod introspect; pub mod nfs_share; pub mod pxe_logo; +pub mod sftp_share; pub mod smb; pub mod smb_share; pub mod store; @@ -40,6 +41,13 @@ pub use smb_share::{SmbAddRequest, SmbShare, SmbShareError, SmbShareManager, Smb // "works in any container" property as SMB, plus support for HTTP // Range requests because NFSv3 READ3 takes an explicit offset. pub use nfs_share::{NfsAddRequest, NfsShare, NfsShareError, NfsShareManager, NfsStream}; +// v0.5.5: SFTP-over-SSH remote shares via the pure-Rust `russh` + +// `russh-sftp` crates (ring backend — no OpenSSL, no new C deps). Like +// NFS, supports HTTP Range requests because SFTP opens a seekable file +// handle. See crates/iso-store/src/sftp_share.rs. +pub use sftp_share::{ + SftpAddRequest, SftpAuthKind, SftpShare, SftpShareError, SftpShareManager, SftpStream, +}; pub use store::{ generate_boot_entries_for, slugify_str, IsoCategory, IsoMeta, IsoSource, IsoStore, UploadHandle, }; diff --git a/crates/iso-store/src/sftp_share.rs b/crates/iso-store/src/sftp_share.rs new file mode 100644 index 0000000..35512d9 --- /dev/null +++ b/crates/iso-store/src/sftp_share.rs @@ -0,0 +1,1145 @@ +//! SFTP share consumer — in-process userspace SSH/SFTP client. +//! +//! v0.5.5 adds a third remote-library protocol alongside SMB (v0.4.65) +//! and NFS (v0.4.67). Like NFS it's a pure-Rust, in-process client — +//! no subprocess, no kernel mount, no `CAP_SYS_ADMIN` — so it works in +//! every container the other two work in (Unraid, restricted-SCC +//! OpenShift). +//! +//! ## Why SFTP and not SCP +//! +//! The obvious "SSH-based shares" ask is SCP, but SCP is the wrong +//! protocol for this job twice over: (1) it's a sequential whole-file +//! stream with no random access, so it could only ever behave like the +//! SMB path (whole-file, no HTTP Range); and (2) the mature Rust SCP +//! crates wrap libssh2 — a C library that needs OpenSSL — which would +//! break the static-musl, OpenSSL-free build. SFTP sidesteps both: +//! `SSH_FXP_READ` takes an explicit offset (so Range works, like NFS), +//! and `russh` + `russh-sftp` are pure Rust on the `ring` crypto +//! backend already in the binary (rustls + bergshamra), adding zero new +//! C dependencies. +//! +//! ## How it works +//! +//! 1. Operator submits `{ server, export, username, port, +secret }` +//! via the Storage tab. The secret is either a password or an SSH +//! private key (PEM, optionally passphrase-protected) — never both. +//! 2. We write the secret to a 0600 JSON file under +//! `/sftp_creds/.cred`. The share metadata +//! (server/user/export/host-key fingerprint) lives in +//! `/sftp_shares.json`; the secret never touches that file. +//! 3. We connect over SSH, authenticate, open the `sftp` subsystem, and +//! `READDIR` the export directory for `*.iso` files (size comes back +//! in the same listing — no extra round trip). +//! 4. Each ISO registers with the `IsoStore` as +//! `IsoSource::Sftp { share_id, relative_path }`. +//! 5. On a PXE client request the HTTP handler asks for a byte stream +//! at an offset; we open the file, `seek`, and pipe bounded chunks +//! into the response body. HTTP Range → `206 Partial Content`, same +//! as NFS. +//! +//! ## Host-key trust (TOFU) +//! +//! On the first successful connect we pin the server's SSH host-key +//! fingerprint (SHA256) into the share. Every later connect compares +//! the presented key against the pinned one and refuses the connection +//! on a mismatch — classic trust-on-first-use, like a fresh +//! `known_hosts`. A rebuilt server (new host key) surfaces a clear +//! "host key changed" error; the operator removes and re-adds to trust +//! the new key. This is strictly more authentication than NFS/SMB do +//! (neither authenticates the server at all), and it's cheap over SSH. +//! +//! ## Same public surface +//! +//! `SftpShareManager` mirrors `NfsShareManager` / `SmbShareManager`; +//! `SftpShare` mirrors `NfsShare` / `SmbShare`; the `{error, stderr, +//! hint}` error shape is shared so the storage tab renders all three +//! protocols through one code path. + +use crate::introspect::{DistroFamily, IntrospectionReport}; +use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore}; +use bytes::Bytes; +use openpxe_core::{Error, Result}; +use parking_lot::Mutex; +use russh::client; +use russh::keys::{decode_secret_key, HashAlg, PrivateKeyWithHashAlg, PublicKey}; +use russh_sftp::client::SftpSession; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::io::{SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; +use time::OffsetDateTime; +use tokio::io::{AsyncReadExt, AsyncSeekExt}; + +/// Default TCP port for SSH (and thus the SFTP subsystem). +const DEFAULT_SFTP_PORT: u16 = 22; + +/// How long we wait for the TCP connect + SSH handshake before giving +/// up. Matches the shape of the NFS/SMB pre-flight timeouts so the UI +/// banner reads consistently across protocols. The SSH key exchange is +/// a couple of round trips plus some asymmetric crypto, so we give it a +/// little more headroom than the bare TCP probe. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); + +/// Bounded TCP pre-flight so a wrong IP / closed port surfaces a clean +/// error before we spin up the (heavier) SSH handshake. +const PROBE_TIMEOUT: Duration = Duration::from_secs(4); + +/// Read chunk size for streaming. 64 KiB keeps in-flight memory bounded +/// and matches the NFS path; russh-sftp pipelines reads internally so +/// the chunk size is about response-body granularity, not throughput. +const READ_CHUNK_BYTES: usize = 64 * 1024; + +/// Bound on the in-flight queue between the read-loop task and the HTTP +/// body stream. 16 * 64 KiB ≈ 1 MiB max buffer per stream. +const STREAM_BUFFER_DEPTH: usize = 16; + +/// Which credential the share authenticates with. The secret itself +/// lives in the 0600 creds file, never here. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SftpAuthKind { + Password, + Key, +} + +/// One configured SFTP share. The id is derived from server+export so +/// re-adding the same coordinates is idempotent. No secret is stored on +/// this struct — it's safe to serialize into the API JSON. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SftpShare { + pub id: String, + pub server: String, + /// Directory on the server holding the ISOs (e.g. "/srv/isos"). + pub export: String, + /// SSH username. + pub username: String, + /// Which credential kind this share uses. The UI echoes it; the + /// secret lives in the creds file. + pub auth: SftpAuthKind, + /// TCP port — 22 unless overridden. + #[serde(default = "default_port")] + pub port: u16, + /// Pinned SSH host-key fingerprint (SHA256, e.g. "SHA256:abc…"). + /// `None` until the first successful connect; once set, a changed + /// key is refused (TOFU). Public information — safe to surface. + #[serde(default)] + pub host_key_fingerprint: Option, + pub last_error: Option, + pub last_hint: Option, + #[serde(with = "time::serde::rfc3339::option")] + pub last_scan: Option, + pub iso_count: u32, + pub reachable: bool, +} + +/// Submission from the UI / API. Exactly one of `password` / +/// `private_key` should be set. +#[derive(Debug, Clone, Deserialize)] +pub struct SftpAddRequest { + pub server: String, + pub export: String, + #[serde(default)] + pub username: Option, + #[serde(default)] + pub port: Option, + #[serde(default)] + pub password: Option, + /// PEM-encoded OpenSSH private key, pasted by the operator. + #[serde(default)] + pub private_key: Option, + /// Passphrase for an encrypted private key. Optional. + #[serde(default)] + pub passphrase: Option, +} + +fn default_port() -> u16 { + DEFAULT_SFTP_PORT +} + +/// The secret material for a share, persisted to a 0600 sidecar JSON +/// file. Never serialized into the public share JSON or API responses. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct SftpCreds { + #[serde(default)] + password: Option, + #[serde(default)] + private_key: Option, + #[serde(default)] + passphrase: Option, +} + +/// Structured error surfaced to the API and rendered in the UI. Same +/// shape as `NfsShareError` / `SmbShareError`. +#[derive(Debug, Clone, Serialize)] +pub struct SftpShareError { + pub error: String, + pub stderr: String, + pub hint: Option, +} + +impl SftpShareError { + fn from_raw(error: impl Into, stderr: impl Into) -> Self { + let stderr = stderr.into(); + let error = error.into(); + let hint = hint_for(&stderr).or_else(|| hint_for(&error)); + Self { + error, + stderr, + hint, + } + } +} + +#[derive(Debug, Default)] +struct Inner { + shares: HashMap, +} + +/// Manages SFTP shares. Cheap to clone — internal state is +/// `Arc>`. +#[derive(Debug, Clone)] +pub struct SftpShareManager { + creds_root: Arc, + state_path: Arc, + inner: Arc>, + iso_store: IsoStore, + /// Serializes scan operations on the same manager for predictable + /// log output; each scan opens its own SSH connection. + op_lock: Arc>, +} + +impl SftpShareManager { + /// Construct a manager. Secrets live under `/sftp_creds/` + /// (0600); state persists to `/sftp_shares.json`. + #[must_use] + pub fn new(work_dir: &Path, iso_store: IsoStore) -> Self { + Self { + creds_root: Arc::new(work_dir.join("sftp_creds")), + state_path: Arc::new(work_dir.join("sftp_shares.json")), + inner: Arc::new(Mutex::new(Inner::default())), + iso_store, + op_lock: Arc::new(tokio::sync::Mutex::new(())), + } + } + + /// Load persisted state and re-scan every share. Per-share failures + /// are logged but never propagated — startup must not block on a + /// single offline server. + pub async fn load_and_rescan(&self) -> Result<()> { + tokio::fs::create_dir_all(self.creds_root.as_path()).await?; + let shares = match tokio::fs::read_to_string(self.state_path.as_path()).await { + Ok(text) => serde_json::from_str::>(&text).unwrap_or_default(), + Err(_) => Vec::new(), + }; + for mut s in shares { + s.last_error = None; + s.last_hint = None; + s.reachable = false; + self.inner.lock().shares.insert(s.id.clone(), s.clone()); + if let Err(e) = self.rescan_inner(&s.id).await { + tracing::warn!( + target: "openpxe::sftp", + id = %s.id, server = %s.server, export = %s.export, + "rescan on startup failed: {e}" + ); + } + } + Ok(()) + } + + /// Register an SFTP share. Validates, writes the creds file, probes + /// connectivity by performing a real SSH connect + SFTP READDIR, + /// pins the host key (TOFU), and registers the resulting ISOs. + pub async fn add(&self, req: SftpAddRequest) -> std::result::Result { + let server = normalize_server(&req.server); + let export = req.export.trim().to_string(); + if server.is_empty() { + return Err(SftpShareError::from_raw("server is required", "")); + } + if !export.starts_with('/') { + return Err(SftpShareError::from_raw( + "export path must be absolute (start with '/', e.g. /srv/isos)", + "", + )); + } + if server.contains('\0') || export.contains('\0') { + return Err(SftpShareError::from_raw("NUL bytes are not allowed", "")); + } + let username = req.username.unwrap_or_default().trim().to_string(); + if username.is_empty() { + return Err(SftpShareError::from_raw("username is required", "")); + } + + // Exactly one secret. Empty strings count as "not provided". + let password = req.password.filter(|s| !s.is_empty()); + let private_key = req.private_key.filter(|s| !s.trim().is_empty()); + let (auth, creds) = match (password, private_key) { + (Some(_), Some(_)) => { + return Err(SftpShareError::from_raw( + "provide either a password or a private key, not both", + "", + )); + } + (Some(pw), None) => ( + SftpAuthKind::Password, + SftpCreds { + password: Some(pw), + ..Default::default() + }, + ), + (None, Some(key)) => ( + SftpAuthKind::Key, + SftpCreds { + private_key: Some(key), + passphrase: req.passphrase.filter(|s| !s.is_empty()), + ..Default::default() + }, + ), + (None, None) => { + return Err(SftpShareError::from_raw( + "a password or an SSH private key is required", + "", + )); + } + }; + let port = req.port.filter(|p| *p != 0).unwrap_or(DEFAULT_SFTP_PORT); + let id = share_id(&server, &export); + + // Pre-flight TCP probe so a wrong IP / firewall surfaces a clean + // error instead of a slow SSH-handshake timeout. + if let Err((err, hint)) = tcp_probe(&server, port).await { + return Err(SftpShareError { + error: err, + stderr: String::new(), + hint: Some(hint), + }); + } + + // Persist the secret (0600) before we register the spec. + if let Err(e) = self.write_creds(&id, &creds).await { + return Err(SftpShareError::from_raw( + format!("could not write credentials file: {e}"), + "", + )); + } + + let spec = SftpShare { + id: id.clone(), + server, + export, + username, + auth, + port, + host_key_fingerprint: None, + last_error: None, + last_hint: None, + last_scan: None, + iso_count: 0, + reachable: false, + }; + self.inner.lock().shares.insert(id.clone(), spec); + self.persist_locked(); + + if let Err(e) = self.rescan_inner(&id).await { + // Clean up the creds file and the half-registered share so a + // failed add doesn't leave a dead row behind. + let _ = tokio::fs::remove_file(self.creds_path_for(&id)).await; + let m = self.get(&id); + self.inner.lock().shares.remove(&id); + self.persist_locked(); + return Err(SftpShareError { + error: m + .as_ref() + .and_then(|m| m.last_error.clone()) + .unwrap_or_else(|| e.to_string()), + stderr: String::new(), + hint: m.and_then(|m| m.last_hint), + }); + } + Ok(self.get(&id).expect("just inserted")) + } + + /// Remove a share. Drops every ISO sourced from it and scrubs the + /// creds file. Idempotent. + pub async fn remove(&self, id: &str) -> Result<()> { + let removed = self.inner.lock().shares.remove(id).is_some(); + if removed { + self.iso_store.drop_external_source(id); + let _ = tokio::fs::remove_file(self.creds_path_for(id)).await; + self.persist_locked(); + } + Ok(()) + } + + /// Re-walk a share for new / removed ISOs. + pub async fn rescan(&self, id: &str) -> Result { + self.rescan_inner(id).await + } + + #[must_use] + pub fn list(&self) -> Vec { + let g = self.inner.lock(); + let mut v: Vec<_> = g.shares.values().cloned().collect(); + v.sort_by(|a, b| a.id.cmp(&b.id)); + v + } + + #[must_use] + pub fn get(&self, id: &str) -> Option { + self.inner.lock().shares.get(id).cloned() + } + + /// Open a byte stream reading `filename` out of share `share_id`, + /// starting at `start_offset` and reading at most `max_len` bytes. + /// Supports HTTP Range requests because SFTP opens a file handle we + /// can `seek` into — the same protocol-level advantage NFS has over + /// the SMB userspace path. + pub async fn stream_iso( + &self, + share_id: &str, + filename: &str, + start_offset: u64, + max_len: Option, + ) -> Result { + let share = self + .get(share_id) + .ok_or_else(|| Error::Invalid(format!("no such SFTP share '{share_id}'")))?; + // Flat path model (matching NFS/SMB): ISOs live at the export + // root, no nested directories. Reject anything that looks like a + // path so a crafted name can't escape the export. + if filename.contains('/') || filename.contains('\\') || filename.contains("..") { + return Err(Error::Invalid(format!("invalid filename '{filename}'"))); + } + let creds = self + .read_creds(share_id) + .await + .map_err(|e| Error::Invalid(format!("could not read credentials: {e}")))?; + + let params = ConnParams::from_share(&share); + let fname = filename.to_string(); + let (tx, rx) = tokio::sync::mpsc::channel::>(STREAM_BUFFER_DEPTH); + + // Spawn a task that owns the SSH connection for the lifetime of + // the stream — like the NFS path, each stream gets its own + // connection so there's no sharing across scan/stream. + let task = tokio::spawn(async move { + let result = + stream_loop(¶ms, &creds, &fname, start_offset, max_len, tx.clone()).await; + if let Err(e) = result { + let _ = tx.send(Err(std::io::Error::other(e.to_string()))).await; + } + }); + + Ok(SftpStream { rx, _task: task }) + } + + // ── internals ───────────────────────────────────────────────────── + + fn creds_path_for(&self, id: &str) -> PathBuf { + self.creds_root.join(format!("{id}.cred")) + } + + async fn rescan_inner(&self, id: &str) -> Result { + let _g = self.op_lock.lock().await; + let share = self + .get(id) + .ok_or_else(|| Error::Invalid(format!("no such share '{id}'")))?; + let now = OffsetDateTime::now_utc(); + + let creds = match self.read_creds(id).await { + Ok(c) => c, + Err(e) => { + let msg = format!("could not read credentials: {e}"); + self.update_status(id, 0, false, Some(msg.clone()), None, now); + return Err(Error::Invalid(msg)); + } + }; + + // Drop prior entries so a deleted file disappears from the store + // on the next scan. + self.iso_store.drop_external_source(id); + + let params = ConnParams::from_share(&share); + let (listing, fingerprint) = match list_isos(¶ms, &creds).await { + Ok(v) => v, + Err(err) => { + let stderr = err.to_string(); + let hint = hint_for(&stderr); + self.update_status(id, 0, false, Some(stderr.clone()), hint, now); + return Err(Error::Invalid(stderr)); + } + }; + + let mut count = 0u32; + for entry in listing { + let iso_id = format!("sftp-{}-{}", share.id, slugify_str(&entry.filename)); + // Same as NFS/SMB: no over-the-network introspection yet, so + // register `Unknown` and let the boot-entry generator fall + // back to filename-based detection. SFTP *could* do bounded + // PVD reads (it has random access) — a follow-up can add it. + let report = IntrospectionReport { + family: DistroFamily::Unknown, + volume_label: None, + kernel_path: None, + initrd_paths: Vec::new(), + has_boot_wim: false, + }; + let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report); + let source = IsoSource::Sftp { + share_id: share.id.clone(), + relative_path: entry.filename.clone(), + }; + self.iso_store.register_external( + iso_id, + entry.filename, + entry.size, + report, + boot_entries, + source, + ); + count += 1; + } + + // Pin the host key (TOFU) on first sight; later scans connect + // with it as the expected key and would have failed above on a + // mismatch, so this only ever sets or re-confirms. + self.pin_fingerprint(id, fingerprint); + self.update_status(id, count, true, None, None, now); + tracing::info!( + target: "openpxe::sftp", + id = %id, server = %share.server, export = %share.export, + iso_count = count, + "SFTP share scanned" + ); + Ok(count) + } + + fn pin_fingerprint(&self, id: &str, fingerprint: String) { + if fingerprint.is_empty() { + return; + } + let mut g = self.inner.lock(); + if let Some(s) = g.shares.get_mut(id) { + if s.host_key_fingerprint.is_none() { + s.host_key_fingerprint = Some(fingerprint); + } + } + } + + fn update_status( + &self, + id: &str, + iso_count: u32, + reachable: bool, + err: Option, + hint: Option, + ts: OffsetDateTime, + ) { + if let Some(s) = self.inner.lock().shares.get_mut(id) { + s.iso_count = iso_count; + s.reachable = reachable; + s.last_error = err; + s.last_hint = hint; + s.last_scan = Some(ts); + } + self.persist_locked(); + } + + async fn write_creds(&self, id: &str, creds: &SftpCreds) -> std::io::Result<()> { + tokio::fs::create_dir_all(self.creds_root.as_path()).await?; + let body = serde_json::to_vec(creds).map_err(std::io::Error::other)?; + let path = self.creds_path_for(id); + // Synchronous write to set 0600 atomically with create — std has + // no async OpenOptions+mode on stable. + tokio::task::spawn_blocking(move || -> std::io::Result<()> { + use std::os::unix::fs::OpenOptionsExt; + let mut f = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(&path)?; + f.write_all(&body)?; + Ok(()) + }) + .await + .map_err(std::io::Error::other)??; + Ok(()) + } + + async fn read_creds(&self, id: &str) -> std::io::Result { + let body = tokio::fs::read(self.creds_path_for(id)).await?; + serde_json::from_slice(&body).map_err(std::io::Error::other) + } + + fn persist_locked(&self) { + let shares: Vec = self.inner.lock().shares.values().cloned().collect(); + let path = self.state_path.as_path(); + let tmp = path.with_extension("json.tmp"); + let body = match serde_json::to_vec_pretty(&shares) { + Ok(b) => b, + Err(e) => { + tracing::warn!(target: "openpxe::sftp", "serialize: {e}"); + return; + } + }; + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Err(e) = std::fs::write(&tmp, body) { + tracing::warn!(target: "openpxe::sftp", "write tmp: {e}"); + return; + } + if let Err(e) = std::fs::rename(&tmp, path) { + tracing::warn!(target: "openpxe::sftp", "rename: {e}"); + } + } +} + +/// HTTP body stream for an SFTP-sourced ISO read. Implements +/// `Stream>` so axum can build a response body +/// via `Body::from_stream`. +#[derive(Debug)] +pub struct SftpStream { + rx: tokio::sync::mpsc::Receiver>, + /// Kept alive so the read-loop task (which owns the SSH connection) + /// isn't cancelled while the client is still consuming. Dropping the + /// stream cancels the task — the right behaviour on client disconnect. + _task: tokio::task::JoinHandle<()>, +} + +impl futures::Stream for SftpStream { + type Item = std::io::Result; + fn poll_next( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + self.rx.poll_recv(cx) + } +} + +/// Connection coordinates extracted from a share (no secret). +#[derive(Debug, Clone)] +struct ConnParams { + server: String, + port: u16, + username: String, + export: String, + /// Pinned host key to enforce, or `None` for trust-on-first-use. + expected_fingerprint: Option, +} + +impl ConnParams { + fn from_share(s: &SftpShare) -> Self { + Self { + server: s.server.clone(), + port: s.port, + username: s.username.clone(), + export: s.export.clone(), + expected_fingerprint: s.host_key_fingerprint.clone(), + } + } +} + +#[derive(Debug, Clone)] +struct SftpListEntry { + filename: String, + size: u64, +} + +/// A live SSH session plus its SFTP subsystem. The session `Handle` +/// must outlive the `SftpSession` — dropping the last `Handle` closes +/// the SSH connection out from under the subsystem channel. +struct SftpConn { + _session: client::Handle, + sftp: SftpSession, +} + +/// russh client handler implementing trust-on-first-use host-key +/// verification. We never construct an `Err` from `check_server_key`; +/// returning `Ok(false)` makes russh abort the handshake, and the +/// caller distinguishes "host key mismatch" from other connect failures +/// by comparing the captured `observed` fingerprint to the expected one. +struct TofuHandler { + expected: Option, + observed: Arc>>, +} + +impl client::Handler for TofuHandler { + type Error = russh::Error; + + async fn check_server_key( + &mut self, + server_public_key: &PublicKey, + ) -> std::result::Result { + let fp = server_public_key + .fingerprint(HashAlg::default()) + .to_string(); + *self.observed.lock() = Some(fp.clone()); + match &self.expected { + Some(exp) if exp != &fp => Ok(false), // mismatch → abort handshake + _ => Ok(true), + } + } +} + +/// Connect, authenticate, and open the SFTP subsystem. Returns the live +/// connection plus the observed host-key fingerprint (for TOFU pinning). +async fn connect( + p: &ConnParams, + creds: &SftpCreds, +) -> std::result::Result<(SftpConn, String), SftpClientError> { + let observed = Arc::new(Mutex::new(None::)); + let handler = TofuHandler { + expected: p.expected_fingerprint.clone(), + observed: observed.clone(), + }; + let config = Arc::new(client::Config::default()); + + let connect_fut = client::connect(config, (p.server.as_str(), p.port), handler); + let mut session = match tokio::time::timeout(CONNECT_TIMEOUT, connect_fut).await { + Ok(Ok(s)) => s, + Ok(Err(e)) => { + // Distinguish a host-key mismatch (handshake aborted by our + // handler) from a generic connect failure. + if let (Some(exp), Some(got)) = (&p.expected_fingerprint, observed.lock().clone()) { + if exp != &got { + return Err(SftpClientError::HostKeyMismatch { + expected: exp.clone(), + got, + }); + } + } + return Err(SftpClientError::Connect(e.to_string())); + } + Err(_) => return Err(SftpClientError::Timeout(p.server.clone(), p.port)), + }; + let observed_fp = observed.lock().clone().unwrap_or_default(); + + let authed = if let Some(pw) = creds.password.as_deref() { + session + .authenticate_password(p.username.as_str(), pw) + .await + .map_err(|e| SftpClientError::Auth(e.to_string()))? + .success() + } else if let Some(pem) = creds.private_key.as_deref() { + let key = decode_secret_key(pem, creds.passphrase.as_deref()) + .map_err(|e| SftpClientError::Auth(format!("private key: {e}")))?; + // For RSA keys, request rsa-sha2-256 (modern servers reject the + // legacy ssh-rsa/SHA-1); `new` ignores the hash for other key + // types (ed25519, ecdsa), so this is safe to pass unconditionally. + let pk = PrivateKeyWithHashAlg::new(Arc::new(key), Some(HashAlg::Sha256)); + session + .authenticate_publickey(p.username.as_str(), pk) + .await + .map_err(|e| SftpClientError::Auth(e.to_string()))? + .success() + } else { + return Err(SftpClientError::Auth("no credential configured".into())); + }; + if !authed { + return Err(SftpClientError::AuthRejected); + } + + let channel = session + .channel_open_session() + .await + .map_err(|e| SftpClientError::Connect(e.to_string()))?; + channel + .request_subsystem(true, "sftp") + .await + .map_err(|e| SftpClientError::Protocol(e.to_string()))?; + let sftp = SftpSession::new(channel.into_stream()) + .await + .map_err(|e| SftpClientError::Sftp(e.to_string()))?; + + Ok(( + SftpConn { + _session: session, + sftp, + }, + observed_fp, + )) +} + +/// Connect and READDIR the export for `*.iso` files. Returns the listing +/// plus the observed host-key fingerprint. +async fn list_isos( + p: &ConnParams, + creds: &SftpCreds, +) -> std::result::Result<(Vec, String), SftpClientError> { + let (conn, fingerprint) = connect(p, creds).await?; + let dir = conn + .sftp + .read_dir(p.export.as_str()) + .await + .map_err(|e| SftpClientError::Sftp(e.to_string()))?; + + let mut entries = Vec::new(); + for entry in dir { + let name = entry.file_name(); + if name == "." || name == ".." { + continue; + } + let md = entry.metadata(); + if md.is_dir() { + continue; + } + if !name.to_ascii_lowercase().ends_with(".iso") { + continue; + } + entries.push(SftpListEntry { + filename: name, + size: md.len(), + }); + } + // `conn` drops here, closing the SSH session. + Ok((entries, fingerprint)) +} + +async fn stream_loop( + p: &ConnParams, + creds: &SftpCreds, + filename: &str, + start_offset: u64, + max_len: Option, + tx: tokio::sync::mpsc::Sender>, +) -> std::result::Result<(), SftpClientError> { + let (conn, _fp) = connect(p, creds).await?; + let full = format!("{}/{}", p.export.trim_end_matches('/'), filename); + let mut file = conn + .sftp + .open(full) + .await + .map_err(|e| SftpClientError::Sftp(e.to_string()))?; + if start_offset > 0 { + file.seek(SeekFrom::Start(start_offset)) + .await + .map_err(|e| SftpClientError::Io(e.to_string()))?; + } + + let mut remaining = max_len; + let mut buf = vec![0u8; READ_CHUNK_BYTES]; + loop { + if remaining == Some(0) { + break; + } + let want = match remaining { + Some(r) if (r as usize) < READ_CHUNK_BYTES => r as usize, + _ => READ_CHUNK_BYTES, + }; + let n = file + .read(&mut buf[..want]) + .await + .map_err(|e| SftpClientError::Io(e.to_string()))?; + if n == 0 { + break; // EOF + } + let bytes = Bytes::copy_from_slice(&buf[..n]); + if tx.send(Ok(bytes)).await.is_err() { + break; // client disconnected + } + if let Some(r) = remaining.as_mut() { + *r = r.saturating_sub(n as u64); + } + } + // `conn` drops here. + Ok(()) +} + +/// Pre-flight TCP probe to `server:port`. Mirrors the SMB probe so the +/// UI banner reads consistently. +async fn tcp_probe(server: &str, port: u16) -> std::result::Result<(), (String, String)> { + use tokio::net::TcpStream; + let addr = format!("{server}:{port}"); + match tokio::time::timeout(PROBE_TIMEOUT, TcpStream::connect(&addr)).await { + Ok(Ok(_)) => Ok(()), + Ok(Err(e)) => Err(( + format!("cannot reach SSH port: {addr}: {e}"), + format!("verify the SSH service is running on {server} and that port {port} is open"), + )), + Err(_) => Err(( + format!( + "cannot reach SSH port: {addr}: timed out after {}s", + PROBE_TIMEOUT.as_secs() + ), + format!( + "no TCP answer from {server}:{port} within {}s — check the IP and any firewall in between", + PROBE_TIMEOUT.as_secs() + ), + )), + } +} + +#[derive(Debug)] +enum SftpClientError { + Connect(String), + Timeout(String, u16), + Auth(String), + AuthRejected, + HostKeyMismatch { expected: String, got: String }, + Protocol(String), + Sftp(String), + Io(String), +} + +impl std::fmt::Display for SftpClientError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Connect(msg) => write!(f, "connect failed: {msg}"), + Self::Timeout(host, port) => write!( + f, + "connect timed out after {}s talking to {host}:{port}", + CONNECT_TIMEOUT.as_secs() + ), + Self::Auth(msg) => write!(f, "authentication error: {msg}"), + Self::AuthRejected => write!(f, "the server rejected the credentials"), + Self::HostKeyMismatch { expected, got } => { + write!(f, "SSH host key changed (expected {expected}, got {got})") + } + Self::Protocol(msg) => write!(f, "SSH channel error: {msg}"), + Self::Sftp(msg) => write!(f, "SFTP error: {msg}"), + Self::Io(msg) => write!(f, "read error: {msg}"), + } + } +} + +impl std::error::Error for SftpClientError {} + +/// Translate well-known SFTP/SSH failures into actionable hints. +fn hint_for(text: &str) -> Option { + let s = text.to_ascii_lowercase(); + if s.contains("host key changed") || s.contains("host key mismatch") { + Some( + "the server's SSH host key is different from the one pinned when \ + this share was added. Either the server was rebuilt / its host \ + key rotated, or this is a man-in-the-middle. If the change is \ + expected, remove and re-add the share to trust the new key." + .into(), + ) + } else if s.contains("rejected the credentials") + || s.contains("authentication error") + || s.contains("auth") + { + Some( + "SSH authentication failed. Double-check the username and the \ + password / private key. For key auth, make sure you pasted the \ + PRIVATE key (the file without .pub), that its passphrase is \ + correct, and that the matching public key is in the server's \ + ~/.ssh/authorized_keys for this user." + .into(), + ) + } else if s.contains("no such file") || s.contains("does not exist") || s.contains("no such") { + Some( + "the export directory or ISO wasn't found on the server. Check \ + the export path (absolute, e.g. /srv/isos) and that the SSH user \ + can list it." + .into(), + ) + } else if s.contains("permission denied") { + Some( + "authenticated, but the SSH user can't read the export directory \ + or file. Check the directory's permissions on the server." + .into(), + ) + } else if s.contains("connection refused") { + Some( + "nothing is listening on this SSH port. Verify sshd is running on \ + the server and that the port (default 22) is correct." + .into(), + ) + } else if s.contains("timed out") || s.contains("timeout") { + Some( + "no answer from the server within the connect timeout. Verify the \ + IP, the port (default 22), and any firewall in between." + .into(), + ) + } else if s.contains("no route to host") || s.contains("network is unreachable") { + Some("the server isn't reachable on this network. Check the IP and routes.".into()) + } else { + None + } +} + +fn share_id(server: &str, export: &str) -> String { + slugify_str(&format!("{server}{export}")) +} + +/// Normalize a server input: trim, strip schemes, drop trailing slashes. +/// Mirrors the NFS/SMB normalizers so paste-from-anywhere works. +fn normalize_server(raw: &str) -> String { + let s = raw.trim(); + let s = s + .strip_prefix("sftp://") + .or_else(|| s.strip_prefix("ssh://")) + .unwrap_or(s); + s.trim_end_matches('/').to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn share_id_is_stable_and_safe() { + let a = share_id("10.0.0.5", "/srv/isos"); + let b = share_id("10.0.0.5", "/srv/isos"); + assert_eq!(a, b); + assert!(!a.contains('/')); + assert!(!a.contains('.')); + } + + #[test] + fn normalize_server_strips_schemes() { + assert_eq!(normalize_server(" 10.0.0.5 "), "10.0.0.5"); + assert_eq!(normalize_server("sftp://nas.lan/"), "nas.lan"); + assert_eq!(normalize_server("ssh://192.168.1.51"), "192.168.1.51"); + assert_eq!(normalize_server("nas.lan"), "nas.lan"); + } + + #[test] + fn hint_for_auth_failure_mentions_credentials() { + let h = hint_for("authentication error: bad password").unwrap(); + let lc = h.to_lowercase(); + assert!(lc.contains("username") || lc.contains("password") || lc.contains("key")); + } + + #[test] + fn hint_for_host_key_mismatch_warns_about_mitm_and_readd() { + let h = hint_for("SSH host key changed (expected SHA256:a, got SHA256:b)").unwrap(); + let lc = h.to_lowercase(); + assert!(lc.contains("host key")); + assert!(lc.contains("re-add") || lc.contains("man-in-the-middle")); + } + + #[test] + fn hint_for_refused_points_at_sshd() { + let h = hint_for("connect failed: Connection refused").unwrap(); + assert!(h.to_lowercase().contains("sshd") || h.to_lowercase().contains("listening")); + } + + #[test] + fn hint_for_unknown_is_none() { + assert!(hint_for("some entirely unrelated string").is_none()); + } + + #[tokio::test] + async fn add_requires_username() { + let dir = tempdir().unwrap(); + let store = IsoStore::new(dir.path().join("isos")); + let mgr = SftpShareManager::new(dir.path(), store); + let err = mgr + .add(SftpAddRequest { + server: "10.0.0.5".into(), + export: "/srv/isos".into(), + username: None, + port: None, + password: Some("pw".into()), + private_key: None, + passphrase: None, + }) + .await + .unwrap_err(); + assert!( + err.error.to_lowercase().contains("username"), + "{}", + err.error + ); + } + + #[tokio::test] + async fn add_requires_a_secret() { + let dir = tempdir().unwrap(); + let store = IsoStore::new(dir.path().join("isos")); + let mgr = SftpShareManager::new(dir.path(), store); + let err = mgr + .add(SftpAddRequest { + server: "10.0.0.5".into(), + export: "/srv/isos".into(), + username: Some("root".into()), + port: None, + password: None, + private_key: None, + passphrase: None, + }) + .await + .unwrap_err(); + let lc = err.error.to_lowercase(); + assert!( + lc.contains("password") || lc.contains("private key"), + "{}", + err.error + ); + } + + #[tokio::test] + async fn add_rejects_both_secrets() { + let dir = tempdir().unwrap(); + let store = IsoStore::new(dir.path().join("isos")); + let mgr = SftpShareManager::new(dir.path(), store); + let err = mgr + .add(SftpAddRequest { + server: "10.0.0.5".into(), + export: "/srv/isos".into(), + username: Some("root".into()), + port: None, + password: Some("pw".into()), + private_key: Some("-----BEGIN OPENSSH PRIVATE KEY-----".into()), + passphrase: None, + }) + .await + .unwrap_err(); + assert!( + err.error.to_lowercase().contains("not both"), + "{}", + err.error + ); + } + + #[tokio::test] + async fn add_requires_absolute_export() { + let dir = tempdir().unwrap(); + let store = IsoStore::new(dir.path().join("isos")); + let mgr = SftpShareManager::new(dir.path(), store); + let err = mgr + .add(SftpAddRequest { + server: "10.0.0.5".into(), + export: "relative/path".into(), + username: Some("root".into()), + port: None, + password: Some("pw".into()), + private_key: None, + passphrase: None, + }) + .await + .unwrap_err(); + assert!( + err.error.to_lowercase().contains("absolute"), + "{}", + err.error + ); + } + + #[tokio::test] + async fn creds_round_trip_0600() { + use std::os::unix::fs::PermissionsExt; + let dir = tempdir().unwrap(); + let store = IsoStore::new(dir.path().join("isos")); + let mgr = SftpShareManager::new(dir.path(), store); + let creds = SftpCreds { + password: Some("hunter2".into()), + private_key: None, + passphrase: None, + }; + mgr.write_creds("share1", &creds).await.unwrap(); + let read = mgr.read_creds("share1").await.unwrap(); + assert_eq!(read.password.as_deref(), Some("hunter2")); + // The secret file must be 0600. + let meta = std::fs::metadata(mgr.creds_path_for("share1")).unwrap(); + assert_eq!(meta.permissions().mode() & 0o777, 0o600); + } +} diff --git a/crates/iso-store/src/store.rs b/crates/iso-store/src/store.rs index d27680a..e5ba75c 100644 --- a/crates/iso-store/src/store.rs +++ b/crates/iso-store/src/store.rs @@ -24,6 +24,10 @@ use tokio::io::AsyncWriteExt; /// `nfs3_client` crate (in-process, no subprocess). Same "works in /// any container" property as SMB, plus Range requests work because /// NFSv3 READ3 takes an explicit offset. +/// `Sftp` (v0.5.5) — remote SFTP-over-SSH share, streamed via the +/// pure-Rust `russh` + `russh-sftp` crates (in-process). Like NFS it +/// supports HTTP Range requests because SFTP opens a seekable file +/// handle (`SSH_FXP_READ` at offset). #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum IsoSource { @@ -42,6 +46,13 @@ pub enum IsoSource { /// Filename at the export root. relative_path: String, }, + /// v0.5.5: SFTP-over-SSH via the in-process `russh` + `russh-sftp` + /// crates. + Sftp { + share_id: String, + /// Filename at the export root. + relative_path: String, + }, } /// Where the ISO lands in the PXE menu hierarchy. @@ -299,11 +310,11 @@ impl IsoStore { None } } - // SMB and NFS sources have no local path — they're + // SMB, NFS, and SFTP sources have no local path — they're // streamed in-process. Callers must inspect the source // kind first and dispatch to the appropriate share // manager. - IsoSource::Smb { .. } | IsoSource::Nfs { .. } => None, + IsoSource::Smb { .. } | IsoSource::Nfs { .. } | IsoSource::Sftp { .. } => None, } } @@ -363,9 +374,9 @@ impl IsoStore { pub fn drop_external_source(&self, share_id: &str) { let mut g = self.inner.write(); g.isos.retain(|_, m| match &m.source { - IsoSource::Smb { share_id: sid, .. } | IsoSource::Nfs { share_id: sid, .. } => { - sid != share_id - } + IsoSource::Smb { share_id: sid, .. } + | IsoSource::Nfs { share_id: sid, .. } + | IsoSource::Sftp { share_id: sid, .. } => sid != share_id, IsoSource::Local => true, }); } @@ -659,7 +670,10 @@ mod tests { // good. let s = linux_cmdline(DistroFamily::DebianUbuntu, "ubuntu-24-04"); assert!(s.contains("boot=casper"), "{s}"); - assert!(s.contains("iso-url=${base-url}/iso/ubuntu-24-04.iso"), "{s}"); + assert!( + s.contains("iso-url=${base-url}/iso/ubuntu-24-04.iso"), + "{s}" + ); assert!(s.contains("ds=nocloud"), "{s}"); assert!(s.contains("ip=dhcp"), "{s}"); assert!(!s.contains("netboot=url"), "legacy option leaked: {s}"); diff --git a/crates/openpxe/src/main.rs b/crates/openpxe/src/main.rs index 5621956..794335c 100644 --- a/crates/openpxe/src/main.rs +++ b/crates/openpxe/src/main.rs @@ -9,7 +9,7 @@ use openpxe_core::{ }; use openpxe_dhcp_proxy::DhcpProxyServer; use openpxe_http_api::{build_router, AppState}; -use openpxe_iso_store::{IsoStore, NfsShareManager, SmbManager, SmbShareManager}; +use openpxe_iso_store::{IsoStore, NfsShareManager, SftpShareManager, SmbManager, SmbShareManager}; use openpxe_tftp::TftpServer; use std::net::{Ipv4Addr, SocketAddr}; use std::path::PathBuf; @@ -100,8 +100,7 @@ async fn main() -> anyhow::Result<()> { iso_store.load_from_disk().await?; // v0.5.2: unattended answer-file store (Kickstart/Preseed/Autoinstall/ // Windows answer files). Separate directory from the ISO store. - let unattended = - openpxe_iso_store::UnattendedStore::new(config.paths.unattended_dir.clone()); + let unattended = openpxe_iso_store::UnattendedStore::new(config.paths.unattended_dir.clone()); if let Err(e) = unattended.load_from_disk().await { tracing::warn!( target: "openpxe::unattended", @@ -155,6 +154,19 @@ async fn main() -> anyhow::Result<()> { ); } + // v0.5.5: SFTP-over-SSH share manager — pure-Rust in-process + // consumer via `russh` + `russh-sftp` (ring backend, no OpenSSL). + // The third remote-library protocol alongside SMB/NFS; like NFS it + // works in any container (no subprocess, no kernel mount) and + // supports HTTP Range requests because SFTP file handles seek. + let sftp_shares = SftpShareManager::new(&config.paths.work_dir, iso_store.clone()); + if let Err(e) = sftp_shares.load_and_rescan().await { + tracing::warn!( + target: "openpxe::sftp", + "could not reload SFTP shares on startup: {e}" + ); + } + // Sniff network details for the Network tab. None of these are // required for PXE to work — they're informational, surfaced in the // UI so an operator doesn't have to drop to a shell to find their @@ -183,6 +195,7 @@ async fn main() -> anyhow::Result<()> { smb: Some(smb.clone()), smb_shares: smb_shares.clone(), nfs_shares: nfs_shares.clone(), + sftp_shares: sftp_shares.clone(), unattended: unattended.clone(), uploads: openpxe_http_api::uploads::UploadSessions::default(), log_bus: log_bus.clone(), diff --git a/crates/webui/src/app.js b/crates/webui/src/app.js index eb541b2..518b69f 100644 --- a/crates/webui/src/app.js +++ b/crates/webui/src/app.js @@ -291,11 +291,11 @@ el('div', {class: 'trend'}, isos.filter(i => i.introspection.family === 'windows_pe').length + ' Windows · ' + isos.filter(i => i.introspection.family !== 'windows_pe').length + ' Linux · ' + - // v0.4.67: count both protocols. Label generically since - // operators may be using one, the other, or both. - ((status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0)) + + // v0.4.67+v0.5.5: count all remote-share protocols. Label + // generically since operators may use any mix of SMB/NFS/SFTP. + ((status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0) + (status.sftp_share_reachable || 0)) + ' remote share' + - (((status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0)) === 1 ? '' : 's')), + (((status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0) + (status.sftp_share_reachable || 0)) === 1 ? '' : 's')), ])), el('div', {class: 'card'}, el('div', {class: 'stat'}, [ el('div', {class: 'label'}, 'Uptime'), @@ -508,10 +508,11 @@ // v0.4.67: NFSv3 added back as an in-process Rust client // (nfs3_client crate). Both protocols available side-by-side; // operators pick whichever their NAS prefers. - const [isos, settings, smbRes, nfsRes, disk, unattRes] = await Promise.all([ + const [isos, settings, smbRes, nfsRes, sftpRes, disk, unattRes] = await Promise.all([ getJSON('/api/isos'), getJSON('/api/settings'), getJSON('/api/smb-shares'), getJSON('/api/nfs-shares'), + getJSON('/api/sftp-shares'), getJSON('/api/storage/disk').catch(() => ({ total_bytes: 0, available_bytes: 0, used_bytes: 0, path: '?', })), @@ -519,6 +520,7 @@ ]); const shares = smbRes.shares || []; const nfsShares = nfsRes.shares || []; + const sftpShares = sftpRes.shares || []; const unattendedFiles = unattRes.files || []; // ── Upload card ── @@ -829,6 +831,7 @@ const protoSelect = el('select', {}, [ el('option', {value:'nfs'}, 'NFS (NFSv3)'), el('option', {value:'smb'}, 'SMB / CIFS'), + el('option', {value:'sftp'}, 'SFTP (SSH)'), ]); // SMB inputs. @@ -893,11 +896,58 @@ ]), ]); + // SFTP inputs (v0.5.5). Pure-Rust russh client, in-process, so + // SFTP-sourced ISOs support HTTP Range like NFS. Auth is password + // OR an SSH private key (PEM, optional passphrase); the server's + // host key is pinned trust-on-first-use on the first connect. + const sftpServerIn = el('input', {type:'text', placeholder:'10.0.0.5'}); + const sftpExportIn = el('input', {type:'text', placeholder:'/srv/isos'}); + const sftpUserIn = el('input', {type:'text', placeholder:'root'}); + const sftpPortIn = el('input', {type:'number', placeholder:'22', min:'1', max:'65535'}); + const sftpAuthMode = el('select', {}, [ + el('option', {value:'password'}, 'Password'), + el('option', {value:'key'}, 'SSH private key'), + ]); + const sftpPassIn = el('input', {type:'password', placeholder:'••••••••'}); + const sftpKeyIn = el('textarea', {rows:'4', + placeholder:'-----BEGIN OPENSSH PRIVATE KEY-----', + style:'width:100%;font-family:ui-monospace,monospace;font-size:12px;resize:vertical'}); + const sftpPassphraseIn = el('input', {type:'password', + placeholder:'(only if the private key is encrypted)'}); + const sftpPassBlock = el('label', {class:'field'}, + [el('span', {class:'name'}, 'Password'), sftpPassIn]); + const sftpKeyBlock = el('div', {}, [ + el('label', {class:'field'}, + [el('span', {class:'name'}, 'SSH private key (PEM)'), sftpKeyIn]), + el('label', {class:'field', style:'margin-top:10px'}, + [el('span', {class:'name'}, 'Key passphrase (optional)'), sftpPassphraseIn]), + ]); + const syncSftpAuth = () => { + const key = sftpAuthMode.value === 'key'; + sftpPassBlock.style.display = key ? 'none' : ''; + sftpKeyBlock.style.display = key ? '' : 'none'; + }; + sftpAuthMode.addEventListener('change', syncSftpAuth); + syncSftpAuth(); + const sftpFields = el('div', {}, [ + el('div', {class:'form-row cols-2'}, [ + el('label', {class:'field'}, [el('span', {class:'name'}, 'SSH server'), sftpServerIn]), + el('label', {class:'field'}, [el('span', {class:'name'}, 'Export path'), sftpExportIn]), + ]), + el('div', {class:'form-row cols-3', style:'margin-top:14px'}, [ + el('label', {class:'field'}, [el('span', {class:'name'}, 'Username'), sftpUserIn]), + el('label', {class:'field'}, [el('span', {class:'name'}, 'Port'), sftpPortIn]), + el('label', {class:'field'}, [el('span', {class:'name'}, 'Auth'), sftpAuthMode]), + ]), + el('div', {style:'margin-top:14px'}, [sftpPassBlock, sftpKeyBlock]), + ]); + // Swap the visible field block + clear any stale message. const syncProto = () => { - const nfs = protoSelect.value === 'nfs'; - smbFields.style.display = nfs ? 'none' : ''; - nfsFields.style.display = nfs ? '' : 'none'; + const p = protoSelect.value; + smbFields.style.display = p === 'smb' ? '' : 'none'; + nfsFields.style.display = p === 'nfs' ? '' : 'none'; + sftpFields.style.display = p === 'sftp' ? '' : 'none'; shareMsg.replaceChildren(); shareMsg.className = 'msg'; }; @@ -924,6 +974,40 @@ shareMsg.className = 'msg ok'; render('storage'); } else { await showShareError(r); } + } else if (protoSelect.value === 'sftp') { + if (!sftpServerIn.value || !sftpExportIn.value || !sftpUserIn.value) { + shareMsg.replaceChildren(document.createTextNode('Server, export, and username are required.')); + shareMsg.className = 'msg err'; return; + } + const useKey = sftpAuthMode.value === 'key'; + if (useKey && !sftpKeyIn.value.trim()) { + shareMsg.replaceChildren(document.createTextNode('Paste the SSH private key, or switch Auth to Password.')); + shareMsg.className = 'msg err'; return; + } + if (!useKey && !sftpPassIn.value) { + shareMsg.replaceChildren(document.createTextNode('Password is required, or switch Auth to SSH private key.')); + shareMsg.className = 'msg err'; return; + } + shareMsg.replaceChildren(document.createTextNode('Connecting…')); + shareMsg.className = 'msg'; + const body = { + server: sftpServerIn.value, + export: sftpExportIn.value, + username: sftpUserIn.value, + }; + if (sftpPortIn.value) { body.port = parseInt(sftpPortIn.value, 10); } + if (useKey) { + body.private_key = sftpKeyIn.value; + if (sftpPassphraseIn.value) { body.passphrase = sftpPassphraseIn.value; } + } else { + body.password = sftpPassIn.value; + } + const r = await postJSON('/api/sftp-shares', body); + if (r.ok) { + shareMsg.replaceChildren(document.createTextNode('Connected.')); + shareMsg.className = 'msg ok'; + render('storage'); + } else { await showShareError(r); } } else { if (!nfsServerIn.value || !nfsExportIn.value) { shareMsg.replaceChildren(document.createTextNode('Server and export are required.')); @@ -962,9 +1046,36 @@ el('span'), ])); - const totalShares = shares.length + nfsShares.length; + const sftpRowEls = sftpShares.map(m => el('div', {class: 'nfs-row' + (m.reachable ? '' : ' down')}, [ + el('span', {class: 'dot ' + (m.reachable ? 'ok' : 'err')}), + el('div', {}, [ + el('div', {class:'id'}, [el('span', {class:'proto-badge'}, 'SFTP'), + document.createTextNode(m.username + '@' + m.server + ':' + m.export)]), + el('div', {class:'meta'}, + 'SSH · ' + (m.auth === 'key' ? 'key' : 'password') + ' · ' + + (m.reachable ? m.iso_count + ' isos' : 'not reachable')), + m.host_key_fingerprint + ? el('div', {style:'margin-top:4px;opacity:.65;font-size:11px;font-family:ui-monospace,monospace;word-break:break-all'}, + 'host key ' + m.host_key_fingerprint) + : null, + m.last_error ? el('div', {class:'err'}, '⚠ ' + m.last_error) : null, + m.last_hint ? el('div', {style:'margin-top:4px;opacity:.78;font-size:12px'}, m.last_hint) : null, + ]), + el('button', {class:'ghost', onclick: async () => { + const r = await postJSON('/api/sftp-shares/' + encodeURIComponent(m.id) + '/scan', {}); + if (r.ok) render('storage'); + }}, 'Re-scan'), + el('button', {class:'danger', onclick: async () => { + if (!confirm('Forget ' + m.server + ':' + m.export + '?')) return; + await fetch('/api/sftp-shares/' + encodeURIComponent(m.id), {method:'DELETE'}); + render('storage'); + }}, 'Remove'), + el('span'), + ])); + + const totalShares = shares.length + nfsShares.length + sftpShares.length; const remoteRows = totalShares - ? [...smbRowEls, ...nfsRowEls] + ? [...smbRowEls, ...nfsRowEls, ...sftpRowEls] : [el('div', {class:'empty'}, 'No remote shares configured.')]; syncProto(); @@ -1071,13 +1182,13 @@ ]), el('span'), ]), - el('div', {style:'margin-top:14px'}, [smbFields, nfsFields]), + el('div', {style:'margin-top:14px'}, [smbFields, nfsFields, sftpFields]), addShare, shareMsg, el('div', {style:'margin-top:18px;display:grid;gap:8px'}, remoteRows), el('p', {class:'msg', style:'margin-top:14px'}, 'Remote .iso libraries are read on demand — no local cache to ' + - 'preserve disk usage. Support for NFS 3.0 and SMB. Ensure that ' + - 'the hosts IP address is provisioned.'), + 'preserve disk usage. Support for NFS 3.0, SMB, and SFTP (SSH). ' + + 'Ensure that the hosts IP address is provisioned.'), ]), ]), el('div', {class:'card'}, [