diff --git a/client_lib/src/http_client.rs b/client_lib/src/http_client.rs index 8c6e44d..14ef23e 100644 --- a/client_lib/src/http_client.rs +++ b/client_lib/src/http_client.rs @@ -6,12 +6,14 @@ use base64::engine::general_purpose::STANDARD as base64_engine; use base64::{engine::general_purpose, Engine as _}; use reqwest::blocking::{Body, Client, RequestBuilder}; use reqwest::Url; +use reqwest::StatusCode; use serde::{Deserialize, Serialize}; use serde_json::json; use std::fs::File; use std::io::{self, BufRead, BufReader, BufWriter, Write, Read}; use std::path::Path; use std::time::Duration; +use std::env; // Some of these constants are based on the ones in server/main.rs. const MAX_MOTION_FILE_SIZE: u64 = 50 * 1024 * 1024; // 50 mebibytes @@ -159,6 +161,19 @@ impl HttpClient { } } + fn give_hint_to_updater() { + if let Ok(update_hint_path_str) = env::var("UPDATE_HINT_PATH") { + let update_hint_path = Path::new(&update_hint_path_str); + + if !update_hint_path.exists() { + if let Err(e) = File::create(update_hint_path) { + eprintln!("Failed to create file: {}", e); + } + println!("Update hint file created: {}", update_hint_path_str); + } + } + } + /// Atomically confirm pairing with app and receive any phone-side notification target metadata. pub fn send_pairing_token(&self, pairing_token: &str) -> io::Result { let url = format!("{}/pair", self.server_addr); @@ -180,6 +195,10 @@ impl HttpClient { .send() .map_err(|e| io::Error::new(io::ErrorKind::TimedOut, e.to_string()))?; + if response.status() == StatusCode::CONFLICT { + Self::give_hint_to_updater(); + } + if !response.status().is_success() { return Err(io::Error::new( io::ErrorKind::Other, @@ -209,7 +228,11 @@ impl HttpClient { .send() .map_err(|e| io::Error::new(io::ErrorKind::TimedOut, e.to_string()))?; - if response.status() == reqwest::StatusCode::NOT_FOUND { + if response.status() == StatusCode::CONFLICT { + Self::give_hint_to_updater(); + } + + if response.status() == StatusCode::NOT_FOUND { return Ok(None); } @@ -277,6 +300,10 @@ impl HttpClient { .send() .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?; + if response.status() == StatusCode::CONFLICT { + Self::give_hint_to_updater(); + } + if !response.status().is_success() { let status = response.status(); let content_type = response @@ -358,6 +385,10 @@ impl HttpClient { .send() .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?; + if response.status() == StatusCode::CONFLICT { + Self::give_hint_to_updater(); + } + if !response.status().is_success() { return Err(io::Error::new( io::ErrorKind::Other, @@ -402,10 +433,12 @@ impl HttpClient { let response = self.authorized_headers(client .get(&server_url)) .send() - .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))? - .error_for_status() .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?; + if response.status() == StatusCode::CONFLICT { + Self::give_hint_to_updater(); + } + if !response.status().is_success() { return Err(io::Error::new( io::ErrorKind::Other, @@ -431,10 +464,12 @@ impl HttpClient { let del_response = self.authorized_headers(client .delete(&server_url)) .send() - .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))? - .error_for_status() .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?; + if del_response.status() == StatusCode::CONFLICT { + Self::give_hint_to_updater(); + } + if !del_response.status().is_success() { return Err(io::Error::new( io::ErrorKind::Other, @@ -452,10 +487,12 @@ impl HttpClient { let response = self.authorized_headers(client .delete(&server_url)) .send() - .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))? - .error_for_status() .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?; + if response.status() == StatusCode::CONFLICT { + Self::give_hint_to_updater(); + } + if !response.status().is_success() { return Err(io::Error::new( io::ErrorKind::Other, @@ -477,6 +514,10 @@ impl HttpClient { .send() .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?; + if response.status() == StatusCode::CONFLICT { + Self::give_hint_to_updater(); + } + if !response.status().is_success() { return Err(io::Error::new( io::ErrorKind::Other, @@ -498,6 +539,10 @@ impl HttpClient { .send() .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?; + if response.status() == StatusCode::CONFLICT { + Self::give_hint_to_updater(); + } + if !response.status().is_success() { return Err(io::Error::new( io::ErrorKind::Other, @@ -522,10 +567,19 @@ impl HttpClient { let response = self.authorized_headers(client .get(&server_url)) .send() - .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))? - .error_for_status() .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?; + if response.status() == StatusCode::CONFLICT { + Self::give_hint_to_updater(); + } + + if !response.status().is_success() { + return Err(io::Error::new( + io::ErrorKind::Other, + format!("Server error: {}", response.status()), + )); + } + let mut buf = Vec::new(); let mut limited = response.take(max_size); limited.read_to_end(&mut buf)?; @@ -576,6 +630,10 @@ impl HttpClient { .send() .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?; + if response.status() == StatusCode::CONFLICT { + Self::give_hint_to_updater(); + } + if !response.status().is_success() { return Err(io::Error::new( io::ErrorKind::Other, @@ -615,10 +673,12 @@ impl HttpClient { let response = self.authorized_headers(client .get(&server_url)) .send() - .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))? - .error_for_status() .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?; + if response.status() == StatusCode::CONFLICT { + Self::give_hint_to_updater(); + } + if !response.status().is_success() { return Err(io::Error::new( io::ErrorKind::Other, @@ -638,10 +698,12 @@ impl HttpClient { let del_response = self.authorized_headers(client .delete(&server_del_url)) .send() - .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))? - .error_for_status() .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?; + if del_response.status() == StatusCode::CONFLICT { + Self::give_hint_to_updater(); + } + if !del_response.status().is_success() { return Err(io::Error::new( io::ErrorKind::Other, @@ -664,6 +726,10 @@ impl HttpClient { .send() .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?; + if response.status() == StatusCode::CONFLICT { + Self::give_hint_to_updater(); + } + if !response.status().is_success() { return Err(io::Error::new( io::ErrorKind::Other, @@ -686,6 +752,10 @@ impl HttpClient { .send() .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?; + if response.status() == StatusCode::CONFLICT { + Self::give_hint_to_updater(); + } + if !response.status().is_success() { return Err(io::Error::new( io::ErrorKind::Other, @@ -712,10 +782,19 @@ impl HttpClient { let response = self.authorized_headers(client .get(&server_url)) .send() - .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))? - .error_for_status() .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?; + if response.status() == StatusCode::CONFLICT { + Self::give_hint_to_updater(); + } + + if !response.status().is_success() { + return Err(io::Error::new( + io::ErrorKind::Other, + format!("Server error: {}", response.status()), + )); + } + let mut buf = Vec::new(); let mut limited = response.take(max_size); limited.read_to_end(&mut buf)?; @@ -757,6 +836,10 @@ impl HttpClient { .send() .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?; + if response.status() == StatusCode::CONFLICT { + Self::give_hint_to_updater(); + } + if !response.status().is_success() { return Err(io::Error::new( io::ErrorKind::Other, @@ -784,10 +867,12 @@ impl HttpClient { let response = self.authorized_headers(client .get(&server_url)) .send() - .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))? - .error_for_status() .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?; + if response.status() == StatusCode::CONFLICT { + Self::give_hint_to_updater(); + } + if !response.status().is_success() { return Err(io::Error::new( io::ErrorKind::Other, diff --git a/deploy/src-tauri/assets/pi_hub/build_image.sh b/deploy/src-tauri/assets/pi_hub/build_image.sh index debe699..e08ce60 100755 --- a/deploy/src-tauri/assets/pi_hub/build_image.sh +++ b/deploy/src-tauri/assets/pi_hub/build_image.sh @@ -385,6 +385,7 @@ ExecStartPre=/usr/bin/test -r /var/lib/secluso/wifi_password ExecStart=${SECLUSO_INSTALL_DIR}/bin/secluso-raspberry-camera-hub Environment="RUST_LOG=info" Environment="LD_LIBRARY_PATH=/usr/local/lib/aarch64-linux-gnu/:/usr/local/lib:${LD_LIBRARY_PATH:-}" +Environment="UPDATE_HINT_PATH=/var/lib/secluso/update_hint" Restart=always RestartSec=1 @@ -414,6 +415,8 @@ EOF if [[ -x "$ROOT${SECLUSO_INSTALL_DIR}/bin/$updater_name" ]]; then UPDATE_INTERVAL_SECS="1800" + HINT_CHECK_INTERVAL_SECS="60" + UPDATE_HINT_PATH="/var/lib/secluso/update_hint" cat > "$ROOT/etc/systemd/system/secluso-updater.service" < [u8; NUM_PASSWORD_CHARS] { out } +fn give_hint_to_updater() { + if let Ok(update_hint_path_str) = env::var("UPDATE_HINT_PATH") { + let update_hint_path = Path::new(&update_hint_path_str); + + if !update_hint_path.exists() { + if let Err(e) = File::create(update_hint_path) { + eprintln!("Failed to create file: {}", e); + } + println!("Update hint file created: {}", update_hint_path_str); + } + } +} + + #[rocket::async_trait] impl<'r> FromRequest<'r> for &'r BasicAuth { type Error = (); @@ -128,9 +144,13 @@ impl<'r> FromRequest<'r> for &'r BasicAuth { // We run a check on client-provided version header to ensure a match. // If it fails, we return a 409 (Conflict). // It must be provided for all, or we cannot guarantee version-compatibility. + // When there's a version mismatch, we also give a hint to the updater so + // that it can check the Github latest release. The version mismatch might + // be because the server is running an old version. if let Some(client_version) = client_header { let version = env!("CARGO_PKG_VERSION"); if client_version != version { + give_hint_to_updater(); return Outcome::Error((Status::Conflict, ())); } } else { diff --git a/update/src/lib.rs b/update/src/lib.rs index 75677be..1661049 100644 --- a/update/src/lib.rs +++ b/update/src/lib.rs @@ -116,16 +116,6 @@ struct Artifact { sha256: String, } -#[derive(Debug, Deserialize)] -struct StoredUserCredentials { - #[serde(rename = "u", alias = "username")] - username: String, - #[serde(rename = "p", alias = "password")] - password: String, - #[serde(rename = "sa", alias = "server_addr")] - server_addr: String, -} - #[derive(Debug, Clone)] pub struct VerifiedComponent { pub release_tag: String, @@ -136,63 +126,6 @@ pub struct VerifiedComponent { pub bundle_bytes: Vec, } -pub fn server_version(client_version: &str) -> Result> { - let file_path = format!("{}/{}", WORKING_DIRECTORY, "credentials_full"); - server_version_from_path(Path::new(&file_path), client_version) -} - -fn parse_credentials_full(contents: &str) -> Result> { - if contents.trim().is_empty() { - return Ok(None); - } - - if let Ok(parsed) = serde_json::from_str::(contents) { - return Ok(Some((parsed.username, parsed.password, parsed.server_addr))); - } - - if contents.len() <= NUM_USERNAME_CHARS + NUM_PASSWORD_CHARS { - return Ok(None); - } - - Ok(Some(( - contents[0..NUM_USERNAME_CHARS].to_string(), - contents[NUM_USERNAME_CHARS..NUM_USERNAME_CHARS + NUM_PASSWORD_CHARS].to_string(), - contents[NUM_USERNAME_CHARS + NUM_PASSWORD_CHARS..].to_string(), - ))) -} - -fn server_version_from_path(file_path: &Path, client_version: &str) -> Result> { - let credentials_exist = fs::exists(file_path)?; - if !credentials_exist { - return Ok(None); - } - - let user_credentials_contents = fs::read_to_string(file_path)?; - let Some((server_username, server_password, server_addr)) = - parse_credentials_full(&user_credentials_contents)? - else { - return Ok(None); - }; - - let response = reqwest::blocking::Client::new() - .get(format!("{}/status", server_addr.trim_end_matches('/'))) - .header("Client-Version", client_version) - .basic_auth(server_username, Some(server_password)) - .send()?; - - if !(response.status().is_success() || response.status() == reqwest::StatusCode::CONFLICT) { - return Ok(None); - } - - if let Some(server_version) = response.headers().get("X-Server-Version") { - if let Ok(version_str) = server_version.to_str() { - return Ok(Some(version_str.to_string())); - } - } - - Ok(None) -} - impl Component { pub fn parse(s: &str) -> Result { match s { @@ -335,22 +268,6 @@ pub fn build_github_client( .context("building GitHub HTTP client") } -// Fetches a specific release from GitHub's API endpoint for the target repo. -// Callers are expected to apply additional policy checks (draft/published/immutable) before trusting -// the returned release for installation decisions. -pub fn fetch_versioned_release( - client: &Client, - owner_repo: &str, - tag_name: &str, -) -> Result { - let url = format!( - "https://api.github.com/repos/{}/releases/tags/{}", - owner_repo, tag_name - ); - let resp = client.get(&url).send()?.error_for_status()?; - Ok(resp.json::()?) -} - // Fetches the latest release metadata from GitHub's API endpoint for the target repo. // Callers are expected to apply additional policy checks (draft/published/immutable) before trusting // the returned release for installation decisions. diff --git a/update/src/main.rs b/update/src/main.rs index c83b1d5..7247a5a 100644 --- a/update/src/main.rs +++ b/update/src/main.rs @@ -10,12 +10,12 @@ use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::thread::sleep; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, SystemTime, UNIX_EPOCH, Instant}; use secluso_update::{ build_github_client, default_signers, download_and_verify_component, fetch_latest_release, - fetch_versioned_release, get_current_version, github_token_from_env, parse_sig_keys, - require_release_is_immutable, server_version, write_current_version, Component, + get_current_version, github_token_from_env, parse_sig_keys, + require_release_is_immutable, write_current_version, Component, DEFAULT_OWNER_REPO, }; @@ -23,23 +23,25 @@ const USAGE: &str = r#" Secluso updater. Usage: - secluso-update --component COMPONENT [--once] [--bundle-path PATH] [--interval-secs N] [--github-timeout-secs N] [--restart-unit UNIT] [--github-repo ] [--sig-key ]... + secluso-update --component COMPONENT [--once] [--bundle-path PATH] [--interval-secs N] [--github-timeout-secs N] [--restart-unit UNIT] [--github-repo ] [--sig-key ]... [--update-hint-path PATH] [--hint-check-interval-secs N] secluso-update (--help | -h) secluso-update (--version | -v) Options: - --component COMPONENT Which single binary to update: - server | updater | raspberry_camera_hub | config_tool - --restart-unit UNIT systemd unit to restart after install (optional). - If omitted, no service is restarted. - --interval-secs N Poll interval seconds [default: 60]. - --github-timeout-secs N HTTP timeout seconds [default: 20]. - --github-repo GitHub repo to poll for releases [default: secluso/secluso]. + --component COMPONENT Which single binary to update: + server | updater | raspberry_camera_hub | config_tool + --restart-unit UNIT systemd unit to restart after install (optional). + If omitted, no service is restarted. + --interval-secs N Poll interval seconds [default: 60]. + --github-timeout-secs N HTTP timeout seconds [default: 20]. + --github-repo GitHub repo to poll for releases [default: secluso/secluso]. --sig-key Signature label + GitHub user (repeatable). - --once Run a single update check then exit. - --bundle-path PATH Use a local bundle zip instead of downloading from GitHub. - --version, -v Show tool version. - --help, -h Show this screen. + --once Run a single update check then exit. + --bundle-path PATH Use a local bundle zip instead of downloading from GitHub. + --update-hint-path PATH Path for the local update hint file (optional). + --hint-check-interval-secs N Update hint poll interval seconds [default: 10]. + --version, -v Show tool version. + --help, -h Show this screen. "#; #[derive(Debug, Deserialize)] @@ -52,12 +54,13 @@ struct Args { flag_sig_key: Vec, flag_once: bool, flag_bundle_path: Option, + flag_update_hint_path: Option, + flag_hint_check_interval_secs: u64, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ReleaseSource { LatestImmutableGitHub, - ServerCoordinated, } #[derive(Debug, Clone)] @@ -128,12 +131,59 @@ fn main() -> ! { std::process::exit(0); } - loop { - println!("Going to check for updates."); - if let Err(e) = check_update(&args) { - eprintln!("Update check failed: {:#}", e); + if let Some(ref update_hint_path) = args.flag_update_hint_path { + if args.flag_interval_secs % args.flag_hint_check_interval_secs != 0 { + eprintln!( + "flag_interval_secs ({}) must be divisible by flag_update_hint_interval_secs ({})", + args.flag_interval_secs, + args.flag_hint_check_interval_secs + ); + std::process::exit(1); + } + + // We want to force a check in the beginning. + let mut last_full_check = Instant::now() - Duration::from_secs(args.flag_interval_secs); + + loop { + let elapsed = last_full_check.elapsed(); + if elapsed >= Duration::from_secs(args.flag_interval_secs) { + println!("Scheduled update check."); + if let Err(e) = check_update(&args) { + eprintln!("Update check failed: {:#}", e); + } + last_full_check = Instant::now(); + continue; + } + + sleep(Duration::from_secs(args.flag_hint_check_interval_secs)); + + if is_there_update_hint(&update_hint_path) { + println!("Update hint received, triggering early check."); + if let Err(e) = check_update(&args) { + eprintln!("Update check failed: {:#}", e); + } + last_full_check = Instant::now(); + } + } + } else { + loop { + println!("Going to check for updates."); + if let Err(e) = check_update(&args) { + eprintln!("Update check failed: {:#}", e); + } + sleep(Duration::from_secs(args.flag_interval_secs)); } - sleep(Duration::from_secs(args.flag_interval_secs)); + } +} + +pub fn is_there_update_hint(path: &str) -> bool { + let p = Path::new(path); + + if p.exists() { + let _ = fs::remove_file(p); + true + } else { + false } } @@ -171,8 +221,6 @@ fn check_update(args: &Args) -> Result<()> { ¤t_version, || fetch_latest_release(&client, &github_repo), require_release_is_immutable, - |client_version| server_version(client_version), - |version| fetch_versioned_release(&client, &github_repo, version), )? else { return Ok(()); @@ -182,9 +230,6 @@ fn check_update(args: &Args) -> Result<()> { ReleaseSource::LatestImmutableGitHub => { println!("Using the latest immutable GitHub release."); } - ReleaseSource::ServerCoordinated => { - println!("Using the server-coordinated release."); - } }; let release = selected_release.release; @@ -340,22 +385,18 @@ fn create_secure_install_temp_file( )) } -fn select_release_for_component( +fn select_release_for_component( component: Component, current_version: &Version, fetch_latest_release_fn: FLatest, require_release_is_immutable_fn: FRequireImmutable, - server_version_fn: FServerVersion, - fetch_versioned_release_fn: FFetchVersioned, ) -> Result> where FLatest: FnOnce() -> Result, FRequireImmutable: FnOnce(&secluso_update::GhRelease) -> Result<()>, - FServerVersion: FnOnce(&str) -> Result>, - FFetchVersioned: FnOnce(&str) -> Result, { match component { - Component::Server => { + _ => { let release = fetch_latest_release_fn()?; require_release_is_immutable_fn(&release)?; @@ -370,24 +411,6 @@ where source: ReleaseSource::LatestImmutableGitHub, })) } - Component::Updater | Component::RaspberryCameraHub | Component::ConfigTool => { - let Some(server_version) = server_version_fn(¤t_version.to_string())? else { - println!("Server version unavailable; update not needed yet"); - return Ok(None); - }; - - let latest_version = Version::parse(server_version.trim_start_matches('v'))?; - if current_version >= &latest_version { - println!("Already on the server-coordinated release."); - return Ok(None); - } - - let release = fetch_versioned_release_fn(&server_version)?; - Ok(Some(SelectedRelease { - release, - source: ReleaseSource::ServerCoordinated, - })) - } } }