state: add HA health prober

Ping HA subnet routers each probe cycle and mark unresponsive nodes
unhealthy. Reconnecting a node clears its unhealthy state since the
fresh Noise session proves basic connectivity.

Updates #2129
Updates #2902
This commit is contained in:
Kristoffer Dalby 2026-04-15 13:41:09 +00:00
parent 786ce2dce8
commit 90e65ccd63
3 changed files with 203 additions and 0 deletions

View file

@ -60,6 +60,22 @@ type EphemeralConfig struct {
InactivityTimeout time.Duration
}
// HARouteConfig contains configuration for HA subnet router health probing.
type HARouteConfig struct {
// ProbeInterval is how often HA subnet routers are probed.
// A zero or negative duration disables probing.
ProbeInterval time.Duration
// ProbeTimeout is the maximum time to wait for a probe response
// before declaring a node unhealthy. Must be less than ProbeInterval.
ProbeTimeout time.Duration
}
// RouteConfig contains configuration for route behaviour.
type RouteConfig struct {
HA HARouteConfig
}
// NodeConfig contains configuration for node lifecycle and expiry.
type NodeConfig struct {
// Expiry is the default key expiry duration for non-tagged nodes.
@ -70,6 +86,9 @@ type NodeConfig struct {
// Ephemeral contains configuration for ephemeral node lifecycle.
Ephemeral EphemeralConfig
// Routes contains configuration for route behaviour.
Routes RouteConfig
}
// Config contains the initial Headscale configuration.
@ -414,6 +433,8 @@ func LoadConfig(path string, isFile bool) error {
viper.SetDefault("node.expiry", "0")
viper.SetDefault("node.ephemeral.inactivity_timeout", "120s")
viper.SetDefault("node.routes.ha.probe_interval", "10s")
viper.SetDefault("node.routes.ha.probe_timeout", "5s")
viper.SetDefault("tuning.notifier_send_timeout", "800ms")
viper.SetDefault("tuning.batch_change_delay", "800ms")
@ -576,6 +597,34 @@ func validateServerConfig() error {
}
}
// Validate HA health probing parameters
if haInterval := viper.GetDuration(
"node.routes.ha.probe_interval",
); haInterval > 0 {
if haInterval < 2*time.Second {
errorText += fmt.Sprintf(
"Fatal config error: node.routes.ha.probe_interval (%s) must be >= 2s\n",
haInterval,
)
}
haTimeout := viper.GetDuration("node.routes.ha.probe_timeout")
if haTimeout < 1*time.Second {
errorText += fmt.Sprintf(
"Fatal config error: node.routes.ha.probe_timeout (%s) must be >= 1s\n",
haTimeout,
)
}
if haTimeout >= haInterval {
errorText += fmt.Sprintf(
"Fatal config error: node.routes.ha.probe_timeout (%s) must be less than node.routes.ha.probe_interval (%s)\n",
haTimeout,
haInterval,
)
}
}
// Validate tuning parameters
if size := viper.GetInt("tuning.node_store_batch_size"); size <= 0 {
errorText += fmt.Sprintf(
@ -1129,6 +1178,12 @@ func LoadServerConfig() (*Config, error) {
Ephemeral: EphemeralConfig{
InactivityTimeout: resolveEphemeralInactivityTimeout(),
},
Routes: RouteConfig{
HA: HARouteConfig{
ProbeInterval: viper.GetDuration("node.routes.ha.probe_interval"),
ProbeTimeout: viper.GetDuration("node.routes.ha.probe_timeout"),
},
},
},
Database: databaseConfig(),