It read "On" for an hour through a dead tunnel — NM said activated while wg had 368 B received against 5.3 KiB sent. Now off/connecting/limited/online, decided by a 2s probe of the far-side resolver rather than by the interface being up. Sailfish models the same distinction (Online/Limited/Connecting); Android won't let an unvalidated network win at all.
177 lines
7.4 KiB
QML
177 lines
7.4 KiB
QML
import QtQuick
|
|
import qs.services
|
|
import qs.modules.common
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
|
|
/**
|
|
* A WireGuard tunnel, toggled deliberately.
|
|
*
|
|
* DELIBERATE, NEVER AUTOMATIC — and that is the design, not a limitation.
|
|
* An autonomous gate held this tunnel on 2026-07-30: it recycled 652 times in
|
|
* 90 minutes, through doze tiers whose contract is "network fetchers stopped",
|
|
* and the user could not switch it off because `nmcli connection down` fired
|
|
* the NM dispatcher that immediately brought it back up. A control surface the
|
|
* owner cannot overrule is not a control surface.
|
|
*
|
|
* So there is no daemon, no timer and no dispatcher hook deciding this. The
|
|
* toggle is the whole of it. If tunnel posture later becomes something the
|
|
* device decides for itself, it belongs in sessiond's state machine as an
|
|
* Action (DEVICE-STATE-MACHINE §12) and reachable as a verb (doctrine §13) —
|
|
* not as another actor off to the side.
|
|
*
|
|
* No privilege needed: polkit already lets the seat user activate a system
|
|
* connection, verified on device.
|
|
*/
|
|
QuickToggleModel {
|
|
id: root
|
|
|
|
/// The NetworkManager connection this drives. A property rather than a
|
|
/// constant so a second profile does not need a second component.
|
|
///
|
|
/// `wiufph` — the phone's OWN peer (10.8.0.6). The older `wiuf` profile
|
|
/// shared both its address and its private key with the laptop, so
|
|
/// WireGuard could only ever have one of the two connected; and it dialed
|
|
/// port 51822 with a key the server no longer lists, which is why it sent
|
|
/// and never received. Do not point this back at `wiuf`.
|
|
property string connectionName: "wiufph"
|
|
|
|
/// True while an up/down is in flight, so a double tap cannot race itself.
|
|
property bool busy: false
|
|
|
|
/// A host that only exists at the far end of the tunnel. Reaching it is the
|
|
/// only honest proof the tunnel carries traffic.
|
|
property string probeHost: "10.10.20.5"
|
|
|
|
/// "off" | "connecting" | "limited" | "online".
|
|
///
|
|
/// NOT a bool, and that is the point. On 2026-07-31 this toggle read "On"
|
|
/// for an hour while the tunnel was deaf: NetworkManager said `activated`,
|
|
/// `wg` said 368 B received against 5.3 KiB sent, and every service behind
|
|
/// it — STT, TTS, gitea — was unreachable. "The interface is up" and "the
|
|
/// tunnel carries traffic" are different claims and only one of them is
|
|
/// worth showing.
|
|
///
|
|
/// Sailfish models exactly this distinction (`MobileDataConnection.status`
|
|
/// is Online / Limited / Connecting, never a bool) and Android refuses to
|
|
/// let an unvalidated network win at all. `limited` is the state both have
|
|
/// and we did not.
|
|
///
|
|
/// The probe is deliberately NOT an internet check. This is a split tunnel
|
|
/// whose whole purpose is a private 10.10.0.0/16 that no public probe can
|
|
/// see; a link that fails a general-internet probe may be exactly the one
|
|
/// we want. The question is per-consumer — "can this carry *this*" — so we
|
|
/// ask the far end directly.
|
|
property string status: "off"
|
|
|
|
name: Translation.tr("Wiuf VPN")
|
|
icon: root.status === "online" ? "vpn_lock"
|
|
: root.status === "limited" ? "vpn_lock_off"
|
|
: "vpn_key"
|
|
statusText: {
|
|
if (root.busy)
|
|
return Translation.tr("…");
|
|
switch (root.status) {
|
|
case "online": return Translation.tr("On");
|
|
case "limited": return Translation.tr("No route");
|
|
default: return Translation.tr("Off");
|
|
}
|
|
}
|
|
tooltipText: {
|
|
switch (root.status) {
|
|
case "online":
|
|
return Translation.tr("Wiuf VPN — 10.10.0.0/16 routed over the tunnel");
|
|
case "limited":
|
|
return Translation.tr("Wiuf VPN — connected but not carrying traffic. The tunnel is up and the far side is unreachable; check which link the handshake is leaving by.");
|
|
default:
|
|
return Translation.tr("Wiuf VPN — off");
|
|
}
|
|
}
|
|
|
|
mainAction: () => {
|
|
if (root.busy)
|
|
return;
|
|
root.busy = true;
|
|
if (root.toggled)
|
|
downProc.running = true;
|
|
else
|
|
upProc.running = true;
|
|
}
|
|
|
|
function notify(body) {
|
|
Quickshell.execDetached(["notify-send", Translation.tr("Wiuf VPN"), body, "-a", "Shell"]);
|
|
}
|
|
|
|
Process {
|
|
id: upProc
|
|
command: ["nmcli", "connection", "up", root.connectionName]
|
|
onExited: (exitCode, exitStatus) => {
|
|
root.busy = false;
|
|
refreshProc.running = true;
|
|
if (exitCode !== 0)
|
|
root.notify(Translation.tr("Could not connect."));
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: downProc
|
|
command: ["nmcli", "connection", "down", root.connectionName]
|
|
onExited: (exitCode, exitStatus) => {
|
|
root.busy = false;
|
|
refreshProc.running = true;
|
|
if (exitCode !== 0)
|
|
root.notify(Translation.tr("Could not disconnect."));
|
|
}
|
|
}
|
|
|
|
// Read NM rather than tracking a local bool: the connection can also be
|
|
// brought up or down from nmcli, from Settings, or by NM itself, and a
|
|
// shadow copy that disagrees with the owning subsystem is the failure
|
|
// doctrine §4 names ("never a hand-tracked bool").
|
|
// Two questions, asked together because the answer to the first does not
|
|
// imply the second: is the connection active, and does it carry traffic.
|
|
// A single `nmcli` reply can only answer the first, which is how this
|
|
// reported "On" through an hour of a dead tunnel.
|
|
//
|
|
// The probe is one UDP DNS round-trip to the far-side resolver with a 2 s
|
|
// deadline — cheap enough for a 15 s poll, and it fails in exactly the case
|
|
// that matters (interface up, nothing crossing it). `getent` is not used:
|
|
// it would consult the whole resolver list and could be answered by a
|
|
// nameserver on another link, which is the same conflation this is here to
|
|
// end.
|
|
Process {
|
|
id: refreshProc
|
|
running: true
|
|
command: ["bash", "-c",
|
|
`if ! nmcli -t -f NAME connection show --active | grep -qx '${root.connectionName}'; then echo off; exit 0; fi;`
|
|
+ ` if timeout 2 bash -c 'echo > /dev/udp/${root.probeHost}/53' 2>/dev/null`
|
|
+ ` && timeout 2 ping -c1 -W2 ${root.probeHost} >/dev/null 2>&1; then echo online; else echo limited; fi`]
|
|
stdout: StdioCollector {
|
|
id: stateCollector
|
|
onStreamFinished: {
|
|
const reply = stateCollector.text.trim();
|
|
if (reply.length === 0)
|
|
return;
|
|
root.status = reply;
|
|
// `toggled` stays the user-facing "is it switched on", so the
|
|
// button still lights while the tunnel is limited — the state
|
|
// is reported in statusText rather than by silently un-toggling
|
|
// something the user turned on. Sailfish does the same: the
|
|
// switch stays checked and the description says "Limited".
|
|
root.toggled = (reply !== "off");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Cheap resync for changes made outside the panel. One nmcli call every
|
|
// 15 s while the shell runs; deliberately not a decision loop.
|
|
Timer {
|
|
interval: 15000
|
|
running: true
|
|
repeat: true
|
|
onTriggered: {
|
|
if (!root.busy)
|
|
refreshProc.running = true;
|
|
}
|
|
}
|
|
}
|