mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-05-13 07:42:46 -04:00
feat(tailscale): add Tailscale control center widget (#1875)
* feat(tailscale): add Tailscale control center widget Full-stack Tailscale integration for DMS control center: Backend (Go): - Event-driven manager via WatchIPNBus (no polling) - Reconnects with exponential backoff when tailscaled unavailable - Typed conversion from ipnstate.Status to QML-friendly IPC types - Testable via tailscaleClient interface with mock watcher - Manager cleanup in cleanupManagers() - 19 unit tests Frontend (QML): - TailscaleService with WebSocket subscription - TailscaleWidget with peer list, filter chips, search - Copy-to-clipboard for IPs and DNS names - Daemon lifecycle handling (offline/stopped states) Dependencies: - Add tailscale.com v1.96.1 (official local API client) - Bump Go to 1.26.1 (required by tailscale.com) * cleanups --------- Co-authored-by: bbedward <bbedward@gmail.com>
This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
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 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
|
||||
statusCalled := make(chan struct{}, 4)
|
||||
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) {
|
||||
select {
|
||||
case statusCalled <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return runningStatus(), nil
|
||||
},
|
||||
}
|
||||
|
||||
m := newManager(client)
|
||||
defer m.Close()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return len(statusCalled) > 0
|
||||
}, 2*time.Second, 10*time.Millisecond)
|
||||
|
||||
state := m.GetState()
|
||||
assert.True(t, state.Connected)
|
||||
assert.Equal(t, "Running", state.BackendState)
|
||||
assert.Equal(t, "cachyos", state.Self.Hostname)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user