From 6908fb0de39c76154dec1e85a72e3e5b67d17701 Mon Sep 17 00:00:00 2001 From: Brad Fitzpatrick Date: Fri, 14 Jun 2024 08:05:47 -0700 Subject: [PATCH] ipn/localapi,client/tailscale,cmd/derper: add WhoIs lookup by nodekey, use in derper Fixes #12465 Change-Id: I9b7c87315a3d2b2ecae2b8db9e94b4f5a1eef74a Signed-off-by: Brad Fitzpatrick --- client/tailscale/localclient.go | 29 +++++++++++++++- client/tailscale/localclient_test.go | 31 +++++++++++++++++ derp/derp_server.go | 15 ++++---- ipn/ipnlocal/local.go | 20 +++++++++++ ipn/localapi/localapi.go | 19 ++++++++-- ipn/localapi/localapi_test.go | 52 ++++++++++++++++++++-------- 6 files changed, 141 insertions(+), 25 deletions(-) diff --git a/client/tailscale/localclient.go b/client/tailscale/localclient.go index 68c2d1149..fd582bb90 100644 --- a/client/tailscale/localclient.go +++ b/client/tailscale/localclient.go @@ -253,11 +253,16 @@ func (lc *LocalClient) sendWithHeaders( } if res.StatusCode != wantStatus { err = fmt.Errorf("%v: %s", res.Status, bytes.TrimSpace(slurp)) - return nil, nil, bestError(err, slurp) + return nil, nil, httpStatusError{bestError(err, slurp), res.StatusCode} } return slurp, res.Header, nil } +type httpStatusError struct { + error + HTTPStatus int +} + func (lc *LocalClient) get200(ctx context.Context, path string) ([]byte, error) { return lc.send(ctx, "GET", path, 200, nil) } @@ -278,9 +283,31 @@ func decodeJSON[T any](b []byte) (ret T, err error) { } // WhoIs returns the owner of the remoteAddr, which must be an IP or IP:port. +// +// If not found, the error is ErrPeerNotFound. func (lc *LocalClient) WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, error) { body, err := lc.get200(ctx, "/localapi/v0/whois?addr="+url.QueryEscape(remoteAddr)) 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 WhoIs and WhoIsNodeKey when a peer is not found. +var ErrPeerNotFound = errors.New("peer not found") + +// WhoIsNodeKey returns the owner of the given wireguard public key. +// +// If not found, the error is ErrPeerNotFound. +func (lc *LocalClient) WhoIsNodeKey(ctx context.Context, key key.NodePublic) (*apitype.WhoIsResponse, error) { + body, err := lc.get200(ctx, "/localapi/v0/whois?addr="+url.QueryEscape(key.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) diff --git a/client/tailscale/localclient_test.go b/client/tailscale/localclient_test.go index d992b1e44..950a22f47 100644 --- a/client/tailscale/localclient_test.go +++ b/client/tailscale/localclient_test.go @@ -6,9 +6,14 @@ package tailscale import ( + "context" + "net" + "net/http" + "net/http/httptest" "testing" "tailscale.com/tstest/deptest" + "tailscale.com/types/key" ) func TestGetServeConfigFromJSON(t *testing.T) { @@ -30,6 +35,32 @@ func TestGetServeConfigFromJSON(t *testing.T) { } } +func TestWhoIsPeerNotFound(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(404) + })) + defer ts.Close() + + lc := &LocalClient{ + Dial: func(ctx context.Context, network, addr string) (net.Conn, error) { + var std net.Dialer + return std.DialContext(ctx, network, ts.Listener.Addr().(*net.TCPAddr).String()) + }, + } + var k key.NodePublic + if err := k.UnmarshalText([]byte("nodekey:5c8f86d5fc70d924e55f02446165a5dae8f822994ad26bcf4b08fd841f9bf261")); err != nil { + t.Fatal(err) + } + res, err := lc.WhoIsNodeKey(context.Background(), k) + if err != ErrPeerNotFound { + t.Errorf("got (%v, %v), want ErrPeerNotFound", res, err) + } + res, err = lc.WhoIs(context.Background(), "1.2.3.4:5678") + if err != ErrPeerNotFound { + t.Errorf("got (%v, %v), want ErrPeerNotFound", res, err) + } +} + func TestDeps(t *testing.T) { deptest.DepChecker{ BadDeps: map[string]string{ diff --git a/derp/derp_server.go b/derp/derp_server.go index 0acfb2909..a032a4731 100644 --- a/derp/derp_server.go +++ b/derp/derp_server.go @@ -1151,20 +1151,19 @@ func (c *sclient) requestMeshUpdate() { } } +var localClient tailscale.LocalClient + // verifyClient checks whether the client is allowed to connect to the derper, // depending on how & whether the server's been configured to verify. func (s *Server) verifyClient(ctx context.Context, clientKey key.NodePublic, info *clientInfo, clientIP netip.Addr) error { // tailscaled-based verification: if s.verifyClientsLocalTailscaled { - status, err := tailscale.Status(ctx) + _, err := localClient.WhoIsNodeKey(ctx, clientKey) + if err == tailscale.ErrPeerNotFound { + return fmt.Errorf("peer %v not authorized (not found in local tailscaled)", clientKey) + } if err != nil { - return fmt.Errorf("failed to query local tailscaled status: %w", err) - } - if clientKey == status.Self.PublicKey { - return nil - } - if _, exists := status.Peer[clientKey]; !exists { - return fmt.Errorf("client %v not in set of peers", clientKey) + return fmt.Errorf("failed to query local tailscaled status for %v: %w", clientKey, err) } } diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 60d8a0b53..213a00e21 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -966,6 +966,26 @@ func peerStatusFromNode(ps *ipnstate.PeerStatus, n tailcfg.NodeView) { } } +// WhoIsNodeKey returns the peer info of given public key, if it exists. +func (b *LocalBackend) WhoIsNodeKey(k key.NodePublic) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool) { + b.mu.Lock() + defer b.mu.Unlock() + // TODO(bradfitz): add nodeByKey like nodeByAddr instead of walking peers. + if b.netMap == nil { + return n, u, false + } + if self := b.netMap.SelfNode; self.Valid() && self.Key() == k { + return self, b.netMap.UserProfiles[self.User()], true + } + for _, n := range b.peers { + if n.Key() == k { + u, ok = b.netMap.UserProfiles[n.User()] + return n, u, ok + } + } + return n, u, false +} + // WhoIs reports the node and user who owns the node with the given IP:port. // If the IP address is a Tailscale IP, the provided port may be 0. // If ok == true, n and u are valid. diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index 0711aad40..d303a12ba 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -448,6 +448,7 @@ func (h *Handler) serveWhoIs(w http.ResponseWriter, r *http.Request) { // by the localapi WhoIs method. type localBackendWhoIsMethods interface { WhoIs(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 } @@ -456,9 +457,21 @@ func (h *Handler) serveWhoIsWithBackend(w http.ResponseWriter, r *http.Request, http.Error(w, "whois access denied", http.StatusForbidden) return } + var ( + n tailcfg.NodeView + u tailcfg.UserProfile + ok bool + ) var ipp netip.AddrPort if v := r.FormValue("addr"); v != "" { - if ip, err := netip.ParseAddr(v); err == nil { + if strings.HasPrefix(v, "nodekey:") { + var k key.NodePublic + if err := k.UnmarshalText([]byte(v)); err != nil { + http.Error(w, "invalid nodekey in 'addr' parameter", http.StatusBadRequest) + return + } + n, u, ok = b.WhoIsNodeKey(k) + } else if ip, err := netip.ParseAddr(v); err == nil { ipp = netip.AddrPortFrom(ip, 0) } else { var err error @@ -468,11 +481,13 @@ func (h *Handler) serveWhoIsWithBackend(w http.ResponseWriter, r *http.Request, return } } + if ipp.IsValid() { + n, u, ok = b.WhoIs(ipp) + } } else { http.Error(w, "missing 'addr' parameter", http.StatusBadRequest) return } - n, u, ok := b.WhoIs(ipp) if !ok { http.Error(w, "no match for IP:port", http.StatusNotFound) return diff --git a/ipn/localapi/localapi_test.go b/ipn/localapi/localapi_test.go index 2086023d7..863a4d087 100644 --- a/ipn/localapi/localapi_test.go +++ b/ipn/localapi/localapi_test.go @@ -31,6 +31,7 @@ "tailscale.com/tailcfg" "tailscale.com/tsd" "tailscale.com/tstest" + "tailscale.com/types/key" "tailscale.com/types/logger" "tailscale.com/types/logid" "tailscale.com/util/slicesx" @@ -99,26 +100,46 @@ func TestSetPushDeviceToken(t *testing.T) { } type whoIsBackend struct { - whoIs func(ipp netip.AddrPort) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool) - peerCaps map[netip.Addr]tailcfg.PeerCapMap + whoIs func(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 } func (b whoIsBackend) WhoIs(ipp netip.AddrPort) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool) { return b.whoIs(ipp) } +func (b whoIsBackend) WhoIsNodeKey(k key.NodePublic) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool) { + return b.whoIsNodeKey(k) +} + func (b whoIsBackend) PeerCaps(ip netip.Addr) tailcfg.PeerCapMap { return b.peerCaps[ip] } -// Tests that the WhoIs handler accepts either IPs or IP:ports. +// 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) -func TestWhoIsJustIP(t *testing.T) { +// +// And https://github.com/tailscale/tailscale/issues/12465 +func TestWhoIsArgTypes(t *testing.T) { h := &Handler{ PermitRead: true, } - for _, input := range []string{"100.101.102.103", "127.0.0.1:123"} { + + match := func() (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool) { + return (&tailcfg.Node{ + ID: 123, + Addresses: []netip.Prefix{ + netip.MustParsePrefix("100.101.102.103/32"), + }, + }).View(), + tailcfg.UserProfile{ID: 456, DisplayName: "foo"}, + true + } + + const keyStr = "nodekey:5c8f86d5fc70d924e55f02446165a5dae8f822994ad26bcf4b08fd841f9bf261" + for _, input := range []string{"100.101.102.103", "127.0.0.1:123", keyStr} { rec := httptest.NewRecorder() t.Run(input, func(t *testing.T) { b := whoIsBackend{ @@ -129,14 +150,14 @@ func TestWhoIsJustIP(t *testing.T) { t.Fatalf("backend called with %v; want %v", ipp, want) } } - return (&tailcfg.Node{ - ID: 123, - Addresses: []netip.Prefix{ - netip.MustParsePrefix("100.101.102.103/32"), - }, - }).View(), - tailcfg.UserProfile{ID: 456, DisplayName: "foo"}, - true + return match() + }, + whoIsNodeKey: func(k key.NodePublic) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool) { + if k.String() != keyStr { + t.Fatalf("backend called with %v; want %v", k, keyStr) + } + return match() + }, peerCaps: map[netip.Addr]tailcfg.PeerCapMap{ netip.MustParseAddr("100.101.102.103"): map[tailcfg.PeerCapability][]tailcfg.RawMessage{ @@ -146,9 +167,12 @@ func TestWhoIsJustIP(t *testing.T) { } h.serveWhoIsWithBackend(rec, httptest.NewRequest("GET", "/v0/whois?addr="+url.QueryEscape(input), nil), b) + if rec.Code != 200 { + t.Fatalf("response code %d", rec.Code) + } var res apitype.WhoIsResponse if err := json.Unmarshal(rec.Body.Bytes(), &res); err != nil { - t.Fatal(err) + t.Fatalf("parsing response %#q: %v", rec.Body.Bytes(), err) } if got, want := res.Node.ID, tailcfg.NodeID(123); got != want { t.Errorf("res.Node.ID=%v, want %v", got, want)