Compare commits

..

No commits in common. "d458fa5ea4a82ba06f84fcfb798d690472914f68" and "da0d6bb6a5c869d3bdf6e307e6fbaf69a814ea8e" have entirely different histories.

View File

@ -1,106 +0,0 @@
# 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/<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), 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/<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 **GET**s (list tree / read file — jailed server-side). Writes/deletes/uploads go through the task system → `libreportal appfile write|delete|upload --app <a> --path <p>` (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/<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:**
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)?