mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-18 22:10:44 +09:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9bc43bf324 | |||
| c1ecc562d2 | |||
| 06ac9bdf77 | |||
| 4ab4fbd4b9 | |||
| 5228818a12 | |||
| 2d04fff605 | |||
| 436aa298b7 | |||
| 5dd622a31b | |||
| c68e763e62 | |||
| 3feb307a12 | |||
| 6baee3e6c3 | |||
| 4320aa1348 | |||
| 7b080e8cfa | |||
| 3eefe3c2cc |
@@ -250,6 +250,9 @@ jobs:
|
||||
- TestSubnetRouteACLFiltering
|
||||
- TestGrantViaSubnetSteering
|
||||
- TestHASubnetRouterPingFailover
|
||||
- TestHASubnetRouterFailoverBothOffline
|
||||
- TestHASubnetRouterFailoverBothOfflineCablePull
|
||||
- TestHASubnetRouterFailoverDockerDisconnect
|
||||
- TestHeadscale
|
||||
- TestTailscaleNodesJoiningHeadcale
|
||||
- TestSSHOneUserToAll
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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().
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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()...)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user