fix(heidenhain): namespaced credentials, optional-role credential isolation, loop robustness (#803 review)
- Credential exfiltration (review 2): password references are scoped to secret:heidenhain/<name> and env:OTOPCUA_HEIDENHAIN_<NAME> only. Enforced by HeidenhainOptionsParser.TryParseCredentialReference (parser, probe, deploy gate) AND again at resolve time in HeidenhainCredentials (fail closed without consulting the store or the environment), so Test Connect can no longer send env:ConnectionStrings__ConfigDb or another subsystem's secret to an arbitrary Host. The probe also refuses an unsendable password. - Optional-role credentials (review 6): an unresolvable/NUL/non-Latin-1/>247-byte DNC or DATA credential never sends a login and never fails the session; the reads needing that role are BadConfigurationError (capability ConfigurationError) and INSPECT telemetry continues. A policy refusal inside AcquireAsync is per-tag, not a session teardown. - Start after DisposeAsync is a no-op (review 3); initial subscription push and Publish are ordered under one lock (review 4); JSON-null sections are normalised and the acquisition loop catches every fault (review 5); backoff resets only after a session published a poll batch, with clamped delays (review 7). Tests: HeidenhainReviewFixTests + namespace/normalisation tests in HeidenhainConfigTests; each was confirmed red against the reverted fix.
This commit is contained in:
@@ -67,6 +67,7 @@ public static class HeidenhainCatalog
|
||||
public const string Unsupported = "Unsupported";
|
||||
public const string InvalidData = "InvalidData";
|
||||
public const string CommunicationLost = "CommunicationLost";
|
||||
public const string ConfigurationError = "ConfigurationError";
|
||||
}
|
||||
|
||||
/// <summary>Every fixed leaf, value leaves first then the capability strings.</summary>
|
||||
|
||||
+179
-19
@@ -4,8 +4,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
|
||||
|
||||
/// <summary>
|
||||
/// The one parser + validator of the Heidenhain <c>DriverConfig</c> document, shared by the driver factory,
|
||||
/// the Test-Connect probe, the deploy gate and the AdminUI form, so none of them can disagree about what a
|
||||
/// config means.
|
||||
/// the Test-Connect probe, the deploy gate and the AdminUI form/save path, so none of them can disagree about
|
||||
/// what a config means.
|
||||
/// </summary>
|
||||
public static class HeidenhainOptionsParser
|
||||
{
|
||||
@@ -15,6 +15,37 @@ public static class HeidenhainOptionsParser
|
||||
/// <summary>Prefix of an environment-variable password reference.</summary>
|
||||
public const string EnvPrefix = "env:";
|
||||
|
||||
/// <summary>
|
||||
/// The ONLY secret-store namespace a Heidenhain password may name: <c>secret:heidenhain/<name></c> (the
|
||||
/// store's slash-separated, driver-scoped naming, as in <c>galaxy/<inst>/apikey</c>). Scoping is the
|
||||
/// exfiltration guard: the reference is resolved on the node and sent in cleartext in <c>A_LG</c> to a Host
|
||||
/// the author chose, so it must never be able to name another subsystem's secret.
|
||||
/// </summary>
|
||||
public const string SecretNamespace = "heidenhain/";
|
||||
|
||||
/// <summary>
|
||||
/// The ONLY environment variables a Heidenhain password may name: <c>env:OTOPCUA_HEIDENHAIN_<NAME></c>
|
||||
/// (exact upper-case prefix; <c>NAME</c> is <c>[A-Z0-9_]+</c>) — never <c>ConnectionStrings__*</c>,
|
||||
/// <c>ServerHistorian__ApiKey</c> or any other process variable.
|
||||
/// </summary>
|
||||
public const string EnvVariablePrefix = "OTOPCUA_HEIDENHAIN_";
|
||||
|
||||
/// <summary>The password fields a Heidenhain DriverConfig/DeviceConfig may carry.</summary>
|
||||
public static readonly IReadOnlyList<string> PasswordFields = ["InspectPassword", "DncPassword", "DataPassword"];
|
||||
|
||||
/// <summary>The kind of a parsed credential reference.</summary>
|
||||
public enum CredentialKind
|
||||
{
|
||||
/// <summary>Blank — no password.</summary>
|
||||
None,
|
||||
|
||||
/// <summary><c>secret:heidenhain/…</c>.</summary>
|
||||
Secret,
|
||||
|
||||
/// <summary><c>env:OTOPCUA_HEIDENHAIN_…</c>.</summary>
|
||||
Environment,
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
@@ -22,7 +53,10 @@ public static class HeidenhainOptionsParser
|
||||
AllowTrailingCommas = true,
|
||||
};
|
||||
|
||||
/// <summary>Deserialises a config document. Throws <see cref="InvalidOperationException"/> on unreadable JSON.</summary>
|
||||
/// <summary>
|
||||
/// Deserialises a config document (null sections normalised to defaults). Throws
|
||||
/// <see cref="InvalidOperationException"/> on unreadable JSON.
|
||||
/// </summary>
|
||||
/// <param name="json">The DriverConfig JSON (blank ⇒ defaults).</param>
|
||||
/// <returns>The options.</returns>
|
||||
public static HeidenhainDriverOptions Parse(string? json)
|
||||
@@ -30,8 +64,8 @@ public static class HeidenhainOptionsParser
|
||||
if (string.IsNullOrWhiteSpace(json)) return new HeidenhainDriverOptions();
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<HeidenhainDriverOptions>(json, JsonOptions)
|
||||
?? throw new InvalidOperationException("Heidenhain driver config deserialised to null.");
|
||||
return Normalize(JsonSerializer.Deserialize<HeidenhainDriverOptions>(json, JsonOptions)
|
||||
?? throw new InvalidOperationException("Heidenhain driver config deserialised to null."));
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
@@ -39,6 +73,30 @@ public static class HeidenhainOptionsParser
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces JSON-<c>null</c> sections (<c>"Access": null</c>, <c>"Polling": null</c>, …) and null list entries
|
||||
/// with their defaults, so nothing downstream can dereference a null section.
|
||||
/// </summary>
|
||||
/// <param name="o">The options as deserialised (or constructed).</param>
|
||||
/// <returns>An options instance with every section non-null.</returns>
|
||||
public static HeidenhainDriverOptions Normalize(HeidenhainDriverOptions o)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(o);
|
||||
if (o.Access is not null && o.Polling is not null && o.Reconnect is not null && o.Devices is not null
|
||||
&& o.RawTags is not null && o.Devices.All(d => d is not null) && o.RawTags.All(t => t is not null))
|
||||
return o;
|
||||
return new HeidenhainDriverOptions
|
||||
{
|
||||
Devices = (o.Devices ?? []).Where(d => d is not null).ToList(),
|
||||
RawTags = (o.RawTags ?? []).Where(t => t is not null).ToList(),
|
||||
TimeoutMs = o.TimeoutMs,
|
||||
Access = o.Access ?? new HeidenhainAccessOptions(),
|
||||
Polling = o.Polling ?? new HeidenhainPollingOptions(),
|
||||
Reconnect = o.Reconnect ?? new HeidenhainReconnectOptions(),
|
||||
ProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates driver-level ranges and every password reference. Device endpoints are validated per device
|
||||
/// by <see cref="ValidateDevice"/> (a driver may be authored before its devices).
|
||||
@@ -48,22 +106,23 @@ public static class HeidenhainOptionsParser
|
||||
public static IReadOnlyList<string> Validate(HeidenhainDriverOptions o)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(o);
|
||||
o = Normalize(o);
|
||||
var errors = new List<string>();
|
||||
if (o.TimeoutMs is < 100 or > 60000) errors.Add("TimeoutMs must be 100–60000 ms.");
|
||||
var p = o.Polling ?? new HeidenhainPollingOptions();
|
||||
var p = o.Polling;
|
||||
foreach (var (name, v) in new[] { ("StatusMs", p.StatusMs), ("CustomMs", p.CustomMs), ("SlowMs", p.SlowMs) })
|
||||
if (v is < 100 or > 3_600_000) errors.Add($"Polling.{name} must be 100–3600000 ms.");
|
||||
if (p.MaxAlarms is < 1 or > 256) errors.Add("Polling.MaxAlarms must be 1–256.");
|
||||
var r = o.Reconnect ?? new HeidenhainReconnectOptions();
|
||||
var r = o.Reconnect;
|
||||
if (r.InitialDelayMs is < 100 or > 600_000) errors.Add("Reconnect.InitialDelayMs must be 100–600000 ms.");
|
||||
if (r.MaxDelayMs < r.InitialDelayMs || r.MaxDelayMs > 600_000)
|
||||
errors.Add("Reconnect.MaxDelayMs must be between InitialDelayMs and 600000 ms.");
|
||||
if (o.ProbeTimeoutSeconds is < 1 or > 60) errors.Add("ProbeTimeoutSeconds must be 1–60.");
|
||||
var a = o.Access ?? new HeidenhainAccessOptions();
|
||||
var a = o.Access;
|
||||
CheckRef(errors, "Access.InspectPassword", a.InspectPassword);
|
||||
CheckRef(errors, "Access.DncPassword", a.DncPassword);
|
||||
CheckRef(errors, "Access.DataPassword", a.DataPassword);
|
||||
foreach (var d in o.Devices ?? [])
|
||||
foreach (var d in o.Devices)
|
||||
{
|
||||
var label = string.IsNullOrWhiteSpace(d.DeviceName) ? d.Host : d.DeviceName;
|
||||
CheckRef(errors, $"Device '{label}' InspectPassword", d.InspectPassword);
|
||||
@@ -88,24 +147,125 @@ public static class HeidenhainOptionsParser
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="value"/> is an acceptable password field: blank, <c>secret:<name></c> or
|
||||
/// <c>env:<VARIABLE></c>. A literal password is refused so it never lands in the config database.
|
||||
/// True when <paramref name="value"/> is an acceptable password field: blank,
|
||||
/// <c>secret:heidenhain/<name></c> or <c>env:OTOPCUA_HEIDENHAIN_<NAME></c>.
|
||||
/// </summary>
|
||||
/// <param name="value">The field value.</param>
|
||||
/// <returns>Whether it is a reference (or blank).</returns>
|
||||
public static bool IsCredentialReference(string? value)
|
||||
/// <returns>Whether it is a namespaced reference (or blank).</returns>
|
||||
public static bool IsCredentialReference(string? value) => TryParseCredentialReference(value, out _, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Parses a credential reference, enforcing the Heidenhain namespace — the single rule the parser, the
|
||||
/// resolver (again at resolve time), the probe, the AdminUI save path and the deploy gate all apply.
|
||||
/// </summary>
|
||||
/// <param name="value">The field value.</param>
|
||||
/// <param name="kind">The reference kind.</param>
|
||||
/// <param name="name">The full secret name (incl. <c>heidenhain/</c>) or the environment variable name.</param>
|
||||
/// <returns>True for blank or a namespaced reference.</returns>
|
||||
public static bool TryParseCredentialReference(string? value, out CredentialKind kind, out string name)
|
||||
{
|
||||
kind = CredentialKind.None;
|
||||
name = "";
|
||||
if (string.IsNullOrEmpty(value)) return true;
|
||||
if (value.StartsWith(SecretPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
return value.Length > SecretPrefix.Length && !string.IsNullOrWhiteSpace(value[SecretPrefix.Length..]);
|
||||
if (value.StartsWith(EnvPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
return value.Length > EnvPrefix.Length && !string.IsNullOrWhiteSpace(value[EnvPrefix.Length..]);
|
||||
if (value.StartsWith(SecretPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
var secret = value[SecretPrefix.Length..];
|
||||
if (!secret.StartsWith(SecretNamespace, StringComparison.Ordinal)) return false;
|
||||
var rest = secret[SecretNamespace.Length..];
|
||||
if (rest.Length is 0 or > 200) return false;
|
||||
foreach (var c in rest)
|
||||
if (!(char.IsAsciiLetterOrDigit(c) || c is '-' or '_' or '.' or '/')) return false;
|
||||
if (rest.Split('/').Any(seg => seg.Length == 0 || seg is "." or "..")) return false;
|
||||
kind = CredentialKind.Secret;
|
||||
name = secret;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value.StartsWith(EnvPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
var variable = value[EnvPrefix.Length..];
|
||||
if (!variable.StartsWith(EnvVariablePrefix, StringComparison.Ordinal)) return false;
|
||||
var rest = variable[EnvVariablePrefix.Length..];
|
||||
if (rest.Length is 0 or > 100 || !rest.All(c => char.IsAsciiLetterUpper(c) || char.IsAsciiDigit(c) || c == '_'))
|
||||
return false;
|
||||
kind = CredentialKind.Environment;
|
||||
name = variable;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans a RAW DriverConfig or DeviceConfig document (before it is stored) for password fields that are not
|
||||
/// namespaced references — at the top level (a device / merged config), under <c>Access</c>, and under every
|
||||
/// <c>Devices[]</c> entry; keys matched case-insensitively, as the parser binds them. The messages name the
|
||||
/// field, never the value. Unreadable JSON is reported, since it cannot be checked.
|
||||
/// </summary>
|
||||
/// <param name="json">The config JSON.</param>
|
||||
/// <returns>The problems; empty when every password field is acceptable.</returns>
|
||||
public static IReadOnlyList<string> DescribeCredentialProblems(string? json)
|
||||
{
|
||||
var problems = new List<string>();
|
||||
if (string.IsNullOrWhiteSpace(json)) return problems;
|
||||
JsonDocument doc;
|
||||
try
|
||||
{
|
||||
doc = JsonDocument.Parse(json, new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true });
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
problems.Add("Heidenhain config is not valid JSON, so its credential fields cannot be checked.");
|
||||
return problems;
|
||||
}
|
||||
|
||||
using (doc)
|
||||
{
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return problems;
|
||||
ScanObject(doc.RootElement, "", problems);
|
||||
foreach (var p in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(p.Name, "Access", StringComparison.OrdinalIgnoreCase) && p.Value.ValueKind == JsonValueKind.Object)
|
||||
ScanObject(p.Value, "Access.", problems);
|
||||
if (string.Equals(p.Name, "Devices", StringComparison.OrdinalIgnoreCase) && p.Value.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var i = 0;
|
||||
foreach (var d in p.Value.EnumerateArray())
|
||||
{
|
||||
if (d.ValueKind == JsonValueKind.Object) ScanObject(d, $"Devices[{i}].", problems);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
/// <summary>The one wording of the credential rule (never includes the value).</summary>
|
||||
/// <param name="field">The field.</param>
|
||||
/// <returns>The message.</returns>
|
||||
public static string CredentialRuleMessage(string field) =>
|
||||
$"{field} must be blank, 'secret:{SecretNamespace}<name>' or 'env:{EnvVariablePrefix}<NAME>' — a literal "
|
||||
+ "password, or a reference outside the Heidenhain namespace, is refused.";
|
||||
|
||||
private static void ScanObject(JsonElement obj, string prefix, List<string> problems)
|
||||
{
|
||||
foreach (var p in obj.EnumerateObject())
|
||||
{
|
||||
if (!PasswordFields.Any(f => string.Equals(f, p.Name, StringComparison.OrdinalIgnoreCase))) continue;
|
||||
var ok = p.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Null => true,
|
||||
JsonValueKind.String => IsCredentialReference(p.Value.GetString()),
|
||||
_ => false,
|
||||
};
|
||||
if (!ok) problems.Add(CredentialRuleMessage(prefix + p.Name));
|
||||
}
|
||||
}
|
||||
|
||||
private static void CheckRef(List<string> errors, string field, string? value)
|
||||
{
|
||||
if (!IsCredentialReference(value))
|
||||
errors.Add($"{field} must be blank, 'secret:<name>' or 'env:<VARIABLE>' — a literal password is not stored.");
|
||||
if (!IsCredentialReference(value)) errors.Add(CredentialRuleMessage(field));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,16 @@ using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
|
||||
|
||||
/// <summary>A password reference that cannot be resolved (or may not be resolved). Never carries the value.</summary>
|
||||
public sealed class HeidenhainCredentialException(string message) : InvalidOperationException(message);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves password references on demand (each login), so plaintext is never held in options or session
|
||||
/// state. Fail-closed: a <c>secret:</c> reference with no resolver, an absent secret, an unset environment
|
||||
/// variable or a literal value throws — the reference text is never sent to the control as a password.
|
||||
/// state. Fail-closed AT RESOLVE TIME, independently of any earlier validation: only blank,
|
||||
/// <c>secret:heidenhain/…</c> and <c>env:OTOPCUA_HEIDENHAIN_…</c> are resolved — anything else (a literal, another
|
||||
/// subsystem's secret, <c>ConnectionStrings__*</c>), a <c>secret:</c> with no store, an absent secret or an unset
|
||||
/// variable throws <see cref="HeidenhainCredentialException"/>. The reference is resolved on this node and sent
|
||||
/// in cleartext to the configured control, which is why the namespace is enforced here as well.
|
||||
/// </summary>
|
||||
internal sealed class HeidenhainCredentials(ISecretResolver? resolver, Func<string, string?>? environment = null)
|
||||
{
|
||||
@@ -14,12 +20,13 @@ internal sealed class HeidenhainCredentials(ISecretResolver? resolver, Func<stri
|
||||
/// <summary>The password for a role on a device (device override, then driver-level).</summary>
|
||||
public async ValueTask<string> ResolveAsync(HeidenhainDriverOptions options, HeidenhainDeviceOptions device, string role, CancellationToken ct)
|
||||
{
|
||||
var access = options.Access ?? new HeidenhainAccessOptions();
|
||||
var reference = role switch
|
||||
{
|
||||
"INSPECT" => device.InspectPassword ?? options.Access.InspectPassword,
|
||||
"DNC" => device.DncPassword ?? options.Access.DncPassword,
|
||||
"DATA" => device.DataPassword ?? options.Access.DataPassword,
|
||||
_ => throw new InvalidOperationException("Unexpected LSV2 role."),
|
||||
"INSPECT" => device.InspectPassword ?? access.InspectPassword,
|
||||
"DNC" => device.DncPassword ?? access.DncPassword,
|
||||
"DATA" => device.DataPassword ?? access.DataPassword,
|
||||
_ => throw new HeidenhainCredentialException("Unexpected LSV2 role."),
|
||||
};
|
||||
return await ResolveReferenceAsync(reference, role, ct).ConfigureAwait(false);
|
||||
}
|
||||
@@ -27,23 +34,23 @@ internal sealed class HeidenhainCredentials(ISecretResolver? resolver, Func<stri
|
||||
/// <summary>Resolves one reference.</summary>
|
||||
public async ValueTask<string> ResolveReferenceAsync(string? reference, string role, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(reference)) return "";
|
||||
if (!HeidenhainOptionsParser.IsCredentialReference(reference))
|
||||
throw new InvalidOperationException($"{role} password must be a secret: or env: reference (fail-closed).");
|
||||
if (reference.StartsWith(HeidenhainOptionsParser.EnvPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
if (!HeidenhainOptionsParser.TryParseCredentialReference(reference, out var kind, out var name))
|
||||
throw new HeidenhainCredentialException(HeidenhainOptionsParser.CredentialRuleMessage($"{role} password") + " (fail-closed)");
|
||||
switch (kind)
|
||||
{
|
||||
var name = reference[HeidenhainOptionsParser.EnvPrefix.Length..].Trim();
|
||||
return _environment(name) is { Length: > 0 } value
|
||||
? value
|
||||
: throw new InvalidOperationException($"{role} password environment variable '{name}' is not set (fail-closed).");
|
||||
case HeidenhainOptionsParser.CredentialKind.None:
|
||||
return "";
|
||||
case HeidenhainOptionsParser.CredentialKind.Environment:
|
||||
return _environment(name) is { Length: > 0 } value
|
||||
? value
|
||||
: throw new HeidenhainCredentialException($"{role} password environment variable '{name}' is not set (fail-closed).");
|
||||
default:
|
||||
if (resolver is null)
|
||||
throw new HeidenhainCredentialException($"{role} password references secret '{name}' but no secret store is available (fail-closed).");
|
||||
var resolved = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false);
|
||||
return !string.IsNullOrEmpty(resolved)
|
||||
? resolved
|
||||
: throw new HeidenhainCredentialException($"{role} password secret '{name}' is absent from the store (fail-closed).");
|
||||
}
|
||||
|
||||
var secret = reference[HeidenhainOptionsParser.SecretPrefix.Length..].Trim();
|
||||
if (resolver is null)
|
||||
throw new InvalidOperationException($"{role} password references secret '{secret}' but no secret store is available (fail-closed).");
|
||||
var resolved = await resolver.GetAsync(new SecretName(secret), ct).ConfigureAwait(false);
|
||||
return !string.IsNullOrEmpty(resolved)
|
||||
? resolved
|
||||
: throw new InvalidOperationException($"{role} password secret '{secret}' is absent from the store (fail-closed).");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,11 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
|
||||
private DateTime? _lastSuccessUtc;
|
||||
private string? _lastError;
|
||||
private int _consecutiveFailures;
|
||||
private bool _disposed;
|
||||
private volatile bool _pollProgress;
|
||||
|
||||
/// <summary>Consecutive sessions that ended without one published poll batch (test/diagnostic).</summary>
|
||||
internal int ConsecutiveFailures => Volatile.Read(ref _consecutiveFailures);
|
||||
|
||||
public HeidenhainDeviceRuntime(
|
||||
HeidenhainDeviceOptions device,
|
||||
@@ -79,7 +84,7 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
|
||||
ILogger logger)
|
||||
{
|
||||
_device = device;
|
||||
_options = options;
|
||||
_options = HeidenhainOptionsParser.Normalize(options);
|
||||
_tags = tags;
|
||||
_passwords = passwords;
|
||||
_publish = publish;
|
||||
@@ -134,7 +139,9 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
|
||||
{
|
||||
lock (_lifecycle)
|
||||
{
|
||||
if (_loop is not null) return;
|
||||
// A Start racing (or following) DisposeAsync must never revive an orphan loop: the driver may be
|
||||
// iterating a device list it captured just before re-init disposed it.
|
||||
if (_disposed || _loop is not null) return;
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
_loop = Task.Run(() => RunAsync(token), CancellationToken.None);
|
||||
@@ -143,9 +150,59 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
|
||||
|
||||
private async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
// Nothing may kill this loop silently: every iteration — connect, poll, retire, backoff — is guarded,
|
||||
// and an unexpected fault becomes a logged retire + backoff, never an unobserved faulted task.
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await RunOnceAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Heidenhain {Host} acquisition loop fault; retrying", HostName);
|
||||
try { RetireAll("Acquisition loop fault: " + ex.GetType().Name); }
|
||||
catch (Exception inner) { _logger.LogError(inner, "Heidenhain {Host} retire failed", HostName); }
|
||||
}
|
||||
|
||||
if (ct.IsCancellationRequested) break;
|
||||
// Backoff resets only when the last session made real progress (published a poll batch): a control
|
||||
// that accepts login + version and then drops must not be re-dialled every InitialDelayMs.
|
||||
if (_pollProgress) _consecutiveFailures = 0;
|
||||
_consecutiveFailures++;
|
||||
try
|
||||
{
|
||||
SetState(HeidenhainConnectionState.Backoff, null);
|
||||
await Task.Delay(BackoffDelay(_consecutiveFailures), _time, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Heidenhain {Host} backoff fault", HostName);
|
||||
try { await Task.Delay(TimeSpan.FromSeconds(1), ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The reconnect delay for the n-th consecutive failure, clamped to sane bounds (never a hot loop).</summary>
|
||||
internal TimeSpan BackoffDelay(int failures)
|
||||
{
|
||||
var r = _options.Reconnect ?? new HeidenhainReconnectOptions();
|
||||
var initial = TimeSpan.FromMilliseconds(Math.Clamp(r.InitialDelayMs, 100, 600_000));
|
||||
var max = TimeSpan.FromMilliseconds(Math.Clamp(r.MaxDelayMs, (int)initial.TotalMilliseconds, 600_000));
|
||||
return ConnectionBackoff.ComputeDelay(initial, failures, max);
|
||||
}
|
||||
|
||||
private async Task RunOnceAsync(CancellationToken ct)
|
||||
{
|
||||
{
|
||||
HeidenhainSession? session = null;
|
||||
_pollProgress = false;
|
||||
try
|
||||
{
|
||||
SetState(HeidenhainConnectionState.Connecting, null);
|
||||
@@ -161,7 +218,6 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
|
||||
_groupGood.Clear();
|
||||
}
|
||||
|
||||
_consecutiveFailures = 0;
|
||||
SetState(HeidenhainConnectionState.Connected, null);
|
||||
_logger.LogInformation("Heidenhain {Host} connected ({Model})", HostName, session.Identity?.Model);
|
||||
await PollAsync(session, generation, ct).ConfigureAwait(false);
|
||||
@@ -170,7 +226,7 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -188,15 +244,6 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
|
||||
{
|
||||
session?.Close();
|
||||
}
|
||||
|
||||
if (ct.IsCancellationRequested) break;
|
||||
_consecutiveFailures++;
|
||||
var delay = ConnectionBackoff.ComputeDelay(
|
||||
TimeSpan.FromMilliseconds(_options.Reconnect.InitialDelayMs), _consecutiveFailures,
|
||||
TimeSpan.FromMilliseconds(_options.Reconnect.MaxDelayMs));
|
||||
SetState(HeidenhainConnectionState.Backoff, null);
|
||||
try { await Task.Delay(delay, _time, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,9 +251,9 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
|
||||
{
|
||||
var intervals = new Dictionary<HeidenhainPollGroup, TimeSpan>
|
||||
{
|
||||
[HeidenhainPollGroup.Status] = TimeSpan.FromMilliseconds(_options.Polling.StatusMs),
|
||||
[HeidenhainPollGroup.Custom] = TimeSpan.FromMilliseconds(_options.Polling.CustomMs),
|
||||
[HeidenhainPollGroup.Slow] = TimeSpan.FromMilliseconds(_options.Polling.SlowMs),
|
||||
[HeidenhainPollGroup.Status] = TimeSpan.FromMilliseconds(Math.Max(100, _options.Polling.StatusMs)),
|
||||
[HeidenhainPollGroup.Custom] = TimeSpan.FromMilliseconds(Math.Max(100, _options.Polling.CustomMs)),
|
||||
[HeidenhainPollGroup.Slow] = TimeSpan.FromMilliseconds(Math.Max(100, _options.Polling.SlowMs)),
|
||||
};
|
||||
var start = _time.GetTimestamp();
|
||||
var due = _groups.Keys.ToDictionary(g => g, _ => TimeSpan.Zero);
|
||||
@@ -257,6 +304,7 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
|
||||
|
||||
_groupGood[group] = good;
|
||||
_lastSuccessUtc = now;
|
||||
_pollProgress = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -325,6 +373,7 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
|
||||
CancellationTokenSource? cts;
|
||||
lock (_lifecycle)
|
||||
{
|
||||
_disposed = true;
|
||||
loop = _loop;
|
||||
cts = _cts;
|
||||
_loop = null;
|
||||
|
||||
@@ -42,6 +42,7 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
|
||||
private readonly TimeProvider _time;
|
||||
private readonly SkippedTagLedger _skipped = new();
|
||||
private readonly object _lifecycleLock = new();
|
||||
private readonly object _publishLock = new();
|
||||
private readonly ConcurrentDictionary<string, DataValueSnapshot> _cache = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<long, Subscription> _subscriptions = new();
|
||||
private HeidenhainDriverOptions _options;
|
||||
@@ -68,7 +69,7 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
_options = options;
|
||||
_options = HeidenhainOptionsParser.Normalize(options);
|
||||
_driverInstanceId = driverInstanceId;
|
||||
_logger = logger ?? NullLogger.Instance;
|
||||
_credentials = credentials;
|
||||
@@ -278,13 +279,18 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fullReferences);
|
||||
var sub = new Subscription(Interlocked.Increment(ref _nextSubscriptionId), fullReferences.ToHashSet(StringComparer.Ordinal));
|
||||
_subscriptions[sub.Id] = sub;
|
||||
// Initial push of the current cache so the subscriber starts from the driver's real state.
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
foreach (var r in sub.References)
|
||||
// Register + initial push under the publication lock, so no Publish can interleave: either it ran before
|
||||
// (the push carries its value) or it runs after (it reaches this subscription). The subscriber can never
|
||||
// receive an older snapshot after a newer one.
|
||||
lock (_publishLock)
|
||||
{
|
||||
var snapshot = _cache.TryGetValue(r, out var s) ? s : new DataValueSnapshot(null, HeidenhainStatus.BadNodeIdUnknown, null, now);
|
||||
Raise(sub, r, snapshot);
|
||||
_subscriptions[sub.Id] = sub;
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
foreach (var r in sub.References)
|
||||
{
|
||||
var snapshot = _cache.TryGetValue(r, out var s) ? s : new DataValueSnapshot(null, HeidenhainStatus.BadNodeIdUnknown, null, now);
|
||||
Raise(sub, r, snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
EnsureStarted();
|
||||
@@ -298,17 +304,19 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void Publish(string rawPath, DataValueSnapshot snapshot)
|
||||
internal void Publish(string rawPath, DataValueSnapshot snapshot)
|
||||
{
|
||||
var changed = true;
|
||||
_cache.AddOrUpdate(rawPath, snapshot, (_, old) =>
|
||||
// Same lock as SubscribeAsync's register + initial push (see there). Lock order is always
|
||||
// runtime publication → _publishLock; nothing takes them the other way round.
|
||||
lock (_publishLock)
|
||||
{
|
||||
changed = old.StatusCode != snapshot.StatusCode || !ValuesEqual(old.Value, snapshot.Value);
|
||||
return snapshot;
|
||||
});
|
||||
if (!changed) return;
|
||||
foreach (var sub in _subscriptions.Values)
|
||||
if (sub.References.Contains(rawPath)) Raise(sub, rawPath, snapshot);
|
||||
var changed = !_cache.TryGetValue(rawPath, out var old)
|
||||
|| old.StatusCode != snapshot.StatusCode || !ValuesEqual(old.Value, snapshot.Value);
|
||||
_cache[rawPath] = snapshot;
|
||||
if (!changed) return;
|
||||
foreach (var sub in _subscriptions.Values)
|
||||
if (sub.References.Contains(rawPath)) Raise(sub, rawPath, snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
private void Raise(Subscription sub, string rawPath, DataValueSnapshot snapshot)
|
||||
|
||||
@@ -40,8 +40,16 @@ public sealed class HeidenhainDriverProbe(ISecretResolver? secretResolver = null
|
||||
try
|
||||
{
|
||||
string password;
|
||||
// Resolved only through the namespaced resolver (fail-closed at resolve time): Test Connect can never be
|
||||
// used to send another subsystem's secret or environment variable to an arbitrary Host.
|
||||
try { password = await new HeidenhainCredentials(secretResolver).ResolveAsync(options, device, "INSPECT", cts.Token).ConfigureAwait(false); }
|
||||
catch (InvalidOperationException ex) { return new(false, ex.Message, null); }
|
||||
catch (HeidenhainCredentialException ex) { return new(false, ex.Message, null); }
|
||||
|
||||
try { _ = Lsv2ReadOnlyPolicy.LoginPayload("INSPECT", password); }
|
||||
catch (Exception ex) when (ex is Lsv2PolicyException or ArgumentException)
|
||||
{
|
||||
return new(false, "The INSPECT password cannot be sent in an LSV2 login (NUL, non-Latin-1 or too long).", null);
|
||||
}
|
||||
|
||||
var perCall = TimeSpan.FromMilliseconds(Math.Min(options.TimeoutMs, budget.TotalMilliseconds));
|
||||
await using var client = new Lsv2Client(device.Host, device.Port, perCall);
|
||||
|
||||
@@ -100,13 +100,42 @@ internal sealed class HeidenhainSession : IAsyncDisposable
|
||||
{
|
||||
try
|
||||
{
|
||||
await _client.LoginAsync(role, await _passwords(role, ct).ConfigureAwait(false), ct).ConfigureAwait(false);
|
||||
await LoginWithCredentialAsync(role, ct).ConfigureAwait(false);
|
||||
return HeidenhainStatus.Good;
|
||||
}
|
||||
catch (Lsv2RefusalException e)
|
||||
{
|
||||
return HeidenhainStatus.FromRefusal(e.Code);
|
||||
}
|
||||
catch (QualityException e)
|
||||
{
|
||||
// An unusable optional-role credential degrades only the reads that need that role; INSPECT stays up.
|
||||
return e.Status;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the role's password and logs in. A credential problem — an unresolvable reference, or a
|
||||
/// password the policy-checked payload cannot carry (NUL, non-Latin-1, > 247 bytes) — becomes
|
||||
/// <see cref="HeidenhainStatus.BadConfigurationError"/> (never sent, never a session failure). Refusals and
|
||||
/// transport failures propagate unchanged.
|
||||
/// </summary>
|
||||
private async Task LoginWithCredentialAsync(string role, CancellationToken ct)
|
||||
{
|
||||
string password;
|
||||
try { password = await _passwords(role, ct).ConfigureAwait(false); }
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
throw new QualityException(HeidenhainStatus.BadConfigurationError, HeidenhainCatalog.States.ConfigurationError);
|
||||
}
|
||||
|
||||
try { _ = Lsv2ReadOnlyPolicy.LoginPayload(role, password); }
|
||||
catch (Exception ex) when (ex is Lsv2PolicyException or ArgumentException)
|
||||
{
|
||||
throw new QualityException(HeidenhainStatus.BadConfigurationError, HeidenhainCatalog.States.ConfigurationError);
|
||||
}
|
||||
|
||||
await _client.LoginAsync(role, password, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -269,6 +298,12 @@ internal sealed class HeidenhainSession : IAsyncDisposable
|
||||
_client.Close();
|
||||
return new Outcome(null, HeidenhainStatus.BadNoCommunication);
|
||||
}
|
||||
catch (Exception ex) when (ex is Lsv2PolicyException or ArgumentException)
|
||||
{
|
||||
// A request the read-only policy refuses is never sent and never ends the session: this tag only.
|
||||
if (!isCustom) _capabilities[feature] = HeidenhainCatalog.States.ConfigurationError;
|
||||
return new Outcome(null, HeidenhainStatus.BadConfigurationError);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<long> TimerAsync(string feature, CancellationToken ct)
|
||||
@@ -330,14 +365,7 @@ internal sealed class HeidenhainSession : IAsyncDisposable
|
||||
{
|
||||
await WireAsync(wire, "Login_" + role, async c =>
|
||||
{
|
||||
string password;
|
||||
try { password = await _passwords(role, c).ConfigureAwait(false); }
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
throw new QualityException(HeidenhainStatus.BadUnexpectedError, HeidenhainCatalog.States.Unknown);
|
||||
}
|
||||
|
||||
await _client.LoginAsync(role, password, c).ConfigureAwait(false);
|
||||
await LoginWithCredentialAsync(role, c).ConfigureAwait(false);
|
||||
return [];
|
||||
}, ct).ConfigureAwait(false);
|
||||
if (dnc) _dncStatus = HeidenhainStatus.Good;
|
||||
@@ -349,6 +377,13 @@ internal sealed class HeidenhainSession : IAsyncDisposable
|
||||
else _dataStatus = HeidenhainStatus.FromRefusal(e.Code);
|
||||
throw;
|
||||
}
|
||||
catch (QualityException e)
|
||||
{
|
||||
// Credential problem: stable for this session (a fixed secret/variable is picked up on reconnect).
|
||||
if (dnc) _dncStatus = e.Status;
|
||||
else _dataStatus = e.Status;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PrepareTelemetryAsync(Dictionary<string, Raw> wire, CancellationToken ct)
|
||||
|
||||
@@ -29,6 +29,12 @@ public static class HeidenhainStatus
|
||||
/// <summary>Any other controller refusal (retried next poll).</summary>
|
||||
public const uint BadUnexpectedError = 0x80010000u;
|
||||
|
||||
/// <summary>
|
||||
/// An optional-role (DNC/DATA) credential cannot be used — unresolvable reference, or a password the login
|
||||
/// payload cannot carry (NUL, non-Latin-1, too long). Degrades only the reads that need that role.
|
||||
/// </summary>
|
||||
public const uint BadConfigurationError = 0x80890000u;
|
||||
|
||||
/// <summary>A RawPath this driver has no authored tag for.</summary>
|
||||
public const uint BadNodeIdUnknown = 0x80340000u;
|
||||
|
||||
@@ -48,7 +54,7 @@ public static class HeidenhainStatus
|
||||
/// <summary>True for the stable refusals that may be suppressed for the rest of a session.</summary>
|
||||
/// <param name="status">The status.</param>
|
||||
/// <returns>Whether it is stable.</returns>
|
||||
public static bool IsStable(uint status) => status is BadNotSupported or BadUserAccessDenied;
|
||||
public static bool IsStable(uint status) => status is BadNotSupported or BadUserAccessDenied or BadConfigurationError;
|
||||
|
||||
/// <summary>The capability state for a failure status.</summary>
|
||||
/// <param name="status">The status.</param>
|
||||
@@ -59,6 +65,7 @@ public static class HeidenhainStatus
|
||||
BadNotSupported => HeidenhainCatalog.States.Unsupported,
|
||||
BadDecodingError or BadTypeMismatch or BadOutOfRange => HeidenhainCatalog.States.InvalidData,
|
||||
BadNoCommunication => HeidenhainCatalog.States.CommunicationLost,
|
||||
BadConfigurationError => HeidenhainCatalog.States.ConfigurationError,
|
||||
_ => HeidenhainCatalog.States.Unknown,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -117,13 +117,94 @@ public sealed class HeidenhainConfigTests
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("secret:lsv2-inspect")]
|
||||
[InlineData("env:LSV2_DNC_PASSWORD")]
|
||||
[InlineData("secret:heidenhain/inspect")]
|
||||
[InlineData("secret:heidenhain/line3/cnc-7.inspect")]
|
||||
[InlineData("env:OTOPCUA_HEIDENHAIN_DNC_PASSWORD")]
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
public void Credential_references_are_accepted(string? value) =>
|
||||
public void Namespaced_credential_references_are_accepted(string? value) =>
|
||||
HeidenhainOptionsParser.IsCredentialReference(value).ShouldBeTrue();
|
||||
|
||||
/// <summary>
|
||||
/// Review finding 2: an unscoped reference let a Designer exfiltrate any node secret by pointing Host at their
|
||||
/// own machine and clicking Test Connect (the value is sent in cleartext in A_LG).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("env:ConnectionStrings__ConfigDb")]
|
||||
[InlineData("env:ServerHistorian__ApiKey")]
|
||||
[InlineData("env:HEIDENHAIN_PW")]
|
||||
[InlineData("env:otopcua_heidenhain_pw")]
|
||||
[InlineData("env:OTOPCUA_HEIDENHAIN_")]
|
||||
[InlineData("env:OTOPCUA_HEIDENHAIN_A-B")]
|
||||
[InlineData("secret:galaxy/inst1/apikey")]
|
||||
[InlineData("secret:opcua/inst/password")]
|
||||
[InlineData("secret:heidenhain/")]
|
||||
[InlineData("secret:heidenhain/../galaxy/apikey")]
|
||||
[InlineData("secret:heidenhain//x")]
|
||||
[InlineData("secret:HEIDENHAIN/x")]
|
||||
[InlineData("SECRET:heidenhain/x")]
|
||||
[InlineData("secret:")]
|
||||
[InlineData("hunter2")]
|
||||
public void References_outside_the_heidenhain_namespace_are_refused(string value)
|
||||
{
|
||||
HeidenhainOptionsParser.IsCredentialReference(value).ShouldBeFalse(value);
|
||||
HeidenhainOptionsParser.Validate(new HeidenhainDriverOptions { Access = new() { InspectPassword = value } }).ShouldNotBeEmpty();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("env:ConnectionStrings__ConfigDb")]
|
||||
[InlineData("secret:galaxy/inst1/apikey")]
|
||||
public async Task The_resolver_fails_closed_for_out_of_namespace_references_without_consulting_store_or_environment(string reference)
|
||||
{
|
||||
var resolver = new FakeResolver(new() { ["galaxy/inst1/apikey"] = "GALAXY-KEY" });
|
||||
var envReads = 0;
|
||||
var c = new HeidenhainCredentials(resolver, n => { envReads++; return "CONFIG-DB-CONNSTR"; });
|
||||
var ex = await Should.ThrowAsync<HeidenhainCredentialException>(() => c.ResolveReferenceAsync(reference, "INSPECT", TestContext.Current.CancellationToken).AsTask());
|
||||
ex.Message.ShouldNotContain("GALAXY-KEY");
|
||||
ex.Message.ShouldNotContain("CONFIG-DB-CONNSTR");
|
||||
envReads.ShouldBe(0);
|
||||
resolver.Reads.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_probe_never_sends_an_out_of_namespace_credential()
|
||||
{
|
||||
var sent = 0;
|
||||
await using var sim = Simulator.Lsv2Simulator.WithScript(async (conn, ct) => { while (await conn.ReadByteAsync(ct) >= 0) sent++; });
|
||||
var json = $$$"""{"Devices":[{"Host":"127.0.0.1","Port":{{{sim.Port}}},"DeviceName":"d"}],"Access":{"InspectPassword":"env:ConnectionStrings__ConfigDb"}}""";
|
||||
var result = await new HeidenhainDriverProbe(new FakeResolver(new())).ProbeAsync(json, TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken);
|
||||
result.Ok.ShouldBeFalse();
|
||||
await Task.Delay(100, TestContext.Current.CancellationToken);
|
||||
sim.Connections.ShouldBe(0);
|
||||
sent.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"Access":null,"Polling":null,"Reconnect":null,"Devices":null,"RawTags":null}""")]
|
||||
[InlineData("""{"Devices":[null],"RawTags":[null]}""")]
|
||||
public void Null_sections_normalise_to_defaults(string json)
|
||||
{
|
||||
var o = HeidenhainOptionsParser.Parse(json);
|
||||
o.Access.ShouldNotBeNull();
|
||||
o.Polling.MaxAlarms.ShouldBe(64);
|
||||
o.Reconnect.InitialDelayMs.ShouldBe(1000);
|
||||
o.Devices.ShouldBeEmpty();
|
||||
o.RawTags.ShouldBeEmpty();
|
||||
HeidenhainOptionsParser.Validate(o).ShouldBeEmpty();
|
||||
Should.NotThrow(() => HeidenhainDriver.ParseOptions(json));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Raw_config_scan_finds_literals_at_every_level_case_insensitively()
|
||||
{
|
||||
HeidenhainOptionsParser.DescribeCredentialProblems("""{"Host":"h","dncpassword":"hunter2"}""").Count.ShouldBe(1);
|
||||
HeidenhainOptionsParser.DescribeCredentialProblems("""{"access":{"InspectPassword":"hunter2"}}""").Count.ShouldBe(1);
|
||||
HeidenhainOptionsParser.DescribeCredentialProblems("""{"Devices":[{"DataPassword":"x"},{"DataPassword":7}]}""").Count.ShouldBe(2);
|
||||
HeidenhainOptionsParser.DescribeCredentialProblems("""{"Access":{"InspectPassword":"secret:heidenhain/a"}}""").ShouldBeEmpty();
|
||||
string.Join(" ", HeidenhainOptionsParser.DescribeCredentialProblems("""{"Access":{"InspectPassword":"hunter2"}}""")).ShouldNotContain("hunter2");
|
||||
HeidenhainOptionsParser.DescribeCredentialProblems("not json").ShouldNotBeEmpty();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("http://host")]
|
||||
@@ -139,25 +220,30 @@ public sealed class HeidenhainConfigTests
|
||||
[Fact]
|
||||
public async Task Credentials_resolve_from_secret_store_and_environment_and_fail_closed()
|
||||
{
|
||||
var resolver = new FakeResolver(new() { ["lsv2"] = "s3cret" });
|
||||
var env = new Dictionary<string, string> { ["LSV2_PW"] = "envpw" };
|
||||
var resolver = new FakeResolver(new() { ["heidenhain/lsv2"] = "s3cret" });
|
||||
var env = new Dictionary<string, string> { ["OTOPCUA_HEIDENHAIN_PW"] = "envpw" };
|
||||
var c = new HeidenhainCredentials(resolver, n => env.GetValueOrDefault(n));
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
(await c.ResolveReferenceAsync("secret:lsv2", "INSPECT", ct)).ShouldBe("s3cret");
|
||||
(await c.ResolveReferenceAsync("env:LSV2_PW", "DNC", ct)).ShouldBe("envpw");
|
||||
(await c.ResolveReferenceAsync("secret:heidenhain/lsv2", "INSPECT", ct)).ShouldBe("s3cret");
|
||||
(await c.ResolveReferenceAsync("env:OTOPCUA_HEIDENHAIN_PW", "DNC", ct)).ShouldBe("envpw");
|
||||
(await c.ResolveReferenceAsync(null, "DNC", ct)).ShouldBe("");
|
||||
await Should.ThrowAsync<InvalidOperationException>(() => c.ResolveReferenceAsync("secret:missing", "DNC", ct).AsTask());
|
||||
await Should.ThrowAsync<InvalidOperationException>(() => c.ResolveReferenceAsync("env:UNSET", "DNC", ct).AsTask());
|
||||
await Should.ThrowAsync<InvalidOperationException>(() => c.ResolveReferenceAsync("literal", "DNC", ct).AsTask());
|
||||
await Should.ThrowAsync<InvalidOperationException>(() => new HeidenhainCredentials(null).ResolveReferenceAsync("secret:lsv2", "DNC", ct).AsTask());
|
||||
await Should.ThrowAsync<HeidenhainCredentialException>(() => c.ResolveReferenceAsync("secret:heidenhain/missing", "DNC", ct).AsTask());
|
||||
await Should.ThrowAsync<HeidenhainCredentialException>(() => c.ResolveReferenceAsync("env:OTOPCUA_HEIDENHAIN_UNSET", "DNC", ct).AsTask());
|
||||
await Should.ThrowAsync<HeidenhainCredentialException>(() => c.ResolveReferenceAsync("literal", "DNC", ct).AsTask());
|
||||
await Should.ThrowAsync<HeidenhainCredentialException>(() => new HeidenhainCredentials(null).ResolveReferenceAsync("secret:heidenhain/lsv2", "DNC", ct).AsTask());
|
||||
// device override wins over the driver-level reference
|
||||
var o = new HeidenhainDriverOptions { Access = new() { InspectPassword = "secret:missing" } };
|
||||
(await c.ResolveAsync(o, new HeidenhainDeviceOptions { Host = "h", InspectPassword = "secret:lsv2" }, "INSPECT", ct)).ShouldBe("s3cret");
|
||||
var o = new HeidenhainDriverOptions { Access = new() { InspectPassword = "secret:heidenhain/missing" } };
|
||||
(await c.ResolveAsync(o, new HeidenhainDeviceOptions { Host = "h", InspectPassword = "secret:heidenhain/lsv2" }, "INSPECT", ct)).ShouldBe("s3cret");
|
||||
}
|
||||
|
||||
private sealed class FakeResolver(Dictionary<string, string> secrets) : ZB.MOM.WW.Secrets.Abstractions.ISecretResolver
|
||||
{
|
||||
public Task<string?> GetAsync(ZB.MOM.WW.Secrets.Abstractions.SecretName name, CancellationToken ct) =>
|
||||
Task.FromResult(secrets.TryGetValue(name.Value, out var v) ? v : null);
|
||||
public int Reads { get; private set; }
|
||||
|
||||
public Task<string?> GetAsync(ZB.MOM.WW.Secrets.Abstractions.SecretName name, CancellationToken ct)
|
||||
{
|
||||
Reads++;
|
||||
return Task.FromResult(secrets.TryGetValue(name.Value, out var v) ? v : null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,7 +275,7 @@ public sealed class HeidenhainDriverTests
|
||||
{
|
||||
var registry = new DriverFactoryRegistry();
|
||||
HeidenhainDriverFactoryExtensions.Register(registry);
|
||||
var json = """{"Devices":[{"Host":"10.0.0.5","DeviceName":"d"}],"Access":{"InspectPassword":"secret:lsv2/inspect"}}""";
|
||||
var json = """{"Devices":[{"Host":"10.0.0.5","DeviceName":"d"}],"Access":{"InspectPassword":"secret:heidenhain/inspect"}}""";
|
||||
var driver = registry.TryGet(DriverTypeNames.Heidenhain)!("hh", json);
|
||||
driver.ShouldBeOfType<HeidenhainDriver>();
|
||||
driver.DriverType.ShouldBe(DriverTypeNames.Heidenhain);
|
||||
|
||||
+3
-3
@@ -12,7 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Env: <c>HEIDENHAIN_LSV2_HOST</c> (required), <c>HEIDENHAIN_LSV2_PORT</c> (default 19000),
|
||||
/// <c>HEIDENHAIN_LSV2_INSPECT_PASSWORD</c> (optional; never logged).
|
||||
/// <c>OTOPCUA_HEIDENHAIN_INSPECT_PASSWORD</c> (optional; never logged).
|
||||
/// </remarks>
|
||||
[Trait("Category", "LiveIntegration")]
|
||||
public sealed class HeidenhainLiveIntegrationTests
|
||||
@@ -28,7 +28,7 @@ public sealed class HeidenhainLiveIntegrationTests
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var client = new Lsv2Client(Host!, Port, TimeSpan.FromSeconds(5));
|
||||
await client.OpenAsync(ct);
|
||||
await client.LoginAsync("INSPECT", Environment.GetEnvironmentVariable("HEIDENHAIN_LSV2_INSPECT_PASSWORD") ?? "", ct);
|
||||
await client.LoginAsync("INSPECT", Environment.GetEnvironmentVariable("OTOPCUA_HEIDENHAIN_INSPECT_PASSWORD") ?? "", ct);
|
||||
var versions = Lsv2Data.Versions(await client.ReadVersionAsync(ct));
|
||||
TestContext.Current.SendDiagnosticMessage($"model={versions.Model} nc={versions.Nc} plc={versions.Plc}");
|
||||
versions.Model.ShouldNotBeNullOrWhiteSpace();
|
||||
@@ -68,7 +68,7 @@ public sealed class HeidenhainLiveIntegrationTests
|
||||
{
|
||||
Devices = [new HeidenhainDeviceOptions { Host = Host!, Port = Port, DeviceName = "dev1" }],
|
||||
RawTags = tags,
|
||||
Access = new HeidenhainAccessOptions { InspectPassword = Environment.GetEnvironmentVariable("HEIDENHAIN_LSV2_INSPECT_PASSWORD") is { Length: > 0 } ? "env:HEIDENHAIN_LSV2_INSPECT_PASSWORD" : null },
|
||||
Access = new HeidenhainAccessOptions { InspectPassword = Environment.GetEnvironmentVariable("OTOPCUA_HEIDENHAIN_INSPECT_PASSWORD") is { Length: > 0 } ? "env:OTOPCUA_HEIDENHAIN_INSPECT_PASSWORD" : null },
|
||||
};
|
||||
await using var driver = new HeidenhainDriver(options, "live");
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.Simulator;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests;
|
||||
|
||||
/// <summary>Regression tests for the independent-review findings 3–7 on feat/803 (each fails on the pre-fix code).</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HeidenhainReviewFixTests
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private static async Task<(Lsv2Simulator Sim, HeidenhainSession Session)> OpenAsync(
|
||||
SimulatedControl control, HeidenhainSessionSettings settings, HeidenhainSession.PasswordSource passwords)
|
||||
{
|
||||
var sim = Lsv2Simulator.WithControl(control);
|
||||
var client = new Lsv2Client("127.0.0.1", sim.Port, TimeSpan.FromSeconds(1));
|
||||
var session = new HeidenhainSession(client, settings, passwords);
|
||||
await client.OpenAsync(Ct);
|
||||
await session.InitializeAsync(Ct);
|
||||
return (sim, session);
|
||||
}
|
||||
|
||||
// ---- Finding 6: an unusable OPTIONAL-role credential degrades only the reads that need that role. ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("throw")] // unresolvable reference (unset variable / missing secret)
|
||||
[InlineData("nul")] // a NUL the login payload cannot carry
|
||||
[InlineData("non-latin1")] // € is not Latin-1
|
||||
[InlineData("too-long")] // A_LG payload > 247 bytes
|
||||
public async Task Bad_dnc_credential_keeps_inspect_telemetry_and_sends_no_dnc_login(string kind)
|
||||
{
|
||||
var control = new SimulatedControl();
|
||||
control.Runtime[23] = () => SimulatedControl.U16(4);
|
||||
var settings = new HeidenhainSessionSettings(true, true, false, 64, false, false, false);
|
||||
var (sim, session) = await OpenAsync(control, settings, (role, _) => role == "DNC" ? BadPassword(kind) : ValueTask.FromResult(""));
|
||||
await using var _ = sim;
|
||||
await using var __ = session;
|
||||
var tags = new[] { TestTags.Def("Status/ExecutionState"), TestTags.Def("Capabilities/DncLogin") };
|
||||
var s1 = await session.ReadBatchAsync(tags, Ct);
|
||||
var s2 = await session.ReadBatchAsync(tags, Ct);
|
||||
s1[0].Value.ShouldBe(4);
|
||||
s2[0].Value.ShouldBe(4);
|
||||
s1[1].Value.ShouldBe("ConfigurationError");
|
||||
session.CommunicationLost.ShouldBeFalse();
|
||||
control.Logins.Select(l => l.Role).ShouldBe(new[] { "INSPECT" });
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("throw")]
|
||||
[InlineData("nul")]
|
||||
[InlineData("too-long")]
|
||||
public async Task Bad_data_credential_is_BadConfigurationError_on_data_bindings_only(string kind)
|
||||
{
|
||||
var control = new SimulatedControl();
|
||||
control.Runtime[23] = () => SimulatedControl.U16(4);
|
||||
control.DataPaths["/x"] = SimReply.Ok("S_DP", [0, 0, 0, 3, 0, 0, 0, 42]);
|
||||
var settings = new HeidenhainSessionSettings(true, false, true, 64, false, false, true);
|
||||
var (sim, session) = await OpenAsync(control, settings, (role, _) => role == "DATA" ? BadPassword(kind) : ValueTask.FromResult(""));
|
||||
await using var _ = sim;
|
||||
await using var __ = session;
|
||||
var tags = new[] { TestTags.Def("DATA:/x", "Int64"), TestTags.Def("Status/ExecutionState"), TestTags.Def("Capabilities/DataLogin") };
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
var s = await session.ReadBatchAsync(tags, Ct);
|
||||
s[0].Status.ShouldBe(HeidenhainStatus.BadConfigurationError);
|
||||
s[1].Value.ShouldBe(4);
|
||||
s[2].Value.ShouldBe("ConfigurationError");
|
||||
}
|
||||
|
||||
session.CommunicationLost.ShouldBeFalse();
|
||||
sim.Requests.ShouldNotContain(r => r.Command == "R_DP");
|
||||
control.Logins.ShouldNotContain(l => l.Role == "DATA");
|
||||
}
|
||||
|
||||
private static ValueTask<string> BadPassword(string kind) => kind switch
|
||||
{
|
||||
"throw" => throw new HeidenhainCredentialException("DATA password environment variable 'OTOPCUA_HEIDENHAIN_X' is not set (fail-closed)."),
|
||||
"nul" => ValueTask.FromResult("a\0b"),
|
||||
"non-latin1" => ValueTask.FromResult("p€ss"),
|
||||
_ => ValueTask.FromResult(new string('x', 250)),
|
||||
};
|
||||
|
||||
// ---- Finding 3: Start after DisposeAsync must not revive an orphan loop. ----
|
||||
|
||||
[Fact]
|
||||
public async Task Start_after_dispose_is_a_no_op()
|
||||
{
|
||||
var control = new SimulatedControl();
|
||||
control.Runtime[23] = () => SimulatedControl.U16(4);
|
||||
await using var sim = Lsv2Simulator.WithControl(control);
|
||||
var runtime = NewRuntime(sim.Port, (_, _) => { });
|
||||
await runtime.DisposeAsync();
|
||||
runtime.Start();
|
||||
await Task.Delay(400, Ct);
|
||||
sim.Connections.ShouldBe(0);
|
||||
runtime.State.ShouldBe(HeidenhainConnectionState.Stopped);
|
||||
}
|
||||
|
||||
// ---- Finding 5: null sections must not NRE, and nothing may kill the loop silently. ----
|
||||
|
||||
[Fact]
|
||||
public async Task Null_option_sections_from_a_constructed_options_object_do_not_kill_the_loop()
|
||||
{
|
||||
int port;
|
||||
await using (var probe = Lsv2Simulator.WithScript((_, _) => Task.CompletedTask)) port = probe.Port;
|
||||
var options = new HeidenhainDriverOptions
|
||||
{
|
||||
Devices = [new HeidenhainDeviceOptions { Host = "127.0.0.1", Port = port, DeviceName = "dev1" }],
|
||||
RawTags = [TestTags.Raw("Status/ExecutionState")],
|
||||
Access = null!,
|
||||
Polling = null!,
|
||||
Reconnect = null!,
|
||||
TimeoutMs = 300,
|
||||
};
|
||||
var attempts = new ConcurrentQueue<DateTime>();
|
||||
await using var d = new HeidenhainDriver(options, "hh", null, new HeidenhainCredentials(null),
|
||||
(h, p, t) => { attempts.Enqueue(DateTime.UtcNow); return new Lsv2Client(h, p, t); }, null);
|
||||
await d.InitializeAsync("{}", Ct);
|
||||
await d.SubscribeAsync([TestTags.Raw("Status/ExecutionState").RawPath], TimeSpan.FromSeconds(1), Ct);
|
||||
await Task.Delay(3500, Ct);
|
||||
attempts.Count.ShouldBeGreaterThanOrEqualTo(2); // the loop survived the first failure and retried
|
||||
d.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Null_option_sections_normalise_so_the_control_is_actually_read()
|
||||
{
|
||||
var control = new SimulatedControl();
|
||||
control.Runtime[23] = () => SimulatedControl.U16(4);
|
||||
await using var sim = Lsv2Simulator.WithControl(control);
|
||||
var tag = TestTags.Raw("Status/ExecutionState");
|
||||
var options = new HeidenhainDriverOptions
|
||||
{
|
||||
Devices = [new HeidenhainDeviceOptions { Host = "127.0.0.1", Port = sim.Port, DeviceName = "dev1" }],
|
||||
RawTags = [tag],
|
||||
Access = null!,
|
||||
Polling = null!,
|
||||
Reconnect = null!,
|
||||
};
|
||||
await using var d = new HeidenhainDriver(options, "hh", null, new HeidenhainCredentials(null), null, null);
|
||||
await d.InitializeAsync("{}", Ct);
|
||||
await d.SubscribeAsync([tag.RawPath], TimeSpan.FromSeconds(1), Ct);
|
||||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||
while ((await d.ReadAsync([tag.RawPath], Ct))[0].StatusCode != HeidenhainStatus.Good && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(50, Ct);
|
||||
(await d.ReadAsync([tag.RawPath], Ct))[0].Value.ShouldBe(4);
|
||||
}
|
||||
|
||||
// ---- Finding 7: backoff resets only after real progress (a published poll batch). ----
|
||||
|
||||
[Fact]
|
||||
public async Task A_control_that_accepts_login_then_drops_is_backed_off_not_hammered()
|
||||
{
|
||||
var control = new SimulatedControl();
|
||||
control.Runtime[23] = SimReply.Drop; // login + version succeed, the first poll read drops
|
||||
await using var sim = Lsv2Simulator.WithControl(control);
|
||||
var runtime = NewRuntime(sim.Port, (_, _) => { });
|
||||
await using var _ = runtime;
|
||||
runtime.Start();
|
||||
await Task.Delay(2000, Ct);
|
||||
// initial 100 ms doubling to 800 ms: ~100+200+400+800 ⇒ ≤ 6 connects in 2 s; pre-fix it reset to
|
||||
// 100 ms after every login and dialled ~15 times.
|
||||
sim.Connections.ShouldBeInRange(2, 6);
|
||||
runtime.ConsecutiveFailures.ShouldBeGreaterThanOrEqualTo(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Backoff_resets_after_a_session_that_published()
|
||||
{
|
||||
var reads = 0;
|
||||
var control = new SimulatedControl();
|
||||
control.Runtime[23] = () => Interlocked.Increment(ref reads) % 3 == 0 ? SimReply.Drop() : SimulatedControl.U16(4);
|
||||
await using var sim = Lsv2Simulator.WithControl(control);
|
||||
var runtime = NewRuntime(sim.Port, (_, _) => { });
|
||||
await using var _ = runtime;
|
||||
runtime.Start();
|
||||
await Task.Delay(1500, Ct);
|
||||
runtime.ConsecutiveFailures.ShouldBeLessThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
// ---- Finding 4: the initial subscription push can never deliver an older value after a newer one. ----
|
||||
|
||||
[Fact]
|
||||
public async Task Initial_push_and_publish_are_ordered()
|
||||
{
|
||||
var options = new HeidenhainDriverOptions
|
||||
{
|
||||
Devices = [new HeidenhainDeviceOptions { Host = "127.0.0.1", Port = 1, DeviceName = "dev1" }],
|
||||
RawTags = [TestTags.Raw("Status/ExecutionState")],
|
||||
};
|
||||
await using var d = new HeidenhainDriver(options, "hh", null, new HeidenhainCredentials(null), null, null);
|
||||
await d.InitializeAsync("{}", Ct);
|
||||
var raw = options.RawTags[0].RawPath;
|
||||
var perSub = new ConcurrentDictionary<ISubscriptionHandle, List<int>>();
|
||||
d.OnDataChange += (_, e) =>
|
||||
{
|
||||
if (e.Snapshot.Value is int v) perSub.GetOrAdd(e.SubscriptionHandle, _ => []).Add(v);
|
||||
};
|
||||
using var stop = new CancellationTokenSource();
|
||||
var publisher = Task.Run(() =>
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
for (var i = 1; i < 20_000 && !stop.IsCancellationRequested; i++)
|
||||
d.Publish(raw, new DataValueSnapshot(i, HeidenhainStatus.Good, now, now));
|
||||
}, Ct);
|
||||
for (var i = 0; i < 200; i++)
|
||||
await d.SubscribeAsync([raw], TimeSpan.FromSeconds(1), Ct);
|
||||
await publisher;
|
||||
foreach (var (_, values) in perSub)
|
||||
{
|
||||
List<int> copy;
|
||||
lock (values) copy = values.ToList();
|
||||
for (var i = 1; i < copy.Count; i++)
|
||||
copy[i].ShouldBeGreaterThan(copy[i - 1], "a subscriber saw an older value after a newer one");
|
||||
}
|
||||
}
|
||||
|
||||
private static HeidenhainDeviceRuntime NewRuntime(int port, Action<string, DataValueSnapshot> publish)
|
||||
{
|
||||
var options = new HeidenhainDriverOptions
|
||||
{
|
||||
TimeoutMs = 500,
|
||||
Polling = new HeidenhainPollingOptions { StatusMs = 100, CustomMs = 100, SlowMs = 100 },
|
||||
Reconnect = new HeidenhainReconnectOptions { InitialDelayMs = 100, MaxDelayMs = 800 },
|
||||
};
|
||||
var device = new HeidenhainDeviceOptions { Host = "127.0.0.1", Port = port, DeviceName = "dev1" };
|
||||
return new HeidenhainDeviceRuntime(device, options, [TestTags.Def("Status/ExecutionState")],
|
||||
(_, _) => ValueTask.FromResult(""), publish, (_, _, _) => { },
|
||||
(h, p, t) => new Lsv2Client(h, p, t), TimeProvider.System, NullLogger.Instance);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user