servertest, state: add node name golden data

Labels under taken hostnames, case-only renames, hostname changes, long
hostnames and a Service's name; API, peer and self per step, and State
labels as a restart would load them.
This commit is contained in:
Kristoffer Dalby committed 2026-10-27 21:31:35 +00:00
1 parent 376390ed7c
commit 791ad5fd2c
6 files changed
+34106

No files matched your search

@@ -428,6 +428,8 @@ func (r *stepReplay) hsDeviceRoutes(t *testing.T, alias string) (deviceRoutes, b
// Keys and IDs are the capture's labels.
type saasDevice struct {
ID string `json:"id"`
Name string `json:"name"`
Hostname string `json:"hostname"`
NodeKey string `json:"nodeKey"`
MachineKey string `json:"machineKey"`
Addresses []string `json:"addresses"`
+509
View File
@@ -0,0 +1,509 @@
// This file replays node name captures from Tailscale SaaS and compares each
// device's MagicDNS label with what SaaS showed.
//
// Test data source: testdata/steps/{names,names-svc}.hujson, loaded by name.
// names-svc ran on names' tailnet right after it, reusing its viewer and
// keys, so the two replay in one server. TestNodeNameReplayCompat in
// ../state replays names through State.
// Source format: github.com/juanfont/headscale/hscontrol/types/testcapture
package servertest_test
import (
"bytes"
"encoding/json"
"fmt"
"maps"
"net/http"
"slices"
"strconv"
"strings"
"testing"
"github.com/juanfont/headscale/hscontrol/servertest"
"github.com/juanfont/headscale/hscontrol/types/testcapture"
"github.com/stretchr/testify/require"
"tailscale.com/tailcfg"
"tailscale.com/types/netmap"
)
// nameSkipReasons lists checks where headscale differs from SaaS, keyed
// <test_id>/<step>/<node or action index>/<check>. A listed check that
// matches fails, so a fix must delete its entry.
var nameSkipReasons = map[string]string{
"names/N1/n63b/label": labelSuffixOverflow,
"names/N2/n63b/label": labelSuffixOverflow,
"names/N3/n63b/label": labelSuffixOverflow,
"names/N4/n63b/label": labelSuffixOverflow,
"names/N5/n63b/label": labelSuffixOverflow,
"names/N6/n63b/label": labelSuffixOverflow,
"names/N7/n63b/label": labelSuffixOverflow,
"names/N8/n63b/label": labelSuffixOverflow,
"names/N9a/n63b/label": labelSuffixOverflow,
"names/N9b/n63b/label": labelSuffixOverflow,
"names/N10/n63b/label": labelSuffixOverflow,
"names/N11/n63b/label": labelSuffixOverflow,
"names/N1/domain": v2NameBare,
"names/N2/domain": v2NameBare,
"names/N3/domain": v2NameBare,
"names/N4/domain": v2NameBare,
"names/N5/domain": v2NameBare,
"names/N6/domain": v2NameBare,
"names/N7/domain": v2NameBare,
"names/N8/domain": v2NameBare,
"names/N9a/domain": v2NameBare,
"names/N9b/domain": v2NameBare,
"names/N10/domain": v2NameBare,
"names/N11/domain": v2NameBare,
"names/N11b/domain": v2NameBare,
"names/N12a/domain": v2NameBare,
"names/N12b/domain": v2NameBare,
"names/N13/domain": v2NameBare,
"names-svc/S1/domain": v2NameBare,
"names-svc/S2/domain": v2NameBare,
"names-svc/S3/domain": v2NameBare,
"names-svc/S4/domain": v2NameBare,
"names-svc/S5/domain": v2NameBare,
"names-svc/S6/domain": v2NameBare,
"names-svc/S7/domain": v2NameBare,
"names/N11/pdb/label": adminNameFollowsHostname,
"names/N11b/pdb/label": adminNameFollowsHostname,
"names/N12a/pdb/label": adminNameFollowsHostname,
"names/N12b/pdb/label": adminNameFollowsHostname,
"names/N13/pdb/label": adminNameFollowsHostname,
"names/N9a/0/message": caseTwinTakenText,
"names/N9b/0/message": caseTwinTakenText,
"names-svc/S3/g1/label": extraRecordNotTaken,
"names-svc/S4/g1/label": extraRecordNotTaken,
"names-svc/S4/svc-other/label": extraRecordNotTaken,
"names-svc/S5/g1/label": extraRecordNotTaken,
"names-svc/S5/svc-other/label": extraRecordNotTaken,
"names-svc/S5/0/status": extraRecordNotTaken,
"names-svc/S5/0/message": recordNameTakenText,
"names-svc/S6/g1/label": extraRecordNotTaken,
"names-svc/S6/svc-other/label": extraRecordNotTaken,
"names-svc/S7/g1/label": extraRecordNotTaken,
"names-svc/S7/svc-other/label": extraRecordNotTaken,
}
const (
labelSuffixOverflow = "LABEL_SUFFIX_OVERFLOW: headscale appends -N to a 63-byte label, giving a 65-byte one; SaaS cuts the label so the suffix fits in 63 bytes"
adminNameFollowsHostname = "ADMIN_NAME_FOLLOWS_HOSTNAME: an admin name equal to the hostname's label counts as derived from it, so a hostname change replaces it; SaaS keeps an admin name across hostname changes"
extraRecordNotTaken = "EXTRA_RECORD_NOT_TAKEN: headscale gives a device the label an extra DNS record holds and accepts a rename to it; SaaS bumps a device past a Service's name and refuses the rename with 400"
caseTwinTakenText = "NAME_TAKEN_TEXT: headscale refuses a rename to a case twin saying the node name is not unique, where SaaS says the name is already taken"
recordNameTakenText = "EXTRA_RECORD_NOT_TAKEN+NAME_TAKEN_TEXT: headscale accepts a rename to an extra record's name; refused, its text says the node name is not unique, where SaaS says the name is already taken"
v2NameBare = "V2_NAME_BARE: headscale's v2 device name is the bare label; SaaS's is the FQDN under the tailnet domain"
)
// nameViewer is the node whose netmap every step captured.
const nameViewer = "viewer"
// TestNodeNameCompat replays devices joining under taken hostnames, a
// restart, the holder's deletion, hostnames that sanitise to nothing, admin
// renames that differ in case, hostname changes after a rename and the
// longest hostnames a stock client sends; then a Tailscale Service holding
// a label, replayed as the extra DNS record it publishes. After each step
// it compares, per device, its label in the v2 API, in the viewer's netmap
// and in its own, and its hostname in the v2 API and the viewer's netmap;
// the v2 name's domain; which devices exist; each captured node's extra
// records; and each rename and delete answer.
func TestNodeNameCompat(t *testing.T) {
t.Parallel()
// One subtest owns the clients, so both captures replay before its
// cleanup stops them.
t.Run("replay", func(t *testing.T) {
t.Parallel()
names := readStepCapture(t, "names")
svc := readStepCapture(t, "names-svc")
self := names.Captures[nameViewer].Netmap
require.NotNil(t, self, "names: no baseline netmap of %s", nameViewer)
_, domain, _ := strings.Cut(strings.TrimSuffix(self.SelfNode.Name(), "."), ".")
n := &nameReplay{
stepReplay: newStepReplay(t, names, servertest.WithMagicDNS(domain)),
domain: domain,
services: map[string][]string{},
}
n.readAPI = true
evaluated := map[string]bool{}
for _, c := range []*testcapture.Capture{names, svc} {
n.replay(t, c, evaluated)
}
for k := range nameSkipReasons {
if !evaluated[k] {
t.Errorf("skip entry %q names no check", k)
}
}
})
}
type nameReplay struct {
*stepReplay
domain string
// services holds each live Service's addresses by name.
services map[string][]string
}
func (n *nameReplay) replay(t *testing.T, c *testcapture.Capture, evaluated map[string]bool) {
t.Helper()
n.c = c
for _, step := range c.Steps {
require.Contains(t, []string{"observed", "expected-refusal"}, step.Result,
"%s: the replay follows every step", step.Name)
answers := n.applyStep(t, step)
devices, _, ok := stepDeviceRead(step)
if !ok {
continue
}
t.Run(c.TestID+"/"+step.Name, func(t *testing.T) {
holdChecks(t, nameSkipReasons, c.TestID+"/"+step.Name+"/", n.checks(t, step, devices, answers), evaluated)
})
}
}
// applyStep runs the step's actions one at a time, as the probe let each land
// before the next, and returns headscale's answer to each admin exchange by
// its index in the step.
func (n *nameReplay) applyStep(t *testing.T, step testcapture.Step) map[int]apiAnswer {
t.Helper()
answers := map[int]apiAnswer{}
for i, a := range step.Actions {
if n.service(t, step.Name, a) {
continue
}
one := step
one.Actions = []testcapture.Action{a}
require.Empty(t, n.apply(t, one), "%s: every login succeeds", step.Name)
n.waitSession(t, step.Name, a)
if ans, ok := n.answers[0]; ok {
answers[i] = ans
}
}
return answers
}
// service replays what headscale has no Services for, and reports whether a
// was such an action. A Service publishes its name as an extra DNS record,
// so creating and deleting one sets the records. Serving it, the grants and
// approvers that name it, and the client's own DNS answers have no headscale
// side.
func (n *nameReplay) service(t *testing.T, stepName string, a testcapture.Action) bool {
t.Helper()
switch {
case a.Kind == "dns_query",
a.Kind == "cli" && len(a.Args) > 0 && a.Args[0] == "serve",
a.Kind == "policy" && bytes.Contains(a.Body, []byte(`"svc:`)):
return true
case a.Kind != "api" || !strings.HasPrefix(a.Path, "/tailnet/-/vip-services"):
return false
}
// A refused create has no address to publish.
name, one := strings.CutPrefix(a.Path, "/tailnet/-/vip-services/svc:")
if !one || a.Status != http.StatusOK {
return true
}
switch a.Method {
case http.MethodPut:
var body struct {
Addrs []string `json:"addrs"`
}
require.NoError(t, json.Unmarshal(a.Response, &body), "%s: %s", stepName, a.Path)
n.services[name] = body.Addrs
case http.MethodDelete:
delete(n.services, name)
default:
return true
}
var records []tailcfg.DNSRecord
for _, name := range slices.Sorted(maps.Keys(n.services)) {
for _, addr := range n.services[name] {
records = append(records, tailcfg.DNSRecord{Name: name + "." + n.domain + ".", Value: addr})
}
}
n.srv.SetExtraRecords(records)
return true
}
// checks compares, per device of the capture's topology in the step's device
// list, its label and hostname; per step, the viewer's peers, the v2 name's
// domain and which devices exist; per node SaaS captured, its extra records;
// and per rename or delete, its status and, for a refusal, its message.
func (n *nameReplay) checks(t *testing.T, step testcapture.Step, devices []saasDevice, answers map[int]apiAnswer) []stepCheck {
t.Helper()
saasViewer := step.Captures[nameViewer].Netmap
require.NotNil(t, saasViewer, "%s: no netmap of %s", step.Name, nameViewer)
var (
checks []stepCheck
listed []string
domains []string
)
for _, d := range devices {
x := deviceLabelAlias(d.ID)
// Devices left from the capture before this one are not in its
// topology.
if _, ok := n.c.Topology.Nodes[x]; !ok {
continue
}
listed = append(listed, x)
label, _, _ := strings.Cut(d.Name, ".")
domains = append(domains, nameDomain(d.Name))
saasSeen, saasMissing := nodeIn(saasViewer, n.c.NodeIDs[x])
saasSelf, captured := step.Captures[x]
checks = append(checks,
stepCheck{x + "/label", agreed(label, peerName(saasSeen, saasMissing), selfLabel(saasSelf.Netmap, captured)), func() string {
dev, missing := n.hsDevice(t, x)
if missing != "" {
return missing
}
hsLabel, _, _ := strings.Cut(dev.Name, ".")
seen, seenMissing := n.hsSeen(x)
return agreed(hsLabel, peerName(seen, seenMissing), selfLabel(n.clients[x].Netmap(), captured))
}},
stepCheck{x + "/hostname", agreed(d.Hostname, peerHostname(saasSeen, saasMissing), ""), func() string {
dev, missing := n.hsDevice(t, x)
if missing != "" {
return missing
}
return agreed(dev.Hostname, peerHostname(n.hsSeen(x)), "")
}},
)
}
// Only this capture's devices: the one before left its own behind.
inTopology := func(alias func(tailcfg.NodeID) (string, bool)) func(tailcfg.NodeID) (string, bool) {
return func(id tailcfg.NodeID) (string, bool) {
x, ok := alias(id)
_, mine := n.c.Topology.Nodes[x]
return x, ok && mine
}
}
checks = append(checks,
stepCheck{nameViewer + "/peers", peerAliases(saasViewer, inTopology(n.saasAlias)), func() string {
return peerAliases(n.clients[nameViewer].Netmap(), inTopology(n.hsAlias))
}},
stepCheck{"devices", sortedStrings(listed), func() string {
var live []string
for x := range n.c.Topology.Nodes {
if len(n.machineNodes(x)) > 0 {
live = append(live, x)
}
}
return sortedStrings(live)
}},
stepCheck{"domain", distinct(domains), func() string {
var got []string
for _, x := range listed {
dev, missing := n.hsDevice(t, x)
if missing != "" {
return missing
}
got = append(got, nameDomain(dev.Name))
}
return distinct(got)
}},
)
for _, x := range slices.Sorted(maps.Keys(step.Captures)) {
saas := step.Captures[x].Netmap
require.NotNil(t, saas, "%s: no netmap of %s", step.Name, x)
checks = append(checks, stepCheck{x + "/records", renderRecords(saas), func() string {
return renderRecords(n.clients[x].Netmap())
}})
}
for i, a := range step.Actions {
if a.Kind != "api" || a.Method == http.MethodGet || !strings.HasPrefix(a.Path, "/device/") {
continue
}
got, ok := answers[i]
require.True(t, ok, "%s: %s %s not replayed", step.Name, a.Method, a.Path)
idx := strconv.Itoa(i)
checks = append(checks, stepCheck{idx + "/status", strconv.Itoa(a.Status), func() string { return strconv.Itoa(got.status) }})
if a.Status >= http.StatusBadRequest {
want := errorMessage(a.Response)
checks = append(checks, stepCheck{idx + "/message", want, func() string {
hs := errorMessage(got.body)
if strings.Contains(hs, want) {
return want
}
return hs
}})
}
}
return checks
}
// hsDevice reads alias's device from headscale's v2 API, while its machine
// holds one node.
func (n *nameReplay) hsDevice(t *testing.T, alias string) (saasDevice, string) {
t.Helper()
nodes := n.machineNodes(alias)
if len(nodes) != 1 {
return saasDevice{}, fmt.Sprintf("devices %d", len(nodes))
}
status, body := n.call(t, http.MethodGet, "/api/v2/device/"+nodes[0].StringID(), nil)
require.Equal(t, http.StatusOK, status, "%s", body)
var dev saasDevice
require.NoError(t, json.Unmarshal(body, &dev))
return dev, ""
}
// hsSeen is alias's node as headscale's viewer sees it.
func (n *nameReplay) hsSeen(alias string) (tailcfg.NodeView, string) {
nodes := n.machineNodes(alias)
if len(nodes) != 1 {
return tailcfg.NodeView{}, fmt.Sprintf("devices %d", len(nodes))
}
return nodeIn(n.clients[nameViewer].Netmap(), nodes[0].ID().NodeID())
}
// nodeIn finds node id in nm, itself or as a peer.
func nodeIn(nm *netmap.NetworkMap, id tailcfg.NodeID) (tailcfg.NodeView, string) {
if nm != nil && nm.SelfNode.Valid() && nm.SelfNode.ID() == id {
return nm.SelfNode, ""
}
return peerByID(nm, id)
}
// agreed is the one value the API, the viewer and the node itself show, or
// all three when they differ. self is empty when not compared.
func agreed(api, viewer, self string) string {
if api == viewer && (self == "" || self == api) {
return api
}
return fmt.Sprintf("api %q, viewer %q, self %q", api, viewer, self)
}
// selfLabel is the first label of nm's own name, when SaaS captured it.
func selfLabel(nm *netmap.NetworkMap, captured bool) string {
if !captured {
return ""
}
if nm == nil || !nm.SelfNode.Valid() {
return "no netmap"
}
label, _, _ := strings.Cut(nm.SelfNode.Name(), ".")
return label
}
func peerHostname(p tailcfg.NodeView, missing string) string {
if missing != "" {
return missing
}
return p.Hostinfo().Hostname()
}
// nameDomain is what follows a device name's first label.
func nameDomain(name string) string {
if _, domain, ok := strings.Cut(name, "."); ok {
return domain
}
return "none"
}
func distinct(s []string) string {
return fmt.Sprint(slices.Compact(slices.Sorted(slices.Values(s))))
}
// renderRecords lists nm's extra DNS records.
func renderRecords(nm *netmap.NetworkMap) string {
if nm == nil {
return "no netmap"
}
out := make([]string, 0, len(nm.DNS.ExtraRecords))
for _, r := range nm.DNS.ExtraRecords {
out = append(out, r.Name+" "+r.Type+" "+r.Value)
}
return sortedStrings(out)
}
// errorMessage is an error body's text: SaaS's message, or headscale's
// detail and error messages.
func errorMessage(body []byte) string {
var e struct {
Message string `json:"message"`
Detail string `json:"detail"`
Errors []struct {
Message string `json:"message"`
} `json:"errors"`
}
if json.Unmarshal(body, &e) != nil {
return string(body)
}
texts := []string{e.Message, e.Detail}
for _, x := range e.Errors {
texts = append(texts, x.Message)
}
return strings.Join(slices.DeleteFunc(texts, func(s string) bool { return s == "" }), " | ")
}
+12
View File
@@ -20,6 +20,7 @@ import (
hsdns "github.com/juanfont/headscale/hscontrol/dns"
"github.com/juanfont/headscale/hscontrol/state"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/types/change"
"tailscale.com/net/memnet"
"tailscale.com/tailcfg"
"tailscale.com/types/dnstype"
@@ -38,6 +39,7 @@ type TestServer struct {
ln net.Listener
httpServer *http.Server
st *state.State
cfg *types.Config
}
// ServerOption configures a [TestServer].
@@ -280,6 +282,7 @@ func NewServer(tb testing.TB, opts ...ServerOption) *TestServer {
ln: ln,
httpServer: httpServer,
st: app.GetState(),
cfg: &cfg,
}
tb.Cleanup(ts.Close)
@@ -312,6 +315,15 @@ func (s *TestServer) MemNet() *memnet.Network {
return s.memNet
}
// SetExtraRecords replaces the extra DNS records and sends them to every
// node, as a reload of dns.extra_records_path does. The server needs
// [WithMagicDNS] or [WithDNSResolvers], which give it a DNS config to hold
// them.
func (s *TestServer) SetExtraRecords(records []tailcfg.DNSRecord) {
s.cfg.SetExtraRecords(records)
s.App.Change(change.ExtraRecords())
}
// CreateUser creates a test user and returns it.
func (s *TestServer) CreateUser(tb testing.TB, name string) *types.User {
tb.Helper()
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+352
View File
@@ -1,9 +1,15 @@
package state
import (
"encoding/json"
"errors"
"maps"
"net/http"
"net/netip"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
@@ -13,6 +19,7 @@ import (
"github.com/juanfont/headscale/hscontrol/policy"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/types/change"
"github.com/juanfont/headscale/hscontrol/types/testcapture"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -1260,3 +1267,348 @@ func TestBackfillNodeIPsSurvivesConcurrentPersist(t *testing.T) {
require.NoError(t, err)
assert.Nil(t, dbNode.IPv6, "database kept the removed IPv6 address")
}
// nameSkipReasons lists checks of [TestNodeNameReplayCompat] where headscale
// differs from SaaS, keyed <test_id>/<step>/<node or action index>/<check>.
// A listed check that matches fails, so a fix must delete its entry.
var nameSkipReasons = map[string]string{
"names/N1/db-b/reopen": labelNotSaved,
"names/N5/e2/reopen": labelNotSaved,
"names/N5/e1/reopen": labelNotSaved,
"names/N6/e2/reopen": labelNotSaved,
"names/N6/e1/reopen": labelNotSaved,
"names/N7/e2/reopen": labelNotSaved,
"names/N7/e1/reopen": labelNotSaved,
"names/N8/e2/reopen": labelNotSaved,
"names/N8/e1/reopen": labelNotSaved,
"names/N9a/e2/reopen": labelNotSaved,
"names/N9a/e1/reopen": labelNotSaved,
"names/N9b/e2/reopen": labelNotSaved,
"names/N9b/e1/reopen": labelNotSaved,
"names/N10/e2/reopen": labelNotSaved,
"names/N10/e1/reopen": labelNotSaved,
"names/N11/e2/reopen": labelNotSaved,
"names/N11/e1/reopen": labelNotSaved,
"names/N11b/e2/reopen": labelNotSaved,
"names/N11b/e1/reopen": labelNotSaved,
"names/N12a/e2/reopen": labelNotSaved,
"names/N12a/e1/reopen": labelNotSaved,
"names/N12b/e2/reopen": labelNotSaved,
"names/N12b/e1/reopen": labelNotSaved,
"names/N13/e2/reopen": labelNotSaved,
"names/N13/e1/reopen": labelNotSaved,
"names/N1/n63b/label": labelSuffixOverflow,
"names/N2/n63b/label": labelSuffixOverflow,
"names/N3/n63b/label": labelSuffixOverflow,
"names/N4/n63b/label": labelSuffixOverflow,
"names/N5/n63b/label": labelSuffixOverflow,
"names/N6/n63b/label": labelSuffixOverflow,
"names/N7/n63b/label": labelSuffixOverflow,
"names/N8/n63b/label": labelSuffixOverflow,
"names/N9a/n63b/label": labelSuffixOverflow,
"names/N9b/n63b/label": labelSuffixOverflow,
"names/N10/n63b/label": labelSuffixOverflow,
"names/N11/n63b/label": labelSuffixOverflow,
"names/N1/n63b/reopen": labelNotSavedOverflow,
"names/N2/n63b/reopen": labelNotSavedOverflow,
"names/N3/n63b/reopen": labelNotSavedOverflow,
"names/N4/n63b/reopen": labelNotSavedOverflow,
"names/N5/n63b/reopen": labelNotSavedOverflow,
"names/N6/n63b/reopen": labelNotSavedOverflow,
"names/N7/n63b/reopen": labelNotSavedOverflow,
"names/N8/n63b/reopen": labelNotSavedOverflow,
"names/N9a/n63b/reopen": labelNotSavedOverflow,
"names/N9b/n63b/reopen": labelNotSavedOverflow,
"names/N10/n63b/reopen": labelNotSavedOverflow,
"names/N11/n63b/reopen": labelNotSavedOverflow,
"names/N11/pdb/label": adminNameFollowsHostname,
"names/N11/pdb/reopen": adminNameFollowsHostname,
"names/N11b/pdb/label": adminNameFollowsHostname,
"names/N11b/pdb/reopen": adminNameFollowsHostname,
"names/N12a/pdb/label": adminNameFollowsHostname,
"names/N12a/pdb/reopen": adminNameFollowsHostname,
"names/N12b/pdb/label": adminNameFollowsHostname,
"names/N12b/pdb/reopen": adminNameFollowsHostname,
"names/N13/pdb/label": adminNameFollowsHostname,
"names/N13/pdb/reopen": adminNameFollowsHostname,
"names/N7/m1/reopen": labelNotSaved,
"names/N8/m1/reopen": labelNotSaved,
"names/N9a/m1/reopen": labelNotSaved,
"names/N9b/m1/reopen": labelNotSaved,
"names/N10/m1/reopen": labelNotSaved,
"names/N11/m1/reopen": labelNotSaved,
"names/N11b/m1/reopen": labelNotSaved,
"names/N12a/m1/reopen": labelNotSaved,
"names/N12b/m1/reopen": labelNotSaved,
"names/N13/m1/reopen": labelNotSaved,
}
const (
labelSuffixOverflow = "LABEL_SUFFIX_OVERFLOW: headscale appends -N to a 63-byte label, giving a 65-byte one; SaaS cuts the label so the suffix fits in 63 bytes"
adminNameFollowsHostname = "ADMIN_NAME_FOLLOWS_HOSTNAME: an admin name equal to the hostname's label counts as derived from it, so a hostname change replaces it; SaaS keeps an admin name across hostname changes"
labelNotSaved = "LABEL_NOT_SAVED: headscale writes a new node's row with its hostname's label before the NodeStore resolves a collision or an empty label, and saves the result only on a later change; SaaS keeps the label it gave"
labelNotSavedOverflow = "LABEL_NOT_SAVED+LABEL_SUFFIX_OVERFLOW: the row keeps the unsuffixed 63-byte label, and the label the NodeStore resolves is 65 bytes; SaaS cuts the label so the suffix fits"
)
// TestNodeNameReplayCompat replays the names capture through State: joins by
// pre-auth key with no map request after them, restarts and hostname changes
// as map requests, renames and deletes. After each step every device's label
// must be SaaS's, in the NodeStore and as a restart would load it from the
// database, since SaaS never changes a label without a cause.
//
// Test data source: ../servertest/testdata/steps/names.hujson, loaded by
// name; TestNodeNameCompat in ../servertest replays it with clients.
func TestNodeNameReplayCompat(t *testing.T) {
c, err := testcapture.Read(filepath.Join("..", "servertest", "testdata", "steps", "names.hujson"))
require.NoError(t, err)
s, err := NewState(persistTestConfig(t.TempDir() + "/headscale.db"))
require.NoError(t, err)
t.Cleanup(func() { _ = s.Close() })
// SaaS keys and admin calls act as the tailnet's admin.
adminName, _, _ := strings.Cut(c.Tailnet, "@")
admin := s.CreateUserForTest(adminName)
type replayNode struct {
id types.NodeID
machine key.MachinePublic
nodeKey key.NodePublic
hostinfo *tailcfg.Hostinfo
}
keys := map[string]string{}
nodes := map[string]*replayNode{}
evaluated := map[string]bool{}
for _, step := range c.Steps {
outcomes := map[int]string{}
for i, a := range step.Actions {
switch {
case a.Kind == "policy":
_, err := s.SetPolicy([]byte(strings.ReplaceAll(string(a.Body), c.Tailnet, adminName+"@")))
require.NoError(t, err, "%s: policy", step.Name)
case a.Kind == "api" && a.Method == http.MethodPost && a.Path == "/tailnet/-/keys":
var body struct {
Capabilities struct {
Devices struct {
Create struct {
Tags []string `json:"tags"`
} `json:"create"`
} `json:"devices"`
} `json:"capabilities"`
}
require.NoError(t, json.Unmarshal(a.Body, &body), "%s: key body", step.Name)
pak, err := s.CreatePreAuthKey(admin.TypedID(), true, false, nil, body.Capabilities.Devices.Create.Tags)
require.NoError(t, err, "%s: mint %s", step.Name, a.Key)
keys[a.Key] = pak.Key
case a.Kind == "join":
n := &replayNode{
machine: key.NewMachine().Public(),
nodeKey: key.NewNode().Public(),
hostinfo: &tailcfg.Hostinfo{Hostname: a.Hostname},
}
if a.RequestTags != nil {
n.hostinfo.RequestTags = *a.RequestTags
}
nv, _, err := s.HandleNodeFromPreAuthKey(tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{AuthKey: keys[a.Key]},
NodeKey: n.nodeKey,
Hostinfo: n.hostinfo.Clone(),
}, n.machine)
require.NoError(t, err, "%s: join %s", step.Name, a.Node)
n.id = nv.ID()
nodes[a.Node] = n
case a.Kind == "restart", a.Kind == "set_hostname":
// A restarted tailscaled polls with a new disco key.
n := nodes[a.Node]
if a.Kind == "set_hostname" {
n.hostinfo.Hostname = a.Hostname
}
_, err := s.UpdateNodeFromMapRequest(n.id, tailcfg.MapRequest{
NodeKey: n.nodeKey,
DiscoKey: key.NewDisco().Public(),
Hostinfo: n.hostinfo.Clone(),
})
require.NoError(t, err, "%s: %s %s", step.Name, a.Kind, a.Node)
case a.Kind == "retire", a.Kind == "api" && a.Method == http.MethodDelete:
alias := a.Node
if alias == "" {
alias = captureDeviceAlias(a.Path)
}
nv, ok := s.GetNodeByID(nodes[alias].id)
require.True(t, ok, "%s: delete of a missing %s", step.Name, alias)
_, err := s.DeleteNode(nv)
require.NoError(t, err, "%s: delete %s", step.Name, alias)
case a.Kind == "api" && strings.HasSuffix(a.Path, "/name"):
var body struct {
Name string `json:"name"`
}
require.NoError(t, json.Unmarshal(a.Body, &body), "%s: rename body", step.Name)
_, _, err := s.RenameNode(nodes[captureDeviceAlias(a.Path)].id, body.Name)
outcomes[i] = renameOutcome(err == nil)
case a.Kind == "kill", a.Kind == "wait",
a.Kind == "api" && (a.Method == http.MethodGet || strings.HasPrefix(a.Path, "/tailnet/-/dns/")):
// A killed daemon only stops polling; reads change nothing,
// and DNS comes from the config.
default:
t.Fatalf("%s: no replay for %s %s %s", step.Name, a.Kind, a.Method, a.Path)
}
}
devices, ok := captureDevices(t, step)
if !ok {
continue
}
reopened := reopenLabels(t, s)
t.Run(step.Name, func(t *testing.T) {
prefix := c.TestID + "/" + step.Name + "/"
var listed, live []string
for _, d := range devices {
alias := captureDeviceAlias(d.ID)
n, ok := nodes[alias]
require.True(t, ok, "%s never joined", alias)
listed = append(listed, alias)
want, _, _ := strings.Cut(d.Name, ".")
got := "absent"
if nv, ok := s.GetNodeByID(n.id); ok {
got = nv.GivenName()
}
reload, ok := reopened[n.id]
if !ok {
reload = "absent"
}
nameCheck(t, prefix, alias+"/label", want, got, evaluated)
nameCheck(t, prefix, alias+"/reopen", want, reload, evaluated)
}
for alias, n := range nodes {
if _, ok := s.GetNodeByID(n.id); ok {
live = append(live, alias)
}
}
slices.Sort(listed)
slices.Sort(live)
nameCheck(t, prefix, "devices", strings.Join(listed, " "), strings.Join(live, " "), evaluated)
for _, i := range slices.Sorted(maps.Keys(outcomes)) {
want := renameOutcome(step.Actions[i].Status == http.StatusOK)
nameCheck(t, prefix, strconv.Itoa(i)+"/outcome", want, outcomes[i], evaluated)
}
})
}
for k := range nameSkipReasons {
if !evaluated[k] {
t.Errorf("skip entry %q names no check", k)
}
}
}
// nameCheck compares one check against SaaS. A check listed in
// nameSkipReasons is skipped while it differs and fails once it matches.
func nameCheck(t *testing.T, prefix, name, want, got string, evaluated map[string]bool) {
t.Helper()
key := prefix + name
evaluated[key] = true
t.Run(name, func(t *testing.T) {
reason, listed := nameSkipReasons[key]
switch {
case listed && got == want:
t.Errorf("matches SaaS; delete the skip entry %q (%s)", key, reason)
case listed:
t.Skip(reason)
default:
assert.Equal(t, want, got)
}
})
}
func renameOutcome(accepted bool) string {
if accepted {
return "accepted"
}
return "refused"
}
// captureDevices decodes the step's last device-list read.
func captureDevices(t *testing.T, step testcapture.Step) ([]struct{ ID, Name string }, bool) {
t.Helper()
for _, a := range slices.Backward(step.Actions) {
if a.Kind != "api" || a.Method != http.MethodGet || !strings.HasPrefix(a.Path, "/tailnet/-/devices") {
continue
}
var out struct {
Devices []struct{ ID, Name string } `json:"devices"`
}
require.NoError(t, json.Unmarshal(a.Response, &out), "%s: %s", step.Name, a.Path)
return out.Devices, true
}
return nil, false
}
// captureDeviceAlias extracts x from "{device:x}" or "/device/{device:x}/...".
func captureDeviceAlias(s string) string {
_, rest, _ := strings.Cut(s, "{device:")
alias, _, _ := strings.Cut(rest, "}")
return alias
}
// reopenLabels returns each node's label as a restart would load it: from a
// State opened on a copy of s's database.
func reopenLabels(t *testing.T, s *State) map[types.NodeID]string {
t.Helper()
path := filepath.Join(t.TempDir(), "copy.db")
require.NoError(t, s.db.DB.Exec("VACUUM INTO ?", path).Error)
reopened, err := NewState(persistTestConfig(path))
require.NoError(t, err)
defer reopened.Close()
out := map[types.NodeID]string{}
for _, nv := range reopened.ListNodes().All() {
out[nv.ID()] = nv.GivenName()
}
return out
}