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.
This commit is contained in:
Andy Dixon
2026-10-12 17:08:37 +01:00
parent 02bd237636
commit aacd44a275
9 changed files with 91 additions and 2 deletions
+1
View File
@@ -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`.
+55
View File
@@ -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"
+2
View File
@@ -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"] }
+22
View File
@@ -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();
}
+1 -1
View File
@@ -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()));
+1
View File
@@ -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"
+1
View File
@@ -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),
+7
View File
@@ -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<Self>, id: &str, epoch: u64, p: &Profile, prompt: Prompt) -> Result<Client> {
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()) {
+1 -1
View File
@@ -48,7 +48,7 @@ fn json_col<T: serde::Serialize>(v: &Option<T>) -> Option<String> {
fn parse_col<T: serde::de::DeserializeOwned>(s: Option<String>) -> Option<T> {
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<Profile> {