test(validation): pair-gate helper for the #801 revocation/expiry/ACL checks on docker-dev
v2-ci / unit-tests (push) Has been cancelled
v2-ci / unit-tests (push) Has been cancelled
Console helper that mints and revokes OPC UA API keys through the same IApiKeyAdminService the AdminUI /apikeys page calls, edits NodeAcl grants in the rig ConfigDb, and holds one OPC UA session open per node (Client.Shared) with a subscription plus a read/write every 3 s, so a revocation, expiry or grant removal can be observed landing on an ALREADY-OPEN session on both pair nodes next to a non-revoked control key. Credentials go to a 0600 file and are never printed. LOCAL docker-dev only. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- lmxopcua#801 pair-behaviour gate helper for the LOCAL docker-dev rig only. See README.md. -->
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../src/Core/ZB.MOM.WW.OtOpcUa.Core/ZB.MOM.WW.OtOpcUa.Core.csproj" />
|
||||
<ProjectReference Include="../../../src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -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 <credDir> <name> <principal> [expiresUtcIso] mint via IApiKeyAdminService; writes <credDir>/<name>.key (0600)
|
||||
set-enabled <keyId> <true|false> IApiKeyAdminService.SetEnabledAsync (AdminUI Revoke / Re-enable)
|
||||
grant <nodeAclId> <principal> <clusterId> <scopeKind> <scopeId|-> <permissionFlags>
|
||||
ungrant <nodeAclId>
|
||||
probe <url> <@credFile|anon> [writeValue] one fresh session: activate, read + write the probe tag
|
||||
watch <url> <@credFile|anon> <seconds> <writeTag> <subscribeTag>...
|
||||
ONE session held open; JSON lines: data changes + a read/write every 3 s
|
||||
server <url> ServiceLevel (i=2267) and ServerArray (i=2254)
|
||||
""");
|
||||
return 2;
|
||||
}
|
||||
|
||||
var dbOptions = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseSqlServer(configDb).Options;
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> factory = new PooledDbContextFactory<OtOpcUaConfigDbContext>(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<NodeAclScopeKind>(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<int> 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<int> 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<int> 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}");
|
||||
}
|
||||
@@ -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 `<credDir>/<name>.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 </dev/null`).
|
||||
That's the documented revocation contract in `docs/security.md`.
|
||||
|
||||
```sh
|
||||
dotnet build scripts/validation/pair-gate -c Release -o /tmp/pairgate
|
||||
g="dotnet /tmp/pairgate/PairGate.dll"
|
||||
$g mint ./keys k-revoke p-revoke # plus a control key, and one with an expiry: ... p-exp 2026-11-12T15:00:00Z
|
||||
$g grant p-revoke-main p-revoke MAIN Cluster - 159 # ReadOnly + WriteOperate
|
||||
./docker-dev/deploy.sh </dev/null
|
||||
$g watch opc.tcp://localhost:4840 @keys/k-revoke.key 600 ns=2\;s=pymodbus/plc/HR200 ns=2\;s=pymodbus/plc/HR100 > 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 <keyId> false # new activations refused within StoreCacheSeconds
|
||||
./docker-dev/deploy.sh </dev/null # the open sessions on BOTH nodes lose write (ACL on: everything)
|
||||
```
|
||||
|
||||
Always run a non-revoked control key next to the revoked one, so a denial can't be mistaken for an outage.
|
||||
The 2026-11-12 run and its results are on #801.
|
||||
Reference in New Issue
Block a user