Sign/SignAndEncrypt sessions failed before activation with "ApplicationCertificate
for the security profile ... cannot be found" (BadConfigurationError): both
RealOpcUaClient.ConnectAsync and the verify probe built an empty CertificateIdentifier.
- New OpcUaApplicationIdentity builds the client ApplicationConfiguration for both
paths. Secured modes load the application certificate from a configurable own
store (subject + ApplicationUri), auto-creating a self-signed RSA-2048 cert only
when none exists. A persisted cert is reused across reconnect/restart and never
silently rotated: one that no longer validates (e.g. ApplicationUri mismatch)
fails closed. First-time creation is serialised per own store and logged at
Warning with the thumbprint. None mode is unchanged and never touches the store.
- New OpcUaGlobalOptions: ApplicationUri, ApplicationCertificateSubject,
OwnCertificateStorePath, AutoCreateApplicationCertificate,
ApplicationCertificateLifetimeMonths (validated).
- Owned resources: the private-key cert is disposed on disconnect, failed connect
and at probe end; sessions are now disposed after close, and a reconnect on the
same instance closes the previous session first.
- CertStoreActor wrote trusted server certs to the store ROOT, which the SDK never
reads (it uses {store}/certs), so an operator Trust never affected validation and
SDK-rejected certs never listed. It now uses certs/ and migrates legacy root files
on start.
- Rig: all site nodes (docker/, docker-env2/) put the four PKI stores on the
persistent /app/data volume with a pinned ApplicationUri.
- Tests: in-process strict-trust StandardServer (test-only Opc.Ua.Server 1.5.378.106)
exercising the real adapter through ConnectAsync and VerifyEndpointAsync for Sign
and SignAndEncrypt: allow + read + reconnect + restart on one identity, deny when
the client doesn't trust the server (BadCertificateUntrusted), deny when the server
doesn't trust the client (BadSecurityChecksFailed), None untouched, concurrent
provisioning creates one cert, URI mismatch fails without rotating, auto-create off.
Against the pre-fix client these tests fail with the exact #121 error.
ScadaBridge Env2 Docker Infrastructure
A second Docker deployment of a minimal ScadaBridge cluster topology, designed to run concurrently with the primary docker/ stack so the Transport (#24) feature can be exercised end-to-end across two real environments.
See docs/plans/2026-05-24-second-environment-design.md for the design rationale.
Cluster Topology
┌───────────────────┐
│ Traefik LB :9100 │ ◄── CLI / Browser
│ Dashboard :8181 │
└────────┬──────────┘
│ routes to active node
┌──────────────────────┼──────────────────────────────┐
│ Env2 Central Cluster │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ env2-central-a │◄──►│ env2-central-b │ │
│ │ Web UI :9101 │ │ Web UI :9102 │ │
│ │ Akka :9111 │ │ Akka :9112 │ │
│ └────────┬─────────┘ └─────────────────┘ │
│ │ │
└───────────┼─────────────────────────────────────────┘
│ Akka.NET Remoting
▼
┌────────────────────┐
│ Env2 Site-X │
│ (Env2 Site X) │
│ │
│ node-a ◄──► node-b│
│ Akka :9121 :9122 │
│ gRPC :9123 :9124 │
└────────────────────┘
Port Allocation
Env2 host ports are the primary's ports + 100. Both stacks can run simultaneously.
| Node | Container Name | Host Web | Host Akka | Host gRPC | Internal |
|---|---|---|---|---|---|
| Traefik LB | scadabridge-env2-traefik |
9100 | — | — | 80 (proxy), 8080 (dashboard host:8181) |
| Central A | scadabridge-env2-central-a |
9101 | 9111 | 9113 | 5000 (web), 8081 (Akka), 8083 (gRPC) |
| Central B | scadabridge-env2-central-b |
9102 | 9112 | 9114 | 5000 (web), 8081 (Akka), 8083 (gRPC) |
| Site-X A | scadabridge-env2-site-x-a |
— | 9121 | 9123 | 8082 (Akka), 8083 (gRPC) |
| Site-X B | scadabridge-env2-site-x-b |
— | 9122 | 9124 | 8082 (Akka), 8083 (gRPC) |
Shared Infrastructure
Env2 attaches to the existing scadabridge-net Docker bridge network and reuses these primary infra containers:
| Service | Container | What env2 uses it for |
|---|---|---|
| MS SQL | scadabridge-mssql |
Env2-specific databases ScadaBridgeConfig2 / ScadaBridgeMachineData2 |
| SMTP | scadabridge-smtp |
Notification capture in Mailpit (env2 emails distinguishable by FromAddress) |
| OPC UA | scadabridge-opcua |
Simulated tags for site-x data connections |
| REST API | scadabridge-restapi |
External REST API testing |
LDAP is not one of the reused infra/ containers (that stack's ldap service is
retired/commented out). Env2's central nodes bind to the same local redundant GLAuth pair
as docker/ — zb-ldap-primary on host.docker.internal:3893 with zb-ldap-backup on
host.docker.internal:3894 as FallbackServers (source of truth:
scadaproj/infra/glauth-redundant); same test users, same bind credentials as the primary
stack.
Commands
First-Time Setup
# 1. Make sure primary infra is up (creates scadabridge-net, scadabridge-mssql, etc.)
cd infra && docker compose up -d && cd ..
# 2. Build image + create env2 databases + deploy env2 containers
bash docker-env2/deploy.sh
# 3. Seed env2's single test site (first-time only)
bash docker-env2/seed-sites.sh
The seed also creates a native alarm source demo: a MxAlarmDemo template with a
GalaxyAlarms native alarm source bound to the shared MxGateway connection, plus a
deployed MxAlarmDemo-1 instance on site-x. This exercises the read-only native alarm
mirror end-to-end — mirrored alarms appear live in the Central UI Debug View with severity
and condition badges. Verify the binding with:
scadabridge --url http://localhost:9100 template native-alarm-source list --template-id <id>
After Code Changes
bash docker-env2/deploy.sh
The Docker build is shared with the primary stack — if you've just run docker/deploy.sh, the env2 build hits a fully cached image.
Running Both Stacks Concurrently
bash docker/deploy.sh # primary
bash docker-env2/deploy.sh # env2
Both UIs are now reachable:
- Primary: http://localhost:9000
- Env2: http://localhost:9100
Teardown
bash docker-env2/teardown.sh
Containers stop; node data lives in named Docker volumes (scadabridge-env2-*-data) and
logs in node directories, both left in place. To also remove the persistent data:
# Destructive: deletes all env2 SQLite data (site config cache, S&F buffers, LocalDb).
docker volume rm $(docker volume ls -q --filter name=scadabridge-env2-)
rm -rf docker-env2/*/logs
To also drop the env2 databases:
docker exec scadabridge-mssql /opt/mssql-tools18/bin/sqlcmd \
-S localhost -U sa -P 'ScadaBridge_Dev1#' -C \
-Q "DROP DATABASE ScadaBridgeConfig2; DROP DATABASE ScadaBridgeMachineData2;"
CLI Access
dotnet run --project src/ZB.MOM.WW.ScadaBridge.CLI -- \
--url http://localhost:9100 \
--username multi-role --password password \
template list
View Logs
docker compose -f docker-env2/docker-compose.yml logs -f
docker logs -f scadabridge-env2-central-a
Test Users
Same as primary (env2 shares the local redundant GLAuth pair, scadaproj/infra/glauth-redundant).
See "Shared Infrastructure" above and primary docker/README.md.
Transport Testing Workflow
See docs/plans/2026-05-24-second-environment-verification.md for the manual golden-path checklist.
What's Different from Primary
- Single site (
site-x) instead of three (site-a/b/c). - Host port range 91XX vs primary 90XX.
- Container names prefixed
scadabridge-env2-. - Databases
ScadaBridgeConfig2/ScadaBridgeMachineData2on the sharedscadabridge-mssql. Transport.SourceEnvironment="docker-cluster-env2"(stamped into exported bundle manifests).- Distinct
Security.JwtSigningKey(sessions cannot cross envs).