hscontrol: route hostname handling through dnsname and NodeStore

Ingest (registration and MapRequest updates) now calls
dnsname.SanitizeHostname directly and lets NodeStore auto-bump on
collision. Admin rename uses dnsname.ValidLabel + SetGivenName so
conflicts are surfaced to the caller instead of silently mutated.

Three duplicate invalidDNSRegex definitions, the old NormaliseHostname
and ValidateHostname helpers, EnsureHostname, InvalidString,
ApplyHostnameFromHostInfo, GivenNameHasBeenChanged, generateGivenName
and EnsureUniqueGivenName are removed along with their tests.
ValidateHostname's username half is retained as ValidateUsername for
users.go.

The SaaS-matching collision rule replaces the random "invalid-xxxxxx"
fallback and the 8-character hash suffix; the empty-input fallback is
the literal "node". TestUpdateHostnameFromClient now exercises the
rewrite end-to-end with awkward macOS/Windows names.

Fixes #3188
Fixes #2926
Fixes #2343
Fixes #2762
Fixes #2449
Updates #2177
Updates #2121
Updates #363
This commit is contained in:
Kristoffer Dalby 2026-04-17 12:06:10 +00:00
parent a2c3ac095e
commit d6dfdc100c
15 changed files with 104 additions and 1707 deletions

View file

@ -7,11 +7,8 @@ import (
"time"
"github.com/google/go-cmp/cmp"
"tailscale.com/tailcfg"
)
const testUnknownNode = "unknown-node"
func TestTailscaleVersionNewerOrEqual(t *testing.T) {
type args struct {
minimum string
@ -799,514 +796,6 @@ over a maximum of 30 hops:
}
}
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()