Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 97 additions & 2 deletions nix/home/mutagen-sync.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/<host> 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.
Expand Down
Loading