diff options
| author | Craig Jennings <c@cjennings.net> | 2026-08-20 11:52:14 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-08-20 11:52:14 -0500 |
| commit | 1c504d4d6a7a944de2ca000660d4c4461b5f1779 (patch) | |
| tree | 922fcdba6d8aef1c42d6d69bccac3de4fc077a4b /scripts/post-rebuild-check | |
| parent | fb13378559cdc6a9ef3b23883bcbda2937dc1195 (diff) | |
| parent | c588b0841df440e1a5290c7150ee245f90d1788e (diff) | |
| download | archsetup-1c504d4d6a7a944de2ca000660d4c4461b5f1779.tar.gz archsetup-1c504d4d6a7a944de2ca000660d4c4461b5f1779.zip | |
Merge origin/main: reconcile two parallel sessions' velox work
Both sides worked velox independently while this branch was open, so the
overlaps needed settling by hand rather than by whichever side landed last.
The settings-persistence bug had been recovered twice under different headings.
Kept the upstream wording and dropped my duplicate — one bug, one task.
The six tasks I archived had graduated from the Resolved section into
archive/task-archive.org; upstream still listed them under Resolved. Removed
them there so each lives in exactly one place.
The ribbon task is the real disagreement. I verified on 08-15 that the reseat
happened and the touchpad came back — the interrupt on amd_gpio pin 8 went from
zero counts to 1795 and the i2c-HID reset timeout disappeared. The 08-17 entry
concluded the opposite from the absence of a /dev/input/by-path/ node, which an
i2c-HID touchpad often lacks even when working. Rather than close it over that
entry, I left the task open and recorded both readings with the check that
settles it, because velox was refusing ssh and I could not re-verify.
Carried across my checks 6 and 7 on the post-rebuild task — the per-install
Proton Bridge cert and password, neither of which can be restored from backup —
and the restore-versus-re-derive split they belong to.
Diffstat (limited to 'scripts/post-rebuild-check')
| -rwxr-xr-x | scripts/post-rebuild-check | 667 |
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 |
