From aacd44a2758d27cc8c4b28d26cf539c589e08d56 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Mon, 12 Oct 2026 17:08:37 +0100 Subject: [PATCH] feat: diagnostic log (tracing) The app now writes a diagnostic log to nexus.log in the data directory (0600, rotated at 5 MiB): connection attempts and results, remote closes, reconnect scheduling, host-key/certificate checks with the policy applied, and credentials that couldn't be stored (kind only). Credentials and terminal data are never logged. RUST_LOG sets the level and also mirrors the log to stderr. The remaining eprintln! diagnostics in storage and URL handling go through it too. --- docs/user-manual.md | 1 + rust/Cargo.lock | 55 +++++++++++++++++++++++++++ rust/app/Cargo.toml | 2 + rust/app/src/main.rs | 22 +++++++++++ rust/app/src/widgets.rs | 2 +- rust/core/Cargo.toml | 1 + rust/core/src/sessions/credentials.rs | 1 + rust/core/src/sessions/mod.rs | 7 ++++ rust/core/src/storage.rs | 2 +- 9 files changed, 91 insertions(+), 2 deletions(-) diff --git a/docs/user-manual.md b/docs/user-manual.md index df49db1..4607ad0 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -434,6 +434,7 @@ The directory holds: - `recordings/` — session recordings - `triggers.json` — trigger rules - `sftp-bookmarks.json` — SFTP bookmarks +- `nexus.log` — the diagnostic log (owner-only, rotated to `nexus.log.1` at 5 MiB): connections, reconnects, host-key and certificate checks and errors. Credentials and terminal contents are never logged. Set `RUST_LOG` (for example `RUST_LOG=debug`) for more detail; it then also goes to the terminal you started Nexus Terminal from. Credentials saved in the OS keychain are stored under the service name `NexusTerminal`. diff --git a/rust/Cargo.lock b/rust/Cargo.lock index d08fdbf..af571ef 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -3225,6 +3225,15 @@ dependencies = [ "libc", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "md-5" version = "0.10.6" @@ -3449,6 +3458,8 @@ dependencies = [ "rfd", "serde", "serde_json", + "tracing", + "tracing-subscriber", "winresource", "zeroize", ] @@ -3483,6 +3494,7 @@ dependencies = [ "sha2 0.11.0", "tiny_http", "tokio", + "tracing", "ureq", "uuid", "webpki-roots", @@ -3554,6 +3566,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num" version = "0.4.3" @@ -5507,6 +5528,15 @@ dependencies = [ "sponge-cursor", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shared_library" version = "0.1.9" @@ -5984,6 +6014,15 @@ dependencies = [ "syn 3.0.7", ] +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + [[package]] name = "tiff" version = "0.11.3" @@ -6198,6 +6237,22 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "thread_local", + "tracing", + "tracing-core", +] + [[package]] name = "type-map" version = "0.5.1" diff --git a/rust/app/Cargo.toml b/rust/app/Cargo.toml index c2c2fed..cd99e14 100644 --- a/rust/app/Cargo.toml +++ b/rust/app/Cargo.toml @@ -14,6 +14,8 @@ eframe = { version = "0.36", default-features = false, features = ["default_font egui = "0.36" parking_lot = "0.12" zeroize = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "env-filter", "std", "ansi"] } egui-phosphor = "0.14" rfd = "0.17" image = { version = "0.25", default-features = false, features = ["png"] } diff --git a/rust/app/src/main.rs b/rust/app/src/main.rs index 2f7a3fe..00cc0ce 100644 --- a/rust/app/src/main.rs +++ b/rust/app/src/main.rs @@ -114,6 +114,8 @@ fn main() -> eframe::Result { std::process::exit(1); } }; + init_logging(&svc.config.data_dir); + tracing::info!(version = nexus_core::service::VERSION, "starting"); let headless = opts.screenshot.is_some(); let native = eframe::NativeOptions { persist_window: !headless, @@ -139,3 +141,23 @@ fn window_icon() -> egui::IconData { let img = image::load_from_memory(chrome::LOGO_PNG).map(|i| i.to_rgba8()).unwrap_or_default(); egui::IconData { width: img.width(), height: img.height(), rgba: img.into_raw() } } + +/// Diagnostics go to `nexus.log` in the data directory (rotated at 5 MiB), and to stderr too when +/// RUST_LOG is set. RUST_LOG also sets the level (default: info for Nexus, warn for libraries). +/// Connection lifecycle, host-key checks and errors are logged; credentials and terminal data never. +fn init_logging(data_dir: &std::path::Path) { + use tracing_subscriber::prelude::*; + let path = data_dir.join("nexus.log"); + if std::fs::metadata(&path).is_ok_and(|m| m.len() > 5 << 20) { + let _ = std::fs::rename(&path, data_dir.join("nexus.log.1")); + } + let mut opts = std::fs::OpenOptions::new(); + opts.create(true).append(true); + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut opts, 0o600); + let file = opts.open(&path).ok().map(|f| tracing_subscriber::fmt::layer().with_ansi(false).with_writer(std::sync::Mutex::new(f))); + let stderr = std::env::var_os("RUST_LOG").map(|_| tracing_subscriber::fmt::layer().with_writer(std::io::stderr)); + let filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn,nexus_terminal=info,nexus_core=info")); + let _ = tracing_subscriber::registry().with(filter).with(file).with(stderr).try_init(); +} diff --git a/rust/app/src/widgets.rs b/rust/app/src/widgets.rs index d2d2d20..63a7e1b 100644 --- a/rust/app/src/widgets.rs +++ b/rust/app/src/widgets.rs @@ -136,7 +136,7 @@ pub fn is_web_url(url: &str) -> bool { /// Opens `url` in the browser if it is a web link; returns false (and opens nothing) otherwise. pub fn open_web_url(ctx: &egui::Context, url: &str) -> bool { if !is_web_url(url) { - eprintln!("refusing to open non-web URL {url:?}"); + tracing::warn!(url, "refusing to open a non-web URL"); return false; } ctx.open_url(egui::OpenUrl::new_tab(url.trim())); diff --git a/rust/core/Cargo.toml b/rust/core/Cargo.toml index 75bb549..f79f149 100644 --- a/rust/core/Cargo.toml +++ b/rust/core/Cargo.toml @@ -23,6 +23,7 @@ hmac = "0.13.0" sha1 = "0.11.0" zeroize = "1" parking_lot = "0.12" +tracing = "0.1" portable-pty = "0.9.0" serialport = { version = "4", default-features = false } des = "0.9" diff --git a/rust/core/src/sessions/credentials.rs b/rust/core/src/sessions/credentials.rs index 7c1b5ef..a3cca15 100644 --- a/rust/core/src/sessions/credentials.rs +++ b/rust/core/src/sessions/credentials.rs @@ -149,6 +149,7 @@ impl Manager { Err(_) => Err("host key prompt failed: host key prompt timed out waiting for user response".into()), } }; + tracing::info!(host = %k.host, port = k.port, key_type = %k.key_type, fingerprint = %k.fingerprint, ?policy, known = stored.is_some(), "checking host key"); match (policy, stored) { (HostKeyPolicy::Insecure, None) | (HostKeyPolicy::AcceptNew, None) => save(), (HostKeyPolicy::Insecure, Some(s)) => touch(s), diff --git a/rust/core/src/sessions/mod.rs b/rust/core/src/sessions/mod.rs index d02c76e..228558d 100644 --- a/rust/core/src/sessions/mod.rs +++ b/rust/core/src/sessions/mod.rs @@ -346,6 +346,7 @@ impl Manager { } (Err(_), None) => {} (Err(e), Some(s)) => { + tracing::warn!(session = id, error = %e, "connection failed"); s.info.state = SessionState::Error; s.info.error = e.to_string(); attempt_failed(&s.session_secrets, &e.to_string()); @@ -353,6 +354,7 @@ impl Manager { self.emit_state(id, SessionState::Error, &e.to_string()); } (Ok(client), Some(s)) => { + tracing::info!(session = id, "connected"); let typed = std::mem::take(&mut s.session_secrets.lock().pending); s.client = client.clone(); s.info.state = SessionState::Connected; @@ -365,6 +367,7 @@ impl Manager { } for (key, value) in typed { if let Err(e) = self.secrets.set_checked(&key, &value) { + tracing::warn!(session = id, credential = key.rsplit(':').next().unwrap_or(""), error = %e, "credential not saved"); self.emit(Event::Warning { session: id.into(), message: format!("Credential not saved: {e}") }); } } @@ -375,6 +378,7 @@ impl Manager { } fn connect_protocol(self: &Arc, id: &str, epoch: u64, p: &Profile, prompt: Prompt) -> Result { + tracing::info!(session = id, protocol = p.protocol.id(), host = %p.host, port = p.port, "connecting"); let _ = self.with(id, |s| { let mut st = s.session_secrets.lock(); st.pending.clear(); @@ -510,6 +514,7 @@ impl Manager { s.profile.clone() }; self.store.add_audit("disconnect", "", id, "Session disconnected"); + tracing::info!(session = id, "remote closed the session"); if let Some(p) = profile.filter(|p| p.auto_reconnect()) { self.schedule_reconnect(id, epoch, p); return; @@ -525,9 +530,11 @@ impl Manager { let attempt = self.with(id, |s| { s.reconnect_count += 1; if s.reconnect_count > max { + tracing::warn!(session = id, tries = max, "giving up reconnecting"); s.info.state = SessionState::Disconnected; return None; } + tracing::info!(session = id, attempt = s.reconnect_count, of = max, "scheduling reconnect"); s.info.state = SessionState::Reconnecting; s.info.error.clear(); if let Some(c) = s.cancel_reconnect.replace(cancel.clone()) { diff --git a/rust/core/src/storage.rs b/rust/core/src/storage.rs index 9fb0648..cfad031 100644 --- a/rust/core/src/storage.rs +++ b/rust/core/src/storage.rs @@ -48,7 +48,7 @@ fn json_col(v: &Option) -> Option { fn parse_col(s: Option) -> Option { let s = s.filter(|s| !s.is_empty() && s != "null")?; - serde_json::from_str(&s).inspect_err(|e| eprintln!("storage: ignoring unreadable profile config: {e}")).ok() + serde_json::from_str(&s).inspect_err(|e| tracing::warn!(error = %e, "ignoring unreadable profile config")).ok() } fn scan_profile(r: &Row) -> rusqlite::Result {