test(validation): #800 Windows service gate harness + windev results (SDK 1.5 -> 2.0.1 in place, rollback)
v2-ci / unit-tests (push) Has been cancelled

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
This commit is contained in:
Joseph Doherty
2026-11-12 10:28:48 -05:00
parent 0f001bccdb
commit ac4c4d40dc
55 changed files with 13754 additions and 0 deletions
@@ -0,0 +1,135 @@
// #800 Windows service gate probe. Peer auto-accept is always false.
// prepare <pki> [applicationName] create the client application certificate
// endpoints <pki> <url> list advertised endpoints; fail if None or anonymous is offered
// probe <pki> <url> <none|sign|encrypt> <expected> expected = allow | key-deny | peer-deny | anon-deny
// The API key comes from GATE_PROBE_KEY (never an argument). One JSON line is printed per run.
using System.Text;
using System.Text.Json;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Configuration;
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(60));
var ct = deadline.Token;
var mode = args[0];
var root = Path.GetFullPath(args[1]);
var telemetry = DefaultTelemetry.Create(_ => { });
var clientName = mode == "prepare" && args.Length > 2 ? args[2] : "Gate800Client";
var security = new SecurityConfiguration
{
ApplicationCertificate = new CertificateIdentifier { StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(root, "own"), SubjectName = "CN=" + clientName },
TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(root, "issuer") },
TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(root, "trusted") },
RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(root, "rejected") },
AutoAcceptUntrustedCertificates = false,
};
var configuration = new ApplicationConfiguration(telemetry)
{
ApplicationName = clientName, ApplicationUri = "urn:localhost:" + clientName,
ApplicationType = ApplicationType.Client, SecurityConfiguration = security,
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
};
await using var application = new ApplicationInstance(telemetry)
{ ApplicationName = configuration.ApplicationName, ApplicationType = ApplicationType.Client, ApplicationConfiguration = configuration };
await configuration.ValidateAsync(ApplicationType.Client, ct);
await application.CheckApplicationInstanceCertificatesAsync(true, lifeTimeInMonths: 12, ct);
if (mode == "prepare")
{
Emit(new { mode, result = "prepared", clientName, autoAccept = security.AutoAcceptUntrustedCertificates });
return 0;
}
var url = args[2];
List<EndpointDescription> advertised;
using (var discovery = await DiscoveryClient.CreateAsync(configuration, new Uri(url), DiagnosticsMasks.None, ct))
advertised = (await discovery.GetEndpointsAsync(default, ct)).ToList();
var summary = advertised.Select(e => new
{
mode = e.SecurityMode.ToString(),
policy = (e.SecurityPolicyUri ?? "").Split('#').Last(),
tokens = TokenTypes(e),
certSha1 = e.ServerCertificate.Length > 0 ? Convert.ToHexString(System.Security.Cryptography.SHA1.HashData(e.ServerCertificate.ToArray())) : "",
}).ToList();
if (mode == "endpoints")
{
var insecure = summary.Any(e => e.mode == nameof(MessageSecurityMode.None));
var anonymous = summary.Any(e => e.tokens.Contains(nameof(UserTokenType.Anonymous)));
Emit(new { mode, result = insecure || anonymous ? "insecure-offered" : "secure-only", endpoints = summary });
return insecure || anonymous ? 3 : 0;
}
var wanted = args[3] switch { "sign" => MessageSecurityMode.Sign, "none" => MessageSecurityMode.None, _ => MessageSecurityMode.SignAndEncrypt };
var wantedPolicy = wanted == MessageSecurityMode.None ? SecurityPolicies.None : SecurityPolicies.Basic256Sha256;
var expected = args[4];
var selected = advertised.FirstOrDefault(e => e.SecurityMode == wanted && e.SecurityPolicyUri == wantedPolicy)
?? throw new InvalidOperationException("No " + wantedPolicy + " " + wanted + " endpoint advertised.");
var identity = expected == "anon-deny"
? new UserIdentity()
: new UserIdentity("apikey", Encoding.UTF8.GetBytes(Environment.GetEnvironmentVariable("GATE_PROBE_KEY") ?? throw new InvalidOperationException("Missing GATE_PROBE_KEY.")));
try
{
using var session = await new DefaultSessionFactory(telemetry).CreateAsync(configuration,
new ConfiguredEndpoint(null, selected, EndpointConfiguration.Create(configuration)), false, true,
"Gate800Probe", 60_000, identity, default, ct);
if (expected != "allow")
{
Emit(new { mode, securityMode = wanted.ToString(), expected, result = "UNEXPECTED-ADMIT" });
await session.CloseAsync(ct);
return 4;
}
var uns = session.NamespaceUris.GetIndex("https://zb.com/otopcua/uns");
var reads = new[]
{
new ReadValueId { NodeId = VariableIds.Server_ServerStatus_State, AttributeId = Attributes.Value },
new ReadValueId { NodeId = VariableIds.Server_ServiceLevel, AttributeId = Attributes.Value },
new ReadValueId { NodeId = new NodeId("EQ-000000000800/GateValue", (ushort)Math.Max(uns, 0)), AttributeId = Attributes.Value },
};
// The timer-driven virtual tag needs a moment after boot; retry its read for up to 20 s.
List<DataValue>? results = null;
for (var attempt = 0; attempt < 20; attempt++)
{
var response = await session.ReadAsync(null, 0, TimestampsToReturn.Both, reads, ct);
results = response.Results.ToList();
if (uns >= 0 && StatusCode.IsGood(results[2].StatusCode)) break;
await Task.Delay(1000, ct);
}
var state = results![0];
var level = results[1];
var vt = results[2];
var ok = StatusCode.IsGood(state.StatusCode) && StatusCode.IsGood(level.StatusCode);
Emit(new
{
mode, securityMode = wanted.ToString(), policy = wantedPolicy.Split('#').Last(), expected, result = ok ? "allowed" : "READ-FAILED",
serverState = $"{state.Value} ({state.StatusCode})",
serviceLevel = $"{level.Value} ({level.StatusCode})",
unsNamespaceIndex = uns,
virtualTag = $"{vt.Value} ({vt.StatusCode})",
serverCertSha1 = summary.First(e => e.mode == wanted.ToString()).certSha1,
autoAccept = false,
});
await session.CloseAsync(ct);
return ok ? 0 : 5;
}
catch (ServiceResultException error) when (expected != "allow")
{
var accepted = expected switch
{
"key-deny" => new[] { StatusCodes.BadUserAccessDenied, StatusCodes.BadIdentityTokenRejected, StatusCodes.BadIdentityTokenInvalid },
"anon-deny" => new[] { StatusCodes.BadUserAccessDenied, StatusCodes.BadIdentityTokenRejected, StatusCodes.BadIdentityTokenInvalid, StatusCodes.BadSecurityPolicyRejected },
_ => new[] { StatusCodes.BadCertificateUntrusted, StatusCodes.BadSecurityChecksFailed, StatusCodes.BadSecureChannelClosed },
};
var good = accepted.Contains(error.StatusCode);
Emit(new { mode, securityMode = wanted.ToString(), expected, result = good ? "denied" : "DENIED-WITH-UNEXPECTED-CODE",
status = error.StatusCode.ToString(), autoAccept = false });
return good ? 0 : 6;
}
static string[] TokenTypes(EndpointDescription e)
{
var types = new List<string>();
foreach (var t in e.UserIdentityTokens) types.Add(t.TokenType.ToString());
return types.Distinct().ToArray();
}
static void Emit(object value) => Console.WriteLine(JsonSerializer.Serialize(value));