Backup endpoint, version 1
Backup endpoint, version 1version 1rim-ui/docs/specs/backup-endpoint-v1.mdcommit 82ba0e9
The desktop app keeps its history archive (history-archive-v1.md) in a folder, in Google Drive
or Dropbox, or on a service the user runs: the backup endpoint. This document is that
service's protocol, so anyone can write one. It is one URL answering GET and POST; the files
it stores are the archive's, already encrypted, and it never needs to understand them.
The device side is src-tauri/src/history_archive/cloud/endpoint.rs. A reference service ships
next to this document: backup-endpoint-reference.py, a single Python 3 file using only the
standard library, which keeps the files under one directory. The app's tests run the endpoint
provider against it.
A write-only webhook is deliberately not the protocol: restoring on a new device, and several devices sharing one archive, need read and list.
The URL #
The user types the endpoint's URL in Settings > History, "Your own server". When its path ends
in /backup, requests go to that URL; otherwise /backup is appended
(https://backup.example.com and https://backup.example.com/backup are the same endpoint;
https://example.com/rim becomes https://example.com/rim/backup). The URL has no user name,
password, query or fragment.
Every request carries exactly one query parameter, path or list, whose value is an archive
path, percent-encoded as application/x-www-form-urlencoded (a / may arrive as %2F).
Paths #
Archive paths are relative and /-separated (accounts/<tag>/devices/<id>/head.rimx). Every
segment is non-empty, is not . or .., and holds no \, : or NUL. Version 1 writes paths
of lowercase hex, digits, -, . and the fixed names of history-archive-v1.md, at most about
110 bytes long; a service may refuse paths over 1,024 bytes with 400.
A service must refuse (400) any path that would leave its storage, whatever the device sends. It maps paths to its storage however it likes (directories on disk, object keys, database rows); directories exist exactly while a file is under them.
Requests #
Read a file #
GET /backup?path=accounts/0011…/devices/0f0e…/head.rimx
200with the file's bytes exactly as stored. TheContent-Typedoes not matter.404when there is no file at that path.
List a directory #
GET /backup?list=accounts/0011…/devices
list= with an empty value is the root.
200with a JSON array of the entries directly in that directory, files and directories, hidden names (starting with.) included:[ { "name": "0f0e0d0c-aaaa-4bbb-8ccc-123456789abc", "dir": true }, { "name": "head.rimx", "dir": false } ]nameis one path segment;dirsays whether it is a directory. Other fields are ignored. The order is free, except for a service that ignoresIf-None-Match(below), which lists in the order it stored the entries, oldest first.404, or200with[], for a directory that does not exist. The root always exists.
Store a file #
POST /backup?path=accounts/0011…/devices/0f0e…/seg-0000000001.rimx
Content-Type: application/octet-stream
<the file's bytes>
The service stores the body whole under that path, creating the directories above it and replacing any file already there. A reader sees the old bytes or the new ones, never a mix or a truncated file: write to a temporary name, then rename over the target.
201when the file is new,204when it replaced one. The device accepts any2xx.
Exclusive create #
POST /backup?path=rim-archive.json
If-None-Match: *
The same, except that the service stores the body only if nothing is at the path, and otherwise
answers 412 and leaves the file alone. It must be atomic: of two devices creating one path at
once, exactly one gets 2xx (a hard link of the fully written temporary file, an exclusive
open, a conditional insert). The device creates only the archive's manifest this way, once per
archive.
A service that ignores the header still works. The check (below) finds out, and from then on the device creates a file without the service's help:
- It reads the path. A file there means the name is taken.
- It stores the bytes under a claim of its own in the same directory,
.<name>.<12 random hex digits>.claim. - It lists the directory and keeps the claim for
<name>that the listing names first. Since such a service lists in the order it stored, that is the oldest claim, and every device that lists agrees on it. - It copies the kept claim's bytes to the path. Every device does, so they all write the same bytes there; the one whose claim was kept has created the file, the others join it.
Claims stay behind: version 1 has no delete. They are small, hidden, and only ever made for the manifest.
Authentication #
The user picks one at setup:
| Mode | Request header |
|---|---|
| None | none |
| User and password | Authorization: Basic <base64(user:password)> (RFC 7617) |
| Token | Authorization: Bearer <token> (RFC 6750) |
| Token in a header | <header name the user typed>: <token> |
The service answers 401 to missing or wrong credentials and 403 to valid ones it does not
allow; it should compare secrets in constant time. On the device, the password or token is kept
in the secret vault as history-archive-cloud-v1:<connection id>; the account's archive settings
file names the URL, the mode, and the user name or header name, never the secret. Stopping
backups on a device deletes the secret from its vault.
Transport #
HTTPS is required, with the certificate verified against the system's trust store, unless the
host is this computer (localhost, 127.0.0.0/8, ::1) or the user ticks "Allow an
unencrypted connection". Over plain HTTP the archive's files are still encrypted, but the
credentials travel in the clear and anyone on the way can withhold or replay files. Version 1
has no certificate pinning, self-signed certificate override or client certificates: use a
certificate the system trusts, or the override on a network the user controls.
The check #
Before an endpoint is used, Settings runs a check, which also stands for the provider's "check writable":
POST ?path=.rim-probe-<16 random hex digits>with 32 random bytes.GET ?list=(the root): it must be a JSON array of entries.GET ?path=.rim-probe-…: it must return the same 32 bytes.POST ?path=.rim-probe-…again withIf-None-Match: *:412means the service honours exclusive creates;2xxor501means it does not, and the device uses claims. The answer is saved with the connection.GET ?path=rim-archive.json, to tell the user whether an archive is already there.
The probe stays; a service may delete .rim-probe-* files whenever it likes. A failed check
names the problem:
| Found | Told |
|---|---|
| No connection, timeout, DNS failure | nothing answers at that address |
| TLS handshake or certificate failure | the secure connection failed |
| Plain HTTP to another computer, not allowed | use https://, or allow an unencrypted connection |
401 |
the credentials were refused |
403 |
the credentials were accepted, access refused |
404 or 405 to the probe's POST |
no backup endpoint at that URL |
| A listing that is not a JSON array | not a backup endpoint |
| The probe not returned, or other bytes | the service does not keep files correctly |
The check retries once; a TLS failure is not retried.
Status codes #
| Status | To | Meaning to the device |
|---|---|---|
200 |
GET |
the file, or the listing |
201, 204 (any 2xx) |
POST |
stored |
400 |
any | the request is malformed or the path refused; reported as an error |
401 |
any | credentials missing or wrong |
403 |
any | credentials valid, access refused |
404 |
GET ?path |
no such file |
404 |
GET ?list |
no such directory (an empty listing); at the root, not a backup endpoint |
404, 405 |
POST |
not a backup endpoint at that URL |
412 |
POST with If-None-Match: * |
the name is taken |
413 |
POST |
the file is larger than the service takes; the backup fails |
429 |
any | busy: retried, honouring Retry-After |
500, 502, 503, 504 |
any | retried |
501 |
POST with If-None-Match: * |
no exclusive create (only during the check) |
507 |
POST |
the service is full; the backup fails |
Retries #
As for every provider (history-archive-v1.md, "Retries and interrupted backups"): a 429, a
5xx or a network failure is retried up to six attempts in all, waiting as Retry-After says
(at most 60 s) or with exponential backoff and jitter from 0.5 s to 16 s; a TLS failure is not
retried. A POST is retried like a GET: storing a file whole is idempotent. An exclusive
create whose 2xx was lost is answered 412 on the retry; the device then joins the manifest,
which is its own.
Size limits #
- Files. A segment holds at most 4 MiB of plaintext and its envelope adds 46 bytes; heads,
key wraps and the manifest are a few KiB, an identity backup under 1 KiB. A service must
accept bodies of at least 8 MiB: twice the largest file version 1 writes, which leaves
room for additive changes within version 1. It answers
413beyond its own limit. The reference service takes 64 MiB. - Time. The device gives a request 180 s, so a 4 MiB upload must complete within that on the user's uplink.
- Listings. There is no paging. The device lists only the root,
devices/,accounts/andaccounts/<tag>/devices/, which hold one entry per account or device; it never lists a device's own directory, where segments accumulate. A service must answer listings of at least 10,000 entries. - Paths. At most 1,024 bytes (above).
What the service learns #
The same as any drive the archive is kept in: the file names (account tags, device ids, segment
numbers, none of which reveal the user, their server or their conversations), the files' sizes,
and when each is written or read, which shows when each device is active (a device exports a few
minutes after it syncs). It also sees each device's IP address and the credentials it was given.
Nothing else: it cannot read the history, the identity key backup or the key wraps. Like any
drive, it can delete, withhold or roll back files, with the consequences
history-archive-v1.md describes.
Writing a service #
What a service must get right, in order of how much it matters:
- Store a file whole and atomically: temporary file, then rename.
- Refuse paths that leave the storage.
- Make
If-None-Match: *atomic, or ignore it entirely and list in storage order. A service that half honours it (checks, then writes) can let two devices create two archives. - Answer listings as a JSON array of
{"name", "dir"}. - Require credentials and serve HTTPS when it is reachable from another computer.
The reference service is plain HTTP on loopback by default:
python3 docs/specs/backup-endpoint-reference.py --dir ~/rim-backup --bearer "$(openssl rand -hex 24)"
To reach it from other computers, put a TLS-terminating reverse proxy (Caddy, nginx) in front of it.
Not in version 1 #
- Deleting files: probes and claims stay until the service removes them.
- Paged listings, resumable or chunked uploads.
- Certificate pinning, self-signed certificate overrides, client certificates.
- WebDAV (Nextcloud, ownCloud) as a provider of its own; this protocol's shape resembles it.