137 lines
7.0 KiB
Go
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))
|
|
}
|