From 3121b598f9daff03f07ed610ac8e702f89ecf467 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 09:19:41 +0000 Subject: [PATCH] Fase 3: signed updates of rule packs and the application Rule packs (support dates and newest CU per release) are published in a signed feed and hot-loaded without a restart; the verified pack is reloaded at startup. New versions are announced (Notify) or downloaded and staged by the unprivileged agent (Automatic), and installed by a privileged apply-update task that re-verifies signature and hash in an admin-only folder, keeps appsettings/web.config, health-checks the services and rolls back on failure. Stable and preview channels, manifest expiry and rollback protection, https or folder/share feeds for offline sites. Vendor tool gains rulepack and publish; the Hub can serve the feed; the web UI gets a System page with update, rule pack, license and exactly what is sent to the Hub. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK --- Directory.Build.props | 2 + Directory.Packages.props | 1 + SQLInsight.slnx | 2 + docs/installation.md | 2 +- docs/opdatering.md | 137 +++++++++ src/SQLInsight.Agent/Program.cs | 16 +- src/SQLInsight.Agent/SQLInsight.Agent.csproj | 1 + .../Updates/UpdateCommands.cs | 226 +++++++++++++++ src/SQLInsight.Agent/Updates/UpdateOptions.cs | 89 ++++++ src/SQLInsight.Agent/Updates/UpdateService.cs | 141 +++++++++ src/SQLInsight.Hub/HubOptions.cs | 6 + src/SQLInsight.Hub/Program.cs | 14 + .../InsightRepository.Updates.cs | 52 ++++ .../Migrations/0003_update_status.sql | 22 ++ src/SQLInsight.Rules/BuiltInRules.cs | 121 ++++---- .../Lifecycle/BuildCatalog.cs | 26 +- .../Lifecycle/PatchLevelRule.cs | 15 +- src/SQLInsight.Rules/Lifecycle/RulePack.cs | 108 +++++++ .../Lifecycle/SupportLifecycleCatalog.cs | 24 +- .../Lifecycle/SupportLifecycleRule.cs | 9 +- src/SQLInsight.Updates/AppVersion.cs | 90 ++++++ src/SQLInsight.Updates/ManifestVerifier.cs | 77 +++++ src/SQLInsight.Updates/RulePackStore.cs | 45 +++ .../SQLInsight.Updates.csproj | 12 + src/SQLInsight.Updates/ServiceControl.cs | 83 ++++++ src/SQLInsight.Updates/UpdateApplier.cs | 269 ++++++++++++++++++ src/SQLInsight.Updates/UpdateChecker.cs | 92 ++++++ src/SQLInsight.Updates/UpdateDirectories.cs | 79 +++++ src/SQLInsight.Updates/UpdateFeed.cs | 151 ++++++++++ src/SQLInsight.Updates/UpdateManifest.cs | 45 +++ .../Components/Layout/MainLayout.razor | 1 + .../Components/Pages/SystemStatus.razor | 150 ++++++++++ src/SQLInsight.Web/wwwroot/app.css | 3 + .../HubApiTests.cs | 20 ++ .../UpdateIntegrationTests.cs | 141 +++++++++ .../SQLInsight.Updates.Tests.csproj | 14 + tests/SQLInsight.Updates.Tests/TestFeed.cs | 144 ++++++++++ tests/SQLInsight.Updates.Tests/UpdateTests.cs | 259 +++++++++++++++++ tools/SQLInsight.LicenseTool/Program.cs | 124 +++++++- .../SQLInsight.LicenseTool.csproj | 1 + 40 files changed, 2739 insertions(+), 75 deletions(-) create mode 100644 docs/opdatering.md create mode 100644 src/SQLInsight.Agent/Updates/UpdateCommands.cs create mode 100644 src/SQLInsight.Agent/Updates/UpdateOptions.cs create mode 100644 src/SQLInsight.Agent/Updates/UpdateService.cs create mode 100644 src/SQLInsight.Repository/InsightRepository.Updates.cs create mode 100644 src/SQLInsight.Repository/Migrations/0003_update_status.sql create mode 100644 src/SQLInsight.Rules/Lifecycle/RulePack.cs create mode 100644 src/SQLInsight.Updates/AppVersion.cs create mode 100644 src/SQLInsight.Updates/ManifestVerifier.cs create mode 100644 src/SQLInsight.Updates/RulePackStore.cs create mode 100644 src/SQLInsight.Updates/SQLInsight.Updates.csproj create mode 100644 src/SQLInsight.Updates/ServiceControl.cs create mode 100644 src/SQLInsight.Updates/UpdateApplier.cs create mode 100644 src/SQLInsight.Updates/UpdateChecker.cs create mode 100644 src/SQLInsight.Updates/UpdateDirectories.cs create mode 100644 src/SQLInsight.Updates/UpdateFeed.cs create mode 100644 src/SQLInsight.Updates/UpdateManifest.cs create mode 100644 src/SQLInsight.Web/Components/Pages/SystemStatus.razor create mode 100644 tests/SQLInsight.IntegrationTests/UpdateIntegrationTests.cs create mode 100644 tests/SQLInsight.Updates.Tests/SQLInsight.Updates.Tests.csproj create mode 100644 tests/SQLInsight.Updates.Tests/TestFeed.cs create mode 100644 tests/SQLInsight.Updates.Tests/UpdateTests.cs diff --git a/Directory.Build.props b/Directory.Build.props index e1fec25..ebc3a09 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -13,6 +13,8 @@ SEOSoft SQLInsight Pro Copyright (c) SEOSoft + + 1.0.0 diff --git a/Directory.Packages.props b/Directory.Packages.props index 2fc5a8d..8628728 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -19,6 +19,7 @@ + diff --git a/SQLInsight.slnx b/SQLInsight.slnx index 02b0e57..c7edcfc 100644 --- a/SQLInsight.slnx +++ b/SQLInsight.slnx @@ -7,6 +7,7 @@ + @@ -21,6 +22,7 @@ + diff --git a/docs/installation.md b/docs/installation.md index 6709e8c..460c193 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -129,4 +129,4 @@ Begge programmer understøtter systemd (`Type=notify`). Windows-login på Linux ## Opdatering -Stop tjenesten (eller app poolen), kopiér de nye filer ind uden at overskrive `appsettings.json`, og start igen. Agenten opgraderer repository-skemaet selv. +Se [opdatering.md](opdatering.md): regelpakken opdateres automatisk, og nye versioner kan installeres med `SQLInsight.Agent update apply` eller automatisk af en planlagt opgave. Manuelt: stop tjenesten (eller app poolen), kopiér de nye filer ind uden at overskrive `appsettings.json`, og start igen. Agenten opgraderer repository-skemaet selv. diff --git a/docs/opdatering.md b/docs/opdatering.md new file mode 100644 index 0000000..7aa0772 --- /dev/null +++ b/docs/opdatering.md @@ -0,0 +1,137 @@ +# Opdateringer + +SQLInsight Pro holder sig selv opdateret på to måder: + +| Hvad | Indhold | Hvordan | +|---|---|---| +| **Regelpakken** | Microsofts supportdatoer og seneste CU/sikkerhedsopdatering pr. SQL Server-version | Hentes og tages i brug automatisk, uden genstart. Det er data, ikke kode. | +| **Programmet** | Agent og webbrugerflade | Tilstanden `Off`, `Notify` eller `Automatic`. Installeres af en privilegeret opdateringsopgave med backup og automatisk tilbagerulning. | + +Begge kommer fra et **opdateringsfeed**: en https-adresse eller en mappe/et fildrev. Feedet behøver ikke at være betroet. Alt i det er signeret af SEOSoft (ECDSA P-256), og hver fil er låst af sin SHA-256 i det signerede manifest. + +## Opsætning + +I agentens `appsettings.json`: + +```json +"SQLInsight": { + "Updates": { + "Feed": "https://hub.example.com/updates/", + "Channel": "stable", + "RulePacks": true, + "Application": "Notify", + "CheckInterval": "06:00:00", + "MaintenanceWindow": "02:00-05:00" + } +} +``` + +| Indstilling | Betydning | +|---|---| +| `Feed` | https-adresse, lokal mappe eller UNC-sti (`\\filserver\sqlinsight-feed`). Tom: ingen opdateringstjek. | +| `Channel` | `stable` (anbefalet i produktion) eller `preview` (nye regler og funktioner før alle andre). | +| `RulePacks` | Hent nyere regelpakker automatisk. Standard: til. | +| `Application` | `Off`: aldrig. `Notify`: vis i webbrugerfladen, at en ny version findes; en administrator installerer. `Automatic`: agenten henter og verificerer; opdateringsopgaven installerer i vedligeholdelsesvinduet. | +| `MaintenanceWindow` | Lokal tid for automatisk installation, fx `02:00-05:00`. Tom: når som helst. | +| `Components` | Hvad der opdateres. Standard: agenten selv. Se [Web på samme server](#web-på-samme-server). | +| `DataDirectory` | Agentens datamappe. Standard `%ProgramData%\SQLInsight` eller `/var/lib/sqlinsight`. | + +Status for opdateringer, regelpakke og licens ses under **System** i webbrugerfladen. + +## Installation af en ny version + +### Manuelt (`Notify`) + +Som administrator på agentens server: + +```powershell +& "C:\Program Files\SQLInsight\Agent\SQLInsight.Agent.exe" update check +& "C:\Program Files\SQLInsight\Agent\SQLInsight.Agent.exe" update apply +``` + +`update apply` henter pakken, verificerer den, stopper tjenesten, tager backup, kopierer de nye filer ind (uden at røre `appsettings*.json` og `web.config`), starter tjenesten og holder øje med den i et minut. Stopper den, rulles der automatisk tilbage. + +### Automatisk (`Automatic`) + +Agenten kører som en konto med få rettigheder og kan ikke selv udskifte programfiler. Det gør en planlagt opgave, der kører som SYSTEM/root, og som selv verificerer signatur og hash igen, før noget installeres. + +Windows: + +```powershell +schtasks /Create /TN "SQLInsight Update" /SC HOURLY /RU SYSTEM /RL HIGHEST ` + /TR "\"C:\Program Files\SQLInsight\Agent\SQLInsight.Agent.exe\" apply-update" +``` + +Linux (`/etc/systemd/system/sqlinsight-update.service` og `.timer`): + +```ini +[Unit] +Description=SQLInsight Pro: installér hentede opdateringer + +[Service] +Type=oneshot +ExecStart=/opt/sqlinsight/agent/SQLInsight.Agent apply-update +``` + +```ini +[Unit] +Description=SQLInsight Pro: kig efter hentede opdateringer hver time + +[Timer] +OnCalendar=hourly +RandomizedDelaySec=10m + +[Install] +WantedBy=timers.target +``` + +```bash +sudo systemctl enable --now sqlinsight-update.timer +``` + +Agentens systemd-enhed skal have `StateDirectory=sqlinsight`, så den kan skrive i `/var/lib/sqlinsight`. + +### Web på samme server + +Skal webbrugerfladen opdateres sammen med agenten, angives begge komponenter: + +```json +"Components": [ + { "Name": "Agent", "Path": "C:\\Program Files\\SQLInsight\\Agent", "Service": "SQLInsight Agent" }, + { "Name": "Web", "Path": "C:\\Program Files\\SQLInsight\\Web", "Service": "SQLInsight Web" } +] +``` + +Kører web i IIS, angives `"AppOffline": true` i stedet for `Service`. Så lægges `app_offline.htm` i mappen, mens filerne udskiftes. + +## Uden internetadgang + +`Feed` kan pege på en mappe eller et fildrev. Kopiér feed-mappen fra SEOSoft (eller fra en server med internetadgang) derhen. Signaturen kontrolleres på samme måde. Et manifest udløber efter 90 dage, så et gammelt feed ikke kan genbruges i det uendelige. Hent et nyt, inden det udløber. + +## Sikkerhed + +- Kun manifester signeret med en nøgle i `trusted-keys.json` (indbygget) eller `SQLInsight:Updates:TrustedKeys` accepteres. +- Hver fil (regelpakke og programpakke) er låst af størrelse og SHA-256 i manifestet. En ændret fil bliver aldrig brugt. +- Ingen nedgradering. Et manifest, der er ældre end et allerede set, afvises, og en version, der ikke er nyere end den installerede, installeres ikke. +- Opdateringsopgaven stoler ikke på agentens datamappe. Den kopierer pakken til sin egen mappe (`.sqlinsight-update` ved siden af installationen, kun skrivbar for administratorer), verificerer den der og installerer derfra. +- Den gemte regelpakke verificeres igen ved hver start. Fejler den, bruges den indbyggede. +- Den seneste backup ligger i `.sqlinsight-update\work\backup` indtil næste opdatering. Log: `.sqlinsight-update\update.log`. + +## For SEOSoft: udgiv en opdatering + +```bash +# Regelpakke ud fra katalogfilerne i src/SQLInsight.Rules/Lifecycle +dotnet run --project tools/SQLInsight.LicenseTool -- rulepack --version 2026.10.14 --out rulepack-2026.10.14.json + +# Programpakker: én zip pr. platform med mapperne Agent/ og Web/ +dotnet publish src/SQLInsight.Agent -c Release -r win-x64 --self-contained false -p:Version=1.1.0 -o pkg/win-x64/Agent +dotnet publish src/SQLInsight.Web -c Release -r win-x64 --self-contained false -p:Version=1.1.0 -o pkg/win-x64/Web + +# Signér kanalens manifest (det, der ikke angives, overtages fra det eksisterende manifest) +dotnet run --project tools/SQLInsight.LicenseTool -- publish --key sqlinsight-k20260929.private.pem --kid k20260929 \ + --feed feed --channel stable --rulepack rulepack-2026.10.14.json \ + --release 1.1.0 --asset win-x64=sqlinsight-1.1.0-win-x64.zip --asset linux-x64=sqlinsight-1.1.0-linux-x64.zip \ + --notes https://git.seosoft.dk/seo/sqlinsight-pro/releases +``` + +Udgiv først på `preview`, og flyt til `stable`, når versionen har kørt et stykke tid. Feed-mappen kan serveres af SQLInsight Hub (`SQLInsight:Hub:UpdateFeedPath`, adressen bliver `https://hub…/updates/`) eller af enhver webserver. diff --git a/src/SQLInsight.Agent/Program.cs b/src/SQLInsight.Agent/Program.cs index dc6adea..5ae3ab8 100644 --- a/src/SQLInsight.Agent/Program.cs +++ b/src/SQLInsight.Agent/Program.cs @@ -1,12 +1,18 @@ using Microsoft.Extensions.Options; using SQLInsight.Agent; using SQLInsight.Agent.Monitoring; +using SQLInsight.Agent.Updates; using SQLInsight.Connectivity; using SQLInsight.Notifications; using SQLInsight.Repository; using SQLInsight.Rules; using SQLInsight.Rules.Engine; +if (UpdateCommands.Handles(args)) +{ + return await UpdateCommands.RunAsync(args); +} + var builder = Host.CreateApplicationBuilder(args); builder.Services.AddWindowsService(o => o.ServiceName = "SQLInsight Agent"); @@ -15,7 +21,10 @@ builder.Services.AddSystemd(); builder.Services.Configure(builder.Configuration.GetSection(AgentOptions.SectionName)); builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddSingleton(); -builder.Services.AddSingleton(new RuleEngine(BuiltInRules.Create())); +builder.Services.Configure(builder.Configuration.GetSection(UpdateOptions.SectionName)); +builder.Services.AddSingleton(sp => UpdateService.LoadKnowledge( + sp.GetRequiredService>().Value, sp.GetRequiredService().CreateLogger())); +builder.Services.AddSingleton(sp => new RuleEngine(BuiltInRules.Create(sp.GetRequiredService()))); builder.Services.AddSingleton(sp => { var connectionString = sp.GetRequiredService>().Value.Repository.ConnectionString; @@ -55,7 +64,10 @@ builder.Services.Configure(builder.Configuratio builder.Services.AddHttpClient("hub", c => c.Timeout = TimeSpan.FromSeconds(30)); builder.Services.AddSingleton(); builder.Services.AddHostedService(); +builder.Services.AddHttpClient("updates", c => c.Timeout = TimeSpan.FromMinutes(10)); +builder.Services.AddHostedService(); builder.Services.AddHostedService(); var host = builder.Build(); -host.Run(); +await host.RunAsync(); +return 0; diff --git a/src/SQLInsight.Agent/SQLInsight.Agent.csproj b/src/SQLInsight.Agent/SQLInsight.Agent.csproj index 9276c54..7fb2300 100644 --- a/src/SQLInsight.Agent/SQLInsight.Agent.csproj +++ b/src/SQLInsight.Agent/SQLInsight.Agent.csproj @@ -12,6 +12,7 @@ + diff --git a/src/SQLInsight.Agent/Updates/UpdateCommands.cs b/src/SQLInsight.Agent/Updates/UpdateCommands.cs new file mode 100644 index 0000000..2d1e7fc --- /dev/null +++ b/src/SQLInsight.Agent/Updates/UpdateCommands.cs @@ -0,0 +1,226 @@ +using System.Diagnostics; +using System.Globalization; +using SQLInsight.Rules.Lifecycle; +using SQLInsight.Updates; + +namespace SQLInsight.Agent.Updates; + +/// +/// Command line: +/// +/// update check: show what the feed offers. +/// update apply [--feed <url|mappe>]: download and install the newest version now (administrator). +/// apply-update: for the scheduled update task; installs a staged version when the mode is Automatic and +/// the maintenance window is open. +/// +/// Installing runs from a copy of the program in the updater's folder, so no file in the installation is in use. +/// +public static class UpdateCommands +{ + private const string RunnerFlag = "--runner"; + + public static bool Handles(string[] args) => args.FirstOrDefault() is "update" or "apply-update"; + + public static async Task RunAsync(string[] args) + { + ArgumentNullException.ThrowIfNull(args); + var agentFolder = Option(args, "--install-dir") ?? AppContext.BaseDirectory; + var settings = LoadOptions(agentFolder); + if (Option(args, "--feed") is { } feed) + { + settings.Feed = feed; + } + + using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(10) }; + try + { + return (args.ElementAtOrDefault(0), args.ElementAtOrDefault(1)) switch + { + ("update", "check") => await CheckAsync(settings, http).ConfigureAwait(false), + ("update", "apply") => await ApplyNowAsync(args, settings, agentFolder, http).ConfigureAwait(false), + ("apply-update", _) when args.Contains(RunnerFlag) => await RunApplierAsync(args, settings, agentFolder).ConfigureAwait(false), + ("apply-update", _) => ScheduledApply(args, settings, agentFolder), + _ => Usage(), + }; + } + catch (Exception ex) when (ex is UpdateException or IOException or UnauthorizedAccessException or FormatException) + { + Console.Error.WriteLine(ex.Message); + return 2; + } + } + + private static async Task CheckAsync(UpdateOptions settings, HttpClient http) + { + var checker = Checker(settings, http); + var manifest = (await checker.FetchAsync(CancellationToken.None).ConfigureAwait(false)).Manifest; + var installed = AppVersion.Running; + var pack = RulePack.Embedded(); + Console.WriteLine($"Feed: {settings.Feed} ({settings.Channel}, udgivet {manifest.PublishedAt:yyyy-MM-dd HH:mm} UTC)"); + Console.WriteLine($"Installeret: {installed}"); + Console.WriteLine($"Tilgængelig: {manifest.Release?.Version ?? "-"}{(UpdateChecker.NewerRelease(manifest, installed) is { Security: true } ? " (sikkerhedsopdatering)" : string.Empty)}"); + Console.WriteLine($"Regelpakke: {manifest.RulePack?.Version ?? "-"} (indbygget {pack.Version})"); + return 0; + } + + /// Administrator: download and install now, regardless of mode and maintenance window. + private static async Task ApplyNowAsync(string[] args, UpdateOptions settings, string agentFolder, HttpClient http) + { + var checker = Checker(settings, http); + var manifest = await checker.FetchAsync(CancellationToken.None).ConfigureAwait(false); + if (!await checker.StageAsync(manifest, AppVersion.Running, CancellationToken.None).ConfigureAwait(false)) + { + Console.WriteLine($"Version {AppVersion.Running} er den nyeste på {settings.Channel}-kanalen."); + return 0; + } + + Console.WriteLine($"Version {manifest.Manifest.Release!.Version} er hentet og verificeret."); + return LaunchRunner(args, settings, agentFolder); + } + + /// Scheduled task: only in Automatic mode, only in the maintenance window, only if something is staged. + private static int ScheduledApply(string[] args, UpdateOptions settings, string agentFolder) + { + if (settings.Application != AppUpdateMode.Automatic) + { + Console.WriteLine($"Automatisk installation er slået fra (Application = {settings.Application})."); + return 0; + } + + if (!settings.InMaintenanceWindow(TimeOnly.FromDateTime(DateTime.Now))) + { + Console.WriteLine($"Uden for vedligeholdelsesvinduet {settings.MaintenanceWindow}."); + return 0; + } + + if (!File.Exists(Path.Combine(settings.Directories.Pending, UpdateFeed.ManifestFile))) + { + Console.WriteLine("Ingen opdatering venter."); + return 0; + } + + return LaunchRunner(args, settings, agentFolder); + } + + /// + /// Copies the program to the updater's folder and runs apply-update --runner from there. On Windows the + /// installation's files are locked while this process runs, so it does not wait; on Linux it waits, so a systemd + /// oneshot unit does not kill the updater when it exits. + /// + private static int LaunchRunner(string[] args, UpdateOptions settings, string agentFolder) + { + var runner = Path.Combine(settings.UpdaterRoot(agentFolder), "runner"); + if (Directory.Exists(runner)) + { + Directory.Delete(runner, recursive: true); + } + + foreach (var file in Directory.EnumerateFiles(agentFolder, "*", SearchOption.AllDirectories)) + { + var target = Path.Combine(runner, Path.GetRelativePath(agentFolder, file)); + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + File.Copy(file, target); + } + + var host = Environment.ProcessPath ?? throw new InvalidOperationException("Programmets sti er ukendt."); + var viaDotnet = Path.GetFileNameWithoutExtension(host).Equals("dotnet", StringComparison.OrdinalIgnoreCase); + var start = new ProcessStartInfo(viaDotnet ? host : Path.Combine(runner, Path.GetFileName(host))) { UseShellExecute = false }; + if (viaDotnet) + { + start.ArgumentList.Add(Path.Combine(runner, "SQLInsight.Agent.dll")); + } + + string[] runnerArgs = ["apply-update", RunnerFlag, "--install-dir", agentFolder, "--wait-pid", Environment.ProcessId.ToString(CultureInfo.InvariantCulture)]; + foreach (var arg in runnerArgs.Concat(Option(args, "--feed") is { } feed ? ["--feed", feed] : [])) + { + start.ArgumentList.Add(arg); + } + + using var process = Process.Start(start) ?? throw new UpdateException("Opdateringen kunne ikke startes."); + if (OperatingSystem.IsWindows()) + { + Console.WriteLine($"Opdateringen kører nu i baggrunden. Resultatet skrives i {settings.Directories.ResultFile} og vises i webbrugerfladen."); + return 0; + } + + process.WaitForExit(); + return process.ExitCode; + } + + private static async Task RunApplierAsync(string[] args, UpdateOptions settings, string agentFolder) + { + // On Windows the process that started us holds the installation's files open until it exits. + if (OperatingSystem.IsWindows() && int.TryParse(Option(args, "--wait-pid"), NumberStyles.None, CultureInfo.InvariantCulture, out var parent)) + { + try + { + using var launcher = Process.GetProcessById(parent); + using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(1)); + await launcher.WaitForExitAsync(timeout.Token).ConfigureAwait(false); + } + catch (Exception ex) when (ex is ArgumentException or OperationCanceledException) + { + // Already gone, or still running after a minute: go ahead, a locked file triggers a rollback. + } + } + + var root = settings.UpdaterRoot(agentFolder); + var logFile = Path.Combine(root, "update.log"); + void Log(string line) + { + var stamped = $"{DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss} {line}"; + Console.WriteLine(stamped); + File.AppendAllText(logFile, stamped + Environment.NewLine); + } + + var applier = new UpdateApplier( + new UpdateApplierOptions + { + Channel = settings.Channel, + PrivateRoot = Path.Combine(root, "work"), + Components = settings.ComponentsFor(agentFolder), + HealthCheckDuration = settings.HealthCheckDuration, + }, + settings.Directories, settings.Keys, ServiceControl.ForCurrentOs(), TimeProvider.System, Log); + var result = await applier.ApplyAsync(AppVersion.Running, CancellationToken.None).ConfigureAwait(false); + return result.Outcome is UpdateOutcome.Applied or UpdateOutcome.NothingToDo ? 0 : 2; + } + + private static UpdateChecker Checker(UpdateOptions settings, HttpClient http) + { + if (string.IsNullOrWhiteSpace(settings.Feed)) + { + throw new UpdateException("Der er intet opdateringsfeed. Angiv SQLInsight:Updates:Feed eller --feed ."); + } + + return new UpdateChecker(new UpdateFeed(settings.Feed, http), settings.Keys, settings.Directories, settings.Channel, TimeProvider.System); + } + + private static UpdateOptions LoadOptions(string agentFolder) + { + var environment = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? "Production"; + var configuration = new ConfigurationBuilder() + .SetBasePath(agentFolder) + .AddJsonFile("appsettings.json", optional: true) + .AddJsonFile($"appsettings.{environment}.json", optional: true) + .AddEnvironmentVariables() + .Build(); + return configuration.GetSection(UpdateOptions.SectionName).Get() ?? new UpdateOptions(); + } + + private static string? Option(string[] args, string name) + { + var i = Array.IndexOf(args, name); + return i >= 0 && i + 1 < args.Length ? args[i + 1] : null; + } + + private static int Usage() + { + Console.Error.WriteLine(""" + SQLInsight.Agent update check Vis hvad opdateringsfeedet tilbyder + SQLInsight.Agent update apply [--feed ] Hent og installér nyeste version nu (administrator) + SQLInsight.Agent apply-update Til den planlagte opdateringsopgave + """); + return 1; + } +} diff --git a/src/SQLInsight.Agent/Updates/UpdateOptions.cs b/src/SQLInsight.Agent/Updates/UpdateOptions.cs new file mode 100644 index 0000000..06ddc9e --- /dev/null +++ b/src/SQLInsight.Agent/Updates/UpdateOptions.cs @@ -0,0 +1,89 @@ +using System.Globalization; +using SQLInsight.Updates; + +namespace SQLInsight.Agent.Updates; + +public enum AppUpdateMode +{ + /// Never download or install new versions (rule packs are controlled separately). + Off, + + /// Show that a new version exists; an administrator installs it with SQLInsight.Agent update apply. + Notify, + + /// Download new versions; the scheduled update task installs them in the maintenance window. + Automatic, +} + +/// Updates of the rule pack and of SQLInsight Pro itself, from a signed feed. +public sealed class UpdateOptions +{ + public const string SectionName = "SQLInsight:Updates"; + + /// + /// Where updates are published: an https URL, or a folder/file share for installations without internet access. + /// Empty: no update checks. + /// + public string? Feed { get; set; } + + /// stable or preview. + public string Channel { get; set; } = UpdateManifest.StableChannel; + + /// Load newer rule packs (support dates and the newest CU per version) automatically. They are data, not code. + public bool RulePacks { get; set; } = true; + + public AppUpdateMode Application { get; set; } = AppUpdateMode.Notify; + + public TimeSpan CheckInterval { get; set; } = TimeSpan.FromHours(6); + + /// Local time window for automatic installation, e.g. "02:00-05:00". Empty: any time. + public string? MaintenanceWindow { get; set; } + + /// The agent's data folder. Default %ProgramData%\SQLInsight or /var/lib/sqlinsight. + public string? DataDirectory { get; set; } + + /// + /// The updater's own folder: the running copy of the updater, the verified package and the backup. Must be writable + /// by administrators only. Default: .sqlinsight-update next to the agent's installation folder. + /// + public string? UpdaterDirectory { get; set; } + + public TimeSpan HealthCheckDuration { get; set; } = TimeSpan.FromMinutes(1); + + /// What to update. Default: the agent itself. Add Web to update the web UI on the same server. + public IList Components { get; } = []; + + /// Additional public keys trusted for update manifests (key id → base64 SubjectPublicKeyInfo). + public IDictionary TrustedKeys { get; } = new Dictionary(); + + public UpdateDirectories Directories => new(DataDirectory ?? UpdateDirectories.DefaultRoot); + + public Licensing.TrustedKeys Keys => Licensing.TrustedKeys.Embedded().With(TrustedKeys); + + public static string DefaultAgentService => OperatingSystem.IsWindows() ? "SQLInsight Agent" : "sqlinsight-agent"; + + public IReadOnlyList ComponentsFor(string agentFolder) => Components.Count > 0 + ? [.. Components] + : [new UpdateComponent { Name = "Agent", Path = agentFolder, Service = DefaultAgentService }]; + + public string UpdaterRoot(string agentFolder) => + UpdaterDirectory ?? Path.Combine(Path.GetDirectoryName(Path.TrimEndingDirectorySeparator(agentFolder))!, ".sqlinsight-update"); + + public bool InMaintenanceWindow(TimeOnly localTime) + { + if (string.IsNullOrWhiteSpace(MaintenanceWindow)) + { + return true; + } + + var parts = MaintenanceWindow.Split('-', 2, StringSplitOptions.TrimEntries); + if (parts.Length != 2 + || !TimeOnly.TryParseExact(parts[0], "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var from) + || !TimeOnly.TryParseExact(parts[1], "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var to)) + { + throw new FormatException($"MaintenanceWindow '{MaintenanceWindow}' skal have formen HH:mm-HH:mm, fx 02:00-05:00."); + } + + return localTime.IsBetween(from, to); + } +} diff --git a/src/SQLInsight.Agent/Updates/UpdateService.cs b/src/SQLInsight.Agent/Updates/UpdateService.cs new file mode 100644 index 0000000..6410ba2 --- /dev/null +++ b/src/SQLInsight.Agent/Updates/UpdateService.cs @@ -0,0 +1,141 @@ +using Microsoft.Extensions.Options; +using SQLInsight.Repository; +using SQLInsight.Rules.Lifecycle; +using SQLInsight.Updates; + +namespace SQLInsight.Agent.Updates; + +/// +/// Checks the feed on a schedule: loads newer rule packs straight away, and records (Notify) or downloads (Automatic) +/// new versions of SQLInsight Pro. Installing is left to the privileged updater (apply-update). +/// +public sealed partial class UpdateService( + IOptions options, + RuleKnowledge knowledge, + InsightRepository repository, + IHttpClientFactory httpClientFactory, + TimeProvider time, + ILogger logger) : BackgroundService +{ + /// Give the monitoring service time to create the repository schema first. + public static readonly TimeSpan StartupDelay = TimeSpan.FromSeconds(30); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await Task.Delay(StartupDelay, time, stoppingToken).ConfigureAwait(false); + using var timer = new PeriodicTimer(options.Value.CheckInterval, time); + do + { + try + { + await CheckOnceAsync(stoppingToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + LogCheckFailed(logger, ex); + } + } + while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)); + } + + public async Task CheckOnceAsync(CancellationToken cancellationToken) + { + var settings = options.Value; + var directories = settings.Directories; + var installed = AppVersion.Running; + var last = directories.LastResult(); + string? error = null; + AppRelease? available = null; + string? staged = null; + DateTime? checkedAt = null; + + if (!string.IsNullOrWhiteSpace(settings.Feed)) + { + try + { + var checker = new UpdateChecker(new UpdateFeed(settings.Feed, httpClientFactory.CreateClient("updates")), settings.Keys, directories, settings.Channel, time); + var manifest = await checker.FetchAsync(cancellationToken).ConfigureAwait(false); + checkedAt = time.GetUtcNow().UtcDateTime; + if (settings.RulePacks && await checker.UpdateRulePackAsync(manifest, knowledge.Current, cancellationToken).ConfigureAwait(false) is { } pack) + { + var previous = knowledge.Current.Version; + knowledge.Replace(pack); + LogRulePackLoaded(logger, pack.Version, previous); + } + + available = UpdateChecker.NewerRelease(manifest.Manifest, installed); + if (available is not null) + { + LogReleaseAvailable(logger, available.Version, installed, settings.Application); + if (settings.Application == AppUpdateMode.Automatic && await checker.StageAsync(manifest, installed, cancellationToken).ConfigureAwait(false)) + { + staged = available.Version; + } + } + } + catch (Exception ex) when (ex is UpdateException or IOException or UnauthorizedAccessException or HttpRequestException or TaskCanceledException && !cancellationToken.IsCancellationRequested) + { + error = ex.Message; + LogCheckError(logger, ex.Message); + } + } + + var status = new UpdateStatusRow + { + Feed = settings.Feed, + Channel = settings.Channel, + Mode = settings.Application.ToString(), + RulePacks = settings.RulePacks, + InstalledVersion = installed.ToString(), + RulePackVersion = knowledge.Current.Version.ToString(), + RulePackSource = knowledge.Current.Source, + AvailableVersion = available?.Version, + AvailableSecurity = available?.Security ?? false, + AvailableNotesUrl = available?.NotesUrl, + StagedVersion = staged, + LastCheckAt = checkedAt, + LastError = error, + LastResultOutcome = last?.Outcome.ToString(), + LastResultMessage = last?.Message, + LastResultAt = last?.At.UtcDateTime, + UpdatedAt = time.GetUtcNow().UtcDateTime, + }; + await repository.SaveUpdateStatusAsync(status, cancellationToken).ConfigureAwait(false); + return status; + } + + /// The rule pack at startup: a verified downloaded one if it is newer than the one compiled in. + public static RuleKnowledge LoadKnowledge(UpdateOptions settings, ILogger logger) + { + ArgumentNullException.ThrowIfNull(settings); + var knowledge = RuleKnowledge.Embedded(); + try + { + if (new RulePackStore(settings.Directories).Load(settings.Keys) is { } stored && stored.Version > knowledge.Current.Version) + { + knowledge.Replace(stored); + } + } + catch (Exception ex) when (ex is UpdateException or IOException or UnauthorizedAccessException) + { + LogStoredPackRejected(logger, ex.Message); + } + + return knowledge; + } + + [LoggerMessage(Level = LogLevel.Information, Message = "Regelpakke {Version} indlæst (før {Previous}).")] + private static partial void LogRulePackLoaded(ILogger logger, Version version, Version previous); + + [LoggerMessage(Level = LogLevel.Information, Message = "SQLInsight Pro {Available} er tilgængelig (installeret: {Installed}, tilstand: {Mode}).")] + private static partial void LogReleaseAvailable(ILogger logger, string available, AppVersion installed, AppUpdateMode mode); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Opdateringstjek fejlede: {Message}")] + private static partial void LogCheckError(ILogger logger, string message); + + [LoggerMessage(Level = LogLevel.Error, Message = "Opdateringstjek fejlede uventet.")] + private static partial void LogCheckFailed(ILogger logger, Exception exception); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Den gemte regelpakke bruges ikke: {Message}. Den indbyggede bruges i stedet.")] + private static partial void LogStoredPackRejected(ILogger logger, string message); +} diff --git a/src/SQLInsight.Hub/HubOptions.cs b/src/SQLInsight.Hub/HubOptions.cs index d0960ea..39d8074 100644 --- a/src/SQLInsight.Hub/HubOptions.cs +++ b/src/SQLInsight.Hub/HubOptions.cs @@ -15,4 +15,10 @@ public sealed class HubServerOptions /// Portal login attempts allowed per client IP per minute. public int LoginAttemptsPerMinute { get; set; } = 10; + + /// + /// Folder with the signed update feed (made with the vendor tool's publish command), served at /updates/. + /// Empty: the hub does not serve updates. + /// + public string? UpdateFeedPath { get; set; } } diff --git a/src/SQLInsight.Hub/Program.cs b/src/SQLInsight.Hub/Program.cs index 0a67196..281ce2f 100644 --- a/src/SQLInsight.Hub/Program.cs +++ b/src/SQLInsight.Hub/Program.cs @@ -82,6 +82,20 @@ public static class HubProgram app.UseRateLimiter(); app.UseAntiforgery(); app.MapStaticAssets(); + if (app.Configuration.GetSection(HubServerOptions.SectionName).Get()?.UpdateFeedPath is { Length: > 0 } feed) + { + // Everything in the feed is signed or pinned by hash in a signed manifest; the hub only has to serve it. + var types = new Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider(); + types.Mappings[".sqli"] = "text/plain"; + app.UseStaticFiles(new StaticFileOptions + { + FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(Path.GetFullPath(feed)), + RequestPath = "/updates", + ContentTypeProvider = types, + ServeUnknownFileTypes = false, + }); + } + app.MapGet("/", () => Results.Redirect("/portal")); app.MapGet("/health", () => Results.Ok(new { status = "ok" })); FindingsEndpoint.Map(app); diff --git a/src/SQLInsight.Repository/InsightRepository.Updates.cs b/src/SQLInsight.Repository/InsightRepository.Updates.cs new file mode 100644 index 0000000..aec68bf --- /dev/null +++ b/src/SQLInsight.Repository/InsightRepository.Updates.cs @@ -0,0 +1,52 @@ +using Dapper; + +namespace SQLInsight.Repository; + +public sealed record UpdateStatusRow +{ + public string? Feed { get; init; } + public string Channel { get; init; } = "stable"; + public string Mode { get; init; } = "Notify"; + public bool RulePacks { get; init; } + public string InstalledVersion { get; init; } = string.Empty; + public string RulePackVersion { get; init; } = string.Empty; + public string RulePackSource { get; init; } = string.Empty; + public string? AvailableVersion { get; init; } + public bool AvailableSecurity { get; init; } + public string? AvailableNotesUrl { get; init; } + public string? StagedVersion { get; init; } + public DateTime? LastCheckAt { get; init; } + public string? LastError { get; init; } + public string? LastResultOutcome { get; init; } + public string? LastResultMessage { get; init; } + public DateTime? LastResultAt { get; init; } + public DateTime UpdatedAt { get; init; } +} + +public sealed partial class InsightRepository +{ + public async Task SaveUpdateStatusAsync(UpdateStatusRow status, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(status); + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + await connection.ExecuteAsync(""" + MERGE insight.UpdateStatus AS t + USING (SELECT 1 AS Id) AS s ON t.Id = s.Id + WHEN MATCHED THEN UPDATE SET Feed = @Feed, Channel = @Channel, Mode = @Mode, RulePacks = @RulePacks, InstalledVersion = @InstalledVersion, + RulePackVersion = @RulePackVersion, RulePackSource = @RulePackSource, AvailableVersion = @AvailableVersion, + AvailableSecurity = @AvailableSecurity, AvailableNotesUrl = @AvailableNotesUrl, StagedVersion = @StagedVersion, + LastCheckAt = @LastCheckAt, LastError = @LastError, LastResultOutcome = @LastResultOutcome, + LastResultMessage = @LastResultMessage, LastResultAt = @LastResultAt, UpdatedAt = @UpdatedAt + WHEN NOT MATCHED THEN INSERT (Id, Feed, Channel, Mode, RulePacks, InstalledVersion, RulePackVersion, RulePackSource, AvailableVersion, + AvailableSecurity, AvailableNotesUrl, StagedVersion, LastCheckAt, LastError, LastResultOutcome, LastResultMessage, LastResultAt, UpdatedAt) + VALUES (1, @Feed, @Channel, @Mode, @RulePacks, @InstalledVersion, @RulePackVersion, @RulePackSource, @AvailableVersion, + @AvailableSecurity, @AvailableNotesUrl, @StagedVersion, @LastCheckAt, @LastError, @LastResultOutcome, @LastResultMessage, @LastResultAt, @UpdatedAt); + """, status).ConfigureAwait(false); + } + + public async Task GetUpdateStatusAsync(CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + return await connection.QuerySingleOrDefaultAsync("SELECT * FROM insight.UpdateStatus WHERE Id = 1;").ConfigureAwait(false); + } +} diff --git a/src/SQLInsight.Repository/Migrations/0003_update_status.sql b/src/SQLInsight.Repository/Migrations/0003_update_status.sql new file mode 100644 index 0000000..8b4e7bc --- /dev/null +++ b/src/SQLInsight.Repository/Migrations/0003_update_status.sql @@ -0,0 +1,22 @@ +/* What the agent last knew about updates, for the web UI. One row. */ +CREATE TABLE insight.UpdateStatus +( + Id tinyint NOT NULL CONSTRAINT PK_UpdateStatus PRIMARY KEY CONSTRAINT CK_UpdateStatus_Single CHECK (Id = 1), + Feed nvarchar(400) NULL, + Channel varchar(20) NOT NULL, + Mode varchar(20) NOT NULL, + RulePacks bit NOT NULL, + InstalledVersion varchar(50) NOT NULL, + RulePackVersion varchar(50) NOT NULL, + RulePackSource nvarchar(200) NOT NULL, + AvailableVersion varchar(50) NULL, + AvailableSecurity bit NOT NULL, + AvailableNotesUrl nvarchar(400) NULL, + StagedVersion varchar(50) NULL, + LastCheckAt datetime2(0) NULL, + LastError nvarchar(2000) NULL, + LastResultOutcome varchar(20) NULL, + LastResultMessage nvarchar(2000) NULL, + LastResultAt datetime2(0) NULL, + UpdatedAt datetime2(0) NOT NULL +); diff --git a/src/SQLInsight.Rules/BuiltInRules.cs b/src/SQLInsight.Rules/BuiltInRules.cs index f331fb6..05ea4e7 100644 --- a/src/SQLInsight.Rules/BuiltInRules.cs +++ b/src/SQLInsight.Rules/BuiltInRules.cs @@ -11,68 +11,73 @@ namespace SQLInsight.Rules; public static class BuiltInRules { - public static IReadOnlyList Create() => - [ - // Version - new SupportLifecycleRule(SupportLifecycleCatalog.LoadEmbedded()), - new PatchLevelRule(BuildCatalog.LoadEmbedded(), SupportLifecycleCatalog.LoadEmbedded()), + /// The rule pack behind the version rules; the embedded one if null. + public static IReadOnlyList Create(RuleKnowledge? knowledge = null) + { + knowledge ??= RuleKnowledge.Embedded(); + return + [ + // Version + new SupportLifecycleRule(knowledge), + new PatchLevelRule(knowledge), - // Configuration - new MaxServerMemoryDefaultRule(), - new MaxServerMemoryTooHighRule(), - new CostThresholdRule(), - new MaxDopRule(), - new OptimizeForAdHocRule(), - .. SimpleConfigurationRules.Create(), - new PendingConfigurationRule(), - new DeadlockTraceFlagRule(), + // Configuration + new MaxServerMemoryDefaultRule(), + new MaxServerMemoryTooHighRule(), + new CostThresholdRule(), + new MaxDopRule(), + new OptimizeForAdHocRule(), + .. SimpleConfigurationRules.Create(), + new PendingConfigurationRule(), + new DeadlockTraceFlagRule(), - // Databases - new DatabaseStateRule(), - .. SimpleDatabaseRules.Create(), - new CompatibilityLevelRule(), - new FullBackupRule(), - new LogBackupRule(), - new CheckDbRule(), - new SuspectPagesRule(), - new VirtualLogFileRule(), - new FileGrowthRule(), - new FileMaxSizeRule(), - new QueryStoreStateRule(), - new TempdbFilesRule(), + // Databases + new DatabaseStateRule(), + .. SimpleDatabaseRules.Create(), + new CompatibilityLevelRule(), + new FullBackupRule(), + new LogBackupRule(), + new CheckDbRule(), + new SuspectPagesRule(), + new VirtualLogFileRule(), + new FileGrowthRule(), + new FileMaxSizeRule(), + new QueryStoreStateRule(), + new TempdbFilesRule(), - // Performance - .. WaitRules.Create(), - new DataFileReadLatencyRule(), - new LogFileWriteLatencyRule(), - new CpuRule(), - new MemoryGrantsPendingRule(), - new AnomalyRule(), - new PlanRegressionRule(), - new FailedForcedPlanRule(), - new ParameterSensitivityRule(), - new ImplicitConversionRule(), - new NoJoinPredicateRule(), - new ScalarUdfRule(), - new SpillRule(), - new ExcessiveGrantRule(), + // Performance + .. WaitRules.Create(), + new DataFileReadLatencyRule(), + new LogFileWriteLatencyRule(), + new CpuRule(), + new MemoryGrantsPendingRule(), + new AnomalyRule(), + new PlanRegressionRule(), + new FailedForcedPlanRule(), + new ParameterSensitivityRule(), + new ImplicitConversionRule(), + new NoJoinPredicateRule(), + new ScalarUdfRule(), + new SpillRule(), + new ExcessiveGrantRule(), - // Indexes and statistics - new MissingIndexRule(), - new UnusedIndexRule(), - new DuplicateIndexRule(), - new ForwardedRecordsRule(), - new StaleStatisticsRule(), - new AutoStatisticsRule(), + // Indexes and statistics + new MissingIndexRule(), + new UnusedIndexRule(), + new DuplicateIndexRule(), + new ForwardedRecordsRule(), + new StaleStatisticsRule(), + new AutoStatisticsRule(), - // Availability and jobs - new AvailabilityGroupHealthRule(), - new AvailabilityGroupLagRule(), - new FailedJobRule(), - new AgentStoppedRule(), + // Availability and jobs + new AvailabilityGroupHealthRule(), + new AvailabilityGroupLagRule(), + new FailedJobRule(), + new AgentStoppedRule(), - // Concurrency - new BlockingRule(), - new DeadlockRule(), - ]; + // Concurrency + new BlockingRule(), + new DeadlockRule(), + ]; + } } diff --git a/src/SQLInsight.Rules/Lifecycle/BuildCatalog.cs b/src/SQLInsight.Rules/Lifecycle/BuildCatalog.cs index 0891e6d..efe6b7a 100644 --- a/src/SQLInsight.Rules/Lifecycle/BuildCatalog.cs +++ b/src/SQLInsight.Rules/Lifecycle/BuildCatalog.cs @@ -23,10 +23,28 @@ public sealed class BuildCatalog(DateOnly asOf, IEnumerable builds) { using var stream = typeof(BuildCatalog).Assembly.GetManifestResourceStream(ResourceName) ?? throw new InvalidOperationException($"Embedded resource '{ResourceName}' is missing."); - var document = JsonSerializer.Deserialize(stream, JsonOptions) - ?? throw new InvalidOperationException("The build catalog is empty."); - return new BuildCatalog(document.AsOf, document.Releases.Select(r => - new KnownBuild(r.Release, SqlServerVersion.Parse(r.Build), r.Name, r.Kb, r.Released, r.Security))); + return From(JsonSerializer.Deserialize(stream, JsonOptions)); + } + + /// Reads the catalog section of a rule pack. Throws if it is malformed. + public static BuildCatalog FromJson(JsonElement element) => From(element.Deserialize(JsonOptions)); + + private static BuildCatalog From(Document? document) + { + if (document?.Releases is not { Count: > 0 } releases) + { + throw new InvalidDataException("The build catalog is empty."); + } + + try + { + return new BuildCatalog(document.AsOf, releases.Select(r => + new KnownBuild(r.Release, SqlServerVersion.Parse(r.Build), r.Name, r.Kb, r.Released, r.Security))); + } + catch (Exception ex) when (ex is FormatException or ArgumentException) + { + throw new InvalidDataException("The build catalog is invalid: " + ex.Message, ex); + } } private static readonly JsonSerializerOptions JsonOptions = new() diff --git a/src/SQLInsight.Rules/Lifecycle/PatchLevelRule.cs b/src/SQLInsight.Rules/Lifecycle/PatchLevelRule.cs index 5708731..0e2c21a 100644 --- a/src/SQLInsight.Rules/Lifecycle/PatchLevelRule.cs +++ b/src/SQLInsight.Rules/Lifecycle/PatchLevelRule.cs @@ -8,8 +8,13 @@ namespace SQLInsight.Rules.Lifecycle; /// /// Instance behind the newest known cumulative update, security update or service pack. /// -public sealed class PatchLevelRule(BuildCatalog catalog, SupportLifecycleCatalog lifecycle) : Rule +public sealed class PatchLevelRule(RuleKnowledge knowledge) : Rule { + public PatchLevelRule(BuildCatalog catalog, SupportLifecycleCatalog lifecycle) + : this(new RuleKnowledge(new RulePack(RulePack.VersionOf(catalog.AsOf), "test", catalog, lifecycle))) + { + } + /// After this long the catalog itself is flagged as possibly outdated. public static readonly TimeSpan CatalogMaxAge = TimeSpan.FromDays(120); @@ -21,6 +26,8 @@ public sealed class PatchLevelRule(BuildCatalog catalog, SupportLifecycleCatalog { ArgumentNullException.ThrowIfNull(context); var instance = context.Instance; + var pack = knowledge.Current; + var catalog = pack.Builds; var latest = catalog.Latest(instance.Release) ?? throw new Core.Collection.DataUnavailableException($"Regelpakken kender ikke seneste build for {instance.Release.DisplayName()}. Opdater SQLInsight Pro."); @@ -30,7 +37,7 @@ public sealed class PatchLevelRule(BuildCatalog catalog, SupportLifecycleCatalog } var today = DateOnly.FromDateTime(context.Now.UtcDateTime); - var supported = lifecycle.Find(instance.Release)?.ExtendedEnd is { } end && today <= end; + var supported = pack.Lifecycle.Find(instance.Release)?.ExtendedEnd is { } end && today <= end; var catalogAge = today.ToDateTime(TimeOnly.MinValue) - catalog.AsOf.ToDateTime(TimeOnly.MinValue); var current = $"{instance.Version}" + (instance.ProductLevel is { } level ? $" ({level}{(instance.ProductUpdateLevel is { } cu ? " " + cu : string.Empty)})" : string.Empty); @@ -38,8 +45,8 @@ public sealed class PatchLevelRule(BuildCatalog catalog, SupportLifecycleCatalog { new("Installeret build", current), new("Seneste kendte build", $"{latest.Build} ({latest.Name}, {latest.Kb}, {latest.Released.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)})"), - new("Katalog pr.", catalog.AsOf.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) - + (catalogAge > CatalogMaxAge ? " (forældet: opdater SQLInsight Pro for at se nyere opdateringer)" : string.Empty)), + new("Katalog pr.", catalog.AsOf.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + $" (regelpakke {pack.Version})" + + (catalogAge > CatalogMaxAge ? " (forældet: opdater regelpakken eller SQLInsight Pro for at se nyere opdateringer)" : string.Empty)), }; return RuleEvaluation.Found(CreateFinding( diff --git a/src/SQLInsight.Rules/Lifecycle/RulePack.cs b/src/SQLInsight.Rules/Lifecycle/RulePack.cs new file mode 100644 index 0000000..3f08541 --- /dev/null +++ b/src/SQLInsight.Rules/Lifecycle/RulePack.cs @@ -0,0 +1,108 @@ +using System.Globalization; +using System.Text.Json; + +namespace SQLInsight.Rules.Lifecycle; + +/// +/// The data behind the version rules: Microsoft's support dates and the newest build per release. It changes every +/// month, so it can be updated between releases of SQLInsight Pro through a signed rule pack. +/// +public sealed class RulePack +{ + public const int FormatVersion = 1; + + private static readonly JsonSerializerOptions Indented = new() { WriteIndented = true }; + + public RulePack(Version version, string source, BuildCatalog builds, SupportLifecycleCatalog lifecycle) + { + Version = version ?? throw new ArgumentNullException(nameof(version)); + Source = source ?? throw new ArgumentNullException(nameof(source)); + Builds = builds ?? throw new ArgumentNullException(nameof(builds)); + Lifecycle = lifecycle ?? throw new ArgumentNullException(nameof(lifecycle)); + } + + public Version Version { get; } + + /// Where the pack came from, for the UI ("indbygget", or the feed it was downloaded from). + public string Source { get; } + + public BuildCatalog Builds { get; } + + public SupportLifecycleCatalog Lifecycle { get; } + + /// The pack compiled into this build. Its version is the build catalog date, e.g. 2026.9.29. + public static RulePack Embedded() + { + var builds = BuildCatalog.LoadEmbedded(); + return new RulePack(VersionOf(builds.AsOf), "indbygget", builds, SupportLifecycleCatalog.LoadEmbedded()); + } + + public static Version VersionOf(DateOnly date) => new(date.Year, date.Month, date.Day); + + /// + /// Parses a rule pack file: { "formatVersion": 1, "version": "2026.10.14", "buildCatalog": {…}, "supportLifecycle": {…} }. + /// Throws if anything is missing or malformed, so a broken pack is never loaded. + /// + public static RulePack Parse(ReadOnlySpan json, string source) + { + try + { + using var document = JsonDocument.Parse(json.ToArray()); + var root = document.RootElement; + if (!root.TryGetProperty("formatVersion", out var format) || format.GetInt32() != FormatVersion) + { + throw new InvalidDataException($"Regelpakken har et formatVersion, som denne version ikke kender (forventet {FormatVersion}). Opdater SQLInsight Pro."); + } + + if (!root.TryGetProperty("version", out var versionText) || !Version.TryParse(versionText.GetString(), out var version)) + { + throw new InvalidDataException("Regelpakken mangler et gyldigt versionsnummer."); + } + + if (!root.TryGetProperty("buildCatalog", out var builds) || !root.TryGetProperty("supportLifecycle", out var lifecycle)) + { + throw new InvalidDataException("Regelpakken mangler buildCatalog eller supportLifecycle."); + } + + return new RulePack(version, source, BuildCatalog.FromJson(builds), SupportLifecycleCatalog.FromJson(lifecycle)); + } + catch (Exception ex) when (ex is JsonException or InvalidOperationException or FormatException) + { + throw new InvalidDataException("Regelpakken er ikke gyldig JSON: " + ex.Message, ex); + } + } + + /// Builds a rule pack file from the two catalog files (vendor tool). + public static byte[] Create(Version version, ReadOnlySpan buildCatalogJson, ReadOnlySpan supportLifecycleJson) + { + ArgumentNullException.ThrowIfNull(version); + using var builds = JsonDocument.Parse(buildCatalogJson.ToArray()); + using var lifecycle = JsonDocument.Parse(supportLifecycleJson.ToArray()); + var bytes = JsonSerializer.SerializeToUtf8Bytes(new + { + formatVersion = FormatVersion, + version = version.ToString(), + createdAt = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture), + buildCatalog = builds.RootElement, + supportLifecycle = lifecycle.RootElement, + }, Indented); + + // Refuse to publish a pack this version could not load. + Parse(bytes, "validering"); + return bytes; + } +} + +/// +/// The rule pack in use. Rules read on every evaluation, so a new pack takes effect without a restart. +/// +public sealed class RuleKnowledge(RulePack pack) +{ + private volatile RulePack _current = pack ?? throw new ArgumentNullException(nameof(pack)); + + public RulePack Current => _current; + + public static RuleKnowledge Embedded() => new(RulePack.Embedded()); + + public void Replace(RulePack pack) => _current = pack ?? throw new ArgumentNullException(nameof(pack)); +} diff --git a/src/SQLInsight.Rules/Lifecycle/SupportLifecycleCatalog.cs b/src/SQLInsight.Rules/Lifecycle/SupportLifecycleCatalog.cs index f7f4426..62df183 100644 --- a/src/SQLInsight.Rules/Lifecycle/SupportLifecycleCatalog.cs +++ b/src/SQLInsight.Rules/Lifecycle/SupportLifecycleCatalog.cs @@ -31,9 +31,27 @@ public sealed class SupportLifecycleCatalog { using var stream = typeof(SupportLifecycleCatalog).Assembly.GetManifestResourceStream(ResourceName) ?? throw new InvalidOperationException($"Embedded resource '{ResourceName}' is missing."); - var document = JsonSerializer.Deserialize(stream, JsonOptions) - ?? throw new InvalidOperationException("The support lifecycle catalog is empty."); - return new SupportLifecycleCatalog(document.Releases); + return From(JsonSerializer.Deserialize(stream, JsonOptions)); + } + + /// Reads the lifecycle section of a rule pack. Throws if it is malformed. + public static SupportLifecycleCatalog FromJson(JsonElement element) => From(element.Deserialize(JsonOptions)); + + private static SupportLifecycleCatalog From(CatalogDocument? document) + { + if (document?.Releases is not { Count: > 0 } releases) + { + throw new InvalidDataException("The support lifecycle catalog is empty."); + } + + try + { + return new SupportLifecycleCatalog(releases); + } + catch (ArgumentException ex) + { + throw new InvalidDataException("The support lifecycle catalog is invalid: " + ex.Message, ex); + } } public SupportLifecycle? Find(SqlServerRelease release) => _entries.GetValueOrDefault(release); diff --git a/src/SQLInsight.Rules/Lifecycle/SupportLifecycleRule.cs b/src/SQLInsight.Rules/Lifecycle/SupportLifecycleRule.cs index 185bc25..eaa5abd 100644 --- a/src/SQLInsight.Rules/Lifecycle/SupportLifecycleRule.cs +++ b/src/SQLInsight.Rules/Lifecycle/SupportLifecycleRule.cs @@ -9,8 +9,13 @@ namespace SQLInsight.Rules.Lifecycle; /// Flags instances running a SQL Server release that is out of, or about to leave, Microsoft support. /// Needs no capabilities, so it runs on every version from SQL Server 2000. /// -public sealed class SupportLifecycleRule(SupportLifecycleCatalog catalog) : IRule +public sealed class SupportLifecycleRule(RuleKnowledge knowledge) : IRule { + public SupportLifecycleRule(SupportLifecycleCatalog catalog) + : this(new RuleKnowledge(new RulePack(new Version(0, 0), "test", BuildCatalog.LoadEmbedded(), catalog))) + { + } + public const string RuleId = "SQLI-VER-001"; /// How long before end of extended support the finding escalates to High. @@ -34,7 +39,7 @@ public sealed class SupportLifecycleRule(SupportLifecycleCatalog catalog) : IRul $"Versionen {context.Instance.Version} er ukendt for regelpakken. Opdater SQLInsight Pro."); } - var lifecycle = catalog.Find(release); + var lifecycle = knowledge.Current.Lifecycle.Find(release); if (lifecycle?.MainstreamEnd is not { } mainstreamEnd || lifecycle.ExtendedEnd is not { } extendedEnd) { return RuleEvaluation.NotMeasurable( diff --git a/src/SQLInsight.Updates/AppVersion.cs b/src/SQLInsight.Updates/AppVersion.cs new file mode 100644 index 0000000..7eda5cf --- /dev/null +++ b/src/SQLInsight.Updates/AppVersion.cs @@ -0,0 +1,90 @@ +using System.Globalization; +using System.Reflection; + +namespace SQLInsight.Updates; + +/// A release version: 1.4.0 or a preview such as 1.5.0-preview.2. A preview sorts before its release. +public sealed class AppVersion : IComparable +{ + private AppVersion(Version core, string? preRelease) + { + Core = core; + PreRelease = preRelease; + } + + public Version Core { get; } + public string? PreRelease { get; } + + public static AppVersion Parse(string text) + { + ArgumentException.ThrowIfNullOrWhiteSpace(text); + var withoutBuild = text.Split('+')[0].Trim(); + var dash = withoutBuild.IndexOf('-', StringComparison.Ordinal); + var core = dash < 0 ? withoutBuild : withoutBuild[..dash]; + if (!Version.TryParse(core, out var version)) + { + throw new FormatException($"'{text}' er ikke et gyldigt versionsnummer."); + } + + return new AppVersion(Normalize(version), dash < 0 ? null : withoutBuild[(dash + 1)..]); + } + + /// The version of the running SQLInsight Pro (from the informational version set at build time). + public static AppVersion Running => Parse( + Assembly.GetEntryAssembly()?.GetCustomAttribute()?.InformationalVersion ?? "0.0.0"); + + public int CompareTo(AppVersion? other) + { + if (other is null) + { + return 1; + } + + var core = Core.CompareTo(other.Core); + if (core != 0) + { + return core; + } + + return (PreRelease, other.PreRelease) switch + { + (null, null) => 0, + (null, _) => 1, + (_, null) => -1, + _ => ComparePreRelease(PreRelease!, other.PreRelease!), + }; + } + + public static bool operator ==(AppVersion? left, AppVersion? right) => left is null ? right is null : left.Equals(right); + public static bool operator !=(AppVersion? left, AppVersion? right) => !(left == right); + public static bool operator >(AppVersion left, AppVersion right) => left.CompareTo(right) > 0; + public static bool operator <(AppVersion left, AppVersion right) => left.CompareTo(right) < 0; + public static bool operator >=(AppVersion left, AppVersion right) => left.CompareTo(right) >= 0; + public static bool operator <=(AppVersion left, AppVersion right) => left.CompareTo(right) <= 0; + + public override bool Equals(object? obj) => obj is AppVersion other && CompareTo(other) == 0; + + public override int GetHashCode() => HashCode.Combine(Core, PreRelease); + + public override string ToString() => PreRelease is null ? Core.ToString(3) : $"{Core.ToString(3)}-{PreRelease}"; + + private static Version Normalize(Version v) => new(v.Major, v.Minor, Math.Max(v.Build, 0)); + + /// SemVer: dot-separated identifiers, numeric ones compared as numbers. + private static int ComparePreRelease(string a, string b) + { + var x = a.Split('.'); + var y = b.Split('.'); + for (var i = 0; i < Math.Min(x.Length, y.Length); i++) + { + var bothNumeric = int.TryParse(x[i], NumberStyles.None, CultureInfo.InvariantCulture, out var nx) & int.TryParse(y[i], NumberStyles.None, CultureInfo.InvariantCulture, out var ny); + var c = bothNumeric ? nx.CompareTo(ny) : string.CompareOrdinal(x[i], y[i]); + if (c != 0) + { + return c; + } + } + + return x.Length.CompareTo(y.Length); + } +} diff --git a/src/SQLInsight.Updates/ManifestVerifier.cs b/src/SQLInsight.Updates/ManifestVerifier.cs new file mode 100644 index 0000000..d0bf1c0 --- /dev/null +++ b/src/SQLInsight.Updates/ManifestVerifier.cs @@ -0,0 +1,77 @@ +using System.Runtime.InteropServices; +using SQLInsight.Licensing; + +namespace SQLInsight.Updates; + +public static class ManifestVerifier +{ + /// + /// Verifies signature, channel and expiry. Throws with a message for the operator. + /// + /// The newest manifest accepted earlier; an older one is refused (rollback protection). + public static UpdateManifest Verify(string token, TrustedKeys keys, string channel, DateTimeOffset now, DateTimeOffset? notBefore = null) + { + ArgumentNullException.ThrowIfNull(keys); + if (keys.Count == 0) + { + throw new UpdateException("Der er ingen betroede nøgler til at verificere opdateringer. Opdateringer er slået fra i denne build."); + } + + var manifest = SignedDocument.Verify(token, keys, m => m.KeyId) + ?? throw new UpdateException("Opdateringsmanifestet har ikke en gyldig signatur fra SEOSoft. Det bliver ignoreret."); + if (!string.Equals(manifest.Channel, channel, StringComparison.OrdinalIgnoreCase)) + { + throw new UpdateException($"Manifestet er til kanalen '{manifest.Channel}', men agenten følger '{channel}'."); + } + + if (manifest.ExpiresAt < now) + { + throw new UpdateException($"Manifestet udløb {manifest.ExpiresAt:yyyy-MM-dd}. Feedet er ikke blevet opdateret; kontakt SEOSoft eller hent et nyt offline-feed."); + } + + if (notBefore is { } seen && manifest.PublishedAt < seen) + { + throw new UpdateException($"Manifestet er ældre ({manifest.PublishedAt:yyyy-MM-dd HH:mm}) end et, der allerede er brugt ({seen:yyyy-MM-dd HH:mm}). Det bliver ignoreret."); + } + + foreach (var file in Files(manifest)) + { + FeedPath.Validate(file.Path); + } + + return manifest; + } + + private static IEnumerable Files(UpdateManifest manifest) + { + if (manifest.RulePack is { } pack) + { + yield return pack.File; + } + + foreach (var asset in manifest.Release?.Assets ?? []) + { + yield return asset.File; + } + } +} + +public static class Platform +{ + /// The package platform for this machine, e.g. win-x64 or linux-arm64. + public static string Current => + (OperatingSystem.IsWindows() ? "win" : OperatingSystem.IsLinux() ? "linux" : "other") + "-" + RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant(); +} + +public static class FeedPath +{ + /// Feed paths are relative, forward-slash, and never leave the feed root. + public static void Validate(string path) + { + if (string.IsNullOrWhiteSpace(path) || path.StartsWith('/') || path.Contains('\\', StringComparison.Ordinal) || path.Contains(':', StringComparison.Ordinal) + || path.Split('/').Any(segment => segment is "" or "." or "..")) + { + throw new UpdateException($"Manifestet indeholder en ugyldig sti: '{path}'."); + } + } +} diff --git a/src/SQLInsight.Updates/RulePackStore.cs b/src/SQLInsight.Updates/RulePackStore.cs new file mode 100644 index 0000000..51fa346 --- /dev/null +++ b/src/SQLInsight.Updates/RulePackStore.cs @@ -0,0 +1,45 @@ +using System.Security.Cryptography; +using System.Text; +using SQLInsight.Licensing; +using SQLInsight.Rules.Lifecycle; + +namespace SQLInsight.Updates; + +/// +/// The downloaded rule pack on disk, next to the signed manifest that vouches for it. The signature and hash are checked +/// again every time it is loaded, so a changed file is never used. +/// +public sealed class RulePackStore(UpdateDirectories directories) +{ + private string PackFile => Path.Combine(directories.RulePack, "pack.json"); + private string ManifestFile => Path.Combine(directories.RulePack, "manifest.sqli"); + + public void Save(byte[] pack, string manifestToken) + { + UpdateDirectories.WriteAtomic(PackFile, pack); + UpdateDirectories.WriteAtomic(ManifestFile, Encoding.UTF8.GetBytes(manifestToken)); + } + + /// The stored pack, or null if there is none. Throws if it fails verification. + public RulePack? Load(TrustedKeys keys) + { + if (!File.Exists(PackFile) || !File.Exists(ManifestFile)) + { + return null; + } + + var manifest = SignedDocument.Verify(File.ReadAllText(ManifestFile), keys, m => m.KeyId) + ?? throw new UpdateException("Den gemte regelpakkes manifest har ikke en gyldig signatur."); + var reference = manifest.RulePack ?? throw new UpdateException("Den gemte regelpakkes manifest nævner ingen regelpakke."); + var bytes = File.ReadAllBytes(PackFile); + UpdateFeed.Verify(reference.File, bytes.Length, SHA256.HashData(bytes)); + try + { + return RulePack.Parse(bytes, $"regelpakke fra {manifest.Channel}-kanalen, {manifest.PublishedAt:yyyy-MM-dd}"); + } + catch (InvalidDataException ex) + { + throw new UpdateException(ex.Message, ex); + } + } +} diff --git a/src/SQLInsight.Updates/SQLInsight.Updates.csproj b/src/SQLInsight.Updates/SQLInsight.Updates.csproj new file mode 100644 index 0000000..a35df0b --- /dev/null +++ b/src/SQLInsight.Updates/SQLInsight.Updates.csproj @@ -0,0 +1,12 @@ + + + SQLInsight.Updates + + + + + + + + + diff --git a/src/SQLInsight.Updates/ServiceControl.cs b/src/SQLInsight.Updates/ServiceControl.cs new file mode 100644 index 0000000..614a3ad --- /dev/null +++ b/src/SQLInsight.Updates/ServiceControl.cs @@ -0,0 +1,83 @@ +using System.Diagnostics; +using System.Runtime.Versioning; +using System.ServiceProcess; + +namespace SQLInsight.Updates; + +/// Stops and starts the services being updated. +public interface IServiceControl +{ + Task StopAsync(string service, CancellationToken cancellationToken); + Task StartAsync(string service, CancellationToken cancellationToken); + Task IsRunningAsync(string service, CancellationToken cancellationToken); +} + +public static class ServiceControl +{ + public static IServiceControl ForCurrentOs() => + OperatingSystem.IsWindows() ? new WindowsServiceControl() : new SystemdServiceControl(); +} + +[SupportedOSPlatform("windows")] +public sealed class WindowsServiceControl : IServiceControl +{ + private static readonly TimeSpan Timeout = TimeSpan.FromMinutes(2); + + public Task StopAsync(string service, CancellationToken cancellationToken) => Task.Run(() => + { + using var controller = new ServiceController(service); + if (controller.Status != ServiceControllerStatus.Stopped) + { + controller.Stop(); + controller.WaitForStatus(ServiceControllerStatus.Stopped, Timeout); + } + }, cancellationToken); + + public Task StartAsync(string service, CancellationToken cancellationToken) => Task.Run(() => + { + using var controller = new ServiceController(service); + if (controller.Status != ServiceControllerStatus.Running) + { + controller.Start(); + controller.WaitForStatus(ServiceControllerStatus.Running, Timeout); + } + }, cancellationToken); + + public Task IsRunningAsync(string service, CancellationToken cancellationToken) => Task.Run(() => + { + using var controller = new ServiceController(service); + controller.Refresh(); + return controller.Status == ServiceControllerStatus.Running; + }, cancellationToken); +} + +public sealed class SystemdServiceControl : IServiceControl +{ + public async Task StopAsync(string service, CancellationToken cancellationToken) => + await RunAsync(["stop", service], throwOnError: true, cancellationToken).ConfigureAwait(false); + + public async Task StartAsync(string service, CancellationToken cancellationToken) => + await RunAsync(["start", service], throwOnError: true, cancellationToken).ConfigureAwait(false); + + public async Task IsRunningAsync(string service, CancellationToken cancellationToken) => + await RunAsync(["is-active", "--quiet", service], throwOnError: false, cancellationToken).ConfigureAwait(false) == 0; + + private static async Task RunAsync(string[] args, bool throwOnError, CancellationToken cancellationToken) + { + var start = new ProcessStartInfo("systemctl") { RedirectStandardError = true, UseShellExecute = false }; + foreach (var arg in args) + { + start.ArgumentList.Add(arg); + } + + using var process = Process.Start(start) ?? throw new UpdateException("systemctl kunne ikke startes."); + var error = await process.StandardError.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + if (throwOnError && process.ExitCode != 0) + { + throw new UpdateException($"systemctl {string.Join(' ', args)} fejlede: {error.Trim()}"); + } + + return process.ExitCode; + } +} diff --git a/src/SQLInsight.Updates/UpdateApplier.cs b/src/SQLInsight.Updates/UpdateApplier.cs new file mode 100644 index 0000000..642a914 --- /dev/null +++ b/src/SQLInsight.Updates/UpdateApplier.cs @@ -0,0 +1,269 @@ +using System.IO.Compression; +using System.Security.Cryptography; +using SQLInsight.Licensing; + +namespace SQLInsight.Updates; + +/// A program folder that is updated from the package folder with the same name. +public sealed class UpdateComponent +{ + /// Folder name in the package: Agent or Web. + public string Name { get; set; } = string.Empty; + + /// Installation folder. + public string Path { get; set; } = string.Empty; + + /// Service to stop and start, if the component runs as a Windows service or systemd unit. + public string? Service { get; set; } + + /// Hosted in IIS: take it offline with app_offline.htm while files are replaced. + public bool AppOffline { get; set; } +} + +public sealed class UpdateApplierOptions +{ + public required string Channel { get; init; } + + /// + /// Working folder for the package, the extracted files and the backup. Must be writable by administrators only + /// (next to the installation), never the agent's data folder: files are verified here and then installed from here. + /// + public required string PrivateRoot { get; init; } + public required IReadOnlyList Components { get; init; } + + /// How long every restarted service must keep running before the update counts as good. + public TimeSpan HealthCheckDuration { get; init; } = TimeSpan.FromMinutes(1); + + /// Files kept from the installation, never overwritten by the package. + public IReadOnlyList PreservedFiles { get; init; } = ["appsettings.json", "appsettings.*.json", "web.config"]; +} + +/// +/// The privileged half of updating, run by an administrator or a scheduled task. It trusts nothing the agent staged: +/// the manifest signature, the package hash and the version are all checked again here. Every component is backed up +/// first; if a service does not come back, everything is restored. +/// +public sealed class UpdateApplier(UpdateApplierOptions options, UpdateDirectories directories, TrustedKeys keys, IServiceControl services, TimeProvider time, Action log) +{ + private const string AppOfflineFile = "app_offline.htm"; + + private string PrivatePackage => System.IO.Path.Combine(options.PrivateRoot, "package.zip"); + + /// The previous version, kept until the next update. + private string BackupRoot => System.IO.Path.Combine(options.PrivateRoot, "backup"); + + public async Task ApplyAsync(AppVersion installed, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(installed); + var result = await ApplyCoreAsync(installed, cancellationToken).ConfigureAwait(false); + if (result.Outcome != UpdateOutcome.NothingToDo) + { + directories.SaveResult(result); + } + + log(result.Message); + return result; + } + + private async Task ApplyCoreAsync(AppVersion installed, CancellationToken cancellationToken) + { + var manifestFile = System.IO.Path.Combine(directories.Pending, UpdateFeed.ManifestFile); + var package = System.IO.Path.Combine(directories.Pending, "package.zip"); + if (!File.Exists(manifestFile) || !File.Exists(package)) + { + return Result(UpdateOutcome.NothingToDo, installed, null, "Ingen opdatering venter."); + } + + UpdateManifest manifest; + ReleaseAsset asset; + try + { + manifest = ManifestVerifier.Verify(await File.ReadAllTextAsync(manifestFile, cancellationToken).ConfigureAwait(false), keys, options.Channel, time.GetUtcNow()); + if (UpdateChecker.NewerRelease(manifest, installed) is not { } release) + { + DiscardPending(); + return Result(UpdateOutcome.NothingToDo, installed, null, $"Den ventende opdatering er ikke nyere end {installed}; den er fjernet."); + } + + asset = release.Assets.FirstOrDefault(a => string.Equals(a.Platform, Platform.Current, StringComparison.OrdinalIgnoreCase)) + ?? throw new UpdateException($"Version {release.Version} har ingen pakke til {Platform.Current}."); + + // Copy first, then verify the copy: the pending folder is writable by the agent's account. + DeleteDirectory(options.PrivateRoot); + Directory.CreateDirectory(options.PrivateRoot); + File.Copy(package, PrivatePackage); + await using (var stream = File.OpenRead(PrivatePackage)) + { + UpdateFeed.Verify(asset.File, stream.Length, await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false)); + } + } + catch (UpdateException ex) + { + DiscardPending(); + return Result(UpdateOutcome.Failed, installed, null, "Opdateringen blev afvist: " + ex.Message); + } + + var target = manifest.Release!.Version; + var extracted = System.IO.Path.Combine(options.PrivateRoot, "files"); + ZipFile.ExtractToDirectory(PrivatePackage, extracted); + + var components = options.Components.Where(c => Directory.Exists(System.IO.Path.Combine(extracted, c.Name))).ToList(); + if (components.Count == 0) + { + DiscardPending(); + return Result(UpdateOutcome.Failed, installed, target, $"Pakken til {target} indeholder ingen af de installerede komponenter ({string.Join(", ", options.Components.Select(c => c.Name))})."); + } + + log($"Opdaterer {string.Join(", ", components.Select(c => c.Name))} fra {installed} til {target}."); + try + { + await StopAsync(components, cancellationToken).ConfigureAwait(false); + foreach (var component in components) + { + var backup = System.IO.Path.Combine(BackupRoot, component.Name); + DeleteDirectory(backup); + CopyDirectory(component.Path, backup, _ => true); + } + + foreach (var component in components) + { + CopyDirectory(System.IO.Path.Combine(extracted, component.Name), component.Path, relative => !IsPreserved(component.Path, relative)); + } + + await StartAsync(components, cancellationToken).ConfigureAwait(false); + if (await UnhealthyAsync(components, cancellationToken).ConfigureAwait(false) is { } failed) + { + throw new UpdateException($"{failed} kørte ikke stabilt efter opdateringen."); + } + } + catch (Exception ex) when (ex is UpdateException or IOException or UnauthorizedAccessException or InvalidOperationException or System.ComponentModel.Win32Exception) + { + log($"Opdateringen fejlede ({ex.Message}); ruller tilbage til {installed}."); + try + { + await StopAsync(components, cancellationToken).ConfigureAwait(false); + foreach (var component in components) + { + CopyDirectory(System.IO.Path.Combine(BackupRoot, component.Name), component.Path, _ => true); + } + + await StartAsync(components, cancellationToken).ConfigureAwait(false); + } + catch (Exception rollback) when (rollback is UpdateException or IOException or UnauthorizedAccessException or InvalidOperationException or System.ComponentModel.Win32Exception) + { + // Not retried automatically: an administrator has to look at it. + DiscardPending(); + return Result(UpdateOutcome.Failed, installed, target, + $"Opdateringen til {target} fejlede ({ex.Message}), og tilbagerulningen fejlede også ({rollback.Message}). Backup ligger i {BackupRoot}."); + } + + DiscardPending(); + return Result(UpdateOutcome.RolledBack, installed, target, $"Opdateringen til {target} fejlede ({ex.Message}) og blev rullet tilbage til {installed}."); + } + finally + { + DeleteDirectory(extracted); + File.Delete(PrivatePackage); + } + + DiscardPending(); + return Result(UpdateOutcome.Applied, installed, target, $"Opdateret fra {installed} til {target}."); + } + + private async Task StopAsync(IEnumerable components, CancellationToken cancellationToken) + { + foreach (var component in components) + { + if (component.AppOffline) + { + await File.WriteAllTextAsync(System.IO.Path.Combine(component.Path, AppOfflineFile), + "Opdaterer

