Files
felhom.eu/hub/internal/offsitekeys/snapshots.go
T
admin 7d64038f02
gates / gates (push) Successful in 6m45s
Hub v0.158.0 code (built, NOT deployed): agent-only vouch, R-895 snapshot alarms, the hub's own 90-day access log (R-1140 A); installer --rotate-recovery-only (R-1141); 02/03/05/09 + break-glass
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.
2026-10-17 23:31:34 +02:00

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
}