use crate::atomic_file;
use anyhow::{Context, Result, bail};
use microsandbox::sandbox::SandboxHandle;
use russh::keys::ssh_key::LineEnding;
use russh::keys::{Algorithm, PrivateKey};
use std::collections::HashSet;
use std::fs;
use std::io::{ErrorKind, Write};
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
pub const HOSTNAME_LABEL: &str = "firebrick.hostname";
const HOST_SUFFIX: &str = ".fbk";
const CLIENT_KEY_FILE: &str = "id_ed25519";
const CLIENT_PUBLIC_KEY_FILE: &str = "id_ed25519.pub";
const HOST_KEY_FILE: &str = "host_ed25519";
const KNOWN_HOSTS_FILE: &str = "known_hosts";
const CONFIG_FILE: &str = "config";
const CLI_BINARY: &str = "fbk";
pub fn host_key_path() -> Result<PathBuf> {
Ok(ssh_dir()?.join(HOST_KEY_FILE))
}
pub fn client_public_key_path() -> Result<PathBuf> {
Ok(ssh_dir()?.join(CLIENT_PUBLIC_KEY_FILE))
}
fn ssh_dir() -> Result<PathBuf> {
firebrick_utils::ssh_dir().context(firebrick_utils::NO_DATA_DIR)
}
pub fn ensure_keys() -> Result<()> {
let dir = ssh_dir()?;
fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
firebrick_utils::make_private_dir(&dir)?;
let client_key = load_or_create_key(&dir.join(CLIENT_KEY_FILE))?;
write_file(
&dir.join(CLIENT_PUBLIC_KEY_FILE),
&format!("{}\n", client_key.public_key().to_openssh()?),
)?;
let host_key = load_or_create_key(&dir.join(HOST_KEY_FILE))?;
write_file(
&dir.join(KNOWN_HOSTS_FILE),
&format!("*{HOST_SUFFIX} {}\n", host_key.public_key().to_openssh()?),
)?;
Ok(())
}
fn load_or_create_key(path: &Path) -> Result<PrivateKey> {
match fs::read_to_string(path) {
Ok(content) => PrivateKey::from_openssh(content)
.with_context(|| format!("failed to read SSH key {}", path.display())),
Err(err) if err.kind() == ErrorKind::NotFound => {
let key = PrivateKey::random(&mut russh::keys::key::safe_rng(), Algorithm::Ed25519)?;
let mut file = private_file_options()
.create_new(true)
.open(path)
.with_context(|| format!("failed to create SSH key {}", path.display()))?;
file.write_all(key.to_openssh(LineEnding::LF)?.as_bytes())?;
Ok(key)
}
Err(err) => Err(err).with_context(|| format!("failed to read SSH key {}", path.display())),
}
}
pub fn hostname_of(handle: &SandboxHandle) -> Option<String> {
handle
.config()
.ok()?
.spec
.labels
.get(HOSTNAME_LABEL)
.cloned()
}
pub fn pick_hostname(workspace: &str, taken: &HashSet<String>) -> String {
let base = firebrick_utils::sanitize_label(Path::new(workspace));
(1..)
.map(|n| match n {
1 => format!("{base}{HOST_SUFFIX}"),
n => format!("{base}-{n}{HOST_SUFFIX}"),
})
.find(|hostname| !taken.contains(hostname))
.expect("there's always a free host name")
}
pub fn sync_config(hostnames: &[String]) -> Result<()> {
let dir = ssh_dir()?;
let config_path = dir.join(CONFIG_FILE);
let config = render_config(hostnames, &dir, &cli_binary());
atomic_file::write(&config_path, &config, 0o600)
.with_context(|| format!("failed to write {}", config_path.display()))?;
let home_var = firebrick_utils::HOME_VAR;
let home = std::env::var_os(home_var).with_context(|| format!("{home_var} is not set"))?;
ensure_include(
&PathBuf::from(home).join(".ssh").join("config"),
&config_path,
)
}
fn config_path(path: &Path) -> String {
let path = path.display().to_string();
if cfg!(windows) {
path.replace('\\', "/")
} else {
path
}
}
fn render_config(hostnames: &[String], ssh_dir: &Path, cli: &Path) -> String {
let mut config =
String::from("# Generated by firebrick. Changes to this file are overwritten.\n");
let mut hostnames = hostnames.to_vec();
hostnames.sort();
for hostname in hostnames {
config.push_str(&format!(
"\nHost {hostname}\n \
User agent\n \
ProxyCommand \"{cli}\" ssh-proxy %n\n \
IdentityFile \"{identity}\"\n \
IdentitiesOnly yes\n \
UserKnownHostsFile \"{known_hosts}\"\n \
StrictHostKeyChecking yes\n",
cli = config_path(cli),
identity = config_path(&ssh_dir.join(CLIENT_KEY_FILE)),
known_hosts = config_path(&ssh_dir.join(KNOWN_HOSTS_FILE)),
));
}
config
}
fn ensure_include(user_config: &Path, include: &Path) -> Result<()> {
let include_line = format!("Include \"{}\"", config_path(include));
let existing = read_user_config(user_config)?;
if existing.lines().any(|line| line.trim() == include_line) {
return Ok(());
}
atomic_file::write(user_config, &format!("{include_line}\n\n{existing}"), 0o600)
.with_context(|| format!("failed to update {}", user_config.display()))
}
fn read_user_config(path: &Path) -> Result<String> {
match fs::read_to_string(path) {
Ok(content) => Ok(content),
Err(err) if err.kind() == ErrorKind::NotFound && path.is_symlink() => bail!(
"{} is a symlink to a missing file; create the target or remove the link",
path.display()
),
Err(err) if err.kind() == ErrorKind::NotFound => {
if let Some(dir) = path.parent() {
fs::create_dir_all(dir)?;
firebrick_utils::make_private_dir(dir)?;
}
Ok(String::new())
}
Err(err) => Err(err).with_context(|| format!("failed to read {}", path.display())),
}
}
fn write_file(path: &Path, content: &str) -> Result<()> {
let mut file = private_file_options()
.create(true)
.truncate(true)
.open(path)
.with_context(|| format!("failed to write {}", path.display()))?;
file.write_all(content.as_bytes())?;
Ok(())
}
fn private_file_options() -> fs::OpenOptions {
let mut options = fs::OpenOptions::new();
options.write(true);
#[cfg(unix)]
options.mode(0o600);
options
}
fn cli_binary() -> PathBuf {
let file_name = format!("{CLI_BINARY}{}", std::env::consts::EXE_SUFFIX);
std::env::current_exe()
.ok()
.and_then(|exe| exe.parent().map(|dir| dir.join(&file_name)))
.filter(|path| path.is_file())
.unwrap_or_else(|| PathBuf::from(CLI_BINARY))
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use tempfile::TempDir;
fn taken(names: &[&str]) -> HashSet<String> {
names.iter().map(|name| name.to_string()).collect()
}
#[test]
fn hostname_uses_workspace_leaf() {
assert_eq!(
pick_hostname("/home/user/project", &taken(&[])),
"project.fbk"
);
}
#[test]
fn hostname_is_sanitized() {
assert_eq!(
pick_hostname("/home/user/My_Project.v2--", &taken(&[])),
"my-project-v2.fbk"
);
assert_eq!(pick_hostname("/home/user/__", &taken(&[])), "sandbox.fbk");
assert_eq!(pick_hostname("/", &taken(&[])), "sandbox.fbk");
}
#[test]
fn hostname_gets_numbered_when_taken() {
assert_eq!(
pick_hostname("/a/project", &taken(&["project.fbk"])),
"project-2.fbk"
);
assert_eq!(
pick_hostname(
"/a/project",
&taken(&["project.fbk", "project-2.fbk", "other.fbk"])
),
"project-3.fbk"
);
}
#[test]
fn config_has_block_per_host() {
let config = render_config(
&["web.fbk".to_string(), "api.fbk".to_string()],
Path::new("/home/user/.local/share/firebrick/ssh"),
Path::new("/usr/bin/fbk"),
);
assert!(config.find("Host api.fbk").unwrap() < config.find("Host web.fbk").unwrap());
assert!(config.contains("User agent\n"));
assert!(config.contains("ProxyCommand \"/usr/bin/fbk\" ssh-proxy %n\n"));
assert!(
config.contains("IdentityFile \"/home/user/.local/share/firebrick/ssh/id_ed25519\"\n")
);
assert!(config.contains(
"UserKnownHostsFile \"/home/user/.local/share/firebrick/ssh/known_hosts\"\n"
));
}
#[test]
fn include_is_prepended_once() {
let dir = TempDir::new().unwrap();
let user_config = dir.path().join(".ssh/config");
let include = Path::new("/data/firebrick/ssh/config");
fs::create_dir_all(user_config.parent().unwrap()).unwrap();
fs::write(&user_config, "Host github.com\n User git\n").unwrap();
ensure_include(&user_config, include).unwrap();
ensure_include(&user_config, include).unwrap();
assert_eq!(
fs::read_to_string(&user_config).unwrap(),
"Include \"/data/firebrick/ssh/config\"\n\nHost github.com\n User git\n"
);
}
#[test]
fn include_creates_missing_config() {
let dir = TempDir::new().unwrap();
let user_config = dir.path().join(".ssh/config");
ensure_include(&user_config, Path::new("/data/firebrick/ssh/config")).unwrap();
#[cfg(unix)]
let mode = fs::metadata(&user_config).unwrap().permissions().mode();
#[cfg(unix)]
assert_eq!(mode & 0o777, 0o600);
assert!(
fs::read_to_string(&user_config)
.unwrap()
.starts_with("Include \"/data/firebrick/ssh/config\"\n")
);
}
#[test]
fn failed_write_leaves_config_unchanged() {
let dir = TempDir::new().unwrap();
let ssh_dir = dir.path().join(".ssh");
let user_config = ssh_dir.join("config");
fs::create_dir_all(&ssh_dir).unwrap();
fs::write(&user_config, "Host github.com\n").unwrap();
fs::create_dir(ssh_dir.join(".config.firebrick-tmp")).unwrap();
let result = ensure_include(&user_config, Path::new("/data/firebrick/ssh/config"));
let err = result.unwrap_err().to_string();
assert!(err.starts_with("failed to update"), "{err}");
assert_eq!(
fs::read_to_string(&user_config).unwrap(),
"Host github.com\n"
);
}
#[test]
fn symlinked_config_stays_symlink() {
let dir = TempDir::new().unwrap();
let dotfiles = dir.path().join("dotfiles/ssh_config");
let user_config = dir.path().join(".ssh/config");
fs::create_dir_all(dotfiles.parent().unwrap()).unwrap();
fs::create_dir_all(user_config.parent().unwrap()).unwrap();
fs::write(&dotfiles, "Host github.com\n").unwrap();
if !crate::atomic_file::test_symlink(&dotfiles, &user_config) {
return;
}
ensure_include(&user_config, Path::new("/data/firebrick/ssh/config")).unwrap();
assert!(user_config.is_symlink());
assert_eq!(
fs::read_to_string(&dotfiles).unwrap(),
"Include \"/data/firebrick/ssh/config\"\n\nHost github.com\n"
);
}
#[test]
fn dangling_symlink_is_left_alone() {
let dir = TempDir::new().unwrap();
let user_config = dir.path().join(".ssh/config");
fs::create_dir_all(user_config.parent().unwrap()).unwrap();
let missing = dir.path().join("missing");
if !crate::atomic_file::test_symlink(&missing, &user_config) {
return;
}
let err = ensure_include(&user_config, Path::new("/data/firebrick/ssh/config"))
.unwrap_err()
.to_string();
assert!(err.contains("is a symlink to a missing file"), "{err}");
assert_eq!(fs::read_link(&user_config).unwrap(), missing);
assert!(!missing.exists());
}
#[test]
#[cfg(unix)]
fn existing_config_keeps_mode() {
let dir = TempDir::new().unwrap();
let user_config = dir.path().join(".ssh/config");
fs::create_dir_all(user_config.parent().unwrap()).unwrap();
fs::write(&user_config, "Host github.com\n").unwrap();
fs::set_permissions(&user_config, fs::Permissions::from_mode(0o640)).unwrap();
ensure_include(&user_config, Path::new("/data/firebrick/ssh/config")).unwrap();
#[cfg(unix)]
let mode = fs::metadata(&user_config).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o640);
}
#[test]
fn created_key_can_be_loaded_again() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("key");
let created = load_or_create_key(&path).unwrap();
let loaded = load_or_create_key(&path).unwrap();
assert_eq!(created.public_key(), loaded.public_key());
#[cfg(unix)]
let mode = fs::metadata(&path).unwrap().permissions().mode();
#[cfg(unix)]
assert_eq!(mode & 0o777, 0o600);
}
}