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

rsend backs up folders such as DCIM, WhatsApp media, and call recordings to
one or more remote hosts 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
downloaded application .so files are repackaged.


What it does
------------
- Pushes one or more local folders to one or more SSH hosts over rsync,
  each folder mapped to a named remote.
- Incremental: only changed files move; large videos resume. Partial transfers
  are staged in a .rsend-partial directory, so an interrupted sync never
  replaces a complete file on the server with a truncated one.
- Per-folder deletion policy: additive backup (default) or mirror
  (--delete-after). A mirror follows rsync exactly: an empty readable source
  empties its remote destination. A missing, non-directory, root, or unreadable
  source fails before rsync starts. Deletions happen only after transfers, so
  an interrupted run never removes the server's copy of a file whose
  replacement has not arrived. Every item rsync removes there is named in the
  log, and a mirror that ends with files unaccounted for is reported as failed.
- Rejects duplicate and ancestor remote destinations on the same configured
  endpoint. The check is lexical, case-insensitive, and does not allow absolute
  and relative paths to be mixed for one endpoint. Root, bare home, dot, and
  parent components are rejected. Separate folder mappings use separate trees.
- Regular files and directories only. A source path that is itself a symlink is
  refused. Symlinks, devices and sockets inside it are skipped and named in the
  log ("skipping non-regular file"), which is what media backup wants; rsync's
  -l would copy them if you ever need it.
- Native executables and APK packaging are aligned for Android devices with
  4 KB or 16 KB memory pages.
- Runs on an unmetered network, periodically (WorkManager) or on demand. A
  manual sync obeys the same unmetered setting, and says so and stops rather
  than queueing itself until the phone next sees wifi.
- Ed25519 client-key auth with strict, pinned host-key verification. rsend
  accepts modern Ed25519, ECDSA, and RSA-SHA2 host keys, pins the key type with
  the key, and warns loudly if a pinned host presents a different one.


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.
- THIRD_PARTY  complete shipped-component inventory and license notices.
- APACHE-2.0  license terms for the shipped Apache-licensed components.
- versions   pinned toolchain and source versions; the single source of truth.


Build
-----
The repository is self-contained: clone it and the build fetches everything
else. Toolchain setup supports x86_64 Linux with glibc or musl and selects a
separately checksummed Temurin JDK for each. The host prerequisites are a POSIX
shell, coreutils, make, curl, python3, tar, git, a C compiler for the Go race
detector, and the commands checked by ci/test.sh. Alpine also needs bash for
the NDK compiler launchers and gcompat for the NDK host binaries. The CI file
gives exact Debian and Alpine package lists. Toolchain archives, the SDK
platform revision, and rsync source are version- and checksum-pinned by the
build; see versions.

    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                     # tests, race detector, lint, vulnerability scan
    make verify-repro             # clean commit, two paths, byte-identical APKs

`make test` also requires host rsync and lets govulncheck query the public Go
vulnerability database. No source code is uploaded. The APK lands under
app/build/outputs/apk/. It is unsigned by default and cannot be installed until
signed. .gitlab-ci.yml runs exactly those scripts and holds no build logic of
its own: ci/setup-toolchain.sh, ci/test.sh, and ci/build.sh.

For a locally installable APK, create a keystore and a gitignored
keystore.properties before building:

    keytool -genkeypair -keystore rsend.jks -alias rsend -keyalg EC -validity 3650
    cat > keystore.properties <<EOF
    storeFile=rsend.jks
    storePassword=change-me
    keyAlias=rsend
    keyPassword=change-me
    EOF

Keep both files private and backed up. The signed output is
app/build/outputs/apk/release/app-release.apk.


Run
---
1. Install the APK and grant all-files access and notifications. All-files
   access is required: a sync refuses to start without it because configured
   storage may otherwise be unreadable.
2. Generate a key in the app and add the shown public key to the home host's
   ~/.ssh/authorized_keys. Import accepts unencrypted Ed25519 private keys only.
3. Add a remote (name, host, user, port); run Test connection and accept the
   pinned host-key fingerprint. The dialog shows the key type next to the
   fingerprint; verify it against the matching key file on the server. Nothing
   is saved until you accept a key, so a failed test cannot disturb a remote
   that already works. Repeat for every host you push to.
4. Add folders, pick each folder's remote and deletion policy, set the
   schedule. Enabling mirror shows its exact deletion semantics before saving.
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 otherwise delay
or skip background jobs. Android 16 also counts long WorkManager foreground
runs against the app's job quota; a very large first backup may need several
runs and resumes from .rsend-partial each time.


