1use std::{
2 collections::BTreeMap,
3 env,
4 error::Error,
5 ffi::OsString,
6 fmt, fs, io,
7 net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
8 path::{Path, PathBuf},
9 process,
10 time::Duration,
11};
12
13use fs4::{FileExt, TryLockError};
14use subc_protocol::PROTOCOL_VERSION;
15pub use subc_transport::user_connection_token;
16use subc_transport::{
17 authenticate_client, connection_file, generate_daemon_id, generate_key, write_atomic,
18 AuthError, ConnectionFileError, ConnectionInfo, Endpoint, SCHEMA_VERSION,
19};
20use tokio::{
21 net::{TcpListener, TcpStream},
22 task::{JoinError, JoinHandle},
23 time::{sleep, timeout},
24};
25use tracing::{error, info, warn};
26
27use crate::{
28 daemon_config::{self, ConfiguredModule, DaemonConfigError},
29 server::{serve_listeners, ServerAuth, ServerError},
30 supervise::HealthConfig,
31 ConnectedClients, ControlHandler, DaemonSelfWatchdog, DaemonSelfWatchdogConfig,
32 ForwardingTable, Registry, RestartPolicy, Router, Supervisor, SupervisorHandle,
33 SupervisorProcessLiveness,
34};
35use std::sync::Arc;
36
37pub const DEFAULT_SUBC_PORT: u16 = 8757;
38pub const SUBC_PORT_ENV: &str = "SUBC_PORT";
39use subc_transport::CONNECTION_FILE_NAME;
40const DAEMON_VERSION: &str = env!("CARGO_PKG_VERSION");
41const CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
42const PROBE_AUTH_DEADLINE: Duration = Duration::from_secs(2);
43const START_LOCK_RETRIES: usize = 40;
44const START_LOCK_RETRY_DELAY: Duration = Duration::from_millis(25);
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum ConnectionFileSource {
48 XdgRuntimeDir,
49 TempDirFallback,
50 Explicit,
51}
52
53impl ConnectionFileSource {
54 fn reason(self) -> &'static str {
55 match self {
56 Self::XdgRuntimeDir => "XDG_RUNTIME_DIR set and non-empty",
57 Self::TempDirFallback => "XDG_RUNTIME_DIR unset or empty",
58 Self::Explicit => "configured path",
59 }
60 }
61}
62
63impl fmt::Display for ConnectionFileSource {
64 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
65 formatter.write_str(match self {
66 Self::XdgRuntimeDir => "xdg_runtime_dir",
67 Self::TempDirFallback => "temp_dir_fallback",
68 Self::Explicit => "explicit",
69 })
70 }
71}
72
73#[derive(Debug, Clone, Default)]
77struct AdmissionFactsConfig {
78 carrier_module_id: Option<String>,
79 targets: Option<Vec<String>>,
80}
81
82#[derive(Debug, Clone, Default, PartialEq, Eq)]
91pub enum CgroupPlacementConfig {
92 #[default]
93 Disabled,
94 Current,
95 Root(PathBuf),
96}
97
98#[derive(Debug, Clone)]
99pub struct BootstrapConfig {
100 pub connection_file_path: PathBuf,
101 pub port: u16,
102 pub daemon_ver: String,
103 configured_modules: Vec<ConfiguredModule>,
104 storage_config: Option<daemon_config::StorageConfig>,
105 admission_facts: AdmissionFactsConfig,
106 daemon_config_path: Option<PathBuf>,
107 configured_port: Option<u16>,
108 route_bind_relay_default_ms: Option<u64>,
112 reserved_capabilities: BTreeMap<String, String>,
113 watchdog_config: DaemonSelfWatchdogConfig,
114 connection_file_source: ConnectionFileSource,
115 cgroup_placement: CgroupPlacementConfig,
118 capture_logs_dir: Option<PathBuf>,
122 terminal_journal_path: Option<PathBuf>,
130 machine_id_path: Option<PathBuf>,
136 live_children_path: Option<PathBuf>,
144}
145
146impl BootstrapConfig {
147 pub fn new(connection_file_path: impl Into<PathBuf>, port: u16) -> Self {
148 Self {
149 connection_file_path: connection_file_path.into(),
150 port,
151 daemon_ver: DAEMON_VERSION.to_owned(),
152 configured_modules: Vec::new(),
153 storage_config: None,
154 admission_facts: AdmissionFactsConfig::default(),
155 daemon_config_path: None,
156 configured_port: None,
157 route_bind_relay_default_ms: None,
158 reserved_capabilities: BTreeMap::new(),
159 watchdog_config: DaemonSelfWatchdogConfig::default(),
160 connection_file_source: ConnectionFileSource::Explicit,
161 cgroup_placement: CgroupPlacementConfig::default(),
162 capture_logs_dir: None,
163 terminal_journal_path: None,
164 machine_id_path: None,
165 live_children_path: None,
166 }
167 }
168
169 pub fn with_live_children_record(mut self, path: impl Into<PathBuf>) -> Self {
173 self.live_children_path = Some(path.into());
174 self
175 }
176
177 pub fn with_machine_id_path(mut self, path: impl Into<PathBuf>) -> Self {
181 self.machine_id_path = Some(path.into());
182 self
183 }
184
185 pub fn with_cgroup_placement(mut self, placement: CgroupPlacementConfig) -> Self {
187 self.cgroup_placement = placement;
188 self
189 }
190
191 pub fn with_capture_logs_dir(mut self, dir: impl Into<PathBuf>) -> Self {
195 self.capture_logs_dir = Some(dir.into());
196 self
197 }
198
199 pub fn with_terminal_journal_path(mut self, path: impl Into<PathBuf>) -> Self {
202 self.terminal_journal_path = Some(path.into());
203 self
204 }
205
206 pub fn from_env() -> Result<Self, BootstrapError> {
207 Self::from_env_with_daemon_config_path(daemon_config::default_config_path())
208 }
209
210 pub fn from_env_for_daemon_binary() -> Result<Self, BootstrapError> {
219 let run_dir = daemon_config::daemon_run_dir().map_err(BootstrapError::RunDir)?;
220 let machine_id_path =
221 crate::machine_id::default_machine_id_path().map_err(BootstrapError::MachineId)?;
222 Ok(Self::from_env()?
223 .with_capture_logs_dir(run_dir.join("logs"))
224 .with_terminal_journal_path(run_dir.join("terminals.jsonl"))
225 .with_live_children_record(crate::live_children::record_path(&run_dir))
226 .with_machine_id_path(machine_id_path))
227 }
228
229 pub fn from_env_with_daemon_config_path(
230 daemon_config_path: impl AsRef<Path>,
231 ) -> Result<Self, BootstrapError> {
232 let daemon_config_path = daemon_config_path.as_ref().to_path_buf();
233 let daemon_config =
234 daemon_config::load(&daemon_config_path).map_err(BootstrapError::DaemonConfig)?;
235 let config_port = daemon_config.as_ref().and_then(|config| config.port);
236 let storage_config = daemon_config
237 .as_ref()
238 .and_then(|config| config.storage.clone());
239 let admission_facts_carrier_module_id = daemon_config
240 .as_ref()
241 .and_then(|config| config.admission_facts_carrier_module_id.clone());
242 let admission_facts_targets = daemon_config
243 .as_ref()
244 .and_then(|config| config.admission_facts_targets.clone());
245 let route_bind_relay_default_ms = daemon_config
246 .as_ref()
247 .and_then(|config| config.route_bind_relay_timeout_ms);
248 let reserved_capabilities = daemon_config
249 .as_ref()
250 .map(|config| config.reserved_capabilities.clone())
251 .unwrap_or_default();
252 let configured_modules = daemon_config
253 .map(|config| config.modules)
254 .unwrap_or_default();
255
256 let port = match env::var(SUBC_PORT_ENV) {
257 Ok(raw) if !raw.trim().is_empty() => {
258 let port = raw
259 .parse::<u16>()
260 .map_err(|source| BootstrapError::InvalidPort { raw, source })?;
261 if let Some(config_port) = config_port {
262 info!(
263 env = SUBC_PORT_ENV,
264 env_port = port,
265 config_port,
266 "SUBC_PORT overrides daemon config port"
267 );
268 }
269 port
270 }
271 Ok(_) | Err(_) => config_port.unwrap_or(DEFAULT_SUBC_PORT),
272 };
273
274 let (connection_file_path, connection_file_source) =
275 connection_file_path_with_source(non_empty_os_var("XDG_RUNTIME_DIR"));
276 Ok(Self::new(connection_file_path, port)
277 .with_configured_modules(configured_modules)
278 .with_storage_config(storage_config)
279 .with_admission_facts_config(admission_facts_carrier_module_id, admission_facts_targets)
280 .with_route_bind_relay_default_ms(route_bind_relay_default_ms)
281 .with_reserved_capabilities(reserved_capabilities)
282 .with_daemon_config_source(daemon_config_path, config_port)
283 .with_connection_file_source(connection_file_source))
284 }
285
286 pub fn with_daemon_config_path(
287 self,
288 daemon_config_path: impl AsRef<Path>,
289 ) -> Result<Self, BootstrapError> {
290 let daemon_config_path = daemon_config_path.as_ref().to_path_buf();
291 let daemon_config =
292 daemon_config::load(&daemon_config_path).map_err(BootstrapError::DaemonConfig)?;
293 let configured_port = daemon_config.as_ref().and_then(|config| config.port);
294 let storage_config = daemon_config
295 .as_ref()
296 .and_then(|config| config.storage.clone());
297 let admission_facts_carrier_module_id = daemon_config
298 .as_ref()
299 .and_then(|config| config.admission_facts_carrier_module_id.clone());
300 let admission_facts_targets = daemon_config
301 .as_ref()
302 .and_then(|config| config.admission_facts_targets.clone());
303 let route_bind_relay_default_ms = daemon_config
304 .as_ref()
305 .and_then(|config| config.route_bind_relay_timeout_ms);
306 let reserved_capabilities = daemon_config
307 .as_ref()
308 .map(|config| config.reserved_capabilities.clone())
309 .unwrap_or_default();
310 let configured_modules = daemon_config
311 .map(|config| config.modules)
312 .unwrap_or_default();
313 Ok(self
314 .with_configured_modules(configured_modules)
315 .with_storage_config(storage_config)
316 .with_admission_facts_config(admission_facts_carrier_module_id, admission_facts_targets)
317 .with_route_bind_relay_default_ms(route_bind_relay_default_ms)
318 .with_reserved_capabilities(reserved_capabilities)
319 .with_daemon_config_source(daemon_config_path, configured_port))
320 }
321
322 pub fn with_configured_modules(
323 mut self,
324 modules: impl IntoIterator<Item = ConfiguredModule>,
325 ) -> Self {
326 self.configured_modules = modules.into_iter().collect();
327 self.configured_modules
328 .sort_by(|left, right| left.module_id.cmp(&right.module_id));
329 self
330 }
331
332 pub fn with_storage_config(
333 mut self,
334 storage_config: Option<daemon_config::StorageConfig>,
335 ) -> Self {
336 self.storage_config = storage_config;
337 self
338 }
339
340 pub fn with_admission_facts_config(
341 mut self,
342 carrier_module_id: Option<String>,
343 targets: Option<Vec<String>>,
344 ) -> Self {
345 self.admission_facts = AdmissionFactsConfig {
346 carrier_module_id,
347 targets,
348 };
349 self
350 }
351
352 pub fn with_route_bind_relay_default_ms(mut self, ms: Option<u64>) -> Self {
357 self.route_bind_relay_default_ms = ms;
358 self
359 }
360
361 pub fn with_reserved_capabilities(
362 mut self,
363 reserved_capabilities: BTreeMap<String, String>,
364 ) -> Self {
365 self.reserved_capabilities = reserved_capabilities;
366 self
367 }
368
369 fn with_daemon_config_source(
370 mut self,
371 daemon_config_path: PathBuf,
372 configured_port: Option<u16>,
373 ) -> Self {
374 self.daemon_config_path = Some(daemon_config_path);
375 self.configured_port = configured_port;
376 self
377 }
378
379 fn with_connection_file_source(mut self, source: ConnectionFileSource) -> Self {
380 self.connection_file_source = source;
381 self
382 }
383
384 pub fn with_watchdog_config(mut self, watchdog_config: DaemonSelfWatchdogConfig) -> Self {
385 self.watchdog_config = watchdog_config;
386 self
387 }
388}
389
390#[allow(clippy::large_enum_variant)]
394#[derive(Debug)]
395pub enum Outcome {
396 AlreadyRunning,
398 Bound(BoundDaemon),
401}
402
403#[derive(Debug)]
404pub struct BoundDaemon {
405 pub listeners: Vec<TcpListener>,
406 pub connection_info: ConnectionInfo,
407 pub connection_file_path: PathBuf,
408 pub connection_file_source: ConnectionFileSource,
409 pub machine_id: Option<crate::machine_id::MachineId>,
412 run_dir_lock: Option<crate::run_dir_lock::RunDirLock>,
416}
417
418pub fn connection_file_path() -> PathBuf {
425 connection_file_path_with_source(non_empty_os_var("XDG_RUNTIME_DIR")).0
426}
427
428fn connection_file_path_with_source(
429 runtime_dir: Option<OsString>,
430) -> (PathBuf, ConnectionFileSource) {
431 if let Some(runtime_dir) = runtime_dir.filter(|value| !value.is_empty()) {
432 return (
433 PathBuf::from(runtime_dir).join(CONNECTION_FILE_NAME),
434 ConnectionFileSource::XdgRuntimeDir,
435 );
436 }
437
438 (
439 env::temp_dir().join(format!("subc-{}.connection.json", user_connection_token())),
440 ConnectionFileSource::TempDirFallback,
441 )
442}
443
444pub async fn run() -> Result<(), BootstrapError> {
450 run_with_config(
453 BootstrapConfig::from_env_for_daemon_binary()?
454 .with_cgroup_placement(CgroupPlacementConfig::Current),
455 )
456 .await
457}
458
459pub async fn run_with_config(config: BootstrapConfig) -> Result<(), BootstrapError> {
483 let configured_modules = config.configured_modules.clone();
484 let storage_config = config.storage_config.clone();
485 let admission_facts = config.admission_facts.clone();
486 let daemon_config_path = config.daemon_config_path.clone();
487 let configured_port = config.configured_port;
488 let route_bind_relay_default_ms = config.route_bind_relay_default_ms;
489 let reserved_capabilities = config.reserved_capabilities.clone();
490 let watchdog_config = config.watchdog_config.clone();
491 let cgroup_placement_config = config.cgroup_placement.clone();
492 let capture_logs_dir = config.capture_logs_dir.clone();
493 let terminal_journal_path = config.terminal_journal_path.clone();
494 let live_children_path = config.live_children_path.clone();
495 match ensure_singleton_with_config(config).await? {
496 Outcome::AlreadyRunning => {
497 info!("subc daemon already running");
498 Ok(())
499 }
500 Outcome::Bound(bound) => {
501 #[cfg(target_os = "linux")]
502 let cgroup_placement = prepare_cgroup_placement(&cgroup_placement_config);
503 #[cfg(not(target_os = "linux"))]
504 let _ = cgroup_placement_config;
505 serve_bound_daemon(
506 bound,
507 configured_modules,
508 storage_config,
509 admission_facts,
510 daemon_config_path,
511 configured_port,
512 route_bind_relay_default_ms,
513 reserved_capabilities,
514 watchdog_config,
515 capture_logs_dir,
516 terminal_journal_path,
517 live_children_path,
518 #[cfg(target_os = "linux")]
519 cgroup_placement,
520 )
521 .await
522 }
523 }
524}
525
526#[cfg(target_os = "linux")]
527fn prepare_cgroup_placement(config: &CgroupPlacementConfig) -> Option<subc_cgroup::Placement> {
528 let result = match config {
529 CgroupPlacementConfig::Disabled => return None,
530 CgroupPlacementConfig::Current => subc_cgroup::prepare_current(),
531 CgroupPlacementConfig::Root(root) => subc_cgroup::prepare_at(root),
532 };
533
534 match result {
535 Ok(Some(placement)) => Some(placement),
536 Ok(None) => {
537 warn!(
538 placement = ?config,
539 "module cgroup placement is disabled: configured cgroup root is not delegated"
540 );
541 None
542 }
543 Err(error) => {
544 warn!(
545 placement = ?config,
546 error = %error,
547 "module cgroup placement is disabled by an unexpected cgroup probe error"
548 );
549 None
550 }
551 }
552}
553
554#[cfg(unix)]
561const NOFILE_TARGET: u64 = 65536;
562
563#[cfg(unix)]
567fn raise_nofile_limit() {
568 match rlimit::Resource::NOFILE.get() {
569 Ok((soft, hard)) => {
570 if soft >= NOFILE_TARGET {
571 return;
572 }
573 let target = NOFILE_TARGET.min(hard);
574 match rlimit::Resource::NOFILE.set(target, hard) {
575 Ok(()) => info!(
576 previous_soft = soft,
577 new_soft = target,
578 hard,
579 "raised open-file soft limit for daemon and module children"
580 ),
581 Err(err) => warn!(
582 soft,
583 hard,
584 error = %err,
585 "could not raise open-file soft limit; multi-root modules may exhaust descriptors"
586 ),
587 }
588 }
589 Err(err) => warn!(error = %err, "could not read open-file limit"),
590 }
591}
592
593#[cfg(windows)]
600fn raise_nofile_limit() {
601 const MAXSTDIO_TARGET: u32 = 8192;
602 let current = rlimit::getmaxstdio();
603 if current >= MAXSTDIO_TARGET {
604 return;
605 }
606 match rlimit::setmaxstdio(MAXSTDIO_TARGET) {
607 Ok(new_max) => info!(
608 previous = current,
609 new_max, "raised CRT stdio-stream limit for daemon"
610 ),
611 Err(err) => warn!(
612 current,
613 error = %err,
614 "could not raise CRT stdio-stream limit"
615 ),
616 }
617}
618
619#[cfg(not(any(unix, windows)))]
620fn raise_nofile_limit() {}
621
622pub async fn run_with_daemon_config_path(
623 config: BootstrapConfig,
624 daemon_config_path: impl AsRef<Path>,
625) -> Result<(), BootstrapError> {
626 run_with_config(config.with_daemon_config_path(daemon_config_path)?).await
627}
628
629#[allow(clippy::too_many_arguments)]
630async fn serve_bound_daemon(
631 bound: BoundDaemon,
632 configured_modules: Vec<ConfiguredModule>,
633 storage_config: Option<daemon_config::StorageConfig>,
634 admission_facts: AdmissionFactsConfig,
635 daemon_config_path: Option<PathBuf>,
636 configured_port: Option<u16>,
637 route_bind_relay_default_ms: Option<u64>,
638 reserved_capabilities: BTreeMap<String, String>,
639 watchdog_config: DaemonSelfWatchdogConfig,
640 capture_logs_dir: Option<PathBuf>,
641 terminal_journal_path: Option<PathBuf>,
642 live_children_path: Option<PathBuf>,
643 #[cfg(target_os = "linux")] cgroup_placement: Option<subc_cgroup::Placement>,
644) -> Result<(), BootstrapError> {
645 #[cfg(unix)]
646 let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
647 .map_err(BootstrapError::Signal)?;
648 raise_nofile_limit();
651
652 info!(
653 connection_file = %bound.connection_file_path.display(),
654 connection_file_source = %bound.connection_file_source,
655 connection_file_source_reason = bound.connection_file_source.reason(),
656 endpoints = ?bound.connection_info.endpoints,
657 configured_modules = configured_modules.len(),
658 machine_id = bound.machine_id.as_ref().map(|id| id.as_str()).unwrap_or("none"),
659 "subc daemon starting"
660 );
661
662 let run_dir_lock = bound.run_dir_lock;
673 if let Some(owner) = &run_dir_lock {
674 crate::live_children::sweep_orphans(
675 owner,
676 &crate::live_children::AdoptedPids::none(),
677 crate::live_children::SweepBounds::default(),
678 )
679 .await;
680 }
681
682 let registry = Arc::new(Registry::default());
683 let process_liveness = Arc::new(SupervisorProcessLiveness::new());
684 let supervisor_handle = SupervisorHandle::new();
685 let connected_clients = ConnectedClients::new();
686 let forwarding = Arc::new(ForwardingTable::default());
687 let daemon_incarnation = format!(
688 "{:032x}",
689 u128::from_be_bytes(bound.connection_info.daemon_id)
690 );
691 let supervisor = Supervisor::new(Arc::clone(®istry), RestartPolicy::default())
692 .with_process_liveness(process_liveness.clone())
693 .with_forwarding(Arc::clone(&forwarding))
694 .with_handle(supervisor_handle.clone())
695 .with_connection_file_path(bound.connection_file_path.clone())
696 .with_daemon_incarnation(daemon_incarnation.clone());
697 let supervisor = match terminal_journal_path {
734 Some(path) => supervisor.with_terminal_journal(path, daemon_incarnation),
735 None => supervisor,
736 };
737 let supervisor = match capture_logs_dir {
738 Some(dir) => supervisor.with_capture_logs_dir(dir),
739 None => supervisor,
740 };
741 let supervisor = match live_children_path {
742 Some(path) => supervisor.with_live_children_record(path),
743 None => supervisor,
744 };
745 #[cfg(target_os = "linux")]
746 let supervisor = supervisor.with_cgroup_placement(cgroup_placement);
747 let route_bind_relay_timeouts = configured_modules
753 .iter()
754 .filter_map(|module| {
755 module
756 .route_bind_relay_timeout_ms
757 .map(|ms| (module.module_id.clone(), Duration::from_millis(ms)))
758 })
759 .collect::<std::collections::BTreeMap<_, _>>();
760 let control_start_clock = crate::clock::StartClock::capture();
761 let mut control = ControlHandler::with_forwarding(Arc::clone(®istry), forwarding)
762 .with_process_liveness(process_liveness)
763 .with_supervisor(supervisor_handle)
764 .with_connected_clients(connected_clients.clone())
765 .with_storage_config(storage_config)
766 .with_machine_id(bound.machine_id.clone())
767 .with_admission_facts_config(admission_facts.carrier_module_id, admission_facts.targets)
768 .with_route_bind_relay_timeouts(route_bind_relay_timeouts)
769 .with_daemon_provenance(
770 bound.connection_info.pid,
771 control_start_clock.started_at_ms(),
772 std::env::current_exe().ok(),
773 normalized_build_provenance(env!("SUBC_BUILD_GIT_SHA")),
774 normalized_build_provenance(env!("SUBC_BUILD_LOCK_DIGEST")),
775 )
776 .with_daemon_start_clock(control_start_clock)
777 .with_capability_config(
778 configured_modules
779 .iter()
780 .map(|module| (module.module_id.clone(), module.enabled)),
781 reserved_capabilities,
782 );
783 if let Some(ms) = route_bind_relay_default_ms {
784 control = control.with_route_bind_relay_timeout(Duration::from_millis(ms));
787 }
788 if let Some(config_path) = daemon_config_path {
789 control = control.with_supervisor_rescan(supervisor.clone(), config_path, configured_port);
790 }
791 let control = Arc::new(control);
792 let router = Arc::new(Router::with_control_handler(Arc::clone(&control)));
793 let auth = ServerAuth::new(
794 bound.connection_info.key.clone(),
795 bound.connection_info.daemon_id,
796 bound.connection_info.daemon_ver.clone(),
797 )
798 .with_connected_clients(connected_clients);
799
800 let mut serve_task =
801 AbortOnDrop::new(tokio::spawn(serve_listeners(bound.listeners, router, auth)));
802 tokio::task::yield_now().await;
803 let _clock_step_task = AbortOnDrop::new(crate::watchdog::spawn_clock_step_monitor());
804 #[cfg(target_os = "linux")]
806 let _kill_mode_check = AbortOnDrop::new(tokio::spawn(
807 crate::systemd_kill_mode::warn_if_kill_mode_defeats_ordered_shutdown(),
808 ));
809 let _watchdog_task = AbortOnDrop::new(
810 DaemonSelfWatchdog::new(
811 bound.connection_info.clone(),
812 bound.connection_file_path.clone(),
813 )
814 .with_config(watchdog_config)
815 .spawn(),
816 );
817
818 for configured in configured_modules {
819 let enabled = configured.enabled;
820 let health = configured.health;
821 let module_id = configured.module_id.clone();
822 match supervisor.supervise_configured_with_health(
823 configured.module_spec(),
824 enabled,
825 health,
826 configured.drain_timeout_ms,
827 configured.restart,
828 ) {
829 Ok(_) => {
830 let default_threshold = HealthConfig::default().failure_threshold;
839 if enabled && health.failure_threshold > default_threshold {
840 warn!(
841 module_id = %module_id,
842 failure_threshold = health.failure_threshold,
843 default_threshold,
844 tolerance_secs = health.cadence.as_secs() * u64::from(health.failure_threshold),
845 "health failure threshold is relaxed above the default; a wedged module stays unflagged for longer"
846 );
847 }
848 info!(module_id = %module_id, enabled, "configured module supervised");
849 }
850 Err(err) => {
851 error!(module_id = %module_id, error = %err, "failed to supervise configured module; continuing daemon startup");
852 }
853 }
854 }
855
856 control.refresh_capability_requirements();
857 Arc::clone(&control).spawn_capability_deadline_loop();
858
859 #[cfg(unix)]
860 {
861 tokio::select! {
862 result = serve_task.join() => {
863 return result.map_err(BootstrapError::ServeJoin)?.map_err(BootstrapError::Serve);
864 }
865 _ = terminate.recv() => {}
866 }
867 supervisor.begin_daemon_shutdown();
872 drop(_watchdog_task);
876 drop(serve_task);
879 let escalated = tokio::select! {
880 biased;
881 _ = terminate.recv() => {
882 info!("second SIGTERM: abandoning daemon shutdown wait");
883 true
884 }
885 result = supervisor.drain_for_daemon_shutdown() => {
886 if let Err(error) = result {
887 warn!(%error, "daemon shutdown drain failed; exiting anyway");
888 }
889 false
890 }
891 };
892 supervisor
899 .end_children_for_daemon_shutdown(escalated, async {
900 terminate.recv().await;
901 })
902 .await;
903 Ok(())
904 }
905 #[cfg(not(unix))]
906 serve_task
907 .join()
908 .await
909 .map_err(BootstrapError::ServeJoin)?
910 .map_err(BootstrapError::Serve)
911}
912
913fn normalized_build_provenance(value: &str) -> Option<String> {
914 match value.trim() {
915 "" | "unavailable" => None,
916 value => Some(value.to_string()),
917 }
918}
919
920pub async fn ensure_singleton(
928 connection_file_path: impl AsRef<Path>,
929 port: u16,
930) -> Result<Outcome, BootstrapError> {
931 ensure_singleton_with_config(BootstrapConfig::new(connection_file_path.as_ref(), port)).await
932}
933
934pub async fn ensure_singleton_with_config(
935 config: BootstrapConfig,
936) -> Result<Outcome, BootstrapError> {
937 let path = config.connection_file_path;
938
939 if matches!(probe_existing(&path).await?, Probe::Live) {
940 return Ok(Outcome::AlreadyRunning);
941 }
942
943 let _lock = StartLock::acquire(&path).await?;
944
945 if matches!(probe_existing(&path).await?, Probe::Live) {
949 return Ok(Outcome::AlreadyRunning);
950 }
951
952 let run_dir_lock = config
958 .live_children_path
959 .as_deref()
960 .map(crate::run_dir_lock::RunDirLock::acquire)
961 .transpose()?;
962
963 remove_stale_connection_file_if_present(&path)?;
964
965 let machine_id = config
969 .machine_id_path
970 .as_deref()
971 .map(crate::machine_id::load_or_mint)
972 .transpose()
973 .map_err(BootstrapError::MachineId)?;
974
975 let (listeners, endpoints) = bind_loopback(config.port).await?;
976 let connection_info = ConnectionInfo {
977 schema: SCHEMA_VERSION,
978 wire_version: Some(PROTOCOL_VERSION),
979 endpoints,
980 key: generate_key().map_err(BootstrapError::GenerateConnectionFile)?,
981 daemon_id: generate_daemon_id().map_err(BootstrapError::GenerateConnectionFile)?,
982 pid: process::id(),
983 daemon_ver: config.daemon_ver,
984 };
985
986 if let Err(source) = write_atomic(&path, &connection_info) {
987 drop(listeners);
988 return Err(BootstrapError::ConnectionFileWrite { path, source });
989 }
990
991 Ok(Outcome::Bound(BoundDaemon {
992 listeners,
993 connection_info,
994 connection_file_path: path,
995 connection_file_source: config.connection_file_source,
996 machine_id,
997 run_dir_lock,
998 }))
999}
1000
1001#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1002enum Probe {
1003 Live,
1004 StaleOrAbsent,
1005}
1006
1007async fn probe_existing(path: &Path) -> Result<Probe, BootstrapError> {
1008 let info = match connection_file::read(path) {
1009 Ok(info) => info,
1010 Err(source) if is_absent_or_stale_connection_file(&source) => {
1011 return Ok(Probe::StaleOrAbsent)
1012 }
1013 Err(source) => {
1014 return Err(BootstrapError::ConnectionFileRead {
1015 path: path.to_path_buf(),
1016 source,
1017 })
1018 }
1019 };
1020
1021 for endpoint in &info.endpoints {
1022 if matches!(probe_endpoint(&info, endpoint).await, Probe::Live) {
1023 return Ok(Probe::Live);
1024 }
1025 }
1026
1027 Ok(Probe::StaleOrAbsent)
1028}
1029
1030async fn probe_endpoint(info: &ConnectionInfo, endpoint: &Endpoint) -> Probe {
1031 let Ok(ip) = endpoint.host.parse::<IpAddr>() else {
1032 return Probe::StaleOrAbsent;
1033 };
1034 if !ip.is_loopback() {
1035 return Probe::StaleOrAbsent;
1036 }
1037 let addr = SocketAddr::new(ip, endpoint.port);
1038
1039 let mut stream = match timeout(CONNECT_TIMEOUT, TcpStream::connect(addr)).await {
1040 Ok(Ok(stream)) => stream,
1041 Ok(Err(_)) | Err(_) => return Probe::StaleOrAbsent,
1042 };
1043
1044 match authenticate_client(&mut stream, info, PROBE_AUTH_DEADLINE).await {
1045 Ok(()) => Probe::Live,
1046 Err(AuthError::DaemonIdMismatch)
1047 | Err(AuthError::InvalidServerProof)
1048 | Err(AuthError::UnexpectedEof { .. })
1049 | Err(AuthError::Timeout { .. })
1050 | Err(AuthError::JsonEncode { .. })
1051 | Err(AuthError::JsonDecode { .. })
1052 | Err(AuthError::Io { .. })
1053 | Err(AuthError::MessageTooLarge { .. })
1054 | Err(AuthError::KeyTooShort { .. })
1055 | Err(AuthError::Random(_))
1056 | Err(AuthError::InvalidClientAuth) => Probe::StaleOrAbsent,
1057 }
1058}
1059
1060fn is_absent_or_stale_connection_file(err: &ConnectionFileError) -> bool {
1061 match err {
1062 ConnectionFileError::Io { source, .. } if source.kind() == io::ErrorKind::NotFound => true,
1063 ConnectionFileError::JsonRead { .. }
1064 | ConnectionFileError::UnsupportedSchema { .. }
1065 | ConnectionFileError::Invalid { .. }
1066 | ConnectionFileError::KeyTooShort { .. }
1067 | ConnectionFileError::InsecurePermissions { .. } => true,
1071 ConnectionFileError::MissingParent { .. }
1072 | ConnectionFileError::MissingFileName { .. }
1073 | ConnectionFileError::InsecureParentDirectory { .. }
1077 | ConnectionFileError::Io { .. }
1078 | ConnectionFileError::JsonWrite { .. }
1079 | ConnectionFileError::Random(_)
1080 | ConnectionFileError::WireVersionMismatch { .. } => false,
1083 }
1084}
1085
1086fn remove_stale_connection_file_if_present(path: &Path) -> Result<(), BootstrapError> {
1087 match fs::remove_file(path) {
1088 Ok(()) => Ok(()),
1089 Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
1090 Err(source) => Err(BootstrapError::RemoveStale {
1091 path: path.to_path_buf(),
1092 source,
1093 }),
1094 }
1095}
1096
1097async fn bind_loopback(port: u16) -> Result<(Vec<TcpListener>, Vec<Endpoint>), BootstrapError> {
1098 let v4_host = Ipv4Addr::LOCALHOST;
1099 let v4 = TcpListener::bind((v4_host, port))
1100 .await
1101 .map_err(|source| BootstrapError::Bind {
1102 host: v4_host.to_string(),
1103 port,
1104 source,
1105 })?;
1106 let actual_port = v4
1107 .local_addr()
1108 .map_err(|source| BootstrapError::LocalAddr {
1109 host: v4_host.to_string(),
1110 source,
1111 })?
1112 .port();
1113
1114 let mut listeners = vec![v4];
1115 let mut endpoints = vec![Endpoint {
1116 host: v4_host.to_string(),
1117 port: actual_port,
1118 }];
1119
1120 let v6_host = Ipv6Addr::LOCALHOST;
1121 match TcpListener::bind((v6_host, actual_port)).await {
1122 Ok(v6) => {
1123 listeners.push(v6);
1124 endpoints.push(Endpoint {
1125 host: v6_host.to_string(),
1126 port: actual_port,
1127 });
1128 }
1129 Err(err) if ipv6_loopback_unavailable(&err) => {
1130 warn!(
1131 port = actual_port,
1132 error = %err,
1133 "IPv6 loopback unavailable; serving only IPv4 loopback"
1134 );
1135 }
1136 Err(source) => {
1137 drop(listeners);
1138 return Err(BootstrapError::Bind {
1139 host: v6_host.to_string(),
1140 port: actual_port,
1141 source,
1142 });
1143 }
1144 }
1145
1146 Ok((listeners, endpoints))
1147}
1148
1149fn ipv6_loopback_unavailable(err: &io::Error) -> bool {
1150 matches!(
1151 err.kind(),
1152 io::ErrorKind::AddrNotAvailable | io::ErrorKind::Unsupported
1153 ) || matches!(err.raw_os_error(), Some(47) | Some(49) | Some(97))
1154}
1155
1156struct AbortOnDrop<T> {
1157 handle: JoinHandle<T>,
1158}
1159
1160impl<T> AbortOnDrop<T> {
1161 fn new(handle: JoinHandle<T>) -> Self {
1162 Self { handle }
1163 }
1164
1165 async fn join(&mut self) -> Result<T, JoinError> {
1166 (&mut self.handle).await
1167 }
1168}
1169
1170impl<T> Drop for AbortOnDrop<T> {
1171 fn drop(&mut self) {
1172 if !self.handle.is_finished() {
1173 self.handle.abort();
1174 }
1175 }
1176}
1177
1178struct StartLock {
1179 _file: fs::File,
1182}
1183
1184impl StartLock {
1185 async fn acquire(connection_file_path: &Path) -> Result<Self, BootstrapError> {
1186 let path = start_lock_path(connection_file_path);
1187 for _ in 0..START_LOCK_RETRIES {
1188 let file = match open_owner_only_lock(&path) {
1189 Ok(file) => file,
1190 Err(source) => return Err(BootstrapError::StartLockCreate { path, source }),
1191 };
1192 match FileExt::try_lock(&file) {
1193 Ok(()) => return Ok(Self { _file: file }),
1194 Err(TryLockError::WouldBlock) => sleep(START_LOCK_RETRY_DELAY).await,
1195 Err(TryLockError::Error(source)) => {
1196 return Err(BootstrapError::StartLockCreate { path, source });
1197 }
1198 }
1199 }
1200
1201 Err(BootstrapError::StartLockBusy {
1202 path,
1203 attempts: START_LOCK_RETRIES,
1204 })
1205 }
1206}
1207
1208pub(crate) fn open_owner_only_lock(path: &Path) -> io::Result<fs::File> {
1209 let mut options = fs::OpenOptions::new();
1210 options.read(true).write(true).create(true);
1211 #[cfg(unix)]
1212 {
1213 use std::os::unix::fs::OpenOptionsExt;
1214 options.mode(0o600);
1215 }
1216 options.open(path)
1217}
1218
1219fn start_lock_path(connection_file_path: &Path) -> PathBuf {
1220 let file_name = connection_file_path
1221 .file_name()
1222 .map(|name| name.to_string_lossy())
1223 .unwrap_or_else(|| CONNECTION_FILE_NAME.into());
1224 let lock_name = format!("{file_name}.start-lock");
1225 connection_file_path
1226 .parent()
1227 .filter(|parent| !parent.as_os_str().is_empty())
1228 .unwrap_or_else(|| Path::new("."))
1229 .join(lock_name)
1230}
1231
1232fn non_empty_os_var(key: &str) -> Option<OsString> {
1233 let value = env::var_os(key)?;
1234 if value.is_empty() {
1235 None
1236 } else {
1237 Some(value)
1238 }
1239}
1240
1241#[derive(Debug)]
1244pub enum BootstrapError {
1245 #[cfg(unix)]
1246 Signal(io::Error),
1247 InvalidPort {
1248 raw: String,
1249 source: std::num::ParseIntError,
1250 },
1251 ConnectionFileRead {
1252 path: PathBuf,
1253 source: ConnectionFileError,
1254 },
1255 ConnectionFileWrite {
1256 path: PathBuf,
1257 source: ConnectionFileError,
1258 },
1259 GenerateConnectionFile(ConnectionFileError),
1260 StartLockCreate {
1261 path: PathBuf,
1262 source: io::Error,
1263 },
1264 StartLockBusy {
1265 path: PathBuf,
1266 attempts: usize,
1267 },
1268 RunDirLockCreate {
1270 path: PathBuf,
1271 source: io::Error,
1272 },
1273 RunDirBusy {
1278 path: PathBuf,
1279 holder_pid: Option<u32>,
1280 },
1281 RemoveStale {
1282 path: PathBuf,
1283 source: io::Error,
1284 },
1285 Bind {
1286 host: String,
1287 port: u16,
1288 source: io::Error,
1289 },
1290 LocalAddr {
1291 host: String,
1292 source: io::Error,
1293 },
1294 DaemonConfig(DaemonConfigError),
1295 MachineId(crate::machine_id::MachineIdFileError),
1298 RunDir(daemon_config::DaemonRunDirError),
1301 Serve(ServerError),
1302 ServeJoin(tokio::task::JoinError),
1303}
1304
1305impl fmt::Display for BootstrapError {
1306 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1307 match self {
1308 #[cfg(unix)]
1309 Self::Signal(error) => write!(f, "failed to register SIGTERM handler: {error}"),
1310 Self::InvalidPort { raw, source } => {
1311 write!(f, "invalid {SUBC_PORT_ENV} value '{raw}': {source}")
1312 }
1313 Self::ConnectionFileRead { path, source } => write!(
1314 f,
1315 "failed to read connection file {}: {source}",
1316 path.display()
1317 ),
1318 Self::ConnectionFileWrite { path, source } => write!(
1319 f,
1320 "failed to publish connection file {}: {source}",
1321 path.display()
1322 ),
1323 Self::GenerateConnectionFile(err) => {
1324 write!(f, "failed to generate connection-file auth material: {err}")
1325 }
1326 Self::StartLockCreate { path, source } => {
1327 write!(
1328 f,
1329 "failed to create start lock {}: {source}",
1330 path.display()
1331 )
1332 }
1333 Self::StartLockBusy { path, attempts } => write!(
1334 f,
1335 "start lock {} remained busy after {attempts} attempts",
1336 path.display()
1337 ),
1338 Self::RunDirLockCreate { path, source } => write!(
1339 f,
1340 "refusing to start: failed to lock run directory via {}: {source}",
1341 path.display()
1342 ),
1343 Self::RunDirBusy { path, holder_pid } => {
1344 write!(
1345 f,
1346 "refusing to start: run directory lock {} is held by another daemon",
1347 path.display()
1348 )?;
1349 if let Some(pid) = holder_pid {
1350 write!(f, " (pid {pid})")?;
1351 }
1352 write!(
1353 f,
1354 "; a live daemon owns this data home's run state (typically one started with a different XDG_RUNTIME_DIR)"
1355 )
1356 }
1357 Self::RemoveStale { path, source } => write!(
1358 f,
1359 "failed to remove stale connection file {}: {source}",
1360 path.display()
1361 ),
1362 Self::Bind { host, port, source } if source.kind() == io::ErrorKind::AddrInUse => {
1363 write!(
1364 f,
1365 "port {port} in use on loopback {host}: {source}; set the port in config"
1366 )
1367 }
1368 Self::Bind { host, port, source } => {
1369 write!(f, "failed to bind loopback TCP {host}:{port}: {source}")
1370 }
1371 Self::LocalAddr { host, source } => {
1372 write!(f, "failed to read local address for {host}: {source}")
1373 }
1374 Self::DaemonConfig(err) => write!(f, "failed to load daemon config: {err}"),
1375 Self::MachineId(err) => write!(f, "refusing to start: {err}"),
1376 Self::RunDir(err) => write!(f, "refusing to start: {err}"),
1377 Self::Serve(err) => write!(f, "daemon server failed: {err}"),
1378 Self::ServeJoin(err) => write!(f, "daemon server task failed: {err}"),
1379 }
1380 }
1381}
1382
1383impl Error for BootstrapError {
1384 fn source(&self) -> Option<&(dyn Error + 'static)> {
1385 match self {
1386 #[cfg(unix)]
1387 Self::Signal(source) => Some(source),
1388 Self::InvalidPort { source, .. } => Some(source),
1389 Self::ConnectionFileRead { source, .. }
1390 | Self::ConnectionFileWrite { source, .. }
1391 | Self::GenerateConnectionFile(source) => Some(source),
1392 Self::StartLockCreate { source, .. }
1393 | Self::RunDirLockCreate { source, .. }
1394 | Self::RemoveStale { source, .. }
1395 | Self::Bind { source, .. }
1396 | Self::LocalAddr { source, .. } => Some(source),
1397 Self::DaemonConfig(err) => Some(err),
1398 Self::MachineId(err) => Some(err),
1399 Self::RunDir(err) => Some(err),
1400 Self::Serve(err) => Some(err),
1401 Self::ServeJoin(err) => Some(err),
1402 Self::StartLockBusy { .. } | Self::RunDirBusy { .. } => None,
1403 }
1404 }
1405}
1406
1407#[cfg(test)]
1408mod tests {
1409 use super::*;
1410 use crate::server::ServerAuth;
1411 #[cfg(target_os = "linux")]
1412 use std::collections::BTreeSet;
1413 use std::sync::Mutex;
1414 #[cfg(target_os = "linux")]
1415 use subc_control::ModuleProtocol;
1416 use subc_test_support::TestTempDir;
1417 use subc_transport::MIN_KEY_LEN;
1418 use tokio::io::AsyncReadExt;
1419 use tokio::task::JoinHandle;
1420
1421 #[cfg(unix)]
1422 use std::os::unix::fs::PermissionsExt;
1423
1424 static ENV_LOCK: Mutex<()> = Mutex::new(());
1425
1426 #[test]
1427 fn normalized_build_provenance_preserves_real_values() {
1428 assert_eq!(normalized_build_provenance("abc"), Some("abc".to_string()));
1429 }
1430
1431 #[test]
1432 fn normalized_build_provenance_omits_unavailable_and_empty_values() {
1433 assert_eq!(normalized_build_provenance("unavailable"), None);
1434 assert_eq!(normalized_build_provenance(""), None);
1435 }
1436
1437 #[cfg(target_os = "linux")]
1438 fn current_cgroup_path_for_test() -> io::Result<PathBuf> {
1439 let cgroups = fs::read_to_string("/proc/self/cgroup")?;
1440 let relative = cgroups
1441 .lines()
1442 .find_map(|line| line.strip_prefix("0::"))
1443 .ok_or_else(|| {
1444 io::Error::new(io::ErrorKind::Unsupported, "cgroup v2 is unavailable")
1445 })?;
1446 Ok(Path::new("/sys/fs/cgroup").join(relative.trim_start_matches('/')))
1447 }
1448
1449 #[cfg(target_os = "linux")]
1450 fn module_cgroup_directories() -> io::Result<BTreeSet<OsString>> {
1451 let modules = current_cgroup_path_for_test()?.join("subc-modules");
1452 let entries = match fs::read_dir(modules) {
1453 Ok(entries) => entries,
1454 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(BTreeSet::new()),
1455 Err(error) => return Err(error),
1456 };
1457 let mut directories = BTreeSet::new();
1458 for entry in entries {
1459 let entry = entry?;
1460 if entry.file_type()?.is_dir() {
1461 directories.insert(entry.file_name());
1462 }
1463 }
1464 Ok(directories)
1465 }
1466
1467 #[cfg(target_os = "linux")]
1468 async fn wait_for_path(path: &Path, task: &JoinHandle<Result<(), BootstrapError>>) {
1469 let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
1470 while !path.exists() && tokio::time::Instant::now() < deadline {
1471 assert!(
1472 !task.is_finished(),
1473 "daemon exited before creating {}",
1474 path.display()
1475 );
1476 sleep(Duration::from_millis(10)).await;
1477 }
1478 assert!(path.exists(), "daemon did not create {}", path.display());
1479 }
1480
1481 #[cfg(target_os = "linux")]
1486 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1487 async fn run_with_config_does_not_reconcile_the_ambient_cgroup_by_default() {
1488 let temp = unique_temp_dir("bootstrap-cgroup-default-disabled");
1489 let module_id = format!("cgroup-isolation-probe-{}", process::id());
1490 let before = module_cgroup_directories().expect("read ambient module cgroups before boot");
1491 assert!(
1492 !before.contains(&OsString::from(&module_id)),
1493 "isolation probe cgroup already exists before this daemon starts"
1494 );
1495 let capture = temp.join("logs").join(format!("{module_id}.stderr.log"));
1496 let module = ConfiguredModule {
1497 module_id,
1498 program: PathBuf::from("sh"),
1499 args: vec!["-c".to_string(), "sleep 30".to_string()],
1500 env: Vec::new(),
1501 log: None,
1502 enabled: true,
1503 reserved: false,
1504 reserved_prefixes: Vec::new(),
1505 protocol: ModuleProtocol::None,
1506 overlap: Default::default(),
1507 health: HealthConfig::default(),
1508 drain_timeout_ms: None,
1509 route_bind_relay_timeout_ms: None,
1510 restart: RestartPolicy::default(),
1511 };
1512 let config = BootstrapConfig::new(temp.join("connection.json"), 0)
1513 .with_configured_modules([module])
1514 .with_capture_logs_dir(temp.join("logs"))
1515 .with_terminal_journal_path(temp.join("terminals.jsonl"));
1516 let task = tokio::spawn(run_with_config(config));
1517
1518 wait_for_path(&capture, &task).await;
1519 let after = module_cgroup_directories().expect("read ambient module cgroups after boot");
1520
1521 task.abort();
1522 assert!(task
1523 .await
1524 .expect_err("aborted daemon task must cancel")
1525 .is_cancelled());
1526 assert_eq!(
1527 before, after,
1528 "an in-process daemon must not create or reconcile ambient module cgroups"
1529 );
1530 }
1531
1532 #[cfg(target_os = "linux")]
1533 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1534 async fn explicit_cgroup_root_is_prepared_inside_the_fixture_tree() {
1535 let temp = unique_temp_dir("bootstrap-cgroup-explicit-root");
1536 let cgroup_root = temp.join("cgroup");
1537 fs::create_dir(&cgroup_root).expect("create scratch cgroup root");
1538 fs::write(cgroup_root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
1539 let modules = cgroup_root.join("subc-modules");
1540 let config = BootstrapConfig::new(temp.join("connection.json"), 0)
1541 .with_cgroup_placement(CgroupPlacementConfig::Root(cgroup_root))
1542 .with_terminal_journal_path(temp.join("terminals.jsonl"));
1543 let task = tokio::spawn(run_with_config(config));
1544
1545 wait_for_path(&modules, &task).await;
1546
1547 task.abort();
1548 assert!(task
1549 .await
1550 .expect_err("aborted daemon task must cancel")
1551 .is_cancelled());
1552 assert!(
1553 fs::read_dir(&modules)
1554 .expect("read prepared modules directory")
1555 .next()
1556 .is_none(),
1557 "the delegation probe must clean up after itself"
1558 );
1559 }
1560
1561 struct EnvGuard {
1562 key: &'static str,
1563 previous: Option<OsString>,
1564 }
1565
1566 impl EnvGuard {
1567 fn set(key: &'static str, value: &Path) -> Self {
1568 let previous = env::var_os(key);
1569 env::set_var(key, value);
1570 Self { key, previous }
1571 }
1572
1573 fn set_str(key: &'static str, value: &str) -> Self {
1574 let previous = env::var_os(key);
1575 env::set_var(key, value);
1576 Self { key, previous }
1577 }
1578
1579 fn unset(key: &'static str) -> Self {
1580 let previous = env::var_os(key);
1581 env::remove_var(key);
1582 Self { key, previous }
1583 }
1584 }
1585
1586 impl Drop for EnvGuard {
1587 fn drop(&mut self) {
1588 match &self.previous {
1589 Some(value) => env::set_var(self.key, value),
1590 None => env::remove_var(self.key),
1591 }
1592 }
1593 }
1594
1595 fn unique_temp_dir(name: &str) -> TestTempDir {
1596 TestTempDir::new(name)
1597 }
1598
1599 fn temp_connection_file_path(name: &str) -> (TestTempDir, PathBuf) {
1600 let dir = unique_temp_dir(name);
1601 let path = dir.join("conn.json");
1602 (dir, path)
1603 }
1604
1605 fn auth_for(info: &ConnectionInfo) -> ServerAuth {
1606 ServerAuth::new(info.key.clone(), info.daemon_id, info.daemon_ver.clone())
1607 }
1608
1609 fn start_server(bound: BoundDaemon) -> JoinHandle<Result<(), ServerError>> {
1610 let auth = auth_for(&bound.connection_info);
1611 tokio::spawn(serve_listeners(
1612 bound.listeners,
1613 Arc::new(Router::with_default_self_handler()),
1614 auth,
1615 ))
1616 }
1617
1618 fn expect_bound(outcome: Outcome) -> BoundDaemon {
1619 match outcome {
1620 Outcome::Bound(bound) => bound,
1621 Outcome::AlreadyRunning => panic!("fresh connection file unexpectedly had a daemon"),
1622 }
1623 }
1624
1625 async fn connect_from_info(conn: &ConnectionInfo) -> io::Result<TcpStream> {
1626 let endpoint = conn
1627 .endpoints
1628 .first()
1629 .expect("test connection file should have an endpoint");
1630 let ip: IpAddr = endpoint.host.parse().unwrap();
1631 TcpStream::connect(SocketAddr::new(ip, endpoint.port)).await
1632 }
1633
1634 fn make_connection_info(port: u16) -> ConnectionInfo {
1635 ConnectionInfo {
1636 schema: SCHEMA_VERSION,
1637 wire_version: Some(PROTOCOL_VERSION),
1638 endpoints: vec![Endpoint {
1639 host: "127.0.0.1".to_owned(),
1640 port,
1641 }],
1642 key: generate_key().unwrap(),
1643 daemon_id: generate_daemon_id().unwrap(),
1644 pid: process::id(),
1645 daemon_ver: "test-subc".to_owned(),
1646 }
1647 }
1648
1649 fn write_raw_owner_only_connection_file(path: &Path, contents: &[u8]) {
1650 fs::write(path, contents).unwrap();
1651 #[cfg(unix)]
1652 fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap();
1653 }
1654
1655 fn assert_owner_only_connection_file(path: &Path) {
1656 #[cfg(unix)]
1659 {
1660 let mode = fs::metadata(path).unwrap().permissions().mode() & 0o777;
1661 assert_eq!(mode, 0o600);
1662 }
1663 #[cfg(not(unix))]
1664 let _ = path;
1665 }
1666
1667 #[test]
1668 fn connection_file_path_uses_xdg_runtime_dir_when_set() {
1669 let _env_lock = ENV_LOCK.lock().unwrap();
1670 let runtime_dir = unique_temp_dir("xdg-runtime");
1671 let _xdg = EnvGuard::set("XDG_RUNTIME_DIR", runtime_dir.path());
1672
1673 assert_eq!(
1674 connection_file_path(),
1675 runtime_dir.join(CONNECTION_FILE_NAME)
1676 );
1677 }
1678
1679 #[test]
1680 fn connection_file_path_source_is_xdg_runtime_dir_when_set() {
1681 let runtime_dir = OsString::from("/run/user/1000");
1682
1683 let (path, source) = connection_file_path_with_source(Some(runtime_dir));
1684
1685 assert_eq!(
1686 path,
1687 PathBuf::from("/run/user/1000").join(CONNECTION_FILE_NAME)
1688 );
1689 assert_eq!(source, ConnectionFileSource::XdgRuntimeDir);
1690 }
1691
1692 #[test]
1693 fn connection_file_path_falls_back_to_temp_dir_with_user_token_when_xdg_unset() {
1694 let _env_lock = ENV_LOCK.lock().unwrap();
1695 let _xdg = EnvGuard::unset("XDG_RUNTIME_DIR");
1696
1697 assert_eq!(
1698 connection_file_path(),
1699 env::temp_dir().join(format!("subc-{}.connection.json", user_connection_token()))
1700 );
1701 }
1702
1703 #[test]
1704 fn connection_file_path_source_is_temp_dir_when_xdg_unset() {
1705 let (path, source) = connection_file_path_with_source(None);
1706
1707 assert_eq!(
1708 path,
1709 env::temp_dir().join(format!("subc-{}.connection.json", user_connection_token()))
1710 );
1711 assert_eq!(source, ConnectionFileSource::TempDirFallback);
1712 }
1713
1714 #[test]
1719 fn user_connection_token_is_stable_under_concurrent_callers() {
1720 let expected = user_connection_token();
1721 let workers: Vec<_> = (0..32)
1722 .map(|_| {
1723 std::thread::spawn(|| (0..40).map(|_| user_connection_token()).collect::<Vec<_>>())
1724 })
1725 .collect();
1726 for worker in workers {
1727 for token in worker.join().expect("probe thread") {
1728 assert_eq!(token, expected, "token diverged under concurrent probes");
1729 }
1730 }
1731 }
1732
1733 #[test]
1734 fn connection_file_path_source_is_temp_dir_when_xdg_empty() {
1735 let (path, source) = connection_file_path_with_source(Some(OsString::new()));
1736
1737 assert_eq!(
1738 path,
1739 env::temp_dir().join(format!("subc-{}.connection.json", user_connection_token()))
1740 );
1741 assert_eq!(source, ConnectionFileSource::TempDirFallback);
1742 }
1743
1744 #[test]
1748 fn daemon_binary_config_journals_and_captures_into_the_run_dir() {
1749 let _env_lock = ENV_LOCK.lock().unwrap();
1750 let root = unique_temp_dir("daemon-binary-config");
1751 let data_home = root.join("data");
1752 let _data = EnvGuard::set("XDG_DATA_HOME", &data_home);
1753 let _config = EnvGuard::set("XDG_CONFIG_HOME", &root.join("config"));
1754 let _port = EnvGuard::unset(SUBC_PORT_ENV);
1755
1756 let config = BootstrapConfig::from_env_for_daemon_binary().unwrap();
1757
1758 let run_dir = data_home.join("cortexkit").join("run");
1759 assert_eq!(
1760 config.terminal_journal_path,
1761 Some(run_dir.join("terminals.jsonl"))
1762 );
1763 assert_eq!(config.capture_logs_dir, Some(run_dir.join("logs")));
1764 assert_eq!(
1765 BootstrapConfig::new(root.join("connection.json"), 0).terminal_journal_path,
1766 None,
1767 "an in-process config must not journal anywhere unless asked to"
1768 );
1769 }
1770
1771 #[test]
1772 fn daemon_binary_config_refuses_a_relative_data_home() {
1773 let _env_lock = ENV_LOCK.lock().unwrap();
1774 let root = unique_temp_dir("daemon-binary-relative-data");
1775 let _data = EnvGuard::set_str("XDG_DATA_HOME", "relative-data-home");
1776 let _config = EnvGuard::set("XDG_CONFIG_HOME", &root.join("config"));
1777 let _port = EnvGuard::unset(SUBC_PORT_ENV);
1778
1779 let error = BootstrapConfig::from_env_for_daemon_binary()
1780 .expect_err("a relative data home must refuse the daemon binary's config");
1781 assert!(
1782 matches!(error, BootstrapError::RunDir(_)),
1783 "expected a run-directory refusal, got {error}"
1784 );
1785 assert!(error.to_string().contains("XDG_DATA_HOME"), "{error}");
1786 }
1787
1788 #[test]
1789 fn configured_port_uses_default_config_and_env_override() {
1790 let _env_lock = ENV_LOCK.lock().unwrap();
1791 let (_dir, conn_path) = temp_connection_file_path("daemon-config-port");
1792 let config_path = conn_path.with_file_name("subc.jsonc");
1793
1794 let _port = EnvGuard::unset(SUBC_PORT_ENV);
1795 assert_eq!(
1796 BootstrapConfig::from_env_with_daemon_config_path(&config_path)
1797 .unwrap()
1798 .port,
1799 DEFAULT_SUBC_PORT
1800 );
1801
1802 fs::write(&config_path, r#"{ "version": 1, "port": 8123 }"#).unwrap();
1803 assert_eq!(
1804 BootstrapConfig::from_env_with_daemon_config_path(&config_path)
1805 .unwrap()
1806 .port,
1807 8123
1808 );
1809
1810 let _port = EnvGuard::set_str(SUBC_PORT_ENV, "9012");
1811 assert_eq!(
1812 BootstrapConfig::from_env_with_daemon_config_path(&config_path)
1813 .unwrap()
1814 .port,
1815 9012
1816 );
1817 }
1818
1819 #[tokio::test]
1820 async fn second_singleton_probe_against_served_tcp_daemon_reports_already_running() {
1821 let (_dir, path) = temp_connection_file_path("already-running");
1822
1823 let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
1824 let server = start_server(bound);
1825
1826 let second = ensure_singleton(&path, 0).await.unwrap();
1827 assert!(matches!(second, Outcome::AlreadyRunning));
1828
1829 server.abort();
1830 let _ = server.await;
1831 }
1832
1833 #[tokio::test]
1834 async fn daemon_connection_file_publishes_protocol_wire_version() {
1835 let (_dir, path) = temp_connection_file_path("wire-version");
1836 let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
1837 assert_eq!(bound.connection_info.wire_version, Some(PROTOCOL_VERSION));
1838 assert_eq!(
1839 connection_file::read(&path).unwrap().wire_version,
1840 Some(PROTOCOL_VERSION)
1841 );
1842
1843 drop(bound.listeners);
1844 }
1845
1846 #[tokio::test]
1847 async fn stale_unbound_connection_file_is_reclaimed() {
1848 let (_dir, path) = temp_connection_file_path("stale-reclaim");
1849 let stale = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
1850 let stale_port = stale.local_addr().unwrap().port();
1851 drop(stale);
1852 let stale_info = make_connection_info(stale_port);
1853 write_atomic(&path, &stale_info).unwrap();
1854
1855 let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
1856 assert_ne!(bound.connection_info.key, stale_info.key);
1857 drop(bound.listeners);
1858 }
1859
1860 #[cfg(unix)]
1861 #[tokio::test]
1862 async fn ensure_singleton_reclaims_insecure_connection_file() {
1863 let (_dir, path) = temp_connection_file_path("insecure-reclaim");
1864 let stale = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
1865 let stale_port = stale.local_addr().unwrap().port();
1866 drop(stale);
1867 let stale_info = make_connection_info(stale_port);
1868 write_atomic(&path, &stale_info).unwrap();
1869 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
1870
1871 let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
1872 assert_ne!(bound.connection_info.key, stale_info.key);
1873 assert_ne!(bound.connection_info.daemon_id, stale_info.daemon_id);
1874 assert_owner_only_connection_file(&path);
1875
1876 drop(bound.listeners);
1877 }
1878
1879 #[tokio::test]
1880 async fn ensure_singleton_reclaims_non_loopback_connection_file() {
1881 let (_dir, path) = temp_connection_file_path("non-loopback-reclaim");
1882 let mut stale_info = make_connection_info(8757);
1883 stale_info.endpoints = vec![Endpoint {
1884 host: "192.0.2.10".to_owned(),
1885 port: 8757,
1886 }];
1887 write_atomic(&path, &stale_info).unwrap();
1888
1889 let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
1890 assert_ne!(bound.connection_info.key, stale_info.key);
1891 assert_ne!(bound.connection_info.daemon_id, stale_info.daemon_id);
1892 assert!(bound
1893 .connection_info
1894 .endpoints
1895 .iter()
1896 .all(|endpoint| endpoint.host.parse::<IpAddr>().unwrap().is_loopback()));
1897 assert_owner_only_connection_file(&path);
1898
1899 drop(bound.listeners);
1900 }
1901
1902 #[tokio::test]
1903 async fn ensure_singleton_reclaims_invalid_connection_file_shapes() {
1904 let mut unsupported_schema = make_connection_info(8757);
1905 unsupported_schema.schema = SCHEMA_VERSION + 1;
1906
1907 let mut empty_endpoints = make_connection_info(8757);
1908 empty_endpoints.endpoints.clear();
1909
1910 let mut short_key = make_connection_info(8757);
1911 short_key.key = vec![0x5A; MIN_KEY_LEN - 1];
1912
1913 let cases = vec![
1914 (
1915 "unsupported-schema",
1916 serde_json::to_vec(&unsupported_schema).unwrap(),
1917 Some(unsupported_schema),
1918 ),
1919 (
1920 "empty-endpoints",
1921 serde_json::to_vec(&empty_endpoints).unwrap(),
1922 Some(empty_endpoints),
1923 ),
1924 (
1925 "short-key",
1926 serde_json::to_vec(&short_key).unwrap(),
1927 Some(short_key),
1928 ),
1929 ("invalid-json", b"{not valid connection json".to_vec(), None),
1930 ];
1931
1932 for (label, contents, old_info) in cases {
1933 let (_dir, path) = temp_connection_file_path(label);
1934 write_raw_owner_only_connection_file(&path, &contents);
1935
1936 let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
1937 if let Some(old_info) = old_info {
1938 assert_ne!(bound.connection_info.key, old_info.key, "{label}");
1939 assert_ne!(
1940 bound.connection_info.daemon_id, old_info.daemon_id,
1941 "{label}"
1942 );
1943 }
1944 assert!(bound.connection_info.key.len() >= MIN_KEY_LEN, "{label}");
1945 assert_ne!(bound.connection_info.daemon_id, [0u8; 16], "{label}");
1946 assert_owner_only_connection_file(&path);
1947
1948 drop(bound.listeners);
1949 }
1950 }
1951
1952 #[tokio::test]
1953 async fn foreign_reused_port_connection_file_is_reclaimed_after_auth_probe_fails() {
1954 let (_dir, path) = temp_connection_file_path("foreign-reclaim");
1955 let foreign = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
1956 let foreign_port = foreign.local_addr().unwrap().port();
1957 write_atomic(&path, &make_connection_info(foreign_port)).unwrap();
1958 let foreign_task = tokio::spawn(async move {
1959 if let Ok((mut stream, _)) = foreign.accept().await {
1960 let mut buf = [0u8; 64];
1961 let _ = stream.read(&mut buf).await;
1962 }
1963 });
1964
1965 let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
1966 assert!(bound
1967 .connection_info
1968 .endpoints
1969 .iter()
1970 .all(|endpoint| endpoint.port != foreign_port));
1971
1972 drop(bound.listeners);
1973 let _ = foreign_task.await;
1974 }
1975
1976 #[tokio::test]
1977 async fn stale_start_lock_file_is_reclaimable() {
1978 let (_dir, path) = temp_connection_file_path("start-lock-stale-file");
1979 let lock_path = start_lock_path(&path);
1980 drop(open_owner_only_lock(&lock_path).unwrap());
1981 assert!(lock_path.is_file());
1982
1983 let lock = StartLock::acquire(&path).await.unwrap();
1984 assert!(lock_path.is_file());
1985
1986 drop(lock);
1987 assert!(lock_path.is_file());
1988 }
1989
1990 #[tokio::test]
1991 async fn held_start_lock_blocks_second_acquire_until_release() {
1992 let (_dir, path) = temp_connection_file_path("start-lock-held");
1993 let lock_path = start_lock_path(&path);
1994 let first = StartLock::acquire(&path).await.unwrap();
1995
1996 let err = match StartLock::acquire(&path).await {
1997 Ok(_) => panic!("second acquire while held must stay busy"),
1998 Err(err) => err,
1999 };
2000 assert!(matches!(
2001 err,
2002 BootstrapError::StartLockBusy {
2003 ref path,
2004 attempts: START_LOCK_RETRIES,
2005 } if path == &lock_path
2006 ));
2007
2008 drop(first);
2009
2010 let second = StartLock::acquire(&path)
2011 .await
2012 .expect("released advisory lock should be reclaimable");
2013 drop(second);
2014 }
2015
2016 #[tokio::test]
2017 async fn bind_conflict_on_fixed_port_fails_loud_without_reselecting() {
2018 let (_dir, path) = temp_connection_file_path("bind-conflict");
2019 let occupied = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
2020 let occupied_port = occupied.local_addr().unwrap().port();
2021
2022 let err = ensure_singleton(&path, occupied_port).await.unwrap_err();
2023 assert!(matches!(
2024 err,
2025 BootstrapError::Bind { ref source, .. } if source.kind() == io::ErrorKind::AddrInUse
2026 ));
2027 assert!(err.to_string().contains("set the port in config"));
2028
2029 drop(occupied);
2030 }
2031
2032 #[tokio::test]
2033 async fn key_rotation_republishes_new_material_and_old_file_fails_auth() {
2034 let (_dir, path) = temp_connection_file_path("key-rotation");
2035 let first = expect_bound(ensure_singleton(&path, 0).await.unwrap());
2036 let old_info = first.connection_info.clone();
2037 let fixed_port = old_info.endpoints[0].port;
2038 drop(first.listeners);
2039
2040 let second = expect_bound(ensure_singleton(&path, fixed_port).await.unwrap());
2041 let new_info = second.connection_info.clone();
2042 assert_ne!(old_info.key, new_info.key);
2043 assert_ne!(old_info.daemon_id, new_info.daemon_id);
2044 let server = start_server(second);
2045
2046 let mut old_stream = connect_from_info(&old_info).await.unwrap();
2047 let old_auth = authenticate_client(&mut old_stream, &old_info, PROBE_AUTH_DEADLINE).await;
2048 assert!(
2049 old_auth.is_err(),
2050 "old key must not authenticate after restart"
2051 );
2052
2053 let reread = connection_file::read(&path).unwrap();
2054 let mut new_stream = connect_from_info(&reread).await.unwrap();
2055 authenticate_client(&mut new_stream, &reread, PROBE_AUTH_DEADLINE)
2056 .await
2057 .unwrap();
2058
2059 server.abort();
2060 let _ = server.await;
2061 }
2062
2063 #[cfg(unix)]
2064 #[tokio::test]
2065 async fn published_connection_file_permissions_are_owner_only() {
2066 let (_dir, path) = temp_connection_file_path("permissions");
2067 let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
2068
2069 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
2070 assert_eq!(mode, 0o600);
2071
2072 drop(bound.listeners);
2073 }
2074}