227 lines
5.8 KiB
Rust
227 lines
5.8 KiB
Rust
//! 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)]: <https://manpages.debian.org/buster/manpages/glob.7.en.html>
|
|
//! [ssh(1)]: <https://manpages.debian.org/testing/manpages-de/ssh.1.de.html>
|
|
//! [ssh_config(5)]: <https://manpages.debian.org/bullseye/openssh-client/ssh_config.5.en.html>
|
|
|
|
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<PathBuf>,
|
|
/// 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<HashSet<String>> {
|
|
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<String>, 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<String>, 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"));
|
|
}
|
|
}
|