Skip to content

Commit 265a26a

Browse files
sarg3ntclaude
andauthored
feat(rotation): Phase 1 — multi-key keyring plumbing (#72) (#128)
* feat(rotation): Phase 1 multi-key keyring plumbing (#72) Foundation for issue #72's rotation work. Adds the data structures and storage required for N-entry keyrings on both the agent and dashboard sides, with no operator-visible behaviour change yet — rotation endpoints and UI follow in Phase 2. Agent side ---------- - `internal/framework/crypto/keyring.go` — `KeyRing` type with up to `MaxKeyRingEntries = 4` accepted keys, atomic tmpfile+rename on disk, AES-256-GCM (GBE1) encryption when `GEARBOX_AGENT_ENCRYPTION_KEY` is set. Wire token format: `gbx_<6-hex-kid>_<base64url(32 random bytes)>`, with legacy 64-hex tokens still accepted for one release cycle. - `LoadOrCreateKeyRing(keyringPath, legacyAPIKeyPath)` migrates an existing `/var/lib/gearbox-agent/api-key` file into a single keyring entry tagged `kid="legacy"`, role=primary. Legacy file stays on disk as a read-only fallback. - `KeyRingPointer` wraps `atomic.Pointer[KeyRing]` so Phase 2's install/use/remove endpoints can swap the live keyring without middleware restart. Verified by the new auth-middleware test `TestAPIKeyAuth_HotSwapVisibleImmediately`. - `internal/framework/middleware/auth.go` rewritten to take a keyring pointer instead of a static key. Accepts both prefixed and legacy token formats; matched `kid` echoed back as `X-Gearbox-Kid:` header on every authenticated response so the dashboard can detect drift (consumed in Phase 5). Auth with a secondary key logs at INFO so the audit log can later flag "old key still in use after rotation". - New endpoint `GET /api/v1/system/keyring` (authenticated) returns metadata only — kids, roles, created_at, sha256-prefix fingerprint for diagnostic equality checks — never the secret bytes themselves. - `--show-api-key` and `--rotate-api-key` CLI flags work against the keyring; the printed key uses the new `gbx_<kid>_<b64>` wire format the dashboard can paste verbatim. - `GEARBOX_AGENT_KEYRING_PATH` env var (default `<DataDir>/keyring.json`) is now a config field alongside the legacy `HAPROXY_AGENT_API_KEY_PATH`. Dashboard side -------------- - Migration `000002_add_box_agent_keys` adds the `(box_id, kid)`-keyed `box_agent_keys` table and idempotently backfills one `kid='legacy'` row per existing box from `boxes.api_key_encrypted`. The legacy column stays for one release. - `database/box_agent_keys.go` exposes Get/Insert/SetPrimary/Delete/ TouchLastUsed — the storage primitives Phase 2's rotator service composes into the install→use→remove dance. Tests ----- - 19 keyring unit tests covering token parsing (prefixed + legacy + malformed), keyring mutation, file round-trip with and without encryption, legacy api-key migration, and pointer hot-swap. - 8 auth-middleware integration tests covering bearer parsing, kid header echo, secondary-key acceptance, and the live hot-swap path Phase 2 depends on. - 5 storage tests covering primary-key lookup, atomic role flip, delete-refuses-last guard, and last_used_at touch. Carry-overs to Phase 2 (intentional gaps surfaced from this PR) --------------------------------------------------------------- - `DeleteBox` does not yet cascade to `box_agent_keys` (SQLite `PRAGMA foreign_keys` is off in this codebase; enabling it is a broader change). Phase 2's box-delete path will clean dependent rows explicitly. Documented in box_agent_keys_test.go. - The dashboard's `agent.Client` does not yet send `X-Gearbox-Kid` on outbound requests — there's no kid to send while every box's keyring contains only the legacy entry. Phase 2 wires this when the rotator starts mutating keyrings. Refs: research summary and implementation plan posted to #72. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(rotation): address Copilot review on PR #128 Nine findings from the Copilot review on PR #128, all valid or worth addressing. Fixed in this commit; replies + thread-resolves go with the push. 1. LoadOrCreateKeyRing fall-through (keyring.go:131-167) Was: any error reading the legacy api-key file (incl. ErrKeyRequired from a missing encryption-key env, or a permission error) silently fell through to generating a fresh keyring — would rotate every dashboard out for a transient operator mistake. Now: distinguish "file doesn't exist" (proceed to fresh-gen) from "file exists but errored / malformed" (return the error to the caller). os.Stat + os.IsNotExist gates the choice explicitly. 2. MatchToken constant-time guarantee (keyring.go ~195) Was: the prefixed-token path returned early on the first kid match, making total runtime depend on which kid the request claimed — kid enumeration via timing. The doc said "All comparisons are constant-time" but the prefixed branch broke that promise. Now: walk every entry, compare both kid and secret with subtle.ConstantTimeCompare, AND the two results. Match is recorded without short-circuit; runtime is uniform regardless of which kid (if any) matches. Doc updated to reflect the actual guarantee. 3. writeKeyRingFile mutates input (keyring.go ~415) Was: the function populated SecretHex on each entry of the passed- in keyring before marshaling. KeyRing values are shared via atomic.Pointer and treated as immutable; mutating in-place risks races with concurrent middleware readers. Now: marshal off a local snapshot whose entries have SecretHex backfilled from Secret where needed. Input is never written to. 4. --rotate-api-key zero CreatedAt (main.go ~155) Was: the fresh KeyRingEntry built for the CLI rotate command omitted CreatedAt, so the keyring file got 0001-01-01T00:00:00Z and the /api/v1/system/keyring metadata exposed the same. Now: CreatedAt: time.Now().UTC(). 5. handleGet nil-guard (api/keyring.go ~50) Was: h.keyring.Load() was dereferenced unconditionally; a future wiring bug that left the pointer nil would panic the agent on every keyring request. Now: nil check + 500 + log line. Fails loud rather than crashing. 6. At-most-one-primary-per-box constraint (migration 000002) Was: nothing in the schema stopped two rows with role='primary' for the same box. SetBoxPrimaryKey's transactional flip is correct, but a buggy code path or a manual DB edit could produce the invalid state and GetBoxPrimaryKey would return an arbitrary row. Now: partial unique index on box_agent_keys(box_id) WHERE role='primary'. SQLite supports this directly; index is dropped in the down migration too. 7. Test naming clarity (box_agent_keys_test.go) Was: TestBoxAgentKeys_MigrationBackfillsLegacyEntry was named as if it validated migration behaviour but actually only exercised InsertBoxAgentKey + GetBoxPrimaryKey roundtrip; the comment also misled. Now: split into two clearly-named tests — InsertAndLookup covers the roundtrip, and a new MigrationBackfillStatementWorks test wipes the migrated rows for a single box, re-executes the migration's INSERT-FROM-boxes statement, and asserts the row appears + reruns are idempotent. 8. DeleteBox cascade gap (servers.go DeleteBox) Was: the schema declared ON DELETE CASCADE but PRAGMA foreign_keys is off in this codebase, so deleting a box left orphaned box_agent_keys rows holding encrypted secrets. Phase 1 docs flagged this as a deferred gap; Copilot pushed back, and fairly — it's a small, contained fix. Now: DeleteBox runs inside a transaction that wipes box_agent_keys WHERE box_id = ? before deleting from boxes. Both succeed or neither does. Test re-added: TestBoxAgentKeys_DeleteBoxClearsDependentKeys. 9. APIKeyAuth nil-guard (middleware/auth.go) Was: keyring.Load() was called without first checking the pointer itself for nil. A miswired ServerConfig would panic on every authenticated request. Now: fail-closed nil check at the top of the request handler — returns 401 + logs at error level. Same defensive treatment as fix #5. Tests ----- All 3 dashboard-side suites pass (`database` package, 7 new tests including the new DeleteBox cascade test). All 3 agent-side suites pass (`crypto`, `middleware`, `api`). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(rotation): clarify constant-time precondition on MatchToken Add a note that subtle.ConstantTimeCompare's length-dependent fast-fail is fine here because every kid in the system is exactly 6 chars long (kidLength = 6 hex chars; the legacy entry uses 'legacy' which is also 6 chars by deliberate convention). Custom kids of a different length would naturally hash-mismatch — which is the intended failure mode. Also serves to force a synchronize event so PR #128's CI re-runs on the fix commit; the prior synchronize from fe3c762 didn't trigger workflows (still unclear why; not blocking the work). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(rotation): adapt phase-1 to main after rebase Two changes required by main moving forward (PRs #127, #134, #137): 1. internal/api/server_test.go was added in PR #127 (remote console) after Phase 1 branched. It uses the old ServerConfig.APIKey field that Phase 1 replaced with KeyRing. Updated the test to construct a one-entry KeyRing and send the legacy 64-hex bearer token. 2. PR #127 also added migration 000002_add_box_console_enabled, colliding with Phase 1's 000002_add_box_agent_keys. Renumbered Phase 1's migration to 000003. Migrations are content-addressed by the embedded iofs, so the rename is mechanical — no schema change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e09943a commit 265a26a

14 files changed

Lines changed: 1893 additions & 47 deletions

File tree

gearbox-agent/cmd/gearbox-agent/main.go

Lines changed: 70 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -113,29 +113,64 @@ func main() {
113113
os.Exit(1)
114114
}
115115

116-
// Handle API key commands
116+
// Handle API key commands.
117+
//
118+
// Both flags read/write the agent's keyring (issue #72), falling back
119+
// to the legacy api-key file when no keyring is present yet. The
120+
// printed key is the keyring's primary entry, in the
121+
// `gbx_<kid>_<base64url>` wire format the dashboard accepts.
117122
if *showAPIKey {
118-
key, err := crypto.ReadAPIKey(cfg.APIKeyPath)
123+
kr, _, err := crypto.LoadOrCreateKeyRing(cfg.KeyRingPath, cfg.APIKeyPath)
119124
if err != nil {
120-
logger.Error("Failed to read API key", "error", err)
125+
logger.Error("Failed to read keyring", "error", err)
121126
os.Exit(1)
122127
}
123-
fmt.Println(key)
128+
primary := kr.Primary()
129+
if primary == nil {
130+
fmt.Fprintln(os.Stderr, "no primary key found in keyring")
131+
os.Exit(1)
132+
}
133+
fmt.Println(crypto.FormatToken(primary.KID, primary.Secret))
124134
os.Exit(0)
125135
}
126136

127137
if *rotateAPIKey {
128-
key, err := crypto.GenerateAPIKey()
138+
// Generates a fresh primary key and writes it to the keyring,
139+
// replacing whatever was primary before. The agent reads the
140+
// keyring on startup so the new key takes effect after restart.
141+
// (Phase 2 will add a hot-reload endpoint that avoids restart.)
142+
kr, _, err := crypto.LoadOrCreateKeyRing(cfg.KeyRingPath, cfg.APIKeyPath)
129143
if err != nil {
130-
logger.Error("Failed to generate API key", "error", err)
144+
logger.Error("Failed to load keyring", "error", err)
145+
os.Exit(1)
146+
}
147+
// Replace all entries with a single fresh primary. CLI rotate is
148+
// the operator escape hatch — it's intentionally not the same as
149+
// the controller-driven three-phase rotation; rather, it wipes
150+
// the slate so a fresh dashboard pairing can take over.
151+
kid, kerr := crypto.NewKID()
152+
if kerr != nil {
153+
logger.Error("Failed to generate key id", "error", kerr)
154+
os.Exit(1)
155+
}
156+
secret, serr := crypto.NewSecret()
157+
if serr != nil {
158+
logger.Error("Failed to generate secret", "error", serr)
131159
os.Exit(1)
132160
}
133-
if err := crypto.WriteAPIKey(cfg.APIKeyPath, key); err != nil {
134-
logger.Error("Failed to write API key", "error", err)
161+
fresh := crypto.KeyRingEntry{
162+
KID: kid,
163+
Secret: secret,
164+
Role: "primary",
165+
CreatedAt: time.Now().UTC(),
166+
}
167+
kr.Entries = []crypto.KeyRingEntry{fresh}
168+
if err := crypto.SaveKeyRing(cfg.KeyRingPath, kr); err != nil {
169+
logger.Error("Failed to write keyring", "error", err)
135170
os.Exit(1)
136171
}
137172
fmt.Println("New API key generated:")
138-
fmt.Println(key)
173+
fmt.Println(crypto.FormatToken(fresh.KID, fresh.Secret))
139174
fmt.Println("\nRestart the service for the new key to take effect.")
140175
os.Exit(0)
141176
}
@@ -244,19 +279,33 @@ func main() {
244279
"to enable AES-256-GCM encryption-at-rest.")
245280
}
246281

247-
// Load or create API key
248-
apiKey, isNewKey, err := crypto.LoadOrCreateAPIKey(cfg.APIKeyPath)
282+
// Load or create the keyring. Issue #72 replaced the single-key model
283+
// with an N-entry keyring to support zero-downtime rotation. Existing
284+
// installs that still have an api-key file (no keyring.json yet) are
285+
// migrated transparently: the on-disk hex key becomes the keyring's
286+
// primary entry with kid="legacy", and the file is left in place as
287+
// a read-only fallback for one release cycle.
288+
keyring, isNewKey, err := crypto.LoadOrCreateKeyRing(cfg.KeyRingPath, cfg.APIKeyPath)
249289
if err != nil {
250-
logger.Error("Failed to initialize API key", "error", err)
290+
logger.Error("Failed to initialize keyring", "error", err)
251291
os.Exit(1)
252292
}
293+
keyringPtr := crypto.NewKeyRingPointer(keyring)
253294
if isNewKey {
254-
logger.Warn("NEW API KEY GENERATED - Save this key, it will not be shown again!")
255-
// Print to stdout (not logger) so it doesn't appear in system logs
256-
fmt.Printf("API Key: %s\n", apiKey)
257-
logger.Info("API key saved", "path", cfg.APIKeyPath)
295+
primary := keyring.Primary()
296+
if primary != nil {
297+
logger.Warn("NEW API KEY GENERATED - Save this key, it will not be shown again!")
298+
// Print to stdout (not logger) so it doesn't appear in system logs
299+
fmt.Printf("API Key: %s\n", crypto.FormatToken(primary.KID, primary.Secret))
300+
logger.Info("Keyring saved", "path", cfg.KeyRingPath, "kid", primary.KID)
301+
}
258302
} else {
259-
logger.Info("API key loaded from file")
303+
primary := keyring.Primary()
304+
kid := "<none>"
305+
if primary != nil {
306+
kid = primary.KID
307+
}
308+
logger.Info("Keyring loaded", "path", cfg.KeyRingPath, "entries", len(keyring.Entries), "primary_kid", kid)
260309
}
261310

262311
// Load or create TLS certificates
@@ -398,7 +447,7 @@ func main() {
398447
// Create and start API server
399448
serverCfg := api.ServerConfig{
400449
ListenAddr: cfg.ListenAddr,
401-
APIKey: apiKey,
450+
KeyRing: keyringPtr,
402451
CertFile: tlsCfg.CertPath,
403452
KeyFile: tlsCfg.KeyPath,
404453
Version: Version,
@@ -493,12 +542,14 @@ func main() {
493542
// backoff layered on top — see 2026-05 audit P1-7).
494543
rateLimiter := middleware.DefaultRateLimiter(logger)
495544
authBackoff := middleware.DefaultBackoffTracker(logger)
545+
keyRingHandler := api.NewKeyRingHandler(server.KeyRing(), logger)
496546
pluginRouter := server.Router().Group(func(r chi.Router) {
497547
r.Use(middleware.RateLimitMiddleware(rateLimiter))
498-
r.Use(middleware.APIKeyAuth(server.APIKey(), logger, authBackoff))
548+
r.Use(middleware.APIKeyAuth(server.KeyRing(), logger, authBackoff))
499549
})
500550
gearManager.RegisterRoutes(pluginRouter)
501551
gearManager.RegisterSystemRoutes(pluginRouter)
552+
keyRingHandler.RegisterRoutes(pluginRouter)
502553

503554
logger.Info("Plugin system initialized",
504555
"plugins", gear.Names(),
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package api
2+
3+
import (
4+
"encoding/json"
5+
"log/slog"
6+
"net/http"
7+
8+
"github.com/go-chi/chi/v5"
9+
10+
"github.com/sarg3nt/gearbox-agent/internal/framework/crypto"
11+
)
12+
13+
// KeyRingHandler exposes the agent's keyring metadata over the HTTP API.
14+
// Phase 1 ships only the read endpoint; Phase 2 adds install/use/remove
15+
// for the controller-driven three-phase rotation flow (issue #72).
16+
type KeyRingHandler struct {
17+
keyring *crypto.KeyRingPointer
18+
logger *slog.Logger
19+
}
20+
21+
// NewKeyRingHandler wires a handler around the agent's keyring pointer.
22+
func NewKeyRingHandler(keyring *crypto.KeyRingPointer, logger *slog.Logger) *KeyRingHandler {
23+
return &KeyRingHandler{keyring: keyring, logger: logger}
24+
}
25+
26+
// RegisterRoutes mounts the keyring endpoints on r. The caller is
27+
// responsible for putting r behind the API-key auth middleware.
28+
func (h *KeyRingHandler) RegisterRoutes(r chi.Router) {
29+
r.Get("/api/v1/system/keyring", h.handleGet)
30+
}
31+
32+
// keyRingResponse is what the API returns. Never includes secret bytes.
33+
type keyRingResponse struct {
34+
Version int `json:"version"`
35+
Entries []crypto.KeyRingMetadata `json:"entries"`
36+
}
37+
38+
// handleGet returns the keyring metadata — kids, roles, creation times,
39+
// and short fingerprints — but never the actual secrets.
40+
//
41+
// @Summary Get agent keyring metadata
42+
// @Description Returns the currently-installed API keys' metadata. Secrets are NEVER exposed. The fingerprint is the first 8 hex chars of sha256(secret); useful to verify the dashboard has the same secret without round-tripping the secret itself.
43+
// @Tags system
44+
// @Security BearerAuth
45+
// @Produce json
46+
// @Success 200 {object} keyRingResponse
47+
// @Failure 401 {string} string "Unauthorized"
48+
// @Router /api/v1/system/keyring [get]
49+
func (h *KeyRingHandler) handleGet(w http.ResponseWriter, _ *http.Request) {
50+
kr := h.keyring.Load()
51+
if kr == nil {
52+
// Should be unreachable — main.go calls NewKeyRingPointer(kr)
53+
// with a non-nil value before mounting the handler. Defensive
54+
// 500 + log so a future wiring bug fails loud rather than
55+
// panicking the agent on every keyring request.
56+
h.logger.Error("keyring pointer empty when serving /system/keyring")
57+
http.Error(w, "keyring unavailable", http.StatusInternalServerError)
58+
return
59+
}
60+
resp := keyRingResponse{
61+
Version: kr.Version,
62+
Entries: kr.Snapshot(),
63+
}
64+
w.Header().Set("Content-Type", "application/json")
65+
if err := json.NewEncoder(w).Encode(resp); err != nil {
66+
h.logger.Error("encode keyring response failed", "error", err)
67+
}
68+
}

gearbox-agent/internal/api/server.go

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717

1818
_ "github.com/sarg3nt/gearbox-agent/docs" // Swagger docs
1919
"github.com/sarg3nt/gearbox-agent/internal/api/console"
20+
"github.com/sarg3nt/gearbox-agent/internal/framework/crypto"
2021
"github.com/sarg3nt/gearbox-agent/internal/framework/events"
2122
frameworkmiddleware "github.com/sarg3nt/gearbox-agent/internal/framework/middleware"
2223
)
@@ -26,15 +27,15 @@ type Server struct {
2627
httpServer *http.Server
2728
router chi.Router
2829
logger *slog.Logger
29-
apiKey string
30+
keyring *crypto.KeyRingPointer
3031
certFile string
3132
keyFile string
3233
}
3334

3435
// ServerConfig holds configuration for the API server.
3536
type ServerConfig struct {
3637
ListenAddr string
37-
APIKey string
38+
KeyRing *crypto.KeyRingPointer
3839
CertFile string
3940
KeyFile string
4041
Version string
@@ -147,7 +148,7 @@ func NewServer(cfg ServerConfig) *Server {
147148
// Protected API routes (require API key auth)
148149
r.Group(func(r chi.Router) {
149150
r.Use(frameworkmiddleware.RateLimitMiddleware(rateLimiter))
150-
r.Use(frameworkmiddleware.APIKeyAuth(cfg.APIKey, cfg.Logger, authBackoff))
151+
r.Use(frameworkmiddleware.APIKeyAuth(cfg.KeyRing, cfg.Logger, authBackoff))
151152

152153
// Core endpoints (not handled by plugins)
153154
r.Get("/api/v1/metadata", handlers.Metadata)
@@ -193,7 +194,7 @@ func NewServer(cfg ServerConfig) *Server {
193194
},
194195
router: r,
195196
logger: cfg.Logger,
196-
apiKey: cfg.APIKey,
197+
keyring: cfg.KeyRing,
197198
certFile: cfg.CertFile,
198199
keyFile: cfg.KeyFile,
199200
}
@@ -204,9 +205,10 @@ func (s *Server) Router() chi.Router {
204205
return s.router
205206
}
206207

207-
// APIKey returns the server's API key for middleware configuration.
208-
func (s *Server) APIKey() string {
209-
return s.apiKey
208+
// KeyRing returns the server's keyring pointer for middleware
209+
// configuration on plugin routes registered after server construction.
210+
func (s *Server) KeyRing() *crypto.KeyRingPointer {
211+
return s.keyring
210212
}
211213

212214
// Logger returns the server's logger.

gearbox-agent/internal/api/server_test.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
package api
22

33
import (
4+
"encoding/hex"
45
"io"
56
"log/slog"
67
"net/http"
78
"net/http/httptest"
9+
"strings"
810
"testing"
911

12+
"github.com/sarg3nt/gearbox-agent/internal/framework/crypto"
1013
"github.com/sarg3nt/gearbox-agent/internal/framework/events"
1114
)
1215

@@ -29,9 +32,21 @@ func TestNewServer_ConsoleRoutesAlwaysMounted(t *testing.T) {
2932
bus := events.NewBus()
3033
defer bus.Close()
3134

35+
// Build a one-entry keyring whose legacy bare-hex token the test
36+
// then sends in the Authorization header. The keyring replaced the
37+
// single-string APIKey field on ServerConfig (issue #72).
38+
testSecret := strings.Repeat("ab", 32) // 64 hex chars / 32 bytes
39+
secretBytes, _ := hex.DecodeString(testSecret)
40+
kr := &crypto.KeyRing{
41+
Version: 1,
42+
Entries: []crypto.KeyRingEntry{{
43+
KID: "legacy", Secret: secretBytes, SecretHex: testSecret, Role: "primary",
44+
}},
45+
}
46+
3247
srv := NewServer(ServerConfig{
3348
ListenAddr: "127.0.0.1:0",
34-
APIKey: "test-key",
49+
KeyRing: crypto.NewKeyRingPointer(kr),
3550
Logger: newSilentLogger(),
3651
EventBus: bus,
3752
})
@@ -42,7 +57,7 @@ func TestNewServer_ConsoleRoutesAlwaysMounted(t *testing.T) {
4257
// Capabilities — auth-gated, no token required, deterministic
4358
// response.
4459
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/console/capabilities", nil)
45-
req.Header.Set("Authorization", "Bearer test-key")
60+
req.Header.Set("Authorization", "Bearer "+testSecret)
4661
resp, err := http.DefaultClient.Do(req)
4762
if err != nil {
4863
t.Fatalf("capabilities request: %v", err)

gearbox-agent/internal/framework/config/config.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,18 @@ type Config struct {
3535
// whitespace around each entry is trimmed, empty entries are dropped.
3636
TLSHosts []string
3737

38-
// API key settings
39-
APIKeyPath string
38+
// API key settings.
39+
//
40+
// APIKeyPath is the legacy single-key file; retained for backwards
41+
// compatibility with installs that pre-date the keyring. On startup
42+
// the agent migrates its contents into a single keyring entry
43+
// (kid="legacy", role=primary) and the file is left in place as a
44+
// read-only fallback for one release cycle.
45+
//
46+
// KeyRingPath is the new N-entry rotating keyring (issue #72).
47+
// Defaults to <DataDir>/keyring.json.
48+
APIKeyPath string
49+
KeyRingPath string
4050

4151
// Data directory for state, certs, etc.
4252
DataDir string
@@ -174,6 +184,7 @@ func Load() (*Config, error) {
174184
}
175185
cfg.TLSHosts = parseCommaList(os.Getenv("HAPROXY_AGENT_TLS_HOSTS"))
176186
cfg.APIKeyPath = getEnvOrDefault("HAPROXY_AGENT_API_KEY_PATH", cfg.DataDir+"/api-key")
187+
cfg.KeyRingPath = getEnvOrDefault("GEARBOX_AGENT_KEYRING_PATH", cfg.DataDir+"/keyring.json")
177188

178189
// Logging
179190
if v := os.Getenv("HAPROXY_AGENT_LOG_LEVEL"); v != "" {

0 commit comments

Comments
 (0)