Skip to main content

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}