Commit Graph

5 Commits

Author SHA1 Message Date
schmidt.florian 1f575c9da2 Update: drop the API-key requirement from the update trigger
ci / build-test (push) Successful in 1m14s
Owner's call: MeterVault__AllowInAppUpdate is now the whole gate. One click on
the banner, no key, no prompt, and the REST endpoint no longer asks for one
either.

What that means, recorded so it is not rediscovered later: with the flag on,
anything that can reach MeterVault can trigger a rebuild and restart. On the
realistic threat model that is a repeatable denial of service — minutes of
downtime and a pegged CPU per request — rather than code injection, because the
build comes from the owner's own repository. It becomes remote code execution
if that repository is ever compromised. The flag still defaults off, and that
default is now the only thing between an upgrade and an open trigger, so
UpdateRunnerTests pins it along with the fact that configuring API keys does not
imply consent to rebuild the host.

Kept one guard, which is not authentication: the REST endpoint requires an
X-MeterVault-Update header. Without it any website could POST to the endpoint
through the browser of someone on the network — a plain HTML form is enough,
and no key means nothing else would stop it. A form cannot set a custom header
and a cross-origin fetch that tries is stopped by a preflight nothing here
answers, so this costs a deliberate caller one flag and costs the button
nothing, since it runs over the Blazor circuit rather than HTTP.

The confirmation dialog stays, now purely as a guard against a stray click
costing several minutes of downtime. Every triggered update is logged as a
warning: with no key there is no caller to attribute it to, and the restart
discards anything held in memory.

Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd
2026-07-18 20:39:12 +02:00
schmidt.florian 9eb3f7d53c Update: add an opt-in "Update now" button, gated on an API key
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
2026-07-18 20:23:47 +02:00
schmidt.florian 62d102c335 Ingestion: derive consumption on ingest, and poll HA in minutes not seconds
ci / build-test (push) Successful in 1m13s
Live ingestion wrote the raw reading and stopped there. Import, the REST push
endpoint and the meter editor all recompute afterwards; the MQTT/Tasmota/HA
path was the one that did not, so a polled reading landed in `reading` and
every derived figure stayed frozen at the last import. Observed on a
GenerationCounter: 45 readings, 44 consumption rows, generation pinned to the
register value of the last imported reading.

Recompute inline rather than behind a debounce. Normalizing a whole meter is
cheap at metering cadence and a background dirty-set worker is machinery this
does not yet need; the remark on RenormalizeAsync records when it would.

Fixes a latent bug this surfaced in NormalizationService: ExecuteDelete drops
the consumption rows in the database but leaves them in the change tracker, so
a second recompute on the same context threw an identity conflict on
(meter, time, kind). One worker scope ingesting two readings was enough to hit
it. Detach the stale entries after the delete.

Poll interval is now minutes, default 60, replacing seconds/60. A meter answers
"how much this month, what will it cost" — an hourly sample answers that
exactly as well as a per-second one, with far less raw volume (SDD §5.5). The
`pollSeconds` key no longer binds, so existing sources fall back to the 60
default and move from every-60-seconds to hourly, which is the intent. A source
that had deliberately set e.g. 300 seconds also lands on 60 minutes.

Two test cleanups now delete consumption before the meter: live ingestion never
produced any before, so the FK had nothing to trip on.

Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd
2026-07-18 18:45:57 +02:00
schmidt.florian a6edec2b12 Polish/audit: fix bugs found by 3 subsystem audits
ci / build-test (push) Successful in 2m45s
Correctness/data:
- Fix demo cost double-count: reference importer no longer imports the Kosten Strom/Wasser
  columns for categories that are metered (only Heizung), so Wasser rollup is 70€ not 140€.
- Spurious-decrease guard: only a reset/swap in the window (prevReading, thisReading] explains
  a decrease — an old historical reset no longer permanently disables the guard.
- Gate swap auto-detection on MappingProfile.DetectCumulativeSwaps (flag was ignored).
- Prorate basePrice by bucket length (day/month/year); guard virtual expressions against NaN/Inf.

Concurrency/infra:
- Blazor: register a DbContextFactory; CostService/DashboardService and the read pages now use
  short-lived per-operation contexts (no shared circuit DbContext); guard Trends re-entrancy.
- /events: wrap event insert + consumption recompute in one transaction (atomic); 404 (not 500)
  on unknown meter.
- MQTT worker: subscribe to newly-added topics on each tick; move client cleanup into finally.
- Migrations: CREATE MATERIALIZED VIEW IF NOT EXISTS + if_not_exists on CAgg/compression/
  hypertable calls (re-run-safe after a mid-migration crash).
- HA worker: prune stale poll-schedule entries; export: null dangling ImportBatchIds on restore.

API/security:
- API fail-closed by default: with no keys and AllowAnonymousApi off, /api/v1 returns 401
  (protects /export and /import). New MeterVault:AllowAnonymousApi opt-in.
- Cap /readings batch at 5000; report ignored (unknown-meter) count; enums as strings in JSON.

+4 regression tests (guard window, API closed, /events 404, no demo double-count). 98 tests
green; Docker deploy re-verified healthy with the API fail-closed.

Claude-Session: https://claude.ai/code/session_01WujdMtMJPbxDpDnMeK22rr
2026-07-13 12:56:51 +02:00
schmidt.florian 9abc2937c2 M6: REST API + API-key auth + OpenAPI
- Minimal API under /api/v1 (SDD §9): POST /readings (idempotent HA push), GET /meters,
  /energy-types, /consumption, /cost, /dashboard/summary, POST /events (records + recomputes),
  GET+POST /tariffs, GET /sources/status.
- IngestionService.IngestByMeterAsync for direct REST push (batch-safe upsert via Local cache).
- ApiKeyFilter: X-Api-Key enforced against configured keys (open only when none set).
- ReverseProxyTrust middleware: adopt X-Forwarded-User/Remote-User behind Authelia/Traefik.
- Swagger/OpenAPI (Swashbuckle) at /swagger.
- Tests: push rejected without key (401), accepted + persisted with key; meters + swagger live.

94 tests green (56 Core + 38 integration).

Claude-Session: https://claude.ai/code/session_01WujdMtMJPbxDpDnMeK22rr
2026-07-13 12:16:49 +02:00