mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-06-19 17:45:21 -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:
@@ -66,16 +66,17 @@ func convertPeerStatus(ps *ipnstate.PeerStatus, users map[tailcfg.UserID]tailcfg
|
|||||||
}
|
}
|
||||||
|
|
||||||
peer := Peer{
|
peer := Peer{
|
||||||
ID: string(ps.ID),
|
ID: string(ps.ID),
|
||||||
Hostname: hostname,
|
Hostname: hostname,
|
||||||
DNSName: dnsName,
|
DNSName: dnsName,
|
||||||
OS: ps.OS,
|
OS: ps.OS,
|
||||||
Online: ps.Online,
|
Online: ps.Online,
|
||||||
Active: ps.Active,
|
Active: ps.Active,
|
||||||
ExitNode: ps.ExitNode,
|
ExitNode: ps.ExitNode,
|
||||||
Relay: ps.Relay,
|
ExitNodeOption: ps.ExitNodeOption,
|
||||||
RxBytes: ps.RxBytes,
|
Relay: ps.Relay,
|
||||||
TxBytes: ps.TxBytes,
|
RxBytes: ps.RxBytes,
|
||||||
|
TxBytes: ps.TxBytes,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, ip := range ps.TailscaleIPs {
|
for _, ip := range ps.TailscaleIPs {
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
|
|||||||
handleGetStatus(conn, req, manager)
|
handleGetStatus(conn, req, manager)
|
||||||
case "tailscale.refresh":
|
case "tailscale.refresh":
|
||||||
handleRefresh(conn, req, manager)
|
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:
|
default:
|
||||||
models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
|
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()
|
manager.RefreshState()
|
||||||
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "refreshed"})
|
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"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -78,6 +79,63 @@ func TestHandleRefresh(t *testing.T) {
|
|||||||
assert.True(t, resp.Result.Success)
|
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) {
|
func TestHandleRequest_UnknownMethod(t *testing.T) {
|
||||||
m := handlerTestManager()
|
m := handlerTestManager()
|
||||||
defer m.Close()
|
defer m.Close()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"tailscale.com/client/local"
|
"tailscale.com/client/local"
|
||||||
"tailscale.com/ipn"
|
"tailscale.com/ipn"
|
||||||
"tailscale.com/ipn/ipnstate"
|
"tailscale.com/ipn/ipnstate"
|
||||||
|
"tailscale.com/tailcfg"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -22,6 +23,8 @@ const (
|
|||||||
type tailscaleClient interface {
|
type tailscaleClient interface {
|
||||||
WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error)
|
WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error)
|
||||||
Status(ctx context.Context) (*ipnstate.Status, 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.
|
// 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)
|
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.
|
// Manager manages Tailscale state via IPN bus events and subscriber notifications.
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
state *TailscaleState
|
state *TailscaleState
|
||||||
@@ -169,16 +180,36 @@ func (m *Manager) fetchAndBroadcast(ctx context.Context) {
|
|||||||
statusCtx, cancel := context.WithTimeout(ctx, statusTimeout)
|
statusCtx, cancel := context.WithTimeout(ctx, statusTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
status, err := m.client.Status(statusCtx)
|
state, err := m.fetchState(statusCtx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warnf("[Tailscale] Failed to fetch status: %v", err)
|
log.Warnf("[Tailscale] Failed to fetch status: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
state := convertStatus(status)
|
|
||||||
m.updateState(state)
|
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) {
|
func (m *Manager) updateState(state *TailscaleState) {
|
||||||
m.stateMutex.Lock()
|
m.stateMutex.Lock()
|
||||||
m.state = state
|
m.state = state
|
||||||
@@ -266,12 +297,62 @@ func (m *Manager) RefreshState() {
|
|||||||
ctx, cancel := context.WithTimeout(m.ctx, statusTimeout)
|
ctx, cancel := context.WithTimeout(m.ctx, statusTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
status, err := m.client.Status(ctx)
|
state, err := m.fetchState(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warnf("[Tailscale] Failed to refresh state: %v", err)
|
log.Warnf("[Tailscale] Failed to refresh state: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
state := convertStatus(status)
|
|
||||||
m.updateState(state)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,8 +12,16 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"tailscale.com/ipn"
|
"tailscale.com/ipn"
|
||||||
"tailscale.com/ipn/ipnstate"
|
"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.
|
// mockWatcher yields canned Notify events, then returns err or blocks until Close/context cancel.
|
||||||
type mockWatcher struct {
|
type mockWatcher struct {
|
||||||
events []ipn.Notify
|
events []ipn.Notify
|
||||||
@@ -68,8 +76,10 @@ func (w *mockWatcher) Close() error {
|
|||||||
|
|
||||||
// mockClient implements tailscaleClient for testing.
|
// mockClient implements tailscaleClient for testing.
|
||||||
type mockClient struct {
|
type mockClient struct {
|
||||||
watchFn func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error)
|
watchFn func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error)
|
||||||
statusFn func(ctx context.Context) (*ipnstate.Status, 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) {
|
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)
|
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 {
|
func runningStatus() *ipnstate.Status {
|
||||||
return &ipnstate.Status{
|
return &ipnstate.Status{
|
||||||
Version: "1.94.2",
|
Version: "1.94.2",
|
||||||
@@ -296,3 +320,78 @@ func TestManager_RefreshState(t *testing.T) {
|
|||||||
assert.True(t, state.Connected)
|
assert.True(t, state.Connected)
|
||||||
assert.Equal(t, "cachyos", state.Self.Hostname)
|
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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,30 +2,32 @@ package tailscale
|
|||||||
|
|
||||||
// TailscaleState represents the current state of the Tailscale daemon.
|
// TailscaleState represents the current state of the Tailscale daemon.
|
||||||
type TailscaleState struct {
|
type TailscaleState struct {
|
||||||
Connected bool `json:"connected"`
|
Connected bool `json:"connected"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
BackendState string `json:"backendState"`
|
BackendState string `json:"backendState"`
|
||||||
MagicDNSSuffix string `json:"magicDnsSuffix"`
|
MagicDNSSuffix string `json:"magicDnsSuffix"`
|
||||||
TailnetName string `json:"tailnetName"`
|
TailnetName string `json:"tailnetName"`
|
||||||
Self Peer `json:"self"`
|
ExitNodeAllowLANAccess bool `json:"exitNodeAllowLanAccess"`
|
||||||
Peers []Peer `json:"peers"`
|
Self Peer `json:"self"`
|
||||||
|
Peers []Peer `json:"peers"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Peer represents a single node in the Tailscale network.
|
// Peer represents a single node in the Tailscale network.
|
||||||
type Peer struct {
|
type Peer struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Hostname string `json:"hostname"`
|
Hostname string `json:"hostname"`
|
||||||
DNSName string `json:"dnsName"`
|
DNSName string `json:"dnsName"`
|
||||||
TailscaleIP string `json:"tailscaleIp"`
|
TailscaleIP string `json:"tailscaleIp"`
|
||||||
TailscaleIPv6 string `json:"tailscaleIpv6,omitempty"`
|
TailscaleIPv6 string `json:"tailscaleIpv6,omitempty"`
|
||||||
OS string `json:"os"`
|
OS string `json:"os"`
|
||||||
Online bool `json:"online"`
|
Online bool `json:"online"`
|
||||||
LastSeen string `json:"lastSeen,omitempty"`
|
LastSeen string `json:"lastSeen,omitempty"`
|
||||||
ExitNode bool `json:"exitNode"`
|
ExitNode bool `json:"exitNode"`
|
||||||
Tags []string `json:"tags,omitempty"`
|
ExitNodeOption bool `json:"exitNodeOption"`
|
||||||
Owner string `json:"owner"`
|
Tags []string `json:"tags,omitempty"`
|
||||||
Relay string `json:"relay,omitempty"`
|
Owner string `json:"owner"`
|
||||||
Active bool `json:"active"`
|
Relay string `json:"relay,omitempty"`
|
||||||
RxBytes int64 `json:"rxBytes"`
|
Active bool `json:"active"`
|
||||||
TxBytes int64 `json:"txBytes"`
|
RxBytes int64 `json:"rxBytes"`
|
||||||
|
TxBytes int64 `json:"txBytes"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,14 @@ PluginComponent {
|
|||||||
}
|
}
|
||||||
ccWidgetIsActive: TailscaleService.connected
|
ccWidgetIsActive: TailscaleService.connected
|
||||||
|
|
||||||
onCcWidgetToggled: {}
|
onCcWidgetToggled: {
|
||||||
|
if (!TailscaleService.available)
|
||||||
|
return;
|
||||||
|
if (TailscaleService.connected)
|
||||||
|
TailscaleService.disconnectTailscale(null);
|
||||||
|
else
|
||||||
|
TailscaleService.connectTailscale(null);
|
||||||
|
}
|
||||||
|
|
||||||
ccDetailContent: Component {
|
ccDetailContent: Component {
|
||||||
Rectangle {
|
Rectangle {
|
||||||
@@ -88,6 +95,122 @@ PluginComponent {
|
|||||||
width: parent.width
|
width: parent.width
|
||||||
spacing: Theme.spacingS
|
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
|
// Search bar + refresh button
|
||||||
RowLayout {
|
RowLayout {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ Singleton {
|
|||||||
property string tailnetName: ""
|
property string tailnetName: ""
|
||||||
property var selfNode: null
|
property var selfNode: null
|
||||||
property var peers: []
|
property var peers: []
|
||||||
|
property bool exitNodeAllowLanAccess: false
|
||||||
|
|
||||||
property bool available: false
|
property bool available: false
|
||||||
property bool stateInitialized: false
|
property bool stateInitialized: false
|
||||||
@@ -56,6 +57,19 @@ Singleton {
|
|||||||
|
|
||||||
readonly property var onlinePeers: allPeersList.filter(p => p.online)
|
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: {
|
readonly property var myPeers: {
|
||||||
if (!selfNode)
|
if (!selfNode)
|
||||||
return allPeersList;
|
return allPeersList;
|
||||||
@@ -141,6 +155,7 @@ Singleton {
|
|||||||
tailnetName = data.tailnetName || "";
|
tailnetName = data.tailnetName || "";
|
||||||
selfNode = data.self || null;
|
selfNode = data.self || null;
|
||||||
peers = data.peers || [];
|
peers = data.peers || [];
|
||||||
|
exitNodeAllowLanAccess = data.exitNodeAllowLanAccess || false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function refresh(callback) {
|
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) {
|
function isMine(peer) {
|
||||||
const myOwner = selfNode ? (selfNode.owner || "") : "";
|
const myOwner = selfNode ? (selfNode.owner || "") : "";
|
||||||
if (peer.owner === myOwner && myOwner !== "")
|
if (peer.owner === myOwner && myOwner !== "")
|
||||||
|
|||||||
Reference in New Issue
Block a user