internal/tsutil: add Auto-VPN to toggle Tailscale based on Wi-Fi network - #294
Open
gitzone83 wants to merge 4 commits into
Open
internal/tsutil: add Auto-VPN to toggle Tailscale based on Wi-Fi network#294gitzone83 wants to merge 4 commits into
gitzone83 wants to merge 4 commits into
Conversation
…work
Adds an Auto-VPN feature that automatically enables or disables the
Tailscale VPN based on the current Wi-Fi network. When connected to a
trusted Wi-Fi network, VPN is disabled. On untrusted Wi-Fi, wired, or
no network, VPN is enabled (fail-secure).
This mirrors the Auto-VPN feature implemented in the Tailscale Android
client, adapted for Linux using NetworkManager DBus for SSID detection.
- internal/tsutil/autowatcher.go
- Subscribes to NetworkManager DBus signals (StateChanged,
PropertiesChanged) for event-driven network change detection
- Queries active Wi-Fi SSID via NetworkManager DBus API
(GetDevices -> DeviceType == Wi-Fi -> ActiveAccessPoint -> Ssid)
- Falls back to iwgetid -r if DBus is unavailable
- 2-second debounce on network events (matches Android implementation)
- 10-second periodic re-evaluation ticker to catch external VPN
state changes
- State-aware dedup: checks actual VPN state via Poller before
toggling, prevents redundant Start()/Stop() calls
- Standalone DetectCurrentSSID() for UI use without running watcher
- Exports callbacks: OnSSIDChange, OnVPNToggle, IsEnabled, TrustedList
- dev.deedles.Trayscale.gschema.xml
- Added auto-vpn-enabled (boolean, default false)
- Added auto-vpn-trusted-ssids (string array, default [])
- internal/ui/preferences.ui
- Added AdwPreferencesGroup "Auto-VPN" inside PreferencesPage with:
- AdwSwitchRow for enable/disable toggle
- AdwActionRow showing current Wi-Fi network name
- internal/ui/preferences.go
- Added Auto-VPN UI fields to PreferencesDialog struct
- Trust/Untrust button on current network row with live UI updates
- Manual SSID entry via AdwEntryRow with apply button
- Tracked trusted SSID rows with individual remove buttons
- Rows add/remove immediately without needing to reopen the dialog
- internal/ui/settings.go
- Handles auto-vpn-enabled change: starts/stops AutoWatcher
- Handles auto-vpn-trusted-ssids change: triggers reevaluation
in a goroutine (avoids GTK main loop deadlock)
- internal/ui/app.go
- Added autoWatcher field to App struct
- startAutoWatcher()/stopAutoWatcher() methods
- Wires OnVPNToggle to desktop notifications
- Starts watcher in Run() if enabled on launch
- Stops watcher in Quit() for clean shutdown
Requires additional permission in the Flatpak manifest:
--system-talk-name=org.freedesktop.NetworkManager
No new dependencies - github.com/godbus/dbus/v5 is already an
indirect dependency via the Tailscale library.
Tested on Ubuntu 24.04 (GNOME, Wayland) via Flatpak build:
- SSID detection works inside Flatpak sandbox
- Trust/Untrust toggles VPN state correctly
- Settings persist across app restarts
- Desktop notifications fire on Auto-VPN state changes
Auto-VPN resolves the current Wi-Fi SSID over NetworkManager's DBus API. There is no portable alternative -- neither gio.NetworkMonitor nor the org.freedesktop.portal.NetworkMonitor portal expose SSID information -- so the feature cannot work without it. Rather than presenting settings that would silently do nothing, probe for NetworkManager and, when it is missing, mark the preferences group insensitive with a description explaining why. The watcher makes the same check and returns instead of looping on an unresolvable network, which the fail-secure path would otherwise read as untrusted and use to pin the VPN on. Also drop the iwgetid fallback, which cannot work inside the Flatpak sandbox, and replace a deprecated adw.ActionRow.SetIconName call.
Three related problems in the watcher: Evaluations could overlap. A settings change (Reevaluate), the debounce timer and the 10s ticker can all fire at once, and each read lastAction, released the mutex, blocked on the poller, then acted. Two callers could therefore both pass the dedup check and both issue a Start/Stop for the same transition. Guard whole evaluations with a separate mutex; it is never held while taking the existing one, so Stop() cannot block behind a running evaluation. Reading the poller could not be cancelled. GetIPN() returns an unbuffered channel, so a watcher parked on it stayed parked even after Stop(), and Stop() had no way to release it. Select on ctx.Done() as well. Callbacks could fire after Stop(). An in-flight evaluation carried on and called OnSSIDChange/OnVPNToggle into UI state the app had already torn down. Check the context after taking the lock and again after Start/Stop returns. Tests cover all three and fail against the previous code: without the evaluation lock eight concurrent evaluations overlap instead of one.
The Auto-VPN watcher imports github.com/godbus/dbus/v5 directly, so it is no longer an indirect dependency. No version change; it was already in the module graph via the Tailscale library.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This is the Auto-VPN thing from #278, finally. Sorry it took so long to turn up.
Short version: it turns Tailscale off on Wi-Fi networks you've marked trusted and back on everywhere else — untrusted Wi-Fi, wired, no network at all, or an SSID it can't read. Off by default.
I built it to what we agreed in that thread rather than what I first proposed, so: NetworkManager only, no iwd backend, no gio.NetworkMonitor swap. If NM isn't on the bus the whole preferences group goes insensitive with a line explaining why, and the watcher just returns rather than spinning. The
iwgetidfallback is gone too — you were right that it was never going to work inside the Flatpak. Settings are two GSettings keys in the existing preferences dialog.Couple of things in #278 that are stale now: no iwgetid, and autowatcher.go came out around 400 lines rather than the ~315 I guessed, mostly the NM-availability handling and the concurrency stuff below.
You asked:
Yeah, on/off — same
tsutil.Start()/Stop()path as the tray toggle.The case I built it for is a laptop that moves around. At home I want the NAS and the printer on the plain LAN without going through the tunnel; on hotel or conference wifi I want it up and I don't want to have to remember. So it's peer-to-peer access being toggled, not exit node routing. I get that if you're mostly stationary this is less compelling.
An exit-node version would drop into the same machinery though — the trusted list and the watcher are unchanged, only the action differs. Say the word and I'll add it here or as a follow-up, whichever you prefer.
Mechanically it watches NetworkManager's StateChanged and PropertiesChanged, pulls the SSID off the active AP over DBus, checks it against the list. Network events get a 2s debounce so roaming doesn't thrash it, plus a 10s ticker for anything that changes without a signal. It checks actual VPN state before doing anything, so a settled state doesn't generate repeated calls. Anything it can't identify counts as untrusted and the tunnel comes up — I'd rather it be on when it shouldn't be than off when it should.
The concurrency was where the actual bugs were, so worth flagging. Settings changes, the debounce timer and the ticker can all land at once, and the original code checked its dedup state, dropped the lock, blocked on the poller, then acted — so two of them could both decide to act on the same transition. Evaluations are now serialised under their own mutex, and it's deliberately never held while taking the other one so Stop() can't wedge behind a running evaluation.
Two things fell out of that.
GetIPN()hands back an unbuffered channel, so anything parked on it couldn't be released by Stop() at all — that read selects on the context now. And an evaluation already in flight would happily call back into UI state after teardown, so the context gets checked after the lock and again after Start/Stop returns.There are tests for all three in autowatcher_test.go. I checked they actually fail against the old code rather than just passing — without the evaluation lock you get eight overlapping evaluations instead of one.
Testing was on Ubuntu 26.04.1, GTK 4.22.4 / libadwaita 1.9.1, Go 1.27, tailscaled 1.102.3. SSID detection matches what nmcli reports. Trusting the current network takes it Running -> Stopped, untrusting brings it back, and I confirmed both against tailscaled's own log rather than just the UI. I also sat there flapping trust/untrust every two or three seconds to try and provoke it: exactly one EditPrefs per action, no oscillation, and the ticker stays quiet once things settle. Empty and nil trusted lists are inert, as is a disabled watcher. build/vet/staticcheck are clean — staticcheck reports the same two pre-existing poller.go findings as master and nothing else.
go test -raceis clean.One gap I should mention: I haven't actually exercised the debounce. Settings changes go through Reevaluate and skip it by design, so testing it properly needs a real network transition rather than clicking things in the UI.
For Flatpak it needs one permission in the flathub manifest, which I'll send separately if this lands:
- --system-talk-name=org.freedesktop.NetworkManagerWithout it you get the NM-unavailable path rather than anything broken.
Last thing, unrelated to this branch but relevant if you go to test it: master currently segfaults on startup against a 1.102.x daemon, which I wrote up in #290. I had that library bump applied locally while testing this; it's deliberately not in this PR.