// lmxopcua#801 pair-behaviour gate helper — LOCAL docker-dev rig only (never a wonder box). // // Mints/revokes API keys through the same IApiKeyAdminService the AdminUI /apikeys page calls, edits // NodeAcl grants in the rig ConfigDb, and holds real OPC UA sessions (Client.Shared, the CLI's own // client) open against one node so revocation/expiry can be observed on an ALREADY-OPEN session. // Credentials are written to a 0600 file and never printed; output names key ids only. using System.Diagnostics; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Opc.Ua; using ZB.MOM.WW.OtOpcUa.Client.Shared; using ZB.MOM.WW.OtOpcUa.Client.Shared.Models; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Core.Authorization; var configDb = Environment.GetEnvironmentVariable("PAIRGATE_CONFIGDB") ?? "Server=localhost,14330;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"; // The docker-dev rig's committed dev pepper (docker-dev/docker-compose.yml x-apikeys-env). var pepper = Environment.GetEnvironmentVariable("PAIRGATE_PEPPER") ?? "docker-dev-apikey-pepper-not-for-production"; var pkiRoot = Environment.GetEnvironmentVariable("PAIRGATE_PKI") ?? Path.Combine(Path.GetTempPath(), "otopcua-pairgate-pki"); if (args.Length == 0) { Console.Error.WriteLine(""" usage: mint [expiresUtcIso] mint via IApiKeyAdminService; writes /.key (0600) set-enabled IApiKeyAdminService.SetEnabledAsync (AdminUI Revoke / Re-enable) grant ungrant probe <@credFile|anon> [writeValue] one fresh session: activate, read + write the probe tag watch <@credFile|anon> ... ONE session held open; JSON lines: data changes + a read/write every 3 s server ServiceLevel (i=2267) and ServerArray (i=2254) """); return 2; } var dbOptions = new DbContextOptionsBuilder().UseSqlServer(configDb).Options; IDbContextFactory factory = new PooledDbContextFactory(dbOptions); switch (args[0]) { case "mint": { DateTime? expires = args.Length > 4 ? DateTime.Parse(args[4], null, System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal) : null; var presented = await new ApiKeyAdminService(factory, pepper).CreateAsync(args[2], args[3], expires, "lmxopcua#801 pair gate", default); Directory.CreateDirectory(args[1]); var path = Path.Combine(args[1], args[2] + ".key"); await File.WriteAllTextAsync(path, presented); if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); var keyId = presented.Split('_')[1]; Emit(new { op = "mint", name = args[2], principal = args[3], keyId, expiresUtc = expires, credFile = path }); return 0; } case "set-enabled": await new ApiKeyAdminService(factory, pepper).SetEnabledAsync(args[1], bool.Parse(args[2]), default); Emit(new { op = "set-enabled", keyId = args[1], enabled = bool.Parse(args[2]) }); return 0; case "grant": { await using var db = await factory.CreateDbContextAsync(); db.NodeAcls.Add(new NodeAcl { NodeAclRowId = Guid.NewGuid(), NodeAclId = args[1], Principal = args[2], ClusterId = args[3], ScopeKind = Enum.Parse(args[4]), ScopeId = args[5] == "-" ? null : args[5], PermissionFlags = (NodePermissions)int.Parse(args[6]), Notes = "lmxopcua#801 pair gate", }); await db.SaveChangesAsync(); Emit(new { op = "grant", nodeAclId = args[1], principal = args[2], clusterId = args[3], scopeKind = args[4], scopeId = args[5], flags = int.Parse(args[6]) }); return 0; } case "ungrant": { await using var db = await factory.CreateDbContextAsync(); var n = await db.NodeAcls.Where(a => a.NodeAclId == args[1]).ExecuteDeleteAsync(); Emit(new { op = "ungrant", nodeAclId = args[1], rows = n }); return 0; } case "probe": return await ProbeAsync(args[1], args[2], args.Length > 3 ? args[3] : null); case "watch": return await WatchAsync(args[1], args[2], int.Parse(args[3]), args[4], args[5..]); case "server": return await ServerAsync(args[1]); default: Console.Error.WriteLine($"unknown command {args[0]}"); return 2; } ConnectionSettings Settings(string url, string cred) { var s = new ConnectionSettings { EndpointUrl = url, SecurityMode = SecurityMode.None, SessionTimeoutSeconds = 60, AutoAcceptCertificates = true, CertificateStorePath = pkiRoot, }; if (cred != "anon") { s.Username = "apikey"; s.Password = File.ReadAllText(cred.TrimStart('@')).Trim(); } return s; } string Who(string cred) => cred == "anon" ? "anonymous" : Path.GetFileNameWithoutExtension(cred.TrimStart('@')); async Task ProbeAsync(string url, string cred, string? writeValue) { var client = new OpcUaClientServiceFactory().Create(); var probeTag = Environment.GetEnvironmentVariable("PAIRGATE_TAG") ?? "ns=2;s=pymodbus/plc/HR200"; try { await client.ConnectAsync(Settings(url, cred)); } catch (Exception ex) { Emit(new { op = "probe", url, who = Who(cred), activated = false, error = Short(ex) }); return 1; } var read = await client.ReadValueAsync(NodeId.Parse(probeTag)); string? write = null; if (writeValue is not null) write = (await client.WriteValueAsync(NodeId.Parse(probeTag), ushort.Parse(writeValue))).ToString(); Emit(new { op = "probe", url, who = Who(cred), activated = true, read = read.StatusCode.ToString(), value = read.WrappedValue.ToString(), write }); await client.DisconnectAsync(); return 0; } async Task WatchAsync(string url, string cred, int seconds, string writeTag, string[] subscribeTags) { var client = new OpcUaClientServiceFactory().Create(); var who = Who(cred); client.DataChanged += (_, e) => Emit(new { op = "data", url, who, node = e.NodeId, status = e.Value.StatusCode.ToString(), value = e.Value.WrappedValue.ToString() }); client.ConnectionStateChanged += (_, e) => Emit(new { op = "conn", url, who, from = e.OldState.ToString(), to = e.NewState.ToString(), endpoint = e.EndpointUrl }); try { await client.ConnectAsync(Settings(url, cred)); } catch (Exception ex) { Emit(new { op = "watch-start", url, who, activated = false, error = Short(ex) }); return 1; } Emit(new { op = "watch-start", url, who, activated = true }); foreach (var t in subscribeTags) await client.SubscribeAsync(NodeId.Parse(t), 500); var sw = Stopwatch.StartNew(); ushort v = 100; while (sw.Elapsed < TimeSpan.FromSeconds(seconds)) { try { var r = await client.ReadValueAsync(NodeId.Parse(writeTag)); var w = await client.WriteValueAsync(NodeId.Parse(writeTag), v++); Emit(new { op = "rw", url, who, read = r.StatusCode.ToString(), write = w.ToString(), connected = client.IsConnected }); } catch (Exception ex) { Emit(new { op = "rw", url, who, error = Short(ex), connected = client.IsConnected }); } await Task.Delay(3000); } await client.DisconnectAsync(); Emit(new { op = "watch-end", url, who }); return 0; } async Task ServerAsync(string url) { var client = new OpcUaClientServiceFactory().Create(); await client.ConnectAsync(Settings(url, "anon")); var sl = await client.ReadValueAsync(VariableIds.Server_ServiceLevel); var sa = await client.ReadValueAsync(VariableIds.Server_ServerArray); var arr = sa.WrappedValue.ToString(); Emit(new { op = "server", url, serviceLevel = sl.WrappedValue.ToString(), slSource = sl.SourceTimestamp.ToString(), serverArray = arr }); await client.DisconnectAsync(); return 0; } static string Short(Exception ex) => ex.GetBaseException().Message.Split('\n')[0]; static void Emit(object o) { var line = JsonSerializer.Serialize(o); lock (typeof(Console)) Console.WriteLine($"{DateTime.UtcNow:HH:mm:ss.fff} {line}"); }