Skip to main content

rash/monitor/
mod.rs

1//! Testing that the tunnel still carries traffic.
2//!
3//! Three arrangements, all set up by the `-L`/`-R` forwards this module hands
4//! to each ssh start:
5//!
6//! * **Loop** — data written to the local monitor port travels to the remote,
7//!   where the matching `-R` forward sends it straight back to a second local
8//!   port that rash is listening on.
9//! * **Echo** — data written to the local monitor port reaches an echo service
10//!   on the remote, which returns it over the same connection.
11//! * **Unix** — the loop again, but over UNIX-domain sockets, so there are no
12//!   ports to choose and none to collide at either end.
13//!
14//! The listening socket is opened once and held for the life of the process, as
15//! autossh does (autossh.c:465-472).
16
17pub mod probe;
18
19use crate::config::{Config, Monitor as Spec, UnixPaths};
20use crate::{log_debug, log_info};
21use std::ffi::OsString;
22use std::fs;
23use std::io;
24use std::net::{IpAddr, SocketAddr};
25use std::os::fd::{AsRawFd, RawFd};
26use std::os::unix::fs::{DirBuilderExt, MetadataExt};
27use std::path::{Path, PathBuf};
28use std::time::Duration;
29use tokio::net::{TcpListener, TcpStream, UnixListener, UnixStream};
30use tokio::time::timeout;
31
32/// `MAX_CONN_TRIES`, autossh.c:96.
33///
34/// autossh configures 3 but performs 2: its loop is `while (tries++ < 3)` with
35/// an immediate `if (tries >= 3) break` at the top (autossh.c:1285-1292). rash
36/// makes all three attempts.
37const MAX_TRIES: u32 = 3;
38
39/// How long to pause between probe attempts, as a fraction of the net timeout.
40///
41/// Without a pause the retries are worthless in the case that most needs them:
42/// a refused connection fails in microseconds, so three attempts finish in less
43/// time than one round trip and nothing transient has a chance to clear. Scaled
44/// rather than fixed so a short poll interval keeps a short total probe budget,
45/// and capped so a long one does not sit idle.
46const RETRY_PAUSE_DIVISOR: u32 = 10;
47const RETRY_PAUSE_MAX: Duration = Duration::from_secs(1);
48
49/// Whatever the monitor listens on, held for the whole run.
50enum Inbound {
51    Tcp(TcpListener),
52    Unix(UnixListener),
53}
54
55/// The live monitor.
56pub struct Monitor {
57    spec: Spec,
58    host: IpAddr,
59    unix: Option<UnixPaths>,
60    inbound: Option<Inbound>,
61}
62
63impl Monitor {
64    /// Open the listening socket, if this arrangement needs one.
65    ///
66    /// A failure to bind is fatal, exactly as in autossh: without the listener
67    /// the loop can never complete and every probe would fail.
68    pub async fn bind(cfg: &Config) -> io::Result<Self> {
69        let inbound = match (cfg.monitor, cfg.unix.as_ref()) {
70            (Spec::Loop { port }, _) => {
71                let addr = SocketAddr::new(cfg.monitor_host, port + 1);
72                // Rust opens sockets with SOCK_CLOEXEC, which is what keeps the
73                // forked ssh from inheriting this listener and holding the port
74                // open after rash is gone. autossh sets FD_CLOEXEC by hand
75                // (autossh.c:469).
76                let listener = TcpListener::bind(addr).await?;
77                log_debug!("monitor listening on {addr}");
78                Some(Inbound::Tcp(listener))
79            }
80            (Spec::Unix, Some(paths)) => {
81                prepare_socket_dir(&paths.local_in)?;
82                // A socket file left by a previous run would stop the bind.
83                let _ = fs::remove_file(&paths.local_in);
84                let listener = UnixListener::bind(&paths.local_in)?;
85                log_debug!("monitor listening on {}", paths.local_in.display());
86                Some(Inbound::Unix(listener))
87            }
88            _ => None,
89        };
90
91        Ok(Self {
92            spec: cfg.monitor,
93            host: cfg.monitor_host,
94            unix: cfg.unix.clone(),
95            inbound,
96        })
97    }
98
99    pub fn enabled(&self) -> bool {
100        !matches!(self.spec, Spec::Disabled)
101    }
102
103    /// The descriptor the monitor listens on, if it has one.
104    ///
105    /// Exposed so a test can assert it is close-on-exec: were the forked ssh to
106    /// inherit it, the port or socket would stay held after rash exited.
107    pub fn listener_fd(&self) -> Option<RawFd> {
108        match &self.inbound {
109            Some(Inbound::Tcp(l)) => Some(l.as_raw_fd()),
110            Some(Inbound::Unix(l)) => Some(l.as_raw_fd()),
111            None => None,
112        }
113    }
114
115    /// The forwards for the next ssh start.
116    ///
117    /// For the UNIX arrangement the remote socket path is different every time.
118    /// A socket left behind on the remote by an unclean disconnect would stop
119    /// sshd binding it, and `StreamLocalBindUnlink` defaults to `no` on the
120    /// server — which a client cannot override. Without a fresh path, rash
121    /// would restart for ever against a forward that could never come up.
122    pub fn next_forwards(&self) -> Vec<OsString> {
123        // ssh binds the outbound socket itself, so clear any leftover first.
124        if let Some(u) = &self.unix {
125            let _ = fs::remove_file(&u.local_out);
126        }
127
128        let remote = self.remote_sock();
129        self.spec.forwards(self.host, self.unix.as_ref(), &remote)
130    }
131
132    fn remote_sock(&self) -> PathBuf {
133        match &self.unix {
134            Some(u) => u
135                .remote_dir
136                .join(format!("rash-{:016x}.sock", probe::nonce())),
137            None => PathBuf::new(),
138        }
139    }
140
141    /// Send a probe and wait for it to come back. `false` means the tunnel is
142    /// not carrying traffic and ssh should be restarted.
143    pub async fn probe(&self, cfg: &Config) -> bool {
144        let pause = (cfg.net_timeout / RETRY_PAUSE_DIVISOR).min(RETRY_PAUSE_MAX);
145
146        for attempt in 1..=MAX_TRIES {
147            if self.attempt(cfg).await {
148                log_debug!("connection ok");
149                return true;
150            }
151            log_debug!("monitor attempt {attempt} of {MAX_TRIES} failed");
152            if attempt < MAX_TRIES {
153                tokio::time::sleep(pause).await;
154            }
155        }
156        log_info!("tried connection {MAX_TRIES} times and failed");
157        false
158    }
159
160    /// One probe, on connections of its own.
161    ///
162    /// autossh opens the write connection once and reuses it across retries
163    /// while re-accepting the read side (autossh.c:1279-1299), which cannot
164    /// work in loop mode: the remote end is still bound to the read connection
165    /// that was just closed, so the retry waits for an accept that never comes.
166    /// A fresh pair per attempt keeps the two sides paired.
167    async fn attempt(&self, cfg: &Config) -> bool {
168        let net = cfg.net_timeout;
169        let msg = probe::message(cfg);
170
171        match self.spec {
172            Spec::Disabled => true,
173
174            Spec::Echo { port, .. } => {
175                let addr = SocketAddr::new(self.host, port);
176                let Some(mut s) = connect_tcp(addr, net).await else {
177                    return false;
178                };
179                let (mut r, mut w) = s.split();
180                matches!(
181                    timeout(net, probe::exchange(&mut w, &mut r, &msg)).await,
182                    Ok(true)
183                )
184            }
185
186            Spec::Loop { port } => {
187                let Some(Inbound::Tcp(listener)) = &self.inbound else {
188                    return false;
189                };
190                let addr = SocketAddr::new(self.host, port);
191                let Some(mut w) = connect_tcp(addr, net).await else {
192                    return false;
193                };
194                // Waiting for the accept before writing is safe because the
195                // forward cascade is triggered by the connection, not by the
196                // data: connecting to the local port makes ssh open a channel,
197                // the remote's -R listener accept it, and a connection come
198                // back to us. autossh relies on the same ordering — its poll
199                // for an accept precedes any send (autossh.c:1320-1337).
200                let Some(mut r) = accept_tcp(listener, net).await else {
201                    return false;
202                };
203                matches!(
204                    timeout(net, probe::exchange(&mut w, &mut r, &msg)).await,
205                    Ok(true)
206                )
207            }
208
209            Spec::Unix => {
210                let (Some(u), Some(Inbound::Unix(listener))) = (&self.unix, &self.inbound) else {
211                    return false;
212                };
213                let Some(mut w) = connect_unix(&u.local_out, net).await else {
214                    return false;
215                };
216                let Some(mut r) = accept_unix(listener, net).await else {
217                    return false;
218                };
219                matches!(
220                    timeout(net, probe::exchange(&mut w, &mut r, &msg)).await,
221                    Ok(true)
222                )
223            }
224        }
225    }
226}
227
228impl Drop for Monitor {
229    /// Take the sockets away on the way out.
230    ///
231    /// Binding already unlinks a stale file, so a leftover cannot break the next
232    /// run — but without this the directory accumulates a dead pair per run,
233    /// indefinitely. The pid file has the same guard, and the same limitation:
234    /// a process killed with SIGKILL runs no destructors, so the next bind's
235    /// unlink is still the thing that guarantees correctness.
236    fn drop(&mut self) {
237        if let Some(u) = &self.unix {
238            let _ = fs::remove_file(&u.local_in);
239            // ssh owns this one, but it does not always outlive us to clean up.
240            let _ = fs::remove_file(&u.local_out);
241        }
242    }
243}
244
245/// Create the socket's directory if it is missing, readable by nobody else.
246///
247/// Created with the mode in the `mkdir(2)` call rather than chmod-ed afterwards,
248/// so it is never briefly world-readable — the default `/tmp/rash-<uid>` sits in
249/// a world-writable directory, and the umask decides what `create_dir_all`
250/// alone would leave behind.
251///
252/// An existing directory is left as it is, so pointing `RASH_SOCKET_DIR` at
253/// something shared cannot lock other users out of it — but it must belong to
254/// us. The default path is derived from the uid and therefore guessable, so
255/// another local user can create it first; binding our sockets inside a
256/// directory they control would let them answer probes and report a wedged
257/// tunnel as healthy.
258fn prepare_socket_dir(sock: &Path) -> io::Result<()> {
259    let Some(dir) = sock.parent() else {
260        return Ok(());
261    };
262
263    match fs::metadata(dir) {
264        Ok(md) => {
265            // SAFETY: getuid always succeeds and reads no memory.
266            let me = unsafe { libc::getuid() };
267            if md.uid() != me {
268                return Err(io::Error::new(
269                    io::ErrorKind::PermissionDenied,
270                    format!(
271                        "socket directory {} belongs to uid {}, not to us ({me})",
272                        dir.display(),
273                        md.uid()
274                    ),
275                ));
276            }
277            Ok(())
278        }
279        Err(e) if e.kind() == io::ErrorKind::NotFound => fs::DirBuilder::new()
280            .recursive(true)
281            .mode(0o700)
282            .create(dir),
283        Err(e) => Err(e),
284    }
285}
286
287async fn connect_tcp(addr: SocketAddr, net: Duration) -> Option<TcpStream> {
288    match timeout(net, TcpStream::connect(addr)).await {
289        Ok(Ok(s)) => Some(s),
290        Ok(Err(e)) => {
291            log_info!("{addr}: {e}");
292            None
293        }
294        Err(_) => {
295            log_info!("{addr}: connect timed out");
296            None
297        }
298    }
299}
300
301async fn connect_unix(path: &Path, net: Duration) -> Option<UnixStream> {
302    match timeout(net, UnixStream::connect(path)).await {
303        Ok(Ok(s)) => Some(s),
304        Ok(Err(e)) => {
305            log_info!("{}: {e}", path.display());
306            None
307        }
308        Err(_) => {
309            log_info!("{}: connect timed out", path.display());
310            None
311        }
312    }
313}
314
315async fn accept_tcp(listener: &TcpListener, net: Duration) -> Option<TcpStream> {
316    match timeout(net, listener.accept()).await {
317        Ok(Ok((s, _))) => Some(s),
318        Ok(Err(e)) => {
319            log_debug!("error accepting read connection: {e}");
320            None
321        }
322        Err(_) => {
323            log_info!("timeout polling to accept read connection");
324            None
325        }
326    }
327}
328
329async fn accept_unix(listener: &UnixListener, net: Duration) -> Option<UnixStream> {
330    match timeout(net, listener.accept()).await {
331        Ok(Ok((s, _))) => Some(s),
332        Ok(Err(e)) => {
333            log_debug!("error accepting read connection: {e}");
334            None
335        }
336        Err(_) => {
337            log_info!("timeout polling to accept read connection");
338            None
339        }
340    }
341}