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

137 lines
7.0 KiB
Go

package api
import (
"encoding/json"
"net/http"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/configgen"
"gitea.dooplex.hu/admin/felhom-hub/internal/semver"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// R-1159 (hub v0.161.0, operator decision D19 = „box key first, then lock", 2026-10-19) — what the household's
// retrieval passphrase may still get.
//
// MEASURED 2026-10-19 (audits/day-2026-10-19b/P2/passphrase-reach.txt): the passphrase alone returned the controller's
// hub key (GET /config, GET /recovery) and the existing host's key (POST /host-enroll), and through them every off-site
// key route and the host routes. The passphrase is needed by a NEW box: the installer (preflight, its download login,
// enroll), and the new guest's first boot (bootstrap.json → GET /config, which is how the box gets its own key). And,
// until controller 0.334.0, by every LIVE box on each config change (report.ConfigRefresher → GET /config).
//
// THE RULE. The passphrase gives keys only until the box has a key of its own and uses it:
// - no host record → open (a first install; a reinstall after the operator deleted the old host);
// - a host record, and no controller report received since the record was made → open (the install in progress,
// its --resume, the new guest's first boot);
// - a host record, and a controller report received since it was made from a controller that pulls its config with
// its OWN key (boxConfigMinController and newer, GET /box-config) → CLOSED: /config and /recovery answer 409 with
// no config, /host-enroll answers 409 with no key, and the operator gets passphrase_keys_refused (warning, mailed).
// A reinstall then needs the operator's delete first — the rule since R-1154.
// - a controller OLDER than boxConfigMinController still refreshes with the passphrase, so it stays open for it
// (the lock arms itself box by box as the floor moves; no ordering between the hub and controller releases).
//
// GET /box-config/{id} — the box's own config pull, authenticated with the controller's hub key (self-scoped, or the
// global key), the same generated controller.yaml as GET /config.
//
// Pinned by r1159_passphrase_scope_test.go.
const (
// boxConfigMinController is the first controller that refreshes its config with its own key (GET /box-config).
boxConfigMinController = "0.334.0"
eventPassphraseKeysRefused = "passphrase_keys_refused"
)
// passphraseKeysClosed applies THE RULE above. A failed lookup is closed (fail-safe: a key is a key).
func (h *Handler) passphraseKeysClosed(customerID string) (closed bool, why string) {
host, err := h.store.GetHostByCustomer(customerID)
if err != nil {
return true, "the host lookup failed: " + err.Error()
}
if host == nil {
return false, "no host record"
}
found, at, ver, err := h.store.LatestControllerReport(customerID)
if err != nil {
return true, "the controller report lookup failed: " + err.Error()
}
if !found || at.Before(host.CreatedAt) {
return false, "the box's controller has not reported since host " + host.HostID + " was made"
}
if !semver.Valid(ver) || semver.Compare(ver, boxConfigMinController) < 0 {
return false, "the box's controller (" + ver + ") still refreshes its config with the passphrase"
}
return true, "host " + host.HostID + " is live: its controller " + ver + " reported at " + at.UTC().Format("2006-01-02 15:04 UTC") +
" and pulls its config with its own key"
}
// refusePassphraseKeys answers 409 with a plain sentence (no config, no key) and tells the operator.
func (h *Handler) refusePassphraseKeys(w http.ResponseWriter, r *http.Request, customerID, route, why string) {
h.logger.Printf("[WARN] %s for %s with the retrieval passphrase REFUSED (R-1159): %s — visitor %s", route, customerID, why, r.RemoteAddr)
msg := "Someone with the household's retrieval passphrase asked " + route + " for the box keys of " + customerID +
"; refused because " + why + ". If this is a reinstall: delete the old host (Hosts page), then run the installer again. " +
"If nobody in the household did this, the passphrase may be known to someone else."
details, _ := json.Marshal(map[string]string{"route": route, "reason": why})
if _, err := h.store.SaveEvent(customerID, eventPassphraseKeysRefused, "warning", msg, string(details), "hub"); err != nil {
h.logger.Printf("[WARN] save %s for %s: %v", eventPassphraseKeysRefused, customerID, err)
} else if h.dispatcher != nil {
go h.dispatcher.ProcessEvent(customerID, eventPassphraseKeysRefused, "warning", msg, string(details), "hub")
}
http.Error(w, "Conflict: this customer already has a live box, which uses its own key. The passphrase gives no keys now. "+
"For a reinstall the operator deletes the old host first.", http.StatusConflict)
}
// handleBoxConfig — GET /box-config/{id}: the box's own config pull with its controller key (controller ≥ 0.334.0).
func (h *Handler) handleBoxConfig(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
}
cfg, err := h.store.GetCustomerConfig(customerID)
if err != nil {
h.logger.Printf("[ERROR] box-config: customer lookup failed for %s: %v", customerID, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
if cfg == nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
if cfg.SecretsUnreadable {
h.logger.Printf("[ERROR] customer %s: sealed secrets do not open (OFFSITE_SECRET_KEY)", cfg.CustomerID)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
h.serveGeneratedConfig(w, cfg, "box key")
}
// serveGeneratedConfig writes the generated controller.yaml (GET /config and GET /box-config). The claim code is
// issued here (idempotent) — the real config pull is the Day-0 claim entry point.
func (h *Handler) serveGeneratedConfig(w http.ResponseWriter, cfg *store.CustomerConfig, by string) {
if h.templateProvider == nil {
http.Error(w, "Config generation not available", http.StatusServiceUnavailable)
return
}
var claimState *store.ClaimState
if h.claimEngine != nil {
var cerr error
claimState, cerr = h.claimEngine.EnsureIssued(cfg)
if cerr != nil {
// Loud but non-fatal: the config is still served; if a hash was stored the gate is armed
// and the operator resends the email from the customer page.
h.logger.Printf("[WARN] claim issue for %s on config retrieve: %v", cfg.CustomerID, cerr)
}
}
start := time.Now()
yamlOutput, err := configgen.Generate(h.templateProvider.Template(), cfg, claimState)
if err != nil {
h.logger.Printf("[ERROR] Failed to generate config for %s: %v", cfg.CustomerID, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
h.logger.Printf("[INFO] Config downloaded for customer %s (by %s, %s)", cfg.CustomerID, by, time.Since(start).Round(time.Millisecond))
w.Header().Set("Content-Type", "text/yaml; charset=utf-8")
w.Write([]byte(yamlOutput))
}