v0.334.0: a live box refreshes its config with its own hub key (GET /box-config; R-1159, operator D19)
gates / gates (push) Successful in 49s

This commit is contained in:
2026-10-19 15:55:10 +02:00
parent e1a2abf1e3
commit 546eef37bd
5 changed files with 205 additions and 1 deletions
+15
View File
@@ -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
+4
View File
@@ -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
+12 -1
View File
@@ -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,
}
+54
View File
@@ -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)
}
@@ -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" <nil> (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)
}
}