docs(historian): implementation plan for sidecar TCP transport
This commit is contained in:
@@ -0,0 +1,477 @@
|
||||
# Wonderware Historian Sidecar — TCP Transport Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Replace the Wonderware Historian sidecar's local Windows named-pipe IPC with a TCP transport (plaintext dev / optional TLS prod, shared-secret `Hello` auth) so a remote off-VM OtOpcUa host can reach the net48 sidecar.
|
||||
|
||||
**Architecture:** Everything above the socket is reused verbatim (`Hello`/`HelloAck`, length-prefixed `MessageKind` framing, both `Contracts.cs` MessagePack DTOs, `FrameReader`/`FrameWriter` — they operate on `Stream`, and `NetworkStream`/`SslStream` *are* `Stream`s — `HistorianFrameHandler` dispatch, the AVEVA SDK backends). The work is: swap the server's `PipeServer` for a `TcpFrameServer` (TcpListener + optional `SslStream`), swap the client's pipe connect-factory for a TCP one, thread host/port/TLS through options + config, and update deploy scripts + docs. Single active connection, serial accept (mirrors today's pipe `maxNumberOfServerInstances:1`).
|
||||
|
||||
**Tech Stack:** C# .NET 10 (client/host) + .NET Framework 4.8 x64 (sidecar — forced by AVEVA `aahClientManaged`), MessagePack, `System.Net.Sockets`/`System.Net.Security`, xUnit + Shouldly. **Design doc:** `docs/plans/2026-06-12-historian-tcp-transport-design.md` (master `3d3f8a47`).
|
||||
|
||||
**Green-at-each-step strategy:** Because the named pipe is fully replaced, several files change shape. To keep the build green per commit we go **additive then cleanup**: add the TCP path alongside the pipe path, switch the defaults, then delete the dead pipe code in one final cleanup task. The client side (Tasks 1–4, 7-cleanup) and the sidecar side (Tasks 5–6) touch disjoint projects and can proceed in parallel.
|
||||
|
||||
**net48 caveats (sidecar only — bake into Task 5):**
|
||||
- `TcpListener.AcceptTcpClientAsync(CancellationToken)` does **not** exist on net48. Use the no-arg `AcceptTcpClientAsync()` and tie cancellation to the listener: `ct.Register(() => _listener.Stop())` (a `Stop()` makes the pending accept throw, which the loop treats as cancellation).
|
||||
- `SslStream.AuthenticateAsServerAsync` on net48: use `SslProtocols.Tls12` (Tls13 isn't available). `checkCertificateRevocation: false` for self-signed dev certs.
|
||||
|
||||
**Hard rules:** stage by path (never `git add .`); never stage `sql_login.txt` / `src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/` / `pending.md`; never echo the gateway API key or the historian `SharedSecret` into a tracked file; no force-push / no `--no-verify`; NO Configuration entity / EF migration change.
|
||||
|
||||
---
|
||||
|
||||
### Task 0: Create the feature branch
|
||||
|
||||
**Classification:** trivial
|
||||
**Estimated implement time:** ~1 min
|
||||
**Parallelizable with:** none
|
||||
|
||||
**Files:** none (git only)
|
||||
|
||||
**Step 1:** From master (HEAD `3d3f8a47`, design doc already committed):
|
||||
```bash
|
||||
git checkout master && git pull --ff-only
|
||||
git checkout -b feat/historian-tcp-transport
|
||||
```
|
||||
**Step 2:** Confirm `git status` clean (except untracked `pending.md`, which is left alone) and `git rev-parse --abbrev-ref HEAD` → `feat/historian-tcp-transport`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add TCP/TLS fields to the client options
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** Task 5
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts/WonderwareHistorianClientOptions.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/WonderwareHistorianClientOptionsTests.cs` (new)
|
||||
|
||||
**Additive** — keep `PipeName` for now (removed in Task 7) so the build stays green. Add init-only properties:
|
||||
|
||||
```csharp
|
||||
/// <summary>Sidecar TCP host (DNS name or IP). Required for the TCP transport.</summary>
|
||||
public string? Host { get; init; }
|
||||
|
||||
/// <summary>Sidecar TCP port (matches the sidecar's OTOPCUA_HISTORIAN_TCP_PORT).</summary>
|
||||
public int Port { get; init; }
|
||||
|
||||
/// <summary>When true, the client wraps the TCP stream in TLS before the Hello handshake.</summary>
|
||||
public bool UseTls { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional SHA-1 thumbprint (hex, no spaces) the client pins the sidecar's TLS
|
||||
/// server cert against. When null/empty and <see cref="UseTls"/> is true, the client
|
||||
/// validates the cert chain normally (CA-issued cert).
|
||||
/// </summary>
|
||||
public string? ServerCertThumbprint { get; init; }
|
||||
```
|
||||
|
||||
**Step 1 (test, TDD):** assert an options instance carries the new fields and that defaults are `Port=0`, `UseTls=false`, `Host/ServerCertThumbprint=null`.
|
||||
**Step 2:** run `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests --filter WonderwareHistorianClientOptionsTests` → fail (no fields).
|
||||
**Step 3:** add the properties.
|
||||
**Step 4:** test passes. `dotnet build` on the Contracts project is green.
|
||||
**Step 5:** commit (`git add` the two files by path) — `feat(historian-client): add TCP/TLS options fields`.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Client TCP connect factory + FrameChannel rename
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 5
|
||||
|
||||
**Files:**
|
||||
- Rename: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Internal/PipeChannel.cs` → `Internal/FrameChannel.cs` (rename the class `PipeChannel` → `FrameChannel`; it is already transport-agnostic — it only touches `Stream`/`FrameReader`/`FrameWriter`). Update the one reference in `WonderwareHistorianClient.cs` (`private readonly PipeChannel _channel;` and `new PipeChannel(...)`).
|
||||
- Modify (same file): keep `DefaultNamedPipeConnectFactory` for now (deleted in Task 7); **add** `DefaultTcpConnectFactory`.
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/TcpConnectFactoryTests.cs` (new)
|
||||
|
||||
Add to `FrameChannel` (needs `using System.Net.Sockets; using System.Net.Security; using System.Security.Authentication;`):
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Default TCP factory: connects to the sidecar over TCP, optionally wrapping the
|
||||
/// stream in TLS (server-auth; pinned-thumbprint or CA-chain validation). The Hello
|
||||
/// handshake + shared secret still authenticate the caller on top of this.
|
||||
/// </summary>
|
||||
public static Func<WonderwareHistorianClientOptions, CancellationToken, Task<Stream>> DefaultTcpConnectFactory =
|
||||
async (opts, ct) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(opts.Host))
|
||||
throw new InvalidOperationException("WonderwareHistorianClientOptions.Host is required for the TCP transport.");
|
||||
|
||||
var tcp = new TcpClient();
|
||||
using (var connectCts = CancellationTokenSource.CreateLinkedTokenSource(ct))
|
||||
{
|
||||
connectCts.CancelAfter(opts.EffectiveConnectTimeout);
|
||||
await tcp.ConnectAsync(opts.Host!, opts.Port, connectCts.Token).ConfigureAwait(false);
|
||||
}
|
||||
tcp.NoDelay = true;
|
||||
|
||||
Stream stream = tcp.GetStream();
|
||||
if (!opts.UseTls) return stream;
|
||||
|
||||
var ssl = new SslStream(stream, leaveInnerStreamOpen: false, (_, cert, _, errors) =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(opts.ServerCertThumbprint))
|
||||
return string.Equals(cert?.GetCertHashString(), opts.ServerCertThumbprint, StringComparison.OrdinalIgnoreCase);
|
||||
return errors == SslPolicyErrors.None;
|
||||
});
|
||||
await ssl.AuthenticateAsClientAsync(opts.Host!).ConfigureAwait(false);
|
||||
return ssl;
|
||||
};
|
||||
```
|
||||
|
||||
**Step 1 (test, TDD):** stand up a loopback `TcpListener` on `127.0.0.1:0`, accept in a background task and (a) for plaintext: read the first byte to prove a stream arrived; (b) for TLS: `AuthenticateAsServerAsync` a self-signed cert (build one with `CertificateRequest` / `X509Certificate2.CreateSelfSigned`-style helper, EKU serverAuth) and assert the client factory with the matching `ServerCertThumbprint` connects, and with a wrong thumbprint throws `AuthenticationException`.
|
||||
**Step 2:** run the new test → fail (no `DefaultTcpConnectFactory`).
|
||||
**Step 3:** add the factory + do the rename.
|
||||
**Step 4:** `dotnet test …Client.Tests --filter TcpConnectFactory` → pass; whole Client project builds.
|
||||
**Step 5:** commit by path — `feat(historian-client): TCP connect factory + FrameChannel rename`.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Switch the client default ctor to TCP
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~2 min
|
||||
**Parallelizable with:** none (needs Task 2)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianClient.cs:42`
|
||||
- Test: `tests/.../Client.Tests/WonderwareHistorianClientTests.cs` (extend)
|
||||
|
||||
Change the public ctor delegation from the pipe factory to TCP:
|
||||
```csharp
|
||||
public WonderwareHistorianClient(WonderwareHistorianClientOptions options, ILogger<WonderwareHistorianClient>? logger = null)
|
||||
: this(options, ct => FrameChannel.DefaultTcpConnectFactory(options, ct), logger)
|
||||
{
|
||||
}
|
||||
```
|
||||
**Step 1:** add a test that constructs the client with `Host=127.0.0.1, Port=<loopback>` against a loopback `TcpListener` running the real `Hello`/`HelloAck` exchange (reuse/extend `FakeSidecarServer` to accept a `Stream` from a TCP socket) and assert a `ReadRawAsync` round-trips. **Step 2:** fail. **Step 3:** flip the ctor. **Step 4:** pass. **Step 5:** commit by path — `feat(historian-client): default ctor dials TCP`.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Host config binding (Host/Port/TLS)
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** Task 5
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/AlarmHistorianOptions.cs:29` (add fields next to `PipeName`)
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs:96-97`
|
||||
- Test: `tests/Server/…Runtime.Tests/…` (extend an options-binding test if present; else add a small bind test)
|
||||
|
||||
`AlarmHistorianOptions`: add
|
||||
```csharp
|
||||
public string Host { get; init; } = "localhost";
|
||||
public int Port { get; init; } = 32569;
|
||||
public bool UseTls { get; init; }
|
||||
public string? ServerCertThumbprint { get; init; }
|
||||
```
|
||||
`Host/Program.cs:97` — pass them through:
|
||||
```csharp
|
||||
new WonderwareHistorianClientOptions(opts.PipeName, opts.SharedSecret)
|
||||
{
|
||||
Host = opts.Host, Port = opts.Port, UseTls = opts.UseTls, ServerCertThumbprint = opts.ServerCertThumbprint,
|
||||
}
|
||||
```
|
||||
(`PipeName` still passes through positionally until Task 7 reshapes the record; harmless.)
|
||||
Add the keys to the Host `appsettings.json` `Historian:Wonderware` section (Host/Port/UseTls/ServerCertThumbprint).
|
||||
**Steps:** TDD the binding (config → options carries Host/Port/UseTls) → green → commit by path — `feat(historian-host): bind TCP host/port/tls config`.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Sidecar `TcpFrameServer`
|
||||
|
||||
**Classification:** high-risk (transport + security + concurrency, net48)
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 1, Task 2, Task 4 (disjoint project)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/TcpFrameServer.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Ipc/TcpRoundTripTests.cs` (new; model on the existing `Ipc/PipeRoundTripTests.cs`)
|
||||
|
||||
Mirror `PipeServer`'s shape (same `IFrameHandler`, same `Hello` verify minus SID, same `RunAsync` backoff + `MaxConsecutiveFailures=20`→throw so `Program.Main`'s exit-2/NSSM semantics are identical). Keep `PipeServer.cs` for now (deleted Task 7).
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Security;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Authentication;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MessagePack;
|
||||
using Serilog;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Accepts one TCP client at a time, optionally over TLS, verifies the shared-secret
|
||||
/// Hello, then dispatches frames to <see cref="IFrameHandler"/>. The TCP replacement for
|
||||
/// <c>PipeServer</c>; the Windows-SID ACL is replaced by TLS + the shared secret.
|
||||
/// </summary>
|
||||
public sealed class TcpFrameServer : IDisposable
|
||||
{
|
||||
private readonly IPAddress _bind;
|
||||
private readonly int _port;
|
||||
private readonly string _sharedSecret;
|
||||
private readonly X509Certificate2? _tlsCert; // null = plaintext
|
||||
private readonly ILogger _logger;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private TcpListener? _listener;
|
||||
|
||||
public TcpFrameServer(IPAddress bind, int port, string sharedSecret, X509Certificate2? tlsCert, ILogger logger)
|
||||
{
|
||||
_bind = bind ?? throw new ArgumentNullException(nameof(bind));
|
||||
_port = port;
|
||||
_sharedSecret = sharedSecret ?? throw new ArgumentNullException(nameof(sharedSecret));
|
||||
_tlsCert = tlsCert;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>The port the listener actually bound (useful when constructed with port 0 in tests).</summary>
|
||||
public int BoundPort => ((IPEndPoint)_listener!.LocalEndpoint).Port;
|
||||
|
||||
private void EnsureListening()
|
||||
{
|
||||
if (_listener is not null) return;
|
||||
_listener = new TcpListener(_bind, _port);
|
||||
_listener.Start();
|
||||
}
|
||||
|
||||
public async Task RunOneConnectionAsync(IFrameHandler handler, CancellationToken ct)
|
||||
{
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token, ct);
|
||||
EnsureListening();
|
||||
|
||||
// net48 has no AcceptTcpClientAsync(CancellationToken); Stop() unblocks a pending accept.
|
||||
using var reg = linked.Token.Register(() => { try { _listener!.Stop(); } catch { /* ignore */ } });
|
||||
TcpClient client;
|
||||
try { client = await _listener!.AcceptTcpClientAsync().ConfigureAwait(false); }
|
||||
catch (ObjectDisposedException) when (linked.Token.IsCancellationRequested) { throw new OperationCanceledException(linked.Token); }
|
||||
catch (InvalidOperationException) when (linked.Token.IsCancellationRequested) { throw new OperationCanceledException(linked.Token); }
|
||||
|
||||
using (client)
|
||||
{
|
||||
client.NoDelay = true;
|
||||
Stream stream = client.GetStream();
|
||||
SslStream? ssl = null;
|
||||
try
|
||||
{
|
||||
if (_tlsCert is not null)
|
||||
{
|
||||
ssl = new SslStream(stream, leaveInnerStreamOpen: false);
|
||||
await ssl.AuthenticateAsServerAsync(_tlsCert, clientCertificateRequired: false,
|
||||
enabledSslProtocols: SslProtocols.Tls12, checkCertificateRevocation: false).ConfigureAwait(false);
|
||||
stream = ssl;
|
||||
}
|
||||
|
||||
using var reader = new FrameReader(stream, leaveOpen: true);
|
||||
using var writer = new FrameWriter(stream, leaveOpen: true);
|
||||
|
||||
var first = await reader.ReadFrameAsync(linked.Token).ConfigureAwait(false);
|
||||
if (first is null || first.Value.Kind != MessageKind.Hello)
|
||||
{
|
||||
_logger.Warning("Sidecar TCP first frame was not Hello; dropping");
|
||||
return;
|
||||
}
|
||||
var hello = MessagePackSerializer.Deserialize<Hello>(first.Value.Body);
|
||||
if (!string.Equals(hello.SharedSecret, _sharedSecret, StringComparison.Ordinal))
|
||||
{
|
||||
await writer.WriteAsync(MessageKind.HelloAck,
|
||||
new HelloAck { Accepted = false, RejectReason = "shared-secret-mismatch" }, linked.Token).ConfigureAwait(false);
|
||||
_logger.Warning("Sidecar TCP Hello rejected: shared-secret-mismatch");
|
||||
return;
|
||||
}
|
||||
if (hello.ProtocolMajor != Hello.CurrentMajor)
|
||||
{
|
||||
await writer.WriteAsync(MessageKind.HelloAck,
|
||||
new HelloAck { Accepted = false, RejectReason = $"major-version-mismatch-peer={hello.ProtocolMajor}-server={Hello.CurrentMajor}" },
|
||||
linked.Token).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
await writer.WriteAsync(MessageKind.HelloAck,
|
||||
new HelloAck { Accepted = true, HostName = Environment.MachineName }, linked.Token).ConfigureAwait(false);
|
||||
|
||||
while (!linked.Token.IsCancellationRequested)
|
||||
{
|
||||
var frame = await reader.ReadFrameAsync(linked.Token).ConfigureAwait(false);
|
||||
if (frame is null) break;
|
||||
await handler.HandleAsync(frame.Value.Kind, frame.Value.Body, writer, linked.Token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally { ssl?.Dispose(); }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- identical backoff/give-up policy to PipeServer (copy verbatim) ----
|
||||
private static readonly TimeSpan[] BackoffSteps =
|
||||
{
|
||||
TimeSpan.FromMilliseconds(250), TimeSpan.FromMilliseconds(500), TimeSpan.FromSeconds(1),
|
||||
TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(8),
|
||||
};
|
||||
private const int MaxConsecutiveFailures = 20;
|
||||
|
||||
public async Task RunAsync(IFrameHandler handler, CancellationToken ct)
|
||||
{
|
||||
var consecutiveFailures = 0;
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try { await RunOneConnectionAsync(handler, ct).ConfigureAwait(false); consecutiveFailures = 0; }
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
consecutiveFailures++;
|
||||
if (consecutiveFailures >= MaxConsecutiveFailures)
|
||||
{
|
||||
_logger.Fatal(ex, "Sidecar TCP connection loop failed {Count} consecutive times — giving up so supervisor can restart", consecutiveFailures);
|
||||
throw;
|
||||
}
|
||||
var delay = BackoffSteps[Math.Min(consecutiveFailures - 1, BackoffSteps.Length - 1)];
|
||||
_logger.Error(ex, "Sidecar TCP connection loop error (consecutive failure {Count}/{Max}) — retrying in {Delay}", consecutiveFailures, MaxConsecutiveFailures, delay);
|
||||
try { await Task.Delay(delay, ct).ConfigureAwait(false); } catch (OperationCanceledException) { break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() { _cts.Cancel(); try { _listener?.Stop(); } catch { } _cts.Dispose(); }
|
||||
}
|
||||
```
|
||||
|
||||
**Tests (`TcpRoundTripTests`):** with a fake `IFrameHandler` that echoes a known `ReadRawReply`:
|
||||
1. **plaintext round-trip** — server on `127.0.0.1:0`, a raw `TcpClient` does Hello (good secret) → HelloAck.Accepted, then a `ReadRaw` → echoed reply.
|
||||
2. **TLS round-trip** — construct with a self-signed cert; client `SslStream` (pin thumbprint) → same exchange.
|
||||
3. **bad secret** — Hello with wrong secret → `HelloAck.Accepted == false`, `RejectReason == "shared-secret-mismatch"`.
|
||||
4. **single-active serial accept** — while one client is connected, a second connect does not get served until the first disconnects (assert the second's Hello completes only after the first closes).
|
||||
|
||||
Run `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests --filter TcpRoundTrip`. Commit by path — `feat(historian-sidecar): TcpFrameServer (TCP + optional TLS)`.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Sidecar `Program.cs` — TCP bootstrap + env
|
||||
|
||||
**Classification:** high-risk (process entry, env contract)
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none (needs Task 5)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Program.cs`
|
||||
- Modify: `tests/.../Driver.Historian.Wonderware.Tests/ProgramSmokeTests.cs`
|
||||
|
||||
Changes:
|
||||
- Remove the `OTOPCUA_ALLOWED_SID` read + `new SecurityIdentifier(...)` + the `using System.Security.Principal;`.
|
||||
- Read new env: `OTOPCUA_HISTORIAN_TCP_PORT` (int, default 32569), `OTOPCUA_HISTORIAN_BIND` (`IPAddress.Parse`, default `IPAddress.Any`), `OTOPCUA_HISTORIAN_TLS_ENABLED` (`== "true"`), and when TLS on, load the cert from `OTOPCUA_HISTORIAN_TLS_CERT` (pfx path or store thumbprint) + `OTOPCUA_HISTORIAN_TLS_CERT_PASSWORD` into an `X509Certificate2?` (null when TLS off). Keep `OTOPCUA_HISTORIAN_SECRET`/`OTOPCUA_HISTORIAN_ENABLED` + the SDK vars.
|
||||
- Preserve the `ENABLED!=true` idle branch (now log "tcp-only idle" wording; still `WaitOne()` → return 0).
|
||||
- Swap line ~62/64-65:
|
||||
```csharp
|
||||
using var server = new TcpFrameServer(bind, tcpPort, sharedSecret, tlsCert, Log.Logger);
|
||||
Log.Information("Wonderware historian sidecar serving — bind={Bind} port={Port} tls={Tls}", bind, tcpPort, tlsCert is not null);
|
||||
try { server.RunAsync(handler, cts.Token).GetAwaiter().GetResult(); }
|
||||
catch (OperationCanceledException) { }
|
||||
```
|
||||
- Update the class doc comment (drop "named-pipe"/"allowed-SID"; say "hosts a TCP server").
|
||||
|
||||
Update `ProgramSmokeTests` to the new env contract (no `OTOPCUA_ALLOWED_SID`; set `OTOPCUA_HISTORIAN_TCP_PORT`). Build the sidecar project → green. Commit by path — `feat(historian-sidecar): TCP bootstrap + env, drop allowed-SID`.
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Remove dead pipe code + finalize options shape
|
||||
|
||||
**Classification:** standard (deletion-heavy; must keep build green)
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none (needs Tasks 3, 4, 6)
|
||||
|
||||
**Files:**
|
||||
- Delete: `src/Drivers/…Driver.Historian.Wonderware/Ipc/PipeServer.cs`, `…/Ipc/PipeAcl.cs`
|
||||
- Delete: `tests/.../Driver.Historian.Wonderware.Tests/Ipc/PipeServerSidRejectTests.cs`, `Ipc/PipeRoundTripTests.cs` (superseded by `TcpRoundTripTests`)
|
||||
- Modify: `…Client/Internal/FrameChannel.cs` — delete `DefaultNamedPipeConnectFactory` + `using System.IO.Pipes;`
|
||||
- Modify: `WonderwareHistorianClientOptions.cs` — reshape record to `(string Host, int Port, string SharedSecret, string PeerName="OtOpcUa", TimeSpan? ConnectTimeout=null, TimeSpan? CallTimeout=null)`; drop `PipeName`; keep `UseTls`/`ServerCertThumbprint`/`ProbeTimeoutSeconds` as init props.
|
||||
- Modify call sites that passed `PipeName`: `Host/Program.cs:97` (`new WonderwareHistorianClientOptions(opts.Host, opts.Port, opts.SharedSecret) { UseTls = …, ServerCertThumbprint = … }`), `AlarmHistorianOptions.cs` (drop `PipeName`), and any test constructing options.
|
||||
|
||||
**Step — grep gate:** `grep -rn "PipeName\|PipeServer\|PipeAcl\|System.IO.Pipes\|DefaultNamedPipeConnectFactory\|OTOPCUA_ALLOWED_SID" src tests` must return **zero** non-comment hits before commit. Run the full two suites (`Client.Tests` + `Driver.Historian.Wonderware.Tests`) green. Commit by path — `refactor(historian): remove named-pipe transport`.
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Deploy scripts — env block + firewall + cert
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 9, Task 10
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/install/Install-Services.ps1` (the `$historianEnv` array, lines ~100-108)
|
||||
- Modify: `scripts/install/Refresh-Services.ps1` (the Step-5 env patch; the Step 4b assertion is unchanged)
|
||||
|
||||
- `$historianEnv`: drop `OTOPCUA_ALLOWED_SID=$sid`; add `OTOPCUA_HISTORIAN_TCP_PORT=$HistorianTcpPort`, `OTOPCUA_HISTORIAN_BIND=$HistorianBind`, `OTOPCUA_HISTORIAN_TLS_ENABLED=$($HistorianUseTls.ToString().ToLower())`, and (when TLS) `OTOPCUA_HISTORIAN_TLS_CERT=…`. Add params `[int]$HistorianTcpPort = 32569`, `[string]$HistorianBind='0.0.0.0'`, `[switch]$HistorianUseTls`, `[string]$HistorianTlsCertThumbprint`.
|
||||
- Add a **Windows Firewall rule** for the port: `New-NetFirewallRule -DisplayName "OtOpcUa Wonderware Historian (TCP $HistorianTcpPort)" -Direction Inbound -Action Allow -Protocol TCP -LocalPort $HistorianTcpPort` (idempotent: remove-then-add or check `Get-NetFirewallRule`).
|
||||
- A comment block on cert provisioning for prod (store thumbprint or pfx in `C:\ProgramData\OtOpcUa\pki`). The SharedSecret handling is unchanged (still generated/printed, never written to a tracked file).
|
||||
|
||||
No automated test (PowerShell). Validate with `pwsh -NoProfile -Command "[Parser]::ParseFile(...)"` parse-check on both scripts (like the Step 4b commit). Commit by path — `feat(install): historian TCP env + firewall rule`.
|
||||
|
||||
---
|
||||
|
||||
### Task 9: AdminUI Test-Connect probe → host/port/TLS
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** Task 8, Task 10
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/HistorianWonderwareAddressBuilder.cs`
|
||||
- Modify: the probe path that builds `WonderwareHistorianClientOptions` for the Test-Connect (find via `grep -rn "WonderwareHistorianClientOptions\|ProbeTimeoutSeconds" src/Server/ZB.MOM.WW.OtOpcUa.AdminUI`).
|
||||
|
||||
Replace pipe-name input with Host/Port/UseTls/(optional)Thumbprint fields; build the probe options with those. No bUnit (per repo convention — verified live in Task 11). Build AdminUI green. Commit by path — `feat(adminui): historian probe uses TCP host/port/tls`.
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Docs
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 8, Task 9
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/drivers/Historian.Wonderware.md` (rewrite the Architecture ASCII diagram + "named pipe / shared secret + allowed-SID" text → TCP + optional TLS + shared secret; note the remote-host capability)
|
||||
- Modify: `docs/ServiceHosting.md`, `docs/AlarmHistorian.md` (any named-pipe references → TCP)
|
||||
- Optionally: a short note in `CLAUDE.md` if it gains a historian transport line (it currently has none — skip unless adding).
|
||||
|
||||
Grep gate: `grep -rni "named pipe\|allowed-sid\|OTOPCUA_ALLOWED_SID\|PipeName" docs/drivers/Historian.Wonderware.md docs/ServiceHosting.md docs/AlarmHistorian.md` → no stale references. Commit by path — `docs(historian): TCP transport`.
|
||||
|
||||
---
|
||||
|
||||
### Task 11: Verification (build + test + live)
|
||||
|
||||
**Classification:** verification
|
||||
**Estimated implement time:** ~3 min agent + user-driven live
|
||||
**Parallelizable with:** none (needs all)
|
||||
|
||||
**Agent:**
|
||||
- `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → 0 errors.
|
||||
- `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests` → green; plus `…Runtime.Tests` if Task 4 touched a bound options test.
|
||||
- Final grep gate (Task 7) clean.
|
||||
|
||||
**Live (user-driven — agent does NOT sign in):**
|
||||
1. Publish the sidecar to the VM (the now-master checkout) with TCP env: `OTOPCUA_HISTORIAN_TCP_PORT=32569`, TLS off; open the firewall port (Task 8 rule). Restart `OtOpcUaWonderwareHistorian`; confirm log "serving — bind=… port=32569 tls=False".
|
||||
2. Point a (MacBook-docker) OtOpcUa `Historian:Wonderware` config at `Host=<VM-ip> Port=32569 UseTls=false` + the matching `SharedSecret`; do a `ReadRaw` (live samples) and a `WriteAlarmEvents` round-trip.
|
||||
3. Flip `OTOPCUA_HISTORIAN_TLS_ENABLED=true` (provision a cert) + client `UseTls=true` (+ thumbprint pin); re-verify the read.
|
||||
|
||||
**Done =** build clean + `dotnet test` green + live read/write pass (plaintext, then TLS).
|
||||
|
||||
Then run **superpowers-extended-cc:finishing-a-development-branch**.
|
||||
|
||||
---
|
||||
|
||||
## Dependency graph
|
||||
|
||||
```
|
||||
T0 ─┬─ T1 ─ T2 ─ T3 ─┐
|
||||
│ └── T4 ───────┤
|
||||
└─ T5 ─ T6 ───────┤
|
||||
T7 ─┬─ T8 ─┐
|
||||
├─ T9 ─┤
|
||||
└─ T10 ┤
|
||||
T11
|
||||
```
|
||||
T1∥T5, T2∥T5, T4∥T5 (disjoint projects). T8∥T9∥T10. T3 needs T2; T4 needs T1; T6 needs T5; T7 needs T3+T4+T6; T8/T9/T10 need T7; T11 needs all.
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-12-historian-tcp-transport.md",
|
||||
"tasks": [
|
||||
{"id": 0, "nativeTaskId": 296, "subject": "Task 0: Create feature branch", "status": "pending"},
|
||||
{"id": 1, "nativeTaskId": 297, "subject": "Task 1: Add TCP/TLS fields to client options", "status": "pending", "blockedBy": [0]},
|
||||
{"id": 2, "nativeTaskId": 298, "subject": "Task 2: Client TCP connect factory + FrameChannel rename", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 3, "nativeTaskId": 299, "subject": "Task 3: Switch client default ctor to TCP", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 4, "nativeTaskId": 300, "subject": "Task 4: Host config binding (Host/Port/TLS)", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 5, "nativeTaskId": 301, "subject": "Task 5: Sidecar TcpFrameServer", "status": "pending", "blockedBy": [0]},
|
||||
{"id": 6, "nativeTaskId": 302, "subject": "Task 6: Sidecar Program.cs — TCP bootstrap + env", "status": "pending", "blockedBy": [5]},
|
||||
{"id": 7, "nativeTaskId": 303, "subject": "Task 7: Remove dead pipe code + finalize options shape", "status": "pending", "blockedBy": [3, 4, 6]},
|
||||
{"id": 8, "nativeTaskId": 304, "subject": "Task 8: Deploy scripts — env block + firewall + cert", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 9, "nativeTaskId": 305, "subject": "Task 9: AdminUI Test-Connect probe to host/port/TLS", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 10, "nativeTaskId": 306, "subject": "Task 10: Docs — TCP transport", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 11, "nativeTaskId": 307, "subject": "Task 11: Verification (build + test + live)", "status": "pending", "blockedBy": [8, 9, 10]}
|
||||
],
|
||||
"lastUpdated": "2026-06-12"
|
||||
}
|
||||
Reference in New Issue
Block a user