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.
This commit is contained in:
Kristoffer Dalby committed 2026-10-27 21:32:33 +00:00
1 parent 0fdbe12e9e
commit b67228a31a
9 files changed
+76 -209

No files matched your search

+3
View File
@@ -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)
}
+1
View File
@@ -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)
}
+9 -28
View File
@@ -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.
@@ -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"
)
+2 -64
View File
@@ -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")
+5 -45
View File
@@ -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).
+7 -50
View File
@@ -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))
+47
View File
@@ -20,6 +20,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"
@@ -1453,3 +1454,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()
}
+2 -7
View File
@@ -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())