diff options
| -rwxr-xr-x | archsetup | 42 | ||||
| -rw-r--r-- | docs/post-install-checklist.org | 29 | ||||
| -rw-r--r-- | tests/installer-steps/test_configure_backlight_access.py | 139 | ||||
| -rw-r--r-- | tests/installer-steps/test_orchestrators.py | 1 |
4 files changed, 211 insertions, 0 deletions
@@ -1689,6 +1689,7 @@ essential_services() { configure_randomness configure_networking configure_power + configure_backlight_access configure_ssh_server configure_fail2ban configure_firewall @@ -1809,6 +1810,47 @@ EOF run_task "linking resolv.conf to systemd-resolved" ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf } +configure_backlight_access() { + # Screen backlight and keyboard-LED brightness, writable by the video + # group. Arch ships brightnessctl with no udev rules: it relies on + # logind, which grants brightness writes only to the *active seat + # session*, so a script, a remote shell, or a panel launched into a + # different session gets EPERM against root-owned sysfs. Found on velox + # 2026-08-13: after a fresh install both brightness sliders in the + # desktop settings panel were inert. Group-writable sysfs is + # session-independent, and inert on a machine with no such devices. + # + # leds are granted to video rather than input on purpose: input-group + # membership also confers read access to every input device, which is + # too much authority to hand out for dimming a keyboard. + # + # $1 is the rules directory, defaulting to the system's so tests can + # run against a temp dir. + local ruledir="${1:-/etc/udev/rules.d}" + local rule="$ruledir/90-backlight.rules" + + action="granting the video group brightness control" && display "task" "$action" + + mkdir -p "$ruledir" 2>> "$logfile" || { error_warn "$action" "$?"; return 1; } + cat > "$rule" << 'UDEVEOF' 2>> "$logfile" || { error_warn "$action" "$?"; return 1; } +# Brightness control without root: make the backlight and keyboard-LED +# brightness attributes group-writable by video. brightnessctl otherwise +# depends on logind, which only grants the active seat session. +ACTION=="add", SUBSYSTEM=="backlight", RUN+="/usr/bin/chgrp video /sys/class/backlight/%k/brightness" +ACTION=="add", SUBSYSTEM=="backlight", RUN+="/usr/bin/chmod g+w /sys/class/backlight/%k/brightness" +ACTION=="add", SUBSYSTEM=="leds", KERNEL=="*kbd_backlight", RUN+="/usr/bin/chgrp video /sys/class/leds/%k/brightness" +ACTION=="add", SUBSYSTEM=="leds", KERNEL=="*kbd_backlight", RUN+="/usr/bin/chmod g+w /sys/class/leds/%k/brightness" +UDEVEOF + chmod 644 "$rule" 2>> "$logfile" || error_warn "$action" "$?" + + # Apply now so brightness works in this boot, not just after a reboot. + udevadm control --reload >> "$logfile" 2>&1 || error_warn "reloading udev rules" "$?" + udevadm trigger --subsystem-match=backlight --action=add >> "$logfile" 2>&1 || \ + error_warn "triggering backlight udev rules" "$?" + udevadm trigger --subsystem-match=leds --action=add >> "$logfile" 2>&1 || \ + error_warn "triggering leds udev rules" "$?" +} + configure_power() { # Power diff --git a/docs/post-install-checklist.org b/docs/post-install-checklist.org index fa704b6..97fc0d5 100644 --- a/docs/post-install-checklist.org +++ b/docs/post-install-checklist.org @@ -37,6 +37,35 @@ Trusted devices reconnect on their own after reboot; if one doesn't, check =rfkill list= first (radios should be unblocked — TLP owns radio state and enables bluetooth/wifi at startup per =/etc/tlp.d/01-custom.conf=). +** Brightness control on a machine installed before 2026-08-13 + +Only for a machine whose install predates =configure_backlight_access=. New +installs get the rule automatically, and a re-run of =archsetup= skips the +step because =essential_services= is already marked complete, so an older +machine needs one of these by hand: + +#+begin_src bash +# either drop the rule in directly... +sudo tee /etc/udev/rules.d/90-backlight.rules > /dev/null << 'EOF' +ACTION=="add", SUBSYSTEM=="backlight", RUN+="/usr/bin/chgrp video /sys/class/backlight/%k/brightness" +ACTION=="add", SUBSYSTEM=="backlight", RUN+="/usr/bin/chmod g+w /sys/class/backlight/%k/brightness" +ACTION=="add", SUBSYSTEM=="leds", KERNEL=="*kbd_backlight", RUN+="/usr/bin/chgrp video /sys/class/leds/%k/brightness" +ACTION=="add", SUBSYSTEM=="leds", KERNEL=="*kbd_backlight", RUN+="/usr/bin/chmod g+w /sys/class/leds/%k/brightness" +EOF +sudo udevadm control --reload +sudo udevadm trigger --subsystem-match=backlight --action=add +sudo udevadm trigger --subsystem-match=leds --action=add + +# ...or clear the marker and re-run just that step +sudo rm /var/lib/archsetup/state/essential_services +#+end_src + +Verify: =ls -l /sys/class/backlight/*/brightness= shows group =video= with +=g+w=, and =brightnessctl -c backlight set 70%= succeeds without sudo. + +Only laptops have these devices; on a desktop the rule is inert and nothing +needs doing. + ** Proton Mail Bridge (cmail) The installer's completion message carries the steps; recorded here too so diff --git a/tests/installer-steps/test_configure_backlight_access.py b/tests/installer-steps/test_configure_backlight_access.py new file mode 100644 index 0000000..6fd171a --- /dev/null +++ b/tests/installer-steps/test_configure_backlight_access.py @@ -0,0 +1,139 @@ +"""Test configure_backlight_access — udev rule for brightness writes. + +Arch's brightnessctl ships no udev rules: it relies on logind, which grants +brightness writes only to the *active seat session*. Anything outside that +session — a script, a remote shell, a panel launched into a different +session — gets EPERM against root-owned sysfs. Found on velox 2026-08-13: +on a fresh install the desktop settings panel's screen and keyboard +brightness sliders were both inert. + +The step drops a rule making the brightness attributes group-writable by +video (the group create_user already adds the user to). leds are granted to +video as well rather than input, so nobody needs input-group membership — +and the keylogging surface that carries — just to dim a keyboard. + +Method: sed-extract configure_backlight_access from the real `archsetup`, +point it at a temp rules dir, and assert on the file it writes. + + python3 -m unittest tests.installer-steps.test_configure_backlight_access +""" + +import os +import re +import stat +import subprocess +import tempfile +import textwrap +import unittest + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +ARCHSETUP = os.path.join(REPO_ROOT, "archsetup") + + +def run(ruledir, marker=None): + # The step sends udevadm's own output to $logfile, so the stub records + # its argv to a marker file instead of stdout — writing straight to the + # file is unaffected by the caller's redirect. + marker = marker or os.devnull + script = textwrap.dedent(f"""\ + logfile=/dev/null + action="" + display() {{ :; }} + error_warn() {{ echo "WARN: $1"; return 1; }} + udevadm() {{ echo "UDEVADM: $*" >> "{marker}"; }} + source <(sed -n '/^configure_backlight_access() {{/,/^}}/p' "{ARCHSETUP}") + configure_backlight_access "{ruledir}" + echo "RC=$?" + exit 0 + """) + return subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, timeout=10, + ) + + +def rc_of(r): + m = re.search(r"^RC=(\d+)$", r.stdout, re.M) + assert m, "no RC line in output: %r / %r" % (r.stdout, r.stderr) + return int(m.group(1)) + + +class ConfigureBacklightAccess(unittest.TestCase): + # ------------------------------------------------------------ normal ---- + def test_writes_a_rule_covering_backlight_and_keyboard_leds(self): + with tempfile.TemporaryDirectory() as d: + r = run(d) + self.assertEqual(rc_of(r), 0) + rule = os.path.join(d, "90-backlight.rules") + self.assertTrue(os.path.exists(rule)) + body = open(rule).read() + self.assertIn('SUBSYSTEM=="backlight"', body) + self.assertIn('SUBSYSTEM=="leds"', body) + self.assertIn('KERNEL=="*kbd_backlight"', body) + + def test_grants_the_video_group_write_access(self): + with tempfile.TemporaryDirectory() as d: + run(d) + body = open(os.path.join(d, "90-backlight.rules")).read() + self.assertIn("chgrp video", body) + self.assertIn("chmod g+w", body) + + def test_does_not_use_the_input_group(self): + # Granting leds to input would require input-group membership, + # which also confers read access to every input device. + with tempfile.TemporaryDirectory() as d: + run(d) + body = open(os.path.join(d, "90-backlight.rules")).read() + self.assertNotIn("chgrp input", body) + + def test_rule_is_world_readable_not_writable(self): + with tempfile.TemporaryDirectory() as d: + run(d) + mode = stat.S_IMODE(os.stat(os.path.join(d, "90-backlight.rules")).st_mode) + self.assertEqual(mode, 0o644) + + def test_reloads_udev_so_the_rule_applies_without_a_reboot(self): + with tempfile.TemporaryDirectory() as d: + marker = os.path.join(d, "udevadm.calls") + run(d, marker=marker) + calls = open(marker).read() + self.assertIn("UDEVADM: control --reload", calls) + self.assertRegex(calls, r"UDEVADM: trigger .*backlight") + self.assertRegex(calls, r"UDEVADM: trigger .*leds") + + # ---------------------------------------------------------- boundary ---- + def test_running_twice_leaves_one_correct_rule(self): + # Compare whole bodies rather than counting lines: a count encodes + # today's line total, so it breaks on a correct edit and passes an + # append regression that happens to hit the same number. + with tempfile.TemporaryDirectory() as d: + run(d) + first = open(os.path.join(d, "90-backlight.rules")).read() + r = run(d) + self.assertEqual(rc_of(r), 0) + second = open(os.path.join(d, "90-backlight.rules")).read() + self.assertEqual(first, second) + + def test_absent_rules_directory_is_created(self): + with tempfile.TemporaryDirectory() as d: + nested = os.path.join(d, "etc", "udev", "rules.d") + r = run(nested) + self.assertEqual(rc_of(r), 0) + self.assertTrue(os.path.exists(os.path.join(nested, "90-backlight.rules"))) + + # ------------------------------------------------------------- error ---- + @unittest.skipUnless(os.geteuid() != 0, "root ignores directory write bits") + def test_unwritable_directory_warns_and_does_not_crash(self): + with tempfile.TemporaryDirectory() as d: + ruledir = os.path.join(d, "ro") + os.mkdir(ruledir) + os.chmod(ruledir, 0o500) + try: + r = run(ruledir) + self.assertIn("WARN:", r.stdout) + self.assertNotEqual(rc_of(r), 0) + finally: + os.chmod(ruledir, 0o700) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/installer-steps/test_orchestrators.py b/tests/installer-steps/test_orchestrators.py index 395ec61..34c46d1 100644 --- a/tests/installer-steps/test_orchestrators.py +++ b/tests/installer-steps/test_orchestrators.py @@ -28,6 +28,7 @@ ARCHSETUP = os.path.join(REPO_ROOT, "archsetup") ORCHESTRATORS = { "essential_services": [ "configure_randomness", "configure_networking", "configure_power", + "configure_backlight_access", "configure_ssh_server", "configure_fail2ban", "configure_firewall", "configure_service_discovery", "configure_job_scheduling", "configure_package_cache", "configure_snapshots", |
