mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-06-16 16:15:23 -04:00
988b54515e
* 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.
398 lines
9.8 KiB
Go
398 lines
9.8 KiB
Go
package tailscale
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"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
|
|
idx int
|
|
err error
|
|
done chan struct{}
|
|
ctx context.Context
|
|
mu sync.Mutex
|
|
closed bool
|
|
}
|
|
|
|
func newMockWatcher(ctx context.Context, events []ipn.Notify, err error) *mockWatcher {
|
|
return &mockWatcher{
|
|
events: events,
|
|
err: err,
|
|
done: make(chan struct{}),
|
|
ctx: ctx,
|
|
}
|
|
}
|
|
|
|
func (w *mockWatcher) Next() (ipn.Notify, error) {
|
|
w.mu.Lock()
|
|
if w.idx < len(w.events) {
|
|
n := w.events[w.idx]
|
|
w.idx++
|
|
w.mu.Unlock()
|
|
return n, nil
|
|
}
|
|
if w.err != nil {
|
|
err := w.err
|
|
w.mu.Unlock()
|
|
return ipn.Notify{}, err
|
|
}
|
|
w.mu.Unlock()
|
|
select {
|
|
case <-w.done:
|
|
return ipn.Notify{}, fmt.Errorf("watcher closed")
|
|
case <-w.ctx.Done():
|
|
return ipn.Notify{}, w.ctx.Err()
|
|
}
|
|
}
|
|
|
|
func (w *mockWatcher) Close() error {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
if !w.closed {
|
|
w.closed = true
|
|
close(w.done)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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)
|
|
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) {
|
|
return c.watchFn(ctx, mask)
|
|
}
|
|
|
|
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",
|
|
BackendState: "Running",
|
|
MagicDNSSuffix: "example.ts.net",
|
|
CurrentTailnet: &ipnstate.TailnetStatus{
|
|
Name: "user@example.com",
|
|
MagicDNSSuffix: "example.ts.net",
|
|
},
|
|
Self: &ipnstate.PeerStatus{
|
|
HostName: "cachyos",
|
|
DNSName: "cachyos.example.ts.net.",
|
|
OS: "linux",
|
|
Online: true,
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestWatchLoop_StateChange(t *testing.T) {
|
|
stateVal := ipn.Running
|
|
var watchCount int32
|
|
|
|
client := &mockClient{
|
|
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
|
watchCount++
|
|
if watchCount == 1 {
|
|
return newMockWatcher(ctx,
|
|
[]ipn.Notify{{State: &stateVal}},
|
|
fmt.Errorf("done"),
|
|
), nil
|
|
}
|
|
return newMockWatcher(ctx, nil, nil), nil
|
|
},
|
|
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
|
|
return runningStatus(), nil
|
|
},
|
|
}
|
|
|
|
m := newManager(client)
|
|
defer m.Close()
|
|
|
|
require.Eventually(t, func() bool {
|
|
s := m.GetState()
|
|
return s.Connected && s.BackendState == "Running" && s.Self.Hostname == "cachyos"
|
|
}, 2*time.Second, 10*time.Millisecond)
|
|
}
|
|
|
|
func TestWatchLoop_CoalescesNotifies(t *testing.T) {
|
|
stateVal := ipn.Running
|
|
var statusCalls atomic.Int32
|
|
|
|
notifies := make([]ipn.Notify, 0, 20)
|
|
for range 20 {
|
|
notifies = append(notifies, ipn.Notify{State: &stateVal})
|
|
}
|
|
|
|
client := &mockClient{
|
|
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
|
return newMockWatcher(ctx, notifies, nil), nil
|
|
},
|
|
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
|
|
statusCalls.Add(1)
|
|
return runningStatus(), nil
|
|
},
|
|
}
|
|
|
|
m := newManager(client)
|
|
defer m.Close()
|
|
|
|
// Wait for the debounce window to expire plus margin so the burst settles.
|
|
time.Sleep(debounceWindow + 100*time.Millisecond)
|
|
|
|
calls := statusCalls.Load()
|
|
assert.Less(t, int(calls), 5,
|
|
"20 rapid notifies should coalesce to a small number of Status RPCs, got %d", calls)
|
|
assert.Greater(t, int(calls), 0, "expected at least one Status RPC")
|
|
}
|
|
|
|
func TestWatchLoop_Reconnect(t *testing.T) {
|
|
watchCalled := make(chan struct{}, 4)
|
|
|
|
client := &mockClient{
|
|
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
|
select {
|
|
case watchCalled <- struct{}{}:
|
|
default:
|
|
}
|
|
if len(watchCalled) <= 1 {
|
|
return nil, fmt.Errorf("connection refused")
|
|
}
|
|
return newMockWatcher(ctx, nil, nil), nil
|
|
},
|
|
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
|
|
return runningStatus(), nil
|
|
},
|
|
}
|
|
|
|
m := newManager(client)
|
|
defer m.Close()
|
|
|
|
require.Eventually(t, func() bool {
|
|
state := m.GetState()
|
|
return state.BackendState == "Unreachable"
|
|
}, 2*time.Second, 10*time.Millisecond)
|
|
|
|
require.Eventually(t, func() bool {
|
|
return len(watchCalled) >= 2
|
|
}, 3*time.Second, 50*time.Millisecond)
|
|
}
|
|
|
|
func TestManager_Subscribe(t *testing.T) {
|
|
client := &mockClient{
|
|
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
|
<-ctx.Done()
|
|
return nil, ctx.Err()
|
|
},
|
|
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
|
|
return runningStatus(), nil
|
|
},
|
|
}
|
|
|
|
m := newManager(client)
|
|
defer m.Close()
|
|
|
|
ch := m.Subscribe("test-1")
|
|
assert.NotNil(t, ch)
|
|
|
|
ch2 := m.Subscribe("test-2")
|
|
assert.NotNil(t, ch2)
|
|
|
|
m.Unsubscribe("test-1")
|
|
m.Unsubscribe("test-2")
|
|
}
|
|
|
|
func TestManager_Close(t *testing.T) {
|
|
client := &mockClient{
|
|
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
|
<-ctx.Done()
|
|
return nil, ctx.Err()
|
|
},
|
|
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
|
|
return runningStatus(), nil
|
|
},
|
|
}
|
|
|
|
m := newManager(client)
|
|
|
|
ch := m.Subscribe("test")
|
|
assert.NotNil(t, ch)
|
|
|
|
assert.NotPanics(t, func() {
|
|
m.Close()
|
|
})
|
|
}
|
|
|
|
func TestManager_Availability(t *testing.T) {
|
|
var watchAttempts atomic.Int32
|
|
|
|
client := &mockClient{
|
|
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
|
n := watchAttempts.Add(1)
|
|
if n == 1 {
|
|
return nil, fmt.Errorf("tailscaled socket not found")
|
|
}
|
|
return newMockWatcher(ctx, nil, nil), nil
|
|
},
|
|
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
|
|
return runningStatus(), nil
|
|
},
|
|
}
|
|
|
|
m := newManager(client)
|
|
defer m.Close()
|
|
|
|
cbFired := make(chan bool, 1)
|
|
m.SetAvailabilityCallback(func(b bool) {
|
|
select {
|
|
case cbFired <- b:
|
|
default:
|
|
}
|
|
})
|
|
|
|
assert.False(t, m.IsAvailable())
|
|
|
|
require.Eventually(t, func() bool {
|
|
return m.IsAvailable()
|
|
}, 3*time.Second, 50*time.Millisecond)
|
|
|
|
select {
|
|
case b := <-cbFired:
|
|
assert.True(t, b)
|
|
case <-time.After(time.Second):
|
|
t.Fatal("availability callback did not fire")
|
|
}
|
|
}
|
|
|
|
func TestManager_RefreshState(t *testing.T) {
|
|
client := &mockClient{
|
|
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
|
<-ctx.Done()
|
|
return nil, ctx.Err()
|
|
},
|
|
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
|
|
return runningStatus(), nil
|
|
},
|
|
}
|
|
|
|
m := newManager(client)
|
|
defer m.Close()
|
|
|
|
m.RefreshState()
|
|
|
|
state := m.GetState()
|
|
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))
|
|
}
|