using System.Diagnostics; using System.Net.Sockets; using System.Text.Json; using System.Text.Json.Serialization; using Opc.Ua; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; /// /// Probe for the -shaped driver config. Parses the /// first endpoint URL (from or the /// convenience fallback), performs a /// TCP-connect preflight to detect closed ports quickly, then executes a real OPC UA /// GetEndpoints discovery handshake (mirroring the driver's /// SelectMatchingEndpointAsync) to confirm the remote process is actually an OPC UA /// server. No session is opened and no authentication is required. /// public sealed class OpcUaClientDriverProbe : IDriverProbe { // Kept identical to OpcUaClientDriverFactoryExtensions.JsonOptions so the probe and the // factory parse a given DriverConfig the same way. The JsonStringEnumConverter lets // enum-valued knobs be authored as their string names. private static readonly JsonSerializerOptions _opts = new() { PropertyNameCaseInsensitive = true, UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, Converters = { new JsonStringEnumConverter() }, }; /// public string DriverType => "OpcUaClient"; /// public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) { OpcUaClientDriverOptions? opts; try { opts = JsonSerializer.Deserialize(configJson, _opts); } catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); } if (opts is null) return new(false, "Config JSON deserialized to null.", null); var (host, port, endpointUrl) = ExtractTarget(opts); if (string.IsNullOrWhiteSpace(host) || port <= 0) return new(false, "Config has no host/port to probe.", null); // Bound the whole probe (both phases) by the caller timeout, independent of `ct` — a // stalled OPC UA endpoint that accepts TCP but never answers GetEndpoints must not block // the probe when the caller passes CancellationToken.None. using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(timeout); // --- TCP preflight: fast-fail for closed ports / unreachable hosts --- var sw = Stopwatch.StartNew(); try { using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); await socket.ConnectAsync(host, port, cts.Token); } catch (SocketException ex) { return new(false, $"Connect failed: {ex.SocketErrorCode}", null); } catch (OperationCanceledException) { return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null); } catch (Exception ex) { return new(false, ex.Message, null); } // --- OPC UA discovery handshake: confirm the remote is an OPC UA server --- // Mirrors OpcUaClientDriver.SelectMatchingEndpointAsync exactly: // DiscoveryClient.CreateAsync(appConfig, new Uri(endpointUrl), DiagnosticsMasks.None, ct) // GetEndpoints needs no session, no application certificate, and no authentication. try { var appConfig = BuildMinimalAppConfig(); using var client = await DiscoveryClient.CreateAsync( appConfig, new Uri(endpointUrl), DiagnosticsMasks.None, cts.Token).ConfigureAwait(false); var endpoints = await client.GetEndpointsAsync(null, cts.Token).ConfigureAwait(false); sw.Stop(); if (endpoints is { Count: > 0 }) return new(true, $"OPC UA: {endpoints.Count} endpoint(s)", sw.Elapsed); return new(false, $"Reachable at {host}:{port} but OPC UA handshake failed: server published 0 endpoints", null); } catch (OperationCanceledException) { return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null); } catch (Exception ex) { return new(false, $"Reachable at {host}:{port} but OPC UA handshake failed: {ex.Message}", null); } } /// /// Builds a minimal suitable for unauthenticated /// discovery (GetEndpoints). No PKI stores, no session certificate — discovery traffic /// is plain-text and does not require a client certificate. /// private static ApplicationConfiguration BuildMinimalAppConfig() => new() { ApplicationName = "OtOpcUa.Probe", ApplicationType = ApplicationType.Client, SecurityConfiguration = new SecurityConfiguration { AutoAcceptUntrustedCertificates = true, }, TransportQuotas = new TransportQuotas(), ClientConfiguration = new ClientConfiguration(), DisableHiResClock = true, }; private static (string host, int port, string endpointUrl) ExtractTarget(OpcUaClientDriverOptions opts) { // EndpointUrls wins over the convenience EndpointUrl when both are set. var endpointUrl = opts.EndpointUrls.FirstOrDefault() ?? (string.IsNullOrWhiteSpace(opts.EndpointUrl) ? null : opts.EndpointUrl); if (endpointUrl is null) return (string.Empty, 0, string.Empty); // Parse as a URI — opc.tcp://host:port is a valid URI. if (!Uri.TryCreate(endpointUrl, UriKind.Absolute, out var uri)) return (string.Empty, 0, string.Empty); var host = uri.Host; var port = uri.IsDefaultPort ? 4840 : uri.Port; return (host, port, endpointUrl); } }