ARD-0015: ulogd2 sidecar for cross-platform --learn-mode¶
- Status: Accepted (blocks v0.4 release)
- Date: 2026-05-24
- Deciders: Tom (Claude facilitating)
- Amends: ARD-0011 —
--learn-modelog-source mechanism - Related: [[ard-0008-v03-to-v10-release-plan-and-thesis-evolution]], [[ard-0011-egress-enforcement-via-iptables]]
Context¶
ARD-0011 shipped iptables-in-container egress enforcement with --learn-mode as the observe-and-propose authoring tool. The original --learn-mode design reads the container's dmesg (kernel ring buffer) to capture iptables LOG-prefix entries from the LOG-only rule set, then parses them into a proposed egress.allow: diff.
This works on Linux native (container shares the host's kernel; dmesg from inside the container returns the iptables LOG entries directly). It does not work on Mac+Orbstack — the platform we and the dogfood team actually use:
- Orbstack runs containers inside a managed Linux VM
- The container's
dmesginside the VM does not see iptables LOG entries - They go to the VM's kernel buffer, in a different namespace from the container's user-space view
- Net effect:
--learn-moderuns without error but produces an empty proposed allowlist diff at session end
ARD-0011's smoke test caught this and documented it as a known gap with the v1.x followup "an alternative log source (e.g. ulogd2 sidecar or kmsg-piping daemon) for Orbstack." We had to make a release-shape call:
- Ship v0.4 with
--learn-modeLinux-only → Mac users (us; the team) get egress enforcement without authoring, which is exactly the unshippable shape C8 in /grill-me explicitly rejected. - Hold v0.4 until cross-platform
--learn-modeworks → ship the feature once, ship it whole.
The second is the only option that respects the v0.4 contract. This ARD records the chosen mechanism so the work doesn't drift.
Decision¶
--learn-mode log source is a ulogd2 userspace sidecar, not dmesg¶
A new compose sidecar runs ulogd2 (a userspace netfilter logger from the netfilter.org project). The iptables LOG-prefix rules in the dev container are replaced with NFLOG rules that emit packets on a netfilter netlink socket. The sidecar reads that socket and writes a structured log to a shared volume that boring reads on session end.
Concrete shape:
- New sidecar
egress-logger, compose-wired whenegress.allow:is non-empty ANDBORING_EGRESS_MODE=learn. Image: a thin Debian-slim base +ulogd2package (the standard netfilter logger) + minimal ulogd config. - Shared volume
egress-logbetween the dev container and the sidecar — mounted at/var/log/boring/egress/in both. The sidecar writes; the dev container does not read (boring reads from the host via the bind-mount on shutdown). - iptables rules in
learnmode use-j NFLOG --nflog-group 5 --nflog-prefix boring-egress-attemptinstead of-j LOG. NFLOG is the userspace-deliverable counterpart of LOG; it doesn't require kernel-log access. - Sidecar shares the dev container's network namespace via
network_mode: "service:dev". This lets the sidecar'sulogd2see the dev container's netfilter NFLOG packets — they're per-network-namespace, not per-container. - boring's
egress_propose_allowlist_diffchanges log source: readegress-log/ulogd.jsoninstead ofdmesg. ulogd2's JSON output plugin gives us structured rows directly; less parser fragility than dmesg text-grep. enforcemode is unchanged — iptables REJECT happens at the kernel level on both platforms; the ulogd2 sidecar islearn-only.
Cross-platform consequences¶
- Mac+Orbstack:
--learn-modeproduces a non-empty diff for the first time. Tom and the team can author allowlists on the platform they actually use. - Linux native: Same flow. ulogd2 is platform-portable (it's a standard Debian package on both). The dmesg path is removed entirely — one log source for both platforms instead of one-that-half-works-everywhere.
- Docker Desktop (Win/Mac): Same as Orbstack — runs Linux in a VM; container's dmesg doesn't see iptables; ulogd2 sidecar with shared netns works because NFLOG is delivered to the sidecar process directly.
Sidecar lifecycle¶
- compose
depends_on: dev service waits foregress-loggerto be running (service_started, notservice_healthy— ulogd2 doesn't have a meaningful healthcheck; the netlink socket is opened immediately on start). - Sidecar dies with the dev service via
restart: "no"and the existing compose shutdown. - The log file rotates internally per ulogd2 config (size cap; the audit log infrastructure in ARD-0010 is separate and doesn't share the file).
Consequences¶
Positive¶
--learn-modeworks on every platform v0.4 supports, including the Mac+Orbstack path that the dogfood team uses daily.- One log source, not two. dmesg goes away.
egress_propose_allowlist_diffreads a single well-formed JSON file regardless of platform. - The "enforcement without authoring" anti-pattern stays defended — v0.4 ships only when the authoring tool works for everyone enforcement is shipping for.
Negative¶
- v0.4 timeline grows by 1-3 days (sidecar Dockerfile + ulogd2 config + NFLOG rule swap in install-egress + JSON-log parser update + smoke on both platforms). ARD-0011 originally estimated v0.4 at 2-3 weeks; this brings it closer to the upper end.
- One more compose service to maintain. The sidecar adds image-build surface, a shared-volume contract, and a netns sharing dependency on docker. Not exotic but more moving parts than dmesg-grep.
- Slightly higher container start time in
learnmode (sidecar boot + netns coordination, ~1-2s).
Neutral¶
enforcemode is unchanged — same iptables rules, same kernel-level REJECT, no sidecar. The cost islearn-mode-only.- The NFLOG group number (5) is arbitrary but should be documented so the user can change it if their host kernel already has a netfilter consumer on that group.
Alternatives Considered (rejected)¶
- Ship v0.4 with
--learn-modeLinux-only; document the Mac gap. Rejected: the dogfood team is on Mac. Shipping a feature that doesn't work for the people who would validate it means the feature is never validated. Same failure mode as if we hadn't built it. - Read iptables LOG via Orbstack's VM-level log API. Rejected: ties boring to Orbstack-specific behavior. Docker Desktop users would still be broken; future runtime changes break us silently.
-j AUDIT(auditd path). Rejected: requires a host-side auditd daemon + audit rule routing, even more platform variation. ulogd2 is purpose-built for this exact use case.- kmsg-piping daemon as a sidecar. Rejected: similar mechanism to ulogd2 but reading
/proc/kmsgrequires CAP_SYS_ADMIN, which is stronger than the CAP_NET_ADMIN we already grant. ulogd2 only needs the NFLOG netlink socket, which works with no extra capabilities. - Defer
--learn-modeentirely to a later release; ship v0.4 enforcement only with documented "author by hand." Rejected at the /grill-me C8 decision — enforcement without authoring is the textbook unshippable feature; we're not going to ship it just to hit a date.
Implementation Order¶
templates/_common/egress-logger/— new shared sidecar image.FROM debian:bookworm-slim,apt-get install -y --no-install-recommends ulogd2, a config file that turns on the JSON output plugin and points at/var/log/boring/egress/ulogd.jsonwith size-based rotation. Default ulogd config reads NFLOG group 5, prefixboring-egress-attempt.templates/_common/bin/install-egress— swap-j LOG --log-prefix "[boring-egress-attempt]"for-j NFLOG --nflog-group 5 --nflog-prefix boring-egress-attemptwhenBORING_EGRESS_MODE=learn.enforcemode unchanged (still REJECT).lib/compose.sh— whenegress_enabledANDBORING_EGRESS_MODE=learn: emit theegress-loggersidecar withnetwork_mode: "service:dev", shared volumeegress-log:/var/log/boring/egress,cap_add: [NET_ADMIN]. The dev service gainsdepends_on: egress-logger. Top-level named volumeegress-logadded.lib/egress.sh—egress_propose_allowlist_diff— read from<repo>/.devcontainer/boring-runtime/egress-log/ulogd.json(host-side path; the shared volume backs to a host bind-mount) instead of from a piped dmesg. Parse JSON line-by-line; group by destination host (reverse-DNS the destination IPs); emit YAML diff against the current allowlist.boring_cmd_open_emit_learn_diff— change the log-source argument from a dmesg pipe to the JSON file path. The function shape is otherwise unchanged.- Smoke test (cross-platform) — Mac+Orbstack: run
--learn-mode, hit a non-allowlisted host withcurl, Ctrl-C, verify the diff includes the host. Linux native: same flow.
Done definition¶
boring open --learn-mode <repo> against a profile with egress.allow: set, on Mac+Orbstack, produces a proposed allowlist diff on Ctrl-C that includes every host the container attempted to reach during the session. Same flow on Linux produces equivalent output. When this passes, v0.4 is unblocked.