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
This commit is contained in:
2026-07-18 20:23:47 +02:00
parent cf7e0396f0
commit 9eb3f7d53c
9 changed files with 425 additions and 6 deletions
@@ -42,6 +42,7 @@ public static class DependencyInjection
services.AddSingleton<Security.SecretProtector>();
// Singleton: it caches the last answer so the dashboard never waits on a remote call.
services.AddSingleton<Update.UpdateCheckService>();
services.AddSingleton<Update.UpdateRunner>();
services.AddScoped<Costing.CostService>();
services.AddScoped<Dashboard.DashboardService>();
services.AddScoped<Dashboard.SolarService>();
@@ -32,6 +32,16 @@ public sealed class MeterVaultOptions
/// <summary>How long full-resolution raw readings are retained (SDD §5.5, default 3 years).</summary>
public int RawRetentionDays { get; set; } = 1095;
/// <summary>
/// Allow an update to be triggered from the UI/API. <b>Off by default, and deliberately.</b> 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.
/// </summary>
public bool AllowInAppUpdate { get; set; }
/// <summary>
/// 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.
+160
View File
@@ -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;
/// <summary>Why an in-app update cannot be started, or <see cref="Allowed"/> if it can.</summary>
public enum UpdateAvailability
{
Allowed,
/// <summary>The operator has not set <c>MeterVault__AllowInAppUpdate</c>.</summary>
NotEnabled,
/// <summary>No API key is configured, so no request could ever be authorised to do this.</summary>
NoApiKeyConfigured,
/// <summary>This install has no update mechanism — a container is replaced, not updated in place.</summary>
NotSupportedHere,
}
/// <summary>Outcome of trying to launch the updater.</summary>
public sealed record UpdateLaunch(bool Started, string Message);
/// <summary>
/// Starts the in-container updater on request, gated hard.
/// </summary>
/// <remarks>
/// This is the most dangerous thing in the codebase, so the reasoning is written down. The updater
/// runs <c>git reset --hard</c> and <c>dotnet publish</c> 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
/// (<see cref="MeterVaultOptions.AllowAnonymousApi"/>) can never reach this: that flag opens reads,
/// and opening reads must not open root.
/// </remarks>
public sealed class UpdateRunner(IOptions<MeterVaultOptions> options, ILogger<UpdateRunner> logger)
{
private const string UpdateCommandPath = "/usr/bin/update";
private const string TransientUnit = "metervault-update";
private readonly MeterVaultOptions _options = options.Value;
private readonly ILogger<UpdateRunner> _logger = logger;
/// <summary>True where an in-place update exists at all — the LXC install, not a container.</summary>
public static bool IsSupportedHere => OperatingSystem.IsLinux() && File.Exists(UpdateCommandPath);
/// <summary>Whether an update could be started, ignoring whether any particular caller may.</summary>
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;
}
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// Launches the updater detached from this process and returns immediately.
/// </summary>
/// <remarks>
/// 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. <c>systemd-run</c>
/// 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 <see cref="Availability"/> and
/// <see cref="IsAuthorised"/> first; this re-checks availability rather than trusting them.
/// </remarks>
public async Task<UpdateLaunch> 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}");
}
}
}