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
gates / gates (push) Successful in 49s
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user