Files
felhom.eu/hub/internal/api/offsite.go
T

216 lines
8.1 KiB
Go

package api
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/offsitekeys"
)
// OffsiteKeyService is the registrar seam (decision 69). nil → the key endpoints answer 503.
type OffsiteKeyService interface {
RegisterKey(ctx context.Context, customerID, pub string) (offsitekeys.InstallResult, error)
ConfirmKey(ctx context.Context, customerID, fp string) (int, error)
MoveAside(ctx context.Context, customerID string) (string, error)
OpenWindowFor(ctx context.Context, customerID string, countBefore int) (offsitekeys.WindowGrant, error)
CloseWindowFor(ctx context.Context, customerID string, r offsitekeys.WindowResult) error
RequestAbandon(ctx context.Context, customerID, path string) (offsitekeys.AbandonStatus, error)
CancelAbandon(customerID, by string) (int, error)
AbandonStatusFor(customerID string) (offsitekeys.AbandonStatus, error)
}
// SetOffsiteKeyService wires the key registrar.
func (h *Handler) SetOffsiteKeyService(s OffsiteKeyService) { h.offsiteKeys = s }
// handleOffsiteConsumePassword is RETIRED (hub v0.127.0, decision 69, R-820). It used to hand the box the
// Storage Box sub-account password; that password can rewrite `.ssh/authorized_keys` and so remove the
// append-only pin from any key (measured 2026-10-03). A box now sends its PUBLIC key to
// /offsite/register-key and the hub installs it. This answers 410 with no body that could carry a secret.
// Pinned by TestConsumePassword_RetiredReturnsNoPassword.
func (h *Handler) handleOffsiteConsumePassword(w http.ResponseWriter, r *http.Request, customerID string) {
authCustomerID, isGlobal, ok := h.checkAuthCustomer(r)
if !ok || (!isGlobal && authCustomerID != customerID) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
h.logger.Printf("[WARN] offsite consume-password called by %s — retired (decision 69); the box must register its public key (controller >= 0.289.0)", customerID)
http.Error(w, "gone: the hub no longer serves the storage password; register the box's public key at /api/v1/offsite/register-key/", http.StatusGone)
}
func (h *Handler) offsiteKeyAuth(w http.ResponseWriter, r *http.Request, customerID string) bool {
authCustomerID, isGlobal, ok := h.checkAuthCustomer(r)
if !ok || (!isGlobal && authCustomerID != customerID) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return false
}
if h.offsiteKeys == nil {
http.Error(w, "offsite key registrar not configured", http.StatusServiceUnavailable)
return false
}
return true
}
func offsiteKeyErr(w http.ResponseWriter, err error) {
if errors.Is(err, offsitekeys.ErrNotProvisioned) {
http.Error(w, "no provisioned off-site target", http.StatusConflict)
return
}
http.Error(w, "registrar failed: "+err.Error(), http.StatusBadGateway)
}
// handleOffsiteRegisterKey: POST {"public_key": "ssh-ed25519 AAAA… comment"} → the hub writes it into the
// sub-account's authorized_keys pinned append-only and answers {"installed":true,"fingerprint":"SHA256:…"}.
// The response carries NO credential.
func (h *Handler) handleOffsiteRegisterKey(w http.ResponseWriter, r *http.Request, customerID string) {
if !h.offsiteKeyAuth(w, r, customerID) {
return
}
var req struct {
PublicKey string `json:"public_key"`
}
body, _ := io.ReadAll(io.LimitReader(r.Body, 16<<10))
if err := json.Unmarshal(body, &req); err != nil || req.PublicKey == "" {
http.Error(w, "body must be {\"public_key\": \"…\"}", http.StatusBadRequest)
return
}
if _, _, err := offsitekeys.KeyFingerprint(req.PublicKey); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
res, err := h.offsiteKeys.RegisterKey(ctx, customerID, req.PublicKey)
if err != nil {
offsiteKeyErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"installed": true, "fingerprint": res.Fingerprint})
}
// handleOffsiteConfirmKey: POST {"fingerprint": "SHA256:…"} → only that key's pinned line stays.
func (h *Handler) handleOffsiteConfirmKey(w http.ResponseWriter, r *http.Request, customerID string) {
if !h.offsiteKeyAuth(w, r, customerID) {
return
}
var req struct {
Fingerprint string `json:"fingerprint"`
}
body, _ := io.ReadAll(io.LimitReader(r.Body, 4<<10))
if err := json.Unmarshal(body, &req); err != nil || req.Fingerprint == "" {
http.Error(w, "body must be {\"fingerprint\": \"SHA256:…\"}", http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
removed, err := h.offsiteKeys.ConfirmKey(ctx, customerID, req.Fingerprint)
if err != nil {
offsiteKeyErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"confirmed": true, "removed": removed})
}
// handleOffsiteMoveAside: POST → the hub renames the repository to <repo>.orphaned-<date>[-n]. Never deletes.
func (h *Handler) handleOffsiteMoveAside(w http.ResponseWriter, r *http.Request, customerID string) {
if !h.offsiteKeyAuth(w, r, customerID) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
to, err := h.offsiteKeys.MoveAside(ctx, customerID)
if err != nil {
offsiteKeyErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"moved_to": to})
}
// handleOffsiteWindowOpen: POST {"count_before": N} → the hub decides (weekly / operator one-shot) and,
// if granted, prepends a deleting line for the box's confirmed key for 20 minutes (decision 68).
func (h *Handler) handleOffsiteWindowOpen(w http.ResponseWriter, r *http.Request, customerID string) {
if !h.offsiteKeyAuth(w, r, customerID) {
return
}
var req struct {
CountBefore int `json:"count_before"`
}
body, _ := io.ReadAll(io.LimitReader(r.Body, 4<<10))
_ = json.Unmarshal(body, &req)
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
g, err := h.offsiteKeys.OpenWindowFor(ctx, customerID, req.CountBefore)
if err != nil {
offsiteKeyErr(w, err)
return
}
writeJSON(w, http.StatusOK, g)
}
// handleOffsiteWindowClose: POST the box's result → the hub removes the deleting line and checks the count.
func (h *Handler) handleOffsiteWindowClose(w http.ResponseWriter, r *http.Request, customerID string) {
if !h.offsiteKeyAuth(w, r, customerID) {
return
}
var req offsitekeys.WindowResult
body, _ := io.ReadAll(io.LimitReader(r.Body, 8<<10))
if err := json.Unmarshal(body, &req); err != nil || req.WindowID == 0 {
http.Error(w, "body must carry window_id", http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
if err := h.offsiteKeys.CloseWindowFor(ctx, customerID, req); err != nil {
offsiteKeyErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"closed": true})
}
// handleOffsiteAbandon: the box's half of decision 74 (R-823).
//
// POST /offsite/abandon-request/<id> {"path": "<repo>.orphaned-…"} → recorded; deleted by the hub after the delay
// POST /offsite/abandon-cancel/<id> → every pending request cancelled
// POST /offsite/abandon-status/<id> → {"state": none|pending|cancelled|deleted, …}
func (h *Handler) handleOffsiteAbandon(w http.ResponseWriter, r *http.Request, verb, customerID string) {
if !h.offsiteKeyAuth(w, r, customerID) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
switch verb {
case "request":
var req struct {
Path string `json:"path"`
}
body, _ := io.ReadAll(io.LimitReader(r.Body, 4<<10))
if err := json.Unmarshal(body, &req); err != nil || req.Path == "" {
http.Error(w, "body must be {\"path\": \"…\"}", http.StatusBadRequest)
return
}
st, err := h.offsiteKeys.RequestAbandon(ctx, customerID, req.Path)
if err != nil {
offsiteKeyErr(w, err)
return
}
writeJSON(w, http.StatusOK, st)
case "cancel":
n, err := h.offsiteKeys.CancelAbandon(customerID, "box")
if err != nil {
offsiteKeyErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"cancelled": n})
default:
st, err := h.offsiteKeys.AbandonStatusFor(customerID)
if err != nil {
offsiteKeyErr(w, err)
return
}
writeJSON(w, http.StatusOK, st)
}
}