{
"schema": "restock-beads/v1",
"pin": "e2ed432399c9b0fda7aa14e9eb27784d2d893c55",
"beads": [
{
"title": "Act on a DERP PeerGone frame: invalidate the relay route Go invalidates, instead of logging it",
"description": "A DERP PeerGone frame is decoded and thrown away here, so a relay route this node learned from a peer's traffic is never invalidated. At commit e2ed432399c9b0fda7aa14e9eb27784d2d893c55, wgengine/magicsock/derp.go treats derp.PeerGoneMessage as a route event: whatever the one-byte reason says, runDerpReader calls c.removeDerpPeerRoute(peer, regionID, dc) at line 665, deleting the derpRoute[peer] entry it added at line 626 when that peer's first packet arrived on this connection. It does the same wholesale when the connection dies: on any RecvDetail error it walks peerPresent and removes the route for every peer learned on that connection (lines 566 to 569). removeDerpPeerRoute is scoped, not blanket - it builds r2 := derpRoute{regionID, dc} and deletes only if the stored entry equals it (lines 56 to 59) - so a frame from one region cannot clear a route learned on another. The frame exists for exactly this. derp/derp.go lines 81 to 88 say FramePeerGone is sent 'so B can forget that a reverse path exists on that connection to get back to A', and is ALSO sent when 'A tries to send a CallMeMaybe to B and the server has no record of B' - the PeerGoneReasonNotHere case, which Go counts separately (metricRecvDiscoDERPPeerNotHere, derp.go line 657) precisely because it means this server cannot reach that peer. In this repository the frame is parsed correctly and goes no further. ts_derp::Client::recv_one (ts_derp/src/client.rs lines 249 to 282) decodes the 32-byte key, decodes the optional reason byte totally, logs at debug, and loops. recv_one returns only (NodePublicKey, PacketMut) for a RecvPacket, so there is no channel by which a PeerGone can leave ts_derp at all, and grepping PeerGone across *.rs outside that crate returns nothing. The map it should invalidate is Multiderp::observed_routes (ts_runtime/src/multiderp.rs line 100), written on every received frame by record_observed_route (line 421, called at line 559) and pruned only against the live netmap on a peer-state update (line 751). So a peer whose route this node learned keeps it until control removes the peer: after the server has said the peer is gone - including the NotHere case, which is the server stating it has no path - RouteUpdater (ts_runtime/src/route_updater.rs line 261) keeps handing that region's transport id to every WireGuard packet for that peer. Peer-observable as a black hole rather than an error: packets are relayed to a server that drops them, the handshake never completes, and nothing in this node's logs distinguishes it from a peer that is simply offline. The port has three pieces and they should be one change. (1) Give recv_one a way to surface a control event, or give the region runner in run_derp_once the frame directly - it already sees every frame and already knows its own region id, which is what record_observed_route needs. (2) Remove the observed route on PeerGone, SCOPED TO THE REGION THE FRAME ARRIVED ON, mirroring removeDerpPeerRoute's equality check. (3) Drop every route learned on a connection when that connection fails, as lines 566 to 569 do. Test the refusals and the edges, not only the happy path: a PeerGone for a peer whose observed route points at a DIFFERENT region must leave that route alone; a 32-byte reason-less PeerGone must invalidate exactly as a 33-byte one does, and the existing ts_derp test reasonless_32_byte_peer_gone_is_accepted_then_continues must keep passing unchanged; an unknown future reason byte must invalidate too, because Go's default arm removes the route after counting metricRecvDiscoDERPPeerGoneUnknown; and a PeerGone whose body is shorter than 32 bytes must still be dropped without touching any route, as short_peer_gone_is_dropped_not_fatal already pins. Assert on the map the production code mutates, not on a re-derivation of it in the test body. See PORTING.md section B.",
"upstream": "wgengine/magicsock/derp.go @ e2ed432399c9b0fda7aa14e9eb27784d2d893c55",
"priority": 2
},
{
"title": "Decide the DERP relay choice: Go prefers a learned route over dialling a peer's home region",
"description": "This repository uses the DERP route it learns from a peer's traffic only when the netmap named no home region for that peer; Go prefers it over dialling the peer's home region at all. At commit e2ed432399c9b0fda7aa14e9eb27784d2d893c55, wgengine/magicsock/derp.go asks the question in one place, in a fixed order. derpWriteChanForRegion(regionID, peer) (line 339) is called with the peer's HOME region and: (1) if an active connection to that region already exists, use it (line 364); (2) otherwise, if derpRoute[peer] names a region this node is already connected to, use THAT connection instead (lines 375 to 381) - the comment is explicit and names the issue it fixed: 'perhaps peer's home is Frankfurt, but they dialed our home DERP node in SF to reach us, so we can reply to them using our SF connection rather than dialing Frankfurt. (Issue 150)'; (3) only then open a connection to the home region (line 389). The separate fallbackDERPRegionForPeer (line 82) is a THIRD, narrower use, reached from wgengine/magicsock/endpoint.go line 1130 when a peer has neither a valid UDP endpoint nor a DERP home at all. This repository ported (3) and the third use, and not (2). RouteUpdater (ts_runtime/src/route_updater.rs line 261) takes peer.derp_region whenever the netmap carried one and consults Multiderp::region_for_peer only in the None arm; region_for_peer (ts_runtime/src/multiderp.rs line 250) and its doc comment (lines 237 to 249) are written for that arm alone, and docs/PARITY_ROADMAP.md records the port (#24, v0.8.1) as 'a peer with no netmap home region was denied any underlay route', which is upstream's third use, faithfully done. The consequence is not a dropped packet, it is a connection: Multiderp spawns a runner for every region in the derp map (multiderp.rs line 726) and lets non-home runners idle their connection out, so sending to a peer whose home is Frankfurt wakes a connection to Frankfurt even when this node is already connected to SF and has heard from that peer there. DERP-server-observable (this node appears at a relay a Go client would not have dialled) and cost-observable (one more concurrent DERP connection per remote region in which a peer talks to us). THE DECISION TO MAKE IS WHETHER TO PORT IT AT ALL, and there is a real argument both ways, which is why this is an entry rather than a bug report. For: it is upstream behaviour, it is visible in aggregate at the relays, and the data is already collected here - record_observed_route runs for every peer, not only home-less ones, so only the CONSUMPTION is narrow. Against: this fork's DERP model is a runner per region with an idle timeout, not Go's activeDerp map with a lastWrite clock, so 'are we already connected' is a question RouteUpdater cannot ask today without new plumbing, and preferring an observed route over a netmap-stated home makes the relay choice depend on inbound traffic in a way the current design deliberately avoids. Whichever way it goes, WRITE IT DOWN at region_for_peer and in PORTING.md: the current doc comment claims 'Go derpRoute parity' for a port that implements one of Go's three uses of derpRoute, and that sentence is how the next reader concludes there is nothing left to do. If it is ported, keep what the existing arm gets right - only a region with a live transport is ever returned (resolve_region_for_peer, multiderp.rs line 387) - and test it as Go frames it, calling the production resolver rather than re-deriving its answer: a peer whose netmap home is region B, with an observed route on region A, a live transport on A and none on B, must relay over A; the same peer with a live transport on B must relay over B, because Go checks the home region FIRST. See PORTING.md section B.",
"upstream": "wgengine/magicsock/derp.go @ e2ed432399c9b0fda7aa14e9eb27784d2d893c55",
"priority": 2
},
{
"title": "Send FrameNotePreferred, so a DERP server learns whether this node considers it home",
"description": "This node never tells a DERP server which of them it considers home: FrameNotePreferred is modelled here and never sent. At commit e2ed432399c9b0fda7aa14e9eb27784d2d893c55, a Go client sends a one-byte 0x07 frame - 0x01 for 'you are my home', 0x00 for 'you are not' (derp/derp.go line 79, derp/derp_client.go line 344) - on connect when this is the preferred server (derp/derphttp/derphttp_client.go line 423 and line 569, both guarded by if c.preferred), and to EVERY active connection when the home region moves, including the false to the region that just stopped being home (wgengine/magicsock/derp.go line 292, go ad.c.NotePreferred(i == c.myDerp), and line 423). This repository has the frame type (FrameType::NotePreferred, ts_derp/src/frame/frame_type.rs line 28) and its body (ts_derp/src/frame/body/note_preferred.rs), and nothing constructs one: the symbol appears only in those two files and the mod.rs re-export. The state needed is already present and already distributed - RegionEntry::home_derp (ts_runtime/src/multiderp.rs line 116) is a watch channel set true for the home region and false for the rest, and run_derp_once (line 483) already awaits changes on it to arm its idle timeout - so the port is to send the frame on that edge and once after the handshake, not to build new state. STATE THE VALUE HONESTLY, BECAUSE UPSTREAM DOES: derphttp_client.go line 1066 says of NotePreferred 'It's only used for stats', and the server side bears that out - derpserver's setPreferred moves the curHomeClients gauge and the homeMovesIn/homeMovesOut counters and changes nothing about how a packet is delivered. So this is server-observable and NOT peer-observable: a real Tailscale relay's home-client accounting never counts this node, and this node's home moves never appear in it. Do not oversell it in the PR; it is a small, cheap, cited divergence, and the reason it is worth filing is the shape it shares with the other two DERP entries - a frame this tree models completely and acts on not at all. Port the EDGE as well as the connect, and port the refusals with it: Go's derphttp.Client.NotePreferred (line 1067) returns early when the value is unchanged (so a repeated home selection puts nothing on the wire) and drops the connection for reconnect if the write fails (line 1079). A port that only sends true on connect, and never the false to a former home, tells that relay this node is still homed there forever - which is worse than sending nothing. Test against the bytes the client actually writes, not a re-derivation of them: connecting to a non-home region must put no NotePreferred on the wire at all; connecting to the home region must put exactly one 0x07 frame with payload 0x01; a home move must put 0x00 on the old home and 0x01 on the new one; and re-selecting the same home must put nothing on either. See PORTING.md section B.",
"upstream": "derp/derphttp/derphttp_client.go @ e2ed432399c9b0fda7aa14e9eb27784d2d893c55",
"priority": 2
}
]
}