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:
2026-08-13 11:27:35 -07:00
parent fe854b1091
commit cd8aa290ca
66 changed files with 6084 additions and 171 deletions
+93
View File
@@ -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
View File
@@ -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.
+20 -1
View File
@@ -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"
+20 -6
View File
@@ -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
+1
View File
@@ -63,6 +63,7 @@ mod tests {
severity: 0,
message: msg.into(),
attributes: Default::default(),
record_id: String::new(),
}
}
+65 -8
View File
@@ -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
View File
@@ -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())
}
+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 = "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(())
}
+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 = "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()
+20 -4
View File
@@ -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
}
}