ADR-015: Compile a Windows message resource into the binary¶
Status: accepted Date: 2026-08-09
Context¶
Win32EventLogWriter registered its event source with
eventlog.InstallAsEventCreate, which points the registry value
EventMessageFile at EventCreate.exe. That resource carries message
definitions for event IDs 1–1000. pkg/mapper emits 4660, 4663 and 4670.
Windows therefore could not resolve a description for any event this product
writes. Event Viewer, Get-EventLog, and every forwarder built on the Event
Log API rendered:
The description for Event ID 4663 from source PowerStore-CEPA cannot be found. … The following information is part of the event: '
'
The payload survived as a raw insertion string, so the data was never lost — what was lost was the description, and with it any chance of the output matching what a Windows-native consumer expects.
This shipped in every release for the product's entire life. It was never
caught because nothing executed the code. make ci runs on Linux, where
//go:build windows excludes writer_windows.go from the compiler
altogether — not compiled-then-discarded; the compiler never sees it. A
Windows-only defect could be green on every check.
Decision¶
Author a .mc message file, compile it to a .syso, and commit the
compiled object to the repository.
pkg/evtx/messages.mc defines one message per mapped event ID, each carrying
the output of formatWin32Message as its single insertion string %1.
make winres compiles it in two steps — windmc produces .rc plus a
binary message table, windres produces the linkable COFF object — using a
local mingw-w64 toolchain when present and a container otherwise.
Register against the exporter's own executable.
eventlog.Install(win32SourceName, os.Executable(), …) replaces
InstallAsEventCreate, so EventMessageFile names a binary that actually
carries the messages.
Detect and repair a stale registration. eventlog.Install is a no-op when
the source already exists. Every host that ran a previous version has a
PowerStore-CEPA source pointing at EventCreate.exe and would have rendered
placeholder text forever. ensureEventSource reads the registered
EventMessageFile, and when it does not match the current executable, removes
and reinstalls the source.
Degrade rather than refuse to start. Registration needs Administrator rights. Without them the writer logs a warning naming the consequence and continues. An exporter writing badly-rendered events is more useful than one that will not run.
Why the .syso is committed¶
This is the part that looks wrong at first glance — a binary artifact in version control — so the reasoning is recorded rather than left to be rediscovered:
- The Go linker picks up
*.sysofiles in a package directory automatically, by filename convention._windows_amd64in the name keeps it out of every other target. No build-tag plumbing, no linker flags. - It works under
CGO_ENABLED=0. A C toolchain is needed to produce the object, never to consume it, so cross-compiling to Windows from Linux or macOS stays dependency-free. - Nothing extra ships. The message resource lives inside
cee-exporter.exe, so an operator has no separate DLL to install, register, or get wrong. - The alternative — generating it during the build — would put mingw-w64 in the dependency path of every build on every platform, including the release pipeline, to produce a 468-byte file that changes only when the message text changes.
The cost is that a committed binary can rot silently: nothing in Go references
it, so deleting it, truncating it, or building it for the wrong architecture
would break Event Viewer output while every build and test stayed green.
pkg/evtx/rsrc_test.go guards against that on every platform — it checks
the file exists, is non-empty, and opens with the COFF machine type
0x8664 — so the Linux CI gate catches a resource problem even though the
resource is only ever used on Windows.
Consequences¶
A Windows CI job became mandatory. A message resource whose correctness
cannot be observed is no better than the placeholder it replaces. ci.yml
gained a windows-latest job — the first thing in this repo's history to
execute writer_windows.go and service_windows.go rather than merely
compile them.
Two things about that job are worth recording, because both were found by mutation and neither is obvious:
Get-WinEventreturns a$nullMessagefor an unresolvable event. The stringThe description for Event IDnever appears in its output; it only surfaces viaGet-EventLog. An assertion searchingGet-WinEventfor that text would pass on broken output.- Asserting that the message contains the payload is not enough.
Object Name:comes from the%1insertion string, not from the resource, so a resource with missing or swapped descriptions satisfies it. The job asserts each event ID's own description text.
Adding a mapped event ID now requires four coordinated edits —
pkg/mapper, messages.mc, emit_test_events.go and the assertion table in
ci.yml. Nothing enforces that they agree. A fifth ID added to the mapper
alone would render as a placeholder again, and no job would notice.
windows/arm64 was dropped from the release matrix (v5.0.0). Windows
Server does not ship for ARM64 and no CI runner can execute the artifact, so
it would have been the one published target with an unverifiable message
resource. Producing an arm64 object is possible —
llvm-windres --target=aarch64-w64-mingw32 yields a valid coff-arm64 — and
was deliberately not done.
Only English is defined. messages.mc declares
LanguageNames=(English=0x409:MSG00409). On a non-English Windows install the
description falls back to the English text rather than the placeholder, which
is a degradation and not a regression, but it is undefined territory nobody
has tested.
Alternatives considered¶
Ship a separate message DLL. Rejected: an operator step that can be skipped or done wrong, and a second artifact to version, sign and distribute.
Keep InstallAsEventCreate and remap to IDs under 1000. Rejected: 4663,
4660 and 4670 are the Windows Security audit IDs that SIEM content and
operator familiarity are built around. Renumbering to make the tooling happy
would trade a rendering defect for a semantic one.
Generate the .syso in CI and commit it from there. Rejected: a bot
commit on every build, and the release pipeline would still need the
toolchain.
References¶
pkg/evtx/messages.mc,pkg/evtx/rsrc_windows_amd64.sysopkg/evtx/writer_windows.go—ensureEventSourcepkg/evtx/rsrc_test.go— the cross-platform guard.github/workflows/ci.yml— thewindowsjob- docs/windows-verification.md — the manual Event Viewer protocol, including what CI cannot cover
- ADR-010 — Windows SCM
integration, whose
svcProgram.Start/Stopthe same job now exercises