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:
Brad Fitzpatrick 2026-05-01 04:03:55 +00:00 committed by Brad Fitzpatrick
parent cac94f51cc
commit 89a78dc9b7
4 changed files with 130 additions and 0 deletions

View File

@ -1064,6 +1064,20 @@ func (lc *Client) DNSConfig(ctx context.Context) (*tailcfg.DNSConfig, error) {
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.
//
// The zero value is valid, which means to use defaults.

View File

@ -1621,6 +1621,16 @@ func (b *LocalBackend) PeerCaps(src netip.Addr) tailcfg.PeerCapMap {
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 {
testenv.AssertInTest()
nb := b.currentNode()

View File

@ -80,6 +80,7 @@ var handler = map[string]LocalAPIHandler{
"goroutines": (*Handler).serveGoroutines,
"login-interactive": (*Handler).serveLoginInteractive,
"logout": (*Handler).serveLogout,
"peer-by-id": (*Handler).servePeerByID,
"ping": (*Handler).servePing,
"prefs": (*Handler).servePrefs,
"reload-config": (*Handler).reloadConfig,
@ -1110,6 +1111,45 @@ func (h *Handler) serveDNSConfig(w http.ResponseWriter, r *http.Request) {
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
// by an `expiry` unix timestamp as POST or query param.
func (h *Handler) serveSetExpirySooner(w http.ResponseWriter, r *http.Request) {

View File

@ -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) {
newHandler := func(connIsLocalAdmin bool) *Handler {
return handlerForTest(t, &Handler{