diff --git a/.dockerignore b/.dockerignore index 4c5003d..9ff15e4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,6 +4,8 @@ camera_hub/pending_videos/ camera_hub/state/ releases/builds/ +release_work/ motion_ai/cli/output camera_hub/output -**/target \ No newline at end of file +**/target +*.wic \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6016e1e..7931a7f 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ test_data cameras.yaml credentials_full *.sh +!update/scripts/secluso_sign_checksums.sh # Firebase Service Files google-services.json @@ -41,3 +42,5 @@ secluso-v* # Exclude local release staging outputs and imported signing material /releases/release_assets/ *.p12 +__pycache__ +*.wic \ No newline at end of file diff --git a/app_native/examples/app.rs b/app_native/examples/app.rs index 1956aed..e39ce70 100644 --- a/app_native/examples/app.rs +++ b/app_native/examples/app.rs @@ -344,19 +344,11 @@ fn heartbeat( config_response.clone(), timestamp, ) { - Ok(response) if response.contains("healthy") => { + Ok(response) if response.contains("\"status\":\"healthy\"") => { println!("Healthy heartbeat"); - - if let Some((_, firmware_version)) = response.split_once('_') { - println!("firmware_version = {firmware_version}"); - } else { - return Err(io::Error::new( - io::ErrorKind::Other, - format!("Error: unknown firmware version."), - )); - } + println!("{response}"); } - Ok(response) if response == "invalid ciphertext".to_string() => { + Ok(response) if response.contains("\"status\":\"invalid ciphertext\"") => { return Err(io::Error::new( io::ErrorKind::Other, format!("The connection to the camera is corrupted. Pair the app with the camera again."), @@ -586,4 +578,4 @@ fn read_varying_len(stream: &mut TcpStream) -> io::Result> { } Ok(msg) -} \ No newline at end of file +} diff --git a/app_native/src/lib.rs b/app_native/src/lib.rs index 710b55a..2c9f721 100644 --- a/app_native/src/lib.rs +++ b/app_native/src/lib.rs @@ -8,7 +8,7 @@ use log::{debug, error, info}; use rand::distr::Alphanumeric; use rand::Rng; use secluso_client_lib::config::{ - Heartbeat, HeartbeatRequest, HeartbeatResult, OPCODE_HEARTBEAT_REQUEST, OPCODE_HEARTBEAT_RESPONSE, + CameraVersionInfo, Heartbeat, HeartbeatRequest, HeartbeatResult, OPCODE_HEARTBEAT_REQUEST, OPCODE_HEARTBEAT_RESPONSE, AddAppRequest, AddAppResponseCommon, AddAppResponseDedicated, OPCODE_ADD_APP_REQUEST, OPCODE_ADD_APP_RESPONSE, }; use secluso_client_lib::mls_client::{Contact, MlsClient, ClientType}; @@ -44,6 +44,13 @@ const CAMERA_IO_TIMEOUT: Duration = Duration::from_secs(12); const CAMERA_CONNECT_RETRIES: usize = 3; const CAMERA_CONNECT_RETRY_DELAY: Duration = Duration::from_millis(350); +#[derive(Serialize)] +struct HeartbeatStatus { + status: String, + #[serde(skip_serializing_if = "Option::is_none")] + version_info: Option, +} + #[flutter_rust_bridge::frb] pub struct Clients { mls_clients: MlsClients, @@ -199,14 +206,14 @@ fn send_credentials_full( Ok(()) } -fn receive_firmware_version( +fn receive_camera_version_info( stream: &mut TcpStream, -) -> anyhow::Result { - info!("Sending credentials_full"); - let firmware_version_bytes = read_varying_len(stream)?; - let firmware_version = String::from_utf8(firmware_version_bytes)?; +) -> anyhow::Result { + info!("Receiving camera version info"); + let version_info_bytes = read_varying_len(stream)?; + let version_info = serde_json::from_slice::(&version_info_bytes)?; - Ok(firmware_version) + Ok(version_info) } fn send_timestamp( @@ -371,9 +378,9 @@ pub fn add_camera( } info!("Waiting for firmware version from camera"); - let firmware_version = - match receive_firmware_version(&mut stream) { - Ok(version) => version, + let version_info = + match receive_camera_version_info(&mut stream) { + Ok(version_info) => version_info, Err(e) => { info!("Error (firmware): {e}"); return "Error".to_string(); @@ -381,8 +388,11 @@ pub fn add_camera( }; let app_native_version = format!("v{}", env!("CARGO_PKG_VERSION")); - info!("Camera version = {}, app native version = {}", firmware_version, app_native_version); - if app_native_version != firmware_version { + info!( + "Camera firmware version = {}, camera OS version = {}, app native version = {}", + version_info.firmware_version, version_info.os_version, app_native_version + ); + if app_native_version != version_info.firmware_version { return "PairVersionIncompatible".to_string(); } @@ -425,7 +435,13 @@ pub fn add_camera( } } - firmware_version + match serde_json::to_string(&version_info) { + Ok(version_info_json) => version_info_json, + Err(e) => { + info!("Error (version-info-json): {e}"); + "Error".to_string() + } + } } pub fn initialize( @@ -725,11 +741,28 @@ pub fn process_heartbeat_config_response( match heartbeat_result { HeartbeatResult::HealthyHeartbeat(_timestamp) => { - Ok(format!("healthy_{}", heartbeat.firmware_version)) + let status = HeartbeatStatus { + status: "healthy".to_string(), + version_info: Some(CameraVersionInfo { + firmware_version: heartbeat.firmware_version, + os_version: heartbeat.os_version, + }), + }; + serde_json::to_string(&status) + .map_err(|e| io::Error::other(e.to_string())) } - HeartbeatResult::InvalidTimestamp => Ok("invalid timestamp".to_string()), - HeartbeatResult::InvalidCiphertext => Ok("invalid ciphertext".to_string()), - HeartbeatResult::InvalidEpoch => Ok("invalid epoch".to_string()), + HeartbeatResult::InvalidTimestamp => Ok(serde_json::to_string(&HeartbeatStatus { + status: "invalid timestamp".to_string(), + version_info: None, + }).unwrap()), + HeartbeatResult::InvalidCiphertext => Ok(serde_json::to_string(&HeartbeatStatus { + status: "invalid ciphertext".to_string(), + version_info: None, + }).unwrap()), + HeartbeatResult::InvalidEpoch => Ok(serde_json::to_string(&HeartbeatStatus { + status: "invalid epoch".to_string(), + version_info: None, + }).unwrap()), } } _ => { diff --git a/camera_hub/Cargo.lock b/camera_hub/Cargo.lock index 5c59c83..1e4142a 100644 --- a/camera_hub/Cargo.lock +++ b/camera_hub/Cargo.lock @@ -273,9 +273,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.16.2" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" dependencies = [ "aws-lc-sys", "zeroize", @@ -283,9 +283,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.39.1" +version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" +checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" dependencies = [ "cc", "cmake", @@ -337,9 +337,9 @@ checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "bitstream-io" @@ -349,11 +349,11 @@ checksum = "6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2" [[package]] name = "bitstream-io" -version = "4.9.0" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" dependencies = [ - "core2", + "no_std_io2", ] [[package]] @@ -392,12 +392,6 @@ version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "byteorder-lite" version = "0.1.0" @@ -412,9 +406,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.60" +version = "1.2.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" dependencies = [ "find-msvc-tools", "jobserver", @@ -422,12 +416,6 @@ dependencies = [ "shlex", ] -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - [[package]] name = "cfg-if" version = "1.0.4" @@ -567,19 +555,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "657f625ff361906f779745d08375ae3cc9fef87a35fba5f22874cf773010daf4" dependencies = [ "hax-lib", - "pastey 0.2.1", + "pastey 0.2.2", "rand 0.9.4", ] -[[package]] -name = "core2" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" -dependencies = [ - "memchr", -] - [[package]] name = "cpufeatures" version = "0.2.17" @@ -704,17 +683,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", - "pem-rfc7468 0.7.0", - "zeroize", -] - -[[package]] -name = "der" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" -dependencies = [ - "pem-rfc7468 1.0.0", + "pem-rfc7468", "zeroize", ] @@ -803,7 +772,7 @@ version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "der 0.7.10", + "der", "digest", "elliptic-curve", "rfc6979", @@ -855,7 +824,7 @@ dependencies = [ "generic-array", "group", "hkdf", - "pem-rfc7468 0.7.0", + "pem-rfc7468", "pkcs8", "rand_core 0.6.4", "sec1", @@ -921,16 +890,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.59.0", -] - [[package]] name = "erydanos" version = "0.2.18" @@ -951,7 +910,7 @@ dependencies = [ "lebe", "miniz_oxide", "rayon-core", - "smallvec 1.15.1", + "smallvec", "zune-inflate", ] @@ -976,31 +935,11 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "fastrand" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" - [[package]] name = "fax" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" -dependencies = [ - "fax_derive", -] - -[[package]] -name = "fax_derive" -version = "0.2.0" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" [[package]] name = "fdeflate" @@ -1027,17 +966,6 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" -[[package]] -name = "filetime" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" -dependencies = [ - "cfg-if", - "libc", - "libredox", -] - [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1072,21 +1000,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1243,7 +1156,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", - "rand_core 0.10.0", + "rand_core 0.10.1", "wasip2", "wasip3", ] @@ -1451,9 +1364,9 @@ dependencies = [ "libcrux-hkdf", "libcrux-kem", "libcrux-traits", - "rand 0.10.0", + "rand 0.10.1", "rand_chacha 0.10.0", - "rand_core 0.10.0", + "rand_core 0.10.1", "zeroize", ] @@ -1472,7 +1385,7 @@ dependencies = [ "p384", "rand 0.8.6", "rand_chacha 0.3.1", - "rand_core 0.10.0", + "rand_core 0.10.1", "rand_core 0.6.4", "sha2", "subtle", @@ -1549,22 +1462,21 @@ dependencies = [ "httparse", "itoa", "pin-project-lite", - "smallvec 1.15.1", + "smallvec", "tokio", "want", ] [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", "hyper-util", "rustls", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -1654,7 +1566,7 @@ dependencies = [ "icu_normalizer_data", "icu_properties", "icu_provider", - "smallvec 1.15.1", + "smallvec", "zerovec", ] @@ -1712,15 +1624,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", - "smallvec 1.15.1", + "smallvec", "utf8_iter", ] [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -1762,9 +1674,9 @@ dependencies = [ [[package]] name = "imageproc" -version = "0.25.0" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2393fb7808960751a52e8a154f67e7dd3f8a2ef9bd80d1553078a7b4e8ed3f0d" +checksum = "602b4e8a4cc3e98372b766cd184ab532999bc0e839b7469e759511ccabc65d77" dependencies = [ "ab_glyph", "approx", @@ -1780,9 +1692,9 @@ dependencies = [ [[package]] name = "imgref" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" +checksum = "40fac9d56ed6437b198fddba683305e8e2d651aa42647f00f5ae542e7f5c94a2" [[package]] name = "include_dir" @@ -1892,9 +1804,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.23" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" +checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d" dependencies = [ "jiff-static", "jiff-tzdb-platform", @@ -1902,14 +1814,14 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "jiff-static" -version = "0.2.23" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" +checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" dependencies = [ "proc-macro2", "quote", @@ -1933,27 +1845,32 @@ dependencies = [ [[package]] name = "jni" -version = "0.21.1" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" dependencies = [ - "cesu8", "cfg-if", "combine", - "jni-sys 0.3.1", + "jni-macros", + "jni-sys", "log", - "thiserror 1.0.69", + "simd_cesu8", + "thiserror 2.0.18", "walkdir", - "windows-sys 0.45.0", + "windows-link", ] [[package]] -name = "jni-sys" -version = "0.3.1" +name = "jni-macros" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" dependencies = [ - "jni-sys 0.4.1", + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", ] [[package]] @@ -1987,9 +1904,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.95" +version = "0.3.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" dependencies = [ "cfg-if", "futures-util", @@ -2050,9 +1967,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.184" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libcrux-aead" @@ -2328,22 +2245,20 @@ dependencies = [ ] [[package]] -name = "libm" -version = "0.2.16" +name = "libloading" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link", +] [[package]] -name = "libredox" -version = "0.1.16" +name = "libm" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" -dependencies = [ - "bitflags", - "libc", - "plain", - "redox_syscall", -] +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "linfa" @@ -2352,7 +2267,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87b84e47ca7a9d63f5be24c104e216c8263bfada38080cbdfe1082e611a81fd3" dependencies = [ "approx", - "ndarray", + "ndarray 0.16.1", "num-traits", "rand 0.8.6", "sprs", @@ -2368,7 +2283,7 @@ dependencies = [ "linfa", "linfa-linalg", "linfa-nn", - "ndarray", + "ndarray 0.16.1", "ndarray-rand", "ndarray-stats", "noisy_float", @@ -2384,7 +2299,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02a834c0ec063937688a0d13573aa515ab8c425bd8de3154b908dd3b9c197dc4" dependencies = [ - "ndarray", + "ndarray 0.16.1", "num-traits", "thiserror 1.0.69", ] @@ -2397,7 +2312,7 @@ checksum = "d7ba257f89880df17b486e67731ef20c4a748a5f5f5eaace010853f69a0beee3" dependencies = [ "kdtree", "linfa", - "ndarray", + "ndarray 0.16.1", "ndarray-stats", "noisy_float", "num-traits", @@ -2405,12 +2320,6 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - [[package]] name = "litemap" version = "0.8.2" @@ -2576,29 +2485,28 @@ dependencies = [ ] [[package]] -name = "native-tls" -version = "0.2.18" +name = "ndarray" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841" dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", + "approx", + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", + "rayon", ] [[package]] name = "ndarray" -version = "0.16.1" +version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" dependencies = [ - "approx", "matrixmultiply", "num-complex", "num-integer", @@ -2615,7 +2523,7 @@ version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f093b3db6fd194718dcdeea6bd8c829417deae904e3fcc7732dabcd4416d25d8" dependencies = [ - "ndarray", + "ndarray 0.16.1", "rand 0.8.6", "rand_distr", ] @@ -2628,7 +2536,7 @@ checksum = "17ebbe97acce52d06aebed4cd4a87c0941f4b2519b59b82b4feb5bd0ce003dfd" dependencies = [ "indexmap", "itertools 0.13.0", - "ndarray", + "ndarray 0.16.1", "noisy_float", "num-integer", "num-traits", @@ -2641,6 +2549,15 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "no_std_io2" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b51ed7824b6e07d354605f4abb3d9d300350701299da96642ee084f5ce631550" +dependencies = [ + "memchr", +] + [[package]] name = "noisy_float" version = "0.2.1" @@ -2878,50 +2795,12 @@ dependencies = [ "tls_codec", ] -[[package]] -name = "openssl" -version = "0.10.78" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" -dependencies = [ - "bitflags", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "openssl-sys" -version = "0.9.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "order-stat" version = "0.1.3" @@ -2930,28 +2809,22 @@ checksum = "efa535d5117d3661134dbf1719b6f0ffe06f2375843b13935db186cd094105eb" [[package]] name = "ort" -version = "2.0.0-rc.10" +version = "2.0.0-rc.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa7e49bd669d32d7bc2a15ec540a527e7764aec722a45467814005725bcd721" +checksum = "d7de3af33d24a745ffb8fab904b13478438d1cd52868e6f17735ef6e1f8bf133" dependencies = [ - "ndarray", + "libloading", + "ndarray 0.17.2", "ort-sys", - "smallvec 2.0.0-alpha.10", + "smallvec", "tracing", ] [[package]] name = "ort-sys" -version = "2.0.0-rc.10" +version = "2.0.0-rc.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2aba9f5c7c479925205799216e7e5d07cc1d4fa76ea8058c60a9a30f6a4e890" -dependencies = [ - "flate2", - "pkg-config", - "sha2", - "tar", - "ureq", -] +checksum = "d7b497d21a8b6fbb4b5a544f8fadb77e801a09ae0add9e411d31c6f89e3c1e90" [[package]] name = "owned_ttf_parser" @@ -3007,9 +2880,9 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" [[package]] name = "pastey" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" +checksum = "c5a797f0e07bdf071d15742978fc3128ec6c22891c31a3a931513263904c982a" [[package]] name = "pem-rfc7468" @@ -3020,15 +2893,6 @@ dependencies = [ "base64ct", ] -[[package]] -name = "pem-rfc7468" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" -dependencies = [ - "base64ct", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -3076,22 +2940,10 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der 0.7.10", + "der", "spki", ] -[[package]] -name = "pkg-config" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" - -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" - [[package]] name = "png" version = "0.18.1" @@ -3136,9 +2988,9 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" dependencies = [ "portable-atomic", ] @@ -3219,18 +3071,18 @@ dependencies = [ [[package]] name = "profiling" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" dependencies = [ "profiling-procmacros", ] [[package]] name = "profiling-procmacros" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" dependencies = [ "quote", "syn", @@ -3238,9 +3090,9 @@ dependencies = [ [[package]] name = "pxfm" -version = "0.1.28" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" [[package]] name = "qoi" @@ -3319,7 +3171,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -3366,12 +3218,12 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "getrandom 0.4.2", - "rand_core 0.10.0", + "rand_core 0.10.1", ] [[package]] @@ -3401,7 +3253,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb" dependencies = [ "ppv-lite86", - "rand_core 0.10.0", + "rand_core 0.10.1", ] [[package]] @@ -3424,9 +3276,9 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rand_distr" @@ -3459,7 +3311,7 @@ dependencies = [ "arrayvec", "av-scenechange", "av1-grain", - "bitstream-io 4.9.0", + "bitstream-io 4.10.0", "built", "cfg-if", "interpolate_name", @@ -3505,9 +3357,9 @@ checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" [[package]] name = "rayon" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", @@ -3523,15 +3375,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "redox_syscall" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" -dependencies = [ - "bitflags", -] - [[package]] name = "regex" version = "1.12.3" @@ -3563,9 +3406,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" -version = "0.13.2" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" dependencies = [ "base64", "bytes", @@ -3622,7 +3465,7 @@ dependencies = [ "pretty-hex", "rand 0.8.6", "sdp-types", - "smallvec 1.15.1", + "smallvec", "tokio", "tokio-util", "url", @@ -3670,23 +3513,23 @@ dependencies = [ [[package]] name = "rpassword" -version = "7.4.0" +version = "7.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66d4c8b64f049c6721ec8ccec37ddfc3d641c4a7fca57e8f2a89de509c73df39" +checksum = "5ac5b223d9738ef56e0b98305410be40fa0941bf6036c56f1506751e43552d64" dependencies = [ "libc", "rtoolbox", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "rtoolbox" -version = "0.0.3" +version = "0.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7cc970b249fbe527d6e02e0a227762c9108b2f49d81094fe357ffc6d14d7f6f" +checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3704,24 +3547,11 @@ dependencies = [ "semver", ] -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.59.0", -] - [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "aws-lc-rs", "once_cell", @@ -3745,9 +3575,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "web-time", "zeroize", @@ -3755,9 +3585,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.6.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation", "core-foundation-sys", @@ -3771,7 +3601,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3858,7 +3688,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ "base16ct", - "der 0.7.10", + "der", "generic-array", "pkcs8", "subtle", @@ -3882,7 +3712,7 @@ dependencies = [ "linfa", "linfa-clustering", "log", - "ndarray", + "ndarray 0.17.2", "openmls", "rand 0.9.4", "reqwest", @@ -3947,10 +3777,9 @@ dependencies = [ "include_dir", "libblur", "log", - "ndarray", + "ndarray 0.17.2", "once_cell", "ort", - "ort-sys", "rayon", "rusttype", "serde", @@ -4112,6 +3941,16 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + [[package]] name = "simd_helpers" version = "0.1.0" @@ -4121,6 +3960,12 @@ dependencies = [ "quote", ] +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.12" @@ -4133,12 +3978,6 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "smallvec" -version = "2.0.0-alpha.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d44cfb396c3caf6fbfd0ab422af02631b69ddd96d2eff0b0f0724f9024051b" - [[package]] name = "socket2" version = "0.6.3" @@ -4149,17 +3988,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "socks" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" -dependencies = [ - "byteorder", - "libc", - "winapi", -] - [[package]] name = "space" version = "0.19.0" @@ -4187,7 +4015,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der 0.7.10", + "der", ] [[package]] @@ -4196,10 +4024,10 @@ version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "704ef26d974e8a452313ed629828cd9d4e4fa34667ca1ad9d6b1fffa43c6e166" dependencies = [ - "ndarray", + "ndarray 0.16.1", "num-complex", "num-traits", - "smallvec 1.15.1", + "smallvec", ] [[package]] @@ -4271,30 +4099,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" -[[package]] -name = "tar" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" -dependencies = [ - "filetime", - "libc", - "xattr", -] - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.2", - "once_cell", - "rustix", - "windows-sys 0.59.0", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -4398,9 +4202,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.51.1" +version = "1.52.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386" dependencies = [ "bytes", "libc", @@ -4570,9 +4374,9 @@ checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "unicase" @@ -4614,36 +4418,6 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" -[[package]] -name = "ureq" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" -dependencies = [ - "base64", - "der 0.8.0", - "log", - "native-tls", - "percent-encoding", - "rustls-pki-types", - "socks", - "ureq-proto", - "utf8-zero", - "webpki-root-certs", -] - -[[package]] -name = "ureq-proto" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" -dependencies = [ - "base64", - "http", - "httparse", - "log", -] - [[package]] name = "url" version = "2.5.8" @@ -4656,12 +4430,6 @@ dependencies = [ "serde", ] -[[package]] -name = "utf8-zero" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -4676,9 +4444,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.0" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -4696,12 +4464,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "version_check" version = "0.9.5" @@ -4735,11 +4497,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", ] [[package]] @@ -4748,14 +4510,14 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] name = "wasm-bindgen" -version = "0.2.118" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" dependencies = [ "cfg-if", "once_cell", @@ -4766,9 +4528,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.68" +version = "0.4.70" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084" dependencies = [ "js-sys", "wasm-bindgen", @@ -4776,9 +4538,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.118" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4786,9 +4548,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.118" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" dependencies = [ "bumpalo", "proc-macro2", @@ -4799,9 +4561,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.118" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" dependencies = [ "unicode-ident", ] @@ -4842,9 +4604,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.95" +version = "0.3.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" dependencies = [ "js-sys", "wasm-bindgen", @@ -4862,9 +4624,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.6" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" dependencies = [ "rustls-pki-types", ] @@ -4907,7 +4669,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5028,15 +4790,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -5057,26 +4810,20 @@ dependencies = [ [[package]] name = "windows-sys" -version = "0.61.2" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-link", + "windows-targets 0.53.5", ] [[package]] -name = "windows-targets" -version = "0.42.2" +name = "windows-sys" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", + "windows-link", ] [[package]] @@ -5088,7 +4835,7 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", @@ -5096,10 +4843,21 @@ dependencies = [ ] [[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" +name = "windows-targets" +version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] [[package]] name = "windows_aarch64_gnullvm" @@ -5108,10 +4866,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" +name = "windows_aarch64_gnullvm" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" [[package]] name = "windows_aarch64_msvc" @@ -5120,10 +4878,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] -name = "windows_i686_gnu" -version = "0.42.2" +name = "windows_aarch64_msvc" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" [[package]] name = "windows_i686_gnu" @@ -5131,6 +4889,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" @@ -5138,10 +4902,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] -name = "windows_i686_msvc" -version = "0.42.2" +name = "windows_i686_gnullvm" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" [[package]] name = "windows_i686_msvc" @@ -5150,10 +4914,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" +name = "windows_i686_msvc" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" [[package]] name = "windows_x86_64_gnu" @@ -5162,10 +4926,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" +name = "windows_x86_64_gnu" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" [[package]] name = "windows_x86_64_gnullvm" @@ -5174,10 +4938,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" +name = "windows_x86_64_gnullvm" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" [[package]] name = "windows_x86_64_msvc" @@ -5185,6 +4949,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.7.15" @@ -5203,6 +4973,12 @@ dependencies = [ "wit-bindgen-rust-macro", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "wit-bindgen-core" version = "0.51.0" @@ -5300,16 +5076,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "xattr" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" -dependencies = [ - "libc", - "rustix", -] - [[package]] name = "y4m" version = "0.8.0" @@ -5352,9 +5118,9 @@ dependencies = [ [[package]] name = "yuv" -version = "0.8.13" +version = "0.8.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d3a7e2cda3061858987ee2fb028f61695f5ee13f9490d75be6c3900df9a4ea" +checksum = "89c90da4fb561f9750984de2c5e7f0ba01035d2eb29d69a7f375b1caef37fdf4" dependencies = [ "num-traits", ] diff --git a/camera_hub/Cargo.toml b/camera_hub/Cargo.toml index 69f9861..4466db8 100644 --- a/camera_hub/Cargo.toml +++ b/camera_hub/Cargo.toml @@ -32,7 +32,7 @@ anyhow = "1.0.102" bytes = "1.11.1" futures = { version = "0.3.32", optional = true } serde_yaml2 = { version = "0.1.3", optional = true } -ndarray = { version = "=0.16.1", features = ["rayon"], optional = true } +ndarray = { version = "=0.17.2", features = ["rayon"], optional = true } crossbeam-channel = "0.5.15" cfg-if = "1.0.4" reqwest = { version = "0.13", default-features = false, features = ["blocking", "json"], optional = true } diff --git a/camera_hub/src/config.rs b/camera_hub/src/config.rs index cc1b080..cf79cec 100644 --- a/camera_hub/src/config.rs +++ b/camera_hub/src/config.rs @@ -4,6 +4,7 @@ use crate::DeliveryMonitor; use crate::pairing::get_names; +use crate::version::camera_version_info; use secluso_client_lib::config::{ Heartbeat, HeartbeatRequest, OPCODE_HEARTBEAT_REQUEST, OPCODE_HEARTBEAT_RESPONSE, AddAppRequest, AddAppResponseCommon, AddAppResponseDedicated, OPCODE_ADD_APP_REQUEST, OPCODE_ADD_APP_RESPONSE, @@ -108,12 +109,7 @@ fn send_heartbeat_response( timestamp: u64, http_client: &HttpClient, ) -> io::Result<()> { - let heartbeat = Heartbeat::generate( - clients_com, - clients_ded, - timestamp, - format!("v{}", env!("CARGO_PKG_VERSION")), - )?; + let heartbeat = Heartbeat::generate(clients_com, clients_ded, timestamp, camera_version_info()?)?; let mut config_msg = vec![OPCODE_HEARTBEAT_RESPONSE]; config_msg.extend(bincode::serialize(&heartbeat).unwrap()); @@ -237,4 +233,4 @@ fn handle_add_app_request( http_client.config_response(&clients_ded[CONFIG_DED].get_group_name().unwrap(), config_msg_enc)?; Ok(Some(new_clients_ded)) -} \ No newline at end of file +} diff --git a/camera_hub/src/main.rs b/camera_hub/src/main.rs index f7add0d..762b0c2 100644 --- a/camera_hub/src/main.rs +++ b/camera_hub/src/main.rs @@ -60,6 +60,8 @@ mod config; use crate::config::process_config_command; +mod version; + mod notification_target; use crate::notification_target::send_notification; @@ -91,6 +93,16 @@ const STATE_DIR_GENERAL: &str = "state"; const VIDEO_DIR_GENERAL: &str = "pending_videos"; const THUMBNAIL_DIR_GENERAL: &str = "pending_thumbnails"; +#[cfg(feature = "test")] +const VERSION_DIR: &str = "current_version"; +#[cfg(feature = "test")] +const VERSION_FILE: &str = "current_version/raspberry_camera_hub"; + +#[cfg(not(feature = "test"))] +const VERSION_DIR: &str = "/var/lib/secluso/current_version"; +#[cfg(not(feature = "test"))] +const VERSION_FILE: &str = "/var/lib/secluso/current_version/raspberry_camera_hub"; + // A counter representing the amount of active camera threads static GLOBAL_THREAD_COUNT: AtomicUsize = AtomicUsize::new(0); @@ -135,8 +147,9 @@ fn main() -> io::Result<()> { fs::create_dir_all(VIDEO_DIR_GENERAL).unwrap(); fs::create_dir_all(THUMBNAIL_DIR_GENERAL).unwrap(); - // Write current package version to a file to be used by the update service if needed. - fs::write("current_version", format!("v{}", env!("CARGO_PKG_VERSION")))?; + // Write the updater's component-scoped version marker + fs::create_dir_all(VERSION_DIR)?; + fs::write(VERSION_FILE, format!("v{}\n", env!("CARGO_PKG_VERSION")))?; cfg_if! { if #[cfg(feature = "manual")] { diff --git a/camera_hub/src/pairing.rs b/camera_hub/src/pairing.rs index 420306d..a28432d 100644 --- a/camera_hub/src/pairing.rs +++ b/camera_hub/src/pairing.rs @@ -5,10 +5,11 @@ use crate::initialize_mls_clients; use crate::notification_target::persist_notification_target; use crate::traits::Camera; +use crate::version::camera_version_info; use cfg_if::cfg_if; use openmls::prelude::KeyPackage; use rand::Rng; -use secluso_client_lib::http_client::HttpClient; +use secluso_client_lib::http_client::{HttpClient, PairingStatus}; use secluso_client_lib::mls_client::MlsClient; use secluso_client_lib::mls_clients::{MlsClients, CONFIG}; use secluso_client_lib::pairing::{self, generate_ip_camera_secret, MAX_ALLOWED_MSG_LEN}; @@ -70,7 +71,7 @@ fn read_varying_len(stream: &mut TcpStream) -> io::Result> { return Err(io::Error::new( ErrorKind::InvalidInput, "Intended message length is too large", - )) + )); } let mut msg = vec![0u8; len as usize]; @@ -125,7 +126,11 @@ fn perform_pairing_handshake( } pub fn get_input_camera_secret() -> Vec { - let pathname = "./camera_secret"; + let pathname = match std::env::var("SECLUSO_USE_PROVISION").as_deref() { + Ok("1") => "/provision/camera_secret", + _ => "./camera_secret", + }; + let file = File::open(pathname).expect( "Could not open file \"camera_secret\". You can generate this with the config_tool", ); @@ -138,7 +143,10 @@ pub fn get_input_camera_secret() -> Vec { // Read the WiFi password contents from file to use for the hotspot pub fn get_input_wifi_password() -> String { - let pathname = "./wifi_password"; + let pathname = match std::env::var("SECLUSO_USE_PROVISION").as_deref() { + Ok("1") => "/provision/wifi_password", + _ => "./wifi_password", + }; let contents = fs::read_to_string(pathname).expect("Failed to read from \"wifi_password\" file. You can generate this in config tool"); return contents; } @@ -190,8 +198,9 @@ fn receive_credentials_full(stream: &mut TcpStream, mls_client: &mut MlsClient) } fn send_firmware_version(stream: &mut TcpStream) -> io::Result<()> { - let msg = format!("v{}", env!("CARGO_PKG_VERSION")); - write_varying_len(stream, &msg.as_bytes())?; + let msg = serde_json::to_vec(&camera_version_info()?) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?; + write_varying_len(stream, &msg)?; Ok(()) } @@ -578,6 +587,34 @@ fn attempt_wifi_connection(ssid: String, password: String, server_addr: &str) -> ))) } +fn send_pairing_token_after_wifi_ready( + http_client: &HttpClient, + pairing_token: &str, +) -> io::Result { + let deadline = std::time::Instant::now() + Duration::from_secs(20); + let mut attempt = 1; + let mut last_error = None; + + while std::time::Instant::now() < deadline { + match http_client.send_pairing_token(pairing_token) { + Ok(status) => return Ok(status), + Err(e) => { + debug!("[Pairing] Pairing token POST attempt {attempt} failed: {e}"); + last_error = Some(e); + attempt += 1; + thread::sleep(Duration::from_secs(1)); + } + } + } + + Err(last_error.unwrap_or_else(|| { + io::Error::new( + io::ErrorKind::TimedOut, + "Timed out sending pairing token after Wi-Fi became ready", + ) + })) +} + fn bring_hotspot_back_up() -> io::Result<()> { debug!("[Pairing] Bringing hotspot back up..."); // If pairing fails, we want the device to recover back into the discoverable state @@ -589,16 +626,40 @@ fn bring_hotspot_back_up() -> io::Result<()> { } pub fn create_wifi_hotspot() { - // less fragile than shell parsing to use argv - let _ = Command::new("nmcli") - .args([ - "device", "wifi", "hotspot", "ssid", "Secluso", "password", get_input_wifi_password().as_str(), - ]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .unwrap() - .wait(); + let password = get_input_wifi_password(); + + loop { + // less fragile than shell parsing to use argv + match Command::new("nmcli") + .args([ + "device", + "wifi", + "hotspot", + "ssid", + "Secluso", + "password", + password.as_str(), + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + { + Ok(mut child) => match child.wait() { + Ok(status) if status.success() => return, + Ok(status) => { + debug!("[Pairing] Failed to create WiFi hotspot; nmcli exited with {status}"); + } + Err(e) => { + debug!("[Pairing] Failed to wait for WiFi hotspot creation: {e}"); + } + }, + Err(e) => { + debug!("[Pairing] Failed to start WiFi hotspot creation: {e}"); + } + } + + thread::sleep(Duration::from_secs(2)); + } } #[allow(clippy::too_many_arguments)] @@ -753,10 +814,10 @@ pub fn pair_all( Ok(_) => { changed_wifi = true; debug!("[Pairing] Attempting to confirm pairing..."); - match http_client - .unwrap() - .send_pairing_token(&pairing_token) - { + match send_pairing_token_after_wifi_ready( + &http_client.unwrap(), + &pairing_token, + ) { Ok(pairing_status) => { debug!( "[Pairing] Pairing token acknowledged with status: {}", diff --git a/camera_hub/src/version.rs b/camera_hub/src/version.rs new file mode 100644 index 0000000..508a3f4 --- /dev/null +++ b/camera_hub/src/version.rs @@ -0,0 +1,30 @@ +//! Camera hub version metadata. +//! +//! SPDX-License-Identifier: GPL-3.0-or-later + +use secluso_client_lib::config::CameraVersionInfo; +use std::fs; +use std::io; + +const OS_VERSION_PATH: &str = "/etc/secluso-os-version"; +const UNKNOWN_OS_VERSION: &str = "0.0.0"; + +pub fn camera_version_info() -> io::Result { + let os_version = match fs::read_to_string(OS_VERSION_PATH) { + Ok(raw) => { + let version = raw.trim(); + if version.is_empty() { + UNKNOWN_OS_VERSION.to_string() + } else { + version.to_string() + } + } + Err(e) if e.kind() == io::ErrorKind::NotFound => UNKNOWN_OS_VERSION.to_string(), + Err(e) => return Err(e), + }; + + Ok(CameraVersionInfo { + firmware_version: format!("v{}", env!("CARGO_PKG_VERSION")), + os_version, + }) +} diff --git a/client_lib/src/config.rs b/client_lib/src/config.rs index fa484a5..958651d 100644 --- a/client_lib/src/config.rs +++ b/client_lib/src/config.rs @@ -82,9 +82,16 @@ impl HeartbeatRequest { } } +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CameraVersionInfo { + pub firmware_version: String, + pub os_version: String, +} + #[derive(Serialize, Deserialize)] pub struct Heartbeat { pub firmware_version: String, + pub os_version: String, pub timestamp: u64, pub epochs: Vec, //for motion and livestream MLS clients pub ciphertexts: Vec>, //for all MLS clients except for config @@ -95,7 +102,7 @@ impl Heartbeat { clients_com: &mut MlsClientsCommon, clients_ded: &mut MlsClientsDedicated, timestamp: u64, - firmware_version: String, + version_info: CameraVersionInfo, ) -> io::Result { let mut ciphertexts: Vec> = vec![]; let mut epochs: Vec = vec![]; @@ -123,7 +130,8 @@ impl Heartbeat { epochs.push(epoch); Ok(Self { - firmware_version, + firmware_version: version_info.firmware_version, + os_version: version_info.os_version, timestamp, epochs, ciphertexts, @@ -228,4 +236,4 @@ pub struct AddAppResponseDedicated { pub camera_key_package: KeyPackage, pub welcome_msg_vec: Vec, pub group_name: String, -} \ No newline at end of file +} diff --git a/deploy/README.md b/deploy/README.md index 241cf16..9f36605 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,10 +1,10 @@ Secluso deploy tool (developer notes) -This repo is the full deploy workflow for Secluso. It builds a Raspberry Pi image, provisions a server over SSH, and shows live status in the UI while Docker or SSH steps run. Binaries are installed via secluso-update, and signature checks are optional but supported. +This repo is the full deploy workflow for Secluso. It prepares a Raspberry Pi image, provisions a server over SSH, and shows live status in the UI while image or SSH steps run. Binaries and images are downloaded through secluso-update verification. -The UI lives in src/ with the SvelteKit pages, while src-tauri/ holds the backend commands and the shell scripts used for the image build and server provision. The image builder scripts live under src-tauri/assets/pi_hub/, the server install script lives under src-tauri/assets/server/, and the test/ folder has the manual harness and fixtures. +The UI lives in src/ with the SvelteKit pages, while src-tauri/ holds the backend commands used for image preparation and server provision. The server install script lives under src-tauri/assets/server/, and the test/ folder has the manual harness and fixtures. -For dev work you need node 18+, pnpm, rust 1.85.0, Docker Desktop, and the normal Tauri system deps for your OS. Install and run dev with: +For dev work you need node 18+, pnpm, rust 1.85.0, and the normal Tauri system deps for your OS. Install and run dev with: ``` pnpm install pnpm dev @@ -18,9 +18,8 @@ pnpm build pnpm tauri build ``` -There are two main flows. The image build flow collects output paths and optional dev settings, generates the camera secret through secluso-update, then builds the image in Docker and prefetches the hub binary. The server flow collects the SSH target plus credentials, generates user credentials with secluso-update, then runs the remote install script and enables services. +The image flow collects output paths and optional dev settings, generates the camera_secret and wifi_password provisioning files locally, downloads and verifies the prebuilt Pi image through secluso-update library, then injects those generated files into the image's /provision partition. -Updater wise, we bootstrap secluso-update by building it from the update submodule pinned to the latest release tag. That bootstrap updater fetches the server or hub binary before any service starts. After that, we install the bundled secluso-update from the release zip so runtime updates use the distributed binary for the target arch. - -Developer settings are stored in localStorage under secluso-dev-settings. Developer mode lets you set a Wi‑Fi preset for first boot and a custom repo plus signature keys for updater verification. Signature keys are passed as name:github_user via --sig-key. +The server flow collects the SSH target plus credentials, generates user credentials locally, then runs the remote install script and enables services. +Developer settings are stored in localStorage under secluso-dev-settings. Developer mode lets you set a custom repo plus signature keys for updater verification. Signature keys are passed as name:github_user via --sig-key. diff --git a/deploy/src-tauri/Cargo.lock b/deploy/src-tauri/Cargo.lock index 23f8d77..4b712c4 100644 --- a/deploy/src-tauri/Cargo.lock +++ b/deploy/src-tauri/Cargo.lock @@ -1648,6 +1648,18 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fatfs" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05669f8e7e2d7badc545c513710f0eba09c2fbef683eb859fd79c46c355048e0" +dependencies = [ + "bitflags 1.3.2", + "byteorder", + "chrono", + "log", +] + [[package]] name = "fax" version = "0.2.6" @@ -5499,6 +5511,7 @@ dependencies = [ "anyhow", "base64 0.21.7", "base64-url", + "fatfs", "image", "openmls_rust_crypto", "openmls_traits", @@ -5507,6 +5520,7 @@ dependencies = [ "secluso-client-lib", "secluso-client-server-lib", "secluso-update", + "semver", "serde", "serde_json", "sha2", diff --git a/deploy/src-tauri/Cargo.toml b/deploy/src-tauri/Cargo.toml index caad3e3..54deafd 100644 --- a/deploy/src-tauri/Cargo.toml +++ b/deploy/src-tauri/Cargo.toml @@ -28,6 +28,7 @@ tauri = { version = "2.10.3", features = [] } tauri-plugin-opener = "2.5.3" serde = { version = "1", features = ["derive"] } serde_json = "1" +semver = "1" tauri-plugin-dialog = "2.7.0" anyhow = "1" tempfile = "3" @@ -46,9 +47,11 @@ qrcode = "0.14.1" base64-url = "3.0.3" openmls_traits = "=0.5.0" openmls_rust_crypto = "=0.5.1" +fatfs = "0.3.6" # Keep OpenSSL handling target-specific: +# TODO: replace with russh [https://github.com/Eugeny/russh] to get rid of OpenSSL # macOS: vendored OpenSSL avoids host/pkg-config discovery issues when building # x86_64-apple-darwin on Apple Silicon hosts. diff --git a/deploy/src-tauri/assets/pi_hub/Dockerfile b/deploy/src-tauri/assets/pi_hub/Dockerfile deleted file mode 100755 index 1452dfa..0000000 --- a/deploy/src-tauri/assets/pi_hub/Dockerfile +++ /dev/null @@ -1,16 +0,0 @@ -# SPDX-License-Identifier: GPL-3.0-or-later -FROM docker.io/debian@sha256:1d6cd964917a13b547d1ea392dff9a000c3f36070686ebc5c8755d53fb374435 - -RUN set -eux; \ - apt-get update; \ - apt-get install -y --no-install-recommends \ - bash ca-certificates curl xz-utils jq \ - util-linux kmod udev \ - unzip \ - parted dosfstools e2fsprogs rsync \ - ; \ - rm -rf /var/lib/apt/lists/* - -WORKDIR /app -COPY build.sh /app/build.sh -ENTRYPOINT ["bash", "/app/build.sh"] diff --git a/deploy/src-tauri/assets/pi_hub/build_image.sh b/deploy/src-tauri/assets/pi_hub/build_image.sh deleted file mode 100755 index 124c132..0000000 --- a/deploy/src-tauri/assets/pi_hub/build_image.sh +++ /dev/null @@ -1,602 +0,0 @@ -#!/usr/bin/env bash -# SPDX-License-Identifier: GPL-3.0-or-later -set -euo pipefail - -WORK=/work -OUT=/out -CFG="$WORK/config.json" - -run() { echo "+ $*" >&2; "$@"; } -jqr() { jq -r "$1" "$CFG"; } - -emit() { - local level="$1" step="$2" msg="$3" - msg="${msg//\"/\\\"}" - printf '::SECLUSO_EVENT::{"level":"%s","step":"%s","msg":"%s"}\n' "$level" "$step" "$msg" -} - -verify_sha256() { - local path="$1" - local expected_sha="$2" - local actual_sha - - actual_sha="$(sha256sum "$path" | awk '{print $1}')" - [ "$actual_sha" = "$expected_sha" ] || { - echo "SHA-256 mismatch for $path" >&2 - echo " expected: $expected_sha" >&2 - echo " actual: $actual_sha" >&2 - exit 1 - } -} - -ensure_loop_devices() { - # Fixes case where the host already had many loop devices - # in use (for example snap mounts), and a failed run could also leave stale mappings behind. This helper makes - # sure loop support is present and that the container sees enough /dev/loopN device files to request a free slot. - - local loop_max="${LOOP_MAX:-63}" - - if command -v modprobe >/dev/null 2>&1; then - modprobe loop "max_loop=$((loop_max + 1))" 2>/dev/null || modprobe loop 2>/dev/null || true - fi - - if [[ ! -e /dev/loop-control ]]; then - mknod -m 0600 /dev/loop-control c 10 237 2>/dev/null || true - fi - - for i in $(seq 0 "$loop_max"); do - [[ -b "/dev/loop$i" ]] || mknod -m 0660 "/dev/loop$i" b 7 "$i" 2>/dev/null || true - done - - if [[ ! -e /dev/loop-control ]]; then - echo "Missing /dev/loop-control. Run Docker with --privileged and ensure the host loop module is available." >&2 - exit 1 - fi - - if ! compgen -G "/dev/loop[0-9]*" > /dev/null; then - echo "No /dev/loopN devices were found. Run Docker with --privileged and ensure the host loop module is loaded." >&2 - exit 1 - fi -} - -cleanup_stale_work_loops() { - # Old loop mappings for working.img from previous failed runs can keep - # consuming loop slots even after the file is gone. We detach only mappings tied to this build path so we free - # capacity for the current run without messing with unrelated loop devices. - while read -r dev; do - [[ -n "$dev" ]] || continue - losetup -d "$dev" 2>/dev/null || true - done < <(losetup -a 2>/dev/null | awk -F: '/\/work\/working\.img|\/working\.img/{print $1}') -} - -BASE_IMAGE="$(jqr '.base_image')" -RPICAM_APPS_COMMIT="$(jqr '.rpicam_apps_commit')" -OUT_NAME="$(jqr '.output_name')" -HOSTNAME="$(jqr '.hostname')" - -USER_NAME="$(jqr '.user.name')" -USER_PASS="$(jqr '.user.password')" - -SSH_ENABLE="$(jqr '.ssh.enable // false')" -HAS_WIFI="$(jqr '.wifi != null')" -VARIANT="$(jqr '.variant // "diy"')" - -HAS_SECLUSO="$(jqr '.secluso != null')" -if [[ "$HAS_SECLUSO" == "true" ]]; then - SECLUSO_INSTALL_DIR="$(jqr '.secluso.install_dir // "/opt/secluso"')" - SECLUSO_ETC_DIR="$(jqr '.secluso.etc_dir // "/etc/secluso"')" - SECLUSO_REPO="$(jqr '.secluso.repo // "secluso/secluso"')" -fi - -PKGS="$(jqr '(.apt.packages // []) | join(" ")')" - -cd "$WORK" - -emit "info" "base_image" "Preparing base image..." - -# fetch base image -if [[ "$BASE_IMAGE" == http://* || "$BASE_IMAGE" == https://* ]]; then - base_image_sha256="6ac3a10a1f144c7e9d1f8e568d75ca809288280a593eb6ca053e49b539f465a4" - fname="$(basename "$BASE_IMAGE")" - if [[ ! -f "$fname" ]]; then - run curl -L -o "$fname" "$BASE_IMAGE" - fi - verify_sha256 "$fname" "$base_image_sha256" - BASE_PATH="$WORK/$fname" -else - if [[ -f "$BASE_IMAGE" ]]; then BASE_PATH="$BASE_IMAGE" - elif [[ -f "$WORK/$BASE_IMAGE" ]]; then BASE_PATH="$WORK/$BASE_IMAGE" - else echo "Base image not found: $BASE_IMAGE"; exit 1; fi -fi -IMG="$BASE_PATH" -if [[ "$IMG" == *.xz ]]; then - if [[ ! -f "${IMG%.xz}" ]]; then run xz -dk "$IMG"; fi - IMG="${IMG%.xz}" -fi - -WORK_IMG="$WORK/working.img" -run cp -f "$IMG" "$WORK_IMG" - -# grow image and root partition -# add 4g to the image file -run truncate -s +4G "$WORK_IMG" - -# expand partition 2 to fill the image -run parted -s "$WORK_IMG" resizepart 2 100% - -# mount partitions by offset -# read partition offsets and sizes -BOOT_OFF="$(parted -ms "$WORK_IMG" unit B print | awk -F: '$1=="1"{gsub("B","",$2); print $2}')" -BOOT_SIZE="$(parted -ms "$WORK_IMG" unit B print | awk -F: '$1=="1"{gsub("B","",$4); print $4}')" - -ROOT_OFF="$(parted -ms "$WORK_IMG" unit B print | awk -F: '$1=="2"{gsub("B","",$2); print $2}')" -ROOT_SIZE="$(parted -ms "$WORK_IMG" unit B print | awk -F: '$1=="2"{gsub("B","",$4); print $4}')" - -if [[ -z "$BOOT_OFF" || -z "$ROOT_OFF" || -z "$BOOT_SIZE" || -z "$ROOT_SIZE" ]]; then - echo "Failed to parse partition offsets/sizes via parted" >&2 - parted -s "$WORK_IMG" print >&2 || true - exit 1 -fi - -MNT="$WORK/mnt" -BOOT="$MNT/boot" -ROOT="$MNT/root" -run mkdir -p "$BOOT" "$ROOT" - -# create loop devices for boot and root -if [[ ! -f "$WORK_IMG" ]]; then - echo "Working image is missing: $WORK_IMG" >&2 - ls -la "$WORK" >&2 || true - exit 1 -fi - -ensure_loop_devices -cleanup_stale_work_loops - -if ! LOOP_ROOT="$(losetup --find --show --offset "$ROOT_OFF" --sizelimit "$ROOT_SIZE" "$WORK_IMG")"; then - echo "Failed to attach root loop device from $WORK_IMG." >&2 - ls -l /dev/loop* /dev/loop-control >&2 || true - losetup -a >&2 || true - exit 1 -fi - -if ! LOOP_BOOT="$(losetup --find --show --offset "$BOOT_OFF" --sizelimit "$BOOT_SIZE" "$WORK_IMG")"; then - echo "Failed to attach boot loop device from $WORK_IMG." >&2 - ls -l /dev/loop* /dev/loop-control >&2 || true - losetup -a >&2 || true - losetup -d "$LOOP_ROOT" 2>/dev/null || true - exit 1 -fi - -echo "+ LOOP_ROOT=$LOOP_ROOT (rootfs)" >&2 -echo "+ LOOP_BOOT=$LOOP_BOOT (bootfs)" >&2 - -# grow ext4 to fill the root partition -run e2fsck -f -y "$LOOP_ROOT" -run resize2fs "$LOOP_ROOT" -cleanup() { - set +e - run umount -R "$ROOT/dev" 2>/dev/null || true - run umount -R "$ROOT/proc" 2>/dev/null || true - run umount -R "$ROOT/sys" 2>/dev/null || true - run umount "$BOOT" 2>/dev/null || true - run umount "$ROOT" 2>/dev/null || true - run losetup -d "$LOOP_BOOT" 2>/dev/null || true - run losetup -d "$LOOP_ROOT" 2>/dev/null || true -} -trap cleanup EXIT - -emit "info" "mount" "Mounting partitions..." - -# mount root and boot -run mount "$LOOP_ROOT" "$ROOT" -run mount "$LOOP_BOOT" "$BOOT" - -# enable ssh for headless setup -if [[ "$SSH_ENABLE" == "true" ]]; then - run touch "$BOOT/ssh" || true -fi - -if [[ "$HAS_WIFI" == "true" ]]; then - WIFI_COUNTRY="$(jqr '.wifi.country')" - WIFI_SSID="$(jqr '.wifi.ssid')" - WIFI_PSK="$(jqr '.wifi.psk')" -fi - -write_custom_toml() { - # bookworm headless flow in bootfs - cat > "$BOOT/custom.toml" < 0' "$CFG" >/dev/null 2>&1; then - # toml array of strings - echo -n 'authorized_keys = [' >> "$BOOT/custom.toml" - jq -r '.ssh.authorized_keys[]' "$CFG" | awk 'BEGIN{first=1}{gsub(/\\/,"\\\\"); gsub(/"/,"\\\""); if(!first) printf(", "); first=0; printf("\"%s\"", $0)} END{print "]"}' >> "$BOOT/custom.toml" - fi - - if [[ "$HAS_WIFI" == "true" ]]; then - cat >> "$BOOT/custom.toml" < "$ROOT/etc/hostname" -if [[ -f "$ROOT/etc/hosts" ]]; then - # keep 127.0.1.1 aligned - sed -i "s/^127\.0\.1\.1.*/127.0.1.1\t$HOSTNAME/" "$ROOT/etc/hosts" || true -fi - -# create user and password in chroot -# bind mounts for chroot -run mount --bind /dev "$ROOT/dev" -run mount --bind /proc "$ROOT/proc" -run mount --bind /sys "$ROOT/sys" - -# copy dns settings into chroot -if [[ -f /etc/resolv.conf ]]; then - run cp -f /etc/resolv.conf "$ROOT/etc/resolv.conf" -fi - -# user and password -run chroot "$ROOT" bash -lc "id -u '$USER_NAME' >/dev/null 2>&1 || useradd -m -s /bin/bash -G sudo '$USER_NAME'" -run chroot "$ROOT" bash -lc "echo '$USER_NAME:$USER_PASS' | chpasswd" - -# write a build marker for easy verification -build_stamp="$(date -u +%Y-%m-%dT%H:%M:%SZ)" -cat > "$ROOT/etc/secluso-build.txt" < "$ROOT/home/$USER_NAME/secluso-build.txt" < "$AUTH_KEYS" || true -if [[ -s "$AUTH_KEYS" ]]; then - run chroot "$ROOT" bash -lc "install -d -m 700 -o '$USER_NAME' -g '$USER_NAME' /home/'$USER_NAME'/.ssh" - run install -m 600 "$AUTH_KEYS" "$ROOT/home/$USER_NAME/.ssh/authorized_keys" - run chroot "$ROOT" bash -lc "chown '$USER_NAME:$USER_NAME' /home/'$USER_NAME'/.ssh/authorized_keys" - # disable password ssh if keys exist - if [[ -f "$ROOT/etc/ssh/sshd_config" ]]; then - run chroot "$ROOT" bash -lc "sed -i 's/^#\\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config || true" - fi -fi - -# install apt packages inside image -if [[ -n "$PKGS" ]]; then - emit "info" "packages" "Installing base packages..." - run chroot "$ROOT" bash -lc "apt-get update" - run chroot "$ROOT" bash -lc "DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $PKGS" -fi - -if [[ "$VARIANT" == "official" ]]; then - emit "info" "official" "Applying official variant customizations..." - # official-only customization area. Keep extra setup that should not land in DIY images here - : -fi - -if [[ "$HAS_SECLUSO" == "true" ]]; then - ARCHDIR_AARCH64="aarch64-unknown-linux-gnu" - BUNDLE_ZIP="$WORK/secluso_bundle.zip" - - emit "info" "secluso" "Installing Secluso hub binaries and config..." - # create dirs - run mkdir -p "$ROOT${SECLUSO_INSTALL_DIR}/bin" - run mkdir -p "$ROOT${SECLUSO_ETC_DIR}" - run chmod 700 "$ROOT${SECLUSO_ETC_DIR}" || true - run chroot "$ROOT" bash -lc "chmod 700 '${SECLUSO_ETC_DIR}' || true" - - # runtime dir for state and logs - run mkdir -p "$ROOT/var/lib/secluso" - run chmod 700 "$ROOT/var/lib/secluso" - - # copy camera secret into runtime dir - if [[ -f "$WORK/camera_secret" ]]; then - run install -m 600 "$WORK/camera_secret" "$ROOT/var/lib/secluso/camera_secret" - fi - - # copy wifi password into runtime dir - if [[ -f "$WORK/wifi_password" ]]; then - run install -m 600 "$WORK/wifi_password" "$ROOT/var/lib/secluso/wifi_password" - fi - - [[ -f "$BUNDLE_ZIP" ]] || { - emit "error" "secluso" "Missing required bundle at $BUNDLE_ZIP" - exit 1 - } - - emit "info" "secluso" "Installing updater and hub from provided bundle..." - rm -rf /tmp/secluso_bundle && mkdir -p /tmp/secluso_bundle - unzip -o "$BUNDLE_ZIP" -d /tmp/secluso_bundle >/dev/null - root="/tmp/secluso_bundle" - maybe="$(find /tmp/secluso_bundle -maxdepth 2 -type f -name manifest.json | head -n 1 || true)" - if [[ -n "$maybe" ]]; then - root="$(dirname "$maybe")" - fi - if [[ -x "$root/artifacts/$ARCHDIR_AARCH64/secluso-update" ]]; then - run install -m 0755 "$root/artifacts/$ARCHDIR_AARCH64/secluso-update" "$ROOT${SECLUSO_INSTALL_DIR}/bin/secluso-update" - updater_name="secluso-update" - else - echo "Missing secluso-update in provided bundle" >&2 - exit 1 - fi - - if [[ -x "$root/artifacts/$ARCHDIR_AARCH64/secluso-raspberry-camera-hub" ]]; then - run install -m 0755 "$root/artifacts/$ARCHDIR_AARCH64/secluso-raspberry-camera-hub" "$ROOT${SECLUSO_INSTALL_DIR}/bin/secluso-raspberry-camera-hub" - else - echo "Missing secluso-raspberry-camera-hub in provided bundle" >&2 - exit 1 - fi - - SIG_ARGS="" - if jq -e '.secluso.sig_keys | length > 0' "$CFG" >/dev/null 2>&1; then - while read -r key; do - SIG_ARGS="$SIG_ARGS --sig-key $key" - done < <(jq -r '.secluso.sig_keys[] | if (.fingerprint // "") != "" then "\(.name):\(.github_user):\(.fingerprint)" else "\(.name):\(.github_user)" end' "$CFG") - fi - - run chroot "$ROOT" bash -lc "cd '${SECLUSO_INSTALL_DIR}/bin' && './${updater_name}' --help 2>/dev/null | grep -q -- '--component' || exit 1" - emit "info" "secluso" "Hub binary preinstalled from provided bundle; skipping one-shot updater run." - - if [[ ! -x "$ROOT${SECLUSO_INSTALL_DIR}/bin/secluso-raspberry-camera-hub" ]]; then - emit "error" "secluso" "hub binary missing after install" - exit 1 - fi - - emit "info" "secluso" "Bundled updater installed from provided bundle." - - # systemd unit - cat > "$ROOT/etc/systemd/system/secluso_camera_hub.service" < "$ROOT/etc/systemd/system/secluso-wifi-radio.service" < "$ROOT/etc/systemd/system/secluso-updater.service" <&2 - - # deps - run chroot "$ROOT" bash -lc "apt-get update" - - install_pinned_libcamera() { - local ver="0.4.0+rpt20250213-1" - local arch="arm64" - local base="https://archive.raspberrypi.com/debian/pool/main/libc/libcamera" - local libcamera0_4_sha256="c505ae5c3311c82b8cd8257da4288aacb50717e394a494c205f89fdc9385f72b" - local libcamera_ipa_sha256="ff88b7089155f85fb939a0588cc2df859fa119847d7e7b8fbab33790063b6622" - local libcamera_dev_sha256="defeb14a390df0bb806e1c4cce4329a761d4e7ede9c31ba979e6662f0a8fdb07" - local libcamera_tools_sha256="aa7dacdc7b71d2a6da8566c13adb12133925304da40bba938553eb8b33d9617d" - local python3_libcamera_sha256="f17ab305580a2efa81246b7b2035f1c7ca9a790e9d0abed9d097b005a9077f40" - - echo "+ Pinning libcamera stack to $ver" >&2 - - # make sure curl and ca certs exist - run chroot "$ROOT" bash -lc "apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ca-certificates curl" - - # pin 0.4 to avoid apt pulling 0.5 - run chroot "$ROOT" bash -lc "cat > /etc/apt/preferences.d/secluso-libcamera <<'EOF' -Package: libcamera0.4 libcamera-ipa libcamera-dev libcamera-tools python3-libcamera -Pin: version 0.4.* -Pin-Priority: 1001 - -Package: libcamera0.5 libcamera-ipa -Pin: version 0.5.* -Pin-Priority: -10 -EOF" - - # remove newer libcamera packages - run chroot "$ROOT" bash -lc "DEBIAN_FRONTEND=noninteractive apt-get remove -y 'libcamera0.5*' 'libcamera-ipa' 'libcamera-tools' 'libcamera-dev' 'python3-libcamera*' || true; apt-get autoremove -y || true" - - # install deps needed for 0.4 - run chroot "$ROOT" bash -lc "DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends libpisp1 liblttng-ust1 libunwind8 libevent-2.1-7 libevent-pthreads-2.1-7 libsdl2-2.0-0" - - # download pinned debs - run chroot "$ROOT" bash -lc "set -eux; - cd /tmp; - curl -fsSL -O '$base/libcamera0.4_${ver}_${arch}.deb'; - curl -fsSL -O '$base/libcamera-ipa_${ver}_${arch}.deb'; - curl -fsSL -O '$base/libcamera-dev_${ver}_${arch}.deb'; - curl -fsSL -O '$base/libcamera-tools_${ver}_${arch}.deb' || true; - curl -fsSL -O '$base/python3-libcamera_${ver}_${arch}.deb' || true; - echo '$libcamera0_4_sha256 /tmp/libcamera0.4_${ver}_${arch}.deb' | sha256sum -c -; - echo '$libcamera_ipa_sha256 /tmp/libcamera-ipa_${ver}_${arch}.deb' | sha256sum -c -; - echo '$libcamera_dev_sha256 /tmp/libcamera-dev_${ver}_${arch}.deb' | sha256sum -c -; - if [ -f /tmp/libcamera-tools_${ver}_${arch}.deb ]; then echo '$libcamera_tools_sha256 /tmp/libcamera-tools_${ver}_${arch}.deb' | sha256sum -c -; fi; - if [ -f /tmp/python3-libcamera_${ver}_${arch}.deb ]; then echo '$python3_libcamera_sha256 /tmp/python3-libcamera_${ver}_${arch}.deb' | sha256sum -c -; fi; - " - - # install debs - run chroot "$ROOT" bash -lc "set -eux; - dpkg -i /tmp/libcamera*_${ver}_${arch}.deb /tmp/python3-libcamera*_${ver}_${arch}.deb; - " || { - echo "libcamera install failed, dumping versions" >&2 - run chroot "$ROOT" bash -lc "dpkg -l | grep -E 'libcamera|libpisp|lttng|unwind|libevent|libsdl2' || true" >&2 - exit 1 - } - - # hold libcamera packages - run chroot "$ROOT" bash -lc "apt-mark hold libcamera0.4 libcamera-ipa libcamera-dev libcamera-tools python3-libcamera || true" -} - install_pinned_libcamera - run chroot "$ROOT" bash -lc "DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - git ca-certificates \ - libepoxy-dev libjpeg-dev libtiff5-dev libpng-dev \ - cmake libboost-program-options-dev libdrm-dev libexif-dev \ - meson ninja-build \ - pkg-config" - - if [[ ! -f "$WORK/rpicam_apps/rpicam-vid" ]]; then - echo "rpicam-apps not cached" - - # clone for each build - run chroot "$ROOT" bash -lc "rm -rf '$src_dir' && mkdir -p /opt" - run chroot "$ROOT" bash -lc "git clone --depth 1 '$repo_url' '$src_dir'" - - # pin revision if provided - if [[ -n "$repo_ref" && "$repo_ref" != "main" ]]; then - run chroot "$ROOT" bash -lc "cd '$src_dir' && git fetch --depth 1 origin '$repo_ref' || true; git checkout '$repo_ref'" - fi - - # build and install - run chroot "$ROOT" bash -lc "cd '$src_dir' && rm -rf build" - run chroot "$ROOT" bash -lc "cd '$src_dir' && meson setup build \ - -Denable_libav=disabled \ - -Denable_drm=enabled \ - -Denable_egl=disabled \ - -Denable_qt=disabled \ - -Denable_opencv=disabled \ - -Denable_tflite=disabled \ - -Denable_hailo=disabled" - - run chroot "$ROOT" bash -lc "cd '$src_dir' && meson compile -C build -j 1" - if ! run chroot "$ROOT" bash -lc "test -s '$src_dir/build/apps/rpicam-vid'"; then - emit "error" "rpicam" "rpicam-vid build failed or output is empty" - run chroot "$ROOT" bash -lc "ls -la '$src_dir/build/apps' || true" - exit 1 - fi - run chroot "$ROOT" bash -lc "cd '$src_dir' && meson install -C build" - else - echo "Using cached rpicam-apps" - run chroot "$ROOT" bash -lc "mkdir -p '$src_dir'/build/apps" - run cp "$WORK"/rpicam_apps/* "$ROOT$src_dir"/build/apps - fi - - # copy binaries if install did not place them in path - run chroot "$ROOT" bash -lc "mkdir -p /usr/local/bin" - if ! run chroot "$ROOT" bash -lc "command -v rpicam-vid >/dev/null 2>&1"; then - emit "warn" "rpicam" "rpicam-vid not in path, copying from build/apps" - run chroot "$ROOT" bash -lc "if [ -d '$src_dir/build/apps' ]; then install -m 0755 '$src_dir'/build/apps/rpicam-* /usr/local/bin/; fi" - fi - - if [[ ! -f "$WORK/rpicam_apps/rpicam-vid" ]]; then - # we want to save rpicam-hello, rpicam-jpeg, rpicam-raw, rpicam-still, rpicam-vid for re-use in caching - run chroot "$ROOT" bash -lc "ls '$src_dir'/build/apps" - mkdir "$WORK"/rpicam_apps - cp "$ROOT$src_dir"/build/apps/rpicam-hello "$WORK"/rpicam_apps/. - cp "$ROOT$src_dir"/build/apps/rpicam-jpeg "$WORK"/rpicam_apps/. - cp "$ROOT$src_dir"/build/apps/rpicam-raw "$WORK"/rpicam_apps/. - cp "$ROOT$src_dir"/build/apps/rpicam-still "$WORK"/rpicam_apps/. - cp "$ROOT$src_dir"/build/apps/rpicam-vid "$WORK"/rpicam_apps/. - fi -} - -# run install -emit "info" "rpicam" "Building rpicam-apps..." -install_rpicam_apps "https://github.com/secluso/rpicam-apps.git" "${RPICAM_APPS_COMMIT}" -# pinned commit for repeatability - -# done, flush and unmount before copying -emit "info" "output" "Finalizing filesystem..." -run sync -cleanup -trap - EXIT - -emit "info" "output" "Writing final image..." -run cp -f "$WORK_IMG" "$OUT/$OUT_NAME" -echo "Wrote image: $OUT/$OUT_NAME" diff --git a/deploy/src-tauri/assets/server/provision_server.sh b/deploy/src-tauri/assets/server/provision_server.sh index 0cd6d57..f6faec9 100644 --- a/deploy/src-tauri/assets/server/provision_server.sh +++ b/deploy/src-tauri/assets/server/provision_server.sh @@ -3,7 +3,7 @@ set -euo pipefail # expected env vars -# install_prefix, owner_repo +# install_bin_dir, version_root, owner_repo # server_unit, updater_service, update_interval_secs # sudo_cmd ("" or "sudo -S -p ''" or "sudo"), enable_updater ("1"/"0") # bind_address, listen_port, first_install, overwrite @@ -24,7 +24,8 @@ else emit "info" "sudo" "No sudo wrapper (running as root)" fi -INSTALL_PREFIX="${INSTALL_PREFIX:-/opt/secluso}" +INSTALL_BIN_DIR="${INSTALL_BIN_DIR:-/usr/bin}" +VERSION_ROOT="${VERSION_ROOT:-/var/lib/secluso/current_version}" STATE_DIR="${STATE_DIR:-/var/lib/secluso}" SERVICE_USER="${SERVICE_USER:-secluso}" RELEASE_TAG="${RELEASE_TAG:-unknown}" @@ -41,7 +42,8 @@ if [[ -n "${SIG_KEYS:-}" ]]; then done fi -emit "info" "config" "INSTALL_PREFIX=$INSTALL_PREFIX" +emit "info" "config" "INSTALL_BIN_DIR=$INSTALL_BIN_DIR" +emit "info" "config" "VERSION_ROOT=$VERSION_ROOT" emit "info" "config" "STATE_DIR=$STATE_DIR" emit "info" "config" "OWNER_REPO=$OWNER_REPO" emit "info" "config" "SERVER_UNIT=$SERVER_UNIT" @@ -68,12 +70,13 @@ USER_CREDENTIALS_STAGE="$STAGING_DIR/user_credentials" CREDENTIALS_FULL_STAGE="$STAGING_DIR/credentials_full" if [[ "${OVERWRITE:-0}" == "1" ]]; then - emit "warn" "overwrite" "Overwrite enabled: stopping services and deleting Secluso install directories" + emit "warn" "overwrite" "Overwrite enabled: stopping services and deleting Secluso install state" ${SUDO} systemctl stop "$UPDATER_SERVICE" 2>/dev/null || true ${SUDO} systemctl stop "$SERVER_UNIT" 2>/dev/null || true ${SUDO} systemctl disable "$UPDATER_SERVICE" 2>/dev/null || true ${SUDO} systemctl disable "$SERVER_UNIT" 2>/dev/null || true - ${SUDO} rm -rf "$INSTALL_PREFIX" "$STATE_DIR" + ${SUDO} rm -f "$INSTALL_BIN_DIR/secluso-server" "$INSTALL_BIN_DIR/secluso-update" + ${SUDO} rm -rf "$STATE_DIR" fi emit "info" "deps" "Installing minimal runtime dependencies (apt-get)..." @@ -86,7 +89,7 @@ if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then fi emit "info" "install" "Ensuring install and state directories..." -${SUDO} mkdir -p "$INSTALL_PREFIX/bin" "$INSTALL_PREFIX/current_version" "$STATE_DIR" "$STATE_DIR/user_credentials" +${SUDO} mkdir -p "$INSTALL_BIN_DIR" "$VERSION_ROOT" "$STATE_DIR" "$STATE_DIR/user_credentials" if [[ ! -f "$SERVER_STAGE" ]]; then emit "error" "install" "Missing staged server binary" @@ -99,10 +102,10 @@ fi emit "info" "install" "Installing verified binaries..." # The uploaded files only become live binaries here. -${SUDO} install -m 0755 "$SERVER_STAGE" "$INSTALL_PREFIX/bin/secluso-server" -${SUDO} install -m 0755 "$UPDATER_STAGE" "$INSTALL_PREFIX/bin/secluso-update" -printf '%s\n' "${RELEASE_TAG#v}" | ${SUDO} tee "$INSTALL_PREFIX/current_version/server" >/dev/null -printf '%s\n' "${RELEASE_TAG#v}" | ${SUDO} tee "$INSTALL_PREFIX/current_version/updater" >/dev/null +${SUDO} install -m 0755 "$SERVER_STAGE" "$INSTALL_BIN_DIR/secluso-server" +${SUDO} install -m 0755 "$UPDATER_STAGE" "$INSTALL_BIN_DIR/secluso-update" +printf '%s\n' "${RELEASE_TAG#v}" | ${SUDO} tee "$VERSION_ROOT/server" >/dev/null +printf '%s\n' "${RELEASE_TAG#v}" | ${SUDO} tee "$VERSION_ROOT/updater" >/dev/null if [[ -f "$SERVICE_ACCOUNT_STAGE" ]]; then emit "info" "secrets" "Installing service account key" @@ -129,7 +132,7 @@ Type=simple User=$SERVICE_USER Group=$SERVICE_USER WorkingDirectory=$STATE_DIR -ExecStart=$INSTALL_PREFIX/bin/secluso-server --bind-address=${BIND_ADDRESS:-127.0.0.1} --port=${LISTEN_PORT:-8000} +ExecStart=$INSTALL_BIN_DIR/secluso-server --bind-address=${BIND_ADDRESS:-127.0.0.1} --port=${LISTEN_PORT:-8000} Restart=always RestartSec=1 Environment=RUST_LOG=info @@ -154,7 +157,7 @@ Wants=network-online.target [Service] Type=simple -ExecStart=$INSTALL_PREFIX/bin/secluso-update --component server --interval-secs $UPDATE_INTERVAL_SECS --github-timeout-secs 20 --restart-unit $SERVER_UNIT --github-repo $OWNER_REPO$SIG_ARGS --update-hint-path $STATE_DIR/update_hint --hint-check-interval-secs $HINT_CHECK_INTERVAL_SECS +ExecStart=$INSTALL_BIN_DIR/secluso-update --component server --interval-secs $UPDATE_INTERVAL_SECS --github-timeout-secs 20 --restart-unit $SERVER_UNIT --github-repo $OWNER_REPO$SIG_ARGS --update-hint-path $STATE_DIR/update_hint --hint-check-interval-secs $HINT_CHECK_INTERVAL_SECS Restart=always RestartSec=2 ${GITHUB_TOKEN:+Environment=GITHUB_TOKEN=$GITHUB_TOKEN} diff --git a/deploy/src-tauri/src/lib.rs b/deploy/src-tauri/src/lib.rs index 99e4a38..51f4f7c 100644 --- a/deploy/src-tauri/src/lib.rs +++ b/deploy/src-tauri/src/lib.rs @@ -3,7 +3,7 @@ mod pi_hub_provision; mod provision_server; -mod requirements; +mod release_config; mod open_external; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -12,11 +12,10 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .invoke_handler(tauri::generate_handler![ - pi_hub_provision::check_docker, - pi_hub_provision::build_image, + pi_hub_provision::prepare_image, pi_hub_provision::generate_user_credentials, - requirements::check_requirements, open_external::open_external_url, + release_config::get_deploy_version_status, provision_server::fetch_server_host_key, provision_server::test_server_ssh, provision_server::provision_server, diff --git a/deploy/src-tauri/src/pi_hub_provision/build.rs b/deploy/src-tauri/src/pi_hub_provision/build.rs deleted file mode 100755 index dbc05dc..0000000 --- a/deploy/src-tauri/src/pi_hub_provision/build.rs +++ /dev/null @@ -1,416 +0,0 @@ -//! SPDX-License-Identifier: GPL-3.0-or-later -use crate::pi_hub_provision::credentials::generate_secluso_credentials; -use crate::pi_hub_provision::docker::{docker_ready, err_to_string, run_with_output, write_docker_context}; -use crate::pi_hub_provision::events::{log_line, step_error, step_ok, step_start}; -use crate::pi_hub_provision::model::{Apt, Config, RuntimeConfig, Secluso, SigKey, Ssh, User}; -use crate::pi_hub_provision::temp::{shared_temp_root, shared_temp_dir}; -use crate::pi_hub_provision::{BuildImageRequest, BuildImageResponse}; -use anyhow::{anyhow, bail, Context, Result}; -use secluso_update::{ - build_github_client, default_signers, download_and_verify_component, fetch_latest_release, Component as ReleaseComponent, - Signer, -}; -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; -use tauri::AppHandle; -use uuid::Uuid; - -const DEFAULT_BASE_IMAGE: &str = "https://downloads.raspberrypi.com/raspios_lite_arm64/images/raspios_lite_arm64-2024-11-19/2024-11-19-raspios-bookworm-arm64-lite.img.xz"; -const DEFAULT_USER_NAME: &str = "pi"; -const DEFAULT_USER_PASSWORD: &str = "ChangeMe123!"; -const DEFAULT_HOSTNAME_OFFICIAL: &str = "secluso-camera"; -const DEFAULT_HOSTNAME_DIY: &str = "secluso-camera-diy"; -const DEFAULT_PLATFORM: &str = "linux/arm64"; -const DEFAULT_DOCKER_TAG: &str = "rpi-img-builder:local"; -const RPICAM_APPS_COMMIT: &str = "9159a2b0f9ab0fd535189ca0364e0539deaef6a4"; -const DEFAULT_PACKAGES: &[&str] = &[ - "ca-certificates", - "curl", - "jq", - "net-tools", - "vim", - "htop", -]; - -const LIST_OF_BINARIES: &[&str] = &["rpicam-hello", "rpicam-jpeg", "rpicam-raw", "rpicam-still", "rpicam-vid"]; - - -fn is_linux_x86() -> bool { - std::env::consts::OS == "linux" - && matches!(std::env::consts::ARCH, "x86" | "x86_64" | "i586" | "i686") -} - -fn has_qemu_user_static() -> bool { - let probe = Command::new("qemu-aarch64-static") - .arg("--version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .is_ok(); - if probe { - return true; - } - Command::new("qemu-user-static") - .arg("--version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .is_ok() -} - -fn normalize_repo(input: &str) -> String { - let trimmed = input.trim().trim_end_matches('/'); - if let Some(idx) = trimmed.find("github.com/") { - let repo = &trimmed[idx + "github.com/".len()..]; - return repo.trim_end_matches(".git").to_string(); - } - trimmed.trim_end_matches(".git").to_string() -} - -fn resolve_signers(sig_keys: Option<&[SigKey]>) -> Vec { - let Some(sig_keys) = sig_keys else { - return default_signers(); - }; - - if sig_keys.is_empty() { - return default_signers(); - } - - sig_keys - .iter() - .map(|key| Signer { - label: key.name.trim().to_string(), - github_user: key.github_user.trim().to_string(), - fingerprint: key - .fingerprint - .as_deref() - .map(str::trim) - .filter(|v| !v.is_empty()) - .map(ToOwned::to_owned), - }) - .collect() -} - -fn download_verified_bundle(owner_repo: &str, sig_keys: Option<&[SigKey]>, github_token: Option<&str>) -> Result<(String, Vec)> { - let signers = resolve_signers(sig_keys); - let client = build_github_client(20, github_token, "secluso-deploy")?; - let release = fetch_latest_release(&client, owner_repo) - .with_context(|| format!("Fetching latest release metadata for {owner_repo}"))?; - let verified = download_and_verify_component( - &client, - &release, - ReleaseComponent::RaspberryCameraHub, - "aarch64", - None, - &signers, - ) - .context("Downloading and verifying Raspberry Pi bundle")?; - - Ok((verified.release_tag, verified.bundle_bytes)) -} - -fn normalize_ssh_suffix(output_name: &str, ssh_enabled: bool) -> String { - if !output_name.ends_with(".img") { - return output_name.to_string(); - } - if ssh_enabled { - if output_name.ends_with("-ssh-enabled.img") { - return output_name.to_string(); - } - return format!("{}-ssh-enabled.img", &output_name[..output_name.len() - 4]); - } - if let Some(base) = output_name.strip_suffix("-ssh-enabled.img") { - return format!("{base}.img"); - } - output_name.to_string() -} - -pub fn run_build_image(app: &AppHandle, run_id: Uuid, req: BuildImageRequest) -> Result { - // input validation happens here so the ui can show a clear first failure - step_start(app, run_id, "validate", "Validating inputs"); - if !req.image_output_path.ends_with(".img") { - step_error(app, run_id, "validate", "Output image must end with .img."); - bail!("Output image must end with .img."); - } - if !req.qr_output_path.ends_with(".png") { - step_error(app, run_id, "validate", "QR output must end with .png."); - bail!("QR output must end with .png."); - } - step_ok(app, run_id, "validate"); - - // check docker early so we fail fast before doing work - step_start(app, run_id, "docker_check", "Checking Docker"); - let docker_ver = match docker_ready() { - Ok(version) => version, - Err(e) => { - let msg = err_to_string(e); - step_error(app, run_id, "docker_check", &msg); - return Err(anyhow!(msg)); - } - }; - log_line(app, run_id, "info", Some("docker_check"), docker_ver); - step_ok(app, run_id, "docker_check"); - - if is_linux_x86() { - step_start(app, run_id, "qemu_check", "Checking qemu-user-static"); - if !has_qemu_user_static() { - let msg = "qemu-user-static is required on Linux x86 hosts to build ARM images. Install it (e.g. sudo apt-get install -y qemu-user-static) and retry."; - step_error(app, run_id, "qemu_check", msg); - bail!(msg); - } - step_ok(app, run_id, "qemu_check"); - } - - // resolve output paths and build a config used by the image builder script - let output_path = PathBuf::from(&req.image_output_path); - let output_name = output_path - .file_name() - .and_then(|s| s.to_str()) - .ok_or_else(|| anyhow!("Invalid output image path: {}", req.image_output_path))?; - let ssh_enabled = req.ssh_enabled.unwrap_or(false); - let output_name = normalize_ssh_suffix(output_name, ssh_enabled); - let out_dir = output_path - .parent() - .filter(|p| !p.as_os_str().is_empty()) - .unwrap_or_else(|| Path::new(".")); - fs::create_dir_all(out_dir).with_context(|| format!("creating output dir {}", out_dir.display()))?; - - let variant = req.variant.as_deref().unwrap_or("diy"); - let hostname = if variant == "official" { - DEFAULT_HOSTNAME_OFFICIAL - } else { - DEFAULT_HOSTNAME_DIY - }; - - - // base config mirrors the test harness defaults - let cfg = Config { - output_name, - hostname: hostname.to_string(), - user: User { - name: DEFAULT_USER_NAME.to_string(), - password: DEFAULT_USER_PASSWORD.to_string(), - }, - ssh: Ssh { enable: ssh_enabled, authorized_keys: vec![] }, - wifi: req.wifi.clone(), - apt: Apt { packages: DEFAULT_PACKAGES.iter().map(|p| (*p).to_string()).collect() }, - secluso: Some(Secluso { - server_url: None, - camera_name: None, - release_mode: None, - release_tag: None, - asset_name: None, - asset_kind: None, - install_dir: None, - etc_dir: None, - repo: req.binaries_repo.as_ref().map(|repo| normalize_repo(repo)), - sig_keys: req.sig_keys.clone().map(|keys| { - keys - .into_iter() - .map(|k| SigKey { name: k.name, github_user: k.github_user, fingerprint: k.fingerprint }) - .collect() - }), - github_token: req.github_token.clone().filter(|v| !v.trim().is_empty()), - }), - }; - - // build the image builder container from the embedded Dockerfile - step_start(app, run_id, "docker_build", "Building image builder"); - let ctx = shared_temp_dir("secluso-docker-ctx").context("creating temp docker context")?; - write_docker_context(ctx.path())?; - - let mut build_cmd = Command::new("docker"); - build_cmd.args(["build", "--no-cache"]); - build_cmd - .args(["--platform", DEFAULT_PLATFORM, "-t", DEFAULT_DOCKER_TAG]) - .arg(ctx.path()); - run_with_output(app, run_id, "docker_build", &mut build_cmd) - .context("docker build failed") - .map_err(|e| { - let msg = err_to_string(e); - step_error(app, run_id, "docker_build", &msg); - anyhow!(msg) - })?; - step_ok(app, run_id, "docker_build"); - - // generate pairing artifacts before building the image so we can inject them - step_start(app, run_id, "credentials", "Generating pairing credentials"); - let work_dir = shared_temp_dir("secluso-work").context("creating temp work dir")?; - let work_path = work_dir.path(); - - if let Some(secluso) = &cfg.secluso { - let repo = secluso - .repo - .clone() - .unwrap_or_else(|| "secluso/secluso".to_string()); - let sig_keys = secluso.sig_keys.as_deref(); - let github_token = secluso.github_token.as_deref(); - generate_secluso_credentials(app, run_id, work_path, &repo, sig_keys, github_token)?; - } - step_ok(app, run_id, "credentials"); - - if let Some(secluso) = &cfg.secluso { - step_start(app, run_id, "artifacts", "Downloading verified bundle"); - let repo = secluso - .repo - .as_deref() - .map(normalize_repo) - .unwrap_or_else(|| "secluso/secluso".to_string()); - let sig_keys = secluso.sig_keys.as_deref(); - let github_token = secluso.github_token.as_deref(); - let (release_tag, bundle_bytes) = download_verified_bundle(&repo, sig_keys, github_token) - .map_err(|e| { - let msg = format!("{e:#}"); - step_error(app, run_id, "artifacts", &msg); - anyhow!(msg) - })?; - fs::write(work_path.join("secluso_bundle.zip"), bundle_bytes) - .with_context(|| format!("writing {}", work_path.join("secluso_bundle.zip").display())) - .map_err(|e| { - let msg = format!("{e:#}"); - step_error(app, run_id, "artifacts", &msg); - anyhow!(msg) - })?; - log_line( - app, - run_id, - "info", - Some("artifacts"), - format!("Verified Raspberry Pi bundle {release_tag} for {repo}."), - ); - step_ok(app, run_id, "artifacts"); - } - - - let mut base_image = DEFAULT_BASE_IMAGE; - let mut search_image_name: &str = DEFAULT_BASE_IMAGE.split('/').last().unwrap(); - if search_image_name.ends_with(".xz") { - search_image_name = search_image_name.strip_suffix(".xz").unwrap(); - } - - let images_folder = shared_temp_root().join("images"); - let rpicam_apps_folder = shared_temp_root().join("rpicam_apps"); - - let expected_rpicam_apps_in = rpicam_apps_folder.join(RPICAM_APPS_COMMIT); - let expected_rpicam_apps_out = work_path.join("rpicam_apps"); - let expected_image_in = images_folder.join(search_image_name); - let expected_image_out = work_path.join(search_image_name); - - let mut found_image_cache: bool = false; - let mut found_rpicam_apps_cache: bool = false; - if req.cache { - if !images_folder.exists() { - fs::create_dir(&images_folder)?; - } else { - if !expected_image_in.exists() { - // Clear out old caches - fs::remove_dir_all(&images_folder)?; - fs::create_dir(&images_folder)?; - } else { - found_image_cache = true; - fs::copy(&expected_image_in, &expected_image_out)?; - base_image = search_image_name; - } - } - - if !rpicam_apps_folder.exists() { - fs::create_dir(&rpicam_apps_folder)?; - } else { - let exists = expected_rpicam_apps_in.exists() && LIST_OF_BINARIES.iter().all(|binary| expected_rpicam_apps_in.join(binary).exists()); - - if !exists { - // Clear out old caches - fs::remove_dir_all(&rpicam_apps_folder)?; - fs::create_dir(&rpicam_apps_folder)?; - } else { - found_rpicam_apps_cache = true; - fs::create_dir(&expected_rpicam_apps_out)?; - - for binary in LIST_OF_BINARIES { - fs::copy(expected_rpicam_apps_in.join(binary), expected_rpicam_apps_out.join(binary))?; - } - } - } - } - - // write the config that build.sh reads inside the container - step_start(app, run_id, "config", "Writing runtime config"); - let rt_cfg = RuntimeConfig { - base_image: base_image.to_string(), - rpicam_apps_commit: RPICAM_APPS_COMMIT.to_string(), - output_name: cfg.output_name.clone(), - hostname: cfg.hostname.clone(), - variant: variant.to_string(), - user: cfg.user.clone(), - ssh: cfg.ssh.clone(), - wifi: cfg.wifi.clone(), - apt: cfg.apt.clone(), - secluso: cfg.secluso.clone(), - }; - let cfg_json = serde_json::to_string_pretty(&rt_cfg).context("serialize runtime config")?; - fs::write(work_path.join("config.json"), cfg_json).context("write /work/config.json")?; - step_ok(app, run_id, "config"); - - // run the image builder container with the work and output mounts - step_start(app, run_id, "docker_run", "Building image (Docker)"); - let mut cmd = Command::new("docker"); - cmd.args(["run", "--rm", "--platform", DEFAULT_PLATFORM, "--privileged"]) - .args(["--security-opt", "seccomp=unconfined"]) - .args(["--tmpfs", "/tmp:exec,mode=1777"]) - .arg("--mount") - .arg(format!("type=bind,source={},target=/work", work_path.display())) - .arg("--mount") - .arg(format!("type=bind,source={},target=/out", out_dir.display())) - .arg("-e").arg("LIBGUESTFS_BACKEND=direct") - .arg("-e").arg("LIBGUESTFS_MEMSIZE=1024") - .arg("-e").arg("LIBGUESTFS_QEMU_OPTIONS=-accel tcg") - .arg(DEFAULT_DOCKER_TAG); - - if Path::new("/dev/kvm").exists() { - cmd.args(["--device", "/dev/kvm"]); - } - - run_with_output(app, run_id, "docker_run", &mut cmd) - .context("docker run failed") - .map_err(|e| { - let msg = err_to_string(e); - step_error(app, run_id, "docker_run", &msg); - anyhow!(msg) - })?; - step_ok(app, run_id, "docker_run"); - - // We want to cache; we didn't have it originally; we save it now. - if req.cache && !found_image_cache { - fs::copy(&expected_image_out, &expected_image_in)?; - } - - if req.cache && !found_rpicam_apps_cache { - fs::create_dir_all(&expected_rpicam_apps_in)?; - for binary in LIST_OF_BINARIES { - fs::copy(expected_rpicam_apps_out.join(binary), expected_rpicam_apps_in.join(binary))?; - } - } - - // verify the output and copy the qr code to the requested path - step_start(app, run_id, "verify", "Verifying outputs"); - let out_img = out_dir.join(&cfg.output_name); - if !out_img.exists() { - step_error(app, run_id, "verify", format!("Expected output image not found: {}", out_img.display())); - bail!("Expected output image not found: {}", out_img.display()); - } - - let qr_src = work_path.join("camera_secret_qrcode.png"); - if qr_src.exists() { - fs::copy(&qr_src, &req.qr_output_path) - .with_context(|| format!("copying QR code to {}", req.qr_output_path))?; - log_line(app, run_id, "info", Some("verify"), format!("QR code saved at: {}", req.qr_output_path)); - } else { - log_line(app, run_id, "warn", Some("verify"), "QR code was not generated (missing camera_secret_qrcode.png)."); - } - step_ok(app, run_id, "verify"); - - Ok(BuildImageResponse { - out_image: out_img.display().to_string(), - }) -} diff --git a/deploy/src-tauri/src/pi_hub_provision/docker.rs b/deploy/src-tauri/src/pi_hub_provision/docker.rs deleted file mode 100644 index 506954e..0000000 --- a/deploy/src-tauri/src/pi_hub_provision/docker.rs +++ /dev/null @@ -1,104 +0,0 @@ -//! SPDX-License-Identifier: GPL-3.0-or-later -use crate::pi_hub_provision::events::handle_event_line; -use anyhow::{anyhow, bail, Context, Result}; -use std::fs; -use std::io::{BufRead, BufReader}; -use std::path::Path; -use std::process::{Command, Stdio}; -use std::thread; -use tauri::AppHandle; -use uuid::Uuid; - -const DOCKERFILE_TXT: &str = include_str!("../../assets/pi_hub/Dockerfile"); -const BUILD_SH_TXT: &str = include_str!("../../assets/pi_hub/build_image.sh"); - -pub fn write_docker_context(dir: &Path) -> Result<()> { - // embed the build assets so we can run without shipping extra files - fs::write(dir.join("Dockerfile"), DOCKERFILE_TXT).context("write Dockerfile")?; - fs::write(dir.join("build.sh"), BUILD_SH_TXT).context("write build.sh")?; - Ok(()) -} - -pub fn docker_version() -> Result { - // basic docker health check used for the status page - let out = Command::new("docker") - .args(["--version"]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .map_err(|_| anyhow!("Docker is not installed or not available on PATH. Install Docker and try again."))?; - - if !out.status.success() { - bail!("Docker is not installed or not available on PATH. Install Docker and try again."); - } - - Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) -} - -pub fn docker_ready() -> Result { - let client_version = docker_version()?; - let out = Command::new("docker") - .args(["info", "--format", "{{.ServerVersion}}"]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .map_err(|_| anyhow!("Docker is installed, but the Docker daemon is not reachable. Start Docker and try again."))?; - - if !out.status.success() { - bail!("Docker is installed, but the Docker daemon is not reachable. Start Docker and try again."); - } - - let server_version = String::from_utf8_lossy(&out.stdout).trim().trim_matches('"').to_string(); - if server_version.is_empty() { - return Ok(client_version); - } - - Ok(format!("{client_version} | server {server_version}")) -} - -pub fn run_with_output(app: &AppHandle, run_id: Uuid, step: &str, cmd: &mut Command) -> Result<()> { - // stream stdout and stderr into ui events - handle_event_line(app, run_id, "info", step, &format!("running: {:?}", cmd)); - let mut child = cmd - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .with_context(|| format!("failed to spawn {:?}", cmd))?; - - let stdout = child.stdout.take().unwrap(); - let stderr = child.stderr.take().unwrap(); - - let step_out = step.to_string(); - let app_out = app.clone(); - let run_out = run_id; - let out_handle = thread::spawn(move || { - let reader = BufReader::new(stdout); - for line in reader.lines().flatten() { - handle_event_line(&app_out, run_out, "info", &step_out, &line); - } - }); - - let step_err = step.to_string(); - let app_err = app.clone(); - let run_err = run_id; - let err_handle = thread::spawn(move || { - let reader = BufReader::new(stderr); - for line in reader.lines().flatten() { - handle_event_line(&app_err, run_err, "error", &step_err, &line); - } - }); - - let status = child.wait()?; - let _ = out_handle.join(); - let _ = err_handle.join(); - - if !status.success() { - bail!("command failed with status: {} ({:?})", status, cmd); - } - - Ok(()) -} - -pub fn err_to_string(e: anyhow::Error) -> String { - format!("{:#}", e) -} diff --git a/deploy/src-tauri/src/pi_hub_provision/events.rs b/deploy/src-tauri/src/pi_hub_provision/events.rs index a2ed11d..c5afd8f 100644 --- a/deploy/src-tauri/src/pi_hub_provision/events.rs +++ b/deploy/src-tauri/src/pi_hub_provision/events.rs @@ -89,20 +89,3 @@ pub fn log_line(app: &AppHandle, run_id: Uuid, level: &str, step: Option<&str>, }, ); } - -pub fn handle_event_line(app: &AppHandle, run_id: Uuid, default_level: &str, step: &str, line: &str) { - // accept structured lines from the docker scripts - const PREFIX: &str = "::SECLUSO_EVENT::"; - if let Some(rest) = line.strip_prefix(PREFIX) { - if let Ok(v) = serde_json::from_str::(rest) { - let level = v.get("level").and_then(|x| x.as_str()).unwrap_or(default_level); - let rstep = v.get("step").and_then(|x| x.as_str()).unwrap_or(step); - let msg = v.get("msg").and_then(|x| x.as_str()).unwrap_or(line); - log_line(app, run_id, level, Some(rstep), msg.to_string()); - return; - } - } - if !line.trim().is_empty() { - log_line(app, run_id, default_level, Some(step), line.to_string()); - } -} diff --git a/deploy/src-tauri/src/pi_hub_provision/image_inject.rs b/deploy/src-tauri/src/pi_hub_provision/image_inject.rs new file mode 100644 index 0000000..3e6aca5 --- /dev/null +++ b/deploy/src-tauri/src/pi_hub_provision/image_inject.rs @@ -0,0 +1,296 @@ +//! SPDX-License-Identifier: GPL-3.0-or-later +use anyhow::{bail, Context}; +use fatfs::{FileSystem, FsOptions}; +use std::fs::{File, OpenOptions}; +use std::io::{self, ErrorKind, Read, Seek, SeekFrom, Write}; +use std::path::Path; + +const PROVISION_PARTITION_INDEX: usize = 3; + +// The partition layout reference specifically is meta-secluso-os/wic/sdcard-raspberrypi.wks in https://github.com/secluso/os +// The Yocto WKS syntax reference used by that file is https://docs.yoctoproject.org/ref-manual/kickstart.html. +// A WIC file is a raw disk image produced by Yocto tooling (so the first bytes of the file are laid out like the first bytes of the storage device that will eventually be flashed) +// The Yocto WIC tool reference is https://docs.yoctoproject.org/dev-manual/wic.html. +// In the Secluso OS image, sector zero contains an MBR partition table, each partition entry points at a byte range inside the same file, and partition 3 contains the FAT filesystem mounted as /provision during first boot that we use for provisioning in the deploy tool +// Reference for the MBR partition table byte layout reference used by this parser: https://wiki.osdev.org/Partition_Table +// This module edits the WIC as a disk image rather than unpacking it through a mount helper for optimal cross compatibility across Windows, macOS and Linux. +// +// +----------------------+----------------------+----------------------+----------------------+ +// | sector 0 | partition 1 | partition 2 | partition 3 | +// | MBR bootstrap bytes | boot | rootfs | /provision | +// | partition table | boot filesystem | Linux rootfs | 16MB FAT filesystem | +// | 0x55aa signature | | | provisioning files | +// +----------------------+----------------------+----------------------+----------------------+ +// +// Once we find a FAT partition range, fatfs interprets only that subrange as a filesystem, and the PartitionBlockDevice adapter makes the subrange look like an independent disk to fatfs. +// fatfs reference used for FileSystem over a custom Read + Write + Seek device: https://docs.rs/fatfs/latest/fatfs/ + +#[derive(Debug, Clone)] +pub struct ConstructedFile { + pub image_path: String, + pub contents: Vec, +} + +impl ConstructedFile { + pub fn new(image_path: impl Into, contents: impl Into>) -> Self { + Self { + image_path: image_path.into(), + contents: contents.into(), + } + } +} + +struct PartitionBlockDevice { + file: File, + offset: u64, + len: u64, + position: u64, +} + +impl PartitionBlockDevice { + // fatfs expects a block-device-like object whose offsets are relative to the start of the filesystem, but our actual storage is the entirety of the WIC file + // thus this translates every relative FAT read, seek, and write into an absolute file operation at partition_offset + relative_position. + // FAT code should never be allowed to read or write through the logical end of the selected partition into another partition or into unused disk-image space + // https://doc.rust-lang.org/std/io/trait.Read.html, https://doc.rust-lang.org/std/io/trait.Write.html, https://doc.rust-lang.org/std/io/trait.Seek.html + fn new(file: File, offset: u64, len: u64) -> Self { + Self { + file, + offset, + len, + position: 0, + } + } + + fn remaining(&self) -> u64 { + self.len.saturating_sub(self.position) + } +} + +impl Read for PartitionBlockDevice { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + // The adapter clamps each read to the remaining partition length before touching the underlying file. + // callers can treat EOF as the end of the partition + let amount = self.remaining().min(buf.len() as u64) as usize; + if amount == 0 { + return Ok(0); + } + + self.file + .seek(SeekFrom::Start(self.offset + self.position))?; + let bytes_read = self.file.read(&mut buf[..amount])?; + self.position += bytes_read as u64; + Ok(bytes_read) + } +} + +impl Seek for PartitionBlockDevice { + fn seek(&mut self, pos: SeekFrom) -> io::Result { + // Evaluated in signed space first so negative seeks and seeks past the partition boundary are rejected + // code writes secrets into the image and should never corrupt unrelated partitions. + let next = match pos { + SeekFrom::Start(pos) => pos as i128, + SeekFrom::End(delta) => self.len as i128 + delta as i128, + SeekFrom::Current(delta) => self.position as i128 + delta as i128, + }; + + if next < 0 || next > self.len as i128 { + return Err(io::Error::new( + ErrorKind::InvalidInput, + "seek outside partition", + )); + } + + self.position = next as u64; + Ok(self.position) + } +} + +impl Write for PartitionBlockDevice { + fn write(&mut self, buf: &[u8]) -> io::Result { + // Writes are also clamped to the partition, and a write that would start beyond the partition is treated as WriteZero. + let amount = self.remaining().min(buf.len() as u64) as usize; + if amount == 0 { + return Err(io::Error::new( + ErrorKind::WriteZero, + "write outside partition", + )); + } + + self.file + .seek(SeekFrom::Start(self.offset + self.position))?; + let bytes_written = self.file.write(&buf[..amount])?; + self.position += bytes_written as u64; + Ok(bytes_written) + } + + fn flush(&mut self) -> io::Result<()> { + self.file.flush() + } +} + +#[derive(Debug, Clone)] +pub struct MbrPartition { + pub index: usize, + pub partition_type: u8, + pub start_lba: u32, + pub size_sectors: u32, +} + +impl MbrPartition { + pub fn offset_bytes(&self) -> u64 { + // MBR partition entries store the start as a logical block address, and classic disk images use 512-byte sectors for that address calculation. + // FAT driver needs a byte offset. thus, every partition access goes through this conversion before we open the subregion. + self.start_lba as u64 * 512 + } + + pub fn len_bytes(&self) -> u64 { + // size in the MBR is also expressed in 512-byte sectors, multiplying by 512 gives the exact byte length of the partition slice + self.size_sectors as u64 * 512 + } + + pub fn is_fat(&self) -> bool { + // These partition type values cover the common FAT12, FAT16, and FAT32 MBR identifiers + matches!(self.partition_type, 0x01 | 0x04 | 0x06 | 0x0b | 0x0c | 0x0e) + } +} + +fn read_u32_le(buf: &[u8]) -> u32 { + u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]) +} + +pub fn parse_mbr(path: impl AsRef) -> anyhow::Result> { + let path = path.as_ref(); + let mut f = File::open(path).with_context(|| format!("failed to open {}", path.display()))?; + + // Sector zero of an MBR-partitioned WIC image contains bootstrap bytes followed by four 16-byte partition entries beginning at byte 446 and a required 0x55aa signature at bytes 510 and 511. + // The 0x55aa signature and the 16-byte partition entry structure are described in the MBR partition table reference (https://wiki.osdev.org/Partition_Table) + // We only need the partition table metadata, so reading the first 512 bytes is enough to locate partition 3 without interpreting the boot partition or root filesystem. + let mut mbr = [0u8; 512]; + f.read_exact(&mut mbr) + .with_context(|| format!("failed to read MBR from {}", path.display()))?; + + if mbr[510] != 0x55 || mbr[511] != 0xaa { + bail!("Invalid MBR signature"); + } + + let mut parts = vec![]; + + for i in 0..4 { + // Each MBR entry is fixed-width and little-endian, with the partition type at byte 4, the starting LBA at bytes 8 through 11, and the sector count at bytes 12 through 15. + let offset = 446 + i * 16; + let entry = &mbr[offset..offset + 16]; + + let partition_type = entry[4]; + let start_lba = read_u32_le(&entry[8..12]); + let size = read_u32_le(&entry[12..16]); + + if start_lba != 0 && size != 0 { + parts.push(MbrPartition { + index: i + 1, + partition_type, + start_lba, + size_sectors: size, + }); + } + } + + if parts.is_empty() { + bail!("No valid MBR partitions found"); + } + + Ok(parts) +} + +pub fn select_partition( + partitions: &[MbrPartition], + requested_partition: Option, +) -> anyhow::Result<&MbrPartition> { + let index = requested_partition.unwrap_or(PROVISION_PARTITION_INDEX); + let partition = partitions + .iter() + .find(|part| part.index == index) + .with_context(|| format!("partition {index} was not found"))?; + + if !partition.is_fat() { + bail!( + "partition {} is type 0x{:02x}, not a FAT /provision partition", + partition.index, + partition.partition_type + ); + } + + Ok(partition) +} + +pub fn inject_files( + image_path: impl AsRef, + requested_partition: Option, + files: impl IntoIterator, +) -> anyhow::Result { + let image_path = image_path.as_ref(); + let files = files.into_iter().collect::>(); + if files.is_empty() { + bail!("at least one file is required"); + } + + // First locate the partition table, then select the FAT /provision partition, then write the requested files into that filesystem root through the partition-bounded adapter. + let partitions = parse_mbr(image_path)?; + let partition = select_partition(&partitions, requested_partition)?.clone(); + + inject_files_into_partition(image_path, &partition, files)?; + + Ok(partition) +} + +fn inject_files_into_partition( + image_path: &Path, + partition: &MbrPartition, + files: Vec, +) -> anyhow::Result<()> { + // only bytes changed should be the FAT directory and data clusters needed to create the injected files. + let file = OpenOptions::new() + .read(true) + .write(true) + .open(image_path) + .with_context(|| format!("failed to open {} for read/write", image_path.display()))?; + + let device = PartitionBlockDevice::new(file, partition.offset_bytes(), partition.len_bytes()); + let fs = FileSystem::new(device, FsOptions::new()).with_context(|| { + format!( + "partition {} is not a readable FAT filesystem", + partition.index + ) + })?; + + let root = fs.root_dir(); + + for file in files { + validate_image_path(&file.image_path)?; + let mut output = root + .create_file(&file.image_path) + .with_context(|| format!("failed to create {}", file.image_path))?; + output + .write_all(&file.contents) + .with_context(|| format!("failed to write {}", file.image_path))?; + } + + Ok(()) +} + +fn validate_image_path(path: &str) -> anyhow::Result<()> { + // defense-in-depth boundary around the injected file names + // https://doc.rust-lang.org/std/path/enum.Component.html + let path = Path::new(path); + if path.is_absolute() { + bail!("image file path must be relative"); + } + + if path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + bail!("image file path must not contain '..'"); + } + + Ok(()) +} diff --git a/deploy/src-tauri/src/pi_hub_provision/mod.rs b/deploy/src-tauri/src/pi_hub_provision/mod.rs index ca71bf4..212a968 100644 --- a/deploy/src-tauri/src/pi_hub_provision/mod.rs +++ b/deploy/src-tauri/src/pi_hub_provision/mod.rs @@ -1,16 +1,15 @@ //! SPDX-License-Identifier: GPL-3.0-or-later -mod build; pub(crate) mod credentials; -mod docker; mod events; +mod image_inject; pub(crate) mod model; +mod prepare; pub(crate) mod temp; -use crate::pi_hub_provision::build::run_build_image; use crate::pi_hub_provision::credentials::generate_user_credentials_only; -use crate::pi_hub_provision::docker::{docker_ready, err_to_string}; use crate::pi_hub_provision::events::{emit, log_line, ProvisionEvent}; -use crate::pi_hub_provision::model::{SigKey, Wifi}; +use crate::pi_hub_provision::model::SigKey; +use crate::pi_hub_provision::prepare::run_prepare_image; use crate::pi_hub_provision::temp::shared_temp_dir; use anyhow::Context; use serde::{Deserialize, Serialize}; @@ -23,126 +22,113 @@ use uuid::Uuid; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct BuildImageRequest { - variant: Option, - cache: bool, - qr_output_path: String, - image_output_path: String, - ssh_enabled: Option, - wifi: Option, - binaries_repo: Option, - sig_keys: Option>, - github_token: Option, +pub struct PrepareImageRequest { + qr_output_path: String, + image_output_path: String, + binaries_repo: Option, + sig_keys: Option>, + github_token: Option, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] -pub struct BuildImageResponse { - out_image: String, +pub struct PrepareImageResponse { + out_image: String, } #[derive(Debug, Serialize)] pub struct BuildStart { - pub run_id: Uuid, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct DockerStatus { - ok: bool, - version: Option, - message: Option, + pub run_id: Uuid, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GenerateUserCredentialsRequest { - server_url: String, - output_path: String, - qr_output_path: Option, + server_url: String, + output_path: String, + qr_output_path: Option, } #[tauri::command] pub async fn generate_user_credentials( - app: AppHandle, - req: GenerateUserCredentialsRequest, + app: AppHandle, + req: GenerateUserCredentialsRequest, ) -> std::result::Result<(), String> { - tauri::async_runtime::spawn_blocking(move || -> anyhow::Result<()> { - let run_id = Uuid::new_v4(); - let work_dir = shared_temp_dir("secluso-user-creds").context("creating temp work dir")?; - let work_path = work_dir.path(); - - generate_user_credentials_only(&app, run_id, work_path, &req.server_url, "secluso/secluso", None, None)?; - - let out_path = Path::new(&req.output_path); - if let Some(parent) = out_path.parent() { - if !parent.as_os_str().is_empty() { - fs::create_dir_all(parent)?; - } - } - fs::copy(work_path.join("user_credentials"), &req.output_path) - .with_context(|| format!("copying user_credentials to {}", req.output_path))?; - - if let Some(qr_out) = &req.qr_output_path { - let qr_src = work_path.join("user_credentials_qrcode.png"); - if qr_src.exists() { - let qr_path = Path::new(qr_out); - if let Some(parent) = qr_path.parent() { - if !parent.as_os_str().is_empty() { - fs::create_dir_all(parent)?; - } + tauri::async_runtime::spawn_blocking(move || -> anyhow::Result<()> { + let run_id = Uuid::new_v4(); + let work_dir = shared_temp_dir("secluso-user-creds").context("creating temp work dir")?; + let work_path = work_dir.path(); + + generate_user_credentials_only( + &app, + run_id, + work_path, + &req.server_url, + "secluso/secluso", + None, + None, + )?; + + let out_path = Path::new(&req.output_path); + if let Some(parent) = out_path.parent() { + if !parent.as_os_str().is_empty() { + fs::create_dir_all(parent)?; + } + } + fs::copy(work_path.join("user_credentials"), &req.output_path) + .with_context(|| format!("copying user_credentials to {}", req.output_path))?; + + if let Some(qr_out) = &req.qr_output_path { + let qr_src = work_path.join("user_credentials_qrcode.png"); + if qr_src.exists() { + let qr_path = Path::new(qr_out); + if let Some(parent) = qr_path.parent() { + if !parent.as_os_str().is_empty() { + fs::create_dir_all(parent)?; + } + } + fs::copy(&qr_src, qr_out) + .with_context(|| format!("copying QR code to {}", qr_out))?; + } else { + anyhow::bail!("Expected QR code missing at {}", qr_src.display()); + } } - fs::copy(&qr_src, qr_out).with_context(|| format!("copying QR code to {}", qr_out))?; - } else { - anyhow::bail!("Expected QR code missing at {}", qr_src.display()); - } - } - - Ok(()) - }) - .await - .map_err(|e| e.to_string())? - .map_err(err_to_string) -} -#[tauri::command] -pub async fn check_docker() -> std::result::Result { - tauri::async_runtime::spawn_blocking(|| -> anyhow::Result { - match docker_ready() { - Ok(version) => Ok(DockerStatus { - ok: true, - version: Some(version), - message: None, - }), - Err(e) => Ok(DockerStatus { - ok: false, - version: None, - message: Some(err_to_string(e)), - }), - } - }) - .await - .map_err(|e| e.to_string())? - .map_err(err_to_string) + Ok(()) + }) + .await + .map_err(|e| e.to_string())? + .map_err(err_to_string) } #[tauri::command] -pub async fn build_image(app: AppHandle, req: BuildImageRequest) -> std::result::Result { - let run_id = Uuid::new_v4(); - let app2 = app.clone(); +pub async fn prepare_image( + app: AppHandle, + req: PrepareImageRequest, +) -> std::result::Result { + let run_id = Uuid::new_v4(); + let app2 = app.clone(); + + tokio::task::spawn_blocking(move || match run_prepare_image(&app2, run_id, req) { + Ok(result) => { + log_line( + &app2, + run_id, + "info", + Some("result"), + format!("Image saved at: {}", result.out_image), + ); + emit(&app2, ProvisionEvent::Done { run_id, ok: true }); + } + Err(e) => { + log_line(&app2, run_id, "error", Some("fatal"), format!("{e:#}")); + emit(&app2, ProvisionEvent::Done { run_id, ok: false }); + } + }); - tokio::task::spawn_blocking(move || { - match run_build_image(&app2, run_id, req) { - Ok(result) => { - log_line(&app2, run_id, "info", Some("result"), format!("Image saved at: {}", result.out_image)); - emit(&app2, ProvisionEvent::Done { run_id, ok: true }); - } - Err(e) => { - log_line(&app2, run_id, "error", Some("fatal"), format!("{e:#}")); - emit(&app2, ProvisionEvent::Done { run_id, ok: false }); - } - } - }); + Ok(BuildStart { run_id }) +} - Ok(BuildStart { run_id }) +fn err_to_string(e: anyhow::Error) -> String { + format!("{:#}", e) } diff --git a/deploy/src-tauri/src/pi_hub_provision/model.rs b/deploy/src-tauri/src/pi_hub_provision/model.rs index 9426dd4..be4536d 100755 --- a/deploy/src-tauri/src/pi_hub_provision/model.rs +++ b/deploy/src-tauri/src/pi_hub_provision/model.rs @@ -1,53 +1,6 @@ //! SPDX-License-Identifier: GPL-3.0-or-later use serde::{Deserialize, Serialize}; -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct Secluso { - pub server_url: Option, - pub camera_name: Option, - pub release_mode: Option, - pub release_tag: Option, - pub asset_name: Option, - pub asset_kind: Option, - pub install_dir: Option, - pub etc_dir: Option, - pub repo: Option, - pub sig_keys: Option>, - pub github_token: Option, -} - -#[derive(Debug, Deserialize, Clone)] -pub(crate) struct Config { - pub output_name: String, - pub hostname: String, - pub user: User, - - #[serde(default)] - pub ssh: Ssh, - - #[serde(default)] - pub wifi: Option, - - #[serde(default)] - pub apt: Apt, - - #[serde(default)] - pub secluso: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub(crate) struct User { - pub name: String, - pub password: String, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub(crate) struct Wifi { - pub country: String, - pub ssid: String, - pub psk: String, -} - #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub(crate) struct SigKey { @@ -56,31 +9,3 @@ pub(crate) struct SigKey { #[serde(default)] pub fingerprint: Option, } - -#[derive(Debug, Default, Serialize, Deserialize, Clone)] -pub(crate) struct Ssh { - #[serde(default)] - pub enable: bool, - #[serde(default)] - pub authorized_keys: Vec, -} - -#[derive(Debug, Default, Serialize, Deserialize, Clone)] -pub(crate) struct Apt { - #[serde(default)] - pub packages: Vec, -} - -#[derive(Debug, Serialize)] -pub(crate) struct RuntimeConfig { - pub base_image: String, - pub rpicam_apps_commit: String, - pub output_name: String, - pub hostname: String, - pub variant: String, - pub user: User, - pub ssh: Ssh, - pub wifi: Option, - pub apt: Apt, - pub secluso: Option, -} diff --git a/deploy/src-tauri/src/pi_hub_provision/prepare.rs b/deploy/src-tauri/src/pi_hub_provision/prepare.rs new file mode 100644 index 0000000..11719fd --- /dev/null +++ b/deploy/src-tauri/src/pi_hub_provision/prepare.rs @@ -0,0 +1,176 @@ +//! SPDX-License-Identifier: GPL-3.0-or-later +use crate::pi_hub_provision::credentials::generate_secluso_credentials; +use crate::pi_hub_provision::events::{log_line, step_error, step_ok, step_start}; +use crate::pi_hub_provision::image_inject::{inject_files, ConstructedFile}; +use crate::pi_hub_provision::model::SigKey; +use crate::pi_hub_provision::temp::shared_temp_dir; +use crate::pi_hub_provision::{PrepareImageRequest, PrepareImageResponse}; +use crate::release_config::{normalize_repo, resolve_signers}; +use anyhow::{anyhow, bail, Context, Result}; +use secluso_update::{ + build_github_client, download_and_verify_release_asset_to_path, fetch_latest_release, + DEFAULT_OWNER_REPO, +}; +use std::fs; +use std::path::{Path, PathBuf}; +use tauri::AppHandle; +use uuid::Uuid; + +fn download_verified_image( + owner_repo: &str, + sig_keys: Option<&[SigKey]>, + github_token: Option<&str>, + output_path: &Path, +) -> Result<(String, String)> { + // The image is not something the deploy tool builds locally anymore. It's downloaded and then modified by the tool. + // The updater library verifies the release immutability, the signed sha256sums file, the signer keys, the named WIC asset checksum, and GitHub's asset digest metadata before this function returns. + // GitHub release assets API reference for release asset metadata and downloads: https://docs.github.com/en/rest/releases/assets?apiVersion=2022-11-28. + let signers = resolve_signers(sig_keys); + let client = build_github_client(20, github_token, "secluso-deploy")?; + let release = fetch_latest_release(&client, owner_repo) + .with_context(|| format!("Fetching latest release metadata for {owner_repo}"))?; + let asset_name = format!("secluso-pi-image-{}.wic", release.tag_name); + let verified = download_and_verify_release_asset_to_path( + &client, + &release, + &asset_name, + output_path, + &signers, + ) + .with_context(|| format!("Downloading and verifying {}", asset_name))?; + + Ok((verified.release_tag, verified.asset_name)) +} + +pub fn run_prepare_image( + app: &AppHandle, + run_id: Uuid, + req: PrepareImageRequest, +) -> Result { + step_start(app, run_id, "validate", "Validating inputs"); + // I made this specifically for our Secluso OS WIC file (injection code expects our fixed image layout, not an arbitrary WIC layout) + // partition 1 is /boot, partition 2 is /, and partition 3 is the 16MB FAT /provision partition. + // The Yocto WIC image creation flow used by Secluso OS is documented at https://docs.yoctoproject.org/dev-manual/wic.html, https://github.com/secluso/os + // Validation happens before any credential generation or network work + if !req.image_output_path.ends_with(".wic") { + step_error(app, run_id, "validate", "Output image must end with .wic."); + bail!("Output image must end with .wic."); + } + if !req.qr_output_path.ends_with(".png") { + step_error(app, run_id, "validate", "QR output must end with .png."); + bail!("QR output must end with .png."); + } + step_ok(app, run_id, "validate"); + + let output_path = PathBuf::from(&req.image_output_path); + let out_dir = output_path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(out_dir) + .with_context(|| format!("creating output dir {}", out_dir.display()))?; + + let repo = req + .binaries_repo + .as_deref() + .map(normalize_repo) + .unwrap_or_else(|| DEFAULT_OWNER_REPO.to_string()); + let sig_keys = req.sig_keys.as_deref(); + let github_token = req.github_token.as_deref().filter(|v| !v.trim().is_empty()); + + step_start(app, run_id, "credentials", "Generating pairing credentials"); + // The camera secret and hotspot password are generated before the image is downloaded + // Both generated files are then copied into the partition 3 /provision FAT filesystem as plain files + // The QR code is generated from the same pairing material in the temporary work directory, and later copied to the path the user wants after the image has been prepared + let work_dir = shared_temp_dir("secluso-work").context("creating temp work dir")?; + let work_path = work_dir.path(); + generate_secluso_credentials(app, run_id, work_path, &repo, sig_keys, github_token)?; + let camera_secret = fs::read(work_path.join("camera_secret")) + .with_context(|| format!("reading {}", work_path.join("camera_secret").display()))?; + let wifi_password = fs::read(work_path.join("wifi_password")) + .with_context(|| format!("reading {}", work_path.join("wifi_password").display()))?; + step_ok(app, run_id, "credentials"); + + step_start( + app, + run_id, + "image_download", + "Downloading verified Secluso image", + ); + // Fetch the latest immutable GitHub release metadata, derives the expected WIC asset name from the tag, verifies the signed release checksum file, and streams the WIC to the requested output path + let (release_tag, image_asset_name) = + download_verified_image(&repo, sig_keys, github_token, &output_path).map_err(|e| { + let msg = format!("{e:#}"); + step_error(app, run_id, "image_download", &msg); + anyhow!(msg) + })?; + log_line( + app, + run_id, + "info", + Some("image_download"), + format!("Verified image {image_asset_name} from {release_tag}."), + ); + step_ok(app, run_id, "image_download"); + + step_start(app, run_id, "inject", "Injecting camera configuration"); + let files = vec![ + ConstructedFile::new("camera_secret", camera_secret), + ConstructedFile::new("wifi_password", wifi_password), + ]; + let partition = inject_files(&output_path, None, files).map_err(|e| { + let msg = format!("{e:#}"); + step_error(app, run_id, "inject", &msg); + anyhow!(msg) + })?; + log_line( + app, + run_id, + "info", + Some("inject"), + format!( + "Injected camera config into FAT partition {} at byte offset {}.", + partition.index, + partition.offset_bytes() + ), + ); + step_ok(app, run_id, "inject"); + + step_start(app, run_id, "verify", "Verifying outputs"); + // At this point we confirm that the prepared WIC still exists and copy the QR code to its requested destination + if !output_path.exists() { + step_error( + app, + run_id, + "verify", + format!("Expected output image not found: {}", output_path.display()), + ); + bail!("Expected output image not found: {}", output_path.display()); + } + + let qr_src = work_path.join("camera_secret_qrcode.png"); + if qr_src.exists() { + fs::copy(&qr_src, &req.qr_output_path) + .with_context(|| format!("copying QR code to {}", req.qr_output_path))?; + log_line( + app, + run_id, + "info", + Some("verify"), + format!("QR code saved at: {}", req.qr_output_path), + ); + } else { + log_line( + app, + run_id, + "warn", + Some("verify"), + "QR code was not generated (missing camera_secret_qrcode.png).", + ); + } + step_ok(app, run_id, "verify"); + + Ok(PrepareImageResponse { + out_image: output_path.display().to_string(), + }) +} diff --git a/deploy/src-tauri/src/provision_server/mod.rs b/deploy/src-tauri/src/provision_server/mod.rs index 3e0ca9a..0232c37 100644 --- a/deploy/src-tauri/src/provision_server/mod.rs +++ b/deploy/src-tauri/src/provision_server/mod.rs @@ -4,7 +4,7 @@ mod preflight; mod provision; mod script; mod ssh; -mod types; +pub(crate) mod types; use crate::provision_server::events::{emit, log_line, step_error, step_ok, step_start, ProvisionEvent}; use crate::provision_server::preflight::run_preflight; diff --git a/deploy/src-tauri/src/provision_server/preflight.rs b/deploy/src-tauri/src/provision_server/preflight.rs index 85725ed..2a80bda 100644 --- a/deploy/src-tauri/src/provision_server/preflight.rs +++ b/deploy/src-tauri/src/provision_server/preflight.rs @@ -33,7 +33,6 @@ pub struct PreflightReport { pub service_active: bool, pub installed_version: Option, pub port_in_use: bool, - pub remote_has_credentials_full: bool, pub remote_arch: String, } @@ -154,18 +153,17 @@ pub fn run_preflight( verify_outbound_network(app, run_id, step, sess)?; - let remote_has_bin = remote_success(sess, "test -x /opt/secluso/bin/secluso-server", None)?; + let remote_has_bin = remote_success(sess, "test -x /usr/bin/secluso-server", None)?; let remote_has_unit = remote_success( sess, "systemctl list-unit-files --type=service | awk '{print $1}' | grep -qx 'secluso-server.service'", None, )?; let service_active = remote_success(sess, "systemctl is-active --quiet secluso-server.service", None)?; - let remote_has_credentials_full = remote_success(sess, "test -f /var/lib/secluso/credentials_full", None)?; let version = if remote_has_bin { let out = remote_shell( sess, - "if [ -x /opt/secluso/bin/secluso-server ]; then /opt/secluso/bin/secluso-server --version 2>/dev/null | head -n1; fi", + "if [ -x /usr/bin/secluso-server ]; then /usr/bin/secluso-server --version 2>/dev/null | head -n1; fi", None, )?; let trimmed = out.stdout.trim(); @@ -188,15 +186,6 @@ pub fn run_preflight( version.clone().unwrap_or_else(|| "unknown".to_string()) ), ); - if !remote_has_credentials_full { - log_line( - app, - run_id, - "warn", - Some(step), - "Existing install is missing /var/lib/secluso/credentials_full. This older layout is no longer upgraded in place; use Overwrite existing install for a clean reinstall.".to_string(), - ); - } } else { log_line(app, run_id, "info", Some(step), "No existing Secluso server install detected.".to_string()); } @@ -225,39 +214,14 @@ pub fn run_preflight( .filter(|line| !line.is_empty()) .collect::>(); let port_in_use = !port_lines.is_empty(); - let mut occupied_by_secluso = + let occupied_by_secluso = port_probe.stdout.contains("secluso-server") - || port_probe.stdout.contains("/opt/secluso/bin/secluso-server") + || port_probe.stdout.contains("/usr/bin/secluso-server") || service_active; if port_in_use { for line in &port_lines { log_line(app, run_id, "warn", Some(step), format!("Port {listen_port} listener: {line}")); } - if !occupied_by_secluso { - if let Some(status_url) = direct_status_url_for_preflight(runtime, public_server_url, listen_port)? { - match verify_existing_secluso_status_endpoint(app, run_id, step, status_url.as_ref()) { - Ok(()) => { - occupied_by_secluso = true; - log_line( - app, - run_id, - "info", - Some(step), - format!("Port {listen_port} is already serving a healthy Secluso endpoint."), - ); - } - Err(err) => { - log_line( - app, - run_id, - "warn", - Some(step), - format!("Port {listen_port} is in use, and the existing listener did not look like a healthy Secluso endpoint: {err:#}"), - ); - } - } - } - } if !occupied_by_secluso { bail!( @@ -276,7 +240,7 @@ pub fn run_preflight( log_line(app, run_id, "info", Some(step), format!("Port {listen_port} is free on the server.")); } - check_firewall(app, run_id, step, sess, runtime)?; + check_firewall(app, run_id, step, sess, target, runtime)?; verify_public_http_reachability( app, run_id, @@ -295,7 +259,6 @@ pub fn run_preflight( service_active, installed_version: version, port_in_use, - remote_has_credentials_full, remote_arch, }) } @@ -447,17 +410,15 @@ fn verify_public_http_reachability( if port_in_use { if existing_secluso_listener { - let status_url = base_url.join("status").context("Preparing preflight /status probe URL")?; log_line( app, run_id, "info", Some(step), format!( - "Port {listen_port} is already serving Secluso. Reusing its public /status endpoint for reachability preflight." + "Skipping temporary public port probe because port {listen_port} is already used by the existing Secluso service." ), ); - verify_existing_secluso_status_endpoint(app, run_id, step, status_url.as_ref())?; return Ok(()); } @@ -562,40 +523,6 @@ fn prepare_direct_probe_base_url(public_server_url: &str, listen_port: u16) -> R Ok(parsed) } -fn verify_existing_secluso_status_endpoint(app: &AppHandle, run_id: Uuid, step: &str, status_url: &str) -> Result<()> { - let client = Client::builder() - .timeout(PUBLIC_PROBE_HTTP_TIMEOUT) - .build() - .context("Creating HTTP client for preflight reachability check")?; - - let response = client - .get(status_url) - .send() - .with_context(|| format!("Existing Secluso /status endpoint is not reachable from this computer at {status_url}"))?; - - let server_version = response - .headers() - .get("X-Server-Version") - .and_then(|value| value.to_str().ok()) - .map(str::to_string); - - let Some(server_version) = server_version else { - bail!( - "Reached {}, but it did not return X-Server-Version. This does not look like a healthy Secluso /status response.", - status_url - ); - }; - - log_line( - app, - run_id, - "info", - Some(step), - format!("Existing public Secluso /status endpoint is reachable (X-Server-Version: {server_version})."), - ); - Ok(()) -} - fn start_temp_http_probe( sess: &Session, target: &SshTarget, @@ -747,24 +674,6 @@ fn probe_public_demo_endpoint(probe_url: &str, probe_token: &str, listen_port: u Err(last_error.context("Public HTTP probe failed without an error.")?) } -fn direct_status_url_for_preflight( - runtime: Option<&ServerRuntimePlan>, - public_server_url: Option<&str>, - listen_port: u16, -) -> Result> { - let exposure_mode = runtime.map(|value| value.exposure_mode.as_str()).unwrap_or("direct"); - if exposure_mode != "direct" { - return Ok(None); - } - - let Some(public_server_url) = public_server_url.map(str::trim).filter(|value| !value.is_empty()) else { - return Ok(None); - }; - - let base_url = prepare_direct_probe_base_url(public_server_url, listen_port)?; - Ok(Some(base_url.join("status").context("Preparing preflight /status probe URL")?)) -} - fn verify_outbound_network(app: &AppHandle, run_id: Uuid, step: &str, sess: &Session) -> Result<()> { let dns_checks = [ ( @@ -821,9 +730,17 @@ fn verify_outbound_network(app: &AppHandle, run_id: Uuid, step: &str, sess: &Ses Ok(()) } -fn check_firewall(app: &AppHandle, run_id: Uuid, step: &str, sess: &Session, runtime: Option<&ServerRuntimePlan>) -> Result<()> { +fn check_firewall( + app: &AppHandle, + run_id: Uuid, + step: &str, + sess: &Session, + target: &SshTarget, + runtime: Option<&ServerRuntimePlan>, +) -> Result<()> { let exposure_mode = runtime.map(|value| value.exposure_mode.as_str()).unwrap_or("direct"); let listen_port = runtime.map(|value| value.listen_port).unwrap_or(DEFAULT_SERVER_HTTP_PORT); + let allow_ufw_rule = runtime.map(|value| value.allow_ufw_rule).unwrap_or(false); if exposure_mode == "proxy" { if remote_success(sess, "command -v nginx >/dev/null 2>&1 || command -v caddy >/dev/null 2>&1 || command -v apache2 >/dev/null 2>&1", None)? { log_line( @@ -856,7 +773,18 @@ fn check_firewall(app: &AppHandle, run_id: Uuid, step: &str, sess: &Session, run return Ok(()); } - let ufw = remote_shell(sess, "ufw status", None)?; + let uses_sudo = target.user != "root"; + let ufw = remote_shell_for_probe(sess, target, "ufw status", uses_sudo)?; + if ufw.exit != 0 { + log_line( + app, + run_id, + "warn", + Some(step), + format!("Could not inspect ufw status. {}", summarize_remote_failure(&ufw)), + ); + return Ok(()); + } let status = ufw.stdout.to_lowercase(); if status.contains("inactive") { log_line( @@ -868,20 +796,53 @@ fn check_firewall(app: &AppHandle, run_id: Uuid, step: &str, sess: &Session, run "ufw is inactive. That is fine locally, but you may still need to open TCP port {listen_port} in your provider firewall or security group." ), ); - } else if status.contains(&listen_port.to_string()) && status.contains("allow") { + } else if ufw_allows_tcp_port(&status, listen_port) { log_line(app, run_id, "info", Some(step), format!("ufw appears to allow TCP port {listen_port}.")); - } else { + } else if allow_ufw_rule { log_line( app, run_id, "warn", Some(step), - format!("ufw is active and does not obviously allow TCP port {listen_port}. Remote app access may fail until you open that port."), + format!("ufw is active and does not allow TCP port {listen_port}; adding an allow rule with user permission."), + ); + let add_rule = remote_shell_for_probe( + sess, + target, + &format!("ufw allow {listen_port}/tcp comment 'Secluso server'"), + uses_sudo, + )?; + if add_rule.exit != 0 { + bail!( + "ufw is active, but Secluso could not add an allow rule for TCP port {listen_port}. {}", + summarize_remote_failure(&add_rule) + ); + } + + let updated = remote_shell_for_probe(sess, target, "ufw status", uses_sudo)?; + if updated.exit != 0 || !ufw_allows_tcp_port(&updated.stdout.to_lowercase(), listen_port) { + bail!("Secluso ran ufw allow for TCP port {listen_port}, but could not verify the resulting ufw rule."); + } + log_line(app, run_id, "info", Some(step), format!("Added ufw allow rule for TCP port {listen_port}.")); + } else { + bail!( + "ufw is active and does not allow TCP port {listen_port}. Enable permission to add a ufw rule, or run this on the server: sudo ufw allow {listen_port}/tcp" ); } Ok(()) } +fn ufw_allows_tcp_port(status: &str, listen_port: u16) -> bool { + let port_tcp = format!("{listen_port}/tcp"); + let port_any = listen_port.to_string(); + status.lines().any(|line| { + let line = line.trim(); + line.contains("allow") + && (line.split_whitespace().any(|field| field == port_tcp) + || line.split_whitespace().any(|field| field == port_any)) + }) +} + fn parse_os_release_field(contents: &str, key: &str) -> Option { contents .lines() diff --git a/deploy/src-tauri/src/provision_server/provision.rs b/deploy/src-tauri/src/provision_server/provision.rs index a463af9..413ba42 100644 --- a/deploy/src-tauri/src/provision_server/provision.rs +++ b/deploy/src-tauri/src/provision_server/provision.rs @@ -5,16 +5,18 @@ use crate::provision_server::events::{log_line, step_ok, step_start}; use crate::provision_server::preflight::run_preflight; use crate::provision_server::script::remote_provision_script; use crate::provision_server::ssh::{ - cleanup_remote_path, connect_ssh, create_remote_temp_dir, exec_remote_script_streaming, scp_upload_bytes, sudo_prefix, + cleanup_remote_path, connect_ssh, create_remote_temp_dir, exec_remote_script_streaming, + scp_upload_bytes, sudo_prefix, }; use crate::provision_server::types::{ServerPlan, ServerSecrets, SshTarget}; +use crate::release_config::{normalize_repo, resolve_signers}; use anyhow::{bail, Context, Result}; use reqwest::blocking::Client; +use secluso_client_server_lib::auth::parse_user_credentials; use secluso_update::{ - build_github_client, default_signers, download_and_verify_component, fetch_latest_release, Component as ReleaseComponent, - Signer, + build_github_client, download_and_verify_component, fetch_latest_release, + Component as ReleaseComponent, }; -use secluso_client_server_lib::auth::parse_user_credentials; use std::fs; use std::path::PathBuf; use std::thread::sleep; @@ -22,400 +24,438 @@ use std::time::Duration; use tauri::AppHandle; use uuid::Uuid; -const INSTALL_PREFIX: &str = "/opt/secluso"; +const INSTALL_BIN_DIR: &str = "/usr/bin"; +const VERSION_ROOT: &str = "/var/lib/secluso/current_version"; const SERVER_UNIT: &str = "secluso-server.service"; const UPDATER_SERVICE: &str = "secluso-updater.service"; const UPDATE_INTERVAL_SECS: &str = "1800"; struct DownloadedArtifacts { - release_tag: String, - server_manifest_version: String, - server_bytes: Vec, - updater_bytes: Vec, + release_tag: String, + server_manifest_version: String, + server_bytes: Vec, + updater_bytes: Vec, } fn remote_stage_path(stage_dir: &str, name: &str) -> String { - format!("{stage_dir}/{name}") -} - -fn normalize_repo(input: &str) -> String { - let trimmed = input.trim().trim_end_matches('/'); - if let Some(idx) = trimmed.find("github.com/") { - let repo = &trimmed[idx + "github.com/".len()..]; - return repo.trim_end_matches(".git").to_string(); - } - trimmed.trim_end_matches(".git").to_string() -} - -fn resolve_signers(sig_keys: &[crate::provision_server::types::SigKey]) -> Vec { - if sig_keys.is_empty() { - return default_signers(); - } - - sig_keys - .iter() - .map(|key| Signer { - label: key.name.trim().to_string(), - github_user: key.github_user.trim().to_string(), - fingerprint: key - .fingerprint - .as_deref() - .map(str::trim) - .filter(|v| !v.is_empty()) - .map(ToOwned::to_owned), - }) - .collect() + format!("{stage_dir}/{name}") } fn download_verified_artifacts( - app: &AppHandle, - run_id: Uuid, - owner_repo: &str, - remote_arch: &str, - sig_keys: &[crate::provision_server::types::SigKey], - github_token: Option<&str>, + app: &AppHandle, + run_id: Uuid, + owner_repo: &str, + remote_arch: &str, + sig_keys: &[crate::provision_server::types::SigKey], + github_token: Option<&str>, ) -> Result { - let signers = resolve_signers(sig_keys); - let client = build_github_client(20, github_token, "secluso-deploy")?; - let release = fetch_latest_release(&client, owner_repo) - .with_context(|| format!("Fetching latest release metadata for {owner_repo}"))?; - log_line( - app, - run_id, - "info", - Some("artifacts"), - format!("Latest immutable release for {owner_repo}: {}", release.tag_name), - ); + let signers = resolve_signers(Some(sig_keys)); + let client = build_github_client(20, github_token, "secluso-deploy")?; + let release = fetch_latest_release(&client, owner_repo) + .with_context(|| format!("Fetching latest release metadata for {owner_repo}"))?; + log_line( + app, + run_id, + "info", + Some("artifacts"), + format!( + "Latest immutable release for {owner_repo}: {}", + release.tag_name + ), + ); - let server_verified = download_and_verify_component( - &client, - &release, - ReleaseComponent::Server, - remote_arch, - None, - &signers, - ) - .with_context(|| format!("Downloading and verifying secluso-server for {remote_arch}"))?; - - let bundle_dir = shared_temp_dir("secluso-server-bundle").context("creating temp bundle dir")?; - let bundle_path = bundle_dir.path().join("release.zip"); - fs::write(&bundle_path, &server_verified.bundle_bytes) - .with_context(|| format!("writing temporary bundle {}", bundle_path.display()))?; - - let updater_verified = download_and_verify_component( - &client, - &release, - ReleaseComponent::Updater, - remote_arch, - Some(bundle_path.to_str().context("bundle path is not valid UTF-8")?), - &signers, - ) - .with_context(|| format!("Downloading and verifying secluso-update for {remote_arch}"))?; - - log_line( - app, - run_id, - "info", - Some("artifacts"), - format!( - "Verified release bundle {} for {} (server={} bytes, updater={} bytes).", - release.tag_name, - remote_arch, - server_verified.component_bytes.len(), - updater_verified.component_bytes.len() - ), - ); + let server_verified = download_and_verify_component( + &client, + &release, + ReleaseComponent::Server, + remote_arch, + None, + &signers, + ) + .with_context(|| format!("Downloading and verifying secluso-server for {remote_arch}"))?; + + let bundle_dir = + shared_temp_dir("secluso-server-bundle").context("creating temp bundle dir")?; + let bundle_path = bundle_dir.path().join("release.zip"); + fs::write(&bundle_path, &server_verified.bundle_bytes) + .with_context(|| format!("writing temporary bundle {}", bundle_path.display()))?; + + let updater_verified = download_and_verify_component( + &client, + &release, + ReleaseComponent::Updater, + remote_arch, + Some( + bundle_path + .to_str() + .context("bundle path is not valid UTF-8")?, + ), + &signers, + ) + .with_context(|| format!("Downloading and verifying secluso-update for {remote_arch}"))?; + + log_line( + app, + run_id, + "info", + Some("artifacts"), + format!( + "Verified release bundle {} for {} (server={} bytes, updater={} bytes).", + release.tag_name, + remote_arch, + server_verified.component_bytes.len(), + updater_verified.component_bytes.len() + ), + ); - Ok(DownloadedArtifacts { - release_tag: server_verified.release_tag, - server_manifest_version: server_verified.manifest_version, - server_bytes: server_verified.component_bytes, - updater_bytes: updater_verified.component_bytes, - }) + Ok(DownloadedArtifacts { + release_tag: server_verified.release_tag, + server_manifest_version: server_verified.manifest_version, + server_bytes: server_verified.component_bytes, + updater_bytes: updater_verified.component_bytes, + }) } -pub fn run_provision(app: &AppHandle, run_id: Uuid, target: SshTarget, plan: ServerPlan) -> Result<()> { - let owner_repo = plan - .binaries_repo - .as_ref() - .map(|repo| normalize_repo(repo)) - .unwrap_or_else(|| "secluso/secluso".to_string()); - - step_start(app, run_id, "ssh_connect", "Connecting via SSH"); - let (sess, _temps) = connect_ssh(&target)?; - step_ok(app, run_id, "ssh_connect"); - - let (sudo_cmd, sudo_pw) = sudo_prefix(&target); - - step_start(app, run_id, "preflight", "Checking server compatibility"); - let preflight = run_preflight( - app, - run_id, - "preflight", - &sess, - &target, - Some(&plan.runtime), - plan.secrets.as_ref().map(|value| value.server_url.as_str()), - )?; - step_ok(app, run_id, "preflight"); - - cleanup_preflight_helpers(app, run_id, &sess, &sudo_cmd, sudo_pw.as_deref())?; - - // detect remote state - step_start(app, run_id, "detect", "Detecting remote install state"); - let remote_has_bin = preflight.remote_has_bin; - let remote_has_unit = preflight.remote_has_unit; - log_line(app, run_id, "info", Some("detect"), format!("REMOTE_HAS_BIN={remote_has_bin}")); - log_line(app, run_id, "info", Some("detect"), format!("REMOTE_HAS_UNIT={remote_has_unit}")); - log_line(app, run_id, "info", Some("detect"), format!("REMOTE_SERVICE_ACTIVE={}", preflight.service_active)); - log_line( - app, - run_id, - "info", - Some("detect"), - format!( - "REMOTE_SERVER_VERSION={}", - preflight.installed_version.clone().unwrap_or_else(|| "unknown".to_string()) - ), - ); - log_line( - app, - run_id, - "info", - Some("detect"), - format!("REMOTE_PORT_{}_IN_USE={}", plan.runtime.listen_port, preflight.port_in_use), - ); - step_ok(app, run_id, "detect"); +pub fn run_provision( + app: &AppHandle, + run_id: Uuid, + target: SshTarget, + plan: ServerPlan, +) -> Result<()> { + let owner_repo = plan + .binaries_repo + .as_ref() + .map(|repo| normalize_repo(repo)) + .unwrap_or_else(|| "secluso/secluso".to_string()); - let overwrite = plan.overwrite.unwrap_or(false); - let sig_keys = plan.sig_keys.clone().unwrap_or_default(); + step_start(app, run_id, "ssh_connect", "Connecting via SSH"); + let (sess, _temps) = connect_ssh(&target)?; + step_ok(app, run_id, "ssh_connect"); - // decide if this is a first install - let first_install = overwrite || !(remote_has_bin && remote_has_unit); - if !first_install && !preflight.remote_has_credentials_full { - bail!( - "Existing install is missing /var/lib/secluso/credentials_full. That older server layout is no longer updated in place. Turn on Overwrite existing install to replace it cleanly." - ); - } - - let mut generated_user_credentials: Option> = None; - // Give each provisioning run its own remote staging dir. - // Installer now reads inputs from one private per-run location instead of fixed top-level temp paths. - let remote_stage_dir = create_remote_temp_dir(&sess, "secluso-provision") - .context("creating remote staging dir")?; - - let provision_result = (|| -> Result<()> { - step_start(app, run_id, "artifacts", "Downloading verified release binaries"); - let artifacts = download_verified_artifacts( - app, - run_id, - &owner_repo, - &preflight.remote_arch, - &sig_keys, - plan.github_token.as_deref(), - )?; - // Keep the uploaded binaries non-executable here. - // They only become executable when the remote installer places them into the actual install path. - scp_upload_bytes( - &sess, - &remote_stage_path(&remote_stage_dir, "secluso-server"), - 0o600, - &artifacts.server_bytes, - )?; - scp_upload_bytes( - &sess, - &remote_stage_path(&remote_stage_dir, "secluso-update"), - 0o600, - &artifacts.updater_bytes, - )?; - step_ok(app, run_id, "artifacts"); - - // step 2 generate and upload secrets - step_start(app, run_id, "secrets", "Preparing runtime secrets"); - let secrets = plan.secrets.as_ref().context("Missing secrets config")?; - let sa_path = PathBuf::from(&secrets.service_account_key_path); - let sa = std::fs::read(&sa_path).with_context(|| format!("Missing service account key at {}", sa_path.display()))?; - scp_upload_bytes( - &sess, - &remote_stage_path(&remote_stage_dir, "service_account_key.json"), - 0o600, - &sa, - )?; + let (sudo_cmd, sudo_pw) = sudo_prefix(&target); - if first_install { - let work_dir = shared_temp_dir("secluso-server-creds").context("creating temp work dir")?; - let work_path = work_dir.path(); - let sig_keys = plan.sig_keys.as_ref().map(|keys| { - keys - .iter() - .map(|k| crate::pi_hub_provision::model::SigKey { - name: k.name.trim().to_string(), - github_user: k.github_user.trim().to_string(), - fingerprint: k - .fingerprint - .as_deref() - .map(str::trim) - .filter(|v| !v.is_empty()) - .map(ToOwned::to_owned), - }) - .collect::>() - }); - generate_user_credentials_only( + step_start(app, run_id, "preflight", "Checking server compatibility"); + let preflight = run_preflight( app, run_id, - work_path, - &secrets.server_url, - &owner_repo, - sig_keys.as_deref(), - plan.github_token.as_deref(), - )?; - - let uc_path = work_path.join("user_credentials"); - let uc = std::fs::read(&uc_path).with_context(|| format!("Missing user credentials at {}", uc_path.display()))?; - let credentials_full_path = work_path.join("credentials_full"); - let credentials_full = std::fs::read(&credentials_full_path) - .with_context(|| format!("Missing credentials_full at {}", credentials_full_path.display()))?; - - let qr_src = work_path.join("user_credentials_qrcode.png"); - if !qr_src.exists() { - bail!("Missing QR code at {}", qr_src.display()); - } - let qr_path = PathBuf::from(&secrets.user_credentials_qr_path); - if let Some(parent) = qr_path.parent() { - if !parent.as_os_str().is_empty() { - std::fs::create_dir_all(parent)?; - } - } - std::fs::copy(&qr_src, &qr_path).with_context(|| format!("Saving QR code to {}", qr_path.display()))?; - - scp_upload_bytes( - &sess, - &remote_stage_path(&remote_stage_dir, "user_credentials"), - 0o600, - &uc, - )?; - scp_upload_bytes( + "preflight", &sess, - &remote_stage_path(&remote_stage_dir, "credentials_full"), - 0o600, - &credentials_full, - )?; - generated_user_credentials = Some(uc); - } else { - log_line( + &target, + Some(&plan.runtime), + plan.secrets.as_ref().map(|value| value.server_url.as_str()), + )?; + step_ok(app, run_id, "preflight"); + + cleanup_preflight_helpers(app, run_id, &sess, &sudo_cmd, sudo_pw.as_deref())?; + + // detect remote state + step_start(app, run_id, "detect", "Detecting remote install state"); + let remote_has_bin = preflight.remote_has_bin; + let remote_has_unit = preflight.remote_has_unit; + log_line( app, run_id, "info", - Some("secrets"), - "Existing install detected. Leaving the current server credentials unchanged.".to_string(), - ); - } - step_ok(app, run_id, "secrets"); - - // step 3 run the remote provision script - step_start(app, run_id, "remote", "Running remote installer"); - let mut envs = vec![ - ("INSTALL_PREFIX", INSTALL_PREFIX.to_string()), - ("OWNER_REPO", owner_repo.to_string()), - ("SERVER_UNIT", SERVER_UNIT.to_string()), - ("UPDATER_SERVICE", UPDATER_SERVICE.to_string()), - ("UPDATE_INTERVAL_SECS", UPDATE_INTERVAL_SECS.to_string()), - ("BIND_ADDRESS", plan.runtime.bind_address.clone()), - ("LISTEN_PORT", plan.runtime.listen_port.to_string()), - ("SUDO_CMD", sudo_cmd.clone()), - ("ENABLE_UPDATER", if plan.auto_updater.enable { "1".to_string() } else { "0".to_string() }), - ("OVERWRITE", if overwrite { "1".to_string() } else { "0".to_string() }), - ("FIRST_INSTALL", if first_install { "1".to_string() } else { "0".to_string() }), - ("RELEASE_TAG", artifacts.release_tag.clone()), - // The remote script only trusts staged inputs under this directory for this run. - ("STAGING_DIR", remote_stage_dir.clone()), - ( - "SIG_KEYS", - sig_keys - .iter() - .map(|k| { - let mut value = format!("{}:{}", k.name.trim(), k.github_user.trim()); - if let Some(fingerprint) = k.fingerprint.as_deref().map(str::trim).filter(|v| !v.is_empty()) { - value.push(':'); - value.push_str(fingerprint); - } - value - }) - .filter(|v| !v.trim().is_empty()) - .collect::>() - .join(","), - ), - ]; - if let Some(token) = plan.github_token.as_ref().map(|v| v.trim().to_string()).filter(|v| !v.is_empty()) { - envs.push(("GITHUB_TOKEN", token)); - } - exec_remote_script_streaming( - app, - run_id, - "remote", - &sess, - &envs.iter().map(|(k, v)| (*k, v.clone())).collect::>(), - sudo_pw, - remote_provision_script(), - )?; - - step_ok(app, run_id, "remote"); - - step_start(app, run_id, "health", "Checking public server health"); - if first_install { - if let Some(uc) = generated_user_credentials.as_ref() { - let probe_version = plan - .manifest_version_override - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(&artifacts.server_manifest_version); - verify_public_server_health(app, run_id, &plan, secrets, probe_version, uc)?; - } else { - log_line(app, run_id, "warn", Some("health"), "Skipping public health check because generated credentials are unavailable.".to_string()); - } - } else { - log_line( + Some("detect"), + format!("REMOTE_HAS_BIN={remote_has_bin}"), + ); + log_line( app, run_id, "info", - Some("health"), - "Skipping public health check for update-only runs because no new credentials were generated.".to_string(), - ); + Some("detect"), + format!("REMOTE_HAS_UNIT={remote_has_unit}"), + ); + log_line( + app, + run_id, + "info", + Some("detect"), + format!("REMOTE_SERVICE_ACTIVE={}", preflight.service_active), + ); + log_line( + app, + run_id, + "info", + Some("detect"), + format!( + "REMOTE_SERVER_VERSION={}", + preflight + .installed_version + .clone() + .unwrap_or_else(|| "unknown".to_string()) + ), + ); + log_line( + app, + run_id, + "info", + Some("detect"), + format!( + "REMOTE_PORT_{}_IN_USE={}", + plan.runtime.listen_port, preflight.port_in_use + ), + ); + step_ok(app, run_id, "detect"); + + let overwrite = plan.overwrite.unwrap_or(false); + let sig_keys = plan.sig_keys.clone().unwrap_or_default(); + + let existing_install = remote_has_bin || remote_has_unit; + if existing_install && !overwrite { + bail!( + "Existing Secluso install detected. Server provisioning does not perform manual update-only installs; leave auto-updater enabled or turn on Overwrite existing install to replace it cleanly." + ); } - step_ok(app, run_id, "health"); + let first_install = true; + + let mut generated_user_credentials: Option> = None; + // Give each provisioning run its own remote staging dir. + // Installer now reads inputs from one private per-run location instead of fixed top-level temp paths. + let remote_stage_dir = create_remote_temp_dir(&sess, "secluso-provision") + .context("creating remote staging dir")?; + + let provision_result = (|| -> Result<()> { + step_start( + app, + run_id, + "artifacts", + "Downloading verified release binaries", + ); + let artifacts = download_verified_artifacts( + app, + run_id, + &owner_repo, + &preflight.remote_arch, + &sig_keys, + plan.github_token.as_deref(), + )?; + // Keep the uploaded binaries non-executable here. + // They only become executable when the remote installer places them into the actual install path. + scp_upload_bytes( + &sess, + &remote_stage_path(&remote_stage_dir, "secluso-server"), + 0o600, + &artifacts.server_bytes, + )?; + scp_upload_bytes( + &sess, + &remote_stage_path(&remote_stage_dir, "secluso-update"), + 0o600, + &artifacts.updater_bytes, + )?; + step_ok(app, run_id, "artifacts"); + + // step 2 generate and upload secrets + step_start(app, run_id, "secrets", "Preparing runtime secrets"); + let secrets = plan.secrets.as_ref().context("Missing secrets config")?; + let sa_path = PathBuf::from(&secrets.service_account_key_path); + let sa = std::fs::read(&sa_path) + .with_context(|| format!("Missing service account key at {}", sa_path.display()))?; + scp_upload_bytes( + &sess, + &remote_stage_path(&remote_stage_dir, "service_account_key.json"), + 0o600, + &sa, + )?; + + if first_install { + let work_dir = + shared_temp_dir("secluso-server-creds").context("creating temp work dir")?; + let work_path = work_dir.path(); + let sig_keys = plan.sig_keys.as_ref().map(|keys| { + keys.iter() + .map(|k| crate::pi_hub_provision::model::SigKey { + name: k.name.trim().to_string(), + github_user: k.github_user.trim().to_string(), + fingerprint: k + .fingerprint + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(ToOwned::to_owned), + }) + .collect::>() + }); + generate_user_credentials_only( + app, + run_id, + work_path, + &secrets.server_url, + &owner_repo, + sig_keys.as_deref(), + plan.github_token.as_deref(), + )?; + + let uc_path = work_path.join("user_credentials"); + let uc = std::fs::read(&uc_path) + .with_context(|| format!("Missing user credentials at {}", uc_path.display()))?; + let credentials_full_path = work_path.join("credentials_full"); + let credentials_full = std::fs::read(&credentials_full_path).with_context(|| { + format!( + "Missing credentials_full at {}", + credentials_full_path.display() + ) + })?; + + let qr_src = work_path.join("user_credentials_qrcode.png"); + if !qr_src.exists() { + bail!("Missing QR code at {}", qr_src.display()); + } + let qr_path = PathBuf::from(&secrets.user_credentials_qr_path); + if let Some(parent) = qr_path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent)?; + } + } + std::fs::copy(&qr_src, &qr_path) + .with_context(|| format!("Saving QR code to {}", qr_path.display()))?; + + scp_upload_bytes( + &sess, + &remote_stage_path(&remote_stage_dir, "user_credentials"), + 0o600, + &uc, + )?; + scp_upload_bytes( + &sess, + &remote_stage_path(&remote_stage_dir, "credentials_full"), + 0o600, + &credentials_full, + )?; + generated_user_credentials = Some(uc); + } + step_ok(app, run_id, "secrets"); + + // step 3 run the remote provision script + step_start(app, run_id, "remote", "Running remote installer"); + let mut envs = vec![ + ("INSTALL_BIN_DIR", INSTALL_BIN_DIR.to_string()), + ("VERSION_ROOT", VERSION_ROOT.to_string()), + ("OWNER_REPO", owner_repo.to_string()), + ("SERVER_UNIT", SERVER_UNIT.to_string()), + ("UPDATER_SERVICE", UPDATER_SERVICE.to_string()), + ("UPDATE_INTERVAL_SECS", UPDATE_INTERVAL_SECS.to_string()), + ("BIND_ADDRESS", plan.runtime.bind_address.clone()), + ("LISTEN_PORT", plan.runtime.listen_port.to_string()), + ("SUDO_CMD", sudo_cmd.clone()), + ("ENABLE_UPDATER", "1".to_string()), + ( + "OVERWRITE", + if overwrite { + "1".to_string() + } else { + "0".to_string() + }, + ), + ( + "FIRST_INSTALL", + if first_install { + "1".to_string() + } else { + "0".to_string() + }, + ), + ("RELEASE_TAG", artifacts.release_tag.clone()), + // The remote script only trusts staged inputs under this directory for this run. + ("STAGING_DIR", remote_stage_dir.clone()), + ( + "SIG_KEYS", + sig_keys + .iter() + .map(|k| { + let mut value = format!("{}:{}", k.name.trim(), k.github_user.trim()); + if let Some(fingerprint) = k + .fingerprint + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()) + { + value.push(':'); + value.push_str(fingerprint); + } + value + }) + .filter(|v| !v.trim().is_empty()) + .collect::>() + .join(","), + ), + ]; + if let Some(token) = plan + .github_token + .as_ref() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + { + envs.push(("GITHUB_TOKEN", token)); + } + exec_remote_script_streaming( + app, + run_id, + "remote", + &sess, + &envs + .iter() + .map(|(k, v)| (*k, v.clone())) + .collect::>(), + sudo_pw, + remote_provision_script(), + )?; + + step_ok(app, run_id, "remote"); + + step_start(app, run_id, "health", "Checking public server health"); + if let Some(uc) = generated_user_credentials.as_ref() { + let probe_version = plan + .manifest_version_override + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(&artifacts.server_manifest_version); + verify_public_server_health(app, run_id, &plan, secrets, probe_version, uc)?; + } else { + log_line( + app, + run_id, + "warn", + Some("health"), + "Skipping public health check because generated credentials are unavailable." + .to_string(), + ); + } + step_ok(app, run_id, "health"); + Ok(()) + })(); + + // Old staged binaries and secrets do not need to hang around after the run finishes. + let cleanup_result = cleanup_remote_path(&sess, &remote_stage_dir); + if let Err(err) = provision_result { + let _ = cleanup_result; + return Err(err); + } + cleanup_result?; Ok(()) - })(); - - // Old staged binaries and secrets do not need to hang around after the run finishes. - let cleanup_result = cleanup_remote_path(&sess, &remote_stage_dir); - if let Err(err) = provision_result { - let _ = cleanup_result; - return Err(err); - } - cleanup_result?; - Ok(()) } fn cleanup_preflight_helpers( - app: &AppHandle, - run_id: Uuid, - sess: &ssh2::Session, - sudo_cmd: &str, - sudo_pw: Option<&str>, + app: &AppHandle, + run_id: Uuid, + sess: &ssh2::Session, + sudo_cmd: &str, + sudo_pw: Option<&str>, ) -> Result<()> { - let systemctl_prefix = if sudo_cmd.is_empty() { - "systemctl".to_string() - } else { - format!("{sudo_cmd} systemctl") - }; - let shell_prefix = if sudo_cmd.is_empty() { - "".to_string() - } else { - format!("{sudo_cmd} ") - }; - - let script = format!( + let systemctl_prefix = if sudo_cmd.is_empty() { + "systemctl".to_string() + } else { + format!("{sudo_cmd} systemctl") + }; + let shell_prefix = if sudo_cmd.is_empty() { + "".to_string() + } else { + format!("{sudo_cmd} ") + }; + + let script = format!( "set +e\n\ if command -v systemctl >/dev/null 2>&1; then\n\ units=\"$({systemctl_prefix} list-units --all --plain --no-legend 'secluso-preflight-http-*' 2>/dev/null | awk '{{print $1}}')\"\n\ @@ -433,167 +473,177 @@ fi\n\ exit 0\n" ); - exec_remote_script_streaming( - app, - run_id, - "preflight", - sess, - &[], - sudo_pw.map(str::to_string), - &script, - )?; - Ok(()) + exec_remote_script_streaming( + app, + run_id, + "preflight", + sess, + &[], + sudo_pw.map(str::to_string), + &script, + )?; + Ok(()) } fn verify_public_server_health( - app: &AppHandle, - run_id: Uuid, - plan: &ServerPlan, - secrets: &ServerSecrets, - probe_version: &str, - user_credentials: &[u8], + app: &AppHandle, + run_id: Uuid, + plan: &ServerPlan, + secrets: &ServerSecrets, + probe_version: &str, + user_credentials: &[u8], ) -> Result<()> { - let (username, password) = parse_user_credentials(user_credentials.to_vec()).context("Parsing generated user credentials")?; - let status_url = format!("{}/status", secrets.server_url.trim_end_matches('/')); - let client = Client::builder() - .timeout(Duration::from_secs(15)) - .build() - .context("Creating HTTP client for post-install health check")?; - - log_line( - app, - run_id, - "info", - Some("health"), - "Checking the public /status endpoint from this computer.".to_string(), - ); + let (username, password) = parse_user_credentials(user_credentials.to_vec()) + .context("Parsing generated user credentials")?; + let status_url = format!("{}/status", secrets.server_url.trim_end_matches('/')); + let client = Client::builder() + .timeout(Duration::from_secs(15)) + .build() + .context("Creating HTTP client for post-install health check")?; + + log_line( + app, + run_id, + "info", + Some("health"), + "Checking the public /status endpoint from this computer with generated credentials." + .to_string(), + ); - let mut discovered_version = None; - probe_public_server_health( - &client, - &status_url, - &plan.runtime.exposure_mode, - plan.runtime.listen_port, - probe_version, - &username, - &password, - 8, - Duration::from_secs(2), - |attempt| { - log_line( + let mut discovered_version = None; + probe_public_server_health( + &client, + &status_url, + &plan.runtime.exposure_mode, + plan.runtime.listen_port, + probe_version, + &username, + &password, + 8, + Duration::from_secs(2), + |attempt| { + log_line( + app, + run_id, + "warn", + Some("health"), + format!("Public /status probe not ready yet (attempt {attempt}/8). Retrying..."), + ); + }, + |server_version| { + discovered_version = Some(server_version.to_string()); + }, + )?; + + if let Some(server_version) = discovered_version { + log_line( + app, + run_id, + "info", + Some("health"), + format!("Remote server version header: {server_version}"), + ); + } + + log_line( app, run_id, - "warn", + "info", Some("health"), - format!("Public /status probe not ready yet (attempt {attempt}/8). Retrying..."), - ); - }, - |server_version| { - discovered_version = Some(server_version.to_string()); - }, - )?; - - if let Some(server_version) = discovered_version { - log_line(app, run_id, "info", Some("health"), format!("Remote server version header: {server_version}")); - } - - log_line( - app, - run_id, - "info", - Some("health"), - "Authenticated public health check succeeded.".to_string(), - ); - Ok(()) + "Authenticated public health check succeeded.".to_string(), + ); + Ok(()) } -fn unreachable_public_status_error(exposure_mode: &str, listen_port: u16, err: &reqwest::Error) -> anyhow::Error { - if exposure_mode == "proxy" { - anyhow::anyhow!( +fn unreachable_public_status_error( + exposure_mode: &str, + listen_port: u16, + err: &reqwest::Error, +) -> anyhow::Error { + if exposure_mode == "proxy" { + anyhow::anyhow!( "Secluso finished installing, but the public /status endpoint is not reachable from this computer yet: {}. Check your reverse proxy route, TLS setup, and whether it forwards to 127.0.0.1:{}.", err, listen_port ) - } else { - anyhow::anyhow!( + } else { + anyhow::anyhow!( "Secluso finished installing, but the public /status endpoint is not reachable from this computer yet: {}. Check that port {} is open in the server firewall and your provider security group.", err, listen_port ) - } + } } fn probe_public_server_health( - client: &Client, - status_url: &str, - exposure_mode: &str, - listen_port: u16, - probe_version: &str, - username: &str, - password: &str, - max_attempts: usize, - retry_delay: Duration, - mut on_retry: F, - mut on_version: G, + client: &Client, + status_url: &str, + exposure_mode: &str, + listen_port: u16, + probe_version: &str, + username: &str, + password: &str, + max_attempts: usize, + retry_delay: Duration, + mut on_retry: F, + mut on_version: G, ) -> Result<()> where - F: FnMut(usize), - G: FnMut(&str), + F: FnMut(usize), + G: FnMut(&str), { - let mut discover = None; - let mut last_discover_err = None; - for attempt in 1..=max_attempts { - match client - .get(status_url) - .header("Client-Version", probe_version) - .send() - { - Ok(response) => { - discover = Some(response); - break; - } - Err(err) => { - last_discover_err = Some(err); - if attempt < max_attempts { - on_retry(attempt); - sleep(retry_delay); + let mut auth = None; + let mut last_auth_err = None; + for attempt in 1..=max_attempts { + match client + .get(status_url) + .header("Client-Version", probe_version) + .basic_auth(username, Some(password)) + .send() + { + Ok(response) => { + auth = Some(response); + break; + } + Err(err) => { + last_auth_err = Some(err); + if attempt < max_attempts { + on_retry(attempt); + sleep(retry_delay); + } + } } - } } - } - let discover = match discover { - Some(response) => response, - None => { - let err = last_discover_err.context("Public /status probe failed without an error.")?; - return Err(unreachable_public_status_error(exposure_mode, listen_port, &err)); + let auth = match auth { + Some(response) => response, + None => { + let err = last_auth_err.context("Public /status probe failed without an error.")?; + return Err(unreachable_public_status_error( + exposure_mode, + listen_port, + &err, + )); + } + }; + + if !auth.status().is_success() { + bail!( + "The server is reachable, but the authenticated /status check failed with HTTP {}.", + auth.status() + ); } - }; - - let server_version = discover - .headers() - .get("X-Server-Version") - .and_then(|value| value.to_str().ok()) - .map(|value| value.to_string()); - - let Some(server_version) = server_version else { - bail!("Reached the server, but it did not return X-Server-Version. This does not look like a healthy Secluso server response."); - }; - on_version(&server_version); - - let auth = client - .get(status_url) - .header("Client-Version", &server_version) - .basic_auth(username, Some(password)) - .send() - .context("Authenticated health check failed.")?; - - if !auth.status().is_success() { - bail!( - "The server is reachable, but the authenticated /status check failed with HTTP {}.", - auth.status() - ); - } - Ok(()) + let server_version = auth + .headers() + .get("X-Server-Version") + .and_then(|value| value.to_str().ok()) + .map(|value| value.to_string()); + + let Some(server_version) = server_version else { + bail!("Authenticated /status succeeded, but the server did not return X-Server-Version. This does not look like a healthy Secluso server response."); + }; + on_version(&server_version); + + Ok(()) } diff --git a/deploy/src-tauri/src/provision_server/ssh.rs b/deploy/src-tauri/src/provision_server/ssh.rs index 4361139..e780f1e 100644 --- a/deploy/src-tauri/src/provision_server/ssh.rs +++ b/deploy/src-tauri/src/provision_server/ssh.rs @@ -346,7 +346,7 @@ pub fn create_remote_temp_dir(sess: &Session, prefix: &str) -> Result { let template = format!("/tmp/{prefix}.XXXXXX"); let cmd = format!( "stage_dir=\"$(mktemp -d {})\" && chmod 700 \"$stage_dir\" && printf '%s' \"$stage_dir\"", - shell_escape(&template) + shell_word(&template) ); let result = remote_shell(sess, &cmd, None)?; if result.exit != 0 { @@ -366,7 +366,7 @@ pub fn create_remote_temp_dir(sess: &Session, prefix: &str) -> Result { pub fn cleanup_remote_path(sess: &Session, remote_path: &str) -> Result<()> { // Best to clear staged inputs once the provisioning run is over. - let cmd = format!("rm -rf -- {}", shell_escape(remote_path)); + let cmd = format!("rm -rf -- {}", shell_word(remote_path)); let result = remote_shell(sess, &cmd, None)?; if result.exit != 0 { bail!( @@ -379,7 +379,7 @@ pub fn cleanup_remote_path(sess: &Session, remote_path: &str) -> Result<()> { } fn remote_shell(sess: &Session, cmd: &str, stdin: Option<&str>) -> Result { - let full = format!("bash -lc '{}'", shell_escape(cmd)); + let full = format!("bash -lc '{}'", shell_single_quote_inner(cmd)); let mut channel = sess.channel_session().context("Failed to open SSH channel")?; channel.exec(&full).with_context(|| format!("Remote exec failed: {cmd}"))?; if let Some(stdin) = stdin { @@ -412,7 +412,11 @@ fn summarize_remote_failure(result: &RemoteExecResult) -> String { format!("command exited with status {}", result.exit) } -fn shell_escape(s: &str) -> String { +fn shell_single_quote_inner(s: &str) -> String { + s.replace('\'', r#"'\''"#) +} + +fn shell_word(s: &str) -> String { if s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '/' | ':' | '@')) { s.to_string() } else { diff --git a/deploy/src-tauri/src/provision_server/types.rs b/deploy/src-tauri/src/provision_server/types.rs index 2f544b7..cc50313 100644 --- a/deploy/src-tauri/src/provision_server/types.rs +++ b/deploy/src-tauri/src/provision_server/types.rs @@ -49,12 +49,6 @@ pub struct HostKeyProof { pub sha256: String, } -#[derive(Debug, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct AutoUpdaterPlan { - pub enable: bool, -} - #[derive(Debug, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct SigKey { @@ -78,12 +72,13 @@ pub struct ServerRuntimePlan { pub exposure_mode: String, pub bind_address: String, pub listen_port: u16, + #[serde(default)] + pub allow_ufw_rule: bool, } #[derive(Debug, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct ServerPlan { - pub auto_updater: AutoUpdaterPlan, pub runtime: ServerRuntimePlan, pub secrets: Option, pub overwrite: Option, diff --git a/deploy/src-tauri/src/release_config.rs b/deploy/src-tauri/src/release_config.rs new file mode 100644 index 0000000..bcdf317 --- /dev/null +++ b/deploy/src-tauri/src/release_config.rs @@ -0,0 +1,114 @@ +//! SPDX-License-Identifier: GPL-3.0-or-later +use anyhow::{Context, Result}; +use semver::Version; +use secluso_update::{ + build_github_client, default_signers, fetch_latest_release, Signer, DEFAULT_OWNER_REPO, +}; +use serde::Serialize; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeployVersionStatus { + current_version: String, + latest_version: String, + release_tag: String, + outdated: bool, +} + +pub trait ReleaseSigKey { + fn name(&self) -> &str; + fn github_user(&self) -> &str; + fn fingerprint(&self) -> Option<&str>; +} + +impl ReleaseSigKey for crate::pi_hub_provision::model::SigKey { + fn name(&self) -> &str { + &self.name + } + + fn github_user(&self) -> &str { + &self.github_user + } + + fn fingerprint(&self) -> Option<&str> { + self.fingerprint.as_deref() + } +} + +impl ReleaseSigKey for crate::provision_server::types::SigKey { + fn name(&self) -> &str { + &self.name + } + + fn github_user(&self) -> &str { + &self.github_user + } + + fn fingerprint(&self) -> Option<&str> { + self.fingerprint.as_deref() + } +} + +pub fn normalize_repo(input: &str) -> String { + // Users may paste either owner/repo... an HTTPS GitHub URL... or a URL with a trailing .git suffix. + // All of those should resolve to the owner/repo string used by the GitHub release API. + // GitHub REST release endpoints take the repository as owner and repo path parameters, see here: https://docs.github.com/en/rest/releases/releases?apiVersion=2026-03-10#get-the-latest-release. + let trimmed = input.trim().trim_end_matches('/'); + if let Some(idx) = trimmed.find("github.com/") { + let repo = &trimmed[idx + "github.com/".len()..]; + return repo.trim_end_matches(".git").to_string(); + } + trimmed.trim_end_matches(".git").to_string() +} + +pub fn resolve_signers(sig_keys: Option<&[K]>) -> Vec { + // An omitted or empty signer list means the default Secluso release signers are required. + // When custom keys are provided, each field is trimmed before being handed to the updater library + // The updater later resolves GitHub-published signing keys through GitHub's GPG key API, see here: https://docs.github.com/en/rest/users/gpg-keys?apiVersion=2026-03-10 + let Some(sig_keys) = sig_keys else { + return default_signers(); + }; + + if sig_keys.is_empty() { + return default_signers(); + } + + sig_keys + .iter() + .map(|key| Signer { + label: key.name().trim().to_string(), + github_user: key.github_user().trim().to_string(), + fingerprint: key + .fingerprint() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned), + }) + .collect() +} + +fn format_version(version: &Version) -> String { + format!("v{version}") +} + +fn check_deploy_version_status() -> Result { + let current_version = Version::parse(env!("CARGO_PKG_VERSION")) + .context("parsing bundled deploy app version")?; + let client = build_github_client(10, None, "secluso-deploy")?; + let release = fetch_latest_release(&client, DEFAULT_OWNER_REPO) + .with_context(|| format!("fetching latest release metadata for {DEFAULT_OWNER_REPO}"))?; + let latest_version = release.parsed_version()?; + let outdated = current_version < latest_version; + + Ok(DeployVersionStatus { + current_version: format_version(¤t_version), + latest_version: format_version(&latest_version), + release_tag: release.tag_name, + outdated, + }) +} + +#[tauri::command] +pub fn get_deploy_version_status() -> std::result::Result { + check_deploy_version_status().map_err(|err| format!("{err:#}")) +} diff --git a/deploy/src-tauri/src/requirements.rs b/deploy/src-tauri/src/requirements.rs deleted file mode 100644 index 78ad5dd..0000000 --- a/deploy/src-tauri/src/requirements.rs +++ /dev/null @@ -1,54 +0,0 @@ -//! SPDX-License-Identifier: GPL-3.0-or-later - -use serde::Serialize; -use std::process::Command; - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct RequirementStatus { - pub name: String, - pub ok: bool, - pub version: Option, - pub hint: String, -} - -fn check_cmd(cmd: &str, args: &[&str]) -> (bool, Option) { - let out = Command::new(cmd).args(args).output(); - match out { - Ok(res) if res.status.success() => { - let stdout = String::from_utf8_lossy(&res.stdout).trim().to_string(); - let stderr = String::from_utf8_lossy(&res.stderr).trim().to_string(); - let version = if !stdout.is_empty() { stdout } else if !stderr.is_empty() { stderr } else { String::new() }; - let version = if version.is_empty() { None } else { Some(version) }; - (true, version) - } - _ => (false, None), - } -} - -#[tauri::command] -pub async fn check_requirements() -> Result, String> { - tauri::async_runtime::spawn_blocking(|| { - let mut statuses = Vec::new(); - - let checks = vec![ - ("Docker", "docker", vec!["--version"], "Needed to build Raspberry Pi images."), - ("Docker Buildx", "docker", vec!["buildx", "version"], "Needed for reproducible release builds."), - ]; - - for (name, cmd, args, hint) in checks { - let (ok, version) = check_cmd(cmd, &args); - statuses.push(RequirementStatus { - name: name.to_string(), - ok, - version, - hint: hint.to_string(), - }); - } - - Ok(statuses) - }) - .await - .map_err(|e| e.to_string())? - .map_err(|e: std::io::Error| e.to_string()) -} diff --git a/deploy/src/lib/api.ts b/deploy/src/lib/api.ts index bdc1ac5..9618fab 100644 --- a/deploy/src/lib/api.ts +++ b/deploy/src/lib/api.ts @@ -36,10 +36,10 @@ export interface ServerRuntimePlan { exposureMode: "direct" | "proxy"; bindAddress: string; listenPort: number; + allowUfwRule?: boolean; } export interface ServerPlan { - autoUpdater: { enable: boolean }; runtime: ServerRuntimePlan; secrets?: { serviceAccountKeyPath: string; serverUrl: string; userCredentialsQrPath: string }; overwrite?: boolean; @@ -60,31 +60,21 @@ export type ProvisionEvent = | { type: "log"; run_id: string; level: "info" | "warn" | "error"; step?: string; line: string } | { type: "done"; run_id: string; ok: boolean }; -export interface RequirementStatus { - name: string; - ok: boolean; - version?: string; - hint: string; -} - -export interface DockerStatus { - ok: boolean; - version?: string; - message?: string; -} - -export interface ImageBuildRequest { - variant?: "official" | "diy"; - cache: boolean; +export interface PrepareImageRequest { qrOutputPath: string; imageOutputPath: string; - sshEnabled?: boolean; - wifi?: { country: string; ssid: string; psk: string }; binariesRepo?: string; sigKeys?: { name: string; githubUser: string; fingerprint?: string }[]; githubToken?: string; } +export interface DeployVersionStatus { + currentVersion: string; + latestVersion: string; + releaseTag: string; + outdated: boolean; +} + export async function testServerSsh(target: SshTarget, runtime?: ServerRuntimePlan, serverUrl?: string): Promise { await invoke("test_server_ssh", { target, runtime, serverUrl }); } @@ -100,22 +90,18 @@ export async function provisionServer( return invoke("provision_server", { target, plan }); } -export async function buildImage(req: ImageBuildRequest): Promise { - return invoke("build_image", { req }); -} - -export async function checkDocker(): Promise { - return invoke("check_docker"); -} - -export async function checkRequirements(): Promise { - return invoke("check_requirements"); +export async function prepareImage(req: PrepareImageRequest): Promise { + return invoke("prepare_image", { req }); } export async function openExternalUrl(url: string): Promise { await invoke("open_external_url", { url }); } +export async function getDeployVersionStatus(): Promise { + return invoke("get_deploy_version_status"); +} + export async function listenProvisionEvents( handler: (event: ProvisionEvent) => void ): Promise { diff --git a/deploy/src/lib/components/AppHeader.svelte b/deploy/src/lib/components/AppHeader.svelte index 5e40bd4..cee7354 100644 --- a/deploy/src/lib/components/AppHeader.svelte +++ b/deploy/src/lib/components/AppHeader.svelte @@ -1,9 +1,60 @@
- +
{/if} + + {#if pendingRoute} + + {/if}
diff --git a/deploy/src/routes/image/+page.svelte b/deploy/src/routes/image/+page.svelte index 815e940..9831bd1 100644 --- a/deploy/src/routes/image/+page.svelte +++ b/deploy/src/routes/image/+page.svelte @@ -3,44 +3,11 @@ import { onMount } from "svelte"; import { save } from "@tauri-apps/plugin-dialog"; import { goto } from "$app/navigation"; - import { browser } from "$app/environment"; - import { buildImage, checkDocker, checkRequirements, openExternalUrl, type RequirementStatus } from "$lib/api"; + import { prepareImage } from "$lib/api"; import { maskDemoText } from "$lib/demoDisplay"; - // variants data model - type VariantKey = "official" | "diy"; - interface VariantDef { value: VariantKey; title: string; subtitle?: string; bullets: string[] } - - const variantDefs: VariantDef[] = [ - { - value: "official", - title: "Official", - subtitle: "Production camera", - bullets: [ - "LED and button hardware supported.", - "Night-vision IR auto-toggle service.", - "Auto-updater enabled.", - "Production config & indicators." - ] - }, - { - value: "diy", - title: "DIY", - subtitle: "Simple Pi setup", - bullets: [ - "No button, LED, or integrated night-vision controller.", - "Auto-updater enabled.", - ] - } - ]; - type DevSettings = { enabled: boolean; - cache: boolean; - wifiSsid: string; - wifiPsk: string; - wifiCountry: string; - sshEnabled: boolean; binariesSource: "main" | "custom"; binariesRepo: string; key1Name: string; @@ -48,7 +15,6 @@ key2Name: string; key2User: string; githubToken: string; - showDockerHelp: boolean; maskUserPathsWithDemo: boolean; }; @@ -56,10 +22,6 @@ const FIRST_TIME_KEY = "secluso-first-time"; const imageBackIcon = "/deploy-assets/image-back-latest.svg"; const imageHeroArt = "/deploy-assets/image-hero-latest.svg"; - const officialIcon = "/deploy-assets/image-official-icon-latest.svg"; - const diyIcon = "/deploy-assets/image-diy-icon-latest.svg"; - const selectedIcon = "/deploy-assets/image-selected-icon-latest.svg"; - const tipIcon = "/deploy-assets/image-tip-icon-latest.svg"; const imageLocationIcon = "/deploy-assets/image-output-icon-latest.svg"; const pickerIcon = "/deploy-assets/image-picker-icon-latest.svg"; const qrLocationIcon = "/deploy-assets/image-qr-icon-latest.svg"; @@ -67,16 +29,10 @@ const buildArrowIcon = "/deploy-assets/image-build-arrow-latest.svg"; // config state - let productVariant: VariantKey = "diy"; let qrOutputPath = ""; // full file path from the os save dialog let imageOutputPath = ""; // full file path from the os save dialog let devSettings: DevSettings = { enabled: false, - cache: false, - wifiSsid: "", - wifiPsk: "", - wifiCountry: "", - sshEnabled: true, binariesSource: "main", binariesRepo: "", key1Name: "", @@ -84,51 +40,14 @@ key2Name: "", key2User: "", githubToken: "", - showDockerHelp: false, maskUserPathsWithDemo: false }; // progress state - let building = false; + let preparing = false; let errorMsg = ""; let firstTimeOn = false; - let requirements: RequirementStatus[] = []; - let missingRequirements: RequirementStatus[] = []; - let checkingRequirements = true; - $: dockerMissing = missingRequirements.some((req) => req.name === "Docker"); - $: buildxMissing = missingRequirements.some((req) => req.name === "Docker Buildx"); - $: showDockerHelp = dockerMissing || buildxMissing || (devSettings.enabled && devSettings.showDockerHelp); - $: sshStatusText = !devSettings.enabled - ? "SSH disabled." - : devSettings.sshEnabled - ? "SSH enabled (developer options)." - : "SSH disabled (developer options)."; - $: imageOutputPlaceholder = effectiveSshEnabled() - ? "Choose file (e.g., secluso-rpi-ssh-enabled.img)" - : "Choose file (e.g., secluso-rpi.img)"; - - function effectiveSshEnabled(): boolean { - return devSettings.enabled && devSettings.sshEnabled; - } - - function normalizeSshSuffix(path: string, sshEnabled: boolean): string { - if (!path.endsWith(".img")) return path; - if (sshEnabled) { - if (path.endsWith("-ssh-enabled.img")) return path; - return `${path.slice(0, -4)}-ssh-enabled.img`; - } - if (path.endsWith("-ssh-enabled.img")) { - return `${path.slice(0, -"-ssh-enabled.img".length)}.img`; - } - return path; - } - - $: if (imageOutputPath) { - const normalized = normalizeSshSuffix(imageOutputPath, effectiveSshEnabled()); - if (normalized !== imageOutputPath) { - imageOutputPath = normalized; - } - } + $: imageOutputPlaceholder = "Choose file (e.g., secluso-rpi.wic)"; async function pickQrOutput() { const path = await save({ @@ -149,27 +68,20 @@ String(now.getHours()).padStart(2, "0"), String(now.getMinutes()).padStart(2, "0") ].join(""); - const defaultPath = normalizeSshSuffix(`secluso-rpi-${stamp}.img`, effectiveSshEnabled()); + const defaultPath = `secluso-rpi-${stamp}.wic`; const path = await save({ title: "Save Raspberry Pi image as…", defaultPath, - filters: [ { name: "Disk image", extensions: ["img"] } ] + filters: [ { name: "WIC image", extensions: ["wic"] } ] }); if (typeof path === "string" && path.length) imageOutputPath = path; } function validate(): string | null { if (!qrOutputPath) return "Please choose where to save the QR code."; - if (!imageOutputPath) return "Please choose where to save the image (.img)."; - if (!imageOutputPath.endsWith(".img")) return "Output image must end with .img"; + if (!imageOutputPath) return "Please choose where to save the image (.wic)."; + if (!imageOutputPath.endsWith(".wic")) return "Output image must end with .wic"; if (!qrOutputPath.endsWith(".png")) return "QR code must end with .png"; - if (devSettings.enabled) { - const hasAny = !!(devSettings.wifiSsid || devSettings.wifiPsk || devSettings.wifiCountry); - const hasAll = !!(devSettings.wifiSsid && devSettings.wifiPsk && devSettings.wifiCountry); - if (hasAny && !hasAll) { - return "Developer Wi-Fi needs SSID, password, and country."; - } - } if (devSettings.enabled && devSettings.binariesSource === "custom") { if (!devSettings.binariesRepo.trim()) return "Custom repo URL is required."; if (!devSettings.key1Name.trim() || !devSettings.key1User.trim()) { @@ -184,44 +96,15 @@ async function startBuild() { errorMsg = ""; - if (checkingRequirements) { - errorMsg = "Checking required tools. Try again in a moment."; - return; - } - if (missingRequirements.length > 0) { - errorMsg = `Missing required tools: ${missingRequirements.map((req) => req.name).join(", ")}.`; - return; - } const err = validate(); if (err) { errorMsg = err; return; } - building = true; + preparing = true; try { - const dockerStatus = await checkDocker(); - if (!dockerStatus.ok) { - errorMsg = dockerStatus.message ?? "Docker is installed, but the Docker daemon is not reachable. Start Docker and try again."; - return; - } - - const sshEnabled = effectiveSshEnabled(); - const outputWithSuffix = normalizeSshSuffix(imageOutputPath, sshEnabled); - if (outputWithSuffix !== imageOutputPath) { - imageOutputPath = outputWithSuffix; - } - - const devWifiEnabled = - devSettings.enabled && - devSettings.wifiSsid.trim() && - devSettings.wifiPsk.trim() && - devSettings.wifiCountry.trim(); - - const { run_id } = await buildImage({ - variant: productVariant, - cache: devSettings.cache, + const { run_id } = await prepareImage({ qrOutputPath, - imageOutputPath: outputWithSuffix, - sshEnabled, + imageOutputPath, binariesRepo: devSettings.binariesSource === "custom" ? devSettings.binariesRepo.trim() : undefined, githubToken: devSettings.enabled && devSettings.githubToken.trim() ? devSettings.githubToken.trim() : undefined, sigKeys: @@ -230,20 +113,13 @@ { name: devSettings.key1Name.trim(), githubUser: devSettings.key1User.trim() }, { name: devSettings.key2Name.trim(), githubUser: devSettings.key2User.trim() } ] - : undefined, - wifi: devWifiEnabled - ? { - ssid: devSettings.wifiSsid.trim(), - psk: devSettings.wifiPsk.trim(), - country: devSettings.wifiCountry.trim() - } - : undefined + : undefined }); goto(`/status?mode=image&runId=${encodeURIComponent(run_id)}`); } catch (e: any) { - errorMsg = e?.toString() ?? "Build failed."; + errorMsg = e?.toString() ?? "Image preparation failed."; } finally { - building = false; + preparing = false; } } @@ -258,11 +134,6 @@ } catch { devSettings = { enabled: false, - cache: false, - wifiSsid: "", - wifiPsk: "", - wifiCountry: "", - sshEnabled: true, binariesSource: "main", binariesRepo: "", key1Name: "", @@ -270,7 +141,6 @@ key2Name: "", key2User: "", githubToken: "", - showDockerHelp: false, maskUserPathsWithDemo: false }; } @@ -289,28 +159,6 @@ firstTimeOn = !firstTimeOn; localStorage.setItem(FIRST_TIME_KEY, String(firstTimeOn)); } - - async function openExternal(url: string) { - if (!browser) return; - try { - await openExternalUrl(url); - } catch (err) { - console.warn("Failed to open external link via shell opener.", err); - window.open(url, "_blank", "noopener,noreferrer"); - } - } - - onMount(async () => { - try { - requirements = await checkRequirements(); - missingRequirements = requirements.filter((req) => !req.ok); - } catch { - requirements = []; - missingRequirements = []; - } finally { - checkingRequirements = false; - } - });
@@ -335,52 +183,19 @@
-

Build Raspberry Pi Image

-

Generate a custom Pi OS image and camera pairing QR code.

+

Prepare Raspberry Pi Image

+

Download a verified Pi image and add the camera pairing secret.

-
-
Hardware Type
- - {#each variantDefs as v} - - {/each} - - {#if firstTimeOn} -
- -

- Choose Official if you bought a Secluso camera. Choose DIY for custom Pi builds. -

-
- {/if} -
-
Output Locations
- Save Pi image (.img) to + Save Pi image (.wic) to
@@ -410,45 +225,20 @@ {#if firstTimeOn}
-

The .img file is flashed to your SD card. The QR code is scanned by the mobile app to connect securely.

+

The .wic file is flashed to your SD card. The QR code is scanned by the mobile app to connect securely.

{/if}
- {#if checkingRequirements} -
-

Checking local tools…

-
- {:else if missingRequirements.length > 0} -
-
    - {#each missingRequirements as req} -
  • {req.name}: {maskDemoText(req.hint)}
  • - {/each} -
-
- {/if} - - {#if showDockerHelp} -
-

Docker is required for image builds.

-
-
- {/if} - {#if errorMsg}
{maskDemoText(errorMsg)}
{/if} - -

This generates a downloadable .img file for Raspberry Pi Imager

+

This prepares a downloadable .wic file for Raspberry Pi Imager

@@ -480,17 +270,6 @@ linear-gradient(180deg, rgba(3, 3, 3, 0.98), #030303 46%); } - .appbar { - height: 57px; - margin-bottom: 32px; - position: sticky; - top: 0; - z-index: 20; - background: rgba(3, 3, 3, 0.9); - backdrop-filter: blur(12px); - border-bottom: 1px solid rgba(255, 255, 255, 0.03); - } - .frame { position: relative; z-index: 1; @@ -644,125 +423,6 @@ margin-top: 24px; } - .hardware-card { - position: relative; - display: grid; - grid-template-columns: 40px 1fr auto; - align-items: center; - gap: 16px; - min-height: 83px; - padding: 0 20px; - border-radius: 20px; - border: 1px solid rgba(255, 255, 255, 0.04); - background: rgba(255, 255, 255, 0.02); - cursor: pointer; - } - - .hardware-card + .hardware-card { - margin-top: 16px; - } - - .hardware-card.selected { - background: rgba(59, 130, 246, 0.06); - border-color: rgba(59, 130, 246, 0.25); - } - - .hardware-card input { - position: absolute; - opacity: 0; - pointer-events: none; - } - - .hardware-icon { - width: 40px; - height: 40px; - border-radius: 12px; - display: grid; - place-items: center; - background: rgba(255, 255, 255, 0.03); - } - - .hardware-icon img { - width: 20px; - height: 20px; - display: block; - } - - .hardware-icon.diy { - background: rgba(43, 127, 255, 0.15); - } - - .hardware-copy { - display: grid; - gap: 5px; - } - - .hardware-copy strong { - font-size: 14px; - line-height: 21px; - font-weight: 500; - } - - .hardware-copy small { - color: rgba(255, 255, 255, 0.4); - font-size: 12px; - line-height: 18px; - } - - .selected-pill, - .empty-pill { - width: 20px; - height: 20px; - border-radius: 999px; - display: grid; - place-items: center; - } - - .selected-pill { - background: #2b7fff; - } - - .selected-pill img { - width: 12px; - height: 12px; - display: block; - } - - .empty-pill { - border: 1px solid rgba(255, 255, 255, 0.1); - } - - .tip-banner { - margin-top: 16px; - min-height: 45.5px; - padding: 0 12px; - border-radius: 16px; - border: 1px solid rgba(43, 127, 255, 0.1); - background: rgba(43, 127, 255, 0.05); - display: flex; - align-items: center; - gap: 10px; - cursor: pointer; - } - - .tip-banner img { - width: 16px; - height: 16px; - display: block; - flex: 0 0 auto; - } - - .tip-banner p { - margin: 0; - color: rgba(255, 255, 255, 0.5); - font-size: 12px; - line-height: 19.5px; - } - - .tip-banner span { - color: rgba(255, 255, 255, 0.7); - } - .outputs { margin-top: 42px; } @@ -868,52 +528,6 @@ color: rgba(255, 255, 255, 0.6); } - .status-panel { - margin-top: 18px; - padding: 14px 16px; - border-radius: 16px; - border: 1px solid rgba(255, 255, 255, 0.04); - background: rgba(255, 255, 255, 0.02); - } - - .status-panel p { - margin: 0; - color: rgba(255, 255, 255, 0.55); - font-size: 13px; - line-height: 19.5px; - } - - .req-list { - margin: 0; - padding-left: 18px; - color: rgba(255, 255, 255, 0.65); - font-size: 13px; - line-height: 19.5px; - } - - .req-list li + li { - margin-top: 4px; - } - - .help-links { - display: flex; - flex-wrap: wrap; - gap: 12px; - margin-top: 10px; - } - - .help-links a { - color: #51a2ff; - text-decoration: none; - font-size: 12px; - line-height: 18px; - } - - .error-panel { - border-color: rgba(248, 113, 113, 0.2); - background: rgba(127, 29, 29, 0.16); - } - .primary { width: 100%; height: 49px; @@ -964,10 +578,6 @@ } @media (max-width: 640px) { - .appbar-inner { - padding-inline: 14px; - } - .frame { width: calc(100% - 28px); } diff --git a/deploy/src/routes/server-ssh/+page.svelte b/deploy/src/routes/server-ssh/+page.svelte index 1d04200..d9486d2 100644 --- a/deploy/src/routes/server-ssh/+page.svelte +++ b/deploy/src/routes/server-ssh/+page.svelte @@ -30,7 +30,6 @@ let useSameForSudo = true; let sudoPassword = ""; - let enableAutoUpdater = true; let overwriteInstall = false; let serviceAccountKeyPath = ""; let userCredentialsQrPath = ""; @@ -39,6 +38,7 @@ let accessMode: AccessMode = "direct"; let directPublicAddress = ""; let directListenPort = 8000; + let allowUfwRule = false; let proxyPublicUrl = ""; let proxyListenPort = 18000; @@ -52,7 +52,6 @@ key2User: string; githubToken: string; manifestVersionOverride: string; - showDockerHelp: boolean; maskUserPathsWithDemo: boolean; }; @@ -68,7 +67,6 @@ key2User: "", githubToken: "", manifestVersionOverride: "", - showDockerHelp: false, maskUserPathsWithDemo: false }; let devSettings: DevSettings | null = null; @@ -240,13 +238,15 @@ return { exposureMode: "proxy", bindAddress: "127.0.0.1", - listenPort: proxyListenPort + listenPort: proxyListenPort, + allowUfwRule: false }; } return { exposureMode: "direct", bindAddress: "0.0.0.0", - listenPort: directListenPort + listenPort: directListenPort, + allowUfwRule }; } @@ -383,7 +383,6 @@ : []; const plan: ServerPlan = { - autoUpdater: { enable: enableAutoUpdater }, runtime: buildRuntimePlan(), secrets: { serviceAccountKeyPath, @@ -719,6 +718,13 @@ {/if} + {#if effectiveAccessMode() === "direct"} + + {/if} + {#if buildCredentialsServerUrl()} - -