diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6126e667..76003bd3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1831,6 +1831,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.18", + "tokio", "windows 0.62.2", "windows-acl", "windows-service", diff --git a/src-tauri/daemon/src/daemon.rs b/src-tauri/daemon/src/daemon.rs index 175177a0..ff94edb1 100644 --- a/src-tauri/daemon/src/daemon.rs +++ b/src-tauri/daemon/src/daemon.rs @@ -22,7 +22,7 @@ use defguard_client_proto::defguard::{ enterprise::posture::v2::DevicePostureData, }; use defguard_client_service_locations::ServiceLocationError; -#[cfg(windows)] +#[cfg(any(windows, target_os = "linux"))] use defguard_client_service_locations::ServiceLocationManager; #[cfg(not(target_os = "macos"))] use defguard_wireguard_rs::Kernel; @@ -57,6 +57,11 @@ pub(super) const DAEMON_SOCKET_PATH: &str = "/var/run/defguard.socket"; #[cfg(target_os = "linux")] pub(super) const DAEMON_SOCKET_GROUP: &str = "defguard"; +#[cfg(any(windows, target_os = "linux"))] +pub(crate) const SERVICE_LOCATION_CONNECT_RETRY_COUNT: u32 = 5; +#[cfg(any(windows, target_os = "linux"))] +pub(crate) const SERVICE_LOCATION_CONNECT_RETRY_DELAY: Duration = Duration::from_secs(30); + #[derive(Debug, thiserror::Error)] pub enum DaemonError { #[error(transparent)] @@ -84,7 +89,7 @@ pub(crate) struct DaemonService { wgapis: Arc>>, stats_period: Duration, stat_tasks: Arc>>>, - #[cfg(windows)] + #[cfg(any(windows, target_os = "linux"))] service_location_manager: Arc>, } @@ -92,13 +97,15 @@ impl DaemonService { #[must_use] pub fn new( config: &Config, - #[cfg(windows)] service_location_manager: Arc>, + #[cfg(any(windows, target_os = "linux"))] service_location_manager: Arc< + RwLock, + >, ) -> Self { Self { wgapis: Arc::new(RwLock::new(HashMap::new())), stats_period: Duration::from_secs(config.stats_period), stat_tasks: Arc::new(Mutex::new(HashMap::new())), - #[cfg(windows)] + #[cfg(any(windows, target_os = "linux"))] service_location_manager, } } @@ -178,7 +185,7 @@ pub(crate) fn setup_wgapi(ifname: &str) -> Result { impl DesktopDaemonService for DaemonService { type ReadInterfaceDataStream = InterfaceDataStream; - #[cfg(not(windows))] + #[cfg(all(not(windows), not(target_os = "linux")))] async fn save_service_locations( &self, _request: tonic::Request, @@ -187,7 +194,7 @@ impl DesktopDaemonService for DaemonService { Ok(Response::new(())) } - #[cfg(not(windows))] + #[cfg(all(not(windows), not(target_os = "linux")))] async fn delete_service_locations( &self, _request: tonic::Request, @@ -196,57 +203,29 @@ impl DesktopDaemonService for DaemonService { Ok(Response::new(())) } - #[cfg(windows)] + #[cfg(any(windows, target_os = "linux"))] async fn save_service_locations( &self, request: tonic::Request, ) -> Result, Status> { - debug!("Received a request to save service location"); + debug!("Received a request to save service locations"); let service_location = request.into_inner(); - match self - .service_location_manager - .clone() - .read() + self.service_location_manager + .write() .unwrap() .save_service_locations( service_location.service_locations.as_slice(), &service_location.instance_id, &service_location.private_key, - ) { - Ok(()) => { - debug!("Service location saved successfully"); - } - Err(e) => { - let msg = format!("Failed to save service location: {e}"); + ) + .map_err(|err| { + let msg = format!("Failed to save service locations: {err}"); error!(msg); - return Err(Status::internal(msg)); - } - } - - for saved_location in service_location.service_locations { - match self - .service_location_manager - .clone() - .write() - .unwrap() - .reset_service_location_state(&service_location.instance_id, &saved_location.pubkey) - { - Ok(()) => { - debug!( - "Service location '{}' state reset successfully", - saved_location.name - ); - } - Err(e) => { - error!( - "Failed to reset state for service location '{}': {e}", - saved_location.name - ); - } - } - } + Status::internal(msg) + })?; + debug!("Service locations saved successfully"); Ok(Response::new(())) } @@ -263,43 +242,33 @@ impl DesktopDaemonService for DaemonService { )) } - #[cfg(windows)] + #[cfg(any(windows, target_os = "linux"))] async fn delete_service_locations( &self, request: tonic::Request, ) -> Result, Status> { - debug!("Received a request to delete service location"); + debug!("Received a request to delete service locations"); let instance_id = request.into_inner().instance_id; - self.service_location_manager - .clone() - .write() - .unwrap() + let mut manager = self.service_location_manager.write().unwrap(); + manager .disconnect_service_locations_by_instance(&instance_id) .map_err(|err| { - let msg = format!("Failed to disconnect service location: {err}"); + let msg = format!("Failed to disconnect service locations: {err}"); error!(msg); Status::internal(msg) })?; - match self - .service_location_manager - .clone() - .read() - .unwrap() + manager .delete_all_service_locations_for_instance(&instance_id) - { - Ok(()) => { - debug!("Service location deleted successfully"); - Ok(Response::new(())) - } - Err(err) => { - error!("Failed to delete service location: {err}"); - Err(Status::internal(format!( - "Failed to delete service location: {err}" - ))) - } - } + .map_err(|err| { + let msg = format!("Failed to delete service locations: {err}"); + error!(msg); + Status::internal(msg) + })?; + + debug!("Service locations deleted successfully"); + Ok(Response::new(())) } async fn create_interface( @@ -586,7 +555,22 @@ impl DesktopDaemonService for DaemonService { pub async fn run_server(config: Config) -> anyhow::Result<()> { debug!("Starting Defguard interface management daemon"); - let daemon_service = DaemonService::new(&config); + #[cfg(target_os = "linux")] + let service_location_manager = Arc::new(RwLock::new(ServiceLocationManager::init()?)); + #[cfg(target_os = "linux")] + tokio::spawn( + defguard_client_service_locations::connect_service_locations( + service_location_manager.clone(), + SERVICE_LOCATION_CONNECT_RETRY_COUNT, + SERVICE_LOCATION_CONNECT_RETRY_DELAY, + ), + ); + + let daemon_service = DaemonService::new( + &config, + #[cfg(target_os = "linux")] + service_location_manager, + ); // Remove existing socket if it exists if Path::new(DAEMON_SOCKET_PATH).exists() { diff --git a/src-tauri/daemon/src/windows.rs b/src-tauri/daemon/src/windows.rs index e986982c..a453253b 100644 --- a/src-tauri/daemon/src/windows.rs +++ b/src-tauri/daemon/src/windows.rs @@ -10,7 +10,7 @@ use defguard_client_service_locations::{ windows::{watch_for_login_logoff, watch_for_network_change}, ServiceLocationError, ServiceLocationManager, }; -use tokio::{runtime::Runtime, time::sleep}; +use tokio::runtime::Runtime; use tracing::{error, info, warn}; use windows_service::{ define_windows_service, @@ -24,15 +24,16 @@ use windows_service::{ use crate::{ config::Config, - daemon::{run_server, DaemonError}, + daemon::{ + run_server, DaemonError, SERVICE_LOCATION_CONNECT_RETRY_COUNT, + SERVICE_LOCATION_CONNECT_RETRY_DELAY, + }, utils::logging_setup, }; static SERVICE_NAME: &str = "DefguardService"; const SERVICE_TYPE: ServiceType = ServiceType::OWN_PROCESS; const LOGIN_LOGOFF_MONITORING_RESTART_DELAY_SECS: Duration = Duration::from_secs(5); -const SERVICE_LOCATION_CONNECT_RETRY_COUNT: u32 = 5; -const SERVICE_LOCATION_CONNECT_RETRY_DELAY: Duration = Duration::from_secs(30); pub fn run() -> Result<(), windows_service::Error> { // Register generated `ffi_service_main` with the system and start the service, blocking @@ -127,52 +128,18 @@ fn run_service() -> Result<(), DaemonError> { }) .expect("Failed to spawn network change monitor thread"); - // Spawn service location auto-connect task with retries. - // Each attempt skips locations that are already connected, so it is safe to call - // connect_to_service_locations repeatedly. The retry loop exists to handle the case - // where the connection may fail initially at startup because the network - // (e.g. Wi-Fi) is not yet available (mainly DNS resolution issues), and serves as - // a backstop for any network events missed by the watcher above. - // If all locations connect successfully on a given attempt, no further retries are made. - let service_location_manager_connect = service_location_manager.clone(); - runtime.spawn(async move { - for attempt in 1..=SERVICE_LOCATION_CONNECT_RETRY_COUNT { - info!( - "Attempting to auto-connect to service locations \ - (attempt {attempt}/{SERVICE_LOCATION_CONNECT_RETRY_COUNT})" - ); - match service_location_manager_connect - .write() - .unwrap() - .connect_to_service_locations() - { - Ok(true) => { - info!( - "All service locations connected successfully \ - (attempt {attempt}/{SERVICE_LOCATION_CONNECT_RETRY_COUNT})" - ); - break; - } - Ok(false) => { - warn!( - "Auto-connect attempt {attempt}/{SERVICE_LOCATION_CONNECT_RETRY_COUNT} \ - completed with some failures" - ); - } - Err(err) => { - warn!( - "Auto-connect attempt {attempt}/{SERVICE_LOCATION_CONNECT_RETRY_COUNT} \ - failed: {err}" - ); - } - } - - if attempt < SERVICE_LOCATION_CONNECT_RETRY_COUNT { - sleep(SERVICE_LOCATION_CONNECT_RETRY_DELAY).await; - } - } - info!("Service location auto-connect task finished"); - }); + // Spawn the service location auto-connect task with retries. Each attempt skips locations + // that are already connected, so it is safe to call repeatedly. The retry loop handles the + // case where the connection fails initially at startup because the network (e.g. Wi-Fi) is + // not yet available (mainly DNS resolution issues), and serves as a backstop for any + // network events missed by the watcher above. + runtime.spawn( + defguard_client_service_locations::connect_service_locations( + service_location_manager.clone(), + SERVICE_LOCATION_CONNECT_RETRY_COUNT, + SERVICE_LOCATION_CONNECT_RETRY_DELAY, + ), + ); // Spawn login/logoff monitoring on a dedicated OS thread so the blocking // WTSWaitSystemEvent syscall does not stall Tokio's async worker threads. diff --git a/src-tauri/enterprise/config-sync/src/commands.rs b/src-tauri/enterprise/config-sync/src/commands.rs index f74fc8b4..17df0334 100644 --- a/src-tauri/enterprise/config-sync/src/commands.rs +++ b/src-tauri/enterprise/config-sync/src/commands.rs @@ -83,8 +83,6 @@ pub async fn do_update_instance( "A new base configuration has been applied to instance {instance}, even if nothing changed" ); - let mut service_locations = Vec::new(); - if locations_changed_val { debug!( "Updating locations for instance {}({}).", @@ -130,10 +128,9 @@ pub async fn do_update_instance( if saved_location.is_service_location() { debug!( - "Adding service location {}({}) for instance {}({}) to be saved to the daemon.", + "Location {}({}) for instance {}({}) is a service location.", saved_location.name, saved_location.id, instance.name, instance.id, ); - service_locations.push(to_service_location(&saved_location)?); } } @@ -149,6 +146,33 @@ pub async fn do_update_instance( info!("Locations for instance {instance} didn't change. Not updating them."); } + sync_service_locations(transaction, instance).await?; + + Ok(locations_changed_val) +} + +/// Synchronizes the daemon's persisted service-location state from the current database state. +/// +/// This is called after a real config update has been applied locally. It sends all currently +/// persisted service locations for the instance to the daemon, or asks the daemon to delete its +/// service-location state when none remain. +pub async fn sync_service_locations( + transaction: &mut Transaction<'_, Sqlite>, + instance: &Instance, +) -> Result<(), Error> { + let mut service_locations = Vec::new(); + let current_locations = + Location::find_by_instance_id(transaction.as_mut(), instance.id, true).await?; + for location in current_locations { + if location.is_service_location() { + debug!( + "Adding service location {}({}) for instance {}({}) to be saved to the daemon.", + location.name, location.id, instance.name, instance.id, + ); + service_locations.push(to_service_location(&location)?); + } + } + if service_locations.is_empty() { debug!( "No service locations for instance {}({}), removing all existing service locations.", @@ -232,7 +256,7 @@ pub async fn do_update_instance( } } - Ok(locations_changed_val) + Ok(()) } pub async fn disable_enterprise_features<'e, E>( diff --git a/src-tauri/enterprise/config-sync/src/lib.rs b/src-tauri/enterprise/config-sync/src/lib.rs index c1fd79cd..53da4ec5 100644 --- a/src-tauri/enterprise/config-sync/src/lib.rs +++ b/src-tauri/enterprise/config-sync/src/lib.rs @@ -171,7 +171,6 @@ pub async fn poll_instance( fetched.response.device_config.as_ref().ok_or_else(|| { Error::InternalError("Device config not present in response".to_string()) })?; - if !config_changed(transaction, instance, device_config).await? { debug!( "Config for instance {}({}) didn't change", diff --git a/src-tauri/enterprise/service-locations/Cargo.toml b/src-tauri/enterprise/service-locations/Cargo.toml index ba88f497..9d231fe8 100644 --- a/src-tauri/enterprise/service-locations/Cargo.toml +++ b/src-tauri/enterprise/service-locations/Cargo.toml @@ -19,6 +19,7 @@ prost = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } +tokio = { workspace = true, features = ["time"] } [target.'cfg(windows)'.dependencies] known-folders = "1.4" diff --git a/src-tauri/enterprise/service-locations/src/lib.rs b/src-tauri/enterprise/service-locations/src/lib.rs index 14e2d143..311094c2 100644 --- a/src-tauri/enterprise/service-locations/src/lib.rs +++ b/src-tauri/enterprise/service-locations/src/lib.rs @@ -1,4 +1,9 @@ use std::{collections::HashMap, fmt}; +#[cfg(any(windows, target_os = "linux"))] +use std::{ + sync::{Arc, RwLock}, + time::Duration, +}; use defguard_client_core::{ database::models::{ @@ -11,9 +16,13 @@ use defguard_client_proto::defguard::client::v1::{ ServiceLocation, ServiceLocationMode as ProtoServiceLocationMode, }; use defguard_wireguard_rs::{error::WireguardInterfaceError, WGApi}; +#[cfg(any(windows, target_os = "linux"))] +use log::info; use log::warn; use serde::{Deserialize, Serialize}; +#[cfg(target_os = "linux")] +pub mod linux; #[cfg(windows)] pub mod windows; @@ -122,3 +131,41 @@ pub fn to_service_location(location: &Location) -> Result>, + retry_count: u32, + retry_delay: Duration, +) { + for attempt in 1..=retry_count { + info!("Attempting to auto-connect service locations (attempt {attempt}/{retry_count})"); + match manager.write().unwrap().connect_to_service_locations() { + Ok(true) => { + info!( + "All service locations connected successfully (attempt {attempt}/{retry_count})" + ); + break; + } + Ok(false) => warn!( + "Service location auto-connect attempt {attempt}/{retry_count} completed with some \ + failures" + ), + Err(err) => { + warn!("Service location auto-connect attempt {attempt}/{retry_count} failed: {err}") + } + } + + if attempt < retry_count { + tokio::time::sleep(retry_delay).await; + } + } + + info!("Service location auto-connect task finished"); +} diff --git a/src-tauri/enterprise/service-locations/src/linux.rs b/src-tauri/enterprise/service-locations/src/linux.rs new file mode 100644 index 00000000..39b41be2 --- /dev/null +++ b/src-tauri/enterprise/service-locations/src/linux.rs @@ -0,0 +1,433 @@ +use std::{ + collections::HashSet, + ffi::OsStr, + fs::{self, create_dir_all, set_permissions}, + os::unix::fs::PermissionsExt, + path::PathBuf, + str::FromStr, +}; + +use defguard_client_common::{dns_borrow, find_free_tcp_port, get_interface_name}; +use defguard_client_proto::defguard::client::v1::{ServiceLocation, ServiceLocationMode}; +use defguard_wireguard_rs::{ + key::Key, net::IpAddrMask, peer::Peer, InterfaceConfiguration, WGApi, WireguardInterfaceApi, +}; +use log::{debug, error, warn}; + +use crate::{ServiceLocationData, ServiceLocationError, ServiceLocationManager}; + +const DEFGUARD_DIR: &str = "/etc/defguard"; +const SERVICE_LOCATIONS_SUBDIR: &str = "service_locations"; +const SERVICE_LOCATION_DIR_PERMS: u32 = 0o700; +const SERVICE_LOCATION_FILE_PERMS: u32 = 0o600; +const DEFAULT_WIREGUARD_PORT: u16 = 51820; + +fn get_shared_directory() -> PathBuf { + PathBuf::from(DEFGUARD_DIR).join(SERVICE_LOCATIONS_SUBDIR) +} + +fn get_instance_file_path(instance_id: &str) -> PathBuf { + get_shared_directory().join(format!("{instance_id}.json")) +} + +fn ensure_shared_directory() -> Result { + let path = get_shared_directory(); + create_dir_all(&path)?; + set_permissions( + &path, + fs::Permissions::from_mode(SERVICE_LOCATION_DIR_PERMS), + )?; + Ok(path) +} + +impl ServiceLocationManager { + pub fn init() -> Result { + debug!("Initializing Linux service location storage"); + ensure_shared_directory()?; + Ok(Self::default()) + } + + /// Persists Linux-supported service locations and resets their runtime connection state. + /// + /// Linux supports Always-on service locations only. Unsupported modes are filtered out before + /// storage, stale previously-saved locations are disconnected, and every saved Always-on location + /// is reset. All resets are attempted before returning an aggregate error. + pub fn save_service_locations( + &mut self, + service_locations: &[ServiceLocation], + instance_id: &str, + private_key: &str, + ) -> Result<(), ServiceLocationError> { + debug!( + "Received a request to save {} service location(s) for instance {instance_id}", + service_locations.len(), + ); + + debug!("Service locations to save: {service_locations:?}"); + let old_locations = self + .load_service_locations_for_instance(instance_id)? + .map_or_else(Vec::new, |data| data.service_locations); + let old_pubkeys = old_locations + .iter() + .map(|location| location.pubkey.clone()) + .collect::>(); + + let service_locations = service_locations + .iter() + .filter(|location| location.mode == ServiceLocationMode::AlwaysOn as i32) + .cloned() + .collect::>(); + let new_pubkeys = service_locations + .iter() + .map(|location| location.pubkey.clone()) + .collect::>(); + + let service_location_data = ServiceLocationData { + service_locations: service_locations.clone(), + instance_id: instance_id.to_string(), + private_key: private_key.to_string(), + }; + + ensure_shared_directory()?; + let instance_file_path = get_instance_file_path(instance_id); + let json = serde_json::to_string_pretty(&service_location_data)?; + + debug!( + "Writing service location data to file: {}", + instance_file_path.display() + ); + fs::write(&instance_file_path, json)?; + set_permissions( + &instance_file_path, + fs::Permissions::from_mode(SERVICE_LOCATION_FILE_PERMS), + )?; + + debug!("Service locations saved for instance {instance_id}"); + + for removed_pubkey in old_pubkeys.difference(&new_pubkeys) { + self.disconnect_service_location(instance_id, removed_pubkey)?; + } + + let mut reset_failed = false; + for location in &service_locations { + if let Err(err) = self.reset_service_location_state(instance_id, location, private_key) + { + warn!( + "Failed to reset Linux service location '{}' after saving: {err}", + location.name + ); + reset_failed = true; + } + } + + if reset_failed { + return Err(ServiceLocationError::InterfaceError(format!( + "Failed to connect one or more Linux service locations for instance {instance_id}" + ))); + } + + Ok(()) + } + + /// Reconnects one Linux always-on service location. + fn reset_service_location_state( + &mut self, + instance_id: &str, + location: &ServiceLocation, + private_key: &str, + ) -> Result<(), ServiceLocationError> { + debug!( + "Resetting Linux service location '{}' for instance {instance_id}", + location.name + ); + + self.disconnect_service_location(instance_id, &location.pubkey)?; + self.connect_service_location(instance_id, location, private_key)?; + + debug!( + "Linux service location '{}' state reset successfully", + location.name + ); + Ok(()) + } + + /// Records a service location as connected in the in-memory daemon state. + fn add_connected_service_location(&mut self, instance_id: &str, location: &ServiceLocation) { + self.connected_service_locations + .entry(instance_id.to_string()) + .or_default() + .push(location.clone()); + + debug!( + "Added connected Linux service location for instance '{instance_id}', location '{}'", + location.name + ); + } + + fn is_service_location_connected(&self, instance_id: &str, location_pubkey: &str) -> bool { + self.connected_service_locations + .get(instance_id) + .is_some_and(|locations| { + locations + .iter() + .any(|location| location.pubkey == location_pubkey) + }) + } + + pub fn disconnect_service_locations_by_instance( + &mut self, + instance_id: &str, + ) -> Result<(), ServiceLocationError> { + debug!("Disconnecting Linux service locations for instance {instance_id}"); + + let Some(locations) = self.connected_service_locations.remove(instance_id) else { + debug!("No connected Linux service locations found for instance {instance_id}"); + return Ok(()); + }; + + for location in locations { + let ifname = get_interface_name(&location.name); + debug!("Tearing down Linux service location interface: {ifname}"); + if let Some(wgapi) = self.wgapis.remove(&ifname) { + if let Err(err) = wgapi.remove_interface() { + error!("Failed to remove Linux service location interface {ifname}: {err}"); + } else { + debug!("Linux service location interface {ifname} removed successfully"); + } + } else { + debug!("Linux service location interface {ifname} was not tracked as connected"); + } + } + + Ok(()) + } + + fn disconnect_service_location( + &mut self, + instance_id: &str, + location_pubkey: &str, + ) -> Result<(), ServiceLocationError> { + let Some(locations) = self.connected_service_locations.get_mut(instance_id) else { + debug!("No connected Linux service locations found for instance {instance_id}"); + return Ok(()); + }; + + let Some(position) = locations + .iter() + .position(|location| location.pubkey == location_pubkey) + else { + debug!( + "Linux service location with pubkey {location_pubkey} for instance {instance_id} is not connected" + ); + return Ok(()); + }; + + let location = locations.remove(position); + if locations.is_empty() { + self.connected_service_locations.remove(instance_id); + } + + let ifname = get_interface_name(&location.name); + debug!("Tearing down Linux service location interface: {ifname}"); + if let Some(wgapi) = self.wgapis.remove(&ifname) { + if let Err(err) = wgapi.remove_interface() { + error!("Failed to remove Linux service location interface {ifname}: {err}"); + } else { + debug!("Linux service location interface {ifname} removed successfully"); + } + } else { + debug!("Linux service location interface {ifname} was not tracked as connected"); + } + + Ok(()) + } + + fn setup_service_location_interface( + &mut self, + location: &ServiceLocation, + private_key: &str, + ) -> Result<(), ServiceLocationError> { + let peer_key = Key::from_str(&location.pubkey)?; + let mut peer = Peer::new(peer_key); + peer.set_endpoint(&location.endpoint)?; + peer.persistent_keepalive_interval = location.keepalive_interval.try_into().ok(); + + for allowed_ip in location.allowed_ips.split(',').map(str::trim) { + if allowed_ip.is_empty() { + continue; + } + match IpAddrMask::from_str(allowed_ip) { + Ok(addr) => peer.allowed_ips.push(addr), + Err(err) => error!( + "Error parsing allowed IP {allowed_ip} while setting up Linux service location {}: {err}", + location.name + ), + } + } + + let addresses = location + .address + .split(',') + .map(str::trim) + .filter(|address| !address.is_empty()) + .map(IpAddrMask::from_str) + .collect::, _>>()?; + + let ifname = get_interface_name(&location.name); + let config = InterfaceConfiguration { + name: ifname.clone(), + prvkey: private_key.to_string(), + addresses, + port: find_free_tcp_port().unwrap_or(DEFAULT_WIREGUARD_PORT), + peers: vec![peer], + mtu: None, + fwmark: None, + }; + + let mut wgapi = WGApi::new(&ifname).map_err(|err| { + ServiceLocationError::InterfaceError(format!( + "Failed to setup Linux WireGuard API for interface {ifname}: {err}" + )) + })?; + + wgapi.create_interface()?; + let dns_config = Some(location.dns.clone()); + let (dns, search_domains) = dns_borrow(&dns_config); + debug!( + "Configuring Linux service location interface {ifname} with DNS: {dns:?} and search domains: {search_domains:?}" + ); + wgapi.configure_interface(&config)?; + wgapi.configure_dns(&dns, &search_domains)?; + self.wgapis.insert(ifname.clone(), wgapi); + + debug!("Linux service location interface {ifname} configured successfully"); + Ok(()) + } + + fn connect_service_location( + &mut self, + instance_id: &str, + location: &ServiceLocation, + private_key: &str, + ) -> Result<(), ServiceLocationError> { + if self.is_service_location_connected(instance_id, &location.pubkey) { + debug!( + "Skipping Linux service location '{}' because it's already connected", + location.name + ); + return Ok(()); + } + + self.setup_service_location_interface(location, private_key)?; + self.add_connected_service_location(instance_id, location); + debug!("Connected Linux service location '{}'", location.name); + Ok(()) + } + + /// Attempts to connect all persisted Linux always-on service locations. + /// + /// Returns `Ok(true)` when every supported location is connected or already connected, and + /// `Ok(false)` when at least one supported location failed so the caller can retry later. + pub fn connect_to_service_locations(&mut self) -> Result { + debug!("Attempting to auto-connect Linux Always-on service locations"); + + let data = self.load_service_locations()?; + let mut all_connected = true; + + for instance_data in data { + for location in instance_data.service_locations { + if location.mode != ServiceLocationMode::AlwaysOn as i32 { + debug!( + "Skipping Linux service location '{}' because only Always-on is supported", + location.name + ); + continue; + } + + if self.is_service_location_connected(&instance_data.instance_id, &location.pubkey) + { + debug!( + "Skipping Linux service location '{}' because it's already connected", + location.name + ); + continue; + } + + if let Err(err) = self.connect_service_location( + &instance_data.instance_id, + &location, + &instance_data.private_key, + ) { + warn!( + "Failed to setup Linux service location interface for '{}': {err:?}", + location.name + ); + all_connected = false; + } + } + } + + Ok(all_connected) + } + + pub fn delete_all_service_locations_for_instance( + &self, + instance_id: &str, + ) -> Result<(), ServiceLocationError> { + debug!("Deleting Linux service locations for instance {instance_id}"); + + let instance_file_path = get_instance_file_path(instance_id); + if instance_file_path.exists() { + fs::remove_file(&instance_file_path)?; + debug!("Deleted Linux service locations for instance {instance_id}"); + } else { + debug!("No Linux service location file found for instance {instance_id}"); + } + + Ok(()) + } + + #[allow(dead_code)] + /// Loads persisted service-location data for all Linux instances. + fn load_service_locations(&self) -> Result, ServiceLocationError> { + let base_dir = ensure_shared_directory()?; + let mut all_locations_data = Vec::new(); + + for entry in fs::read_dir(base_dir)? { + let entry = entry?; + let file_path = entry.path(); + + if file_path.is_file() && file_path.extension() == Some(OsStr::new("json")) { + match fs::read_to_string(&file_path) { + Ok(data) => match serde_json::from_str::(&data) { + Ok(locations_data) => all_locations_data.push(locations_data), + Err(err) => warn!( + "Failed to parse Linux service locations from file {}: {err}", + file_path.display() + ), + }, + Err(err) => warn!( + "Failed to read Linux service locations file {}: {err}", + file_path.display() + ), + } + } + } + + Ok(all_locations_data) + } + + /// Loads persisted service-location data for one Linux instance, if present. + fn load_service_locations_for_instance( + &self, + instance_id: &str, + ) -> Result, ServiceLocationError> { + let instance_file_path = get_instance_file_path(instance_id); + if !instance_file_path.exists() { + return Ok(None); + } + + let data = fs::read_to_string(instance_file_path)?; + Ok(Some(serde_json::from_str::(&data)?)) + } +} diff --git a/src-tauri/enterprise/service-locations/src/windows.rs b/src-tauri/enterprise/service-locations/src/windows.rs index 23c9dd18..3133ef4d 100644 --- a/src-tauri/enterprise/service-locations/src/windows.rs +++ b/src-tauri/enterprise/service-locations/src/windows.rs @@ -1,5 +1,5 @@ use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, ffi::OsStr, fs::{self, create_dir_all}, path::PathBuf, @@ -820,7 +820,7 @@ impl ServiceLocationManager { } pub fn save_service_locations( - &self, + &mut self, service_locations: &[ServiceLocation], instance_id: &str, private_key: &str, @@ -831,6 +831,17 @@ impl ServiceLocationManager { ); debug!("Service locations to save: {service_locations:?}"); + let old_locations = self + .load_service_locations_for_instance(instance_id)? + .map_or_else(Vec::new, |data| data.service_locations); + let old_pubkeys = old_locations + .iter() + .map(|location| location.pubkey.clone()) + .collect::>(); + let new_pubkeys = service_locations + .iter() + .map(|location| location.pubkey.clone()) + .collect::>(); create_dir_all(get_shared_directory()?)?; @@ -869,6 +880,36 @@ impl ServiceLocationManager { "Service locations saved successfully for instance {instance_id} to {}", instance_file_path.display() ); + + for removed_pubkey in old_pubkeys.difference(&new_pubkeys) { + self.disconnect_service_location(instance_id, removed_pubkey)?; + } + + let mut reset_failed = false; + for saved_location in service_locations { + match self.reset_service_location_state(instance_id, &saved_location.pubkey) { + Ok(()) => { + debug!( + "Service location '{}' state reset successfully", + saved_location.name + ); + } + Err(err) => { + error!( + "Failed to reset state for service location '{}': {err}", + saved_location.name + ); + reset_failed = true; + } + } + } + + if reset_failed { + return Err(ServiceLocationError::InterfaceError(format!( + "Failed to reset one or more service locations for instance {instance_id}" + ))); + } + Ok(()) } @@ -949,6 +990,19 @@ impl ServiceLocationManager { } } + fn load_service_locations_for_instance( + &self, + instance_id: &str, + ) -> Result, ServiceLocationError> { + let instance_file_path = get_instance_file_path(instance_id)?; + if !instance_file_path.exists() { + return Ok(None); + } + + let data = fs::read_to_string(instance_file_path)?; + Ok(Some(serde_json::from_str::(&data)?)) + } + pub fn delete_all_service_locations_for_instance( &self, instance_id: &str,