aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-08-09 11:20:43 -0500
committerCraig Jennings <c@cjennings.net>2026-08-09 11:47:36 -0500
commit169dab71a063f4fab6c46f95e11d6e85b140cb8f (patch)
tree60f669ec9f84e4b74174d30e0f600935de129e6e
parent24963baa69b2ecfc8e4a3b98d65101f16d6eb873 (diff)
downloadarchsetup-169dab71a063f4fab6c46f95e11d6e85b140cb8f.tar.gz
archsetup-169dab71a063f4fab6c46f95e11d6e85b140cb8f.zip
feat: enable the podman API socket and ship the camera udev grantHEADmain
Two one-time machine setups from the winvm handoffs now happen at install time. - The devops podman block enables the rootless podman API socket. Socket-activated, so it costs nothing idle, and API clients like Pods fail with an empty window without it. enable_user_service grew an optional wants-target argument because a socket unit's [Install] is WantedBy=sockets.target. The old default.target link would never socket-activate. - install_camera_passthrough_rules ships 72-usb-passthrough-cameras.rules: GROUP="video", MODE="0660" plus the uaccess tag on the OBSBOT and BRIO USB IDs, so usbredirect can claim them for VM passthrough. The filename is load-bearing: logind's ACL is applied by 73-seat-late.rules, so the tag only works from a file sorting below 73. A test pins that property. Both are live on ratio (the old 99- rules file is retired there). Whether uaccess alone would suffice from the corrected position is untested and stays documented as a hypothesis.
-rwxr-xr-xarchsetup43
-rw-r--r--docs/design/2026-08-07-podman-socket-and-camera-udev.md15
-rw-r--r--tests/installer-steps/test_idempotency_cluster.py27
-rw-r--r--tests/installer-steps/test_install_camera_passthrough_rules.py118
-rw-r--r--todo.org92
5 files changed, 259 insertions, 36 deletions
diff --git a/archsetup b/archsetup
index 86f92a2..1d263fa 100755
--- a/archsetup
+++ b/archsetup
@@ -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()
diff --git a/todo.org b/todo.org
index 5daacf2..68f790d 100644
--- a/todo.org
+++ b/todo.org
@@ -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]