rsend
=====
Push configured phone folders to a home SSH host using real rsync.

rsend backs up folders such as DCIM, WhatsApp media, and call recordings to a
remote host over SSH. One-way push only, on an unmetered network by default,
periodic or manual. Small, boring, self-contained: real rsync and a pure-Go SSH
transport, both built from source and shipped inside the APK. No foreign
prebuilt binaries.


What it does
------------
- Pushes one or more local folders to remote paths over rsync-over-SSH.
- Incremental: only changed files move; large videos resume (rsync --partial).
- Per-folder deletion policy: additive backup (default) or mirror (--delete).
- Runs on an unmetered network, periodically (WorkManager) or on demand.
- ed25519 key auth with strict, pinned host-key verification.


Layout
------
- rsh/       pure-Go SSH transport used as rsync's remote shell (-e).
- rsync/     build script that compiles pinned rsync from source via the NDK.
- app/       Android app (Kotlin, classic Views); bundles both as lib*.so.
- ci/        CI-agnostic build and test scripts; the Makefile drives them.
- metadata/  reproducibility and F-Droid build notes.
- versions   pinned toolchain and source versions; the single source of truth.


Build
-----
The repository is self-contained: clone it and the build fetches and pins
everything else from source. The only host prerequisites are a POSIX shell,
curl, python3, tar, and git. The toolchain (JDK, Go, Android SDK and NDK,
Gradle) and the rsync source are downloaded and version-pinned by the build
(see versions for every pin).

    make setup                    # provision the toolchain into $HOME/toolchains
    . "$HOME/toolchains/env.sh"    # put it on PATH (do this in each shell)
    make                          # rsync (NDK) -> rsh (Go) -> APK
    make test                     # Go tests plus the host-side end-to-end push
    make verify-repro             # build twice, diff the artifacts

The APK lands under app/build/outputs/apk/. It is unsigned by default; signing
config is local and gitignored. Continuous integration runs the same scripts:
ci/setup-toolchain.sh then ci/build.sh and ci/test.sh.


Run
---
1. Install the APK and grant all-files access and notifications.
2. Generate a key in the app and add the shown public key to the home host's
   ~/.ssh/authorized_keys.
3. Set the remote host, user, and port; run Test connection and accept the
   pinned host-key fingerprint.
4. Add folders, choose each folder's deletion policy, set the schedule.
5. Tap Sync now, or wait for the periodic sync. Scheduled intervals have a
   15-minute minimum imposed by WorkManager.

For scheduled backup to run reliably, tap Battery and allow rsend to ignore
battery optimization. Phones with aggressive power management (Samsung, Xiaomi,
and others) otherwise delay or skip background jobs; see dontkillmyapp.com.


Server
------
Any host with sshd and rsync works, old rsyncs included (stock macOS); rsend
pushes over rsync-over-SSH, not SFTP. rsync creates the final component of a
folder's remote path on its own; create deeper missing parents once with
mkdir -p on the server.

The account is confined to "rsync into one folder and nothing else" by a forced
rrsync command, not by its shell. sshd runs that command through the account's
login shell, so the shell must be real: /bin/sh works, while /bin/false or
/sbin/nologin would break rsync. The confinement comes from key-only auth, the
forced command, and rrsync's write-only root. As root on the server:

    useradd -m -d /srv/backup -s /bin/sh rsendbackup
    passwd -l rsendbackup
    mkdir -p /srv/backup/phone && chown -R rsendbackup:rsendbackup /srv/backup

This assumes UsePAM yes in sshd_config (the default on most distros; set it if
your build has it off). With UsePAM yes a password-locked account still accepts
key logins. With UsePAM no, sshd refuses any locked account even for keys
("account is locked"); there, skip passwd -l, set PasswordAuthentication no, and
leave the account with a non-locked password field.

Put the app's public key in /srv/backup/.ssh/authorized_keys as one restricted
line that forces rrsync, write-only, into that directory:

    command="/usr/bin/rrsync -wo /srv/backup/phone",restrict ssh-ed25519 AAAA... rsend

To apply the same limit account-wide, so it holds even if another key is added
later and not only on this key, force the command in sshd_config and reload
sshd:

    Match User rsendbackup
        ForceCommand /usr/bin/rrsync -wo /srv/backup/phone
        PasswordAuthentication no
        PermitTTY no
        AllowTcpForwarding no
        AllowAgentForwarding no
        AllowStreamLocalForwarding no
        X11Forwarding no

The account can then do nothing but receive rsync into that folder: every
session, with any key, is forced through rrsync, which rejects anything but a
plain rsync transfer into its root. Remote paths are relative to that root, so
set a folder's remote path to DCIM, not an absolute path. rrsync may instead
live at /usr/share/rsync/scripts/rrsync; check command -v rrsync. Verify the
host key from Test connection against
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub on the server.


Debug
-----
- The in-app log viewer displays the plain-text rsync log; tap Refresh to reload.
- The log records the rsync arguments and complete process output for each run.
- rsh transport: RSH_KEY, RSH_KNOWN_HOSTS, and RSH_PORT select the key,
  known_hosts file, and port. Run rsh by hand to isolate SSH from rsync.


License
-------
GPLv3. Bundling rsync makes the whole app GPLv3; see LICENSE.
