From 3a9a1a6a4cc3b636e70cb54b0467b2b4087fe65a Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 21 Oct 2026 17:55:04 +0000 Subject: [PATCH] mapper: trim the Hostinfo sent to peers and to the node itself As on Tailscale SaaS, the node's own entry keeps Hostname, RequestTags, RoutableIPs and Services; peers get Hostname, OS, Services without peerapi-dns-proxy, SSH host keys and AppConnector, and resend on those. --- hscontrol/mapper/builder.go | 3 + hscontrol/mapper/mapper_test.go | 1 + hscontrol/servertest/issues_test.go | 37 +++-------- hscontrol/servertest/mapstream_compat_test.go | 15 ----- hscontrol/servertest/routes_test.go | 66 +------------------ hscontrol/state/maprequest.go | 50 ++------------ hscontrol/state/maprequest_test.go | 57 ++-------------- hscontrol/types/node.go | 47 +++++++++++++ integration/helpers.go | 9 +-- 9 files changed, 76 insertions(+), 209 deletions(-) diff --git a/hscontrol/mapper/builder.go b/hscontrol/mapper/builder.go index 03da7ccb7..2ff6ffacb 100644 --- a/hscontrol/mapper/builder.go +++ b/hscontrol/mapper/builder.go @@ -114,6 +114,8 @@ func (b *MapResponseBuilder) WithSelfNode() *MapResponseBuilder { tailnode.MachineAuthorized = true tailnode.Expired = false + tailnode.Hostinfo = types.SelfHostinfo(tailnode.Hostinfo) + b.resp.Node = tailnode return b @@ -319,6 +321,7 @@ func (b *MapResponseBuilder) buildTailPeers(peers views.Slice[types.NodeView]) ( // cap stays off the peer view, leaving CapMap empty for most // peers. [policyv2.PeerCapMap] encodes those conditions. tn.CapMap = policyv2.PeerCapMap(peer, peerCapMaps[peer.ID()]) + tn.Hostinfo = types.PeerHostinfo(tn.Hostinfo) tailPeers = append(tailPeers, tn) } diff --git a/hscontrol/mapper/mapper_test.go b/hscontrol/mapper/mapper_test.go index 6da031e1e..7d80db824 100644 --- a/hscontrol/mapper/mapper_test.go +++ b/hscontrol/mapper/mapper_test.go @@ -1774,6 +1774,7 @@ func (b *MapResponseBuilder) buildTailPeersCapMapsReference(peers views.Slice[ty // cap stays off the peer view, leaving CapMap empty for most // peers. [policyv2.PeerCapMap] encodes those conditions. tn.CapMap = policyv2.PeerCapMap(peer, allCapMaps[peer.ID()]) + tn.Hostinfo = types.PeerHostinfo(tn.Hostinfo) tailPeers = append(tailPeers, tn) } diff --git a/hscontrol/servertest/issues_test.go b/hscontrol/servertest/issues_test.go index 0a51c83ea..1479c0292 100644 --- a/hscontrol/servertest/issues_test.go +++ b/hscontrol/servertest/issues_test.go @@ -240,7 +240,7 @@ func TestIssuesRoutes(t *testing.T) { servertest.WithUser(user)) exit.WaitForPeers(t, 1, 10*time.Second) - advertiseRoutes(t, exit, obs, tsaddr.ExitRoutes()) + advertiseRoutes(t, exit, tsaddr.ExitRoutes()) _, c, err := srv.State().SetApprovedRoutes( findNodeID(t, srv, "selfexit-node"), tsaddr.ExitRoutes()) @@ -274,7 +274,7 @@ func TestIssuesRoutes(t *testing.T) { require.NoError(t, err) srv.App.Change(c) - advertiseRoutes(t, exit, obs, tsaddr.ExitRoutes()) + advertiseRoutes(t, exit, tsaddr.ExitRoutes()) obs.WaitForCondition(t, "peer offers exit node", 10*time.Second, peerOffersExit("preexit-node")) @@ -306,7 +306,7 @@ func TestIssuesRoutes(t *testing.T) { servertest.WithUser(user)) exit.WaitForPeers(t, 1, 10*time.Second) - advertiseRoutes(t, exit, obs, tsaddr.ExitRoutes()) + advertiseRoutes(t, exit, tsaddr.ExitRoutes()) obs.WaitForCondition(t, "peer offers exit node", 10*time.Second, peerOffersExit("advexit-node")) @@ -323,7 +323,7 @@ func TestIssuesRoutes(t *testing.T) { c1 := servertest.NewClient(t, srv, "histore-node1", servertest.WithUser(user)) - c2 := servertest.NewClient(t, srv, "histore-node2", + servertest.NewClient(t, srv, "histore-node2", servertest.WithUser(user)) c1.WaitForPeers(t, 1, 10*time.Second) @@ -339,19 +339,7 @@ func TestIssuesRoutes(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - _ = c1.Direct().SendUpdate(ctx) - - c2.WaitForCondition(t, "route in peer hostinfo", 10*time.Second, - func(nm *netmap.NetworkMap) bool { - for _, p := range nm.Peers { - hi := p.Hostinfo() - if hi.Valid() && hi.Hostname() == "histore-node1" { - return hi.RoutableIPs().Len() > 0 - } - } - - return false - }) + require.NoError(t, c1.Direct().SendUpdate(ctx)) nodeID := findNodeID(t, srv, "histore-node1") nv, ok := srv.State().GetNodeByID(nodeID) @@ -1165,9 +1153,9 @@ func findNodeID(tb testing.TB, srv *servertest.TestServer, hostname string) type return 0 } -// advertiseRoutes pushes routes in c's Hostinfo and waits until obs sees -// them, so the server has stored the announcement. -func advertiseRoutes(tb testing.TB, c, obs *servertest.TestClient, routes []netip.Prefix) { +// advertiseRoutes pushes routes in c's Hostinfo. The server stores the +// announcement before it answers. +func advertiseRoutes(tb testing.TB, c *servertest.TestClient, routes []netip.Prefix) { tb.Helper() c.Direct().SetHostinfo(&tailcfg.Hostinfo{ @@ -1179,14 +1167,7 @@ func advertiseRoutes(tb testing.TB, c, obs *servertest.TestClient, routes []neti ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - _ = c.Direct().SendUpdate(ctx) - - obs.WaitForCondition(tb, "routes in peer hostinfo", 10*time.Second, - func(nm *netmap.NetworkMap) bool { - p, ok := peerByHostname(nm, c.Name) - - return ok && p.Hostinfo().RoutableIPs().Len() == len(routes) - }) + require.NoError(tb, c.Direct().SendUpdate(ctx)) } // selfOffersExit mirrors how tailscaled computes Self.ExitNodeOption. diff --git a/hscontrol/servertest/mapstream_compat_test.go b/hscontrol/servertest/mapstream_compat_test.go index 12d69228e..18098a4ce 100644 --- a/hscontrol/servertest/mapstream_compat_test.go +++ b/hscontrol/servertest/mapstream_compat_test.go @@ -82,18 +82,6 @@ var mapStreamSkipReasons = map[string]string{ "map-delivery/S3.6/bystander/peers": sameUserPeers, "map-delivery/S3.6/laptop/peers": sameUserPeers, - "map-delivery/S2.0/appc/self-hostinfo-keys": selfHostinfoWhole, - "map-delivery/S2.0/bystander/self-hostinfo-keys": selfHostinfoWhole, - "map-delivery/S2.0/sshd/self-hostinfo-keys": selfHostinfoWhole, - - "map-delivery/S2.0/appc/peer-hostinfo-keys": peerHostinfoWhole, - "map-delivery/S2.0/bystander/peer-hostinfo-keys": peerHostinfoWhole, - "map-delivery/S2.0/sshd/peer-hostinfo-keys": peerHostinfoWhole, - - "map-delivery/S2.0/appc/peer-hostinfo": peerServicesWhole, - "map-delivery/S2.0/bystander/peer-hostinfo": peerServicesWhole, - "map-delivery/S2.0/sshd/peer-hostinfo": peerServicesWhole, - "hidden-peers/S9/observer/removed": removalSentToAll, "hidden-peers/S10/observer/removed": removalSentToAll, "hidden-peers/S13/observer/removed": removalSentToAll, @@ -141,9 +129,6 @@ const ( restartEndpointsKept = "RESTART_ENDPOINTS_KEPT: headscale keeps a restarted node's last endpoints at its peers until it reports new ones; SaaS sends peers the node without endpoints until then" reconnectAnnounced = "RECONNECT_ANNOUNCED: headscale tells peers a node is online after a stream gap of a few seconds; SaaS sends them nothing" routerReconnectRecompute = "ROUTER_RECONNECT_RECOMPUTE: headscale sends every node a policy response when a subnet router's stream comes back after a few seconds; SaaS sends nothing" - selfHostinfoWhole = "SELF_HOSTINFO_WHOLE: headscale sends a node its whole reported Hostinfo; SaaS sends only Hostname, RequestTags and Services" - peerHostinfoWhole = "PEER_HOSTINFO_WHOLE: headscale sends peers a node's whole reported Hostinfo; SaaS sends only Hostname, OS, Services, the SSH host keys and AppConnector" - peerServicesWhole = "PEER_SERVICES_WHOLE: headscale sends peers every service a node reports; SaaS leaves out peerapi-dns-proxy" removalSentToAll = "REMOVAL_SENT_TO_ALL: headscale tells every connected node that a deleted node is gone; SaaS tells only the nodes that had it as a peer" taggedDeviceUser = "TAGGED_DEVICE_USER: SaaS shows a tagged device's user as the login that made its key; headscale shows tagged-devices" ) diff --git a/hscontrol/servertest/routes_test.go b/hscontrol/servertest/routes_test.go index de304f7aa..ad0e101a1 100644 --- a/hscontrol/servertest/routes_test.go +++ b/hscontrol/servertest/routes_test.go @@ -60,54 +60,6 @@ func TestRoutes(t *testing.T) { } }) - t.Run("advertised_routes_in_hostinfo", func(t *testing.T) { - t.Parallel() - - srv := servertest.NewServer(t) - user := srv.CreateUser(t, "advroute-user") - - routePrefix := netip.MustParsePrefix("192.168.1.0/24") - - c1 := servertest.NewClient(t, srv, "advroute-node1", - servertest.WithUser(user)) - c2 := servertest.NewClient(t, srv, "advroute-node2", - servertest.WithUser(user)) - - c1.WaitForPeers(t, 1, 10*time.Second) - - // Update hostinfo with advertised routes. - c1.Direct().SetHostinfo(&tailcfg.Hostinfo{ - BackendLogID: "servertest-advroute-node1", - Hostname: "advroute-node1", - RoutableIPs: []netip.Prefix{routePrefix}, - }) - - // Send a non-streaming update to push the new [tailcfg.Hostinfo]. - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - _ = c1.Direct().SendUpdate(ctx) - - // The observer should eventually see the advertised routes - // in the peer's [tailcfg.Hostinfo]. - c2.WaitForCondition(t, "advertised route in hostinfo", - 15*time.Second, - func(nm *netmap.NetworkMap) bool { - for _, p := range nm.Peers { - hi := p.Hostinfo() - if hi.Valid() && hi.Hostname() == "advroute-node1" { - for i := range hi.RoutableIPs().Len() { - if hi.RoutableIPs().At(i) == routePrefix { - return true - } - } - } - } - - return false - }) - }) - t.Run("route_advertise_and_approve", func(t *testing.T) { t.Parallel() @@ -134,22 +86,8 @@ func TestRoutes(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - _ = c1.Direct().SendUpdate(ctx) - - // Wait for the server to process the [tailcfg.Hostinfo] update - // by waiting for observer to see the advertised route. - c2.WaitForCondition(t, "hostinfo update propagated", - 10*time.Second, - func(nm *netmap.NetworkMap) bool { - for _, p := range nm.Peers { - hi := p.Hostinfo() - if hi.Valid() && hi.Hostname() == "fullrt-advertiser" { - return hi.RoutableIPs().Len() > 0 - } - } - - return false - }) + // The server stores the announcement before it answers. + require.NoError(t, c1.Direct().SendUpdate(ctx)) // Step 2: Approve the route on the server. nodeID := findNodeID(t, srv, "fullrt-advertiser") diff --git a/hscontrol/state/maprequest.go b/hscontrol/state/maprequest.go index edbe9cac8..8565d5479 100644 --- a/hscontrol/state/maprequest.go +++ b/hscontrol/state/maprequest.go @@ -34,7 +34,7 @@ type mapRequestDelta struct { hostinfoChanged bool // peerHostinfoChanged reports whether a Hostinfo field that peers read - // changed (see [peerHostinfo]). Only that forces a whole-node resend. + // changed (see [peerHostinfoEqual]). Only that forces a whole-node resend. peerHostinfoChanged bool // dnsMetadataChanged reports whether a Hostinfo field feeding the @@ -153,58 +153,18 @@ func hostinfoEqual(oldHI, newHI *tailcfg.Hostinfo) bool { return oldCopy.Equal(&newCopy) } -// peerHostinfoEqual reports whether a and b agree on the Hostinfo fields -// another node's client reads from a peer: what tailscale status shows, PeerAPI -// services, SSH known hosts, exit-node location, app-connector eligibility, and -// the routes this server feeds into policy. Everything else is stored but never -// fanned out, and a peer's NetInfo is never read at all. +// peerHostinfoEqual reports whether a and b agree on what peers are sent of +// them ([types.PeerHostinfo]) and on the routes this server feeds into policy. +// Everything else is stored but never fanned out. func peerHostinfoEqual(a, b tailcfg.HostinfoView) bool { if !a.Valid() || !b.Valid() { return a.Valid() == b.Valid() } - return a.Hostname() == b.Hostname() && - a.OS() == b.OS() && - servicesEqual(a.Services(), b.Services()) && - views.SliceEqual(a.SSH_HostKeys(), b.SSH_HostKeys()) && - locationEqual(a.Location(), b.Location()) && - a.AppConnector() == b.AppConnector() && + return types.PeerHostinfo(a).Equal(types.PeerHostinfo(b)) && views.SliceEqual(a.RoutableIPs(), b.RoutableIPs()) } -// servicesEqual compares field by field, as [tailcfg.Service] is -// incomparable. -func servicesEqual(a, b views.Slice[tailcfg.Service]) bool { - if a.Len() != b.Len() { - return false - } - - for i := range a.Len() { - x, y := a.At(i), b.At(i) - if x.Proto != y.Proto || x.Port != y.Port || x.Description != y.Description { - return false - } - } - - return true -} - -// locationEqual compares field by field, as [tailcfg.LocationView] has no -// Equal. -func locationEqual(a, b tailcfg.LocationView) bool { - if !a.Valid() || !b.Valid() { - return a.Valid() == b.Valid() - } - - return a.Country() == b.Country() && - a.CountryCode() == b.CountryCode() && - a.City() == b.City() && - a.CityCode() == b.CityCode() && - a.Latitude() == b.Latitude() && - a.Longitude() == b.Longitude() && - a.Priority() == b.Priority() -} - // netInfoEqualIgnoringDERP compares two NetInfo values via // [tailcfg.NetInfo.BasicallyEqual] after zeroing PreferredDERP on both, so // that DERP-only changes do not appear here (they are tracked separately). diff --git a/hscontrol/state/maprequest_test.go b/hscontrol/state/maprequest_test.go index e16fb35c5..cc321332e 100644 --- a/hscontrol/state/maprequest_test.go +++ b/hscontrol/state/maprequest_test.go @@ -4,7 +4,6 @@ import ( "fmt" "net/netip" "path/filepath" - "reflect" "strings" "sync" "sync/atomic" @@ -886,7 +885,13 @@ func TestPeerHostinfoEqual(t *testing.T) { want: false, }, {name: "ssh host keys", mutate: func(hi *tailcfg.Hostinfo) { hi.SSH_HostKeys = []string{"ssh-ed25519 AAAA"} }, want: false}, - {name: "location", mutate: func(hi *tailcfg.Hostinfo) { hi.Location = &tailcfg.Location{Priority: 5} }, want: false}, + {name: "location", mutate: func(hi *tailcfg.Hostinfo) { hi.Location = &tailcfg.Location{Priority: 5} }, want: true}, + { + name: "dns proxy service", + mutate: func(hi *tailcfg.Hostinfo) { hi.Services = []tailcfg.Service{{Proto: tailcfg.PeerAPIDNS, Port: 1}} }, + want: true, + }, + {name: "app connector false", mutate: func(hi *tailcfg.Hostinfo) { hi.AppConnector = opt.NewBool(false) }, want: true}, {name: "app connector", mutate: func(hi *tailcfg.Hostinfo) { hi.AppConnector = opt.NewBool(true) }, want: false}, { name: "routes", @@ -907,54 +912,6 @@ func TestPeerHostinfoEqual(t *testing.T) { require.False(t, peerHostinfoEqual(tailcfg.HostinfoView{}, base().View())) } -// TestPeerHostinfoEqualSeesEveryField fails when tailscale adds a field to -// [tailcfg.Service] or [tailcfg.Location] that servicesEqual or locationEqual -// does not compare. -func TestPeerHostinfoEqualSeesEveryField(t *testing.T) { - set := func(t *testing.T, f reflect.Value) { - t.Helper() - - switch { - case f.Kind() == reflect.String: - f.SetString("x") - case f.CanInt(): - f.SetInt(1) - case f.CanUint(): - f.SetUint(1) - case f.CanFloat(): - f.SetFloat(1) - default: - t.Fatalf("field kind %s not handled; compare it", f.Kind()) - } - } - - for _, field := range reflect.VisibleFields(reflect.TypeFor[tailcfg.Service]()) { - if !field.IsExported() { - continue - } - - t.Run("Service."+field.Name, func(t *testing.T) { - var svc tailcfg.Service - set(t, reflect.ValueOf(&svc).Elem().FieldByIndex(field.Index)) - - a := &tailcfg.Hostinfo{Services: []tailcfg.Service{{}}} - b := &tailcfg.Hostinfo{Services: []tailcfg.Service{svc}} - assert.False(t, peerHostinfoEqual(a.View(), b.View())) - }) - } - - for _, field := range reflect.VisibleFields(reflect.TypeFor[tailcfg.Location]()) { - t.Run("Location."+field.Name, func(t *testing.T) { - var loc tailcfg.Location - set(t, reflect.ValueOf(&loc).Elem().FieldByIndex(field.Index)) - - a := &tailcfg.Hostinfo{Location: &tailcfg.Location{}} - b := &tailcfg.Hostinfo{Location: &loc} - assert.False(t, peerHostinfoEqual(a.View(), b.View())) - }) - } -} - func TestNetInfoEqualIgnoringDERP(t *testing.T) { t.Run("both nil", func(t *testing.T) { require.True(t, netInfoEqualIgnoringDERP(nil, nil)) diff --git a/hscontrol/types/node.go b/hscontrol/types/node.go index 19818902a..6aa89a08a 100644 --- a/hscontrol/types/node.go +++ b/hscontrol/types/node.go @@ -22,6 +22,7 @@ import ( "tailscale.com/tailcfg" "tailscale.com/tailcfg/nodecap" "tailscale.com/types/key" + "tailscale.com/types/opt" "tailscale.com/types/views" "tailscale.com/util/dnsname" "tailscale.com/util/set" @@ -1558,3 +1559,49 @@ func (nv NodeView) TailNode( return &tNode, nil } + +// SelfHostinfo is the part of a node's reported Hostinfo that its own map +// entry carries, as on Tailscale SaaS. +func SelfHostinfo(hi tailcfg.HostinfoView) tailcfg.HostinfoView { + if !hi.Valid() { + return hi + } + + return (&tailcfg.Hostinfo{ + Hostname: hi.Hostname(), + RequestTags: hi.RequestTags().AsSlice(), + RoutableIPs: hi.RoutableIPs().AsSlice(), + Services: hi.Services().AsSlice(), + }).View() +} + +// PeerHostinfo is the part of a node's reported Hostinfo that its peers are +// sent, as on Tailscale SaaS. Clients find a peer's DNS proxy by its +// capability version, so the service announcing it is left out. Empty +// fields stay nil, so two results compare equal by content. +func PeerHostinfo(hi tailcfg.HostinfoView) tailcfg.HostinfoView { + if !hi.Valid() { + return hi + } + + out := &tailcfg.Hostinfo{ + Hostname: hi.Hostname(), + OS: hi.OS(), + } + + for _, s := range hi.Services().All() { + if s.Proto != tailcfg.PeerAPIDNS { + out.Services = append(out.Services, s) + } + } + + if hi.SSH_HostKeys().Len() > 0 { + out.SSH_HostKeys = hi.SSH_HostKeys().AsSlice() + } + + if hi.AppConnector().EqualBool(true) { + out.AppConnector = opt.NewBool(true) + } + + return out.View() +} diff --git a/integration/helpers.go b/integration/helpers.go index 91eea56d9..469451368 100644 --- a/integration/helpers.go +++ b/integration/helpers.go @@ -817,14 +817,9 @@ func assertValidNetmap(t *testing.T, client TailscaleClient) { assert.Truef(c, peer.Hostinfo().Valid(), "peer (%s) of %q does not have Hostinfo", peer.ComputedName(), client.Hostname()) + // Peers get peerapi4 and peerapi6, not peerapi-dns-proxy. if hi := peer.Hostinfo(); hi.Valid() { - assert.LessOrEqualf(c, 3, peer.Hostinfo().Services().Len(), "peer (%s) of %q does not have enough services, got: %v", peer.ComputedName(), client.Hostname(), peer.Hostinfo().Services()) - - // Netinfo is not always set - // assert.Truef(c, hi.NetInfo().Valid(), "peer (%s) of %q does not have NetInfo", peer.ComputedName(), client.Hostname()) - if ni := hi.NetInfo(); ni.Valid() { - assert.NotEqualf(c, 0, ni.PreferredDERP(), "peer (%s) has no home DERP in %q's netmap, got: %s", peer.ComputedName(), client.Hostname(), peer.Hostinfo().NetInfo().PreferredDERP()) - } + assert.LessOrEqualf(c, 2, peer.Hostinfo().Services().Len(), "peer (%s) of %q does not have enough services, got: %v", peer.ComputedName(), client.Hostname(), peer.Hostinfo().Services()) } assert.NotEmptyf(c, peer.Endpoints(), "peer (%s) of %q does not have any endpoints", peer.ComputedName(), client.Hostname())