fix(opcua): #874 route the server's OPC UA SDK log output to the host logger

The host handed the SDK a provider-less telemetry context, so SDK-side
refusals — identity-token decryption (the SDK 2 RsaEncryptedSecret clock
window, #873), certificate validation, secure-channel faults — left no server
log line (found on windev during the #800 Windows validation). The hosted
service now creates one ServerSdkTelemetry context from its ILoggerFactory
(Warning and above, SDK categories, rate-limited 20/min per category) and
shares it between OtOpcUaSdkServer and the ApplicationInstance; released on
dispose. Test: an untrusted client's rejection reaches the captured log.

Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
This commit is contained in:
Joseph Doherty
2026-11-11 16:37:03 -05:00
parent 88698ca042
commit 2e948d3139
5 changed files with 160 additions and 9 deletions
@@ -1,6 +1,7 @@
using System.Text;
using System.Net;
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Client;
@@ -116,6 +117,26 @@ public sealed class Sdk2TrustedChannelTests
rig.Writes.ShouldBe(0);
}
/// <summary>
/// #874: SDK-side refusals used to leave no server log line (the host gave the SDK a provider-less telemetry
/// context). With <see cref="ServerSdkTelemetry"/> wired, rejecting an untrusted client is logged at Warning+.
/// </summary>
[Fact]
public async Task An_sdk_side_refusal_reaches_the_server_log()
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await TrustedRig.StartAsync(OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt, ct);
rig.SdkLog.Clear();
await Should.ThrowAsync<ServiceResultException>(() => rig.ConnectAsync("contract-key", ct, untrustedClient: true));
var deadline = DateTime.UtcNow.AddSeconds(5);
while (rig.SdkLog.IsEmpty && DateTime.UtcNow < deadline) await Task.Delay(50, ct);
var entries = rig.SdkLog.ToArray();
entries.ShouldNotBeEmpty("the SDK's refusal must reach the host's logger");
entries.ShouldAllBe(e => e.Level >= LogLevel.Warning);
TestContext.Current.SendDiagnosticMessage(string.Join("\n", entries.Select(e => $"{e.Level} {e.Category}: {e.Text}")));
}
private sealed class TrustedRig : IAsyncDisposable, IOpcUaUserAuthenticator, IOpcUaNodeWriteGateway
{
internal const string Principal = "trusted-contract";
@@ -137,6 +158,9 @@ public sealed class Sdk2TrustedChannelTests
private readonly TaskCompletionSource _peerBarrier = new(TaskCreationOptions.RunContinuationsAsynchronously);
private int _peerBarrierArrivals;
internal OtOpcUaSdkServer Server { get; private set; } = null!;
internal ConcurrentQueue<(LogLevel Level, string Category, string Text)> SdkLog { get; } = new();
private readonly ILoggerFactory _sdkLoggerFactory;
private readonly ITelemetryContext _sdkTelemetry;
internal int Writes => Volatile.Read(ref _writes);
internal int AuthCalls => Volatile.Read(ref _authCalls);
private string ServerPki => System.IO.Path.Combine(_root, "server");
@@ -150,6 +174,23 @@ public sealed class Sdk2TrustedChannelTests
PublicHostname = "localhost", PkiStoreRoot = ServerPki, AllowAnonymous = false,
AutoAcceptUntrustedClientCertificates = false, EnabledSecurityProfiles = [profile],
};
_sdkLoggerFactory = LoggerFactory.Create(b => b.SetMinimumLevel(LogLevel.Trace).AddProvider(new CaptureProvider(SdkLog)));
_sdkTelemetry = ServerSdkTelemetry.Create(_sdkLoggerFactory);
}
private sealed class CaptureProvider(ConcurrentQueue<(LogLevel, string, string)> sink) : ILoggerProvider
{
public ILogger CreateLogger(string categoryName) => new Capture(sink, categoryName);
public void Dispose() { }
private sealed class Capture(ConcurrentQueue<(LogLevel, string, string)> sink, string category) : ILogger
{
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter) =>
sink.Enqueue((logLevel, category, formatter(state, exception) + (exception is null ? "" : " | " + exception.Message)));
}
}
internal static async Task<TrustedRig> StartAsync(OpcUaSecurityProfile profile, CancellationToken ct)
@@ -186,8 +227,8 @@ public sealed class Sdk2TrustedChannelTests
(_host, Server) = await OpcUaHostStart.StartAsync(port =>
{
_port = port; _options.OpcUaPort = port;
return new OpcUaApplicationHost(_options, NullLogger<OpcUaApplicationHost>.Instance, this);
}, () => new OtOpcUaSdkServer(), ct);
return new OpcUaApplicationHost(_options, NullLogger<OpcUaApplicationHost>.Instance, this, _sdkTelemetry);
}, () => new OtOpcUaSdkServer(telemetry: _sdkTelemetry), ct);
}
private static void CopyCertificates(string from, string to)
@@ -286,6 +327,8 @@ public sealed class Sdk2TrustedChannelTests
if (_host is not null) await _host.DisposeAsync();
foreach (var app in _applications.Values) await app.DisposeAsync();
_applications.Clear();
ServerSdkTelemetry.Release(_sdkTelemetry);
_sdkLoggerFactory.Dispose();
if (Directory.Exists(_root)) Directory.Delete(_root, true);
}
}