diff --git a/gearbox/cmd/server/main.go b/gearbox/cmd/server/main.go index 190b90c..475449f 100644 --- a/gearbox/cmd/server/main.go +++ b/gearbox/cmd/server/main.go @@ -616,6 +616,8 @@ func main() { r.Post("/boxes/{id}/edit", h.HAProxyBoxUpdatePost) r.Post("/boxes/{id}/delete", h.HAProxyBoxDeletePost) r.Post("/boxes/{id}/toggle", h.HAProxyBoxTogglePost) + r.Post("/boxes/{id}/rotate-key", h.HAProxyBoxRotateKeyPost) + r.Post("/boxes/rotate-key-all", h.HAProxyBoxesRotateKeyAllPost) r.Post("/boxes/test", h.HAProxyBoxTestConnectionPost) r.Get("/boxes/{id}/logs", h.HAProxyBoxLogSettingsPage) r.Post("/boxes/{id}/logs", h.HAProxyBoxLogSettingsPost) diff --git a/gearbox/internal/framework/handler/haproxy_rotate_key.go b/gearbox/internal/framework/handler/haproxy_rotate_key.go new file mode 100644 index 0000000..2c8444c --- /dev/null +++ b/gearbox/internal/framework/handler/haproxy_rotate_key.go @@ -0,0 +1,123 @@ +package handler + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + "github.com/sarg3nt/gearbox/internal/framework/services/agent_keyring" +) + +// HAProxyBoxRotateKeyPost rotates the API key for a single box via the +// install -> use -> mark-retired three-phase dance. The old key stays +// accepted on the agent until the overlap window elapses; an explicit +// cleanup step (manual or scheduled) removes it after that. +// +// Wired at POST /settings/boxes/{id}/rotate-key. Body is empty; the +// only input is the box id from the path. +// +// Response: 200 with {"success":true, "new_kid":..., "old_kid":..., +// "retire_after": "..."} or 4xx/5xx with {"success":false, "message": +// ...}. +func (h *Handler) HAProxyBoxRotateKeyPost(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + idStr := chi.URLParam(r, "id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + writeRotateError(w, http.StatusBadRequest, "invalid box id") + return + } + + encryptor, err := h.getEncryptor() + if err != nil { + h.logger.Error("rotate-key: getEncryptor", "error", err) + writeRotateError(w, http.StatusInternalServerError, "encryption unavailable") + return + } + + rotator := agent_keyring.New(h.db, encryptor, h.logger) + + result, err := rotator.RotateBox(id, agent_keyring.DefaultOverlapWindow) + if err != nil { + h.logger.Warn("rotate-key: failed", "box_id", id, "error", err) + writeRotateError(w, http.StatusBadGateway, "rotation failed: "+err.Error()) + return + } + + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "new_kid": result.NewKID, + "old_kid": result.OldKID, + "retire_after": result.RetireAfter, + }) +} + +// HAProxyBoxesRotateKeyAllPost rotates every enabled box sequentially +// with a small stagger between rotations (avoids hammering the +// agents). Reports per-box outcomes; the operator sees which boxes +// succeeded and which failed and can act on each. +// +// Wired at POST /settings/boxes/rotate-key-all. Body is empty. +func (h *Handler) HAProxyBoxesRotateKeyAllPost(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + encryptor, err := h.getEncryptor() + if err != nil { + h.logger.Error("rotate-all: getEncryptor", "error", err) + writeRotateError(w, http.StatusInternalServerError, "encryption unavailable") + return + } + + boxes, err := h.db.GetEnabledBoxes() + if err != nil { + h.logger.Error("rotate-all: list boxes", "error", err) + writeRotateError(w, http.StatusInternalServerError, "failed to list boxes") + return + } + + rotator := agent_keyring.New(h.db, encryptor, h.logger) + + type boxResult struct { + BoxID int64 `json:"box_id"` + Name string `json:"name"` + Success bool `json:"success"` + NewKID string `json:"new_kid,omitempty"` + OldKID string `json:"old_kid,omitempty"` + Error string `json:"error,omitempty"` + } + results := make([]boxResult, 0, len(boxes)) + successCount := 0 + for _, box := range boxes { + out := boxResult{BoxID: box.ID, Name: box.Name} + rr, rerr := rotator.RotateBox(box.ID, agent_keyring.DefaultOverlapWindow) + if rerr != nil { + out.Success = false + out.Error = rerr.Error() + h.logger.Warn("rotate-all: box failed", "box_id", box.ID, "name", box.Name, "error", rerr) + } else { + out.Success = true + out.NewKID = rr.NewKID + out.OldKID = rr.OldKID + successCount++ + } + results = append(results, out) + } + + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": successCount == len(boxes), + "rotated": successCount, + "total": len(boxes), + "results": results, + }) +} + +func writeRotateError(w http.ResponseWriter, status int, msg string) { + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": false, + "message": msg, + }) +} diff --git a/gearbox/internal/framework/templates/pages/haproxy_settings.templ b/gearbox/internal/framework/templates/pages/haproxy_settings.templ index fe7c0ab..f501011 100644 --- a/gearbox/internal/framework/templates/pages/haproxy_settings.templ +++ b/gearbox/internal/framework/templates/pages/haproxy_settings.templ @@ -30,6 +30,19 @@ templ HAProxyBoxesPageContent(user *models.User, servers []*database.BoxDB) {

