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:
@@ -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 })
|
||||
|
||||
@@ -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 @@
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-4" Icon="@Icons.Material.Filled.SystemUpdateAlt">
|
||||
<div class="d-flex flex-wrap align-center" style="gap:.75rem">
|
||||
<span>MeterVault <b>@latest</b> is available — this instance runs <b>@running</b>.</span>
|
||||
<MudTooltip Text="@_command">
|
||||
@if (Runner.Availability is UpdateAvailability.Allowed)
|
||||
{
|
||||
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.SystemUpdateAlt" OnClick="@(() => _confirmOpen = true)">
|
||||
Update now
|
||||
</MudButton>
|
||||
}
|
||||
else
|
||||
{
|
||||
<code style="opacity:.85">@_command</code>
|
||||
</MudTooltip>
|
||||
}
|
||||
</div>
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
<MudDialog @bind-Visible="_confirmOpen" Options="_dialogOptions">
|
||||
<TitleContent><MudText Typo="Typo.h6">Update MeterVault</MudText></TitleContent>
|
||||
<DialogContent>
|
||||
<MudText Typo="Typo.body2" Class="mb-3">
|
||||
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.
|
||||
</MudText>
|
||||
@* 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. *@
|
||||
<MudTextField @bind-Value="_apiKey" Label="API key" InputType="InputType.Password" Immediate="true"
|
||||
HelperText="The key from MeterVault__ApiKeys — required because the web UI has no login." />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@(() => _confirmOpen = false)" Disabled="_starting">Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="StartUpdateAsync"
|
||||
Disabled="@(_starting || string.IsNullOrWhiteSpace(_apiKey))">
|
||||
@(_starting ? "Starting…" : "Update now")
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How this particular install updates. The LXC has an <c>update</c> command; a container is
|
||||
/// replaced by pulling a new image, and telling those users to run <c>update</c> would send them
|
||||
/// looking for a command that does not exist.
|
||||
/// </summary>
|
||||
private static string UpdateCommandHint() =>
|
||||
File.Exists("/usr/bin/update")
|
||||
UpdateRunner.IsSupportedHere
|
||||
? "run: update"
|
||||
: "pull the new image and recreate the container";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user