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)]
88pub enum CgroupPlacementConfig {
89 #[default]
90 Disabled,
91 Current,
92 Root(PathBuf),
93}
94
95#[derive(Debug, Clone)]
96pub struct BootstrapConfig {
97 pub connection_file_path: PathBuf,
98 pub port: u16,
99 pub daemon_ver: String,
100 configured_modules: Vec<ConfiguredModule>,
101 storage_config: Option<daemon_config::StorageConfig>,
102 admission_facts: AdmissionFactsConfig,
103 daemon_config_path: Option<PathBuf>,
104 configured_port: Option<u16>,
105 route_bind_relay_default_ms: Option<u64>,
109 reserved_capabilities: BTreeMap<String, String>,
110 watchdog_config: DaemonSelfWatchdogConfig,
111 connection_file_source: ConnectionFileSource,
112 cgroup_placement: CgroupPlacementConfig,
115 capture_logs_dir: Option<PathBuf>,
119 terminal_journal_path: Option<PathBuf>,
120}
121
122impl BootstrapConfig {
123 pub fn new(connection_file_path: impl Into<PathBuf>, port: u16) -> Self {
124 Self {
125 connection_file_path: connection_file_path.into(),
126 port,
127 daemon_ver: DAEMON_VERSION.to_owned(),
128 configured_modules: Vec::new(),
129 storage_config: None,
130 admission_facts: AdmissionFactsConfig::default(),
131 daemon_config_path: None,
132 configured_port: None,
133 route_bind_relay_default_ms: None,
134 reserved_capabilities: BTreeMap::new(),
135 watchdog_config: DaemonSelfWatchdogConfig::default(),
136 connection_file_source: ConnectionFileSource::Explicit,
137 cgroup_placement: CgroupPlacementConfig::default(),
138 capture_logs_dir: None,
139 terminal_journal_path: None,
140 }
141 }
142
143 pub fn with_cgroup_placement(mut self, placement: CgroupPlacementConfig) -> Self {
145 self.cgroup_placement = placement;
146 self
147 }
148
149 pub fn with_capture_logs_dir(mut self, dir: impl Into<PathBuf>) -> Self {
153 self.capture_logs_dir = Some(dir.into());
154 self
155 }
156
157 pub fn with_terminal_journal_path(mut self, path: impl Into<PathBuf>) -> Self {
160 self.terminal_journal_path = Some(path.into());
161 self
162 }
163
164 pub fn from_env() -> Result<Self, BootstrapError> {
165 Self::from_env_with_daemon_config_path(daemon_config::default_config_path())
166 }
167
168 pub fn from_env_for_daemon_binary() -> Result<Self, BootstrapError> {
175 Ok(Self::from_env()?.with_capture_logs_dir(daemon_config::daemon_run_dir().join("logs")))
176 }
177
178 pub fn from_env_with_daemon_config_path(
179 daemon_config_path: impl AsRef<Path>,
180 ) -> Result<Self, BootstrapError> {
181 let daemon_config_path = daemon_config_path.as_ref().to_path_buf();
182 let daemon_config =
183 daemon_config::load(&daemon_config_path).map_err(BootstrapError::DaemonConfig)?;
184 let config_port = daemon_config.as_ref().and_then(|config| config.port);
185 let storage_config = daemon_config
186 .as_ref()
187 .and_then(|config| config.storage.clone());
188 let admission_facts_carrier_module_id = daemon_config
189 .as_ref()
190 .and_then(|config| config.admission_facts_carrier_module_id.clone());
191 let admission_facts_targets = daemon_config
192 .as_ref()
193 .and_then(|config| config.admission_facts_targets.clone());
194 let route_bind_relay_default_ms = daemon_config
195 .as_ref()
196 .and_then(|config| config.route_bind_relay_timeout_ms);
197 let reserved_capabilities = daemon_config
198 .as_ref()
199 .map(|config| config.reserved_capabilities.clone())
200 .unwrap_or_default();
201 let configured_modules = daemon_config
202 .map(|config| config.modules)
203 .unwrap_or_default();
204
205 let port = match env::var(SUBC_PORT_ENV) {
206 Ok(raw) if !raw.trim().is_empty() => {
207 let port = raw
208 .parse::<u16>()
209 .map_err(|source| BootstrapError::InvalidPort { raw, source })?;
210 if let Some(config_port) = config_port {
211 info!(
212 env = SUBC_PORT_ENV,
213 env_port = port,
214 config_port,
215 "SUBC_PORT overrides daemon config port"
216 );
217 }
218 port
219 }
220 Ok(_) | Err(_) => config_port.unwrap_or(DEFAULT_SUBC_PORT),
221 };
222
223 let (connection_file_path, connection_file_source) =
224 connection_file_path_with_source(non_empty_os_var("XDG_RUNTIME_DIR"));
225 Ok(Self::new(connection_file_path, port)
226 .with_configured_modules(configured_modules)
227 .with_storage_config(storage_config)
228 .with_admission_facts_config(admission_facts_carrier_module_id, admission_facts_targets)
229 .with_route_bind_relay_default_ms(route_bind_relay_default_ms)
230 .with_reserved_capabilities(reserved_capabilities)
231 .with_daemon_config_source(daemon_config_path, config_port)
232 .with_connection_file_source(connection_file_source))
233 }
234
235 pub fn with_daemon_config_path(
236 self,
237 daemon_config_path: impl AsRef<Path>,
238 ) -> Result<Self, BootstrapError> {
239 let daemon_config_path = daemon_config_path.as_ref().to_path_buf();
240 let daemon_config =
241 daemon_config::load(&daemon_config_path).map_err(BootstrapError::DaemonConfig)?;
242 let configured_port = daemon_config.as_ref().and_then(|config| config.port);
243 let storage_config = daemon_config
244 .as_ref()
245 .and_then(|config| config.storage.clone());
246 let admission_facts_carrier_module_id = daemon_config
247 .as_ref()
248 .and_then(|config| config.admission_facts_carrier_module_id.clone());
249 let admission_facts_targets = daemon_config
250 .as_ref()
251 .and_then(|config| config.admission_facts_targets.clone());
252 let route_bind_relay_default_ms = daemon_config
253 .as_ref()
254 .and_then(|config| config.route_bind_relay_timeout_ms);
255 let reserved_capabilities = daemon_config
256 .as_ref()
257 .map(|config| config.reserved_capabilities.clone())
258 .unwrap_or_default();
259 let configured_modules = daemon_config
260 .map(|config| config.modules)
261 .unwrap_or_default();
262 Ok(self
263 .with_configured_modules(configured_modules)
264 .with_storage_config(storage_config)
265 .with_admission_facts_config(admission_facts_carrier_module_id, admission_facts_targets)
266 .with_route_bind_relay_default_ms(route_bind_relay_default_ms)
267 .with_reserved_capabilities(reserved_capabilities)
268 .with_daemon_config_source(daemon_config_path, configured_port))
269 }
270
271 pub fn with_configured_modules(
272 mut self,
273 modules: impl IntoIterator<Item = ConfiguredModule>,
274 ) -> Self {
275 self.configured_modules = modules.into_iter().collect();
276 self.configured_modules
277 .sort_by(|left, right| left.module_id.cmp(&right.module_id));
278 self
279 }
280
281 pub fn with_storage_config(
282 mut self,
283 storage_config: Option<daemon_config::StorageConfig>,
284 ) -> Self {
285 self.storage_config = storage_config;
286 self
287 }
288
289 pub fn with_admission_facts_config(
290 mut self,
291 carrier_module_id: Option<String>,
292 targets: Option<Vec<String>>,
293 ) -> Self {
294 self.admission_facts = AdmissionFactsConfig {
295 carrier_module_id,
296 targets,
297 };
298 self
299 }
300
301 pub fn with_route_bind_relay_default_ms(mut self, ms: Option<u64>) -> Self {
306 self.route_bind_relay_default_ms = ms;
307 self
308 }
309
310 pub fn with_reserved_capabilities(
311 mut self,
312 reserved_capabilities: BTreeMap<String, String>,
313 ) -> Self {
314 self.reserved_capabilities = reserved_capabilities;
315 self
316 }
317
318 fn with_daemon_config_source(
319 mut self,
320 daemon_config_path: PathBuf,
321 configured_port: Option<u16>,
322 ) -> Self {
323 self.daemon_config_path = Some(daemon_config_path);
324 self.configured_port = configured_port;
325 self
326 }
327
328 fn with_connection_file_source(mut self, source: ConnectionFileSource) -> Self {
329 self.connection_file_source = source;
330 self
331 }
332
333 pub fn with_watchdog_config(mut self, watchdog_config: DaemonSelfWatchdogConfig) -> Self {
334 self.watchdog_config = watchdog_config;
335 self
336 }
337}
338
339#[derive(Debug)]
341pub enum Outcome {
342 AlreadyRunning,
344 Bound(BoundDaemon),
347}
348
349#[derive(Debug)]
350pub struct BoundDaemon {
351 pub listeners: Vec<TcpListener>,
352 pub connection_info: ConnectionInfo,
353 pub connection_file_path: PathBuf,
354 pub connection_file_source: ConnectionFileSource,
355}
356
357pub fn connection_file_path() -> PathBuf {
364 connection_file_path_with_source(non_empty_os_var("XDG_RUNTIME_DIR")).0
365}
366
367fn connection_file_path_with_source(
368 runtime_dir: Option<OsString>,
369) -> (PathBuf, ConnectionFileSource) {
370 if let Some(runtime_dir) = runtime_dir.filter(|value| !value.is_empty()) {
371 return (
372 PathBuf::from(runtime_dir).join(CONNECTION_FILE_NAME),
373 ConnectionFileSource::XdgRuntimeDir,
374 );
375 }
376
377 (
378 env::temp_dir().join(format!("subc-{}.connection.json", user_connection_token())),
379 ConnectionFileSource::TempDirFallback,
380 )
381}
382
383pub async fn run() -> Result<(), BootstrapError> {
389 run_with_config(
392 BootstrapConfig::from_env_for_daemon_binary()?
393 .with_cgroup_placement(CgroupPlacementConfig::Current),
394 )
395 .await
396}
397
398pub async fn run_with_config(config: BootstrapConfig) -> Result<(), BootstrapError> {
422 let configured_modules = config.configured_modules.clone();
423 let storage_config = config.storage_config.clone();
424 let admission_facts = config.admission_facts.clone();
425 let daemon_config_path = config.daemon_config_path.clone();
426 let configured_port = config.configured_port;
427 let route_bind_relay_default_ms = config.route_bind_relay_default_ms;
428 let reserved_capabilities = config.reserved_capabilities.clone();
429 let watchdog_config = config.watchdog_config.clone();
430 let cgroup_placement_config = config.cgroup_placement.clone();
431 let capture_logs_dir = config.capture_logs_dir.clone();
432 let terminal_journal_path = config.terminal_journal_path.clone();
433 match ensure_singleton_with_config(config).await? {
434 Outcome::AlreadyRunning => {
435 info!("subc daemon already running");
436 Ok(())
437 }
438 Outcome::Bound(bound) => {
439 #[cfg(target_os = "linux")]
440 let cgroup_placement = prepare_cgroup_placement(&cgroup_placement_config);
441 #[cfg(not(target_os = "linux"))]
442 let _ = cgroup_placement_config;
443 serve_bound_daemon(
444 bound,
445 configured_modules,
446 storage_config,
447 admission_facts,
448 daemon_config_path,
449 configured_port,
450 route_bind_relay_default_ms,
451 reserved_capabilities,
452 watchdog_config,
453 capture_logs_dir,
454 terminal_journal_path,
455 #[cfg(target_os = "linux")]
456 cgroup_placement,
457 )
458 .await
459 }
460 }
461}
462
463#[cfg(target_os = "linux")]
464fn prepare_cgroup_placement(config: &CgroupPlacementConfig) -> Option<subc_cgroup::Placement> {
465 let result = match config {
466 CgroupPlacementConfig::Disabled => return None,
467 CgroupPlacementConfig::Current => subc_cgroup::prepare_current(),
468 CgroupPlacementConfig::Root(root) => subc_cgroup::prepare_at(root),
469 };
470
471 match result {
472 Ok(Some(placement)) => Some(placement),
473 Ok(None) => {
474 warn!(
475 placement = ?config,
476 "module cgroup placement is disabled: configured cgroup root is not delegated"
477 );
478 None
479 }
480 Err(error) => {
481 warn!(
482 placement = ?config,
483 error = %error,
484 "module cgroup placement is disabled by an unexpected cgroup probe error"
485 );
486 None
487 }
488 }
489}
490
491#[cfg(unix)]
498const NOFILE_TARGET: u64 = 65536;
499
500#[cfg(unix)]
504fn raise_nofile_limit() {
505 match rlimit::Resource::NOFILE.get() {
506 Ok((soft, hard)) => {
507 if soft >= NOFILE_TARGET {
508 return;
509 }
510 let target = NOFILE_TARGET.min(hard);
511 match rlimit::Resource::NOFILE.set(target, hard) {
512 Ok(()) => info!(
513 previous_soft = soft,
514 new_soft = target,
515 hard,
516 "raised open-file soft limit for daemon and module children"
517 ),
518 Err(err) => warn!(
519 soft,
520 hard,
521 error = %err,
522 "could not raise open-file soft limit; multi-root modules may exhaust descriptors"
523 ),
524 }
525 }
526 Err(err) => warn!(error = %err, "could not read open-file limit"),
527 }
528}
529
530#[cfg(windows)]
537fn raise_nofile_limit() {
538 const MAXSTDIO_TARGET: u32 = 8192;
539 let current = rlimit::getmaxstdio();
540 if current >= MAXSTDIO_TARGET {
541 return;
542 }
543 match rlimit::setmaxstdio(MAXSTDIO_TARGET) {
544 Ok(new_max) => info!(
545 previous = current,
546 new_max, "raised CRT stdio-stream limit for daemon"
547 ),
548 Err(err) => warn!(
549 current,
550 error = %err,
551 "could not raise CRT stdio-stream limit"
552 ),
553 }
554}
555
556#[cfg(not(any(unix, windows)))]
557fn raise_nofile_limit() {}
558
559pub async fn run_with_daemon_config_path(
560 config: BootstrapConfig,
561 daemon_config_path: impl AsRef<Path>,
562) -> Result<(), BootstrapError> {
563 run_with_config(config.with_daemon_config_path(daemon_config_path)?).await
564}
565
566#[allow(clippy::too_many_arguments)]
567async fn serve_bound_daemon(
568 bound: BoundDaemon,
569 configured_modules: Vec<ConfiguredModule>,
570 storage_config: Option<daemon_config::StorageConfig>,
571 admission_facts: AdmissionFactsConfig,
572 daemon_config_path: Option<PathBuf>,
573 configured_port: Option<u16>,
574 route_bind_relay_default_ms: Option<u64>,
575 reserved_capabilities: BTreeMap<String, String>,
576 watchdog_config: DaemonSelfWatchdogConfig,
577 capture_logs_dir: Option<PathBuf>,
578 terminal_journal_path: Option<PathBuf>,
579 #[cfg(target_os = "linux")] cgroup_placement: Option<subc_cgroup::Placement>,
580) -> Result<(), BootstrapError> {
581 #[cfg(unix)]
582 let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
583 .map_err(BootstrapError::Signal)?;
584 raise_nofile_limit();
587
588 info!(
589 connection_file = %bound.connection_file_path.display(),
590 connection_file_source = %bound.connection_file_source,
591 connection_file_source_reason = bound.connection_file_source.reason(),
592 endpoints = ?bound.connection_info.endpoints,
593 configured_modules = configured_modules.len(),
594 "subc daemon starting"
595 );
596
597 let registry = Arc::new(Registry::default());
598 let process_liveness = Arc::new(SupervisorProcessLiveness::new());
599 let supervisor_handle = SupervisorHandle::new();
600 let connected_clients = ConnectedClients::new();
601 let forwarding = Arc::new(ForwardingTable::default());
602 let supervisor = Supervisor::new(Arc::clone(®istry), RestartPolicy::default())
603 .with_process_liveness(process_liveness.clone())
604 .with_forwarding(Arc::clone(&forwarding))
605 .with_handle(supervisor_handle.clone())
606 .with_connection_file_path(bound.connection_file_path.clone())
607 .with_terminal_journal(
608 terminal_journal_path
609 .unwrap_or_else(|| daemon_config::daemon_run_dir().join("terminals.jsonl")),
610 format!(
611 "{:032x}",
612 u128::from_be_bytes(bound.connection_info.daemon_id)
613 ),
614 );
615 let supervisor = match capture_logs_dir {
642 Some(dir) => supervisor.with_capture_logs_dir(dir),
643 None => supervisor,
644 };
645 #[cfg(target_os = "linux")]
646 let supervisor = supervisor.with_cgroup_placement(cgroup_placement);
647 let route_bind_relay_timeouts = configured_modules
653 .iter()
654 .filter_map(|module| {
655 module
656 .route_bind_relay_timeout_ms
657 .map(|ms| (module.module_id.clone(), Duration::from_millis(ms)))
658 })
659 .collect::<std::collections::BTreeMap<_, _>>();
660 let control_start_clock = crate::clock::StartClock::capture();
661 let mut control = ControlHandler::with_forwarding(Arc::clone(®istry), forwarding)
662 .with_process_liveness(process_liveness)
663 .with_supervisor(supervisor_handle)
664 .with_connected_clients(connected_clients.clone())
665 .with_storage_config(storage_config)
666 .with_admission_facts_config(admission_facts.carrier_module_id, admission_facts.targets)
667 .with_route_bind_relay_timeouts(route_bind_relay_timeouts)
668 .with_daemon_provenance(
669 bound.connection_info.pid,
670 control_start_clock.started_at_ms(),
671 std::env::current_exe().ok(),
672 normalized_build_provenance(env!("SUBC_BUILD_GIT_SHA")),
673 normalized_build_provenance(env!("SUBC_BUILD_LOCK_DIGEST")),
674 )
675 .with_daemon_start_clock(control_start_clock)
676 .with_capability_config(
677 configured_modules
678 .iter()
679 .map(|module| (module.module_id.clone(), module.enabled)),
680 reserved_capabilities,
681 );
682 if let Some(ms) = route_bind_relay_default_ms {
683 control = control.with_route_bind_relay_timeout(Duration::from_millis(ms));
686 }
687 if let Some(config_path) = daemon_config_path {
688 control = control.with_supervisor_rescan(supervisor.clone(), config_path, configured_port);
689 }
690 let control = Arc::new(control);
691 let router = Arc::new(Router::with_control_handler(Arc::clone(&control)));
692 let auth = ServerAuth::new(
693 bound.connection_info.key.clone(),
694 bound.connection_info.daemon_id,
695 bound.connection_info.daemon_ver.clone(),
696 )
697 .with_connected_clients(connected_clients);
698
699 let mut serve_task =
700 AbortOnDrop::new(tokio::spawn(serve_listeners(bound.listeners, router, auth)));
701 tokio::task::yield_now().await;
702 let _clock_step_task = AbortOnDrop::new(crate::watchdog::spawn_clock_step_monitor());
703 let _watchdog_task = AbortOnDrop::new(
704 DaemonSelfWatchdog::new(
705 bound.connection_info.clone(),
706 bound.connection_file_path.clone(),
707 )
708 .with_config(watchdog_config)
709 .spawn(),
710 );
711
712 for configured in configured_modules {
713 let enabled = configured.enabled;
714 let health = configured.health;
715 let module_id = configured.module_id.clone();
716 match supervisor.supervise_configured_with_health(
717 configured.module_spec(),
718 enabled,
719 health,
720 configured.drain_timeout_ms,
721 configured.restart,
722 ) {
723 Ok(_) => {
724 let default_threshold = HealthConfig::default().failure_threshold;
733 if enabled && health.failure_threshold > default_threshold {
734 warn!(
735 module_id = %module_id,
736 failure_threshold = health.failure_threshold,
737 default_threshold,
738 tolerance_secs = health.cadence.as_secs() * u64::from(health.failure_threshold),
739 "health failure threshold is relaxed above the default; a wedged module stays unflagged for longer"
740 );
741 }
742 info!(module_id = %module_id, enabled, "configured module supervised");
743 }
744 Err(err) => {
745 error!(module_id = %module_id, error = %err, "failed to supervise configured module; continuing daemon startup");
746 }
747 }
748 }
749
750 control.refresh_capability_requirements();
751 Arc::clone(&control).spawn_capability_deadline_loop();
752
753 #[cfg(unix)]
754 {
755 tokio::select! {
756 result = serve_task.join() => {
757 return result.map_err(BootstrapError::ServeJoin)?.map_err(BootstrapError::Serve);
758 }
759 _ = terminate.recv() => {}
760 }
761 supervisor.stamp_shutdown();
763 drop(serve_task);
766 tokio::select! {
767 biased;
768 _ = terminate.recv() => info!("second SIGTERM: abandoning daemon shutdown wait"),
769 result = supervisor.drain_for_daemon_shutdown() => {
770 if let Err(error) = result {
771 warn!(%error, "daemon shutdown drain failed; exiting anyway");
772 }
773 }
774 }
775 Ok(())
776 }
777 #[cfg(not(unix))]
778 serve_task
779 .join()
780 .await
781 .map_err(BootstrapError::ServeJoin)?
782 .map_err(BootstrapError::Serve)
783}
784
785fn normalized_build_provenance(value: &str) -> Option<String> {
786 match value.trim() {
787 "" | "unavailable" => None,
788 value => Some(value.to_string()),
789 }
790}
791
792pub async fn ensure_singleton(
800 connection_file_path: impl AsRef<Path>,
801 port: u16,
802) -> Result<Outcome, BootstrapError> {
803 ensure_singleton_with_config(BootstrapConfig::new(connection_file_path.as_ref(), port)).await
804}
805
806pub async fn ensure_singleton_with_config(
807 config: BootstrapConfig,
808) -> Result<Outcome, BootstrapError> {
809 let path = config.connection_file_path;
810
811 if matches!(probe_existing(&path).await?, Probe::Live) {
812 return Ok(Outcome::AlreadyRunning);
813 }
814
815 let _lock = StartLock::acquire(&path).await?;
816
817 if matches!(probe_existing(&path).await?, Probe::Live) {
821 return Ok(Outcome::AlreadyRunning);
822 }
823
824 remove_stale_connection_file_if_present(&path)?;
825
826 let (listeners, endpoints) = bind_loopback(config.port).await?;
827 let connection_info = ConnectionInfo {
828 schema: SCHEMA_VERSION,
829 wire_version: Some(PROTOCOL_VERSION),
830 endpoints,
831 key: generate_key().map_err(BootstrapError::GenerateConnectionFile)?,
832 daemon_id: generate_daemon_id().map_err(BootstrapError::GenerateConnectionFile)?,
833 pid: process::id(),
834 daemon_ver: config.daemon_ver,
835 };
836
837 if let Err(source) = write_atomic(&path, &connection_info) {
838 drop(listeners);
839 return Err(BootstrapError::ConnectionFileWrite { path, source });
840 }
841
842 Ok(Outcome::Bound(BoundDaemon {
843 listeners,
844 connection_info,
845 connection_file_path: path,
846 connection_file_source: config.connection_file_source,
847 }))
848}
849
850#[derive(Debug, Clone, Copy, PartialEq, Eq)]
851enum Probe {
852 Live,
853 StaleOrAbsent,
854}
855
856async fn probe_existing(path: &Path) -> Result<Probe, BootstrapError> {
857 let info = match connection_file::read(path) {
858 Ok(info) => info,
859 Err(source) if is_absent_or_stale_connection_file(&source) => {
860 return Ok(Probe::StaleOrAbsent)
861 }
862 Err(source) => {
863 return Err(BootstrapError::ConnectionFileRead {
864 path: path.to_path_buf(),
865 source,
866 })
867 }
868 };
869
870 for endpoint in &info.endpoints {
871 if matches!(probe_endpoint(&info, endpoint).await, Probe::Live) {
872 return Ok(Probe::Live);
873 }
874 }
875
876 Ok(Probe::StaleOrAbsent)
877}
878
879async fn probe_endpoint(info: &ConnectionInfo, endpoint: &Endpoint) -> Probe {
880 let Ok(ip) = endpoint.host.parse::<IpAddr>() else {
881 return Probe::StaleOrAbsent;
882 };
883 if !ip.is_loopback() {
884 return Probe::StaleOrAbsent;
885 }
886 let addr = SocketAddr::new(ip, endpoint.port);
887
888 let mut stream = match timeout(CONNECT_TIMEOUT, TcpStream::connect(addr)).await {
889 Ok(Ok(stream)) => stream,
890 Ok(Err(_)) | Err(_) => return Probe::StaleOrAbsent,
891 };
892
893 match authenticate_client(&mut stream, info, PROBE_AUTH_DEADLINE).await {
894 Ok(()) => Probe::Live,
895 Err(AuthError::DaemonIdMismatch)
896 | Err(AuthError::InvalidServerProof)
897 | Err(AuthError::UnexpectedEof { .. })
898 | Err(AuthError::Timeout { .. })
899 | Err(AuthError::JsonEncode { .. })
900 | Err(AuthError::JsonDecode { .. })
901 | Err(AuthError::Io { .. })
902 | Err(AuthError::MessageTooLarge { .. })
903 | Err(AuthError::KeyTooShort { .. })
904 | Err(AuthError::Random(_))
905 | Err(AuthError::InvalidClientAuth) => Probe::StaleOrAbsent,
906 }
907}
908
909fn is_absent_or_stale_connection_file(err: &ConnectionFileError) -> bool {
910 match err {
911 ConnectionFileError::Io { source, .. } if source.kind() == io::ErrorKind::NotFound => true,
912 ConnectionFileError::JsonRead { .. }
913 | ConnectionFileError::UnsupportedSchema { .. }
914 | ConnectionFileError::Invalid { .. }
915 | ConnectionFileError::KeyTooShort { .. }
916 | ConnectionFileError::InsecurePermissions { .. } => true,
920 ConnectionFileError::MissingParent { .. }
921 | ConnectionFileError::MissingFileName { .. }
922 | ConnectionFileError::InsecureParentDirectory { .. }
926 | ConnectionFileError::Io { .. }
927 | ConnectionFileError::JsonWrite { .. }
928 | ConnectionFileError::Random(_)
929 | ConnectionFileError::WireVersionMismatch { .. } => false,
932 }
933}
934
935fn remove_stale_connection_file_if_present(path: &Path) -> Result<(), BootstrapError> {
936 match fs::remove_file(path) {
937 Ok(()) => Ok(()),
938 Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
939 Err(source) => Err(BootstrapError::RemoveStale {
940 path: path.to_path_buf(),
941 source,
942 }),
943 }
944}
945
946async fn bind_loopback(port: u16) -> Result<(Vec<TcpListener>, Vec<Endpoint>), BootstrapError> {
947 let v4_host = Ipv4Addr::LOCALHOST;
948 let v4 = TcpListener::bind((v4_host, port))
949 .await
950 .map_err(|source| BootstrapError::Bind {
951 host: v4_host.to_string(),
952 port,
953 source,
954 })?;
955 let actual_port = v4
956 .local_addr()
957 .map_err(|source| BootstrapError::LocalAddr {
958 host: v4_host.to_string(),
959 source,
960 })?
961 .port();
962
963 let mut listeners = vec![v4];
964 let mut endpoints = vec![Endpoint {
965 host: v4_host.to_string(),
966 port: actual_port,
967 }];
968
969 let v6_host = Ipv6Addr::LOCALHOST;
970 match TcpListener::bind((v6_host, actual_port)).await {
971 Ok(v6) => {
972 listeners.push(v6);
973 endpoints.push(Endpoint {
974 host: v6_host.to_string(),
975 port: actual_port,
976 });
977 }
978 Err(err) if ipv6_loopback_unavailable(&err) => {
979 warn!(
980 port = actual_port,
981 error = %err,
982 "IPv6 loopback unavailable; serving only IPv4 loopback"
983 );
984 }
985 Err(source) => {
986 drop(listeners);
987 return Err(BootstrapError::Bind {
988 host: v6_host.to_string(),
989 port: actual_port,
990 source,
991 });
992 }
993 }
994
995 Ok((listeners, endpoints))
996}
997
998fn ipv6_loopback_unavailable(err: &io::Error) -> bool {
999 matches!(
1000 err.kind(),
1001 io::ErrorKind::AddrNotAvailable | io::ErrorKind::Unsupported
1002 ) || matches!(err.raw_os_error(), Some(47) | Some(49) | Some(97))
1003}
1004
1005struct AbortOnDrop<T> {
1006 handle: JoinHandle<T>,
1007}
1008
1009impl<T> AbortOnDrop<T> {
1010 fn new(handle: JoinHandle<T>) -> Self {
1011 Self { handle }
1012 }
1013
1014 async fn join(&mut self) -> Result<T, JoinError> {
1015 (&mut self.handle).await
1016 }
1017}
1018
1019impl<T> Drop for AbortOnDrop<T> {
1020 fn drop(&mut self) {
1021 if !self.handle.is_finished() {
1022 self.handle.abort();
1023 }
1024 }
1025}
1026
1027struct StartLock {
1028 _file: fs::File,
1031}
1032
1033impl StartLock {
1034 async fn acquire(connection_file_path: &Path) -> Result<Self, BootstrapError> {
1035 let path = start_lock_path(connection_file_path);
1036 for _ in 0..START_LOCK_RETRIES {
1037 let file = match open_owner_only_lock(&path) {
1038 Ok(file) => file,
1039 Err(source) => return Err(BootstrapError::StartLockCreate { path, source }),
1040 };
1041 match FileExt::try_lock(&file) {
1042 Ok(()) => return Ok(Self { _file: file }),
1043 Err(TryLockError::WouldBlock) => sleep(START_LOCK_RETRY_DELAY).await,
1044 Err(TryLockError::Error(source)) => {
1045 return Err(BootstrapError::StartLockCreate { path, source });
1046 }
1047 }
1048 }
1049
1050 Err(BootstrapError::StartLockBusy {
1051 path,
1052 attempts: START_LOCK_RETRIES,
1053 })
1054 }
1055}
1056
1057fn open_owner_only_lock(path: &Path) -> io::Result<fs::File> {
1058 let mut options = fs::OpenOptions::new();
1059 options.read(true).write(true).create(true);
1060 #[cfg(unix)]
1061 {
1062 use std::os::unix::fs::OpenOptionsExt;
1063 options.mode(0o600);
1064 }
1065 options.open(path)
1066}
1067
1068fn start_lock_path(connection_file_path: &Path) -> PathBuf {
1069 let file_name = connection_file_path
1070 .file_name()
1071 .map(|name| name.to_string_lossy())
1072 .unwrap_or_else(|| CONNECTION_FILE_NAME.into());
1073 let lock_name = format!("{file_name}.start-lock");
1074 connection_file_path
1075 .parent()
1076 .filter(|parent| !parent.as_os_str().is_empty())
1077 .unwrap_or_else(|| Path::new("."))
1078 .join(lock_name)
1079}
1080
1081fn non_empty_os_var(key: &str) -> Option<OsString> {
1082 let value = env::var_os(key)?;
1083 if value.is_empty() {
1084 None
1085 } else {
1086 Some(value)
1087 }
1088}
1089
1090#[derive(Debug)]
1093pub enum BootstrapError {
1094 #[cfg(unix)]
1095 Signal(io::Error),
1096 InvalidPort {
1097 raw: String,
1098 source: std::num::ParseIntError,
1099 },
1100 ConnectionFileRead {
1101 path: PathBuf,
1102 source: ConnectionFileError,
1103 },
1104 ConnectionFileWrite {
1105 path: PathBuf,
1106 source: ConnectionFileError,
1107 },
1108 GenerateConnectionFile(ConnectionFileError),
1109 StartLockCreate {
1110 path: PathBuf,
1111 source: io::Error,
1112 },
1113 StartLockBusy {
1114 path: PathBuf,
1115 attempts: usize,
1116 },
1117 RemoveStale {
1118 path: PathBuf,
1119 source: io::Error,
1120 },
1121 Bind {
1122 host: String,
1123 port: u16,
1124 source: io::Error,
1125 },
1126 LocalAddr {
1127 host: String,
1128 source: io::Error,
1129 },
1130 DaemonConfig(DaemonConfigError),
1131 Serve(ServerError),
1132 ServeJoin(tokio::task::JoinError),
1133}
1134
1135impl fmt::Display for BootstrapError {
1136 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1137 match self {
1138 #[cfg(unix)]
1139 Self::Signal(error) => write!(f, "failed to register SIGTERM handler: {error}"),
1140 Self::InvalidPort { raw, source } => {
1141 write!(f, "invalid {SUBC_PORT_ENV} value '{raw}': {source}")
1142 }
1143 Self::ConnectionFileRead { path, source } => write!(
1144 f,
1145 "failed to read connection file {}: {source}",
1146 path.display()
1147 ),
1148 Self::ConnectionFileWrite { path, source } => write!(
1149 f,
1150 "failed to publish connection file {}: {source}",
1151 path.display()
1152 ),
1153 Self::GenerateConnectionFile(err) => {
1154 write!(f, "failed to generate connection-file auth material: {err}")
1155 }
1156 Self::StartLockCreate { path, source } => {
1157 write!(
1158 f,
1159 "failed to create start lock {}: {source}",
1160 path.display()
1161 )
1162 }
1163 Self::StartLockBusy { path, attempts } => write!(
1164 f,
1165 "start lock {} remained busy after {attempts} attempts",
1166 path.display()
1167 ),
1168 Self::RemoveStale { path, source } => write!(
1169 f,
1170 "failed to remove stale connection file {}: {source}",
1171 path.display()
1172 ),
1173 Self::Bind { host, port, source } if source.kind() == io::ErrorKind::AddrInUse => {
1174 write!(
1175 f,
1176 "port {port} in use on loopback {host}: {source}; set the port in config"
1177 )
1178 }
1179 Self::Bind { host, port, source } => {
1180 write!(f, "failed to bind loopback TCP {host}:{port}: {source}")
1181 }
1182 Self::LocalAddr { host, source } => {
1183 write!(f, "failed to read local address for {host}: {source}")
1184 }
1185 Self::DaemonConfig(err) => write!(f, "failed to load daemon config: {err}"),
1186 Self::Serve(err) => write!(f, "daemon server failed: {err}"),
1187 Self::ServeJoin(err) => write!(f, "daemon server task failed: {err}"),
1188 }
1189 }
1190}
1191
1192impl Error for BootstrapError {
1193 fn source(&self) -> Option<&(dyn Error + 'static)> {
1194 match self {
1195 #[cfg(unix)]
1196 Self::Signal(source) => Some(source),
1197 Self::InvalidPort { source, .. } => Some(source),
1198 Self::ConnectionFileRead { source, .. }
1199 | Self::ConnectionFileWrite { source, .. }
1200 | Self::GenerateConnectionFile(source) => Some(source),
1201 Self::StartLockCreate { source, .. }
1202 | Self::RemoveStale { source, .. }
1203 | Self::Bind { source, .. }
1204 | Self::LocalAddr { source, .. } => Some(source),
1205 Self::DaemonConfig(err) => Some(err),
1206 Self::Serve(err) => Some(err),
1207 Self::ServeJoin(err) => Some(err),
1208 Self::StartLockBusy { .. } => None,
1209 }
1210 }
1211}
1212
1213#[cfg(test)]
1214mod tests {
1215 use super::*;
1216 use crate::server::ServerAuth;
1217 use crate::test_support::TestTempDir;
1218 #[cfg(target_os = "linux")]
1219 use std::collections::BTreeSet;
1220 use std::sync::Mutex;
1221 #[cfg(target_os = "linux")]
1222 use subc_control::ModuleProtocol;
1223 use subc_transport::MIN_KEY_LEN;
1224 use tokio::io::AsyncReadExt;
1225 use tokio::task::JoinHandle;
1226
1227 #[cfg(unix)]
1228 use std::os::unix::fs::PermissionsExt;
1229
1230 static ENV_LOCK: Mutex<()> = Mutex::new(());
1231
1232 #[test]
1233 fn normalized_build_provenance_preserves_real_values() {
1234 assert_eq!(normalized_build_provenance("abc"), Some("abc".to_string()));
1235 }
1236
1237 #[test]
1238 fn normalized_build_provenance_omits_unavailable_and_empty_values() {
1239 assert_eq!(normalized_build_provenance("unavailable"), None);
1240 assert_eq!(normalized_build_provenance(""), None);
1241 }
1242
1243 #[cfg(target_os = "linux")]
1244 fn current_cgroup_path_for_test() -> io::Result<PathBuf> {
1245 let cgroups = fs::read_to_string("/proc/self/cgroup")?;
1246 let relative = cgroups
1247 .lines()
1248 .find_map(|line| line.strip_prefix("0::"))
1249 .ok_or_else(|| {
1250 io::Error::new(io::ErrorKind::Unsupported, "cgroup v2 is unavailable")
1251 })?;
1252 Ok(Path::new("/sys/fs/cgroup").join(relative.trim_start_matches('/')))
1253 }
1254
1255 #[cfg(target_os = "linux")]
1256 fn module_cgroup_directories() -> io::Result<BTreeSet<OsString>> {
1257 let modules = current_cgroup_path_for_test()?.join("subc-modules");
1258 let entries = match fs::read_dir(modules) {
1259 Ok(entries) => entries,
1260 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(BTreeSet::new()),
1261 Err(error) => return Err(error),
1262 };
1263 let mut directories = BTreeSet::new();
1264 for entry in entries {
1265 let entry = entry?;
1266 if entry.file_type()?.is_dir() {
1267 directories.insert(entry.file_name());
1268 }
1269 }
1270 Ok(directories)
1271 }
1272
1273 #[cfg(target_os = "linux")]
1274 async fn wait_for_path(path: &Path, task: &JoinHandle<Result<(), BootstrapError>>) {
1275 let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
1276 while !path.exists() && tokio::time::Instant::now() < deadline {
1277 assert!(
1278 !task.is_finished(),
1279 "daemon exited before creating {}",
1280 path.display()
1281 );
1282 sleep(Duration::from_millis(10)).await;
1283 }
1284 assert!(path.exists(), "daemon did not create {}", path.display());
1285 }
1286
1287 #[cfg(target_os = "linux")]
1288 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1289 async fn run_with_config_does_not_reconcile_the_ambient_cgroup_by_default() {
1290 let temp = unique_temp_dir("bootstrap-cgroup-default-disabled");
1291 let module_id = format!("cgroup-isolation-probe-{}", process::id());
1292 let before = module_cgroup_directories().expect("read ambient module cgroups before boot");
1293 assert!(
1294 !before.contains(&OsString::from(&module_id)),
1295 "isolation probe cgroup already exists before this daemon starts"
1296 );
1297 let capture = temp.join("logs").join(format!("{module_id}.stderr.log"));
1298 let module = ConfiguredModule {
1299 module_id,
1300 program: PathBuf::from("sh"),
1301 args: vec!["-c".to_string(), "sleep 30".to_string()],
1302 env: Vec::new(),
1303 log: None,
1304 enabled: true,
1305 reserved: false,
1306 reserved_prefixes: Vec::new(),
1307 protocol: ModuleProtocol::None,
1308 overlap: Default::default(),
1309 health: HealthConfig::default(),
1310 drain_timeout_ms: None,
1311 route_bind_relay_timeout_ms: None,
1312 restart: RestartPolicy::default(),
1313 };
1314 let config = BootstrapConfig::new(temp.join("connection.json"), 0)
1315 .with_configured_modules([module])
1316 .with_capture_logs_dir(temp.join("logs"))
1317 .with_terminal_journal_path(temp.join("terminals.jsonl"));
1318 let task = tokio::spawn(run_with_config(config));
1319
1320 wait_for_path(&capture, &task).await;
1321 let after = module_cgroup_directories().expect("read ambient module cgroups after boot");
1322
1323 task.abort();
1324 assert!(task
1325 .await
1326 .expect_err("aborted daemon task must cancel")
1327 .is_cancelled());
1328 assert_eq!(
1329 before, after,
1330 "an in-process daemon must not create or reconcile ambient module cgroups"
1331 );
1332 }
1333
1334 #[cfg(target_os = "linux")]
1335 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1336 async fn explicit_cgroup_root_is_prepared_inside_the_fixture_tree() {
1337 let temp = unique_temp_dir("bootstrap-cgroup-explicit-root");
1338 let cgroup_root = temp.join("cgroup");
1339 fs::create_dir(&cgroup_root).expect("create scratch cgroup root");
1340 fs::write(cgroup_root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
1341 let modules = cgroup_root.join("subc-modules");
1342 let config = BootstrapConfig::new(temp.join("connection.json"), 0)
1343 .with_cgroup_placement(CgroupPlacementConfig::Root(cgroup_root))
1344 .with_terminal_journal_path(temp.join("terminals.jsonl"));
1345 let task = tokio::spawn(run_with_config(config));
1346
1347 wait_for_path(&modules, &task).await;
1348
1349 task.abort();
1350 assert!(task
1351 .await
1352 .expect_err("aborted daemon task must cancel")
1353 .is_cancelled());
1354 assert!(
1355 fs::read_dir(&modules)
1356 .expect("read prepared modules directory")
1357 .next()
1358 .is_none(),
1359 "the delegation probe must clean up after itself"
1360 );
1361 }
1362
1363 struct EnvGuard {
1364 key: &'static str,
1365 previous: Option<OsString>,
1366 }
1367
1368 impl EnvGuard {
1369 fn set(key: &'static str, value: &Path) -> Self {
1370 let previous = env::var_os(key);
1371 env::set_var(key, value);
1372 Self { key, previous }
1373 }
1374
1375 fn set_str(key: &'static str, value: &str) -> Self {
1376 let previous = env::var_os(key);
1377 env::set_var(key, value);
1378 Self { key, previous }
1379 }
1380
1381 fn unset(key: &'static str) -> Self {
1382 let previous = env::var_os(key);
1383 env::remove_var(key);
1384 Self { key, previous }
1385 }
1386 }
1387
1388 impl Drop for EnvGuard {
1389 fn drop(&mut self) {
1390 match &self.previous {
1391 Some(value) => env::set_var(self.key, value),
1392 None => env::remove_var(self.key),
1393 }
1394 }
1395 }
1396
1397 fn unique_temp_dir(name: &str) -> TestTempDir {
1398 TestTempDir::new(name)
1399 }
1400
1401 fn temp_connection_file_path(name: &str) -> (TestTempDir, PathBuf) {
1402 let dir = unique_temp_dir(name);
1403 let path = dir.join("conn.json");
1404 (dir, path)
1405 }
1406
1407 fn auth_for(info: &ConnectionInfo) -> ServerAuth {
1408 ServerAuth::new(info.key.clone(), info.daemon_id, info.daemon_ver.clone())
1409 }
1410
1411 fn start_server(bound: BoundDaemon) -> JoinHandle<Result<(), ServerError>> {
1412 let auth = auth_for(&bound.connection_info);
1413 tokio::spawn(serve_listeners(
1414 bound.listeners,
1415 Arc::new(Router::with_default_self_handler()),
1416 auth,
1417 ))
1418 }
1419
1420 fn expect_bound(outcome: Outcome) -> BoundDaemon {
1421 match outcome {
1422 Outcome::Bound(bound) => bound,
1423 Outcome::AlreadyRunning => panic!("fresh connection file unexpectedly had a daemon"),
1424 }
1425 }
1426
1427 async fn connect_from_info(conn: &ConnectionInfo) -> io::Result<TcpStream> {
1428 let endpoint = conn
1429 .endpoints
1430 .first()
1431 .expect("test connection file should have an endpoint");
1432 let ip: IpAddr = endpoint.host.parse().unwrap();
1433 TcpStream::connect(SocketAddr::new(ip, endpoint.port)).await
1434 }
1435
1436 fn make_connection_info(port: u16) -> ConnectionInfo {
1437 ConnectionInfo {
1438 schema: SCHEMA_VERSION,
1439 wire_version: Some(PROTOCOL_VERSION),
1440 endpoints: vec![Endpoint {
1441 host: "127.0.0.1".to_owned(),
1442 port,
1443 }],
1444 key: generate_key().unwrap(),
1445 daemon_id: generate_daemon_id().unwrap(),
1446 pid: process::id(),
1447 daemon_ver: "test-subc".to_owned(),
1448 }
1449 }
1450
1451 fn write_raw_owner_only_connection_file(path: &Path, contents: &[u8]) {
1452 fs::write(path, contents).unwrap();
1453 #[cfg(unix)]
1454 fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap();
1455 }
1456
1457 fn assert_owner_only_connection_file(path: &Path) {
1458 #[cfg(unix)]
1461 {
1462 let mode = fs::metadata(path).unwrap().permissions().mode() & 0o777;
1463 assert_eq!(mode, 0o600);
1464 }
1465 #[cfg(not(unix))]
1466 let _ = path;
1467 }
1468
1469 #[test]
1470 fn connection_file_path_uses_xdg_runtime_dir_when_set() {
1471 let _env_lock = ENV_LOCK.lock().unwrap();
1472 let runtime_dir = unique_temp_dir("xdg-runtime");
1473 let _xdg = EnvGuard::set("XDG_RUNTIME_DIR", runtime_dir.path());
1474
1475 assert_eq!(
1476 connection_file_path(),
1477 runtime_dir.join(CONNECTION_FILE_NAME)
1478 );
1479 }
1480
1481 #[test]
1482 fn connection_file_path_source_is_xdg_runtime_dir_when_set() {
1483 let runtime_dir = OsString::from("/run/user/1000");
1484
1485 let (path, source) = connection_file_path_with_source(Some(runtime_dir));
1486
1487 assert_eq!(
1488 path,
1489 PathBuf::from("/run/user/1000").join(CONNECTION_FILE_NAME)
1490 );
1491 assert_eq!(source, ConnectionFileSource::XdgRuntimeDir);
1492 }
1493
1494 #[test]
1495 fn connection_file_path_falls_back_to_temp_dir_with_user_token_when_xdg_unset() {
1496 let _env_lock = ENV_LOCK.lock().unwrap();
1497 let _xdg = EnvGuard::unset("XDG_RUNTIME_DIR");
1498
1499 assert_eq!(
1500 connection_file_path(),
1501 env::temp_dir().join(format!("subc-{}.connection.json", user_connection_token()))
1502 );
1503 }
1504
1505 #[test]
1506 fn connection_file_path_source_is_temp_dir_when_xdg_unset() {
1507 let (path, source) = connection_file_path_with_source(None);
1508
1509 assert_eq!(
1510 path,
1511 env::temp_dir().join(format!("subc-{}.connection.json", user_connection_token()))
1512 );
1513 assert_eq!(source, ConnectionFileSource::TempDirFallback);
1514 }
1515
1516 #[test]
1521 fn user_connection_token_is_stable_under_concurrent_callers() {
1522 let expected = user_connection_token();
1523 let workers: Vec<_> = (0..32)
1524 .map(|_| {
1525 std::thread::spawn(|| (0..40).map(|_| user_connection_token()).collect::<Vec<_>>())
1526 })
1527 .collect();
1528 for worker in workers {
1529 for token in worker.join().expect("probe thread") {
1530 assert_eq!(token, expected, "token diverged under concurrent probes");
1531 }
1532 }
1533 }
1534
1535 #[test]
1536 fn connection_file_path_source_is_temp_dir_when_xdg_empty() {
1537 let (path, source) = connection_file_path_with_source(Some(OsString::new()));
1538
1539 assert_eq!(
1540 path,
1541 env::temp_dir().join(format!("subc-{}.connection.json", user_connection_token()))
1542 );
1543 assert_eq!(source, ConnectionFileSource::TempDirFallback);
1544 }
1545
1546 #[test]
1547 fn configured_port_uses_default_config_and_env_override() {
1548 let _env_lock = ENV_LOCK.lock().unwrap();
1549 let (_dir, conn_path) = temp_connection_file_path("daemon-config-port");
1550 let config_path = conn_path.with_file_name("subc.jsonc");
1551
1552 let _port = EnvGuard::unset(SUBC_PORT_ENV);
1553 assert_eq!(
1554 BootstrapConfig::from_env_with_daemon_config_path(&config_path)
1555 .unwrap()
1556 .port,
1557 DEFAULT_SUBC_PORT
1558 );
1559
1560 fs::write(&config_path, r#"{ "version": 1, "port": 8123 }"#).unwrap();
1561 assert_eq!(
1562 BootstrapConfig::from_env_with_daemon_config_path(&config_path)
1563 .unwrap()
1564 .port,
1565 8123
1566 );
1567
1568 let _port = EnvGuard::set_str(SUBC_PORT_ENV, "9012");
1569 assert_eq!(
1570 BootstrapConfig::from_env_with_daemon_config_path(&config_path)
1571 .unwrap()
1572 .port,
1573 9012
1574 );
1575 }
1576
1577 #[tokio::test]
1578 async fn second_singleton_probe_against_served_tcp_daemon_reports_already_running() {
1579 let (_dir, path) = temp_connection_file_path("already-running");
1580
1581 let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
1582 let server = start_server(bound);
1583
1584 let second = ensure_singleton(&path, 0).await.unwrap();
1585 assert!(matches!(second, Outcome::AlreadyRunning));
1586
1587 server.abort();
1588 let _ = server.await;
1589 }
1590
1591 #[tokio::test]
1592 async fn daemon_connection_file_publishes_protocol_wire_version() {
1593 let (_dir, path) = temp_connection_file_path("wire-version");
1594 let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
1595 assert_eq!(bound.connection_info.wire_version, Some(PROTOCOL_VERSION));
1596 assert_eq!(
1597 connection_file::read(&path).unwrap().wire_version,
1598 Some(PROTOCOL_VERSION)
1599 );
1600
1601 drop(bound.listeners);
1602 }
1603
1604 #[tokio::test]
1605 async fn stale_unbound_connection_file_is_reclaimed() {
1606 let (_dir, path) = temp_connection_file_path("stale-reclaim");
1607 let stale = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
1608 let stale_port = stale.local_addr().unwrap().port();
1609 drop(stale);
1610 let stale_info = make_connection_info(stale_port);
1611 write_atomic(&path, &stale_info).unwrap();
1612
1613 let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
1614 assert_ne!(bound.connection_info.key, stale_info.key);
1615 drop(bound.listeners);
1616 }
1617
1618 #[cfg(unix)]
1619 #[tokio::test]
1620 async fn ensure_singleton_reclaims_insecure_connection_file() {
1621 let (_dir, path) = temp_connection_file_path("insecure-reclaim");
1622 let stale = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
1623 let stale_port = stale.local_addr().unwrap().port();
1624 drop(stale);
1625 let stale_info = make_connection_info(stale_port);
1626 write_atomic(&path, &stale_info).unwrap();
1627 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
1628
1629 let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
1630 assert_ne!(bound.connection_info.key, stale_info.key);
1631 assert_ne!(bound.connection_info.daemon_id, stale_info.daemon_id);
1632 assert_owner_only_connection_file(&path);
1633
1634 drop(bound.listeners);
1635 }
1636
1637 #[tokio::test]
1638 async fn ensure_singleton_reclaims_non_loopback_connection_file() {
1639 let (_dir, path) = temp_connection_file_path("non-loopback-reclaim");
1640 let mut stale_info = make_connection_info(8757);
1641 stale_info.endpoints = vec![Endpoint {
1642 host: "192.0.2.10".to_owned(),
1643 port: 8757,
1644 }];
1645 write_atomic(&path, &stale_info).unwrap();
1646
1647 let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
1648 assert_ne!(bound.connection_info.key, stale_info.key);
1649 assert_ne!(bound.connection_info.daemon_id, stale_info.daemon_id);
1650 assert!(bound
1651 .connection_info
1652 .endpoints
1653 .iter()
1654 .all(|endpoint| endpoint.host.parse::<IpAddr>().unwrap().is_loopback()));
1655 assert_owner_only_connection_file(&path);
1656
1657 drop(bound.listeners);
1658 }
1659
1660 #[tokio::test]
1661 async fn ensure_singleton_reclaims_invalid_connection_file_shapes() {
1662 let mut unsupported_schema = make_connection_info(8757);
1663 unsupported_schema.schema = SCHEMA_VERSION + 1;
1664
1665 let mut empty_endpoints = make_connection_info(8757);
1666 empty_endpoints.endpoints.clear();
1667
1668 let mut short_key = make_connection_info(8757);
1669 short_key.key = vec![0x5A; MIN_KEY_LEN - 1];
1670
1671 let cases = vec![
1672 (
1673 "unsupported-schema",
1674 serde_json::to_vec(&unsupported_schema).unwrap(),
1675 Some(unsupported_schema),
1676 ),
1677 (
1678 "empty-endpoints",
1679 serde_json::to_vec(&empty_endpoints).unwrap(),
1680 Some(empty_endpoints),
1681 ),
1682 (
1683 "short-key",
1684 serde_json::to_vec(&short_key).unwrap(),
1685 Some(short_key),
1686 ),
1687 ("invalid-json", b"{not valid connection json".to_vec(), None),
1688 ];
1689
1690 for (label, contents, old_info) in cases {
1691 let (_dir, path) = temp_connection_file_path(label);
1692 write_raw_owner_only_connection_file(&path, &contents);
1693
1694 let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
1695 if let Some(old_info) = old_info {
1696 assert_ne!(bound.connection_info.key, old_info.key, "{label}");
1697 assert_ne!(
1698 bound.connection_info.daemon_id, old_info.daemon_id,
1699 "{label}"
1700 );
1701 }
1702 assert!(bound.connection_info.key.len() >= MIN_KEY_LEN, "{label}");
1703 assert_ne!(bound.connection_info.daemon_id, [0u8; 16], "{label}");
1704 assert_owner_only_connection_file(&path);
1705
1706 drop(bound.listeners);
1707 }
1708 }
1709
1710 #[tokio::test]
1711 async fn foreign_reused_port_connection_file_is_reclaimed_after_auth_probe_fails() {
1712 let (_dir, path) = temp_connection_file_path("foreign-reclaim");
1713 let foreign = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
1714 let foreign_port = foreign.local_addr().unwrap().port();
1715 write_atomic(&path, &make_connection_info(foreign_port)).unwrap();
1716 let foreign_task = tokio::spawn(async move {
1717 if let Ok((mut stream, _)) = foreign.accept().await {
1718 let mut buf = [0u8; 64];
1719 let _ = stream.read(&mut buf).await;
1720 }
1721 });
1722
1723 let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
1724 assert!(bound
1725 .connection_info
1726 .endpoints
1727 .iter()
1728 .all(|endpoint| endpoint.port != foreign_port));
1729
1730 drop(bound.listeners);
1731 let _ = foreign_task.await;
1732 }
1733
1734 #[tokio::test]
1735 async fn stale_start_lock_file_is_reclaimable() {
1736 let (_dir, path) = temp_connection_file_path("start-lock-stale-file");
1737 let lock_path = start_lock_path(&path);
1738 drop(open_owner_only_lock(&lock_path).unwrap());
1739 assert!(lock_path.is_file());
1740
1741 let lock = StartLock::acquire(&path).await.unwrap();
1742 assert!(lock_path.is_file());
1743
1744 drop(lock);
1745 assert!(lock_path.is_file());
1746 }
1747
1748 #[tokio::test]
1749 async fn held_start_lock_blocks_second_acquire_until_release() {
1750 let (_dir, path) = temp_connection_file_path("start-lock-held");
1751 let lock_path = start_lock_path(&path);
1752 let first = StartLock::acquire(&path).await.unwrap();
1753
1754 let err = match StartLock::acquire(&path).await {
1755 Ok(_) => panic!("second acquire while held must stay busy"),
1756 Err(err) => err,
1757 };
1758 assert!(matches!(
1759 err,
1760 BootstrapError::StartLockBusy {
1761 ref path,
1762 attempts: START_LOCK_RETRIES,
1763 } if path == &lock_path
1764 ));
1765
1766 drop(first);
1767
1768 let second = StartLock::acquire(&path)
1769 .await
1770 .expect("released advisory lock should be reclaimable");
1771 drop(second);
1772 }
1773
1774 #[tokio::test]
1775 async fn bind_conflict_on_fixed_port_fails_loud_without_reselecting() {
1776 let (_dir, path) = temp_connection_file_path("bind-conflict");
1777 let occupied = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
1778 let occupied_port = occupied.local_addr().unwrap().port();
1779
1780 let err = ensure_singleton(&path, occupied_port).await.unwrap_err();
1781 assert!(matches!(
1782 err,
1783 BootstrapError::Bind { ref source, .. } if source.kind() == io::ErrorKind::AddrInUse
1784 ));
1785 assert!(err.to_string().contains("set the port in config"));
1786
1787 drop(occupied);
1788 }
1789
1790 #[tokio::test]
1791 async fn key_rotation_republishes_new_material_and_old_file_fails_auth() {
1792 let (_dir, path) = temp_connection_file_path("key-rotation");
1793 let first = expect_bound(ensure_singleton(&path, 0).await.unwrap());
1794 let old_info = first.connection_info.clone();
1795 let fixed_port = old_info.endpoints[0].port;
1796 drop(first.listeners);
1797
1798 let second = expect_bound(ensure_singleton(&path, fixed_port).await.unwrap());
1799 let new_info = second.connection_info.clone();
1800 assert_ne!(old_info.key, new_info.key);
1801 assert_ne!(old_info.daemon_id, new_info.daemon_id);
1802 let server = start_server(second);
1803
1804 let mut old_stream = connect_from_info(&old_info).await.unwrap();
1805 let old_auth = authenticate_client(&mut old_stream, &old_info, PROBE_AUTH_DEADLINE).await;
1806 assert!(
1807 old_auth.is_err(),
1808 "old key must not authenticate after restart"
1809 );
1810
1811 let reread = connection_file::read(&path).unwrap();
1812 let mut new_stream = connect_from_info(&reread).await.unwrap();
1813 authenticate_client(&mut new_stream, &reread, PROBE_AUTH_DEADLINE)
1814 .await
1815 .unwrap();
1816
1817 server.abort();
1818 let _ = server.await;
1819 }
1820
1821 #[cfg(unix)]
1822 #[tokio::test]
1823 async fn published_connection_file_permissions_are_owner_only() {
1824 let (_dir, path) = temp_connection_file_path("permissions");
1825 let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
1826
1827 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1828 assert_eq!(mode, 0o600);
1829
1830 drop(bound.listeners);
1831 }
1832}