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:
committed by
Adriano Sela Aviles
parent
ad8ead9c94
commit
72578de033
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user