Skip to main content

rash/
log.rs

1//! Log sinks: syslog, a file, or stderr.
2//!
3//! Lines written to a file or to stderr keep autossh's format,
4//! `%Y/%m/%d %H:%M:%S rash[pid]: message` (autossh.c:1783-1819), so anything
5//! already parsing autossh logs keeps working.
6//!
7//! The syslog call is always `syslog(level, "%s", msg)`. autossh's fallback path
8//! passes the message *as* the format string (autossh.c:1797), which misbehaves
9//! on any log line containing a `%`.
10
11use crate::config::{Format, Level, Log, LogTarget};
12use std::ffi::{CStr, CString};
13use std::fmt;
14use std::fs::{File, OpenOptions};
15use std::io::{self, Write};
16use std::sync::{Mutex, OnceLock};
17
18static LOGGER: OnceLock<Logger> = OnceLock::new();
19
20struct Logger {
21    level: Level,
22    format: Format,
23    also_stderr: bool,
24    sink: Mutex<Sink>,
25}
26
27enum Sink {
28    Syslog,
29    File(File),
30    Stderr,
31}
32
33/// Set up the process-wide sink. Call once, and only after any daemonising fork.
34pub fn init(cfg: &Log) -> io::Result<()> {
35    let sink = match &cfg.target {
36        LogTarget::Syslog => {
37            // syslog(3) retains the ident pointer, so it has to be 'static.
38            // SAFETY: `c"rash"` is a 'static NUL-terminated string, and the flag
39            // and facility constants come from libc.
40            unsafe { libc::openlog(c"rash".as_ptr(), libc::LOG_PID, libc::LOG_USER) };
41            Sink::Syslog
42        }
43        LogTarget::File(p) => Sink::File(OpenOptions::new().create(true).append(true).open(p)?),
44        LogTarget::Stderr => Sink::Stderr,
45    };
46
47    let _ = LOGGER.set(Logger {
48        level: cfg.level,
49        format: cfg.format,
50        also_stderr: cfg.also_stderr,
51        sink: Mutex::new(sink),
52    });
53    Ok(())
54}
55
56/// Emit one line, if the configured verbosity allows it.
57///
58/// Prefer the `log_err!` / `log_info!` / `log_debug!` macros.
59pub fn emit(level: Level, args: fmt::Arguments<'_>) {
60    let Some(logger) = LOGGER.get() else {
61        // Errors raised before init() still have to reach someone, exactly as
62        // autossh's doerrlog() falls back to stderr.
63        let _ = writeln!(io::stderr(), "rash: {args}");
64        return;
65    };
66
67    // Higher numbers are chattier, so a level above the configured one is
68    // filtered out (autossh.c:1793).
69    if level > logger.level {
70        return;
71    }
72
73    let msg = args.to_string();
74    // A poisoned mutex only means some other thread panicked mid-log; the sink
75    // itself is still usable, and dropping log lines would be worse.
76    let mut sink = logger.sink.lock().unwrap_or_else(|e| e.into_inner());
77
78    // Rendered once, not once per sink: `line` stamps the current time, so
79    // rendering again for the stderr mirror below can date the two copies of
80    // one message a second apart.
81    let rendered = line(logger.format, level, &msg);
82
83    match &mut *sink {
84        Sink::Syslog => {
85            if let Ok(c) = CString::new(msg.as_bytes()) {
86                // SAFETY: both pointers are valid NUL-terminated strings that
87                // outlive the call. The message is an argument to "%s", never
88                // the format string itself.
89                unsafe { libc::syslog(level as i32, c"%s".as_ptr(), c.as_ptr()) };
90            }
91        }
92        Sink::File(f) => {
93            let _ = writeln!(f, "{rendered}");
94            let _ = f.flush();
95        }
96        Sink::Stderr => {
97            let _ = writeln!(io::stderr(), "{rendered}");
98        }
99    }
100
101    // AUTOSSH_DEBUG mirrors everything to stderr as well.
102    if logger.also_stderr && !matches!(&*sink, Sink::Stderr) {
103        let _ = writeln!(io::stderr(), "{rendered}");
104    }
105}
106
107/// One rendered log line.
108///
109/// The text shape is autossh's, so existing log parsing keeps working. JSON is
110/// for anything that would rather not parse it.
111fn line(format: Format, level: Level, msg: &str) -> String {
112    match format {
113        Format::Text => format!(
114            "{} rash[{}]: {msg}",
115            timestamp(c"%Y/%m/%d %H:%M:%S"),
116            std::process::id()
117        ),
118        Format::Json => serde_json::json!({
119            "ts": timestamp(c"%Y-%m-%dT%H:%M:%S%z"),
120            "level": level.to_string(),
121            "pid": std::process::id(),
122            "msg": msg,
123        })
124        .to_string(),
125    }
126}
127
128/// Local time, formatted by `strftime(3)` — the same source autossh's
129/// `timestr()` uses, so the text sink matches it character for character.
130fn timestamp(fmt: &CStr) -> String {
131    let mut buf = [0u8; 64];
132
133    // SAFETY: `time(NULL)` is always valid; `tm` is a plain C struct that
134    // localtime_r fills in, and a zeroed one is a valid starting value.
135    // strftime writes at most `buf.len()` bytes, including the NUL.
136    let n = unsafe {
137        let now = libc::time(std::ptr::null_mut());
138        let mut tm: libc::tm = std::mem::zeroed();
139        if libc::localtime_r(&now, &mut tm).is_null() {
140            return String::new();
141        }
142        libc::strftime(buf.as_mut_ptr().cast(), buf.len(), fmt.as_ptr(), &tm)
143    };
144
145    String::from_utf8_lossy(&buf[..n]).into_owned()
146}
147
148#[macro_export]
149macro_rules! logmsg {
150    ($level:expr, $($arg:tt)*) => {
151        $crate::log::emit($level, ::core::format_args!($($arg)*))
152    };
153}
154
155#[macro_export]
156macro_rules! log_err {
157    ($($arg:tt)*) => { $crate::logmsg!($crate::config::Level::Err, $($arg)*) };
158}
159
160#[macro_export]
161macro_rules! log_info {
162    ($($arg:tt)*) => { $crate::logmsg!($crate::config::Level::Info, $($arg)*) };
163}
164
165#[macro_export]
166macro_rules! log_debug {
167    ($($arg:tt)*) => { $crate::logmsg!($crate::config::Level::Debug, $($arg)*) };
168}