rust: nexus-core storage, vault, keychain, export/import, data-dir migration

This commit is contained in:
Andy Dixon
2026-10-11 16:05:13 +01:00
parent f588a797f9
commit e03298de11
25 changed files with 4539 additions and 0 deletions
+128
View File
@@ -0,0 +1,128 @@
//! Data directories, and the one-time move of Alchementary Link data into Nexus Terminal's.
use std::path::{Path, PathBuf};
pub const APP_DIR: &str = "NexusTerminal";
pub const LEGACY_APP_DIR: &str = "AlchementaryLink";
#[derive(Clone, Debug)]
pub struct AppConfig {
pub data_dir: PathBuf,
pub db_path: PathBuf,
pub vault_dir: PathBuf,
pub recordings_dir: PathBuf,
pub log_path: PathBuf,
/// Set when this run copied data across from Alchementary Link.
pub migrated_from: Option<PathBuf>,
}
/// `~/Library/Application Support` on macOS, `%APPDATA%` on Windows, `$XDG_CONFIG_HOME` or
/// `~/.config` elsewhere — the same bases the Go app used.
pub fn base_dir() -> PathBuf {
let home = || std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(PathBuf::from).unwrap_or_default();
if cfg!(target_os = "macos") {
home().join("Library/Application Support")
} else if cfg!(windows) {
std::env::var_os("APPDATA").map(PathBuf::from).unwrap_or_else(|| home().join("AppData").join("Roaming"))
} else {
std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).unwrap_or_else(|| home().join(".config"))
}
}
impl AppConfig {
pub fn in_dir(data_dir: PathBuf) -> AppConfig {
AppConfig {
db_path: data_dir.join("nexus.db"),
vault_dir: data_dir.join("vault"),
recordings_dir: data_dir.join("recordings"),
log_path: data_dir.join("nexus.log"),
data_dir,
migrated_from: None,
}
}
/// Resolves the data directory, migrating legacy data first, and creates the directories.
pub fn load() -> std::io::Result<AppConfig> {
let base = base_dir();
let mut cfg = AppConfig::in_dir(base.join(APP_DIR));
cfg.migrated_from = migrate_legacy(&base.join(LEGACY_APP_DIR), &cfg)?;
for d in [&cfg.data_dir, &cfg.vault_dir] {
create_private_dir(d)?;
}
Ok(cfg)
}
}
fn create_private_dir(d: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(d)?;
#[cfg(unix)]
std::fs::set_permissions(d, std::os::unix::fs::PermissionsExt::from_mode(0o700))?;
Ok(())
}
/// Copies (never moves) `AlchementaryLink/{link.db, vault/, recordings/}` into the Nexus
/// directory when Nexus has no database yet. A pre-rebrand `NexusTerminal/nexus.db` wins as-is.
pub fn migrate_legacy(legacy: &Path, cfg: &AppConfig) -> std::io::Result<Option<PathBuf>> {
let legacy_db = legacy.join("link.db");
if cfg.db_path.exists() || !legacy_db.exists() {
return Ok(None);
}
create_private_dir(&cfg.data_dir)?;
// Fold the WAL into the main file so the copy is complete.
if let Ok(c) = rusqlite::Connection::open(&legacy_db) {
let _ = c.pragma_update(None, "wal_checkpoint", "TRUNCATE");
}
std::fs::copy(&legacy_db, &cfg.db_path)?;
copy_dir(&legacy.join("vault"), &cfg.vault_dir)?;
copy_dir(&legacy.join("recordings"), &cfg.recordings_dir)?;
std::fs::write(cfg.data_dir.join("migrated-from"), legacy.to_string_lossy().as_bytes())?;
Ok(Some(legacy.to_path_buf()))
}
fn copy_dir(from: &Path, to: &Path) -> std::io::Result<()> {
let Ok(rd) = std::fs::read_dir(from) else { return Ok(()) };
create_private_dir(to)?;
for e in rd.flatten() {
if e.file_type()?.is_file() && !to.join(e.file_name()).exists() {
std::fs::copy(e.path(), to.join(e.file_name()))?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("nexus-cfg-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
d
}
#[test]
fn migrates_legacy_once_and_keeps_source() {
let base = tmp("mig");
let legacy = base.join(LEGACY_APP_DIR);
std::fs::create_dir_all(legacy.join("vault")).unwrap();
std::fs::write(legacy.join("link.db"), b"db").unwrap();
std::fs::write(legacy.join("vault/vault.json"), b"{}").unwrap();
let cfg = AppConfig::in_dir(base.join(APP_DIR));
assert_eq!(migrate_legacy(&legacy, &cfg).unwrap(), Some(legacy.clone()));
assert_eq!(std::fs::read(&cfg.db_path).unwrap(), b"db");
assert!(cfg.vault_dir.join("vault.json").exists());
assert!(legacy.join("link.db").exists());
// Second run: Nexus already has a database, nothing happens.
std::fs::write(legacy.join("link.db"), b"newer").unwrap();
assert_eq!(migrate_legacy(&legacy, &cfg).unwrap(), None);
assert_eq!(std::fs::read(&cfg.db_path).unwrap(), b"db");
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn no_legacy_no_migration() {
let base = tmp("none");
let cfg = AppConfig::in_dir(base.join(APP_DIR));
assert_eq!(migrate_legacy(&base.join(LEGACY_APP_DIR), &cfg).unwrap(), None);
}
}
+90
View File
@@ -0,0 +1,90 @@
//! Password-encrypted profile exports: `[version 1][salt 32][nonce 12][AES-256-GCM ciphertext]`
//! over a JSON bundle, with the vault's Argon2id parameters. `.link` files from Alchementary
//! Link are the same format.
use crate::models::Profile;
use crate::secrets::{derive_key, random_bytes};
use crate::Result;
use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Nonce};
use serde::{Deserialize, Serialize};
use std::path::Path;
const VERSION: u8 = 1;
const MAX_IMPORT_BYTES: u64 = 10 * 1024 * 1024;
pub const EXTENSION: &str = "nexus";
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Bundle {
version: i64,
exported_at: String,
#[serde(default)]
profiles: Vec<Profile>,
count: usize,
}
pub fn encrypt_and_export(profiles: &[Profile], password: &str, path: &Path) -> Result<()> {
if password.is_empty() {
return Err("exportimport: password cannot be empty".into());
}
if profiles.is_empty() {
return Err("exportimport: no profiles to export".into());
}
let bundle = Bundle {
version: 1,
exported_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
profiles: profiles.to_vec(),
count: profiles.len(),
};
let plain = serde_json::to_vec_pretty(&bundle)?;
let salt = random_bytes::<32>();
let nonce = random_bytes::<12>();
let key = derive_key(password, &salt)?;
let ct = Aes256Gcm::new((&key).into()).encrypt(Nonce::from_slice(&nonce), plain.as_slice()).map_err(|e| e.to_string())?;
let mut out = vec![VERSION];
out.extend(salt);
out.extend(nonce);
out.extend(ct);
crate::secrets::write_private(path, &out)
}
pub fn decrypt_and_import(path: &Path, password: &str) -> Result<Vec<Profile>> {
if password.is_empty() {
return Err("exportimport: password cannot be empty".into());
}
if std::fs::metadata(path)?.len() > MAX_IMPORT_BYTES {
return Err("exportimport: file is too large (max 10 MiB)".into());
}
let data = std::fs::read(path)?;
if data.len() < 1 + 32 + 12 + 1 + 16 {
return Err("exportimport: file is too small or corrupt".into());
}
if data[0] != VERSION {
return Err(format!("exportimport: unsupported format version {}", data[0]).into());
}
let key = derive_key(password, &data[1..33])?;
let plain = Aes256Gcm::new((&key).into())
.decrypt(Nonce::from_slice(&data[33..45]), &data[45..])
.map_err(|_| "exportimport: decryption failed — wrong password or corrupt file")?;
let bundle: Bundle = serde_json::from_slice(&plain).map_err(|e| format!("exportimport: invalid bundle JSON: {e}"))?;
Ok(bundle.profiles)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_go_export_and_round_trips() {
let fx = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/profiles.link");
assert!(decrypt_and_import(&fx, "nope").is_err());
let ps = decrypt_and_import(&fx, "export-pass").unwrap();
assert_eq!(ps.len(), 6);
assert_eq!(ps[0].ssh.as_ref().unwrap().jump_hosts[0].port, 2222);
let out = std::env::temp_dir().join(format!("nexus-export-{}.nexus", std::process::id()));
encrypt_and_export(&ps, "pw", &out).unwrap();
assert_eq!(decrypt_and_import(&out, "pw").unwrap(), ps);
std::fs::remove_file(out).unwrap();
}
}
+11
View File
@@ -0,0 +1,11 @@
//! nexus-core: everything in Nexus Terminal that isn't drawing — storage, secrets, protocol
//! clients and the session manager. The egui app is one client of this crate.
pub mod config;
pub mod exportimport;
pub mod models;
pub mod secrets;
pub mod storage;
pub type Error = Box<dyn std::error::Error + Send + Sync>;
pub type Result<T> = std::result::Result<T, Error>;
+913
View File
@@ -0,0 +1,913 @@
//! Data model shared by storage, the session manager and the UI. Field names serialise exactly as
//! the Go structs did, so database JSON columns and `.link`/`.nexus` exports round-trip unchanged.
use chrono::{DateTime, FixedOffset, Local};
use serde::{Deserialize, Deserializer, Serialize};
use std::collections::BTreeMap;
pub const DEFAULT_SSH_PORT: u16 = 22;
pub const DEFAULT_TELNET_PORT: u16 = 23;
pub const DEFAULT_RAW_PORT: u16 = 23;
pub const DEFAULT_GITHUB_PORT: u16 = 443;
pub const DEFAULT_VNC_PORT: u16 = 5900;
pub const DEFAULT_IRC_PORT: u16 = 6667;
pub const DEFAULT_IRC_TLS_PORT: u16 = 6697;
pub const DEFAULT_CONNECT_TIMEOUT: i64 = 30;
pub const DEFAULT_SCROLLBACK: i64 = 10000;
pub const DEFAULT_FONT_SIZE: i64 = 14;
pub const DEFAULT_FONT_FAMILY: &str = "JetBrains Mono, Menlo, Monaco, Consolas, monospace";
pub const DEFAULT_CURSOR_STYLE: &str = "block";
pub const DEFAULT_TERMINAL_TYPE: &str = "xterm-256color";
pub const DEFAULT_KEEPALIVE_INTERVAL: i64 = 30;
pub const DEFAULT_KEEPALIVE_COUNT_MAX: i64 = 3;
pub type Time = DateTime<FixedOffset>;
pub fn now() -> Time {
Local::now().fixed_offset()
}
/// Go's zero `time.Time`, which is what a missing timestamp decodes to.
pub fn zero_time() -> Time {
DateTime::parse_from_rfc3339("0001-01-01T00:00:00Z").unwrap()
}
/// Go writes nil slices and maps as `null`; treat that as empty.
fn nd<'de, D: Deserializer<'de>, T: Default + Deserialize<'de>>(d: D) -> Result<T, D::Error> {
Ok(Option::<T>::deserialize(d)?.unwrap_or_default())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum Protocol {
#[default]
Ssh,
Telnet,
Raw,
Github,
Vnc,
Irc,
Serial,
Local,
}
impl Protocol {
pub const SAVED: [Protocol; 7] =
[Protocol::Ssh, Protocol::Telnet, Protocol::Raw, Protocol::Github, Protocol::Vnc, Protocol::Irc, Protocol::Serial];
pub fn id(self) -> &'static str {
match self {
Protocol::Ssh => "ssh",
Protocol::Telnet => "telnet",
Protocol::Raw => "raw",
Protocol::Github => "github",
Protocol::Vnc => "vnc",
Protocol::Irc => "irc",
Protocol::Serial => "serial",
Protocol::Local => "local",
}
}
pub fn from_id(s: &str) -> Option<Self> {
[Protocol::Local].into_iter().chain(Self::SAVED).find(|p| p.id() == s)
}
pub fn label(self) -> &'static str {
match self {
Protocol::Ssh => "SSH",
Protocol::Telnet => "Telnet",
Protocol::Raw => "RAW TCP",
Protocol::Github => "GitHub",
Protocol::Vnc => "VNC",
Protocol::Irc => "IRC",
Protocol::Serial => "Serial",
Protocol::Local => "Local",
}
}
/// Protocols whose sessions render in a terminal view.
pub fn is_terminal(self) -> bool {
matches!(self, Protocol::Ssh | Protocol::Telnet | Protocol::Serial | Protocol::Local)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum HostKeyPolicy {
#[serde(rename = "strict")]
Strict,
#[default]
#[serde(rename = "ask")]
Ask,
#[serde(rename = "accept-new")]
AcceptNew,
#[serde(rename = "insecure")]
Insecure,
}
impl HostKeyPolicy {
pub const ALL: [HostKeyPolicy; 4] = [Self::Strict, Self::Ask, Self::AcceptNew, Self::Insecure];
pub fn label(self) -> &'static str {
match self {
Self::Strict => "Strict (known hosts only)",
Self::Ask => "Ask on new or changed keys",
Self::AcceptNew => "Accept new, reject changed",
Self::Insecure => "Insecure (accept anything)",
}
}
}
/// How a credential is obtained. Unknown strings (and the legacy "prompt") are kept as
/// ask-every-time, matching Go's normaliseSecretMode.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Default)]
pub enum SecretMode {
#[default]
#[serde(rename = "saved")]
Saved,
#[serde(rename = "session-only")]
SessionOnly,
#[serde(rename = "ask-every-time")]
AskEveryTime,
}
impl<'de> Deserialize<'de> for SecretMode {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
Ok(match Option::<String>::deserialize(d)?.as_deref().unwrap_or("") {
"saved" | "" => SecretMode::Saved,
"session-only" => SecretMode::SessionOnly,
_ => SecretMode::AskEveryTime,
})
}
}
impl SecretMode {
pub const ALL: [SecretMode; 3] = [Self::Saved, Self::SessionOnly, Self::AskEveryTime];
pub fn label(self) -> &'static str {
match self {
Self::Saved => "Save securely",
Self::SessionOnly => "Remember for this session",
Self::AskEveryTime => "Ask every time",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum LineEnding {
Lf,
Cr,
#[default]
Crlf,
None,
}
impl LineEnding {
pub const ALL: [LineEnding; 4] = [Self::Crlf, Self::Lf, Self::Cr, Self::None];
pub fn bytes(self) -> &'static [u8] {
match self {
Self::Lf => b"\n",
Self::Cr => b"\r",
Self::Crlf => b"\r\n",
Self::None => b"",
}
}
pub fn label(self) -> &'static str {
match self {
Self::Lf => "LF (\\n)",
Self::Cr => "CR (\\r)",
Self::Crlf => "CRLF (\\r\\n)",
Self::None => "None",
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Profile {
#[serde(default)]
pub id: String,
#[serde(default)]
pub display_name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub protocol: Protocol,
#[serde(default)]
pub host: String,
#[serde(default)]
pub port: i64,
#[serde(default)]
pub username: String,
#[serde(default, deserialize_with = "nd")]
pub tags: Vec<String>,
#[serde(default)]
pub folder: String,
#[serde(default)]
pub environment: String,
#[serde(default)]
pub notes: String,
#[serde(default)]
pub favourite: bool,
#[serde(default)]
pub last_connected: Option<Time>,
#[serde(default)]
pub connect_timeout: i64,
#[serde(default)]
pub read_timeout: i64,
#[serde(default = "zero_time")]
pub created_at: Time,
#[serde(default = "zero_time")]
pub updated_at: Time,
#[serde(default)]
pub terminal_font_family: String,
#[serde(default)]
pub terminal_font_size: i64,
#[serde(default)]
pub terminal_scrollback: i64,
#[serde(default)]
pub terminal_cursor_style: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ssh: Option<SshConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub telnet: Option<TelnetConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub raw: Option<RawConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub github: Option<GitHubConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vnc: Option<VncConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub irc: Option<IrcConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub serial: Option<SerialConfig>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase", default)]
pub struct SshConfig {
#[serde(deserialize_with = "nd")]
pub auth_methods: Vec<String>,
pub secret_mode: SecretMode,
pub private_key_path: String,
pub key_secret_mode: SecretMode,
pub host_key_policy: HostKeyPolicy,
pub terminal_type: String,
pub keepalive_interval: i64,
pub keepalive_count_max: i64,
pub compression: bool,
pub agent_auth: bool,
pub agent_forwarding: bool,
pub auto_reconnect: bool,
pub max_reconnect_tries: i64,
#[serde(deserialize_with = "nd")]
pub login_scripts: Vec<LoginScriptStep>,
#[serde(rename = "totpSecret")]
pub totp_secret: String,
pub proxy_type: String,
pub proxy_host: String,
pub proxy_port: i64,
pub proxy_username: String,
pub proxy_password: String,
#[serde(deserialize_with = "nd")]
pub jump_hosts: Vec<JumpHost>,
#[serde(deserialize_with = "nd")]
pub port_forwards: Vec<PortForward>,
pub startup_command: String,
#[serde(deserialize_with = "nd")]
pub env_vars: BTreeMap<String, String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase", default)]
pub struct LoginScriptStep {
#[serde(rename = "type")]
pub kind: String,
pub value: String,
pub timeout: i64,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase", default)]
pub struct JumpHost {
pub host: String,
pub port: i64,
pub username: String,
pub auth_method: String,
pub secret_mode: SecretMode,
pub private_key_path: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ForwardDirection {
#[default]
Local,
Remote,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase", default)]
pub struct PortForward {
pub id: String,
pub direction: ForwardDirection,
pub bind_host: String,
pub bind_port: i64,
pub dest_host: String,
pub dest_port: i64,
pub enabled: bool,
pub label: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase", default)]
pub struct TelnetConfig {
pub local_echo: bool,
pub line_ending: LineEnding,
pub terminal_type: String,
pub encoding: String,
pub negotiation_debug: bool,
pub line_mode: bool,
pub auto_reconnect: bool,
pub max_reconnect_tries: i64,
#[serde(deserialize_with = "nd")]
pub login_scripts: Vec<LoginScriptStep>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase", default)]
pub struct RawConfig {
pub auto_reconnect: bool,
pub line_ending: LineEnding,
pub encoding: String,
pub show_timestamps: bool,
pub show_direction: bool,
#[serde(deserialize_with = "nd")]
pub payload_presets: Vec<PayloadPreset>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase", default)]
pub struct PayloadPreset {
pub id: String,
pub name: String,
/// base64-encoded for binary safety
pub payload: String,
/// text, hex, escaped
pub format: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase", default)]
pub struct GitHubConfig {
#[serde(rename = "baseURL")]
pub base_url: String,
pub secret_mode: SecretMode,
pub refresh_interval_secs: i64,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase", default)]
pub struct VncConfig {
pub vnc_host: String,
pub vnc_port: i64,
pub secret_mode: SecretMode,
pub view_only: bool,
pub scale_to_fit: bool,
pub quality: i64,
pub shared_session: bool,
pub ssh_tunnel: bool,
pub ssh_host: String,
pub ssh_port: i64,
pub ssh_username: String,
pub ssh_auth_method: String,
pub ssh_secret_mode: SecretMode,
#[serde(rename = "sshPrivateKeyPath")]
pub ssh_private_key: String,
pub ssh_agent_auth: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase", default)]
pub struct IrcConfig {
pub nick: String,
pub real_name: String,
#[serde(rename = "useTLS")]
pub use_tls: bool,
pub server_password_required: bool,
pub server_password_mode: SecretMode,
pub nick_serv_identify: bool,
pub nick_serv_account: String,
pub nick_serv_mode: SecretMode,
#[serde(deserialize_with = "nd")]
pub auto_join_rooms: Vec<String>,
pub ssh_tunnel: bool,
pub ssh_host: String,
pub ssh_port: i64,
pub ssh_username: String,
pub ssh_auth_method: String,
pub ssh_secret_mode: SecretMode,
#[serde(rename = "sshPrivateKeyPath")]
pub ssh_private_key: String,
pub ssh_agent_auth: bool,
pub auto_reconnect: bool,
pub max_reconnect_tries: i64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum Parity {
#[default]
None,
Odd,
Even,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum FlowControl {
#[default]
None,
Xonxoff,
Rtscts,
}
/// Serial line settings. The device path lives in `Profile::host`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct SerialConfig {
pub baud_rate: u32,
pub data_bits: u8,
pub parity: Parity,
pub stop_bits: u8,
pub flow_control: FlowControl,
pub line_ending: LineEnding,
pub local_echo: bool,
pub dtr_on_connect: bool,
pub rts_on_connect: bool,
pub encoding: String,
pub auto_reconnect: bool,
pub max_reconnect_tries: i64,
}
impl Default for SerialConfig {
fn default() -> Self {
Self {
baud_rate: 115200,
data_bits: 8,
parity: Parity::None,
stop_bits: 1,
flow_control: FlowControl::None,
line_ending: LineEnding::Cr,
local_echo: false,
dtr_on_connect: true,
rts_on_connect: true,
encoding: "utf-8".into(),
auto_reconnect: true,
max_reconnect_tries: 5,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase", default)]
pub struct GitHubRepo {
pub full_name: String,
pub name: String,
pub owner: String,
pub description: String,
#[serde(rename = "htmlURL")]
pub html_url: String,
pub default_branch: String,
pub latest_committer: String,
pub latest_commit_at: String,
pub private: bool,
pub fork: bool,
pub archived: bool,
pub language: String,
pub stars: i64,
pub pushed_at: String,
pub updated_at: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum SessionState {
#[default]
Connecting,
Connected,
Disconnected,
Reconnecting,
Error,
}
#[derive(Clone, Debug)]
pub struct SessionInfo {
pub id: String,
pub profile_id: String,
pub profile_name: String,
pub protocol: Protocol,
pub host: String,
pub port: i64,
pub state: SessionState,
pub tab_name: String,
pub pinned: bool,
pub connected_at: Time,
pub error: String,
}
#[derive(Clone, Debug, PartialEq)]
pub struct KnownHost {
pub id: String,
pub host: String,
pub port: i64,
pub key_type: String,
pub fingerprint: String,
pub public_key: String,
pub first_seen: Time,
pub last_seen: Time,
pub trusted: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct AppPreferences {
pub theme: String,
pub terminal_theme: String,
pub default_protocol: String,
pub confirm_on_close: bool,
pub confirm_on_disconnect: bool,
pub show_status_bar: bool,
pub sidebar_width: i64,
pub default_font_family: String,
pub default_font_size: i64,
pub default_scrollback: i64,
pub default_cursor_style: String,
pub use_native_paste: bool,
pub local_shell: String,
}
impl Default for AppPreferences {
fn default() -> Self {
Self {
theme: "dark".into(),
terminal_theme: "charcoal".into(),
default_protocol: "ssh".into(),
confirm_on_close: true,
confirm_on_disconnect: true,
show_status_bar: true,
sidebar_width: 280,
default_font_family: DEFAULT_FONT_FAMILY.into(),
default_font_size: DEFAULT_FONT_SIZE,
default_scrollback: DEFAULT_SCROLLBACK,
default_cursor_style: DEFAULT_CURSOR_STYLE.into(),
use_native_paste: false,
local_shell: String::new(),
}
}
}
impl AppPreferences {
/// Fills zero values a stored preference blob may carry (Go's applyDefaults).
pub fn apply_defaults(&mut self) {
let d = Self::default();
if self.theme.is_empty() {
self.theme = d.theme;
}
if self.terminal_theme.is_empty() {
self.terminal_theme = d.terminal_theme;
}
if self.default_protocol.is_empty() {
self.default_protocol = d.default_protocol;
}
if self.sidebar_width == 0 {
self.sidebar_width = d.sidebar_width;
}
if self.default_font_family.is_empty() {
self.default_font_family = d.default_font_family;
}
if self.default_font_size == 0 {
self.default_font_size = d.default_font_size;
}
if self.default_scrollback == 0 {
self.default_scrollback = d.default_scrollback;
}
if self.default_cursor_style.is_empty() {
self.default_cursor_style = d.default_cursor_style;
}
}
}
#[derive(Clone, Debug)]
pub struct AuditEntry {
pub id: String,
pub timestamp: Time,
pub action: String,
pub profile_id: String,
pub session_id: String,
pub details: String,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Snippet {
pub id: String,
pub name: String,
pub command: String,
pub description: String,
pub folder: String,
pub tags: Vec<String>,
pub created_at: Time,
pub updated_at: Time,
}
pub fn new_id() -> String {
uuid::Uuid::new_v4().to_string()
}
impl Profile {
/// A profile with protocol defaults filled in (Go's NewProfile).
pub fn new(protocol: Protocol) -> Profile {
let now = now();
let mut p = Profile {
id: new_id(),
display_name: String::new(),
description: String::new(),
protocol,
host: String::new(),
port: 0,
username: String::new(),
tags: vec![],
folder: String::new(),
environment: String::new(),
notes: String::new(),
favourite: false,
last_connected: None,
connect_timeout: DEFAULT_CONNECT_TIMEOUT,
read_timeout: 0,
created_at: now,
updated_at: now,
terminal_font_family: DEFAULT_FONT_FAMILY.into(),
terminal_font_size: DEFAULT_FONT_SIZE,
terminal_scrollback: DEFAULT_SCROLLBACK,
terminal_cursor_style: DEFAULT_CURSOR_STYLE.into(),
ssh: None,
telnet: None,
raw: None,
github: None,
vnc: None,
irc: None,
serial: None,
};
match protocol {
Protocol::Ssh => {
p.port = DEFAULT_SSH_PORT.into();
p.ssh = Some(SshConfig::defaults());
}
Protocol::Telnet => {
p.port = DEFAULT_TELNET_PORT.into();
p.telnet = Some(TelnetConfig::defaults());
}
Protocol::Raw => {
p.port = DEFAULT_RAW_PORT.into();
p.raw = Some(RawConfig::defaults());
}
Protocol::Github => {
p.port = DEFAULT_GITHUB_PORT.into();
p.host = "github.com".into();
p.github = Some(GitHubConfig::defaults());
}
Protocol::Vnc => {
p.port = DEFAULT_VNC_PORT.into();
p.vnc = Some(VncConfig::defaults());
}
Protocol::Irc => {
p.port = DEFAULT_IRC_PORT.into();
p.irc = Some(IrcConfig::defaults());
}
Protocol::Serial => p.serial = Some(SerialConfig::default()),
Protocol::Local => {}
}
p
}
/// Fills missing fields the way Go's NormalizeProfileDefaults did.
pub fn normalize(&mut self) {
let d = Profile::new(self.protocol);
if self.id.is_empty() {
self.id = d.id.clone();
}
if self.connect_timeout == 0 {
self.connect_timeout = DEFAULT_CONNECT_TIMEOUT;
}
if self.terminal_font_family.is_empty() {
self.terminal_font_family = DEFAULT_FONT_FAMILY.into();
}
if self.terminal_font_size == 0 {
self.terminal_font_size = DEFAULT_FONT_SIZE;
}
if self.terminal_scrollback == 0 {
self.terminal_scrollback = DEFAULT_SCROLLBACK;
}
if self.terminal_cursor_style.is_empty() {
self.terminal_cursor_style = DEFAULT_CURSOR_STYLE.into();
}
if self.created_at == zero_time() {
self.created_at = d.created_at;
}
if self.updated_at == zero_time() {
self.updated_at = d.updated_at;
}
if self.port == 0 && self.protocol != Protocol::Serial {
self.port = d.port;
}
if self.protocol == Protocol::Github && self.host.is_empty() {
self.host = d.host.clone();
}
if self.display_name.is_empty() {
self.display_name = self.default_name();
}
match self.protocol {
Protocol::Ssh => {
let c = self.ssh.get_or_insert_with(SshConfig::defaults);
if c.auth_methods.is_empty() {
c.auth_methods = SshConfig::defaults().auth_methods;
}
if c.terminal_type.is_empty() {
c.terminal_type = DEFAULT_TERMINAL_TYPE.into();
}
if c.keepalive_interval == 0 {
c.keepalive_interval = DEFAULT_KEEPALIVE_INTERVAL;
}
if c.keepalive_count_max == 0 {
c.keepalive_count_max = DEFAULT_KEEPALIVE_COUNT_MAX;
}
}
Protocol::Telnet => {
let c = self.telnet.get_or_insert_with(TelnetConfig::defaults);
if c.terminal_type.is_empty() {
c.terminal_type = DEFAULT_TERMINAL_TYPE.into();
}
if c.encoding.is_empty() {
c.encoding = "utf-8".into();
}
}
Protocol::Raw => {
let c = self.raw.get_or_insert_with(RawConfig::defaults);
if c.encoding.is_empty() {
c.encoding = "utf-8".into();
}
for p in &mut c.payload_presets {
if p.id.is_empty() {
p.id = new_id();
}
if p.format.is_empty() {
p.format = "text".into();
}
}
}
Protocol::Github => {
let c = self.github.get_or_insert_with(GitHubConfig::defaults);
if c.base_url.is_empty() {
c.base_url = "https://api.github.com".into();
}
if c.refresh_interval_secs == 0 {
c.refresh_interval_secs = 300;
}
}
Protocol::Vnc => {
let c = self.vnc.get_or_insert_with(VncConfig::defaults);
if c.vnc_port == 0 {
c.vnc_port = DEFAULT_VNC_PORT.into();
}
if c.quality < 0 {
c.quality = 6;
}
if c.ssh_port == 0 {
c.ssh_port = DEFAULT_SSH_PORT.into();
}
if c.ssh_auth_method.is_empty() {
c.ssh_auth_method = "publickey".into();
}
}
Protocol::Irc => {
let c = self.irc.get_or_insert_with(IrcConfig::defaults);
if c.nick.is_empty() {
c.nick = "nexus".into();
}
if c.real_name.is_empty() {
c.real_name = "Nexus Terminal".into();
}
if c.ssh_port == 0 {
c.ssh_port = DEFAULT_SSH_PORT.into();
}
if c.ssh_auth_method.is_empty() {
c.ssh_auth_method = "publickey".into();
}
}
Protocol::Serial => {
self.serial.get_or_insert_with(SerialConfig::default);
}
Protocol::Local => {}
}
}
fn default_name(&self) -> String {
if !self.host.is_empty() {
return self.host.clone();
}
if let Some(v) = &self.vnc
&& !v.vnc_host.is_empty()
{
return v.vnc_host.clone();
}
match self.protocol {
Protocol::Github => "GitHub".into(),
p => p.label().into(),
}
}
/// The default tab name (Go's createTabName).
pub fn tab_name(&self) -> String {
if !self.display_name.is_empty() {
self.display_name.clone()
} else if !self.username.is_empty() {
format!("{}@{}", self.username, self.host)
} else {
self.host.clone()
}
}
pub fn auto_reconnect(&self) -> bool {
match self.protocol {
Protocol::Ssh => self.ssh.as_ref().is_some_and(|c| c.auto_reconnect),
Protocol::Telnet => self.telnet.as_ref().is_some_and(|c| c.auto_reconnect),
Protocol::Raw => self.raw.as_ref().is_some_and(|c| c.auto_reconnect),
Protocol::Irc => self.irc.as_ref().is_some_and(|c| c.auto_reconnect),
Protocol::Serial => self.serial.as_ref().is_some_and(|c| c.auto_reconnect),
_ => false,
}
}
pub fn max_reconnect_tries(&self) -> i64 {
let n = match self.protocol {
Protocol::Ssh => self.ssh.as_ref().map_or(0, |c| c.max_reconnect_tries),
Protocol::Telnet => self.telnet.as_ref().map_or(0, |c| c.max_reconnect_tries),
Protocol::Irc => self.irc.as_ref().map_or(0, |c| c.max_reconnect_tries),
Protocol::Serial => self.serial.as_ref().map_or(0, |c| c.max_reconnect_tries),
_ => 0,
};
if n > 0 { n } else { 5 }
}
}
impl SshConfig {
pub fn defaults() -> Self {
SshConfig {
auth_methods: vec!["publickey".into(), "password".into(), "keyboard-interactive".into()],
terminal_type: DEFAULT_TERMINAL_TYPE.into(),
keepalive_interval: DEFAULT_KEEPALIVE_INTERVAL,
keepalive_count_max: DEFAULT_KEEPALIVE_COUNT_MAX,
..Default::default()
}
}
}
impl TelnetConfig {
pub fn defaults() -> Self {
TelnetConfig { terminal_type: DEFAULT_TERMINAL_TYPE.into(), encoding: "utf-8".into(), ..Default::default() }
}
}
impl RawConfig {
pub fn defaults() -> Self {
RawConfig { encoding: "utf-8".into(), ..Default::default() }
}
}
impl GitHubConfig {
pub fn defaults() -> Self {
GitHubConfig { base_url: "https://api.github.com".into(), refresh_interval_secs: 300, ..Default::default() }
}
}
impl VncConfig {
pub fn defaults() -> Self {
VncConfig {
vnc_port: DEFAULT_VNC_PORT.into(),
scale_to_fit: true,
shared_session: true,
quality: 6,
ssh_port: DEFAULT_SSH_PORT.into(),
ssh_auth_method: "publickey".into(),
..Default::default()
}
}
}
impl IrcConfig {
pub fn defaults() -> Self {
IrcConfig {
nick: "nexus".into(),
real_name: "Nexus Terminal".into(),
server_password_mode: SecretMode::AskEveryTime,
ssh_port: DEFAULT_SSH_PORT.into(),
ssh_auth_method: "publickey".into(),
auto_reconnect: true,
..Default::default()
}
}
}
+257
View File
@@ -0,0 +1,257 @@
//! Credential storage: the OS keychain first, then an Argon2id + AES-256-GCM vault file that is
//! byte-compatible with the Go app's `vault/vault.json`.
use crate::Result;
use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Nonce};
use base64::Engine;
use base64::engine::general_purpose::STANDARD as B64;
use rand::RngCore;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Mutex;
const VAULT_FILE: &str = "vault.json";
const MAX_VAULT_BYTES: u64 = 10 * 1024 * 1024;
/// Argon2id with the Go parameters: t=3, m=128 MiB, p=4, 32-byte key.
pub fn derive_key(password: &str, salt: &[u8]) -> Result<[u8; 32]> {
let params = argon2::Params::new(128 * 1024, 3, 4, Some(32)).map_err(|e| e.to_string())?;
let mut key = [0u8; 32];
argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params)
.hash_password_into(password.as_bytes(), salt, &mut key)
.map_err(|e| e.to_string())?;
Ok(key)
}
pub fn random_bytes<const N: usize>() -> [u8; N] {
let mut b = [0u8; N];
rand::thread_rng().fill_bytes(&mut b);
b
}
/// nonce(12) ‖ ciphertext ‖ tag
pub fn seal(key: &[u8; 32], plaintext: &[u8]) -> Vec<u8> {
let nonce = random_bytes::<12>();
let mut out = nonce.to_vec();
out.extend(Aes256Gcm::new(key.into()).encrypt(Nonce::from_slice(&nonce), plaintext).expect("aes-gcm encrypt"));
out
}
pub fn open(key: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
if data.len() < 12 {
return Err("ciphertext too short".into());
}
Aes256Gcm::new(key.into())
.decrypt(Nonce::from_slice(&data[..12]), &data[12..])
.map_err(|_| "decryption failed".into())
}
#[derive(Serialize, Deserialize)]
struct VaultFile {
version: i64,
salt: String,
secrets: BTreeMap<String, String>,
}
struct Unlocked {
key: [u8; 32],
vault: VaultFile,
}
impl Drop for Unlocked {
fn drop(&mut self) {
self.key.fill(0);
}
}
pub struct Vault {
dir: PathBuf,
state: Mutex<Option<Unlocked>>,
}
impl Vault {
pub fn new(dir: PathBuf) -> Vault {
Vault { dir, state: Mutex::new(None) }
}
fn path(&self) -> PathBuf {
self.dir.join(VAULT_FILE)
}
pub fn is_initialised(&self) -> bool {
self.path().exists()
}
pub fn is_unlocked(&self) -> bool {
self.state.lock().unwrap().is_some()
}
pub fn initialise(&self, password: &str) -> Result<()> {
if self.is_initialised() {
return Err("secrets: vault already initialised".into());
}
let salt = random_bytes::<32>();
let u = Unlocked {
key: derive_key(password, &salt)?,
vault: VaultFile { version: 1, salt: B64.encode(salt), secrets: BTreeMap::new() },
};
std::fs::create_dir_all(&self.dir)?;
self.save(&u)?;
*self.state.lock().unwrap() = Some(u);
Ok(())
}
pub fn unlock(&self, password: &str) -> Result<()> {
let meta = std::fs::metadata(self.path()).map_err(|e| format!("secrets: failed to load vault: {e}"))?;
if meta.len() > MAX_VAULT_BYTES {
return Err("secrets: vault file is too large".into());
}
let vault: VaultFile = serde_json::from_slice(&std::fs::read(self.path())?)?;
let salt = B64.decode(&vault.salt).map_err(|e| format!("secrets: corrupt vault salt: {e}"))?;
let u = Unlocked { key: derive_key(password, &salt)?, vault };
// An empty vault accepts any password, as in Go; otherwise one entry must decrypt.
if let Some(v) = u.vault.secrets.values().next() {
let data = B64.decode(v).unwrap_or_default();
open(&u.key, &data).map_err(|_| "secrets: incorrect master password or corrupt vault")?;
}
*self.state.lock().unwrap() = Some(u);
Ok(())
}
pub fn lock(&self) {
*self.state.lock().unwrap() = None;
}
fn save(&self, u: &Unlocked) -> Result<()> {
let data = serde_json::to_vec_pretty(&u.vault)?;
write_private(&self.path(), &data)
}
pub fn get(&self, key: &str) -> Result<String> {
let st = self.state.lock().unwrap();
let u = st.as_ref().ok_or("secrets: vault is locked")?;
let enc = u.vault.secrets.get(key).ok_or_else(|| format!("secrets: key {key:?} not found"))?;
let data = B64.decode(enc).map_err(|e| format!("failed to decode secret: {e}"))?;
Ok(String::from_utf8_lossy(&open(&u.key, &data)?).into_owned())
}
pub fn set(&self, key: &str, value: &str) -> Result<()> {
let mut st = self.state.lock().unwrap();
let u = st.as_mut().ok_or("secrets: vault is locked")?;
let enc = B64.encode(seal(&u.key, value.as_bytes()));
u.vault.secrets.insert(key.to_string(), enc);
self.save(u)
}
pub fn delete_prefix(&self, prefix: &str) -> Result<()> {
let mut st = self.state.lock().unwrap();
let u = st.as_mut().ok_or("secrets: vault is locked")?;
u.vault.secrets.retain(|k, _| !k.starts_with(prefix));
self.save(u)
}
pub fn delete(&self, key: &str) -> Result<()> {
let mut st = self.state.lock().unwrap();
let u = st.as_mut().ok_or("secrets: vault is locked")?;
u.vault.secrets.remove(key);
self.save(u)
}
}
/// Writes a file readable only by the owner.
pub fn write_private(path: &std::path::Path, data: &[u8]) -> Result<()> {
use std::io::Write;
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut opts, 0o600);
let mut f = opts.open(path)?;
f.write_all(data)?;
f.sync_all()?;
Ok(())
}
pub const KEYCHAIN_SERVICE: &str = "NexusTerminal";
/// The service name Alchementary Link wrote under; read as a fallback (§7 of the port plan).
pub const LEGACY_KEYCHAIN_SERVICE: &str = "AlchementaryLink";
fn entry(service: &str, key: &str) -> keyring::Result<keyring::Entry> {
// go-keyring's Windows target is "service:user"; keyring's default differs.
if cfg!(windows) {
keyring::Entry::new_with_target(&format!("{service}:{key}"), service, key)
} else {
keyring::Entry::new(service, key)
}
}
/// The OS keychain. Every call can fail (no secret service, locked keyring); callers fall back
/// to the vault.
pub struct Keychain {
service: String,
legacy: Option<String>,
}
impl Keychain {
pub fn new(service: &str, legacy: Option<&str>) -> Keychain {
Keychain { service: service.into(), legacy: legacy.map(Into::into) }
}
pub fn available(&self) -> bool {
let probe = "__nexus_keychain_probe__";
entry(&self.service, probe).and_then(|e| e.set_password("probe").and_then(|_| e.delete_credential())).is_ok()
}
/// Reads a secret; a miss under the current service falls back to the legacy one and copies
/// the value forward. Legacy items are never deleted, so the old app keeps working.
pub fn get(&self, key: &str) -> Option<String> {
if let Ok(v) = entry(&self.service, key).and_then(|e| e.get_password()) {
return Some(v);
}
let legacy = self.legacy.as_deref()?;
let v = entry(legacy, key).and_then(|e| e.get_password()).ok()?;
let _ = self.set(key, &v);
Some(v)
}
pub fn set(&self, key: &str, value: &str) -> Result<()> {
entry(&self.service, key)
.and_then(|e| e.set_password(value))
.map_err(|e| format!("keychain: failed to store secret {key:?}: {e}").into())
}
pub fn delete(&self, key: &str) {
let _ = entry(&self.service, key).and_then(|e| e.delete_credential());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_go_vault_fixture() {
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/vault");
let v = Vault::new(dir);
assert!(v.is_initialised());
assert!(v.unlock("wrong").is_err());
v.unlock("correct horse battery staple").unwrap();
assert_eq!(v.get("profile:00000000-0000-0000-0000-000000000001:password").unwrap(), "s3cret-ssh");
assert_eq!(v.get("profile:00000000-0000-0000-0000-000000000001:totp").unwrap(), "JBSWY3DPEHPK3PXP");
}
#[test]
fn vault_round_trip() {
let dir = std::env::temp_dir().join(format!("nexus-vault-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let v = Vault::new(dir.clone());
v.initialise("pw").unwrap();
v.set("a", "1").unwrap();
v.lock();
assert!(v.get("a").is_err());
v.unlock("pw").unwrap();
assert_eq!(v.get("a").unwrap(), "1");
std::fs::remove_dir_all(dir).unwrap();
}
}
+500
View File
@@ -0,0 +1,500 @@
//! SQLite persistence: profiles, known hosts, preferences, audit log, snippets and IRC channel
//! state. The schema and migration files are the Go app's, so existing databases open unchanged.
use crate::models::*;
use crate::Result;
use chrono::{DateTime, SecondsFormat};
use rusqlite::{Connection, OptionalExtension, Row, params};
use std::path::Path;
use std::sync::Mutex;
const MIGRATIONS: &[(i64, &str)] = &[
(1, include_str!("../migrations/001_initial.sql")),
(2, include_str!("../migrations/002_github_protocol.sql")),
(3, include_str!("../migrations/003_snippets.sql")),
(4, include_str!("../migrations/004_vnc_protocol.sql")),
(5, include_str!("../migrations/005_irc_protocol.sql")),
(6, include_str!("../migrations/006_irc_channel_state.sql")),
(7, include_str!("../migrations/007_serial_protocol.sql")),
];
const PROFILE_COLS: &str = "id, display_name, description, protocol, host, port, username,
tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config, serial_config,
created_at, updated_at";
/// Formats like Go's `time.RFC3339` (whole seconds, `Z` for UTC).
pub fn fmt_time(t: &Time) -> String {
t.to_rfc3339_opts(SecondsFormat::Secs, true)
}
pub fn parse_time(s: &str) -> Time {
DateTime::parse_from_rfc3339(s).unwrap_or_else(|_| zero_time())
}
pub struct Store {
db: Mutex<Connection>,
}
fn json_col<T: serde::Serialize>(v: &Option<T>) -> Option<String> {
v.as_ref().and_then(|v| serde_json::to_string(v).ok())
}
fn parse_col<T: serde::de::DeserializeOwned>(s: Option<String>) -> Option<T> {
s.filter(|s| !s.is_empty() && s != "null").and_then(|s| serde_json::from_str(&s).ok())
}
fn scan_profile(r: &Row) -> rusqlite::Result<Profile> {
let protocol: String = r.get(3)?;
let tags: String = r.get::<_, Option<String>>(7)?.unwrap_or_default();
let last: Option<String> = r.get(12)?;
Ok(Profile {
id: r.get(0)?,
display_name: r.get(1)?,
description: r.get::<_, Option<String>>(2)?.unwrap_or_default(),
protocol: Protocol::from_id(&protocol).unwrap_or_default(),
host: r.get(4)?,
port: r.get(5)?,
username: r.get::<_, Option<String>>(6)?.unwrap_or_default(),
tags: serde_json::from_str::<Option<Vec<String>>>(&tags).ok().flatten().unwrap_or_default(),
folder: r.get::<_, Option<String>>(8)?.unwrap_or_default(),
environment: r.get::<_, Option<String>>(9)?.unwrap_or_default(),
notes: r.get::<_, Option<String>>(10)?.unwrap_or_default(),
favourite: r.get::<_, Option<i64>>(11)?.unwrap_or(0) == 1,
last_connected: last.filter(|s| !s.is_empty()).and_then(|s| DateTime::parse_from_rfc3339(&s).ok()),
connect_timeout: r.get::<_, Option<i64>>(13)?.unwrap_or(0),
read_timeout: r.get::<_, Option<i64>>(14)?.unwrap_or(0),
terminal_font_family: r.get::<_, Option<String>>(15)?.unwrap_or_default(),
terminal_font_size: r.get::<_, Option<i64>>(16)?.unwrap_or(0),
terminal_scrollback: r.get::<_, Option<i64>>(17)?.unwrap_or(0),
terminal_cursor_style: r.get::<_, Option<String>>(18)?.unwrap_or_default(),
ssh: parse_col(r.get(19)?),
telnet: parse_col(r.get(20)?),
raw: parse_col(r.get(21)?),
github: parse_col(r.get(22)?),
vnc: parse_col(r.get(23)?),
irc: parse_col(r.get(24)?),
serial: parse_col(r.get(25)?),
created_at: parse_time(&r.get::<_, String>(26)?),
updated_at: parse_time(&r.get::<_, String>(27)?),
})
}
fn scan_known_host(r: &Row) -> rusqlite::Result<KnownHost> {
Ok(KnownHost {
id: r.get(0)?,
host: r.get(1)?,
port: r.get(2)?,
key_type: r.get(3)?,
fingerprint: r.get(4)?,
public_key: r.get(5)?,
first_seen: parse_time(&r.get::<_, String>(6)?),
last_seen: parse_time(&r.get::<_, String>(7)?),
trusted: r.get::<_, Option<i64>>(8)?.unwrap_or(1) == 1,
})
}
fn scan_snippet(r: &Row) -> rusqlite::Result<Snippet> {
let tags: String = r.get(5)?;
Ok(Snippet {
id: r.get(0)?,
name: r.get(1)?,
command: r.get(2)?,
description: r.get(3)?,
folder: r.get(4)?,
tags: serde_json::from_str::<Option<Vec<String>>>(&tags).ok().flatten().unwrap_or_default(),
created_at: parse_time(&r.get::<_, String>(6)?),
updated_at: parse_time(&r.get::<_, String>(7)?),
})
}
impl Store {
pub fn open(path: &Path) -> Result<Store> {
let db = Connection::open(path).map_err(|e| format!("storage: failed to open database: {e}"))?;
db.busy_timeout(std::time::Duration::from_secs(5))?;
db.pragma_update(None, "journal_mode", "WAL")?;
let s = Store { db: Mutex::new(db) };
s.migrate().map_err(|e| format!("storage: migration failed: {e}"))?;
Ok(s)
}
fn migrate(&self) -> Result<()> {
let db = self.db.lock().unwrap();
db.execute_batch("CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)")?;
let current: i64 = db.query_row("SELECT COALESCE(MAX(version), 0) FROM schema_version", [], |r| r.get(0))?;
for (ver, sql) in MIGRATIONS {
if *ver > current {
db.execute_batch(sql).map_err(|e| format!("failed to apply migration {ver:03}: {e}"))?;
}
}
Ok(())
}
fn profile_params(p: &Profile) -> [Box<dyn rusqlite::ToSql>; 25] {
[
Box::new(p.display_name.clone()),
Box::new(p.description.clone()),
Box::new(p.protocol.id()),
Box::new(p.host.clone()),
Box::new(p.port),
Box::new(p.username.clone()),
Box::new(serde_json::to_string(&p.tags).unwrap_or_else(|_| "[]".into())),
Box::new(p.folder.clone()),
Box::new(p.environment.clone()),
Box::new(p.notes.clone()),
Box::new(p.favourite as i64),
Box::new(p.last_connected.as_ref().map(fmt_time)),
Box::new(p.connect_timeout),
Box::new(p.read_timeout),
Box::new(p.terminal_font_family.clone()),
Box::new(p.terminal_font_size),
Box::new(p.terminal_scrollback),
Box::new(p.terminal_cursor_style.clone()),
Box::new(json_col(&p.ssh)),
Box::new(json_col(&p.telnet)),
Box::new(json_col(&p.raw)),
Box::new(json_col(&p.github)),
Box::new(json_col(&p.vnc)),
Box::new(json_col(&p.irc)),
Box::new(json_col(&p.serial)),
]
}
pub fn create_profile(&self, p: &Profile) -> Result<()> {
let mut args: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(p.id.clone())];
args.extend(Self::profile_params(p));
args.push(Box::new(fmt_time(&p.created_at)));
args.push(Box::new(fmt_time(&p.updated_at)));
let sql = format!("INSERT INTO profiles ({PROFILE_COLS}) VALUES ({})", vec!["?"; 28].join(","));
self.db
.lock()
.unwrap()
.execute(&sql, rusqlite::params_from_iter(args.iter().map(|b| b.as_ref())))
.map_err(|e| format!("storage: failed to create profile: {e}"))?;
Ok(())
}
pub fn update_profile(&self, p: &Profile) -> Result<()> {
let mut args: Vec<Box<dyn rusqlite::ToSql>> = Self::profile_params(p).into_iter().collect();
args.push(Box::new(fmt_time(&p.updated_at)));
args.push(Box::new(p.id.clone()));
let n = self
.db
.lock()
.unwrap()
.execute(
"UPDATE profiles SET display_name=?, description=?, protocol=?, host=?, port=?, username=?,
tags=?, folder=?, environment=?, notes=?, favourite=?, last_connected=?,
connect_timeout=?, read_timeout=?,
terminal_font_family=?, terminal_font_size=?, terminal_scrollback=?, terminal_cursor_style=?,
ssh_config=?, telnet_config=?, raw_config=?, github_config=?, vnc_config=?, irc_config=?, serial_config=?,
updated_at=? WHERE id=?",
rusqlite::params_from_iter(args.iter().map(|b| b.as_ref())),
)
.map_err(|e| format!("storage: failed to update profile: {e}"))?;
if n == 0 {
return Err(format!("storage: profile {:?} not found", p.id).into());
}
Ok(())
}
pub fn get_profile(&self, id: &str) -> Result<Profile> {
self.db
.lock()
.unwrap()
.query_row(&format!("SELECT {PROFILE_COLS} FROM profiles WHERE id = ?"), [id], scan_profile)
.optional()?
.ok_or_else(|| format!("storage: profile {id:?} not found").into())
}
pub fn delete_profile(&self, id: &str) -> Result<()> {
let mut db = self.db.lock().unwrap();
let tx = db.transaction()?;
tx.execute("DELETE FROM irc_channel_state WHERE profile_id = ?", [id])?;
if tx.execute("DELETE FROM profiles WHERE id = ?", [id])? == 0 {
return Err(format!("storage: profile {id:?} not found").into());
}
tx.commit()?;
Ok(())
}
pub fn list_profiles(&self) -> Result<Vec<Profile>> {
let db = self.db.lock().unwrap();
let mut st = db.prepare(&format!("SELECT {PROFILE_COLS} FROM profiles ORDER BY display_name"))?;
let rows = st.query_map([], scan_profile)?.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
pub fn set_favourite(&self, id: &str, fav: bool) -> Result<()> {
let n = self.db.lock().unwrap().execute(
"UPDATE profiles SET favourite = ?, updated_at = ? WHERE id = ?",
params![fav as i64, fmt_time(&now()), id],
)?;
if n == 0 {
return Err(format!("storage: profile {id:?} not found").into());
}
Ok(())
}
pub fn update_last_connected(&self, id: &str) -> Result<()> {
let now = fmt_time(&now());
self.db
.lock()
.unwrap()
.execute("UPDATE profiles SET last_connected = ?, updated_at = ? WHERE id = ?", params![now, now, id])?;
Ok(())
}
/// Imports profiles with a skip/overwrite/duplicate strategy; returns how many were written.
pub fn import_profiles(&self, profiles: Vec<Profile>, strategy: &str) -> Result<usize> {
if !matches!(strategy, "skip" | "overwrite" | "duplicate") {
return Err(format!("storage: unknown import strategy {strategy:?}").into());
}
let mut n = 0;
for mut p in profiles {
let exists = self.get_profile(&p.id).is_ok();
match (strategy, exists) {
("skip", true) => continue,
("overwrite", true) => {
self.update_profile(&p)?;
n += 1;
continue;
}
("duplicate", true) => {
p.id = new_id();
p.created_at = now();
p.updated_at = p.created_at;
}
_ => {}
}
self.create_profile(&p)?;
n += 1;
}
Ok(n)
}
pub fn get_known_host(&self, host: &str, port: i64) -> Result<Option<KnownHost>> {
Ok(self
.db
.lock()
.unwrap()
.query_row(
"SELECT id, host, port, key_type, fingerprint, public_key, first_seen, last_seen, trusted
FROM known_hosts WHERE host = ? AND port = ?",
params![host, port],
scan_known_host,
)
.optional()?)
}
pub fn save_known_host(&self, kh: &KnownHost) -> Result<()> {
self.db.lock().unwrap().execute(
"INSERT INTO known_hosts (id, host, port, key_type, fingerprint, public_key, first_seen, last_seen, trusted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(host, port, key_type) DO UPDATE SET
fingerprint=excluded.fingerprint, public_key=excluded.public_key,
last_seen=excluded.last_seen, trusted=excluded.trusted",
params![
if kh.id.is_empty() { new_id() } else { kh.id.clone() },
kh.host,
kh.port,
kh.key_type,
kh.fingerprint,
kh.public_key,
fmt_time(&kh.first_seen),
fmt_time(&kh.last_seen),
kh.trusted as i64
],
)?;
Ok(())
}
pub fn list_known_hosts(&self) -> Result<Vec<KnownHost>> {
let db = self.db.lock().unwrap();
let mut st = db.prepare(
"SELECT id, host, port, key_type, fingerprint, public_key, first_seen, last_seen, trusted
FROM known_hosts ORDER BY host, port",
)?;
let rows = st.query_map([], scan_known_host)?.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
pub fn delete_known_host(&self, id: &str) -> Result<()> {
if self.db.lock().unwrap().execute("DELETE FROM known_hosts WHERE id = ?", [id])? == 0 {
return Err(format!("storage: known host {id:?} not found").into());
}
Ok(())
}
pub fn add_audit(&self, action: &str, profile_id: &str, session_id: &str, details: &str) {
let _ = self.db.lock().unwrap().execute(
"INSERT INTO audit_log (id, timestamp, action, profile_id, session_id, details) VALUES (?, ?, ?, ?, ?, ?)",
params![new_id(), fmt_time(&now()), action, profile_id, session_id, details],
);
}
pub fn list_audit(&self, limit: i64) -> Result<Vec<AuditEntry>> {
let db = self.db.lock().unwrap();
let mut st = db.prepare(
"SELECT id, timestamp, action, profile_id, session_id, details FROM audit_log ORDER BY timestamp DESC LIMIT ?",
)?;
let rows = st
.query_map([limit], |r| {
Ok(AuditEntry {
id: r.get(0)?,
timestamp: parse_time(&r.get::<_, String>(1)?),
action: r.get(2)?,
profile_id: r.get::<_, Option<String>>(3)?.unwrap_or_default(),
session_id: r.get::<_, Option<String>>(4)?.unwrap_or_default(),
details: r.get::<_, Option<String>>(5)?.unwrap_or_default(),
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
pub fn irc_last_rooms(&self, profile_id: &str) -> Result<Vec<String>> {
let db = self.db.lock().unwrap();
let mut st =
db.prepare("SELECT room_name FROM irc_channel_state WHERE profile_id = ? ORDER BY position, room_name")?;
let rows = st.query_map([profile_id], |r| r.get(0))?.collect::<rusqlite::Result<Vec<String>>>()?;
Ok(rows)
}
pub fn save_irc_last_rooms(&self, profile_id: &str, rooms: &[String]) -> Result<()> {
let profile_id = profile_id.trim();
if profile_id.is_empty() {
return Err("storage: IRC channel state requires a profile ID".into());
}
let rooms = dedup_rooms(&[rooms]);
let mut db = self.db.lock().unwrap();
let tx = db.transaction()?;
tx.execute("DELETE FROM irc_channel_state WHERE profile_id = ?", [profile_id])?;
let now = fmt_time(&now());
for (i, room) in rooms.iter().enumerate() {
tx.execute(
"INSERT INTO irc_channel_state (profile_id, room_name, position, updated_at) VALUES (?, ?, ?, ?)",
params![profile_id, room, i as i64, now],
)?;
}
tx.commit()?;
Ok(())
}
pub fn preferences(&self) -> AppPreferences {
let v: Option<String> = self
.db
.lock()
.unwrap()
.query_row("SELECT value FROM preferences WHERE key = '_all'", [], |r| r.get(0))
.optional()
.ok()
.flatten();
let mut p = v.and_then(|v| serde_json::from_str::<AppPreferences>(&v).ok()).unwrap_or_default();
p.apply_defaults();
p
}
pub fn save_preferences(&self, p: &AppPreferences) -> Result<()> {
self.db.lock().unwrap().execute(
"INSERT INTO preferences (key, value) VALUES ('_all', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
[serde_json::to_string(p)?],
)?;
Ok(())
}
pub fn list_snippets(&self) -> Result<Vec<Snippet>> {
let db = self.db.lock().unwrap();
let mut st = db.prepare(
"SELECT id, name, command, description, folder, tags, created_at, updated_at FROM snippets ORDER BY name ASC",
)?;
let rows = st.query_map([], scan_snippet)?.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
pub fn create_snippet(&self, s: &Snippet) -> Result<()> {
self.db.lock().unwrap().execute(
"INSERT INTO snippets (id, name, command, description, folder, tags, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
params![s.id, s.name, s.command, s.description, s.folder, serde_json::to_string(&s.tags)?, fmt_time(&s.created_at), fmt_time(&s.updated_at)],
)?;
Ok(())
}
pub fn update_snippet(&self, s: &Snippet) -> Result<()> {
self.db.lock().unwrap().execute(
"UPDATE snippets SET name=?, command=?, description=?, folder=?, tags=?, updated_at=? WHERE id=?",
params![s.name, s.command, s.description, s.folder, serde_json::to_string(&s.tags)?, fmt_time(&s.updated_at), s.id],
)?;
Ok(())
}
pub fn delete_snippet(&self, id: &str) -> Result<()> {
self.db.lock().unwrap().execute("DELETE FROM snippets WHERE id = ?", [id])?;
Ok(())
}
}
/// Trims, drops blanks and removes case-insensitive duplicates, keeping first-seen order.
pub fn dedup_rooms(groups: &[&[String]]) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
let mut out = vec![];
for g in groups {
for r in g.iter() {
let r = r.trim();
if !r.is_empty() && seen.insert(r.to_lowercase()) {
out.push(r.to_string());
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn opens_go_database_and_migrates() {
let fx = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/link.db");
let tmp = std::env::temp_dir().join(format!("nexus-store-{}.db", std::process::id()));
std::fs::copy(fx, &tmp).unwrap();
let s = Store::open(&tmp).unwrap();
let ps = s.list_profiles().unwrap();
assert_eq!(ps.len(), 6);
let ssh = ps.iter().find(|p| p.protocol == Protocol::Ssh).unwrap();
assert!(ssh.favourite);
assert_eq!(ssh.tags, ["fixture", "ssh"]);
assert_eq!(ssh.last_connected.map(|t| fmt_time(&t)).as_deref(), Some("2026-01-02T03:04:05Z"));
let c = ssh.ssh.as_ref().unwrap();
assert_eq!(c.env_vars["LANG"], "en_GB.UTF-8");
assert_eq!(c.login_scripts.len(), 2);
assert_eq!(c.port_forwards[0].bind_port, 8080);
let irc = ps.iter().find(|p| p.protocol == Protocol::Irc).unwrap();
assert_eq!(s.irc_last_rooms(&irc.id).unwrap(), ["#rust", "#nexus"]);
assert_eq!(s.list_known_hosts().unwrap()[0].fingerprint, "SHA256:abc");
assert_eq!(s.list_snippets().unwrap()[0].command, "uptime");
assert_eq!(s.preferences().terminal_theme, "dracula");
// Migration 007 lets serial profiles in.
let mut p = Profile::new(Protocol::Serial);
p.host = "/dev/ttyUSB0".into();
p.normalize();
s.create_profile(&p).unwrap();
assert_eq!(s.get_profile(&p.id).unwrap(), p.clone().with_secs());
// Round trip of an existing row is lossless.
s.update_profile(ssh).unwrap();
assert_eq!(&s.get_profile(&ssh.id).unwrap(), ssh);
drop(s);
let _ = std::fs::remove_file(&tmp);
}
impl Profile {
/// Storage keeps whole seconds.
fn with_secs(mut self) -> Self {
self.created_at = parse_time(&fmt_time(&self.created_at));
self.updated_at = parse_time(&fmt_time(&self.updated_at));
self
}
}
}