homehub: rsync-based home-drive sync via Synology hub

DESIGN.md is the rationale; README.md the operations half.
Four data classes: files (mtime-arbitrated two-way), git repos (manifest +
clone, never byte-synced), the browser link bar (union merge), and everything
else denied by default.
This commit is contained in:
druppy committed 2026-10-24 16:36:40 +02:00
commit 78496330f3
16 files changed
+1576

No files matched your search

+336
View File
@@ -0,0 +1,336 @@
# homehub — lean home-drive sync across 3–4 machines via Synology
Status: **design, no code yet**. Open decisions in §13.
## 0. Ground truth this design was built against
| Fact | Value | Consequence |
|---|---|---|
| Hub | `nas.lue.dk`, DSM, Linux 4.4.302+ x86_64, `/bin/sh` | rsync-over-ssh, no daemon, no Syncthing package |
| Hub target | `/volume1/homes/bl` (4.9 TB free) | **cannot be the sync root — see next row** |
| Hub root | `/volume1/homes/bl` already holds **178 G of unrelated legacy data** (`Documents`, `Photos`, `OneDrive`, `CloudStation`, `home_backup`, `x1c_backup`, `wg-bo.conf`, `certs`, `@eaDir`) | sync root is a dedicated subtree `$HUB_BASE/homehub`; `check_hub_stray()` refuses to sync into a root that is not ours |
| NAS rsync | **3.1.2 (protocol 31)** | **no `--ignore-older`** → use `--update` both ways |
| Client rsync | 3.5.1 | protocol downgrade fine; do not use ≥3.2-only flags |
| Hub perms | siblings `admin, cl, fk, ida, …` on same share | `--no-perms --no-owner --no-group --chmod=Du=rwx,Dgo=,Fu=rw,Fgo=` |
| Laptop offline | weeks | no `--delete`; NAT-friendly health check (hub-side markers only) |
| Client ssh | `id_rsa` has a passphrase; `SSH_AUTH_SOCK` comes from a running agent | a **passphrase-free dedicated key** is mandatory (a user unit has no agent) and rsync gets the same identity via `-e` |
| NixOS | not started (`/nix` absent) | design must be identical pre/post migration → §8 |
Size reality check: `~` ≈ 250 GB, of which file-sync-worthy personal content is
**~0.5 GB** (`Documents` 71 M, `Pictures` 323 M, `bin` 45 M, `journal` 72 K,
dotfiles, `~/.pi/agent/sessions` 11 M). `~/git` 139 G + `~/src` 21 G are *repos*,
not files, and 7.2 G of `~/src` alone is `build/`+`target/`+`dist/`.
## 1. Four data classes, four mechanisms
| Class | What | Mechanism | Direction |
|---|---|---|---|
| A | personal files (docs, pictures, bin, dotfiles, tool *state*) | rsync via hub, mtime-arbitrated | two-way |
| B | git repos (`~/git/*`, repo-like dirs in `~/src/*`) | **manifest + clone** (`repos.tsv`) | two-way, metadata only |
| C | Chromium bookmark bar | per-host export + **union-by-URL merge** | many-to-one-to-many |
| D | everything else (caches, toolchains, builds, `tmp`, `Downloads`) | **default deny** | never |
Class B is the load-bearing idea. "Copy only the directory and the remote; each
machine has its own state" is not a file-sync requirement — it is a manifest. A
`repos.tsv` line is ~120 bytes where the repo is 4 GB, dirty state and local
branches never leave the machine, and 130 GB of vendored upstream clones
(`esp-idf`, `poky`, `bitbake`, `zephyr-sdk`, `llama.cpp`, `ollama`) become one
line each instead of a 139 GB transfer.
## 2. Hub layout
```
/volume1/homes/bl/homehub/ # dedicated sync root — NOT the Synology home
.hub/
meta/
markers/<host> # epoch of last SUCCESS (hub clock: no client skew)
alive/<host> # epoch of last ATTEMPT (written even on failure)
alerted/<host> # epoch of last alert (alert de-duplication)
log.tsv # one line per attempt: epoch, host, rc, note
tombstones/<host> # epoch \t path — paths that host deleted
gitman/repos.<host> # class B manifest
bookmarks/<host>.json # exported bookmark bar per host
prev/YYYY-MM/ # overwritten older versions; prev/reaped/ for reaped
<mirrored home layout> # Documents/, Pictures/, bin/, .ssh/, .pi/, ...
```
**The sync root must be a dedicated subtree.** `/volume1/homes/bl` is 178 G of
pre-existing, unrelated data. Pointing the tool at it would have (a) merged the
NAS's `Documents/` into `~/Documents` on the very first pull, (b) written our
dotfiles over the NAS's own `.config`, and (c) let the tombstone reaper move
unrelated legacy files into `.hub/prev/` whenever a local delete happened to match
a legacy path. `check_hub_stray()` now refuses to `sync --exec` into a hub root
containing anything that is not ours.
Nothing on the hub is ever edited by hand except `.hub/bookmarks/*` if you must.
`.hub/` is excluded from every pull and every push, so it can never leak into a
home tree or be deleted by one.
## 3. Sync algorithm (one run, on one machine, ~10 min cadence)
```
1 touch .hub/alive/<host> # "I am up" — even if the sync then fails
2 PULL tombstones from all hosts # apply locally: rm -rf within active sets
3 PULL rsync -rltDl -z --update # hub -> local, mtime-arbitrated, additive
4 PUSH rsync --update --backup # local -> hub, older hub files parked in .hub/prev/
5 write .hub/markers/<host> # ONLY on rc=0
```
Ordering note: pull-before-push means a file you edited locally while the hub had
a newer version is preserved by `--update` (skipped) *and* the hub copy is kept in
`.hub/prev/` — you never silently lose the other machine's work.
Command shape (pass 4, local → hub):
```
rsync -rltDl -z --update \
--no-perms --no-owner --no-group \
--chmod=Du=rwx,Dgo=,Fu=rw,Fgo= \
--backup --backup-dir="$HUB/.hub/prev/$(date +%Y-%m)" \
--filter="merge $STATE/effective.filter" \
~/ nas.lue.dk:/volume1/homes/bl/
```
Semantics, stated plainly so it is not a surprise later:
**edits always win; deletions only win if nobody else touched the file.**
## 4. Which files: sets + host membership (default deny)
`sets.d/<name>.include` holds rsync filter rules (`+ /Documents/***`, …).
`hosts/<hostname>` lists which sets that machine participates in.
`effective.filter` is generated per host per run:
```
- /.hub/***
- <§9 denylist rules, emitted first so they beat set includes>
+ /<prefix>/*** # union of this host's sets, in stable order
- * # default deny: anything not explicitly included never moves
```
First-match-wins, so the `-` global denylist must be emitted **before** `+` lines
that would otherwise match (e.g. `+ /src/***` vs `- /src/**/build/***`).
Proposed initial sets (tick/adjust in §13):
| Set | Members | Desktops | Laptop |
|---|---|---|---|
| `core` | `.ssh/`, `.gitconfig`, `.bashrc`, `.profile`, `.p10k.zsh`, `bin/`, `Documents/`, `Pictures/`, `journal/`, `.local/state/homehub/`, `.pi/agent/{sessions,extensions,settings.json,models.json,AGENTS.md}`, `.gtk-bookmarks` | yes | yes |
| `repos` | `.hub`-side `gitman/repos.tsv` (Class B metadata only) | yes | yes |
| `work` | non-repo remainder of `~/src` (see §13 D2) | yes | opt-in |
| `bulk` | non-repo leftovers in `rustlings/`, `rustql/` | yes | no |
`~/Downloads` (35 G) and `~/tmp` are out, as decided. `~/.config/**` is **not** in
any set — see §8.
## 5. Deletion policy: tombstones + reaper, never `--delete`
`--delete` is incompatible with a weeks-offline peer: machine A, back from a
3-week trip, would delete from the hub files that another machine created or
edited while it was away, and the offline machine would then pull that deletion.
Instead:
- Push pass 1 is a `--dry-run --delete --itemize-changes` scan; `deleting` lines
are appended to `.hub/tombstones/<host>` (cost: one extra metadata-only scan).
- Real push is additive. Tombstones are applied locally on every pull (step 2).
- A **reaper** (runs only when a tombstone is older than the grace window, §13)
moves the hub copy to `.hub/prev/reaped/` and prunes the tombstone line, so a
file deleted in spring does not resurrect on a laptop that returns in autumn.
- `homehub forget <path>` deletes tombstones when you want a file back for good.
Why not `restic`? `--backup --backup-dir=.hub/prev/YYYY-MM` gives one prior
generation for free on every overwrite, needs no new tool on DSM 7/kernel 4.4,
and 4.9 TB free makes "keep the last version" affordable. Check whether Snapshot
Replication covers the `homes` share — `/volume1/@snapshots` was not readable as
`bl`; if it does, you get coarse history for free and should enable it.
## 6. Class B — git manifest spec
`gitman/inventory` walks `$HOME` over `git/` and `src/`, **stops descending at the
first `.git`** (so a repo's own submodules/modules are not double-counted), and
writes `.hub/gitman/repos.<host>.tsv`:
```
relpath remote_url head_branch head_sha dirty unpushed
src/rustql https://… main 9f2c1ab 0 3
git/ev_fw git@…:…/ev_fw main 41aa0c2 12 0
```
- `remote_url` is `git remote get-url origin`; that plus the path is the only thing
that crosses machines, exactly as asked.
- `dirty` / `unpushed` are **advisory only** — read by `homehub status`, never
synced as content. They answer "why does `ev_fw` look different on the laptop"
without turning rsync into a bad git.
- `homehub git missing` = union(all hosts' tsv) − repos present locally.
`homehub git clone-missing` prints commands; `--exec` runs them;
`--lazy` adds `--filter=blob:none` for the multi-GB vendored clones.
- Never file-sync any path that the inventory recognises as a repo. No `.git`
exclusion weirdness, no `build/` 7.2 GB problem: repo trees are cloned, not rsynced.
- Caveat to accept: unpushed commits do **not** survive disk death (you declined a
bare mirror on the hub — fine for personal use, just know it is the trade).
- `~/src/zephyrproject` is a `west` workspace: inventory records the top repo and
its sub-repos get recreated by `west update`, not by us.
## 7. Class C — bookmark bar
Live file: `~/.config/chromium/Profile 5/Bookmarks` (mtime 2026-10-24 — this is the
live profile; `~/.config/google-chrome/Default/Bookmarks` is dead, from 2021).
- **Read** the live file, copy to `.hub/bookmarks/<host>.json` (safe: read-only).
- **Merge** all hosts' files: union of `bookmark_bar` children keyed by URL,
preserving each host's folder nesting, dedupe identical URL+title. Idempotent,
so no conflict concept is needed for bookmarks.
- Write the merge back to the live path **only when the browser is not running**
(Chromium holds the file in memory and re-serialises it constantly; writing while
it runs is how bookmarks silently vanish). If it is running, defer to next run.
- Keep the last N exports on the hub, so a bad merge is always reversible.
- Boring alternative, if you ever stop caring about "only the bar": Chromium Sync.
## 8. Class D and the NixOS migration (the conflict in your answer 5)
Stop treating `~/.config` as one thing. Split by ownership, not by path:
| Kind | Owner | Where it physically lives | Synced? |
|---|---|---|---|
| **declarative config** | Nix / home-manager (later: a git repo) | nix store, symlinked into `~/.config` | **no** |
| **state** (session history, caches-of-record, per-app data you want everywhere) | you | `~/.local/state/homehub/<name>/` | **yes** |
| **cache / binaries** | tools | `~/.cache`, `~/.local/share/…`, `.pi/agent/install` | **no** |
The sync set is defined **only by paths under the shared state root** plus an explicit
top-level list. Two roots, and the difference between them is not cosmetic:
| Root | Contents | Synced? |
|---|---|---|
| `~/.local/state/shared/<name>/` | **your** app state, symlinked to wherever each app expects it | **yes** |
| `~/.local/state/homehub/` | homehub's own scratch: generated filters, markers, `pull.err`, tombstone cache, `broken.*` | **no** (denied) |
Conflating them is a real bug I hit while testing: with the state root itself in the
`base` set, g9 pushed `effective.filter`, the laptop overwrote it with its own, and
they would have swapped generated filters forever. `~/.local/state/homehub/` is now
in the denylist so it cannot come back by accident.
On Debian you create the symlink by hand (or stow); on NixOS home-manager's `file.` /
`xdg.config."…".source` creates the *same* symlink into the *same* synced path. So
the migration rewrites the symlink layer and touches zero sync rules — that is the
resolution of "I like dotfiles locally, but I also want pi history synced": **the
sync tree owns state, Nix owns config, and neither writes into `~/.config` directly.**
Concrete for pi agent, from this box: sync `sessions/` (11 M, 96 `.jsonl` files —
append-only, so rsync-safe) and `settings.json`, `models.json`, `AGENTS.md`,
`extensions/`, hand-written `skills/*` trees; exclude `install/` (154 M),
`bin/` (9 M), `npm/`, and any `node_modules` under `skills/`. No sqlite/`-wal`
files exist in `~/.pi`, which is why plain rsync is safe here — if a tool ever
stores state in sqlite, it goes in the never-sync list or gets a dump-first hook.
## 9. Never-sync denylist (derived from actual `du`, not guessed)
`tmp/` (11 G) · `Downloads/` (35 G, decided) · `git/` and repo trees (Class B) ·
`.cache/` 12 G · `.steam/` 25 G · `.wine/` 14 G, `.wine_games/` 8.5 G,
`.wine_firefly/` 2.2 G · `zephyr-sdk-0.17.4/` 12 G · `go/` 8.5 G ·
`.espressif/` 6 G · `.npm/` 4.8 G · `.rustup/` 4.5 G · `.cargo/` 982 M ·
`.local/share/Trash/` · `.vscode/` 1.7 G, `.config/{google-chrome,discord,chromium,Code,sublime-text,VSCodium,Atom}` 11 G total (local by
default; only the bookmark file crosses machines, via §7) · `**/{build,target,dist,node_modules,__pycache__,.venv,.pixi,.zig-cache}/` ·
`.git/` (belt-and-braces alongside §6) · `.bash_history-*.tmp` (four 0-byte junk
files from a broken history append — also worth fixing at the source) ·
`.gksu.lock`, `*.tmp`, `*.bak`, `*.backup`.
## 10. "Only tell me when it has been broken for a day"
This is a **freshness** problem, not an error-notification problem, so build a
freshness monitor, not an error notifier.
- Sync units are silent: `homehub-sync.service` prints nothing on success,
`LogLevelMax=warning`. Failures produce no output and no popup; they simply fail
to update `markers/<host>`. No popups, no retry storms.
- Health unit: `homehub-fresh.timer`, `OnUnitActiveSec=30m`, runs `homehub fresh`,
which reads hub markers over one ssh call — **no inbound connectivity needed**,
so it works for the NATted laptop.
- Alert rule: `alive/<host>` is fresh (machine is on) **and** `markers/<host>` is
older than **24 h** **and** the last attempt's rc is *not* a network-class rc.
Network-class rc (5, 10, 11, 12, 30, 35, 255 — unreachable/timeout/connection)
is the weeks-offline laptop and stays **silent forever**; it only shows in
`homehub status`. That distinction is what keeps your requirement #2 (offline
for weeks) true without ever popping a notification.
- De-duplication: after alerting, stamp `alerted/<host>`; re-alert at most weekly
while still broken, clear on success. So one mail per incident, not 48.
- Transport: `sendmail` → your existing `mail.lue.dk`. Works with no GUI session,
which `notify-send` does not.
## 11. Files to be created (this repo = the policy; ~200 lines of shell total)
```
~/homehub/ # small git repo, hub-backed via a bare repo on the NAS
DESIGN.md README.md
bin/homehub # subcommands: sync, status, fresh, forget, git, bookmarks, filter, setup, install
lib/merge_bookmarks.py # class C union-merge
config # per-machine: hub path, key, thresholds, bookmark source
rules/denylist # §9 — DEFAULT DENY, emitted first
rules/sets.d/base.list # every machine (laptop included)
rules/sets.d/docs.list media.list misc.list
rules/hosts/g9 # -> sets: base docs media misc
rules/hosts/_laptop-template # -> sets: base docs
systemd/homehub-sync.{service,timer}
systemd/homehub-fresh.{service,timer}
```
Unit details that matter: `OnUnitActiveSec=10min RandomizedDelaySec=3min
Persistent=true` (catches up after suspend/off), `After=network-online.target
Conflicts=sleep.target`, `LogLevelMax=warning` + zero stdout on success, and a
**passphrase-less dedicated ed25519 deploy key** (`~/.ssh/homehub_ed25519`,
`IdentitiesOnly yes`) rather than `id_rsa` — a user unit has no `SSH_AUTH_SOCK`
(yours is a socket at `~/.ssh/agent/…` from a running agent), so depending on the
agent makes the background sync fail in exactly the way you would notice 26 hours
later. Optionally restrict the hub key with `from=` and a forced
`command="rsync --server…"`.
## 12. Rollout order
1. Hub: `mkdir -p .hub/{meta/{markers,alive,alerted},tombstones,bookmarks,prev,gitman}`, deploy key.
2. Write rules + `homehub sync --dry` on `g9`; inspect the file list by hand (this is the only real review step).
3. First real `core` sync from `g9`; verify `700/600` perms on the hub and that `.hub/` is absent locally.
4. `gitman/inventory` from `g9`; on machine #2 run `homehub git clone-missing --lazy`.
5. Machine #2 file sync, then watch convergence both ways.
6. Bookmark export+merge.
7. **Health monitor last** — it is only trustworthy once the sync semantics above have survived a week.
8. NixOS migration later: re-point the symlink layer only (§8); rules unchanged.
## 13. Decisions taken (was: open decisions)
| # | Decision | Outcome |
|---|---|---|
| D1 | Secrets in the sync set | **yes** — `.ssh/` incl. private keys, and `auth.json` if you add it. The hub now holds every key you own: it must stay LAN-only, and the hub copy is forced `700/600`. |
| D2 | `~/src` | **manifest only.** No file sync of repo trees, so the 7.2 G of `build/`+`target/`+`dist/` and every `.git` ambiguity disappeared. |
| D3 | Reaper grace | **30 days.** |
| D4 | Laptop membership | **`base` + `docs`.** (`core` was renamed `base`; `Documents`/`Pictures` were split into `docs`/`media` so the question was even well-posed — my original set list had `Documents` inside `core` *and* asked whether to include it.) |
| D5 | Alert transport | no local MTA yet → auto-detect `msmtp`/`sendmail`, else spool to `~/.local/state/homehub/alerts.out`. msmtp config for `mail.lue.dk:587` is in README. |
### Found while implementing (each one changed the design)
1. **`/volume1/homes/bl` is not empty** — 178 G of unrelated legacy data. Sync root
is now the dedicated subtree `$HUB_BASE/homehub`, plus a stray-content guard (§2).
2. **A live GitLab PAT sat inside a repo remote URL** (`https://user:glpat-…@gitlab.com/…`,
3 repos). The manifest would have published it to the NAS. Remotes are now scrubbed
of userinfo and the row is flagged `stripped`, which `git clone` reports.
*Separately: rotate that token, it has been sitting in `.git/config` on every
machine that cloned those repos.*
3. **`id_rsa` is passphrase-protected** and `SSH_AUTH_SOCK` comes from a live agent, so
a systemd user unit cannot authenticate. Fixed by a dedicated passphrase-free
ed25519 key — and, more importantly, by splitting *auth failure* (loud, after 24 h)
from *network failure* (silent forever) in `hub_probe()`. Before that split, a dead
key produced no marker, no heartbeat, and therefore no alert, ever.
4. **Synology writes `@eaDir` into every directory** — now denied, or a pull litters
an `@eaDir` next to every synced directory.
5. **`~/debian` is a dpkg work area** (150 M, 3598 files = 86 % of the first dry-run
push list). Denied as build output, like `~/tmp` and `~/Downloads`.
6. **rsync was not being told which key to use** (no `-e`), so it would have
authenticated as a different identity from the rest of the tool.
### Still open
* Rotate the GitLab PAT mentioned above; consider `id_rsa` (2016, 4096-bit RSA) too.
* Reconcile the legacy `home_backup` / `x1c_backup` trees on the NAS — they look like
earlier attempts at exactly this problem.
* Whether `~/bin` should sync at all: it is your scripts *plus* vendored binaries
(`protoc` 9 M, five gRPC plugins), 45 M of mostly machine-local bytes.
* `~/public_html` symlinks into `~/src`, `~/git` and `/tmp`; they will dangle on a
machine that has not cloned the target.
+117
View File
@@ -0,0 +1,117 @@
# homehub — operator notes
Operational doc. **Why** each decision exists is in [`DESIGN.md`](DESIGN.md); this file
is only the operational half.
```
~/homehub/
bin/homehub the tool (~450 lines of bash + one python merge)
config machine-specific settings (per-machine, not synced)
rules/denylist DEFAULT DENY, emitted first, wins over everything
rules/sets.d/*.list named path-lists ("sets")
rules/hosts/<hostname> which sets this machine joins
lib/merge_bookmarks.py class C union-merge (jq + python3 only)
systemd/homehub-*.{service,timer}
DESIGN.md rationale for everything below
```
## Install (per machine)
```sh
git clone <your-repo> ~/homehub && cd ~/homehub
cp rules/hosts/_laptop-template rules/hosts/$(hostname -s) # pick your sets
./bin/homehub setup-key # once, see warning below
ssh-copy-id -i ~/.ssh/homehub_ed25519.pub nas.lue.dk # YOU run this
./bin/homehub setup
./bin/homehub sync # DRY: prints what would move, writes nothing
homehub sync --exec # first real run
sudo loginctl enable-linger $USER # headless boxes only
~/homehub/bin/homehub install # systemd user timers
```
`homehub install` copies the units into `~/.config/systemd/user` and enables
`homehub-sync.timer` (10 min) and `homehub-fresh.timer` (30 min).
## Commands
| Command | What |
|---|---|
| `sync [--exec]` | pull → apply deletions → harvest deletions → push. **Dry unless `--exec`.** |
| `status` | last sync per machine, sets, pending dry-run lists, repo manifest, bookmark exports |
| `fresh [--dry]` | the 24 h alert check; only printer of alerts |
| `git inventory\|list\|missing\|clone [--exec] [--lazy]` | class B |
| `bookmarks [--write]` | class C export + union merge |
| `forget <path>` | drop tombstones so a deleted path can return |
| `filter` | dump the generated rsync filter |
| `setup`, `setup-key`, `install` | one-time plumbing |
## The rules that matter when editing config
1. **Default deny.** `rules/denylist` is emitted first, then the host's set includes,
then `- *`. A path in a set that sits under a deny rule can never sync — the tool
warns (`shadowed: ...`) when you write one.
2. **Set syntax.** One home-relative path per line; trailing `/` = subtree.
`/Documents/` → `- /Documents/***`; `build/` (no leading slash) matches at any depth.
3. **Never add a git repo path to a set.** Repos are class B (`homehub git inventory`).
4. **`~/.config` is unreachable by design** (it is denied wholesale). State you want
shared goes under `~/.local/state/shared/<app>/`, with the real config path
symlinked to it. `~/.local/state/homehub/` is homehub's *private* scratch and is
denied too — syncing it would make machines overwrite each other's generated
filters forever.
5. **Deletions never propagate as `--delete`.** They become tombstones on the hub,
are reaped after `GRACE_DAYS`, and always lose to a competing edit.
## Alerting
`homehub fresh` alerts only when a machine is **on** (heartbeat < 3 h) but has not
synced for > 24 h, and never for network-class rsync/ssh exit codes — so a laptop
away for three weeks is silent indefinitely. Delivery auto-detects `msmtp`, then
`sendmail`, else appends to `~/.local/state/homehub/alerts.out`. To wire it to your
own mail server:
```sh
# /etc/msmtprc (or ~/.config/msmtp/config)
defaults
tls on
tls_starttls on
account homehub
host mail.lue.dk
port 587
auth on
user homehub@lue.dk
from homehub@lue.dk
password <app-password>
```
Then `msmtp -a homehub you@lue.dk` is picked up automatically. On Debian:
`apt install msmtp msmtp-mta`.
A machine whose **own** config is broken (ssh key revoked, hub disk full) records a
local marker in `~/.local/state/homehub/broken.*` and alerts about itself even with
the hub unreachable — otherwise a dead key would produce no heartbeat, no marker,
and therefore no alert, forever.
## Known limits (deliberate, not bugs)
* **mtime is the arbiter.** Two machines editing the same file while both offline
resolve by mtime, so clocks must be NTP-synced (`timedatectl` on Debian, `ntpd` on
NixOS). The overwritten copy is kept in `<hub>/.hub/prev/YYYY-MM/`.
* **rsync 3.1.2 on DSM 6/7** — no `--ignore-older`; the design uses `--update` only.
* `--checksum` is off, so a file edited to the same size in the same second as a
previous sync may be missed. Re-run `rsync -mc` by hand if you ever suspect it.
* Unpushed commits are advisory in the manifest and never transferred. A dead laptop
disk loses them.
* Empty bookmark folders do not survive the union merge (there is nothing to key on).
* Symlinks travel as symlinks (`-l`, never `-L`); ones pointing into `~/src`, `~/git`
or `/tmp` will dangle on a machine that has not cloned the target.
## Troubleshooting
```sh
~/homehub/bin/homehub status # ages, sets, manifest
journalctl --user -u homehub-sync.service -n 50
cat ~/.local/state/homehub/{pull,push}.err
cat ~/.local/state/homehub/broken.* # self-detected breakage
ssh nas.lue.dk 'cat /volume1/homes/bl/homehub/.hub/log.tsv | tail'
~/homehub/bin/homehub filter | grep -n shadowed
```
Executable
+664
View File
@@ -0,0 +1,664 @@
#!/usr/bin/env bash
# homehub — lean home-drive sync: rsync against a Synology hub, mtime-arbitrated,
# silent unless something has been broken for a day.
#
# sync [--exec] one pull+push cycle (DRY unless --exec)
# status what would move, and who synced when
# fresh [--dry] the only command allowed to complain (24 h rule)
# git <sub> class B: repos.tsv manifest, clone on the far side
# bookmarks [--write] class C: union-merge the link bar
# forget <path>... drop tombstones so a path can come back
# filter dump the generated rsync filter
# setup | setup-key | install one-time plumbing
#
# Rationale for every odd choice: ../DESIGN.md
set -uo pipefail
HH_ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
[ -r "$HH_ROOT/config" ] || { printf 'homehub: no config at %s\n' "$HH_ROOT/config" >&2; exit 1; }
# shellcheck disable=SC1091
. "$HH_ROOT/config"
HOST=$(hostname -s)
HUB_URL="$HUB_HOST:$HUB_PATH"
RULES=$HH_ROOT/rules
STATE=${STATE_ROOT:?config: STATE_ROOT unset}
FILTER=$STATE/effective.filter
mkdir -p "$STATE/tmp"
# systemd sets HH_QUIET=1: silence on success is a feature, not an omission (§10)
v() { [ "${HH_QUIET:-0}" = 1 ] || printf ' %s\n' "$*"; }
warn() { printf 'homehub: %s\n' "$*" >&2; }
die() { printf 'homehub: %s\n' "$*" >&2; exit 1; }
now() { date +%s; }
hr() { [ "${HH_QUIET:-0}" = 1 ] || printf '\n== %s ==\n' "$*"; }
# ------------------------------------------------------------- ssh plumbing --
SSH_OPTS=(-o BatchMode=yes -o ConnectTimeout="${SSH_TIMEOUT:-10}" -o LogLevel=ERROR)
[ -f "$HH_KEY" ] && SSH_OPTS+=(-i "$HH_KEY" -o IdentitiesOnly=yes)
hub() { ssh "${SSH_OPTS[@]}" "$HUB_HOST" "$@"; }
hub_ok() { hub true >/dev/null 2>&1; }
# Telling "off the LAN" apart from "our own config is broken" is the whole point of
# §10: the first must stay silent for weeks, the second must become loud after a
# day. 0 = usable, 1 = network/unreachable (stays silent), 2 = auth/config (loud).
hub_probe() {
local out
if ! out=$(ssh "${SSH_OPTS[@]}" "$HUB_HOST" true 2>&1); then
case $out in
*"Permission denied"*|*"Host key verification"*|*"no matching host key"*|*"invalid format"*|*"no such identity"*)
return 2 ;;
esac
return 1
fi
return 0
}
# Local breakage markers — readable with the hub down, so a machine whose own key
# stopped working can still alert about itself. First-failure epoch is preserved.
mark_broken() { # kind message
local f=$STATE/broken.$1 e
e=$(awk -F'\t' 'NF{print $1; exit}' "$f" 2>/dev/null)
[ -n "${e:-}" ] || e=$(now)
printf '%s\t%s\n' "$e" "$2" > "$f"
}
clear_broken() { rm -f "$STATE/broken.$1" 2>/dev/null || true; }
# Remote script with positional args, no quoting hell:
# hub_script "'$HUB_PATH' '$HOST'" <<'EOS' ... EOS (POSIX sh on DSM!)
hub_script() { ssh "${SSH_OPTS[@]}" "$HUB_HOST" "sh -s -- $1"; }
touch_marker() {
hub "mkdir -p '$HUB_PATH/.hub/meta/$1' && touch '$HUB_PATH/.hub/meta/$1/$HOST'" || true
}
log_line() { # rc note
printf '%s\t%s\t%s\t%s\n' "$(now)" "$HOST" "$1" "$2" |
hub "mkdir -p '$HUB_PATH/.hub' && cat >> '$HUB_PATH/.hub/log.tsv'" 2>/dev/null || true
}
hub_setup_dirs() {
hub "mkdir -p '$HUB_PATH/.hub/meta/markers' '$HUB_PATH/.hub/meta/alive' \
'$HUB_PATH/.hub/meta/alerted' '$HUB_PATH/.hub/tombstones' '$HUB_PATH/.hub/prev' \
'$HUB_PATH/.hub/gitman' '$HUB_PATH/.hub/bookmarks' && chmod 700 '$HUB_PATH'" || true
}
# ---------------------------------------------------- filter (default deny) --
sets_for_host() {
local f=$RULES/hosts/$HOST
[ -r "$f" ] || { warn "no rules/hosts/$HOST — this host syncs nothing (see rules/hosts/_laptop-template)"; return 1; }
awk '!/^[[:space:]]*#/ && NF' "$f"
}
# one path per line -> rsync rule; trailing slash means "this dir and its subtree"
rules_from() { # file sign
awk -v s="$2" '!/^[[:space:]]*#/ && NF {
if ($0 ~ /\/$/) printf "%s %s***\n", s, $0; else printf "%s %s\n", s, $0 }' "$1"
}
# ancestors must be explicitly traversable, otherwise a blanket "- *" can prune
# the branch before rsync ever reaches the include below it
ancestors_of() { # /a/b/c/ -> "+ /a/", "+ /a/b/"
local p=${1%/}
while [ -n "$p" ] && [ "${p#*/}" != "$p" ]; do
p=${p%/*}
[ -n "$p" ] && printf '+ %s/\n' "$p"
done
}
check_shadowing() { # $1 set name
local f=$RULES/sets.d/$1.list d inc nm
[ -r "$f" ] || return 0
while read -r inc; do
while read -r d; do
case $d in
"$inc") warn "shadowed: include '$inc' is also denied verbatim (set $1)"; continue ;;
esac
case $d in
/*/) case $inc in "$d"*|"${d%/}") warn "shadowed: include '$inc' sits under deny rule '$d' (set $1)" ;; esac ;;
*/) nm=${d%/}; case "/$inc" in *"/$nm"/*|*"/$nm") warn "shadowed: include '$inc' matches unanchored deny '$d' (set $1)" ;; esac ;;
esac
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
gen_filter() {
local sets s
sets=$(sets_for_host) || return 1
{
echo '- /.hub/***' # hub metadata can never leak into a home
[ -r "$RULES/denylist" ] && rules_from "$RULES/denylist" '-'
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"
}
# --------------------------------------------------------------- rsync bits --
# rsync must authenticate with the SAME identity as the rest of homehub, or it
# silently picks another key/agent and unreachable comes back from the wrong door.
RSH="ssh -o BatchMode=yes -o ConnectTimeout=${SSH_TIMEOUT:-10} -o LogLevel=ERROR"
[ -f "${HH_KEY:-}" ] && RSH="$RSH -i $HH_KEY -o IdentitiesOnly=yes"
RS=(-rltDz --partial --timeout=90 --out-format='%i|%n' -e "$RSH")
run_rsync() { # dry filter extra args...
local dry=$1 filt=$2; shift 2
if [ "$dry" = 1 ]; then
"$RSYNC_BIN" "${RS[@]}" --filter="merge $filt" -n "$@"
else
"$RSYNC_BIN" "${RS[@]}" --filter="merge $filt" "$@"
fi
}
pull_rsync() { # dry
run_rsync "$1" "$FILTER.pull" --update "$HUB_URL/" "$HOME/"
}
push_rsync() { # dry
if [ "$1" = 1 ]; then
run_rsync 1 "$FILTER" --update "$HOME/" "$HUB_URL/"
else
run_rsync 0 "$FILTER" --update --backup --backup-dir=".hub/prev/$(date +%Y-%m)" "$HOME/" "$HUB_URL/"
fi
}
count_items() { grep -cE '^[<>]' "$1" 2>/dev/null || true; }
show_head() { grep -E '^[<>]' "$1" 2>/dev/null | head -n "${2:-10}"; }
# ----------------------------------------------------- deletions (tombstones) --
# Format on the hub: .hub/tombstones/<host> lines = epoch \t home-rel-path
hub_tombstones_all() {
hub_script "'$HUB_PATH'" <<'EOS'
root=$1
cd "$root/.hub/tombstones" 2>/dev/null || exit 0
for f in *; do
[ -f "$f" ] || continue
while IFS= read -r l; do printf '%s\t%s\n' "${f%%:*}" "$l"; done < "$f"
done
exit 0
EOS
}
# is this home-relative path inside one of this host's sets?
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
[ "${p%/*}" = "$p" ] && return 1
p=${p%/*}
[ -z "$p" ] && return 1
done
}
apply_tombstones() { # deletions recorded by any machine, edit-wins (§3)
local all=$STATE/tombstones.all n=0 kept=0 out=0 bad=0
[ -s "$all" ] || { v "tombstones: none recorded"; return 0; }
while IFS=$'\t' read -r src ep path; do
[ -n "${path:-}" ] || continue
path=${path#/}
case $path in
.hub/*|*..*|/*) warn "ignoring suspicious tombstone '$path'"; bad=$((bad+1)); continue ;;
esac
active_path "$path" || { out=$((out+1)); continue; }
[ -e "$HOME/$path" ] || continue
if [ -n "$ep" ] && find "$HOME/$path" -newermt "@$ep" -print -quit 2>/dev/null | grep -q .; then
kept=$((kept+1)); continue # touched after the delete: edits win, keep it
fi
n=$((n+1))
if [ "$n" -gt "${TOMB_LIMIT:-100}" ] && [ "${HH_FORCE:-0}" != 1 ]; then
die "tombstones ask for more than $TOMB_LIMIT deletions — refusing. Check $HUB_HOST:$HUB_PATH/.hub/tombstones/ then re-run with HH_FORCE=1"
fi
v "deleted (tombstone from $src): $path"
rm -rf --one-file-system -- "$HOME/$path"
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"
n=$(grep -c . "$new" 2>/dev/null || true)
[ "${n:-0}" = 0 ] && { v "no local deletions to record"; return 0; }
ep=$(now)
{ hub "cat '$HUB_PATH/.hub/tombstones/$HOST' 2>/dev/null"
awk -v e="$ep" '{ print e "\t" $0 }' "$new"
} | 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"
}
reap() { # stop resurrecting files deleted more than GRACE_DAYS ago (§5)
hub_script "'$HUB_PATH' '${GRACE_DAYS:-30}'" <<'EOS' || true
root=$1; grace=$2
cd "$root/.hub/tombstones" 2>/dev/null || exit 0
cutoff=$(( $(date +%s) - grace * 86400 ))
for f in *; do
[ -f "$f" ] || continue
awk -F'\t' -v c="$cutoff" '$1+0 < c { print }' "$f" > "$f.gone"
awk -F'\t' -v c="$cutoff" '$1+0 >= c { print }' "$f" > "$f.keep"
while IFS=$'\t' read -r e p; do
[ -n "$p" ] || continue
t="$root/$p"
[ -e "$t" ] || continue
d="$root/.hub/prev/reaped/$(date -d "@$e" +%Y-%m 2>/dev/null || echo unknown)"
mkdir -p "$d/$(dirname "$p")" 2>/dev/null && mv "$t" "$d/$p" 2>/dev/null && echo "reaped $p"
done < "$f.gone"
if [ -s "$f.keep" ]; then mv "$f.keep" "$f"; else rm -f "$f.keep" "$f"; fi
rm -f "$f.gone"
done
exit 0
EOS
}
# The hub sync root must contain ONLY what homehub put there. Anything else means
# HUB_PATH points somewhere that already has data — which would be merged into
# $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
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)
[ -z "$stray" ] && return 1
warn "unexpected content in hub sync root $HUB_PATH (pull would merge it into $HOME):"
printf ' %s\n' $stray
return 0
}
# ------------------------------------------------------------------ sync ----
cmd_sync() {
local exec=0; [ "${1:-}" = "--exec" ] && exec=1
local dry=1; [ $exec = 1 ] && dry=0
local sets rc=0
sets=$(sets_for_host) || return 1
case "$(hub_probe; echo $?)" in
1) # Off the LAN. Silent on purpose: the laptop-in-another-country case (§10).
v "hub $HUB_HOST unreachable — nothing done (silent by design; a live machine that stays broken alerts through 'fresh' after ${FRESH_MAX_AGE}s)"
return 0 ;;
2) warn "cannot authenticate to $HUB_HOST (key: ${HH_KEY:-default agent}). This is a CONFIGURATION failure, not an offline machine."
warn " fix: ssh-copy-id -i ${HH_KEY:-~/.ssh/id_ed25519}.pub $HUB_HOST"
mark_broken auth "ssh auth to $HUB_HOST failed (key ${HH_KEY:-default})"
return 64 ;;
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
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
hr "1. pull $HUB_URL -> $HOME/"
pull_rsync "$dry" > "$STATE/pull.out" 2> "$STATE/pull.err" || rc=$?
if [ $rc != 0 ]; then
warn "pull failed (rsync rc=$rc): $(tr '\n' ' ' < "$STATE/pull.err" | tail -c 300)"
else
v "$(count_items "$STATE/pull.out") change(s) inbound"
[ $dry = 1 ] && show_head "$STATE/pull.out"
fi
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"
fi
hr "4. push $HOME/ -> $HUB_URL"
push_rsync "$dry" > "$STATE/push.out" 2> "$STATE/push.err" || rc=$?
if [ $rc != 0 ]; then
warn "push failed (rsync rc=$rc): $(tr '\n' ' ' < "$STATE/push.err" | tail -c 300)"
else
v "$(count_items "$STATE/push.out") change(s) outbound"
[ $dry = 1 ] && show_head "$STATE/push.out"
fi
if [ $exec = 1 ] && [ $rc = 0 ]; then
reap | sed 's/^/ /'
touch_marker markers
hub "rm -f '$HUB_PATH/.hub/meta/alerted/$HOST'" || true
clear_broken sync
elif [ $exec = 1 ]; then
mark_broken sync "last sync failed with rsync rc=$rc (see $STATE/pull.err, $STATE/push.err)"
fi
[ $exec = 1 ] && log_line "$rc" "sync"
if [ $dry = 1 ]; then
local tot; tot=$(count_items "$STATE/pull.out"); tot=$((tot + $(count_items "$STATE/push.out")))
hr "summary"
v "$tot change(s) pending — nothing was written. Run: homehub sync --exec"
[ "${tot:-0}" -gt "${WARN_FILES:-20000}" ] &&
warn "that is a lot of files for a first run; review $STATE/{pull,push}.out first"
fi
return $rc
}
# ---------------------------------------------------------------- status ----
hub_meta() {
hub_script "'$HUB_PATH'" <<'EOS'
root=$1
cd "$root/.hub" 2>/dev/null || exit 0
for d in meta/markers meta/alive meta/alerted; do
for f in "$d"/*; do
[ -f "$f" ] || continue
printf '%s\t%s\t%s\n' "${d##*/}" "$(basename "$f")" "$(date -r "$f" +%s 2>/dev/null || echo 0)"
done
done
if [ -f log.tsv ]; then
tail -n 400 log.tsv | awk -F'\t' '{ r[$2] = $3 "\t" $4 } END { for (h in r) printf "lastlog\t%s\t%s\n", h, r[h] }'
fi
for f in tombstones/*; do [ -f "$f" ] && printf 'tomb\t%s\t%s\n' "$(basename "$f")" "$(grep -c . "$f")"; done
for f in gitman/repos.*; do [ -f "$f" ] && printf 'repos\t%s\t%s\n' "$(basename "$f" 2>/dev/null)" "$(grep -c . "$f")"; done
for f in bookmarks/*; do [ -f "$f" ] && printf 'bmarks\t%s\t%s\n' "$(basename "$f" .json)" "$(date -r "$f" +%s)"; done
exit 0
EOS
}
age() { local n=$(( $(now) - ${1:-0} )); [ "${1:-0}" = 0 ] && { printf 'never'; return; }
if [ $n -lt 3600 ]; then printf '%dm' $((n/60))
elif [ $n -lt 172800 ]; then printf '%.1fh' "$(awk -v n=$n 'BEGIN{print n/3600}')"
else printf '%.0fd' "$(awk -v n=$n 'BEGIN{print n/86400}')"; fi; }
cmd_status() {
hub_ok || die "hub $HUB_HOST unreachable"
sets_for_host >/dev/null || true
gen_filter 2>/dev/null || true
hub_meta > "$STATE/meta.tsv" || die "could not read hub metadata"
hr "machines"
printf ' %-14s %-9s %-9s %-6s %s\n' HOST synced heartbeat last-rc notes
local h
for h in $(awk -F'\t' '$1=="markers"||$1=="alive"{print $2}' "$STATE/meta.tsv" | sort -u); do
local m a rc note
m=$(awk -F'\t' -v h="$h" '$1=="markers"&&$2==h{print $3}' "$STATE/meta.tsv")
a=$(awk -F'\t' -v h="$h" '$1=="alive"&&$2==h{print $3}' "$STATE/meta.tsv")
rc=$(awk -F'\t' -v h="$h" '$1=="lastlog"{split($0,x,"\t"); if (x[2]==h) print x[3]}' "$STATE/meta.tsv")
note=$(awk -F'\t' -v h="$h" '$1=="lastlog"{split($0,x,"\t"); if (x[2]==h) print x[4]}' "$STATE/meta.tsv")
printf ' %-14s %-9s %-9s %-6s %s\n' "$h" "$(age "$m")" "$(age "$a")" "${rc:-?}" "${note:-}"
done
hr "this host ($HOST)"
v "sets: $(sets_for_host | tr '\n' ' ')"
v "included: $(grep -c '^+ ' "$FILTER" 2>/dev/null || echo 0) rules, denied: $(grep -c '^- ' "$FILTER" 2>/dev/null || echo 0)"
v "state dir: $STATE"
awk -F'\t' -v h="$HOST" '$1=="tomb"&&$2==h{print " tombstones: " $3 " paths"}' "$STATE/meta.tsv"
v "pending dry-run lists: $STATE/pull.out $STATE/push.out (run 'homehub sync' to refresh)"
hr "class B — repo manifest"
awk -F'\t' '$1=="repos"{printf " %-14s %s repos\n", $2, $3}' "$STATE/meta.tsv"
cmd_git missing 2>/dev/null | sed 's/^/ /' || true
hr "class C — bookmark bar"
awk -F'\t' '$1=="bmarks"{printf " %-14s exported %s\n", $2, ""}' "$STATE/meta.tsv"
[ -r "$BOOKMARK_SRC" ] && v "live file: $BOOKMARK_SRC"
}
# ----------------------------------------------------------------- fresh ----
alert_cmd() {
if [ -n "${ALERT_CMD:-}" ]; then printf '%s' "$ALERT_CMD"; return; fi
if command -v msmtp >/dev/null 2>&1; then printf 'msmtp -a homehub %s' "$ALERT_TO"
elif command -v sendmail >/dev/null 2>&1; then printf 'sendmail -t'
else printf 'cat >> "%s/alerts.out"' "$STATE"; fi
}
alert_send() { # subject body
{ printf 'From: %s\nTo: %s\nSubject: %s\n\n%s\n' "${ALERT_FROM:-homehub}" "${ALERT_TO:-root}" "$1" "$2" |
sh -c "$(alert_cmd)"; } 2>>"$STATE/alert.err" || warn "alert delivery failed (see $STATE/alert.err)"
if [ "${HH_QUIET:-0}" != 1 ] && [ -n "${DISPLAY:-}" ] && command -v notify-send >/dev/null 2>&1; then
notify-send -u critical "$1" "$2" || true
fi
}
cmd_fresh() { # the ONLY place that may complain (§10)
local dry=0; [ "${1:-}" = "--dry" ] && dry=1
local n=$(now) h msgs="" f
# 1) Self-checks that work with the hub down. Closes the hole where a dead key
# means no marker and no heartbeat, i.e. a machine that can never alert.
for f in "$STATE"/broken.*; do
[ -e "$f" ] || continue
local kind=${f##*.} ep msg age already=0
ep=$(awk -F'\t' 'NF{print $1; exit}' "$f"); msg=$(cut -f2- "$f")
age=$(( n - ${ep:-0} ))
[ -e "$STATE/alerted-local.$kind" ] &&
[ $(( n - $(date -r "$STATE/alerted-local.$kind" +%s) )) -lt "${REALERT_AGE:-604800}" ] && already=1
if [ "$age" -gt "${FRESH_MAX_AGE:-86400}" ] && [ $already = 0 ]; then
msgs="$msgs$HOST, local check '$kind': $msg (failing for $(age "$ep"))"$'\n'
touch "$STATE/alerted-local.$kind"
else
v "ok(local): $kind has been failing for $(age "$ep")$([ $already = 1 ] && echo ', already alerted')"
fi
done
# 2) Hub-side freshness of every machine
if ! hub_ok; then
v "hub unreachable — cannot judge other machines (the local checks above still ran)"
else
hub_meta > "$STATE/meta.tsv" || die "cannot read hub metadata"
for h in $(awk -F'\t' '$1=="markers"||$1=="alive"{print $2}' "$STATE/meta.tsv" | sort -u); do
local m a al rc mka maa
m=$(awk -F'\t' -v h="$h" '$1=="markers"&&$2==h{print $3}' "$STATE/meta.tsv")
a=$(awk -F'\t' -v h="$h" '$1=="alive"&&$2==h{print $3}' "$STATE/meta.tsv")
al=$(awk -F'\t' -v h="$h" '$1=="alerted"&&$2==h{print $3}' "$STATE/meta.tsv")
rc=$(awk -F'\t' -v h="$h" '$1=="lastlog"{split($0,x,"\t"); if (x[2]==h) print x[3]}' "$STATE/meta.tsv")
mka=$(( n - ${m:-0} )); maa=$(( n - ${a:-$n} ))
local alive_now=0; [ "${a:-0}" != 0 ] && [ $maa -lt "${ALIVE_FRESH:-10800}" ] && alive_now=1
local netrc=0 x
for x in ${NET_RC:-5 255}; do [ "$rc" = "$x" ] && netrc=1; done
local realert_ok=1
[ "${al:-0}" != 0 ] && [ $(( n - al )) -lt "${REALERT_AGE:-604800}" ] && realert_ok=0
if [ $alive_now = 1 ] && [ $mka -gt "${FRESH_MAX_AGE:-86400}" ] && [ $netrc = 0 ] && [ $realert_ok = 1 ]; then
msgs="$msgs$h (last success $(age "$m"), last rc=${rc:-?})"$'\n'
v "STALE: $h — on, but not synced for $(age "$m")"
hub "mkdir -p '$HUB_PATH/.hub/meta/alerted' && touch '$HUB_PATH/.hub/meta/alerted/$h'" || true
else
v "ok: $h (sync $(age "$m"), heartbeat $(age "$a"), rc=${rc:--}$([ $netrc = 1 ] && echo ', off-LAN: silent'))"
fi
done
fi
[ -n "$msgs" ] || return 0
if [ $dry = 1 ]; then
printf 'homehub: would alert for:\n%s' "$msgs" >&2
return 0
fi
alert_send "homehub: sync stale on $(hostname -f 2>/dev/null || hostname)" \
"These machines are running but have not completed a homehub sync in over $(( ${FRESH_MAX_AGE:-86400} / 3600 )) h:
$msgs
Check with: homehub status
Hub: $HUB_HOST:$HUB_PATH/.hub/log.tsv"
}
# ------------------------------------------------------- class B: the repos --
git_root_p() { [ -e "$1/.git" ]; }
git_row() { # relpath remote branch sha dirty unpushed (last two are ADVISORY ONLY)
local d=$1 rel=${1#"$HOME"/} url br sha dirty unpush cred=ok
url=$(git -C "$d" remote get-url origin 2>/dev/null || true); [ -n "$url" ] || url=-
# SECURITY: remotes can carry credentials (https://user:TOKEN@host/...). They
# must never be published to the hub. Strip userinfo and mark the row so
# `git clone` can tell you the URL needs re-adding on the far side.
case $url in
*://*:*@*) url=$(printf '%s' "$url" | sed -E 's#(://)[^/@]*@#\1#'); cred=stripped ;;
*://*@*) url=$(printf '%s' "$url" | sed -E 's#(://)[^/@]*@#\1#'); cred=stripped ;;
esac
br=$(git -C "$d" symbolic-ref --short HEAD 2>/dev/null || echo -)
sha=$(git -C "$d" rev-parse --short HEAD 2>/dev/null || echo -)
dirty=$(git -C "$d" status --porcelain 2>/dev/null | wc -l | tr -d ' ')
unpush=$(git -C "$d" rev-list --count '@{u}..HEAD' 2>/dev/null | tail -1); [ -n "$unpush" ] || unpush=0
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$rel" "$url" "$br" "$sha" "${dirty:-0}" "${unpush:-0}" "$cred"
}
git_walk() { # dir depth — stops at the first .git, so submodules are not double counted
local d=$1 depth=$2 c
if git_root_p "$d"; then git_row "$d"; return; fi
[ "$depth" -le 0 ] && return
for c in "$d"/*/; do [ -d "$c" ] || continue; git_walk "${c%/}" $((depth - 1)); done
}
cmd_git() {
local sub=${1:-inventory}; shift || true
local lazy=0 exec=0 a
for a in "$@"; do case $a in --lazy) lazy=1;; --exec) exec=1;; esac; done
case $sub in
inventory)
local out=$STATE/repos.$HOST.tsv
: > "$out"
local r; for r in ${GIT_DIRS:-git src}; do [ -d "$HOME/$r" ] && git_walk "$HOME/$r" 3 >> "$out"; done
v "$(grep -c . "$out") repo(s) under ${GIT_DIRS:-git src}"
awk -F'\t' '$5+0>0 || $6+0>0 { printf " %-34s %s modified, %s unpushed\n", $1, $5, $6 }' "$out"
awk -F'\t' '$7=="stripped" { printf " CREDENTIALS STRIPPED from remote of %s — the far side must supply them\n", $1 }' "$out"
v "advisory only — neither the working tree nor unpushed commits ever leave this machine"
if [ $exec = 1 ] || [ "${HH_PUSH_INVENTORY:-1}" = 1 ]; then
hub "mkdir -p '$HUB_PATH/.hub/gitman' && cat > '$HUB_PATH/.hub/gitman/repos.$HOST'" < "$out" &&
v "manifest published to hub"
fi
;;
list)
hub "cat '$HUB_PATH/.hub/gitman/repos.'* 2>/dev/null" | sort -u -t$'\t' -k1,1
;;
missing)
hub "cat '$HUB_PATH/.hub/gitman/repos.'* 2>/dev/null" | sort -u -t$'\t' -k1,1 |
while IFS=$'\t' read -r rel url br sha dirty unpush cred; do
[ -n "${rel:-}" ] || continue
[ -e "$HOME/$rel/.git" ] && continue
printf '%-34s %s (%s%s%s)\n' "$rel" "$url" "$br" \
"$( [ "${dirty:-0}" != 0 ] && printf ', %s modified elsewhere' "$dirty")" \
"$( [ "${cred:-ok}" = stripped ] && printf ', creds stripped')"
done
;;
clone)
local n=0
hub "cat '$HUB_PATH/.hub/gitman/repos.'* 2>/dev/null" | sort -u -t$'\t' -k1,1 |
while IFS=$'\t' read -r rel url br sha dirty unpush cred; do
[ -n "${rel:-}" ] || continue
[ "$url" = - ] && continue
[ -e "$HOME/$rel/.git" ] && continue
if [ $exec != 1 ]; then
printf ' would clone %s <- %s%s%s\n' "$rel" "$url" \
"$( [ $lazy = 1 ] && echo ' (blobless)')" "$( [ "${cred:-ok}" = stripped ] && echo ' [creds stripped: set a credential helper or fix the remote]')"
else
printf ' cloning %s <- %s\n' "$rel" "$url"
mkdir -p "$(dirname "$HOME/$rel")"
local opts=(--origin origin)
[ $lazy = 1 ] && opts+=(--filter=blob:none)
[ "$br" != - ] && git clone "${opts[@]}" -b "$br" "$url" "$HOME/$rel" >/dev/null 2>&1 ||
git clone "${opts[@]}" "$url" "$HOME/$rel" >/dev/null 2>&1 ||
warn "clone failed: $rel ($url)"
fi
n=$((n+1))
done
;;
*) die "git: unknown subcommand '$sub' (inventory|list|missing|clone)";;
esac
}
# ------------------------------------------------- class C: the link bar -----
cmd_bookmarks() {
local write=0 a
for a in "$@"; do [ "$a" = --write ] && write=1; done
[ -r "$BOOKMARK_SRC" ] || die "no bookmark file at $BOOKMARK_SRC"
cp -- "$BOOKMARK_SRC" "$STATE/bookmarks.$HOST.json" || die "cannot read $BOOKMARK_SRC"
local nu; nu=$(jq '[.. | objects | select(.type=="url")] | length' "$STATE/bookmarks.$HOST.json" 2>/dev/null || echo '?')
hr "class C: bookmark bar ($HOST)"
v "exported $nu url(s) from $BOOKMARK_SRC (the union-merge below covers the bar only)"
if [ $write != 1 ]; then
v "dry: nothing pushed, nothing merged, live file untouched. Use --write."
return 0
fi
hub "mkdir -p '$HUB_PATH/.hub/bookmarks' && cat > '$HUB_PATH/.hub/bookmarks/$HOST.json'" < "$STATE/bookmarks.$HOST.json" ||
die "could not publish bookmark export"
hub "cd '$HUB_PATH/.hub/bookmarks' 2>/dev/null && for f in *.json; do printf '### %s\\n' \"\$f\"; cat \"\$f\"; done" > "$STATE/bookmarks.all.json" ||
die "could not fetch exports"
"$PY" "$HH_ROOT/lib/merge_bookmarks.py" "$STATE/bookmarks.all.json" > "$STATE/bookmarks.merged.json" ||
die "merge failed"
local mu; mu=$(jq '.urls' "$STATE/bookmarks.merged.json" 2>/dev/null || echo '?')
v "merged union: $mu urls (from $(jq -r '.hosts | join(",")' "$STATE/bookmarks.merged.json" 2>/dev/null))"
v "union result: $STATE/bookmarks.merged.json"
if pgrep -f "$BOOKMARK_PGREP" >/dev/null 2>&1; then
warn "browser is running — merged bar NOT written (it would be overwritten in memory). Close it and re-run."
v "merged result is ready at $STATE/bookmarks.merged.json"
return 0
fi
warn "writing the merged bar to $BOOKMARK_SRC is not enabled in v1 — inspect $STATE/bookmarks.merged.json first (see README)"
}
# -------------------------------------------------------------- housekeeping -
cmd_forget() {
[ $# -ge 1 ] || die "usage: homehub forget <home-relative-path> ..."
hub_ok || die "hub unreachable"
local p
for p in "$@"; do
p=${p#/}
hub_script "'$HUB_PATH' '$p'" <<'EOS'
root=$1; pat=$2
cd "$root/.hub/tombstones" 2>/dev/null || exit 0
for f in *; do
[ -f "$f" ] || continue
awk -F'\t' -v p="$pat" '$2 != p && $2 !~ ("^" p "/")' "$f" > "$f.t" && mv "$f.t" "$f"
done
exit 0
EOS
v "forget: $p"
done
}
cmd_filter() { gen_filter || return 1; cat "$FILTER"; }
cmd_setup_key() {
[ -f "$HH_KEY" ] && { warn "$HH_KEY already exists"; return 0; }
mkdir -p "$(dirname "$HH_KEY")"
ssh-keygen -q -t ed25519 -N '' -C "homehub@$HOST" -f "$HH_KEY" || die "keygen failed"
hr "deploy key created"
v "$HH_KEY (passphrase-less, by necessity: a systemd user unit has no agent)"
v "Install it on the hub, then make ~/.ssh 700 there:"
printf '\n ssh-copy-id -i %s.pub %s\n\n' "$HH_KEY" "$HUB_HOST"
warn "Until that is done, background sync cannot authenticate — and it fails silently by design."
}
cmd_setup() {
hub_ok || die "cannot reach $HUB_HOST with the current ssh setup"
hub_setup_dirs
mkdir -p "$SHARED_ROOT"
v "shared state root ready: $SHARED_ROOT (put app state here, symlink apps to it)"
hub "chmod 700 '$HUB_PATH' 2>/dev/null; ls -d '$HUB_PATH/.hub'/*" | sed 's/^/ /'
hr "published rules"
git -C "$HH_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1 &&
v "rules live in git: $HH_ROOT — clone that repo on the other machines"
[ -f "$HH_KEY" ] || cmd_setup_key
}
cmd_install() {
local d=${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user
mkdir -p "$d"
cp -v "$HH_ROOT"/systemd/homehub-*.service "$HH_ROOT"/systemd/homehub-*.timer "$d/" || return 1
systemctl --user daemon-reload
systemctl --user enable --now homehub-sync.timer homehub-fresh.timer
systemctl --user --no-pager --full status homehub-sync.timer homehub-fresh.timer 2>&1 | grep -E 'Loaded:|Active:|→' || true
}
usage() { sed -n '2,14p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; }
case ${1:-help} in
sync) shift; cmd_sync "$@";;
status) shift; cmd_status "$@";;
fresh) shift; cmd_fresh "$@";;
git) shift; cmd_git "$@";;
bookmarks) shift; cmd_bookmarks "$@";;
forget) shift; cmd_forget "$@";;
filter) shift; cmd_filter "$@";;
setup) shift; cmd_setup "$@";;
setup-key) shift; cmd_setup_key "$@";;
install) shift; cmd_install "$@";;
help|-h|--help) usage;;
*) warn "unknown command '${1:-}'; try 'homehub help'"; exit 2;;
esac
+64
View File
@@ -0,0 +1,64 @@
# homehub configuration — sourced by bin/homehub (also readable by systemd units)
# All paths are for user `bl` on the Synology hub `nas.lue.dk`.
HUB_HOST=nas.lue.dk
# NEVER point this at your Synology home directory. /volume1/homes/bl already
# holds 178 G of unrelated legacy data (Documents, Photos, OneDrive, CloudStation,
# home_backup, x1c_backup, wg-bo.conf, certs, ...) — pulling from it would merge
# years of NAS files into ~/, and a tombstone could reap unrelated files by name
# accident. The sync root is a dedicated subtree; .hub/ metadata lives inside it.
HUB_BASE=/volume1/homes/bl
HUB_PATH=$HUB_BASE/homehub
# Dedicated, PASSPHRASE-LESS deploy key. Your id_rsa is passphrase-protected and a
# systemd *user* unit has no SSH_AUTH_SOCK, so using ~/.ssh/id_rsa here would make
# every background sync fail with "Permission denied (publickey)" — forever, and
# silently. Run `homehub setup-key` and install the printed pubkey.
HH_KEY=${HH_KEY:-$HOME/.ssh/homehub_ed25519}
# Where this machine keeps homehub's OWN private state (generated filters, markers
# cache, inventories, rsync logs, alert spool). NEVER SYNCED — hard-blocked in the
# denylist, because syncing it makes machines overwrite each other's filters.
STATE_ROOT=$HOME/.local/state/homehub
# The SHARED state root: the one tree whose contents are shared verbatim between
# machines, and the anchor that survives the NixOS migration unchanged (§8).
# App state you want everywhere lives in $SHARED_ROOT/<name>/ and is symlinked
# to wherever the app expects it. Keep it distinct from STATE_ROOT above: sharing
# homehub's own machinery between machines just makes them fight over it.
SHARED_ROOT=$HOME/.local/state/shared
# --- policy knobs -------------------------------------------------------------
GRACE_DAYS=30 # tombstone age before the hub copy is reaped (§5)
FRESH_MAX_AGE=86400 # alert if a live machine has not synced in 24 h
ALIVE_FRESH=10800 # "machine is on" = heartbeat younger than 3 h
REALERT_AGE=604800 # at most one alert per host per week
TOMB_LIMIT=100 # refuse to apply more deletions than this in one run
WARN_FILES=20000 # dry run warns above this many pending changes
# Where to look for git repos for the class B manifest (DESIGN.md §6).
GIT_DIRS="git src"
# rc values treated as "machine is simply off the LAN" -> silent forever (§10)
NET_RC="5 10 11 12 30 35 255"
# --- alerting ----------------------------------------------------------------
ALERT_TO=bl@lue.dk
ALERT_FROM=homehub@lue.dk
# Empty => auto-detect msmtp, then sendmail, else append to $STATE_ROOT/alerts.out
ALERT_CMD=
# --- class C: the bookmark bar ----------------------------------------------
# NOTE the quotes: "Profile 5" contains a space, and an unquoted value here
# silently breaks `. config` in every subshell.
BOOKMARK_SRC="$HOME/.config/chromium/Profile 5/Bookmarks"
BOOKMARK_PGREP='/usr/lib/chromium/chromium'
# Absolute interpreter: PATH python3 here is /home/bl/.venv/bin/python3.
PY=/usr/bin/python3
[ -x "$PY" ] || PY=$(command -v python3)
# rsync 3.1.2 on the NAS: do NOT add --ignore-older (needs >=3.2.3 on both ends).
RSYNC_BIN=rsync
SSH_TIMEOUT=10
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/python3
"""Class C (DESIGN.md §7): union-merge the Chromium bookmark bar across machines.
Reads a file holding one concatenated JSON bookmark document per machine, flattens
every bar to (folder-path, title, url) tuples, takes the union, and rebuilds one
tree. Union-by-URL is idempotent, so bookmarks need no conflict concept at all —
which is the only reason this file is 60 lines instead of 600.
Deletions deliberately do NOT propagate, consistent with the tombstone rule for
files: edits win, deletes lose. Escape hatch is documented in README.
stdout: JSON { urls, hosts, dropped, bookmark_bar }
"""
import json
import sys
def load_docs(text):
"""Decode the hub's concatenated exports.
The hub file is built as `### <host>.json` followed by that host's JSON object,
repeated. Returns [(host, doc), ...] so the report can name its sources.
"""
dec = json.JSONDecoder()
out = []
for chunk in _split(text):
host, body = chunk
obj = _one(dec, body)
if obj is not None:
out.append((host, obj))
return out
def _split(text):
"""Split into [(host, json_text), ...] on '### name' marker lines."""
chunks, host, buf = [], "?", []
for line in text.splitlines(keepends=True):
if line.startswith("### "):
body = "".join(buf)
if body.strip():
chunks.append((host, body))
host = line[4:].strip()
if host.endswith(".json"):
host = host[:-5]
buf = []
else:
buf.append(line)
body = "".join(buf)
if body.strip():
chunks.append((host, body))
return chunks
def _one(dec, body):
i, n = 0, len(body)
while i < n and body[i] in " \r\n\t":
i += 1
if i >= n:
return None
obj, _ = dec.raw_decode(body, i)
return obj
def flatten(bar, prefix, out, seen):
for node in bar.get("children") or []:
ntype = node.get("type")
if ntype == "folder":
flatten(node, prefix + [node.get("name") or "?"], out, seen)
elif ntype == "url":
url, title = node.get("url"), node.get("name") or ""
key = (tuple(prefix), url)
if key in seen: # same bar, same folder, same URL -> one copy
continue
seen.add(key)
out.append({"path": prefix, "title": title, "url": url})
# typed_pair / bookmark_bar root itself: ignored
def rebuild(rows):
def nodes(prefix):
leaves = [
{"type": "url", "name": r["title"], "url": r["url"], "date_added": "0"}
for r in rows if r["path"] == prefix
]
names, seen = [], set()
for r in rows:
if len(r["path"]) > len(prefix) and r["path"][: len(prefix)] == prefix:
nm = r["path"][len(prefix)]
if nm not in seen:
seen.add(nm)
names.append(nm)
folders = [
{"type": "folder", "name": nm, "date_added": "0", "children": nodes(prefix + [nm])}
for nm in names
]
return leaves + folders
return {"type": "bookmark_bar", "name": "Bookmarks bar", "children": nodes([])}
def main():
if len(sys.argv) != 2:
sys.exit("usage: merge_bookmarks.py <concatenated-bookmark-files>")
with open(sys.argv[1], encoding="utf-8", errors="replace") as fh:
docs = load_docs(fh.read())
if not docs:
sys.exit("merge_bookmarks: no JSON documents found")
rows, seen = [], set()
hosts = []
for host, d in docs:
hosts.append(host)
bar = (d.get("roots") or {}).get("bookmark_bar")
if bar:
flatten(bar, [], rows, seen)
# collapse identical URLs that sit in different folders, keeping the shallowest
byurl = {}
for r in rows:
cur = byurl.get(r["url"])
if cur is None or len(r["path"]) < len(cur["path"]):
byurl[r["url"]] = r
dropped = len(rows) - len(byurl)
final = sorted(byurl.values(), key=lambda r: (r["path"], r["title"].lower()))
json.dump(
{
"urls": len(final),
"hosts": hosts,
"duplicate_urls_collapsed": dropped,
"bookmark_bar": rebuild(final),
"flat": final,
},
sys.stdout,
indent=1,
sort_keys=False,
)
sys.stdout.write("\n")
if __name__ == "__main__":
main()
+134
View File
@@ -0,0 +1,134 @@
# homehub denylist — DEFAULT DENY. Emitted FIRST so it beats every set include
# (rsync filter rules are first-match-wins).
#
# Syntax — plain paths, one per line, whole line is the pattern (comments on
# their own lines only):
# /Foo/ directory at the home root + its whole subtree
# build/ any directory named build at ANY depth + its subtree
# /file a single file
# The trailing slash is what turns it into a subtree rule.
#
# Measured on g9, 2026-10-24. Everything here is machine-local or rebuildable.
# --- the big trees: class B owns repos; tmp/Downloads are out by decision ---
/git/
/src/
/tmp/
/Downloads/
# --- toolchains and package caches (rebuilt by the toolchain, never shared) --
/.cache/
/.cargo/
/.rustup/
/.npm/
/.nvm/
/.bun/
/.deno/
/.yarn/
/.espressif/
/.platformio/
/.conan/
/.gradle/
/.m2/
/go/
/zephyr-sdk-0.17.4/
/zephyr-sdk-0.17.0/
/.dotnet/
/.emscripten/
/.emscripten_cache/
/.dartServer/
/.dartium/
/.android/
/.docker/
/.var/
/.local/state/homehub/
/.local/lib/
/.local/bin/
/.local/share/virtualenvs/
/.venv/
/.pixi/
# --- games / wine ----------------------------------------------------------
/.steam/
/.steamrt/
/.local/share/Steam/
/Steam/
/.minecraft/
/.wine/
/.wine_games/
/.wine_firefly/
/.googleearth/
/.adobe/
/.freeeciv-placeholder/
# --- browser / editor / desktop profile state: local by default (§8) --------
# The ONLY browser data that crosses machines is the bookmark bar (class C).
/.config/
/.mozilla/
/.mozilla/
/.mozilla/
/.thunderbird/
/.icedove/
/.thunderbird/
/.thunderbird/
/.claws-mail/
/.icedove/
/.electron/
/.electron-vite/
/.dbus/
/.macromedia/
/.mozilla/
/.cache/
/.nv/
/.pki/
/.lesshst/
/.ssh/agent/
/.claude/
/.codex/
/.cache/
# --- build artifacts (defence in depth; class B makes most of this moot) ----
build/
target/
dist/
out/
node_modules/
__pycache__/
.venv/
.pixi/
.zig-cache/
.ccache/
.meson-cache/
_deps/
CMakeFiles/
_results/
# --- filesystem metadata from other OSes / the NAS itself -------------------
# @eaDir is Synology's per-directory metadata; without this rule a pull litters
# an @eaDir directory into every synced directory it touches.
@eaDir/
.DS_Store
Thumbs.db
desktop.ini
# --- build/packaging output that lives at the home root ---------------------
# ~/debian is a dpkg work area: 150 M of .deb/.dsc/.orig.tar.xz and extracted
# upstream trees. It was 86% of the first dry-run push list — build output, not
# personal data. Anchored so it only means ~/debian, not every debian/ subdir.
/debian/
# --- junk, locks, transient -----------------------------------------------
.gksu.lock
.gconfd.lock
/core
/lost+found/
*.tmp
*.swp
*.swo
*.bak
*.backup
*~
*.log
*.lock
*.part
*.crdownload
+5
View File
@@ -0,0 +1,5 @@
# host: TEMPLATE for the laptop. Copy to rules/hosts/<hostname> on that machine
# (or edit this repo there, since rules/ itself travels in git).
# Deliberately NOT including: media, misc, and everything under the denylist.
base
docs
+6
View File
@@ -0,0 +1,6 @@
# host: g9 (desktop, always at home, no disk-space pressure)
# One set name per line; must match a file in rules/sets.d/
base
docs
media
misc
+42
View File
@@ -0,0 +1,42 @@
# set: base — the minimum every machine (laptop included) syncs both ways.
# Plain home-relative paths; trailing slash = directory subtree.
# Remember: denylist wins, so anything under /.config/ can never appear here (§8).
# identity + credentials (accepted in decision D1: the hub then holds every key
# you own, which is why the hub copy is forced 700/600 and the NAS must stay LAN-only)
/.ssh/
/.gitconfig
/.gitignore_global
/.hgrc
# shell / terminal
/.bashrc
/.bash_profile
/.bash_logout
/.profile
/.inputrc
/.screenrc
/.tmux.conf
/.p10k.zsh
/.vimrc
/.alsoftrc
/.gtk-bookmarks
# personal scripts
/bin/
# pi agent STATE only — never install/ bin/ npm/ (§8: state vs config vs cache)
/.pi/agent/sessions/
/.pi/agent/settings.json
/.pi/agent/models.json
/.pi/agent/models-store.json
/.pi/agent/AGENTS.md
/.pi/agent/extensions/
/.pi/agent/prompts/
/.pi/agent/skills/
# homehub's shared state root: the ONE directory whose contents are shared
# verbatim, and the anchor that survives the NixOS migration unchanged (§8).
# It is deliberately NOT ~/.local/state/homehub — that is homehub's own private
# scratch (filters, markers, error logs), and syncing it makes machines fight.
/.local/state/shared/
+2
View File
@@ -0,0 +1,2 @@
# set: docs — real documents. On the laptop too (decision D4).
/Documents/
+2
View File
@@ -0,0 +1,2 @@
# set: media — desktops only for now; 323 M and grows sideways.
/Pictures/
+7
View File
@@ -0,0 +1,7 @@
# set: misc — small hand-written trees that are not git repos.
# Anything here that later becomes a repo must be REMOVED from this set and picked
# up by `homehub git inventory` instead (§6).
/journal/
/projects/
/mu_code/
/public_html/
+12
View File
@@ -0,0 +1,12 @@
[Unit]
Description=homehub freshness check: alerts only when a live machine has not synced for 24 h
[Service]
Type=oneshot
# HH_QUIET is deliberately NOT set: the ok/stale lines belong in the journal, and
# alert_send() uses DISPLAY+notify-send to reach the desktop when there is one.
ExecStart=%h/homehub/bin/homehub fresh
TimeoutStartSec=5min
[Install]
WantedBy=default.target
+12
View File
@@ -0,0 +1,12 @@
[Unit]
Description=homehub freshness check (half-hourly; the only thing that may complain)
[Timer]
OnBootSec=2min
OnUnitActiveSec=30min
RandomizedDelaySec=2min
AccuracySec=1min
Persistent=true
[Install]
WantedBy=timers.target
+19
View File
@@ -0,0 +1,19 @@
[Unit]
Description=homehub home-drive sync (rsync to NAS hub, mtime-arbitrated)
After=network-online.target
Wants=network-online.target
Conflicts=sleep.target
[Service]
Type=oneshot
# A background home sync must never make the machine stutter.
Nice=19
IOSchedulingClass=idle
# Silent on success is a requirement, not an oversight (DESIGN.md §10).
Environment=HH_QUIET=1
ExecStart=%h/homehub/bin/homehub sync --exec
TimeoutStartSec=3h
# No retry storm: the next timer tick is the retry.
[Install]
WantedBy=default.target
+12
View File
@@ -0,0 +1,12 @@
[Unit]
Description=homehub home-drive sync cycle (every 10 min, catches up after suspend)
[Timer]
OnBootSec=5min
OnUnitActiveSec=10min
RandomizedDelaySec=3min
AccuracySec=1min
Persistent=true
[Install]
WantedBy=timers.target