From ebdb5c94fae685c90ec47b18510175b9f312950e Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 21 Oct 2026 15:37:41 +0000 Subject: [PATCH] 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. --- hscontrol/noise.go | 18 +++ hscontrol/noise_test.go | 165 +++++++++++++++++++ hscontrol/types/const.go | 8 + hscontrol/types/node.go | 89 +++++++++++ hscontrol/types/node_test.go | 296 +++++++++++++++++++++++++++++++++++ 5 files changed, 576 insertions(+) diff --git a/hscontrol/noise.go b/hscontrol/noise.go index 6f11fab41..137764f0e 100644 --- a/hscontrol/noise.go +++ b/hscontrol/noise.go @@ -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(®isterRequest) + // 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. diff --git a/hscontrol/noise_test.go b/hscontrol/noise_test.go index dadde22ef..0a1bb7131 100644 --- a/hscontrol/noise_test.go +++ b/hscontrol/noise_test.go @@ -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) + }) +} diff --git a/hscontrol/types/const.go b/hscontrol/types/const.go index 1b75c89d9..b53a065c0 100644 --- a/hscontrol/types/const.go +++ b/hscontrol/types/const.go @@ -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 +) diff --git a/hscontrol/types/node.go b/hscontrol/types/node.go index 5e6ce9c88..292ec41c2 100644 --- a/hscontrol/types/node.go +++ b/hscontrol/types/node.go @@ -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" @@ -24,6 +26,7 @@ import ( "tailscale.com/types/views" "tailscale.com/util/dnsname" "tailscale.com/util/set" + "tailscale.com/util/truncate" ) var ( @@ -36,6 +39,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 @@ -713,6 +717,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. diff --git a/hscontrol/types/node_test.go b/hscontrol/types/node_test.go index 95c516837..5ad1d634b 100644 --- a/hscontrol/types/node_test.go +++ b/hscontrol/types/node_test.go @@ -1,12 +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" @@ -18,6 +20,7 @@ import ( "tailscale.com/net/tsaddr" "tailscale.com/tailcfg" "tailscale.com/types/key" + "tailscale.com/util/dnsname" ) func Test_NodeCanAccess(t *testing.T) { @@ -1389,3 +1392,296 @@ func canAccessRouteReference( return false } + +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) + } +}