Files
UA-.NETStandard/samples/PubSub/ConsoleSecureChannelPubSub/SecureChannelSample.cs

578 lines
29 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Opc.Ua;
using Opc.Ua.Bindings;
using Opc.Ua.Client;
using Opc.Ua.Client.DataChannels;
using Opc.Ua.Configuration;
using Opc.Ua.DataChannels;
using Opc.Ua.PubSub.Server.DataChannels;
using Opc.Ua.PubSub.Transports.DataChannels;
using Opc.Ua.Security.Certificates;
using Opc.Ua.Server;
using Opc.Ua.Server.DataChannels;
using Opc.Ua.Server.Hosting;
using ClientSession = Opc.Ua.Client.ISession;
using ServerSession = Opc.Ua.Server.ISession;
namespace Quickstarts.ConsoleSecureChannelPubSub
{
/// <summary>
/// Composes production server, client, certificate and PubSub APIs over real loopback TCP.
/// </summary>
internal static class SecureChannelSample
{
/// <summary>
/// Starts both peers in one process without replacing either peer's sockets or protocol stack.
/// </summary>
/// <exception cref="InvalidOperationException"></exception>
internal static async Task<RunEvidence> RunDemoAsync(
SampleOptions options,
SamplePki pki,
ITelemetryContext telemetry,
CancellationToken cancellationToken)
{
using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var ready = new TaskCompletionSource<ServerReady>(TaskCreationOptions.RunContinuationsAsynchronously);
Task<EndpointEvidence> server = RunServerAsync(options, pki, telemetry, ready, lifetime.Token);
ServerReady endpoint;
try
{
endpoint = await ready.Task.WaitAsync(lifetime.Token).ConfigureAwait(false);
}
catch
{
await lifetime.CancelAsync().ConfigureAwait(false);
await server.ConfigureAwait(false);
throw;
}
Task<EndpointEvidence> client = RunClientAsync(options, pki, telemetry, endpoint, lifetime.Token);
Task first = await Task.WhenAny(server, client).ConfigureAwait(false);
if (!first.IsCompletedSuccessfully)
{
await lifetime.CancelAsync().ConfigureAwait(false);
}
EndpointEvidence[] peers = await Task.WhenAll(server, client).ConfigureAwait(false);
EndpointEvidence publisher = peers.Single(peer => peer.Publisher);
EndpointEvidence subscriber = peers.Single(peer => !peer.Publisher);
if (publisher.ContextId != subscriber.ContextId ||
publisher.ChannelId != subscriber.ChannelId ||
!publisher.Sent.SequenceEqual(subscriber.Received, StringComparer.Ordinal))
{
throw new InvalidOperationException("The exact emitted and received NetworkMessages differ.");
}
return new RunEvidence { Endpoints = peers };
}
/// <summary>
/// Hosts the standard server and its sole production PubSub accept router.
/// </summary>
/// <exception cref="InvalidOperationException"></exception>
/// <exception cref="DataChannelException"></exception>
internal static async Task<EndpointEvidence> RunServerAsync(
SampleOptions options,
SamplePki pki,
ITelemetryContext telemetry,
TaskCompletionSource<ServerReady>? ready,
CancellationToken cancellationToken)
{
try
{
if (options.ReadyFile != null && File.Exists(options.ReadyFile))
{
throw new InvalidOperationException("Use a fresh --ready path; stale readiness is not reused.");
}
int port = options.Port == 0 ? FreePort() : options.Port;
int controlPort = FreePort();
while (controlPort == port)
{
controlPort = FreePort();
}
string flowEndpoint = $"opc.tcp://localhost:{port}" +
(options.Shared ? DataChannelProfiles.SharedResource : DataChannelProfiles.DedicatedResource);
string controlEndpoint = $"opc.tcp://localhost:{controlPort}/control";
bool needServices = options.Shared || options.SessionAuthorization;
string[] addresses = needServices ? [flowEndpoint, controlEndpoint] : [flowEndpoint];
ApplicationInstance application = await pki.OpenApplicationAsync(
true, addresses, telemetry, false, cancellationToken).ConfigureAwait(false);
await using ConfiguredAsyncDisposable applicationLifetime = application.ConfigureAwait(false);
ICertificateManager certificates = SamplePki.Manager(application);
using Certificate peer = await SamplePki.PeerCertificateAsync(
certificates, SampleConfiguration.ClientApplicationUri, cancellationToken).ConfigureAwait(false);
var sources = new DataChannelSourceRegistry();
sources.Register(PubSubDataChannelSource.Create(
SampleConfiguration.Build(options, !options.Reverse).Connections[0],
new SampleSourcePolicy(
DataChannelCertificateIdentity.GetKeyThumbprint(peer), options.SessionAuthorization),
maximumFlows: 1,
sourceNodeId: options.SessionAuthorization ? SourceId : default));
var provider = new ServerDataChannelProvider(
sources, new DataChannelOptions(), new DataChannelApplicationBudget(),
TimeProvider.System, telemetry);
await using ConfiguredAsyncDisposable providerLifetime = provider.ConfigureAwait(false);
var server = new StandardServer(telemetry);
await using ConfiguredAsyncDisposable serverLifetime = server.ConfigureAwait(false);
server.UseDataChannels(provider, enableModel: options.SessionAuthorization);
await application.StartAsync(server, cancellationToken).ConfigureAwait(false);
server.CurrentInstance.IdentityRegistry.Register(new X509Authenticator(certificates));
var sessions = new ConcurrentQueue<ServerSession>();
void SessionCreated(ServerSession session, SessionEventReason reason) => sessions.Enqueue(session);
server.CurrentInstance.SessionManager.SessionCreated += SessionCreated;
try
{
bool model = server.EnableDataChannelModel;
var endpoint = new ServerReady
{
FlowEndpoint = flowEndpoint,
ControlEndpoint = needServices ? controlEndpoint : string.Empty,
Shared = options.Shared,
SessionAuthorization = options.SessionAuthorization,
Reverse = options.Reverse,
Count = options.Count
};
if (options.ReadyFile != null)
{
await EvidenceFiles.WriteAsync(options.ReadyFile,
JsonSerializer.Serialize(endpoint, EvidenceJsonContext.Default.ServerReady),
cancellationToken).ConfigureAwait(false);
}
ready?.TrySetResult(endpoint);
Console.WriteLine($"LISTENING {flowEndpoint} profile=" +
DataChannelPubSubConfiguration.GetTransportProfile(options.Carrier));
DataChannelContext context = await provider.WaitForContextAsync(
SampleConfiguration.ClientApplicationUri, options.Carrier, cancellationToken)
.ConfigureAwait(false);
DataChannelContextIdentity identity = context.Identity;
var peerClosed = new TaskCompletionSource<DataChannelResult>(
TaskCreationOptions.RunContinuationsAsynchronously);
void ContextClosed(DataChannelResult result) => peerClosed.TrySetResult(result);
context.Closed += ContextClosed;
if (!context.IsReady)
{
throw new InvalidOperationException("The peer closed its context before the workload started.");
}
EndpointEvidence result = await PubSubWorkload.RunAsync(
options, true, new ServerDataChannelPubSubFlowProvider(provider), telemetry, null,
cancellationToken).ConfigureAwait(false);
int reads = 0;
int publishes = 0;
int calls = 0;
foreach (ServerSession session in sessions)
{
reads += session.ReadDiagnostics(data => checked((int)data.ReadCount.TotalCount));
publishes += session.ReadDiagnostics(data => checked((int)data.PublishCount.TotalCount));
calls += session.ReadDiagnostics(data => checked((int)data.CallCount.TotalCount));
}
if ((!needServices && !sessions.IsEmpty) ||
(options.Shared && (sessions.Count != 1 || reads == 0 || publishes == 0)) ||
(options.SessionAuthorization && calls < 2))
{
throw new InvalidOperationException(
"Actual server Session/Read/Publish/Call accounting differs.");
}
try
{
DataChannelResult closed = await peerClosed.Task.WaitAsync(cancellationToken)
.ConfigureAwait(false);
if (closed is not (DataChannelResult.Good or DataChannelResult.Closed))
{
throw new DataChannelException(closed);
}
}
finally
{
context.Closed -= ContextClosed;
}
return result with
{
Sessions = sessions.Count,
Reads = reads,
Publishes = publishes,
Calls = calls,
ModelEnabled = model,
TransportInitiator = identity.Peer.IsTransportInitiator,
PeerApplicationUri = identity.Peer.PeerApplicationUri
};
}
finally
{
server.CurrentInstance.SessionManager.SessionCreated -= SessionCreated;
using var cleanup = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await application.StopAsync(cleanup.Token).ConfigureAwait(false);
}
}
catch (Exception exception)
{
ready?.TrySetException(exception);
throw;
}
}
/// <summary>
/// Opens the selected UASC data endpoint directly, with no discovery or Services on a dedicated endpoint.
/// </summary>
/// <exception cref="InvalidOperationException"></exception>
/// <exception cref="DataChannelException"></exception>
internal static async Task<EndpointEvidence> RunClientAsync(
SampleOptions options,
SamplePki pki,
ITelemetryContext telemetry,
ServerReady ready,
CancellationToken cancellationToken)
{
ApplicationInstance application = await pki.OpenApplicationAsync(
false, [], telemetry, false, cancellationToken).ConfigureAwait(false);
await using ConfiguredAsyncDisposable applicationLifetime = application.ConfigureAwait(false);
ApplicationConfiguration configuration = application.ApplicationConfiguration!;
ICertificateManager certificates = SamplePki.Manager(application);
using CertificateEntry own = SamplePki.OwnCertificate(certificates);
using Certificate peer = await SamplePki.PeerCertificateAsync(
certificates, SampleConfiguration.ServerApplicationUri, cancellationToken).ConfigureAwait(false);
var policy = new SampleClientPolicy(options, DataChannelCertificateIdentity.GetKeyThumbprint(peer));
var sessionAuthorizer = new SessionDataChannelAuthorizer(policy, TimeProvider.System, telemetry);
await using ConfiguredAsyncDisposable authorizerLifetime = sessionAuthorizer.ConfigureAwait(false);
var factory = new DataChannelContextFactory(
sessionAuthorizer,
new DataChannelOptions(), new DataChannelApplicationBudget(), TimeProvider.System, telemetry);
EndpointDescription endpoint = DescribeEndpoint(ready.FlowEndpoint, options, peer, false);
ServiceMessageContext messageContext = configuration.CreateMessageContext();
var settings = new TransportChannelSettings
{
Description = endpoint,
Configuration = EndpointConfiguration.Create(configuration),
CertificateValidator = certificates,
NamespaceUris = messageContext.NamespaceUris,
Factory = messageContext.Factory,
DataChannels = factory
};
var connector = new UascDataChannelClient(new TcpTransportChannelFactory(), telemetry);
UascDataChannelConnection connection;
Certificate? channelClient = null;
Certificate? channelServer = null;
try
{
channelClient = own.Certificate.AddRef();
channelServer = peer.AddRef();
settings.ClientCertificate = channelClient;
settings.ServerCertificate = channelServer;
connection = await connector.ConnectAsync(
new Uri(ready.FlowEndpoint), settings, cancellationToken).ConfigureAwait(false);
channelClient = null;
channelServer = null;
}
finally
{
channelClient?.Dispose();
channelServer?.Dispose();
}
await using ConfiguredAsyncDisposable connectionLifetime = connection.ConfigureAwait(false);
uint secureChannelId = connection.SecureChannelId;
ClientSession? session = options.Shared || options.SessionAuthorization
? await OpenSessionAsync(options, pki, telemetry, configuration, certificates, own, peer,
ready, connection, cancellationToken).ConfigureAwait(false)
: null;
var serviceScope = new SessionScope(session, options.Shared ? connection : null);
await using ConfiguredAsyncDisposable serviceLifetime = serviceScope.ConfigureAwait(false);
if (session != null)
{
await CheckOptionalModelAsync(session, options.SessionAuthorization, cancellationToken)
.ConfigureAwait(false);
}
DataChannelSessionControl? control = options.SessionAuthorization
? new DataChannelSessionControl(session!, sessionAuthorizer, TimeProvider.System)
: null;
SampleGrantProvider? grants = control != null
? new SampleGrantProvider(control, ExpandedNodeId.ToNodeId(SourceId, session!.NamespaceUris))
: null;
var flows = new DataChannelContextPubSubFlowProvider(
connection.Context, openLocally: true, grantProvider: grants);
await using ConfiguredAsyncDisposable flowLifetime = flows.ConfigureAwait(false);
var probe = new SharedServiceProbe();
int revisionCalls = 0;
bool sameTransport = session != null && ReferenceEquals(session.TransportChannel, connection.Services);
uint serviceChannelId = (session?.TransportChannel as ISecureChannel)?.CurrentToken?.ChannelId ?? 0;
if (options.Shared && (!sameTransport || secureChannelId == 0 || serviceChannelId != secureChannelId))
{
throw new InvalidOperationException("Services and PubSub did not select the same SecureChannel.");
}
if (options.Shared &&
(session!.ConfiguredEndpoint.Description.TransportProfileUri != DataChannelProfiles.SharedUasc ||
session.ConfiguredEndpoint.Description.EndpointUrl != ready.FlowEndpoint))
{
throw new InvalidOperationException(
"The shared Session changed the selected flow endpoint or profile.");
}
if (!options.Shared && connection.Services != null)
{
throw new InvalidOperationException("The dedicated data endpoint unexpectedly exposes Services.");
}
async Task ReviseAsync(DataChannel channel, CancellationToken token)
{
if (options.Shared)
{
await probe.RunAsync(session!, token).ConfigureAwait(false);
}
DataChannelParametersDataType reduced = channel.Parameters;
reduced.MaxMessageSize = 16384;
reduced.MaxReceiveBytes = 131072;
if (control != null)
{
DataChannelStatusDataType modified = await control.ModifyAsync(channel, 1, reduced, token)
.ConfigureAwait(false);
revisionCalls++;
if (modified.Revision != 2)
{
throw new InvalidOperationException("The Session Call did not finish the data revision.");
}
}
else
{
DataChannelResult result = await channel.UpdateAsync(reduced, 1, token).ConfigureAwait(false);
if (result != DataChannelResult.Good)
{
throw new DataChannelException(result);
}
}
}
EndpointEvidence result = await PubSubWorkload.RunAsync(
options, false, flows, telemetry, ReviseAsync, cancellationToken).ConfigureAwait(false);
if (connection.SecureChannelId != secureChannelId ||
(options.Shared &&
(session!.TransportChannel as ISecureChannel)?.CurrentToken?.ChannelId != secureChannelId))
{
throw new InvalidOperationException(
"The flow or ordinary traffic silently changed SecureChannels.");
}
return result with
{
SecureChannelId = secureChannelId,
ServiceSecureChannelId = serviceChannelId,
SameServiceTransport = sameTransport,
Sessions = session == null ? 0 : 1,
Reads = probe.Reads,
Publishes = probe.Publishes,
Notifications = probe.Notifications,
Calls = (grants?.Calls ?? 0) + revisionCalls,
TransportInitiator = connection.Context.Identity.Peer.IsTransportInitiator,
PeerApplicationUri = connection.Context.Identity.Peer.PeerApplicationUri
};
}
/// <summary>
/// Attaches a Session to the exact shared transport, or opens a separate ordinary control connection.
/// </summary>
/// <exception cref="InvalidOperationException"></exception>
private static async Task<ClientSession> OpenSessionAsync(
SampleOptions options,
SamplePki pki,
ITelemetryContext telemetry,
ApplicationConfiguration configuration,
ICertificateManager certificates,
CertificateEntry own,
Certificate peer,
ServerReady ready,
UascDataChannelConnection connection,
CancellationToken cancellationToken)
{
CertificateIdentifier identifier = configuration.SecurityConfiguration.ApplicationCertificates[0];
using Certificate? materialized = await certificates.CertificateProvider.GetPrivateKeyCertificateAsync(
identifier, pki.Password, configuration.ApplicationUri, cancellationToken).ConfigureAwait(false) ??
throw new InvalidOperationException(
"The manager could not load the X.509 user identity.");
UserIdentity identity = await UserIdentity.CreateAsync(
identifier, pki.Password, certificates.CertificateProvider, cancellationToken).ConfigureAwait(false);
ISessionFactory sessions = new DefaultSessionFactory(telemetry)
.WithSubscriptionEngine(DefaultSubscriptionEngineFactory.Instance);
EndpointDescription description = options.Shared
? DescribeEndpoint(ready.FlowEndpoint, options, peer, false)
: DescribeEndpoint(ready.ControlEndpoint, options, peer, true);
var endpoint = new ConfiguredEndpoint(null, description, EndpointConfiguration.Create(configuration));
if (!options.Shared)
{
return await sessions.CreateAsync(
configuration, endpoint, false, "SecureChannelPubSubControl", 60000, identity, [],
cancellationToken).ConfigureAwait(false);
}
ClientSession session = sessions.Create(
connection.Services!, configuration, endpoint, own.Certificate.AddRef());
try
{
await session.OpenAsync("SecureChannelPubSubShared", 60000, identity, [],
checkDomain: true, closeChannel: false, cancellationToken).ConfigureAwait(false);
return session;
}
catch
{
using var cleanup = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await connection.CloseAsync(cleanup.Token).ConfigureAwait(false);
await session.DisposeAsync().ConfigureAwait(false);
throw;
}
}
/// <summary>
/// Verifies optional model presence through the actual ordinary Session, never the diagnostics node manager.
/// </summary>
/// <exception cref="InvalidOperationException"></exception>
private static async Task CheckOptionalModelAsync(
ClientSession session,
bool expected,
CancellationToken cancellationToken)
{
ReadResponse response = await session.ReadAsync(
null, 0, TimestampsToReturn.Neither,
[new ReadValueId { NodeId = ObjectIds.DataChannelManager, AttributeId = Attributes.NodeClass }],
cancellationToken).ConfigureAwait(false);
if (response.Results.Count != 1 ||
(expected &&
(StatusCode.IsBad(response.Results[0].StatusCode) ||
!response.Results[0].WrappedValue.TryGetValue(out int nodeClass) ||
nodeClass != (int)NodeClass.Object)) ||
(!expected && response.Results[0].StatusCode != StatusCodes.BadNodeIdUnknown))
{
throw new InvalidOperationException("The optional model did not match its actual Session Read.");
}
}
/// <summary>
/// Constructs only the explicitly selected endpoint using independently pinned application evidence.
/// </summary>
/// <exception cref="InvalidOperationException"></exception>
private static EndpointDescription DescribeEndpoint(
string address,
SampleOptions options,
Certificate peer,
bool control)
{
var uri = new Uri(address);
string expectedPath = control ? "/control" :
options.Shared ? DataChannelProfiles.SharedResource : DataChannelProfiles.DedicatedResource;
if (uri.Scheme != Utils.UriSchemeOpcTcp ||
!uri.IsLoopback ||
uri.AbsolutePath != expectedPath ||
uri.Query.Length != 0 ||
uri.Fragment.Length != 0 ||
uri.UserInfo.Length != 0)
{
throw new InvalidOperationException("Only the selected exact loopback UASC resource is supported.");
}
return new EndpointDescription
{
EndpointUrl = address,
TransportProfileUri = control ? Profiles.UaTcpTransport :
DataChannelPubSubConfiguration.GetTransportProfile(options.Carrier),
SecurityMode = MessageSecurityMode.SignAndEncrypt,
SecurityPolicyUri = SecurityPolicies.Basic256Sha256,
ServerCertificate = new ByteString(peer.RawData),
Server = new ApplicationDescription
{
ApplicationUri = SampleConfiguration.ServerApplicationUri,
ApplicationName = new LocalizedText("SecureChannelPubSubServer"),
ProductUri = SampleConfiguration.ModelNamespace,
ApplicationType = ApplicationType.Server
},
UserIdentityTokens = control || options.Shared
?
[
new UserTokenPolicy
{
PolicyId = "certificate",
TokenType = UserTokenType.Certificate,
SecurityPolicyUri = SecurityPolicies.Basic256Sha256
}
]
: []
};
}
/// <summary>
/// Chooses a bounded local port; actual readiness is signalled only after the standard listener opens.
/// </summary>
private static int FreePort()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
return ((IPEndPoint)listener.LocalEndpoint).Port;
}
/// <summary>
/// Disposes an optional Session before its owning UASC connection.
/// </summary>
private sealed class SessionScope(
ClientSession? session,
UascDataChannelConnection? sharedConnection) : IAsyncDisposable
{
/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
if (session != null)
{
using var cleanup = new CancellationTokenSource(TimeSpan.FromSeconds(10));
try
{
StatusCode result = await session.CloseAsync(10000, false, cleanup.Token)
.ConfigureAwait(false);
if (!StatusCode.IsGood(result))
{
throw new ServiceResultException(result, "The ordinary Session did not close cleanly.");
}
}
finally
{
try
{
if (sharedConnection != null)
{
await sharedConnection.CloseAsync(cleanup.Token).ConfigureAwait(false);
}
}
finally
{
await session.DisposeAsync().ConfigureAwait(false);
}
}
}
}
}
private static readonly ExpandedNodeId SourceId = new("Telemetry", SampleConfiguration.ModelNamespace);
}
}