diff options
| -rwxr-xr-x | archsetup | 43 | ||||
| -rw-r--r-- | docs/design/2026-08-07-podman-socket-and-camera-udev.md | 15 | ||||
| -rw-r--r-- | tests/installer-steps/test_idempotency_cluster.py | 27 | ||||
| -rw-r--r-- | tests/installer-steps/test_install_camera_passthrough_rules.py | 118 | ||||
| -rw-r--r-- | todo.org | 92 |
5 files changed, 259 insertions, 36 deletions
@@ -1266,14 +1266,17 @@ zfs_scrub_timer_units() { done } -# Enable a systemd --user service at install time by creating the wants symlink +# Enable a systemd --user unit at install time by creating the wants symlink # directly. `systemctl --user enable` fails during install (no user session # bus), so the link is wired by hand -- the pattern syncthing and gamemode both -# need. Args: <username> <service-unit> <service-file> [home-dir]. +# need. Args: <username> <unit> <unit-file> [home-dir] [wants-target]. +# The wants target defaults to default.target; a socket unit passes +# sockets.target to match its [Install] WantedBy. enable_user_service() { local username="$1" service="$2" service_file="$3" local home="${4:-/home/$username}" - local wants_dir="$home/.config/systemd/user/default.target.wants" + local target="${5:-default.target}" + local wants_dir="$home/.config/systemd/user/${target}.wants" mkdir -p "$wants_dir" ln -sf "$service_file" "$wants_dir/$service" chown -R "$username:$username" "$home/.config/systemd" @@ -3058,6 +3061,40 @@ EOF pacman_install podman pacman_install podman-compose pacman_install python-dotenv + + # Rootless podman API socket. Socket-activated (zero cost while idle), + # and every podman GUI/API client connects through it rather than the + # CLI — without it a client like Pods opens to an empty window with no + # useful error. From the winvm 2026-08-07 handoff. + action="enabling rootless podman API socket" && display "task" "$action" + enable_user_service "$username" podman.socket \ + /usr/lib/systemd/user/podman.socket "/home/$username" sockets.target \ + || error_warn "$action" "$?" + + install_camera_passthrough_rules +} + +# USB camera passthrough into the Windows VM: usbredirect must open the raw +# USB node read-write to claim it and detach the kernel drivers, and the node +# defaults to root-owned with no group write, so the attach fails with a bare +# "Failed to open device!". GROUP/MODE is the verified grant (winvm, +# 2026-08-07). The uaccess tag is kept, and the file NUMBER is load-bearing: +# logind's ACL is applied by 73-seat-late.rules, so the tag only works from a +# file that sorts below 73 — a 99- file adds it after that test already ran +# (winvm correction, 2026-08-08). Whether uaccess alone would then suffice is +# untested; the group grant stays as the belt. 3564:ff02 is the OBSBOT, +# 046d:085e the Logitech BRIO. $1 is the rules path, defaulting to the system +# path so tests can run against fixtures. +install_camera_passthrough_rules() { + local rules_file="${1:-/etc/udev/rules.d/72-usb-passthrough-cameras.rules}" + action="installing camera passthrough udev rules" && display "task" "$action" + cat << 'EOF' > "$rules_file" || error_warn "installing camera passthrough udev rules" "$?" +# USB camera passthrough for the Windows VM (usbredirect needs rw on the raw +# node). GROUP/MODE is the working grant; the uaccess tag only matters because +# this file sorts below 73-seat-late.rules, which applies the ACL. +SUBSYSTEM=="usb", ATTR{idVendor}=="3564", ATTR{idProduct}=="ff02", GROUP="video", MODE="0660", TAG+="uaccess" +SUBSYSTEM=="usb", ATTR{idVendor}=="046d", ATTR{idProduct}=="085e", GROUP="video", MODE="0660", TAG+="uaccess" +EOF } ### Supplemental Software diff --git a/docs/design/2026-08-07-podman-socket-and-camera-udev.md b/docs/design/2026-08-07-podman-socket-and-camera-udev.md index 7eb99e8..f9477a2 100644 --- a/docs/design/2026-08-07-podman-socket-and-camera-udev.md +++ b/docs/design/2026-08-07-podman-socket-and-camera-udev.md @@ -68,3 +68,18 @@ principle; it just isn't sufficient. I lost time on that, so it is recorded rather than left to be rediscovered. Both items are live on ratio and neither exists on velox. + +## Correction (winvm, 2026-08-08) + +The udev mechanism claim in section 2 above is wrong and stands corrected by +the sender: `TAG+="uaccess"` does apply to raw `SUBSYSTEM=="usb"` devices +(`70-uaccess.rules` tags them in several places). The real failure was rule +ordering — the ACL is applied by `73-seat-late.rules:16`, and a rule file +numbered `99-` adds the tag after that test has run. Every distro rule adding +the tag sorts at or below 70. + +The `GROUP="video", MODE="0660"` grant remains the verified working fix. +Untested hypothesis from the sender: a rule file numbered below 73 would +likely make uaccess work on its own (a tighter grant than the group). The +installer task ships the rule numbered below 73 with both mechanisms and +leaves the uaccess-alone test for when the camera is attached. diff --git a/tests/installer-steps/test_idempotency_cluster.py b/tests/installer-steps/test_idempotency_cluster.py index ecb279d..0cca750 100644 --- a/tests/installer-steps/test_idempotency_cluster.py +++ b/tests/installer-steps/test_idempotency_cluster.py @@ -114,6 +114,33 @@ class EnableUserService(unittest.TestCase): out = run("enable_user_service", one).stdout self.assertIn("RC=0", out) + def test_socket_unit_lands_in_sockets_target_wants(self): + # A socket unit's [Install] is WantedBy=sockets.target, so enabling it + # via default.target.wants would never socket-activate. The optional + # fifth arg names the wants target. + with tempfile.TemporaryDirectory() as home: + body = ( + f'enable_user_service {ME!r} podman.socket ' + f'/usr/lib/systemd/user/podman.socket {home!r} sockets.target\n' + f'link="{home}/.config/systemd/user/sockets.target.wants/podman.socket"\n' + f'[ -L "$link" ] && echo "LINK=yes" || echo "LINK=no"\n' + f'echo "TARGET=$(readlink "$link")"' + ) + out = run("enable_user_service", body).stdout + self.assertIn("LINK=yes", out) + self.assertIn("TARGET=/usr/lib/systemd/user/podman.socket", out) + + def test_omitted_target_still_defaults_to_default_target(self): + with tempfile.TemporaryDirectory() as home: + body = ( + f'enable_user_service {ME!r} gamemoded.service ' + f'/usr/lib/systemd/user/gamemoded.service {home!r}\n' + f'[ -L "{home}/.config/systemd/user/default.target.wants/gamemoded.service" ] ' + f'&& echo "DEFAULT=yes" || echo "DEFAULT=no"' + ) + out = run("enable_user_service", body).stdout + self.assertIn("DEFAULT=yes", out) + if __name__ == "__main__": unittest.main() diff --git a/tests/installer-steps/test_install_camera_passthrough_rules.py b/tests/installer-steps/test_install_camera_passthrough_rules.py new file mode 100644 index 0000000..ab28575 --- /dev/null +++ b/tests/installer-steps/test_install_camera_passthrough_rules.py @@ -0,0 +1,118 @@ +"""Test install_camera_passthrough_rules — the VM camera udev grant. + +usbredirect must open a camera's raw USB node read-write to claim it for the +Windows VM; the node defaults to root-owned with no group write, so the +attach fails with a bare "Failed to open device!". The rule grants +GROUP="video", MODE="0660" (the verified fix) and keeps TAG+="uaccess". + +The file NUMBER is load-bearing (winvm correction, 2026-08-08): the uaccess +ACL is applied by 73-seat-late.rules, so a 99- file adds the tag after that +already ran. The shipped filename must sort below 73. + +Method: sed-extract install_camera_passthrough_rules from the real +`archsetup`, point it at a temp rules path, fake display / error_warn. + +Run from repo root: + python3 -m unittest tests.installer-steps.test_install_camera_passthrough_rules +""" + +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(rules_path, pre=""): + script = textwrap.dedent(f"""\ + logfile=/dev/null + action="" + display() {{ :; }} + error_warn() {{ echo "WARN: $1"; return 1; }} + source <(sed -n '/^install_camera_passthrough_rules() {{/,/^}}/p' "{ARCHSETUP}") + {pre} + install_camera_passthrough_rules "{rules_path}" + echo "RC=$?" + echo "RULES:[$(cat "{rules_path}" 2>/dev/null)]" + exit 0 + """) + return subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, timeout=10, + ) + + +def rules_body(r): + return r.stdout.split("RULES:[")[1].split("]")[0] + + +class InstallCameraPassthroughRules(unittest.TestCase): + # ------------------------------------------------------------ normal ---- + def test_writes_both_camera_rules_with_group_mode_and_tag(self): + with tempfile.TemporaryDirectory() as d: + r = run(os.path.join(d, "72-test.rules")) + body = rules_body(r) + for vendor, product in (("3564", "ff02"), ("046d", "085e")): + line = next((ln for ln in body.splitlines() + if f'ATTR{{idVendor}}=="{vendor}"' in ln), None) + assert line is not None, f"no rule line for {vendor}:{product}" + self.assertIn(f'ATTR{{idProduct}}=="{product}"', line) + self.assertIn('GROUP="video"', line) + self.assertIn('MODE="0660"', line) + self.assertIn('TAG+="uaccess"', line) + self.assertIn("RC=0", r.stdout) + + def test_default_filename_sorts_below_seat_late(self): + # The rule file's default install path must sort before + # 73-seat-late.rules or the uaccess tag lands too late to be ACLed. + with open(ARCHSETUP) as f: + src = f.read() + func = re.search( + r'^install_camera_passthrough_rules\(\)\s*{.*?^}', src, re.S | re.M) + assert func is not None, "function not found in archsetup" + m = re.search(r'\$\{1:-(/etc/udev/rules\.d/[^}]+)\}', func.group(0)) + assert m is not None, "default rules path not found in the function" + basename = os.path.basename(m.group(1)) + self.assertLess(basename, "73-seat-late.rules", + "the rules file must sort below 73-seat-late.rules") + + # ---------------------------------------------------------- boundary ---- + def test_rerun_is_idempotent(self): + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "72-test.rules") + first = run(path) + body_one = rules_body(first) + second = run(path) + self.assertEqual(body_one, rules_body(second)) + self.assertIn("RC=0", second.stdout) + + def test_overwrites_a_stale_existing_file(self): + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "72-test.rules") + with open(path, "w") as f: + f.write("# stale content that must not survive\n") + r = run(path) + self.assertNotIn("stale content", rules_body(r)) + self.assertIn('GROUP="video"', rules_body(r)) + + # ------------------------------------------------------------- error ---- + @unittest.skipUnless(os.geteuid() != 0, "root ignores directory write bits") + def test_unwritable_dir_warns_and_does_not_crash(self): + with tempfile.TemporaryDirectory() as d: + locked = os.path.join(d, "locked") + os.makedirs(locked) + os.chmod(locked, stat.S_IRUSR | stat.S_IXUSR) + r = run(os.path.join(locked, "72-test.rules")) + os.chmod(locked, stat.S_IRWXU) + self.assertIn("WARN:", r.stdout) + self.assertIn("RC=", r.stdout, + "the harness must reach its RC line — the function " + "returned rather than aborting the script") + + +if __name__ == "__main__": + unittest.main() @@ -54,20 +54,22 @@ The full todo.org sweep you asked for before sleeping, ranked by what I'd fix before departure (~2026-08-15, velox travels). Approve, reorder, or strike; items needing your call say so. -1. Velox reliability (the anchor — [#A] sleep/suspend, scheduled Monday). - Prerequisite: velox powered on and on the tailnet; it's been offline since - ~2026-08-05. Riders already folded in: the tlp.d radio-enable line, a - dotfiles pull, the touchpad-detection spot-check. +1. Velox reliability (the anchor — [#A] sleep/suspend, rescheduled Wed + 2026-08-12). Velox is out for repair/upgrade until Tuesday or Wednesday + (Craig, 2026-08-08), so every velox item waits for its return — a tight + but workable window before the ~08-15 departure. Riders already folded + in: the tlp.d radio-enable line, a dotfiles pull, the touchpad-detection + spot-check. 2. Velox machine health for travel (NEW — filed nowhere else): resolve the ~/code/auto-dim-other-buffers.el merge conflict (literal conflict markers in a loaded .el; its emacs suite has been red since 2026-08-01), clear the stale password prompt sitting on its screen since 2026-07-31, and run a maint doctor pass. 3. Remote access verified from OUTSIDE the LAN while you're still home: - tailscale to ratio, truenas, and truenas-kvm from a phone hotspot. Decide - whether the wolf WireGuard profile should also work from velox on the road - (it exists on ratio only, inactive). Cheap now, expensive to debug from a - hotel. + tailscale to ratio, truenas, and truenas-kvm from a phone hotspot. + DECIDED (Craig, 2026-08-08): the wolf WireGuard profile gets set up on + velox when it returns Tue/Wed — added to the velox-return riders. Cheap + at home, expensive to debug from a hotel. 4. The cgit secrets/privacy audit ([#B] below): a world-readable secret standing while you're away is the worst timing. The repo-by-repo scan is mine to run; the public-vs-private call per repo is yours. The archsetup @@ -82,11 +84,20 @@ items needing your call say so. if you disagree). Found tonight, low priority: the orchestrator sequence pin can't see an added-but-unstubbed call (it caught drops only) — worth a harness hardening pass someday. -** TODO [#B] Podman API socket and camera-passthrough udev rule :feature:solo: +** DONE [#B] Podman API socket and camera-passthrough udev rule :feature:solo: +CLOSED: [2026-08-09 Sun] :PROPERTIES: :CREATED: [2026-08-07 Fri] :LAST_REVIEWED: 2026-08-07 :END: +Shipped 2026-08-09: the installer enables the rootless podman socket at +install time (enable_user_service grew a wants-target arg so socket units +land in sockets.target.wants) and ships +=72-usb-passthrough-cameras.rules= — numbered below 73 per the winvm +rule-ordering correction, GROUP/MODE as the verified grant, uaccess tag kept. +Applied live on ratio (socket enabled+active, 99- file retired, udev +reloaded); velox apply rides the velox-return riders on the sleep/suspend +task. The uaccess-alone hypothesis stays untested until a camera is attached. From winvm 2026-08-07 (ratio). Two one-time machine-level setups, both live on ratio and absent on velox; full evidence and rationale in [[file:docs/design/2026-08-07-podman-socket-and-camera-udev.md]]. @@ -97,12 +108,17 @@ ratio and absent on velox; full evidence and rationale in (Pods opens to an empty window). The installer already carries the "=systemctl --user enable= fails during install" workaround pattern (=archsetup:1270=, =:2722=) — use it. -- Ship =/etc/udev/rules.d/99-usb-passthrough-cameras.rules= granting - GROUP="video", MODE="0660" on the OBSBOT (3564:ff02) and BRIO (046d:085e) - USB nodes so =usbredirect= can claim them for VM passthrough. The - hard-won finding: =TAG+="uaccess"= alone does NOT work — logind doesn't ACL - raw =/dev/bus/usb/*= nodes; the GROUP/MODE grant is what works. Keep the tag - anyway (harmless, correct in principle). +- Ship a udev rule granting GROUP="video", MODE="0660" on the OBSBOT + (3564:ff02) and BRIO (046d:085e) USB nodes so =usbredirect= can claim them + for VM passthrough. CORRECTED (winvm, 2026-08-08): the original "uaccess + can't ACL raw USB nodes" claim was wrong — the mechanism is rule ordering. + The ACL is applied by =73-seat-late.rules=, so a =99-= rule adds the tag + after that already ran; distro rules that add the tag all sort at or below + 70. So number our file below 73 (e.g. =72-usb-passthrough-cameras.rules=), + keep the verified GROUP/MODE grant, and keep the tag — correctly ordered it + may make uaccess work on its own (untested hypothesis; a tighter grant if + it holds, needs the camera plugged in to verify). Reconcile ratio's + existing =99-= file (winvm installed it) when the installer version lands. Scope: installer step + rule file + tests per existing shapes, and apply both live to velox over tailscale (daily-driver sync — neither exists there today). @@ -1182,7 +1198,7 @@ Read recommended resources to make informed security decisions (see metrics for Practical guidelines for working in public spaces ** TODO [#A] Ensure sleep/suspend works on laptops -SCHEDULED: <2026-08-10 Mon> +SCHEDULED: <2026-08-12 Wed> :PROPERTIES: :LAST_REVIEWED: 2026-08-08 :END: @@ -1192,12 +1208,18 @@ drain must be verified working before departure. Verification plan: apply the params, suspend velox, measure overnight drain; the hands-on resume check is Craig's (a manual-testing entry rides the fix). -While on velox for this (it was offline 2026-08-08, so these ride along): +While on velox for this (out for repair until ~2026-08-11/12, so these ride +along when it returns): - Append =DEVICES_TO_ENABLE_ON_STARTUP="bluetooth wifi"= to its =/etc/tlp.d/01-custom.conf= (the installer now writes it; velox predates that) and confirm radios unblocked after reboot. - Pull dotfiles so the touchpad auto-detection lands; spot-check =touchpad-auto --detect= prints the pixa name on real hardware. +- Set up the wolf WireGuard profile (Craig's 2026-08-08 decision: velox + should reach home over wolf on the road; profile exists on ratio only). +- Enable the rootless podman socket and install the + =72-usb-passthrough-cameras.rules= file (both live on ratio as of + 2026-08-09; the installer now ships both, velox predates it). Critical functionality for laptop use - current battery drain unacceptable *NOTE:* This applies to Framework Laptop (velox), not Framework Desktop (ratio) Add kernel parameter: ~rtc_cmos.use_acpi_alarm=1~ (will become systemd default) @@ -1960,33 +1982,37 @@ Grading: Minor severity (legibility on a shipped chip, nothing broken) × freque Offer a period-appropriate selector for timer duration, likely drawing on the tape-counter idiom, while preserving the existing direct-entry path. -** TODO [#C] Order network-panel connections by availability :feature:network: +** TODO [#C] Order network-panel connections by availability :feature:network:solo: :PROPERTIES: -:LAST_REVIEWED: 2026-08-08 +:LAST_REVIEWED: 2026-08-09 :END: Present saved and currently available networks in this order: available saved profiles, available unsaved networks, then saved profiles that are unavailable. -Held on a design conflict (recorded 2026-07-19, folded in at the 2026-08-08 -review): this wants one tiered list, but the network-panel spec says "three -labelled groups, never one merged list" with Saved MRU-first. Craig's call -before build: reorder within the Saved group only, or merge into one list and -override the spec. Not :solo: until that's answered. +*** 2026-08-09 Sun @ 11:13:15 -0500 Design conflict resolved; now :solo: +Craig, 2026-08-09: sort available-first within the Saved group (available +saved MRU-first, unavailable saved below), keeping the spec's three labelled +groups intact — no merged list. Escalate to a merge later only if it still +reads wrong in use. -** TODO [#C] Indicate hotspot or metered WiFi in amber :feature:network:waybar: +** TODO [#C] Indicate hotspot or metered WiFi in amber :feature:network:waybar:solo: :PROPERTIES: -:LAST_REVIEWED: 2026-08-08 +:LAST_REVIEWED: 2026-08-09 :END: Detect hotspot/metered connectivity and render the WiFi icon plus SSID amber, while ordinary WiFi stays white. -Held on two design questions (recorded 2026-07-19, folded in at the 2026-08-08 -review), both Craig's call before build: -- "Hotspot" is ambiguous — connected to a phone's hotspot, or this machine - running an AP? Which one (or both) goes amber? -- Metered detection needs new nmcli reads on the status fast path, whose - contract is one nmcli call. Bend the contract, or find a zero-cost signal? -Not :solo: until both are answered. +*** 2026-08-09 Sun @ 11:13:15 -0500 Both design calls answered; now :solo: +Craig, 2026-08-08/09: amber means connected to a phone's hotspot (this +machine running an AP does not trigger it), and the one-nmcli-call fast-path +contract stays intact. Build shape: NetworkManager's metered flag is the +detector (Android tethering auto-flags via the DHCP vendor hint; NM's +GENERAL.METERED yes/guessed-yes on the active wifi device), read by the +slow-path probe and written into the connectivity cache the fast path +already consumes, so the indicator pays nothing new. Where NM can't guess +(some iPhones), the per-connection metered flag is the manual override; a +panel affordance for it can come later. Live phone-hotspot check is Craig's +manual-testing entry; everything else verifies with fakes. ** CANCELLED [#C] Add a whole-display dim mode :feature:hyprland: CLOSED: [2026-08-08 Sat] |
