| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
|
| 12 |
|
| 13 |
|
| 14 |
|
| 15 |
|
| 16 |
|
| 17 |
|
| 18 |
|
| 19 |
|
| 20 |
|
| 21 |
|
| 22 |
|
| 23 |
|
| 24 |
|
| 25 |
|
| 26 |
|
| 27 |
|
| 28 |
|
| 29 |
|
| 30 |
|
| 31 |
|
| 32 |
|
| 33 |
|
| 34 |
|
| 35 |
|
| 36 |
|
| 37 |
|
| 38 |
|
| 39 |
|
| 40 |
|
| 41 |
|
| 42 |
|
| 43 |
|
| 44 |
|
| 45 |
set -uo pipefail |
| 46 |
|
| 47 |
PASSED=0 |
| 48 |
FAILED=0 |
| 49 |
SKIPPED=0 |
| 50 |
|
| 51 |
row() { |
| 52 |
local status="$1" name="$2"; shift 2 |
| 53 |
case "$status" in |
| 54 |
PASS) PASSED=$((PASSED + 1)) ;; |
| 55 |
FAIL) FAILED=$((FAILED + 1)) ;; |
| 56 |
SKIP) SKIPPED=$((SKIPPED + 1)) ;; |
| 57 |
esac |
| 58 |
printf '%-4s %-16s %s\n' "$status" "$name" "$*" |
| 59 |
} |
| 60 |
|
| 61 |
have() { command -v "$1" >/dev/null 2>&1; } |
| 62 |
is_root() { [ "$(id -u)" -eq 0 ]; } |
| 63 |
|
| 64 |
|
| 65 |
|
| 66 |
|
| 67 |
|
| 68 |
|
| 69 |
target_user() { echo "${SUDO_USER:-$(id -un)}"; } |
| 70 |
|
| 71 |
|
| 72 |
|
| 73 |
|
| 74 |
|
| 75 |
|
| 76 |
target_home() { |
| 77 |
local home |
| 78 |
home="$(getent passwd "$(target_user)" 2>/dev/null | cut -d: -f6)" |
| 79 |
[ -n "$home" ] || home="${HOME:-}" |
| 80 |
echo "$home" |
| 81 |
} |
| 82 |
|
| 83 |
|
| 84 |
has_session() { [ -n "${WAYLAND_DISPLAY:-}" ] && have swaymsg; } |
| 85 |
|
| 86 |
self_test() { |
| 87 |
local out fails=0 |
| 88 |
out="$(PASSED=0; FAILED=0; SKIPPED=0; row FAIL x y >/dev/null; echo "$FAILED")" |
| 89 |
[ "$out" = "1" ] || { echo "self-test: a FAIL row did not count as a failure" >&2; fails=1; } |
| 90 |
out="$(PASSED=0; FAILED=0; SKIPPED=0; row SKIP x y >/dev/null; echo "$FAILED")" |
| 91 |
[ "$out" = "0" ] || { echo "self-test: a SKIP row counted as a failure" >&2; fails=1; } |
| 92 |
[ "$fails" -eq 0 ] && echo "self-test: ok" |
| 93 |
exit "$fails" |
| 94 |
} |
| 95 |
|
| 96 |
[ "${1:-}" = "--self-test" ] && self_test |
| 97 |
|
| 98 |
|
| 99 |
|
| 100 |
|
| 101 |
. /etc/os-release 2>/dev/null || true |
| 102 |
[ "${ID:-}" = "alloy" ] || { printf 'error: not an Alloy machine (ID=%s)\n' "${ID:-unknown}" >&2; exit 3; } |
| 103 |
row PASS os "alloy ${VERSION_ID:-?} build ${IMAGE_VERSION:-unstamped}" |
| 104 |
|
| 105 |
|
| 106 |
|
| 107 |
|
| 108 |
|
| 109 |
|
| 110 |
|
| 111 |
|
| 112 |
if ! have bootc; then |
| 113 |
row FAIL image "no bootc on an image-based system" |
| 114 |
elif ! is_root; then |
| 115 |
row SKIP image "needs root: bootc status refuses an unprivileged caller" |
| 116 |
else |
| 117 |
if bootc status >/dev/null 2>&1; then |
| 118 |
booted="$(bootc status --format=json 2>/dev/null \ |
| 119 |
| tr -d ' \n' | grep -o '"image":"[^"]*"' | head -1 | cut -d'"' -f4)" |
| 120 |
row PASS image "${booted:-bootc answers; image name not parsed from this output shape}" |
| 121 |
else |
| 122 |
row FAIL image "bootc is present and \`bootc status\` fails" |
| 123 |
fi |
| 124 |
fi |
| 125 |
|
| 126 |
|
| 127 |
|
| 128 |
|
| 129 |
if is_root && [ -x "$(dirname "$0")/check-installed.sh" ]; then |
| 130 |
if out="$("$(dirname "$0")/check-installed.sh" 2>&1)"; then |
| 131 |
row PASS labels "/etc and /usr match the policy, and a DynamicUser unit starts" |
| 132 |
else |
| 133 |
row FAIL labels "$(printf '%s' "$out" | head -3 | tr '\n' ' ') -- repair: restorecon -R /etc" |
| 134 |
fi |
| 135 |
else |
| 136 |
row SKIP labels "needs root and build/check-installed.sh beside this script" |
| 137 |
fi |
| 138 |
|
| 139 |
|
| 140 |
|
| 141 |
|
| 142 |
|
| 143 |
if have alloy-shot; then |
| 144 |
if has_session; then |
| 145 |
binds="$(swaymsg -t get_config 2>/dev/null | grep -c 'exec alloy-shot')" |
| 146 |
else |
| 147 |
binds="$(grep -c 'exec alloy-shot' "$(target_home)/.config/sway/config" 2>/dev/null || echo 0)" |
| 148 |
fi |
| 149 |
if [ "${binds:-0}" -ge 4 ]; then |
| 150 |
row PASS shot-binds "$binds alloy-shot binds, and the binary resolves" |
| 151 |
else |
| 152 |
row FAIL shot-binds "expected 4 alloy-shot binds (output, region, window, annotate), found ${binds:-0}" |
| 153 |
fi |
| 154 |
else |
| 155 |
row FAIL shot-binds "alloy-shot is not on PATH, so all four Print binds are dead" |
| 156 |
fi |
| 157 |
|
| 158 |
|
| 159 |
|
| 160 |
|
| 161 |
|
| 162 |
if have swayosd-client; then |
| 163 |
row PASS brightness-bind "swayosd-client resolves for the XF86MonBrightness binds" |
| 164 |
else |
| 165 |
row FAIL brightness-bind "swayosd-client is not on PATH, so the brightness keys do nothing" |
| 166 |
fi |
| 167 |
bl="$(find /sys/class/backlight -mindepth 1 -maxdepth 1 2>/dev/null | head -1)" |
| 168 |
if [ -z "$bl" ]; then |
| 169 |
row SKIP backlight "no backlight device on this machine" |
| 170 |
elif [ -w "$bl/brightness" ]; then |
| 171 |
row PASS backlight "$(basename "$bl") is writable by this user" |
| 172 |
else |
| 173 |
row FAIL backlight "$(basename "$bl")/brightness is not writable, so the keys cannot take effect" |
| 174 |
fi |
| 175 |
|
| 176 |
|
| 177 |
|
| 178 |
|
| 179 |
|
| 180 |
|
| 181 |
|
| 182 |
if nmcli -t -f DEVICE,TYPE device 2>/dev/null | grep -q ':wifi$'; then |
| 183 |
seen="$(nmcli -t -f DEVICE,TYPE device 2>/dev/null | grep ':wifi$' | cut -d: -f1 | tr '\n' ' ')" |
| 184 |
row PASS wifi "NetworkManager presents ${seen%% }" |
| 185 |
elif ! ls /usr/lib64/NetworkManager/*/libnm-device-plugin-wifi.so >/dev/null 2>&1; then |
| 186 |
row FAIL wifi "the image carries no NetworkManager wifi plugin, so no wireless device can ever appear" |
| 187 |
elif ls -d /sys/class/net/*/wireless >/dev/null 2>&1; then |
| 188 |
row FAIL wifi "the kernel has a wireless interface and NetworkManager does not present it; check wpa_supplicant and rfkill" |
| 189 |
else |
| 190 |
row SKIP wifi "no wireless interface on this machine" |
| 191 |
fi |
| 192 |
|
| 193 |
|
| 194 |
|
| 195 |
|
| 196 |
|
| 197 |
|
| 198 |
|
| 199 |
|
| 200 |
if have firewall-cmd; then |
| 201 |
if systemctl is-active firewalld >/dev/null 2>&1; then |
| 202 |
if ! is_root; then |
| 203 |
row SKIP firewall "running; the trusted-zone check needs root" |
| 204 |
elif firewall-cmd --zone=trusted --query-interface=tailscale0 >/dev/null 2>&1; then |
| 205 |
row PASS firewall "running, tailscale0 in the trusted zone" |
| 206 |
else |
| 207 |
row FAIL firewall "running, and tailscale0 is NOT trusted; inbound tailnet traffic is being dropped" |
| 208 |
fi |
| 209 |
elif systemctl is-enabled firewalld >/dev/null 2>&1; then |
| 210 |
row FAIL firewall "firewalld is enabled and not running" |
| 211 |
else |
| 212 |
row FAIL firewall "firewalld is installed and neither enabled nor running" |
| 213 |
fi |
| 214 |
else |
| 215 |
row SKIP firewall "no firewall-cmd in this image" |
| 216 |
fi |
| 217 |
|
| 218 |
|
| 219 |
|
| 220 |
|
| 221 |
|
| 222 |
|
| 223 |
|
| 224 |
|
| 225 |
export_dir="$(target_home)/.local/bin" |
| 226 |
if [ ! -d "$export_dir" ]; then |
| 227 |
row SKIP export-path "nothing has been exported on this machine yet" |
| 228 |
elif is_root && [ -n "${SUDO_USER:-}" ]; then |
| 229 |
row SKIP export-path "$export_dir exists; whether it is on $(target_user)'s PATH cannot be read from a root shell" |
| 230 |
else |
| 231 |
case ":$PATH:" in |
| 232 |
*":$export_dir:"*) row PASS export-path "$export_dir exists and is on PATH" ;; |
| 233 |
*) row FAIL export-path "$export_dir exists and is NOT on PATH, so every exported wrapper is invisible" ;; |
| 234 |
esac |
| 235 |
fi |
| 236 |
|
| 237 |
|
| 238 |
|
| 239 |
|
| 240 |
if have fc-match; then |
| 241 |
fam="$(fc-match -f '%{family}' 'Quasi Mono' 2>/dev/null)" |
| 242 |
case "$fam" in |
| 243 |
*Quasi*) row PASS font-match "Quasi Mono resolves to $fam" ;; |
| 244 |
*) row FAIL font-match "Quasi Mono falls back to ${fam:-nothing}; the font layer did not take" ;; |
| 245 |
esac |
| 246 |
|
| 247 |
if fc-list ':charset=2500' family 2>/dev/null | grep -qi quasi; then |
| 248 |
row PASS font-borders "the Quasi face covers U+2500, so table borders draw" |
| 249 |
else |
| 250 |
row FAIL font-borders "no Quasi face covers U+2500; borders will render from a fallback" |
| 251 |
fi |
| 252 |
else |
| 253 |
row FAIL font-match "no fc-match, so nothing can resolve a font" |
| 254 |
fi |
| 255 |
|
| 256 |
|
| 257 |
|
| 258 |
|
| 259 |
if ! is_root; then |
| 260 |
row SKIP luks "needs root to dump the header" |
| 261 |
elif ! have cryptsetup; then |
| 262 |
row SKIP luks "no cryptsetup" |
| 263 |
else |
| 264 |
dev="$(lsblk -rno NAME,FSTYPE | awk '$2=="crypto_LUKS"{print "/dev/"$1; exit}')" |
| 265 |
if [ -z "$dev" ]; then |
| 266 |
row SKIP luks "no LUKS device; this machine was installed unencrypted" |
| 267 |
else |
| 268 |
dump="$(cryptsetup luksDump "$dev" 2>/dev/null)" |
| 269 |
slots="$(printf '%s' "$dump" | grep -cE '^[[:space:]]+[0-9]+: luks2')" |
| 270 |
if [ -z "$dump" ]; then |
| 271 |
row SKIP luks "$dev did not dump; header unreadable" |
| 272 |
elif [ "${slots:-0}" -eq 0 ]; then |
| 273 |
|
| 274 |
|
| 275 |
row SKIP luks "$dev dumped, no keyslot line recognised (LUKS1, or a changed format)" |
| 276 |
elif [ "$slots" -ge 3 ]; then |
| 277 |
row PASS luks "$dev has $slots keyslots" |
| 278 |
else |
| 279 |
row FAIL luks "$dev has $slots keyslots, expected 3 (passphrase, recovery, TPM)" |
| 280 |
fi |
| 281 |
fi |
| 282 |
fi |
| 283 |
|
| 284 |
|
| 285 |
|
| 286 |
|
| 287 |
if have pkaction; then |
| 288 |
|
| 289 |
|
| 290 |
|
| 291 |
impl="$(pkaction --action-id org.freedesktop.udisks2.filesystem-mount --verbose 2>/dev/null \ |
| 292 |
| awk -F: '/implicit active/{gsub(/[[:space:]]/,"",$2); print $2; exit}')" |
| 293 |
case "$impl" in |
| 294 |
yes) row PASS udisks "filesystem-mount is allowed for an active session" ;; |
| 295 |
"") row SKIP udisks "udisks2 policy not present" ;; |
| 296 |
*) row FAIL udisks "filesystem-mount implicit active is '$impl', so mounting a stick prompts" ;; |
| 297 |
esac |
| 298 |
else |
| 299 |
row SKIP udisks "no pkaction" |
| 300 |
fi |
| 301 |
|
| 302 |
|
| 303 |
|
| 304 |
|
| 305 |
if have resolvectl; then |
| 306 |
if resolvectl query "$(hostname).local" >/dev/null 2>&1; then |
| 307 |
row PASS mdns "this machine resolves its own .local name" |
| 308 |
else |
| 309 |
row FAIL mdns "cannot resolve $(hostname).local; mDNS resolution is off even if publishing works" |
| 310 |
fi |
| 311 |
else |
| 312 |
row SKIP mdns "no resolvectl" |
| 313 |
fi |
| 314 |
|
| 315 |
|
| 316 |
|
| 317 |
if has_session && have gdbus; then |
| 318 |
if gdbus call --session --dest org.freedesktop.portal.Desktop \ |
| 319 |
--object-path /org/freedesktop/portal/desktop \ |
| 320 |
--method org.freedesktop.portal.Settings.ReadOne \ |
| 321 |
org.freedesktop.appearance color-scheme >/dev/null 2>&1; then |
| 322 |
row PASS portal-theme "the portal answers color-scheme" |
| 323 |
else |
| 324 |
row FAIL portal-theme "the portal does not answer color-scheme, so clients will not follow the theme" |
| 325 |
fi |
| 326 |
else |
| 327 |
row SKIP portal-theme "needs a graphical session; not answerable over ssh" |
| 328 |
fi |
| 329 |
|
| 330 |
|
| 331 |
|
| 332 |
|
| 333 |
if have loginctl; then |
| 334 |
user="${SUDO_USER:-$(id -un)}" |
| 335 |
if loginctl show-user "$user" -p Linger 2>/dev/null | grep -q 'Linger=yes'; then |
| 336 |
row PASS linger "$user has Linger=yes, so user timers run without a session" |
| 337 |
else |
| 338 |
row SKIP linger "$user has no linger; only a finding on a machine with user timers" |
| 339 |
fi |
| 340 |
else |
| 341 |
row SKIP linger "no loginctl" |
| 342 |
fi |
| 343 |
|
| 344 |
|
| 345 |
if lspci 2>/dev/null | grep -qi 'nvidia'; then |
| 346 |
if have nvidia-smi && nvidia-smi -L >/dev/null 2>&1; then |
| 347 |
row PASS nvidia "$(nvidia-smi -L 2>/dev/null | head -1)" |
| 348 |
else |
| 349 |
row FAIL nvidia "an NVIDIA card is on the bus and the driver does not answer" |
| 350 |
fi |
| 351 |
else |
| 352 |
row SKIP nvidia "no NVIDIA card on this machine" |
| 353 |
fi |
| 354 |
|
| 355 |
|
| 356 |
|
| 357 |
|
| 358 |
|
| 359 |
|
| 360 |
|
| 361 |
|
| 362 |
|
| 363 |
|
| 364 |
|
| 365 |
|
| 366 |
|
| 367 |
|
| 368 |
|
| 369 |
|
| 370 |
|
| 371 |
|
| 372 |
|
| 373 |
|
| 374 |
|
| 375 |
|
| 376 |
|
| 377 |
|
| 378 |
|
| 379 |
pin_file="$(target_home)/Code/alloy/rust-toolchain.toml" |
| 380 |
if [ ! -r "$pin_file" ]; then |
| 381 |
row SKIP toolchain "no ~/Code/alloy/rust-toolchain.toml; not a build host" |
| 382 |
elif ! have rustc; then |
| 383 |
row SKIP toolchain "no rustc for $(target_user); not a build host" |
| 384 |
else |
| 385 |
|
| 386 |
|
| 387 |
|
| 388 |
pinned="$(sed -n 's/^[[:space:]]*channel[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' "$pin_file" | head -1)" |
| 389 |
active="$(rustc --version 2>/dev/null | awk '{print $2}')" |
| 390 |
if [ -z "$pinned" ] || [ -z "$active" ]; then |
| 391 |
row SKIP toolchain "could not read the pin or the active version from this output shape" |
| 392 |
elif [ "$pinned" = "$active" ]; then |
| 393 |
|
| 394 |
|
| 395 |
missing="" |
| 396 |
cargo fmt --version >/dev/null 2>&1 || missing="rustfmt" |
| 397 |
cargo clippy --version >/dev/null 2>&1 || missing="${missing:+$missing }clippy" |
| 398 |
if [ -n "$missing" ]; then |
| 399 |
row FAIL toolchain "rustc $active matches the pin but $missing is absent -- run: rustup component add $missing" |
| 400 |
else |
| 401 |
row PASS toolchain "rustc $active matches the pinned channel, with rustfmt and clippy" |
| 402 |
fi |
| 403 |
elif have rustup; then |
| 404 |
row FAIL toolchain "rustc $active, tree pins $pinned, and rustup is installed -- run: rustup toolchain install $pinned" |
| 405 |
else |
| 406 |
row FAIL toolchain "rustc $active, tree pins $pinned, and there is no rustup to honour it -- the pin is being ignored silently" |
| 407 |
fi |
| 408 |
fi |
| 409 |
|
| 410 |
|
| 411 |
printf '\n%s passed, %s failed, %s skipped\n' "$PASSED" "$FAILED" "$SKIPPED" |
| 412 |
[ "$SKIPPED" -gt 0 ] && printf 'skipped rows are unanswered questions, not passes\n' |
| 413 |
[ "$FAILED" -eq 0 ] || exit 1 |
| 414 |
exit 0 |
| 415 |
|