fix(mtconnect): redact the Agent URI everywhere it is shown (R06:T-8)
The driver logged and surfaced the raw AgentUri (which may carry user:pass@) as HostName (the /hosts key), in ~20 log lines and in exception messages that reach DriverHealth.LastError, and the client's request-URI messages (timeouts, stream end, not-a-stream, framing) embedded it too - while the Test-Connect probe deliberately redacted it. New MTConnectUriRedaction (user-info, and for the configured string its query, removed; a clean URI is unchanged so host keys do not move); the driver computes its display form once per options document. Refs #743 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -156,7 +156,7 @@ Read off `MTConnectDriverOptions` / `MTConnectDriverConfigDto`
|
||||
|
||||
| Key | Default | Notes |
|
||||
|---|---|---|
|
||||
| `AgentUri` | — (required) | Agent base URI; the driver appends `/probe`, `/current`, `/sample` |
|
||||
| `AgentUri` | — (required) | Agent base URI; the driver appends `/probe`, `/current`, `/sample`. Any `user:pass@` (and query) is **redacted** from `HostName` (the `/hosts` key), every log line and every exception / health message (review 2026-10-21 T-8); a URI without credentials is shown unchanged |
|
||||
| `DeviceName` | `null` | **Double duty.** (1) Scopes requests to `{AgentUri}/{DeviceName}/...`; `null` = whole Agent. (2) The write path's fallback device — the device a write is addressed to when the probe model does not say which device declared the DataItem (see [Write-back](#write-back--observation-injection-off-by-default)) |
|
||||
| `RequestTimeoutMs` | `5000` | Per-call deadline for `/probe` and `/current` |
|
||||
| `SampleIntervalMs` | `1000` | The Agent's `/sample?interval=` query param |
|
||||
|
||||
@@ -280,7 +280,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
_ = MTConnectStreamsParser.Parse(single);
|
||||
|
||||
throw new MTConnectStreamNotSupportedException(
|
||||
$"MTConnect Agent answered the /sample request '{uri}' with a single '{response.Content.Headers.ContentType?.MediaType ?? "unknown"}' document instead of a multipart/x-mixed-replace stream. It parsed as a valid MTConnectStreams document but will never stream, so no subscription is possible; check that the URI addresses an MTConnect Agent directly and is not fronted by a proxy that buffers streaming responses.");
|
||||
$"MTConnect Agent answered the /sample request '{MTConnectUriRedaction.Redact(uri)}' with a single '{response.Content.Headers.ContentType?.MediaType ?? "unknown"}' document instead of a multipart/x-mixed-replace stream. It parsed as a valid MTConnectStreams document but will never stream, so no subscription is possible; check that the URI addresses an MTConnect Agent directly and is not fronted by a proxy that buffers streaming responses.");
|
||||
}
|
||||
|
||||
var reader = new MultipartStreamReader(body, boundary, _streamIdleTimeout, uri, _logger);
|
||||
@@ -293,7 +293,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
// The caller cancelling is the ONE contracted way out; anything else is reported.
|
||||
live.ThrowIfCancellationRequested();
|
||||
|
||||
throw new MTConnectStreamEndedException(reader.EndReason, uri.AbsoluteUri, delivered);
|
||||
throw new MTConnectStreamEndedException(reader.EndReason, MTConnectUriRedaction.Redact(uri), delivered);
|
||||
}
|
||||
|
||||
// A frame carrying nothing but whitespace is transport filler, not a document. The
|
||||
@@ -560,7 +560,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
}
|
||||
|
||||
private TimeoutException TimedOut(Uri uri) =>
|
||||
new($"MTConnect Agent request to '{uri}' did not complete within {_requestTimeout.TotalMilliseconds:F0} ms.");
|
||||
new($"MTConnect Agent request to '{MTConnectUriRedaction.Redact(uri)}' did not complete within {_requestTimeout.TotalMilliseconds:F0} ms.");
|
||||
|
||||
/// <summary>
|
||||
/// The <c>boundary</c> parameter of a <c>multipart/*</c> response, or <c>null</c> when the
|
||||
@@ -589,7 +589,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
if (string.IsNullOrEmpty(boundary))
|
||||
{
|
||||
throw new MTConnectStreamNotSupportedException(
|
||||
$"MTConnect Agent answered the /sample request '{uri}' with Content-Type '{contentType.MediaType}' but no 'boundary' parameter, so the multipart stream cannot be framed.");
|
||||
$"MTConnect Agent answered the /sample request '{MTConnectUriRedaction.Redact(uri)}' with Content-Type '{contentType.MediaType}' but no 'boundary' parameter, so the multipart stream cannot be framed.");
|
||||
}
|
||||
|
||||
return boundary;
|
||||
@@ -632,7 +632,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
(parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"{nameof(MTConnectDriverOptions.AgentUri)} '{agentUri}' is not an absolute http(s) URI.", nameof(options));
|
||||
$"{nameof(MTConnectDriverOptions.AgentUri)} '{MTConnectUriRedaction.Redact(agentUri)}' is not an absolute http(s) URI.", nameof(options));
|
||||
}
|
||||
|
||||
return parsed.GetLeftPart(UriPartial.Path).TrimEnd('/');
|
||||
@@ -768,7 +768,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
|
||||
logger?.LogWarning(
|
||||
"MTConnect /sample stream from '{AgentUri}' sends parts with no Content-length header. Falling back to boundary-scan framing, which cannot emit a chunk until the NEXT chunk begins, so every observation is delayed by up to one Agent heartbeat. Values will read stale; this is the Agent's framing, not a driver fault.",
|
||||
uri);
|
||||
MTConnectUriRedaction.Redact(uri));
|
||||
}
|
||||
|
||||
private byte[]? Finish(MTConnectStreamEndReason reason)
|
||||
@@ -937,7 +937,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
if (_buffer.Length >= MaxBufferBytes)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"MTConnect /sample stream from '{uri}' sent a part larger than {MaxBufferBytes} bytes without a boundary; refusing to buffer further.");
|
||||
$"MTConnect /sample stream from '{MTConnectUriRedaction.Redact(uri)}' sent a part larger than {MaxBufferBytes} bytes without a boundary; refusing to buffer further.");
|
||||
}
|
||||
|
||||
Array.Resize(ref _buffer, Math.Min(_buffer.Length * 2, MaxBufferBytes));
|
||||
@@ -954,7 +954,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
throw new TimeoutException(
|
||||
$"MTConnect /sample stream from '{uri}' sent no data (not even a heartbeat) within {idleTimeout.TotalMilliseconds:F0} ms; treating the Agent as unreachable rather than waiting indefinitely.");
|
||||
$"MTConnect /sample stream from '{MTConnectUriRedaction.Redact(uri)}' sent no data (not even a heartbeat) within {idleTimeout.TotalMilliseconds:F0} ms; treating the Agent as unreachable rather than waiting indefinitely.");
|
||||
}
|
||||
|
||||
if (read <= 0)
|
||||
|
||||
@@ -132,7 +132,7 @@ public sealed partial class MTConnectDriver : IDriverDiagnostics
|
||||
Interlocked.Increment(ref _sampleCountRaises);
|
||||
_logger.LogInformation(
|
||||
"MTConnect driver {DriverInstanceId} is falling behind {AgentUri}'s /sample stream (chunks full at count {Count}, lag {Lag} observation(s)); reopening at sequence {Cursor} with count {NextCount}.",
|
||||
_driverInstanceId, options.AgentUri, sampleCount, DescribeLag(), cursor, next);
|
||||
_driverInstanceId, AgentDisplay(options), sampleCount, DescribeLag(), cursor, next);
|
||||
|
||||
return next;
|
||||
}
|
||||
@@ -142,7 +142,7 @@ public sealed partial class MTConnectDriver : IDriverDiagnostics
|
||||
state.CeilingWarned = true;
|
||||
_logger.LogWarning(
|
||||
"MTConnect driver {DriverInstanceId} is still behind {AgentUri}'s /sample stream at its count ceiling {Count} (lag {Lag} observation(s)): the machine produces more than count / SampleIntervalMs observations a second, so values are backlog-stale and the stream will re-baseline (dropping intermediate transitions) whenever it falls out of the Agent's buffer. Lower SampleIntervalMs or raise SampleCount.",
|
||||
_driverInstanceId, options.AgentUri, sampleCount, DescribeLag());
|
||||
_driverInstanceId, AgentDisplay(options), sampleCount, DescribeLag());
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -1002,7 +1002,7 @@ public sealed partial class MTConnectDriver
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"MTConnect driver {DriverInstanceId} could not write {Count} DataItem(s) ({DataItemIds}) on device '{DeviceName}' to {AgentUri}.",
|
||||
_driverInstanceId, batch.Count, string.Join(", ", batch.Select(w => w.DataItemId)), device, options.AgentUri);
|
||||
_driverInstanceId, batch.Count, string.Join(", ", batch.Select(w => w.DataItemId)), device, AgentDisplay(options));
|
||||
|
||||
foreach (var write in batch)
|
||||
{
|
||||
@@ -1029,7 +1029,7 @@ public sealed partial class MTConnectDriver
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"MTConnect driver {DriverInstanceId}: {AgentUri} refused the write of '{Value}' to DataItem '{DataItemId}' on device '{DeviceName}' — {AgentError}. Check the Agent's AllowPut / AllowPutFrom settings and that the DataItem exists.",
|
||||
_driverInstanceId, options.AgentUri, batch[0].Text, batch[0].DataItemId, device, outcome.AgentError);
|
||||
_driverInstanceId, AgentDisplay(options), batch[0].Text, batch[0].DataItemId, device, outcome.AgentError);
|
||||
results[batch[0].Slot] = new WriteResult(BadCommunicationError);
|
||||
|
||||
return;
|
||||
@@ -1370,7 +1370,7 @@ public sealed partial class MTConnectDriver
|
||||
// downstream). Its data items are unreachable either way.
|
||||
_logger.LogWarning(
|
||||
"MTConnect driver {DriverInstanceId} skipped a device in {AgentUri}'s /probe model that declares neither a name nor an id; it cannot be given a browse path.",
|
||||
_driverInstanceId, options.AgentUri);
|
||||
_driverInstanceId, AgentDisplay(options));
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -1388,7 +1388,7 @@ public sealed partial class MTConnectDriver
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"MTConnect driver {DriverInstanceId} is scoped to device '{DeviceScope}', but {AgentUri}'s /probe model declares no device with that name or id ({DeclaredCount} declared). The browse is empty because the scope matched nothing, not because the Agent has nothing.",
|
||||
_driverInstanceId, deviceScope, options.AgentUri, model.Devices.Count);
|
||||
_driverInstanceId, deviceScope, AgentDisplay(options), model.Devices.Count);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -1398,7 +1398,7 @@ public sealed partial class MTConnectDriver
|
||||
_driverInstanceId,
|
||||
devices,
|
||||
variables,
|
||||
options.AgentUri,
|
||||
AgentDisplay(options),
|
||||
deviceScope is null or "" ? string.Empty : $" (scoped to '{deviceScope}')");
|
||||
}
|
||||
|
||||
@@ -1568,7 +1568,33 @@ public sealed partial class MTConnectDriver
|
||||
/// <c>host:port</c> convention: several MTConnect drivers in one server disambiguate on the
|
||||
/// Admin dashboard by endpoint rather than by driver-instance id.
|
||||
/// </summary>
|
||||
public string HostName => Volatile.Read(ref _options).AgentUri;
|
||||
public string HostName => AgentDisplay(Volatile.Read(ref _options));
|
||||
|
||||
/// <summary>
|
||||
/// R06:T-8 — the redacted display form of <paramref name="options"/>' <c>AgentUri</c> (no <c>user:pass@</c>),
|
||||
/// used for <see cref="HostName"/>, every log line and every message that can reach health text. Computed once
|
||||
/// per options document and cached by reference.
|
||||
/// </summary>
|
||||
/// <param name="options">The options whose Agent is being named.</param>
|
||||
/// <returns>The display form; identical to the configured value when it carries no credentials.</returns>
|
||||
private string AgentDisplay(MTConnectDriverOptions options)
|
||||
{
|
||||
var cached = Volatile.Read(ref _agentDisplay);
|
||||
if (cached is not null && ReferenceEquals(cached.Options, options))
|
||||
{
|
||||
return cached.Display;
|
||||
}
|
||||
|
||||
var fresh = new AgentDisplayCache(options, MTConnectUriRedaction.Redact(options.AgentUri));
|
||||
Volatile.Write(ref _agentDisplay, fresh);
|
||||
|
||||
return fresh.Display;
|
||||
}
|
||||
|
||||
/// <summary>The last computed <see cref="AgentDisplay"/>, keyed by the options instance it was computed for.</summary>
|
||||
private AgentDisplayCache? _agentDisplay;
|
||||
|
||||
private sealed record AgentDisplayCache(MTConnectDriverOptions Options, string Display);
|
||||
|
||||
/// <inheritdoc />
|
||||
// One entry, always — an Agent is a single host, and the driver knows of it whether or not
|
||||
@@ -1691,7 +1717,7 @@ public sealed partial class MTConnectDriver
|
||||
_logger.LogDebug(
|
||||
ex,
|
||||
"MTConnect driver {DriverInstanceId} could not reach {AgentUri} on its connectivity probe.",
|
||||
_driverInstanceId, options.AgentUri);
|
||||
_driverInstanceId, AgentDisplay(options));
|
||||
}
|
||||
|
||||
TransitionHostTo(reachable ? HostState.Running : HostState.Stopped);
|
||||
@@ -1702,7 +1728,7 @@ public sealed partial class MTConnectDriver
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"MTConnect driver {DriverInstanceId} stopped its connectivity probe on an unexpected fault; host status for {AgentUri} will not be updated until the driver is re-initialized.",
|
||||
_driverInstanceId, options.AgentUri);
|
||||
_driverInstanceId, AgentDisplay(options));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1870,7 +1896,7 @@ public sealed partial class MTConnectDriver
|
||||
|
||||
_logger.LogInformation(
|
||||
"MTConnect driver {DriverInstanceId} dropped its cached /probe device model for {AgentUri} and raised rediscovery: agent instanceId is now {InstanceId}.",
|
||||
_driverInstanceId, options.AgentUri, instanceId);
|
||||
_driverInstanceId, AgentDisplay(options), instanceId);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -1993,7 +2019,7 @@ public sealed partial class MTConnectDriver
|
||||
// that is not running; without this, the resubscribe returns a handle, the caller is
|
||||
// told SubscriptionEstablished, and the next successful read reports a perfectly green
|
||||
// driver over a subscription that can never deliver a value.
|
||||
var agentUri = Volatile.Read(ref _options).AgentUri;
|
||||
var agentUri = AgentDisplay(Volatile.Read(ref _options));
|
||||
|
||||
_streamDegraded = true;
|
||||
Degrade($"The MTConnect Agent at '{agentUri}' does not serve a framed /sample stream; subscriptions cannot deliver until the driver is re-initialized.");
|
||||
@@ -2167,7 +2193,7 @@ public sealed partial class MTConnectDriver
|
||||
_logger.LogError(
|
||||
outcome.Error,
|
||||
"MTConnect driver {DriverInstanceId} cannot stream /sample from {AgentUri}: the endpoint did not answer with a framed multipart stream. Subscriptions stay registered but will receive no updates until the driver is re-initialized; check for a proxy or load balancer in front of the Agent.",
|
||||
_driverInstanceId, options.AgentUri);
|
||||
_driverInstanceId, AgentDisplay(options));
|
||||
|
||||
return;
|
||||
|
||||
@@ -2205,7 +2231,7 @@ public sealed partial class MTConnectDriver
|
||||
_logger.LogWarning(
|
||||
outcome.Error,
|
||||
"MTConnect driver {DriverInstanceId} lost the /sample stream from {AgentUri}; reconnecting from sequence {Cursor} (attempt {Attempt}, delivered={Delivered}).",
|
||||
_driverInstanceId, options.AgentUri, cursor, attempt, outcome.DeliveredAny);
|
||||
_driverInstanceId, AgentDisplay(options), cursor, attempt, outcome.DeliveredAny);
|
||||
|
||||
break;
|
||||
}
|
||||
@@ -2296,7 +2322,7 @@ public sealed partial class MTConnectDriver
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"MTConnect driver {DriverInstanceId} saw {AgentUri} restart (instanceId {Previous} -> {Current}); dropping every held observation and re-baselining.",
|
||||
_driverInstanceId, options.AgentUri, instanceId, chunk.InstanceId);
|
||||
_driverInstanceId, AgentDisplay(options), instanceId, chunk.InstanceId);
|
||||
|
||||
// Dropped BEFORE the re-baseline, and the subscribers are told: if the /current
|
||||
// then fails, holding stale values from a dead device model would be worse than
|
||||
@@ -2318,7 +2344,7 @@ public sealed partial class MTConnectDriver
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"MTConnect driver {DriverInstanceId} fell out of {AgentUri}'s buffer (expected sequence {Expected}, oldest retained {FirstSequence}); re-baselining from /current.",
|
||||
_driverInstanceId, options.AgentUri, expected, chunk.FirstSequence);
|
||||
_driverInstanceId, AgentDisplay(options), expected, chunk.FirstSequence);
|
||||
|
||||
return await RebaselineAsync(client, options, expected, instanceId, delivered, generation, ct)
|
||||
.ConfigureAwait(false);
|
||||
@@ -2364,7 +2390,7 @@ public sealed partial class MTConnectDriver
|
||||
instanceId,
|
||||
delivered,
|
||||
new MTConnectStreamEndedException(
|
||||
MTConnectStreamEndReason.ConnectionClosed, options.AgentUri, 0));
|
||||
MTConnectStreamEndReason.ConnectionClosed, AgentDisplay(options), 0));
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
@@ -2384,7 +2410,7 @@ public sealed partial class MTConnectDriver
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"MTConnect driver {DriverInstanceId} could not resume {AgentUri}'s /sample stream at sequence {Cursor} (the Agent rejected it as out of range); re-baselining from /current.",
|
||||
_driverInstanceId, options.AgentUri, cursor);
|
||||
_driverInstanceId, AgentDisplay(options), cursor);
|
||||
|
||||
return await RebaselineAsync(client, options, expected, instanceId, delivered, generation, ct)
|
||||
.ConfigureAwait(false);
|
||||
@@ -2440,7 +2466,7 @@ public sealed partial class MTConnectDriver
|
||||
_logger.LogInformation(
|
||||
"MTConnect driver {DriverInstanceId} re-baselined from {AgentUri}'s /current ({ObservationCount} observation(s), agent instanceId {InstanceId}) and resumes /sample from sequence {NextSequence}.",
|
||||
_driverInstanceId,
|
||||
options.AgentUri,
|
||||
AgentDisplay(options),
|
||||
current.Observations.Count,
|
||||
current.InstanceId,
|
||||
current.NextSequence);
|
||||
@@ -3040,7 +3066,7 @@ public sealed partial class MTConnectDriver
|
||||
_logger.LogInformation(
|
||||
"MTConnect driver {DriverInstanceId} initialized against {AgentUri}{DeviceScope}: {DeviceCount} device(s), {ObservationCount} primed observation(s), agent instanceId {InstanceId}.",
|
||||
_driverInstanceId,
|
||||
options.AgentUri,
|
||||
AgentDisplay(options),
|
||||
options.DeviceName is null ? string.Empty : $"/{options.DeviceName}",
|
||||
probe.Devices.Count,
|
||||
index.Count,
|
||||
@@ -3072,7 +3098,7 @@ public sealed partial class MTConnectDriver
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"MTConnect driver {DriverInstanceId} failed to initialize against {AgentUri}; the driver is Faulted and serves no values.",
|
||||
_driverInstanceId, options.AgentUri);
|
||||
_driverInstanceId, AgentDisplay(options));
|
||||
|
||||
throw;
|
||||
}
|
||||
@@ -3376,7 +3402,7 @@ public sealed partial class MTConnectDriver
|
||||
{
|
||||
// A bare "A task was canceled" names neither the Agent nor the request that stalled.
|
||||
throw new TimeoutException(
|
||||
$"MTConnect {request} request for driver '{_driverInstanceId}' against '{Volatile.Read(ref _options).AgentUri}' did not complete within {timeoutMs} ms.");
|
||||
$"MTConnect {request} request for driver '{_driverInstanceId}' against '{AgentDisplay(Volatile.Read(ref _options))}' did not complete within {timeoutMs} ms.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3413,7 +3439,7 @@ public sealed partial class MTConnectDriver
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"MTConnect driver {DriverInstanceId} could not read /current from {AgentUri}; the batch is reported Bad.",
|
||||
_driverInstanceId, Volatile.Read(ref _options).AgentUri);
|
||||
_driverInstanceId, AgentDisplay(Volatile.Read(ref _options)));
|
||||
}
|
||||
|
||||
/// <summary>Publishes <see cref="DriverState.Degraded"/> without downgrading a Faulted driver.</summary>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 R06:T-8 — the display form of an Agent URI for every log line, exception message,
|
||||
/// <c>DriverHealth.LastError</c> and <c>HostName</c> this driver produces. An <c>AgentUri</c> may carry
|
||||
/// <c>user:pass@</c>; the Test-Connect probe already redacted it (<c>MTConnectDriverProbe.RedactedDisplay</c>)
|
||||
/// while the driver itself logged and surfaced the raw string — and every request URI built from it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A URI with no user-info (and, for the configured string, no query or fragment) is returned <b>unchanged</b>,
|
||||
/// so the common case — and every host key derived from it — is byte-identical to what it was. One that does
|
||||
/// carry user-info loses it (and, for the configured string, its query / fragment, which a reverse proxy may use
|
||||
/// for a token). A configured string that is not an absolute URI but contains an <c>@</c> is replaced wholesale:
|
||||
/// there is no reliable way to find the credential in it.
|
||||
/// </remarks>
|
||||
internal static class MTConnectUriRedaction
|
||||
{
|
||||
/// <summary>Placeholder for a configured value that cannot be parsed and might carry a credential.</summary>
|
||||
internal const string Unparseable = "(agentUri withheld: not an absolute URI)";
|
||||
|
||||
/// <summary>The display form of a configured <c>AgentUri</c>.</summary>
|
||||
/// <param name="agentUri">The configured value.</param>
|
||||
/// <returns>The value with any user-info (and query / fragment) removed.</returns>
|
||||
public static string Redact(string? agentUri)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(agentUri))
|
||||
{
|
||||
return agentUri ?? string.Empty;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(agentUri.Trim(), UriKind.Absolute, out var uri))
|
||||
{
|
||||
return agentUri.Contains('@', StringComparison.Ordinal) ? Unparseable : agentUri;
|
||||
}
|
||||
|
||||
if (uri.UserInfo.Length == 0 && uri.Query.Length == 0 && uri.Fragment.Length == 0)
|
||||
{
|
||||
return agentUri;
|
||||
}
|
||||
|
||||
var port = uri.IsDefaultPort ? string.Empty : $":{uri.Port}";
|
||||
var path = uri.AbsolutePath == "/" ? string.Empty : uri.AbsolutePath;
|
||||
|
||||
return $"{uri.Scheme}://{uri.Host}{port}{path}";
|
||||
}
|
||||
|
||||
/// <summary>The display form of a request URI built from the <c>AgentUri</c> (its query is the request's own).</summary>
|
||||
/// <param name="uri">The request URI.</param>
|
||||
/// <returns>The URI as text, without user-info.</returns>
|
||||
public static string Redact(Uri uri)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(uri);
|
||||
if (!uri.IsAbsoluteUri || uri.UserInfo.Length == 0)
|
||||
{
|
||||
return uri.ToString();
|
||||
}
|
||||
|
||||
return new UriBuilder(uri) { UserName = string.Empty, Password = string.Empty }.Uri.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using System.Net;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 R06:T-8 — the driver logged and surfaced the raw <c>AgentUri</c> (which may carry
|
||||
/// <c>user:pass@</c>) as its <c>HostName</c>, in every log line and in exception messages that reach
|
||||
/// <c>DriverHealth.LastError</c> → <c>/hosts</c>, while its own Test-Connect probe deliberately redacted it.
|
||||
/// </summary>
|
||||
public sealed class MTConnectUriRedactionTests
|
||||
{
|
||||
private const string Secret = "s3cretPw";
|
||||
private const string CredentialedUri = "http://operator:" + Secret + "@fixture-agent:5000";
|
||||
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
[Theory]
|
||||
[InlineData("http://fixture-agent:5000", "http://fixture-agent:5000")] // unchanged when clean
|
||||
[InlineData("http://agent:5000/prefix", "http://agent:5000/prefix")]
|
||||
[InlineData("http://u:p@agent:5000", "http://agent:5000")]
|
||||
[InlineData("https://u:p@agent/prefix?token=abc", "https://agent/prefix")]
|
||||
[InlineData("not a uri with u:p@host", MTConnectUriRedaction.Unparseable)]
|
||||
[InlineData("not-a-uri", "not-a-uri")]
|
||||
public void The_configured_uri_loses_its_user_info(string raw, string expected) =>
|
||||
MTConnectUriRedaction.Redact(raw).ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public void A_request_uri_loses_its_user_info_and_keeps_its_query() =>
|
||||
MTConnectUriRedaction.Redact(new Uri("http://u:p@agent:5000/sample?from=5&count=10"))
|
||||
.ShouldBe("http://agent:5000/sample?from=5&count=10");
|
||||
|
||||
/// <summary>The host key, every log line and the health text of a credentialed driver carry no password.</summary>
|
||||
[Fact]
|
||||
public async Task A_credentialed_agent_uri_never_reaches_host_name_logs_or_health()
|
||||
{
|
||||
var logger = new RecordingDriverLogger();
|
||||
var client = CannedAgentClient.FromFixtures();
|
||||
var driver = new MTConnectDriver(
|
||||
new MTConnectDriverOptions
|
||||
{
|
||||
AgentUri = CredentialedUri,
|
||||
RequestTimeoutMs = 200,
|
||||
Tags = [new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64)],
|
||||
},
|
||||
"mt-t8",
|
||||
_ => client,
|
||||
logger);
|
||||
await driver.InitializeAsync("{}", Ct);
|
||||
try
|
||||
{
|
||||
// A /current that never answers: the read times out, degrades health and logs a Warning.
|
||||
client.CurrentGate = new TaskCompletionSource();
|
||||
await driver.ReadAsync(["dev1_pos"], Ct);
|
||||
|
||||
var health = driver.GetHealth();
|
||||
health.State.ShouldBe(DriverState.Degraded, "positive control: the failure path that carries the URI ran");
|
||||
health.LastError.ShouldNotBeNull().ShouldNotContain(Secret);
|
||||
health.LastError.ShouldContain("fixture-agent:5000", Case.Sensitive, "the host is still named");
|
||||
|
||||
driver.HostName.ShouldBe("http://fixture-agent:5000");
|
||||
((IHostConnectivityProbe)driver).GetHostStatuses().ShouldAllBe(s => !s.HostName.Contains(Secret));
|
||||
|
||||
var lines = logger.AllSnapshot();
|
||||
lines.ShouldNotBeEmpty("positive control: the initialize line and the read warning were logged");
|
||||
lines.ShouldContain(l => l.Contains("fixture-agent:5000"));
|
||||
lines.ShouldAllBe(l => !l.Contains(Secret));
|
||||
}
|
||||
finally
|
||||
{
|
||||
client.CurrentGate?.TrySetResult();
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The production client's own exception messages (they become health text) carry no password.</summary>
|
||||
[Fact]
|
||||
public async Task The_client_timeout_message_carries_no_password()
|
||||
{
|
||||
using var handler = new HangingHandler();
|
||||
using var client = new MTConnectAgentClient(
|
||||
new MTConnectDriverOptions { AgentUri = CredentialedUri, RequestTimeoutMs = 200 }, handler);
|
||||
|
||||
var ex = await Should.ThrowAsync<TimeoutException>(async () => await client.CurrentAsync(Ct));
|
||||
|
||||
ex.Message.ShouldContain("fixture-agent:5000");
|
||||
ex.Message.ShouldNotContain(Secret);
|
||||
}
|
||||
|
||||
/// <summary>The stream-ended exception (it becomes the pump's Degrade text) carries no password.</summary>
|
||||
[Fact]
|
||||
public async Task The_client_stream_messages_carry_no_password()
|
||||
{
|
||||
using var handler = new SingleDocumentHandler();
|
||||
using var client = new MTConnectAgentClient(new MTConnectDriverOptions { AgentUri = CredentialedUri }, handler);
|
||||
|
||||
var ex = await Should.ThrowAsync<MTConnectStreamNotSupportedException>(async () =>
|
||||
{
|
||||
await foreach (var _ in client.SampleAsync(1, Ct)) { }
|
||||
});
|
||||
|
||||
ex.Message.ShouldContain("fixture-agent:5000");
|
||||
ex.Message.ShouldNotContain(Secret);
|
||||
}
|
||||
|
||||
private sealed class HangingHandler : HttpMessageHandler
|
||||
{
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, cancellationToken);
|
||||
return new HttpResponseMessage(HttpStatusCode.OK);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SingleDocumentHandler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(File.ReadAllText("Fixtures/current.xml"), System.Text.Encoding.UTF8, "text/xml"),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,24 @@ internal sealed class RecordingDriverLogger : ILogger<MTConnectDriver>
|
||||
/// <summary>Formatted <see cref="LogLevel.Error"/> lines, in order.</summary>
|
||||
public List<string> Errors { get; } = [];
|
||||
|
||||
/// <summary>Formatted <see cref="LogLevel.Information"/> lines, in order.</summary>
|
||||
public List<string> Infos { get; } = [];
|
||||
|
||||
/// <summary>Every recorded line at Information and above, with its exception's message appended.</summary>
|
||||
public IReadOnlyList<string> AllSnapshot()
|
||||
{
|
||||
var all = new List<string>();
|
||||
foreach (var sink in new[] { Infos, Warnings, Errors })
|
||||
{
|
||||
lock (sink)
|
||||
{
|
||||
all.AddRange(sink);
|
||||
}
|
||||
}
|
||||
|
||||
return all;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IDisposable? BeginScope<TState>(TState state)
|
||||
where TState : notnull => null;
|
||||
@@ -39,6 +57,7 @@ internal sealed class RecordingDriverLogger : ILogger<MTConnectDriver>
|
||||
{
|
||||
LogLevel.Warning => Warnings,
|
||||
LogLevel.Error => Errors,
|
||||
LogLevel.Information => Infos,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
@@ -50,7 +69,7 @@ internal sealed class RecordingDriverLogger : ILogger<MTConnectDriver>
|
||||
|
||||
lock (sink)
|
||||
{
|
||||
sink.Add(formatter(state, exception));
|
||||
sink.Add(exception is null ? formatter(state, exception) : $"{formatter(state, exception)} | {exception.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user