/* ======================================================================== * 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; } } }