backup: LibrePortal Connect as a destination type, greyed out until it exists
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 <noreply@anthropic.com>
This commit is contained in:
parent
f227435abf
commit
99e81e9ab8
@ -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
|
||||
|
||||
@ -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 {
|
||||
<option value="sftp"${loc.type === 'sftp' ? ' selected' : ''}>SFTP</option>
|
||||
<option value="s3"${loc.type === 's3' ? ' selected' : ''}>S3</option>
|
||||
<option value="b2"${loc.type === 'b2' ? ' selected' : ''}>Backblaze B2</option>
|
||||
<option value="connect"${loc.type === 'connect' ? ' selected' : ''}${this.connect.available ? '' : ' disabled'}>LibrePortal Connect${this.connect.available ? '' : ' \u2014 not available yet'}</option>
|
||||
</select>`)}
|
||||
|
||||
<div data-bk-group="local">
|
||||
@ -908,6 +915,21 @@ class SetupWizard {
|
||||
${field('bk-s3secret', 'S3 Secret Key', 'Stored where only LibrePortal can read it.', secret('bk-s3secret'))}
|
||||
</div>
|
||||
|
||||
<div data-bk-group="connect">
|
||||
${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'))}
|
||||
<p class="setup-section-hint">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.</p>
|
||||
` : `
|
||||
<p class="setup-section-hint">The hosted option: somewhere off this machine to keep backups without
|
||||
running a server yourself. Not available yet.</p>
|
||||
<p class="setup-section-hint">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.</p>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<div data-bk-group="b2">
|
||||
${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');
|
||||
|
||||
101
docs/roadmap/connect-backup-destination.md
Normal file
101
docs/roadmap/connect-backup-destination.md
Normal file
@ -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_<n>_TYPE=connect
|
||||
CFG_BACKUP_LOC_<n>_URI=rest:https://<device>:<token>@connect.libreportal.org/v1/<repo>/
|
||||
CFG_BACKUP_LOC_<n>_PASSWORD=<repository password — never sent>
|
||||
```
|
||||
|
||||
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.
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user