From 78e037f5a7872ea79382b39137ad8a6247ff213e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augusto=20C=C3=A9sar?= Date: Wed, 18 Jun 2025 09:01:17 +0200 Subject: [PATCH 1/3] Release 3.4.0 (#242) Changelog: - #241 - #244 - #245 - #246 - #243 - #248 --------- Co-authored-by: Oliver Stenbom --- Cargo.lock | 2 +- linkup-cli/Cargo.toml | 2 +- linkup-cli/src/commands/health.rs | 22 +- linkup-cli/src/commands/start.rs | 11 +- linkup-cli/src/commands/status.rs | 105 +++-- linkup-cli/src/commands/update.rs | 18 +- linkup-cli/src/local_config.rs | 39 +- linkup-cli/src/release.rs | 584 +++++++++++++----------- linkup-cli/src/services/local_server.rs | 5 +- linkup/src/versioning.rs | 6 +- local-server/src/lib.rs | 31 +- local-server/src/ws.rs | 10 +- 12 files changed, 474 insertions(+), 361 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e1af5c75..aae1882d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1725,7 +1725,7 @@ dependencies = [ [[package]] name = "linkup-cli" -version = "3.3.0" +version = "3.4.0" dependencies = [ "anyhow", "base64", diff --git a/linkup-cli/Cargo.toml b/linkup-cli/Cargo.toml index e8a423c8..e16a6948 100644 --- a/linkup-cli/Cargo.toml +++ b/linkup-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "linkup-cli" -version = "3.3.0" +version = "3.4.0" edition = "2021" build = "build.rs" diff --git a/linkup-cli/src/commands/health.rs b/linkup-cli/src/commands/health.rs index bf26e95b..6a625d11 100644 --- a/linkup-cli/src/commands/health.rs +++ b/linkup-cli/src/commands/health.rs @@ -62,7 +62,7 @@ struct Session { } impl Session { - fn load(state: &Option) -> Self { + fn load(state: Option<&LocalState>) -> Self { match state { Some(state) => Self { name: Some(state.linkup.session_name.clone()), @@ -87,23 +87,23 @@ struct OrphanProcess { } #[derive(Debug, Serialize)] -struct BackgroudServices { - linkup_server: BackgroundServiceHealth, +pub struct BackgroundServices { + pub linkup_server: BackgroundServiceHealth, cloudflared: BackgroundServiceHealth, dns_server: BackgroundServiceHealth, possible_orphan_processes: Vec, } #[derive(Debug, Serialize)] -enum BackgroundServiceHealth { +pub enum BackgroundServiceHealth { Unknown, NotInstalled, Stopped, Running(u32), } -impl BackgroudServices { - fn load(state: &Option) -> Self { +impl BackgroundServices { + pub fn load(state: Option<&LocalState>) -> Self { let mut managed_pids: Vec = Vec::with_capacity(4); let linkup_server = match find_service_pid(services::LocalServer::ID) { @@ -283,7 +283,7 @@ struct LocalDNS { } impl LocalDNS { - fn load(state: &Option) -> Result { + fn load(state: Option<&LocalState>) -> Result { // If there is no state, we cannot know if local-dns is installed since we depend on // the domains listed on it. let is_installed = state.as_ref().map(|state| { @@ -302,7 +302,7 @@ struct Health { state_exists: bool, system: System, session: Session, - background_services: BackgroudServices, + background_services: BackgroundServices, linkup: Linkup, local_dns: LocalDNS, } @@ -310,15 +310,15 @@ struct Health { impl Health { pub fn load() -> Result { let state = LocalState::load().ok(); - let session = Session::load(&state); + let session = Session::load(state.as_ref()); Ok(Self { state_exists: state.is_some(), system: System::load(), session, - background_services: BackgroudServices::load(&state), + background_services: BackgroundServices::load(state.as_ref()), linkup: Linkup::load()?, - local_dns: LocalDNS::load(&state)?, + local_dns: LocalDNS::load(state.as_ref())?, }) } } diff --git a/linkup-cli/src/commands/start.rs b/linkup-cli/src/commands/start.rs index 9c33e38f..6ba8347e 100644 --- a/linkup-cli/src/commands/start.rs +++ b/linkup-cli/src/commands/start.rs @@ -34,7 +34,7 @@ pub struct Args { pub async fn start(args: &Args, fresh_state: bool, config_arg: &Option) -> Result<()> { let mut state = if fresh_state { - let state = load_and_save_state(config_arg, args.no_tunnel, true)?; + let state = load_and_save_state(config_arg, args.no_tunnel)?; set_linkup_env(state.clone())?; state @@ -227,17 +227,12 @@ fn set_linkup_env(state: LocalState) -> Result<()> { Ok(()) } -// TODO: Remove this `is_paid` arg -fn load_and_save_state( - config_arg: &Option, - no_tunnel: bool, - is_paid: bool, -) -> Result { +fn load_and_save_state(config_arg: &Option, no_tunnel: bool) -> Result { let previous_state = LocalState::load(); let config_path = config_path(config_arg)?; let input_config = get_config(&config_path)?; - let mut state = config_to_state(input_config.clone(), config_path, no_tunnel, is_paid); + let mut state = config_to_state(input_config.clone(), config_path, no_tunnel); // Reuse previous session name if possible if let Ok(ps) = previous_state { diff --git a/linkup-cli/src/commands/status.rs b/linkup-cli/src/commands/status.rs index 733f0ed0..7b22a5ac 100644 --- a/linkup-cli/src/commands/status.rs +++ b/linkup-cli/src/commands/status.rs @@ -12,8 +12,9 @@ use std::{ }; use crate::{ - local_config::{LocalService, LocalState, ServiceTarget}, - services, Result, + commands, + local_config::{HealthConfig, LocalService, LocalState, ServiceTarget}, + services, }; const LOADING_CHARS: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; @@ -51,8 +52,9 @@ pub fn status(args: &Args) -> anyhow::Result<()> { } let state = LocalState::load().context("Failed to load local state")?; + let linkup_services = linkup_services(&state); - let all_services = state.services.into_iter().chain(linkup_services); + let all_services = state.clone().services.into_iter().chain(linkup_services); let (services_statuses, status_receiver) = prepare_services_statuses(&state.linkup.session_name, all_services); @@ -89,6 +91,11 @@ pub fn status(args: &Args) -> anyhow::Result<()> { status.session.print(); println!(); + match commands::health::BackgroundServices::load(Some(&state)).linkup_server { + commands::health::BackgroundServiceHealth::Running(_) => (), + _ => println!("{}", "Linkup is not currently running.\n".yellow()), + } + let mut stdout = stdout(); execute!(stdout, cursor::Hide, terminal::DisableLineWrap)?; @@ -180,7 +187,6 @@ struct ServiceStatus { name: String, status: ServerStatus, component_kind: String, - location: String, service: LocalService, priority: i8, } @@ -196,7 +202,7 @@ impl ServiceStatus { let mut status_name = ColoredString::from(self.name.clone()); let mut status_component_kind = ColoredString::from(self.component_kind.clone()); - let mut status_location = ColoredString::from(self.location.clone()); + let mut status_location = ColoredString::from(self.service.current_url().to_string()); if status_component_kind.deref() == "local" { status_name = status_name.bright_magenta(); @@ -242,16 +248,6 @@ impl ServerStatus { } } -impl From> for ServerStatus { - fn from(res: Result) -> Self { - match res { - Ok(res) if res.status().is_server_error() => ServerStatus::Error, - Ok(_) => ServerStatus::Ok, - Err(_) => ServerStatus::Timeout, - } - } -} - fn table_header(terminal_width: u16) -> String { let terminal_width = terminal_width as usize; @@ -302,6 +298,10 @@ fn linkup_services(state: &LocalState) -> Vec { current: ServiceTarget::Local, directory: None, rewrites: vec![], + health: Some(HealthConfig { + path: Some("/linkup/check".to_string()), + ..Default::default() + }), }, LocalService { name: "linkup_remote_server".to_string(), @@ -310,6 +310,10 @@ fn linkup_services(state: &LocalState) -> Vec { current: ServiceTarget::Remote, directory: None, rewrites: vec![], + health: Some(HealthConfig { + path: Some("/linkup/check".to_string()), + ..Default::default() + }), }, LocalService { name: "tunnel".to_string(), @@ -318,15 +322,27 @@ fn linkup_services(state: &LocalState) -> Vec { current: ServiceTarget::Remote, directory: None, rewrites: vec![], + health: Some(HealthConfig { + path: Some("/linkup/check".to_string()), + ..Default::default() + }), }, ] } fn service_status(service: &LocalService, session_name: &str) -> ServerStatus { - let url = match service.current { - ServiceTarget::Local => service.local.clone(), - ServiceTarget::Remote => service.remote.clone(), - }; + let mut acceptable_statuses_override: Option> = None; + let mut url = service.current_url(); + + if let Some(health_config) = &service.health { + if let Some(path) = &health_config.path { + url = url.join(path).unwrap(); + } + + if let Some(statuses) = &health_config.statuses { + acceptable_statuses_override = Some(statuses.clone()); + } + } let headers = get_additional_headers( url.as_ref(), @@ -338,23 +354,58 @@ fn service_status(service: &LocalService, session_name: &str) -> ServerStatus { }, ); - server_status(url.to_string(), Some(headers)) + server_status( + url.as_str(), + acceptable_statuses_override.as_ref(), + Some(headers), + ) } -pub fn server_status(url: String, extra_headers: Option) -> ServerStatus { +pub fn server_status( + url: &str, + acceptable_statuses_override: Option<&Vec>, + extra_headers: Option, +) -> ServerStatus { let client = reqwest::blocking::Client::builder() .timeout(Duration::from_secs(2)) .build(); match client { Ok(client) => { - let mut request = client.get(url); + let mut req = client.get(url); if let Some(extra_headers) = extra_headers { - request = request.headers(extra_headers.into()); + req = req.headers(extra_headers.into()); } - request.send().into() + match req.send() { + Ok(res) => { + log::debug!( + "'{}' responded with status: {}. Acceptable statuses: {:?}", + url, + res.status().as_u16(), + acceptable_statuses_override + ); + + match (acceptable_statuses_override, res.status()) { + (None, status) => { + if !status.is_server_error() { + ServerStatus::Ok + } else { + ServerStatus::Error + } + } + (Some(override_statuses), status) => { + if override_statuses.contains(&status.as_u16()) { + ServerStatus::Ok + } else { + ServerStatus::Error + } + } + } + } + Err(_) => ServerStatus::Error, + } } Err(_) => ServerStatus::Error, } @@ -370,16 +421,10 @@ where let services_statuses: Vec = services .clone() .map(|service| { - let url = match service.current { - ServiceTarget::Local => service.local.clone(), - ServiceTarget::Remote => service.remote.clone(), - }; - let priority = service_priority(&service); ServiceStatus { name: service.name.clone(), - location: url.to_string(), component_kind: service.current.to_string(), status: ServerStatus::Loading, service, diff --git a/linkup-cli/src/commands/update.rs b/linkup-cli/src/commands/update.rs index c9d0abf3..47d1234e 100644 --- a/linkup-cli/src/commands/update.rs +++ b/linkup-cli/src/commands/update.rs @@ -1,6 +1,8 @@ -use crate::{current_version, linkup_exe_path, release, InstallationMethod, Result}; +use anyhow::Context; use std::fs; +use crate::{commands, current_version, linkup_exe_path, release, InstallationMethod, Result}; + #[derive(clap::Args)] pub struct Args { /// Ignore the cached last version and check remote server again for the latest version. @@ -33,13 +35,15 @@ pub async fn update(args: &Args) -> Result<()> { if args.skip_cache { log::debug!("Clearing cache to force a new check for the latest version."); - release::clear_cache(); + release::CachedReleases::clear(); } let requested_channel = args.channel.as_ref().map(linkup::VersionChannel::from); - match release::available_update(¤t_version, requested_channel).await { + match release::check_for_update(¤t_version, requested_channel).await { Some(update) => { + commands::stop(&commands::StopArgs {}, false)?; + println!( "Updating from version '{}' ({}) to '{}' ({})...", ¤t_version, @@ -48,7 +52,11 @@ pub async fn update(args: &Args) -> Result<()> { &update.version.channel() ); - let new_linkup_path = update.linkup.download_decompressed("linkup").await.unwrap(); + let new_linkup_path = update + .binary + .download() + .await + .with_context(|| "Failed to download new version")?; let current_linkup_path = linkup_exe_path()?; let bkp_linkup_path = current_linkup_path.with_extension("bkp"); @@ -85,7 +93,7 @@ pub async fn update(args: &Args) -> Result<()> { } pub async fn new_version_available() -> bool { - release::available_update(¤t_version(), None) + release::check_for_update(¤t_version(), None) .await .is_some() } diff --git a/linkup-cli/src/local_config.rs b/linkup-cli/src/local_config.rs index 40d1399a..67bfe955 100644 --- a/linkup-cli/src/local_config.rs +++ b/linkup-cli/src/local_config.rs @@ -87,10 +87,15 @@ pub struct LinkupState { pub worker_token: String, pub config_path: String, pub tunnel: Option, - pub is_paid: Option, pub cache_routes: Option>, } +#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Default)] +pub struct HealthConfig { + pub path: Option, + pub statuses: Option>, +} + #[derive(Deserialize, Serialize, Clone, Debug, PartialEq)] pub struct LocalService { pub name: String, @@ -99,6 +104,16 @@ pub struct LocalService { pub current: ServiceTarget, pub directory: Option, pub rewrites: Vec, + pub health: Option, +} + +impl LocalService { + pub fn current_url(&self) -> Url { + match self.current { + ServiceTarget::Local => self.local.clone(), + ServiceTarget::Remote => self.remote.clone(), + } + } } #[derive(Debug, PartialEq, Deserialize, Serialize, Clone)] @@ -168,6 +183,7 @@ pub struct YamlLocalService { local: Url, directory: Option, rewrites: Option>, + health: Option, } #[derive(Debug)] @@ -180,7 +196,6 @@ pub fn config_to_state( yaml_config: YamlLocalConfig, config_path: String, no_tunnel: bool, - is_paid: bool, ) -> LocalState { let random_token: String = rand::thread_rng() .sample_iter(&Alphanumeric) @@ -194,7 +209,6 @@ pub fn config_to_state( }; let linkup = LinkupState { - is_paid: Some(is_paid), session_name: String::new(), session_token: random_token, worker_token: yaml_config.linkup.worker_token, @@ -214,6 +228,7 @@ pub fn config_to_state( current: ServiceTarget::Remote, directory: yaml_service.directory, rewrites: yaml_service.rewrites.unwrap_or_default(), + health: yaml_service.health, }) .collect::>(); @@ -426,6 +441,9 @@ services: remote: http://remote-service2.example.com local: http://localhost:8001 directory: ../backend + health: + path: /health + statuses: [200, 304] domains: - domain: example.com default_service: frontend @@ -440,12 +458,7 @@ domains: fn test_config_to_state() { let input_str = String::from(CONF_STR); let yaml_config = serde_yaml::from_str(&input_str).unwrap(); - let local_state = config_to_state( - yaml_config, - "./path/to/config.yaml".to_string(), - false, - false, - ); + let local_state = config_to_state(yaml_config, "./path/to/config.yaml".to_string(), false); assert_eq!(local_state.linkup.config_path, "./path/to/config.yaml"); @@ -469,6 +482,7 @@ domains: Url::parse("http://localhost:8000").unwrap() ); assert_eq!(local_state.services[0].current, ServiceTarget::Remote); + assert_eq!(local_state.services[0].health, None); assert_eq!(local_state.services[0].rewrites.len(), 1); assert_eq!(local_state.services[1].name, "backend"); @@ -485,6 +499,13 @@ domains: local_state.services[1].directory, Some("../backend".to_string()) ); + assert_eq!( + local_state.services[1].health, + Some(HealthConfig { + path: Some("/health".to_string()), + statuses: Some(vec![200, 304]), + }) + ); assert_eq!(local_state.domains.len(), 2); assert_eq!(local_state.domains[0].domain, "example.com"); diff --git a/linkup-cli/src/release.rs b/linkup-cli/src/release.rs index 7dd5910c..b150837c 100644 --- a/linkup-cli/src/release.rs +++ b/linkup-cli/src/release.rs @@ -1,67 +1,100 @@ -use std::{ - env, fs, - path::PathBuf, - time::{self, Duration}, -}; - -use flate2::read::GzDecoder; -use linkup::VersionChannel; -use reqwest::header::HeaderValue; -use serde::{Deserialize, Serialize}; -use tar::Archive; -use url::Url; - -use crate::{linkup_file_path, Version}; - -const CACHED_LATEST_STABLE_RELEASE_FILE: &str = "latest_release_stable.json"; -const CACHED_LATEST_BETA_RELEASE_FILE: &str = "latest_release_beta.json"; - -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("ReqwestError: {0}")] - Reqwest(#[from] reqwest::Error), - #[error("IoError: {0}")] - Io(#[from] std::io::Error), - #[error("File missing from downloaded compressed archive")] - MissingBinary, -} +mod github { + use std::{env, fs, path::PathBuf, time::Duration}; + + use flate2::read::GzDecoder; + use linkup::VersionError; + use reqwest::header::HeaderValue; + use serde::{de::DeserializeOwned, Deserialize, Serialize}; + use tar::Archive; + use url::Url; + + #[derive(Debug, thiserror::Error)] + pub enum Error { + #[error("ReqwestError: {0}")] + Reqwest(#[from] reqwest::Error), + #[error("IoError: {0}")] + Io(#[from] std::io::Error), + #[error("File missing from downloaded compressed archive")] + MissingBinary, + #[error("Release have an invalid tag")] + InvalidVersionTag(#[from] VersionError), + #[error("Hit a rate limit while checking for updates")] + RateLimit(u64), + } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Asset { - name: String, - #[serde(rename = "browser_download_url")] - download_url: String, -} + #[derive(Debug, Serialize, Deserialize)] + pub struct Release { + #[serde(rename = "name")] + pub version: String, + pub assets: Vec, + } -impl Asset { - pub async fn download(&self) -> Result { - let response = reqwest::get(&self.download_url).await?; + impl Release { + /// Examples of Linkup asset files: + /// - linkup-1.7.1-x86_64-apple-darwin.tar.gz + /// - linkup-1.7.1-aarch64-apple-darwin.tar.gz + /// - linkup-1.7.1-x86_64-unknown-linux-gnu.tar.gz + /// - linkup-1.7.1-aarch64-unknown-linux-gnu.tar.gz + pub fn linkup_asset(&self, os: &str, arch: &str) -> Option { + let lookup_os = match os { + "macos" => "apple-darwin", + "linux" => "unknown-linux", + _ => return None, + }; - let file_path = env::temp_dir().join(&self.name); - let mut file = fs::File::create(&file_path)?; + let asset = self + .assets + .iter() + .find(|asset| asset.name.contains(lookup_os) && asset.name.contains(arch)) + .cloned(); + + if asset.is_none() { + log::debug!( + "Linkup release for OS '{}' and ARCH '{}' not found on version {}", + lookup_os, + arch, + &self.version + ); + } - let mut content = std::io::Cursor::new(response.bytes().await?); - std::io::copy(&mut content, &mut file)?; + asset + } + } - Ok(file_path) + #[derive(Debug, Clone, Serialize, Deserialize)] + pub struct Asset { + name: String, + #[serde(rename = "browser_download_url")] + download_url: String, } - pub async fn download_decompressed(&self, lookup_name: &str) -> Result { - let file_path = self.download().await?; - let file = fs::File::open(&file_path)?; + impl Asset { + async fn inner_download(&self) -> Result { + let response = reqwest::get(&self.download_url).await?; + + let file_path = env::temp_dir().join(&self.name); + let mut file = fs::File::create(&file_path)?; + + let mut content = std::io::Cursor::new(response.bytes().await?); + std::io::copy(&mut content, &mut file)?; + + Ok(file_path) + } + + pub async fn download(&self) -> Result { + let filename = "linkup"; + let file_path = self.inner_download().await?; + let file = fs::File::open(&file_path)?; - let decoder = GzDecoder::new(file); - let mut archive = Archive::new(decoder); + let decoder = GzDecoder::new(file); + let mut archive = Archive::new(decoder); - let new_exe_path = - archive - .entries()? - .filter_map(|e| e.ok()) - .find_map(|mut entry| -> Option { + let new_exe_path = archive.entries()?.filter_map(|e| e.ok()).find_map( + |mut entry| -> Option { let entry_path = entry.path().unwrap(); - if entry_path.to_str().unwrap().contains(lookup_name) { - let path = env::temp_dir().join(lookup_name); + if entry_path.to_str().unwrap().contains(filename) { + let path = env::temp_dir().join(filename); entry.unpack(&path).unwrap(); @@ -69,288 +102,283 @@ impl Asset { } else { None } - }); + }, + ); - match new_exe_path { - Some(new_exe_path) => Ok(new_exe_path), - None => Err(Error::MissingBinary), + match new_exe_path { + Some(new_exe_path) => Ok(new_exe_path), + None => Err(Error::MissingBinary), + } } } -} -#[derive(Debug, Serialize, Deserialize)] -pub struct Release { - #[serde(rename = "name")] - version: String, - assets: Vec, -} + pub(super) async fn fetch_stable_release() -> Result, Error> { + let url: Url = "https://api.github.com/repos/mentimeter/linkup/releases/latest" + .parse() + .expect("GitHub URL to be correct"); -impl Release { - /// Examples of Linkup asset files: - /// - linkup-1.7.1-x86_64-apple-darwin.tar.gz - /// - linkup-1.7.1-aarch64-apple-darwin.tar.gz - /// - linkup-1.7.1-x86_64-unknown-linux-gnu.tar.gz - /// - linkup-1.7.1-aarch64-unknown-linux-gnu.tar.gz - pub fn linkup_asset(&self, os: &str, arch: &str) -> Option { - let lookup_os = match os { - "macos" => "apple-darwin", - "linux" => "unknown-linux", - _ => return None, - }; + let release = fetch(url).await?; - let asset = self - .assets - .iter() - .find(|asset| asset.name.contains(lookup_os) && asset.name.contains(arch)) - .cloned(); - - if asset.is_none() { - log::debug!( - "Linkup release for OS '{}' and ARCH '{}' not found on version {}", - lookup_os, - arch, - &self.version - ); + Ok(Some(release)) + } + + pub(super) async fn fetch_beta_release() -> Result, Error> { + let url: Url = "https://api.github.com/repos/mentimeter/linkup/releases" + .parse() + .expect("GitHub URL to be correct"); + + let releases: Vec = fetch(url).await?; + + let beta_release = releases + .into_iter() + .find(|release| release.version.starts_with("0.0.0-next-")); + + Ok(beta_release) + } + + async fn fetch(url: Url) -> Result + where + T: DeserializeOwned, + { + let mut req = reqwest::Request::new(reqwest::Method::GET, url); + let headers = req.headers_mut(); + headers.append("User-Agent", HeaderValue::from_static("linkup-cli")); + headers.append( + "Accept", + HeaderValue::from_static("application/vnd.github+json"), + ); + headers.append( + "X-GitHub-Api-Version", + HeaderValue::from_static("2022-11-28"), + ); + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .unwrap(); + + let response = client.execute(req).await?; + + if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS { + // https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28#checking-the-status-of-your-rate-limit + let retry_at = response + .headers() + .get("x-ratelimit-reset") + .and_then(|value| value.to_str().ok()) + .and_then(|s| s.parse::().ok()) + .unwrap_or_else(super::next_morning_utc_seconds); + + return Err(Error::RateLimit(retry_at)); } - asset + Ok(response.json::().await?) } } -#[derive(Serialize, Deserialize)] -struct CachedLatestRelease { - time: u64, - release: Release, -} +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use crate::linkup_file_path; +use github::Asset; +use linkup::{Version, VersionChannel}; -pub struct Update { +const CACHE_FILE_NAME: &str = "releases_cache.json"; + +#[derive(Clone, Serialize, Deserialize)] +pub struct Release { + pub channel: VersionChannel, pub version: Version, - pub linkup: Asset, + pub binary: Asset, } -pub async fn available_update( - current_version: &Version, - desired_channel: Option, -) -> Option { - let os = env::consts::OS; - let arch = env::consts::ARCH; +impl Release { + fn from_github_release(gh_release: &github::Release, os: &str, arch: &str) -> Option { + let version = Version::try_from(gh_release.version.as_str()); + let asset = gh_release.linkup_asset(os, arch); + + match (version, asset) { + (Ok(version), Some(asset)) => Some(Release { + channel: version.channel(), + version, + binary: asset, + }), + _ => None, + } + } +} - let channel = desired_channel.unwrap_or_else(|| current_version.channel()); - log::debug!("Looking for available update on '{channel}' channel."); +#[derive(Serialize, Deserialize)] +pub struct CachedReleases { + fetched_at: u64, + next_fetch_at: u64, + releases: Vec, +} - let latest_release = match cached_latest_release(&channel).await { - Some(cached_latest_release) => { - let release = cached_latest_release.release; +impl CachedReleases { + fn empty_with_retry(retry_at: u64) -> Self { + Self { + fetched_at: now(), + next_fetch_at: retry_at, + releases: Vec::default(), + } + } - log::debug!("Found cached release: {}", release.version); + fn cache_path() -> PathBuf { + linkup_file_path(CACHE_FILE_NAME) + } - release + /// Always return the cache only if is "fresh". If the cache is expired, this will delete the + /// cache file and return None. + fn load() -> Option { + let path = linkup_file_path(CACHE_FILE_NAME); + if !path.exists() { + return None; } - None => { - log::debug!("No cached release found. Fetching from remote..."); - let release = match channel { - linkup::VersionChannel::Stable => fetch_stable_release().await, - linkup::VersionChannel::Beta => fetch_beta_release().await, - }; + let file = match std::fs::File::open(&path) { + Ok(file) => file, + Err(error) => { + log::debug!("failed to open cached latest release file: {}", error); - let release = match release { - Ok(Some(release)) => { - log::debug!("Found release {} on channel '{channel}'.", release.version); + return None; + } + }; - release - } - Ok(None) => { - log::debug!("No release found on remote for channel '{channel}'"); + let cache: Self = match serde_json::from_reader(file) { + Ok(cache) => cache, + Err(error) => { + log::debug!("failed to parse cached latest release: {}", error); - return None; + if std::fs::remove_file(&path).is_err() { + log::debug!("failed to delete latest release cache file"); } - Err(error) => { - log::error!("Failed to fetch the latest release: {}", error); - return None; - } - }; - - let cache_file = match channel { - VersionChannel::Stable => CACHED_LATEST_STABLE_RELEASE_FILE, - VersionChannel::Beta => CACHED_LATEST_BETA_RELEASE_FILE, - }; + return None; + } + }; - match fs::File::create(linkup_file_path(cache_file)) { - Ok(new_file) => { - let release_cache = CachedLatestRelease { - time: now(), - release, - }; + if now() > cache.next_fetch_at { + Self::clear(); - if let Err(error) = serde_json::to_writer_pretty(new_file, &release_cache) { - log::error!("Failed to write the release data into cache: {}", error); - } + return None; + } - release_cache.release - } - Err(error) => { - log::error!("Failed to create release cache file: {}", error); + Some(cache) + } - release + fn save(&self) { + match std::fs::File::create(linkup_file_path(CACHE_FILE_NAME)) { + Ok(new_file) => { + if let Err(error) = serde_json::to_writer_pretty(new_file, self) { + log::debug!("failed to write the release data into cache: {}", error); } } + Err(error) => { + log::debug!("Failed to create release cache file: {}", error); + } } - }; - - let latest_version = match Version::try_from(latest_release.version.as_str()) { - Ok(version) => version, - Err(error) => { - log::error!( - "Failed to parse latest version '{}': {}", - latest_release.version, - error - ); + } - return None; + pub fn clear() { + let path = Self::cache_path(); + if !path.exists() { + return; } - }; - // Only check the version if the channel is the same. - if current_version.channel() == latest_version.channel() && current_version >= &latest_version { - log::debug!("Current version ({current_version}) is newer than latest ({latest_version})."); + if let Err(error) = std::fs::remove_file(&path) { + log::debug!("failed to delete cached latest release file: {}", error); + } + } - return None; + fn get_release(&self, channel: VersionChannel) -> Option<&Release> { + self.releases + .iter() + .find(|update| update.channel == channel) } +} - let linkup = latest_release - .linkup_asset(os, arch) - .expect("Linkup asset to be present on a release"); +async fn fetch_releases(os: &str, arch: &str) -> Result, github::Error> { + // TODO: Could we maybe do a single request to GH to list the releases and do the filtering + // locally? + let mut releases = Vec::::with_capacity(2); - Some(Update { - version: latest_version, - linkup, - }) -} + if let Some(stable) = github::fetch_stable_release() + .await? + .and_then(|gh_release| Release::from_github_release(&gh_release, os, arch)) + { + releases.push(stable); + } -async fn fetch_stable_release() -> Result, reqwest::Error> { - let url: Url = "https://api.github.com/repos/mentimeter/linkup/releases/latest" - .parse() - .unwrap(); - - let mut req = reqwest::Request::new(reqwest::Method::GET, url); - let headers = req.headers_mut(); - headers.append("User-Agent", HeaderValue::from_str("linkup-cli").unwrap()); - headers.append( - "Accept", - HeaderValue::from_str("application/vnd.github+json").unwrap(), - ); - headers.append( - "X-GitHub-Api-Version", - HeaderValue::from_str("2022-11-28").unwrap(), - ); - - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(1)) - .build() - .unwrap(); - - let release = client.execute(req).await?.json().await?; - - Ok(Some(release)) -} + if let Some(beta) = github::fetch_beta_release() + .await? + .and_then(|gh_release| Release::from_github_release(&gh_release, os, arch)) + { + releases.push(beta); + } -pub async fn fetch_beta_release() -> Result, reqwest::Error> { - let url: Url = "https://api.github.com/repos/mentimeter/linkup/releases" - .parse() - .unwrap(); - - let mut req = reqwest::Request::new(reqwest::Method::GET, url); - let headers = req.headers_mut(); - headers.append("User-Agent", HeaderValue::from_str("linkup-cli").unwrap()); - headers.append( - "Accept", - HeaderValue::from_str("application/vnd.github+json").unwrap(), - ); - headers.append( - "X-GitHub-Api-Version", - HeaderValue::from_str("2022-11-28").unwrap(), - ); - - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(1)) - .build() - .unwrap(); - - let releases: Vec = client.execute(req).await?.json().await?; - - let beta_release = releases - .into_iter() - .find(|release| release.version.starts_with("0.0.0-next-")); - - Ok(beta_release) + Ok(releases) } -async fn cached_latest_release(channel: &VersionChannel) -> Option { - let file = match channel { - VersionChannel::Stable => CACHED_LATEST_STABLE_RELEASE_FILE, - VersionChannel::Beta => CACHED_LATEST_STABLE_RELEASE_FILE, - }; +pub async fn check_for_update( + current_version: &Version, + channel: Option, +) -> Option { + let channel = channel.unwrap_or_else(|| current_version.channel()); + log::debug!("Looking for available update on '{channel}' channel."); - let path = linkup_file_path(file); - if !path.exists() { - return None; - } + let cached_releases = CachedReleases::load(); + let release = match cached_releases { + Some(cached_releases) => cached_releases.get_release(channel).cloned(), + None => { + let os = std::env::consts::OS; + let arch = std::env::consts::ARCH; + + let new_cache = match fetch_releases(os, arch).await { + Ok(releases) => { + let cache = CachedReleases { + fetched_at: now(), + next_fetch_at: next_morning_utc_seconds(), + releases, + }; - let file = match fs::File::open(&path) { - Ok(file) => file, - Err(error) => { - log::error!("Failed to open cached latest release file: {}", error); + cache.save(); - return None; - } - }; + cache + } + Err(error) => { + let cache = match error { + github::Error::RateLimit(retry_at) => { + CachedReleases::empty_with_retry(retry_at) + } + _ => CachedReleases::empty_with_retry(next_morning_utc_seconds()), + }; - let cached_latest_release: CachedLatestRelease = match serde_json::from_reader(file) { - Ok(cached_latest_release) => cached_latest_release, - Err(error) => { - log::error!("Failed to parse cached latest release: {}", error); + cache.save(); - if fs::remove_file(&path).is_err() { - log::error!("Failed to delete latest release cache file"); - } + cache + } + }; - return None; + new_cache.get_release(channel).cloned() } }; - let cache_time = Duration::from_secs(cached_latest_release.time); - let time_now = Duration::from_secs(now()); - - if time_now - cache_time > Duration::from_secs(60 * 60 * 24) { - if let Err(error) = fs::remove_file(&path) { - log::error!("Failed to delete cached latest release file: {}", error); - } - - return None; - } - - Some(cached_latest_release) + release.filter(|release| &release.version > current_version) } -pub fn clear_cache() { - for path in [ - linkup_file_path(CACHED_LATEST_STABLE_RELEASE_FILE), - linkup_file_path(CACHED_LATEST_BETA_RELEASE_FILE), - ] { - if path.exists() { - if let Err(error) = fs::remove_file(&path) { - log::error!("Failed to delete release cache file {path:?}: {error}"); - } - } - } +fn now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("time went backwards") + .as_secs() } -fn now() -> u64 { - let start = time::SystemTime::now(); +fn next_morning_utc_seconds() -> u64 { + let seconds_in_day = 60 * 60 * 24; + let now_in_seconds = now(); - let since_the_epoch = start.duration_since(time::UNIX_EPOCH).unwrap(); + let seconds_since_midnight = now_in_seconds % seconds_in_day; - since_the_epoch.as_secs() + now_in_seconds + (seconds_in_day - seconds_since_midnight) } diff --git a/linkup-cli/src/services/local_server.rs b/linkup-cli/src/services/local_server.rs index 495aadc3..7bbeb420 100644 --- a/linkup-cli/src/services/local_server.rs +++ b/linkup-cli/src/services/local_server.rs @@ -59,7 +59,10 @@ impl LocalServer { let mut command = process::Command::new( env::current_exe().context("Failed to get the current executable")?, ); - command.env("RUST_LOG", "debug"); + command.env( + "RUST_LOG", + "info,hickory_server=warn,hyper_util=warn,h2=warn,tower_http=info", + ); command.env("LINKUP_SERVICE_ID", Self::ID); command.args([ "server", diff --git a/linkup/src/versioning.rs b/linkup/src/versioning.rs index 2e383af6..39179ef2 100644 --- a/linkup/src/versioning.rs +++ b/linkup/src/versioning.rs @@ -1,12 +1,14 @@ use std::fmt::Display; +use serde::{Deserialize, Serialize}; + #[derive(thiserror::Error, Debug)] pub enum VersionError { #[error("Failed to parse version '{0}'")] Parsing(String), } -#[derive(Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum VersionChannel { Stable, Beta, @@ -21,7 +23,7 @@ impl Display for VersionChannel { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Version { pub major: u16, pub minor: u16, diff --git a/local-server/src/lib.rs b/local-server/src/lib.rs index d3dee5aa..5eed9100 100644 --- a/local-server/src/lib.rs +++ b/local-server/src/lib.rs @@ -23,7 +23,7 @@ use hickory_server::{ }, ServerFuture, }; -use http::{header::HeaderMap, Uri}; +use http::{header::HeaderMap, HeaderValue, Uri}; use hyper_rustls::HttpsConnector; use hyper_util::{ client::legacy::{connect::HttpConnector, Client}, @@ -237,13 +237,25 @@ async fn linkup_request_handler( } let uri = url.parse::().unwrap(); + let host = uri.host().unwrap().to_string(); let mut upstream_request = uri.into_client_request().unwrap(); + // Copy over all headers from the incoming request + for (key, value) in req.headers() { + upstream_request.headers_mut().insert(key, value.clone()); + } + + // add the extra headers that linkup wants let extra_http_headers: HeaderMap = extra_headers.into(); for (key, value) in extra_http_headers.iter() { upstream_request.headers_mut().insert(key, value.clone()); } + // Overriding host header neccesary for tokio_tungstenite + upstream_request + .headers_mut() + .insert(http::header::HOST, HeaderValue::from_str(&host).unwrap()); + let (upstream_ws_stream, upstream_response) = match tokio_tungstenite::connect_async(upstream_request).await { Ok(connection) => connection, @@ -258,25 +270,24 @@ async fn linkup_request_handler( return Response::builder() .status(StatusCode::BAD_GATEWAY) .body(Body::from(error.to_string())) - .unwrap() + .unwrap(); } }, }; - let mut upstream_upgrade_response = + let mut downstream_upgrade_response = downstream_upgrade.on_upgrade(ws::context_handle_socket(upstream_ws_stream)); - let websocket_upgrade_response_headers = upstream_upgrade_response.headers_mut(); + let downstream_response_headers = downstream_upgrade_response.headers_mut(); + + // The headers from the upstream response are more important - trust the upstream server for upstream_header in upstream_response.headers() { - if !websocket_upgrade_response_headers.contains_key(upstream_header.0) { - websocket_upgrade_response_headers - .append(upstream_header.0, upstream_header.1.clone()); - } + downstream_response_headers.insert(upstream_header.0, upstream_header.1.clone()); } - websocket_upgrade_response_headers.extend(allow_all_cors()); + downstream_response_headers.extend(allow_all_cors()); - upstream_upgrade_response + downstream_upgrade_response } None => handle_http_req(req, target_service, extra_headers, client).await, } diff --git a/local-server/src/ws.rs b/local-server/src/ws.rs index fe770ffe..da66b6b9 100644 --- a/local-server/src/ws.rs +++ b/local-server/src/ws.rs @@ -101,14 +101,14 @@ pub fn context_handle_socket( } _ => { if let Err(e) = upstream_write.send(tungstenite_message).await { - eprintln!("Error sending message to upstream: {}", e); + println!("Error sending message to upstream: {}", e); break; } } } } Err(e) => { - eprint!("Got error on reading message from downstream: {}", e); + println!("Got error on reading message from downstream: {}", e); break; } }, @@ -128,14 +128,14 @@ pub fn context_handle_socket( } _ => { if let Err(e) = downstream_write.send(axum_message).await { - eprintln!("Error sending message to upstream: {}", e); + println!("Error sending message to upstream: {}", e); break; } } } } Err(e) => { - eprint!("Got error on reading message from upstream: {}", e); + println!("Got error on reading message from upstream: {}", e); break; } }, @@ -144,7 +144,7 @@ pub fn context_handle_socket( // this might be better than panicking? Or do we want to "fail loudly" here? // // https://docs.rs/tokio/latest/tokio/macro.select.html#panics - eprint!("Received unexpected message: {other:?}"); + println!("Received unexpected message: {other:?}"); break; } From 59b0d6cb0b524c17aee177d314152df365820c72 Mon Sep 17 00:00:00 2001 From: Oliver Stenbom Date: Fri, 27 Jun 2025 11:05:42 +0200 Subject: [PATCH 2/3] fix: deny content encoding and length headers when proxying websockets (#249) When proxying WebSocket connections over two hops via the Cloudflare worker and the linkup local server, we have found that there are cases where content encoding and content length headers exist in the upstream response from the underlying local server. When those headers exist in a WebSocket handshake, Cloudflare workers completely denies the WebSocket connection. These headers shouldn't make sense in a WebSocket proxying scenario since they are only valid for HTTP response bodies which shouldn't exist when using WebSockets. --- local-server/src/lib.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/local-server/src/lib.rs b/local-server/src/lib.rs index 5eed9100..6849f511 100644 --- a/local-server/src/lib.rs +++ b/local-server/src/lib.rs @@ -23,7 +23,7 @@ use hickory_server::{ }, ServerFuture, }; -use http::{header::HeaderMap, HeaderValue, Uri}; +use http::{header::HeaderMap, HeaderName, HeaderValue, Uri}; use hyper_rustls::HttpsConnector; use hyper_util::{ client::legacy::{connect::HttpConnector, Client}, @@ -49,6 +49,11 @@ mod ws; type HttpsClient = Client, Body>; +const DISALLOWED_HEADERS: [HeaderName; 2] = [ + HeaderName::from_static("content-encoding"), + HeaderName::from_static("content-length"), +]; + #[derive(Debug)] struct ApiError { message: String, @@ -281,8 +286,12 @@ async fn linkup_request_handler( let downstream_response_headers = downstream_upgrade_response.headers_mut(); // The headers from the upstream response are more important - trust the upstream server - for upstream_header in upstream_response.headers() { - downstream_response_headers.insert(upstream_header.0, upstream_header.1.clone()); + for (upstream_key, upstream_value) in upstream_response.headers() { + // Except for content encoding headers, cloudflare does _not_ like them.. + if !DISALLOWED_HEADERS.contains(upstream_key) { + downstream_response_headers + .insert(upstream_key.clone(), upstream_value.clone()); + } } downstream_response_headers.extend(allow_all_cors()); From 6b99f4c499e53fbdb593c5b4cd1ff85d22a9c36c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augusto=20C=C3=A9sar?= Date: Fri, 27 Jun 2025 11:07:06 +0200 Subject: [PATCH 3/3] Release 3.4.1 Changelog: - #249 --- Cargo.lock | 2 +- linkup-cli/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index aae1882d..a8f83bb1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1725,7 +1725,7 @@ dependencies = [ [[package]] name = "linkup-cli" -version = "3.4.0" +version = "3.4.1" dependencies = [ "anyhow", "base64", diff --git a/linkup-cli/Cargo.toml b/linkup-cli/Cargo.toml index e16a6948..968d60a6 100644 --- a/linkup-cli/Cargo.toml +++ b/linkup-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "linkup-cli" -version = "3.4.0" +version = "3.4.1" edition = "2021" build = "build.rs"