using System.Diagnostics; using System.Net.Http; using System.Text.Json; using System.Text.Json.Serialization; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect; /// /// One-shot MTConnect Agent reachability probe for the AdminUI's Test Connect button. Issues a /// single GET {AgentUri}/probe under the supplied deadline and reports success/failure /// with latency — it does not construct or initialize an instance. /// /// /// /// Deliberately does NOT delegate to . That /// method builds the full driver options document, including every authored /// MTConnectTagDefinition (BuildTag can throw on a malformed tag entry that has /// nothing to do with reachability). A probe only needs agentUri, so this type parses /// its own minimal DTO — a config that is invalid for the driver (bad tag, bad data type) can /// still be reachability-tested, matching the AdminUI's "does the endpoint answer" intent for /// Test Connect. It also avoids a compile-time dependency on the factory DTO shape /// (MTConnectDriverFactoryExtensions), which is authored separately (Task 15). /// /// /// Accepted gap: reachable ≠ deployable. Because only agentUri is read, a /// config can show green on Test Connect and still fail to deploy — e.g. a non-positive /// requestTimeoutMs or a malformed tag entry the factory's full parse would reject. /// This is the direct consequence of the independent-parsing decision above, judged /// acceptable because Test Connect's job is "is the endpoint there", not "will this exact /// config deploy". /// /// /// Deliberately DOES reuse and /// for the actual round trip. Hand-rolling the HTTP /// call would have to re-derive the exact behaviour the client already provides: per-call /// bounding, a linked-CTS deadline, and — most importantly — /// correct handling of an Agent's MTConnectError document served under HTTP 200 (so /// EnsureSuccessStatusCode never fires). already /// lifts the Agent's own error text into the thrown exception's message; re-parsing by hand /// here would either duplicate that logic or under-validate (accepting any 200 response as /// healthy, which is precisely the "empty-but-successful" defect class this codebase has hit /// before, #485). The one-off cost of a full probe-document parse is negligible next to that /// risk. /// /// /// Never throws (the contract): every failure — unparseable /// JSON, missing/malformed agentUri, DNS/TCP failure, a non-2xx response, a 200 body /// that is not a valid MTConnectDevices document, and timeout — becomes /// Ok = false with a message. Genuine caller cancellation is likewise converted rather /// than left to propagate, matching ModbusDriverProbe's posture (its linked-CTS catch /// does not distinguish the caller's token from its own deadline). /// /// /// Non-positive timeout (arch-review 01/S-6). A 0 or negative /// timeout does NOT mean "wait forever" — it is replaced with /// (5s, matching 's own default) so an /// operator-authorable field can never brick the probe UI. /// /// /// Secrets discipline. AgentUri may carry userinfo credentials /// (http://user:pass@host/). Every message this type constructs from the URI uses a /// redacted display form () built ONLY from /// /// /// — never and never the raw config string — so a password can /// never reach the AdminUI via the returned . /// /// public sealed class MTConnectDriverProbe : IDriverProbe { /// Replaces a non-positive caller timeout — see the type remarks. private static readonly TimeSpan FallbackTimeout = TimeSpan.FromSeconds(5); /// /// Caps an excessive caller timeout. CancellationTokenSource.CancelAfter(TimeSpan)'s /// legal range tops out near ~49.7 days ( ms minus one); an /// uncapped pathological value (e.g. a caller passing TimeSpan.FromDays(1000)) throws /// straight out of /// CancelAfter, which would violate the "Never throws" contract just as surely as a /// non-positive timeout meaning "wait forever" would (arch-review 01/S-6 — this is the same /// defect class, the high end rather than the low end). 10 minutes is generous for a /// reachability probe and leaves an enormous margin under the legal ceiling. /// private static readonly TimeSpan MaxTimeout = TimeSpan.FromMinutes(10); private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true, UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, Converters = { new JsonStringEnumConverter() }, }; /// Test seam: routes the reused through a stub handler so response-shape cases need no socket. private readonly HttpMessageHandler? _handler; /// Creates the probe used in production (real sockets). public MTConnectDriverProbe() : this(handler: null) { } /// Test seam constructor — see . internal MTConnectDriverProbe(HttpMessageHandler? handler) { _handler = handler; } /// /// /// Sourced from the constant, not a literal — the probe is /// looked up by AdminOperationsActor under this key, so a drift from the constant /// silently disables the Test Connect button rather than failing anything at build time. /// public string DriverType => DriverTypeNames.MTConnect; /// Minimal probe-only config shape. Deliberately independent of the factory DTO — see the type remarks. private sealed class MTConnectProbeConfigDto { public string? AgentUri { get; set; } } /// public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) { MTConnectProbeConfigDto? dto; try { dto = JsonSerializer.Deserialize(configJson, JsonOptions); } catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); } if (dto is null) { return new(false, "Config JSON deserialized to null.", null); } var agentUri = dto.AgentUri?.Trim(); if (string.IsNullOrWhiteSpace(agentUri)) { return new(false, "Config has no agentUri to probe.", null); } if (!Uri.TryCreate(agentUri, UriKind.Absolute, out var parsed) || (parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps)) { return new(false, "Config's agentUri is not an absolute http(s) URI.", null); } var safeUri = RedactedDisplay(parsed); // arch-review 01/S-6: a non-positive timeout must never mean "wait forever" (low end), and a // pathologically large one must never overrun CancelAfter's legal range (high end) — see // MaxTimeout. var effectiveTimeout = timeout > TimeSpan.Zero ? timeout : FallbackTimeout; if (effectiveTimeout > MaxTimeout) { effectiveTimeout = MaxTimeout; } var requestTimeoutMs = Math.Max(1, (int)Math.Ceiling(effectiveTimeout.TotalMilliseconds)); var sw = Stopwatch.StartNew(); using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(effectiveTimeout); var options = new MTConnectDriverOptions { AgentUri = agentUri, RequestTimeoutMs = requestTimeoutMs, }; try { using var client = new MTConnectAgentClient(options, _handler); _ = await client.ProbeAsync(cts.Token).ConfigureAwait(false); sw.Stop(); return new(true, $"MTConnect /probe OK ({safeUri})", sw.Elapsed); } catch (OperationCanceledException) { // Covers both the caller's token and our own CancelAfter deadline firing — see the type // remarks on why cancellation is never allowed to propagate. return new(false, $"Probe timed out after {effectiveTimeout.TotalSeconds:F0}s.", null); } catch (TimeoutException) { // MTConnectAgentClient's own request-deadline signal. return new(false, $"Probe timed out after {effectiveTimeout.TotalSeconds:F0}s.", null); } catch (HttpRequestException ex) { // Connection failure or non-2xx status. HttpRequestException messages describe the // socket endpoint or status code, never the request URI/userinfo, so ex.Message is safe. return new(false, $"Request to {safeUri} failed: {ex.Message}", null); } catch (InvalidDataException ex) { // MTConnectProbeParser: not well-formed XML, not an MTConnectDevices document (including // an MTConnectError-under-200, whose own errorCode/text ex.Message already carries), or // a document with no devices. Never includes the request URI. return new(false, $"Agent at {safeUri} did not return a valid MTConnect device model: {ex.Message}", null); } catch (ArgumentException) { // Defensive: MTConnectAgentClient's own AgentUri validation, reached only if this // method's own Uri.TryCreate above passed but the client's stricter check disagreed. // Never forward the exception's message verbatim — it can echo the raw (possibly // credentialed) config string. return new(false, "Config's agentUri was rejected by the MTConnect client as invalid.", null); } catch (Exception ex) { // Last-resort catch for an exception type none of the above enumerate. Deliberately does // NOT forward ex.Message: unlike the typed catches above (each individually audited for // whether its message can carry the raw AgentUri/userinfo), an unenumerated type has no // such audit, so ex.Message is an open door for a credential leak. safeUri + the // exception's TYPE name is enough for an operator to act on without that risk. return new(false, $"Probe against {safeUri} failed unexpectedly ({ex.GetType().Name}).", null); } } /// /// Builds a display form of for use in returned messages, composed /// ONLY from scheme/host/port/path — never — so a credentialed /// AgentUri can never leak a password into the AdminUI. /// private static string RedactedDisplay(Uri uri) { var portSegment = uri.IsDefaultPort ? string.Empty : $":{uri.Port}"; return $"{uri.Scheme}://{uri.Host}{portSegment}{uri.AbsolutePath}"; } }