8.9 KiB
Wonderware Historian Sidecar — TCP Transport Design
Date: 2026-06-12 Status: Approved (design); implementation plan to follow.
Goal
Replace the Wonderware Historian sidecar's local named-pipe IPC with a TCP transport so a remote OtOpcUa host (e.g. the dev server running in Linux Docker on a MacBook) can reach the net48 sidecar on the Windows Historian VM. Today the IPC is a local, Windows-SID-gated named pipe, so the only possible consumer is a Windows OtOpcUa process on the same machine; once the host moves off the VM the sidecar is orphaned.
Why not gRPC (the "like mxaccessgw" question)
mxaccessgw uses gRPC/HTTP2, but it can do so only because it is split into a
net10 Server (Kestrel/Grpc.AspNetCore) + a net48 Worker. The
Historian sidecar must stay net48 (the AVEVA aahClientManaged + native
aahClient.dll SDK is .NET Framework 4.8), and net48 cannot host
Kestrel/Grpc.AspNetCore. The only gRPC-on-net48 option is the EOL
Grpc.Core C-core library, or adding a second net10 front process.
Decision: plain TCP reusing the existing MessagePack frame protocol. The
protocol is 5 unary request/reply ops (ReadRaw, ReadProcessed,
ReadAtTime, ReadEvents, WriteAlarmEvents) + a Hello handshake — no
streaming — and is already abstracted behind a Stream on both ends, so a TCP
swap is small, native to net48, depends on no EOL libraries, and reuses every
contract.
Locked decisions
| Decision | Choice |
|---|---|
| Transport | TCP only — named pipe fully removed |
| Concurrency | Single active connection, serial accept (mirrors today's pipe maxInstances:1) |
| Caller auth | Shared-secret Hello, required in every mode (replaces the SID ACL) |
| Transport security | TLS optional — plaintext in dev, TLS in prod (config-driven) |
| mTLS / client-cert | Out of scope now; future hardening follow-up |
Architecture
Before: After:
OtOpcUa host (same VM, Windows) OtOpcUa host (anywhere, .NET 10)
| named pipe (local, SID-gated) | TCP (+ optional TLS), MessagePack frames
v v
Sidecar (net48) PipeServer Sidecar (net48) TcpFrameServer
Everything above the socket is unchanged: Hello/HelloAck handshake,
length-prefixed MessageKind framing, MessagePack DTOs, FrameReader/
FrameWriter (they operate on Stream; NetworkStream/SslStream are
Streams), HistorianFrameHandler dispatch, and the AVEVA SDK backends
(HistorianDataSource reads, SdkAlarmHistorianWriteBackend writes).
Detailed design
Server (net48 sidecar — src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/)
- New
Ipc/TcpFrameServer.csreplacesIpc/PipeServer.cs:TcpListenerbound to<bind>:<port>; single-active serial accept loop (accept the next connection only after the current disconnects).- Per connection: if TLS enabled, wrap
NetworkStreaminSslStreamandAuthenticateAsServer(cert); else use the rawNetworkStream. - Run the existing
Hellohandshake (verify shared secret; reject with aHelloAck{Accepted=false}on mismatch / major-version mismatch) → then the existingreader.ReadFrameAsync→handler.HandleAsync→writer.WriteAsyncloop. - Keep the
RunAsyncbackoff (250ms…8s) +MaxConsecutiveFailures=20→throw behavior soProgram.Main's exit-2 + NSSM restart semantics are identical.
- Remove:
PipeServer,PipeAcl,VerifyCaller/CallerVerifier(Windows-pipe-only), theOTOPCUA_ALLOWED_SIDenv +SecurityIdentifier. Program.cs: swapnew PipeServer(pipeName, allowedSid, sharedSecret, …)(line ~62) fornew TcpFrameServer(bind, port, sharedSecret, tlsCert?, …); drop the SID read; keepOTOPCUA_HISTORIAN_ENABLED(pipe-only-idle behavior becomes "tcp-only-idle" / listen-but-SDK-disabled, semantics preserved).- New env vars:
OTOPCUA_HISTORIAN_TCP_PORT(default e.g. 32569),OTOPCUA_HISTORIAN_BIND(default0.0.0.0),OTOPCUA_HISTORIAN_TLS_ENABLED(defaultfalse),OTOPCUA_HISTORIAN_TLS_CERT(pfx path or cert-store thumbprint),OTOPCUA_HISTORIAN_TLS_CERT_PASSWORD. KeepOTOPCUA_HISTORIAN_SECRET,OTOPCUA_HISTORIAN_ENABLED, and the SDK vars (OTOPCUA_HISTORIAN_SERVER/PORT/…).
Client (.NET 10 — src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/)
Internal/PipeChannel.cs→Internal/FrameChannel.cs(cosmetic rename; already transport-agnostic). AddDefaultTcpConnectFactory:TcpClient.ConnectAsync(host, port, ct)→ if TLS,SslStream.AuthenticateAsClientAsync(validate server cert: thumbprint-pin or CA-chain per config; skip in plaintext mode) → return the stream. TheFrameReader/FrameWriter/Hello/MessagePack layer is reused unchanged.WonderwareHistorianClient.cs: default ctor switches to the TCP factory; the injectableconnect-func ctor stays (used by tests).
Options + host wiring
…Client.Contracts/WonderwareHistorianClientOptions.cs: replacePipeNamewithHost+Port; addUseTlsandServerCertThumbprint(optional pin) / validation mode. KeepSharedSecret,PeerName,ConnectTimeout,CallTimeout,ProbeTimeoutSeconds.Historian:Wonderwareappsettings:Host/Port/UseTls/ServerCertThumbprintreplacePipeName. Bound where the client is built:src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs,src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs,src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/AlarmHistorianOptions.cs.- AdminUI Test-Connect probe:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/HistorianWonderwareAddressBuilder.cs- the probe path updated to host/port/TLS.
Security model
- Caller auth: shared-secret
Hello, required in all modes — this is the replacement for the named pipe's Windows-SID ACL. - Transport: TLS optional, config-driven. Dev = plaintext (
UseTls=false). Prod = server cert; client pins the thumbprint or validates the CA chain (both supported). Server cert can live in the existingC:\ProgramData\OtOpcUa\pki. - Network exposure: a new inbound port requires a Windows Firewall rule
on the VM. Bind to a specific management NIC instead of
0.0.0.0to scope exposure. - mTLS (client-cert auth) is a future follow-up; the shared secret covers caller authentication for now.
Deployment
scripts/install/Install-Services.ps1/Refresh-Services.ps1: swap the historian service env block (dropOTOPCUA_ALLOWED_SID, addOTOPCUA_HISTORIAN_TCP_PORT+OTOPCUA_HISTORIAN_TLS_*), add an inbound firewall rule for the port, and provision the server cert (prod). The Step 4b deploy-completeness assertion stays as-is.
Testing (no live sign-in by the agent)
- Reuse the existing byte-parity / round-trip contract tests (contracts unchanged).
- New unit/integration (xUnit + Shouldly): TCP connect factory; self-signed
TLS loopback handshake; end-to-end loopback (
TcpFrameServer+ client over127.0.0.1, both plaintext and TLS);Hello-reject on bad shared secret over TCP; single-active-connection serial-accept behavior. - Live (user-driven): MacBook OtOpcUa → VM sidecar over TCP — dev plaintext
first:
ReadRawreturns live samples + aWriteAlarmEventsround-trips; then flipUseTls=trueand re-verify. Open the VM firewall port. Done = build clean +dotnet testgreen + live read/write pass.
Rollout / migration
- The pipe is fully replaced, so both ends move together (no mixed pipe/TCP). The protocol above the socket is byte-identical, so this is a transport swap, not a contract change. Sequence: deploy the TCP sidecar (+firewall +env), then the TCP-client host, with the same shared secret.
Open items / follow-ups (not blockers)
- mTLS / client-cert auth (hardening).
- Optional: bind-NIC scoping vs
0.0.0.0. - Same-machine deploys now use loopback TCP (
127.0.0.1:<port>) instead of a pipe — expected given full replacement.
Touched code (authoritative file list)
- Sidecar:
Ipc/TcpFrameServer.cs(new, replacesIpc/PipeServer.cs), removeIpc/PipeAcl.cs,Program.cs. - Client:
Internal/FrameChannel.cs(rename ofPipeChannel.cs+ TCP factory),WonderwareHistorianClient.cs. - Contracts:
WonderwareHistorianClientOptions.cs. - Host:
Runtime/ServiceCollectionExtensions.cs,Host/Drivers/DriverFactoryBootstrap.cs,Runtime/Historian/AlarmHistorianOptions.cs,AdminUI/.../Pickers/HistorianWonderwareAddressBuilder.cs. - Deploy:
scripts/install/Install-Services.ps1,scripts/install/Refresh-Services.ps1. - Docs:
docs/drivers/Historian.Wonderware.md,docs/ServiceHosting.md,docs/AlarmHistorian.md. - Unchanged (reused): both
Ipc/Contracts.cs,Ipc/Framing.cs,Ipc/FrameReader.cs,Ipc/HistorianFrameHandler.cs, the AVEVA SDK backends.