ssh_cli/tracing_setup.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-SECDEV-05: pure module — no `unsafe` permitted (crate root allows only OS FFI / test env).
3#![forbid(unsafe_code)]
4//! Process-local **tracing** setup for the ssh-cli binary path.
5//!
6//! # Why this module was renamed from `telemetry`
7//!
8//! The old name described a category of thing this product forbids. It never did any
9//! of it — the "out of scope" list below has been accurate since the first commit —
10//! but a reader auditing the tree for a telemetry ban found a module called
11//! `telemetry` and had to read it to learn it was innocent. A name that costs every
12//! reader a verification is a name that is wrong, however defensible its history.
13//!
14//! The wire field `"telemetry": false` in `vps doctor` is a different thing and keeps
15//! its name. There the word is the *claim being denied*, and denying it in the
16//! operator's own vocabulary is the point.
17//!
18//! This rename is **breaking** for anyone importing `ssh_cli::telemetry` as a
19//! library. There is no deprecated alias: an alias would keep the name this rename
20//! exists to remove, and leave the audit exactly where it started.
21//!
22//! # Product identity (Rules Rust — logs / tracing / rotation)
23//!
24//! ssh-cli is a **one-shot agent CLI**, not a long-lived server:
25//!
26//! | Signal | Policy |
27//! |--------|--------|
28//! | Facade | `tracing` only (no `println!` / `env_logger` / dual `log` consumer) |
29//! | Sink | **stderr** text (data JSON stays on **stdout**) |
30//! | Default filter | `error` (agent-first quiet stderr) |
31//! | Override | **CLI only:** `-v` → `debug`; ambient `RUST_LOG` is **ignored**; `-q` does not change the filter |
32//! | Reload | `reload::Layer` so bootstrap (pre-parse) can reconfigure after argv |
33//! | Bridge | `tracing-log::LogTracer` for deps that emit via the `log` crate (`russh`, `keyring`) |
34//! | Errors | `tracing_error::ErrorLayer` for `SpanTrace` capture |
35//!
36//! # Explicitly out of scope
37//!
38//! - OpenTelemetry / OTLP / metrics backends (product: **zero telemetry**)
39//! - `tracing-appender` file rotation + `WorkerGuard` (no local log files; short process)
40//! - Admin HTTP `/admin/log-level` (no daemon / no network control plane)
41//! - `tokio-console` / Chrome tracing / journald / Docker log drivers as product features
42//! - Encrypted log-at-rest (no log files written by this binary)
43//!
44//! Libraries that depend on `ssh_cli` as a crate should **not** call these
45//! installers; only the binary entry (`run` → `bootstrap_logs`) installs the
46//! global subscriber. Product modules only emit events/spans.
47//!
48//! # Lifecycle
49//!
50//! 1. [`crate::tracing_setup::bootstrap_logs`] — before clap parse (phase 1b).
51//! 2. [`crate::tracing_setup::initialize_logs`] — after parse, reloads `EnvFilter` from `-v` only (G-E2E-09).
52//! 3. Process exit — `main` flushes stderr; no file worker to join.
53
54use std::sync::OnceLock;
55
56use tracing_error::ErrorLayer;
57use tracing_subscriber::reload;
58use tracing_subscriber::{fmt, prelude::*, EnvFilter, Registry};
59
60/// Reload handle: bootstrap installs once; [`crate::tracing_setup::initialize_logs`] reloads the filter.
61///
62/// `OnceLock` (not `lazy_static`): value is created only when the binary path
63/// installs the global subscriber.
64static LOG_FILTER_RELOAD: OnceLock<reload::Handle<EnvFilter, Registry>> = OnceLock::new();
65
66/// Builds the process-local tracing filter from CLI `-v` count only (G-AUD-22 / G14).
67///
68/// | level | filter |
69/// |-------|--------|
70/// | 0 | `error` |
71/// | 1 (`-v`) | `warn,ssh_cli=info` |
72/// | 2 (`-vv`) | `warn,ssh_cli=debug` |
73/// | ≥3 (`-vvv`) | `warn,ssh_cli=trace` |
74///
75/// Ambient `RUST_LOG` is **ignored** (not an env store of product config).
76/// `--quiet` affects human stdout only ([`crate::output::set_quiet`]).
77///
78/// **G2 security:** never emit a bare global `debug`/`trace` directive — that
79/// enables `russh::client::encrypted`, which logs the raw userauth packet
80/// (SSH password in cleartext) to stderr.
81#[must_use]
82pub fn build_env_filter(verbose: u8) -> EnvFilter {
83 match verbose {
84 0 => EnvFilter::new("error"),
85 1 => EnvFilter::new("warn,ssh_cli=info"),
86 2 => EnvFilter::new("warn,ssh_cli=debug"),
87 _ => EnvFilter::new("warn,ssh_cli=trace"),
88 }
89}
90
91/// Installs stderr tracing **before** clap parse (one-shot lifecycle phase 1b).
92///
93/// Default filter is `error` so agents stay quiet; [`crate::tracing_setup::initialize_logs`] reloads
94/// from `-v` after parse (ambient `RUST_LOG` ignored).
95///
96/// Safe to call more than once: subsequent calls are no-ops once the reload
97/// handle is stored (or if another test subscriber already owns the global).
98pub fn bootstrap_logs() {
99 if LOG_FILTER_RELOAD.get().is_some() {
100 return;
101 }
102
103 let (filter_layer, handle) = reload::Layer::new(EnvFilter::new("error"));
104 let subscriber = Registry::default()
105 .with(filter_layer)
106 .with(ErrorLayer::default())
107 .with(
108 fmt::layer()
109 .with_writer(std::io::stderr)
110 // Targets remain in the log line for human diagnostics.
111 .with_target(true)
112 // Tokio workers are named `ssh-cli-worker` in `main`.
113 .with_thread_names(true)
114 // Agents / CI: never emit ANSI on the diagnostics channel.
115 .with_ansi(false),
116 );
117
118 // Prefer `set_global_default` so the reload handle stays valid.
119 // Ignore failure when tests already installed a subscriber.
120 if tracing::subscriber::set_global_default(subscriber).is_ok() {
121 let _ = LOG_FILTER_RELOAD.set(handle);
122 // Bridge `log` crate records (russh, keyring, …) into `tracing`.
123 // Ignore if already installed (re-entrant tests).
124 let _ = tracing_log::LogTracer::builder()
125 .with_max_level(log::LevelFilter::Trace)
126 .init();
127 tracing::debug!("tracing subscriber installed (stderr, filter=error)");
128 }
129}
130
131/// Initializes or reloads `tracing-subscriber` from the verbose CLI count (G14).
132///
133/// GAP-SSH-LOG-001 / G-AUD-22: default **error** (agent-first).
134/// Ambient `RUST_LOG` is ignored (CLI-only filter).
135pub fn initialize_logs(verbose: u8) {
136 let filter = build_env_filter(verbose);
137 if let Some(handle) = LOG_FILTER_RELOAD.get() {
138 match handle.reload(filter) {
139 Ok(()) => {
140 // Visible only when the new filter admits `debug` (e.g. `-vv`).
141 tracing::debug!(
142 verbose,
143 rust_log_set = std::env::var_os("RUST_LOG").is_some(),
144 "tracing filter reloaded"
145 );
146 }
147 Err(e) => {
148 // Keep the previous filter; surface the reload failure.
149 tracing::warn!(err = %e, "failed to reload tracing filter");
150 }
151 }
152 return;
153 }
154
155 // Tests / alternate entry: no bootstrap — try_init once (no reload handle).
156 let _ = fmt()
157 .with_env_filter(filter)
158 .with_writer(std::io::stderr)
159 .with_target(true)
160 .with_thread_names(true)
161 .with_ansi(false)
162 .try_init();
163 let _ = tracing_log::LogTracer::builder()
164 .with_max_level(log::LevelFilter::Trace)
165 .init();
166}
167
168/// Returns whether the process owns a reloadable global filter (binary path).
169#[must_use]
170pub fn has_reload_handle() -> bool {
171 LOG_FILTER_RELOAD.get().is_some()
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 #[test]
179 fn build_env_filter_default_is_error() {
180 // Isolate from ambient RUST_LOG in the test runner.
181 let prev = std::env::var_os("RUST_LOG");
182 crate::test_util::env::remove_var("RUST_LOG");
183 let f = build_env_filter(0);
184 assert_eq!(f.to_string(), "error");
185 match prev {
186 Some(v) => crate::test_util::env::set_var("RUST_LOG", v),
187 None => crate::test_util::env::remove_var("RUST_LOG"),
188 }
189 }
190
191 #[test]
192 fn build_env_filter_verbose_scopes_levels_to_this_crate() {
193 let prev = std::env::var_os("RUST_LOG");
194 crate::test_util::env::remove_var("RUST_LOG");
195 let f1 = build_env_filter(1).to_string();
196 let f2 = build_env_filter(2).to_string();
197 let f3 = build_env_filter(3).to_string();
198 assert!(
199 f1.contains("ssh_cli=info"),
200 "-v must enable info for the product crate, got {f1:?}"
201 );
202 assert!(
203 f2.contains("ssh_cli=debug"),
204 "-vv must enable debug for the product crate, got {f2:?}"
205 );
206 assert!(
207 f3.contains("ssh_cli=trace"),
208 "-vvv must enable trace for the product crate, got {f3:?}"
209 );
210 // G2 security: never a bare global debug/trace directive.
211 for f in [&f1, &f2, &f3] {
212 assert!(
213 !f.split(',')
214 .any(|d| d.trim() == "debug" || d.trim() == "trace" || d.trim() == "info"),
215 "verbose must NOT set a global level directive, got {f:?}"
216 );
217 }
218 match prev {
219 Some(v) => crate::test_util::env::set_var("RUST_LOG", v),
220 None => crate::test_util::env::remove_var("RUST_LOG"),
221 }
222 }
223
224 #[test]
225 fn bootstrap_logs_is_idempotent() {
226 bootstrap_logs();
227 bootstrap_logs();
228 // In a full binary path the handle is set; under parallel tests another
229 // suite may have taken the global subscriber first — both outcomes OK.
230 let _ = has_reload_handle();
231 }
232}