Skip to content

Commit 964ecd4

Browse files
committed
main: gate AdminForward on adminEnabled + close conn cache (review)
Four findings from Claude / Codex / CodeRabbit on PR #648: 1) **P1 / Major**: AdminForward gRPC registration was gated only on adminForwardServerDeps.readyForRegistration() (tables + roles both non-nil), neither of which referenced *adminEnabled. A node started with -adminEnabled=false but with admin access keys still configured would have registered the leader-side gRPC AdminForward service and accepted forwarded admin writes — re-exposing the table-mutation surface a follower-direct admin call could reach. Fix: build roleStore + connCache only when *adminEnabled. With admin disabled, forwardDeps.roles stays nil and registerAdminForwardServer's readyForRegistration gate skips the gRPC bind entirely. 2) **Resource leak (Claude Issue 1, CodeRabbit Minor)**: connCache was created in startServers() but never closed. Every address dialled accumulates an open HTTP/2 connection that is never released. Fix: shutdown goroutine that drains the cache via Close() on in.ctx.Done(), wired through the existing errgroup. 3) **Lint hygiene (Claude Issue 2)**: //nolint:nilnil suppresses a linter that is not enabled in .golangci.yaml AND violates CLAUDE.md's "Avoid //nolint — refactor instead" rule. The directive was pure dead weight. Fix: drop the directive; the doc comment on the function and the inline comment already explain the nil-means-disabled contract. 4) **Coverage gap (Claude Issue 3)**: buildAdminLeaderForwarder's nil-coordinate / nil-cache short-circuit was untested. A future refactor that drops the guard would silently pass a nil collaborator into buildLeaderForwarder, which would either crash on the nil deref or build a forwarder that panics on the first request. Fix: TestBuildAdminLeaderForwarder_NilGateReturnsNoForwarder covers all three nil combinations + the empty-nodeID error path, plus a happy-path companion.
1 parent 19f6b70 commit 964ecd4

3 files changed

Lines changed: 94 additions & 13 deletions

File tree

