From f7ab448f0016a97394ea95efb766da831da51148 Mon Sep 17 00:00:00 2001 From: DjDeveloperr Date: Wed, 17 Jun 2026 10:45:09 -0400 Subject: [PATCH] fix: harden service credential lifecycle --- packages/server/src/main.rs | 312 +++++++++++++++++++-- packages/server/src/simulators/registry.rs | 14 + packages/server/src/simulators/session.rs | 41 +++ 3 files changed, 344 insertions(+), 23 deletions(-) diff --git a/packages/server/src/main.rs b/packages/server/src/main.rs index de931088..c5b8f5fa 100644 --- a/packages/server/src/main.rs +++ b/packages/server/src/main.rs @@ -123,6 +123,9 @@ const SERVER_HEALTH_WATCHDOG_PROBE_TIMEOUT: Duration = Duration::from_secs(3); const SERVER_HEALTH_WATCHDOG_STALE_HEARTBEAT: Duration = Duration::from_secs(60); const SERVER_HEALTH_WATCHDOG_FAILURE_THRESHOLD: usize = 12; const SERVER_HEALTH_WATCHDOG_HTTP_FAILURE_THRESHOLD: usize = 3; +const SIMULATOR_SESSION_IDLE_TIMEOUT: Duration = Duration::from_secs(5 * 60); +const SIMULATOR_SESSION_IDLE_REAPER_INITIAL_DELAY: Duration = Duration::from_secs(60); +const SIMULATOR_SESSION_IDLE_REAPER_INTERVAL: Duration = Duration::from_secs(30); const SERVICE_PORT: u16 = 4310; const ORPHAN_WORKSPACE_SERVICE_SHUTDOWN_GRACE: Duration = Duration::from_millis(250); const ORPHAN_WORKSPACE_SERVICE_KILL_GRACE: Duration = Duration::from_millis(250); @@ -1011,6 +1014,13 @@ struct ServiceLaunchOptions { local_stream_fps: Option, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct ProjectServiceCredentials { + access_token: String, + pairing_code: String, +} + struct StudioExposeOptions { simulator: Option, studio_url: String, @@ -1269,6 +1279,7 @@ fn ensure_singleton_service_with_status( options: ServiceLaunchOptions, ) -> anyhow::Result<(ServiceMetadata, bool)> { let active = service::active()?; + let mut preserved_credentials = None; if let Some(result) = active.as_ref() { let metadata = metadata_from_launch_agent(result.clone())?; if service_is_healthy(&metadata) @@ -1284,6 +1295,7 @@ fn ensure_singleton_service_with_status( if healthy && same_binary && service_matches_launch_options(&metadata, &options) { return Ok((metadata, false)); } + preserved_credentials = project_service_credentials_from_metadata(&metadata); let active_on_metadata_port = active .as_ref() .is_some_and(|result| result.port == metadata.port); @@ -1297,7 +1309,10 @@ fn ensure_singleton_service_with_status( ); } } - Ok((start_project_service(options)?, true)) + Ok(( + start_project_service_with_credentials(options, preserved_credentials)?, + true, + )) } fn ensure_launch_agent_service(options: ServiceLaunchOptions) -> anyhow::Result { @@ -1324,7 +1339,10 @@ fn ensure_launch_agent_service(options: ServiceLaunchOptions) -> anyhow::Result< Ok(metadata) } -fn start_project_service(options: ServiceLaunchOptions) -> anyhow::Result { +fn start_project_service_with_credentials( + options: ServiceLaunchOptions, + preserved_credentials: Option, +) -> anyhow::Result { let project_root = project_root()?; let metadata_path = service_metadata_path()?; let log_path = service_log_path()?; @@ -1337,8 +1355,10 @@ fn start_project_service(options: ServiceLaunchOptions) -> anyhow::Result Option { + let access_token = metadata.access_token.trim(); + if access_token.is_empty() { + return None; + } + let pairing_code = metadata + .pairing_code + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .unwrap_or_else(auth::generate_pairing_code); + Some(ProjectServiceCredentials { + access_token: access_token.to_owned(), + pairing_code, + }) +} + +fn project_service_credentials_for_start( + preserved_credentials: Option, +) -> anyhow::Result { + let credentials = project_service_credentials_for_start_at_path( + preserved_credentials, + &project_service_credentials_path(), + )?; + Ok(credentials) +} + +fn project_service_credentials_for_start_at_path( + preserved_credentials: Option, + path: &Path, +) -> anyhow::Result { + let credentials = match preserved_credentials { + Some(credentials) => credentials, + None => read_project_service_credentials_from_path(path)? + .unwrap_or_else(|| new_project_service_credentials(None)), + }; + let credentials = normalize_project_service_credentials(credentials) + .unwrap_or_else(|| new_project_service_credentials(None)); + write_project_service_credentials_to_path(path, &credentials)?; + Ok(credentials) +} + +fn reset_project_service_credentials( + access_token: Option, +) -> anyhow::Result { + let credentials = new_project_service_credentials(access_token); + write_project_service_credentials_to_path(&project_service_credentials_path(), &credentials)?; + Ok(credentials) +} + +fn new_project_service_credentials(access_token: Option) -> ProjectServiceCredentials { + ProjectServiceCredentials { + access_token: access_token + .map(|token| token.trim().to_owned()) + .filter(|token| !token.is_empty()) + .unwrap_or_else(auth::generate_access_token), + pairing_code: auth::generate_pairing_code(), + } +} + +fn read_project_service_credentials_from_path( + path: &Path, +) -> anyhow::Result> { + if !path.exists() { + return Ok(None); + } + let data = fs::read_to_string(path) + .with_context(|| format!("read service credentials {}", path.display()))?; + let credentials = serde_json::from_str::(&data) + .with_context(|| format!("parse service credentials {}", path.display()))?; + Ok(normalize_project_service_credentials(credentials)) +} + +fn normalize_project_service_credentials( + credentials: ProjectServiceCredentials, +) -> Option { + let access_token = credentials.access_token.trim(); + let pairing_code = credentials.pairing_code.trim(); + if access_token.is_empty() || pairing_code.is_empty() { + return None; + } + Some(ProjectServiceCredentials { + access_token: access_token.to_owned(), + pairing_code: pairing_code.to_owned(), + }) +} + +fn write_project_service_credentials_to_path( + path: &Path, + credentials: &ProjectServiceCredentials, +) -> anyhow::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| { + format!("create service credentials directory {}", parent.display()) + })?; + } + fs::write(path, serde_json::to_vec_pretty(credentials)?) + .with_context(|| format!("write service credentials {}", path.display())) +} + +fn project_service_credentials_path() -> PathBuf { + simdeck_user_state_dir().join("service-credentials.json") +} + fn stop_project_service() -> anyhow::Result<()> { let active = service::active()?; let Some(metadata) = read_service_metadata()? else { @@ -2706,11 +2833,6 @@ fn run_default_service(options: DefaultServiceLaunchOptions) -> anyhow::Result<( print_service_metadata_result(&metadata, selector.as_deref(), false, started) } -fn start_detached_service(options: ServiceLaunchOptions) -> anyhow::Result<()> { - let (metadata, started) = ensure_project_service_with_status(options)?; - print_service_start_result(&metadata, started) -} - fn restart_detached_service(options: ServiceLaunchOptions) -> anyhow::Result<()> { if service::active()?.is_some() { return service::restart(ServiceOptions { @@ -2727,10 +2849,15 @@ fn restart_detached_service(options: ServiceLaunchOptions) -> anyhow::Result<()> pairing_code: None, }); } - if let Some(metadata) = read_service_metadata()? { + let preserved_credentials = if let Some(metadata) = read_service_metadata()? { + let credentials = project_service_credentials_from_metadata(&metadata); terminate_service_metadata(&metadata)?; - } - start_detached_service(options) + credentials + } else { + None + }; + let metadata = start_project_service_with_credentials(options, preserved_credentials)?; + print_service_start_result(&metadata, true) } fn service_restart_port(explicit_port: Option) -> anyhow::Result { @@ -3787,6 +3914,7 @@ fn main() -> anyhow::Result<()> { access_token, } => { cleanup_orphaned_workspace_services_for_root(None); + let credentials = reset_project_service_credentials(access_token)?; service::reset(ServiceOptions { port, bind, @@ -3800,8 +3928,8 @@ fn main() -> anyhow::Result<()> { stream_quality, ), local_stream_fps, - access_token, - pairing_code: None, + access_token: Some(credentials.access_token), + pairing_code: Some(credentials.pairing_code), }) } ServiceCommand::Stop => stop_project_service(), @@ -5270,6 +5398,22 @@ fn http_health_probe(address: SocketAddr, timeout: Duration) -> bool { read > 12 && response[..read].starts_with(b"HTTP/1.1 200") } +fn start_simulator_session_idle_reaper(registry: SessionRegistry) { + tokio::spawn(async move { + tokio::time::sleep(SIMULATOR_SESSION_IDLE_REAPER_INITIAL_DELAY).await; + loop { + for udid in registry.remove_idle_sessions(SIMULATOR_SESSION_IDLE_TIMEOUT) { + info!( + udid = %udid, + idle_seconds = SIMULATOR_SESSION_IDLE_TIMEOUT.as_secs(), + "Released idle simulator session" + ); + } + tokio::time::sleep(SIMULATOR_SESSION_IDLE_REAPER_INTERVAL).await; + } + }); +} + fn now_secs() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -5921,6 +6065,7 @@ async fn serve( accessibility_cache: Default::default(), android: Default::default(), }; + start_simulator_session_idle_reaper(state.registry.clone()); let http_router = app_router( state.clone(), @@ -6175,15 +6320,18 @@ mod tests { use super::{ batch_line_to_json_step, http_url_for_host, interactive_accessibility_snapshot, is_tailscale_ip, maestro_commands_from_flow, maestro_selector, - no_command_action_from_args_slice, normalize_accessibility_point_for_display, - parse_maestro_flow_yaml, parse_maestro_point, parse_optional_udid_f64_args, - parse_optional_udid_text_args, parse_optional_udid_value_args, parse_tap_command_args, - parse_workspace_service_process_line, removed_service_process_name, - render_agent_accessibility_tree, render_qr_code, run_maestro_command, - server_health_watchdog_should_restart, service_addresses, service_matches_launch_options, - service_post_error_is_retryable, simdeck_open_link, simdeck_pair_url, - studio_service_restart_args, workspace_service_process_is_current, AndroidGpuMode, Cli, - Command, ElementSelector, NoCommandAction, PairingAddress, ServiceCommand, + new_project_service_credentials, no_command_action_from_args_slice, + normalize_accessibility_point_for_display, parse_maestro_flow_yaml, parse_maestro_point, + parse_optional_udid_f64_args, parse_optional_udid_text_args, + parse_optional_udid_value_args, parse_tap_command_args, + parse_workspace_service_process_line, project_service_credentials_for_start_at_path, + project_service_credentials_from_metadata, read_project_service_credentials_from_path, + removed_service_process_name, render_agent_accessibility_tree, render_qr_code, + run_maestro_command, server_health_watchdog_should_restart, service_addresses, + service_matches_launch_options, service_post_error_is_retryable, simdeck_open_link, + simdeck_pair_url, studio_service_restart_args, workspace_service_process_is_current, + write_project_service_credentials_to_path, AndroidGpuMode, Cli, Command, ElementSelector, + NoCommandAction, PairingAddress, ProjectServiceCredentials, ServiceCommand, ServiceLaunchOptions, ServiceMetadata, StreamQualityProfileArg, StudioExposeOptions, TapCommandTarget, VideoCodecMode, WorkspaceServiceProcess, YamlValue, DEFAULT_LOCAL_STREAM_QUALITY_PROFILE, SERVER_HEALTH_WATCHDOG_FAILURE_THRESHOLD, @@ -6191,8 +6339,10 @@ mod tests { }; use clap::Parser; use std::collections::HashMap; + use std::fs; use std::net::{IpAddr, Ipv4Addr, TcpListener}; use std::path::{Path, PathBuf}; + use std::time::{SystemTime, UNIX_EPOCH}; fn service_metadata_for_test( port: u16, @@ -6243,6 +6393,17 @@ mod tests { } } + fn temp_credentials_path_for_test(name: &str) -> PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!( + "simdeck-{name}-{}-{suffix}.json", + std::process::id() + )) + } + #[test] fn local_service_start_defaults_to_auto_video_codec() { let cli = Cli::parse_from(["simdeck", "service", "start"]); @@ -6438,6 +6599,111 @@ mod tests { assert_eq!(port, Some(4315)); } + #[test] + fn project_service_credentials_preserve_metadata_values() { + let metadata = service_metadata_for_test(4310, "127.0.0.1", None, None); + + let credentials = project_service_credentials_from_metadata(&metadata) + .expect("metadata credentials should be reusable"); + + assert_eq!( + credentials, + ProjectServiceCredentials { + access_token: "token".to_owned(), + pairing_code: "123456".to_owned(), + } + ); + } + + #[test] + fn project_service_credentials_generate_missing_pairing_code() { + let mut metadata = service_metadata_for_test(4310, "127.0.0.1", None, None); + metadata.pairing_code = None; + + let credentials = project_service_credentials_from_metadata(&metadata) + .expect("metadata access token should be reusable"); + + assert_eq!(credentials.access_token, "token"); + assert_eq!(credentials.pairing_code.len(), 6); + assert!(credentials + .pairing_code + .chars() + .all(|ch| ch.is_ascii_digit())); + } + + #[test] + fn project_service_credentials_ignore_blank_access_token() { + let mut metadata = service_metadata_for_test(4310, "127.0.0.1", None, None); + metadata.access_token = " ".to_owned(); + + assert_eq!(project_service_credentials_from_metadata(&metadata), None); + } + + #[test] + fn project_service_credentials_reuse_persisted_values() { + let path = temp_credentials_path_for_test("persisted"); + let stored = ProjectServiceCredentials { + access_token: "stored-token".to_owned(), + pairing_code: "222333".to_owned(), + }; + write_project_service_credentials_to_path(&path, &stored).unwrap(); + + let credentials = project_service_credentials_for_start_at_path(None, &path).unwrap(); + + assert_eq!(credentials, stored); + let _ = fs::remove_file(path); + } + + #[test] + fn project_service_credentials_preserved_metadata_updates_persisted_values() { + let path = temp_credentials_path_for_test("metadata"); + let preserved = ProjectServiceCredentials { + access_token: "metadata-token".to_owned(), + pairing_code: "444555".to_owned(), + }; + + let credentials = + project_service_credentials_for_start_at_path(Some(preserved.clone()), &path).unwrap(); + let reloaded = read_project_service_credentials_from_path(&path) + .unwrap() + .expect("credentials should be persisted"); + + assert_eq!(credentials, preserved); + assert_eq!(reloaded, preserved); + let _ = fs::remove_file(path); + } + + #[test] + fn project_service_credentials_generate_and_persist_when_missing() { + let path = temp_credentials_path_for_test("generated"); + + let credentials = project_service_credentials_for_start_at_path(None, &path).unwrap(); + let reloaded = read_project_service_credentials_from_path(&path) + .unwrap() + .expect("generated credentials should be persisted"); + + assert_eq!(credentials, reloaded); + assert_eq!(credentials.access_token.len(), 64); + assert_eq!(credentials.pairing_code.len(), 6); + let _ = fs::remove_file(path); + } + + #[test] + fn project_service_credentials_trim_explicit_reset_token() { + let credentials = new_project_service_credentials(Some(" explicit-token ".to_owned())); + + assert_eq!(credentials.access_token, "explicit-token"); + assert_eq!(credentials.pairing_code.len(), 6); + } + + #[test] + fn project_service_credentials_generate_for_blank_explicit_reset_token() { + let credentials = new_project_service_credentials(Some(" ".to_owned())); + + assert_eq!(credentials.access_token.len(), 64); + assert_eq!(credentials.pairing_code.len(), 6); + } + #[test] fn workspace_service_process_parser_reads_supervised_command_paths() { let process = parse_workspace_service_process_line( diff --git a/packages/server/src/simulators/registry.rs b/packages/server/src/simulators/registry.rs index 4314abcc..a00e8e8a 100644 --- a/packages/server/src/simulators/registry.rs +++ b/packages/server/src/simulators/registry.rs @@ -5,6 +5,7 @@ use crate::simulators::session::SimulatorSession; use serde_json::json; use std::collections::HashMap; use std::sync::{Arc, Mutex}; +use std::time::Duration; use tokio::task; type SessionFactory = @@ -130,6 +131,19 @@ impl SessionRegistry { } impl SessionRegistry { + pub fn remove_idle_sessions(&self, idle_timeout: Duration) -> Vec { + let mut sessions = self.store.sessions.lock().unwrap(); + let idle_udids = sessions + .iter() + .filter(|(_, session)| session.is_idle_for(idle_timeout)) + .map(|(udid, _)| udid.clone()) + .collect::>(); + for udid in &idle_udids { + sessions.remove(udid); + } + idle_udids + } + pub fn reconfigure_video_encoders(&self) { for session in self.store.values() { session.reconfigure_video_encoder(); diff --git a/packages/server/src/simulators/session.rs b/packages/server/src/simulators/session.rs index 83534ff2..4b3d61c6 100644 --- a/packages/server/src/simulators/session.rs +++ b/packages/server/src/simulators/session.rs @@ -50,6 +50,7 @@ struct SimulatorSessionInner { last_frame_ms: AtomicU64, last_refresh_us: AtomicU64, last_keyframe_ms: AtomicU64, + last_activity_ms: AtomicU64, active_frame_subscribers: AtomicU64, refresh_pump_running: AtomicBool, } @@ -75,6 +76,7 @@ impl Drop for FrameSubscription { }) .unwrap_or(0); if previous <= 1 { + *self.inner.state.lock().unwrap() = SessionState::Ready; self.inner.native.set_client_foreground(false); *self.inner.latest_keyframe.write().unwrap() = None; self.inner.last_keyframe_ms.store(0, Ordering::Relaxed); @@ -110,6 +112,7 @@ impl SimulatorSession { .map(simulator_has_fixed_orientation) .unwrap_or(false); let (sender, _) = broadcast::channel(FRAME_BROADCAST_CAPACITY); + let now = now_ms(); let inner = Arc::new(SimulatorSessionInner { udid, native, @@ -128,6 +131,7 @@ impl SimulatorSession { last_frame_ms: AtomicU64::new(0), last_refresh_us: AtomicU64::new(0), last_keyframe_ms: AtomicU64::new(0), + last_activity_ms: AtomicU64::new(now), active_frame_subscribers: AtomicU64::new(0), refresh_pump_running: AtomicBool::new(false), }); @@ -146,6 +150,7 @@ impl SimulatorSession { } pub fn ensure_started(&self) -> Result<(), AppError> { + self.mark_activity(); loop { let mut state = self.inner.state.lock().unwrap(); match *state { @@ -178,6 +183,7 @@ impl SimulatorSession { } pub fn subscribe(&self) -> FrameSubscription { + self.mark_activity(); *self.inner.state.lock().unwrap() = SessionState::Streaming; let previous = self .inner @@ -198,6 +204,7 @@ impl SimulatorSession { } pub async fn wait_for_keyframe(&self, timeout_duration: Duration) -> Option { + self.mark_activity(); self.inner.native.set_client_foreground(true); let deadline = Instant::now() + timeout_duration; let baseline_sequence = self @@ -233,10 +240,12 @@ impl SimulatorSession { } pub fn request_refresh(&self) { + self.mark_activity(); self.inner.request_refresh(); } pub fn request_keyframe(&self) { + self.mark_activity(); let now = now_ms(); let previous = self.inner.last_keyframe_ms.load(Ordering::Relaxed); if now.saturating_sub(previous) < MIN_KEYFRAME_INTERVAL_MS { @@ -268,6 +277,7 @@ impl SimulatorSession { } pub fn reconfigure_video_encoder(&self) { + self.mark_activity(); *self.inner.latest_keyframe.write().unwrap() = None; self.inner.last_keyframe_ms.store(0, Ordering::Relaxed); self.inner.last_refresh_us.store(0, Ordering::Relaxed); @@ -275,6 +285,7 @@ impl SimulatorSession { } pub fn set_client_foreground(&self, foreground: bool) { + self.mark_activity(); self.inner.native.set_client_foreground(foreground); if foreground { self.request_keyframe(); @@ -294,6 +305,7 @@ impl SimulatorSession { } pub fn send_touch(&self, x: f64, y: f64, phase: &str) -> Result<(), AppError> { + self.mark_activity(); if self.is_tvos() { return Err(AppError::bad_request( "tvOS simulators do not support direct screen touch. Use Enter and arrow keys instead.", @@ -303,6 +315,7 @@ impl SimulatorSession { } pub fn send_edge_touch(&self, x: f64, y: f64, phase: &str, edge: u32) -> Result<(), AppError> { + self.mark_activity(); if self.is_tvos() { return Err(AppError::bad_request( "tvOS simulators do not support direct screen touch. Use Enter and arrow keys instead.", @@ -319,6 +332,7 @@ impl SimulatorSession { y2: f64, phase: &str, ) -> Result<(), AppError> { + self.mark_activity(); if self.is_tvos() { return Err(AppError::bad_request( "tvOS simulators do not support direct screen touch. Use Enter and arrow keys instead.", @@ -328,14 +342,17 @@ impl SimulatorSession { } pub fn send_key(&self, key_code: u16, modifiers: u32) -> Result<(), AppError> { + self.mark_activity(); self.inner.native.send_key(key_code, modifiers) } pub fn press_home(&self) -> Result<(), AppError> { + self.mark_activity(); self.inner.native.press_home() } pub fn press_button(&self, button: &str, duration_ms: u32) -> Result<(), AppError> { + self.mark_activity(); self.inner.native.press_button(button, duration_ms) } @@ -346,12 +363,14 @@ impl SimulatorSession { usage_page: Option, usage: Option, ) -> Result<(), AppError> { + self.mark_activity(); self.inner .native .send_button(button, pressed, usage_page, usage) } pub fn rotate_crown(&self, delta: f64) -> Result<(), AppError> { + self.mark_activity(); if !self.is_watchos() { return Err(digital_crown_error()); } @@ -359,10 +378,12 @@ impl SimulatorSession { } pub fn open_app_switcher(&self) -> Result<(), AppError> { + self.mark_activity(); self.inner.native.open_app_switcher() } pub fn rotate_left(&self) -> Result<(), AppError> { + self.mark_activity(); if self.has_fixed_orientation() { return Err(fixed_orientation_error()); } @@ -370,6 +391,7 @@ impl SimulatorSession { } pub fn rotate_right(&self) -> Result<(), AppError> { + self.mark_activity(); if self.has_fixed_orientation() { return Err(fixed_orientation_error()); } @@ -388,6 +410,21 @@ impl SimulatorSession { "encoder": self.inner.native.video_encoder_stats(), }) } + + pub fn is_idle_for(&self, idle_timeout: Duration) -> bool { + if Arc::strong_count(&self.inner) > 1 { + return false; + } + if self.inner.active_frame_subscribers.load(Ordering::Relaxed) > 0 { + return false; + } + let idle_ms = idle_timeout.as_millis().try_into().unwrap_or(u64::MAX); + now_ms().saturating_sub(self.inner.last_activity_ms.load(Ordering::Relaxed)) >= idle_ms + } + + fn mark_activity(&self) { + self.inner.mark_activity(); + } } impl Drop for SimulatorSession { @@ -429,6 +466,10 @@ unsafe extern "C" fn native_frame_callback( } impl SimulatorSessionInner { + fn mark_activity(&self) { + self.last_activity_ms.store(now_ms(), Ordering::Relaxed); + } + fn start_refresh_pump(self: &Arc) { if self .refresh_pump_running