Compare commits
12 Commits
phase-3-pr
...
phase-3-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5fa1f450e | ||
| 6fdaee3a71 | |||
|
|
ed88835d34 | ||
| 5389d4d22d | |||
|
|
b5f8661e98 | ||
| 4058b88784 | |||
|
|
6b04a85f86 | ||
| cd8691280a | |||
|
|
77d09bf64e | ||
| 163c821e74 | |||
|
|
eea31dcc4e | ||
| 8a692d4ba8 |
@@ -27,33 +27,36 @@ only exposes `ReadRawAsync` + `ReadProcessedAsync`.
|
|||||||
- Integration test: OPC UA client calls `HistoryReadAtTime` / `HistoryReadEvents`,
|
- Integration test: OPC UA client calls `HistoryReadAtTime` / `HistoryReadEvents`,
|
||||||
value flows through IPC to the Host's `HistorianDataSource`, back to the client.
|
value flows through IPC to the Host's `HistorianDataSource`, back to the client.
|
||||||
|
|
||||||
## 2. Write-gating by role
|
## 2. Write-gating by role — **DONE (PR 26)**
|
||||||
|
|
||||||
**Status**: `RoleBasedIdentity.Roles` populated on the session (PR 19) but
|
Landed in PR 26. `WriteAuthzPolicy` in `Server/Security/` maps
|
||||||
`DriverNodeManager.OnWriteValue` doesn't consult it.
|
`SecurityClassification` → required role (`FreeAccess` → no role required,
|
||||||
|
`Operate`/`SecuredWrite` → `WriteOperate`, `Tune` → `WriteTune`,
|
||||||
|
`Configure`/`VerifiedWrite` → `WriteConfigure`, `ViewOnly` → deny regardless).
|
||||||
|
`DriverNodeManager` caches the classification per variable during discovery and
|
||||||
|
checks the session's roles (via `IRoleBearer`) in `OnWriteValue` before calling
|
||||||
|
`IWritable.WriteAsync`. Roles do not cascade — a session with `WriteOperate`
|
||||||
|
can't write a `Tune` attribute unless it also carries `WriteTune`.
|
||||||
|
|
||||||
CLAUDE.md defines the role set: `ReadOnly` / `WriteOperate` / `WriteTune` /
|
See `feedback_acl_at_server_layer.md` in memory for the architectural directive
|
||||||
`WriteConfigure` / `AlarmAck`. Each `DriverAttributeInfo.SecurityClassification`
|
that authz stays at the server layer and never delegates to driver-specific auth.
|
||||||
maps to a required role for writes.
|
|
||||||
|
|
||||||
**To do**:
|
## 3. Admin UI client-cert trust management — **DONE (PR 28)**
|
||||||
- Add a `RoleRequirements` table: `SecurityClassification` → required role.
|
|
||||||
- `OnWriteValue` reads `context.UserIdentity` → cast to `RoleBasedIdentity`
|
|
||||||
→ check role membership before calling `IWritable.WriteAsync`. Return
|
|
||||||
`BadUserAccessDenied` on miss.
|
|
||||||
- Unit test against a fake `ISystemContext` with varying role sets.
|
|
||||||
|
|
||||||
## 3. Admin UI client-cert trust management
|
PR 28 shipped `/certificates` in the Admin UI. `CertTrustService` reads the OPC
|
||||||
|
UA server's PKI store root (`OpcUaServerOptions.PkiStoreRoot` — default
|
||||||
|
`%ProgramData%\OtOpcUa\pki`) and lists rejected + trusted certs by parsing the
|
||||||
|
`.der` files directly, so it has no `Opc.Ua` dependency and runs on any
|
||||||
|
Admin host that can reach the shared PKI directory.
|
||||||
|
|
||||||
**Status**: Server side auto-accepts untrusted client certs when the
|
Operator actions: Trust (moves `rejected/certs/*.der` → `trusted/certs/*.der`),
|
||||||
`AutoAcceptUntrustedClientCertificates` option is true (dev default).
|
Delete rejected, Revoke trust. The OPC UA stack re-reads the trusted store on
|
||||||
Production deployments want operator-controlled trust via the Admin UI.
|
each new client handshake, so no explicit reload signal is needed —
|
||||||
|
operators retry the rejected client's connection after trusting.
|
||||||
|
|
||||||
**To do**:
|
Deferred: flipping `AutoAcceptUntrustedClientCertificates` to `false` as the
|
||||||
- Surface the server's rejected-certificate store in the Admin UI.
|
deployment default. That's a production-hardening config change, not a code
|
||||||
- Page to move certs between `rejected` / `trusted`.
|
gap — the Admin UI is now ready to be the trust gate.
|
||||||
- Flip `AutoAcceptUntrustedClientCertificates` to false once Admin UI is the
|
|
||||||
trust gate.
|
|
||||||
|
|
||||||
## 4. Live-LDAP integration test
|
## 4. Live-LDAP integration test
|
||||||
|
|
||||||
|
|||||||
103
docs/v2/modbus-test-plan.md
Normal file
103
docs/v2/modbus-test-plan.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# Modbus driver — test plan + device-quirk catalog
|
||||||
|
|
||||||
|
The Modbus TCP driver unit tests (PRs 21–24) cover the protocol surface against an
|
||||||
|
in-memory fake transport. They validate the codec, state machine, and function-code
|
||||||
|
routing against a textbook Modbus server. That's necessary but not sufficient: real PLC
|
||||||
|
populations disagree with the spec in small, device-specific ways, and a driver that
|
||||||
|
passes textbook tests can still misbehave against actual equipment.
|
||||||
|
|
||||||
|
This doc is the harness-and-quirks playbook. It's what gets wired up in the
|
||||||
|
`tests/Driver.Modbus.IntegrationTests` project when we ship that (PR 26 candidate).
|
||||||
|
|
||||||
|
## Harness
|
||||||
|
|
||||||
|
**Chosen simulator: ModbusPal** (Java, scriptable). Rationale:
|
||||||
|
- Scriptable enough to mimic device-specific behaviors (non-standard register
|
||||||
|
layouts, custom exception codes, intentional response delays).
|
||||||
|
- Runs locally, no CI dependency. Tests skip when `localhost:502` (or the configured
|
||||||
|
simulator endpoint) isn't reachable.
|
||||||
|
- Free + long-maintained — physical PLC bench is unavailable in most dev
|
||||||
|
environments, and renting cloud PLCs isn't worth the per-test cost.
|
||||||
|
|
||||||
|
**Setup pattern** (not yet codified in a script — will land alongside the integration
|
||||||
|
test project):
|
||||||
|
1. Install ModbusPal, load the per-device `.xmpp` profile from
|
||||||
|
`tests/Driver.Modbus.IntegrationTests/ModbusPal/` (TBD directory).
|
||||||
|
2. Start the simulator listening on `localhost:502` (or override via
|
||||||
|
`MODBUS_SIM_ENDPOINT` env var).
|
||||||
|
3. `dotnet test` the integration project — tests auto-skip when the endpoint is
|
||||||
|
unreachable, so forgetting to start the simulator doesn't wedge CI.
|
||||||
|
|
||||||
|
## Per-device quirk catalog
|
||||||
|
|
||||||
|
### AutomationDirect DL205
|
||||||
|
|
||||||
|
First known target device. Quirks to document and cover with named tests (to be
|
||||||
|
filled in when user validates each behavior in ModbusPal with a DL205 profile):
|
||||||
|
|
||||||
|
- **Word order for 32-bit values**: _pending_ — confirm whether DL205 uses ABCD
|
||||||
|
(Modbus TCP standard) or CDAB (Siemens-style word-swap) for Int32/UInt32/Float32.
|
||||||
|
Test name: `DL205_Float32_word_order_is_CDAB` (or `ABCD`, whichever proves out).
|
||||||
|
- **Register-zero access**: _pending_ — some DL205 configurations reject FC03 at
|
||||||
|
register 0 with exception code 02 (illegal data address). If confirmed, the
|
||||||
|
integration test suite verifies `ModbusProbeOptions.ProbeAddress` default of 0
|
||||||
|
triggers the rejection and operators must override; test name:
|
||||||
|
`DL205_FC03_at_register_0_returns_IllegalDataAddress`.
|
||||||
|
- **Coil addressing base**: _pending_ — DL205 documentation sometimes uses 1-based
|
||||||
|
coil addresses; verify the driver's zero-based addressing matches the physical
|
||||||
|
PLC without an off-by-one adjustment.
|
||||||
|
- **Maximum registers per FC03**: _pending_ — Modbus spec caps at 125; some DL205
|
||||||
|
models enforce a lower limit (e.g., 64). Test name:
|
||||||
|
`DL205_FC03_beyond_max_registers_returns_IllegalDataValue`.
|
||||||
|
- **Response framing under sustained load**: _pending_ — the driver's
|
||||||
|
single-flight semaphore assumes the server pairs requests/responses by
|
||||||
|
transaction id; at least one DL205 firmware revision is reported to drop the
|
||||||
|
TxId under load. If reproduced in ModbusPal we add a retry + log-and-continue
|
||||||
|
path to `ModbusTcpTransport`.
|
||||||
|
- **Exception code on coil write to a protected bit**: _pending_ — some DL205
|
||||||
|
setups protect internal coils; the driver should surface the PLC's exception
|
||||||
|
PDU as `BadNotWritable` rather than `BadInternalError`.
|
||||||
|
|
||||||
|
_User action item_: as each quirk is validated in ModbusPal, replace the _pending_
|
||||||
|
marker with the confirmed behavior and file a named test in the integration suite.
|
||||||
|
|
||||||
|
### Future devices
|
||||||
|
|
||||||
|
One section per device class, same shape as DL205. Quirks that apply across
|
||||||
|
multiple devices (e.g., "all AB PLCs use CDAB") can be noted in the cross-device
|
||||||
|
patterns section below once we have enough data points.
|
||||||
|
|
||||||
|
## Cross-device patterns
|
||||||
|
|
||||||
|
Once multiple device catalogs accumulate, quirks that recur across two or more
|
||||||
|
vendors get promoted into driver defaults or opt-in options:
|
||||||
|
|
||||||
|
- _(empty — filled in as catalogs grow)_
|
||||||
|
|
||||||
|
## Test conventions
|
||||||
|
|
||||||
|
- **One named test per quirk.** `DL205_word_order_is_CDAB_for_Float32` is easier to
|
||||||
|
diagnose on failure than a generic `Float32_roundtrip`. The `DL205_` prefix makes
|
||||||
|
filtering by device class trivial (`--filter "DisplayName~DL205"`).
|
||||||
|
- **Skip with a clear SkipReason.** Follow the pattern from
|
||||||
|
`GalaxyRepositoryLiveSmokeTests`: check reachability in the fixture, capture
|
||||||
|
a `SkipReason` string, and have each test call `Assert.Skip(SkipReason)` when
|
||||||
|
it's set. Don't throw — skipped tests read cleanly in CI logs.
|
||||||
|
- **Use the real `ModbusTcpTransport`.** Integration tests exercise the wire
|
||||||
|
protocol end-to-end. The in-memory `FakeTransport` from the unit test suite is
|
||||||
|
deliberately not used here — its value is speed + determinism, which doesn't
|
||||||
|
help reproduce device-specific issues.
|
||||||
|
- **Don't depend on ModbusPal state between tests.** Each test resets the
|
||||||
|
simulator's register bank or uses a unique address range. Avoid relying on
|
||||||
|
"previous test left value at register 10" setups that flake when tests run in
|
||||||
|
parallel or re-order.
|
||||||
|
|
||||||
|
## Next concrete PRs
|
||||||
|
|
||||||
|
- **PR 26 — Integration test project + DL205 profile scaffold**: creates
|
||||||
|
`tests/Driver.Modbus.IntegrationTests`, imports the ModbusPal profile (or
|
||||||
|
generates it from JSON), adds the fixture with skip-when-unreachable, plus
|
||||||
|
one smoke test that reads a register. No DL205-specific assertions yet — that
|
||||||
|
waits for the user to validate each quirk.
|
||||||
|
- **PR 27+**: one PR per confirmed DL205 quirk, landing the named test + any
|
||||||
|
driver-side adjustment (e.g., retry on dropped TxId) needed to pass it.
|
||||||
@@ -5,15 +5,17 @@
|
|||||||
<h5 class="mb-4">OtOpcUa Admin</h5>
|
<h5 class="mb-4">OtOpcUa Admin</h5>
|
||||||
<ul class="nav flex-column">
|
<ul class="nav flex-column">
|
||||||
<li class="nav-item"><a class="nav-link text-light" href="/">Overview</a></li>
|
<li class="nav-item"><a class="nav-link text-light" href="/">Overview</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link text-light" href="/fleet">Fleet status</a></li>
|
||||||
<li class="nav-item"><a class="nav-link text-light" href="/clusters">Clusters</a></li>
|
<li class="nav-item"><a class="nav-link text-light" href="/clusters">Clusters</a></li>
|
||||||
<li class="nav-item"><a class="nav-link text-light" href="/reservations">Reservations</a></li>
|
<li class="nav-item"><a class="nav-link text-light" href="/reservations">Reservations</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link text-light" href="/certificates">Certificates</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="mt-5">
|
<div class="mt-5">
|
||||||
<AuthorizeView>
|
<AuthorizeView>
|
||||||
<Authorized>
|
<Authorized>
|
||||||
<div class="small text-light">
|
<div class="small text-light">
|
||||||
Signed in as <strong>@context.User.Identity?.Name</strong>
|
Signed in as <a class="text-light" href="/account"><strong>@context.User.Identity?.Name</strong></a>
|
||||||
</div>
|
</div>
|
||||||
<div class="small text-muted">
|
<div class="small text-muted">
|
||||||
@string.Join(", ", context.User.Claims.Where(c => c.Type.EndsWith("/role")).Select(c => c.Value))
|
@string.Join(", ", context.User.Claims.Where(c => c.Type.EndsWith("/role")).Select(c => c.Value))
|
||||||
|
|||||||
129
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Account.razor
Normal file
129
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Account.razor
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
@page "/account"
|
||||||
|
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||||
|
@using System.Security.Claims
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||||
|
|
||||||
|
<h1 class="mb-4">My account</h1>
|
||||||
|
|
||||||
|
<AuthorizeView>
|
||||||
|
<Authorized>
|
||||||
|
@{
|
||||||
|
var username = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "—";
|
||||||
|
var displayName = context.User.Identity?.Name ?? "—";
|
||||||
|
var roles = context.User.Claims
|
||||||
|
.Where(c => c.Type == ClaimTypes.Role).Select(c => c.Value).ToList();
|
||||||
|
var ldapGroups = context.User.Claims
|
||||||
|
.Where(c => c.Type == "ldap_group").Select(c => c.Value).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Identity</h5>
|
||||||
|
<dl class="row mb-0">
|
||||||
|
<dt class="col-sm-4">Username</dt><dd class="col-sm-8"><code>@username</code></dd>
|
||||||
|
<dt class="col-sm-4">Display name</dt><dd class="col-sm-8">@displayName</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Admin roles</h5>
|
||||||
|
@if (roles.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-muted mb-0">No Admin roles mapped — sign-in would have been blocked, so if you're seeing this, the session claim is likely stale.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="mb-2">
|
||||||
|
@foreach (var r in roles)
|
||||||
|
{
|
||||||
|
<span class="badge bg-primary me-1">@r</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">LDAP groups: @(ldapGroups.Count == 0 ? "(none surfaced)" : string.Join(", ", ldapGroups))</small>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Capabilities</h5>
|
||||||
|
<p class="text-muted small">
|
||||||
|
Each Admin role grants a fixed capability set per <code>admin-ui.md</code> §Admin Roles.
|
||||||
|
Pages below reflect what this session can access; the route's <code>[Authorize]</code> guard
|
||||||
|
is the ground truth — this table mirrors it for readability.
|
||||||
|
</p>
|
||||||
|
<table class="table table-sm align-middle mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Capability</th>
|
||||||
|
<th>Required role(s)</th>
|
||||||
|
<th class="text-end">You have it?</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var cap in Capabilities)
|
||||||
|
{
|
||||||
|
var has = cap.RequiredRoles.Any(r => roles.Contains(r, StringComparer.OrdinalIgnoreCase));
|
||||||
|
<tr>
|
||||||
|
<td>@cap.Name<br /><small class="text-muted">@cap.Description</small></td>
|
||||||
|
<td>@string.Join(" or ", cap.RequiredRoles)</td>
|
||||||
|
<td class="text-end">
|
||||||
|
@if (has)
|
||||||
|
{
|
||||||
|
<span class="badge bg-success">Yes</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="badge bg-secondary">No</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<form method="post" action="/auth/logout">
|
||||||
|
<button class="btn btn-outline-danger" type="submit">Sign out</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Authorized>
|
||||||
|
</AuthorizeView>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private sealed record Capability(string Name, string Description, string[] RequiredRoles);
|
||||||
|
|
||||||
|
// Kept in sync with Program.cs authorization policies + each page's [Authorize] attribute.
|
||||||
|
// When a new page or policy is added, extend this list so operators can self-service check
|
||||||
|
// whether their session has access without trial-and-error navigation.
|
||||||
|
private static readonly IReadOnlyList<Capability> Capabilities =
|
||||||
|
[
|
||||||
|
new("View clusters + fleet status",
|
||||||
|
"Read-only access to the cluster list, fleet dashboard, and generation history.",
|
||||||
|
[AdminRoles.ConfigViewer, AdminRoles.ConfigEditor, AdminRoles.FleetAdmin]),
|
||||||
|
new("Edit configuration drafts",
|
||||||
|
"Create and edit draft generations, manage namespace bindings and node ACLs. CanEdit policy.",
|
||||||
|
[AdminRoles.ConfigEditor, AdminRoles.FleetAdmin]),
|
||||||
|
new("Publish generations",
|
||||||
|
"Promote a draft to Published — triggers node roll-out. CanPublish policy.",
|
||||||
|
[AdminRoles.FleetAdmin]),
|
||||||
|
new("Manage certificate trust",
|
||||||
|
"Trust rejected client certs + revoke trust. FleetAdmin-only because the trust decision gates OPC UA client access.",
|
||||||
|
[AdminRoles.FleetAdmin]),
|
||||||
|
new("Manage external-ID reservations",
|
||||||
|
"Reserve / release external IDs that map into Galaxy contained names.",
|
||||||
|
[AdminRoles.ConfigEditor, AdminRoles.FleetAdmin]),
|
||||||
|
];
|
||||||
|
}
|
||||||
154
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Certificates.razor
Normal file
154
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Certificates.razor
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
@page "/certificates"
|
||||||
|
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Roles = AdminRoles.FleetAdmin)]
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||||
|
@inject CertTrustService Certs
|
||||||
|
@inject AuthenticationStateProvider AuthState
|
||||||
|
@inject ILogger<Certificates> Log
|
||||||
|
|
||||||
|
<h1 class="mb-4">Certificate trust</h1>
|
||||||
|
|
||||||
|
<div class="alert alert-info small mb-4">
|
||||||
|
PKI store root <code>@Certs.PkiStoreRoot</code>. Trusting a rejected cert moves the file into the trusted store — the OPC UA server picks up the change on the next client handshake, so operators should retry the rejected client's connection after trusting.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_status is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-@_statusKind alert-dismissible">
|
||||||
|
@_status
|
||||||
|
<button type="button" class="btn-close" @onclick="ClearStatus"></button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<h2 class="h4">Rejected (@_rejected.Count)</h2>
|
||||||
|
@if (_rejected.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-muted">No rejected certificates. Clients that fail to handshake with an untrusted cert land here.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<table class="table table-sm align-middle">
|
||||||
|
<thead><tr><th>Subject</th><th>Issuer</th><th>Thumbprint</th><th>Valid</th><th class="text-end">Actions</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var c in _rejected)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@c.Subject</td>
|
||||||
|
<td>@c.Issuer</td>
|
||||||
|
<td><code class="small">@c.Thumbprint</code></td>
|
||||||
|
<td class="small">@c.NotBefore.ToString("yyyy-MM-dd") → @c.NotAfter.ToString("yyyy-MM-dd")</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<button class="btn btn-sm btn-success me-1" @onclick="() => TrustAsync(c)">Trust</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteRejectedAsync(c)">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
|
||||||
|
<h2 class="h4 mt-5">Trusted (@_trusted.Count)</h2>
|
||||||
|
@if (_trusted.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-muted">No client certs have been explicitly trusted. The server's own application cert lives in <code>own/</code> and is not listed here.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<table class="table table-sm align-middle">
|
||||||
|
<thead><tr><th>Subject</th><th>Issuer</th><th>Thumbprint</th><th>Valid</th><th class="text-end">Actions</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var c in _trusted)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@c.Subject</td>
|
||||||
|
<td>@c.Issuer</td>
|
||||||
|
<td><code class="small">@c.Thumbprint</code></td>
|
||||||
|
<td class="small">@c.NotBefore.ToString("yyyy-MM-dd") → @c.NotAfter.ToString("yyyy-MM-dd")</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<button class="btn btn-sm btn-outline-danger" @onclick="() => UntrustAsync(c)">Revoke</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private IReadOnlyList<CertInfo> _rejected = [];
|
||||||
|
private IReadOnlyList<CertInfo> _trusted = [];
|
||||||
|
private string? _status;
|
||||||
|
private string _statusKind = "success";
|
||||||
|
|
||||||
|
protected override void OnInitialized() => Reload();
|
||||||
|
|
||||||
|
private void Reload()
|
||||||
|
{
|
||||||
|
_rejected = Certs.ListRejected();
|
||||||
|
_trusted = Certs.ListTrusted();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TrustAsync(CertInfo c)
|
||||||
|
{
|
||||||
|
if (Certs.TrustRejected(c.Thumbprint))
|
||||||
|
{
|
||||||
|
await LogActionAsync("cert.trust", c);
|
||||||
|
Set($"Trusted cert {c.Subject} ({Short(c.Thumbprint)}).", "success");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Set($"Could not trust {Short(c.Thumbprint)} — file missing; another admin may have already handled it.", "warning");
|
||||||
|
}
|
||||||
|
Reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteRejectedAsync(CertInfo c)
|
||||||
|
{
|
||||||
|
if (Certs.DeleteRejected(c.Thumbprint))
|
||||||
|
{
|
||||||
|
await LogActionAsync("cert.delete.rejected", c);
|
||||||
|
Set($"Deleted rejected cert {c.Subject} ({Short(c.Thumbprint)}).", "success");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Set($"Could not delete {Short(c.Thumbprint)} — file missing.", "warning");
|
||||||
|
}
|
||||||
|
Reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UntrustAsync(CertInfo c)
|
||||||
|
{
|
||||||
|
if (Certs.UntrustCert(c.Thumbprint))
|
||||||
|
{
|
||||||
|
await LogActionAsync("cert.untrust", c);
|
||||||
|
Set($"Revoked trust for {c.Subject} ({Short(c.Thumbprint)}).", "success");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Set($"Could not revoke {Short(c.Thumbprint)} — file missing.", "warning");
|
||||||
|
}
|
||||||
|
Reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LogActionAsync(string action, CertInfo c)
|
||||||
|
{
|
||||||
|
// Cert trust changes are operator-initiated and security-sensitive — Serilog captures the
|
||||||
|
// user + thumbprint trail. CertTrustService also logs at Information on each filesystem
|
||||||
|
// move/delete; this line ties the action to the authenticated admin user so the two logs
|
||||||
|
// correlate. DB-level ConfigAuditLog persistence is deferred — its schema is
|
||||||
|
// cluster-scoped and cert actions are cluster-agnostic.
|
||||||
|
var state = await AuthState.GetAuthenticationStateAsync();
|
||||||
|
var user = state.User.Identity?.Name ?? "(anonymous)";
|
||||||
|
Log.LogInformation("Admin cert action: user={User} action={Action} thumbprint={Thumbprint} subject={Subject}",
|
||||||
|
user, action, c.Thumbprint, c.Subject);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Set(string message, string kind)
|
||||||
|
{
|
||||||
|
_status = message;
|
||||||
|
_statusKind = kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearStatus() => _status = null;
|
||||||
|
|
||||||
|
private static string Short(string thumbprint) =>
|
||||||
|
thumbprint.Length > 12 ? thumbprint[..12] + "…" : thumbprint;
|
||||||
|
}
|
||||||
172
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Fleet.razor
Normal file
172
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Fleet.razor
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
@page "/fleet"
|
||||||
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||||
|
@inject IServiceScopeFactory ScopeFactory
|
||||||
|
@implements IDisposable
|
||||||
|
|
||||||
|
<h1 class="mb-4">Fleet status</h1>
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center mb-3 gap-2">
|
||||||
|
<button class="btn btn-sm btn-outline-primary" @onclick="RefreshAsync" disabled="@_refreshing">
|
||||||
|
@if (_refreshing) { <span class="spinner-border spinner-border-sm me-1" /> }
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
<span class="text-muted small">
|
||||||
|
Auto-refresh every @RefreshIntervalSeconds s. Last updated: @(_lastRefreshUtc?.ToString("HH:mm:ss 'UTC'") ?? "—")
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_rows is null)
|
||||||
|
{
|
||||||
|
<p>Loading…</p>
|
||||||
|
}
|
||||||
|
else if (_rows.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="alert alert-info">
|
||||||
|
No node state recorded yet. Nodes publish their state to the central DB on each poll; if
|
||||||
|
this list is empty, either no nodes have been registered or the poller hasn't run yet.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card"><div class="card-body">
|
||||||
|
<h6 class="text-muted mb-1">Nodes</h6>
|
||||||
|
<div class="fs-3">@_rows.Count</div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card border-success"><div class="card-body">
|
||||||
|
<h6 class="text-muted mb-1">Applied</h6>
|
||||||
|
<div class="fs-3 text-success">@_rows.Count(r => r.Status == "Applied")</div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card border-warning"><div class="card-body">
|
||||||
|
<h6 class="text-muted mb-1">Stale</h6>
|
||||||
|
<div class="fs-3 text-warning">@_rows.Count(r => IsStale(r))</div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card border-danger"><div class="card-body">
|
||||||
|
<h6 class="text-muted mb-1">Failed</h6>
|
||||||
|
<div class="fs-3 text-danger">@_rows.Count(r => r.Status == "Failed")</div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="table table-hover align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Node</th>
|
||||||
|
<th>Cluster</th>
|
||||||
|
<th>Generation</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Last applied</th>
|
||||||
|
<th>Last seen</th>
|
||||||
|
<th>Error</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var r in _rows)
|
||||||
|
{
|
||||||
|
<tr class="@RowClass(r)">
|
||||||
|
<td><code>@r.NodeId</code></td>
|
||||||
|
<td>@r.ClusterId</td>
|
||||||
|
<td>@(r.GenerationId?.ToString() ?? "—")</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge @StatusBadge(r.Status)">@(r.Status ?? "—")</span>
|
||||||
|
</td>
|
||||||
|
<td>@FormatAge(r.AppliedAt)</td>
|
||||||
|
<td class="@(IsStale(r) ? "text-warning" : "")">@FormatAge(r.SeenAt)</td>
|
||||||
|
<td class="text-truncate" style="max-width: 320px;" title="@r.Error">@r.Error</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
// Refresh cadence. 5s matches FleetStatusPoller's poll interval — the dashboard always sees
|
||||||
|
// the most recent published state without polling ahead of the broadcaster.
|
||||||
|
private const int RefreshIntervalSeconds = 5;
|
||||||
|
|
||||||
|
private List<FleetNodeRow>? _rows;
|
||||||
|
private bool _refreshing;
|
||||||
|
private DateTime? _lastRefreshUtc;
|
||||||
|
private Timer? _timer;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await RefreshAsync();
|
||||||
|
_timer = new Timer(async _ => await InvokeAsync(RefreshAsync),
|
||||||
|
state: null,
|
||||||
|
dueTime: TimeSpan.FromSeconds(RefreshIntervalSeconds),
|
||||||
|
period: TimeSpan.FromSeconds(RefreshIntervalSeconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RefreshAsync()
|
||||||
|
{
|
||||||
|
if (_refreshing) return;
|
||||||
|
_refreshing = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = ScopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||||
|
var rows = await db.ClusterNodeGenerationStates.AsNoTracking()
|
||||||
|
.Join(db.ClusterNodes.AsNoTracking(), s => s.NodeId, n => n.NodeId, (s, n) => new FleetNodeRow(
|
||||||
|
s.NodeId, n.ClusterId, s.CurrentGenerationId,
|
||||||
|
s.LastAppliedStatus != null ? s.LastAppliedStatus.ToString() : null,
|
||||||
|
s.LastAppliedError, s.LastAppliedAt, s.LastSeenAt))
|
||||||
|
.OrderBy(r => r.ClusterId)
|
||||||
|
.ThenBy(r => r.NodeId)
|
||||||
|
.ToListAsync();
|
||||||
|
_rows = rows;
|
||||||
|
_lastRefreshUtc = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_refreshing = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsStale(FleetNodeRow r)
|
||||||
|
{
|
||||||
|
if (r.SeenAt is null) return true;
|
||||||
|
return (DateTime.UtcNow - r.SeenAt.Value) > TimeSpan.FromSeconds(30);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RowClass(FleetNodeRow r) => r.Status switch
|
||||||
|
{
|
||||||
|
"Failed" => "table-danger",
|
||||||
|
_ when IsStale(r) => "table-warning",
|
||||||
|
_ => "",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string StatusBadge(string? status) => status switch
|
||||||
|
{
|
||||||
|
"Applied" => "bg-success",
|
||||||
|
"Failed" => "bg-danger",
|
||||||
|
"Applying" => "bg-info",
|
||||||
|
_ => "bg-secondary",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string FormatAge(DateTime? t)
|
||||||
|
{
|
||||||
|
if (t is null) return "—";
|
||||||
|
var age = DateTime.UtcNow - t.Value;
|
||||||
|
if (age.TotalSeconds < 60) return $"{(int)age.TotalSeconds}s ago";
|
||||||
|
if (age.TotalMinutes < 60) return $"{(int)age.TotalMinutes}m ago";
|
||||||
|
if (age.TotalHours < 24) return $"{(int)age.TotalHours}h ago";
|
||||||
|
return t.Value.ToString("yyyy-MM-dd HH:mm 'UTC'");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _timer?.Dispose();
|
||||||
|
|
||||||
|
internal sealed record FleetNodeRow(
|
||||||
|
string NodeId, string ClusterId, long? GenerationId,
|
||||||
|
string? Status, string? Error, DateTime? AppliedAt, DateTime? SeenAt);
|
||||||
|
}
|
||||||
@@ -48,6 +48,12 @@ builder.Services.AddScoped<ReservationService>();
|
|||||||
builder.Services.AddScoped<DraftValidationService>();
|
builder.Services.AddScoped<DraftValidationService>();
|
||||||
builder.Services.AddScoped<AuditLogService>();
|
builder.Services.AddScoped<AuditLogService>();
|
||||||
|
|
||||||
|
// Cert-trust management — reads the OPC UA server's PKI store root so rejected client certs
|
||||||
|
// can be promoted to trusted via the Admin UI. Singleton: no per-request state, just
|
||||||
|
// filesystem operations.
|
||||||
|
builder.Services.Configure<CertTrustOptions>(builder.Configuration.GetSection(CertTrustOptions.SectionName));
|
||||||
|
builder.Services.AddSingleton<CertTrustService>();
|
||||||
|
|
||||||
// LDAP auth — parity with ScadaLink's LdapAuthService (decision #102).
|
// LDAP auth — parity with ScadaLink's LdapAuthService (decision #102).
|
||||||
builder.Services.Configure<LdapOptions>(
|
builder.Services.Configure<LdapOptions>(
|
||||||
builder.Configuration.GetSection("Authentication:Ldap"));
|
builder.Configuration.GetSection("Authentication:Ldap"));
|
||||||
|
|||||||
22
src/ZB.MOM.WW.OtOpcUa.Admin/Services/CertTrustOptions.cs
Normal file
22
src/ZB.MOM.WW.OtOpcUa.Admin/Services/CertTrustOptions.cs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Points the Admin UI at the OPC UA Server's PKI store root so
|
||||||
|
/// <see cref="CertTrustService"/> can list and move certs between the
|
||||||
|
/// <c>rejected/</c> and <c>trusted/</c> directories the server maintains. Must match the
|
||||||
|
/// <c>OpcUaServer:PkiStoreRoot</c> the Server process is configured with.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CertTrustOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "CertTrust";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Absolute path to the PKI root. Defaults to
|
||||||
|
/// <c>%ProgramData%\OtOpcUa\pki</c> — matches <c>OpcUaServerOptions.PkiStoreRoot</c>'s
|
||||||
|
/// default so a standard side-by-side install needs no override.
|
||||||
|
/// </summary>
|
||||||
|
public string PkiStoreRoot { get; init; } =
|
||||||
|
Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||||
|
"OtOpcUa", "pki");
|
||||||
|
}
|
||||||
135
src/ZB.MOM.WW.OtOpcUa.Admin/Services/CertTrustService.cs
Normal file
135
src/ZB.MOM.WW.OtOpcUa.Admin/Services/CertTrustService.cs
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Metadata for a certificate file found in one of the OPC UA server's PKI stores. The
|
||||||
|
/// <see cref="FilePath"/> is the absolute path of the DER/CRT file the stack created when it
|
||||||
|
/// rejected the cert (for <see cref="CertStoreKind.Rejected"/>) or when an operator trusted
|
||||||
|
/// it (for <see cref="CertStoreKind.Trusted"/>).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record CertInfo(
|
||||||
|
string Thumbprint,
|
||||||
|
string Subject,
|
||||||
|
string Issuer,
|
||||||
|
DateTime NotBefore,
|
||||||
|
DateTime NotAfter,
|
||||||
|
string FilePath,
|
||||||
|
CertStoreKind Store);
|
||||||
|
|
||||||
|
public enum CertStoreKind
|
||||||
|
{
|
||||||
|
Rejected,
|
||||||
|
Trusted,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Filesystem-backed view over the OPC UA server's PKI store. The Opc.Ua stack uses a
|
||||||
|
/// Directory-typed store — each cert is a <c>.der</c> file under <c>{root}/{store}/certs/</c>
|
||||||
|
/// with a filename derived from subject + thumbprint. This service exposes operators for the
|
||||||
|
/// Admin UI: list rejected, list trusted, trust a rejected cert (move to trusted), remove a
|
||||||
|
/// rejected cert (delete), untrust a previously trusted cert (delete from trusted).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The Admin process is separate from the Server process; this service deliberately has no
|
||||||
|
/// Opc.Ua dependency — it works on the on-disk layout directly so it can run on the Admin
|
||||||
|
/// host even when the Server isn't installed locally, as long as the PKI root is reachable
|
||||||
|
/// (typical deployment has Admin + Server side-by-side on the same machine).
|
||||||
|
///
|
||||||
|
/// Trust/untrust requires the Server to re-read its trust list. The Opc.Ua stack re-reads
|
||||||
|
/// the Directory store on each new incoming connection, so there's no explicit signal
|
||||||
|
/// needed — the next client handshake picks up the change. Operators should retry the
|
||||||
|
/// rejected client's connection after trusting.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class CertTrustService
|
||||||
|
{
|
||||||
|
private readonly CertTrustOptions _options;
|
||||||
|
private readonly ILogger<CertTrustService> _logger;
|
||||||
|
|
||||||
|
public CertTrustService(IOptions<CertTrustOptions> options, ILogger<CertTrustService> logger)
|
||||||
|
{
|
||||||
|
_options = options.Value;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string PkiStoreRoot => _options.PkiStoreRoot;
|
||||||
|
|
||||||
|
public IReadOnlyList<CertInfo> ListRejected() => ListStore(CertStoreKind.Rejected);
|
||||||
|
public IReadOnlyList<CertInfo> ListTrusted() => ListStore(CertStoreKind.Trusted);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Move the cert with <paramref name="thumbprint"/> from the rejected store to the
|
||||||
|
/// trusted store. No-op returns false if the rejected file doesn't exist (already moved
|
||||||
|
/// by another operator, or thumbprint mismatch). Overwrites an existing trusted copy
|
||||||
|
/// silently — idempotent.
|
||||||
|
/// </summary>
|
||||||
|
public bool TrustRejected(string thumbprint)
|
||||||
|
{
|
||||||
|
var cert = FindInStore(CertStoreKind.Rejected, thumbprint);
|
||||||
|
if (cert is null) return false;
|
||||||
|
|
||||||
|
var trustedDir = CertsDir(CertStoreKind.Trusted);
|
||||||
|
Directory.CreateDirectory(trustedDir);
|
||||||
|
var destPath = Path.Combine(trustedDir, Path.GetFileName(cert.FilePath));
|
||||||
|
File.Move(cert.FilePath, destPath, overwrite: true);
|
||||||
|
_logger.LogInformation("Trusted cert {Thumbprint} (subject={Subject}) — moved {From} → {To}",
|
||||||
|
cert.Thumbprint, cert.Subject, cert.FilePath, destPath);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool DeleteRejected(string thumbprint) => DeleteFromStore(CertStoreKind.Rejected, thumbprint);
|
||||||
|
public bool UntrustCert(string thumbprint) => DeleteFromStore(CertStoreKind.Trusted, thumbprint);
|
||||||
|
|
||||||
|
private bool DeleteFromStore(CertStoreKind store, string thumbprint)
|
||||||
|
{
|
||||||
|
var cert = FindInStore(store, thumbprint);
|
||||||
|
if (cert is null) return false;
|
||||||
|
File.Delete(cert.FilePath);
|
||||||
|
_logger.LogInformation("Deleted cert {Thumbprint} (subject={Subject}) from {Store} store",
|
||||||
|
cert.Thumbprint, cert.Subject, store);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private CertInfo? FindInStore(CertStoreKind store, string thumbprint) =>
|
||||||
|
ListStore(store).FirstOrDefault(c =>
|
||||||
|
string.Equals(c.Thumbprint, thumbprint, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
private IReadOnlyList<CertInfo> ListStore(CertStoreKind store)
|
||||||
|
{
|
||||||
|
var dir = CertsDir(store);
|
||||||
|
if (!Directory.Exists(dir)) return [];
|
||||||
|
|
||||||
|
var results = new List<CertInfo>();
|
||||||
|
foreach (var path in Directory.EnumerateFiles(dir))
|
||||||
|
{
|
||||||
|
// Skip CRL sidecars + private-key files — trust operations only concern public certs.
|
||||||
|
var ext = Path.GetExtension(path);
|
||||||
|
if (!ext.Equals(".der", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
!ext.Equals(".crt", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
!ext.Equals(".cer", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cert = X509CertificateLoader.LoadCertificateFromFile(path);
|
||||||
|
results.Add(new CertInfo(
|
||||||
|
cert.Thumbprint, cert.Subject, cert.Issuer,
|
||||||
|
cert.NotBefore.ToUniversalTime(), cert.NotAfter.ToUniversalTime(),
|
||||||
|
path, store));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// A malformed file in the store shouldn't take down the page. Surface it in logs
|
||||||
|
// but skip — operators see the other certs and can clean the bad file manually.
|
||||||
|
_logger.LogWarning(ex, "Failed to parse cert at {Path} — skipping", path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string CertsDir(CertStoreKind store) =>
|
||||||
|
Path.Combine(_options.PkiStoreRoot, store == CertStoreKind.Rejected ? "rejected" : "trusted", "certs");
|
||||||
|
}
|
||||||
@@ -169,14 +169,14 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
|
|||||||
case ModbusRegion.HoldingRegisters:
|
case ModbusRegion.HoldingRegisters:
|
||||||
case ModbusRegion.InputRegisters:
|
case ModbusRegion.InputRegisters:
|
||||||
{
|
{
|
||||||
var quantity = RegisterCount(tag.DataType);
|
var quantity = RegisterCount(tag);
|
||||||
var fc = tag.Region == ModbusRegion.HoldingRegisters ? (byte)0x03 : (byte)0x04;
|
var fc = tag.Region == ModbusRegion.HoldingRegisters ? (byte)0x03 : (byte)0x04;
|
||||||
var pdu = new byte[] { fc, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
|
var pdu = new byte[] { fc, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
|
||||||
(byte)(quantity >> 8), (byte)(quantity & 0xFF) };
|
(byte)(quantity >> 8), (byte)(quantity & 0xFF) };
|
||||||
var resp = await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
|
var resp = await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
|
||||||
// resp = [fc][byte-count][data...]
|
// resp = [fc][byte-count][data...]
|
||||||
var data = new ReadOnlySpan<byte>(resp, 2, resp[1]);
|
var data = new ReadOnlySpan<byte>(resp, 2, resp[1]);
|
||||||
return DecodeRegister(data, tag.DataType);
|
return DecodeRegister(data, tag);
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
throw new InvalidOperationException($"Unknown region {tag.Region}");
|
throw new InvalidOperationException($"Unknown region {tag.Region}");
|
||||||
@@ -230,7 +230,7 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
|
|||||||
}
|
}
|
||||||
case ModbusRegion.HoldingRegisters:
|
case ModbusRegion.HoldingRegisters:
|
||||||
{
|
{
|
||||||
var bytes = EncodeRegister(value, tag.DataType);
|
var bytes = EncodeRegister(value, tag);
|
||||||
if (bytes.Length == 2)
|
if (bytes.Length == 2)
|
||||||
{
|
{
|
||||||
var pdu = new byte[] { 0x06, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
|
var pdu = new byte[] { 0x06, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
|
||||||
@@ -397,73 +397,173 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
|
|||||||
|
|
||||||
// ---- codec ----
|
// ---- codec ----
|
||||||
|
|
||||||
internal static ushort RegisterCount(ModbusDataType t) => t switch
|
/// <summary>
|
||||||
|
/// How many 16-bit registers a given tag occupies. Accounts for multi-register logical
|
||||||
|
/// types (Int32/Float32 = 2 regs, Int64/Float64 = 4 regs) and for strings (rounded up
|
||||||
|
/// from 2 chars per register).
|
||||||
|
/// </summary>
|
||||||
|
internal static ushort RegisterCount(ModbusTagDefinition tag) => tag.DataType switch
|
||||||
{
|
{
|
||||||
ModbusDataType.Int16 or ModbusDataType.UInt16 => 1,
|
ModbusDataType.Int16 or ModbusDataType.UInt16 or ModbusDataType.BitInRegister => 1,
|
||||||
ModbusDataType.Int32 or ModbusDataType.UInt32 or ModbusDataType.Float32 => 2,
|
ModbusDataType.Int32 or ModbusDataType.UInt32 or ModbusDataType.Float32 => 2,
|
||||||
_ => throw new InvalidOperationException($"Non-register data type {t}"),
|
ModbusDataType.Int64 or ModbusDataType.UInt64 or ModbusDataType.Float64 => 4,
|
||||||
|
ModbusDataType.String => (ushort)((tag.StringLength + 1) / 2), // 2 chars per register
|
||||||
|
_ => throw new InvalidOperationException($"Non-register data type {tag.DataType}"),
|
||||||
};
|
};
|
||||||
|
|
||||||
internal static object DecodeRegister(ReadOnlySpan<byte> data, ModbusDataType t) => t switch
|
/// <summary>
|
||||||
|
/// Word-swap the input into the big-endian layout the decoders expect. For 2-register
|
||||||
|
/// types this reverses the two words; for 4-register types it reverses the four words
|
||||||
|
/// (PLC stored [hi-mid, low-mid, hi-high, low-high] → memory [hi-high, low-high, hi-mid, low-mid]).
|
||||||
|
/// </summary>
|
||||||
|
private static byte[] NormalizeWordOrder(ReadOnlySpan<byte> data, ModbusByteOrder order)
|
||||||
{
|
{
|
||||||
ModbusDataType.Int16 => BinaryPrimitives.ReadInt16BigEndian(data),
|
if (order == ModbusByteOrder.BigEndian) return data.ToArray();
|
||||||
ModbusDataType.UInt16 => BinaryPrimitives.ReadUInt16BigEndian(data),
|
var result = new byte[data.Length];
|
||||||
ModbusDataType.Int32 => BinaryPrimitives.ReadInt32BigEndian(data),
|
for (var word = 0; word < data.Length / 2; word++)
|
||||||
ModbusDataType.UInt32 => BinaryPrimitives.ReadUInt32BigEndian(data),
|
{
|
||||||
ModbusDataType.Float32 => BinaryPrimitives.ReadSingleBigEndian(data),
|
var srcWord = data.Length / 2 - 1 - word;
|
||||||
_ => throw new InvalidOperationException($"Non-register data type {t}"),
|
result[word * 2] = data[srcWord * 2];
|
||||||
};
|
result[word * 2 + 1] = data[srcWord * 2 + 1];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
internal static byte[] EncodeRegister(object? value, ModbusDataType t)
|
internal static object DecodeRegister(ReadOnlySpan<byte> data, ModbusTagDefinition tag)
|
||||||
{
|
{
|
||||||
switch (t)
|
switch (tag.DataType)
|
||||||
|
{
|
||||||
|
case ModbusDataType.Int16: return BinaryPrimitives.ReadInt16BigEndian(data);
|
||||||
|
case ModbusDataType.UInt16: return BinaryPrimitives.ReadUInt16BigEndian(data);
|
||||||
|
case ModbusDataType.BitInRegister:
|
||||||
|
{
|
||||||
|
var raw = BinaryPrimitives.ReadUInt16BigEndian(data);
|
||||||
|
return (raw & (1 << tag.BitIndex)) != 0;
|
||||||
|
}
|
||||||
|
case ModbusDataType.Int32:
|
||||||
|
{
|
||||||
|
var b = NormalizeWordOrder(data, tag.ByteOrder);
|
||||||
|
return BinaryPrimitives.ReadInt32BigEndian(b);
|
||||||
|
}
|
||||||
|
case ModbusDataType.UInt32:
|
||||||
|
{
|
||||||
|
var b = NormalizeWordOrder(data, tag.ByteOrder);
|
||||||
|
return BinaryPrimitives.ReadUInt32BigEndian(b);
|
||||||
|
}
|
||||||
|
case ModbusDataType.Float32:
|
||||||
|
{
|
||||||
|
var b = NormalizeWordOrder(data, tag.ByteOrder);
|
||||||
|
return BinaryPrimitives.ReadSingleBigEndian(b);
|
||||||
|
}
|
||||||
|
case ModbusDataType.Int64:
|
||||||
|
{
|
||||||
|
var b = NormalizeWordOrder(data, tag.ByteOrder);
|
||||||
|
return BinaryPrimitives.ReadInt64BigEndian(b);
|
||||||
|
}
|
||||||
|
case ModbusDataType.UInt64:
|
||||||
|
{
|
||||||
|
var b = NormalizeWordOrder(data, tag.ByteOrder);
|
||||||
|
return BinaryPrimitives.ReadUInt64BigEndian(b);
|
||||||
|
}
|
||||||
|
case ModbusDataType.Float64:
|
||||||
|
{
|
||||||
|
var b = NormalizeWordOrder(data, tag.ByteOrder);
|
||||||
|
return BinaryPrimitives.ReadDoubleBigEndian(b);
|
||||||
|
}
|
||||||
|
case ModbusDataType.String:
|
||||||
|
{
|
||||||
|
// ASCII, 2 chars per register, packed high byte = first char.
|
||||||
|
// Respect the caller's StringLength (truncate nul-padded regions).
|
||||||
|
var chars = new char[tag.StringLength];
|
||||||
|
for (var i = 0; i < tag.StringLength; i++)
|
||||||
|
{
|
||||||
|
var b = data[i];
|
||||||
|
if (b == 0) { return new string(chars, 0, i); }
|
||||||
|
chars[i] = (char)b;
|
||||||
|
}
|
||||||
|
return new string(chars);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new InvalidOperationException($"Non-register data type {tag.DataType}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static byte[] EncodeRegister(object? value, ModbusTagDefinition tag)
|
||||||
|
{
|
||||||
|
switch (tag.DataType)
|
||||||
{
|
{
|
||||||
case ModbusDataType.Int16:
|
case ModbusDataType.Int16:
|
||||||
{
|
{
|
||||||
var v = Convert.ToInt16(value);
|
var v = Convert.ToInt16(value);
|
||||||
var b = new byte[2];
|
var b = new byte[2]; BinaryPrimitives.WriteInt16BigEndian(b, v); return b;
|
||||||
BinaryPrimitives.WriteInt16BigEndian(b, v);
|
|
||||||
return b;
|
|
||||||
}
|
}
|
||||||
case ModbusDataType.UInt16:
|
case ModbusDataType.UInt16:
|
||||||
{
|
{
|
||||||
var v = Convert.ToUInt16(value);
|
var v = Convert.ToUInt16(value);
|
||||||
var b = new byte[2];
|
var b = new byte[2]; BinaryPrimitives.WriteUInt16BigEndian(b, v); return b;
|
||||||
BinaryPrimitives.WriteUInt16BigEndian(b, v);
|
|
||||||
return b;
|
|
||||||
}
|
}
|
||||||
case ModbusDataType.Int32:
|
case ModbusDataType.Int32:
|
||||||
{
|
{
|
||||||
var v = Convert.ToInt32(value);
|
var v = Convert.ToInt32(value);
|
||||||
var b = new byte[4];
|
var b = new byte[4]; BinaryPrimitives.WriteInt32BigEndian(b, v);
|
||||||
BinaryPrimitives.WriteInt32BigEndian(b, v);
|
return NormalizeWordOrder(b, tag.ByteOrder);
|
||||||
return b;
|
|
||||||
}
|
}
|
||||||
case ModbusDataType.UInt32:
|
case ModbusDataType.UInt32:
|
||||||
{
|
{
|
||||||
var v = Convert.ToUInt32(value);
|
var v = Convert.ToUInt32(value);
|
||||||
var b = new byte[4];
|
var b = new byte[4]; BinaryPrimitives.WriteUInt32BigEndian(b, v);
|
||||||
BinaryPrimitives.WriteUInt32BigEndian(b, v);
|
return NormalizeWordOrder(b, tag.ByteOrder);
|
||||||
return b;
|
|
||||||
}
|
}
|
||||||
case ModbusDataType.Float32:
|
case ModbusDataType.Float32:
|
||||||
{
|
{
|
||||||
var v = Convert.ToSingle(value);
|
var v = Convert.ToSingle(value);
|
||||||
var b = new byte[4];
|
var b = new byte[4]; BinaryPrimitives.WriteSingleBigEndian(b, v);
|
||||||
BinaryPrimitives.WriteSingleBigEndian(b, v);
|
return NormalizeWordOrder(b, tag.ByteOrder);
|
||||||
|
}
|
||||||
|
case ModbusDataType.Int64:
|
||||||
|
{
|
||||||
|
var v = Convert.ToInt64(value);
|
||||||
|
var b = new byte[8]; BinaryPrimitives.WriteInt64BigEndian(b, v);
|
||||||
|
return NormalizeWordOrder(b, tag.ByteOrder);
|
||||||
|
}
|
||||||
|
case ModbusDataType.UInt64:
|
||||||
|
{
|
||||||
|
var v = Convert.ToUInt64(value);
|
||||||
|
var b = new byte[8]; BinaryPrimitives.WriteUInt64BigEndian(b, v);
|
||||||
|
return NormalizeWordOrder(b, tag.ByteOrder);
|
||||||
|
}
|
||||||
|
case ModbusDataType.Float64:
|
||||||
|
{
|
||||||
|
var v = Convert.ToDouble(value);
|
||||||
|
var b = new byte[8]; BinaryPrimitives.WriteDoubleBigEndian(b, v);
|
||||||
|
return NormalizeWordOrder(b, tag.ByteOrder);
|
||||||
|
}
|
||||||
|
case ModbusDataType.String:
|
||||||
|
{
|
||||||
|
var s = Convert.ToString(value) ?? string.Empty;
|
||||||
|
var regs = (tag.StringLength + 1) / 2;
|
||||||
|
var b = new byte[regs * 2];
|
||||||
|
for (var i = 0; i < tag.StringLength && i < s.Length; i++) b[i] = (byte)s[i];
|
||||||
|
// remaining bytes stay 0 — nul-padded per PLC convention
|
||||||
return b;
|
return b;
|
||||||
}
|
}
|
||||||
|
case ModbusDataType.BitInRegister:
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"BitInRegister writes require a read-modify-write; not supported in PR 24 (separate follow-up).");
|
||||||
default:
|
default:
|
||||||
throw new InvalidOperationException($"Non-register data type {t}");
|
throw new InvalidOperationException($"Non-register data type {tag.DataType}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static DriverDataType MapDataType(ModbusDataType t) => t switch
|
private static DriverDataType MapDataType(ModbusDataType t) => t switch
|
||||||
{
|
{
|
||||||
ModbusDataType.Bool => DriverDataType.Boolean,
|
ModbusDataType.Bool or ModbusDataType.BitInRegister => DriverDataType.Boolean,
|
||||||
ModbusDataType.Int16 or ModbusDataType.Int32 => DriverDataType.Int32,
|
ModbusDataType.Int16 or ModbusDataType.Int32 => DriverDataType.Int32,
|
||||||
ModbusDataType.UInt16 or ModbusDataType.UInt32 => DriverDataType.Int32,
|
ModbusDataType.UInt16 or ModbusDataType.UInt32 => DriverDataType.Int32,
|
||||||
|
ModbusDataType.Int64 or ModbusDataType.UInt64 => DriverDataType.Int32, // widening to Int32 loses precision; PR 25 adds Int64 to DriverDataType
|
||||||
ModbusDataType.Float32 => DriverDataType.Float32,
|
ModbusDataType.Float32 => DriverDataType.Float32,
|
||||||
|
ModbusDataType.Float64 => DriverDataType.Float64,
|
||||||
|
ModbusDataType.String => DriverDataType.String,
|
||||||
_ => DriverDataType.Int32,
|
_ => DriverDataType.Int32,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ public sealed class ModbusProbeOptions
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One Modbus-backed OPC UA variable. Address is zero-based (Modbus spec numbering, not
|
/// One Modbus-backed OPC UA variable. Address is zero-based (Modbus spec numbering, not
|
||||||
/// the documentation's 1-based coil/register conventions).
|
/// the documentation's 1-based coil/register conventions). Multi-register types
|
||||||
|
/// (Int32/UInt32/Float32 = 2 regs; Int64/UInt64/Float64 = 4 regs) respect the
|
||||||
|
/// <see cref="ByteOrder"/> field — real-world PLCs disagree on word ordering.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="Name">
|
/// <param name="Name">
|
||||||
/// Tag name, used for both the OPC UA browse name and the driver's full reference. Must be
|
/// Tag name, used for both the OPC UA browse name and the driver's full reference. Must be
|
||||||
@@ -46,14 +48,50 @@ public sealed class ModbusProbeOptions
|
|||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="Region">Coils / DiscreteInputs / InputRegisters / HoldingRegisters.</param>
|
/// <param name="Region">Coils / DiscreteInputs / InputRegisters / HoldingRegisters.</param>
|
||||||
/// <param name="Address">Zero-based address within the region.</param>
|
/// <param name="Address">Zero-based address within the region.</param>
|
||||||
/// <param name="DataType">Logical data type. Int16/UInt16 = single register; Int32/UInt32/Float32 = two registers big-endian.</param>
|
/// <param name="DataType">
|
||||||
|
/// Logical data type. See <see cref="ModbusDataType"/> for the register count each encodes.
|
||||||
|
/// </param>
|
||||||
/// <param name="Writable">When true and Region supports writes (Coils / HoldingRegisters), IWritable routes writes here.</param>
|
/// <param name="Writable">When true and Region supports writes (Coils / HoldingRegisters), IWritable routes writes here.</param>
|
||||||
|
/// <param name="ByteOrder">Word ordering for multi-register types. Ignored for Bool / Int16 / UInt16 / BitInRegister / String.</param>
|
||||||
|
/// <param name="BitIndex">For <c>DataType = BitInRegister</c>: which bit of the holding register (0-15, LSB-first).</param>
|
||||||
|
/// <param name="StringLength">For <c>DataType = String</c>: number of ASCII characters (2 per register, rounded up).</param>
|
||||||
public sealed record ModbusTagDefinition(
|
public sealed record ModbusTagDefinition(
|
||||||
string Name,
|
string Name,
|
||||||
ModbusRegion Region,
|
ModbusRegion Region,
|
||||||
ushort Address,
|
ushort Address,
|
||||||
ModbusDataType DataType,
|
ModbusDataType DataType,
|
||||||
bool Writable = true);
|
bool Writable = true,
|
||||||
|
ModbusByteOrder ByteOrder = ModbusByteOrder.BigEndian,
|
||||||
|
byte BitIndex = 0,
|
||||||
|
ushort StringLength = 0);
|
||||||
|
|
||||||
public enum ModbusRegion { Coils, DiscreteInputs, InputRegisters, HoldingRegisters }
|
public enum ModbusRegion { Coils, DiscreteInputs, InputRegisters, HoldingRegisters }
|
||||||
public enum ModbusDataType { Bool, Int16, UInt16, Int32, UInt32, Float32 }
|
|
||||||
|
public enum ModbusDataType
|
||||||
|
{
|
||||||
|
Bool,
|
||||||
|
Int16,
|
||||||
|
UInt16,
|
||||||
|
Int32,
|
||||||
|
UInt32,
|
||||||
|
Int64,
|
||||||
|
UInt64,
|
||||||
|
Float32,
|
||||||
|
Float64,
|
||||||
|
/// <summary>Single bit within a holding register. <see cref="ModbusTagDefinition.BitIndex"/> selects 0-15 LSB-first.</summary>
|
||||||
|
BitInRegister,
|
||||||
|
/// <summary>ASCII string packed 2 chars per register, <see cref="ModbusTagDefinition.StringLength"/> characters long.</summary>
|
||||||
|
String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Word ordering for multi-register types. Modbus TCP standard is <see cref="BigEndian"/>
|
||||||
|
/// (ABCD for 32-bit: high word at the lower address). Many PLCs — Siemens S7, several
|
||||||
|
/// Allen-Bradley series, some Modicon families — use <see cref="WordSwap"/> (CDAB), which
|
||||||
|
/// keeps bytes big-endian within each register but reverses the word pair(s).
|
||||||
|
/// </summary>
|
||||||
|
public enum ModbusByteOrder
|
||||||
|
{
|
||||||
|
BigEndian,
|
||||||
|
WordSwap,
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging;
|
|||||||
using Opc.Ua;
|
using Opc.Ua;
|
||||||
using Opc.Ua.Server;
|
using Opc.Ua.Server;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||||
using DriverWriteRequest = ZB.MOM.WW.OtOpcUa.Core.Abstractions.WriteRequest;
|
using DriverWriteRequest = ZB.MOM.WW.OtOpcUa.Core.Abstractions.WriteRequest;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||||
@@ -35,6 +36,12 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
|||||||
private FolderState? _driverRoot;
|
private FolderState? _driverRoot;
|
||||||
private readonly Dictionary<string, BaseDataVariableState> _variablesByFullRef = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, BaseDataVariableState> _variablesByFullRef = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// PR 26: SecurityClassification per variable, populated during Variable() registration.
|
||||||
|
// OnWriteValue looks up the classification here to gate the write by the session's roles.
|
||||||
|
// Drivers never enforce authz themselves — the classification is discovery-time metadata
|
||||||
|
// only (feedback_acl_at_server_layer.md).
|
||||||
|
private readonly Dictionary<string, SecurityClassification> _securityByFullRef = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
// Active building folder — set per Folder() call so Variable() lands under the right parent.
|
// Active building folder — set per Folder() call so Variable() lands under the right parent.
|
||||||
// A stack would support nested folders; we use a single current folder because IAddressSpaceBuilder
|
// A stack would support nested folders; we use a single current folder because IAddressSpaceBuilder
|
||||||
// returns a child builder per Folder call and the caller threads nesting through those references.
|
// returns a child builder per Folder call and the caller threads nesting through those references.
|
||||||
@@ -122,6 +129,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
|||||||
_currentFolder.AddChild(v);
|
_currentFolder.AddChild(v);
|
||||||
AddPredefinedNode(SystemContext, v);
|
AddPredefinedNode(SystemContext, v);
|
||||||
_variablesByFullRef[attributeInfo.FullName] = v;
|
_variablesByFullRef[attributeInfo.FullName] = v;
|
||||||
|
_securityByFullRef[attributeInfo.FullName] = attributeInfo.SecurityClass;
|
||||||
|
|
||||||
v.OnReadValue = OnReadValue;
|
v.OnReadValue = OnReadValue;
|
||||||
v.OnWriteValue = OnWriteValue;
|
v.OnWriteValue = OnWriteValue;
|
||||||
@@ -337,6 +345,22 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
|||||||
var fullRef = node.NodeId.Identifier as string;
|
var fullRef = node.NodeId.Identifier as string;
|
||||||
if (string.IsNullOrEmpty(fullRef)) return StatusCodes.BadNodeIdUnknown;
|
if (string.IsNullOrEmpty(fullRef)) return StatusCodes.BadNodeIdUnknown;
|
||||||
|
|
||||||
|
// PR 26: server-layer write authorization. Look up the attribute's classification
|
||||||
|
// (populated during Variable() in Discover) and check the session's roles against the
|
||||||
|
// policy table. Drivers don't participate in this decision — IWritable.WriteAsync
|
||||||
|
// never sees a request we'd have refused here.
|
||||||
|
if (_securityByFullRef.TryGetValue(fullRef!, out var classification))
|
||||||
|
{
|
||||||
|
var roles = context.UserIdentity is IRoleBearer rb ? rb.Roles : [];
|
||||||
|
if (!WriteAuthzPolicy.IsAllowed(classification, roles))
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Write denied for {FullRef}: classification={Classification} userRoles=[{Roles}]",
|
||||||
|
fullRef, classification, string.Join(",", roles));
|
||||||
|
return new ServiceResult(StatusCodes.BadUserAccessDenied);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var results = _writable.WriteAsync(
|
var results = _writable.WriteAsync(
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ public sealed class OtOpcUaServer : StandardServer
|
|||||||
/// managers can gate writes by role via <c>session.Identity</c>. Anonymous identity still
|
/// managers can gate writes by role via <c>session.Identity</c>. Anonymous identity still
|
||||||
/// uses the stack's default.
|
/// uses the stack's default.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private sealed class RoleBasedIdentity : UserIdentity
|
private sealed class RoleBasedIdentity : UserIdentity, IRoleBearer
|
||||||
{
|
{
|
||||||
public IReadOnlyList<string> Roles { get; }
|
public IReadOnlyList<string> Roles { get; }
|
||||||
public string? Display { get; }
|
public string? Display { get; }
|
||||||
|
|||||||
13
src/ZB.MOM.WW.OtOpcUa.Server/Security/IRoleBearer.cs
Normal file
13
src/ZB.MOM.WW.OtOpcUa.Server/Security/IRoleBearer.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal interface a <see cref="Opc.Ua.IUserIdentity"/> implementation can expose so
|
||||||
|
/// <see cref="ZB.MOM.WW.OtOpcUa.Server.OpcUa.DriverNodeManager"/> can read the session's
|
||||||
|
/// resolved roles without a hard dependency on any specific identity subtype. Implemented
|
||||||
|
/// by <c>OtOpcUaServer.RoleBasedIdentity</c>; tests implement it with stub identities to
|
||||||
|
/// drive the authz policy under different role sets.
|
||||||
|
/// </summary>
|
||||||
|
public interface IRoleBearer
|
||||||
|
{
|
||||||
|
IReadOnlyList<string> Roles { get; }
|
||||||
|
}
|
||||||
70
src/ZB.MOM.WW.OtOpcUa.Server/Security/WriteAuthzPolicy.cs
Normal file
70
src/ZB.MOM.WW.OtOpcUa.Server/Security/WriteAuthzPolicy.cs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-layer write-authorization policy. ACL enforcement lives here — drivers report
|
||||||
|
/// <see cref="SecurityClassification"/> as discovery metadata only; the server decides
|
||||||
|
/// whether a given session is allowed to write a given attribute by checking the session's
|
||||||
|
/// roles (resolved at login via <see cref="LdapUserAuthenticator"/>) against the required
|
||||||
|
/// role for the attribute's classification.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Matches the table in <c>docs/Configuration.md</c>:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>FreeAccess</c>: no role required — anonymous sessions can write (matches v1 default).</item>
|
||||||
|
/// <item><c>Operate</c> / <c>SecuredWrite</c>: <c>WriteOperate</c> role required.</item>
|
||||||
|
/// <item><c>Tune</c>: <c>WriteTune</c> role required.</item>
|
||||||
|
/// <item><c>VerifiedWrite</c> / <c>Configure</c>: <c>WriteConfigure</c> role required.</item>
|
||||||
|
/// <item><c>ViewOnly</c>: no role grants write access.</item>
|
||||||
|
/// </list>
|
||||||
|
/// <c>AlarmAck</c> is checked at the alarm-acknowledge path, not here.
|
||||||
|
/// </remarks>
|
||||||
|
public static class WriteAuthzPolicy
|
||||||
|
{
|
||||||
|
public const string RoleWriteOperate = "WriteOperate";
|
||||||
|
public const string RoleWriteTune = "WriteTune";
|
||||||
|
public const string RoleWriteConfigure = "WriteConfigure";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decide whether a session with <paramref name="userRoles"/> is allowed to write to an
|
||||||
|
/// attribute with the given <paramref name="classification"/>. Returns true for
|
||||||
|
/// <c>FreeAccess</c> regardless of roles (including empty / anonymous sessions) and
|
||||||
|
/// false for <c>ViewOnly</c> regardless of roles. Every other classification requires
|
||||||
|
/// the session to carry the mapped role — case-insensitive match.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsAllowed(SecurityClassification classification, IReadOnlyCollection<string> userRoles)
|
||||||
|
{
|
||||||
|
if (classification == SecurityClassification.FreeAccess) return true;
|
||||||
|
if (classification == SecurityClassification.ViewOnly) return false;
|
||||||
|
|
||||||
|
var required = RequiredRole(classification);
|
||||||
|
if (required is null) return false;
|
||||||
|
|
||||||
|
foreach (var r in userRoles)
|
||||||
|
{
|
||||||
|
if (string.Equals(r, required, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Required role for a classification, or null when no role grants access
|
||||||
|
/// (<see cref="SecurityClassification.ViewOnly"/>) or no role is needed
|
||||||
|
/// (<see cref="SecurityClassification.FreeAccess"/> — also returns null; callers use
|
||||||
|
/// <see cref="IsAllowed"/> which handles the special-cases rather than branching on
|
||||||
|
/// null themselves).
|
||||||
|
/// </summary>
|
||||||
|
public static string? RequiredRole(SecurityClassification classification) => classification switch
|
||||||
|
{
|
||||||
|
SecurityClassification.FreeAccess => null, // IsAllowed short-circuits
|
||||||
|
SecurityClassification.Operate => RoleWriteOperate,
|
||||||
|
SecurityClassification.SecuredWrite => RoleWriteOperate,
|
||||||
|
SecurityClassification.Tune => RoleWriteTune,
|
||||||
|
SecurityClassification.VerifiedWrite => RoleWriteConfigure,
|
||||||
|
SecurityClassification.Configure => RoleWriteConfigure,
|
||||||
|
SecurityClassification.ViewOnly => null, // IsAllowed short-circuits
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
153
tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/CertTrustServiceTests.cs
Normal file
153
tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/CertTrustServiceTests.cs
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class CertTrustServiceTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _root;
|
||||||
|
|
||||||
|
public CertTrustServiceTests()
|
||||||
|
{
|
||||||
|
_root = Path.Combine(Path.GetTempPath(), $"otopcua-cert-test-{Guid.NewGuid():N}");
|
||||||
|
Directory.CreateDirectory(Path.Combine(_root, "rejected", "certs"));
|
||||||
|
Directory.CreateDirectory(Path.Combine(_root, "trusted", "certs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private CertTrustService Service() => new(
|
||||||
|
Options.Create(new CertTrustOptions { PkiStoreRoot = _root }),
|
||||||
|
NullLogger<CertTrustService>.Instance);
|
||||||
|
|
||||||
|
private X509Certificate2 WriteTestCert(CertStoreKind kind, string subject)
|
||||||
|
{
|
||||||
|
using var rsa = RSA.Create(2048);
|
||||||
|
var req = new CertificateRequest($"CN={subject}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||||
|
var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1));
|
||||||
|
var dir = Path.Combine(_root, kind == CertStoreKind.Rejected ? "rejected" : "trusted", "certs");
|
||||||
|
var path = Path.Combine(dir, $"{subject} [{cert.Thumbprint}].der");
|
||||||
|
File.WriteAllBytes(path, cert.Export(X509ContentType.Cert));
|
||||||
|
return cert;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ListRejected_returns_parsed_cert_info_for_each_der_in_rejected_certs_dir()
|
||||||
|
{
|
||||||
|
var c = WriteTestCert(CertStoreKind.Rejected, "test-client-A");
|
||||||
|
|
||||||
|
var rows = Service().ListRejected();
|
||||||
|
|
||||||
|
rows.Count.ShouldBe(1);
|
||||||
|
rows[0].Thumbprint.ShouldBe(c.Thumbprint);
|
||||||
|
rows[0].Subject.ShouldContain("test-client-A");
|
||||||
|
rows[0].Store.ShouldBe(CertStoreKind.Rejected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ListTrusted_is_separate_from_rejected()
|
||||||
|
{
|
||||||
|
WriteTestCert(CertStoreKind.Rejected, "rej");
|
||||||
|
WriteTestCert(CertStoreKind.Trusted, "trust");
|
||||||
|
|
||||||
|
var svc = Service();
|
||||||
|
svc.ListRejected().Count.ShouldBe(1);
|
||||||
|
svc.ListTrusted().Count.ShouldBe(1);
|
||||||
|
svc.ListRejected()[0].Subject.ShouldContain("rej");
|
||||||
|
svc.ListTrusted()[0].Subject.ShouldContain("trust");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TrustRejected_moves_file_from_rejected_to_trusted()
|
||||||
|
{
|
||||||
|
var c = WriteTestCert(CertStoreKind.Rejected, "promoteme");
|
||||||
|
var svc = Service();
|
||||||
|
|
||||||
|
svc.TrustRejected(c.Thumbprint).ShouldBeTrue();
|
||||||
|
|
||||||
|
svc.ListRejected().ShouldBeEmpty();
|
||||||
|
var trusted = svc.ListTrusted();
|
||||||
|
trusted.Count.ShouldBe(1);
|
||||||
|
trusted[0].Thumbprint.ShouldBe(c.Thumbprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TrustRejected_returns_false_when_thumbprint_not_in_rejected()
|
||||||
|
{
|
||||||
|
var svc = Service();
|
||||||
|
svc.TrustRejected("00DEADBEEF00DEADBEEF00DEADBEEF00DEADBEEF").ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeleteRejected_removes_the_file()
|
||||||
|
{
|
||||||
|
var c = WriteTestCert(CertStoreKind.Rejected, "killme");
|
||||||
|
var svc = Service();
|
||||||
|
|
||||||
|
svc.DeleteRejected(c.Thumbprint).ShouldBeTrue();
|
||||||
|
svc.ListRejected().ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UntrustCert_removes_from_trusted_only()
|
||||||
|
{
|
||||||
|
var c = WriteTestCert(CertStoreKind.Trusted, "revoke");
|
||||||
|
var svc = Service();
|
||||||
|
|
||||||
|
svc.UntrustCert(c.Thumbprint).ShouldBeTrue();
|
||||||
|
svc.ListTrusted().ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Thumbprint_match_is_case_insensitive()
|
||||||
|
{
|
||||||
|
var c = WriteTestCert(CertStoreKind.Rejected, "case");
|
||||||
|
var svc = Service();
|
||||||
|
|
||||||
|
// X509Certificate2.Thumbprint is upper-case hex; operators pasting from logs often
|
||||||
|
// lowercase it. IsAllowed-style case-insensitive match keeps the UX forgiving.
|
||||||
|
svc.TrustRejected(c.Thumbprint.ToLowerInvariant()).ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Missing_store_directories_produce_empty_lists_not_exceptions()
|
||||||
|
{
|
||||||
|
// Fresh root with no certs subfolder — service should tolerate a pristine install.
|
||||||
|
var altRoot = Path.Combine(Path.GetTempPath(), $"otopcua-cert-empty-{Guid.NewGuid():N}");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var svc = new CertTrustService(
|
||||||
|
Options.Create(new CertTrustOptions { PkiStoreRoot = altRoot }),
|
||||||
|
NullLogger<CertTrustService>.Instance);
|
||||||
|
svc.ListRejected().ShouldBeEmpty();
|
||||||
|
svc.ListTrusted().ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (Directory.Exists(altRoot)) Directory.Delete(altRoot, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Malformed_file_is_skipped_not_fatal()
|
||||||
|
{
|
||||||
|
// Drop junk bytes that don't parse as a cert into the rejected/certs directory. The
|
||||||
|
// service must skip it and still return the valid certs — one bad file can't take the
|
||||||
|
// whole management page offline.
|
||||||
|
File.WriteAllText(Path.Combine(_root, "rejected", "certs", "junk.der"), "not a cert");
|
||||||
|
var c = WriteTestCert(CertStoreKind.Rejected, "valid");
|
||||||
|
|
||||||
|
var rows = Service().ListRejected();
|
||||||
|
rows.Count.ShouldBe(1);
|
||||||
|
rows[0].Thumbprint.ShouldBe(c.Thumbprint);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
using System.Buffers.Binary;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class ModbusDataTypeTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Register-count lookup is per-tag now (strings need StringLength; Int64/Float64 need 4).
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(ModbusDataType.BitInRegister, 1)]
|
||||||
|
[InlineData(ModbusDataType.Int16, 1)]
|
||||||
|
[InlineData(ModbusDataType.UInt16, 1)]
|
||||||
|
[InlineData(ModbusDataType.Int32, 2)]
|
||||||
|
[InlineData(ModbusDataType.UInt32, 2)]
|
||||||
|
[InlineData(ModbusDataType.Float32, 2)]
|
||||||
|
[InlineData(ModbusDataType.Int64, 4)]
|
||||||
|
[InlineData(ModbusDataType.UInt64, 4)]
|
||||||
|
[InlineData(ModbusDataType.Float64, 4)]
|
||||||
|
public void RegisterCount_returns_correct_register_count_per_type(ModbusDataType t, int expected)
|
||||||
|
{
|
||||||
|
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, t);
|
||||||
|
ModbusDriver.RegisterCount(tag).ShouldBe((ushort)expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0, 1)] // 0 chars → still 1 byte / 1 register (pathological but well-defined: length 0 is 0 bytes)
|
||||||
|
[InlineData(1, 1)]
|
||||||
|
[InlineData(2, 1)]
|
||||||
|
[InlineData(3, 2)]
|
||||||
|
[InlineData(10, 5)]
|
||||||
|
public void RegisterCount_for_String_rounds_up_to_register_pair(ushort chars, int expectedRegs)
|
||||||
|
{
|
||||||
|
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String, StringLength: chars);
|
||||||
|
// 0-char is encoded as 0 regs; the test case expects 1 for lengths 1-2, 2 for 3-4, etc.
|
||||||
|
if (chars == 0) ModbusDriver.RegisterCount(tag).ShouldBe((ushort)0);
|
||||||
|
else ModbusDriver.RegisterCount(tag).ShouldBe((ushort)expectedRegs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Int32 / UInt32 / Float32 with byte-order variants ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Int32_BigEndian_decodes_ABCD_layout()
|
||||||
|
{
|
||||||
|
// Value 0x12345678 → bytes [0x12, 0x34, 0x56, 0x78] as PLC wrote them.
|
||||||
|
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int32,
|
||||||
|
ByteOrder: ModbusByteOrder.BigEndian);
|
||||||
|
var bytes = new byte[] { 0x12, 0x34, 0x56, 0x78 };
|
||||||
|
ModbusDriver.DecodeRegister(bytes, tag).ShouldBe(0x12345678);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Int32_WordSwap_decodes_CDAB_layout()
|
||||||
|
{
|
||||||
|
// Siemens/AB PLC stored 0x12345678 as register[0] = 0x5678, register[1] = 0x1234.
|
||||||
|
// Wire bytes are [0x56, 0x78, 0x12, 0x34]; with ByteOrder=WordSwap we get 0x12345678 back.
|
||||||
|
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int32,
|
||||||
|
ByteOrder: ModbusByteOrder.WordSwap);
|
||||||
|
var bytes = new byte[] { 0x56, 0x78, 0x12, 0x34 };
|
||||||
|
ModbusDriver.DecodeRegister(bytes, tag).ShouldBe(0x12345678);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Float32_WordSwap_encode_decode_roundtrips()
|
||||||
|
{
|
||||||
|
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Float32,
|
||||||
|
ByteOrder: ModbusByteOrder.WordSwap);
|
||||||
|
var wire = ModbusDriver.EncodeRegister(25.5f, tag);
|
||||||
|
wire.Length.ShouldBe(4);
|
||||||
|
ModbusDriver.DecodeRegister(wire, tag).ShouldBe(25.5f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Int64 / UInt64 / Float64 ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Int64_BigEndian_roundtrips()
|
||||||
|
{
|
||||||
|
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int64);
|
||||||
|
var wire = ModbusDriver.EncodeRegister(0x0123456789ABCDEFL, tag);
|
||||||
|
wire.Length.ShouldBe(8);
|
||||||
|
BinaryPrimitives.ReadInt64BigEndian(wire).ShouldBe(0x0123456789ABCDEFL);
|
||||||
|
ModbusDriver.DecodeRegister(wire, tag).ShouldBe(0x0123456789ABCDEFL);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UInt64_WordSwap_reverses_four_words()
|
||||||
|
{
|
||||||
|
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.UInt64,
|
||||||
|
ByteOrder: ModbusByteOrder.WordSwap);
|
||||||
|
var value = 0xAABBCCDDEEFF0011UL;
|
||||||
|
|
||||||
|
var wireBE = new byte[8];
|
||||||
|
BinaryPrimitives.WriteUInt64BigEndian(wireBE, value);
|
||||||
|
|
||||||
|
// Word-swap layout: [word3, word2, word1, word0] where each word keeps its bytes big-endian.
|
||||||
|
var wireWS = new byte[] { wireBE[6], wireBE[7], wireBE[4], wireBE[5], wireBE[2], wireBE[3], wireBE[0], wireBE[1] };
|
||||||
|
ModbusDriver.DecodeRegister(wireWS, tag).ShouldBe(value);
|
||||||
|
|
||||||
|
var roundtrip = ModbusDriver.EncodeRegister(value, tag);
|
||||||
|
roundtrip.ShouldBe(wireWS);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Float64_roundtrips_under_word_swap()
|
||||||
|
{
|
||||||
|
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Float64,
|
||||||
|
ByteOrder: ModbusByteOrder.WordSwap);
|
||||||
|
var wire = ModbusDriver.EncodeRegister(3.14159265358979d, tag);
|
||||||
|
wire.Length.ShouldBe(8);
|
||||||
|
((double)ModbusDriver.DecodeRegister(wire, tag)!).ShouldBe(3.14159265358979d, tolerance: 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- BitInRegister ---
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0b0000_0000_0000_0001, 0, true)]
|
||||||
|
[InlineData(0b0000_0000_0000_0001, 1, false)]
|
||||||
|
[InlineData(0b1000_0000_0000_0000, 15, true)]
|
||||||
|
[InlineData(0b0100_0000_0100_0000, 6, true)]
|
||||||
|
[InlineData(0b0100_0000_0100_0000, 14, true)]
|
||||||
|
[InlineData(0b0100_0000_0100_0000, 7, false)]
|
||||||
|
public void BitInRegister_extracts_bit_at_index(ushort raw, byte bitIndex, bool expected)
|
||||||
|
{
|
||||||
|
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.BitInRegister,
|
||||||
|
BitIndex: bitIndex);
|
||||||
|
var bytes = new byte[] { (byte)(raw >> 8), (byte)(raw & 0xFF) };
|
||||||
|
ModbusDriver.DecodeRegister(bytes, tag).ShouldBe(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BitInRegister_write_is_not_supported_in_PR24()
|
||||||
|
{
|
||||||
|
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.BitInRegister,
|
||||||
|
BitIndex: 5);
|
||||||
|
Should.Throw<InvalidOperationException>(() => ModbusDriver.EncodeRegister(true, tag))
|
||||||
|
.Message.ShouldContain("read-modify-write");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- String ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void String_decodes_ASCII_packed_two_chars_per_register()
|
||||||
|
{
|
||||||
|
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
|
||||||
|
StringLength: 6);
|
||||||
|
// "HELLO!" = 0x48 0x45 0x4C 0x4C 0x4F 0x21 across 3 registers.
|
||||||
|
var bytes = "HELLO!"u8.ToArray();
|
||||||
|
ModbusDriver.DecodeRegister(bytes, tag).ShouldBe("HELLO!");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void String_decode_truncates_at_first_nul()
|
||||||
|
{
|
||||||
|
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
|
||||||
|
StringLength: 10);
|
||||||
|
var bytes = new byte[] { 0x48, 0x69, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };
|
||||||
|
ModbusDriver.DecodeRegister(bytes, tag).ShouldBe("Hi");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void String_encode_nul_pads_remaining_bytes()
|
||||||
|
{
|
||||||
|
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
|
||||||
|
StringLength: 8);
|
||||||
|
var wire = ModbusDriver.EncodeRegister("Hi", tag);
|
||||||
|
wire.Length.ShouldBe(8);
|
||||||
|
wire[0].ShouldBe((byte)'H');
|
||||||
|
wire[1].ShouldBe((byte)'i');
|
||||||
|
for (var i = 2; i < 8; i++) wire[i].ShouldBe((byte)0);
|
||||||
|
}
|
||||||
|
}
|
||||||
134
tests/ZB.MOM.WW.OtOpcUa.Server.Tests/WriteAuthzPolicyTests.cs
Normal file
134
tests/ZB.MOM.WW.OtOpcUa.Server.Tests/WriteAuthzPolicyTests.cs
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class WriteAuthzPolicyTests
|
||||||
|
{
|
||||||
|
// --- FreeAccess and ViewOnly special-cases ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FreeAccess_allows_write_even_for_empty_role_set()
|
||||||
|
{
|
||||||
|
WriteAuthzPolicy.IsAllowed(SecurityClassification.FreeAccess, []).ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FreeAccess_allows_write_for_arbitrary_roles()
|
||||||
|
{
|
||||||
|
WriteAuthzPolicy.IsAllowed(SecurityClassification.FreeAccess, ["SomeOtherRole"]).ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ViewOnly_denies_write_even_with_every_role()
|
||||||
|
{
|
||||||
|
var allRoles = new[] { "WriteOperate", "WriteTune", "WriteConfigure", "AlarmAck" };
|
||||||
|
WriteAuthzPolicy.IsAllowed(SecurityClassification.ViewOnly, allRoles).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Operate tier ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Operate_requires_WriteOperate_role()
|
||||||
|
{
|
||||||
|
WriteAuthzPolicy.IsAllowed(SecurityClassification.Operate, ["WriteOperate"]).ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Operate_role_match_is_case_insensitive()
|
||||||
|
{
|
||||||
|
WriteAuthzPolicy.IsAllowed(SecurityClassification.Operate, ["writeoperate"]).ShouldBeTrue();
|
||||||
|
WriteAuthzPolicy.IsAllowed(SecurityClassification.Operate, ["WRITEOPERATE"]).ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Operate_denies_empty_role_set()
|
||||||
|
{
|
||||||
|
WriteAuthzPolicy.IsAllowed(SecurityClassification.Operate, []).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Operate_denies_wrong_role()
|
||||||
|
{
|
||||||
|
WriteAuthzPolicy.IsAllowed(SecurityClassification.Operate, ["ReadOnly"]).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SecuredWrite_maps_to_same_WriteOperate_requirement_as_Operate()
|
||||||
|
{
|
||||||
|
WriteAuthzPolicy.IsAllowed(SecurityClassification.SecuredWrite, ["WriteOperate"]).ShouldBeTrue();
|
||||||
|
WriteAuthzPolicy.IsAllowed(SecurityClassification.SecuredWrite, ["WriteTune"]).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tune tier ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Tune_requires_WriteTune_role()
|
||||||
|
{
|
||||||
|
WriteAuthzPolicy.IsAllowed(SecurityClassification.Tune, ["WriteTune"]).ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Tune_denies_WriteOperate_only_session()
|
||||||
|
{
|
||||||
|
// Important: role roles do NOT cascade — a session with WriteOperate can't write a Tune
|
||||||
|
// attribute. Operators escalate by adding WriteTune to the session's roles, not by a
|
||||||
|
// hierarchy the policy infers on its own.
|
||||||
|
WriteAuthzPolicy.IsAllowed(SecurityClassification.Tune, ["WriteOperate"]).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Configure tier ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Configure_requires_WriteConfigure_role()
|
||||||
|
{
|
||||||
|
WriteAuthzPolicy.IsAllowed(SecurityClassification.Configure, ["WriteConfigure"]).ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void VerifiedWrite_maps_to_same_WriteConfigure_requirement_as_Configure()
|
||||||
|
{
|
||||||
|
WriteAuthzPolicy.IsAllowed(SecurityClassification.VerifiedWrite, ["WriteConfigure"]).ShouldBeTrue();
|
||||||
|
WriteAuthzPolicy.IsAllowed(SecurityClassification.VerifiedWrite, ["WriteOperate"]).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Multi-role sessions ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Session_with_multiple_roles_is_allowed_when_any_matches()
|
||||||
|
{
|
||||||
|
var roles = new[] { "ReadOnly", "WriteTune", "AlarmAck" };
|
||||||
|
WriteAuthzPolicy.IsAllowed(SecurityClassification.Tune, roles).ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Session_with_only_unrelated_roles_is_denied()
|
||||||
|
{
|
||||||
|
var roles = new[] { "ReadOnly", "AlarmAck", "SomeCustomRole" };
|
||||||
|
WriteAuthzPolicy.IsAllowed(SecurityClassification.Configure, roles).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mapping table ---
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(SecurityClassification.Operate, WriteAuthzPolicy.RoleWriteOperate)]
|
||||||
|
[InlineData(SecurityClassification.SecuredWrite, WriteAuthzPolicy.RoleWriteOperate)]
|
||||||
|
[InlineData(SecurityClassification.Tune, WriteAuthzPolicy.RoleWriteTune)]
|
||||||
|
[InlineData(SecurityClassification.VerifiedWrite, WriteAuthzPolicy.RoleWriteConfigure)]
|
||||||
|
[InlineData(SecurityClassification.Configure, WriteAuthzPolicy.RoleWriteConfigure)]
|
||||||
|
public void RequiredRole_returns_expected_role_for_classification(SecurityClassification c, string expected)
|
||||||
|
{
|
||||||
|
WriteAuthzPolicy.RequiredRole(c).ShouldBe(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(SecurityClassification.FreeAccess)]
|
||||||
|
[InlineData(SecurityClassification.ViewOnly)]
|
||||||
|
public void RequiredRole_returns_null_for_special_classifications(SecurityClassification c)
|
||||||
|
{
|
||||||
|
WriteAuthzPolicy.RequiredRole(c).ShouldBeNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user