1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-05-12 07:19:41 -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:
Giorgio De Trane
2026-05-04 19:37:25 +02:00
committed by GitHub
parent 408beb202c
commit d223a74740
19 changed files with 2055 additions and 11 deletions

View File

@@ -21,6 +21,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
serverPlugins "github.com/AvengeMedia/DankMaterialShell/core/internal/server/plugins"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/tailscale"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
serverThemes "github.com/AvengeMedia/DankMaterialShell/core/internal/server/themes"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
@@ -110,6 +111,15 @@ func RouteRequest(conn net.Conn, req models.Request) {
return
}
if strings.HasPrefix(req.Method, "tailscale.") {
if tailscaleManager == nil {
models.RespondError(conn, req.ID, "Tailscale not available")
return
}
tailscale.HandleRequest(conn, req, tailscaleManager)
return
}
if strings.HasPrefix(req.Method, "dwl.") {
if dwlManager == nil {
models.RespondError(conn, req.ID, "dwl manager not initialized")

View File

@@ -31,6 +31,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/tailscale"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/trayrecovery"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
@@ -65,6 +66,7 @@ var waylandManager *wayland.Manager
var bluezManager *bluez.Manager
var appPickerManager *apppicker.Manager
var cupsManager *cups.Manager
var tailscaleManager *tailscale.Manager
var dwlManager *dwl.Manager
var extWorkspaceManager *extworkspace.Manager
var brightnessManager *brightness.Manager
@@ -489,6 +491,10 @@ func getCapabilities() Capabilities {
caps = append(caps, "cups")
}
if tailscaleManager != nil && tailscaleManager.IsAvailable() {
caps = append(caps, "tailscale")
}
if dwlManager != nil {
caps = append(caps, "dwl")
}
@@ -559,6 +565,10 @@ func getServerInfo() ServerInfo {
caps = append(caps, "cups")
}
if tailscaleManager != nil && tailscaleManager.IsAvailable() {
caps = append(caps, "tailscale")
}
if dwlManager != nil {
caps = append(caps, "dwl")
}
@@ -1039,6 +1049,38 @@ func handleSubscribe(conn net.Conn, req models.Request) {
}
}
if shouldSubscribe("tailscale") && tailscaleManager != nil && tailscaleManager.IsAvailable() {
wg.Add(1)
tailscaleChan := tailscaleManager.Subscribe(clientID + "-tailscale")
go func() {
defer wg.Done()
defer tailscaleManager.Unsubscribe(clientID + "-tailscale")
initialState := tailscaleManager.GetState()
select {
case eventChan <- ServiceEvent{Service: "tailscale", Data: initialState}:
case <-stopChan:
return
}
for {
select {
case state, ok := <-tailscaleChan:
if !ok {
return
}
select {
case eventChan <- ServiceEvent{Service: "tailscale", Data: state}:
case <-stopChan:
return
}
case <-stopChan:
return
}
}
}()
}
if shouldSubscribe("dwl") && dwlManager != nil {
wg.Add(1)
dwlChan := dwlManager.Subscribe(clientID + "-dwl")
@@ -1409,11 +1451,22 @@ func cleanupManagers() {
if geoClientInstance != nil {
geoClientInstance.Close()
}
if tailscaleManager != nil {
tailscaleManager.Close()
}
}
func Start(printDocs bool) error {
cleanupStaleSockets()
// Tailscale manager always starts — reconnects internally via WatchIPNBus.
// The capability is only advertised once tailscaled is reachable; the
// callback wakes capability subscribers so QML clients see it transition.
tailscaleManager = tailscale.NewManager("")
tailscaleManager.SetAvailabilityCallback(func(bool) {
notifyCapabilityChange()
})
socketPath := GetSocketPath()
os.Remove(socketPath)

View File

@@ -0,0 +1,135 @@
package tailscale
import (
"fmt"
"sort"
"strings"
"time"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
)
// convertStatus converts an ipnstate.Status into our TailscaleState IPC type.
func convertStatus(status *ipnstate.Status) *TailscaleState {
connected := status.BackendState == "Running"
state := &TailscaleState{
Connected: connected,
BackendState: status.BackendState,
Version: status.Version,
}
if status.CurrentTailnet != nil {
state.TailnetName = status.CurrentTailnet.Name
state.MagicDNSSuffix = status.CurrentTailnet.MagicDNSSuffix
}
if !connected {
return state
}
users := status.User
if status.Self != nil {
state.Self = convertPeerStatus(status.Self, users)
}
if len(status.Peer) > 0 {
peers := make([]Peer, 0, len(status.Peer))
for _, ps := range status.Peer {
peers = append(peers, convertPeerStatus(ps, users))
}
sort.Slice(peers, func(i, j int) bool {
if peers[i].Online != peers[j].Online {
return peers[i].Online
}
return strings.ToLower(peers[i].Hostname) < strings.ToLower(peers[j].Hostname)
})
state.Peers = peers
}
return state
}
// convertPeerStatus converts an ipnstate.PeerStatus into our Peer IPC type.
func convertPeerStatus(ps *ipnstate.PeerStatus, users map[tailcfg.UserID]tailcfg.UserProfile) Peer {
dnsName := strings.TrimSuffix(ps.DNSName, ".")
// DNSName first label is unique per node; OS HostName is not.
hostname := ps.HostName
if dnsName != "" {
parts := strings.SplitN(dnsName, ".", 2)
if len(parts) > 0 && parts[0] != "" {
hostname = parts[0]
}
}
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,
}
for _, ip := range ps.TailscaleIPs {
if ip.Is4() {
if peer.TailscaleIP == "" {
peer.TailscaleIP = ip.String()
}
} else {
if peer.TailscaleIPv6 == "" {
peer.TailscaleIPv6 = ip.String()
}
}
}
if ps.Tags != nil {
peer.Tags = ps.Tags.AsSlice()
}
if ps.UserID > 0 {
if user, ok := users[ps.UserID]; ok {
peer.Owner = user.LoginName
}
}
if !ps.LastSeen.IsZero() {
peer.LastSeen = formatRelativeTime(ps.LastSeen)
}
return peer
}
// formatRelativeTime formats a time as a human-readable relative duration (e.g., "5 minutes ago").
func formatRelativeTime(t time.Time) string {
d := time.Since(t)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
m := int(d.Minutes())
if m == 1 {
return "1 minute ago"
}
return fmt.Sprintf("%d minutes ago", m)
case d < 24*time.Hour:
h := int(d.Hours())
if h == 1 {
return "1 hour ago"
}
return fmt.Sprintf("%d hours ago", h)
default:
days := int(d.Hours() / 24)
if days == 1 {
return "1 day ago"
}
return fmt.Sprintf("%d days ago", days)
}
}

View File

@@ -0,0 +1,223 @@
package tailscale
import (
"net/netip"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go4.org/mem"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"tailscale.com/types/views"
)
func makeTestStatus() *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{
ID: "node1",
HostName: "cachyos",
DNSName: "cachyos.example.ts.net.",
OS: "linux",
TailscaleIPs: []netip.Addr{
netip.MustParseAddr("100.85.254.40"),
netip.MustParseAddr("fd7a:115c:a1e0::1"),
},
Online: true,
UserID: 12345,
},
Peer: map[key.NodePublic]*ipnstate.PeerStatus{
key.NodePublicFromRaw32(mem.B(make([]byte, 32))): {
ID: "node2",
HostName: "thinkpad-x390",
DNSName: "thinkpad-x390.example.ts.net.",
OS: "linux",
TailscaleIPs: []netip.Addr{
netip.MustParseAddr("100.97.21.17"),
netip.MustParseAddr("fd7a:115c:a1e0::2"),
},
Online: true,
Active: true,
Relay: "fra",
RxBytes: 1024,
TxBytes: 2048,
UserID: 12345,
ExitNode: false,
LastSeen: time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC),
},
},
User: map[tailcfg.UserID]tailcfg.UserProfile{
12345: {
ID: 12345,
LoginName: "user@example.com",
DisplayName: "User",
},
},
}
}
func TestConvertStatus_Running(t *testing.T) {
status := makeTestStatus()
state := convertStatus(status)
require.NotNil(t, state)
assert.True(t, state.Connected)
assert.Equal(t, "1.94.2", state.Version)
assert.Equal(t, "Running", state.BackendState)
assert.Equal(t, "example.ts.net", state.MagicDNSSuffix)
assert.Equal(t, "user@example.com", state.TailnetName)
// Self
assert.Equal(t, "cachyos", state.Self.Hostname)
assert.Equal(t, "cachyos.example.ts.net", state.Self.DNSName)
assert.Equal(t, "100.85.254.40", state.Self.TailscaleIP)
assert.Equal(t, "fd7a:115c:a1e0::1", state.Self.TailscaleIPv6)
assert.Equal(t, "linux", state.Self.OS)
assert.True(t, state.Self.Online)
// Peers
require.Len(t, state.Peers, 1)
peer := state.Peers[0]
assert.Equal(t, "thinkpad-x390", peer.Hostname)
assert.Equal(t, "100.97.21.17", peer.TailscaleIP)
assert.Equal(t, "fra", peer.Relay)
assert.Equal(t, "user@example.com", peer.Owner)
assert.Equal(t, int64(1024), peer.RxBytes)
assert.True(t, peer.Online)
}
func TestConvertStatus_NotRunning(t *testing.T) {
status := &ipnstate.Status{
BackendState: "Stopped",
}
state := convertStatus(status)
assert.False(t, state.Connected)
assert.Equal(t, "Stopped", state.BackendState)
assert.Empty(t, state.Peers)
}
func TestConvertStatus_NilSelf(t *testing.T) {
status := &ipnstate.Status{
BackendState: "Running",
}
state := convertStatus(status)
assert.True(t, state.Connected)
assert.Equal(t, Peer{}, state.Self)
}
func TestConvertPeerStatus_Tags(t *testing.T) {
tags := views.SliceOf([]string{"tag:k8s", "tag:server"})
ps := &ipnstate.PeerStatus{
ID: "node3",
HostName: "k8s-node",
DNSName: "k8s-node.example.ts.net.",
OS: "linux",
Online: false,
Tags: &tags,
}
users := map[tailcfg.UserID]tailcfg.UserProfile{}
peer := convertPeerStatus(ps, users)
assert.Equal(t, "k8s-node", peer.Hostname)
assert.Contains(t, peer.Tags, "tag:k8s")
assert.Contains(t, peer.Tags, "tag:server")
assert.Equal(t, "", peer.Owner)
}
func TestConvertPeerStatus_HostnameFromDNS(t *testing.T) {
// Hostname should always be derived from DNSName, not OS HostName
ps := &ipnstate.PeerStatus{
HostName: "GL-MT6000",
DNSName: "gl-mt6000-2.example.ts.net.",
}
users := map[tailcfg.UserID]tailcfg.UserProfile{}
peer := convertPeerStatus(ps, users)
assert.Equal(t, "gl-mt6000-2", peer.Hostname)
}
func TestConvertPeerStatus_FallbackToHostName(t *testing.T) {
// When DNSName is empty, fall back to OS HostName
ps := &ipnstate.PeerStatus{
HostName: "my-device",
}
users := map[tailcfg.UserID]tailcfg.UserProfile{}
peer := convertPeerStatus(ps, users)
assert.Equal(t, "my-device", peer.Hostname)
}
func TestConvertPeerStatus_LastSeen(t *testing.T) {
ps := &ipnstate.PeerStatus{
HostName: "recent-node",
LastSeen: time.Now().Add(-5 * time.Minute),
}
users := map[tailcfg.UserID]tailcfg.UserProfile{}
peer := convertPeerStatus(ps, users)
assert.NotEmpty(t, peer.LastSeen)
assert.Contains(t, peer.LastSeen, "minutes ago")
}
func TestPeerSorting(t *testing.T) {
b1 := make([]byte, 32)
b2 := make([]byte, 32)
b2[0] = 1
b3 := make([]byte, 32)
b3[0] = 2
k1 := key.NodePublicFromRaw32(mem.B(b1))
k2 := key.NodePublicFromRaw32(mem.B(b2))
k3 := key.NodePublicFromRaw32(mem.B(b3))
status := &ipnstate.Status{
BackendState: "Running",
Peer: map[key.NodePublic]*ipnstate.PeerStatus{
k1: {HostName: "zebra", Online: false},
k2: {HostName: "alpha", Online: true},
k3: {HostName: "beta", Online: true},
},
}
state := convertStatus(status)
// Online peers first (alpha, beta), then offline (zebra)
require.Len(t, state.Peers, 3)
assert.True(t, state.Peers[0].Online)
assert.True(t, state.Peers[1].Online)
assert.False(t, state.Peers[2].Online)
assert.Equal(t, "alpha", state.Peers[0].Hostname)
assert.Equal(t, "beta", state.Peers[1].Hostname)
assert.Equal(t, "zebra", state.Peers[2].Hostname)
}
func TestFormatRelativeTime(t *testing.T) {
tests := []struct {
name string
duration string
contains string
}{
{"minutes", "5m", "minutes ago"},
{"hours", "3h", "hours ago"},
{"days", "48h", "days ago"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
d, _ := time.ParseDuration(tt.duration)
result := formatRelativeTime(time.Now().Add(-d))
assert.Contains(t, result, tt.contains)
})
}
}

