diff --git a/CLAUDE.md b/CLAUDE.md index 3f9b3b5..eacd9f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index 4672ecf..ec7af5b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/CliTool.md b/docs/CliTool.md index 01c3604..77bfdb1 100644 --- a/docs/CliTool.md +++ b/docs/CliTool.md @@ -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 diff --git a/docs/Configuration.md b/docs/Configuration.md index abda467..f2fd2f4 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -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` 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` | `["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 } } ``` diff --git a/docs/OpcUaServer.md b/docs/OpcUaServer.md index 8811e6d..1def92d 100644 --- a/docs/OpcUaServer.md +++ b/docs/OpcUaServer.md @@ -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 diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..50074b2 --- /dev/null +++ b/docs/security.md @@ -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). diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/AppConfiguration.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/AppConfiguration.cs index 8054c1f..3beed6d 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/AppConfiguration.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/AppConfiguration.cs @@ -34,5 +34,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration /// Gets or sets the authentication and role-based access control settings. /// public AuthenticationConfiguration Authentication { get; set; } = new AuthenticationConfiguration(); + + /// + /// Gets or sets the transport security settings that control which OPC UA security profiles are exposed. + /// + public SecurityProfileConfiguration Security { get; set; } = new SecurityProfileConfiguration(); } } diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/ConfigurationValidator.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/ConfigurationValidator.cs index 7991591..eea803b 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/ConfigurationValidator.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/ConfigurationValidator.cs @@ -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; } diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/OpcUaConfiguration.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/OpcUaConfiguration.cs index 64f7363..fa01aac 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/OpcUaConfiguration.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/OpcUaConfiguration.cs @@ -5,6 +5,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration /// public class OpcUaConfiguration { + /// + /// Gets or sets the IP address or hostname the OPC UA server binds to. + /// Defaults to 0.0.0.0 (all interfaces). Set to a specific IP or hostname to restrict listening. + /// + public string BindAddress { get; set; } = "0.0.0.0"; + /// /// Gets or sets the TCP port on which the OPC UA server listens for client sessions. /// diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/SecurityProfileConfiguration.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/SecurityProfileConfiguration.cs new file mode 100644 index 0000000..a841ed2 --- /dev/null +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/SecurityProfileConfiguration.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; + +namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration +{ + /// + /// Transport security settings that control which OPC UA security profiles the server exposes and how client certificates are handled. + /// + public class SecurityProfileConfiguration + { + /// + /// 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. + /// + public List Profiles { get; set; } = new List { "None" }; + + /// + /// Gets or sets a value indicating whether the server automatically accepts client certificates + /// that are not in the trusted store. Should be in production. + /// + public bool AutoAcceptClientCertificates { get; set; } = true; + + /// + /// Gets or sets a value indicating whether client certificates signed with SHA-1 are rejected. + /// + public bool RejectSHA1Certificates { get; set; } = true; + + /// + /// Gets or sets the minimum RSA key size required for client certificates. + /// + public int MinimumCertificateKeySize { get; set; } = 2048; + + /// + /// Gets or sets an optional override for the PKI root directory. + /// When , defaults to %LOCALAPPDATA%\OPC Foundation\pki. + /// + public string? PkiRootPath { get; set; } + + /// + /// Gets or sets an optional override for the server certificate subject name. + /// When , defaults to CN={ServerName}, O=ZB MOM, DC=localhost. + /// + public string? CertificateSubject { get; set; } + } +} diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/OpcUaServerHost.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/OpcUaServerHost.cs index 36b8d8c..42b0325 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/OpcUaServerHost.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/OpcUaServerHost.cs @@ -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(); } /// @@ -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); + } } /// diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/SecurityProfileResolver.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/SecurityProfileResolver.cs new file mode 100644 index 0000000..c4a1133 --- /dev/null +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/SecurityProfileResolver.cs @@ -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 +{ + /// + /// Maps configured security profile names to OPC UA instances. + /// + public static class SecurityProfileResolver + { + private static readonly ILogger Log = Serilog.Log.ForContext(typeof(SecurityProfileResolver)); + + private static readonly Dictionary KnownProfiles = + new Dictionary(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 + } + }; + + /// + /// Resolves the configured profile names to entries. + /// Unknown names are skipped with a warning. An empty or fully-invalid list falls back to None. + /// + /// The profile names from configuration. + /// A deduplicated list of server security policies. + public static List Resolve(IReadOnlyCollection profileNames) + { + var resolved = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var name in profileNames ?? Array.Empty()) + { + 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; + } + + /// + /// Gets the list of valid profile names for validation and documentation. + /// + public static IReadOnlyCollection ValidProfileNames => KnownProfiles.Keys.ToList().AsReadOnly(); + } +} diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs index cc4dfec..9d43ab3 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs @@ -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; diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaServiceBuilder.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaServiceBuilder.cs index 6fbd56a..5be4d29 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaServiceBuilder.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaServiceBuilder.cs @@ -122,6 +122,17 @@ namespace ZB.MOM.WW.LmxOpcUa.Host return this; } + /// + /// Sets the security profile configuration for the test host. + /// + /// The security profile configuration to inject. + /// The current builder so additional overrides can be chained. + public OpcUaServiceBuilder WithSecurity(SecurityProfileConfiguration security) + { + _config.Security = security; + return this; + } + /// /// Effectively disables Galaxy change detection by pushing the polling interval beyond realistic test durations. /// diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/appsettings.json b/src/ZB.MOM.WW.LmxOpcUa.Host/appsettings.json index 9cbd700..a3da7ae 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/appsettings.json +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/appsettings.json @@ -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;", diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Configuration/ConfigurationLoadingTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Configuration/ConfigurationLoadingTests.cs index 8dff46a..167294c 100644 --- a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Configuration/ConfigurationLoadingTests.cs +++ b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Configuration/ConfigurationLoadingTests.cs @@ -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); } + /// + /// Confirms that BindAddress can be overridden to a specific hostname or IP. + /// + [Fact] + public void OpcUa_BindAddress_CanBeOverridden() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new[] + { + new System.Collections.Generic.KeyValuePair("OpcUa:BindAddress", "localhost"), + }) + .Build(); + + var config = new OpcUaConfiguration(); + configuration.GetSection("OpcUa").Bind(config); + config.BindAddress.ShouldBe("localhost"); + } + /// /// Confirms that a valid configuration passes startup validation. /// @@ -154,5 +175,66 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Configuration config.OpcUa.GalaxyName = ""; ConfigurationValidator.ValidateAndLog(config).ShouldBe(false); } + + /// + /// Confirms that the Security section binds profile list from appsettings.json. + /// + [Fact] + public void Security_Section_BindsProfilesCorrectly() + { + var config = LoadFromJson(); + config.Security.Profiles.ShouldContain("None"); + config.Security.AutoAcceptClientCertificates.ShouldBe(true); + config.Security.MinimumCertificateKeySize.ShouldBe(2048); + } + + /// + /// Confirms that a minimum key size below 2048 is rejected by the validator. + /// + [Fact] + public void Validator_InvalidMinKeySize_ReturnsFalse() + { + var config = new AppConfiguration(); + config.Security.MinimumCertificateKeySize = 1024; + ConfigurationValidator.ValidateAndLog(config).ShouldBe(false); + } + + /// + /// Confirms that a valid configuration with security defaults passes validation. + /// + [Fact] + public void Validator_DefaultSecurityConfig_ReturnsTrue() + { + var config = LoadFromJson(); + ConfigurationValidator.ValidateAndLog(config).ShouldBe(true); + } + + /// + /// Confirms that custom security profiles can be bound from in-memory configuration. + /// + [Fact] + public void Security_Section_BindsCustomProfiles() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new[] + { + new System.Collections.Generic.KeyValuePair("Security:Profiles:0", "None"), + new System.Collections.Generic.KeyValuePair("Security:Profiles:1", "Basic256Sha256-SignAndEncrypt"), + new System.Collections.Generic.KeyValuePair("Security:AutoAcceptClientCertificates", "false"), + new System.Collections.Generic.KeyValuePair("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); + } } } diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaServerFixture.cs b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaServerFixture.cs index c2a0a35..f115a51 100644 --- a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaServerFixture.cs +++ b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaServerFixture.cs @@ -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 /// /// An optional fake MXAccess client to inject; otherwise a default fake is created. /// An optional fake repository to inject; otherwise standard test data is used. + /// An optional security profile configuration for the test server. /// A fixture configured to exercise the direct fake-client path. 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); } diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaTestClient.cs b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaTestClient.cs index c873b33..c8c846d 100644 --- a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaTestClient.cs +++ b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaTestClient.cs @@ -49,7 +49,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers /// Connects the helper to an OPC UA endpoint exposed by the test bridge. /// /// The OPC UA endpoint URL to connect to. - public async Task ConnectAsync(string endpointUrl) + /// The requested message security mode (default: None). + /// Optional username for authenticated connections. + /// Optional password for authenticated connections. + 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}"); } /// diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Security/SecurityProfileConfigurationTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Security/SecurityProfileConfigurationTests.cs new file mode 100644 index 0000000..f08764e --- /dev/null +++ b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Security/SecurityProfileConfigurationTests.cs @@ -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(); + } + } +} diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Security/SecurityProfileResolverTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Security/SecurityProfileResolverTests.cs new file mode 100644 index 0000000..4d965c2 --- /dev/null +++ b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Security/SecurityProfileResolverTests.cs @@ -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 { "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 { "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 { "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 + { + "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 + { + "None", "None", "Basic256Sha256-Sign", "Basic256Sha256-Sign" + }); + + result.Count.ShouldBe(2); + } + + [Fact] + public void Resolve_UnknownProfile_SkippedWithWarning() + { + var result = SecurityProfileResolver.Resolve(new List + { + "None", "SomeUnknownProfile" + }); + + result.Count.ShouldBe(1); + result[0].SecurityMode.ShouldBe(MessageSecurityMode.None); + } + + [Fact] + public void Resolve_EmptyList_FallsBackToNone() + { + var result = SecurityProfileResolver.Resolve(new List()); + + 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 { "Bogus", "AlsoBogus" }); + + result.Count.ShouldBe(1); + result[0].SecurityMode.ShouldBe(MessageSecurityMode.None); + } + + [Fact] + public void Resolve_CaseInsensitive() + { + var result = SecurityProfileResolver.Resolve(new List { "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 { "", " ", "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); + } + } +} diff --git a/tools/opcuacli-dotnet/Commands/AlarmsCommand.cs b/tools/opcuacli-dotnet/Commands/AlarmsCommand.cs index a7e3893..6a6226f 100644 --- a/tools/opcuacli-dotnet/Commands/AlarmsCommand.cs +++ b/tools/opcuacli-dotnet/Commands/AlarmsCommand.cs @@ -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"; + /// /// Gets the node to subscribe to for event notifications, typically a source object or the server node. /// @@ -45,7 +48,7 @@ public class AlarmsCommand : ICommand /// The CLI console used for cancellation and alarm-event output. 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 diff --git a/tools/opcuacli-dotnet/Commands/BrowseCommand.cs b/tools/opcuacli-dotnet/Commands/BrowseCommand.cs index 9d416c7..7433eef 100644 --- a/tools/opcuacli-dotnet/Commands/BrowseCommand.cs +++ b/tools/opcuacli-dotnet/Commands/BrowseCommand.cs @@ -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"; + /// /// Gets the optional node identifier to browse from; defaults to the OPC UA Objects folder. /// @@ -45,7 +48,7 @@ public class BrowseCommand : ICommand /// The console used to emit browse output. 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 diff --git a/tools/opcuacli-dotnet/Commands/ConnectCommand.cs b/tools/opcuacli-dotnet/Commands/ConnectCommand.cs index 9e761da..e885ad2 100644 --- a/tools/opcuacli-dotnet/Commands/ConnectCommand.cs +++ b/tools/opcuacli-dotnet/Commands/ConnectCommand.cs @@ -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"; + /// /// Connects to the OPC UA endpoint and prints the resolved server metadata. /// /// The console used to report connection results. 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}"); diff --git a/tools/opcuacli-dotnet/Commands/HistoryReadCommand.cs b/tools/opcuacli-dotnet/Commands/HistoryReadCommand.cs index e3abb21..f7dd755 100644 --- a/tools/opcuacli-dotnet/Commands/HistoryReadCommand.cs +++ b/tools/opcuacli-dotnet/Commands/HistoryReadCommand.cs @@ -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"; + /// /// Gets the node identifier for the historized variable to query. /// @@ -63,7 +66,7 @@ public class HistoryReadCommand : ICommand /// The CLI console used for output, errors, and cancellation handling. 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(); diff --git a/tools/opcuacli-dotnet/Commands/ReadCommand.cs b/tools/opcuacli-dotnet/Commands/ReadCommand.cs index 6ce69b7..8c2e16e 100644 --- a/tools/opcuacli-dotnet/Commands/ReadCommand.cs +++ b/tools/opcuacli-dotnet/Commands/ReadCommand.cs @@ -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"; + /// /// Gets the node identifier whose value should be read. /// @@ -33,7 +36,7 @@ public class ReadCommand : ICommand /// The console used to report the read result. 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); diff --git a/tools/opcuacli-dotnet/Commands/SubscribeCommand.cs b/tools/opcuacli-dotnet/Commands/SubscribeCommand.cs index c8e66ac..c467b3a 100644 --- a/tools/opcuacli-dotnet/Commands/SubscribeCommand.cs +++ b/tools/opcuacli-dotnet/Commands/SubscribeCommand.cs @@ -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"; + /// /// Gets the node identifier to monitor for value changes. /// @@ -39,7 +42,7 @@ public class SubscribeCommand : ICommand /// The console used to display subscription updates. 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) { diff --git a/tools/opcuacli-dotnet/Commands/WriteCommand.cs b/tools/opcuacli-dotnet/Commands/WriteCommand.cs index ce7eac3..d3bc24d 100644 --- a/tools/opcuacli-dotnet/Commands/WriteCommand.cs +++ b/tools/opcuacli-dotnet/Commands/WriteCommand.cs @@ -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"; + /// /// Gets the node identifier that should receive the write. /// @@ -39,7 +42,7 @@ public class WriteCommand : ICommand /// The console used to report the write result. 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); diff --git a/tools/opcuacli-dotnet/OpcUaHelper.cs b/tools/opcuacli-dotnet/OpcUaHelper.cs index e90bd7b..1c24afb 100644 --- a/tools/opcuacli-dotnet/OpcUaHelper.cs +++ b/tools/opcuacli-dotnet/OpcUaHelper.cs @@ -10,8 +10,12 @@ public static class OpcUaHelper /// Creates an OPC UA client session for the specified endpoint URL. /// /// The OPC UA endpoint URL to connect to. + /// Optional username for authentication. + /// Optional password for authentication. + /// The requested transport security mode: "none", "sign", or "encrypt". /// An active OPC UA client session. - public static async Task ConnectAsync(string endpointUrl, string? username = null, string? password = null) + public static async Task 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 } + /// + /// Parses the security mode string from the CLI option. + /// + 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") + }; + } + + /// + /// Discovers server endpoints and selects one matching the requested security mode, + /// preferring Basic256Sha256 when multiple matches exist. + /// + 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; + } + /// /// Converts a raw command-line string into the runtime type expected by the target node. ///