ac4c4d40dc
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
136 lines
7.2 KiB
C#
136 lines
7.2 KiB
C#
// #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));
|