Design note for a per-app Files tab scoped to LibrePortal-managed files (not system files): four file buckets (hidden/view-only/editable/lever), the advanced/dev mode as the single escalation lever (not per-file flags), and the hard rule that the flag is UX-only while the locked-down task CLI stays the security boundary (jail + secret allowlist). Includes the live UID-access spike: the manager owns and can write the config tree (/libreportal-system/configs) directly, but the container tree (/libreportal-containers/<app>) is dockerinstall-owned — readable, not writable — so config edits need no helper while compose-class edits do. webui_logins is manager-readable, so secret-hiding must live in the CLI allowlist, not in perms. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: librelad <librelad@digitalangels.vip>
9.3 KiB
LibrePortal — App "Files" tab (Roadmap / Proposal)
Status: Proposal / brainstorm — not built. Design agreed in conversation 2026-06-18; the UID-access spike (§7) is run, results recorded. · Audience: us, future-self · Scope: a per-app file view/edit surface scoped to LibrePortal-managed files, with safe defaults + opt-in escalation · Origin: "file manager for services, like Virtualmin" idea, narrowed to "view/edit LibrePortal-specific files, not system files."
0. The one idea
A per-app Files tab (alongside Services on the app detail page) that lets you view — and, for the safe subset, edit — the files LibrePortal manages for that app, without dropping to SSH. The whole feature hangs off one boundary:
You can only touch files under LibrePortal's own roots for this app. Never arbitrary host/system files.
This is deliberately not Virtualmin/cPanel. Virtualmin is a whole-server control panel; LibrePortal is a curated, privacy-first app manager. A general "browse/edit/delete anywhere" explorer would (a) be the highest-value target on the box, (b) reopen the rootless + de-sudo hardening, and (c) drift the product toward "web root shell." The scoping to LibrePortal-owned files is what keeps it on-brand.
1. Non-goals
- ❌ A general filesystem browser. No
/etc,/home, arbitrary paths. - ❌ A replacement for the config system. Compose/
.configare generated; this views them, it does not become the front door for changing them. - ❌ Editing inside the running container's ephemeral FS (changes lost on recreate — a trap). Scope is the host-side files LibrePortal owns/generates.
- ❌ A new mutating backend API. Writes route through the task system → locked-down CLI, per the project rule.
2. The central constraint — file ownership under rootless
This decides what's even possible, so it leads the design.
App files live under /libreportal-containers/<app>/ and are owned by the dockerinstall user (uid 1002 on the current box), not the manager. The manager/runtime user (libreportal; uid 1003 here — always id it, uids drift per box) is a different user. Observed perms:
docker-compose.yml→-rw-r--r--(644), owner1002→ manager can read, not write.libreportal.config→-rwxr-xr-x(755), owner1002→ manager can read, not write.- app dirs →
drwxr-xr-x(755), owner1002→ manager can list/traverse, not create/delete entries.
So: the manager can read LibrePortal's app files but cannot write them. And actual app data written by a container (inside bind mounts) may be owned by rootless-namespaced subuids (100000+), which can be non-readable by the manager at all.
Consequences (refined by the §7 spike):
- Reads (browse/view) of the management files are free — they're world-readable.
- Config files live in a different tree (
/libreportal-system/configs/) that the manager owns — so config edits are a direct write, no helper needed. This is the most-useful bucket and the cheapest to build. - Container-tree writes (compose,
.config, app code under/libreportal-containers/<app>/) need a privileged helper running as the owner (dockerinstall) or root, invoked via the locked-down task CLI — exactly the shape the de-sudo work already uses (root-owned helpers in/usr/local/lib/libreportal/). - App data files (3rd-party app bind mounts) may not even be readable by the manager; that subset is best-effort and may require a container-side path. (Untested — only the manager's own app is installed; see §7.)
3. File classification (buckets, not per-file flags)
Rather than a config variable per file type (which balloons), classify every candidate file into one of four buckets via a small ruleset:
| Bucket | Default behaviour | Examples |
|---|---|---|
| Hidden | Never listed, in any mode. Allowlist-in, not blocklist-out. | webui_logins, SSH keys, tokens, anything matching a secret pattern |
| View-only | Always viewable; editable only when the lever is on | docker-compose.yml, generated config artifacts, .env (view-masked — tends to hold generated secrets) |
| Editable | View + edit by default | app config files, plain text/data the app reads |
| (lever) | promotes View-only → Editable | advanced/dev mode (see §4) |
docker-compose.yml = view is the headline default: it's automated, and raw-editing it desyncs from the config system and has a large blast radius.
4. The escalation lever — reuse what exists, don't build a permission engine
The "let me edit the locked stuff" lever should be the advanced/dev UI mode that already exists (lp.ui.advanced / lp.ui.dev, window.LpUi), not a parallel per-file flag system. At most, add one CFG_ to set the default policy per install (e.g. CFG_FILES_EDIT_POLICY = safe|advanced); the live override is the mode toggle. Layering that falls out cleanly:
dev mode → reveals the "allow advanced file edits" policy
→ promotes the View-only bucket to Editable in the Files tab
→ edits still flow through the validated task verb (§5)
One mental model, three honest layers, no new permission system.
5. The security boundary — the flag is UX, the CLI is the gate
Non-negotiable: the config/mode flag is presentation only. The locked-down task verb (libreportal appfile …) is the real boundary and must enforce, regardless of any flag:
- the per-app jail (resolve symlinks; reject any path escaping the app's root — no
.., no absolute escapes), - the hidden/secret allowlist (refuse to read or write a forbidden path even if the UI thinks it's unlocked),
- writes execute as the file owner (
dockerinstall/root helper), never as an arbitrary target.
A CFG_ variable must never be the only thing between a user and webui_logins. Frame the unlock as "I accept the risk" tiers, not "turn off safety" — and keep the bypass strictly in the UI layer.
6. Surface + phasing
- API: reads are plain authed GETs (list tree / read file — jailed server-side). Writes/deletes/uploads go through the task system →
libreportal appfile write|delete|upload --app <a> --path <p>(mirrorssystem reclaim). - MVP (Phase 1): read-only browse + text viewer for an app's management files (compose, config, generated artifacts). Pure GET, low risk, immediately useful ("what got deployed / what's in this config"). Confirms the jail + secret-hiding before any write path exists.
- Phase 2: edit/delete for the Editable bucket via the task verb, owner-helper write path.
- Phase 3: the advanced lever promotes View-only → Editable;
.envmasking; app-data files (pending §7).
7. UID-access spike — results (2026-06-18, live box)
Goal: empirically confirm what the manager user can read/write under rootless, so we know how much of the "editable" bucket needs an owner-helper. Run as the manager user
libreportal(uid 1003). Only the manager's own app was installed, so the 3rd-party-app data case is reasoned, not measured.
| Tree | Owner | Manager read | Manager write | → Bucket / build cost |
|---|---|---|---|---|
/libreportal-system/configs/<app>/ (config files) |
manager (1003) | ✅ | ✅ direct | Editable — no helper. Cheapest, most useful. |
/libreportal-containers/<app>/ (docker-compose.yml, .config, code) |
dockerinstall (1002) |
✅ | ❌ | View-only; edit needs an owner-helper task |
| app data in bind mounts (3rd-party apps) | container subuids (100000+) | likely ❌ | ❌ | best-effort / out of v1 (untested) |
secrets (webui_logins, keys) |
manager, but world-readable (mode 755) | ✅ | ✅ | Hidden — perms do NOT protect it; the CLI allowlist must |
Probe output (as libreportal): compose/config READ ok, WRITE denied, CREATE denied in the container tree; LIST ok. In the configs tree: CREATE ok. webui_logins: READ ok (hence must be hidden in the UI, not relied on perms).
Conclusions:
- Friction and risk align. The most useful case (edit a config) is a direct manager write — no privilege at all. The risky case (compose-class) is both view-only-by-default and the one that needs a helper. So the helper write path is only ever taken for files you've deliberately unlocked.
- Phase-1 MVP is even cheaper than feared: read-only browse/view across both trees + direct edit of the config tree needs no new privileged helper — just the jailed read GETs and a manager-side write through the existing task CLI.
- The secret-hiding boundary must live in the CLI/allowlist, not in filesystem perms —
webui_loginsis world-readable today (a separate hardening smell: a secret at mode 755). - Container-tree edits (compose/
.config) are deferred behind the advanced lever + an owner-helper, which matches them being view-only by default anyway.
8. Open questions
- App data files owned by namespaced subuids: readable at all by the manager? If not, do we want a container-side read path, or just declare data files out of scope for v1?
.env: view-masked, or hidden entirely? It usually holds generated secrets.- Per-app Files tab only, or also a small global "LibrePortal files" area? (Lean per-app — the jail is naturally that app's root.)
- Do we diff/snapshot before an edit so a bad save is revertible (tie into the existing snapshot/rollback primitive)?