diff --git a/Directory.Build.props b/Directory.Build.props index 1118ef8..7bf8995 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -15,6 +15,16 @@ false + + + $(MSBuildThisFileDirectory)VERSION + $([System.IO.File]::ReadAllText('$(MeterVaultVersionFile)').Trim().TrimStart('v')) + $(MeterVaultVersion) + + false diff --git a/README.md b/README.md index 912d207..3fb7441 100644 --- a/README.md +++ b/README.md @@ -73,10 +73,24 @@ Configuration is via environment variables (`Section__Key` double-underscore map | `MeterVault__EnableLiveIngestion` | `false` to disable the MQTT/HA workers | | `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__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 returns 401. Set at least one API key (or open it explicitly for a trusted network). +> **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. + +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`. + 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` diff --git a/src/App/Components/Pages/Dashboard.razor b/src/App/Components/Pages/Dashboard.razor index 66d1aca..6ca90a2 100644 --- a/src/App/Components/Pages/Dashboard.razor +++ b/src/App/Components/Pages/Dashboard.razor @@ -5,6 +5,8 @@ Overview + + @if (_summary is null) { diff --git a/src/App/Components/Shared/UpdateBanner.razor b/src/App/Components/Shared/UpdateBanner.razor new file mode 100644 index 0000000..76ba983 --- /dev/null +++ b/src/App/Components/Shared/UpdateBanner.razor @@ -0,0 +1,53 @@ +@using MeterVault.Infrastructure.Update +@inject UpdateCheckService Updates + +@* 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 }) +{ + +
+ MeterVault @latest is available — this instance runs @running. + + @_command + +
+
+} + +@code { + private UpdateStatus? _status; + private string _command = ""; + + protected override void OnInitialized() + { + // Cached answer only — awaiting the check here would hold the dashboard's first paint open + // for the length of an HTTP timeout on a cold start, or whenever the repo is unreachable. + _status = Updates.Current; + _command = UpdateCommandHint(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + { + return; + } + + var refreshed = await Updates.GetAsync(); + if (refreshed != _status) + { + _status = refreshed; + StateHasChanged(); + } + } + + /// + /// How this particular install updates. The LXC has an update command; a container is + /// replaced by pulling a new image, and telling those users to run update would send them + /// looking for a command that does not exist. + /// + private static string UpdateCommandHint() => + File.Exists("/usr/bin/update") + ? "run: update" + : "pull the new image and recreate the container"; +} diff --git a/src/Infrastructure/DependencyInjection.cs b/src/Infrastructure/DependencyInjection.cs index 5183533..76aae35 100644 --- a/src/Infrastructure/DependencyInjection.cs +++ b/src/Infrastructure/DependencyInjection.cs @@ -40,6 +40,8 @@ public static class DependencyInjection // Singleton: wraps one IDataProtector, and the ingestion workers (themselves singletons) // resolve connector secrets on every reconnect. services.AddSingleton(); + // Singleton: it caches the last answer so the dashboard never waits on a remote call. + services.AddSingleton(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Infrastructure/Options/MeterVaultOptions.cs b/src/Infrastructure/Options/MeterVaultOptions.cs index 38b1417..a9794f1 100644 --- a/src/Infrastructure/Options/MeterVaultOptions.cs +++ b/src/Infrastructure/Options/MeterVaultOptions.cs @@ -32,6 +32,20 @@ public sealed class MeterVaultOptions /// How long full-resolution raw readings are retained (SDD §5.5, default 3 years). public int RawRetentionDays { get; set; } = 1095; + /// + /// 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. + /// + public bool UpdateCheckEnabled { get; set; } = true; + + /// + /// Tag listing consulted by the update check. Points at the project's own Gitea, not a vendor + /// endpoint — nothing about the instance is sent, it is a plain GET of a public tag list. + /// Repoint it at a fork, or blank it to disable the check as surely as the flag above. + /// + public string UpdateCheckUrl { get; set; } = + "https://git.finalfactory.de/api/v1/repos/FinalFactory/MeterVault/tags?limit=50"; + /// /// API keys accepted on the X-Api-Key header for the REST API (SDD §9). Provide via env /// (e.g. MeterVault__ApiKeys__0=...). Empty means the API is open (dev only). diff --git a/src/Infrastructure/Update/ReleaseVersion.cs b/src/Infrastructure/Update/ReleaseVersion.cs new file mode 100644 index 0000000..9a56caf --- /dev/null +++ b/src/Infrastructure/Update/ReleaseVersion.cs @@ -0,0 +1,95 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; + +namespace MeterVault.Infrastructure.Update; + +/// +/// A released version, parsed from either the running assembly or a git tag name. +/// +/// +/// Deliberately not . Tags are written vX.Y.Z while the assembly +/// carries X.Y.Z — often with a +commithash suffix from the build — and comparing +/// those as strings, or letting System.Version see a 4th component it invents as -1, produces an +/// "update available" banner that never clears. Parsing to exactly three numbers makes the +/// comparison total and boring. +/// +public readonly record struct ReleaseVersion(int Major, int Minor, int Patch) : IComparable +{ + public static bool TryParse(string? text, out ReleaseVersion version) + { + version = default; + if (string.IsNullOrWhiteSpace(text)) + { + return false; + } + + var value = text.Trim(); + + // Build metadata ("0.2.0+3f1a9c") and prerelease suffixes ("0.2.0-rc.1") are not part of the + // ordering here: a prerelease tag compares equal to its release, so it never nags. + var cut = value.IndexOfAny(['+', '-']); + if (cut >= 0) + { + value = value[..cut]; + } + + value = value.TrimStart('v', 'V'); + + var parts = value.Split('.'); + if (parts.Length != 3) + { + return false; + } + + if (!int.TryParse(parts[0], NumberStyles.None, CultureInfo.InvariantCulture, out var major) + || !int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var minor) + || !int.TryParse(parts[2], NumberStyles.None, CultureInfo.InvariantCulture, out var patch)) + { + return false; + } + + version = new ReleaseVersion(major, minor, patch); + return true; + } + + /// Picks the highest parseable version from a list of tag names, ignoring the rest. + public static bool TryPickLatest(IEnumerable tagNames, [NotNullWhen(true)] out ReleaseVersion? latest) + { + ArgumentNullException.ThrowIfNull(tagNames); + + ReleaseVersion? best = null; + foreach (var name in tagNames) + { + if (TryParse(name, out var parsed) && (best is null || parsed > best.Value)) + { + best = parsed; + } + } + + latest = best; + return best is not null; + } + + public int CompareTo(ReleaseVersion other) + { + var major = Major.CompareTo(other.Major); + if (major != 0) + { + return major; + } + + var minor = Minor.CompareTo(other.Minor); + return minor != 0 ? minor : Patch.CompareTo(other.Patch); + } + + public static bool operator <(ReleaseVersion left, ReleaseVersion right) => left.CompareTo(right) < 0; + + public static bool operator >(ReleaseVersion left, ReleaseVersion right) => left.CompareTo(right) > 0; + + public static bool operator <=(ReleaseVersion left, ReleaseVersion right) => left.CompareTo(right) <= 0; + + public static bool operator >=(ReleaseVersion left, ReleaseVersion right) => left.CompareTo(right) >= 0; + + public override string ToString() => + string.Create(CultureInfo.InvariantCulture, $"{Major}.{Minor}.{Patch}"); +} diff --git a/src/Infrastructure/Update/UpdateCheckService.cs b/src/Infrastructure/Update/UpdateCheckService.cs new file mode 100644 index 0000000..6ff34f6 --- /dev/null +++ b/src/Infrastructure/Update/UpdateCheckService.cs @@ -0,0 +1,154 @@ +using System.Reflection; +using System.Text.Json; +using MeterVault.Infrastructure.Options; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace MeterVault.Infrastructure.Update; + +/// What the instance is running, and what the newest published release is. +/// Version of the running build, or null if the assembly carries none. +/// Newest release tag seen, or null if the check has not succeeded. +/// When the last successful check completed. +public sealed record UpdateStatus(ReleaseVersion? Running, ReleaseVersion? Latest, DateTimeOffset? CheckedAt) +{ + /// True only when both versions are known and the published one is genuinely newer. + public bool UpdateAvailable => Running is { } running && Latest is { } latest && latest > running; +} + +/// +/// Compares the running build against the newest tag in the source repository, so an instance can +/// say it is behind (SDD §12 releases are driven by the VERSION file). +/// +/// +/// Singleton with a cached result: the dashboard renders on every navigation and must never wait on, +/// or fail because of, a remote call. A failed check keeps serving the last good answer and is +/// retried on a short backoff rather than per page view — an instance with no outbound access should +/// cost one failed request every few minutes, not one per render. +/// +public sealed class UpdateCheckService( + IHttpClientFactory httpClientFactory, + IOptions options, + ILogger logger) +{ + private static readonly TimeSpan SuccessTtl = TimeSpan.FromHours(6); + private static readonly TimeSpan FailureTtl = TimeSpan.FromMinutes(15); + + private readonly IHttpClientFactory _httpClientFactory = httpClientFactory; + private readonly MeterVaultOptions _options = options.Value; + private readonly ILogger _logger = logger; + private readonly SemaphoreSlim _gate = new(1, 1); + + private UpdateStatus _status = new(RunningVersion(), null, null); + private DateTimeOffset _nextCheck = DateTimeOffset.MinValue; + + /// The version of the running build, or null when the assembly carries no usable one. + /// + /// Reads MeterVault's own assembly rather than , which is + /// whatever process happens to be hosting — the test runner under dotnet test, whose + /// version parses fine and would report a confident, wrong answer. Every project shares the + /// VERSION stamp from Directory.Build.props, so this is the release version wherever it runs. + /// + public static ReleaseVersion? RunningVersion() + { + var informational = typeof(UpdateCheckService).Assembly + .GetCustomAttribute()?.InformationalVersion; + + return ReleaseVersion.TryParse(informational, out var version) ? version : null; + } + + /// + /// The last known status, without touching the network. Lets a page paint immediately and fill + /// the banner in afterwards, instead of holding first render open for the length of an HTTP + /// timeout on a cold start. + /// + public UpdateStatus Current => _status; + + /// + /// The current status, refreshing at most once per TTL. Never throws: a check that cannot reach + /// the repository leaves the banner absent rather than breaking the page that asked. + /// + public async Task GetAsync(CancellationToken cancellationToken = default) + { + if (!_options.UpdateCheckEnabled || DateTimeOffset.UtcNow < _nextCheck) + { + return _status; + } + + // One caller refreshes; the rest take the cached answer rather than queueing behind it, so a + // slow endpoint cannot stack up render-blocking waits. + if (!await _gate.WaitAsync(0, cancellationToken).ConfigureAwait(false)) + { + return _status; + } + + try + { + if (DateTimeOffset.UtcNow < _nextCheck) + { + return _status; + } + + var latest = await FetchLatestTagAsync(cancellationToken).ConfigureAwait(false); + if (latest is not null) + { + _status = new UpdateStatus(RunningVersion(), latest, DateTimeOffset.UtcNow); + _nextCheck = DateTimeOffset.UtcNow + SuccessTtl; + } + else + { + _nextCheck = DateTimeOffset.UtcNow + FailureTtl; + } + + return _status; + } + finally + { + _gate.Release(); + } + } + + private async Task FetchLatestTagAsync(CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_options.UpdateCheckUrl)) + { + return null; + } + + try + { + var client = _httpClientFactory.CreateClient(); + client.Timeout = TimeSpan.FromSeconds(10); + + using var response = await client + .GetAsync(new Uri(_options.UpdateCheckUrl), cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + _logger.LogDebug("Update check returned {Status}", (int)response.StatusCode); + return null; + } + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); + + // Gitea's /tags returns an array of objects with a "name". Order is not guaranteed to be + // semver, so take the highest rather than the first. + if (document.RootElement.ValueKind != JsonValueKind.Array) + { + return null; + } + + var names = document.RootElement.EnumerateArray() + .Select(e => e.TryGetProperty("name", out var name) ? name.GetString() : null); + + return ReleaseVersion.TryPickLatest(names, out var latest) ? latest : null; + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException or UriFormatException) + { + // Offline, DNS gone, repository moved, unexpected payload: all mean "cannot tell", which + // is a missing banner, not an error the operator needs to see on every page. + _logger.LogDebug(ex, "Update check failed"); + return null; + } + } +} diff --git a/tests/Integration.Tests/MeterVaultAppFactory.cs b/tests/Integration.Tests/MeterVaultAppFactory.cs index cb928cd..4ecc78c 100644 --- a/tests/Integration.Tests/MeterVaultAppFactory.cs +++ b/tests/Integration.Tests/MeterVaultAppFactory.cs @@ -17,6 +17,9 @@ public sealed class MeterVaultAppFactory(string connectionString, bool configure builder.UseSetting("ConnectionStrings:Default", connectionString); builder.UseSetting("MeterVault:RunMigrationsAtStartup", "false"); builder.UseSetting("MeterVault:EnableLiveIngestion", "false"); + // No outbound calls from tests: the update check would otherwise hit the real Gitea on every + // page render, making the suite slow and dependent on that host being up. + builder.UseSetting("MeterVault:UpdateCheckEnabled", "false"); if (configureApiKey) { builder.UseSetting("MeterVault:ApiKeys:0", ApiKey); diff --git a/tests/Integration.Tests/UpdateCheckTests.cs b/tests/Integration.Tests/UpdateCheckTests.cs new file mode 100644 index 0000000..7ee68ee --- /dev/null +++ b/tests/Integration.Tests/UpdateCheckTests.cs @@ -0,0 +1,203 @@ +using System.Net; +using MeterVault.Infrastructure.Options; +using MeterVault.Infrastructure.Update; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace MeterVault.Integration.Tests; + +/// +/// Version comparison behind the update banner. Pure — no database, no network. +/// +/// +/// The failure mode worth guarding is a banner that never clears: it trains the operator to ignore +/// it, and the next real update goes unnoticed. Every case here is one that would produce exactly +/// that if the parse or the ordering were naive. +/// +public sealed class UpdateCheckTests +{ + [Theory] + [InlineData("0.1.0", 0, 1, 0)] + [InlineData("v0.2.0", 0, 2, 0)] + [InlineData(" 1.4.7 ", 1, 4, 7)] + // The shape the running assembly actually reports — MSBuild appends the commit hash. + [InlineData("0.1.0+8fe5f4411b467943c6717cc994ca74d8883d2839", 0, 1, 0)] + // A prerelease compares equal to its release, so an rc tag never nags a released instance. + [InlineData("0.2.0-rc.1", 0, 2, 0)] + public void Parses_the_forms_tags_and_assemblies_actually_use(string text, int major, int minor, int patch) + { + Assert.True(ReleaseVersion.TryParse(text, out var version)); + Assert.Equal(new ReleaseVersion(major, minor, patch), version); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("latest")] + [InlineData("1.0")] // System.Version would accept this and invent a -1 + [InlineData("1.0.0.0")] // ...and this, silently changing the comparison + [InlineData("v1.x.0")] + public void Rejects_anything_it_cannot_order(string? text) + { + Assert.False(ReleaseVersion.TryParse(text, out _)); + } + + [Fact] + public void Orders_numerically_not_lexically() + { + Assert.True(ReleaseVersion.TryParse("0.10.0", out var ten)); + Assert.True(ReleaseVersion.TryParse("0.9.0", out var nine)); + + // "0.10.0" < "0.9.0" as strings — the classic way an update banner goes permanently quiet. + Assert.True(ten > nine); + Assert.False(nine > ten); + } + + [Fact] + public void Picks_the_highest_tag_regardless_of_listing_order() + { + // Gitea does not promise semver ordering, so taking the first entry is not safe. + string?[] tags = ["v0.9.0", "v0.10.0", "v0.2.0"]; + + Assert.True(ReleaseVersion.TryPickLatest(tags, out var latest)); + Assert.Equal(new ReleaseVersion(0, 10, 0), latest!.Value); + } + + [Fact] + public void Ignores_tags_that_are_not_versions() + { + string?[] tags = ["nightly", null, "v0.3.0", "release-candidate", ""]; + + Assert.True(ReleaseVersion.TryPickLatest(tags, out var latest)); + Assert.Equal(new ReleaseVersion(0, 3, 0), latest!.Value); + } + + [Fact] + public void Reports_nothing_when_no_tag_is_a_version() + { + Assert.False(ReleaseVersion.TryPickLatest(["nightly", "main"], out _)); + } + + [Fact] + public void An_update_is_only_available_when_the_published_version_is_strictly_newer() + { + var running = new ReleaseVersion(0, 1, 0); + + Assert.True(new UpdateStatus(running, new ReleaseVersion(0, 2, 0), DateTimeOffset.UtcNow).UpdateAvailable); + Assert.False(new UpdateStatus(running, running, DateTimeOffset.UtcNow).UpdateAvailable); + + // A dev build ahead of the newest tag must not be told to "update" backwards. + Assert.False(new UpdateStatus(new ReleaseVersion(0, 3, 0), running, DateTimeOffset.UtcNow).UpdateAvailable); + } + + [Fact] + public void An_unknown_version_on_either_side_shows_no_banner() + { + // Never guess. A missing version means "cannot tell", which is silence, not a nag. + Assert.False(new UpdateStatus(null, new ReleaseVersion(9, 9, 9), null).UpdateAvailable); + Assert.False(new UpdateStatus(new ReleaseVersion(0, 1, 0), null, null).UpdateAvailable); + } + + [Fact] + public void The_running_assembly_reports_the_version_from_the_VERSION_file() + { + // Guards the MSBuild plumbing: without VERSION stamped into the assembly this is null and + // the banner can never appear, however well the comparison works. Compared against the file + // itself, so reading the host process's version by mistake cannot pass this. + var repoRoot = FindRepoRoot(); + Assert.True(ReleaseVersion.TryParse(File.ReadAllText(Path.Combine(repoRoot, "VERSION")), out var declared)); + + Assert.Equal(declared, UpdateCheckService.RunningVersion()); + } + + [Fact] + public async Task Reads_the_newest_tag_out_of_a_gitea_tag_listing() + { + // The payload shape is the real one, captured from + // /api/v1/repos/FinalFactory/MeterVault/tags — the seam the unit tests above cannot cover. + const string Payload = """ + [ + {"name":"v0.1.0","message":"Release v0.1.0","id":"4a51306"}, + {"name":"v0.3.0","message":"Release v0.3.0","id":"aa11bb2"}, + {"name":"v0.2.0","message":"Release v0.2.0","id":"cc33dd4"} + ] + """; + + var status = await NewService(HttpStatusCode.OK, Payload).GetAsync(); + + Assert.Equal(new ReleaseVersion(0, 3, 0), status.Latest); + Assert.NotNull(status.CheckedAt); + } + + [Fact] + public async Task An_unreachable_repository_reports_no_latest_instead_of_throwing() + { + // A dashboard on an instance with no outbound access must still render. + var status = await NewService(HttpStatusCode.ServiceUnavailable, "nope").GetAsync(); + + Assert.Null(status.Latest); + Assert.False(status.UpdateAvailable); + } + + [Fact] + public async Task Garbage_in_the_response_is_treated_as_cannot_tell() + { + var status = await NewService(HttpStatusCode.OK, "login page").GetAsync(); + + Assert.Null(status.Latest); + Assert.False(status.UpdateAvailable); + } + + [Fact] + public async Task Disabling_the_check_makes_no_request_at_all() + { + var handler = new StubHandler(HttpStatusCode.OK, "[]"); + var service = new UpdateCheckService( + new StubHttpClientFactory(handler), + Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { UpdateCheckEnabled = false }), + NullLogger.Instance); + + await service.GetAsync(); + + Assert.Equal(0, handler.Calls); + } + + private static UpdateCheckService NewService(HttpStatusCode status, string payload) => + new(new StubHttpClientFactory(new StubHandler(status, payload)), + Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions + { + UpdateCheckEnabled = true, + UpdateCheckUrl = "https://example.invalid/tags", + }), + NullLogger.Instance); + + private sealed class StubHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory + { + public HttpClient CreateClient(string name) => new(handler, disposeHandler: false); + } + + private sealed class StubHandler(HttpStatusCode status, string payload) : HttpMessageHandler + { + public int Calls { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + Calls++; + return Task.FromResult(new HttpResponseMessage(status) { Content = new StringContent(payload) }); + } + } + + private static string FindRepoRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "VERSION"))) + { + directory = directory.Parent; + } + + Assert.NotNull(directory); + return directory!.FullName; + } +}