Skip to content

Commit 814b33d

Browse files
committed
state: log nodes with map-breaking data at startup
Scan a node-health check registry at boot and log each node whose name can't form a valid FQDN, with the rename fix. Log-only, no mutation. Updates #3346
1 parent 708b44a commit 814b33d

3 files changed

Lines changed: 168 additions & 2 deletions

File tree

hscontrol/state/node_health.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package state
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/juanfont/headscale/hscontrol/types"
7+
"github.com/juanfont/headscale/hscontrol/util/zlog/zf"
8+
"github.com/rs/zerolog/log"
9+
)
10+
11+
// nodeHealthCheck names a class of stored-node-data defect that breaks normal
12+
// operation and explains how to fix it. ok == true means the node passes the
13+
// check. This is the extension point for node-data validation: add a check
14+
// here as new corrupt-data classes surface (nil hostinfo, invalid IPs,
15+
// tags-XOR-user violations, ...) and both the boot scan and any future caller
16+
// run the whole set.
17+
type nodeHealthCheck struct {
18+
name string
19+
check func(nv types.NodeView, cfg *types.Config) (problem, fixHint string, ok bool)
20+
}
21+
22+
// nodeHealthChecks is the registry of node-data health checks. Today it carries
23+
// the one issue #3346 needs; append to it rather than reshaping callers.
24+
var nodeHealthChecks = []nodeHealthCheck{givenNameMapsToValidFQDN}
25+
26+
// givenNameMapsToValidFQDN flags a node whose stored GivenName cannot produce a
27+
// valid FQDN (empty, or longer than MaxHostnameLength once base_domain is
28+
// applied). Such a node cannot be rendered into a netmap — neither its own nor
29+
// any peer's — so it must be renamed to recover.
30+
var givenNameMapsToValidFQDN = nodeHealthCheck{
31+
name: "given-name-maps-to-valid-fqdn",
32+
check: func(nv types.NodeView, cfg *types.Config) (string, string, bool) {
33+
err := types.ValidateGivenName(nv.GivenName(), cfg.BaseDomain)
34+
if err != nil {
35+
return err.Error(), fmt.Sprintf("headscale nodes rename %d <name>", nv.ID()), false
36+
}
37+
38+
return "", "", true
39+
},
40+
}
41+
42+
// nodeHealthFinding is a single failed check for a single node.
43+
type nodeHealthFinding struct {
44+
nodeID types.NodeID
45+
hostname string
46+
check string
47+
problem string
48+
fixHint string
49+
}
50+
51+
// scanNodeHealth runs every registered check against every node in the store
52+
// and returns one finding per failure. It only reports — it never mutates a
53+
// node — so an operator can repair the underlying data without the server
54+
// silently rewriting a user-visible name.
55+
func (s *State) scanNodeHealth() []nodeHealthFinding {
56+
var findings []nodeHealthFinding
57+
58+
for _, nv := range s.nodeStore.ListNodes().All() {
59+
for _, c := range nodeHealthChecks {
60+
problem, fixHint, ok := c.check(nv, s.cfg)
61+
if ok {
62+
continue
63+
}
64+
65+
findings = append(findings, nodeHealthFinding{
66+
nodeID: nv.ID(),
67+
hostname: nv.Hostname(),
68+
check: c.name,
69+
problem: problem,
70+
fixHint: fixHint,
71+
})
72+
}
73+
}
74+
75+
return findings
76+
}
77+
78+
// logNodeHealth scans the store once and logs an actionable warning per
79+
// finding. Called at startup so an operator learns — by node id and fix
80+
// command — about stored data that will break map generation, without the
81+
// server changing anything itself.
82+
func (s *State) logNodeHealth() {
83+
for _, f := range s.scanNodeHealth() {
84+
log.Warn().
85+
Uint64(zf.NodeID, f.nodeID.Uint64()).
86+
Str(zf.NodeHostname, f.hostname).
87+
Str("check", f.check).
88+
Str("problem", f.problem).
89+
Str("fix", f.fixHint).
90+
Msg("node has invalid data that breaks map generation; rename it to restore connectivity")
91+
}
92+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package state
2+
3+
import (
4+
"testing"
5+
6+
"github.com/juanfont/headscale/hscontrol/db"
7+
"github.com/juanfont/headscale/hscontrol/types"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestGivenNameMapsToValidFQDNCheck(t *testing.T) {
12+
cfg := &types.Config{BaseDomain: "example.com"}
13+
14+
_, _, ok := givenNameMapsToValidFQDN.check((&types.Node{ID: 1, GivenName: "valid"}).View(), cfg)
15+
require.True(t, ok, "a valid given name must pass the check")
16+
17+
problem, fixHint, ok := givenNameMapsToValidFQDN.check((&types.Node{ID: 7, GivenName: ""}).View(), cfg)
18+
require.False(t, ok, "an empty given name must fail the check")
19+
require.NotEmpty(t, problem)
20+
require.Contains(t, fixHint, "rename 7", "fix hint must name the offending node")
21+
}
22+
23+
// TestScanNodeHealthReportsInvalidNameWithoutMutating proves the boot scan
24+
// reports a node whose stored name would break map generation (issue #3346)
25+
// with an actionable fix, and that it never rewrites the stored name — the
26+
// maintainer's decision is log-only, no silent mutation.
27+
func TestScanNodeHealthReportsInvalidNameWithoutMutating(t *testing.T) {
28+
dbPath := t.TempDir() + "/headscale.db"
29+
cfg := persistTestConfig(dbPath)
30+
31+
database, err := db.NewHeadscaleDatabase(cfg)
32+
require.NoError(t, err)
33+
34+
user := database.CreateUserForTest("scan-user")
35+
bad := database.CreateRegisteredNodeForTest(user, "scan-bad")
36+
good := database.CreateRegisteredNodeForTest(user, "scan-good")
37+
38+
require.NoError(t, database.DB.
39+
Model(&types.Node{}).
40+
Where("id = ?", bad.ID).
41+
Update("given_name", "").Error)
42+
require.NoError(t, database.Close())
43+
44+
s, err := NewState(cfg)
45+
require.NoError(t, err)
46+
t.Cleanup(func() { _ = s.Close() })
47+
48+
findings := s.scanNodeHealth()
49+
50+
var badFinding *nodeHealthFinding
51+
52+
for i := range findings {
53+
require.NotEqual(t, good.ID, findings[i].nodeID, "a valid node must not be reported")
54+
55+
if findings[i].nodeID == bad.ID {
56+
badFinding = &findings[i]
57+
}
58+
}
59+
60+
require.NotNil(t, badFinding, "a node with an invalid name must be reported")
61+
require.Contains(t, badFinding.fixHint, "rename", "finding must carry an actionable fix")
62+
63+
// Log-only: neither the scan nor boot may rewrite the stored name.
64+
nv, ok := s.GetNodeByID(bad.ID)
65+
require.True(t, ok)
66+
require.Empty(t, nv.GivenName(), "boot scan must not mutate the stored name")
67+
}

hscontrol/state/state.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ func NewState(cfg *types.Config) (*State, error) {
277277
)
278278
nodeStore.Start()
279279

280-
return &State{
280+
s := &State{
281281
cfg: cfg,
282282

283283
db: db,
@@ -289,7 +289,14 @@ func NewState(cfg *types.Config) (*State, error) {
289289

290290
sshCheckAuth: make(map[sshCheckPair]time.Time),
291291
registerLocks: xsync.NewMap[key.MachinePublic, *sync.Mutex](),
292-
}, nil
292+
}
293+
294+
// Surface nodes whose stored data would break map generation (e.g. an
295+
// invalid given name from a legacy row) so an operator can fix them. This
296+
// only logs; it never mutates a node's stored name at boot.
297+
s.logNodeHealth()
298+
299+
return s, nil
293300
}
294301

295302
// Close gracefully shuts down the [State] instance and releases all resources.

0 commit comments

Comments
 (0)