feat!: persist ssh keys across builds by writing them in data dir

This commit is contained in:
Erica Marigold 2025-08-27 10:34:50 +01:00
parent 7a55e0defd
commit 3e44d24f9b
Signed by: DevComp
SSH key fingerprint: SHA256:jD3oMT4WL3WHPJQbrjC3l5feNCnkv7ndW8nYaHX5wFw
7 changed files with 145 additions and 57 deletions

View file

@ -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": {
"<q>": "Quit", // Quit the application

46
Cargo.lock generated
View file

@ -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",

View file

@ -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"] }

View file

@ -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(

View file

@ -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,

View file

@ -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<PrivateKey>,
#[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<PathBuf> =
pub static ref DATA_DIR: Option<PathBuf> =
env::var(format!("{}_DATA", PROJECT_NAME.clone())).ok().map(PathBuf::from);
pub static ref CONFIG_FOLDER: Option<PathBuf> =
pub static ref CONFIG_DIR: Option<PathBuf> =
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> {
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<Vec<PrivateKey>, D::Error>
where
D: Deserializer<'de>, {
let keys = HashMap::<String, String>::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::<Result<Vec<PrivateKey>, 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)]

View file

@ -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<SocketAddr> = Some(SocketAddr::from((host_ip().ok()?, OPTIONS.ssh_port)));
pub(crate) static ref WEB_SERVER_ADDR: Option<SocketAddr> = 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
}