homehub: implement + validate sync, tombstones, manifest, alerts

Tested end-to-end with two throwaway homes against a scratch hub root:
deletion propagation, new-machine join safety, edit-wins-over-delete,
reaping, forget, alert timing. Fixes found by those tests:
 - harvest deletions BEFORE the pull (pull used to undo every deletion)
 - gate attribution on $STATE/seen (a new machine would have tombstoned
   every hub file its peers had just uploaded)
 - active_path probed '+ /dir***' instead of '+ /dir/***' -> every tombstone
   looked out-of-scope, so deletions never applied
 - tombstone excludes must precede includes in the filter (first match wins)
 - rsync must get -e with the deploy key; harvest stderr must not be dropped
 - guard hub sync root that already holds unrelated data (178 G here)
 - scrub credentials out of repo remote URLs before publishing the manifest
This commit is contained in:
druppy committed 2026-10-24 16:36:40 +02:00
1 parent 78496330f3
commit be375fb312
4 files changed
+150 -45

No files matched your search

+118 -36
View File
@@ -19,7 +19,9 @@ HH_ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
# shellcheck disable=SC1091
. "$HH_ROOT/config"
HOST=$(hostname -s)
# HH_HOST lets the deletion/convergence semantics be tested with two throwaway
# "machines" on one box; leave unset in normal use.
HOST=${HH_HOST:-$(hostname -s)}
HUB_URL="$HUB_HOST:$HUB_PATH"
RULES=$HH_ROOT/rules
STATE=${STATE_ROOT:?config: STATE_ROOT unset}
@@ -115,33 +117,36 @@ check_shadowing() { # $1 set name
done < <(awk '!/^[[:space:]]*#/ && NF' "$RULES/denylist" 2>/dev/null)
done < <(awk '!/^[[:space:]]*#/ && NF' "$f")
}
# base filter: deny first, then includes (+ ancestors), NO trailing default-deny
# Filter composition. Order is load-bearing: rsync applies FIRST MATCH WINS, so
# 1. denylist (default-deny trees, wins over everything)
# 2. tombstone excludes (pull only — must precede the includes or they never fire)
# 3. includes (this host's sets + their traversable ancestors)
# 4. "- *" (default deny)
# Two variants: the push filter must NOT exclude tombstones, or a file you re-create
# after deleting it could never travel again (edits win, §3).
gen_filter() {
local sets s
sets=$(sets_for_host) || return 1
{
echo '- /.hub/***' # hub metadata can never leak into a home
{ echo '- /.hub/***' # hub metadata can never leak into a home
[ -r "$RULES/denylist" ] && rules_from "$RULES/denylist" '-'
for s in $sets; do
} | awk '!seen[$0]++' > "$FILTER.deny" || return 1
{ for s in $sets; do
[ -r "$RULES/sets.d/$s.list" ] || { warn "unknown set '$s' (no rules/sets.d/$s.list)"; continue; }
{ rules_from "$RULES/sets.d/$s.list" '+'
awk '!/^[[:space:]]*#/ && NF' "$RULES/sets.d/$s.list" | while read -r l; do ancestors_of "$l"; done; }
check_shadowing "$s"
done
} | awk '!seen[$0]++' > "$FILTER.base.tmp" || return 1
mv "$FILTER.base.tmp" "$FILTER.base"
# push filter: default deny only
{ cat "$FILTER.base"; echo '- *'; } > "$FILTER.tmp" && mv "$FILTER.tmp" "$FILTER"
# pull filter: default deny, PLUS still-tombstoned paths excluded so a deleted
# file is not re-downloaded every cycle for 30 days only to be re-deleted.
# Tombstones never enter the push filter: a file re-created after the delete
# must still be able to travel (edits win — DESIGN.md §3).
{
cat "$FILTER.base"
awk -F'\t' 'NF>=2 { p=$2; sub(/^\//, "", p); if (p != "" && p !~ /^\.hub\// && p !~/\.\./) {
print "- /" p; print "- /" p "/***" } }' "$STATE/tombstones.all" 2>/dev/null
echo '- *'
} | awk '!seen[$0]++' > "$FILTER.pull.tmp" && mv "$FILTER.pull.tmp" "$FILTER.pull"
} | awk '!seen[$0]++' > "$FILTER.incl" || return 1
awk -F'\t' 'NF>=2 { p=$2; sub(/^\//, "", p)
if (p != "" && p !~ /^\.hub\// && p !~ /\.\./) { print "- /" p; print "- /" p "/***" } }' \
"$STATE/tombstones.all" 2>/dev/null | awk '!seen[$0]++' > "$FILTER.tomb"
cat "$FILTER.deny" "$FILTER.tomb" "$FILTER.incl" > "$FILTER.base"
{ cat "$FILTER.deny" "$FILTER.incl"; echo '- *'; } > "$FILTER.tmp" && mv "$FILTER.tmp" "$FILTER"
{ cat "$FILTER.deny" "$FILTER.tomb" "$FILTER.incl"; echo '- *'; } > "$FILTER.tmp.pull" && mv "$FILTER.tmp.pull" "$FILTER.pull"
}
# --------------------------------------------------------------- rsync bits --
@@ -172,6 +177,20 @@ count_items() { grep -cE '^[<>]' "$1" 2>/dev/null || true; }
show_head() { grep -E '^[<>]' "$1" 2>/dev/null | head -n "${2:-10}"; }
# ----------------------------------------------------- deletions (tombstones) --
# $STATE/seen = every path this machine has actually transferred (pushed or pulled).
# It is what makes "on the hub but not local" decidable: a file we transferred and
# then removed is OUR deletion and may propagate; a file only a peer ever uploaded is
# not. Without it, a newly added machine harvests every existing hub file as its own
# deletion and tombstones the whole hub on join. Built from rsync's own itemized
# output (no extra round trip); its failure mode is the safe one — a file that is not
# recognised is never deleted.
seen_update() {
{ [ -s "$STATE/seen" ] && cat "$STATE/seen"
awk '/^[<>]/ { sub(/^[^|]*\|/, ""); if (length($0)) print }' \
"$STATE/pull.out" "$STATE/push.out" 2>/dev/null
} | sed '/^\.$/d' | sort -u > "$STATE/seen.new" && mv "$STATE/seen.new" "$STATE/seen"
}
# Format on the hub: .hub/tombstones/<host> lines = epoch \t home-rel-path
hub_tombstones_all() {
hub_script "'$HUB_PATH'" <<'EOS'
@@ -188,8 +207,11 @@ EOS
active_path() { # $1 = path without leading slash
local p="/$1"
while :; do
grep -qxF "+ $p***" "$FILTER" && return 0 # inside an included subtree
grep -qxF "+ $p" "$FILTER" && return 0 # is itself an included file
# NOTE the slash before ***: the include rules are "+ /dir/***", so probing
# "+ /dir***" would never match and every tombstone would look out-of-scope.
grep -qxF "+ $p/***" "$FILTER" && return 0 # inside an included subtree
grep -qxF "+ $p" "$FILTER" && return 0 # is itself an included file
grep -qxF "+ $p/" "$FILTER" && return 0 # an included ancestor dir
[ "${p%/*}" = "$p" ] && return 1
p=${p%/*}
[ -z "$p" ] && return 1
@@ -218,20 +240,73 @@ apply_tombstones() { # deletions recorded by any machine, edit-wins (§3)
done < "$all"
v "tombstones: applied=$n edited-since-so-kept=$kept outside-sets=$out malformed=$bad"
}
harvest_tombstones() { # record what this machine deleted; runs only after a real pull
local new=$STATE/tomb.new merged=$STATE/tomb.merged n ep
run_rsync 1 --delete "$HOME/" "$HUB_URL/" 2>/dev/null |
awk -F'|' '$1 ~ /^\*/ { sub(/^\//, "", $2); print $2 }' | sort -u > "$new"
harvest_tombstones() { # record what this machine deleted — BEFORE any pull, else
# the pull re-downloads the file and the deletion is silently lost forever.
# A hub copy newer than our last successful sync was edited by someone else
# since we last looked, so it is an edit, not a deletion: leave it alone (§3).
local new=$STATE/tomb.new merged=$STATE/tomb.merged n ep last rc=0
# stderr is kept, never sent to /dev/null: an ssh / known_hosts / transport error
# here would otherwise look exactly like "nothing was deleted" and silently stop
# deletions propagating anywhere, forever.
run_rsync 1 "$FILTER" --delete "$HOME/" "$HUB_URL/" > "$STATE/harvest.out" 2> "$STATE/harvest.err" || rc=$?
if [ "$rc" != 0 ]; then
warn "deletion harvest failed (rsync rc=$rc): $(tr '\n' ' ' < "$STATE/harvest.err" | tail -c 200)"
return 1
fi
awk -F'|' '$1 ~ /^\*/ { sub(/^\//, "", $2); print $2 }' "$STATE/harvest.out" | sort -u > "$new.raw"
# intersect with what we saw at the hub on the previous sync (first sync: no cache,
# so nothing can be claimed as a deletion — correct, nothing was ever ours to delete)
if [ -s "$STATE/seen" ]; then
comm -12 "$new.raw" "$STATE/seen" > "$new"
else
: > "$new"
v "nothing transferred by this machine yet: no deletions are attributable to it"
fi
local skipped; skipped=$(($(grep -c . "$new.raw" 2>/dev/null || true) - $(grep -c . "$new" 2>/dev/null || true)))
[ "${skipped:-0}" -gt 0 ] && v "ignored $skipped hub file(s) this machine never had (not our deletions)"
n=$(grep -c . "$new" 2>/dev/null || true)
[ "${n:-0}" = 0 ] && { v "no local deletions to record"; return 0; }
if [ "${n:-0}" = 0 ]; then v "no local deletions to record"; return 0; fi
last=$(hub "date -r '$HUB_PATH/.hub/meta/markers/$HOST' +%s 2>/dev/null || echo 0")
local kept=$STATE/tomb.kept edited=$STATE/tomb.edited
# Candidates exist on the hub by definition (hub has it, we do not), so spool the
# list there and let the remote loop stat it. A heredoc cannot share stdin with a
# pipe, which is why this is two ssh calls rather than one.
hub "mkdir -p '$HUB_PATH/.hub/tmp' && cat > '$HUB_PATH/.hub/tmp/cand.$HOST'" < "$new" || return 1
hub_script "'$HUB_PATH' '${last:-0}' '$HOST'" > "$kept" <<'EOS'
root=$1; last=$2; me=$3
cf="$root/.hub/tmp/cand.$me"
[ -f "$cf" ] || exit 0
while IFS= read -r p; do
[ -n "$p" ] || continue
m=$(date -r "$root/$p" +%s 2>/dev/null || echo 0)
if [ "$last" != 0 ] && [ "$m" -gt "$last" ]; then
printf '%s\tEDITED-ELSEWHERE\n' "$p"
else
printf '%s\n' "$p"
fi
done < "$cf"
rm -f "$cf"
exit 0
EOS
grep -c 'EDITED-ELSEWHERE' "$kept" 2>/dev/null | tr -d ' ' > "$edited" || echo 0 > "$edited"
grep -v 'EDITED-ELSEWHERE' "$kept" > "$kept.paths" || true
if [ "$(grep -c . "$kept.paths" 2>/dev/null || true)" = 0 ]; then
v "no deletions recorded ($(cat "$edited") path(s) were edited elsewhere -> kept as edits)"; return 0
fi
ep=$(now)
{ hub "cat '$HUB_PATH/.hub/tombstones/$HOST' 2>/dev/null"
awk -v e="$ep" '{ print e "\t" $0 }' "$new"
awk -v e="$ep" 'NF { print e "\t" $0 }' "$kept.paths"
} | awk -F'\t' 'NF>=2 { p=$2; if (!(p in e) || $1+0 < e[p]) e[p]=$1+0 }
END { for (p in e) printf "%d\t%s\n", e[p], p }' | sort -t$'\t' -k2 > "$merged"
hub "mkdir -p '$HUB_PATH/.hub/tombstones' && cat > '$HUB_PATH/.hub/tombstones/$HOST'" < "$merged" ||
{ warn "could not write tombstones"; return 1; }
v "recorded $n deletion(s) as tombstones on the hub"
# fold into the local cache and refresh the pull filter so the pull cannot undo us
cat "$merged" >> "$STATE/tombstones.all"
sort -u -t$'\t' -k2,2 "$STATE/tombstones.all" -o "$STATE/tombstones.all"
gen_filter >/dev/null 2>&1 || true
v "recorded $(grep -c . "$kept.paths") deletion(s) as tombstones ($(cat "$edited") edited elsewhere, kept)"
}
reap() { # stop resurrecting files deleted more than GRACE_DAYS ago (§5)
hub_script "'$HUB_PATH' '${GRACE_DAYS:-30}'" <<'EOS' || true
@@ -261,7 +336,7 @@ EOS
# $HOME on the first pull (and could be reaped by a tombstone). This guard exists
# because /volume1/homes/bl really did hold 178 G of unrelated legacy data.
check_hub_stray() {
[ -r "$FILTER.base" ] || return 0
[ -r "$FILTER.base" ] || return 1 # cannot judge -> do not block the sync
local allowed stray
allowed=$(printf '.hub\n@eaDir\n'; grep '^+ /' "$FILTER.base" | sed -E 's#^\+ /([^/]+).*#\1#; s#/$##' | sort -u)
stray=$(hub "cd '$HUB_PATH' 2>/dev/null && ls -A" | grep -vxF -f <(printf '%s\n' "$allowed") || true)
@@ -290,18 +365,26 @@ cmd_sync() {
esac
clear_broken auth
hub_setup_dirs
if check_hub_stray; then
[ $exec = 1 ] && [ "${HH_FORCE:-0}" != 1 ] &&
die "refusing to sync into a hub root that is not ours. Fix HUB_PATH in config, or move that data away, or re-run with HH_FORCE=1 if you really mean it"
fi
# tombstones must be known BEFORE the filter is built: they shape the pull filter
hub_tombstones_all > "$STATE/tombstones.all" || true
gen_filter || return 1
# ...and the filter must exist before the hub root can be checked against it
if check_hub_stray; then
[ $exec = 1 ] && [ "${HH_FORCE:-0}" != 1 ] &&
die "refusing to sync into a hub root that is not ours. Fix HUB_PATH in config, move that data away, or re-run with HH_FORCE=1 if you really mean it"
fi
hr "homehub sync ($([ $exec = 1 ] && echo EXECUTE || echo 'dry run')) on $HOST"
v "sets: $sets"
v "filter: $(grep -c . "$FILTER" || true) push, $(grep -c . "$FILTER.pull" || true) pull rules, $(grep -c . "$STATE/tombstones.all" 2>/dev/null || true) tombstoned"
[ $exec = 1 ] && touch_marker alive
# 0. harvest BEFORE the pull: a deletion the pull would otherwise undo
if [ $exec = 1 ]; then
hr "0. harvest this machine's deletions (before the pull can undo them)"
harvest_tombstones || rc=1
gen_filter || return 1 # pull filter must know the fresh tombstones
fi
hr "1. pull $HUB_URL -> $HOME/"
pull_rsync "$dry" > "$STATE/pull.out" 2> "$STATE/pull.err" || rc=$?
if [ $rc != 0 ]; then
@@ -314,10 +397,8 @@ cmd_sync() {
if [ $exec = 1 ]; then
hr "2. apply deletions recorded by any machine (edit-wins)"
apply_tombstones
hr "3. harvest this machine's deletions"
harvest_tombstones || rc=1
else
hr "2./3. deletion handling skipped in dry run"
hr "2. deletion handling skipped in dry run (harvest only runs with --exec)"
fi
hr "4. push $HOME/ -> $HUB_URL"
@@ -334,6 +415,7 @@ cmd_sync() {
touch_marker markers
hub "rm -f '$HUB_PATH/.hub/meta/alerted/$HOST'" || true
clear_broken sync
seen_update
elif [ $exec = 1 ]; then
mark_broken sync "last sync failed with rsync rc=$rc (see $STATE/pull.err, $STATE/push.err)"
fi