Skip to main content

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}