aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-08-13 22:34:42 -0500
committerCraig Jennings <c@cjennings.net>2026-08-13 22:34:42 -0500
commita42e06d6ef4fc6296a17c69580aa9911667a6c88 (patch)
tree74475a705d891f8f0347e11b877812ffe0f85377
parentab44b307f67159915fafa88b09b81a37b59b8e23 (diff)
downloadarchsetup-a42e06d6ef4fc6296a17c69580aa9911667a6c88.tar.gz
archsetup-a42e06d6ef4fc6296a17c69580aa9911667a6c88.zip
feat(installer): grant brightness control to the video group
Arch's brightnessctl ships no udev rules and leans on logind, which grants brightness writes only to the active seat session. Anything outside that session (a script, an ssh shell, a panel launched into a different one) meets root-owned sysfs and gets EPERM. On velox both panel sliders were dead after a fresh install. configure_backlight_access writes a udev rule making the backlight and keyboard-LED brightness attributes group-writable by video, which create_user already adds the user to. I granted the keyboard LED to video rather than input: anyone in input can read every input device, which is too much authority for dimming a keyboard. essential_services is already marked complete on machines installed before this step, so a re-run skips them. The post-install checklist carries the manual route.
-rwxr-xr-xarchsetup42
-rw-r--r--docs/post-install-checklist.org29
-rw-r--r--tests/installer-steps/test_configure_backlight_access.py139
-rw-r--r--tests/installer-steps/test_orchestrators.py1
4 files changed, 211 insertions, 0 deletions
diff --git a/archsetup b/archsetup
index 1d263fa..2555234 100755
--- a/archsetup
+++ b/archsetup
@@ -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",