Implement the headless SSH server

This commit is contained in:
2026-07-19 20:35:02 +02:00
parent 92a942d52f
commit 45cb0cd502
26 changed files with 4226 additions and 55 deletions

View File

@@ -0,0 +1,317 @@
use std::fs::{self, OpenOptions};
use std::io::Write as _;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use russh::keys::key::safe_rng;
use russh::keys::{Algorithm, PrivateKey, PublicKey, load_secret_key, ssh_key};
pub const HOST_KEY_FILE: &str = "ssh_host_rsa_key";
pub const AUTHORIZED_KEYS_FILE: &str = "authorized_keys";
pub const CLIENT_KEY_FILE: &str = "id_ed25519";
pub const KNOWN_HOSTS_FILE: &str = "known_hosts";
#[derive(Debug, Clone)]
pub struct KeyMaterial {
pub directory: PathBuf,
pub host_key_path: PathBuf,
pub authorized_keys_path: PathBuf,
}
impl KeyMaterial {
pub fn ensure(data_dir: &Path) -> Result<Self> {
let directory = data_dir.join("ssh");
ensure_private_directory(&directory)?;
let host_key_path = directory.join(HOST_KEY_FILE);
let authorized_keys_path = directory.join(AUTHORIZED_KEYS_FILE);
ensure_private_file(&authorized_keys_path, b"")?;
if !host_key_path.exists() {
let key = PrivateKey::random(&mut safe_rng(), Algorithm::Rsa { hash: None })
.context("could not generate the SSH host key")?;
let encoded = key
.to_openssh(ssh_key::LineEnding::LF)
.context("could not encode the SSH host key")?;
ensure_private_file(&host_key_path, encoded.as_bytes())?;
}
validate_private_file(&host_key_path)?;
validate_private_file(&authorized_keys_path)?;
load_secret_key(&host_key_path, None)
.with_context(|| format!("could not read SSH host key {}", host_key_path.display()))?;
Ok(Self {
directory,
host_key_path,
authorized_keys_path,
})
}
pub fn host_key(&self) -> Result<PrivateKey> {
validate_private_file(&self.host_key_path)?;
load_secret_key(&self.host_key_path, None).with_context(|| {
format!(
"could not read SSH host key {}",
self.host_key_path.display()
)
})
}
/// Re-reads the file for every authentication attempt, so removing a key
/// revokes it without restarting the server.
pub fn authorizes(&self, candidate: &PublicKey) -> Result<bool> {
validate_private_file(&self.authorized_keys_path)?;
let contents = fs::read_to_string(&self.authorized_keys_path).with_context(|| {
format!(
"could not read authorized keys {}",
self.authorized_keys_path.display()
)
})?;
for (index, line) in contents.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let fields = line.split_whitespace().collect::<Vec<_>>();
let key_start = fields
.iter()
.position(|field| {
field.starts_with("ssh-")
|| field.starts_with("ecdsa-")
|| field.starts_with("sk-")
})
.filter(|index| fields.get(index + 1).is_some());
let encoded = key_start
.map(|index| format!("{} {}", fields[index], fields[index + 1]))
.unwrap_or_else(|| line.to_owned());
let key = PublicKey::from_openssh(&encoded).with_context(|| {
format!(
"invalid authorized key at {}:{}",
self.authorized_keys_path.display(),
index + 1
)
})?;
if key.key_data() == candidate.key_data() {
return Ok(true);
}
}
Ok(false)
}
}
#[derive(Debug, Clone)]
pub struct ClientKeyMaterial {
pub directory: PathBuf,
pub private_key_path: PathBuf,
pub public_key_path: PathBuf,
pub known_hosts_path: PathBuf,
}
impl ClientKeyMaterial {
pub fn ensure(data_dir: &Path) -> Result<Self> {
let directory = data_dir.join("ssh");
ensure_private_directory(&directory)?;
let private_key_path = directory.join(CLIENT_KEY_FILE);
let public_key_path = directory.join(format!("{CLIENT_KEY_FILE}.pub"));
let known_hosts_path = directory.join(KNOWN_HOSTS_FILE);
ensure_private_file(&known_hosts_path, b"")?;
if !private_key_path.exists() {
let mut key = PrivateKey::random(&mut safe_rng(), Algorithm::Ed25519)
.context("could not generate the SSH client identity")?;
key.set_comment("ruds-desktop");
let private = key
.to_openssh(ssh_key::LineEnding::LF)
.context("could not encode the SSH client identity")?;
ensure_private_file(&private_key_path, private.as_bytes())?;
let public = format!("{}\n", key.public_key().to_openssh()?);
ensure_public_file(&public_key_path, public.as_bytes())?;
}
validate_private_file(&private_key_path)?;
validate_private_file(&known_hosts_path)?;
let key = load_secret_key(&private_key_path, None).with_context(|| {
format!(
"could not read SSH client identity {}",
private_key_path.display()
)
})?;
let public = format!("{}\n", key.public_key().to_openssh()?);
ensure_public_file(&public_key_path, public.as_bytes())?;
Ok(Self {
directory,
private_key_path,
public_key_path,
known_hosts_path,
})
}
}
fn ensure_private_directory(path: &Path) -> Result<()> {
if !path.exists() {
fs::create_dir_all(path).with_context(|| {
format!("could not create private SSH directory {}", path.display())
})?;
set_mode(path, 0o700)?;
}
let metadata = fs::symlink_metadata(path)?;
if metadata.file_type().is_symlink() || !metadata.is_dir() {
bail!("SSH key directory is not a directory: {}", path.display());
}
validate_mode(path, 0o700, "SSH key directory")
}
fn ensure_private_file(path: &Path, contents: &[u8]) -> Result<()> {
if path.exists() {
return validate_private_file(path);
}
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(0o600);
}
let mut file = options
.open(path)
.with_context(|| format!("could not create private SSH file {}", path.display()))?;
file.write_all(contents)?;
file.sync_all()?;
set_mode(path, 0o600)?;
Ok(())
}
fn ensure_public_file(path: &Path, contents: &[u8]) -> Result<()> {
if path.exists() {
return Ok(());
}
let mut file = OpenOptions::new().write(true).create_new(true).open(path)?;
file.write_all(contents)?;
file.sync_all()?;
set_mode(path, 0o644)
}
fn validate_private_file(path: &Path) -> Result<()> {
let metadata = fs::symlink_metadata(path)?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
bail!(
"SSH key file is missing or not a regular file: {}",
path.display()
);
}
validate_mode(path, 0o600, "SSH key file")
}
#[cfg(unix)]
fn validate_mode(path: &Path, maximum: u32, label: &str) -> Result<()> {
use std::os::unix::fs::PermissionsExt as _;
let mode = fs::metadata(path)?.permissions().mode() & 0o777;
if mode & !maximum != 0 {
bail!(
"unsafe permissions {mode:o} on {label} {}; expected {maximum:o}",
path.display()
);
}
Ok(())
}
#[cfg(not(unix))]
fn validate_mode(_path: &Path, _maximum: u32, _label: &str) -> Result<()> {
Ok(())
}
#[cfg(unix)]
fn set_mode(path: &Path, mode: u32) -> Result<()> {
use std::os::unix::fs::PermissionsExt as _;
fs::set_permissions(path, fs::Permissions::from_mode(mode))?;
Ok(())
}
#[cfg(not(unix))]
fn set_mode(_path: &Path, _mode: u32) -> Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn creates_and_reuses_restrictive_server_key_material() {
let root = tempfile::tempdir().unwrap();
let first = KeyMaterial::ensure(root.path()).unwrap();
let bytes = fs::read(&first.host_key_path).unwrap();
assert_eq!(
first.host_key().unwrap().algorithm(),
Algorithm::Rsa { hash: None }
);
assert_eq!(fs::read_to_string(&first.authorized_keys_path).unwrap(), "");
let second = KeyMaterial::ensure(root.path()).unwrap();
assert_eq!(fs::read(second.host_key_path).unwrap(), bytes);
assert_private(&first.directory, 0o700);
assert_private(&first.host_key_path, 0o600);
assert_private(&first.authorized_keys_path, 0o600);
}
#[test]
fn authorized_keys_accept_reject_and_revoke_immediately() {
let root = tempfile::tempdir().unwrap();
let material = KeyMaterial::ensure(root.path()).unwrap();
let allowed = PrivateKey::random(&mut safe_rng(), Algorithm::Ed25519).unwrap();
let unknown = PrivateKey::random(&mut safe_rng(), Algorithm::Ed25519).unwrap();
fs::write(
&material.authorized_keys_path,
format!(
"# desktop\nrestrict {} user@desktop\n",
allowed.public_key().to_openssh().unwrap()
),
)
.unwrap();
assert!(material.authorizes(allowed.public_key()).unwrap());
assert!(!material.authorizes(unknown.public_key()).unwrap());
fs::write(&material.authorized_keys_path, "").unwrap();
assert!(!material.authorizes(allowed.public_key()).unwrap());
}
#[cfg(unix)]
#[test]
fn unsafe_key_files_are_rejected_with_the_path_and_mode() {
use std::os::unix::fs::PermissionsExt as _;
let root = tempfile::tempdir().unwrap();
let material = KeyMaterial::ensure(root.path()).unwrap();
fs::set_permissions(
&material.authorized_keys_path,
fs::Permissions::from_mode(0o644),
)
.unwrap();
let error = material
.authorizes(
PrivateKey::random(&mut safe_rng(), Algorithm::Ed25519)
.unwrap()
.public_key(),
)
.unwrap_err()
.to_string();
assert!(error.contains("unsafe permissions 644"));
assert!(error.contains(AUTHORIZED_KEYS_FILE));
}
#[test]
fn creates_a_reusable_desktop_identity_and_known_hosts() {
let root = tempfile::tempdir().unwrap();
let first = ClientKeyMaterial::ensure(root.path()).unwrap();
let bytes = fs::read(&first.private_key_path).unwrap();
fs::remove_file(&first.public_key_path).unwrap();
let second = ClientKeyMaterial::ensure(root.path()).unwrap();
assert_eq!(fs::read(second.private_key_path).unwrap(), bytes);
assert!(first.public_key_path.is_file());
assert_private(&first.private_key_path, 0o600);
assert_private(&first.known_hosts_path, 0o600);
}
fn assert_private(path: &Path, expected: u32) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
assert_eq!(
fs::metadata(path).unwrap().permissions().mode() & 0o777,
expected
);
}
}
}