d98f4a5bf1
The knob was authored by the AdminUI and parsed, but consumed by nothing. It now caps the probe deadline (shorter of the caller's budget and the config; never extends it; 1 s floor), the same rule as the Galaxy probe. Refs #743 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
162 lines
7.7 KiB
C#
162 lines
7.7 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Probe for the <see cref="OpcUaClientDriverOptions"/>-shaped driver config. Parses the
|
|
/// first endpoint URL (from <see cref="OpcUaClientDriverOptions.EndpointUrls"/> or the
|
|
/// convenience <see cref="OpcUaClientDriverOptions.EndpointUrl"/> fallback), performs a
|
|
/// TCP-connect preflight to detect closed ports quickly, then executes a real OPC UA
|
|
/// <c>GetEndpoints</c> discovery handshake (mirroring the driver's
|
|
/// <c>SelectMatchingEndpointAsync</c>) to confirm the remote process is actually an OPC UA
|
|
/// server. No session is opened and no authentication is required.
|
|
/// </summary>
|
|
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() },
|
|
};
|
|
|
|
/// <inheritdoc />
|
|
public string DriverType => "OpcUaClient";
|
|
|
|
/// <inheritdoc />
|
|
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
|
|
{
|
|
// G-2b note: the probe does an UNAUTHENTICATED GetEndpoints discovery preflight
|
|
// (BuildMinimalAppConfig sets no user identity) and never reads opts.Password /
|
|
// opts.UserCertificatePassword. Those credentials — including any secret: refs — are
|
|
// therefore intentionally NOT resolved here; resolving them would be dead code. Secret
|
|
// resolution happens lazily in OpcUaClientDriver's async session-open path, where the
|
|
// credentials are actually consumed.
|
|
OpcUaClientDriverOptions? opts;
|
|
try { opts = JsonSerializer.Deserialize<OpcUaClientDriverOptions>(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. The config's ProbeTimeoutSeconds caps it (O-4).
|
|
timeout = EffectiveTimeout(timeout, opts.ProbeTimeoutSeconds);
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// R06:O-4 — the deadline the probe actually runs under: the caller's budget, tightened by the config's
|
|
/// <see cref="OpcUaClientDriverOptions.ProbeTimeoutSeconds"/> when that is positive and shorter (it can only
|
|
/// shorten the budget — the caller's own deadline fires regardless). A non-positive caller budget is floored
|
|
/// to 1 s so the probe runs briefly rather than cancelling before it starts. Same rule as the Galaxy probe.
|
|
/// </summary>
|
|
/// <param name="callerTimeout">The budget the host handed the probe.</param>
|
|
/// <param name="configuredSeconds">The config's <c>ProbeTimeoutSeconds</c>; ignored when not positive.</param>
|
|
/// <returns>The effective probe deadline.</returns>
|
|
internal static TimeSpan EffectiveTimeout(TimeSpan callerTimeout, int configuredSeconds)
|
|
{
|
|
var caller = callerTimeout > TimeSpan.Zero ? callerTimeout : TimeSpan.FromSeconds(1);
|
|
if (configuredSeconds <= 0) return caller;
|
|
var configured = TimeSpan.FromSeconds(configuredSeconds);
|
|
return configured < caller ? configured : caller;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds a minimal <see cref="ApplicationConfiguration"/> suitable for unauthenticated
|
|
/// discovery (GetEndpoints). No PKI stores, no session certificate — discovery traffic
|
|
/// is plain-text and does not require a client certificate.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|