diff --git a/CHANGELOG.md b/CHANGELOG.md index 94856479..b62beb47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,14 @@ night report (`audits/night-2026-10-28/`). `LoadKeptOffsite`) return `ErrRestoreBusy` with nothing started, and `LoadKeptApp` takes the dated folder's file move as `prepare`, inside the operation. The install-time „use my kept data" asks about the second-drive copy too (`RestoreBusy`, was RestoreStatus/IsRunning only). Tests `TestR1256_H6_*` (backup, web, api). +- **R-1254 H2, the controller half (felhom.eu R-1254; agent `5ddd124`):** the escrow claim and confirm reach the agent + with their own 60 s timeout (`agentapi.escrowCallTimeout`; was the client's 15 s) — the agent may spend its 30 s + upload plus 15 s asking the hub before it answers, and a claim abandoned at 15 s could still store a package whose + code reached nobody. The confirm's household sentence: 502 (the hub holds another package) keeps „…a régi kódod + továbbra is érvényes…"; 504 (the agent cannot learn the outcome) or a failed call (0) now reads + `api.escrow.confirm_store_unknown` („Nem tudjuk biztosan… Őrizd meg mindkét kódot…", hu + en) and never promises the + previous code. `TestR1151_ConfirmAnswersInTheBoxLanguage`'s status-0 case changes with it, on purpose. Tests + `TestR1254_*` (agentapi, web), red before the fix. - **H8 — `internal/backup/restore_copy_sweep.go`:** an unused restore copy goes after 14 days. Every 6 hours (`restore-copy-sweep`) a copy is removed only when the app has no restore hold of any kind, the app-stop marker is readable and does not name the app, no restore runs, and the copy is older than 14 days. Tests `TestR1256_H8_*` diff --git a/controller/internal/agentapi/client.go b/controller/internal/agentapi/client.go index d34d03a7..09b45f12 100644 --- a/controller/internal/agentapi/client.go +++ b/controller/internal/agentapi/client.go @@ -1071,6 +1071,18 @@ func (c *Client) RemoveNetStorage(ctx context.Context, name string) error { // status, even on a non-2xx (so callers like FormatDisk can read a 403 refusal body). A transport or // envelope-parse failure is still an error; an `ok:false` business refusal is NOT (the data carries it). func (c *Client) postWithStatus(ctx context.Context, path string, body any) (apiResponse, int, error) { + return c.postWithStatusOn(c.hc, ctx, path, body) +} + +// postWithStatusTimeout is postWithStatus with its own overall timeout (the escrow claim and confirm, R-1254). It shares +// the pinned transport. +func (c *Client) postWithStatusTimeout(ctx context.Context, path string, body any, timeout time.Duration) (apiResponse, int, error) { + hc := *c.hc + hc.Timeout = timeout + return c.postWithStatusOn(&hc, ctx, path, body) +} + +func (c *Client) postWithStatusOn(hc *http.Client, ctx context.Context, path string, body any) (apiResponse, int, error) { var env apiResponse buf, err := json.Marshal(body) if err != nil { @@ -1083,7 +1095,7 @@ func (c *Client) postWithStatus(ctx context.Context, path string, body any) (api req.Header.Set("Authorization", "Bearer "+c.token) req.Header.Set("Content-Type", "application/json") start := time.Now() - resp, err := c.hc.Do(req) + resp, err := hc.Do(req) if err != nil { logx.Debugf(c.logger, "[agentapi] POST %s failed after %dms: %v", path, time.Since(start).Milliseconds(), err) return env, 0, fmt.Errorf("agentapi: POST %s: %w", path, err) diff --git a/controller/internal/agentapi/escrow.go b/controller/internal/agentapi/escrow.go index 663d6a00..a96a0138 100644 --- a/controller/internal/agentapi/escrow.go +++ b/controller/internal/agentapi/escrow.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "net/http" + "time" ) // Controller-driven escrow ceremony client methods (v0.127.0, agent ≥ v0.88.0). The claim call @@ -108,8 +109,15 @@ type EscrowClaim struct { // // R-1151: the claim asks the agent to keep the new package until the household typed the code back // (`defer_upload`). An agent older than 0.160.0 ignores the body and uploads as before. +// escrowCallTimeout bounds the claim and the confirm (R-1254 H2). The agent may take its whole upload timeout (30 s) +// plus its question to the hub (15 s) before it answers; the client's own 15 s used to abandon the call while the +// agent went on — a claim abandoned that way could still store the package, and its code reached nobody. 60 s +// outlasts both (TestR1254_ClaimAndConfirmOutwaitTheAgentsUpload; the agent side: escrowUploadTimeout, +// escrowHoldTimeout in felhom-agent internal/localapi/escrow_ceremony.go — if either grows, this must too). +var escrowCallTimeout = 60 * time.Second + func (c *Client) EscrowCeremonyClaim(ctx context.Context) (EscrowClaim, int, error) { - env, status, err := c.postWithStatus(ctx, "/escrow/ceremony/claim", map[string]bool{"defer_upload": true}) + env, status, err := c.postWithStatusTimeout(ctx, "/escrow/ceremony/claim", map[string]bool{"defer_upload": true}, escrowCallTimeout) if err != nil { return EscrowClaim{}, status, err } @@ -141,7 +149,7 @@ func (c *Client) EscrowCeremonyClaim(ctx context.Context) (EscrowClaim, int, err // match (still waiting), 410 the window ran out, 404 nothing waits (superseded, restarted), 502 the hub failed (still // waiting). The words are part of the recovery code — never logged. func (c *Client) EscrowCeremonyConfirm(ctx context.Context, words []string) (int, error) { - env, status, err := c.postWithStatus(ctx, "/escrow/ceremony/confirm", map[string][]string{"words": words}) + env, status, err := c.postWithStatusTimeout(ctx, "/escrow/ceremony/confirm", map[string][]string{"words": words}, escrowCallTimeout) if err != nil { return status, err } diff --git a/controller/internal/agentapi/r1254_escrow_timeout_test.go b/controller/internal/agentapi/r1254_escrow_timeout_test.go new file mode 100644 index 00000000..9b675d95 --- /dev/null +++ b/controller/internal/agentapi/r1254_escrow_timeout_test.go @@ -0,0 +1,42 @@ +package agentapi + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// R-1254 H2: the agent's claim/confirm may take its whole upload timeout (30 s) plus the hub question (15 s) before it +// answers. The controller's agent client gives up at 15 s — and a claim the controller abandons can still complete on +// the agent: the hub then holds a package whose code nobody saw. The two calls carry their own, longer timeout. +// RED-PROOF: send claim/confirm through the plain 15 s client again → the slow agent below times them out → FAILS. +func TestR1254_ClaimAndConfirmOutwaitTheAgentsUpload(t *testing.T) { + if escrowCallTimeout < 45*time.Second+10*time.Second { + t.Fatalf("escrowCallTimeout %s must outlast the agent's upload (30 s) + hub question (15 s) with room", escrowCallTimeout) + } + mux := http.NewServeMux() + mux.HandleFunc("POST /escrow/ceremony/claim", func(w http.ResponseWriter, r *http.Request) { + time.Sleep(300 * time.Millisecond) + _, _ = w.Write([]byte(`{"ok":true,"data":{"recovery_code":"a-b-c"}}`)) + }) + mux.HandleFunc("POST /escrow/ceremony/confirm", func(w http.ResponseWriter, r *http.Request) { + time.Sleep(300 * time.Millisecond) + _, _ = w.Write([]byte(`{"ok":true,"data":{"uploaded":true}}`)) + }) + s := httptest.NewTLSServer(mux) + defer s.Close() + c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://")) + c.hc.Timeout = 100 * time.Millisecond // the client's own short timeout, scaled down + old := escrowCallTimeout + escrowCallTimeout = 5 * time.Second + defer func() { escrowCallTimeout = old }() + if claim, st, err := c.EscrowCeremonyClaim(context.Background()); err != nil || st != http.StatusOK || claim.Code != "a-b-c" { + t.Fatalf("a slow claim was abandoned: %+v %d %v", claim, st, err) + } + if st, err := c.EscrowCeremonyConfirm(context.Background(), []string{"a", "c"}); err != nil || st != http.StatusOK { + t.Fatalf("a slow confirm was abandoned: %d %v", st, err) + } +} diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index 22e3890c..39780db2 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -2672,6 +2672,7 @@ "api.escrow.confirm_expired": "The time to type the new code back has run out — your previous code still works. Start the process again.", "api.escrow.confirm_lost": "The new code no longer waits for confirmation — your previous code still works. Start the process again.", "api.escrow.confirm_store_failed": "The new code could not be saved — your previous code still works. Try again.", + "api.escrow.confirm_store_unknown": "We cannot tell yet whether the new code was saved — the server did not answer in time. Keep both codes (the old one and the new one) and try again.", "api.escrow.confirm_words_mismatch": "The words do not match — check the code you wrote down.", "api.share.form_invalid": "Invalid form — reload the page.", "api.share.too_many_attempts": "Too many failed attempts, try again in 1 minute", diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index 0c139783..805529ee 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -2660,6 +2660,7 @@ "api.escrow.confirm_expired": "Lejárt az idő az új kód visszaírására — a régi kódod továbbra is érvényes. Indítsd újra a folyamatot.", "api.escrow.confirm_lost": "Az új kód már nem vár megerősítésre — a régi kódod továbbra is érvényes. Indítsd újra a folyamatot.", "api.escrow.confirm_store_failed": "Az új kódot nem sikerült elmenteni — a régi kódod továbbra is érvényes. Próbáld újra.", + "api.escrow.confirm_store_unknown": "Nem tudjuk biztosan, hogy az új kód mentése sikerült-e — a központ nem válaszolt időben. Őrizd meg mindkét kódot (a régit és az újat is), és próbáld újra.", "api.escrow.confirm_words_mismatch": "A megadott szavak nem egyeznek — ellenőrizd a felírt kódot.", "api.share.form_invalid": "Érvénytelen űrlap — töltsd újra az oldalt.", "api.share.too_many_attempts": "Túl sok sikertelen próbálkozás, próbáld újra 1 perc múlva", diff --git a/controller/internal/web/escrow_handlers.go b/controller/internal/web/escrow_handlers.go index 71d67448..5d882541 100644 --- a/controller/internal/web/escrow_handlers.go +++ b/controller/internal/web/escrow_handlers.go @@ -352,6 +352,12 @@ func (s *Server) escrowConfirmAPIHandler(w http.ResponseWriter, r *http.Request) escrowJSON(w, http.StatusGone, map[string]any{"restart": true}, s.msg(r, "api.escrow.confirm_expired")) case http.StatusNotFound: escrowJSON(w, http.StatusGone, map[string]any{"restart": true}, s.msg(r, "api.escrow.confirm_lost")) + case http.StatusGatewayTimeout, 0: + // R-1254 H2: the agent could not learn which package the hub holds (504), or this call itself failed (0 — it may + // have timed out while the agent still stored the package). Either way the new package MAY be on the hub: no + // promise about the previous code. Only 502 (the hub holds another package) keeps the store-failed sentence. + s.logger.Printf("[WARN] [web] escrow confirm outcome unknown (status %d)", status) + escrowJSON(w, http.StatusGatewayTimeout, nil, s.msg(r, "api.escrow.confirm_store_unknown")) default: s.logger.Printf("[WARN] [web] escrow confirm failed (status %d)", status) escrowJSON(w, http.StatusBadGateway, nil, s.msg(r, "api.escrow.confirm_store_failed")) diff --git a/controller/internal/web/r1151_escrow_confirm_test.go b/controller/internal/web/r1151_escrow_confirm_test.go index 2c023bdb..cfe228b4 100644 --- a/controller/internal/web/r1151_escrow_confirm_test.go +++ b/controller/internal/web/r1151_escrow_confirm_test.go @@ -93,7 +93,9 @@ func TestR1151_ConfirmAnswersInTheBoxLanguage(t *testing.T) { {http.StatusGone, http.StatusGone, true, "Lejárt az idő", "has run out"}, {http.StatusNotFound, http.StatusGone, true, "már nem vár megerősítésre", "no longer waits"}, {http.StatusBadGateway, http.StatusBadGateway, false, "nem sikerült elmenteni", "could not be saved"}, - {0, http.StatusBadGateway, false, "nem sikerült elmenteni", "could not be saved"}, + // R-1254 H2 (2026-10-28): a failed call (0) or an agent that cannot learn the outcome (504) promises nothing. + {0, http.StatusGatewayTimeout, false, "Nem tudjuk biztosan", "cannot tell yet"}, + {http.StatusGatewayTimeout, http.StatusGatewayTimeout, false, "Nem tudjuk biztosan", "cannot tell yet"}, } for _, c := range cases { for _, lang := range []string{"hu", "en"} { diff --git a/controller/internal/web/r1254_escrow_outcome_test.go b/controller/internal/web/r1254_escrow_outcome_test.go new file mode 100644 index 00000000..de10e6b0 --- /dev/null +++ b/controller/internal/web/r1254_escrow_outcome_test.go @@ -0,0 +1,48 @@ +package web + +import ( + "errors" + "net/http" + "strings" + "testing" +) + +// R-1254 H2 (the household's sentence): a confirm whose upload answer is late or lost must not promise „your previous +// code still works" — the hub may hold the new package. Since agent 0.168.0 the agent asks the hub and answers 502 only +// when the hub holds ANOTHER package; 504 = unknown. A controller-side transport failure (status 0 — the call itself +// timed out while the agent may still store) is unknown too. +// RED-PROOF: drop the 504/0 case from escrowConfirmAPIHandler → the default sentence („a régi kódod továbbra is +// érvényes") is shown → FAILS. +func TestR1254_AnUnknownConfirmOutcomePromisesNothing(t *testing.T) { + for _, tc := range []struct { + name string + status int + }{{"agent 504", http.StatusGatewayTimeout}, {"transport failure", 0}} { + t.Run(tc.name, func(t *testing.T) { + h := newEscrowWizardHarness(t) + h.configureOffbox(t, "pending") + h.agent.confirmStatus, h.agent.confirmErr = tc.status, errors.New("late") + for _, lang := range []string{"hu", "en"} { + w := postConfirm(t, h.s, lang, `{"words":["ketto","negy"]}`) + low := strings.ToLower(w.Body.String()) + // ASCII fragments (the accented forms carry these stems): „régi kódod továbbra is érvényes" / „still works". + if strings.Contains(low, "tovabbra is") || strings.Contains(low, "továbbra is") || strings.Contains(low, "still works") { + t.Fatalf("%s: an unknown outcome promised the previous code: %s", lang, w.Body.String()) + } + if !strings.Contains(low, "mindket") && !strings.Contains(low, "mindkét") && !strings.Contains(low, "both") { + t.Fatalf("%s: the sentence must tell the household to keep both codes: %s", lang, w.Body.String()) + } + } + if ceremonyStamped(t, h) { + t.Fatal("an unknown outcome stamped the ceremony completed") + } + }) + } + // Control: the agent says the hub holds another package (502) → the old sentence, now true. + h := newEscrowWizardHarness(t) + h.configureOffbox(t, "pending") + h.agent.confirmStatus, h.agent.confirmErr = http.StatusBadGateway, errors.New("not stored") + if w := postConfirm(t, h.s, "en", `{"words":["ketto","negy"]}`); !strings.Contains(w.Body.String(), "still works") { + t.Fatalf("502 must keep the store-failed sentence: %s", w.Body.String()) + } +} diff --git a/controller/scripts/i18n_go_keys.json b/controller/scripts/i18n_go_keys.json index 72b2c93c..cc393eb9 100644 --- a/controller/scripts/i18n_go_keys.json +++ b/controller/scripts/i18n_go_keys.json @@ -5,6 +5,7 @@ "api.escrow.confirm_expired": "BORN AS A KEY (R-1151, 2026-10-19) -- a NEW sentence, never a Go literal: the wizard's typed-back confirm answer (agent >= 0.160.0 keeps the new package until the words come back). Pinned by TestR1151_ConfirmAnswersInTheBoxLanguage.", "api.escrow.confirm_lost": "BORN AS A KEY (R-1151, 2026-10-19) -- a NEW sentence, never a Go literal: the wizard's typed-back confirm answer (agent >= 0.160.0 keeps the new package until the words come back). Pinned by TestR1151_ConfirmAnswersInTheBoxLanguage.", "api.escrow.confirm_store_failed": "BORN AS A KEY (R-1151, 2026-10-19) -- a NEW sentence, never a Go literal: the wizard's typed-back confirm answer (agent >= 0.160.0 keeps the new package until the words come back). Pinned by TestR1151_ConfirmAnswersInTheBoxLanguage.", + "api.escrow.confirm_store_unknown": "BORN AS A KEY (R-1254, 2026-10-28 night) -- a NEW sentence, never a Go literal: the confirm's outcome is unknown (agent 504 or the call itself failed) -- no promise about the previous code. Pinned by TestR1254_AnUnknownConfirmOutcomePromisesNothing and TestR1151_ConfirmAnswersInTheBoxLanguage.", "waiting.title": "BORN AS A KEY (R-554, 2026-10-12) -- the title of the page an unconfigured box shows while it retries its configuration, replacing the deleted setup wizard. Pinned by internal/bootstrap TestWaitForConfig_ServesWaitingPageThenStarts.", "waiting.body": "BORN AS A KEY (R-554, 2026-10-12) -- the body of that waiting page. Pinned by internal/bootstrap TestWaitForConfig_ServesWaitingPageThenStarts.", "recovery_info.body": "BORN AS A KEY (R-554, 2026-10-12, `09` §3 decision 222) -- the NEW text of recovery-info.txt, pointing at the dashboard's Restore page and the recovery code instead of the deleted setup wizard. The operator approves the Hungarian before release. Pinned by internal/recovery TestRecoveryInfo_PointsAtDashboardNotWizard.",