+ if len(servers) > 0 { + + } !r.success); + if (failures.length === 0) { + window.showToast('Rotated ' + data.rotated + ' of ' + data.total + ' boxes.', 'success'); + } else { + const msg = failures.map(r => r.name + ' (id=' + r.box_id + '): ' + r.error).join('\n'); + await showAlertDialog({ + title: 'Some rotations failed', + message: 'Succeeded: ' + data.rotated + ' / ' + data.total + '\n\nFailed:\n' + msg, + type: 'error', + }); + } + } else { + await showAlertDialog({ + title: 'Rotation failed', + message: (data && data.message) || 'Unknown server error.', + type: 'error', + }); + } + } catch (err) { + await showAlertDialog({ + title: 'Rotation failed', + message: err.message || String(err), + type: 'error', + }); + } finally { + spinner.classList.add('hidden'); + buttonEl.disabled = false; + } + } } @@ -578,6 +640,30 @@ templ haProxyBoxForm(user *models.User, server *database.BoxDB, isEdit bool, err

Leave blank to keep existing API key

}
+ if isEdit && server != nil { +
+
+
+

Rotate API key

+

+ 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. +

+
+ +
+
+ } @@ -719,6 +805,46 @@ templ haProxyBoxForm(user *models.User, server *database.BoxDB, isEdit bool, err } }); + async function rotateApiKey(buttonEl) { + const boxID = buttonEl.dataset.boxId; + const confirmed = await showConfirmDialog({ + title: 'Rotate this box\'s API key?', + 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.', + confirmText: 'Rotate Key', + type: 'warning', + }); + if (!confirmed) return; + + const spinner = document.getElementById('rotate-spinner'); + spinner.classList.remove('hidden'); + buttonEl.disabled = true; + try { + const resp = await fetch('/settings/boxes/' + encodeURIComponent(boxID) + '/rotate-key', { + method: 'POST', + headers: { 'Accept': 'application/json' }, + }); + const data = await resp.json(); + if (resp.ok && data.success) { + window.showToast('Rotation complete. New kid: ' + data.new_kid + '. Old kid (' + data.old_kid + ') retires after ' + new Date(data.retire_after).toLocaleString() + '.', 'success'); + } else { + await showAlertDialog({ + title: 'Rotation failed', + message: data.message || 'Unknown error during rotation.', + type: 'error', + }); + } + } catch (err) { + await showAlertDialog({ + title: 'Rotation failed', + message: err.message || String(err), + type: 'error', + }); + } finally { + spinner.classList.add('hidden'); + buttonEl.disabled = false; + } + } + async function testConnection() { const spinner = document.getElementById('test-spinner'); const resultDiv = document.getElementById('test-result');