Files
felhom-controller/controller/internal/backup/offbox_recovery_cli.go
T
admin 7c05b59708
gates / gates (push) Successful in 24s
v0.253.0 — errors carry the key of the sentence they are (R-557 slice 2 release B)
179 Hungarian sentences were built deep inside a package with fmt.Errorf and printed by
whoever caught them: too late to translate where they are shown, too early where they are
made. Every one now carries its key across that gap. ZERO Hungarian error literals remain.

util.MsgError does three things at once, each earned:
  - Error() is the Hungarian, byte for byte, so every un-converted printer is unchanged;
  - errors.Is answers for the kind AND for a wrapped cause (KindErrorf dropped the cause);
  - an error ARGUMENT renders recursively, so "formázás sikertelen: %w" translates whole.
A foreign error — restic, docker, ssh, the stdlib — prints verbatim. It is not ours.

76 display sites go through errText, and TestNoErrErrorInPageOutput convicts any that do
not. memoryVerdict returns an error rather than a sentence, so the deploy's 409 and the
household's language come from one value; UpdateRefusal gained a Cause to carry it.

Plurals, one rule, stated once: a key with .one/.other takes its COUNT first. Not a
per-call-site flag — the producer somebody forgot would read "3 app is not running". The
guard caught a real key collision (alert.deadapp.one) the day the rule landed.

TWO DEFECTS FOUND IN MY OWN TOOLING, recorded rather than quietly fixed. The bulk converter
silently dropped multi-line concatenations, damaging 7 producers — and the parity gate could
not see it, because every surviving fragment WAS a real base literal while the CALL had lost
text; two behaviour tests caught it. And the counting script was case-sensitive, so it said
"0 left" while five remained.

MinAgent: 0.131.0 (unchanged). No hub release needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-18 11:44:30 +02:00

298 lines
13 KiB
Go

