From 72578de0330c3a3150240b4f087033111e1f4b2e Mon Sep 17 00:00:00 2001 From: Adriano Sela Aviles Date: Mon, 11 May 2026 14:48:25 -0700 Subject: [PATCH] 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 --- client/local/local.go | 29 +++++ ipn/ipnlocal/local.go | 12 ++ ipn/ipnlocal/node_backend.go | 40 +++++++ ipn/localapi/localapi.go | 22 +++- ipn/localapi/localapi_test.go | 199 +++++++++++++++++++++++++++++++++- 5 files changed, 298 insertions(+), 4 deletions(-) diff --git a/client/local/local.go b/client/local/local.go index 5c75c0487..1a2d7342b 100644 --- a/client/local/local.go +++ b/client/local/local.go @@ -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") diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index cba23b6a6..8897eb813 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -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. diff --git a/ipn/ipnlocal/node_backend.go b/ipn/ipnlocal/node_backend.go index f8579900d..59c26ebe5 100644 --- a/ipn/ipnlocal/node_backend.go +++ b/ipn/ipnlocal/node_backend.go @@ -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 { diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index 6375f440d..9d4977e48 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -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 { diff --git a/ipn/localapi/localapi_test.go b/ipn/localapi/localapi_test.go index 352f71e00..84d8e1e0f 100644 --- a/ipn/localapi/localapi_test.go +++ b/ipn/localapi/localapi_test.go @@ -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) {