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
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:
@@ -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));
|
||||
Reference in New Issue
Block a user