1use 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
15const DEFAULT_SSH_PATH: &str = "/usr/bin/ssh";
17const DEFAULT_POLL: u64 = 600;
19const DEFAULT_GATE: u64 = 30;
21const DEFAULT_NET_TIMEOUT_MS: u64 = 15_000;
23const MAX_MESSAGE: usize = 64;
25const DEFAULT_KILL_TIMEOUT: u64 = 5;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Monitor {
31 Disabled,
33 Loop { port: u16 },
35 Echo { port: u16, echo: u16 },
37 Unix,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct UnixPaths {
45 pub local_out: PathBuf,
48 pub local_in: PathBuf,
50 pub remote_dir: PathBuf,
53}
54
55impl Monitor {
56 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 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 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 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
109fn 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
117fn 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#[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 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#[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#[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 pub also_stderr: bool,
243}
244
245#[derive(Debug, Clone, PartialEq, Eq)]
247pub struct Config {
248 pub ssh_path: PathBuf,
249 pub ssh_args: Vec<OsString>,
254 pub inject_at: usize,
257 pub monitor: Monitor,
258 pub monitor_host: IpAddr,
259 pub unix: Option<UnixPaths>,
261 pub poll: Duration,
262 pub first_poll: Duration,
263 pub net_timeout: Duration,
264 pub gate_time: Duration,
265 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 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
287pub fn remote_sock_example(unix: &UnixPaths) -> PathBuf {
290 unix.remote_dir.join("rash-<nonce>.sock")
291}
292
293const SUN_PATH_MAX: usize = 96;
296
297pub 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
319fn 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 let uid = unsafe { libc::getuid() };
330 PathBuf::from(format!("/tmp/rash-{uid}"))
331}
332
333pub 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 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 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#[derive(Debug, PartialEq, Eq)]
398pub struct Resolved {
399 pub config: Config,
400 pub warnings: Vec<String>,
401}
402
403pub trait EnvSource {
406 fn var(&self, key: &str) -> Option<OsString>;
407}
408
409pub 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
426fn 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
432fn 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
439fn 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
449fn 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
465pub fn resolve<E: EnvSource + ?Sized>(inv: Invocation, env: &E) -> Result<Resolved, ConfigError> {
468 resolve_with(inv, env, &settings::File::default())
469}
470
471pub 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 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 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 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 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 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 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 §ion.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 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 if inv.ssh_args.is_empty()
688 && let Some(args) = §ion.ssh_args
689 {
690 inv.ssh_args = args.iter().map(OsString::from).collect();
691 }
692
693 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 if inv.background {
712 gate_secs = 0;
713 }
714
715 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
757fn parse_monitor(spec: &str, warnings: &mut Vec<String>) -> Result<Monitor, ConfigError> {
759 if spec.eq_ignore_ascii_case("unix") {
761 return Ok(Monitor::Unix);
762 }
763
764 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 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 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}