diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyBrowseSession.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyBrowseSession.cs index 756d2977..3049e23a 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyBrowseSession.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyBrowseSession.cs @@ -12,11 +12,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser; /// caches it by TagName (and GobjectId for parent lookup), and serves /// subsequent calls in-memory. Attribute fetches are /// per-object via DiscoverHierarchyAsync(MaxDepth=0, IncludeAttributes=true). -/// Owns the supplied and disposes it best-effort. +/// Owns the supplied and disposes it +/// best-effort. (Browse does not need an MxGatewaySession — that's only +/// required for live subscribe/write paths handled by the runtime driver.) /// internal sealed class GalaxyBrowseSession : IBrowseSession { - private readonly MxGatewaySession _session; private readonly GalaxyRepositoryClient _client; private readonly ConcurrentDictionary _byTagName = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _byGobjectId = new(); @@ -31,15 +32,14 @@ internal sealed class GalaxyBrowseSession : IBrowseSession public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow; /// - /// Initializes a new session wrapping a connected gateway client. The factory - /// in GalaxyDriverBrowser (Task 9) constructs both the session and the - /// repository client and hands them off here for the session's lifetime. + /// Initializes a new session wrapping a connected repository client. The factory + /// in GalaxyDriverBrowser (Task 9) constructs the client via + /// and hands it off here for the + /// session's lifetime. /// - /// Gateway session to dispose when the browse closes. /// Galaxy repository client to query for hierarchy and attributes. - internal GalaxyBrowseSession(MxGatewaySession session, GalaxyRepositoryClient client) + internal GalaxyBrowseSession(GalaxyRepositoryClient client) { - _session = session ?? throw new ArgumentNullException(nameof(session)); _client = client ?? throw new ArgumentNullException(nameof(client)); } @@ -186,7 +186,7 @@ internal sealed class GalaxyBrowseSession : IBrowseSession }; /// - /// Idempotently tears down the underlying gateway session. Swallows exceptions + /// Idempotently tears down the underlying repository client. Swallows exceptions /// on shutdown — the registry's reaper may be racing a client-initiated close. /// public async ValueTask DisposeAsync() @@ -196,7 +196,7 @@ internal sealed class GalaxyBrowseSession : IBrowseSession _rootGate.Dispose(); try { - await _session.DisposeAsync().ConfigureAwait(false); + await _client.DisposeAsync().ConfigureAwait(false); } catch { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyDriverBrowser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyDriverBrowser.cs new file mode 100644 index 00000000..98b8cbdb --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyDriverBrowser.cs @@ -0,0 +1,192 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using MxGateway.Client; +using ZB.MOM.WW.OtOpcUa.Commons.Browsing; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser; + +/// +/// Opens transient gateway connections for the AdminUI address picker. Mirrors the +/// runtime GalaxyDriver.BuildClientOptions pattern so the gateway sees the +/// same option shape, but tweaks +/// resolution + the gateway-side client identity so browse sessions are +/// distinguishable from the runtime driver's live MX session. +/// +public sealed class GalaxyDriverBrowser : IDriverBrowser +{ + /// + /// Identifier used in gateway-side logs / metrics for AdminUI browse sessions. + /// Distinct from any runtime driver's MxAccess.ClientName so an operator + /// can tell the two apart when triaging. + /// + internal const string BrowseClientIdentity = "OtOpcUa-AdminUI-Browse"; + + /// Hard cap on the time we'll wait for the initial gateway handshake. + private static readonly TimeSpan ConnectBudget = TimeSpan.FromSeconds(30); + + private static readonly JsonSerializerOptions JsonOpts = new() + { + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + PropertyNameCaseInsensitive = true, + }; + + private readonly ILogger _logger; + + /// Creates a new browser. Logger defaults to . + /// Optional logger; null is allowed for unit-test construction. + public GalaxyDriverBrowser(ILogger? logger = null) + { + _logger = logger ?? NullLogger.Instance; + } + + /// Driver type key — matches the AdminUI's persisted "Galaxy" value. + public string DriverType => "Galaxy"; + + /// + /// Deserializes a blob, opens a transient + /// against the configured gateway endpoint, + /// and returns a browse session over it. The session owns the client and disposes + /// it on . + /// + /// Driver options serialized as JSON; same shape the runtime + /// driver would consume. + /// Cancellation for the connect phase only. + /// + /// Thrown when the JSON deserialises to null, when Gateway.Endpoint is empty, + /// or when MxAccess.ClientName is empty. + /// + public async Task OpenAsync(string configJson, CancellationToken cancellationToken) + { + var opts = JsonSerializer.Deserialize(configJson, JsonOpts) + ?? throw new InvalidOperationException("Galaxy options deserialized to null."); + + if (string.IsNullOrWhiteSpace(opts.Gateway.Endpoint)) + throw new InvalidOperationException("Galaxy browser requires Gateway.Endpoint."); + + // The form persists MXAccess identity as ClientName (there is no separate + // "galaxy name" knob on the driver — the gateway picks the galaxy via its + // own GalaxyRepository config). Refuse a blank ClientName so the gateway side + // doesn't see anonymous browse sessions during triage. + if (string.IsNullOrWhiteSpace(opts.MxAccess.ClientName)) + throw new InvalidOperationException("Galaxy browser requires MxAccess.ClientName."); + + var clientOpts = BuildClientOptions(opts.Gateway); + + // 30s wall-clock budget for the connect phase, linked to the caller's token so + // an AdminUI cancel still wins early. + using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + connectCts.CancelAfter(ConnectBudget); + + GalaxyRepositoryClient? client = null; + try + { + client = GalaxyRepositoryClient.Create(clientOpts); + + // TestConnectionAsync gives the gateway a chance to surface auth / TLS / DNS + // failures synchronously inside the connect budget rather than waiting for + // the first DiscoverHierarchyAsync call to fail. The client's own + // ConnectTimeout already bounds the underlying gRPC handshake; the linked + // CTS layered on top guarantees the AdminUI never blocks past 30s. + await client.TestConnectionAsync(connectCts.Token).ConfigureAwait(false); + + _logger.LogInformation( + "AdminUI Galaxy browse session opened against {Endpoint} (admin-client={Identity}, runtime-client={RuntimeClient})", + opts.Gateway.Endpoint, BrowseClientIdentity, opts.MxAccess.ClientName); + + var session = new GalaxyBrowseSession(client); + client = null; // Ownership transferred — keep finally from disposing. + return session; + } + catch + { + if (client is not null) + { + try + { + await client.DisposeAsync().ConfigureAwait(false); + } + catch + { + // Best-effort cleanup; the original exception is more useful. + } + } + throw; + } + } + + /// + /// Build the gateway client options from the form's Gateway section. Mirrors the + /// runtime driver's GalaxyDriver.BuildClientOptions field-for-field so the + /// gateway sees an identical option shape. The API-key reference is resolved + /// inline (a slim version of GalaxyDriver.ResolveApiKey) because the + /// Browser project doesn't reference Driver.Galaxy. + /// + private MxGatewayClientOptions BuildClientOptions(GalaxyGatewayOptions gw) => new() + { + Endpoint = new Uri(gw.Endpoint, UriKind.Absolute), + ApiKey = ResolveApiKey(gw.ApiKeySecretRef), + UseTls = gw.UseTls, + CaCertificatePath = gw.CaCertificatePath, + ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds), + DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds), + StreamTimeout = gw.StreamTimeoutSeconds > 0 + ? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds) + : null, + }; + + /// + /// Resolves env:NAME, file:PATH, and dev:KEY prefixes; + /// anything else is treated as a literal cleartext key with a startup warning. + /// Slim mirror of GalaxyDriver.ResolveApiKey — the runtime version lives + /// in a sibling project the Browser intentionally doesn't reference. + /// + /// The secret reference string to resolve. + private string ResolveApiKey(string secretRef) + { + ArgumentException.ThrowIfNullOrEmpty(secretRef); + + if (secretRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase)) + { + var name = secretRef[4..]; + var value = Environment.GetEnvironmentVariable(name); + return !string.IsNullOrEmpty(value) + ? value + : throw new InvalidOperationException( + $"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' resolves to env var '{name}', but it is unset."); + } + + if (secretRef.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) + { + var path = secretRef[5..]; + if (!File.Exists(path)) + { + throw new InvalidOperationException( + $"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' points at '{path}', which doesn't exist."); + } + var contents = File.ReadAllText(path).Trim(); + return !string.IsNullOrEmpty(contents) + ? contents + : throw new InvalidOperationException( + $"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' file '{path}' is empty."); + } + + if (secretRef.StartsWith("dev:", StringComparison.OrdinalIgnoreCase)) + { + // Explicit dev opt-in — no warning, the operator deliberately chose a + // cleartext literal (dev box, parity rig). + return secretRef[4..]; + } + + // Back-compat literal arm. An unprefixed string is treated as the literal + // API key — but emit a warning so an operator who accidentally committed a + // cleartext key into DriverConfig sees it when they open the address picker. + _logger.LogWarning( + "Galaxy.Gateway.ApiKeySecretRef is being treated as a literal cleartext API key. " + + "Prefer env:NAME, file:PATH, or the explicit dev:KEY prefix for dev rigs — " + + "a literal key in DriverConfig JSON is stored in cleartext in the central config DB."); + return secretRef; + } +}