From d522a19cae6fef7a58216f9749a72994109176ef Mon Sep 17 00:00:00 2001 From: librelad Date: Thu, 18 Jun 2026 17:51:27 +0100 Subject: [PATCH] docs(roadmap): App Files tab proposal + UID-access spike results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/) 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 Signed-off-by: librelad --- docs/roadmap/files-tab.md | 106 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 docs/roadmap/files-tab.md diff --git a/docs/roadmap/files-tab.md b/docs/roadmap/files-tab.md new file mode 100644 index 0000000..42a8529 --- /dev/null +++ b/docs/roadmap/files-tab.md @@ -0,0 +1,106 @@ +# 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/`.config` are *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//` 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), owner `1002` → manager can **read**, not **write**. +- `libreportal.config` → `-rwxr-xr-x` (755), owner `1002` → manager can **read**, not **write**. +- app dirs → `drwxr-xr-x` (755), owner `1002` → 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//`) 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 **GET**s (list tree / read file — jailed server-side). Writes/deletes/uploads go through the task system → `libreportal appfile write|delete|upload --app --path

` (mirrors `system 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; `.env` masking; 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//` (config files) | **manager** (1003) | ✅ | ✅ **direct** | **Editable** — no helper. Cheapest, most useful. | +| `/libreportal-containers//` (`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:** +1. **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. +2. **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. +3. **The secret-hiding boundary must live in the CLI/allowlist, not in filesystem perms** — `webui_logins` is world-readable today (a separate hardening smell: a secret at mode 755). +4. 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)?