1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-06-16 16:15:23 -04:00

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.
This commit is contained in:
Rocho
2026-06-16 15:08:22 +02:00
committed by GitHub
parent 2fd9de5062
commit 988b54515e
8 changed files with 499 additions and 39 deletions
+11 -10
View File
@@ -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 {
@@ -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"})
}
@@ -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()
+85 -4
View File
@@ -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=<id>`,
// 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
}
+101 -2
View File
@@ -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))
}
+24 -22
View File
@@ -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"`
}
@@ -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
+54
View File
@@ -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 !== "")