Phase 1 is built. Phase 2 hit a blocker worth deciding rather than silently working around. The WebUI cannot run restic, so a password typed in the browser has to reach the host. Both existing channels leak it. The task command string — which is what the Backup page ALREADY uses for this exact field — lands in a task JSON under frontend/data/tasks at 0644, world-readable, and shows in ps while the task runs. A file in that directory does not work either: the container writes as dockerinstall, the manager runs as libreportal, and at 0640 the manager cannot read it (verified on the live box). So this is an existing product-wide weakness that the restore branch happens to surface, not one the feature would introduce — and the restore case is its sharpest form, since that password is the key to every backup the user has. Recommends a one-shot secret drop: the ownership helper already solves the mirror-image case (_webui_bind_access chowns MANAGER:cowner 0640 so the container can read manager-owned files), so the reverse is a small, well-scoped addition — a directory owned cowner:MANAGER 0730 that the container drops a 0640 file into, which the manager reads once and unlinks. Worth doing because it also fixes the Backup page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 KiB
LibrePortal — First-run: New Install or Restore (Roadmap / Proposal)
Status: Proposal — not built, but most of the machinery is. · Audience: us, future-self · Scope: make "I'm rebuilding my server" a first-class path at first run, not a CLI expedition · Origin: "on the first install/setup we need 2 option blocks (New Install and Restore from Backup)" (2026-08-27)
0. The one idea
The wizard's first question becomes "is this a new server, or a replacement for one?" Everything else follows from the answer.
Today a rebuild means knowing that libreportal backup location add, libreportal restore first-run discover and libreportal restore first-run bulk exist, in that order. That is a fine CLI story and a terrible first-run story — and rebuilding after a disk dies is exactly when someone is least able to go reading docs.
1. How much of this already exists
More than it looks. The restore path is built; what is missing is the front door.
| Piece | Status |
|---|---|
| Connect a backup repo (local / sftp / rest / s3 / b2 / gs / azure / rclone) | ✅ locationAdd |
| Read a repo without touching live state | ✅ restoreFirstRunDiscover, migrateDiscoverHosts/Apps/AppDetail |
| Restore the system config (settings + credentials) into staging | ✅ backupRestoreSystemConfig, restore system |
| Restore many apps in one go from another host | ✅ restoreFirstRunBulk, restore first-run bulk |
Rewrite host-bound CFG_* (URL/HOST/DOMAIN) to this machine |
✅ migrateUrlRewrite |
| Preflight a migrate before committing | ✅ migratePreflight |
| Safety snapshot of the destination before overwriting | ✅ migratePreBackupDestination |
| Restore an app whose path differs from this host's | ✅ storageRestoreAppTo (§9 of storage-locations) |
| Snapshot records where the app lived | ✅ manifest storage block |
| The wizard branch, and the reconciliation screen | ❌ this document |
So this is mostly assembly plus one genuinely new screen, not new plumbing.
2. What the user sees
Step 0 becomes two blocks, before Experience:
┌───────────────────────────┐ ┌───────────────────────────┐
│ 🌱 New install │ │ ♻️ Restore from backup │
│ Set this machine up │ │ Rebuild a server from │
│ from scratch. │ │ an existing backup. │
└───────────────────────────┘ └───────────────────────────┘
New install → the wizard exactly as it is now.
Restore → Identity is skipped (it comes from the backup), and the flow becomes:
- Where is your backup? — the existing backup-location fields, rendered from config metadata like the Locations page already does.
- Unlock it — the repository password. See §4; this is the step that decides whether a restore is possible at all.
- What's in there? — hosts found, then apps per host, with sizes and snapshot dates.
- What will change on this machine — the reconciliation screen. §3. The new part.
- Restore — system config first, then the chosen apps, with the task list the wizard already renders.
3. The reconciliation screen — the only genuinely new logic
A backup describes a machine that no longer exists. Restoring it onto different hardware means a set of facts have to be re-decided, and the failure mode we care about is doing that silently. Everything below is detectable before anything is written:
| What differs | Where we already know it | Resolution |
|---|---|---|
| Storage location named in the manifest doesn't exist here | manifest storage.location vs the registry |
offer the drives this host has, or register a new one — the §9 prompt |
| App's old absolute path ≠ where it goes here | manifest storage.path |
already handled: storageRestoreAppTo stages and moves |
| Domains point at the old host | migrateUrlRewrite |
rewrite (its existing per-app opt-out still applies) |
| Ports / IPs already taken on this box | port allocation tables | reallocate, and say which changed |
| Drive smaller than the data | manifest size_bytes vs df |
refuse that app, not the whole restore |
| Apps in the backup that this version no longer ships | template presence | list them as skipped rather than failing |
It should read as a diff, not a wall: "12 apps · 3 need a decision · 9 restore as-is", with the three expanded.
4. The password problem, stated plainly
An encrypted repository cannot be opened with anything inside itself. CFG_BACKUP_LOC_<idx>_PASSWORD lives in the system config — which is inside the backup. So on a fresh machine the user must supply the repository password by hand. There is no way around this and it is not a bug; it is what encryption means.
That single fact drives two requirements:
- The restore step must ask for it early and say what it is, because a user who never wrote it down has no backup, and finding that out at step 2 is better than at step 5.
- Everywhere we generate that password we must push harder than a comment. The location config already says "back up offline!"; the WebUI should show it once, prominently, at creation — treated like a recovery key, because that is what it is.
Once the repo opens, ordering is already correct in the CLI and should be preserved: system config first (it carries every other location's credentials, so one password unlocks the rest), then apps.
4.1 — Where does the typed password actually travel? (blocker for phase 2)
Found while building phase 2, and it needs a decision before the restore branch can be written, because it is a security trade-off rather than an implementation detail.
The WebUI cannot run restic. So a password the user types in the browser has to reach the host somehow, and the two existing channels both leak it:
| Channel | Problem |
|---|---|
Task command string (what the Backup page already does for this exact field, via config_update CFG_BACKUP_LOC_<n>_PASSWORD=…) |
lands in the task JSON under frontend/data/tasks/, which is 0644 so the manager can read it — i.e. world-readable — and is visible in ps while the task runs |
A file in frontend/data/tasks/ |
the container writes as dockerinstall; the manager runs as libreportal. At 0640 the manager cannot read it (verified), and 0644 is world-readable again |
Note the first row is existing behaviour, not something this feature would introduce: editing a backup location's password on the Backup page already sends it that way. So this is a product-wide finding that phase 2 happens to surface, and the restore case is the sharpest version of it — that password is the key to every backup the user has.
Three ways out, roughly in order of effort:
- A one-shot secret drop. The ownership helper already knows how to make a
path readable across exactly this boundary (
_webui_bind_accesschownsMANAGER:cowner0640 so the container can read manager-owned files). The reverse needs the same treatment: a root-helper-created directory ownedcowner:MANAGER0730, into which the container drops a 0640 file the manager reads once and unlinks. - Never persist it. Hold the password only in the task processor's memory for the life of the restore; write it into the location config only after the system-config restore lands (and reconcile with what the backup contained).
- Accept the existing channel for consistency, and fix it product-wide later — cheapest now, and no worse than what shipping code already does, but it does mean a restore password sits world-readable in a task file until that task is pruned.
Recommendation: (1), and apply it to the Backup page's password field at the same time. It is a small, well-scoped addition to a helper that already exists for the mirror-image case, and it fixes a live weakness rather than only avoiding a new one.
5. "Set up a backup server if you don't have one"
Same components, other direction. After a New install, offer: "Where should your backups go?" — the same location fields, then engineInit and a first backup system. That closes a real gap: today backups exist but nothing prompts you to configure them, so the people most likely to need a restore are the least likely to have one.
Worth doing as its own wizard step even without the restore branch.
6. On "upload the backup file"
Worth being precise, because the mental model doesn't match the engines. restic, borg and kopia back up to a repository — a directory or a remote — not a single file. There is nothing to upload. The equivalents are:
- local — a path on a plugged-in disk. "Navigate to it" is right, and a directory picker is the natural UI.
- sftp / s3 / b2 / … — credentials, which the existing fields already collect.
If a genuine single-file import is wanted, that is a different feature: a portable per-app export (tar of the app dir + manifest, optionally encrypted) that could be handed around and imported. Cheap to build on the manifest that already exists, but it is not what the backup engines produce and shouldn't be conflated with them.
7. Phasing
| Phase | Deliverable |
|---|---|
| 1 ✅ | Backup destination step for the New-install path (§5) — built |
| 2 ⛔ | The two blocks and the restore branch. Blocked on §4.1 — the typed repository password has no safe channel to the host yet |
| 3 | The reconciliation screen (§3) and the restore itself, driven by restoreFirstRunBulk |
| 4 | Portable per-app export/import (§6), if wanted |
8. Open questions
- Does the restore branch also restore the system config's identity — install name, domains, WebUI credentials? Restoring the WebUI login means the user logs into the new box with the old password, which is probably what they expect, but it is a surprise if not stated.
- Partial restore of a host — pick apps individually (already supported by
restoreFirstRunBulk's signature) or all-or-nothing at first run? - What if the backup is newer than this LibrePortal version? The manifest records the commit; refusing is safer than guessing, but it strands someone whose only copy is newer.
- Where does the repository password go once entered — straight into the location config it will restore over, or held only in memory until the system config lands and then reconciled?