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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user