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:
@@ -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