Some checks are pending
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 / Build og unit tests (push) Waiting to run
CI / MSI-installer (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Blocked by required conditions
installer/SQLInsight.wxs (WiX 3 syntax) builds on Linux with wixl (installer/build-msi.sh) and in Forgejo CI on every v* tag. Agent and Web are installed self-contained as Windows services under virtual service accounts (or a gMSA), with a settings page in the wizard or MSI properties for silent installs; the values go to sqlinsight.ini, read after appsettings.json. The data folder is locked to SYSTEM, Administrators and the agent account, the event log source is registered, the web port is opened in the firewall, and the automatic update task is an optional feature. Upgrades happen in place, keep edited settings and the chosen features; uninstall keeps the settings. msi-tables.py writes what wixl lacks (component and feature conditions, Permanent/NeverOverwrite, vital services, REG_EXPAND_SZ) and verify-msi.py checks the result (sequencing, deferred actions, ASCII only). test-msi-wine.sh runs install, upgrade and uninstall under Wine. Installations made with the MSI get new versions as MSI packages run by the update task, so Windows Installer stays in charge of the files. The example instance is removed from the shipped appsettings.json, and the CLI writes UTF-8 to the console. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
204 lines
8.5 KiB
C#
204 lines
8.5 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] [--community rules/community]
|
|
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 win-x64-msi=<msi>] [--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 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<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}{Path.GetExtension(file).ToLowerInvariant()}", 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.");
|