From 146bfd12047cc10b3a512f8d687c31165ee65275 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augusto=20C=C3=A9sar?= Date: Fri, 6 Mar 2026 14:02:44 +0100 Subject: [PATCH 1/8] chore: remove unused domain selection order (#255) --- linkup/src/session.rs | 69 +------------------------------------------ 1 file changed, 1 insertion(+), 68 deletions(-) diff --git a/linkup/src/session.rs b/linkup/src/session.rs index 68122278..5e3fe603 100644 --- a/linkup/src/session.rs +++ b/linkup/src/session.rs @@ -1,7 +1,4 @@ -use std::{ - cmp::Ordering, - collections::{HashMap, HashSet}, -}; +use std::collections::{HashMap, HashSet}; use thiserror::Error; use regex::Regex; @@ -15,7 +12,6 @@ pub struct Session { pub session_token: String, pub services: HashMap, pub domains: HashMap, - pub domain_selection_order: Vec, pub cache_routes: Option>, } @@ -219,8 +215,6 @@ impl TryFrom for Session { domains.insert(stored_domain.domain, domain); } - let domain_names = domains.keys().cloned().collect(); - let cache_routes = match value.cache_routes { Some(cr) => Some( cr.into_iter() @@ -235,7 +229,6 @@ impl TryFrom for Session { session_token: value.session_token, services, domains, - domain_selection_order: choose_domain_ordering(domain_names), cache_routes, }) } @@ -423,30 +416,6 @@ fn validate_url_origin(url: &Url) -> Result<(), ConfigError> { Ok(()) } -fn choose_domain_ordering(domains: Vec) -> Vec { - let mut sorted_domains = domains; - sorted_domains.sort_by(|a, b| { - let a_subdomains: Vec<&str> = a.split('.').collect(); - let b_subdomains: Vec<&str> = b.split('.').collect(); - - let a_len = a_subdomains.len(); - let b_len = b_subdomains.len(); - - if a_len != b_len { - b_len.cmp(&a_len) - } else { - a_subdomains - .iter() - .zip(b_subdomains.iter()) - .map(|(a_sub, b_sub)| b_sub.len().cmp(&a_sub.len())) - .find(|&ord| ord != Ordering::Equal) - .unwrap_or(Ordering::Equal) - } - }); - - sorted_domains -} - pub fn session_to_json(session: Session) -> String { let storable_session: StorableSession = session.into(); @@ -587,40 +556,4 @@ mod tests { "/static/.*" ); } - - #[test] - fn test_choose_domain_ordering() { - let input = vec![ - "example.com".to_string(), - "api.example.com".to_string(), - "render-api.example.com".to_string(), - "another-example.com".to_string(), - ]; - - let expected_output = vec![ - "render-api.example.com".to_string(), - "api.example.com".to_string(), - "another-example.com".to_string(), - "example.com".to_string(), - ]; - - assert_eq!(choose_domain_ordering(input), expected_output); - } - - #[test] - fn test_choose_domain_ordering_with_same_length() { - let input = vec![ - "a.domain.com".to_string(), - "b.domain.com".to_string(), - "c.domain.com".to_string(), - ]; - - let expected_output = vec![ - "a.domain.com".to_string(), - "b.domain.com".to_string(), - "c.domain.com".to_string(), - ]; - - assert_eq!(choose_domain_ordering(input), expected_output); - } } From 8ee3ed583bb48b38173cae292c4b98292f0861a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augusto=20C=C3=A9sar?= Date: Wed, 11 Mar 2026 10:00:25 +0100 Subject: [PATCH 2/8] refactor: simplify core session structure (#256) ### Description The main focus on this PR is to reduce the duplication of structs on `linkup/src/session.rs` and the cascade effects of doing so. --- linkup-cli/src/commands/status.rs | 4 +- linkup-cli/src/local_config.rs | 59 ++-- linkup/src/lib.rs | 56 ++-- linkup/src/serde_ext.rs | 136 +++++++++ linkup/src/session.rs | 475 ++++++++---------------------- linkup/src/session_allocator.rs | 7 +- server-tests/tests/helpers.rs | 6 +- server-tests/tests/server_test.rs | 6 +- 8 files changed, 336 insertions(+), 413 deletions(-) create mode 100644 linkup/src/serde_ext.rs diff --git a/linkup-cli/src/commands/status.rs b/linkup-cli/src/commands/status.rs index 7b22a5ac..bb672fdf 100644 --- a/linkup-cli/src/commands/status.rs +++ b/linkup-cli/src/commands/status.rs @@ -1,7 +1,7 @@ use anyhow::Context; use colored::{ColoredString, Colorize}; use crossterm::{cursor, execute, style::Print, terminal}; -use linkup::{get_additional_headers, HeaderMap, StorableDomain, TargetService}; +use linkup::{get_additional_headers, Domain, HeaderMap, TargetService}; use serde::{Deserialize, Serialize}; use std::{ io::stdout, @@ -269,7 +269,7 @@ fn table_header(terminal_width: u16) -> String { output } -pub fn format_state_domains(session_name: &str, domains: &[StorableDomain]) -> Vec { +pub fn format_state_domains(session_name: &str, domains: &[Domain]) -> Vec { // Filter out domains that are subdomains of other domains let filtered_domains = domains .iter() diff --git a/linkup-cli/src/local_config.rs b/linkup-cli/src/local_config.rs index 4d37da45..15134f33 100644 --- a/linkup-cli/src/local_config.rs +++ b/linkup-cli/src/local_config.rs @@ -6,12 +6,12 @@ use std::{ use anyhow::Context; use rand::distr::{Alphanumeric, SampleString}; +use regex::Regex; use serde::{Deserialize, Serialize}; use url::Url; use linkup::{ - CreatePreviewRequest, StorableDomain, StorableRewrite, StorableService, StorableSession, - UpdateSessionRequest, + CreatePreviewRequest, Domain, Rewrite, Session, SessionService, UpdateSessionRequest, }; use crate::{ @@ -20,10 +20,10 @@ use crate::{ Result, LINKUP_CONFIG_ENV, LINKUP_STATE_FILE, }; -#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)] +#[derive(Deserialize, Serialize, Clone, Debug)] pub struct LocalState { pub linkup: LinkupState, - pub domains: Vec, + pub domains: Vec, pub services: Vec, } @@ -70,7 +70,7 @@ impl LocalState { pub fn domain_strings(&self) -> Vec { self.domains .iter() - .map(|storable_domain| storable_domain.domain.clone()) + .map(|domain| domain.domain.clone()) .collect::>() } @@ -79,7 +79,7 @@ impl LocalState { } } -#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)] +#[derive(Deserialize, Serialize, Clone, Debug)] pub struct LinkupState { pub session_name: String, pub session_token: String, @@ -87,7 +87,12 @@ pub struct LinkupState { pub worker_token: String, pub config_path: String, pub tunnel: Option, - pub cache_routes: Option>, + #[serde( + default, + serialize_with = "linkup::serde_ext::serialize_opt_vec_regex", + deserialize_with = "linkup::serde_ext::deserialize_opt_vec_regex" + )] + pub cache_routes: Option>, } #[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Default)] @@ -96,14 +101,14 @@ pub struct HealthConfig { pub statuses: Option>, } -#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)] +#[derive(Deserialize, Serialize, Clone, Debug)] pub struct LocalService { pub name: String, pub remote: Url, pub local: Url, pub current: ServiceTarget, pub directory: Option, - pub rewrites: Vec, + pub rewrites: Vec, pub health: Option, } @@ -135,7 +140,7 @@ impl Display for ServiceTarget { pub struct YamlLocalConfig { pub linkup: LinkupConfig, pub services: Vec, - pub domains: Vec, + pub domains: Vec, } impl YamlLocalConfig { @@ -153,7 +158,7 @@ impl YamlLocalConfig { } } - StorableService { + SessionService { name, location, rewrites: yaml_local_service.rewrites.clone(), @@ -173,7 +178,11 @@ impl YamlLocalConfig { pub struct LinkupConfig { pub worker_url: Url, pub worker_token: String, - cache_routes: Option>, + #[serde( + default, + deserialize_with = "linkup::serde_ext::deserialize_opt_vec_regex" + )] + cache_routes: Option>, } #[derive(Deserialize, Clone)] @@ -182,14 +191,14 @@ pub struct YamlLocalService { remote: Url, local: Url, directory: Option, - rewrites: Option>, + rewrites: Option>, health: Option, } #[derive(Debug)] pub struct ServerConfig { - pub local: StorableSession, - pub remote: StorableSession, + pub local: Session, + pub remote: Session, } pub fn config_to_state( @@ -307,7 +316,7 @@ async fn upload_config_to_server( linkup_url: &Url, worker_token: &str, desired_name: &str, - config: StorableSession, + config: Session, ) -> Result { let session_update_req = UpdateSessionRequest { session_token: config.session_token, @@ -329,7 +338,7 @@ impl From<&LocalState> for ServerConfig { let local_server_services = state .services .iter() - .map(|service| StorableService { + .map(|service| SessionService { name: service.name.clone(), location: if service.current == ServiceTarget::Remote { service.remote.clone() @@ -338,12 +347,12 @@ impl From<&LocalState> for ServerConfig { }, rewrites: Some(service.rewrites.clone()), }) - .collect::>(); + .collect::>(); let remote_server_services = state .services .iter() - .map(|service| StorableService { + .map(|service| SessionService { name: service.name.clone(), location: if service.current == ServiceTarget::Remote { service.remote.clone() @@ -352,16 +361,16 @@ impl From<&LocalState> for ServerConfig { }, rewrites: Some(service.rewrites.clone()), }) - .collect::>(); + .collect::>(); - let local_storable_session = StorableSession { + let local_session = Session { session_token: state.linkup.session_token.clone(), services: local_server_services, domains: state.domains.clone(), cache_routes: state.linkup.cache_routes.clone(), }; - let remote_storable_session = StorableSession { + let remote_session = Session { session_token: state.linkup.session_token.clone(), services: remote_server_services, domains: state.domains.clone(), @@ -369,8 +378,8 @@ impl From<&LocalState> for ServerConfig { }; ServerConfig { - local: local_storable_session, - remote: remote_storable_session, + local: local_session, + remote: remote_session, } } } @@ -382,7 +391,7 @@ pub fn managed_domains(state: Option<&LocalState>, cfg_path: &Option) -> config .domains .iter() - .map(|storable_domain| storable_domain.domain.clone()) + .map(|domain| domain.domain.clone()) .collect::>(), ), Err(_) => None, diff --git a/linkup/src/lib.rs b/linkup/src/lib.rs index cc3f3a35..53c7941a 100644 --- a/linkup/src/lib.rs +++ b/linkup/src/lib.rs @@ -1,3 +1,5 @@ +pub mod serde_ext; + mod headers; mod memory_session_store; mod name_gen; @@ -163,8 +165,8 @@ pub fn get_target_service( // If there was a destination created in a previous linkup, we don't want to // re-do path rewrites, so we use the destination service. if let Some(destination_service) = headers.get(HeaderName::LinkupDestination) { - if let Some(service) = config.services.get(destination_service) { - let target = redirect(target.clone(), &service.origin, Some(path.to_string())); + if let Some(service) = config.get_service(destination_service) { + let target = redirect(target.clone(), &service.location, Some(path.to_string())); return Some(TargetService { name: destination_service.to_string(), url: target.to_string(), @@ -172,22 +174,22 @@ pub fn get_target_service( } } - let url_target = config.domains.get(&get_target_domain(url, session_name)); + let url_target = config.get_domain(&get_target_domain(url, session_name)); // Forwarded hosts persist over the tunnel - let forwarded_host_target = config.domains.get(&get_target_domain( + let forwarded_host_target = config.get_domain(&get_target_domain( headers.get_or_default(HeaderName::ForwardedHost, "does-not-exist"), session_name, )); // This is more for e2e tests to work - let referer_target = config.domains.get(&get_target_domain( + let referer_target = config.get_domain(&get_target_domain( headers.get_or_default(HeaderName::Referer, "does-not-exist"), session_name, )); // This one is for redirects, where the referer doesn't exist - let origin_target = config.domains.get(&get_target_domain( + let origin_target = config.get_domain(&get_target_domain( headers.get_or_default(HeaderName::Origin, "does-not-exist"), session_name, )); @@ -203,30 +205,34 @@ pub fn get_target_service( }; if let Some(domain) = target_domain { - let service_name = domain - .routes - .iter() - .find_map(|route| { - if route.path.is_match(path) { - Some(route.service.clone()) - } else { - None - } - }) - .unwrap_or_else(|| domain.default_service.clone()); + let service_name = match &domain.routes { + Some(routes) => routes + .iter() + .find_map(|route| { + if route.path.is_match(path) { + Some(route.service.clone()) + } else { + None + } + }) + .unwrap_or_else(|| domain.default_service.clone()), + None => domain.default_service.clone(), + }; - if let Some(service) = config.services.get(&service_name) { + if let Some(service) = config.get_service(&service_name) { let mut new_path = path.to_string(); - for modifier in &service.rewrites { - if modifier.source.is_match(&new_path) { - new_path = modifier - .source - .replace_all(&new_path, &modifier.target) - .to_string(); + if let Some(rewrites) = &service.rewrites { + for modifier in rewrites { + if modifier.source.is_match(&new_path) { + new_path = modifier + .source + .replace_all(&new_path, &modifier.target) + .to_string(); + } } } - let target = redirect(target, &service.origin, Some(new_path)); + let target = redirect(target, &service.location, Some(new_path)); return Some(TargetService { name: service_name, url: target.to_string(), diff --git a/linkup/src/serde_ext.rs b/linkup/src/serde_ext.rs new file mode 100644 index 00000000..fc1d0700 --- /dev/null +++ b/linkup/src/serde_ext.rs @@ -0,0 +1,136 @@ +use std::str::FromStr; + +use regex::Regex; +use serde::{ser::SerializeSeq, Deserialize, Deserializer, Serializer}; + +pub fn serialize_regex(regex: &Regex, serializer: S) -> Result +where + S: Serializer, +{ + serializer.serialize_str(regex.as_str()) +} + +pub fn deserialize_regex<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + Regex::from_str(&s).map_err(serde::de::Error::custom) +} + +pub fn serialize_opt_vec_regex( + regexes: &Option>, + serializer: S, +) -> Result +where + S: Serializer, +{ + match regexes { + Some(regexes) => { + let mut seq = serializer.serialize_seq(Some(regexes.len()))?; + + for regex in regexes { + seq.serialize_element(regex.as_str())?; + } + + seq.end() + } + None => serializer.serialize_none(), + } +} + +pub fn deserialize_opt_vec_regex<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + let regexes_str: Option> = Option::deserialize(deserializer)?; + let Some(regexes_str) = regexes_str else { + return Ok(None); + }; + + let mut regexes: Vec = Vec::with_capacity(regexes_str.len()); + + for regex_str in regexes_str { + let regex = Regex::from_str(®ex_str).map_err(serde::de::Error::custom)?; + regexes.push(regex); + } + + Ok(Some(regexes)) +} + +#[cfg(test)] +mod tests { + use regex::Regex; + use serde::{Deserialize, Serialize}; + + #[test] + fn test_serialize_deserialize_regex() { + #[derive(Serialize, Deserialize)] + struct A { + #[serde( + deserialize_with = "crate::serde_ext::deserialize_regex", + serialize_with = "crate::serde_ext::serialize_regex" + )] + reg_field: Regex, + } + + let record = A { + reg_field: Regex::new("abc: (.+)").unwrap(), + }; + + let serialized_record = serde_json::to_string(&record).unwrap(); + assert_eq!(r#"{"reg_field":"abc: (.+)"}"#, &serialized_record); + + let des_record: A = serde_json::from_str(&serialized_record).unwrap(); + assert!(des_record.reg_field.is_match("abc: foo")); + + let captures = des_record.reg_field.captures("abc: foo").unwrap(); + assert_eq!("foo", captures.get(1).unwrap().as_str()); + } + + #[test] + fn test_serialize_deserialize_opt_vec_regex() { + #[derive(Serialize, Deserialize)] + struct A { + #[serde( + deserialize_with = "crate::serde_ext::deserialize_opt_vec_regex", + serialize_with = "crate::serde_ext::serialize_opt_vec_regex" + )] + reg_field: Option>, + + #[serde( + deserialize_with = "crate::serde_ext::deserialize_opt_vec_regex", + serialize_with = "crate::serde_ext::serialize_opt_vec_regex" + )] + reg_field2: Option>, + + #[serde( + deserialize_with = "crate::serde_ext::deserialize_opt_vec_regex", + serialize_with = "crate::serde_ext::serialize_opt_vec_regex" + )] + reg_field3: Option>, + } + + let record = A { + reg_field: None, + reg_field2: Some(vec![]), + reg_field3: Some(vec![Regex::new("abc: (.+)").unwrap()]), + }; + + let serialized_record = serde_json::to_string(&record).unwrap(); + assert_eq!( + r#"{"reg_field":null,"reg_field2":[],"reg_field3":["abc: (.+)"]}"#, + &serialized_record + ); + + let des_record: A = serde_json::from_str(&serialized_record).unwrap(); + + assert!(des_record.reg_field.is_none()); + + assert!(des_record.reg_field2.is_some()); + assert!(des_record.reg_field2.unwrap().is_empty()); + + assert!(des_record.reg_field3.is_some()); + assert!(des_record.reg_field3.unwrap()[0].is_match("abc: foo")); + } +} diff --git a/linkup/src/session.rs b/linkup/src/session.rs index 5e3fe603..4ae24c33 100644 --- a/linkup/src/session.rs +++ b/linkup/src/session.rs @@ -1,4 +1,4 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use thiserror::Error; use regex::Regex; @@ -7,88 +7,79 @@ use url::Url; pub const PREVIEW_SESSION_TOKEN: &str = "preview_session"; -#[derive(Clone, Debug)] -pub struct Session { - pub session_token: String, - pub services: HashMap, - pub domains: HashMap, - pub cache_routes: Option>, -} - -#[derive(Clone, Debug)] -pub struct Service { - pub origin: Url, - pub rewrites: Vec, -} - -#[derive(Clone, Debug)] -pub struct Rewrite { - pub source: Regex, - pub target: String, -} - -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Deserialize, Serialize)] pub struct Domain { + pub domain: String, pub default_service: String, - pub routes: Vec, + pub routes: Option>, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Deserialize, Serialize)] pub struct Route { + #[serde( + serialize_with = "crate::serde_ext::serialize_regex", + deserialize_with = "crate::serde_ext::deserialize_regex" + )] pub path: Regex, pub service: String, } -#[derive(Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] pub struct UpdateSessionRequest { pub desired_name: String, pub session_token: String, - pub services: Vec, - pub domains: Vec, - pub cache_routes: Option>, + pub services: Vec, + pub domains: Vec, + #[serde( + default, + serialize_with = "crate::serde_ext::serialize_opt_vec_regex", + deserialize_with = "crate::serde_ext::deserialize_opt_vec_regex" + )] + pub cache_routes: Option>, } -#[derive(Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] pub struct CreatePreviewRequest { - pub services: Vec, - pub domains: Vec, - pub cache_routes: Option>, + pub services: Vec, + pub domains: Vec, + #[serde( + default, + serialize_with = "crate::serde_ext::serialize_opt_vec_regex", + deserialize_with = "crate::serde_ext::deserialize_opt_vec_regex" + )] + pub cache_routes: Option>, } -#[derive(Debug, Deserialize, Serialize)] -pub struct StorableSession { +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Session { pub session_token: String, - pub services: Vec, - pub domains: Vec, - pub cache_routes: Option>, + pub services: Vec, + pub domains: Vec, + #[serde( + default, + serialize_with = "crate::serde_ext::serialize_opt_vec_regex", + deserialize_with = "crate::serde_ext::deserialize_opt_vec_regex" + )] + pub cache_routes: Option>, } #[derive(Clone, Debug, Deserialize, Serialize)] -pub struct StorableService { +pub struct SessionService { pub name: String, pub location: Url, - pub rewrites: Option>, + pub rewrites: Option>, } -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] -pub struct StorableRewrite { - pub source: String, +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Rewrite { + #[serde( + serialize_with = "crate::serde_ext::serialize_regex", + deserialize_with = "crate::serde_ext::deserialize_regex" + )] + pub source: Regex, pub target: String, } -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] -pub struct StorableDomain { - pub domain: String, - pub default_service: String, - pub routes: Option>, -} - -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] -pub struct StorableRoute { - pub path: String, - pub service: String, -} - #[derive(Error, Debug)] pub enum ConfigError { #[error("linkup session json format error: {0}")] @@ -105,132 +96,53 @@ pub enum ConfigError { Empty, } -impl From for StorableSession { - fn from(req: UpdateSessionRequest) -> Self { - StorableSession { - session_token: req.session_token, - services: req.services, - domains: req.domains, - cache_routes: req.cache_routes, - } +impl Session { + pub fn get_service(&self, service_name: &str) -> Option<&SessionService> { + self.services + .iter() + .find(|service| service.name == service_name) + } + + pub fn get_domain(&self, domain: &str) -> Option<&Domain> { + self.domains + .iter() + .find(|domain_record| domain_record.domain == domain) } } impl TryFrom for Session { type Error = ConfigError; - fn try_from(value: UpdateSessionRequest) -> Result { - let storable: StorableSession = value.into(); - storable.try_into() - } -} - -impl From for StorableSession { - fn from(req: CreatePreviewRequest) -> Self { - StorableSession { - session_token: PREVIEW_SESSION_TOKEN.to_string(), + fn try_from(req: UpdateSessionRequest) -> Result { + let session = Self { + session_token: req.session_token, services: req.services, domains: req.domains, cache_routes: req.cache_routes, - } - } -} - -impl TryFrom for Session { - type Error = ConfigError; - - fn try_from(value: CreatePreviewRequest) -> Result { - let storable: StorableSession = value.into(); - storable.try_into() - } -} - -impl TryFrom for Rewrite { - type Error = ConfigError; - - fn try_from(value: StorableRewrite) -> Result { - let source: Result = Regex::new(&value.source); - match source { - Err(e) => Err(ConfigError::InvalidRegex(value.source, e)), - Ok(s) => Ok(Rewrite { - source: s, - target: value.target, - }), - } - } -} + }; -impl TryFrom for Route { - type Error = ConfigError; + validate_not_empty(&session)?; + validate_services(&session)?; - fn try_from(value: StorableRoute) -> Result { - let path = Regex::new(&value.path); - match path { - Err(e) => Err(ConfigError::InvalidRegex(value.path, e)), - Ok(p) => Ok(Route { - path: p, - service: value.service, - }), - } + Ok(session) } } -impl TryFrom for Session { +impl TryFrom for Session { type Error = ConfigError; - fn try_from(value: StorableSession) -> Result { - validate_not_empty(&value)?; - validate_service_references(&value)?; - - let mut services: HashMap = HashMap::new(); - let mut domains: HashMap = HashMap::new(); - - for stored_service in value.services { - validate_url_origin(&stored_service.location)?; - - let rewrites = match stored_service.rewrites { - Some(pm) => pm.into_iter().map(|r| r.try_into()).collect(), - None => Ok(Vec::new()), - }?; - - let service = Service { - origin: stored_service.location, - rewrites, - }; - - services.insert(stored_service.name, service); - } - - for stored_domain in value.domains { - let routes = match stored_domain.routes { - Some(dr) => dr.into_iter().map(|r| r.try_into()).collect(), - None => Ok(Vec::new()), - }?; - - let domain = Domain { - default_service: stored_domain.default_service, - routes, - }; - - domains.insert(stored_domain.domain, domain); - } - - let cache_routes = match value.cache_routes { - Some(cr) => Some( - cr.into_iter() - .map(|r| Regex::new(&r)) - .collect::, regex::Error>>() - .map_err(|e| ConfigError::InvalidRegex("cache route".to_string(), e))?, - ), - None => None, + fn try_from(req: CreatePreviewRequest) -> Result { + let session = Self { + session_token: PREVIEW_SESSION_TOKEN.to_string(), + services: req.services, + domains: req.domains, + cache_routes: req.cache_routes, }; - Ok(Session { - session_token: value.session_token, - services, - domains, - cache_routes, - }) + validate_not_empty(&session)?; + validate_services(&session)?; + + Ok(session) } } @@ -238,135 +150,16 @@ impl TryFrom for Session { type Error = ConfigError; fn try_from(value: serde_json::Value) -> Result { - let session_yml_res: Result = - serde_json::from_value(value); + let session = serde_json::from_value(value)?; - match session_yml_res { - Err(e) => Err(ConfigError::JsonFormat(e)), - Ok(c) => c.try_into(), - } - } -} + validate_not_empty(&session)?; + validate_services(&session)?; -impl From for StorableSession { - fn from(value: Session) -> Self { - let services: Vec = value - .services - .into_iter() - .map(|(name, service)| { - let rewrites = if service.rewrites.is_empty() { - None - } else { - Some( - service - .rewrites - .into_iter() - .map(|path_modifier| StorableRewrite { - source: path_modifier.source.to_string(), - target: path_modifier.target, - }) - .collect(), - ) - }; - - StorableService { - name, - location: service.origin, - rewrites, - } - }) - .collect(); - - let domains: Vec = value - .domains - .into_iter() - .map(|(domain, domain_data)| { - let default_service = domain_data.default_service; - let routes = if domain_data.routes.is_empty() { - None - } else { - Some( - domain_data - .routes - .into_iter() - .map(|route| StorableRoute { - path: route.path.to_string(), - service: route.service, - }) - .collect(), - ) - }; - - StorableDomain { - domain, - default_service, - routes, - } - }) - .collect(); - - let cache_routes = value.cache_routes.map(|cr| { - cr.into_iter() - .map(|r| r.to_string()) - .collect::>() - }); - - StorableSession { - session_token: value.session_token, - services, - domains, - cache_routes, - } - } -} - -pub fn update_session_req_from_json(input_json: String) -> Result<(String, Session), ConfigError> { - let update_session_req_res: Result = - serde_json::from_str(&input_json); - - match update_session_req_res { - Err(e) => Err(ConfigError::JsonFormat(e)), - Ok(c) => { - let server_conf = StorableSession { - session_token: c.session_token, - services: c.services, - domains: c.domains, - cache_routes: c.cache_routes, - } - .try_into(); - - match server_conf { - Err(e) => Err(e), - Ok(sc) => Ok((c.desired_name, sc)), - } - } + Ok(session) } } -pub fn create_preview_req_from_json(input_json: String) -> Result { - let update_session_req_res: Result = - serde_json::from_str(&input_json); - - match update_session_req_res { - Err(e) => Err(ConfigError::JsonFormat(e)), - Ok(c) => { - let server_conf = StorableSession { - session_token: String::from(PREVIEW_SESSION_TOKEN), - services: c.services, - domains: c.domains, - cache_routes: None, - } - .try_into(); - - match server_conf { - Err(e) => Err(e), - Ok(sc) => Ok(sc), - } - } - } -} - -fn validate_not_empty(server_config: &StorableSession) -> Result<(), ConfigError> { +fn validate_not_empty(server_config: &Session) -> Result<(), ConfigError> { if server_config.services.is_empty() { return Err(ConfigError::Empty); } @@ -377,12 +170,14 @@ fn validate_not_empty(server_config: &StorableSession) -> Result<(), ConfigError Ok(()) } -fn validate_service_references(server_config: &StorableSession) -> Result<(), ConfigError> { - let service_names: HashSet<&str> = server_config - .services - .iter() - .map(|s| s.name.as_str()) - .collect(); +fn validate_services(server_config: &Session) -> Result<(), ConfigError> { + let mut service_names: HashSet<&str> = HashSet::new(); + + for service in &server_config.services { + validate_url_origin(&service.location)?; + + service_names.insert(&service.name); + } for domain in &server_config.domains { if !service_names.contains(&domain.default_service.as_str()) { @@ -416,13 +211,6 @@ fn validate_url_origin(url: &Url) -> Result<(), ConfigError> { Ok(()) } -pub fn session_to_json(session: Session) -> String { - let storable_session: StorableSession = session.into(); - - // This should never fail, due to previous validation - serde_json::to_string(&storable_session).unwrap() -} - #[cfg(test)] mod tests { use super::*; @@ -477,7 +265,7 @@ mod tests { check_means_same_as_input_conf(&server_config); // Inverse should mean the same thing - let output_conf = session_to_json(server_config); + let output_conf = serde_json::to_string(&server_config).unwrap(); let output_conf_value = serde_json::from_str::(&output_conf).unwrap(); let second_server_conf: Session = output_conf_value.try_into().unwrap(); check_means_same_as_input_conf(&second_server_conf); @@ -486,69 +274,52 @@ mod tests { fn check_means_same_as_input_conf(server_config: &Session) { // Test services assert_eq!(server_config.services.len(), 2); - assert!(server_config.services.contains_key("frontend")); - assert!(server_config.services.contains_key("backend")); + + let frontend_service = server_config.get_service("frontend").unwrap(); assert_eq!( - server_config.services.get("frontend").unwrap().origin, + frontend_service.location, Url::parse("http://localhost:8000").unwrap() ); + assert_eq!( - server_config.services.get("frontend").unwrap().rewrites[0] - .source - .as_str(), - "/foo/(.*)" - ); - assert_eq!( - server_config.services.get("frontend").unwrap().rewrites[0].target, - "/bar/$1" + Some(1), + frontend_service + .rewrites + .as_ref() + .map(|rewrites| rewrites.len()) ); + + let frontend_service_rewrite = &frontend_service.rewrites.as_ref().unwrap()[0]; + assert_eq!(frontend_service_rewrite.source.as_str(), "/foo/(.*)"); + assert_eq!(frontend_service_rewrite.target, "/bar/$1"); + + let backend_service = server_config.get_service("backend").unwrap(); assert_eq!( - server_config.services.get("backend").unwrap().origin, + backend_service.location, Url::parse("http://localhost:8001").unwrap() ); - assert!(server_config - .services - .get("backend") - .unwrap() - .rewrites - .is_empty()); + assert!(backend_service.rewrites.is_none()); // Test domains - assert_eq!(server_config.domains.len(), 2); - assert!(server_config.domains.contains_key("example.com")); - assert!(server_config.domains.contains_key("api.example.com")); - assert_eq!( - server_config - .domains - .get("example.com") - .unwrap() - .default_service, - "frontend" - ); - assert_eq!( - server_config.domains.get("example.com").unwrap().routes[0] - .path - .as_str(), - "/api/v1/.*" - ); - assert_eq!( - server_config.domains.get("example.com").unwrap().routes[0].service, - "backend" - ); + assert_eq!(2, server_config.domains.len()); + + let example_domain = server_config.get_domain("example.com").unwrap(); + assert_eq!(example_domain.default_service, "frontend"); + assert_eq!( - server_config - .domains - .get("api.example.com") - .unwrap() - .default_service, - "backend" + Some(1), + example_domain.routes.as_ref().map(|routes| routes.len()) ); - assert!(server_config - .domains - .get("api.example.com") - .unwrap() - .routes - .is_empty()); + + let example_domain_route = &example_domain.routes.as_ref().unwrap()[0]; + assert_eq!(example_domain_route.path.as_str(), "/api/v1/.*"); + assert_eq!(example_domain_route.service, "backend"); + + let api_domain = server_config.get_domain("api.example.com").unwrap(); + assert_eq!(api_domain.default_service, "backend"); + assert!(api_domain.routes.is_none()); + + // Test cache routes assert_eq!(server_config.cache_routes.as_ref().unwrap().len(), 1); assert_eq!( diff --git a/linkup/src/session_allocator.rs b/linkup/src/session_allocator.rs index 0507554d..ed7718d4 100644 --- a/linkup/src/session_allocator.rs +++ b/linkup/src/session_allocator.rs @@ -1,7 +1,7 @@ use crate::{ extract_tracestate_session, first_subdomain, headers::HeaderName, - name_gen::deterministic_six_char_hash, random_animal, random_six_char, session_to_json, - ConfigError, HeaderMap, NameKind, Session, SessionError, StringStore, + name_gen::deterministic_six_char_hash, random_animal, random_six_char, ConfigError, HeaderMap, + NameKind, Session, SessionError, StringStore, }; pub struct SessionAllocator<'a, S: StringStore> { @@ -63,7 +63,8 @@ impl<'a, S: StringStore> SessionAllocator<'a, S> { name_kind: NameKind, desired_name: String, ) -> Result { - let config_str = session_to_json(config.clone()); + let config_str = serde_json::to_string(&config) + .map_err(|error| SessionError::ConfigErr(error.to_string()))?; let name = self .choose_name(desired_name, config.session_token, name_kind, &config_str) diff --git a/server-tests/tests/helpers.rs b/server-tests/tests/helpers.rs index b44d40a0..97407731 100644 --- a/server-tests/tests/helpers.rs +++ b/server-tests/tests/helpers.rs @@ -1,6 +1,6 @@ use std::process::Command; -use linkup::{MemoryStringStore, StorableDomain, StorableService, UpdateSessionRequest}; +use linkup::{Domain, MemoryStringStore, SessionService, UpdateSessionRequest}; use linkup_local_server::linkup_router; use reqwest::Url; use tokio::net::TcpListener; @@ -57,12 +57,12 @@ pub fn create_session_request(name: String, fe_location: Option) -> Stri let req = UpdateSessionRequest { desired_name: name, session_token: "token".to_string(), - domains: vec![StorableDomain { + domains: vec![Domain { domain: "example.com".to_string(), default_service: "frontend".to_string(), routes: None, }], - services: vec![StorableService { + services: vec![SessionService { name: "frontend".to_string(), location: Url::parse(&location).unwrap(), rewrites: None, diff --git a/server-tests/tests/server_test.rs b/server-tests/tests/server_test.rs index 3a560aab..2ca38cdc 100644 --- a/server-tests/tests/server_test.rs +++ b/server-tests/tests/server_test.rs @@ -1,5 +1,5 @@ use helpers::ServerKind; -use linkup::{CreatePreviewRequest, StorableDomain, StorableService}; +use linkup::{CreatePreviewRequest, Domain, SessionService}; use reqwest::Url; use rstest::rstest; @@ -85,12 +85,12 @@ pub fn create_preview_request(fe_location: Option) -> String { None => "http://example.com".to_string(), }; let req = CreatePreviewRequest { - domains: vec![StorableDomain { + domains: vec![Domain { domain: "example.com".to_string(), default_service: "frontend".to_string(), routes: None, }], - services: vec![StorableService { + services: vec![SessionService { name: "frontend".to_string(), location: Url::parse(&location).unwrap(), rewrites: None, From cfe99b8b17e0cfddace65c92e5cebce0bf5dd035 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augusto=20C=C3=A9sar?= Date: Wed, 11 Mar 2026 10:12:47 +0100 Subject: [PATCH 3/8] refactor: simplify State and Config entities (#258) --- linkup-cli/src/commands/health.rs | 17 +- linkup-cli/src/commands/local.rs | 8 +- linkup-cli/src/commands/local_dns.rs | 11 +- linkup-cli/src/commands/preview.rs | 7 +- linkup-cli/src/commands/remote.rs | 8 +- linkup-cli/src/commands/reset.rs | 4 +- linkup-cli/src/commands/start.rs | 14 +- linkup-cli/src/commands/status.rs | 89 ++++---- linkup-cli/src/commands/stop.rs | 11 +- linkup-cli/src/commands/uninstall.rs | 9 +- linkup-cli/src/main.rs | 2 +- linkup-cli/src/services/cloudflare_tunnel.rs | 6 +- linkup-cli/src/services/local_dns_server.rs | 4 +- linkup-cli/src/services/local_server.rs | 6 +- linkup-cli/src/services/mod.rs | 4 +- linkup-cli/src/{local_config.rs => state.rs} | 204 ++++++------------- linkup-cli/src/worker_client.rs | 6 +- linkup/src/config.rs | 40 ++++ linkup/src/lib.rs | 1 + linkup/src/session.rs | 79 ++++--- 20 files changed, 269 insertions(+), 261 deletions(-) rename linkup-cli/src/{local_config.rs => state.rs} (67%) create mode 100644 linkup/src/config.rs diff --git a/linkup-cli/src/commands/health.rs b/linkup-cli/src/commands/health.rs index 6a625d11..9d4959a9 100644 --- a/linkup-cli/src/commands/health.rs +++ b/linkup-cli/src/commands/health.rs @@ -10,8 +10,8 @@ use std::{ use crate::{ linkup_dir_path, - local_config::LocalState, services::{self, find_service_pid, BackgroundService}, + state::State, Result, }; @@ -62,7 +62,7 @@ struct Session { } impl Session { - fn load(state: Option<&LocalState>) -> Self { + fn load(state: Option<&State>) -> Self { match state { Some(state) => Self { name: Some(state.linkup.session_name.clone()), @@ -103,7 +103,7 @@ pub enum BackgroundServiceHealth { } impl BackgroundServices { - pub fn load(state: Option<&LocalState>) -> Self { + pub fn load(state: Option<&State>) -> Self { let mut managed_pids: Vec = Vec::with_capacity(4); let linkup_server = match find_service_pid(services::LocalServer::ID) { @@ -138,10 +138,7 @@ impl BackgroundServices { // If there is no state, we cannot know if local-dns is installed since we depend on // the domains listed on it. Some(state) => { - if local_dns::is_installed(&crate::local_config::managed_domains( - Some(state), - &None, - )) { + if local_dns::is_installed(&crate::state::managed_domains(Some(state), &None)) { BackgroundServiceHealth::Stopped } else { BackgroundServiceHealth::NotInstalled @@ -283,11 +280,11 @@ struct LocalDNS { } impl LocalDNS { - fn load(state: Option<&LocalState>) -> Result { + fn load(state: Option<&State>) -> 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| { - local_dns::is_installed(&crate::local_config::managed_domains(Some(state), &None)) + local_dns::is_installed(&crate::state::managed_domains(Some(state), &None)) }); Ok(Self { @@ -309,7 +306,7 @@ struct Health { impl Health { pub fn load() -> Result { - let state = LocalState::load().ok(); + let state = State::load().ok(); let session = Session::load(state.as_ref()); Ok(Self { diff --git a/linkup-cli/src/commands/local.rs b/linkup-cli/src/commands/local.rs index f994f1d6..90f72b44 100644 --- a/linkup-cli/src/commands/local.rs +++ b/linkup-cli/src/commands/local.rs @@ -2,8 +2,8 @@ use anyhow::anyhow; use colored::Colorize; use crate::{ - local_config::{upload_state, LocalState, ServiceTarget}, services::{self, find_service_pid, BackgroundService}, + state::{upload_state, ServiceTarget, State}, Result, }; @@ -25,7 +25,7 @@ pub async fn local(args: &Args) -> Result<()> { return Err(anyhow!("No service names provided")); } - if !LocalState::exists() { + if !State::exists() { println!( "{}", "Seems like you don't have any state yet to point to local.".yellow() @@ -45,7 +45,7 @@ pub async fn local(args: &Args) -> Result<()> { return Ok(()); } - let mut state = LocalState::load()?; + let mut state = State::load()?; if args.all { for service in state.services.iter_mut() { @@ -56,7 +56,7 @@ pub async fn local(args: &Args) -> Result<()> { let service = state .services .iter_mut() - .find(|s| s.name.as_str() == service_name) + .find(|s| s.config.name.as_str() == service_name) .ok_or_else(|| anyhow!("Service with name '{}' does not exist", service_name))?; service.current = ServiceTarget::Local; diff --git a/linkup-cli/src/commands/local_dns.rs b/linkup-cli/src/commands/local_dns.rs index 0a1560aa..f69d76c5 100644 --- a/linkup-cli/src/commands/local_dns.rs +++ b/linkup-cli/src/commands/local_dns.rs @@ -5,7 +5,7 @@ use std::{ use crate::{ commands, is_sudo, linkup_certs_dir_path, - local_config::{self, managed_domains, top_level_domains, LocalState}, + state::{self, managed_domains, top_level_domains, State}, sudo_su, Result, }; use anyhow::{anyhow, Context}; @@ -50,7 +50,7 @@ pub async fn install(config_arg: &Option) -> Result<()> { ensure_resolver_dir()?; - let domains = managed_domains(LocalState::load().ok().as_ref(), config_arg); + let domains = managed_domains(State::load().ok().as_ref(), config_arg); install_resolvers(&top_level_domains(&domains))?; @@ -76,9 +76,10 @@ pub async fn uninstall(config_arg: &Option) -> Result<()> { commands::stop(&commands::StopArgs {}, false)?; - let managed_top_level_domains = local_config::top_level_domains( - &local_config::managed_domains(LocalState::load().ok().as_ref(), config_arg), - ); + let managed_top_level_domains = state::top_level_domains(&state::managed_domains( + State::load().ok().as_ref(), + config_arg, + )); uninstall_resolvers(&managed_top_level_domains)?; uninstall_self_signed_certificates(&linkup_certs_dir_path()) diff --git a/linkup-cli/src/commands/preview.rs b/linkup-cli/src/commands/preview.rs index b26a9d83..384f8ba0 100644 --- a/linkup-cli/src/commands/preview.rs +++ b/linkup-cli/src/commands/preview.rs @@ -1,10 +1,11 @@ use crate::commands::status::{format_state_domains, SessionStatus}; -use crate::local_config::{config_path, get_config}; +use crate::state::{config_path, get_config}; use crate::worker_client::WorkerClient; use crate::Result; use anyhow::Context; use clap::builder::ValueParser; use linkup::CreatePreviewRequest; +use url::Url; #[derive(clap::Args)] pub struct Args { @@ -14,7 +15,7 @@ pub struct Args { required = true, num_args = 1.., )] - services: Vec<(String, String)>, + services: Vec<(String, Url)>, #[arg(long, help = "Print the request body instead of sending it.")] print_request: bool, @@ -24,7 +25,7 @@ pub async fn preview(args: &Args, config: &Option) -> Result<()> { let config_path = config_path(config)?; let input_config = get_config(&config_path)?; let create_preview_request: CreatePreviewRequest = - input_config.create_preview_request(&args.services); + linkup::create_preview_req_from_config(&input_config, &args.services); let url = input_config.linkup.worker_url.clone(); if args.print_request { diff --git a/linkup-cli/src/commands/remote.rs b/linkup-cli/src/commands/remote.rs index 2fd638f6..4b1b9d01 100644 --- a/linkup-cli/src/commands/remote.rs +++ b/linkup-cli/src/commands/remote.rs @@ -1,6 +1,6 @@ use crate::{ - local_config::{upload_state, LocalState, ServiceTarget}, services::{self, find_service_pid, BackgroundService}, + state::{upload_state, ServiceTarget, State}, Result, }; @@ -25,7 +25,7 @@ pub async fn remote(args: &Args) -> Result<()> { return Err(anyhow!("No service names provided")); } - if !LocalState::exists() { + if !State::exists() { println!( "{}", "Seems like you don't have any state yet to point to remote.".yellow() @@ -35,7 +35,7 @@ pub async fn remote(args: &Args) -> Result<()> { return Ok(()); } - let mut state = LocalState::load()?; + let mut state = State::load()?; if find_service_pid(services::LocalServer::ID).is_none() { println!( @@ -56,7 +56,7 @@ pub async fn remote(args: &Args) -> Result<()> { let service = state .services .iter_mut() - .find(|s| s.name.as_str() == service_name) + .find(|s| s.config.name.as_str() == service_name) .ok_or_else(|| anyhow!("Service with name '{}' does not exist", service_name))?; service.current = ServiceTarget::Remote; diff --git a/linkup-cli/src/commands/reset.rs b/linkup-cli/src/commands/reset.rs index b700d9cf..4f67e20f 100644 --- a/linkup-cli/src/commands/reset.rs +++ b/linkup-cli/src/commands/reset.rs @@ -1,10 +1,10 @@ -use crate::{commands, local_config::LocalState, Result}; +use crate::{commands, state::State, Result}; #[derive(clap::Args)] pub struct Args {} pub async fn reset(_args: &Args) -> Result<()> { - let _ = LocalState::load()?; + let _ = State::load()?; commands::stop(&commands::StopArgs {}, false)?; commands::start(&commands::StartArgs { no_tunnel: false }, false, &None).await?; diff --git a/linkup-cli/src/commands/start.rs b/linkup-cli/src/commands/start.rs index 6ba8347e..624f2bbb 100644 --- a/linkup-cli/src/commands/start.rs +++ b/linkup-cli/src/commands/start.rs @@ -15,10 +15,10 @@ use crossterm::{cursor, ExecutableCommand}; use crate::{ commands::status::{format_state_domains, SessionStatus}, env_files::write_to_env_file, - local_config::{config_path, config_to_state, get_config}, services::{self, BackgroundService}, + state::{config_path, config_to_state, get_config}, }; -use crate::{local_config::LocalState, Result}; +use crate::{state::State, Result}; const LOADING_CHARS: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; @@ -39,7 +39,7 @@ pub async fn start(args: &Args, fresh_state: bool, config_arg: &Option) state } else { - LocalState::load()? + State::load()? }; let status_update_channel = sync::mpsc::channel::(); @@ -217,18 +217,18 @@ fn spawn_display_thread( }) } -fn set_linkup_env(state: LocalState) -> Result<()> { +fn set_linkup_env(state: State) -> Result<()> { // Set env vars to linkup for service in &state.services { - if let Some(d) = &service.directory { + if let Some(d) = &service.config.directory { set_service_env(d.clone(), state.linkup.config_path.clone())? } } Ok(()) } -fn load_and_save_state(config_arg: &Option, no_tunnel: bool) -> Result { - let previous_state = LocalState::load(); +fn load_and_save_state(config_arg: &Option, no_tunnel: bool) -> Result { + let previous_state = State::load(); let config_path = config_path(config_arg)?; let input_config = get_config(&config_path)?; diff --git a/linkup-cli/src/commands/status.rs b/linkup-cli/src/commands/status.rs index bb672fdf..b89658a3 100644 --- a/linkup-cli/src/commands/status.rs +++ b/linkup-cli/src/commands/status.rs @@ -1,7 +1,7 @@ use anyhow::Context; use colored::{ColoredString, Colorize}; use crossterm::{cursor, execute, style::Print, terminal}; -use linkup::{get_additional_headers, Domain, HeaderMap, TargetService}; +use linkup::{config::HealthConfig, get_additional_headers, Domain, HeaderMap, TargetService}; use serde::{Deserialize, Serialize}; use std::{ io::stdout, @@ -12,9 +12,8 @@ use std::{ }; use crate::{ - commands, - local_config::{HealthConfig, LocalService, LocalState, ServiceTarget}, - services, + commands, services, + state::{LocalService, ServiceTarget, State}, }; const LOADING_CHARS: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; @@ -41,7 +40,7 @@ pub fn status(args: &Args) -> anyhow::Result<()> { println!("{}", warning.yellow()); } - if !LocalState::exists() { + if !State::exists() { println!( "{}", "Seems like you don't have any state yet, so there is no status to report.".yellow() @@ -51,7 +50,7 @@ pub fn status(args: &Args) -> anyhow::Result<()> { return Ok(()); } - let state = LocalState::load().context("Failed to load local state")?; + let state = State::load().context("Failed to load local state")?; let linkup_services = linkup_services(&state); let all_services = state.clone().services.into_iter().chain(linkup_services); @@ -287,45 +286,51 @@ pub fn format_state_domains(session_name: &str, domains: &[Domain]) -> Vec Vec { +fn linkup_services(state: &State) -> Vec { let local_url = services::LocalServer::url(); vec![ LocalService { - name: "linkup_local_server".to_string(), - remote: local_url.clone(), - local: local_url.clone(), current: ServiceTarget::Local, - directory: None, - rewrites: vec![], - health: Some(HealthConfig { - path: Some("/linkup/check".to_string()), - ..Default::default() - }), + config: linkup::config::ServiceConfig { + name: "linkup_local_server".to_string(), + remote: local_url.clone(), + local: local_url.clone(), + directory: None, + rewrites: None, + health: Some(HealthConfig { + path: Some("/linkup/check".to_string()), + ..Default::default() + }), + }, }, LocalService { - name: "linkup_remote_server".to_string(), - remote: state.linkup.worker_url.clone(), - local: state.linkup.worker_url.clone(), current: ServiceTarget::Remote, - directory: None, - rewrites: vec![], - health: Some(HealthConfig { - path: Some("/linkup/check".to_string()), - ..Default::default() - }), + config: linkup::config::ServiceConfig { + name: "linkup_remote_server".to_string(), + remote: state.linkup.worker_url.clone(), + local: state.linkup.worker_url.clone(), + directory: None, + rewrites: None, + health: Some(HealthConfig { + path: Some("/linkup/check".to_string()), + ..Default::default() + }), + }, }, LocalService { - name: "tunnel".to_string(), - remote: state.get_tunnel_url(), - local: state.get_tunnel_url(), current: ServiceTarget::Remote, - directory: None, - rewrites: vec![], - health: Some(HealthConfig { - path: Some("/linkup/check".to_string()), - ..Default::default() - }), + config: linkup::config::ServiceConfig { + name: "tunnel".to_string(), + remote: state.get_tunnel_url(), + local: state.get_tunnel_url(), + directory: None, + rewrites: None, + health: Some(HealthConfig { + path: Some("/linkup/check".to_string()), + ..Default::default() + }), + }, }, ] } @@ -334,7 +339,7 @@ fn service_status(service: &LocalService, session_name: &str) -> ServerStatus { let mut acceptable_statuses_override: Option> = None; let mut url = service.current_url(); - if let Some(health_config) = &service.health { + if let Some(health_config) = &service.config.health { if let Some(path) = &health_config.path { url = url.join(path).unwrap(); } @@ -349,7 +354,7 @@ fn service_status(service: &LocalService, session_name: &str) -> ServerStatus { &HeaderMap::new(), session_name, &TargetService { - name: service.name.clone(), + name: service.config.name.clone(), url: url.to_string(), }, ); @@ -424,7 +429,7 @@ where let priority = service_priority(&service); ServiceStatus { - name: service.name.clone(), + name: service.config.name.clone(), component_kind: service.current.to_string(), status: ServerStatus::Loading, service, @@ -443,7 +448,7 @@ where thread::spawn(move || { let status = service_status(&service_clone, &session_name); - tx.send((service_clone.name.clone(), status)) + tx.send((service_clone.config.name.clone(), status)) .expect("Failed to send service status"); }); } @@ -454,9 +459,11 @@ where } fn is_internal_service(service: &LocalService) -> bool { - service.name == "linkup_local_server" - || service.name == "linkup_remote_server" - || service.name == "tunnel" + let service_name = &service.config.name; + + service_name == "linkup_local_server" + || service_name == "linkup_remote_server" + || service_name == "tunnel" } fn service_priority(service: &LocalService) -> i8 { diff --git a/linkup-cli/src/commands/stop.rs b/linkup-cli/src/commands/stop.rs index 2bbbb06a..b44a7151 100644 --- a/linkup-cli/src/commands/stop.rs +++ b/linkup-cli/src/commands/stop.rs @@ -4,25 +4,28 @@ use std::path::{Path, PathBuf}; use anyhow::Context; use crate::env_files::clear_env_file; -use crate::local_config::LocalState; use crate::services::{stop_service, BackgroundService}; +use crate::state::State; use crate::{services, Result}; #[derive(clap::Args)] pub struct Args {} pub fn stop(_args: &Args, clear_env: bool) -> Result<()> { - match (LocalState::load(), clear_env) { + match (State::load(), clear_env) { (Ok(state), true) => { // Reset env vars back to what they were before for service in &state.services { - let remove_res = match &service.directory { + let remove_res = match &service.config.directory { Some(d) => remove_service_env(d.clone(), state.linkup.config_path.clone()), None => Ok(()), }; if let Err(e) = remove_res { - println!("Could not remove env for service {}: {}", service.name, e); + println!( + "Could not remove env for service {}: {}", + service.config.name, e + ); } } } diff --git a/linkup-cli/src/commands/uninstall.rs b/linkup-cli/src/commands/uninstall.rs index d4f1b6f1..cc9d6b3b 100644 --- a/linkup-cli/src/commands/uninstall.rs +++ b/linkup-cli/src/commands/uninstall.rs @@ -1,8 +1,8 @@ use std::{fs, process}; use crate::{ - commands, commands::local_dns, linkup_dir_path, linkup_exe_path, local_config::managed_domains, - local_config::LocalState, prompt, InstallationMethod, Result, + commands, commands::local_dns, linkup_dir_path, linkup_exe_path, prompt, + state::managed_domains, state::State, InstallationMethod, Result, }; #[cfg(target_os = "linux")] @@ -24,10 +24,7 @@ pub async fn uninstall(_args: &Args, config_arg: &Option) -> Result<()> commands::stop(&commands::StopArgs {}, true)?; - if local_dns::is_installed(&managed_domains( - LocalState::load().ok().as_ref(), - config_arg, - )) { + if local_dns::is_installed(&managed_domains(State::load().ok().as_ref(), config_arg)) { local_dns::uninstall(config_arg).await?; } diff --git a/linkup-cli/src/main.rs b/linkup-cli/src/main.rs index 785d21d5..e38d0b6e 100644 --- a/linkup-cli/src/main.rs +++ b/linkup-cli/src/main.rs @@ -10,9 +10,9 @@ pub use linkup::Version; mod commands; mod env_files; -mod local_config; mod release; mod services; +mod state; mod worker_client; const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/linkup-cli/src/services/cloudflare_tunnel.rs b/linkup-cli/src/services/cloudflare_tunnel.rs index 3d9ee091..707f0a14 100644 --- a/linkup-cli/src/services/cloudflare_tunnel.rs +++ b/linkup-cli/src/services/cloudflare_tunnel.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; use tokio::time::sleep; use url::Url; -use crate::{linkup_file_path, local_config::LocalState, worker_client::WorkerClient, Result}; +use crate::{linkup_file_path, state::State, worker_client::WorkerClient, Result}; use super::{find_service_pid, BackgroundService, PidError}; @@ -129,7 +129,7 @@ impl CloudflareTunnel { false } - fn update_state(&self, tunnel_url: &Url, state: &mut LocalState) -> Result<()> { + fn update_state(&self, tunnel_url: &Url, state: &mut State) -> Result<()> { debug!("Adding tunnel url {} to the state", tunnel_url.as_str()); state.linkup.tunnel = Some(tunnel_url.clone()); @@ -147,7 +147,7 @@ impl BackgroundService for CloudflareTunnel { async fn run_with_progress( &self, - state: &mut LocalState, + state: &mut State, status_sender: std::sync::mpsc::Sender, ) -> Result<()> { if !state.should_use_tunnel() { diff --git a/linkup-cli/src/services/local_dns_server.rs b/linkup-cli/src/services/local_dns_server.rs index 80638c08..6686cc6a 100644 --- a/linkup-cli/src/services/local_dns_server.rs +++ b/linkup-cli/src/services/local_dns_server.rs @@ -8,7 +8,7 @@ use std::{ use anyhow::Context; -use crate::{commands::local_dns, linkup_file_path, local_config::LocalState, Result}; +use crate::{commands::local_dns, linkup_file_path, state::State, Result}; use super::BackgroundService; @@ -62,7 +62,7 @@ impl BackgroundService for LocalDnsServer { async fn run_with_progress( &self, - state: &mut LocalState, + state: &mut State, status_sender: std::sync::mpsc::Sender, ) -> Result<()> { self.notify_update(&status_sender, super::RunStatus::Starting); diff --git a/linkup-cli/src/services/local_server.rs b/linkup-cli/src/services/local_server.rs index 7bbeb420..ccf59d3b 100644 --- a/linkup-cli/src/services/local_server.rs +++ b/linkup-cli/src/services/local_server.rs @@ -14,7 +14,7 @@ use url::Url; use crate::{ linkup_certs_dir_path, linkup_file_path, - local_config::{upload_state, LocalState}, + state::{upload_state, State}, worker_client, Result, }; @@ -93,7 +93,7 @@ impl LocalServer { matches!(response, Ok(res) if res.status() == StatusCode::OK) } - async fn update_state(&self, state: &mut LocalState) -> Result<()> { + async fn update_state(&self, state: &mut State) -> Result<()> { let session_name = upload_state(state).await?; state.linkup.session_name = session_name; @@ -111,7 +111,7 @@ impl BackgroundService for LocalServer { async fn run_with_progress( &self, - state: &mut LocalState, + state: &mut State, status_sender: std::sync::mpsc::Sender, ) -> Result<()> { self.notify_update(&status_sender, super::RunStatus::Starting); diff --git a/linkup-cli/src/services/mod.rs b/linkup-cli/src/services/mod.rs index 3559ef4d..32e175d6 100644 --- a/linkup-cli/src/services/mod.rs +++ b/linkup-cli/src/services/mod.rs @@ -15,7 +15,7 @@ pub use { cloudflare_tunnel::CloudflareTunnel, }; -use crate::local_config::LocalState; +use crate::state::State; #[derive(Clone)] pub enum RunStatus { @@ -51,7 +51,7 @@ pub trait BackgroundService { async fn run_with_progress( &self, - local_state: &mut LocalState, + local_state: &mut State, status_sender: sync::mpsc::Sender, ) -> anyhow::Result<()>; diff --git a/linkup-cli/src/local_config.rs b/linkup-cli/src/state.rs similarity index 67% rename from linkup-cli/src/local_config.rs rename to linkup-cli/src/state.rs index 15134f33..dbe8ded1 100644 --- a/linkup-cli/src/local_config.rs +++ b/linkup-cli/src/state.rs @@ -10,9 +10,7 @@ use regex::Regex; use serde::{Deserialize, Serialize}; use url::Url; -use linkup::{ - CreatePreviewRequest, Domain, Rewrite, Session, SessionService, UpdateSessionRequest, -}; +use linkup::{Domain, Session, SessionService, UpdateSessionRequest}; use crate::{ linkup_file_path, services, @@ -21,13 +19,13 @@ use crate::{ }; #[derive(Deserialize, Serialize, Clone, Debug)] -pub struct LocalState { +pub struct State { pub linkup: LinkupState, pub domains: Vec, pub services: Vec, } -impl LocalState { +impl State { pub fn load() -> anyhow::Result { let state_file_path = linkup_file_path(LINKUP_STATE_FILE); let content = fs::read_to_string(&state_file_path) @@ -95,28 +93,19 @@ pub struct LinkupState { 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)] pub struct LocalService { - pub name: String, - pub remote: Url, - pub local: Url, pub current: ServiceTarget, - pub directory: Option, - pub rewrites: Vec, - pub health: Option, + + #[serde(flatten)] + pub config: linkup::config::ServiceConfig, } impl LocalService { pub fn current_url(&self) -> Url { match self.current { - ServiceTarget::Local => self.local.clone(), - ServiceTarget::Remote => self.remote.clone(), + ServiceTarget::Local => self.config.local.clone(), + ServiceTarget::Remote => self.config.remote.clone(), } } } @@ -136,76 +125,17 @@ impl Display for ServiceTarget { } } -#[derive(Deserialize, Clone)] -pub struct YamlLocalConfig { - pub linkup: LinkupConfig, - pub services: Vec, - pub domains: Vec, -} - -impl YamlLocalConfig { - pub fn create_preview_request(&self, services: &[(String, String)]) -> CreatePreviewRequest { - let services = self - .services - .iter() - .map(|yaml_local_service: &YamlLocalService| { - let name = yaml_local_service.name.clone(); - let mut location = yaml_local_service.remote.clone(); - - for (param_service_name, param_service_url) in services { - if param_service_name == &name { - location = Url::parse(param_service_url).unwrap(); - } - } - - SessionService { - name, - location, - rewrites: yaml_local_service.rewrites.clone(), - } - }) - .collect(); - - CreatePreviewRequest { - services, - domains: self.domains.clone(), - cache_routes: self.linkup.cache_routes.clone(), - } - } -} - -#[derive(Deserialize, Clone)] -pub struct LinkupConfig { - pub worker_url: Url, - pub worker_token: String, - #[serde( - default, - deserialize_with = "linkup::serde_ext::deserialize_opt_vec_regex" - )] - cache_routes: Option>, -} - -#[derive(Deserialize, Clone)] -pub struct YamlLocalService { - name: String, - remote: Url, - local: Url, - directory: Option, - rewrites: Option>, - health: Option, -} - #[derive(Debug)] -pub struct ServerConfig { +pub struct ServersSessions { pub local: Session, pub remote: Session, } pub fn config_to_state( - yaml_config: YamlLocalConfig, + config: linkup::config::Config, config_path: String, no_tunnel: bool, -) -> LocalState { +) -> State { let random_token = Alphanumeric.sample_string(&mut rand::rng(), 16); let tunnel = match no_tunnel { @@ -216,30 +146,25 @@ pub fn config_to_state( let linkup = LinkupState { session_name: String::new(), session_token: random_token, - worker_token: yaml_config.linkup.worker_token, + worker_token: config.linkup.worker_token, config_path, - worker_url: yaml_config.linkup.worker_url, + worker_url: config.linkup.worker_url, tunnel, - cache_routes: yaml_config.linkup.cache_routes, + cache_routes: config.linkup.cache_routes, }; - let services = yaml_config + let services = config .services .into_iter() - .map(|yaml_service| LocalService { - name: yaml_service.name, - remote: yaml_service.remote, - local: yaml_service.local, + .map(|service_config| LocalService { + config: service_config.clone(), current: ServiceTarget::Remote, - directory: yaml_service.directory, - rewrites: yaml_service.rewrites.unwrap_or_default(), - health: yaml_service.health, }) .collect::>(); - let domains = yaml_config.domains; + let domains = config.domains; - LocalState { + State { linkup, domains, services, @@ -267,7 +192,7 @@ pub fn config_path(config_arg: &Option) -> Result { } } -pub fn get_config(config_path: &str) -> Result { +pub fn get_config(config_path: &str) -> Result { let content = fs::read_to_string(config_path) .with_context(|| format!("Failed to read config file {config_path:?}"))?; @@ -277,25 +202,25 @@ pub fn get_config(config_path: &str) -> Result { // This method gets the local state and uploads it to both the local linkup server and // the remote linkup server (worker). -pub async fn upload_state(state: &LocalState) -> Result { +pub async fn upload_state(state: &State) -> Result { let local_url = services::LocalServer::url(); - let server_config = ServerConfig::from(state); + let servers_sessions = ServersSessions::from(state); let session_name = &state.linkup.session_name; - let server_session_name = upload_config_to_server( + let server_session_name = upload_session_to_server( &state.linkup.worker_url, &state.linkup.worker_token, session_name, - server_config.remote, + servers_sessions.remote, ) .await?; - let local_session_name = upload_config_to_server( + let local_session_name = upload_session_to_server( &local_url, &state.linkup.worker_token, &server_session_name, - server_config.local, + servers_sessions.local, ) .await?; @@ -312,18 +237,18 @@ pub async fn upload_state(state: &LocalState) -> Result { Ok(server_session_name) } -async fn upload_config_to_server( +async fn upload_session_to_server( linkup_url: &Url, worker_token: &str, desired_name: &str, - config: Session, + session: Session, ) -> Result { let session_update_req = UpdateSessionRequest { - session_token: config.session_token, + session_token: session.session_token, desired_name: desired_name.to_string(), - services: config.services, - domains: config.domains, - cache_routes: config.cache_routes, + services: session.services, + domains: session.domains, + cache_routes: session.cache_routes, }; let session_name = WorkerClient::new(linkup_url, worker_token) @@ -333,19 +258,19 @@ async fn upload_config_to_server( Ok(session_name) } -impl From<&LocalState> for ServerConfig { - fn from(state: &LocalState) -> Self { +impl From<&State> for ServersSessions { + fn from(state: &State) -> Self { let local_server_services = state .services .iter() .map(|service| SessionService { - name: service.name.clone(), + name: service.config.name.clone(), location: if service.current == ServiceTarget::Remote { - service.remote.clone() + service.config.remote.clone() } else { - service.local.clone() + service.config.local.clone() }, - rewrites: Some(service.rewrites.clone()), + rewrites: service.config.rewrites.clone(), }) .collect::>(); @@ -353,13 +278,13 @@ impl From<&LocalState> for ServerConfig { .services .iter() .map(|service| SessionService { - name: service.name.clone(), + name: service.config.name.clone(), location: if service.current == ServiceTarget::Remote { - service.remote.clone() + service.config.remote.clone() } else { state.get_tunnel_url() }, - rewrites: Some(service.rewrites.clone()), + rewrites: service.config.rewrites.clone(), }) .collect::>(); @@ -377,14 +302,14 @@ impl From<&LocalState> for ServerConfig { cache_routes: state.linkup.cache_routes.clone(), }; - ServerConfig { + ServersSessions { local: local_session, remote: remote_session, } } } -pub fn managed_domains(state: Option<&LocalState>, cfg_path: &Option) -> Vec { +pub fn managed_domains(state: Option<&State>, cfg_path: &Option) -> Vec { let config_domains = match config_path(cfg_path).ok() { Some(cfg_path) => match get_config(&cfg_path) { Ok(config) => Some( @@ -462,8 +387,8 @@ domains: #[test] 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); + let config = serde_yaml::from_str(&input_str).unwrap(); + let local_state = config_to_state(config, "./path/to/config.yaml".to_string(), false); assert_eq!(local_state.linkup.config_path, "./path/to/config.yaml"); @@ -477,40 +402,45 @@ domains: ); assert_eq!(local_state.services.len(), 2); - assert_eq!(local_state.services[0].name, "frontend"); + assert_eq!(local_state.services[0].config.name, "frontend"); assert_eq!( - local_state.services[0].remote, + local_state.services[0].config.remote, Url::parse("http://remote-service1.example.com").unwrap() ); assert_eq!( - local_state.services[0].local, + local_state.services[0].config.local, Url::parse("http://localhost:8000").unwrap() ); assert_eq!(local_state.services[0].current, ServiceTarget::Remote); - assert_eq!(local_state.services[0].health, None); + assert!(local_state.services[0].config.health.is_none()); - assert_eq!(local_state.services[0].rewrites.len(), 1); - assert_eq!(local_state.services[1].name, "backend"); assert_eq!( - local_state.services[1].remote, + local_state.services[0] + .config + .rewrites + .as_ref() + .unwrap() + .len(), + 1 + ); + assert_eq!(local_state.services[1].config.name, "backend"); + assert_eq!( + local_state.services[1].config.remote, Url::parse("http://remote-service2.example.com").unwrap() ); assert_eq!( - local_state.services[1].local, + local_state.services[1].config.local, Url::parse("http://localhost:8001").unwrap() ); - assert_eq!(local_state.services[1].rewrites.len(), 0); + assert!(local_state.services[1].config.rewrites.is_none()); assert_eq!( - local_state.services[1].directory, + local_state.services[1].config.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!(local_state.services[1].config.health.is_some()); + let health = local_state.services[1].config.health.as_ref().unwrap(); + assert_eq!(health.path, Some("/health".to_string())); + assert_eq!(health.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/worker_client.rs b/linkup-cli/src/worker_client.rs index f69fcb2e..711cb345 100644 --- a/linkup-cli/src/worker_client.rs +++ b/linkup-cli/src/worker_client.rs @@ -3,8 +3,6 @@ use reqwest::{header, StatusCode}; use serde::{Deserialize, Serialize}; use url::Url; -use crate::local_config::YamlLocalConfig; - #[derive(thiserror::Error, Debug)] pub enum Error { #[error("{0}")] @@ -116,8 +114,8 @@ impl WorkerClient { } } -impl From<&YamlLocalConfig> for WorkerClient { - fn from(config: &YamlLocalConfig) -> Self { +impl From<&linkup::config::Config> for WorkerClient { + fn from(config: &linkup::config::Config) -> Self { Self::new(&config.linkup.worker_url, &config.linkup.worker_token) } } diff --git a/linkup/src/config.rs b/linkup/src/config.rs new file mode 100644 index 00000000..8a06a216 --- /dev/null +++ b/linkup/src/config.rs @@ -0,0 +1,40 @@ +use regex::Regex; +use serde::{Deserialize, Serialize}; +use url::Url; + +use crate::{Domain, Rewrite}; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Config { + pub linkup: LinkupConfig, + pub services: Vec, + pub domains: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct LinkupConfig { + pub worker_url: Url, + pub worker_token: String, + #[serde( + default, + deserialize_with = "crate::serde_ext::deserialize_opt_vec_regex", + serialize_with = "crate::serde_ext::serialize_opt_vec_regex" + )] + pub cache_routes: Option>, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ServiceConfig { + pub name: String, + pub remote: Url, + pub local: Url, + pub directory: Option, + pub rewrites: Option>, + pub health: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct HealthConfig { + pub path: Option, + pub statuses: Option>, +} diff --git a/linkup/src/lib.rs b/linkup/src/lib.rs index 53c7941a..6342c526 100644 --- a/linkup/src/lib.rs +++ b/linkup/src/lib.rs @@ -1,3 +1,4 @@ +pub mod config; pub mod serde_ext; mod headers; diff --git a/linkup/src/session.rs b/linkup/src/session.rs index 4ae24c33..8b2aaefb 100644 --- a/linkup/src/session.rs +++ b/linkup/src/session.rs @@ -5,6 +5,8 @@ use regex::Regex; use serde::{Deserialize, Serialize}; use url::Url; +use crate::config::Config; + pub const PREVIEW_SESSION_TOKEN: &str = "preview_session"; #[derive(Clone, Debug, Deserialize, Serialize)] @@ -159,27 +161,57 @@ impl TryFrom for Session { } } -fn validate_not_empty(server_config: &Session) -> Result<(), ConfigError> { - if server_config.services.is_empty() { +pub fn create_preview_req_from_config( + config: &Config, + services_overwrite: &[(String, Url)], +) -> CreatePreviewRequest { + let mut session_services: Vec = Vec::with_capacity(config.services.len()); + + for service in &config.services { + let service_overwrite = services_overwrite + .iter() + .find(|overwrite| overwrite.0 == service.name); + + let location = match service_overwrite { + Some((_, location_overwrite)) => location_overwrite.clone(), + None => service.remote.clone(), + }; + + session_services.push(SessionService { + name: service.name.clone(), + location, + rewrites: service.rewrites.clone(), + }); + } + + CreatePreviewRequest { + services: session_services, + domains: config.domains.clone(), + cache_routes: config.linkup.cache_routes.clone(), + } +} + +fn validate_not_empty(session: &Session) -> Result<(), ConfigError> { + if session.services.is_empty() { return Err(ConfigError::Empty); } - if server_config.domains.is_empty() { + if session.domains.is_empty() { return Err(ConfigError::Empty); } Ok(()) } -fn validate_services(server_config: &Session) -> Result<(), ConfigError> { +fn validate_services(session: &Session) -> Result<(), ConfigError> { let mut service_names: HashSet<&str> = HashSet::new(); - for service in &server_config.services { + for service in &session.services { validate_url_origin(&service.location)?; service_names.insert(&service.name); } - for domain in &server_config.domains { + for domain in &session.domains { if !service_names.contains(&domain.default_service.as_str()) { return Err(ConfigError::NoSuchService( domain.default_service.to_string(), @@ -257,25 +289,26 @@ mod tests { "#; #[test] - fn test_convert_server_config() { + fn test_convert_session() { let input_str = String::from(CONF_STR); - let server_config_value = serde_json::from_str::(&input_str).unwrap(); - let server_config: Session = server_config_value.try_into().unwrap(); - check_means_same_as_input_conf(&server_config); + let session_value = serde_json::from_str::(&input_str).unwrap(); + let session: Session = session_value.try_into().unwrap(); + check_means_same_as_input_conf(&session); // Inverse should mean the same thing - let output_conf = serde_json::to_string(&server_config).unwrap(); - let output_conf_value = serde_json::from_str::(&output_conf).unwrap(); - let second_server_conf: Session = output_conf_value.try_into().unwrap(); - check_means_same_as_input_conf(&second_server_conf); + let output_session = serde_json::to_string(&session).unwrap(); + let output_session_value = + serde_json::from_str::(&output_session).unwrap(); + let second_session: Session = output_session_value.try_into().unwrap(); + check_means_same_as_input_conf(&second_session); } - fn check_means_same_as_input_conf(server_config: &Session) { + fn check_means_same_as_input_conf(session: &Session) { // Test services - assert_eq!(server_config.services.len(), 2); + assert_eq!(session.services.len(), 2); - let frontend_service = server_config.get_service("frontend").unwrap(); + let frontend_service = session.get_service("frontend").unwrap(); assert_eq!( frontend_service.location, Url::parse("http://localhost:8000").unwrap() @@ -293,7 +326,7 @@ mod tests { assert_eq!(frontend_service_rewrite.source.as_str(), "/foo/(.*)"); assert_eq!(frontend_service_rewrite.target, "/bar/$1"); - let backend_service = server_config.get_service("backend").unwrap(); + let backend_service = session.get_service("backend").unwrap(); assert_eq!( backend_service.location, Url::parse("http://localhost:8001").unwrap() @@ -301,9 +334,9 @@ mod tests { assert!(backend_service.rewrites.is_none()); // Test domains - assert_eq!(2, server_config.domains.len()); + assert_eq!(2, session.domains.len()); - let example_domain = server_config.get_domain("example.com").unwrap(); + let example_domain = session.get_domain("example.com").unwrap(); assert_eq!(example_domain.default_service, "frontend"); assert_eq!( @@ -315,15 +348,15 @@ mod tests { assert_eq!(example_domain_route.path.as_str(), "/api/v1/.*"); assert_eq!(example_domain_route.service, "backend"); - let api_domain = server_config.get_domain("api.example.com").unwrap(); + let api_domain = session.get_domain("api.example.com").unwrap(); assert_eq!(api_domain.default_service, "backend"); assert!(api_domain.routes.is_none()); // Test cache routes - assert_eq!(server_config.cache_routes.as_ref().unwrap().len(), 1); + assert_eq!(session.cache_routes.as_ref().unwrap().len(), 1); assert_eq!( - server_config.cache_routes.as_ref().unwrap()[0].as_str(), + session.cache_routes.as_ref().unwrap()[0].as_str(), "/static/.*" ); } From 20c531c5cb72f854827ede04920e61e52acca6f8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 10:45:08 +0100 Subject: [PATCH 4/8] chore(deps): bump openssl from 0.10.68 to 0.10.75 in the cargo group across 1 directory (#259) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the cargo group with 1 update in the / directory: [openssl](https://github.com/rust-openssl/rust-openssl). Updates `openssl` from 0.10.68 to 0.10.75
Release notes

Sourced from openssl's releases.

openssl-v0.10.75

What's Changed

New Contributors

Full Changelog: https://github.com/rust-openssl/rust-openssl/compare/openssl-v0.10.74...openssl-v0.10.75

openssl-v0.10.74

What's Changed

... (truncated)

Commits
  • 09b90d0 Merge pull request #2518 from alex/bump-for-release
  • 26533f3 Release openssl v0.10.75 and openssl-sys v0.9.111
  • 395ecca Merge pull request #2517 from alex/claude/fix-ocsp-find-status-011CUqcGFNKeKJ...
  • cc26867 Fix unsound OCSP find_status handling of optional next_update field
  • 95aa8e8 Merge pull request #2513 from botovq/libressl-stable
  • e735a32 CI: bump LibreSSL 4.x branches to latest releases
  • 21ab91d Merge pull request #2510 from huwcbjones/huw/sys/evp-mac
  • d9161dc sys/evp: add EVP_MAC symbols
  • 3fd4bf2 Merge pull request #2508 from goffrie/oaep-label
  • 52022fd Implement set_rsa_oaep_label for AWS-LC/BoringSSL
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=openssl&package-manager=cargo&previous-version=0.10.68&new-version=0.10.75)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/mentimeter/linkup/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0a9e381b..80f07d7a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2149,9 +2149,9 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.68" +version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6174bc48f102d208783c2c84bf931bb75927a617866870de8a4ea85597f871f5" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ "bitflags", "cfg-if", @@ -2181,9 +2181,9 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" [[package]] name = "openssl-sys" -version = "0.9.104" +version = "0.9.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45abf306cbf99debc8195b66b7346498d7b10c210de50418b5ccd7ceba08c741" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" dependencies = [ "cc", "libc", From abd0af29af6f984978caa38912fb779aed9be374 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augusto=20C=C3=A9sar?= Date: Mon, 16 Mar 2026 15:00:00 +0100 Subject: [PATCH 5/8] chore: remove deprecated --all arg on `status` (#262) --- linkup-cli/src/commands/status.rs | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/linkup-cli/src/commands/status.rs b/linkup-cli/src/commands/status.rs index b89658a3..cf510ceb 100644 --- a/linkup-cli/src/commands/status.rs +++ b/linkup-cli/src/commands/status.rs @@ -25,21 +25,9 @@ pub struct Args { // Output status in JSON format #[arg(long)] pub json: bool, - - #[arg(short, long)] - all: bool, } pub fn status(args: &Args) -> anyhow::Result<()> { - // TODO(augustocesar)[2024-10-28]: Remove --all/-a in a future release. - // Do not print the warning in case of JSON so it doesn't break any usage if the result of the command - // is passed on to somewhere else. - if args.all && !args.json { - let warning = "--all/-a is a noop now. All services statuses will always be shown. \ - This arg will be removed in a future release.\n"; - println!("{}", warning.yellow()); - } - if !State::exists() { println!( "{}", From ecb1ce8c17a7f76550f7349367e506006d94a669 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augusto=20C=C3=A9sar?= Date: Tue, 17 Mar 2026 08:35:39 +0100 Subject: [PATCH 6/8] refactor: move service functions to default trait (#261) --- linkup-cli/src/commands/health.rs | 8 +-- linkup-cli/src/commands/local.rs | 4 +- linkup-cli/src/commands/remote.rs | 4 +- linkup-cli/src/commands/stop.rs | 8 +-- linkup-cli/src/services/cloudflare_tunnel.rs | 4 +- linkup-cli/src/services/mod.rs | 68 ++++++++++---------- 6 files changed, 48 insertions(+), 48 deletions(-) diff --git a/linkup-cli/src/commands/health.rs b/linkup-cli/src/commands/health.rs index 9d4959a9..55071f53 100644 --- a/linkup-cli/src/commands/health.rs +++ b/linkup-cli/src/commands/health.rs @@ -10,7 +10,7 @@ use std::{ use crate::{ linkup_dir_path, - services::{self, find_service_pid, BackgroundService}, + services::{self, BackgroundService}, state::State, Result, }; @@ -106,7 +106,7 @@ impl BackgroundServices { pub fn load(state: Option<&State>) -> Self { let mut managed_pids: Vec = Vec::with_capacity(4); - let linkup_server = match find_service_pid(services::LocalServer::ID) { + let linkup_server = match services::LocalServer::find_pid() { Some(pid) => { managed_pids.push(pid); @@ -116,7 +116,7 @@ impl BackgroundServices { }; let cloudflared = if services::is_cloudflared_installed() { - match find_service_pid(services::CloudflareTunnel::ID) { + match services::CloudflareTunnel::find_pid() { Some(pid) => { managed_pids.push(pid); @@ -128,7 +128,7 @@ impl BackgroundServices { BackgroundServiceHealth::NotInstalled }; - let dns_server = match find_service_pid(services::LocalDnsServer::ID) { + let dns_server = match services::LocalDnsServer::find_pid() { Some(pid) => { managed_pids.push(pid); diff --git a/linkup-cli/src/commands/local.rs b/linkup-cli/src/commands/local.rs index 90f72b44..c92db073 100644 --- a/linkup-cli/src/commands/local.rs +++ b/linkup-cli/src/commands/local.rs @@ -2,7 +2,7 @@ use anyhow::anyhow; use colored::Colorize; use crate::{ - services::{self, find_service_pid, BackgroundService}, + services::{self, BackgroundService}, state::{upload_state, ServiceTarget, State}, Result, }; @@ -35,7 +35,7 @@ pub async fn local(args: &Args) -> Result<()> { return Ok(()); } - if find_service_pid(services::LocalServer::ID).is_none() { + if services::LocalServer::find_pid().is_none() { println!( "{}", "Seems like your local Linkup server is not running. Please run 'linkup start' first." diff --git a/linkup-cli/src/commands/remote.rs b/linkup-cli/src/commands/remote.rs index 4b1b9d01..6f60a694 100644 --- a/linkup-cli/src/commands/remote.rs +++ b/linkup-cli/src/commands/remote.rs @@ -1,5 +1,5 @@ use crate::{ - services::{self, find_service_pid, BackgroundService}, + services::{self, BackgroundService}, state::{upload_state, ServiceTarget, State}, Result, }; @@ -37,7 +37,7 @@ pub async fn remote(args: &Args) -> Result<()> { let mut state = State::load()?; - if find_service_pid(services::LocalServer::ID).is_none() { + if services::LocalServer::find_pid().is_none() { println!( "{}", "Seems like your local Linkup server is not running. Please run 'linkup start' first." diff --git a/linkup-cli/src/commands/stop.rs b/linkup-cli/src/commands/stop.rs index b44a7151..306dbf93 100644 --- a/linkup-cli/src/commands/stop.rs +++ b/linkup-cli/src/commands/stop.rs @@ -4,7 +4,7 @@ use std::path::{Path, PathBuf}; use anyhow::Context; use crate::env_files::clear_env_file; -use crate::services::{stop_service, BackgroundService}; +use crate::services::BackgroundService; use crate::state::State; use crate::{services, Result}; @@ -35,9 +35,9 @@ pub fn stop(_args: &Args, clear_env: bool) -> Result<()> { } } - stop_service(services::LocalServer::ID); - stop_service(services::CloudflareTunnel::ID); - stop_service(services::LocalDnsServer::ID); + services::LocalServer::stop(); + services::CloudflareTunnel::stop(); + services::LocalDnsServer::stop(); println!("Stopped linkup"); diff --git a/linkup-cli/src/services/cloudflare_tunnel.rs b/linkup-cli/src/services/cloudflare_tunnel.rs index 707f0a14..93bd5d1d 100644 --- a/linkup-cli/src/services/cloudflare_tunnel.rs +++ b/linkup-cli/src/services/cloudflare_tunnel.rs @@ -15,7 +15,7 @@ use url::Url; use crate::{linkup_file_path, state::State, worker_client::WorkerClient, Result}; -use super::{find_service_pid, BackgroundService, PidError}; +use super::{BackgroundService, PidError}; #[derive(thiserror::Error, Debug)] #[allow(dead_code)] @@ -170,7 +170,7 @@ impl BackgroundService for CloudflareTunnel { return Err(Error::InvalidSessionName(state.linkup.session_name.clone()).into()); } - if find_service_pid(Self::ID).is_some() { + if Self::find_pid().is_some() { self.notify_update_with_details( &status_sender, super::RunStatus::Started, diff --git a/linkup-cli/src/services/mod.rs b/linkup-cli/src/services/mod.rs index 32e175d6..374ea50a 100644 --- a/linkup-cli/src/services/mod.rs +++ b/linkup-cli/src/services/mod.rs @@ -26,16 +26,12 @@ pub enum RunStatus { Error, } -impl Display for RunStatus { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Pending => write!(f, "pending"), - Self::Starting => write!(f, "starting"), - Self::Started => write!(f, "started"), - Self::Skipped => write!(f, "skipped"), - Self::Error => write!(f, "error"), - } - } +#[derive(Error, Debug)] +pub enum PidError { + #[error("no pid file: {0}")] + NoPidFile(String), + #[error("bad pid file: {0}")] + BadPidFile(String), } #[derive(Clone)] @@ -55,6 +51,14 @@ pub trait BackgroundService { status_sender: sync::mpsc::Sender, ) -> anyhow::Result<()>; + fn stop() { + if let Some(pid) = Self::find_pid() { + system() + .process(pid) + .map(|process| process.kill_with(Signal::Interrupt)); + } + } + fn notify_update(&self, status_sender: &sync::mpsc::Sender, status: RunStatus) { status_sender .send(RunUpdate { @@ -79,35 +83,31 @@ pub trait BackgroundService { }) .unwrap(); } -} -#[derive(Error, Debug)] -pub enum PidError { - #[error("no pid file: {0}")] - NoPidFile(String), - #[error("bad pid file: {0}")] - BadPidFile(String), -} - -pub fn find_service_pid(service_id: &str) -> Option { - for (pid, process) in system().processes() { - if process - .environ() - .iter() - .any(|item| item.to_string_lossy() == format!("LINKUP_SERVICE_ID={service_id}")) - { - return Some(*pid); + fn find_pid() -> Option { + for (pid, process) in system().processes() { + if process + .environ() + .iter() + .any(|item| item.to_string_lossy() == format!("LINKUP_SERVICE_ID={}", Self::ID)) + { + return Some(*pid); + } } - } - None + None + } } -pub fn stop_service(service_id: &str) { - if let Some(pid) = find_service_pid(service_id) { - system() - .process(pid) - .map(|process| process.kill_with(Signal::Interrupt)); +impl Display for RunStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Pending => write!(f, "pending"), + Self::Starting => write!(f, "starting"), + Self::Started => write!(f, "started"), + Self::Skipped => write!(f, "skipped"), + Self::Error => write!(f, "error"), + } } } From ef0eddc617e5a547aed0e53c80452a26028efcaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augusto=20C=C3=A9sar?= Date: Fri, 27 Mar 2026 14:14:30 +0100 Subject: [PATCH 7/8] feat: all local servers managed together (#265) Related to SHIP-2558 --- linkup-cli/src/commands/health.rs | 35 +------- linkup-cli/src/commands/server.rs | 83 ++++-------------- linkup-cli/src/commands/start.rs | 12 --- linkup-cli/src/commands/stop.rs | 1 - linkup-cli/src/services/local_dns_server.rs | 97 --------------------- linkup-cli/src/services/local_server.rs | 12 ++- linkup-cli/src/services/mod.rs | 2 - local-server/src/lib.rs | 79 +++++++++++++---- 8 files changed, 91 insertions(+), 230 deletions(-) delete mode 100644 linkup-cli/src/services/local_dns_server.rs diff --git a/linkup-cli/src/commands/health.rs b/linkup-cli/src/commands/health.rs index 55071f53..03d3c318 100644 --- a/linkup-cli/src/commands/health.rs +++ b/linkup-cli/src/commands/health.rs @@ -90,20 +90,18 @@ struct OrphanProcess { pub struct BackgroundServices { pub linkup_server: BackgroundServiceHealth, cloudflared: BackgroundServiceHealth, - dns_server: BackgroundServiceHealth, possible_orphan_processes: Vec, } #[derive(Debug, Serialize)] pub enum BackgroundServiceHealth { - Unknown, NotInstalled, Stopped, Running(u32), } impl BackgroundServices { - pub fn load(state: Option<&State>) -> Self { + pub fn load(_state: Option<&State>) -> Self { let mut managed_pids: Vec = Vec::with_capacity(4); let linkup_server = match services::LocalServer::find_pid() { @@ -128,30 +126,9 @@ impl BackgroundServices { BackgroundServiceHealth::NotInstalled }; - let dns_server = match services::LocalDnsServer::find_pid() { - Some(pid) => { - managed_pids.push(pid); - - BackgroundServiceHealth::Running(pid.as_u32()) - } - None => match state { - // If there is no state, we cannot know if local-dns is installed since we depend on - // the domains listed on it. - Some(state) => { - if local_dns::is_installed(&crate::state::managed_domains(Some(state), &None)) { - BackgroundServiceHealth::Stopped - } else { - BackgroundServiceHealth::NotInstalled - } - } - None => BackgroundServiceHealth::Unknown, - }, - }; - Self { linkup_server, cloudflared, - dns_server, possible_orphan_processes: find_potential_orphan_processes(managed_pids), } } @@ -357,15 +334,6 @@ impl Display for Health { BackgroundServiceHealth::NotInstalled => writeln!(f, "{}", "NOT INSTALLED".yellow())?, BackgroundServiceHealth::Stopped => writeln!(f, "{}", "NOT RUNNING".yellow())?, BackgroundServiceHealth::Running(pid) => writeln!(f, "{} ({})", "RUNNING".blue(), pid)?, - BackgroundServiceHealth::Unknown => writeln!(f, "{}", "UNKNOWN".yellow())?, - } - - write!(f, " - DNS Server ")?; - match &self.background_services.dns_server { - BackgroundServiceHealth::NotInstalled => writeln!(f, "{}", "NOT INSTALLED".yellow())?, - BackgroundServiceHealth::Stopped => writeln!(f, "{}", "NOT RUNNING".yellow())?, - BackgroundServiceHealth::Running(pid) => writeln!(f, "{} ({})", "RUNNING".blue(), pid)?, - BackgroundServiceHealth::Unknown => writeln!(f, "{}", "UNKNOWN".yellow())?, } write!(f, " - Cloudflared ")?; @@ -373,7 +341,6 @@ impl Display for Health { BackgroundServiceHealth::NotInstalled => writeln!(f, "{}", "NOT INSTALLED".yellow())?, BackgroundServiceHealth::Stopped => writeln!(f, "{}", "NOT RUNNING".yellow())?, BackgroundServiceHealth::Running(pid) => writeln!(f, "{} ({})", "RUNNING".blue(), pid)?, - BackgroundServiceHealth::Unknown => writeln!(f, "{}", "UNKNOWN".yellow())?, } writeln!(f, "{}", "Linkup:".bold().italic())?; diff --git a/linkup-cli/src/commands/server.rs b/linkup-cli/src/commands/server.rs index fa488143..75213b47 100644 --- a/linkup-cli/src/commands/server.rs +++ b/linkup-cli/src/commands/server.rs @@ -1,78 +1,31 @@ +use std::path::PathBuf; + use crate::Result; use linkup::MemoryStringStore; -use tokio::select; #[derive(clap::Args)] pub struct Args { - #[command(subcommand)] - server_kind: ServerKind, -} + #[arg(long)] + session_name: String, -#[derive(clap::Subcommand)] -pub enum ServerKind { - LocalWorker { - #[arg(long)] - certs_dir: String, - }, + #[arg(long, value_parser, num_args = 1.., value_delimiter = ',')] + domains: Vec, - Dns { - #[arg(long)] - session_name: String, - #[arg(long, value_parser, num_args = 1.., value_delimiter = ',')] - domains: Vec, - }, + #[arg(long)] + certs_dir: String, } pub async fn server(args: &Args) -> Result<()> { - match &args.server_kind { - ServerKind::LocalWorker { certs_dir } => { - let config_store = MemoryStringStore::default(); - - let http_config_store = config_store.clone(); - let handler_http = tokio::spawn(async move { - linkup_local_server::start_server_http(http_config_store) - .await - .unwrap(); - }); - - let handler_https = { - use std::path::PathBuf; - - let https_config_store = config_store.clone(); - let https_certs_dir = PathBuf::from(certs_dir); - - Some(tokio::spawn(async move { - linkup_local_server::start_server_https(https_config_store, &https_certs_dir) - .await; - })) - }; - - match handler_https { - Some(handler_https) => { - select! { - _ = handler_http => (), - _ = handler_https => (), - } - } - None => { - handler_http.await.unwrap(); - } - } - } - ServerKind::Dns { - session_name, - domains, - } => { - let session_name = session_name.clone(); - let domains = domains.clone(); - - let handler_dns = tokio::spawn(async move { - linkup_local_server::start_dns_server(session_name, domains).await; - }); - - handler_dns.await.unwrap(); - } - } + let config_store = MemoryStringStore::default(); + let https_certs_dir = PathBuf::from(&args.certs_dir); + + linkup_local_server::start( + config_store, + &https_certs_dir, + args.session_name.clone(), + args.domains.clone(), + ) + .await; Ok(()) } diff --git a/linkup-cli/src/commands/start.rs b/linkup-cli/src/commands/start.rs index 624f2bbb..82bc72ea 100644 --- a/linkup-cli/src/commands/start.rs +++ b/linkup-cli/src/commands/start.rs @@ -46,7 +46,6 @@ pub async fn start(args: &Args, fresh_state: bool, config_arg: &Option) let local_server = services::LocalServer::new(); let cloudflare_tunnel = services::CloudflareTunnel::new(); - let local_dns_server = services::LocalDnsServer::new(); let mut display_thread: Option> = None; let display_channel = sync::mpsc::channel::(); @@ -59,7 +58,6 @@ pub async fn start(args: &Args, fresh_state: bool, config_arg: &Option) &[ services::LocalServer::NAME, services::CloudflareTunnel::NAME, - services::LocalDnsServer::NAME, ], status_update_channel.1, display_channel.1, @@ -89,16 +87,6 @@ pub async fn start(args: &Args, fresh_state: bool, config_arg: &Option) } } - if exit_error.is_none() { - match local_dns_server - .run_with_progress(&mut state, status_update_channel.0.clone()) - .await - { - Ok(_) => (), - Err(err) => exit_error = Some(err), - } - } - if let Some(display_thread) = display_thread { display_channel.0.send(true).unwrap(); display_thread.join().unwrap(); diff --git a/linkup-cli/src/commands/stop.rs b/linkup-cli/src/commands/stop.rs index 306dbf93..9b0b8e0b 100644 --- a/linkup-cli/src/commands/stop.rs +++ b/linkup-cli/src/commands/stop.rs @@ -37,7 +37,6 @@ pub fn stop(_args: &Args, clear_env: bool) -> Result<()> { services::LocalServer::stop(); services::CloudflareTunnel::stop(); - services::LocalDnsServer::stop(); println!("Stopped linkup"); diff --git a/linkup-cli/src/services/local_dns_server.rs b/linkup-cli/src/services/local_dns_server.rs deleted file mode 100644 index 6686cc6a..00000000 --- a/linkup-cli/src/services/local_dns_server.rs +++ /dev/null @@ -1,97 +0,0 @@ -use std::{ - env, - fs::File, - os::unix::process::CommandExt, - path::PathBuf, - process::{self, Stdio}, -}; - -use anyhow::Context; - -use crate::{commands::local_dns, linkup_file_path, state::State, Result}; - -use super::BackgroundService; - -pub struct LocalDnsServer { - stdout_file_path: PathBuf, - stderr_file_path: PathBuf, -} - -impl LocalDnsServer { - pub fn new() -> Self { - Self { - stdout_file_path: linkup_file_path("localdns-stdout"), - stderr_file_path: linkup_file_path("localdns-stderr"), - } - } - - fn start(&self, session_name: &str, domains: &[String]) -> Result<()> { - log::debug!("Starting {}", Self::NAME); - - let stdout_file = File::create(&self.stdout_file_path)?; - let stderr_file = File::create(&self.stderr_file_path)?; - - let mut command = process::Command::new( - env::current_exe().context("Failed to get the current executable")?, - ); - command.env("RUST_LOG", "debug"); - command.env("LINKUP_SERVICE_ID", Self::ID); - command.args([ - "server", - "dns", - "--session-name", - session_name, - "--domains", - &domains.join(","), - ]); - - command - .process_group(0) - .stdout(stdout_file) - .stderr(stderr_file) - .stdin(Stdio::null()) - .spawn()?; - - Ok(()) - } -} - -impl BackgroundService for LocalDnsServer { - const ID: &str = "linkup-local-dns-server"; - const NAME: &str = "Local DNS server"; - - async fn run_with_progress( - &self, - state: &mut State, - status_sender: std::sync::mpsc::Sender, - ) -> Result<()> { - self.notify_update(&status_sender, super::RunStatus::Starting); - - let session_name = state.linkup.session_name.clone(); - let domains = state.domain_strings(); - - if !local_dns::is_installed(&domains) { - self.notify_update_with_details( - &status_sender, - super::RunStatus::Skipped, - "Not installed", - ); - - return Ok(()); - } - - if let Err(e) = self.start(&session_name, &domains) { - self.notify_update_with_details( - &status_sender, - super::RunStatus::Error, - "Failed to start", - ); - - return Err(e); - } - - self.notify_update(&status_sender, super::RunStatus::Started); - - Ok(()) - } -} diff --git a/linkup-cli/src/services/local_server.rs b/linkup-cli/src/services/local_server.rs index ccf59d3b..ec6222bd 100644 --- a/linkup-cli/src/services/local_server.rs +++ b/linkup-cli/src/services/local_server.rs @@ -50,7 +50,7 @@ impl LocalServer { Url::parse("http://localhost:80").expect("linkup url invalid") } - fn start(&self) -> Result<()> { + fn start(&self, session_name: String, domains: Vec) -> Result<()> { log::debug!("Starting {}", Self::NAME); let stdout_file = File::create(&self.stdout_file_path)?; @@ -66,7 +66,10 @@ impl LocalServer { command.env("LINKUP_SERVICE_ID", Self::ID); command.args([ "server", - "local-worker", + "--session-name", + &session_name, + "--domains", + &domains.join(","), "--certs-dir", linkup_certs_dir_path().to_str().unwrap(), ]); @@ -116,6 +119,9 @@ impl BackgroundService for LocalServer { ) -> Result<()> { self.notify_update(&status_sender, super::RunStatus::Starting); + let session_name = state.linkup.session_name.clone(); + let domains = state.domain_strings(); + if self.reachable().await { self.notify_update_with_details( &status_sender, @@ -126,7 +132,7 @@ impl BackgroundService for LocalServer { return Ok(()); } - if let Err(e) = self.start() { + if let Err(e) = self.start(session_name, domains) { self.notify_update_with_details( &status_sender, super::RunStatus::Error, diff --git a/linkup-cli/src/services/mod.rs b/linkup-cli/src/services/mod.rs index 374ea50a..8d099fd3 100644 --- a/linkup-cli/src/services/mod.rs +++ b/linkup-cli/src/services/mod.rs @@ -4,10 +4,8 @@ use sysinfo::{ProcessRefreshKind, RefreshKind, System}; use thiserror::Error; mod cloudflare_tunnel; -mod local_dns_server; mod local_server; -pub use local_dns_server::LocalDnsServer; pub use local_server::LocalServer; pub use sysinfo::{Pid, Signal}; pub use { diff --git a/local-server/src/lib.rs b/local-server/src/lib.rs index 0a726d88..bf199038 100644 --- a/local-server/src/lib.rs +++ b/local-server/src/lib.rs @@ -36,10 +36,11 @@ use linkup::{ use rustls::ServerConfig; use std::{ net::{Ipv4Addr, SocketAddr}, + path::PathBuf, str::FromStr, }; use std::{path::Path, sync::Arc}; -use tokio::{net::UdpSocket, signal}; +use tokio::{net::UdpSocket, select, signal}; use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tower::ServiceBuilder; use tower_http::trace::{DefaultOnRequest, DefaultOnResponse, TraceLayer}; @@ -99,7 +100,33 @@ pub fn linkup_router(config_store: MemoryStringStore) -> Router { ) } -pub async fn start_server_https(config_store: MemoryStringStore, certs_dir: &Path) { +pub async fn start( + config_store: MemoryStringStore, + certs_dir: &Path, + session_name: String, + domains: Vec, +) { + let http_config_store = config_store.clone(); + let https_config_store = config_store.clone(); + let https_certs_dir = PathBuf::from(certs_dir); + + select! { + () = start_server_http(http_config_store) => { + println!("HTTP server shut down"); + }, + () = start_server_https(https_config_store, &https_certs_dir) => { + println!("HTTPS server shut down"); + }, + () = start_dns_server(session_name, domains) => { + println!("DNS server shut down"); + }, + () = shutdown_signal() => { + println!("Shutdown signal received, stopping all servers"); + } + } +} + +async fn start_server_https(config_store: MemoryStringStore, certs_dir: &Path) { let _ = rustls::crypto::ring::default_provider().install_default(); let sni = match certificates::WildcardSniResolver::load_dir(certs_dir) { @@ -121,7 +148,7 @@ pub async fn start_server_https(config_store: MemoryStringStore, certs_dir: &Pat let app = linkup_router(config_store); let addr = SocketAddr::from(([0, 0, 0, 0], 443)); - println!("listening on {}", &addr); + println!("HTTPS listening on {}", &addr); axum_server::bind_rustls(addr, RustlsConfig::from_config(Arc::new(server_config))) .serve(app.into_make_service()) @@ -129,21 +156,22 @@ pub async fn start_server_https(config_store: MemoryStringStore, certs_dir: &Pat .expect("failed to start HTTPS server"); } -pub async fn start_server_http(config_store: MemoryStringStore) -> std::io::Result<()> { +async fn start_server_http(config_store: MemoryStringStore) { let app = linkup_router(config_store); let addr = SocketAddr::from(([0, 0, 0, 0], 80)); - println!("listening on {}", &addr); + println!("HTTP listening on {}", &addr); - let listener = tokio::net::TcpListener::bind(addr).await?; - axum::serve(listener, app) - .with_graceful_shutdown(shutdown_signal()) - .await?; + let listener = tokio::net::TcpListener::bind(addr) + .await + .expect("failed to bind to address"); - Ok(()) + axum::serve(listener, app) + .await + .expect("failed to start HTTP server"); } -pub async fn start_dns_server(linkup_session_name: String, domains: Vec) { +async fn start_dns_server(linkup_session_name: String, domains: Vec) { let mut catalog = Catalog::new(); for domain in &domains { @@ -399,11 +427,6 @@ async fn always_ok() -> &'static str { "OK" } -async fn shutdown_signal() { - let _ = signal::ctrl_c().await; - println!("signal received, starting graceful shutdown"); -} - fn https_client() -> HttpsClient { let _ = rustls::crypto::ring::default_provider().install_default(); @@ -425,3 +448,27 @@ fn https_client() -> HttpsClient { Client::builder(TokioExecutor::new()).build(https) } + +async fn shutdown_signal() { + let ctrl_c = async { + signal::ctrl_c() + .await + .expect("failed to start SIGINT handler"); + }; + + let terminate = async { + signal::unix::signal(signal::unix::SignalKind::terminate()) + .expect("failed to start SIGTERM handler") + .recv() + .await; + }; + + tokio::select! { + () = ctrl_c => { + println!("Received SIGINT signal"); + }, + () = terminate => { + println!("Received SIGTERM signal"); + }, + } +} From 9f97371c8f18912b055dab022569bc506b9f0fae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augusto=20C=C3=A9sar?= Date: Mon, 30 Mar 2026 10:08:12 +0200 Subject: [PATCH 8/8] chore: reference correct source on HTTP errors --- worker/src/lib.rs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/worker/src/lib.rs b/worker/src/lib.rs index 8d12e559..6a92a4c0 100644 --- a/worker/src/lib.rs +++ b/worker/src/lib.rs @@ -208,7 +208,7 @@ async fn linkup_session_handler( Ok(conf) => conf, Err(e) => { return HttpError::new( - format!("Failed to parse server config: {} - local server", e), + format!("Failed to parse server config: {} - Worker", e), StatusCode::BAD_REQUEST, ) .into_response() @@ -245,7 +245,7 @@ async fn linkup_preview_handler( Ok(conf) => conf, Err(e) => { return HttpError::new( - format!("Failed to parse server config: {} - local server", e), + format!("Failed to parse server config: {} - Worker", e), StatusCode::BAD_REQUEST, ) .into_response() @@ -296,8 +296,7 @@ async fn linkup_request_handler( Ok(session) => session, Err(_) => { return HttpError::new( - "Linkup was unable to determine the session origin of the request. - Make sure your request includes a valid session ID in the referer or tracestate headers. - Local Server".to_string(), + "Linkup was unable to determine the session origin of the request.\nMake sure your request includes a valid session ID in the referer or tracestate headers. - Worker".to_string(), StatusCode::UNPROCESSABLE_ENTITY, ) .into_response() @@ -308,9 +307,7 @@ async fn linkup_request_handler( Some(result) => result, None => { return HttpError::new( - "The request belonged to a session, but there was no target for the request. - Check your routing rules in the linkup config for a match. - Local Server" - .to_string(), + "The request belonged to a session, but there was no target for the request.\nCheck your routing rules in the linkup config for a match. - Worker".to_string(), StatusCode::NOT_FOUND, ) .into_response()