mirror of
https://github.com/tailscale/tailscale.git
synced 2026-05-05 12:16:44 +02:00
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:
parent
cac94f51cc
commit
89a78dc9b7
@ -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.
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user