Update: add an opt-in "Update now" button, gated on an API key
ci / build-test (push) Successful in 1m9s
ci / build-test (push) Successful in 1m9s
Adds the button, plus POST /api/v1/system/update for driving it from Home Assistant or curl. Both pull the latest source, rebuild and restart the service. The gating is the substance of this change. The updater builds whatever is on the branch and the LXC runs MeterVault as root, so triggering it is root-equivalent on that host, and the web UI has no login — "reachable from the dashboard" alone would mean any device on the network could take the machine. Three independent conditions must hold before anything runs: the operator set MeterVault__AllowInAppUpdate, at least one API key is configured, and the caller presented one, compared in constant time so retries cannot time out the key. AllowAnonymousApi cannot reach it. That flag opens reads, and opening reads must not open root, so the endpoint checks the presented key itself rather than relying on the shared group filter that honours it. Availability is re-checked inside LaunchAsync rather than trusting the caller to have done so. The UI button asks for the key every time instead of remembering it: with no login, a browser left open on the dashboard would otherwise be a standing permission to execute code on the host. The key is cleared from component state immediately, and a wrong key and a keyless deployment give the same message so an unauthenticated caller cannot tell them apart. Launched detached through systemd-run: the updater restarts the service, so a child process would be killed part-way through, leaving the app down with a half-published build. --collect reaps the transient unit so a later update is not blocked by the remains of the previous one. Off by default, and where there is no /usr/bin/update — a container, a dev box — it reports that rather than half-running something. Tests pin every refusal, including through the real HTTP pipeline; none of them launch anything. Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd
This commit is contained in:
@@ -74,6 +74,7 @@ Configuration is via environment variables (`Section__Key` double-underscore map
|
||||
| `MeterVault__SeedReferenceData` | `true` to load the bundled demo dataset on first start (idempotent) |
|
||||
| `MeterVault__DataProtectionKeyPath` | Where the key ring for UI-entered connector secrets lives (default `/var/lib/metervault/keys`) |
|
||||
| `MeterVault__UpdateCheckEnabled` | `false` to stop the dashboard checking for a newer release |
|
||||
| `MeterVault__AllowInAppUpdate` | `true` to allow updates triggered from the UI/API — grants root-equivalent access to anyone with an API key; see below |
|
||||
| `MeterVault__UpdateCheckUrl` | Tag listing consulted by that check (repoint at a fork; blank also disables it) |
|
||||
|
||||
The REST API is **closed by default**: with no `ApiKeys` configured and `AllowAnonymousApi` off, it
|
||||
@@ -82,15 +83,34 @@ returns 401. Set at least one API key (or open it explicitly for a trusted netwo
|
||||
> **The web UI has no authentication.** There is no login: anything that can reach the port can read
|
||||
> and change everything, including connectors and their stored secrets. Put it behind a reverse proxy
|
||||
> with auth (Authelia, Traefik forward-auth, …) — `MeterVault__ReverseProxyTrust` then honours the
|
||||
> user header — or keep it on a trusted network. This is why the dashboard reports that an update is
|
||||
> available but does not offer to apply it: with the app running as root in the LXC, a one-click
|
||||
> update would be an unauthenticated path to arbitrary code execution.
|
||||
> user header — or keep it on a trusted network.
|
||||
|
||||
The dashboard compares the running build against the newest tag in the source repository and shows a
|
||||
banner when it is behind. That is a plain GET of a public tag list — nothing about the instance is
|
||||
sent — cached for six hours, and it never blocks or fails a page render. Turn it off with
|
||||
`MeterVault__UpdateCheckEnabled=false`.
|
||||
|
||||
### Updating from the UI (opt-in)
|
||||
|
||||
`MeterVault__AllowInAppUpdate=true` adds an **Update now** button to that banner, and a
|
||||
`POST /api/v1/system/update` endpoint for scripting it from Home Assistant or `curl`:
|
||||
|
||||
```bash
|
||||
curl -X POST http://metervault:8760/api/v1/system/update -H "X-Api-Key: $METERVAULT_API_KEY"
|
||||
```
|
||||
|
||||
Both pull the latest source, rebuild, and restart the service — a few minutes during which MeterVault
|
||||
is unavailable. Readings are untouched; ingestion resumes on restart. LXC only: containers are
|
||||
replaced by pulling a new image, and the endpoint reports that rather than pretending.
|
||||
|
||||
> **Understand what this grants before enabling it.** The updater builds whatever is on the branch,
|
||||
> and the LXC runs MeterVault as **root** — so a valid API key becomes arbitrary code execution on
|
||||
> that host. Three things must all hold before anything runs: the opt-in above, at least one
|
||||
> configured API key, and a caller presenting one. In particular `MeterVault__AllowAnonymousApi` can
|
||||
> **never** reach it — opening reads must not open root — and the UI button asks for the key every
|
||||
> time rather than remembering it, because the UI itself has no login. Leave this off unless the UI
|
||||
> is behind an authenticating proxy or on a network you fully trust.
|
||||
|
||||
Secrets (broker/HA tokens) are **never** stored in the database as plaintext. Each connector picks
|
||||
one of two forms: the *name* of an environment variable, resolved at runtime, or the secret typed
|
||||
into the admin UI and encrypted at rest under the data-protection key ring. Either way a `pg_dump`
|
||||
|
||||
Reference in New Issue
Block a user