LibrePortal/docs/roadmap/first-run-restore.md
librelad d44ebf0ca7 docs(roadmap): the restore password has no safe channel to the host yet
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>
2026-08-27 08:14:18 +01:00

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:

  1. Where is your backup? — the existing backup-location fields, rendered from config metadata like the Locations page already does.
  2. Unlock it — the repository password. See §4; this is the step that decides whether a restore is possible at all.
  3. What's in there? — hosts found, then apps per host, with sizes and snapshot dates.
  4. What will change on this machine — the reconciliation screen. §3. The new part.
  5. 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:

  1. A one-shot secret drop. The ownership helper already knows how to make a path readable across exactly this boundary (_webui_bind_access chowns MANAGER:cowner 0640 so the container can read manager-owned files). The reverse needs the same treatment: a root-helper-created directory owned cowner:MANAGER 0730, into which the container drops a 0640 file the manager reads once and unlinks.
  2. 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).
  3. 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

  1. 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.
  2. Partial restore of a host — pick apps individually (already supported by restoreFirstRunBulk's signature) or all-or-nothing at first run?
  3. 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.
  4. 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?