View File

@@ -0,0 +1,30 @@
package tailscale
import (
"fmt"
"net"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
)
// HandleRequest routes an IPC request to the appropriate handler.
func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
switch req.Method {
case "tailscale.getStatus":
handleGetStatus(conn, req, manager)
case "tailscale.refresh":
handleRefresh(conn, req, manager)
default:
models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
}
}
func handleGetStatus(conn net.Conn, req models.Request, manager *Manager) {
state := manager.GetState()
models.Respond(conn, req.ID, state)
}
func handleRefresh(conn net.Conn, req models.Request, manager *Manager) {
manager.RefreshState()
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "refreshed"})
}

View File

@@ -0,0 +1,97 @@
package tailscale
import (
"bytes"
"context"
"encoding/json"
"net"
"testing"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
)
type mockConn struct {
*bytes.Buffer
}
func (m *mockConn) Close() error { return nil }
func (m *mockConn) LocalAddr() net.Addr { return nil }
func (m *mockConn) RemoteAddr() net.Addr { return nil }
func (m *mockConn) SetDeadline(t time.Time) error { return nil }
func (m *mockConn) SetReadDeadline(t time.Time) error { return nil }
func (m *mockConn) SetWriteDeadline(t time.Time) error { return nil }
func handlerTestManager() *Manager {
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)
m.RefreshState()
return m
}
func TestHandleGetStatus(t *testing.T) {
m := handlerTestManager()
defer m.Close()
buf := &bytes.Buffer{}
conn := &mockConn{Buffer: buf}
req := models.Request{ID: 1, Method: "tailscale.getStatus"}
handleGetStatus(conn, req, m)
var resp models.Response[TailscaleState]
err := json.NewDecoder(buf).Decode(&resp)
require.NoError(t, err)
assert.Equal(t, 1, resp.ID)
assert.NotNil(t, resp.Result)
assert.True(t, resp.Result.Connected)
assert.Equal(t, "cachyos", resp.Result.Self.Hostname)
}
func TestHandleRefresh(t *testing.T) {
m := handlerTestManager()
defer m.Close()
buf := &bytes.Buffer{}
conn := &mockConn{Buffer: buf}
req := models.Request{ID: 1, Method: "tailscale.refresh"}
handleRefresh(conn, req, m)
var resp models.Response[models.SuccessResult]
err := json.NewDecoder(buf).Decode(&resp)
require.NoError(t, err)
assert.Equal(t, 1, resp.ID)
assert.NotNil(t, resp.Result)
assert.True(t, resp.Result.Success)
}
func TestHandleRequest_UnknownMethod(t *testing.T) {
m := handlerTestManager()
defer m.Close()
buf := &bytes.Buffer{}
conn := &mockConn{Buffer: buf}
req := models.Request{ID: 1, Method: "tailscale.unknownMethod"}
HandleRequest(conn, req, m)
var resp models.Response[any]
err := json.NewDecoder(buf).Decode(&resp)
require.NoError(t, err)
assert.Nil(t, resp.Result)
assert.NotEmpty(t, resp.Error)
assert.Contains(t, resp.Error, "unknown method")
}

