diff --git a/hscontrol/types/change/change.go b/hscontrol/types/change/change.go index 77d0f083..d15469d9 100644 --- a/hscontrol/types/change/change.go +++ b/hscontrol/types/change/change.go @@ -490,6 +490,35 @@ func EndpointOrDERPUpdate(id types.NodeID, patch *tailcfg.PeerChange) Change { return c } +// NodeKeyRotated returns a [Change] for a node re-logging in: its NodeKey (and +// possibly DiscoKey, key expiry, or endpoints) changed, but nothing structural +// did. Peers only need those changed fields, so it is sent as the minimal +// incremental [tailcfg.PeerChange] patch rather than re-advertising the whole +// node — the smallest update that conveys the rotation, and the least +// disruptive for peers reconciling it. +func NodeKeyRotated(node types.NodeView) Change { + nk := node.NodeKey() + dk := node.DiscoKey() + + // KeyExpiry is always set: the zero value clears any prior expiry on the + // peer (un-expire), and a non-zero value carries the new expiry. + var expiry time.Time + if e, ok := node.Expiry().GetOk(); ok { + expiry = e + } + + c := PeerPatched("node key rotated (relogin)", &tailcfg.PeerChange{ + NodeID: tailcfg.NodeID(node.ID()), //nolint:gosec // NodeID is bounded + Key: &nk, + DiscoKey: &dk, + KeyExpiry: &expiry, + Endpoints: node.Endpoints().AsSlice(), + }) + c.OriginNode = node.ID() + + return c +} + // UserAdded returns a [Change] for when a user is added or updated. // A full update is sent to refresh user profiles on all nodes. func UserAdded() Change { diff --git a/hscontrol/types/change/change_test.go b/hscontrol/types/change/change_test.go index 14fe077a..5d857892 100644 --- a/hscontrol/types/change/change_test.go +++ b/hscontrol/types/change/change_test.go @@ -4,11 +4,13 @@ import ( "net/netip" "reflect" "testing" + "time" "github.com/juanfont/headscale/hscontrol/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "tailscale.com/tailcfg" + "tailscale.com/types/key" ) func TestChange_FieldSync(t *testing.T) { @@ -653,3 +655,34 @@ func TestNodeOnlineOfflineForSubnetRouter(t *testing.T) { }) } } + +// TestNodeKeyRotatedEmitsPatchNotWholeNode proves a relogin is delivered to +// peers as an incremental peer patch, not a whole-node add. A whole-node add is +// non-patchifiable on the tailscale client whenever Hostinfo changed (which it +// does on relogin), forcing the broken NodeMutationAdd path that strands a +// re-keyed, momentarily-endpoint-less peer. +func TestNodeKeyRotatedEmitsPatchNotWholeNode(t *testing.T) { + expiry := time.Now().Add(24 * time.Hour).UTC() + node := types.Node{ + ID: 7, + NodeKey: key.NewNode().Public(), + DiscoKey: key.NewDisco().Public(), + Endpoints: []netip.AddrPort{netip.MustParseAddrPort("192.168.1.9:41641")}, + Expiry: &expiry, + } + view := node.View() + + c := NodeKeyRotated(view) + + assert.False(t, c.IsFull(), "relogin must be a peer patch, not a full update") + assert.Empty(t, c.PeersChanged, "relogin must not emit a whole-node PeersChanged") + require.Len(t, c.PeerPatches, 1, "relogin must emit exactly one peer patch") + + patch := c.PeerPatches[0] + assert.Equal(t, view.ID().NodeID(), patch.NodeID) + require.NotNil(t, patch.Key, "patch must carry the rotated NodeKey") + assert.Equal(t, node.NodeKey, *patch.Key) + require.NotNil(t, patch.KeyExpiry, "patch must carry KeyExpiry to (un)expire the peer") + assert.Equal(t, expiry, *patch.KeyExpiry) + assert.Equal(t, []netip.AddrPort(node.Endpoints), patch.Endpoints, "patch must carry endpoints") +}