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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user