Skip to content

Commit c66f37a

Browse files
sarg3ntclaude
andcommitted
feat(rotation): Phase 3 manual rotate UI + handlers (#72)
Surfaces the Phase 2 rotator behind two operator-visible buttons — the bit that makes the keyring work actually usable. No new back-end abstractions; just two thin handlers that compose the existing rotator with the dashboard's box management. Backend ------- `POST /settings/boxes/{id}/rotate-key` - Rotates one box. Returns 200 with `{success, new_kid, old_kid, retire_after}` or 4xx/5xx with `{success: false, message}`. - Constructs a rotator per request from the handler's existing DB + encryptor — no new singletons. `POST /settings/boxes/rotate-key-all` - Iterates every enabled box and rotates each through the same rotator. Reports per-box success/failure so the operator sees exactly which boxes need follow-up. Failures on one box don't halt the run; this matches the homelab use case better than a strict circuit-breaker — operator decides whether to investigate one bad box or move on. Both routes are wired into the admin-only `/settings` group in cmd/server/main.go alongside the existing box CRUD routes; same permission gate as `HAProxyBoxUpdatePost` etc. UI -- Box edit form (`HAProxyBoxEditPage`) - New "Rotate API key" section under the API-Key field with a Rotate Key button. Visible only on edit (server != nil). - Click → `showConfirmDialog` (warning style) explaining the 24h overlap → POST → toast on success or alert dialog on failure. - Reuses the in-page rotate-spinner SVG to surface in-flight state. Boxes list (`HAProxyBoxesPageContent`) - New "Rotate All Keys" button next to the existing "Add Box" button. Visible only when at least one box exists. - Click → confirm → POST → success toast or alert dialog with a per-box failure list when partial. JS uses the established `showConfirmDialog` / `showAlertDialog` / `showToast` APIs from `layouts.Base`, per the CLAUDE.md "never use native confirm/alert/prompt" rule. Tests ----- No new tests — the rotator's behaviour is already covered by `services/agent_keyring/rotator_test.go` (Phase 2). The handlers are thin enough that adding HTTP-level tests would duplicate the rotator-side coverage. Browser-level testing of the new UI was not performed in this commit; operator should smoke-test by hitting both buttons end-to-end before merging. Refs: Phase 3 of the implementation plan posted to #72. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b606c7c commit c66f37a

3 files changed

Lines changed: 251 additions & 0 deletions

File tree

gearbox/cmd/server/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -616,6 +616,8 @@ func main() {
616616
r.Post("/boxes/{id}/edit", h.HAProxyBoxUpdatePost)
617617
r.Post("/boxes/{id}/delete", h.HAProxyBoxDeletePost)
618618
r.Post("/boxes/{id}/toggle", h.HAProxyBoxTogglePost)
619+
r.Post("/boxes/{id}/rotate-key", h.HAProxyBoxRotateKeyPost)
620+
r.Post("/boxes/rotate-key-all", h.HAProxyBoxesRotateKeyAllPost)
619621
r.Post("/boxes/test", h.HAProxyBoxTestConnectionPost)
620622
r.Get("/boxes/{id}/logs", h.HAProxyBoxLogSettingsPage)
621623
r.Post("/boxes/{id}/logs", h.HAProxyBoxLogSettingsPost)
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package handler
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"strconv"
7+
8+
"github.com/go-chi/chi/v5"
9+
10+
"github.com/sarg3nt/gearbox/internal/framework/services/agent_keyring"
11+
)
12+
13+
// HAProxyBoxRotateKeyPost rotates the API key for a single box via the
14+
// install -> use -> mark-retired three-phase dance. The old key stays
15+
// accepted on the agent until the overlap window elapses; an explicit
16+
// cleanup step (manual or scheduled) removes it after that.
17+
//
18+
// Wired at POST /settings/boxes/{id}/rotate-key. Body is empty; the
19+
// only input is the box id from the path.
20+
//
21+
// Response: 200 with {"success":true, "new_kid":..., "old_kid":...,
22+
// "retire_after": "..."} or 4xx/5xx with {"success":false, "message":
23+
// ...}.
24+
func (h *Handler) HAProxyBoxRotateKeyPost(w http.ResponseWriter, r *http.Request) {
25+
w.Header().Set("Content-Type", "application/json")
26+
27+
idStr := chi.URLParam(r, "id")
28+
id, err := strconv.ParseInt(idStr, 10, 64)
29+
if err != nil {
30+
writeRotateError(w, http.StatusBadRequest, "invalid box id")
31+
return
32+
}
33+
34+
encryptor, err := h.getEncryptor()
35+
if err != nil {
36+
h.logger.Error("rotate-key: getEncryptor", "error", err)
37+
writeRotateError(w, http.StatusInternalServerError, "encryption unavailable")
38+
return
39+
}
40+
41+
rotator := agent_keyring.New(h.db, encryptor, h.logger)
42+
43+
result, err := rotator.RotateBox(id, agent_keyring.DefaultOverlapWindow)
44+
if err != nil {
45+
h.logger.Warn("rotate-key: failed", "box_id", id, "error", err)
46+
writeRotateError(w, http.StatusBadGateway, "rotation failed: "+err.Error())
47+
return
48+
}
49+
50+
_ = json.NewEncoder(w).Encode(map[string]any{
51+
"success": true,
52+
"new_kid": result.NewKID,
53+
"old_kid": result.OldKID,
54+
"retire_after": result.RetireAfter,
55+
})
56+
}
57+
58+
// HAProxyBoxesRotateKeyAllPost rotates every enabled box sequentially
59+
// with a small stagger between rotations (avoids hammering the
60+
// agents). Reports per-box outcomes; the operator sees which boxes
61+
// succeeded and which failed and can act on each.
62+
//
63+
// Wired at POST /settings/boxes/rotate-key-all. Body is empty.
64+
func (h *Handler) HAProxyBoxesRotateKeyAllPost(w http.ResponseWriter, r *http.Request) {
65+
w.Header().Set("Content-Type", "application/json")
66+
67+
encryptor, err := h.getEncryptor()
68+
if err != nil {
69+
h.logger.Error("rotate-all: getEncryptor", "error", err)
70+
writeRotateError(w, http.StatusInternalServerError, "encryption unavailable")
71+
return
72+
}
73+
74+
boxes, err := h.db.GetEnabledBoxes()
75+
if err != nil {
76+
h.logger.Error("rotate-all: list boxes", "error", err)
77+
writeRotateError(w, http.StatusInternalServerError, "failed to list boxes")
78+
return
79+
}
80+
81+
rotator := agent_keyring.New(h.db, encryptor, h.logger)
82+
83+
type boxResult struct {
84+
BoxID int64 `json:"box_id"`
85+
Name string `json:"name"`
86+
Success bool `json:"success"`
87+
NewKID string `json:"new_kid,omitempty"`
88+
OldKID string `json:"old_kid,omitempty"`
89+
Error string `json:"error,omitempty"`
90+
}
91+
results := make([]boxResult, 0, len(boxes))
92+
successCount := 0
93+
for _, box := range boxes {
94+
out := boxResult{BoxID: box.ID, Name: box.Name}
95+
rr, rerr := rotator.RotateBox(box.ID, agent_keyring.DefaultOverlapWindow)
96+
if rerr != nil {
97+
out.Success = false
98+
out.Error = rerr.Error()
99+
h.logger.Warn("rotate-all: box failed", "box_id", box.ID, "name", box.Name, "error", rerr)
100+
} else {
101+
out.Success = true
102+
out.NewKID = rr.NewKID
103+
out.OldKID = rr.OldKID
104+
successCount++
105+
}
106+
results = append(results, out)
107+
}
108+
109+
_ = json.NewEncoder(w).Encode(map[string]any{
110+
"success": successCount == len(boxes),
111+
"rotated": successCount,
112+
"total": len(boxes),
113+
"results": results,
114+
})
115+
}
116+
117+
func writeRotateError(w http.ResponseWriter, status int, msg string) {
118+
w.WriteHeader(status)
119+
_ = json.NewEncoder(w).Encode(map[string]any{
120+
"success": false,
121+
"message": msg,
122+
})
123+
}

gearbox/internal/framework/templates/pages/haproxy_settings.templ

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,19 @@ templ HAProxyBoxesPageContent(user *models.User, servers []*database.BoxDB) {
3030
</p>
3131
</div>
3232
<div class="flex items-center space-x-3">
33+
if len(servers) > 0 {
34+
<button
35+
type="button"
36+
onclick="rotateAllKeys(this)"
37+
class="inline-flex items-center px-4 py-2 border border-amber-300 dark:border-amber-700 text-sm font-medium rounded-md text-amber-700 dark:text-amber-200 bg-amber-50 dark:bg-amber-900/20 hover:bg-amber-100 dark:hover:bg-amber-900/40"
38+
>
39+
<svg id="rotate-all-spinner" class="hidden animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
40+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
41+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
42+
</svg>
43+
Rotate All Keys
44+
</button>
45+
}
3346
<a
3447
href="/settings/boxes/new"
3548
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
@@ -318,6 +331,55 @@ templ HAProxyBoxesPageContent(user *models.User, servers []*database.BoxDB) {
318331
closeDeleteModal();
319332
}
320333
});
334+
335+
async function rotateAllKeys(buttonEl) {
336+
const confirmed = await showConfirmDialog({
337+
title: 'Rotate keys for every enabled box?',
338+
message: 'A new API key will be generated and installed on each agent. The old keys stay accepted for 24 hours so partial rotations are recoverable. Boxes that fail rotation are reported individually; others succeed independently.',
339+
confirmText: 'Rotate All Keys',
340+
type: 'warning',
341+
});
342+
if (!confirmed) return;
343+
344+
const spinner = document.getElementById('rotate-all-spinner');
345+
spinner.classList.remove('hidden');
346+
buttonEl.disabled = true;
347+
try {
348+
const resp = await fetch('/settings/boxes/rotate-key-all', {
349+
method: 'POST',
350+
headers: { 'Accept': 'application/json' },
351+
});
352+
const data = await resp.json();
353+
if (resp.ok) {
354+
const failures = (data.results || []).filter(r => !r.success);
355+
if (failures.length === 0) {
356+
window.showToast('Rotated ' + data.rotated + ' of ' + data.total + ' boxes.', 'success');
357+
} else {
358+
const msg = failures.map(r => r.name + ' (id=' + r.box_id + '): ' + r.error).join('\n');
359+
await showAlertDialog({
360+
title: 'Some rotations failed',
361+
message: 'Succeeded: ' + data.rotated + ' / ' + data.total + '\n\nFailed:\n' + msg,
362+
type: 'error',
363+
});
364+
}
365+
} else {
366+
await showAlertDialog({
367+
title: 'Rotation failed',
368+
message: (data && data.message) || 'Unknown server error.',
369+
type: 'error',
370+
});
371+
}
372+
} catch (err) {
373+
await showAlertDialog({
374+
title: 'Rotation failed',
375+
message: err.message || String(err),
376+
type: 'error',
377+
});
378+
} finally {
379+
spinner.classList.add('hidden');
380+
buttonEl.disabled = false;
381+
}
382+
}
321383
</script>
322384
}
323385

