//! Desktop adapter for the embedded HTTP server lifecycle and exposure config. //! //! This module stays in the Tauri adapter layer: it owns persisted exposure //! settings, starts/stops the embedded web server, and never crosses into the //! domain/application layers. use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use backend::BackendCore; use serde::{Deserialize, Serialize}; use web_server::{EmbeddedServerHandle, ServerConfig, TrustedProxy}; use crate::dto::ErrorDto; /// Remote exposure mode selected by the desktop settings UI. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum ServerExposureMode { /// Bind only loopback and serve local HTTP. LocalOnly, /// Bind loopback and expect a same-machine HTTPS reverse proxy. RemoteProxyLocal, /// Bind a LAN address and expect a trusted reverse proxy on another host. RemoteProxyOtherMachine, } /// Persisted server exposure settings. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ServerExposureSettingsDto { /// Exposure mode. pub mode: ServerExposureMode, /// TCP port to bind. `0` asks the OS for an ephemeral port. pub port: u16, /// Public HTTPS origin used by reverse-proxy modes. pub public_origin: Option, /// Authorized proxy peers as IPs or CIDR ranges. #[serde(default)] pub trusted_proxies: Vec, /// LAN bind address for `remoteProxyOtherMachine`. pub lan_bind_address: Option, } /// Non-fatal diagnostic shown by the settings UI. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DiagnosticWarningDto { /// Stable warning code. pub code: String, /// Human-readable warning. pub message: String, } /// Derived exposure preview for the desktop UI. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ServerExposurePreviewDto { /// Candidate LAN addresses discovered by the backend. pub candidate_lan_addresses: Vec, /// URL the reverse proxy should use as its upstream. pub upstream_url: Option, /// Non-fatal diagnostics. pub warnings: Vec, } /// Current embedded server lifecycle state exposed to the desktop UI. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum EmbeddedServerStatusStateDto { /// No embedded server is running. Stopped, /// Start is in progress. Starting, /// Server is running. Running, /// Stop is in progress. Stopping, /// Last start/stop failed. Failed, } /// Embedded server status exposed to the desktop UI. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EmbeddedServerStatusDto { /// Lifecycle state. pub state: EmbeddedServerStatusStateDto, /// Local URL, when running. pub local_url: Option, /// Public URL, when configured. pub public_url: Option, /// Reverse-proxy upstream URL derived from settings. pub upstream_url: Option, /// Runtime pairing code, never persisted. pub pairing_code: Option, /// Last failure, when state is `failed`. pub error: Option, } #[derive(Default)] struct EmbeddedServerInner { handle: Option, state: EmbeddedServerStatusStateDto, upstream_url: Option, public_url: Option, error: Option, } impl Default for EmbeddedServerStatusStateDto { fn default() -> Self { Self::Stopped } } /// Filesystem-backed exposure settings store. #[derive(Debug, Clone)] pub struct FsServerExposureSettingsStore { path: PathBuf, } impl FsServerExposureSettingsStore { /// Creates a store at `/deployment/server-exposure.json`. #[must_use] pub fn new(app_data_dir: PathBuf) -> Self { Self { path: app_data_dir.join("deployment").join("server-exposure.json"), } } /// Reads persisted settings, or returns defaults when no file exists. /// /// # Errors /// Returns an [`ErrorDto`] when the file exists but is invalid. pub fn read(&self) -> Result { if !self.path.exists() { return Ok(default_settings()); } let bytes = std::fs::read(&self.path).map_err(io_error)?; let settings = serde_json::from_slice::(&bytes) .map_err(|err| invalid_error(format!("invalid server exposure settings: {err}")))?; validate_settings(&settings)?; Ok(settings) } /// Validates and atomically writes settings with user-only permissions when /// the platform supports them. /// /// # Errors /// Returns an [`ErrorDto`] when validation or persistence fails. pub fn write(&self, settings: &ServerExposureSettingsDto) -> Result<(), ErrorDto> { validate_settings(settings)?; let bytes = serde_json::to_vec_pretty(settings).map_err(|err| ErrorDto { code: "INTERNAL".to_owned(), message: err.to_string(), })?; write_atomic_user_only(&self.path, &bytes).map_err(io_error) } } /// Desktop-owned embedded server manager. pub struct EmbeddedServerController { app_data_dir: PathBuf, resource_dir: Option, store: FsServerExposureSettingsStore, inner: Mutex, } impl EmbeddedServerController { /// Creates the controller. #[must_use] pub fn new(app_data_dir: PathBuf) -> Self { Self::with_resource_dir(app_data_dir, None) } /// Creates the controller with the Tauri bundle resource directory. #[must_use] pub fn with_resource_dir(app_data_dir: PathBuf, resource_dir: Option) -> Self { Self { store: FsServerExposureSettingsStore::new(app_data_dir.clone()), app_data_dir, resource_dir, inner: Mutex::new(EmbeddedServerInner::default()), } } /// Returns the current status. #[must_use] pub fn status(&self) -> EmbeddedServerStatusDto { let inner = self.inner.lock().expect("embedded server mutex poisoned"); status_from_inner(&inner) } /// Reads persisted exposure settings. /// /// # Errors /// Returns an [`ErrorDto`] if persisted settings are invalid. pub fn get_settings(&self) -> Result { self.store.read() } /// Persists exposure settings. /// /// # Errors /// Returns an [`ErrorDto`] if settings are invalid or cannot be written. pub fn save_settings(&self, settings: ServerExposureSettingsDto) -> Result<(), ErrorDto> { self.store.write(&settings) } /// Builds a preview for the provided settings without starting the server. /// /// # Errors /// Returns an [`ErrorDto`] if settings are invalid. pub fn preview( &self, settings: ServerExposureSettingsDto, ) -> Result { validate_settings(&settings)?; Ok(preview_settings(&settings)) } /// Starts the embedded server. Idempotent when already running/starting. /// /// # Errors /// Returns an [`ErrorDto`] if settings are invalid or the listener fails. pub async fn start(&self, core: Arc) -> Result { { let mut inner = self.inner.lock().expect("embedded server mutex poisoned"); if inner.handle.is_some() || inner.state == EmbeddedServerStatusStateDto::Starting { return Ok(status_from_inner(&inner)); } inner.state = EmbeddedServerStatusStateDto::Starting; inner.error = None; } let settings = match self.store.read() { Ok(settings) => settings, Err(err) => { self.mark_failed(err.clone()); return Err(err); } }; let preview = preview_settings(&settings); let config = match server_config_from_settings( &settings, self.app_data_dir.clone(), resolve_web_root(self.resource_dir.as_deref())?, ) { Ok(config) => config, Err(err) => { self.mark_failed(err.clone()); return Err(err); } }; match web_server::run_embedded_with_core(config, core).await { Ok(handle) => { let status = { let mut inner = self.inner.lock().expect("embedded server mutex poisoned"); inner.public_url = settings.public_origin.clone(); inner.upstream_url = preview.upstream_url; inner.state = EmbeddedServerStatusStateDto::Running; inner.error = None; inner.handle = Some(handle); status_from_inner(&inner) }; Ok(status) } Err(message) => { let err = ErrorDto { code: "PROCESS".to_owned(), message, }; self.mark_failed(err.clone()); Err(err) } } } /// Stops the embedded server. Idempotent when already stopped. /// /// # Errors /// Returns an [`ErrorDto`] when stopping the listener task fails. pub async fn stop(&self) -> Result { let handle = { let mut inner = self.inner.lock().expect("embedded server mutex poisoned"); let Some(handle) = inner.handle.take() else { inner.state = EmbeddedServerStatusStateDto::Stopped; inner.public_url = None; inner.upstream_url = None; return Ok(status_from_inner(&inner)); }; inner.state = EmbeddedServerStatusStateDto::Stopping; handle }; if let Err(message) = handle.stop().await { let err = ErrorDto { code: "PROCESS".to_owned(), message, }; self.mark_failed(err.clone()); return Err(err); } let mut inner = self.inner.lock().expect("embedded server mutex poisoned"); inner.state = EmbeddedServerStatusStateDto::Stopped; inner.public_url = None; inner.upstream_url = None; inner.error = None; Ok(status_from_inner(&inner)) } fn mark_failed(&self, err: ErrorDto) { let mut inner = self.inner.lock().expect("embedded server mutex poisoned"); inner.handle = None; inner.state = EmbeddedServerStatusStateDto::Failed; inner.public_url = None; inner.upstream_url = None; inner.error = Some(err); } } fn status_from_inner(inner: &EmbeddedServerInner) -> EmbeddedServerStatusDto { let error = (inner.state == EmbeddedServerStatusStateDto::Failed) .then(|| inner.error.clone()) .flatten(); EmbeddedServerStatusDto { state: inner.state, local_url: inner.handle.as_ref().map(|handle| handle.url().to_owned()), public_url: inner.public_url.clone(), upstream_url: inner.upstream_url.clone(), pairing_code: inner .handle .as_ref() .map(|handle| handle.pairing_code().to_owned()), error, } } fn default_settings() -> ServerExposureSettingsDto { ServerExposureSettingsDto { mode: ServerExposureMode::LocalOnly, port: 17373, public_origin: None, trusted_proxies: Vec::new(), lan_bind_address: None, } } fn validate_settings(settings: &ServerExposureSettingsDto) -> Result<(), ErrorDto> { match settings.mode { ServerExposureMode::LocalOnly => {} ServerExposureMode::RemoteProxyLocal => { let origin = require_https_origin(settings)?; validate_origin_shape(origin)?; } ServerExposureMode::RemoteProxyOtherMachine => { let origin = require_https_origin(settings)?; validate_origin_shape(origin)?; let Some(addr) = &settings.lan_bind_address else { return Err(invalid_error( "remoteProxyOtherMachine requires lanBindAddress", )); }; let ip = parse_ip(addr, "lanBindAddress")?; if ip.is_loopback() || ip.is_unspecified() { return Err(invalid_error( "remoteProxyOtherMachine requires a concrete LAN bind address", )); } if settings.trusted_proxies.is_empty() { return Err(invalid_error( "remoteProxyOtherMachine requires at least one trustedProxies entry", )); } } } for proxy in &settings.trusted_proxies { TrustedProxy::parse(proxy).map_err(invalid_error)?; } Ok(()) } fn require_https_origin(settings: &ServerExposureSettingsDto) -> Result<&str, ErrorDto> { let Some(origin) = settings.public_origin.as_deref() else { return Err(invalid_error("remote exposure requires publicOrigin")); }; if !origin.starts_with("https://") { return Err(invalid_error( "remote exposure requires publicOrigin to start with https://", )); } Ok(origin) } fn validate_origin_shape(origin: &str) -> Result<(), ErrorDto> { if origin == "*" || origin.contains('*') { return Err(invalid_error("publicOrigin must be exact, not a wildcard")); } if origin.ends_with('/') { return Err(invalid_error("publicOrigin must not end with '/'")); } Ok(()) } fn server_config_from_settings( settings: &ServerExposureSettingsDto, app_data_dir: PathBuf, web_root: PathBuf, ) -> Result { validate_settings(settings)?; let listen_ip = match settings.mode { ServerExposureMode::LocalOnly | ServerExposureMode::RemoteProxyLocal => { IpAddr::V4(Ipv4Addr::LOCALHOST) } ServerExposureMode::RemoteProxyOtherMachine => parse_ip( settings.lan_bind_address.as_deref().unwrap_or_default(), "lanBindAddress", )?, }; let allow_remote = settings.mode != ServerExposureMode::LocalOnly; let trusted_proxies = settings .trusted_proxies .iter() .map(|proxy| TrustedProxy::parse(proxy).map_err(invalid_error)) .collect::, _>>()?; let config = ServerConfig { listen: SocketAddr::new(listen_ip, settings.port), public_origin: allow_remote .then(|| settings.public_origin.clone()) .flatten(), allow_remote, trust_reverse_proxy: allow_remote, trusted_proxies, app_data_dir, web_root, }; config.validate().map_err(invalid_error)?; Ok(config) } fn preview_settings(settings: &ServerExposureSettingsDto) -> ServerExposurePreviewDto { let candidate_lan_addresses = candidate_lan_addresses(); let bind_address = match settings.mode { ServerExposureMode::LocalOnly | ServerExposureMode::RemoteProxyLocal => { Some(Ipv4Addr::LOCALHOST.to_string()) } ServerExposureMode::RemoteProxyOtherMachine => settings.lan_bind_address.clone(), }; let upstream_url = bind_address.map(|addr| format!("http://{addr}:{}", settings.port)); let upstream_url = (settings.mode != ServerExposureMode::LocalOnly) .then_some(upstream_url) .flatten(); let mut warnings = Vec::new(); if settings.mode == ServerExposureMode::RemoteProxyOtherMachine && settings.trusted_proxies.is_empty() { warnings.push(DiagnosticWarningDto { code: "missingTrustedProxy".to_owned(), message: "Add the IP address or CIDR of the reverse proxy that connects to this server." .to_owned(), }); } ServerExposurePreviewDto { candidate_lan_addresses, upstream_url, warnings, } } fn candidate_lan_addresses() -> Vec { let mut addresses = Vec::new(); if let Ok(socket) = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)) { if socket.connect((Ipv4Addr::new(192, 0, 2, 1), 80)).is_ok() { if let Ok(addr) = socket.local_addr() { if let IpAddr::V4(ip) = addr.ip() { if !ip.is_loopback() && !ip.is_unspecified() { addresses.push(ip.to_string()); } } } } } addresses.sort(); addresses.dedup(); addresses } fn parse_ip(value: &str, field: &str) -> Result { value .parse::() .map_err(|_| invalid_error(format!("{field} must be an IP address"))) } fn resolve_web_root(resource_dir: Option<&Path>) -> Result { let explicit_web_root = std::env::var_os("IDEA_WEB_ROOT").map(PathBuf::from); let candidates = web_root_candidates(explicit_web_root, resource_dir); first_web_root(candidates).ok_or_else(|| { invalid_error("web assets not found: build frontend/dist or set IDEA_WEB_ROOT") }) } fn web_root_candidates( explicit_web_root: Option, resource_dir: Option<&Path>, ) -> Vec { let mut candidates = Vec::new(); if let Some(path) = explicit_web_root { candidates.push(path); } if let Some(resource_dir) = resource_dir { candidates.push(resource_dir.join("web")); } if let Ok(exe) = std::env::current_exe() { if let Some(dir) = exe.parent() { candidates.push(dir.join("web")); candidates.push(dir.join("frontend").join("dist")); } } if let Ok(cwd) = std::env::current_dir() { candidates.push(cwd.join("frontend").join("dist")); } candidates } fn first_web_root(candidates: impl IntoIterator) -> Option { candidates .into_iter() .find(|candidate| candidate.join("index.html").is_file()) } fn write_atomic_user_only(path: &Path, bytes: &[u8]) -> std::io::Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } let tmp = path.with_extension("json.tmp"); std::fs::write(&tmp, bytes)?; set_user_only_permissions(&tmp)?; std::fs::rename(tmp, path) } #[cfg(unix)] fn set_user_only_permissions(path: &Path) -> std::io::Result<()> { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) } #[cfg(not(unix))] fn set_user_only_permissions(_path: &Path) -> std::io::Result<()> { Ok(()) } fn invalid_error(message: impl Into) -> ErrorDto { ErrorDto { code: "INVALID".to_owned(), message: message.into(), } } fn io_error(err: std::io::Error) -> ErrorDto { ErrorDto { code: "STORE".to_owned(), message: err.to_string(), } } #[cfg(test)] mod tests { use super::*; use uuid::Uuid; struct EnvVarGuard { key: &'static str, previous: Option, } impl EnvVarGuard { fn set(key: &'static str, value: &Path) -> Self { let previous = std::env::var_os(key); std::env::set_var(key, value); Self { key, previous } } } impl Drop for EnvVarGuard { fn drop(&mut self) { if let Some(previous) = &self.previous { std::env::set_var(self.key, previous); } else { std::env::remove_var(self.key); } } } fn tmp_app_data() -> PathBuf { std::env::temp_dir().join(format!("idea-embedded-server-test-{}", Uuid::new_v4())) } fn tmp_web_root() -> PathBuf { let web_root = std::env::temp_dir().join(format!("idea-embedded-server-web-root-{}", Uuid::new_v4())); std::fs::create_dir_all(&web_root).unwrap(); std::fs::write( web_root.join("index.html"), "IdeA", ) .unwrap(); web_root } #[test] fn web_root_candidates_keep_packaged_resource_before_exe_fallbacks() { let explicit = tmp_app_data().join("explicit-web-root"); let resource_dir = tmp_app_data().join("resources"); let candidates = web_root_candidates(Some(explicit.clone()), Some(&resource_dir)); assert_eq!(candidates[0], explicit); assert_eq!(candidates[1], resource_dir.join("web")); } #[test] fn first_web_root_uses_first_candidate_with_index_html() { let missing = tmp_app_data().join("missing-web-root"); let resource_dir = tmp_app_data().join("resources"); let resource_web = resource_dir.join("web"); std::fs::create_dir_all(&resource_web).unwrap(); std::fs::write( resource_web.join("index.html"), "Packaged", ) .unwrap(); let later = tmp_web_root(); let resolved = first_web_root([missing, resource_web.clone(), later]) .expect("web root should resolve"); assert_eq!(resolved, resource_web); } #[test] fn non_loopback_remote_requires_trusted_proxy() { let settings = ServerExposureSettingsDto { mode: ServerExposureMode::RemoteProxyOtherMachine, port: 17373, public_origin: Some("https://idea.example.com".to_owned()), trusted_proxies: Vec::new(), lan_bind_address: Some("192.0.2.75".to_owned()), }; let err = validate_settings(&settings).unwrap_err(); assert_eq!(err.code, "INVALID"); assert!(err.message.contains("trustedProxies")); } #[test] fn remote_requires_https_public_origin() { let settings = ServerExposureSettingsDto { mode: ServerExposureMode::RemoteProxyLocal, port: 17373, public_origin: Some("http://idea.example.com".to_owned()), trusted_proxies: Vec::new(), lan_bind_address: None, }; let err = validate_settings(&settings).unwrap_err(); assert_eq!(err.code, "INVALID"); assert!(err.message.contains("https://")); } #[test] fn local_only_derives_loopback_config() { let settings = ServerExposureSettingsDto { mode: ServerExposureMode::LocalOnly, port: 0, public_origin: Some("https://ignored.example".to_owned()), trusted_proxies: Vec::new(), lan_bind_address: None, }; let config = server_config_from_settings(&settings, tmp_app_data(), tmp_app_data()).unwrap(); assert_eq!(config.listen, "127.0.0.1:0".parse().unwrap()); assert!(!config.allow_remote); assert_eq!(config.public_origin, None); } #[test] fn remote_proxy_local_accepts_loopback_ephemeral_port() { let settings = ServerExposureSettingsDto { mode: ServerExposureMode::RemoteProxyLocal, port: 0, public_origin: Some("https://idea.example.com".to_owned()), trusted_proxies: Vec::new(), lan_bind_address: None, }; let config = server_config_from_settings(&settings, tmp_app_data(), tmp_app_data()).unwrap(); assert_eq!(config.listen, "127.0.0.1:0".parse().unwrap()); assert!(config.allow_remote); assert!(config.trusted_proxies.is_empty()); } #[test] fn store_round_trips_without_pairing_code() { let store = FsServerExposureSettingsStore::new(tmp_app_data()); let settings = ServerExposureSettingsDto { mode: ServerExposureMode::RemoteProxyOtherMachine, port: 17373, public_origin: Some("https://idea.example.com".to_owned()), trusted_proxies: vec!["192.0.2.22".to_owned()], lan_bind_address: Some("192.0.2.75".to_owned()), }; store.write(&settings).unwrap(); let read = store.read().unwrap(); assert_eq!(read, settings); } #[tokio::test] async fn stop_is_idempotent_when_already_stopped() { let controller = EmbeddedServerController::new(tmp_app_data()); let first = controller.stop().await.unwrap(); let second = controller.stop().await.unwrap(); assert!(matches!(first.state, EmbeddedServerStatusStateDto::Stopped)); assert!(matches!( second.state, EmbeddedServerStatusStateDto::Stopped )); } #[tokio::test] async fn start_is_idempotent_and_stop_stops_running_server() { let app_data = tmp_app_data(); let web_root = tmp_web_root(); let _env = EnvVarGuard::set("IDEA_WEB_ROOT", &web_root); let controller = EmbeddedServerController::new(app_data.clone()); controller .save_settings(ServerExposureSettingsDto { mode: ServerExposureMode::LocalOnly, port: 0, public_origin: None, trusted_proxies: Vec::new(), lan_bind_address: None, }) .unwrap(); let core = Arc::new(BackendCore::build(app_data)); let first = controller.start(Arc::clone(&core)).await.unwrap(); let second = controller.start(core).await.unwrap(); assert!(matches!(first.state, EmbeddedServerStatusStateDto::Running)); assert!(matches!( second.state, EmbeddedServerStatusStateDto::Running )); assert_eq!(first.local_url, second.local_url); assert!(first .local_url .as_deref() .is_some_and(|url| url.starts_with("http://127.0.0.1:"))); assert_ne!(first.local_url.as_deref(), Some("http://127.0.0.1:0")); assert_eq!(first.pairing_code, second.pairing_code); let stopped = controller.stop().await.unwrap(); assert!(matches!( stopped.state, EmbeddedServerStatusStateDto::Stopped )); assert!(stopped.local_url.is_none()); assert!(stopped.pairing_code.is_none()); } #[tokio::test] async fn start_with_invalid_persisted_settings_fails_closed() { let app_data = tmp_app_data(); let controller = EmbeddedServerController::new(app_data.clone()); let bad = ServerExposureSettingsDto { mode: ServerExposureMode::RemoteProxyLocal, port: 17373, public_origin: Some("http://idea.example.com".to_owned()), trusted_proxies: Vec::new(), lan_bind_address: None, }; let path = app_data.join("deployment").join("server-exposure.json"); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(&path, serde_json::to_vec(&bad).unwrap()).unwrap(); let core = Arc::new(BackendCore::build(app_data)); let err = controller.start(core).await.unwrap_err(); let status = controller.status(); assert_eq!(err.code, "INVALID"); assert!(matches!(status.state, EmbeddedServerStatusStateDto::Failed)); assert!(status.pairing_code.is_none()); } }