No description
  • Python 98.7%
  • Shell 1.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Olivier Cortès 707edb4dd1 Adopt GPL-3.0-or-later, and require sudo on the backup host
The project had no license, which left its terms undefined for anyone who
received a copy. Adopt GNU GPL version 3 or later, as for the other projects,
with the full text in LICENSE.

State sudo as a requirement of the backup host rather than a remark buried in
the restore section. A restore reads the recorded uid and gid back from the
capture metadata, and only a privileged process can apply them: without root it
returns the right content at the right paths with the right modes, but owned by
whoever ran it, which is rarely what rebuilding a host calls for.

Assisted-by: Félin Capiteux <felin@lodlp.net>
2026-10-08 13:19:38 +00:00
.githooks Implement generic pull-backup core 2026-10-06 15:41:20 +02:00
docs Capture the sip-watchdog secrets, and scope the private key guard honestly 2026-10-08 12:30:49 +00:00
examples Capture the sip-watchdog secrets, and scope the private key guard honestly 2026-10-08 12:30:49 +00:00
resources/git Implement generic pull-backup core 2026-10-06 15:41:20 +02:00
src/simple_backup Capture paths a daemon refuses to see widened 2026-10-07 21:53:25 +00:00
tests Capture paths a daemon refuses to see widened 2026-10-07 21:53:25 +00:00
.gitignore Implement generic pull-backup core 2026-10-06 15:41:20 +02:00
LICENSE Adopt GPL-3.0-or-later, and require sudo on the backup host 2026-10-08 13:19:38 +00:00
pyproject.toml Generalize database capture configuration 2026-10-06 17:03:15 +02:00
README.md Adopt GPL-3.0-or-later, and require sudo on the backup host 2026-10-08 13:19:38 +00:00

Simple Backup

Simple Backup pulls a backup of one host onto another over key-only SSH, from a scope you state explicitly. You say what may be read; the tool grants exactly that, backs it up, and takes the access away again.

The first target is Relay, pulled from Terry, one backup a week plus on-demand runs after a significant change, keeping eight generations.

What you get

One file states the scope, and nothing else grants access. The backup account holds no standing privilege: read access is reconverged immediately before each run and never installed as a default ACL. A file created or chmodded between two runs is picked up by the next convergence rather than being permanently exposed in the meantime.

Files whose reader checks their own mode are captured instead of read in place. Granting an ACL on a private key raises the mode stat() reports, and OpenSSH rejects a host key it sees as group-readable: sshd would then fail to start at its next restart, which unattended upgrades perform on their own. Declaring the key as a capture backs up a snapshot instead, leaves the original untouched, and records its real mode so a restore puts back a 0600 key as 0600.

A live database is captured coherently, not copied. A SQLite file copied while a service is writing to it can lose committed transactions sitting in the write-ahead log. Capture uses SQLite's own online backup API, checks the result and publishes it atomically.

Restores never touch live paths. A restore refuses an existing target, rebuilds the tree under a new root, puts each capture back at its original path with its recorded modes, and removes the ACL that existed only for transport.

Source host

One file, /etc/asami/simple-backup/source.conf, states the readable scope and the captures:

[scope]
version = 1
user = simple-backup
include =
    /etc
    /var/lib/sip-watchdog/state.json

[capture ssh-hostkeys]
engine = files
source = /etc/ssh

[capture rustdesk]
engine = sqlite
source = /var/lib/rustdesk-server/db_v2.sqlite3
integrity_check = true
timeout_seconds = 30

Everything else follows from those lines. Each capture publishes its snapshot under /var/lib/simple-backup/capture/<name>, that snapshot joins the readable scope, and the captured source is excluded both from ACL application and from the backup selection. You never write a snapshot path yourself, so the scope and the captures cannot drift apart.

A capture needs no include above it. /root/.ssh can be captured without the backup account ever being granted traversal on /root.

