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