mirror of
https://github.com/juanfont/headscale.git
synced 2026-11-04 12:39:25 +09:00
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:
1 parent
8bb0454fce
commit
18cbaf6f2a
6 files changed
+34105
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"`
|
||||
|
||||
@@ -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 == "" }), " | ")
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
+10818
File diff suppressed because it is too large.
Load diff
+22413
File diff suppressed because it is too large.
Load diff
@@ -1,10 +1,15 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -15,6 +20,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"
|
||||
@@ -1358,6 +1364,351 @@ func TestBackfillNodeIPsSurvivesConcurrentPersist(t *testing.T) {
|
||||
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
|
||||
}
|
||||
|
||||
// TestSetNodeExpiryRejectsUnencodableExpiry proves an admin cannot store a key
|
||||
// expiry that every map carrying the node would fail to encode.
|
||||
func TestSetNodeExpiryRejectsUnencodableExpiry(t *testing.T) {
|
||||
|
||||
Reference in new issue
Block a user