17130d9c82
Two guards enumerated the same RawBrowseCommitMapper.BuildTagConfig seam against
two hand-authored lists, which can only drift. The older one's list was also
misnamed: DriverDispatchMapParityTests.NonBrowsableDriverTypes really meant
"types that produce the generic key" — AbLegacy is genuinely non-browsable yet
correctly absent from it, because it has a typed branch.
Retire the older fact. RawBrowseCommitMapperParityTests.GenericKeyExemptions is
now the single hand-authored list for this seam: named for what it holds, one
written reason per entry, and checked against a DI-derived browsable set rather
than a second list. The one assertion the retired fact made that nothing here
covered — set EQUALITY, i.e. an exemption must still DESCRIBE the mapper — moves
across as Every_generic_key_exemption_still_commits_to_the_generic_key. Without
it, a driver that gained a typed branch would keep a silently inert exemption.
Item 2: OpcUaClientDriverBrowser and GalaxyDriverBrowser returned string
literals ("OpcUaClient", "GalaxyMxGateway") where Sql/Mqtt use DriverTypeNames.
The values matched, and nothing guarded that. Repoint both at the constants —
DriverTypeNames.Galaxy carries the identical value — and add
Every_registered_browser_is_keyed_by_a_declared_driver_type_name over the same
AddAdminUI() read-back. Compared ORDINALLY: BrowserSessionService indexes on the
exact persisted string, and case-only drift (TwinCat/TwinCAT) is what
DriverTypeNames exists to end. Browser projects are out of reach of
DriverTypeNamesGuardTests, which pins the driver FACTORIES' names.
Falsified, four ways, each red naming the offender and green on revert:
- #555 re-run: disabling the Sql branch reddens the two enumeration facts
("...no dedicated BuildTagConfig branch...: Sql"), the same catch the
retired fact made.
- giving Modbus a typed branch reddens ONLY the new stale-exemption fact,
proving it is not redundant with the four that were already there.
- SqlDriverBrowser.DriverType => "SQL" and Galaxy => "GalaxyMXGateway" each
redden the new browser-key guard by name — case-only typos, which an
OrdinalIgnoreCase compare would have waved through.
248 lines
12 KiB
C#
248 lines
12 KiB
C#
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Opc.Ua;
|
|
using Opc.Ua.Client;
|
|
using Opc.Ua.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; // OpcUaClientDriverOptions + NamespaceMap
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
|
|
|
|
/// <summary>
|
|
/// Opens transient OPC UA sessions from form-supplied JSON for the AdminUI address picker.
|
|
/// Mirrors the runtime driver's connect path but with a separate PKI store so browse-time
|
|
/// trust decisions cannot poison the runtime driver's cert store.
|
|
/// </summary>
|
|
public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
|
{
|
|
// Kept identical to OpcUaClientDriverFactoryExtensions.JsonOptions and
|
|
// OpcUaClientDriverProbe._opts so the browser parses a given DriverConfig JSON
|
|
// the same way as the factory and the probe. The JsonStringEnumConverter lets
|
|
// enum-valued knobs (SecurityPolicy / SecurityMode / AuthType / TargetNamespaceKind)
|
|
// be authored as their string names — the natural form for AdminUI-emitted JSON.
|
|
// JsonStringEnumConverter also accepts numeric ordinals, so existing numeric configs
|
|
// continue to work without migration.
|
|
private static readonly JsonSerializerOptions JsonOpts = new()
|
|
{
|
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
|
PropertyNameCaseInsensitive = true,
|
|
Converters = { new JsonStringEnumConverter() },
|
|
};
|
|
|
|
private readonly ILogger<OpcUaClientDriverBrowser> _logger;
|
|
|
|
/// <summary>Creates a new browser. Logger defaults to NullLogger when not supplied.</summary>
|
|
/// <param name="logger">Optional logger; defaults to <see cref="NullLogger{T}"/>.</param>
|
|
public OpcUaClientDriverBrowser(ILogger<OpcUaClientDriverBrowser>? logger = null)
|
|
{
|
|
_logger = logger ?? NullLogger<OpcUaClientDriverBrowser>.Instance;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
/// <remarks>
|
|
/// Sourced from the shared <see cref="DriverTypeNames"/> set rather than the hand-written literal
|
|
/// this line used to carry (Gitea #558). The value is unchanged; the constant is what
|
|
/// <c>DriverTypeNamesGuardTests</c> pins to the factory's own registration, so a rename on either
|
|
/// side can no longer leave this browser indexed under a key nothing dispatches to.
|
|
/// </remarks>
|
|
public string DriverType => DriverTypeNames.OpcUaClient;
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IBrowseSession> OpenAsync(string configJson, CancellationToken cancellationToken)
|
|
{
|
|
var opts = JsonSerializer.Deserialize<OpcUaClientDriverOptions>(configJson, JsonOpts)
|
|
?? throw new InvalidOperationException("OpcUaClient options deserialized to null.");
|
|
|
|
var endpoint = opts.EndpointUrls is { Count: > 0 } ? opts.EndpointUrls[0] : opts.EndpointUrl;
|
|
if (string.IsNullOrWhiteSpace(endpoint))
|
|
throw new InvalidOperationException("OpcUaClient browser requires EndpointUrl or EndpointUrls[0].");
|
|
|
|
if (opts.AuthType == OpcUaAuthType.Certificate)
|
|
throw new InvalidOperationException(
|
|
"Browser does not support OpcUaAuthType.Certificate in v1; use Anonymous or Username.");
|
|
|
|
if (opts.AutoAcceptCertificates)
|
|
_logger.LogWarning(
|
|
"AdminUI browse session opens against {Endpoint} with form's AutoAcceptCertificates=true — " +
|
|
"browse uses its own cert store and does NOT auto-accept; trust the cert via the runtime " +
|
|
"driver's PKI store instead.",
|
|
endpoint);
|
|
|
|
var appConfig = await BuildBrowseAppConfigurationAsync(cancellationToken).ConfigureAwait(false);
|
|
var identity = BuildBrowseUserIdentity(opts);
|
|
|
|
var perEndpointBudget = TimeSpan.FromSeconds(
|
|
Math.Clamp(opts.PerEndpointConnectTimeout.TotalSeconds, 5, 30));
|
|
using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
connectCts.CancelAfter(perEndpointBudget);
|
|
|
|
var endpointDesc = await SelectEndpointAsync(
|
|
appConfig, endpoint, opts.SecurityPolicy, opts.SecurityMode, connectCts.Token).ConfigureAwait(false);
|
|
var endpointCfg = EndpointConfiguration.Create(appConfig);
|
|
endpointCfg.OperationTimeout = (int)opts.Timeout.TotalMilliseconds;
|
|
var configuredEndpoint = new ConfiguredEndpoint(null, endpointDesc, endpointCfg);
|
|
|
|
var session = await new DefaultSessionFactory(telemetry: null!).CreateAsync(
|
|
appConfig,
|
|
configuredEndpoint,
|
|
updateBeforeConnect: false,
|
|
sessionName: "OtOpcUa AdminUI Browse",
|
|
(uint)opts.SessionTimeout.TotalMilliseconds,
|
|
identity,
|
|
preferredLocales: null,
|
|
connectCts.Token).ConfigureAwait(false);
|
|
|
|
try
|
|
{
|
|
var nsMap = NamespaceMap.FromSession(session);
|
|
var rootNodeId = string.IsNullOrEmpty(opts.BrowseRoot)
|
|
? ObjectIds.ObjectsFolder
|
|
: NodeId.Parse(session.MessageContext, opts.BrowseRoot);
|
|
|
|
_logger.LogInformation(
|
|
"AdminUI OPC UA browse session opened against {Endpoint} (policy {Policy}, mode {Mode})",
|
|
endpoint, opts.SecurityPolicy, opts.SecurityMode);
|
|
|
|
return new OpcUaClientBrowseSession(session, nsMap, rootNodeId);
|
|
}
|
|
catch
|
|
{
|
|
try { if (session is Session s) await s.CloseAsync().ConfigureAwait(false); } catch { /* best-effort */ }
|
|
try { session.Dispose(); } catch { /* best-effort */ }
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Build a minimal in-memory ApplicationConfiguration using a SEPARATE PKI root from
|
|
/// the runtime driver (<c>%LocalAppData%/OtOpcUa/adminui-browse-pki/</c>). Browse
|
|
/// trust decisions don't leak into the deployed driver's cert store.
|
|
/// </summary>
|
|
/// <param name="ct">Cancellation token for the configuration build.</param>
|
|
private static async Task<ApplicationConfiguration> BuildBrowseAppConfigurationAsync(CancellationToken ct)
|
|
{
|
|
// The default ctor is obsolete in favour of the ITelemetryContext overload; suppress
|
|
// locally rather than plumbing a telemetry context all the way through — the browser
|
|
// emits no per-request telemetry of its own and the SDK's internal fallback is fine
|
|
// for a transient picker session (mirrors the runtime driver's same suppression).
|
|
#pragma warning disable CS0618
|
|
var app = new ApplicationInstance
|
|
{
|
|
ApplicationName = "OtOpcUa AdminUI Browse",
|
|
ApplicationType = ApplicationType.Client,
|
|
};
|
|
#pragma warning restore CS0618
|
|
|
|
var pkiRoot = Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
|
"OtOpcUa", "adminui-browse-pki");
|
|
|
|
var config = new ApplicationConfiguration
|
|
{
|
|
ApplicationName = "OtOpcUa AdminUI Browse",
|
|
ApplicationType = ApplicationType.Client,
|
|
ApplicationUri = "urn:OtOpcUa:AdminUI:Browse",
|
|
SecurityConfiguration = new SecurityConfiguration
|
|
{
|
|
ApplicationCertificate = new CertificateIdentifier
|
|
{
|
|
StoreType = CertificateStoreType.Directory,
|
|
StorePath = Path.Combine(pkiRoot, "own"),
|
|
SubjectName = "CN=OtOpcUa AdminUI Browse",
|
|
},
|
|
TrustedPeerCertificates = new CertificateTrustList
|
|
{
|
|
StoreType = CertificateStoreType.Directory,
|
|
StorePath = Path.Combine(pkiRoot, "trusted"),
|
|
},
|
|
TrustedIssuerCertificates = new CertificateTrustList
|
|
{
|
|
StoreType = CertificateStoreType.Directory,
|
|
StorePath = Path.Combine(pkiRoot, "issuers"),
|
|
},
|
|
RejectedCertificateStore = new CertificateTrustList
|
|
{
|
|
StoreType = CertificateStoreType.Directory,
|
|
StorePath = Path.Combine(pkiRoot, "rejected"),
|
|
},
|
|
AutoAcceptUntrustedCertificates = false,
|
|
},
|
|
TransportQuotas = new TransportQuotas { OperationTimeout = 30_000 },
|
|
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
|
|
DisableHiResClock = true,
|
|
};
|
|
await config.ValidateAsync(ApplicationType.Client, ct).ConfigureAwait(false);
|
|
app.ApplicationConfiguration = config;
|
|
await app.CheckApplicationInstanceCertificatesAsync(silent: true, lifeTimeInMonths: null, ct)
|
|
.ConfigureAwait(false);
|
|
return config;
|
|
}
|
|
|
|
/// <summary>Build the OPC UA user identity from the form's auth fields.</summary>
|
|
/// <param name="opts">Driver options carrying the form's auth fields.</param>
|
|
private static UserIdentity BuildBrowseUserIdentity(OpcUaClientDriverOptions opts) => opts.AuthType switch
|
|
{
|
|
OpcUaAuthType.Anonymous => new UserIdentity(new AnonymousIdentityToken()),
|
|
OpcUaAuthType.Username => new UserIdentity(
|
|
opts.Username ?? string.Empty,
|
|
System.Text.Encoding.UTF8.GetBytes(opts.Password ?? string.Empty)),
|
|
_ => new UserIdentity(new AnonymousIdentityToken()),
|
|
};
|
|
|
|
/// <summary>Select the endpoint matching the requested SecurityPolicy + SecurityMode pair.</summary>
|
|
/// <param name="appConfig">Application configuration used by the discovery client.</param>
|
|
/// <param name="url">Remote endpoint URL to query.</param>
|
|
/// <param name="policy">Required security policy.</param>
|
|
/// <param name="mode">Required message security mode.</param>
|
|
/// <param name="ct">Cancellation token for the discovery call.</param>
|
|
private static async Task<EndpointDescription> SelectEndpointAsync(
|
|
ApplicationConfiguration appConfig, string url,
|
|
OpcUaSecurityPolicy policy, OpcUaSecurityMode mode, CancellationToken ct)
|
|
{
|
|
using var client = await DiscoveryClient.CreateAsync(
|
|
appConfig, new Uri(url), DiagnosticsMasks.None, ct).ConfigureAwait(false);
|
|
var all = await client.GetEndpointsAsync(null, ct).ConfigureAwait(false);
|
|
|
|
var wantedPolicy = MapPolicy(policy);
|
|
var wantedMode = mode switch
|
|
{
|
|
OpcUaSecurityMode.None => MessageSecurityMode.None,
|
|
OpcUaSecurityMode.Sign => MessageSecurityMode.Sign,
|
|
OpcUaSecurityMode.SignAndEncrypt => MessageSecurityMode.SignAndEncrypt,
|
|
_ => throw new ArgumentOutOfRangeException(nameof(mode)),
|
|
};
|
|
var match = all.FirstOrDefault(e =>
|
|
e.SecurityPolicyUri == wantedPolicy && e.SecurityMode == wantedMode);
|
|
if (match is null)
|
|
{
|
|
var advertised = string.Join(", ", all.Select(e =>
|
|
$"{ShortName(e.SecurityPolicyUri)}/{e.SecurityMode}"));
|
|
throw new InvalidOperationException(
|
|
$"No endpoint at '{url}' matches SecurityPolicy={policy} + SecurityMode={mode}. " +
|
|
$"Server advertises: {advertised}");
|
|
}
|
|
return match;
|
|
}
|
|
|
|
/// <summary>Convert the driver options enum to the OPC UA policy URI.</summary>
|
|
/// <param name="p">The driver security policy to map.</param>
|
|
private static string MapPolicy(OpcUaSecurityPolicy p) => p switch
|
|
{
|
|
OpcUaSecurityPolicy.None => SecurityPolicies.None,
|
|
OpcUaSecurityPolicy.Basic128Rsa15 => SecurityPolicies.Basic128Rsa15,
|
|
OpcUaSecurityPolicy.Basic256 => SecurityPolicies.Basic256,
|
|
OpcUaSecurityPolicy.Basic256Sha256 => SecurityPolicies.Basic256Sha256,
|
|
OpcUaSecurityPolicy.Aes128_Sha256_RsaOaep => SecurityPolicies.Aes128_Sha256_RsaOaep,
|
|
OpcUaSecurityPolicy.Aes256_Sha256_RsaPss => SecurityPolicies.Aes256_Sha256_RsaPss,
|
|
_ => throw new ArgumentOutOfRangeException(nameof(p)),
|
|
};
|
|
|
|
/// <summary>Render an OPC UA security-policy URI as its short suffix for diag messages.</summary>
|
|
/// <param name="uri">Full policy URI to shorten.</param>
|
|
private static string ShortName(string uri) =>
|
|
uri?.Substring(uri.LastIndexOf('#') + 1) ?? "(null)";
|
|
}
|