Assets { get; init; } = [];
+}
+
+/// A zip with one folder per component (Agent, Web), each the output of dotnet publish.
+public sealed record ReleaseAsset(string Platform, FileRef File);
+
+public sealed class UpdateException(string message, Exception? inner = null) : Exception(message, inner);
diff --git a/src/SQLInsight.Web/Components/Layout/MainLayout.razor b/src/SQLInsight.Web/Components/Layout/MainLayout.razor
index d59d2c3..978444f 100644
--- a/src/SQLInsight.Web/Components/Layout/MainLayout.razor
+++ b/src/SQLInsight.Web/Components/Layout/MainLayout.razor
@@ -11,6 +11,7 @@
Overvågning
Instanser
Alle fund
+ System
Community
Projekt og forum
diff --git a/src/SQLInsight.Web/Components/Pages/SystemStatus.razor b/src/SQLInsight.Web/Components/Pages/SystemStatus.razor
new file mode 100644
index 0000000..c01ea66
--- /dev/null
+++ b/src/SQLInsight.Web/Components/Pages/SystemStatus.razor
@@ -0,0 +1,150 @@
+@page "/system"
+@using System.Text.Json
+@inject InsightRepository Repository
+@inject Display Display
+
+System · SQLInsight Pro
+
+System
+Version, opdateringer, regelpakke og hvad der sendes til SQLInsight Hub.
+
+
+ Opdateringer
+ @if (_update is null)
+ {
+ Agenten har ikke rapporteret opdateringsstatus endnu. Den tjekker første gang et halvt minut efter start.
+ }
+ else
+ {
+ @if (_update.AvailableVersion is { } available)
+ {
+
+
SQLInsight Pro @available er tilgængelig@(_update.AvailableSecurity ? " og indeholder sikkerhedsrettelser" : "").
+ @if (_update.AvailableNotesUrl is { } notes) {
Se ændringer. }
+
+ @switch (_update.Mode)
+ {
+ case "Automatic":
+ @(_update.StagedVersion is null ? "Den hentes ved næste tjek og installeres af opdateringsopgaven" : "Den er hentet og verificeret, og installeres af opdateringsopgaven")
+ @(" i vedligeholdelsesvinduet.")
+ break;
+ case "Notify":
+ Installér den som administrator på agentens server med SQLInsight.Agent update apply.
+ break;
+ default:
+ Automatiske opdateringer er slået fra.
+ break;
+ }
+
+
+ }
+
+ @if (_update.LastError is { } error)
+ {
+ Seneste tjek fejlede.@error
+ }
+
+
+ | Installeret version | @_update.InstalledVersion |
+ | Opdateringer af programmet | @ModeText(_update.Mode) · kanal @_update.Channel |
+ | Feed | @(_update.Feed ?? "ikke sat op (SQLInsight:Updates:Feed)") |
+ | Seneste tjek | @Display.Time(_update.LastCheckAt) |
+ @if (_update.LastResultOutcome is { } outcome)
+ {
+ | Seneste installation | @Display.Time(_update.LastResultAt): @_update.LastResultMessage |
+ }
+
+ }
+
+
+
+ Regelpakke
+ @if (_update is null)
+ {
+ Ukendt, indtil agenten har rapporteret.
+ }
+ else
+ {
+ Supportdatoer og seneste CU pr. SQL Server-version kommer fra regelpakken. Den opdateres uafhængigt af programmet, så nye CU'er kendes, så snart de er udgivet.
+
+ | Version | @_update.RulePackVersion |
+ | Kilde | @_update.RulePackSource |
+ | Automatisk opdatering | @(_update.RulePacks ? "Til" : "Fra") |
+
+ }
+
+
+
+ SQLInsight Hub og licens
+ @if (_hub is null)
+ {
+ Agenten har ikke rapporteret licensstatus endnu.
+ }
+ else
+ {
+
+ | Deling med SQLInsight Hub | @(_hub.Enabled ? $"Til ({_hub.HubUrl})" : "Fra: intet forlader netværket") |
+ | Licens | @_hub.LicenseMessage |
+ @if (_hub.Customer is { } customer)
+ {
+ | Kunde og aftale | @customer · @_hub.LicensePlan · udløber @Display.Time(_hub.LicenseExpires) |
+ }
+ | Følsomme målinger | @(_hub.SendSensitive ? "Sendes (forespørgselstekst, logins, værtsnavne)" : "Sendes ikke; erstattes af [udeladt]") |
+ | Senest sendt | @Display.Time(_hub.LastSentAt) · @_hub.Pending i kø |
+ @if (_hub.LastError is { } hubError)
+ {
+ | Seneste fejl | @Display.Time(_hub.LastErrorAt): @hubError |
+ }
+
+ }
+
+
+@if (_submissions.Count > 0)
+{
+
+ Det sendes til SQLInsight Hub
+ Præcis det indhold, agenten senest har sendt (eller har i kø) pr. instans.
+ @foreach (var s in _submissions)
+ {
+
+ @s.InstanceName · @(s.SentAt is { } sent ? "sendt " + Display.Time(sent) : "i kø siden " + Display.Time(s.CreatedAt))
+
+
+ }
+
+}
+
+@code {
+ private static readonly JsonSerializerOptions Indented = new() { WriteIndented = true, Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
+
+ private UpdateStatusRow? _update;
+ private HubStatusRow? _hub;
+ private IReadOnlyList<(string InstanceName, DateTime CreatedAt, DateTime? SentAt, string Payload)> _submissions = [];
+
+ protected override async Task OnInitializedAsync()
+ {
+ _update = await Repository.GetUpdateStatusAsync();
+ _hub = await Repository.GetHubStatusAsync();
+ _submissions = await Repository.GetLatestSubmissionsAsync();
+ }
+
+ private static string ModeText(string mode) => mode switch
+ {
+ "Automatic" => "Automatisk i vedligeholdelsesvinduet",
+ "Notify" => "Besked, installeres manuelt",
+ _ => "Fra",
+ };
+
+ private static string Pretty(string json)
+ {
+ try
+ {
+ using var document = JsonDocument.Parse(json);
+ return JsonSerializer.Serialize(document.RootElement, Indented);
+ }
+ catch (JsonException)
+ {
+ return json;
+ }
+ }
+}
diff --git a/src/SQLInsight.Web/wwwroot/app.css b/src/SQLInsight.Web/wwwroot/app.css
index fb8f24b..a716fb8 100644
--- a/src/SQLInsight.Web/wwwroot/app.css
+++ b/src/SQLInsight.Web/wwwroot/app.css
@@ -154,3 +154,6 @@ meter { width: 64px; height: 8px; margin-right: .4rem; vertical-align: middle; }
.page, .topbar { padding-left: 1rem; padding-right: 1rem; }
.grid2 { grid-template-columns: 1fr; }
}
+details { border-top: 1px solid var(--border); padding: .6rem 0; }
+details summary { cursor: pointer; font-weight: 500; }
+details .script pre { max-height: 32rem; }
diff --git a/tests/SQLInsight.IntegrationTests/HubApiTests.cs b/tests/SQLInsight.IntegrationTests/HubApiTests.cs
index afd1412..c2355b1 100644
--- a/tests/SQLInsight.IntegrationTests/HubApiTests.cs
+++ b/tests/SQLInsight.IntegrationTests/HubApiTests.cs
@@ -147,4 +147,24 @@ public class HubApiTests(HubFixture hub) : IClassFixture
Assert.Equal(HttpStatusCode.BadRequest, (await PostAsync(license, new { schemaVersion = 99 })).StatusCode);
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await PostAsync(license, badSeverity)).StatusCode);
}
+
+ [Fact]
+ public async Task Hub_serves_the_update_feed()
+ {
+ var feed = Path.Combine(Path.GetTempPath(), "hubfeed-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(Path.Combine(feed, "stable"));
+ await File.WriteAllTextAsync(Path.Combine(feed, "stable", "manifest.sqli"), "SQLI1.x.y");
+ try
+ {
+ await using var factory = hub.Factory.WithWebHostBuilder(b => b.UseSetting("SQLInsight:Hub:UpdateFeedPath", feed));
+ var client = factory.CreateClient();
+
+ Assert.Equal("SQLI1.x.y", await client.GetStringAsync("/updates/stable/manifest.sqli"));
+ Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/updates/../HubOptions.cs")).StatusCode);
+ }
+ finally
+ {
+ Directory.Delete(feed, recursive: true);
+ }
+ }
}
diff --git a/tests/SQLInsight.IntegrationTests/UpdateIntegrationTests.cs b/tests/SQLInsight.IntegrationTests/UpdateIntegrationTests.cs
new file mode 100644
index 0000000..95eec11
--- /dev/null
+++ b/tests/SQLInsight.IntegrationTests/UpdateIntegrationTests.cs
@@ -0,0 +1,141 @@
+using System.Security.Cryptography;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using SQLInsight.Agent.Updates;
+using SQLInsight.Licensing;
+using SQLInsight.Repository;
+using SQLInsight.Rules.Lifecycle;
+using SQLInsight.Updates;
+
+namespace SQLInsight.IntegrationTests;
+
+///
+/// The agent's update check against a signed feed folder (the offline case), with the status stored in a real
+/// repository and shown in the web UI.
+///
+[Collection(SqlServerTestGroup.Name)]
+[Trait("Category", "Integration")]
+public sealed class UpdateIntegrationTests(SqlServerFixture sql) : IAsyncLifetime, IDisposable
+{
+ private const string KeyId = "itest-updates";
+
+ private readonly string _database = "SQLInsightUpd_" + Guid.NewGuid().ToString("N")[..8];
+ private readonly string _root = Path.Combine(Path.GetTempPath(), "sqlinsight-upd-" + Guid.NewGuid().ToString("N"));
+ private readonly ECDsa _key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
+ private InsightRepository _repository = null!;
+
+ public async Task InitializeAsync()
+ {
+ await sql.ExecuteAsSaAsync($"CREATE DATABASE [{_database}];");
+ _repository = new InsightRepository(sql.DatabaseConnectionString(_database));
+ await _repository.MigrateAsync();
+ }
+
+ public Task DisposeAsync() => sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];");
+
+ public void Dispose()
+ {
+ _key.Dispose();
+ if (Directory.Exists(_root))
+ {
+ Directory.Delete(_root, recursive: true);
+ }
+ }
+
+ private FileRef Publish(string path, byte[] bytes)
+ {
+ var full = Path.Combine(_root, "feed", path);
+ Directory.CreateDirectory(Path.GetDirectoryName(full)!);
+ File.WriteAllBytes(full, bytes);
+ return new FileRef(path, Convert.ToHexString(SHA256.HashData(bytes)), bytes.Length);
+ }
+
+ private static string RepositoryRoot()
+ {
+ for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir is not null; dir = dir.Parent)
+ {
+ if (File.Exists(Path.Combine(dir.FullName, "SQLInsight.slnx")))
+ {
+ return dir.FullName;
+ }
+ }
+
+ throw new InvalidOperationException("Repository root not found.");
+ }
+
+ [Fact]
+ public async Task Rule_pack_is_hot_loaded_release_staged_and_status_shown_in_the_web_ui()
+ {
+ var lifecycle = Path.Combine(RepositoryRoot(), "src", "SQLInsight.Rules", "Lifecycle");
+ var pack = RulePack.Create(new Version(2099, 1, 1), File.ReadAllBytes(Path.Combine(lifecycle, "build-catalog.json")), File.ReadAllBytes(Path.Combine(lifecycle, "support-lifecycle.json")));
+ var manifest = new UpdateManifest
+ {
+ KeyId = KeyId,
+ Channel = "stable",
+ PublishedAt = DateTimeOffset.UtcNow,
+ ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
+ RulePack = new RulePackRef("2099.1.1", Publish("rulepacks/rulepack-2099.1.1.json", pack)),
+ Release = new AppRelease
+ {
+ Version = "99.0.0",
+ Released = new DateOnly(2099, 1, 1),
+ Security = true,
+ Assets = [new ReleaseAsset(Platform.Current, Publish("releases/sqlinsight-99.0.0.zip", [1, 2, 3]))],
+ },
+ };
+ Publish("stable/manifest.sqli", System.Text.Encoding.UTF8.GetBytes(SignedDocument.Sign(manifest, _key)));
+
+ var options = new UpdateOptions { Feed = Path.Combine(_root, "feed"), DataDirectory = Path.Combine(_root, "data"), Application = AppUpdateMode.Automatic };
+ options.TrustedKeys[KeyId] = Convert.ToBase64String(_key.ExportSubjectPublicKeyInfo());
+ var knowledge = RuleKnowledge.Embedded();
+ using var http = new HttpClient();
+ var service = new UpdateService(Options.Create(options), knowledge, _repository, new SingleClient(http), TimeProvider.System, NullLogger.Instance);
+
+ var status = await service.CheckOnceAsync(CancellationToken.None);
+
+ Assert.Null(status.LastError);
+ Assert.Equal(new Version(2099, 1, 1), knowledge.Current.Version);
+ Assert.Equal("99.0.0", status.AvailableVersion);
+ Assert.Equal("99.0.0", status.StagedVersion);
+ Assert.True(File.Exists(Path.Combine(options.Directories.Pending, "package.zip")));
+ var stored = await _repository.GetUpdateStatusAsync();
+ Assert.Equal("2099.1.1", stored!.RulePackVersion);
+ Assert.True(stored.AvailableSecurity);
+
+ // After a restart the verified pack on disk is used again.
+ Assert.Equal(new Version(2099, 1, 1), UpdateService.LoadKnowledge(options, NullLogger.Instance).Current.Version);
+
+ await using var web = new WebApplicationFactory().WithWebHostBuilder(b =>
+ {
+ b.UseEnvironment("Development");
+ b.UseSetting("SQLInsight:Repository:ConnectionString", sql.DatabaseConnectionString(_database));
+ b.UseSetting("SQLInsight:Web:Authentication", "None");
+ });
+ var html = await web.CreateClient().GetStringAsync("/system");
+ Assert.Contains("SQLInsight Pro 99.0.0 er tilgængelig og indeholder sikkerhedsrettelser", html, StringComparison.Ordinal);
+ Assert.Contains("2099.1.1", html, StringComparison.Ordinal);
+ Assert.Contains("hentet og verificeret", html, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task Without_a_feed_the_status_still_reports_version_and_rule_pack()
+ {
+ var options = new UpdateOptions { DataDirectory = Path.Combine(_root, "data") };
+ using var http = new HttpClient();
+ var service = new UpdateService(Options.Create(options), RuleKnowledge.Embedded(), _repository, new SingleClient(http), TimeProvider.System, NullLogger.Instance);
+
+ await service.CheckOnceAsync(CancellationToken.None);
+
+ var stored = await _repository.GetUpdateStatusAsync();
+ Assert.Null(stored!.Feed);
+ Assert.Equal(RulePack.Embedded().Version.ToString(), stored.RulePackVersion);
+ Assert.Equal("indbygget", stored.RulePackSource);
+ }
+
+ private sealed class SingleClient(HttpClient client) : IHttpClientFactory
+ {
+ public HttpClient CreateClient(string name) => client;
+ }
+}
diff --git a/tests/SQLInsight.Updates.Tests/SQLInsight.Updates.Tests.csproj b/tests/SQLInsight.Updates.Tests/SQLInsight.Updates.Tests.csproj
new file mode 100644
index 0000000..c7ce194
--- /dev/null
+++ b/tests/SQLInsight.Updates.Tests/SQLInsight.Updates.Tests.csproj
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/SQLInsight.Updates.Tests/TestFeed.cs b/tests/SQLInsight.Updates.Tests/TestFeed.cs
new file mode 100644
index 0000000..0588adf
--- /dev/null
+++ b/tests/SQLInsight.Updates.Tests/TestFeed.cs
@@ -0,0 +1,144 @@
+using System.IO.Compression;
+using System.Security.Cryptography;
+using SQLInsight.Licensing;
+using SQLInsight.Rules.Lifecycle;
+
+namespace SQLInsight.Updates.Tests;
+
+/// A feed folder signed with a throwaway key, plus a data folder and an installation to update.
+public sealed class TestFeed : IDisposable
+{
+ public const string KeyId = "test";
+
+ private readonly ECDsa _key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
+
+ public TestFeed()
+ {
+ Root = Path.Combine(Path.GetTempPath(), "sqlinsight-updates-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(FeedDirectory);
+ Directories = new UpdateDirectories(Path.Combine(Root, "data"));
+ Keys = new TrustedKeys([KeyValuePair.Create(KeyId, Convert.ToBase64String(_key.ExportSubjectPublicKeyInfo()))]);
+ Feed = new UpdateFeed(FeedDirectory, null);
+ }
+
+ public string Root { get; }
+ public string FeedDirectory => Path.Combine(Root, "feed");
+ public string AgentFolder => Path.Combine(Root, "install", "Agent");
+ public UpdateDirectories Directories { get; }
+ public TrustedKeys Keys { get; }
+ public UpdateFeed Feed { get; }
+
+ public FileRef AddFile(string path, byte[] bytes)
+ {
+ var full = Path.Combine(FeedDirectory, path);
+ Directory.CreateDirectory(Path.GetDirectoryName(full)!);
+ File.WriteAllBytes(full, bytes);
+ return new FileRef(path, Convert.ToHexString(SHA256.HashData(bytes)), bytes.Length);
+ }
+
+ public string Sign(UpdateManifest manifest) => SignedDocument.Sign(manifest, _key);
+
+ public UpdateManifest Publish(string channel = "stable", RulePackRef? rulePack = null, AppRelease? release = null, DateTimeOffset? publishedAt = null, DateTimeOffset? expiresAt = null)
+ {
+ var manifest = new UpdateManifest
+ {
+ KeyId = KeyId,
+ Channel = channel,
+ PublishedAt = publishedAt ?? DateTimeOffset.UtcNow,
+ ExpiresAt = expiresAt ?? DateTimeOffset.UtcNow.AddDays(30),
+ RulePack = rulePack,
+ Release = release,
+ };
+ var path = Path.Combine(FeedDirectory, channel, UpdateFeed.ManifestFile);
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ File.WriteAllText(path, Sign(manifest));
+ return manifest;
+ }
+
+ public RulePackRef RulePack(Version version)
+ {
+ var root = RepositoryRoot();
+ var bytes = Rules.Lifecycle.RulePack.Create(version,
+ File.ReadAllBytes(Path.Combine(root, "src", "SQLInsight.Rules", "Lifecycle", "build-catalog.json")),
+ File.ReadAllBytes(Path.Combine(root, "src", "SQLInsight.Rules", "Lifecycle", "support-lifecycle.json")));
+ return new RulePackRef(version.ToString(), AddFile($"rulepacks/rulepack-{version}.json", bytes));
+ }
+
+ /// A release whose Agent folder holds the given program file content.
+ public AppRelease Release(string version, string program = "v2", string? platform = null)
+ {
+ using var buffer = new MemoryStream();
+ using (var zip = new ZipArchive(buffer, ZipArchiveMode.Create, leaveOpen: true))
+ {
+ Add(zip, "Agent/SQLInsight.Agent.dll", program);
+ Add(zip, "Agent/appsettings.json", "{ \"from\": \"package\" }");
+ Add(zip, "Agent/runtimes/new.dll", "new");
+ Add(zip, "Web/SQLInsight.Web.dll", "web");
+ }
+
+ return new AppRelease
+ {
+ Version = version,
+ Released = new DateOnly(2026, 10, 1),
+ Assets = [new ReleaseAsset(platform ?? Platform.Current, AddFile($"releases/sqlinsight-{version}.zip", buffer.ToArray()))],
+ };
+ }
+
+ public void Install(string program = "v1")
+ {
+ Directory.CreateDirectory(AgentFolder);
+ File.WriteAllText(Path.Combine(AgentFolder, "SQLInsight.Agent.dll"), program);
+ File.WriteAllText(Path.Combine(AgentFolder, "appsettings.json"), "{ \"from\": \"customer\" }");
+ }
+
+ public UpdateChecker Checker(string channel = "stable") => new(Feed, Keys, Directories, channel, TimeProvider.System);
+
+ public void Dispose()
+ {
+ _key.Dispose();
+ if (Directory.Exists(Root))
+ {
+ Directory.Delete(Root, recursive: true);
+ }
+ }
+
+ private static void Add(ZipArchive zip, string name, string content)
+ {
+ using var writer = new StreamWriter(zip.CreateEntry(name).Open());
+ writer.Write(content);
+ }
+
+ private static string RepositoryRoot()
+ {
+ for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir is not null; dir = dir.Parent)
+ {
+ if (File.Exists(Path.Combine(dir.FullName, "SQLInsight.slnx")))
+ {
+ return dir.FullName;
+ }
+ }
+
+ throw new InvalidOperationException("Repository root not found.");
+ }
+}
+
+/// Services that "crash" when the installed program file says so.
+public sealed class FakeServices(string programFile) : IServiceControl
+{
+ public List Calls { get; } = [];
+
+ public Task StopAsync(string service, CancellationToken cancellationToken)
+ {
+ Calls.Add("stop " + service);
+ return Task.CompletedTask;
+ }
+
+ public Task StartAsync(string service, CancellationToken cancellationToken)
+ {
+ Calls.Add("start " + service);
+ return Task.CompletedTask;
+ }
+
+ public Task IsRunningAsync(string service, CancellationToken cancellationToken) =>
+ Task.FromResult(File.ReadAllText(programFile) != "broken");
+}
diff --git a/tests/SQLInsight.Updates.Tests/UpdateTests.cs b/tests/SQLInsight.Updates.Tests/UpdateTests.cs
new file mode 100644
index 0000000..2fee0cd
--- /dev/null
+++ b/tests/SQLInsight.Updates.Tests/UpdateTests.cs
@@ -0,0 +1,259 @@
+using System.Text;
+using SQLInsight.Licensing;
+using SQLInsight.Rules.Lifecycle;
+
+namespace SQLInsight.Updates.Tests;
+
+public sealed class UpdateTests : IDisposable
+{
+ private readonly TestFeed _feed = new();
+
+ public void Dispose() => _feed.Dispose();
+
+ private UpdateApplier Applier(FakeServices services) => new(
+ new UpdateApplierOptions
+ {
+ Channel = "stable",
+ PrivateRoot = Path.Combine(_feed.Root, "private"),
+ Components = [new UpdateComponent { Name = "Agent", Path = _feed.AgentFolder, Service = "SQLInsight Agent" }],
+ HealthCheckDuration = TimeSpan.FromMilliseconds(50),
+ },
+ _feed.Directories, _feed.Keys, services, TimeProvider.System, _ => { });
+
+ private string AgentFile(string name) => File.ReadAllText(Path.Combine(_feed.AgentFolder, name));
+
+ [Fact]
+ public void Manifest_with_valid_signature_is_accepted()
+ {
+ var token = _feed.Sign(new UpdateManifest { KeyId = TestFeed.KeyId, Channel = "stable", PublishedAt = DateTimeOffset.UtcNow, ExpiresAt = DateTimeOffset.UtcNow.AddDays(1) });
+
+ Assert.Equal("stable", ManifestVerifier.Verify(token, _feed.Keys, "stable", DateTimeOffset.UtcNow).Channel);
+ }
+
+ [Fact]
+ public void Tampered_or_foreign_manifests_are_refused()
+ {
+ var now = DateTimeOffset.UtcNow;
+ var manifest = new UpdateManifest { KeyId = TestFeed.KeyId, Channel = "stable", PublishedAt = now, ExpiresAt = now.AddDays(1) };
+ var token = _feed.Sign(manifest);
+ var parts = token.Split('.');
+ var forged = Convert.ToBase64String(Encoding.UTF8.GetBytes("{\"keyId\":\"test\",\"channel\":\"preview\",\"publishedAt\":\"2026-01-01T00:00:00Z\",\"expiresAt\":\"2099-01-01T00:00:00Z\"}"))
+ .TrimEnd('=').Replace('+', '-').Replace('/', '_');
+ using var otherKey = System.Security.Cryptography.ECDsa.Create(System.Security.Cryptography.ECCurve.NamedCurves.nistP256);
+
+ Assert.Throws(() => ManifestVerifier.Verify($"{parts[0]}.{forged}.{parts[2]}", _feed.Keys, "preview", now));
+ Assert.Throws(() => ManifestVerifier.Verify(SignedDocument.Sign(manifest, otherKey), _feed.Keys, "stable", now));
+ Assert.Throws(() => ManifestVerifier.Verify(token, new TrustedKeys([]), "stable", now));
+ }
+
+ [Fact]
+ public void Wrong_channel_expired_and_older_manifests_are_refused()
+ {
+ var now = DateTimeOffset.UtcNow;
+ var token = _feed.Sign(new UpdateManifest { KeyId = TestFeed.KeyId, Channel = "preview", PublishedAt = now.AddDays(-2), ExpiresAt = now.AddDays(-1) });
+ var fresh = _feed.Sign(new UpdateManifest { KeyId = TestFeed.KeyId, Channel = "stable", PublishedAt = now.AddDays(-2), ExpiresAt = now.AddDays(1) });
+
+ Assert.Contains("kanalen", Assert.Throws(() => ManifestVerifier.Verify(token, _feed.Keys, "stable", now)).Message, StringComparison.Ordinal);
+ Assert.Contains("udløb", Assert.Throws(() => ManifestVerifier.Verify(token, _feed.Keys, "preview", now)).Message, StringComparison.Ordinal);
+ Assert.Contains("ældre", Assert.Throws(() => ManifestVerifier.Verify(fresh, _feed.Keys, "stable", now, notBefore: now.AddDays(-1))).Message, StringComparison.Ordinal);
+ }
+
+ [Theory]
+ [InlineData("../secret.zip")]
+ [InlineData("/etc/passwd")]
+ [InlineData("releases\\x.zip")]
+ [InlineData("C:/x.zip")]
+ [InlineData("releases//x.zip")]
+ public void Paths_outside_the_feed_are_refused(string path)
+ {
+ var now = DateTimeOffset.UtcNow;
+ var token = _feed.Sign(new UpdateManifest
+ {
+ KeyId = TestFeed.KeyId, Channel = "stable", PublishedAt = now, ExpiresAt = now.AddDays(1),
+ RulePack = new RulePackRef("2027.1.1", new FileRef(path, "00", 1)),
+ });
+
+ Assert.Throws(() => ManifestVerifier.Verify(token, _feed.Keys, "stable", now));
+ }
+
+ [Theory]
+ [InlineData("1.2.0", "1.10.0")]
+ [InlineData("1.3.0-preview.2", "1.3.0")]
+ [InlineData("1.3.0-preview.2", "1.3.0-preview.10")]
+ [InlineData("1.3.0-beta", "1.3.0-rc")]
+ [InlineData("0.9.9", "1.0.0-preview.1")]
+ public void Versions_are_ordered_like_semver(string lower, string higher)
+ {
+ Assert.True(AppVersion.Parse(lower) < AppVersion.Parse(higher));
+ Assert.True(AppVersion.Parse(higher) > AppVersion.Parse(lower));
+ }
+
+ [Fact]
+ public void Build_metadata_and_short_versions_compare_equal()
+ {
+ Assert.Equal(AppVersion.Parse("1.2"), AppVersion.Parse("1.2.0+4f1c2a9"));
+ Assert.Equal("1.2.0", AppVersion.Parse("1.2+abc").ToString());
+ }
+
+ [Fact]
+ public async Task Newer_rule_pack_is_downloaded_stored_and_reloaded()
+ {
+ var embedded = RulePack.Embedded();
+ var newer = new Version(embedded.Version.Major + 1, 1, 1);
+ _feed.Publish(rulePack: _feed.RulePack(newer));
+ var checker = _feed.Checker();
+
+ var manifest = await checker.FetchAsync(CancellationToken.None);
+ var pack = await checker.UpdateRulePackAsync(manifest, embedded, CancellationToken.None);
+ var again = await checker.UpdateRulePackAsync(manifest, pack!, CancellationToken.None);
+ var reloaded = new RulePackStore(_feed.Directories).Load(_feed.Keys);
+
+ Assert.Equal(newer, pack!.Version);
+ Assert.Null(again);
+ Assert.Equal(newer, reloaded!.Version);
+ Assert.Contains("stable", reloaded.Source, StringComparison.Ordinal);
+ Assert.NotNull(reloaded.Builds.Latest(Core.Instances.SqlServerRelease.SqlServer2022));
+ }
+
+ [Fact]
+ public async Task Rule_pack_changed_on_disk_is_not_loaded()
+ {
+ var newer = new Version(RulePack.Embedded().Version.Major + 1, 1, 1);
+ _feed.Publish(rulePack: _feed.RulePack(newer));
+ var checker = _feed.Checker();
+ await checker.UpdateRulePackAsync(await checker.FetchAsync(CancellationToken.None), RulePack.Embedded(), CancellationToken.None);
+
+ var file = Path.Combine(_feed.Directories.RulePack, "pack.json");
+ File.WriteAllText(file, File.ReadAllText(file).Replace("CU", "XX", StringComparison.Ordinal));
+
+ Assert.Throws(() => new RulePackStore(_feed.Directories).Load(_feed.Keys));
+ }
+
+ [Fact]
+ public async Task Rule_pack_that_does_not_match_the_manifest_hash_is_refused()
+ {
+ var reference = _feed.RulePack(new Version(2099, 1, 1));
+ File.AppendAllText(Path.Combine(_feed.FeedDirectory, reference.File.Path), " ");
+ _feed.Publish(rulePack: reference);
+ var checker = _feed.Checker();
+
+ var manifest = await checker.FetchAsync(CancellationToken.None);
+
+ await Assert.ThrowsAsync(() => checker.UpdateRulePackAsync(manifest, RulePack.Embedded(), CancellationToken.None));
+ Assert.Null(new RulePackStore(_feed.Directories).Load(_feed.Keys));
+ }
+
+ [Fact]
+ public async Task Older_manifest_is_refused_after_a_newer_one_was_seen()
+ {
+ var now = DateTimeOffset.UtcNow;
+ _feed.Publish(publishedAt: now);
+ await _feed.Checker().FetchAsync(CancellationToken.None);
+ _feed.Publish(publishedAt: now.AddHours(-1));
+
+ await Assert.ThrowsAsync(() => _feed.Checker().FetchAsync(CancellationToken.None));
+ }
+
+ [Fact]
+ public void Plain_http_feed_is_refused()
+ {
+ using var http = new HttpClient();
+
+ Assert.Throws(() => new UpdateFeed("http://updates.example.com/", http));
+ }
+
+ [Fact]
+ public async Task Update_is_staged_applied_and_keeps_the_configuration()
+ {
+ _feed.Install();
+ _feed.Publish(release: _feed.Release("1.1.0"));
+ var checker = _feed.Checker();
+ var services = new FakeServices(Path.Combine(_feed.AgentFolder, "SQLInsight.Agent.dll"));
+
+ Assert.True(await checker.StageAsync(await checker.FetchAsync(CancellationToken.None), AppVersion.Parse("1.0.0"), CancellationToken.None));
+ var result = await Applier(services).ApplyAsync(AppVersion.Parse("1.0.0"), CancellationToken.None);
+
+ Assert.Equal(UpdateOutcome.Applied, result.Outcome);
+ Assert.Equal("v2", AgentFile("SQLInsight.Agent.dll"));
+ Assert.Equal("new", AgentFile(Path.Combine("runtimes", "new.dll")));
+ Assert.Contains("customer", AgentFile("appsettings.json"), StringComparison.Ordinal);
+ Assert.Equal(["stop SQLInsight Agent", "start SQLInsight Agent"], services.Calls);
+ Assert.False(Directory.Exists(_feed.Directories.Pending));
+ Assert.Equal(UpdateOutcome.Applied, _feed.Directories.LastResult()!.Outcome);
+ }
+
+ [Fact]
+ public async Task Update_that_does_not_start_is_rolled_back()
+ {
+ _feed.Install();
+ _feed.Publish(release: _feed.Release("1.1.0", program: "broken"));
+ var checker = _feed.Checker();
+ var services = new FakeServices(Path.Combine(_feed.AgentFolder, "SQLInsight.Agent.dll"));
+ await checker.StageAsync(await checker.FetchAsync(CancellationToken.None), AppVersion.Parse("1.0.0"), CancellationToken.None);
+
+ var result = await Applier(services).ApplyAsync(AppVersion.Parse("1.0.0"), CancellationToken.None);
+
+ Assert.Equal(UpdateOutcome.RolledBack, result.Outcome);
+ Assert.Equal("v1", AgentFile("SQLInsight.Agent.dll"));
+ Assert.Contains("customer", AgentFile("appsettings.json"), StringComparison.Ordinal);
+ Assert.Equal(["stop SQLInsight Agent", "start SQLInsight Agent", "stop SQLInsight Agent", "start SQLInsight Agent"], services.Calls);
+ Assert.Equal("v1", File.ReadAllText(Path.Combine(_feed.Root, "private", "backup", "Agent", "SQLInsight.Agent.dll")));
+ }
+
+ [Fact]
+ public async Task Applier_refuses_a_package_changed_after_staging()
+ {
+ _feed.Install();
+ _feed.Publish(release: _feed.Release("1.1.0"));
+ var checker = _feed.Checker();
+ await checker.StageAsync(await checker.FetchAsync(CancellationToken.None), AppVersion.Parse("1.0.0"), CancellationToken.None);
+ File.AppendAllText(Path.Combine(_feed.Directories.Pending, "package.zip"), "x");
+ var services = new FakeServices(Path.Combine(_feed.AgentFolder, "SQLInsight.Agent.dll"));
+
+ var result = await Applier(services).ApplyAsync(AppVersion.Parse("1.0.0"), CancellationToken.None);
+
+ Assert.Equal(UpdateOutcome.Failed, result.Outcome);
+ Assert.Empty(services.Calls);
+ Assert.Equal("v1", AgentFile("SQLInsight.Agent.dll"));
+ }
+
+ [Fact]
+ public async Task Applier_refuses_a_manifest_it_cannot_verify()
+ {
+ _feed.Install();
+ _feed.Publish(release: _feed.Release("1.1.0"));
+ var checker = _feed.Checker();
+ await checker.StageAsync(await checker.FetchAsync(CancellationToken.None), AppVersion.Parse("1.0.0"), CancellationToken.None);
+ var services = new FakeServices(Path.Combine(_feed.AgentFolder, "SQLInsight.Agent.dll"));
+ var applier = new UpdateApplier(
+ new UpdateApplierOptions { Channel = "stable", PrivateRoot = Path.Combine(_feed.Root, "private"), Components = [new UpdateComponent { Name = "Agent", Path = _feed.AgentFolder, Service = "a" }] },
+ _feed.Directories, new TrustedKeys([]), services, TimeProvider.System, _ => { });
+
+ var result = await applier.ApplyAsync(AppVersion.Parse("1.0.0"), CancellationToken.None);
+
+ Assert.Equal(UpdateOutcome.Failed, result.Outcome);
+ Assert.Empty(services.Calls);
+ }
+
+ [Fact]
+ public async Task Nothing_is_staged_or_applied_when_not_newer()
+ {
+ _feed.Install();
+ _feed.Publish(release: _feed.Release("1.0.0"));
+ var checker = _feed.Checker();
+
+ Assert.False(await checker.StageAsync(await checker.FetchAsync(CancellationToken.None), AppVersion.Parse("1.0.0"), CancellationToken.None));
+ Assert.Equal(UpdateOutcome.NothingToDo, (await Applier(new FakeServices(Path.Combine(_feed.AgentFolder, "SQLInsight.Agent.dll"))).ApplyAsync(AppVersion.Parse("1.0.0"), CancellationToken.None)).Outcome);
+ }
+
+ [Fact]
+ public async Task Release_without_a_package_for_this_platform_is_reported()
+ {
+ _feed.Publish(release: _feed.Release("1.1.0", platform: "plan9-mips"));
+ var checker = _feed.Checker();
+
+ var error = await Assert.ThrowsAsync(async () => await checker.StageAsync(await checker.FetchAsync(CancellationToken.None), AppVersion.Parse("1.0.0"), CancellationToken.None));
+
+ Assert.Contains(Platform.Current, error.Message, StringComparison.Ordinal);
+ }
+}
diff --git a/tools/SQLInsight.LicenseTool/Program.cs b/tools/SQLInsight.LicenseTool/Program.cs
index 7e09035..ed0ab7a 100644
--- a/tools/SQLInsight.LicenseTool/Program.cs
+++ b/tools/SQLInsight.LicenseTool/Program.cs
@@ -1,13 +1,19 @@
-// Vendor tool: generate a signing key pair, issue licenses, verify them. Runs on the vendor's machine only.
+// Vendor tool: generate a signing key pair, issue licenses, build rule packs and publish update feeds.
+// Runs on the vendor's machine only; the private key never leaves it.
using System.Globalization;
using System.Security.Cryptography;
+using System.Text;
using SQLInsight.Licensing;
+using SQLInsight.Rules.Lifecycle;
+using SQLInsight.Updates;
return args.FirstOrDefault() switch
{
"keygen" => KeyGen(Option("--out") ?? "."),
"issue" => Issue(),
"verify" => Verify(),
+ "rulepack" => BuildRulePack(),
+ "publish" => Publish(),
_ => Usage(),
};
@@ -19,6 +25,14 @@ int Usage()
issue --key --kid --customer --plan
--expires [--instances ] Udsted licens
verify --public --kid Verificér licens
+ rulepack --version --out
+ [--catalog build-catalog.json] [--lifecycle support-lifecycle.json]
+ Byg regelpakke af katalogfilerne
+ publish --key --kid --feed --channel [--days 90]
+ [--rulepack ]
+ [--release --asset win-x64= [--asset linux-x64=] [--notes ] [--security]]
+ Signér kanalens manifest; det, der ikke angives,
+ overtages fra det eksisterende manifest
""");
return 1;
}
@@ -63,6 +77,114 @@ int Verify()
return status.State is LicenseState.Valid ? 0 : 2;
}
+int BuildRulePack()
+{
+ var root = FindRepositoryRoot();
+ var catalog = Option("--catalog") ?? Path.Combine(root, "src", "SQLInsight.Rules", "Lifecycle", "build-catalog.json");
+ var lifecycle = Option("--lifecycle") ?? Path.Combine(root, "src", "SQLInsight.Rules", "Lifecycle", "support-lifecycle.json");
+ var pack = RulePack.Create(Version.Parse(Required("--version")), File.ReadAllBytes(catalog), File.ReadAllBytes(lifecycle));
+ File.WriteAllBytes(Required("--out"), pack);
+ Console.WriteLine($"Regelpakke {Required("--version")} skrevet til {Required("--out")}");
+ return 0;
+}
+
+int Publish()
+{
+ using var key = ECDsa.Create();
+ key.ImportFromPem(File.ReadAllText(Required("--key")));
+ var kid = Required("--kid");
+ var feed = Required("--feed");
+ var channel = Required("--channel");
+ if (channel is not (UpdateManifest.StableChannel or UpdateManifest.PreviewChannel))
+ {
+ Console.Error.WriteLine("--channel skal være stable eller preview.");
+ return 1;
+ }
+
+ var manifestPath = Path.Combine(feed, channel, UpdateFeed.ManifestFile);
+ var keys = new TrustedKeys([KeyValuePair.Create(kid, Convert.ToBase64String(key.ExportSubjectPublicKeyInfo()))]);
+ var previous = File.Exists(manifestPath) ? SignedDocument.Verify(File.ReadAllText(manifestPath), keys, m => m.KeyId) : null;
+
+ var rulePack = previous?.RulePack;
+ if (Option("--rulepack") is { } packPath)
+ {
+ var bytes = File.ReadAllBytes(packPath);
+ var pack = RulePack.Parse(bytes, packPath);
+ rulePack = new RulePackRef(pack.Version.ToString(), AddFile(feed, "rulepacks", $"rulepack-{pack.Version}.json", bytes));
+ }
+
+ var release = previous?.Release;
+ if (Option("--release") is { } version)
+ {
+ AppVersion.Parse(version);
+ var assets = new List();
+ for (var i = 0; i < args.Length - 1; i++)
+ {
+ if (args[i] == "--asset" && args[i + 1].Split('=', 2) is [var platform, var file])
+ {
+ assets.Add(new ReleaseAsset(platform, AddFile(feed, "releases", $"sqlinsight-{version}-{platform}.zip", File.ReadAllBytes(file))));
+ }
+ }
+
+ if (assets.Count == 0)
+ {
+ Console.Error.WriteLine("--release kræver mindst én --asset =.");
+ return 1;
+ }
+
+ release = new AppRelease
+ {
+ Version = version,
+ Released = DateOnly.FromDateTime(DateTime.UtcNow),
+ Security = args.Contains("--security"),
+ NotesUrl = Option("--notes"),
+ Assets = assets,
+ };
+ }
+
+ var now = DateTimeOffset.UtcNow;
+ var manifest = new UpdateManifest
+ {
+ KeyId = kid,
+ Channel = channel,
+ PublishedAt = now,
+ ExpiresAt = now.AddDays(int.Parse(Option("--days") ?? "90", CultureInfo.InvariantCulture)),
+ RulePack = rulePack,
+ Release = release,
+ };
+ Directory.CreateDirectory(Path.GetDirectoryName(manifestPath)!);
+ File.WriteAllText(manifestPath, SignedDocument.Sign(manifest, key));
+ Console.WriteLine($"{manifestPath}: regelpakke {rulePack?.Version ?? "-"}, version {release?.Version ?? "-"}, udløber {manifest.ExpiresAt:yyyy-MM-dd}");
+ return 0;
+}
+
+static FileRef AddFile(string feed, string folder, string name, byte[] bytes)
+{
+ var relative = $"{folder}/{name}";
+ var path = Path.Combine(feed, folder, name);
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ if (File.Exists(path) && !File.ReadAllBytes(path).AsSpan().SequenceEqual(bytes))
+ {
+ throw new InvalidOperationException($"{relative} findes allerede med andet indhold. Udgivne filer ændres aldrig; brug et nyt versionsnummer.");
+ }
+
+ File.WriteAllBytes(path, bytes);
+ return new FileRef(relative, Convert.ToHexString(SHA256.HashData(bytes)), bytes.LongLength);
+}
+
+static string FindRepositoryRoot()
+{
+ for (var dir = new DirectoryInfo(Environment.CurrentDirectory); dir is not null; dir = dir.Parent)
+ {
+ if (File.Exists(Path.Combine(dir.FullName, "SQLInsight.slnx")))
+ {
+ return dir.FullName;
+ }
+ }
+
+ throw new InvalidOperationException("Kør fra repositoriet, eller angiv --catalog og --lifecycle.");
+}
+
string? Option(string name)
{
var i = Array.IndexOf(args, name);
diff --git a/tools/SQLInsight.LicenseTool/SQLInsight.LicenseTool.csproj b/tools/SQLInsight.LicenseTool/SQLInsight.LicenseTool.csproj
index 9dcdb32..1dbf29f 100644
--- a/tools/SQLInsight.LicenseTool/SQLInsight.LicenseTool.csproj
+++ b/tools/SQLInsight.LicenseTool/SQLInsight.LicenseTool.csproj
@@ -5,5 +5,6 @@