diff --git a/rust/core/src/lib.rs b/rust/core/src/lib.rs index 9b1131f..2a4ec6e 100644 --- a/rust/core/src/lib.rs +++ b/rust/core/src/lib.rs @@ -9,6 +9,7 @@ pub mod keyfiles; pub mod models; pub mod recording; pub mod secrets; +pub mod service; pub mod sessions; pub mod sshconfig; pub mod storage; diff --git a/rust/core/src/service.rs b/rust/core/src/service.rs new file mode 100644 index 0000000..2d385e7 --- /dev/null +++ b/rust/core/src/service.rs @@ -0,0 +1,402 @@ +//! The app-facing API (a port of internal/api.Service minus the Wails plumbing): profiles, +//! sessions, credentials, known hosts, preferences, snippets, import/export, SSH keys, updates. +//! File dialogs live in the UI; methods here take paths. + +use crate::clients::sftp::{self, FileEntry}; +use crate::config::AppConfig; +use crate::models::*; +use crate::secrets::{KEYCHAIN_SERVICE, Keychain, LEGACY_KEYCHAIN_SERVICE, Secrets, Vault}; +use crate::sessions::Manager; +use crate::storage::Store; +use crate::{Result, exportimport, keyfiles, sshconfig, updater, validation}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); +const MAX_IMPORT_JSON_BYTES: usize = 5 * 1024 * 1024; + +pub struct Service { + pub config: AppConfig, + pub store: Arc, + pub secrets: Arc, + pub sessions: Arc, +} + +#[derive(Clone, Debug)] +pub struct SshKeyInfo { + pub name: String, + pub kind: String, + pub public_key: String, + pub private_key_path: PathBuf, +} + +impl Service { + pub fn open(config: AppConfig) -> Result { + let store = Arc::new(Store::open(&config.db_path)?); + let secrets = Arc::new(Secrets::new(Vault::new(config.vault_dir.clone()), Keychain::new(KEYCHAIN_SERVICE, Some(LEGACY_KEYCHAIN_SERVICE)))); + let sessions = Manager::new(store.clone(), secrets.clone(), config.recordings_dir.clone()); + Ok(Service { config, store, secrets, sessions }) + } + + // --- profiles ----------------------------------------------------------------------- + + pub fn create_profile(&self, mut p: Profile) -> Result { + if p.id.is_empty() { + p.id = new_id(); + } + p.normalize(); + p.created_at = now(); + p.updated_at = p.created_at; + validation::validate_profile(&p)?; + self.store.create_profile(&p).map_err(|e| format!("failed to create profile: {e}"))?; + self.store.add_audit("profile.create", &p.id, "", &format!("Created profile {:?}", p.display_name)); + Ok(p) + } + + pub fn update_profile(&self, mut p: Profile) -> Result { + p.normalize(); + p.updated_at = now(); + validation::validate_profile(&p)?; + self.store.update_profile(&p).map_err(|e| format!("failed to update profile: {e}"))?; + self.store.add_audit("profile.update", &p.id, "", &format!("Updated profile {:?}", p.display_name)); + Ok(p) + } + + pub fn delete_profile(&self, id: &str) -> Result<()> { + let p = self.store.get_profile(id).map_err(|e| format!("profile not found: {e}"))?; + self.store.delete_profile(id).map_err(|e| format!("failed to delete profile: {e}"))?; + self.secrets.delete_profile(id); + self.store.add_audit("profile.delete", id, "", &format!("Deleted profile {:?}", p.display_name)); + Ok(()) + } + + pub fn duplicate_profile(&self, id: &str) -> Result { + let mut d = self.store.get_profile(id).map_err(|e| format!("profile not found: {e}"))?; + d.id = new_id(); + d.display_name += " (copy)"; + d.created_at = now(); + d.updated_at = d.created_at; + d.last_connected = None; + self.store.create_profile(&d).map_err(|e| format!("failed to duplicate profile: {e}"))?; + self.store.add_audit("profile.create", &d.id, "", &format!("Created profile {:?}", d.display_name)); + Ok(d) + } + + pub fn profiles(&self) -> Vec { + self.store.list_profiles().unwrap_or_default() + } + + pub fn set_favourite(&self, id: &str, fav: bool) -> Result<()> { + self.store.set_favourite(id, fav) + } + + // --- sessions ----------------------------------------------------------------------- + + /// Opens a saved profile; returns the new session id. + pub fn connect_profile(&self, id: &str) -> Result { + let mut p = self.store.get_profile(id).map_err(|e| format!("profile not found: {e}"))?; + let sid = self.sessions.connect(p.clone()); + p.last_connected = Some(now()); + p.updated_at = now(); + let _ = self.store.update_profile(&p); + Ok(sid) + } + + /// Opens an unsaved profile (Quick Connect). + pub fn connect_quick(&self, mut p: Profile) -> String { + if p.id.is_empty() { + p.id = new_id(); + } + p.normalize(); + self.sessions.connect(p) + } + + pub fn connect_local(&self) -> String { + let shell = self.store.preferences().local_shell; + self.sessions.connect_local(&shell) + } + + pub fn reconnect(&self, id: &str) -> Result { + self.sessions.reconnect(id) + } + + // --- credentials -------------------------------------------------------------------- + + pub fn set_profile_secret(&self, profile_id: &str, kind: &str, value: &str) -> Result<()> { + self.secrets.set_checked(&format!("profile:{profile_id}:{kind}"), value) + } + + pub fn has_totp(&self, profile_id: &str) -> bool { + self.secrets.get(&format!("profile:{profile_id}:totp")).is_some() + } + + pub fn totp_code(&self, profile_id: &str) -> Result { + let s = self.secrets.get(&format!("profile:{profile_id}:totp")).ok_or("no TOTP secret configured for this profile")?; + crate::clients::ssh::totp_code(&s).map_err(|e| format!("failed to generate TOTP code: {e}").into()) + } + + pub fn delete_totp(&self, profile_id: &str) { + self.secrets.delete(&format!("profile:{profile_id}:totp")); + } + + // --- known hosts, prefs, snippets, audit -------------------------------------------- + + pub fn known_hosts(&self) -> Vec { + self.store.list_known_hosts().unwrap_or_default() + } + + pub fn delete_known_host(&self, id: &str) -> Result<()> { + self.store.delete_known_host(id) + } + + pub fn preferences(&self) -> AppPreferences { + self.store.preferences() + } + + pub fn save_preferences(&self, p: &AppPreferences) -> Result<()> { + self.store.save_preferences(p) + } + + pub fn snippets(&self) -> Vec { + self.store.list_snippets().unwrap_or_default() + } + + pub fn save_snippet(&self, mut s: Snippet) -> Result { + if s.name.trim().is_empty() || s.command.trim().is_empty() { + return Err("snippet name and command are required".into()); + } + s.updated_at = now(); + if s.id.is_empty() { + s.id = new_id(); + s.created_at = s.updated_at; + self.store.create_snippet(&s)?; + } else { + self.store.update_snippet(&s)?; + } + Ok(s) + } + + pub fn delete_snippet(&self, id: &str) -> Result<()> { + self.store.delete_snippet(id) + } + + // --- import / export ---------------------------------------------------------------- + + fn import(&self, mut profiles: Vec, strategy: &str) -> Result { + if !matches!(strategy, "skip" | "overwrite" | "duplicate") { + return Err(format!("unknown import strategy {strategy:?} (expected skip, overwrite, or duplicate)").into()); + } + for (i, p) in profiles.iter_mut().enumerate() { + p.normalize(); + validation::validate_profile(p).map_err(|e| format!("profile import entry {i} is invalid: {e}"))?; + } + let mut n = 0; + for mut p in profiles { + let exists = self.store.get_profile(&p.id).is_ok(); + let ok = match strategy { + "skip" if exists => continue, + "overwrite" if exists => { + p.updated_at = now(); + self.store.update_profile(&p).is_ok() + } + "duplicate" => { + p.id = new_id(); + p.created_at = now(); + p.updated_at = p.created_at; + self.store.create_profile(&p).is_ok() + } + _ => self.store.create_profile(&p).is_ok(), + }; + n += ok as usize; + } + self.store.add_audit("import", "", "", &format!("Imported {n} profile(s)")); + Ok(n) + } + + /// Plain JSON (as the old Export button wrote): secrets are redacted unless asked for. + pub fn export_json(&self, include_secrets: bool) -> Result { + let mut ps = self.profiles(); + if !include_secrets { + for p in &mut ps { + if let Some(s) = &mut p.ssh { + s.proxy_password.clear(); + s.totp_secret.clear(); + } + } + } + self.store.add_audit("export", "", "", &format!("Exported {} profile(s) {}", ps.len(), if include_secrets { "with secrets" } else { "without secrets" })); + Ok(serde_json::to_string_pretty(&ps)?) + } + + pub fn import_json(&self, json: &str, strategy: &str) -> Result { + if json.len() > MAX_IMPORT_JSON_BYTES { + return Err(format!("import data is too large (max {MAX_IMPORT_JSON_BYTES} bytes)").into()); + } + let ps: Vec> = serde_json::from_str(json).map_err(|e| format!("invalid JSON: {e}"))?; + let ps = ps.into_iter().enumerate().map(|(i, p)| p.ok_or_else(|| format!("profile import entry {i} is null"))).collect::, _>>()?; + self.import(ps, strategy) + } + + /// Encrypted export of the given profiles (all when empty) to `path` (`.nexus` appended). + pub fn export_encrypted(&self, ids: &[String], password: &str, path: &Path) -> Result { + let ps: Vec = if ids.is_empty() { self.profiles() } else { ids.iter().filter_map(|id| self.store.get_profile(id).ok()).collect() }; + if ps.is_empty() { + return Err("no profiles to export".into()); + } + let mut path = path.to_path_buf(); + if path.extension().and_then(|e| e.to_str()) != Some(exportimport::EXTENSION) { + path.as_mut_os_string().push(format!(".{}", exportimport::EXTENSION)); + } + exportimport::encrypt_and_export(&ps, password, &path)?; + self.store.add_audit("export", "", "", &format!("Exported {} profile(s) without secrets", ps.len())); + Ok(path) + } + + /// Imports a `.nexus` (or legacy `.link`) export. + pub fn import_encrypted(&self, path: &Path, password: &str, strategy: &str) -> Result { + let ps = exportimport::decrypt_and_import(path, password)?; + self.import(ps, strategy) + } + + pub fn import_ssh_config(&self, path: Option<&Path>, folder: &str) -> Result { + let path = path.map(Path::to_path_buf).or_else(sshconfig::default_config_path).ok_or("no ~/.ssh/config found")?; + let blocks = sshconfig::parse_file(&path)?; + let mut n = 0; + for p in sshconfig::to_profiles(&blocks, folder) { + n += self.store.create_profile(&p).is_ok() as usize; + } + self.store.add_audit("import", "", "", &format!("Imported {n} profile(s)")); + Ok(n) + } + + // --- SSH keys (via ssh-keygen, as before) ------------------------------------------- + + fn ssh_dir() -> Result { + let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).ok_or("unable to determine home directory")?; + Ok(PathBuf::from(home).join(".ssh")) + } + + pub fn ssh_key_pairs(&self) -> Result> { + let dir = Self::ssh_dir()?; + let Ok(rd) = std::fs::read_dir(&dir) else { return Ok(vec![]) }; + let mut out = vec![]; + let mut names: Vec = rd.flatten().filter_map(|e| e.file_name().to_str().and_then(|n| n.strip_suffix(".pub")).map(String::from)).collect(); + names.sort(); + for base in names { + let priv_path = dir.join(&base); + let Ok(pubk) = std::fs::read_to_string(dir.join(format!("{base}.pub"))) else { continue }; + if !priv_path.is_file() || pubk.len() > 64 * 1024 { + continue; + } + let pubk = pubk.trim().to_string(); + let algo = pubk.split_whitespace().next().unwrap_or(""); + let kind = ["ed25519", "ecdsa", "rsa", "dsa"].into_iter().find(|k| algo.contains(k)).unwrap_or("unknown"); + out.push(SshKeyInfo { name: base, kind: kind.into(), public_key: pubk, private_key_path: priv_path }); + } + Ok(out) + } + + pub fn generate_ssh_key(&self, name: &str, kind: &str, bits: u32, passphrase: &str) -> Result<()> { + let name = keyfiles::validate_ssh_key_name(name)?; + let kind = if kind.trim().is_empty() { "ed25519".to_string() } else { kind.trim().to_lowercase() }; + let bits = match kind.as_str() { + "ed25519" | "ecdsa" => bits, + "rsa" if bits == 0 => 4096, + "rsa" if [2048, 3072, 4096].contains(&bits) => bits, + "rsa" => return Err(format!("unsupported RSA key size {bits} (use 2048, 3072, or 4096)").into()), + k => return Err(format!("unsupported SSH key type {k:?}").into()), + }; + let dir = Self::ssh_dir()?; + std::fs::create_dir_all(&dir)?; + let path = dir.join(&name); + if path.exists() { + return Err(format!("key {name:?} already exists").into()); + } + let mut cmd = std::process::Command::new("ssh-keygen"); + cmd.args(["-q", "-t", &kind, "-f"]).arg(&path).args(["-C", &format!("{name}@nexus-terminal"), "-N", passphrase]); + if kind == "rsa" { + cmd.args(["-b", &bits.to_string()]); + } + let out = cmd.stdin(std::process::Stdio::null()).output().map_err(|e| format!("ssh-keygen failed: {e}"))?; + if !out.status.success() { + return Err(format!("ssh-keygen failed: {}", String::from_utf8_lossy(&out.stderr).trim()).into()); + } + Ok(()) + } + + pub fn delete_ssh_key(&self, name: &str) -> Result<()> { + let name = keyfiles::validate_ssh_key_name(name)?; + let p = Self::ssh_dir()?.join(&name); + let mut errs = vec![]; + for (what, f) in [("private key", p.clone()), ("public key", p.with_extension("pub"))] { + if let Err(e) = std::fs::remove_file(&f) + && e.kind() != std::io::ErrorKind::NotFound + { + errs.push(format!("{what}: {e}")); + } + } + if errs.is_empty() { Ok(()) } else { Err(format!("failed to delete: {}", errs.join("; ")).into()) } + } + + // --- SFTP --------------------------------------------------------------------------- + + pub fn sftp_list(&self, sid: &str, path: &str) -> Result> { + sftp::list(&self.sessions.ssh(sid)?.sftp()?, path) + } + + pub fn sftp_pwd(&self, sid: &str) -> Result { + sftp::pwd(&self.sessions.ssh(sid)?.sftp()?) + } + + pub fn sftp_mkdir(&self, sid: &str, path: &str) -> Result<()> { + sftp::mkdir(&self.sessions.ssh(sid)?.sftp()?, path) + } + + pub fn sftp_remove(&self, sid: &str, path: &str) -> Result<()> { + sftp::remove(&self.sessions.ssh(sid)?.sftp()?, path) + } + + pub fn sftp_rename(&self, sid: &str, from: &str, to: &str) -> Result<()> { + sftp::rename(&self.sessions.ssh(sid)?.sftp()?, from, to) + } + + /// Uploads a local file into `remote_dir`, keeping its file name. + pub fn sftp_upload_path(&self, sid: &str, remote_dir: &str, local: &Path) -> Result { + let name = keyfiles::safe_sftp_upload_name(&local.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default())?; + let remote = sftp::join(remote_dir, &name); + self.sessions.sftp_upload(sid, local, &remote)?; + Ok(remote) + } + + // --- misc --------------------------------------------------------------------------- + + /// TCP reachability and latency in milliseconds. + pub fn test_connection(&self, host: &str, port: i64) -> Result { + if host.is_empty() { + return Err("host is required".into()); + } + if !(1..=65535).contains(&port) { + return Err("port must be between 1 and 65535".into()); + } + let addr = if host.contains(':') { format!("[{host}]:{port}") } else { format!("{host}:{port}") }; + let start = Instant::now(); + let mut last = None; + for sa in std::net::ToSocketAddrs::to_socket_addrs(&addr.as_str())? { + match std::net::TcpStream::connect_timeout(&sa, Duration::from_secs(10)) { + Ok(_) => return Ok(start.elapsed().as_millis()), + Err(e) => last = Some(e), + } + } + Err(format!("connection to {addr} failed after {}ms: {}", start.elapsed().as_millis(), last.map_or("no addresses".into(), |e| e.to_string())).into()) + } + + pub fn check_for_updates(&self) -> Result { + let url = option_env!("NEXUS_UPDATE_URL").unwrap_or(updater::DEFAULT_UPDATE_URL); + updater::check_url(&updater::expand_update_url(url), VERSION) + } + + pub fn audit_entries(&self, limit: i64) -> Vec { + self.store.list_audit(limit).unwrap_or_default() + } +} diff --git a/rust/core/src/sessions.rs b/rust/core/src/sessions.rs index a4a237e..6264555 100644 --- a/rust/core/src/sessions.rs +++ b/rust/core/src/sessions.rs @@ -192,8 +192,9 @@ impl Manager { /// Opens a session for `profile` and connects it on a worker thread. Returns the session id /// immediately; progress arrives as `Event::State`. - pub fn connect(self: &Arc, profile: Profile, prompt: Prompt) -> String { + pub fn connect(self: &Arc, profile: Profile) -> String { let id = new_id(); + let prompt = self.prompt_for(&id, "Credentials Required", Duration::from_secs(300)); let info = SessionInfo { id: id.clone(), profile_id: profile.id.clone(), @@ -740,13 +741,13 @@ impl Manager { /// Closes and re-opens a session from its profile, keeping the tab name and pin; returns the /// new session id. - pub fn reconnect(self: &Arc, id: &str, prompt: Prompt) -> Result { + pub fn reconnect(self: &Arc, id: &str) -> Result { let s = self.sessions.lock().unwrap().remove(id).ok_or_else(|| format!("sessions: session {id:?} not found"))?; let (profile, tab, pinned, proto, shell) = (s.profile.clone(), s.info.tab_name.clone(), s.info.pinned, s.info.protocol, s.local_shell.clone()); self.close_session(s); let new = match (proto, profile) { (Protocol::Local, _) => self.connect_local(&shell), - (_, Some(p)) => self.connect(p, prompt), + (_, Some(p)) => self.connect(p), _ => return err("sessions: nothing to reconnect".into()), }; let _ = self.with(&new, |s| { diff --git a/rust/core/tests/sessions.rs b/rust/core/tests/sessions.rs index aba647e..be579fc 100644 --- a/rust/core/tests/sessions.rs +++ b/rust/core/tests/sessions.rs @@ -2,7 +2,7 @@ use nexus_core::models::{Profile, Protocol, SessionState}; use nexus_core::secrets::{Keychain, Secrets, Vault}; -use nexus_core::sessions::{Event, Manager, Prompt}; +use nexus_core::sessions::{Event, Manager}; use nexus_core::storage::Store; use std::io::{Read, Write}; use std::net::TcpListener; @@ -25,10 +25,6 @@ fn manager(name: &str) -> (Arc, mpsc::Receiver, std::path::PathB (m, rx, dir) } -fn no_prompt() -> Prompt { - Arc::new(|_| Err("no prompt in tests".into())) -} - /// Waits for an event matching `f`, answering host-key prompts with `accept`. fn wait(rx: &mpsc::Receiver, mut f: impl FnMut(&Event) -> Option) -> T { let deadline = std::time::Instant::now() + Duration::from_secs(15); @@ -101,7 +97,7 @@ fn raw_session_and_auto_reconnect() { p.host = "127.0.0.1".into(); p.port = port as i64; p.raw.as_mut().unwrap().auto_reconnect = true; - let id = m.connect(p, no_prompt()); + let id = m.connect(p); wait(&rx, |e| state(e, SessionState::Connected)); m.raw_write_text(&id, "ping").unwrap(); wait(&rx, |e| matches!(e, Event::RawData { direction, data, .. } if direction == "recv" && data.starts_with(b"ping")).then_some(())); @@ -124,7 +120,7 @@ fn connect_failure_reports_error_state() { let mut p = Profile::new(Protocol::Telnet); p.host = "127.0.0.1".into(); p.port = port as i64; - let id = m.connect(p, no_prompt()); + let id = m.connect(p); let err = wait(&rx, |e| state(e, SessionState::Error)); assert!(err.starts_with(&id) && err.len() > id.len() + 1, "{err}"); let _ = std::fs::remove_dir_all(dir);