From 988b54515e804307ed8029fcebfd47802e07e06b Mon Sep 17 00:00:00 2001 From: Rocho Date: Tue, 16 Jun 2026 15:08:22 +0200 Subject: [PATCH] feat(tailscale): add connect/disconnect, exit-node and LAN-access controls (#2644) * feat(tailscale): add connect/disconnect/exit-node/LAN-access backend The Tailscale backend previously exposed only read-only status (tailscale.getStatus, tailscale.refresh). This adds write actions through the existing tailscale.com/client/local integration: - tailscale.connect / tailscale.disconnect (EditPrefs WantRunning) - tailscale.setExitNode (EditPrefs ExitNodeID; empty id clears it and any legacy ExitNodeIP, mirroring `tailscale set --exit-node`) - tailscale.setAllowLanAccess (EditPrefs ExitNodeAllowLANAccess) The manager's client interface gains GetPrefs/EditPrefs; fetchState merges ExitNodeAllowLANAccess from prefs, and Peer exposes ExitNodeOption so the UI can list exit-node-capable peers. * feat(tailscale): expose the new actions in TailscaleService Adds connectTailscale/disconnectTailscale, setExitNode/clearExitNode and setAllowLanAccess wrappers, plus derived exitNodeOptions/currentExitNode and the exitNodeAllowLanAccess state. Write-action errors surface via ToastService. * feat(tailscale): add connection, exit-node and LAN-access controls to the widget The control-center widget toggle was a no-op. It now connects/disconnects, and the detail panel gains a connection status row with a connect/disconnect button, an exit-node picker and a LAN-access toggle. --- core/internal/server/tailscale/client.go | 21 +-- core/internal/server/tailscale/handlers.go | 42 ++++++ .../server/tailscale/handlers_test.go | 58 ++++++++ core/internal/server/tailscale/manager.go | 89 ++++++++++++- .../internal/server/tailscale/manager_test.go | 103 ++++++++++++++- core/internal/server/tailscale/types.go | 46 ++++--- .../BuiltinPlugins/TailscaleWidget.qml | 125 +++++++++++++++++- quickshell/Services/TailscaleService.qml | 54 ++++++++ 8 files changed, 499 insertions(+), 39 deletions(-) diff --git a/core/internal/server/tailscale/client.go b/core/internal/server/tailscale/client.go index 167e45c6..987b927e 100644 --- a/core/internal/server/tailscale/client.go +++ b/core/internal/server/tailscale/client.go @@ -66,16 +66,17 @@ func convertPeerStatus(ps *ipnstate.PeerStatus, users map[tailcfg.UserID]tailcfg } peer := Peer{ - ID: string(ps.ID), - Hostname: hostname, - DNSName: dnsName, - OS: ps.OS, - Online: ps.Online, - Active: ps.Active, - ExitNode: ps.ExitNode, - Relay: ps.Relay, - RxBytes: ps.RxBytes, - TxBytes: ps.TxBytes, + ID: string(ps.ID), + Hostname: hostname, + DNSName: dnsName, + OS: ps.OS, + Online: ps.Online, + Active: ps.Active, + ExitNode: ps.ExitNode, + ExitNodeOption: ps.ExitNodeOption, + Relay: ps.Relay, + RxBytes: ps.RxBytes, + TxBytes: ps.TxBytes, } for _, ip := range ps.TailscaleIPs { diff --git a/core/internal/server/tailscale/handlers.go b/core/internal/server/tailscale/handlers.go index 187b8b7b..d87a20c7 100644 --- a/core/internal/server/tailscale/handlers.go +++ b/core/internal/server/tailscale/handlers.go @@ -14,6 +14,14 @@ func HandleRequest(conn net.Conn, req models.Request, manager *Manager) { handleGetStatus(conn, req, manager) case "tailscale.refresh": handleRefresh(conn, req, manager) + case "tailscale.connect": + handleConnect(conn, req, manager) + case "tailscale.disconnect": + handleDisconnect(conn, req, manager) + case "tailscale.setExitNode": + handleSetExitNode(conn, req, manager) + case "tailscale.setAllowLanAccess": + handleSetAllowLanAccess(conn, req, manager) default: models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method)) } @@ -28,3 +36,37 @@ func handleRefresh(conn net.Conn, req models.Request, manager *Manager) { manager.RefreshState() models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "refreshed"}) } + +func handleConnect(conn net.Conn, req models.Request, manager *Manager) { + if err := manager.Connect(); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "connected"}) +} + +func handleDisconnect(conn net.Conn, req models.Request, manager *Manager) { + if err := manager.Disconnect(); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "disconnected"}) +} + +func handleSetExitNode(conn net.Conn, req models.Request, manager *Manager) { + id := models.GetOr(req, "id", "") + if err := manager.SetExitNode(id); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "exit node updated"}) +} + +func handleSetAllowLanAccess(conn net.Conn, req models.Request, manager *Manager) { + enabled := models.GetOr(req, "enabled", false) + if err := manager.SetAllowLANAccess(enabled); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "lan access updated"}) +} diff --git a/core/internal/server/tailscale/handlers_test.go b/core/internal/server/tailscale/handlers_test.go index 124e27aa..ed1a3265 100644 --- a/core/internal/server/tailscale/handlers_test.go +++ b/core/internal/server/tailscale/handlers_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "net" "testing" "time" @@ -78,6 +79,63 @@ func TestHandleRefresh(t *testing.T) { assert.True(t, resp.Result.Success) } +func TestHandleActions(t *testing.T) { + cases := []struct { + name string + method string + params map[string]any + }{ + {"connect", "tailscale.connect", nil}, + {"disconnect", "tailscale.disconnect", nil}, + {"setExitNode", "tailscale.setExitNode", map[string]any{"id": "nABC123"}}, + {"clearExitNode", "tailscale.setExitNode", map[string]any{"id": ""}}, + {"setAllowLanAccess", "tailscale.setAllowLanAccess", map[string]any{"enabled": true}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := handlerTestManager() + defer m.Close() + + buf := &bytes.Buffer{} + conn := &mockConn{Buffer: buf} + + req := models.Request{ID: 1, Method: tc.method, Params: tc.params} + HandleRequest(conn, req, m) + + var resp models.Response[models.SuccessResult] + require.NoError(t, json.NewDecoder(buf).Decode(&resp)) + assert.Equal(t, 1, resp.ID) + assert.Empty(t, resp.Error) + require.NotNil(t, resp.Result) + assert.True(t, resp.Result.Success) + }) + } +} + +func TestHandleAction_BackendError(t *testing.T) { + client := &mockClient{ + watchFn: blockingWatch, + statusFn: func(ctx context.Context) (*ipnstate.Status, error) { return runningStatus(), nil }, + editPrefsFn: func(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) { + return nil, fmt.Errorf("backend rejected edit") + }, + } + m := newManager(client) + defer m.Close() + + buf := &bytes.Buffer{} + conn := &mockConn{Buffer: buf} + + req := models.Request{ID: 1, Method: "tailscale.connect"} + HandleRequest(conn, req, m) + + var resp models.Response[models.SuccessResult] + require.NoError(t, json.NewDecoder(buf).Decode(&resp)) + assert.Nil(t, resp.Result) + assert.Contains(t, resp.Error, "backend rejected edit") +} + func TestHandleRequest_UnknownMethod(t *testing.T) { m := handlerTestManager() defer m.Close() diff --git a/core/internal/server/tailscale/manager.go b/core/internal/server/tailscale/manager.go index e102ead4..e622df9a 100644 --- a/core/internal/server/tailscale/manager.go +++ b/core/internal/server/tailscale/manager.go @@ -11,6 +11,7 @@ import ( "tailscale.com/client/local" "tailscale.com/ipn" "tailscale.com/ipn/ipnstate" + "tailscale.com/tailcfg" ) const ( @@ -22,6 +23,8 @@ const ( type tailscaleClient interface { WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) Status(ctx context.Context) (*ipnstate.Status, error) + GetPrefs(ctx context.Context) (*ipn.Prefs, error) + EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) } // ipnBusWatcher abstracts the IPN bus watcher for testing. @@ -43,6 +46,14 @@ func (w *localClientWrapper) Status(ctx context.Context) (*ipnstate.Status, erro return w.client.Status(ctx) } +func (w *localClientWrapper) GetPrefs(ctx context.Context) (*ipn.Prefs, error) { + return w.client.GetPrefs(ctx) +} + +func (w *localClientWrapper) EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) { + return w.client.EditPrefs(ctx, mp) +} + // Manager manages Tailscale state via IPN bus events and subscriber notifications. type Manager struct { state *TailscaleState @@ -169,16 +180,36 @@ func (m *Manager) fetchAndBroadcast(ctx context.Context) { statusCtx, cancel := context.WithTimeout(ctx, statusTimeout) defer cancel() - status, err := m.client.Status(statusCtx) + state, err := m.fetchState(statusCtx) if err != nil { log.Warnf("[Tailscale] Failed to fetch status: %v", err) return } - state := convertStatus(status) m.updateState(state) } +// fetchState fetches the current status and merges in pref-derived fields +// (e.g. exit-node LAN access) that are not present in the IPN status itself. +func (m *Manager) fetchState(ctx context.Context) (*TailscaleState, error) { + status, err := m.client.Status(ctx) + if err != nil { + return nil, err + } + + state := convertStatus(status) + + // Prefs carry the exit-node LAN-access toggle, which the status does not + // expose. Treat a prefs failure as non-fatal so status still updates. + if prefs, err := m.client.GetPrefs(ctx); err != nil { + log.Warnf("[Tailscale] Failed to fetch prefs: %v", err) + } else if prefs != nil { + state.ExitNodeAllowLANAccess = prefs.ExitNodeAllowLANAccess + } + + return state, nil +} + func (m *Manager) updateState(state *TailscaleState) { m.stateMutex.Lock() m.state = state @@ -266,12 +297,62 @@ func (m *Manager) RefreshState() { ctx, cancel := context.WithTimeout(m.ctx, statusTimeout) defer cancel() - status, err := m.client.Status(ctx) + state, err := m.fetchState(ctx) if err != nil { log.Warnf("[Tailscale] Failed to refresh state: %v", err) return } - state := convertStatus(status) m.updateState(state) } + +// Connect brings the Tailscale backend up (WantRunning = true). +func (m *Manager) Connect() error { + return m.editPrefs(&ipn.MaskedPrefs{ + Prefs: ipn.Prefs{WantRunning: true}, + WantRunningSet: true, + }) +} + +// Disconnect brings the Tailscale backend down (WantRunning = false). +func (m *Manager) Disconnect() error { + return m.editPrefs(&ipn.MaskedPrefs{ + Prefs: ipn.Prefs{WantRunning: false}, + WantRunningSet: true, + }) +} + +// SetExitNode selects the exit node identified by its stable node ID. An empty +// id clears the current exit node. Mirrors `tailscale set --exit-node=`, +// which also clears any legacy IP-based exit node so a stale ExitNodeIP cannot +// silently take precedence over the now-empty ID. +func (m *Manager) SetExitNode(id string) error { + return m.editPrefs(&ipn.MaskedPrefs{ + Prefs: ipn.Prefs{ExitNodeID: tailcfg.StableNodeID(id)}, + ExitNodeIDSet: true, + ExitNodeIPSet: true, + }) +} + +// SetAllowLANAccess toggles whether locally accessible subnets remain +// reachable while an exit node is in use. +func (m *Manager) SetAllowLANAccess(enabled bool) error { + return m.editPrefs(&ipn.MaskedPrefs{ + Prefs: ipn.Prefs{ExitNodeAllowLANAccess: enabled}, + ExitNodeAllowLANAccessSet: true, + }) +} + +// editPrefs applies a masked prefs edit and refreshes state so subscribers see +// the result immediately, in addition to the IPN bus notification it triggers. +func (m *Manager) editPrefs(mp *ipn.MaskedPrefs) error { + ctx, cancel := context.WithTimeout(m.ctx, statusTimeout) + defer cancel() + + if _, err := m.client.EditPrefs(ctx, mp); err != nil { + return err + } + + m.RefreshState() + return nil +} diff --git a/core/internal/server/tailscale/manager_test.go b/core/internal/server/tailscale/manager_test.go index 7ac4e8f2..87666e91 100644 --- a/core/internal/server/tailscale/manager_test.go +++ b/core/internal/server/tailscale/manager_test.go @@ -12,8 +12,16 @@ import ( "github.com/stretchr/testify/require" "tailscale.com/ipn" "tailscale.com/ipn/ipnstate" + "tailscale.com/tailcfg" ) +// blockingWatch is a watchFn that blocks until the context is cancelled, used +// by tests that exercise direct manager calls rather than the watch loop. +func blockingWatch(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) { + <-ctx.Done() + return nil, ctx.Err() +} + // mockWatcher yields canned Notify events, then returns err or blocks until Close/context cancel. type mockWatcher struct { events []ipn.Notify @@ -68,8 +76,10 @@ func (w *mockWatcher) Close() error { // mockClient implements tailscaleClient for testing. type mockClient struct { - watchFn func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) - statusFn func(ctx context.Context) (*ipnstate.Status, error) + watchFn func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) + statusFn func(ctx context.Context) (*ipnstate.Status, error) + getPrefsFn func(ctx context.Context) (*ipn.Prefs, error) + editPrefsFn func(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) } func (c *mockClient) WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) { @@ -80,6 +90,20 @@ func (c *mockClient) Status(ctx context.Context) (*ipnstate.Status, error) { return c.statusFn(ctx) } +func (c *mockClient) GetPrefs(ctx context.Context) (*ipn.Prefs, error) { + if c.getPrefsFn != nil { + return c.getPrefsFn(ctx) + } + return &ipn.Prefs{}, nil +} + +func (c *mockClient) EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) { + if c.editPrefsFn != nil { + return c.editPrefsFn(ctx, mp) + } + return &ipn.Prefs{}, nil +} + func runningStatus() *ipnstate.Status { return &ipnstate.Status{ Version: "1.94.2", @@ -296,3 +320,78 @@ func TestManager_RefreshState(t *testing.T) { assert.True(t, state.Connected) assert.Equal(t, "cachyos", state.Self.Hostname) } + +func TestManager_RefreshState_MergesPrefs(t *testing.T) { + client := &mockClient{ + watchFn: blockingWatch, + statusFn: func(ctx context.Context) (*ipnstate.Status, error) { return runningStatus(), nil }, + getPrefsFn: func(ctx context.Context) (*ipn.Prefs, error) { + return &ipn.Prefs{ExitNodeAllowLANAccess: true}, nil + }, + } + + m := newManager(client) + defer m.Close() + + m.RefreshState() + + assert.True(t, m.GetState().ExitNodeAllowLANAccess) +} + +func TestManager_Actions_EditPrefs(t *testing.T) { + var captured *ipn.MaskedPrefs + client := &mockClient{ + watchFn: blockingWatch, + statusFn: func(ctx context.Context) (*ipnstate.Status, error) { return runningStatus(), nil }, + editPrefsFn: func(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) { + captured = mp + return &ipn.Prefs{}, nil + }, + } + + m := newManager(client) + defer m.Close() + + require.NoError(t, m.Connect()) + require.NotNil(t, captured) + assert.True(t, captured.WantRunningSet) + assert.True(t, captured.WantRunning) + + require.NoError(t, m.Disconnect()) + assert.True(t, captured.WantRunningSet) + assert.False(t, captured.WantRunning) + + require.NoError(t, m.SetExitNode("nABC123")) + assert.True(t, captured.ExitNodeIDSet) + assert.Equal(t, tailcfg.StableNodeID("nABC123"), captured.ExitNodeID) + // ExitNodeIPSet must also be set so a stale legacy ExitNodeIP cannot + // override the ID-based selection (mirrors `tailscale set --exit-node`). + assert.True(t, captured.ExitNodeIPSet) + + require.NoError(t, m.SetExitNode("")) + assert.True(t, captured.ExitNodeIDSet) + assert.Equal(t, tailcfg.StableNodeID(""), captured.ExitNodeID) + // Clearing must zero both the ID and any legacy IP-based exit node. + assert.True(t, captured.ExitNodeIPSet) + + require.NoError(t, m.SetAllowLANAccess(true)) + assert.True(t, captured.ExitNodeAllowLANAccessSet) + assert.True(t, captured.ExitNodeAllowLANAccess) +} + +func TestManager_Actions_PropagateError(t *testing.T) { + client := &mockClient{ + watchFn: blockingWatch, + statusFn: func(ctx context.Context) (*ipnstate.Status, error) { return runningStatus(), nil }, + editPrefsFn: func(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) { + return nil, fmt.Errorf("backend rejected edit") + }, + } + + m := newManager(client) + defer m.Close() + + assert.Error(t, m.Connect()) + assert.Error(t, m.SetExitNode("nABC123")) + assert.Error(t, m.SetAllowLANAccess(true)) +} diff --git a/core/internal/server/tailscale/types.go b/core/internal/server/tailscale/types.go index d4fe49b7..ee2c726e 100644 --- a/core/internal/server/tailscale/types.go +++ b/core/internal/server/tailscale/types.go @@ -2,30 +2,32 @@ package tailscale // TailscaleState represents the current state of the Tailscale daemon. type TailscaleState struct { - Connected bool `json:"connected"` - Version string `json:"version"` - BackendState string `json:"backendState"` - MagicDNSSuffix string `json:"magicDnsSuffix"` - TailnetName string `json:"tailnetName"` - Self Peer `json:"self"` - Peers []Peer `json:"peers"` + Connected bool `json:"connected"` + Version string `json:"version"` + BackendState string `json:"backendState"` + MagicDNSSuffix string `json:"magicDnsSuffix"` + TailnetName string `json:"tailnetName"` + ExitNodeAllowLANAccess bool `json:"exitNodeAllowLanAccess"` + Self Peer `json:"self"` + Peers []Peer `json:"peers"` } // Peer represents a single node in the Tailscale network. type Peer struct { - ID string `json:"id"` - Hostname string `json:"hostname"` - DNSName string `json:"dnsName"` - TailscaleIP string `json:"tailscaleIp"` - TailscaleIPv6 string `json:"tailscaleIpv6,omitempty"` - OS string `json:"os"` - Online bool `json:"online"` - LastSeen string `json:"lastSeen,omitempty"` - ExitNode bool `json:"exitNode"` - Tags []string `json:"tags,omitempty"` - Owner string `json:"owner"` - Relay string `json:"relay,omitempty"` - Active bool `json:"active"` - RxBytes int64 `json:"rxBytes"` - TxBytes int64 `json:"txBytes"` + ID string `json:"id"` + Hostname string `json:"hostname"` + DNSName string `json:"dnsName"` + TailscaleIP string `json:"tailscaleIp"` + TailscaleIPv6 string `json:"tailscaleIpv6,omitempty"` + OS string `json:"os"` + Online bool `json:"online"` + LastSeen string `json:"lastSeen,omitempty"` + ExitNode bool `json:"exitNode"` + ExitNodeOption bool `json:"exitNodeOption"` + Tags []string `json:"tags,omitempty"` + Owner string `json:"owner"` + Relay string `json:"relay,omitempty"` + Active bool `json:"active"` + RxBytes int64 `json:"rxBytes"` + TxBytes int64 `json:"txBytes"` } diff --git a/quickshell/Modules/ControlCenter/BuiltinPlugins/TailscaleWidget.qml b/quickshell/Modules/ControlCenter/BuiltinPlugins/TailscaleWidget.qml index 154885e1..d733caea 100644 --- a/quickshell/Modules/ControlCenter/BuiltinPlugins/TailscaleWidget.qml +++ b/quickshell/Modules/ControlCenter/BuiltinPlugins/TailscaleWidget.qml @@ -25,7 +25,14 @@ PluginComponent { } ccWidgetIsActive: TailscaleService.connected - onCcWidgetToggled: {} + onCcWidgetToggled: { + if (!TailscaleService.available) + return; + if (TailscaleService.connected) + TailscaleService.disconnectTailscale(null); + else + TailscaleService.connectTailscale(null); + } ccDetailContent: Component { Rectangle { @@ -88,6 +95,122 @@ PluginComponent { width: parent.width spacing: Theme.spacingS + // Connection status + connect/disconnect. Always shown + // (when available) so the connection can be toggled from + // the detail, including while disconnected. + RowLayout { + width: parent.width + spacing: Theme.spacingS + + Column { + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + spacing: 1 + + StyledText { + text: TailscaleService.connected ? I18n.tr("Connected", "Tailscale connection status: connected") : I18n.tr("Disconnected", "Tailscale connection status: disconnected") + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + } + + StyledText { + visible: TailscaleService.connected && TailscaleService.tailnetName.length > 0 + text: TailscaleService.tailnetName + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + width: parent.width + elide: Text.ElideRight + } + } + + Rectangle { + id: connButton + Layout.alignment: Qt.AlignVCenter + height: 28 + radius: 14 + width: connButtonRow.implicitWidth + Theme.spacingM * 2 + + readonly property bool isConnected: TailscaleService.connected + color: isConnected ? (connButtonArea.containsMouse ? Theme.errorHover : Theme.surfaceLight) : (connButtonArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight) + + Row { + id: connButtonRow + anchors.centerIn: parent + spacing: Theme.spacingXS + + DankIcon { + name: connButton.isConnected ? "link_off" : "link" + size: Theme.fontSizeSmall + color: connButton.isConnected ? Theme.surfaceText : Theme.primary + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: connButton.isConnected ? I18n.tr("Disconnect", "Tailscale disconnect button") : I18n.tr("Connect", "Tailscale connect button") + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + color: connButton.isConnected ? Theme.surfaceText : Theme.primary + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + id: connButtonArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (TailscaleService.connected) + TailscaleService.disconnectTailscale(null); + else + TailscaleService.connectTailscale(null); + } + } + } + } + + // Connection controls: exit node picker + LAN access. + // Only meaningful while the backend is connected. + Column { + id: controlsColumn + width: parent.width + spacing: Theme.spacingS + visible: TailscaleService.connected + + readonly property string noneLabel: I18n.tr("None", "Tailscale exit node: none selected") + + DankDropdown { + width: parent.width + text: I18n.tr("Exit node", "Tailscale exit node selector label") + currentValue: TailscaleService.currentExitNode ? TailscaleService.currentExitNode.hostname : controlsColumn.noneLabel + options: { + const opts = [controlsColumn.noneLabel]; + for (const p of TailscaleService.exitNodeOptions) + opts.push(p.hostname); + return opts; + } + onValueChanged: value => { + if (value === controlsColumn.noneLabel) { + TailscaleService.clearExitNode(null); + return; + } + const peer = TailscaleService.exitNodeOptions.find(p => p.hostname === value); + if (peer) + TailscaleService.setExitNode(peer.id, null); + } + } + + DankToggle { + width: parent.width + text: I18n.tr("Allow LAN access", "Tailscale allow LAN access toggle") + description: I18n.tr("Reach local network devices while using an exit node", "Tailscale allow LAN access description") + visible: TailscaleService.currentExitNode !== null + checked: TailscaleService.exitNodeAllowLanAccess + onToggled: value => TailscaleService.setAllowLanAccess(value, null) + } + } + // Search bar + refresh button RowLayout { width: parent.width diff --git a/quickshell/Services/TailscaleService.qml b/quickshell/Services/TailscaleService.qml index 0ff807ce..68933018 100644 --- a/quickshell/Services/TailscaleService.qml +++ b/quickshell/Services/TailscaleService.qml @@ -41,6 +41,7 @@ Singleton { property string tailnetName: "" property var selfNode: null property var peers: [] + property bool exitNodeAllowLanAccess: false property bool available: false property bool stateInitialized: false @@ -56,6 +57,19 @@ Singleton { readonly property var onlinePeers: allPeersList.filter(p => p.online) + // Peers that may be used as an exit node (offered && approved). Self is + // excluded: a node can never route through itself, and tailscaled rejects it. + readonly property var exitNodeOptions: allPeersList.filter(p => p && p.exitNodeOption && p !== selfNode) + + // The currently selected exit node, or null if none is in use. + readonly property var currentExitNode: { + for (const p of allPeersList) { + if (p && p.exitNode) + return p; + } + return null; + } + readonly property var myPeers: { if (!selfNode) return allPeersList; @@ -141,6 +155,7 @@ Singleton { tailnetName = data.tailnetName || ""; selfNode = data.self || null; peers = data.peers || []; + exitNodeAllowLanAccess = data.exitNodeAllowLanAccess || false; } function refresh(callback) { @@ -152,6 +167,45 @@ Singleton { }); } + // sendAction issues a state-changing request. The backend refreshes and + // broadcasts on success, so subscribers update without an extra getStatus. + function sendAction(method, params, callback) { + if (!available) + return; + DMSService.sendRequest(method, params, response => { + if (response.error) { + root.log.warn(method + " failed: " + response.error); + ToastService.showError(I18n.tr("Tailscale action failed", "Toast shown when a Tailscale write action is rejected"), response.error); + } + if (callback) + callback(response); + }); + } + + function connectTailscale(callback) { + sendAction("tailscale.connect", null, callback); + } + + function disconnectTailscale(callback) { + sendAction("tailscale.disconnect", null, callback); + } + + function setExitNode(id, callback) { + sendAction("tailscale.setExitNode", { + "id": id || "" + }, callback); + } + + function clearExitNode(callback) { + setExitNode("", callback); + } + + function setAllowLanAccess(enabled, callback) { + sendAction("tailscale.setAllowLanAccess", { + "enabled": enabled + }, callback); + } + function isMine(peer) { const myOwner = selfNode ? (selfNode.owner || "") : ""; if (peer.owner === myOwner && myOwner !== "")