Rebrand: Sentry -> Cairn OBS

Full rebrand across cosmetic branding, code identifiers, and
infrastructure/data-plane naming, using the supplied Cairn OBS logo
package. Cosmetic: favicon/logo swap (also closes a stale license-audit
finding -- the old favicon was SvelteKit's unreplaced scaffold logo),
new centered welcome landing page, larger/legible sidebar logo, page
titles, CLAUDE.md/README/docs prose.

Code identifiers: Go module path github.com/sentry/sentry ->
github.com/cairnobs/cairnobs across all 13 modules and ~91 files (protoc
regenerated); Rust crates sentry-agent/sentry-parser/sentry-search ->
cairnobs-*; CLI sentryctl -> cairnobsctl; Terraform provider fully
renamed (sentry_dashboard etc. -> cairnobs_dashboard, provider type,
env vars); every session/auth cookie name; agent config paths and
Windows service identity.

Deliberately preserved: the gRPC wire protocol's protobuf packages
(sentry.logs.v1, sentry.agent.v1) and their Go import directory
(proto/sentry/...) -- renaming the wire-level package would break every
currently-deployed agent binary (confirmed two real hosts, including
mail.inbuxa.com, are actively streaming through this exact contract)
until rebuilt and redeployed in lockstep with an ingest cutover. Only
the Go module path wrapping the generated code changes.

Infrastructure: every docker-compose container name (root and three
component-level compose files); the Helm chart (directory, Chart.yaml,
named-template helpers, all templates, values.yaml image repos);
Kubernetes Operator (CRD group sentry.io -> cairnobs.io, both CRD YAML
files, Go identifiers, RBAC markers); the coupled enterprise/tenantcrd
package. Caught and fixed real path-coupling bugs along the way: the
Helm chart's search/ingest volume mounts and the dev-only-credential
detection constant vs. docker-compose.yml's literal values had to move
together or a security warning would have silently stopped firing.

Data plane: Postgres database sentry_metadata -> cairnobs_metadata and
role sentry -> cairnobs; ClickHouse database sentry -> cairnobs; Kafka
topic sentry.logs.raw -> cairnobs.logs.raw and its consumer groups.
Source-level defaults, docker-compose.yml, and every migrate.sh/
provision script default updated together; already-applied migration
files left untouched per this repo's immutable-migration convention.

Verified at every layer: all 13 Go modules build/vet/test clean, both
Rust workspaces (agent, search) build/clippy/test clean, npm run check/
build clean, docker compose config validates on all four compose files.
Live-verified against a real docker stack multiple times through this
work, including a final fresh-volume run confirming the actual renamed
Postgres database/role, ClickHouse database, and Kafka topic all work
end to end with a real login and query, zero console errors.
This commit is contained in:
2026-08-21 20:53:32 -07:00
parent 9e21ea17bb
commit 13cf9a30cb
291 changed files with 1565 additions and 1441 deletions
+53
View File
@@ -0,0 +1,53 @@
[package]
name = "cairnobs-agent"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Cairn OBS distro-agnostic Linux/Windows log collector"
[[bin]]
name = "cairnobs-agent"
path = "src/main.rs"
[features]
default = ["journald"]
journald = []
file-tail = []
# Windows-only sources. Feature-enabled AND target_os="windows"-gated at
# the module level (see src/source/mod.rs), so enabling these on a
# non-Windows build is a harmless no-op, not a build failure -- keeps
# `cargo test --workspace --all-features` green on Linux CI.
windows-eventlog = []
etw = []
[dependencies]
cairnobs-parser = { path = "../cairnobs-parser" }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "io-util", "io-std", "time", "fs", "sync", "signal"] }
tonic = { version = "0.12", features = ["tls"] }
prost = "0.13"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
clap = { version = "4", features = ["derive"] }
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Windows-only: not in the dependency graph at all on other targets, so
# they don't affect Linux build times or the musl release binary.
[target.'cfg(windows)'.dependencies]
windows = { version = "0.58", features = [
"Win32_Foundation",
"Win32_System_EventLog",
"Win32_System_Threading",
"Win32_System_Diagnostics_Etw",
"Win32_Security",
] }
windows-service = "0.7"
quick-xml = "0.41"
[build-dependencies]
tonic-build = "0.12"
+12
View File
@@ -0,0 +1,12 @@
fn main() -> Result<(), Box<dyn std::error::Error>> {
tonic_build::configure()
.build_server(false)
.compile_protos(
&[
"../../proto/sentry/logs/v1/logs.proto",
"../../proto/sentry/agent/v1/agent_control.proto",
],
&["../../proto"],
)?;
Ok(())
}
@@ -0,0 +1,71 @@
# Example cairnobs-agent config. Copy to the platform's conventional path
# (/etc/cairnobs-agent/agent.toml on Linux, C:\ProgramData\CairnObsAgent\agent.toml
# on Windows), or pass --config /path/to/this/file.
#
# Every field has a built-in default (see src/config.rs), so this file only
# needs to contain what you're overriding. An agent with NO config file at
# all still runs: on Linux it defaults to journald, service = "default",
# and expects mTLS material at /etc/cairnobs-agent/{ca,client,client-key}.pem
# (Windows equivalents under C:\ProgramData\CairnObsAgent\).
[agent]
# host = "explicit-hostname-override" # defaults to /etc/hostname (Linux) or %COMPUTERNAME% (Windows)
service = "my-service"
[source]
kind = "journald"
# unit = "nginx.service" # omit to tail the whole journal
# To tail a file instead (works on both Linux and Windows):
# [source]
# kind = "file"
# path = "/var/log/nginx/access.log"
# from_beginning = false
# Windows Event Log (requires the agent to be built with the
# `windows-eventlog` feature — see /agent/README.md):
# [source]
# kind = "eventlog"
# channels = ["Application", "System", "Security"] # this is the default if omitted
# ETW (requires the `etw` feature, and usually elevated privileges — read
# /agent/README.md's privilege section before enabling this):
# [source]
# kind = "etw"
# providers = ["{22FB2CD6-0E7B-422B-A0C7-2FAD1FD0E716}"] # GUIDs, not friendly names
[batch]
max_size = 500
flush_interval_ms = 2000
[heartbeat]
# How often this agent proves it's still alive to the platform, sent as
# its own record independent of whatever real log traffic is flowing --
# pair with an "absence" alert rule on the cairnobs.heartbeat attribute to
# get paged when a host goes quiet. Accepts a plain number + unit: s
# (seconds), m (minutes), or h (hours) -- same vocabulary as
# earliest=/latest= in the query language. See
# /docs/agent-heartbeat-monitoring.md.
enabled = true
interval = "60s"
# interval = "5m"
# interval = "1h"
[metrics]
# Host CPU/memory/disk usage, sent as its own periodic record the same
# way heartbeat is (see web/'s "Hosts" nav section). Off by default --
# unlike heartbeat, this is a deliberate per-host decision: if several
# cairnobs-agent processes run on the same physical host (e.g. one per log
# source), enable this on only ONE of them, or the same host will report
# multiple conflicting metric series. Linux-only for now. Root disk ("/")
# only -- not configurable in this release.
enabled = false
interval = "60s"
[ingest]
endpoint = "https://ingest.internal:4317"
[tls]
ca_cert = "/etc/cairnobs-agent/ca.pem"
client_cert = "/etc/cairnobs-agent/client.pem"
client_key = "/etc/cairnobs-agent/client-key.pem"
+138
View File
@@ -0,0 +1,138 @@
use crate::pb::LogRecord;
use std::time::{Duration, Instant};
/// Buffers `LogRecord`s and signals when to flush, either because the
/// buffer hit `max_size` (checked on every push) or because
/// `flush_interval` elapsed since the last flush (checked by the caller via
/// `poll_timeout` on a timer tick). Not thread-safe by design — one
/// batcher per agent, driven from a single async task's select loop.
pub struct Batcher {
max_size: usize,
flush_interval: Duration,
buf: Vec<LogRecord>,
last_flush: Instant,
}
impl Batcher {
pub fn new(max_size: usize, flush_interval: Duration) -> Self {
Self {
max_size,
flush_interval,
buf: Vec::with_capacity(max_size),
last_flush: Instant::now(),
}
}
/// Push a record. Returns the drained batch if this push filled the
/// buffer to `max_size`.
pub fn push(&mut self, record: LogRecord) -> Option<Vec<LogRecord>> {
self.buf.push(record);
if self.buf.len() >= self.max_size {
Some(self.drain())
} else {
None
}
}
/// Call periodically (e.g. from a timer tick). Returns the drained
/// batch if the flush interval has elapsed and there's anything
/// buffered.
pub fn poll_timeout(&mut self) -> Option<Vec<LogRecord>> {
if !self.buf.is_empty() && self.last_flush.elapsed() >= self.flush_interval {
Some(self.drain())
} else {
None
}
}
/// Unconditionally drains whatever is buffered, ignoring both
/// `max_size` and `flush_interval` -- for shutdown and for a
/// config hot-reload replacing this `Batcher` outright (Phase:
/// agent management's remote config editing), neither of which
/// should silently drop records just because the timeout hadn't
/// elapsed yet.
pub fn flush_all(&mut self) -> Option<Vec<LogRecord>> {
if self.buf.is_empty() {
None
} else {
Some(self.drain())
}
}
fn drain(&mut self) -> Vec<LogRecord> {
self.last_flush = Instant::now();
std::mem::replace(&mut self.buf, Vec::with_capacity(self.max_size))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rec(msg: &str) -> LogRecord {
LogRecord {
timestamp_unix_nano: 0,
host: "h".into(),
service: "s".into(),
severity: 0,
message: msg.into(),
attributes: Default::default(),
record_id: String::new(),
}
}
#[test]
fn flushes_on_size() {
let mut b = Batcher::new(2, Duration::from_secs(999));
assert!(b.push(rec("a")).is_none());
let batch = b.push(rec("b")).expect("should flush at max_size");
assert_eq!(batch.len(), 2);
assert_eq!(batch[0].message, "a");
assert_eq!(batch[1].message, "b");
}
#[test]
fn buffer_empty_after_size_flush() {
let mut b = Batcher::new(1, Duration::from_secs(999));
b.push(rec("a")).expect("flush at max_size 1");
assert!(b.poll_timeout().is_none(), "buffer should be empty post-flush");
}
#[test]
fn flushes_on_timeout() {
let mut b = Batcher::new(100, Duration::from_millis(10));
assert!(b.push(rec("a")).is_none());
std::thread::sleep(Duration::from_millis(30));
let batch = b.poll_timeout().expect("should flush after timeout");
assert_eq!(batch.len(), 1);
}
#[test]
fn no_flush_when_buffer_empty() {
let mut b = Batcher::new(10, Duration::from_millis(1));
std::thread::sleep(Duration::from_millis(5));
assert!(b.poll_timeout().is_none());
}
#[test]
fn no_flush_before_timeout_elapsed() {
let mut b = Batcher::new(10, Duration::from_secs(999));
b.push(rec("a"));
assert!(b.poll_timeout().is_none());
}
// Regression test: shutdown used to call poll_timeout(), which
// silently drops anything buffered before flush_interval elapses --
// real data loss on a graceful shutdown that happened to land
// between flushes. flush_all() is what shutdown (and hot-reload)
// must use instead.
#[test]
fn flush_all_drains_regardless_of_timeout() {
let mut b = Batcher::new(10, Duration::from_secs(999));
b.push(rec("a"));
assert!(b.poll_timeout().is_none(), "sanity: timeout hasn't elapsed");
let batch = b.flush_all().expect("flush_all should drain unconditionally");
assert_eq!(batch.len(), 1);
assert!(b.flush_all().is_none(), "buffer should be empty after draining");
}
}
+330
View File
@@ -0,0 +1,330 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Deserializer};
use std::path::{Path, PathBuf};
use std::time::Duration;
#[cfg(not(windows))]
const DEFAULT_CONFIG_PATH: &str = "/etc/cairnobs-agent/agent.toml";
#[cfg(windows)]
const DEFAULT_CONFIG_PATH: &str = r"C:\ProgramData\CairnObsAgent\agent.toml";
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(default)]
pub struct Config {
pub agent: AgentConfig,
pub source: SourceConfig,
pub batch: BatchConfig,
pub heartbeat: HeartbeatConfig,
pub metrics: MetricsConfig,
pub ingest: IngestConfig,
pub tls: TlsConfig,
}
impl Config {
/// Loads config from `explicit_path` if given, else from the
/// platform's conventional config path if it exists
/// (`/etc/cairnobs-agent/agent.toml` on Linux,
/// `C:\ProgramData\CairnObsAgent\agent.toml` on Windows), else falls
/// back to built-in defaults (journald source on Linux, default TLS
/// cert paths). Only an explicitly-passed `--config` path that doesn't
/// exist is an error; the conventional default path is optional.
pub fn load(explicit_path: Option<&Path>) -> Result<Config> {
let path = match explicit_path {
Some(p) => Some(p.to_path_buf()),
None => {
let default = PathBuf::from(DEFAULT_CONFIG_PATH);
default.exists().then_some(default)
}
};
match path {
Some(p) => {
let raw = std::fs::read_to_string(&p)
.with_context(|| format!("reading config file {}", p.display()))?;
toml::from_str(&raw).with_context(|| format!("parsing config file {}", p.display()))
}
None => Ok(Config::default()),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct AgentConfig {
/// Overrides the auto-detected system hostname. Defaults to reading
/// /etc/hostname at startup when unset.
pub host: Option<String>,
pub service: String,
}
impl Default for AgentConfig {
fn default() -> Self {
Self {
host: None,
service: "default".to_string(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "lowercase", tag = "kind")]
pub enum SourceConfig {
Journald {
#[serde(default)]
#[cfg_attr(not(all(feature = "journald", target_os = "linux")), allow(dead_code))]
unit: Option<String>,
},
// Dead-code-on-default-build, same reasoning as EventLog/Etw below:
// these fields are only read by the `file-tail`-gated arm in
// spawn_source (main.rs), which doesn't exist in the default build
// (`default = ["journald"]`). Pre-existing gap from Phase 0 — CLAUDE.md
// mandates plain `cargo clippy --all-targets -- -D warnings` (no
// --all-features), which this broke silently since only
// --all-features clippy was ever actually run.
File {
#[cfg_attr(not(feature = "file-tail"), allow(dead_code))]
path: PathBuf,
#[serde(default)]
#[cfg_attr(not(feature = "file-tail"), allow(dead_code))]
from_beginning: bool,
},
/// Windows Event Log via EvtSubscribe. Requires the agent to be built
/// with the `windows-eventlog` feature; see /agent/README.md.
///
/// `channels`/`providers` below are read only by the Windows-only
/// consumers in `spawn_source` (main.rs), which don't exist at all on
/// non-Windows builds — unlike `File`'s fields (dead only when the
/// `file-tail` feature happens to be off), these are dead on *every*
/// non-Windows build regardless of feature flags, since their sole
/// consumer is `target_os = "windows"`-gated. `cfg_attr` here keeps
/// clippy honest: still flags genuine dead code on an actual Windows
/// build, just not on the platform where these fields can never be
/// read no matter what.
EventLog {
#[serde(default = "default_eventlog_channels")]
#[cfg_attr(not(windows), allow(dead_code))]
channels: Vec<String>,
},
/// ETW (Event Tracing for Windows). Requires the `etw` feature and
/// (usually) elevated privileges — see /agent/README.md before
/// enabling this in any environment that isn't Windows-first.
Etw {
#[cfg_attr(not(windows), allow(dead_code))]
providers: Vec<String>,
},
}
fn default_eventlog_channels() -> Vec<String> {
vec![
"Application".to_string(),
"System".to_string(),
"Security".to_string(),
]
}
impl Default for SourceConfig {
fn default() -> Self {
SourceConfig::Journald { unit: None }
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct BatchConfig {
pub max_size: usize,
pub flush_interval_ms: u64,
}
impl Default for BatchConfig {
fn default() -> Self {
Self {
max_size: 500,
flush_interval_ms: 2000,
}
}
}
/// Sent independently of `batch` -- a heartbeat is a punctual liveness
/// signal, not log data, so it bypasses `Batcher` entirely (see
/// main.rs's `send_heartbeat`) rather than waiting on `max_size`/
/// `flush_interval_ms` like real records do. This is the operator-facing
/// "polling resolution" knob: how often this agent proves it's still
/// alive, which a `condition_type = "absence"` alert rule on the
/// `cairnobs.heartbeat` attribute (see /docs/agent-heartbeat-monitoring.md)
/// turns into "alert when this host goes quiet."
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct HeartbeatConfig {
pub enabled: bool,
#[serde(deserialize_with = "deserialize_duration")]
pub interval: Duration,
}
impl Default for HeartbeatConfig {
fn default() -> Self {
Self {
enabled: true,
interval: Duration::from_secs(60),
}
}
}
/// Off by default -- same "off unless configured" posture as every other
/// optional feature in this codebase -- since collecting host metrics is
/// a deliberate per-host decision (see /agent/README.md's Hosts-feature
/// notes: only one agent process per physical host should have this on,
/// to avoid duplicate/fragmented metric series when several agent
/// processes share a host under different `[agent] host` overrides).
/// Sent independently of `batch`, same reasoning and mechanism as
/// `HeartbeatConfig` above (see main.rs's `send_metrics`).
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct MetricsConfig {
pub enabled: bool,
#[serde(deserialize_with = "deserialize_duration")]
pub interval: Duration,
}
impl Default for MetricsConfig {
fn default() -> Self {
Self {
enabled: false,
interval: Duration::from_secs(60),
}
}
}
/// Parses a human-friendly duration string with an explicit unit suffix
/// -- "30s", "5m", "1h" -- deliberately the same s/m/h vocabulary
/// `earliest=`/`latest=` use in the query language
/// (/docs/query-language-reference.md), so the interval you set here and
/// the window you write in the matching alert rule's query read the same
/// way. Kept as a small hand-rolled parser rather than pulling in a
/// duration-parsing crate for this one field -- this is the
/// statically-linked edge agent every "no glibc runtime deps" constraint
/// in CLAUDE.md is about keeping lean, and the grammar needed here is a
/// handful of lines.
fn deserialize_duration<'de, D>(deserializer: D) -> Result<Duration, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
parse_duration(&s).map_err(serde::de::Error::custom)
}
fn parse_duration(s: &str) -> Result<Duration, String> {
let s = s.trim();
let (num, unit) = s.split_at(s.len().saturating_sub(1));
let n: u64 = num
.parse()
.map_err(|_| format!("expected a duration like \"30s\", \"5m\", or \"1h\", got {s:?}"))?;
match unit {
"s" => Ok(Duration::from_secs(n)),
"m" => Ok(Duration::from_secs(n * 60)),
"h" => Ok(Duration::from_secs(n * 3600)),
_ => Err(format!("expected a time unit of s, m, or h after {n}, got {s:?}")),
}
}
#[cfg(test)]
mod heartbeat_config_tests {
use super::*;
#[test]
fn parses_seconds_minutes_hours() {
assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30));
assert_eq!(parse_duration("5m").unwrap(), Duration::from_secs(300));
assert_eq!(parse_duration("2h").unwrap(), Duration::from_secs(7200));
}
#[test]
fn rejects_missing_or_unknown_unit() {
assert!(parse_duration("30").is_err());
assert!(parse_duration("30x").is_err());
assert!(parse_duration("").is_err());
}
#[test]
fn default_is_60_seconds_and_enabled() {
let cfg = HeartbeatConfig::default();
assert!(cfg.enabled);
assert_eq!(cfg.interval, Duration::from_secs(60));
}
#[test]
fn toml_field_parses_via_deserialize() {
#[derive(Deserialize)]
struct Wrapper {
#[serde(default)]
heartbeat: HeartbeatConfig,
}
let w: Wrapper = toml::from_str("[heartbeat]\nenabled = true\ninterval = \"90s\"\n").unwrap();
assert_eq!(w.heartbeat.interval, Duration::from_secs(90));
}
}
#[cfg(test)]
mod metrics_config_tests {
use super::*;
#[test]
fn default_is_60_seconds_and_disabled() {
let cfg = MetricsConfig::default();
assert!(!cfg.enabled);
assert_eq!(cfg.interval, Duration::from_secs(60));
}
#[test]
fn toml_field_parses_via_deserialize() {
#[derive(Deserialize)]
struct Wrapper {
#[serde(default)]
metrics: MetricsConfig,
}
let w: Wrapper = toml::from_str("[metrics]\nenabled = true\ninterval = \"30s\"\n").unwrap();
assert!(w.metrics.enabled);
assert_eq!(w.metrics.interval, Duration::from_secs(30));
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct IngestConfig {
pub endpoint: String,
}
impl Default for IngestConfig {
fn default() -> Self {
Self {
endpoint: "https://127.0.0.1:4317".to_string(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct TlsConfig {
pub ca_cert: PathBuf,
pub client_cert: PathBuf,
pub client_key: PathBuf,
}
impl Default for TlsConfig {
fn default() -> Self {
Self {
ca_cert: default_cert_path("ca.pem"),
client_cert: default_cert_path("client.pem"),
client_key: default_cert_path("client-key.pem"),
}
}
}
#[cfg(not(windows))]
fn default_cert_path(name: &str) -> PathBuf {
PathBuf::from(format!("/etc/cairnobs-agent/{name}"))
}
#[cfg(windows)]
fn default_cert_path(name: &str) -> PathBuf {
PathBuf::from(format!(r"C:\ProgramData\CairnObsAgent\{name}"))
}
+57
View File
@@ -0,0 +1,57 @@
use crate::config::{IngestConfig, TlsConfig};
use crate::pb::agent::v1::{agent_control_client::AgentControlClient, CheckInRequest, CheckInResponse};
use crate::pb::{log_ingest_client::LogIngestClient, LogRecord, PushBatchRequest};
use anyhow::{Context, Result};
use tonic::transport::{Certificate, Channel, ClientTlsConfig, Identity};
/// Establishes the one mTLS gRPC channel an agent has to ingest —
/// agents never talk to Redpanda directly, and never accept an inbound
/// connection either (see /docs/architecture.md and
/// /docs/agent-management-design.md). Returns the bare `Channel` rather
/// than a client wrapper so callers can build both `LogIngestClient`
/// (data plane) and `AgentControlClient` (control plane) from the same
/// connection — `Channel` is a cheap-to-clone handle, not the socket
/// itself, so there's no cost to sharing it across two client stubs.
pub async fn connect(ingest: &IngestConfig, tls: &TlsConfig) -> Result<Channel> {
let ca = tokio::fs::read(&tls.ca_cert)
.await
.with_context(|| format!("reading CA cert at {}", tls.ca_cert.display()))?;
let cert = tokio::fs::read(&tls.client_cert)
.await
.with_context(|| format!("reading client cert at {}", tls.client_cert.display()))?;
let key = tokio::fs::read(&tls.client_key)
.await
.with_context(|| format!("reading client key at {}", tls.client_key.display()))?;
let tls_config = ClientTlsConfig::new()
.ca_certificate(Certificate::from_pem(ca))
.identity(Identity::from_pem(cert, key));
Channel::from_shared(ingest.endpoint.clone())
.context("invalid ingest endpoint URL")?
.tls_config(tls_config)
.context("configuring mTLS")?
.connect()
.await
.context("connecting to ingest service")
}
pub async fn send_batch(
client: &mut LogIngestClient<Channel>,
batch_id: String,
records: Vec<LogRecord>,
) -> Result<u32> {
let resp = client
.push_batch(PushBatchRequest { batch_id, records })
.await
.context("PushBatch RPC failed")?;
Ok(resp.into_inner().accepted)
}
pub async fn check_in(
client: &mut AgentControlClient<Channel>,
req: CheckInRequest,
) -> Result<CheckInResponse> {
let resp = client.check_in(req).await.context("CheckIn RPC failed")?;
Ok(resp.into_inner())
}
+615
View File
@@ -0,0 +1,615 @@
mod batch;
mod config;
mod grpc;
mod metrics;
mod source;
#[cfg(windows)]
mod service;
pub mod pb {
tonic::include_proto!("sentry.logs.v1");
pub mod agent {
pub mod v1 {
tonic::include_proto!("sentry.agent.v1");
}
}
}
use anyhow::{Context, Result};
use batch::Batcher;
use clap::Parser;
use config::Config;
use pb::agent::v1::{agent_control_client::AgentControlClient, AgentCommand, CheckInRequest, DesiredOverride, ReportedConfig};
use pb::{log_ingest_client::LogIngestClient, LogRecord, Severity};
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::PathBuf;
use std::time::Duration;
use tokio::sync::mpsc;
use tonic::transport::Channel;
#[derive(Parser)]
#[command(name = "cairnobs-agent", about = "Cairn OBS Linux/Windows log collector")]
struct Cli {
/// Path to a TOML config file. Defaults to the platform's conventional
/// path if present, otherwise built-in defaults — see config::Config::load.
#[arg(long)]
config: Option<PathBuf>,
#[cfg(windows)]
#[command(subcommand)]
command: Option<WindowsCommand>,
}
#[cfg(windows)]
#[derive(clap::Subcommand)]
enum WindowsCommand {
/// Registers this binary as a Windows service (Automatic start,
/// LocalSystem account). Requires an administrator shell.
Install,
/// Removes the Windows service registration.
Uninstall,
/// Entry point the Service Control Manager invokes when starting the
/// registered service. Not meant to be run directly by a user — use
/// `cairnobs-agent` with no subcommand for a normal foreground/console
/// run, same as on Linux.
RunService,
}
/// Not `#[tokio::main]`: the Windows service dispatcher
/// (`service_dispatcher::start`, see service.rs) is a blocking, synchronous
/// FFI call into the Service Control Manager and needs to be invoked
/// directly from a plain thread, not from inside an already-running tokio
/// runtime. Every other path builds its own runtime explicitly instead.
fn main() -> Result<()> {
let cli = Cli::parse();
#[cfg(windows)]
{
match cli.command {
Some(WindowsCommand::Install) => return service::install().context("installing Windows service"),
Some(WindowsCommand::Uninstall) => return service::uninstall().context("removing Windows service"),
Some(WindowsCommand::RunService) => return service::run_as_service().context("running as a Windows service"),
None => {}
}
}
let rt = tokio::runtime::Runtime::new().context("building tokio runtime")?;
rt.block_on(run_agent(cli.config))
}
/// The actual agent: load config, connect to ingest, run the source ->
/// parse -> batch -> ship loop until the source exits or the process is
/// signaled to stop. Called from `main()` directly for a normal run, and
/// from within the Windows service's own thread when running as a
/// service (see service.rs) — same logic either way.
pub async fn run_agent(config_path: Option<PathBuf>) -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
let cfg = Config::load(config_path.as_deref()).context("loading config")?;
let host = cfg.agent.host.clone().unwrap_or_else(default_hostname);
let service = cfg.agent.service.clone();
// One long-lived channel for the whole process, not one per source
// task -- both the primary source and every extra-file-path task
// (see apply_override's extra_file_paths handling) send into clones
// of the same `tx`. This matters specifically because the primary
// source can be aborted and respawned at runtime (a journald_unit
// override): if each respawn created a *new* channel the way this
// used to work, any extra-file-path task still holding a clone of
// the old `tx` would be silently orphaned (sending into a channel
// whose `rx` had just been replaced/dropped). Creating the channel
// once and only ever swapping the *task* (never the channel) avoids
// that entirely.
let (tx, mut rx) = mpsc::channel(1024);
// source_cfg is mutable: a remote DesiredOverride's journald_unit
// field (see apply_override below) can change it at runtime, which
// means aborting and respawning the source task with the new
// filter -- source_handle is mutable for the same reason.
let mut source_cfg = cfg.source.clone();
let mut source_handle = spawn_source_task(source_cfg.clone(), tx.clone());
// Extra file paths an operator has remotely added via the web UI
// (see apply_override's extra_file_paths handling) -- empty until
// the first such override arrives. Keyed by path so a later
// override with a different path list can be diffed against what's
// already running: abort tasks for paths that were removed, spawn
// tasks for paths that are new, leave everything else untouched.
let mut extra_file_tasks: HashMap<PathBuf, tokio::task::JoinHandle<()>> = HashMap::new();
let channel = grpc::connect(&cfg.ingest, &cfg.tls)
.await
.context("connecting to ingest service")?;
let mut client = LogIngestClient::new(channel.clone());
let mut control_client = AgentControlClient::new(channel);
tracing::info!(endpoint = %cfg.ingest.endpoint, "connected to ingest service");
// Effective runtime settings, seeded from local config -- every one
// of these is mutable because a remote DesiredOverride can change
// it (see apply_override). The local agent.toml is never rewritten;
// an override lives only in memory here and reverts to agent.toml's
// own values on restart, re-syncing on the next successful CheckIn
// (see /docs/agent-management-design.md's merge-semantics section).
let mut batch_max_size = cfg.batch.max_size;
let mut flush_interval = Duration::from_millis(cfg.batch.flush_interval_ms);
let mut heartbeat_enabled = cfg.heartbeat.enabled;
let mut heartbeat_interval = cfg.heartbeat.interval;
// Not remotely overridable (unlike batch/heartbeat above) -- see
// /agent/README.md's Hosts-feature notes on why this is a
// deliberate, per-host, config-file-only decision, not something
// the web UI can flip on for an arbitrary agent.
let metrics_enabled = cfg.metrics.enabled;
let metrics_interval = cfg.metrics.interval;
// Empty until the first override is ever applied -- echoed back on
// every CheckIn as-is so the server can tell "pending" (an edit
// exists this agent hasn't picked up) from "applied."
let mut applied_override_version = String::new();
let mut batcher = Batcher::new(batch_max_size, flush_interval);
let mut ticker = tokio::time::interval(flush_interval.max(Duration::from_millis(50)));
// Heartbeat's own ticker, independent of the batch flush ticker above
// -- it always fires on heartbeat_interval regardless of the batch
// settings or whether any real log traffic is flowing. Also drives
// CheckIn (see the arm below) unconditionally -- CheckIn keeps
// running even when heartbeat_enabled is false, since that's an
// agent's only path to ever receive a remote override that
// re-enables it; only the heartbeat log record itself is gated on
// heartbeat_enabled.
let mut heartbeat_ticker = tokio::time::interval(heartbeat_interval.max(Duration::from_millis(50)));
// Unlike heartbeat_ticker, metrics has no secondary purpose (no
// CheckIn-equivalent side effect) to keep it running when disabled,
// so the select! arm below skips it entirely via `if metrics_enabled`
// rather than always firing and conditionally acting.
let mut metrics_ticker = tokio::time::interval(metrics_interval.max(Duration::from_millis(50)));
loop {
tokio::select! {
_ = metrics_ticker.tick(), if metrics_enabled => {
send_metrics(&mut client, &host, &service).await;
}
_ = heartbeat_ticker.tick() => {
if heartbeat_enabled {
send_heartbeat(&mut client, &host, &service).await;
}
let reported = ReportedConfig {
agent_version: env!("CARGO_PKG_VERSION").to_string(),
source_kind: source_kind_name(&source_cfg),
source_detail: source_detail_summary(&source_cfg),
batch_max_size: batch_max_size as u64,
batch_flush_interval_ms: flush_interval.as_millis() as u64,
heartbeat_enabled,
heartbeat_interval_ms: heartbeat_interval.as_millis() as u64,
};
match grpc::check_in(&mut control_client, CheckInRequest {
host: host.clone(),
service: service.clone(),
current_config: Some(reported),
applied_override_version: applied_override_version.clone(),
}).await {
Ok(resp) => {
if let Some(ov) = resp.has_override.then_some(resp.r#override).flatten() {
if ov.version != applied_override_version {
apply_override(
&ov,
&mut batch_max_size, &mut flush_interval,
&mut heartbeat_enabled, &mut heartbeat_interval,
&mut batcher, &mut ticker, &mut heartbeat_ticker,
&mut source_cfg, &mut source_handle,
&mut extra_file_tasks, &tx,
&mut client,
).await;
applied_override_version = ov.version.clone();
tracing::info!(version = %applied_override_version, "applied remote config override");
}
}
if AgentCommand::try_from(resp.pending_command) == Ok(AgentCommand::Restart) {
tracing::info!("received remote restart command, shutting down gracefully");
// flush_all(), not poll_timeout(): same reasoning as
// the normal shutdown path below -- whatever's
// buffered must go out regardless of whether
// flush_interval has elapsed yet.
if let Some(batch) = batcher.flush_all() {
flush(&mut client, batch).await;
}
source_handle.abort();
// A hard process exit, not a `break` out of this
// loop: this agent's own restart policy is
// entirely the host's service manager's
// responsibility (systemd/Windows SCM), the same
// contract any well-behaved service relies on --
// see AgentCommand's doc comment for why STOP/
// UNINSTALL need real per-platform work this
// doesn't.
std::process::exit(0);
}
}
// A failed check-in is not fatal -- same graceful-
// degradation posture as a failed heartbeat/batch
// flush: an agent management feature being
// unreachable must never stop log collection.
Err(e) => tracing::debug!(error = %e, "check-in failed"),
}
}
maybe_line = rx.recv() => {
let Some(raw) = maybe_line else {
tracing::warn!("source exited, flushing remaining batch and shutting down");
break;
};
let parsed = cairnobs_parser::parse(&raw.line);
let severity = to_pb_severity(raw.severity_hint.or(parsed.severity));
let mut attributes: std::collections::HashMap<String, String> =
parsed.attributes.into_iter().collect();
// Source-provided structured fields (e.g. Windows Event
// Log's EventID/Provider/Channel) win over anything the
// RFC 5424 parser inferred from the raw text, since they
// come from a more authoritative place.
attributes.extend(raw.extra_attributes);
let record = LogRecord {
timestamp_unix_nano: raw.timestamp_unix_nano,
host: host.clone(),
service: service.clone(),
severity: severity as i32,
message: parsed.message,
attributes,
// Always empty as sent by the agent -- ingest assigns
// this server-side. See the proto field comment.
record_id: String::new(),
};
if let Some(batch) = batcher.push(record) {
flush(&mut client, batch).await;
}
}
_ = ticker.tick() => {
if let Some(batch) = batcher.poll_timeout() {
flush(&mut client, batch).await;
}
}
}
}
// flush_all(), not poll_timeout(): shutdown must send whatever's
// buffered unconditionally -- poll_timeout() only drains once
// flush_interval has elapsed, so anything buffered more recently
// than that would otherwise be silently dropped on every graceful
// shutdown that happens to land between flushes. Same reasoning
// applies to a config hot-reload replacing this batcher outright
// (see apply_override).
if let Some(batch) = batcher.flush_all() {
flush(&mut client, batch).await;
}
source_handle.abort();
Ok(())
}
/// Applies a newly-received DesiredOverride to the agent's in-memory
/// runtime state -- see run_agent's CheckIn arm, and
/// /docs/agent-management-design.md's merge-semantics section for why
/// this never touches the local agent.toml file. Every field is
/// independently optional (unset = keep the current value); batch/
/// heartbeat settings always get their batcher/ticker rebuilt together
/// when *any* override arrives, for simplicity, rather than tracking
/// which specific field changed -- this only runs when a human edits an
/// agent's config from the web UI, not on a hot path, so the extra
/// timer/allocation churn doesn't matter.
#[allow(clippy::too_many_arguments)]
async fn apply_override(
ov: &DesiredOverride,
batch_max_size: &mut usize,
flush_interval: &mut Duration,
heartbeat_enabled: &mut bool,
heartbeat_interval: &mut Duration,
batcher: &mut Batcher,
ticker: &mut tokio::time::Interval,
heartbeat_ticker: &mut tokio::time::Interval,
source_cfg: &mut config::SourceConfig,
source_handle: &mut tokio::task::JoinHandle<()>,
extra_file_tasks: &mut HashMap<PathBuf, tokio::task::JoinHandle<()>>,
tx: &source::LineSender,
client: &mut LogIngestClient<Channel>,
) {
if let Some(v) = ov.batch_max_size {
*batch_max_size = v as usize;
}
if let Some(v) = ov.batch_flush_interval_ms {
*flush_interval = Duration::from_millis(v);
}
// Flush whatever the old batcher was holding before replacing it --
// a hot-reload must never silently drop buffered-but-not-yet-due
// records, same reasoning as shutdown's flush_all() above.
if let Some(old) = batcher.flush_all() {
flush(client, old).await;
}
*batcher = Batcher::new(*batch_max_size, *flush_interval);
*ticker = tokio::time::interval((*flush_interval).max(Duration::from_millis(50)));
if let Some(v) = ov.heartbeat_enabled {
*heartbeat_enabled = v;
}
if let Some(v) = ov.heartbeat_interval_ms {
*heartbeat_interval = Duration::from_millis(v);
}
*heartbeat_ticker = tokio::time::interval((*heartbeat_interval).max(Duration::from_millis(50)));
// Only meaningful (and only ever sent by the server) when this
// agent's local source is journald -- ignored otherwise, per
// agent_control.proto's DesiredOverride.journald_unit comment.
// Changing it means aborting and respawning the source task: unlike
// batch/heartbeat, there's no way to change what journald::run is
// tailing without restarting that task.
if let Some(unit) = &ov.journald_unit {
if let config::SourceConfig::Journald { unit: current_unit } = source_cfg {
let new_unit = if unit.is_empty() { None } else { Some(unit.clone()) };
if *current_unit != new_unit {
*source_cfg = config::SourceConfig::Journald { unit: new_unit };
source_handle.abort();
*source_handle = spawn_source_task(source_cfg.clone(), tx.clone());
tracing::info!(unit = ?unit, "applied remote journald unit override, restarted source");
}
}
}
// Extra file paths to tail alongside whatever the primary source
// above is -- see agent_control.proto's DesiredOverride.
// extra_file_paths comment. Diffed against what's already running
// (extra_file_tasks' keys) rather than blindly tearing everything
// down and respawning: an edit to, say, heartbeat_interval_ms must
// never interrupt a file that's already being tailed and hasn't
// changed. `ov.extra_file_paths` is the complete desired list every
// time (never a partial patch -- see the proto field's own doc
// comment), so anything not in it gets removed.
let desired: HashSet<PathBuf> = ov.extra_file_paths.iter().map(PathBuf::from).collect();
let current: HashSet<PathBuf> = extra_file_tasks.keys().cloned().collect();
for removed in current.difference(&desired) {
if let Some(handle) = extra_file_tasks.remove(removed) {
handle.abort();
tracing::info!(path = %removed.display(), "stopped tailing removed extra file path");
}
}
for added in desired.difference(&current) {
extra_file_tasks.insert(added.clone(), spawn_extra_file_task(added.clone(), tx.clone()));
tracing::info!(path = %added.display(), "started tailing new extra file path");
}
}
fn spawn_source_task(source_cfg: config::SourceConfig, tx: source::LineSender) -> tokio::task::JoinHandle<()> {
tokio::spawn(spawn_source(source_cfg, tx))
}
/// Same shape as `spawn_source`'s own per-source-kind feature gating --
/// an extra file path is really just another `File` source, tailed
/// from the end (no `from_beginning` knob for these; matches the
/// primary `file` source's own sensible default), feeding into the
/// same shared channel every other source in this process uses.
#[cfg_attr(not(feature = "file-tail"), allow(unused_variables))]
fn spawn_extra_file_task(path: PathBuf, tx: source::LineSender) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
#[cfg(feature = "file-tail")]
let result = source::file_tail::run(&path, false, tx).await;
#[cfg(not(feature = "file-tail"))]
let result: Result<(), anyhow::Error> =
Err(anyhow::anyhow!("this build was compiled without the `file-tail` feature"));
if let Err(e) = result {
tracing::error!(error = %e, path = %path.display(), "extra file path exited with error");
}
})
}
fn source_kind_name(cfg: &config::SourceConfig) -> String {
match cfg {
config::SourceConfig::Journald { .. } => "journald".to_string(),
config::SourceConfig::File { .. } => "file".to_string(),
config::SourceConfig::EventLog { .. } => "eventlog".to_string(),
config::SourceConfig::Etw { .. } => "etw".to_string(),
}
}
fn source_detail_summary(cfg: &config::SourceConfig) -> String {
match cfg {
config::SourceConfig::Journald { unit } => unit.clone().unwrap_or_else(|| "(whole journal)".to_string()),
config::SourceConfig::File { path, .. } => path.display().to_string(),
config::SourceConfig::EventLog { channels } => channels.join(","),
config::SourceConfig::Etw { providers } => providers.join(","),
}
}
// `tx` genuinely goes unused in one rare-but-valid combination: Windows
// features enabled while targeting a non-Windows platform (e.g. sanity-
// checking the Windows source arms compile shape from Linux, which is
// exactly how these were checked before a real Windows toolchain was
// available) collapses every arm to the tx-free `Err(...)` fallback.
#[allow(unused_variables)]
async fn spawn_source(source: config::SourceConfig, tx: source::LineSender) {
// Explicit type: with an unusual feature combination (e.g.
// windows-eventlog enabled while targeting Linux), every arm below can
// collapse to the same untyped `Err(...)` fallback, and Rust can't
// infer the Ok type without at least one real `.await` call anywhere
// in the compiled match to anchor it.
let result: Result<(), anyhow::Error> = match source {
#[cfg(all(feature = "journald", target_os = "linux"))]
config::SourceConfig::Journald { unit } => source::journald::run(unit.as_deref(), tx).await,
#[cfg(not(all(feature = "journald", target_os = "linux")))]
config::SourceConfig::Journald { .. } => {
Err(anyhow::anyhow!("this build was compiled without the `journald` feature (or isn't targeting Linux)"))
}
#[cfg(feature = "file-tail")]
config::SourceConfig::File { path, from_beginning } => {
source::file_tail::run(&path, from_beginning, tx).await
}
#[cfg(not(feature = "file-tail"))]
config::SourceConfig::File { .. } => {
Err(anyhow::anyhow!("this build was compiled without the `file-tail` feature"))
}
#[cfg(all(feature = "windows-eventlog", target_os = "windows"))]
config::SourceConfig::EventLog { channels } => source::windows_eventlog::run(&channels, tx).await,
#[cfg(not(all(feature = "windows-eventlog", target_os = "windows")))]
config::SourceConfig::EventLog { .. } => {
Err(anyhow::anyhow!("this build was compiled without the `windows-eventlog` feature (or isn't targeting Windows)"))
}
#[cfg(all(feature = "etw", target_os = "windows"))]
config::SourceConfig::Etw { providers } => source::etw::run(&providers, tx).await,
#[cfg(not(all(feature = "etw", target_os = "windows")))]
config::SourceConfig::Etw { .. } => {
Err(anyhow::anyhow!("this build was compiled without the `etw` feature (or isn't targeting Windows)"))
}
};
if let Err(e) = result {
tracing::error!(error = %e, "log source exited with error");
}
}
/// Sends a single synthetic record through the same `PushBatch` RPC and
/// mTLS identity as real log data -- no new proto message, no new ingest
/// code, no new ClickHouse schema. Bypasses `Batcher` (see the heartbeat
/// ticker's own comment above): a heartbeat that got queued behind
/// `batch.max_size` or `batch.flush_interval_ms` would defeat the point
/// of a punctual "still alive" signal. Distinguished from a real log
/// record purely by the `cairnobs.heartbeat` attribute -- `service` stays
/// the agent's real configured service so it doesn't pollute
/// service-based dashboards/faceting with a fake value. See
/// /docs/agent-heartbeat-monitoring.md for how an absence alert rule
/// turns a run of missed heartbeats into a notification.
async fn send_heartbeat(client: &mut LogIngestClient<Channel>, host: &str, service: &str) {
let record = LogRecord {
timestamp_unix_nano: now_unix_nanos(),
host: host.to_string(),
service: service.to_string(),
severity: Severity::Info as i32,
message: "agent heartbeat".to_string(),
attributes: std::collections::HashMap::from([("cairnobs.heartbeat".to_string(), "true".to_string())]),
record_id: String::new(),
};
match grpc::send_batch(client, format!("heartbeat-{}", batch_id()), vec![record]).await {
Ok(_) => tracing::debug!(host, "heartbeat sent"),
Err(e) => tracing::warn!(error = %e, host, "heartbeat send failed"),
}
}
/// Same "no new proto, no new ingest code, no new ClickHouse schema"
/// shape as `send_heartbeat` above -- a metrics sample is just another
/// tagged `LogRecord`, distinguished by the `cairnobs.metrics` attribute.
/// Unlike heartbeat, the numeric fields themselves are real query-language
/// attributes too (`cpu_percent`, `mem_used_bytes`, etc.) rather than
/// being folded into `message` -- confirmed before building this that
/// arbitrary attribute names are transparently queryable/comparable as
/// numbers (`api/querylang/executor/sql.go`'s `topLevelFields` fallback
/// to `attributes['field']` with automatic numeric casting), so there's
/// no need to encode them as a JSON blob in `message` and parse it
/// client-side instead.
async fn send_metrics(client: &mut LogIngestClient<Channel>, host: &str, service: &str) {
let m = match metrics::collect("/").await {
Ok(m) => m,
Err(e) => {
tracing::warn!(error = %e, host, "collecting host metrics failed");
return;
}
};
let record = LogRecord {
timestamp_unix_nano: now_unix_nanos(),
host: host.to_string(),
service: service.to_string(),
severity: Severity::Info as i32,
message: "host metrics".to_string(),
attributes: std::collections::HashMap::from([
("cairnobs.metrics".to_string(), "true".to_string()),
("cpu_percent".to_string(), format!("{:.2}", m.cpu_percent)),
("mem_used_bytes".to_string(), m.mem_used_bytes.to_string()),
("mem_total_bytes".to_string(), m.mem_total_bytes.to_string()),
("disk_used_bytes".to_string(), m.disk_used_bytes.to_string()),
("disk_total_bytes".to_string(), m.disk_total_bytes.to_string()),
// Static-or-slow-changing context, not utilization numbers --
// sent on the same record so a viewer never has to correlate
// two different samples to make sense of the numbers above
// (see metrics::Metrics's doc comment).
("cpu_cores".to_string(), m.cpu_cores.to_string()),
("os_name".to_string(), m.os_name.clone()),
("kernel_version".to_string(), m.kernel_version.clone()),
("arch".to_string(), m.arch.to_string()),
("uptime_seconds".to_string(), m.uptime_seconds.to_string()),
// Comma-joined -- LogRecord attributes are string-valued,
// and a host can have more than one address per family
// (multi-NIC, or a v6 privacy/temporary address alongside
// the stable one). Empty string, not an omitted key, when
// a host genuinely has none of a given family -- matches
// every other soft-failed context field here (see
// metrics::collect's unwrap_or_default for this one).
("ipv4_addresses".to_string(), m.ipv4_addresses.join(",")),
("ipv6_addresses".to_string(), m.ipv6_addresses.join(",")),
]),
record_id: String::new(),
};
match grpc::send_batch(client, format!("metrics-{}", batch_id()), vec![record]).await {
Ok(_) => tracing::debug!(host, "metrics sent"),
Err(e) => tracing::warn!(error = %e, host, "metrics send failed"),
}
}
fn now_unix_nanos() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as i64)
.unwrap_or(0)
}
async fn flush(client: &mut LogIngestClient<Channel>, batch: Vec<LogRecord>) {
let n = batch.len();
let batch_id = batch_id();
match grpc::send_batch(client, batch_id, batch).await {
Ok(accepted) => tracing::debug!(accepted, sent = n, "batch flushed"),
Err(e) => tracing::error!(error = %e, sent = n, "batch flush failed"),
}
}
/// Best-effort batch identifier for ingest-side dedup on retry. Not
/// globally unique (host + nanosecond timestamp), which is good enough for
/// Phase 0's single-agent-per-host reality; revisit if agents ever share
/// an identity or clock resolution becomes a problem.
fn batch_id() -> String {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
format!("{nanos:x}")
}
fn to_pb_severity(sev: Option<u8>) -> Severity {
match sev {
Some(0..=2) => Severity::Fatal, // emerg / alert / crit
Some(3) => Severity::Error, // err
Some(4) => Severity::Warn, // warning
Some(5) | Some(6) => Severity::Info, // notice / info
Some(7) => Severity::Debug, // debug
_ => Severity::Unspecified,
}
}
#[cfg(not(windows))]
fn default_hostname() -> String {
if let Ok(s) = std::fs::read_to_string("/etc/hostname") {
let s = s.trim().to_string();
if !s.is_empty() {
return s;
}
}
std::env::var("HOSTNAME").unwrap_or_else(|_| "unknown-host".to_string())
}
#[cfg(windows)]
fn default_hostname() -> String {
// Windows sets this in every process's environment; no Win32 API call
// needed (GetComputerNameW would be the "proper" way, but this is the
// same value and far simpler).
std::env::var("COMPUTERNAME").unwrap_or_else(|_| "unknown-host".to_string())
}
+394
View File
@@ -0,0 +1,394 @@
use anyhow::{Context, Result};
use std::fs;
use std::process::Command;
use std::time::Duration;
pub struct Metrics {
pub cpu_percent: f64,
pub mem_used_bytes: u64,
pub mem_total_bytes: u64,
pub disk_used_bytes: u64,
pub disk_total_bytes: u64,
/// The rest of these are static-or-slow-changing context, not
/// utilization numbers -- sent alongside the utilization fields on
/// the same record (rather than as a separate one-off record) so a
/// viewer never has to correlate two different samples to answer
/// "is 21% CPU busy or idle for this box" (needs core count) or
/// "is this usage normal" (needs how long it's been running).
pub cpu_cores: u32,
pub os_name: String,
pub kernel_version: String,
pub arch: &'static str,
pub uptime_seconds: u64,
/// Non-loopback, non-link-local addresses only -- a host's `fe80::/10`
/// and `127.0.0.1`/`::1` are never what a viewer means by "this
/// host's IP", and would just add noise. Sorted and deduplicated,
/// but otherwise unfiltered: a multi-NIC host reports every address
/// it has, not just one "primary" guess (there's no reliable way to
/// pick a single "the" address from userspace without also knowing
/// which interface actually carries this host's traffic).
pub ipv4_addresses: Vec<String>,
pub ipv6_addresses: Vec<String>,
}
/// Collects a point-in-time snapshot of host resource usage plus the
/// system context needed to make it legible. Linux-only for now (a
/// disclosed gap, not a silent assumption -- see /agent/README.md):
/// every host this has been deployed to so far is Linux, and a Windows
/// implementation (perf counters/WMI) is real future work, not
/// attempted here.
pub async fn collect(disk_path: &str) -> Result<Metrics> {
let cpu_percent = cpu_percent().await.context("reading CPU usage")?;
let (mem_used_bytes, mem_total_bytes) = memory().context("reading memory usage")?;
let (disk_used_bytes, disk_total_bytes) = disk(disk_path).context("reading disk usage")?;
// Soft-fail on all four: none of these should ever cost a whole
// sample (losing real cpu_percent/mem/disk numbers) just because
// e.g. /etc/os-release is missing on some minimal distro --
// consistent with this codebase's existing "an optional feature's
// failure must never take down the thing it's supplementing"
// posture (see send_heartbeat/CheckIn's own graceful degradation).
let cpu_cores = cpu_cores().unwrap_or(0);
let os_name = os_name().unwrap_or_else(|_| "unknown".to_string());
let kernel_version = kernel_version().unwrap_or_else(|_| "unknown".to_string());
let uptime_seconds = uptime_seconds().unwrap_or(0);
let (ipv4_addresses, ipv6_addresses) = ip_addresses().unwrap_or_default();
Ok(Metrics {
cpu_percent,
mem_used_bytes,
mem_total_bytes,
disk_used_bytes,
disk_total_bytes,
cpu_cores,
os_name,
kernel_version,
arch: std::env::consts::ARCH,
uptime_seconds,
ipv4_addresses,
ipv6_addresses,
})
}
/// Two `/proc/stat` samples ~200ms apart, delta-based -- the standard
/// technique every `top`-like tool uses, since a single snapshot of
/// cumulative jiffies-since-boot can't express a percentage on its own.
/// Self-contained (no state threaded through main.rs's `select!` loop)
/// at the cost of blocking this one `collect()` call for ~200ms once
/// per `metrics.interval` tick -- an acceptable trade against the
/// complexity of holding a previous-sample struct across ticks in an
/// already-busy loop, for a feature that only runs once a minute by
/// default.
async fn cpu_percent() -> Result<f64> {
let (total1, idle1) = read_proc_stat()?;
tokio::time::sleep(Duration::from_millis(200)).await;
let (total2, idle2) = read_proc_stat()?;
let total_delta = total2.saturating_sub(total1);
let idle_delta = idle2.saturating_sub(idle1);
if total_delta == 0 {
return Ok(0.0);
}
Ok((1.0 - (idle_delta as f64 / total_delta as f64)) * 100.0)
}
fn read_proc_stat() -> Result<(u64, u64)> {
let contents = fs::read_to_string("/proc/stat").context("reading /proc/stat")?;
parse_proc_stat(&contents)
}
/// Parses `/proc/stat`'s leading "cpu " line: user nice system idle
/// iowait irq softirq steal guest guest_nice, all in USER_HZ jiffies
/// since boot. Returns (total, idle) -- idle here is idle+iowait,
/// matching what every standard CPU%-from-/proc/stat implementation
/// treats as "not busy" (iowait is a CPU waiting on I/O, not doing
/// work, even though the kernel's own `idle` field alone doesn't
/// include it).
fn parse_proc_stat(contents: &str) -> Result<(u64, u64)> {
let line = contents
.lines()
.find(|l| l.starts_with("cpu "))
.context("/proc/stat has no leading \"cpu \" line")?;
let fields: Vec<u64> = line.split_whitespace().skip(1).filter_map(|f| f.parse().ok()).collect();
if fields.len() < 4 {
anyhow::bail!("unexpected /proc/stat format: {line:?}");
}
let idle = fields[3] + fields.get(4).copied().unwrap_or(0);
let total: u64 = fields.iter().sum();
Ok((total, idle))
}
fn memory() -> Result<(u64, u64)> {
let contents = fs::read_to_string("/proc/meminfo").context("reading /proc/meminfo")?;
parse_meminfo(&contents)
}
/// Parses `/proc/meminfo`'s MemTotal/MemAvailable (kB). MemAvailable
/// (not MemFree) is the kernel's own "how much could a new process
/// actually get" estimate, accounting for reclaimable caches/buffers --
/// what a human means by "memory used" far better than MemFree alone
/// (a system with most of RAM in disk cache but MemFree near zero is
/// not actually under memory pressure).
fn parse_meminfo(contents: &str) -> Result<(u64, u64)> {
let mut total_kb = None;
let mut available_kb = None;
for line in contents.lines() {
if let Some(v) = line.strip_prefix("MemTotal:") {
total_kb = parse_meminfo_kb(v);
} else if let Some(v) = line.strip_prefix("MemAvailable:") {
available_kb = parse_meminfo_kb(v);
}
}
let total_kb = total_kb.context("MemTotal not found in /proc/meminfo")?;
let available_kb = available_kb.context("MemAvailable not found in /proc/meminfo")?;
let used_kb = total_kb.saturating_sub(available_kb);
Ok((used_kb * 1024, total_kb * 1024))
}
fn parse_meminfo_kb(s: &str) -> Option<u64> {
s.trim().trim_end_matches("kB").trim().parse().ok()
}
fn disk(path: &str) -> Result<(u64, u64)> {
let output = Command::new("df").arg("-B1").arg(path).output().context("running df")?;
if !output.status.success() {
anyhow::bail!("df exited with status {}: {}", output.status, String::from_utf8_lossy(&output.stderr));
}
parse_df_output(&String::from_utf8_lossy(&output.stdout))
}
/// Shells out to `df` rather than linking a statvfs binding -- same
/// "shell out to a boring, ubiquitous tool rather than add a dependency
/// or FFI binding" precedent `source/journald.rs` already sets for
/// `journalctl` (see /agent/README.md's "Why journalctl, not
/// libsystemd"). `-B1` requests byte-granularity output instead of the
/// default 1K-block units, so no unit conversion is needed here. Total
/// is `used + available`, not the raw block count `df` also reports --
/// some filesystems (ext4's default ~5% root reservation) hold back
/// blocks a normal process can never use, which would make a "percent
/// full" computed against the raw total look artificially low; `used +
/// available` matches what `df`'s own `Use%` column is computed
/// against.
fn parse_df_output(stdout: &str) -> Result<(u64, u64)> {
let data_line = stdout.lines().nth(1).context("df produced no data line")?;
let fields: Vec<&str> = data_line.split_whitespace().collect();
// Filesystem, 1B-blocks, Used, Available, Use%, Mounted on
if fields.len() < 4 {
anyhow::bail!("unexpected df output: {data_line:?}");
}
let used: u64 = fields[2].parse().context("parsing df's Used column")?;
let available: u64 = fields[3].parse().context("parsing df's Available column")?;
Ok((used, used + available))
}
fn cpu_cores() -> Result<u32> {
let contents = fs::read_to_string("/proc/cpuinfo").context("reading /proc/cpuinfo")?;
parse_cpuinfo_core_count(&contents)
}
/// Counts `processor\t: N` lines in `/proc/cpuinfo` -- one per logical
/// CPU (a hyperthreaded core counts as two, same as what `nproc`/every
/// scheduler-facing tool means by "CPU count"), which is what
/// `cpu_percent`'s 0-100 scale is an average across.
fn parse_cpuinfo_core_count(contents: &str) -> Result<u32> {
let n = contents.lines().filter(|l| l.starts_with("processor")).count() as u32;
if n == 0 {
anyhow::bail!("no \"processor\" lines found in /proc/cpuinfo");
}
Ok(n)
}
fn os_name() -> Result<String> {
let contents = fs::read_to_string("/etc/os-release").context("reading /etc/os-release")?;
parse_os_release_pretty_name(&contents)
}
/// Parses `/etc/os-release`'s `PRETTY_NAME="..."` line (e.g. "Debian
/// GNU/Linux 13 (trixie)") -- the one field every distro's os-release
/// is guaranteed to carry for exactly this "show a human a readable OS
/// name" purpose (see os-release(5)).
fn parse_os_release_pretty_name(contents: &str) -> Result<String> {
contents
.lines()
.find_map(|l| l.strip_prefix("PRETTY_NAME="))
.map(|v| v.trim().trim_matches('"').to_string())
.context("PRETTY_NAME not found in /etc/os-release")
}
/// `/proc/sys/kernel/osrelease` is just the bare version string (e.g.
/// "6.12.90+deb13.1-amd64") with no parsing needed -- simpler and more
/// robust than picking the version back out of `/proc/version`'s
/// free-form `uname -a`-style sentence.
fn kernel_version() -> Result<String> {
Ok(fs::read_to_string("/proc/sys/kernel/osrelease")
.context("reading /proc/sys/kernel/osrelease")?
.trim()
.to_string())
}
fn uptime_seconds() -> Result<u64> {
let contents = fs::read_to_string("/proc/uptime").context("reading /proc/uptime")?;
parse_uptime(&contents)
}
/// `/proc/uptime`'s first field is seconds since boot (as a float, to
/// centisecond precision) -- the second field (total idle time summed
/// across all cores) isn't relevant here.
fn parse_uptime(contents: &str) -> Result<u64> {
let first = contents.split_whitespace().next().context("/proc/uptime is empty")?;
let seconds: f64 = first.parse().context("parsing /proc/uptime's first field")?;
Ok(seconds as u64)
}
fn ip_addresses() -> Result<(Vec<String>, Vec<String>)> {
let output = Command::new("ip").arg("-o").arg("addr").arg("show").output().context("running ip addr show")?;
if !output.status.success() {
anyhow::bail!("ip exited with status {}: {}", output.status, String::from_utf8_lossy(&output.stderr));
}
Ok(parse_ip_addr_output(&String::from_utf8_lossy(&output.stdout)))
}
/// Shells out to `ip -o addr show` -- same "boring, ubiquitous tool"
/// precedent `disk`'s `df` call and `source/journald.rs`'s `journalctl`
/// call already set, over an FFI binding to `getifaddrs(3)`. `-o`
/// (oneline) puts each address on its own line, e.g.:
/// 2: eth0 inet 172.239.44.244/24 brd ... scope global eth0\ ...
/// 2: eth0 inet6 fe80::1/64 scope link \ ...
/// Skips the loopback interface by name (`lo`) and any address whose
/// line mentions `scope link` (IPv6 link-local, `fe80::/10`) or
/// `scope host` (loopback addresses `ip` sometimes reports even on a
/// non-`lo` line) -- neither is what a viewer means by "this host's
/// IP". Field 1 is the interface name, field 2 is the address family
/// (`inet`/`inet6`), field 3 is `address/prefix-length`.
fn parse_ip_addr_output(stdout: &str) -> (Vec<String>, Vec<String>) {
let mut v4 = Vec::new();
let mut v6 = Vec::new();
for line in stdout.lines() {
let fields: Vec<&str> = line.split_whitespace().collect();
if fields.len() < 4 {
continue;
}
let iface = fields[1];
if iface == "lo" || line.contains("scope link") || line.contains("scope host") {
continue;
}
let addr = fields[3].split('/').next().unwrap_or(fields[3]);
match fields[2] {
"inet" => v4.push(addr.to_string()),
"inet6" => v6.push(addr.to_string()),
_ => {}
}
}
v4.sort();
v4.dedup();
v6.sort();
v6.dedup();
(v4, v6)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_proc_stat() {
let contents = "cpu 100 0 50 800 20 0 0 0 0 0\ncpu0 100 0 50 800 20 0 0 0 0 0\n";
let (total, idle) = parse_proc_stat(contents).unwrap();
// total = 100+0+50+800+20 = 970; idle = 800 (idle) + 20 (iowait) = 820
assert_eq!(total, 970);
assert_eq!(idle, 820);
}
#[test]
fn rejects_proc_stat_with_no_cpu_line() {
assert!(parse_proc_stat("not cpu data\n").is_err());
}
#[test]
fn parses_meminfo() {
let contents = "MemTotal: 16384000 kB\nMemFree: 1000000 kB\nMemAvailable: 8192000 kB\n";
let (used, total) = parse_meminfo(contents).unwrap();
assert_eq!(total, 16384000 * 1024);
assert_eq!(used, (16384000 - 8192000) * 1024);
}
#[test]
fn rejects_meminfo_missing_fields() {
assert!(parse_meminfo("MemTotal: 16384000 kB\n").is_err());
}
#[test]
fn parses_df_output() {
let stdout = "Filesystem 1B-blocks Used Available Use% Mounted on\n/dev/sda1 80000000000 20000000000 60000000000 25% /\n";
let (used, total) = parse_df_output(stdout).unwrap();
assert_eq!(used, 20000000000);
assert_eq!(total, 20000000000 + 60000000000);
}
#[test]
fn rejects_df_output_with_no_data_line() {
assert!(parse_df_output("Filesystem 1B-blocks Used Available Use% Mounted on\n").is_err());
}
#[test]
fn counts_cpuinfo_processors() {
let contents = "processor\t: 0\nmodel name\t: x\n\nprocessor\t: 1\nmodel name\t: x\n";
assert_eq!(parse_cpuinfo_core_count(contents).unwrap(), 2);
}
#[test]
fn rejects_cpuinfo_with_no_processor_lines() {
assert!(parse_cpuinfo_core_count("model name: x\n").is_err());
}
#[test]
fn parses_os_release_pretty_name() {
let contents = "NAME=\"Debian GNU/Linux\"\nPRETTY_NAME=\"Debian GNU/Linux 13 (trixie)\"\nVERSION_ID=\"13\"\n";
assert_eq!(parse_os_release_pretty_name(contents).unwrap(), "Debian GNU/Linux 13 (trixie)");
}
#[test]
fn rejects_os_release_missing_pretty_name() {
assert!(parse_os_release_pretty_name("NAME=\"Debian\"\n").is_err());
}
#[test]
fn parses_uptime() {
assert_eq!(parse_uptime("12345.67 98765.43\n").unwrap(), 12345);
}
#[test]
fn rejects_empty_uptime() {
assert!(parse_uptime("").is_err());
}
#[test]
fn parses_ip_addr_output_excluding_loopback_and_link_local() {
let stdout = concat!(
"1: lo inet 127.0.0.1/8 scope host lo\\ valid_lft forever preferred_lft forever\n",
"1: lo inet6 ::1/128 scope host \\ valid_lft forever preferred_lft forever\n",
"2: eth0 inet 172.239.44.244/24 brd 172.239.44.255 scope global eth0\\ valid_lft forever preferred_lft forever\n",
"2: eth0 inet6 2600:3c06::1/64 scope global dynamic mngtmpaddr noprefixroute \\ valid_lft forever preferred_lft forever\n",
"2: eth0 inet6 fe80::abcd/64 scope link \\ valid_lft forever preferred_lft forever\n",
);
let (v4, v6) = parse_ip_addr_output(stdout);
assert_eq!(v4, vec!["172.239.44.244".to_string()]);
assert_eq!(v6, vec!["2600:3c06::1".to_string()]);
}
#[test]
fn parses_ip_addr_output_dedupes_and_sorts_multiple_interfaces() {
let stdout = concat!(
"2: eth0 inet 10.0.0.5/24 scope global eth0\\ valid_lft forever preferred_lft forever\n",
"3: eth1 inet 10.0.0.2/24 scope global eth1\\ valid_lft forever preferred_lft forever\n",
"3: eth1 inet 10.0.0.5/24 scope global secondary eth1\\ valid_lft forever preferred_lft forever\n",
);
let (v4, _v6) = parse_ip_addr_output(stdout);
assert_eq!(v4, vec!["10.0.0.2".to_string(), "10.0.0.5".to_string()]);
}
#[test]
fn parses_ip_addr_output_with_no_addresses() {
let (v4, v6) = parse_ip_addr_output("1: lo inet 127.0.0.1/8 scope host lo\\ valid_lft forever preferred_lft forever\n");
assert!(v4.is_empty());
assert!(v6.is_empty());
}
}
+170
View File
@@ -0,0 +1,170 @@
//! Windows Service Control Manager integration: install/uninstall the
//! agent as a native Windows service, and the SCM-invoked entry point
//! that actually runs it as one.
//!
//! UNVERIFIED, same caveat as source/windows_eventlog.rs and
//! source/etw.rs -- written against the `windows-service` crate's
//! documented usage pattern, not compiled or run (no Windows toolchain
//! available). This one is lower-risk than etw.rs (no raw FFI struct
//! layout to get right; `windows-service` wraps that), but the
//! stop-signal plumbing between the SCM callback and the tokio-running
//! agent thread is new code worth testing carefully.
//!
//! Known limitation, not addressed here: when running as a service (no
//! console attached), `tracing_subscriber::fmt()`'s stdout writer has
//! nowhere to go. Logs won't be visible anywhere useful until this is
//! redirected to a file or an actual Windows Event Log tracing sink is
//! written -- flagging this now rather than shipping it silently broken.
use anyhow::{Context, Result};
use std::ffi::OsString;
use std::time::Duration;
use windows_service::service::{
ServiceAccess, ServiceControl, ServiceControlAccept, ServiceErrorControl, ServiceExitCode,
ServiceInfo, ServiceStartType, ServiceState, ServiceStatus, ServiceType,
};
use windows_service::service_control_handler::{self, ServiceControlHandlerResult};
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
use windows_service::{define_windows_service, service_dispatcher};
pub const SERVICE_NAME: &str = "CairnObsAgent";
const SERVICE_TYPE: ServiceType = ServiceType::OWN_PROCESS;
/// Registers this binary as a Windows service: Automatic start,
/// LocalSystem account, invoked with the `run-service` subcommand (which
/// is what the SCM actually launches — not a bare `cairnobs-agent` with no
/// arguments). Requires an administrator shell.
pub fn install() -> Result<()> {
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CREATE_SERVICE)
.context("opening Service Control Manager")?;
let exe_path = std::env::current_exe().context("resolving current executable path")?;
let service_info = ServiceInfo {
name: OsString::from(SERVICE_NAME),
display_name: OsString::from("Cairn OBS Log Agent"),
service_type: SERVICE_TYPE,
start_type: ServiceStartType::AutoStart,
error_control: ServiceErrorControl::Normal,
executable_path: exe_path,
launch_arguments: vec![OsString::from("run-service")],
dependencies: vec![],
account_name: None, // LocalSystem
account_password: None,
};
let service = manager
.create_service(&service_info, ServiceAccess::CHANGE_CONFIG)
.context("creating service")?;
service
.set_description("Ships local logs to Cairn OBS ingest over mTLS.")
.context("setting service description")?;
tracing::info!(service = SERVICE_NAME, "installed Windows service");
Ok(())
}
/// Removes the service registration. Does not stop a currently-running
/// instance first — stop it via `services.msc`/`sc.exe stop` before
/// uninstalling if it's running.
pub fn uninstall() -> Result<()> {
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)
.context("opening Service Control Manager")?;
let service = manager
.open_service(SERVICE_NAME, ServiceAccess::DELETE)
.context("opening service for deletion")?;
service.delete().context("deleting service")?;
tracing::info!(service = SERVICE_NAME, "removed Windows service");
Ok(())
}
define_windows_service!(ffi_service_main, service_main);
/// Blocks, handing control to the SCM dispatch loop -- this is what
/// `main()` calls for the `run-service` subcommand, which is what the SCM
/// itself launches when the service starts. Must not be called from
/// inside a tokio runtime (see the doc comment on `main()` in main.rs).
pub fn run_as_service() -> Result<()> {
service_dispatcher::start(SERVICE_NAME, ffi_service_main)
.context("starting Windows service dispatcher")
}
fn service_main(_arguments: Vec<OsString>) {
if let Err(e) = run_service() {
// Nowhere better to put this yet -- see the module-level caveat
// about tracing having no attached console under the SCM.
tracing::error!(error = ?e, "windows service run failed");
}
}
fn run_service() -> Result<()> {
let (shutdown_tx, shutdown_rx) = std::sync::mpsc::channel::<()>();
let event_handler = move |control_event| -> ServiceControlHandlerResult {
match control_event {
ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
ServiceControl::Stop => {
let _ = shutdown_tx.send(());
ServiceControlHandlerResult::NoError
}
_ => ServiceControlHandlerResult::NotImplemented,
}
};
let status_handle = service_control_handler::register(SERVICE_NAME, event_handler)
.context("registering service control handler")?;
status_handle
.set_service_status(ServiceStatus {
service_type: SERVICE_TYPE,
current_state: ServiceState::Running,
controls_accepted: ServiceControlAccept::STOP,
exit_code: ServiceExitCode::Win32(0),
checkpoint: 0,
wait_hint: Duration::default(),
process_id: None,
})
.context("reporting Running status to the SCM")?;
// service_main is invoked by the SCM on a plain thread, not an async
// context -- build a dedicated tokio runtime here and run the actual
// agent on it, same `run_agent` entry point a normal foreground run
// uses. Block this thread until either the agent exits on its own or
// the SCM asks us to stop.
let agent_thread = std::thread::spawn(|| {
let rt = match tokio::runtime::Runtime::new() {
Ok(rt) => rt,
Err(e) => {
tracing::error!(error = %e, "building tokio runtime for service run");
return;
}
};
if let Err(e) = rt.block_on(crate::run_agent(None)) {
tracing::error!(error = %e, "agent exited with error while running as a service");
}
});
loop {
if shutdown_rx.recv_timeout(Duration::from_millis(500)).is_ok() {
break;
}
if agent_thread.is_finished() {
break;
}
}
status_handle
.set_service_status(ServiceStatus {
service_type: SERVICE_TYPE,
current_state: ServiceState::Stopped,
controls_accepted: ServiceControlAccept::empty(),
exit_code: ServiceExitCode::Win32(0),
checkpoint: 0,
wait_hint: Duration::default(),
process_id: None,
})
.context("reporting Stopped status to the SCM")?;
Ok(())
}
+248
View File
@@ -0,0 +1,248 @@
//! ETW (Event Tracing for Windows) source: a real-time trace session
//! subscribed to specific provider GUIDs.
//!
//! UNVERIFIED, and the highest-risk file in this whole Windows integration
//! -- more so than windows_eventlog.rs. `EVENT_TRACE_PROPERTIES` requires
//! a variable-length buffer appended after the fixed struct (a classic C
//! "flexible array member" pattern for LoggerName), which is exactly the
//! kind of FFI layout detail most likely to be subtly wrong without a
//! Windows toolchain to actually compile and run this against. No Windows
//! target was available in the environment this was written in -- see the
//! module-level note in windows_eventlog.rs for what that means. Compile-
//! check and test this file specifically, first, before trusting any of
//! it.
//!
//! Providers are configured by **GUID**, not friendly name (e.g.
//! `"{22FB2CD6-0E7B-422B-A0C7-2FAD1FD0E716}"`) -- ETW's own
//! `EnableTraceEx2` API takes a GUID, not a name, and there's no simple
//! name-to-GUID resolution in the raw ETW API (that needs the separate TDH
//! provider-enumeration API, not implemented here). Look up a provider's
//! GUID with `logman query providers "<Friendly Name>"`.
//!
//! Message extraction here is deliberately limited to what's available
//! directly on `EVENT_RECORD`'s header (ProviderId, EventID, Level,
//! Keywords, timestamp, process/thread ID) -- no TDH-based property
//! decoding or message-template rendering (`TdhGetEventInformation`),
//! which is a meaningfully larger undertaking left for a follow-up. This
//! gives real session/provider/callback plumbing with a coarse message,
//! not full structured event decoding.
use super::{LineSender, RawLine};
use anyhow::{Context, Result};
use std::collections::HashMap;
use std::ffi::c_void;
use std::sync::mpsc as std_mpsc;
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::sync::mpsc as tokio_mpsc;
use windows::core::{GUID, PCWSTR};
use windows::Win32::System::Diagnostics::Etw::{
CloseTrace, ControlTraceW, EnableTraceEx2, OpenTraceW, ProcessTrace, StartTraceW,
EVENT_CONTROL_CODE_ENABLE_PROVIDER, EVENT_RECORD, EVENT_TRACE_CONTROL_STOP,
EVENT_TRACE_LOGFILEW, EVENT_TRACE_LOGFILEW_0, EVENT_TRACE_LOGFILEW_1,
EVENT_TRACE_PROPERTIES, EVENT_TRACE_REAL_TIME_MODE, PROCESS_TRACE_MODE_EVENT_RECORD,
PROCESS_TRACE_MODE_REAL_TIME, TRACE_LEVEL_VERBOSE,
};
const SESSION_NAME: &str = "CairnObsAgentEtw";
pub async fn run(providers: &[String], tx: LineSender) -> Result<()> {
let providers = providers.to_vec();
let (blocking_tx, mut blocking_rx) = tokio_mpsc::channel::<RawLine>(256);
let handle = tokio::task::spawn_blocking(move || run_session(&providers, blocking_tx));
while let Some(line) = blocking_rx.recv().await {
if tx.send(line).await.is_err() {
break;
}
}
handle.await.context("ETW session task panicked")??;
Ok(())
}
fn to_wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
}
/// Thread-local-ish channel used to get the async sender into the
/// C-callable `event_record_callback`, which has a fixed extern "system"
/// signature and can't capture a closure. Set once per `run_session` call
/// before `ProcessTrace` starts invoking the callback.
thread_local! {
static CALLBACK_TX: std::cell::RefCell<Option<tokio_mpsc::Sender<RawLine>>> =
const { std::cell::RefCell::new(None) };
}
fn run_session(providers: &[String], tx: tokio_mpsc::Sender<RawLine>) -> Result<()> {
let guids: Vec<GUID> = providers
.iter()
.map(|p| GUID::try_from(p.as_str()).with_context(|| format!("invalid provider GUID: {p}")))
.collect::<Result<_>>()?;
unsafe {
let session_handle = start_session()?;
for guid in &guids {
enable_provider(session_handle, guid)?;
}
// ProcessTrace runs the consumer loop on *this* thread until
// CloseTrace is called (from the callback, or from another
// thread against the same handle) -- there's no separate
// "shutdown channel" here because the agent's top-level shutdown
// path currently aborts the whole spawn_blocking task rather
// than signaling sources to stop gracefully (same as the other
// sources today).
CALLBACK_TX.with(|cell| *cell.borrow_mut() = Some(tx));
let mut logfile = EVENT_TRACE_LOGFILEW::default();
let mut session_name_wide = to_wide(SESSION_NAME);
logfile.LoggerName = PCWSTR(session_name_wide.as_mut_ptr());
logfile.Anonymous1 = EVENT_TRACE_LOGFILEW_0 {
ProcessTraceMode: PROCESS_TRACE_MODE_REAL_TIME.0 | PROCESS_TRACE_MODE_EVENT_RECORD.0,
};
logfile.Anonymous2 = EVENT_TRACE_LOGFILEW_1 {
EventRecordCallback: Some(event_record_callback),
};
let trace_handle = OpenTraceW(&mut logfile);
if trace_handle.0 == u64::MAX as usize {
anyhow::bail!("OpenTraceW failed");
}
let result = ProcessTrace(&[trace_handle], None, None);
let _ = CloseTrace(trace_handle);
stop_session(session_handle);
result.ok().context("ProcessTrace failed")?;
}
Ok(())
}
unsafe fn start_session() -> Result<windows::Win32::System::Diagnostics::Etw::CONTROLTRACE_HANDLE> {
// EVENT_TRACE_PROPERTIES needs a trailing buffer (appended after the
// fixed struct) for the session's LoggerName -- this is the flexible-
// array-member pattern flagged in the module doc comment as the
// highest-risk detail in this file. LogFileNameOffset is left 0 (no
// log file; real-time only).
const LOGGER_NAME_CAPACITY: usize = 256;
let total_size = std::mem::size_of::<EVENT_TRACE_PROPERTIES>() + LOGGER_NAME_CAPACITY * 2;
let mut buffer = vec![0u8; total_size];
let props = buffer.as_mut_ptr() as *mut EVENT_TRACE_PROPERTIES;
(*props).Wnode.BufferSize = total_size as u32;
(*props).Wnode.Flags = windows::Win32::System::Diagnostics::Etw::WNODE_FLAG_TRACED_GUID;
(*props).LogFileMode = EVENT_TRACE_REAL_TIME_MODE;
(*props).LoggerNameOffset = std::mem::size_of::<EVENT_TRACE_PROPERTIES>() as u32;
let session_name_wide = to_wide(SESSION_NAME);
let mut session_handle = Default::default();
StartTraceW(
&mut session_handle,
PCWSTR(session_name_wide.as_ptr()),
props,
)
.ok()
.context("StartTraceW failed")?;
Ok(session_handle)
}
unsafe fn enable_provider(
session_handle: windows::Win32::System::Diagnostics::Etw::CONTROLTRACE_HANDLE,
guid: &GUID,
) -> Result<()> {
EnableTraceEx2(
session_handle,
guid,
EVENT_CONTROL_CODE_ENABLE_PROVIDER.0,
TRACE_LEVEL_VERBOSE as u8,
0,
0,
0,
None,
)
.ok()
.with_context(|| format!("EnableTraceEx2 failed for provider {guid:?}"))
}
unsafe fn stop_session(session_handle: windows::Win32::System::Diagnostics::Etw::CONTROLTRACE_HANDLE) {
let mut buffer = vec![0u8; std::mem::size_of::<EVENT_TRACE_PROPERTIES>() + 512];
let props = buffer.as_mut_ptr() as *mut EVENT_TRACE_PROPERTIES;
(*props).Wnode.BufferSize = buffer.len() as u32;
let _ = ControlTraceW(session_handle, PCWSTR::null(), props, EVENT_TRACE_CONTROL_STOP);
}
/// `extern "system"` callback ETW invokes per event during `ProcessTrace`.
/// Deliberately minimal: header fields only, no TDH property decoding
/// (see module doc comment).
unsafe extern "system" fn event_record_callback(record: *mut EVENT_RECORD) {
if record.is_null() {
return;
}
let record = &*record;
let header = &record.EventHeader;
let mut attributes = HashMap::new();
attributes.insert(
"etw.provider_guid".to_string(),
format!("{:?}", header.ProviderId),
);
attributes.insert("etw.event_id".to_string(), header.EventDescriptor.Id.to_string());
attributes.insert(
"etw.opcode".to_string(),
header.EventDescriptor.Opcode.to_string(),
);
attributes.insert(
"etw.task".to_string(),
header.EventDescriptor.Task.to_string(),
);
attributes.insert("etw.process_id".to_string(), header.ProcessId.to_string());
attributes.insert("etw.thread_id".to_string(), header.ThreadId.to_string());
let severity_hint = etw_level_to_syslog_severity(header.EventDescriptor.Level);
let timestamp_unix_nano = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as i64)
.unwrap_or(0);
// No TDH-based message rendering (see module doc comment) -- this is
// a coarse, structured summary rather than a human-authored message.
// Downstream (cairnobs_parser's raw-passthrough fallback) handles a
// non-RFC5424 line like this the same as any other raw line.
let message = format!(
"ETW event: provider={:?} id={} level={}",
header.ProviderId, header.EventDescriptor.Id, header.EventDescriptor.Level
);
let raw = RawLine {
line: message,
timestamp_unix_nano,
severity_hint,
extra_attributes: attributes,
};
CALLBACK_TX.with(|cell| {
if let Some(tx) = cell.borrow().as_ref() {
let _ = tx.blocking_send(raw);
}
});
}
/// Maps ETW `Level` (0=LogAlways/Verbose-ish through 5=Verbose, following
/// the same TRACE_LEVEL_* scale as windows_eventlog's Level values) onto
/// the syslog 0-7 scale, same reasoning as windows_eventlog.rs.
fn etw_level_to_syslog_severity(level: u8) -> Option<u8> {
match level {
1 => Some(2), // Critical -> crit
2 => Some(3), // Error -> err
3 => Some(4), // Warning -> warning
4 => Some(6), // Informational -> info
5 => Some(7), // Verbose -> debug
_ => None,
}
}
@@ -0,0 +1,74 @@
use super::{LineSender, RawLine};
use anyhow::{Context, Result};
use std::io::SeekFrom;
use std::path::Path;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::fs::File;
use tokio::io::{AsyncBufReadExt, AsyncSeekExt, BufReader};
const POLL_INTERVAL: Duration = Duration::from_millis(500);
/// Polling-based file tailer: no inotify/`notify` crate dependency. Good
/// enough for Phase 0 (journald is the primary source). Handles basic
/// truncation (e.g. logrotate `copytruncate`) by detecting the file shrank
/// and reopening from the start. Does not follow rename-based rotation
/// (logrotate `create`) — that's deferred until file-tail is more than a
/// fallback path.
pub async fn run(path: &Path, from_beginning: bool, tx: LineSender) -> Result<()> {
let file = File::open(path)
.await
.with_context(|| format!("opening {}", path.display()))?;
let mut pos = if from_beginning { 0 } else { file.metadata().await?.len() };
let mut reader = BufReader::new(file);
reader.seek(SeekFrom::Start(pos)).await?;
let mut buf = String::new();
loop {
buf.clear();
let n = reader
.read_line(&mut buf)
.await
.context("reading line from file")?;
if n == 0 {
let metadata = tokio::fs::metadata(path).await.context("stat-ing file")?;
if metadata.len() < pos {
tracing::warn!(path = %path.display(), "file shrank, assuming truncation and reopening from start");
let f = File::open(path)
.await
.context("reopening file after truncation")?;
reader = BufReader::new(f);
pos = 0;
}
tokio::time::sleep(POLL_INTERVAL).await;
continue;
}
pos += n as u64;
let line = buf.trim_end_matches(['\n', '\r']).to_string();
if line.is_empty() {
continue;
}
let timestamp_unix_nano = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as i64)
.unwrap_or(0);
if tx
.send(RawLine {
line,
timestamp_unix_nano,
severity_hint: None,
extra_attributes: Default::default(),
})
.await
.is_err()
{
break;
}
}
Ok(())
}
@@ -0,0 +1,76 @@
use super::{LineSender, RawLine};
use anyhow::{Context, Result};
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
/// Reads journald entries by shelling out to `journalctl -f -o json`
/// rather than linking libsystemd via FFI. Linking libsystemd into a
/// statically-linked musl binary is fragile (it pulls in dbus/libcap
/// transitively and isn't designed for static linking) and would work
/// against the no-glibc-runtime-deps constraint in spirit even where it's
/// technically possible. `journalctl` ships on every systemd distro this
/// agent targets, so shelling out avoids the problem entirely. See
/// /docs/architecture.md.
pub async fn run(unit: Option<&str>, tx: LineSender) -> Result<()> {
let mut cmd = Command::new("journalctl");
cmd.arg("-f")
.arg("-o")
.arg("json")
.arg("--since=now")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null());
if let Some(unit) = unit {
cmd.arg("-u").arg(unit);
}
let mut child = cmd
.spawn()
.context("spawning journalctl -f -o json (is systemd-journal installed?)")?;
let stdout = child.stdout.take().context("journalctl child had no stdout")?;
let mut lines = BufReader::new(stdout).lines();
while let Some(line) = lines.next_line().await.context("reading journalctl output")? {
let Ok(entry) = serde_json::from_str::<serde_json::Value>(&line) else {
tracing::warn!(%line, "skipping unparseable journalctl JSON line");
continue;
};
let message = entry
.get("MESSAGE")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
if message.is_empty() {
continue;
}
let severity_hint = entry
.get("PRIORITY")
.and_then(|v| v.as_str().map(str::to_string).or_else(|| v.as_u64().map(|n| n.to_string())))
.and_then(|s| s.parse::<u8>().ok())
.filter(|&p| p <= 7);
let timestamp_unix_nano = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as i64)
.unwrap_or(0);
if tx
.send(RawLine {
line: message,
timestamp_unix_nano,
severity_hint,
extra_attributes: Default::default(),
})
.await
.is_err()
{
break; // receiver dropped, agent is shutting down
}
}
let status = child.wait().await.context("waiting for journalctl to exit")?;
tracing::warn!(?status, "journalctl exited");
Ok(())
}
+39
View File
@@ -0,0 +1,39 @@
use std::collections::HashMap;
use tokio::sync::mpsc;
/// A raw line read from a source, plus whatever metadata the source itself
/// already knows before the RFC 5424 parser ever sees it.
#[derive(Debug, Clone)]
pub struct RawLine {
pub line: String,
/// Unix epoch nanoseconds at time of read.
pub timestamp_unix_nano: i64,
/// Syslog severity (0-7) if the source already knows it independent of
/// the line's own content — e.g. journald's PRIORITY field, or a
/// Windows Event Log Level mapped onto the same scale. When set, this
/// takes precedence over whatever the RFC 5424 parser infers from the
/// message text, since it comes from a more authoritative place.
pub severity_hint: Option<u8>,
/// Structured fields the source already knows, independent of the raw
/// message text — e.g. Windows Event Log's EventID/Provider/Channel.
/// Merged into the record's attributes alongside whatever the RFC 5424
/// parser extracts from `line`; on key collision, these win, since
/// they also come from a more authoritative place than text parsing.
/// Sources that have nothing to add (journald, file-tail) just leave
/// this empty.
pub extra_attributes: HashMap<String, String>,
}
pub type LineSender = mpsc::Sender<RawLine>;
#[cfg(all(feature = "journald", target_os = "linux"))]
pub mod journald;
#[cfg(feature = "file-tail")]
pub mod file_tail;
#[cfg(all(feature = "windows-eventlog", target_os = "windows"))]
pub mod windows_eventlog;
#[cfg(all(feature = "etw", target_os = "windows"))]
pub mod etw;
@@ -0,0 +1,283 @@
//! Windows Event Log source via `EvtSubscribe`.
//!
//! UNVERIFIED: this module was written against the documented
//! EvtSubscribe/EvtNext/EvtRender API shape (the same pull-model pattern
//! Microsoft's own C++ samples use for subscriptions), but has not been
//! compiled or run on a real Windows host — no Windows target toolchain
//! was available in the environment this was written in (confirmed: only
//! x86_64-unknown-linux-gnu std was installed, no rustup, no way to even
//! `cargo check --target x86_64-pc-windows-*`). Treat this as a first
//! draft to compile-check and test for real before trusting it. See
//! /docs/phase-1-runbook.md for what's actually been verified vs. not.
use super::{LineSender, RawLine};
use anyhow::{Context, Result};
use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::sync::mpsc as tokio_mpsc;
use windows::core::PCWSTR;
use windows::Win32::Foundation::{ERROR_NO_MORE_ITEMS, WAIT_OBJECT_0};
use windows::Win32::System::EventLog::{
EvtClose, EvtNext, EvtRender, EvtRenderEventXml, EvtSubscribe, EVT_HANDLE,
EVT_SUBSCRIBE_TO_FUTURE_EVENTS,
};
use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject};
/// Tails one or more Windows Event Log channels. Runs the blocking
/// EvtSubscribe/EvtNext calls on a dedicated OS thread per channel (via
/// `spawn_blocking`) and forwards parsed lines back over `tx`, same shape
/// as the journald source's subprocess-reading loop.
pub async fn run(channels: &[String], tx: LineSender) -> Result<()> {
let channels = channels.to_vec();
let (blocking_tx, mut blocking_rx) = tokio_mpsc::channel::<RawLine>(256);
let handle = tokio::task::spawn_blocking(move || subscribe_all(&channels, blocking_tx));
while let Some(line) = blocking_rx.recv().await {
if tx.send(line).await.is_err() {
break; // receiver dropped, agent is shutting down
}
}
handle
.await
.context("windows event log subscription task panicked")??;
Ok(())
}
/// One `std::thread` per channel, each blocked in its own
/// wait-then-drain loop. Simpler and still correct for the common case of
/// 1-3 channels; a single `WaitForMultipleObjects`-based dispatcher would
/// scale better to many channels but isn't needed for Phase 1's default
/// three (Application/System/Security).
fn subscribe_all(channels: &[String], tx: tokio_mpsc::Sender<RawLine>) -> Result<()> {
let mut threads = Vec::with_capacity(channels.len());
for channel in channels {
let channel = channel.clone();
let tx = tx.clone();
threads.push(std::thread::spawn(move || subscribe_one(&channel, tx)));
}
for t in threads {
t.join()
.map_err(|_| anyhow::anyhow!("event log subscriber thread panicked"))??;
}
Ok(())
}
fn to_wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
}
fn subscribe_one(channel: &str, tx: tokio_mpsc::Sender<RawLine>) -> Result<()> {
unsafe {
let signal_event = CreateEventW(None, true, false, None)
.context("CreateEventW for subscription signal failed")?;
let channel_wide = to_wide(channel);
// NULL query (PCWSTR::null()) means "all events on this channel".
// No callback (None) -- pull model via the signal event instead,
// so this stays a plain loop rather than a Win32 callback that
// would need to cross back into the tokio runtime.
let subscription = EvtSubscribe(
None,
Some(signal_event),
PCWSTR(channel_wide.as_ptr()),
PCWSTR::null(),
None,
None,
None,
EVT_SUBSCRIBE_TO_FUTURE_EVENTS.0,
)
.context("EvtSubscribe failed")?;
loop {
let wait = WaitForSingleObject(signal_event, u32::MAX);
if wait != WAIT_OBJECT_0 {
anyhow::bail!("WaitForSingleObject on event log subscription failed");
}
loop {
let mut events: [EVT_HANDLE; 16] = [EVT_HANDLE::default(); 16];
let mut returned = 0u32;
let next = EvtNext(subscription, &mut events, 0, 0, &mut returned);
if let Err(err) = next {
if err.code() == ERROR_NO_MORE_ITEMS.into() {
break; // drained this batch; go back to waiting on the signal
}
return Err(err).context("EvtNext failed");
}
for &event in &events[..returned as usize] {
if let Some(raw) = render_event(event, channel) {
if tx.blocking_send(raw).is_err() {
let _ = EvtClose(event);
let _ = EvtClose(subscription);
return Ok(());
}
}
let _ = EvtClose(event);
}
}
}
}
}
fn render_event(event: EVT_HANDLE, channel: &str) -> Option<RawLine> {
unsafe {
let mut buffer_used = 0u32;
let mut property_count = 0u32;
// First call with a zero-length buffer to learn the required size.
let _ = EvtRender(
None,
event,
EvtRenderEventXml.0,
0,
None,
&mut buffer_used,
&mut property_count,
);
if buffer_used == 0 {
return None;
}
let mut buffer = vec![0u16; (buffer_used as usize).div_ceil(2)];
let rendered = EvtRender(
None,
event,
EvtRenderEventXml.0,
buffer_used,
Some(buffer.as_mut_ptr() as *mut _),
&mut buffer_used,
&mut property_count,
);
if rendered.is_err() {
return None;
}
let xml = String::from_utf16_lossy(&buffer);
let xml = xml.trim_end_matches('\0');
parse_event_xml(xml, channel)
}
}
/// Minimal, deliberately non-validating extraction of the fields Phase 1
/// needs from the rendered event XML: EventID, Provider, Level, Computer,
/// Windows' own EventRecordID, and a best-effort message. Not a full XML
/// parser in the schema-aware sense -- uses `quick-xml`'s streaming
/// reader to pull specific elements/attributes rather than hand-rolled
/// string search, but doesn't attempt full EventData/UserData schema
/// awareness across every provider's custom shape. Worth revisiting once
/// this is running against real events from real providers.
fn parse_event_xml(xml: &str, channel: &str) -> Option<RawLine> {
use quick_xml::events::Event as XmlEvent;
use quick_xml::reader::Reader;
let mut reader = Reader::from_str(xml);
reader.config_mut().trim_text(true);
let mut event_id = None;
let mut provider = None;
let mut level = None;
let mut computer = None;
let mut record_id = None;
let mut event_data_values: Vec<String> = Vec::new();
let mut current_tag: Option<String> = None;
let mut buf = Vec::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(XmlEvent::Start(e)) | Ok(XmlEvent::Empty(e)) => {
let name = local_name(&e);
if name == "Provider" {
for attr in e.attributes().flatten() {
if attr.key.as_ref() == b"Name" {
provider = attr
.decode_and_unescape_value(reader.decoder())
.ok()
.map(|v| v.into_owned());
}
}
}
current_tag = Some(name);
}
Ok(XmlEvent::Text(t)) => {
let text = t.unescape().unwrap_or_default().into_owned();
match current_tag.as_deref() {
Some("EventID") => event_id = Some(text),
Some("Level") => level = text.parse::<u8>().ok(),
Some("Computer") => computer = Some(text),
Some("EventRecordID") => record_id = Some(text),
Some("Data") => event_data_values.push(text),
_ => {}
}
}
Ok(XmlEvent::Eof) => break,
Err(_) => return None,
_ => {}
}
buf.clear();
}
// <EventData> commonly holds one or more <Data Name="...">value</Data>
// elements rather than a single free-text message; joining them is a
// reasonable Phase 1 default until per-provider message templates are
// rendered properly. Real message-template rendering needs
// EvtFormatMessage against the provider's message-table resource --
// worth a follow-up, not required for a raw-passthrough-shaped record
// (cairnobs_parser's raw fallback handles this fine either way).
let message = if event_data_values.is_empty() {
xml.to_string()
} else {
event_data_values.join(" | ")
};
let mut attributes = HashMap::new();
if let Some(id) = &event_id {
attributes.insert("winevt.event_id".to_string(), id.clone());
}
if let Some(p) = provider {
attributes.insert("winevt.provider".to_string(), p);
}
attributes.insert("winevt.channel".to_string(), channel.to_string());
if let Some(c) = computer {
attributes.insert("winevt.computer".to_string(), c);
}
if let Some(r) = record_id {
attributes.insert("winevt.record_number".to_string(), r);
}
let severity_hint = level.and_then(windows_level_to_syslog_severity);
let timestamp_unix_nano = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as i64)
.unwrap_or(0);
Some(RawLine {
line: message,
timestamp_unix_nano,
severity_hint,
extra_attributes: attributes,
})
}
fn local_name(e: &quick_xml::events::BytesStart) -> String {
String::from_utf8_lossy(e.local_name().as_ref()).into_owned()
}
/// Maps Windows Event Log `Level` values (0=LogAlways, 1=Critical,
/// 2=Error, 3=Warning, 4=Informational, 5=Verbose) onto the same syslog
/// 0-7 severity scale `severity_hint` uses everywhere else in the agent,
/// so `main.rs`'s `to_pb_severity` needs no Windows-specific knowledge.
fn windows_level_to_syslog_severity(level: u8) -> Option<u8> {
match level {
1 => Some(2), // Critical -> crit
2 => Some(3), // Error -> err
3 => Some(4), // Warning -> warning
4 => Some(6), // Informational -> info
5 => Some(7), // Verbose -> debug
_ => None, // 0 (LogAlways) or unrecognized -- let the parser decide
}
}