SQLInsight Pro opdateres. Prøv igen om et øjeblik.

", cancellationToken).ConfigureAwait(false); + } + + if (component.Service is { Length: > 0 } service) + { + await services.StopAsync(service, cancellationToken).ConfigureAwait(false); + } + } + + if (components.Any(c => c.AppOffline)) + { + // IIS unloads the application when app_offline.htm appears; give it time to release the files. + await Task.Delay(TimeSpan.FromSeconds(5), time, cancellationToken).ConfigureAwait(false); + } + } + + private async Task StartAsync(IEnumerable components, CancellationToken cancellationToken) + { + foreach (var component in components) + { + if (component.Service is { Length: > 0 } service) + { + await services.StartAsync(service, cancellationToken).ConfigureAwait(false); + } + + File.Delete(System.IO.Path.Combine(component.Path, AppOfflineFile)); + } + } + + /// Watches the services for ; returns the first that stops. + private async Task UnhealthyAsync(IReadOnlyList components, CancellationToken cancellationToken) + { + var watched = components.Where(c => c.Service is { Length: > 0 }).Select(c => c.Service!).ToList(); + var until = time.GetUtcNow() + options.HealthCheckDuration; + do + { + foreach (var service in watched) + { + if (!await services.IsRunningAsync(service, cancellationToken).ConfigureAwait(false)) + { + return service; + } + } + + await Task.Delay(TimeSpan.FromMilliseconds(Math.Min(2000, Math.Max(10, options.HealthCheckDuration.TotalMilliseconds / 5))), time, cancellationToken).ConfigureAwait(false); + } + while (time.GetUtcNow() < until); + + return null; + } + + private bool IsPreserved(string installFolder, string relativePath) => + !relativePath.Contains(System.IO.Path.DirectorySeparatorChar, StringComparison.Ordinal) + && File.Exists(System.IO.Path.Combine(installFolder, relativePath)) + && options.PreservedFiles.Any(pattern => System.IO.Enumeration.FileSystemName.MatchesSimpleExpression(pattern, relativePath, ignoreCase: true)); + + private void DiscardPending() => DeleteDirectory(directories.Pending); + + private UpdateResult Result(UpdateOutcome outcome, AppVersion from, string? to, string message) => + new(outcome, from.ToString(), to, message, time.GetUtcNow()); + + private static void CopyDirectory(string source, string destination, Func include) + { + foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories)) + { + var relative = System.IO.Path.GetRelativePath(source, file); + if (!include(relative)) + { + continue; + } + + var target = System.IO.Path.Combine(destination, relative); + Directory.CreateDirectory(System.IO.Path.GetDirectoryName(target)!); + + // Write beside the target and rename over it: replaces a program file that is still mapped by a running + // process on Linux (a plain overwrite fails with "text file busy"), and never leaves half a file behind. + var temporary = target + ".sqlinsight-new"; + File.Copy(file, temporary, overwrite: true); + File.Move(temporary, target, overwrite: true); + } + } + + private static void DeleteDirectory(string path) + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + } +} diff --git a/src/SQLInsight.Updates/UpdateChecker.cs b/src/SQLInsight.Updates/UpdateChecker.cs new file mode 100644 index 0000000..e2369ea --- /dev/null +++ b/src/SQLInsight.Updates/UpdateChecker.cs @@ -0,0 +1,92 @@ +using System.Text; +using SQLInsight.Licensing; +using SQLInsight.Rules.Lifecycle; + +namespace SQLInsight.Updates; + +public sealed record CheckedManifest(UpdateManifest Manifest, string Token); + +/// +/// The unprivileged half of updating, run by the agent: reads the signed manifest, installs newer rule packs, and +/// downloads and stages application packages. It never replaces program files; does that. +/// +public sealed class UpdateChecker(UpdateFeed feed, TrustedKeys keys, UpdateDirectories directories, string channel, TimeProvider time) +{ + public string Channel { get; } = channel; + + public async Task FetchAsync(CancellationToken cancellationToken) + { + var token = await feed.ReadManifestAsync(Channel, cancellationToken).ConfigureAwait(false); + var manifest = ManifestVerifier.Verify(token, keys, Channel, time.GetUtcNow(), directories.LastManifest(Channel)); + directories.RememberManifest(Channel, manifest.PublishedAt); + return new CheckedManifest(manifest, token); + } + + /// Downloads the manifest's rule pack if it is newer than . Returns the new pack or null. + public async Task UpdateRulePackAsync(CheckedManifest source, RulePack current, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(current); + if (source.Manifest.RulePack is not { } reference || !Version.TryParse(reference.Version, out var offered) || offered <= current.Version) + { + return null; + } + + var bytes = await feed.DownloadAsync(reference.File, cancellationToken).ConfigureAwait(false); + RulePack pack; + try + { + pack = RulePack.Parse(bytes, $"regelpakke fra {source.Manifest.Channel}-kanalen, {source.Manifest.PublishedAt:yyyy-MM-dd}"); + } + catch (InvalidDataException ex) + { + throw new UpdateException(ex.Message, ex); + } + + if (pack.Version != offered) + { + throw new UpdateException($"Regelpakkens version ({pack.Version}) svarer ikke til manifestet ({offered})."); + } + + new RulePackStore(directories).Save(bytes, source.Token); + return pack; + } + + /// The release in the manifest if it is newer than the running version, otherwise null. + public static AppRelease? NewerRelease(UpdateManifest manifest, AppVersion installed) + { + ArgumentNullException.ThrowIfNull(manifest); + return manifest.Release is { } release && AppVersion.Parse(release.Version) > installed ? release : null; + } + + /// + /// Downloads the package for this platform into the pending folder, where the privileged updater picks it up. + /// Returns false if there is nothing newer than . + /// + public async Task StageAsync(CheckedManifest source, AppVersion installed, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(source); + if (NewerRelease(source.Manifest, installed) is not { } release) + { + return false; + } + + var asset = release.Assets.FirstOrDefault(a => string.Equals(a.Platform, Platform.Current, StringComparison.OrdinalIgnoreCase)) + ?? throw new UpdateException($"Version {release.Version} har ingen pakke til {Platform.Current}."); + + var package = Path.Combine(directories.Pending, "package.zip"); + var manifestFile = Path.Combine(directories.Pending, UpdateFeed.ManifestFile); + if (File.Exists(package) && File.Exists(manifestFile) && await File.ReadAllTextAsync(manifestFile, cancellationToken).ConfigureAwait(false) == source.Token) + { + return true; + } + + Directory.CreateDirectory(directories.Pending); + File.Delete(manifestFile); + var download = package + ".download"; + await feed.DownloadToFileAsync(asset.File, download, cancellationToken).ConfigureAwait(false); + File.Move(download, package, overwrite: true); + UpdateDirectories.WriteAtomic(manifestFile, Encoding.UTF8.GetBytes(source.Token)); + return true; + } +} diff --git a/src/SQLInsight.Updates/UpdateDirectories.cs b/src/SQLInsight.Updates/UpdateDirectories.cs new file mode 100644 index 0000000..305fde1 --- /dev/null +++ b/src/SQLInsight.Updates/UpdateDirectories.cs @@ -0,0 +1,79 @@ +using System.Text.Json; + +namespace SQLInsight.Updates; + +/// +/// Files the agent keeps between runs: the active rule pack, a staged application update, and the result of the last +/// update. Windows: %ProgramData%\SQLInsight; Linux: /var/lib/sqlinsight. The agent's service account can write here, +/// so the privileged updater treats everything in it as untrusted. +/// +public sealed class UpdateDirectories(string root) +{ + internal static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) + { + WriteIndented = true, + Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }, + }; + + public string Root { get; } = root; + public string RulePack => Path.Combine(Root, "rulepack"); + public string Pending => Path.Combine(Root, "updates", "pending"); + public string ResultFile => Path.Combine(Root, "updates", "last-result.json"); + public string StateFile => Path.Combine(Root, "updates", "state.json"); + + public static string DefaultRoot => OperatingSystem.IsWindows() + ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "SQLInsight") + : "/var/lib/sqlinsight"; + + /// The newest manifest date accepted per channel (rollback protection). + public DateTimeOffset? LastManifest(string channel) => + ReadJson>(StateFile) is { } state && state.TryGetValue(channel, out var at) ? at : null; + + public void RememberManifest(string channel, DateTimeOffset publishedAt) + { + var state = ReadJson>(StateFile) ?? []; + if (!state.TryGetValue(channel, out var at) || at < publishedAt) + { + state[channel] = publishedAt; + WriteJson(StateFile, state); + } + } + + public UpdateResult? LastResult() => ReadJson(ResultFile); + + public void SaveResult(UpdateResult result) => WriteJson(ResultFile, result); + + internal static T? ReadJson(string path) + where T : class + { + try + { + return File.Exists(path) ? JsonSerializer.Deserialize(File.ReadAllBytes(path), Json) : null; + } + catch (JsonException) + { + return null; + } + } + + internal static void WriteJson(string path, T value) => WriteAtomic(path, JsonSerializer.SerializeToUtf8Bytes(value, Json)); + + /// Writes to a temporary file and moves it into place, so a reader never sees half a file. + internal static void WriteAtomic(string path, byte[] bytes) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + var temp = path + ".tmp"; + File.WriteAllBytes(temp, bytes); + File.Move(temp, path, overwrite: true); + } +} + +public enum UpdateOutcome +{ + NothingToDo, + Applied, + RolledBack, + Failed, +} + +public sealed record UpdateResult(UpdateOutcome Outcome, string? FromVersion, string? ToVersion, string Message, DateTimeOffset At); diff --git a/src/SQLInsight.Updates/UpdateFeed.cs b/src/SQLInsight.Updates/UpdateFeed.cs new file mode 100644 index 0000000..ee1bf96 --- /dev/null +++ b/src/SQLInsight.Updates/UpdateFeed.cs @@ -0,0 +1,151 @@ +using System.Security.Cryptography; + +namespace SQLInsight.Updates; + +/// +/// Where updates come from: an HTTPS URL, or a local folder or file share (offline and air-gapped installations). +/// Layout: <channel>/manifest.sqli plus the files the manifests point to. +/// +public sealed class UpdateFeed +{ + public const string ManifestFile = "manifest.sqli"; + private const long MaxManifestBytes = 64 * 1024; + + private readonly Uri? _baseUri; + private readonly string? _directory; + private readonly HttpClient? _http; + + public UpdateFeed(string location, HttpClient? http) + { + ArgumentException.ThrowIfNullOrWhiteSpace(location); + if (Uri.TryCreate(location, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp)) + { + if (uri.Scheme == Uri.UriSchemeHttp && !uri.IsLoopback) + { + throw new UpdateException("Opdateringsfeedet skal bruge https."); + } + + _baseUri = uri.AbsoluteUri.EndsWith('/') ? uri : new Uri(uri.AbsoluteUri + "/"); + _http = http ?? throw new ArgumentNullException(nameof(http)); + } + else + { + _directory = uri is { IsFile: true } ? uri.LocalPath : location; + } + + Location = location; + } + + public string Location { get; } + + public async Task ReadManifestAsync(string channel, CancellationToken cancellationToken) + { + FeedPath.Validate(channel); + var bytes = await ReadAsync($"{channel}/{ManifestFile}", MaxManifestBytes, cancellationToken).ConfigureAwait(false); + return System.Text.Encoding.UTF8.GetString(bytes).Trim(); + } + + /// Reads a small file into memory and checks size and SHA-256 against the signed manifest. + public async Task DownloadAsync(FileRef file, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(file); + FeedPath.Validate(file.Path); + var bytes = await ReadAsync(file.Path, file.Size, cancellationToken).ConfigureAwait(false); + Verify(file, bytes.Length, SHA256.HashData(bytes)); + return bytes; + } + + /// Streams a package to , checking size and SHA-256. The file is deleted if it does not match. + public async Task DownloadToFileAsync(FileRef file, string destination, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(file); + FeedPath.Validate(file.Path); + try + { + await using (var source = await OpenAsync(file.Path, cancellationToken).ConfigureAwait(false)) + await using (var target = File.Create(destination)) + { + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + var buffer = new byte[81920]; + long total = 0; + int read; + while ((read = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0) + { + total += read; + if (total > file.Size) + { + throw new UpdateException($"{file.Path} er større end angivet i manifestet."); + } + + hash.AppendData(buffer, 0, read); + await target.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false); + } + + Verify(file, total, hash.GetHashAndReset()); + } + } + catch + { + File.Delete(destination); + throw; + } + } + + public static void Verify(FileRef file, long size, byte[] sha256) + { + ArgumentNullException.ThrowIfNull(file); + if (size != file.Size || !string.Equals(Convert.ToHexString(sha256), file.Sha256, StringComparison.OrdinalIgnoreCase)) + { + throw new UpdateException($"{file.Path} matcher ikke manifestets SHA-256. Filen er beskadiget eller ændret og bliver ikke brugt."); + } + } + + private async Task ReadAsync(string path, long maxBytes, CancellationToken cancellationToken) + { + await using var stream = await OpenAsync(path, cancellationToken).ConfigureAwait(false); + using var buffer = new MemoryStream(); + var chunk = new byte[16384]; + int read; + while ((read = await stream.ReadAsync(chunk, cancellationToken).ConfigureAwait(false)) > 0) + { + if (buffer.Length + read > maxBytes) + { + throw new UpdateException($"{path} er større end tilladt."); + } + + buffer.Write(chunk, 0, read); + } + + return buffer.ToArray(); + } + + private async Task OpenAsync(string path, CancellationToken cancellationToken) + { + if (_directory is not null) + { + var full = Path.GetFullPath(Path.Combine(_directory, path.Replace('/', Path.DirectorySeparatorChar))); + if (!File.Exists(full)) + { + throw new UpdateException($"Filen {path} findes ikke i opdateringsfeedet {_directory}."); + } + + return File.OpenRead(full); + } + + try + { + var response = await _http!.GetAsync(new Uri(_baseUri!, path), HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + response.Dispose(); + throw new UpdateException($"Opdateringsfeedet svarede {(int)response.StatusCode} for {path}."); + } + + return await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + } + catch (HttpRequestException ex) + { + throw new UpdateException($"Opdateringsfeedet kunne ikke nås: {ex.Message}", ex); + } + } +} diff --git a/src/SQLInsight.Updates/UpdateManifest.cs b/src/SQLInsight.Updates/UpdateManifest.cs new file mode 100644 index 0000000..5ae5009 --- /dev/null +++ b/src/SQLInsight.Updates/UpdateManifest.cs @@ -0,0 +1,45 @@ +namespace SQLInsight.Updates; + +/// +/// What is published on a channel (stable or preview). Signed by the vendor as a SQLI1.… document; every file it +/// points to is pinned by SHA-256, so a feed server or file share only needs to be available, not trusted. +/// +public sealed record UpdateManifest +{ + public const string StableChannel = "stable"; + public const string PreviewChannel = "preview"; + + public required string KeyId { get; init; } + public required string Channel { get; init; } + public required DateTimeOffset PublishedAt { get; init; } + + /// After this the manifest is refused, so an old manifest cannot be replayed forever to hide newer updates. + public required DateTimeOffset ExpiresAt { get; init; } + + public RulePackRef? RulePack { get; init; } + public AppRelease? Release { get; init; } +} + +/// A file in the feed. is relative to the feed root. +public sealed record FileRef(string Path, string Sha256, long Size); + +public sealed record RulePackRef(string Version, FileRef File); + +public sealed record AppRelease +{ + public required string Version { get; init; } + public required DateOnly Released { get; init; } + + /// Contains security fixes; shown prominently. + public bool Security { get; init; } + + public string? NotesUrl { get; init; } + + /// One package per platform, e.g. win-x64 and linux-x64. + public IReadOnlyList 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
+ } + + + + + + + @if (_update.LastResultOutcome is { } outcome) + { + + } +
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)
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 + { + + + + @if (_hub.Customer is { } customer) + { + + } + + + @if (_hub.LastError is { } hubError) + { + + } +
Deling med SQLInsight Hub@(_hub.Enabled ? $"Til ({_hub.HubUrl})" : "Fra: intet forlader netværket")
Licens@_hub.LicenseMessage
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ø
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)) +
@Pretty(s.Payload)
+
+ } +
+} + +@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 @@
+