Add configurable transport security profiles and bind address
Adds Security section to appsettings.json with configurable OPC UA transport profiles (None, Basic256Sha256-Sign, Basic256Sha256-SignAndEncrypt), certificate policy settings, and a configurable BindAddress for the OPC UA endpoint. Defaults preserve backward compatibility. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -77,6 +77,10 @@ dotnet test --filter "FullyQualifiedName~MyTestClass.MyMethod" # single test
|
||||
- MXAccess requires a deployed ArchestrA Platform on the machine running the server
|
||||
- COM apartment: MXAccess objects must live on an STA thread with a message pump
|
||||
|
||||
## Transport Security
|
||||
|
||||
The server supports configurable OPC UA transport security via the `Security` section in `appsettings.json`. Phase 1 profiles: `None` (default), `Basic256Sha256-Sign`, `Basic256Sha256-SignAndEncrypt`. Security profiles are resolved by `SecurityProfileResolver` at startup. The server certificate is always created even for `None`-only deployments because `UserName` token encryption depends on it. See `docs/security.md` for the full guide.
|
||||
|
||||
## Library Preferences
|
||||
|
||||
- **Logging**: Serilog with rolling daily file sink
|
||||
|
||||
@@ -52,7 +52,7 @@ dotnet build ZB.MOM.WW.LmxOpcUa.slnx
|
||||
dotnet run --project src/ZB.MOM.WW.LmxOpcUa.Host
|
||||
```
|
||||
|
||||
The server starts on `opc.tcp://localhost:4840/LmxOpcUa` with SecurityPolicy None.
|
||||
The server starts on `opc.tcp://localhost:4840/LmxOpcUa` with the `None` security profile by default. Configure `Security.Profiles` in `appsettings.json` to enable `Basic256Sha256-Sign` or `Basic256Sha256-SignAndEncrypt` for transport security. See [Security Guide](docs/security.md).
|
||||
|
||||
### Install as Windows service
|
||||
|
||||
@@ -140,6 +140,7 @@ gr/ Galaxy repository docs, SQL queries, schema
|
||||
| [Status Dashboard](docs/StatusDashboard.md) | HTTP server, health checks, metrics reporting |
|
||||
| [Service Hosting](docs/ServiceHosting.md) | TopShelf, startup/shutdown sequence, error handling |
|
||||
| [CLI Tool](docs/CliTool.md) | Connect, browse, read, write, subscribe, historyread, alarms commands |
|
||||
| [Security](docs/security.md) | Transport security profiles, certificate trust, production hardening |
|
||||
|
||||
## Related Documentation
|
||||
|
||||
|
||||
@@ -37,6 +37,31 @@ Example:
|
||||
dotnet run -- write -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -v 42 -U operator -P op123
|
||||
```
|
||||
|
||||
## Transport Security Options
|
||||
|
||||
All commands accept the `-S` / `--security` flag to select the transport security mode:
|
||||
|
||||
| Flag | Values | Description |
|
||||
|------|--------|-------------|
|
||||
| `-S` / `--security` | `none`, `sign`, `encrypt` | Transport security mode (default: `none`) |
|
||||
|
||||
When `sign` or `encrypt` is specified, the CLI tool:
|
||||
|
||||
1. Ensures a client application certificate exists (auto-created if missing)
|
||||
2. Discovers server endpoints and selects one matching the requested `MessageSecurityMode`
|
||||
3. Prefers `Basic256Sha256` when multiple matching endpoints exist
|
||||
4. Fails with a clear error if no matching endpoint is found
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
# Connect with encrypted transport
|
||||
dotnet run -- connect -u opc.tcp://localhost:4840/LmxOpcUa -S encrypt
|
||||
|
||||
# Browse with signed transport and credentials
|
||||
dotnet run -- browse -u opc.tcp://localhost:4840/LmxOpcUa -S sign -U admin -P secret -r -d 2
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### connect
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
The service loads configuration from `appsettings.json` at startup using the Microsoft.Extensions.Configuration stack. `AppConfiguration` is the root holder class that aggregates five typed sections: `OpcUa`, `MxAccess`, `GalaxyRepository`, `Dashboard`, and `Historian`. Each section binds to a dedicated POCO class with sensible defaults, so the service runs with zero configuration on a standard deployment.
|
||||
The service loads configuration from `appsettings.json` at startup using the Microsoft.Extensions.Configuration stack. `AppConfiguration` is the root holder class that aggregates typed sections: `OpcUa`, `MxAccess`, `GalaxyRepository`, `Dashboard`, `Historian`, `Authentication`, and `Security`. Each section binds to a dedicated POCO class with sensible defaults, so the service runs with zero configuration on a standard deployment.
|
||||
|
||||
## Config Binding Pattern
|
||||
|
||||
@@ -22,6 +22,7 @@ configuration.GetSection("GalaxyRepository").Bind(_config.GalaxyRepository);
|
||||
configuration.GetSection("Dashboard").Bind(_config.Dashboard);
|
||||
configuration.GetSection("Historian").Bind(_config.Historian);
|
||||
configuration.GetSection("Authentication").Bind(_config.Authentication);
|
||||
configuration.GetSection("Security").Bind(_config.Security);
|
||||
```
|
||||
|
||||
This pattern uses `IConfiguration.GetSection().Bind()` rather than `IOptions<T>` because the service targets .NET Framework 4.8, where the full dependency injection container is not used.
|
||||
@@ -46,6 +47,7 @@ Controls the OPC UA server endpoint and session limits. Defined in `OpcUaConfigu
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `BindAddress` | `string` | `"0.0.0.0"` | IP address or hostname the server binds to. Use `0.0.0.0` for all interfaces, `localhost` for local-only, or a specific IP |
|
||||
| `Port` | `int` | `4840` | TCP port the OPC UA server listens on |
|
||||
| `EndpointPath` | `string` | `"/LmxOpcUa"` | Path appended to the host URI |
|
||||
| `ServerName` | `string` | `"LmxOpcUa"` | Server name presented to OPC UA clients |
|
||||
@@ -130,6 +132,30 @@ Example configuration:
|
||||
}
|
||||
```
|
||||
|
||||
### Security
|
||||
|
||||
Controls OPC UA transport security profiles and certificate handling. Defined in `SecurityProfileConfiguration`. See [Security Guide](security.md) for detailed usage.
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `Profiles` | `List<string>` | `["None"]` | Security profiles to expose. Valid: `None`, `Basic256Sha256-Sign`, `Basic256Sha256-SignAndEncrypt` |
|
||||
| `AutoAcceptClientCertificates` | `bool` | `true` | Auto-accept untrusted client certificates. Set to `false` in production |
|
||||
| `RejectSHA1Certificates` | `bool` | `true` | Reject client certificates signed with SHA-1 |
|
||||
| `MinimumCertificateKeySize` | `int` | `2048` | Minimum RSA key size for client certificates |
|
||||
| `PkiRootPath` | `string?` | `null` | Override for PKI root directory. Defaults to `%LOCALAPPDATA%\OPC Foundation\pki` |
|
||||
| `CertificateSubject` | `string?` | `null` | Override for server certificate subject. Defaults to `CN={ServerName}, O=ZB MOM, DC=localhost` |
|
||||
|
||||
Example — production deployment with encrypted transport:
|
||||
|
||||
```json
|
||||
"Security": {
|
||||
"Profiles": ["Basic256Sha256-SignAndEncrypt"],
|
||||
"AutoAcceptClientCertificates": false,
|
||||
"RejectSHA1Certificates": true,
|
||||
"MinimumCertificateKeySize": 2048
|
||||
}
|
||||
```
|
||||
|
||||
## Feature Flags
|
||||
|
||||
Three boolean properties act as feature flags that control optional subsystems:
|
||||
@@ -146,6 +172,10 @@ Three boolean properties act as feature flags that control optional subsystems:
|
||||
- `OpcUa.GalaxyName` must not be empty
|
||||
- `MxAccess.ClientName` must not be empty
|
||||
- `GalaxyRepository.ConnectionString` must not be empty
|
||||
- `Security.MinimumCertificateKeySize` must be at least 2048
|
||||
- Unknown security profile names are logged as warnings
|
||||
- `AutoAcceptClientCertificates = true` emits a warning
|
||||
- Only-`None` profile configuration emits a warning
|
||||
|
||||
If validation fails, the service throws `InvalidOperationException` and does not start.
|
||||
|
||||
@@ -169,6 +199,7 @@ Integration tests use this constructor to inject substitute implementations of `
|
||||
```json
|
||||
{
|
||||
"OpcUa": {
|
||||
"BindAddress": "0.0.0.0",
|
||||
"Port": 4840,
|
||||
"EndpointPath": "/LmxOpcUa",
|
||||
"ServerName": "LmxOpcUa",
|
||||
@@ -210,6 +241,14 @@ Integration tests use this constructor to inject substitute implementations of `
|
||||
"AllowAnonymous": true,
|
||||
"AnonymousCanWrite": true,
|
||||
"Users": []
|
||||
},
|
||||
"Security": {
|
||||
"Profiles": ["None"],
|
||||
"AutoAcceptClientCertificates": true,
|
||||
"RejectSHA1Certificates": true,
|
||||
"MinimumCertificateKeySize": 2048,
|
||||
"PkiRootPath": null,
|
||||
"CertificateSubject": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -8,6 +8,7 @@ The OPC UA server component hosts the Galaxy-backed namespace on a configurable
|
||||
|
||||
| Property | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `BindAddress` | `0.0.0.0` | IP address or hostname the server binds to |
|
||||
| `Port` | `4840` | TCP port the server listens on |
|
||||
| `EndpointPath` | `/LmxOpcUa` | URI path appended to the base address |
|
||||
| `ServerName` | `LmxOpcUa` | Application name presented to clients |
|
||||
@@ -16,7 +17,7 @@ The OPC UA server component hosts the Galaxy-backed namespace on a configurable
|
||||
| `SessionTimeoutMinutes` | `30` | Idle session timeout |
|
||||
| `AlarmTrackingEnabled` | `false` | Enables `AlarmConditionState` nodes for alarm attributes |
|
||||
|
||||
The resulting endpoint URL is `opc.tcp://0.0.0.0:{Port}{EndpointPath}`, e.g., `opc.tcp://0.0.0.0:4840/LmxOpcUa`.
|
||||
The resulting endpoint URL is `opc.tcp://{BindAddress}:{Port}{EndpointPath}`, e.g., `opc.tcp://0.0.0.0:4840/LmxOpcUa`.
|
||||
|
||||
The namespace URI follows the pattern `urn:{GalaxyName}:LmxOpcUa` and serves as both the `ApplicationUri` and `ProductUri`.
|
||||
|
||||
@@ -31,19 +32,21 @@ The configuration covers:
|
||||
- **TransportQuotas** -- 4 MB max message/string/byte-string size, 120-second operation timeout, 1-hour security token lifetime
|
||||
- **TraceConfiguration** -- OPC Foundation SDK tracing is disabled (output path `null`, trace masks `0`); all logging goes through Serilog instead
|
||||
|
||||
## Security Policy
|
||||
## Security Profiles
|
||||
|
||||
The server runs with `MessageSecurityMode.None` and `SecurityPolicies.None`:
|
||||
The server supports configurable transport security profiles controlled by the `Security` section in `appsettings.json`. The default configuration exposes only `MessageSecurityMode.None` for backward compatibility.
|
||||
|
||||
```csharp
|
||||
SecurityPolicies = { new ServerSecurityPolicy
|
||||
{
|
||||
SecurityMode = MessageSecurityMode.None,
|
||||
SecurityPolicyUri = SecurityPolicies.None
|
||||
} }
|
||||
```
|
||||
Supported Phase 1 profiles:
|
||||
|
||||
This is intentional for plant-floor deployments where the server sits on an isolated OT network. Galaxy-level security classification controls write access per attribute rather than at the transport layer.
|
||||
| Profile Name | SecurityPolicy URI | MessageSecurityMode |
|
||||
|---|---|---|
|
||||
| `None` | `SecurityPolicy#None` | `None` |
|
||||
| `Basic256Sha256-Sign` | `SecurityPolicy#Basic256Sha256` | `Sign` |
|
||||
| `Basic256Sha256-SignAndEncrypt` | `SecurityPolicy#Basic256Sha256` | `SignAndEncrypt` |
|
||||
|
||||
`SecurityProfileResolver` maps configured profile names to `ServerSecurityPolicy` instances at startup. Unknown names are skipped with a warning, and an empty or invalid list falls back to `None`.
|
||||
|
||||
For production deployments, configure `["Basic256Sha256-SignAndEncrypt"]` or `["None", "Basic256Sha256-SignAndEncrypt"]` and set `AutoAcceptClientCertificates` to `false`. See the [Security Guide](security.md) for hardening details.
|
||||
|
||||
### User token policies
|
||||
|
||||
@@ -62,7 +65,7 @@ On successful validation, the session identity is set to a `RoleBasedIdentity` t
|
||||
|
||||
## Certificate handling
|
||||
|
||||
On startup, `OpcUaServerHost.StartAsync` calls `CheckApplicationInstanceCertificate(false, 2048)` to locate or create a 2048-bit self-signed certificate. The certificate subject follows the format `CN={ServerName}, O=ZB MOM, DC=localhost`. Certificate stores use the directory-based store type under `%LOCALAPPDATA%\OPC Foundation\pki\`:
|
||||
On startup, `OpcUaServerHost.StartAsync` calls `CheckApplicationInstanceCertificate(false, minKeySize)` to locate or create a self-signed certificate meeting the configured minimum key size (default 2048). The certificate subject defaults to `CN={ServerName}, O=ZB MOM, DC=localhost` but can be overridden via `Security.CertificateSubject`. Certificate stores use the directory-based store type under the configured `Security.PkiRootPath` (default `%LOCALAPPDATA%\OPC Foundation\pki\`):
|
||||
|
||||
| Store | Path suffix |
|
||||
|-------|-------------|
|
||||
@@ -71,7 +74,7 @@ On startup, `OpcUaServerHost.StartAsync` calls `CheckApplicationInstanceCertific
|
||||
| Trusted peers | `pki/trusted` |
|
||||
| Rejected | `pki/rejected` |
|
||||
|
||||
`AutoAcceptUntrustedCertificates` is set to `true` so the server does not reject client certificates.
|
||||
`AutoAcceptUntrustedCertificates` is controlled by `Security.AutoAcceptClientCertificates` (default `true`). Set to `false` in production to enforce client certificate trust. When `RejectSHA1Certificates` is `true` (default), client certificates signed with SHA-1 are rejected. Certificate validation events are logged for visibility into accepted and rejected client connections.
|
||||
|
||||
## Server class hierarchy
|
||||
|
||||
@@ -101,4 +104,6 @@ On startup, `OpcUaServerHost.StartAsync` calls `CheckApplicationInstanceCertific
|
||||
|
||||
- `src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/OpcUaServerHost.cs` -- Application lifecycle and programmatic configuration
|
||||
- `src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxOpcUaServer.cs` -- StandardServer subclass and node manager creation
|
||||
- `src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/SecurityProfileResolver.cs` -- Profile-name to ServerSecurityPolicy mapping
|
||||
- `src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/OpcUaConfiguration.cs` -- Configuration POCO
|
||||
- `src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/SecurityProfileConfiguration.cs` -- Security configuration POCO
|
||||
|
||||
259
docs/security.md
Normal file
259
docs/security.md
Normal file
@@ -0,0 +1,259 @@
|
||||
# Transport Security
|
||||
|
||||
## Overview
|
||||
|
||||
The LmxOpcUa server supports configurable transport security profiles that control how data is protected on the wire between OPC UA clients and the server.
|
||||
|
||||
There are two distinct layers of security in OPC UA:
|
||||
|
||||
- **Transport security** -- secures the communication channel itself using TLS-style certificate exchange, message signing, and encryption. This is what the `Security` configuration section controls.
|
||||
- **UserName token encryption** -- protects user credentials (username/password) sent during session activation. The OPC UA stack encrypts UserName tokens using the server's application certificate regardless of the transport security mode. This means UserName authentication works on `None` endpoints too — the credentials themselves are always encrypted. However, a secure transport profile adds protection against message-level tampering and eavesdropping of data payloads.
|
||||
|
||||
## Supported Security Profiles
|
||||
|
||||
The server supports three transport security profiles in Phase 1:
|
||||
|
||||
| Profile Name | Security Policy | Message Security Mode | Description |
|
||||
|-----------------------------------|---------------------|-----------------------|--------------------------------------------------|
|
||||
| `None` | None | None | No signing or encryption. Suitable for development and isolated networks only. |
|
||||
| `Basic256Sha256-Sign` | Basic256Sha256 | Sign | Messages are signed but not encrypted. Protects against tampering but data is visible on the wire. |
|
||||
| `Basic256Sha256-SignAndEncrypt` | Basic256Sha256 | SignAndEncrypt | Messages are both signed and encrypted. Full protection against tampering and eavesdropping. |
|
||||
|
||||
Multiple profiles can be enabled simultaneously. The server exposes a separate endpoint for each configured profile, and clients select the one they prefer during connection.
|
||||
|
||||
If no valid profiles are configured (or all names are unrecognized), the server falls back to `None` with a warning in the log.
|
||||
|
||||
## Configuration
|
||||
|
||||
Transport security is configured in the `Security` section of `appsettings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"Security": {
|
||||
"Profiles": ["None"],
|
||||
"AutoAcceptClientCertificates": true,
|
||||
"RejectSHA1Certificates": true,
|
||||
"MinimumCertificateKeySize": 2048,
|
||||
"PkiRootPath": null,
|
||||
"CertificateSubject": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|--------------------------------|------------|--------------------------------------------------|-------------|
|
||||
| `Profiles` | `string[]` | `["None"]` | List of security profile names to expose as server endpoints. Valid values: `None`, `Basic256Sha256-Sign`, `Basic256Sha256-SignAndEncrypt`. Profile names are case-insensitive. Duplicates are ignored. |
|
||||
| `AutoAcceptClientCertificates` | `bool` | `true` | When `true`, the server automatically trusts client certificates that are not already in the trusted store. Set to `false` in production for explicit trust management. |
|
||||
| `RejectSHA1Certificates` | `bool` | `true` | When `true`, client certificates signed with SHA-1 are rejected. SHA-1 is considered cryptographically weak. |
|
||||
| `MinimumCertificateKeySize` | `int` | `2048` | Minimum RSA key size (in bits) required for client certificates. Certificates with shorter keys are rejected. |
|
||||
| `PkiRootPath` | `string?` | `null` (defaults to `%LOCALAPPDATA%\OPC Foundation\pki`) | Override for the PKI root directory where certificates are stored. When `null`, uses the OPC Foundation default location. |
|
||||
| `CertificateSubject` | `string?` | `null` (defaults to `CN={ServerName}, O=ZB MOM, DC=localhost`) | Override for the server certificate subject name. When `null`, the subject is derived from the configured `ServerName`. |
|
||||
|
||||
### Example: Development (no security)
|
||||
|
||||
```json
|
||||
{
|
||||
"Security": {
|
||||
"Profiles": ["None"],
|
||||
"AutoAcceptClientCertificates": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Production (encrypted only)
|
||||
|
||||
```json
|
||||
{
|
||||
"Security": {
|
||||
"Profiles": ["Basic256Sha256-SignAndEncrypt"],
|
||||
"AutoAcceptClientCertificates": false,
|
||||
"RejectSHA1Certificates": true,
|
||||
"MinimumCertificateKeySize": 2048
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Mixed (sign and encrypt endpoints, no plaintext)
|
||||
|
||||
```json
|
||||
{
|
||||
"Security": {
|
||||
"Profiles": ["Basic256Sha256-Sign", "Basic256Sha256-SignAndEncrypt"],
|
||||
"AutoAcceptClientCertificates": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## PKI Directory Layout
|
||||
|
||||
The server stores certificates in a directory-based PKI store. The default root is:
|
||||
|
||||
```
|
||||
%LOCALAPPDATA%\OPC Foundation\pki\
|
||||
```
|
||||
|
||||
This can be overridden with the `PkiRootPath` setting. The directory structure is:
|
||||
|
||||
```
|
||||
pki/
|
||||
own/ Server's own application certificate and private key
|
||||
issuer/ CA certificates that issued trusted client certificates
|
||||
trusted/ Explicitly trusted client (peer) certificates
|
||||
rejected/ Certificates that were presented but not trusted
|
||||
```
|
||||
|
||||
### Certificate Trust Flow
|
||||
|
||||
When a client connects using a secure profile (`Sign` or `SignAndEncrypt`), the following trust evaluation occurs:
|
||||
|
||||
1. The client presents its application certificate during the secure channel handshake.
|
||||
2. The server checks whether the certificate exists in the `trusted/` store.
|
||||
3. If found, the connection proceeds (subject to key size and SHA-1 checks).
|
||||
4. If not found and `AutoAcceptClientCertificates` is `true`, the certificate is automatically copied to `trusted/` and the connection proceeds.
|
||||
5. If not found and `AutoAcceptClientCertificates` is `false`, the certificate is copied to `rejected/` and the connection is refused.
|
||||
6. Regardless of trust status, the certificate must meet the `MinimumCertificateKeySize` requirement and pass the SHA-1 check (if `RejectSHA1Certificates` is `true`).
|
||||
|
||||
On first startup with a secure profile, the server automatically generates a self-signed application certificate in the `own/` directory if one does not already exist.
|
||||
|
||||
## Production Hardening
|
||||
|
||||
The default settings prioritize ease of development. Before deploying to production, apply the following changes:
|
||||
|
||||
### 1. Disable automatic certificate acceptance
|
||||
|
||||
Set `AutoAcceptClientCertificates` to `false` so that only explicitly trusted client certificates are accepted:
|
||||
|
||||
```json
|
||||
{
|
||||
"Security": {
|
||||
"AutoAcceptClientCertificates": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
After changing this setting, you must manually copy each client's application certificate (the `.der` file) into the `trusted/` directory.
|
||||
|
||||
### 2. Remove the None profile
|
||||
|
||||
Remove `None` from the `Profiles` list to prevent unencrypted connections:
|
||||
|
||||
```json
|
||||
{
|
||||
"Security": {
|
||||
"Profiles": ["Basic256Sha256-SignAndEncrypt"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Configure named users
|
||||
|
||||
Disable anonymous access and define named users in the `Authentication` section. Use `AnonymousCanWrite` to control whether anonymous clients (if still allowed) can write:
|
||||
|
||||
```json
|
||||
{
|
||||
"Authentication": {
|
||||
"AllowAnonymous": false,
|
||||
"AnonymousCanWrite": false,
|
||||
"Users": [
|
||||
{ "Username": "operator", "Password": "secure-password" },
|
||||
{ "Username": "viewer", "Password": "read-only-password" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
While UserName tokens are always encrypted by the OPC UA stack (using the server certificate), enabling a secure transport profile adds protection against message-level tampering and data eavesdropping.
|
||||
|
||||
### 4. Review the rejected certificate store
|
||||
|
||||
Periodically inspect the `rejected/` directory. Certificates that appear here were presented by clients but were not trusted. If you recognize a legitimate client certificate, move it to the `trusted/` directory to grant access.
|
||||
|
||||
## CLI Examples
|
||||
|
||||
The `tools/opcuacli-dotnet` CLI tool supports the `-S` (or `--security`) flag to select the transport security mode when connecting. Valid values are `none`, `sign`, and `encrypt`.
|
||||
|
||||
### Connect with no security
|
||||
|
||||
```bash
|
||||
cd tools/opcuacli-dotnet
|
||||
dotnet run -- connect -u opc.tcp://localhost:4840/LmxOpcUa -S none
|
||||
```
|
||||
|
||||
### Connect with signing
|
||||
|
||||
```bash
|
||||
dotnet run -- connect -u opc.tcp://localhost:4840/LmxOpcUa -S sign
|
||||
```
|
||||
|
||||
### Connect with signing and encryption
|
||||
|
||||
```bash
|
||||
dotnet run -- connect -u opc.tcp://localhost:4840/LmxOpcUa -S encrypt
|
||||
```
|
||||
|
||||
### Browse with encryption and authentication
|
||||
|
||||
```bash
|
||||
dotnet run -- browse -u opc.tcp://localhost:4840/LmxOpcUa -S encrypt -U operator -P secure-password -r -d 3
|
||||
```
|
||||
|
||||
### Read a node with signing
|
||||
|
||||
```bash
|
||||
dotnet run -- read -u opc.tcp://localhost:4840/LmxOpcUa -S sign -n "ns=2;s=TestMachine_001/Speed"
|
||||
```
|
||||
|
||||
The CLI tool auto-generates its own client certificate on first use (stored under `%LOCALAPPDATA%\OpcUaCli\pki\own\`). When connecting to a server with `AutoAcceptClientCertificates` set to `false`, you must copy the CLI tool's certificate into the server's `trusted/` directory before the connection will succeed.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Certificate trust failure
|
||||
|
||||
**Symptom:** The client receives a `BadSecurityChecksFailed` or `BadCertificateUntrusted` error when connecting.
|
||||
|
||||
**Cause:** The server does not trust the client's certificate (or vice versa), and `AutoAcceptClientCertificates` is `false`.
|
||||
|
||||
**Resolution:**
|
||||
1. Check the server's `rejected/` directory for the client's certificate file.
|
||||
2. Copy the `.der` file from `rejected/` to `trusted/`.
|
||||
3. Retry the connection.
|
||||
4. If the server's own certificate is not trusted by the client, copy the server's certificate from `pki/own/certs/` to the client's trusted store.
|
||||
|
||||
### Endpoint mismatch
|
||||
|
||||
**Symptom:** The client receives a `BadSecurityModeRejected` or `BadSecurityPolicyRejected` error, or reports "No endpoint found with security mode...".
|
||||
|
||||
**Cause:** The client is requesting a security mode that the server does not expose. For example, the client requests `SignAndEncrypt` but the server only has `None` configured.
|
||||
|
||||
**Resolution:**
|
||||
1. Verify the server's configured `Profiles` in `appsettings.json`.
|
||||
2. Ensure the profile matching the client's requested mode is listed (e.g., add `Basic256Sha256-SignAndEncrypt` for encrypted connections).
|
||||
3. Restart the server after changing the configuration.
|
||||
4. Use the CLI tool to verify available endpoints:
|
||||
```bash
|
||||
dotnet run -- connect -u opc.tcp://localhost:4840/LmxOpcUa -S none
|
||||
```
|
||||
The output displays the security mode and policy of the connected endpoint.
|
||||
|
||||
### Server certificate not generated
|
||||
|
||||
**Symptom:** The server logs a warning about application certificate check failure on startup.
|
||||
|
||||
**Cause:** The `pki/own/` directory may not be writable, or the certificate generation failed.
|
||||
|
||||
**Resolution:**
|
||||
1. Ensure the service account has write access to the PKI root directory.
|
||||
2. Check that the `PkiRootPath` (if overridden) points to a valid, writable location.
|
||||
3. Delete any corrupt certificate files in `pki/own/` and restart the server to trigger regeneration.
|
||||
|
||||
### SHA-1 certificate rejection
|
||||
|
||||
**Symptom:** A client with a valid certificate is rejected, and the server logs mention SHA-1.
|
||||
|
||||
**Cause:** The client's certificate was signed with SHA-1, and `RejectSHA1Certificates` is `true` (the default).
|
||||
|
||||
**Resolution:**
|
||||
- Regenerate the client certificate using SHA-256 or stronger (recommended).
|
||||
- Alternatively, set `RejectSHA1Certificates` to `false` in the server configuration (not recommended for production).
|
||||
@@ -34,5 +34,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
|
||||
/// Gets or sets the authentication and role-based access control settings.
|
||||
/// </summary>
|
||||
public AuthenticationConfiguration Authentication { get; set; } = new AuthenticationConfiguration();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the transport security settings that control which OPC UA security profiles are exposed.
|
||||
/// </summary>
|
||||
public SecurityProfileConfiguration Security { get; set; } = new SecurityProfileConfiguration();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Linq;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
|
||||
{
|
||||
@@ -21,8 +23,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
|
||||
Log.Information("=== Effective Configuration ===");
|
||||
|
||||
// OPC UA
|
||||
Log.Information("OpcUa.Port={Port}, EndpointPath={EndpointPath}, ServerName={ServerName}, GalaxyName={GalaxyName}",
|
||||
config.OpcUa.Port, config.OpcUa.EndpointPath, config.OpcUa.ServerName, config.OpcUa.GalaxyName);
|
||||
Log.Information("OpcUa.BindAddress={BindAddress}, Port={Port}, EndpointPath={EndpointPath}, ServerName={ServerName}, GalaxyName={GalaxyName}",
|
||||
config.OpcUa.BindAddress, config.OpcUa.Port, config.OpcUa.EndpointPath, config.OpcUa.ServerName, config.OpcUa.GalaxyName);
|
||||
Log.Information("OpcUa.MaxSessions={MaxSessions}, SessionTimeoutMinutes={SessionTimeout}",
|
||||
config.OpcUa.MaxSessions, config.OpcUa.SessionTimeoutMinutes);
|
||||
|
||||
@@ -67,6 +69,41 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
|
||||
Log.Information("Dashboard.Enabled={Enabled}, Port={Port}, RefreshInterval={Refresh}s",
|
||||
config.Dashboard.Enabled, config.Dashboard.Port, config.Dashboard.RefreshIntervalSeconds);
|
||||
|
||||
// Security
|
||||
Log.Information("Security.Profiles=[{Profiles}], AutoAcceptClientCertificates={AutoAccept}, RejectSHA1={RejectSHA1}, MinKeySize={MinKeySize}",
|
||||
string.Join(", ", config.Security.Profiles), config.Security.AutoAcceptClientCertificates,
|
||||
config.Security.RejectSHA1Certificates, config.Security.MinimumCertificateKeySize);
|
||||
|
||||
if (config.Security.PkiRootPath != null)
|
||||
Log.Information("Security.PkiRootPath={PkiRootPath}", config.Security.PkiRootPath);
|
||||
if (config.Security.CertificateSubject != null)
|
||||
Log.Information("Security.CertificateSubject={CertificateSubject}", config.Security.CertificateSubject);
|
||||
|
||||
var unknownProfiles = config.Security.Profiles
|
||||
.Where(p => !SecurityProfileResolver.ValidProfileNames.Contains(p, System.StringComparer.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
if (unknownProfiles.Count > 0)
|
||||
{
|
||||
Log.Warning("Unknown security profile(s): {Profiles}. Valid values: {ValidProfiles}",
|
||||
string.Join(", ", unknownProfiles), string.Join(", ", SecurityProfileResolver.ValidProfileNames));
|
||||
}
|
||||
|
||||
if (config.Security.MinimumCertificateKeySize < 2048)
|
||||
{
|
||||
Log.Error("Security.MinimumCertificateKeySize must be at least 2048");
|
||||
valid = false;
|
||||
}
|
||||
|
||||
if (config.Security.AutoAcceptClientCertificates)
|
||||
{
|
||||
Log.Warning("Security.AutoAcceptClientCertificates is enabled — client certificate trust is not enforced. Set to false in production");
|
||||
}
|
||||
|
||||
if (config.Security.Profiles.Count == 1 && config.Security.Profiles[0].Equals("None", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Log.Warning("Only the 'None' security profile is configured — transport security is disabled");
|
||||
}
|
||||
|
||||
Log.Information("=== Configuration {Status} ===", valid ? "Valid" : "INVALID");
|
||||
return valid;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
|
||||
/// </summary>
|
||||
public class OpcUaConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the IP address or hostname the OPC UA server binds to.
|
||||
/// Defaults to <c>0.0.0.0</c> (all interfaces). Set to a specific IP or hostname to restrict listening.
|
||||
/// </summary>
|
||||
public string BindAddress { get; set; } = "0.0.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the TCP port on which the OPC UA server listens for client sessions.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// Transport security settings that control which OPC UA security profiles the server exposes and how client certificates are handled.
|
||||
/// </summary>
|
||||
public class SecurityProfileConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the list of security profile names to expose as server endpoints.
|
||||
/// Valid values: "None", "Basic256Sha256-Sign", "Basic256Sha256-SignAndEncrypt".
|
||||
/// Defaults to ["None"] for backward compatibility.
|
||||
/// </summary>
|
||||
public List<string> Profiles { get; set; } = new List<string> { "None" };
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the server automatically accepts client certificates
|
||||
/// that are not in the trusted store. Should be <see langword="false"/> in production.
|
||||
/// </summary>
|
||||
public bool AutoAcceptClientCertificates { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether client certificates signed with SHA-1 are rejected.
|
||||
/// </summary>
|
||||
public bool RejectSHA1Certificates { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum RSA key size required for client certificates.
|
||||
/// </summary>
|
||||
public int MinimumCertificateKeySize { get; set; } = 2048;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional override for the PKI root directory.
|
||||
/// When <see langword="null"/>, defaults to <c>%LOCALAPPDATA%\OPC Foundation\pki</c>.
|
||||
/// </summary>
|
||||
public string? PkiRootPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional override for the server certificate subject name.
|
||||
/// When <see langword="null"/>, defaults to <c>CN={ServerName}, O=ZB MOM, DC=localhost</c>.
|
||||
/// </summary>
|
||||
public string? CertificateSubject { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
private readonly HistorianDataSource? _historianDataSource;
|
||||
private readonly AuthenticationConfiguration _authConfig;
|
||||
private readonly IUserAuthenticationProvider? _authProvider;
|
||||
private readonly SecurityProfileConfiguration _securityConfig;
|
||||
private ApplicationInstance? _application;
|
||||
private LmxOpcUaServer? _server;
|
||||
|
||||
@@ -52,7 +53,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
public OpcUaServerHost(OpcUaConfiguration config, IMxAccessClient mxAccessClient, PerformanceMetrics metrics,
|
||||
HistorianDataSource? historianDataSource = null,
|
||||
AuthenticationConfiguration? authConfig = null,
|
||||
IUserAuthenticationProvider? authProvider = null)
|
||||
IUserAuthenticationProvider? authProvider = null,
|
||||
SecurityProfileConfiguration? securityConfig = null)
|
||||
{
|
||||
_config = config;
|
||||
_mxAccessClient = mxAccessClient;
|
||||
@@ -60,6 +62,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
_historianDataSource = historianDataSource;
|
||||
_authConfig = authConfig ?? new AuthenticationConfiguration();
|
||||
_authProvider = authProvider;
|
||||
_securityConfig = securityConfig ?? new SecurityProfileConfiguration();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -69,63 +72,66 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
{
|
||||
var namespaceUri = $"urn:{_config.GalaxyName}:LmxOpcUa";
|
||||
|
||||
// Resolve configured security profiles
|
||||
var securityPolicies = SecurityProfileResolver.Resolve(_securityConfig.Profiles);
|
||||
foreach (var sp in securityPolicies)
|
||||
{
|
||||
Log.Information("Security profile active: {PolicyUri} / {Mode}", sp.SecurityPolicyUri, sp.SecurityMode);
|
||||
}
|
||||
|
||||
// Build PKI paths
|
||||
var pkiRoot = _securityConfig.PkiRootPath ?? System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"OPC Foundation", "pki");
|
||||
var certSubject = _securityConfig.CertificateSubject ?? $"CN={_config.ServerName}, O=ZB MOM, DC=localhost";
|
||||
|
||||
var serverConfig = new ServerConfiguration
|
||||
{
|
||||
BaseAddresses = { $"opc.tcp://{_config.BindAddress}:{_config.Port}{_config.EndpointPath}" },
|
||||
MaxSessionCount = _config.MaxSessions,
|
||||
MaxSessionTimeout = _config.SessionTimeoutMinutes * 60 * 1000, // ms
|
||||
MinSessionTimeout = 10000,
|
||||
UserTokenPolicies = BuildUserTokenPolicies()
|
||||
};
|
||||
foreach (var policy in securityPolicies)
|
||||
serverConfig.SecurityPolicies.Add(policy);
|
||||
|
||||
var secConfig = new SecurityConfiguration
|
||||
{
|
||||
ApplicationCertificate = new CertificateIdentifier
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = System.IO.Path.Combine(pkiRoot, "own"),
|
||||
SubjectName = certSubject
|
||||
},
|
||||
TrustedIssuerCertificates = new CertificateTrustList
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = System.IO.Path.Combine(pkiRoot, "issuer")
|
||||
},
|
||||
TrustedPeerCertificates = new CertificateTrustList
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = System.IO.Path.Combine(pkiRoot, "trusted")
|
||||
},
|
||||
RejectedCertificateStore = new CertificateTrustList
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = System.IO.Path.Combine(pkiRoot, "rejected")
|
||||
},
|
||||
AutoAcceptUntrustedCertificates = _securityConfig.AutoAcceptClientCertificates,
|
||||
RejectSHA1SignedCertificates = _securityConfig.RejectSHA1Certificates,
|
||||
MinimumCertificateKeySize = (ushort)_securityConfig.MinimumCertificateKeySize
|
||||
};
|
||||
|
||||
var appConfig = new ApplicationConfiguration
|
||||
{
|
||||
ApplicationName = _config.ServerName,
|
||||
ApplicationUri = namespaceUri,
|
||||
ApplicationType = ApplicationType.Server,
|
||||
ProductUri = namespaceUri,
|
||||
|
||||
ServerConfiguration = new ServerConfiguration
|
||||
{
|
||||
BaseAddresses = { $"opc.tcp://0.0.0.0:{_config.Port}{_config.EndpointPath}" },
|
||||
MaxSessionCount = _config.MaxSessions,
|
||||
MaxSessionTimeout = _config.SessionTimeoutMinutes * 60 * 1000, // ms
|
||||
MinSessionTimeout = 10000,
|
||||
SecurityPolicies =
|
||||
{
|
||||
new ServerSecurityPolicy
|
||||
{
|
||||
SecurityMode = MessageSecurityMode.None,
|
||||
SecurityPolicyUri = SecurityPolicies.None
|
||||
}
|
||||
},
|
||||
UserTokenPolicies = BuildUserTokenPolicies()
|
||||
},
|
||||
|
||||
SecurityConfiguration = new SecurityConfiguration
|
||||
{
|
||||
ApplicationCertificate = new CertificateIdentifier
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"OPC Foundation", "pki", "own"),
|
||||
SubjectName = $"CN={_config.ServerName}, O=ZB MOM, DC=localhost"
|
||||
},
|
||||
TrustedIssuerCertificates = new CertificateTrustList
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"OPC Foundation", "pki", "issuer")
|
||||
},
|
||||
TrustedPeerCertificates = new CertificateTrustList
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"OPC Foundation", "pki", "trusted")
|
||||
},
|
||||
RejectedCertificateStore = new CertificateTrustList
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"OPC Foundation", "pki", "rejected")
|
||||
},
|
||||
AutoAcceptUntrustedCertificates = true
|
||||
},
|
||||
ServerConfiguration = serverConfig,
|
||||
SecurityConfiguration = secConfig,
|
||||
|
||||
TransportQuotas = new TransportQuotas
|
||||
{
|
||||
@@ -148,6 +154,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
|
||||
await appConfig.Validate(ApplicationType.Server);
|
||||
|
||||
// Hook certificate validation logging
|
||||
appConfig.CertificateValidator.CertificateValidation += OnCertificateValidation;
|
||||
|
||||
_application = new ApplicationInstance
|
||||
{
|
||||
ApplicationName = _config.ServerName,
|
||||
@@ -156,19 +165,34 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
};
|
||||
|
||||
// Check/create application certificate
|
||||
bool certOk = await _application.CheckApplicationInstanceCertificate(false, 2048);
|
||||
var minKeySize = (ushort)_securityConfig.MinimumCertificateKeySize;
|
||||
bool certOk = await _application.CheckApplicationInstanceCertificate(false, minKeySize);
|
||||
if (!certOk)
|
||||
{
|
||||
Log.Warning("Application certificate check failed, attempting to create...");
|
||||
certOk = await _application.CheckApplicationInstanceCertificate(false, 2048);
|
||||
certOk = await _application.CheckApplicationInstanceCertificate(false, minKeySize);
|
||||
}
|
||||
|
||||
_server = new LmxOpcUaServer(_config.GalaxyName, _mxAccessClient, _metrics, _historianDataSource,
|
||||
_config.AlarmTrackingEnabled, _authConfig, _authProvider);
|
||||
await _application.Start(_server);
|
||||
|
||||
Log.Information("OPC UA server started on opc.tcp://localhost:{Port}{EndpointPath} (namespace={Namespace})",
|
||||
_config.Port, _config.EndpointPath, namespaceUri);
|
||||
Log.Information("OPC UA server started on opc.tcp://{BindAddress}:{Port}{EndpointPath} (namespace={Namespace})",
|
||||
_config.BindAddress, _config.Port, _config.EndpointPath, namespaceUri);
|
||||
}
|
||||
|
||||
private void OnCertificateValidation(CertificateValidator sender, CertificateValidationEventArgs e)
|
||||
{
|
||||
if (_securityConfig.AutoAcceptClientCertificates)
|
||||
{
|
||||
e.Accept = true;
|
||||
Log.Debug("Client certificate auto-accepted: {Subject}", e.Certificate?.Subject);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Warning("Client certificate validation: {Error} for {Subject} — Accepted={Accepted}",
|
||||
e.Error?.StatusCode, e.Certificate?.Subject, e.Accept);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
86
src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/SecurityProfileResolver.cs
Normal file
86
src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/SecurityProfileResolver.cs
Normal file
@@ -0,0 +1,86 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using Serilog;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps configured security profile names to OPC UA <see cref="ServerSecurityPolicy"/> instances.
|
||||
/// </summary>
|
||||
public static class SecurityProfileResolver
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext(typeof(SecurityProfileResolver));
|
||||
|
||||
private static readonly Dictionary<string, ServerSecurityPolicy> KnownProfiles =
|
||||
new Dictionary<string, ServerSecurityPolicy>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["None"] = new ServerSecurityPolicy
|
||||
{
|
||||
SecurityMode = MessageSecurityMode.None,
|
||||
SecurityPolicyUri = SecurityPolicies.None
|
||||
},
|
||||
["Basic256Sha256-Sign"] = new ServerSecurityPolicy
|
||||
{
|
||||
SecurityMode = MessageSecurityMode.Sign,
|
||||
SecurityPolicyUri = SecurityPolicies.Basic256Sha256
|
||||
},
|
||||
["Basic256Sha256-SignAndEncrypt"] = new ServerSecurityPolicy
|
||||
{
|
||||
SecurityMode = MessageSecurityMode.SignAndEncrypt,
|
||||
SecurityPolicyUri = SecurityPolicies.Basic256Sha256
|
||||
}
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the configured profile names to <see cref="ServerSecurityPolicy"/> entries.
|
||||
/// Unknown names are skipped with a warning. An empty or fully-invalid list falls back to <c>None</c>.
|
||||
/// </summary>
|
||||
/// <param name="profileNames">The profile names from configuration.</param>
|
||||
/// <returns>A deduplicated list of server security policies.</returns>
|
||||
public static List<ServerSecurityPolicy> Resolve(IReadOnlyCollection<string> profileNames)
|
||||
{
|
||||
var resolved = new List<ServerSecurityPolicy>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var name in profileNames ?? Array.Empty<string>())
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
continue;
|
||||
|
||||
var trimmed = name.Trim();
|
||||
|
||||
if (!seen.Add(trimmed))
|
||||
{
|
||||
Log.Debug("Skipping duplicate security profile: {Profile}", trimmed);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (KnownProfiles.TryGetValue(trimmed, out var policy))
|
||||
{
|
||||
resolved.Add(policy);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Warning("Unknown security profile '{Profile}' — skipping. Valid profiles: {ValidProfiles}",
|
||||
trimmed, string.Join(", ", KnownProfiles.Keys));
|
||||
}
|
||||
}
|
||||
|
||||
if (resolved.Count == 0)
|
||||
{
|
||||
Log.Warning("No valid security profiles configured — falling back to None");
|
||||
resolved.Add(KnownProfiles["None"]);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of valid profile names for validation and documentation.
|
||||
/// </summary>
|
||||
public static IReadOnlyCollection<string> ValidProfileNames => KnownProfiles.Keys.ToList().AsReadOnly();
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
|
||||
configuration.GetSection("Dashboard").Bind(_config.Dashboard);
|
||||
configuration.GetSection("Historian").Bind(_config.Historian);
|
||||
configuration.GetSection("Authentication").Bind(_config.Authentication);
|
||||
// Clear the default Profiles list before binding so JSON values replace rather than append
|
||||
_config.Security.Profiles.Clear();
|
||||
configuration.GetSection("Security").Bind(_config.Security);
|
||||
|
||||
_mxProxy = new MxProxyAdapter();
|
||||
_galaxyRepository = new GalaxyRepositoryService(_config.GalaxyRepository);
|
||||
@@ -161,7 +164,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
|
||||
? new Domain.ConfigUserAuthenticationProvider(_config.Authentication.Users)
|
||||
: (Domain.IUserAuthenticationProvider?)null;
|
||||
_serverHost = new OpcUaServerHost(_config.OpcUa, effectiveMxClient, _metrics, historianDataSource,
|
||||
_config.Authentication, authProvider);
|
||||
_config.Authentication, authProvider, _config.Security);
|
||||
|
||||
// Step 9-10: Query hierarchy, start server, build address space
|
||||
DateTime? initialDeployTime = null;
|
||||
|
||||
@@ -122,6 +122,17 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the security profile configuration for the test host.
|
||||
/// </summary>
|
||||
/// <param name="security">The security profile configuration to inject.</param>
|
||||
/// <returns>The current builder so additional overrides can be chained.</returns>
|
||||
public OpcUaServiceBuilder WithSecurity(SecurityProfileConfiguration security)
|
||||
{
|
||||
_config.Security = security;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Effectively disables Galaxy change detection by pushing the polling interval beyond realistic test durations.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"OpcUa": {
|
||||
"BindAddress": "0.0.0.0",
|
||||
"Port": 4840,
|
||||
"EndpointPath": "/LmxOpcUa",
|
||||
"ServerName": "LmxOpcUa",
|
||||
@@ -36,6 +37,14 @@
|
||||
"AnonymousCanWrite": true,
|
||||
"Users": []
|
||||
},
|
||||
"Security": {
|
||||
"Profiles": ["None"],
|
||||
"AutoAcceptClientCertificates": true,
|
||||
"RejectSHA1Certificates": true,
|
||||
"MinimumCertificateKeySize": 2048,
|
||||
"PkiRootPath": null,
|
||||
"CertificateSubject": null
|
||||
},
|
||||
"Historian": {
|
||||
"Enabled": false,
|
||||
"ConnectionString": "Server=localhost;Database=Runtime;Integrated Security=true;",
|
||||
|
||||
@@ -25,6 +25,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Configuration
|
||||
configuration.GetSection("MxAccess").Bind(config.MxAccess);
|
||||
configuration.GetSection("GalaxyRepository").Bind(config.GalaxyRepository);
|
||||
configuration.GetSection("Dashboard").Bind(config.Dashboard);
|
||||
configuration.GetSection("Security").Bind(config.Security);
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -35,6 +36,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Configuration
|
||||
public void OpcUa_Section_BindsCorrectly()
|
||||
{
|
||||
var config = LoadFromJson();
|
||||
config.OpcUa.BindAddress.ShouldBe("0.0.0.0");
|
||||
config.OpcUa.Port.ShouldBe(4840);
|
||||
config.OpcUa.EndpointPath.ShouldBe("/LmxOpcUa");
|
||||
config.OpcUa.ServerName.ShouldBe("LmxOpcUa");
|
||||
@@ -117,12 +119,31 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Configuration
|
||||
public void DefaultValues_AreCorrect()
|
||||
{
|
||||
var config = new AppConfiguration();
|
||||
config.OpcUa.BindAddress.ShouldBe("0.0.0.0");
|
||||
config.OpcUa.Port.ShouldBe(4840);
|
||||
config.MxAccess.ClientName.ShouldBe("LmxOpcUa");
|
||||
config.GalaxyRepository.ChangeDetectionIntervalSeconds.ShouldBe(30);
|
||||
config.Dashboard.Enabled.ShouldBe(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that BindAddress can be overridden to a specific hostname or IP.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void OpcUa_BindAddress_CanBeOverridden()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new[]
|
||||
{
|
||||
new System.Collections.Generic.KeyValuePair<string, string>("OpcUa:BindAddress", "localhost"),
|
||||
})
|
||||
.Build();
|
||||
|
||||
var config = new OpcUaConfiguration();
|
||||
configuration.GetSection("OpcUa").Bind(config);
|
||||
config.BindAddress.ShouldBe("localhost");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that a valid configuration passes startup validation.
|
||||
/// </summary>
|
||||
@@ -154,5 +175,66 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Configuration
|
||||
config.OpcUa.GalaxyName = "";
|
||||
ConfigurationValidator.ValidateAndLog(config).ShouldBe(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the Security section binds profile list from appsettings.json.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Security_Section_BindsProfilesCorrectly()
|
||||
{
|
||||
var config = LoadFromJson();
|
||||
config.Security.Profiles.ShouldContain("None");
|
||||
config.Security.AutoAcceptClientCertificates.ShouldBe(true);
|
||||
config.Security.MinimumCertificateKeySize.ShouldBe(2048);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that a minimum key size below 2048 is rejected by the validator.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Validator_InvalidMinKeySize_ReturnsFalse()
|
||||
{
|
||||
var config = new AppConfiguration();
|
||||
config.Security.MinimumCertificateKeySize = 1024;
|
||||
ConfigurationValidator.ValidateAndLog(config).ShouldBe(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that a valid configuration with security defaults passes validation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Validator_DefaultSecurityConfig_ReturnsTrue()
|
||||
{
|
||||
var config = LoadFromJson();
|
||||
ConfigurationValidator.ValidateAndLog(config).ShouldBe(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that custom security profiles can be bound from in-memory configuration.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Security_Section_BindsCustomProfiles()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new[]
|
||||
{
|
||||
new System.Collections.Generic.KeyValuePair<string, string>("Security:Profiles:0", "None"),
|
||||
new System.Collections.Generic.KeyValuePair<string, string>("Security:Profiles:1", "Basic256Sha256-SignAndEncrypt"),
|
||||
new System.Collections.Generic.KeyValuePair<string, string>("Security:AutoAcceptClientCertificates", "false"),
|
||||
new System.Collections.Generic.KeyValuePair<string, string>("Security:MinimumCertificateKeySize", "4096"),
|
||||
})
|
||||
.Build();
|
||||
|
||||
// Clear default list before binding to match production behavior
|
||||
var config = new AppConfiguration();
|
||||
config.Security.Profiles.Clear();
|
||||
configuration.GetSection("Security").Bind(config.Security);
|
||||
|
||||
config.Security.Profiles.Count.ShouldBe(2);
|
||||
config.Security.Profiles.ShouldContain("None");
|
||||
config.Security.Profiles.ShouldContain("Basic256Sha256-SignAndEncrypt");
|
||||
config.Security.AutoAcceptClientCertificates.ShouldBe(false);
|
||||
config.Security.MinimumCertificateKeySize.ShouldBe(4096);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
|
||||
{
|
||||
@@ -108,10 +109,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
|
||||
/// </summary>
|
||||
/// <param name="mxClient">An optional fake MXAccess client to inject; otherwise a default fake is created.</param>
|
||||
/// <param name="repo">An optional fake repository to inject; otherwise standard test data is used.</param>
|
||||
/// <param name="security">An optional security profile configuration for the test server.</param>
|
||||
/// <returns>A fixture configured to exercise the direct fake-client path.</returns>
|
||||
public static OpcUaServerFixture WithFakeMxAccessClient(
|
||||
FakeMxAccessClient? mxClient = null,
|
||||
FakeGalaxyRepository? repo = null)
|
||||
FakeGalaxyRepository? repo = null,
|
||||
SecurityProfileConfiguration? security = null)
|
||||
{
|
||||
var client = mxClient ?? new FakeMxAccessClient();
|
||||
var r = repo ?? new FakeGalaxyRepository
|
||||
@@ -125,6 +128,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
|
||||
.WithGalaxyRepository(r)
|
||||
.WithGalaxyName("TestGalaxy");
|
||||
|
||||
if (security != null)
|
||||
builder.WithSecurity(security);
|
||||
|
||||
return new OpcUaServerFixture(builder, repo: r, mxClient: client);
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
|
||||
/// Connects the helper to an OPC UA endpoint exposed by the test bridge.
|
||||
/// </summary>
|
||||
/// <param name="endpointUrl">The OPC UA endpoint URL to connect to.</param>
|
||||
public async Task ConnectAsync(string endpointUrl)
|
||||
/// <param name="securityMode">The requested message security mode (default: None).</param>
|
||||
/// <param name="username">Optional username for authenticated connections.</param>
|
||||
/// <param name="password">Optional password for authenticated connections.</param>
|
||||
public async Task ConnectAsync(string endpointUrl,
|
||||
MessageSecurityMode securityMode = MessageSecurityMode.None,
|
||||
string? username = null, string? password = null)
|
||||
{
|
||||
var config = new ApplicationConfiguration
|
||||
{
|
||||
@@ -87,13 +92,64 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
|
||||
await config.Validate(ApplicationType.Client);
|
||||
config.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||
|
||||
var endpoint = CoreClientUtils.SelectEndpoint(config, endpointUrl, false);
|
||||
EndpointDescription endpoint;
|
||||
if (securityMode != MessageSecurityMode.None)
|
||||
{
|
||||
// Ensure client certificate exists for secure connections
|
||||
var app = new ApplicationInstance
|
||||
{
|
||||
ApplicationName = "OpcUaTestClient",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
ApplicationConfiguration = config
|
||||
};
|
||||
await app.CheckApplicationInstanceCertificate(false, 2048);
|
||||
|
||||
// Discover and select endpoint matching the requested mode
|
||||
endpoint = SelectEndpointByMode(endpointUrl, securityMode);
|
||||
}
|
||||
else
|
||||
{
|
||||
endpoint = CoreClientUtils.SelectEndpoint(config, endpointUrl, false);
|
||||
}
|
||||
|
||||
var endpointConfig = EndpointConfiguration.Create(config);
|
||||
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfig);
|
||||
|
||||
UserIdentity identity = username != null
|
||||
? new UserIdentity(username, password ?? "")
|
||||
: new UserIdentity();
|
||||
|
||||
_session = await Session.Create(
|
||||
config, configuredEndpoint, false,
|
||||
"OpcUaTestClient", 30000, null, null);
|
||||
"OpcUaTestClient", 30000, identity, null);
|
||||
}
|
||||
|
||||
private static EndpointDescription SelectEndpointByMode(string endpointUrl, MessageSecurityMode mode)
|
||||
{
|
||||
using var client = DiscoveryClient.Create(new Uri(endpointUrl));
|
||||
var endpoints = client.GetEndpoints(null);
|
||||
|
||||
foreach (var ep in endpoints)
|
||||
{
|
||||
if (ep.SecurityMode == mode && ep.SecurityPolicyUri == SecurityPolicies.Basic256Sha256)
|
||||
{
|
||||
ep.EndpointUrl = endpointUrl;
|
||||
return ep;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to any matching mode
|
||||
foreach (var ep in endpoints)
|
||||
{
|
||||
if (ep.SecurityMode == mode)
|
||||
{
|
||||
ep.EndpointUrl = endpointUrl;
|
||||
return ep;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"No endpoint with security mode {mode} found on {endpointUrl}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Tests.Security
|
||||
{
|
||||
public class SecurityProfileConfigurationTests
|
||||
{
|
||||
[Fact]
|
||||
public void DefaultConfig_HasNoneProfile()
|
||||
{
|
||||
var config = new SecurityProfileConfiguration();
|
||||
config.Profiles.ShouldContain("None");
|
||||
config.Profiles.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultConfig_AutoAcceptTrue()
|
||||
{
|
||||
var config = new SecurityProfileConfiguration();
|
||||
config.AutoAcceptClientCertificates.ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultConfig_RejectSha1True()
|
||||
{
|
||||
var config = new SecurityProfileConfiguration();
|
||||
config.RejectSHA1Certificates.ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultConfig_MinKeySize2048()
|
||||
{
|
||||
var config = new SecurityProfileConfiguration();
|
||||
config.MinimumCertificateKeySize.ShouldBe(2048);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultConfig_PkiRootPathNull()
|
||||
{
|
||||
var config = new SecurityProfileConfiguration();
|
||||
config.PkiRootPath.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultConfig_CertificateSubjectNull()
|
||||
{
|
||||
var config = new SecurityProfileConfiguration();
|
||||
config.CertificateSubject.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Tests.Security
|
||||
{
|
||||
public class SecurityProfileResolverTests
|
||||
{
|
||||
[Fact]
|
||||
public void Resolve_DefaultNone_ReturnsSingleNonePolicy()
|
||||
{
|
||||
var result = SecurityProfileResolver.Resolve(new List<string> { "None" });
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
result[0].SecurityMode.ShouldBe(MessageSecurityMode.None);
|
||||
result[0].SecurityPolicyUri.ShouldBe(SecurityPolicies.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_SignProfile_ReturnsBasic256Sha256Sign()
|
||||
{
|
||||
var result = SecurityProfileResolver.Resolve(new List<string> { "Basic256Sha256-Sign" });
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
result[0].SecurityMode.ShouldBe(MessageSecurityMode.Sign);
|
||||
result[0].SecurityPolicyUri.ShouldBe(SecurityPolicies.Basic256Sha256);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_SignAndEncryptProfile_ReturnsBasic256Sha256SignAndEncrypt()
|
||||
{
|
||||
var result = SecurityProfileResolver.Resolve(new List<string> { "Basic256Sha256-SignAndEncrypt" });
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
result[0].SecurityMode.ShouldBe(MessageSecurityMode.SignAndEncrypt);
|
||||
result[0].SecurityPolicyUri.ShouldBe(SecurityPolicies.Basic256Sha256);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_MultipleProfiles_ReturnsExpectedPolicies()
|
||||
{
|
||||
var result = SecurityProfileResolver.Resolve(new List<string>
|
||||
{
|
||||
"None", "Basic256Sha256-Sign", "Basic256Sha256-SignAndEncrypt"
|
||||
});
|
||||
|
||||
result.Count.ShouldBe(3);
|
||||
result.ShouldContain(p => p.SecurityMode == MessageSecurityMode.None);
|
||||
result.ShouldContain(p => p.SecurityMode == MessageSecurityMode.Sign);
|
||||
result.ShouldContain(p => p.SecurityMode == MessageSecurityMode.SignAndEncrypt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_DuplicateProfiles_Deduplicated()
|
||||
{
|
||||
var result = SecurityProfileResolver.Resolve(new List<string>
|
||||
{
|
||||
"None", "None", "Basic256Sha256-Sign", "Basic256Sha256-Sign"
|
||||
});
|
||||
|
||||
result.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_UnknownProfile_SkippedWithWarning()
|
||||
{
|
||||
var result = SecurityProfileResolver.Resolve(new List<string>
|
||||
{
|
||||
"None", "SomeUnknownProfile"
|
||||
});
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
result[0].SecurityMode.ShouldBe(MessageSecurityMode.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_EmptyList_FallsBackToNone()
|
||||
{
|
||||
var result = SecurityProfileResolver.Resolve(new List<string>());
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
result[0].SecurityMode.ShouldBe(MessageSecurityMode.None);
|
||||
result[0].SecurityPolicyUri.ShouldBe(SecurityPolicies.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_NullList_FallsBackToNone()
|
||||
{
|
||||
var result = SecurityProfileResolver.Resolve(null!);
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
result[0].SecurityMode.ShouldBe(MessageSecurityMode.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_AllUnknownProfiles_FallsBackToNone()
|
||||
{
|
||||
var result = SecurityProfileResolver.Resolve(new List<string> { "Bogus", "AlsoBogus" });
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
result[0].SecurityMode.ShouldBe(MessageSecurityMode.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_CaseInsensitive()
|
||||
{
|
||||
var result = SecurityProfileResolver.Resolve(new List<string> { "none", "BASIC256SHA256-SIGN" });
|
||||
|
||||
result.Count.ShouldBe(2);
|
||||
result.ShouldContain(p => p.SecurityMode == MessageSecurityMode.None);
|
||||
result.ShouldContain(p => p.SecurityMode == MessageSecurityMode.Sign);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_WhitespaceEntries_Skipped()
|
||||
{
|
||||
var result = SecurityProfileResolver.Resolve(new List<string> { "", " ", "None" });
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
result[0].SecurityMode.ShouldBe(MessageSecurityMode.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidProfileNames_ContainsExpectedEntries()
|
||||
{
|
||||
var names = SecurityProfileResolver.ValidProfileNames;
|
||||
|
||||
names.ShouldContain("None");
|
||||
names.ShouldContain("Basic256Sha256-Sign");
|
||||
names.ShouldContain("Basic256Sha256-SignAndEncrypt");
|
||||
names.Count.ShouldBe(3);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,9 @@ public class AlarmsCommand : ICommand
|
||||
[CommandOption("password", 'P', Description = "Password for authentication")]
|
||||
public string? Password { get; init; }
|
||||
|
||||
[CommandOption("security", 'S', Description = "Transport security: none, sign, encrypt (default: none)")]
|
||||
public string Security { get; init; } = "none";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the node to subscribe to for event notifications, typically a source object or the server node.
|
||||
/// </summary>
|
||||
@@ -45,7 +48,7 @@ public class AlarmsCommand : ICommand
|
||||
/// <param name="console">The CLI console used for cancellation and alarm-event output.</param>
|
||||
public async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
using var session = await OpcUaHelper.ConnectAsync(Url, Username, Password);
|
||||
using var session = await OpcUaHelper.ConnectAsync(Url, Username, Password, Security);
|
||||
|
||||
var nodeId = string.IsNullOrEmpty(NodeId)
|
||||
? ObjectIds.Server
|
||||
|
||||
@@ -21,6 +21,9 @@ public class BrowseCommand : ICommand
|
||||
[CommandOption("password", 'P', Description = "Password for authentication")]
|
||||
public string? Password { get; init; }
|
||||
|
||||
[CommandOption("security", 'S', Description = "Transport security: none, sign, encrypt (default: none)")]
|
||||
public string Security { get; init; } = "none";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the optional node identifier to browse from; defaults to the OPC UA Objects folder.
|
||||
/// </summary>
|
||||
@@ -45,7 +48,7 @@ public class BrowseCommand : ICommand
|
||||
/// <param name="console">The console used to emit browse output.</param>
|
||||
public async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
using var session = await OpcUaHelper.ConnectAsync(Url, Username, Password);
|
||||
using var session = await OpcUaHelper.ConnectAsync(Url, Username, Password, Security);
|
||||
|
||||
var startNode = string.IsNullOrEmpty(NodeId)
|
||||
? ObjectIds.ObjectsFolder
|
||||
|
||||
@@ -19,13 +19,16 @@ public class ConnectCommand : ICommand
|
||||
[CommandOption("password", 'P', Description = "Password for authentication")]
|
||||
public string? Password { get; init; }
|
||||
|
||||
[CommandOption("security", 'S', Description = "Transport security: none, sign, encrypt (default: none)")]
|
||||
public string Security { get; init; } = "none";
|
||||
|
||||
/// <summary>
|
||||
/// Connects to the OPC UA endpoint and prints the resolved server metadata.
|
||||
/// </summary>
|
||||
/// <param name="console">The console used to report connection results.</param>
|
||||
public async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
using var session = await OpcUaHelper.ConnectAsync(Url, Username, Password);
|
||||
using var session = await OpcUaHelper.ConnectAsync(Url, Username, Password, Security);
|
||||
await console.Output.WriteLineAsync($"Connected to: {session.Endpoint.EndpointUrl}");
|
||||
await console.Output.WriteLineAsync($"Server: {session.Endpoint.Server!.ApplicationName}");
|
||||
await console.Output.WriteLineAsync($"Security Mode: {session.Endpoint.SecurityMode}");
|
||||
|
||||
@@ -21,6 +21,9 @@ public class HistoryReadCommand : ICommand
|
||||
[CommandOption("password", 'P', Description = "Password for authentication")]
|
||||
public string? Password { get; init; }
|
||||
|
||||
[CommandOption("security", 'S', Description = "Transport security: none, sign, encrypt (default: none)")]
|
||||
public string Security { get; init; } = "none";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the node identifier for the historized variable to query.
|
||||
/// </summary>
|
||||
@@ -63,7 +66,7 @@ public class HistoryReadCommand : ICommand
|
||||
/// <param name="console">The CLI console used for output, errors, and cancellation handling.</param>
|
||||
public async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
using var session = await OpcUaHelper.ConnectAsync(Url, Username, Password);
|
||||
using var session = await OpcUaHelper.ConnectAsync(Url, Username, Password, Security);
|
||||
|
||||
var nodeId = new NodeId(NodeId);
|
||||
var start = string.IsNullOrEmpty(StartTime) ? DateTime.UtcNow.AddHours(-24) : DateTime.Parse(StartTime).ToUniversalTime();
|
||||
|
||||
@@ -21,6 +21,9 @@ public class ReadCommand : ICommand
|
||||
[CommandOption("password", 'P', Description = "Password for authentication")]
|
||||
public string? Password { get; init; }
|
||||
|
||||
[CommandOption("security", 'S', Description = "Transport security: none, sign, encrypt (default: none)")]
|
||||
public string Security { get; init; } = "none";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the node identifier whose value should be read.
|
||||
/// </summary>
|
||||
@@ -33,7 +36,7 @@ public class ReadCommand : ICommand
|
||||
/// <param name="console">The console used to report the read result.</param>
|
||||
public async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
using var session = await OpcUaHelper.ConnectAsync(Url, Username, Password);
|
||||
using var session = await OpcUaHelper.ConnectAsync(Url, Username, Password, Security);
|
||||
|
||||
var node = new NodeId(NodeId);
|
||||
var value = await session.ReadValueAsync(node);
|
||||
|
||||
@@ -21,6 +21,9 @@ public class SubscribeCommand : ICommand
|
||||
[CommandOption("password", 'P', Description = "Password for authentication")]
|
||||
public string? Password { get; init; }
|
||||
|
||||
[CommandOption("security", 'S', Description = "Transport security: none, sign, encrypt (default: none)")]
|
||||
public string Security { get; init; } = "none";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the node identifier to monitor for value changes.
|
||||
/// </summary>
|
||||
@@ -39,7 +42,7 @@ public class SubscribeCommand : ICommand
|
||||
/// <param name="console">The console used to display subscription updates.</param>
|
||||
public async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
using var session = await OpcUaHelper.ConnectAsync(Url, Username, Password);
|
||||
using var session = await OpcUaHelper.ConnectAsync(Url, Username, Password, Security);
|
||||
|
||||
var subscription = new Subscription(session.DefaultSubscription)
|
||||
{
|
||||
|
||||
@@ -21,6 +21,9 @@ public class WriteCommand : ICommand
|
||||
[CommandOption("password", 'P', Description = "Password for authentication")]
|
||||
public string? Password { get; init; }
|
||||
|
||||
[CommandOption("security", 'S', Description = "Transport security: none, sign, encrypt (default: none)")]
|
||||
public string Security { get; init; } = "none";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the node identifier that should receive the write.
|
||||
/// </summary>
|
||||
@@ -39,7 +42,7 @@ public class WriteCommand : ICommand
|
||||
/// <param name="console">The console used to report the write result.</param>
|
||||
public async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
using var session = await OpcUaHelper.ConnectAsync(Url, Username, Password);
|
||||
using var session = await OpcUaHelper.ConnectAsync(Url, Username, Password, Security);
|
||||
|
||||
var node = new NodeId(NodeId);
|
||||
var current = await session.ReadValueAsync(node);
|
||||
|
||||
@@ -10,8 +10,12 @@ public static class OpcUaHelper
|
||||
/// Creates an OPC UA client session for the specified endpoint URL.
|
||||
/// </summary>
|
||||
/// <param name="endpointUrl">The OPC UA endpoint URL to connect to.</param>
|
||||
/// <param name="username">Optional username for authentication.</param>
|
||||
/// <param name="password">Optional password for authentication.</param>
|
||||
/// <param name="security">The requested transport security mode: "none", "sign", or "encrypt".</param>
|
||||
/// <returns>An active OPC UA client session.</returns>
|
||||
public static async Task<Session> ConnectAsync(string endpointUrl, string? username = null, string? password = null)
|
||||
public static async Task<Session> ConnectAsync(string endpointUrl, string? username = null, string? password = null,
|
||||
string security = "none")
|
||||
{
|
||||
var config = new ApplicationConfiguration
|
||||
{
|
||||
@@ -49,7 +53,28 @@ public static class OpcUaHelper
|
||||
await config.Validate(ApplicationType.Client);
|
||||
config.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||
|
||||
var endpoint = CoreClientUtils.SelectEndpoint(config, endpointUrl, false);
|
||||
var requestedMode = ParseSecurityMode(security);
|
||||
|
||||
EndpointDescription endpoint;
|
||||
if (requestedMode == MessageSecurityMode.None)
|
||||
{
|
||||
endpoint = CoreClientUtils.SelectEndpoint(config, endpointUrl, false);
|
||||
}
|
||||
else
|
||||
{
|
||||
// For secure connections, ensure the client has a certificate
|
||||
var app = new ApplicationInstance
|
||||
{
|
||||
ApplicationName = "OpcUaCli",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
ApplicationConfiguration = config
|
||||
};
|
||||
await app.CheckApplicationInstanceCertificatesAsync(false, 2048);
|
||||
|
||||
// Discover endpoints and pick the one matching the requested security mode
|
||||
endpoint = SelectSecureEndpoint(config, endpointUrl, requestedMode);
|
||||
}
|
||||
|
||||
var endpointConfig = EndpointConfiguration.Create(config);
|
||||
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfig);
|
||||
|
||||
@@ -70,6 +95,73 @@ public static class OpcUaHelper
|
||||
#pragma warning restore CS0618
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the security mode string from the CLI option.
|
||||
/// </summary>
|
||||
private static MessageSecurityMode ParseSecurityMode(string security)
|
||||
{
|
||||
return (security ?? "none").Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"none" => MessageSecurityMode.None,
|
||||
"sign" => MessageSecurityMode.Sign,
|
||||
"encrypt" or "signandencrypt" => MessageSecurityMode.SignAndEncrypt,
|
||||
_ => throw new ArgumentException(
|
||||
$"Unknown security mode '{security}'. Valid values: none, sign, encrypt")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discovers server endpoints and selects one matching the requested security mode,
|
||||
/// preferring Basic256Sha256 when multiple matches exist.
|
||||
/// </summary>
|
||||
private static EndpointDescription SelectSecureEndpoint(ApplicationConfiguration config,
|
||||
string endpointUrl, MessageSecurityMode requestedMode)
|
||||
{
|
||||
// Use discovery to get all endpoints
|
||||
using var client = DiscoveryClient.Create(new Uri(endpointUrl));
|
||||
var allEndpoints = client.GetEndpoints(null);
|
||||
|
||||
EndpointDescription? best = null;
|
||||
|
||||
foreach (var ep in allEndpoints)
|
||||
{
|
||||
if (ep.SecurityMode != requestedMode)
|
||||
continue;
|
||||
|
||||
if (best == null)
|
||||
{
|
||||
best = ep;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Prefer Basic256Sha256
|
||||
if (ep.SecurityPolicyUri == SecurityPolicies.Basic256Sha256)
|
||||
best = ep;
|
||||
}
|
||||
|
||||
if (best == null)
|
||||
{
|
||||
var available = string.Join(", ", allEndpoints.Select(e => $"{e.SecurityMode}/{e.SecurityPolicyUri}"));
|
||||
throw new InvalidOperationException(
|
||||
$"No endpoint found with security mode '{requestedMode}'. Available endpoints: {available}");
|
||||
}
|
||||
|
||||
// Rewrite endpoint URL to use the user-supplied hostname instead of the server's
|
||||
// internal address (e.g., 0.0.0.0 -> localhost) to handle NAT/hostname differences
|
||||
var serverUri = new Uri(best.EndpointUrl);
|
||||
var requestedUri = new Uri(endpointUrl);
|
||||
if (serverUri.Host != requestedUri.Host)
|
||||
{
|
||||
var builder = new UriBuilder(best.EndpointUrl)
|
||||
{
|
||||
Host = requestedUri.Host
|
||||
};
|
||||
best.EndpointUrl = builder.ToString();
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a raw command-line string into the runtime type expected by the target node.
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user