use std::path::PathBuf;
use anyhow::{Context, Result, bail};
use ssh_browser::config;
use ssh_browser::control::{self, Token};
use ssh_browser::origin::{Alias, Origin, pac};
use ssh_browser::reachable;
use ssh_browser::ssh_config;
use ssh_browser::theme;
const USAGE: &str = "usage:\n ssh-browser serve [--config FILE] [--port N] [--suffix S] [--new-token] [<alias>=<ssh-host>[:<base>] ...]\n ssh-browser pac [--config FILE] [--port N] [--suffix S]
ssh-browser hosts\n\nWith no --config, a file at <config dir>/ssh-browser/config.toml is used if it exists:\n\n [server]\n port = 7391\n suffix = \"ssh-browser\"\n\n [[alias]]\n name = \"docs\"\n host = \"myhost\"\n base = \"~/docs\" # or an absolute path; omit for the home directory itself";
#[tokio::main]
async fn main() -> Result<()> {
let argv: Vec<String> = std::env::args().skip(1).collect();
let Some((command, rest)) = argv.split_first() else {
bail!("{USAGE}");
};
let mut named_config: Option<PathBuf> = None;
let mut cli = config::Overrides::default();
let mut new_token = false;
let mut args = rest.iter();
while let Some(arg) = args.next() {
match arg.as_str() {
"--config" => {
named_config = Some(PathBuf::from(args.next().context("--config needs a path")?));
}
"--port" => {
cli.port = Some(
args.next()
.context("--port needs a value")?
.parse()
.context("--port must be a number")?,
);
}
"--suffix" => {
cli.suffix = Some(args.next().context("--suffix needs a value")?.clone());
}
"--new-token" => new_token = true,
spec => cli.aliases.push(parse_alias(spec)?),
}
}
let file = match named_config {
Some(path) => Some(config::load(&path)?),
None => match config::default_path().filter(|p| p.exists()) {
Some(path) => Some(config::load(&path)?),
None => None,
},
};
let from_file = file.unwrap_or(config::Config {
server: config::Server::default(),
aliases: Vec::new(),
hosts: Vec::new(),
});
let from_file_theme = from_file.server.theme.clone();
let config::Resolved {
port,
suffix,
aliases,
hosts,
} = config::merge(cli, from_file)?;
match command.as_str() {
"hosts" => {
let found = ssh_config::read()?;
if found.hosts.is_empty() && found.unusable.is_empty() {
match ssh_config::default_path() {
Some(path) => eprintln!("no hosts in {}", path.display()),
None => eprintln!("no home directory, so no ssh_config to read"),
}
}
for host in &found.hosts {
let s = ssh_config::describe(&host.host).await?;
let mut parts = Vec::new();
if let Some(user) = &s.user {
parts.push(format!("user {user}"));
}
if let Some(hostname) = &s.hostname {
parts.push(format!("hostname {hostname}"));
}
if let Some(port) = s.port {
parts.push(format!("port {port}"));
}
if let Some(jump) = &s.proxy_jump {
parts.push(format!("via {jump}"));
}
println!("{:<16} {}", host.alias, parts.join(" "));
}
for skipped in &found.unusable {
eprintln!("skipped {}: {}", skipped.host, skipped.why);
}
Ok(())
}
"pac" => {
print!("{}", pac::script(&suffix, port)?);
Ok(())
}
"serve" => {
let (token, source) = Token::load_or_generate(new_token)?;
eprintln!("control token: {}", token.as_str());
match source {
control::Source::Reused(path) => eprintln!(
" unchanged since last time, from {} — a browser holding it is still connected",
path.display()
),
control::Source::Fresh(Some(path)) => eprintln!(
" new, and written to {} — paste it into the extension once",
path.display()
),
control::Source::Fresh(None) => {
eprintln!(" new, and could not be written to disk; copy it from above");
}
}
eprintln!(
" the extension sends it as {}",
ssh_browser::control::TOKEN_HEADER
);
eprintln!();
match aliases.len() {
0 => eprintln!("taking 127.0.0.1:{port}..."),
1 => eprintln!("taking 127.0.0.1:{port} and connecting over ssh..."),
n => eprintln!("taking 127.0.0.1:{port} and connecting {n} hosts over ssh..."),
}
let theme = theme::remembered()
.or_else(|| from_file_theme.clone())
.unwrap_or_else(|| theme::DEFAULT.to_string());
let reachable = reachable::Set::new(
hosts
.into_iter()
.map(|h| reachable::Host {
name: h.name,
base: h.base,
enabled: h.enabled,
})
.collect(),
);
let bound =
Origin::bind(aliases, reachable, suffix.clone(), port, token, theme).await?;
eprintln!("listening on 127.0.0.1:{port}");
for route in bound.routes() {
eprintln!("{route}");
}
if bound.routes().is_empty() {
eprintln!(" no aliases open yet — pick a host in the extension, or see");
eprintln!(" `ssh-browser hosts` for what your ssh_config can reach");
}
if !bound.refused().is_empty() {
eprintln!();
for line in bound.refused() {
eprintln!("{line}");
}
}
eprintln!();
eprintln!("point the browser at the generated PAC, for example:");
eprintln!(" chrome --proxy-pac-url=http://127.0.0.1:{port}/proxy.pac");
eprintln!();
eprintln!("or, without touching proxy settings: http://127.0.0.1:{port}/");
bound.serve().await
}
other => bail!("unknown command {other:?}\n\n{USAGE}"),
}
}
fn parse_alias(spec: &str) -> Result<Alias> {
let (name, rest) = spec
.split_once('=')
.with_context(|| format!("expected <alias>=<ssh-host>[:<base>], got {spec:?}"))?;
let (host, base) = match rest.split_once(':') {
Some((host, base)) => (host, Some(base)),
None => (rest, None),
};
Alias::new(name, host, base).with_context(|| format!("in {spec:?}"))
}