Adds Security section to appsettings.json with configurable OPC UA transport profiles (None, Basic256Sha256-Sign, Basic256Sha256-SignAndEncrypt), certificate policy settings, and a configurable BindAddress for the OPC UA endpoint. Defaults preserve backward compatibility. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
189 lines
7.4 KiB
C#
189 lines
7.4 KiB
C#
using Opc.Ua;
|
|
using Opc.Ua.Client;
|
|
using Opc.Ua.Configuration;
|
|
|
|
namespace OpcUaCli;
|
|
|
|
public static class OpcUaHelper
|
|
{
|
|
/// <summary>
|
|
/// Creates an OPC UA client session for the specified endpoint URL.
|
|
/// </summary>
|
|
/// <param name="endpointUrl">The OPC UA endpoint URL to connect to.</param>
|
|
/// <param name="username">Optional username for authentication.</param>
|
|
/// <param name="password">Optional password for authentication.</param>
|
|
/// <param name="security">The requested transport security mode: "none", "sign", or "encrypt".</param>
|
|
/// <returns>An active OPC UA client session.</returns>
|
|
public static async Task<Session> ConnectAsync(string endpointUrl, string? username = null, string? password = null,
|
|
string security = "none")
|
|
{
|
|
var config = new ApplicationConfiguration
|
|
{
|
|
ApplicationName = "OpcUaCli",
|
|
ApplicationUri = "urn:localhost:OpcUaCli",
|
|
ApplicationType = ApplicationType.Client,
|
|
SecurityConfiguration = new SecurityConfiguration
|
|
{
|
|
ApplicationCertificate = new CertificateIdentifier
|
|
{
|
|
StoreType = CertificateStoreType.Directory,
|
|
StorePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "OpcUaCli", "pki", "own")
|
|
},
|
|
TrustedIssuerCertificates = new CertificateTrustList
|
|
{
|
|
StoreType = CertificateStoreType.Directory,
|
|
StorePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "OpcUaCli", "pki", "issuer")
|
|
},
|
|
TrustedPeerCertificates = new CertificateTrustList
|
|
{
|
|
StoreType = CertificateStoreType.Directory,
|
|
StorePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "OpcUaCli", "pki", "trusted")
|
|
},
|
|
RejectedCertificateStore = new CertificateTrustList
|
|
{
|
|
StoreType = CertificateStoreType.Directory,
|
|
StorePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "OpcUaCli", "pki", "rejected")
|
|
},
|
|
AutoAcceptUntrustedCertificates = true
|
|
},
|
|
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 }
|
|
};
|
|
|
|
#pragma warning disable CS0618 // Sync/obsolete API is fine for a CLI tool
|
|
await config.Validate(ApplicationType.Client);
|
|
config.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
|
|
|
var requestedMode = ParseSecurityMode(security);
|
|
|
|
EndpointDescription endpoint;
|
|
if (requestedMode == MessageSecurityMode.None)
|
|
{
|
|
endpoint = CoreClientUtils.SelectEndpoint(config, endpointUrl, false);
|
|
}
|
|
else
|
|
{
|
|
// For secure connections, ensure the client has a certificate
|
|
var app = new ApplicationInstance
|
|
{
|
|
ApplicationName = "OpcUaCli",
|
|
ApplicationType = ApplicationType.Client,
|
|
ApplicationConfiguration = config
|
|
};
|
|
await app.CheckApplicationInstanceCertificatesAsync(false, 2048);
|
|
|
|
// Discover endpoints and pick the one matching the requested security mode
|
|
endpoint = SelectSecureEndpoint(config, endpointUrl, requestedMode);
|
|
}
|
|
|
|
var endpointConfig = EndpointConfiguration.Create(config);
|
|
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfig);
|
|
|
|
UserIdentity identity = (username != null)
|
|
? new UserIdentity(username, System.Text.Encoding.UTF8.GetBytes(password ?? ""))
|
|
: new UserIdentity();
|
|
|
|
var session = await Session.Create(
|
|
config,
|
|
configuredEndpoint,
|
|
false,
|
|
"OpcUaCli",
|
|
60000,
|
|
identity,
|
|
null);
|
|
|
|
return session;
|
|
#pragma warning restore CS0618
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses the security mode string from the CLI option.
|
|
/// </summary>
|
|
private static MessageSecurityMode ParseSecurityMode(string security)
|
|
{
|
|
return (security ?? "none").Trim().ToLowerInvariant() switch
|
|
{
|
|
"none" => MessageSecurityMode.None,
|
|
"sign" => MessageSecurityMode.Sign,
|
|
"encrypt" or "signandencrypt" => MessageSecurityMode.SignAndEncrypt,
|
|
_ => throw new ArgumentException(
|
|
$"Unknown security mode '{security}'. Valid values: none, sign, encrypt")
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Discovers server endpoints and selects one matching the requested security mode,
|
|
/// preferring Basic256Sha256 when multiple matches exist.
|
|
/// </summary>
|
|
private static EndpointDescription SelectSecureEndpoint(ApplicationConfiguration config,
|
|
string endpointUrl, MessageSecurityMode requestedMode)
|
|
{
|
|
// Use discovery to get all endpoints
|
|
using var client = DiscoveryClient.Create(new Uri(endpointUrl));
|
|
var allEndpoints = client.GetEndpoints(null);
|
|
|
|
EndpointDescription? best = null;
|
|
|
|
foreach (var ep in allEndpoints)
|
|
{
|
|
if (ep.SecurityMode != requestedMode)
|
|
continue;
|
|
|
|
if (best == null)
|
|
{
|
|
best = ep;
|
|
continue;
|
|
}
|
|
|
|
// Prefer Basic256Sha256
|
|
if (ep.SecurityPolicyUri == SecurityPolicies.Basic256Sha256)
|
|
best = ep;
|
|
}
|
|
|
|
if (best == null)
|
|
{
|
|
var available = string.Join(", ", allEndpoints.Select(e => $"{e.SecurityMode}/{e.SecurityPolicyUri}"));
|
|
throw new InvalidOperationException(
|
|
$"No endpoint found with security mode '{requestedMode}'. Available endpoints: {available}");
|
|
}
|
|
|
|
// Rewrite endpoint URL to use the user-supplied hostname instead of the server's
|
|
// internal address (e.g., 0.0.0.0 -> localhost) to handle NAT/hostname differences
|
|
var serverUri = new Uri(best.EndpointUrl);
|
|
var requestedUri = new Uri(endpointUrl);
|
|
if (serverUri.Host != requestedUri.Host)
|
|
{
|
|
var builder = new UriBuilder(best.EndpointUrl)
|
|
{
|
|
Host = requestedUri.Host
|
|
};
|
|
best.EndpointUrl = builder.ToString();
|
|
}
|
|
|
|
return best;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Converts a raw command-line string into the runtime type expected by the target node.
|
|
/// </summary>
|
|
/// <param name="rawValue">The raw string supplied by the user.</param>
|
|
/// <param name="currentValue">The current node value used to infer the target type.</param>
|
|
/// <returns>A typed value suitable for an OPC UA write request.</returns>
|
|
public static object ConvertValue(string rawValue, object? currentValue)
|
|
{
|
|
return currentValue switch
|
|
{
|
|
bool => bool.Parse(rawValue),
|
|
byte => byte.Parse(rawValue),
|
|
short => short.Parse(rawValue),
|
|
ushort => ushort.Parse(rawValue),
|
|
int => int.Parse(rawValue),
|
|
uint => uint.Parse(rawValue),
|
|
long => long.Parse(rawValue),
|
|
ulong => ulong.Parse(rawValue),
|
|
float => float.Parse(rawValue),
|
|
double => double.Parse(rawValue),
|
|
_ => rawValue
|
|
};
|
|
}
|
|
}
|