// 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 Opret nøglepar (privat PEM + offentlig nøgle) 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] [--community rules/community] 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; } 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(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 communityFolder = Option("--community") ?? Path.Combine(root, "rules", "community"); var community = SQLInsight.Core.CustomRules.CustomRuleLoader.LoadDirectory(communityFolder, "community"); if (community.Errors.Count > 0) { community.Errors.ToList().ForEach(e => Console.Error.WriteLine(e)); return 2; } var communityJson = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(community.Rules, SQLInsight.Core.CustomRules.CustomRuleDefinition.Json); var pack = RulePack.Create(Version.Parse(Required("--version")), File.ReadAllBytes(catalog), File.ReadAllBytes(lifecycle), communityJson); Console.WriteLine($"{community.Rules.Count} community-regler fra {communityFolder}"); 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); return i >= 0 && i + 1 < args.Length ? args[i + 1] : null; } string Required(string name) => Option(name) ?? throw new ArgumentException($"{name} mangler.");