diff --git a/scripts/validation/pair-gate/PairGate.csproj b/scripts/validation/pair-gate/PairGate.csproj new file mode 100644 index 000000000..3d1ec3d8d --- /dev/null +++ b/scripts/validation/pair-gate/PairGate.csproj @@ -0,0 +1,11 @@ + + + + Exe + false + + + + + + diff --git a/scripts/validation/pair-gate/Program.cs b/scripts/validation/pair-gate/Program.cs new file mode 100644 index 000000000..d4b843ff8 --- /dev/null +++ b/scripts/validation/pair-gate/Program.cs @@ -0,0 +1,199 @@ +// 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}"); +} diff --git a/scripts/validation/pair-gate/README.md b/scripts/validation/pair-gate/README.md new file mode 100644 index 000000000..4f649299d --- /dev/null +++ b/scripts/validation/pair-gate/README.md @@ -0,0 +1,35 @@ +# Pair-behaviour gate helper (lmxopcua#801) + +`PairGate` is a small console tool for the **local docker-dev rig only**. Never point it at a wonder box. +It drives the redundancy and revocation half of the #801 SDK 2 live gate against the central pair +(central-1 `opc.tcp://localhost:4840`, central-2 `:4841`): + +- **`mint` / `set-enabled`** call the same `IApiKeyAdminService` that the AdminUI `/apikeys` page uses + (Create key, Revoke / Re-enable). The rig's committed dev pepper is the default. Override it with + `PAIRGATE_PEPPER` and the ConfigDb with `PAIRGATE_CONFIGDB`. The credential goes to `/.key` + (mode 0600) and is never printed. Output names the key id only. +- **`grant` / `ungrant`** add or remove a `NodeAcl` row in the rig ConfigDb. +- **`probe`** opens a fresh session (a new activation), then reads and writes the probe tag + (`PAIRGATE_TAG`, default the writable `ns=2;s=pymodbus/plc/HR200`). +- **`watch`** holds **one** session open for N seconds. It subscribes to the given tags and reads and writes + every 3 s. Output is JSON lines, so a revocation or expiry can be seen landing on an + **already-open** session. +- **`server`** reads `Server.ServiceLevel` (`i=2267`) and `Server.ServerArray` (`i=2254`). + +DB edits only reach the nodes' data-plane snapshot through a deployment (`docker-dev/deploy.sh c1.log & +$g watch opc.tcp://localhost:4841 @keys/k-revoke.key 600 ns=2\;s=pymodbus/plc/HR200 ns=2\;s=pymodbus/plc/HR100 > c2.log & +$g set-enabled false # new activations refused within StoreCacheSeconds +./docker-dev/deploy.sh