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:
@@ -434,6 +434,7 @@ The directory holds:
|
|||||||
- `recordings/` — session recordings
|
- `recordings/` — session recordings
|
||||||
- `triggers.json` — trigger rules
|
- `triggers.json` — trigger rules
|
||||||
- `sftp-bookmarks.json` — SFTP bookmarks
|
- `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`.
|
Credentials saved in the OS keychain are stored under the service name `NexusTerminal`.
|
||||||
|
|
||||||
|
|||||||
Generated
+55
@@ -3225,6 +3225,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "matchers"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
|
||||||
|
dependencies = [
|
||||||
|
"regex-automata",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "md-5"
|
name = "md-5"
|
||||||
version = "0.10.6"
|
version = "0.10.6"
|
||||||
@@ -3449,6 +3458,8 @@ dependencies = [
|
|||||||
"rfd",
|
"rfd",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
"winresource",
|
"winresource",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
@@ -3483,6 +3494,7 @@ dependencies = [
|
|||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
"tiny_http",
|
"tiny_http",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tracing",
|
||||||
"ureq",
|
"ureq",
|
||||||
"uuid",
|
"uuid",
|
||||||
"webpki-roots",
|
"webpki-roots",
|
||||||
@@ -3554,6 +3566,15 @@ dependencies = [
|
|||||||
"minimal-lexical",
|
"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]]
|
[[package]]
|
||||||
name = "num"
|
name = "num"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
@@ -5507,6 +5528,15 @@ dependencies = [
|
|||||||
"sponge-cursor",
|
"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]]
|
[[package]]
|
||||||
name = "shared_library"
|
name = "shared_library"
|
||||||
version = "0.1.9"
|
version = "0.1.9"
|
||||||
@@ -5984,6 +6014,15 @@ dependencies = [
|
|||||||
"syn 3.0.7",
|
"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]]
|
[[package]]
|
||||||
name = "tiff"
|
name = "tiff"
|
||||||
version = "0.11.3"
|
version = "0.11.3"
|
||||||
@@ -6198,6 +6237,22 @@ dependencies = [
|
|||||||
"once_cell",
|
"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]]
|
[[package]]
|
||||||
name = "type-map"
|
name = "type-map"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ eframe = { version = "0.36", default-features = false, features = ["default_font
|
|||||||
egui = "0.36"
|
egui = "0.36"
|
||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
zeroize = "1"
|
zeroize = "1"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "env-filter", "std", "ansi"] }
|
||||||
egui-phosphor = "0.14"
|
egui-phosphor = "0.14"
|
||||||
rfd = "0.17"
|
rfd = "0.17"
|
||||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ fn main() -> eframe::Result {
|
|||||||
std::process::exit(1);
|
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 headless = opts.screenshot.is_some();
|
||||||
let native = eframe::NativeOptions {
|
let native = eframe::NativeOptions {
|
||||||
persist_window: !headless,
|
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();
|
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() }
|
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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
/// 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 {
|
pub fn open_web_url(ctx: &egui::Context, url: &str) -> bool {
|
||||||
if !is_web_url(url) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
ctx.open_url(egui::OpenUrl::new_tab(url.trim()));
|
ctx.open_url(egui::OpenUrl::new_tab(url.trim()));
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ hmac = "0.13.0"
|
|||||||
sha1 = "0.11.0"
|
sha1 = "0.11.0"
|
||||||
zeroize = "1"
|
zeroize = "1"
|
||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
|
tracing = "0.1"
|
||||||
portable-pty = "0.9.0"
|
portable-pty = "0.9.0"
|
||||||
serialport = { version = "4", default-features = false }
|
serialport = { version = "4", default-features = false }
|
||||||
des = "0.9"
|
des = "0.9"
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ impl Manager {
|
|||||||
Err(_) => Err("host key prompt failed: host key prompt timed out waiting for user response".into()),
|
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) {
|
match (policy, stored) {
|
||||||
(HostKeyPolicy::Insecure, None) | (HostKeyPolicy::AcceptNew, None) => save(),
|
(HostKeyPolicy::Insecure, None) | (HostKeyPolicy::AcceptNew, None) => save(),
|
||||||
(HostKeyPolicy::Insecure, Some(s)) => touch(s),
|
(HostKeyPolicy::Insecure, Some(s)) => touch(s),
|
||||||
|
|||||||
@@ -346,6 +346,7 @@ impl Manager {
|
|||||||
}
|
}
|
||||||
(Err(_), None) => {}
|
(Err(_), None) => {}
|
||||||
(Err(e), Some(s)) => {
|
(Err(e), Some(s)) => {
|
||||||
|
tracing::warn!(session = id, error = %e, "connection failed");
|
||||||
s.info.state = SessionState::Error;
|
s.info.state = SessionState::Error;
|
||||||
s.info.error = e.to_string();
|
s.info.error = e.to_string();
|
||||||
attempt_failed(&s.session_secrets, &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());
|
self.emit_state(id, SessionState::Error, &e.to_string());
|
||||||
}
|
}
|
||||||
(Ok(client), Some(s)) => {
|
(Ok(client), Some(s)) => {
|
||||||
|
tracing::info!(session = id, "connected");
|
||||||
let typed = std::mem::take(&mut s.session_secrets.lock().pending);
|
let typed = std::mem::take(&mut s.session_secrets.lock().pending);
|
||||||
s.client = client.clone();
|
s.client = client.clone();
|
||||||
s.info.state = SessionState::Connected;
|
s.info.state = SessionState::Connected;
|
||||||
@@ -365,6 +367,7 @@ impl Manager {
|
|||||||
}
|
}
|
||||||
for (key, value) in typed {
|
for (key, value) in typed {
|
||||||
if let Err(e) = self.secrets.set_checked(&key, &value) {
|
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}") });
|
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> {
|
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 _ = self.with(id, |s| {
|
||||||
let mut st = s.session_secrets.lock();
|
let mut st = s.session_secrets.lock();
|
||||||
st.pending.clear();
|
st.pending.clear();
|
||||||
@@ -510,6 +514,7 @@ impl Manager {
|
|||||||
s.profile.clone()
|
s.profile.clone()
|
||||||
};
|
};
|
||||||
self.store.add_audit("disconnect", "", id, "Session disconnected");
|
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()) {
|
if let Some(p) = profile.filter(|p| p.auto_reconnect()) {
|
||||||
self.schedule_reconnect(id, epoch, p);
|
self.schedule_reconnect(id, epoch, p);
|
||||||
return;
|
return;
|
||||||
@@ -525,9 +530,11 @@ impl Manager {
|
|||||||
let attempt = self.with(id, |s| {
|
let attempt = self.with(id, |s| {
|
||||||
s.reconnect_count += 1;
|
s.reconnect_count += 1;
|
||||||
if s.reconnect_count > max {
|
if s.reconnect_count > max {
|
||||||
|
tracing::warn!(session = id, tries = max, "giving up reconnecting");
|
||||||
s.info.state = SessionState::Disconnected;
|
s.info.state = SessionState::Disconnected;
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
tracing::info!(session = id, attempt = s.reconnect_count, of = max, "scheduling reconnect");
|
||||||
s.info.state = SessionState::Reconnecting;
|
s.info.state = SessionState::Reconnecting;
|
||||||
s.info.error.clear();
|
s.info.error.clear();
|
||||||
if let Some(c) = s.cancel_reconnect.replace(cancel.clone()) {
|
if let Some(c) = s.cancel_reconnect.replace(cancel.clone()) {
|
||||||
|
|||||||
@@ -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> {
|
fn parse_col<T: serde::de::DeserializeOwned>(s: Option<String>) -> Option<T> {
|
||||||
let s = s.filter(|s| !s.is_empty() && s != "null")?;
|
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> {
|
fn scan_profile(r: &Row) -> rusqlite::Result<Profile> {
|
||||||
|
|||||||
Reference in New Issue
Block a user