hub v0.157.0: app_definition_refused (R-971), the vault time moves on a re-vault (R-954), the hub's own snapshot count (R-895 slice); uninstall knows felhom-thin-check
gates / gates (push) Successful in 6m58s

- hub: `app_definition_refused` in allowedEventTypes + operatorOnlyEvents (controller v0.328.0's catalog
  allow-list refusal); SaveHostRecoveryCredential moves set_at on a re-vault (found live: both rotated hosts
  kept their first vault's time); the daily key audit also lists the box's restic snapshots/ read-only over the
  login the hub already holds and logs count/newest/oldest (no alarm yet).
- installer (main only, rides the next tag): --uninstall removes /usr/local/sbin/felhom-thin-check (agent
  v0.159.0's bundle file); test_hostinstall.py knows it.
- runbook whole-guest-backup-orphan-thin.md: the human repair the agent's new orphan finding points to.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-17 16:27:51 +02:00
parent ab2fc21a9c
commit 8f97b8009b
12 changed files with 276 additions and 1 deletions
@@ -0,0 +1,42 @@
# RUNBOOK — an orphan thin device stops a box's whole-guest backups (R-1012)
**When:** the operator mail „whole-guest backup failed" ends with „probable cause: thin pool `<vg>/<pool>` holds device
`<N>` that LVM does not know". The agent found it (agent ≥ v0.159.0); **it removed nothing.** The repair below is a
human step on purpose: it deletes a thin device, and only a person decides that.
**Why it happens:** the host's root filled up while vzdump took its storage snapshot; the kernel made the thin
snapshot, LVM could not write its own metadata, and the device stayed in the pool with no LV. From then on LVM's next
snapshot asks for the same device number and fails („Creation of new snapshot <N> of device <M> failed"). First seen
on demo-hp 2026-10-14 (`audits/power-cut-fixes-2026-10-14/E/`). Agent v0.156.1's leftover cleanup keeps the root from
filling; this is the repair when it happened anyway.
## 1. Confirm (read-only)
On the PVE host, as root:
```bash
felhom-thin-check pve data # the agent's own read-only helper; prints pool ids and LVM's, ends with "ok"
lvs -o lv_name,thin_id,pool_lv pve # LVM's view: every thin LV with its id
```
The orphan is a `pool <N>` line with no `lvm-before <N>` and no `lvm-after <N>`. If the helper refuses with
„a metadata snapshot … is already held", someone else is reading the pool; wait and run it again.
**Stop here if the id belongs to an LV, or if a backup or restore is running on the host** (`pct list` shows a lock,
or `ps -ef | grep vzdump`). Write down the id and the LVM list in the evidence folder first (R-320).
## 2. Repair (deletes ONE thin device — the operator decides)
```bash
dmsetup message pve-data-tpool 0 "delete <N>"
felhom-thin-check pve data # now the pool's ids equal LVM's
```
(`pve-data-tpool` is `<vg>-<pool>-tpool`, with every `-` in a name doubled.) Then start one whole-guest backup of the
guest from the controller („Mentés most") and read that it made its snapshot and finished. Done this way on demo-hp
2026-10-14 12:02 UTC (`audits/power-cut-fixes-2026-10-14/E/repair.txt`, `E/next-backup.txt`).
## 3. Afterwards
- The register row for the box, with the id and the evidence path.
- Why the root filled: `df -h /`, `/var/lib/vz/dump` — the cleanup (R-1006) should have prevented it.
+17
View File
@@ -1,3 +1,20 @@
## v0.157.0 — the allow-list's operator event, the vault time moves on a re-vault, the hub's own snapshot count (2026-10-17)
- **`app_definition_refused`** (R-971, operator decision D2 = A): controller v0.328.0 refuses, before it writes any app
compose, a catalog definition that asks for container rights not on the box's compiled allow-list, and reports it.
Registered in `allowedEventTypes` AND `operatorOnlyEvents` in this commit (the household can do nothing; its app
page says the app keeps its last good definition). Not added to the per-app cooldown list — that list is fenced
(`TestR389_TheAllowListHasExactlyOneMember`); the controller already sends one event per refused definition.
- **The Console access card's „last vaulted" time moves on a re-vault** (R-954 follow-up). `SaveHostRecoveryCredential`
kept the FIRST vault's `set_at` on every later upsert, so after the 2026-10-17 rotation demo-hp read „2026-07-21"
and Tester 1 „2026-10-04" over passwords changed minutes before. `set_at` now moves; pinned by
`TestHostRecovery_ReVaultMovesSetAt` (red-proved: the old upsert fails it).
- **R-895, the measurement slice:** the daily off-site key audit also lists the box's restic `snapshots/` over the
login the hub already holds (the sealed sub-account password — no new credential) and LOGS the count, the newest and
the oldest upload time. Read-only (one `ls -l`), no alarm yet: the next slice alarms on files no box run explains,
once this log line has shown the provider's listing on real boxes. `internal/offsitekeys/snapshots.go`, pinned by
`TestListSnapshots_CountsTheBoxSnapshotFilesReadOnly`.
## v0.156.0 — a supported Go: 1.27.2, x/crypto v0.58.0, alpine 3.24.2 (R-972) (2026-10-16)
- Built on Go 1.27.2 (was 1.24.13 — no security fixes left) with golang.org/x/crypto v0.58.0 (was v0.31.0) and
+4
View File
@@ -2345,6 +2345,10 @@ var allowedEventTypes = map[string]bool{
// and repaired after an unclean host boot — COUNTS ONLY. Operator-only (notify.operatorOnlyEvents) — registered in
// BOTH in the same commit; the household's side is the app page's lost-files note on its own box.
"app_repaired_after_crash": true,
// R-971 (hub v0.157.0, controller v0.328.0, operator decision D2 = A): the box's catalog allow-list REFUSED an app's
// definition (a correction, an update, a restore, a settings change) — nothing was written, the app runs on its last
// good compose. Operator-only (notify.operatorOnlyEvents) — registered in BOTH in the same commit.
"app_definition_refused": true,
// Special
"test": true,
}
+4
View File
@@ -871,6 +871,10 @@ var operatorOnlyEvents = map[string]bool{
// of empty files found / repaired / still broken / originals lost. The household learns of a lost photo on its own
// app page (name and date, on its box); THESE counts are the operator's. Registered in the same commit that mints it.
"app_repaired_after_crash": true,
// R-971 (v0.157.0, controller v0.328.0, operator decision D2 = A). The box's catalog allow-list refused an app's
// definition: someone pushed a catalog change asking for container rights the box does not give. The household can
// do nothing; its app page says the app keeps its last good definition. Registered in the same commit that mints it.
"app_definition_refused": true,
}
// IsOperatorOnly reports whether an event type is barred from customer dispatch. Exported so the
+9
View File
@@ -215,6 +215,15 @@ func (s *Service) AuditAll(ctx context.Context, windowOpen WindowOpenFunc) []Aud
default:
s.logf("[INFO] offsitekeys: audit %s: %d line(s), all pinned append-only", c.CustomerID, o.Result.Lines)
}
// R-895 measurement slice: the hub's OWN count of the box's snapshot files (read-only, logged, no alarm yet).
if terr == nil {
if l, lerr := s.Reg.ListSnapshots(ctx, t, pw, time.Now().UTC()); lerr != nil {
s.logf("[WARN] offsitekeys: snapshots %s: the hub's own listing failed: %v", c.CustomerID, lerr)
} else {
s.logf("[INFO] offsitekeys: snapshots %s: the hub's own listing — %d snapshot file(s), %d other line(s), newest %s, oldest %s (R-895 measurement; no alarm yet)",
c.CustomerID, l.Files, l.Other, l.Newest.Format(time.RFC3339), l.Oldest.Format(time.RFC3339))
}
}
}
return out
}
+82
View File
@@ -0,0 +1,82 @@
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
}
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 ts, ok := lsTime(f[len(f)-4], f[len(f)-3], f[len(f)-2], now); ok {
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
}
@@ -0,0 +1,67 @@
package offsitekeys
import (
"context"
"strings"
"testing"
"time"
)
type lsShell struct {
out string
cmds []string
}
func (s *lsShell) Run(_ context.Context, cmd string, _ []byte) ([]byte, error) {
s.cmds = append(s.cmds, cmd)
return []byte(s.out), nil
}
func (s *lsShell) Close() error { return nil }
type lsDialer struct{ sh *lsShell }
func (d lsDialer) Dial(context.Context, Target, string) (Shell, error) { return d.sh, nil }
const id1 = "0a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f9"
const id2 = "ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100"
// R-895 measurement slice: the hub's own listing reads the snapshot files and their upload times from `ls -l`, counts
// only restic snapshot ids, and issues exactly ONE read-only command. RED-PROOF
// (audits/agent-allowlist-rotation-2026-10-17/P4/redproof-listing.txt): with parseLsL counting every line, Files is 3
// and this test fails.
func TestListSnapshots_CountsTheBoxSnapshotFilesReadOnly(t *testing.T) {
sh := &lsShell{out: "total 8\n" +
"-rw------- 1 u1-sub4 u1-sub4 324 Oct 17 03:12 " + id1 + "\n" +
"-rw------- 1 u1-sub4 u1-sub4 331 Mar 2 2026 " + id2 + "\n" +
"-rw------- 1 u1-sub4 u1-sub4 12 Oct 17 03:13 .tmp-upload\n"}
r := &Registrar{Dialer: lsDialer{sh}}
now := time.Date(2026, 10, 17, 14, 0, 0, 0, time.UTC)
l, err := r.ListSnapshots(context.Background(), tgt, "pw", now)
if err != nil {
t.Fatal(err)
}
if l.Files != 2 || l.Other != 1 {
t.Fatalf("files=%d other=%d, want 2 and 1", l.Files, l.Other)
}
if !l.Newest.Equal(time.Date(2026, 10, 17, 3, 12, 0, 0, time.UTC)) || !l.Oldest.Equal(time.Date(2026, 3, 2, 0, 0, 0, 0, time.UTC)) {
t.Fatalf("newest=%v oldest=%v", l.Newest, l.Oldest)
}
if len(sh.cmds) != 1 || sh.cmds[0] != "ls -l /home/felhom-repo/snapshots" {
t.Fatalf("commands = %v — the listing must be one read-only ls", sh.cmds)
}
for _, c := range sh.cmds {
for _, w := range []string{"rm", "mv", "dd", "chmod", "mkdir", "touch", "cp", "unlink"} {
if strings.HasPrefix(c, w+" ") {
t.Fatalf("a write verb in the listing: %q", c)
}
}
}
}
// A December file read in January is last year's, not next year's.
func TestLsTime_YearBoundary(t *testing.T) {
ts, ok := lsTime("Dec", "30", "23:10", time.Date(2027, 1, 2, 0, 0, 0, 0, time.UTC))
if !ok || ts.Year() != 2026 {
t.Fatalf("got %v %v", ts, ok)
}
}
+4 -1
View File
@@ -18,7 +18,9 @@ type HostRecoveryCredential struct {
}
// SaveHostRecoveryCredential upserts a host's break-glass credential (last-write-wins: day-0 sets it,
// --rotate re-sets). R-133 (hub v0.135.0): the secret is SEALED at rest with the same key and the same
// --rotate re-sets). set_at is the time THIS secret was vaulted — a re-vault moves it (R-954 follow-up, hub v0.157.0:
// it used to keep the FIRST vault's time, so the Console access card said „last vaulted 2026-07-21" over a password
// rotated minutes before; pinned by TestHostRecovery_ReVaultMovesSetAt). R-133 (hub v0.135.0): the secret is SEALED at rest with the same key and the same
// helpers as the off-site sub-account passwords (offsite_seal.go, OFFSITE_SECRET_KEY) — a copy of hub.db
// alone no longer holds any box's console password. No key → the save is REFUSED (ErrNoSealKey): a hub that
// cannot seal must not fall back to plaintext. The hub NEVER logs the secret and only ever returns it over
@@ -34,6 +36,7 @@ func (s *Store) SaveHostRecoveryCredential(hostID, username, secret string) erro
ON CONFLICT(host_id) DO UPDATE SET
username = excluded.username,
secret = excluded.secret,
set_at = datetime('now'),
updated_at = datetime('now')`,
hostID, username, sealed)
return err
@@ -0,0 +1,37 @@
package store
import (
"testing"
"time"
)
// R-954 follow-up (hub v0.157.0): a re-vault (`--rotate-recovery`, the operator's own set-and-vault) MOVES set_at — the
// time the Console access card shows as „last vaulted". Found live 2026-10-17: demo-hp's and Tester 1's rotated
// passwords both still read set_at = their FIRST vault (2026-07-21, 2026-10-04). RED-PROOF
// (felhom.eu audits/agent-allowlist-rotation-2026-10-17/P3/redproof-set-at.txt): without `set_at = datetime('now')` in
// the upsert this test fails on both the meta and the credential read.
func TestHostRecovery_ReVaultMovesSetAt(t *testing.T) {
st := sealTestStore(t)
if err := st.SaveHostRecoveryCredential("h1", "root@pam", "first-Pw-1"); err != nil {
t.Fatal(err)
}
// Age the first vault by a year, as a real day-0 vault would be.
if _, err := st.db.Exec(`UPDATE host_recovery SET set_at = '2025-10-17 10:00:00' WHERE host_id = 'h1'`); err != nil {
t.Fatal(err)
}
before := time.Now().UTC().Add(-time.Minute)
if err := st.SaveHostRecoveryCredential("h1", "root@pam", "second-Pw-2"); err != nil {
t.Fatal(err)
}
c, err := st.GetHostRecoveryCredential("h1")
if err != nil || c == nil || c.Secret != "second-Pw-2" {
t.Fatalf("credential = %+v, %v", c, err)
}
if c.SetAt.Before(before) {
t.Fatalf("credential set_at = %v — the re-vault kept the first vault's time", c.SetAt)
}
m, err := st.GetHostRecoveryMeta("h1")
if err != nil || m == nil || m.SetAt.Before(before) {
t.Fatalf("meta set_at = %+v, %v — the card would show the first vault's time", m, err)
}
}
+7
View File
@@ -1,3 +1,10 @@
## 2026-10-17 — the uninstall removes agent v0.159.0's new root helper (R-1012); not yet tagged
`felhom-host-install.sh --uninstall` removes `/usr/local/sbin/felhom-thin-check`, the read-only orphan-thin check the
agent's config bundle installs since v0.159.0; `test_hostinstall.py` names it among the bundle files (its
`test_bundle_list_is_current` fails when the bundle gains a file the uninstall does not know). Pushing to `main`
publishes nothing — this rides the next `installer-v…` tag. SCRIPT_VERSION unchanged.
## 2026-10-17 — `~/.config/credentials` deleted (R-1134)
The operator's go, after every key in it was checked equal in the CC store. `read_credential.py`, `fb_probe.py` and
+2
View File
@@ -1357,6 +1357,8 @@ run_uninstall() {
if [[ -f /usr/local/sbin/felhom-backup-target-apply ]]; then run rm -f /usr/local/sbin/felhom-backup-target-apply; else log_skip " felhom-backup-target-apply already absent"; fi
# agent v0.156.1 (R-1006): the dead half-written vzdump remover the config bundle installs.
if [[ -f /usr/local/sbin/felhom-vzdump-partial-clean ]]; then run rm -f /usr/local/sbin/felhom-vzdump-partial-clean; else log_skip " felhom-vzdump-partial-clean already absent"; fi
# agent v0.159.0 (R-1012): the read-only orphan-thin check the config bundle installs.
if [[ -f /usr/local/sbin/felhom-thin-check ]]; then run rm -f /usr/local/sbin/felhom-thin-check; else log_skip " felhom-thin-check already absent"; fi
# 1.33.0 (`16` §5): the internal-place helper. Its VOLUME (pve/felhom-belso) holds household data and is KEPT.
if [[ -f /usr/local/sbin/felhom-internal-place ]]; then run rm -f /usr/local/sbin/felhom-internal-place; else log_skip " felhom-internal-place already absent"; fi
if [[ -f /usr/local/sbin/felhom-os-apply ]]; then run rm -f /usr/local/sbin/felhom-os-apply; else log_skip " felhom-os-apply already absent"; fi
+1
View File
@@ -36,6 +36,7 @@ BUNDLE_DESTS = [
"/usr/local/sbin/felhom-pbs-apply",
"/usr/local/sbin/felhom-backup-target-apply",
"/usr/local/sbin/felhom-vzdump-partial-clean", # agent v0.156.1 (R-1006)
"/usr/local/sbin/felhom-thin-check", # agent v0.159.0 (R-1012)
"/usr/local/sbin/felhom-internal-place", # agent v0.158.0 (felhom.eu `16` §5)
"/usr/local/sbin/felhom-os-apply",
"/usr/local/sbin/felhom-crash-guard",