Server
------
Any rsync-compatible SSH service works, including ordinary shell accounts and
older rsync releases such as stock macOS. rsend pushes over rsync-over-SSH, not
SFTP. Remote paths may be absolute, relative, or home-relative, but mappings on
one endpoint must use either absolute or relative paths consistently. A path
must name a directory below root or home and cannot contain dot or parent
components. rsync creates the final component on its own; create deeper missing
parent directories once with mkdir -p on the server. A remote path cannot begin
with a colon: rsync interprets host::path as its unencrypted daemon protocol
instead of SSH.

Protocol compatibility is not a security promise. This release bundles rsync
3.5.0, but it cannot update the rsync receiver or rrsync wrapper on the server.
Install upstream 3.5.0 or a vendor package carrying the August 2026 security
fixes on the server before relying on rrsync confinement.

The recommended deployment confines the account to "rsync into one folder and
nothing else" with a forced rrsync command. This is not a protocol requirement.
sshd runs the forced command through the account's login shell, so the shell
must be real: /bin/sh works, while /bin/false or /sbin/nologin breaks 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. The Test
connection dialog prints the key type it pinned; if it says something other
than ssh-ed25519, the server has no ed25519 host key and you should compare
against that type's .pub file instead.

Interrupted transfers leave a .rsend-partial directory inside the destination
folder. rsync reuses it to resume and excludes it from the transfer, so it is
never deleted by a mirror. Abandoned partials from a sync that never resumed
can be removed by hand.


Upgrading
---------
A configuration written by 0.1.x is read once and rewritten in the current
shape on first launch. The single remote becomes the sole entry, named after
its host; every safe folder points at it; schedule intervals below 15 minutes
are raised to WorkManager's minimum; and the pin 0.1.x kept in its own
known_hosts file is carried over. A pin that no longer describes that host and
port is dropped rather than failing the upgrade, which costs one Test
connection instead of the whole configuration. The exact old JSON is preserved
as config.json.0.1. A folder or endpoint that violates current safety rules is
omitted, preserved in that file, and reported in the app instead of causing the
rest of the configuration to be discarded.

This is a deliberate, time-limited exception to the project's rule against
compatibility shims, kept because 0.1.3 is the last published release and is
therefore what every upgrading install has. It will be removed in 0.4.0: a
0.1.x configuration not opened by then is read as unparsable, preserved
alongside as config.json.broken, and the app starts empty.


Debug
-----
- The in-app log viewer displays the plain-text rsync log; tap Refresh to
  reload.
- The log records the rsync arguments, the errors, and every file a mirror
  deleted on the server ("del. <path>"). Transfers are counted rather than
  listed one line each: on a first sync of a large library the filenames would
  push everything worth reading out of the capped log. The log is capped at
  512 KB and rolls over to sync.log.1. The viewer reads at most the newest
  64 KB across both files so layout cost stays bounded. Control characters are
  escaped before writing so remote output cannot forge terminal log lines.
- If config.json cannot be read or parsed, rsend atomically renames it to a
  unique config.json.broken file, starts from an empty configuration, and shows
  the preserved filename in the app. It never deletes unreadable config bytes.
- rsh transport: RSH_KEY, RSH_KNOWN_HOSTS, and RSH_PORT select the key,
  known_hosts file, and port; RSH_KEY_DATA passes the key itself, which is what
  the app uses so the plaintext key never reaches the filesystem. Run rsh by
  hand to isolate SSH from rsync.
- During transport, rsh offers only the host-key type already pinned for that
  remote. Test connection prefers the pinned type so an untouched server
  reproduces the same key, but can show a newly rotated type for explicit
  approval. Re-run Test connection after deliberately rotating a host key.
- An IPv6 host goes in the host field as a bare literal (2001:db8::1); rsend
  adds the brackets where rsh and rsync each need them. A bracketed literal is
  accepted too.
- "foreground service refused" in the log means the sync ran as a plain
  background job, which the system may stop early. Grant Battery >
  unrestricted; rsync resumes from .rsend-partial on the next run either way.
- A host that drops packets instead of refusing them, a firewall closed to the
  phone's current network for instance, stalls the connect for the full budget.
  That budget is 10 seconds; RSH_CONNECT_TIMEOUT overrides it in whole seconds.
  The log line reads "rsh: unreachable:", and the remaining folders bound to
  that remote are skipped for the rest of the run rather than each paying the
  timeout again.


License
-------
Copyright (C) 2026 Lena. GPLv3; see LICENSE.

Bundling rsync makes the whole app GPLv3. THIRD_PARTY records every native and
JVM component shipped in the APK, its license, and its corresponding source.
Native source and tool archives are pinned and checksummed; JVM dependencies
use exact Maven versions. Test and build-only dependencies are listed
separately because they are not part of the APK. LICENSE and THIRD_PARTY are
packaged as plain APK assets, along with APACHE-2.0.
