Compare commits

...

14 Commits

Author SHA1 Message Date
Kristoffer Dalby 9bc43bf324 integration: relax HA docker disconnect non-bug stability checks
docker network disconnect of one router triggers bridge
reconfiguration that transiently fails probes on OTHER routers
on the same network — a test-infrastructure quirk that does not
occur with a real cable pull. The lifecycle test's
"stays primary across X seconds" assertions on the prev-primary
return legs (phases 2b, 3b, 4d) tripped on this side effect.

Drop the identity assertions on those legs; keep traffic-flow
checks. Phases 4b and 5a (the no-flap regression barriers for
the algorithm fix) keep their stability windows.

Updates #3203
2026-04-29 13:35:03 +00:00
Kristoffer Dalby c1ecc562d2 state: preserve previous primary when all HA advertisers unhealthy
electPrimaryRoutes' all-unhealthy fallback used to pick
candidates[0] (lowest NodeID) regardless of who was prev primary.
Under cable-pull semantics IsOnline lags reality (long-poll TCP
half-open), so both routers stay in candidates and both go
Unhealthy via the prober — the fallback then churned primary to
a node that was itself unreachable.

Prefer prev when still in candidates; fall through to
candidates[0] only when prev is gone. Anti-blackhole property is
preserved (peers still see *some* primary), but no flap to a
known-bad node.

Update the property test's reference model to mirror the new
algorithm and split the unit test into existence
(KeepsAPrimary) and identity (PreservesPrevious) cases — the
old "lowest-ID wins" assertion encoded the bug.

Fixes #3203
2026-04-29 13:34:51 +00:00
Kristoffer Dalby 06ac9bdf77 integration: expand HA docker disconnect to lifecycle test
Cover 5 phases of cable-pull lifecycle: initial primary, single
failure+recovery either side, sequential dual failure (the bug),
simultaneous dual failure. Phases 4b and 5a are the no-flap
regression barriers; the rest guard against future algorithm
changes regressing one lifecycle while fixing another.

Currently fails at phase 4b on this branch — primary flaps to
offline r1 within ~10s of r2 cable pull.

Updates #3203
2026-04-29 12:54:02 +00:00
Kristoffer Dalby 4ab4fbd4b9 integration: regenerate workflow for HA docker disconnect test
Updates #3203
2026-04-29 12:27:40 +00:00
Kristoffer Dalby 5228818a12 integration: reproduce HA primary flap to offline node via docker disconnect
Add a docker-network-disconnect primitive — `DisconnectFromNetwork`
/ `ReconnectToNetwork` on `TailscaleClient`, backed by
`pool.Client.DisconnectNetwork` — so an integration test can
faithfully simulate the cable-pull scenario the issue reporter
observed in Proxmox. Iptables-based simulations leave the socket
open at the container kernel and miss the bug.

Use it in `TestHASubnetRouterFailoverDockerDisconnect` to assert
the no-flap invariant: once primary has failed over from r1 to r2
and r2 also loses connectivity, primary must not switch back to
the still-offline r1. The assertion fails on the current branch —
the all-unhealthy fallback in `electPrimaryRoutes` flips primary
to the lowest-NodeID candidate regardless of online state. Locking
the failure in via TDD before designing the fix.

Updates #3203
2026-04-29 12:25:40 +00:00
Kristoffer Dalby 2d04fff605 state: clear Unhealthy when node leaves HA candidate set
The legacy routes.PrimaryRoutes.SetRoutes(node, empty) implicitly
deleted the node from the unhealthy set; the snapshot refactor lost
that side-effect, so a probe timeout that landed during a
SetApprovedRoutes(empty) (or just before a Disconnect) left
Unhealthy=true forever. The bit then surfaced in DebugRoutes and
broke TestHASubnetRouterFailover.

Restore the invariant at the write boundaries that drop HA
candidacy (Disconnect, SetApprovedRoutes, UpdateNodeFromMapRequest)
and add a defensive guard in SetNodeUnhealthy so the prober's
post-timeout write cannot install a stale bit when the node has
since left the candidate set.

