From 394ad108248361876fbd9cf18564cbea7cb13199 Mon Sep 17 00:00:00 2001 From: Audiobuzz Date: Fri, 10 Jul 2026 09:02:05 +0000 Subject: [PATCH] agent: query chrony over loopback UDP command socket (unix socket unusable under sandbox) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the blank chrony fields (found on comet-beacon-54): chrony 4.x (RHEL 9) defaults its command interface to the UNIX socket only (/run/chrony/chronyd.sock), usable by root or the chrony group with a writable /run/chrony. The fleet-agent unit is hardened — DynamicUser (group tss, not chrony) + ProtectSystem=strict (/run read-only) — so it cannot open the unix socket nor create chronyc's reply socket. Bench proof: normal-user 'chronyc tracking' -> 506 Cannot talk to daemon; only sudo worked; 'chronyc -h 127.0.0.1' failed and ss showed no UDP :323. So the probe always failed and every chrony_* field stayed empty. SELinux permissive did NOT help — it was never (only) an SELinux problem; the nnp_transition module (prev commit) is still needed for the enforcing exec transition. Fix: bind chronyd's command socket on 127.0.0.1 (bindcmdaddress in chrony.conf, idempotent in the 2c provisioner) and point the probe at it (chronyc -n -h 127.0.0.1 tracking). UDP needs no fs write or group and rides the unit's already-allowed AF_INET. Loopback + monitoring-only (state changes still need the root unix socket), so exposure is minimal. Rejected the unix-socket route (chrony group + ReadWritePaths=/run/chrony) because /run/chrony is 0750 (group has no write). Documented in docs/stig-deviations.md #17; .te bench note updated for the enforcing UDP path. Needs a workstation image rebuild + reboot. --- diskless.pkr.hcl | 11 ++++++++ docs/stig-deviations.md | 42 +++++++++++++++++++++++++++++++ files/agent/snapshot.go | 9 ++++++- files/stig/fleet-agent-chronyc.te | 14 ++++++++--- 4 files changed, 71 insertions(+), 5 deletions(-) diff --git a/diskless.pkr.hcl b/diskless.pkr.hcl index 2272ae4..d45a40f 100644 --- a/diskless.pkr.hcl +++ b/diskless.pkr.hcl @@ -405,6 +405,17 @@ build { "restorecon /etc/NetworkManager/dispatcher.d/10-chrony-dhcp-ntp", "sed -i -E 's/^[[:space:]]*(pool|server)[[:space:]]/#&/' /etc/chrony.conf", "grep -q '^sourcedir /run/chrony-dhcp' /etc/chrony.conf || echo 'sourcedir /run/chrony-dhcp' >> /etc/chrony.conf", + # Bind chronyd's command socket on LOOPBACK UDP so the fleet-agent can query + # clock health (`chronyc -h 127.0.0.1 tracking`). chrony 4.x (RHEL 9) defaults + # the command interface to the UNIX socket /run/chrony/chronyd.sock ONLY + # (root or the chrony group + a writable /run/chrony for chronyc's reply + # socket). The agent runs hardened — DynamicUser (not in chrony group), + # ProtectSystem=strict (/run read-only) — so it can't use the unix socket; a + # loopback UDP command socket needs no fs write or group and rides the unit's + # already-allowed AF_INET. Localhost-only + monitoring-only (state changes + # still require the root unix socket), so the exposure is minimal. Documented + # in docs/stig-deviations.md #17. + "grep -q '^bindcmdaddress 127.0.0.1' /etc/chrony.conf || echo 'bindcmdaddress 127.0.0.1' >> /etc/chrony.conf", # Inject the fleet NIC drivers into the dracut config "sed -i 's/@NIC_DRIVERS@/${var.nic_drivers}/' /etc/dracut.conf.d/90-diskless.conf", diff --git a/docs/stig-deviations.md b/docs/stig-deviations.md index 27c375f..0ab1991 100644 --- a/docs/stig-deviations.md +++ b/docs/stig-deviations.md @@ -287,6 +287,48 @@ AVC under the agent's mount namespace (extend the `.te` if any appears); (d) hash stability across two builds (same concern as #12/#14/#15). NB: only shipped when `enable_stig=true`; a non-STIG enforcing build would still see the AVC. +**Not the whole story — see #17.** Permissive-mode bench (`comet-beacon-54`, +2026-07-10) showed the chrony fields STILL empty with SELinux permissive, proving +the module isn't the only blocker: chronyd's command interface was reachable only +via its **unix socket**, which the hardened agent can't use. #17 is the companion +fix; this module remains needed for the enforcing exec transition. + +## 17. chronyd loopback UDP command socket for the fleet-agent probe (added 2026-07-10) + +Not a STIG-rule waiver — a **one-line `chrony.conf` addition** +(`bindcmdaddress 127.0.0.1`) so the hardened `fleet-agent` can read clock health. + +**Why:** the agent reports offset/source/stratum via `chronyc tracking`. On chrony +4.x (RHEL 9) the command interface defaults to the **unix socket only** +(`/run/chrony/chronyd.sock`), usable solely by root or the `chrony` group **and** +needing a writable `/run/chrony` for chronyc's reply socket. The agent unit is +hardened — `DynamicUser=yes` (ephemeral uid, group `tss` only, not `chrony`) and +`ProtectSystem=strict` (`/run` read-only) — so it cannot open the unix socket nor +create a reply socket. Bench (`comet-beacon-54`, 2026-07-10): as a normal user +`chronyc tracking` → `506 Cannot talk to daemon`; only `sudo chronyc` worked; +`chronyc -h 127.0.0.1` failed and `ss -lnup` showed **no UDP :323** — the loopback +command socket wasn't bound. Result: the probe always failed, every `chrony_*` +field stayed empty (Monitor chrony row `—`), and SELinux permissive did NOT help +(this is a sandbox/socket issue, orthogonal to #16). + +**The change:** bind chronyd's command socket on `127.0.0.1` (UDP 323, loopback) +in the 2c provisioner (idempotent, next to the DHCP `sourcedir` edit); the agent +probe uses `chronyc -n -h 127.0.0.1 tracking`. UDP needs no filesystem write and no +group, and rides the unit's already-allowed `AF_INET` — the one transport that +works under the sandbox. The unix-socket alternative (add `chrony` group + +`ReadWritePaths=/run/chrony`) was rejected: `/run/chrony` is `0750` (group has no +write), so chronyc still couldn't create its reply socket. + +**Scope / risk:** the command socket is bound **loopback-only** and is +**monitoring-only** — chronyd still requires the root unix socket for any state +change (`makestep`, `add server`, etc.), so a local caller can *read* clock status +but cannot alter time. No network exposure (127.0.0.1, not the fleet LAN). This is +also chrony's historical pre-4.x default. **BENCH-VERIFY:** (a) `ss -lnup | grep +:323` shows the loopback socket after boot; (b) the Monitor chrony row populates; +(c) under enforcing, no follow-on `chronyc_t → chronyd_t` UDP AVC (extend +`fleet-agent-chronyc.te` if so — see #16). Only applied when the NTP/DHCP provisioner +runs (always, independent of `enable_stig`). + ## Things that are NOT deviations (kept / enforced) - **SELinux enforcing** (`selinux --enforcing`; labels preserved into the erofs). diff --git a/files/agent/snapshot.go b/files/agent/snapshot.go index e960701..4dadc43 100644 --- a/files/agent/snapshot.go +++ b/files/agent/snapshot.go @@ -305,8 +305,15 @@ type chrony struct { // chronyTracking runs `chronyc tracking` once and parses it. Best-effort: a zero // chrony{} (state "") if chronyc is unavailable — never fails the snapshot. +// +// -h 127.0.0.1 forces the LOOPBACK UDP command socket instead of chronyd's unix +// socket. Under the agent's hardened unit (DynamicUser — not in the chrony group; +// ProtectSystem=strict — /run read-only) the unix socket at /run/chrony is +// unreachable (can't open it, nor create chronyc's reply socket there). UDP needs +// no fs write or group and rides the unit's already-allowed AF_INET. The image +// binds this socket via `bindcmdaddress 127.0.0.1` in chrony.conf. func chronyTracking() chrony { - out, err := runShort("chronyc", "-n", "tracking") + out, err := runShort("chronyc", "-n", "-h", "127.0.0.1", "tracking") if err != nil { return chrony{} } diff --git a/files/stig/fleet-agent-chronyc.te b/files/stig/fleet-agent-chronyc.te index 3a426cd..6f31ff4 100644 --- a/files/stig/fleet-agent-chronyc.te +++ b/files/stig/fleet-agent-chronyc.te @@ -23,10 +23,16 @@ module fleet_agent_chronyc 1.0.0; # This single allow lets the transition proceed to the MORE-confined chronyc_t # domain (the safe direction NNP's nnp_transition permission exists to gate), with # no loosening of the agent's own sandbox. Minimal + auditable; justified in -# docs/stig-deviations.md. BENCH-VERIFY: after this, the manager's Monitor chrony -# row populates (offset/source/stratum). If a FOLLOW-ON chronyc_t AVC appears on -# the bench (e.g. reaching chronyd's socket under the agent's mount namespace), -# extend here. +# docs/stig-deviations.md #16. +# +# NB this is necessary but NOT sufficient: the agent reaches chronyd over the +# LOOPBACK UDP command socket (`chronyc -h 127.0.0.1 tracking`; the image binds it +# via `bindcmdaddress 127.0.0.1`, see stig-deviations #17) because the unix socket +# is unusable under the agent's sandbox. Under enforcing, once chronyc runs in +# chronyc_t it must also be allowed to reach chronyd_t on UDP 323 — the base chrony +# policy is expected to cover chronyc_t's command traffic, but BENCH-VERIFY on the +# first enforcing boot and, if a FOLLOW-ON chronyc_t AVC appears (e.g. a corenet +# udp_socket/name_connect denial to chronyd), extend here. ######################################## require {