R-101 + F-DIAG: the restore dialog names the last SUCCESSFUL copy (v0.182.0)

Tier2LastRun is the attempt clock and was rendered as 'Legutóbbi másolat' in the restore
confirm dialog. New LastSuccess + SuccessTracked anchor; tier2Update makes the three
rebuild sites safe by construction. F-DIAG: six distinct causes, target-aware redaction.
This commit is contained in:
2026-07-28 16:36:47 +02:00
parent 3db8bfb953
commit 3f048e042b
12 changed files with 829 additions and 72 deletions
+120
View File
@@ -92,6 +92,126 @@ func classifyResticProbe(out []byte, err error) string {
}
}
// ── F-DIAG: four causes, four messages, and no secrets ──────────────────────────────────────────
//
// The offsite failure notification was a raw passthrough:
//
// "a NAS-ra mentés hibázott (<dur>): " + err.Error()
//
// One string for every cause, so an operator could not tell a full disk from a dead network without
// reading logs — AND a raw restic/ssh error carries the repo URL, which is built as
// `sftp:<user>@<host>:<path>` (offboxBaseArgs). That breaks this project's keys-not-values rule at the
// one place the text leaves the box.
//
// OffsiteFailureClass names the causes that are genuinely DISTINGUISHABLE where the error is produced.
// Nothing is invented: each maps to a signal the code already has.
type OffsiteFailureClass string
const (
OffsiteFailQuota OffsiteFailureClass = "quota" // the pre-run soft-quota gate refused (offbox.go quota state)
OffsiteFailOrphaned OffsiteFailureClass = "orphaned" // ErrOffboxOrphaned — repo keyed under a lost passphrase
OffsiteFailNoRepo OffsiteFailureClass = "no_repo" // classifyResticProbe "norepo" — nothing at the location
OffsiteFailNoUnits OffsiteFailureClass = "no_units" // apps toggled but no recovery unit found on any drive
OffsiteFailTransport OffsiteFailureClass = "transport" // network / SFTP auth / host key / timeout
OffsiteFailUnknown OffsiteFailureClass = "unknown" // genuinely unclassified — say so rather than guess
)
// offsiteRepoURLRe matches the `sftp:user@host:/path` repo reference restic echoes back in its errors.
// It is the BACKSTOP, not the primary defence — see sanitiseOffsiteErrorFor.
var offsiteRepoURLRe = regexp.MustCompile(`sftp:[^\s"']+`)
// sanitiseOffsiteErrorFor strips anything that could carry a secret or a customer-identifying location
// out of an error before it reaches a message, an event or a report.
//
// IT REDACTS THE KNOWN TARGET VALUES, not a guessed pattern. The first version of this function
// regex-matched `sftp:…` and `user@host` and looked complete; its own test caught it leaking on
// `ssh: connect to host <host> port 23: Connection refused`, which contains a BARE hostname in neither
// shape. Guessing at what a secret looks like fails exactly where it matters — the target's host, user
// and repo path are known here, so they are removed literally and the regex stays only as a backstop
// for forms built before the target is loaded.
//
// Whole-token replacement, not masking: a partially-masked host still identifies the customer, and
// "it looked masked" is how a leak survives review.
func sanitiseOffsiteErrorFor(t *settings.OffboxTarget, err error) string {
if err == nil {
return ""
}
out := offsiteRepoURLRe.ReplaceAllString(err.Error(), "<repo>")
if t != nil {
// Longest first, so the repo path is not half-eaten by the host replacement.
for _, v := range []string{t.RepoPath, t.Host, t.User} {
if len(strings.TrimSpace(v)) >= 3 {
out = strings.ReplaceAll(out, v, "<repo>")
}
}
}
if len(out) > 300 {
out = out[:300] + "…"
}
return out
}
// ClassifyOffsiteFailure maps a run error to its cause.
//
// Order matters: the explicit sentinels first, then the text signatures. A cause that cannot be told
// apart here returns OffsiteFailUnknown rather than being folded into a neighbour — inventing a
// precision the code does not have is how a confident-but-wrong diagnosis ships.
func ClassifyOffsiteFailure(err error) OffsiteFailureClass {
if err == nil {
return ""
}
if errors.Is(err, ErrOffboxOrphaned) {
return OffsiteFailOrphaned
}
s := strings.ToLower(err.Error())
switch {
case strings.Contains(s, "tárhelykeretet"):
return OffsiteFailQuota
case strings.Contains(s, "produced no snapshots"):
return OffsiteFailNoUnits
case strings.Contains(s, "unable to open config file"),
strings.Contains(s, "is there a repository at the following location"):
return OffsiteFailNoRepo
case strings.Contains(s, "connection refused"), strings.Contains(s, "connection reset"),
strings.Contains(s, "no route to host"), strings.Contains(s, "i/o timeout"),
strings.Contains(s, "timed out"), strings.Contains(s, "permission denied"),
strings.Contains(s, "host key"), strings.Contains(s, "handshake"),
strings.Contains(s, "could not resolve"), strings.Contains(s, "network is unreachable"):
return OffsiteFailTransport
default:
return OffsiteFailUnknown
}
}
// OffsiteFailureMessage returns the operator-facing Hungarian message for a run failure: a distinct
// cause line plus the SANITISED detail. The detail is kept because an operator needs something to act
// on; it is sanitised because this text leaves the box.
//
// A method, not a function, so it can reach the target and redact its ACTUAL host/user/path rather
// than pattern-matching at what those might look like.
func (m *Manager) OffsiteFailureMessage(err error, dur time.Duration) string {
var t *settings.OffboxTarget
if m != nil && m.settings != nil {
t = m.settings.GetOffboxTarget()
}
return offsiteFailureMessage(t, err, dur)
}
func offsiteFailureMessage(t *settings.OffboxTarget, err error, dur time.Duration) string {
head := map[OffsiteFailureClass]string{
OffsiteFailQuota: "A távoli mentés nem fért el a tárhelykereten belül",
OffsiteFailOrphaned: "A távoli tárhely egy korábbi, már nem elérhető kulccsal készült",
OffsiteFailNoRepo: "A távoli tárhelyen nincs mentési adattár",
OffsiteFailNoUnits: "Nem volt mit menteni: egyetlen kijelölt alkalmazásnak sem található mentése",
OffsiteFailTransport: "A távoli tárhely nem érhető el (hálózat vagy bejelentkezés)",
OffsiteFailUnknown: "A távoli mentés ismeretlen okból nem sikerült",
}[ClassifyOffsiteFailure(err)]
if head == "" {
head = "A távoli mentés nem sikerült"
}
return fmt.Sprintf("%s (%s): %s", head, dur.Round(time.Second), sanitiseOffsiteErrorFor(t, err))
}
func defaultOffboxSSH(ctx context.Context, host, user string, port int, keyPath, knownHosts, remoteCmd string) ([]byte, error) {
if port == 0 {
port = 22