client/local, ipn/localapi, ipn/ipnlocal: add PeerByID
Add a narrow LocalAPI accessor and matching client/LocalBackend method to look up a single peer's current full [tailcfg.Node] by NodeID, in O(1) time on the daemon side, without fetching the entire netmap. Useful for callers that need the latest state of a single peer (e.g. in response to a peer-mutation event on the IPN bus) without paying for a full netmap fetch. Updates #12542 Change-Id: I1cb2d350e6ad846a5dabc1f5368dfc8121387f7c Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
committed by
Brad Fitzpatrick
parent
cac94f51cc
commit
89a78dc9b7
@@ -1064,6 +1064,20 @@ func (lc *Client) DNSConfig(ctx context.Context) (*tailcfg.DNSConfig, error) {
|
|||||||
return decodeJSON[*tailcfg.DNSConfig](body)
|
return decodeJSON[*tailcfg.DNSConfig](body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PeerByID returns a peer's current full [tailcfg.Node] looked up by its
|
||||||
|
// [tailcfg.NodeID], in O(1) time on the daemon side. It returns an error
|
||||||
|
// if no peer with that NodeID is in the current netmap.
|
||||||
|
//
|
||||||
|
// It is intended for callers that need the latest state of a single peer
|
||||||
|
// without fetching the entire netmap.
|
||||||
|
func (lc *Client) PeerByID(ctx context.Context, id tailcfg.NodeID) (*tailcfg.Node, error) {
|
||||||
|
body, err := lc.get200(ctx, "/localapi/v0/peer-by-id?id="+strconv.FormatInt(int64(id), 10))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return decodeJSON[*tailcfg.Node](body)
|
||||||
|
}
|
||||||
|
|
||||||
// PingOpts contains options for the ping request.
|
// PingOpts contains options for the ping request.
|
||||||
//
|
//
|
||||||
// The zero value is valid, which means to use defaults.
|
// The zero value is valid, which means to use defaults.
|
||||||
|
|||||||
@@ -1621,6 +1621,16 @@ func (b *LocalBackend) PeerCaps(src netip.Addr) tailcfg.PeerCapMap {
|
|||||||
return b.currentNode().PeerCaps(src)
|
return b.currentNode().PeerCaps(src)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// It is intended for callers that need the latest state of a single peer
|
||||||
|
// without fetching the entire netmap.
|
||||||
|
func (b *LocalBackend) PeerByID(id tailcfg.NodeID) (n tailcfg.NodeView, ok bool) {
|
||||||
|
return b.currentNode().NodeByID(id)
|
||||||
|
}
|
||||||
|
|
||||||
func (b *LocalBackend) GetFilterForTest() *filter.Filter {
|
func (b *LocalBackend) GetFilterForTest() *filter.Filter {
|
||||||
testenv.AssertInTest()
|
testenv.AssertInTest()
|
||||||
nb := b.currentNode()
|
nb := b.currentNode()
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ var handler = map[string]LocalAPIHandler{
|
|||||||
"goroutines": (*Handler).serveGoroutines,
|
"goroutines": (*Handler).serveGoroutines,
|
||||||
"login-interactive": (*Handler).serveLoginInteractive,
|
"login-interactive": (*Handler).serveLoginInteractive,
|
||||||
"logout": (*Handler).serveLogout,
|
"logout": (*Handler).serveLogout,
|
||||||
|
"peer-by-id": (*Handler).servePeerByID,
|
||||||
"ping": (*Handler).servePing,
|
"ping": (*Handler).servePing,
|
||||||
"prefs": (*Handler).servePrefs,
|
"prefs": (*Handler).servePrefs,
|
||||||
"reload-config": (*Handler).reloadConfig,
|
"reload-config": (*Handler).reloadConfig,
|
||||||
@@ -1110,6 +1111,45 @@ func (h *Handler) serveDNSConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
e.Encode(nm.DNS)
|
e.Encode(nm.DNS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// peerByIDBackend is the subset of [ipnlocal.LocalBackend] used by
|
||||||
|
// [Handler.servePeerByID]. It exists so the handler can be tested with a
|
||||||
|
// trivial mock without spinning up a full LocalBackend.
|
||||||
|
type peerByIDBackend interface {
|
||||||
|
PeerByID(tailcfg.NodeID) (tailcfg.NodeView, bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// servePeerByID returns the current full [tailcfg.Node] for the peer with
|
||||||
|
// the NodeID given in the "id" query parameter, in O(1) time. It returns
|
||||||
|
// 404 if no such peer is in the current netmap.
|
||||||
|
//
|
||||||
|
// It is intended for clients that need the latest state of a single peer
|
||||||
|
// without fetching the entire netmap.
|
||||||
|
func (h *Handler) servePeerByID(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.servePeerByIDWithBackend(w, r, h.b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) servePeerByIDWithBackend(w http.ResponseWriter, r *http.Request, b peerByIDBackend) {
|
||||||
|
if !h.PermitRead {
|
||||||
|
http.Error(w, "peer-by-id access denied", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idStr := r.FormValue("id")
|
||||||
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
http.Error(w, "invalid 'id' parameter", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nv, ok := b.PeerByID(tailcfg.NodeID(id))
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "no peer with that NodeID", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
e := json.NewEncoder(w)
|
||||||
|
e.SetIndent("", "\t")
|
||||||
|
e.Encode(nv.AsStruct())
|
||||||
|
}
|
||||||
|
|
||||||
// serveSetExpirySooner sets the expiry date on the current machine, specified
|
// serveSetExpirySooner sets the expiry date on the current machine, specified
|
||||||
// by an `expiry` unix timestamp as POST or query param.
|
// by an `expiry` unix timestamp as POST or query param.
|
||||||
func (h *Handler) serveSetExpirySooner(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) serveSetExpirySooner(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -202,6 +202,72 @@ func TestWhoIsArgTypes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type fakePeerByIDBackend map[tailcfg.NodeID]*tailcfg.Node
|
||||||
|
|
||||||
|
func (f fakePeerByIDBackend) PeerByID(id tailcfg.NodeID) (tailcfg.NodeView, bool) {
|
||||||
|
n, ok := f[id]
|
||||||
|
if !ok {
|
||||||
|
return tailcfg.NodeView{}, false
|
||||||
|
}
|
||||||
|
return n.View(), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServePeerByID(t *testing.T) {
|
||||||
|
h := handlerForTest(t, &Handler{PermitRead: true})
|
||||||
|
b := fakePeerByIDBackend{
|
||||||
|
42: {
|
||||||
|
ID: 42,
|
||||||
|
Name: "alpha",
|
||||||
|
Addresses: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("100.64.0.42/32"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
query string
|
||||||
|
wantCode int
|
||||||
|
wantNodeID tailcfg.NodeID
|
||||||
|
}{
|
||||||
|
{"hit", "id=42", 200, 42},
|
||||||
|
{"miss", "id=99", 404, 0},
|
||||||
|
{"bad_id", "id=garbage", 400, 0},
|
||||||
|
{"missing_id", "", 400, 0},
|
||||||
|
{"zero_id", "id=0", 400, 0},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest("GET", "/v0/peer-by-id?"+tt.query, nil)
|
||||||
|
h.servePeerByIDWithBackend(rec, req, b)
|
||||||
|
if rec.Code != tt.wantCode {
|
||||||
|
t.Fatalf("status = %d, want %d; body=%q", rec.Code, tt.wantCode, rec.Body.String())
|
||||||
|
}
|
||||||
|
if tt.wantCode != 200 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var got tailcfg.Node
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("unmarshal body %q: %v", rec.Body.Bytes(), err)
|
||||||
|
}
|
||||||
|
if got.ID != tt.wantNodeID {
|
||||||
|
t.Errorf("Node.ID = %d, want %d", got.ID, tt.wantNodeID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("forbidden", func(t *testing.T) {
|
||||||
|
hh := handlerForTest(t, &Handler{PermitRead: false})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest("GET", "/v0/peer-by-id?id=42", nil)
|
||||||
|
hh.servePeerByIDWithBackend(rec, req, b)
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestShouldDenyServeConfigForGOOSAndUserContext(t *testing.T) {
|
func TestShouldDenyServeConfigForGOOSAndUserContext(t *testing.T) {
|
||||||
newHandler := func(connIsLocalAdmin bool) *Handler {
|
newHandler := func(connIsLocalAdmin bool) *Handler {
|
||||||
return handlerForTest(t, &Handler{
|
return handlerForTest(t, &Handler{
|
||||||
|
|||||||
Reference in New Issue
Block a user