package backup
import (
"bufio"
"context"
"fmt"
"io"
"os"
"strings"
"time"
)
// R-200 — the operator-facing entry point for the recovery check, and the ONLY one this session
// ships. Deliberately a `docker exec` escape hatch in the shape of `--print-reset-code`, not a page,
// a card or an API a browser can reach: the customer-facing flow is designed on top of a chain that
// has been walked, and this is the walk.
//
// WHY R COMES FROM STDIN AND NOT A FLAG. A flag value is visible in `ps`, in the shell history, in a
// container's command line and in any transcript of the session that ran it. R is the one secret in
// this system that cannot be rotated, re-issued or recovered. It is read from stdin, held in one
// string, and cleared before the function returns — on the success path and on every failure path.
//
// docker exec -i felhom-controller /app/felhom-controller --recover-offsite-check < /root/r.txt
//
// WHAT IT PRINTS: two sha256 hashes and a verdict. Never a password, never R, never a blob. The
// hashes are of 256-bit random secrets and are non-reversible — the same value the hub already stores
// and serves in report ACKs.
// RecoveryCheckDeps is what the CLI needs; injected so the entry point is testable without a live
// agent, a live hub or real crypto.
type RecoveryCheckDeps struct {
// Manager owns the on-disk repo password hash.
Manager *Manager
// Recoverer is the agent seam (agentapi.Client satisfies it).
Recoverer OffsiteKeyRecoverer
// In is where R is read from (os.Stdin in production).
In io.Reader
// Out / Err are the report streams (os.Stdout / os.Stderr in production).
Out, Err io.Writer
// Timeout bounds the whole check. 0 → 90s (an unseal shells out to age and a fetch crosses the WAN).
Timeout time.Duration
}
// RunRecoveryCheck reads R from stdin, recovers the offsite repository password through the agent,
// and reports whether it matches the one on disk — BY HASH. Returns a process exit code:
//
// 0 = the hashes matched (the key is recoverable)
// 1 = a step failed (fetch, unseal, or no local password to compare against)
// 2 = the check ran cleanly and the hashes DIFFER — the loud case, and the one that would mean the
// sealed bundle does not carry what four weeks of documents say it carries
//
// A distinct code for the mismatch on purpose: "it failed" and "it worked and disagreed" must never
// share an exit status, because only one of them is a finding about the system rather than about the
// run.
func RunRecoveryCheck(d RecoveryCheckDeps) int {
out, errw := d.Out, d.Err
if out == nil {
out = os.Stdout
}
if errw == nil {
errw = os.Stderr
}
if d.Manager == nil || d.Recoverer == nil {
fmt.Fprintln(errw, "recover-offsite-check: not configured (no backup manager or no agent channel)")
return 1
}
in := d.In
if in == nil {
in = os.Stdin
}
// Read R: the first line of stdin, trimmed. A 10-word EFF code contains spaces, so only the
// line ending is stripped — never internal whitespace.
br := bufio.NewReader(io.LimitReader(in, 4096))
line, rerr := br.ReadString('\n')
R := strings.TrimRight(line, "\r\n")
if R == "" {
fmt.Fprintln(errw, "recover-offsite-check: no recovery code on stdin. Pipe it in:")
fmt.Fprintln(errw, " docker exec -i felhom-controller /app/felhom-controller --recover-offsite-check < /path/to/code")
if rerr != nil && rerr != io.EOF {
fmt.Fprintf(errw, " (read error: %v)\n", rerr)
}
return 1
}
timeout := d.Timeout
if timeout == 0 {
timeout = 90 * time.Second
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
fmt.Fprintln(out, "=== offsite key recovery check (R-200) — compares, never installs ===")
res, err := d.Manager.CheckOffsiteKeyRecoverable(ctx, d.Recoverer, R)
R = "" // cleared before anything else, on every path below
if err != nil {
fmt.Fprintf(errw, " [FAIL] %v\n", err) // the agent's message names the step; it carries no secret
fmt.Fprintln(errw, " nothing was written.")
return 1
}
if !res.LocalPresent {
fmt.Fprintln(errw, " [FAIL] there is no repository password on this box to compare against")
fmt.Fprintf(out, " recovered sha256: %s\n", res.RecoveredSHA256)
fmt.Fprintln(errw, " (the recovery itself SUCCEEDED — this box simply has no local key. That is the")
fmt.Fprintln(errw, " rebuilt-box shape, where the next step is to INSTALL rather than compare.)")
return 1
}
fmt.Fprintf(out, " on-disk sha256: %s\n", res.LocalSHA256)
fmt.Fprintf(out, " recovered sha256: %s\n", res.RecoveredSHA256)
if !res.Match {
fmt.Fprintln(errw, " [MISMATCH] the recovered key is NOT the key this box uses.")
fmt.Fprintln(errw, " This is a finding about the system, not about the run: the sealed bundle does not")
fmt.Fprintln(errw, " carry the repository password this box's off-site history is encrypted under.")
return 2
}
fmt.Fprintln(out, " [MATCH] the offsite repository password IS recoverable from the sealed escrow.")
fmt.Fprintln(out, " Nothing was written: this check compares and never installs.")
return 0
}
// RecoverAndInstall is the sibling of RunRecoveryCheck that PLACES the recovered repository password,
// so a rebuilt box can reopen the off-site history it inherited (R-200's remaining plumbing half).
//
// WHY THIS IS CODE AND NOT A MANUAL STEP. The alternative — recover the password, read it off a
// terminal, and paste it into the injection endpoint by hand — puts the offsite DATA key through a
// human's screen, clipboard and shell history. Doing it in-process is both simpler and strictly
// safer: the value goes agent → this process → the 0600 file and is never rendered anywhere.
//
// THE CONFIRMATION IS A SEPARATE INVOCATION, ON PURPOSE. Without `confirm` this prints the two hashes
// and writes nothing — the operator sees the comparison BEFORE any write exists as a possibility.
// With `confirm` it prints the same hashes and then installs. A single interactive prompt would have
// had to share stdin with R, which is where R must not be competing for attention.
//
// THREE OUTCOMES, NAMED DISTINCTLY, because "it did nothing" and "it refused" are different facts:
//
// installed — this box had NO repository password (the rebuilt-box shape). The recovered one is placed.
// unchanged — a password is present and is byte-identical to the recovered one. Nothing is written.
// refused — a password is present and DIFFERS. Installing would clobber the key this box's CURRENT
// repository is encrypted under, so it is refused. No force option is offered here: that
// decision needs a human who knows which history they intend to keep.
//
// RecoverInstallOutcome names the terminal states of a recovery+install. Distinct values because
// "it did nothing", "it refused" and "it installed" are different facts and a caller — CLI or web —
// must be able to say which happened without parsing prose.
type RecoverInstallOutcome string
const (
// RecoverInstalled — the box had NO repository password; the recovered one is now in place.
RecoverInstalled RecoverInstallOutcome = "installed"
// RecoverUnchanged — a password was present and is byte-identical to the recovered one.
RecoverUnchanged RecoverInstallOutcome = "unchanged"
// RecoverRefused — a DIFFERENT password is present; installing would clobber the key the box's
// current repository is encrypted under.
RecoverRefused RecoverInstallOutcome = "refused"
// RecoverDryRun — nothing was written because confirm was false.
RecoverDryRun RecoverInstallOutcome = "dry_run"
)
// RecoverInstallResult is the non-secret outcome of a recovery. It carries HASHES ONLY — never the
// password, never R. The hashes are of 256-bit random secrets, non-reversible, and are the same
// values the hub already stores and serves in report ACKs.
type RecoverInstallResult struct {
Outcome RecoverInstallOutcome
LocalPresent bool
LocalSHA256 string
RecoveredSHA256 string
}
// RecoverInstallCore is THE recovery+install path in this codebase — fetch the sealed bundle through
// the agent, unseal it with R, compare against what is on disk, and place it when that is the right
// thing to do.
//
// ONE FUNCTION, TWO CALLERS (R-193). The CLI (`--recover-offsite-install`) and the customer's recovery
// page both call this. They must not each carry a copy: two implementations of the one operation that
// can permanently lose a customer's data would drift, and only one of them would ever be tested.
// `RecoverAndInstall` below is a thin wrapper that maps this result onto the CLI's exit codes and
// printed lines; the web handler maps it onto Hungarian copy. Neither contains recovery logic.
//
// R IS THE CALLER'S TO CLEAR. This function does not retain it: it is passed to the agent seam and
// never stored, logged or returned. The password recovered from the bundle IS cleared here, on every
// path, before returning — it never leaves this function in any form.
//
// The three outcomes and their reasoning are unchanged from the CLI's original implementation; see
// RecoverAndInstall's header, which remains the authority on WHY a differing local password is
// refused rather than forced.
func RecoverInstallCore(ctx context.Context, m *Manager, rec OffsiteKeyRecoverer, R string, confirm bool) (RecoverInstallResult, error) {
var res RecoverInstallResult
if m == nil || rec == nil {
return res, fmt.Errorf("recovery not configured (no backup manager or no agent channel)")
}
pw, recoveredHash, err := rec.RecoverOffsiteRepoPassword(ctx, R)
if err != nil {
return res, err // the agent's message names the step; it carries no secret
}
res.RecoveredSHA256 = recoveredHash
res.LocalSHA256, res.LocalPresent = m.OffboxRepoPasswordHash()
switch {
case res.LocalPresent && res.LocalSHA256 == recoveredHash:
pw = ""
res.Outcome = RecoverUnchanged
return res, nil
case res.LocalPresent:
pw = ""
res.Outcome = RecoverRefused
return res, nil
}
if !confirm {
pw = ""
res.Outcome = RecoverDryRun
return res, nil
}
if err := m.InjectOffboxPassword(pw, false); err != nil {
pw = ""
return res, fmt.Errorf("placing the recovered password: %w", err)
}
pw = ""
// Re-read from disk rather than trusting what we just wrote — the observable is the file's state.
afterHash, ok := m.OffboxRepoPasswordHash()
if !ok || afterHash != recoveredHash {
return res, fmt.Errorf("the password was written but does not read back as expected (on-disk %q)", afterHash)
}
res.Outcome = RecoverInstalled
return res, nil
}
func RecoverAndInstall(d RecoveryCheckDeps, confirm bool) int {
out, errw := d.Out, d.Err
if out == nil {
out = os.Stdout
}
if errw == nil {
errw = os.Stderr
}
if d.Manager == nil || d.Recoverer == nil {
fmt.Fprintln(errw, "recover-offsite-install: not configured (no backup manager or no agent channel)")
return 1
}
in := d.In
if in == nil {
in = os.Stdin
}
br := bufio.NewReader(io.LimitReader(in, 4096))
line, rerr := br.ReadString('\n')
R := strings.TrimRight(line, "\r\n")
if R == "" {
fmt.Fprintln(errw, "recover-offsite-install: no recovery code on stdin. Pipe it in:")
fmt.Fprintln(errw, " docker exec -i felhom-controller /usr/local/bin/felhom-controller --recover-offsite-install [--confirm-install] < /path/to/code")
if rerr != nil && rerr != io.EOF {
fmt.Fprintf(errw, " (read error: %v)\n", rerr)
}
return 1
}
timeout := d.Timeout
if timeout == 0 {
timeout = 90 * time.Second
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
fmt.Fprintln(out, "=== offsite key recovery INSTALL (R-200) ===")
// THE RECOVERY ITSELF IS RecoverInstallCore — the same function the customer's recovery page
// drives (R-193). This wrapper adds the CLI's stdin handling, its printed lines and its exit
// codes, and NOTHING else; there is exactly one fetch→unseal→compare→install path in this
// codebase and no chance of the two callers drifting. Pinned by
// TestRecoverAndInstall_DrivesTheSharedCore and by the AST wiring test.
res, err := RecoverInstallCore(ctx, d.Manager, d.Recoverer, R, confirm)
R = "" // cleared immediately, on every path below
if err != nil {
fmt.Fprintf(errw, " [FAIL] %v\n", err)
fmt.Fprintln(errw, " nothing was written.")
return 1
}
if res.LocalPresent {
fmt.Fprintf(out, " on-disk sha256: %s\n", res.LocalSHA256)
} else {
fmt.Fprintln(out, " on-disk sha256: (none — this box has no repository password)")
}
fmt.Fprintf(out, " recovered sha256: %s\n", res.RecoveredSHA256)
switch res.Outcome {
case RecoverUnchanged:
fmt.Fprintln(out, " [UNCHANGED] the box already holds exactly this key. Nothing written.")
return 0
case RecoverRefused:
fmt.Fprintln(errw, " [REFUSED] a DIFFERENT repository password is already present.")
fmt.Fprintln(errw, " Installing would clobber the key this box's current repository is encrypted under,")
fmt.Fprintln(errw, " and which history to keep is not a decision this command may take. Nothing written.")
return 2
case RecoverDryRun:
fmt.Fprintln(out, " [DRY RUN] nothing written. The recovered key is ready to install.")
fmt.Fprintln(out, " Re-run with --confirm-install to place it.")
return 0
}
fmt.Fprintln(out, " [INSTALLED] the recovered repository password is in place and reads back identical.")
fmt.Fprintln(out, " Re-apply the offsite target and run a backup: the existing repository should open.")
return 0
}