use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HostEntry {
pub org: String,
pub instance: String,
pub user: String,
pub host_keys: Vec<String>,
}
impl HostEntry {
pub fn alias(&self) -> String {
alias(&self.org, &self.instance)
}
}
pub fn alias(org: &str, instance: &str) -> String {
format!("{instance}.{org}.isb")
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ProxyOptions {
pub isb: String,
pub url: Option<String>,
pub token_file: Option<String>,
pub keys_of: Option<String>,
pub identity: Option<String>,
pub known_hosts: String,
}
fn word(s: &str) -> String {
if !s.is_empty()
&& !s
.chars()
.any(|c| c.is_whitespace() || matches!(c, '"' | '\'' | '\\' | '#'))
{
s.to_string()
} else {
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
}
}
pub fn render(e: &HostEntry, o: &ProxyOptions) -> String {
let a = e.alias();
let mut proxy = format!("{} ssh-proxy {}/{}", word(&o.isb), e.org, e.instance);
if let Some(u) = &o.url {
proxy.push_str(&format!(" --url {}", word(u)));
}
if let Some(t) = &o.token_file {
proxy.push_str(&format!(" --token-file {}", word(t)));
}
if let Some(k) = &o.keys_of {
proxy.push_str(&format!(" --as {}", word(k)));
}
let mut s = format!(
"# {org}/{inst}: isb ssh-proxy over isb serve's websocket (isb ssh-config)\n\
Host {a}\n\
\x20 HostName {a}\n\
\x20 User {user}\n\
\x20 ProxyCommand {proxy}\n\
\x20 HostKeyAlias {a}\n\
\x20 UserKnownHostsFile {kh}\n\
\x20 StrictHostKeyChecking {strict}\n\
\x20 ServerAliveInterval 30\n\
\x20 ServerAliveCountMax 4\n",
org = e.org,
inst = e.instance,
user = word(&e.user),
kh = word(&o.known_hosts),
strict = if e.host_keys.is_empty() {
"accept-new"
} else {
"yes"
},
);
if let Some(i) = &o.identity {
s.push_str(&format!(
" IdentityFile {}\n IdentitiesOnly yes\n",
word(i)
));
}
s
}
pub fn herdr_line(e: &HostEntry) -> String {
format!(
"herdr machine add {} --label {}/{}",
e.alias(),
e.org,
e.instance
)
}
pub fn update_known_hosts(existing: &str, entries: &[HostEntry]) -> String {
let aliases: Vec<String> = entries.iter().map(HostEntry::alias).collect();
let mut out: Vec<String> = existing
.lines()
.filter(|l| {
let host = l.split_whitespace().next().unwrap_or("");
!host.split(',').any(|h| aliases.iter().any(|a| a == h))
})
.map(String::from)
.collect();
for e in entries {
for k in &e.host_keys {
out.push(format!("{} {k}", e.alias()));
}
}
let mut s = out.join("\n");
if !s.is_empty() {
s.push('\n');
}
s
}
pub fn write_known_hosts(path: &Path, entries: &[HostEntry]) -> std::io::Result<()> {
let existing = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e),
};
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let tmp = path.with_extension("isb-tmp");
std::fs::write(&tmp, update_known_hosts(&existing, entries))?;
std::fs::rename(&tmp, path)
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(keys: &[&str]) -> HostEntry {
HostEntry {
org: "acme".into(),
instance: "box".into(),
user: "dev".into(),
host_keys: keys.iter().map(|s| s.to_string()).collect(),
}
}
#[test]
fn renders_a_pinned_host_block() {
let o = ProxyOptions {
isb: "/usr/local/bin/isb".into(),
url: Some("https://isb.example.com".into()),
token_file: Some("/home/me/.config/isb/my token".into()),
keys_of: None,
identity: None,
known_hosts: "/home/me/.config/isb/known_hosts".into(),
};
let s = render(&entry(&["ssh-ed25519 AAAA"]), &o);
assert_eq!(
s,
"# acme/box: isb ssh-proxy over isb serve's websocket (isb ssh-config)\n\
Host box.acme.isb\n\
\x20 HostName box.acme.isb\n\
\x20 User dev\n\
\x20 ProxyCommand /usr/local/bin/isb ssh-proxy acme/box --url https://isb.example.com --token-file \"/home/me/.config/isb/my token\"\n\
\x20 HostKeyAlias box.acme.isb\n\
\x20 UserKnownHostsFile /home/me/.config/isb/known_hosts\n\
\x20 StrictHostKeyChecking yes\n\
\x20 ServerAliveInterval 30\n\
\x20 ServerAliveCountMax 4\n"
);
assert_eq!(
herdr_line(&entry(&[])),
"herdr machine add box.acme.isb --label acme/box"
);
}
#[test]
fn no_host_key_yet_accepts_the_first_and_identity_is_pinned() {
let o = ProxyOptions {
isb: "isb".into(),
keys_of: Some("me@example.com".into()),
identity: Some("/tmp/k".into()),
known_hosts: "/kh".into(),
..Default::default()
};
let s = render(&entry(&[]), &o);
assert!(s.contains("StrictHostKeyChecking accept-new\n"), "{s}");
assert!(s.contains("ProxyCommand isb ssh-proxy acme/box --as me@example.com\n"));
assert!(s.contains(" IdentityFile /tmp/k\n IdentitiesOnly yes\n"));
}
#[test]
fn known_hosts_replaces_only_its_own_lines() {
let old = "github.com ssh-ed25519 GH\nbox.acme.isb ssh-ed25519 OLD\nother.acme.isb ssh-rsa KEEP\n";
let new = update_known_hosts(old, &[entry(&["ssh-ed25519 NEW", "ssh-rsa NEW2"])]);
assert_eq!(
new,
"github.com ssh-ed25519 GH\nother.acme.isb ssh-rsa KEEP\nbox.acme.isb ssh-ed25519 NEW\nbox.acme.isb ssh-rsa NEW2\n"
);
assert_eq!(
update_known_hosts(&new, &[entry(&["ssh-ed25519 NEW", "ssh-rsa NEW2"])]),
new
);
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("isb/known_hosts");
write_known_hosts(&p, &[entry(&["ssh-ed25519 K"])]).unwrap();
assert_eq!(
std::fs::read_to_string(&p).unwrap(),
"box.acme.isb ssh-ed25519 K\n"
);
}
}