use std::path::PathBuf;
use anyhow::{Context, Result, bail, ensure};
use ssh_browser::config;
use ssh_browser::control::Token;
use ssh_browser::origin::{Alias, Origin, pac};
const USAGE: &str = "usage:\n ssh-browser serve [--config FILE] [--port N] [--suffix S] [--author NAME] [<alias>=<ssh-host>:<base> ...]\n ssh-browser pac [--config FILE] [--port N] [--suffix S]\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 = \"/srv/docs\"";
#[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 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());
}
"--author" => {
cli.author = Some(args.next().context("--author needs a value")?.clone());
}
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(),
});
let config::Resolved {
port,
suffix,
author,
aliases,
} = config::merge(cli, from_file, default_author())?;
match command.as_str() {
"pac" => {
print!("{}", pac::script(&suffix, port)?);
Ok(())
}
"serve" => {
ensure!(
!aliases.is_empty(),
"no aliases: give one as <alias>=<ssh-host>:<base>, or put them in a config file\n\n{USAGE}"
);
let routes: Vec<String> = aliases
.iter()
.map(|a| {
format!(
" http://{}.{suffix}/ -> {}:{}",
a.name(),
a.host(),
a.base()
)
})
.collect();
let token = Token::generate()?;
eprintln!("control token: {}", token.as_str());
match token.write_to_disk() {
Some(path) => eprintln!(" also written to {}", path.display()),
None => eprintln!(" (could not be written to disk; copy it from above)"),
}
eprintln!(
" the extension sends it as {}",
ssh_browser::control::TOKEN_HEADER
);
eprintln!(" annotations are written as {author}");
eprintln!();
match routes.len() {
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 bound = Origin::bind(aliases, suffix.clone(), port, token, author).await?;
eprintln!("listening on 127.0.0.1:{port}");
for route in &routes {
eprintln!("{route}");
}
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 default_author() -> String {
std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| "unknown".to_string())
}
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) = rest
.split_once(':')
.with_context(|| format!("expected <ssh-host>:<base> after the =, got {rest:?}"))?;
Alias::new(name, host, base).with_context(|| format!("in {spec:?}"))
}