Skip to main content

rash/
config.rs

1//! Resolving a complete configuration from the command line and the environment.
2//!
3//! This is a pure function: it reads no files, opens no sockets, and logs
4//! nothing. Anything worth telling the user about comes back as a warning for
5//! the caller to emit once a log sink exists.
6
7use crate::cli::{self, Invocation};
8use crate::settings::{self, Section};
9use std::ffi::OsString;
10use std::fmt;
11use std::net::{IpAddr, Ipv4Addr};
12use std::path::{Path, PathBuf};
13use std::time::Duration;
14
15/// autossh's compiled-in default (`SSH_PATH`, autossh.c:88-90).
16const DEFAULT_SSH_PATH: &str = "/usr/bin/ssh";
17/// `POLL_TIME`, autossh.c:92.
18const DEFAULT_POLL: u64 = 600;
19/// `GATE_TIME`, autossh.c:93.
20const DEFAULT_GATE: u64 = 30;
21/// `TIMEO_NET`, autossh.c:95 — milliseconds.
22const DEFAULT_NET_TIMEOUT_MS: u64 = 15_000;
23/// `MAX_MESSAGE`, autossh.c:98.
24const MAX_MESSAGE: usize = 64;
25/// rash's own: how long a child gets to honour SIGTERM before SIGKILL.
26const DEFAULT_KILL_TIMEOUT: u64 = 5;
27
28/// How the connection is monitored.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Monitor {
31    /// `-M 0`: no monitoring; react only to ssh exiting and to signals.
32    Disabled,
33    /// `-M port`: a loop of forwardings. Write to `port`, read back on `port + 1`.
34    Loop { port: u16 },
35    /// `-M port:echo`: a remote echo service. `port` carries both directions.
36    Echo { port: u16, echo: u16 },
37    /// `-M unix`: the same loop, over UNIX-domain sockets. No ports to pick and
38    /// none to collide, at either end.
39    Unix,
40}
41
42/// Where the UNIX-domain monitor's sockets live, once resolved.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct UnixPaths {
45    /// ssh's `-L` listener. rash connects here to send a probe, and unlinks it
46    /// before each start so a leftover cannot stop ssh binding it.
47    pub local_out: PathBuf,
48    /// rash's own listener, where the probe arrives back.
49    pub local_in: PathBuf,
50    /// Directory on the remote in which sshd binds the `-R` socket. The file
51    /// name within it changes on every start.
52    pub remote_dir: PathBuf,
53}
54
55impl Monitor {
56    /// The ssh arguments that build the monitor path (§1.3 of the plan).
57    ///
58    /// `unix` and `remote_sock` are consulted only for [`Monitor::Unix`], where
59    /// the remote path is different on every ssh start.
60    pub fn forwards(
61        &self,
62        host: IpAddr,
63        unix: Option<&UnixPaths>,
64        remote_sock: &Path,
65    ) -> Vec<OsString> {
66        let host = host_literal(host);
67        match *self {
68            Self::Disabled => vec![],
69            Self::Loop { port } => vec![
70                "-L".into(),
71                format!("{port}:{host}:{port}").into(),
72                "-R".into(),
73                format!("{port}:{host}:{}", port + 1).into(),
74            ],
75            Self::Echo { port, echo } => {
76                vec!["-L".into(), format!("{port}:{host}:{echo}").into()]
77            }
78            // -L local_socket:remote_socket and -R remote_socket:local_socket,
79            // both documented in ssh(1).
80            Self::Unix => match unix {
81                Some(u) => vec![
82                    "-L".into(),
83                    sock_pair(&u.local_out, remote_sock),
84                    "-R".into(),
85                    sock_pair(remote_sock, &u.local_in),
86                ],
87                None => vec![],
88            },
89        }
90    }
91
92    /// The port rash writes its probe to, if it uses one.
93    pub fn write_port(&self) -> Option<u16> {
94        match *self {
95            Self::Disabled | Self::Unix => None,
96            Self::Loop { port } | Self::Echo { port, .. } => Some(port),
97        }
98    }
99
100    /// The port rash listens on for the probe to come back, if it uses one.
101    pub fn read_port(&self) -> Option<u16> {
102        match *self {
103            Self::Loop { port } => Some(port + 1),
104            Self::Disabled | Self::Echo { .. } | Self::Unix => None,
105        }
106    }
107}
108
109/// `a:b`, built without going through `str` so a non-UTF-8 path survives.
110fn sock_pair(a: &Path, b: &Path) -> OsString {
111    let mut s = OsString::from(a);
112    s.push(":");
113    s.push(b);
114    s
115}
116
117/// ssh's forwarding specs are colon-separated, so an IPv6 literal has to be
118/// bracketed or it is unparseable: `-L 20000:[::1]:20000`, never `20000:::1:20000`.
119/// autossh cannot reach this case at all — it hardcodes `AF_INET` (autossh.c:1624).
120fn host_literal(host: IpAddr) -> String {
121    match host {
122        IpAddr::V4(a) => a.to_string(),
123        IpAddr::V6(a) => format!("[{a}]"),
124    }
125}
126
127impl fmt::Display for Monitor {
128    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129        match *self {
130            Self::Disabled => write!(f, "disabled"),
131            Self::Loop { port } => write!(f, "loop, write {port}, read {}", port + 1),
132            Self::Echo { port, echo } => write!(f, "echo, write {port} to remote echo {echo}"),
133            Self::Unix => write!(f, "loop over UNIX sockets"),
134        }
135    }
136}
137
138/// Log verbosity, numbered as syslog(3) levels so `AUTOSSH_LOGLEVEL` carries over.
139#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
140pub enum Level {
141    Emerg = 0,
142    Alert = 1,
143    Crit = 2,
144    Err = 3,
145    Warning = 4,
146    Notice = 5,
147    Info = 6,
148    Debug = 7,
149}
150
151impl Level {
152    /// A syslog number, as `AUTOSSH_LOGLEVEL` takes, or a level name.
153    fn parse(s: &str) -> Option<Self> {
154        if let Ok(n) = s.parse::<u8>() {
155            return Self::from_num(n);
156        }
157        Some(match s.to_ascii_lowercase().as_str() {
158            "emerg" => Self::Emerg,
159            "alert" => Self::Alert,
160            "crit" => Self::Crit,
161            "err" | "error" => Self::Err,
162            "warning" | "warn" => Self::Warning,
163            "notice" => Self::Notice,
164            "info" => Self::Info,
165            "debug" => Self::Debug,
166            _ => return None,
167        })
168    }
169
170    fn from_num(n: u8) -> Option<Self> {
171        Some(match n {
172            0 => Self::Emerg,
173            1 => Self::Alert,
174            2 => Self::Crit,
175            3 => Self::Err,
176            4 => Self::Warning,
177            5 => Self::Notice,
178            6 => Self::Info,
179            7 => Self::Debug,
180            _ => return None,
181        })
182    }
183}
184
185impl fmt::Display for Level {
186    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
187        let s = match self {
188            Self::Emerg => "emerg",
189            Self::Alert => "alert",
190            Self::Crit => "crit",
191            Self::Err => "err",
192            Self::Warning => "warning",
193            Self::Notice => "notice",
194            Self::Info => "info",
195            Self::Debug => "debug",
196        };
197        f.write_str(s)
198    }
199}
200
201/// Where log lines go. autossh's `L_SYSLOG` / `L_FILELOG` split (autossh.c:105-106).
202#[derive(Debug, Clone, PartialEq, Eq)]
203pub enum LogTarget {
204    Syslog,
205    File(PathBuf),
206    Stderr,
207}
208
209impl fmt::Display for LogTarget {
210    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
211        match self {
212            Self::Syslog => f.write_str("syslog"),
213            Self::File(p) => write!(f, "file {}", p.display()),
214            Self::Stderr => f.write_str("stderr"),
215        }
216    }
217}
218
219/// How a log line is shaped. Orthogonal to where it goes, except for syslog,
220/// which has its own structure and always gets plain text.
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222pub enum Format {
223    Text,
224    Json,
225}
226
227impl fmt::Display for Format {
228    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
229        f.write_str(match self {
230            Self::Text => "text",
231            Self::Json => "json",
232        })
233    }
234}
235
236#[derive(Debug, Clone, PartialEq, Eq)]
237pub struct Log {
238    pub target: LogTarget,
239    pub format: Format,
240    pub level: Level,
241    /// `AUTOSSH_DEBUG` also mirrors syslog output to stderr (`LOG_PERROR`).
242    pub also_stderr: bool,
243}
244
245/// Everything rash needs in order to run, fully resolved.
246#[derive(Debug, Clone, PartialEq, Eq)]
247pub struct Config {
248    pub ssh_path: PathBuf,
249    /// ssh's argv\[1..\] as the user wrote it, *without* the monitor forwards.
250    /// Use [`Config::ssh_argv`] to get what actually gets executed — the
251    /// forwards are built fresh on every start, because the UNIX arrangement
252    /// needs a different remote socket path each time.
253    pub ssh_args: Vec<OsString>,
254    /// Where the forwards belong: the position `-M` occupied, or 0 when the
255    /// port came from the environment (autossh.c:420-427).
256    pub inject_at: usize,
257    pub monitor: Monitor,
258    pub monitor_host: IpAddr,
259    /// Socket paths, resolved only when the monitor is [`Monitor::Unix`].
260    pub unix: Option<UnixPaths>,
261    pub poll: Duration,
262    pub first_poll: Duration,
263    pub net_timeout: Duration,
264    pub gate_time: Duration,
265    /// Negative means no limit (`MAX_START`, autossh.c:97).
266    pub max_start: i64,
267    pub max_lifetime: Option<Duration>,
268    pub message: String,
269    pub pid_file: Option<PathBuf>,
270    pub touch_pid_file: bool,
271    pub background: bool,
272    pub kill_timeout: Duration,
273    pub log: Log,
274    pub dry_run: bool,
275}
276
277impl Config {
278    /// What actually gets executed: the user's arguments with `forwards`
279    /// spliced in where `-M` stood.
280    pub fn ssh_argv(&self, forwards: Vec<OsString>) -> Vec<OsString> {
281        let mut argv = self.ssh_args.clone();
282        cli::splice_forwards(&mut argv, self.inject_at, forwards);
283        argv
284    }
285}
286
287/// The shape of the per-start remote socket name, for `--dry-run`. The literal
288/// placeholder is the point: the real name is different on every ssh start.
289pub fn remote_sock_example(unix: &UnixPaths) -> PathBuf {
290    unix.remote_dir.join("rash-<nonce>.sock")
291}
292
293/// `sun_path` holds 104 bytes on macOS and 108 on Linux, NUL included. Leave
294/// room rather than sitting on the limit.
295const SUN_PATH_MAX: usize = 96;
296
297/// Where to look for a config file, in order of preference:
298///
299/// 1. `~/.rash.toml`, for a single file you can keep next to your dotfiles
300/// 2. `$XDG_CONFIG_HOME/rash/config.toml`, or `~/.config/rash/config.toml`
301///
302/// The caller takes the first that exists. Returning the candidates rather than
303/// resolving them here keeps this module free of filesystem access, so
304/// [`resolve_with`] stays testable without one.
305pub fn config_file_candidates<E: EnvSource + ?Sized>(env: &E) -> Vec<PathBuf> {
306    let home = env.var("HOME").map(PathBuf::from).unwrap_or_default();
307
308    let xdg = match env.var("XDG_CONFIG_HOME").filter(|d| !d.is_empty()) {
309        Some(d) => PathBuf::from(d),
310        None => home.join(".config"),
311    };
312
313    vec![
314        home.join(".rash.toml"),
315        xdg.join("rash").join("config.toml"),
316    ]
317}
318
319/// Where the UNIX monitor's local sockets live.
320///
321/// `$XDG_RUNTIME_DIR` when it is set, else `/tmp/rash-<uid>`. Deliberately not
322/// `$TMPDIR`, which on macOS is a long `/var/folders/...` path that would eat
323/// most of the `sun_path` budget on its own.
324fn socket_dir<E: EnvSource + ?Sized>(env: &E) -> PathBuf {
325    if let Some(d) = env.var("XDG_RUNTIME_DIR").filter(|d| !d.is_empty()) {
326        return PathBuf::from(d);
327    }
328    // SAFETY: getuid always succeeds and reads no memory.
329    let uid = unsafe { libc::getuid() };
330    PathBuf::from(format!("/tmp/rash-{uid}"))
331}
332
333/// Resolve the local socket paths for the UNIX monitor.
334///
335/// The names carry the pid, so this has to be redone after a daemonising fork
336/// or the sockets are named after a process that no longer exists. `main` does
337/// exactly that, for the same reason it writes the pid file after the fork.
338pub fn unix_paths<E: EnvSource + ?Sized>(env: &E) -> Result<UnixPaths, ConfigError> {
339    let dir = match env.var("RASH_SOCKET_DIR").filter(|d| !d.is_empty()) {
340        Some(d) => PathBuf::from(d),
341        None => socket_dir(env),
342    };
343    let remote_dir = match env.var("RASH_REMOTE_SOCKET_DIR").filter(|d| !d.is_empty()) {
344        Some(d) => PathBuf::from(d),
345        None => PathBuf::from("/tmp"),
346    };
347
348    let pid = std::process::id();
349    let paths = UnixPaths {
350        local_out: dir.join(format!("rash-{pid}-out.sock")),
351        local_in: dir.join(format!("rash-{pid}-in.sock")),
352        remote_dir,
353    };
354
355    // Over-long paths fail at bind time with a baffling error from deep inside
356    // the socket layer, so complain here where the cause is obvious. The remote
357    // name is generated per start, so check a representative one.
358    check_sun_path(&paths.local_out)?;
359    check_sun_path(&paths.local_in)?;
360    check_sun_path(&paths.remote_dir.join("rash-0123456789abcdef.sock"))?;
361
362    Ok(paths)
363}
364
365fn check_sun_path(p: &Path) -> Result<(), ConfigError> {
366    let len = p.as_os_str().as_encoded_bytes().len();
367    if len > SUN_PATH_MAX {
368        return Err(ConfigError::Invalid(format!(
369            "socket path is {len} bytes, over rash's {SUN_PATH_MAX}-byte limit \
370             (the kernel's sun_path holds 104 on macOS, 108 on Linux): {}",
371            p.display()
372        )));
373    }
374    Ok(())
375}
376
377#[derive(Debug, PartialEq, Eq)]
378pub enum ConfigError {
379    /// Neither `-M`, `--monitor`, nor `AUTOSSH_PORT` gave a monitor port.
380    /// autossh answers this with usage rather than a message (autossh.c:334-335).
381    NoMonitorPort,
382    Invalid(String),
383}
384
385impl fmt::Display for ConfigError {
386    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
387        match self {
388            Self::NoMonitorPort => f.write_str("no monitor port given"),
389            Self::Invalid(m) => f.write_str(m),
390        }
391    }
392}
393
394impl std::error::Error for ConfigError {}
395
396/// A resolved configuration plus anything the user should be told about it.
397#[derive(Debug, PartialEq, Eq)]
398pub struct Resolved {
399    pub config: Config,
400    pub warnings: Vec<String>,
401}
402
403/// A source of environment variables, so resolution can be tested without
404/// touching the real process environment.
405pub trait EnvSource {
406    fn var(&self, key: &str) -> Option<OsString>;
407}
408
409/// The real process environment.
410pub struct ProcessEnv;
411
412impl EnvSource for ProcessEnv {
413    fn var(&self, key: &str) -> Option<OsString> {
414        std::env::var_os(key)
415    }
416}
417
418impl<S: AsRef<str>> EnvSource for [(S, S)] {
419    fn var(&self, key: &str) -> Option<OsString> {
420        self.iter()
421            .find(|(k, _)| k.as_ref() == key)
422            .map(|(_, v)| OsString::from(v.as_ref()))
423    }
424}
425
426/// `RASH_<name>` if set, else `AUTOSSH_<name>`.
427fn dual<E: EnvSource + ?Sized>(env: &E, name: &str) -> Option<OsString> {
428    env.var(&format!("RASH_{name}"))
429        .or_else(|| env.var(&format!("AUTOSSH_{name}")))
430}
431
432/// Environment values must be text; a non-UTF-8 one is a configuration error.
433fn as_str(name: &str, v: &OsString) -> Result<String, ConfigError> {
434    v.to_str()
435        .map(str::to_owned)
436        .ok_or_else(|| ConfigError::Invalid(format!("{name} is not valid text")))
437}
438
439/// Parse an integer the whole of which must be consumed, as autossh's
440/// `strtoul`/`strtol` checks require (`*s == '\0' || *t != '\0'`).
441///
442/// Unlike autossh this is base 10 only. autossh passes base 0 to `strtoul`, so
443/// it reads `-M 020000` as octal; rash reads it as 20000.
444fn number<T: std::str::FromStr>(what: &str, s: &str) -> Result<T, ConfigError> {
445    s.parse::<T>()
446        .map_err(|_| ConfigError::Invalid(format!("invalid {what} \"{s}\"")))
447}
448
449/// Parse a rash-only boolean variable.
450///
451/// `AUTOSSH_DEBUG` is deliberately not routed through here: autossh treats it
452/// as set-or-not and rash matches that. rash's own switches read their value,
453/// so `RASH_TOUCH_PIDFILE=0` means off rather than a surprising on.
454fn boolean(what: &str, v: &OsString) -> Result<bool, ConfigError> {
455    let s = as_str(what, v)?;
456    match s.trim().to_ascii_lowercase().as_str() {
457        "" | "0" | "false" | "no" | "off" => Ok(false),
458        "1" | "true" | "yes" | "on" => Ok(true),
459        _ => Err(ConfigError::Invalid(format!(
460            "invalid {what} \"{s}\", expected 1/0, true/false, yes/no, or on/off"
461        ))),
462    }
463}
464
465/// Resolve the command line and environment into a runnable configuration,
466/// with no config file involved.
467pub fn resolve<E: EnvSource + ?Sized>(inv: Invocation, env: &E) -> Result<Resolved, ConfigError> {
468    resolve_with(inv, env, &settings::File::default())
469}
470
471/// Resolve, consulting a config file as the bottom layer.
472///
473/// Precedence, highest first: a long flag, `RASH_*`, `AUTOSSH_*`, the named
474/// `[session.<name>]`, `[defaults]`, then the built-in default. The monitor
475/// port inverts the top two rungs, because autossh documents `AUTOSSH_PORT` as
476/// overriding `-M`.
477pub fn resolve_with<E: EnvSource + ?Sized>(
478    mut inv: Invocation,
479    env: &E,
480    file: &settings::File,
481) -> Result<Resolved, ConfigError> {
482    let mut warnings = Vec::new();
483    let section: Section = file
484        .section(inv.session.as_deref())
485        .map_err(ConfigError::Invalid)?;
486
487    // autossh spells this AUTOSSH_PATH; RASH_PATH would read as an override of
488    // $PATH, so rash's alias is the less ambiguous RASH_SSH_PATH.
489    let ssh_path = env
490        .var("RASH_SSH_PATH")
491        .or_else(|| env.var("AUTOSSH_PATH"))
492        .map(PathBuf::from)
493        .or_else(|| section.ssh_path.clone())
494        .unwrap_or_else(|| PathBuf::from(DEFAULT_SSH_PATH));
495
496    // Logging. AUTOSSH_DEBUG wins over AUTOSSH_LOGLEVEL, as in autossh.c:589-603.
497    let mut level = Level::Info;
498    let mut also_stderr = false;
499    if dual(env, "DEBUG").is_some() {
500        level = Level::Debug;
501        also_stderr = true;
502    } else {
503        let spec = match dual(env, "LOGLEVEL") {
504            Some(v) => Some(as_str("log level", &v)?),
505            None => section.loglevel.clone(),
506        };
507        if let Some(s) = spec {
508            level = Level::parse(&s)
509                .ok_or_else(|| ConfigError::Invalid(format!("invalid log level \"{s}\"")))?;
510        }
511    }
512
513    // AUTOSSH_LOGFILE is always a path; RASH_LOG and the config file also take
514    // the keywords `syslog` and `stderr`.
515    let log_spec = match env.var("RASH_LOG") {
516        Some(v) => Some(as_str("log target", &v)?),
517        None => match dual(env, "LOGFILE") {
518            Some(v) => Some(as_str("log file", &v)?),
519            None => section.log.clone(),
520        },
521    };
522    let target = match log_spec.as_deref() {
523        None | Some("syslog") => LogTarget::Syslog,
524        Some("stderr") => LogTarget::Stderr,
525        Some(p) => LogTarget::File(PathBuf::from(p)),
526    };
527
528    let fmt_spec = match env.var("RASH_LOG_FORMAT") {
529        Some(v) => Some(as_str("log format", &v)?),
530        None => section.log_format.clone(),
531    };
532    let format = match fmt_spec.as_deref() {
533        None | Some("text") => Format::Text,
534        Some("json") => Format::Json,
535        Some(other) => {
536            return Err(ConfigError::Invalid(format!(
537                "invalid log format \"{other}\", expected text or json"
538            )));
539        }
540    };
541
542    let mut poll_secs: u64 = match dual(env, "POLL") {
543        Some(v) => {
544            let s = as_str("poll time", &v)?;
545            let n: u64 = number("poll time", &s)?;
546            if n == 0 {
547                return Err(ConfigError::Invalid(format!("invalid poll time \"{s}\"")));
548            }
549            n
550        }
551        None => section.poll.unwrap_or(DEFAULT_POLL),
552    };
553
554    // Unless set explicitly the first poll matches the poll time (autossh.c:621-627).
555    let mut first_poll_secs: u64 = match dual(env, "FIRST_POLL") {
556        Some(v) => {
557            let s = as_str("first poll time", &v)?;
558            let n: u64 = number("first poll time", &s)?;
559            if n == 0 {
560                return Err(ConfigError::Invalid(format!(
561                    "invalid first poll time \"{s}\""
562                )));
563            }
564            n
565        }
566        None => section.first_poll.unwrap_or(poll_secs),
567    };
568
569    let mut gate_secs: u64 = match dual(env, "GATETIME") {
570        Some(v) => {
571            let s = as_str("gate time", &v)?;
572            let n: i64 = number("gate time", &s)?;
573            if n < 0 {
574                return Err(ConfigError::Invalid(format!("invalid gate time \"{s}\"")));
575            }
576            n as u64
577        }
578        None => section.gatetime.unwrap_or(DEFAULT_GATE),
579    };
580
581    let max_start: i64 = match dual(env, "MAXSTART") {
582        Some(v) => {
583            let s = as_str("max start number", &v)?;
584            let n: i64 = number("max start number", &s)?;
585            if n < -1 {
586                return Err(ConfigError::Invalid(format!(
587                    "invalid max start number \"{s}\""
588                )));
589            }
590            n
591        }
592        None => section.maxstart.unwrap_or(-1),
593    };
594
595    let message = match dual(env, "MESSAGE") {
596        Some(v) => as_str("message", &v)?,
597        None => section.message.clone().unwrap_or_default(),
598    };
599    if message.len() > MAX_MESSAGE {
600        return Err(ConfigError::Invalid(format!(
601            "echo message may only be {MAX_MESSAGE} bytes long"
602        )));
603    }
604
605    let lifetime_secs = match dual(env, "MAXLIFETIME") {
606        Some(v) => {
607            let s = as_str("max lifetime", &v)?;
608            number::<u64>("max lifetime", &s)?
609        }
610        None => section.maxlifetime.unwrap_or(0),
611    };
612    let max_lifetime = (lifetime_secs > 0).then(|| Duration::from_secs(lifetime_secs));
613
614    // A lifetime shorter than a poll interval would mean never polling at all
615    // (autossh.c:661-677).
616    if let Some(life) = max_lifetime {
617        let life_secs = life.as_secs();
618        if poll_secs > life_secs {
619            warnings.push(format!(
620                "poll time is greater than lifetime, dropping poll time to {life_secs}"
621            ));
622            poll_secs = life_secs;
623        }
624        if first_poll_secs > life_secs {
625            warnings.push(format!(
626                "first poll time is greater than lifetime, dropping first poll time to {life_secs}"
627            ));
628            first_poll_secs = life_secs;
629        }
630    }
631
632    let pid_file = dual(env, "PIDFILE")
633        .filter(|v| !v.is_empty())
634        .map(PathBuf::from)
635        .or_else(|| section.pidfile.clone());
636    // The next three have no autossh counterpart — TOUCH_PIDFILE is a compile-time
637    // #define there, and the other two are rash's own — so they are RASH_-only.
638    let touch_pid_file = match env.var("RASH_TOUCH_PIDFILE") {
639        Some(v) => boolean("touch pidfile", &v)?,
640        None => false,
641    };
642
643    let kill_timeout = match env.var("RASH_KILL_TIMEOUT") {
644        Some(v) => {
645            let s = as_str("kill timeout", &v)?;
646            Duration::from_secs(number("kill timeout", &s)?)
647        }
648        None => Duration::from_secs(section.kill_timeout.unwrap_or(DEFAULT_KILL_TIMEOUT)),
649    };
650
651    let monitor_host: IpAddr = match env.var("RASH_MONITOR_HOST") {
652        Some(v) => {
653            let s = as_str("monitor host", &v)?;
654            s.parse()
655                .map_err(|_| ConfigError::Invalid(format!("invalid monitor host \"{s}\"")))?
656        }
657        None => match &section.monitor_host {
658            Some(s) => s
659                .parse()
660                .map_err(|_| ConfigError::Invalid(format!("invalid monitor host \"{s}\"")))?,
661            None => IpAddr::V4(Ipv4Addr::LOCALHOST),
662        },
663    };
664
665    // The monitor port. `--monitor` outranks the environment, which outranks
666    // `-M` — the inversion is autossh's own documented behaviour for
667    // AUTOSSH_PORT (autossh.c:327-329).
668    let env_port = dual(env, "PORT").filter(|v| !v.is_empty());
669    let (spec, from_dash_m) = match (inv.monitor_long.clone(), env_port, inv.monitor.clone()) {
670        (Some(s), _, _) => (Some(s), false),
671        (None, Some(s), _) => (Some(s), false),
672        (None, None, Some(s)) => (Some(s), true),
673        (None, None, None) => (
674            section
675                .monitor
676                .as_ref()
677                .map(|m| OsString::from(m.as_text())),
678            false,
679        ),
680    };
681    let spec = spec.ok_or(ConfigError::NoMonitorPort)?;
682    let spec = as_str("monitor port", &spec)?;
683    let monitor = parse_monitor(&spec, &mut warnings)?;
684
685    // A named session can carry the ssh arguments too, so `rash --session x`
686    // needs nothing else on the command line.
687    if inv.ssh_args.is_empty()
688        && let Some(args) = &section.ssh_args
689    {
690        inv.ssh_args = args.iter().map(OsString::from).collect();
691    }
692
693    // Short poll times need proportionally shorter network timeouts, or a single
694    // probe could outlast the interval (autossh.c:391-396).
695    //
696    // Saturating, because the poll time is an unbounded u64 straight from the
697    // user: `AUTOSSH_POLL=18446744073709552` overflows the multiply, which
698    // panics in a debug build and — far worse — silently wraps to a 192ms
699    // timeout in a release one.
700    let mut net_timeout_ms = DEFAULT_NET_TIMEOUT_MS;
701    let half_poll_ms = poll_secs.saturating_mul(1000) / 2;
702    if half_poll_ms < net_timeout_ms {
703        net_timeout_ms = half_poll_ms;
704        warnings.push(format!(
705            "short poll time: adjusting net timeouts to {net_timeout_ms}"
706        ));
707    }
708
709    // Backgrounding means nobody is there to type a passphrase, so the starting
710    // gate would only cause a spurious exit (autossh.c:447-458).
711    if inv.background {
712        gate_secs = 0;
713    }
714
715    // autossh injects the forwards where -M stood, but at the very front when the
716    // port came from the environment instead (autossh.c:420-427).
717    if !from_dash_m {
718        inv.inject_at = 0;
719    }
720
721    let unix = match monitor {
722        Monitor::Unix => Some(unix_paths(env)?),
723        _ => None,
724    };
725
726    Ok(Resolved {
727        config: Config {
728            ssh_path,
729            ssh_args: inv.ssh_args,
730            inject_at: inv.inject_at,
731            monitor,
732            monitor_host,
733            unix,
734            poll: Duration::from_secs(poll_secs),
735            first_poll: Duration::from_secs(first_poll_secs),
736            net_timeout: Duration::from_millis(net_timeout_ms),
737            gate_time: Duration::from_secs(gate_secs),
738            max_start,
739            max_lifetime,
740            message,
741            pid_file,
742            touch_pid_file,
743            background: inv.background,
744            kill_timeout,
745            log: Log {
746                target,
747                format,
748                level,
749                also_stderr,
750            },
751            dry_run: inv.dry_run,
752        },
753        warnings,
754    })
755}
756
757/// Parse `port`, `port:echo_port`, `0`, or `unix`.
758fn parse_monitor(spec: &str, warnings: &mut Vec<String>) -> Result<Monitor, ConfigError> {
759    // rash's own: the same loop, but over UNIX-domain sockets.
760    if spec.eq_ignore_ascii_case("unix") {
761        return Ok(Monitor::Unix);
762    }
763
764    // autossh splits the echo port off first (autossh.c:343-349).
765    let (port_s, echo) = match spec.split_once(':') {
766        Some((p, e)) => {
767            let n: u32 = number("echo port", e)?;
768            if n == 0 || n > u32::from(u16::MAX) {
769                // One space. autossh.c:348 has two — "invalid echo port  \"%s\"" —
770                // and this is a deliberate divergence from it, listed with the
771                // others in rash(1). Nothing can be relying on the spacing: it
772                // is a startup rejection written to stderr before a log sink
773                // even exists, so no log parser ever sees it, and rash exits
774                // non-zero either way.
775                return Err(ConfigError::Invalid(format!("invalid echo port \"{e}\"")));
776            }
777            (p, Some(n as u16))
778        }
779        None => (spec, None),
780    };
781
782    let port: u32 = number("port", port_s)?;
783    if port == 0 {
784        warnings.push("port set to 0, monitoring disabled".into());
785        return Ok(Monitor::Disabled);
786    }
787    // The loop mode needs port + 1 as well, so autossh caps both modes at 65534.
788    if port > 65534 {
789        return Err(ConfigError::Invalid(format!(
790            "monitor port ({port}) out of range"
791        )));
792    }
793    let port = port as u16;
794
795    Ok(match echo {
796        Some(echo) => Monitor::Echo { port, echo },
797        None => Monitor::Loop { port },
798    })
799}