2025-07-28 11:15:53 +02:00
|
|
|
package mapper
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"net/netip"
|
policy/v2,state,mapper: implement per-viewer via route steering
Via grants steer routes to specific nodes per viewer. Until now,
all clients saw the same routes for each peer because route
assembly was viewer-independent. This implements per-viewer route
visibility so that via-designated peers serve routes only to
matching viewers, while non-designated peers have those routes
withdrawn.
Add ViaRouteResult type (Include/Exclude prefix lists) and
ViaRoutesForPeer to the PolicyManager interface. The v2
implementation iterates via grants, resolves sources against the
viewer, matches destinations against the peer's advertised routes
(both subnet and exit), and categorizes prefixes by whether the
peer has the via tag.
Add RoutesForPeer to State which composes global primary election,
via Include/Exclude filtering, exit routes, and ACL reduction.
When no via grants exist, it falls back to existing behavior.
Update the mapper to call RoutesForPeer per-peer instead of using
a single route function for all peers. The route function now
returns all routes (subnet + exit), and TailNode filters exit
routes out of the PrimaryRoutes field for HA tracking.
Updates #2180
2026-03-22 20:43:28 +00:00
|
|
|
"slices"
|
2025-07-28 11:15:53 +02:00
|
|
|
"sort"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/juanfont/headscale/hscontrol/policy"
|
2026-05-11 14:52:04 +00:00
|
|
|
policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2"
|
2025-07-28 11:15:53 +02:00
|
|
|
"github.com/juanfont/headscale/hscontrol/types"
|
|
|
|
|
"tailscale.com/tailcfg"
|
|
|
|
|
"tailscale.com/types/views"
|
|
|
|
|
"tailscale.com/util/multierr"
|
|
|
|
|
)
|
|
|
|
|
|
all: apply godoc [Name] link conventions across comments
Every Go-identifier reference in // and /* */ comments now uses
godoc's [Name] linking syntax so pkg.go.dev and `go doc` render
them as clickable cross-references. No behaviour change.
Pattern applied across the tree:
In-package [Foo], [Foo.Bar]
Cross-package [pkg.Foo], [pkg.Foo.Bar]
Stdlib [netip.Prefix], [errors.Is], [context.Context]
Tailscale [tailcfg.MapResponse], [tailcfg.Node.CapMap],
[tailcfg.NodeAttrSuggestExitNode]
Skip rules:
- File:line refs left as plain text
- HuJSON wire keys inside backtick raw strings untouched
- ACL/policy syntax tokens (tag:foo, autogroup:self, ...) not Go
symbols, left as plain text
- JSON/OIDC wire keys, gorm tags, RFC IPv6 placeholders, markdown
link tags, decorative dividers — all left as-is
2026-05-18 18:35:53 +00:00
|
|
|
// MapResponseBuilder provides a fluent interface for building [tailcfg.MapResponse].
|
2025-07-28 11:15:53 +02:00
|
|
|
type MapResponseBuilder struct {
|
|
|
|
|
resp *tailcfg.MapResponse
|
|
|
|
|
mapper *mapper
|
|
|
|
|
nodeID types.NodeID
|
|
|
|
|
capVer tailcfg.CapabilityVersion
|
|
|
|
|
errs []error
|
2025-09-05 16:32:46 +02:00
|
|
|
|
|
|
|
|
debugType debugType
|
2025-07-28 11:15:53 +02:00
|
|
|
}
|
|
|
|
|
|
2025-07-05 23:30:47 +02:00
|
|
|
type debugType string
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
fullResponseDebug debugType = "full"
|
2025-09-17 14:23:21 +02:00
|
|
|
selfResponseDebug debugType = "self"
|
2025-07-05 23:30:47 +02:00
|
|
|
changeResponseDebug debugType = "change"
|
2025-12-15 14:36:21 +00:00
|
|
|
policyResponseDebug debugType = "policy"
|
2025-07-05 23:30:47 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// NewMapResponseBuilder creates a new builder with basic fields set.
|
2025-07-28 11:15:53 +02:00
|
|
|
func (m *mapper) NewMapResponseBuilder(nodeID types.NodeID) *MapResponseBuilder {
|
|
|
|
|
now := time.Now()
|
2026-02-06 21:45:32 +01:00
|
|
|
|
2025-07-28 11:15:53 +02:00
|
|
|
return &MapResponseBuilder{
|
|
|
|
|
resp: &tailcfg.MapResponse{
|
|
|
|
|
KeepAlive: false,
|
|
|
|
|
ControlTime: &now,
|
|
|
|
|
},
|
|
|
|
|
mapper: m,
|
|
|
|
|
nodeID: nodeID,
|
|
|
|
|
errs: nil,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-05 23:30:47 +02:00
|
|
|
// addError adds an error to the builder's error list.
|
2025-07-28 11:15:53 +02:00
|
|
|
func (b *MapResponseBuilder) addError(err error) {
|
|
|
|
|
if err != nil {
|
|
|
|
|
b.errs = append(b.errs, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-05 23:30:47 +02:00
|
|
|
// hasErrors returns true if the builder has accumulated any errors.
|
2025-07-28 11:15:53 +02:00
|
|
|
func (b *MapResponseBuilder) hasErrors() bool {
|
|
|
|
|
return len(b.errs) > 0
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-05 23:30:47 +02:00
|
|
|
// WithCapabilityVersion sets the capability version for the response.
|
2025-07-28 11:15:53 +02:00
|
|
|
func (b *MapResponseBuilder) WithCapabilityVersion(capVer tailcfg.CapabilityVersion) *MapResponseBuilder {
|
|
|
|
|
b.capVer = capVer
|
|
|
|
|
return b
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-05 23:30:47 +02:00
|
|
|
// WithSelfNode adds the requesting node to the response.
|
2025-07-28 11:15:53 +02:00
|
|
|
func (b *MapResponseBuilder) WithSelfNode() *MapResponseBuilder {
|
2025-09-17 14:23:21 +02:00
|
|
|
nv, ok := b.mapper.state.GetNodeByID(b.nodeID)
|
2025-07-05 23:30:47 +02:00
|
|
|
if !ok {
|
2026-02-06 21:45:32 +01:00
|
|
|
b.addError(ErrNodeNotFoundMapper)
|
2025-07-28 11:15:53 +02:00
|
|
|
return b
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_, matchers := b.mapper.state.Filter()
|
2025-12-10 09:16:22 +01:00
|
|
|
|
|
|
|
|
tailnode, err := nv.TailNode(
|
|
|
|
|
b.capVer,
|
2025-07-28 11:15:53 +02:00
|
|
|
func(id types.NodeID) []netip.Prefix {
|
policy/v2,state,mapper: implement per-viewer via route steering
Via grants steer routes to specific nodes per viewer. Until now,
all clients saw the same routes for each peer because route
assembly was viewer-independent. This implements per-viewer route
visibility so that via-designated peers serve routes only to
matching viewers, while non-designated peers have those routes
withdrawn.
Add ViaRouteResult type (Include/Exclude prefix lists) and
ViaRoutesForPeer to the PolicyManager interface. The v2
implementation iterates via grants, resolves sources against the
viewer, matches destinations against the peer's advertised routes
(both subnet and exit), and categorizes prefixes by whether the
peer has the via tag.
Add RoutesForPeer to State which composes global primary election,
via Include/Exclude filtering, exit routes, and ACL reduction.
When no via grants exist, it falls back to existing behavior.
Update the mapper to call RoutesForPeer per-peer instead of using
a single route function for all peers. The route function now
returns all routes (subnet + exit), and TailNode filters exit
routes out of the PrimaryRoutes field for HA tracking.
Updates #2180
2026-03-22 20:43:28 +00:00
|
|
|
// Self node: include own primaries + exit routes (no via steering for self).
|
|
|
|
|
primaries := policy.ReduceRoutes(nv, b.mapper.state.GetNodePrimaryRoutes(id), matchers)
|
|
|
|
|
|
|
|
|
|
return slices.Concat(primaries, nv.ExitRoutes())
|
2025-07-28 11:15:53 +02:00
|
|
|
},
|
2026-05-11 14:53:09 +00:00
|
|
|
b.mapper.cfg,
|
|
|
|
|
b.mapper.state.NodeCapMap(nv.ID()),
|
|
|
|
|
)
|
2025-07-28 11:15:53 +02:00
|
|
|
if err != nil {
|
|
|
|
|
b.addError(err)
|
|
|
|
|
return b
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
b.resp.Node = tailnode
|
2025-07-05 23:30:47 +02:00
|
|
|
|
|
|
|
|
return b
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (b *MapResponseBuilder) WithDebugType(t debugType) *MapResponseBuilder {
|
|
|
|
|
if debugDumpMapResponsePath != "" {
|
|
|
|
|
b.debugType = t
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-28 11:15:53 +02:00
|
|
|
return b
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-05 23:30:47 +02:00
|
|
|
// WithDERPMap adds the DERP map to the response.
|
2025-07-28 11:15:53 +02:00
|
|
|
func (b *MapResponseBuilder) WithDERPMap() *MapResponseBuilder {
|
2025-08-22 10:40:38 +02:00
|
|
|
b.resp.DERPMap = b.mapper.state.DERPMap().AsStruct()
|
2025-07-28 11:15:53 +02:00
|
|
|
return b
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-05 23:30:47 +02:00
|
|
|
// WithDomain adds the domain configuration.
|
2025-07-28 11:15:53 +02:00
|
|
|
func (b *MapResponseBuilder) WithDomain() *MapResponseBuilder {
|
|
|
|
|
b.resp.Domain = b.mapper.cfg.Domain()
|
|
|
|
|
return b
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-05 23:30:47 +02:00
|
|
|
// WithCollectServicesDisabled sets the collect services flag to false.
|
2025-07-28 11:15:53 +02:00
|
|
|
func (b *MapResponseBuilder) WithCollectServicesDisabled() *MapResponseBuilder {
|
|
|
|
|
b.resp.CollectServices.Set(false)
|
|
|
|
|
return b
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// WithDebugConfig adds debug configuration
|
2025-07-05 23:30:47 +02:00
|
|
|
// It disables log tailing if the mapper's LogTail is not enabled.
|
2025-07-28 11:15:53 +02:00
|
|
|
func (b *MapResponseBuilder) WithDebugConfig() *MapResponseBuilder {
|
|
|
|
|
b.resp.Debug = &tailcfg.Debug{
|
|
|
|
|
DisableLogTail: !b.mapper.cfg.LogTail.Enabled,
|
|
|
|
|
}
|
2026-02-06 21:45:32 +01:00
|
|
|
|
2025-07-28 11:15:53 +02:00
|
|
|
return b
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-05 23:30:47 +02:00
|
|
|
// WithSSHPolicy adds SSH policy configuration for the requesting node.
|
2025-07-28 11:15:53 +02:00
|
|
|
func (b *MapResponseBuilder) WithSSHPolicy() *MapResponseBuilder {
|
2025-07-05 23:30:47 +02:00
|
|
|
node, ok := b.mapper.state.GetNodeByID(b.nodeID)
|
|
|
|
|
if !ok {
|
2026-02-06 21:45:32 +01:00
|
|
|
b.addError(ErrNodeNotFoundMapper)
|
2025-07-28 11:15:53 +02:00
|
|
|
return b
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-05 23:30:47 +02:00
|
|
|
sshPolicy, err := b.mapper.state.SSHPolicy(node)
|
2025-07-28 11:15:53 +02:00
|
|
|
if err != nil {
|
|
|
|
|
b.addError(err)
|
|
|
|
|
return b
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
b.resp.SSHPolicy = sshPolicy
|
2025-07-05 23:30:47 +02:00
|
|
|
|
2025-07-28 11:15:53 +02:00
|
|
|
return b
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-05 23:30:47 +02:00
|
|
|
// WithDNSConfig adds DNS configuration for the requesting node.
|
2025-07-28 11:15:53 +02:00
|
|
|
func (b *MapResponseBuilder) WithDNSConfig() *MapResponseBuilder {
|
2025-07-05 23:30:47 +02:00
|
|
|
node, ok := b.mapper.state.GetNodeByID(b.nodeID)
|
|
|
|
|
if !ok {
|
2026-02-06 21:45:32 +01:00
|
|
|
b.addError(ErrNodeNotFoundMapper)
|
2025-07-28 11:15:53 +02:00
|
|
|
return b
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 14:47:58 +00:00
|
|
|
b.resp.DNSConfig = generateDNSConfig(b.mapper.cfg, node, b.mapper.state.NodeCapMap(node.ID()))
|
2025-07-05 23:30:47 +02:00
|
|
|
|
2025-07-28 11:15:53 +02:00
|
|
|
return b
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-05 23:30:47 +02:00
|
|
|
// WithUserProfiles adds user profiles for the requesting node and given peers.
|
|
|
|
|
func (b *MapResponseBuilder) WithUserProfiles(peers views.Slice[types.NodeView]) *MapResponseBuilder {
|
|
|
|
|
node, ok := b.mapper.state.GetNodeByID(b.nodeID)
|
|
|
|
|
if !ok {
|
2026-02-06 21:45:32 +01:00
|
|
|
b.addError(ErrNodeNotFoundMapper)
|
2025-07-28 11:15:53 +02:00
|
|
|
return b
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
b.resp.UserProfiles = generateUserProfiles(node, peers)
|
2025-07-05 23:30:47 +02:00
|
|
|
|
2025-07-28 11:15:53 +02:00
|
|
|
return b
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-05 23:30:47 +02:00
|
|
|
// WithPacketFilters adds packet filter rules based on policy.
|
all: apply godoc [Name] link conventions across comments
Every Go-identifier reference in // and /* */ comments now uses
godoc's [Name] linking syntax so pkg.go.dev and `go doc` render
them as clickable cross-references. No behaviour change.
Pattern applied across the tree:
In-package [Foo], [Foo.Bar]
Cross-package [pkg.Foo], [pkg.Foo.Bar]
Stdlib [netip.Prefix], [errors.Is], [context.Context]
Tailscale [tailcfg.MapResponse], [tailcfg.Node.CapMap],
[tailcfg.NodeAttrSuggestExitNode]
Skip rules:
- File:line refs left as plain text
- HuJSON wire keys inside backtick raw strings untouched
- ACL/policy syntax tokens (tag:foo, autogroup:self, ...) not Go
symbols, left as plain text
- JSON/OIDC wire keys, gorm tags, RFC IPv6 placeholders, markdown
link tags, decorative dividers — all left as-is
2026-05-18 18:35:53 +00:00
|
|
|
//
|
|
|
|
|
// [State.FilterForNode] returns rules already reduced to only those relevant for this node.
|
|
|
|
|
// For autogroup:self policies, it returns per-node compiled rules.
|
|
|
|
|
// For global policies, it returns the global filter reduced for this node.
|
2025-07-28 11:15:53 +02:00
|
|
|
func (b *MapResponseBuilder) WithPacketFilters() *MapResponseBuilder {
|
2025-07-05 23:30:47 +02:00
|
|
|
node, ok := b.mapper.state.GetNodeByID(b.nodeID)
|
|
|
|
|
if !ok {
|
2026-02-06 21:45:32 +01:00
|
|
|
b.addError(ErrNodeNotFoundMapper)
|
2025-07-28 11:15:53 +02:00
|
|
|
return b
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-16 12:59:52 +02:00
|
|
|
filter, err := b.mapper.state.FilterForNode(node)
|
|
|
|
|
if err != nil {
|
|
|
|
|
b.addError(err)
|
|
|
|
|
return b
|
|
|
|
|
}
|
2025-07-28 11:15:53 +02:00
|
|
|
|
|
|
|
|
// CapVer 81: 2023-11-17: MapResponse.PacketFilters (incremental packet filter updates)
|
|
|
|
|
// Currently, we do not send incremental package filters, however using the
|
|
|
|
|
// new PacketFilters field and "base" allows us to send a full update when we
|
|
|
|
|
// have to send an empty list, avoiding the hack in the else block.
|
|
|
|
|
b.resp.PacketFilters = map[string][]tailcfg.FilterRule{
|
2025-10-23 17:57:41 +02:00
|
|
|
"base": filter,
|
2025-07-28 11:15:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return b
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-05 23:30:47 +02:00
|
|
|
// WithPeers adds full peer list with policy filtering (for full map response).
|
|
|
|
|
func (b *MapResponseBuilder) WithPeers(peers views.Slice[types.NodeView]) *MapResponseBuilder {
|
2025-07-28 11:15:53 +02:00
|
|
|
tailPeers, err := b.buildTailPeers(peers)
|
|
|
|
|
if err != nil {
|
|
|
|
|
b.addError(err)
|
|
|
|
|
return b
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
b.resp.Peers = tailPeers
|
2025-07-05 23:30:47 +02:00
|
|
|
|
2025-07-28 11:15:53 +02:00
|
|
|
return b
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-05 23:30:47 +02:00
|
|
|
// WithPeerChanges adds changed peers with policy filtering (for incremental updates).
|
|
|
|
|
func (b *MapResponseBuilder) WithPeerChanges(peers views.Slice[types.NodeView]) *MapResponseBuilder {
|
2025-07-28 11:15:53 +02:00
|
|
|
tailPeers, err := b.buildTailPeers(peers)
|
|
|
|
|
if err != nil {
|
|
|
|
|
b.addError(err)
|
|
|
|
|
return b
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
b.resp.PeersChanged = tailPeers
|
2025-07-05 23:30:47 +02:00
|
|
|
|
2025-07-28 11:15:53 +02:00
|
|
|
return b
|
|
|
|
|
}
|
|
|
|
|
|
all: apply godoc [Name] link conventions across comments
Every Go-identifier reference in // and /* */ comments now uses
godoc's [Name] linking syntax so pkg.go.dev and `go doc` render
them as clickable cross-references. No behaviour change.
Pattern applied across the tree:
In-package [Foo], [Foo.Bar]
Cross-package [pkg.Foo], [pkg.Foo.Bar]
Stdlib [netip.Prefix], [errors.Is], [context.Context]
Tailscale [tailcfg.MapResponse], [tailcfg.Node.CapMap],
[tailcfg.NodeAttrSuggestExitNode]
Skip rules:
- File:line refs left as plain text
- HuJSON wire keys inside backtick raw strings untouched
- ACL/policy syntax tokens (tag:foo, autogroup:self, ...) not Go
symbols, left as plain text
- JSON/OIDC wire keys, gorm tags, RFC IPv6 placeholders, markdown
link tags, decorative dividers — all left as-is
2026-05-18 18:35:53 +00:00
|
|
|
// buildTailPeers converts [views.Slice] of [types.NodeView] to a slice of [tailcfg.Node]
|
|
|
|
|
// with policy filtering and sorting.
|
2025-07-05 23:30:47 +02:00
|
|
|
func (b *MapResponseBuilder) buildTailPeers(peers views.Slice[types.NodeView]) ([]*tailcfg.Node, error) {
|
|
|
|
|
node, ok := b.mapper.state.GetNodeByID(b.nodeID)
|
|
|
|
|
if !ok {
|
2026-02-06 21:45:32 +01:00
|
|
|
return nil, ErrNodeNotFoundMapper
|
2025-07-28 11:15:53 +02:00
|
|
|
}
|
|
|
|
|
|
2025-10-23 17:57:41 +02:00
|
|
|
// Get unreduced matchers for peer relationship determination.
|
all: apply godoc [Name] link conventions across comments
Every Go-identifier reference in // and /* */ comments now uses
godoc's [Name] linking syntax so pkg.go.dev and `go doc` render
them as clickable cross-references. No behaviour change.
Pattern applied across the tree:
In-package [Foo], [Foo.Bar]
Cross-package [pkg.Foo], [pkg.Foo.Bar]
Stdlib [netip.Prefix], [errors.Is], [context.Context]
Tailscale [tailcfg.MapResponse], [tailcfg.Node.CapMap],
[tailcfg.NodeAttrSuggestExitNode]
Skip rules:
- File:line refs left as plain text
- HuJSON wire keys inside backtick raw strings untouched
- ACL/policy syntax tokens (tag:foo, autogroup:self, ...) not Go
symbols, left as plain text
- JSON/OIDC wire keys, gorm tags, RFC IPv6 placeholders, markdown
link tags, decorative dividers — all left as-is
2026-05-18 18:35:53 +00:00
|
|
|
// [State.MatchersForNode] returns unreduced matchers that include all rules where the
|
|
|
|
|
// node could be either source or destination. This is different from
|
|
|
|
|
// [State.FilterForNode] which returns reduced rules for packet filtering (only rules
|
|
|
|
|
// where node is destination).
|
2025-10-23 17:57:41 +02:00
|
|
|
matchers, err := b.mapper.state.MatchersForNode(node)
|
2025-10-16 12:59:52 +02:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-28 11:15:53 +02:00
|
|
|
// If there are filter rules present, see if there are any nodes that cannot
|
|
|
|
|
// access each-other at all and remove them from the peers.
|
|
|
|
|
var changedViews views.Slice[types.NodeView]
|
2025-10-23 17:57:41 +02:00
|
|
|
if len(matchers) > 0 {
|
2025-07-05 23:30:47 +02:00
|
|
|
changedViews = policy.ReduceNodes(node, peers, matchers)
|
2025-07-28 11:15:53 +02:00
|
|
|
} else {
|
2025-07-05 23:30:47 +02:00
|
|
|
changedViews = peers
|
2025-07-28 11:15:53 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-11 14:52:04 +00:00
|
|
|
// Snapshot the per-node policy CapMap once per peer-list build
|
|
|
|
|
// instead of locking the policy manager per peer. The per-call
|
|
|
|
|
// path used to take pm.mu N times for an N-peer response.
|
|
|
|
|
allCapMaps := b.mapper.state.NodeCapMaps()
|
|
|
|
|
|
policy/v2,state,mapper: implement per-viewer via route steering
Via grants steer routes to specific nodes per viewer. Until now,
all clients saw the same routes for each peer because route
assembly was viewer-independent. This implements per-viewer route
visibility so that via-designated peers serve routes only to
matching viewers, while non-designated peers have those routes
withdrawn.
Add ViaRouteResult type (Include/Exclude prefix lists) and
ViaRoutesForPeer to the PolicyManager interface. The v2
implementation iterates via grants, resolves sources against the
viewer, matches destinations against the peer's advertised routes
(both subnet and exit), and categorizes prefixes by whether the
peer has the via tag.
Add RoutesForPeer to State which composes global primary election,
via Include/Exclude filtering, exit routes, and ACL reduction.
When no via grants exist, it falls back to existing behavior.
Update the mapper to call RoutesForPeer per-peer instead of using
a single route function for all peers. The route function now
returns all routes (subnet + exit), and TailNode filters exit
routes out of the PrimaryRoutes field for HA tracking.
Updates #2180
2026-03-22 20:43:28 +00:00
|
|
|
// Build tail nodes with per-peer via-aware route function.
|
|
|
|
|
tailPeers := make([]*tailcfg.Node, 0, changedViews.Len())
|
|
|
|
|
|
|
|
|
|
for _, peer := range changedViews.All() {
|
2026-05-13 09:58:11 +00:00
|
|
|
// Pass the peer's policy CapMap as selfPolicyCaps so per-peer
|
|
|
|
|
// address-shape rules (today: disable-ipv4) apply consistently
|
|
|
|
|
// in the viewer's netmap. The CapMap merge into tn.CapMap is
|
|
|
|
|
// overwritten by the PeerCapMap call below; only the address
|
|
|
|
|
// filtering side-effect inside TailNode survives.
|
policy/v2,state,mapper: implement per-viewer via route steering
Via grants steer routes to specific nodes per viewer. Until now,
all clients saw the same routes for each peer because route
assembly was viewer-independent. This implements per-viewer route
visibility so that via-designated peers serve routes only to
matching viewers, while non-designated peers have those routes
withdrawn.
Add ViaRouteResult type (Include/Exclude prefix lists) and
ViaRoutesForPeer to the PolicyManager interface. The v2
implementation iterates via grants, resolves sources against the
viewer, matches destinations against the peer's advertised routes
(both subnet and exit), and categorizes prefixes by whether the
peer has the via tag.
Add RoutesForPeer to State which composes global primary election,
via Include/Exclude filtering, exit routes, and ACL reduction.
When no via grants exist, it falls back to existing behavior.
Update the mapper to call RoutesForPeer per-peer instead of using
a single route function for all peers. The route function now
returns all routes (subnet + exit), and TailNode filters exit
routes out of the PrimaryRoutes field for HA tracking.
Updates #2180
2026-03-22 20:43:28 +00:00
|
|
|
tn, err := peer.TailNode(b.capVer, func(_ types.NodeID) []netip.Prefix {
|
|
|
|
|
return b.mapper.state.RoutesForPeer(node, peer, matchers)
|
2026-05-13 09:58:11 +00:00
|
|
|
}, b.mapper.cfg, allCapMaps[peer.ID()])
|
policy/v2,state,mapper: implement per-viewer via route steering
Via grants steer routes to specific nodes per viewer. Until now,
all clients saw the same routes for each peer because route
assembly was viewer-independent. This implements per-viewer route
visibility so that via-designated peers serve routes only to
matching viewers, while non-designated peers have those routes
withdrawn.
Add ViaRouteResult type (Include/Exclude prefix lists) and
ViaRoutesForPeer to the PolicyManager interface. The v2
implementation iterates via grants, resolves sources against the
viewer, matches destinations against the peer's advertised routes
(both subnet and exit), and categorizes prefixes by whether the
peer has the via tag.
Add RoutesForPeer to State which composes global primary election,
via Include/Exclude filtering, exit routes, and ACL reduction.
When no via grants exist, it falls back to existing behavior.
Update the mapper to call RoutesForPeer per-peer instead of using
a single route function for all peers. The route function now
returns all routes (subnet + exit), and TailNode filters exit
routes out of the PrimaryRoutes field for HA tracking.
Updates #2180
2026-03-22 20:43:28 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 14:52:04 +00:00
|
|
|
// [tailcfg.Node.CapMap] on a peer carries the small set of
|
|
|
|
|
// caps the Tailscale client reads from the peer view rather
|
|
|
|
|
// than the self view (suggest-exit-node, dns-subdomain-resolve
|
|
|
|
|
// — see ipn/ipnlocal/local.go:7534 and node_backend.go:745).
|
|
|
|
|
// The Tailscale-hosted control plane stamps these only when
|
|
|
|
|
// the peer satisfies the cap's emission condition; every other
|
|
|
|
|
// 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()])
|
2026-05-11 14:47:58 +00:00
|
|
|
|
policy/v2,state,mapper: implement per-viewer via route steering
Via grants steer routes to specific nodes per viewer. Until now,
all clients saw the same routes for each peer because route
assembly was viewer-independent. This implements per-viewer route
visibility so that via-designated peers serve routes only to
matching viewers, while non-designated peers have those routes
withdrawn.
Add ViaRouteResult type (Include/Exclude prefix lists) and
ViaRoutesForPeer to the PolicyManager interface. The v2
implementation iterates via grants, resolves sources against the
viewer, matches destinations against the peer's advertised routes
(both subnet and exit), and categorizes prefixes by whether the
peer has the via tag.
Add RoutesForPeer to State which composes global primary election,
via Include/Exclude filtering, exit routes, and ACL reduction.
When no via grants exist, it falls back to existing behavior.
Update the mapper to call RoutesForPeer per-peer instead of using
a single route function for all peers. The route function now
returns all routes (subnet + exit), and TailNode filters exit
routes out of the PrimaryRoutes field for HA tracking.
Updates #2180
2026-03-22 20:43:28 +00:00
|
|
|
tailPeers = append(tailPeers, tn)
|
2025-07-28 11:15:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Peers is always returned sorted by Node.ID.
|
|
|
|
|
sort.SliceStable(tailPeers, func(x, y int) bool {
|
|
|
|
|
return tailPeers[x].ID < tailPeers[y].ID
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return tailPeers, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 12:46:19 +00:00
|
|
|
// WithPingRequest adds a PingRequest to the response.
|
|
|
|
|
func (b *MapResponseBuilder) WithPingRequest(pr *tailcfg.PingRequest) *MapResponseBuilder {
|
|
|
|
|
b.resp.PingRequest = pr
|
|
|
|
|
return b
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-05 23:30:47 +02:00
|
|
|
// WithPeerChangedPatch adds peer change patches.
|
2025-07-28 11:15:53 +02:00
|
|
|
func (b *MapResponseBuilder) WithPeerChangedPatch(changes []*tailcfg.PeerChange) *MapResponseBuilder {
|
|
|
|
|
b.resp.PeersChangedPatch = changes
|
|
|
|
|
return b
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-05 23:30:47 +02:00
|
|
|
// WithPeersRemoved adds removed peer IDs.
|
2025-07-28 11:15:53 +02:00
|
|
|
func (b *MapResponseBuilder) WithPeersRemoved(removedIDs ...types.NodeID) *MapResponseBuilder {
|
2026-02-06 21:45:32 +01:00
|
|
|
tailscaleIDs := make([]tailcfg.NodeID, 0, len(removedIDs))
|
2025-07-28 11:15:53 +02:00
|
|
|
for _, id := range removedIDs {
|
|
|
|
|
tailscaleIDs = append(tailscaleIDs, id.NodeID())
|
|
|
|
|
}
|
2026-02-06 21:45:32 +01:00
|
|
|
|
2025-07-28 11:15:53 +02:00
|
|
|
b.resp.PeersRemoved = tailscaleIDs
|
2025-07-05 23:30:47 +02:00
|
|
|
|
2025-07-28 11:15:53 +02:00
|
|
|
return b
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-06 21:45:32 +01:00
|
|
|
// Build finalizes the response and returns marshaled bytes.
|
2025-08-27 17:09:13 +02:00
|
|
|
func (b *MapResponseBuilder) Build() (*tailcfg.MapResponse, error) {
|
2025-07-28 11:15:53 +02:00
|
|
|
if len(b.errs) > 0 {
|
|
|
|
|
return nil, multierr.New(b.errs...)
|
|
|
|
|
}
|
2026-02-06 21:45:32 +01:00
|
|
|
|
2025-07-28 11:15:53 +02:00
|
|
|
if debugDumpMapResponsePath != "" {
|
2025-07-05 23:30:47 +02:00
|
|
|
writeDebugMapResponse(b.resp, b.debugType, b.nodeID)
|
2025-07-28 11:15:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return b.resp, nil
|
|
|
|
|
}
|