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;
use ssh_browser::tls;
const USAGE: &str = "usage:\n ssh-browser serve [--config FILE] [--port N] [--suffix S] [--scheme http|https] [--new-token] [<alias>=<ssh-host>[:<base>] ...]\n ssh-browser pac [--config FILE] [--port N] [--suffix S]
ssh-browser trust [--config FILE] [--suffix S]\n 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 scheme = \"http\" # https terminates TLS behind CONNECT; see `ssh-browser trust`\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());
}
"--scheme" => {
cli.scheme = Some(args.next().context("--scheme 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,
scheme,
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(())
}
"trust" => {
let authority = tls::load_or_create(&suffix)?;
let Some(path) = tls::certificate_path() else {
bail!("no state directory to keep a local certificate authority in");
};
println!("{}", tls::trust_instructions(&suffix, &path));
match tls::limits_of(authority.certificate_pem()) {
Ok(limits) => {
println!("What it is allowed to vouch for, read out of that file:");
println!(" names under: {}", limits.permitted.join(", "));
println!(
" marked critical: {} (so a browser cannot skip the limit)",
limits.constraints_critical
);
println!(
" can sign a sub-CA: {}",
match limits.path_len {
Some(0) => "no".to_string(),
other => format!("{other:?}"),
}
);
}
Err(e) => println!(" (could not read the certificate back: {e:#})"),
}
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, startup) = Origin::bind(
aliases,
reachable,
suffix.clone(),
scheme.clone(),
port,
token,
theme,
)
.await?;
eprintln!("listening on 127.0.0.1:{port}");
for route in startup.routes() {
eprintln!("{route}");
}
if startup.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 !startup.refused().is_empty() {
eprintln!();
for line in startup.refused() {
eprintln!("{line}");
}
}
if let Some(advice) = startup.trust() {
eprintln!();
eprintln!("{advice}");
}
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)
.map(Alias::for_this_run)
.with_context(|| format!("in {spec:?}"))
}