hub v0.161.0: passphrase keys only until the box uses its own (box-config route; R-1159, D19); ep0 namespaces without a prune job alarmed, tenantsync v1.3.0 creates the job (R-1165)
gates / gates (push) Successful in 7m47s

This commit is contained in:
admin committed 2026-10-19 15:55:45 +02:00
1 parent 224fe41e06
commit b6a911c3f9
16 files changed
+802 -35

No files matched your search

+19 -28
View File
@@ -408,6 +408,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
case r.Method == http.MethodGet && strings.HasPrefix(path, "/config/"):
customerID := strings.TrimPrefix(path, "/config/")
h.handleConfigRetrieve(w, r, customerID)
case r.Method == http.MethodGet && strings.HasPrefix(path, "/box-config/"):
h.handleBoxConfig(w, r, strings.TrimPrefix(path, "/box-config/"))
case r.Method == http.MethodPost && strings.HasPrefix(path, "/offsite/consume-password/"):
customerID := strings.TrimPrefix(path, "/offsite/consume-password/")
h.handleOffsiteConsumePassword(w, r, customerID)
@@ -1239,6 +1241,11 @@ func (h *Handler) handleHostEnroll(w http.ResponseWriter, r *http.Request) {
return
}
h.noteReenrollOverExistingHost(cc, existing, time.Now())
// R-1159: once the box uses its own key, the passphrase no longer re-serves the host key.
if closed, why := h.passphraseKeysClosed(req.CustomerID); closed {
h.refusePassphraseKeys(w, r, req.CustomerID, "POST /host-enroll", why)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"host_id": existing.HostID, "api_key": existing.APIKey})
@@ -2824,6 +2831,10 @@ func (h *Handler) handleRecovery(w http.ResponseWriter, r *http.Request, custome
http.Error(w, "Unauthorized: invalid password", http.StatusUnauthorized)
return
}
if closed, why := h.passphraseKeysClosed(customerID); closed {
h.refusePassphraseKeys(w, r, customerID, "GET /recovery", why)
return
}
// Generate controller.yaml. The claim state is baked read-only (no issue on the DR path — a
// recovered box whose settings.json is gone re-gates on the EXISTING hash; the reset flow
@@ -2897,36 +2908,16 @@ func (h *Handler) handleConfigRetrieve(w http.ResponseWriter, r *http.Request, c
return
}
if h.templateProvider == nil {
http.Error(w, "Config generation not available", http.StatusServiceUnavailable)
// R-1159: the passphrase gives keys only until the box uses its own (passphrase_scope.go).
if closed, why := h.passphraseKeysClosed(customerID); closed {
h.refusePassphraseKeys(w, r, customerID, "GET /config", why)
return
}
// Customer-claim arc (v0.50.0): the REAL config pull (Day-0 installer / controller refresh) is
// the Day-0 claim entry point — issue + email the first code here (idempotent), and bake the
// active hash into the generated web.claim_code_hash so the box is gated from FIRST boot.
// (The operator-UI preview deliberately does NOT issue — it only bakes an existing hash.)
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", customerID, cerr)
}
}
yamlOutput, err := configgen.Generate(h.templateProvider.Template(), cfg, claimState)
if err != nil {
h.logger.Printf("[ERROR] Failed to generate config for %s: %v", customerID, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
h.logger.Printf("[INFO] Config downloaded for customer %s", customerID)
w.Header().Set("Content-Type", "text/yaml; charset=utf-8")
w.Write([]byte(yamlOutput))
// Customer-claim arc (v0.50.0): the REAL config pull (Day-0 installer / controller refresh) is the Day-0 claim
// entry point — serveGeneratedConfig issues + emails the first code (idempotent) and bakes the active hash into
// the generated web.claim_code_hash so the box is gated from FIRST boot. (The operator-UI preview deliberately
// does NOT issue — it only bakes an existing hash.)
h.serveGeneratedConfig(w, cfg, "retrieval passphrase")
}
// artifactManifestResponse is the wire shape the host-bootstrap script consumes: the operator-vouched
+136
View File
@@ -0,0 +1,136 @@
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))
}
@@ -0,0 +1,34 @@
package api
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
// boxConfigMinController must be the controller release that pulls its config with its own key (GET /box-config). If
// it named an earlier one, a box on that controller would be refused its passphrase config refresh while it still
// needs it (its config changes would stop arriving). Read from the controller's CHANGELOG beside this repo (the
// workspace layout; a CI clone has no sibling, and says so).
func TestR1159_BoxConfigMinControllerIsTheReleaseThatPullsWithItsKey(t *testing.T) {
p := filepath.Join("..", "..", "..", "..", "felhom-controller", "CHANGELOG.md")
b, err := os.ReadFile(p)
if err != nil {
t.Skipf("NOT CHECKED: the controller clone is not beside this repo (%v) — the workspace run checks it", err)
}
m := regexp.MustCompile(`(?m)^## v` + regexp.QuoteMeta(boxConfigMinController) + ` .*$`).FindString(string(b))
if m == "" {
t.Fatalf("the controller CHANGELOG has no `## v%s` heading — boxConfigMinController names a release that does not exist", boxConfigMinController)
}
if !strings.Contains(m, "box-config") && !strings.Contains(m, "own key") {
t.Fatalf("controller v%s's heading does not say it pulls its config with its own key: %s", boxConfigMinController, m)
}
// And no EARLIER release may claim it (then the constant would be too high, and the lock would arm late).
for _, h := range regexp.MustCompile(`(?m)^## v(\d+\.\d+\.\d+) .*$`).FindAllStringSubmatch(string(b), -1) {
if h[1] != boxConfigMinController && strings.Contains(h[0], "box-config") {
t.Errorf("controller v%s also names box-config: %s", h[1], h[0])
}
}
}
@@ -0,0 +1,198 @@
package api
import (
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
"gopkg.in/yaml.v3"
)
// R-1159 (operator decision D19, 2026-10-19): the household's retrieval passphrase gives keys only until the box uses
// its own. The measurement of 2026-10-19 (audits/day-2026-10-19b/P2/) turned into the regression: the same calls, now
// with the answer each must get.
type r1159Tpl struct{}
func (r1159Tpl) Template() string { return "hub: {}\n" }
type r1159Fixture struct {
h *Handler
st *store.Store
db *sql.DB
}
func newR1159(t *testing.T) *r1159Fixture {
t.Helper()
h, st, path := newTestHandler(t)
h.templateProvider = r1159Tpl{}
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey-1159", RetrievalPassword: "owner-pass"}); err != nil {
t.Fatal(err)
}
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c2", APIKey: "ckey-other", RetrievalPassword: "other-pass"}); err != nil {
t.Fatal(err)
}
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
return &r1159Fixture{h: h, st: st, db: db}
}
func (f *r1159Fixture) exec(t *testing.T, q string, args ...any) {
t.Helper()
if _, err := f.db.Exec(q, args...); err != nil {
t.Fatalf("%s: %v", q, err)
}
}
func (f *r1159Fixture) host(t *testing.T) {
t.Helper()
if err := f.st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY-1159"}); err != nil {
t.Fatal(err)
}
f.exec(t, `UPDATE hosts SET created_at = datetime('now', '-1 hour') WHERE host_id = 'h1'`)
}
// report stores a controller report `ago` SQLite modifier old (e.g. "-5 minutes") naming version.
func (f *r1159Fixture) report(t *testing.T, version, ago string) {
t.Helper()
if err := f.st.SaveReport("c1", []byte(`{"customer_id":"c1","controller_version":"`+version+`"}`)); err != nil {
t.Fatal(err)
}
f.exec(t, `UPDATE reports SET received_at = datetime('now', ?) WHERE id = (SELECT max(id) FROM reports)`, ago)
}
func (f *r1159Fixture) refusals(t *testing.T) int {
t.Helper()
var n int
if err := f.db.QueryRow(`SELECT count(*) FROM events WHERE customer_id='c1' AND event_type=?`, eventPassphraseKeysRefused).Scan(&n); err != nil {
t.Fatal(err)
}
return n
}
// keysByPassphrase makes the three passphrase calls that ever returned a key and reports, per route, the status and
// whether a key came back.
func (f *r1159Fixture) keysByPassphrase(t *testing.T) map[string][2]any {
t.Helper()
out := map[string][2]any{}
rr := r879Get(f.h, "/config/c1", "owner-pass")
var y map[string]any
_ = yaml.Unmarshal(rr.Body.Bytes(), &y)
key := ""
if hm, ok := y["hub"].(map[string]any); ok {
key, _ = hm["api_key"].(string)
}
out["config"] = [2]any{rr.Code, key == "ckey-1159"}
rr = r879Get(f.h, "/recovery/c1", "owner-pass")
out["recovery"] = [2]any{rr.Code, strings.Contains(rr.Body.String(), "ckey-1159")}
er := doEnroll(f.h, "c1", "owner-pass")
var e enrollResp
_ = json.Unmarshal(er.Body.Bytes(), &e)
out["enroll"] = [2]any{er.Code, e.APIKey != ""}
return out
}
// THE RULE, case by case.
//
// COMPANION RED-PROOF (observed 2026-10-19): with passphraseKeysClosed returning false always (the pre-fix shape) the
// "live box on 0.334.0" case fails with
//
// r1159_passphrase_scope_test.go: live box on 0.334.0: config → [200 true], want 409 and no key
//
// Restored.
func TestR1159_PassphraseGivesKeysOnlyUntilTheBoxUsesItsOwn(t *testing.T) {
open := func(t *testing.T, name string, f *r1159Fixture) {
t.Helper()
got := f.keysByPassphrase(t)
if got["config"] != [2]any{200, true} || got["recovery"] != [2]any{200, true} {
t.Errorf("%s: config → %v, recovery → %v, want 200 with the key (the box still needs the passphrase)", name, got["config"], got["recovery"])
}
if c := got["enroll"][0]; c != 200 && c != 201 {
t.Errorf("%s: enroll → %v, want 200/201 with a key", name, got["enroll"])
}
if f.refusals(t) != 0 {
t.Errorf("%s: a refusal event was raised for an allowed call", name)
}
}
t.Run("no host record (a first install)", func(t *testing.T) {
f := newR1159(t)
open(t, "no host", f)
})
t.Run("host made, its controller has not reported (the install, its first boot)", func(t *testing.T) {
f := newR1159(t)
f.host(t)
open(t, "host, no report", f)
})
t.Run("only reports from BEFORE the host record (the old box, a reinstall after the delete)", func(t *testing.T) {
f := newR1159(t)
f.report(t, "0.334.0", "-3 hours")
f.host(t)
open(t, "older report", f)
})
t.Run("live box on an older controller (still refreshes with the passphrase)", func(t *testing.T) {
f := newR1159(t)
f.host(t)
f.report(t, "0.333.0", "-5 minutes")
open(t, "0.333.0", f)
})
t.Run("live box on 0.334.0", func(t *testing.T) {
f := newR1159(t)
f.host(t)
f.report(t, "0.334.0", "-5 minutes")
got := f.keysByPassphrase(t)
for _, route := range []string{"config", "recovery", "enroll"} {
if got[route] != [2]any{http.StatusConflict, false} {
t.Errorf("live box on 0.334.0: %s → %v, want 409 and no key", route, got[route])
}
}
if n := f.refusals(t); n != 3 {
t.Errorf("want one passphrase_keys_refused per refused call (3), got %d", n)
}
rr := r879Get(f.h, "/config/c1", "owner-pass")
if !strings.Contains(rr.Body.String(), "deletes the old host first") || strings.Contains(rr.Body.String(), "ckey-1159") {
t.Errorf("the refusal must say what to do and carry no key: %q", rr.Body.String())
}
// A wrong passphrase is still a plain 401, never the live-box answer (it must not tell a guesser anything).
if c := r879Get(f.h, "/config/c1", "nope").Code; c != http.StatusUnauthorized {
t.Errorf("a wrong passphrase on a live box → %d, want 401", c)
}
})
}
// GET /box-config/{id}: the box's own pull, with its controller key only.
func TestR1159_BoxPullsItsConfigWithItsOwnKey(t *testing.T) {
f := newR1159(t)
f.host(t)
f.report(t, "0.334.0", "-5 minutes")
get := func(id, bearer string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, "/api/v1/box-config/"+id, nil)
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
rr := httptest.NewRecorder()
f.h.ServeHTTP(rr, req)
return rr
}
if rr := get("c1", "ckey-1159"); rr.Code != 200 || !strings.Contains(rr.Body.String(), "ckey-1159") {
t.Fatalf("own key → %d, want 200 with the config", rr.Code)
}
for name, c := range map[string]struct{ id, key string }{
"no key": {"c1", ""}, "a wrong key": {"c1", "nope"}, "another household's key": {"c1", "ckey-other"},
"the passphrase as a bearer": {"c1", "owner-pass"},
} {
if rr := get(c.id, c.key); rr.Code != http.StatusUnauthorized || strings.Contains(rr.Body.String(), "ckey-1159") {
t.Errorf("%s → %d, want 401 and no config", name, rr.Code)
}
}
if f.refusals(t) != 0 {
t.Error("the box's own pull raised a refusal event")
}
}
+1
View File
@@ -68,6 +68,7 @@ var apiRoutes = []apiRoute{
{Method: "GET", Prefix: "/customers/", Auth: authGlobal},
{Method: "GET", Prefix: "/recovery/", Auth: authPassphrase},
{Method: "GET", Prefix: "/config/", Auth: authPassphrase},
{Method: "GET", Prefix: "/box-config/", Auth: authCustomer},
{Method: "POST", Prefix: "/offsite/consume-password/", Auth: authCustomer},
{Method: "POST", Prefix: "/offsite/register-key/", Auth: authCustomer},
{Method: "POST", Prefix: "/offsite/confirm-key/", Auth: authCustomer},