rusty_autossh/lib.rs
1//! # rusty-autossh
2//!
3//! A Rust port of Carson Harding's `autossh(1)` SSH connection supervisor.
4//! Spawns `ssh` as a child process, optionally probes tunnel liveness via the
5//! `-M <port>` heartbeat (or `-M 0` exit-only respawn), and respawns the ssh
6//! process when it dies or stops responding.
7//!
8//! This crate ships both a CLI binary (`rusty-autossh`) and a Rust-native
9//! library API. With `default-features = false` the library API is available
10//! without pulling in any CLI-only dependencies (clap, clap_complete, anstyle,
11//! tracing-appender, daemonize, atomicwrites, windows-sys).
12//!
13//! ## Library entry points
14//!
15//! - [`SshSupervisorBuilder`] — fluent builder for the supervisor.
16//! - [`SshSupervisor`] — the supervisor task; drive via `run().await`.
17//! - [`MonitorMode`] — `-M 0` (None) or `-M <port>[:<echo>]` (Active).
18//! - [`SupervisorEvent`] — emitted over the user's `mpsc::Sender`.
19//! - [`AutosshError`] — public error type.
20//!
21//! ## Feature gates
22//!
23//! - `default = ["cli"]` — full CLI binary + library API.
24//! - `default-features = false` — library only (`tokio` + `thiserror` +
25//! `socket2`).
26//!
27//! ## SemVer + thread-safety policy
28//!
29//! [`AutosshError`] and [`SupervisorEvent`] are `#[non_exhaustive]` per
30//! AD-014, so additive variants in later releases are NOT breaking changes.
31//! `SshSupervisor: Send`, `SshSupervisorBuilder: Send + Sync`, all enums
32//! `Send + Sync`. See `tests` module for the `static_assertions` guards.
33//!
34//! ## Concurrency
35//!
36//! [`SshSupervisor::run`] requires **exclusive ownership of SIGCHLD** in the
37//! host tokio runtime per FR-062 / AD-017. Library consumers running multiple
38//! supervisors must run each in its own dedicated tokio runtime.
39//!
40//! ## Quick-start example
41//!
42//! ```no_run
43//! use rusty_autossh::{MonitorMode, SshSupervisorBuilder};
44//!
45//! # async fn doc() -> Result<(), rusty_autossh::AutosshError> {
46//! let mut supervisor = SshSupervisorBuilder::new()
47//! .ssh_args(vec!["user@host".to_string()])
48//! .monitor_mode(MonitorMode::None)
49//! .build()?;
50//!
51//! supervisor.run().await?;
52//! # Ok(())
53//! # }
54//! ```
55
56#![deny(missing_docs)]
57
58use std::path::PathBuf;
59use std::process::ExitStatus;
60use std::time::Duration;
61
62use tokio::sync::mpsc;
63
64pub mod clock;
65pub mod error;
66pub mod mode;
67pub mod monitor;
68pub mod spawner;
69pub mod strict;
70pub mod supervisor;
71
72pub mod signals;
73
74#[cfg(feature = "cli")]
75pub mod cli;
76#[cfg(feature = "cli")]
77pub mod daemonizer;
78#[cfg(feature = "cli")]
79pub mod logging;
80#[cfg(feature = "cli")]
81pub mod pidfile;
82
83pub use error::AutosshError;
84
85/// Signal-kind tag carried by [`SupervisorEvent::SignalReceived`].
86///
87/// Abstracts over Unix `tokio::signal::unix::SignalKind` and the Windows
88/// `ctrl_c` / `ctrl_break` model so the public surface is the same on every
89/// platform.
90#[non_exhaustive]
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
92pub enum SignalKind {
93 /// SIGTERM (Unix) / Ctrl+C (Windows).
94 Terminate,
95 /// SIGINT (Unix) / Ctrl+C (Windows).
96 Interrupt,
97 /// SIGUSR1 (Unix). No Windows equivalent (variant unreachable on
98 /// Windows but kept on the public enum for cross-platform exhaustive
99 /// matching with a `_` arm).
100 UserDefined1,
101 /// SIGHUP (Unix). No Windows equivalent.
102 Hangup,
103 /// Ctrl+Break (Windows). Unreachable on Unix.
104 CtrlBreak,
105}
106
107/// Monitor-port mode resolved from the `-M` flag or `AUTOSSH_PORT` env var.
108///
109/// - [`MonitorMode::None`] (`-M 0`) — no TCP listeners; respawn ssh only on
110/// non-zero exit.
111/// - [`MonitorMode::Active`] (`-M <port>` or `-M <port>:<echo>`) — bind a
112/// monitor-port [`tokio::net::TcpListener`] pair (or single listener when
113/// `echo` is supplied) and probe round-trip every `AUTOSSH_POLL` seconds.
114///
115/// # Example
116///
117/// ```
118/// use rusty_autossh::MonitorMode;
119///
120/// // -M 0: exit-only supervision, no TCP listeners.
121/// let none = MonitorMode::None;
122/// assert_eq!(none, MonitorMode::default());
123///
124/// // -M 20000:22 single-listener mode.
125/// let active = MonitorMode::Active { port: 20000, echo: Some(22) };
126/// match active {
127/// MonitorMode::Active { port, echo } => {
128/// assert_eq!(port, 20000);
129/// assert_eq!(echo, Some(22));
130/// }
131/// _ => unreachable!(),
132/// }
133/// ```
134#[non_exhaustive]
135#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
136pub enum MonitorMode {
137 /// `-M 0`: no monitor-port listeners; supervisor only watches child
138 /// exit status.
139 #[default]
140 None,
141 /// `-M <port>` (echo `None`) or `-M <port>:<echo>` (echo `Some`).
142 Active {
143 /// Local monitor port.
144 port: u16,
145 /// Optional remote echo port. `None` → two local listeners
146 /// (`<port>` + `<port>+1`); `Some` → single local listener + remote
147 /// echo service.
148 echo: Option<u16>,
149 },
150}
151
152/// Compatibility mode resolved from the `--strict` / `--no-strict` flags,
153/// `RUSTY_AUTOSSH_STRICT` env var, and `argv[0]` basename per AD-006.
154///
155/// # Example
156///
157/// ```
158/// use rusty_autossh::CompatibilityMode;
159///
160/// assert_eq!(CompatibilityMode::default(), CompatibilityMode::Default);
161/// let strict = CompatibilityMode::Strict;
162/// assert_ne!(strict, CompatibilityMode::Default);
163/// ```
164#[non_exhaustive]
165#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
166pub enum CompatibilityMode {
167 /// Default Rust-native mode: long-form flags, structured tracing
168 /// output, clap-styled errors.
169 #[default]
170 Default,
171 /// Strict upstream-`autossh 1.4g` compatibility mode: short flags only,
172 /// byte-equal stderr, no ISO timestamp prefix on log lines.
173 Strict,
174}
175
176/// Events emitted by [`SshSupervisor::run`] over the consumer's
177/// `mpsc::Sender<SupervisorEvent>` (set on the builder via
178/// `SshSupervisorBuilder::event_sender`).
179///
180/// `#[non_exhaustive]` per AD-014 — additive variants in later releases are
181/// not a breaking change.
182///
183/// Exhaustive matches without a wildcard arm fail to compile, guarding
184/// downstream consumers against future variant additions:
185///
186/// ```compile_fail
187/// use rusty_autossh::SupervisorEvent;
188///
189/// fn handle(ev: SupervisorEvent) {
190/// // Missing required wildcard `_` arm.
191/// match ev {
192/// SupervisorEvent::ChildSpawned { .. } => {}
193/// SupervisorEvent::ChildExited { .. } => {}
194/// SupervisorEvent::ChildRespawned => {}
195/// SupervisorEvent::ProbeTimeout => {}
196/// SupervisorEvent::MaxStartReached { .. } => {}
197/// SupervisorEvent::MaxLifetimeReached => {}
198/// SupervisorEvent::SignalReceived(_) => {}
199/// }
200/// }
201/// ```
202///
203/// # Example — consume events from the supervisor channel
204///
205/// ```
206/// use rusty_autossh::SupervisorEvent;
207///
208/// // Library consumers MUST include a wildcard `_` arm because
209/// // `SupervisorEvent` is `#[non_exhaustive]` (SemVer policy per AD-014).
210/// fn classify(event: &SupervisorEvent) -> &'static str {
211/// match event {
212/// SupervisorEvent::ChildSpawned { .. } => "spawned",
213/// SupervisorEvent::ChildExited { .. } => "exited",
214/// SupervisorEvent::ChildRespawned => "respawned",
215/// SupervisorEvent::ProbeTimeout => "probe-timeout",
216/// SupervisorEvent::MaxStartReached { .. } => "max-start",
217/// SupervisorEvent::MaxLifetimeReached => "max-lifetime",
218/// SupervisorEvent::SignalReceived(_) => "signal",
219/// _ => "unknown",
220/// }
221/// }
222///
223/// let e = SupervisorEvent::ChildSpawned { pid: 4242 };
224/// assert_eq!(classify(&e), "spawned");
225/// ```
226#[non_exhaustive]
227#[derive(Debug)]
228pub enum SupervisorEvent {
229 /// An ssh child process was successfully spawned. Fires AFTER
230 /// `Command::spawn` returns `Ok(Child)` (i.e., the child is reapable).
231 ChildSpawned {
232 /// OS-assigned process id.
233 pid: u32,
234 },
235 /// The active ssh child exited and was reaped via `child.wait()`.
236 ChildExited {
237 /// Exit status observed by `child.wait()`.
238 status: ExitStatus,
239 },
240 /// A replacement ssh child was spawned (kill + respawn cycle).
241 ChildRespawned,
242 /// Probe round-trip timed out on the monitor port.
243 ProbeTimeout,
244 /// Consecutive-retry counter reached the `AUTOSSH_MAXSTART` cap.
245 MaxStartReached {
246 /// Number of consecutive spawn attempts before the cap was hit.
247 attempts: u32,
248 },
249 /// `AUTOSSH_MAXLIFETIME` elapsed.
250 MaxLifetimeReached,
251 /// A signal was received by the supervisor.
252 SignalReceived(SignalKind),
253}
254
255/// Fluent builder for [`SshSupervisor`].
256///
257/// Construction entry point — there is no other public constructor for
258/// `SshSupervisor`. Builder fields default to upstream-`autossh 1.4g`
259/// defaults (poll=600s, gate_time=30s, max_start=None (unlimited),
260/// max_lifetime=None (unlimited)).
261///
262/// # Example
263///
264/// ```
265/// use std::time::Duration;
266/// use rusty_autossh::{MonitorMode, SshSupervisorBuilder};
267///
268/// let builder = SshSupervisorBuilder::new()
269/// .ssh_args(vec!["user@host".to_string()])
270/// .monitor_mode(MonitorMode::None)
271/// .poll(Duration::from_secs(60))
272/// .gate_time(Duration::from_secs(10))
273/// .max_start(Some(3));
274///
275/// // Stop short of `.build()?` here because `build()` resolves the ssh
276/// // binary on the host and is fallible in environments without `ssh`.
277/// // See the crate-level rustdoc for the full happy-path example.
278/// drop(builder);
279/// ```
280#[derive(Debug, Default)]
281pub struct SshSupervisorBuilder {
282 ssh_args: Vec<String>,
283 monitor_mode: MonitorMode,
284 ssh_path: Option<PathBuf>,
285 poll: Option<Duration>,
286 first_poll: Option<Duration>,
287 gate_time: Option<Duration>,
288 max_start: Option<Option<u32>>,
289 max_lifetime: Option<Option<Duration>>,
290 event_sender: Option<mpsc::Sender<SupervisorEvent>>,
291 message: Option<String>,
292 compatibility_mode: CompatibilityMode,
293 one_shot: bool,
294 pidfile_path: Option<PathBuf>,
295 logfile_path: Option<PathBuf>,
296}
297
298impl SshSupervisorBuilder {
299 /// Construct a fresh builder with all fields at their upstream-default
300 /// values.
301 pub fn new() -> Self {
302 Self::default()
303 }
304
305 /// Set the argv passed verbatim to the ssh child (autossh's
306 /// argv-passthrough design).
307 pub fn ssh_args(mut self, args: Vec<String>) -> Self {
308 self.ssh_args = args;
309 self
310 }
311
312 /// Set the [`MonitorMode`] (default: `MonitorMode::None`).
313 pub fn monitor_mode(mut self, mode: MonitorMode) -> Self {
314 self.monitor_mode = mode;
315 self
316 }
317
318 /// Override the resolved ssh binary path. When `None` the supervisor
319 /// resolves `AUTOSSH_PATH` then walks `PATH` per AD-011.
320 pub fn ssh_path(mut self, path: PathBuf) -> Self {
321 self.ssh_path = Some(path);
322 self
323 }
324
325 /// Override `AUTOSSH_POLL` (default 600 s).
326 pub fn poll(mut self, poll: Duration) -> Self {
327 self.poll = Some(poll);
328 self
329 }
330
331 /// Override `AUTOSSH_FIRST_POLL` (default = `poll`).
332 pub fn first_poll(mut self, first_poll: Duration) -> Self {
333 self.first_poll = Some(first_poll);
334 self
335 }
336
337 /// Override `AUTOSSH_GATETIME` (default 30 s).
338 pub fn gate_time(mut self, gate_time: Duration) -> Self {
339 self.gate_time = Some(gate_time);
340 self
341 }
342
343 /// Override `AUTOSSH_MAXSTART`. `None` corresponds to the `-1`
344 /// sentinel (unlimited retries).
345 pub fn max_start(mut self, max_start: Option<u32>) -> Self {
346 self.max_start = Some(max_start);
347 self
348 }
349
350 /// Override `AUTOSSH_MAXLIFETIME`. `None` corresponds to `0` (unlimited).
351 pub fn max_lifetime(mut self, max_lifetime: Option<Duration>) -> Self {
352 self.max_lifetime = Some(max_lifetime);
353 self
354 }
355
356 /// Attach an `mpsc::Sender<SupervisorEvent>` for library consumers that
357 /// want to observe the supervisor loop.
358 pub fn event_sender(mut self, tx: mpsc::Sender<SupervisorEvent>) -> Self {
359 self.event_sender = Some(tx);
360 self
361 }
362
363 /// Override `AUTOSSH_MESSAGE` (heartbeat payload suffix per FR-013).
364 pub fn message(mut self, message: String) -> Self {
365 self.message = Some(message);
366 self
367 }
368
369 /// Override the compatibility mode (defaults to Default).
370 pub fn compatibility_mode(mut self, mode: CompatibilityMode) -> Self {
371 self.compatibility_mode = mode;
372 self
373 }
374
375 /// Mark this supervisor as one-shot (`-1`) — exit non-zero on the
376 /// first child failure (US1 / spec FR-010).
377 pub fn one_shot(mut self, one_shot: bool) -> Self {
378 self.one_shot = one_shot;
379 self
380 }
381
382 /// Configure the pidfile path (`AUTOSSH_PIDFILE` / `--pid-file`).
383 ///
384 /// When set, [`SshSupervisor::run`] writes the supervisor PID
385 /// atomically at startup and removes the file on termination (per
386 /// FR-030 / AD-012). When `None` (default), no pidfile is written.
387 pub fn pidfile_path(mut self, path: PathBuf) -> Self {
388 self.pidfile_path = Some(path);
389 self
390 }
391
392 /// Configure the logfile path (`AUTOSSH_LOGFILE` / `--log-file`).
393 ///
394 /// When set, [`SshSupervisor::run`] initializes a non-blocking writer
395 /// for the file (Default mode adds an ISO 8601 timestamp prefix per
396 /// FR-031; Strict mode opens raw append per FR-054). An unwritable
397 /// path triggers the one-time stderr fallback warning per FR-032
398 /// without aborting.
399 pub fn logfile_path(mut self, path: PathBuf) -> Self {
400 self.logfile_path = Some(path);
401 self
402 }
403
404 /// Finalize the builder into an [`SshSupervisor`]. Fallible: ssh-binary
405 /// resolution and monitor-port pre-bind validation can fail here.
406 pub fn build(self) -> Result<SshSupervisor, AutosshError> {
407 Ok(SshSupervisor {
408 ssh_args: self.ssh_args,
409 monitor_mode: self.monitor_mode,
410 ssh_path: self.ssh_path,
411 poll: self.poll.unwrap_or_else(|| Duration::from_secs(600)),
412 first_poll: self.first_poll,
413 gate_time: self.gate_time.unwrap_or_else(|| Duration::from_secs(30)),
414 max_start: self.max_start.unwrap_or(None),
415 max_lifetime: self.max_lifetime.unwrap_or(None),
416 event_sender: self.event_sender,
417 message: self.message,
418 compatibility_mode: self.compatibility_mode,
419 one_shot: self.one_shot,
420 pidfile_path: self.pidfile_path,
421 logfile_path: self.logfile_path,
422 })
423 }
424}
425
426/// SSH connection supervisor.
427///
428/// Constructed via [`SshSupervisorBuilder::build`]. Drive the supervisor
429/// loop via [`SshSupervisor::run`]. Single-use — consume on completion or
430/// termination.
431///
432/// # Concurrency
433///
434/// `run()` requires **exclusive ownership of SIGCHLD** in the host tokio
435/// runtime. Consumers running multiple supervisors must spawn each in its
436/// own dedicated tokio runtime (FR-062 / AD-017).
437///
438/// # Example
439///
440/// ```no_run
441/// use rusty_autossh::{MonitorMode, SshSupervisorBuilder};
442///
443/// # async fn doc() -> Result<(), rusty_autossh::AutosshError> {
444/// let mut supervisor = SshSupervisorBuilder::new()
445/// .ssh_args(vec!["-M".to_string(), "0".to_string(), "user@host".to_string()])
446/// .monitor_mode(MonitorMode::None)
447/// .build()?;
448///
449/// supervisor.run().await?;
450/// # Ok(())
451/// # }
452/// ```
453#[derive(Debug)]
454pub struct SshSupervisor {
455 ssh_args: Vec<String>,
456 monitor_mode: MonitorMode,
457 ssh_path: Option<PathBuf>,
458 poll: Duration,
459 first_poll: Option<Duration>,
460 gate_time: Duration,
461 max_start: Option<u32>,
462 max_lifetime: Option<Duration>,
463 event_sender: Option<mpsc::Sender<SupervisorEvent>>,
464 message: Option<String>,
465 compatibility_mode: CompatibilityMode,
466 one_shot: bool,
467 /// Pidfile path (consumed in `SshSupervisor::run` under `cfg(feature = "cli")`).
468 #[allow(dead_code)]
469 pidfile_path: Option<PathBuf>,
470 /// Logfile path (consumed in `SshSupervisor::run` under `cfg(feature = "cli")`).
471 #[allow(dead_code)]
472 logfile_path: Option<PathBuf>,
473}
474
475impl SshSupervisor {
476 /// Drive the supervisor loop.
477 ///
478 /// Implements HINT-001 + HINT-011 + HINT-012 + HINT-018 by composing
479 /// the [`supervisor::Supervisor`] internal state machine. Single-use
480 /// — consume on completion or termination.
481 ///
482 /// # Concurrency
483 ///
484 /// Requires exclusive ownership of SIGCHLD in the host tokio runtime
485 /// (FR-062 / AD-017). Library consumers running multiple supervisors
486 /// MUST spawn each in its own dedicated tokio runtime.
487 pub async fn run(&mut self) -> Result<(), AutosshError> {
488 use std::time::Instant;
489
490 // HINT-011 step 1: env vars already merged at builder time.
491 // HINT-011 step 2: resolve ssh path (if not provided).
492 let ssh_path = match &self.ssh_path {
493 Some(p) => p.clone(),
494 None => {
495 let autossh_path = std::env::var_os("AUTOSSH_PATH");
496 let path = std::env::var_os("PATH");
497 spawner::resolve_ssh_path(autossh_path.as_deref(), path.as_deref())?
498 }
499 };
500
501 // HINT-011 step 3: bind monitor-port listeners when active.
502 let monitor = match &self.monitor_mode {
503 MonitorMode::None => None,
504 MonitorMode::Active { .. } => Some(monitor::ProbeLoop::bind(
505 &self.monitor_mode,
506 self.message.as_deref(),
507 )?),
508 };
509
510 // HINT-011 step 4: write pidfile (atomicwrites + Drop guard per
511 // FR-030 + AD-012 + HINT-010). Daemonization (step 5) happens at
512 // the CLI dispatch layer BEFORE entering Supervisor::run, so the
513 // PID we record here is the post-daemonize child's PID.
514 #[cfg(feature = "cli")]
515 let pidfile_guard: Option<pidfile::PidfileGuard> = match &self.pidfile_path {
516 Some(p) => Some(pidfile::write_pid(p.clone(), std::process::id())?),
517 None => None,
518 };
519
520 // HINT-011 step 4b: initialize logfile writer (FR-031 + FR-054 +
521 // FR-032). On unwritable path the function emits the one-time
522 // stderr warning + returns None so the supervisor continues.
523 #[cfg(feature = "cli")]
524 let _log_guard: Option<tracing_appender::non_blocking::WorkerGuard> =
525 match &self.logfile_path {
526 Some(p) => logging::init_logfile(Some(p.clone()), self.compatibility_mode)?,
527 None => None,
528 };
529
530 // Adjust ssh_args with the monitor-port pair resolved from the
531 // listeners (so callers that pass `port = 0` get the OS-assigned
532 // port reflected in the -L/-R forwards).
533 let monitor_mode = match (&self.monitor_mode, &monitor) {
534 (MonitorMode::Active { echo: Some(e), .. }, Some(m)) => MonitorMode::Active {
535 port: m.ports.port_in,
536 echo: Some(*e),
537 },
538 (MonitorMode::Active { echo: None, .. }, Some(m)) => MonitorMode::Active {
539 port: m.ports.port_in,
540 echo: None,
541 },
542 _ => self.monitor_mode.clone(),
543 };
544
545 let clock = PollClock {
546 poll: self.poll,
547 first_poll: self.first_poll.unwrap_or(self.poll),
548 gate_time: self.gate_time,
549 max_start: self.max_start,
550 max_lifetime: self.max_lifetime,
551 };
552
553 // HINT-011 step 6 + US6 (T120-T123 + AD-015): install the
554 // platform-appropriate signal sources (Unix SignalKind +
555 // SIGUSR1 + SIGHUP; Windows ctrl_c + ctrl_break) feeding a
556 // unified `mpsc<SupervisorEvent>` channel. The supervisor
557 // `select!` loop consumes this receiver uniformly.
558 let signal_rx = Some(signals::spawn_signal_source());
559
560 let mut supervisor = supervisor::Supervisor {
561 child: None,
562 monitor,
563 clock,
564 mode: self.compatibility_mode,
565 monitor_mode,
566 ssh_path,
567 ssh_args: self.ssh_args.clone(),
568 retry_count: 0,
569 lifetime_start: Instant::now(),
570 child_spawn_instant: None,
571 event_tx: self.event_sender.clone(),
572 signal_rx,
573 one_shot: self.one_shot,
574 #[cfg(feature = "cli")]
575 pidfile_guard,
576 };
577
578 supervisor.run().await
579 }
580}
581
582/// Re-export `PollClock` for use inside `lib.rs::SshSupervisor::run`.
583use crate::clock::PollClock;
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588 use static_assertions::assert_impl_all;
589
590 // Thread-safety bounds pinned per plan §Library Surface Pins (SC-009).
591 assert_impl_all!(SshSupervisorBuilder: Send, Sync);
592 // SshSupervisor is Send but NOT Sync — owns mutable child handle +
593 // listeners.
594 assert_impl_all!(SshSupervisor: Send);
595 assert_impl_all!(MonitorMode: Send, Sync, Clone);
596 assert_impl_all!(SupervisorEvent: Send, Sync);
597 assert_impl_all!(AutosshError: Send, Sync);
598 assert_impl_all!(CompatibilityMode: Send, Sync, Clone, Copy);
599
600 // 'static via std::error::Error supertrait
601 fn _autossh_error_is_static() {
602 fn assert_static<T: 'static>() {}
603 assert_static::<AutosshError>();
604 }
605}