From 07e4ccaf066d1d402f36d43c1fc09886e0f73aaf Mon Sep 17 00:00:00 2001 From: gapul <92638132+gapul@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:47:37 +0900 Subject: [PATCH] feat(mutagen): pair a host the first time we ssh into it The peers list was a curated list of one, which is the shape that guarantees it goes stale. A zsh wrapper hands the argv to a small script that works out the destination, skips what should not be paired, and creates the session once. --- nix/home/mutagen-sync.nix | 99 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 97 insertions(+), 2 deletions(-) diff --git a/nix/home/mutagen-sync.nix b/nix/home/mutagen-sync.nix index ed68d2e1..09f9b95b 100644 --- a/nix/home/mutagen-sync.nix +++ b/nix/home/mutagen-sync.nix @@ -20,8 +20,17 @@ # far side. That is fine for machines we own (homelab, macmini) and not fine for an external # company server, so those go through mutagen over the SSH connection we already have. # +# Which hosts get a session: whichever ones we actually ssh into. The zsh wrapper below pairs +# a host the first time we reach it, so the list maintains itself instead of being curated here. +# The peers list stays for the one host that needs a hand-written entry (see sshHost). +# # Why not restic-backed up: the data exists on both sides by definition, so restic keeps -# covering ~/Sync/syncthing only (see home/restic-backup.nix). +# covering ~/Sync/syncthing only (see home/restic-backup.nix). Nothing here is the only copy +# of anything — that is the price of not backing it up, and it is the rule for what we drop in. +# +# Why no failure notification, unlike restic: a stalled sync is noticed the moment a file does +# not show up on the other side, which is the only time this matters. `mutagen sync list` is the +# check. (If that stops being true, the pattern to copy is restic-backup.nix's ntfy notify().) # # Not for source code. Repositories are cloned on both sides with ghq and moved with git; # .git carries absolute paths (git worktrees) and an index that must stay consistent with the @@ -100,9 +109,95 @@ let fi exit 0 ''; + + # Pair whatever host we just reached over ssh. Called by the zsh wrapper with the very argv + # the user typed, so the destination has to be dug out of it the way ssh itself would. + ensureScript = pkgs.writeShellScriptBin "mutagen-sync-ensure" '' + set -uo pipefail + export PATH=${mutagenBin}:$PATH + + stateDir="${home}/.local/state/mutagen-sync" + mkdir -p "$stateDir" "${logDir}" + # Nothing is ever printed to the caller: this runs behind an interactive ssh. + exec >>"${logDir}/auto.log" 2>&1 + + dest="" + while [ $# -gt 0 ]; do + case "$1" in + # the short options that take a separate value + -[bcDEeFIiJLlmOopQRSWw]) shift 2 || exit 0 ;; + -*) shift ;; + *) dest="$1"; break ;; + esac + done + [ -n "$dest" ] || exit 0 + host="''${dest#*@}" + + # Only named entries pair. An address is a one-off, and it would name the sync directory + # after something that changes; *-sync is our own alias for a host the peers list owns. + case "$host" in + *.* | *:* | */*) exit 0 ;; + *-sync) exit 0 ;; + esac + case " github localhost " in *" $host "*) exit 0 ;; esac + + # Alias-proof identity: ssh -G resolves locally, so rpi4 / rpi / raspberrypi collapse into + # one key and cannot end up as three sessions pointed at the same directory. + key=$(/usr/bin/ssh -G "$host" 2>/dev/null | awk ' + $1=="hostname"{h=$2} $1=="user"{u=$2} $1=="port"{p=$2} + END{if(h==""){exit 1}; s=u"_"h"_"p; gsub(/[^A-Za-z0-9_.-]/,"_",s); print s}') + [ -n "$key" ] || exit 0 + marker="$stateDir/$key" + if [ -f "$marker" ] && mutagen sync list "$(cat "$marker")" >/dev/null 2>&1; then + exit 0 + fi + if mutagen sync list "$host" >/dev/null 2>&1; then + printf '%s' "$host" >"$marker" + exit 0 + fi + + localDir="${home}/Sync/$host" + remoteDir="Sync/$(scutil --get LocalHostName 2>/dev/null || hostname -s)" + + # Reachability and the parent directory in one round trip. BatchMode so a host that wants a + # password fails here instead of waiting on a prompt that nobody can see. + if ! /usr/bin/ssh -o BatchMode=yes -o ConnectTimeout=10 "$host" "mkdir -p '$remoteDir'"; then + echo "$(date '+%F %T') skip $host: no unattended login" + exit 0 + fi + + mkdir -p "$localDir" + echo "$(date '+%F %T') creating session $host" + if mutagen sync create \ + --name="$host" \ + --ignore-vcs \ + --ignore=.DS_Store \ + "$localDir" "$host:$remoteDir"; then + printf '%s' "$host" >"$marker" + else + # leave no empty ~/Sync/ behind to suggest a pairing that does not exist + rmdir "$localDir" 2>/dev/null || true + fi + exit 0 + ''; in { - home.packages = [ pkgs.mutagen ]; + home.packages = [ + pkgs.mutagen + ensureScript + ]; + + # Pair a host the first time we ssh into it. Deliberately after the session ends: during it a + # second connection would ask the Bitwarden agent to approve again, and on a host carrying port + # forwards it would fail outright. The wrapper never touches ssh's own behaviour or exit code. + programs.zsh.initContent = lib.mkAfter '' + function ssh() { + command ssh "$@" + local rc=$? + (mutagen-sync-ensure "$@" &) >/dev/null 2>&1 + return $rc + } + ''; # One agent per peer. The agent only ensures the session exists; the synchronization itself is # carried by mutagen's own daemon, which the CLI starts on demand and which stays resident.