Compare commits
34 Commits
phase-3-pr
...
phase-3-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf329b05d8 | ||
| 2584379e75 | |||
|
|
ef2a810b2d | ||
| a7764e50f3 | |||
|
|
8464e3f376 | ||
| a9357600e7 | |||
|
|
2f00c74bbb | ||
| 5d5e1f9650 | |||
|
|
4886a5783f | ||
| d70a2e0077 | |||
|
|
cb7b81a87a | ||
| 901d2b8019 | |||
|
|
d5fa1f450e | ||
| 6fdaee3a71 | |||
|
|
ed88835d34 | ||
| 5389d4d22d | |||
|
|
b5f8661e98 | ||
| 4058b88784 | |||
|
|
6b04a85f86 | ||
| cd8691280a | |||
|
|
77d09bf64e | ||
| 163c821e74 | |||
|
|
eea31dcc4e | ||
| 8a692d4ba8 | |||
|
|
268b12edec | ||
| edce1be742 | |||
|
|
18b3e24710 | ||
| f6a12dafe9 | |||
|
|
058c3dddd3 | ||
| 52791952dd | |||
|
|
860deb8e0d | ||
| f5e7173de3 | |||
|
|
22d3b0d23c | ||
| 55696a8750 |
@@ -8,6 +8,7 @@
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.UI/ZB.MOM.WW.OtOpcUa.Client.UI.csproj"/>
|
||||
@@ -22,6 +23,8 @@
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests.csproj"/>
|
||||
|
||||
@@ -348,6 +348,44 @@ The project uses [GLAuth](https://github.com/glauth/glauth) v2.4.0 as the LDAP s
|
||||
|
||||
Enable LDAP in `appsettings.json` under `Authentication.Ldap`. See [Configuration Guide](Configuration.md) for the full property reference.
|
||||
|
||||
### Active Directory configuration
|
||||
|
||||
Production deployments typically point at Active Directory instead of GLAuth. Only four properties differ from the dev defaults: `Server`, `Port`, `UserNameAttribute`, and `ServiceAccountDn`. The same `GroupToRole` mechanism works — map your AD security groups to OPC UA roles.
|
||||
|
||||
```json
|
||||
{
|
||||
"OpcUaServer": {
|
||||
"Ldap": {
|
||||
"Enabled": true,
|
||||
"Server": "dc01.corp.example.com",
|
||||
"Port": 636,
|
||||
"UseTls": true,
|
||||
"AllowInsecureLdap": false,
|
||||
"SearchBase": "DC=corp,DC=example,DC=com",
|
||||
"ServiceAccountDn": "CN=OpcUaSvc,OU=Service Accounts,DC=corp,DC=example,DC=com",
|
||||
"ServiceAccountPassword": "<from your secret store>",
|
||||
"DisplayNameAttribute": "displayName",
|
||||
"GroupAttribute": "memberOf",
|
||||
"UserNameAttribute": "sAMAccountName",
|
||||
"GroupToRole": {
|
||||
"OPCUA-Operators": "WriteOperate",
|
||||
"OPCUA-Engineers": "WriteConfigure",
|
||||
"OPCUA-AlarmAck": "AlarmAck",
|
||||
"OPCUA-Tuners": "WriteTune"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `UserNameAttribute: "sAMAccountName"` is the critical AD override — the default `uid` is not populated on AD user entries, so the user-DN lookup returns no results without it. Use `userPrincipalName` instead if operators log in with `user@corp.example.com` form.
|
||||
- `Port: 636` + `UseTls: true` is required under AD's LDAP-signing enforcement. AD increasingly rejects plain-LDAP bind; set `AllowInsecureLdap: false` to refuse fallback.
|
||||
- `ServiceAccountDn` should name a dedicated read-only service principal — not a privileged admin. The account needs read access to user and group entries in the search base.
|
||||
- `memberOf` values come back as full DNs like `CN=OPCUA-Operators,OU=OPC UA Security Groups,OU=Groups,DC=corp,DC=example,DC=com`. The authenticator strips the leading `CN=` RDN value so operators configure `GroupToRole` with readable group common-names.
|
||||
- Nested group membership is **not** expanded — assign users directly to the role-mapped groups, or pre-flatten membership in AD. `LDAP_MATCHING_RULE_IN_CHAIN` / `tokenGroups` expansion is an authenticator enhancement, not a config change.
|
||||
|
||||
### Security Considerations
|
||||
|
||||
- LDAP credentials are transmitted in plaintext over the OPC UA channel unless transport security is enabled. Use `Basic256Sha256-SignAndEncrypt` for production deployments.
|
||||
|
||||
136
docs/v2/lmx-followups.md
Normal file
136
docs/v2/lmx-followups.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# LMX Galaxy bridge — remaining follow-ups
|
||||
|
||||
State after PR 19: the Galaxy driver is functionally at v1 parity through the
|
||||
`IDriver` abstraction; the OPC UA server runs with LDAP-authenticated
|
||||
Basic256Sha256 endpoints and alarms are observable through
|
||||
`AlarmConditionState.ReportEvent`. The items below are what remains LMX-
|
||||
specific before the stack can fully replace the v1 deployment, in
|
||||
rough priority order.
|
||||
|
||||
## 1. Proxy-side `IHistoryProvider` for `ReadAtTime` / `ReadEvents`
|
||||
|
||||
**Status**: Capability surface complete (PR 35). OPC UA HistoryRead service-handler
|
||||
wiring in `DriverNodeManager` remains as the next step; integration-test still
|
||||
pending.
|
||||
|
||||
PR 35 extended `IHistoryProvider` with `ReadAtTimeAsync` + `ReadEventsAsync`
|
||||
(default throwing implementations so existing impls keep compiling), added the
|
||||
`HistoricalEvent` + `HistoricalEventsResult` records to
|
||||
`Core.Abstractions`, and implemented both methods in `GalaxyProxyDriver` on top
|
||||
of the PR 10 / PR 11 IPC messages. Wire-to-domain mapping (`ToHistoricalEvent`)
|
||||
is unit-tested for field fidelity, null-preservation, and `DateTimeKind.Utc`.
|
||||
|
||||
**Remaining**:
|
||||
- `DriverNodeManager` wires the new capability methods onto `HistoryRead`
|
||||
`AtTime` + `Events` service handlers.
|
||||
- Integration test: OPC UA client calls `HistoryReadAtTime` / `HistoryReadEvents`,
|
||||
value flows through IPC to the Host's `HistorianDataSource`, back to the client.
|
||||
|
||||
## 2. Write-gating by role — **DONE (PR 26)**
|
||||
|
||||
Landed in PR 26. `WriteAuthzPolicy` in `Server/Security/` maps
|
||||
`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`.
|
||||
|
||||
See `feedback_acl_at_server_layer.md` in memory for the architectural directive
|
||||
that authz stays at the server layer and never delegates to driver-specific auth.
|
||||
|
||||
## 3. Admin UI client-cert trust management — **DONE (PR 28)**
|
||||
|
||||
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.
|
||||
|
||||
Operator actions: Trust (moves `rejected/certs/*.der` → `trusted/certs/*.der`),
|
||||
Delete rejected, Revoke trust. The OPC UA stack re-reads the trusted store on
|
||||
each new client handshake, so no explicit reload signal is needed —
|
||||
operators retry the rejected client's connection after trusting.
|
||||
|
||||
Deferred: flipping `AutoAcceptUntrustedClientCertificates` to `false` as the
|
||||
deployment default. That's a production-hardening config change, not a code
|
||||
gap — the Admin UI is now ready to be the trust gate.
|
||||
|
||||
## 4. Live-LDAP integration test — **DONE (PR 31)**
|
||||
|
||||
PR 31 shipped `Server.Tests/LdapUserAuthenticatorLiveTests.cs` — 6 live-bind
|
||||
tests against the dev GLAuth instance at `localhost:3893`, skipped cleanly
|
||||
when the port is unreachable. Covers: valid bind, wrong password, unknown
|
||||
user, empty credentials, single-group → WriteOperate mapping, multi-group
|
||||
admin user surfacing all mapped roles.
|
||||
|
||||
Also added `UserNameAttribute` to `LdapOptions` (default `uid` for RFC 2307
|
||||
compat) so Active Directory deployments can configure `sAMAccountName` /
|
||||
`userPrincipalName` without code changes. `LdapUserAuthenticatorAdCompatTests`
|
||||
(5 unit guards) pins the AD-shape DN parsing + filter escape behaviors. See
|
||||
`docs/security.md` §"Active Directory configuration" for the AD appsettings
|
||||
snippet.
|
||||
|
||||
Deferred: asserting `session.Identity` end-to-end on the server side (i.e.
|
||||
drive a full OPC UA session with username/password, then read an
|
||||
`IHostConnectivityProbe`-style "whoami" node to verify the role surfaced).
|
||||
That needs a test-only address-space node and is a separate PR.
|
||||
|
||||
## 5. Full Galaxy live-service smoke test against the merged v2 stack
|
||||
|
||||
**Status**: Individual pieces have live smoke tests (PR 5 MXAccess, PR 13
|
||||
probe manager, PR 14 alarm tracker), but the full loop — OPC UA client →
|
||||
`OtOpcUaServer` → `GalaxyProxyDriver` (in-process) → named-pipe to
|
||||
Galaxy.Host subprocess → live MXAccess runtime → real Galaxy objects — has
|
||||
no single end-to-end smoke test.
|
||||
|
||||
**To do**:
|
||||
- Test that spawns the full topology, discovers a deployed Galaxy object,
|
||||
subscribes to one of its attributes, writes a value back, and asserts the
|
||||
write round-tripped through MXAccess. Skip when ArchestrA isn't running.
|
||||
|
||||
## 6. Second driver instance on the same server — **DONE (PR 32)**
|
||||
|
||||
`Server.Tests/MultipleDriverInstancesIntegrationTests.cs` registers two
|
||||
drivers with distinct `DriverInstanceId`s on one `DriverHost`, spins up the
|
||||
full OPC UA server, and asserts three behaviors: (1) each driver's namespace
|
||||
URI (`urn:OtOpcUa:{id}`) resolves to a distinct index in the client's
|
||||
NamespaceUris, (2) browsing one subtree returns that driver's folder and
|
||||
does NOT leak the other driver's folder, (3) reads route to the correct
|
||||
driver — the alpha instance returns 42 while beta returns 99, so a misroute
|
||||
would surface at the assertion layer.
|
||||
|
||||
Deferred: the alarm-event multi-driver parity case (two drivers each raising
|
||||
a `GalaxyAlarmEvent`, assert each condition lands on its owning instance's
|
||||
condition node). Alarm tracking already has its own integration test
|
||||
(`AlarmSubscription*`); the multi-driver alarm case would need a stub
|
||||
`IAlarmSource` that's worth its own focused PR.
|
||||
|
||||
## 7. Host-status per-AppEngine granularity → Admin UI dashboard — **DONE (PRs 33 + 34)**
|
||||
|
||||
**PR 33** landed the data layer: `DriverHostStatus` entity + migration with
|
||||
composite key `(NodeId, DriverInstanceId, HostName)` and two query-supporting
|
||||
indexes (per-cluster drill-down on `NodeId`, stale-row detection on
|
||||
`LastSeenUtc`).
|
||||
|
||||
**PR 34** wired the publisher + consumer. `HostStatusPublisher` is a
|
||||
`BackgroundService` in the Server process that walks every registered
|
||||
`IHostConnectivityProbe`-capable driver every 10s, calls
|
||||
`GetHostStatuses()`, and upserts rows (`LastSeenUtc` advances each tick;
|
||||
`State` + `StateChangedUtc` update on transitions). Admin UI `/hosts` page
|
||||
groups by cluster, shows four summary cards (Hosts / Running / Stale /
|
||||
Faulted), and flags rows whose `LastSeenUtc` is older than 30s as Stale so
|
||||
operators see crashed Servers without waiting for a state change.
|
||||
|
||||
Deferred as follow-ups:
|
||||
|
||||
- Event-driven push (subscribe to `OnHostStatusChanged` per driver for
|
||||
sub-heartbeat latency). Adds DriverHost lifecycle-event plumbing;
|
||||
10s polling is fine for operator-scale use.
|
||||
- Failure-count column — needs the publisher to track a transition history
|
||||
per host, not just current-state.
|
||||
- SignalR fan-out to the Admin page (currently the page polls the DB, not
|
||||
a hub). The DB-polled version is fine at current cadence but a hub push
|
||||
would eliminate the 10s race where a new row sits in the DB before the
|
||||
Admin page notices.
|
||||
108
docs/v2/modbus-test-plan.md
Normal file
108
docs/v2/modbus-test-plan.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# 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. The project it describes lives at
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/` — scaffolded in PR 30 with
|
||||
the simulator fixture, DL205 profile stub, and one write/read smoke test. Each
|
||||
confirmed DL205 quirk lands in a follow-up PR as a named test in that project.
|
||||
|
||||
## 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 30 — Integration test project + DL205 profile scaffold** — **DONE**.
|
||||
Shipped `tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests` with
|
||||
`ModbusSimulatorFixture` (TCP-probe, skips with a clear `SkipReason` when the
|
||||
endpoint is unreachable), `DL205/DL205Profile.cs` (tag map stub — one
|
||||
writable holding register at address 100), and `DL205/DL205SmokeTests.cs`
|
||||
(write-then-read round-trip). `ModbusPal/` directory holds the README
|
||||
pointing at the to-be-committed `DL205.xmpp` profile.
|
||||
- **PR 31+**: one PR per confirmed DL205 quirk, landing the named test + any
|
||||
driver-side adjustment (e.g., retry on dropped TxId) needed to pass it. Drop
|
||||
the `DL205.xmpp` profile into `ModbusPal/` alongside the first quirk PR.
|
||||
@@ -5,15 +5,18 @@
|
||||
<h5 class="mb-4">OtOpcUa Admin</h5>
|
||||
<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="/fleet">Fleet status</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-light" href="/hosts">Host 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="/reservations">Reservations</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-light" href="/certificates">Certificates</a></li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-5">
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<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 class="small text-muted">
|
||||
@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);
|
||||
}
|
||||
160
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Hosts.razor
Normal file
160
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Hosts.razor
Normal file
@@ -0,0 +1,160 @@
|
||||
@page "/hosts"
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@inject IServiceScopeFactory ScopeFactory
|
||||
@implements IDisposable
|
||||
|
||||
<h1 class="mb-4">Driver host 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>
|
||||
|
||||
<div class="alert alert-info small mb-4">
|
||||
Each row is one host reported by a driver instance on a server node. Galaxy drivers report
|
||||
per-Platform / per-AppEngine entries; Modbus drivers report the PLC endpoint. Rows age out
|
||||
of the Server's publisher on every 10-second heartbeat — rows whose LastSeen is older than
|
||||
30s are flagged Stale, which usually means the owning Server process has crashed or lost
|
||||
its DB connection.
|
||||
</div>
|
||||
|
||||
@if (_rows is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (_rows.Count == 0)
|
||||
{
|
||||
<div class="alert alert-secondary">
|
||||
No host-status rows yet. The Server publishes its first tick 2s after startup; if this list stays empty, check that the Server is running and the driver implements <code>IHostConnectivityProbe</code>.
|
||||
</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">Hosts</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">Running</h6>
|
||||
<div class="fs-3 text-success">@_rows.Count(r => r.State == DriverHostState.Running && !HostStatusService.IsStale(r))</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(HostStatusService.IsStale)</div>
|
||||
</div></div></div>
|
||||
<div class="col-md-3"><div class="card border-danger"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Faulted</h6>
|
||||
<div class="fs-3 text-danger">@_rows.Count(r => r.State == DriverHostState.Faulted)</div>
|
||||
</div></div></div>
|
||||
</div>
|
||||
|
||||
@foreach (var cluster in _rows.GroupBy(r => r.ClusterId ?? "(unassigned)").OrderBy(g => g.Key))
|
||||
{
|
||||
<h2 class="h5 mt-4">Cluster: <code>@cluster.Key</code></h2>
|
||||
<table class="table table-sm table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Driver</th>
|
||||
<th>Host</th>
|
||||
<th>State</th>
|
||||
<th>Last transition</th>
|
||||
<th>Last seen</th>
|
||||
<th>Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var r in cluster)
|
||||
{
|
||||
<tr class="@RowClass(r)">
|
||||
<td><code>@r.NodeId</code></td>
|
||||
<td><code>@r.DriverInstanceId</code></td>
|
||||
<td>@r.HostName</td>
|
||||
<td>
|
||||
<span class="badge @StateBadge(r.State)">@r.State</span>
|
||||
@if (HostStatusService.IsStale(r))
|
||||
{
|
||||
<span class="badge bg-warning text-dark ms-1">Stale</span>
|
||||
}
|
||||
</td>
|
||||
<td class="small">@FormatAge(r.StateChangedUtc)</td>
|
||||
<td class="small @(HostStatusService.IsStale(r) ? "text-warning" : "")">@FormatAge(r.LastSeenUtc)</td>
|
||||
<td class="text-truncate small" style="max-width: 320px;" title="@r.Detail">@r.Detail</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
// Mirrors HostStatusPublisher.HeartbeatInterval — polling ahead of the broadcaster
|
||||
// produces stale-looking rows mid-cycle.
|
||||
private const int RefreshIntervalSeconds = 10;
|
||||
|
||||
private List<HostStatusRow>? _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 svc = scope.ServiceProvider.GetRequiredService<HostStatusService>();
|
||||
_rows = (await svc.ListAsync()).ToList();
|
||||
_lastRefreshUtc = DateTime.UtcNow;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_refreshing = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private static string RowClass(HostStatusRow r) => r.State switch
|
||||
{
|
||||
DriverHostState.Faulted => "table-danger",
|
||||
_ when HostStatusService.IsStale(r) => "table-warning",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
private static string StateBadge(DriverHostState s) => s switch
|
||||
{
|
||||
DriverHostState.Running => "bg-success",
|
||||
DriverHostState.Stopped => "bg-secondary",
|
||||
DriverHostState.Faulted => "bg-danger",
|
||||
_ => "bg-secondary",
|
||||
};
|
||||
|
||||
private static string FormatAge(DateTime t)
|
||||
{
|
||||
var age = DateTime.UtcNow - t;
|
||||
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.ToString("yyyy-MM-dd HH:mm 'UTC'");
|
||||
}
|
||||
|
||||
public void Dispose() => _timer?.Dispose();
|
||||
}
|
||||
@@ -47,6 +47,13 @@ builder.Services.AddScoped<NodeAclService>();
|
||||
builder.Services.AddScoped<ReservationService>();
|
||||
builder.Services.AddScoped<DraftValidationService>();
|
||||
builder.Services.AddScoped<AuditLogService>();
|
||||
builder.Services.AddScoped<HostStatusService>();
|
||||
|
||||
// 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).
|
||||
builder.Services.Configure<LdapOptions>(
|
||||
|
||||
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");
|
||||
}
|
||||
63
src/ZB.MOM.WW.OtOpcUa.Admin/Services/HostStatusService.cs
Normal file
63
src/ZB.MOM.WW.OtOpcUa.Admin/Services/HostStatusService.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// One row per <see cref="DriverHostStatus"/> record, enriched with the owning
|
||||
/// <c>ClusterNode.ClusterId</c> when available (left-join). The Admin <c>/hosts</c> page
|
||||
/// groups by cluster and renders a per-node → per-driver → per-host tree.
|
||||
/// </summary>
|
||||
public sealed record HostStatusRow(
|
||||
string NodeId,
|
||||
string? ClusterId,
|
||||
string DriverInstanceId,
|
||||
string HostName,
|
||||
DriverHostState State,
|
||||
DateTime StateChangedUtc,
|
||||
DateTime LastSeenUtc,
|
||||
string? Detail);
|
||||
|
||||
/// <summary>
|
||||
/// Read-side service for the Admin UI's per-host drill-down. Loads
|
||||
/// <see cref="DriverHostStatus"/> rows (written by the Server process's
|
||||
/// <c>HostStatusPublisher</c>) and left-joins <c>ClusterNode</c> so each row knows which
|
||||
/// cluster it belongs to — the Admin UI groups by cluster for the fleet-wide view.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The publisher heartbeat is 10s (<c>HostStatusPublisher.HeartbeatInterval</c>). The
|
||||
/// Admin page also polls every ~10s and treats rows with <c>LastSeenUtc</c> older than
|
||||
/// <c>StaleThreshold</c> (30s) as stale — covers a missed heartbeat tolerance plus
|
||||
/// a generous buffer for clock skew and publisher GC pauses.
|
||||
/// </remarks>
|
||||
public sealed class HostStatusService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public static readonly TimeSpan StaleThreshold = TimeSpan.FromSeconds(30);
|
||||
|
||||
public async Task<IReadOnlyList<HostStatusRow>> ListAsync(CancellationToken ct = default)
|
||||
{
|
||||
// LEFT JOIN on NodeId so a row persists even when its owning ClusterNode row hasn't
|
||||
// been created yet (first-boot bootstrap case — keeps the UI from losing sight of
|
||||
// the reporting server).
|
||||
var rows = await (from s in db.DriverHostStatuses.AsNoTracking()
|
||||
join n in db.ClusterNodes.AsNoTracking()
|
||||
on s.NodeId equals n.NodeId into nodeJoin
|
||||
from n in nodeJoin.DefaultIfEmpty()
|
||||
orderby s.NodeId, s.DriverInstanceId, s.HostName
|
||||
select new HostStatusRow(
|
||||
s.NodeId,
|
||||
n != null ? n.ClusterId : null,
|
||||
s.DriverInstanceId,
|
||||
s.HostName,
|
||||
s.State,
|
||||
s.StateChangedUtc,
|
||||
s.LastSeenUtc,
|
||||
s.Detail)).ToListAsync(ct);
|
||||
return rows;
|
||||
}
|
||||
|
||||
public static bool IsStale(HostStatusRow row) =>
|
||||
DateTime.UtcNow - row.LastSeenUtc > StaleThreshold;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Per-host connectivity snapshot the Server publishes for each driver's
|
||||
/// <c>IHostConnectivityProbe.GetHostStatuses</c> entry. One row per
|
||||
/// (<see cref="NodeId"/>, <see cref="DriverInstanceId"/>, <see cref="HostName"/>) triple —
|
||||
/// a redundant 2-node cluster with one Galaxy driver reporting 3 platforms produces 6
|
||||
/// rows, not 3, because each server node owns its own runtime view.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Closes the data-layer piece of LMX follow-up #7 (per-AppEngine Admin dashboard
|
||||
/// drill-down). The publisher hosted service on the Server side subscribes to every
|
||||
/// registered driver's <c>OnHostStatusChanged</c> and upserts rows on transitions +
|
||||
/// periodic liveness heartbeats. <see cref="LastSeenUtc"/> advances on every
|
||||
/// heartbeat so the Admin UI can flag stale rows from a crashed Server.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// No foreign-key to <see cref="ClusterNode"/> — a Server may start reporting host
|
||||
/// status before its ClusterNode row exists (e.g. first-boot bootstrap), and we'd
|
||||
/// rather keep the status row than drop it. The Admin-side service left-joins on
|
||||
/// NodeId when presenting rows.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class DriverHostStatus
|
||||
{
|
||||
/// <summary>Server node that's running the driver.</summary>
|
||||
public required string NodeId { get; set; }
|
||||
|
||||
/// <summary>Driver instance's stable id (matches <c>IDriver.DriverInstanceId</c>).</summary>
|
||||
public required string DriverInstanceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Driver-side host identifier — Galaxy Platform / AppEngine name, Modbus
|
||||
/// <c>host:port</c>, whatever the probe returns. Opaque to the Admin UI except as
|
||||
/// a display string.
|
||||
/// </summary>
|
||||
public required string HostName { get; set; }
|
||||
|
||||
public DriverHostState State { get; set; } = DriverHostState.Unknown;
|
||||
|
||||
/// <summary>Timestamp of the last state transition (not of the most recent heartbeat).</summary>
|
||||
public DateTime StateChangedUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Advances on every publisher heartbeat — the Admin UI uses
|
||||
/// <c>now - LastSeenUtc > threshold</c> to flag rows whose owning Server has
|
||||
/// stopped reporting (crashed, network-partitioned, etc.), independent of
|
||||
/// <see cref="State"/>.
|
||||
/// </summary>
|
||||
public DateTime LastSeenUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional human-readable detail populated when <see cref="State"/> is
|
||||
/// <see cref="DriverHostState.Faulted"/> — e.g. the exception message from the
|
||||
/// driver's probe. Null for Running / Stopped / Unknown transitions.
|
||||
/// </summary>
|
||||
public string? Detail { get; set; }
|
||||
}
|
||||
21
src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/DriverHostState.cs
Normal file
21
src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/DriverHostState.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Persisted mirror of <c>Core.Abstractions.HostState</c> — the lifecycle state each
|
||||
/// <c>IHostConnectivityProbe</c>-capable driver reports for its per-host topology
|
||||
/// (Galaxy Platforms / AppEngines, Modbus PLC endpoints, future OPC UA gateway upstreams).
|
||||
/// Defined here instead of re-using <c>Core.Abstractions.HostState</c> so the
|
||||
/// Configuration project stays free of driver-runtime dependencies.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The server-side publisher (follow-up PR) translates
|
||||
/// <c>HostStatusChangedEventArgs.NewState</c> to this enum on every transition and
|
||||
/// upserts into <see cref="Entities.DriverHostStatus"/>. Admin UI reads from the DB.
|
||||
/// </remarks>
|
||||
public enum DriverHostState
|
||||
{
|
||||
Unknown,
|
||||
Running,
|
||||
Stopped,
|
||||
Faulted,
|
||||
}
|
||||
1248
src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260418193608_AddDriverHostStatus.Designer.cs
generated
Normal file
1248
src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260418193608_AddDriverHostStatus.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddDriverHostStatus : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DriverHostStatus",
|
||||
columns: table => new
|
||||
{
|
||||
NodeId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
HostName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
|
||||
State = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
|
||||
StateChangedUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: false),
|
||||
LastSeenUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: false),
|
||||
Detail = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DriverHostStatus", x => new { x.NodeId, x.DriverInstanceId, x.HostName });
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DriverHostStatus_LastSeen",
|
||||
table: "DriverHostStatus",
|
||||
column: "LastSeenUtc");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DriverHostStatus_Node",
|
||||
table: "DriverHostStatus",
|
||||
column: "NodeId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "DriverHostStatus");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -332,6 +332,46 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverHostStatus", b =>
|
||||
{
|
||||
b.Property<string>("NodeId")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("DriverInstanceId")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("HostName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<string>("Detail")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("nvarchar(1024)");
|
||||
|
||||
b.Property<DateTime>("LastSeenUtc")
|
||||
.HasColumnType("datetime2(3)");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("nvarchar(16)");
|
||||
|
||||
b.Property<DateTime>("StateChangedUtc")
|
||||
.HasColumnType("datetime2(3)");
|
||||
|
||||
b.HasKey("NodeId", "DriverInstanceId", "HostName");
|
||||
|
||||
b.HasIndex("LastSeenUtc")
|
||||
.HasDatabaseName("IX_DriverHostStatus_LastSeen");
|
||||
|
||||
b.HasIndex("NodeId")
|
||||
.HasDatabaseName("IX_DriverHostStatus_Node");
|
||||
|
||||
b.ToTable("DriverHostStatus", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b =>
|
||||
{
|
||||
b.Property<Guid>("DriverInstanceRowId")
|
||||
|
||||
@@ -27,6 +27,7 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
||||
public DbSet<ClusterNodeGenerationState> ClusterNodeGenerationStates => Set<ClusterNodeGenerationState>();
|
||||
public DbSet<ConfigAuditLog> ConfigAuditLogs => Set<ConfigAuditLog>();
|
||||
public DbSet<ExternalIdReservation> ExternalIdReservations => Set<ExternalIdReservation>();
|
||||
public DbSet<DriverHostStatus> DriverHostStatuses => Set<DriverHostStatus>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -47,6 +48,7 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
||||
ConfigureClusterNodeGenerationState(modelBuilder);
|
||||
ConfigureConfigAuditLog(modelBuilder);
|
||||
ConfigureExternalIdReservation(modelBuilder);
|
||||
ConfigureDriverHostStatus(modelBuilder);
|
||||
}
|
||||
|
||||
private static void ConfigureServerCluster(ModelBuilder modelBuilder)
|
||||
@@ -484,4 +486,30 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
||||
e.HasIndex(x => x.EquipmentUuid).HasDatabaseName("IX_ExternalIdReservation_Equipment");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureDriverHostStatus(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<DriverHostStatus>(e =>
|
||||
{
|
||||
e.ToTable("DriverHostStatus");
|
||||
// Composite key — one row per (server node, driver instance, probe-reported host).
|
||||
// A redundant 2-node cluster with one Galaxy driver reporting 3 platforms produces
|
||||
// 6 rows because each server node owns its own runtime view; the composite key is
|
||||
// what lets both views coexist without shadowing each other.
|
||||
e.HasKey(x => new { x.NodeId, x.DriverInstanceId, x.HostName });
|
||||
e.Property(x => x.NodeId).HasMaxLength(64);
|
||||
e.Property(x => x.DriverInstanceId).HasMaxLength(64);
|
||||
e.Property(x => x.HostName).HasMaxLength(256);
|
||||
e.Property(x => x.State).HasConversion<string>().HasMaxLength(16);
|
||||
e.Property(x => x.StateChangedUtc).HasColumnType("datetime2(3)");
|
||||
e.Property(x => x.LastSeenUtc).HasColumnType("datetime2(3)");
|
||||
e.Property(x => x.Detail).HasMaxLength(1024);
|
||||
|
||||
// NodeId-only index drives the Admin UI's per-cluster drill-down (select all host
|
||||
// statuses for the nodes of a specific cluster via join on ClusterNode.ClusterId).
|
||||
e.HasIndex(x => x.NodeId).HasDatabaseName("IX_DriverHostStatus_Node");
|
||||
// LastSeenUtc index powers the Admin UI's stale-row query (now - LastSeen > N).
|
||||
e.HasIndex(x => x.LastSeenUtc).HasDatabaseName("IX_DriverHostStatus_LastSeen");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,52 @@ public interface IHistoryProvider
|
||||
TimeSpan interval,
|
||||
HistoryAggregateType aggregate,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Read one sample per requested timestamp — OPC UA HistoryReadAtTime service. The
|
||||
/// driver interpolates (or returns the prior-boundary sample) when no exact match
|
||||
/// exists. Optional; drivers that can't interpolate throw <see cref="NotSupportedException"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Default implementation throws. Drivers opt in by overriding; keeps existing
|
||||
/// <c>IHistoryProvider</c> implementations compiling without forcing a ReadAtTime path
|
||||
/// they may not have a backend for.
|
||||
/// </remarks>
|
||||
Task<HistoryReadResult> ReadAtTimeAsync(
|
||||
string fullReference,
|
||||
IReadOnlyList<DateTime> timestampsUtc,
|
||||
CancellationToken cancellationToken)
|
||||
=> throw new NotSupportedException(
|
||||
$"{GetType().Name} does not implement ReadAtTimeAsync. " +
|
||||
"Drivers whose backends support at-time reads override this method.");
|
||||
|
||||
/// <summary>
|
||||
/// Read historical alarm/event records — OPC UA HistoryReadEvents service. Distinct
|
||||
/// from the live event stream — historical rows come from an event historian (Galaxy's
|
||||
/// Alarm Provider history log, etc.) rather than the driver's active subscription.
|
||||
/// </summary>
|
||||
/// <param name="sourceName">
|
||||
/// Optional filter: null means "all sources", otherwise restrict to events from that
|
||||
/// source-object name. Drivers may ignore the filter if the backend doesn't support it.
|
||||
/// </param>
|
||||
/// <param name="startUtc">Inclusive lower bound on <c>EventTimeUtc</c>.</param>
|
||||
/// <param name="endUtc">Exclusive upper bound on <c>EventTimeUtc</c>.</param>
|
||||
/// <param name="maxEvents">Upper cap on returned events — the driver's backend enforces this.</param>
|
||||
/// <param name="cancellationToken">Request cancellation.</param>
|
||||
/// <remarks>
|
||||
/// Default implementation throws. Only drivers with an event historian (Galaxy via the
|
||||
/// Wonderware Alarm & Events log) override. Modbus / the OPC UA Client driver stay
|
||||
/// with the default and let callers see <c>BadHistoryOperationUnsupported</c>.
|
||||
/// </remarks>
|
||||
Task<HistoricalEventsResult> ReadEventsAsync(
|
||||
string? sourceName,
|
||||
DateTime startUtc,
|
||||
DateTime endUtc,
|
||||
int maxEvents,
|
||||
CancellationToken cancellationToken)
|
||||
=> throw new NotSupportedException(
|
||||
$"{GetType().Name} does not implement ReadEventsAsync. " +
|
||||
"Drivers whose backends have an event historian override this method.");
|
||||
}
|
||||
|
||||
/// <summary>Result of a HistoryRead call.</summary>
|
||||
@@ -48,3 +94,29 @@ public enum HistoryAggregateType
|
||||
Total,
|
||||
Count,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One row returned by <see cref="IHistoryProvider.ReadEventsAsync"/> — a historical
|
||||
/// alarm/event record, not the OPC UA live-event stream. Fields match the minimum set the
|
||||
/// Server needs to populate a <c>HistoryEventFieldList</c> for HistoryReadEvents responses.
|
||||
/// </summary>
|
||||
/// <param name="EventId">Stable unique id for the event — driver-specific format.</param>
|
||||
/// <param name="SourceName">Source object that emitted the event. May differ from the <c>sourceName</c> filter the caller passed (fuzzy matches).</param>
|
||||
/// <param name="EventTimeUtc">Process-side timestamp — when the event actually occurred.</param>
|
||||
/// <param name="ReceivedTimeUtc">Historian-side timestamp — when the historian persisted the row; may lag <paramref name="EventTimeUtc"/> by the historian's buffer flush cadence.</param>
|
||||
/// <param name="Message">Human-readable message text.</param>
|
||||
/// <param name="Severity">OPC UA severity (1-1000). Drivers map their native priority scale onto this range.</param>
|
||||
public sealed record HistoricalEvent(
|
||||
string EventId,
|
||||
string? SourceName,
|
||||
DateTime EventTimeUtc,
|
||||
DateTime ReceivedTimeUtc,
|
||||
string? Message,
|
||||
ushort Severity);
|
||||
|
||||
/// <summary>Result of a <see cref="IHistoryProvider.ReadEventsAsync"/> call.</summary>
|
||||
/// <param name="Events">Events in chronological order by <c>EventTimeUtc</c>.</param>
|
||||
/// <param name="ContinuationPoint">Opaque token for the next call when more events are available; null when complete.</param>
|
||||
public sealed record HistoricalEventsResult(
|
||||
IReadOnlyList<HistoricalEvent> Events,
|
||||
byte[]? ContinuationPoint);
|
||||
|
||||
@@ -339,6 +339,64 @@ public sealed class GalaxyProxyDriver(GalaxyProxyOptions options)
|
||||
return new HistoryReadResult(samples, ContinuationPoint: null);
|
||||
}
|
||||
|
||||
public async Task<HistoryReadResult> ReadAtTimeAsync(
|
||||
string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = RequireClient();
|
||||
var resp = await client.CallAsync<HistoryReadAtTimeRequest, HistoryReadAtTimeResponse>(
|
||||
MessageKind.HistoryReadAtTimeRequest,
|
||||
new HistoryReadAtTimeRequest
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TagReference = fullReference,
|
||||
TimestampsUtcUnixMs = [.. timestampsUtc.Select(t => new DateTimeOffset(t, TimeSpan.Zero).ToUnixTimeMilliseconds())],
|
||||
},
|
||||
MessageKind.HistoryReadAtTimeResponse,
|
||||
cancellationToken);
|
||||
|
||||
if (!resp.Success)
|
||||
throw new InvalidOperationException($"Galaxy.Host HistoryReadAtTime failed: {resp.Error}");
|
||||
|
||||
// ReadAtTime returns one sample per requested timestamp in the same order — the Host
|
||||
// pads with bad-quality snapshots when a timestamp can't be interpolated, so response
|
||||
// length matches request length exactly. We trust that contract rather than
|
||||
// re-aligning here, because the Host is the source-of-truth for interpolation policy.
|
||||
IReadOnlyList<DataValueSnapshot> samples = [.. resp.Values.Select(ToSnapshot)];
|
||||
return new HistoryReadResult(samples, ContinuationPoint: null);
|
||||
}
|
||||
|
||||
public async Task<HistoricalEventsResult> ReadEventsAsync(
|
||||
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = RequireClient();
|
||||
var resp = await client.CallAsync<HistoryReadEventsRequest, HistoryReadEventsResponse>(
|
||||
MessageKind.HistoryReadEventsRequest,
|
||||
new HistoryReadEventsRequest
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
SourceName = sourceName,
|
||||
StartUtcUnixMs = new DateTimeOffset(startUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
||||
EndUtcUnixMs = new DateTimeOffset(endUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
||||
MaxEvents = maxEvents,
|
||||
},
|
||||
MessageKind.HistoryReadEventsResponse,
|
||||
cancellationToken);
|
||||
|
||||
if (!resp.Success)
|
||||
throw new InvalidOperationException($"Galaxy.Host HistoryReadEvents failed: {resp.Error}");
|
||||
|
||||
IReadOnlyList<HistoricalEvent> events = [.. resp.Events.Select(ToHistoricalEvent)];
|
||||
return new HistoricalEventsResult(events, ContinuationPoint: null);
|
||||
}
|
||||
|
||||
internal static HistoricalEvent ToHistoricalEvent(GalaxyHistoricalEvent wire) => new(
|
||||
EventId: wire.EventId,
|
||||
SourceName: wire.SourceName,
|
||||
EventTimeUtc: DateTimeOffset.FromUnixTimeMilliseconds(wire.EventTimeUtcUnixMs).UtcDateTime,
|
||||
ReceivedTimeUtc: DateTimeOffset.FromUnixTimeMilliseconds(wire.ReceivedTimeUtcUnixMs).UtcDateTime,
|
||||
Message: wire.DisplayText,
|
||||
Severity: wire.Severity);
|
||||
|
||||
/// <summary>
|
||||
/// Maps the OPC UA Part 13 aggregate enum onto the Wonderware Historian
|
||||
/// AnalogSummaryQuery column names consumed by <c>HistorianDataSource.ReadAggregateAsync</c>.
|
||||
|
||||
25
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/IModbusTransport.cs
Normal file
25
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/IModbusTransport.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over the Modbus TCP socket. Takes a <c>PDU</c> (function code + data, excluding
|
||||
/// the 7-byte MBAP header) and returns the response PDU — the transport owns transaction-id
|
||||
/// pairing, framing, and socket I/O. Tests supply in-memory fakes.
|
||||
/// </summary>
|
||||
public interface IModbusTransport : IAsyncDisposable
|
||||
{
|
||||
Task ConnectAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Send a Modbus PDU (function code + function-specific data) and read the response PDU.
|
||||
/// Throws <see cref="ModbusException"/> when the server returns an exception PDU
|
||||
/// (function code + 0x80 + exception code).
|
||||
/// </summary>
|
||||
Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed class ModbusException(byte functionCode, byte exceptionCode, string message)
|
||||
: Exception(message)
|
||||
{
|
||||
public byte FunctionCode { get; } = functionCode;
|
||||
public byte ExceptionCode { get; } = exceptionCode;
|
||||
}
|
||||
583
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
Normal file
583
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
Normal file
@@ -0,0 +1,583 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// Modbus TCP implementation of <see cref="IDriver"/> + <see cref="ITagDiscovery"/> +
|
||||
/// <see cref="IReadable"/> + <see cref="IWritable"/>. First native-protocol greenfield
|
||||
/// driver for the v2 stack — validates the driver-agnostic <c>IAddressSpaceBuilder</c> +
|
||||
/// <c>IReadable</c>/<c>IWritable</c> abstractions generalize beyond Galaxy.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Scope limits: synchronous Read/Write only, no subscriptions (Modbus has no push model;
|
||||
/// subscriptions would need a polling loop over the declared tags — additive PR). Historian
|
||||
/// + alarm capabilities are out of scope (the protocol doesn't express them).
|
||||
/// </remarks>
|
||||
public sealed class ModbusDriver(ModbusDriverOptions options, string driverInstanceId,
|
||||
Func<ModbusDriverOptions, IModbusTransport>? transportFactory = null)
|
||||
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IDisposable, IAsyncDisposable
|
||||
{
|
||||
// Active polling subscriptions. Each subscription owns a background Task that polls the
|
||||
// tags at its configured interval, diffs against _lastKnownValues, and fires OnDataChange
|
||||
// per changed tag. UnsubscribeAsync cancels the task via the CTS stored on the handle.
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<long, SubscriptionState> _subscriptions = new();
|
||||
private long _nextSubscriptionId;
|
||||
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
|
||||
// Single-host probe state — Modbus driver talks to exactly one endpoint so the "hosts"
|
||||
// collection has at most one entry. HostName is the Host:Port string so the Admin UI can
|
||||
// display the PLC endpoint uniformly with Galaxy platforms/engines.
|
||||
private readonly object _probeLock = new();
|
||||
private HostState _hostState = HostState.Unknown;
|
||||
private DateTime _hostStateChangedUtc = DateTime.UtcNow;
|
||||
private CancellationTokenSource? _probeCts;
|
||||
private readonly ModbusDriverOptions _options = options;
|
||||
private readonly Func<ModbusDriverOptions, IModbusTransport> _transportFactory =
|
||||
transportFactory ?? (o => new ModbusTcpTransport(o.Host, o.Port, o.Timeout));
|
||||
|
||||
private IModbusTransport? _transport;
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
private readonly Dictionary<string, ModbusTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public string DriverInstanceId => driverInstanceId;
|
||||
public string DriverType => "Modbus";
|
||||
|
||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||
try
|
||||
{
|
||||
_transport = _transportFactory(_options);
|
||||
await _transport.ConnectAsync(cancellationToken).ConfigureAwait(false);
|
||||
foreach (var t in _options.Tags) _tagsByName[t.Name] = t;
|
||||
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
|
||||
// PR 23: kick off the probe loop once the transport is up. Initial state stays
|
||||
// Unknown until the first probe tick succeeds — avoids broadcasting a premature
|
||||
// Running transition before any register round-trip has happened.
|
||||
if (_options.Probe.Enabled)
|
||||
{
|
||||
_probeCts = new CancellationTokenSource();
|
||||
_ = Task.Run(() => ProbeLoopAsync(_probeCts.Token), _probeCts.Token);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
await ShutdownAsync(cancellationToken);
|
||||
await InitializeAsync(driverConfigJson, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try { _probeCts?.Cancel(); } catch { }
|
||||
_probeCts?.Dispose();
|
||||
_probeCts = null;
|
||||
|
||||
foreach (var state in _subscriptions.Values)
|
||||
{
|
||||
try { state.Cts.Cancel(); } catch { }
|
||||
state.Cts.Dispose();
|
||||
}
|
||||
_subscriptions.Clear();
|
||||
|
||||
if (_transport is not null) await _transport.DisposeAsync().ConfigureAwait(false);
|
||||
_transport = null;
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
}
|
||||
|
||||
public DriverHealth GetHealth() => _health;
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
// ---- ITagDiscovery ----
|
||||
|
||||
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
var folder = builder.Folder("Modbus", "Modbus");
|
||||
foreach (var t in _options.Tags)
|
||||
{
|
||||
folder.Variable(t.Name, t.Name, new DriverAttributeInfo(
|
||||
FullName: t.Name,
|
||||
DriverDataType: MapDataType(t.DataType),
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: t.Writable ? SecurityClassification.Operate : SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false));
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ---- IReadable ----
|
||||
|
||||
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
var transport = RequireTransport();
|
||||
var now = DateTime.UtcNow;
|
||||
var results = new DataValueSnapshot[fullReferences.Count];
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
{
|
||||
if (!_tagsByName.TryGetValue(fullReferences[i], out var tag))
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, StatusBadNodeIdUnknown, null, now);
|
||||
continue;
|
||||
}
|
||||
try
|
||||
{
|
||||
var value = await ReadOneAsync(transport, tag, cancellationToken).ConfigureAwait(false);
|
||||
results[i] = new DataValueSnapshot(value, 0u, now, now);
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, StatusBadInternalError, null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task<object> ReadOneAsync(IModbusTransport transport, ModbusTagDefinition tag, CancellationToken ct)
|
||||
{
|
||||
switch (tag.Region)
|
||||
{
|
||||
case ModbusRegion.Coils:
|
||||
{
|
||||
var pdu = new byte[] { 0x01, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF), 0x00, 0x01 };
|
||||
var resp = await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
|
||||
return (resp[2] & 0x01) == 1;
|
||||
}
|
||||
case ModbusRegion.DiscreteInputs:
|
||||
{
|
||||
var pdu = new byte[] { 0x02, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF), 0x00, 0x01 };
|
||||
var resp = await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
|
||||
return (resp[2] & 0x01) == 1;
|
||||
}
|
||||
case ModbusRegion.HoldingRegisters:
|
||||
case ModbusRegion.InputRegisters:
|
||||
{
|
||||
var quantity = RegisterCount(tag);
|
||||
var fc = tag.Region == ModbusRegion.HoldingRegisters ? (byte)0x03 : (byte)0x04;
|
||||
var pdu = new byte[] { fc, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
|
||||
(byte)(quantity >> 8), (byte)(quantity & 0xFF) };
|
||||
var resp = await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
|
||||
// resp = [fc][byte-count][data...]
|
||||
var data = new ReadOnlySpan<byte>(resp, 2, resp[1]);
|
||||
return DecodeRegister(data, tag);
|
||||
}
|
||||
default:
|
||||
throw new InvalidOperationException($"Unknown region {tag.Region}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- IWritable ----
|
||||
|
||||
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
||||
{
|
||||
var transport = RequireTransport();
|
||||
var results = new WriteResult[writes.Count];
|
||||
for (var i = 0; i < writes.Count; i++)
|
||||
{
|
||||
var w = writes[i];
|
||||
if (!_tagsByName.TryGetValue(w.FullReference, out var tag))
|
||||
{
|
||||
results[i] = new WriteResult(StatusBadNodeIdUnknown);
|
||||
continue;
|
||||
}
|
||||
if (!tag.Writable || tag.Region is ModbusRegion.DiscreteInputs or ModbusRegion.InputRegisters)
|
||||
{
|
||||
results[i] = new WriteResult(StatusBadNotWritable);
|
||||
continue;
|
||||
}
|
||||
try
|
||||
{
|
||||
await WriteOneAsync(transport, tag, w.Value, cancellationToken).ConfigureAwait(false);
|
||||
results[i] = new WriteResult(0u);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
results[i] = new WriteResult(StatusBadInternalError);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task WriteOneAsync(IModbusTransport transport, ModbusTagDefinition tag, object? value, CancellationToken ct)
|
||||
{
|
||||
switch (tag.Region)
|
||||
{
|
||||
case ModbusRegion.Coils:
|
||||
{
|
||||
var on = Convert.ToBoolean(value);
|
||||
var pdu = new byte[] { 0x05, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
|
||||
on ? (byte)0xFF : (byte)0x00, 0x00 };
|
||||
await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
case ModbusRegion.HoldingRegisters:
|
||||
{
|
||||
var bytes = EncodeRegister(value, tag);
|
||||
if (bytes.Length == 2)
|
||||
{
|
||||
var pdu = new byte[] { 0x06, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
|
||||
bytes[0], bytes[1] };
|
||||
await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
// FC 16 (Write Multiple Registers) for 32-bit types
|
||||
var qty = (ushort)(bytes.Length / 2);
|
||||
var pdu = new byte[6 + 1 + bytes.Length];
|
||||
pdu[0] = 0x10;
|
||||
pdu[1] = (byte)(tag.Address >> 8); pdu[2] = (byte)(tag.Address & 0xFF);
|
||||
pdu[3] = (byte)(qty >> 8); pdu[4] = (byte)(qty & 0xFF);
|
||||
pdu[5] = (byte)bytes.Length;
|
||||
Buffer.BlockCopy(bytes, 0, pdu, 6, bytes.Length);
|
||||
await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
default:
|
||||
throw new InvalidOperationException($"Writes not supported for region {tag.Region}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ISubscribable (polling overlay) ----
|
||||
|
||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
|
||||
{
|
||||
var id = Interlocked.Increment(ref _nextSubscriptionId);
|
||||
var cts = new CancellationTokenSource();
|
||||
var interval = publishingInterval < TimeSpan.FromMilliseconds(100)
|
||||
? TimeSpan.FromMilliseconds(100) // floor — Modbus can't sustain < 100ms polling reliably
|
||||
: publishingInterval;
|
||||
var handle = new ModbusSubscriptionHandle(id);
|
||||
var state = new SubscriptionState(handle, [.. fullReferences], interval, cts);
|
||||
_subscriptions[id] = state;
|
||||
_ = Task.Run(() => PollLoopAsync(state, cts.Token), cts.Token);
|
||||
return Task.FromResult<ISubscriptionHandle>(handle);
|
||||
}
|
||||
|
||||
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
if (handle is ModbusSubscriptionHandle h && _subscriptions.TryRemove(h.Id, out var state))
|
||||
{
|
||||
state.Cts.Cancel();
|
||||
state.Cts.Dispose();
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task PollLoopAsync(SubscriptionState state, CancellationToken ct)
|
||||
{
|
||||
// Initial-data push: read every tag once at subscribe time so OPC UA clients see the
|
||||
// current value per Part 4 convention, even if the value never changes thereafter.
|
||||
try { await PollOnceAsync(state, forceRaise: true, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
catch { /* first-read error — polling continues */ }
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try { await Task.Delay(state.Interval, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
|
||||
try { await PollOnceAsync(state, forceRaise: false, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
catch { /* transient polling error — loop continues, health surface reflects it */ }
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PollOnceAsync(SubscriptionState state, bool forceRaise, CancellationToken ct)
|
||||
{
|
||||
var snapshots = await ReadAsync(state.TagReferences, ct).ConfigureAwait(false);
|
||||
for (var i = 0; i < state.TagReferences.Count; i++)
|
||||
{
|
||||
var tagRef = state.TagReferences[i];
|
||||
var current = snapshots[i];
|
||||
var lastSeen = state.LastValues.TryGetValue(tagRef, out var prev) ? prev : default;
|
||||
|
||||
// Raise on first read (forceRaise) OR when the boxed value differs from last-known.
|
||||
if (forceRaise || !Equals(lastSeen?.Value, current.Value) || lastSeen?.StatusCode != current.StatusCode)
|
||||
{
|
||||
state.LastValues[tagRef] = current;
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(state.Handle, tagRef, current));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record SubscriptionState(
|
||||
ModbusSubscriptionHandle Handle,
|
||||
IReadOnlyList<string> TagReferences,
|
||||
TimeSpan Interval,
|
||||
CancellationTokenSource Cts)
|
||||
{
|
||||
public System.Collections.Concurrent.ConcurrentDictionary<string, DataValueSnapshot> LastValues { get; }
|
||||
= new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private sealed record ModbusSubscriptionHandle(long Id) : ISubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId => $"modbus-sub-{Id}";
|
||||
}
|
||||
|
||||
// ---- IHostConnectivityProbe ----
|
||||
|
||||
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses()
|
||||
{
|
||||
lock (_probeLock)
|
||||
return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Host identifier surfaced to <c>IHostConnectivityProbe.GetHostStatuses</c> and the Admin UI.
|
||||
/// Formatted as <c>host:port</c> so multiple Modbus drivers in the same server disambiguate
|
||||
/// by endpoint without needing the driver-instance-id in the Admin dashboard.
|
||||
/// </summary>
|
||||
public string HostName => $"{_options.Host}:{_options.Port}";
|
||||
|
||||
private async Task ProbeLoopAsync(CancellationToken ct)
|
||||
{
|
||||
var transport = _transport; // captured reference; disposal tears the loop down via ct
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var success = false;
|
||||
try
|
||||
{
|
||||
using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
probeCts.CancelAfter(_options.Probe.Timeout);
|
||||
var pdu = new byte[] { 0x03,
|
||||
(byte)(_options.Probe.ProbeAddress >> 8),
|
||||
(byte)(_options.Probe.ProbeAddress & 0xFF), 0x00, 0x01 };
|
||||
_ = await transport!.SendAsync(_options.UnitId, pdu, probeCts.Token).ConfigureAwait(false);
|
||||
success = true;
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// transport / timeout / exception PDU — treated as Stopped below
|
||||
}
|
||||
|
||||
TransitionTo(success ? HostState.Running : HostState.Stopped);
|
||||
|
||||
try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
}
|
||||
}
|
||||
|
||||
private void TransitionTo(HostState newState)
|
||||
{
|
||||
HostState old;
|
||||
lock (_probeLock)
|
||||
{
|
||||
old = _hostState;
|
||||
if (old == newState) return;
|
||||
_hostState = newState;
|
||||
_hostStateChangedUtc = DateTime.UtcNow;
|
||||
}
|
||||
OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, old, newState));
|
||||
}
|
||||
|
||||
// ---- codec ----
|
||||
|
||||
/// <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 or ModbusDataType.BitInRegister => 1,
|
||||
ModbusDataType.Int32 or ModbusDataType.UInt32 or ModbusDataType.Float32 => 2,
|
||||
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}"),
|
||||
};
|
||||
|
||||
/// <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)
|
||||
{
|
||||
if (order == ModbusByteOrder.BigEndian) return data.ToArray();
|
||||
var result = new byte[data.Length];
|
||||
for (var word = 0; word < data.Length / 2; word++)
|
||||
{
|
||||
var srcWord = data.Length / 2 - 1 - word;
|
||||
result[word * 2] = data[srcWord * 2];
|
||||
result[word * 2 + 1] = data[srcWord * 2 + 1];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
internal static object DecodeRegister(ReadOnlySpan<byte> data, ModbusTagDefinition tag)
|
||||
{
|
||||
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:
|
||||
{
|
||||
var v = Convert.ToInt16(value);
|
||||
var b = new byte[2]; BinaryPrimitives.WriteInt16BigEndian(b, v); return b;
|
||||
}
|
||||
case ModbusDataType.UInt16:
|
||||
{
|
||||
var v = Convert.ToUInt16(value);
|
||||
var b = new byte[2]; BinaryPrimitives.WriteUInt16BigEndian(b, v); return b;
|
||||
}
|
||||
case ModbusDataType.Int32:
|
||||
{
|
||||
var v = Convert.ToInt32(value);
|
||||
var b = new byte[4]; BinaryPrimitives.WriteInt32BigEndian(b, v);
|
||||
return NormalizeWordOrder(b, tag.ByteOrder);
|
||||
}
|
||||
case ModbusDataType.UInt32:
|
||||
{
|
||||
var v = Convert.ToUInt32(value);
|
||||
var b = new byte[4]; BinaryPrimitives.WriteUInt32BigEndian(b, v);
|
||||
return NormalizeWordOrder(b, tag.ByteOrder);
|
||||
}
|
||||
case ModbusDataType.Float32:
|
||||
{
|
||||
var v = Convert.ToSingle(value);
|
||||
var b = new byte[4]; 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;
|
||||
}
|
||||
case ModbusDataType.BitInRegister:
|
||||
throw new InvalidOperationException(
|
||||
"BitInRegister writes require a read-modify-write; not supported in PR 24 (separate follow-up).");
|
||||
default:
|
||||
throw new InvalidOperationException($"Non-register data type {tag.DataType}");
|
||||
}
|
||||
}
|
||||
|
||||
private static DriverDataType MapDataType(ModbusDataType t) => t switch
|
||||
{
|
||||
ModbusDataType.Bool or ModbusDataType.BitInRegister => DriverDataType.Boolean,
|
||||
ModbusDataType.Int16 or ModbusDataType.Int32 => 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.Float64 => DriverDataType.Float64,
|
||||
ModbusDataType.String => DriverDataType.String,
|
||||
_ => DriverDataType.Int32,
|
||||
};
|
||||
|
||||
private IModbusTransport RequireTransport() =>
|
||||
_transport ?? throw new InvalidOperationException("ModbusDriver not initialized");
|
||||
|
||||
private const uint StatusBadInternalError = 0x80020000u;
|
||||
private const uint StatusBadNodeIdUnknown = 0x80340000u;
|
||||
private const uint StatusBadNotWritable = 0x803B0000u;
|
||||
|
||||
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_transport is not null) await _transport.DisposeAsync().ConfigureAwait(false);
|
||||
_transport = null;
|
||||
}
|
||||
}
|
||||
97
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverOptions.cs
Normal file
97
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverOptions.cs
Normal file
@@ -0,0 +1,97 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// Modbus TCP driver configuration. Bound from the driver's <c>DriverConfig</c> JSON at
|
||||
/// <c>DriverHost.RegisterAsync</c>. Every register the driver exposes appears in
|
||||
/// <see cref="Tags"/>; names become the OPC UA browse name + full reference.
|
||||
/// </summary>
|
||||
public sealed class ModbusDriverOptions
|
||||
{
|
||||
public string Host { get; init; } = "127.0.0.1";
|
||||
public int Port { get; init; } = 502;
|
||||
public byte UnitId { get; init; } = 1;
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>Pre-declared tag map. Modbus has no discovery protocol — the driver returns exactly these.</summary>
|
||||
public IReadOnlyList<ModbusTagDefinition> Tags { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Background connectivity-probe settings. When <see cref="ModbusProbeOptions.Enabled"/>
|
||||
/// is true the driver runs a tick loop that issues a cheap FC03 at register 0 every
|
||||
/// <see cref="ModbusProbeOptions.Interval"/> and raises <c>OnHostStatusChanged</c> on
|
||||
/// Running ↔ Stopped transitions. The Admin UI / OPC UA clients see the state through
|
||||
/// <see cref="IHostConnectivityProbe"/>.
|
||||
/// </summary>
|
||||
public ModbusProbeOptions Probe { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed class ModbusProbeOptions
|
||||
{
|
||||
public bool Enabled { get; init; } = true;
|
||||
public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(5);
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||
/// <summary>Register to read for the probe. Zero is usually safe; override for PLCs that lock register 0.</summary>
|
||||
public ushort ProbeAddress { get; init; } = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One Modbus-backed OPC UA variable. Address is zero-based (Modbus spec numbering, not
|
||||
/// 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>
|
||||
/// <param name="Name">
|
||||
/// Tag name, used for both the OPC UA browse name and the driver's full reference. Must be
|
||||
/// unique within the driver.
|
||||
/// </param>
|
||||
/// <param name="Region">Coils / DiscreteInputs / InputRegisters / HoldingRegisters.</param>
|
||||
/// <param name="Address">Zero-based address within the region.</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="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(
|
||||
string Name,
|
||||
ModbusRegion Region,
|
||||
ushort Address,
|
||||
ModbusDataType DataType,
|
||||
bool Writable = true,
|
||||
ModbusByteOrder ByteOrder = ModbusByteOrder.BigEndian,
|
||||
byte BitIndex = 0,
|
||||
ushort StringLength = 0);
|
||||
|
||||
public enum ModbusRegion { Coils, DiscreteInputs, InputRegisters, HoldingRegisters }
|
||||
|
||||
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,
|
||||
}
|
||||
113
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTcpTransport.cs
Normal file
113
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTcpTransport.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// Concrete Modbus TCP transport. Wraps a single <see cref="TcpClient"/> and serializes
|
||||
/// requests so at most one transaction is in-flight at a time — Modbus servers typically
|
||||
/// support concurrent transactions, but the single-flight model keeps the wire trace
|
||||
/// easy to diagnose and avoids interleaved-response correlation bugs.
|
||||
/// </summary>
|
||||
public sealed class ModbusTcpTransport : IModbusTransport
|
||||
{
|
||||
private readonly string _host;
|
||||
private readonly int _port;
|
||||
private readonly TimeSpan _timeout;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private TcpClient? _client;
|
||||
private NetworkStream? _stream;
|
||||
private ushort _nextTx;
|
||||
private bool _disposed;
|
||||
|
||||
public ModbusTcpTransport(string host, int port, TimeSpan timeout)
|
||||
{
|
||||
_host = host;
|
||||
_port = port;
|
||||
_timeout = timeout;
|
||||
}
|
||||
|
||||
public async Task ConnectAsync(CancellationToken ct)
|
||||
{
|
||||
_client = new TcpClient();
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(_timeout);
|
||||
await _client.ConnectAsync(_host, _port, cts.Token).ConfigureAwait(false);
|
||||
_stream = _client.GetStream();
|
||||
}
|
||||
|
||||
public async Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(ModbusTcpTransport));
|
||||
if (_stream is null) throw new InvalidOperationException("Transport not connected");
|
||||
|
||||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var txId = ++_nextTx;
|
||||
|
||||
// MBAP: [TxId(2)][Proto=0(2)][Length(2)][UnitId(1)] + PDU
|
||||
var adu = new byte[7 + pdu.Length];
|
||||
adu[0] = (byte)(txId >> 8);
|
||||
adu[1] = (byte)(txId & 0xFF);
|
||||
// protocol id already zero
|
||||
var len = (ushort)(1 + pdu.Length); // unit id + pdu
|
||||
adu[4] = (byte)(len >> 8);
|
||||
adu[5] = (byte)(len & 0xFF);
|
||||
adu[6] = unitId;
|
||||
Buffer.BlockCopy(pdu, 0, adu, 7, pdu.Length);
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(_timeout);
|
||||
await _stream.WriteAsync(adu.AsMemory(), cts.Token).ConfigureAwait(false);
|
||||
await _stream.FlushAsync(cts.Token).ConfigureAwait(false);
|
||||
|
||||
var header = new byte[7];
|
||||
await ReadExactlyAsync(_stream, header, cts.Token).ConfigureAwait(false);
|
||||
var respTxId = (ushort)((header[0] << 8) | header[1]);
|
||||
if (respTxId != txId)
|
||||
throw new InvalidDataException($"Modbus TxId mismatch: expected {txId} got {respTxId}");
|
||||
var respLen = (ushort)((header[4] << 8) | header[5]);
|
||||
if (respLen < 1) throw new InvalidDataException($"Modbus response length too small: {respLen}");
|
||||
var respPdu = new byte[respLen - 1];
|
||||
await ReadExactlyAsync(_stream, respPdu, cts.Token).ConfigureAwait(false);
|
||||
|
||||
// Exception PDU: function code has high bit set.
|
||||
if ((respPdu[0] & 0x80) != 0)
|
||||
{
|
||||
var fc = (byte)(respPdu[0] & 0x7F);
|
||||
var ex = respPdu[1];
|
||||
throw new ModbusException(fc, ex, $"Modbus exception fc={fc} code={ex}");
|
||||
}
|
||||
|
||||
return respPdu;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ReadExactlyAsync(Stream s, byte[] buf, CancellationToken ct)
|
||||
{
|
||||
var read = 0;
|
||||
while (read < buf.Length)
|
||||
{
|
||||
var n = await s.ReadAsync(buf.AsMemory(read), ct).ConfigureAwait(false);
|
||||
if (n == 0) throw new EndOfStreamException("Modbus socket closed mid-response");
|
||||
read += n;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
try
|
||||
{
|
||||
if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
_client?.Dispose();
|
||||
_gate.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Modbus</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
143
src/ZB.MOM.WW.OtOpcUa.Server/HostStatusPublisher.cs
Normal file
143
src/ZB.MOM.WW.OtOpcUa.Server/HostStatusPublisher.cs
Normal file
@@ -0,0 +1,143 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
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.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Walks every registered driver once per heartbeat interval, asks each
|
||||
/// <see cref="IHostConnectivityProbe"/>-capable driver for its current
|
||||
/// <see cref="HostConnectivityStatus"/> list, and upserts one
|
||||
/// <see cref="DriverHostStatus"/> row per (NodeId, DriverInstanceId, HostName) into the
|
||||
/// central config DB. Powers the Admin UI's per-host drill-down page (LMX follow-up #7).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Polling rather than event-driven: simpler, and matches the cadence the Admin UI
|
||||
/// consumes. An event-subscription optimization (push on <c>OnHostStatusChanged</c> for
|
||||
/// immediate reflection) is a straightforward follow-up but adds lifecycle complexity
|
||||
/// — drivers can be registered after the publisher starts, and subscribing to each
|
||||
/// one's event on register + unsubscribing on unregister requires DriverHost to expose
|
||||
/// lifecycle events it doesn't today.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="DriverHostStatus.LastSeenUtc"/> advances every heartbeat so the Admin UI
|
||||
/// can flag stale rows from a crashed Server process independent of
|
||||
/// <see cref="DriverHostStatus.State"/> — a Faulted publisher that stops heartbeating
|
||||
/// stays Faulted in the DB but its LastSeenUtc ages out, which is the signal
|
||||
/// operators actually want.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If the DB is unreachable on a given tick, the publisher logs and moves on — it
|
||||
/// does not retry or buffer. The next heartbeat picks up the current-state snapshot,
|
||||
/// which is more useful than replaying stale transitions after a long outage.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class HostStatusPublisher(
|
||||
DriverHost driverHost,
|
||||
NodeOptions nodeOptions,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<HostStatusPublisher> logger) : BackgroundService
|
||||
{
|
||||
internal static readonly TimeSpan HeartbeatInterval = TimeSpan.FromSeconds(10);
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
// Wait a short moment at startup so NodeBootstrap's RegisterAsync calls have had a
|
||||
// chance to land. First tick runs immediately after so a freshly-started Server
|
||||
// surfaces its host topology in the Admin UI without waiting a full interval.
|
||||
try { await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try { await PublishOnceAsync(stoppingToken); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Never take down the Server on a publisher failure. Log and continue —
|
||||
// stale-row detection on the Admin side will surface the outage.
|
||||
logger.LogWarning(ex, "Host-status publisher tick failed — will retry next heartbeat");
|
||||
}
|
||||
|
||||
try { await Task.Delay(HeartbeatInterval, stoppingToken); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task PublishOnceAsync(CancellationToken ct)
|
||||
{
|
||||
var driverIds = driverHost.RegisteredDriverIds;
|
||||
if (driverIds.Count == 0) return;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||
|
||||
foreach (var driverId in driverIds)
|
||||
{
|
||||
var driver = driverHost.GetDriver(driverId);
|
||||
if (driver is not IHostConnectivityProbe probe) continue;
|
||||
|
||||
IReadOnlyList<HostConnectivityStatus> statuses;
|
||||
try { statuses = probe.GetHostStatuses(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Driver {DriverId} GetHostStatuses threw — skipping this tick", driverId);
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var status in statuses)
|
||||
{
|
||||
await UpsertAsync(db, driverId, status, now, ct);
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private async Task UpsertAsync(OtOpcUaConfigDbContext db, string driverId,
|
||||
HostConnectivityStatus status, DateTime now, CancellationToken ct)
|
||||
{
|
||||
var mapped = MapState(status.State);
|
||||
var existing = await db.DriverHostStatuses.SingleOrDefaultAsync(r =>
|
||||
r.NodeId == nodeOptions.NodeId
|
||||
&& r.DriverInstanceId == driverId
|
||||
&& r.HostName == status.HostName, ct);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
db.DriverHostStatuses.Add(new DriverHostStatus
|
||||
{
|
||||
NodeId = nodeOptions.NodeId,
|
||||
DriverInstanceId = driverId,
|
||||
HostName = status.HostName,
|
||||
State = mapped,
|
||||
StateChangedUtc = status.LastChangedUtc,
|
||||
LastSeenUtc = now,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
existing.LastSeenUtc = now;
|
||||
if (existing.State != mapped)
|
||||
{
|
||||
existing.State = mapped;
|
||||
existing.StateChangedUtc = status.LastChangedUtc;
|
||||
}
|
||||
}
|
||||
|
||||
internal static DriverHostState MapState(HostState state) => state switch
|
||||
{
|
||||
HostState.Running => DriverHostState.Running,
|
||||
HostState.Stopped => DriverHostState.Stopped,
|
||||
HostState.Faulted => DriverHostState.Faulted,
|
||||
_ => DriverHostState.Unknown,
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
using DriverWriteRequest = ZB.MOM.WW.OtOpcUa.Core.Abstractions.WriteRequest;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
@@ -35,6 +36,12 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
private FolderState? _driverRoot;
|
||||
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.
|
||||
// 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.
|
||||
@@ -122,6 +129,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
_currentFolder.AddChild(v);
|
||||
AddPredefinedNode(SystemContext, v);
|
||||
_variablesByFullRef[attributeInfo.FullName] = v;
|
||||
_securityByFullRef[attributeInfo.FullName] = attributeInfo.SecurityClass;
|
||||
|
||||
v.OnReadValue = OnReadValue;
|
||||
v.OnWriteValue = OnWriteValue;
|
||||
@@ -337,6 +345,22 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
var fullRef = node.NodeId.Identifier as string;
|
||||
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
|
||||
{
|
||||
var results = _writable.WriteAsync(
|
||||
|
||||
@@ -3,6 +3,7 @@ using Opc.Ua;
|
||||
using Opc.Ua.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
|
||||
@@ -18,6 +19,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
{
|
||||
private readonly OpcUaServerOptions _options;
|
||||
private readonly DriverHost _driverHost;
|
||||
private readonly IUserAuthenticator _authenticator;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly ILogger<OpcUaApplicationHost> _logger;
|
||||
private ApplicationInstance? _application;
|
||||
@@ -25,10 +27,11 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
private bool _disposed;
|
||||
|
||||
public OpcUaApplicationHost(OpcUaServerOptions options, DriverHost driverHost,
|
||||
ILoggerFactory loggerFactory, ILogger<OpcUaApplicationHost> logger)
|
||||
IUserAuthenticator authenticator, ILoggerFactory loggerFactory, ILogger<OpcUaApplicationHost> logger)
|
||||
{
|
||||
_options = options;
|
||||
_driverHost = driverHost;
|
||||
_authenticator = authenticator;
|
||||
_loggerFactory = loggerFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -55,7 +58,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
throw new InvalidOperationException(
|
||||
$"OPC UA application certificate could not be validated or created in {_options.PkiStoreRoot}");
|
||||
|
||||
_server = new OtOpcUaServer(_driverHost, _loggerFactory);
|
||||
_server = new OtOpcUaServer(_driverHost, _authenticator, _loggerFactory);
|
||||
await _application.Start(_server).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("OPC UA server started — endpoint={Endpoint} driverCount={Count}",
|
||||
@@ -126,22 +129,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
ServerConfiguration = new ServerConfiguration
|
||||
{
|
||||
BaseAddresses = new StringCollection { _options.EndpointUrl },
|
||||
SecurityPolicies = new ServerSecurityPolicyCollection
|
||||
{
|
||||
new ServerSecurityPolicy
|
||||
{
|
||||
SecurityMode = MessageSecurityMode.None,
|
||||
SecurityPolicyUri = SecurityPolicies.None,
|
||||
},
|
||||
},
|
||||
UserTokenPolicies = new UserTokenPolicyCollection
|
||||
{
|
||||
new UserTokenPolicy(UserTokenType.Anonymous)
|
||||
{
|
||||
PolicyId = "Anonymous",
|
||||
SecurityPolicyUri = SecurityPolicies.None,
|
||||
},
|
||||
},
|
||||
SecurityPolicies = BuildSecurityPolicies(),
|
||||
UserTokenPolicies = BuildUserTokenPolicies(),
|
||||
MinRequestThreadCount = 5,
|
||||
MaxRequestThreadCount = 100,
|
||||
MaxQueuedRequestCount = 200,
|
||||
@@ -164,6 +153,58 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
return cfg;
|
||||
}
|
||||
|
||||
private ServerSecurityPolicyCollection BuildSecurityPolicies()
|
||||
{
|
||||
var policies = new ServerSecurityPolicyCollection
|
||||
{
|
||||
// Keep the None policy present so legacy clients can discover + browse. Locked-down
|
||||
// deployments remove this by setting Ldap.Enabled=true + dropping None here; left in
|
||||
// for PR 19 so the PR 17 test harness continues to pass unchanged.
|
||||
new ServerSecurityPolicy
|
||||
{
|
||||
SecurityMode = MessageSecurityMode.None,
|
||||
SecurityPolicyUri = SecurityPolicies.None,
|
||||
},
|
||||
};
|
||||
|
||||
if (_options.SecurityProfile == OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt)
|
||||
{
|
||||
policies.Add(new ServerSecurityPolicy
|
||||
{
|
||||
SecurityMode = MessageSecurityMode.SignAndEncrypt,
|
||||
SecurityPolicyUri = SecurityPolicies.Basic256Sha256,
|
||||
});
|
||||
}
|
||||
|
||||
return policies;
|
||||
}
|
||||
|
||||
private UserTokenPolicyCollection BuildUserTokenPolicies()
|
||||
{
|
||||
var tokens = new UserTokenPolicyCollection
|
||||
{
|
||||
new UserTokenPolicy(UserTokenType.Anonymous)
|
||||
{
|
||||
PolicyId = "Anonymous",
|
||||
SecurityPolicyUri = SecurityPolicies.None,
|
||||
},
|
||||
};
|
||||
|
||||
if (_options.SecurityProfile == OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt
|
||||
&& _options.Ldap.Enabled)
|
||||
{
|
||||
tokens.Add(new UserTokenPolicy(UserTokenType.UserName)
|
||||
{
|
||||
PolicyId = "UserName",
|
||||
// Passwords must ride an encrypted channel — scope this token to Basic256Sha256
|
||||
// so the stack rejects any attempt to send UserName over the None endpoint.
|
||||
SecurityPolicyUri = SecurityPolicies.Basic256Sha256,
|
||||
});
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA transport security profile selector. Controls which <c>ServerSecurityPolicy</c>
|
||||
/// entries the endpoint advertises + which token types the <c>UserTokenPolicies</c> permits.
|
||||
/// </summary>
|
||||
public enum OpcUaSecurityProfile
|
||||
{
|
||||
/// <summary>Anonymous only on <c>SecurityPolicies.None</c> — dev-only, no signing or encryption.</summary>
|
||||
None,
|
||||
|
||||
/// <summary>
|
||||
/// <c>Basic256Sha256 SignAndEncrypt</c> with <c>UserName</c> and <c>Anonymous</c> token
|
||||
/// policies. Clients must present a valid application certificate + user credentials.
|
||||
/// </summary>
|
||||
Basic256Sha256SignAndEncrypt,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA server endpoint + application-identity configuration. Bound from the
|
||||
/// <c>OpcUaServer</c> section of <c>appsettings.json</c>. PR 17 minimum-viable scope: no LDAP,
|
||||
@@ -39,4 +57,18 @@ public sealed class OpcUaServerOptions
|
||||
/// Admin UI.
|
||||
/// </summary>
|
||||
public bool AutoAcceptUntrustedClientCertificates { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Security profile advertised on the endpoint. Default <see cref="OpcUaSecurityProfile.None"/>
|
||||
/// preserves the PR 17 endpoint shape; set to <see cref="OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt"/>
|
||||
/// for production deployments with LDAP-backed UserName auth.
|
||||
/// </summary>
|
||||
public OpcUaSecurityProfile SecurityProfile { get; init; } = OpcUaSecurityProfile.None;
|
||||
|
||||
/// <summary>
|
||||
/// LDAP binding for UserName token validation. Only consulted when the active
|
||||
/// <see cref="SecurityProfile"/> advertises a UserName token policy. When
|
||||
/// <c>LdapOptions.Enabled = false</c>, UserName token attempts are rejected.
|
||||
/// </summary>
|
||||
public LdapOptions Ldap { get; init; } = new();
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ using Opc.Ua.Server;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
|
||||
@@ -17,12 +18,14 @@ namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
public sealed class OtOpcUaServer : StandardServer
|
||||
{
|
||||
private readonly DriverHost _driverHost;
|
||||
private readonly IUserAuthenticator _authenticator;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly List<DriverNodeManager> _driverNodeManagers = new();
|
||||
|
||||
public OtOpcUaServer(DriverHost driverHost, ILoggerFactory loggerFactory)
|
||||
public OtOpcUaServer(DriverHost driverHost, IUserAuthenticator authenticator, ILoggerFactory loggerFactory)
|
||||
{
|
||||
_driverHost = driverHost;
|
||||
_authenticator = authenticator;
|
||||
_loggerFactory = loggerFactory;
|
||||
}
|
||||
|
||||
@@ -50,6 +53,63 @@ public sealed class OtOpcUaServer : StandardServer
|
||||
return new MasterNodeManager(server, configuration, null, _driverNodeManagers.ToArray());
|
||||
}
|
||||
|
||||
protected override void OnServerStarted(IServerInternal server)
|
||||
{
|
||||
base.OnServerStarted(server);
|
||||
// Hook UserName / Anonymous token validation here. Anonymous passes through; UserName
|
||||
// is validated against the IUserAuthenticator (LDAP in production). Rejected identities
|
||||
// throw ServiceResultException which the stack translates to Bad_IdentityTokenInvalid.
|
||||
server.SessionManager.ImpersonateUser += OnImpersonateUser;
|
||||
}
|
||||
|
||||
private void OnImpersonateUser(Session session, ImpersonateEventArgs args)
|
||||
{
|
||||
switch (args.NewIdentity)
|
||||
{
|
||||
case AnonymousIdentityToken:
|
||||
args.Identity = new UserIdentity(); // anonymous
|
||||
return;
|
||||
|
||||
case UserNameIdentityToken user:
|
||||
{
|
||||
var result = _authenticator.AuthenticateAsync(
|
||||
user.UserName, user.DecryptedPassword, CancellationToken.None)
|
||||
.GetAwaiter().GetResult();
|
||||
if (!result.Success)
|
||||
{
|
||||
throw ServiceResultException.Create(
|
||||
StatusCodes.BadUserAccessDenied,
|
||||
"Invalid username or password ({0})", result.Error ?? "no detail");
|
||||
}
|
||||
args.Identity = new RoleBasedIdentity(user.UserName, result.DisplayName, result.Roles);
|
||||
return;
|
||||
}
|
||||
|
||||
default:
|
||||
throw ServiceResultException.Create(
|
||||
StatusCodes.BadIdentityTokenInvalid,
|
||||
"Unsupported user identity token type: {0}", args.NewIdentity?.GetType().Name ?? "null");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tiny UserIdentity carrier that preserves the resolved roles so downstream node
|
||||
/// managers can gate writes by role via <c>session.Identity</c>. Anonymous identity still
|
||||
/// uses the stack's default.
|
||||
/// </summary>
|
||||
private sealed class RoleBasedIdentity : UserIdentity, IRoleBearer
|
||||
{
|
||||
public IReadOnlyList<string> Roles { get; }
|
||||
public string? Display { get; }
|
||||
|
||||
public RoleBasedIdentity(string userName, string? displayName, IReadOnlyList<string> roles)
|
||||
: base(userName, "")
|
||||
{
|
||||
Display = displayName;
|
||||
Roles = roles;
|
||||
}
|
||||
}
|
||||
|
||||
protected override ServerProperties LoadServerProperties() => new()
|
||||
{
|
||||
ManufacturerName = "OtOpcUa",
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Server;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
@@ -31,6 +35,20 @@ var options = new NodeOptions
|
||||
};
|
||||
|
||||
var opcUaSection = builder.Configuration.GetSection(OpcUaServerOptions.SectionName);
|
||||
var ldapSection = opcUaSection.GetSection("Ldap");
|
||||
var ldapOptions = new LdapOptions
|
||||
{
|
||||
Enabled = ldapSection.GetValue<bool?>("Enabled") ?? false,
|
||||
Server = ldapSection.GetValue<string>("Server") ?? "localhost",
|
||||
Port = ldapSection.GetValue<int?>("Port") ?? 3893,
|
||||
UseTls = ldapSection.GetValue<bool?>("UseTls") ?? false,
|
||||
AllowInsecureLdap = ldapSection.GetValue<bool?>("AllowInsecureLdap") ?? true,
|
||||
SearchBase = ldapSection.GetValue<string>("SearchBase") ?? "dc=lmxopcua,dc=local",
|
||||
ServiceAccountDn = ldapSection.GetValue<string>("ServiceAccountDn") ?? string.Empty,
|
||||
ServiceAccountPassword = ldapSection.GetValue<string>("ServiceAccountPassword") ?? string.Empty,
|
||||
GroupToRole = ldapSection.GetSection("GroupToRole").Get<Dictionary<string, string>>() ?? new(StringComparer.OrdinalIgnoreCase),
|
||||
};
|
||||
|
||||
var opcUaOptions = new OpcUaServerOptions
|
||||
{
|
||||
EndpointUrl = opcUaSection.GetValue<string>("EndpointUrl") ?? "opc.tcp://0.0.0.0:4840/OtOpcUa",
|
||||
@@ -39,15 +57,28 @@ var opcUaOptions = new OpcUaServerOptions
|
||||
PkiStoreRoot = opcUaSection.GetValue<string>("PkiStoreRoot")
|
||||
?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "OtOpcUa", "pki"),
|
||||
AutoAcceptUntrustedClientCertificates = opcUaSection.GetValue<bool?>("AutoAcceptUntrustedClientCertificates") ?? true,
|
||||
SecurityProfile = Enum.TryParse<OpcUaSecurityProfile>(opcUaSection.GetValue<string>("SecurityProfile"), true, out var p)
|
||||
? p : OpcUaSecurityProfile.None,
|
||||
Ldap = ldapOptions,
|
||||
};
|
||||
|
||||
builder.Services.AddSingleton(options);
|
||||
builder.Services.AddSingleton(opcUaOptions);
|
||||
builder.Services.AddSingleton(ldapOptions);
|
||||
builder.Services.AddSingleton<IUserAuthenticator>(sp => ldapOptions.Enabled
|
||||
? new LdapUserAuthenticator(ldapOptions, sp.GetRequiredService<ILogger<LdapUserAuthenticator>>())
|
||||
: new DenyAllUserAuthenticator());
|
||||
builder.Services.AddSingleton<ILocalConfigCache>(_ => new LiteDbConfigCache(options.LocalCachePath));
|
||||
builder.Services.AddSingleton<DriverHost>();
|
||||
builder.Services.AddSingleton<NodeBootstrap>();
|
||||
builder.Services.AddSingleton<OpcUaApplicationHost>();
|
||||
builder.Services.AddHostedService<OpcUaServerService>();
|
||||
|
||||
// Central-config DB access for the host-status publisher (LMX follow-up #7). Scoped context
|
||||
// so per-heartbeat change-tracking stays isolated; publisher opens one scope per tick.
|
||||
builder.Services.AddDbContext<OtOpcUaConfigDbContext>(opt =>
|
||||
opt.UseSqlServer(options.ConfigDbConnectionString));
|
||||
builder.Services.AddHostedService<HostStatusPublisher>();
|
||||
|
||||
var host = builder.Build();
|
||||
await host.RunAsync();
|
||||
|
||||
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; }
|
||||
}
|
||||
23
src/ZB.MOM.WW.OtOpcUa.Server/Security/IUserAuthenticator.cs
Normal file
23
src/ZB.MOM.WW.OtOpcUa.Server/Security/IUserAuthenticator.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Validates a (username, password) pair and returns the resolved OPC UA roles for the user.
|
||||
/// The Server's <c>SessionManager_ImpersonateUser</c> hook delegates here so unit tests can
|
||||
/// swap in a fake authenticator without a live LDAP.
|
||||
/// </summary>
|
||||
public interface IUserAuthenticator
|
||||
{
|
||||
Task<UserAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public sealed record UserAuthResult(bool Success, string? DisplayName, IReadOnlyList<string> Roles, string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// Always-reject authenticator used when no security config is provided. Lets the server
|
||||
/// start (with only an anonymous endpoint) without throwing on UserName token attempts.
|
||||
/// </summary>
|
||||
public sealed class DenyAllUserAuthenticator : IUserAuthenticator
|
||||
{
|
||||
public Task<UserAuthResult> AuthenticateAsync(string _, string __, CancellationToken ___)
|
||||
=> Task.FromResult(new UserAuthResult(false, null, [], "UserName token not supported"));
|
||||
}
|
||||
72
src/ZB.MOM.WW.OtOpcUa.Server/Security/LdapOptions.cs
Normal file
72
src/ZB.MOM.WW.OtOpcUa.Server/Security/LdapOptions.cs
Normal file
@@ -0,0 +1,72 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
/// <summary>
|
||||
/// LDAP settings for the OPC UA server's UserName token validator. Bound from
|
||||
/// <c>appsettings.json</c> <c>OpcUaServer:Ldap</c>. Defaults target the GLAuth dev instance
|
||||
/// (localhost:3893, <c>dc=lmxopcua,dc=local</c>) for the stock inner-loop setup. Production
|
||||
/// deployments are expected to point at Active Directory; see <see cref="UserNameAttribute"/>
|
||||
/// and the per-field xml-docs for the AD-specific overrides.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Active Directory cheat-sheet</b>:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="Server"/>: one of the domain controllers, or the domain FQDN (will round-robin DCs).</item>
|
||||
/// <item><see cref="Port"/>: <c>389</c> (LDAP) or <c>636</c> (LDAPS); use 636 + <see cref="UseTls"/> in production.</item>
|
||||
/// <item><see cref="UseTls"/>: <c>true</c>. AD increasingly rejects plain-LDAP bind under LDAP-signing enforcement.</item>
|
||||
/// <item><see cref="AllowInsecureLdap"/>: <c>false</c>. Dev escape hatch only.</item>
|
||||
/// <item><see cref="SearchBase"/>: <c>DC=corp,DC=example,DC=com</c> — your domain's base DN.</item>
|
||||
/// <item><see cref="ServiceAccountDn"/>: a dedicated service principal with read access to user + group entries
|
||||
/// (e.g. <c>CN=OpcUaSvc,OU=Service Accounts,DC=corp,DC=example,DC=com</c>). Never a privileged admin.</item>
|
||||
/// <item><see cref="UserNameAttribute"/>: <c>sAMAccountName</c> (classic login name) or <c>userPrincipalName</c>
|
||||
/// (user@domain form). Default is <c>uid</c> which AD does <b>not</b> populate, so this override is required.</item>
|
||||
/// <item><see cref="DisplayNameAttribute"/>: <c>displayName</c> gives the human name; <c>cn</c> works too but is less rich.</item>
|
||||
/// <item><see cref="GroupAttribute"/>: <c>memberOf</c> — matches AD's default. Values are full DNs
|
||||
/// (<c>CN=<Group>,OU=...,DC=...</c>); the authenticator strips the leading <c>CN=</c> RDN value and uses
|
||||
/// that as the lookup key in <see cref="GroupToRole"/>.</item>
|
||||
/// <item><see cref="GroupToRole"/>: maps your AD group common-names to OPC UA roles — e.g.
|
||||
/// <c>{"OPCUA-Operators" : "WriteOperate", "OPCUA-Engineers" : "WriteConfigure"}</c>.</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Nested groups are <b>not</b> expanded — AD's <c>tokenGroups</c> / <c>LDAP_MATCHING_RULE_IN_CHAIN</c>
|
||||
/// membership-chain filter isn't used. Assign users directly to the role-mapped groups, or pre-flatten
|
||||
/// membership in your directory. If nested expansion becomes a requirement, it's an authenticator
|
||||
/// enhancement (not a config change).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class LdapOptions
|
||||
{
|
||||
public bool Enabled { get; init; } = false;
|
||||
public string Server { get; init; } = "localhost";
|
||||
public int Port { get; init; } = 3893;
|
||||
public bool UseTls { get; init; } = false;
|
||||
|
||||
/// <summary>Dev-only escape hatch — must be false in production.</summary>
|
||||
public bool AllowInsecureLdap { get; init; } = true;
|
||||
|
||||
public string SearchBase { get; init; } = "dc=lmxopcua,dc=local";
|
||||
public string ServiceAccountDn { get; init; } = string.Empty;
|
||||
public string ServiceAccountPassword { get; init; } = string.Empty;
|
||||
public string DisplayNameAttribute { get; init; } = "cn";
|
||||
public string GroupAttribute { get; init; } = "memberOf";
|
||||
|
||||
/// <summary>
|
||||
/// LDAP attribute used to match a login name against user entries in the directory.
|
||||
/// Defaults to <c>uid</c> (RFC 2307). Common overrides:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>sAMAccountName</c> — Active Directory, classic NT-style login names (e.g. <c>jdoe</c>).</item>
|
||||
/// <item><c>userPrincipalName</c> — Active Directory, email-style (e.g. <c>jdoe@corp.example.com</c>).</item>
|
||||
/// <item><c>cn</c> — GLAuth + some OpenLDAP deployments where users are keyed by common-name.</item>
|
||||
/// </list>
|
||||
/// Used only when <see cref="ServiceAccountDn"/> is non-empty (search-then-bind path) —
|
||||
/// direct-bind fallback constructs the DN as <c>cn=<name>,<SearchBase></c>
|
||||
/// regardless of this setting and is not a production-grade path against AD.
|
||||
/// </summary>
|
||||
public string UserNameAttribute { get; init; } = "uid";
|
||||
|
||||
/// <summary>
|
||||
/// LDAP group → OPC UA role. Each authenticated user gets every role whose source group
|
||||
/// is in their membership list. Recognized role names (CLAUDE.md): <c>ReadOnly</c> (browse
|
||||
/// + read), <c>WriteOperate</c>, <c>WriteTune</c>, <c>WriteConfigure</c>, <c>AlarmAck</c>.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> GroupToRole { get; init; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
151
src/ZB.MOM.WW.OtOpcUa.Server/Security/LdapUserAuthenticator.cs
Normal file
151
src/ZB.MOM.WW.OtOpcUa.Server/Security/LdapUserAuthenticator.cs
Normal file
@@ -0,0 +1,151 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Novell.Directory.Ldap;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IUserAuthenticator"/> that binds to the configured LDAP directory to validate
|
||||
/// the (username, password) pair, then pulls group membership and maps to OPC UA roles.
|
||||
/// Mirrors the bind-then-search pattern in <c>Admin.Security.LdapAuthService</c> but stays
|
||||
/// in the Server project so the Server process doesn't take a cross-app dependency on Admin.
|
||||
/// </summary>
|
||||
public sealed class LdapUserAuthenticator(LdapOptions options, ILogger<LdapUserAuthenticator> logger)
|
||||
: IUserAuthenticator
|
||||
{
|
||||
public async Task<UserAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
|
||||
{
|
||||
if (!options.Enabled)
|
||||
return new UserAuthResult(false, null, [], "LDAP authentication disabled");
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||
return new UserAuthResult(false, null, [], "Credentials required");
|
||||
|
||||
if (!options.UseTls && !options.AllowInsecureLdap)
|
||||
return new UserAuthResult(false, null, [],
|
||||
"Insecure LDAP is disabled. Set UseTls or AllowInsecureLdap for dev/test.");
|
||||
|
||||
try
|
||||
{
|
||||
using var conn = new LdapConnection();
|
||||
if (options.UseTls) conn.SecureSocketLayer = true;
|
||||
await Task.Run(() => conn.Connect(options.Server, options.Port), ct);
|
||||
|
||||
var bindDn = await ResolveUserDnAsync(conn, username, ct);
|
||||
await Task.Run(() => conn.Bind(bindDn, password), ct);
|
||||
|
||||
// Rebind as service account for attribute read, if configured — otherwise the just-
|
||||
// bound user reads their own entry (works when ACL permits self-read).
|
||||
if (!string.IsNullOrWhiteSpace(options.ServiceAccountDn))
|
||||
await Task.Run(() => conn.Bind(options.ServiceAccountDn, options.ServiceAccountPassword), ct);
|
||||
|
||||
var displayName = username;
|
||||
var groups = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
var filter = $"(cn={EscapeLdapFilter(username)})";
|
||||
var results = await Task.Run(() =>
|
||||
conn.Search(options.SearchBase, LdapConnection.ScopeSub, filter, attrs: null, typesOnly: false), ct);
|
||||
|
||||
while (results.HasMore())
|
||||
{
|
||||
try
|
||||
{
|
||||
var entry = results.Next();
|
||||
var name = entry.GetAttribute(options.DisplayNameAttribute);
|
||||
if (name is not null) displayName = name.StringValue;
|
||||
|
||||
var groupAttr = entry.GetAttribute(options.GroupAttribute);
|
||||
if (groupAttr is not null)
|
||||
{
|
||||
foreach (var groupDn in groupAttr.StringValueArray)
|
||||
groups.Add(ExtractFirstRdnValue(groupDn));
|
||||
}
|
||||
|
||||
// GLAuth fallback: primary group is encoded as the ou= RDN above cn=.
|
||||
if (groups.Count == 0 && !string.IsNullOrEmpty(entry.Dn))
|
||||
{
|
||||
var primary = ExtractOuSegment(entry.Dn);
|
||||
if (primary is not null) groups.Add(primary);
|
||||
}
|
||||
}
|
||||
catch (LdapException) { break; }
|
||||
}
|
||||
}
|
||||
catch (LdapException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "LDAP attribute lookup failed for {User}", username);
|
||||
}
|
||||
|
||||
conn.Disconnect();
|
||||
|
||||
var roles = groups
|
||||
.Where(g => options.GroupToRole.ContainsKey(g))
|
||||
.Select(g => options.GroupToRole[g])
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
return new UserAuthResult(true, displayName, roles, null);
|
||||
}
|
||||
catch (LdapException ex)
|
||||
{
|
||||
logger.LogInformation("LDAP bind rejected user {User}: {Reason}", username, ex.ResultCode);
|
||||
return new UserAuthResult(false, null, [], "Invalid username or password");
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogError(ex, "Unexpected LDAP error for {User}", username);
|
||||
return new UserAuthResult(false, null, [], "Authentication error");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> ResolveUserDnAsync(LdapConnection conn, string username, CancellationToken ct)
|
||||
{
|
||||
if (username.Contains('=')) return username; // caller passed a DN directly
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.ServiceAccountDn))
|
||||
{
|
||||
await Task.Run(() => conn.Bind(options.ServiceAccountDn, options.ServiceAccountPassword), ct);
|
||||
|
||||
var filter = $"({options.UserNameAttribute}={EscapeLdapFilter(username)})";
|
||||
var results = await Task.Run(() =>
|
||||
conn.Search(options.SearchBase, LdapConnection.ScopeSub, filter, ["dn"], false), ct);
|
||||
|
||||
if (results.HasMore())
|
||||
return results.Next().Dn;
|
||||
|
||||
throw new LdapException("User not found", LdapException.NoSuchObject,
|
||||
$"No entry for uid={username}");
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(options.SearchBase)
|
||||
? $"cn={username}"
|
||||
: $"cn={username},{options.SearchBase}";
|
||||
}
|
||||
|
||||
internal static string EscapeLdapFilter(string input) =>
|
||||
input.Replace("\\", "\\5c")
|
||||
.Replace("*", "\\2a")
|
||||
.Replace("(", "\\28")
|
||||
.Replace(")", "\\29")
|
||||
.Replace("\0", "\\00");
|
||||
|
||||
internal static string? ExtractOuSegment(string dn)
|
||||
{
|
||||
foreach (var segment in dn.Split(','))
|
||||
{
|
||||
var trimmed = segment.Trim();
|
||||
if (trimmed.StartsWith("ou=", StringComparison.OrdinalIgnoreCase))
|
||||
return trimmed[3..];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
internal static string ExtractFirstRdnValue(string dn)
|
||||
{
|
||||
var eq = dn.IndexOf('=');
|
||||
if (eq < 0) return dn;
|
||||
var valueStart = eq + 1;
|
||||
var comma = dn.IndexOf(',', valueStart);
|
||||
return comma > valueStart ? dn[valueStart..comma] : dn[valueStart..];
|
||||
}
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -23,12 +23,18 @@
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0"/>
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Server" Version="1.5.374.126"/>
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration" Version="1.5.374.126"/>
|
||||
<PackageReference Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0"/>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Server.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
|
||||
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,128 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end round-trip through the DB for the <see cref="DriverHostStatus"/> entity
|
||||
/// added in PR 33 — exercises the composite primary key (NodeId, DriverInstanceId,
|
||||
/// HostName), string-backed <c>DriverHostState</c> conversion, and the two indexes the
|
||||
/// Admin UI's drill-down queries will scan (NodeId, LastSeenUtc).
|
||||
/// </summary>
|
||||
[Trait("Category", "SchemaCompliance")]
|
||||
[Collection(nameof(SchemaComplianceCollection))]
|
||||
public sealed class DriverHostStatusTests(SchemaComplianceFixture fixture)
|
||||
{
|
||||
[Fact]
|
||||
public async Task Composite_key_allows_same_host_across_different_nodes_or_drivers()
|
||||
{
|
||||
await using var ctx = NewContext();
|
||||
|
||||
// Same HostName + DriverInstanceId across two different server nodes — classic 2-node
|
||||
// redundancy case. Both rows must be insertable because each server node owns its own
|
||||
// runtime view of the shared host.
|
||||
var now = DateTime.UtcNow;
|
||||
ctx.DriverHostStatuses.Add(new DriverHostStatus
|
||||
{
|
||||
NodeId = "node-a", DriverInstanceId = "galaxy-1", HostName = "GRPlatform",
|
||||
State = DriverHostState.Running,
|
||||
StateChangedUtc = now, LastSeenUtc = now,
|
||||
});
|
||||
ctx.DriverHostStatuses.Add(new DriverHostStatus
|
||||
{
|
||||
NodeId = "node-b", DriverInstanceId = "galaxy-1", HostName = "GRPlatform",
|
||||
State = DriverHostState.Stopped,
|
||||
StateChangedUtc = now, LastSeenUtc = now,
|
||||
Detail = "secondary hasn't taken over yet",
|
||||
});
|
||||
// Same server node + host, different driver instance — second driver doesn't clobber.
|
||||
ctx.DriverHostStatuses.Add(new DriverHostStatus
|
||||
{
|
||||
NodeId = "node-a", DriverInstanceId = "modbus-plc1", HostName = "GRPlatform",
|
||||
State = DriverHostState.Running,
|
||||
StateChangedUtc = now, LastSeenUtc = now,
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var rows = await ctx.DriverHostStatuses.AsNoTracking()
|
||||
.Where(r => r.HostName == "GRPlatform").ToListAsync();
|
||||
|
||||
rows.Count.ShouldBe(3);
|
||||
rows.ShouldContain(r => r.NodeId == "node-a" && r.DriverInstanceId == "galaxy-1");
|
||||
rows.ShouldContain(r => r.NodeId == "node-b" && r.State == DriverHostState.Stopped && r.Detail == "secondary hasn't taken over yet");
|
||||
rows.ShouldContain(r => r.NodeId == "node-a" && r.DriverInstanceId == "modbus-plc1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upsert_pattern_for_same_key_updates_in_place()
|
||||
{
|
||||
// The publisher hosted service (follow-up PR) upserts on every transition +
|
||||
// heartbeat. This test pins the two-step pattern it will use: check-then-add-or-update
|
||||
// keyed on the composite PK. If the composite key ever changes, this test breaks
|
||||
// loudly so the publisher gets a synchronized update.
|
||||
await using var ctx = NewContext();
|
||||
var t0 = DateTime.UtcNow;
|
||||
ctx.DriverHostStatuses.Add(new DriverHostStatus
|
||||
{
|
||||
NodeId = "upsert-node", DriverInstanceId = "upsert-driver", HostName = "upsert-host",
|
||||
State = DriverHostState.Running,
|
||||
StateChangedUtc = t0, LastSeenUtc = t0,
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var t1 = t0.AddSeconds(30);
|
||||
await using (var ctx2 = NewContext())
|
||||
{
|
||||
var existing = await ctx2.DriverHostStatuses.SingleAsync(r =>
|
||||
r.NodeId == "upsert-node" && r.DriverInstanceId == "upsert-driver" && r.HostName == "upsert-host");
|
||||
existing.State = DriverHostState.Faulted;
|
||||
existing.StateChangedUtc = t1;
|
||||
existing.LastSeenUtc = t1;
|
||||
existing.Detail = "transport reset by peer";
|
||||
await ctx2.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await using var ctx3 = NewContext();
|
||||
var final = await ctx3.DriverHostStatuses.AsNoTracking().SingleAsync(r =>
|
||||
r.NodeId == "upsert-node" && r.HostName == "upsert-host");
|
||||
final.State.ShouldBe(DriverHostState.Faulted);
|
||||
final.Detail.ShouldBe("transport reset by peer");
|
||||
// Only one row — a naive "always insert" would have created a duplicate PK and thrown.
|
||||
(await ctx3.DriverHostStatuses.CountAsync(r => r.NodeId == "upsert-node")).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Enum_persists_as_string_not_int()
|
||||
{
|
||||
// Fluent config sets HasConversion<string>() on State — the DB stores 'Running' /
|
||||
// 'Stopped' / 'Faulted' / 'Unknown' as nvarchar(16). Verify by reading the raw
|
||||
// string back via ADO; if someone drops the conversion the column will contain '1'
|
||||
// / '2' / '3' and this assertion fails. Matters because DBAs inspecting the table
|
||||
// directly should see readable state names, not enum ordinals.
|
||||
await using var ctx = NewContext();
|
||||
ctx.DriverHostStatuses.Add(new DriverHostStatus
|
||||
{
|
||||
NodeId = "enum-node", DriverInstanceId = "enum-driver", HostName = "enum-host",
|
||||
State = DriverHostState.Faulted,
|
||||
StateChangedUtc = DateTime.UtcNow, LastSeenUtc = DateTime.UtcNow,
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
await using var conn = fixture.OpenConnection();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT [State] FROM DriverHostStatus WHERE NodeId = 'enum-node'";
|
||||
var rawValue = (string?)await cmd.ExecuteScalarAsync();
|
||||
rawValue.ShouldBe("Faulted");
|
||||
}
|
||||
|
||||
private OtOpcUaConfigDbContext NewContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseSqlServer(fixture.ConnectionString)
|
||||
.Options;
|
||||
return new OtOpcUaConfigDbContext(options);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ public sealed class SchemaComplianceTests
|
||||
"Namespace", "UnsArea", "UnsLine",
|
||||
"DriverInstance", "Device", "Equipment", "Tag", "PollGroup",
|
||||
"NodeAcl", "ExternalIdReservation",
|
||||
"DriverHostStatus",
|
||||
};
|
||||
|
||||
var actual = QueryStrings(@"
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins <see cref="GalaxyProxyDriver.ToHistoricalEvent"/> — the wire-to-domain mapping
|
||||
/// from <see cref="GalaxyHistoricalEvent"/> (MessagePack-annotated IPC contract,
|
||||
/// Unix-ms timestamps) to <c>Core.Abstractions.HistoricalEvent</c> (domain record,
|
||||
/// <see cref="DateTime"/> timestamps). Added in PR 35 alongside the new
|
||||
/// <c>IHistoryProvider.ReadEventsAsync</c> method.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HistoricalEventMappingTests
|
||||
{
|
||||
[Fact]
|
||||
public void Maps_every_field_from_wire_to_domain_record()
|
||||
{
|
||||
var wire = new GalaxyHistoricalEvent
|
||||
{
|
||||
EventId = "evt-42",
|
||||
SourceName = "Tank1.HiAlarm",
|
||||
EventTimeUtcUnixMs = 1_700_000_000_000L, // 2023-11-14T22:13:20.000Z
|
||||
ReceivedTimeUtcUnixMs = 1_700_000_000_500L,
|
||||
DisplayText = "High level reached",
|
||||
Severity = 750,
|
||||
};
|
||||
|
||||
var domain = GalaxyProxyDriver.ToHistoricalEvent(wire);
|
||||
|
||||
domain.EventId.ShouldBe("evt-42");
|
||||
domain.SourceName.ShouldBe("Tank1.HiAlarm");
|
||||
domain.EventTimeUtc.ShouldBe(new DateTime(2023, 11, 14, 22, 13, 20, DateTimeKind.Utc));
|
||||
domain.ReceivedTimeUtc.ShouldBe(new DateTime(2023, 11, 14, 22, 13, 20, 500, DateTimeKind.Utc));
|
||||
domain.Message.ShouldBe("High level reached");
|
||||
domain.Severity.ShouldBe((ushort)750);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Preserves_null_SourceName_and_DisplayText()
|
||||
{
|
||||
// Historical rows from the Galaxy event historian often omit source or message for
|
||||
// system events (e.g. time sync). The mapping must preserve null — callers use it to
|
||||
// distinguish system events from alarm events.
|
||||
var wire = new GalaxyHistoricalEvent
|
||||
{
|
||||
EventId = "sys-1",
|
||||
SourceName = null,
|
||||
EventTimeUtcUnixMs = 0,
|
||||
ReceivedTimeUtcUnixMs = 0,
|
||||
DisplayText = null,
|
||||
Severity = 1,
|
||||
};
|
||||
|
||||
var domain = GalaxyProxyDriver.ToHistoricalEvent(wire);
|
||||
|
||||
domain.SourceName.ShouldBeNull();
|
||||
domain.Message.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EventTime_and_ReceivedTime_are_produced_as_DateTimeKind_Utc()
|
||||
{
|
||||
// Unix-ms timestamps come off the wire timezone-agnostic; the mapping must tag the
|
||||
// resulting DateTime as Utc so downstream serializers (JSON, OPC UA types) don't apply
|
||||
// an unexpected local-time offset.
|
||||
var wire = new GalaxyHistoricalEvent
|
||||
{
|
||||
EventId = "e",
|
||||
EventTimeUtcUnixMs = 1_000L,
|
||||
ReceivedTimeUtcUnixMs = 2_000L,
|
||||
};
|
||||
|
||||
var domain = GalaxyProxyDriver.ToHistoricalEvent(wire);
|
||||
|
||||
domain.EventTimeUtc.Kind.ShouldBe(DateTimeKind.Utc);
|
||||
domain.ReceivedTimeUtc.Kind.ShouldBe(DateTimeKind.Utc);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
|
||||
|
||||
/// <summary>
|
||||
/// Tag map for the AutomationDirect DL205 device class. Mirrors what the ModbusPal
|
||||
/// <c>.xmpp</c> profile in <c>ModbusPal/DL205.xmpp</c> exposes (or the real PLC, when
|
||||
/// <see cref="ModbusSimulatorFixture"/> is pointed at one).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the scaffold — each tag is deliberately generic so the smoke test has stable
|
||||
/// addresses to read. Device-specific quirk tests (word order, max-register, register-zero
|
||||
/// access, etc.) will land in their own test classes alongside this profile as the user
|
||||
/// validates each behavior in ModbusPal; see <c>docs/v2/modbus-test-plan.md</c> §per-device
|
||||
/// quirk catalog for the checklist.
|
||||
/// </remarks>
|
||||
public static class DL205Profile
|
||||
{
|
||||
/// <summary>Holding register the smoke test reads. Address 100 sidesteps the DL205
|
||||
/// register-zero quirk (pending confirmation) — see modbus-test-plan.md.</summary>
|
||||
public const ushort SmokeHoldingRegister = 100;
|
||||
|
||||
/// <summary>Expected value the ModbusPal profile seeds into register 100. When running
|
||||
/// against a real DL205 (or a ModbusPal profile where this register is writable), the smoke
|
||||
/// test seeds this value first, then reads it back.</summary>
|
||||
public const short SmokeHoldingValue = 1234;
|
||||
|
||||
public static ModbusDriverOptions BuildOptions(string host, int port) => new()
|
||||
{
|
||||
Host = host,
|
||||
Port = port,
|
||||
UnitId = 1,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
Tags =
|
||||
[
|
||||
new ModbusTagDefinition(
|
||||
Name: "DL205_Smoke_HReg100",
|
||||
Region: ModbusRegion.HoldingRegisters,
|
||||
Address: SmokeHoldingRegister,
|
||||
DataType: ModbusDataType.Int16,
|
||||
Writable: true),
|
||||
],
|
||||
// Disable the background probe loop — integration tests drive reads explicitly and
|
||||
// the probe would race with assertions.
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end smoke against the DL205 ModbusPal profile (or a real DL205 when
|
||||
/// <c>MODBUS_SIM_ENDPOINT</c> points at one). Drives the full <see cref="ModbusDriver"/>
|
||||
/// + real <see cref="ModbusTcpTransport"/> stack — no fake transport. Success proves the
|
||||
/// driver can initialize against the simulator, write a known value, and read it back
|
||||
/// with the correct status and value, which is the baseline every device-quirk test
|
||||
/// builds on.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Device-specific quirk tests (word order, max-register, register-zero access, exception
|
||||
/// code translation, etc.) land as separate test classes in this directory as each quirk
|
||||
/// is validated in ModbusPal. Keep this smoke test deliberately narrow — any deviation
|
||||
/// the driver hits beyond "happy-path FC16 + FC03 round-trip" belongs in its own named
|
||||
/// test so filtering by device class (<c>--filter DisplayName~DL205</c>) surfaces the
|
||||
/// quirk-specific failure mode.
|
||||
/// </remarks>
|
||||
[Collection(ModbusSimulatorCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "DL205")]
|
||||
public sealed class DL205SmokeTests(ModbusSimulatorFixture sim)
|
||||
{
|
||||
[Fact]
|
||||
public async Task DL205_roundtrip_write_then_read_of_holding_register()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
|
||||
var options = DL205Profile.BuildOptions(sim.Host, sim.Port);
|
||||
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-smoke");
|
||||
await driver.InitializeAsync(driverConfigJson: "{}", TestContext.Current.CancellationToken);
|
||||
|
||||
// Write first so the test is self-contained — ModbusPal's default register bank is
|
||||
// zeroed at simulator start, and tests must not depend on prior-test state per the
|
||||
// test-plan conventions.
|
||||
var writeResults = await driver.WriteAsync(
|
||||
[new(FullReference: "DL205_Smoke_HReg100", Value: (short)DL205Profile.SmokeHoldingValue)],
|
||||
TestContext.Current.CancellationToken);
|
||||
writeResults.Count.ShouldBe(1);
|
||||
writeResults[0].StatusCode.ShouldBe(0u, "write must succeed against the ModbusPal DL205 profile");
|
||||
|
||||
var readResults = await driver.ReadAsync(
|
||||
["DL205_Smoke_HReg100"],
|
||||
TestContext.Current.CancellationToken);
|
||||
readResults.Count.ShouldBe(1);
|
||||
readResults[0].StatusCode.ShouldBe(0u);
|
||||
readResults[0].Value.ShouldBe((short)DL205Profile.SmokeHoldingValue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
# ModbusPal simulator profiles
|
||||
|
||||
Drop device-specific `.xmpp` profiles here. The integration tests connect to the
|
||||
endpoint in `MODBUS_SIM_ENDPOINT` (default `localhost:502`) and expect the
|
||||
simulator to already be running — tests do not launch ModbusPal themselves,
|
||||
because its Java GUI + JRE requirement is heavier than the harness is worth.
|
||||
|
||||
## Getting started
|
||||
|
||||
1. Download ModbusPal from SourceForge (`modbuspal.jar`).
|
||||
2. `java -jar modbuspal.jar` to launch the GUI.
|
||||
3. Load a profile from this directory (or configure one manually) and start the
|
||||
simulator on TCP port 502.
|
||||
4. `dotnet test tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests` — tests
|
||||
auto-skip with a clear `SkipReason` if the TCP probe at the configured
|
||||
endpoint fails within 2 seconds.
|
||||
|
||||
## Profile files
|
||||
|
||||
- `DL205.xmpp` — _to be added_ — register map reflecting the AutomationDirect
|
||||
DL205 quirks tracked in `docs/v2/modbus-test-plan.md`. The scaffolded smoke
|
||||
test in `DL205/DL205SmokeTests.cs` needs holding register 100 writable and
|
||||
present; a minimal ModbusPal profile with a single holding-register bank at
|
||||
address 100 is sufficient.
|
||||
|
||||
## Environment variables
|
||||
|
||||
- `MODBUS_SIM_ENDPOINT` — override the simulator endpoint. Accepts `host:port`;
|
||||
defaults to `localhost:502`. Useful when pointing the suite at a real PLC on
|
||||
the bench.
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Reachability probe for a Modbus TCP simulator (ModbusPal or a real PLC). Parses
|
||||
/// <c>MODBUS_SIM_ENDPOINT</c> (default <c>localhost:502</c>) and TCP-connects once at
|
||||
/// fixture construction. Each test checks <see cref="SkipReason"/> and calls
|
||||
/// <c>Assert.Skip</c> when the endpoint was unreachable, so a dev box without a running
|
||||
/// simulator still passes `dotnet test` cleanly — matches the Galaxy live-smoke pattern in
|
||||
/// <c>GalaxyRepositoryLiveSmokeTests</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Do NOT keep the probe socket open for the life of the fixture. The probe is a
|
||||
/// one-shot liveness check; tests open their own transports (the real
|
||||
/// <see cref="ModbusTcpTransport"/>) against the same endpoint. Sharing a socket
|
||||
/// across tests would serialize them on a single TCP stream.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The fixture is a collection fixture so the reachability probe runs once per test
|
||||
/// session, not per test — checking every test would waste several seconds against a
|
||||
/// firewalled endpoint that times out each attempt.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ModbusSimulatorFixture : IAsyncDisposable
|
||||
{
|
||||
private const string DefaultEndpoint = "localhost:502";
|
||||
private const string EndpointEnvVar = "MODBUS_SIM_ENDPOINT";
|
||||
|
||||
public string Host { get; }
|
||||
public int Port { get; }
|
||||
public string? SkipReason { get; }
|
||||
|
||||
public ModbusSimulatorFixture()
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable(EndpointEnvVar) ?? DefaultEndpoint;
|
||||
var parts = raw.Split(':', 2);
|
||||
Host = parts[0];
|
||||
Port = parts.Length == 2 && int.TryParse(parts[1], out var p) ? p : 502;
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
var task = client.ConnectAsync(Host, Port);
|
||||
if (!task.Wait(TimeSpan.FromSeconds(2)) || !client.Connected)
|
||||
{
|
||||
SkipReason = $"Modbus simulator at {Host}:{Port} did not accept a TCP connection within 2s. " +
|
||||
$"Start ModbusPal (or override {EndpointEnvVar}) and re-run.";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SkipReason = $"Modbus simulator at {Host}:{Port} unreachable: {ex.GetType().Name}: {ex.Message}. " +
|
||||
$"Start ModbusPal (or override {EndpointEnvVar}) and re-run.";
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
[Xunit.CollectionDefinition(Name)]
|
||||
public sealed class ModbusSimulatorCollection : Xunit.ICollectionFixture<ModbusSimulatorFixture>
|
||||
{
|
||||
public const string Name = "ModbusSimulator";
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Modbus\ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="ModbusPal\**\*" CopyToOutputDirectory="PreserveNewest"/>
|
||||
<None Update="DL205\**\*" CopyToOutputDirectory="PreserveNewest"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
244
tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusDriverTests.cs
Normal file
244
tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusDriverTests.cs
Normal file
@@ -0,0 +1,244 @@
|
||||
using System.Buffers.Binary;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusDriverTests
|
||||
{
|
||||
/// <summary>
|
||||
/// In-memory Modbus TCP server impl that speaks the function codes the driver uses.
|
||||
/// Maintains a register/coil bank so Read/Write round-trips work.
|
||||
/// </summary>
|
||||
private sealed class FakeTransport : IModbusTransport
|
||||
{
|
||||
public readonly ushort[] HoldingRegisters = new ushort[256];
|
||||
public readonly ushort[] InputRegisters = new ushort[256];
|
||||
public readonly bool[] Coils = new bool[256];
|
||||
public readonly bool[] DiscreteInputs = new bool[256];
|
||||
public bool ForceConnectFail { get; set; }
|
||||
|
||||
public Task ConnectAsync(CancellationToken ct)
|
||||
=> ForceConnectFail ? Task.FromException(new InvalidOperationException("connect refused")) : Task.CompletedTask;
|
||||
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
var fc = pdu[0];
|
||||
return fc switch
|
||||
{
|
||||
0x01 => Task.FromResult(ReadBits(pdu, Coils)),
|
||||
0x02 => Task.FromResult(ReadBits(pdu, DiscreteInputs)),
|
||||
0x03 => Task.FromResult(ReadRegs(pdu, HoldingRegisters)),
|
||||
0x04 => Task.FromResult(ReadRegs(pdu, InputRegisters)),
|
||||
0x05 => Task.FromResult(WriteCoil(pdu)),
|
||||
0x06 => Task.FromResult(WriteSingleReg(pdu)),
|
||||
0x10 => Task.FromResult(WriteMultipleRegs(pdu)),
|
||||
_ => Task.FromException<byte[]>(new ModbusException(fc, 0x01, $"fc={fc} not supported by fake")),
|
||||
};
|
||||
}
|
||||
|
||||
private byte[] ReadBits(byte[] pdu, bool[] bank)
|
||||
{
|
||||
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
|
||||
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
var byteCount = (byte)((qty + 7) / 8);
|
||||
var resp = new byte[2 + byteCount];
|
||||
resp[0] = pdu[0];
|
||||
resp[1] = byteCount;
|
||||
for (var i = 0; i < qty; i++)
|
||||
if (bank[addr + i]) resp[2 + (i / 8)] |= (byte)(1 << (i % 8));
|
||||
return resp;
|
||||
}
|
||||
|
||||
private byte[] ReadRegs(byte[] pdu, ushort[] bank)
|
||||
{
|
||||
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
|
||||
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
var byteCount = (byte)(qty * 2);
|
||||
var resp = new byte[2 + byteCount];
|
||||
resp[0] = pdu[0];
|
||||
resp[1] = byteCount;
|
||||
for (var i = 0; i < qty; i++)
|
||||
{
|
||||
resp[2 + i * 2] = (byte)(bank[addr + i] >> 8);
|
||||
resp[3 + i * 2] = (byte)(bank[addr + i] & 0xFF);
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
private byte[] WriteCoil(byte[] pdu)
|
||||
{
|
||||
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
|
||||
Coils[addr] = pdu[3] == 0xFF;
|
||||
return pdu; // Modbus echoes the request on write success
|
||||
}
|
||||
|
||||
private byte[] WriteSingleReg(byte[] pdu)
|
||||
{
|
||||
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
|
||||
HoldingRegisters[addr] = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
return pdu;
|
||||
}
|
||||
|
||||
private byte[] WriteMultipleRegs(byte[] pdu)
|
||||
{
|
||||
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
|
||||
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
for (var i = 0; i < qty; i++)
|
||||
HoldingRegisters[addr + i] = (ushort)((pdu[6 + i * 2] << 8) | pdu[7 + i * 2]);
|
||||
return new byte[] { 0x10, pdu[1], pdu[2], pdu[3], pdu[4] };
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private static (ModbusDriver driver, FakeTransport fake) NewDriver(params ModbusTagDefinition[] tags)
|
||||
{
|
||||
var fake = new FakeTransport();
|
||||
var opts = new ModbusDriverOptions { Host = "fake", Tags = tags };
|
||||
var drv = new ModbusDriver(opts, "modbus-1", _ => fake);
|
||||
return (drv, fake);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initialize_connects_and_populates_tag_map()
|
||||
{
|
||||
var (drv, _) = NewDriver(
|
||||
new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16),
|
||||
new ModbusTagDefinition("Run", ModbusRegion.Coils, 0, ModbusDataType.Bool));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_Int16_holding_register_returns_BigEndian_value()
|
||||
{
|
||||
var (drv, fake) = NewDriver(new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 10, ModbusDataType.Int16));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
fake.HoldingRegisters[10] = 12345;
|
||||
|
||||
var r = await drv.ReadAsync(["Level"], CancellationToken.None);
|
||||
r[0].Value.ShouldBe((short)12345);
|
||||
r[0].StatusCode.ShouldBe(0u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_Float32_spans_two_registers_BigEndian()
|
||||
{
|
||||
var (drv, fake) = NewDriver(new ModbusTagDefinition("Temp", ModbusRegion.HoldingRegisters, 4, ModbusDataType.Float32));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// IEEE 754 single for 25.5f is 0x41CC0000 — [41 CC][00 00] big-endian across two regs.
|
||||
var bytes = new byte[4];
|
||||
BinaryPrimitives.WriteSingleBigEndian(bytes, 25.5f);
|
||||
fake.HoldingRegisters[4] = (ushort)((bytes[0] << 8) | bytes[1]);
|
||||
fake.HoldingRegisters[5] = (ushort)((bytes[2] << 8) | bytes[3]);
|
||||
|
||||
var r = await drv.ReadAsync(["Temp"], CancellationToken.None);
|
||||
r[0].Value.ShouldBe(25.5f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_Coil_returns_boolean()
|
||||
{
|
||||
var (drv, fake) = NewDriver(new ModbusTagDefinition("Run", ModbusRegion.Coils, 3, ModbusDataType.Bool));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
fake.Coils[3] = true;
|
||||
|
||||
var r = await drv.ReadAsync(["Run"], CancellationToken.None);
|
||||
r[0].Value.ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_tag_returns_BadNodeIdUnknown_not_an_exception()
|
||||
{
|
||||
var (drv, _) = NewDriver();
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var r = await drv.ReadAsync(["DoesNotExist"], CancellationToken.None);
|
||||
r[0].StatusCode.ShouldBe(0x80340000u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_UInt16_holding_register_roundtrips()
|
||||
{
|
||||
var (drv, fake) = NewDriver(new ModbusTagDefinition("Setpoint", ModbusRegion.HoldingRegisters, 20, ModbusDataType.UInt16));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync([new WriteRequest("Setpoint", (ushort)42000)], CancellationToken.None);
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
fake.HoldingRegisters[20].ShouldBe((ushort)42000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_Float32_uses_FC16_WriteMultipleRegisters()
|
||||
{
|
||||
var (drv, fake) = NewDriver(new ModbusTagDefinition("Temp", ModbusRegion.HoldingRegisters, 4, ModbusDataType.Float32));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.WriteAsync([new WriteRequest("Temp", 25.5f)], CancellationToken.None);
|
||||
|
||||
// Decode back through the fake bank to check the two-register shape.
|
||||
var raw = new byte[4];
|
||||
raw[0] = (byte)(fake.HoldingRegisters[4] >> 8);
|
||||
raw[1] = (byte)(fake.HoldingRegisters[4] & 0xFF);
|
||||
raw[2] = (byte)(fake.HoldingRegisters[5] >> 8);
|
||||
raw[3] = (byte)(fake.HoldingRegisters[5] & 0xFF);
|
||||
BinaryPrimitives.ReadSingleBigEndian(raw).ShouldBe(25.5f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_to_InputRegister_returns_BadNotWritable()
|
||||
{
|
||||
var (drv, _) = NewDriver(new ModbusTagDefinition("Ro", ModbusRegion.InputRegisters, 0, ModbusDataType.UInt16, Writable: false));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Ro", (ushort)7)], CancellationToken.None);
|
||||
r[0].StatusCode.ShouldBe(0x803B0000u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Discover_streams_one_folder_per_driver_with_a_variable_per_tag()
|
||||
{
|
||||
var (drv, _) = NewDriver(
|
||||
new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16),
|
||||
new ModbusTagDefinition("Temp", ModbusRegion.HoldingRegisters, 4, ModbusDataType.Float32),
|
||||
new ModbusTagDefinition("Run", ModbusRegion.Coils, 0, ModbusDataType.Bool));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var builder = new RecordingBuilder();
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.Count.ShouldBe(1);
|
||||
builder.Folders[0].BrowseName.ShouldBe("Modbus");
|
||||
builder.Variables.Count.ShouldBe(3);
|
||||
builder.Variables.ShouldContain(v => v.BrowseName == "Level" && v.Info.DriverDataType == DriverDataType.Int32);
|
||||
builder.Variables.ShouldContain(v => v.BrowseName == "Temp" && v.Info.DriverDataType == DriverDataType.Float32);
|
||||
builder.Variables.ShouldContain(v => v.BrowseName == "Run" && v.Info.DriverDataType == DriverDataType.Boolean);
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{ Folders.Add((browseName, displayName)); return this; }
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
|
||||
public void AddProperty(string _, DriverDataType __, object? ___) { }
|
||||
private sealed class Handle(string fullRef) : IVariableHandle
|
||||
{
|
||||
public string FullReference => fullRef;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||
}
|
||||
private sealed class NullSink : IAlarmConditionSink
|
||||
{
|
||||
public void OnTransition(AlarmEventArgs args) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
208
tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusProbeTests.cs
Normal file
208
tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusProbeTests.cs
Normal file
@@ -0,0 +1,208 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusProbeTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Transport fake the probe tests flip between "responding" and "unreachable" to
|
||||
/// exercise the state machine. Calls to SendAsync with FC=0x03 count as probe traffic
|
||||
/// (the driver's probe loop issues exactly that shape).
|
||||
/// </summary>
|
||||
private sealed class FlappyTransport : IModbusTransport
|
||||
{
|
||||
public volatile bool Reachable = true;
|
||||
public int ProbeCount;
|
||||
|
||||
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
if (pdu[0] == 0x03) Interlocked.Increment(ref ProbeCount);
|
||||
if (!Reachable)
|
||||
return Task.FromException<byte[]>(new IOException("transport unreachable"));
|
||||
|
||||
// Happy path — return a valid FC03 response for 1 register at addr.
|
||||
if (pdu[0] == 0x03)
|
||||
{
|
||||
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
var resp = new byte[2 + qty * 2];
|
||||
resp[0] = 0x03;
|
||||
resp[1] = (byte)(qty * 2);
|
||||
return Task.FromResult(resp);
|
||||
}
|
||||
return Task.FromException<byte[]>(new NotSupportedException());
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private static (ModbusDriver drv, FlappyTransport fake) NewDriver(ModbusProbeOptions probe)
|
||||
{
|
||||
var fake = new FlappyTransport();
|
||||
var opts = new ModbusDriverOptions { Host = "fake", Port = 502, Probe = probe };
|
||||
return (new ModbusDriver(opts, "modbus-1", _ => fake), fake);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initial_state_is_Unknown_before_first_probe_tick()
|
||||
{
|
||||
var (drv, _) = NewDriver(new ModbusProbeOptions { Enabled = false });
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var statuses = drv.GetHostStatuses();
|
||||
statuses.Count.ShouldBe(1);
|
||||
statuses[0].State.ShouldBe(HostState.Unknown);
|
||||
statuses[0].HostName.ShouldBe("fake:502");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task First_successful_probe_transitions_to_Running()
|
||||
{
|
||||
var (drv, fake) = NewDriver(new ModbusProbeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Interval = TimeSpan.FromMilliseconds(150),
|
||||
Timeout = TimeSpan.FromSeconds(1),
|
||||
});
|
||||
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Wait for the first probe to complete.
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2);
|
||||
while (fake.ProbeCount == 0 && DateTime.UtcNow < deadline) await Task.Delay(25);
|
||||
|
||||
// Then wait for the event to actually arrive.
|
||||
deadline = DateTime.UtcNow + TimeSpan.FromSeconds(1);
|
||||
while (transitions.Count == 0 && DateTime.UtcNow < deadline) await Task.Delay(25);
|
||||
|
||||
transitions.Count.ShouldBeGreaterThanOrEqualTo(1);
|
||||
transitions.TryDequeue(out var t).ShouldBeTrue();
|
||||
t!.OldState.ShouldBe(HostState.Unknown);
|
||||
t.NewState.ShouldBe(HostState.Running);
|
||||
drv.GetHostStatuses()[0].State.ShouldBe(HostState.Running);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Transport_failure_transitions_to_Stopped()
|
||||
{
|
||||
var (drv, fake) = NewDriver(new ModbusProbeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Interval = TimeSpan.FromMilliseconds(150),
|
||||
Timeout = TimeSpan.FromSeconds(1),
|
||||
});
|
||||
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitForStateAsync(drv, HostState.Running, TimeSpan.FromSeconds(2));
|
||||
|
||||
fake.Reachable = false;
|
||||
await WaitForStateAsync(drv, HostState.Stopped, TimeSpan.FromSeconds(2));
|
||||
|
||||
transitions.Select(t => t.NewState).ShouldContain(HostState.Stopped);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Recovery_transitions_Stopped_back_to_Running()
|
||||
{
|
||||
var (drv, fake) = NewDriver(new ModbusProbeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Interval = TimeSpan.FromMilliseconds(150),
|
||||
Timeout = TimeSpan.FromSeconds(1),
|
||||
});
|
||||
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitForStateAsync(drv, HostState.Running, TimeSpan.FromSeconds(2));
|
||||
|
||||
fake.Reachable = false;
|
||||
await WaitForStateAsync(drv, HostState.Stopped, TimeSpan.FromSeconds(2));
|
||||
|
||||
fake.Reachable = true;
|
||||
await WaitForStateAsync(drv, HostState.Running, TimeSpan.FromSeconds(2));
|
||||
|
||||
// We expect at minimum: Unknown→Running, Running→Stopped, Stopped→Running.
|
||||
transitions.Count.ShouldBeGreaterThanOrEqualTo(3);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Repeated_successful_probes_do_not_generate_duplicate_Running_events()
|
||||
{
|
||||
var (drv, _) = NewDriver(new ModbusProbeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Interval = TimeSpan.FromMilliseconds(100),
|
||||
Timeout = TimeSpan.FromSeconds(1),
|
||||
});
|
||||
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitForStateAsync(drv, HostState.Running, TimeSpan.FromSeconds(2));
|
||||
await Task.Delay(500); // several more probe ticks, all successful — state shouldn't thrash
|
||||
|
||||
transitions.Count.ShouldBe(1); // only the initial Unknown→Running
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disabled_probe_stays_Unknown_and_fires_no_events()
|
||||
{
|
||||
var (drv, _) = NewDriver(new ModbusProbeOptions { Enabled = false });
|
||||
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await Task.Delay(300);
|
||||
|
||||
transitions.Count.ShouldBe(0);
|
||||
drv.GetHostStatuses()[0].State.ShouldBe(HostState.Unknown);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Shutdown_stops_the_probe_loop()
|
||||
{
|
||||
var (drv, fake) = NewDriver(new ModbusProbeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Interval = TimeSpan.FromMilliseconds(100),
|
||||
Timeout = TimeSpan.FromSeconds(1),
|
||||
});
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitForStateAsync(drv, HostState.Running, TimeSpan.FromSeconds(2));
|
||||
|
||||
var before = fake.ProbeCount;
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
await Task.Delay(400);
|
||||
|
||||
// A handful of in-flight ticks may complete after shutdown in a narrow race; the
|
||||
// contract is that the loop stops scheduling new ones. Tolerate ≤1 extra.
|
||||
(fake.ProbeCount - before).ShouldBeLessThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
private static async Task WaitForStateAsync(ModbusDriver drv, HostState expected, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (drv.GetHostStatuses()[0].State == expected) return;
|
||||
await Task.Delay(25);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusSubscriptionTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Lightweight fake transport the subscription tests drive through — only the FC03
|
||||
/// (Read Holding Registers) path is used. Mutating <see cref="HoldingRegisters"/>
|
||||
/// between polls is how each test simulates a PLC value change.
|
||||
/// </summary>
|
||||
private sealed class FakeTransport : IModbusTransport
|
||||
{
|
||||
public readonly ushort[] HoldingRegisters = new ushort[256];
|
||||
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
if (pdu[0] != 0x03) return Task.FromException<byte[]>(new NotSupportedException("FC not supported"));
|
||||
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
|
||||
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
var resp = new byte[2 + qty * 2];
|
||||
resp[0] = 0x03;
|
||||
resp[1] = (byte)(qty * 2);
|
||||
for (var i = 0; i < qty; i++)
|
||||
{
|
||||
resp[2 + i * 2] = (byte)(HoldingRegisters[addr + i] >> 8);
|
||||
resp[3 + i * 2] = (byte)(HoldingRegisters[addr + i] & 0xFF);
|
||||
}
|
||||
return Task.FromResult(resp);
|
||||
}
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private static (ModbusDriver drv, FakeTransport fake) NewDriver(params ModbusTagDefinition[] tags)
|
||||
{
|
||||
var fake = new FakeTransport();
|
||||
var opts = new ModbusDriverOptions { Host = "fake", Tags = tags };
|
||||
return (new ModbusDriver(opts, "modbus-1", _ => fake), fake);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initial_poll_raises_OnDataChange_for_every_subscribed_tag()
|
||||
{
|
||||
var (drv, fake) = NewDriver(
|
||||
new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16),
|
||||
new ModbusTagDefinition("Temp", ModbusRegion.HoldingRegisters, 1, ModbusDataType.Int16));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
fake.HoldingRegisters[0] = 100;
|
||||
fake.HoldingRegisters[1] = 200;
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["Level", "Temp"], TimeSpan.FromMilliseconds(200), CancellationToken.None);
|
||||
await WaitForCountAsync(events, 2, TimeSpan.FromSeconds(2));
|
||||
|
||||
events.Select(e => e.FullReference).ShouldContain("Level");
|
||||
events.Select(e => e.FullReference).ShouldContain("Temp");
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unchanged_values_do_not_raise_after_initial_poll()
|
||||
{
|
||||
var (drv, fake) = NewDriver(new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
fake.HoldingRegisters[0] = 100;
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["Level"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
await Task.Delay(500); // ~5 poll cycles at 100ms, value stable the whole time
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
|
||||
events.Count.ShouldBe(1); // only the initial-data push, no change events after
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Value_change_between_polls_raises_OnDataChange()
|
||||
{
|
||||
var (drv, fake) = NewDriver(new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
fake.HoldingRegisters[0] = 100;
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["Level"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
await WaitForCountAsync(events, 1, TimeSpan.FromSeconds(1));
|
||||
fake.HoldingRegisters[0] = 200; // simulate PLC update
|
||||
await WaitForCountAsync(events, 2, TimeSpan.FromSeconds(2));
|
||||
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
events.Count.ShouldBeGreaterThanOrEqualTo(2);
|
||||
events.Last().Snapshot.Value.ShouldBe((short)200);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unsubscribe_stops_the_polling_loop()
|
||||
{
|
||||
var (drv, fake) = NewDriver(new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["Level"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
await WaitForCountAsync(events, 1, TimeSpan.FromSeconds(1));
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
|
||||
var countAfterUnsub = events.Count;
|
||||
fake.HoldingRegisters[0] = 999; // would trigger a change if still polling
|
||||
await Task.Delay(400);
|
||||
events.Count.ShouldBe(countAfterUnsub);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_floors_intervals_below_100ms()
|
||||
{
|
||||
var (drv, _) = NewDriver(new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// 10ms requested — implementation floors to 100ms. We verify indirectly: over 300ms, a
|
||||
// 10ms interval would produce many more events than a 100ms interval would on a stable
|
||||
// value. Since the value is unchanged, we only expect the initial-data push (1 event).
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["Level"], TimeSpan.FromMilliseconds(10), CancellationToken.None);
|
||||
await Task.Delay(300);
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
|
||||
events.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Multiple_subscriptions_fire_independently()
|
||||
{
|
||||
var (drv, fake) = NewDriver(
|
||||
new ModbusTagDefinition("A", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16),
|
||||
new ModbusTagDefinition("B", ModbusRegion.HoldingRegisters, 1, ModbusDataType.Int16));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var eventsA = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
var eventsB = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) =>
|
||||
{
|
||||
if (e.FullReference == "A") eventsA.Enqueue(e);
|
||||
else if (e.FullReference == "B") eventsB.Enqueue(e);
|
||||
};
|
||||
|
||||
var ha = await drv.SubscribeAsync(["A"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
var hb = await drv.SubscribeAsync(["B"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
await WaitForCountAsync(eventsA, 1, TimeSpan.FromSeconds(1));
|
||||
await WaitForCountAsync(eventsB, 1, TimeSpan.FromSeconds(1));
|
||||
|
||||
await drv.UnsubscribeAsync(ha, CancellationToken.None);
|
||||
var aCount = eventsA.Count;
|
||||
fake.HoldingRegisters[1] = 77; // only B should pick this up
|
||||
await WaitForCountAsync(eventsB, 2, TimeSpan.FromSeconds(2));
|
||||
|
||||
eventsA.Count.ShouldBe(aCount); // unchanged since unsubscribe
|
||||
eventsB.Count.ShouldBeGreaterThanOrEqualTo(2);
|
||||
await drv.UnsubscribeAsync(hb, CancellationToken.None);
|
||||
}
|
||||
|
||||
private static async Task WaitForCountAsync<T>(ConcurrentQueue<T> q, int target, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (q.Count < target && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(25);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Modbus\ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
197
tests/ZB.MOM.WW.OtOpcUa.Server.Tests/HostStatusPublisherTests.cs
Normal file
197
tests/ZB.MOM.WW.OtOpcUa.Server.Tests/HostStatusPublisherTests.cs
Normal file
@@ -0,0 +1,197 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Server;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class HostStatusPublisherTests : IDisposable
|
||||
{
|
||||
private const string DefaultServer = "localhost,14330";
|
||||
private const string DefaultSaPassword = "OtOpcUaDev_2026!";
|
||||
|
||||
private readonly string _databaseName = $"OtOpcUaPublisher_{Guid.NewGuid():N}";
|
||||
private readonly string _connectionString;
|
||||
private readonly ServiceProvider _sp;
|
||||
|
||||
public HostStatusPublisherTests()
|
||||
{
|
||||
var server = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SERVER") ?? DefaultServer;
|
||||
var password = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SA_PASSWORD") ?? DefaultSaPassword;
|
||||
_connectionString =
|
||||
$"Server={server};Database={_databaseName};User Id=sa;Password={password};TrustServerCertificate=True;Encrypt=False;";
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddDbContext<OtOpcUaConfigDbContext>(o => o.UseSqlServer(_connectionString));
|
||||
_sp = services.BuildServiceProvider();
|
||||
|
||||
using var scope = _sp.CreateScope();
|
||||
scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>().Database.Migrate();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_sp.Dispose();
|
||||
using var conn = new Microsoft.Data.SqlClient.SqlConnection(
|
||||
new Microsoft.Data.SqlClient.SqlConnectionStringBuilder(_connectionString) { InitialCatalog = "master" }.ConnectionString);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = $@"
|
||||
IF DB_ID(N'{_databaseName}') IS NOT NULL
|
||||
BEGIN
|
||||
ALTER DATABASE [{_databaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
|
||||
DROP DATABASE [{_databaseName}];
|
||||
END";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publisher_upserts_one_row_per_host_reported_by_each_probe_driver()
|
||||
{
|
||||
var driverHost = new DriverHost();
|
||||
await driverHost.RegisterAsync(new ProbeStubDriver("driver-a",
|
||||
new HostConnectivityStatus("HostA1", HostState.Running, DateTime.UtcNow),
|
||||
new HostConnectivityStatus("HostA2", HostState.Stopped, DateTime.UtcNow)),
|
||||
"{}", CancellationToken.None);
|
||||
await driverHost.RegisterAsync(new NonProbeStubDriver("driver-no-probe"), "{}", CancellationToken.None);
|
||||
|
||||
var nodeOptions = NewNodeOptions("node-a");
|
||||
var publisher = new HostStatusPublisher(driverHost, nodeOptions, _sp.GetRequiredService<IServiceScopeFactory>(),
|
||||
NullLogger<HostStatusPublisher>.Instance);
|
||||
|
||||
await publisher.PublishOnceAsync(CancellationToken.None);
|
||||
|
||||
using var scope = _sp.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||
var rows = await db.DriverHostStatuses.AsNoTracking().ToListAsync();
|
||||
|
||||
rows.Count.ShouldBe(2, "driver-no-probe doesn't implement IHostConnectivityProbe — no rows for it");
|
||||
rows.ShouldContain(r => r.HostName == "HostA1" && r.State == DriverHostState.Running && r.DriverInstanceId == "driver-a");
|
||||
rows.ShouldContain(r => r.HostName == "HostA2" && r.State == DriverHostState.Stopped && r.DriverInstanceId == "driver-a");
|
||||
rows.ShouldAllBe(r => r.NodeId == "node-a");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Second_tick_updates_LastSeenUtc_without_creating_duplicate_rows()
|
||||
{
|
||||
var driver = new ProbeStubDriver("driver-x",
|
||||
new HostConnectivityStatus("HostX", HostState.Running, DateTime.UtcNow));
|
||||
var driverHost = new DriverHost();
|
||||
await driverHost.RegisterAsync(driver, "{}", CancellationToken.None);
|
||||
|
||||
var publisher = new HostStatusPublisher(driverHost, NewNodeOptions("node-x"),
|
||||
_sp.GetRequiredService<IServiceScopeFactory>(),
|
||||
NullLogger<HostStatusPublisher>.Instance);
|
||||
|
||||
await publisher.PublishOnceAsync(CancellationToken.None);
|
||||
var firstSeen = await SingleRowAsync("node-x", "driver-x", "HostX");
|
||||
await Task.Delay(50); // guarantee a later wall-clock value so LastSeenUtc advances
|
||||
await publisher.PublishOnceAsync(CancellationToken.None);
|
||||
var secondSeen = await SingleRowAsync("node-x", "driver-x", "HostX");
|
||||
|
||||
secondSeen.LastSeenUtc.ShouldBeGreaterThan(firstSeen.LastSeenUtc,
|
||||
"heartbeat advances LastSeenUtc so Admin can stale-flag rows from crashed Servers");
|
||||
|
||||
// Still exactly one row — a naive Add-every-tick would have thrown or duplicated.
|
||||
using var scope = _sp.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||
(await db.DriverHostStatuses.CountAsync(r => r.NodeId == "node-x")).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task State_change_between_ticks_updates_State_and_StateChangedUtc()
|
||||
{
|
||||
var driver = new ProbeStubDriver("driver-y",
|
||||
new HostConnectivityStatus("HostY", HostState.Running, DateTime.UtcNow.AddSeconds(-10)));
|
||||
var driverHost = new DriverHost();
|
||||
await driverHost.RegisterAsync(driver, "{}", CancellationToken.None);
|
||||
|
||||
var publisher = new HostStatusPublisher(driverHost, NewNodeOptions("node-y"),
|
||||
_sp.GetRequiredService<IServiceScopeFactory>(),
|
||||
NullLogger<HostStatusPublisher>.Instance);
|
||||
|
||||
await publisher.PublishOnceAsync(CancellationToken.None);
|
||||
var before = await SingleRowAsync("node-y", "driver-y", "HostY");
|
||||
|
||||
// Swap the driver's reported state to Faulted with a newer transition timestamp.
|
||||
var newChange = DateTime.UtcNow;
|
||||
driver.Statuses = [new HostConnectivityStatus("HostY", HostState.Faulted, newChange)];
|
||||
await publisher.PublishOnceAsync(CancellationToken.None);
|
||||
|
||||
var after = await SingleRowAsync("node-y", "driver-y", "HostY");
|
||||
after.State.ShouldBe(DriverHostState.Faulted);
|
||||
// datetime2(3) has millisecond precision — DateTime.UtcNow carries up to 100ns ticks,
|
||||
// so the stored value rounds down. Compare at millisecond granularity to stay clean.
|
||||
after.StateChangedUtc.ShouldBe(newChange, tolerance: TimeSpan.FromMilliseconds(1));
|
||||
after.StateChangedUtc.ShouldBeGreaterThan(before.StateChangedUtc,
|
||||
"StateChangedUtc must advance when the state actually changed");
|
||||
before.State.ShouldBe(DriverHostState.Running);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapState_translates_every_HostState_member()
|
||||
{
|
||||
HostStatusPublisher.MapState(HostState.Running).ShouldBe(DriverHostState.Running);
|
||||
HostStatusPublisher.MapState(HostState.Stopped).ShouldBe(DriverHostState.Stopped);
|
||||
HostStatusPublisher.MapState(HostState.Faulted).ShouldBe(DriverHostState.Faulted);
|
||||
HostStatusPublisher.MapState(HostState.Unknown).ShouldBe(DriverHostState.Unknown);
|
||||
}
|
||||
|
||||
private async Task<Configuration.Entities.DriverHostStatus> SingleRowAsync(string node, string driver, string host)
|
||||
{
|
||||
using var scope = _sp.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||
return await db.DriverHostStatuses.AsNoTracking()
|
||||
.SingleAsync(r => r.NodeId == node && r.DriverInstanceId == driver && r.HostName == host);
|
||||
}
|
||||
|
||||
private static NodeOptions NewNodeOptions(string nodeId) => new()
|
||||
{
|
||||
NodeId = nodeId,
|
||||
ClusterId = "cluster-t",
|
||||
ConfigDbConnectionString = "unused-publisher-gets-db-from-scope",
|
||||
};
|
||||
|
||||
private sealed class ProbeStubDriver(string id, params HostConnectivityStatus[] initial)
|
||||
: IDriver, IHostConnectivityProbe
|
||||
{
|
||||
public HostConnectivityStatus[] Statuses { get; set; } = initial;
|
||||
public string DriverInstanceId => id;
|
||||
public string DriverType => "ProbeStub";
|
||||
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() => Statuses;
|
||||
|
||||
// Keeps the compiler happy — event is part of the interface contract even if unused here.
|
||||
internal void Raise(HostStatusChangedEventArgs e) => OnHostStatusChanged?.Invoke(this, e);
|
||||
}
|
||||
|
||||
private sealed class NonProbeStubDriver(string id) : IDriver
|
||||
{
|
||||
public string DriverInstanceId => id;
|
||||
public string DriverType => "NonProbeStub";
|
||||
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic guards for Active Directory compatibility of the internal helpers
|
||||
/// <see cref="LdapUserAuthenticator"/> relies on. We can't live-bind against AD in unit
|
||||
/// tests — instead, we pin the behaviors AD depends on (DN-parsing of AD-style
|
||||
/// <c>memberOf</c> values, filter escaping with case-preserving RDN extraction) so a
|
||||
/// future refactor can't silently break the AD path while the GLAuth live-smoke stays
|
||||
/// green.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class LdapUserAuthenticatorAdCompatTests
|
||||
{
|
||||
[Fact]
|
||||
public void ExtractFirstRdnValue_parses_AD_memberOf_group_name_from_CN_dn()
|
||||
{
|
||||
// AD's memberOf values use uppercase CN=… and full domain paths. The extractor
|
||||
// returns the first RDN's value regardless of attribute-type case, so operators'
|
||||
// GroupToRole keys stay readable ("OPCUA-Operators" not "CN=OPCUA-Operators,...").
|
||||
var dn = "CN=OPCUA-Operators,OU=OPC UA Security Groups,OU=Groups,DC=corp,DC=example,DC=com";
|
||||
LdapUserAuthenticator.ExtractFirstRdnValue(dn).ShouldBe("OPCUA-Operators");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractFirstRdnValue_handles_mixed_case_and_spaces_in_group_name()
|
||||
{
|
||||
var dn = "CN=Domain Users,CN=Users,DC=corp,DC=example,DC=com";
|
||||
LdapUserAuthenticator.ExtractFirstRdnValue(dn).ShouldBe("Domain Users");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractFirstRdnValue_also_works_for_OpenLDAP_ou_style_memberOf()
|
||||
{
|
||||
// GLAuth + some OpenLDAP deployments expose memberOf as ou=<group>,ou=groups,...
|
||||
// The authenticator needs one extractor that tolerates both shapes since directories
|
||||
// in the field mix them depending on schema.
|
||||
var dn = "ou=WriteOperate,ou=groups,dc=lmxopcua,dc=local";
|
||||
LdapUserAuthenticator.ExtractFirstRdnValue(dn).ShouldBe("WriteOperate");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EscapeLdapFilter_prevents_injection_via_samaccountname_lookup()
|
||||
{
|
||||
// AD login names can contain characters that are meaningful to LDAP filter syntax
|
||||
// (parens, backslashes). The authenticator builds filters as
|
||||
// ($"({UserNameAttribute}={EscapeLdapFilter(username)})") so injection attempts must
|
||||
// not break out of the filter. The RFC 4515 escape set is: \ → \5c, * → \2a, ( → \28,
|
||||
// ) → \29, \0 → \00.
|
||||
LdapUserAuthenticator.EscapeLdapFilter("admin)(cn=*")
|
||||
.ShouldBe("admin\\29\\28cn=\\2a");
|
||||
LdapUserAuthenticator.EscapeLdapFilter("domain\\user")
|
||||
.ShouldBe("domain\\5cuser");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LdapOptions_default_UserNameAttribute_is_uid_for_rfc2307_compat()
|
||||
{
|
||||
// Regression guard: PR 31 introduced UserNameAttribute with a default of "uid" so
|
||||
// existing deployments (pre-AD config) keep working. Changing the default breaks
|
||||
// everyone's config silently; require an explicit review.
|
||||
new LdapOptions().UserNameAttribute.ShouldBe("uid");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Live-service tests against the dev GLAuth instance at <c>localhost:3893</c>. Skipped
|
||||
/// when the port is unreachable so the test suite stays portable on boxes without a
|
||||
/// running directory. Closes LMX follow-up #4 — the server-side <see cref="LdapUserAuthenticator"/>
|
||||
/// is exercised end-to-end against a real LDAP server (same one the Admin process uses),
|
||||
/// not just the flow-shape unit tests from PR 19.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The <c>Admin.Tests</c> project already has a live-bind test for its own
|
||||
/// <c>LdapAuthService</c>; this pair catches divergence between the two bind paths — the
|
||||
/// Server authenticator has to work even when the Server process is on a machine that
|
||||
/// doesn't have the Admin assemblies loaded, and the two share no code by design
|
||||
/// (cross-app dependency avoidance). If one side drifts past the other on LDAP filter
|
||||
/// construction, DN resolution, or memberOf parsing, these tests surface it.
|
||||
/// </remarks>
|
||||
[Trait("Category", "LiveLdap")]
|
||||
public sealed class LdapUserAuthenticatorLiveTests
|
||||
{
|
||||
private const string GlauthHost = "localhost";
|
||||
private const int GlauthPort = 3893;
|
||||
|
||||
private static bool GlauthReachable()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
var task = client.ConnectAsync(GlauthHost, GlauthPort);
|
||||
return task.Wait(TimeSpan.FromSeconds(1)) && client.Connected;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
// GLAuth dev directory groups are named identically to the OPC UA roles
|
||||
// (ReadOnly / WriteOperate / WriteTune / WriteConfigure / AlarmAck), so the map is an
|
||||
// identity translation. The authenticator still exercises every step of the pipeline —
|
||||
// bind, memberOf lookup, group-name extraction, GroupToRole lookup — against real LDAP
|
||||
// data; the identity map just means the assertion is phrased with no surprise rename
|
||||
// in the middle.
|
||||
private static LdapOptions GlauthOptions() => new()
|
||||
{
|
||||
Enabled = true,
|
||||
Server = GlauthHost,
|
||||
Port = GlauthPort,
|
||||
UseTls = false,
|
||||
AllowInsecureLdap = true,
|
||||
SearchBase = "dc=lmxopcua,dc=local",
|
||||
// Search-then-bind: service account resolves the user's full DN (cn=<user> lives
|
||||
// under ou=<primary-group>,ou=users), the authenticator binds that DN with the
|
||||
// user's password, then stays on the service-account session for memberOf lookup.
|
||||
// Without this path, GLAuth ACLs block the authenticated user from reading their
|
||||
// own entry in full — a plain self-search returns zero results and the role list
|
||||
// ends up empty.
|
||||
ServiceAccountDn = "cn=serviceaccount,dc=lmxopcua,dc=local",
|
||||
ServiceAccountPassword = "serviceaccount123",
|
||||
DisplayNameAttribute = "cn",
|
||||
GroupAttribute = "memberOf",
|
||||
UserNameAttribute = "cn", // GLAuth keys users by cn — see LdapOptions xml-doc.
|
||||
GroupToRole = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["ReadOnly"] = "ReadOnly",
|
||||
["WriteOperate"] = WriteAuthzPolicy.RoleWriteOperate,
|
||||
["WriteTune"] = WriteAuthzPolicy.RoleWriteTune,
|
||||
["WriteConfigure"] = WriteAuthzPolicy.RoleWriteConfigure,
|
||||
["AlarmAck"] = "AlarmAck",
|
||||
},
|
||||
};
|
||||
|
||||
private static LdapUserAuthenticator NewAuthenticator() =>
|
||||
new(GlauthOptions(), NullLogger<LdapUserAuthenticator>.Instance);
|
||||
|
||||
[Fact]
|
||||
public async Task Valid_credentials_bind_and_return_success()
|
||||
{
|
||||
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
|
||||
|
||||
var result = await NewAuthenticator().AuthenticateAsync("readonly", "readonly123", TestContext.Current.CancellationToken);
|
||||
|
||||
result.Success.ShouldBeTrue(result.Error);
|
||||
result.DisplayName.ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Writeop_user_gets_WriteOperate_role_from_group_mapping()
|
||||
{
|
||||
// Drives end-to-end: bind as writeop, memberOf lists the WriteOperate group, the
|
||||
// authenticator surfaces WriteOperate via GroupToRole. If this test fails,
|
||||
// WriteAuthzPolicy.IsAllowed for an Operate-tier write would also fail
|
||||
// (WriteOperate is the exact string the policy checks for), so the failure mode is
|
||||
// concrete, not abstract.
|
||||
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
|
||||
|
||||
var result = await NewAuthenticator().AuthenticateAsync("writeop", "writeop123", TestContext.Current.CancellationToken);
|
||||
|
||||
result.Success.ShouldBeTrue(result.Error);
|
||||
result.Roles.ShouldContain(WriteAuthzPolicy.RoleWriteOperate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_user_gets_multiple_roles_from_multiple_groups()
|
||||
{
|
||||
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
|
||||
|
||||
// 'admin' has primarygroup=ReadOnly and othergroups=[WriteOperate, AlarmAck,
|
||||
// WriteTune, WriteConfigure] per the GLAuth dev config — the authenticator must
|
||||
// surface every mapped role, not just the primary group. Guards against a regression
|
||||
// where the memberOf parsing stops after the first match or misses the primary-group
|
||||
// fallback.
|
||||
var result = await NewAuthenticator().AuthenticateAsync("admin", "admin123", TestContext.Current.CancellationToken);
|
||||
|
||||
result.Success.ShouldBeTrue(result.Error);
|
||||
result.Roles.ShouldContain(WriteAuthzPolicy.RoleWriteOperate);
|
||||
result.Roles.ShouldContain(WriteAuthzPolicy.RoleWriteTune);
|
||||
result.Roles.ShouldContain(WriteAuthzPolicy.RoleWriteConfigure);
|
||||
result.Roles.ShouldContain("AlarmAck");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Wrong_password_returns_failure()
|
||||
{
|
||||
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
|
||||
|
||||
var result = await NewAuthenticator().AuthenticateAsync("readonly", "wrong-pw", TestContext.Current.CancellationToken);
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Error.ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_user_returns_failure()
|
||||
{
|
||||
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
|
||||
|
||||
var result = await NewAuthenticator().AuthenticateAsync("no-such-user-42", "whatever", TestContext.Current.CancellationToken);
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Empty_credentials_fail_without_touching_the_directory()
|
||||
{
|
||||
// Pre-flight guard — doesn't require GLAuth.
|
||||
var result = await NewAuthenticator().AuthenticateAsync("", "", TestContext.Current.CancellationToken);
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Error.ShouldContain("Credentials", Case.Insensitive);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Opc.Ua.Configuration;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Closes LMX follow-up #6 — proves that two <see cref="IDriver"/> instances registered
|
||||
/// on the same <see cref="DriverHost"/> land in isolated namespaces and their reads
|
||||
/// route to the correct driver. The existing <see cref="OpcUaServerIntegrationTests"/>
|
||||
/// only exercises a single-driver topology; this sibling fixture registers two.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Each driver gets its own namespace URI of the form <c>urn:OtOpcUa:{DriverInstanceId}</c>
|
||||
/// (per <c>DriverNodeManager</c>'s base-class <c>namespaceUris</c> argument). A client
|
||||
/// that browses one namespace must see only that driver's subtree, and a read against a
|
||||
/// variable in one namespace must return that driver's value, not the other's — this is
|
||||
/// what stops a cross-driver routing regression from going unnoticed when the v1
|
||||
/// single-driver code path gets new knobs.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class MultipleDriverInstancesIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private static readonly int Port = 48500 + Random.Shared.Next(0, 99);
|
||||
private readonly string _endpoint = $"opc.tcp://localhost:{Port}/OtOpcUaMultiDriverTest";
|
||||
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-multi-{Guid.NewGuid():N}");
|
||||
|
||||
private DriverHost _driverHost = null!;
|
||||
private OpcUaApplicationHost _server = null!;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
_driverHost = new DriverHost();
|
||||
await _driverHost.RegisterAsync(new StubDriver("alpha", folderName: "AlphaFolder", readValue: 42),
|
||||
"{}", CancellationToken.None);
|
||||
await _driverHost.RegisterAsync(new StubDriver("beta", folderName: "BetaFolder", readValue: 99),
|
||||
"{}", CancellationToken.None);
|
||||
|
||||
var options = new OpcUaServerOptions
|
||||
{
|
||||
EndpointUrl = _endpoint,
|
||||
ApplicationName = "OtOpcUaMultiDriverTest",
|
||||
ApplicationUri = "urn:OtOpcUa:Server:MultiDriverTest",
|
||||
PkiStoreRoot = _pkiRoot,
|
||||
AutoAcceptUntrustedClientCertificates = true,
|
||||
};
|
||||
|
||||
_server = new OpcUaApplicationHost(options, _driverHost, new DenyAllUserAuthenticator(),
|
||||
NullLoggerFactory.Instance, NullLogger<OpcUaApplicationHost>.Instance);
|
||||
await _server.StartAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _server.DisposeAsync();
|
||||
await _driverHost.DisposeAsync();
|
||||
try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Both_drivers_register_under_their_own_urn_namespace()
|
||||
{
|
||||
using var session = await OpenSessionAsync();
|
||||
|
||||
var alphaNs = session.NamespaceUris.GetIndex("urn:OtOpcUa:alpha");
|
||||
var betaNs = session.NamespaceUris.GetIndex("urn:OtOpcUa:beta");
|
||||
|
||||
alphaNs.ShouldBeGreaterThanOrEqualTo(0, "DriverNodeManager for 'alpha' must register its namespace URI");
|
||||
betaNs.ShouldBeGreaterThanOrEqualTo(0, "DriverNodeManager for 'beta' must register its namespace URI");
|
||||
alphaNs.ShouldNotBe(betaNs, "each driver owns its own namespace");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Each_driver_subtree_exposes_only_its_own_folder()
|
||||
{
|
||||
using var session = await OpenSessionAsync();
|
||||
|
||||
var alphaNs = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:alpha");
|
||||
var betaNs = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:beta");
|
||||
|
||||
var alphaRoot = new NodeId("alpha", alphaNs);
|
||||
session.Browse(null, null, alphaRoot, 0, BrowseDirection.Forward, ReferenceTypeIds.HierarchicalReferences,
|
||||
true, (uint)NodeClass.Object | (uint)NodeClass.Variable, out _, out var alphaRefs);
|
||||
alphaRefs.ShouldContain(r => r.BrowseName.Name == "AlphaFolder",
|
||||
"alpha's subtree must contain alpha's folder");
|
||||
alphaRefs.ShouldNotContain(r => r.BrowseName.Name == "BetaFolder",
|
||||
"alpha's subtree must NOT see beta's folder — cross-driver leak would hide subscription-routing bugs");
|
||||
|
||||
var betaRoot = new NodeId("beta", betaNs);
|
||||
session.Browse(null, null, betaRoot, 0, BrowseDirection.Forward, ReferenceTypeIds.HierarchicalReferences,
|
||||
true, (uint)NodeClass.Object | (uint)NodeClass.Variable, out _, out var betaRefs);
|
||||
betaRefs.ShouldContain(r => r.BrowseName.Name == "BetaFolder");
|
||||
betaRefs.ShouldNotContain(r => r.BrowseName.Name == "AlphaFolder");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reads_route_to_the_correct_driver_by_namespace()
|
||||
{
|
||||
using var session = await OpenSessionAsync();
|
||||
|
||||
var alphaNs = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:alpha");
|
||||
var betaNs = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:beta");
|
||||
|
||||
var alphaValue = session.ReadValue(new NodeId("AlphaFolder.Var1", alphaNs));
|
||||
var betaValue = session.ReadValue(new NodeId("BetaFolder.Var1", betaNs));
|
||||
|
||||
alphaValue.Value.ShouldBe(42, "alpha driver's ReadAsync returns 42 — a misroute would surface as 99");
|
||||
betaValue.Value.ShouldBe(99, "beta driver's ReadAsync returns 99 — a misroute would surface as 42");
|
||||
}
|
||||
|
||||
private async Task<ISession> OpenSessionAsync()
|
||||
{
|
||||
var cfg = new ApplicationConfiguration
|
||||
{
|
||||
ApplicationName = "OtOpcUaMultiDriverTestClient",
|
||||
ApplicationUri = "urn:OtOpcUa:MultiDriverTestClient",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
SecurityConfiguration = new SecurityConfiguration
|
||||
{
|
||||
ApplicationCertificate = new CertificateIdentifier
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = Path.Combine(_pkiRoot, "client-own"),
|
||||
SubjectName = "CN=OtOpcUaMultiDriverTestClient",
|
||||
},
|
||||
TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-issuers") },
|
||||
TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-trusted") },
|
||||
RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-rejected") },
|
||||
AutoAcceptUntrustedCertificates = true,
|
||||
AddAppCertToTrustedStore = true,
|
||||
},
|
||||
TransportConfigurations = new TransportConfigurationCollection(),
|
||||
TransportQuotas = new TransportQuotas { OperationTimeout = 15000 },
|
||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 },
|
||||
};
|
||||
await cfg.Validate(ApplicationType.Client);
|
||||
cfg.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||
|
||||
var instance = new ApplicationInstance { ApplicationConfiguration = cfg, ApplicationType = ApplicationType.Client };
|
||||
await instance.CheckApplicationInstanceCertificate(true, CertificateFactory.DefaultKeySize);
|
||||
|
||||
var selected = CoreClientUtils.SelectEndpoint(cfg, _endpoint, useSecurity: false);
|
||||
var endpointConfig = EndpointConfiguration.Create(cfg);
|
||||
var configuredEndpoint = new ConfiguredEndpoint(null, selected, endpointConfig);
|
||||
|
||||
return await Session.Create(cfg, configuredEndpoint, false, "OtOpcUaMultiDriverTestClientSession", 60000,
|
||||
new UserIdentity(new AnonymousIdentityToken()), null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Driver stub that returns a caller-specified folder + variable + read value so two
|
||||
/// instances in the same server can be told apart at the assertion layer.
|
||||
/// </summary>
|
||||
private sealed class StubDriver(string driverInstanceId, string folderName, int readValue)
|
||||
: IDriver, ITagDiscovery, IReadable
|
||||
{
|
||||
public string DriverInstanceId => driverInstanceId;
|
||||
public string DriverType => "Stub";
|
||||
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct)
|
||||
{
|
||||
var folder = builder.Folder(folderName, folderName);
|
||||
folder.Variable("Var1", "Var1", new DriverAttributeInfo(
|
||||
$"{folderName}.Var1", DriverDataType.Int32, false, null, SecurityClassification.FreeAccess, false, IsAlarm: false));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
IReadOnlyList<DataValueSnapshot> result =
|
||||
fullReferences.Select(_ => new DataValueSnapshot(readValue, 0u, now, now)).ToArray();
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
@@ -38,8 +39,8 @@ public sealed class OpcUaServerIntegrationTests : IAsyncLifetime
|
||||
AutoAcceptUntrustedClientCertificates = true,
|
||||
};
|
||||
|
||||
_server = new OpcUaApplicationHost(options, _driverHost, NullLoggerFactory.Instance,
|
||||
NullLogger<OpcUaApplicationHost>.Instance);
|
||||
_server = new OpcUaApplicationHost(options, _driverHost, new DenyAllUserAuthenticator(),
|
||||
NullLoggerFactory.Instance, NullLogger<OpcUaApplicationHost>.Instance);
|
||||
await _server.StartAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SecurityConfigurationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task DenyAllAuthenticator_rejects_every_credential()
|
||||
{
|
||||
var auth = new DenyAllUserAuthenticator();
|
||||
var r = await auth.AuthenticateAsync("admin", "admin", CancellationToken.None);
|
||||
r.Success.ShouldBeFalse();
|
||||
r.Error.ShouldContain("not supported");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LdapAuthenticator_rejects_blank_credentials_without_hitting_server()
|
||||
{
|
||||
var options = new LdapOptions { Enabled = true, AllowInsecureLdap = true };
|
||||
var auth = new LdapUserAuthenticator(options, Microsoft.Extensions.Logging.Abstractions.NullLogger<LdapUserAuthenticator>.Instance);
|
||||
|
||||
var empty = await auth.AuthenticateAsync("", "", CancellationToken.None);
|
||||
empty.Success.ShouldBeFalse();
|
||||
empty.Error.ShouldContain("Credentials");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LdapAuthenticator_rejects_when_disabled()
|
||||
{
|
||||
var options = new LdapOptions { Enabled = false };
|
||||
var auth = new LdapUserAuthenticator(options, Microsoft.Extensions.Logging.Abstractions.NullLogger<LdapUserAuthenticator>.Instance);
|
||||
|
||||
var r = await auth.AuthenticateAsync("alice", "pw", CancellationToken.None);
|
||||
r.Success.ShouldBeFalse();
|
||||
r.Error.ShouldContain("disabled");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LdapAuthenticator_rejects_plaintext_when_both_TLS_and_insecure_are_disabled()
|
||||
{
|
||||
var options = new LdapOptions { Enabled = true, UseTls = false, AllowInsecureLdap = false };
|
||||
var auth = new LdapUserAuthenticator(options, Microsoft.Extensions.Logging.Abstractions.NullLogger<LdapUserAuthenticator>.Instance);
|
||||
|
||||
var r = await auth.AuthenticateAsync("alice", "pw", CancellationToken.None);
|
||||
r.Success.ShouldBeFalse();
|
||||
r.Error.ShouldContain("Insecure");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("hello", "hello")]
|
||||
[InlineData("hi(there)", "hi\\28there\\29")]
|
||||
[InlineData("name*", "name\\2a")]
|
||||
[InlineData("a\\b", "a\\5cb")]
|
||||
public void LdapFilter_escapes_reserved_characters(string input, string expected)
|
||||
{
|
||||
LdapUserAuthenticator.EscapeLdapFilter(input).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("cn=alice,ou=Engineering,dc=example,dc=com", "Engineering")]
|
||||
[InlineData("cn=bob,dc=example,dc=com", null)]
|
||||
[InlineData("cn=carol,ou=Ops,dc=example,dc=com", "Ops")]
|
||||
public void ExtractOuSegment_pulls_primary_group_from_DN(string dn, string? expected)
|
||||
{
|
||||
LdapUserAuthenticator.ExtractOuSegment(dn).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("cn=Operators,ou=Groups,dc=example", "Operators")]
|
||||
[InlineData("cn=LoneValue", "LoneValue")]
|
||||
[InlineData("plain-no-equals", "plain-no-equals")]
|
||||
public void ExtractFirstRdnValue_returns_first_rdn(string dn, string expected)
|
||||
{
|
||||
LdapUserAuthenticator.ExtractFirstRdnValue(dn).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpcUaServerOptions_default_is_anonymous_only()
|
||||
{
|
||||
var opts = new OpcUaServerOptions();
|
||||
opts.SecurityProfile.ShouldBe(OpcUaSecurityProfile.None);
|
||||
opts.Ldap.Enabled.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
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