From 99e81e9ab8ef92f6811eedae4409a8142587d27f Mon Sep 17 00:00:00 2001 From: librelad Date: Fri, 28 Aug 2026 12:21:37 +0100 Subject: [PATCH] backup: LibrePortal Connect as a destination type, greyed out until it exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit promise.md names Connect as a paid service for "keeping off-site backups", with two constraints that are load-bearing rather than marketing: it never sees your data, and every hosted service has a free equivalent in the open code. Nothing was implemented โ€” no endpoint, no account, no client support. The client half turns out to be almost entirely there, because a Connect destination is not a new kind of thing: it is a restic REST repository whose password never leaves the machine. So `connect` resolves exactly like `rest` in resticLocationUri โ€” it IS one. It is a separate TYPE only so the UI can tell it apart from a REST server someone runs themselves, which are identical on disk. Availability is data, not code: CFG_BACKUP_CONNECT_ENDPOINT (empty) is reported through the locations feed as connect:{available,endpoint}, and the wizard renders from that โ€” the option present but disabled, its panel saying what it will be and that SFTP and S3 do the same job today. The day the service exists, setting that one value turns it on with no release. Verified both ways. The device code is a credential, so it goes through the secret channel as a reference rather than travelling in the wizard payload, which is base64'd into a world-readable task file. Design, and what the service still has to provide, in docs/roadmap/connect-backup-destination.md. Co-Authored-By: Claude Opus 5 --- configs/backup/backup_general | 1 + .../frontend/core/setup/js/setup-wizard.js | 29 +++++ docs/roadmap/connect-backup-destination.md | 101 ++++++++++++++++++ scripts/backup/engine/restic_env.sh | 6 +- scripts/backup/locations/location_add.sh | 6 +- .../backup/webui_backup_locations.sh | 13 +++ 6 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 docs/roadmap/connect-backup-destination.md diff --git a/configs/backup/backup_general b/configs/backup/backup_general index ba519a3..019f120 100755 --- a/configs/backup/backup_general +++ b/configs/backup/backup_general @@ -2,6 +2,7 @@ # Backup General - Scheduling # @icon ๐Ÿ’พ # ================================================================================ +CFG_BACKUP_CONNECT_ENDPOINT= # LibrePortal Connect - Base URL of the hosted backup service. Empty means Connect is not available yet and the WebUI offers it greyed out; setting it is all that turns it on. **ADVANCED** CFG_BACKUP_MODE=automatic # Backups - Automatic runs them on the schedule below; Manual means you start them yourself from the Backup page [automatic:Automatic|manual:Manual] CFG_BACKUP_CRONTAB_APP="0 5 * * *" # App Backup Schedule - Crontab schedule for application backups CFG_BACKUP_DASHBOARD_REFRESH_INTERVAL=30 # Dashboard Refresh Interval - Minutes between routine restic pulls that refresh the Backups dashboard diff --git a/containers/libreportal/frontend/core/setup/js/setup-wizard.js b/containers/libreportal/frontend/core/setup/js/setup-wizard.js index d251c35..9836123 100755 --- a/containers/libreportal/frontend/core/setup/js/setup-wizard.js +++ b/containers/libreportal/frontend/core/setup/js/setup-wizard.js @@ -46,6 +46,8 @@ class SetupWizard { // the install already has, so the default one is shown and editable rather // than being a thing you discover later on the Backup page. this.backupLocations = []; + // Set from the locations feed; assumed off until it says otherwise. + this.connect = { available: false, endpoint: '' }; // .lpapp exports found at the path the user gave, and which to import. this.importResults = []; this.importSelected = []; @@ -734,6 +736,10 @@ class SetupWizard { const r = await fetch('/data/backup/generated/locations.json', { cache: 'no-store' }); const d = await r.json(); const list = Array.isArray(d) ? d : (d.locations || []); + // Whether LibrePortal Connect can be chosen. Reported by the host rather + // than decided here, so the day the service exists this is a config value + // and the UI needs no change. + this.connect = (d && d.connect) || { available: false, endpoint: '' }; this.backupLocations = list.map(l => ({ idx: l.idx, name: l.name || `Location ${l.idx}`, @@ -877,6 +883,7 @@ class SetupWizard { + `)}
@@ -908,6 +915,21 @@ class SetupWizard { ${field('bk-s3secret', 'S3 Secret Key', 'Stored where only LibrePortal can read it.', secret('bk-s3secret'))}
+
+ ${this.connect.available ? ` + ${field('bk-cn-token', 'Device code', 'From your LibrePortal Connect account. Pairs this machine to your storage there.', + text('bk-cn-token', '', 'XXXX-XXXX-XXXX'))} +

Backups are encrypted here before they leave. Connect stores the result + and never holds the key, so nobody there can open them \u2014 keep the repository password somewhere + other than this machine.

+ ` : ` +

The hosted option: somewhere off this machine to keep backups without + running a server yourself. Not available yet.

+

Nothing waits on it \u2014 SFTP and S3 above do the same job today, and + always will. Every hosted service has a free equivalent in the open code.

+ `} +
+
${field('bk-b2uri', 'Bucket', 'For example b2:my-bucket.', text('bk-b2uri', loc.uri, 'b2:my-bucket'))} ${field('bk-b2id', 'B2 Account ID', '', text('bk-b2id', loc.b2_account_id))} @@ -954,6 +976,13 @@ class SetupWizard { next.uri = v('bk-s3uri'); next.s3_access_key = v('bk-s3key'); if (!await stash('bk-s3secret', 's3_secret_ref')) return; + } else if (type === 'connect') { + // Refuse rather than save something that cannot work. The option + // is disabled in the list, but a type can also arrive from an + // existing location, so the guard belongs here too. + if (!this.connect.available) return; + next.uri = this.connect.endpoint; + if (!await stash('bk-cn-token', 'connect_token_ref')) return; } else if (type === 'b2') { next.uri = v('bk-b2uri'); next.b2_account_id = v('bk-b2id'); diff --git a/docs/roadmap/connect-backup-destination.md b/docs/roadmap/connect-backup-destination.md new file mode 100644 index 0000000..e2d1358 --- /dev/null +++ b/docs/roadmap/connect-backup-destination.md @@ -0,0 +1,101 @@ +# LibrePortal Connect as a backup destination (design) + +**Status:** client side built, greyed out. There is no service yet. ยท +**Scope:** what Connect has to be for backups, and what already exists ยท +**Origin:** "maybe add a section of adding LibrePortal's connect service" (2026-08-28) + +--- + +## 0. What Connect is promised to be + +From `docs/guide/promise.md`, which is where this starts and what it has to obey: + +> **LibrePortal Connect** โ€” optional services for the tricky parts, like reaching +> your server from your phone or keeping off-site backups. We work like a +> courier carrying a sealed box: we move and store your data, but it stays +> locked and *you* hold the only key. + +and: + +> Every paid service has a free, self-hostable equivalent in the open code. + +Both of those are load-bearing here, not marketing. The first says the service +must never be able to read a backup. The second says shipping Connect must not +make anything worse for someone who never uses it. + +## 1. What already exists + +Almost all of it, because a Connect destination is not a new kind of thing. + +| Piece | Status | +|---|---| +| Client-side encryption with a key the server never sees | โœ… restic, always on | +| A backend that stores packs over HTTP | โœ… `rest` location type | +| Per-location URI, engine, retention, append-only | โœ… the location model | +| A password the user keeps, escrowed locally | โœ… `passwords.txt`, 0600 | +| Somewhere to enter a credential without it leaking | โœ… the secret channel | +| A destination list in the wizard to hang it off | โœ… the Backups step | +| **The service** | โŒ does not exist | + +So the client half is a REST repository with credentials in the URI. That is why +`connect` resolves exactly like `rest` in `resticLocationUri` โ€” it *is* one. + +It is a separate TYPE only so the UI can tell "LibrePortal Connect" apart from +"a REST server I run myself", which are identical on disk. Anyone who wants the +second can have it today, free, which is the promise's litmus test passing. + +## 2. Shape of a Connect destination + +``` +CFG_BACKUP_LOC__TYPE=connect +CFG_BACKUP_LOC__URI=rest:https://:@connect.libreportal.org/v1// +CFG_BACKUP_LOC__PASSWORD= +``` + +The URI carries the device credentials; the PASSWORD is the encryption key and +stays here. The service holds packs it cannot open. That is the sealed box, +implemented rather than asserted. + +The device code the user pastes is a credential, so it travels through the +secret channel (`scripts/webui/webui_secret.sh`) as a reference โ€” never in the +wizard payload, which is base64'd into a world-readable task file. + +## 3. The not-yet-available state + +`CFG_BACKUP_CONNECT_ENDPOINT` decides it, and it is empty. + +The locations feed reports `connect: {available, endpoint}`, and the wizard +renders from that: the option is present but disabled and labelled "not +available yet", and its panel says what it will be and โ€” more importantly โ€” +that SFTP and S3 do the same job today. + +Deliberately data-driven: **the day the service exists, setting that one value +turns it on.** No release, no code change. Verified by setting it and watching +the option go from disabled to a working device-code field, then unsetting it. + +Showing a disabled option at all is a judgement call: it advertises something +that cannot be bought. It earns its place by answering the question the step +otherwise raises โ€” "is off-site a thing this can do?" โ€” and by pointing at the +free answer in the same breath. + +## 4. What the service still has to provide + +None of this is client work: + +- accounts, and a device-code flow that yields a per-device credential +- a restic-compatible REST endpoint per repository, scoped to that device +- quota and retention policy the client can read, so the UI can say what is left +- revocation, so a lost machine can be cut off without rotating the repo password + +## 5. Open questions + +1. **Per-device or per-account credentials?** Per-device is what allows + revocation without re-keying every machine, but it means the service tracks + devices โ€” which is state about the user it does not otherwise need. +2. **Does Connect ever hold the repository password?** It must not. But then a + user who loses it loses the backup, and a hosted service is exactly what + someone buys to avoid that responsibility. The honest answer is that it + cannot be both; the escrow file and saying so plainly is the current answer. +3. **What does the wizard do about quota before an account exists?** Nothing to + show until the endpoint answers, so the step stays silent rather than + guessing. diff --git a/scripts/backup/engine/restic_env.sh b/scripts/backup/engine/restic_env.sh index d691e10..dc06fbd 100644 --- a/scripts/backup/engine/restic_env.sh +++ b/scripts/backup/engine/restic_env.sh @@ -88,7 +88,11 @@ resticLocationUri() path=$(resticLocationField "$idx" SSH_PATH) echo "sftp:${user}@${host}:${path}" ;; - rest) + rest|connect) + # Connect is a REST repository on storage we do not run. The URI + # carries the device credentials; the repo PASSWORD stays here and + # is what the data is encrypted with, so the far side only ever + # holds packs it cannot open. resticLocationField "$idx" URI ;; s3|b2|gs|azure|rclone) diff --git a/scripts/backup/locations/location_add.sh b/scripts/backup/locations/location_add.sh index 88c6fb7..eacf5a3 100644 --- a/scripts/backup/locations/location_add.sh +++ b/scripts/backup/locations/location_add.sh @@ -11,7 +11,11 @@ locationAdd() fi case "$type" in - local|sftp|rest|s3|b2|gs|azure|rclone) ;; + # connect resolves exactly like rest โ€” it IS a restic REST repository. + # It is its own type only so the UI can tell a LibrePortal Connect + # destination apart from a REST server someone runs themselves, which + # otherwise look identical on disk. + local|sftp|rest|connect|s3|b2|gs|azure|rclone) ;; *) isError "Unsupported location type: $type"; return 1 ;; esac diff --git a/scripts/webui/data/generators/backup/webui_backup_locations.sh b/scripts/webui/data/generators/backup/webui_backup_locations.sh index f4fc7fa..2b3ebaa 100644 --- a/scripts/webui/data/generators/backup/webui_backup_locations.sh +++ b/scripts/webui/data/generators/backup/webui_backup_locations.sh @@ -17,6 +17,19 @@ webuiGenerateBackupLocations() local content="{" content+="\"generated_at\":\"$(date -Iseconds)\"," + + # Whether LibrePortal Connect can be chosen at all. Reported as data rather + # than decided in the UI, so the day the service exists this becomes a + # config value and nothing ships to enable it. Empty endpoint = not yet. + local _connect_ep="${CFG_BACKUP_CONNECT_ENDPOINT:-}" + content+="\"connect\":{" + content+="\"available\":$( [[ -n "$_connect_ep" ]] && echo true || echo false )," + # A URL from config; strip the two characters that would break the JSON + # rather than lean on an escaper this generator does not have. + _connect_ep="${_connect_ep//\\/}" + _connect_ep="${_connect_ep//\"/}" + content+="\"endpoint\":\"${_connect_ep}\"" + content+="}," content+="\"locations\":[" local first=true