//! 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}; /// The release version (`NEXUS_VERSION` from release.sh, e.g. "v1.2.0-abc1234"), else the crate version. pub const VERSION: &str = match option_env!("NEXUS_VERSION") { Some(v) => v, None => 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() } }