mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-05-05 12:02:06 -04:00
* 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>
308 lines
6.9 KiB
Go
308 lines
6.9 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"
|
|
)
|
|
|
|
// 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)
|
|
}
|