7d64038f02
gates / gates (push) Successful in 6m45s
Hub: the R-120 golden guard judges only a CHANGED golden selection; offsite_snapshots_vanished / _unexplained (operator-only, hub-origin) on the 07:10 listing; internal/accesslog writes path-only lines to /data/access-log, 90 days. Installer: --rotate-recovery-only runs step 4b alone (rides the next installer tag). 03: the orphan check runs after a failed backup (agent 6545ccc). 02: the allow-list is empty, appdata_folders (controller v0.329.0). All red-proofed.
91 lines
3.3 KiB
Go
91 lines
3.3 KiB
Go
package offsitekeys
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// R-895, THE MEASUREMENT SLICE (hub v0.157.0; design `audits/agent-allowlist-rotation-2026-10-17/P4/design-R-895.md`).
|
|
//
|
|
// The clean-up window's drop check trusts the snapshot counts the box sends. Option B of the design: the hub counts the
|
|
// box's restic `snapshots/` files ITSELF, over the login it already holds (the sub-account password, sealed — no new
|
|
// credential), and later alarms on files no box run explains. This slice only LISTS and LOGS — count, newest and
|
|
// oldest upload time — once a day beside the authorized_keys audit, so the listing's shape on the provider is measured
|
|
// before anything alarms on it. READ-ONLY: one `ls -l`; it writes, moves and deletes nothing.
|
|
|
|
// SnapshotListing is the hub's own read of one repository's snapshots/ directory.
|
|
type SnapshotListing struct {
|
|
Files int // entries whose name is a restic snapshot id (64 hex)
|
|
Other int // lines that are not (a header, a temp file, an unparsable line)
|
|
Newest time.Time // zero when no time could be read
|
|
Oldest time.Time
|
|
// Entries (slice 2, hub v0.158.0): each snapshot id with its upload time (zero when unreadable). A restic
|
|
// snapshot file is written once and never changed, so its mtime is its upload time.
|
|
Entries map[string]time.Time
|
|
}
|
|
|
|
var snapshotID = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
|
|
|
// ListSnapshots lists <repo>/snapshots with `ls -l` in the restricted shell. now anchors a year-less date.
|
|
func (r *Registrar) ListSnapshots(ctx context.Context, t Target, password string, now time.Time) (SnapshotListing, error) {
|
|
sh, err := r.open(ctx, t, password)
|
|
if err != nil {
|
|
return SnapshotListing{}, err
|
|
}
|
|
defer sh.Close()
|
|
out, err := sh.Run(ctx, "ls -l "+strings.TrimRight(t.RepoPath, "/")+"/snapshots", nil)
|
|
if err != nil {
|
|
return SnapshotListing{}, fmt.Errorf("offsitekeys: list snapshots: %w", err)
|
|
}
|
|
return parseLsL(string(out), now), nil
|
|
}
|
|
|
|
// parseLsL reads `ls -l` lines: mode links owner group size MON DD (HH:MM|YYYY) name.
|
|
func parseLsL(out string, now time.Time) SnapshotListing {
|
|
var l SnapshotListing
|
|
for _, line := range strings.Split(out, "\n") {
|
|
f := strings.Fields(line)
|
|
if len(f) == 0 || strings.HasPrefix(line, "total ") {
|
|
continue
|
|
}
|
|
if len(f) < 9 || !snapshotID.MatchString(f[len(f)-1]) {
|
|
l.Other++
|
|
continue
|
|
}
|
|
l.Files++
|
|
if l.Entries == nil {
|
|
l.Entries = map[string]time.Time{}
|
|
}
|
|
l.Entries[f[len(f)-1]] = time.Time{}
|
|
if ts, ok := lsTime(f[len(f)-4], f[len(f)-3], f[len(f)-2], now); ok {
|
|
l.Entries[f[len(f)-1]] = ts
|
|
if l.Newest.IsZero() || ts.After(l.Newest) {
|
|
l.Newest = ts
|
|
}
|
|
if l.Oldest.IsZero() || ts.Before(l.Oldest) {
|
|
l.Oldest = ts
|
|
}
|
|
}
|
|
}
|
|
return l
|
|
}
|
|
|
|
// lsTime reads ls's two date forms: "Oct 17 03:12" (within six months, year implied) and "Oct 17 2025".
|
|
func lsTime(mon, day, hm string, now time.Time) (time.Time, bool) {
|
|
if strings.Contains(hm, ":") {
|
|
ts, err := time.Parse("Jan 2 15:04 2006", fmt.Sprintf("%s %s %s %d", mon, day, hm, now.Year()))
|
|
if err != nil {
|
|
return time.Time{}, false
|
|
}
|
|
if ts.After(now.Add(24 * time.Hour)) { // December's file read in January
|
|
ts = ts.AddDate(-1, 0, 0)
|
|
}
|
|
return ts, true
|
|
}
|
|
ts, err := time.Parse("Jan 2 2006", fmt.Sprintf("%s %s %s", mon, day, hm))
|
|
return ts, err == nil
|
|
}
|