mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-05-05 20:12:07 -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>
224 lines
5.8 KiB
Go
224 lines
5.8 KiB
Go
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)
|
|
})
|
|
}
|
|
}
|