Phase 1: Windows log collection + full-text search
Extends the agent, ingest, storage, api, and web with Windows Event Log/ETW sourcing and Tantivy-backed free-text search, per the approved Phase 1 plan. - CLAUDE.md: materialized on disk (never existed as a file before) with a new Phase 1 "done looks like" section. - agent: Windows Event Log (EvtSubscribe) and ETW sources, Windows service wrapper (install/uninstall/run-service), both feature- and target_os-gated so Linux builds/tests/clippy stay unaffected. Also fixed two pre-existing Phase 0 clippy gaps (dead-code on default-features-only builds, a type-inference edge case) found while testing every feature combination properly for the first time. UNVERIFIED on real Windows -- no Windows toolchain existed anywhere in the build environment; flagged prominently in three places. - proto/ingest: new record_id field, assigned once server-side in ingest's gRPC front end so ClickHouse and Tantivy agree on the same ID for the same record. - storage: record_id column + bloom filter index, verified against a live ClickHouse. - search: new service, Tantivy index, rskafka consumer as an independent second consumer group on the same Redpanda topic ingest already reads. - api/web: new /search endpoint and page, sharing the query page's result-table shape and component. - hack/windows-fixture: sends realistic Windows-shaped data straight to ingest, so the pipeline's handling of it is verifiable without a Windows host. Verified end-to-end on the live docker-compose stack: the same record_id comes back from both /query and /search for the same log line, including for windows-fixture's synthetic Windows Event Log data. Real bugs found and fixed along the way: api/Dockerfile missing proto/ in its build context, search's logs being completely silent (RUST_LOG gap), and search/target/ missing from .gitignore/.dockerignore.
This commit is contained in:
Generated
+93
@@ -735,6 +735,15 @@ dependencies = [
|
||||
"prost",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
@@ -893,6 +902,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"prost",
|
||||
"quick-xml",
|
||||
"sentry-parser",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -902,6 +912,8 @@ dependencies = [
|
||||
"tonic-build",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"windows",
|
||||
"windows-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1380,12 +1392,93 @@ version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "widestring"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.58.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.58.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.58.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.58.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-service"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d24d6bcc7f734a4091ecf8d7a64c5f7d7066f45585c1861eba06449909609c8a"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"widestring",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
|
||||
dependencies = [
|
||||
"windows-result",
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
|
||||
+146
-16
@@ -1,15 +1,39 @@
|
||||
# sentry-agent
|
||||
|
||||
Distro-agnostic Linux log collector. Statically linked against musl, no
|
||||
glibc runtime dependency. Tails journald (default) or a file, batches
|
||||
lines, and ships them over mTLS gRPC to the ingest service.
|
||||
Distro-agnostic Linux/Windows log collector. On Linux, statically linked
|
||||
against musl, no glibc runtime dependency. Tails journald (Linux default),
|
||||
a file, Windows Event Log, or ETW, batches lines, and ships them over mTLS
|
||||
gRPC to the ingest service.
|
||||
|
||||
**Windows support status:** the Windows-specific code
|
||||
(`source/windows_eventlog.rs`, `source/etw.rs`, `service.rs`) was written
|
||||
against documented Win32/ETW API shapes but has **not been compiled or run
|
||||
on Windows** — no Windows toolchain was available in the environment this
|
||||
was built in (confirmed: only the Linux target's std library was
|
||||
installed, no way to even `cargo check --target x86_64-pc-windows-*`).
|
||||
Linux builds/tests/clippy are verified clean across every feature
|
||||
combination; Windows code is a first draft to compile-check and test for
|
||||
real before trusting it. See `/docs/phase-1-runbook.md`.
|
||||
|
||||
## Workspace layout
|
||||
|
||||
- `sentry-parser` — pure-`std` RFC 5424 syslog parser with raw-passthrough
|
||||
fallback. No I/O, easy to unit test in isolation.
|
||||
- `sentry-agent` — the binary: config loading, sourcing (journald/file),
|
||||
batching, mTLS gRPC client.
|
||||
- `sentry-agent` — the binary: config loading, sourcing (journald/file/
|
||||
Windows Event Log/ETW), batching, mTLS gRPC client, Windows service
|
||||
wrapper.
|
||||
|
||||
## Why one crate for both platforms, not a platform split
|
||||
|
||||
`config.rs`, `batch.rs`, `grpc.rs`, and `main.rs`'s event loop are already
|
||||
100% cross-platform Rust — nothing in them is Linux- or Windows-specific.
|
||||
Only the `source/` modules differ per platform, and that boundary already
|
||||
existed before Windows support was added (it's exactly what made adding
|
||||
Windows sources a matter of adding two files, not restructuring anything).
|
||||
Windows-only dependencies (`windows`, `windows-service`, `quick-xml`) live
|
||||
in a `[target.'cfg(windows)'.dependencies]` section in `Cargo.toml`, so
|
||||
they're not in the Linux build's dependency graph at all — no crate split
|
||||
needed to keep the two platforms from stepping on each other.
|
||||
|
||||
## Why journalctl, not libsystemd
|
||||
|
||||
@@ -62,6 +86,32 @@ container without deliberately bind-mounting `/var/log/journal` (or
|
||||
deployment for journald sourcing is as a native binary managed by systemd
|
||||
on the host, not containerized.
|
||||
|
||||
### Building for Windows
|
||||
|
||||
```sh
|
||||
# Cross-compiling FROM Linux, for the build step only:
|
||||
rustup target add x86_64-pc-windows-gnu
|
||||
cargo build --release --target x86_64-pc-windows-gnu \
|
||||
--no-default-features --features windows-eventlog,etw
|
||||
|
||||
# Natively on Windows (MSVC toolchain):
|
||||
cargo build --release --target x86_64-pc-windows-msvc \
|
||||
--no-default-features --features windows-eventlog,etw
|
||||
```
|
||||
|
||||
`--no-default-features` matters: the default feature set is `journald`,
|
||||
which is Linux-only (the module is `target_os = "linux"`-gated and simply
|
||||
won't compile in on Windows, but there's no reason to carry the dead
|
||||
feature flag). Drop `,etw` from `--features` if you only want Event Log —
|
||||
see the privilege note below for why most environments will want to.
|
||||
|
||||
**Cross-compilation only covers the *build* step.** Running/testing the
|
||||
Windows sources — actually calling `EvtSubscribe`, starting an ETW
|
||||
session, registering a Windows service — needs a real or virtualized
|
||||
Windows host. There is no way around that, and nothing in this repo
|
||||
pretends otherwise; see `/docs/phase-1-runbook.md` for exactly what's
|
||||
automatable vs. manual-only.
|
||||
|
||||
## Running
|
||||
|
||||
No CLI flags are required for the common case:
|
||||
@@ -70,12 +120,14 @@ No CLI flags are required for the common case:
|
||||
./sentry-agent
|
||||
```
|
||||
|
||||
This uses `/etc/sentry-agent/agent.toml` if present, otherwise built-in
|
||||
defaults: journald source (whole journal, no unit filter), service name
|
||||
`default`, and mTLS material expected at
|
||||
`/etc/sentry-agent/{ca,client,client-key}.pem`. mTLS is mandatory per the
|
||||
project's transport requirements, so a from-scratch run with no certs in
|
||||
place will fail fast with a clear error rather than connecting insecurely.
|
||||
This uses the platform's conventional config path if present
|
||||
(`/etc/sentry-agent/agent.toml` on Linux, `C:\ProgramData\SentryAgent\agent.toml`
|
||||
on Windows), otherwise built-in defaults: journald source on Linux (whole
|
||||
journal, no unit filter), service name `default`, and mTLS material
|
||||
expected under the same conventional directory
|
||||
(`{ca,client,client-key}.pem`). mTLS is mandatory per the project's
|
||||
transport requirements, so a from-scratch run with no certs in place will
|
||||
fail fast with a clear error rather than connecting insecurely.
|
||||
|
||||
See `config/agent.example.toml` for all fields.
|
||||
|
||||
@@ -83,6 +135,70 @@ See `config/agent.example.toml` for all fields.
|
||||
./sentry-agent --config /path/to/agent.toml
|
||||
```
|
||||
|
||||
## Running as a Windows service
|
||||
|
||||
"A native Windows service, not a WSL wrapper" means implementing the Win32
|
||||
Service Control Manager protocol, not just running the binary in a
|
||||
console — that's what `service.rs` (via the `windows-service` crate)
|
||||
does. From an administrator shell:
|
||||
|
||||
```powershell
|
||||
sentry-agent.exe install # registers the service, Automatic start, LocalSystem account
|
||||
sc.exe start SentryAgent
|
||||
sc.exe stop SentryAgent
|
||||
sentry-agent.exe uninstall
|
||||
```
|
||||
|
||||
`install`/`uninstall`/`run-service` are subcommands only present in
|
||||
Windows builds (`sentry-agent` with no subcommand is still the normal
|
||||
foreground/console run, same as on Linux) — `run-service` specifically is
|
||||
what the SCM itself invokes at service start; don't run it directly.
|
||||
|
||||
**Known limitation:** when running as a service, there's no console
|
||||
attached, so `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 a proper Windows Event Log tracing sink is written. Not addressed
|
||||
in Phase 1; flagging it here rather than shipping it silently broken.
|
||||
|
||||
## ETW: read this before enabling it
|
||||
|
||||
ETW needs elevated privileges to subscribe to most providers — running
|
||||
the agent under an administrator token or a service account with
|
||||
`SeSystemProfilePrivilege`/ETW-specific rights. This is a real privilege
|
||||
escalation, not a footnote: think about whether your environment wants
|
||||
the log-shipping agent running with that level of access before turning
|
||||
on the `etw` feature and an `[source] kind = "etw"` config. Event Log
|
||||
alone (no elevated privileges needed) covers the common case and is what
|
||||
Phase 1's exit criteria in `/CLAUDE.md` actually requires to be running.
|
||||
|
||||
Providers are configured by **GUID**, not friendly name — ETW's own API
|
||||
requires it. Look one up with `logman query providers "<Friendly Name>"`.
|
||||
|
||||
## Windows Event Forwarding (WEF)
|
||||
|
||||
Two different things people mean by "WEF support," worth being explicit
|
||||
about since they're very different amounts of work:
|
||||
|
||||
1. **What this repo supports today, with zero extra code:** WEF is a
|
||||
native Windows-to-Windows mechanism (`wecsvc`, the built-in Windows
|
||||
Event Collector role) — endpoints forward to a Windows Server acting
|
||||
as collector using Windows' own mechanism, no Sentry code involved in
|
||||
the forwarding itself. Run this agent *on the collector box*,
|
||||
subscribed to the `ForwardedEvents` channel instead of the usual three:
|
||||
```toml
|
||||
[source]
|
||||
kind = "eventlog"
|
||||
channels = ["ForwardedEvents"]
|
||||
```
|
||||
2. **What this repo does *not* implement:** a true agentless receiver —
|
||||
Sentry itself speaking the WS-Management/WinRM event-subscription
|
||||
protocol so endpoints can forward directly to `ingest` without any
|
||||
Windows Event Collector role or Sentry agent anywhere. That's a
|
||||
standalone protocol implementation (SOAP-ish subscription/heartbeat/
|
||||
delivery over WinRM), not an agent or ingest-side tweak, and it's out
|
||||
of scope for Phase 1. If you need this, it's a real project of its
|
||||
own — say so before assuming it's a small addition.
|
||||
|
||||
## Testing
|
||||
|
||||
```sh
|
||||
@@ -91,10 +207,24 @@ cargo test --workspace
|
||||
|
||||
## Feature flags
|
||||
|
||||
- `journald` (default) — journalctl-based journald source.
|
||||
- `journald` (default) — journalctl-based journald source. `target_os =
|
||||
"linux"`-gated: enabling this on a Windows build is a no-op, not a
|
||||
build failure.
|
||||
- `file-tail` — polling-based file tailer (no inotify dependency; doesn't
|
||||
follow rename-based log rotation yet).
|
||||
follow rename-based log rotation yet). Cross-platform, works on Windows
|
||||
too.
|
||||
- `windows-eventlog` — Windows Event Log via `EvtSubscribe`.
|
||||
`target_os = "windows"`-gated the same way; a no-op on Linux.
|
||||
- `etw` — ETW real-time session. Same gating. See the privilege section
|
||||
above before enabling.
|
||||
|
||||
Both can be enabled together; `[source].kind` in config picks which one
|
||||
runs. Building without a feature and configuring that source at runtime
|
||||
fails at startup with a clear error rather than silently doing nothing.
|
||||
Any combination can be enabled together; `[source].kind` in config picks
|
||||
which one actually runs. Building without a feature and configuring that
|
||||
source at runtime fails at startup with a clear error rather than
|
||||
silently doing nothing.
|
||||
|
||||
Dependencies added for Windows support, worth knowing about:
|
||||
`windows` (Microsoft's official Win32/ETW bindings), `windows-service`
|
||||
(Windows Service Control Manager wrapper), `quick-xml` (parses
|
||||
EvtSubscribe's rendered event XML). All three are `[target.'cfg(windows)'.dependencies]`
|
||||
— not in the Linux build's dependency graph at all.
|
||||
|
||||
@@ -3,7 +3,7 @@ name = "sentry-agent"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Sentry distro-agnostic Linux log collector"
|
||||
description = "Sentry distro-agnostic Linux/Windows log collector"
|
||||
|
||||
[[bin]]
|
||||
name = "sentry-agent"
|
||||
@@ -13,6 +13,12 @@ path = "src/main.rs"
|
||||
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]
|
||||
sentry-parser = { path = "../sentry-parser" }
|
||||
@@ -30,5 +36,18 @@ 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.36"
|
||||
|
||||
[build-dependencies]
|
||||
tonic-build = "0.12"
|
||||
|
||||
@@ -1,25 +1,39 @@
|
||||
# Example sentry-agent config. Copy to /etc/sentry-agent/agent.toml, or
|
||||
# pass --config /path/to/this/file.
|
||||
# Example sentry-agent config. Copy to the platform's conventional path
|
||||
# (/etc/sentry-agent/agent.toml on Linux, C:\ProgramData\SentryAgent\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: it defaults to journald, service = "default", and expects
|
||||
# mTLS material at /etc/sentry-agent/{ca,client,client-key}.pem.
|
||||
# all still runs: on Linux it defaults to journald, service = "default",
|
||||
# and expects mTLS material at /etc/sentry-agent/{ca,client,client-key}.pem
|
||||
# (Windows equivalents under C:\ProgramData\SentryAgent\).
|
||||
|
||||
[agent]
|
||||
# host = "explicit-hostname-override" # defaults to /etc/hostname
|
||||
# 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:
|
||||
# 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
|
||||
|
||||
@@ -63,6 +63,7 @@ mod tests {
|
||||
severity: 0,
|
||||
message: msg.into(),
|
||||
attributes: Default::default(),
|
||||
record_id: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@ use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[cfg(not(windows))]
|
||||
const DEFAULT_CONFIG_PATH: &str = "/etc/sentry-agent/agent.toml";
|
||||
#[cfg(windows)]
|
||||
const DEFAULT_CONFIG_PATH: &str = r"C:\ProgramData\SentryAgent\agent.toml";
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
@@ -15,11 +18,13 @@ pub struct Config {
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Loads config from `explicit_path` if given, else from
|
||||
/// `/etc/sentry-agent/agent.toml` if it exists, else falls back to
|
||||
/// built-in defaults (journald source, default TLS cert paths). Only an
|
||||
/// explicitly-passed `--config` path that doesn't exist is an error;
|
||||
/// the conventional default path is optional.
|
||||
/// Loads config from `explicit_path` if given, else from the
|
||||
/// platform's conventional config path if it exists
|
||||
/// (`/etc/sentry-agent/agent.toml` on Linux,
|
||||
/// `C:\ProgramData\SentryAgent\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()),
|
||||
@@ -63,13 +68,55 @@ impl Default for AgentConfig {
|
||||
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 {
|
||||
@@ -119,9 +166,19 @@ pub struct TlsConfig {
|
||||
impl Default for TlsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
ca_cert: PathBuf::from("/etc/sentry-agent/ca.pem"),
|
||||
client_cert: PathBuf::from("/etc/sentry-agent/client.pem"),
|
||||
client_key: PathBuf::from("/etc/sentry-agent/client-key.pem"),
|
||||
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/sentry-agent/{name}"))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn default_cert_path(name: &str) -> PathBuf {
|
||||
PathBuf::from(format!(r"C:\ProgramData\SentryAgent\{name}"))
|
||||
}
|
||||
|
||||
+103
-13
@@ -3,6 +3,9 @@ mod config;
|
||||
mod grpc;
|
||||
mod source;
|
||||
|
||||
#[cfg(windows)]
|
||||
mod service;
|
||||
|
||||
pub mod pb {
|
||||
tonic::include_proto!("sentry.logs.v1");
|
||||
}
|
||||
@@ -18,23 +21,66 @@ use tokio::sync::mpsc;
|
||||
use tonic::transport::Channel;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "sentry-agent", about = "Sentry Linux log collector")]
|
||||
#[command(name = "sentry-agent", about = "Sentry Linux/Windows log collector")]
|
||||
struct Cli {
|
||||
/// Path to a TOML config file. Defaults to /etc/sentry-agent/agent.toml
|
||||
/// if present, otherwise built-in defaults (journald source, default
|
||||
/// TLS cert paths under /etc/sentry-agent/).
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
#[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
|
||||
/// `sentry-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 cli = Cli::parse();
|
||||
let cfg = Config::load(cli.config.as_deref()).context("loading config")?;
|
||||
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();
|
||||
@@ -60,13 +106,23 @@ async fn main() -> Result<()> {
|
||||
};
|
||||
let parsed = sentry_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: parsed.attributes.into_iter().collect(),
|
||||
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;
|
||||
@@ -87,13 +143,24 @@ async fn main() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// `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) {
|
||||
let result = match source {
|
||||
#[cfg(feature = "journald")]
|
||||
// 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(feature = "journald"))]
|
||||
#[cfg(not(all(feature = "journald", target_os = "linux")))]
|
||||
config::SourceConfig::Journald { .. } => {
|
||||
Err(anyhow::anyhow!("this build was compiled without the `journald` feature"))
|
||||
Err(anyhow::anyhow!("this build was compiled without the `journald` feature (or isn't targeting Linux)"))
|
||||
}
|
||||
|
||||
#[cfg(feature = "file-tail")]
|
||||
@@ -104,6 +171,20 @@ async fn spawn_source(source: config::SourceConfig, tx: source::LineSender) {
|
||||
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");
|
||||
@@ -142,6 +223,7 @@ fn to_pb_severity(sev: Option<u8>) -> Severity {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn default_hostname() -> String {
|
||||
if let Ok(s) = std::fs::read_to_string("/etc/hostname") {
|
||||
let s = s.trim().to_string();
|
||||
@@ -151,3 +233,11 @@ fn default_hostname() -> String {
|
||||
}
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -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 = "SentryAgent";
|
||||
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 `sentry-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("Sentry 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 Sentry 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(())
|
||||
}
|
||||
@@ -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 = "SentryAgentEtw";
|
||||
|
||||
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 (sentry_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,
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,7 @@ pub async fn run(path: &Path, from_beginning: bool, tx: LineSender) -> Result<()
|
||||
line,
|
||||
timestamp_unix_nano,
|
||||
severity_hint: None,
|
||||
extra_attributes: Default::default(),
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
|
||||
@@ -61,6 +61,7 @@ pub async fn run(unit: Option<&str>, tx: LineSender) -> Result<()> {
|
||||
line: message,
|
||||
timestamp_unix_nano,
|
||||
severity_hint,
|
||||
extra_attributes: Default::default(),
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// A raw line read from a source, plus whatever metadata the source itself
|
||||
@@ -8,16 +9,31 @@ pub struct RawLine {
|
||||
/// 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. When set,
|
||||
/// this takes precedence over whatever the RFC 5424 parser infers from
|
||||
/// the message text, since it comes from a more authoritative place.
|
||||
/// 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(feature = "journald")]
|
||||
#[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
|
||||
// (sentry_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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user