Skip to main content

rash/
settings.rs

1//! The optional TOML configuration file.
2//!
3//! Nothing here is required: rash works entirely from the command line and the
4//! environment, exactly as autossh does. The file exists so a tunnel you run
5//! often can be named once and started with `rash --session homelab`.
6//!
7//! ```toml
8//! [defaults]
9//! poll = 600
10//! gatetime = 30
11//!
12//! [session.homelab]
13//! monitor  = 20000                                    # or "20000:7", "unix", 0
14//! ssh_args = ["-N", "-R", "2200:localhost:22", "me@host"]
15//! poll     = 300
16//! message  = "homelab"
17//! ```
18//!
19//! Everything a section can set is also settable by flag or environment
20//! variable, and those win — the file is the lowest layer of the precedence
21//! stack, above only the built-in defaults.
22//!
23//! # Key names
24//!
25//! A key is its environment variable with the `AUTOSSH_`/`RASH_` prefix removed
26//! and lowercased, which is why `gatetime` and `maxlifetime` run together while
27//! `first_poll` and `kill_timeout` do not — `AUTOSSH_GATETIME` has no
28//! underscore and `AUTOSSH_FIRST_POLL` does. It looks inconsistent and is not:
29//! do not "tidy" one group to match the other, or every key becomes a guess.
30//! `ssh_args` is the sole exception, having no variable of its own.
31//!
32//! `AUTOSSH_DEBUG` and `RASH_TOUCH_PIDFILE` deliberately have no key here: both
33//! are switches for a single run, not settings for a tunnel.
34//!
35//! `deny_unknown_fields` is on, so a mistyped key is a hard error rather than a
36//! setting that silently does nothing. That makes the list in rash(1) the
37//! contract; keep the two in step.
38
39use serde::de::{self, Unexpected, Visitor};
40use serde::{Deserialize, Deserializer};
41use std::collections::BTreeMap;
42use std::fmt;
43use std::io;
44use std::path::{Path, PathBuf};
45
46/// A parsed config file.
47#[derive(Debug, Default, Clone, Deserialize, PartialEq, Eq)]
48#[serde(deny_unknown_fields)]
49pub struct File {
50    #[serde(default)]
51    pub defaults: Section,
52    /// Written as `[session.<name>]`.
53    #[serde(default, rename = "session")]
54    pub sessions: BTreeMap<String, Section>,
55}
56
57/// A monitor spec, which TOML may express as a bare integer or as a string.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum Spec {
60    Port(u32),
61    Text(String),
62}
63
64impl Spec {
65    pub fn as_text(&self) -> String {
66        match self {
67            Self::Port(n) => n.to_string(),
68            Self::Text(s) => s.clone(),
69        }
70    }
71}
72
73/// Hand-written so a bad value says what was wanted.
74///
75/// `#[serde(untagged)]` gets the parsing right but reports a failure as "data
76/// did not match any variant of untagged enum Spec", which tells a user nothing
77/// about what to write instead. Every other error rash produces is a sentence.
78impl<'de> Deserialize<'de> for Spec {
79    fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
80        struct SpecVisitor;
81
82        impl Visitor<'_> for SpecVisitor {
83            type Value = Spec;
84
85            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86                f.write_str("a monitor port, or \"port:echo_port\", \"unix\", or 0")
87            }
88
89            fn visit_u64<E: de::Error>(self, n: u64) -> Result<Spec, E> {
90                u32::try_from(n)
91                    .map(Spec::Port)
92                    .map_err(|_| E::invalid_value(Unexpected::Unsigned(n), &self))
93            }
94
95            fn visit_i64<E: de::Error>(self, n: i64) -> Result<Spec, E> {
96                u32::try_from(n)
97                    .map(Spec::Port)
98                    .map_err(|_| E::invalid_value(Unexpected::Signed(n), &self))
99            }
100
101            fn visit_str<E: de::Error>(self, s: &str) -> Result<Spec, E> {
102                Ok(Spec::Text(s.to_owned()))
103            }
104        }
105
106        de.deserialize_any(SpecVisitor)
107    }
108}
109
110/// One `[defaults]` or `[session.<name>]` block. Every field is optional; an
111/// absent one simply defers to the layer below.
112#[derive(Debug, Default, Clone, Deserialize, PartialEq, Eq)]
113#[serde(deny_unknown_fields)]
114pub struct Section {
115    pub monitor: Option<Spec>,
116    pub ssh_args: Option<Vec<String>>,
117    pub ssh_path: Option<PathBuf>,
118    pub poll: Option<u64>,
119    pub first_poll: Option<u64>,
120    pub gatetime: Option<u64>,
121    pub maxstart: Option<i64>,
122    pub maxlifetime: Option<u64>,
123    pub message: Option<String>,
124    pub pidfile: Option<PathBuf>,
125    pub monitor_host: Option<String>,
126    pub kill_timeout: Option<u64>,
127    /// `syslog`, `stderr`, or a path to a file.
128    pub log: Option<String>,
129    /// `text` or `json`.
130    pub log_format: Option<String>,
131    /// A syslog level name, or the numbers 0-7 as a string.
132    pub loglevel: Option<String>,
133}
134
135impl File {
136    /// The effective section: `[defaults]` with the named session laid over it.
137    pub fn section(&self, session: Option<&str>) -> Result<Section, String> {
138        let mut merged = self.defaults.clone();
139        if let Some(name) = session {
140            let Some(s) = self.sessions.get(name) else {
141                return Err(format!(
142                    "no session named \"{name}\" in the config file{}",
143                    self.hint()
144                ));
145            };
146            merged.overlay(s);
147        }
148        Ok(merged)
149    }
150
151    /// The session names, sorted.
152    pub fn session_names(&self) -> Vec<&str> {
153        self.sessions.keys().map(String::as_str).collect()
154    }
155
156    fn hint(&self) -> String {
157        let names = self.session_names();
158        if names.is_empty() {
159            " (it defines none)".to_owned()
160        } else {
161            format!(" (it has: {})", names.join(", "))
162        }
163    }
164}
165
166impl Section {
167    /// Take every value `other` sets, leaving the rest alone.
168    fn overlay(&mut self, other: &Section) {
169        macro_rules! take {
170            ($($field:ident),* $(,)?) => {
171                $( if other.$field.is_some() { self.$field = other.$field.clone(); } )*
172            };
173        }
174        take!(
175            monitor,
176            ssh_args,
177            ssh_path,
178            poll,
179            first_poll,
180            gatetime,
181            maxstart,
182            maxlifetime,
183            message,
184            pidfile,
185            monitor_host,
186            kill_timeout,
187            log,
188            log_format,
189            loglevel,
190        );
191    }
192}
193
194/// Parse config text. Exposed so a caller need not take a TOML dependency of
195/// its own just to build a `File`.
196pub fn parse(text: &str) -> Result<File, String> {
197    toml::from_str(text).map_err(|e| e.to_string())
198}
199
200/// Read a config file. A missing file is not an error — most runs have none.
201pub fn load(path: &Path) -> Result<File, String> {
202    match std::fs::read_to_string(path) {
203        Ok(text) => parse(&text).map_err(|e| format!("{}: {e}", path.display())),
204        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(File::default()),
205        Err(e) => Err(format!("{}: {e}", path.display())),
206    }
207}