ipn/ipnlocal: add a C2N endpoint for fetching a netmap

For debugging purposes, add a new C2N endpoint returning the current
netmap. Optionally, coordination server can send a new "candidate" map
response, which the client will generate a separate netmap for.
Coordination server can later compare two netmaps, detecting unexpected
changes to the client state.

Updates tailscale/corp#32095

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
This commit is contained in:
Anton Tolchanov
2025-08-13 15:00:35 +01:00
committed by Anton Tolchanov
parent 394718a4ca
commit 4a04161828
9 changed files with 506 additions and 9 deletions

View File

@@ -27,6 +27,7 @@ import (
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/miekg/dns"
"go4.org/mem"
"tailscale.com/client/local"
@@ -41,6 +42,7 @@ import (
"tailscale.com/tstest"
"tailscale.com/tstest/integration/testcontrol"
"tailscale.com/types/key"
"tailscale.com/types/netmap"
"tailscale.com/types/opt"
"tailscale.com/types/ptr"
"tailscale.com/util/must"
@@ -1623,3 +1625,146 @@ func TestPeerRelayPing(t *testing.T) {
}
}
}
func TestC2NDebugNetmap(t *testing.T) {
tstest.Shard(t)
tstest.Parallel(t)
env := NewTestEnv(t)
var testNodes []*TestNode
var nodes []*tailcfg.Node
for i := range 2 {
n := NewTestNode(t, env)
d := n.StartDaemon()
defer d.MustCleanShutdown(t)
n.AwaitResponding()
n.MustUp()
n.AwaitRunning()
testNodes = append(testNodes, n)
controlNodes := env.Control.AllNodes()
if len(controlNodes) != i+1 {
t.Fatalf("expected %d nodes, got %d nodes", i+1, len(controlNodes))
}
for _, cn := range controlNodes {
if n.MustStatus().Self.PublicKey == cn.Key {
nodes = append(nodes, cn)
break
}
}
}
// getC2NNetmap fetches the current netmap. If a candidate map response is provided,
// a candidate netmap is also fetched and compared to the current netmap.
getC2NNetmap := func(node key.NodePublic, cand *tailcfg.MapResponse) *netmap.NetworkMap {
t.Helper()
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
var req *http.Request
if cand != nil {
body := must.Get(json.Marshal(&tailcfg.C2NDebugNetmapRequest{Candidate: cand}))
req = must.Get(http.NewRequestWithContext(ctx, "POST", "/debug/netmap", bytes.NewReader(body)))
} else {
req = must.Get(http.NewRequestWithContext(ctx, "GET", "/debug/netmap", nil))
}
httpResp := must.Get(env.Control.NodeRoundTripper(node).RoundTrip(req))
defer httpResp.Body.Close()
if httpResp.StatusCode != 200 {
t.Errorf("unexpected status code: %d", httpResp.StatusCode)
return nil
}
respBody := must.Get(io.ReadAll(httpResp.Body))
var resp tailcfg.C2NDebugNetmapResponse
must.Do(json.Unmarshal(respBody, &resp))
var current netmap.NetworkMap
must.Do(json.Unmarshal(resp.Current, &current))
if !current.PrivateKey.IsZero() {
t.Errorf("current netmap has non-zero private key: %v", current.PrivateKey)
}
// Check candidate netmap if we sent a map response.
if cand != nil {
var candidate netmap.NetworkMap
must.Do(json.Unmarshal(resp.Candidate, &candidate))
if !candidate.PrivateKey.IsZero() {
t.Errorf("candidate netmap has non-zero private key: %v", candidate.PrivateKey)
}
if diff := cmp.Diff(current.SelfNode, candidate.SelfNode); diff != "" {
t.Errorf("SelfNode differs (-current +candidate):\n%s", diff)
}
if diff := cmp.Diff(current.Peers, candidate.Peers); diff != "" {
t.Errorf("Peers differ (-current +candidate):\n%s", diff)
}
}
return &current
}
for _, n := range nodes {
mr := must.Get(env.Control.MapResponse(&tailcfg.MapRequest{NodeKey: n.Key}))
nm := getC2NNetmap(n.Key, mr)
// Make sure peers do not have "testcap" initially (we'll change this later).
if len(nm.Peers) != 1 || nm.Peers[0].CapMap().Contains("testcap") {
t.Fatalf("expected 1 peer without testcap, got: %v", nm.Peers)
}
// Make sure nodes think each other are offline initially.
if nm.Peers[0].Online().Get() {
t.Fatalf("expected 1 peer to be offline, got: %v", nm.Peers)
}
}
// Send a delta update to n0, setting "testcap" on node 1.
env.Control.AddRawMapResponse(nodes[0].Key, &tailcfg.MapResponse{
PeersChangedPatch: []*tailcfg.PeerChange{{
NodeID: nodes[1].ID, CapMap: tailcfg.NodeCapMap{"testcap": []tailcfg.RawMessage{}},
}},
})
// node 0 should see node 1 with "testcap".
must.Do(tstest.WaitFor(5*time.Second, func() error {
st := testNodes[0].MustStatus()
p, ok := st.Peer[nodes[1].Key]
if !ok {
return fmt.Errorf("node 0 (%s) doesn't see node 1 (%s) as peer\n%v", nodes[0].Key, nodes[1].Key, st)
}
if _, ok := p.CapMap["testcap"]; !ok {
return fmt.Errorf("node 0 (%s) sees node 1 (%s) as peer but without testcap\n%v", nodes[0].Key, nodes[1].Key, p)
}
return nil
}))
// Check that node 0's current netmap has "testcap" for node 1.
nm := getC2NNetmap(nodes[0].Key, nil)
if len(nm.Peers) != 1 || !nm.Peers[0].CapMap().Contains("testcap") {
t.Errorf("current netmap missing testcap: %v", nm.Peers[0].CapMap())
}
// Send a delta update to n1, marking node 0 as online.
env.Control.AddRawMapResponse(nodes[1].Key, &tailcfg.MapResponse{
PeersChangedPatch: []*tailcfg.PeerChange{{
NodeID: nodes[0].ID, Online: ptr.To(true),
}},
})
// node 1 should see node 0 as online.
must.Do(tstest.WaitFor(5*time.Second, func() error {
st := testNodes[1].MustStatus()
p, ok := st.Peer[nodes[0].Key]
if !ok || !p.Online {
return fmt.Errorf("node 0 (%s) doesn't see node 1 (%s) as an online peer\n%v", nodes[0].Key, nodes[1].Key, st)
}
return nil
}))
// The netmap from node 1 should show node 0 as online.
nm = getC2NNetmap(nodes[1].Key, nil)
if len(nm.Peers) != 1 || !nm.Peers[0].Online().Get() {
t.Errorf("expected peer to be online; got %+v", nm.Peers[0].AsStruct())
}
}