|
|
|
@@ -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<Store>,
|
|
|
|
|
pub secrets: Arc<Secrets>,
|
|
|
|
|
pub sessions: Arc<Manager>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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<Service> {
|
|
|
|
|
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<Profile> {
|
|
|
|
|
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<Profile> {
|
|
|
|
|
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<Profile> {
|
|
|
|
|
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<Profile> {
|
|
|
|
|
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<String> {
|
|
|
|
|
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<String> {
|
|
|
|
|
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<String> {
|
|
|
|
|
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<KnownHost> {
|
|
|
|
|
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<Snippet> {
|
|
|
|
|
self.store.list_snippets().unwrap_or_default()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn save_snippet(&self, mut s: Snippet) -> Result<Snippet> {
|
|
|
|
|
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<Profile>, strategy: &str) -> Result<usize> {
|
|
|
|
|
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<String> {
|
|
|
|
|
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<usize> {
|
|
|
|
|
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<Option<Profile>> = 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::<std::result::Result<Vec<_>, _>>()?;
|
|
|
|
|
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<PathBuf> {
|
|
|
|
|
let ps: Vec<Profile> = 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<usize> {
|
|
|
|
|
let ps = exportimport::decrypt_and_import(path, password)?;
|
|
|
|
|
self.import(ps, strategy)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn import_ssh_config(&self, path: Option<&Path>, folder: &str) -> Result<usize> {
|
|
|
|
|
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<PathBuf> {
|
|
|
|
|
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<Vec<SshKeyInfo>> {
|
|
|
|
|
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<String> = 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<Vec<FileEntry>> {
|
|
|
|
|
sftp::list(&self.sessions.ssh(sid)?.sftp()?, path)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn sftp_pwd(&self, sid: &str) -> Result<String> {
|
|
|
|
|
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<String> {
|
|
|
|
|
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<u128> {
|
|
|
|
|
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<updater::UpdateInfo> {
|
|
|
|
|
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<AuditEntry> {
|
|
|
|
|
self.store.list_audit(limit).unwrap_or_default()
|
|
|
|
|
}
|
|
|
|
|
}
|