mirror of
https://github.com/juanfont/headscale.git
synced 2026-11-04 12:39:25 +09:00
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.
This commit is contained in:
1 parent
11cff1da0b
commit
c0f1f48483
5 files changed
+576
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user