af786c7b28750263c5c51c4a07d5ef12364a641a
12 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
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 |
||
|
|
cf7e0396f0 |
Dashboard: report when a newer release is available
ci / build-test (push) Successful in 1m13s
The instance had no idea what version it was: VERSION drives tagging and the image publish, but was never stamped into the assemblies, so a running build reported 1.0.0 forever. Directory.Build.props now stamps it into every project. The dashboard compares that against the newest tag in the source repository and shows a banner when behind. A plain GET of a public tag list -- nothing about the instance is sent -- cached six hours, failing quiet. Two things it deliberately does not do. It never blocks a render: the banner paints from the cached answer and refreshes after first render, so a cold start or an unreachable repository costs nothing rather than holding the dashboard open for an HTTP timeout. And it never guesses: an unknown version on either side shows no banner at all, because a banner that cannot clear trains people to ignore the next real one. Version comparison is numeric on exactly three components, not System.Version and not string order. Tags are written vX.Y.Z, the assembly reports X.Y.Z with a +commithash suffix, and "0.10.0" sorts below "0.9.0" as a string -- each of those is a way the banner sticks or never appears. Prerelease suffixes compare equal to their release so an rc tag does not nag. Gitea does not promise semver ordering, so the highest tag wins rather than the first. The command shown depends on the install: the LXC has `update`, a container is replaced by pulling an image, and telling container users to run `update` sends them after a command that does not exist. No update *button*. The UI has no authentication and the LXC runs the app as root, and `update` builds whatever is on master, so a click would be an unauthenticated path to arbitrary code execution for anything on the LAN. The README now states the no-auth position plainly rather than leaving it implied. Tests cover the parse and ordering cases that would strand a banner, the Gitea payload shape captured from the live API, unreachable and garbage responses, and that the VERSION file actually reaches the assembly -- read from MeterVault's own assembly rather than GetEntryAssembly(), which under `dotnet test` is the test host and reported a confident wrong answer. The suite makes no outbound request: the app factory disables the check. Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd |
||
|
|
cedd60ab45 |
Audit fixes: batch recompute, negative-baseline percentages, key-ring persistence
ci / build-test (push) Successful in 1m17s
Three defects found reviewing the last few commits. Deriving consumption on ingest made the batch reading endpoint quadratic. A recompute rewrites a meter's entire consumption series, and POST /api/v1/readings ran one per reading -- 500 readings for one meter meant 500 full rewrites. IngestByMeterAsync takes renormalize:false and the endpoint normalizes each touched meter once after the batch. Percentage change divided by a possibly negative baseline. A net-export meter going from -100 to -150 exported half again as much and would have been reported as "+50%", reading as more consumption. A non-positive baseline now reports no basis rather than a confident lie. The data-protection key ring had no persistent home outside Docker Compose. The LXC installer now creates /var/lib/metervault/keys at 0700 -- the app would otherwise create it under the default umask, leaving a key ring world-readable -- and the Unraid template maps it, since without that every UI-entered secret was lost whenever the container was recreated. README documents the variable and the trust boundary: keys on disk protect against leaked database content, not against an attacker who already has the host. Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd |
||
|
|
18dd4f720d |
Deploy: Proxmox VE community-scripts LXC installer (Gitea, build-from-source)
ci / build-test (push) Successful in 1m18s
Adds deploy/ct/metervault.sh (host) + deploy/install/metervault-install.sh (in-LXC), modeled on MQTTower's community-scripts installer but adapted for MeterVault: it lives on public Gitea (not GitHub) and is a single Blazor app + TimescaleDB (not multi-mode). The host script sources the community-scripts framework, creates a Debian 12 LXC and runs the install script via pct exec. The install script sets up PostgreSQL 16 + TimescaleDB, installs the .NET SDK, builds the app from the public Gitea repo (no prebuilt tarball exists — releases ship as a container image), writes /etc/metervault/environment + a systemd unit on port 8760, and installs an 'update' command (git pull + rebuild). scripts/run-metervault-ct-install.sh is the maintainer helper. All three pass shellcheck (warning-level clean) and bash -n. README documents the one-liner. Claude-Session: https://claude.ai/code/session_01Kib2MniVFbD95fkgLgBBnB |
||
|
|
fe3d81b192 |
Deploy: change default HTTP port 8080 -> 8760
8080 is a very common, collision-prone port. 8760 (hours in a year) is an uncommon default that also avoids Home Assistant's 8123. Applied across Dockerfile (ASPNETCORE_URLS/EXPOSE), compose (mapping + healthcheck), the Unraid template, README and a code comment. The METERVAULT_PORT host override still works. Claude-Session: https://claude.ai/code/session_01Kib2MniVFbD95fkgLgBBnB |
||
|
|
7e34aeccc8 |
Meter chain topology + per-energy-type flow (Sankey) pages
ci / build-test (push) Successful in 1m24s
Adds a meter hierarchy and a flow view: a downstream meter is a *subsection*
of an upstream one (not an addition), so you can see where a main meter's flow
divides — e.g. official water → garden, pool, other; grid/battery → all → car.
- MeterLink (schema + migration AddMeterLinks): a directed from→to flow edge.
Multi-parent allowed (a merge, e.g. grid + solar → house); multi-child is a
split. Cascade-deletes with either endpoint; unique + distinct-endpoint checks.
- FlowService: per energy type + period, builds a Sankey graph — nodes = meters
sized by consumption; link value = downstream meter's consumption, split
proportionally across multiple upstreams; unaccounted remainder under a meter
becomes a synthetic "Other" node; depth via topological longest-path.
- SankeyChart.razor: hand-rolled inline-SVG Sankey (ApexCharts has no Sankey
type) — columns by depth, nodes stacked by value, bezier ribbons sized by flow,
left→right, theme-aware, HTML-encoded labels, tooltips. Built as a MarkupString
to sidestep Razor's <text> element clash.
- /energy/{id} page (one per energy type): KPIs (consumption + cost), the flow
Sankey, and the meter list. NavMenu now lists a link per energy type
(Electricity, Water, Gas, …) loaded from the DB.
- Meters admin: cycle-safe "Sub-meter of (upstream meters)" multi-select
(descendants excluded to prevent cycles); reconciles meter_link rows on save.
- Reference data seeds a demo chain (Haus → Auto) so electricity flow shows
Haus dividing into Auto + Other.
Tests: FlowServiceTests (single-parent remainder; two-parent proportional
split); render test now asserts the flow chain + covers /energy/{id}. 69 Core +
47 Integration = 116 green. Live-verified: Haus 95,450 kWh → Auto 51,909 +
Other 43,541 (flow conserved), all 5 energy-type pages render.
Claude-Session: https://claude.ai/code/session_01Lz2RqAsnQhetqWNoCDfexK
|
||
|
|
09cd435c2b |
Admin write-CRUD, Home Assistant connector config, wiring audit
ci / build-test (push) Successful in 1m19s
Three requested phases.
1) Admin section (SDD §8.7) — MudBlazor inline-dialog CRUD, consistent pattern,
delete guards, snackbar feedback, shared Confirm helper:
- Energy types: create/edit/delete (blocks delete when meters reference it).
- Meters: create/edit/delete; recomputes consumption when mode/baseline
changes (NormalizationService over a fresh factory context, in a tx);
delete cascades data (consumption+readings are Restrict → removed first).
- A meter's ingest sources: manage on the meter-detail Sources tab
(add/edit/delete MQTT/Tasmota/HA sources with typed config).
- Tariffs: full CRUD (scope/component/value/validity).
- Cost categories: CRUD + member management (meter or energy-type members).
- Connectors: ingestion_endpoint CRUD (MQTT broker + Home Assistant);
secrets referenced by env-var name only, never stored.
- Settings: read-only effective-config view (settings are env-driven and
reproducible, so an editable form would change nothing — kept honest).
PV role is now editable on meters (MeterMeta.SetRole can clear a role).
2) Read Home Assistant — extracted a shared public HaEndpointConfig (was a
private record in the worker), added HaConnectionTester (powers the connector
"Test connection": checks base URL + env-resolved token, optionally reads one
entity). Configuring an HA connector + an HA source on a meter drives the
existing REST-poll worker end to end. (WebSocket push stays a future
optimization; REST poll already reads HA.)
3) Wiring/placeholder audit — swept every OnClick/Href: all handlers are real,
all internal links resolve to real routes, no TODO/stub/placeholder code.
Fixed one genuine gap: MainLayout had no drawer toggle, so the nav was
unreachable on narrow screens — added a hamburger button.
Tests: +6 (MeterMeta.SetRole role-removal; HaConnectionTester fail-closed
guard branches with a throwing HttpClientFactory proving no network on bad
config); render test now covers all admin routes. 69 Core + 45 Integration =
114 green. Live-verified in Docker: all admin pages 200, drawer toggle present,
Settings shows real effective config.
Claude-Session: https://claude.ai/code/session_01Lz2RqAsnQhetqWNoCDfexK
|
||
|
|
1282acf82c |
SDD §8 panels (PV/oil/meter-detail) + fix reference-data-in-Docker
Complete the SDD §8 dashboard views that were deferred at the M5 boundary,
and fix a shipping bug that left the Docker demo empty.
Bug: "Load reference data" created meters/tank/tariffs but imported zero
readings in Docker. Root cause: sampledata/ was excluded by .dockerignore and
never copied into the build stage, so the App csproj's linked Content glob
resolved to nothing at publish time; ReferenceDataImporter then silently
skipped the missing CSVs after already writing its marker meter, leaving the
DB permanently "loaded" but empty.
- .dockerignore: stop excluding sampledata/
- Dockerfile: COPY sampledata/ into the build stage
- ReferenceDataImporter: fail-fast (validate CSVs exist before the marker
meter) and throw instead of silently skipping a missing file
- Program.cs + MeterVaultOptions: opt-in MeterVault__SeedReferenceData
(compose METERVAULT_SEED=true) for a one-command populated demo
New SDD §8 panels (read models in Infrastructure/Dashboard, Blazor pages):
- §8.4 Solar/PV (/solar): generation from GenerationCounter meters;
self-consumption / autarky % / self-consumption % / savings derived from
meters tagged total_load & grid_import via Meter.Meta role config
(MeterRoles/MeterMeta) — nothing hardcoded by name.
- §8.5 Oil/consumable (/consumables): tank level (cm→L calibrated), fill
gauge, deliveries log, burner runtime, effective L/h (fixed/empirical),
forecast-to-empty, tariff cost, monthly series.
- §8.6 Meter detail (/meters/{id}): raw readings, normalized consumption,
source status, tariff timeline, events, measured-vs-estimated markers.
- Reusable SeriesChart component; nav links; Meters list rows link to detail.
Tests: MeterMetaTests (Core, +10); DashboardRenderTests extended to assert the
three panel services compute real figures and the new routes render (108 total,
all green). Live-verified in Docker: seed imports 302 readings / 347 consumption
rows; panels render (generation 16,481 kWh, oil 3,967 L) cross-checking the DB.
Claude-Session: https://claude.ai/code/session_01Lz2RqAsnQhetqWNoCDfexK
|
||
|
|
39da00d486 |
Switch to master branch + Gitea Actions
- Move CI/release workflows from .github/workflows to .gitea/workflows (Gitea Actions), targeting the master branch. - docker-publish: push to the Gitea container registry (git.finalfactory.de) with a lowercased image name; login via github.token or a PACKAGES_TOKEN secret. - version-tag: gitea-actions bot identity; optional RELEASE_TOKEN to re-trigger the image build. - Update README/CLAUDE.md/Unraid template/build-and-push.ps1 to the Gitea registry + master. Claude-Session: https://claude.ai/code/session_01WujdMtMJPbxDpDnMeK22rr |
||
|
|
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 |
||
|
|
e223278771 |
M7: release polish — export/import, docs, easy docker push
- Easy docker push (MQTTower ergonomics + the image push it lacks): VERSION file → version-tag.yml (semver-guard auto-tag) → docker-publish.yml (buildx multi-arch → GHCR, registry centralized for one-line retarget to git.finalfactory.de) + ci.yml + build-and-push.ps1. - Verified end to end: deploy/Dockerfile builds; docker compose stack (app + timescaledb) comes up healthy; /healthz and the dashboard respond in-container. - JSON config export/import (ExportService) with id remapping on restore + GET /export, POST /import endpoints; round-trip test preserves meter→type, meter-scoped tariff, category links. - README, HA/Tasmota/MQTT wiring guide (docs/wiring.md), Unraid template. - CLAUDE.md updated to reflect the built codebase. - i18n: locale-aware number/currency formatting (de-DE); full de UI string localization deferred. 95 tests green (56 Core + 39 integration). Claude-Session: https://claude.ai/code/session_01WujdMtMJPbxDpDnMeK22rr |