Files
Julius Pfrommer a476806541 Merge remote-tracking branch 'origin/1.5' into merge_15_master
# Conflicts:
#	.github/workflows/build_linux.yml
#	CHANGES.md
#	arch/common/eventloop_mqtt.c
#	examples/encryption/server_encryption_filestore.c
#	plugins/crypto/ua_certificategroup_filestore.c
#	src/pubsub/ua_pubsub_networkmessage_json.c
#	src/server/ua_discovery.h
#	src/server/ua_discovery_mdns.c
#	src/server/ua_discovery_mdns_avahi.c
#	src/server/ua_services_attribute.c
#	src/server/ua_services_discovery.c
#	src/server/ua_services_monitoreditem.c
#	src/server/ua_services_nodemanagement.c
#	src/server/ua_subscription.c
#	src/server/ua_subscription.h
#	src/server/ua_subscription_alarms_conditions.c
#	src/server/ua_subscription_event.c
#	src/ua_types_encoding_json_105.c
#	tests/client/check_activateSessionAsync.c
#	tests/server/check_local_monitored_item.c
#	tests/server/check_services_subscriptions.c
2026-09-03 14:33:21 +02:00

539 lines
18 KiB
JavaScript

/**
* node-opcua interoperability test client.
*
* Runs the T-1 through T-9 test suite against any OPC UA server.
* Mirrors the checks performed by the other SDK interop clients
* (see tests/interop/README.md).
*
* Usage: node client.mjs <server_url> <client_cert.pem> <client_key.pem> <server_cert.pem>
* server_url - Endpoint URL (e.g. opc.tcp://localhost:4840)
* client_cert.pem - Client certificate (PEM)
* client_key.pem - Client private key (PEM)
* server_cert.pem - Server certificate (PEM) for trust
*/
import {
OPCUAClient,
SecurityPolicy,
MessageSecurityMode,
UserTokenType,
OPCUACertificateManager,
AttributeIds,
DataType,
Variant,
StatusCodes,
} from "node-opcua";
import { readCertificate } from "node-opcua-crypto";
import { readFileSync, mkdtempSync, mkdirSync, cpSync } from "fs";
import { X509Certificate } from "crypto";
import { tmpdir } from "os";
import { join } from "path";
import { pathToFileURL } from "url";
// ---------------------------------------------------------------------------
// Parse arguments
// ---------------------------------------------------------------------------
const [serverUrl, clientCertFile, clientKeyFile, serverCertFile] =
process.argv.slice(2);
if (!serverUrl || !clientCertFile || !clientKeyFile || !serverCertFile) {
console.error(
"Usage: node client.mjs <server_url> <client_cert.pem> <client_key.pem> <server_cert.pem>"
);
process.exit(1);
}
// ---------------------------------------------------------------------------
// Test counters
// ---------------------------------------------------------------------------
let passed = 0;
let failed = 0;
let skipped = 0;
let clientTransportFactory;
function PASS(name) {
console.log(` PASS: ${name}`);
passed++;
}
function FAIL(name, err) {
console.error(` FAIL: ${name}: ${err}`);
failed++;
}
function SKIP(name, reason) {
console.log(` SKIP: ${name}: ${reason}`);
skipped++;
}
// ---------------------------------------------------------------------------
// PKI setup — temporary directory with auto-accept
// ---------------------------------------------------------------------------
const pkiDir = mkdtempSync(join(tmpdir(), "nodeopcua-client-pki-"));
mkdirSync(join(pkiDir, "trusted", "certs"), { recursive: true });
mkdirSync(join(pkiDir, "rejected", "certs"), { recursive: true });
mkdirSync(join(pkiDir, "own", "certs"), { recursive: true });
mkdirSync(join(pkiDir, "own", "private"), { recursive: true });
// Trust the server certificate
cpSync(serverCertFile, join(pkiDir, "trusted", "certs", "server.cert.pem"));
const clientCertificateManager = new OPCUACertificateManager({
rootFolder: pkiDir,
automaticallyAcceptUnknownCertificate: true,
});
let certManagerInitialized = false;
async function ensureCertManager() {
if (!certManagerInitialized) {
await clientCertificateManager.initialize();
certManagerInitialized = true;
}
}
// ---------------------------------------------------------------------------
// Client factory
// ---------------------------------------------------------------------------
// Extract the OPC UA Application URI from a PEM certificate's subjectAltName.
// OPC UA requires the client to ANNOUNCE the same URI embedded in its cert;
// if the two differ the server sends BadCertificateUriInvalid and drops the
// connection before any session is established.
function getAppUri(certFile) {
try {
const pem = readFileSync(certFile, "utf8");
const cert = new X509Certificate(pem);
const match = cert.subjectAltName?.match(/URI:([^,\s]+)/);
return match?.[1] ?? undefined;
} catch {
return undefined;
}
}
async function createClient(securityPolicy, securityMode) {
await ensureCertManager();
const opts = {
securityPolicy,
securityMode,
endpointMustExist: false,
clientCertificateManager,
};
if (clientTransportFactory) {
opts.transportFactory = clientTransportFactory;
}
if (securityPolicy !== SecurityPolicy.None) {
opts.certificateFile = clientCertFile;
opts.privateKeyFile = clientKeyFile;
// node-opcua's temporary endpoint-discovery client does not inherit a
// custom transportFactory. Supplying the expected certificate avoids
// that TCP-only discovery connection for secure WebSocket endpoints.
if (clientTransportFactory)
opts.serverCertificate = readCertificate(serverCertFile);
// Set applicationUri to match the certificate's SAN URI so the server
// doesn't reject the connection with BadCertificateUriInvalid.
const appUri = getAppUri(clientCertFile);
if (appUri) opts.applicationUri = appUri;
}
return OPCUAClient.create(opts);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function isSkippableSecurityError(msg) {
return (
msg.includes("BadSecurityPolicyRejected") ||
msg.includes("invalid securityPolicy") ||
msg.includes("Cannot find an Endpoint matching") ||
msg.includes("invalid securityPolicy") ||
msg.includes("is not supported") ||
msg.includes("getEndpoints")
);
}
function isSkippableTrustError(msg) {
return (
msg.includes("BadCertificateUntrusted") ||
msg.includes("BadCertificateInvalid") ||
msg.includes("BadCertificatePolicyCheckFailed") ||
msg.includes("BadSecurityChecksFailed")
);
}
function isSkippableIdentityError(msg) {
return (
msg.includes("BadIdentityTokenRejected") ||
msg.includes("BadIdentityTokenInvalid") ||
msg.includes("BadUserAccessDenied")
);
}
// ---------------------------------------------------------------------------
// T-1: Anonymous Connection (No Security)
// ---------------------------------------------------------------------------
async function t1() {
const name = "T-1: Anonymous Connection (No Security)";
try {
const client = await createClient(
SecurityPolicy.None,
MessageSecurityMode.None
);
await client.connect(serverUrl);
const session = await client.createSession();
// Read ServerStatus State (i=2259)
const result = await session.read({
nodeId: "i=2259",
attributeId: AttributeIds.Value,
});
if (!result.statusCode.isGood()) {
throw new Error(`ServerStatus read failed: ${result.statusCode}`);
}
await session.close();
await client.disconnect();
PASS(name);
} catch (err) {
FAIL(name, err.message);
}
}
// ---------------------------------------------------------------------------
// T-2: Username/Password (No Security)
// ---------------------------------------------------------------------------
async function t2() {
const name = "T-2: Username/Password (No Security)";
try {
const client = await createClient(
SecurityPolicy.None,
MessageSecurityMode.None
);
await client.connect(serverUrl);
const session = await client.createSession({
type: UserTokenType.UserName,
userName: "user1",
password: "password",
});
await session.close();
await client.disconnect();
PASS(name);
} catch (err) {
// T-2 is ambiguous: both accepting and rejecting username/password over
// an unencrypted channel are valid server behaviours. Some servers
// require the password to be encrypted with a specific security policy
// (e.g. Aes256_Sha256_RsaPss) even over a None channel; others simply
// reject credentials entirely. Either way, treat every error here as
// a graceful SKIP rather than a FAIL.
const msg = err.message || String(err);
SKIP(name, `Server rejected or could not process credentials: ${msg.slice(0, 100)}`);
}
}
// ---------------------------------------------------------------------------
// T-3: Read Custom Variable (ns=1;s=the.answer = 43)
// ---------------------------------------------------------------------------
async function t3() {
const name = "T-3: Read Custom Variable (the.answer = 43)";
try {
const client = await createClient(
SecurityPolicy.None,
MessageSecurityMode.None
);
await client.connect(serverUrl);
const session = await client.createSession();
const result = await session.read({
nodeId: "ns=1;s=the.answer",
attributeId: AttributeIds.Value,
});
if (result.statusCode.equals(StatusCodes.BadNodeIdUnknown)) {
await session.close();
await client.disconnect();
SKIP(name, "Node ns=1;s=the.answer not found");
return;
}
if (!result.statusCode.isGood()) {
throw new Error(`Read failed: ${result.statusCode}`);
}
const val = result.value.value;
if (val !== 43) {
throw new Error(`Expected 43, got ${val}`);
}
await session.close();
await client.disconnect();
PASS(name);
} catch (err) {
FAIL(name, err.message);
}
}
// ---------------------------------------------------------------------------
// T-4: Call HelloWorld Method
// ---------------------------------------------------------------------------
async function t4() {
const name = "T-4: Call HelloWorld Method";
try {
const client = await createClient(
SecurityPolicy.None,
MessageSecurityMode.None
);
await client.connect(serverUrl);
const session = await client.createSession();
let result;
try {
result = await session.call({
objectId: "ns=1;i=1000",
methodId: "ns=1;i=62541",
inputArguments: [
new Variant({ dataType: DataType.String, value: "World" }),
],
});
} catch (callErr) {
const msg = callErr.message || String(callErr);
if (msg.includes("BadNodeIdUnknown") || msg.includes("BadMethodInvalid")) {
await session.close();
await client.disconnect();
SKIP(name, "Method ns=1;i=62541 not found");
return;
}
throw callErr;
}
if (
result.statusCode.equals(StatusCodes.BadNodeIdUnknown) ||
result.statusCode.equals(StatusCodes.BadMethodInvalid)
) {
await session.close();
await client.disconnect();
SKIP(name, "Method ns=1;i=62541 not found");
return;
}
if (!result.statusCode.isGood()) {
throw new Error(`Method call failed: ${result.statusCode}`);
}
const output = result.outputArguments[0].value;
if (typeof output !== "string" || !output.startsWith("Hello")) {
throw new Error(
`Expected output starting with 'Hello', got '${output}'`
);
}
await session.close();
await client.disconnect();
PASS(name);
} catch (err) {
FAIL(name, err.message);
}
}
// ---------------------------------------------------------------------------
// T-5/6/7: Encrypted Anonymous Connection
// ---------------------------------------------------------------------------
async function connectWithSecurity(securityPolicy, securityMode, identity, testName) {
try {
const client = await createClient(securityPolicy, securityMode);
await client.connect(serverUrl);
const session = identity
? await client.createSession(identity)
: await client.createSession();
// Read ServerStatus State to verify connection works
const result = await session.read({
nodeId: "i=2259",
attributeId: AttributeIds.Value,
});
if (!result.statusCode.isGood()) {
throw new Error(`ServerStatus read failed: ${result.statusCode}`);
}
await session.close();
await client.disconnect();
PASS(testName);
} catch (err) {
const msg = err.message || String(err);
if (isSkippableSecurityError(msg)) {
SKIP(testName, "Server does not offer this security policy");
} else if (isSkippableTrustError(msg)) {
SKIP(testName, "Server rejected client certificate");
} else if (isSkippableIdentityError(msg)) {
SKIP(testName, "Server rejected identity token");
} else {
FAIL(testName, msg);
}
}
}
async function t5() {
await connectWithSecurity(
SecurityPolicy.Basic256Sha256,
MessageSecurityMode.SignAndEncrypt,
null,
"T-5: Basic256Sha256 Encrypted Anonymous"
);
}
async function t6() {
await connectWithSecurity(
SecurityPolicy.Aes128_Sha256_RsaOaep,
MessageSecurityMode.SignAndEncrypt,
null,
"T-6: Aes128_Sha256_RsaOaep Encrypted Anonymous"
);
}
async function t7() {
await connectWithSecurity(
SecurityPolicy.Aes256_Sha256_RsaPss,
MessageSecurityMode.SignAndEncrypt,
null,
"T-7: Aes256_Sha256_RsaPss Encrypted Anonymous"
);
}
// ---------------------------------------------------------------------------
// T-8: Username/Password over Encrypted Channel
// ---------------------------------------------------------------------------
async function t8() {
await connectWithSecurity(
SecurityPolicy.Basic256Sha256,
MessageSecurityMode.SignAndEncrypt,
{
type: UserTokenType.UserName,
userName: "user1",
password: "password",
},
"T-8: Username over Basic256Sha256"
);
}
// ---------------------------------------------------------------------------
// T-9: X509 Certificate Authentication
// ---------------------------------------------------------------------------
async function t9() {
const name = "T-9: X509 Certificate Authentication";
try {
const client = await createClient(
SecurityPolicy.Basic256Sha256,
MessageSecurityMode.SignAndEncrypt
);
await client.connect(serverUrl);
const session = await client.createSession({
type: UserTokenType.Certificate,
certificateData: readFileSync(clientCertFile),
privateKey: readFileSync(clientKeyFile, "utf-8"),
});
const result = await session.read({
nodeId: "i=2259",
attributeId: AttributeIds.Value,
});
if (!result.statusCode.isGood()) {
throw new Error(`ServerStatus read failed: ${result.statusCode}`);
}
await session.close();
await client.disconnect();
PASS(name);
} catch (err) {
const msg = err.message || String(err);
if (isSkippableSecurityError(msg)) {
SKIP(name, "Server does not offer Basic256Sha256");
} else if (
isSkippableTrustError(msg) ||
isSkippableIdentityError(msg)
) {
SKIP(name, "Server rejected X509 identity");
} else {
FAIL(name, msg);
}
}
}
// ---------------------------------------------------------------------------
// T-10..T-17: ECC Security Policies (expected to SKIP in node-opcua).
// Labels and ordering match check_interop_client.c.
// ---------------------------------------------------------------------------
const eccPolicies = [
{ label: "T-10", name: "ECC_nistP256", uri: "http://opcfoundation.org/UA/SecurityPolicy#ECC_nistP256" },
{ label: "T-11", name: "ECC_nistP256_AesGcm", uri: "http://opcfoundation.org/UA/SecurityPolicy#ECC_nistP256_AesGcm" },
{ label: "T-12", name: "ECC_nistP256_ChaChaPoly", uri: "http://opcfoundation.org/UA/SecurityPolicy#ECC_nistP256_ChaChaPoly" },
{ label: "T-13", name: "ECC_nistP384", uri: "http://opcfoundation.org/UA/SecurityPolicy#ECC_nistP384" },
{ label: "T-14", name: "ECC_brainpoolP256r1", uri: "http://opcfoundation.org/UA/SecurityPolicy#ECC_brainpoolP256r1" },
{ label: "T-15", name: "ECC_brainpoolP384r1", uri: "http://opcfoundation.org/UA/SecurityPolicy#ECC_brainpoolP384r1" },
{ label: "T-16", name: "ECC_curve25519", uri: "http://opcfoundation.org/UA/SecurityPolicy#ECC_curve25519" },
{ label: "T-17", name: "ECC_curve448", uri: "http://opcfoundation.org/UA/SecurityPolicy#ECC_curve448" },
];
async function runEccTests() {
for (const ecc of eccPolicies) {
const testName = `${ecc.label}: ${ecc.name} Encrypted Anonymous`;
try {
await connectWithSecurity(
ecc.uri,
MessageSecurityMode.SignAndEncrypt,
null,
testName
);
} catch (err) {
// connectWithSecurity handles its own errors, but catch any
// top-level issue (e.g. invalid enum value in node-opcua)
SKIP(testName, `Not supported by node-opcua: ${(err.message || String(err)).slice(0, 80)}`);
}
}
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
export async function runInteropClient(transportFactory) {
clientTransportFactory = transportFactory;
console.log("node-opcua interop client test suite");
console.log(` Server URL: ${serverUrl}`);
console.log(` Client cert: ${clientCertFile}`);
console.log(` Server cert: ${serverCertFile}`);
console.log("");
await t1();
await t2();
await t3();
await t4();
await t5();
await t6();
await t7();
await t8();
await t9();
await runEccTests();
console.log("");
console.log(`Results: ${passed} passed, ${failed} failed, ${skipped} skipped`);
process.exit(failed > 0 ? 1 : 0);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
runInteropClient().catch((err) => {
console.error("Fatal error:", err.message || err);
process.exit(1);
});
}