mirror of
https://github.com/jmcorgan/fips.git
synced 2026-10-06 03:28:24 +00:00
Merge branch 'master' into next
This commit is contained in:
@@ -322,8 +322,20 @@ with v0.5.x or earlier peers.
|
||||
build-it-yourself because rustup ships no toolchain for it. See
|
||||
`packaging/pfsense/README.md`.
|
||||
|
||||
- The daemon can own and roll its log file, for supervisors that cannot rotate
|
||||
its output. `node.log_file`, or the `--log-file` flag that overrides it,
|
||||
sends the log to a file instead of stdout. The file is rolled by size at
|
||||
`node.log_max_size_mb` (default 10) and keeps `node.log_max_files` rolled
|
||||
files (default 4, at most 100) as `<name>.1` to `<name>.N`; the live file
|
||||
keeps its name, so `tail -F` follows it. Unset, logging stays on stdout,
|
||||
which journald and syslog already rotate.
|
||||
|
||||
### Changed
|
||||
|
||||
- The Windows service log is rolled at `node.log_max_size_mb` and keeps
|
||||
`node.log_max_files` old files. The defaults are the 10 MiB and four files it
|
||||
used before.
|
||||
|
||||
- Transport address clones share immutable bytes instead of allocating a copy.
|
||||
Socket addresses are formatted on the stack so construction still needs only
|
||||
one allocation, including scoped IPv6 addresses.
|
||||
@@ -965,6 +977,16 @@ with v0.5.x or earlier peers.
|
||||
|
||||
#### macOS
|
||||
|
||||
- The macOS daemon's log, `/usr/local/var/log/fips/fips.log`, no longer grows
|
||||
without bound (717 MB on a node at debug level). launchd appends stdout to a
|
||||
file forever and gives the daemon no way to reopen one rotated away, so the
|
||||
launchd plist now passes `--log-file` and the daemon rolls the log itself;
|
||||
an upgrade needs no config edit. The existing file rolls to `fips.log.1` on
|
||||
the first write and ages out with the rest. stderr goes to
|
||||
`fips.stderr.log` and carries only what fails before the log opens. The
|
||||
package makes the log directory root-owned, and the daemon refuses to open
|
||||
the log through a symlink.
|
||||
|
||||
- If an encrypt worker thread exits, the daemon no longer stops once that
|
||||
worker's send queue fills. Packets for that worker are now dropped instead
|
||||
of blocking forever. This applies to the default sender.
|
||||
|
||||
@@ -5,7 +5,7 @@ The FIPS mesh network daemon.
|
||||
## Synopsis
|
||||
|
||||
```text
|
||||
fips [-c FILE]
|
||||
fips [-c FILE] [--log-file FILE]
|
||||
```
|
||||
|
||||
On Windows the same binary additionally accepts `--install-service`,
|
||||
@@ -19,7 +19,8 @@ identity, brings up the TUN adapter, listens on configured transports,
|
||||
authenticates peers, maintains the spanning tree, and forwards mesh
|
||||
traffic. There is one daemon per node.
|
||||
|
||||
The daemon stays in the foreground, logging to stderr, until it
|
||||
The daemon stays in the foreground, logging to stderr (or to the file
|
||||
named by `--log-file` or `node.log_file`), until it
|
||||
receives `SIGINT` or `SIGTERM`. On Windows, the service variant is
|
||||
controlled through the standard service control manager.
|
||||
|
||||
@@ -28,6 +29,7 @@ controlled through the standard service control manager.
|
||||
| Flag | Argument | Description |
|
||||
| ---- | -------- | ----------- |
|
||||
| `-c`, `--config` | `FILE` | Use `FILE` as the configuration. Skips the default search paths. |
|
||||
| `--log-file` | `FILE` | Log to `FILE` instead of stdout, rolled by size at `node.log_max_size_mb` and kept to `node.log_max_files` rolled files. Overrides `node.log_file`, and is opened before the configuration loads, so a configuration error is logged there too. The macOS package passes it from the launchd plist. See [configuration.md](configuration.md). |
|
||||
| `-V` | — | Print the short version, `<version> (rev <git-hash>)`. The `rev` part is omitted when the build could not read a git revision, as in a package built from a git worktree. |
|
||||
| `--version` | — | Print the long version: short version plus build target triple. |
|
||||
| `-h`, `--help` | — | Print usage and exit. |
|
||||
@@ -35,8 +37,8 @@ controlled through the standard service control manager.
|
||||
| `--uninstall-service` | — | (Windows only) Uninstall the Windows service. Requires Administrator. |
|
||||
| `--service` | — | (Windows only, internal) Run as a Windows service. Invoked by the service control manager — not for direct use. |
|
||||
|
||||
There are no other CLI flags; all daemon behaviour is governed by the
|
||||
YAML configuration. See [configuration.md](configuration.md).
|
||||
There are no other CLI flags; all other daemon behaviour is governed by
|
||||
the YAML configuration. See [configuration.md](configuration.md).
|
||||
|
||||
## Exit Codes
|
||||
|
||||
|
||||
@@ -125,6 +125,9 @@ ephemeral mode a `fips.key` found at startup is moved aside to
|
||||
| `node.link_dead_timeout_secs` | u64 | `30` | No-traffic timeout before a peer is declared dead and removed |
|
||||
| `node.drain_timeout_secs` | u64 | `2` | Upper bound in seconds on the `Draining` shutdown phase. On shutdown the node broadcasts Disconnect to its peers and then waits up to this long for the links to clear, exiting as soon as the last peer is gone. `0` skips the wait. The key is absent from a default config file rather than written with its default value, so an unset key and the 2-second default are the same thing |
|
||||
| `node.log_level` | string | `"info"` | Tracing filter default. Case-insensitive; one of `trace`, `debug`, `info`, `warn`, `error`. Overridden by the `RUST_LOG` environment variable when set |
|
||||
| `node.log_file` | string | unset | Log to this file instead of to stdout, rolled by the daemon at `node.log_max_size_mb`. Leave unset wherever the platform already rotates the captured stream — journald and syslog both do, and logging to a file there would only duplicate what they hold. Set it where nothing rotates: launchd redirects stdout to a plain file and never truncates it, and gives the daemon no way to reopen one rotated out from under it, so the daemon has to own the file. The live file keeps this name, so `tail -F` follows it across a roll; rolled files are `<name>.1` (newest) to `<name>.<log_max_files>`, and no other file in the directory is touched. The `--log-file` flag overrides this key; the macOS package passes it from the launchd plist, so an upgraded config needs no edit. The Windows service always logs to `fips.log` in its config directory and ignores this key |
|
||||
| `node.log_max_size_mb` | u64 | `10` | Size in MiB at which an owned log file is rolled. Applies to `--log-file`, `node.log_file` and the Windows service log. `0` is raised to `1` |
|
||||
| `node.log_max_files` | u32 | `4` | How many rolled log files to keep beside the live one; the oldest is deleted as a new roll is made, as are any left numbered past this count after it is lowered. The live file is never deleted. `0` is raised to `1`, and anything above `100` lowered to `100`. With the defaults an owned log takes at most about 50 MiB |
|
||||
|
||||
### Resource Limits (`node.limits.*`)
|
||||
|
||||
|
||||
@@ -149,6 +149,15 @@ if [ ! -f "$CONFDIR/hosts" ]; then
|
||||
cp "$CONFDIR/hosts.default" "$CONFDIR/hosts"
|
||||
fi
|
||||
|
||||
# The daemon writes its log here as root. A directory that predates the
|
||||
# package keeps its owner, and one another user can write to would let them
|
||||
# swap the log for a link elsewhere; the daemon refuses to follow one, but a
|
||||
# root-owned directory keeps the log from being tampered with at all.
|
||||
LOGDIR="/usr/local/var/log/fips"
|
||||
mkdir -p "$LOGDIR"
|
||||
chown root:wheel "$LOGDIR"
|
||||
chmod 755 "$LOGDIR"
|
||||
|
||||
# Flush DNS cache so macOS picks up the new /etc/resolver/fips file
|
||||
dscacheutil -flushcache
|
||||
killall -HUP mDNSResponder 2>/dev/null || true
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
<string>/usr/local/bin/fips</string>
|
||||
<string>--config</string>
|
||||
<string>/usr/local/etc/fips/fips.yaml</string>
|
||||
<string>--log-file</string>
|
||||
<string>/usr/local/var/log/fips/fips.log</string>
|
||||
</array>
|
||||
|
||||
<key>RunAtLoad</key>
|
||||
@@ -21,11 +23,21 @@
|
||||
<false/>
|
||||
</dict>
|
||||
|
||||
<!-- The daemon owns and rolls its own log (the log-file argument
|
||||
above), because launchd holds this descriptor and never truncates
|
||||
or rotates it, and gives the daemon no way to reopen one rotated
|
||||
out from under it. Passed as a flag rather than set in fips.yaml so that an
|
||||
upgrade, which keeps the existing config, turns it on too.
|
||||
|
||||
stdout carries nothing once the log file is open. stderr keeps
|
||||
only what is written before it opens, chiefly a failure to open
|
||||
it; errors and panics after that go to the log file alone, so a
|
||||
restart loop cannot grow this unrolled file. -->
|
||||
<key>StandardOutPath</key>
|
||||
<string>/usr/local/var/log/fips/fips.log</string>
|
||||
<string>/dev/null</string>
|
||||
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/usr/local/var/log/fips/fips.log</string>
|
||||
<string>/usr/local/var/log/fips/fips.stderr.log</string>
|
||||
|
||||
<key>WorkingDirectory</key>
|
||||
<string>/usr/local/etc/fips</string>
|
||||
|
||||
+182
-58
@@ -5,10 +5,14 @@
|
||||
|
||||
use clap::Parser;
|
||||
use fips::config::{IdentitySource, resolve_identity};
|
||||
use fips::utils::logfile::{ROLL_BYTES, ROLL_KEEP, RollingFile, SharedLog};
|
||||
use fips::version;
|
||||
use fips::{Config, Node};
|
||||
use std::path::PathBuf;
|
||||
use tracing::{debug, error, info};
|
||||
use std::io::IsTerminal;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
use tracing::{debug, error, info, warn};
|
||||
use tracing_subscriber::fmt::writer::BoxMakeWriter;
|
||||
use tracing_subscriber::{EnvFilter, fmt};
|
||||
use zeroize::Zeroize;
|
||||
|
||||
@@ -25,6 +29,12 @@ struct Args {
|
||||
#[arg(short, long, value_name = "FILE")]
|
||||
config: Option<PathBuf>,
|
||||
|
||||
/// Log to this file, rolled by size, instead of stdout. Overrides
|
||||
/// `node.log_file`, and is opened before the configuration loads so that
|
||||
/// a configuration error is logged to it too.
|
||||
#[arg(long, value_name = "FILE")]
|
||||
log_file: Option<PathBuf>,
|
||||
|
||||
/// Run as a Windows service (internal use by service control manager)
|
||||
#[cfg(windows)]
|
||||
#[arg(long, hide = true)]
|
||||
@@ -41,6 +51,76 @@ struct Args {
|
||||
uninstall_service: bool,
|
||||
}
|
||||
|
||||
/// The log file the daemon owns, when it owns one: opened from `--log-file`
|
||||
/// before the configuration loads, from `node.log_file` after it, or by the
|
||||
/// Windows service. Unset, the log goes to stdout.
|
||||
///
|
||||
/// Writes to it are synchronous and unbuffered, so the line logged before a
|
||||
/// `process::exit` is on disk when the process ends.
|
||||
static LOG: OnceLock<SharedLog> = OnceLock::new();
|
||||
|
||||
/// Open `path` as the daemon's log, with the default limits until the
|
||||
/// configuration supplies its own, and send panics to it.
|
||||
fn open_log(path: &Path) -> std::io::Result<()> {
|
||||
let log = SharedLog::new(RollingFile::open(path, ROLL_BYTES, ROLL_KEEP)?);
|
||||
if LOG.set(log.clone()).is_err() {
|
||||
return Ok(());
|
||||
}
|
||||
// The default hook writes to stderr, which a supervisor that cannot
|
||||
// rotate is likely to discard or never look at, and which launchd appends
|
||||
// to a file nothing rolls. It still runs where stderr is a terminal.
|
||||
let previous = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
if echo_to_stderr(true, std::io::stderr().is_terminal()) {
|
||||
previous(info);
|
||||
}
|
||||
log.try_line(&info.to_string());
|
||||
}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether an error or panic that goes to the log file should also go to
|
||||
/// stderr: always when no log is open, and otherwise only when stderr is a
|
||||
/// terminal someone is watching.
|
||||
///
|
||||
/// Not under a supervisor: launchd restarts a daemon that exits on a bad
|
||||
/// config or panics every ten seconds, and the stderr file it appends to is
|
||||
/// never rolled.
|
||||
fn echo_to_stderr(log_open: bool, stderr_is_terminal: bool) -> bool {
|
||||
!log_open || stderr_is_terminal
|
||||
}
|
||||
|
||||
/// Report an error raised before logging is set up: to the log file when one
|
||||
/// is open, and to stderr as [`echo_to_stderr`] decides.
|
||||
fn startup_error(msg: &str) {
|
||||
if let Some(log) = LOG.get() {
|
||||
log.line(&format!("ERROR {msg}"));
|
||||
}
|
||||
if echo_to_stderr(LOG.get().is_some(), std::io::stderr().is_terminal()) {
|
||||
eprintln!("{msg}");
|
||||
}
|
||||
}
|
||||
|
||||
/// What to do with `node.log_file`, given whether `--log-file` or the
|
||||
/// Windows service already opened a log, which take precedence.
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum ConfiguredLog<'a> {
|
||||
/// Unset: log to the file already open, or to stdout.
|
||||
Unset,
|
||||
/// Open it.
|
||||
Open(&'a str),
|
||||
/// Set, but a log is already open; worth a warning.
|
||||
Ignored(&'a str),
|
||||
}
|
||||
|
||||
fn configured_log(configured: Option<&str>, already_open: bool) -> ConfiguredLog<'_> {
|
||||
match (configured, already_open) {
|
||||
(None, _) => ConfiguredLog::Unset,
|
||||
(Some(path), false) => ConfiguredLog::Open(path),
|
||||
(Some(path), true) => ConfiguredLog::Ignored(path),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the FIPS daemon (shared between foreground and service modes).
|
||||
///
|
||||
/// `config_path` overrides the default config search. `shutdown_signal`
|
||||
@@ -48,8 +128,19 @@ struct Args {
|
||||
/// Ctrl+C / SIGTERM, in service mode it's the service stop event.
|
||||
async fn run_daemon(
|
||||
config_path: Option<PathBuf>,
|
||||
log_file: Option<PathBuf>,
|
||||
shutdown_signal: impl std::future::Future<Output = ()>,
|
||||
) {
|
||||
// Fatal on failure, as an unusable config is: a daemon that silently ran
|
||||
// without the log it was told to keep would present as exactly the
|
||||
// missing-logs problem the file exists to solve.
|
||||
if let Some(path) = &log_file
|
||||
&& let Err(e) = open_log(path)
|
||||
{
|
||||
eprintln!("Cannot open log file {}: {e}", path.display());
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// Load configuration before initializing logging so we can use
|
||||
// the config's log_level as the tracing filter default.
|
||||
let (config, loaded_paths) = if let Some(config_path) = &config_path {
|
||||
@@ -61,9 +152,7 @@ async fn run_daemon(
|
||||
config_path.display(),
|
||||
e
|
||||
);
|
||||
#[cfg(windows)]
|
||||
service::startup_error(&msg);
|
||||
eprintln!("{msg}");
|
||||
startup_error(&msg);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -72,9 +161,7 @@ async fn run_daemon(
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
let msg = format!("Failed to load configuration: {}", e);
|
||||
#[cfg(windows)]
|
||||
service::startup_error(&msg);
|
||||
eprintln!("{msg}");
|
||||
startup_error(&msg);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -106,23 +193,63 @@ async fn run_daemon(
|
||||
_ => filter,
|
||||
};
|
||||
|
||||
// Where the log goes. With no log file the stream stays on stdout for a
|
||||
// supervisor to capture, which is what journald and syslog want and
|
||||
// rotate. `node.log_file` is opened here unless `--log-file` or the
|
||||
// Windows service already opened one, which take precedence.
|
||||
let ignored_log_file =
|
||||
match configured_log(config.node.log_file.as_deref(), LOG.get().is_some()) {
|
||||
ConfiguredLog::Open(path) => {
|
||||
if let Err(e) = open_log(Path::new(path)) {
|
||||
startup_error(&format!("Cannot open log file {path}: {e}"));
|
||||
std::process::exit(1);
|
||||
}
|
||||
None
|
||||
}
|
||||
ConfiguredLog::Ignored(path) => Some(path),
|
||||
ConfiguredLog::Unset => None,
|
||||
};
|
||||
let writer = match LOG.get() {
|
||||
Some(log) => {
|
||||
log.set_limits(config.node.log_max_bytes(), config.node.log_max_files());
|
||||
BoxMakeWriter::new(log.clone())
|
||||
}
|
||||
None => BoxMakeWriter::new(std::io::stdout),
|
||||
};
|
||||
|
||||
// ANSI color only when stdout is a terminal — under a supervisor
|
||||
// (daemon(8), systemd) escape codes would litter the log file.
|
||||
// (daemon(8), systemd) escape codes would litter the log file. A log file
|
||||
// we own is never a terminal.
|
||||
//
|
||||
// Never let a failed log write panic the thread that logged. The default
|
||||
// is to report a write failure with `eprintln!`, which itself panics when
|
||||
// stderr fails too — and the shipped supervisor configs point stdout and
|
||||
// stderr at the same place, so one full disk satisfies both. A worker
|
||||
// thread killed that way takes its share of the peer space with it.
|
||||
let builder = fmt()
|
||||
fmt()
|
||||
.with_env_filter(filter)
|
||||
.with_target(true)
|
||||
.with_ansi(std::io::IsTerminal::is_terminal(&std::io::stdout()))
|
||||
.log_internal_errors(false);
|
||||
// A Windows service has no stdout to log to; it writes a file instead.
|
||||
#[cfg(windows)]
|
||||
let builder = builder.with_writer(service::log_writer());
|
||||
builder.init();
|
||||
.with_ansi(LOG.get().is_none() && std::io::stdout().is_terminal())
|
||||
.with_writer(writer)
|
||||
.log_internal_errors(false)
|
||||
.init();
|
||||
|
||||
// Logged first: an operator looking for output that is no longer on
|
||||
// stdout needs the path and the limits that govern it.
|
||||
if let Some(log) = LOG.get() {
|
||||
info!(
|
||||
path = %log.path().display(),
|
||||
max_bytes = config.node.log_max_bytes(),
|
||||
max_files = config.node.log_max_files(),
|
||||
"Logging to file"
|
||||
);
|
||||
}
|
||||
if let Some(path) = ignored_log_file {
|
||||
warn!(
|
||||
configured = path,
|
||||
"node.log_file ignored: the log is already open"
|
||||
);
|
||||
}
|
||||
|
||||
info!("FIPS {} starting", version::short_version());
|
||||
|
||||
@@ -256,7 +383,7 @@ async fn foreground_shutdown_signal() {
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() {
|
||||
let args = Args::parse();
|
||||
run_daemon(args.config, foreground_shutdown_signal()).await;
|
||||
run_daemon(args.config, args.log_file, foreground_shutdown_signal()).await;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -299,17 +426,18 @@ fn main() {
|
||||
.build()
|
||||
.expect("Failed to create tokio runtime");
|
||||
|
||||
rt.block_on(run_daemon(args.config, foreground_shutdown_signal()));
|
||||
rt.block_on(run_daemon(
|
||||
args.config,
|
||||
args.log_file,
|
||||
foreground_shutdown_signal(),
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod service {
|
||||
use fips::utils::logfile::{ROLL_BYTES, ROLL_KEEP, RollingFile, SharedLog};
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
use tracing_subscriber::fmt::writer::BoxMakeWriter;
|
||||
use windows_service::{
|
||||
define_windows_service,
|
||||
service::{
|
||||
@@ -329,46 +457,14 @@ mod service {
|
||||
|
||||
define_windows_service!(ffi_service_main, service_main);
|
||||
|
||||
/// The service's log file. Set at the start of `service_main`, and never
|
||||
/// in a foreground run, which logs to the console.
|
||||
static LOG: OnceLock<SharedLog> = OnceLock::new();
|
||||
|
||||
/// Open the service log in the config directory and send panics to it.
|
||||
/// Open the service log in the config directory, before anything can go
|
||||
/// wrong: a service has no console, so without it nothing is recorded.
|
||||
///
|
||||
/// A failure leaves the log unset and the daemon runs on without one:
|
||||
/// with no console and logging not yet up, nothing could report it.
|
||||
fn open_log() {
|
||||
let path = Path::new(fips::config::SYSTEM_CONFIG_DIR).join("fips.log");
|
||||
let Ok(file) = RollingFile::open(&path, ROLL_BYTES, ROLL_KEEP) else {
|
||||
return;
|
||||
};
|
||||
let log = SharedLog::new(file);
|
||||
if LOG.set(log.clone()).is_err() {
|
||||
return;
|
||||
}
|
||||
// The default hook writes to stderr, which a service does not have.
|
||||
let previous = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
previous(info);
|
||||
log.try_line(&info.to_string());
|
||||
}));
|
||||
}
|
||||
|
||||
/// Where the daemon's tracing output goes: the service log when one is
|
||||
/// open, otherwise stdout.
|
||||
pub fn log_writer() -> BoxMakeWriter {
|
||||
match LOG.get() {
|
||||
Some(log) => BoxMakeWriter::new(log.clone()),
|
||||
None => BoxMakeWriter::new(std::io::stdout),
|
||||
}
|
||||
}
|
||||
|
||||
/// Record an error raised before logging is set up in the service log.
|
||||
/// Does nothing in a foreground run, where stderr carries it.
|
||||
pub fn startup_error(msg: &str) {
|
||||
if let Some(log) = LOG.get() {
|
||||
log.line(&format!("ERROR {msg}"));
|
||||
}
|
||||
let _ = super::open_log(&path);
|
||||
}
|
||||
|
||||
/// Start the service dispatcher, which blocks until the service stops.
|
||||
@@ -381,7 +477,7 @@ mod service {
|
||||
open_log();
|
||||
if let Err(e) = run_service(arguments) {
|
||||
let msg = format!("Service error: {:?}", e);
|
||||
match LOG.get() {
|
||||
match super::LOG.get() {
|
||||
Some(log) => log.line(&msg),
|
||||
None => eprintln!("{msg}"),
|
||||
}
|
||||
@@ -429,7 +525,7 @@ mod service {
|
||||
// Look for config file path from FIPS_CONFIG env var
|
||||
let config_path: Option<PathBuf> = std::env::var("FIPS_CONFIG").ok().map(PathBuf::from);
|
||||
|
||||
rt.block_on(super::run_daemon(config_path, async {
|
||||
rt.block_on(super::run_daemon(config_path, None, async {
|
||||
let _ = shutdown_rx.await;
|
||||
}));
|
||||
|
||||
@@ -527,3 +623,31 @@ mod service {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn log_file_flag_takes_precedence_over_config() {
|
||||
assert_eq!(configured_log(None, false), ConfiguredLog::Unset);
|
||||
assert_eq!(configured_log(None, true), ConfiguredLog::Unset);
|
||||
assert_eq!(
|
||||
configured_log(Some("a.log"), false),
|
||||
ConfiguredLog::Open("a.log")
|
||||
);
|
||||
assert_eq!(
|
||||
configured_log(Some("a.log"), true),
|
||||
ConfiguredLog::Ignored("a.log")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_reach_stderr_only_without_a_log_or_with_a_terminal() {
|
||||
assert!(echo_to_stderr(false, false));
|
||||
assert!(echo_to_stderr(false, true));
|
||||
assert!(echo_to_stderr(true, true));
|
||||
// Under launchd: the log has it, and stderr is a file nothing rolls.
|
||||
assert!(!echo_to_stderr(true, false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1424,6 +1424,32 @@ pub struct NodeConfig {
|
||||
/// Valid values: trace, debug, info, warn, error. Default: info.
|
||||
#[serde(default)]
|
||||
pub log_level: Option<String>,
|
||||
|
||||
/// Log file (`node.log_file`). Unset by default, which keeps logging on
|
||||
/// stdout for a supervisor to capture — the right arrangement wherever
|
||||
/// the platform already rotates that stream (journald, syslog).
|
||||
///
|
||||
/// Set it only where nothing else rotates. Naming a file makes the daemon
|
||||
/// own it and roll it by size: the live file keeps this name, and rolled
|
||||
/// files are `<name>.1` (newest) to `<name>.<log_max_files>`. The
|
||||
/// `--log-file` flag takes precedence, which is how the macOS package
|
||||
/// turns this on without editing an existing config.
|
||||
///
|
||||
/// This and the two limits below skip serializing when unset, for the
|
||||
/// reason `drain_timeout_secs` does.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub log_file: Option<String>,
|
||||
|
||||
/// Size in MiB at which the log file is rolled (`node.log_max_size_mb`).
|
||||
/// Default: 10. Only consulted when the daemon owns a log file.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub log_max_size_mb: Option<u64>,
|
||||
|
||||
/// How many rolled log files to keep beside the live one
|
||||
/// (`node.log_max_files`). Default: 4, at most 100. Only consulted when
|
||||
/// the daemon owns a log file.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub log_max_files: Option<u32>,
|
||||
}
|
||||
|
||||
impl Default for NodeConfig {
|
||||
@@ -1456,6 +1482,9 @@ impl Default for NodeConfig {
|
||||
rekey: RekeyConfig::default(),
|
||||
netmon: NetmonConfig::default(),
|
||||
log_level: None,
|
||||
log_file: None,
|
||||
log_max_size_mb: None,
|
||||
log_max_files: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1477,6 +1506,28 @@ impl NodeConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Size in bytes at which an owned log file is rolled. Default: 10 MiB.
|
||||
///
|
||||
/// Zero is raised to 1 MiB: a cap of zero would roll on every line.
|
||||
pub fn log_max_bytes(&self) -> u64 {
|
||||
match self.log_max_size_mb {
|
||||
Some(mb) => mb.max(1).saturating_mul(1024 * 1024),
|
||||
None => crate::utils::logfile::ROLL_BYTES,
|
||||
}
|
||||
}
|
||||
|
||||
/// How many rolled log files to keep beside the live one. Default: 4.
|
||||
///
|
||||
/// The live file is never deleted, so zero keeps one rolled file, the
|
||||
/// least a roll needs. More than 100 is lowered to 100: a roll renames
|
||||
/// every kept file with the log locked, which stalls the daemon for as
|
||||
/// long as that takes.
|
||||
pub fn log_max_files(&self) -> u32 {
|
||||
self.log_max_files
|
||||
.unwrap_or(crate::utils::logfile::ROLL_KEEP)
|
||||
.clamp(1, 100)
|
||||
}
|
||||
|
||||
fn default_tick_interval_secs() -> u64 {
|
||||
1
|
||||
}
|
||||
@@ -1608,6 +1659,39 @@ owd_window_size: 48
|
||||
assert_eq!(c.startup_sweep_max_age_secs, 3_600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_log_limits() {
|
||||
let node = |mb: Option<u64>, files: Option<u32>| NodeConfig {
|
||||
log_max_size_mb: mb,
|
||||
log_max_files: files,
|
||||
..NodeConfig::default()
|
||||
};
|
||||
let default = node(None, None);
|
||||
assert_eq!(default.log_max_bytes(), 10 * 1024 * 1024);
|
||||
assert_eq!(default.log_max_files(), 4);
|
||||
assert_eq!(node(Some(0), Some(0)).log_max_bytes(), 1024 * 1024);
|
||||
assert_eq!(node(Some(0), Some(0)).log_max_files(), 1);
|
||||
assert_eq!(node(Some(25), Some(9)).log_max_bytes(), 25 * 1024 * 1024);
|
||||
assert_eq!(node(Some(25), Some(9)).log_max_files(), 9);
|
||||
assert_eq!(node(None, Some(u32::MAX)).log_max_files(), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unset_log_keys_are_not_serialized() {
|
||||
let yaml = serde_yaml::to_string(&NodeConfig::default()).unwrap();
|
||||
for key in ["log_file", "log_max_size_mb", "log_max_files"] {
|
||||
assert!(!yaml.contains(key), "{key} written:\n{yaml}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_logging_defaults_to_stdout() {
|
||||
// Unset log_file is what keeps every platform whose supervisor
|
||||
// already rotates (journald, syslog) on stdout. Regressing this to a
|
||||
// file default would double-log there.
|
||||
assert!(NodeConfig::default().log_file.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_log_level_parser() {
|
||||
// Pin the observed behavior of NodeConfig::log_level():
|
||||
|
||||
+118
-10
@@ -1,10 +1,14 @@
|
||||
//! A size-rolling log file for the Windows service.
|
||||
//! A size-rolling log file, for supervisors that cannot rotate the daemon's
|
||||
//! output.
|
||||
//!
|
||||
//! A process started by the service control manager has no standard handles,
|
||||
//! and writes to its absent stdout report success, so the daemon's log is lost
|
||||
//! unless it goes to a file. The file is rolled by size, which bounds the disk
|
||||
//! it can take: `ROLL_KEEP` old files of about `ROLL_BYTES` each, plus the
|
||||
//! current one.
|
||||
//! A process started by the Windows service control manager has no standard
|
||||
//! handles, and writes to its absent stdout report success, so the daemon's
|
||||
//! log is lost unless it goes to a file. launchd redirects stdout to a plain
|
||||
//! file it holds open and never truncates, and gives the daemon no way to
|
||||
//! reopen one rotated out from under it. Both need the daemon to own the file.
|
||||
//! It is rolled by size, which bounds the disk it can take: `keep` old files of
|
||||
//! about `max` bytes each, plus the current one. The live file keeps its name,
|
||||
//! so `tail -F` follows it across a roll.
|
||||
//!
|
||||
//! Nothing here may emit a tracing event. The writer runs inside the
|
||||
//! subscriber with the `SharedLog` lock held, so an event raised from here
|
||||
@@ -17,10 +21,10 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, MutexGuard, PoisonError, TryLockError};
|
||||
use tracing_subscriber::fmt::MakeWriter;
|
||||
|
||||
/// Size at which the service log is rolled.
|
||||
/// Default size at which the log is rolled.
|
||||
pub const ROLL_BYTES: u64 = 10 * 1024 * 1024;
|
||||
|
||||
/// Number of rolled service log files kept beside the current one.
|
||||
/// Default number of rolled log files kept beside the current one.
|
||||
pub const ROLL_KEEP: u32 = 4;
|
||||
|
||||
/// An append-only file that is renamed aside once it would pass `max` bytes.
|
||||
@@ -37,8 +41,17 @@ pub struct RollingFile {
|
||||
}
|
||||
|
||||
/// Open `path` for appending, creating it if absent.
|
||||
///
|
||||
/// On Unix a symlink at `path` is refused rather than followed. The daemon
|
||||
/// writes its log as root, and every roll creates the file again, so a
|
||||
/// directory another user can write to would otherwise let them point the
|
||||
/// next one at any file on the system.
|
||||
fn append(path: &Path) -> io::Result<File> {
|
||||
OpenOptions::new().create(true).append(true).open(path)
|
||||
let mut options = OpenOptions::new();
|
||||
options.create(true).append(true);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::OpenOptionsExt::custom_flags(&mut options, libc::O_NOFOLLOW);
|
||||
options.open(path)
|
||||
}
|
||||
|
||||
impl RollingFile {
|
||||
@@ -62,6 +75,20 @@ impl RollingFile {
|
||||
})
|
||||
}
|
||||
|
||||
/// The path of the live file.
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// Change the cap and the number of old files kept, for limits that are
|
||||
/// only known once the config has loaded. A file already past the new cap
|
||||
/// rolls on the next write.
|
||||
pub fn set_limits(&mut self, max: u64, keep: u32) {
|
||||
self.max = max;
|
||||
self.keep = keep.max(1);
|
||||
self.limit = max;
|
||||
}
|
||||
|
||||
/// The path of the `n`th rolled file.
|
||||
fn numbered(&self, n: u32) -> PathBuf {
|
||||
let mut name = self.path.clone().into_os_string();
|
||||
@@ -84,7 +111,8 @@ impl RollingFile {
|
||||
}
|
||||
|
||||
/// Rename the current file aside and shift the older ones down, dropping
|
||||
/// the oldest. The next write reopens `path` as a new, empty file.
|
||||
/// the oldest, and any numbered past `keep` that a larger limit left
|
||||
/// behind. The next write reopens `path` as a new, empty file.
|
||||
///
|
||||
/// The handle is dropped first so that a failed roll reopens cleanly and
|
||||
/// nothing is left pointing at `path.1`. Renaming a file this process
|
||||
@@ -93,6 +121,11 @@ impl RollingFile {
|
||||
/// blocks, such as a viewer holding the file without delete sharing.
|
||||
fn roll(&mut self) -> io::Result<()> {
|
||||
self.file = None;
|
||||
// Stops at the first gap: rolls only ever leave a contiguous run.
|
||||
let mut n = self.keep + 1;
|
||||
while fs::remove_file(self.numbered(n)).is_ok() {
|
||||
n += 1;
|
||||
}
|
||||
for n in (1..self.keep).rev() {
|
||||
match fs::rename(self.numbered(n), self.numbered(n + 1)) {
|
||||
Err(e) if e.kind() != io::ErrorKind::NotFound => return Err(e),
|
||||
@@ -173,6 +206,20 @@ impl SharedLog {
|
||||
Self(Arc::new(Mutex::new(file)))
|
||||
}
|
||||
|
||||
/// The path of the live file.
|
||||
pub fn path(&self) -> PathBuf {
|
||||
let file = self.0.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
file.path().to_path_buf()
|
||||
}
|
||||
|
||||
/// See [`RollingFile::set_limits`].
|
||||
pub fn set_limits(&self, max: u64, keep: u32) {
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.set_limits(max, keep);
|
||||
}
|
||||
|
||||
/// Write `text` as one line, waiting for the lock.
|
||||
pub fn line(&self, text: &str) {
|
||||
let mut file = self.0.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
@@ -357,6 +404,67 @@ mod tests {
|
||||
assert_eq!(read(&path), "dddddddddd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lowering_the_limits_rolls_on_the_next_write() {
|
||||
let (dir, path) = setup();
|
||||
let log = shared(&path);
|
||||
log.line("aaaaaaaaaa");
|
||||
log.set_limits(8, 1);
|
||||
log.line("bb");
|
||||
assert_eq!(names(&dir), ["fips.log", "fips.log.1"]);
|
||||
assert_eq!(read(&rolled(&path, 1)), "aaaaaaaaaa\n");
|
||||
assert_eq!(read(&path), "bb\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lowering_the_kept_count_removes_the_excess_on_the_next_roll() {
|
||||
let (dir, path) = setup();
|
||||
let log = SharedLog::new(RollingFile::open(&path, 4, 3).unwrap());
|
||||
for line in ["aaaa", "bbbb", "cccc", "dddd"] {
|
||||
log.line(line);
|
||||
}
|
||||
assert_eq!(
|
||||
names(&dir),
|
||||
["fips.log", "fips.log.1", "fips.log.2", "fips.log.3"]
|
||||
);
|
||||
log.set_limits(4, 1);
|
||||
log.line("eeee");
|
||||
assert_eq!(names(&dir), ["fips.log", "fips.log.1"]);
|
||||
assert_eq!(read(&rolled(&path, 1)), "dddd\n");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_symlink_in_place_of_the_log_is_refused() {
|
||||
let (dir, path) = setup();
|
||||
let target = dir.path().join("target");
|
||||
fs::write(&target, "untouched").unwrap();
|
||||
std::os::unix::fs::symlink(&target, &path).unwrap();
|
||||
assert!(RollingFile::open(&path, 16, 1).is_err());
|
||||
assert_eq!(read(&target), "untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_never_deletes_and_rolling_touches_only_numbered_files() {
|
||||
let (dir, path) = setup();
|
||||
fs::write(&path, "earlier run\n").unwrap();
|
||||
fs::write(dir.path().join("fips-incident.log"), "keep me").unwrap();
|
||||
fs::write(dir.path().join("fips.2026-08-31.log"), "keep me").unwrap();
|
||||
let mut file = RollingFile::open(&path, 16, 1).unwrap();
|
||||
assert_eq!(read(&path), "earlier run\n");
|
||||
file.write_all(b"aaaaaaaaaa").unwrap();
|
||||
assert_eq!(
|
||||
names(&dir),
|
||||
[
|
||||
"fips-incident.log",
|
||||
"fips.2026-08-31.log",
|
||||
"fips.log",
|
||||
"fips.log.1"
|
||||
]
|
||||
);
|
||||
assert_eq!(read(&rolled(&path, 1)), "earlier run\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_log_receives_tracing_events() {
|
||||
let (_dir, path) = setup();
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
//! primitives, and other cross-cutting concerns.
|
||||
|
||||
pub mod index;
|
||||
#[cfg(any(windows, test))]
|
||||
pub mod logfile;
|
||||
pub mod sockbind;
|
||||
#[cfg(unix)]
|
||||
|
||||
Reference in New Issue
Block a user