Skip to main content

rash/
supervise.rs

1//! The supervision loop.
2//!
3//! autossh's equivalent is `ssh_run()`/`ssh_watch()` (autossh.c:711-892), built
4//! on `sigsetjmp`/`siglongjmp`, `alarm()` and `pause()`, with `syslog()`
5//! reachable from the signal handler. Here the same events — the child exiting,
6//! the poll timer firing, the lifetime deadline passing, a signal arriving — are
7//! arms of one `select!`. There is no handler context to be careful in and no
8//! jump buffer to unwind, and signals stay live for the whole run rather than
9//! only while a child exists (autossh CHANGES, 1.4f).
10
11use crate::backoff::Backoff;
12use crate::config::Config;
13use crate::monitor::Monitor;
14use crate::pidfile::PidFile;
15use crate::{log_debug, log_err, log_info};
16use std::fmt;
17use std::io;
18use std::os::unix::process::ExitStatusExt;
19use std::process::ExitStatus;
20use std::time::Duration;
21use tokio::process::{Child, Command};
22use tokio::signal::unix::{Signal, SignalKind, signal};
23use tokio::time::{Instant, MissedTickBehavior};
24
25/// What should happen once the ssh child is gone.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Verdict {
28    Restart,
29    ExitOk,
30    ExitErr,
31}
32
33/// Why the supervisor reached its verdict. Kept separate from [`Verdict`] so the
34/// log line and the tests can tell "never made it out of the starting gate"
35/// apart from "the command line was wrong".
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum Reason {
38    Signalled,
39    PrematureExit,
40    ConnectionLost,
41    CleanExit,
42    Failed,
43}
44
45/// How the child died.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum Death {
48    Signal(i32),
49    Exit(i32),
50}
51
52impl Death {
53    pub fn from_status(s: ExitStatus) -> Self {
54        match s.signal() {
55            Some(sig) => Self::Signal(sig),
56            None => Self::Exit(s.code().unwrap_or(0)),
57        }
58    }
59}
60
61/// autossh's `ssh_wait()` policy (autossh.c:960-1071).
62///
63/// `uptime` is how long this child ran, and `start_count` counts from 1.
64pub fn classify(
65    death: Death,
66    start_count: u64,
67    uptime: Duration,
68    gate: Duration,
69) -> (Verdict, Reason) {
70    let code = match death {
71        // A killed child was more likely hung than deliberately stopped, so
72        // restarting beats assuming we were meant to exit too (CHANGES, 1.4f).
73        Death::Signal(_) => return (Verdict::Restart, Reason::Signalled),
74        Death::Exit(c) => c,
75    };
76
77    // The starting gate: a first session that dies at once never authenticated
78    // or never connected, and retrying would only spin (autossh.c:998-1010).
79    if start_count == 1 && !gate.is_zero() && uptime <= gate {
80        return (Verdict::ExitErr, Reason::PrematureExit);
81    }
82
83    match code {
84        // ssh reports both a dropped connection and a failed authentication as
85        // 255 and gives us no way to tell them apart — hence the gate above.
86        255 => (Verdict::Restart, Reason::ConnectionLost),
87        0 => (Verdict::ExitOk, Reason::CleanExit),
88        // 1 and 2 on a later run mean the network went away, not that the
89        // command line is wrong. 2 can also come from a tunnel setup race.
90        1 | 2 if start_count > 1 || gate.is_zero() => (Verdict::Restart, Reason::ConnectionLost),
91        _ => (Verdict::ExitErr, Reason::Failed),
92    }
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum Sig {
97    Term,
98    Int,
99    Quit,
100    Hup,
101    Usr1,
102    Usr2,
103}
104
105impl fmt::Display for Sig {
106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107        let s = match self {
108            Self::Term => "SIGTERM",
109            Self::Int => "SIGINT",
110            Self::Quit => "SIGQUIT",
111            Self::Hup => "SIGHUP",
112            Self::Usr1 => "SIGUSR1",
113            Self::Usr2 => "SIGUSR2",
114        };
115        f.write_str(s)
116    }
117}
118
119/// The signals rash reacts to.
120///
121/// SIGPIPE is not here because the Rust runtime already sets it to `SIG_IGN` at
122/// startup, which is what autossh arranges by hand (autossh.c:1202-1204).
123pub struct Signals {
124    term: Signal,
125    int: Signal,
126    quit: Signal,
127    hup: Signal,
128    usr1: Signal,
129    usr2: Signal,
130}
131
132impl Signals {
133    pub fn new() -> io::Result<Self> {
134        Ok(Self {
135            term: signal(SignalKind::terminate())?,
136            int: signal(SignalKind::interrupt())?,
137            quit: signal(SignalKind::quit())?,
138            hup: signal(SignalKind::hangup())?,
139            usr1: signal(SignalKind::user_defined1())?,
140            usr2: signal(SignalKind::user_defined2())?,
141        })
142    }
143
144    pub async fn next(&mut self) -> Sig {
145        tokio::select! {
146            _ = self.term.recv() => Sig::Term,
147            _ = self.int.recv()  => Sig::Int,
148            _ = self.quit.recv() => Sig::Quit,
149            _ = self.hup.recv()  => Sig::Hup,
150            _ = self.usr1.recv() => Sig::Usr1,
151            _ = self.usr2.recv() => Sig::Usr2,
152        }
153    }
154}
155
156/// Start ssh, watch it, restart it, until something says to stop.
157pub async fn run(cfg: &Config, pid_file: Option<&PidFile>) -> Verdict {
158    let mut sigs = match Signals::new() {
159        Ok(s) => s,
160        Err(e) => {
161            log_err!("cannot install signal handlers: {e}");
162            return Verdict::ExitErr;
163        }
164    };
165
166    // Opened once and held for the whole run. A failure to bind is fatal: the
167    // loop could never complete, so every probe would fail (autossh.c:465-472).
168    let monitor = match Monitor::bind(cfg).await {
169        Ok(m) => m,
170        Err(e) => {
171            log_err!("cannot open monitor socket: {e}");
172            return Verdict::ExitErr;
173        }
174    };
175
176    let ctx = Ctx {
177        cfg,
178        monitor: &monitor,
179        pid_file,
180        deadline: cfg.max_lifetime.map(|d| Instant::now() + d),
181    };
182    let deadline = ctx.deadline;
183    let mut backoff = Backoff::default();
184    let mut start_count: u64 = 0;
185    let mut last_start: Option<Instant> = None;
186
187    loop {
188        if cfg.max_start >= 0 && start_count >= cfg.max_start as u64 {
189            log_info!("max start count reached; exiting");
190            return Verdict::ExitOk;
191        }
192        if deadline.is_some_and(|d| Instant::now() >= d) {
193            log_info!("exceeded maximum time to live, shutting down");
194            return Verdict::ExitOk;
195        }
196
197        let uptime = last_start.map_or(Duration::MAX, |t| t.elapsed());
198        let delay = backoff.next_delay(uptime, cfg.poll);
199        log_debug!("checking for grace period, tries = {}", backoff.tries());
200        if !delay.is_zero() {
201            log_debug!("sleeping for grace time {} secs", delay.as_secs());
202            // autossh leaves most signals unhandled while it sleeps here, so a
203            // SIGHUP meant to prod it kills it instead. This stays responsive.
204            tokio::select! {
205                _ = tokio::time::sleep(delay) => {}
206                sig = sigs.next() => {
207                    if let Some(v) = exiting(sig) {
208                        log_info!("received signal to exit ({sig})");
209                        return v;
210                    }
211                    log_debug!("{sig} during backoff; retrying now");
212                }
213            }
214        }
215
216        start_count += 1;
217        if cfg.max_start < 0 {
218            log_info!("starting ssh (count {start_count})");
219        } else {
220            log_info!("starting ssh (count {start_count} of {})", cfg.max_start);
221        }
222
223        let mut child = match spawn(cfg, &monitor) {
224            Ok(c) => c,
225            Err(e) => {
226                // Rust reports a failed exec back through spawn(), so unlike
227                // autossh there is no forked child left to signal us with
228                // SIGTERM to break the restart loop (autossh.c:748-752).
229                log_err!("{}: {e}", cfg.ssh_path.display());
230                return Verdict::ExitErr;
231            }
232        };
233        let started = Instant::now();
234        last_start = Some(started);
235        log_info!("ssh child pid is {}", child.id().unwrap_or(0));
236
237        let verdict = watch(&mut child, &ctx, start_count, started, &mut sigs).await;
238
239        if verdict != Verdict::Restart {
240            return verdict;
241        }
242    }
243}
244
245/// The parts of a run that do not change from one restart to the next.
246struct Ctx<'a> {
247    cfg: &'a Config,
248    monitor: &'a Monitor,
249    pid_file: Option<&'a PidFile>,
250    deadline: Option<Instant>,
251}
252
253/// One event at a time, until the child's fate is decided.
254enum Event {
255    Exited(io::Result<ExitStatus>),
256    Tick,
257    Deadline,
258    Signal(Sig),
259}
260
261async fn watch(
262    child: &mut Child,
263    ctx: &Ctx<'_>,
264    start_count: u64,
265    started: Instant,
266    sigs: &mut Signals,
267) -> Verdict {
268    let cfg = ctx.cfg;
269    let mut tick = tokio::time::interval_at(Instant::now() + cfg.first_poll, cfg.poll);
270    tick.set_missed_tick_behavior(MissedTickBehavior::Delay);
271
272    loop {
273        // The futures are built here and dropped as soon as one wins, which is
274        // what frees `child` for the handlers below to use.
275        let event = tokio::select! {
276            status = child.wait() => Event::Exited(status),
277            _ = tick.tick() => Event::Tick,
278            _ = at(ctx.deadline) => Event::Deadline,
279            sig = sigs.next() => Event::Signal(sig),
280        };
281
282        match event {
283            Event::Exited(Err(e)) => {
284                log_err!("waiting on ssh: {e}");
285                return Verdict::ExitErr;
286            }
287            Event::Exited(Ok(status)) => {
288                let death = Death::from_status(status);
289                let (verdict, reason) =
290                    classify(death, start_count, started.elapsed(), cfg.gate_time);
291                report(death, reason);
292                return verdict;
293            }
294            Event::Tick => {
295                log_debug!("check on child {}", child.id().unwrap_or(0));
296
297                if ctx.monitor.enabled() && !ctx.monitor.probe(cfg).await {
298                    log_info!("port down, restarting ssh");
299                    kill(child, cfg).await;
300                    return Verdict::Restart;
301                }
302
303                if cfg.touch_pid_file
304                    && let Some(p) = ctx.pid_file
305                    && let Err(e) = p.touch()
306                {
307                    log_err!("could not touch pid file: {e}");
308                }
309            }
310            Event::Deadline => {
311                log_info!("exceeded maximum time to live, shutting down");
312                kill(child, cfg).await;
313                return Verdict::ExitOk;
314            }
315            Event::Signal(sig) => match sig {
316                Sig::Term | Sig::Int | Sig::Quit => {
317                    log_info!("received signal to exit ({sig})");
318                    kill(child, cfg).await;
319                    return Verdict::ExitErr;
320                }
321                Sig::Usr1 => {
322                    log_info!("signalled to kill and restart ssh");
323                    kill(child, cfg).await;
324                    return Verdict::Restart;
325                }
326                Sig::Hup | Sig::Usr2 => log_debug!("woken by {sig}"),
327            },
328        }
329    }
330}
331
332fn spawn(cfg: &Config, monitor: &Monitor) -> io::Result<Child> {
333    // The forwards are built per start rather than once: the UNIX arrangement
334    // needs a different remote socket path each time.
335    let argv = cfg.ssh_argv(monitor.next_forwards());
336
337    // No process_group() call: the child shares rash's group, as autossh's
338    // fork/execvp child does, so a terminal ^C reaches both.
339    Command::new(&cfg.ssh_path)
340        .args(&argv)
341        .kill_on_drop(false)
342        .spawn()
343}
344
345/// SIGTERM, then SIGKILL if the child will not go.
346///
347/// autossh blocks in `waitpid()` here for as long as it takes (autossh.c:1078-1102);
348/// its own comment questions the design. A child that ignores SIGTERM wedges the
349/// supervisor permanently, so rash escalates.
350async fn kill(child: &mut Child, cfg: &Config) {
351    let Some(pid) = child.id() else {
352        return; // already reaped
353    };
354
355    log_debug!("sending SIGTERM to {pid}");
356    // SAFETY: `pid` belongs to a child of this process that has not been reaped,
357    // so the id cannot yet have been reused, and SIGTERM is a valid signal.
358    unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
359
360    match tokio::time::timeout(cfg.kill_timeout, child.wait()).await {
361        Ok(Ok(_)) => {}
362        Ok(Err(e)) => log_err!("waitpid() not successful: {e}"),
363        Err(_) => {
364            log_err!(
365                "ssh {pid} ignored SIGTERM after {}s; sending SIGKILL",
366                cfg.kill_timeout.as_secs()
367            );
368            let _ = child.start_kill();
369            if let Err(e) = child.wait().await {
370                log_err!("waitpid() not successful: {e}");
371            }
372        }
373    }
374}
375
376/// A future that completes at `deadline`, or never if there is not one.
377async fn at(deadline: Option<Instant>) {
378    match deadline {
379        Some(d) => tokio::time::sleep_until(d).await,
380        None => std::future::pending().await,
381    }
382}
383
384/// Signals that mean "stop", and the verdict each produces.
385fn exiting(sig: Sig) -> Option<Verdict> {
386    match sig {
387        Sig::Term | Sig::Int | Sig::Quit => Some(Verdict::ExitErr),
388        Sig::Hup | Sig::Usr1 | Sig::Usr2 => None,
389    }
390}
391
392/// Log the child's death using autossh's wording, so existing log greps still hit.
393fn report(death: Death, reason: Reason) {
394    match (death, reason) {
395        (Death::Signal(s), _) => log_info!("ssh exited on signal {s}, restarting ssh"),
396        (Death::Exit(c), Reason::PrematureExit) => {
397            log_err!("ssh exited prematurely with status {c}; rash exiting")
398        }
399        (Death::Exit(c), Reason::ConnectionLost) => {
400            log_info!("ssh exited with error status {c}; restarting ssh")
401        }
402        (Death::Exit(c), Reason::CleanExit | Reason::Failed) => {
403            log_info!("ssh exited with status {c}; rash exiting")
404        }
405        (Death::Exit(c), Reason::Signalled) => log_info!("ssh exited with status {c}"),
406    }
407}