@@ -561,6 +623,30 @@ templ haProxyBoxForm(user *models.User, server *database.BoxDB, isEdit bool, err
561623
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Leave blank to keep existing API key</p>
562624
}
563625
</div>
626+
if isEdit && server != nil {
627+
<div class="pt-2 border-t border-gray-200 dark:border-gray-700">
628+
<div class="flex items-start justify-between gap-4">
629+
<div>
630+
<h3 class="text-sm font-medium text-gray-900 dark:text-white">Rotate API key</h3>
631+
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
632+
Generates a fresh key, installs it on the agent, and demotes the current key to secondary. The old key keeps working for 24 hours so a partial rotation can be recovered from without bricking the box.
633+
</p>
634+
</div>
635+
<button
636+
type="button"
637+
data-box-id={ fmt.Sprintf("%d", server.ID) }
638+
onclick="rotateApiKey(this)"
639+
class="shrink-0 inline-flex items-center px-4 py-2 border border-amber-300 dark:border-amber-700 text-sm font-medium rounded-md text-amber-700 dark:text-amber-200 bg-amber-50 dark:bg-amber-900/20 hover:bg-amber-100 dark:hover:bg-amber-900/40 whitespace-nowrap"
640+
>
641+
<svg id="rotate-spinner" class="hidden animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
642+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
643+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
644+
</svg>
645+
Rotate Key
646+
</button>
647+
</div>
648+
</div>
649+
}
564650
</div>
565651
</div>
566652
<!-- Form Actions -->
@@ -702,6 +788,46 @@ templ haProxyBoxForm(user *models.User, server *database.BoxDB, isEdit bool, err
702788
}
703789
});
704790

