Files
lmxopcua/scripts/validation/opcua-windows-service/Seeder/Program.cs
T
Joseph Doherty ac4c4d40dc
v2-ci / unit-tests (push) Has been cancelled
test(validation): #800 Windows service gate harness + windev results (SDK 1.5 -> 2.0.1 in place, rollback)
Adds scripts/validation/opcua-windows-service/: a phased PowerShell gate (Invoke-ServiceGate.ps1)
that installs the known-good build as the real OtOpcUaHost service via Install-Services.ps1, seeds
LocalDb through the production cache API (Seeder, built against the baseline tree), upgrades in
place with config/PKI/LocalDb carried with their ACLs, starts/stops through the SCM, runs a strict
SDK 2 probe matrix (Sign + SignAndEncrypt, valid/wrong/unknown/disabled/anonymous keys, untrusted
client and server), exercises the ScriptWorker isolation, rolls back and cleans up.

windev 2026-11-12 (efb9197a -> 438484fd -> efb9197a): app cert CEF9D91F... reused byte-for-byte
with identical PKI SDDL on every start, all gating checks pass, 0 secrets in logs. Findings filed:
#879 (None endpoint cannot be dropped), #880 (SDK 2 client 65-char key vs SDK 1.5 server:
rollback/QA risk), #883 (Install-ScriptWorkerIsolation.ps1 fails on pwsh 7 and 5.1; runtime
isolation proven with the patch in the evidence directory).

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-12 10:28:49 -05:00

55 lines
4.0 KiB
C#

// #800 Windows service gate: seed a deployment artifact into an existing (service-created) LocalDb through
// the production cache API. Usage: Seeder <localdb-path>. Credentials come from the environment only:
// GATE_API_KEY (enabled key, production 65-character form), GATE_API_KEY_SHORT (enabled key whose secret is
// short enough that the whole password stays <= 64 bytes, so an SDK 2 client sends it in the legacy
// UserName-token format instead of RsaEncryptedSecret), GATE_API_KEY_DISABLED (carried with Enabled=false) and
// Security__ApiKeys__Pepper. Nothing secret is printed.
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache;
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(60));
var localDb = Path.GetFullPath(args[0]);
if (!File.Exists(localDb)) throw new InvalidOperationException("The LocalDb must already exist (created by the service).");
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?> { ["LocalDb:Path"] = localDb }).Build();
await using var provider = new ServiceCollection().AddLogging().AddOtOpcUaLocalDb(config).BuildServiceProvider();
var pepper = Environment.GetEnvironmentVariable("Security__ApiKeys__Pepper") ?? throw new InvalidOperationException("Missing pepper.");
object Key(string variable, string name, bool enabled)
{
var presented = Environment.GetEnvironmentVariable(variable) ?? throw new InvalidOperationException("Missing " + variable + ".");
if (!ApiKeySecret.TryParse(presented, out var id, out var secret)) throw new InvalidOperationException("Invalid key format in " + variable + ".");
return new { ApiKeyId = id, Name = name, Principal = name, Enabled = enabled,
SecretHash = ApiKeySecret.ComputeHash(secret, pepper), CreatedUtc = DateTime.UtcNow };
}
const string source = "return 800;";
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
{
Clusters = new[] { new { ClusterId = "gate800", Name = "Gate800" } },
RawFolders = new[] { new { RawFolderId = "gate800", Name = "Gate800", ClusterId = "gate800" } },
UnsAreas = new[] { new { UnsAreaId = "gate800-area", Name = "GateArea", ClusterId = "gate800" } },
UnsLines = new[] { new { UnsLineId = "gate800-line", UnsAreaId = "gate800-area", Name = "GateLine" } },
Equipment = new[] { new { EquipmentId = "EQ-000000000800", UnsLineId = "gate800-line", Name = "GateEquipment", MachineCode = "GATE800" } },
Scripts = new[] { new { ScriptId = "gate800-script", Name = "Gate800Script", SourceCode = source,
SourceHash = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(source))), Language = "CSharp" } },
VirtualTags = new[] { new { VirtualTagId = "gate800-vt", EquipmentId = "EQ-000000000800", Name = "GateValue",
DataType = "Int32", ScriptId = "gate800-script", ChangeTriggered = false, TimerIntervalMs = 1000, Historize = false, Enabled = true } },
ApiKeys = new[] { Key("GATE_API_KEY", "gate800-enabled", true), Key("GATE_API_KEY_SHORT", "gate800-short", true),
Key("GATE_API_KEY_DISABLED", "gate800-disabled", false) },
NodeAcls = new[]
{
new { NodeAclId = "gate800-read", ClusterId = "gate800", Principal = "gate800-enabled", ScopeKind = 0, ScopeId = (string?)null, PermissionFlags = 7 },
new { NodeAclId = "gate800-short-read", ClusterId = "gate800", Principal = "gate800-short", ScopeKind = 0, ScopeId = (string?)null, PermissionFlags = 7 },
},
});
var revision = Convert.ToHexStringLower(SHA256.HashData(artifact));
await provider.GetRequiredService<IDeploymentArtifactCache>().StoreAsync("gate800",
ZB.MOM.WW.OtOpcUa.Commons.Types.DeploymentId.NewId().ToString(), revision, artifact, deadline.Token);
Console.WriteLine(JsonSerializer.Serialize(new { result = "seeded-through-public-cache", localDb, revision, bytes = artifact.Length }));