diff --git a/CLAUDE.md b/CLAUDE.md index e72c413..d083ac7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,6 +82,8 @@ sources (Tasmota/HA/MQTT/manual/CSV) **Time & DST (SDD §10):** store UTC everywhere; bucket and display in the instance timezone (default `Europe/Berlin`). "Daily cost" boundaries are local-midnight — use `time_bucket(..., 'Europe/Berlin')`. +**In-app update (`UpdateRunner`):** the dashboard shows a banner when a newer tag exists (`UpdateCheckService`, cached, never blocks a render). Triggering an update is **off by default** and needs three independent conditions: `MeterVault__AllowInAppUpdate`, at least one configured API key, and a caller presenting one (constant-time compare). `AllowAnonymousApi` deliberately cannot reach it — the updater builds whatever is on the branch and the LXC runs the app as root, so this is root-equivalent. Launches detached via `systemd-run` because the update restarts the service. Treat any change here as security-critical; `UpdateRunnerTests` pins the gate. + **Secrets (SDD §6.4):** broker/HA tokens are **never** stored in DB plaintext. Two forms, chosen per connector in the admin UI: a *reference* (`token_env`/`password_env` naming an env var or Docker secret path) resolved at runtime, or *encrypted at rest* (`token_enc`/`password_enc`) via `SecretProtector` over the ASP.NET Core data-protection key ring. Exactly one survives a save; `EndpointSecret.Resolve` is the single resolution path (encrypted wins). The key ring lives outside the app directory (`MeterVault__DataProtectionKeyPath`, default `/var/lib/metervault/keys`) because the LXC updater republishes `/opt/metervault`. `ExportService` drops `*_enc` values — they are bound to the originating key ring. ## Reference-data behaviours the code must reproduce (from `sampledata/`) diff --git a/README.md b/README.md index 3fb7441..9726db0 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/src/App/Api/ApiEndpoints.cs b/src/App/Api/ApiEndpoints.cs index bfb88bb..07c18f5 100644 --- a/src/App/Api/ApiEndpoints.cs +++ b/src/App/Api/ApiEndpoints.cs @@ -4,6 +4,7 @@ using MeterVault.Infrastructure.Dashboard; using MeterVault.Infrastructure.Ingestion; using MeterVault.Infrastructure.Normalization; using MeterVault.Infrastructure.Persistence; +using MeterVault.Infrastructure.Update; using Microsoft.EntityFrameworkCore; namespace MeterVault.App.Api; @@ -58,6 +59,35 @@ public static class ApiEndpoints return Results.Ok(new IngestResult(written, updated, rejected, ignored)); }).WithSummary("Ingest one or more readings (idempotent). Lets Home Assistant push."); + // Not on the shared group filter: that one lets requests through anonymously when + // AllowAnonymousApi is set, and opening reads must never open a root shell. This checks the + // presented key itself, in constant time, on top of the group filter. + api.MapPost("/system/update", async (HttpContext http, UpdateRunner runner, CancellationToken ct) => + { + if (runner.Availability is not UpdateAvailability.Allowed) + { + return Results.Problem( + statusCode: StatusCodes.Status409Conflict, + title: runner.Availability switch + { + UpdateAvailability.NotEnabled => "In-app update is disabled. Set MeterVault__AllowInAppUpdate=true.", + UpdateAvailability.NoApiKeyConfigured => "In-app update requires at least one configured API key.", + _ => "This install has no in-place update mechanism (containers are replaced, not updated).", + }); + } + + if (!runner.IsAuthorised(http.Request.Headers[ApiKeyFilter.HeaderName].ToString())) + { + return Results.Problem(statusCode: StatusCodes.Status401Unauthorized, + title: "A valid API key is required to start an update."); + } + + var launch = await runner.LaunchAsync(ct); + return launch.Started + ? Results.Accepted(value: new { message = launch.Message }) + : Results.Problem(statusCode: StatusCodes.Status500InternalServerError, title: launch.Message); + }).WithSummary("Start an in-place update (LXC only; requires opt-in and a valid API key)."); + api.MapGet("/meters", async (MeterVaultDbContext db, CancellationToken ct) => Results.Ok(await db.Meters.AsNoTracking() .Select(m => new { m.Id, m.Name, m.EnergyTypeId, Mode = m.Mode.ToString(), m.Unit, m.IsActive }) diff --git a/src/App/Components/Shared/UpdateBanner.razor b/src/App/Components/Shared/UpdateBanner.razor index 76ba983..b37bee4 100644 --- a/src/App/Components/Shared/UpdateBanner.razor +++ b/src/App/Components/Shared/UpdateBanner.razor @@ -1,5 +1,7 @@ @using MeterVault.Infrastructure.Update @inject UpdateCheckService Updates +@inject UpdateRunner Runner +@inject ISnackbar Snackbar @* Renders nothing at all unless a newer release genuinely exists — no "you are up to date" noise. *@ @if (_status is { UpdateAvailable: true, Running: { } running, Latest: { } latest }) @@ -7,16 +9,50 @@
MeterVault @latest is available — this instance runs @running. - + @if (Runner.Availability is UpdateAvailability.Allowed) + { + + Update now + + } + else + { @_command - + }
} + + Update MeterVault + + + This pulls the latest source, rebuilds it, and restarts the service. It takes a few minutes, + during which MeterVault is unavailable. Readings are not affected — ingestion resumes on restart. + + @* The UI has no login, so the key is what actually authorises this — not the click. It is + typed each time rather than remembered, so a browser left open on this page is not a + standing permission to execute code on the host. *@ + + + + Cancel + + @(_starting ? "Starting…" : "Update now") + + + + @code { private UpdateStatus? _status; private string _command = ""; + private bool _confirmOpen; + private bool _starting; + private string? _apiKey; + private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }; protected override void OnInitialized() { @@ -41,13 +77,41 @@ } } + private async Task StartUpdateAsync() + { + _starting = true; + try + { + if (!Runner.IsAuthorised(_apiKey)) + { + // Same message either way: whether a key is wrong or the deployment has none is not + // something an unauthenticated caller should be able to distinguish. + Snackbar.Add("That API key was not accepted.", Severity.Error); + return; + } + + var launch = await Runner.LaunchAsync(); + Snackbar.Add(launch.Message, launch.Started ? Severity.Success : Severity.Error); + if (launch.Started) + { + _confirmOpen = false; + } + } + finally + { + // Never keep the key in component state: this circuit outlives the dialog. + _apiKey = null; + _starting = false; + } + } + /// /// How this particular install updates. The LXC has an update command; a container is /// replaced by pulling a new image, and telling those users to run update would send them /// looking for a command that does not exist. /// private static string UpdateCommandHint() => - File.Exists("/usr/bin/update") + UpdateRunner.IsSupportedHere ? "run: update" : "pull the new image and recreate the container"; } diff --git a/src/Infrastructure/DependencyInjection.cs b/src/Infrastructure/DependencyInjection.cs index 76aae35..145d6a2 100644 --- a/src/Infrastructure/DependencyInjection.cs +++ b/src/Infrastructure/DependencyInjection.cs @@ -42,6 +42,7 @@ public static class DependencyInjection services.AddSingleton(); // Singleton: it caches the last answer so the dashboard never waits on a remote call. services.AddSingleton(); + services.AddSingleton(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Infrastructure/Options/MeterVaultOptions.cs b/src/Infrastructure/Options/MeterVaultOptions.cs index a9794f1..d8d873d 100644 --- a/src/Infrastructure/Options/MeterVaultOptions.cs +++ b/src/Infrastructure/Options/MeterVaultOptions.cs @@ -32,6 +32,16 @@ public sealed class MeterVaultOptions /// How long full-resolution raw readings are retained (SDD §5.5, default 3 years). public int RawRetentionDays { get; set; } = 1095; + /// + /// Allow an update to be triggered from the UI/API. Off by default, and deliberately. The + /// updater builds whatever is on the branch and the LXC runs this app as root, so enabling it + /// turns a valid API key into arbitrary code execution on the host. It additionally requires at + /// least one configured API key: an anonymous-API deployment can never reach it, because opening + /// reads must not open root. Only sensible where the UI is behind an authenticating proxy or on + /// a network you fully trust. + /// + public bool AllowInAppUpdate { get; set; } + /// /// Compare the running build against the newest published release and show a banner when behind. /// Set false for an air-gapped instance, or one that should make no outbound requests at all. diff --git a/src/Infrastructure/Update/UpdateRunner.cs b/src/Infrastructure/Update/UpdateRunner.cs new file mode 100644 index 0000000..4beb4b4 --- /dev/null +++ b/src/Infrastructure/Update/UpdateRunner.cs @@ -0,0 +1,160 @@ +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; +using MeterVault.Infrastructure.Options; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace MeterVault.Infrastructure.Update; + +/// Why an in-app update cannot be started, or if it can. +public enum UpdateAvailability +{ + Allowed, + + /// The operator has not set MeterVault__AllowInAppUpdate. + NotEnabled, + + /// No API key is configured, so no request could ever be authorised to do this. + NoApiKeyConfigured, + + /// This install has no update mechanism — a container is replaced, not updated in place. + NotSupportedHere, +} + +/// Outcome of trying to launch the updater. +public sealed record UpdateLaunch(bool Started, string Message); + +/// +/// Starts the in-container updater on request, gated hard. +/// +/// +/// This is the most dangerous thing in the codebase, so the reasoning is written down. The updater +/// runs git reset --hard and dotnet publish against whatever is on the branch, and in +/// the LXC the app runs as root — so anything able to trigger it can execute arbitrary code as root. +/// The web UI has no authentication, so "reachable from the dashboard" alone would mean any device +/// on the network could take the host. +/// +/// Three independent conditions must therefore hold: the operator opted in explicitly, at least one +/// API key exists, and the caller presented one. The opt-in is not merely a convenience toggle — with +/// it off there is no code path to launch at all. Notably an anonymous-API deployment +/// () can never reach this: that flag opens reads, +/// and opening reads must not open root. +/// +public sealed class UpdateRunner(IOptions options, ILogger logger) +{ + private const string UpdateCommandPath = "/usr/bin/update"; + private const string TransientUnit = "metervault-update"; + + private readonly MeterVaultOptions _options = options.Value; + private readonly ILogger _logger = logger; + + /// True where an in-place update exists at all — the LXC install, not a container. + public static bool IsSupportedHere => OperatingSystem.IsLinux() && File.Exists(UpdateCommandPath); + + /// Whether an update could be started, ignoring whether any particular caller may. + public UpdateAvailability Availability + { + get + { + if (!_options.AllowInAppUpdate) + { + return UpdateAvailability.NotEnabled; + } + + // Without a key nothing can authenticate, and this must never fall back to open access. + if (_options.ApiKeys.Count == 0) + { + return UpdateAvailability.NoApiKeyConfigured; + } + + return IsSupportedHere ? UpdateAvailability.Allowed : UpdateAvailability.NotSupportedHere; + } + } + + /// + /// Whether the presented key authorises an update. Compared in constant time: a naive comparison + /// leaks key material through response timing to a caller who can retry indefinitely. + /// + public bool IsAuthorised(string? providedKey) + { + if (string.IsNullOrEmpty(providedKey) || _options.ApiKeys.Count == 0) + { + return false; + } + + var provided = Encoding.UTF8.GetBytes(providedKey); + var matched = false; + foreach (var candidate in _options.ApiKeys) + { + // No early exit: check every key so the time taken does not reveal which one matched. + var expected = Encoding.UTF8.GetBytes(candidate); + matched |= CryptographicOperations.FixedTimeEquals(provided, expected); + } + + return matched; + } + + /// + /// Launches the updater detached from this process and returns immediately. + /// + /// + /// The updater stops and restarts the service, so a child of this process would be killed + /// half-way through — leaving the app down with a partially published build. systemd-run + /// puts it in its own transient unit, which survives us dying and is what makes "click, wait, + /// come back" possible at all. Callers must have checked and + /// first; this re-checks availability rather than trusting them. + /// + public async Task LaunchAsync(CancellationToken cancellationToken = default) + { + if (Availability is not UpdateAvailability.Allowed) + { + return new UpdateLaunch(false, $"Update cannot be started: {Availability}."); + } + + try + { + var start = new ProcessStartInfo("systemd-run") + { + RedirectStandardError = true, + RedirectStandardOutput = true, + UseShellExecute = false, + }; + + // --collect reaps the unit when it finishes, so a second update is not blocked by the + // corpse of the first. + start.ArgumentList.Add("--collect"); + start.ArgumentList.Add($"--unit={TransientUnit}"); + start.ArgumentList.Add("--description=MeterVault in-app update"); + start.ArgumentList.Add(UpdateCommandPath); + + using var process = Process.Start(start); + if (process is null) + { + return new UpdateLaunch(false, "Could not start systemd-run."); + } + + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + if (process.ExitCode != 0) + { + var error = (await process.StandardError.ReadToEndAsync(cancellationToken).ConfigureAwait(false)).Trim(); + + // Most likely an update already running: the unit name is taken until it is collected. + _logger.LogWarning("systemd-run failed ({ExitCode}): {Error}", process.ExitCode, error); + return new UpdateLaunch(false, + string.IsNullOrWhiteSpace(error) ? "Could not start the update." : error); + } + + // Deliberately loud, and the only record that this happened: the update restarts the app, + // so nothing written after this survives in memory. + _logger.LogWarning("In-app update authorised and started as transient unit {Unit}", TransientUnit); + return new UpdateLaunch(true, + "Update started. The service restarts when the rebuild finishes — this usually takes a few minutes."); + } + catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException or IOException) + { + _logger.LogWarning(ex, "Could not launch the updater"); + return new UpdateLaunch(false, $"Could not launch the updater: {ex.Message}"); + } + } +} diff --git a/tests/Integration.Tests/ApiTests.cs b/tests/Integration.Tests/ApiTests.cs index 48cb13f..7cd384f 100644 --- a/tests/Integration.Tests/ApiTests.cs +++ b/tests/Integration.Tests/ApiTests.cs @@ -11,6 +11,28 @@ public sealed class ApiTests(TimescaleFixture fx) { private sealed record ReadingPush(int MeterId, DateTimeOffset Time, double Value); + [Fact] + public async Task Update_endpoint_is_shut_by_default_and_not_openable_anonymously() + { + // Through the real pipeline, not just the runner: an install that never opted in must return + // a refusal for this endpoint even when the caller presents a valid key, and even when the + // API itself has been opened anonymously. 409 rather than 401 — the endpoint is disabled, + // which is a different fact from the caller being unauthenticated. + using var factory = new MeterVaultAppFactory(fx.ConnectionString, configureApiKey: true); + using var client = factory.CreateClient(); + + using var withKey = new HttpRequestMessage(HttpMethod.Post, "/api/v1/system/update"); + withKey.Headers.Add("X-Api-Key", MeterVaultAppFactory.ApiKey); + var authorised = await client.SendAsync(withKey); + Assert.Equal(HttpStatusCode.Conflict, authorised.StatusCode); + + // And without a key it is certainly not reachable. + var anonymous = await client.PostAsync(new Uri("/api/v1/system/update", UriKind.Relative), content: null); + Assert.True( + anonymous.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Conflict, + $"expected the update endpoint to refuse an unauthenticated caller, got {anonymous.StatusCode}."); + } + [Fact] public async Task Readings_push_requires_key_and_writes_when_authorized() { diff --git a/tests/Integration.Tests/UpdateRunnerTests.cs b/tests/Integration.Tests/UpdateRunnerTests.cs new file mode 100644 index 0000000..6bce300 --- /dev/null +++ b/tests/Integration.Tests/UpdateRunnerTests.cs @@ -0,0 +1,110 @@ +using MeterVault.Infrastructure.Options; +using MeterVault.Infrastructure.Update; +using Microsoft.Extensions.Logging.Abstractions; + +namespace MeterVault.Integration.Tests; + +/// +/// The gate in front of the in-app update. Triggering it runs a build of whatever is on the branch, +/// as root on the LXC, so every one of these is a case where getting it wrong hands the host over. +/// +/// +/// None of these launch anything: they assert the conditions that must hold before a launch +/// is even attempted, which is the part worth pinning. The launch itself needs systemd and a real +/// /usr/bin/update. +/// +public sealed class UpdateRunnerTests +{ + private const string Key = "correct-horse-battery-staple"; + + [Fact] + public void Disabled_by_default() + { + // The single most important assertion here: an operator who has not thought about this does + // not get a remote-code-execution endpoint by upgrading. + Assert.False(new MeterVaultOptions().AllowInAppUpdate); + Assert.Equal(UpdateAvailability.NotEnabled, NewRunner(new MeterVaultOptions()).Availability); + } + + [Fact] + public void Refuses_when_enabled_but_no_api_key_is_configured() + { + // Otherwise "enabled" would mean anyone at all, since there would be no key to present. + var runner = NewRunner(new MeterVaultOptions { AllowInAppUpdate = true }); + + Assert.Equal(UpdateAvailability.NoApiKeyConfigured, runner.Availability); + } + + [Fact] + public void An_anonymous_api_deployment_cannot_reach_it() + { + // AllowAnonymousApi opens reads. It must not open root: with no keys configured this stays + // shut regardless of that flag. + var runner = NewRunner(new MeterVaultOptions + { + AllowInAppUpdate = true, + AllowAnonymousApi = true, + }); + + Assert.Equal(UpdateAvailability.NoApiKeyConfigured, runner.Availability); + Assert.False(runner.IsAuthorised(null)); + Assert.False(runner.IsAuthorised("")); + Assert.False(runner.IsAuthorised("anything")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("wrong")] + [InlineData("correct-horse-battery-stapl")] // prefix of a valid key + [InlineData("correct-horse-battery-staple ")] // trailing space + [InlineData("CORRECT-HORSE-BATTERY-STAPLE")] // case differs + public void Rejects_anything_that_is_not_exactly_a_configured_key(string? provided) + { + Assert.False(NewRunner(Enabled()).IsAuthorised(provided)); + } + + [Fact] + public void Accepts_an_exact_key_and_any_of_several() + { + var options = Enabled(); + options.ApiKeys.Add("second-key"); + var runner = NewRunner(options); + + Assert.True(runner.IsAuthorised(Key)); + Assert.True(runner.IsAuthorised("second-key")); + } + + [Fact] + public async Task Refuses_to_launch_when_not_allowed_even_if_asked_directly() + { + // Defence in depth: LaunchAsync re-checks rather than trusting its caller to have done so. + var launch = await NewRunner(new MeterVaultOptions()).LaunchAsync(); + + Assert.False(launch.Started); + Assert.Contains("NotEnabled", launch.Message, StringComparison.Ordinal); + } + + [Fact] + public void Reports_unsupported_where_there_is_no_in_place_updater() + { + // On a dev box or in a container there is no /usr/bin/update, so a fully configured runner + // still declines rather than half-running something. + if (UpdateRunner.IsSupportedHere) + { + return; // running on a provisioned LXC; the negative case cannot be observed here + } + + Assert.Equal(UpdateAvailability.NotSupportedHere, NewRunner(Enabled()).Availability); + } + + private static MeterVaultOptions Enabled() + { + var options = new MeterVaultOptions { AllowInAppUpdate = true }; + options.ApiKeys.Add(Key); + return options; + } + + private static UpdateRunner NewRunner(MeterVaultOptions options) => + new(Microsoft.Extensions.Options.Options.Create(options), NullLogger.Instance); +}