Autoconnect belongs to the service, not the gui #191

Open
opened 2026-08-28 11:42:34 -04:00 by mysticalsoap · 0 comments
Owner

autoconnect is a global setting in ROOTDIR/config.json, toggleable from both clients (aqomui -e autoconnect), but the only thing that acts on it is the gui: net_state_changed → connect_last_server (aqomui_gui.py:1312-1327), plus gui startup. So:

  • a cli-only user never gets an autoconnect, at boot or ever
  • closing the gui silently disables the feature
  • after a network change the service tears the tunnel down (#111) and nothing brings it back unless a gui happens to be open
  • with autoconnect off, a network change mid-session kills the tunnel permanently — opting out of connect-at-boot also opts you out of session continuity, which nobody asked for

The service is the only component that exists at boot (WantedBy=multi-user.target) and the only one alive continuously. It should own autoconnect.

How the field does it

The daemon is the app; the gui is a thin client.

  • Tailscale — tailscaled owns state in /var/lib/tailscale; cli and gui are clients over a local socket
  • NetworkManager — profiles in /etc/NetworkManager/system-connections (root, 0700), connection.autoconnect is a profile property, brought up at boot with no session in existence
  • wg-quick / openvpn — root-owned config plus systemctl enable wg-quick@wg0; the unit is the feature, there is no policy layer
  • Mullvad — mullvad-daemon holds the account, settings and relay constraints; mullvad auto-connect set on; relay selection runs in the daemon

Even the app-centric clients run daemon-owned execution: ProtonVPN disconnects when its app quits, but that is the app requesting a disconnect from its system service as a UX policy — the permanent kill switch keeps enforcing after the app exits, which proves where the tunnel actually lives. Quit semantics are a policy knob, not an architecture.

The common invariant: the state a daemon acts on unattended is daemon-owned. None of them have root parse a user-writable file and act on it with no session present. NM's secret-agent protocol is the sanctioned exception and it is narrow — secrets, on demand, with a session.

Why that invariant matters here

Per-user data (server.json, profile.json, last_server.json) lives in ~/.aqomui, which for the root service is /root/.aqomui. Having the service read the calling user's home to resolve "random favourite" at boot would turn a user-writable file into an unattended root action.

Today a dict reaching connect_to_server has passed require_authorization (polkit, active local session), and the dict's path is a config file the service hands to openvpn as root. The import path comments out up/down script directives (custom.py:145-146), but a file written directly at that path never went through import. The polkit gate is doing real work; removing it for an unattended boot-time path turns a same-privilege capability into a persistence primitive.

The intent model

"Autoconnect" and "reconnect" are different questions the current single flag conflates: should the machine come up tunneled? (default state) vs I connected and the network blipped — should it come back? (continuity of something already asked for). The field's answer is not two toggles, it is intent tracking à la Mullvad's target state:

  • Connect sets intent up; explicit disconnect clears it. The service's record is {intent, server dict}, written on every connect/disconnect it performs.
  • While intent is up, reconnect is unconditional and not a setting. A blip does not change what the user asked for; the escape hatch is the disconnect button, which already means exactly that. Without the intent field, deliberate-disconnect → blip → unwanted reconnect would betray the user.
  • The autoconnect setting shrinks to one honest question: intent at boot. On = the service starts with intent up, replaying the record. Off = starts neutral.
  • A blip resumes intent (same server — changing exit IPs mid-session is worse); a fresh connect re-rolls policy (#123's job). The line between the two is whether intent was continuously up.

Shape

  1. Service persists {intent, last server dict} in its own root-owned storage next to config.json — content vetted by an authorized session at the moment it was used.
  2. Trigger on network-up, acting only while intent is up. This covers boot for free: the service starts, sees no usable network, the network arrives, same code path. Boot and network-change stop being two features.
  3. Same require_server_dict validation on a replayed dict as on one arriving over D-Bus.
  4. Debounce and backoff hang off intent: a flapping link cannot connect-storm, and retries stop when the user says stop, not when a counter runs out.
  5. The gui's connect_last_server shrinks to re-rolling a policy pick at startup — the only part that needs the catalog.

Gui lifecycle

Without these the model is incoherent — autoconnect brings the tunnel up at boot, the user opens the gui to check on it, and aqomui_gui.py:148-149 disconnects it:

  1. Startup adopts instead of tearing down — the unconditional disconnect main/disconnect bypass on init becomes "read get_tunnel_state, render it" (the read already exists three lines up).
  2. Quit becomes an explicit setting (disconnect on quit): Mullvad semantics leave the tunnel up, ProtonVPN semantics tear it down. Both are one line once the service owns the tunnel; switching the default silently is the only wrong option.
  3. Tray/ux treats "tunnel up, no gui" as a normal state, not an error to reconcile.

Consequences to accept deliberately

  • Service-initiated connects bypass the polkit check by construction, as scheduled provider updates already do.
  • Multi-user: autoconnect uses the last connecting user's record, same semantics as homedirs.json and the bypass owner. Document, don't arbitrate.
  • A "random" pick no longer re-rolls at boot; it resumes the concrete server. Re-rolls still happen on every real connect.

Longer arc, not this issue

The field's answer to wanting random/fastest at boot is daemon-owned preferences, not root reading user files: move the preference, and the catalog it resolves against, into service-owned storage set through the authenticated API. The service already downloads and builds the server list in import_thread and then delivers it into the user's home (homedirs.json remembers where), so the data already originates root-side — inverting that is closer to undoing a detour than to a new imposition.

Worth knowing before chasing it: "fastest" at boot is partly illusory today. Latencies are only measured by the gui (get_latencies), so a boot-time resolution uses whatever was last measured — possibly days old, possibly on a different network.

Depends on #123 for the decision module.

`autoconnect` is a global setting in `ROOTDIR/config.json`, toggleable from both clients (`aqomui -e autoconnect`), but the only thing that acts on it is the gui: `net_state_changed` → `connect_last_server` (aqomui_gui.py:1312-1327), plus gui startup. So: - a cli-only user never gets an autoconnect, at boot or ever - closing the gui silently disables the feature - after a network change the service tears the tunnel down (#111) and nothing brings it back unless a gui happens to be open - with autoconnect *off*, a network change mid-session kills the tunnel permanently — opting out of connect-at-boot also opts you out of session continuity, which nobody asked for The service is the only component that exists at boot (`WantedBy=multi-user.target`) and the only one alive continuously. It should own autoconnect. ## How the field does it The daemon is the app; the gui is a thin client. - **Tailscale** — `tailscaled` owns state in `/var/lib/tailscale`; cli and gui are clients over a local socket - **NetworkManager** — profiles in `/etc/NetworkManager/system-connections` (root, 0700), `connection.autoconnect` is a profile property, brought up at boot with no session in existence - **wg-quick / openvpn** — root-owned config plus `systemctl enable wg-quick@wg0`; the unit *is* the feature, there is no policy layer - **Mullvad** — `mullvad-daemon` holds the account, settings and relay constraints; `mullvad auto-connect set on`; relay *selection* runs in the daemon Even the app-centric clients run daemon-owned execution: ProtonVPN disconnects when its app quits, but that is the app *requesting* a disconnect from its system service as a UX policy — the permanent kill switch keeps enforcing after the app exits, which proves where the tunnel actually lives. Quit semantics are a policy knob, not an architecture. The common invariant: **the state a daemon acts on unattended is daemon-owned.** None of them have root parse a user-writable file and act on it with no session present. NM's secret-agent protocol is the sanctioned exception and it is narrow — secrets, on demand, with a session. ## Why that invariant matters here Per-user data (`server.json`, `profile.json`, `last_server.json`) lives in `~/.aqomui`, which for the root service is `/root/.aqomui`. Having the service read the calling user's home to resolve "random favourite" at boot would turn a user-writable file into an unattended root action. Today a dict reaching `connect_to_server` has passed `require_authorization` (polkit, active local session), and the dict's `path` is a config file the service hands to openvpn as root. The import path comments out `up`/`down` script directives (custom.py:145-146), but a file written directly at that path never went through import. The polkit gate is doing real work; removing it for an unattended boot-time path turns a same-privilege capability into a persistence primitive. ## The intent model "Autoconnect" and "reconnect" are different questions the current single flag conflates: *should the machine come up tunneled?* (default state) vs *I connected and the network blipped — should it come back?* (continuity of something already asked for). The field's answer is not two toggles, it is intent tracking à la Mullvad's target state: - **Connect sets intent up; explicit disconnect clears it.** The service's record is `{intent, server dict}`, written on every connect/disconnect it performs. - **While intent is up, reconnect is unconditional and not a setting.** A blip does not change what the user asked for; the escape hatch is the disconnect button, which already means exactly that. Without the intent field, deliberate-disconnect → blip → unwanted reconnect would betray the user. - **The `autoconnect` setting shrinks to one honest question: intent at boot.** On = the service starts with intent up, replaying the record. Off = starts neutral. - **A blip resumes intent (same server — changing exit IPs mid-session is worse); a fresh connect re-rolls policy** (#123's job). The line between the two is whether intent was continuously up. ## Shape 1. Service persists `{intent, last server dict}` in its own root-owned storage next to `config.json` — content vetted by an authorized session at the moment it was used. 2. Trigger on network-up, acting only while intent is up. This covers boot for free: the service starts, sees no usable network, the network arrives, same code path. Boot and network-change stop being two features. 3. Same `require_server_dict` validation on a replayed dict as on one arriving over D-Bus. 4. Debounce and backoff hang off intent: a flapping link cannot connect-storm, and retries stop when the user says stop, not when a counter runs out. 5. The gui's `connect_last_server` shrinks to re-rolling a policy pick at startup — the only part that needs the catalog. ## Gui lifecycle Without these the model is incoherent — autoconnect brings the tunnel up at boot, the user opens the gui to check on it, and aqomui_gui.py:148-149 disconnects it: 1. **Startup adopts instead of tearing down** — the unconditional `disconnect main`/`disconnect bypass` on init becomes "read `get_tunnel_state`, render it" (the read already exists three lines up). 2. **Quit becomes an explicit setting** (`disconnect on quit`): Mullvad semantics leave the tunnel up, ProtonVPN semantics tear it down. Both are one line once the service owns the tunnel; switching the default silently is the only wrong option. 3. **Tray/ux treats "tunnel up, no gui" as a normal state**, not an error to reconcile. ## Consequences to accept deliberately - Service-initiated connects bypass the polkit check by construction, as scheduled provider updates already do. - Multi-user: autoconnect uses the last connecting user's record, same semantics as `homedirs.json` and the bypass owner. Document, don't arbitrate. - A "random" pick no longer re-rolls at boot; it resumes the concrete server. Re-rolls still happen on every real connect. ## Longer arc, not this issue The field's answer to wanting random/fastest *at boot* is daemon-owned preferences, not root reading user files: move the preference, and the catalog it resolves against, into service-owned storage set through the authenticated API. The service already downloads and builds the server list in `import_thread` and then delivers it into the user's home (`homedirs.json` remembers where), so the data already originates root-side — inverting that is closer to undoing a detour than to a new imposition. Worth knowing before chasing it: "fastest" at boot is partly illusory today. Latencies are only measured by the gui (`get_latencies`), so a boot-time resolution uses whatever was last measured — possibly days old, possibly on a different network. Depends on #123 for the decision module.
Sign in to join this conversation.
No milestone
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
mysticalsoap/aqomui#191
No description provided.