//! Evaluates [ssh_config(5)] files to find all **Host** declarations. //! //! **Include** directives will be resolved and evaluated. Pathnames will be expanded in the same //! ways as in [ssh(1)], with the notable exception of runtime tokens (see "TOKENS" section in //! [ssh_config(5)]. //! //! Patterns given to any **Host** directive will be ignored (see "PATTERNS" section in //! [ssh_config(5)]). //! //! [glob(7)]: //! [ssh(1)]: //! [ssh_config(5)]: mod logger; mod ssh; use std::{ collections::HashSet, env::{self}, fs::File, io::{self, BufRead, BufReader, Read}, path::{Path, PathBuf}, process, }; use clap::Parser; use glob::glob; use log::LevelFilter; use crate::ssh::{SSH_SYSTEM_CONFIG, SSH_USER_CONFIG, Words, expand_inlude_args, is_host_pattern}; /// List host names in ssh_config(5) files #[derive(Debug, Parser)] struct Cli { /// This emulates the behaviour of the ssh(1) -F option #[arg(short = 'F')] file: Option, /// Increase logging verbosity #[arg(short = 'v', action = clap::ArgAction::Count)] verbose: u8, } macro_rules! continue_if_error { ($l:tt: $e:expr) => { match $e { Ok(v) => v, Err(e) => { log::$l!("{e}"); continue; } } }; } macro_rules! io_error { ($kind:ident) => { Err(std::io::Error::new( std::io::ErrorKind::$kind, format!("{}", std::io::ErrorKind::$kind), )) }; ($kind:ident, $prefix:expr) => { Err(io::Error::new( std::io::ErrorKind::$kind, format!("{}: {}", $prefix, std::io::ErrorKind::$kind), )) }; } fn main() { let cli = Cli::parse(); logger::init(match cli.verbose { 0 => LevelFilter::Error, 1 => LevelFilter::Info, 2 => LevelFilter::Debug, 3.. => LevelFilter::Trace, }); let filenames = match &cli.file { Some(path) if path == "none" => vec![], Some(path) => vec![path.clone()], None => match env::home_dir() { Some(home) => vec![SSH_SYSTEM_CONFIG.into(), home.join(SSH_USER_CONFIG)], None => vec![SSH_SYSTEM_CONFIG.into()], }, }; let hosts = { let mut hosts = HashSet::new(); for filename in filenames { match find_hosts(&filename) { Ok(h) => hosts.extend(h), Err(err) => { eprintln!("{err}"); process::exit(1); } }; } let mut hosts = Vec::from_iter(hosts); hosts.sort(); hosts }; for host in hosts { println!("{host}"); } } fn find_hosts(filename: &Path) -> io::Result> { log::info!("reading config file: {}", filename.display()); if !filename.exists() { return io_error!(NotFound, filename.display()); } if filename.is_dir() { return io_error!(IsADirectory, filename.display()); } let mut reader = { let file = File::open(filename)?; BufReader::new(file) }; let mut hosts = HashSet::new(); let mut buf = String::new(); loop { buf.clear(); let n = (&mut reader).take(1024 * 16).read_line(&mut buf)?; if n == 0 { return Ok(hosts); } let line = buf[..n].trim(); let mut words: Words<'_> = line.into(); let Some(Ok(keyword)) = words.next() else { continue; }; match keyword { k if k.starts_with('#') => {} k if k.eq_ignore_ascii_case("include") => { find_hosts_in_include_directive(&mut hosts, words); } k if k.eq_ignore_ascii_case("host") => { find_hosts_in_host_directive(&mut hosts, words); } k => { log::trace!("skip unrelated keyword: '{k}'"); } } } } fn find_hosts_in_include_directive(hosts: &mut HashSet, words: Words<'_>) { for arg in words { let arg = continue_if_error!(warn: arg); // Expanding env vars before globbing is what ssh does as well. let pattern = continue_if_error!(warn: expand_inlude_args(arg)); if arg != pattern { log::debug!("expanded include '{arg}' -> '{pattern}'"); } let paths = continue_if_error!(warn: glob(&pattern)); let mut matches = 0; for path in paths { matches += 1; let path = continue_if_error!(error: path); let ihosts = continue_if_error!(error: find_hosts(&path)); hosts.extend(ihosts); } if matches < 1 { log::warn!("include {pattern} matched no files"); } } } fn find_hosts_in_host_directive(hosts: &mut HashSet, words: Words<'_>) { for word in words { let word = continue_if_error!(warn: word); if is_host_pattern(word) { log::debug!("skip host pattern: '{word}'"); continue; } hosts.insert(word.to_string()); } } #[cfg(test)] mod tests { use clap::CommandFactory; use super::*; #[test] fn cli() { Cli::command().debug_assert(); } #[test] fn find_hosts() { let mut hosts = HashSet::new(); find_hosts_in_host_directive(&mut hosts, "foo.test".into()); assert!(hosts.contains("foo.test")); find_hosts_in_host_directive(&mut hosts, "*".into()); assert!(!hosts.contains("*")); find_hosts_in_host_directive(&mut hosts, "bar.test *.test".into()); assert!(hosts.contains("bar.test")); assert!(!hosts.contains("*.test")); } }