rustls 0.23.43 accepted TLS 1.3 handshake messages sent at the wrong encryption level when they followed a key-changing message in the same record, where RFC 8446 requires the connection to be terminated. The transcript is still authenticated, so a handshake cannot be altered, but a peer could send in plaintext what should be encrypted. It reaches the agent through tonic -> tokio-rustls, on its mTLS gRPC link to ingest. Fixed in 0.23.45, inside the existing range, so only the lockfile moves. The advisory was published after main last passed, so the agent's cargo-deny advisories job failed on every open PR, including ones that never touched Rust. Also drops the RUSTSEC-2025-0134 ignore from agent/deny.toml. Its own reason said to delete it once tonic stopped pulling in rustls-pemfile; tonic 0.14 no longer does, and cargo-deny warned the advisory was not encountered. Signed-off-by: John Coffey <[email protected]>
cairnobs-agent
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
cairnobs-parser— pure-stdRFC 5424 syslog parser with raw-passthrough fallback. No I/O, easy to unit test in isolation.cairnobs-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
The journald source shells out to journalctl -f -o json rather than
linking libsystemd via FFI. Statically linking libsystemd into a musl
binary is fragile — it pulls in dbus/libcap transitively and isn't designed
for static linking — and would undermine the no-glibc-runtime-deps goal
even where technically possible. journalctl ships on every systemd distro
this agent targets, so shelling out sidesteps the problem entirely. See
/docs/architecture.md.
Building
Native build (whatever target your machine is):
cargo build --release
musl targets (what actually ships):
rustup target add x86_64-unknown-linux-musl aarch64-unknown-linux-musl
# x86_64: works with musl-gcc installed locally (musl-tools on Debian,
# musl on Arch, etc.) — the musl target is fully static by default.
cargo build --release --target x86_64-unknown-linux-musl
# aarch64 cross-compilation needs a cross toolchain; the boring, reliable
# option is `cross` (https://github.com/cross-rs/cross), which builds
# inside a Docker container with the right linker preinstalled:
cross build --release --target aarch64-unknown-linux-musl
Building requires protoc on PATH (used by tonic-build/prost-build at
compile time to generate the gRPC client from /proto/sentry/logs/v1/logs.proto).
Container build (see caveat below):
# from the repo root, not agent/
docker build -f agent/Dockerfile -t cairnobs-agent .
Caveat: the container image is provided for CI/completeness, but
journald sourcing needs journalctl and access to the host journal —
neither of which exist in the scratch image or are available to a
container without deliberately bind-mounting /var/log/journal (or
/run/log/journal) and the journalctl binary in. The intended Phase 0
deployment for journald sourcing is as a native binary managed by systemd
on the host, not containerized.
Building for Windows
# 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:
./cairnobs-agent
This uses the platform's conventional config path if present
(/etc/cairnobs-agent/agent.toml on Linux, C:\ProgramData\CairnObsAgent\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.
./cairnobs-agent --config /path/to/agent.toml
Heartbeat and unavailability alerting
Every agent sends a small, independent "still alive" record on its own
schedule ([heartbeat] in the config, default every 60s), separate from
whatever real log traffic is flowing — see config/agent.example.toml.
This isn't a new wire protocol: it's an ordinary record through the same
PushBatch RPC and mTLS identity every log line uses, tagged with a
cairnobs.heartbeat=true attribute so it's easy to filter for and doesn't
show up as noise in normal log views. Set interval to a plain number
plus s/m/h (matches the query language's own earliest=/latest=
units); enabled = false turns it off entirely.
The platform has no separate "agent status" concept — an agent going
quiet is just the absence of its heartbeat records, which the existing
alerting engine already detects natively via an absence-condition
alert rule. See /docs/agent-heartbeat-monitoring.md for the exact rule
to create.
Host CPU/memory/disk metrics
Same shape as heartbeat, same reasoning: [metrics] in the config
(enabled = false by default) sends a periodic record — CPU%, memory
used/total, disk used/total for / — tagged cairnobs.metrics=true, with
the individual numbers as their own attributes (cpu_percent,
mem_used_bytes, mem_total_bytes, disk_used_bytes,
disk_total_bytes), queryable directly (e.g. cpu_percent > 80) since
the query language transparently maps any non-standard field name to
attributes['field'] with automatic numeric casting. Powers the web
UI's "Hosts" nav section. Linux-only for now (src/metrics.rs) — reads
/proc/stat//proc/meminfo and shells out to df, no new
dependencies, same "shell out to a boring, ubiquitous tool" precedent
journalctl already sets.
Enable this on only one agent process per physical host. It's
common for one host to run several cairnobs-agent processes (one per
log source, each needing its own [agent] host value to work around
the agents table's UNIQUE (tenant_id, host) constraint — see
/docs/agent-management-design.md) — turning [metrics] on for more
than one of them reports the same physical machine as multiple
different "hosts" with conflicting metric series. Pick the one process
using that host's real, unoverridden hostname.
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:
cairnobs-agent.exe install # registers the service, Automatic start, LocalSystem account
sc.exe start CairnObsAgent
sc.exe stop CairnObsAgent
cairnobs-agent.exe uninstall
install/uninstall/run-service are subcommands only present in
Windows builds (cairnobs-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 /PROJECT-SPEC.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:
- 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 Cairn OBS code involved in the forwarding itself. Run this agent on the collector box, subscribed to theForwardedEventschannel instead of the usual three:[source] kind = "eventlog" channels = ["ForwardedEvents"] - What this repo does not implement: a true agentless receiver —
Cairn OBS itself speaking the WS-Management/WinRM event-subscription
protocol so endpoints can forward directly to
ingestwithout any Windows Event Collector role or Cairn OBS 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
cargo test --workspace
Feature flags
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). Cross-platform, works on Windows too.windows-eventlog— Windows Event Log viaEvtSubscribe.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.
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.