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}