1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
|
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.
|