Compare commits

...

11 Commits

10 changed files with 914 additions and 1 deletions
@@ -0,0 +1,77 @@
# HistorianGateway consumer TLS migration — issue 131
`consumer-1.0.0.patch` is a reviewable consumer change, kept separate from the
buildable application in this checkout. Real paired Client/Contracts 1.0.0
packages are now published and the isolated owning-project proof passed (RESULTS.md).
The application in this checkout remains pinned to published 0.4.0 until the patch
is applied by the coordinator.
The patch changes both central package pins to 1.0.0 and maps the new option as:
```csharp
AllowUntrustedServerCertificate = options.UseTls && options.AllowUntrustedServerCertificate,
```
The old inverse-mapping and obsolete h2c-history comments are removed. The existing
h2c construction test now includes a stale opt-out as well as a stale CA path.
Three new bounded local wire tests verify behavior through the real adapter and
published client API, without reflection or replacing the transport handler:
- TLS with normal defaults rejects a self-signed server before the metadata RPC.
- Explicit opt-out permits that self-signed server shape and executes one RPC.
- Plaintext with stale opt-out and CA settings executes one RPC, proving TLS-only
settings were not forwarded to the client's validating options.
The local server uses an ephemeral loopback port and a fresh short-lived
self-signed certificate. RPC completion is bounded to 15 seconds; the consumer
call timeout is two seconds. No gateway, deployment, or global trust store is used.
Before applying the patch, query the live Gitea NuGet version indexes for BOTH
packages and require actual stable 1.0.0. Source `main` having the new API is not a
package release. At initial inspection, both feed indexes listed exactly
0.4.0, 0.3.0, 0.2.0, 0.1.0, and the new API was only in Unreleased at HG commit
`e4321915ebc5e0704a68ecad21b85c1cc1c80d6b`.
```bash
git apply --check scripts/validation/historiangw-131/consumer-1.0.0.patch
git apply scripts/validation/historiangw-131/consumer-1.0.0.patch
# From the consumer checkout, using a fresh run/results directory:
hg131_results=/absolute/fresh/results
test ! -e "$hg131_results" || exit 2
mkdir -p "$hg131_results" || exit
hg131_test_status=0
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests \
-c Release --logger trx --results-directory "$hg131_results" || hg131_test_status=$?
hg131_guard_status=0
MIN_EXECUTED=1 bash scripts/ci/assert-not-all-skipped.sh "$hg131_results"/*.trx \
|| hg131_guard_status=$?
if [ "$hg131_test_status" -ne 0 ]; then exit "$hg131_test_status"; fi
exit "$hg131_guard_status"
```
Published-package verification must use the real 1.0.0 packages, preserve all test
and guard exits, run the full owning project, and complete the consumer solution
build/review before issue 131 is closed. The patch is transport-only and can be
applied independently of the OPC UA SDK 2 rollout.
For isolated validation, use a fresh output directory OUTSIDE both repositories
so the archived HG projects do not inherit the consumer's central package policy:
```bash
python3 scripts/validation/historiangw-131/validate.py \
--consumer-root "$PWD" \
--hg-root /Users/dohertj2/Desktop/HistorianGateway \
--output /tmp/historiangw-131-fresh-validation
```
The script checks both live feed indexes. If both actual stable 1.0.0 packages are
available, it validates them in an archived consumer copy. Otherwise it archives
exact HG commit e432191 and temporarily replaces ONLY the archived Gateway csproj's
two package references with source project references. This equivalent source
validation does not create or publish any fake package/version. Restore uses an
independent copy-on-write/copy cache, isolated HTTP cache and CLI home. Neither
user checkout is built or edited. Full owning-suite TRX, required TLS failure
classification, command exits and execution guard are retained under the task
output. Published mode additionally checks package assets/provenance, absence of
source overrides, and exact package-to-test-bin DLL hashes. Never treat source
validation as stable published-package verification.
@@ -0,0 +1,98 @@
# HG issue 131 — actual published 1.0.0 verification
Both real stable **Client 1.0.0** and **Contracts 1.0.0** were observed in the live
Gitea indexes at 2026-11-10 19:35:54 UTC and restored into a fresh isolated cache.
The future consumer patch was applied only to a separate SDK 2 consumer archive.
Application files in this working branch remain on 0.4.0, unchanged.
- Full Gateway owning project: **529 total, 523 passed, 0 failed, 6 existing live skips**.
- Restore, test and repository execution-count guard exits: **0 / 0 / 0**.
- All **3 real TLS/h2c wire cases passed** against the published packages.
- Strict default rejected with AuthenticationException **UntrustedRoot**, before RPC.
- Explicit opt-out and plaintext/stale-TLS-settings cases each completed one RPC.
- Both assets entries are **type=package, version=1.0.0**, from the actual Gitea
source. No source ProjectReference override: the archived Gateway csproj is
byte-identical to the normal consumer csproj.
- The only package folder is the task-isolated cache. Both copied test-bin DLL
hashes equal the package DLL hashes. No package was packed or published here.
Actual package RepositoryCommit for BOTH packages:
`ea9f21c850fb618767e12690924f9966f1e433ac`.
Consumer archive base: `4ea379fa92e571492ff9f49889a5eb91e3ad1e1a`.
Applied patch SHA-256: `9452ee82b2bace7828a384b9f6048e9c931e8b4ec2078e0ee1baa175d0652fac`.
| Package | NuGet package SHA-256 | Consumed DLL SHA-256 |
|---|---|---|
| ZB.MOM.WW.HistorianGateway.Client 1.0.0 | `0a3f2836727671c07c0c393e705cc9737d2ed44258213a51fb4acf0d0ab7129e` | `b89a02e0d228ec549c4dfb755694f7f67b573a6077f06ff2781165e884fc5e12` |
| ZB.MOM.WW.HistorianGateway.Contracts 1.0.0 | `83ffd12bcbbf919c973ba522560e10aa93a4cb8c686a3232030a2cfa5f00995e` | `e60d3d4650be5d251982f7847404daf1b666f61d56e2b0ccb844af8677e97743` |
Proof metadata/source/hash/readback checks:
`/tmp/historiangw-131-published-20261110T193638Z/published-package-proof.json`.
Full run metadata (including typed TLS error and package readback):
`/tmp/historiangw-131-published-20261110T193638Z/metadata.json`.
Fresh actual-package TRX: `/tmp/historiangw-131-published-20261110T193638Z/test-results`.
Restore/test/guard logs: `/tmp/historiangw-131-published-20261110T193638Z`.
This completes the isolated SDK 2 owning-project proof. The coordinator separately
owns the consumer-master full solution build, Astra review, landing/push and issue
131 comment/closure. No issue action, package publication, or HG user-checkout
mutation was performed by this validation lane. The live AVEVA fixture skips do
not substitute for the three executed local wire tests.
---
# Earlier archived-source validation
Future consumer patch validated against exact archived HG source commit
`e4321915ebc5e0704a68ecad21b85c1cc1c80d6b`. Consumer archive base:
`18c22cf7eb042126226e540d9b979907328055c3`. Patch SHA-256:
`9452ee82b2bace7828a384b9f6048e9c931e8b4ec2078e0ee1baa175d0652fac`.
## Scope and isolation
Mode: **unreleased source project references**, not published-package verification.
At that earlier source-validation time, both feed indexes still listed only
0.4.0, 0.3.0, 0.2.0, 0.1.0. The published-package run below supersedes that pending
release-availability status.
The user's HG checkout was only read by git archive. Only the disposable archived
consumer Gateway csproj had its two package references replaced by project
references into the exact archived HG source. No package was packed or published;
no fake 1.0.0 was inserted into any feed/cache. Restore used an independent copied
NuGet cache, isolated HTTP cache and CLI home. The normal consumer files remain
on published 0.4.0.
## Actual execution
- Full Gateway owning project: **529 total, 523 passed, 0 failed, 6 existing live skips**.
- Test exit **0**; repository nonzero-execution guard exit **0**.
- All three new local wire tests passed; no new skip or expected application-result change.
- Strict default: base HistorianGatewayException -> RpcException Status Internal ->
HttpRequestException -> AuthenticationException, certificate-chain **UntrustedRoot**.
The test explicitly requires the typed TLS authentication failure; RPC calls = **0**.
- Explicit TLS opt-out: actual metadata RPC succeeds; calls = **1**.
- Plaintext with stale opt-out and nonexistent CA path: actual metadata RPC succeeds;
calls = **1**.
The first source run compiled and executed but failed one new assertion because
it predicted the narrower HistorianGatewayUnavailableException type. The actual
TLS exception is the public base HistorianGatewayException; the corrected assertion
retains the required AuthenticationException graph and zero-RPC check. The fresh
second unfiltered run above passed. The six skipped cases are the existing AVEVA
live fixture tests; they are not a substitute for TLS wire coverage.
Completed UTC: `2026-11-10T19:33:17.272468+00:00`.
Actual metadata, typed TLS exception output and binary hashes:
`/tmp/historiangw-131-validation-20261110T193106Z/metadata-final.json`.
Fresh second-run TRX: `/tmp/historiangw-131-validation-20261110T193106Z/test-results2`.
Test log: `/tmp/historiangw-131-validation-20261110T193106Z/test2.log`.
Initial failed run and restore logs are retained in the same task directory.
Archived-source Client DLL SHA-256:
`87e995a43c63debdd8f648a8feb8d19d9110b45eb7b2b6906159077d90109862`.
Archived-source Contracts DLL SHA-256:
`ebaaa43ffc485288054eb864a59ec59264d8d9c4ea6871319fd7995192370751`.
That earlier run consumed source only. Actual stable-package proof is recorded
above. Consumer-master solution build/review/landing and issue handling are owned
by the coordinator; no issue comment/close was performed here.
@@ -0,0 +1,226 @@
diff --git a/Directory.Packages.props b/Directory.Packages.props
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -242,7 +242,7 @@
<PackageVersion Include="ZB.MOM.WW.Secrets.Replicator.AkkaDotNet" Version="0.6.2" />
<PackageVersion Include="ZB.MOM.WW.Audit" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Theme" Version="0.5.0" />
- <PackageVersion Include="ZB.MOM.WW.HistorianGateway.Client" Version="0.4.0" />
- <PackageVersion Include="ZB.MOM.WW.HistorianGateway.Contracts" Version="0.4.0" />
+ <PackageVersion Include="ZB.MOM.WW.HistorianGateway.Client" Version="1.0.0" />
+ <PackageVersion Include="ZB.MOM.WW.HistorianGateway.Contracts" Version="1.0.0" />
</ItemGroup>
</Project>
\ No newline at end of file
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/HistorianGatewayClientAdapter.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/HistorianGatewayClientAdapter.cs
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/HistorianGatewayClientAdapter.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/HistorianGatewayClientAdapter.cs
@@ -57,23 +57,15 @@
$"ServerHistorian:Endpoint must be an absolute http(s) URI (e.g. https://host:5222); got '{options.Endpoint}'.");
}
- // TLS-only options must stay at their defaults on a plaintext h2c connection: the client rejects
- // each of them outright when UseTls=false ("<name> is a TLS-only option and requires
- // UseTls=true"). Forwarding them unconditionally made h2c UNREACHABLE — AllowUntrustedServer-
- // Certificate defaults to false, so RequireCertificateValidation was always sent as true and
- // every http:// deployment crashed at startup, even though the scheme is documented as the
- // supported way to select h2c. There is no certificate to have a posture about here.
+ // Certificate settings apply only to TLS. The client rejects an explicit opt-out or a
+ // CA path on h2c, where there is no certificate to validate.
var clientOptions = new HistorianGatewayClientOptions
{
Endpoint = endpointUri,
ApiKey = options.ApiKey,
UseTls = options.UseTls,
CaCertificatePath = options.UseTls ? options.CaCertificatePath : null,
- // INVERTED mapping (TLS only): ServerHistorianOptions.AllowUntrustedServerCertificate
- // (opt-in to accept a self-signed cert) is the negation of the client's
- // RequireCertificateValidation. Allowing an untrusted cert == not requiring validation; a
- // pinned CaCertificatePath always verifies.
- RequireCertificateValidation = options.UseTls && !options.AllowUntrustedServerCertificate,
+ AllowUntrustedServerCertificate = options.UseTls && options.AllowUntrustedServerCertificate,
DefaultCallTimeout = options.CallTimeout,
LoggerFactory = loggerFactory,
};
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayClientAdapterTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayClientAdapterTests.cs
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayClientAdapterTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayClientAdapterTests.cs
@@ -27,23 +27,6 @@
/// A plaintext <c>h2c</c> gateway (an <c>http://</c> endpoint with <c>UseTls=false</c>) must
/// construct, using nothing but documented defaults.
/// </summary>
- /// <remarks>
- /// <para>
- /// Found by the LocalDb Phase 2 live gate, on the third consecutive crash-loop of the rig.
- /// <c>docs/Historian.md</c> and CLAUDE.md both document <c>http://</c> as a supported
- /// transport ("scheme selects transport: https = TLS, http = h2c"), but it was in fact
- /// <b>unreachable</b>: this factory forwarded the TLS-only options unconditionally, and
- /// <see cref="ServerHistorianOptions.AllowUntrustedServerCertificate"/> defaults to
- /// <see langword="false"/>, so it always sent <c>RequireCertificateValidation=true</c> —
- /// which the client rejects outright when <c>UseTls=false</c>
- /// ("RequireCertificateValidation is a TLS-only option and requires UseTls=true").
- /// </para>
- /// <para>
- /// So <b>every</b> h2c deployment crashed at startup, and the only way to avoid it was to
- /// set <c>AllowUntrustedServerCertificate=true</c> — asserting a certificate posture for a
- /// connection that has no certificate at all.
- /// </para>
- /// </remarks>
[Fact]
public void Adapter_constructs_for_plaintext_h2c_endpoint()
{
@@ -71,6 +54,7 @@
ApiKey = "histgw_x_y",
UseTls = false,
CaCertificatePath = "/etc/ssl/certs/leftover-from-a-tls-config.pem",
+ AllowUntrustedServerCertificate = true,
};
using var adapter = HistorianGatewayClientAdapter.Create(opts, NullLoggerFactory.Instance);
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayTlsPolicyWireTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayTlsPolicyWireTests.cs
new file mode 100644
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayTlsPolicyWireTests.cs
@@ -0,0 +1,142 @@
+using System.Net;
+using System.Security.Authentication;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using Grpc.Core;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Server.Kestrel.Core;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.HistorianGateway.Client;
+using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;
+using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests;
+
+/// <summary>historiangw#131: verify the mapped TLS posture through an actual local gRPC connection.</summary>
+public sealed class HistorianGatewayTlsPolicyWireTests(ITestOutputHelper output)
+{
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ [Fact]
+ public async Task Default_tls_rejects_an_untrusted_server_before_the_rpc()
+ {
+ await using var server = await Server.StartAsync(tls: true);
+ using var adapter = HistorianGatewayClientAdapter.Create(server.Options(), NullLoggerFactory.Instance);
+
+ var error = await Should.ThrowAsync<HistorianGatewayException>(
+ () => adapter.TagExistsAsync("T", Ct).WaitAsync(TimeSpan.FromSeconds(15), Ct));
+ output.WriteLine($"TLS rejection: {error}");
+ ContainsTlsAuthenticationFailure(error).ShouldBeTrue("the failure must be TLS authentication, not an arbitrary connection failure");
+ server.MetadataCalls.ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task Explicit_tls_opt_out_reaches_the_same_self_signed_server_shape()
+ {
+ await using var server = await Server.StartAsync(tls: true);
+ var options = server.Options(allowUntrusted: true);
+ using var adapter = HistorianGatewayClientAdapter.Create(options, NullLoggerFactory.Instance);
+
+ (await adapter.TagExistsAsync("T", Ct).WaitAsync(TimeSpan.FromSeconds(15), Ct)).ShouldBeTrue();
+ server.MetadataCalls.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task Plaintext_ignores_stale_tls_opt_out_and_ca_settings_and_reaches_the_rpc()
+ {
+ await using var server = await Server.StartAsync(tls: false);
+ var options = server.Options(allowUntrusted: true, caPath: "/nonexistent/historiangw-131-stale-ca.pem");
+ using var adapter = HistorianGatewayClientAdapter.Create(options, NullLoggerFactory.Instance);
+
+ (await adapter.TagExistsAsync("T", Ct).WaitAsync(TimeSpan.FromSeconds(15), Ct)).ShouldBeTrue();
+ server.MetadataCalls.ShouldBe(1);
+ }
+
+ private static bool ContainsTlsAuthenticationFailure(Exception error)
+ {
+ var pending = new Stack<Exception>();
+ var seen = new HashSet<Exception>(ReferenceEqualityComparer.Instance);
+ pending.Push(error);
+ while (pending.TryPop(out var current))
+ {
+ if (!seen.Add(current)) continue;
+ if (current is AuthenticationException) return true;
+ if (current.InnerException is { } inner) pending.Push(inner);
+ if (current is RpcException { Status.DebugException: { } debug }) pending.Push(debug);
+ }
+ return false;
+ }
+
+ private sealed class TagsService : HistorianTags.HistorianTagsBase
+ {
+ public int Calls;
+ public override Task<GetTagMetadataReply> GetTagMetadata(GetTagMetadataRequest request, ServerCallContext context)
+ {
+ Interlocked.Increment(ref Calls);
+ return Task.FromResult(new GetTagMetadataReply
+ { Metadata = new HistorianTagMetadata { Name = request.Tag } });
+ }
+ }
+
+ private sealed class Server(WebApplication app, TagsService service, string endpoint,
+ RSA? key, X509Certificate2? certificate) : IAsyncDisposable
+ {
+ public int MetadataCalls => Volatile.Read(ref service.Calls);
+ public ServerHistorianOptions Options(bool allowUntrusted = false, string? caPath = null) => new()
+ {
+ Endpoint = endpoint, ApiKey = "histgw_131_test", UseTls = certificate is not null,
+ CallTimeout = TimeSpan.FromSeconds(2),
+ AllowUntrustedServerCertificate = allowUntrusted, CaCertificatePath = caPath,
+ };
+
+ public static async Task<Server> StartAsync(bool tls)
+ {
+ RSA? key = null;
+ X509Certificate2? certificate = null;
+ if (tls)
+ {
+ key = RSA.Create(2048);
+ var request = new CertificateRequest("CN=localhost", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+ request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true));
+ request.CertificateExtensions.Add(new X509KeyUsageExtension(
+ X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, true));
+ var names = new SubjectAlternativeNameBuilder();
+ names.AddDnsName("localhost"); names.AddIpAddress(IPAddress.Loopback);
+ request.CertificateExtensions.Add(names.Build());
+ certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddDays(1));
+ }
+ var service = new TagsService();
+ var builder = WebApplication.CreateBuilder();
+ builder.Logging.ClearProviders();
+ builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, 0, listen =>
+ {
+ listen.Protocols = HttpProtocols.Http2;
+ if (certificate is not null) listen.UseHttps(certificate);
+ }));
+ builder.Services.AddGrpc(); builder.Services.AddSingleton(service);
+ var app = builder.Build();
+ app.MapGrpcService<TagsService>();
+ try
+ {
+ await app.StartAsync(Ct);
+ return new Server(app, service, app.Urls.Single(), key, certificate);
+ }
+ catch
+ {
+ await app.DisposeAsync(); certificate?.Dispose(); key?.Dispose();
+ throw;
+ }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ try { await app.StopAsync(Ct); }
+ finally { await app.DisposeAsync(); certificate?.Dispose(); key?.Dispose(); }
+ }
+ }
+}
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Validate the future patch in archives, using published 1.0.0 or exact HG source project references."""
import argparse, datetime, hashlib, io, json, os, shutil, subprocess, tarfile, urllib.request
import xml.etree.ElementTree as ET
from pathlib import Path
def verify_published_packages(root, consumer, cache, project):
relative = Path('src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.csproj')
assert (consumer/relative).read_bytes() == (root/relative).read_bytes(), 'Unexpected source-project override'
assets = json.loads((consumer/project.parent/'obj/project.assets.json').read_text())
assert all(Path(p).resolve() == cache.resolve() for p in assets['packageFolders'])
proof = {'no_source_project_reference_override': True, 'packages': {}}
for name in ['Client', 'Contracts']:
ident = 'ZB.MOM.WW.HistorianGateway.' + name
library = assets['libraries'][ident + '/1.0.0']
assert library['type'] == 'package'
package = cache/ident.lower()/'1.0.0'
provenance = json.loads((package/'.nupkg.metadata').read_text())
assert provenance['source'].startswith('https://gitea.dohertylan.com/api/packages/dohertj2/nuget')
dll = package/'lib/net10.0'/(ident + '.dll')
copied = consumer/project.parent/'bin/Release/net10.0'/(ident + '.dll')
digest = hashlib.sha256(dll.read_bytes()).hexdigest()
assert digest == hashlib.sha256(copied.read_bytes()).hexdigest()
nuspec = ET.parse(package/(ident.lower() + '.nuspec'))
assert nuspec.findtext('.//{*}version') == '1.0.0'
repository = nuspec.find('.//{*}repository')
proof['packages'][ident] = {'version': '1.0.0', 'asset_type': 'package',
'source': provenance['source'], 'dll_sha256': digest,
'nupkg_sha256': hashlib.sha256((package/(ident.lower() + '.1.0.0.nupkg')).read_bytes()).hexdigest(),
'repository': repository.attrib if repository is not None else None,
'test_bin_dll_matches_package': True}
return proof
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--consumer-root', type=Path, required=True)
parser.add_argument('--hg-root', type=Path, required=True)
parser.add_argument('--hg-commit', default='e4321915ebc5e0704a68ecad21b85c1cc1c80d6b')
parser.add_argument('--output', type=Path, required=True)
a = parser.parse_args(); root = a.consumer_root.resolve(); out = a.output.resolve()
if out.is_relative_to(root) or out.is_relative_to(a.hg_root.resolve()):
raise ValueError('Use an output directory outside both checkouts so archived HG does not inherit consumer central-package settings.')
out.mkdir(parents=True, exist_ok=False)
patch = Path(__file__).with_name('consumer-1.0.0.patch').resolve()
project = Path('tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.csproj')
sha = subprocess.check_output(['git','-C',str(root),'rev-parse','HEAD'],text=True).strip()
hg_sha = subprocess.check_output(['git','-C',str(a.hg_root),'rev-parse',a.hg_commit],text=True).strip()
def archive(repo, rev, target):
target.mkdir()
data = subprocess.check_output(['git','-C',str(repo),'archive',rev])
with tarfile.open(fileobj=io.BytesIO(data)) as tar: tar.extractall(target, filter='data')
versions = {}
for name in ['client','contracts']:
url = 'https://gitea.dohertylan.com/api/packages/dohertj2/nuget/package/zb.mom.ww.historiangateway.' + name + '/index.json'
versions[name] = json.load(urllib.request.urlopen(url,timeout=20))['versions']
published = all('1.0.0' in v for v in versions.values())
metadata = {'utc':datetime.datetime.now(datetime.timezone.utc).isoformat(),'consumer_commit':sha,'hg_snapshot':hg_sha,
'mode':'published-1.0.0' if published else 'unreleased-source-project-reference',
'feed_versions':versions,'patch_sha256':hashlib.sha256(patch.read_bytes()).hexdigest(),
'published_package_verification':published,'normal_package_cache_modified':False}
(out/'metadata.json').write_text(json.dumps(metadata,indent=2))
consumer = out/'consumer'; archive(root,sha,consumer)
subprocess.run(['git','init','-q',str(consumer)],check=True)
subprocess.run(['git','-C',str(consumer),'apply',str(patch)],check=True)
if not published:
hg = out/'historiangw-source'; archive(a.hg_root,hg_sha,hg)
adapter = consumer/'src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.csproj'
tree = ET.parse(adapter)
removed = 0
for group in tree.getroot().findall('ItemGroup'):
for item in list(group):
if item.tag=='PackageReference' and item.get('Include') in ['ZB.MOM.WW.HistorianGateway.Client','ZB.MOM.WW.HistorianGateway.Contracts']:
group.remove(item);removed += 1
assert removed==2
group = ET.SubElement(tree.getroot(),'ItemGroup')
for relative in ['clients/dotnet/ZB.MOM.WW.HistorianGateway.Client/ZB.MOM.WW.HistorianGateway.Client.csproj',
'src/ZB.MOM.WW.HistorianGateway.Contracts/ZB.MOM.WW.HistorianGateway.Contracts.csproj']:
ET.SubElement(group,'ProjectReference',Include=str(hg/relative))
tree.write(adapter,encoding='unicode')
metadata['temporary_override']='Only archived Gateway csproj: two package references replaced by exact archived HG project references; no package packed or published.'
# Copy cached dependencies independently, preferably APFS copy-on-write, into the isolated package root.
cache = out/'packages'; cache.mkdir()
assets = json.loads((root/project.parent/'obj/project.assets.json').read_text())
normal = Path(next(iter(assets['packageFolders'])))
for library in assets['libraries'].values():
if library.get('type')!='package':continue
relative = library['path']; source=normal/relative; target=cache/relative
target.parent.mkdir(parents=True,exist_ok=True)
if target.exists():continue
r=subprocess.run(['cp','-cR',str(source),str(target)],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
if r.returncode: shutil.copytree(source,target)
config = ET.parse(root/'NuGet.config')
for source in config.getroot().find('packageSources'):
if source.get('key')=='local-mxgw':
local = root/'nuget-packages'
if not local.exists():local=out/'empty-local';local.mkdir()
source.set('value',str(local))
config.write(out/'NuGet.config',encoding='unicode')
env=dict(os.environ,NUGET_PACKAGES=str(cache),NUGET_HTTP_CACHE_PATH=str(out/'http-cache'),DOTNET_CLI_HOME=str(out/'dotnet-home'),
DOTNET_SKIP_FIRST_TIME_EXPERIENCE='1',DOTNET_GENERATE_ASPNET_CERTIFICATE='false',DOTNET_CLI_TELEMETRY_OPTOUT='1')
commands=[]
def run(args,name):
with (out/name).open('w') as log:r=subprocess.run(args,cwd=consumer,env=env,stdout=log,stderr=subprocess.STDOUT)
commands.append({'argv':args,'exit':r.returncode,'log':name})
(out/'commands.json').write_text(json.dumps(commands,indent=2));print(name,'exit',r.returncode,flush=True)
return r.returncode
restore = run(['dotnet','restore',str(project),'--configfile',str(out/'NuGet.config'),'--packages',str(cache)],'restore.log')
metadata['restore_exit']=restore
if restore:
(out/'metadata.json').write_text(json.dumps(metadata,indent=2));raise SystemExit(restore)
results=out/'test-results'
test = run(['dotnet','test',str(project),'-c','Release','--no-restore','--logger','trx','--results-directory',str(results)],'test.log')
guard_env=env.copy();guard_env['MIN_EXECUTED']='1'
with (out/'guard.log').open('w') as log:
guard=subprocess.run(['bash',str(consumer/'scripts/ci/assert-not-all-skipped.sh'),*[str(p) for p in results.glob('*.trx')]],env=guard_env,stdout=log,stderr=subprocess.STDOUT)
metadata.update({'test_exit':test,'guard_exit':guard.returncode})
ns={'t':'http://microsoft.com/schemas/VisualStudio/TeamTest/2010'}
metadata['results']=[]
for trx in results.glob('*.trx'):
xml=ET.parse(trx);metadata['results'].append(xml.find('.//t:Counters',ns).attrib)
metadata['tls_cases']=[{'name':r.get('testName'),'outcome':r.get('outcome'),'output':'\n'.join(p.text or '' for p in r.findall('.//t:StdOut',ns)),
'error':r.findtext('.//t:Message','',ns)} for r in xml.findall('.//t:UnitTestResult',ns) if 'HistorianGatewayTlsPolicyWireTests' in r.get('testName','')]
if published:
metadata['package_proof'] = verify_published_packages(root, consumer, cache, project)
(out/'metadata.json').write_text(json.dumps(metadata,indent=2));print(json.dumps(metadata,indent=2),flush=True)
raise SystemExit(test if test else guard.returncode)
@@ -0,0 +1,93 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Configuration;
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(45));
var ct = deadline.Token;
var mode = args[0];
var root = Path.GetFullPath(args[1]);
if (mode == "seed")
{
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{ ["LocalDb:Path"] = Path.Combine(root, "localdb.db") }).Build();
await using var provider = new ServiceCollection().AddLogging().AddOtOpcUaLocalDb(config).BuildServiceProvider();
var presented = Environment.GetEnvironmentVariable("SMOKE_API_KEY") ?? throw new InvalidOperationException("Missing task credential.");
if (!ApiKeySecret.TryParse(presented, out var id, out var secret)) throw new InvalidOperationException("Invalid task credential format.");
var pepper = Environment.GetEnvironmentVariable("Security__ApiKeys__Pepper") ?? throw new InvalidOperationException("Missing task pepper.");
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
{
Clusters = new[] { new { ClusterId = "smoke", Name = "Smoke" } },
RawFolders = new[] { new { RawFolderId = "smoke", Name = "Smoke", ClusterId = "smoke" } },
ApiKeys = new[] { new { ApiKeyId = id, Name = "publish-smoke", Principal = "publish-smoke",
Enabled = true, SecretHash = ApiKeySecret.ComputeHash(secret, pepper), CreatedUtc = DateTime.UtcNow } },
NodeAcls = new[] { new { NodeAclId = "smoke-read", ClusterId = "smoke", Principal = "publish-smoke",
ScopeKind = 0, ScopeId = (string?)null, PermissionFlags = 7 } },
});
var revision = Convert.ToHexStringLower(SHA256.HashData(artifact));
await provider.GetRequiredService<IDeploymentArtifactCache>().StoreAsync("smoke", ZB.MOM.WW.OtOpcUa.Commons.Types.DeploymentId.NewId().ToString(), revision, artifact, ct);
Console.WriteLine(JsonSerializer.Serialize(new { mode, result = "seeded-through-public-cache", revision }));
return;
}
var telemetry = DefaultTelemetry.Create(_ => { });
var clientName = mode == "prepare-cli" ? "OtOpcUaClient" : "PublishSmokeClient";
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 is "prepare" or "prepare-cli")
{
Console.WriteLine(JsonSerializer.Serialize(new { mode, result = "prepared", autoAccept = security.AutoAcceptUntrustedCertificates }));
return;
}
var endpoint = args[2];
var expected = args[3];
var credential = Environment.GetEnvironmentVariable("SMOKE_API_KEY") ?? throw new InvalidOperationException("Missing task credential.");
try
{
var selected = await CoreClientUtils.SelectEndpointAsync(configuration, endpoint, true, telemetry, ct)
?? throw new InvalidOperationException("No secure endpoint advertised.");
if (selected.SecurityMode != MessageSecurityMode.SignAndEncrypt || selected.SecurityPolicyUri != SecurityPolicies.Basic256Sha256)
throw new InvalidOperationException("Unexpected negotiated security policy.");
using var session = await new DefaultSessionFactory(telemetry).CreateAsync(configuration,
new ConfiguredEndpoint(null, selected, EndpointConfiguration.Create(configuration)), false, true,
"PublishSmoke", 60_000, new UserIdentity("apikey", Encoding.UTF8.GetBytes(credential)), default, ct);
if (expected != "allow") throw new InvalidOperationException("A denied test credential or peer was admitted.");
var response = await session.ReadAsync(null, 0, TimestampsToReturn.Both,
[new ReadValueId { NodeId = VariableIds.Server_ServerStatus_State, AttributeId = Attributes.Value }], ct);
if (response.Results.Count != 1 || StatusCode.IsBad(response.Results[0].StatusCode)) throw new InvalidOperationException("Server status read failed.");
Console.WriteLine(JsonSerializer.Serialize(new { mode, expected, result = "allowed", policy = selected.SecurityPolicyUri,
securityMode = selected.SecurityMode.ToString(), status = response.Results[0].StatusCode.ToString(), autoAccept = false }));
await session.CloseAsync(ct);
}
catch (ServiceResultException error) when (expected != "allow")
{
var accepted = expected == "key-deny"
? new[] { StatusCodes.BadUserAccessDenied, StatusCodes.BadIdentityTokenRejected, StatusCodes.BadIdentityTokenInvalid }
: new[] { StatusCodes.BadCertificateUntrusted, StatusCodes.BadSecurityChecksFailed, StatusCodes.BadSecureChannelClosed };
if (!accepted.Contains(error.StatusCode)) throw;
Console.WriteLine(JsonSerializer.Serialize(new { mode, expected, result = "denied", status = error.StatusCode.ToString(), autoAccept = false }));
}
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../../src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,70 @@
# Actual Host publish smoke
From an isolated candidate checkout, publish the actual Host and CLI for Linux ARM64
and Windows x64 using the install recipe's self-contained setting. Do not use a
checkout that another process is building, and choose a new artifact directory.
```bash
task_output=/absolute/fresh/publish-smoke
test ! -e "$task_output" || exit 2
mkdir -p "$task_output"
for rid in linux-arm64 win-x64; do
dotnet publish src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj \
-c Release -r "$rid" --self-contained true \
-p:PublishSingleFile=false -p:PublishTrimmed=false -m:1 \
-o "$task_output/$rid/host" || exit
dotnet publish src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj \
-c Release -r "$rid" --self-contained true \
-p:PublishSingleFile=false -p:PublishTrimmed=false -m:1 \
-o "$task_output/$rid/cli" || exit
done
dotnet publish scripts/validation/opcua-publish-smoke/PublishProbe.csproj \
-c Release -m:1 -o "$task_output/probe" || exit
python3 scripts/validation/opcua-publish-smoke/runtime.py --artifacts "$task_output"
```
The cached `mcr.microsoft.com/dotnet/sdk:10.0.103-noble` image must be ARM64 for this
Linux artifact. The SDK image supplies the small probe's framework; the actual
Host and CLI are launched through their published self-contained apphosts. There
is no Native AOT, trimming, or single-file transformation.
The runtime script creates a fresh private state directory, ephemeral keys and
loopback ports. A temporary preparation container seeds the LocalDb cache through
public production registration/cache APIs. The artifact contains an empty driver
configuration, one raw folder, and a real HMAC API key with read/browse/subscribe
grants. No production authentication seam is replaced. The actual Host uses
`Cluster:Roles=driver`, `ConfigSource:Mode=FetchAndCache`, an unreachable loopback
central endpoint, and task-only LocalDb, secrets SQLite and GUID PKI paths. Its
single-node Akka seed is local. Every container has `--network none`; no fleet
endpoint, ConfigDb, existing container, volume, or PKI is used.
One task-owned container runs the actual Host as its entrypoint, stops cleanly,
then starts the same executable with the same state and PKI again. Each boot must
serve healthy readiness, liveness and active endpoints, admit a trusted
Basic256Sha256 SignAndEncrypt API-key client, reject a wrong key, and allow an
actual published CLI read of ServerStatus.State with Good quality. The first boot
also rejects an untrusted server and an untrusted client. The two certificate
thumbprints must match. Both stops must exit zero without an OOM kill. An explicit
execution guard requires two boots and all six strict SDK probe cases.
The probe retains its public SDK ApplicationInstance throughout its session and
disposes it afterward. Peer autoaccept is false. The CLI's existing configuration
sets autoaccept true and has no strict-validation flag, so its successful reads
are not counted as strict peer-validation coverage. Its certificate is prepared
with its normal application name and URI using public SDK APIs and trusted by the
server before startup. A closed-port CLI preflight is an expected nonzero setup
check, not a passing connection case.
`commands.json`, per-case logs, health JSON and `summary.json` retain actual exits,
results and certificate identity. Secrets are supplied through a mode-0600 task
environment file, never written into recorded arguments, and the file is removed
at the end. Raw process output and this run's appended rolling-file output are
checked for task secrets before redacted copies are saved. Only the captured,
task-labelled container is removed; private bind-mounted state remains local for
inspection and must not be redistributed with release artifacts.
Windows cross-publish and PE/native/dependency inspection can run on macOS.
Windows service installation and start/stop remain a separate operator gate on
an approved Windows environment. The Wonder-box remote-execution hold still
applies: no WinRM, psbridge, or replacement remote-execution channel. This smoke
does not complete backend, load, failover, or two-hour soak validation.
@@ -0,0 +1,152 @@
#!/usr/bin/env python3
"""Smoke actual self-contained Linux Host/CLI artifacts with task-only PKI and LocalDb."""
import argparse, base64, datetime, hashlib, json, os, re, secrets, shutil, subprocess, time, uuid
from pathlib import Path
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('--artifacts', required=True, type=Path)
p.add_argument('--image', default='mcr.microsoft.com/dotnet/sdk:10.0.103-noble')
a = p.parse_args(); out = a.artifacts.resolve()
run = out / ('runtime-' + uuid.uuid4().hex); run.mkdir(exist_ok=False)
state = run / 'state'; state.mkdir(); os.chmod(state, 0o700)
identifier = uuid.uuid4().hex[:12]
web, ua, akka = [20000 + secrets.randbelow(10000) for _ in range(3)]
while len({web, ua, akka}) != 3: web, ua, akka = [20000 + secrets.randbelow(10000) for _ in range(3)]
key = 'otua_' + secrets.token_hex(8) + '_' + secrets.token_urlsafe(32)
pepper, fetch = secrets.token_urlsafe(32), secrets.token_urlsafe(32)
master = base64.b64encode(secrets.token_bytes(32)).decode()
env = {
'ASPNETCORE_URLS': f'http://127.0.0.1:{web}', 'ASPNETCORE_ENVIRONMENT': 'Production',
'Cluster__Roles__0': 'driver',
'Cluster__SystemName': 'otopcua', 'Cluster__Hostname': '127.0.0.1',
'Cluster__PublicHostname': '127.0.0.1', 'Cluster__Port': str(akka),
'Cluster__SeedNodes__0': f'akka.tcp://otopcua@127.0.0.1:{akka}',
'ConfigSource__Mode': 'FetchAndCache', 'ConfigSource__CentralFetchEndpoints__0': 'http://127.0.0.1:1',
'ConfigSource__ApiKey': fetch, 'ConfigSource__FetchTimeoutSeconds': '1',
'LocalDb__Path': '/state/localdb.db', 'Secrets__SqlitePath': '/state/secrets.db',
'ZB_SECRETS_MASTER_KEY': master, 'Security__ApiKeys__Pepper': pepper,
'Security__Ldap__Enabled': 'false', 'Security__Acl__Enabled': 'false',
'OpcUa__ApplicationName': 'PublishSmoke-' + identifier,
'OpcUa__ApplicationUri': 'urn:localhost:PublishSmoke:' + identifier,
'OpcUa__PublicHostname': 'localhost', 'OpcUa__OpcUaPort': str(ua),
'OpcUa__PkiStoreRoot': '/state/server-pki-' + identifier,
'OpcUa__AllowAnonymous': 'false', 'OpcUa__AutoAcceptUntrustedClientCertificates': 'false',
'OpcUa__EnabledSecurityProfiles__0': 'Basic256Sha256SignAndEncrypt',
'SMOKE_API_KEY': key, 'XDG_DATA_HOME': '/state/cli-data', 'HOME': '/state/home',
}
envfile = run / 'private.env'; envfile.write_text(''.join(f'{k}={v}\n' for k,v in env.items())); os.chmod(envfile, 0o600)
redactions = [key, key.split('_', 2)[2], pepper, fetch, master]
def clean(text):
for secret in redactions: text = text.replace(secret, '<ephemeral-secret>')
return text
records = []
raw_secret_findings = []
rolling_root = out / 'linux-arm64/host/logs'
rolling_offsets = {p: p.stat().st_size for p in rolling_root.glob('*') if p.is_file()}
def command(args, name, expected=0, timeout=90):
start = datetime.datetime.now(datetime.timezone.utc).isoformat()
result = subprocess.run(args, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=timeout)
raw_secret_findings.append({'log': name, 'occurrences': sum(result.stdout.count(secret) for secret in redactions)})
(run / (name + '.log')).write_text(clean(result.stdout))
records.append({'name': name, 'utc': start, 'argv': [clean(str(x)) for x in args], 'exit': result.returncode})
(run / 'commands.json').write_text(json.dumps(records, indent=2))
if expected is not None and result.returncode != expected:
raise RuntimeError(f'{name}: exit {result.returncode}; see {run / (name + ".log")}')
return result
mounts = ['--mount', f'type=bind,src={out},dst=/work', '--mount', f'type=bind,src={state},dst=/state']
base = ['docker','run','--rm','--network','none','--label','codex.task=opcua-publish-smoke','--env-file',str(envfile),*mounts,'-w','/work/linux-arm64/host',a.image]
probe = ['dotnet','/work/probe/PublishProbe.dll']
cli = ['/work/linux-arm64/cli/ZB.MOM.WW.OtOpcUa.Client.CLI']
server_pki = state / ('server-pki-' + identifier)
container = None; summary = {'run': str(run), 'image': a.image, 'ports': {'http':web,'ua':ua,'akka':akka}, 'boots':[], 'cases':[]}
def copy_public(source, target):
target.mkdir(parents=True, exist_ok=True)
certs = list(source.glob('*.der'))
if not certs: raise RuntimeError(f'No public certificates at {source}')
for cert in certs: shutil.copy2(cert, target / cert.name)
def inspect(): return json.loads(subprocess.check_output(['docker','inspect',container], text=True))[0]
def save_logs(name):
r = subprocess.run(['docker','logs',container],text=True,stdout=subprocess.PIPE,stderr=subprocess.STDOUT)
raw_secret_findings.append({'log': name, 'occurrences': sum(r.stdout.count(secret) for secret in redactions)})
(run / (name + '.log')).write_text(clean(r.stdout))
def exec_probe(name, pki, expected, credential=None):
args = ['docker','exec']
if credential: args += ['-e','SMOKE_API_KEY=' + credential]
args += [container,*probe,'probe',pki,f'opc.tcp://localhost:{ua}/OtOpcUa',expected]
r = command(args,name)
payload = json.loads(r.stdout.strip().splitlines()[-1]); summary['cases'].append({'name':name,**payload})
try:
command([*base,*probe,'seed','/state'],'seed')
command([*base,*probe,'prepare','/state/client-pki'],'prepare-trusted-client')
command([*base,*probe,'prepare','/state/untrusted-client-pki'],'prepare-untrusted-client')
command([*base,*cli,'--help'],'cli-help')
command([*base,*probe,'prepare-cli','/state/cli-data/OtOpcUaClient/pki'],'prepare-cli-certificate')
failed = command([*base,*cli,'read','--url','opc.tcp://localhost:1/OtOpcUa','--security','encrypt','--username','apikey','--password',key,'--node','i=2259'],'cli-prepare-on-closed-port',expected=None)
if failed.returncode == 0: raise RuntimeError('Closed-port CLI fixture preparation unexpectedly succeeded.')
copy_public(state/'client-pki/own/certs',server_pki/'trusted/certs')
copy_public(state/'cli-data/OtOpcUaClient/pki/own/certs',server_pki/'trusted/certs')
name = 'otopcua-sdk2-publish-' + identifier
result = command(['docker','create','--name',name,'--network','none','--label','codex.task=opcua-publish-smoke','--env-file',str(envfile),*mounts,'-w','/work/linux-arm64/host',a.image,'./OtOpcUa.Host'],'container-create')
container = result.stdout.strip()
summary['container'] = container
for boot in [1,2]:
command(['docker','start',container],f'boot{boot}-start')
deadline = time.monotonic() + 90
health = None
while time.monotonic() < deadline:
if not inspect()['State']['Running']:
save_logs(f'boot{boot}-host'); raise RuntimeError('Full Host exited before health readiness.')
r = subprocess.run(['docker','exec',container,'curl','-fsS',f'http://127.0.0.1:{web}/health/ready'],text=True,stdout=subprocess.PIPE,stderr=subprocess.STDOUT)
if r.returncode == 0:
health = r.stdout; break
time.sleep(1)
if health is None:
save_logs(f'boot{boot}-host'); raise RuntimeError('Full Host readiness did not become healthy.')
if json.loads(health).get('status') != 'Healthy': raise RuntimeError('Readiness is degraded or unhealthy.')
(run/f'boot{boot}-ready.json').write_text(health)
command(['docker','exec',container,'curl','-fsS',f'http://127.0.0.1:{web}/healthz'],f'boot{boot}-live')
command(['docker','exec',container,'curl','-fsS',f'http://127.0.0.1:{web}/health/active'],f'boot{boot}-active')
certs = list((server_pki/'own/certs').glob('*.der'))
if len(certs) != 1: raise RuntimeError('Expected exactly one application certificate.')
cert_sha256 = hashlib.sha256(certs[0].read_bytes()).hexdigest()
thumbprint = subprocess.check_output(['openssl','x509','-inform','DER','-in',str(certs[0]),'-noout','-fingerprint','-sha1'],text=True).strip()
copy_public(server_pki/'own/certs',state/'client-pki/trusted/certs')
copy_public(server_pki/'own/certs',state/'untrusted-client-pki/trusted/certs')
exec_probe(f'boot{boot}-trusted-key','/state/client-pki','allow')
exec_probe(f'boot{boot}-wrong-key','/state/client-pki','key-deny',key+'-wrong')
if boot == 1:
hold = state/'hold-server-trust'; shutil.move(str(state/'client-pki/trusted/certs'),hold)
(state/'client-pki/trusted/certs').mkdir()
try: exec_probe('untrusted-server','/state/client-pki','peer-deny')
finally:
shutil.rmtree(state/'client-pki/trusted/certs'); shutil.move(str(hold),state/'client-pki/trusted/certs')
exec_probe('untrusted-client','/state/untrusted-client-pki','peer-deny')
r = command(['docker','exec',container,*cli,'read','--url',f'opc.tcp://localhost:{ua}/OtOpcUa','--security','encrypt','--username','apikey','--password',key,'--node','i=2259'],f'boot{boot}-actual-cli-read')
if not re.search(r'^Status:\s+Good(?:\s|$)', r.stdout, re.MULTILINE): raise RuntimeError('Actual CLI did not report a Good server status read.')
command(['docker','stop','--time','30',container],f'boot{boot}-stop',timeout=45)
stopped = inspect()['State']; save_logs(f'boot{boot}-host')
if stopped['Running'] or stopped['ExitCode'] != 0 or stopped['OOMKilled']: raise RuntimeError('Full Host did not shut down cleanly.')
summary['boots'].append({'boot':boot,'certificate_sha256':cert_sha256,'thumbprint':thumbprint,'exit':stopped['ExitCode'],'oomKilled':stopped['OOMKilled'],'health_ready':health})
if summary['boots'][0]['thumbprint'] != summary['boots'][1]['thumbprint']: raise RuntimeError('Application certificate changed between boots.')
summary['certificate_preserved'] = True
for path in rolling_root.glob('*'):
if not path.is_file(): continue
text = path.read_bytes()[rolling_offsets.get(path, 0):].decode(errors='replace')
raw_secret_findings.append({'log': 'rolling-' + path.name, 'occurrences': sum(text.count(secret) for secret in redactions)})
(run / ('rolling-' + path.name)).write_text(clean(text))
if len(summary['boots']) != 2 or len(summary['cases']) != 6: raise RuntimeError('Required smoke cases did not all execute.')
if any(x['occurrences'] for x in raw_secret_findings): raise RuntimeError('Task secret appeared in raw application output; see recorded occurrence counts.')
summary['result'] = 'passed'
finally:
if container:
save_logs('host-final')
owned = inspect()
if owned['Config']['Labels'].get('codex.task') != 'opcua-publish-smoke': raise RuntimeError('Task container ownership changed.')
command(['docker','rm','-f',container],'container-cleanup')
logs = '\n'.join(x.read_text(errors='replace') for x in run.glob('*.log'))
summary['task_secret_occurrences_in_saved_logs'] = sum(logs.count(secret) for secret in redactions)
summary['raw_log_secret_findings'] = raw_secret_findings
summary['completed_utc'] = datetime.datetime.now(datetime.timezone.utc).isoformat()
(run/'summary.json').write_text(json.dumps(summary,indent=2))
envfile.unlink(missing_ok=True)
print(json.dumps(summary,indent=2))
@@ -71,7 +71,9 @@ internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfi
ApplicationType = ApplicationType.Client,
ApplicationConfiguration = config
};
await app.CheckApplicationInstanceCertificatesAsync(false, lifeTimeInMonths: 2048, ct: ct);
// Key size is configured separately; this argument is a lifetime in months.
// Keep the SDK creation default and reuse any valid certificate already in the store.
await app.CheckApplicationInstanceCertificatesAsync(false, ct: ct);
}
}
catch
@@ -1,4 +1,7 @@
using Opc.Ua;
using Opc.Ua.Configuration;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Client.Shared.Adapters;
@@ -9,6 +12,61 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Adapters;
/// <summary>Uses the real configuration factory so fake sessions cannot conceal SDK initialization failures.</summary>
public sealed class DefaultApplicationConfigurationFactoryTests
{
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task New_certificates_use_the_default_lifetime_and_existing_private_keys_are_reused(bool seedLongerCertificate)
{
var ct = TestContext.Current.CancellationToken;
var root = Path.Combine(Path.GetTempPath(), $"otopcua-certificate-lifetime-{Guid.NewGuid():N}");
var settings = new ConnectionSettings
{
EndpointUrl = "opc.tcp://localhost:4840", CertificateStorePath = root,
SecurityMode = SecurityMode.None, AutoAcceptCertificates = false,
};
var factory = new DefaultApplicationConfigurationFactory();
ApplicationConfiguration? current = null;
try
{
if (seedLongerCertificate)
{
var seed = await factory.CreateAsync(settings, ct);
await using var application = new ApplicationInstance(DefaultTelemetry.Create(_ => { })) { ApplicationConfiguration = seed };
(await application.CheckApplicationInstanceCertificatesAsync(true, lifeTimeInMonths: 60, ct)).ShouldBeTrue();
}
settings.SecurityMode = SecurityMode.SignAndEncrypt;
current = await factory.CreateAsync(settings, ct);
var certPath = Directory.GetFiles(Path.Combine(root, "own", "certs"), "*.der").ShouldHaveSingleItem();
var privatePath = Directory.GetFiles(Path.Combine(root, "own", "private"), "*.pfx").ShouldHaveSingleItem();
using var certificate = X509CertificateLoader.LoadCertificateFromFile(certPath);
using var privateCertificate = X509CertificateLoader.LoadPkcs12FromFile(privatePath, null, OperatingSystem.IsMacOS() ? X509KeyStorageFlags.DefaultKeySet : X509KeyStorageFlags.EphemeralKeySet);
privateCertificate.HasPrivateKey.ShouldBeTrue();
certificate.Thumbprint.ShouldBe(privateCertificate.Thumbprint);
using var rsa = certificate.GetRSAPublicKey();
rsa.ShouldNotBeNull().KeySize.ShouldBeGreaterThanOrEqualTo(2048);
if (seedLongerCertificate)
certificate.NotAfter.ToUniversalTime().ShouldBeGreaterThan(DateTime.UtcNow.AddYears(4));
else
{
certificate.NotAfter.ToUniversalTime().ShouldBeGreaterThan(DateTime.UtcNow.AddMonths(CertificateFactory.DefaultLifeTime - 1));
certificate.NotAfter.ToUniversalTime().ShouldBeLessThan(DateTime.UtcNow.AddMonths(CertificateFactory.DefaultLifeTime).AddDays(2));
}
var originalKeyHash = SHA256.HashData(await File.ReadAllBytesAsync(privatePath, ct));
(current.CertificateManager as IDisposable)?.Dispose(); current = null;
current = await factory.CreateAsync(settings, ct);
using var reused = X509CertificateLoader.LoadCertificateFromFile(certPath);
reused.Thumbprint.ShouldBe(certificate.Thumbprint);
SHA256.HashData(await File.ReadAllBytesAsync(privatePath, ct)).ShouldBe(originalKeyHash,
"reinitializing with a shorter creation default must not rotate an existing certificate or private key");
Directory.GetFiles(Path.Combine(root, "own", "certs"), "*.der").Length.ShouldBe(1);
}
finally
{
(current?.CertificateManager as IDisposable)?.Dispose();
if (Directory.Exists(root)) Directory.Delete(root, true);
}
}
[Theory]
[InlineData(SecurityMode.None)]
[InlineData(SecurityMode.SignAndEncrypt)]