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)) }