mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-18 14:43:08 +08:00
fbdd0af13b
# Description
Adds an interop test suite between this stack (2.0) and the released
**1.5.378** NuGet stack, in both directions, and fixes a 2.0 bug the
suite found.
## Interop suite
- **`tests/Opc.Ua.Interop.LegacyPeer`** is a console peer built from the
`OPCFoundation.NetStandard.Opc.Ua.*` 1.5.378 NuGet packages.
- It runs as a child process, because the two stacks share assembly
names.
- It deliberately does not import the repository build settings (its own
`Directory.Build.props/targets/Packages.props`).
- Server mode hosts a small interop server with tight limits, or the 1.5
Quickstarts reference server.
- Client mode runs named checks and prints one JSON result line per
check.
- **`tests/Opc.Ua.Interop.Tests`** builds the peer into `legacy-peer/`
and runs in every CI test leg as a normal `*.Tests.csproj`. It covers:
- sessions over None, the RSA policies and the ECC policies (Sign and
SignAndEncrypt), with anonymous and user name logon, plus
read/write/browse/call/subscribe;
- the existing 2.0 `ClientTest` workers run against the 1.5 reference
server, through the new `ClientTestFramework.ExternalServerUrl`;
- custom data types (structures, unions, optional fields, enums) loaded,
decoded and written back by each side's complex type system;
- certificate trust with auto-accept off: untrusted peers, CA-issued
certificates, revoked certificates;
- multi-chunk messages, encoding limits, response size limits, operation
limits, continuation points and security token renewal (RSA and ECC).
- Every peer check is reported as its own test result.
- `-p:LegacyStackVersion=<version>` tests another 1.5 release.
- Docs: `docs/DeveloperGuide.md`.
## 2.0 fix
**Unset optional security attributes now return
`Bad_AttributeIdInvalid`** (`NodeState.ReadNonValueAttribute`).
- Since 3e5f129c6, unset
RolePermissions/UserRolePermissions/AccessRestrictions returned Good
with a null value.
- This made the 1.5 ComplexTypes loader crash against any 2.0 server.
- 1.5 and Part 4 (Read) return `Bad_AttributeIdInvalid` here.
The suite also found two ECC secure-channel bugs: the Sign-only HKDF
salt length, and renewal chaining the previous secret for policies
without SecureChannelEnhancements. #4626 fixed both on master in the
meantime, so this branch now uses master's version. The ECC Sign and ECC
token-renewal interop tests here cover them.
## Reviewer notes
- **Behaviour change:** `NodeStateOptionalStorageTests` previously
asserted Good with a null value for unset optional attributes. It now
asserts `BadAttributeIdInvalid`.
- Monitored items on such unset attributes now fail with
`BadAttributeIdInvalid`, as in 1.5.
- **Known gaps, not changed here:**
- An unset Description still reads Good with a null value (same root
cause).
- A Null write to AccessRestrictions is accepted, as `NodeStateTests`
asserts.
- **Test runtime:** the suite adds roughly 7 minutes per CI test leg. On
net48 the ECC cases skip, because 2.0 has no ECC on .NET Framework.
## Related Issues
- None filed.
## Checklist
- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- Ran: interop suite on net10 (188 passed) and on net48 (passed before
the final review fixes), plus Types, Core, InformationModel,
Core.Security and Sessions on net10.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
236 lines
9.0 KiB
C#
236 lines
9.0 KiB
C#
/* ========================================================================
|
|
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
|
|
*
|
|
* OPC Foundation MIT License 1.00
|
|
*
|
|
* Permission is hereby granted, free of charge, to any person
|
|
* obtaining a copy of this software and associated documentation
|
|
* files (the "Software"), to deal in the Software without
|
|
* restriction, including without limitation the rights to use,
|
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
* copies of the Software, and to permit persons to whom the
|
|
* Software is furnished to do so, subject to the following
|
|
* conditions:
|
|
*
|
|
* The above copyright notice and this permission notice shall be
|
|
* included in all copies or substantial portions of the Software.
|
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
|
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
* OTHER DEALINGS IN THE SOFTWARE.
|
|
*
|
|
* The complete license agreement can be found here:
|
|
* http://opcfoundation.org/License/MIT/1.00/
|
|
* ======================================================================*/
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Threading.Tasks;
|
|
|
|
namespace Opc.Ua.Interop.LegacyPeer
|
|
{
|
|
/// <summary>
|
|
/// Entry point of the 1.5.x interop peer.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <code>
|
|
/// server --port <port> --pki <dir> [--kind interop|reference]
|
|
/// [--autoaccept true|false] [--ecc true|false] [--init-only true]
|
|
/// Starts a 1.5.x server, prints "LEGACY-SERVER-READY <url>" and
|
|
/// runs until standard input is closed or a line "stop" is read.
|
|
/// 'interop' (default) is a small server with a known address space;
|
|
/// 'reference' is the 1.5.x Quickstarts reference server with all of
|
|
/// its default node managers.
|
|
/// client --url <url> --pki <dir> [--policy <uri>] [--mode <mode>]
|
|
/// [--user <name> --password <password>] [--checks a,b,c]
|
|
/// [--autoaccept true|false] [--ecc true|false]
|
|
/// [--expect-connect-error <StatusCode name,...>]
|
|
/// [--token-lifetime <ms>] [--token-test-seconds <s>]
|
|
/// [--timeout-seconds <s>]
|
|
/// Runs the client checks against a server that exposes the
|
|
/// Quickstarts reference server address space. Prints one
|
|
/// "RESULT {json}" line per check and exits with 0 when every
|
|
/// check passed.
|
|
/// With --init-only true, server and client create their certificates
|
|
/// and stores, print "LEGACY-PKI-READY <application uri>" and exit, so a test can
|
|
/// set up trust lists before the peer connects.
|
|
/// </code>
|
|
/// </remarks>
|
|
public static class Program
|
|
{
|
|
public const int ExitSuccess = 0;
|
|
public const int ExitChecksFailed = 1;
|
|
public const int ExitUsage = 2;
|
|
public const int ExitFatal = 3;
|
|
|
|
public static async Task<int> Main(string[] args)
|
|
{
|
|
if (args.Length == 0)
|
|
{
|
|
return Usage("missing command");
|
|
}
|
|
|
|
PeerOptions options;
|
|
try
|
|
{
|
|
options = PeerOptions.Parse(args);
|
|
}
|
|
catch (ArgumentException e)
|
|
{
|
|
return Usage(e.Message);
|
|
}
|
|
|
|
try
|
|
{
|
|
switch (args[0])
|
|
{
|
|
case "server":
|
|
return await LegacyServerHost.RunAsync(options).ConfigureAwait(false);
|
|
case "client":
|
|
return await LegacyClientChecks.RunAsync(options).ConfigureAwait(false);
|
|
default:
|
|
return Usage("unknown command " + args[0]);
|
|
}
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
Console.Error.WriteLine("FATAL " + e);
|
|
return ExitFatal;
|
|
}
|
|
}
|
|
|
|
private static int Usage(string error)
|
|
{
|
|
Console.Error.WriteLine("error: " + error);
|
|
Console.Error.WriteLine(
|
|
"usage: server --port <port> --pki <dir> [--kind interop|reference] " +
|
|
"[--autoaccept true|false] [--ecc true|false]");
|
|
Console.Error.WriteLine(
|
|
" client --url <url> --pki <dir> [--policy <uri>] [--mode <mode>] " +
|
|
"[--user <name> --password <password>] [--checks a,b,c] [--autoaccept true|false] " +
|
|
"[--ecc true|false] [--expect-connect-error <code>] [--token-lifetime <ms>] " +
|
|
"[--token-test-seconds <s>]");
|
|
return ExitUsage;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The --name value options of the peer.
|
|
/// </summary>
|
|
public sealed class PeerOptions
|
|
{
|
|
private readonly Dictionary<string, string> m_options;
|
|
|
|
private PeerOptions(Dictionary<string, string> options)
|
|
{
|
|
m_options = options;
|
|
}
|
|
|
|
public static PeerOptions Parse(string[] args)
|
|
{
|
|
var options = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
for (int ii = 1; ii < args.Length; ii++)
|
|
{
|
|
string name = args[ii];
|
|
if (!name.StartsWith("--", StringComparison.Ordinal) || ii + 1 >= args.Length)
|
|
{
|
|
throw new ArgumentException("expected --name value, got " + name);
|
|
}
|
|
options[name.Substring(2)] = args[++ii];
|
|
}
|
|
return new PeerOptions(options);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The PKI root of the peer.
|
|
/// </summary>
|
|
public string PkiRoot => Get("pki");
|
|
|
|
/// <summary>
|
|
/// Whether untrusted peer certificates are accepted (default true).
|
|
/// </summary>
|
|
public bool AutoAccept => GetBool("autoaccept", true);
|
|
|
|
/// <summary>
|
|
/// Whether the peer uses ECC application certificates and policies in
|
|
/// addition to RSA (default false).
|
|
/// </summary>
|
|
public bool Ecc => GetBool("ecc", false);
|
|
|
|
public string Get(string name, string defaultValue = null)
|
|
{
|
|
if (m_options.TryGetValue(name, out string value))
|
|
{
|
|
return value;
|
|
}
|
|
return defaultValue ?? throw new ArgumentException("missing --" + name);
|
|
}
|
|
|
|
public bool GetBool(string name, bool defaultValue)
|
|
{
|
|
return m_options.TryGetValue(name, out string value)
|
|
? bool.Parse(value)
|
|
: defaultValue;
|
|
}
|
|
|
|
public int GetInt(string name, int defaultValue)
|
|
{
|
|
return m_options.TryGetValue(name, out string value)
|
|
? int.Parse(value, CultureInfo.InvariantCulture)
|
|
: defaultValue;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Certificate configuration of the legacy peer. The stores live below
|
|
/// the PKI root in the default 1.5 layout: own, issuer, trusted and
|
|
/// rejected, each with certs, private and crl folders. The tests rely
|
|
/// on this layout to provision certificates and trust lists before the
|
|
/// peer starts.
|
|
/// </summary>
|
|
public static class LegacyPki
|
|
{
|
|
/// <summary>
|
|
/// The application certificates: RSA only, or RSA and the ECC curves
|
|
/// 1.5.x supports.
|
|
/// </summary>
|
|
public static CertificateIdentifierCollection ApplicationCertificates(
|
|
string subjectName,
|
|
string pkiRoot,
|
|
bool ecc)
|
|
{
|
|
if (ecc)
|
|
{
|
|
// The default set puts the certificates into the PKI root
|
|
// itself; keep them in 'own' like the RSA-only set.
|
|
CertificateIdentifierCollection certificates =
|
|
Configuration.ApplicationConfigurationBuilder.CreateDefaultApplicationCertificates(
|
|
subjectName,
|
|
CertificateStoreType.Directory,
|
|
pkiRoot);
|
|
foreach (CertificateIdentifier certificate in certificates)
|
|
{
|
|
certificate.StorePath = Path.Combine(pkiRoot, "own");
|
|
}
|
|
return certificates;
|
|
}
|
|
return new CertificateIdentifierCollection
|
|
{
|
|
new CertificateIdentifier
|
|
{
|
|
StoreType = CertificateStoreType.Directory,
|
|
StorePath = Path.Combine(pkiRoot, "own"),
|
|
SubjectName = subjectName,
|
|
CertificateType = ObjectTypeIds.RsaSha256ApplicationCertificateType
|
|
}
|
|
};
|
|
}
|
|
}
|
|
}
|