main.go

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -654,18 +654,40 @@ func startServers(in serversInput) error {
654654
if err != nil {
655655
return err
656656
}
657-
// roleStore is parsed from flags up-front so the leader-side gRPC
658-
// AdminForward registration in startRaftServers (which runs from
659-
// runner.start() below, before startAdminFromFlags has a chance
660-
// to parse the admin config) can re-use the same access-key map
661-
// the HTTP path will eventually populate.
662-
roleStore := roleStoreFromFlags(parseCSV(*adminFullAccessKeys), parseCSV(*adminReadOnlyAccessKeys))
663-
// connCache is shared between the follower-side LeaderForwarder
664-
// (built inside startAdminFromFlags) and any future bridge that
665-
// dials the leader's gRPC ports. Keeping a single instance per
666-
// process means the two paths re-use TLS / HTTP/2 connections
667-
// rather than each maintaining a parallel pool.
668-
connCache := &kv.GRPCConnCache{}
657+
// roleStore + connCache are gated on *adminEnabled. With admin
658+
// disabled, building either is wasted work AND a security
659+
// regression risk: a non-empty -adminFullAccessKeys flag would
660+
// otherwise still flip forwardDeps.readyForRegistration() to
661+
// true, registering the leader-side gRPC AdminForward service
662+
// and re-exposing the table-write surface a follower-direct
663+
// admin call could reach (Codex P1, CodeRabbit Major on #648).
664+
// The HTTP admin listener already short-circuits in
665+
// startAdminFromFlags when *adminEnabled is false; the gRPC path
666+
// must do the same.
667+
var (
668+
roleStore admin.RoleStore
669+
connCache *kv.GRPCConnCache
670+
)
671+
if *adminEnabled {
672+
roleStore = roleStoreFromFlags(parseCSV(*adminFullAccessKeys), parseCSV(*adminReadOnlyAccessKeys))
673+
// connCache is shared between the follower-side LeaderForwarder
674+
// (built inside startAdminFromFlags) and any future bridge that
675+
// dials the leader's gRPC ports. Keeping a single instance per
676+
// process means the two paths re-use TLS / HTTP/2 connections
677+
// rather than each maintaining a parallel pool. The shutdown
678+
// goroutine drains the cache on context cancellation so the
679+
// accumulated HTTP/2 connections are not leaked when the
680+
// process exits gracefully (Claude review on #648).
681+
connCache = &kv.GRPCConnCache{}
682+
cache := connCache
683+
in.eg.Go(func() error {
684+
<-in.ctx.Done()
685+
if err := cache.Close(); err != nil {
686+
return errors.Wrap(err, "close admin gRPC connection cache")
687+
}
688+
return nil
689+
})
690+
}
669691
runner := runtimeServerRunner{
670692
ctx: in.ctx,
671693
lc: in.lc,

main_admin.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,13 @@ func startAdminFromFlags(
134134
// only builds; that's handled higher up by ServerDeps.Tables == nil.
135135
func buildAdminLeaderForwarder(coordinate kv.Coordinator, connCache *kv.GRPCConnCache, nodeID string) (admin.LeaderForwarder, error) {
136136
if coordinate == nil || connCache == nil {
137-
return nil, nil //nolint:nilnil // explicit "no forwarder" signal — the handler falls back to 503 + Retry-After:1.
137+
// Returning (nil, nil) is the explicit "no forwarder" signal
138+
// — the handler falls back to 503 + Retry-After:1 on
139+
// ErrTablesNotLeader. The function-level doc comment above
140+
// describes this contract; the nilnil linter is not enabled
141+
// in .golangci.yaml so no suppression directive is needed
142+
// (Claude review on #648).
143+
return nil, nil
138144
}
139145
if nodeID == "" {
140146
// admin.NewGRPCForwardClient enforces this too; surfacing

main_admin_forward_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,59 @@ func TestAdminForwardServerDeps_ReadyForRegistration(t *testing.T) {
124124
}.readyForRegistration())
125125
}
126126

127+
func TestBuildAdminLeaderForwarder_NilGateReturnsNoForwarder(t *testing.T) {
128+
// buildAdminLeaderForwarder is the wrapper in main_admin.go that
129+
// short-circuits to (nil, nil) when either coordinate or
130+
// connCache is nil — the explicit "no forwarder" path for
131+
// single-node / leader-only deployments. A future refactor that
132+
// drops the guard would silently pass a nil collaborator into
133+
// buildLeaderForwarder, which would either crash on the nil
134+
// resolver / cache deref or build a forwarder that panics on
135+
// the first request. Locking this down keeps the contract intact
136+
// (Claude review on #648).
137+
cases := []struct {
138+
name string
139+
coord kv.Coordinator
140+
cache *kv.GRPCConnCache
141+
nodeID string
142+
wantNil bool
143+
wantError string
144+
}{
145+
{name: "nil coordinator", cache: &kv.GRPCConnCache{}, nodeID: "n1", wantNil: true},
146+
{name: "nil conn cache", coord: &kv.Coordinate{}, nodeID: "n1", wantNil: true},
147+
{name: "both nil", nodeID: "n1", wantNil: true},
148+
{
149+
name: "complete deps but empty node id",
150+
coord: &kv.Coordinate{},
151+
cache: &kv.GRPCConnCache{},
152+
wantError: "--raftId is required",
153+
},
154+
}
155+
for _, tc := range cases {
156+
t.Run(tc.name, func(t *testing.T) {
157+
fwd, err := buildAdminLeaderForwarder(tc.coord, tc.cache, tc.nodeID)
158+
if tc.wantError != "" {
159+
require.Error(t, err)
160+
require.Contains(t, err.Error(), tc.wantError)
161+
require.Nil(t, fwd)
162+
return
163+
}
164+
require.NoError(t, err)
165+
if tc.wantNil {
166+
require.Nil(t, fwd)
167+
} else {
168+
require.NotNil(t, fwd)
169+
}
170+
})
171+
}
172+
}
173+
174+
func TestBuildAdminLeaderForwarder_HappyPathReturnsForwarder(t *testing.T) {
175+
fwd, err := buildAdminLeaderForwarder(&kv.Coordinate{}, &kv.GRPCConnCache{}, "n1")
176+
require.NoError(t, err)
177+
require.NotNil(t, fwd)
178+
}
179+
127180
// dummyTablesSource is the smallest concrete admin.TablesSource for
128181
// the readyForRegistration gate test — no method body needs to
129182
// execute, so every method just panics. Using a real implementation

0 commit comments

Comments
 (0)