Updates #3203
2026-04-29 08:02:45 +00:00
Kristoffer Dalby 436aa298b7 state: port primary route tests, delete routes package
The primary route algorithm now lives in hscontrol/state/node_store.go
(snapshotFromNodes / computePrimaries). Move the test coverage that
used to live in hscontrol/routes alongside it: primaries_test.go ports
the named scenarios (anti-flap, HA failover, recovery-no-flap, exit
routes excluded, the issue #3203 both-offline regression) and HANodes,
primaries_property_test.go ports the rapid-driven invariant check.

Both tests drive the algorithm via NodeStore.UpdateNode rather than
the deleted PrimaryRoutes API. With every consumer migrated and the
algorithm relocated, the routes package has no remaining users and
gets removed.

Updates #3203
2026-04-29 08:02:45 +00:00
Kristoffer Dalby 5dd622a31b state: fold primary route election into NodeStore snapshot
primaryRoutes lived as a separate write-then-read store guarded by an
external mutex. Each Connect, Disconnect, SetApprovedRoutes, and HA
prober write touched both NodeStore and primaryRoutes in sequence,
opening a TOCTOU window in which a stale Disconnect could overwrite a
fresh Connect's route assignment (issue #3203).

The election algorithm is a pure function of the snapshot: every node
contributes its IsOnline + AllApprovedRoutes + a runtime Unhealthy
bit. Move the computation into snapshotFromNodes so it runs in the
NodeStore writer goroutine and recomputes from immutable input on
every batch — no separate primaryRoutes mutation is required.

Caller-side changes are local. GetNodePrimaryRoutes, RoutesForPeer,
the HA prober and the debug endpoint all read PrimaryRoutesForNode /
HANodes / DebugRoutes off NodeStore. Health writes flow through
State.SetNodeUnhealthy, which captures the snapshot's primaries
before/after and signals back whether failover happened. The
SetApprovedRoutes and UpdateNodeFromMapRequest paths use the same
prevPrimaries / maps.Equal pattern to detect when announced/approved
changes shifted a primary.

The per-node lock and connectGen counter that closed the original
TOCTOU window are gone too: Connect bumps a SessionEpoch field on
the node inside its UpdateNode closure, Disconnect re-checks it in
its closure and no-ops on mismatch. The NodeStore writer goroutine
serialises both batches so the check + mutation are atomic by
construction.

Updates #3203
2026-04-29 08:02:45 +00:00
Kristoffer Dalby c68e763e62 types: move DebugRoutes from routes to types
Routes package gets folded into NodeStore in a follow-up; the debug
endpoint payload moves to a neutral home so integration consumers
do not need to import the soon-removed routes package.

Updates #3203
2026-04-29 08:02:45 +00:00
Kristoffer Dalby 3feb307a12 state: compute primary routes inside NodeStore snapshot
Add primaries + isPrimary fields to Snapshot. snapshotFromNodes
now elects per-prefix primary advertisers from the current node
set, mirroring routes.PrimaryRoutes.updatePrimaryLocked: skip
exit routes, prefer the previous primary if still a valid
healthy advertiser, fall back to the lowest healthy NodeID, then
to the lowest NodeID overall when all are unhealthy.

Anti-flap memory rides the previous snapshot through applyBatch.
Caller-side coordination is unnecessary; the writer goroutine
serialises every mutation that could change the primary set.

Add NodeStore reader methods (PrimaryFor,
PrimaryRoutesForNode, HANodes, IsNodeHealthy) that mirror the
legacy routes.PrimaryRoutes API on the snapshot. Consumers will
switch in the next commit.

No behaviour change yet — primaries are computed but not read.

Updates #3203
2026-04-29 08:02:45 +00:00
Kristoffer Dalby 6baee3e6c3 types: add Unhealthy and SessionEpoch fields to Node
Runtime-only fields (gorm:"-") that the upcoming refactor folds
into NodeStore's snapshot:

  - Unhealthy: replaces routes.PrimaryRoutes.unhealthy set; written
    by HA prober.
  - SessionEpoch: replaces the external connectGen sync.Map; bumped
    inside Connect's NodeStore update closure so stale Disconnect
    rejection becomes atomic with the mutation.

No behaviour change yet — fields are unread.

Updates #3203
2026-04-29 08:02:45 +00:00
Kristoffer Dalby 4320aa1348 state: serialise Connect/Disconnect with per-node lock
The generation check added in b09af384 closed the most obvious
window for stale Disconnect calls but left a residual TOCTOU:
the read of the gen counter was not atomic with the subsequent
NodeStore.UpdateNode and primaryRoutes.SetRoutes mutations. A
concurrent Connect that bumped the gen and rewrote primary
routes could be silently overtaken by a stale Disconnect's
SetRoutes(empty), leaving the node online in NodeStore but with
no primary route — the persistent broken state described in
the issue.

Replace the bare sync.Map[NodeID]*atomic.Uint64 with a
nodeLock pairing the counter with a sync.Mutex. Connect bumps
gen and runs its mutations under the lock; Disconnect acquires
the lock first, re-checks gen, and only proceeds on a match.

Per-node, so unrelated nodes do not contend; same-node steady
state has near-zero contention (one Connect at session start,
one Disconnect ~10s after grace period). Holding the lock
across persistNodeToDB is safe — that path goes through
nodeStore.RebuildPeerMaps which queues onto the writer
goroutine but is invoked from the caller, so no re-entrance.

TestConnectDisconnectRace drives the race directly via State,
deterministically reproducing the empty-primary state on main
and passing under the lock.

Fixes #3203
2026-04-29 08:02:45 +00:00
Kristoffer Dalby 7b080e8cfa servertest, integration: regression tests for HA both-offline recovery
Cover the user-reported sequence in #3203: two subnet routers
advertise the same prefix, both go offline, one returns. Three
variants:

  - servertest TestRoutes/ha_secondary_recovers_after_all_offline:
    in-process Disconnect/Reconnect.
  - integration TestHASubnetRouterFailoverBothOffline: tailscale
    down/up between two routers, asserts both server and client
    state restore.
  - integration TestHASubnetRouterFailoverBothOfflineCablePull:
    iptables -j DROP between router and headscale, mimicking a
    real cable pull that breaks the ESTABLISHED long-poll.

All three pass on main — the production failure narrows to a
direct State.Connect/State.Disconnect race that the cable-pull
harness does not reliably hit, addressed in a follow-up commit.
The tests stay as regression coverage for the user's scenario.

Updates #3203
2026-04-29 08:02:45 +00:00
Kristoffer Dalby 3eefe3c2cc routes: add property test for PrimaryRoutes algorithm
Generate randomised sequences of ConnectAdvertise, Disconnect,
ProbeUnhealthy, ProbeHealthy and ApprovedRoutesChange operations
with rapid and check the per-prefix primary chosen by the
implementation matches a reference model after every step.

Used to broaden the search for #3203 beyond hand-enumerated
disconnect/reconnect sequences. Algorithm passes 5000 checks;
kept as a safety net for future changes to primary route
selection.

Updates #3203
2026-04-29 08:02:45 +00:00
27 changed files with 2281 additions and 1400 deletions
+3
View File
@@ -250,6 +250,9 @@ jobs:
- TestSubnetRouteACLFiltering
- TestGrantViaSubnetSteering
- TestHASubnetRouterPingFailover
- TestHASubnetRouterFailoverBothOffline
- TestHASubnetRouterFailoverBothOfflineCablePull
- TestHASubnetRouterFailoverDockerDisconnect
- TestHeadscale
- TestTailscaleNodesJoiningHeadcale
- TestSSHOneUserToAll
+1 -1
View File
@@ -27,7 +27,7 @@
let
pkgs = nixpkgs.legacyPackages.${prev.stdenv.hostPlatform.system};
buildGo = pkgs.buildGo126Module;
vendorHash = "sha256-1jVYsI73Sa9/xigxldfvH0TkQThJIGGIq+1A7ARZ068=";
vendorHash = "sha256-8vTEkPEMbJ6DSOjcoQrYRyKSYI8jjcllTmJ6RXmUV9w=";
in
{
headscale = buildGo {
+1
View File
@@ -54,6 +54,7 @@ require (
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
pgregory.net/rapid v1.2.0
tailscale.com v1.96.5
zombiezen.com/go/postgrestest v1.0.1
)
+11 -7
View File
@@ -9,7 +9,6 @@ import (
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/juanfont/headscale/hscontrol/routes"
"github.com/juanfont/headscale/hscontrol/types"
"tailscale.com/net/tsaddr"
"tailscale.com/tailcfg"
@@ -209,25 +208,30 @@ func TestTailNode(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
primary := routes.New()
cfg := &types.Config{
BaseDomain: tt.baseDomain,
TailcfgDNSConfig: tt.dnsConfig,
RandomizeClientPort: false,
Taildrop: types.TaildropConfig{Enabled: true},
}
_ = primary.SetRoutes(tt.node.ID, tt.node.SubnetRoutes()...)
// This is a hack to avoid having a second node to test the primary route.
// This should be baked into the test case proper if it is extended in the future.
_ = primary.SetRoutes(2, netip.MustParsePrefix("192.168.0.0/24"))
// Stub primary-route lookup: tt.node owns its SubnetRoutes,
// node ID 2 owns 192.168.0.0/24 (a hack carried over from
// the original routes-package-driven version of this test —
// avoids spinning up a second node just to validate that
// other nodes' primaries don't leak into tt.node's TailNode
// output).
primaries := map[types.NodeID][]netip.Prefix{
tt.node.ID: tt.node.SubnetRoutes(),
2: {netip.MustParsePrefix("192.168.0.0/24")},
}
nv := tt.node.View()
got, err := nv.TailNode(
0,
func(id types.NodeID) []netip.Prefix {
// Route function returns primaries + exit routes
// (matching the real caller contract).
return slices.Concat(primary.PrimaryRoutes(id), nv.ExitRoutes())
return slices.Concat(primaries[id], nv.ExitRoutes())
},
cfg,
)
-461
View File
@@ -1,461 +0,0 @@
package routes
import (
"fmt"
"net/netip"
"slices"
"sort"
"strings"
"sync"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/juanfont/headscale/hscontrol/util/zlog/zf"
"github.com/rs/zerolog/log"
xmaps "golang.org/x/exp/maps"
"tailscale.com/net/tsaddr"
"tailscale.com/util/set"
)
type PrimaryRoutes struct {
mu sync.Mutex
// routes is a map of prefixes that are adverties and approved and available
// in the global headscale state.
routes map[types.NodeID]set.Set[netip.Prefix]
// primaries is a map of prefixes to the node that is the primary for that prefix.
primaries map[netip.Prefix]types.NodeID
isPrimary map[types.NodeID]bool
// unhealthy tracks nodes that failed health probes. Unhealthy nodes
// are skipped during primary selection but remain in the routes map.
unhealthy set.Set[types.NodeID]
}
func New() *PrimaryRoutes {
return &PrimaryRoutes{
routes: make(map[types.NodeID]set.Set[netip.Prefix]),
primaries: make(map[netip.Prefix]types.NodeID),
isPrimary: make(map[types.NodeID]bool),
unhealthy: make(set.Set[types.NodeID]),
}
}
// updatePrimaryLocked recalculates the primary routes and updates the internal state.
// It returns true if the primary routes have changed.
// It is assumed that the caller holds the lock.
// The algorithm is as follows:
// 1. Reset the primaries map.
// 2. Iterate over the routes and count the number of times a prefix is advertised.
// 3. If a prefix is advertised by at least two nodes, it is a primary route.
// 4. If the primary routes have changed, update the internal state and return true.
// 5. Otherwise, return false.
func (pr *PrimaryRoutes) updatePrimaryLocked() bool {
log.Debug().Caller().Msg("updatePrimaryLocked starting")
// reset the primaries map, as we are going to recalculate it.
allPrimaries := make(map[netip.Prefix][]types.NodeID)
pr.isPrimary = make(map[types.NodeID]bool)
changed := false
// sort the node ids so we can iterate over them in a deterministic order.
// this is important so the same node is chosen two times in a row
// as the primary route.
ids := types.NodeIDs(xmaps.Keys(pr.routes))
sort.Sort(ids)
// Create a map of prefixes to nodes that serve them so we
// can determine the primary route for each prefix.
for _, id := range ids {
routes := pr.routes[id]
for route := range routes {
if _, ok := allPrimaries[route]; !ok {
allPrimaries[route] = []types.NodeID{id}
} else {
allPrimaries[route] = append(allPrimaries[route], id)
}
}
}
// Go through all prefixes and determine the primary route for each.
// If the number of routes is below the minimum, remove the primary.
// If the current primary is still available, continue.
// If the current primary is not available, select a new one.
for prefix, nodes := range allPrimaries {
log.Debug().
Caller().
Str(zf.Prefix, prefix.String()).
Uints64("availableNodes", func() []uint64 {
ids := make([]uint64, len(nodes))
for i, id := range nodes {
ids[i] = id.Uint64()
}
return ids
}()).
Msg("processing prefix for primary route selection")
if node, ok := pr.primaries[prefix]; ok {
// If the current primary is still available and healthy, continue.
if slices.Contains(nodes, node) && !pr.unhealthy.Contains(node) {
log.Debug().
Caller().
Str(zf.Prefix, prefix.String()).
Uint64("currentPrimary", node.Uint64()).
Msg("current primary still available and healthy, keeping it")
continue
} else {
log.Debug().
Caller().
Str(zf.Prefix, prefix.String()).
Uint64("oldPrimary", node.Uint64()).
Bool("unhealthy", pr.unhealthy.Contains(node)).
Msg("current primary no longer available or unhealthy")
}
}
// Select the first healthy node as primary.
// If all nodes are unhealthy, fall back to the first node
// (degraded service is better than no service).
var selected types.NodeID
found := false
for _, n := range nodes {
if !pr.unhealthy.Contains(n) {
selected = n
found = true
break
}
}
if !found && len(nodes) >= 1 {
selected = nodes[0]
found = true
log.Warn().
Caller().
Str(zf.Prefix, prefix.String()).
Uint64("fallbackPrimary", selected.Uint64()).
Msg("all nodes unhealthy for prefix, keeping first as degraded primary")
}
if found {
pr.primaries[prefix] = selected
changed = true
log.Debug().
Caller().
Str(zf.Prefix, prefix.String()).
Uint64("newPrimary", selected.Uint64()).
Msg("selected new primary for prefix")
}
}
// Clean up any remaining primaries that are no longer valid.
for prefix := range pr.primaries {
if _, ok := allPrimaries[prefix]; !ok {
log.Debug().
Caller().
Str(zf.Prefix, prefix.String()).
Msg("cleaning up primary route that no longer has available nodes")
delete(pr.primaries, prefix)
changed = true
}
}
// Populate the quick lookup index for primary routes
for _, nodeID := range pr.primaries {
pr.isPrimary[nodeID] = true
}
log.Debug().
Caller().
Bool(zf.Changes, changed).
Str(zf.FinalState, pr.stringLocked()).
Msg("updatePrimaryLocked completed")
return changed
}
// SetRoutes sets the routes for a given Node ID and recalculates the primary routes
// of the headscale.
// It returns true if there was a change in primary routes.
// All exit routes are ignored as they are not used in primary route context.
func (pr *PrimaryRoutes) SetRoutes(node types.NodeID, prefixes ...netip.Prefix) bool {
pr.mu.Lock()
defer pr.mu.Unlock()
nlog := log.With().Uint64(zf.NodeID, node.Uint64()).Logger()
nlog.Debug().
Caller().
Strs("prefixes", util.PrefixesToString(prefixes)).
Msg("PrimaryRoutes.SetRoutes called")
// If no routes are being set, remove the node from the routes map
// and clear any unhealthy state.
if len(prefixes) == 0 {
wasPresent := false
if _, ok := pr.routes[node]; ok {
delete(pr.routes, node)
pr.unhealthy.Delete(node)
wasPresent = true
nlog.Debug().
Caller().
Msg("removed node from primary routes (no prefixes)")
}
changed := pr.updatePrimaryLocked()
nlog.Debug().
Caller().
Bool("wasPresent", wasPresent).
Bool(zf.Changes, changed).
Str(zf.NewState, pr.stringLocked()).
Msg("SetRoutes completed (remove)")
return changed
}
rs := make(set.Set[netip.Prefix], len(prefixes))
for _, prefix := range prefixes {
if !tsaddr.IsExitRoute(prefix) {
rs.Add(prefix)
}
}
if rs.Len() != 0 {
pr.routes[node] = rs
nlog.Debug().
Caller().
Strs("routes", util.PrefixesToString(rs.Slice())).
Msg("updated node routes in primary route manager")
} else {
delete(pr.routes, node)
nlog.Debug().
Caller().
Msg("removed node from primary routes (only exit routes)")
}
changed := pr.updatePrimaryLocked()
nlog.Debug().
Caller().
Bool(zf.Changes, changed).
Str(zf.NewState, pr.stringLocked()).
Msg("SetRoutes completed (update)")
return changed
}
func (pr *PrimaryRoutes) PrimaryRoutes(id types.NodeID) []netip.Prefix {
if pr == nil {
return nil
}
pr.mu.Lock()
defer pr.mu.Unlock()
// Short circuit if the node is not a primary for any route.
if _, ok := pr.isPrimary[id]; !ok {
return nil
}
var routes []netip.Prefix
for prefix, node := range pr.primaries {
if node == id {
routes = append(routes, prefix)
}
}
slices.SortFunc(routes, netip.Prefix.Compare)
return routes
}
// SetNodeHealthy marks a node as healthy or unhealthy and recalculates
// primary routes. Returns true if primaries changed.
func (pr *PrimaryRoutes) SetNodeHealthy(
node types.NodeID,
healthy bool,
) bool {
pr.mu.Lock()
defer pr.mu.Unlock()
if healthy {
if !pr.unhealthy.Contains(node) {
return false
}
pr.unhealthy.Delete(node)
log.Debug().
Caller().
Uint64(zf.NodeID, node.Uint64()).
Msg("node marked healthy")
} else {
if pr.unhealthy.Contains(node) {
return false
}
pr.unhealthy.Add(node)
log.Info().
Caller().
Uint64(zf.NodeID, node.Uint64()).
Msg("node marked unhealthy")
}
return pr.updatePrimaryLocked()
}
// ClearUnhealthy removes a node from the unhealthy set without
// recalculating primaries. Used when a node reconnects to give
// it a fresh start.
func (pr *PrimaryRoutes) ClearUnhealthy(node types.NodeID) {
pr.mu.Lock()
defer pr.mu.Unlock()
if pr.unhealthy.Contains(node) {
pr.unhealthy.Delete(node)
log.Debug().
Caller().
Uint64(zf.NodeID, node.Uint64()).
Msg("cleared unhealthy state on reconnect")
}
}
// HANodes returns a snapshot of prefixes that have 2+ nodes
// advertising them (HA configurations), mapped to the node IDs.
// Node IDs are sorted deterministically.
func (pr *PrimaryRoutes) HANodes() map[netip.Prefix][]types.NodeID {
pr.mu.Lock()
defer pr.mu.Unlock()
// Build prefix → nodes map.
prefixNodes := make(map[netip.Prefix][]types.NodeID)
ids := types.NodeIDs(xmaps.Keys(pr.routes))
sort.Sort(ids)
for _, id := range ids {
routes := pr.routes[id]
for route := range routes {
prefixNodes[route] = append(prefixNodes[route], id)
}
}
// Keep only prefixes with 2+ advertisers.
result := make(map[netip.Prefix][]types.NodeID)
for prefix, nodes := range prefixNodes {
if len(nodes) >= 2 {
result[prefix] = nodes
}
}
return result
}
// IsNodeHealthy returns whether the node is currently considered healthy.
func (pr *PrimaryRoutes) IsNodeHealthy(node types.NodeID) bool {
pr.mu.Lock()
defer pr.mu.Unlock()
return !pr.unhealthy.Contains(node)
}
func (pr *PrimaryRoutes) String() string {
pr.mu.Lock()
defer pr.mu.Unlock()
return pr.stringLocked()
}
func (pr *PrimaryRoutes) stringLocked() string {
var sb strings.Builder
fmt.Fprintln(&sb, "Available routes:")
ids := types.NodeIDs(xmaps.Keys(pr.routes))
sort.Sort(ids)
for _, id := range ids {
prefixes := pr.routes[id]
fmt.Fprintf(&sb, "\nNode %d: %s", id, strings.Join(util.PrefixesToString(prefixes.Slice()), ", "))
}
fmt.Fprintln(&sb, "\n\nCurrent primary routes:")
for route, nodeID := range pr.primaries {
fmt.Fprintf(&sb, "\nRoute %s: %d", route, nodeID)
}
return sb.String()
}
// DebugRoutes represents the primary routes state in a structured format for JSON serialization.
type DebugRoutes struct {
// AvailableRoutes maps node IDs to their advertised routes
// In the context of primary routes, this represents the routes that are available
// for each node. A route will only be available if it is advertised by the node
// AND approved.
// Only routes by nodes currently connected to the headscale server are included.
AvailableRoutes map[types.NodeID][]netip.Prefix `json:"available_routes"`
// PrimaryRoutes maps route prefixes to the primary node serving them
PrimaryRoutes map[string]types.NodeID `json:"primary_routes"`
// UnhealthyNodes lists nodes that have failed health probes.
UnhealthyNodes []types.NodeID `json:"unhealthy_nodes,omitempty"`
}
// DebugJSON returns a structured representation of the primary routes state suitable for JSON serialization.
func (pr *PrimaryRoutes) DebugJSON() DebugRoutes {
pr.mu.Lock()
defer pr.mu.Unlock()
debug := DebugRoutes{
AvailableRoutes: make(map[types.NodeID][]netip.Prefix),
PrimaryRoutes: make(map[string]types.NodeID),
}
// Populate available routes
for nodeID, routes := range pr.routes {
prefixes := routes.Slice()
slices.SortFunc(prefixes, netip.Prefix.Compare)
debug.AvailableRoutes[nodeID] = prefixes
}
// Populate primary routes
for prefix, nodeID := range pr.primaries {
debug.PrimaryRoutes[prefix.String()] = nodeID
}
// Populate unhealthy nodes
if pr.unhealthy.Len() > 0 {
nodes := pr.unhealthy.Slice()
slices.SortFunc(nodes, func(a, b types.NodeID) int {
if a < b {
return -1
}
if a > b {
return 1
}
return 0
})
debug.UnhealthyNodes = nodes
}
return debug
}
-717
View File
@@ -1,717 +0,0 @@
package routes
import (
"net/netip"
"sync"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/util"
"tailscale.com/util/set"
)
// mp is a helper function that wraps netip.MustParsePrefix.
func mp(prefix string) netip.Prefix {
return netip.MustParsePrefix(prefix)
}
func TestPrimaryRoutes(t *testing.T) {
tests := []struct {
name string
operations func(pr *PrimaryRoutes) bool
expectedRoutes map[types.NodeID]set.Set[netip.Prefix]
expectedPrimaries map[netip.Prefix]types.NodeID
expectedIsPrimary map[types.NodeID]bool
expectedUnhealthy set.Set[types.NodeID]
expectedChange bool
// primaries is a map of prefixes to the node that is the primary for that prefix.
primaries map[netip.Prefix]types.NodeID
isPrimary map[types.NodeID]bool
}{
{
name: "single-node-registers-single-route",
operations: func(pr *PrimaryRoutes) bool {
return pr.SetRoutes(1, mp("192.168.1.0/24"))
},
expectedRoutes: map[types.NodeID]set.Set[netip.Prefix]{
1: {
mp("192.168.1.0/24"): {},
},
},
expectedPrimaries: map[netip.Prefix]types.NodeID{
mp("192.168.1.0/24"): 1,
},
expectedIsPrimary: map[types.NodeID]bool{
1: true,
},
expectedChange: true,
},
{
name: "multiple-nodes-register-different-routes",
operations: func(pr *PrimaryRoutes) bool {
pr.SetRoutes(1, mp("192.168.1.0/24"))
return pr.SetRoutes(2, mp("192.168.2.0/24"))
},
expectedRoutes: map[types.NodeID]set.Set[netip.Prefix]{
1: {
mp("192.168.1.0/24"): {},
},
2: {
mp("192.168.2.0/24"): {},
},
},
expectedPrimaries: map[netip.Prefix]types.NodeID{
mp("192.168.1.0/24"): 1,
mp("192.168.2.0/24"): 2,
},
expectedIsPrimary: map[types.NodeID]bool{
1: true,
2: true,
},
expectedChange: true,
},
{
name: "multiple-nodes-register-overlapping-routes",
operations: func(pr *PrimaryRoutes) bool {
pr.SetRoutes(1, mp("192.168.1.0/24")) // true
return pr.SetRoutes(2, mp("192.168.1.0/24")) // false
},
expectedRoutes: map[types.NodeID]set.Set[netip.Prefix]{
1: {
mp("192.168.1.0/24"): {},
},
2: {
mp("192.168.1.0/24"): {},
},
},
expectedPrimaries: map[netip.Prefix]types.NodeID{
mp("192.168.1.0/24"): 1,
},
expectedIsPrimary: map[types.NodeID]bool{
1: true,
},
expectedChange: false,
},
{
name: "node-deregisters-a-route",
operations: func(pr *PrimaryRoutes) bool {
pr.SetRoutes(1, mp("192.168.1.0/24"))
return pr.SetRoutes(1) // Deregister by setting no routes
},
expectedRoutes: nil,
expectedPrimaries: nil,
expectedIsPrimary: nil,
expectedChange: true,
},
{
name: "node-deregisters-one-of-multiple-routes",
operations: func(pr *PrimaryRoutes) bool {
pr.SetRoutes(1, mp("192.168.1.0/24"), mp("192.168.2.0/24"))
return pr.SetRoutes(1, mp("192.168.2.0/24")) // Deregister one route by setting the remaining route
},
expectedRoutes: map[types.NodeID]set.Set[netip.Prefix]{
1: {
mp("192.168.2.0/24"): {},
},
},
expectedPrimaries: map[netip.Prefix]types.NodeID{
mp("192.168.2.0/24"): 1,
},
expectedIsPrimary: map[types.NodeID]bool{
1: true,
},
expectedChange: true,
},
{
name: "node-registers-and-deregisters-routes-in-sequence",
operations: func(pr *PrimaryRoutes) bool {
pr.SetRoutes(1, mp("192.168.1.0/24"))
pr.SetRoutes(2, mp("192.168.2.0/24"))
pr.SetRoutes(1) // Deregister by setting no routes
return pr.SetRoutes(1, mp("192.168.3.0/24"))
},
expectedRoutes: map[types.NodeID]set.Set[netip.Prefix]{
1: {
mp("192.168.3.0/24"): {},
},
2: {
mp("192.168.2.0/24"): {},
},
},
expectedPrimaries: map[netip.Prefix]types.NodeID{
mp("192.168.2.0/24"): 2,
mp("192.168.3.0/24"): 1,
},
expectedIsPrimary: map[types.NodeID]bool{
1: true,
2: true,
},
expectedChange: true,
},
{
name: "multiple-nodes-register-same-route",
operations: func(pr *PrimaryRoutes) bool {
pr.SetRoutes(1, mp("192.168.1.0/24")) // false
pr.SetRoutes(2, mp("192.168.1.0/24")) // true
return pr.SetRoutes(3, mp("192.168.1.0/24")) // false
},
expectedRoutes: map[types.NodeID]set.Set[netip.Prefix]{
1: {
mp("192.168.1.0/24"): {},
},
2: {
mp("192.168.1.0/24"): {},
},
3: {
mp("192.168.1.0/24"): {},
},
},
expectedPrimaries: map[netip.Prefix]types.NodeID{
mp("192.168.1.0/24"): 1,
},
expectedIsPrimary: map[types.NodeID]bool{
1: true,
},
expectedChange: false,
},
{
name: "register-multiple-routes-shift-primary-check-primary",
operations: func(pr *PrimaryRoutes) bool {
pr.SetRoutes(1, mp("192.168.1.0/24")) // false
pr.SetRoutes(2, mp("192.168.1.0/24")) // true, 1 primary
pr.SetRoutes(3, mp("192.168.1.0/24")) // false, 1 primary
return pr.SetRoutes(1) // true, 2 primary
},
expectedRoutes: map[types.NodeID]set.Set[netip.Prefix]{
2: {
mp("192.168.1.0/24"): {},
},
3: {
mp("192.168.1.0/24"): {},
},
},
expectedPrimaries: map[netip.Prefix]types.NodeID{
mp("192.168.1.0/24"): 2,
},
expectedIsPrimary: map[types.NodeID]bool{
2: true,
},
expectedChange: true,
},
{
name: "primary-route-map-is-cleared-up-no-primary",
operations: func(pr *PrimaryRoutes) bool {
pr.SetRoutes(1, mp("192.168.1.0/24")) // false
pr.SetRoutes(2, mp("192.168.1.0/24")) // true, 1 primary
pr.SetRoutes(3, mp("192.168.1.0/24")) // false, 1 primary
pr.SetRoutes(1) // true, 2 primary
return pr.SetRoutes(2) // true, no primary
},
expectedRoutes: map[types.NodeID]set.Set[netip.Prefix]{
3: {
mp("192.168.1.0/24"): {},
},
},
expectedPrimaries: map[netip.Prefix]types.NodeID{
mp("192.168.1.0/24"): 3,
},
expectedIsPrimary: map[types.NodeID]bool{
3: true,
},
expectedChange: true,
},
{
name: "primary-route-map-is-cleared-up-all-no-primary",
operations: func(pr *PrimaryRoutes) bool {
pr.SetRoutes(1, mp("192.168.1.0/24")) // false
pr.SetRoutes(2, mp("192.168.1.0/24")) // true, 1 primary
pr.SetRoutes(3, mp("192.168.1.0/24")) // false, 1 primary
pr.SetRoutes(1) // true, 2 primary
pr.SetRoutes(2) // true, no primary
return pr.SetRoutes(3) // false, no primary
},
expectedChange: true,
},
{
name: "primary-route-map-is-cleared-up",
operations: func(pr *PrimaryRoutes) bool {
pr.SetRoutes(1, mp("192.168.1.0/24")) // false
pr.SetRoutes(2, mp("192.168.1.0/24")) // true, 1 primary
pr.SetRoutes(3, mp("192.168.1.0/24")) // false, 1 primary
pr.SetRoutes(1) // true, 2 primary
return pr.SetRoutes(2) // true, no primary
},
expectedRoutes: map[types.NodeID]set.Set[netip.Prefix]{
3: {
mp("192.168.1.0/24"): {},
},
},
expectedPrimaries: map[netip.Prefix]types.NodeID{
mp("192.168.1.0/24"): 3,
},
expectedIsPrimary: map[types.NodeID]bool{
3: true,
},
expectedChange: true,
},
{
name: "primary-route-no-flake",
operations: func(pr *PrimaryRoutes) bool {
pr.SetRoutes(1, mp("192.168.1.0/24")) // false
pr.SetRoutes(2, mp("192.168.1.0/24")) // true, 1 primary
pr.SetRoutes(3, mp("192.168.1.0/24")) // false, 1 primary
pr.SetRoutes(1) // true, 2 primary
return pr.SetRoutes(1, mp("192.168.1.0/24")) // false, 2 primary
},
expectedRoutes: map[types.NodeID]set.Set[netip.Prefix]{
1: {
mp("192.168.1.0/24"): {},
},
2: {
mp("192.168.1.0/24"): {},
},
3: {
mp("192.168.1.0/24"): {},
},
},
expectedPrimaries: map[netip.Prefix]types.NodeID{
mp("192.168.1.0/24"): 2,
},
expectedIsPrimary: map[types.NodeID]bool{
2: true,
},
expectedChange: false,
},
{
name: "primary-route-no-flake-check-old-primary",
operations: func(pr *PrimaryRoutes) bool {
pr.SetRoutes(1, mp("192.168.1.0/24")) // false
pr.SetRoutes(2, mp("192.168.1.0/24")) // true, 1 primary
pr.SetRoutes(3, mp("192.168.1.0/24")) // false, 1 primary
pr.SetRoutes(1) // true, 2 primary
return pr.SetRoutes(1, mp("192.168.1.0/24")) // false, 2 primary
},
expectedRoutes: map[types.NodeID]set.Set[netip.Prefix]{
1: {
mp("192.168.1.0/24"): {},
},
2: {
mp("192.168.1.0/24"): {},
},
3: {
mp("192.168.1.0/24"): {},
},
},
expectedPrimaries: map[netip.Prefix]types.NodeID{
mp("192.168.1.0/24"): 2,
},
expectedIsPrimary: map[types.NodeID]bool{
2: true,
},
expectedChange: false,
},
{
name: "primary-route-no-flake-full-integration",
operations: func(pr *PrimaryRoutes) bool {
pr.SetRoutes(1, mp("192.168.1.0/24")) // false
pr.SetRoutes(2, mp("192.168.1.0/24")) // true, 1 primary
pr.SetRoutes(3, mp("192.168.1.0/24")) // false, 1 primary
pr.SetRoutes(1) // true, 2 primary
pr.SetRoutes(2) // true, 3 primary
pr.SetRoutes(1, mp("192.168.1.0/24")) // true, 3 primary
pr.SetRoutes(2, mp("192.168.1.0/24")) // true, 3 primary
pr.SetRoutes(1) // true, 3 primary
return pr.SetRoutes(1, mp("192.168.1.0/24")) // false, 3 primary
},
expectedRoutes: map[types.NodeID]set.Set[netip.Prefix]{
1: {
mp("192.168.1.0/24"): {},
},
2: {
mp("192.168.1.0/24"): {},
},
3: {
mp("192.168.1.0/24"): {},
},
},
expectedPrimaries: map[netip.Prefix]types.NodeID{
mp("192.168.1.0/24"): 3,
},
expectedIsPrimary: map[types.NodeID]bool{
3: true,
},
expectedChange: false,
},
{
name: "multiple-nodes-register-same-route-and-exit",
operations: func(pr *PrimaryRoutes) bool {
pr.SetRoutes(1, mp("0.0.0.0/0"), mp("192.168.1.0/24"))
return pr.SetRoutes(2, mp("192.168.1.0/24"))
},
expectedRoutes: map[types.NodeID]set.Set[netip.Prefix]{
1: {
mp("192.168.1.0/24"): {},
},
2: {
mp("192.168.1.0/24"): {},
},
},
expectedPrimaries: map[netip.Prefix]types.NodeID{
mp("192.168.1.0/24"): 1,
},
expectedIsPrimary: map[types.NodeID]bool{
1: true,
},
expectedChange: false,
},
{
name: "deregister-non-existent-route",
operations: func(pr *PrimaryRoutes) bool {
return pr.SetRoutes(1) // Deregister by setting no routes
},
expectedRoutes: nil,
expectedChange: false,
},
{
name: "register-empty-prefix-list",
operations: func(pr *PrimaryRoutes) bool {
return pr.SetRoutes(1)
},
expectedRoutes: nil,
expectedChange: false,
},
{
name: "exit-nodes",
operations: func(pr *PrimaryRoutes) bool {
pr.SetRoutes(1, mp("10.0.0.0/16"), mp("0.0.0.0/0"), mp("::/0"))
pr.SetRoutes(3, mp("0.0.0.0/0"), mp("::/0"))
return pr.SetRoutes(2, mp("0.0.0.0/0"), mp("::/0"))
},
expectedRoutes: map[types.NodeID]set.Set[netip.Prefix]{
1: {
mp("10.0.0.0/16"): {},
},
},
expectedPrimaries: map[netip.Prefix]types.NodeID{
mp("10.0.0.0/16"): 1,
},
expectedIsPrimary: map[types.NodeID]bool{
1: true,
},
expectedChange: false,
},
{
name: "unhealthy-primary-fails-over",
operations: func(pr *PrimaryRoutes) bool {
pr.SetRoutes(1, mp("192.168.1.0/24"))
pr.SetRoutes(2, mp("192.168.1.0/24"))
return pr.SetNodeHealthy(1, false)
},
expectedRoutes: map[types.NodeID]set.Set[netip.Prefix]{
1: {mp("192.168.1.0/24"): {}},
2: {mp("192.168.1.0/24"): {}},
},
expectedPrimaries: map[netip.Prefix]types.NodeID{
mp("192.168.1.0/24"): 2,
},
expectedIsPrimary: map[types.NodeID]bool{
2: true,
},
expectedUnhealthy: set.Set[types.NodeID]{1: {}},
expectedChange: true,
},
{
name: "unhealthy-non-primary-no-change",
operations: func(pr *PrimaryRoutes) bool {
pr.SetRoutes(1, mp("192.168.1.0/24"))
pr.SetRoutes(2, mp("192.168.1.0/24"))
return pr.SetNodeHealthy(2, false)
},
expectedRoutes: map[types.NodeID]set.Set[netip.Prefix]{
1: {mp("192.168.1.0/24"): {}},
2: {mp("192.168.1.0/24"): {}},
},
expectedPrimaries: map[netip.Prefix]types.NodeID{
mp("192.168.1.0/24"): 1,
},
expectedIsPrimary: map[types.NodeID]bool{
1: true,
},
expectedUnhealthy: set.Set[types.NodeID]{2: {}},
expectedChange: false,
},
{
name: "all-unhealthy-keeps-current-primary",
operations: func(pr *PrimaryRoutes) bool {
pr.SetRoutes(1, mp("192.168.1.0/24"))
pr.SetRoutes(2, mp("192.168.1.0/24"))
pr.SetNodeHealthy(1, false) // failover to 2
return pr.SetNodeHealthy(2, false) // both unhealthy, falls back to first sorted (1)
},
expectedRoutes: map[types.NodeID]set.Set[netip.Prefix]{
1: {mp("192.168.1.0/24"): {}},
2: {mp("192.168.1.0/24"): {}},
},
expectedPrimaries: map[netip.Prefix]types.NodeID{
mp("192.168.1.0/24"): 1,
},
expectedIsPrimary: map[types.NodeID]bool{
1: true,
},
expectedUnhealthy: set.Set[types.NodeID]{1: {}, 2: {}},
expectedChange: true,
},
{
name: "recovery-marks-healthy-no-flap",
operations: func(pr *PrimaryRoutes) bool {
pr.SetRoutes(1, mp("192.168.1.0/24"))
pr.SetRoutes(2, mp("192.168.1.0/24"))
pr.SetRoutes(3, mp("192.168.1.0/24"))
pr.SetNodeHealthy(1, false) // failover to 2
return pr.SetNodeHealthy(1, true) // recovered, but 2 stays primary
},
expectedRoutes: map[types.NodeID]set.Set[netip.Prefix]{
1: {mp("192.168.1.0/24"): {}},
2: {mp("192.168.1.0/24"): {}},
3: {mp("192.168.1.0/24"): {}},
},
expectedPrimaries: map[netip.Prefix]types.NodeID{
mp("192.168.1.0/24"): 2,
},
expectedIsPrimary: map[types.NodeID]bool{
2: true,
},
expectedChange: false,
},
{
name: "clear-unhealthy-on-reconnect",
operations: func(pr *PrimaryRoutes) bool {
pr.SetRoutes(1, mp("192.168.1.0/24"))
pr.SetRoutes(2, mp("192.168.1.0/24"))
pr.SetNodeHealthy(1, false) // failover to 2
pr.ClearUnhealthy(1) // reconnect clears unhealthy
// 2 stays primary (stability), but 1 is healthy again
return pr.SetNodeHealthy(1, false) // can be marked unhealthy again
},
expectedRoutes: map[types.NodeID]set.Set[netip.Prefix]{
1: {mp("192.168.1.0/24"): {}},
2: {mp("192.168.1.0/24"): {}},
},
expectedPrimaries: map[netip.Prefix]types.NodeID{
mp("192.168.1.0/24"): 2,
},
expectedIsPrimary: map[types.NodeID]bool{
2: true,
},
expectedUnhealthy: set.Set[types.NodeID]{1: {}},
expectedChange: false,
},
{
name: "unhealthy-then-deregister",
operations: func(pr *PrimaryRoutes) bool {
pr.SetRoutes(1, mp("192.168.1.0/24"))
pr.SetRoutes(2, mp("192.168.1.0/24"))
pr.SetNodeHealthy(1, false)
return pr.SetRoutes(1) // deregister clears routes and unhealthy
},
expectedRoutes: map[types.NodeID]set.Set[netip.Prefix]{
2: {mp("192.168.1.0/24"): {}},
},
expectedPrimaries: map[netip.Prefix]types.NodeID{
mp("192.168.1.0/24"): 2,
},
expectedIsPrimary: map[types.NodeID]bool{
2: true,
},
expectedChange: false,
},
{
name: "unhealthy-primary-three-nodes-cascade",
operations: func(pr *PrimaryRoutes) bool {
pr.SetRoutes(1, mp("192.168.1.0/24"))
pr.SetRoutes(2, mp("192.168.1.0/24"))
pr.SetRoutes(3, mp("192.168.1.0/24"))
pr.SetNodeHealthy(1, false) // failover to 2
return pr.SetNodeHealthy(2, false) // failover to 3
},
expectedRoutes: map[types.NodeID]set.Set[netip.Prefix]{
1: {mp("192.168.1.0/24"): {}},
2: {mp("192.168.1.0/24"): {}},
3: {mp("192.168.1.0/24"): {}},
},
expectedPrimaries: map[netip.Prefix]types.NodeID{
mp("192.168.1.0/24"): 3,
},
expectedIsPrimary: map[types.NodeID]bool{
3: true,
},
expectedUnhealthy: set.Set[types.NodeID]{1: {}, 2: {}},
expectedChange: true,
},
{
name: "concurrent-access",
operations: func(pr *PrimaryRoutes) bool {
var wg sync.WaitGroup
wg.Add(2)
var change1, change2 bool
go func() {
defer wg.Done()
change1 = pr.SetRoutes(1, mp("192.168.1.0/24"))
}()
go func() {
defer wg.Done()
change2 = pr.SetRoutes(2, mp("192.168.2.0/24"))
}()
wg.Wait()
return change1 || change2
},
expectedRoutes: map[types.NodeID]set.Set[netip.Prefix]{
1: {
mp("192.168.1.0/24"): {},
},
2: {
mp("192.168.2.0/24"): {},
},
},
expectedPrimaries: map[netip.Prefix]types.NodeID{
mp("192.168.1.0/24"): 1,
mp("192.168.2.0/24"): 2,
},
expectedIsPrimary: map[types.NodeID]bool{
1: true,
2: true,
},
expectedChange: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pr := New()
change := tt.operations(pr)
if change != tt.expectedChange {
t.Errorf("change = %v, want %v", change, tt.expectedChange)
}
comps := append(util.Comparers, cmpopts.EquateEmpty())
if diff := cmp.Diff(tt.expectedRoutes, pr.routes, comps...); diff != "" {
t.Errorf("routes mismatch (-want +got):\n%s", diff)
}
if diff := cmp.Diff(tt.expectedPrimaries, pr.primaries, comps...); diff != "" {
t.Errorf("primaries mismatch (-want +got):\n%s", diff)
}
if diff := cmp.Diff(tt.expectedIsPrimary, pr.isPrimary, comps...); diff != "" {
t.Errorf("isPrimary mismatch (-want +got):\n%s", diff)
}
if diff := cmp.Diff(tt.expectedUnhealthy, pr.unhealthy, comps...); diff != "" {
t.Errorf("unhealthy mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestHANodes(t *testing.T) {
tests := []struct {
name string
setup func(pr *PrimaryRoutes)
expected map[netip.Prefix][]types.NodeID
}{
{
name: "single-node-not-ha",
setup: func(pr *PrimaryRoutes) {
pr.SetRoutes(1, mp("192.168.1.0/24"))
},
expected: nil,
},
{
name: "two-nodes-same-prefix-is-ha",
setup: func(pr *PrimaryRoutes) {
pr.SetRoutes(1, mp("192.168.1.0/24"))
pr.SetRoutes(2, mp("192.168.1.0/24"))
},
expected: map[netip.Prefix][]types.NodeID{
mp("192.168.1.0/24"): {1, 2},
},
},
{
name: "two-nodes-different-prefixes-not-ha",
setup: func(pr *PrimaryRoutes) {
pr.SetRoutes(1, mp("192.168.1.0/24"))
pr.SetRoutes(2, mp("192.168.2.0/24"))
},
expected: nil,
},
{
name: "three-nodes-two-share-prefix",
setup: func(pr *PrimaryRoutes) {
pr.SetRoutes(1, mp("192.168.1.0/24"))
pr.SetRoutes(2, mp("192.168.1.0/24"))
pr.SetRoutes(3, mp("10.0.0.0/8"))
},
expected: map[netip.Prefix][]types.NodeID{
mp("192.168.1.0/24"): {1, 2},
},
},
{
name: "three-nodes-all-share",
setup: func(pr *PrimaryRoutes) {
pr.SetRoutes(1, mp("192.168.1.0/24"))
pr.SetRoutes(2, mp("192.168.1.0/24"))
pr.SetRoutes(3, mp("192.168.1.0/24"))
},
expected: map[netip.Prefix][]types.NodeID{
mp("192.168.1.0/24"): {1, 2, 3},
},
},
{
name: "empty",
setup: func(_ *PrimaryRoutes) {
},
expected: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pr := New()
tt.setup(pr)
got := pr.HANodes()
comps := append(util.Comparers, cmpopts.EquateEmpty())
if diff := cmp.Diff(tt.expected, got, comps...); diff != "" {
t.Errorf("HANodes mismatch (-want +got):\n%s", diff)
}
})
}
}
+150
View File
@@ -0,0 +1,150 @@
package servertest_test
import (
"context"
"net/netip"
"slices"
"sync"
"testing"
"time"
"github.com/juanfont/headscale/hscontrol/servertest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tailscale.com/tailcfg"
)
// TestConnectDisconnectRace targets the residual TOCTOU window in
// state.Disconnect: the connectGeneration check at state.go:644 is not
// atomic with the subsequent NodeStore.UpdateNode and
// primaryRoutes.SetRoutes calls. A new Connect that runs between the
// gen check and the mutations can have its effects overwritten by the
// stale Disconnect's SetRoutes(empty).
//
// The poll.go grace-period flow protects against the most common case
// (RemoveNode + stillConnected). Connect/Disconnect on State directly
// bypasses that protection and should still leave the state consistent
// — if it doesn't, that is the bug behind issue #3203.
//
// Run with -race to also catch any data race exposed.
func TestConnectDisconnectRace(t *testing.T) {
srv := servertest.NewServer(t)
user := srv.CreateUser(t, "race-user")
route := netip.MustParsePrefix("10.0.0.0/24")
// Use NewClient to get a node fully registered + Connected via the
// real noise/poll path. After this, NodeStore + primaryRoutes already
// have the node, and Connect has been called once.
//
// Only c2 advertises the route. PrimaryRoutes preserves a current
// primary across changes (anti-flap, see primary.go), so if both
// nodes were advertising, c1 (lower NodeID) would stay primary and
// the test could never observe the route slipping out of c2's
// PrimaryRoutes — it would never have been there in the first place.
c1 := servertest.NewClient(t, srv, "race-r1", servertest.WithUser(user))
c2 := servertest.NewClient(t, srv, "race-r2", servertest.WithUser(user))
c1.WaitForPeers(t, 1, 10*time.Second)
c2.Direct().SetHostinfo(&tailcfg.Hostinfo{
BackendLogID: "servertest-race-r2",
Hostname: "race-r2",
RoutableIPs: []netip.Prefix{route},
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
_ = c2.Direct().SendUpdate(ctx)
cancel()
r2ID := findNodeID(t, srv, "race-r2")
_, ch, err := srv.State().SetApprovedRoutes(r2ID, []netip.Prefix{route})
require.NoError(t, err)
srv.App.Change(ch)
// Wait for advertisement + approval to be reflected as a primary
// route assignment in PrimaryRoutes; otherwise we'd be racing the
// initial steady-state setup, not the Connect/Disconnect window.
require.Eventually(t, func() bool {
return slices.Contains(srv.State().GetNodePrimaryRoutes(r2ID), route)
}, 10*time.Second, 50*time.Millisecond,
"primary route should be assigned to r2 before driving the race")
// Drive the race repeatedly. Each iteration:
// 1. Call Connect(id) to obtain a fresh gen — this stands in for
// a session that "owns" the node.
// 2. Spawn a goroutine that issues Disconnect(id, gen) — the
// stale deferred disconnect.
// 3. Concurrently spawn a goroutine that issues Connect(id) —
// the new session arriving.
// 4. After both finish, check the state is consistent: the node
// should be online and primaryRoutes should hold the approved
// route for it.
//
// The two goroutines synchronise on a barrier so they start
// approximately simultaneously, maximising the chance of hitting the
// TOCTOU window.
const iterations = 100
for i := range iterations {
// Establish a "current session" with a known gen for r2.
_, gen := srv.State().Connect(r2ID)
var wg sync.WaitGroup
start := make(chan struct{})
wg.Add(2)
go func() {
defer wg.Done()
<-start
_, _ = srv.State().Disconnect(r2ID, gen)
}()
go func() {
defer wg.Done()
<-start
_, _ = srv.State().Connect(r2ID)
}()
close(start)
wg.Wait()
// Post-condition: the node should be ONLINE (the new Connect's
// effect must dominate, because the stale Disconnect ran with
// an older gen and should have been a no-op — or its effects
// must not have overtaken the new Connect's writes).
nv, ok := srv.State().GetNodeByID(r2ID)
if !assert.True(t, ok, "iteration %d: node should exist", i) {
continue
}
online, known := nv.IsOnline().GetOk()
if !assert.True(t, known, "iteration %d: online status should be known", i) {
continue
}
assert.True(t, online,
"iteration %d: node should be ONLINE after concurrent Connect+Disconnect (gen=%d)",
i, gen)
// The approved route must still be reflected as a primary for r2.
primary := srv.State().GetNodePrimaryRoutes(r2ID)
assert.True(t, slices.Contains(primary, route),
"iteration %d: r2 should hold primary for %s after concurrent Connect+Disconnect, got %v",
i, route, primary)
if t.Failed() {
t.Logf("primaryRoutes state at failure:\n%s",
srv.State().PrimaryRoutesString())
return
}
}
}
+1 -1
View File
@@ -72,7 +72,7 @@ func TestHAFailover_ViewerSeesPrimaryFlip(t *testing.T) {
return hasPeerPrimaryRoute(nm, "dyn-flip-r1", route)
})
changed := srv.State().PrimaryRoutes().SetNodeHealthy(id1, false)
changed := srv.State().SetNodeUnhealthy(id1, true)
require.True(t, changed, "marking primary unhealthy should change primaries")
srv.App.Change(change.PolicyChange())
+104 -7
View File
@@ -80,7 +80,7 @@ func TestHAHealthProbe_HealthyNodes(t *testing.T) {
prober.ProbeOnce(ctx, srv.App.Change)
// Both nodes should be healthy, primary unchanged (node 1).
assert.True(t, srv.State().PrimaryRoutes().IsNodeHealthy(nodeID1))
assert.True(t, srv.State().IsNodeHealthy(nodeID1))
primaries := srv.State().GetNodePrimaryRoutes(nodeID1)
assert.Contains(t, primaries, route)
@@ -111,7 +111,7 @@ func TestHAHealthProbe_UnhealthyFailover(t *testing.T) {
require.Contains(t, primaries, route, "node 1 should be primary initially")
// Mark node 1 unhealthy — should failover to node 2.
changed := srv.State().PrimaryRoutes().SetNodeHealthy(nodeID1, false)
changed := srv.State().SetNodeUnhealthy(nodeID1, true)
assert.True(t, changed, "marking primary unhealthy should change primaries")
primaries2 := srv.State().GetNodePrimaryRoutes(nodeID2)
@@ -141,12 +141,12 @@ func TestHAHealthProbe_RecoveryNoFlap(t *testing.T) {
nodeID2 := advertiseAndApproveRoute(t, srv, c2, route)
// Failover: node 1 → node 2.
srv.State().PrimaryRoutes().SetNodeHealthy(nodeID1, false)
srv.State().SetNodeUnhealthy(nodeID1, true)
primaries := srv.State().GetNodePrimaryRoutes(nodeID2)
require.Contains(t, primaries, route, "node 2 should be primary")
// Recovery: node 1 healthy again. Node 2 should STAY primary.
changed := srv.State().PrimaryRoutes().SetNodeHealthy(nodeID1, true)
changed := srv.State().SetNodeUnhealthy(nodeID1, false)
assert.False(t, changed, "recovery should not change primaries (no flap)")
primaries = srv.State().GetNodePrimaryRoutes(nodeID2)
@@ -173,8 +173,8 @@ func TestHAHealthProbe_ConnectClearsUnhealthy(t *testing.T) {
advertiseAndApproveRoute(t, srv, c2, route)
// Mark unhealthy.
srv.State().PrimaryRoutes().SetNodeHealthy(nodeID1, false)
assert.False(t, srv.State().PrimaryRoutes().IsNodeHealthy(nodeID1))
srv.State().SetNodeUnhealthy(nodeID1, true)
assert.False(t, srv.State().IsNodeHealthy(nodeID1))
// Reconnect clears unhealthy via State.Connect → ClearUnhealthy.
c1.Disconnect(t)
@@ -182,10 +182,107 @@ func TestHAHealthProbe_ConnectClearsUnhealthy(t *testing.T) {
c1.WaitForPeers(t, 1, 10*time.Second)
assert.True(t, srv.State().PrimaryRoutes().IsNodeHealthy(nodeID1),
assert.True(t, srv.State().IsNodeHealthy(nodeID1),
"reconnect should clear unhealthy state")
}
// TestHAHealthProbe_SetApprovedRoutesEmptyClearsUnhealthy verifies
// that clearing a node's approved routes also clears any stale
// Unhealthy bit, mirroring the legacy routes.SetRoutes(empty)
// auto-clear. Without this, a probe timeout that lands just before
// SetApprovedRoutes would surface as a stale unhealthy node forever.
func TestHAHealthProbe_SetApprovedRoutesEmptyClearsUnhealthy(t *testing.T) {
t.Parallel()
srv := servertest.NewServer(t)
user := srv.CreateUser(t, "ha-clear-approve")
route := netip.MustParsePrefix("10.100.0.0/24")
c1 := servertest.NewClient(t, srv, "ha-ca-r1", servertest.WithUser(user))
c2 := servertest.NewClient(t, srv, "ha-ca-r2", servertest.WithUser(user))
c1.WaitForPeers(t, 1, 10*time.Second)
c2.WaitForPeers(t, 1, 10*time.Second)
nodeID1 := advertiseAndApproveRoute(t, srv, c1, route)
advertiseAndApproveRoute(t, srv, c2, route)
srv.State().SetNodeUnhealthy(nodeID1, true)
require.False(t, srv.State().IsNodeHealthy(nodeID1))
_, _, err := srv.State().SetApprovedRoutes(nodeID1, nil)
require.NoError(t, err)
assert.True(t, srv.State().IsNodeHealthy(nodeID1),
"clearing approved routes should drop stale Unhealthy bit")
}
// TestHAHealthProbe_DisconnectClearsUnhealthy verifies that
// Disconnect resets a stale Unhealthy bit. An offline node is not an
// HA candidate; carrying the bit forward leaks into DebugRoutes.
//
// The poll handler waits a 10s grace period before calling
// state.Disconnect, so the assertion is wrapped in Eventually with a
// generous timeout.
func TestHAHealthProbe_DisconnectClearsUnhealthy(t *testing.T) {
t.Parallel()
srv := servertest.NewServer(t)
user := srv.CreateUser(t, "ha-clear-disc")
route := netip.MustParsePrefix("10.101.0.0/24")
c1 := servertest.NewClient(t, srv, "ha-cd-r1", servertest.WithUser(user))
c2 := servertest.NewClient(t, srv, "ha-cd-r2", servertest.WithUser(user))
c1.WaitForPeers(t, 1, 10*time.Second)
c2.WaitForPeers(t, 1, 10*time.Second)
nodeID1 := advertiseAndApproveRoute(t, srv, c1, route)
advertiseAndApproveRoute(t, srv, c2, route)
srv.State().SetNodeUnhealthy(nodeID1, true)
require.False(t, srv.State().IsNodeHealthy(nodeID1))
c1.Disconnect(t)
assert.Eventually(t, func() bool {
return srv.State().IsNodeHealthy(nodeID1)
}, 15*time.Second, 200*time.Millisecond,
"disconnect should drop stale Unhealthy bit")
}
// TestHAHealthProbe_SetUnhealthyNoRoutesIsNoOp verifies the
// defensive guard for the still-online-but-no-routes case: a probe
// that fires after SetApprovedRoutes(empty) should not be allowed
// to install a stale Unhealthy bit either.
func TestHAHealthProbe_SetUnhealthyNoRoutesIsNoOp(t *testing.T) {
t.Parallel()
srv := servertest.NewServer(t)
user := srv.CreateUser(t, "ha-guard-noroutes")
route := netip.MustParsePrefix("10.103.0.0/24")
c1 := servertest.NewClient(t, srv, "ha-gn-r1", servertest.WithUser(user))
c2 := servertest.NewClient(t, srv, "ha-gn-r2", servertest.WithUser(user))
c1.WaitForPeers(t, 1, 10*time.Second)
c2.WaitForPeers(t, 1, 10*time.Second)
nodeID1 := advertiseAndApproveRoute(t, srv, c1, route)
advertiseAndApproveRoute(t, srv, c2, route)
_, _, err := srv.State().SetApprovedRoutes(nodeID1, nil)
require.NoError(t, err)
srv.State().SetNodeUnhealthy(nodeID1, true)
assert.True(t, srv.State().IsNodeHealthy(nodeID1),
"SetNodeUnhealthy on node with no approved routes should be a no-op")
}
// TestHAHealthProbe_NoHARoutes verifies that the prober is a no-op
// when no HA configuration exists.
func TestHAHealthProbe_NoHARoutes(t *testing.T) {
+130
View File
@@ -3,10 +3,12 @@ package servertest_test
import (
"context"
"net/netip"
"slices"
"testing"
"time"
"github.com/juanfont/headscale/hscontrol/servertest"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tailscale.com/tailcfg"
@@ -211,6 +213,134 @@ func TestRoutes(t *testing.T) {
}
}
})
// Reproduces https://github.com/juanfont/headscale/issues/3203:
// HA tracking loses the secondary subnet router after all routers serving
// the route have been offline simultaneously and one of them returns.
//
// Two assertions split the failure surface:
// R1 — server-side primary route state restores after reconnect.
// R2 — observer's netmap shows the reconnected router online with
// the route in its primary set.
// If R1 fails the bug is in state.Connect / primaryRoutes; if R1 passes
// and R2 fails the bug is in change broadcast / mapBatcher.
//
// Caveat: servertest's Reconnect re-registers via TryLogin in addition
// to starting a new poll session. Production reconnects after a brief
// network outage may bypass re-registration. If this test passes on
// main, fall back to the integration variant noted in the plan
// (TestHASubnetRouterFailover with all routers offline simultaneously).
t.Run("ha_secondary_recovers_after_all_offline", func(t *testing.T) {
t.Parallel()
srv := servertest.NewServer(t)
user := srv.CreateUser(t, "ha3203-user")
route := netip.MustParsePrefix("10.0.0.0/24")
r1 := servertest.NewClient(t, srv, "ha3203-router1",
servertest.WithUser(user))
r2 := servertest.NewClient(t, srv, "ha3203-router2",
servertest.WithUser(user))
obs := servertest.NewClient(t, srv, "ha3203-observer",
servertest.WithUser(user))
obs.WaitForPeers(t, 2, 10*time.Second)
// Both routers advertise the same route via their hostinfo.
advertise := func(c *servertest.TestClient, name string) {
t.Helper()
c.Direct().SetHostinfo(&tailcfg.Hostinfo{
BackendLogID: "servertest-" + name,
Hostname: name,
RoutableIPs: []netip.Prefix{route},
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = c.Direct().SendUpdate(ctx)
}
advertise(r1, "ha3203-router1")
advertise(r2, "ha3203-router2")
// Approve the route on both routers explicitly. Auto-approvers
// would also work but introduce a policy dependency the harness
// does not currently set up here.
approve := func(name string) {
t.Helper()
id := findNodeID(t, srv, name)
_, ch, err := srv.State().SetApprovedRoutes(id, []netip.Prefix{route})
require.NoError(t, err)
srv.App.Change(ch)
}
approve("ha3203-router1")
approve("ha3203-router2")
// Sanity: r1 starts as primary (lower NodeID by registration order).
r1ID := findNodeID(t, srv, "ha3203-router1")
r2ID := findNodeID(t, srv, "ha3203-router2")
hasRoute := func(id types.NodeID) bool {
return slices.Contains(srv.State().GetNodePrimaryRoutes(id), route)
}
assert.Eventually(t, func() bool { return hasRoute(r1ID) },
10*time.Second, 100*time.Millisecond,
"r1 should be primary initially")
// 1. Take r1 offline. After the 10s grace period, r2 should take over.
r1.Disconnect(t)
assert.Eventually(t, func() bool { return hasRoute(r2ID) && !hasRoute(r1ID) },
20*time.Second, 200*time.Millisecond,
"r2 should take over as primary after r1 offline")
// 2. Take r2 offline. With both routers gone, no primary should remain.
r2.Disconnect(t)
assert.Eventually(t, func() bool { return !hasRoute(r1ID) && !hasRoute(r2ID) },
20*time.Second, 200*time.Millisecond,
"no primary should be assigned while both routers are offline")
// 3. Reconnect r2 (cable plugged back in).
r2.Reconnect(t)
// Hostinfo is part of the controlclient.Direct state; the Reconnect
// helper re-registers via TryLogin which carries the same Hostinfo
// that was set above. Push it again to be sure the announced route
// is registered in the new session.
advertise(r2, "ha3203-router2")
// R1: server-side state must restore r2 as primary.
assert.Eventually(t, func() bool { return hasRoute(r2ID) },
15*time.Second, 200*time.Millisecond,
"R1: r2 should be re-registered as primary after reconnect — issue #3203")
// R2: observer must see r2 online with the route in its primary set.
obs.WaitForCondition(t, "R2: observer sees r2 online with primary route",
15*time.Second,
func(nm *netmap.NetworkMap) bool {
for _, p := range nm.Peers {
hi := p.Hostinfo()
if !hi.Valid() || hi.Hostname() != "ha3203-router2" {
continue
}
online, known := p.Online().GetOk()
if !known || !online {
return false
}
for i := range p.PrimaryRoutes().Len() {
if p.PrimaryRoutes().At(i) == route {
return true
}
}
}
return false
})
})
}
// findNodeID is defined in issues_test.go.
+59 -8
View File
@@ -2,11 +2,12 @@ package state
import (
"fmt"
"net/netip"
"slices"
"strings"
"time"
hsdb "github.com/juanfont/headscale/hscontrol/db"
"github.com/juanfont/headscale/hscontrol/routes"
"github.com/juanfont/headscale/hscontrol/types"
"tailscale.com/tailcfg"
)
@@ -132,8 +133,10 @@ func (s *State) DebugOverview() string {
sb.WriteString("\n")
// Route information
routeCount := len(strings.Split(strings.TrimSpace(s.primaryRoutes.String()), "\n"))
if s.primaryRoutes.String() == "" {
primaryStr := s.PrimaryRoutesString()
routeCount := len(strings.Split(strings.TrimSpace(primaryStr), "\n"))
if primaryStr == "" {
routeCount = 0
}
@@ -253,9 +256,55 @@ func (s *State) DebugFilter() ([]tailcfg.FilterRule, error) {
return filter, nil
}
// DebugRoutes returns the current primary routes information as a structured object.
func (s *State) DebugRoutes() routes.DebugRoutes {
return s.primaryRoutes.DebugJSON()
// DebugRoutes returns the current primary routes information as a
// structured object built from the NodeStore snapshot.
func (s *State) DebugRoutes() types.DebugRoutes {
debug := types.DebugRoutes{
AvailableRoutes: make(map[types.NodeID][]netip.Prefix),
PrimaryRoutes: make(map[string]types.NodeID),
}
for _, nv := range s.nodeStore.ListNodes().All() {
if !nv.Valid() {
continue
}
online, known := nv.IsOnline().GetOk()
if !known || !online {
continue
}
approved := nv.AllApprovedRoutes()
if len(approved) == 0 {
continue
}
slices.SortFunc(approved, netip.Prefix.Compare)
debug.AvailableRoutes[nv.ID()] = approved
}
for prefix, id := range s.nodeStore.PrimaryRoutes() {
debug.PrimaryRoutes[prefix.String()] = id
}
var unhealthy []types.NodeID
for _, nv := range s.nodeStore.ListNodes().All() {
if !nv.Valid() {
continue
}
if !s.nodeStore.IsNodeHealthy(nv.ID()) {
unhealthy = append(unhealthy, nv.ID())
}
}
if len(unhealthy) > 0 {
slices.Sort(unhealthy)
debug.UnhealthyNodes = unhealthy
}
return debug
}
// DebugRoutesString returns the current primary routes information as a string.
@@ -322,8 +371,10 @@ func (s *State) DebugOverviewJSON() DebugOverviewInfo {
}
// Route information
routeCount := len(strings.Split(strings.TrimSpace(s.primaryRoutes.String()), "\n"))
if s.primaryRoutes.String() == "" {
primaryStr := s.PrimaryRoutesString()
routeCount := len(strings.Split(strings.TrimSpace(primaryStr), "\n"))
if primaryStr == "" {
routeCount = 0
}
+3 -3
View File
@@ -46,7 +46,7 @@ func (p *HAHealthProber) ProbeOnce(
ctx context.Context,
dispatch func(...change.Change),
) {
haNodes := p.state.primaryRoutes.HANodes()
haNodes := p.state.nodeStore.HANodes()
if len(haNodes) == 0 {
return
}
@@ -97,7 +97,7 @@ func (p *HAHealthProber) ProbeOnce(
Dur("latency", latency).
Msg("HA probe: node responded")
if p.state.primaryRoutes.SetNodeHealthy(id, true) {
if p.state.SetNodeUnhealthy(id, false) {
dispatch(change.PolicyChange())
log.Info().
@@ -121,7 +121,7 @@ func (p *HAHealthProber) ProbeOnce(
Dur("timeout", p.cfg.ProbeTimeout).
Msg("HA probe: node did not respond")
if p.state.primaryRoutes.SetNodeHealthy(id, false) {
if p.state.SetNodeUnhealthy(id, true) {
dispatch(change.PolicyChange())
log.Info().
+210 -8
View File
@@ -4,6 +4,8 @@ import (
"errors"
"fmt"
"maps"
"net/netip"
"slices"
"strconv"
"strings"
"sync/atomic"
@@ -12,6 +14,7 @@ import (
"github.com/juanfont/headscale/hscontrol/types"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"tailscale.com/net/tsaddr"
"tailscale.com/types/key"
"tailscale.com/types/views"
"tailscale.com/util/dnsname"
@@ -114,7 +117,7 @@ func NewNodeStore(allNodes types.Nodes, peersFunc PeersFunc, batchSize int, batc
nodes[n.ID] = *n
}
snap := snapshotFromNodes(nodes, peersFunc)
snap := snapshotFromNodes(nodes, peersFunc, nil)
store := &NodeStore{
peersFunc: peersFunc,
@@ -144,6 +147,12 @@ type Snapshot struct {
peersByNode map[types.NodeID][]types.NodeView
nodesByUser map[types.UserID][]types.NodeView
allNodes []types.NodeView
// routes maps each prefix to its current primary advertiser. The
// previous assignment is carried over when still valid so the
// primary does not flap on every unrelated batch.
routes map[netip.Prefix]types.NodeID
isPrimaryRoute map[types.NodeID]bool
}
// PeersFunc is a function that takes a list of nodes and returns a map
@@ -459,7 +468,8 @@ func (s *NodeStore) applyBatch(batch []work) {
}
}
newSnap := snapshotFromNodes(nodes, s.peersFunc)
prev := s.data.Load()
newSnap := snapshotFromNodes(nodes, s.peersFunc, prev.routes)
s.data.Store(&newSnap)
// Update node count gauge
@@ -543,12 +553,14 @@ func resolveGivenName(nodes map[types.NodeID]types.Node, self types.NodeID, base
}
}
// snapshotFromNodes creates a new Snapshot from the provided nodes.
// It builds a lot of "indexes" to make lookups fast for datasets we
// that is used frequently, like nodesByNodeKey, peersByNode, and nodesByUser.
// This is not a fast operation, it is the "slow" part of our copy-on-write
// structure, but it allows us to have fast reads and efficient lookups.
func snapshotFromNodes(nodes map[types.NodeID]types.Node, peersFunc PeersFunc) Snapshot {
// snapshotFromNodes builds the index maps and primary-route table for
// a new Snapshot. prevRoutes carries forward the previous primary
// assignment so a still-valid choice survives unrelated batches.
func snapshotFromNodes(
nodes map[types.NodeID]types.Node,
peersFunc PeersFunc,
prevRoutes map[netip.Prefix]types.NodeID,
) Snapshot {
timer := prometheus.NewTimer(nodeStoreSnapshotBuildDuration)
defer timer.ObserveDuration()
@@ -557,6 +569,8 @@ func snapshotFromNodes(nodes map[types.NodeID]types.Node, peersFunc PeersFunc) S
allNodes = append(allNodes, n.View())
}
routes, isPrimaryRoute := electPrimaryRoutes(nodes, prevRoutes)
newSnap := Snapshot{
nodesByID: nodes,
allNodes: allNodes,
@@ -574,6 +588,9 @@ func snapshotFromNodes(nodes map[types.NodeID]types.Node, peersFunc PeersFunc) S
return peersFunc(allNodes)
}(),
nodesByUser: make(map[types.UserID][]types.NodeView),
routes: routes,
isPrimaryRoute: isPrimaryRoute,
}
// Build nodesByUser, nodesByNodeKey, and nodesByMachineKey maps
@@ -600,6 +617,89 @@ func snapshotFromNodes(nodes map[types.NodeID]types.Node, peersFunc PeersFunc) S
return newSnap
}
// electPrimaryRoutes picks the primary advertiser for each non-exit
// prefix. The previous primary is preserved when it is still online
// and healthy (anti-flap); otherwise the lowest-NodeID healthy
// advertiser wins. When every advertiser is unhealthy the previous
// primary is preserved if still a candidate, falling back to the
// lowest-NodeID candidate so peers see *some* primary instead of
// none. Anti-flap in the all-unhealthy case matters under cable-pull
// where IsOnline lags reality and a naive lowest-ID fallback churns
// primaries to a node that is itself unreachable (issue #3203).
func electPrimaryRoutes(
nodes map[types.NodeID]types.Node,
prev map[netip.Prefix]types.NodeID,
) (map[netip.Prefix]types.NodeID, map[types.NodeID]bool) {
ids := make([]types.NodeID, 0, len(nodes))
for id := range nodes {
ids = append(ids, id)
}
slices.Sort(ids)
advertisers := make(map[netip.Prefix][]types.NodeID)
for _, id := range ids {
n := nodes[id]
if n.IsOnline == nil || !*n.IsOnline {
continue
}
for _, p := range n.AllApprovedRoutes() {
if tsaddr.IsExitRoute(p) {
continue
}
advertisers[p] = append(advertisers[p], id)
}
}
routes := make(map[netip.Prefix]types.NodeID, len(advertisers))
for prefix, candidates := range advertisers {
if cur, ok := prev[prefix]; ok &&
slices.Contains(candidates, cur) &&
!nodes[cur].Unhealthy {
routes[prefix] = cur
continue
}
var (
selected types.NodeID
found bool
)
for _, c := range candidates {
if !nodes[c].Unhealthy {
selected = c
found = true
break
}
}
if !found && len(candidates) >= 1 {
if cur, ok := prev[prefix]; ok && slices.Contains(candidates, cur) {
selected = cur
} else {
selected = candidates[0]
}
found = true
}
if found {
routes[prefix] = selected
}
}
isPrimaryRoute := make(map[types.NodeID]bool, len(routes))
for _, id := range routes {
isPrimaryRoute[id] = true
}
return routes, isPrimaryRoute
}
// GetNode retrieves a node by its ID.
// The bool indicates if the node exists or is available (like "err not found").
// The NodeView might be invalid, so it must be checked with .Valid(), which must be used to ensure
@@ -752,6 +852,108 @@ func (s *NodeStore) ListPeers(id types.NodeID) views.Slice[types.NodeView] {
return views.SliceOf(s.data.Load().peersByNode[id])
}
// PrimaryRouteFor returns the current primary advertiser for prefix.
func (s *NodeStore) PrimaryRouteFor(prefix netip.Prefix) (types.NodeID, bool) {
id, ok := s.data.Load().routes[prefix]
return id, ok
}
// PrimaryRoutesForNode returns the prefixes for which id is the current
// primary advertiser.
func (s *NodeStore) PrimaryRoutesForNode(id types.NodeID) []netip.Prefix {
snap := s.data.Load()
if !snap.isPrimaryRoute[id] {
return nil
}
out := make([]netip.Prefix, 0)
for prefix, nodeID := range snap.routes {
if nodeID == id {
out = append(out, prefix)
}
}
return out
}
// HANodes returns the prefixes with two or more online advertisers, the
// candidate set the HA prober needs to monitor.
func (s *NodeStore) HANodes() map[netip.Prefix][]types.NodeID {
snap := s.data.Load()
advertisers := make(map[netip.Prefix][]types.NodeID)
for id, n := range snap.nodesByID {
if n.IsOnline == nil || !*n.IsOnline {
continue
}
for _, p := range n.AllApprovedRoutes() {
if tsaddr.IsExitRoute(p) {
continue
}
advertisers[p] = append(advertisers[p], id)
}
}
out := make(map[netip.Prefix][]types.NodeID)
for p, ids := range advertisers {
if len(ids) < 2 {
continue
}
slices.Sort(ids)
out[p] = ids
}
return out
}
// IsNodeHealthy reports whether the HA prober considers id healthy.
// Unknown nodes report healthy so absence does not exclude them from
// election.
func (s *NodeStore) IsNodeHealthy(id types.NodeID) bool {
n, ok := s.data.Load().nodesByID[id]
if !ok {
return true
}
return !n.Unhealthy
}
// PrimaryRoutes returns the snapshot's prefix→primary map. The map is
// owned by the snapshot and must not be mutated; it is safe to read
// concurrently because snapshots are immutable once published.
func (s *NodeStore) PrimaryRoutes() map[netip.Prefix]types.NodeID {
return s.data.Load().routes
}
// PrimaryRoutesString renders the snapshot's prefix→primary map for
// debug output and test diagnostics.
func (s *NodeStore) PrimaryRoutesString() string {
snap := s.data.Load()
if len(snap.routes) == 0 {
return ""
}
prefixes := make([]netip.Prefix, 0, len(snap.routes))
for p := range snap.routes {
prefixes = append(prefixes, p)
}
slices.SortFunc(prefixes, netip.Prefix.Compare)
var b strings.Builder
for _, p := range prefixes {
fmt.Fprintf(&b, "%s: %d\n", p, snap.routes[p])
}
return b.String()
}
// RebuildPeerMaps rebuilds the peer relationship map using the current peersFunc.
// This must be called after policy changes because peersFunc uses PolicyManager's
// filters to determine which nodes can see each other. Without rebuilding, the
+1 -1
View File
@@ -150,7 +150,7 @@ func TestSnapshotFromNodes(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
nodes, peersFunc := tt.setupFunc()
snapshot := snapshotFromNodes(nodes, peersFunc)
snapshot := snapshotFromNodes(nodes, peersFunc, nil)
tt.validate(t, nodes, snapshot)
})
}
+336
View File
@@ -0,0 +1,336 @@
package state
import (
"fmt"
"net/netip"
"slices"
"testing"
"github.com/juanfont/headscale/hscontrol/types"
"pgregory.net/rapid"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
)
// model mirrors the expected primary-route assignment given the
// operations applied so far. It is intentionally simple — close to
// what computePrimaries (node_store.go) prescribes — so divergence
// between the model and the snapshot's primaries map flags a bug in
// the algorithm.
//
// The model lives in the test file because the algorithm under test
// is the one inside snapshotFromNodes; the model's job is to predict
// the same answer from a separate, deliberately direct implementation.
type primariesModel struct {
connected map[types.NodeID]bool
prefixes map[types.NodeID][]netip.Prefix
unhealthy map[types.NodeID]bool
// primary[p] is the current primary for prefix p. The
// implementation preserves the current primary across changes to
// avoid flapping, so the model has to track this across
// operations rather than recompute a fresh choice each time.
primary map[netip.Prefix]types.NodeID
}
func newPrimariesModel() *primariesModel {
return &primariesModel{
connected: map[types.NodeID]bool{},
prefixes: map[types.NodeID][]netip.Prefix{},
unhealthy: map[types.NodeID]bool{},
primary: map[netip.Prefix]types.NodeID{},
}
}
// advertisersByPrefix returns the connected nodes that announce each
// prefix, sorted by NodeID (matches computePrimaries' iteration).
func (m *primariesModel) advertisersByPrefix() map[netip.Prefix][]types.NodeID {
out := map[netip.Prefix][]types.NodeID{}
for n, prefs := range m.prefixes {
if !m.connected[n] {
continue
}
for _, p := range prefs {
out[p] = append(out[p], n)
}
}
for _, nodes := range out {
slices.Sort(nodes)
}
return out
}
// updatePrimaries reapplies the algorithm to recompute the primary
// for each prefix. Called after every operation.
func (m *primariesModel) updatePrimaries() {
advertisers := m.advertisersByPrefix()
// Drop primaries for prefixes that no longer have any advertiser.
for p := range m.primary {
if _, ok := advertisers[p]; !ok {
delete(m.primary, p)
}
}
for p, nodes := range advertisers {
if cur, ok := m.primary[p]; ok {
if slices.Contains(nodes, cur) && !m.unhealthy[cur] {
continue
}
}
var (
selected types.NodeID
found bool
)
for _, n := range nodes {
if !m.unhealthy[n] {
selected = n
found = true
break
}
}
if !found && len(nodes) >= 1 {
if cur, ok := m.primary[p]; ok && slices.Contains(nodes, cur) {
selected = cur
} else {
selected = nodes[0]
}
found = true
}
if found {
m.primary[p] = selected
}
}
}
// allPrefixes returns every prefix mentioned by any connected node.
func (m *primariesModel) allPrefixes() []netip.Prefix {
seen := map[netip.Prefix]bool{}
for n, prefs := range m.prefixes {
if !m.connected[n] {
continue
}
for _, p := range prefs {
seen[p] = true
}
}
out := make([]netip.Prefix, 0, len(seen))
for p := range seen {
out = append(out, p)
}
return out
}
func samePrefixSet(a, b []netip.Prefix) bool {
if len(a) != len(b) {
return false
}
aa := slices.Clone(a)
bb := slices.Clone(b)
slices.SortFunc(aa, netip.Prefix.Compare)
slices.SortFunc(bb, netip.Prefix.Compare)
return slices.Equal(aa, bb)
}
// checkInvariants asserts every property we expect of the snapshot's
// primaries map given the model.
func checkPrimariesInvariants(rt *rapid.T, ns *NodeStore, m *primariesModel, nodeIDs []types.NodeID) {
rt.Helper()
expectedByNode := map[types.NodeID][]netip.Prefix{}
for p, owner := range m.primary {
expectedByNode[owner] = append(expectedByNode[owner], p)
}
for _, id := range nodeIDs {
got := ns.PrimaryRoutesForNode(id)
want := expectedByNode[id]
if !samePrefixSet(got, want) {
rt.Fatalf(
"PrimaryRoutesForNode(%d) = %v, model expected %v",
id, got, want,
)
}
if want := !m.unhealthy[id]; ns.IsNodeHealthy(id) != want {
rt.Fatalf(
"IsNodeHealthy(%d) = %v, want %v",
id, ns.IsNodeHealthy(id), want,
)
}
}
// Every prefix that has at least one connected advertiser must
// have a primary in the snapshot. Issue #3203 manifests as a
// prefix silently losing its primary after a disconnect/reconnect
// cycle.
for _, p := range m.allPrefixes() {
want, expectExists := m.primary[p]
if !expectExists {
continue
}
got, ok := ns.PrimaryRouteFor(p)
if !ok {
rt.Fatalf(
"prefix %s has at least one advertiser in the model but no primary in NodeStore",
p,
)
}
if want != got {
rt.Fatalf(
"prefix %s: snapshot primary = %d, model expected %d",
p, got, want,
)
}
}
}
// nodeForRapid builds a minimal types.Node for use in property
// tests. Tests drive (IsOnline, Hostinfo.RoutableIPs, ApprovedRoutes,
// Unhealthy) via UpdateNode; the rest stays fixed.
func nodeForRapid(id types.NodeID) types.Node {
mk := key.NewMachine()
nk := key.NewNode()
return types.Node{
ID: id,
Hostname: fmt.Sprintf("rapid-%d", id),
MachineKey: mk.Public(),
NodeKey: nk.Public(),
UserID: new(uint(1)),
User: &types.User{Name: "rapid"},
IsOnline: new(false),
Hostinfo: &tailcfg.Hostinfo{},
}
}
// TestPrimaryRoutesProperty drives NodeStore with a randomised
// sequence of high-level operations and checks that the snapshot's
// primaries map matches a reference model after every step.
//
// Background: issue #3203 reports that HA tracking enters a stuck
// state after a sequence of disconnect/reconnect events. The narrow
// integration and servertest reproductions written for the bug do
// not fail on upstream/main, so this property test broadens the
// search by letting rapid generate sequences we have not enumerated
// by hand.
func TestPrimaryRoutesProperty(t *testing.T) {
rapid.Check(t, func(rt *rapid.T) {
const numNodes = 4
nodeIDs := make([]types.NodeID, 0, numNodes)
for i := 1; i <= numNodes; i++ {
nodeIDs = append(nodeIDs, types.NodeID(i))
}
prefixes := []netip.Prefix{
netip.MustParsePrefix("10.0.0.0/24"),
netip.MustParsePrefix("10.0.1.0/24"),
}
ns := NewNodeStore(nil, allowAllPeersFunc, TestBatchSize, TestBatchTimeout)
ns.Start()
defer ns.Stop()
for _, id := range nodeIDs {
ns.PutNode(nodeForRapid(id))
}
m := newPrimariesModel()
nodeGen := rapid.SampledFrom(nodeIDs)
prefixSubsetGen := rapid.SliceOfNDistinct(
rapid.SampledFrom(prefixes),
0, len(prefixes),
func(p netip.Prefix) string { return p.String() },
)
opCount := rapid.IntRange(5, 60).Draw(rt, "opCount")
for step := range opCount {
op := rapid.IntRange(0, 4).Draw(rt, fmt.Sprintf("op_%d", step))
id := nodeGen.Draw(rt, fmt.Sprintf("id_%d", step))
switch op {
case 0: // ConnectAdvertise — Connect path clears Unhealthy.
prefs := prefixSubsetGen.Draw(rt, fmt.Sprintf("prefs_%d", step))
ns.UpdateNode(id, func(n *types.Node) {
n.IsOnline = new(true)
n.Unhealthy = false
n.Hostinfo = &tailcfg.Hostinfo{RoutableIPs: prefs}
n.ApprovedRoutes = prefs
})
if len(prefs) == 0 {
delete(m.connected, id)
delete(m.prefixes, id)
} else {
m.connected[id] = true
m.prefixes[id] = prefs
}
delete(m.unhealthy, id)
case 1: // Disconnect — IsOnline=false; ApprovedRoutes persists.
ns.UpdateNode(id, func(n *types.Node) {
n.IsOnline = new(false)
})
delete(m.connected, id)
case 2: // ProbeUnhealthy — HA prober marks node bad.
ns.UpdateNode(id, func(n *types.Node) {
n.Unhealthy = true
})
m.unhealthy[id] = true
case 3: // ProbeHealthy — HA prober marks node good.
ns.UpdateNode(id, func(n *types.Node) {
n.Unhealthy = false
})
delete(m.unhealthy, id)
case 4: // ApprovedRoutesChange — change advertised prefs without touching health.
prefs := prefixSubsetGen.Draw(rt, fmt.Sprintf("prefs_%d", step))
ns.UpdateNode(id, func(n *types.Node) {
n.IsOnline = new(true)
n.Hostinfo = &tailcfg.Hostinfo{RoutableIPs: prefs}
n.ApprovedRoutes = prefs
})
if len(prefs) == 0 {
delete(m.connected, id)
delete(m.prefixes, id)
} else {
m.connected[id] = true
m.prefixes[id] = prefs
}
}
m.updatePrimaries()
checkPrimariesInvariants(rt, ns, m, nodeIDs)
}
})
}
+342
View File
@@ -0,0 +1,342 @@
package state
import (
"net/netip"
"slices"
"testing"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tailscale.com/tailcfg"
)
// mp wraps netip.MustParsePrefix.
func mp(prefix string) netip.Prefix {
return netip.MustParsePrefix(prefix)
}
// primariesFixture builds a NodeStore with the requested node IDs
// pre-registered (offline, no routes) and provides terse helpers for
// driving the kinds of state transitions the algorithm cares about.
type primariesFixture struct {
t *testing.T
ns *NodeStore
}
func newPrimariesFixture(t *testing.T, ids ...types.NodeID) *primariesFixture {
t.Helper()
ns := NewNodeStore(nil, allowAllPeersFunc, TestBatchSize, TestBatchTimeout)
ns.Start()
t.Cleanup(ns.Stop)
for _, id := range ids {
ns.PutNode(nodeForRapid(id))
}
return &primariesFixture{t: t, ns: ns}
}
// advertise mirrors State.Connect: marks the node online, clears
// Unhealthy, and sets approved + announced routes to prefs. An empty
// prefs argument leaves the node online but advertising nothing.
func (f *primariesFixture) advertise(id types.NodeID, prefs ...netip.Prefix) {
f.t.Helper()
f.ns.UpdateNode(id, func(n *types.Node) {
n.IsOnline = new(true)
n.Unhealthy = false
n.Hostinfo = &tailcfg.Hostinfo{RoutableIPs: prefs}
n.ApprovedRoutes = prefs
})
}
// approveRoutes mirrors State.SetApprovedRoutes / Hostinfo updates:
// it changes the node's announced + approved set without touching
// Unhealthy.
func (f *primariesFixture) approveRoutes(id types.NodeID, prefs ...netip.Prefix) {
f.t.Helper()
f.ns.UpdateNode(id, func(n *types.Node) {
n.IsOnline = new(true)
n.Hostinfo = &tailcfg.Hostinfo{RoutableIPs: prefs}
n.ApprovedRoutes = prefs
})
}
// disconnect mirrors State.Disconnect: marks the node offline. The
// snapshot rebuild treats an offline node as a non-advertiser.
func (f *primariesFixture) disconnect(id types.NodeID) {
f.t.Helper()
f.ns.UpdateNode(id, func(n *types.Node) {
n.IsOnline = new(false)
})
}
// unhealthy mirrors State.SetNodeUnhealthy(id, true).
func (f *primariesFixture) unhealthy(id types.NodeID) {
f.t.Helper()
f.ns.UpdateNode(id, func(n *types.Node) {
n.Unhealthy = true
})
}
// healthy mirrors State.SetNodeUnhealthy(id, false).
func (f *primariesFixture) healthy(id types.NodeID) {
f.t.Helper()
f.ns.UpdateNode(id, func(n *types.Node) {
n.Unhealthy = false
})
}
// requirePrimary asserts that prefix has node id as its primary.
func (f *primariesFixture) requirePrimary(prefix netip.Prefix, id types.NodeID) {
f.t.Helper()
got, ok := f.ns.PrimaryRouteFor(prefix)
require.True(f.t, ok, "expected a primary for %s, got none", prefix)
require.Equal(f.t, id, got, "primary for %s", prefix)
}
// requireNoPrimary asserts that prefix has no primary at all.
func (f *primariesFixture) requireNoPrimary(prefix netip.Prefix) {
f.t.Helper()
_, ok := f.ns.PrimaryRouteFor(prefix)
require.False(f.t, ok, "expected no primary for %s", prefix)
}
// requireNodeRoutes asserts the set of prefixes for which id is the
// primary, regardless of order.
func (f *primariesFixture) requireNodeRoutes(id types.NodeID, want ...netip.Prefix) {
f.t.Helper()
got := f.ns.PrimaryRoutesForNode(id)
gotSorted := slices.Clone(got)
wantSorted := slices.Clone(want)
slices.SortFunc(gotSorted, netip.Prefix.Compare)
slices.SortFunc(wantSorted, netip.Prefix.Compare)
require.Equal(f.t, wantSorted, gotSorted, "primary routes for node %d", id)
}
func TestPrimaries_SingleNodeSingleRoute(t *testing.T) {
f := newPrimariesFixture(t, 1)
f.advertise(1, mp("192.168.1.0/24"))
f.requirePrimary(mp("192.168.1.0/24"), 1)
f.requireNodeRoutes(1, mp("192.168.1.0/24"))
}
func TestPrimaries_TwoNodesDifferentRoutes(t *testing.T) {
f := newPrimariesFixture(t, 1, 2)
f.advertise(1, mp("192.168.1.0/24"))
f.advertise(2, mp("192.168.2.0/24"))
f.requirePrimary(mp("192.168.1.0/24"), 1)
f.requirePrimary(mp("192.168.2.0/24"), 2)
}
func TestPrimaries_OverlappingRoutesLowerIDWins(t *testing.T) {
f := newPrimariesFixture(t, 1, 2)
f.advertise(1, mp("192.168.1.0/24"))
f.advertise(2, mp("192.168.1.0/24"))
f.requirePrimary(mp("192.168.1.0/24"), 1)
f.requireNodeRoutes(1, mp("192.168.1.0/24"))
f.requireNodeRoutes(2)
}
func TestPrimaries_AntiFlapPreservesCurrentPrimary(t *testing.T) {
// A primary that disappears (advertiser leaves the set) should
// trigger failover. When the original primary returns, the new
// primary keeps the assignment — anti-flap.
f := newPrimariesFixture(t, 1, 2)
f.advertise(1, mp("192.168.1.0/24"))
f.advertise(2, mp("192.168.1.0/24"))
f.requirePrimary(mp("192.168.1.0/24"), 1)
f.disconnect(1)
f.requirePrimary(mp("192.168.1.0/24"), 2)
f.advertise(1, mp("192.168.1.0/24"))
f.requirePrimary(mp("192.168.1.0/24"), 2)
}
func TestPrimaries_ClearRoutesDropsPrimary(t *testing.T) {
f := newPrimariesFixture(t, 1)
f.advertise(1, mp("192.168.1.0/24"))
f.requirePrimary(mp("192.168.1.0/24"), 1)
f.approveRoutes(1)
f.requireNoPrimary(mp("192.168.1.0/24"))
}
func TestPrimaries_DisconnectDropsLastAdvertiserPrimary(t *testing.T) {
f := newPrimariesFixture(t, 1)
f.advertise(1, mp("192.168.1.0/24"))
f.requirePrimary(mp("192.168.1.0/24"), 1)
f.disconnect(1)
f.requireNoPrimary(mp("192.168.1.0/24"))
}
func TestPrimaries_UnhealthyTriggersFailover(t *testing.T) {
f := newPrimariesFixture(t, 1, 2)
f.advertise(1, mp("192.168.1.0/24"))
f.advertise(2, mp("192.168.1.0/24"))
f.requirePrimary(mp("192.168.1.0/24"), 1)
f.unhealthy(1)
f.requirePrimary(mp("192.168.1.0/24"), 2)
}
func TestPrimaries_RecoveryFromUnhealthyNoFlap(t *testing.T) {
f := newPrimariesFixture(t, 1, 2)
f.advertise(1, mp("192.168.1.0/24"))
f.advertise(2, mp("192.168.1.0/24"))
f.unhealthy(1)
f.requirePrimary(mp("192.168.1.0/24"), 2)
f.healthy(1)
f.requirePrimary(mp("192.168.1.0/24"), 2)
}
func TestPrimaries_AllUnhealthyKeepsAPrimary(t *testing.T) {
// Anti-blackhole: when every advertiser is unhealthy the
// algorithm keeps *some* primary so peers can recover once one
// flips healthy. The specific node is the prev primary when
// reachable (see PreservesPrevious); this test only pins the
// existence invariant.
prefix := mp("192.168.1.0/24")
f := newPrimariesFixture(t, 1, 2)
f.advertise(1, prefix)
f.advertise(2, prefix)
f.unhealthy(1)
f.unhealthy(2)
_, ok := f.ns.PrimaryRouteFor(prefix)
require.True(t, ok, "all-unhealthy must still produce some primary")
}
func TestPrimaries_AllUnhealthyPreservesPrevious(t *testing.T) {
// Issue #3203: once a failover has moved primary to a higher-ID
// node, a subsequent all-unhealthy state must NOT churn primary
// back to the lowest-ID candidate. Under cable-pull semantics
// both nodes can linger as IsOnline=true (half-open TCP) and
// both go Unhealthy — naive `candidates[0]` would flap the
// primary to a node that is itself unreachable.
prefix := mp("10.0.0.0/24")
f := newPrimariesFixture(t, 1, 2)
f.advertise(1, prefix)
f.advertise(2, prefix)
f.requirePrimary(prefix, 1)
f.unhealthy(1)
f.requirePrimary(prefix, 2)
f.unhealthy(2)
f.requirePrimary(prefix, 2)
}
func TestPrimaries_ExitRouteNotElected(t *testing.T) {
// Exit routes (0.0.0.0/0, ::/0) are not subject to HA primary
// election — every approved exit-route advertiser keeps it.
f := newPrimariesFixture(t, 1)
exitV4 := mp("0.0.0.0/0")
f.advertise(1, exitV4)
f.requireNoPrimary(exitV4)
}
func TestPrimaries_RegressionIssue3203_BothOfflineThenOneReturns(t *testing.T) {
// Issue #3203: with two HA advertisers, dropping both then
// bringing one back used to leave the prefix without any
// primary. After the refactor the snapshot recomputes primaries
// on every NodeStore write, so the returning advertiser must
// be elected.
prefix := mp("10.0.0.0/24")
f := newPrimariesFixture(t, 1, 2)
f.advertise(1, prefix)
f.advertise(2, prefix)
f.requirePrimary(prefix, 1)
f.disconnect(1)
f.requirePrimary(prefix, 2)
f.disconnect(2)
f.requireNoPrimary(prefix)
f.advertise(2, prefix)
f.requirePrimary(prefix, 2)
}
func TestPrimaries_HANodes(t *testing.T) {
tests := []struct {
name string
setup func(*primariesFixture)
want map[netip.Prefix][]types.NodeID
}{
{
name: "single-node-not-ha",
setup: func(f *primariesFixture) {
f.advertise(1, mp("192.168.1.0/24"))
},
want: map[netip.Prefix][]types.NodeID{},
},
{
name: "two-nodes-same-prefix-is-ha",
setup: func(f *primariesFixture) {
f.advertise(1, mp("192.168.1.0/24"))
f.advertise(2, mp("192.168.1.0/24"))
},
want: map[netip.Prefix][]types.NodeID{
mp("192.168.1.0/24"): {1, 2},
},
},
{
name: "two-nodes-different-prefixes-not-ha",
setup: func(f *primariesFixture) {
f.advertise(1, mp("192.168.1.0/24"))
f.advertise(2, mp("192.168.2.0/24"))
},
want: map[netip.Prefix][]types.NodeID{},
},
{
name: "three-nodes-two-share-prefix",
setup: func(f *primariesFixture) {
f.advertise(1, mp("192.168.1.0/24"))
f.advertise(2, mp("192.168.1.0/24"))
f.advertise(3, mp("10.0.0.0/8"))
},
want: map[netip.Prefix][]types.NodeID{
mp("192.168.1.0/24"): {1, 2},
},
},
{
name: "three-nodes-all-share",
setup: func(f *primariesFixture) {
f.advertise(1, mp("192.168.1.0/24"))
f.advertise(2, mp("192.168.1.0/24"))
f.advertise(3, mp("192.168.1.0/24"))
},
want: map[netip.Prefix][]types.NodeID{
mp("192.168.1.0/24"): {1, 2, 3},
},
},
{
name: "empty",
setup: func(*primariesFixture) {
},
want: map[netip.Prefix][]types.NodeID{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := newPrimariesFixture(t, 1, 2, 3)
tt.setup(f)
got := f.ns.HANodes()
assert.Equal(t, tt.want, got)
})
}
}
+131 -165
View File
@@ -15,6 +15,7 @@ import (
"context"
"errors"
"fmt"
"maps"
"net/netip"
"slices"
"strconv"
@@ -27,7 +28,6 @@ import (
hsdb "github.com/juanfont/headscale/hscontrol/db"
"github.com/juanfont/headscale/hscontrol/policy"
"github.com/juanfont/headscale/hscontrol/policy/matcher"
"github.com/juanfont/headscale/hscontrol/routes"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/types/change"
"github.com/juanfont/headscale/hscontrol/util"
@@ -146,15 +146,6 @@ type State struct {
// via the eviction callback so any waiting goroutines wake.
authCache *expirable.LRU[types.AuthID, *types.AuthRequest]
// primaryRoutes tracks primary route assignments for nodes
primaryRoutes *routes.PrimaryRoutes
// connectGen tracks a per-node monotonic generation counter so stale
// Disconnect() calls from old poll sessions are rejected. Connect()
// increments the counter and returns the current value; Disconnect()
// only proceeds when the generation it carries matches the latest.
connectGen sync.Map // types.NodeID → *atomic.Uint64
// pings tracks pending ping requests and their response channels.
pings *pingTracker
@@ -262,13 +253,12 @@ func NewState(cfg *types.Config) (*State, error) {
return &State{
cfg: cfg,
db: db,
ipAlloc: ipAlloc,
polMan: polMan,
authCache: authCache,
primaryRoutes: routes.New(),
nodeStore: nodeStore,
pings: newPingTracker(),
db: db,
ipAlloc: ipAlloc,
polMan: polMan,
authCache: authCache,
nodeStore: nodeStore,
pings: newPingTracker(),
sshCheckAuth: make(map[sshCheckPair]time.Time),
}, nil
@@ -559,131 +549,88 @@ func (s *State) DeleteNode(node types.NodeView) (change.Change, error) {
return c, nil
}
// Connect marks a node as connected and updates its primary routes in the state.
// It returns the list of changes and a generation number. The generation number
// must be passed to Disconnect() so that stale disconnects from old poll sessions
// are rejected (see the grace period logic in poll.go).
// Connect marks a node connected and returns the resulting changes
// plus a session epoch. The caller must pass the epoch back to
// Disconnect so deferred grace-period disconnects from a previous
// poll session are dropped (see poll.go).
func (s *State) Connect(id types.NodeID) ([]change.Change, uint64) {
// Increment the connect generation for this node. This ensures that any
// in-flight Disconnect() from a previous session will see a stale generation
// and become a no-op.
gen := s.nextConnectGen(id)
prevRoutes := s.nodeStore.PrimaryRoutes()
// Update online status in NodeStore before creating change notification
// so the NodeStore already reflects the correct state when other nodes
// process the NodeCameOnline change for full map generation.
// Reconnecting clears Unhealthy: the node just proved basic
// connectivity by completing the Noise handshake.
var epoch uint64
node, ok := s.nodeStore.UpdateNode(id, func(n *types.Node) {
n.SessionEpoch++
epoch = n.SessionEpoch
n.IsOnline = new(true)
// n.LastSeen = ptr.To(now)
n.Unhealthy = false
})
if !ok {
return nil, gen
return nil, 0
}
c := []change.Change{change.NodeOnlineFor(node)}
log.Info().EmbedObject(node).Msg("node connected")
// Reconnecting clears any prior unhealthy state — the node proved
// basic connectivity by establishing the Noise session.
s.primaryRoutes.ClearUnhealthy(id)
// Use the node's current routes for primary route update.
// AllApprovedRoutes() returns only the intersection of announced and approved routes.
routeChange := s.primaryRoutes.SetRoutes(id, node.AllApprovedRoutes()...)
if routeChange {
if !maps.Equal(prevRoutes, s.nodeStore.PrimaryRoutes()) {
c = append(c, change.NodeAdded(id))
}
// Mirror Disconnect: a node coming online may (re)enable cap/relay
// grants targeting it, reintroduce identity-based aliases that
// resolve to its tags/IPs, and so on. Always trigger a PolicyChange
// so peers can recompute their netmap and pick up any policy
// elements that depend on this node being present.
// Coming online may re-enable cap/relay grants and identity-based
// aliases targeting this node, so peers need a fresh netmap.
c = append(c, change.PolicyChange())
return c, gen
return c, epoch
}
// nextConnectGen atomically increments and returns the connect generation for a node.
func (s *State) nextConnectGen(id types.NodeID) uint64 {
val, _ := s.connectGen.LoadOrStore(id, &atomic.Uint64{})
counter, ok := val.(*atomic.Uint64)
if !ok {
return 0
}
return counter.Add(1)
}
// connectGeneration returns the current connect generation for a node.
func (s *State) connectGeneration(id types.NodeID) uint64 {
val, ok := s.connectGen.Load(id)
if !ok {
return 0
}
counter, ok := val.(*atomic.Uint64)
if !ok {
return 0
}
return counter.Load()
}
// Disconnect marks a node as disconnected and updates its primary routes in the state.
// The gen parameter is the generation returned by Connect(). If a newer Connect() has
// been called since the session that is disconnecting, the generation will not match
// and this call becomes a no-op, preventing stale disconnects from overwriting the
// online status set by a newer session.
func (s *State) Disconnect(id types.NodeID, gen uint64) ([]change.Change, error) {
// Check if this disconnect is stale. A newer Connect() will have incremented
// the generation, so if ours doesn't match, a newer session owns this node.
if current := s.connectGeneration(id); current != gen {
log.Debug().
Uint64("disconnect_gen", gen).
Uint64("current_gen", current).
Msg("stale disconnect rejected, newer session active")
return nil, nil
}
// Disconnect marks the node offline. epoch must match the value
// Connect returned for this session; otherwise the call no-ops so a
// deferred disconnect from an older session cannot overwrite state set
// by a newer Connect. The check and the IsOnline write share an
// UpdateNode closure, making them atomic against concurrent Connects.
func (s *State) Disconnect(id types.NodeID, epoch uint64) ([]change.Change, error) {
var stale bool
node, ok := s.nodeStore.UpdateNode(id, func(n *types.Node) {
if n.SessionEpoch != epoch {
stale = true
return
}
now := time.Now()
n.LastSeen = &now
// NodeStore is the source of truth for all node state including online status.
n.IsOnline = new(false)
// Offline nodes are not HA candidates; drop any stale
// Unhealthy bit so it does not surface in DebugRoutes.
n.Unhealthy = false
})
if !ok {
return nil, fmt.Errorf("%w: %d", ErrNodeNotFound, id)
}
if stale {
log.Debug().
Uint64("disconnect_epoch", epoch).
Uint64("current_epoch", node.SessionEpoch()).
Msg("stale disconnect rejected, newer session active")
return nil, nil
}
log.Info().EmbedObject(node).Msg("node disconnected")
// Special error handling for disconnect - we log errors but continue
// because NodeStore is already updated and we need to notify peers
// Persist LastSeen best-effort: NodeStore already reflects offline
// and peers still need the change notifications below.
_, c, err := s.persistNodeToDB(node)
if err != nil {
// Log error but don't fail the disconnection - NodeStore is already updated
// and we need to send change notifications to peers
log.Error().Err(err).EmbedObject(node).Msg("failed to update last seen in database")
c = change.Change{}
}
// The node is disconnecting so make sure that none of the routes it
// announced are served to any nodes.
s.primaryRoutes.SetRoutes(id)
// A node going offline can affect policy compilation in ways beyond
// subnet routes: cap/relay grants targeting this node, identity-based
// aliases (tags, groups, users) that reference its tags/IPs, via
// routes steered through it, and so on. Always trigger a PolicyChange
// so peers receive a recomputed netmap and drop any cached state
// derived from this node (including peer relay allocations).
// Going offline can affect policy compilation beyond subnet routes
// (cap/relay grants, tag/group aliases, via routes), so peers need
// a fresh netmap regardless of whether the primary moved.
//
// TODO(kradalby): fires one full netmap recompute per peer on
// every connect/disconnect. Coalesce in mapper/batcher.go:addToBatch.
@@ -951,8 +898,16 @@ func (s *State) SetApprovedRoutes(nodeID types.NodeID, routes []netip.Prefix) (t
// TODO(kradalby): In principle we should call the AutoApprove logic here
// because even if the CLI removes an auto-approved route, it will be added
// back automatically.
prevRoutes := s.nodeStore.PrimaryRoutes()
n, ok := s.nodeStore.UpdateNode(nodeID, func(node *types.Node) {
node.ApprovedRoutes = routes
// A node with no approved routes is no longer an HA
// candidate; drop any stale Unhealthy bit (mirrors the
// legacy routes.SetRoutes(empty) auto-clear).
if len(node.AllApprovedRoutes()) == 0 {
node.Unhealthy = false
}
})
if !ok {
@@ -965,13 +920,9 @@ func (s *State) SetApprovedRoutes(nodeID types.NodeID, routes []netip.Prefix) (t
return types.NodeView{}, change.Change{}, err
}
// Update primary routes table based on SubnetRoutes (intersection of announced and approved).
// The primary routes table is what the mapper uses to generate network maps, so updating it
// here ensures that route changes are distributed to peers.
routeChange := s.primaryRoutes.SetRoutes(nodeID, nodeView.AllApprovedRoutes()...)
// If routes changed or the changeset isn't already a full update, trigger a policy change
// to ensure all nodes get updated network maps
// PolicyChange fans out a fresh netmap whenever the new approved
// set shifted a primary advertiser.
routeChange := !maps.Equal(prevRoutes, s.nodeStore.PrimaryRoutes())
if routeChange || !c.IsFull() {
c = change.PolicyChange()
}
@@ -1157,20 +1108,9 @@ func (s *State) SetPolicyInDB(data string) (*types.Policy, error) {
return s.db.SetPolicy(data)
}
// SetNodeRoutes sets the primary routes for a node.
func (s *State) SetNodeRoutes(nodeID types.NodeID, routes ...netip.Prefix) change.Change {
if s.primaryRoutes.SetRoutes(nodeID, routes...) {
// Route changes affect packet filters for all nodes, so trigger a policy change
// to ensure filters are regenerated across the entire network
return change.PolicyChange()
}
return change.Change{}
}
// GetNodePrimaryRoutes returns the primary routes for a node.
func (s *State) GetNodePrimaryRoutes(nodeID types.NodeID) []netip.Prefix {
return s.primaryRoutes.PrimaryRoutes(nodeID)
return s.nodeStore.PrimaryRoutesForNode(nodeID)
}
// RoutesForPeer computes the routes a peer should advertise in a viewer's
@@ -1185,7 +1125,7 @@ func (s *State) RoutesForPeer(
matchers []matcher.Match,
) []netip.Prefix {
viaResult := s.polMan.ViaRoutesForPeer(viewer, peer)
globalPrimaries := s.primaryRoutes.PrimaryRoutes(peer.ID())
globalPrimaries := s.nodeStore.PrimaryRoutesForNode(peer.ID())
exitRoutes := peer.ExitRoutes()
var reduced []netip.Prefix
@@ -1245,14 +1185,47 @@ func (s *State) RoutesForPeer(
return reduced
}
// PrimaryRoutes returns the primary routes tracker.
func (s *State) PrimaryRoutes() *routes.PrimaryRoutes {
return s.primaryRoutes
// PrimaryRoutesString renders the current prefix→primary assignment
// for diagnostics.
func (s *State) PrimaryRoutesString() string {
return s.nodeStore.PrimaryRoutesString()
}
// PrimaryRoutesString returns a string representation of all primary routes.
func (s *State) PrimaryRoutesString() string {
return s.primaryRoutes.String()
// IsNodeHealthy reports the HA prober's view of id. Unknown nodes
// report healthy.
func (s *State) IsNodeHealthy(id types.NodeID) bool {
return s.nodeStore.IsNodeHealthy(id)
}
// SetNodeUnhealthy flips the runtime Unhealthy bit and reports whether
// the resulting primary route assignment changed, so the HA prober can
// decide whether to fan out a PolicyChange.
//
// A request to mark a node Unhealthy is dropped if the node is no
// longer an HA candidate (offline or no approved routes). The prober
// reads HANodes() at the start of a probe cycle and writes back after
// the timeout fires; in that window the node may have left the
// candidate set, and the bit would just be stale. The check happens
// inside the writer goroutine so it serialises against the
// SetApprovedRoutes / Disconnect that removed candidacy.
func (s *State) SetNodeUnhealthy(id types.NodeID, unhealthy bool) bool {
prevRoutes := s.nodeStore.PrimaryRoutes()
_, ok := s.nodeStore.UpdateNode(id, func(n *types.Node) {
if unhealthy {
online := n.IsOnline != nil && *n.IsOnline
if !online || len(n.AllApprovedRoutes()) == 0 {
return
}
}
n.Unhealthy = unhealthy
})
if !ok {
return false
}
return !maps.Equal(prevRoutes, s.nodeStore.PrimaryRoutes())
}
// ValidateAPIKey checks if an API key is valid and active.
@@ -2476,6 +2449,10 @@ func (s *State) UpdateNodeFromMapRequest(id types.NodeID, req tailcfg.MapRequest
endpointChanged bool
derpChanged bool
)
// Snapshot the primary assignment so we can tell whether the
// Hostinfo + auto-approval that follows shifted any prefix.
prevRoutes := s.nodeStore.PrimaryRoutes()
// We need to ensure we update the node as it is in the NodeStore at
// the time of the request.
updatedNode, ok := s.nodeStore.UpdateNode(id, func(currentNode *types.Node) {
@@ -2583,6 +2560,14 @@ func (s *State) UpdateNodeFromMapRequest(id types.NodeID, req tailcfg.MapRequest
Msg("applying route approval results")
}
}
// AllApprovedRoutes is announced ∩ approved; a Hostinfo
// update that shrinks the announced set can drop the node
// out of HA candidacy without touching ApprovedRoutes.
// Clear any stale Unhealthy bit in that case.
if len(currentNode.AllApprovedRoutes()) == 0 {
currentNode.Unhealthy = false
}
})
if !ok {
@@ -2607,10 +2592,22 @@ func (s *State) UpdateNodeFromMapRequest(id types.NodeID, req tailcfg.MapRequest
}
} // Continue with the rest of the processing using the updated node
// Handle route changes after NodeStore update.
// Update routes if announced routes changed (even if approved routes stayed the same)
// because SubnetRoutes is the intersection of announced AND approved routes.
nodeRouteChange := s.maybeUpdateNodeRoutes(id, updatedNode, hostinfoChanged, needsRouteApproval, routeChange, req.Hostinfo)
// SubnetRoutes = announced ∩ approved, so a Hostinfo update can
// move a primary without ever touching ApprovedRoutes. The pre/post
// snapshot diff catches that.
nodeRouteChange := change.Change{}
if !maps.Equal(prevRoutes, s.nodeStore.PrimaryRoutes()) {
log.Debug().
Caller().
Uint64(zf.NodeID, id.Uint64()).
Strs(zf.RoutesAnnounced, util.PrefixesToString(updatedNode.AnnouncedRoutes())).
Strs(zf.ApprovedRoutes, util.PrefixesToString(updatedNode.ApprovedRoutes().AsSlice())).
Strs(zf.AllApprovedRoutes, util.PrefixesToString(updatedNode.AllApprovedRoutes())).
Msg("primary route assignment shifted after MapRequest")
nodeRouteChange = change.PolicyChange()
}
_, policyChange, err := s.persistNodeToDB(updatedNode)
if err != nil {
@@ -2704,34 +2701,3 @@ func peerChangeEmpty(peerChange tailcfg.PeerChange) bool {
peerChange.LastSeen == nil &&
peerChange.KeyExpiry == nil
}
// maybeUpdateNodeRoutes updates node routes if announced routes changed but approved routes didn't.
// This is needed because SubnetRoutes is the intersection of announced AND approved routes.
func (s *State) maybeUpdateNodeRoutes(
id types.NodeID,
node types.NodeView,
hostinfoChanged, needsRouteApproval, routeChange bool,
hostinfo *tailcfg.Hostinfo,
) change.Change {
// Only update if announced routes changed without approval change
if !hostinfoChanged || !needsRouteApproval || routeChange || hostinfo == nil {
return change.Change{}
}
log.Debug().
Caller().
Uint64(zf.NodeID, id.Uint64()).
Msg("updating routes because announced routes changed but approved routes did not")
// SetNodeRoutes sets the active/distributed routes using AllApprovedRoutes()
// which returns only the intersection of announced AND approved routes.
log.Debug().
Caller().
Uint64(zf.NodeID, id.Uint64()).
Strs(zf.RoutesAnnounced, util.PrefixesToString(node.AnnouncedRoutes())).
Strs(zf.ApprovedRoutes, util.PrefixesToString(node.ApprovedRoutes().AsSlice())).
Strs(zf.AllApprovedRoutes, util.PrefixesToString(node.AllApprovedRoutes())).
Msg("updating node routes for distribution")
return s.SetNodeRoutes(id, node.AllApprovedRoutes()...)
}
+22
View File
@@ -0,0 +1,22 @@
package types
import "net/netip"
// DebugRoutes is the JSON-shaped snapshot of the headscale primary
// route ledger exposed by the /debug/routes endpoint and consumed by
// the integration test harness. It used to live in hscontrol/routes,
// but the algorithm now runs inside hscontrol/state and that package
// must not be imported from integration code.
type DebugRoutes struct {
// AvailableRoutes maps node IDs to their advertised routes
// (intersection of announced and approved). Only nodes currently
// connected to headscale are listed.
AvailableRoutes map[NodeID][]netip.Prefix `json:"available_routes"`
// PrimaryRoutes maps route prefixes to the node currently elected
// primary for that prefix.
PrimaryRoutes map[string]NodeID `json:"primary_routes"`
// UnhealthyNodes lists nodes that have failed health probes.
UnhealthyNodes []NodeID `json:"unhealthy_nodes,omitempty"`
}
+10
View File
@@ -154,6 +154,16 @@ type Node struct {
DeletedAt *time.Time
IsOnline *bool `gorm:"-"`
// Unhealthy excludes the node from primary route election while
// online. Written by the HA prober. Runtime-only.
Unhealthy bool `gorm:"-"`
// SessionEpoch identifies a poll session. Connect bumps it; a
// Disconnect carrying a stale value is dropped, so a deferred
// disconnect from a previous session cannot overwrite a newer
// Connect. Runtime-only.
SessionEpoch uint64 `gorm:"-"`
}
type Nodes []*Node
+2
View File
@@ -106,6 +106,8 @@ var _NodeCloneNeedsRegeneration = Node(struct {
UpdatedAt time.Time
DeletedAt *time.Time
IsOnline *bool
Unhealthy bool
SessionEpoch uint64
}{})
// Clone makes a deep copy of PreAuthKey.
+12 -1
View File
@@ -258,7 +258,16 @@ func (v NodeView) DeletedAt() views.ValuePointer[time.Time] {
func (v NodeView) IsOnline() views.ValuePointer[bool] { return views.ValuePointerOf(v.ж.IsOnline) }
func (v NodeView) String() string { return v.ж.String() }
// Unhealthy excludes the node from primary route election while
// online. Written by the HA prober. Runtime-only.
func (v NodeView) Unhealthy() bool { return v.ж.Unhealthy }
// SessionEpoch identifies a poll session. Connect bumps it; a
// Disconnect carrying a stale value is dropped, so a deferred
// disconnect from a previous session cannot overwrite a newer
// Connect. Runtime-only.
func (v NodeView) SessionEpoch() uint64 { return v.ж.SessionEpoch }
func (v NodeView) String() string { return v.ж.String() }
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _NodeViewNeedsRegeneration = Node(struct {
@@ -285,6 +294,8 @@ var _NodeViewNeedsRegeneration = Node(struct {
UpdatedAt time.Time
DeletedAt *time.Time
IsOnline *bool
Unhealthy bool
SessionEpoch uint64
}{})
// View returns a read-only view of PreAuthKey.
+1 -2
View File
@@ -6,7 +6,6 @@ import (
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
"github.com/juanfont/headscale/hscontrol"
policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2"
"github.com/juanfont/headscale/hscontrol/routes"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/integration/hsic"
"github.com/ory/dockertest/v3"
@@ -43,7 +42,7 @@ type ControlServer interface {
GetIPInNetwork(network *dockertest.Network) string
SetPolicy(pol *policyv2.Policy) error
GetAllMapReponses() (map[types.NodeID][]tailcfg.MapResponse, error)
PrimaryRoutes() (*routes.DebugRoutes, error)
PrimaryRoutes() (*types.DebugRoutes, error)
DebugBatcher() (*hscontrol.DebugBatcherInfo, error)
DebugNodeStore() (map[types.NodeID]types.Node, error)
DebugFilter() ([]tailcfg.FilterRule, error)
+41
View File
@@ -91,6 +91,47 @@ func AddContainerToNetwork(
return nil
}
// DisconnectContainerFromNetwork removes the container from network at
// the docker daemon level. Mirrors a physical cable pull: the
// container's network interface for that network disappears and any
// in-flight TCP connections are left half-open, exactly the failure
// mode iptables-based simulations cannot reproduce.
func DisconnectContainerFromNetwork(
pool *dockertest.Pool,
network *dockertest.Network,
testContainer string,
) error {
containers, err := pool.Client.ListContainers(docker.ListContainersOptions{
All: true,
Filters: map[string][]string{
"name": {testContainer},
},
})
if err != nil {
return err
}
if len(containers) == 0 {
return fmt.Errorf("%w: %s", ErrContainerNotFound, testContainer)
}
return pool.Client.DisconnectNetwork(network.Network.ID, docker.NetworkConnectionOptions{
Container: containers[0].ID,
Force: true,
})
}
// ReconnectContainerToNetwork is the inverse of
// DisconnectContainerFromNetwork — re-attaches the container to the
// network so traffic can flow again.
func ReconnectContainerToNetwork(
pool *dockertest.Pool,
network *dockertest.Network,
testContainer string,
) error {
return AddContainerToNetwork(pool, network, testContainer)
}
// RandomFreeHostPort asks the kernel for a free open port that is ready to use.
// (from https://github.com/phayes/freeport)
func RandomFreeHostPort() (int, error) {
+2 -3
View File
@@ -27,7 +27,6 @@ import (
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
"github.com/juanfont/headscale/hscontrol"
policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2"
"github.com/juanfont/headscale/hscontrol/routes"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/juanfont/headscale/integration/dockertestutil"
@@ -1639,7 +1638,7 @@ func (t *HeadscaleInContainer) GetAllMapReponses() (map[types.NodeID][]tailcfg.M
}
// PrimaryRoutes fetches the primary routes from the debug endpoint.
func (t *HeadscaleInContainer) PrimaryRoutes() (*routes.DebugRoutes, error) {
func (t *HeadscaleInContainer) PrimaryRoutes() (*types.DebugRoutes, error) {
// Execute curl inside the container to access the debug endpoint locally
command := []string{
"curl", "-s", "-H", "Accept: application/json", "http://localhost:9090/debug/routes",
@@ -1650,7 +1649,7 @@ func (t *HeadscaleInContainer) PrimaryRoutes() (*routes.DebugRoutes, error) {
return nil, fmt.Errorf("fetching routes from debug endpoint: %w", err)
}
var debugRoutes routes.DebugRoutes
var debugRoutes types.DebugRoutes
if err := json.Unmarshal([]byte(result), &debugRoutes); err != nil { //nolint:noinlineerr
return nil, fmt.Errorf("decoding routes response: %w", err)
}
+691 -15
View File
@@ -17,7 +17,6 @@ import (
"github.com/google/go-cmp/cmp/cmpopts"
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2"
"github.com/juanfont/headscale/hscontrol/routes"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/juanfont/headscale/integration/hsic"
@@ -227,7 +226,7 @@ func TestHASubnetRouterFailover(t *testing.T) {
propagationTime := integrationutil.ScaledTimeout(60 * time.Second)
// Helper function to validate primary routes table state
validatePrimaryRoutes := func(t *testing.T, headscale ControlServer, expectedRoutes *routes.DebugRoutes, message string) {
validatePrimaryRoutes := func(t *testing.T, headscale ControlServer, expectedRoutes *types.DebugRoutes, message string) {
t.Helper()
assert.EventuallyWithT(t, func(c *assert.CollectT) {
primaryRoutesState, err := headscale.PrimaryRoutes()
@@ -383,7 +382,7 @@ func TestHASubnetRouterFailover(t *testing.T) {
}
// Validate primary routes table state - no routes approved yet
validatePrimaryRoutes(t, headscale, &routes.DebugRoutes{
validatePrimaryRoutes(t, headscale, &types.DebugRoutes{
AvailableRoutes: map[types.NodeID][]netip.Prefix{},
PrimaryRoutes: map[string]types.NodeID{}, // No primary routes yet
}, "Primary routes table should be empty (no approved routes yet)")
@@ -477,7 +476,7 @@ func TestHASubnetRouterFailover(t *testing.T) {
}, propagationTime, 200*time.Millisecond, "Verifying traceroute goes through router 1")
// Validate primary routes table state - router 1 is primary
validatePrimaryRoutes(t, headscale, &routes.DebugRoutes{
validatePrimaryRoutes(t, headscale, &types.DebugRoutes{
AvailableRoutes: map[types.NodeID][]netip.Prefix{
types.NodeID(MustFindNode(subRouter1.Hostname(), nodes).GetId()): {pref},
// Note: Router 2 and 3 are available but not approved
@@ -557,7 +556,7 @@ func TestHASubnetRouterFailover(t *testing.T) {
}, propagationTime, 200*time.Millisecond, "Verifying Router 1 remains PRIMARY after Router 2 approval")
// Validate primary routes table state - router 1 still primary, router 2 approved but standby
validatePrimaryRoutes(t, headscale, &routes.DebugRoutes{
validatePrimaryRoutes(t, headscale, &types.DebugRoutes{
AvailableRoutes: map[types.NodeID][]netip.Prefix{
types.NodeID(MustFindNode(subRouter1.Hostname(), nodes).GetId()): {pref},
types.NodeID(MustFindNode(subRouter2.Hostname(), nodes).GetId()): {pref},
@@ -594,7 +593,7 @@ func TestHASubnetRouterFailover(t *testing.T) {
}, propagationTime, 200*time.Millisecond, "Verifying traceroute still goes through router 1 in HA mode")
// Validate primary routes table state - router 1 primary, router 2 approved (standby)
validatePrimaryRoutes(t, headscale, &routes.DebugRoutes{
validatePrimaryRoutes(t, headscale, &types.DebugRoutes{
AvailableRoutes: map[types.NodeID][]netip.Prefix{
types.NodeID(MustFindNode(subRouter1.Hostname(), nodes).GetId()): {pref},
types.NodeID(MustFindNode(subRouter2.Hostname(), nodes).GetId()): {pref},
@@ -704,7 +703,7 @@ func TestHASubnetRouterFailover(t *testing.T) {
}, propagationTime, 200*time.Millisecond, "Verifying traffic still flows through PRIMARY router 1 with full HA setup active")
// Validate primary routes table state - all 3 routers approved, router 1 still primary
validatePrimaryRoutes(t, headscale, &routes.DebugRoutes{
validatePrimaryRoutes(t, headscale, &types.DebugRoutes{
AvailableRoutes: map[types.NodeID][]netip.Prefix{
types.NodeID(MustFindNode(subRouter1.Hostname(), nodes).GetId()): {pref},
types.NodeID(MustFindNode(subRouter2.Hostname(), nodes).GetId()): {pref},
@@ -785,7 +784,7 @@ func TestHASubnetRouterFailover(t *testing.T) {
}, propagationTime, 200*time.Millisecond, "Verifying traceroute goes through router 2 after failover")
// Validate primary routes table state - router 2 is now primary after router 1 failure
validatePrimaryRoutes(t, headscale, &routes.DebugRoutes{
validatePrimaryRoutes(t, headscale, &types.DebugRoutes{
AvailableRoutes: map[types.NodeID][]netip.Prefix{
// Router 1 is disconnected, so not in AvailableRoutes
types.NodeID(MustFindNode(subRouter2.Hostname(), nodes).GetId()): {pref},
@@ -859,7 +858,7 @@ func TestHASubnetRouterFailover(t *testing.T) {
}, propagationTime, 200*time.Millisecond, "Verifying traceroute goes through router 3 after second failover")
// Validate primary routes table state - router 3 is now primary after router 2 failure
validatePrimaryRoutes(t, headscale, &routes.DebugRoutes{
validatePrimaryRoutes(t, headscale, &types.DebugRoutes{
AvailableRoutes: map[types.NodeID][]netip.Prefix{
// Routers 1 and 2 are disconnected, so not in AvailableRoutes
types.NodeID(MustFindNode(subRouter3.Hostname(), nodes).GetId()): {pref},
@@ -939,7 +938,7 @@ func TestHASubnetRouterFailover(t *testing.T) {
}, propagationTime, 200*time.Millisecond, "Verifying traceroute still goes through router 3 after router 1 recovery")
// Validate primary routes table state - router 3 remains primary after router 1 comes back
validatePrimaryRoutes(t, headscale, &routes.DebugRoutes{
validatePrimaryRoutes(t, headscale, &types.DebugRoutes{
AvailableRoutes: map[types.NodeID][]netip.Prefix{
types.NodeID(MustFindNode(subRouter1.Hostname(), nodes).GetId()): {pref},
// Router 2 is still disconnected
@@ -1022,7 +1021,7 @@ func TestHASubnetRouterFailover(t *testing.T) {
}, propagationTime, 200*time.Millisecond, "Verifying traceroute goes through router 3 after full recovery")
// Validate primary routes table state - router 3 remains primary after all routers back online
validatePrimaryRoutes(t, headscale, &routes.DebugRoutes{
validatePrimaryRoutes(t, headscale, &types.DebugRoutes{
AvailableRoutes: map[types.NodeID][]netip.Prefix{
types.NodeID(MustFindNode(subRouter1.Hostname(), nodes).GetId()): {pref},
types.NodeID(MustFindNode(subRouter2.Hostname(), nodes).GetId()): {pref},
@@ -1109,7 +1108,7 @@ func TestHASubnetRouterFailover(t *testing.T) {
}, propagationTime, 200*time.Millisecond, "Verifying traceroute goes through router 1 after route disable")
// Validate primary routes table state - router 1 is primary after router 3 route disabled
validatePrimaryRoutes(t, headscale, &routes.DebugRoutes{
validatePrimaryRoutes(t, headscale, &types.DebugRoutes{
AvailableRoutes: map[types.NodeID][]netip.Prefix{
types.NodeID(MustFindNode(subRouter1.Hostname(), nodes).GetId()): {pref},
types.NodeID(MustFindNode(subRouter2.Hostname(), nodes).GetId()): {pref},
@@ -1197,7 +1196,7 @@ func TestHASubnetRouterFailover(t *testing.T) {
}, propagationTime, 200*time.Millisecond, "Verifying traceroute goes through router 2 after second route disable")
// Validate primary routes table state - router 2 is primary after router 1 route disabled
validatePrimaryRoutes(t, headscale, &routes.DebugRoutes{
validatePrimaryRoutes(t, headscale, &types.DebugRoutes{
AvailableRoutes: map[types.NodeID][]netip.Prefix{
// Router 1's route is no longer approved, so not in AvailableRoutes
types.NodeID(MustFindNode(subRouter2.Hostname(), nodes).GetId()): {pref},
@@ -1284,7 +1283,7 @@ func TestHASubnetRouterFailover(t *testing.T) {
}, propagationTime, 200*time.Millisecond, "Verifying traceroute still goes through router 2 after route re-enable")
// Validate primary routes table state after router 1 re-approval
validatePrimaryRoutes(t, headscale, &routes.DebugRoutes{
validatePrimaryRoutes(t, headscale, &types.DebugRoutes{
AvailableRoutes: map[types.NodeID][]netip.Prefix{
types.NodeID(MustFindNode(subRouter1.Hostname(), nodes).GetId()): {pref},
types.NodeID(MustFindNode(subRouter2.Hostname(), nodes).GetId()): {pref},
@@ -1326,7 +1325,7 @@ func TestHASubnetRouterFailover(t *testing.T) {
}, propagationTime, 200*time.Millisecond, "Waiting for route state after router 3 re-approval")
// Validate primary routes table state after router 3 re-approval
validatePrimaryRoutes(t, headscale, &routes.DebugRoutes{
validatePrimaryRoutes(t, headscale, &types.DebugRoutes{
AvailableRoutes: map[types.NodeID][]netip.Prefix{
types.NodeID(MustFindNode(subRouter1.Hostname(), nodes).GetId()): {pref},
types.NodeID(MustFindNode(subRouter2.Hostname(), nodes).GetId()): {pref},
@@ -3733,3 +3732,680 @@ func TestHASubnetRouterPingFailover(t *testing.T) {
assertTracerouteViaIPWithCollect(c, tr, ip)
}, propagationTime, 200*time.Millisecond, "traceroute should still go through router 2 after recovery")
}
// TestHASubnetRouterFailoverBothOffline reproduces issue #3203:
// HA tracking loses the secondary subnet router after all routers serving
// the route have been offline simultaneously and one of them returns.
// See https://github.com/juanfont/headscale/issues/3203.
//
// Existing TestHASubnetRouterFailover keeps subRouter3 online across both
// failover steps, so the all-offline transition is uncovered. This test
// uses two routers and walks them both offline before bringing r2 back.
//
// Two assertion sets split the failure surface:
// - R1: server-side primary route table restores after reconnect.
// If R1 fails, the bug is in state.Connect / primaryRoutes.
// - R2: client's view shows r2 online with the route in PrimaryRoutes.
// If R1 passes and R2 fails, the bug is in change broadcast /
// mapBatcher / multiChannelNodeConn.
func TestHASubnetRouterFailoverBothOffline(t *testing.T) {
IntegrationSkip(t)
propagationTime := integrationutil.ScaledTimeout(60 * time.Second)
spec := ScenarioSpec{
NodesPerUser: 2,
Users: []string{"user1", "user2"},
Networks: map[string]NetworkSpec{
"usernet1": {Users: []string{"user1"}},
"usernet2": {Users: []string{"user2"}},
},
ExtraService: map[string][]extraServiceFunc{
"usernet1": {Webservice},
},
Versions: []string{"head"},
}
scenario, err := NewScenario(spec)
require.NoErrorf(t, err, "failed to create scenario: %s", err)
err = scenario.CreateHeadscaleEnv(
[]tsic.Option{tsic.WithAcceptRoutes()},
hsic.WithTestName("rt-haboth"),
)
requireNoErrHeadscaleEnv(t, err)
allClients, err := scenario.ListTailscaleClients()
requireNoErrListClients(t, err)
err = scenario.WaitForTailscaleSync()
requireNoErrSync(t, err)
headscale, err := scenario.Headscale()
requireNoErrGetHeadscale(t, err)
prefp, err := scenario.SubnetOfNetwork("usernet1")
require.NoError(t, err)
pref := *prefp
t.Logf("usernet1 prefix: %s", pref.String())
usernet1, err := scenario.Network("usernet1")
require.NoError(t, err)
services, err := scenario.Services("usernet1")
require.NoError(t, err)
require.Len(t, services, 1)
web := services[0]
webip := netip.MustParseAddr(web.GetIPInNetwork(usernet1))
weburl := fmt.Sprintf("http://%s/etc/hostname", webip)
sort.SliceStable(allClients, func(i, j int) bool {
return allClients[i].MustStatus().Self.ID < allClients[j].MustStatus().Self.ID
})
subRouter1 := allClients[0]
subRouter2 := allClients[1]
client := allClients[2]
t.Logf("Router 1: %s, Router 2: %s, Client: %s",
subRouter1.Hostname(), subRouter2.Hostname(), client.Hostname())
// Advertise the same route on both routers.
for _, r := range []TailscaleClient{subRouter1, subRouter2} {
_, _, err = r.Execute([]string{
"tailscale", "set", "--advertise-routes=" + pref.String(),
})
require.NoErrorf(t, err, "failed to advertise route on %s", r.Hostname())
}
err = scenario.WaitForTailscaleSync()
requireNoErrSync(t, err)
var nodes []*v1.Node
assert.EventuallyWithT(t, func(c *assert.CollectT) {
nodes, err = headscale.ListNodes()
assert.NoError(c, err)
assert.Len(c, nodes, 4)
}, propagationTime, 200*time.Millisecond, "nodes should be registered")
// Approve the route on both routers explicitly.
_, err = headscale.ApproveRoutes(
MustFindNode(subRouter1.Hostname(), nodes).GetId(),
[]netip.Prefix{pref},
)
require.NoError(t, err)
_, err = headscale.ApproveRoutes(
MustFindNode(subRouter2.Hostname(), nodes).GetId(),
[]netip.Prefix{pref},
)
require.NoError(t, err)
nodeID1 := types.NodeID(MustFindNode(subRouter1.Hostname(), nodes).GetId())
nodeID2 := types.NodeID(MustFindNode(subRouter2.Hostname(), nodes).GetId())
// Sanity: r1 starts as primary (lower NodeID).
assert.EventuallyWithT(t, func(c *assert.CollectT) {
pr, err := headscale.PrimaryRoutes()
assert.NoError(c, err)
assert.Equal(c, map[string]types.NodeID{
pref.String(): nodeID1,
}, pr.PrimaryRoutes, "router 1 should be primary initially")
}, propagationTime, 200*time.Millisecond, "waiting for HA setup")
// Confirm initial connectivity through r1.
assert.EventuallyWithT(t, func(c *assert.CollectT) {
result, err := client.Curl(weburl)
assert.NoError(c, err)
assert.Len(c, result, 13)
}, propagationTime, 200*time.Millisecond, "client reaches webservice via r1")
t.Log("=== Step 1: r1 goes offline. r2 should take over. ===")
require.NoError(t, subRouter1.Down())
assert.EventuallyWithT(t, func(c *assert.CollectT) {
pr, err := headscale.PrimaryRoutes()
assert.NoError(c, err)
assert.Equal(c, map[string]types.NodeID{
pref.String(): nodeID2,
}, pr.PrimaryRoutes, "r2 should be primary after r1 offline")
}, propagationTime, 500*time.Millisecond, "waiting for failover to r2")
t.Log("=== Step 2: r2 also goes offline. No primary should remain. ===")
require.NoError(t, subRouter2.Down())
assert.EventuallyWithT(t, func(c *assert.CollectT) {
pr, err := headscale.PrimaryRoutes()
assert.NoError(c, err)
assert.Empty(c, pr.PrimaryRoutes,
"no primary should be assigned while both routers are offline")
}, propagationTime, 500*time.Millisecond, "waiting for both routers to be offline")
t.Log("=== Step 3: r2 returns. ===")
t.Log(" R1: server-side primary route state must restore r2 as primary.")
t.Log(" R2: client must observe r2 online with the route in PrimaryRoutes.")
require.NoError(t, subRouter2.Up())
// R1 — server side.
assert.EventuallyWithT(t, func(c *assert.CollectT) {
pr, err := headscale.PrimaryRoutes()
assert.NoError(c, err)
assert.Equal(c, map[string]types.NodeID{
pref.String(): nodeID2,
}, pr.PrimaryRoutes,
"R1: r2 should be re-registered as primary after reconnect — issue #3203")
}, propagationTime, 500*time.Millisecond, "R1: waiting for server-side primary restore")
// R2 — client view.
assert.EventuallyWithT(t, func(c *assert.CollectT) {
clientStatus, err := client.Status()
assert.NoError(c, err)
srs2 := subRouter2.MustStatus()
peer := clientStatus.Peer[srs2.Self.PublicKey]
if !assert.NotNil(c, peer, "r2 peer should be in client status") {
return
}
assert.True(c, peer.Online,
"R2: client should see r2 online after reconnect — issue #3203")
if assert.NotNil(c, peer.PrimaryRoutes,
"R2: r2 should have PrimaryRoutes set in client status") {
assert.Contains(c, peer.PrimaryRoutes.AsSlice(), pref,
"R2: client's view of r2 should include the route as primary")
}
requirePeerSubnetRoutesWithCollect(c, peer, []netip.Prefix{pref})
}, propagationTime, 500*time.Millisecond, "R2: waiting for client to see r2 with primary route")
// End-to-end traffic should reach the webservice via r2.
assert.EventuallyWithT(t, func(c *assert.CollectT) {
result, err := client.Curl(weburl)
assert.NoError(c, err)
assert.Len(c, result, 13)
}, propagationTime, 200*time.Millisecond, "client reaches webservice via r2 after recovery")
assert.EventuallyWithT(t, func(c *assert.CollectT) {
tr, err := client.Traceroute(webip)
assert.NoError(c, err)
ip, err := subRouter2.IPv4()
if !assert.NoError(c, err, "failed to get IPv4 for r2") {
return
}
assertTracerouteViaIPWithCollect(c, tr, ip)
}, propagationTime, 200*time.Millisecond, "traceroute should go through r2 after recovery")
}
// TestHASubnetRouterFailoverBothOfflineCablePull is a stricter variant of
// TestHASubnetRouterFailoverBothOffline that simulates a cable pull rather
// than a graceful tailscale down. The two differ in what the server sees:
//
// - tailscale down: poll connection closes cleanly; defer fires
// immediately; grace period starts and ends predictably.
// - cable pull: server's noise long-poll is wedged in a half-open TCP
// connection until kernel keepalives time out (often >60 s). When
// the cable returns, two server-side longpoll sessions can overlap.
//
// Issue #3203 reports the bug after cable pulls; this variant blocks all
// traffic between the router container and headscale via iptables and then
// removes the block to mimic that behaviour.
func TestHASubnetRouterFailoverBothOfflineCablePull(t *testing.T) {
IntegrationSkip(t)
propagationTime := integrationutil.ScaledTimeout(120 * time.Second)
spec := ScenarioSpec{
NodesPerUser: 2,
Users: []string{"user1", "user2"},
Networks: map[string]NetworkSpec{
"usernet1": {Users: []string{"user1"}},
"usernet2": {Users: []string{"user2"}},
},
ExtraService: map[string][]extraServiceFunc{
"usernet1": {Webservice},
},
Versions: []string{"head"},
}
scenario, err := NewScenario(spec)
require.NoErrorf(t, err, "failed to create scenario: %s", err)
err = scenario.CreateHeadscaleEnv(
[]tsic.Option{
tsic.WithAcceptRoutes(),
tsic.WithPackages("iptables"),
},
hsic.WithTestName("rt-hacable"),
)
requireNoErrHeadscaleEnv(t, err)
allClients, err := scenario.ListTailscaleClients()
requireNoErrListClients(t, err)
err = scenario.WaitForTailscaleSync()
requireNoErrSync(t, err)
headscale, err := scenario.Headscale()
requireNoErrGetHeadscale(t, err)
prefp, err := scenario.SubnetOfNetwork("usernet1")
require.NoError(t, err)
pref := *prefp
usernet1, err := scenario.Network("usernet1")
require.NoError(t, err)
services, err := scenario.Services("usernet1")
require.NoError(t, err)
require.Len(t, services, 1)
web := services[0]
webip := netip.MustParseAddr(web.GetIPInNetwork(usernet1))
weburl := fmt.Sprintf("http://%s/etc/hostname", webip)
sort.SliceStable(allClients, func(i, j int) bool {
return allClients[i].MustStatus().Self.ID < allClients[j].MustStatus().Self.ID
})
subRouter1 := allClients[0]
subRouter2 := allClients[1]
client := allClients[2]
for _, r := range []TailscaleClient{subRouter1, subRouter2} {
_, _, err = r.Execute([]string{
"tailscale", "set", "--advertise-routes=" + pref.String(),
})
require.NoErrorf(t, err, "advertise route on %s", r.Hostname())
}
err = scenario.WaitForTailscaleSync()
requireNoErrSync(t, err)
var nodes []*v1.Node
assert.EventuallyWithT(t, func(c *assert.CollectT) {
nodes, err = headscale.ListNodes()
assert.NoError(c, err)
assert.Len(c, nodes, 4)
}, propagationTime, 200*time.Millisecond, "nodes registered")
_, err = headscale.ApproveRoutes(
MustFindNode(subRouter1.Hostname(), nodes).GetId(),
[]netip.Prefix{pref},
)
require.NoError(t, err)
_, err = headscale.ApproveRoutes(
MustFindNode(subRouter2.Hostname(), nodes).GetId(),
[]netip.Prefix{pref},
)
require.NoError(t, err)
nodeID2 := types.NodeID(MustFindNode(subRouter2.Hostname(), nodes).GetId())
// Sanity: r1 starts as primary.
assert.EventuallyWithT(t, func(c *assert.CollectT) {
pr, err := headscale.PrimaryRoutes()
assert.NoError(c, err)
assert.NotEmpty(c, pr.PrimaryRoutes, "a primary should exist")
}, propagationTime, 200*time.Millisecond, "HA setup")
hsIP := headscale.GetIPInNetwork(usernet1)
// "Cable pull" — drop all traffic in BOTH directions to/from headscale.
// Unlike the NEW-state-only filter used by TestHASubnetRouterPingFailover,
// this also breaks the existing ESTABLISHED long-poll, mimicking a
// physically severed link.
cablePull := func(r TailscaleClient) {
t.Helper()
for _, chain := range []string{"OUTPUT", "INPUT"} {
_, _, err := r.Execute([]string{
"iptables", "-A", chain,
"-d", hsIP, "-j", "DROP",
})
require.NoErrorf(t, err, "iptables -A %s on %s", chain, r.Hostname())
_, _, err = r.Execute([]string{
"iptables", "-A", chain,
"-s", hsIP, "-j", "DROP",
})
require.NoErrorf(t, err, "iptables -A %s -s on %s", chain, r.Hostname())
}
}
cableReplug := func(r TailscaleClient) {
t.Helper()
for _, chain := range []string{"OUTPUT", "INPUT"} {
_, _, err := r.Execute([]string{
"iptables", "-D", chain,
"-d", hsIP, "-j", "DROP",
})
require.NoErrorf(t, err, "iptables -D %s on %s", chain, r.Hostname())
_, _, err = r.Execute([]string{
"iptables", "-D", chain,
"-s", hsIP, "-j", "DROP",
})
require.NoErrorf(t, err, "iptables -D %s -s on %s", chain, r.Hostname())
}
}
t.Log("=== Cable-pull r1. Server should eventually fail r1 over to r2. ===")
cablePull(subRouter1)
assert.EventuallyWithT(t, func(c *assert.CollectT) {
pr, err := headscale.PrimaryRoutes()
assert.NoError(c, err)
assert.Equal(c, map[string]types.NodeID{
pref.String(): nodeID2,
}, pr.PrimaryRoutes, "r2 should become primary after r1 cable pull")
}, propagationTime, 1*time.Second, "waiting for r2 promotion")
t.Log("=== Cable-pull r2 while r1 is still cable-pulled. ===")
cablePull(subRouter2)
// Some primary may transiently flip back to r1 (offline) here — see the
// user's "failover to n1 (offline)" observation in the issue. We do not
// assert on that intermediate state; we just assert recovery below.
t.Log("=== Reconnect r2 (cable plugged back in). ===")
cableReplug(subRouter2)
// R1 — server side primary table should restore r2 as primary.
assert.EventuallyWithT(t, func(c *assert.CollectT) {
pr, err := headscale.PrimaryRoutes()
assert.NoError(c, err)
assert.Equal(c, map[string]types.NodeID{
pref.String(): nodeID2,
}, pr.PrimaryRoutes,
"R1: r2 should be re-registered as primary after cable replug — issue #3203")
}, propagationTime, 1*time.Second, "R1: waiting for r2 to be primary again")
// R2 — client should observe r2 online with the route.
assert.EventuallyWithT(t, func(c *assert.CollectT) {
clientStatus, err := client.Status()
assert.NoError(c, err)
srs2 := subRouter2.MustStatus()
peer := clientStatus.Peer[srs2.Self.PublicKey]
if !assert.NotNil(c, peer) {
return
}
assert.True(c, peer.Online,
"R2: client should see r2 online — issue #3203")
if assert.NotNil(c, peer.PrimaryRoutes) {
assert.Contains(c, peer.PrimaryRoutes.AsSlice(), pref)
}
}, propagationTime, 1*time.Second, "R2: waiting for client to see r2")
assert.EventuallyWithT(t, func(c *assert.CollectT) {
result, err := client.Curl(weburl)
assert.NoError(c, err)
assert.Len(c, result, 13)
}, propagationTime, 1*time.Second, "client reaches webservice via r2 after recovery")
}
// TestHASubnetRouterFailoverDockerDisconnect drives a multi-phase
// up/down/up/down lifecycle of two HA subnet routers using real
// docker network disconnects — the same failure primitive nblock
// observed when pulling a Proxmox interface in issue #3203.
// iptables-based simulations cannot reproduce this because the
// container's kernel still owns the socket; only daemon-level
// disconnect leaves the long-poll TCP half-open at the peer.
//
// Phases:
// 1. r1 starts as primary (lowest NodeID).
// 2. r1 alone fails and recovers — failover to r2, then traffic
// resumes when r1 returns.
// 3. r2 alone fails and recovers — failover, then traffic resumes.
// 4. Sequential dual failure — the issue #3203 bug.
// 4a. r1 down → r2 promoted.
// 4b. r2 down → primary must NOT flap to offline r1.
// 4c. r2 up → r2 primary again, traffic resumes.
// 5. Simultaneous dual failure.
// 5a. r1 + r2 down → primary must NOT flap to offline r1.
// 5b. both up → primary stays r2, traffic resumes.
//
// The no-flap assertions in 4b and 5a are the regression barriers
// for #3203. Phases 2/3 are functional checks (failover works,
// traffic recovers) without strict identity assertions on the
// "return" leg, since `docker network disconnect` triggers bridge
// reconfiguration that can transiently affect probing of OTHER
// containers on the same network — a test-infrastructure quirk
// that does not occur with a real cable pull.
func TestHASubnetRouterFailoverDockerDisconnect(t *testing.T) {
IntegrationSkip(t)
propagationTime := integrationutil.ScaledTimeout(120 * time.Second)
flapWindow := integrationutil.ScaledTimeout(40 * time.Second)
spec := ScenarioSpec{
NodesPerUser: 2,
Users: []string{"user1", "user2"},
Networks: map[string]NetworkSpec{
"usernet1": {Users: []string{"user1"}},
"usernet2": {Users: []string{"user2"}},
},
ExtraService: map[string][]extraServiceFunc{
"usernet1": {Webservice},
},
Versions: []string{"head"},
}
scenario, err := NewScenario(spec)
require.NoErrorf(t, err, "failed to create scenario: %s", err)
err = scenario.CreateHeadscaleEnv(
[]tsic.Option{tsic.WithAcceptRoutes()},
hsic.WithTestName("rt-hadocker"),
)
requireNoErrHeadscaleEnv(t, err)
allClients, err := scenario.ListTailscaleClients()
requireNoErrListClients(t, err)
err = scenario.WaitForTailscaleSync()
requireNoErrSync(t, err)
headscale, err := scenario.Headscale()
requireNoErrGetHeadscale(t, err)
prefp, err := scenario.SubnetOfNetwork("usernet1")
require.NoError(t, err)
pref := *prefp
usernet1, err := scenario.Network("usernet1")
require.NoError(t, err)
services, err := scenario.Services("usernet1")
require.NoError(t, err)
require.Len(t, services, 1)
web := services[0]
webip := netip.MustParseAddr(web.GetIPInNetwork(usernet1))
weburl := fmt.Sprintf("http://%s/etc/hostname", webip)
sort.SliceStable(allClients, func(i, j int) bool {
return allClients[i].MustStatus().Self.ID < allClients[j].MustStatus().Self.ID
})
subRouter1 := allClients[0]
subRouter2 := allClients[1]
client := allClients[2]
for _, r := range []TailscaleClient{subRouter1, subRouter2} {
_, _, err = r.Execute([]string{
"tailscale", "set", "--advertise-routes=" + pref.String(),
})
require.NoErrorf(t, err, "advertise route on %s", r.Hostname())
}
err = scenario.WaitForTailscaleSync()
requireNoErrSync(t, err)
var nodes []*v1.Node
assert.EventuallyWithT(t, func(c *assert.CollectT) {
nodes, err = headscale.ListNodes()
assert.NoError(c, err)
assert.Len(c, nodes, 4)
}, propagationTime, 200*time.Millisecond, "nodes registered")
_, err = headscale.ApproveRoutes(
MustFindNode(subRouter1.Hostname(), nodes).GetId(),
[]netip.Prefix{pref},
)
require.NoError(t, err)
_, err = headscale.ApproveRoutes(
MustFindNode(subRouter2.Hostname(), nodes).GetId(),
[]netip.Prefix{pref},
)
require.NoError(t, err)
nodeID1 := types.NodeID(MustFindNode(subRouter1.Hostname(), nodes).GetId())
nodeID2 := types.NodeID(MustFindNode(subRouter2.Hostname(), nodes).GetId())
// requirePrimary blocks until headscale reports want as the
// primary advertiser for pref.
requirePrimary := func(want types.NodeID, msg string) {
t.Helper()
assert.EventuallyWithT(t, func(c *assert.CollectT) {
pr, err := headscale.PrimaryRoutes()
assert.NoError(c, err)
assert.Equal(c, map[string]types.NodeID{
pref.String(): want,
}, pr.PrimaryRoutes, msg)
}, propagationTime, 1*time.Second, msg)
}
// requireTrafficWorks asserts the client can reach the webservice
// across the tailnet (i.e. via whichever router is primary).
requireTrafficWorks := func(msg string) {
t.Helper()
assert.EventuallyWithT(t, func(c *assert.CollectT) {
result, err := client.Curl(weburl)
assert.NoError(c, err)
assert.Len(c, result, 13)
}, propagationTime, 1*time.Second, msg)
}
// requirePrimaryStable asserts primary == want for the entire
// window. Catches transient flaps and verifies anti-flap on
// prev-primary return.
requirePrimaryStable := func(want types.NodeID, window time.Duration, msg string) {
t.Helper()
require.Never(t, func() bool {
pr, err := headscale.PrimaryRoutes()
if err != nil {
return false
}
owner, ok := pr.PrimaryRoutes[pref.String()]
return !ok || owner != want
}, window, 1*time.Second, msg)
}
// ============================================================
// Phase 1: initial state — r1 (lowest NodeID) is primary.
// ============================================================
t.Log("=== Phase 1: initial state — r1 should be primary. ===")
requirePrimary(nodeID1, "phase 1: r1 primary at start")
requireTrafficWorks("phase 1: client reaches webservice via r1")
// ============================================================
// Phase 2: r1 alone fails and returns. Failover to r2, traffic
// resumes; reconnect r1 and verify traffic still flows. We do
// not assert primary identity across the r1-return leg because
// docker bridge reconfiguration can transiently fail probes on
// r2 (real cable pulls do not have this side effect).
// ============================================================
t.Log("=== Phase 2a: cable-pull r1, expect failover to r2. ===")
require.NoError(t, subRouter1.DisconnectFromNetwork(usernet1),
"phase 2a: docker disconnect r1")
requirePrimary(nodeID2, "phase 2a: r2 promoted after r1 down")
requireTrafficWorks("phase 2a: client reaches webservice via r2")
t.Log("=== Phase 2b: reconnect r1, traffic should still flow. ===")
require.NoError(t, subRouter1.ReconnectToNetwork(usernet1),
"phase 2b: docker reconnect r1")
requireTrafficWorks("phase 2b: client still reaches webservice")
// ============================================================
// Phase 3: r2 alone fails and returns. Same caveats as phase 2
// on identity assertions during the return leg.
// ============================================================
t.Log("=== Phase 3a: cable-pull r2, traffic should fail over. ===")
require.NoError(t, subRouter2.DisconnectFromNetwork(usernet1),
"phase 3a: docker disconnect r2")
requireTrafficWorks("phase 3a: client reaches webservice via remaining router")
t.Log("=== Phase 3b: reconnect r2, traffic should still flow. ===")
require.NoError(t, subRouter2.ReconnectToNetwork(usernet1),
"phase 3b: docker reconnect r2")
requireTrafficWorks("phase 3b: client still reaches webservice")
// ============================================================
// Phase 4: sequential dual failure — the issue #3203 bug. The
// flap target is r1 because under cable-pull both routers
// linger as IsOnline=true (half-open TCP), both go Unhealthy,
// and electPrimaryRoutes' all-unhealthy fallback selects the
// lowest NodeID regardless of who was prev primary.
// ============================================================
t.Log("=== Phase 4a: cable-pull r1, expect failover to r2. ===")
require.NoError(t, subRouter1.DisconnectFromNetwork(usernet1),
"phase 4a: docker disconnect r1")
requirePrimary(nodeID2, "phase 4a: r2 promoted after r1 down")
t.Log("=== Phase 4b: cable-pull r2, primary must NOT flap to offline r1. ===")
require.NoError(t, subRouter2.DisconnectFromNetwork(usernet1),
"phase 4b: docker disconnect r2")
requirePrimaryStable(nodeID2, flapWindow,
"phase 4b: primary must not flap to offline r1 (issue #3203)")
t.Log("=== Phase 4c: reconnect r2, r2 should resume as primary. ===")
require.NoError(t, subRouter2.ReconnectToNetwork(usernet1),
"phase 4c: docker reconnect r2")
requirePrimary(nodeID2, "phase 4c: r2 primary after reconnect")
requireTrafficWorks("phase 4c: client reaches webservice via r2 after recovery")
t.Log("=== Phase 4d: reconnect r1, traffic should still flow. ===")
require.NoError(t, subRouter1.ReconnectToNetwork(usernet1),
"phase 4d: docker reconnect r1")
requireTrafficWorks("phase 4d: client still reaches webservice")
// ============================================================
// Phase 5: simultaneous dual failure (whole-segment outage).
// prev going in is r2 — the no-flap invariant must hold.
// ============================================================
t.Log("=== Phase 5a: cable-pull r1 and r2 simultaneously. ===")
require.NoError(t, subRouter1.DisconnectFromNetwork(usernet1),
"phase 5a: docker disconnect r1")
require.NoError(t, subRouter2.DisconnectFromNetwork(usernet1),
"phase 5a: docker disconnect r2")
requirePrimaryStable(nodeID2, flapWindow,
"phase 5a: primary must not flap to offline r1 (issue #3203)")
t.Log("=== Phase 5b: reconnect both, r2 should remain primary. ===")
require.NoError(t, subRouter1.ReconnectToNetwork(usernet1),
"phase 5b: docker reconnect r1")
require.NoError(t, subRouter2.ReconnectToNetwork(usernet1),
"phase 5b: docker reconnect r2")
requirePrimary(nodeID2, "phase 5b: r2 primary after both reconnect")
requireTrafficWorks("phase 5b: client reaches webservice via r2")
}
+2
View File
@@ -58,6 +58,8 @@ type TailscaleClient interface {
ReadFile(path string) ([]byte, error)
PacketFilter() ([]filter.Match, error)
ConnectToNetwork(network *dockertest.Network) error
DisconnectFromNetwork(network *dockertest.Network) error
ReconnectToNetwork(network *dockertest.Network) error
// FailingPeersAsString returns a formatted-ish multi-line-string of peers in the client
// and a bool indicating if the clients online count and peer count is equal.
+15
View File
@@ -807,6 +807,21 @@ func (t *TailscaleInContainer) Down() error {
return nil
}
// DisconnectFromNetwork detaches the container from network at the
// docker daemon level. The container's network interface for that
// network disappears and any in-flight TCP connection is left
// half-open at the peer — the same failure mode a real cable pull
// produces, which iptables-based simulations cannot reproduce.
func (t *TailscaleInContainer) DisconnectFromNetwork(network *dockertest.Network) error {
return dockertestutil.DisconnectContainerFromNetwork(t.pool, network, t.hostname)
}
// ReconnectToNetwork is the inverse of DisconnectFromNetwork: it
// re-attaches the container to network so traffic can flow again.
func (t *TailscaleInContainer) ReconnectToNetwork(network *dockertest.Network) error {
return dockertestutil.ReconnectContainerToNetwork(t.pool, network, t.hostname)
}
// IPs returns the netip.Addr of the Tailscale instance.
func (t *TailscaleInContainer) IPs() ([]netip.Addr, error) {
if len(t.ips) != 0 {