sqlinsight-pro/tools/SQLInsight.LicenseTool/Program.cs
Claude 3121b598f9
Some checks are pending
CI / Build og unit tests (push) Waiting to run
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2019-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2022-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Blocked by required conditions
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
2026-09-29 09:19:41 +00:00

194 lines
7.8 KiB
C#

// 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(),
};
int Usage()
{
Console.Error.WriteLine("""
SQLInsight.LicenseTool
keygen --out <mappe> Opret nøglepar (privat PEM + offentlig nøgle)
issue --key <privat.pem> --kid <id> --customer <navn> --plan <Professional|Enterprise>
--expires <yyyy-mm-dd> [--instances <antal>] Udsted licens
verify --public <base64> --kid <id> <licens> Verificér licens
rulepack --version <yyyy.m.d[.n]> --out <pack.json>
[--catalog build-catalog.json] [--lifecycle support-lifecycle.json]
Byg regelpakke af katalogfilerne
publish --key <privat.pem> --kid <id> --feed <mappe> --channel <stable|preview> [--days 90]
[--rulepack <pack.json>]
[--release <version> --asset win-x64=<zip> [--asset linux-x64=<zip>] [--notes <url>] [--security]]
Signér kanalens manifest; det, der ikke angives,
overtages fra det eksisterende manifest
""");
return 1;
}
int KeyGen(string folder)
{
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var kid = "k" + DateTime.UtcNow.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
Directory.CreateDirectory(folder);
var privatePath = Path.Combine(folder, $"sqlinsight-{kid}.private.pem");
File.WriteAllText(privatePath, key.ExportPkcs8PrivateKeyPem());
var publicKey = Convert.ToBase64String(key.ExportSubjectPublicKeyInfo());
Console.WriteLine($"Privat nøgle: {privatePath} (opbevar sikkert, aldrig i git)");
Console.WriteLine("Tilføj til src/SQLInsight.Licensing/trusted-keys.json:");
Console.WriteLine($$"""{ "id": "{{kid}}", "publicKey": "{{publicKey}}" }""");
return 0;
}
int Issue()
{
using var key = ECDsa.Create();
key.ImportFromPem(File.ReadAllText(Required("--key")));
var license = new License
{
LicenseId = Guid.NewGuid().ToString("N"),
Customer = Required("--customer"),
Plan = Enum.Parse<LicensePlan>(Required("--plan"), ignoreCase: true),
IssuedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.Parse(Required("--expires") + "T23:59:59Z", CultureInfo.InvariantCulture),
MaxInstances = int.Parse(Option("--instances") ?? "0", CultureInfo.InvariantCulture),
KeyId = Required("--kid"),
};
Console.WriteLine(SignedDocument.Sign(license, key));
return 0;
}
int Verify()
{
var keys = new TrustedKeys([KeyValuePair.Create(Required("--kid"), Required("--public"))]);
var status = new LicenseVerifier(keys, TimeProvider.System).Check(args[^1]);
Console.WriteLine($"{status.State}: {status.Message}");
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<UpdateManifest>(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<ReleaseAsset>();
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 <platform>=<zip>.");
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);
return i >= 0 && i + 1 < args.Length ? args[i + 1] : null;
}
string Required(string name) => Option(name) ?? throw new ArgumentException($"{name} mangler.");