Skip to content
openhands edited this page Nov 11, 2026 · 3 revisions

DAD can ingest Windows Event Log events, and there are two ways to get them in:

  • Pull (the default): DAD polls the hosts itself over WinRM. It runs wevtutil on the remote host, converts what it reads, and queues it. No collector to install on the Windows side, no extra daemon, and the polling cadence is DAD's to control. It runs as a Job inside dad-scheduler.
  • Push: OpenWEC collects and a forwarder converts what it wrote. Still supported, and still the right choice for hosts that push events to a collector — a pull model cannot recover records for a host DAD cannot reach.

Both paths converge on the same staging record and the same LogsToProcess queue, so everything below the conversion step is shared.

This is optional either way. Nothing changes if you do not enable it: the base install is still three units.

How it fits the existing pipeline

Windows events are converted into the record format the ingestor already accepts, rather than the ingestor being extended to understand Windows events.

                    ┌── poll_windows_events Job (in dad-scheduler) ──┐
Windows hosts ◄─WinRM─┘  wevtutil qe, per channel, on a per-host cycle │
                                                                       │
syslog/UDP:514 ─► LogStaging ─┐                                        │
                              │   OpenWEC ─► openwec.log ─► import_windows_events.rb
                              │                    (dad-windows, every 10s)
                              ▼                                        ▼
                    WindowsStaging ─► LogsToProcess ─► import_syslog.rb ─► PostgreSQL

That choice is deliberate. EventIngestor.stage_event already parses an RFC 3339 timestamp as a single token, so a converted Windows event is already a valid staging record and needs no importer changes at all. Word indexing, batch chunking, crash checkpointing and partition-aware id allocation then apply to Windows events for free. A second, Windows-aware ingest path would have had to reimplement every one of those and would then drift from the first.

Both the poller and the forwarder enqueue; neither imports. dad-importer owns claim, checkpoint and batch. Running a second importer would race the first for the same files. One queue, one importer.

Polling hosts directly

Enabling it

Add the hosts under WindowsTarget in the UI. Each needs an address, a WinRM username and password, a priority band, and the channels to read. A hand-kept list works for a handful of machines; past that, the hosts that never get added are the ones nobody knows are missing, and DAD can populate the list by querying Active Directory instead — see Active Directory Import.

Then confirm the poller Job exists:

bundle exec rails runner 'puts Job.where(script: "poll_windows_events").pluck(:name, :frequency)'

db:seed creates it, so a fresh install already has it. No new service is needed: the poller runs inside dad-scheduler, which already has the Logs volume and whose unit file applies no network sandboxing.

What has to be true on the Windows side

  • The WinRM listener must be reachable on TCP 5986 (HTTPS). DAD connects out to the host; it opens no listener for the host to call.
  • The account needs read access to the event logs. Reading Security normally needs Manage auditing and security log; Application and System need no extra right.
  • wevtutil must exist. It ships with every supported edition, so this is only worth checking on a stripped Server Core image.

Priority bands and the adaptive interval

Polling every host as often as the busiest one needs does not scale, and polling a quiet host every five minutes is load on both sides for nothing. So each host gets a band, and within its band its interval adapts to the rate it is actually producing:

band fastest slowest
Priority 1 60s 5 min
Priority 2 5 min 30 min
Priority 3 30 min 6 h

The interval aims at roughly 500 events per poll, clamped to the band:

interval = clamp(500 / smoothed_events_per_second, band_min, band_max)

Three things keep that usable rather than twitchy:

  • The rate is an exponential moving average, so one burst — a reboot, a logon storm — decays over a few polls instead of pinning the host at its fastest interval for hours.
  • A change is applied only when it is at least 25% off the interval in force. Without that deadband the interval oscillates, and an oscillating interval is indistinguishable from a bug when you are trying to reason about when a host was last polled.
  • A host that produced nothing takes its band's slowest interval, not zero.

Bands are rows in polling_priorities, editable in the UI, so a fourth level is a form submission rather than a code change. The pair is deliberately two-sided: max_interval_secs is the priority guarantee ("polled at least every five minutes"), and min_interval_secs is a separate guard against a busy host being polled often enough that the poll itself becomes the load.

Scheduling state lives in the row, not in memory. The scheduler runs under Restart=always, and a crash between polls must not reset a host's measured rate, or every restart would send busy hosts back to their slowest interval.

Disabling a host

Each target has an enabled checkbox. Turning it off stops the polling but keeps the row, and with it the measured event rate, the credentials and the poll history — which is what you want for a host down for maintenance. Deleting the row throws all of that away.

Credentials

