Merge claude/2
This commit is contained in:
commit
a18d34fcfb
@ -206,10 +206,12 @@ Add them as they resurface.)*
|
||||
|
||||
This is the concrete shape the brainstorm landed on. It was stress-tested by a
|
||||
four-lens design pass (marketplace-first, security-first, simplicity/reuse-first,
|
||||
ops-ux-first) that **converged** on the same model — which is why it's promoted
|
||||
from "vision" to "spec". The whole thing is **one verb over a type-tagged
|
||||
envelope**; a hotfix is the first artifact type, and apps/themes/components are
|
||||
*new envelope rows*, not new features.
|
||||
ops-ux-first) that **converged** on the same model — *marketplace-first* scored
|
||||
top, with security-first's **publishers-map trust anchor** and ops-ux's **per-op
|
||||
`undo` array** grafted in. That convergence is why it's promoted from "vision" to
|
||||
"spec". The whole thing is **one verb over a type-tagged envelope**; a hotfix is
|
||||
the first artifact type, and apps/themes/components are *new envelope rows*, not
|
||||
new features.
|
||||
|
||||
### 8.0 Three layers (each already half-built)
|
||||
|
||||
@ -232,19 +234,26 @@ type information: `type` and `payload.kind`. That is the whole marketplace seam.
|
||||
"index_serial": 17, // monotonic; anti-rollback (TUF-lite)
|
||||
"valid_until": 1750000000, // epoch; a stale feed is REFUSED (anti-withholding)
|
||||
"generated_at": "2026-05-31T12:00:00Z",
|
||||
"publishers": { // TRUST ANCHOR map — the team-signed index vouches for it
|
||||
"libreportal": { "display": "LibrePortal", "role": "official", "key": "RWR…" }
|
||||
// future: "alice": { "display": "Alice", "role": "community", "key": "RWS…" }
|
||||
},
|
||||
"artifacts": [
|
||||
{
|
||||
"id": "hf-vaultwarden-signup-env-2026-05", // stable, unique
|
||||
"type": "hotfix", // hotfix | app | theme | component
|
||||
"version": 1, // bump to re-issue/supersede
|
||||
"publisher": { "name": "LibrePortal", "trust": "official" }, // official|community|custom
|
||||
"id": "hf-vaultwarden-signup-env-2026-05", // stable, unique — the History/snapshot/toggle key
|
||||
"type": "hotfix", // hotfix | app | theme | component (the dispatch axis)
|
||||
"version": 1, // monotonic per id
|
||||
"supersedes": [], // ids this retires (also the recall mechanism)
|
||||
"reversible": true, // false ⇒ extra confirm before apply
|
||||
"publisher": "libreportal", // a KEY into index.publishers (NEVER inline)
|
||||
"trust": "official", // honored only if the publisher's role permits it
|
||||
"severity": "breakage", // security|breakage|compat|tweak
|
||||
"auto": true, // see §8.5 fork 2 (severity-split default)
|
||||
"title": "Fix Vaultwarden signup after upstream env rename",
|
||||
"why": "Upstream renamed SIGNUPS_ALLOWED; logins break until the new key is set.",
|
||||
"applies_when": { // gates; missing = always
|
||||
"applies_when": { // the "target" gates; missing = always
|
||||
"app": "vaultwarden", "min_lp": "1.0.0", "max_lp": null,
|
||||
"max_footprint": 4
|
||||
"max_footprint": 4, "image_match": null, "requires": [], "conflicts": []
|
||||
},
|
||||
"payload": {
|
||||
"kind": "ops", // ops (hotfix) | bundle (app/theme/component)
|
||||
@ -256,10 +265,18 @@ type information: `type` and `payload.kind`. That is the whole marketplace seam.
|
||||
}
|
||||
```
|
||||
|
||||
**Fixed fields, identical for every type:** `id, type, version, publisher{name,trust},
|
||||
severity, auto, title, why, applies_when, payload{kind,url,sha256,sig}`. An app entry
|
||||
is byte-for-byte this shape with `type:"app"`, `payload.kind:"bundle"`, and a tarball
|
||||
payload. A theme is `type:"theme"`, `kind:"bundle"`. Nothing in the envelope moves.
|
||||
**Fixed fields, identical for every type:** `id, type, version, supersedes, reversible,
|
||||
publisher, trust, severity, auto, title, why, applies_when, payload{kind,url,sha256,sig}`.
|
||||
An app entry is byte-for-byte this shape with `type:"app"`, `payload.kind:"bundle"`, and a
|
||||
tarball payload. A theme is `type:"theme"`, `kind:"bundle"`. Nothing in the envelope moves.
|
||||
|
||||
**`publisher` is a key, never inline.** It points at an entry in the index-root
|
||||
`publishers` map, which the *team-signed index* vouches for (`{display, role, key}`). An
|
||||
artifact's claimed `trust` is honored **only if** (a) the referenced publisher's `role`
|
||||
permits it **and** (b) the artifact's own signature verifies against that publisher's
|
||||
`key`. Because the manager can't edit the (signed) index, it can't self-promote a key — so
|
||||
a `community` publisher can **never** masquerade as `official`. This is the load-bearing
|
||||
trust mechanism for the whole marketplace seam, and it's present day one.
|
||||
|
||||
**Forward-compat firewall:** an installed box that doesn't recognise a `type` or a
|
||||
`payload.kind` **skips + logs** it (never errors). So the registry can publish new
|
||||
@ -267,30 +284,40 @@ types the day a newer client understands them, without breaking older installs.
|
||||
|
||||
### 8.2 The op vocabulary (`payload.kind:"ops"` — the hotfix body)
|
||||
|
||||
A **bounded, closed, declarative** set. **There is no `run-script` op, ever** — that
|
||||
is the supply-chain contract from §1. The payload file is `{ "schema":1, "ops":[ … ] }`.
|
||||
The applier is a hardcoded dispatch `case`; an **unknown op name aborts the whole
|
||||
artifact** (fail-closed, never a partial apply). Every op:
|
||||
A **bounded, closed, declarative** set. **There is no `run-script`/`exec`/`shell` op,
|
||||
ever** — a signed feed is an RCE channel *only if it can carry code*; this is the
|
||||
supply-chain contract from §1. The payload file is `{ "schema":1, "ops":[ … ] }`. The
|
||||
applier is a hardcoded dispatch `case`; an **unknown op name rejects the whole artifact at
|
||||
validation, before any snapshot** (fail-closed, never a partial apply). Every op:
|
||||
|
||||
1. is **precondition-guarded** (checksum / `expect_current`) — it refuses on local drift
|
||||
rather than clobbering,
|
||||
2. is **reversible** — reverse is the snapshot restore the pipeline already takes, so
|
||||
even a buggy op can't make rollback wrong,
|
||||
1. is **precondition-guarded** (checksum / `expect_current`) — it refuses (SKIP, recorded)
|
||||
on local drift rather than clobbering;
|
||||
2. **two-tier reversible** — each op records a **pre-image into the History `undo` array**,
|
||||
so a clean op reverts *precisely* without a full restore; the snapshot (always taken) is
|
||||
the fallback for a dirty/un-invertible op, so even a buggy op can't make rollback wrong;
|
||||
3. writes **only through the existing privilege funnels** — `runInstallOp`/`runFileOp`
|
||||
by tree (never raw `sudo`); `set-config-key` rides `updateConfigOption`, which already
|
||||
routes the write correctly per the de-sudo split.
|
||||
|
||||
| op | args | apply (existing fn) | reverse | precondition |
|
||||
**All-or-nothing**: the applier *dry-prechecks every op first*; if any precondition fails,
|
||||
the whole artifact is **skipped untouched** (a first-class History entry, so coverage gaps
|
||||
are visible — risk: a customised box may legitimately miss a fix). At most one
|
||||
`dockerComposeUp` per app, after all ops.
|
||||
|
||||
| op | args | apply (existing fn) | undo | precondition |
|
||||
|---|---|---|---|---|
|
||||
| `set-config-key` | `key,value` | `updateConfigOption KEY VALUE` | restore snapshot | `key` matches `^CFG_[A-Z0-9_]+$`; opt. `expect_current` |
|
||||
| `add-compose-label` / `remove-compose-label` | `app,service,label` | edit `containers/<app>/docker-compose.yml` via `runFileOp` | inverse op / snapshot | service exists |
|
||||
| `set-compose-image` | `app,service,image` | rewrite the `image:` line | restore prior image | current image == `expect_current` |
|
||||
| `ensure-env` | `app,service,key,value` | upsert env entry | restore / remove | — |
|
||||
| `patch-file-if-checksum-matches` | `path,expect_sha256,content_ref` | write new content **iff** current sha256 matches | restore snapshot | **hard** sha256 match; path-allowlisted to `containers/<app>/` + `configs/` |
|
||||
| `set-config-key` | `key,value` | `updateConfigOption KEY VALUE` → `webuiGenerateSystemConfigs` | prior value (or delete if absent) | `key` matches `^CFG_[A-Z0-9_]+$`; opt. `expect_current` |
|
||||
| `add-compose-label` / `remove-compose-label` | `app,service,label` | edit `containers/<app>/docker-compose.yml` via `runFileOp` | inverse op | service exists |
|
||||
| `set-compose-image` | `app,service,image,from` | rewrite the `image:` line → `updaterComposePull` | restore `from` | current image == `from` (pin-a-broken-`latest`) |
|
||||
| `set-compose-env` / `unset-compose-env` | `app,service,key,value` | upsert/remove env entry | restore prior | — |
|
||||
| `patch-file-if-checksum-matches` | `path,expect_sha256,content_ref,result_sha256` | write new content **iff** current sha256 matches; assert post==result | captured bytes | **hard** sha256 match; allowlisted to `containers/<app>/`; install/WebUI tree **only** `trust:official` + `scope:system` |
|
||||
| `set-data-file` | `path,url,sha256` | fetch + verify + drop a whole file | captured bytes | path-allowlisted; **the bridge to `bundle`** (a bundle = N of these) |
|
||||
| `ensure-compose-up` / `restart-service` | `app` \| unit | `dockerComposeUp` / restart (allowlist: the `libreportal` unit, traefik) | no-op | — |
|
||||
|
||||
`set-compose-image` + `patch-file-if-checksum-matches` are the upstream-breakage killers
|
||||
(§1). The checksum lock turns "patch a file" from an arbitrary write into a drift-safe,
|
||||
conflict-detecting, reversible transform.
|
||||
conflict-detecting, reversible transform. `set-data-file` is deliberately the seam to the
|
||||
future `bundle` applier — apps need a *new applier*, not new ops.
|
||||
|
||||
### 8.3 The PIPELINE (the verb) — `libreportal artifact apply <id>`
|
||||
|
||||
@ -341,13 +368,20 @@ This is exactly the §3 "registry, not marketplace" shape, now expressed in the
|
||||
config/compose, so this loses nothing real.
|
||||
2. **Auto-apply policy** → **severity-split, declarative in the envelope** (`severity` +
|
||||
`auto`). `security`/`breakage` → auto-apply ON by default (defensible because the
|
||||
snapshot/auto-rollback safety net exists); `compat`/`tweak` → surface + one-click, auto
|
||||
only under an opt-in "auto-improve".
|
||||
snapshot/auto-rollback safety net exists); `compat` → surface + one-click; `tweak` →
|
||||
manual unless opted into "auto-improve". A `CFG_HOTFIX_AUTO` toggle
|
||||
(`security-breakage` default / `all` / `off`) lets the operator tune it; `reversible:false`
|
||||
always forces a confirm. Blast radius is bounded by per-box snapshot/rollback **plus**
|
||||
staged rollout (edge before stable), a randomized apply delay, and recall via
|
||||
`supersedes` (a superseding revert).
|
||||
3. **Hotfix locality** → **both.** `applies_when.app` makes an artifact app-scoped (it also
|
||||
surfaces on that app's page); a null app is system-wide. One field, both behaviours.
|
||||
4. **Third-party — yet?** → **first-party only now.** The index ships with `trust:"official"`
|
||||
entries; `community`/`custom` tiers just start appearing later (and gate the riskiest ops).
|
||||
The "tap" mechanism is designed-in but unbuilt until there's real demand (§4 sequencing).
|
||||
4. **Third-party — yet?** → **first-party only now, registry-ready by design.** The index
|
||||
ships with one publisher (`libreportal`, role `official`) and one source — no tap UI,
|
||||
accounts, or moderation. But the `publishers` map + per-artifact `trust` are already in
|
||||
the envelope, so opening to community is *appending publisher entries / sources and
|
||||
flipping the trust-gate UI* — not a rebuild. `community`/`custom` tiers gate the riskiest
|
||||
ops (host-script hooks on `bundle` apps — §3.2).
|
||||
5. **App catalog entry point** → **curated Browse-&-Add** (first-party definitions as the
|
||||
seed catalog), with bring-your-own-compose remaining the advanced/“custom source” path.
|
||||
|
||||
@ -355,14 +389,27 @@ This is exactly the §3 "registry, not marketplace" shape, now expressed in the
|
||||
|
||||
- **Two-tier signatures** anchored on the **root-owned footprint key** (`/usr/local/lib/
|
||||
libreportal/libreportal.pub`) — the manager can't swap it, so it can't bless a forgery.
|
||||
Tier 1: the footprint key signs the **index** (incl. the `publishers` map). Tier 2: each
|
||||
artifact's signature is checked against *its publisher's key from that signed map*, and
|
||||
the claimed `trust` is honored only if the publisher's `role` allows it. A forged inline
|
||||
key can't self-certify — the trust comes from the team-signed map, not from the artifact.
|
||||
- **Canonical bytes** — sign/verify the **exact bytes between the artifact's braces** (sig
|
||||
field excluded); never re-serialize the JSON on the box (a re-serialization mismatch would
|
||||
silently break every signature). One rule, no ambiguity.
|
||||
- **`valid_until`** — a signed feed that simply *stops advancing* is the silent-withholding
|
||||
/ targeting attack; a stale index is **refused**, not treated as "no updates". Same spirit
|
||||
as the [warrant canary](../../) (freshness = signal).
|
||||
as the [warrant canary](../../) (freshness = signal); ideally the fortnightly canary
|
||||
**countersigns the current `index_serial`** so a frozen feed is provable, not just absent.
|
||||
- **`index_serial`** — monotonic; a lower serial than we've accepted is a rollback attack →
|
||||
refused. The high-water mark is recorded locally and never lowered by a refused fetch.
|
||||
refused. The high-water mark is recorded locally (a dedicated `.index_serial` file, *not*
|
||||
derived from History — so the anti-rollback guard never depends on History's jq path) and
|
||||
never lowered by a refused fetch.
|
||||
- **Public + identical for everyone** — one signed feed; a targeted hotfix to a single
|
||||
victim is impossible to send without it being publicly visible.
|
||||
- **Nothing silent** — every apply lands in **History** with what / why / revert.
|
||||
- **Nothing silent** — every apply lands in **History** with what / why / revert. ⚠️ This
|
||||
guarantee currently has a hole: `updaterRecordHistory` (`cli_updater_commands.sh:154-168`)
|
||||
does `command -v jq || return 0` — it *silently skips* the audit entry when jq is absent.
|
||||
Phase 2 must make it **fail-closed with a bash-native fallback** before any hotfix applies.
|
||||
|
||||
### 8.7 Build phases & status
|
||||
|
||||
@ -377,13 +424,26 @@ This is exactly the §3 "registry, not marketplace" shape, now expressed in the
|
||||
that fetches + verifies + lists. Runs directly (no mutation), like `updater check`.
|
||||
- Self-tested: trust core fails closed (real key + no minisign → refuse), happy path,
|
||||
stale-refused, rollback-refused, signature-refused, jq + grep parsing — 12/12.
|
||||
- ⬜ **Phase 2 — the ops applier + apply verb.** `artifactApply`/`artifactApplyOps` with
|
||||
the §8.2 vocabulary, per-payload sig check, snapshot → apply → verify → auto-rollback →
|
||||
`updaterRecordHistory` (extend `history.json` with `artifact_id`/`serial`), wired as the
|
||||
`artifact_apply` task. Makes the Vaultwarden killer use case real, first-party. *(next)*
|
||||
- ⬜ **Phase 3 — WebUI surfacing.** A `webui_artifact_scan.sh` generator (clone of the
|
||||
updater scan) writes `data/updater/generated/artifacts_available.json`; a "Hotfixes"
|
||||
section in the Updates page reads it (graceful-absent). Hook the index fetch into the
|
||||
existing update-check call site — **no second phone-home**.
|
||||
- ⬜ **Phase 4 — marketplace types.** `payload.kind:"bundle"` handler (drop + scan/regen)
|
||||
+ `type:"app"|"theme"|"component"` in step 4; later, the "tap" (custom source) UX.
|
||||
- ⬜ **Phase 2 — the ops applier + apply verb (the heart, *next*).** `artifactApply`
|
||||
(steps 0–9) + `artifactApplyOps` (the §8.2 vocabulary with dry-precheck-all + per-op
|
||||
`undo[]`), the **publishers-map two-tier sig check + canonical-envelope verification**,
|
||||
snapshot → apply → verify → auto-rollback → History, wired as `artifact_apply` /
|
||||
`artifact_revert` tasks. Reuse `updateConfigOption` / `dockerComposeUp` /
|
||||
`updaterComposePull` / `backup app` / `updaterRollbackApp` verbatim. Extend `history.json`
|
||||
(`artifact_id`, `serial`, `undo`) **and fix the `updaterRecordHistory` jq-silent-skip
|
||||
(fail-closed + bash-native fallback)** — the "nothing silent" guarantee depends on it.
|
||||
Makes the Vaultwarden killer use case real, first-party.
|
||||
- ⬜ **Phase 3 — auto-apply policy.** `CFG_HOTFIX_AUTO`, the periodic-check auto-apply of
|
||||
`security`/`breakage` (queue `compat`/`tweak` as suggestions), staged rollout + delay.
|
||||
- ⬜ **Phase 4 — WebUI "Updates & Improvements".** Extend `webuiUpdaterScan` to fetch +
|
||||
verify the index into a **temp then atomically write** `artifacts_available.json` (never
|
||||
emit broken JSON; keep the prior file on failure) — **no second phone-home**. Add the
|
||||
Hotfixes/Improvements stream (why / severity / source, one-click revert, per-app chip).
|
||||
*User-visible → verify with `lp-shot` on the updater route before calling it done.*
|
||||
- ⬜ **Phase 5 — publisher tooling.** `make_hotfix.sh` (sibling of `make_release.sh`) emits
|
||||
a payload + sha256 + minisig + the index entry, then re-signs the index bumping
|
||||
`index_serial`. The piece that lets a maintainer actually ship one.
|
||||
- ⬜ **Deferred (registry; additive, demand-gated).** `payload.kind:"bundle"` applier (verify
|
||||
tarball → extract into the app tree → scan/regen) + `type:"app"|"theme"|"component"` +
|
||||
the `app_add` task + community trust-tier **host-script quarantine** (§3.2) + multi-source
|
||||
"tap" UX + the warrant-canary countersigning `index_serial`.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user