1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Verdict {
28 Restart,
29 ExitOk,
30 ExitErr,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum Reason {
38 Signalled,
39 PrematureExit,
40 ConnectionLost,
41 CleanExit,
42 Failed,
43}
44
45#[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
61pub fn classify(
65 death: Death,
66 start_count: u64,
67 uptime: Duration,
68 gate: Duration,
69) -> (Verdict, Reason) {
70 let code = match death {
71 Death::Signal(_) => return (Verdict::Restart, Reason::Signalled),
74 Death::Exit(c) => c,
75 };
76
77 if start_count == 1 && !gate.is_zero() && uptime <= gate {
80 return (Verdict::ExitErr, Reason::PrematureExit);
81 }
82
83 match code {
84 255 => (Verdict::Restart, Reason::ConnectionLost),
87 0 => (Verdict::ExitOk, Reason::CleanExit),
88 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
119pub 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
156pub 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 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 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 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
245struct Ctx<'a> {
247 cfg: &'a Config,
248 monitor: &'a Monitor,
249 pid_file: Option<&'a PidFile>,
250 deadline: Option<Instant>,
251}
252
253enum 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 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 let argv = cfg.ssh_argv(monitor.next_forwards());
336
337 Command::new(&cfg.ssh_path)
340 .args(&argv)
341 .kill_on_drop(false)
342 .spawn()
343}
344
345async fn kill(child: &mut Child, cfg: &Config) {
351 let Some(pid) = child.id() else {
352 return; };
354
355 log_debug!("sending SIGTERM to {pid}");
356 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
376async 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
384fn 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
392fn 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}