ipn/{ipnlocal,localapi},client/local: add per-dst cap resolution for services

Adds two new cap resolution methods alongside the existing PeerCaps:

PeerCapsForService(src netip.Addr, svcName tailcfg.ServiceName) resolves
the service name to its VIP addresses via the node's service IP mappings
and returns caps scoped to that service. Exposed on /v0/whois via the
svc_name query parameter and on client/local.Client as WhoIsForService.

PeerCapsForIP(src, dst netip.Addr) resolves caps against an arbitrary
destination IP. Exposed on /v0/whois via the svc_addr query parameter
and on client/local.Client as WhoIsForIP.

svc_name takes priority over svc_addr when both are present. Invalid
values for either return 400. The existing PeerCaps/WhoIs path is
unchanged: without a service parameter, WhoIs returns only host-level
caps.

Updates tailscale/corp#41632

Signed-off-by: Adriano Sela Aviles <adriano@tailscale.com>
This commit is contained in:
Adriano Sela Aviles
2026-05-11 14:48:25 -07:00
committed by Adriano Sela Aviles
parent ad8ead9c94
commit 72578de033
5 changed files with 298 additions and 4 deletions
+29
View File
@@ -327,6 +327,35 @@ func (lc *Client) WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsR
return decodeJSON[*apitype.WhoIsResponse](body)
}
// WhoIsForService is like [Client.WhoIs] but scopes the returned CapMap to
// capabilities that apply to the named VIP service. This enables per-service
// capability resolution on hosts that advertise multiple VIP services.
func (lc *Client) WhoIsForService(ctx context.Context, remoteAddr string, svcName tailcfg.ServiceName) (*apitype.WhoIsResponse, error) {
body, err := lc.get200(ctx, "/localapi/v0/whois?addr="+url.QueryEscape(remoteAddr)+"&svc_name="+url.QueryEscape(string(svcName)))
if err != nil {
if hs, ok := err.(httpStatusError); ok && hs.HTTPStatus == http.StatusNotFound {
return nil, ErrPeerNotFound
}
return nil, err
}
return decodeJSON[*apitype.WhoIsResponse](body)
}
// WhoIsForIP is like [Client.WhoIs] but scopes the returned CapMap to
// capabilities that apply to the given destination IP. The IP may be a
// VIP service address, the node's own tailnet address, or any other
// routable IP the node handles.
func (lc *Client) WhoIsForIP(ctx context.Context, remoteAddr string, dst netip.Addr) (*apitype.WhoIsResponse, error) {
body, err := lc.get200(ctx, "/localapi/v0/whois?addr="+url.QueryEscape(remoteAddr)+"&dst_ip="+url.QueryEscape(dst.String()))
if err != nil {
if hs, ok := err.(httpStatusError); ok && hs.HTTPStatus == http.StatusNotFound {
return nil, ErrPeerNotFound
}
return nil, err
}
return decodeJSON[*apitype.WhoIsResponse](body)
}
// ErrPeerNotFound is returned by [Client.WhoIs], [Client.WhoIsNodeKey] and
// [Client.WhoIsProto] when a peer is not found.
var ErrPeerNotFound = errors.New("peer not found")
+12
View File
@@ -1621,6 +1621,18 @@ func (b *LocalBackend) PeerCaps(src netip.Addr) tailcfg.PeerCapMap {
return b.currentNode().PeerCaps(src)
}
// PeerCapsForIP returns the capabilities that remote src IP has when
// talking to the given destination IP on this node.
func (b *LocalBackend) PeerCapsForIP(src, dst netip.Addr) tailcfg.PeerCapMap {
return b.currentNode().PeerCapsForIP(src, dst)
}
// PeerCapsForService returns the capabilities that remote src IP has when
// talking to the named VIP service on this node.
func (b *LocalBackend) PeerCapsForService(src netip.Addr, svcName tailcfg.ServiceName) tailcfg.PeerCapMap {
return b.currentNode().PeerCapsForService(src, svcName)
}
// PeerByID returns the current full [tailcfg.Node] for the peer with the
// given NodeID, in O(1) time. It returns ok=false if no such peer is in
// the current netmap.
+40
View File
@@ -331,6 +331,46 @@ func (nb *nodeBackend) peerCapsLocked(src netip.Addr) tailcfg.PeerCapMap {
return nil
}
// PeerCapsForIP returns the capabilities that remote src IP has when
// talking to the given destination IP on this node. The destination may
// be any IP the node handles: its own tailnet address, a VIP service
// address, or any future routable IP.
func (nb *nodeBackend) PeerCapsForIP(src, dst netip.Addr) tailcfg.PeerCapMap {
nb.mu.Lock()
defer nb.mu.Unlock()
if nb.netMap == nil {
return nil
}
filt := nb.filterAtomic.Load()
if filt == nil {
return nil
}
return filt.CapsWithValues(src, dst)
}
// PeerCapsForService returns the capabilities that remote src IP has when
// talking to the named VIP service on this node. The service name is
// resolved to its VIP addresses via the node's service IP mappings, and
// the first address matching the src IP family is used for cap lookup.
func (nb *nodeBackend) PeerCapsForService(src netip.Addr, svcName tailcfg.ServiceName) tailcfg.PeerCapMap {
nb.mu.Lock()
defer nb.mu.Unlock()
if nb.netMap == nil {
return nil
}
filt := nb.filterAtomic.Load()
if filt == nil {
return nil
}
addrs := nb.netMap.GetVIPServiceIPMap()[svcName]
for _, ip := range addrs {
if ip.BitLen() == src.BitLen() {
return filt.CapsWithValues(src, ip)
}
}
return nil
}
// PeerHasCap reports whether the peer contains the given capability string,
// with any value(s).
func (nb *nodeBackend) PeerHasCap(peer tailcfg.NodeView, wantCap tailcfg.PeerCapability) bool {
+21 -1
View File
@@ -545,6 +545,8 @@ type localBackendWhoIsMethods interface {
WhoIs(string, netip.AddrPort) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool)
WhoIsNodeKey(key.NodePublic) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool)
PeerCaps(netip.Addr) tailcfg.PeerCapMap
PeerCapsForIP(src, dst netip.Addr) tailcfg.PeerCapMap
PeerCapsForService(src netip.Addr, svcName tailcfg.ServiceName) tailcfg.PeerCapMap
}
func (h *Handler) serveWhoIsWithBackend(w http.ResponseWriter, r *http.Request, b localBackendWhoIsMethods) {
@@ -592,7 +594,25 @@ func (h *Handler) serveWhoIsWithBackend(w http.ResponseWriter, r *http.Request,
UserProfile: &u, // always non-nil per WhoIsResponse contract
}
if n.Addresses().Len() > 0 {
res.CapMap = b.PeerCaps(n.Addresses().At(0).Addr())
src := n.Addresses().At(0).Addr()
switch {
case r.FormValue("svc_name") != "":
svcName := tailcfg.AsServiceName(r.FormValue("svc_name"))
if svcName == "" {
http.Error(w, "invalid svc_name", http.StatusBadRequest)
return
}
res.CapMap = b.PeerCapsForService(src, svcName)
case r.FormValue("dst_ip") != "":
svcAddr, err := netip.ParseAddr(r.FormValue("dst_ip"))
if err != nil {
http.Error(w, "invalid dst_ip", http.StatusBadRequest)
return
}
res.CapMap = b.PeerCapsForIP(src, svcAddr)
default:
res.CapMap = b.PeerCaps(src)
}
}
j, err := json.MarshalIndent(res, "", "\t")
if err != nil {
+196 -3
View File
@@ -116,9 +116,11 @@ func TestSetPushDeviceToken(t *testing.T) {
}
type whoIsBackend struct {
whoIs func(proto string, ipp netip.AddrPort) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool)
whoIsNodeKey func(key.NodePublic) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool)
peerCaps map[netip.Addr]tailcfg.PeerCapMap
whoIs func(proto string, ipp netip.AddrPort) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool)
whoIsNodeKey func(key.NodePublic) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool)
peerCaps map[netip.Addr]tailcfg.PeerCapMap
peerCapsForIP func(src, dst netip.Addr) tailcfg.PeerCapMap
peerCapsForSvcName func(src netip.Addr, svcName tailcfg.ServiceName) tailcfg.PeerCapMap
}
func (b whoIsBackend) WhoIs(proto string, ipp netip.AddrPort) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool) {
@@ -133,6 +135,20 @@ func (b whoIsBackend) PeerCaps(ip netip.Addr) tailcfg.PeerCapMap {
return b.peerCaps[ip]
}
func (b whoIsBackend) PeerCapsForIP(src, dst netip.Addr) tailcfg.PeerCapMap {
if b.peerCapsForIP != nil {
return b.peerCapsForIP(src, dst)
}
return nil
}
func (b whoIsBackend) PeerCapsForService(src netip.Addr, svcName tailcfg.ServiceName) tailcfg.PeerCapMap {
if b.peerCapsForSvcName != nil {
return b.peerCapsForSvcName(src, svcName)
}
return nil
}
// Tests that the WhoIs handler accepts IPs, IP:ports, or nodekeys.
//
// From https://github.com/tailscale/tailscale/pull/9714 (a PR that is effectively a bug report)
@@ -202,6 +218,183 @@ func TestWhoIsArgTypes(t *testing.T) {
}
}
func TestWhoIsServiceParams(t *testing.T) {
h := handlerForTest(t, &Handler{
PermitRead: true,
})
peerAddr := netip.MustParseAddr("100.101.102.103")
vipA := netip.MustParseAddr("100.100.0.1")
vipB := netip.MustParseAddr("100.100.0.2")
nodeCapsForAddr := tailcfg.PeerCapMap{"host-cap": {`"host-val"`}}
vipACaps := tailcfg.PeerCapMap{"svc-a-cap": {`"a-val"`}}
vipBCaps := tailcfg.PeerCapMap{"svc-b-cap": {`"b-val"`}}
match := func() (tailcfg.NodeView, tailcfg.UserProfile, bool) {
return (&tailcfg.Node{
ID: 123,
Addresses: []netip.Prefix{netip.PrefixFrom(peerAddr, 32)},
}).View(), tailcfg.UserProfile{ID: 456}, true
}
backend := whoIsBackend{
whoIs: func(proto string, ipp netip.AddrPort) (tailcfg.NodeView, tailcfg.UserProfile, bool) {
return match()
},
peerCaps: map[netip.Addr]tailcfg.PeerCapMap{
peerAddr: nodeCapsForAddr,
},
peerCapsForIP: func(src, dst netip.Addr) tailcfg.PeerCapMap {
switch dst {
case vipA:
return vipACaps
case vipB:
return vipBCaps
}
return nil
},
peerCapsForSvcName: func(src netip.Addr, svcName tailcfg.ServiceName) tailcfg.PeerCapMap {
switch svcName {
case "svc:db":
return vipACaps
case "svc:cache":
return vipBCaps
}
return nil
},
}
doWhoIs := func(t *testing.T, query string) apitype.WhoIsResponse {
t.Helper()
rec := httptest.NewRecorder()
h.serveWhoIsWithBackend(rec, httptest.NewRequest("GET", "/v0/whois?"+query, nil), backend)
if rec.Code != 200 {
t.Fatalf("response code %d; body: %s", rec.Code, rec.Body.String())
}
var res apitype.WhoIsResponse
if err := json.Unmarshal(rec.Body.Bytes(), &res); err != nil {
t.Fatalf("parsing response: %v", err)
}
return res
}
doWhoIsStatus := func(t *testing.T, query string) int {
t.Helper()
rec := httptest.NewRecorder()
h.serveWhoIsWithBackend(rec, httptest.NewRequest("GET", "/v0/whois?"+query, nil), backend)
return rec.Code
}
// No service params — uses PeerCaps (host-level).
t.Run("no_service_params_uses_PeerCaps", func(t *testing.T) {
res := doWhoIs(t, "addr="+peerAddr.String())
if _, ok := res.CapMap["host-cap"]; !ok {
t.Errorf("expected host-cap from PeerCaps; got %v", res.CapMap)
}
if _, ok := res.CapMap["svc-a-cap"]; ok {
t.Error("VIP cap should not appear without service param")
}
})
// dst_ip tests — PeerCapsForIP path.
t.Run("dst_ip_uses_PeerCapsForIP", func(t *testing.T) {
res := doWhoIs(t, "addr="+peerAddr.String()+"&dst_ip="+vipA.String())
if _, ok := res.CapMap["svc-a-cap"]; !ok {
t.Errorf("expected svc-a-cap; got %v", res.CapMap)
}
if _, ok := res.CapMap["host-cap"]; ok {
t.Error("host-cap should not appear when dst_ip is specified")
}
})
t.Run("dst_ip_scopes_to_specific_service", func(t *testing.T) {
resA := doWhoIs(t, "addr="+peerAddr.String()+"&dst_ip="+vipA.String())
resB := doWhoIs(t, "addr="+peerAddr.String()+"&dst_ip="+vipB.String())
if _, ok := resA.CapMap["svc-a-cap"]; !ok {
t.Errorf("dst_ip=vipA: expected svc-a-cap; got %v", resA.CapMap)
}
if _, ok := resA.CapMap["svc-b-cap"]; ok {
t.Error("dst_ip=vipA: svc-b-cap should not appear")
}
if _, ok := resB.CapMap["svc-b-cap"]; !ok {
t.Errorf("dst_ip=vipB: expected svc-b-cap; got %v", resB.CapMap)
}
if _, ok := resB.CapMap["svc-a-cap"]; ok {
t.Error("dst_ip=vipB: svc-a-cap should not appear")
}
})
t.Run("dst_ip_unrelated_ip_returns_empty", func(t *testing.T) {
res := doWhoIs(t, "addr="+peerAddr.String()+"&dst_ip=10.0.0.99")
if len(res.CapMap) != 0 {
t.Errorf("expected empty CapMap for unrelated dst_ip; got %v", res.CapMap)
}
})
t.Run("dst_ip_invalid_returns_400", func(t *testing.T) {
if code := doWhoIsStatus(t, "addr="+peerAddr.String()+"&dst_ip=not-an-ip"); code != 400 {
t.Errorf("expected 400 for invalid dst_ip; got %d", code)
}
})
// svc_name tests — PeerCapsForService path.
t.Run("svc_name_uses_PeerCapsForService", func(t *testing.T) {
res := doWhoIs(t, "addr="+peerAddr.String()+"&svc_name=svc:db")
if _, ok := res.CapMap["svc-a-cap"]; !ok {
t.Errorf("expected svc-a-cap; got %v", res.CapMap)
}
if _, ok := res.CapMap["host-cap"]; ok {
t.Error("host-cap should not appear when svc_name is specified")
}
})
t.Run("svc_name_scopes_to_specific_service", func(t *testing.T) {
resA := doWhoIs(t, "addr="+peerAddr.String()+"&svc_name=svc:db")
resB := doWhoIs(t, "addr="+peerAddr.String()+"&svc_name=svc:cache")
if _, ok := resA.CapMap["svc-a-cap"]; !ok {
t.Errorf("svc_name=svc:db: expected svc-a-cap; got %v", resA.CapMap)
}
if _, ok := resA.CapMap["svc-b-cap"]; ok {
t.Error("svc_name=svc:db: svc-b-cap should not appear")
}
if _, ok := resB.CapMap["svc-b-cap"]; !ok {
t.Errorf("svc_name=svc:cache: expected svc-b-cap; got %v", resB.CapMap)
}
if _, ok := resB.CapMap["svc-a-cap"]; ok {
t.Error("svc_name=svc:cache: svc-a-cap should not appear")
}
})
t.Run("svc_name_unknown_service_returns_empty", func(t *testing.T) {
res := doWhoIs(t, "addr="+peerAddr.String()+"&svc_name=svc:unknown")
if len(res.CapMap) != 0 {
t.Errorf("expected empty CapMap for unknown service; got %v", res.CapMap)
}
})
t.Run("svc_name_invalid_returns_400", func(t *testing.T) {
if code := doWhoIsStatus(t, "addr="+peerAddr.String()+"&svc_name=not-a-service-name"); code != 400 {
t.Errorf("expected 400 for invalid svc_name; got %d", code)
}
})
// svc_name takes priority over dst_ip when both are specified.
t.Run("svc_name_takes_priority_over_dst_ip", func(t *testing.T) {
res := doWhoIs(t, "addr="+peerAddr.String()+"&svc_name=svc:cache&dst_ip="+vipA.String())
if _, ok := res.CapMap["svc-b-cap"]; !ok {
t.Errorf("svc_name should take priority; expected svc-b-cap (cache); got %v", res.CapMap)
}
if _, ok := res.CapMap["svc-a-cap"]; ok {
t.Error("dst_ip result should not appear when svc_name is also specified")
}
})
}
type fakePeerByIDBackend map[tailcfg.NodeID]*tailcfg.Node
func (f fakePeerByIDBackend) PeerByID(id tailcfg.NodeID) (tailcfg.NodeView, bool) {