types, noise: bound the Hostinfo and endpoints a client reports

Cut lists and hostname at decode, refuse >64 KiB of non-route Hostinfo.
The body limit counted wire bytes, not what the cache, NodeStore and
peers keep.

Fixes ANT-2026-RQS7JAPP
Fixes ANT-2026-5B6KE87K
Fixes ANT-2026-KFCFFM34
This commit is contained in:
Kristoffer Dalby committed 2026-10-28 14:25:41 +00:00
1 parent 7259ef6edd
commit 76509000bc
6 files changed
+578

No files matched your search

+1
View File
@@ -153,6 +153,7 @@ the ACME DNS-01 challenge through a built-in DNS provider: `acmedns`,
- Another user's key or login on a tagged machine now creates a new device for that user instead of re-authenticating the tagged node
- Fix one node with a stored key expiry outside the years 0 to 9999 in UTC failing the map of every node that can see it; it is now left out of its peers' maps and the startup check names it with the fix command
- Refuse a key expiry outside the years 0 to 9999 in UTC on registration, on the OIDC token-expiry path and in `headscale nodes expire` / `POST /api/v1/node/{nodeId}/expire` (400); the latest accepted expiry no longer fails node listings on PostgreSQL in a zone east of UTC
- Bound what a client reports: Hostinfo lists and endpoints over 256 entries, announced routes over 4096 and hostnames over 255 bytes are cut, and a Hostinfo whose NetInfo or other fields encode to more than 64 KiB is refused
## 0.29.6 (2026-10-24)
+18
View File
@@ -731,6 +731,18 @@ func (ns *noiseServer) PollNetMapHandler(
return
}
// Bound the report before the session, the node or its peers keep it.
err = types.BoundMapRequest(&mapRequest)
if err != nil {
httpError(writer, NewHTTPError(
http.StatusRequestEntityTooLarge,
"hostinfo too large",
err,
))
return
}
nv, err := ns.getAndValidateNode(mapRequest)
if err != nil {
// The node is gone, but the client does not know that. Tailscale
@@ -909,6 +921,12 @@ func (ns *noiseServer) RegistrationHandler(
decodeErr := json.NewDecoder(req.Body).Decode(&registerRequest)
// Bound the report before a pending registration, a waiting follow-up
// or the node keeps it.
if decodeErr == nil {
decodeErr = types.BoundHostinfo(registerRequest.Hostinfo)
}
// The floor must be enforced before handleRegister: a logout, pre-auth
// key use or auth-cache write it performs is not undone by a later 400.
// A failed decode still gets checked against whatever Version it read.
+165
View File
@@ -12,6 +12,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"slices"
"strconv"
@@ -1673,3 +1674,167 @@ func TestSetDNSHandlerDisabled(t *testing.T) {
})
assert.Equal(t, http.StatusNotImplemented, rec.Code)
}
func manyServices(n int) []tailcfg.Service {
services := make([]tailcfg.Service, n)
for i := range services {
services[i] = tailcfg.Service{Proto: tailcfg.TCP, Port: uint16(i)}
}
return services
}
// TestPollNetMapHandler_BoundsClientReport pins that a map request is cut to
// the report bounds before the node stores it, and that the same report sent
// again compares equal to what was stored.
func TestPollNetMapHandler_BoundsClientReport(t *testing.T) {
t.Parallel()
app := createTestApp(t)
user := app.state.CreateUserForTest("bound-map-user")
node := putTestNodeInStore(t, app, user, "bound-map-node")
endpoints := make([]netip.AddrPort, 1000)
for i := range endpoints {
endpoints[i] = netip.AddrPortFrom(netip.MustParseAddr("192.0.2.1"), uint16(i))
}
report := tailcfg.MapRequest{
Version: tailcfg.CurrentCapabilityVersion,
NodeKey: node.NodeKey,
DiscoKey: node.DiscoKey,
OmitPeers: true,
Endpoints: endpoints,
Hostinfo: &tailcfg.Hostinfo{
Hostname: strings.Repeat("h", 1<<20),
Services: manyServices(1000),
},
}
ns := &noiseServer{headscale: app, machineKey: node.MachineKey}
rec := httptest.NewRecorder()
ns.PollNetMapHandler(rec, newMapRequest(t, report))
require.Equal(t, http.StatusOK, rec.Code, "body=%q", rec.Body.String())
stored, ok := app.state.GetNodeByID(node.ID)
require.True(t, ok)
hostnameLen := len(stored.Hostname())
assert.Equal(t, types.MaxHostnameLength, hostnameLen)
assert.Equal(t, types.MaxReportedListLen, stored.Endpoints().Len())
assert.Equal(t, types.MaxReportedListLen, stored.Hostinfo().Services().Len())
again := report
require.NoError(t, types.BoundMapRequest(&again))
c, err := app.state.UpdateNodeFromMapRequest(node.ID, again)
require.NoError(t, err)
assert.True(t, c.IsEmpty(),
"an unchanged report must not be a change, got %s", c.Type())
}
// TestPollNetMapHandler_OversizedHostinfoRefused pins that a report over the
// size budget is refused before the handler looks anything up.
func TestPollNetMapHandler_OversizedHostinfoRefused(t *testing.T) {
t.Parallel()
// A nil headscale proves the refused request never reaches state.
ns := &noiseServer{machineKey: key.NewMachine().Public()}
rec := httptest.NewRecorder()
require.NotPanics(t, func() {
ns.PollNetMapHandler(rec, newMapRequest(t, tailcfg.MapRequest{
Version: tailcfg.CurrentCapabilityVersion,
NodeKey: key.NewNode().Public(),
Hostinfo: &tailcfg.Hostinfo{
OSVersion: strings.Repeat("v", types.MaxHostinfoSize),
},
}))
})
assert.Equal(t, http.StatusRequestEntityTooLarge, rec.Code,
"body=%q", rec.Body.String())
}
// TestRegistrationHandler_BoundsClientReport pins that a register request is
// cut to the report bounds before a pending registration or a node keeps it.
func TestRegistrationHandler_BoundsClientReport(t *testing.T) {
t.Parallel()
t.Run("pending registration", func(t *testing.T) {
t.Parallel()
app := createTestApp(t)
ns := &noiseServer{headscale: app, machineKey: key.NewMachine().Public()}
rec := serveRegister(t, ns, tailcfg.RegisterRequest{
Version: tailcfg.CurrentCapabilityVersion,
NodeKey: key.NewNode().Public(),
Hostinfo: &tailcfg.Hostinfo{
Hostname: "bound-pending-node",
Services: manyServices(1000),
},
})
require.Equal(t, http.StatusOK, rec.Code, "body=%q", rec.Body.String())
var resp tailcfg.RegisterResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
authID, err := extractRegistrationIDFromAuthURL(resp.AuthURL)
require.NoError(t, err, "resp=%+v", resp)
entry, ok := app.state.GetAuthCacheEntry(authID)
require.True(t, ok)
assert.Len(t, entry.RegistrationData().Hostinfo.Services,
types.MaxReportedListLen)
})
t.Run("oversized report", func(t *testing.T) {
t.Parallel()
// A nil headscale proves the refused request never reaches state, so
// it leaves no pending registration.
ns := &noiseServer{machineKey: key.NewMachine().Public()}
rec := serveRegister(t, ns, tailcfg.RegisterRequest{
Version: tailcfg.CurrentCapabilityVersion,
NodeKey: key.NewNode().Public(),
Hostinfo: &tailcfg.Hostinfo{
OSVersion: strings.Repeat("v", types.MaxHostinfoSize),
},
})
require.Equal(t, http.StatusOK, rec.Code, "body=%q", rec.Body.String())
var resp tailcfg.RegisterResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
assert.Contains(t, resp.Error, types.ErrHostinfoTooLarge.Error())
assert.Empty(t, resp.AuthURL)
})
t.Run("pre-auth key", func(t *testing.T) {
t.Parallel()
app := createTestApp(t)
user := app.state.CreateUserForTest("bound-pak-user")
pak, err := app.state.CreatePreAuthKey(user.TypedID(), false, false, nil, nil)
require.NoError(t, err)
nodeKey := key.NewNode().Public()
ns := &noiseServer{headscale: app, machineKey: key.NewMachine().Public()}
rec := serveRegister(t, ns, tailcfg.RegisterRequest{
Version: tailcfg.CurrentCapabilityVersion,
NodeKey: nodeKey,
Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak.Key},
Hostinfo: &tailcfg.Hostinfo{Hostname: strings.Repeat("p", 1<<20)},
})
require.Equal(t, http.StatusOK, rec.Code, "body=%q", rec.Body.String())
node, ok := app.state.GetNodeByNodeKey(nodeKey)
require.True(t, ok)
hostnameLen := len(node.Hostname())
assert.Equal(t, types.MaxHostnameLength, hostnameLen)
})
}
+8
View File
@@ -14,3 +14,11 @@ const (
MaxHostnameLength = 255
)
// Bounds on what a client reports, applied by [BoundHostinfo] and
// [BoundMapRequest]. They sit far above what a stock client sends.
const (
MaxReportedListLen = 256
MaxRoutableIPs = 4096
MaxHostinfoSize = 64 << 10
)
+89
View File
@@ -1,6 +1,7 @@
package types
import (
"encoding/json"
"errors"
"fmt"
"maps"
@@ -15,6 +16,7 @@ import (
"github.com/juanfont/headscale/hscontrol/util"
"github.com/juanfont/headscale/hscontrol/util/zlog/zf"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"go4.org/netipx"
"tailscale.com/net/tsaddr"
"tailscale.com/tailcfg"
@@ -22,6 +24,7 @@ import (
"tailscale.com/types/key"
"tailscale.com/types/views"
"tailscale.com/util/dnsname"
"tailscale.com/util/truncate"
)
var (
@@ -34,6 +37,7 @@ var (
ErrExpiryOutOfRange = errors.New(
"key expiry is outside the years 0 to 9999 in UTC",
)
ErrHostinfoTooLarge = errors.New("hostinfo too large")
)
// RouteFunc is a function that takes a node ID and returns a list of
@@ -654,6 +658,91 @@ func (node *Node) MarshalZerologObject(e *zerolog.Event) {
}
}
// BoundHostinfo cuts the hostname and lists a client reports to fixed
// lengths, and refuses a report whose NetInfo, or whose other fields apart
// from routes, encode larger than [MaxHostinfoSize]. The body limit counts
// wire bytes only: small entries grow many times once decoded, and the
// stored node is sent to every peer. Lists keep their head; a cut copies,
// so the dropped tail can be freed.
func BoundHostinfo(hi *tailcfg.Hostinfo) error {
if hi == nil {
return nil
}
hi.Hostname = boundString(hi.Hostname, MaxHostnameLength)
if len(hi.RoutableIPs) > MaxRoutableIPs {
log.Warn().
Str(zf.Hostname, hi.Hostname).
Int(zf.RoutableIPCount, len(hi.RoutableIPs)).
Msgf("client announced too many routes, keeping the first %d",
MaxRoutableIPs)
}
hi.RoutableIPs = boundList(hi.RoutableIPs, MaxRoutableIPs)
hi.RequestTags = boundList(hi.RequestTags, MaxReportedListLen)
hi.WoLMACs = boundList(hi.WoLMACs, MaxReportedListLen)
hi.Services = boundList(hi.Services, MaxReportedListLen)
hi.SSH_HostKeys = boundList(hi.SSH_HostKeys, MaxReportedListLen)
// Headscale never reads the latencies, and a map has no head to keep.
if hi.NetInfo != nil && len(hi.NetInfo.DERPLatency) > MaxReportedListLen {
hi.NetInfo.DERPLatency = nil
}
// Routes are bounded by count. Everything else, including fields
// tailcfg adds later, is bounded by its encoded size. NetInfo is
// measured apart: a report without one keeps the stored NetInfo, so
// the stored Hostinfo stays within these bounds however reports combine.
rest := *hi
rest.RoutableIPs = nil
rest.NetInfo = nil
for _, part := range []any{&rest, hi.NetInfo} {
encoded, err := json.Marshal(part)
if err != nil {
return fmt.Errorf("encoding hostinfo: %w", err)
}
if len(encoded) > MaxHostinfoSize {
return fmt.Errorf(
"%w: %d bytes, limit %d",
ErrHostinfoTooLarge, len(encoded), MaxHostinfoSize,
)
}
}
return nil
}
// BoundMapRequest applies [BoundHostinfo] and cuts the reported endpoints.
// Endpoints and their types are cut at the same index, so they stay aligned.
func BoundMapRequest(req *tailcfg.MapRequest) error {
req.Endpoints = boundList(req.Endpoints, MaxReportedListLen)
req.EndpointTypes = boundList(req.EndpointTypes, MaxReportedListLen)
return BoundHostinfo(req.Hostinfo)
}
func boundList[S ~[]E, E any](s S, n int) S {
// Capacity past the length is copied away too: the decoder reuses a
// slice for a repeated key, so it can hold an earlier, longer list.
s = s[:min(len(s), n)]
if len(s) == cap(s) {
return s
}
return slices.Clip(slices.Clone(s))
}
func boundString(s string, n int) string {
if len(s) <= n {
return s
}
return strings.Clone(truncate.String(s, n))
}
// PeerChangeFromMapRequest takes a [tailcfg.MapRequest] and compares it to the node
// to produce a [tailcfg.PeerChange] struct that can be used to updated the node and
// inform peers about smaller changes to the node.
+297
View File
@@ -1,11 +1,14 @@
package types
import (
"encoding/json"
"fmt"
"net/netip"
"slices"
"strings"
"testing"
"time"
"unicode/utf8"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
@@ -14,6 +17,7 @@ import (
"github.com/stretchr/testify/require"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"tailscale.com/util/dnsname"
)
func Test_NodeCanAccess(t *testing.T) {
@@ -1073,3 +1077,296 @@ func TestNodeIsExpiredAt(t *testing.T) {
"an invalid view counts as expired, like IsExpired")
})
}
func TestBoundHostinfo(t *testing.T) {
stock := func() *tailcfg.Hostinfo {
return &tailcfg.Hostinfo{
IPNVersion: "1.96.0",
OS: "linux",
OSVersion: "6.12.0",
Hostname: "laptop",
RoutableIPs: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/24")},
RequestTags: []string{"tag:server"},
Services: []tailcfg.Service{{Proto: tailcfg.PeerAPI4, Port: 41641}},
SSH_HostKeys: []string{"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPc"},
NetInfo: &tailcfg.NetInfo{
PreferredDERP: 1,
DERPLatency: map[string]float64{"1-v4": 0.01, "2-v4": 0.02},
},
}
}
strs := func(n int) []string {
s := make([]string, n)
for i := range s {
s[i] = fmt.Sprintf("entry-%d", i)
}
return s
}
t.Run("stock report is kept as is", func(t *testing.T) {
hi := stock()
want := hi.Clone()
services := hi.Services
require.NoError(t, BoundHostinfo(hi))
require.Equal(t, want, hi)
require.Same(t, &services[0], &hi.Services[0],
"a list within bounds must not be copied")
})
// The longest hostnames in the node name capture: SaaS keeps each raw
// and derives a label cut at 63 bytes, so the bound must not cut them.
t.Run("captured long hostnames are kept as is", func(t *testing.T) {
for _, tc := range []struct{ hostname, label string }{
{
hostname: "label63-" + strings.Repeat("a", 55),
label: "label63-" + strings.Repeat("a", 55),
},
{
hostname: "label64-" + strings.Repeat("b", 56),
label: "label64-" + strings.Repeat("b", 55),
},
{
hostname: "dotted-" + strings.Repeat("d", 56) +
"." + strings.Repeat("e", 63) +
"." + strings.Repeat("f", 63) +
"." + strings.Repeat("g", 61),
label: "dotted-" + strings.Repeat("d", 56),
},
} {
hi := stock()
hi.Hostname = tc.hostname
require.NoError(t, BoundHostinfo(hi))
require.Equal(t, tc.hostname, hi.Hostname)
require.Equal(t, tc.label, dnsname.SanitizeHostname(hi.Hostname))
}
})
t.Run("long lists are cut to their head and copied", func(t *testing.T) {
hi := stock()
hi.Services = make([]tailcfg.Service, 1000)
for i := range hi.Services {
hi.Services[i] = tailcfg.Service{Proto: tailcfg.TCP, Port: uint16(i)}
}
hi.RequestTags = strs(1000)
hi.WoLMACs = strs(1000)
hi.SSH_HostKeys = strs(1000)
services := hi.Services
lists := map[string][]string{
"RequestTags": hi.RequestTags,
"WoLMACs": hi.WoLMACs,
"SSH_HostKeys": hi.SSH_HostKeys,
}
require.NoError(t, BoundHostinfo(hi))
require.Len(t, hi.Services, MaxReportedListLen)
require.NotSame(t, &services[0], &hi.Services[0],
"the cut tail must not stay reachable")
require.Equal(t, uint16(MaxReportedListLen-1),
hi.Services[MaxReportedListLen-1].Port)
for name, got := range map[string][]string{
"RequestTags": hi.RequestTags,
"WoLMACs": hi.WoLMACs,
"SSH_HostKeys": hi.SSH_HostKeys,
} {
require.Len(t, got, MaxReportedListLen, name)
require.NotSame(t, &lists[name][0], &got[0], name)
require.Equal(t, "entry-0", got[0], name)
}
})
t.Run("long hostname is cut", func(t *testing.T) {
hi := stock()
hi.Hostname = strings.Repeat("a", 1<<20)
require.NoError(t, BoundHostinfo(hi))
// Compare lengths, not strings: a failure must not print a megabyte.
got := len(hi.Hostname)
require.Equal(t, MaxHostnameLength, got)
})
t.Run("long hostname is cut at a rune boundary", func(t *testing.T) {
long := strings.Repeat("é", 1<<19)
hi := stock()
hi.Hostname = long
require.NoError(t, BoundHostinfo(hi))
require.True(t, utf8.ValidString(hi.Hostname), "cut must not split a rune")
require.LessOrEqual(t, len(hi.Hostname), MaxHostnameLength)
require.GreaterOrEqual(t, len(hi.Hostname), MaxHostnameLength-utf8.UTFMax)
require.True(t, strings.HasPrefix(long, hi.Hostname))
})
t.Run("routes are cut at the route bound", func(t *testing.T) {
hi := stock()
hi.RoutableIPs = make([]netip.Prefix, 5000)
for i := range hi.RoutableIPs {
addr := netip.AddrFrom4([4]byte{10, 0, byte(i >> 8), byte(i)})
hi.RoutableIPs[i] = netip.PrefixFrom(addr, 32)
}
routes := hi.RoutableIPs
require.NoError(t, BoundHostinfo(hi))
require.Len(t, hi.RoutableIPs, MaxRoutableIPs)
require.NotSame(t, &routes[0], &hi.RoutableIPs[0])
require.Equal(t, routes[:MaxRoutableIPs], hi.RoutableIPs)
})
t.Run("routes do not count against the size budget", func(t *testing.T) {
hi := stock()
hi.RoutableIPs = make([]netip.Prefix, MaxRoutableIPs)
for i := range hi.RoutableIPs {
addr := netip.AddrFrom16(
[16]byte{0xfd, 0, 0, 0, 0, 0, byte(i >> 8), byte(i)},
)
hi.RoutableIPs[i] = netip.PrefixFrom(addr, 64)
}
require.NoError(t, BoundHostinfo(hi))
require.Len(t, hi.RoutableIPs, MaxRoutableIPs)
})
t.Run("large DERP latency map is dropped", func(t *testing.T) {
hi := stock()
hi.NetInfo.DERPLatency = make(map[string]float64, 1000)
for i := range 1000 {
hi.NetInfo.DERPLatency[fmt.Sprintf("%d-v4", i)] = 0.01
}
require.NoError(t, BoundHostinfo(hi))
require.Nil(t, hi.NetInfo.DERPLatency)
require.Equal(t, tailcfg.DERPRegionID(1), hi.NetInfo.PreferredDERP,
"the rest of NetInfo is kept")
})
t.Run("oversized remaining fields are refused", func(t *testing.T) {
hi := stock()
hi.OSVersion = strings.Repeat("v", 64<<10)
err := BoundHostinfo(hi)
require.ErrorIs(t, err, ErrHostinfoTooLarge)
})
t.Run("NetInfo has a budget of its own", func(t *testing.T) {
// A report without NetInfo keeps the stored one, so what is stored
// can hold a full NetInfo next to full other fields.
hi := stock()
hi.OSVersion = strings.Repeat("v", 60<<10)
hi.NetInfo.LinkType = strings.Repeat("l", 60<<10)
require.NoError(t, BoundHostinfo(hi))
hi.NetInfo.LinkType = strings.Repeat("l", 64<<10)
require.ErrorIs(t, BoundHostinfo(hi), ErrHostinfoTooLarge)
})
t.Run("nil is fine", func(t *testing.T) {
require.NoError(t, BoundHostinfo(nil))
})
}
func TestBoundMapRequest(t *testing.T) {
endpoints := make([]netip.AddrPort, 1000)
endpointTypes := make([]tailcfg.EndpointType, 1000)
for i := range endpoints {
endpoints[i] = netip.AddrPortFrom(netip.MustParseAddr("192.0.2.1"), uint16(i))
endpointTypes[i] = tailcfg.EndpointType(i % 5)
}
req := &tailcfg.MapRequest{
Endpoints: slices.Clone(endpoints),
EndpointTypes: slices.Clone(endpointTypes),
Hostinfo: &tailcfg.Hostinfo{Hostname: strings.Repeat("a", 1<<20)},
}
require.NoError(t, BoundMapRequest(req))
require.Equal(t, endpoints[:MaxReportedListLen], req.Endpoints)
require.Equal(t, endpointTypes[:MaxReportedListLen], req.EndpointTypes,
"types must stay aligned with their endpoints")
gotHostname := len(req.Hostinfo.Hostname)
require.Equal(t, MaxHostnameLength, gotHostname, "the Hostinfo is bounded too")
t.Run("no Hostinfo and no endpoints", func(t *testing.T) {
req := &tailcfg.MapRequest{}
require.NoError(t, BoundMapRequest(req))
require.Nil(t, req.Endpoints, "nil endpoints mean unchanged and must stay nil")
})
t.Run("oversized Hostinfo is refused", func(t *testing.T) {
req := &tailcfg.MapRequest{
Hostinfo: &tailcfg.Hostinfo{OSVersion: strings.Repeat("v", 64<<10)},
}
require.ErrorIs(t, BoundMapRequest(req), ErrHostinfoTooLarge)
})
}
// TestBoundMapRequestRepeatedKeys pins that a list sent twice in one body
// keeps nothing of the first. The decoder reuses the slice for the second
// list, so a bound that only reads the length leaves the first in capacity.
func TestBoundMapRequestRepeatedKeys(t *testing.T) {
report := func(n int) []byte {
req := tailcfg.MapRequest{Hostinfo: &tailcfg.Hostinfo{}}
hi := req.Hostinfo
for i := range n {
req.Endpoints = append(req.Endpoints,
netip.AddrPortFrom(netip.MustParseAddr("192.0.2.1"), uint16(i)))
req.EndpointTypes = append(req.EndpointTypes, tailcfg.EndpointLocal)
hi.RoutableIPs = append(hi.RoutableIPs,
netip.PrefixFrom(netip.AddrFrom4([4]byte{10, 0, 0, byte(i)}), 32))
hi.RequestTags = append(hi.RequestTags, fmt.Sprintf("tag:t%d", i))
hi.WoLMACs = append(hi.WoLMACs, fmt.Sprintf("00:00:00:00:00:%02x", i))
hi.SSH_HostKeys = append(hi.SSH_HostKeys, fmt.Sprintf("key-%d", i))
hi.Services = append(hi.Services, tailcfg.Service{
Proto: tailcfg.TCP,
Port: uint16(i),
Description: strings.Repeat("d", 1<<10),
})
}
encoded, err := json.Marshal(req)
require.NoError(t, err)
return encoded
}
// Every key of the long report appears again with a one-entry list.
long, short := report(8), report(1)
raw := slices.Concat(long[:len(long)-1], []byte{','}, short[1:])
var req tailcfg.MapRequest
require.NoError(t, json.Unmarshal(raw, &req))
require.NoError(t, BoundMapRequest(&req))
hi := req.Hostinfo
for name, lenCap := range map[string][2]int{
"Endpoints": {len(req.Endpoints), cap(req.Endpoints)},
"EndpointTypes": {len(req.EndpointTypes), cap(req.EndpointTypes)},
"RoutableIPs": {len(hi.RoutableIPs), cap(hi.RoutableIPs)},
"RequestTags": {len(hi.RequestTags), cap(hi.RequestTags)},
"WoLMACs": {len(hi.WoLMACs), cap(hi.WoLMACs)},
"SSH_HostKeys": {len(hi.SSH_HostKeys), cap(hi.SSH_HostKeys)},
"Services": {len(hi.Services), cap(hi.Services)},
} {
require.Equal(t, 1, lenCap[0], name)
require.Equal(t, lenCap[0], lenCap[1],
"%s keeps the first list past its length", name)
}
}