diff --git a/CHANGELOG.md b/CHANGELOG.md index 8024552..bde61b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +## v0.334.0 — a live box refreshes its config with its own key (box-config, R-1159, operator decision D19) (2026-10-19) + +**MinAgent: 0.158.1** — the floor's, carried; no agent change. No golden (`09` §3 decision 250). Needs hub ≥ 0.161.0 +for the new route; against an older hub it falls back to the passphrase, as before. + +- **The config refresh uses the box's OWN hub key** (R-1159, D19 „box key first, then lock"). On each config change + (the report ACK's `config_version`) the controller re-pulled `controller.yaml` with the household's retrieval + passphrase from `bootstrap.json`. It now pulls `GET /api/v1/box-config/{id}` with its hub key + (`report.PullConfigPreferKey`); the passphrase only when the hub has no such route (404/405) or the box holds no key + yet; a refused key is returned as it is, never retried with the passphrase. Hub 0.161.0 then refuses the + passphrase's keys for a box whose controller is this version or newer and has reported. The first-boot pull (no key + yet) is unchanged. Tests `TestR1159_RefreshUsesTheBoxKey` (new hub / old hub / no key / refused key) — red-proved + with the old pull (0 key pulls) — and `TestR1159_MainWiresTheKeyFirstRefresh` (the live refresh in `main.go` calls + it). + ## v0.333.0 — the file manager's own pass ends with the dashboard sign-out (R-1155); the recovery-code page says the code becomes live after the check (R-1161) (2026-10-19) **MinAgent: 0.158.1** — the floor's, carried. Neither change needs a new agent (R-1161's two texts are chosen by the diff --git a/controller/README.md b/controller/README.md index 8ac5c08..cbc463b 100644 --- a/controller/README.md +++ b/controller/README.md @@ -3155,6 +3155,10 @@ bumped on every config save; felhom-hub v0.26.0). This is how an operator config box — **the hub never connects into the box** (it replaced the retired inbound "Push Config"). Wired in `OnPushResponse` beside the floor reconcile (`internal/report/config_refresh.go`, `ConfigRefresher.Reconcile`): +**Since v0.334.0 (R-1159) the re-pull uses the box's OWN hub key** — `GET /api/v1/box-config/{id}` (hub ≥ 0.161.0), +`report.PullConfigPreferKey`; the retrieval passphrase only against a hub without that route or before the box has a +key. The hub refuses the passphrase's keys once such a box has reported. + - **Changed** vs. the last-applied version (`settings.applied_config_version`) → `bootstrap.RefreshConfig` re-pulls `controller.yaml` from the hub and rewrites it, **re-merging the per-guest `local_api`** from `bootstrap.json` (reuses the first-boot pull machinery; overwrites `controller.yaml` since the hub is its diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index 6efc11b..038703a 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -1173,7 +1173,18 @@ func main() { cr := &report.ConfigRefresher{ Applied: sett.GetAppliedConfigVersion, Record: sett.SetAppliedConfigVersion, - Refresh: func() error { return bootstrap.RefreshConfig(*configPath, logger, pull) }, + // R-1159 (v0.334.0): a live box refreshes with its OWN hub key (GET /box-config); the retrieval + // passphrase only against a hub without that route. The hub then refuses the passphrase's keys once + // this box has reported. + Refresh: func() error { + return bootstrap.RefreshConfig(*configPath, logger, func(hubURL, customerID, rp string) (string, error) { + y, perr := report.PullConfigPreferKey(hubURL, customerID, cfg.Hub.APIKey, rp, logger.Printf) + if perr != nil && errors.Is(perr, report.ErrHubUnreachable) { + return "", fmt.Errorf("%w: %w", bootstrap.ErrPullTransient, perr) + } + return y, perr + }) + }, Restart: func() { api.GracefulSelfRestart(logger) }, Logger: logger, } diff --git a/controller/internal/report/config_pull.go b/controller/internal/report/config_pull.go index 900aaf9..40e6fe8 100644 --- a/controller/internal/report/config_pull.go +++ b/controller/internal/report/config_pull.go @@ -59,3 +59,57 @@ func PullConfig(hubURL, customerID, retrievalPassword string) (string, error) { return string(body), nil } + +// ErrBoxConfigUnsupported: the hub has no GET /box-config (a hub before v0.161.0) — the caller falls back to the +// passphrase pull. +var ErrBoxConfigUnsupported = errors.New("hub has no box-config route") + +// PullBoxConfig fetches the generated controller.yaml with the box's OWN hub key (GET /api/v1/box-config/{id}, hub ≥ +// v0.161.0; R-1159). A live box refreshes its config this way, so the hub can refuse the household's retrieval +// passphrase once the box is live (hub `api/passphrase_scope.go`). +func PullBoxConfig(hubURL, customerID, apiKey string) (string, error) { + url := strings.TrimRight(hubURL, "/") + "/api/v1/box-config/" + customerID + client := &http.Client{Timeout: 30 * time.Second} + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return "", fmt.Errorf("%w: %v", ErrHubError, err) + } + req.Header.Set("Authorization", "Bearer "+apiKey) + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("%w: %v", ErrHubUnreachable, err) + } + defer resp.Body.Close() + switch resp.StatusCode { + case http.StatusOK: + case http.StatusUnauthorized: + return "", ErrAuthFailed + case http.StatusNotFound, http.StatusMethodNotAllowed: + return "", ErrBoxConfigUnsupported + default: + return "", fmt.Errorf("%w: HTTP %d", ErrHubError, resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return "", fmt.Errorf("%w: reading response: %v", ErrHubError, err) + } + return string(body), nil +} + +// PullConfigPreferKey is the LIVE box's config refresh (report.ConfigRefresher): with the box's own key first; the +// retrieval passphrase only when the hub has no box-config route (a hub before v0.161.0) or the box holds no key yet. +// Any other answer to the key pull is returned as it is — never retried with the passphrase (R-1159: the passphrase +// is for a box that has no key of its own). Pinned by TestR1159_RefreshUsesTheBoxKey. +func PullConfigPreferKey(hubURL, customerID, apiKey, retrievalPassword string, logf func(string, ...any)) (string, error) { + if apiKey != "" { + y, err := PullBoxConfig(hubURL, customerID, apiKey) + if !errors.Is(err, ErrBoxConfigUnsupported) { + if err == nil { + logf("[INFO] [config] refreshed controller.yaml with the box's own hub key (box-config)") + } + return y, err + } + logf("[INFO] [config] the hub has no box-config route (hub < 0.161.0) — refreshing with the retrieval passphrase") + } + return PullConfig(hubURL, customerID, retrievalPassword) +} diff --git a/controller/internal/report/r1159_box_config_test.go b/controller/internal/report/r1159_box_config_test.go new file mode 100644 index 0000000..e1085d7 --- /dev/null +++ b/controller/internal/report/r1159_box_config_test.go @@ -0,0 +1,120 @@ +package report + +import ( + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +// R-1159 (controller v0.334.0): a live box refreshes its config with its OWN hub key; the retrieval passphrase is sent +// only to a hub that has no box-config route, or when the box holds no key yet — and never as a retry after the hub +// refused the key. +// +// COMPANION RED-PROOF (observed 2026-10-19): with PullConfigPreferKey reduced to the old PullConfig (the pre-fix +// refresh) the "new hub" case fails with +// +// r1159_box_config_test.go: new hub: "hub:\n api_key: box-key\n" (key pulls 0), want the config by the box key +// +// Restored. +func TestR1159_RefreshUsesTheBoxKey(t *testing.T) { + type seen struct { + mu sync.Mutex + passphrase, bearer int + } + hub := func(boxConfig int) (*httptest.Server, *seen) { + s := &seen{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + switch { + case strings.HasPrefix(r.URL.Path, "/api/v1/box-config/"): + if boxConfig == 0 { + http.NotFound(w, r) + return + } + s.bearer++ + if r.Header.Get("Authorization") != "Bearer box-key" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if boxConfig != 200 { + http.Error(w, "x", boxConfig) + return + } + w.Write([]byte("hub:\n api_key: box-key\n")) + case strings.HasPrefix(r.URL.Path, "/api/v1/config/"): + s.passphrase++ + if r.Header.Get("X-Retrieval-Password") != "owner-pass" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + w.Write([]byte("hub:\n api_key: box-key\n")) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + return srv, s + } + logf := func(string, ...any) {} + + srv, s := hub(200) + y, err := PullConfigPreferKey(srv.URL, "c1", "box-key", "owner-pass", logf) + if err != nil || !strings.Contains(y, "api_key") || s.bearer != 1 { + t.Fatalf("new hub: %q %v (key pulls %d), want the config by the box key", y, err, s.bearer) + } + if s.passphrase != 0 { + t.Fatalf("new hub: the passphrase was sent (%d time(s)) — a live box must refresh with its own key", s.passphrase) + } + + srv, s = hub(0) + if y, err := PullConfigPreferKey(srv.URL, "c1", "box-key", "owner-pass", logf); err != nil || y == "" || s.passphrase != 1 { + t.Fatalf("old hub (no route): %v, passphrase pulls %d — want the fallback", err, s.passphrase) + } + + srv, s = hub(200) + if _, err := PullConfigPreferKey(srv.URL, "c1", "", "owner-pass", logf); err != nil || s.passphrase != 1 || s.bearer != 0 { + t.Fatalf("no key yet: %v, passphrase %d bearer %d — want the passphrase pull", err, s.passphrase, s.bearer) + } + + for _, code := range []int{http.StatusUnauthorized, http.StatusInternalServerError} { + srv, s = hub(code) + key := "box-key" + if code == http.StatusUnauthorized { + key = "wrong-key" + } + _, err := PullConfigPreferKey(srv.URL, "c1", key, "owner-pass", logf) + if err == nil || s.passphrase != 0 { + t.Fatalf("key refused (%d): err=%v passphrase pulls %d — the passphrase must not be the retry", code, err, s.passphrase) + } + if code == http.StatusUnauthorized && !errors.Is(err, ErrAuthFailed) { + t.Errorf("a refused key must read as ErrAuthFailed, got %v", err) + } + } +} + +// The refresh the report ACK drives (cmd/controller main.go, ConfigRefresher.Refresh) must be the key-first pull — a +// PullConfigPreferKey nobody calls would leave every live box on the passphrase (the seam-never-wired shape). +func TestR1159_MainWiresTheKeyFirstRefresh(t *testing.T) { + b, err := os.ReadFile(filepath.Join("..", "..", "cmd", "controller", "main.go")) + if err != nil { + t.Fatalf("main.go: %v", err) + } + src := string(b) + i := strings.Index(src, "cr := &report.ConfigRefresher{") + if i < 0 { + t.Fatal("main.go no longer builds a report.ConfigRefresher — find the live refresh and pin it here") + } + block := src[i:] + if j := strings.Index(block, "cr.Reconcile("); j > 0 { + block = block[:j] + } + if !strings.Contains(block, "report.PullConfigPreferKey(") || !strings.Contains(block, "cfg.Hub.APIKey") { + t.Fatalf("the live config refresh does not use report.PullConfigPreferKey with the box's hub key:\n%s", block) + } +}