#!/bin/bash # NetworkManager dispatcher: point chrony at the fleet NTP server, learned from # DHCP at runtime (no FQDN baked into the image). Two modes, baked at build time: # # nts (secure_ntp=true, default): AUTHENTICATED time. Derive the server NAME # from the DHCP-provided domain as . and write # `server NAME iburst nts`. chrony verifies the NTS-KE TLS cert against the # fleet CA (chrony.conf: ntstrustedcerts). Because the domain comes from # DHCP, a domain change needs only a new DHCP domain + reissued NTS cert — # no image rebuild. A DHCP/NTP/DNS spoofer can only DoS (no fleet-CA-signed # cert for the name it redirects to => NTS fails => no sync); it cannot # shift the clock. See docs/secure-ntp-design.md (F-03/P-18). # # dhcp (secure_ntp=false): LEGACY, UNAUTHENTICATED. Write the DHCP option-42 # server IPs as plain `server iburst`. Lab/interop only. # # We use NetworkManager's internal DHCP (the network-manager dracut port); NM # exports the lease's domain as $DHCP4_DOMAIN_NAME (option 15) / $DHCP4_DOMAIN_SEARCH # (option 119) and NTP servers as $DHCP4_NTP_SERVERS (option 42). chrony.conf # carries `sourcedir /run/chrony-dhcp`, so writing .sources there and # reloading makes chronyd use them. The baked public pool is stripped (provisioner). # # Installed 0755 root:root. Lenient on purpose: a hiccup here must never break # networking. @NTP_MODE@/@NTP_PREFIX@ are substituted at build time. # # NB: the chrony package ships its OWN dispatcher at # /usr/lib/NetworkManager/dispatcher.d/20-chrony-dhcp writing option-42 servers to # the SAME .sources. NM merges /etc + /usr/lib sorted by name, so it runs # AFTER us and would clobber the NTS line — the provisioner removes it (it defeated # NTS on the bench: chrony fell back to plain NTP). This script supersedes it. It # was ALSO (accidentally) labelling the sources chronyd_var_run_t so chronyd could # read them; we restore that via /etc/tmpfiles.d/chrony-dhcp.conf (see the dir note # below) — without it, under enforcing SELinux chronyd can't read the sources and # loads nothing (bench 2026-07-12). MODE="@NTP_MODE@" # nts | dhcp PREFIX="@NTP_PREFIX@" # nts: server name = . interface="$1" action="$2" case "$action" in up|dhcp4-change|reapply) ;; *) exit 0 ;; esac # Loopback carries no DHCP NTP config; acting on it just writes a spurious # lo.sources duplicate of the same server (the distro 20-chrony-dhcp skipped lo — # we must too, or chrony sees two files for one server). Skip it. [ "$interface" = lo ] && exit 0 # /run/chrony-dhcp is pre-created with the chronyd_var_run_t SELinux label by # /etc/tmpfiles.d/chrony-dhcp.conf at boot, so the files we write below inherit a # label chronyd_t can READ (else, under enforcing, chronyd is denied and loads no # sources — bench 2026-07-12). This mkdir is just a fallback if tmpfiles didn't run. dir=/run/chrony-dhcp file="$dir/${interface}.sources" mkdir -p "$dir" if [ "$MODE" = nts ]; then # Prefer option 15 (domain-name); fall back to the first option-119 search # domain; finally fall back to resolv.conf's search/domain. NM does not export # $DHCP4_DOMAIN_NAME on every dispatcher event — at boot the source wasn't # written because the firing event carried no DHCP domain in env, yet # resolv.conf already had `search ` (bench 2026-07-11). Reading it here # makes the NTS source get written reliably regardless of which event fires. domain="${DHCP4_DOMAIN_NAME:-}" [ -z "$domain" ] && domain="${DHCP4_DOMAIN_SEARCH%% *}" [ -z "$domain" ] && domain=$(awk '/^(search|domain)[[:space:]]/{print $2; exit}' /etc/resolv.conf 2>/dev/null) if [ -n "$domain" ]; then printf 'server %s.%s iburst nts\n' "$PREFIX" "$domain" > "$file" # Pick up the new source without restarting chronyd. chronyc reload sources >/dev/null 2>&1 || true fi else if [ -n "${DHCP4_NTP_SERVERS:-}" ]; then : > "$file" for s in $DHCP4_NTP_SERVERS; do printf 'server %s iburst\n' "$s" >> "$file" done chronyc reload sources >/dev/null 2>&1 || true fi fi exit 0