aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLena <lena@omega>2026-08-16 00:00:00 +0000
committerLena <lena@omega>2026-08-16 00:00:00 +0000
commit6578dc47a7d1f7946528e9540d9cfa2e9a5d88d2 (patch)
tree0d5867861e24f31166716fd414d879528c2c7cff
parent8c6390571d28ff21a98ed3802458aceddbb6d2a0 (diff)
downloadrsend-6578dc47a7d1f7946528e9540d9cfa2e9a5d88d2.tar.gz
app: release 0.2.00.2.0
Document the multi-remote release, publish matching store metadata, and advance the version once from the released 0.1.3 base.
-rw-r--r--README195
-rw-r--r--app/build.gradle4
-rw-r--r--fastlane/metadata/android/en-US/changelogs/5.txt6
-rw-r--r--fastlane/metadata/android/en-US/full_description.txt9
-rw-r--r--fastlane/metadata/android/en-US/images/phoneScreenshots/1.pngbin158231 -> 156595 bytes
-rw-r--r--fastlane/metadata/android/en-US/images/phoneScreenshots/2.pngbin70395 -> 167742 bytes
-rw-r--r--fastlane/metadata/android/en-US/images/phoneScreenshots/3.pngbin85264 -> 84590 bytes
-rw-r--r--fastlane/metadata/android/en-US/images/phoneScreenshots/4.pngbin110091 -> 122571 bytes
-rw-r--r--fastlane/metadata/android/en-US/images/phoneScreenshots/5.pngbin0 -> 84384 bytes
-rw-r--r--fastlane/metadata/android/en-US/short_description.txt2
-rw-r--r--metadata/reproducible-builds.md38
-rw-r--r--versions4
12 files changed, 201 insertions, 57 deletions
diff --git a/README b/README
index 2f321c9..f9722c9 100644
--- a/README
+++ b/README
@@ -1,21 +1,44 @@
rsend
=====
-Push configured phone folders to a home SSH host using real rsync.
+Push configured phone folders to home SSH hosts 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.
+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 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.
+- 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
@@ -25,26 +48,34 @@ Layout
- 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. The host prerequisites are a POSIX shell, coreutils, make, curl, python3,
-tar, and git. The toolchain archives and rsync source are version- and
-checksum-pinned by the build (see versions for every pin).
+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 # Go, real-rsync integration, and Kotlin tests
- make verify-repro # build twice, compare the unsigned APKs
+ make test # tests, race detector, lint, vulnerability scan
+ make verify-repro # clean commit, two paths, byte-identical APKs
-`make test` also requires host rsync. The APK lands under
+`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. Continuous integration runs the same scripts: ci/setup-toolchain.sh,
-ci/build.sh, and ci/test.sh.
+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:
@@ -63,32 +94,51 @@ app/build/outputs/apk/release/app-release.apk.
Run
---
-1. Install the APK and grant all-files access and notifications.
+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.
-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.
+ ~/.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 (Samsung, Xiaomi,
-and others) otherwise delay or skip background jobs; see dontkillmyapp.com.
+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 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:
+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
@@ -124,17 +174,80 @@ 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.
+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 and complete process output for each run.
+- 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. Run rsh by hand to isolate SSH from rsync.
+ 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
-------
-GPLv3. Bundling rsync makes the whole app GPLv3; see 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.
diff --git a/app/build.gradle b/app/build.gradle
index df41dc5..3c73d2e 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -17,8 +17,8 @@ android {
applicationId 'invalid.lena.rsend'
minSdk 30
targetSdk 35
- versionCode 4
- versionName '0.1.3'
+ versionCode 5
+ versionName '0.2.0'
// Ship only the ABIs we build native libs for.
ndk { abiFilters 'arm64-v8a', 'x86_64' }
}
diff --git a/fastlane/metadata/android/en-US/changelogs/5.txt b/fastlane/metadata/android/en-US/changelogs/5.txt
new file mode 100644
index 0000000..3ad2f4b
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/5.txt
@@ -0,0 +1,6 @@
+Adds multiple named remotes. Each folder selects its server, and an
+unreachable host no longer blocks the others. Transfers resume safely,
+SSH host keys are pinned, and 0.1.x settings retain every safe entry.
+Mirror deletions run only after transfer and are reported. Scheduling
+survives reboots without duplicate jobs. Includes rsync 3.5.0 security
+fixes and 16 KB page-size support.
diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt
index 95d8690..5197a78 100644
--- a/fastlane/metadata/android/en-US/full_description.txt
+++ b/fastlane/metadata/android/en-US/full_description.txt
@@ -1,6 +1,6 @@
Pushes configured phone folders (DCIM, WhatsApp media, call recordings, and
-so on) to a home SSH host using rsync over SSH. One-way push only, on an
-unmetered network by default, periodic or on demand.
+so on) to one or more home SSH hosts using rsync over SSH. One-way push
+only, on an unmetered network by default, periodic or on demand.
rsync and the SSH transport are built from source and shipped inside the APK.
The app needs all-files access to read arbitrary backup folders such as OEM
@@ -8,7 +8,8 @@ call-recording directories.
Features:
-* Incremental transfers; large videos resume (rsync --partial).
+* Multiple named remotes; each folder picks its destination host.
+* Incremental transfers; large videos resume from a private partial directory.
* Per-folder deletion policy: additive backup or mirror.
* Runs on an unmetered network, periodically or on demand.
-* ed25519 key auth with strict, pinned host-key verification.
+* Ed25519 client-key auth with strict, pinned host-key verification.
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png
index 8a42552..293a40f 100644
--- a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png
+++ b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png
Binary files differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png
index a882b86..d7085c0 100644
--- a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png
+++ b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png
Binary files differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png
index d9aafd8..0a8a954 100644
--- a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png
+++ b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png
Binary files differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png
index d77c244..8eda900 100644
--- a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png
+++ b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png
Binary files differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png
new file mode 100644
index 0000000..c984306
--- /dev/null
+++ b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png
Binary files differ
diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt
index b18f43a..5fdced6 100644
--- a/fastlane/metadata/android/en-US/short_description.txt
+++ b/fastlane/metadata/android/en-US/short_description.txt
@@ -1 +1 @@
-Push phone folders to a home SSH host with rsync over SSH
+Push phone folders to home SSH hosts with rsync over SSH
diff --git a/metadata/reproducible-builds.md b/metadata/reproducible-builds.md
index 6885aa7..ee3102d 100644
--- a/metadata/reproducible-builds.md
+++ b/metadata/reproducible-builds.md
@@ -8,25 +8,43 @@ Pinning
Every tool and source version lives in ./versions: the rsync source, Go, JDK,
Android NDK, SDK platform, build-tools, command-line tools, Gradle, the Android
Gradle Plugin, and Kotlin. Directly downloaded archives have repository-owned
-checksums. The gradle files and go.mod repeat some pins because gradle cannot
-source a shell file; ci/test.sh fails if they drift from ./versions.
+checksums. The SDK platform revision and android.jar bytes are checked after
+sdkmanager installs them. Temurin's glibc and musl archives have separate
+checksums and cache directories. The Gradle wrapper records its distribution
+checksum too. The
+gradle files and go.mod repeat some pins because gradle cannot source a shell
+file. Gradle's verification-metadata.xml locks every resolved Maven and plugin
+artifact by SHA-256. ci/test.sh fails if direct pins or packaged notices drift.
+
+After a deliberate dependency change, regenerate the checksum manifest while
+running every Gradle path that resolves artifacts, then review its diff:
+
+ gradle --write-verification-metadata sha256 \
+ :app:testReleaseUnitTest :app:lintRelease :app:assembleRelease
Determinism
-----------
- rsh (Go): built with the NDK clang, -trimpath, -ldflags "-s -w -buildid=",
- and CGO_CFLAGS=-ffile-prefix-map to drop build paths.
+ CGO_CFLAGS=-ffile-prefix-map to drop build paths, and 16 KB ELF alignment.
- rsync (C): cross-compiled with -ffile-prefix-map and stripped; optional
dependencies are disabled and zlib and popt come from rsync's bundled copies,
- so the binary needs nothing outside Bionic.
+ so the binary needs nothing outside Bionic. Stack protection, fortified libc
+ calls, RELRO, immediate binding, and a non-executable stack are explicit.
+ Configure's host-dependent nobody group is pinned explicitly. Linker flags
+ give it 16 KB ELF alignment too.
- APK: no code shrinking (minifyEnabled false), the Google dependency-metadata
block is dropped (dependenciesInfo off), and native libs use legacy packaging.
-Verify locally by building the complete unsigned APK twice and comparing it
+Verify locally from a clean commit. The script extracts that commit twice at
+different absolute paths, builds both complete unsigned APKs, and compares them
byte for byte:
make verify-repro
+A local gitignored keystore.properties is harmless: git archive cannot copy it
+into either clean build tree.
+
F-Droid
-------
@@ -39,11 +57,17 @@ rsync/build.sh and rsh/build.sh; Gradle then assembles the APK. The release
build is unsigned unless a local keystore.properties is present, so F-Droid
signs the published APK with its own key.
+Each release recipe must set the go srclib ref to the exact GO_VERSION in
+./versions. In particular, the published 0.1.3 recipe's go1.25.0 ref must not
+be reused for a release built with a newer Go patch version.
+
All-files access
----------------
rsend requests MANAGE_EXTERNAL_STORAGE. It is required: backup targets such as
OEM call-recording directories and app-specific media folders live outside the
scoped-media buckets, and rsync needs real filesystem paths rather than SAF
-content URIs. The app makes no network access other than the SSH connection to
-the host the user configures.
+content URIs. At runtime the app makes no network access other than SSH
+connections to hosts the user configures. Android backup and device-transfer
+rules exclude all app data, including the encrypted identity, host pins, paths,
+and logs.
diff --git a/versions b/versions
index 3c2d837..d52e433 100644
--- a/versions
+++ b/versions
@@ -5,8 +5,8 @@
# App release identity. Bump both together; the release's changelog file is
# fastlane/metadata/android/en-US/changelogs/${APP_VERSION_CODE}.txt.
-APP_VERSION=0.1.3
-APP_VERSION_CODE=4
+APP_VERSION=0.2.0
+APP_VERSION_CODE=5
# Native rsync, built from source via the Android NDK. The tarball sha256 lives
# in rsync/rsync-${RSYNC_VERSION}.tar.gz.sha256, which the build verifies.