Rules worth knowing:

  • include paths are absolute directories, may not be /, and may not overlap; a single file is declared as a capture, because rdiff-backup refuses a plain file as a backup source;
  • an include may not live under the capture root, which is derived territory;
  • there is no exclude directive: state the readable scope positively rather than granting a broad tree and subtracting from it afterwards;
  • ancestors of an included path receive traversal only, never read.

Two fixed commands do the source-side work, and they take no path argument, so a narrow sudo rule is enough:

simple-backup ALL=(root) NOPASSWD: /usr/local/sbin/simple-backup-capture apply, /usr/local/sbin/simple-backup-permissions apply

simple-backup-permissions apply reconverges the ACLs and prints the scope it actually applied. It refuses outright, naming the files, if the convergence would widen the mode reported for an OpenSSH private key — the failure arrives while you are watching, instead of at the next restart of sshd.

That check recognises OpenSSH private keys by their header, and nothing else. It cannot cover the general case: any program may check the mode of a file it reads, and a password file carries no recognisable marker. After a first convergence on a new scope, check the source host for services that have stopped. A weekly convergence on a host whose mode-checking readers are unknown is how a backup silently breaks a service.

Backup host

/etc/asami/simple-backup/jobs.conf says where to pull from and where to put it:

[job relay]
source = simple-backup@relay.lodlp.net
repository = /home/asami/backups/relay-rdiff
backend = rdiff-backup
retention_generations = 8
rdiff_api_version = 201
capture_databases = true

Run it:

simple-backup run relay

Each run captures, reconverges the source ACLs, reads back the scope that was really applied, checks that the SSH identity matches it, then backs up each included tree into its own rdiff-backup repository. A root manifest.json maps source paths to those repositories, and is published only once every tree has succeeded: a failed run leaves no manifest claiming a backup that did not happen.

One repository per tree is what keeps the privilege model small. A single repository rooted at / would force the backup account to enumerate / in order to reach an included descendant; with one repository per tree, ancestors need traversal only.

rdiff-backup counts the current mirror as 0B, so eight generations means 0B through 7B, and pruning starts only when a ninth exists.

Restore

sudo simple-backup restore relay /srv/restore/relay

Add --at 1B for an earlier state. The target must not already exist.

Run a restore as root. Ownership is restored from the recorded uid and gid, and only a privileged process can do that. Without it the restore still returns the right content at the right paths with the right modes, but every file ends up owned by whoever ran it — which is rarely what you want when rebuilding a host. sudo is therefore a requirement of the backup host, not an optional convenience.

Each tree is restored to its original absolute path beneath that new root. Captures go back where they came from: /etc/ssh is restored as /srv/restore/relay/etc/ssh, with host keys at 0600, not under the capture path with transport modes. ## Requirements

Python standard library only, plus these on the hosts that need them:

  • source host: rdiff-backup, OpenSSH, the acl tools, python3-pylibacl;
  • backup host: rdiff-backup, OpenSSH, and sudo to run a restore.

python3-pylibacl is a recommendation of rdiff-backup rather than a hard dependency, so install it explicitly: a source host without it refuses to converge, with a message saying so, rather than failing obscurely.

What this does not do

SQLite has a generic coherent capture primitive. Other databases do not become safe merely because their files are readable. Service-specific primitives — quiesce, native dumps, application exports, stop/copy/start, post-start health checks — remain separate work until implemented explicitly. The permissions helper stays permission-only and the capture helper accepts no caller-supplied path.

Development

PYTHONPATH=src python3 -m unittest discover -s tests -v

Git commits use the repository-local helpers under resources/git.

License

GNU General Public License, version 3 or later (GPL-3.0-or-later). The full text is in LICENSE.

Status

Initial implementation. No production deployment is implied by this branch.

Integration-tested with Debian rdiff-backup 2.2.6: key-only SSH, narrow sudo, ACL convergence, multiple independent source trees, several generations, current and historical restore, scope contraction with retired history preserved, repository verification, retention pruning, and coherent capture of a SQLite database under an open writer holding uncheckpointed WAL state.

Source account provisioning, service-coherency steps beyond SQLite, systemd scheduling, notifications, bandwidth limits, encryption and off-site replication remain separate work.