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:
@@ -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/`)
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
using MeterVault.Infrastructure.Options;
|
||||
using MeterVault.Infrastructure.Update;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace MeterVault.Integration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// None of these launch anything: they assert the conditions that must hold <em>before</em> a launch
|
||||
/// is even attempted, which is the part worth pinning. The launch itself needs systemd and a real
|
||||
/// <c>/usr/bin/update</c>.
|
||||
/// </remarks>
|
||||
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<UpdateRunner>.Instance);
|
||||
}
|
||||
Reference in New Issue
Block a user