Files
headscale/hscontrol/util/util_test.go
Kristoffer Dalby cb3b6949ea auth: generalise auth flow and introduce AuthVerdict
Generalise the registration pipeline to a more general auth pipeline
supporting both node registrations and SSH check auth requests.
Rename RegistrationID to AuthID, unexport AuthRequest fields, and
introduce AuthVerdict to unify the auth finish API.

Add the urlParam generic helper for extracting typed URL parameters
from chi routes, used by the new auth request handler.

Updates #1850
2026-02-25 21:28:05 +01:00

1406 lines
35 KiB
Go

package util
import (
"net/netip"
"strings"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"tailscale.com/tailcfg"
)
const testUnknownNode = "unknown-node"
func TestTailscaleVersionNewerOrEqual(t *testing.T) {
type args struct {
minimum string
toCheck string
}
tests := []struct {
name string
args args
want bool
}{
{
name: "is-equal",
args: args{
minimum: "1.56",
toCheck: "1.56",
},
want: true,
},
{
name: "is-newer-head",
args: args{
minimum: "1.56",
toCheck: "head",
},
want: true,
},
{
name: "is-newer-unstable",
args: args{
minimum: "1.56",
toCheck: "unstable",
},
want: true,
},
{
name: "is-newer-patch",
args: args{
minimum: "1.56.1",
toCheck: "1.56.1",
},
want: true,
},
{
name: "is-older-patch-same-minor",
args: args{
minimum: "1.56.1",
toCheck: "1.56.0",
},
want: false,
},
{
name: "is-older-unstable",
args: args{
minimum: "1.56",
toCheck: "1.55",
},
want: false,
},
{
name: "is-older-one-stable",
args: args{
minimum: "1.56",
toCheck: "1.54",
},
want: false,
},
{
name: "is-older-five-stable",
args: args{
minimum: "1.56",
toCheck: "1.46",
},
want: false,
},
{
name: "is-older-patch",
args: args{
minimum: "1.56",
toCheck: "1.48.1",
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := TailscaleVersionNewerOrEqual(tt.args.minimum, tt.args.toCheck); got != tt.want {
t.Errorf("TailscaleVersionNewerThan() = %v, want %v", got, tt.want)
}
})
}
}
func TestParseLoginURLFromCLILogin(t *testing.T) {
tests := []struct {
name string
output string
wantURL string
wantErr string
}{
{
name: "valid https URL",
output: `
To authenticate, visit:
https://headscale.example.com/register/3oYCOZYA2zZmGB4PQ7aHBaMi
Success.`,
wantURL: "https://headscale.example.com/register/3oYCOZYA2zZmGB4PQ7aHBaMi",
wantErr: "",
},
{
name: "valid http URL",
output: `
To authenticate, visit:
http://headscale.example.com/register/3oYCOZYA2zZmGB4PQ7aHBaMi
Success.`,
wantURL: "http://headscale.example.com/register/3oYCOZYA2zZmGB4PQ7aHBaMi",
wantErr: "",
},
{
name: "no URL",
output: `
To authenticate, visit:
Success.`,
wantURL: "",
wantErr: "no URL found",
},
{
name: "multiple URLs",
output: `
To authenticate, visit:
https://headscale.example.com/register/3oYCOZYA2zZmGB4PQ7aHBaMi
To authenticate, visit:
http://headscale.example.com/register/dv1l2k5FackOYl-7-V3mSd_E
Success.`,
wantURL: "",
wantErr: "multiple URLs found: https://headscale.example.com/register/3oYCOZYA2zZmGB4PQ7aHBaMi and http://headscale.example.com/register/dv1l2k5FackOYl-7-V3mSd_E",
},
{
name: "invalid URL",
output: `
To authenticate, visit:
invalid-url
Success.`,
wantURL: "",
wantErr: "no URL found",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotURL, err := ParseLoginURLFromCLILogin(tt.output)
if tt.wantErr != "" {
if err == nil || err.Error() != tt.wantErr {
t.Errorf("ParseLoginURLFromCLILogin() error = %v, wantErr %v", err, tt.wantErr)
}
} else {
if err != nil {
t.Errorf("ParseLoginURLFromCLILogin() error = %v, wantErr %v", err, tt.wantErr)
}
if gotURL.String() != tt.wantURL {
t.Errorf("ParseLoginURLFromCLILogin() = %v, want %v", gotURL, tt.wantURL)
}
}
})
}
}
func TestParseTraceroute(t *testing.T) {
tests := []struct {
name string
input string
want Traceroute
wantErr bool
}{
{
name: "simple successful traceroute",
input: `traceroute to 172.24.0.3 (172.24.0.3), 30 hops max, 46 byte packets
1 ts-head-hk0urr.headscale.net (100.64.0.1) 1.135 ms 0.922 ms 0.619 ms
2 172.24.0.3 (172.24.0.3) 0.593 ms 0.549 ms 0.522 ms`,
want: Traceroute{
Hostname: "172.24.0.3",
IP: netip.MustParseAddr("172.24.0.3"),
Route: []TraceroutePath{
{
Hop: 1,
Hostname: "ts-head-hk0urr.headscale.net",
IP: netip.MustParseAddr("100.64.0.1"),
Latencies: []time.Duration{
1135 * time.Microsecond,
922 * time.Microsecond,
619 * time.Microsecond,
},
},
{
Hop: 2,
Hostname: "172.24.0.3",
IP: netip.MustParseAddr("172.24.0.3"),
Latencies: []time.Duration{
593 * time.Microsecond,
549 * time.Microsecond,
522 * time.Microsecond,
},
},
},
Success: true,
Err: nil,
},
wantErr: false,
},
{
name: "traceroute with timeouts",
input: `traceroute to 8.8.8.8 (8.8.8.8), 30 hops max, 60 byte packets
1 router.local (192.168.1.1) 1.234 ms 1.123 ms 1.121 ms
2 * * *
3 isp-gateway.net (10.0.0.1) 15.678 ms 14.789 ms 15.432 ms
4 8.8.8.8 (8.8.8.8) 20.123 ms 19.876 ms 20.345 ms`,
want: Traceroute{
Hostname: "8.8.8.8",
IP: netip.MustParseAddr("8.8.8.8"),
Route: []TraceroutePath{
{
Hop: 1,
Hostname: "router.local",
IP: netip.MustParseAddr("192.168.1.1"),
Latencies: []time.Duration{
1234 * time.Microsecond,
1123 * time.Microsecond,
1121 * time.Microsecond,
},
},
{
Hop: 2,
Hostname: "*",
},
{
Hop: 3,
Hostname: "isp-gateway.net",
IP: netip.MustParseAddr("10.0.0.1"),
Latencies: []time.Duration{
15678 * time.Microsecond,
14789 * time.Microsecond,
15432 * time.Microsecond,
},
},
{
Hop: 4,
Hostname: "8.8.8.8",
IP: netip.MustParseAddr("8.8.8.8"),
Latencies: []time.Duration{
20123 * time.Microsecond,
19876 * time.Microsecond,
20345 * time.Microsecond,
},
},
},
Success: true,
Err: nil,
},
wantErr: false,
},
{
name: "unsuccessful traceroute",
input: `traceroute to 10.0.0.99 (10.0.0.99), 5 hops max, 60 byte packets
1 router.local (192.168.1.1) 1.234 ms 1.123 ms 1.121 ms
2 * * *
3 * * *
4 * * *
5 * * *`,
want: Traceroute{
Hostname: "10.0.0.99",
IP: netip.MustParseAddr("10.0.0.99"),
Route: []TraceroutePath{
{
Hop: 1,
Hostname: "router.local",
IP: netip.MustParseAddr("192.168.1.1"),
Latencies: []time.Duration{
1234 * time.Microsecond,
1123 * time.Microsecond,
1121 * time.Microsecond,
},
},
{
Hop: 2,
Hostname: "*",
},
{
Hop: 3,
Hostname: "*",
},
{
Hop: 4,
Hostname: "*",
},
{
Hop: 5,
Hostname: "*",
},
},
Success: false,
Err: ErrTracerouteDidNotReach,
},
wantErr: false,
},
{
name: "empty input",
input: "",
want: Traceroute{},
wantErr: true,
},
{
name: "invalid header",
input: "not a valid traceroute output",
want: Traceroute{},
wantErr: true,
},
{
name: "windows tracert format",
input: `Tracing route to google.com [8.8.8.8]
over a maximum of 30 hops:
1 <1 ms <1 ms <1 ms router.local [192.168.1.1]
2 5 ms 4 ms 5 ms 10.0.0.1
3 * * * Request timed out.
4 20 ms 19 ms 21 ms 8.8.8.8`,
want: Traceroute{
Hostname: "google.com",
IP: netip.MustParseAddr("8.8.8.8"),
Route: []TraceroutePath{
{
Hop: 1,
Hostname: "router.local",
IP: netip.MustParseAddr("192.168.1.1"),
Latencies: []time.Duration{
1 * time.Millisecond,
1 * time.Millisecond,
1 * time.Millisecond,
},
},
{
Hop: 2,
Hostname: "10.0.0.1",
IP: netip.MustParseAddr("10.0.0.1"),
Latencies: []time.Duration{
5 * time.Millisecond,
4 * time.Millisecond,
5 * time.Millisecond,
},
},
{
Hop: 3,
Hostname: "*",
},
{
Hop: 4,
Hostname: "8.8.8.8",
IP: netip.MustParseAddr("8.8.8.8"),
Latencies: []time.Duration{
20 * time.Millisecond,
19 * time.Millisecond,
21 * time.Millisecond,
},
},
},
Success: true,
Err: nil,
},
wantErr: false,
},
{
name: "mixed latency formats",
input: `traceroute to 192.168.1.1 (192.168.1.1), 30 hops max, 60 byte packets
1 gateway (192.168.1.1) 0.5 ms * 0.4 ms`,
want: Traceroute{
Hostname: "192.168.1.1",
IP: netip.MustParseAddr("192.168.1.1"),
Route: []TraceroutePath{
{
Hop: 1,
Hostname: "gateway",
IP: netip.MustParseAddr("192.168.1.1"),
Latencies: []time.Duration{
500 * time.Microsecond,
400 * time.Microsecond,
},
},
},
Success: true,
Err: nil,
},
wantErr: false,
},
{
name: "only one latency value",
input: `traceroute to 10.0.0.1 (10.0.0.1), 30 hops max, 60 byte packets
1 10.0.0.1 (10.0.0.1) 1.5 ms`,
want: Traceroute{
Hostname: "10.0.0.1",
IP: netip.MustParseAddr("10.0.0.1"),
Route: []TraceroutePath{
{
Hop: 1,
Hostname: "10.0.0.1",
IP: netip.MustParseAddr("10.0.0.1"),
Latencies: []time.Duration{
1500 * time.Microsecond,
},
},
},
Success: true,
Err: nil,
},
wantErr: false,
},
{
name: "backward compatibility - original format with 3 latencies",
input: `traceroute to 172.24.0.3 (172.24.0.3), 30 hops max, 46 byte packets
1 ts-head-hk0urr.headscale.net (100.64.0.1) 1.135 ms 0.922 ms 0.619 ms
2 172.24.0.3 (172.24.0.3) 0.593 ms 0.549 ms 0.522 ms`,
want: Traceroute{
Hostname: "172.24.0.3",
IP: netip.MustParseAddr("172.24.0.3"),
Route: []TraceroutePath{
{
Hop: 1,
Hostname: "ts-head-hk0urr.headscale.net",
IP: netip.MustParseAddr("100.64.0.1"),
Latencies: []time.Duration{
1135 * time.Microsecond,
922 * time.Microsecond,
619 * time.Microsecond,
},
},
{
Hop: 2,
Hostname: "172.24.0.3",
IP: netip.MustParseAddr("172.24.0.3"),
Latencies: []time.Duration{
593 * time.Microsecond,
549 * time.Microsecond,
522 * time.Microsecond,
},
},
},
Success: true,
Err: nil,
},
wantErr: false,
},
{
name: "two latencies only - common on packet loss",
input: `traceroute to 8.8.8.8 (8.8.8.8), 30 hops max, 60 byte packets
1 gateway (192.168.1.1) 1.2 ms 1.1 ms`,
want: Traceroute{
Hostname: "8.8.8.8",
IP: netip.MustParseAddr("8.8.8.8"),
Route: []TraceroutePath{
{
Hop: 1,
Hostname: "gateway",
IP: netip.MustParseAddr("192.168.1.1"),
Latencies: []time.Duration{
1200 * time.Microsecond,
1100 * time.Microsecond,
},
},
},
Success: false,
Err: ErrTracerouteDidNotReach,
},
wantErr: false,
},
{
name: "hostname without parentheses - some traceroute versions",
input: `traceroute to 8.8.8.8 (8.8.8.8), 30 hops max, 60 byte packets
1 192.168.1.1 1.2 ms 1.1 ms 1.0 ms
2 8.8.8.8 20.1 ms 19.9 ms 20.2 ms`,
want: Traceroute{
Hostname: "8.8.8.8",
IP: netip.MustParseAddr("8.8.8.8"),
Route: []TraceroutePath{
{
Hop: 1,
Hostname: "192.168.1.1",
IP: netip.MustParseAddr("192.168.1.1"),
Latencies: []time.Duration{
1200 * time.Microsecond,
1100 * time.Microsecond,
1000 * time.Microsecond,
},
},
{
Hop: 2,
Hostname: "8.8.8.8",
IP: netip.MustParseAddr("8.8.8.8"),
Latencies: []time.Duration{
20100 * time.Microsecond,
19900 * time.Microsecond,
20200 * time.Microsecond,
},
},
},
Success: true,
Err: nil,
},
wantErr: false,
},
{
name: "ipv6 traceroute",
input: `traceroute to 2001:4860:4860::8888 (2001:4860:4860::8888), 30 hops max, 80 byte packets
1 2001:db8::1 (2001:db8::1) 1.123 ms 1.045 ms 0.987 ms
2 2001:4860:4860::8888 (2001:4860:4860::8888) 15.234 ms 14.876 ms 15.123 ms`,
want: Traceroute{
Hostname: "2001:4860:4860::8888",
IP: netip.MustParseAddr("2001:4860:4860::8888"),
Route: []TraceroutePath{
{
Hop: 1,
Hostname: "2001:db8::1",
IP: netip.MustParseAddr("2001:db8::1"),
Latencies: []time.Duration{
1123 * time.Microsecond,
1045 * time.Microsecond,
987 * time.Microsecond,
},
},
{
Hop: 2,
Hostname: "2001:4860:4860::8888",
IP: netip.MustParseAddr("2001:4860:4860::8888"),
Latencies: []time.Duration{
15234 * time.Microsecond,
14876 * time.Microsecond,
15123 * time.Microsecond,
},
},
},
Success: true,
Err: nil,
},
wantErr: false,
},
{
name: "macos traceroute with extra spacing",
input: `traceroute to google.com (8.8.8.8), 64 hops max, 52 byte packets
1 router.home (192.168.1.1) 2.345 ms 1.234 ms 1.567 ms
2 * * *
3 isp-gw.net (10.1.1.1) 15.234 ms 14.567 ms 15.890 ms
4 google.com (8.8.8.8) 20.123 ms 19.456 ms 20.789 ms`,
want: Traceroute{
Hostname: "google.com",
IP: netip.MustParseAddr("8.8.8.8"),
Route: []TraceroutePath{
{
Hop: 1,
Hostname: "router.home",
IP: netip.MustParseAddr("192.168.1.1"),
Latencies: []time.Duration{
2345 * time.Microsecond,
1234 * time.Microsecond,
1567 * time.Microsecond,
},
},
{
Hop: 2,
Hostname: "*",
},
{
Hop: 3,
Hostname: "isp-gw.net",
IP: netip.MustParseAddr("10.1.1.1"),
Latencies: []time.Duration{
15234 * time.Microsecond,
14567 * time.Microsecond,
15890 * time.Microsecond,
},
},
{
Hop: 4,
Hostname: "google.com",
IP: netip.MustParseAddr("8.8.8.8"),
Latencies: []time.Duration{
20123 * time.Microsecond,
19456 * time.Microsecond,
20789 * time.Microsecond,
},
},
},
Success: true,
Err: nil,
},
wantErr: false,
},
{
name: "busybox traceroute minimal format",
input: `traceroute to 10.0.0.1 (10.0.0.1), 30 hops max, 38 byte packets
1 10.0.0.1 (10.0.0.1) 1.234 ms 1.123 ms 1.456 ms`,
want: Traceroute{
Hostname: "10.0.0.1",
IP: netip.MustParseAddr("10.0.0.1"),
Route: []TraceroutePath{
{
Hop: 1,
Hostname: "10.0.0.1",
IP: netip.MustParseAddr("10.0.0.1"),
Latencies: []time.Duration{
1234 * time.Microsecond,
1123 * time.Microsecond,
1456 * time.Microsecond,
},
},
},
Success: true,
Err: nil,
},
wantErr: false,
},
{
name: "linux traceroute with dns failure fallback to IP",
input: `traceroute to example.com (93.184.216.34), 30 hops max, 60 byte packets
1 192.168.1.1 (192.168.1.1) 1.234 ms 1.123 ms 1.098 ms
2 10.0.0.1 (10.0.0.1) 5.678 ms 5.432 ms 5.321 ms
3 93.184.216.34 (93.184.216.34) 20.123 ms 19.876 ms 20.234 ms`,
want: Traceroute{
Hostname: "example.com",
IP: netip.MustParseAddr("93.184.216.34"),
Route: []TraceroutePath{
{
Hop: 1,
Hostname: "192.168.1.1",
IP: netip.MustParseAddr("192.168.1.1"),
Latencies: []time.Duration{
1234 * time.Microsecond,
1123 * time.Microsecond,
1098 * time.Microsecond,
},
},
{
Hop: 2,
Hostname: "10.0.0.1",
IP: netip.MustParseAddr("10.0.0.1"),
Latencies: []time.Duration{
5678 * time.Microsecond,
5432 * time.Microsecond,
5321 * time.Microsecond,
},
},
{
Hop: 3,
Hostname: "93.184.216.34",
IP: netip.MustParseAddr("93.184.216.34"),
Latencies: []time.Duration{
20123 * time.Microsecond,
19876 * time.Microsecond,
20234 * time.Microsecond,
},
},
},
Success: true,
Err: nil,
},
wantErr: false,
},
{
name: "alpine linux traceroute with ms variations",
input: `traceroute to 1.1.1.1 (1.1.1.1), 30 hops max, 46 byte packets
1 gateway (192.168.0.1) 0.456ms 0.389ms 0.412ms
2 1.1.1.1 (1.1.1.1) 8.234ms 7.987ms 8.123ms`,
want: Traceroute{
Hostname: "1.1.1.1",
IP: netip.MustParseAddr("1.1.1.1"),
Route: []TraceroutePath{
{
Hop: 1,
Hostname: "gateway",
IP: netip.MustParseAddr("192.168.0.1"),
Latencies: []time.Duration{
456 * time.Microsecond,
389 * time.Microsecond,
412 * time.Microsecond,
},
},
{
Hop: 2,
Hostname: "1.1.1.1",
IP: netip.MustParseAddr("1.1.1.1"),
Latencies: []time.Duration{
8234 * time.Microsecond,
7987 * time.Microsecond,
8123 * time.Microsecond,
},
},
},
Success: true,
Err: nil,
},
wantErr: false,
},
{
name: "mixed asterisk and latency values",
input: `traceroute to 8.8.8.8 (8.8.8.8), 30 hops max, 60 byte packets
1 gateway (192.168.1.1) * 1.234 ms 1.123 ms
2 10.0.0.1 (10.0.0.1) 5.678 ms * 5.432 ms
3 8.8.8.8 (8.8.8.8) 20.123 ms 19.876 ms *`,
want: Traceroute{
Hostname: "8.8.8.8",
IP: netip.MustParseAddr("8.8.8.8"),
Route: []TraceroutePath{
{
Hop: 1,
Hostname: "gateway",
IP: netip.MustParseAddr("192.168.1.1"),
Latencies: []time.Duration{
1234 * time.Microsecond,
1123 * time.Microsecond,
},
},
{
Hop: 2,
Hostname: "10.0.0.1",
IP: netip.MustParseAddr("10.0.0.1"),
Latencies: []time.Duration{
5678 * time.Microsecond,
5432 * time.Microsecond,
},
},
{
Hop: 3,
Hostname: "8.8.8.8",
IP: netip.MustParseAddr("8.8.8.8"),
Latencies: []time.Duration{
20123 * time.Microsecond,
19876 * time.Microsecond,
},
},
},
Success: true,
Err: nil,
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseTraceroute(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("ParseTraceroute() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErr {
return
}
// Special handling for error field since it can't be directly compared with cmp.Diff
gotErr := got.Err
wantErr := tt.want.Err
got.Err = nil
tt.want.Err = nil
if diff := cmp.Diff(tt.want, got, IPComparer); diff != "" {
t.Errorf("ParseTraceroute() mismatch (-want +got):\n%s", diff)
}
// Now check error field separately
if (gotErr == nil) != (wantErr == nil) {
t.Errorf("Error field: got %v, want %v", gotErr, wantErr)
} else if gotErr != nil && wantErr != nil && gotErr.Error() != wantErr.Error() {
t.Errorf("Error message: got %q, want %q", gotErr.Error(), wantErr.Error())
}
})
}
}
func TestEnsureHostname(t *testing.T) {
t.Parallel()
tests := []struct {
name string
hostinfo *tailcfg.Hostinfo
machineKey string
nodeKey string
want string
}{
{
name: "valid_hostname",
hostinfo: &tailcfg.Hostinfo{
Hostname: "test-node",
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
want: "test-node",
},
{
name: "nil_hostinfo_with_machine_key",
hostinfo: nil,
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
want: "node-mkey1234",
},
{
name: "nil_hostinfo_with_node_key_only",
hostinfo: nil,
machineKey: "",
nodeKey: "nkey12345678",
want: "node-nkey1234",
},
{
name: "nil_hostinfo_no_keys",
hostinfo: nil,
machineKey: "",
nodeKey: "",
want: testUnknownNode,
},
{
name: "empty_hostname_with_machine_key",
hostinfo: &tailcfg.Hostinfo{
Hostname: "",
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
want: "node-mkey1234",
},
{
name: "empty_hostname_with_node_key_only",
hostinfo: &tailcfg.Hostinfo{
Hostname: "",
},
machineKey: "",
nodeKey: "nkey12345678",
want: "node-nkey1234",
},
{
name: "empty_hostname_no_keys",
hostinfo: &tailcfg.Hostinfo{
Hostname: "",
},
machineKey: "",
nodeKey: "",
want: testUnknownNode,
},
{
name: "hostname_exactly_63_chars",
hostinfo: &tailcfg.Hostinfo{
Hostname: "123456789012345678901234567890123456789012345678901234567890123",
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
want: "123456789012345678901234567890123456789012345678901234567890123",
},
{
name: "hostname_64_chars_truncated",
hostinfo: &tailcfg.Hostinfo{
Hostname: "1234567890123456789012345678901234567890123456789012345678901234",
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
want: "invalid-",
},
{
name: "hostname_very_long_truncated",
hostinfo: &tailcfg.Hostinfo{
Hostname: "test-node-with-very-long-hostname-that-exceeds-dns-label-limits-of-63-characters-and-should-be-truncated",
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
want: "invalid-",
},
{
name: "hostname_with_special_chars",
hostinfo: &tailcfg.Hostinfo{
Hostname: "node-with-special!@#$%",
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
want: "invalid-",
},
{
name: "hostname_with_unicode",
hostinfo: &tailcfg.Hostinfo{
Hostname: "node-ñoño-测试", //nolint:gosmopolitan
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
want: "invalid-",
},
{
name: "short_machine_key",
hostinfo: &tailcfg.Hostinfo{
Hostname: "",
},
machineKey: "short",
nodeKey: "nkey12345678",
want: "node-short",
},
{
name: "short_node_key",
hostinfo: &tailcfg.Hostinfo{
Hostname: "",
},
machineKey: "",
nodeKey: "short",
want: "node-short",
},
{
name: "hostname_with_emoji_replaced",
hostinfo: &tailcfg.Hostinfo{
Hostname: "hostname-with-💩",
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
want: "invalid-",
},
{
name: "hostname_only_emoji_replaced",
hostinfo: &tailcfg.Hostinfo{
Hostname: "🚀",
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
want: "invalid-",
},
{
name: "hostname_with_multiple_emojis_replaced",
hostinfo: &tailcfg.Hostinfo{
Hostname: "node-🎉-🚀-test",
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
want: "invalid-",
},
{
name: "uppercase_to_lowercase",
hostinfo: &tailcfg.Hostinfo{
Hostname: "User2-Host",
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
want: "user2-host",
},
{
name: "underscore_removed",
hostinfo: &tailcfg.Hostinfo{
Hostname: "test_node",
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
want: "invalid-",
},
{
name: "at_sign_invalid",
hostinfo: &tailcfg.Hostinfo{
Hostname: "Test@Host",
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
want: "invalid-",
},
{
name: "chinese_chars_with_dash_invalid",
hostinfo: &tailcfg.Hostinfo{
Hostname: "server-北京-01", //nolint:gosmopolitan
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
want: "invalid-",
},
{
name: "chinese_only_invalid",
hostinfo: &tailcfg.Hostinfo{
Hostname: "我的电脑", //nolint:gosmopolitan
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
want: "invalid-",
},
{
name: "emoji_with_text_invalid",
hostinfo: &tailcfg.Hostinfo{
Hostname: "laptop-🚀",
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
want: "invalid-",
},
{
name: "mixed_chinese_emoji_invalid",
hostinfo: &tailcfg.Hostinfo{
Hostname: "测试💻机器", //nolint:gosmopolitan // intentional i18n test data
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
want: "invalid-",
},
{
name: "only_emojis_invalid",
hostinfo: &tailcfg.Hostinfo{
Hostname: "🎉🎊",
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
want: "invalid-",
},
{
name: "only_at_signs_invalid",
hostinfo: &tailcfg.Hostinfo{
Hostname: "@@@",
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
want: "invalid-",
},
{
name: "starts_with_dash_invalid",
hostinfo: &tailcfg.Hostinfo{
Hostname: "-test",
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
want: "invalid-",
},
{
name: "ends_with_dash_invalid",
hostinfo: &tailcfg.Hostinfo{
Hostname: "test-",
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
want: "invalid-",
},
{
name: "very_long_hostname_truncated",
hostinfo: &tailcfg.Hostinfo{
Hostname: strings.Repeat("t", 70),
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
want: "invalid-",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := EnsureHostname(tt.hostinfo.View(), tt.machineKey, tt.nodeKey)
// For invalid hostnames, we just check the prefix since the random part varies
if strings.HasPrefix(tt.want, "invalid-") {
if !strings.HasPrefix(got, "invalid-") {
t.Errorf("EnsureHostname() = %v, want prefix %v", got, tt.want)
}
} else if got != tt.want {
t.Errorf("EnsureHostname() = %v, want %v", got, tt.want)
}
})
}
}
func TestEnsureHostnameWithHostinfo(t *testing.T) {
t.Parallel()
tests := []struct {
name string
hostinfo *tailcfg.Hostinfo
machineKey string
nodeKey string
wantHostname string
checkHostinfo func(*testing.T, *tailcfg.Hostinfo)
}{
{
name: "valid_hostinfo_unchanged",
hostinfo: &tailcfg.Hostinfo{
Hostname: "test-node",
OS: "linux",
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
wantHostname: "test-node",
checkHostinfo: func(t *testing.T, hi *tailcfg.Hostinfo) { //nolint:thelper
if hi == nil {
t.Fatal("hostinfo should not be nil")
}
if hi.Hostname != "test-node" {
t.Errorf("hostname = %v, want test-node", hi.Hostname)
}
if hi.OS != "linux" {
t.Errorf("OS = %v, want linux", hi.OS)
}
},
},
{
name: "nil_hostinfo_creates_default",
hostinfo: nil,
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
wantHostname: "node-mkey1234",
},
{
name: "empty_hostname_updated",
hostinfo: &tailcfg.Hostinfo{
Hostname: "",
OS: "darwin",
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
wantHostname: "node-mkey1234",
},
{
name: "long_hostname_rejected",
hostinfo: &tailcfg.Hostinfo{
Hostname: "test-node-with-very-long-hostname-that-exceeds-dns-label-limits-of-63-characters",
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
wantHostname: "invalid-",
},
{
name: "nil_hostinfo_node_key_only",
hostinfo: nil,
machineKey: "",
nodeKey: "nkey12345678",
wantHostname: "node-nkey1234",
checkHostinfo: func(t *testing.T, hi *tailcfg.Hostinfo) { //nolint:thelper
if hi == nil {
t.Fatal("hostinfo should not be nil")
}
if hi.Hostname != "node-nkey1234" {
t.Errorf("hostname = %v, want node-nkey1234", hi.Hostname)
}
},
},
{
name: "nil_hostinfo_no_keys",
hostinfo: nil,
machineKey: "",
nodeKey: "",
wantHostname: testUnknownNode,
checkHostinfo: func(t *testing.T, hi *tailcfg.Hostinfo) { //nolint:thelper
if hi == nil {
t.Fatal("hostinfo should not be nil")
}
if hi.Hostname != testUnknownNode {
t.Errorf("hostname = %v, want unknown-node", hi.Hostname)
}
},
},
{
name: "empty_hostname_no_keys",
hostinfo: &tailcfg.Hostinfo{
Hostname: "",
},
machineKey: "",
nodeKey: "",
wantHostname: testUnknownNode,
checkHostinfo: func(t *testing.T, hi *tailcfg.Hostinfo) { //nolint:thelper
if hi == nil {
t.Fatal("hostinfo should not be nil")
}
if hi.Hostname != testUnknownNode {
t.Errorf("hostname = %v, want unknown-node", hi.Hostname)
}
},
},
{
name: "preserves_other_fields",
hostinfo: &tailcfg.Hostinfo{
Hostname: "test",
OS: "windows",
OSVersion: "10.0.19044",
DeviceModel: "test-device",
BackendLogID: "log123",
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
wantHostname: "test",
checkHostinfo: func(t *testing.T, hi *tailcfg.Hostinfo) { //nolint:thelper
if hi == nil {
t.Fatal("hostinfo should not be nil")
}
if hi.Hostname != "test" {
t.Errorf("hostname = %v, want test", hi.Hostname)
}
if hi.OS != "windows" {
t.Errorf("OS = %v, want windows", hi.OS)
}
if hi.OSVersion != "10.0.19044" {
t.Errorf("OSVersion = %v, want 10.0.19044", hi.OSVersion)
}
if hi.DeviceModel != "test-device" {
t.Errorf("DeviceModel = %v, want test-device", hi.DeviceModel)
}
if hi.BackendLogID != "log123" {
t.Errorf("BackendLogID = %v, want log123", hi.BackendLogID)
}
},
},
{
name: "exactly_63_chars_unchanged",
hostinfo: &tailcfg.Hostinfo{
Hostname: "123456789012345678901234567890123456789012345678901234567890123",
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
wantHostname: "123456789012345678901234567890123456789012345678901234567890123",
checkHostinfo: func(t *testing.T, hi *tailcfg.Hostinfo) { //nolint:thelper
if hi == nil {
t.Fatal("hostinfo should not be nil")
}
if len(hi.Hostname) != 63 {
t.Errorf("hostname length = %v, want 63", len(hi.Hostname))
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
gotHostname := EnsureHostname(tt.hostinfo.View(), tt.machineKey, tt.nodeKey)
// For invalid hostnames, we just check the prefix since the random part varies
if strings.HasPrefix(tt.wantHostname, "invalid-") {
if !strings.HasPrefix(gotHostname, "invalid-") {
t.Errorf("EnsureHostname() = %v, want prefix %v", gotHostname, tt.wantHostname)
}
} else if gotHostname != tt.wantHostname {
t.Errorf("EnsureHostname() hostname = %v, want %v", gotHostname, tt.wantHostname)
}
})
}
}
func TestEnsureHostname_DNSLabelLimit(t *testing.T) {
t.Parallel()
testCases := []string{
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
}
for i, hostname := range testCases {
t.Run(cmp.Diff("", ""), func(t *testing.T) {
t.Parallel()
hostinfo := &tailcfg.Hostinfo{Hostname: hostname}
result := EnsureHostname(hostinfo.View(), "mkey", "nkey")
if len(result) > 63 {
t.Errorf("test case %d: hostname length = %d, want <= 63", i, len(result))
}
})
}
}
func TestEnsureHostname_Idempotent(t *testing.T) {
t.Parallel()
originalHostinfo := &tailcfg.Hostinfo{
Hostname: "test-node",
OS: "linux",
}
hostname1 := EnsureHostname(originalHostinfo.View(), "mkey", "nkey")
hostname2 := EnsureHostname(originalHostinfo.View(), "mkey", "nkey")
if hostname1 != hostname2 {
t.Errorf("hostnames not equal: %v != %v", hostname1, hostname2)
}
}
func TestGenerateRegistrationKey(t *testing.T) {
t.Parallel()
tests := []struct {
name string
test func(*testing.T)
}{
{
name: "generates_key_with_correct_prefix",
test: func(t *testing.T) {
t.Helper()
key, err := GenerateRegistrationKey()
if err != nil {
t.Errorf("GenerateRegistrationKey() error = %v", err)
}
if !strings.HasPrefix(key, "hskey-reg-") {
t.Errorf("key does not have expected prefix: %s", key)
}
},
},
{
name: "generates_key_with_correct_length",
test: func(t *testing.T) {
t.Helper()
key, err := GenerateRegistrationKey()
if err != nil {
t.Errorf("GenerateRegistrationKey() error = %v", err)
}
// Expected format: hskey-reg-{64-char-random}
// Total length: 10 (prefix) + 64 (random) = 74
if len(key) != 74 {
t.Errorf("key length = %d, want 74", len(key))
}
},
},
{
name: "generates_unique_keys",
test: func(t *testing.T) {
t.Helper()
key1, err := GenerateRegistrationKey()
if err != nil {
t.Errorf("GenerateRegistrationKey() error = %v", err)
}
key2, err := GenerateRegistrationKey()
if err != nil {
t.Errorf("GenerateRegistrationKey() error = %v", err)
}
if key1 == key2 {
t.Error("generated keys should be unique")
}
},
},
{
name: "key_contains_only_valid_chars",
test: func(t *testing.T) {
t.Helper()
key, err := GenerateRegistrationKey()
if err != nil {
t.Errorf("GenerateRegistrationKey() error = %v", err)
}
// Remove prefix
_, randomPart, found := strings.Cut(key, "hskey-reg-")
if !found {
t.Error("key does not contain expected prefix")
}
// Verify base64 URL-safe characters (A-Za-z0-9_-)
for _, ch := range randomPart {
if (ch < 'A' || ch > 'Z') &&
(ch < 'a' || ch > 'z') &&
(ch < '0' || ch > '9') &&
ch != '_' && ch != '-' {
t.Errorf("key contains invalid character: %c", ch)
}
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
tt.test(t)
})
}
}