/* ========================================================================
* Copyright (c) 2005-2025 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.Generic;
using System.Collections.ObjectModel;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Opc.Ua.Security.Certificates;
namespace Opc.Ua.Server.TestFramework
{
///
/// Extensions for server fixture.
///
public static class ServerFixtureUtils
{
public const double DefaultSessionTimeout = 120000;
public const uint DefaultMaxResponseMessageSize = 128 * 1024;
public const int MinTestPort = 50000;
public const int MaxTestPort = 65000;
private const string kDefaultClientApplicationUri =
"urn:localhost:opcfoundation.org:ServerFixtureClient";
///
/// Create and Activate a session without security.
///
///
/// The request header is used to call services directly,
/// without establishing a session with a client.
///
/// The server to connect to.
/// A session name.
/// The request header for the session.
///
public static async Task<(RequestHeader, SecureChannelContext)> CreateAndActivateSessionAsync(
this SessionServerBase server,
string sessionName,
bool useSecurity = false,
UserIdentityToken? identityToken = null,
double sessionTimeout = DefaultSessionTimeout,
uint maxResponseMessageSize = DefaultMaxResponseMessageSize,
string? clientApplicationUri = null,
ArrayOf localeIds = default)
{
if (clientApplicationUri != null && clientApplicationUri.Length == 0)
{
throw new ArgumentException(
"The client ApplicationUri must not be empty.",
nameof(clientApplicationUri));
}
// Find TCP endpoint
ArrayOf endpoints = server.GetEndpoints();
EndpointDescription endpoint = useSecurity
? endpoints.Find(e =>
e.TransportProfileUri!
.Equals(Profiles.UaTcpTransport, StringComparison.Ordinal) &&
e.SecurityMode == MessageSecurityMode.Sign &&
e.SecurityPolicyUri == SecurityPolicies.Basic256Sha256) ??
endpoints.Find(e =>
e.TransportProfileUri!
.Equals(Profiles.HttpsBinaryTransport, StringComparison.Ordinal) &&
e.SecurityMode == MessageSecurityMode.Sign &&
e.SecurityPolicyUri == SecurityPolicies.Basic256Sha256)
: endpoints.Find(e =>
e.TransportProfileUri!
.Equals(Profiles.UaTcpTransport, StringComparison.Ordinal) ||
e.TransportProfileUri
.Equals(Profiles.HttpsBinaryTransport, StringComparison.Ordinal));
endpoint ??= endpoints.Find(e =>
e.TransportProfileUri!
.Equals(Profiles.UaTcpTransport, StringComparison.Ordinal) ||
e.TransportProfileUri
.Equals(Profiles.HttpsBinaryTransport, StringComparison.Ordinal)) ??
throw new NotSupportedException("Unsupported transport profile or security policy.");
if (useSecurity)
{
endpoint.SecurityMode = MessageSecurityMode.Sign;
endpoint.SecurityPolicyUri = SecurityPolicies.Basic256Sha256;
}
else
{
endpoint.SecurityMode = MessageSecurityMode.None;
endpoint.SecurityPolicyUri = SecurityPolicies.None;
}
Certificate? clientCertificate = null;
try
{
ByteString clientNonce = default;
ByteString clientCertificateData = default;
byte[]? clientChannelCertificate = null;
byte[]? serverChannelCertificate = null;
byte[]? channelThumbprint = null;
if (useSecurity)
{
clientCertificate = CertificateBuilder
.Create("CN=ServerFixtureClient")
.AddExtension(
new X509SubjectAltNameExtension(
clientApplicationUri ?? kDefaultClientApplicationUri,
[Utils.GetHostName()]))
.SetRSAKeySize(CertificateFactory.DefaultKeySize)
.CreateForRSA();
clientNonce = Nonce.CreateRandomNonceData(32).ToByteString();
clientCertificateData = clientCertificate.RawData.ToByteString();
clientChannelCertificate = clientCertificate.RawData;
channelThumbprint = Nonce.CreateRandomNonceData(32);
if (!endpoint.ServerCertificate.IsEmpty)
{
using CertificateCollection serverCertificateChain =
Utils.ParseCertificateChainBlob(
endpoint.ServerCertificate,
server.MessageContext.Telemetry);
serverChannelCertificate = serverCertificateChain[0].RawData;
}
}
var secureChannelContext = new SecureChannelContext(
sessionName,
endpoint,
RequestEncoding.Binary,
clientChannelCertificate,
serverChannelCertificate,
channelThumbprint);
var requestHeader = new RequestHeader();
ApplicationDescription clientDescription = (clientApplicationUri == null
? null
: new ApplicationDescription
{
ApplicationUri = clientApplicationUri,
ApplicationName = new LocalizedText("ServerFixtureClient"),
ApplicationType = ApplicationType.Client
})!;
CreateSessionResponse createSessionResponse = await server.CreateSessionAsync(
secureChannelContext,
requestHeader,
clientDescription,
null,
null,
sessionName,
clientNonce,
clientCertificateData,
sessionTimeout,
maxResponseMessageSize,
RequestLifetime.None).ConfigureAwait(false);
ValidateResponse(createSessionResponse.ResponseHeader);
SignatureData? clientSignature = null;
if (useSecurity)
{
SecurityPolicyInfo securityPolicy =
SecurityPolicies.Default.GetInfo(endpoint.SecurityPolicyUri!)!;
using CertificateCollection serverCertificateChain =
Utils.ParseCertificateChainBlob(
createSessionResponse.ServerCertificate,
server.MessageContext.Telemetry);
byte[] dataToSign = securityPolicy.GetClientSignatureData(
secureChannelContext.ChannelThumbprint.ToArrayOrNull(),
createSessionResponse.ServerNonce.ToArray(),
serverCertificateChain[0].RawData,
secureChannelContext.ServerChannelCertificate.ToArrayOrNull(),
secureChannelContext.ClientChannelCertificate.ToArrayOrNull(),
clientNonce.ToArray());
clientSignature = SecurityPolicies.Default.CreateSignatureData(
securityPolicy,
clientCertificate!,
dataToSign);
}
requestHeader.AuthenticationToken = createSessionResponse.AuthenticationToken;
ActivateSessionResponse activateSessionResponse = await server.ActivateSessionAsync(
secureChannelContext,
requestHeader,
clientSignature,
[],
localeIds.IsNull ? [] : localeIds,
identityToken != null ? new ExtensionObject(identityToken) : default,
null,
RequestLifetime.None).ConfigureAwait(false);
ValidateResponse(activateSessionResponse.ResponseHeader);
return (requestHeader, secureChannelContext);
}
finally
{
clientCertificate?.Dispose();
}
}
///
/// Close a session.
///
/// The server where the session is active.
/// The request header of the session.
public static async Task CloseSessionAsync(
this SessionServerBase server,
SecureChannelContext secureChannelContext,
RequestHeader requestHeader,
CancellationToken ct)
{
// close session
using var requestLifetime = new RequestLifetime(ct);
CloseSessionResponse response = await server.CloseSessionAsync(
secureChannelContext,
requestHeader,
true,
requestLifetime).ConfigureAwait(false);
ValidateResponse(response.ResponseHeader);
}
///
/// Validate the response of a service call.
///
/// The response header of the service call.
///
public static void ValidateResponse(ResponseHeader header)
{
if (header == null)
{
throw new ServiceResultException(
StatusCodes.BadUnknownResponse,
"Null header in response.");
}
if (StatusCode.IsBad(header.ServiceResult))
{
throw new ServiceResultException(
new ServiceResult(
header.ServiceResult,
header.ServiceDiagnostics,
header.StringTable));
}
}
///
/// Validate the response of a service call and validate the number of items returned.
///
///
///
///
/// On the client the generated code already validates the response but the
/// check is duplicated here to catch also issues when running tests within
/// the shared client/server context.
///
/// The response header of the service call.
/// The list of returned values by the service call.
/// The list of requests passed to the service call.
///
///
public static void ValidateResponse(
ResponseHeader header,
ArrayOf response,
ArrayOf request)
{
ValidateResponse(header);
if (response is ArrayOf)
{
throw new ArgumentException(
"Must call ValidateDiagnosticInfos() for ArrayOf.",
nameof(response));
}
if (response.Count != request.Count)
{
throw ServiceResultException.Unexpected(
"The server returned {0} responses but {1} requests were made.",
response.Count,
request.Count);
}
for (int ii = 0; ii < response.Count; ii++)
{
if (response[ii] is null)
{
throw ServiceResultException.Unexpected(
"The server returned responses contain a null response at index {0}.", ii);
}
}
}
///
/// Validate the diagnostic response of a service call.
///
///
///
public static void ValidateDiagnosticInfos(
ArrayOf response,
ArrayOf request,
ArrayOf stringTable,
ILogger logger)
{
// returning an empty list for diagnostic info arrays is allowed.
if (response.Count != 0)
{
if (response.Count != request.Count)
{
throw ServiceResultException.Unexpected(
"The server forgot to fill in the DiagnosticInfos array correctly when returning an operation level error.");
}
// now validate the string table
if (!stringTable.IsEmpty)
{
for (int ii = 0; ii < response.Count; ii++)
{
if (response[ii] is DiagnosticInfo diagnosticInfo &&
!diagnosticInfo.IsNullDiagnosticInfo)
{
if (diagnosticInfo.NamespaceUri >= stringTable.Count ||
diagnosticInfo.SymbolicId >= stringTable.Count ||
diagnosticInfo.Locale >= stringTable.Count ||
diagnosticInfo.LocalizedText >= stringTable.Count)
{
throw ServiceResultException.Unexpected(
"The server forgot to fill in string table for the DiagnosticInfos " +
"array correctly when returning an operation level error.");
}
var serviceResult = new ServiceResult(
StatusCodes.Good,
ii,
response,
stringTable);
if (logger.IsEnabled(LogLevel.Information))
{
logger.LogInformation("DiagnosticInfo: {ServiceResult}", serviceResult);
}
}
}
}
}
}
///
/// Create a browse description from a node id collection.
///
/// The node id collection.
/// The template for the browse description for each node id.
public static ArrayOf CreateBrowseDescriptionCollectionFromNodeId(
ArrayOf nodeIdCollection,
BrowseDescription template)
{
var browseDescriptionCollection = new List();
foreach (NodeId nodeId in nodeIdCollection)
{
var browseDescription = (BrowseDescription)template.MemberwiseClone();
browseDescription.NodeId = nodeId;
browseDescriptionCollection.Add(browseDescription);
}
return browseDescriptionCollection;
}
///
/// Create the continuation point collection from the browse result
/// collection for the BrowseNext service.
///
/// The browse result collection to use.
/// The collection of continuation points for the BrowseNext service.
public static ArrayOf PrepareBrowseNext(
ArrayOf browseResultCollection)
{
var continuationPoints = new List();
foreach (BrowseResult browseResult in browseResultCollection)
{
if (!browseResult.ContinuationPoint.IsEmpty)
{
continuationPoints.Add(browseResult.ContinuationPoint);
}
}
return continuationPoints;
}
///
/// A dictionary of all node attributes.
///
public static readonly ReadOnlyDictionary AttributesIds = new(
new SortedDictionary
{
{ Attributes.NodeId, default },
{ Attributes.NodeClass, default },
{ Attributes.BrowseName, default },
{ Attributes.DisplayName, default },
{ Attributes.Description, default },
{ Attributes.WriteMask, default },
{ Attributes.UserWriteMask, default },
{ Attributes.DataType, default },
{ Attributes.ValueRank, default },
{ Attributes.ArrayDimensions, default },
{ Attributes.AccessLevel, default },
{ Attributes.UserAccessLevel, default },
{ Attributes.Historizing, default },
{ Attributes.MinimumSamplingInterval, default },
{ Attributes.EventNotifier, default },
{ Attributes.Executable, default },
{ Attributes.UserExecutable, default },
{ Attributes.IsAbstract, default },
{ Attributes.InverseName, default },
{ Attributes.Symmetric, default },
{ Attributes.ContainsNoLoops, default },
{ Attributes.DataTypeDefinition, default },
{ Attributes.RolePermissions, default },
{ Attributes.UserRolePermissions, default },
{ Attributes.AccessRestrictions, default },
{ Attributes.AccessLevelEx, default }
});
///
/// Get free IP Port.
///
public static int GetNextFreeIPPort()
{
var endpoint = new IPEndPoint(IPAddress.Any, 0);
using var socket = new Socket(
endpoint.AddressFamily,
SocketType.Stream,
ProtocolType.Tcp);
socket.Bind(endpoint);
if (socket.LocalEndPoint is IPEndPoint ep)
{
return ep.Port;
}
return 0;
}
///
/// Whether starting a server failed because the port it was asked to
/// bind is already taken, in which case picking another one and
/// retrying is worthwhile.
///
///
/// The port handed out by is only free
/// at the instant it is queried - the socket is closed again before the
/// server binds - so a parallel fixture or an unrelated process on the
/// agent can take it in between. How that surfaces depends on the
/// transport: the UA-TCP listener reports
/// , while the HTTPS
/// listeners bind through Kestrel, which throws an
/// wrapping an
/// AddressInUseException and a
///
/// . Only the former used to be retried,
/// so the https and opc.https fixtures failed their entire
/// OneTimeSetUp on a port collision.
///
/// The exception the server start threw.
public static bool IsPortUnavailable(Exception exception)
{
for (Exception current = exception; current != null; current = current.InnerException!)
{
if (current is ServiceResultException sre &&
sre.StatusCode == StatusCodes.BadNoCommunication)
{
return true;
}
if (current is SocketException socket &&
(socket.SocketErrorCode == SocketError.AddressAlreadyInUse ||
socket.SocketErrorCode == SocketError.AccessDenied))
{
return true;
}
// Kestrel reports the collision as
// Microsoft.AspNetCore.Connections.AddressInUseException. It
// normally carries the SocketException matched above as its
// inner exception, but match the type by name too so the
// detection does not depend on that - and so this file needs no
// reference to the ASP.NET Core connection abstractions.
if (current.GetType().Name == "AddressInUseException")
{
return true;
}
if (current is AggregateException aggregate)
{
foreach (Exception inner in aggregate.InnerExceptions)
{
if (IsPortUnavailable(inner))
{
return true;
}
}
}
}
return false;
}
}
}