aboutsummaryrefslogtreecommitdiff
path: root/scripts/post-rebuild-check
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/post-rebuild-check')
-rwxr-xr-xscripts/post-rebuild-check667
1 files changed, 667 insertions, 0 deletions
diff --git a/scripts/post-rebuild-check b/scripts/post-rebuild-check
new file mode 100755
index 0000000..ee77a19
--- /dev/null
+++ b/scripts/post-rebuild-check
@@ -0,0 +1,667 @@
+#!/bin/sh
+# SPDX-License-Identifier: GPL-3.0-or-later
+# post-rebuild-check - the eight checks a rebuilt machine actually needs.
+#
+# A rebuilt machine looks finished and isn't. Five gaps surfaced on velox
+# within two days of the 2026-08-13 reinstall, and three of them LOOKED
+# fine: a stowed unit file, an enabled timer, a present git clone. Each
+# check below is cheap and turns a silent no-op into a visible line:
+#
+# 1. failed systemd units, user and system scope (calendar-sync failed
+# every 15 minutes for two days with nobody watching)
+# 2. user unit files present but not enabled (roam-sync and
+# signal-receive came back linked and inert -- a unit file being
+# present is not the same as running)
+# 3. tracked *.example files whose real sibling is missing (three
+# *.local.el were gone on velox; the .example survives in git,
+# the real file never does)
+# 4. gitignore-mode projects missing tooling paths their own .gitignore
+# names (a reinstall drops every such project's untracked working
+# state -- 374 files in .emacs.d's case -- and nothing carries it)
+# 5. signal-cli holds no registered account (velox lost its registration
+# in the rebuild; at the time agent-text relayed into velox, so that
+# silently broke paging for the WHOLE fleet. agent-text now walks
+# AGENT_TEXT_RELAYS in order and skips itself, so an unregistered
+# machine is only fatal when no relay host is registered either)
+# 6. every NTP source is named by hostname (a wrong clock fails the
+# DoT/DNSSEC validation this machine's DNS runs on, so nothing
+# resolves -- including the NTP pool that would fix the clock; velox
+# deadlocked exactly this way 2026-08-19 and needed a second device)
+# 7. hypridle installed but not running (nothing then triggers idle lock
+# or suspend, so a laptop runs until its battery is gone -- which is
+# how velox reset the RTC that caused check 6's deadlock in the first
+# place; a caffeine remembered from an earlier boot is the known cause)
+# 8. a working repo cloned from the read-only https endpoint (correct
+# for a stranger with no key on the server, wrong for this machine,
+# which finds out at the first push with a 403 -- velox's dotfiles
+# remote sat that way for four days after its rebuild)
+#
+# The .gitignore rule in check 4 is what scopes it: a tooling path is only
+# expected where the project's own .gitignore names it, so a project that
+# never had a todo.org never flags. The ignore file is the project's own
+# record of what it is supposed to hold untracked.
+#
+# EVERY PROBE FAILS CLOSED. A check that cannot run reports a finding, never
+# a pass. This matters more here than anywhere else in the script: the whole
+# point is catching silent no-ops, so a silent no-op in the checker would be
+# the worst possible defect. `systemctl --user` exits 1 with empty output
+# when there is no user bus -- over ssh, from cron, under sudo, on a TTY
+# before the graphical session starts -- and reading that as "no failed
+# units" would report a machine as healthy exactly when nothing was checked.
+#
+# Exit 0 when every check is clean, 1 when any check found something,
+# 2 on usage error.
+#
+# Test seams (env; for each, set-but-empty means "the probe ran and found
+# nothing", unset means "run the real probe"):
+# PRC_FAILED_UNITS newline list of "scope:unit" (scope user|system)
+# PRC_UNIT_STATES newline list of "unit-file state" replacing the
+# user-unit-dir enumeration + is-enabled calls
+# PRC_LOCAL_SCAN_ROOTS NEWLINE-separated roots for the *.example scan
+# (default: ~/.emacs.d ~/.dotfiles)
+# PRC_PROJECT_ROOTS NEWLINE-separated project dirs for check 4
+# (default: ~/code/* ~/projects/* ~/.emacs.d
+# ~/.dotfiles)
+# PRC_SIGNAL_ACCOUNTS signal-cli listAccounts output; "" = no account,
+# the special value MISSING = binary absent
+# PRC_NTP_SOURCES newline list of configured NTP server addresses;
+# the special value MISSING = no NTP daemon active
+# PRC_CHRONY_CONF path to chrony.conf (a fixture, under test) -- the
+# confdir it names is what decides which drop-ins count
+# PRC_IDLE_DAEMON pgrep output for hypridle; "" = installed but not
+# running, the special value MISSING = not installed
+# PRC_REPO_REMOTES newline list of "path origin-url"; an empty URL
+# means origin could not be read
+# PRC_UNITS_EXPECTED_DISABLED
+# newline list of units whose not-enabled state is
+# deliberate here, replacing the file below
+# PRC_UNITS_EXPECTED_DISABLED_FILE
+# path to that list (default:
+# $XDG_CONFIG_HOME/post-rebuild-check/units-expected-disabled).
+# One unit per line, # starts a comment. Machine-local
+# on purpose: the same unit is correctly enabled on one
+# box and not another
+# PRC_SYSTEMCTL path to the systemctl binary (a fake, under test)
+# PRC_SYSTEMCTL_TIMEOUT seconds to allow each systemctl call (default 5)
+#
+# Roots are newline-separated, not space-separated, because a POSIX
+# `for root in $var` splits on spaces and turns one real directory into
+# several imaginary missing ones.
+
+usage() {
+ cat <<'EOF'
+post-rebuild-check - verify a rebuilt machine is actually finished
+
+Runs the eight checks that caught velox's 2026-08 reinstall gaps: failed
+units, present-but-inert user units, orphaned *.example configs, missing
+per-project tooling state, the signal-cli registration, whether time sync
+can recover from a wrong clock without DNS, whether anything still
+triggers idle lock and suspend, and whether the working repos can push.
+
+Usage: post-rebuild-check [--help]
+
+Exit 0 when every check is clean, 1 when any check found something.
+Every probe fails closed: a check that cannot run is a finding, not a pass.
+EOF
+}
+
+case "${1:-}" in
+ --help|-h) usage; exit 0 ;;
+ "") ;;
+ *) echo "post-rebuild-check: unknown argument: $1" >&2; usage >&2; exit 2 ;;
+esac
+
+# Own the internal flags rather than inheriting them, so a caller's unrelated
+# variable of the same name cannot manufacture or mask a finding.
+TOTAL_FINDINGS=0
+CHECK_FINDINGS=0
+FINDING_LINES=""
+signal_missing=""
+ntp_missing=""
+idle_absent=""
+
+# Every systemctl call is bounded. A wedged user manager spins and answers
+# nothing -- seen live on velox 2026-08-17, where `is-enabled`, `cat`, and
+# `list-unit-files` all hung while `list-units` still returned. Unbounded, this
+# script would hang on the first unit and never reach the remaining checks,
+# which is a worse failure than reporting nothing: a check that hangs is its
+# own outage, and the machine most in need of checking is the one it hangs on.
+# A timeout yields empty output and a non-zero status, and both are already
+# handled as findings, so bounding the call is all that is needed to fail closed.
+CHRONY_CONF=${PRC_CHRONY_CONF:-/etc/chrony.conf}
+SCTL_TIMEOUT=${PRC_SYSTEMCTL_TIMEOUT:-5}
+SYSTEMCTL=${PRC_SYSTEMCTL:-systemctl}
+
+sctl() {
+ if command -v timeout >/dev/null 2>&1; then
+ timeout "$SCTL_TIMEOUT" "$SYSTEMCTL" "$@"
+ else
+ # Say so rather than dropping the bound silently: without timeout a
+ # wedged manager hangs this run indefinitely, and the whole point of
+ # the bound is that a check which hangs reports nothing at all.
+ [ -n "${sctl_unbounded_warned:-}" ] || {
+ echo "post-rebuild-check: timeout(1) not found — systemctl calls are UNBOUNDED and may hang" >&2
+ sctl_unbounded_warned=1
+ }
+ "$SYSTEMCTL" "$@"
+ fi
+}
+
+WORK=${TMPDIR:-/tmp}/.post-rebuild-check.$$
+if ! mkdir "$WORK" 2>/dev/null; then
+ # Every check stages its input through a file in here. Without it each
+ # loop would read nothing and every check would come back clean, which is
+ # the one failure this script must never produce.
+ echo "post-rebuild-check: cannot create a work directory under ${TMPDIR:-/tmp}" >&2
+ echo " nothing was checked; this is not a pass" >&2
+ exit 1
+fi
+trap 'rm -rf "$WORK"' EXIT HUP INT TERM
+
+STAGE="$WORK/stage"
+
+finding() {
+ CHECK_FINDINGS=$((CHECK_FINDINGS + 1))
+ TOTAL_FINDINGS=$((TOTAL_FINDINGS + 1))
+ FINDING_LINES="${FINDING_LINES} DEVIATION: $1
+"
+}
+
+# Print the check's one visible line, then its findings. The visible line
+# is the point: a silent no-op is exactly what let the gaps sit unseen.
+report() {
+ if [ "$CHECK_FINDINGS" -eq 0 ]; then
+ echo "$1 — ok"
+ else
+ echo "$1 — $CHECK_FINDINGS finding(s)"
+ printf '%s' "$FINDING_LINES"
+ fi
+ CHECK_FINDINGS=0
+ FINDING_LINES=""
+}
+
+# Stage a value into $STAGE for the read loops. A failed write is fatal for
+# the same reason a missing work directory is.
+stage() {
+ if ! printf '%s\n' "$1" > "$STAGE" 2>/dev/null; then
+ echo "post-rebuild-check: cannot write $STAGE" >&2
+ echo " nothing was checked; this is not a pass" >&2
+ exit 1
+ fi
+}
+
+# --- 1. failed units ------------------------------------------------------
+
+if [ -n "${PRC_FAILED_UNITS+set}" ]; then
+ failed=$PRC_FAILED_UNITS
+else
+ failed=""
+ if user_out=$(sctl --user list-units --state=failed --no-legend --plain 2>/dev/null); then
+ failed=$(printf '%s' "$user_out" | awk 'NF {print "user:"$1}')
+ else
+ finding "could not query user units (no user bus?) — nothing was checked in this scope"
+ fi
+ if sys_out=$(sctl list-units --state=failed --no-legend --plain 2>/dev/null); then
+ failed="$failed
+$(printf '%s' "$sys_out" | awk 'NF {print "system:"$1}')"
+ else
+ finding "could not query system units — nothing was checked in this scope"
+ fi
+fi
+stage "$failed"
+while IFS= read -r line; do
+ [ -n "$line" ] || continue
+ scope=${line%%:*}
+ unit=${line#*:}
+ finding "$scope unit failed: $unit"
+done < "$STAGE"
+report "check 1/8: failed units"
+
+# --- 2. user unit files present but not enabled ---------------------------
+#
+# Units nothing intends to enable here are read from a machine-local list.
+# "Enabled" is this check's proxy for "will actually run", and the proxy is
+# wrong for a unit nobody means to enable on this box. velox carries four, for
+# four different reasons: geoclue-agent is redundant because hyprland's
+# exec-once starts the binary directly, emacs is started on demand by
+# emacsclient, obs-record-watchdog only matters while recording, and
+# obsbot-wb-guard needs an OBSBOT the machine does not have. Left unexempted
+# they report at every run, and four permanent lines in front of every real one
+# teach you to skim the output -- the same argument check 4 makes about
+# CLAUDE.md.
+#
+# Machine-local rather than a marker in the shared unit file, because
+# obsbot-wb-guard is correctly ENABLED on ratio. One unit, a different right
+# answer per machine, so the shared file cannot hold the answer.
+#
+# An entry that turns out to be enabled after all is still a finding. Without
+# that the list rots into somewhere real findings go to die, which is worse
+# than the noise it removes.
+
+EXPECT_DISABLED_FILE="${PRC_UNITS_EXPECTED_DISABLED_FILE:-${XDG_CONFIG_HOME:-$HOME/.config}/post-rebuild-check/units-expected-disabled}"
+if [ -n "${PRC_UNITS_EXPECTED_DISABLED+set}" ]; then
+ expect_disabled=$PRC_UNITS_EXPECTED_DISABLED
+elif [ -f "$EXPECT_DISABLED_FILE" ]; then
+ expect_disabled=$(cat "$EXPECT_DISABLED_FILE" 2>/dev/null)
+else
+ expect_disabled=""
+fi
+# Strip comments and blanks once, here, so the membership test below is a
+# plain word match. The reason a unit is exempt is the most useful thing about
+# the entry, so the format has to carry one.
+expect_disabled=$(printf '%s\n' "$expect_disabled" \
+ | sed 's/#.*//' | awk 'NF {print $1}')
+
+if [ -n "${PRC_UNIT_STATES+set}" ]; then
+ states=$PRC_UNIT_STATES
+else
+ states=""
+ unit_dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
+ if [ ! -d "$unit_dir" ]; then
+ finding "no user unit directory at $unit_dir — nothing was checked"
+ else
+ for f in "$unit_dir"/*.timer "$unit_dir"/*.service; do
+ # -L as well as -e: a stow symlink whose target moved in the
+ # rebuild is exactly the "looked fine" case this check is for,
+ # and -e is false for a broken link.
+ [ -e "$f" ] || [ -L "$f" ] || continue
+ name=$(basename "$f")
+ # A link with nothing behind it is its own finding, decided on the
+ # filesystem rather than from systemd. `is-enabled` calls a
+ # dangling link "not-found" -- the same answer it gives for a unit
+ # that was never installed -- so routing this through the state
+ # table below would drop it silently.
+ if [ -L "$f" ] && [ ! -e "$f" ]; then
+ finding "stowed unit file points at a missing target: $name"
+ continue
+ fi
+ # is-enabled exits non-zero AND prints a state for disabled and
+ # linked, so the exit code cannot distinguish "this unit is
+ # disabled" from "the query failed". The output can: a real answer
+ # is always a word. Empty means no answer, which is a finding
+ # rather than a silent skip -- with no user bus (ssh, cron, sudo,
+ # a TTY before the graphical session) every unit answers empty,
+ # and treating that as unknown-so-ignore would pass the machine
+ # while reading nothing at all.
+ #
+ # No separate bus probe: `is-system-running` and
+ # `show-environment` both block here, and a check that can hang is
+ # its own outage.
+ state=$(sctl --user is-enabled "$name" 2>/dev/null)
+ if [ -z "$state" ]; then
+ finding "could not read the enablement state of $name — it was not checked"
+ continue
+ fi
+ states="${states}${name} ${state}
+"
+ done
+ fi
+fi
+stage "$states"
+# A second copy for the sibling-timer lookup below, so the awk that reads it
+# is never the same open file as the loop reading it.
+cp "$STAGE" "$WORK/states" 2>/dev/null || {
+ echo "post-rebuild-check: cannot write $WORK/states" >&2
+ echo " nothing was checked; this is not a pass" >&2; exit 1; }
+while read -r name state; do
+ [ -n "$name" ] || continue
+ case "$state" in
+ disabled|linked) ;;
+ *) continue ;;
+ esac
+ # A timer-activated service is SUPPOSED to sit linked-and-not-enabled:
+ # the timer owns activation, and enabling the service as well would run
+ # it at boot on top of its schedule. So a service is suppressed only when
+ # its sibling timer can actually start it (enabled), or when the timer is
+ # itself inert and therefore the finding already -- reporting both would
+ # name one gap twice. A masked, static, or not-found timer starts
+ # nothing, so the service beneath it is as dead as one with no timer.
+ case "$name" in
+ *.service)
+ timer="${name%.service}.timer"
+ tstate=$(awk -v t="$timer" '$1 == t {print $2; exit}' "$WORK/states")
+ # enabled-runtime (enabled until reboot) and generated (something
+ # produced and installed it) are live activation paths, so the
+ # service under one is being started and is not a finding.
+ # disabled and linked suppress for a different reason: the timer
+ # is then the finding itself, reported in its own right.
+ #
+ # "indirect" deliberately does NOT suppress. It means the unit
+ # file itself is not enabled, only that some Also= relative might
+ # be, so nothing here is known to start the service. The
+ # fail-closed rule says the uncertain case flags.
+ case "$tstate" in
+ enabled|enabled-runtime|generated) continue ;;
+ disabled|linked) continue ;;
+ esac
+ ;;
+ esac
+ # Deliberately not enabled on this machine. Checked last, so it suppresses
+ # only this finding and never the dangling-link one decided above on the
+ # filesystem.
+ case "
+$expect_disabled
+" in
+ *"
+$name
+"*) continue ;;
+ esac
+ finding "unit file present but not enabled: $name ($state)"
+done < "$STAGE"
+# The exemption list, checked in the other direction. An entry whose unit is
+# enabled after all suppresses nothing, and leaving it there is how the list
+# turns into a place real findings go to die. The loop above cannot catch this:
+# it skips any state that is not disabled or linked, so an enabled unit never
+# reaches it.
+printf '%s\n' "$expect_disabled" > "$WORK/expect" 2>/dev/null || {
+ echo "post-rebuild-check: cannot write $WORK/expect" >&2
+ echo " nothing was checked; this is not a pass" >&2; exit 1; }
+while IFS= read -r name; do
+ [ -n "$name" ] || continue
+ estate=$(awk -v u="$name" '$1 == u {print $2; exit}' "$WORK/states")
+ case "$estate" in
+ enabled|enabled-runtime)
+ finding "$name is listed as expected-disabled but is $estate — drop the stale exemption" ;;
+ esac
+done < "$WORK/expect"
+report "check 2/8: unit files"
+
+# --- 3. *.example files whose real sibling is missing ---------------------
+
+if [ -n "${PRC_LOCAL_SCAN_ROOTS+set}" ]; then
+ scan_roots=$PRC_LOCAL_SCAN_ROOTS
+else
+ scan_roots="$HOME/.emacs.d
+$HOME/.dotfiles"
+fi
+printf '%s\n' "$scan_roots" > "$WORK/roots" 2>/dev/null || {
+ echo "post-rebuild-check: cannot write $WORK/roots" >&2; exit 1; }
+while IFS= read -r root; do
+ [ -n "$root" ] || continue
+ if [ ! -d "$root" ]; then
+ finding "scan root missing: $root"
+ continue
+ fi
+ # Vendored package trees ship their own .example docs; those belong to
+ # the package, not to this machine, so they are noise in front of the
+ # real findings this check exists for.
+ #
+ # -prune, not -not -path: the latter filters find's OUTPUT while still
+ # descending, so an unreadable directory inside a tree we deliberately
+ # ignore would set find's exit status and be reported as an unscanned
+ # part of the root. Pruning means those trees are never entered, so the
+ # exit status only reflects places this check actually wanted to read.
+ #
+ # That status matters: find exits non-zero when it cannot descend
+ # somewhere, having printed only what it could reach. Discarding it would
+ # hide every orphan under an unreadable directory behind a clean "ok",
+ # which is the defect this script exists to catch.
+ if ! find "$root" \
+ \( -name .git -o -name elpa -o -name straight \
+ -o -name node_modules -o -name .venv \) -prune \
+ -o -name '*.example' -print > "$WORK/examples" 2>/dev/null; then
+ finding "could not fully scan $root — part of it was not checked"
+ fi
+ while IFS= read -r ex; do
+ [ -n "$ex" ] || continue
+ # -e, so a sibling that exists only as a dangling symlink counts as
+ # missing. It is not a config the machine can read.
+ [ -e "${ex%.example}" ] || finding "example without its real file: $ex"
+ done < "$WORK/examples"
+done < "$WORK/roots"
+report "check 3/8: local files"
+
+# --- 4. gitignore-mode projects missing their tooling ---------------------
+
+if [ -n "${PRC_PROJECT_ROOTS+set}" ]; then
+ projects=$PRC_PROJECT_ROOTS
+else
+ projects=$(ls -d "$HOME"/code/*/ "$HOME"/projects/*/ 2>/dev/null; \
+ printf '%s\n%s\n' "$HOME/.emacs.d" "$HOME/.dotfiles")
+fi
+printf '%s\n' "$projects" > "$WORK/projects" 2>/dev/null || {
+ echo "post-rebuild-check: cannot write $WORK/projects" >&2; exit 1; }
+# CLAUDE.md is deliberately absent from this set. It is seed-only --
+# install-lang writes it once and the project owns it afterward -- so most
+# projects legitimately never have one, and ratio shows the identical
+# absences in the identical projects. That match is what proves it is the
+# steady state rather than reinstall drift, and flagging it would put nine
+# standing findings in front of every real one.
+#
+# The list is fed to the inner loop straight from a heredoc rather than
+# staged through a file. It is a constant, so a file bought nothing and cost
+# a fifth unguarded write: had it failed (a full tmpfs, say) the inner loop
+# would read nothing and every project would pass silently, which is the one
+# outcome this script must never produce. The heredoc is the inner loop's own
+# stdin and leaves the outer loop's redirect alone.
+while IFS= read -r proj; do
+ [ -n "$proj" ] || continue
+ proj=${proj%/}
+ # -e not -d: in a worktree or submodule .git is a file naming the real
+ # gitdir, and a -d test would skip those projects silently.
+ [ -e "$proj/.git" ] || continue
+ [ -f "$proj/.gitignore" ] || continue
+ while read -r disk pattern; do
+ # Both the anchored (/.ai/) and unanchored (.ai/) ignore styles exist
+ # across the fleet; the sweep-gitignore audit hit exactly that split.
+ #
+ # grep exits 1 for no-match and 2 for an error, so the two are told
+ # apart rather than both read as "the ignore file does not name this".
+ # An unreadable .gitignore would otherwise pass the whole project.
+ grep -Eq "^/?${pattern}/?\$" "$proj/.gitignore" 2>/dev/null
+ case $? in
+ 0) [ -e "$proj/$disk" ] \
+ || finding "$proj: .gitignore names $disk but it is missing on disk" ;;
+ 1) ;;
+ *) finding "$proj: could not read .gitignore — the project was not checked"
+ break ;;
+ esac
+ done <<'EOF'
+.ai \.ai
+.claude \.claude
+todo.org todo\.org
+inbox inbox
+EOF
+done < "$WORK/projects"
+report "check 4/8: project tooling"
+
+# --- 5. signal-cli registration -------------------------------------------
+
+if [ -n "${PRC_SIGNAL_ACCOUNTS+set}" ]; then
+ accounts=$PRC_SIGNAL_ACCOUNTS
+ if [ "$accounts" = "MISSING" ]; then
+ accounts=""
+ signal_missing=1
+ fi
+else
+ if command -v signal-cli >/dev/null 2>&1; then
+ if ! accounts=$(signal-cli listAccounts 2>/dev/null); then
+ accounts=""
+ finding "signal-cli listAccounts failed — the registration was not checked"
+ signal_missing=skip
+ fi
+ else
+ accounts=""
+ signal_missing=1
+ fi
+fi
+if [ "$signal_missing" = 1 ]; then
+ finding "signal-cli is not installed — paging relies on it fleet-wide"
+elif [ -z "$signal_missing" ] && [ -z "$accounts" ]; then
+ finding "no signal account registered — this machine can only page by relaying to one that has an account; if no host in AGENT_TEXT_RELAYS is registered either, the whole fleet loses paging"
+fi
+report "check 5/8: signal registration"
+
+# --- 6. NTP can recover a wrong clock without DNS -------------------------
+#
+# The clock/DNS bootstrap deadlock. This machine resolves through DNSOverTLS
+# with DNSSEC, and both validate against the wall clock, so a boot with a
+# wrong clock resolves nothing at all. If every configured NTP source is named
+# by hostname, the daemon that would correct the clock needs the DNS the clock
+# is breaking, and the machine cannot recover without a second device --
+# which is exactly what happened on velox 2026-08-19. One source addressed by
+# IP breaks the cycle, so that is what this check looks for.
+
+# True when the argument is an address rather than a name. An address needs no
+# resolver, which is the whole property being checked.
+is_ip_literal() {
+ case "$1" in
+ "") return 1 ;;
+ *:*) case "$1" in *[!0-9A-Fa-f:]*) return 1 ;; esac
+ return 0 ;;
+ *[!0-9.]*) return 1 ;;
+ *.*) return 0 ;;
+ esac
+ return 1
+}
+
+if [ -n "${PRC_NTP_SOURCES+set}" ]; then
+ ntp_sources=$PRC_NTP_SOURCES
+ if [ "$ntp_sources" = "MISSING" ]; then
+ ntp_sources=""
+ ntp_missing=1
+ fi
+elif sctl is-active chronyd >/dev/null 2>&1; then
+ # The main file, plus any drop-in directory chrony.conf actually names.
+ #
+ # The confdir read is the load-bearing part. A drop-in is inert unless
+ # chrony.conf points at its directory, and Arch's stock chrony.conf points
+ # at none -- so globbing /etc/chrony.d unconditionally would find the
+ # IP-addressed source, report the machine healthy, and be describing a file
+ # chrony never opens. That is a false pass on exactly the misconfiguration
+ # this check exists to catch, so the sources are read only from files
+ # chrony is actually told to read.
+ ntp_conf_files=$CHRONY_CONF
+ for ntp_dir in $(awk '$1 == "confdir" || $1 == "sourcedir" { print $2 }' \
+ "$CHRONY_CONF" 2>/dev/null); do
+ for ntp_f in "$ntp_dir"/*.conf "$ntp_dir"/*.sources; do
+ [ -f "$ntp_f" ] && ntp_conf_files="$ntp_conf_files $ntp_f"
+ done
+ done
+ # Unquoted on purpose: the accumulated list is several paths, and none of
+ # this script's own paths contain spaces.
+ ntp_sources=$(cat $ntp_conf_files 2>/dev/null \
+ | awk '$1 == "server" || $1 == "pool" { print $2 }')
+elif sctl is-active systemd-timesyncd >/dev/null 2>&1; then
+ ntp_sources=$(awk -F= '/^[[:space:]]*NTP=/ { print $2 }' \
+ /etc/systemd/timesyncd.conf 2>/dev/null | tr ' ' '\n')
+else
+ ntp_sources=""
+ ntp_missing=1
+fi
+
+if [ "$ntp_missing" = 1 ]; then
+ finding "no NTP implementation is active — nothing corrects the clock, and a wrong clock takes DNS down with it"
+elif [ -z "$ntp_sources" ]; then
+ finding "no NTP sources are configured — nothing was checked, and nothing corrects the clock"
+else
+ ntp_has_literal=""
+ stage "$ntp_sources"
+ while IFS= read -r src_addr; do
+ [ -z "$src_addr" ] && continue
+ if is_ip_literal "$src_addr"; then
+ ntp_has_literal=1
+ fi
+ done < "$STAGE"
+ if [ -z "$ntp_has_literal" ]; then
+ finding "every NTP source is named by hostname — a wrong clock breaks DNS, so nothing can resolve them and the clock stays wrong"
+ fi
+fi
+report "check 6/8: NTP bootstrap"
+
+# --- 7. the idle daemon survives session start ----------------------------
+#
+# A laptop that never sleeps has no symptom until the battery is gone, so
+# nothing surfaces this without being asked. On velox 2026-08-19 hypridle
+# started cleanly at 15:29:48 and `settings restore` killed it six seconds
+# later, replaying a caffeine stored in an earlier boot. The machine ran
+# 11h40m fully awake on battery, died when it flattened, and reset its RTC --
+# which took DNS down with it, the same deadlock check 6 exists for. The
+# desktop looked correct throughout.
+#
+# Behavioural on purpose: this asks whether the daemon is alive, not why it
+# might not be, so a stale caffeine, a crash, and a broken config all surface
+# the same way. Gated on hypridle being installed, because that is what marks
+# a machine as using it -- archsetup installs it only for Hyprland, so a
+# headless or dwm box would otherwise report a finding on every run.
+
+if [ -n "${PRC_IDLE_DAEMON+set}" ]; then
+ idle_pids=$PRC_IDLE_DAEMON
+ if [ "$idle_pids" = "MISSING" ]; then
+ idle_pids=""
+ idle_absent=1
+ fi
+elif command -v hypridle >/dev/null 2>&1; then
+ # pgrep exits non-zero with no match, which is the not-running case rather
+ # than a probe failure, so the || keeps `set -e`-style callers out of it.
+ idle_pids=$(pgrep -x hypridle 2>/dev/null) || idle_pids=""
+else
+ idle_pids=""
+ idle_absent=1
+fi
+
+if [ "$idle_absent" = 1 ]; then
+ : # hypridle is not part of this machine -- nothing to check
+elif [ -z "$idle_pids" ]; then
+ finding "hypridle is installed but not running — nothing triggers idle lock or suspend, so this machine stays awake until its battery is gone; a caffeine remembered from an earlier boot is the known cause"
+fi
+report "check 7/8: idle daemon"
+
+# --- 8. working repos cloned from the read-only endpoint ------------------
+#
+# archsetup clones the user's own archsetup and dotfiles from
+# https://git.cjennings.net/..., which serves anonymous clones and refuses
+# pushes. That default is correct for a stranger installing archsetup -- they
+# have no key on the server -- and wrong for this machine, which has to push.
+# ARCHSETUP_REPO / DOTFILES_REPO override it, but only where they are
+# configured: a curl|bash install, or a rebuild from a stock ISO, takes the
+# default straight back.
+#
+# Nothing about the tree shows it. The clone is complete and ordinary, and the
+# machine finds out at the first push, with a 403 -- which is how velox's
+# dotfiles remote was found on 2026-08-17, four days after its rebuild, by
+# which time the same rebuild's shallow clone had already answered a
+# credential-history question wrongly.
+#
+# Only the read-only endpoint is flagged. An https remote elsewhere may be
+# perfectly pushable through a credential helper, and guessing about hosts
+# this machine does not own would stand noise in front of the real findings.
+
+if [ -n "${PRC_REPO_REMOTES+set}" ]; then
+ repo_remotes=$PRC_REPO_REMOTES
+else
+ repo_remotes=""
+ for repo in "$HOME/code/archsetup" "$HOME/.dotfiles"; do
+ # -e not -d: a worktree or submodule .git is a file naming the gitdir.
+ [ -e "$repo/.git" ] || continue
+ # A repo with no origin still gets a line, with an empty URL, so the
+ # loop below reports it rather than skipping it into a silent pass.
+ repo_url=$(git -C "$repo" remote get-url origin 2>/dev/null)
+ repo_remotes="${repo_remotes}${repo} ${repo_url}
+"
+ done
+fi
+
+stage "$repo_remotes"
+while IFS= read -r repo_line; do
+ [ -n "$repo_line" ] || continue
+ repo_path=${repo_line%% *}
+ repo_url=${repo_line#"$repo_path"}
+ repo_url=${repo_url# }
+ case "$repo_url" in
+ "")
+ finding "$repo_path: origin could not be read — the remote was not checked" ;;
+ https://git.cjennings.net/*|https://cjennings.net/*)
+ finding "$repo_path: origin is the read-only endpoint ($repo_url) — git push returns 403; set the ssh form, or ARCHSETUP_REPO/DOTFILES_REPO before installing" ;;
+ esac
+done < "$STAGE"
+report "check 8/8: repo remotes"
+
+# --- summary --------------------------------------------------------------
+
+if [ "$TOTAL_FINDINGS" -eq 0 ]; then
+ echo "all checks clean"
+ exit 0
+fi
+echo "$TOTAL_FINDINGS finding(s) across 8 checks"
+exit 1