Update: drop the API-key requirement from the update trigger
ci / build-test (push) Successful in 1m14s
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
This commit is contained in:
@@ -82,7 +82,7 @@ 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')`.
|
**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.
|
**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**; `MeterVault__AllowInAppUpdate` is the *only* gate — no API key, by explicit owner decision. With it on, anything that can reach the app can trigger a rebuild+restart as root (realistically a DoS, since the build comes from the owner's own repo; RCE if that repo is compromised). The REST endpoint additionally requires an `X-MeterVault-Update` header — a CSRF guard, not auth, so a foreign page cannot drive it via a LAN browser. Launches detached via `systemd-run` because the update restarts the service. Treat any change here as security-critical; `UpdateRunnerTests` pins that the flag defaults off and that API keys alone don't enable it.
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +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__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__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__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__AllowInAppUpdate` | `true` to allow updates triggered from the UI/API — no key required, so anything that can reach MeterVault can trigger one; see below |
|
||||||
| `MeterVault__UpdateCheckUrl` | Tag listing consulted by that check (repoint at a fork; blank also disables it) |
|
| `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
|
The REST API is **closed by default**: with no `ApiKeys` configured and `AllowAnonymousApi` off, it
|
||||||
@@ -96,20 +96,25 @@ sent — cached for six hours, and it never blocks or fails a page render. Turn
|
|||||||
`POST /api/v1/system/update` endpoint for scripting it from Home Assistant or `curl`:
|
`POST /api/v1/system/update` endpoint for scripting it from Home Assistant or `curl`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://metervault:8760/api/v1/system/update -H "X-Api-Key: $METERVAULT_API_KEY"
|
curl -X POST http://metervault:8760/api/v1/system/update -H "X-MeterVault-Update: 1"
|
||||||
```
|
```
|
||||||
|
|
||||||
Both pull the latest source, rebuild, and restart the service — a few minutes during which MeterVault
|
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
|
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.
|
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,
|
> **That flag is the whole gate — there is no key and no prompt.** With it on, anything that can
|
||||||
> and the LXC runs MeterVault as **root** — so a valid API key becomes arbitrary code execution on
|
> reach MeterVault can trigger a rebuild and restart. Realistically that is a repeatable denial of
|
||||||
> that host. Three things must all hold before anything runs: the opt-in above, at least one
|
> service (minutes of downtime and a busy CPU per request), not code injection, because the build
|
||||||
> configured API key, and a caller presenting one. In particular `MeterVault__AllowAnonymousApi` can
|
> comes from your own repository — but it becomes remote code execution if that repository is ever
|
||||||
> **never** reach it — opening reads must not open root — and the UI button asks for the key every
|
> compromised. It defaults off. Enable it only on a network you trust, or behind an authenticating
|
||||||
> time rather than remembering it, because the UI itself has no login. Leave this off unless the UI
|
> proxy.
|
||||||
> is behind an authenticating proxy or on a network you fully trust.
|
>
|
||||||
|
> The `X-MeterVault-Update` header is **not** authentication: it stops a *different website* driving
|
||||||
|
> the endpoint through the browser of someone on your network, which a plain HTML form could
|
||||||
|
> otherwise do. The UI button does not need it — it runs over the Blazor circuit, which a foreign
|
||||||
|
> page cannot reach. Every triggered update is logged as a warning, since with no key there is no
|
||||||
|
> caller to attribute it to.
|
||||||
|
|
||||||
Secrets (broker/HA tokens) are **never** stored in the database as plaintext. Each connector picks
|
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
|
one of two forms: the *name* of an environment variable, resolved at runtime, or the secret typed
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ public static class ApiEndpoints
|
|||||||
{
|
{
|
||||||
private const int MaxReadingsPerRequest = 5000;
|
private const int MaxReadingsPerRequest = 5000;
|
||||||
|
|
||||||
|
/// <summary>Header confirming an update request was made on purpose rather than by a foreign page.</summary>
|
||||||
|
public const string UpdateRequestHeader = "X-MeterVault-Update";
|
||||||
|
|
||||||
public static IEndpointRouteBuilder MapMeterVaultApi(this IEndpointRouteBuilder app)
|
public static IEndpointRouteBuilder MapMeterVaultApi(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
var api = app.MapGroup("/api/v1").AddEndpointFilter<ApiKeyFilter>().WithTags("MeterVault");
|
var api = app.MapGroup("/api/v1").AddEndpointFilter<ApiKeyFilter>().WithTags("MeterVault");
|
||||||
@@ -59,9 +62,6 @@ public static class ApiEndpoints
|
|||||||
return Results.Ok(new IngestResult(written, updated, rejected, ignored));
|
return Results.Ok(new IngestResult(written, updated, rejected, ignored));
|
||||||
}).WithSummary("Ingest one or more readings (idempotent). Lets Home Assistant push.");
|
}).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) =>
|
api.MapPost("/system/update", async (HttpContext http, UpdateRunner runner, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
if (runner.Availability is not UpdateAvailability.Allowed)
|
if (runner.Availability is not UpdateAvailability.Allowed)
|
||||||
@@ -71,22 +71,25 @@ public static class ApiEndpoints
|
|||||||
title: runner.Availability switch
|
title: runner.Availability switch
|
||||||
{
|
{
|
||||||
UpdateAvailability.NotEnabled => "In-app update is disabled. Set MeterVault__AllowInAppUpdate=true.",
|
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).",
|
_ => "This install has no in-place update mechanism (containers are replaced, not updated).",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!runner.IsAuthorised(http.Request.Headers[ApiKeyFilter.HeaderName].ToString()))
|
// Not authentication — the operator opted out of that. This only stops a *different site*
|
||||||
|
// driving the endpoint through the browser of someone on this network: a plain HTML form
|
||||||
|
// cannot set a custom header, and a cross-origin fetch that tries is stopped by the
|
||||||
|
// preflight, which nothing here answers. Costs a deliberate caller one flag.
|
||||||
|
if (!http.Request.Headers.ContainsKey(UpdateRequestHeader))
|
||||||
{
|
{
|
||||||
return Results.Problem(statusCode: StatusCodes.Status401Unauthorized,
|
return Results.Problem(statusCode: StatusCodes.Status400BadRequest,
|
||||||
title: "A valid API key is required to start an update.");
|
title: $"Send the {UpdateRequestHeader} header to confirm this is a deliberate request.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var launch = await runner.LaunchAsync(ct);
|
var launch = await runner.LaunchAsync(ct);
|
||||||
return launch.Started
|
return launch.Started
|
||||||
? Results.Accepted(value: new { message = launch.Message })
|
? Results.Accepted(value: new { message = launch.Message })
|
||||||
: Results.Problem(statusCode: StatusCodes.Status500InternalServerError, title: 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).");
|
}).WithSummary($"Start an in-place update (LXC only; requires MeterVault__AllowInAppUpdate and the {UpdateRequestHeader} header).");
|
||||||
|
|
||||||
api.MapGet("/meters", async (MeterVaultDbContext db, CancellationToken ct) =>
|
api.MapGet("/meters", async (MeterVaultDbContext db, CancellationToken ct) =>
|
||||||
Results.Ok(await db.Meters.AsNoTracking()
|
Results.Ok(await db.Meters.AsNoTracking()
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
@inject UpdateRunner Runner
|
@inject UpdateRunner Runner
|
||||||
@inject ISnackbar Snackbar
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
|
@* No key prompt: the operator opted out of that (MeterVault__AllowInAppUpdate is the whole gate).
|
||||||
|
The confirmation stays — not as a security control, but because a stray click costs several
|
||||||
|
minutes of downtime while the rebuild runs. *@
|
||||||
|
|
||||||
@* Renders nothing at all unless a newer release genuinely exists — no "you are up to date" noise. *@
|
@* 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 })
|
@if (_status is { UpdateAvailable: true, Running: { } running, Latest: { } latest })
|
||||||
{
|
{
|
||||||
@@ -31,16 +35,10 @@
|
|||||||
This pulls the latest source, rebuilds it, and restarts the service. It takes a few minutes,
|
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.
|
during which MeterVault is unavailable. Readings are not affected — ingestion resumes on restart.
|
||||||
</MudText>
|
</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>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<MudButton OnClick="@(() => _confirmOpen = false)" Disabled="_starting">Cancel</MudButton>
|
<MudButton OnClick="@(() => _confirmOpen = false)" Disabled="_starting">Cancel</MudButton>
|
||||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="StartUpdateAsync"
|
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="StartUpdateAsync" Disabled="_starting">
|
||||||
Disabled="@(_starting || string.IsNullOrWhiteSpace(_apiKey))">
|
|
||||||
@(_starting ? "Starting…" : "Update now")
|
@(_starting ? "Starting…" : "Update now")
|
||||||
</MudButton>
|
</MudButton>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
@@ -51,7 +49,6 @@
|
|||||||
private string _command = "";
|
private string _command = "";
|
||||||
private bool _confirmOpen;
|
private bool _confirmOpen;
|
||||||
private bool _starting;
|
private bool _starting;
|
||||||
private string? _apiKey;
|
|
||||||
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.ExtraSmall, FullWidth = true };
|
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.ExtraSmall, FullWidth = true };
|
||||||
|
|
||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
@@ -82,14 +79,6 @@
|
|||||||
_starting = true;
|
_starting = true;
|
||||||
try
|
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();
|
var launch = await Runner.LaunchAsync();
|
||||||
Snackbar.Add(launch.Message, launch.Started ? Severity.Success : Severity.Error);
|
Snackbar.Add(launch.Message, launch.Started ? Severity.Success : Severity.Error);
|
||||||
if (launch.Started)
|
if (launch.Started)
|
||||||
@@ -99,8 +88,6 @@
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
// Never keep the key in component state: this circuit outlives the dialog.
|
|
||||||
_apiKey = null;
|
|
||||||
_starting = false;
|
_starting = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Security.Cryptography;
|
|
||||||
using System.Text;
|
|
||||||
using MeterVault.Infrastructure.Options;
|
using MeterVault.Infrastructure.Options;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
@@ -15,9 +13,6 @@ public enum UpdateAvailability
|
|||||||
/// <summary>The operator has not set <c>MeterVault__AllowInAppUpdate</c>.</summary>
|
/// <summary>The operator has not set <c>MeterVault__AllowInAppUpdate</c>.</summary>
|
||||||
NotEnabled,
|
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>
|
/// <summary>This install has no update mechanism — a container is replaced, not updated in place.</summary>
|
||||||
NotSupportedHere,
|
NotSupportedHere,
|
||||||
}
|
}
|
||||||
@@ -30,16 +25,16 @@ public sealed record UpdateLaunch(bool Started, string Message);
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// This is the most dangerous thing in the codebase, so the reasoning is written down. The updater
|
/// 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
|
/// runs <c>git reset --hard</c> and <c>dotnet publish</c> against whatever is on the branch, then
|
||||||
/// the LXC the app runs as root — so anything able to trigger it can execute arbitrary code as root.
|
/// restarts the service, and in the LXC the app runs 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
|
/// <see cref="MeterVaultOptions.AllowInAppUpdate"/> is the only gate, by explicit operator choice —
|
||||||
/// API key exists, and the caller presented one. The opt-in is not merely a convenience toggle — with
|
/// no key, no prompt. With it on, anything that can reach the UI can trigger a rebuild-and-restart:
|
||||||
/// it off there is no code path to launch at all. Notably an anonymous-API deployment
|
/// on the realistic threat model that is a repeatable denial of service (minutes of downtime and a
|
||||||
/// (<see cref="MeterVaultOptions.AllowAnonymousApi"/>) can never reach this: that flag opens reads,
|
/// pegged CPU per request) rather than code injection, since the build comes from the operator's own
|
||||||
/// and opening reads must not open root.
|
/// repository — but it becomes full remote code execution if that repository is ever compromised.
|
||||||
|
/// With it off there is no code path to launch at all, which is why it defaults off and why the
|
||||||
|
/// check is repeated inside <see cref="LaunchAsync"/> rather than trusted to callers.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class UpdateRunner(IOptions<MeterVaultOptions> options, ILogger<UpdateRunner> logger)
|
public sealed class UpdateRunner(IOptions<MeterVaultOptions> options, ILogger<UpdateRunner> logger)
|
||||||
{
|
{
|
||||||
@@ -62,39 +57,10 @@ public sealed class UpdateRunner(IOptions<MeterVaultOptions> options, ILogger<Up
|
|||||||
return UpdateAvailability.NotEnabled;
|
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;
|
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>
|
/// <summary>
|
||||||
/// Launches the updater detached from this process and returns immediately.
|
/// Launches the updater detached from this process and returns immediately.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -145,9 +111,10 @@ public sealed class UpdateRunner(IOptions<MeterVaultOptions> options, ILogger<Up
|
|||||||
string.IsNullOrWhiteSpace(error) ? "Could not start the update." : error);
|
string.IsNullOrWhiteSpace(error) ? "Could not start the update." : error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deliberately loud, and the only record that this happened: the update restarts the app,
|
// Deliberately loud. With no key there is no caller to attribute this to, so the log is
|
||||||
// so nothing written after this survives in memory.
|
// the only record that it happened at all — and the update restarts the app, so nothing
|
||||||
_logger.LogWarning("In-app update authorised and started as transient unit {Unit}", TransientUnit);
|
// held in memory survives.
|
||||||
|
_logger.LogWarning("In-app update started as transient unit {Unit}", TransientUnit);
|
||||||
return new UpdateLaunch(true,
|
return new UpdateLaunch(true,
|
||||||
"Update started. The service restarts when the rebuild finishes — this usually takes a few minutes.");
|
"Update started. The service restarts when the rebuild finishes — this usually takes a few minutes.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,25 +12,21 @@ public sealed class ApiTests(TimescaleFixture fx)
|
|||||||
private sealed record ReadingPush(int MeterId, DateTimeOffset Time, double Value);
|
private sealed record ReadingPush(int MeterId, DateTimeOffset Time, double Value);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Update_endpoint_is_shut_by_default_and_not_openable_anonymously()
|
public async Task Update_endpoint_is_shut_unless_explicitly_enabled()
|
||||||
{
|
{
|
||||||
// Through the real pipeline, not just the runner: an install that never opted in must return
|
// Through the real pipeline, not just the runner. 409 rather than 401: the endpoint is
|
||||||
// a refusal for this endpoint even when the caller presents a valid key, and even when the
|
// disabled, which is a different fact from the caller being unauthenticated — and the
|
||||||
// API itself has been opened anonymously. 409 rather than 401 — the endpoint is disabled,
|
// default install must refuse regardless of what the caller presents.
|
||||||
// which is a different fact from the caller being unauthenticated.
|
|
||||||
using var factory = new MeterVaultAppFactory(fx.ConnectionString, configureApiKey: true);
|
using var factory = new MeterVaultAppFactory(fx.ConnectionString, configureApiKey: true);
|
||||||
using var client = factory.CreateClient();
|
using var client = factory.CreateClient();
|
||||||
|
|
||||||
using var withKey = new HttpRequestMessage(HttpMethod.Post, "/api/v1/system/update");
|
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/system/update");
|
||||||
withKey.Headers.Add("X-Api-Key", MeterVaultAppFactory.ApiKey);
|
request.Headers.Add("X-Api-Key", MeterVaultAppFactory.ApiKey);
|
||||||
var authorised = await client.SendAsync(withKey);
|
request.Headers.Add(MeterVault.App.Api.ApiEndpoints.UpdateRequestHeader, "1");
|
||||||
Assert.Equal(HttpStatusCode.Conflict, authorised.StatusCode);
|
|
||||||
|
|
||||||
// And without a key it is certainly not reachable.
|
var response = await client.SendAsync(request);
|
||||||
var anonymous = await client.PostAsync(new Uri("/api/v1/system/update", UriKind.Relative), content: null);
|
|
||||||
Assert.True(
|
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
|
||||||
anonymous.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Conflict,
|
|
||||||
$"expected the update endpoint to refuse an unauthenticated caller, got {anonymous.StatusCode}.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ using Microsoft.Extensions.Logging.Abstractions;
|
|||||||
namespace MeterVault.Integration.Tests;
|
namespace MeterVault.Integration.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The gate in front of the in-app update. Triggering it runs a build of whatever is on the branch,
|
/// The gate in front of the in-app update. <c>AllowInAppUpdate</c> is the whole gate by explicit
|
||||||
/// as root on the LXC, so every one of these is a case where getting it wrong hands the host over.
|
/// operator choice — no key — so the flag defaulting off is the only thing standing between an
|
||||||
|
/// upgrade and a network-triggerable rebuild-and-restart as root.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// None of these launch anything: they assert the conditions that must hold <em>before</em> a launch
|
/// None of these launch anything: they assert the conditions that must hold <em>before</em> a launch
|
||||||
@@ -15,64 +16,24 @@ namespace MeterVault.Integration.Tests;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class UpdateRunnerTests
|
public sealed class UpdateRunnerTests
|
||||||
{
|
{
|
||||||
private const string Key = "correct-horse-battery-staple";
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Disabled_by_default()
|
public void Disabled_by_default()
|
||||||
{
|
{
|
||||||
// The single most important assertion here: an operator who has not thought about this does
|
// The single most important assertion in this file: with no key required, an operator who
|
||||||
// not get a remote-code-execution endpoint by upgrading.
|
// upgrades without reading the notes must not silently acquire an open trigger.
|
||||||
Assert.False(new MeterVaultOptions().AllowInAppUpdate);
|
Assert.False(new MeterVaultOptions().AllowInAppUpdate);
|
||||||
Assert.Equal(UpdateAvailability.NotEnabled, NewRunner(new MeterVaultOptions()).Availability);
|
Assert.Equal(UpdateAvailability.NotEnabled, NewRunner(new MeterVaultOptions()).Availability);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Refuses_when_enabled_but_no_api_key_is_configured()
|
public void Configuring_api_keys_alone_does_not_enable_it()
|
||||||
{
|
{
|
||||||
// Otherwise "enabled" would mean anyone at all, since there would be no key to present.
|
// The two settings are independent: having an API key for the REST API is not consent to
|
||||||
var runner = NewRunner(new MeterVaultOptions { AllowInAppUpdate = true });
|
// rebuild the host.
|
||||||
|
var options = new MeterVaultOptions();
|
||||||
|
options.ApiKeys.Add("some-key");
|
||||||
|
|
||||||
Assert.Equal(UpdateAvailability.NoApiKeyConfigured, runner.Availability);
|
Assert.Equal(UpdateAvailability.NotEnabled, NewRunner(options).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]
|
[Fact]
|
||||||
@@ -88,8 +49,8 @@ public sealed class UpdateRunnerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Reports_unsupported_where_there_is_no_in_place_updater()
|
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
|
// On a dev box or in a container there is no /usr/bin/update, so an enabled runner still
|
||||||
// still declines rather than half-running something.
|
// declines rather than half-running something.
|
||||||
if (UpdateRunner.IsSupportedHere)
|
if (UpdateRunner.IsSupportedHere)
|
||||||
{
|
{
|
||||||
return; // running on a provisioned LXC; the negative case cannot be observed here
|
return; // running on a provisioned LXC; the negative case cannot be observed here
|
||||||
@@ -98,12 +59,7 @@ public sealed class UpdateRunnerTests
|
|||||||
Assert.Equal(UpdateAvailability.NotSupportedHere, NewRunner(Enabled()).Availability);
|
Assert.Equal(UpdateAvailability.NotSupportedHere, NewRunner(Enabled()).Availability);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static MeterVaultOptions Enabled()
|
private static MeterVaultOptions Enabled() => new() { AllowInAppUpdate = true };
|
||||||
{
|
|
||||||
var options = new MeterVaultOptions { AllowInAppUpdate = true };
|
|
||||||
options.ApiKeys.Add(Key);
|
|
||||||
return options;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static UpdateRunner NewRunner(MeterVaultOptions options) =>
|
private static UpdateRunner NewRunner(MeterVaultOptions options) =>
|
||||||
new(Microsoft.Extensions.Options.Options.Create(options), NullLogger<UpdateRunner>.Instance);
|
new(Microsoft.Extensions.Options.Options.Create(options), NullLogger<UpdateRunner>.Instance);
|
||||||
|
|||||||
Reference in New Issue
Block a user