From 3e44d24f9b6069b635c465cc8406623e51344861 Mon Sep 17 00:00:00 2001 From: Erica Marigold Date: Wed, 27 Aug 2025 10:34:50 +0100 Subject: [PATCH] feat!: persist ssh keys across builds by writing them in data dir --- .config/config.json5 | 7 ++++ Cargo.lock | 46 ++++++++++++++++++++++-- Cargo.toml | 6 +++- build.rs | 25 ------------- src/app.rs | 4 +-- src/config.rs | 85 ++++++++++++++++++++++++++++++++++++++------ src/main.rs | 29 +++++++-------- 7 files changed, 145 insertions(+), 57 deletions(-) diff --git a/.config/config.json5 b/.config/config.json5 index 1d1ec7a..df3dddb 100644 --- a/.config/config.json5 +++ b/.config/config.json5 @@ -1,4 +1,11 @@ { + // Paths to SSH private keys to use for encryption + "private_keys": { + "ssh-rsa": "$DATA_DIR/ssh/id_rsa", + "ecdsa-sha2-nistp256": "$DATA_DIR/ssh/id_ecdsa", + "ssh-ed25519": "$DATA_DIR/ssh/id_ed25519" + }, + "keybindings": { "Home": { "": "Quit", // Quit the application diff --git a/Cargo.lock b/Cargo.lock index cfb2b8f..58526ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1807,7 +1807,16 @@ version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" dependencies = [ - "dirs-sys", + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", ] [[package]] @@ -1818,10 +1827,22 @@ checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.4.6", "windows-sys 0.48.0", ] +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.60.2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -6075,6 +6096,17 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 2.0.14", +] + [[package]] name = "regex" version = "1.11.1" @@ -6899,6 +6931,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" +[[package]] +name = "shellexpand" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" +dependencies = [ + "dirs", +] + [[package]] name = "shlex" version = "1.3.0" @@ -7117,6 +7158,7 @@ dependencies = [ "rust-embed", "serde", "serde_json", + "shellexpand", "signal-hook", "ssh-key", "strip-ansi-escapes", diff --git a/Cargo.toml b/Cargo.toml index 5d01835..6f36cfd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,9 @@ blog = [ "dep:patch-crate" ] +[profile.dev] +opt-level = 2 + [package.metadata.patch] crates = ["ratatui-image"] @@ -76,6 +79,7 @@ russh = "0.49.2" rust-embed = { version = "8.7.2", features = ["actix"] } serde = { version = "1.0.211", features = ["derive"] } serde_json = "1.0.132" +ssh-key = { version = "0.6.7", features = ["getrandom", "crypto"] } signal-hook = "0.3.17" strip-ansi-escapes = "0.2.0" strum = { version = "0.26.3", features = ["derive"] } @@ -85,10 +89,10 @@ tracing = "0.1.40" tracing-error = "0.2.0" tracing-subscriber = { version = "0.3.18", features = ["env-filter", "serde"] } tui-markdown = { version = "0.3.5", optional = true } +shellexpand = "3.1.1" [build-dependencies] anyhow = "1.0.90" atrium-codegen = { git = "https://github.com/atrium-rs/atrium.git", rev = "ccc0213", optional = true } patch-crate = { version = "0.1.13", optional = true } -ssh-key = { version = "0.6.7", features = ["getrandom", "crypto"] } vergen-gix = { version = "1.0.2", features = ["build", "cargo"] } diff --git a/build.rs b/build.rs index 4ac6a58..a47562d 100644 --- a/build.rs +++ b/build.rs @@ -1,19 +1,12 @@ use std::env; -use std::path::PathBuf; use anyhow::Result; -use ssh_key::{rand_core, Algorithm, EcdsaCurve, LineEnding, PrivateKey}; use vergen_gix::{BuildBuilder, CargoBuilder, Emitter, GixBuilder}; #[cfg(feature = "blog")] const ATPROTO_LEXICON_DIR: &str = "src/atproto/lexicons"; #[cfg(feature = "blog")] const ATPROTO_CLIENT_DIR: &str = "src/atproto"; -const SSH_KEY_ALGOS: &[(&str, Algorithm)] = &[ - ("rsa.pem", Algorithm::Rsa { hash: None }), - ("ed25519.pem", Algorithm::Ed25519), - ("ecdsa.pem", Algorithm::Ecdsa { curve: EcdsaCurve::NistP256 }), -]; fn main() -> Result<()> { println!("cargo:rerun-if-changed=build.rs"); @@ -29,24 +22,6 @@ fn main() -> Result<()> { } } - // Generate openSSH host keys - let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); - let mut rng = rand_core::OsRng; - for (file_name, algo) in SSH_KEY_ALGOS { - let path = out_dir.join(file_name); - if path.exists() { - println!( - "cargo:warning=Skipping existing host key: {:?}", - path.file_stem().unwrap() - ); - continue; - } - - let key = - PrivateKey::random(&mut rng, algo.to_owned()).map_err(anyhow::Error::from)?; - key.write_openssh_file(&path, LineEnding::default())?; - } - // Generate ATProto client with lexicon validation #[cfg(feature = "blog")] atrium_codegen::genapi( diff --git a/src/app.rs b/src/app.rs index cec5fcc..8ff2ec1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -15,7 +15,7 @@ use tokio_util::sync::CancellationToken; use tracing::debug; use crate::action::Action; -use crate::components::*; +use crate::{components::*, CONFIG}; use crate::config::Config; use crate::keycode::KeyCodeExt; use crate::tui::terminal::{TerminalInfo, TerminalKind, UnsupportedReason}; @@ -90,7 +90,7 @@ impl App { should_suspend: false, needs_resize: false, - config: Config::new()?, + config: CONFIG.clone(), mode: Mode::Home, last_tick_key_events: Vec::new(), action_tx, diff --git a/src/config.rs b/src/config.rs index ccdeb68..c9bb90d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,18 +1,19 @@ #![allow(dead_code)] // Remove this once you start using the code use std::collections::HashMap; -use std::env; use std::path::PathBuf; +use std::str::FromStr; +use std::{env, io}; use color_eyre::Result; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use derive_deref::{Deref, DerefMut}; -use directories::ProjectDirs; +use directories::{ProjectDirs, UserDirs}; use lazy_static::lazy_static; use ratatui::style::{Color, Modifier, Style}; -use serde::de::Deserializer; -use serde::Deserialize; -use tracing::error; +use serde::{de::Deserializer, Deserialize}; +use ssh_key::{rand_core, Algorithm, LineEnding, PrivateKey}; +use tracing::{debug, error, info, info_span}; use crate::action::Action; use crate::app::Mode; @@ -31,6 +32,8 @@ pub struct AppConfig { pub struct Config { #[serde(default, flatten)] pub config: AppConfig, + #[serde(default, deserialize_with = "private_key_deserialize")] + pub private_keys: Vec, #[serde(default)] pub keybindings: KeyBindings, #[serde(default)] @@ -39,9 +42,9 @@ pub struct Config { lazy_static! { pub static ref PROJECT_NAME: String = env!("CARGO_CRATE_NAME").to_uppercase().to_string(); - pub static ref DATA_FOLDER: Option = + pub static ref DATA_DIR: Option = env::var(format!("{}_DATA", PROJECT_NAME.clone())).ok().map(PathBuf::from); - pub static ref CONFIG_FOLDER: Option = + pub static ref CONFIG_DIR: Option = env::var(format!("{}_CONFIG", PROJECT_NAME.clone())).ok().map(PathBuf::from); } @@ -50,6 +53,10 @@ impl Config { let default_config: Config = json5::from_str(CONFIG).unwrap(); let data_dir = get_data_dir(); let config_dir = get_config_dir(); + + info!("Using data directory: {}", data_dir.display()); + info!("Using config directory: {}", config_dir.display()); + let mut builder = config::Config::builder() .set_default("data_dir", data_dir.to_str().unwrap())? .set_default("config_dir", config_dir.to_str().unwrap())?; @@ -88,13 +95,18 @@ impl Config { user_styles.entry(style_key.clone()).or_insert(*style); } } + if cfg.private_keys.is_empty() { + for key in default_config.private_keys { + cfg.private_keys.push(key); + } + } Ok(cfg) } } pub fn get_data_dir() -> PathBuf { - let directory = if let Some(s) = DATA_FOLDER.clone() { + let directory = if let Some(s) = DATA_DIR.clone() { s } else if let Some(proj_dirs) = project_directory() { proj_dirs.data_local_dir().to_path_buf() @@ -105,7 +117,7 @@ pub fn get_data_dir() -> PathBuf { } pub fn get_config_dir() -> PathBuf { - let directory = if let Some(s) = CONFIG_FOLDER.clone() { + let directory = if let Some(s) = CONFIG_DIR.clone() { s } else if let Some(proj_dirs) = project_directory() { proj_dirs.config_local_dir().to_path_buf() @@ -116,7 +128,60 @@ pub fn get_config_dir() -> PathBuf { } fn project_directory() -> Option { - ProjectDirs::from("com", "kdheepak", env!("CARGO_PKG_NAME")) + ProjectDirs::from("xyz", "devcomp", env!("CARGO_PKG_NAME")) +} + +fn private_key_deserialize<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, { + let keys = HashMap::::deserialize(deserializer)? + .into_iter() + .map(|(pem_type, pem_or_path)| { + debug!("Loading {} private key", pem_type); + PrivateKey::from_openssh(pem_or_path.as_bytes()).or_else(|_| { + debug!("Failed to parse {} PEM from string, trying as file path", pem_type); + + let ud = UserDirs::new().ok_or(ssh_key::Error::Io(io::ErrorKind::NotFound))?; + let expanded_path = PathBuf::from(&*shellexpand::full_with_context_no_errors( + &pem_or_path, + || ud.home_dir().to_str(), + |var| match var { + "DATA_DIR" => get_data_dir().to_str().map(|s| s.to_string()), + "CONFIG_DIR" => get_config_dir().to_str().map(|s| s.to_string()), + "HOME" => Some(String::from("~")), + _ => None, + }, + )); + + if !expanded_path.exists() { + let span = info_span!("host_keygen", algo = %pem_type, path = %expanded_path.display()); + let _lock = span.enter(); + + if let Some(parent) = expanded_path.parent() { + std::fs::create_dir_all(parent)?; + } + + info!("Generating key... (this may take a while)"); + let algo = Algorithm::from_str(&pem_type)?; + let key = PrivateKey::random(&mut rand_core::OsRng, algo.to_owned())?; + key.write_openssh_file(&expanded_path, LineEnding::default())?; + + return Ok(key); + } + + PrivateKey::read_openssh_file(&expanded_path) + }) + }) + .collect::, ssh_key::Error>>() + .map_err(serde::de::Error::custom) + .inspect_err(|err| error!("Loading private keys error: {}", err))?; + + if keys.is_empty() { + return Err(serde::de::Error::custom("No valid private keys found in configuration")) + .inspect_err(|err| error!("{}", err)); + } + + Ok(keys) } #[derive(Clone, Debug, Default, Deref, DerefMut)] diff --git a/src/main.rs b/src/main.rs index a3f9c7c..8412769 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,8 +5,7 @@ use cli::Cli; use color_eyre::eyre::eyre; use color_eyre::Result; use lazy_static::lazy_static; -use russh::keys::PrivateKey; -use russh::server::Config; +use russh::server::Config as SshConfig; use russh::MethodSet; use ssh::SshServer; @@ -14,7 +13,9 @@ use ssh::SshServer; pub(crate) use atproto::com; #[cfg(feature = "blog")] pub(crate) use atrium_api::*; +use tracing::instrument; +use crate::config::Config; use crate::landing::WebLandingServer; mod action; @@ -31,14 +32,9 @@ mod logging; mod ssh; mod tui; -const SSH_KEYS: &[&[u8]] = &[ - include_bytes!(concat!(env!("OUT_DIR"), "/rsa.pem")), - include_bytes!(concat!(env!("OUT_DIR"), "/ecdsa.pem")), - include_bytes!(concat!(env!("OUT_DIR"), "/ed25519.pem")), -]; - #[rustfmt::skip] lazy_static! { + pub(crate) static ref CONFIG: Config = Config::new().expect("Config loading error, see above"); pub(crate) static ref OPTIONS: Cli = Cli::parse(); pub(crate) static ref SSH_SOCKET_ADDR: Option = Some(SocketAddr::from((host_ip().ok()?, OPTIONS.ssh_port))); pub(crate) static ref WEB_SERVER_ADDR: Option = Some(SocketAddr::from((host_ip().ok()?, OPTIONS.web_port))); @@ -53,8 +49,9 @@ async fn main() -> Result<()> { let ssh_socket_addr = SSH_SOCKET_ADDR.ok_or(eyre!("Invalid host IP provided"))?; let web_server_addr = WEB_SERVER_ADDR.ok_or(eyre!("Invalid host IP provided"))?; + let ssh_config = tokio::task::block_in_place(ssh_config); tokio::select! { - ssh_res = SshServer::start(ssh_socket_addr, ssh_config()) => ssh_res, + ssh_res = SshServer::start(ssh_socket_addr, ssh_config) => ssh_res, web_res = WebLandingServer::start(web_server_addr) => web_res.map_err(|err| eyre!(err)), } } @@ -75,17 +72,15 @@ pub fn host_ip() -> Result<[u8; 4]> { .map_err(|_| eyre!("Invalid host IP provided")) } -fn ssh_config() -> Config { - let conf = Config { +#[instrument] +fn ssh_config() -> SshConfig { + let conf = SshConfig { methods: MethodSet::NONE, - keys: SSH_KEYS - .to_vec() - .iter() - .filter_map(|pem| PrivateKey::from_openssh(pem).ok()) - .collect(), + keys: CONFIG.private_keys.clone(), ..Default::default() }; - tracing::trace!("SSH config: {:#?}", conf); + tracing::info!("SSH will use {} host keys", conf.keys.len()); + tracing::trace!("SSH config: {:?}", conf); conf }