A WinRM password is a domain credential, so it is encrypted at rest by Rails' encrypts; the password column holds ciphertext, not plaintext.

  • Production must set DAD_AR_ENCRYPTION_KEY and DAD_AR_ENCRYPTION_SALT. DAD refuses to boot in production without them rather than falling back to storing credentials in the clear. Rails 8.1 requires the salt whenever encryption is configured.
  • Rotating either value makes stored Windows credentials unreadable — there is no re-derivation path, so every host's password must be re-entered. Keep these keys out of the rotation tooling. See Installation.
  • The credential is never rendered back to a browser, on the HTML screens or in the .json forms. The edit form shows a blank password field and a blank submission leaves the stored credential alone, so an unrelated edit cannot silently wipe it.
  • WinRM uses TLS on 5986 with the peer certificate verified against the system trust store. WINDOWS_SSL_VERIFY=none trusts an internal CA instead, and is an explicit operator decision rather than a default. The same variable covers the LDAPS bind used when querying Active Directory, rather than a second knob for the same decision.

Delta fetching, and the duplicate trade

wevtutil has no cursor, so the boundary is a time window: each poll reads records whose TimeCreated falls since the last poll, widened by 60s. The widening is not slack — TimeCreated is when the event happened, not when it reached the channel, and a service can log a timestamp and have the write land seconds later. An exact boundary drops those.

The cost is that records inside the overlap can be ingested twice. That is the deliberate trade: a duplicate row is visible and merely inflates a count, whereas a missed record is invisible and silently under-reports. There is no dedup anywhere in the ingest path, so this is a choice, not an oversight. It matches the at-least-once guarantee the importer already has — see Architecture § Ingest guarantees.

Two more bounds, both to keep one host from dominating a cycle:

  • At most 5000 records per channel per poll, newest-first, so a runaway log yields its most recent records and the remainder arrives next poll.
  • A new host's first poll reads the last 24h, not the whole channel. Bootstrapping full history would take minutes per channel and flood the queue on first contact.

Failure behaviour

A host that is down, firewalled, or has bad credentials is a normal condition in a fleet of hundreds, not an exception:

  • WindowsPoll.poll never raises for a host-level failure. An exception there would abort the cycle and skip every remaining target.
  • A failing host is recorded in last_status and rescheduled at its band's slowest interval — not retried every 60s, and it does not block other hosts.
  • A failing channel does not lose the host's other channels: a host where Security is access-denied still yields its Application and System records, with wevtutil's own error text kept in the status so the cause is legible.
  • wevtutil's exit code is checked. It exits 3 for an unknown channel and 5 for access denied; reporting those as "no events" would make a permission-locked channel look quiet.

Checking the poller

select address, last_polled_at, next_poll_at, last_event_count, poll_count, last_status
from windows_targets order by last_polled_at desc nulls last;

last_status is where a problem surfaces. partial: Security: wevtutil exit 5: Access is denied means the channel list is wrong for that account, not that the host is quiet.

Collecting through OpenWEC instead

Point an OpenWEC subscription at its Files output driver:

[[subscription]]
name = "Security"

[subscription.output.files]
path = "/var/DAD/Logs/Windows"
file_name = "openwec.log"
format = "Json"

Then run the forwarder:

sudo mkdir -p -m 2775 /var/DAD/Logs/Windows
# writable by whichever user OpenWEC runs as, readable by www-data, which is
# what dad-windows runs under -- hence the group and the setgid bit.
sudo chown openwec:www-data /var/DAD/Logs/Windows
sudo install -m 0644 systemd/dad-windows.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now dad-windows
journalctl -u dad-windows -f

Under Docker Compose it sits behind a profile, because it needs an OpenWEC installation the stack does not provide:

docker compose --profile windows up -d
variable default meaning
WINDOWS_INPUT Logs/Windows/openwec.log the file OpenWEC writes
WINDOWS_STAGING Logs/WindowsStaging where converted lines are buffered
WINDOWS_QUEUE Logs/LogsToProcess the queue dad-importer polls
WINDOWS_MAX_LINES 5000 lines per queue file
WINDOWS_POLL_INTERVAL 10 seconds between input scans

The poller uses the same WINDOWS_STAGING and WINDOWS_QUEUE defaults, so both paths publish to the queue identically.

dad-windows uses Wants=openwec.service, not Requires: DAD should keep running and wait if the collector is down, not be dragged down with it.

Do not point OpenWEC at Logs/LogStaging

This is the one setup mistake that corrupts data silently instead of erroring.

LogStaging belongs to the Rust syslog collector, whose startup recovery moves every file it finds there into LogsToProcess, on the stated assumption that it is the only writer in that directory. If OpenWEC writes there, the collector renames its in-progress file away mid-append, and the tail of one file ends up in another.

So OpenWEC gets its own directory (Logs/Windows), and the forwarder buffers in a second one (Logs/WindowsStaging). Staging has to be separate from the queue too, for the same class of reason: LogQueue.pending globs * and excludes only .processing and .offset names, so a partially written file sitting in the queue directory would be claimed and read while still being appended to. The forwarder writes to staging, fsyncs, and only then renames into the queue — the rename is what publishes the file.