View File

@@ -0,0 +1,277 @@
package tailscale
import (
"context"
"sync"
"sync/atomic"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
"tailscale.com/client/local"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
)
const (
statusTimeout = 3 * time.Second
debounceWindow = 150 * time.Millisecond
)
// tailscaleClient abstracts the Tailscale local API for testing.
type tailscaleClient interface {
WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error)
Status(ctx context.Context) (*ipnstate.Status, error)
}
// ipnBusWatcher abstracts the IPN bus watcher for testing.
type ipnBusWatcher interface {
Next() (ipn.Notify, error)
Close() error
}
// localClientWrapper wraps local.Client to satisfy tailscaleClient.
type localClientWrapper struct {
client *local.Client
}
func (w *localClientWrapper) WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
return w.client.WatchIPNBus(ctx, mask)
}
func (w *localClientWrapper) Status(ctx context.Context) (*ipnstate.Status, error) {
return w.client.Status(ctx)
}
// Manager manages Tailscale state via IPN bus events and subscriber notifications.
type Manager struct {
state *TailscaleState
stateMutex sync.RWMutex
subscribers syncmap.Map[string, chan TailscaleState]
client tailscaleClient
ctx context.Context
cancel context.CancelFunc
watchWG sync.WaitGroup
closed atomic.Bool
dirty chan struct{}
available atomic.Bool
availabilityCallback atomic.Pointer[func(bool)]
}
// NewManager creates a new Tailscale manager and starts watching the IPN bus.
func NewManager(socketPath string) *Manager {
lc := &local.Client{Socket: socketPath}
return newManager(&localClientWrapper{client: lc})
}
func newManager(client tailscaleClient) *Manager {
ctx, cancel := context.WithCancel(context.Background())
m := &Manager{
state: &TailscaleState{},
client: client,
ctx: ctx,
cancel: cancel,
dirty: make(chan struct{}, 1),
}
m.watchWG.Add(2)
go m.watchLoop(ctx)
go m.debounceLoop(ctx)
return m
}
func (m *Manager) watchLoop(ctx context.Context) {
defer m.watchWG.Done()
mask := ipn.NotifyInitialState | ipn.NotifyInitialNetMap | ipn.NotifyRateLimit
backoff := time.Second
unreachableSent := false
for {
select {
case <-ctx.Done():
return
default:
}
watcher, err := m.client.WatchIPNBus(ctx, mask)
if err != nil {
if !unreachableSent {
m.updateState(&TailscaleState{Connected: false, BackendState: "Unreachable"})
unreachableSent = true
}
select {
case <-ctx.Done():
return
case <-time.After(backoff):
}
backoff = min(backoff*2, 30*time.Second)
continue
}
unreachableSent = false
backoff = time.Second
log.Info("[Tailscale] Connected to IPN bus")
m.markAvailable()
for {
notify, err := watcher.Next()
if err != nil {
log.Warnf("[Tailscale] IPN bus error: %v", err)
break
}
if notify.State == nil && notify.NetMap == nil {
continue
}
select {
case m.dirty <- struct{}{}:
default:
}
}
watcher.Close()
}
}
// debounceLoop coalesces rapid bus notifications into a single Status RPC
// per debounceWindow, since NetMap events can fire many times per second
// on busy tailnets.
func (m *Manager) debounceLoop(ctx context.Context) {
defer m.watchWG.Done()
for {
select {
case <-ctx.Done():
return
case <-m.dirty:
}
timer := time.NewTimer(debounceWindow)
collecting := true
for collecting {
select {
case <-ctx.Done():
timer.Stop()
return
case <-m.dirty:
case <-timer.C:
collecting = false
}
}
m.fetchAndBroadcast(ctx)
}
}
func (m *Manager) fetchAndBroadcast(ctx context.Context) {
statusCtx, cancel := context.WithTimeout(ctx, statusTimeout)
defer cancel()
status, err := m.client.Status(statusCtx)
if err != nil {
log.Warnf("[Tailscale] Failed to fetch status: %v", err)
return
}
state := convertStatus(status)
m.updateState(state)
}
func (m *Manager) updateState(state *TailscaleState) {
m.stateMutex.Lock()
m.state = state
m.stateMutex.Unlock()
m.broadcastState(*state)
}
func (m *Manager) broadcastState(state TailscaleState) {
if m.closed.Load() {
return
}
m.subscribers.Range(func(key string, ch chan TailscaleState) bool {
select {
case ch <- state:
default:
}
return true
})
}
// IsAvailable reports whether tailscaled has been reachable via the IPN bus
// at least once since the manager started. False means tailscaled appears
// to not be installed or has never been running.
func (m *Manager) IsAvailable() bool {
return m.available.Load()
}
// SetAvailabilityCallback registers a callback fired when the manager
// transitions from unavailable to available. Replaces any previously set
// callback. Must be set before the manager has a chance to detect tailscaled.
func (m *Manager) SetAvailabilityCallback(cb func(bool)) {
m.availabilityCallback.Store(&cb)
}
func (m *Manager) markAvailable() {
if m.available.Swap(true) {
return
}
if cb := m.availabilityCallback.Load(); cb != nil {
(*cb)(true)
}
}
// GetState returns a copy of the current Tailscale state.
func (m *Manager) GetState() TailscaleState {
m.stateMutex.RLock()
defer m.stateMutex.RUnlock()
if m.state == nil {
return TailscaleState{}
}
return *m.state
}
// Subscribe creates a buffered channel for the given client ID.
func (m *Manager) Subscribe(clientID string) chan TailscaleState {
ch := make(chan TailscaleState, 64)
m.subscribers.Store(clientID, ch)
return ch
}
// Unsubscribe removes and closes the subscriber channel.
func (m *Manager) Unsubscribe(clientID string) {
if val, ok := m.subscribers.LoadAndDelete(clientID); ok {
close(val)
}
}
// Close stops the watch loop and closes all subscriber channels.
func (m *Manager) Close() {
m.closed.Store(true)
m.cancel()
m.watchWG.Wait()
m.subscribers.Range(func(key string, ch chan TailscaleState) bool {
close(ch)
m.subscribers.Delete(key)
return true
})
}
// RefreshState triggers an immediate status fetch and broadcasts.
func (m *Manager) RefreshState() {
ctx, cancel := context.WithTimeout(m.ctx, statusTimeout)
defer cancel()
status, err := m.client.Status(ctx)
if err != nil {
log.Warnf("[Tailscale] Failed to refresh state: %v", err)
return
}
state := convertStatus(status)
m.updateState(state)
}

View File

@@ -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)
}

View File

@@ -0,0 +1,31 @@
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"`
}
// 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"`
}