using System.Diagnostics; using System.Net.Sockets; using System.Text.Json; using System.Text.Json.Serialization; using Grpc.Core; using ZB.MOM.WW.MxGateway.Client; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy; /// /// Probe for the -shaped driver config. Parses the /// Gateway.Endpoint gRPC endpoint (e.g. http://host:5120 or host:5120), /// does a fast TCP-connect preflight to fail closed ports quickly, then issues a lightweight, /// read-only gRPC ping — the GalaxyRepository.TestConnection unary RPC — against the /// mxaccessgw to confirm the remote is actually a live gateway speaking gRPC. The channel is /// built the same way the driver builds it (cleartext for http://, TLS for /// https://), and the result is classified by the gRPC : /// /// OK → reachable, gateway confirmed. /// Unauthenticated / PermissionDenied → ALSO reachable: an auth /// rejection proves a live gateway gRPC server answered. The probe deliberately does /// not resolve secrets — it sends whatever API-key string is in the config /// (possibly an unresolved env:/file: ref or empty), so authentication is /// expected to be rejected and that rejection is the positive signal. /// Unavailable / transport error / DeadlineExceeded → handshake /// failed (the port answered TCP but did not complete a gRPC handshake). /// /// The ping is strictly read-only and never mutates gateway state. /// public sealed class GalaxyDriverProbe : IDriverProbe { private static readonly JsonSerializerOptions _opts = new() { PropertyNameCaseInsensitive = true, UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, Converters = { new JsonStringEnumConverter() }, }; /// // Matches DriverInstance.DriverType strings set by the AdminUI's GalaxyDriverPage. public string DriverType => GalaxyDriverFactoryExtensions.DriverTypeName; /// public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) { GalaxyDriverOptions? 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) = ExtractTarget(opts); if (string.IsNullOrWhiteSpace(host) || port <= 0) return new(false, "Config has no host/port to probe.", null); // --- 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, ct); } 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); } // --- gRPC ping: confirm the port is a live mxaccessgw speaking gRPC --- // TestConnection is a read-only metadata RPC (it asks the gateway whether it can reach // the Galaxy Repository SQL Server). We don't care about its boolean result — only that // the gateway answered the gRPC call. A successful return ⇒ OK; an auth rejection still // proves a live gateway; a transport failure ⇒ the port isn't a gateway. We never resolve // the API-key secret ref here (the host owns secret resolution): whatever string is in the // config is sent as-is, so an unresolved/empty key surfaces as an auth rejection = reachable. GalaxyRepositoryClient? client = null; try { client = GalaxyRepositoryClient.Create(BuildProbeClientOptions(opts.Gateway, timeout)); using var deadlineCts = CancellationTokenSource.CreateLinkedTokenSource(ct); deadlineCts.CancelAfter(timeout); await client.TestConnectionAsync(deadlineCts.Token).ConfigureAwait(false); sw.Stop(); // No RpcException ⇒ the gateway answered the gRPC call successfully. var (ok, message) = ClassifyRpc(StatusCode.OK, host, port); return new(ok, message, sw.Elapsed); } catch (RpcException ex) { sw.Stop(); var (ok, message) = ClassifyRpc(ex.StatusCode, host, port); return new(ok, message, ok ? sw.Elapsed : null); } catch (Exception ex) when (ex is MxGatewayAuthenticationException or MxGatewayAuthorizationException) { // The gateway authenticated/authorized our call and rejected the (unresolved / // placeholder) key — the mxaccessgw client surfaces this as a typed exception, NOT a // raw RpcException. It still PROVES a live gateway gRPC server answered, so auth // rejection counts as reachable (the probe never resolves the real secret). sw.Stop(); return new(true, "gateway reachable & speaking gRPC (auth not checked)", sw.Elapsed); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { // The caller cancelled (their own timeout / shutdown) — surface a timeout message. return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null); } catch (OperationCanceledException) { // Our own per-call deadline fired before the gateway answered — treat as a failed // handshake (the port answered TCP but didn't complete a gRPC handshake in time). var (_, message) = ClassifyRpc(StatusCode.DeadlineExceeded, host, port); return new(false, message, null); } catch (Exception ex) { // Transport / channel construction failures (e.g. an https endpoint mismatch, a TLS // negotiation failure, or a non-gRPC server) — reachable on TCP but not a gateway. return new(false, $"Reachable at {host}:{port} but gateway gRPC handshake failed: {ex.Message}", null); } finally { if (client is not null) await client.DisposeAsync().ConfigureAwait(false); } } /// /// Maps a gRPC from the ping RPC to a probe outcome. Factored out /// as a pure helper so the classification is unit-testable without a live gateway. An /// auth rejection ( / /// ) counts as reachable because it proves a live /// gateway gRPC server answered. /// /// The gRPC status code returned by the ping RPC. /// The probed host, used to compose the failure message. /// The probed port, used to compose the failure message. /// Whether the gateway should be considered reachable, plus a human-readable message. internal static (bool ok, string message) ClassifyRpc(StatusCode code, string host, int port) => code switch { StatusCode.OK => (true, "gateway gRPC OK"), StatusCode.Unauthenticated or StatusCode.PermissionDenied => (true, "gateway reachable & speaking gRPC (auth not checked)"), _ => (false, $"Reachable at {host}:{port} but gateway gRPC handshake failed: {code}"), }; /// /// Builds the gRPC client options for the probe ping from the gateway config WITHOUT /// resolving the API-key secret ref (the host owns secret resolution; the probe sends the /// raw config string). Mirrors the driver's channel build — UseTls selects TLS vs /// cleartext, and the CA path is honoured for TLS — but caps the call/connect timeouts to /// the probe budget and uses a single attempt so an unreachable host fails fast. /// private static MxGatewayClientOptions BuildProbeClientOptions(GalaxyGatewayOptions gw, TimeSpan timeout) { // The gw client's Validate() rejects an empty API key. The config's secret ref may be an // unresolved env:/file: string (non-empty → passes) or, in degenerate configs, empty. Send // a non-empty placeholder only when the config carries no key string at all, so the gateway // can still answer (and reject) the call. We never resolve env:/file: refs to real secrets. var apiKey = string.IsNullOrWhiteSpace(gw.ApiKeySecretRef) ? "probe" : gw.ApiKeySecretRef; // Keep both timeouts inside the probe budget so the ping fails fast on an unreachable host. var budget = timeout > TimeSpan.Zero ? timeout : TimeSpan.FromSeconds(1); return new MxGatewayClientOptions { Endpoint = new Uri(gw.Endpoint, UriKind.Absolute), ApiKey = apiKey, UseTls = gw.UseTls, CaCertificatePath = gw.CaCertificatePath, ConnectTimeout = budget, DefaultCallTimeout = budget, // Leave Retry at the client default (as GalaxyDriver does) — an explicit // MaxAttempts=1 maps to 0 Polly retries, which Polly rejects as an invalid // RetryStrategyOptions. Fast-fail is already guaranteed: the TCP preflight rejects // unreachable hosts before the gRPC call, and the linked deadline caps the call to // the probe budget regardless of retries. }; } private static (string host, int port) ExtractTarget(GalaxyDriverOptions opts) { var endpoint = opts.Gateway.Endpoint; if (string.IsNullOrWhiteSpace(endpoint)) return (string.Empty, 0); // Try absolute URI first (e.g. "http://hostname:5120" or "https://hostname:5120"). if (Uri.TryCreate(endpoint, UriKind.Absolute, out var uri)) { var host = uri.Host; // Uri.Port is -1 when not specified; default mxaccessgw port is 5001. var port = uri.Port > 0 ? uri.Port : 5001; return (host, port); } // Fallback: treat as "host:port" (no scheme). var colonIdx = endpoint.LastIndexOf(':'); if (colonIdx > 0 && int.TryParse(endpoint[(colonIdx + 1)..], out var rawPort) && rawPort > 0) return (endpoint[..colonIdx], rawPort); // No port found — return the whole string as host with default port. return (endpoint, 5001); } }