add: sweep already-running listed apps into bypass at activation (#113) #114

Merged
mysticalsoap merged 4 commits from feature/bypass-unit-sweep into trunk 2026-08-21 10:28:18 -04:00
Owner

Closes #113. Follows #112 (launch-time placement); this is the safety net behind it, per the plan settled on the issue.

Problem: launch-time placement only catches launches that pass through a launcher. A listed app restored by session restore, or started before the GUI at login, keeps running on the tunnel with nothing to correct it.

Fix: a deterministic sweep at bypass activation — no process watching.

  • bypass.sweep_units(desktop_ids, uid) walks the user manager's cgroup tree, recovers the desktop id systemd embeds in each app unit's directory name (app-<id>@<instance>.service, app-<id>.service, app-<id>-<pid>.scope), and moves the pids of matching units — nested unit cgroups included — into the bypass cgroup. Names are parsed before unescaping, so a \x2d inside an id can't be mistaken for the literal dash before a scope's pid.
  • Called on the three bypass activation paths (service recovery, options apply, network change) and on adding an app to the list. No-op when the cgroup is absent or the list is empty; per-pid failures (process exited, cgroup vanished) never abort the sweep.
  • Matching is against systemd's own records, so nothing inspects or guesses at processes, and the walk happens once per activation rather than continuously.

Reach beyond session restore: also backstops the two paths #112 left open — autostart entries that beat activation despite --wait, and launches through KDE's per-session shortcut cache (kglobalaccel's stale KService), since both land in normal app units.

Carried limitation: sockets opened before the move keep their old classification — cgroup v2 tags a socket at creation and migration doesn't retag it. So the sweep is a safety net, not a substitute for launch-time placement. The README now says this where a user meets it: restart the app if it matters that all of its traffic bypasses.

Verification: 255 tests pass (13 new: unit-id parsing across service/scope/autostart/escaped-dash/foreign-dir forms, sweep matching, nested cgroups, unlisted units, prefix collisions, missing cgroup). ruff + compileall clean. The parser was also validated read-only against this machine's live cgroup tree — all 14 real app units parse correctly, including app-arch\x2dupdate\x2dtray@autostart.service and both scope forms.

Live round: with a listed app already running, connect with bypass enabled → journal should log "moved N already-running process(es) into the cgroup", and a new connection from that app (fresh tab/request) should show the bypass IP while its pre-existing connections stay on the tunnel.

🤖 Generated with Claude Code

Closes #113. Follows #112 (launch-time placement); this is the safety net behind it, per the plan settled on the issue. **Problem**: launch-time placement only catches launches that pass through a launcher. A listed app restored by session restore, or started before the GUI at login, keeps running on the tunnel with nothing to correct it. **Fix**: a deterministic sweep at bypass activation — no process watching. - `bypass.sweep_units(desktop_ids, uid)` walks the user manager's cgroup tree, recovers the desktop id systemd embeds in each app unit's directory name (`app-<id>@<instance>.service`, `app-<id>.service`, `app-<id>-<pid>.scope`), and moves the pids of matching units — nested unit cgroups included — into the bypass cgroup. Names are parsed *before* unescaping, so a `\x2d` inside an id can't be mistaken for the literal dash before a scope's pid. - Called on the three bypass activation paths (service recovery, options apply, network change) and on adding an app to the list. No-op when the cgroup is absent or the list is empty; per-pid failures (process exited, cgroup vanished) never abort the sweep. - Matching is against systemd's own records, so nothing inspects or guesses at processes, and the walk happens once per activation rather than continuously. **Reach beyond session restore**: also backstops the two paths #112 left open — autostart entries that beat activation despite `--wait`, and launches through KDE's per-session shortcut cache (kglobalaccel's stale `KService`), since both land in normal app units. **Carried limitation**: sockets opened before the move keep their old classification — cgroup v2 tags a socket at creation and migration doesn't retag it. So the sweep is a safety net, not a substitute for launch-time placement. The README now says this where a user meets it: restart the app if it matters that *all* of its traffic bypasses. **Verification**: 255 tests pass (13 new: unit-id parsing across service/scope/autostart/escaped-dash/foreign-dir forms, sweep matching, nested cgroups, unlisted units, prefix collisions, missing cgroup). ruff + compileall clean. The parser was also validated read-only against this machine's live cgroup tree — all 14 real app units parse correctly, including `app-arch\x2dupdate\x2dtray@autostart.service` and both scope forms. Live round: with a listed app already running, connect with bypass enabled → journal should log "moved N already-running process(es) into the cgroup", and a *new* connection from that app (fresh tab/request) should show the bypass IP while its pre-existing connections stay on the tunnel. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
add: sweep already-running listed apps into bypass at activation (#113)
All checks were successful
ci / test (pull_request) Successful in 40s
f1e9d94312
Launch-time placement can only catch launches that go through a
launcher. An app restored by session restore, or started before the
GUI at login, keeps running on the tunnel with nothing to correct it.

The sweep runs when bypass activates (service recovery, options
apply, network change) and when an app is added to the list: it walks
the user manager's cgroup tree, reads the desktop id systemd embeds
in each app unit's directory name, and moves the pids of matching
units into the bypass cgroup. Unit names are parsed before unescaping
so a \x2d inside an id can't be confused with the literal dash before
a scope's pid. Nothing watches or classifies processes -- the match
is against systemd's own records, and the tree walk happens once per
activation.

This also backstops the two paths PR #112 left open: autostart entries
that beat activation despite --wait, and launches through KDE's
per-session shortcut cache, which land in app units like any other.

Sockets opened before the move keep their old classification -- cgroup
v2 tags a socket at creation and migration doesn't retag it -- so the
sweep is a safety net behind launch-time placement, not a substitute.
README says so where it matters: restart the app if all of its traffic
must bypass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
change: the service owns the activation sweep, not the gui
All checks were successful
ci / test (pull_request) Successful in 31s
f689577b39
Putting the sweep in the gui contradicted the reason it exists. Bypass
activation is the service's own event -- it creates the cgroup -- and
the cli activates bypass too (aqomui_cli.py:211,382), so cli-driven
activation and any restart with no gui running swept nothing at all.
The gap #113 set out to close was left open on exactly the paths where
no gui mediates.

bypass() now sweeps after building the cgroup, reading the owner's app
list from their home, which it locates through the uid it already
derives from the kernel sender (#89) -- the same derivation
import_thread uses. load_bypass_list treats that file as untrusted
input: non-objects and malformed entries are dropped rather than
carried further, and desktop ids are basenames, so no path in the list
can reach anything downstream.

sweep_units now checks every pid against the owner uid before moving
it. Unprivileged callers got that from the kernel for free -- writing
cgroup.procs requires owning the process -- but the service runs as
root, where nothing enforces it and the unit names come indirectly
from a user-writable file. Same guard reclaim_strays carries.

The gui keeps one sweep: adding an app while bypass is already up
produces no service call to hang it on. Its id math moves to
launcher.sweep_running, beside refresh_autostart -- same input, same
domain, and the module the cli already imports (#83).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Owner

Reworked after review: the sweep was in the wrong process.

Activation is the service's event — it builds the cgroup — and aqomui-cli activates bypass too (aqomui_cli.py:211, :382), so with the sweep in the GUI, cli-driven activation and any service restart without a running GUI swept nothing. That left the gap open on precisely the paths where no GUI mediates — self-defeating for an issue whose motivation is "launches the GUI never saw".

bypass() now sweeps after building the cgroup, reading the owner's app list from their home via the uid it already derives from the kernel sender (#89, same derivation import_thread uses for its download dir).

Two things that changed with it:

  • The list is untrusted input now. Root reading a user-writable JSON file means load_bypass_list drops non-objects and malformed entries instead of carrying them further, and desktop ids are basenames — nothing in the list can express a path.
  • sweep_units checks pid ownership explicitly. Unprivileged callers got that from the kernel for free (writing cgroup.procs requires owning the process); as root nothing enforces it, so the guard reclaim_strays already carries is now here too.

The GUI keeps exactly one sweep — adding an app while bypass is already up, which produces no service call to hang one on — and its id math moved to launcher.sweep_running, beside refresh_autostart: same input, same domain, and a module the cli imports anyway (#83).

265 tests pass (10 more: untrusted-list handling, basename-only ids, ownership filtering, sweep_running plumbing).

Reworked after review: the sweep was in the wrong process. Activation is the **service's** event — it builds the cgroup — and `aqomui-cli` activates bypass too ([aqomui_cli.py:211](https://git.mysticalsoap.com/mysticalsoap/aqomui/src/branch/trunk/aqomui/aqomui_cli.py#L211), :382), so with the sweep in the GUI, cli-driven activation and any service restart without a running GUI swept nothing. That left the gap open on precisely the paths where no GUI mediates — self-defeating for an issue whose motivation is "launches the GUI never saw". `bypass()` now sweeps after building the cgroup, reading the owner's app list from their home via the uid it already derives from the kernel sender (#89, same derivation `import_thread` uses for its download dir). Two things that changed with it: - **The list is untrusted input now.** Root reading a user-writable JSON file means `load_bypass_list` drops non-objects and malformed entries instead of carrying them further, and desktop ids are basenames — nothing in the list can express a path. - **`sweep_units` checks pid ownership explicitly.** Unprivileged callers got that from the kernel for free (writing `cgroup.procs` requires owning the process); as root nothing enforces it, so the guard `reclaim_strays` already carries is now here too. The GUI keeps exactly one sweep — adding an app while bypass is already up, which produces no service call to hang one on — and its id math moved to `launcher.sweep_running`, beside `refresh_autostart`: same input, same domain, and a module the cli imports anyway (#83). 265 tests pass (10 more: untrusted-list handling, basename-only ids, ownership filtering, sweep_running plumbing).
fix: the bypass cgroup never outlives its rules (#113)
Some checks failed
ci / test (pull_request) Failing after 45s
0daa0af15b
A restart clears the fwmark rules (#78), but delete_cgroup cannot
remove a cgroup whose dnsmasq is still sitting in it, so the cgroup
survives with nothing pointing at it. Shims go on joining it and
report success while every bypassed app rides the tunnel it was meant
to skip -- the worst failure available to a privacy feature, because
it looks like it is working. Hit live: after installing this branch,
konsole joined the cgroup on every launch and came out of the main
tunnel.

Restoring the rules was the gui's job, through service_recovered, so a
restart with no gui running -- or one whose gui missed the recovery
signal -- left bypass silently inert. The service now reconciles at
startup instead: the owner comes off the filesystem (#89), the rules
the leftover cgroup implies are rebuilt, and if bypass is switched off
the cgroup is removed outright. bypass() and startup share one
apply_bypass path, so the cgroup and its rules are only ever made or
unmade together.

teardown_bypass kills dnsmasq before delete_cgroup. Without that the
rmdir always failed for the resolver aqomui itself put there, which is
how a cgroup came to outlive its rules in the first place; the bypass=0
branch never killed it at all, orphaning both.

First tests for the service: it needs a bus and root to construct, so
they call the lifecycle methods unbound against stubs, as the gui tests
do.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mysticalsoap force-pushed feature/bypass-unit-sweep from 0daa0af15b
Some checks failed
ci / test (pull_request) Failing after 45s
to 13897d68e2
All checks were successful
ci / test (pull_request) Successful in 1m23s
2026-08-21 01:14:34 -04:00
Compare
change: only the service sweeps, and only at activation
Some checks failed
ci / test (pull_request) Failing after 1m5s
af49c5253e
Two methods named sweep_bypass_units, in two files, logging the same
sentence -- and the service's log is forwarded into the gui's log pane,
so the line could appear there from either process with nothing to tell
them apart. That ambiguity lands in the one artifact used to debug
bypass.

The split it came from was a rationalization: the service owns the
cgroup, so nothing else should write to cgroup.procs. Removing the
duplicate beats bridging to it, and the gui's sweep was the one worth
removing -- sweeping on a list edit buys little, since a running app
keeps the connections it already opened either way (cgroup v2 tags a
socket at creation), which is exactly what the gui's notification
already tells the user to fix by restarting it. Activation is different:
a dozen listed apps can be running at login, and 'restart them all' is
no answer -- so the sweep stays there.

Folding it into bypass() instead was the other option and is worse:
create_cgroup opens with delete_cgroup, so bypass() is a full teardown
and rebuild plus a dnsmasq restart -- far too much for ticking a box in
a list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Owner

Two corrections to earlier claims in this PR, plus the sweep consolidation.

Correction — the sweep does not catch KDE global-shortcut launches. I claimed it did, in the PR body and on #113. It only holds when the launch precedes the next activation: the sweep runs at activation, deliberately not continuously, so a Ctrl+Alt+T launch during an already-active bypass session is caught by nothing until something re-activates bypass. What actually fixes shortcut launches is re-login, after which kglobalaccel resolves the override and placement happens at launch time. Confirmed live.

The gui no longer sweeps. There were two sweep_bypass_units methods, in two files, logging the same sentence — and since the service's log is forwarded into the gui's log pane, that line could appear there from either process with nothing distinguishing them. That ambiguity lands squarely in the artifact used to debug bypass.

Resolved by deletion rather than by adding a sweep_bypass() method: the service owns the cgroup, so nothing else writes to cgroup.procs. The gui's sweep was the one worth losing — sweeping on a list edit buys little, because a running app keeps the connections it already opened either way (cgroup v2 tags a socket at creation), which is exactly what the notification already tells the user to fix by restarting. Activation stays swept: a dozen listed apps can be running at login, and "restart them all" is no answer.

Folding it into bypass() was considered and rejected — create_cgroup opens with delete_cgroup, so bypass() is a full teardown/rebuild plus a dnsmasq restart, far too much for ticking a box.

274 tests green.

Two corrections to earlier claims in this PR, plus the sweep consolidation. **Correction — the sweep does not catch KDE global-shortcut launches.** I claimed it did, in the PR body and on #113. It only holds when the launch *precedes* the next activation: the sweep runs at activation, deliberately not continuously, so a Ctrl+Alt+T launch during an already-active bypass session is caught by nothing until something re-activates bypass. What actually fixes shortcut launches is re-login, after which kglobalaccel resolves the override and placement happens at launch time. Confirmed live. **The gui no longer sweeps.** There were two `sweep_bypass_units` methods, in two files, logging the same sentence — and since the service's log is forwarded into the gui's log pane, that line could appear there from either process with nothing distinguishing them. That ambiguity lands squarely in the artifact used to debug bypass. Resolved by deletion rather than by adding a `sweep_bypass()` method: the service owns the cgroup, so nothing else writes to `cgroup.procs`. The gui's sweep was the one worth losing — sweeping on a list edit buys little, because a running app keeps the connections it already opened either way (cgroup v2 tags a socket at creation), which is exactly what the notification already tells the user to fix by restarting. Activation stays swept: a dozen listed apps can be running at login, and "restart them all" is no answer. Folding it into `bypass()` was considered and rejected — `create_cgroup` opens with `delete_cgroup`, so `bypass()` is a full teardown/rebuild plus a dnsmasq restart, far too much for ticking a box. 274 tests green.
mysticalsoap force-pushed feature/bypass-unit-sweep from af49c5253e
Some checks failed
ci / test (pull_request) Failing after 1m5s
to e971dd10f8 2026-08-21 01:41:50 -04:00
Compare
mysticalsoap deleted branch feature/bypass-unit-sweep 2026-08-21 10:28:18 -04:00
Sign in to join this conversation.
No description provided.