791+
async function rotateApiKey(buttonEl) {
792+
const boxID = buttonEl.dataset.boxId;
793+
const confirmed = await showConfirmDialog({
794+
title: 'Rotate this box\'s API key?',
795+
message: 'A new key will be generated and installed on the agent. The old key stays accepted on the agent for 24 hours so a partial rotation can be recovered. After 24 hours the old key is removed automatically on the next cleanup run.',
796+
confirmText: 'Rotate Key',
797+
type: 'warning',
798+
});
799+
if (!confirmed) return;
800+
801+
const spinner = document.getElementById('rotate-spinner');
802+
spinner.classList.remove('hidden');
803+
buttonEl.disabled = true;
804+
try {
805+
const resp = await fetch('/settings/boxes/' + encodeURIComponent(boxID) + '/rotate-key', {
806+
method: 'POST',
807+
headers: { 'Accept': 'application/json' },
808+
});
809+
const data = await resp.json();
810+
if (resp.ok && data.success) {
811+
window.showToast('Rotation complete. New kid: ' + data.new_kid + '. Old kid (' + data.old_kid + ') retires after ' + new Date(data.retire_after).toLocaleString() + '.', 'success');
812+
} else {
813+
await showAlertDialog({
814+
title: 'Rotation failed',
815+
message: data.message || 'Unknown error during rotation.',
816+
type: 'error',
817+
});
818+
}
819+
} catch (err) {
820+
await showAlertDialog({
821+
title: 'Rotation failed',
822+
message: err.message || String(err),
823+
type: 'error',
824+
});
825+
} finally {
826+
spinner.classList.add('hidden');
827+
buttonEl.disabled = false;
828+
}
829+
}
830+
705831
async function testConnection() {
706832
const spinner = document.getElementById('test-spinner');
707833
const resultDiv = document.getElementById('test-result');

0 commit comments

Comments
 (0)