This is an automated email from the ASF dual-hosted git repository.
chrisdutz pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/plc4x.git
The following commit(s) were added to refs/heads/develop by this push:
new a2dbb6bfc0 fix: Addessed various issues with the OPC-UA driver and
it's testsuite.
a2dbb6bfc0 is described below
commit a2dbb6bfc0ef0060674d5614d99f32da0c749795
Author: Christofer Dutz <[email protected]>
AuthorDate: Fri Jul 10 19:18:45 2026 +0200
fix: Addessed various issues with the OPC-UA driver and it's testsuite.
---
.../apache/plc4x/java/opcua/OpcuaConnection.java | 21 +-
.../java/opcua/config/OpcuaConfiguration.java | 22 +
.../opcua/context/AsymmetricEncryptionHandler.java | 2 +-
.../java/opcua/context/BaseEncryptionHandler.java | 7 +-
.../java/opcua/context/OpcuaDriverContext.java | 41 +-
.../plc4x/java/opcua/context/SecureChannel.java | 29 +-
.../opcua/security/PinnedCertificateVerifier.java | 50 ++
.../security/RejectingCertificateVerifier.java | 43 ++
.../apache/plc4x/java/opcua/KeystoreGenerator.java | 115 ++++
.../apache/plc4x/java/opcua/MiloTestContainer.java | 64 ++
.../plc4x/java/opcua/OpcuaPlcDriverTest.java | 697 +++++++++++++++++++++
.../security/PinnedCertificateVerifierTest.java | 54 ++
.../security/RejectingCertificateVerifierTest.java | 42 ++
.../milo/examples/server/TestMiloServer.java | 7 +
.../plc4x/java/spi/drivers/ConnectionBase.java | 17 +
.../apache/plc4x/java/spi/drivers/DriverBase.java | 3 +
.../drivers/messages/DefaultPlcWriteRequest.java | 9 +-
.../java/spi/values/DefaultPlcValueHandler.java | 19 +-
.../spi/transports/api/BaseTransportInstance.java | 18 +
.../java/spi/transports/api/TransportInstance.java | 16 +
.../plc4x/java/transport/tcp/TcpTransport.java | 9 +-
.../plc4x/java/transport/tls/TlsTransport.java | 8 +-
22 files changed, 1268 insertions(+), 25 deletions(-)
diff --git
a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/OpcuaConnection.java
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/OpcuaConnection.java
index b24c3dbacf..fb31b356bd 100644
---
a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/OpcuaConnection.java
+++
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/OpcuaConnection.java
@@ -142,7 +142,11 @@ public class OpcuaConnection extends
ConnectionBase<OpcuaConfiguration> implemen
getTransportCode(),
remote.getHostString(),
String.valueOf(remote.getPort()),
- "",
+ // The driver-config (e.g. the OPC UA "/milo" path) is the part of
the connection
+ // URL after host:port; strict servers reject a Hello whose
endpoint URL omits it,
+ // so it must be carried into the endpoint the driver advertises.
Read from the
+ // TransportInstance interface so this works for any transport
(tcp, tls, ...).
+ transportInstance.getDriverConfig(),
configuration);
messageCodec = new OpcuaMessageCodec(transportInstance,
this::handleIncoming);
@@ -155,7 +159,7 @@ public class OpcuaConnection extends
ConnectionBase<OpcuaConfiguration> implemen
});
this.conversation = new Conversation(this, driverContext,
configuration);
- this.channel = new SecureChannel(conversation, driverContext,
configuration, getAuthentication());
+ this.channel = new SecureChannel(conversation, driverContext,
configuration, resolveAuthentication());
try {
// Discovery only carries information needed for the encrypted
modes
@@ -181,7 +185,13 @@ public class OpcuaConnection extends
ConnectionBase<OpcuaConfiguration> implemen
}
}
- private PlcAuthentication getAuthentication() {
+ private PlcAuthentication resolveAuthentication() {
+ // Authentication passed to getConnection(url, authentication) takes
precedence over
+ // credentials embedded in the connection string.
+ PlcAuthentication passed = getAuthentication();
+ if (passed != null) {
+ return passed;
+ }
if (configuration.getUsername() != null && configuration.getPassword()
!= null) {
return new
PlcUsernamePasswordAuthentication(configuration.getUsername(),
configuration.getPassword());
}
@@ -196,7 +206,10 @@ public class OpcuaConnection extends
ConnectionBase<OpcuaConfiguration> implemen
}
if (channel != null) {
try {
- channel.onDisconnect();
+ // Wait for CloseSession + CloseSecureChannel to actually
reach the server
+ // before we tear down the socket below; otherwise the server
leaks the
+ // session/channel and refuses new channels once its limit is
hit.
+ channel.onDisconnect().get(configuration.getRequestTimeout(),
TimeUnit.MILLISECONDS);
} catch (Exception e) {
LOGGER.warn("Error during secure channel disconnect", e);
}
diff --git
a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/config/OpcuaConfiguration.java
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/config/OpcuaConfiguration.java
index 8fe835de7a..2377068fd4 100644
---
a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/config/OpcuaConfiguration.java
+++
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/config/OpcuaConfiguration.java
@@ -107,6 +107,14 @@ public class OpcuaConfiguration implements Configuration {
@Description("Password used to open trust store.")
private String trustStorePassword;
+ @ConfigurationParameter("insecure-certificate-verification")
+ @BooleanDefaultValue(false)
+ @Description("Disables verification of the OPC UA server certificate,
trusting any certificate the server presents.\n" +
+ "This is UNSAFE: it leaves the connection open to man-in-the-middle
attacks and defeats the integrity/authenticity\n" +
+ "guarantees of a signed secure channel. Only enable it for local
testing. In production, establish trust with\n" +
+ "`trust-store-file` (chain validation) or `server-certificate-file`
(certificate pinning) instead.")
+ private boolean insecureCertificateVerification;
+
// the discovered certificate when discovery is enabled
private X509Certificate serverCertificate;
@@ -198,6 +206,20 @@ public class OpcuaConfiguration implements Configuration {
return trustStorePassword == null ? null :
trustStorePassword.toCharArray();
}
+ public boolean isInsecureCertificateVerification() {
+ return insecureCertificateVerification;
+ }
+
+ /**
+ * The filesystem path of a user-supplied server certificate to pin trust
to,
+ * or {@code null} if none was configured. Unlike {@link
#getServerCertificate()},
+ * this never returns a certificate that was discovered over the
(unauthenticated)
+ * discovery channel, so it is safe to use as a trust anchor.
+ */
+ public String getServerCertificateFile() {
+ return serverCertificateFile;
+ }
+
public Limits getEncodingLimits() {
return limits;
}
diff --git
a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/AsymmetricEncryptionHandler.java
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/AsymmetricEncryptionHandler.java
index 8dfc7d1f2d..8c64128db0 100644
---
a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/AsymmetricEncryptionHandler.java
+++
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/AsymmetricEncryptionHandler.java
@@ -49,7 +49,7 @@ public class AsymmetricEncryptionHandler extends
BaseEncryptionHandler {
Signature signature =
securityPolicy.getAsymmetricSignatureAlgorithm().getSignature();
signature.initVerify(conversation.getRemoteCertificate().getPublicKey());
signature.update(message);
- if (signature.verify(signatureData)) {
+ if (!signature.verify(signatureData)) {
throw new IllegalArgumentException("Invalid signature");
}
}
diff --git
a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/BaseEncryptionHandler.java
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/BaseEncryptionHandler.java
index 005c615a1c..ed17e85a94 100644
---
a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/BaseEncryptionHandler.java
+++
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/BaseEncryptionHandler.java
@@ -181,7 +181,12 @@ abstract class BaseEncryptionHandler {
}
if (chunk.isSigned()) {
- verify(chunkBytes, chunk, messageLength);
+ // After decryption the body may have shrunk (for asymmetric
RSA the
+ // cipher-text block is larger than the plain-text block), so
the
+ // signature slice must be located relative to the post-decrypt
+ // length, not the original on-the-wire messageLength.
+ int decryptedMessageLength = SECURE_MESSAGE_HEADER_SIZE +
chunk.getSecurityHeaderSize() + bodySize;
+ verify(chunkBytes, chunk, decryptedMessageLength);
}
int encryptionOverhead = getEncryptionOverhead(chunk,
messageLength);
diff --git
a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/OpcuaDriverContext.java
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/OpcuaDriverContext.java
index c953374b74..b8533a38db 100644
---
a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/OpcuaDriverContext.java
+++
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/OpcuaDriverContext.java
@@ -30,6 +30,8 @@ import org.apache.plc4x.java.opcua.config.OpcuaConfiguration;
import org.apache.plc4x.java.opcua.readwrite.PascalByteString;
import org.apache.plc4x.java.opcua.security.CertificateVerifier;
import org.apache.plc4x.java.opcua.security.PermissiveCertificateVerifier;
+import org.apache.plc4x.java.opcua.security.PinnedCertificateVerifier;
+import org.apache.plc4x.java.opcua.security.RejectingCertificateVerifier;
import org.apache.plc4x.java.opcua.security.SecurityPolicy;
import org.apache.plc4x.java.opcua.security.TrustStoreCertificateVerifier;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
@@ -80,7 +82,11 @@ public class OpcuaDriverContext {
private X509Certificate serverCertificate;
private PascalByteString thumbprint;
- private CertificateVerifier certificateVerifier = new
PermissiveCertificateVerifier();
+ // Secure by default: reject every server certificate until an explicit
trust
+ // anchor (trust store or pinned server certificate) is configured, or the
user
+ // explicitly opts out of verification. Anything more permissive by
default would
+ // leave signed secure channels open to man-in-the-middle attacks.
+ private CertificateVerifier certificateVerifier = new
RejectingCertificateVerifier();
public void openKeyStore(OpcuaConfiguration configuration) throws
IOException, GeneralSecurityException {
@@ -104,10 +110,41 @@ public class OpcuaDriverContext {
thumbprint = new PascalByteString(sha1.length, sha1);
}
+ certificateVerifier = buildCertificateVerifier(configuration);
+ }
+
+ /**
+ * Selects the server-certificate trust strategy, in order of precedence:
+ * <ol>
+ * <li>{@code insecure-certificate-verification=true} → trust
everything (unsafe, opt-in only);</li>
+ * <li>a {@code trust-store-file} → validate the certificate chain
against the trust store;</li>
+ * <li>a {@code server-certificate-file} → pin trust to that exact
certificate;</li>
+ * <li>otherwise → fail closed and reject, since no trust anchor is
available.</li>
+ * </ol>
+ * Note that the pinned certificate is read from the configured file only
— a
+ * certificate learned over the unauthenticated discovery channel is never
used
+ * as a trust anchor.
+ */
+ private CertificateVerifier buildCertificateVerifier(OpcuaConfiguration
configuration)
+ throws IOException, GeneralSecurityException {
+ if (configuration.isInsecureCertificateVerification()) {
+ LOGGER.warn("OPC UA server certificate verification is DISABLED "
+ + "('insecure-certificate-verification=true'). The connection
is vulnerable to "
+ + "man-in-the-middle attacks; do not use this in production.");
+ return new PermissiveCertificateVerifier();
+ }
if (configuration.getTrustStoreFile() != null) {
KeyStore trustStore =
openKeyStore(configuration.getTrustStoreFile(),
configuration.getTrustStoreType(), configuration.getTrustStorePassword());
- certificateVerifier = new
TrustStoreCertificateVerifier(trustStore);
+ return new TrustStoreCertificateVerifier(trustStore);
+ }
+ if (configuration.getServerCertificateFile() != null) {
+ LOGGER.info("Pinning OPC UA server certificate trust to {}",
configuration.getServerCertificateFile());
+ return new
PinnedCertificateVerifier(configuration.getServerCertificate());
}
+ LOGGER.warn("No OPC UA trust anchor configured ('trust-store-file' or
'server-certificate-file'); "
+ + "server certificates will be rejected. Set
'insecure-certificate-verification=true' to bypass "
+ + "verification for local testing only.");
+ return new RejectingCertificateVerifier();
}
public String getHost() {
diff --git
a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/SecureChannel.java
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/SecureChannel.java
index e18816f3dc..b6a7ad1c27 100644
---
a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/SecureChannel.java
+++
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/SecureChannel.java
@@ -330,7 +330,19 @@ public class SecureChannel {
});
}
- public void onDisconnect() {
+ /**
+ * Closes the session and the secure channel on the server. The returned
future
+ * completes once the {@code CloseSession} has been acknowledged and the
+ * {@code CloseSecureChannel} has been handed to the wire, so callers must
await it
+ * before tearing down the socket — otherwise the server never sees the
close, leaks
+ * the session/channel, and eventually refuses new channels once its
concurrent-channel
+ * limit is reached.
+ *
+ * <p>Note that {@code CloseSecureChannel} is not awaited for a reply: per
the OPC UA
+ * spec the server simply closes the channel without responding, so we
only ensure its
+ * bytes are flushed (which {@code requestChannelClose} does
synchronously).</p>
+ */
+ public CompletableFuture<Void> onDisconnect() {
LOGGER.info("Disconnecting");
if (keepAlive != null) {
@@ -340,13 +352,17 @@ public class SecureChannel {
RequestHeader requestHeader = conversation.createRequestHeader(50000L);
CloseSessionRequest closeSessionRequest = new
CloseSessionRequest(requestHeader, true);
- conversation.submit(closeSessionRequest,
CloseSessionResponse.class).thenAccept(responseMessage -> {
- LOGGER.trace("Got Close Session Response Connection Response" +
responseMessage);
- onDisconnectCloseSecureChannel();
- });
+ return conversation.submit(closeSessionRequest,
CloseSessionResponse.class)
+ // Proceed to close the channel even if the session close
failed/timed out;
+ // the important thing is that we still tell the server to drop
the channel.
+ .handle((responseMessage, error) -> {
+ LOGGER.trace("Got Close Session Response {}", responseMessage);
+ return null;
+ })
+ .thenRun(this::sendCloseSecureChannel);
}
- private void onDisconnectCloseSecureChannel() {
+ private void sendCloseSecureChannel() {
RequestHeader requestHeader = conversation.createRequestHeader();
CloseSecureChannelRequest closeSecureChannelRequest = new
CloseSecureChannelRequest(requestHeader);
@@ -363,6 +379,7 @@ public class SecureChannel {
)
);
+ // Fire-and-forget: the bytes are flushed synchronously; no response
is expected.
conversation.requestChannelClose(closeRequest);
}
diff --git
a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/security/PinnedCertificateVerifier.java
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/security/PinnedCertificateVerifier.java
new file mode 100644
index 0000000000..93ed5edfc6
--- /dev/null
+++
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/security/PinnedCertificateVerifier.java
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.plc4x.java.opcua.security;
+
+import java.security.MessageDigest;
+import java.security.cert.CertificateEncodingException;
+import java.security.cert.CertificateException;
+import java.security.cert.X509Certificate;
+import java.util.Objects;
+
+/**
+ * Certificate verifier that pins trust to a single, explicitly configured
server
+ * certificate (from {@code server-certificate-file}). The presented
certificate
+ * is accepted only when its DER encoding is byte-for-byte identical to the
pinned
+ * certificate. Comparison uses a constant-time check to avoid leaking
information
+ * through timing.
+ */
+public class PinnedCertificateVerifier implements CertificateVerifier {
+
+ private final byte[] pinnedEncoded;
+
+ public PinnedCertificateVerifier(X509Certificate pinnedCertificate) throws
CertificateEncodingException {
+ this.pinnedEncoded = Objects.requireNonNull(pinnedCertificate,
"pinnedCertificate must not be null").getEncoded();
+ }
+
+ @Override
+ public void checkCertificateTrusted(X509Certificate certificate) throws
CertificateException {
+ if (certificate == null || !MessageDigest.isEqual(pinnedEncoded,
certificate.getEncoded())) {
+ throw new CertificateException("Server certificate does not match
the pinned 'server-certificate-file'.");
+ }
+ }
+
+}
diff --git
a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/security/RejectingCertificateVerifier.java
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/security/RejectingCertificateVerifier.java
new file mode 100644
index 0000000000..8cc9ff1c56
--- /dev/null
+++
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/security/RejectingCertificateVerifier.java
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.plc4x.java.opcua.security;
+
+import java.security.cert.CertificateException;
+import java.security.cert.X509Certificate;
+
+/**
+ * Fail-closed certificate verifier used as the secure default: it rejects
every
+ * server certificate because no trust anchor has been configured. To establish
+ * trust, configure either a {@code trust-store-file} (chain validation) or a
+ * {@code server-certificate-file} (certificate pinning). As a last resort,
+ * certificate verification can be disabled entirely with
+ * {@code insecure-certificate-verification=true}, which is unsafe and leaves
the
+ * connection open to man-in-the-middle attacks.
+ */
+public class RejectingCertificateVerifier implements CertificateVerifier {
+
+ @Override
+ public void checkCertificateTrusted(X509Certificate certificate) throws
CertificateException {
+ throw new CertificateException("No trust anchor configured for OPC UA
server certificate verification. "
+ + "Configure 'trust-store-file' or 'server-certificate-file' to
establish trust, or set "
+ + "'insecure-certificate-verification=true' to disable
verification (unsafe).");
+ }
+
+}
diff --git
a/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/KeystoreGenerator.java
b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/KeystoreGenerator.java
new file mode 100644
index 0000000000..c16f0b80e0
--- /dev/null
+++
b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/KeystoreGenerator.java
@@ -0,0 +1,115 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.plc4x.java.opcua;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.security.GeneralSecurityException;
+import java.security.Key;
+import java.security.KeyPair;
+import java.security.KeyStore;
+import java.security.PrivateKey;
+import java.security.cert.CertificateEncodingException;
+import java.security.cert.X509Certificate;
+import java.util.Base64;
+import java.util.Set;
+import org.eclipse.milo.opcua.stack.core.util.SelfSignedCertificateBuilder;
+import org.eclipse.milo.opcua.stack.core.util.SelfSignedCertificateGenerator;
+
+/**
+ * Utility to generate server certificate - based on Eclipse Milo stuff.
+ */
+public class KeystoreGenerator {
+
+ private final String password;
+ private final KeyStore keyStore;
+ private final X509Certificate certificate;
+
+ public KeystoreGenerator(String password, int length, String
applicationUri) {
+ this(password, length, applicationUri, "server-ai", "Milo Server");
+ }
+
+ public KeystoreGenerator(String password, int length, String
applicationUri, String entryName, String commonName) {
+ this.password = password;
+ try {
+ this.keyStore = generate(password, length, applicationUri,
entryName, commonName);
+
+ Key serverPrivateKey = keyStore.getKey(entryName,
password.toCharArray());
+ if (serverPrivateKey instanceof PrivateKey) {
+ this.certificate = (X509Certificate)
keyStore.getCertificate(entryName);
+ } else {
+ throw new IllegalStateException("Unexpected keystore entry,
expected private key");
+ }
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private KeyStore generate(String password, int length, String
applicationUri, String entryName, String commonName) throws Exception {
+ KeyStore keyStore = KeyStore.getInstance("PKCS12");
+ keyStore.load(null, password.toCharArray());
+ KeyPair keyPair =
SelfSignedCertificateGenerator.generateRsaKeyPair(length);
+ SelfSignedCertificateBuilder builder = (new
SelfSignedCertificateBuilder(
+ keyPair)).setCommonName(commonName)
+ .setOrganization("Apache Software Foundation")
+ .setOrganizationalUnit("PLC4X")
+ .setLocalityName("PLC4J")
+ .setStateName("CA")
+ .setCountryCode("US")
+ .setApplicationUri(applicationUri);
+
+ Set<String> hostnames = Set.of("127.0.0.1");
+
+ for (String hostname : hostnames) {
+ if (hostname.startsWith("\\d+\\.")) {
+ builder.addIpAddress(hostname);
+ } else {
+ builder.addDnsName(hostname);
+ }
+ }
+
+ X509Certificate certificate = builder.build();
+ keyStore.setKeyEntry(entryName, keyPair.getPrivate(),
password.toCharArray(), new X509Certificate[]{ certificate });
+ return keyStore;
+ }
+
+ public KeyStore getKeyStore() {
+ return keyStore;
+ }
+
+ public X509Certificate getCertificate() {
+ return certificate;
+ }
+
+ public void writeKeyStoreTo(OutputStream stream) throws IOException,
GeneralSecurityException {
+ keyStore.store(stream, password.toCharArray());
+ stream.flush();
+ }
+
+ public void writeCertificateTo(OutputStream stream) throws IOException,
CertificateEncodingException {
+ String data = "-----BEGIN CERTIFICATE-----\n" +
+ Base64.getMimeEncoder(64,
"\n".getBytes()).encodeToString(certificate.getEncoded()) + "\n" +
+ "-----END CERTIFICATE-----";
+
+ stream.write(data.getBytes());
+ stream.flush();
+ }
+
+}
diff --git
a/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/MiloTestContainer.java
b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/MiloTestContainer.java
new file mode 100644
index 0000000000..817813fd54
--- /dev/null
+++
b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/MiloTestContainer.java
@@ -0,0 +1,64 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.plc4x.java.opcua;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.wait.strategy.Wait;
+import org.testcontainers.images.builder.ImageFromDockerfile;
+
+public class MiloTestContainer extends GenericContainer<MiloTestContainer> {
+
+ private final static Logger logger =
LoggerFactory.getLogger(MiloTestContainer.class);
+
+ private final static ImageFromDockerfile IMAGE = inlineImage();
+
+ public MiloTestContainer() {
+ super(IMAGE);
+
+ waitingFor(Wait.forLogMessage("Server started\\s*", 1))
+ // Uncomment below to debug Milo server
+ //.withStartupTimeout(Duration.ofMinutes(10))
+ ;
+ // Fixed 12686 -> 12686 mapping. The Milo server advertises its
endpoints on
+ // localhost:12686 (its internal bind port), so the host port MUST
equal 12686 —
+ // otherwise the client connects to a random mapped port, is
redirected to the
+ // (unreachable) advertised localhost:12686 endpoint, and the
handshake hangs.
+ addFixedExposedPort(12686, 12686);
+
+ // Uncomment below to enable server debug
+ //withEnv("JAVA_TOOL_OPTIONS",
"-agentlib:jdwp=transport=dt_socket,address=*:8000,server=y,suspend=y");
+ //addFixedExposedPort(8000, 8000);
+ }
+
+ private static ImageFromDockerfile inlineImage() {
+ Path absolutePath = Paths.get(".").toAbsolutePath();
+ logger.info("Building milo server image from {}", absolutePath);
+ // Reuse the Docker layer cache across runs; the build context only
changes when
+ // the milo jar or the compiled TestMiloServer classes change, so a
cached build
+ // stays correct while turning a multi-minute rebuild into a
near-instant one.
+ return new ImageFromDockerfile("plc4x-milo-test", false)
+ .withDockerfile(absolutePath.resolve("Dockerfile.test"));
+ }
+
+}
diff --git
a/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/OpcuaPlcDriverTest.java
b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/OpcuaPlcDriverTest.java
new file mode 100644
index 0000000000..ffe90b0cba
--- /dev/null
+++
b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/OpcuaPlcDriverTest.java
@@ -0,0 +1,697 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.plc4x.java.opcua;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.lang.reflect.Array;
+import java.net.URLEncoder;
+import java.nio.charset.Charset;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.concurrent.ConcurrentLinkedDeque;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+import org.apache.plc4x.java.DefaultPlcDriverManager;
+import org.apache.plc4x.java.api.PlcConnection;
+import org.apache.plc4x.java.api.PlcConnectionManager;
+import org.apache.plc4x.java.api.PlcDriverManager;
+import
org.apache.plc4x.java.api.authentication.PlcUsernamePasswordAuthentication;
+import org.apache.plc4x.java.api.exceptions.PlcUnsupportedDataTypeException;
+import org.apache.plc4x.java.api.messages.PlcReadRequest;
+import org.apache.plc4x.java.api.messages.PlcReadResponse;
+import org.apache.plc4x.java.api.messages.PlcSubscriptionEvent;
+import org.apache.plc4x.java.api.messages.PlcSubscriptionRequest;
+import org.apache.plc4x.java.api.messages.PlcSubscriptionResponse;
+import org.apache.plc4x.java.api.messages.PlcWriteRequest;
+import org.apache.plc4x.java.api.messages.PlcWriteResponse;
+import org.apache.plc4x.java.api.types.PlcResponseCode;
+import org.apache.plc4x.java.opcua.security.MessageSecurity;
+import org.apache.plc4x.java.opcua.security.SecurityPolicy;
+import org.apache.plc4x.java.opcua.tag.OpcuaTag;
+import org.assertj.core.api.Condition;
+import org.assertj.core.api.SoftAssertions;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.math.BigInteger;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.concurrent.ExecutionException;
+import java.util.stream.Stream;
+import org.testcontainers.containers.output.Slf4jLogConsumer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+import static java.util.Map.entry;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.fail;
+
+@Testcontainers(disabledWithoutDocker = true)
+public class OpcuaPlcDriverTest {
+
+ private static final Logger LOGGER =
LoggerFactory.getLogger(OpcuaPlcDriverTest.class);
+
+ private static final String APPLICATION_URI = "urn:org.apache:plc4x";
+ private static final KeystoreGenerator SERVER_KEY_STORE_GENERATOR = new
KeystoreGenerator("password", 2048, APPLICATION_URI);
+ private static final KeystoreGenerator CLIENT_KEY_STORE_GENERATOR = new
KeystoreGenerator("changeit", 2048, APPLICATION_URI, "plc4x_plus_milo",
"client");
+
+ private static final File SECURITY_DIR;
+ private static final File CLIENT_KEY_STORE;
+ // The server's certificate, exported so the client can pin trust to it.
Since the
+ // driver now rejects server certificates by default (no permissive
fallback), every
+ // secured connection needs an explicit trust anchor.
+ private static final File SERVER_CERTIFICATE;
+
+ // Provision the server keystore / client material BEFORE the container
starts. The
+ // container is static (see below) so the Testcontainers extension starts
it during
+ // its beforeAll callback, which runs before any @BeforeAll method — hence
the setup
+ // lives in this static initializer rather than in @BeforeAll.
+ static {
+ try {
+ Path tempDirectory =
Files.createTempDirectory("plc4x_opcua_client");
+
+ SECURITY_DIR = new File(tempDirectory.toFile().getAbsolutePath(),
"server/security");
+ File pkiDir = new File(SECURITY_DIR, "pki");
+ File trustedCerts = new File(pkiDir, "trusted/certs");
+ if (!pkiDir.mkdirs() || !trustedCerts.mkdirs()) {
+ throw new IllegalStateException("Could not start test -
missing permissions to create temporary files");
+ }
+
+ // pre-provisioned server certificate (keystore the Milo server
loads on boot)
+ try (FileOutputStream bos = new FileOutputStream(new
File(pkiDir.getParentFile(), "example-server.pfx"))) {
+ SERVER_KEY_STORE_GENERATOR.writeKeyStoreTo(bos);
+ }
+
+ // export the server certificate so secured client connections can
pin trust to it
+ SERVER_CERTIFICATE = Files.createTempFile("plc4x_opcua_server_",
".crt").toAbsolutePath().toFile();
+ try (FileOutputStream outputStream = new
FileOutputStream(SERVER_CERTIFICATE)) {
+ SERVER_KEY_STORE_GENERATOR.writeCertificateTo(outputStream);
+ }
+
+ // pre-provisioned client certificate, doing it here because
server might be slow in picking up them, and we don't want to wait with our
tests
+ CLIENT_KEY_STORE = Files.createTempFile("plc4x_opcua_client_",
".p12").toAbsolutePath().toFile();
+ try (FileOutputStream outputStream = new
FileOutputStream(CLIENT_KEY_STORE)) {
+ CLIENT_KEY_STORE_GENERATOR.writeKeyStoreTo(outputStream);
+ }
+ try (FileOutputStream outputStream = new FileOutputStream(new
File(trustedCerts, "plc4x.crt"))) {
+ CLIENT_KEY_STORE_GENERATOR.writeCertificateTo(outputStream);
+ }
+ } catch (Exception e) {
+ throw new ExceptionInInitializerError(e);
+ }
+ }
+
+ // Static so the Milo server boots ONCE for the whole class rather than
being torn
+ // down and rebuilt before every test method (which is what an instance
@Container does).
+ @Container
+ public static final MiloTestContainer milo = new MiloTestContainer()
+ //.withCreateContainerCmdModifier(cmd ->
cmd.withHostName("test-opcua-server"))
+ .withLogConsumer(new Slf4jLogConsumer(LOGGER))
+ .withFileSystemBind(SECURITY_DIR.getAbsolutePath(),
"/tmp/server/security")
+ //.withEnv("JAVA_TOOL_OPTIONS",
"-agentlib:jdwp=transport=dt_socket,address=*:8000,server=y,suspend=y")
+// .withCommand("java -cp '/opt/milo/*:/opt/milo/'
org.eclipse.milo.examples.server.TestMiloServer")
+ ;
+
+ // Read only variables of milo example server of version 3.6
+ private static final String BOOL_IDENTIFIER_READ_WRITE =
"ns=2;s=HelloWorld/ScalarTypes/Boolean";
+ private static final String BYTE_IDENTIFIER_READ_WRITE =
"ns=2;s=HelloWorld/ScalarTypes/Byte";
+ private static final String DOUBLE_IDENTIFIER_READ_WRITE =
"ns=2;s=HelloWorld/ScalarTypes/Double";
+ private static final String FLOAT_IDENTIFIER_READ_WRITE =
"ns=2;s=HelloWorld/ScalarTypes/Float";
+ private static final String INT16_IDENTIFIER_READ_WRITE =
"ns=2;s=HelloWorld/ScalarTypes/Int16";
+ private static final String INT32_IDENTIFIER_READ_WRITE =
"ns=2;s=HelloWorld/ScalarTypes/Int32";
+ private static final String INT64_IDENTIFIER_READ_WRITE =
"ns=2;s=HelloWorld/ScalarTypes/Int64";
+ private static final String INTEGER_IDENTIFIER_READ_WRITE =
"ns=2;s=HelloWorld/ScalarTypes/Integer";
+ private static final String SBYTE_IDENTIFIER_READ_WRITE =
"ns=2;s=HelloWorld/ScalarTypes/SByte";
+ private static final String STRING_IDENTIFIER_READ_WRITE =
"ns=2;s=HelloWorld/ScalarTypes/String";
+ private static final String UINT16_IDENTIFIER_READ_WRITE =
"ns=2;s=HelloWorld/ScalarTypes/UInt16";
+ private static final String UINT32_IDENTIFIER_READ_WRITE =
"ns=2;s=HelloWorld/ScalarTypes/UInt32";
+ private static final String UINT64_IDENTIFIER_READ_WRITE =
"ns=2;s=HelloWorld/ScalarTypes/UInt64";
+ private static final String UINTEGER_IDENTIFIER_READ_WRITE =
"ns=2;s=HelloWorld/ScalarTypes/UInteger";
+ private static final String DOES_NOT_EXIST_IDENTIFIER_READ_WRITE =
"ns=2;i=12512623";
+ private static final String DOES_NOT_EXISTS_TAG_NAME = "DoesNotExists"; //
tag name
+
+ // At the moment not used in PLC4X or in the OPC UA driver
+ private static final String BYTE_STRING_IDENTIFIER_READ_WRITE =
"ns=2;s=HelloWorld/ScalarTypes/ByteString";
+ private static final String DATE_TIME_READ_WRITE =
"ns=2;s=HelloWorld/ScalarTypes/DateTime";
+ private static final String DURATION_READ_WRITE =
"ns=2;s=HelloWorld/ScalarTypes/Duration";
+ private static final String GUID_READ_WRITE =
"ns=2;s=HelloWorld/ScalarTypes/Guid";
+ private static final String LOCALIZED_READ_WRITE =
"ns=2;s=HelloWorld/ScalarTypes/LocalizedText";
+ private static final String NODE_ID_READ_WRITE =
"ns=2;s=HelloWorld/ScalarTypes/NodeId";
+ private static final String QUALIFIED_NAM_READ_WRITE =
"ns=2;s=HelloWorld/ScalarTypes/QualifiedName";
+ private static final String UTC_TIME_READ_WRITE =
"ns=2;s=HelloWorld/ScalarTypes/UtcTime";
+ private static final String VARIANT_READ_WRITE =
"ns=2;s=HelloWorld/ScalarTypes/Variant";
+ private static final String XML_ELEMENT_READ_WRITE =
"ns=2;s=HelloWorld/ScalarTypes/XmlElement";
+
+ //Arrays
+ private static final String BOOL_ARRAY_IDENTIFIER =
"ns=2;s=HelloWorld/ArrayTypes/BooleanArray";
+ //private static final String BYTE_STRING_ARRAY_IDENTIFIER =
"ns=2;s=HelloWorld/ArrayTypes/ByteStringArray";
+ private static final String BYTE_ARRAY_IDENTIFIER =
"ns=2;s=HelloWorld/ArrayTypes/ByteArray";
+ private static final String DOUBLE_ARRAY_IDENTIFIER =
"ns=2;s=HelloWorld/ArrayTypes/DoubleArray";
+ private static final String FLOAT_ARRAY_IDENTIFIER =
"ns=2;s=HelloWorld/ArrayTypes/FloatArray";
+ private static final String INT16_ARRAY_IDENTIFIER =
"ns=2;s=HelloWorld/ArrayTypes/Int16Array";
+ private static final String INT32_ARRAY_IDENTIFIER =
"ns=2;s=HelloWorld/ArrayTypes/Int32Array";
+ private static final String INT64_ARRAY_IDENTIFIER =
"ns=2;s=HelloWorld/ArrayTypes/Int64Array";
+ private static final String INTEGER_ARRAY_IDENTIFIER =
"ns=2;s=HelloWorld/ArrayTypes/IntegerArray";
+ private static final String SBYTE_ARRAY_IDENTIFIER =
"ns=2;s=HelloWorld/ArrayTypes/SByteArray";
+ private static final String STRING_ARRAY_IDENTIFIER =
"ns=2;s=HelloWorld/ArrayTypes/StringArray";
+ private static final String UINT16_ARRAY_IDENTIFIER =
"ns=2;s=HelloWorld/ArrayTypes/UInt16Array";
+ private static final String UINT32_ARRAY_IDENTIFIER =
"ns=2;s=HelloWorld/ArrayTypes/UInt32Array";
+ private static final String UINT64_ARRAY_IDENTIFIER =
"ns=2;s=HelloWorld/ArrayTypes/UInt64Array";
+ private static final String DATE_TIME_ARRAY_IDENTIFIER =
"ns=2;s=HelloWorld/ArrayTypes/DateTimeArray";
+
+ //Restricted
+ public static final String STRING_IDENTIFIER_ONLY_ADMIN_READ_WRITE =
"ns=2;s=HelloWorld/OnlyAdminCanRead/String";
+
+ // Address of local milo server, since it comes from test container its
hostname and port is not static
+ private final String miloLocalAddress = "%s:%d/milo";
+ //Tcp pattern of OPC UA
+ private final String opcPattern = "opcua:tcp://";
+
+ private static final String PARAM_SECTION_DIVIDER = "?";
+ private static final String PARAM_DIVIDER = "&";
+
+ private final String discoveryValidParamTrue = "discovery=true";
+ private final String discoveryValidParamFalse = "discovery=false";
+ private final String discoveryCorruptedParamWrongValueNum = "discovery=1";
+ private final String discoveryCorruptedParamWrongName = "diskovery=false";
+
+ private String tcpConnectionAddress;
+ private List<String> connectionStringValidSet;
+
+ final List<String> discoveryParamValidSet =
List.of(discoveryValidParamTrue, discoveryValidParamFalse);
+ List<String> discoveryParamCorruptedSet =
List.of(discoveryCorruptedParamWrongValueNum, discoveryCorruptedParamWrongName);
+
+ @BeforeEach
+ public void startUp() throws Exception {
+ tcpConnectionAddress = String.format(opcPattern + miloLocalAddress,
milo.getHost(), milo.getMappedPort(12686)) + "?endpoint-port=12686";
+ connectionStringValidSet = List.of(tcpConnectionAddress);
+ }
+
+ @Nested
+ class SmokeTest {
+ @Test
+ public void manyReconnectionsWithSingleSubscription() throws Exception
{
+ PlcDriverManager driverManager = new DefaultPlcDriverManager();
+ PlcConnectionManager connectionManager =
driverManager.getConnectionManager();
+
+ for (int i = 0; i < 3; i++) {
+ try (PlcConnection connection =
connectionManager.getConnection(tcpConnectionAddress)) {
+
+ PlcSubscriptionRequest request =
connection.subscriptionRequestBuilder()
+ .addChangeOfStateTag("Demo",
OpcuaTag.of(INTEGER_IDENTIFIER_READ_WRITE))
+ .build();
+
+ PlcSubscriptionResponse response =
request.execute().get(60, TimeUnit.SECONDS);
+
assertThat(response.getResponseCode("Demo")).isEqualTo(PlcResponseCode.OK);
+
+ connection.unsubscriptionRequestBuilder()
+ .addHandles(response.getSubscriptionHandles())
+ .build()
+ .execute();
+ }
+ }
+ }
+ @Test
+ public void manySubscriptionsOnSingleConnection() throws Exception {
+ final int numberOfSubscriptions = 3;
+
+ PlcDriverManager driverManager = new DefaultPlcDriverManager();
+ PlcConnectionManager connectionManager =
driverManager.getConnectionManager();
+
+ ArrayList<PlcSubscriptionResponse> plcSubscriptionResponses = new
ArrayList<>();
+ ConcurrentLinkedDeque<PlcSubscriptionEvent> events = new
ConcurrentLinkedDeque<>();
+
+ try (PlcConnection connection =
connectionManager.getConnection(tcpConnectionAddress)) {
+ for (int i = 0; i < numberOfSubscriptions; i++) {
+ PlcSubscriptionRequest request =
connection.subscriptionRequestBuilder()
+ .addChangeOfStateTag("Demo",
OpcuaTag.of(INTEGER_IDENTIFIER_READ_WRITE))
+ .build();
+
+ PlcSubscriptionResponse response =
request.execute().get(60, TimeUnit.SECONDS);
+
assertThat(response.getResponseCode("Demo")).isEqualTo(PlcResponseCode.OK);
+
+ plcSubscriptionResponses.add(response);
+
+ response.getSubscriptionHandles().forEach(handle ->
handle.register(events::add));
+ }
+
+ for (int i = 0; i < 60; i++) {
+ if (events.size() == numberOfSubscriptions) {
+ break;
+ }
+ try {
+ Thread.sleep(1000);
+ } catch (InterruptedException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ assertThat(events.size()).isEqualTo(numberOfSubscriptions);
+
+ for (PlcSubscriptionResponse response :
plcSubscriptionResponses) {
+ connection.unsubscriptionRequestBuilder()
+ .addHandles(response.getSubscriptionHandles())
+ .build()
+ .execute();
+ }
+ }
+ }
+ }
+
+ @Nested
+ class ConnectionRelated {
+ @TestFactory
+ Stream<DynamicNode> connectionNoParams() {
+ return connectionStringValidSet.stream()
+ .map(connectionString ->
DynamicTest.dynamicTest(connectionString, () -> {
+ PlcConnection opcuaConnection = new
DefaultPlcDriverManager().getConnection(connectionString);
+ Condition<PlcConnection> is_connected = new
Condition<>(PlcConnection::isConnected, "is connected");
+ assertThat(opcuaConnection).is(is_connected);
+ opcuaConnection.close();
+ assertThat(opcuaConnection).isNot(is_connected);
+ }))
+ .map(DynamicNode.class::cast);
+ }
+
+ @TestFactory
+ Stream<DynamicNode> connectionWithDiscoveryParam() throws Exception {
+ return connectionStringValidSet.stream()
+ .map(connectionAddress ->
DynamicContainer.dynamicContainer(connectionAddress, () ->
+ discoveryParamValidSet.stream().map(discoveryParam ->
DynamicTest.dynamicTest(discoveryParam, () -> {
+ String connectionString = connectionAddress +
PARAM_DIVIDER + discoveryParam;
+ PlcConnection opcuaConnection = new
DefaultPlcDriverManager().getConnection(connectionString);
+ Condition<PlcConnection> is_connected = new
Condition<>(PlcConnection::isConnected, "is connected");
+ assertThat(opcuaConnection).is(is_connected);
+ opcuaConnection.close();
+ assertThat(opcuaConnection).isNot(is_connected);
+ }))
+ .map(DynamicNode.class::cast)
+ .iterator()))
+ .map(DynamicNode.class::cast);
+ }
+
+ @Test
+ void connectionWithUrlAuthentication() throws Exception {
+ DefaultPlcDriverManager driverManager = new
DefaultPlcDriverManager();
+ try (PlcConnection opcuaConnection =
driverManager.getConnection(tcpConnectionAddress +
"&username=admin&password=password2")) {
+ Condition<PlcConnection> is_connected = new
Condition<>(PlcConnection::isConnected, "is connected");
+ assertThat(opcuaConnection).is(is_connected);
+
+ PlcReadRequest.Builder builder =
opcuaConnection.readRequestBuilder()
+ .addTagAddress("String",
STRING_IDENTIFIER_ONLY_ADMIN_READ_WRITE);
+
+ PlcReadRequest request = builder.build();
+ PlcReadResponse response = request.execute().get();
+
+
assertThat(response.getResponseCode("String")).isEqualTo(PlcResponseCode.OK);
+ }
+ }
+
+ @Test
+ void connectionWithPlcAuthentication() throws Exception {
+ DefaultPlcDriverManager driverManager = new
DefaultPlcDriverManager();
+ try (PlcConnection opcuaConnection =
driverManager.getConnection(tcpConnectionAddress,
+ new PlcUsernamePasswordAuthentication("admin",
"password2"))) {
+ Condition<PlcConnection> is_connected = new
Condition<>(PlcConnection::isConnected, "is connected");
+ assertThat(opcuaConnection).is(is_connected);
+
+ PlcReadRequest.Builder builder =
opcuaConnection.readRequestBuilder()
+ .addTagAddress("String",
STRING_IDENTIFIER_ONLY_ADMIN_READ_WRITE);
+
+ PlcReadRequest request = builder.build();
+ PlcReadResponse response = request.execute().get();
+
+
assertThat(response.getResponseCode("String")).isEqualTo(PlcResponseCode.OK);
+ }
+ }
+
+ @Test
+ void connectionWithPlcAuthenticationOverridesUrlParam() throws
Exception {
+ DefaultPlcDriverManager driverManager = new
DefaultPlcDriverManager();
+ try (PlcConnection opcuaConnection =
driverManager.getConnection(tcpConnectionAddress +
"&username=user&password=password1",
+ new PlcUsernamePasswordAuthentication("admin",
"password2"))) {
+ Condition<PlcConnection> is_connected = new
Condition<>(PlcConnection::isConnected, "is connected");
+ assertThat(opcuaConnection).is(is_connected);
+
+ PlcReadRequest.Builder builder =
opcuaConnection.readRequestBuilder()
+ .addTagAddress("String",
STRING_IDENTIFIER_ONLY_ADMIN_READ_WRITE);
+
+ PlcReadRequest request = builder.build();
+ PlcReadResponse response = request.execute().get();
+
+
assertThat(response.getResponseCode("String")).isEqualTo(PlcResponseCode.OK);
+ }
+ }
+
+ @Test
+ void staticConfig() throws Exception {
+ DefaultPlcDriverManager driverManager = new
DefaultPlcDriverManager();
+
+ String options = params(
+ entry("discovery", "false"),
+ entry("server-certificate-file",
SERVER_CERTIFICATE.toString().replace("\\", "/")),
+ entry("key-store-file",
CLIENT_KEY_STORE.toString().replace("\\", "/")), // handle windows paths
+ entry("key-store-password", "changeit"),
+ entry("key-store-type", "pkcs12"),
+ entry("security-policy", SecurityPolicy.Basic256Sha256.name()),
+ entry("message-security", MessageSecurity.SIGN.name())
+ );
+ try (PlcConnection opcuaConnection =
driverManager.getConnection(tcpConnectionAddress + PARAM_DIVIDER
+ + options)) {
+ Condition<PlcConnection> is_connected = new
Condition<>(PlcConnection::isConnected, "is connected");
+ assertThat(opcuaConnection).is(is_connected);
+
+ PlcReadRequest.Builder builder =
opcuaConnection.readRequestBuilder()
+ .addTagAddress("String", STRING_IDENTIFIER_READ_WRITE);
+
+ PlcReadRequest request = builder.build();
+ PlcReadResponse response = request.execute().get();
+
+
assertThat(response.getResponseCode("String")).isEqualTo(PlcResponseCode.OK);
+ }
+ }
+
+ @Test
+ void securedConnectionWithoutTrustAnchorIsRejected() {
+ // No trust-store-file and no server-certificate-file: the driver
must fail
+ // closed rather than blindly trusting whatever certificate the
server presents.
+ String options = params(
+ entry("discovery", "false"),
+ entry("key-store-file",
CLIENT_KEY_STORE.toString().replace("\\", "/")),
+ entry("key-store-password", "changeit"),
+ entry("key-store-type", "pkcs12"),
+ entry("security-policy", SecurityPolicy.Basic256Sha256.name()),
+ entry("message-security", MessageSecurity.SIGN.name())
+ );
+ assertThatThrownBy(() ->
+ new
DefaultPlcDriverManager().getConnection(tcpConnectionAddress + PARAM_DIVIDER +
options))
+ .isInstanceOf(Exception.class);
+ }
+
+ @Test
+ void securedConnectionWithInsecureVerificationConnects() throws
Exception {
+ // With insecure-certificate-verification the driver must use the
permissive verifier
+ // (it wins over pinning), so trust is not checked. The server
certificate is still
+ // supplied because an encrypted policy needs the server's public
key to encrypt the
+ // OpenSecureChannel; only its trust verification is bypassed here.
+ String options = params(
+ entry("discovery", "false"),
+ entry("key-store-file",
CLIENT_KEY_STORE.toString().replace("\\", "/")),
+ entry("key-store-password", "changeit"),
+ entry("key-store-type", "pkcs12"),
+ entry("server-certificate-file",
SERVER_CERTIFICATE.toString().replace("\\", "/")),
+ entry("insecure-certificate-verification", "true"),
+ entry("security-policy", SecurityPolicy.Basic256Sha256.name()),
+ entry("message-security", MessageSecurity.SIGN.name())
+ );
+ try (PlcConnection opcuaConnection =
+ new
DefaultPlcDriverManager().getConnection(tcpConnectionAddress + PARAM_DIVIDER +
options)) {
+ assertThat(opcuaConnection.isConnected()).isTrue();
+
+ PlcReadResponse response = opcuaConnection.readRequestBuilder()
+ .addTagAddress("String", STRING_IDENTIFIER_READ_WRITE)
+ .build().execute().get();
+
+
assertThat(response.getResponseCode("String")).isEqualTo(PlcResponseCode.OK);
+ }
+ }
+ }
+
+ @Nested
+ class readWrite {
+ Map<String, Entry<String, Object>> tags = Map.ofEntries(
+ entry("Bool", entry(BOOL_IDENTIFIER_READ_WRITE, true)),
+ entry("Byte", entry(BYTE_IDENTIFIER_READ_WRITE + ";BYTE", (short)
3)),
+ entry("Double", entry(DOUBLE_IDENTIFIER_READ_WRITE, 0.5d)),
+ entry("Float", entry(FLOAT_IDENTIFIER_READ_WRITE, 0.5f)),
+ entry("Int16", entry(INT16_IDENTIFIER_READ_WRITE + ";INT", 1)),
+ entry("Int32", entry(INT32_IDENTIFIER_READ_WRITE, 42)),
+ entry("Int64", entry(INT64_IDENTIFIER_READ_WRITE, 42L)),
+ entry("Integer", entry(INTEGER_IDENTIFIER_READ_WRITE, -127)),
+ //entry("SByte", entry(SBYTE_IDENTIFIER_READ_WRITE, )),
+ entry("String", entry(STRING_IDENTIFIER_READ_WRITE, "Hello
Toddy!")),
+ entry("UInt16", entry(UINT16_IDENTIFIER_READ_WRITE + ";UINT",
65535)),
+ entry("UInt32", entry(UINT32_IDENTIFIER_READ_WRITE + ";UDINT",
101010101L)),
+ entry("UInt64", entry(UINT64_IDENTIFIER_READ_WRITE + ";ULINT", new
BigInteger("1337"))),
+ entry("UInteger", entry(UINTEGER_IDENTIFIER_READ_WRITE + ";UDINT",
102020202L)),
+ entry("BooleanArray", entry(BOOL_ARRAY_IDENTIFIER, new
boolean[]{true, true, true, true, true})),
+ // entry("ByteStringArray", entry(BYTE_STRING_ARRAY_IDENTIFIER,
null)),
+ entry("ByteArray", entry(BYTE_ARRAY_IDENTIFIER + ";BYTE", new
Short[]{1, 100, 100, 255, 123})),
+ entry("DoubleArray", entry(DOUBLE_ARRAY_IDENTIFIER, new
Double[]{1.0, 2.0, 3.0, 4.0, 5.0})),
+ entry("FloatArray", entry(FLOAT_ARRAY_IDENTIFIER, new
Float[]{1.0F, 2.0F, 3.0F, 4.0F, 5.0F})),
+ entry("Int16Array", entry(INT16_ARRAY_IDENTIFIER, new Short[]{1,
2, 3, 4, 5})),
+ entry("Int32Array", entry(INT32_ARRAY_IDENTIFIER, new Integer[]{1,
2, 3, 4, 5})),
+ entry("Int64Array", entry(INT64_ARRAY_IDENTIFIER, new Long[]{1L,
2L, 3L, 4L, 5L})),
+ entry("IntegerArray", entry(INT32_ARRAY_IDENTIFIER, new
Integer[]{1, 2, 3, 4, 5})),
+ entry("SByteArray", entry(SBYTE_ARRAY_IDENTIFIER, new Byte[]{1, 2,
3, 4, 5})),
+ entry("StringArray", entry(STRING_ARRAY_IDENTIFIER, new
String[]{"1", "2", "3", "4", "5"})),
+ entry("UInt16Array", entry(UINT16_ARRAY_IDENTIFIER + ";UINT", new
Short[]{1, 2, 3, 4, 5})),
+ entry("UInt32Array", entry(UINT32_ARRAY_IDENTIFIER + ";UDINT", new
Integer[]{1, 2, 3, 4, 5})),
+ entry("UInt64Array", entry(UINT64_ARRAY_IDENTIFIER + ";ULINT", new
Long[]{1L, 2L, 3L, 4L, 5L})),
+ entry(DOES_NOT_EXISTS_TAG_NAME,
entry(DOES_NOT_EXIST_IDENTIFIER_READ_WRITE, "11"))
+ );
+
+ @ParameterizedTest
+
@MethodSource("org.apache.plc4x.java.opcua.OpcuaPlcDriverTest#getConnectionSecurityPolicies")
+ public void readVariables(SecurityPolicy policy, MessageSecurity
messageSecurity) throws Exception {
+ String connectionString = getConnectionString(policy,
messageSecurity);
+ PlcConnection opcuaConnection = new
DefaultPlcDriverManager().getConnection(connectionString);
+ Condition<PlcConnection> is_connected = new
Condition<>(PlcConnection::isConnected, "is connected");
+ assertThat(opcuaConnection).is(is_connected);
+
+ PlcReadRequest.Builder builder =
opcuaConnection.readRequestBuilder();
+ tags.forEach((tagName, tagEntry) -> builder.addTagAddress(tagName,
tagEntry.getKey()));
+ PlcReadRequest request = builder.build();
+ PlcReadResponse response = request.execute().get(30,
TimeUnit.SECONDS);
+
+ SoftAssertions softly = new SoftAssertions();
+ tags.keySet().forEach(tag -> {
+ if (DOES_NOT_EXISTS_TAG_NAME.equals(tag)) {
+ softly.assertThat(response.getResponseCode(tag))
+ .describedAs("Tag %s should not exist and return
NOT_FOUND status", tag)
+ .isEqualTo(PlcResponseCode.NOT_FOUND);
+ } else {
+ softly.assertThat(response.getResponseCode(tag))
+ .describedAs("Tag %s should exist and return OK
status", tag)
+ .isEqualTo(PlcResponseCode.OK);
+ }
+ });
+ softly.assertAll();
+
+ opcuaConnection.close();
+ assertThat(opcuaConnection.isConnected()).isFalse();
+ }
+
+ @ParameterizedTest
+
@MethodSource("org.apache.plc4x.java.opcua.OpcuaPlcDriverTest#getConnectionSecurityPolicies")
+ public void writeVariables(SecurityPolicy policy, MessageSecurity
messageSecurity) throws Exception {
+ String connectionString = getConnectionString(policy,
messageSecurity);
+ PlcConnection opcuaConnection = new
DefaultPlcDriverManager().getConnection(connectionString);
+ Condition<PlcConnection> is_connected = new
Condition<>(PlcConnection::isConnected, "is connected");
+ assertThat(opcuaConnection).is(is_connected);
+
+ PlcWriteRequest.Builder builder =
opcuaConnection.writeRequestBuilder();
+ tags.forEach((tagName, tagEntry) -> {
+ try {
+ Object value = tagEntry.getValue();
+ if (value.getClass().isArray()) {
+ Object[] values = new Object[Array.getLength(value)];
+ for (int index = 0; index < Array.getLength(value);
index++) {
+ values[index] = Array.get(value, index);
+ }
+ builder.addTagAddress(tagName, tagEntry.getKey(),
values);
+ } else {
+ builder.addTagAddress(tagName, tagEntry.getKey(),
value);
+ }
+ } catch (PlcUnsupportedDataTypeException e) {
+ fail(e.toString());
+ }
+ });
+ PlcWriteRequest request = builder.build();
+ PlcWriteResponse response = request.execute().get(30,
TimeUnit.SECONDS);
+
+ SoftAssertions softly = new SoftAssertions();
+ tags.keySet().forEach(tag -> {
+ if (DOES_NOT_EXISTS_TAG_NAME.equals(tag)) {
+
softly.assertThat(response.getResponseCode(DOES_NOT_EXISTS_TAG_NAME))
+ .describedAs("Tag %s should not exist and return
NOT_FOUND status", tag)
+ .isEqualTo(PlcResponseCode.NOT_FOUND);
+ } else {
+ softly.assertThat(response.getResponseCode(tag))
+ .describedAs("Tag %s should exist and return OK
status", tag)
+ .isEqualTo(PlcResponseCode.OK);
+ }
+ });
+ softly.assertAll();
+
+ opcuaConnection.close();
+ assert !opcuaConnection.isConnected();
+ }
+
+ }
+
+ /*
+ Test added to test the synchronized TransactionHandler. (This was
disabled before being enabled again so it might be a candidate for those tests
not running properly on different platforms)
+ */
+ @Test
+ public void multipleThreads() throws Exception {
+ class ReadWorker extends Thread {
+ private final PlcConnection connection;
+ private final CountDownLatch latch;
+
+ public ReadWorker(PlcConnection opcuaConnection, CountDownLatch
latch) {
+ this.connection = opcuaConnection;
+ this.latch = latch;
+ }
+
+ @Override
+ public void run() {
+ try {
+ PlcReadRequest.Builder read_builder =
connection.readRequestBuilder();
+ read_builder.addTagAddress("Bool",
BOOL_IDENTIFIER_READ_WRITE);
+ PlcReadRequest read_request = read_builder.build();
+
+ for (int i = 0; i < 10; i++) {
+ PlcReadResponse read_response =
read_request.execute().get();
+
assertThat(read_response.getResponseCode("Bool")).isEqualTo(PlcResponseCode.OK);
+ }
+ } catch (ExecutionException e) {
+ LOGGER.error("run aborted", e);
+ } catch (InterruptedException e) {
+ LOGGER.error("thread interrupted", e);
+ Thread.currentThread().interrupt();
+ } finally {
+ this.latch.countDown();
+ }
+ }
+ }
+
+ class WriteWorker extends Thread {
+ private final PlcConnection connection;
+ private final CountDownLatch latch;
+
+ public WriteWorker(PlcConnection opcuaConnection, CountDownLatch
latch) {
+ this.connection = opcuaConnection;
+ this.latch = latch;
+ }
+
+ @Override
+ public void run() {
+ try {
+ PlcWriteRequest.Builder write_builder =
connection.writeRequestBuilder();
+ write_builder.addTagAddress("Bool",
BOOL_IDENTIFIER_READ_WRITE, true);
+ PlcWriteRequest write_request = write_builder.build();
+
+ for (int i = 0; i < 10; i++) {
+ PlcWriteResponse write_response =
write_request.execute().get();
+
assertThat(write_response.getResponseCode("Bool")).isEqualTo(PlcResponseCode.OK);
+ }
+ } catch (ExecutionException e) {
+ LOGGER.error("run aborted", e);
+ } catch (InterruptedException e) {
+ LOGGER.error("thread interrupted", e);
+ Thread.currentThread().interrupt();
+ } finally {
+ this.latch.countDown();
+ }
+ }
+ }
+
+
+ PlcConnection opcuaConnection = new
DefaultPlcDriverManager().getConnection(tcpConnectionAddress);
+ Condition<PlcConnection> is_connected = new
Condition<>(PlcConnection::isConnected, "is connected");
+ assertThat(opcuaConnection).is(is_connected);
+
+ CountDownLatch latch = new CountDownLatch(2);
+ ReadWorker read_worker = new ReadWorker(opcuaConnection, latch);
+ WriteWorker write_worker = new WriteWorker(opcuaConnection, latch);
+ read_worker.start();
+ write_worker.start();
+
+ latch.await();
+
+ opcuaConnection.close();
+ assert !opcuaConnection.isConnected();
+ }
+
+ private String getConnectionString(SecurityPolicy policy, MessageSecurity
messageSecurity) throws Exception {
+ switch (policy) {
+ case NONE:
+ return tcpConnectionAddress;
+
+ case Basic256:
+ case Basic128Rsa15:
+ case Basic256Sha256:
+ case Aes128_Sha256_RsaOaep:
+ case Aes256_Sha256_RsaPss:
+ String connectionParams = params(
+ // Trust is pinned via server-certificate-file below, so
skip discovery.
+ entry("discovery", "false"),
+ entry("key-store-file",
CLIENT_KEY_STORE.getAbsoluteFile().toString().replace("\\", "/")), // handle
windows paths
+ entry("key-store-password", "changeit"),
+ entry("key-store-type", "pkcs12"),
+ // Pin trust to the server certificate; the driver rejects
unknown certs by default.
+ entry("server-certificate-file",
SERVER_CERTIFICATE.toString().replace("\\", "/")),
+ entry("security-policy", policy.name()),
+ entry("message-security", messageSecurity.name())
+ );
+
+ return tcpConnectionAddress + PARAM_DIVIDER + connectionParams;
+ default:
+ throw new IllegalStateException();
+ }
+ }
+
+ private static Stream<Arguments> getConnectionSecurityPolicies() {
+ return Stream.of(
+ Arguments.of(SecurityPolicy.NONE, MessageSecurity.NONE),
+ Arguments.of(SecurityPolicy.NONE, MessageSecurity.SIGN),
+ Arguments.of(SecurityPolicy.NONE, MessageSecurity.SIGN_ENCRYPT),
+ //Arguments.of(SecurityPolicy.Basic256Sha256,
MessageSecurity.NONE),
+ Arguments.of(SecurityPolicy.Basic256Sha256, MessageSecurity.SIGN),
+ Arguments.of(SecurityPolicy.Basic256Sha256,
MessageSecurity.SIGN_ENCRYPT),
+ //Arguments.of(SecurityPolicy.Basic256, MessageSecurity.NONE),
+ Arguments.of(SecurityPolicy.Basic256, MessageSecurity.SIGN),
+ Arguments.of(SecurityPolicy.Basic256,
MessageSecurity.SIGN_ENCRYPT),
+ //Arguments.of(SecurityPolicy.Basic128Rsa15, MessageSecurity.NONE),
+ Arguments.of(SecurityPolicy.Basic128Rsa15, MessageSecurity.SIGN),
+ Arguments.of(SecurityPolicy.Basic128Rsa15,
MessageSecurity.SIGN_ENCRYPT),
+ //Arguments.of(SecurityPolicy.Aes128_Sha256_RsaOaep,
MessageSecurity.NONE),
+ Arguments.of(SecurityPolicy.Aes128_Sha256_RsaOaep,
MessageSecurity.SIGN),
+ Arguments.of(SecurityPolicy.Aes128_Sha256_RsaOaep,
MessageSecurity.SIGN_ENCRYPT),
+ //Arguments.of(SecurityPolicy.Aes256_Sha256_RsaPss,
MessageSecurity.NONE),
+ Arguments.of(SecurityPolicy.Aes256_Sha256_RsaPss,
MessageSecurity.SIGN),
+ Arguments.of(SecurityPolicy.Aes256_Sha256_RsaPss,
MessageSecurity.SIGN_ENCRYPT)
+ );
+ }
+
+ private static String params(Entry<String, String> ... entries) {
+ return Stream.of(entries)
+ .map(entry -> entry.getKey() + "=" +
URLEncoder.encode(entry.getValue(), Charset.defaultCharset()))
+ .collect(Collectors.joining(PARAM_DIVIDER));
+ }
+}
diff --git
a/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/security/PinnedCertificateVerifierTest.java
b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/security/PinnedCertificateVerifierTest.java
new file mode 100644
index 0000000000..1b5d56eae6
--- /dev/null
+++
b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/security/PinnedCertificateVerifierTest.java
@@ -0,0 +1,54 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.plc4x.java.opcua.security;
+
+import java.security.cert.CertificateException;
+import java.security.cert.X509Certificate;
+import org.apache.plc4x.java.opcua.TestCertificateGenerator;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class PinnedCertificateVerifierTest {
+
+ @Test
+ void acceptsTheExactPinnedCertificate() throws Exception {
+ X509Certificate pinned = TestCertificateGenerator.generate(2048,
"CN=server", 3600).getValue();
+ PinnedCertificateVerifier verifier = new
PinnedCertificateVerifier(pinned);
+ assertThatCode(() ->
verifier.checkCertificateTrusted(pinned)).doesNotThrowAnyException();
+ }
+
+ @Test
+ void rejectsADifferentCertificate() throws Exception {
+ X509Certificate pinned = TestCertificateGenerator.generate(2048,
"CN=server", 3600).getValue();
+ X509Certificate impostor = TestCertificateGenerator.generate(2048,
"CN=server", 3600).getValue();
+ PinnedCertificateVerifier verifier = new
PinnedCertificateVerifier(pinned);
+ assertThatThrownBy(() -> verifier.checkCertificateTrusted(impostor))
+ .isInstanceOf(CertificateException.class);
+ }
+
+ @Test
+ void rejectsNullCertificate() throws Exception {
+ X509Certificate pinned = TestCertificateGenerator.generate(2048,
"CN=server", 3600).getValue();
+ PinnedCertificateVerifier verifier = new
PinnedCertificateVerifier(pinned);
+ assertThatThrownBy(() -> verifier.checkCertificateTrusted(null))
+ .isInstanceOf(CertificateException.class);
+ }
+}
diff --git
a/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/security/RejectingCertificateVerifierTest.java
b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/security/RejectingCertificateVerifierTest.java
new file mode 100644
index 0000000000..55b1e9852b
--- /dev/null
+++
b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/security/RejectingCertificateVerifierTest.java
@@ -0,0 +1,42 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.plc4x.java.opcua.security;
+
+import java.security.cert.CertificateException;
+import java.security.cert.X509Certificate;
+import org.apache.plc4x.java.opcua.TestCertificateGenerator;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class RejectingCertificateVerifierTest {
+
+ @Test
+ void rejectsAnyCertificate() {
+ X509Certificate certificate = TestCertificateGenerator.generate(2048,
"CN=server", 3600).getValue();
+ assertThatThrownBy(() -> new
RejectingCertificateVerifier().checkCertificateTrusted(certificate))
+ .isInstanceOf(CertificateException.class);
+ }
+
+ @Test
+ void rejectsNullCertificate() {
+ assertThatThrownBy(() -> new
RejectingCertificateVerifier().checkCertificateTrusted(null))
+ .isInstanceOf(CertificateException.class);
+ }
+}
diff --git
a/plc4j/drivers/opcua/src/test/java/org/eclipse/milo/examples/server/TestMiloServer.java
b/plc4j/drivers/opcua/src/test/java/org/eclipse/milo/examples/server/TestMiloServer.java
index e2f19386df..c6e946cf48 100644
---
a/plc4j/drivers/opcua/src/test/java/org/eclipse/milo/examples/server/TestMiloServer.java
+++
b/plc4j/drivers/opcua/src/test/java/org/eclipse/milo/examples/server/TestMiloServer.java
@@ -39,6 +39,7 @@ import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.eclipse.milo.opcua.stack.core.Stack;
import org.eclipse.milo.opcua.sdk.server.OpcUaServer;
import org.eclipse.milo.opcua.sdk.server.api.config.OpcUaServerConfig;
import org.eclipse.milo.opcua.sdk.server.identity.CompositeValidator;
@@ -81,6 +82,12 @@ public class TestMiloServer {
// Required for SecurityPolicy.Aes256_Sha256_RsaPss
Security.addProvider(new BouncyCastleProvider());
+ // The integration test opens many connections back-to-back (one per
security
+ // policy, plus reconnection/subscription smoke tests). The stack's
default
+ // connection rate limiter rejects more than 4 connection attempts per
second,
+ // which has nothing to do with the driver under test, so disable it
here.
+ Stack.ConnectionLimits.RATE_LIMIT_ENABLED = false;
+
try {
NonceUtil.blockUntilSecureRandomSeeded(10, TimeUnit.SECONDS);
} catch (ExecutionException | InterruptedException | TimeoutException
e) {
diff --git
a/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/ConnectionBase.java
b/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/ConnectionBase.java
index 62f3d89dd4..1d958f9e9a 100644
---
a/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/ConnectionBase.java
+++
b/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/ConnectionBase.java
@@ -20,6 +20,7 @@ package org.apache.plc4x.java.spi.drivers;
import org.apache.plc4x.java.api.EventPlcConnection;
import org.apache.plc4x.java.api.PlcConnection;
+import org.apache.plc4x.java.api.authentication.PlcAuthentication;
import org.apache.plc4x.java.api.exceptions.PlcConnectionException;
import org.apache.plc4x.java.api.exceptions.PlcInvalidTagException;
import org.apache.plc4x.java.api.exceptions.PlcRuntimeException;
@@ -75,6 +76,10 @@ public abstract class ConnectionBase<C extends
Configuration> implements PlcConn
private String protocolName = "unknown";
private String transportCode = "unknown";
private String transportName = "unknown";
+ // Authentication passed to PlcDriverManager.getConnection(url,
authentication), set by
+ // DriverBase after construction. Null when the caller didn't supply one
(credentials may
+ // then still come from the connection-string parameters).
+ private PlcAuthentication authentication;
// Event listeners for connection state changes
private final List<EventListener> eventListeners = new ArrayList<>();
@@ -222,6 +227,18 @@ public abstract class ConnectionBase<C extends
Configuration> implements PlcConn
this.transportName = transportName;
}
+ void setAuthentication(PlcAuthentication authentication) {
+ this.authentication = authentication;
+ }
+
+ /**
+ * The authentication supplied to {@code getConnection(url,
authentication)}, or {@code null}
+ * if none was given. Drivers should prefer this over connection-string
credentials.
+ */
+ protected PlcAuthentication getAuthentication() {
+ return authentication;
+ }
+
@Override
public PlcConnectionMetadata getMetadata() {
return new PlcConnectionMetadata() {
diff --git
a/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/DriverBase.java
b/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/DriverBase.java
index 1edb91a05e..b9d21e2598 100644
---
a/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/DriverBase.java
+++
b/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/DriverBase.java
@@ -241,6 +241,9 @@ public abstract class DriverBase implements PlcDriver {
ConnectionBase<?> connection = getConnection(configuration,
transportInstance, auditLog);
connection.setConnectionInfo(getProtocolCode(), getProtocolName(),
transport.getTransportCode(), transport.getTransportName());
+ // Hand the caller-supplied authentication to the connection;
previously it was accepted
+ // by this method but never propagated, so programmatic credentials
were silently ignored.
+ connection.setAuthentication(plcAuthentication);
return connection;
}
diff --git
a/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/messages/DefaultPlcWriteRequest.java
b/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/messages/DefaultPlcWriteRequest.java
index 0a90870b44..f52f0e0954 100644
---
a/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/messages/DefaultPlcWriteRequest.java
+++
b/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/messages/DefaultPlcWriteRequest.java
@@ -147,12 +147,15 @@ public class DefaultPlcWriteRequest implements
PlcWriteRequest {
}
private PlcValue parsePlcValue(PlcTag tag, Object[] values) {
- if ((values == null) || (values.length != 1)) {
- throw new PlcRuntimeException("Expecting 1 item, but got " +
(values != null ? values.length : "null"));
+ if ((values == null) || (values.length == 0)) {
+ throw new PlcRuntimeException("Expecting at least 1 item, but
got " + (values != null ? values.length : "null"));
}
- if (values[0] instanceof PlcValue) {
+ // A single, already-built PlcValue is passed through unchanged.
+ if ((values.length == 1) && (values[0] instanceof PlcValue)) {
return (PlcValue) values[0];
}
+ // One or more raw values: let the value handler build the
PlcValue (a PlcList
+ // for arrays). Requiring exactly one value here made array writes
impossible.
Objects.requireNonNull(valueHandler, "valueHandler must not be
null");
return valueHandler.newPlcValue(tag, values);
}
diff --git
a/plc4j/spi/values/src/main/java/org/apache/plc4x/java/spi/values/DefaultPlcValueHandler.java
b/plc4j/spi/values/src/main/java/org/apache/plc4x/java/spi/values/DefaultPlcValueHandler.java
index 328bba120f..89cf479269 100644
---
a/plc4j/spi/values/src/main/java/org/apache/plc4x/java/spi/values/DefaultPlcValueHandler.java
+++
b/plc4j/spi/values/src/main/java/org/apache/plc4x/java/spi/values/DefaultPlcValueHandler.java
@@ -69,9 +69,16 @@ public class DefaultPlcValueHandler implements
PlcValueHandler {
}
return ofElement(tag.getPlcValueType(), values[0]);
}
- //
+ // More than one value, but the tag carries no array metadata.
Some drivers
+ // (e.g. OPC UA) don't encode array size in the tag address, so we
can't rely on
+ // getArrayInfo() to recognise an array write. Rather than reject
it, build a
+ // PlcList by inferring each element from the passed values.
else {
- throw new PlcRuntimeException("The type is not an array type,
but more than one value was passed in as argument");
+ List<PlcValue> plcValues = new ArrayList<>(values.length);
+ for (Object value : values) {
+ plcValues.add(ofElement(tag.getPlcValueType(), value));
+ }
+ return new PlcList(plcValues);
}
}
// In all other cases, we're dealing with an array type and this needs
to be handled separately.
@@ -120,8 +127,12 @@ public class DefaultPlcValueHandler implements
PlcValueHandler {
}
private static PlcValue ofElement(PlcValueType type, Object value) {
- // This is a temporary hack for drivers that don't have type
information in their tags (ADS)
- if (type == null) {
+ // For drivers that don't carry type information in their tags, infer
the PlcValue
+ // from the Java object. Some drivers signal "no type" with a Java
null (e.g. ADS),
+ // others with the PlcValueType.NULL enum (e.g. OPC UA tags without a
type suffix);
+ // both mean the same thing, so treat them alike. Mapping the NULL
enum onto the
+ // switch below would yield a PlcNull and silently discard the value
being written.
+ if (type == null || type == PlcValueType.NULL) {
return of(value);
}
return switch (type) {
diff --git
a/plc4j/transports/api/src/main/java/org/apache/plc4x/java/spi/transports/api/BaseTransportInstance.java
b/plc4j/transports/api/src/main/java/org/apache/plc4x/java/spi/transports/api/BaseTransportInstance.java
index c5defcc7e8..c9a017fc65 100644
---
a/plc4j/transports/api/src/main/java/org/apache/plc4x/java/spi/transports/api/BaseTransportInstance.java
+++
b/plc4j/transports/api/src/main/java/org/apache/plc4x/java/spi/transports/api/BaseTransportInstance.java
@@ -29,6 +29,10 @@ public abstract class BaseTransportInstance<T extends
TransportConfiguration> im
private final T transportConfig;
private final AuditLog auditLog;
+ // The driver-config: the URL remainder after the transport-config
(host/port),
+ // set by the transport that parsed the address. Empty unless the transport
+ // supplies one. See TransportInstance#getDriverConfig().
+ private String driverConfig = "";
public BaseTransportInstance(T transportConfig, AuditLog auditLog) {
this.transportConfig = Objects.requireNonNull(transportConfig);
@@ -40,6 +44,20 @@ public abstract class BaseTransportInstance<T extends
TransportConfiguration> im
return transportConfig;
}
+ @Override
+ public String getDriverConfig() {
+ return driverConfig;
+ }
+
+ /**
+ * Sets the driver-config parsed from the connection URL. Transports call
this
+ * right after constructing the instance with the portion of the address
they did
+ * not consume (e.g. an OPC UA {@code /milo} path).
+ */
+ public void setDriverConfig(String driverConfig) {
+ this.driverConfig = driverConfig == null ? "" : driverConfig;
+ }
+
public AuditLog getAuditLog() {
return auditLog;
}
diff --git
a/plc4j/transports/api/src/main/java/org/apache/plc4x/java/spi/transports/api/TransportInstance.java
b/plc4j/transports/api/src/main/java/org/apache/plc4x/java/spi/transports/api/TransportInstance.java
index 992190aa9c..a9505be3af 100644
---
a/plc4j/transports/api/src/main/java/org/apache/plc4x/java/spi/transports/api/TransportInstance.java
+++
b/plc4j/transports/api/src/main/java/org/apache/plc4x/java/spi/transports/api/TransportInstance.java
@@ -31,6 +31,22 @@ public interface TransportInstance<T extends
TransportConfiguration> {
*/
T getConfiguration();
+ /**
+ * Returns the driver-config: the part of the connection URL after the
+ * transport-config (host/port) that the transport consumes and before the
+ * parameter section. Unlike the transport-config, this portion is
meaningful
+ * only to the driver. For example, in
+ * {@code opcua:tcp://localhost:4840/milo?discovery=false} (or the
equivalent
+ * {@code opcua:tls://...}) the transport consumes {@code localhost:4840}
and
+ * this returns {@code /milo}. Transports whose address carries no such
trailing
+ * part return an empty string.
+ *
+ * @return the driver-config, never {@code null}
+ */
+ default String getDriverConfig() {
+ return "";
+ }
+
/**
* Executes a check if the underlying transport is still open.
*
diff --git
a/plc4j/transports/tcp/src/main/java/org/apache/plc4x/java/transport/tcp/TcpTransport.java
b/plc4j/transports/tcp/src/main/java/org/apache/plc4x/java/transport/tcp/TcpTransport.java
index 874193c881..a395c1774a 100644
---
a/plc4j/transports/tcp/src/main/java/org/apache/plc4x/java/transport/tcp/TcpTransport.java
+++
b/plc4j/transports/tcp/src/main/java/org/apache/plc4x/java/transport/tcp/TcpTransport.java
@@ -37,7 +37,7 @@ public class TcpTransport implements
Transport<TcpTransportConfiguration> {
private static final Logger LOGGER =
LoggerFactory.getLogger(TcpTransport.class);
private static final Pattern TRANSPORT_TCP_PATTERN = Pattern.compile(
-
"^((?<ip>[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3})|(?<hostname>[a-zA-Z0-9.\\-]+))(:(?<port>[0-9]{1,5}))?.*");
+
"^((?<ip>[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3})|(?<hostname>[a-zA-Z0-9.\\-]+))(:(?<port>[0-9]{1,5}))?(?<driverConfig>.*)");
@Override
@@ -69,6 +69,9 @@ public class TcpTransport implements
Transport<TcpTransportConfiguration> {
String ip = matcher.group("ip");
String hostname = matcher.group("hostname");
String portString = matcher.group("port");
+ // Everything after host:port (before the '?' parameter section, which
the framework
+ // already stripped) is the driver-config, e.g. the OPC UA "/milo"
path.
+ String driverConfig = matcher.group("driverConfig");
// If the port wasn't specified, try to get a default port from the
configuration.
int port;
@@ -83,7 +86,9 @@ public class TcpTransport implements
Transport<TcpTransportConfiguration> {
InetSocketAddress remoteAddress = new InetSocketAddress((ip != null) ?
ip : hostname, port);
LOGGER.debug("Creating TCP transport instance for {}:{}", (ip != null)
? ip : hostname, port);
- return new TcpTransportInstance(remoteAddress,
tcpTransportConfiguration, auditLog);
+ TcpTransportInstance instance = new
TcpTransportInstance(remoteAddress, tcpTransportConfiguration, auditLog);
+ instance.setDriverConfig(driverConfig);
+ return instance;
}
}
diff --git
a/plc4j/transports/tls/src/main/java/org/apache/plc4x/java/transport/tls/TlsTransport.java
b/plc4j/transports/tls/src/main/java/org/apache/plc4x/java/transport/tls/TlsTransport.java
index 2e71d9398a..aeaae8ab24 100644
---
a/plc4j/transports/tls/src/main/java/org/apache/plc4x/java/transport/tls/TlsTransport.java
+++
b/plc4j/transports/tls/src/main/java/org/apache/plc4x/java/transport/tls/TlsTransport.java
@@ -42,7 +42,7 @@ public class TlsTransport implements
Transport<TlsTransportConfiguration> {
private static final Logger LOGGER =
LoggerFactory.getLogger(TlsTransport.class);
private static final Pattern TRANSPORT_TLS_PATTERN = Pattern.compile(
-
"^((?<ip>[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3})|(?<hostname>[a-zA-Z0-9.\\-]+))(:(?<port>[0-9]{1,5}))?.*");
+
"^((?<ip>[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3})|(?<hostname>[a-zA-Z0-9.\\-]+))(:(?<port>[0-9]{1,5}))?(?<driverConfig>.*)");
@Override
public String getTransportCode() {
@@ -73,6 +73,8 @@ public class TlsTransport implements
Transport<TlsTransportConfiguration> {
String ip = matcher.group("ip");
String hostname = matcher.group("hostname");
String portString = matcher.group("port");
+ // Everything after host:port is the driver-config (e.g. an OPC UA
"/milo" path).
+ String driverConfig = matcher.group("driverConfig");
// If the port wasn't specified, try to get a default port from the
configuration.
int port;
@@ -89,7 +91,9 @@ public class TlsTransport implements
Transport<TlsTransportConfiguration> {
LOGGER.debug("Creating TLS transport instance for {}:{}
(verify-ssl={})",
(ip != null) ? ip : hostname, port,
tlsTransportConfiguration.isVerifySsl());
- return new TlsTransportInstance(remoteAddress,
tlsTransportConfiguration, auditLog);
+ TlsTransportInstance instance = new
TlsTransportInstance(remoteAddress, tlsTransportConfiguration, auditLog);
+ instance.setDriverConfig(driverConfig);
+ return instance;
}
}