ipn/ipnlocal: include service VIP addresses in PeerCaps resolution

peerCapsLocked only checked SelfNode.Addresses() when resolving peer
capabilities via filter.CapsWithValues. This meant that ACL grants
targeting service VIPs (e.g. dst: ["svc:http"]) would never appear in
WhoIs CapMap responses, because service VIP addresses are not included
in SelfNode.Addresses() — they are delivered separately via the
NodeAttrServiceHost capability and AllowedIPs.

This affected both the WhoIs LocalAPI endpoint and the built-in
ServiceModeHTTP serve layer (addAppCapabilitiesHeader), since both
call PeerCaps which delegates to peerCapsLocked.

Fix by also iterating service VIP addresses from ServiceIPMappings
(delivered via NodeAttrServiceHost) and merging caps from all matching
destination addresses.

Updates tailscale/corp#38146

Signed-off-by: Raj Singh <raj@tailscale.com>
This commit is contained in:
Raj Singh
2026-03-05 17:45:39 -08:00
parent 8cfbaa717d
commit 1b451f8bff
2 changed files with 144 additions and 10 deletions

View File

@@ -311,18 +311,49 @@ func (nb *nodeBackend) peerCapsLocked(src netip.Addr) tailcfg.PeerCapMap {
if filt == nil {
return nil
}
addrs := nb.netMap.GetAddresses()
for i := range addrs.Len() {
a := addrs.At(i)
if !a.IsSingleIP() {
continue
}
dst := a.Addr()
if dst.BitLen() == src.BitLen() { // match on family
return filt.CapsWithValues(src, dst)
var dsts []netip.Addr
for _, a := range nb.netMap.GetAddresses().All() {
if a.IsSingleIP() {
dsts = append(dsts, a.Addr())
}
}
return nil
for _, addr := range nb.serviceVIPAddrs() {
dsts = append(dsts, addr)
}
var out tailcfg.PeerCapMap
for _, dst := range dsts {
if dst.BitLen() != src.BitLen() {
continue
}
cm := filt.CapsWithValues(src, dst)
if len(cm) == 0 {
continue
}
if out == nil {
out = cm
continue
}
for k, v := range cm {
out[k] = append(out[k], v...)
}
}
return out
}
// serviceVIPAddrs returns the IP addresses of VIP services this node is
// hosting, as delivered by the control plane via NodeAttrServiceHost.
func (nb *nodeBackend) serviceVIPAddrs() []netip.Addr {
svcMap := nb.netMap.GetVIPServiceIPMap()
if len(svcMap) == 0 {
return nil
}
var addrs []netip.Addr
for _, svcAddrs := range svcMap {
addrs = append(addrs, svcAddrs...)
}
return addrs
}
// PeerHasCap reports whether the peer contains the given capability string,

View File

@@ -5,15 +5,19 @@
import (
"context"
"encoding/json"
"errors"
"net/netip"
"testing"
"time"
"go4.org/netipx"
"tailscale.com/tailcfg"
"tailscale.com/tstest"
"tailscale.com/types/netmap"
"tailscale.com/types/ptr"
"tailscale.com/util/eventbus"
"tailscale.com/wgengine/filter"
)
func TestNodeBackendReadiness(t *testing.T) {
@@ -126,6 +130,105 @@ func TestNodeBackendConcurrentReadyAndShutdown(t *testing.T) {
nb.Wait(context.Background())
}
func TestPeerCapsIncludesServiceVIPs(t *testing.T) {
nb := newNodeBackend(t.Context(), tstest.WhileTestRunningLogger(t), eventbus.New())
nodeIP := netip.MustParseAddr("100.64.1.1")
svcIP := netip.MustParseAddr("100.124.180.147")
peerIP := netip.MustParseAddr("100.64.2.2")
// One grant targets the node IP, one targets the service VIP.
mm, err := filter.MatchesFromFilterRules([]tailcfg.FilterRule{
{
SrcIPs: []string{"100.64.2.0/24"},
CapGrant: []tailcfg.CapGrant{{
Dsts: []netip.Prefix{netip.MustParsePrefix("100.64.1.1/32")},
Caps: []tailcfg.PeerCapability{"node-scoped-cap"},
}},
},
{
SrcIPs: []string{"100.64.2.0/24"},
CapGrant: []tailcfg.CapGrant{{
Dsts: []netip.Prefix{netip.MustParsePrefix("100.124.180.147/32")},
Caps: []tailcfg.PeerCapability{"svc-scoped-cap"},
CapMap: tailcfg.PeerCapMap{"svc-scoped-cap": {tailcfg.RawMessage(`{"routes":["/test/*"]}`)}},
}},
},
})
if err != nil {
t.Fatal(err)
}
filt := filter.New(mm, nil, nil, &netipx.IPSet{}, nil, t.Logf)
nb.filterAtomic.Store(filt)
// Set up the netmap with service VIP mappings.
svcMappings := tailcfg.ServiceIPMappings{
"svc:http": {svcIP},
}
svcMappingsJSON, err := json.Marshal(svcMappings)
if err != nil {
t.Fatal(err)
}
nb.netMap = &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
Addresses: []netip.Prefix{netip.PrefixFrom(nodeIP, 32)},
CapMap: tailcfg.NodeCapMap{
tailcfg.NodeAttrServiceHost: {tailcfg.RawMessage(svcMappingsJSON)},
},
}).View(),
}
caps := nb.PeerCaps(peerIP)
if caps == nil {
t.Fatal("PeerCaps returned nil")
}
if !caps.HasCapability("node-scoped-cap") {
t.Error("missing node-scoped-cap")
}
if !caps.HasCapability("svc-scoped-cap") {
t.Error("missing svc-scoped-cap — service VIP caps not included in PeerCaps")
}
vals := caps["svc-scoped-cap"]
if len(vals) == 0 {
t.Fatal("svc-scoped-cap has no values")
}
}
func TestPeerCapsNodeOnlyWithoutServiceVIPs(t *testing.T) {
nb := newNodeBackend(t.Context(), tstest.WhileTestRunningLogger(t), eventbus.New())
nodeIP := netip.MustParseAddr("100.64.1.1")
peerIP := netip.MustParseAddr("100.64.2.2")
mm, err := filter.MatchesFromFilterRules([]tailcfg.FilterRule{
{
SrcIPs: []string{"100.64.2.0/24"},
CapGrant: []tailcfg.CapGrant{{
Dsts: []netip.Prefix{netip.MustParsePrefix("100.64.1.1/32")},
Caps: []tailcfg.PeerCapability{"basic-cap"},
}},
},
})
if err != nil {
t.Fatal(err)
}
filt := filter.New(mm, nil, nil, &netipx.IPSet{}, nil, t.Logf)
nb.filterAtomic.Store(filt)
nb.netMap = &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
Addresses: []netip.Prefix{netip.PrefixFrom(nodeIP, 32)},
}).View(),
}
caps := nb.PeerCaps(peerIP)
if !caps.HasCapability("basic-cap") {
t.Error("missing basic-cap")
}
}
func TestNodeBackendReachability(t *testing.T) {
for _, tc := range []struct {
name string