max / alloy
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 |