Skip to main content

max / alloy

Ask a running machine the questions that used to cost a sitting
Author: Max Johnson <me@maxj.phd> · 2026-09-03 17:22 UTC
Signed with PGP, not checked
Commit: 8ae66bd2018a8b8ff9913a9ca3301df7dd10f167
Parent: aeead28
1 file changed, +298 insertions, -0 deletions
@@ -1,0 +1,298 @@
1 + #!/usr/bin/env bash
2 + #
3 + # check-host.sh — ask a running Alloy machine the questions that used to cost a
4 + # sitting, and answer them as pass or fail rows.
5 + #
6 + # Why this exists. On 2026-08-31 eighteen GoingsOn tasks were closed carrying
7 + # `dropped:2026-08-31`, and every one of them was a verification that needed a
8 + # person in front of a machine: press the Print binds, press the brightness
9 + # keys, confirm three LUKS keyslots, confirm a .local name resolves. None was
10 + # blocked on code. They piled up because the only machine running Alloy could
11 + # not be reached from the machine doing the work.
12 + #
13 + # So the rule this script serves, from wiki `alloy-fleet-cutover`: no task whose
14 + # output is an observation a person has to transcribe. Anything mechanical is a
15 + # row here, and what is left for a person is only what genuinely needs eyes.
16 + #
17 + # Usage:
18 + # build/check-host.sh # this machine
19 + # ssh fw12 'bash -s' < build/check-host.sh
20 + # sudo build/check-host.sh # adds the rows that need root
21 + # build/check-host.sh --self-test # checks the harness; touches nothing
22 + #
23 + # Exit codes, following build/check-installed.sh:
24 + #
25 + # 0 every row that could run passed.
26 + # 1 at least one row failed. The row says what and, where there is one, the
27 + # repair.
28 + # 3 the question cannot be asked here: this is not an Alloy machine.
29 + #
30 + # SKIP is not a pass and not a failure. A row skips when the machine cannot be
31 + # asked -- no graphical session over ssh, no root for a LUKS dump, no NVIDIA
32 + # card -- and the count is printed at the end so a run that answered half the
33 + # questions cannot read as a clean bill.
34 + #
35 + # What deliberately is NOT here: anything whose verdict is a judgement. Glyph
36 + # crispness, the dark-mode L stops and whether 1.75x looks right are eyeball
37 + # work, and a script that pretended to answer them would be worse than silence.
38 +
39 + set -uo pipefail
40 +
41 + PASSED=0
42 + FAILED=0
43 + SKIPPED=0
44 +
45 + row() { # row STATUS NAME DETAIL...
46 + local status="$1" name="$2"; shift 2
47 + case "$status" in
48 + PASS) PASSED=$((PASSED + 1)) ;;
49 + FAIL) FAILED=$((FAILED + 1)) ;;
50 + SKIP) SKIPPED=$((SKIPPED + 1)) ;;
51 + esac
52 + printf '%-4s %-16s %s\n' "$status" "$name" "$*"
53 + }
54 +
55 + have() { command -v "$1" >/dev/null 2>&1; }
56 + is_root() { [ "$(id -u)" -eq 0 ]; }
57 + # A graphical session is not reachable over a plain ssh connection, which is
58 + # how this script is usually run. Rows that need one skip rather than fail.
59 + has_session() { [ -n "${WAYLAND_DISPLAY:-}" ] && have swaymsg; }
60 +
61 + self_test() {
62 + local out fails=0
63 + out="$(PASSED=0; FAILED=0; SKIPPED=0; row FAIL x y >/dev/null; echo "$FAILED")"
64 + [ "$out" = "1" ] || { echo "self-test: a FAIL row did not count as a failure" >&2; fails=1; }
65 + out="$(PASSED=0; FAILED=0; SKIPPED=0; row SKIP x y >/dev/null; echo "$FAILED")"
66 + [ "$out" = "0" ] || { echo "self-test: a SKIP row counted as a failure" >&2; fails=1; }
67 + [ "$fails" -eq 0 ] && echo "self-test: ok"
68 + exit "$fails"
69 + }
70 +
71 + [ "${1:-}" = "--self-test" ] && self_test
72 +
73 + # ---------------------------------------------------------------- identity ---
74 + # Exit 3 rather than fail: a machine that is not Alloy is outside what any row
75 + # below can answer, and saying so is the honest result.
76 + . /etc/os-release 2>/dev/null || true
77 + [ "${ID:-}" = "alloy" ] || { printf 'error: not an Alloy machine (ID=%s)\n' "${ID:-unknown}" >&2; exit 3; }
78 + row PASS os "alloy ${VERSION_ID:-?} build ${ALLOY_BUILD_STAMP:-unstamped}"
79 +
80 + # ------------------------------------------------------------------- image ---
81 + # `bootc status` is the row; parsing its JSON for the image name is a
82 + # convenience, so an output shape this does not recognise degrades to a plainer
83 + # detail rather than to a verdict.
84 + if have bootc; then
85 + if bootc status >/dev/null 2>&1; then
86 + booted="$(bootc status --format=json 2>/dev/null \
87 + | tr -d ' \n' | grep -o '"image":"[^"]*"' | head -1 | cut -d'"' -f4)"
88 + row PASS image "${booted:-bootc answers; image name not parsed from this output shape}"
89 + else
90 + row FAIL image "bootc is present and \`bootc status\` fails"
91 + fi
92 + else
93 + row FAIL image "no bootc on an image-based system"
94 + fi
95 +
96 + # ------------------------------------------------- labels, and what it costs --
97 + # Delegated: build/check-installed.sh owns this question, including the
98 + # semanage-lock filter and its own self-test. It needs root to read the labels.
99 + if is_root && [ -x "$(dirname "$0")/check-installed.sh" ]; then
100 + if out="$("$(dirname "$0")/check-installed.sh" 2>&1)"; then
101 + row PASS labels "/etc and /usr match the policy, and a DynamicUser unit starts"
102 + else
103 + row FAIL labels "$(printf '%s' "$out" | head -3 | tr '\n' ' ') -- repair: restorecon -R /etc"
104 + fi
105 + else
106 + row SKIP labels "needs root and build/check-installed.sh beside this script"
107 + fi
108 +
109 + # ------------------------------------------------------------- screenshots ---
110 + # The four binds, from the config the compositor is actually running where
111 + # there is one, and from the file otherwise. Pressing them is a person's job;
112 + # that the bind exists and its command resolves is not.
113 + if have alloy-shot; then
114 + if has_session; then
115 + binds="$(swaymsg -t get_config 2>/dev/null | grep -c 'exec alloy-shot')"
116 + else
117 + binds="$(grep -c 'exec alloy-shot' "${HOME}/.config/sway/config" 2>/dev/null || echo 0)"
118 + fi
119 + if [ "${binds:-0}" -ge 4 ]; then
120 + row PASS shot-binds "$binds alloy-shot binds, and the binary resolves"
121 + else
122 + row FAIL shot-binds "expected 4 alloy-shot binds (output, region, window, annotate), found ${binds:-0}"
123 + fi
124 + else
125 + row FAIL shot-binds "alloy-shot is not on PATH, so all four Print binds are dead"
126 + fi
127 +
128 + # -------------------------------------------------------------- brightness ---
129 + # Two separate failures wear the same face. The bind can be missing, or the
130 + # session can lack permission to write the backlight, and only the second one
131 + # survives a reinstall unnoticed.
132 + if have swayosd-client; then
133 + row PASS brightness-bind "swayosd-client resolves for the XF86MonBrightness binds"
134 + else
135 + row FAIL brightness-bind "swayosd-client is not on PATH, so the brightness keys do nothing"
136 + fi
137 + bl="$(find /sys/class/backlight -mindepth 1 -maxdepth 1 2>/dev/null | head -1)"
138 + if [ -z "$bl" ]; then
139 + row SKIP backlight "no backlight device on this machine"
140 + elif [ -w "$bl/brightness" ]; then
141 + row PASS backlight "$(basename "$bl") is writable by this user"
142 + else
143 + row FAIL backlight "$(basename "$bl")/brightness is not writable, so the keys cannot take effect"
144 + fi
145 +
146 + # ---------------------------------------------------------------- firewall ---
147 + # The combination is the risk, not the firewall: tailscaled writes its own
148 + # rules, and firewalld starting without tailscale0 in the trusted zone drops
149 + # every inbound tailnet connection, which on a headless box is the login.
150 + if have firewall-cmd; then
151 + if firewall-cmd --state >/dev/null 2>&1; then
152 + if firewall-cmd --zone=trusted --query-interface=tailscale0 >/dev/null 2>&1; then
153 + row PASS firewall "running, tailscale0 in the trusted zone"
154 + else
155 + row FAIL firewall "running, and tailscale0 is NOT trusted; inbound tailnet traffic is being dropped"
156 + fi
157 + else
158 + row FAIL firewall "firewalld is installed and not running"
159 + fi
160 + else
161 + row SKIP firewall "no firewall-cmd in this image"
162 + fi
163 +
164 + # ------------------------------------------------------------ export wrapper --
165 + # `alloy pkg` writes per-box wrappers under ~/.local/bin. A wrapper that is not
166 + # on PATH is the failure that looks like nothing at all.
167 + if [ -d "$HOME/.local/bin" ]; then
168 + case ":$PATH:" in
169 + *":$HOME/.local/bin:"*) row PASS export-path "~/.local/bin exists and is on PATH" ;;
170 + *) row FAIL export-path "~/.local/bin exists and is NOT on PATH, so every exported wrapper is invisible" ;;
171 + esac
172 + else
173 + row SKIP export-path "nothing has been exported on this machine yet"
174 + fi
175 +
176 + # ------------------------------------------------------------------- fonts ---
177 + # Two halves: fontconfig resolves the face, and the face covers the glyphs the
178 + # TUI draws with. A fallback that answers fc-match still draws broken tables.
179 + if have fc-match; then
180 + fam="$(fc-match -f '%{family}' 'Quasi Mono' 2>/dev/null)"
181 + case "$fam" in
182 + *Quasi*) row PASS font-match "Quasi Mono resolves to $fam" ;;
183 + *) row FAIL font-match "Quasi Mono falls back to ${fam:-nothing}; the font layer did not take" ;;
184 + esac
185 + # U+2500, the box-drawing horizontal every table border is made of.
186 + if fc-list ':charset=2500' family 2>/dev/null | grep -qi quasi; then
187 + row PASS font-borders "the Quasi face covers U+2500, so table borders draw"
188 + else
189 + row FAIL font-borders "no Quasi face covers U+2500; borders will render from a fallback"
190 + fi
191 + else
192 + row FAIL font-match "no fc-match, so nothing can resolve a font"
193 + fi
194 +
195 + # -------------------------------------------------------------------- luks ---
196 + # Three keyslots is the shape the installer writes: the passphrase, the
197 + # recovery key and the TPM binding. One means two of those are missing.
198 + if ! is_root; then
199 + row SKIP luks "needs root to dump the header"
200 + elif ! have cryptsetup; then
201 + row SKIP luks "no cryptsetup"
202 + else
203 + dev="$(lsblk -rno NAME,FSTYPE | awk '$2=="crypto_LUKS"{print "/dev/"$1; exit}')"
204 + if [ -z "$dev" ]; then
205 + row SKIP luks "no LUKS device; this machine was installed unencrypted"
206 + else
207 + dump="$(cryptsetup luksDump "$dev" 2>/dev/null)"
208 + slots="$(printf '%s' "$dump" | grep -cE '^[[:space:]]+[0-9]+: luks2')"
209 + if [ -z "$dump" ]; then
210 + row SKIP luks "$dev did not dump; header unreadable"
211 + elif [ "${slots:-0}" -eq 0 ]; then
212 + # A dump with no recognised slot line is a format this does not read, not
213 + # a device with no keyslots -- which cannot exist and would unlock nothing.
214 + row SKIP luks "$dev dumped, no keyslot line recognised (LUKS1, or a changed format)"
215 + elif [ "$slots" -ge 3 ]; then
216 + row PASS luks "$dev has $slots keyslots"
217 + else
218 + row FAIL luks "$dev has $slots keyslots, expected 3 (passphrase, recovery, TPM)"
219 + fi
220 + fi
221 + fi
222 +
223 + # ------------------------------------------------------------------ udisks ---
224 + # Whether a session user can mount a stick without a polkit rule of ours. The
225 + # answer is a property of the shipped policy, so it is readable without root.
226 + if have pkaction; then
227 + # The label is polkit's own wording; an unrecognised one leaves impl empty
228 + # and the row skips, because guessing here would report a prompt that is not
229 + # there or miss one that is.
230 + impl="$(pkaction --action-id org.freedesktop.UDisks2.filesystem-mount --verbose 2>/dev/null \
231 + | awk -F: '/implicit active/{gsub(/[[:space:]]/,"",$2); print $2; exit}')"
232 + case "$impl" in
233 + yes) row PASS udisks "filesystem-mount is allowed for an active session" ;;
234 + "") row SKIP udisks "udisks2 policy not present" ;;
235 + *) row FAIL udisks "filesystem-mount implicit active is '$impl', so mounting a stick prompts" ;;
236 + esac
237 + else
238 + row SKIP udisks "no pkaction"
239 + fi
240 +
241 + # -------------------------------------------------------------------- mdns ---
242 + # Publishing a .local name and resolving one are different capabilities, and
243 + # only the second makes `ssh max.local` work from this machine.
244 + if have resolvectl; then
245 + if resolvectl query "$(hostname).local" >/dev/null 2>&1; then
246 + row PASS mdns "this machine resolves its own .local name"
247 + else
248 + row FAIL mdns "cannot resolve $(hostname).local; mDNS resolution is off even if publishing works"
249 + fi
250 + else
251 + row SKIP mdns "no resolvectl"
252 + fi
253 +
254 + # ------------------------------------------------------------------ portal ---
255 + # The portal is what tells a GTK or Electron client which way the theme went.
256 + if has_session && have gdbus; then
257 + if gdbus call --session --dest org.freedesktop.portal.Desktop \
258 + --object-path /org/freedesktop/portal/desktop \
259 + --method org.freedesktop.portal.Settings.ReadOne \
260 + org.freedesktop.appearance color-scheme >/dev/null 2>&1; then
261 + row PASS portal-theme "the portal answers color-scheme"
262 + else
263 + row FAIL portal-theme "the portal does not answer color-scheme, so clients will not follow the theme"
264 + fi
265 + else
266 + row SKIP portal-theme "needs a graphical session; not answerable over ssh"
267 + fi
268 +
269 + # ------------------------------------------------------------------ linger ---
270 + # The silent-loss class. On a machine whose evidence tier runs as user timers,
271 + # a missing linger produces a healthy-looking box where nothing ever runs.
272 + if have loginctl; then
273 + user="${SUDO_USER:-$(id -un)}"
274 + if loginctl show-user "$user" -p Linger 2>/dev/null | grep -q 'Linger=yes'; then
275 + row PASS linger "$user has Linger=yes, so user timers run without a session"
276 + else
277 + row SKIP linger "$user has no linger; only a finding on a machine with user timers"
278 + fi
279 + else
280 + row SKIP linger "no loginctl"
281 + fi
282 +
283 + # ------------------------------------------------------------------ nvidia ---
284 + if lspci 2>/dev/null | grep -qi 'nvidia'; then
285 + if have nvidia-smi && nvidia-smi -L >/dev/null 2>&1; then
286 + row PASS nvidia "$(nvidia-smi -L 2>/dev/null | head -1)"
287 + else
288 + row FAIL nvidia "an NVIDIA card is on the bus and the driver does not answer"
289 + fi
290 + else
291 + row SKIP nvidia "no NVIDIA card on this machine"
292 + fi
293 +
294 + # ------------------------------------------------------------------ verdict ---
295 + printf '\n%s passed, %s failed, %s skipped\n' "$PASSED" "$FAILED" "$SKIPPED"
296 + [ "$SKIPPED" -gt 0 ] && printf 'skipped rows are unanswered questions, not passes\n'
297 + [ "$FAILED" -eq 0 ] || exit 1
298 + exit 0