Formats and field mapping

Four OpenWEC output formats are handled, because which one you pick is a deployment choice, not a code one: Json, Nxlog, Raw (the untouched Windows XML) and JsonRaw. A record is identified by its framing rather than by guessing the format — JSON records are one per line, a Raw record runs from <Event to </Event>.

The poller reads Raw XML straight from wevtutil and feeds each <Event> to the same converter, so the Raw XML column below is also what the pull path produces.

DAD field Json / JsonRaw Nxlog Raw XML
host (System) System.Computer Host / Source System/Computer
timestamp (generated) System.TimeCreated EventTime System/TimeCreated
service (Service) System.Provider.Name SourceName System/Provider Name
body (hunks) EventData as key=value Event as key=value EventData as key=value

EventID, Channel, Level, EventRecordID, Keywords, Task, Opcode and the provider name are emitted as explicit fields, and EventData values follow as data_<name>=<value>.

Field keys are downcased in every format, so the same event arriving as Json versus Nxlog does not display under two different spellings. Field values keep their case, because a username is data. Note this affects only hunks in the UI and in exports — the word index downcases regardless, so search behaviour is the same either way. See Search for what that index can and cannot do.

Because search is prefix matching, fields are emitted as eventid=4688 rather than eventid:4688. = is replaced by a space when the word index is built, so both halves stay searchable; with a colon, 4688 could never match inside eventid:4688.

Timestamps are normalised to RFC 3339 with a literal Z. Windows emits 7–9 fractional digits and OpenWEC emits a UTC offset; both parse, but only the Z form survives the ingestor's sanitiser intact — a + becomes a space, which would split the timestamp across two tokens and shift the service name off its position in the line.

One record is not one line

A rendered Windows event message (RenderingInfo/Message) can contain literal newlines: a service-stopped event embeds the service's dependency list, a process-creation event can embed a long command line. OpenWEC's Files driver appends exactly one newline per record, which holds for Json, Nxlog and JsonRaw but not for Raw.

So the forwarder cannot assume one line per record. It reads in byte chunks and splits on record boundaries, tracking a byte offset that advances only past records it has actually yielded. A record that has not finished arriving is left in the buffer and resumed on the next pass; a record that never terminates is dropped at a size ceiling rather than growing the buffer forever. One 4688 event arrives as one row, not a dozen partial ones — and partial ones would be indistinguishable from real data once they were in the database.

The resume offset is written after the converted lines are flushed, which makes this path at-least-once, matching the importer's guarantee (see Architecture § Ingest guarantees). The reverse ordering would lose events instead of duplicating them.

The pull path does not have this problem: wevtutil returns a complete <Events> document per call, so records are split from a whole document rather than from an append-only stream.

Checking it works

select s.address, e.generated, e.hunks
from events e join systems s on s.id = e.system_id
where e.hunks like '%eventid=4688%' limit 5;

If events are missing on the OpenWEC path, work through the chain in order:

symptom cause
journalctl -u dad-windows shows no input at ... wrong WINDOWS_INPUT, or OpenWEC is not writing
skipped N unconvertible record(s) the records do not parse in any of the four supported formats — usually a subscription writing a format or framing the driver config does not match
a file left in Logs/WindowsStaging the forwarder is not flushing
a file left in Logs/LogsToProcess dad-importer is not running

On the pull path, start from windows_targets.last_status instead — see Checking the poller.

Limits worth knowing

  • The WinRM transport is not exercised in CI. There is no Windows host in the test environment, so the transport sits behind a seam and the parsing, delta-query and queueing logic — the parts that can actually be wrong — is tested against recorded wevtutil XML. The WinRM call itself is covered only by a smoke test.
  • The OpenWEC test fixtures are hand-authored. They are transcribed from OpenWEC's format documentation, not captured from a live Windows Event Collector — OpenWEC ships no event fixtures. They are a format contract, not a byte-exact upstream capture. If OpenWEC changes its output, re-derive them from a real collector.
  • Windows events land in the same events table and inherit the same retention and partitioning as syslog (see Retention and Archives). There is no separate retention policy for them.
  • Service is populated from the Windows provider name, which is a different vocabulary from the daemon names syslog produces. Anything you filter on Service needs to account for both.
  • The pull path reads; it does not subscribe. Records written while a host is unreachable are only recovered if they are still in the channel at the next successful poll. That is the trade for not running a collector, and it is why the OpenWEC path is kept: a subscription receives pushes regardless.
  • The LDAP search is not exercised in CI either. As with the WinRM transport, there is no domain controller in the test environment. Filter construction, escaping, OS classification and address matching are tested against an injected client; the bind and paged search against a real directory are not. See Active Directory Import.

Clone this wiki locally