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-extras.git
commit afc56ed6b0c457110a32d98251f44bebb6731264 Author: Christofer Dutz <[email protected]> AuthorDate: Tue Jun 23 19:14:09 2026 +0200 feat: Updated the plc4x server and driver to operate via TLS and to support authentication. --- RELEASE_NOTES | 19 ++ plc4j/tools/plc4x-server/pom.xml | 22 +- .../plc4x/java/tools/plc4xserver/Plc4xServer.java | 285 ++++++++++++++----- .../java/tools/plc4xserver/ServerTlsContext.java | 153 +++++++++++ .../plc4xserver/protocol/Plc4xServerAdapter.java | 302 ++++++++++++--------- .../protocol/SocketTransportInstance.java | 121 +++++++++ .../java/tools/plc4xserver/Plc4xServerTest.java | 58 ++-- 7 files changed, 733 insertions(+), 227 deletions(-) diff --git a/RELEASE_NOTES b/RELEASE_NOTES index 1fc99b6..9dc6606 100644 --- a/RELEASE_NOTES +++ b/RELEASE_NOTES @@ -4,9 +4,28 @@ New Features ------------ +- PLC4X Server: Now supports TLS as a transport (the default). + When no keystore is configured, the server generates an + ephemeral self-signed certificate on startup and logs its + SHA-256 fingerprint so clients can pin/trust it. Plaintext TCP + remains available as an explicit opt-in + (plc4x.server.plaintext=true). +- PLC4X Server: Now enforces mandatory username/password + authentication; no operation is permitted before a successful + auth handshake. If no credentials are configured, the server + starts with the default user "toddy" and a generated secure + password that is printed to the console once at startup. + Configured credentials are never logged. Incompatible changes -------------------- +- PLC4X Server: Was migrated from the SPI2 (Netty) core to SPI3. + It now listens via a plain ServerSocket/SSLServerSocket and no + longer depends on Netty. +- PLC4X Server: Authentication is now mandatory and TLS is the + default transport. Existing clients must provide credentials + and connect via TLS (or explicitly opt into plaintext). See the + matching note in the core 'plc4x' driver release notes. Bug Fixes --------- diff --git a/plc4j/tools/plc4x-server/pom.xml b/plc4j/tools/plc4x-server/pom.xml index 9366bea..42daebe 100644 --- a/plc4j/tools/plc4x-server/pom.xml +++ b/plc4j/tools/plc4x-server/pom.xml @@ -120,15 +120,25 @@ <scope>runtime</scope> </dependency> + <!-- SPI3 transport primitives (RingBuffer / TransportInstance) used to adapt accepted + sockets to the PLC4X message codec. --> <dependency> - <groupId>io.netty</groupId> - <artifactId>netty-buffer</artifactId> - <version>4.1.123.Final</version> + <groupId>org.apache.plc4x</groupId> + <artifactId>plc4j-transports-api</artifactId> + <version>${plc4x.version}</version> + </dependency> + + <!-- Bouncy Castle is used to generate an ephemeral self-signed server certificate when + no keystore is configured. --> + <dependency> + <groupId>org.bouncycastle</groupId> + <artifactId>bcprov-jdk18on</artifactId> + <version>1.84</version> </dependency> <dependency> - <groupId>io.netty</groupId> - <artifactId>netty-transport</artifactId> - <version>4.1.123.Final</version> + <groupId>org.bouncycastle</groupId> + <artifactId>bcpkix-jdk18on</artifactId> + <version>1.84</version> </dependency> <dependency> diff --git a/plc4j/tools/plc4x-server/src/main/java/org/apache/plc4x/java/tools/plc4xserver/Plc4xServer.java b/plc4j/tools/plc4x-server/src/main/java/org/apache/plc4x/java/tools/plc4xserver/Plc4xServer.java index 2227ec8..40657f6 100644 --- a/plc4j/tools/plc4x-server/src/main/java/org/apache/plc4x/java/tools/plc4xserver/Plc4xServer.java +++ b/plc4j/tools/plc4x-server/src/main/java/org/apache/plc4x/java/tools/plc4xserver/Plc4xServer.java @@ -21,54 +21,84 @@ package org.apache.plc4x.java.tools.plc4xserver; import static java.lang.Runtime.getRuntime; -import io.netty.bootstrap.ServerBootstrap; -import io.netty.buffer.ByteBuf; -import io.netty.channel.ChannelFuture; -import io.netty.channel.ChannelInitializer; -import io.netty.channel.ChannelOption; -import io.netty.channel.ChannelPipeline; -import io.netty.channel.EventLoopGroup; -import io.netty.channel.nio.NioEventLoopGroup; -import io.netty.channel.socket.SocketChannel; -import io.netty.channel.socket.nio.NioServerSocketChannel; import java.io.IOException; import java.net.ServerSocket; -import java.util.Arrays; +import java.net.Socket; +import java.security.SecureRandom; +import java.util.Base64; import java.util.Optional; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.concurrent.Future; -import java.util.function.ToIntFunction; +import javax.net.ssl.SSLServerSocket; +import javax.net.ssl.SSLServerSocketFactory; +import org.apache.plc4x.java.api.PlcConnectionManager; import org.apache.plc4x.java.api.exceptions.PlcRuntimeException; +import org.apache.plc4x.java.plc4x.Plc4xMessageCodec; import org.apache.plc4x.java.plc4x.readwrite.Constants; -import org.apache.plc4x.java.plc4x.readwrite.Plc4xMessage; -import org.apache.plc4x.java.spi.connection.GeneratedProtocolMessageCodec; -import org.apache.plc4x.java.spi.generation.ByteOrder; +import org.apache.plc4x.java.spi.drivers.exceptions.MessageCodecException; import org.apache.plc4x.java.tools.plc4xserver.protocol.Plc4xServerAdapter; +import org.apache.plc4x.java.tools.plc4xserver.protocol.SocketTransportInstance; +import org.apache.plc4x.java.utils.cache.CachedPlcConnectionManager; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +/** + * TCP/TLS server that relays {@code plc4x} proxy requests to PLCs reachable from this machine. + * + * <p>Security model:</p> + * <ul> + * <li><b>Authentication is mandatory.</b> Every connection must present a username and + * password before any operation. If none are configured the server uses the default user + * {@code toddy} with a freshly generated secure password that is printed to the console once + * at startup. Explicitly configured credentials are never logged.</li> + * <li><b>TLS is the default transport.</b> Without a configured keystore the server generates + * an ephemeral self-signed certificate and prints its SHA-256 fingerprint so clients can pin + * it. Plaintext TCP is available as an explicit opt-in for trusted networks/testing.</li> + * </ul> + */ public class Plc4xServer { public static final String SERVER_PORT_PROPERTY = "plc4x.server.port"; public static final String SERVER_PORT_ENVIRONMENT_VARIABLE = "PLC4X_SERVER_PORT"; + public static final String SERVER_USERNAME_PROPERTY = "plc4x.server.username"; + public static final String SERVER_USERNAME_ENVIRONMENT_VARIABLE = "PLC4X_SERVER_USERNAME"; + public static final String SERVER_PASSWORD_PROPERTY = "plc4x.server.password"; + public static final String SERVER_PASSWORD_ENVIRONMENT_VARIABLE = "PLC4X_SERVER_PASSWORD"; + public static final String SERVER_PLAINTEXT_PROPERTY = "plc4x.server.plaintext"; + public static final String SERVER_PLAINTEXT_ENVIRONMENT_VARIABLE = "PLC4X_SERVER_PLAINTEXT"; + public static final String SERVER_KEYSTORE_PROPERTY = "plc4x.server.keystore"; + public static final String SERVER_KEYSTORE_PASSWORD_PROPERTY = "plc4x.server.keystore-password"; + + public static final String DEFAULT_USERNAME = "toddy"; public static int DEFAULT_PORT = Constants.PLC4XTCPDEFAULTPORT; - private static final Logger LOG = LoggerFactory.getLogger(Plc4xServerAdapter.class); + private static final Logger LOG = LoggerFactory.getLogger(Plc4xServer.class); + + private final PlcConnectionManager connectionManager = CachedPlcConnectionManager.getBuilder().build(); - private EventLoopGroup loopGroup; - private EventLoopGroup workerGroup; - private ChannelFuture channelFuture; private Integer port; + private String username; + private String password; + private boolean plaintext = false; + private String keystorePath; + private String keystorePassword; + + private ServerSocket serverSocket; + private Thread acceptThread; + private ExecutorService connectionExecutor; + private volatile boolean running = false; public static void main(String[] args) throws Exception { final Plc4xServer server = new Plc4xServer(); Future<Void> serverFuture = server.start( - Arrays.stream(args).findFirst() // port number given as first command line argument - .or(() -> Optional.ofNullable(System.getProperty(SERVER_PORT_PROPERTY))) - .or(() -> Optional.ofNullable(System.getenv(SERVER_PORT_ENVIRONMENT_VARIABLE))) - .map(Integer::parseInt) - .orElse(DEFAULT_PORT) + Arrays_findFirst(args) // port number given as first command line argument + .or(() -> Optional.ofNullable(System.getProperty(SERVER_PORT_PROPERTY))) + .or(() -> Optional.ofNullable(System.getenv(SERVER_PORT_ENVIRONMENT_VARIABLE))) + .map(Integer::parseInt) + .orElse(DEFAULT_PORT) ); CompletableFuture<Void> serverRunning = new CompletableFuture<>(); getRuntime().addShutdownHook(new Thread(() -> serverRunning.complete(null))); @@ -86,89 +116,196 @@ public class Plc4xServer { } } + private static Optional<String> Arrays_findFirst(String[] args) { + return args.length > 0 ? Optional.of(args[0]) : Optional.empty(); + } + public Integer getPort() { return port; } + /** + * The effective username clients must authenticate with. + */ + public String getUsername() { + return username; + } + + /** + * The effective password clients must authenticate with (generated if none was configured). + */ + public String getPassword() { + return password; + } + + public void setUsername(String username) { + this.username = username; + } + + public void setPassword(String password) { + this.password = password; + } + + public void setPlaintext(boolean plaintext) { + this.plaintext = plaintext; + } + + public void setKeystore(String keystorePath, String keystorePassword) { + this.keystorePath = keystorePath; + this.keystorePassword = keystorePassword; + } + public Future<Void> start() { return start(0); } public Future<Void> start(int port) { - if (port == 0) { - this.port = findRandomFreePort(); - } else { - this.port = port; - } - - if (loopGroup != null) { + if (running) { return CompletableFuture.completedFuture(null); } + this.port = (port == 0) ? findRandomFreePort() : port; + + resolveCredentials(); + resolvePlaintext(); - loopGroup = new NioEventLoopGroup(); - workerGroup = new NioEventLoopGroup(); + try { + serverSocket = plaintext ? new ServerSocket(this.port) : createTlsServerSocket(this.port); + } catch (Exception e) { + return CompletableFuture.failedFuture( + new PlcRuntimeException("Failed to start PLC4X server", e)); + } - channelFuture = new ServerBootstrap() - .group(loopGroup, workerGroup) - .channel(NioServerSocketChannel.class) - .childHandler(new SocketChannelChannelInitializer()) - .option(ChannelOption.SO_BACKLOG, 128) - .childOption(ChannelOption.SO_KEEPALIVE, true) - .bind(this.port); + running = true; + connectionExecutor = Executors.newVirtualThreadPerTaskExecutor(); + acceptThread = new Thread(this::acceptLoop, "Plc4xServer-Accept"); + acceptThread.setDaemon(true); + acceptThread.start(); - return channelFuture; + LOG.info("PLC4X server listening on port {} ({})", this.port, plaintext ? "plaintext TCP" : "TLS"); + return CompletableFuture.completedFuture(null); } public void stop() { - if (workerGroup == null) { - return; + running = false; + if (serverSocket != null) { + try { + serverSocket.close(); + } catch (IOException e) { + LOG.debug("Error closing server socket", e); + } + serverSocket = null; } + if (connectionExecutor != null) { + connectionExecutor.shutdownNow(); + connectionExecutor = null; + } + } - channelFuture.cancel(true); - - workerGroup.shutdownGracefully(); - loopGroup.shutdownGracefully(); - - workerGroup = null; - loopGroup = null; + private void acceptLoop() { + while (running) { + final Socket socket; + try { + socket = serverSocket.accept(); + } catch (IOException e) { + if (running) { + LOG.debug("Accept failed", e); + } + return; + } + connectionExecutor.submit(() -> handleConnection(socket)); + } } - private static class SocketChannelChannelInitializer extends ChannelInitializer<SocketChannel> { + private void handleConnection(Socket socket) { + try (socket) { + SocketTransportInstance transport = new SocketTransportInstance(socket); + // The codec needs a message handler and the adapter needs the codec to send replies, + // so wire them through a one-slot holder to break the construction cycle. + final Plc4xServerAdapter[] holder = new Plc4xServerAdapter[1]; + Plc4xMessageCodec codec = new Plc4xMessageCodec(transport, msg -> holder[0].handle(msg)); + holder[0] = new Plc4xServerAdapter(connectionManager, codec, username, password); - @Override - public void initChannel(SocketChannel channel) { - ChannelPipeline pipeline = channel.pipeline(); - pipeline.addLast( - new GeneratedProtocolMessageCodec<>( - Plc4xMessage.class, - Plc4xMessage::staticParse, - ByteOrder.BIG_ENDIAN, - new ByteLengthEstimator(), - null - ) - ); - pipeline.addLast(new Plc4xServerAdapter()); + while (running && transport.fill()) { + codec.processIncomingData(); + } + } catch (MessageCodecException e) { + LOG.debug("Protocol error - dropping connection", e); + } catch (IOException e) { + LOG.debug("Connection I/O error", e); } } - private static class ByteLengthEstimator implements ToIntFunction<ByteBuf> { + private SSLServerSocket createTlsServerSocket(int port) throws Exception { + ServerTlsContext tlsContext; + String keystore = keystorePath != null ? keystorePath : System.getProperty(SERVER_KEYSTORE_PROPERTY); + if (keystore != null) { + String pwd = keystorePassword != null + ? keystorePassword : System.getProperty(SERVER_KEYSTORE_PASSWORD_PROPERTY); + tlsContext = ServerTlsContext.fromKeystore(keystore, pwd, null); + } else { + tlsContext = ServerTlsContext.selfSigned(); + LOG.info("No keystore configured - generated an ephemeral self-signed certificate."); + LOG.info("Server certificate SHA-256 fingerprint: {}", tlsContext.getCertificateFingerprint()); + } + SSLServerSocketFactory factory = tlsContext.getSslContext().getServerSocketFactory(); + return (SSLServerSocket) factory.createServerSocket(port); + } - @Override - public int applyAsInt(ByteBuf byteBuf) { - if (byteBuf.readableBytes() >= 3) { - return byteBuf.getUnsignedShort(byteBuf.readerIndex() + 1); + /** + * Resolves the effective credentials from explicit config, system properties or environment, + * falling back to the default user with a generated password. Generated passwords are printed + * once; configured passwords are never logged. + */ + private void resolveCredentials() { + if (username == null) { + username = Optional.ofNullable(System.getProperty(SERVER_USERNAME_PROPERTY)) + .or(() -> Optional.ofNullable(System.getenv(SERVER_USERNAME_ENVIRONMENT_VARIABLE))) + .orElse(DEFAULT_USERNAME); + } + boolean generated = false; + if (password == null) { + String configured = Optional.ofNullable(System.getProperty(SERVER_PASSWORD_PROPERTY)) + .or(() -> Optional.ofNullable(System.getenv(SERVER_PASSWORD_ENVIRONMENT_VARIABLE))) + .orElse(null); + if (configured != null) { + password = configured; + } else { + password = generateSecurePassword(); + generated = true; } - return -1; + } + if (generated) { + // Intentionally printed to stdout (not just the log) so it is visible on first start. + System.out.println("============================================================"); + System.out.println(" No PLC4X server credentials configured - generated defaults:"); + System.out.println(" username: " + username); + System.out.println(" password: " + password); + System.out.println(" Provide plc4x.server.username/password to set your own."); + System.out.println("============================================================"); + } else { + LOG.info("Using configured credentials for user '{}'", username); } } + private void resolvePlaintext() { + if (!plaintext) { + plaintext = Boolean.parseBoolean(System.getProperty(SERVER_PLAINTEXT_PROPERTY)) + || Boolean.parseBoolean(System.getenv(SERVER_PLAINTEXT_ENVIRONMENT_VARIABLE) == null + ? "false" : System.getenv(SERVER_PLAINTEXT_ENVIRONMENT_VARIABLE)); + } + } + + private static String generateSecurePassword() { + byte[] bytes = new byte[24]; + new SecureRandom().nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + private static int findRandomFreePort() { - final int port; try (ServerSocket socket = new ServerSocket(0)) { - port = socket.getLocalPort(); + return socket.getLocalPort(); } catch (IOException e) { - throw new RuntimeException("Couldn't find any free port.", e); + throw new PlcRuntimeException("Couldn't find any free port.", e); } - return port; } } diff --git a/plc4j/tools/plc4x-server/src/main/java/org/apache/plc4x/java/tools/plc4xserver/ServerTlsContext.java b/plc4j/tools/plc4x-server/src/main/java/org/apache/plc4x/java/tools/plc4xserver/ServerTlsContext.java new file mode 100644 index 0000000..3a0b8a2 --- /dev/null +++ b/plc4j/tools/plc4x-server/src/main/java/org/apache/plc4x/java/tools/plc4xserver/ServerTlsContext.java @@ -0,0 +1,153 @@ +/* + * 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.tools.plc4xserver; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import java.util.HexFormat; + +/** + * Holds the {@link SSLContext} the server listens with, plus the SHA-256 fingerprint of the + * presented certificate. Either loads an operator-provided keystore or, when none is configured, + * generates an ephemeral self-signed certificate so TLS works with zero configuration. The + * fingerprint lets a client pin/trust the auto-generated identity. + */ +public final class ServerTlsContext { + + private final SSLContext sslContext; + private final String certificateFingerprint; + private final boolean selfSigned; + + private ServerTlsContext(SSLContext sslContext, String certificateFingerprint, boolean selfSigned) { + this.sslContext = sslContext; + this.certificateFingerprint = certificateFingerprint; + this.selfSigned = selfSigned; + } + + public SSLContext getSslContext() { + return sslContext; + } + + public String getCertificateFingerprint() { + return certificateFingerprint; + } + + public boolean isSelfSigned() { + return selfSigned; + } + + /** + * Builds a TLS context from an existing PKCS12/JKS keystore. + */ + public static ServerTlsContext fromKeystore(String keystorePath, String keystorePassword, + String keystoreType) throws Exception { + char[] password = keystorePassword == null ? new char[0] : keystorePassword.toCharArray(); + KeyStore keyStore = KeyStore.getInstance(keystoreType == null ? "PKCS12" : keystoreType); + try (var in = new java.io.FileInputStream(keystorePath)) { + keyStore.load(in, password); + } + SSLContext sslContext = buildContext(keyStore, password); + return new ServerTlsContext(sslContext, fingerprintOfFirst(keyStore), false); + } + + /** + * Generates an ephemeral self-signed certificate and wraps it in a fresh TLS context. + */ + public static ServerTlsContext selfSigned() throws Exception { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048, new SecureRandom()); + KeyPair keyPair = keyPairGenerator.generateKeyPair(); + + X500Name subject = new X500Name("CN=PLC4X-Server"); + Instant now = Instant.now(); + Date notBefore = Date.from(now.minus(1, ChronoUnit.HOURS)); + Date notAfter = Date.from(now.plus(3650, ChronoUnit.DAYS)); + BigInteger serial = BigInteger.valueOf(System.nanoTime()); + + X509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder( + subject, serial, notBefore, notAfter, subject, keyPair.getPublic()); + // Add localhost / loopback SANs so common-name verification can succeed for local use. + GeneralNames sans = new GeneralNames(new GeneralName[]{ + new GeneralName(GeneralName.dNSName, "localhost"), + new GeneralName(GeneralName.iPAddress, "127.0.0.1") + }); + certBuilder.addExtension(Extension.subjectAlternativeName, false, sans); + + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(keyPair.getPrivate()); + X509Certificate certificate = new JcaX509CertificateConverter() + .getCertificate(certBuilder.build(signer)); + + char[] password = new char[0]; + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + keyStore.load(null, password); + keyStore.setKeyEntry("plc4x-server", keyPair.getPrivate(), password, + new X509Certificate[]{certificate}); + + SSLContext sslContext = buildContext(keyStore, password); + return new ServerTlsContext(sslContext, fingerprint(certificate), true); + } + + private static SSLContext buildContext(KeyStore keyStore, char[] password) throws Exception { + KeyManagerFactory keyManagerFactory = + KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + keyManagerFactory.init(keyStore, password); + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(keyManagerFactory.getKeyManagers(), null, new SecureRandom()); + return sslContext; + } + + private static String fingerprintOfFirst(KeyStore keyStore) throws Exception { + var aliases = keyStore.aliases(); + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + var cert = keyStore.getCertificate(alias); + if (cert instanceof X509Certificate x509) { + return fingerprint(x509); + } + } + return "unknown"; + } + + private static String fingerprint(X509Certificate certificate) throws Exception { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(certificate.getEncoded()); + return HexFormat.ofDelimiter(":").withUpperCase().formatHex(hash); + } + +} diff --git a/plc4j/tools/plc4x-server/src/main/java/org/apache/plc4x/java/tools/plc4xserver/protocol/Plc4xServerAdapter.java b/plc4j/tools/plc4x-server/src/main/java/org/apache/plc4x/java/tools/plc4xserver/protocol/Plc4xServerAdapter.java index 7194b24..3086fc1 100644 --- a/plc4j/tools/plc4x-server/src/main/java/org/apache/plc4x/java/tools/plc4xserver/protocol/Plc4xServerAdapter.java +++ b/plc4j/tools/plc4x-server/src/main/java/org/apache/plc4x/java/tools/plc4xserver/protocol/Plc4xServerAdapter.java @@ -18,8 +18,6 @@ */ package org.apache.plc4x.java.tools.plc4xserver.protocol; -import io.netty.channel.ChannelHandlerContext; -import io.netty.channel.ChannelInboundHandlerAdapter; import org.apache.plc4x.java.api.PlcConnection; import org.apache.plc4x.java.api.PlcConnectionManager; import org.apache.plc4x.java.api.messages.PlcReadRequest; @@ -28,149 +26,209 @@ 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.api.value.PlcValue; +import org.apache.plc4x.java.plc4x.Plc4xMessageCodec; import org.apache.plc4x.java.plc4x.readwrite.*; -import org.apache.plc4x.java.utils.cache.CachedPlcConnectionManager; +import org.apache.plc4x.java.spi.drivers.exceptions.MessageCodecException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; -public class Plc4xServerAdapter extends ChannelInboundHandlerAdapter { +/** + * Per-connection handler for the PLC4X proxy protocol. One instance lives for the lifetime of a + * single accepted socket; the {@link SocketTransportInstance} read loop feeds the codec, which + * invokes {@link #handle(Plc4xMessage)} for every decoded message. + * + * <p>Authentication is mandatory: a connection must complete a successful {@code AUTH_REQUEST} + * before any {@code CONNECT}/{@code READ}/{@code WRITE} is honoured. Anything else is answered + * with {@code ACCESS_DENIED}.</p> + */ +public class Plc4xServerAdapter implements Consumer<Plc4xMessage> { private final Logger logger = LoggerFactory.getLogger(Plc4xServerAdapter.class); private final PlcConnectionManager connectionManager; - private final AtomicInteger connectionIdGenerator; - private final ConcurrentHashMap<Integer, String> connectionUrls; + private final Plc4xMessageCodec codec; + private final byte[] expectedUsername; + private final byte[] expectedPassword; + + private final AtomicInteger connectionIdGenerator = new AtomicInteger(1); + private final ConcurrentHashMap<Integer, String> connectionUrls = new ConcurrentHashMap<>(); - public Plc4xServerAdapter() { - connectionManager = CachedPlcConnectionManager.getBuilder().build(); - connectionIdGenerator = new AtomicInteger(1); - connectionUrls = new ConcurrentHashMap<>(); + private volatile boolean authenticated = false; + + public Plc4xServerAdapter(PlcConnectionManager connectionManager, Plc4xMessageCodec codec, + String expectedUsername, String expectedPassword) { + this.connectionManager = connectionManager; + this.codec = codec; + this.expectedUsername = expectedUsername.getBytes(StandardCharsets.UTF_8); + this.expectedPassword = expectedPassword.getBytes(StandardCharsets.UTF_8); } @Override - public void channelRead(ChannelHandlerContext ctx, Object msg) { - if (msg instanceof Plc4xMessage) { - final Plc4xMessage plc4xMessage = (Plc4xMessage) msg; - switch (plc4xMessage.getRequestType()) { - case CONNECT_REQUEST: { - Plc4xConnectRequest request = (Plc4xConnectRequest) plc4xMessage; - try (final PlcConnection ignored = connectionManager.getConnection(request.getConnectionString())) { - //connection.ping().get(); - final int connectionId = connectionIdGenerator.getAndIncrement(); - connectionUrls.put(connectionId, request.getConnectionString()); - Plc4xConnectResponse response = new Plc4xConnectResponse( - request.getRequestId(), connectionId, Plc4xResponseCode.OK); - ctx.writeAndFlush(response); - } catch (Exception e) { - Plc4xConnectResponse response = new Plc4xConnectResponse( - request.getRequestId(), 0, Plc4xResponseCode.INVALID_ADDRESS); - ctx.writeAndFlush(response); - } - break; + public void accept(Plc4xMessage message) { + handle(message); + } + + public void handle(Plc4xMessage plc4xMessage) { + switch (plc4xMessage.getRequestType()) { + case AUTH_REQUEST: + handleAuth((Plc4xAuthRequest) plc4xMessage); + break; + case CONNECT_REQUEST: + if (requireAuth(plc4xMessage)) { + handleConnect((Plc4xConnectRequest) plc4xMessage); + } + break; + case READ_REQUEST: + if (requireAuth(plc4xMessage)) { + handleRead((Plc4xReadRequest) plc4xMessage); } - case READ_REQUEST: { - final Plc4xReadRequest request = (Plc4xReadRequest) plc4xMessage; - String connectionUrl = connectionUrls.get(request.getConnectionId()); - try (final PlcConnection connection = connectionManager.getConnection(connectionUrl)) { - // Build a read request for all tags in the request. - final PlcReadRequest.Builder builder = connection.readRequestBuilder(); - for (Plc4xTagRequest requestTag : request.getTags()) { - builder.addTagAddress(requestTag.getTag().getName(), requestTag.getTag().getTagQuery()); - } - final PlcReadRequest rr = builder.build(); - - // Execute the query. - // (It has to be synchronously when working with the connection cache) - final PlcReadResponse apiReadResponse = rr.execute().get(); - - // Create the response. - List<Plc4xTagValueResponse> tags = new ArrayList<>(apiReadResponse.getTagNames().size()); - for (Plc4xTagRequest plc4xRequestTag : request.getTags()) { - final PlcResponseCode responseCode = apiReadResponse.getResponseCode(plc4xRequestTag.getTag().getName()); - Plc4xResponseCode resCode; - Plc4xValueType valueType; - PlcValue value; - if(responseCode == PlcResponseCode.OK) { - resCode = Plc4xResponseCode.OK; - value = apiReadResponse.getPlcValue(plc4xRequestTag.getTag().getName()); - final String valueTypeName = value.getClass().getSimpleName(); - // Cut off the "Plc" prefix to get the name of the PlcValueType. - valueType = Plc4xValueType.valueOf(valueTypeName.substring(3)); - } else { - resCode = Plc4xResponseCode.INVALID_ADDRESS; - value = null; - valueType = Plc4xValueType.NULL; - } - tags.add(new Plc4xTagValueResponse( - plc4xRequestTag.getTag(), resCode, valueType, value)); - } - Plc4xReadResponse response = new Plc4xReadResponse( - request.getRequestId(), request.getConnectionId(), Plc4xResponseCode.OK, tags); - - // Send the response. - ctx.writeAndFlush(response); - } catch (Exception e) { - logger.error("Error executing request", e); - Plc4xReadResponse response = new Plc4xReadResponse( - request.getRequestId(), request.getConnectionId(), - Plc4xResponseCode.INVALID_ADDRESS, Collections.emptyList()); - ctx.writeAndFlush(response); - } - break; + break; + case WRITE_REQUEST: + if (requireAuth(plc4xMessage)) { + handleWrite((Plc4xWriteRequest) plc4xMessage); + } + break; + default: + logger.debug("Ignoring unsupported request type {}", plc4xMessage.getRequestType()); + } + } + + private void handleAuth(Plc4xAuthRequest request) { + boolean ok = constantTimeEquals(expectedUsername, request.getUsername().getBytes(StandardCharsets.UTF_8)) + & constantTimeEquals(expectedPassword, request.getPassword().getBytes(StandardCharsets.UTF_8)); + authenticated = ok; + // Never log the supplied credentials, just the outcome. + logger.info("Authentication {}", ok ? "succeeded" : "failed"); + send(new Plc4xAuthResponse(request.getRequestId(), + ok ? Plc4xResponseCode.OK : Plc4xResponseCode.ACCESS_DENIED)); + } + + /** + * Returns {@code true} if the connection is authenticated. Otherwise emits a type-appropriate + * {@code ACCESS_DENIED} response and returns {@code false}. + */ + private boolean requireAuth(Plc4xMessage message) { + if (authenticated) { + return true; + } + logger.warn("Rejecting {} - connection is not authenticated", message.getRequestType()); + switch (message.getRequestType()) { + case CONNECT_REQUEST -> send(new Plc4xConnectResponse( + message.getRequestId(), 0, Plc4xResponseCode.ACCESS_DENIED)); + case READ_REQUEST -> send(new Plc4xReadResponse(message.getRequestId(), + ((Plc4xReadRequest) message).getConnectionId(), Plc4xResponseCode.ACCESS_DENIED, + Collections.emptyList())); + case WRITE_REQUEST -> send(new Plc4xWriteResponse(message.getRequestId(), + ((Plc4xWriteRequest) message).getConnectionId(), Plc4xResponseCode.ACCESS_DENIED, + Collections.emptyList())); + default -> { /* nothing to answer */ } + } + return false; + } + + private void handleConnect(Plc4xConnectRequest request) { + try (final PlcConnection ignored = connectionManager.getConnection(request.getConnectionString())) { + final int connectionId = connectionIdGenerator.getAndIncrement(); + connectionUrls.put(connectionId, request.getConnectionString()); + send(new Plc4xConnectResponse(request.getRequestId(), connectionId, Plc4xResponseCode.OK)); + } catch (Exception e) { + send(new Plc4xConnectResponse(request.getRequestId(), 0, Plc4xResponseCode.INVALID_ADDRESS)); + } + } + + private void handleRead(Plc4xReadRequest request) { + String connectionUrl = connectionUrls.get(request.getConnectionId()); + try (final PlcConnection connection = connectionManager.getConnection(connectionUrl)) { + final PlcReadRequest.Builder builder = connection.readRequestBuilder(); + for (Plc4xTagRequest requestTag : request.getTags()) { + builder.addTagAddress(requestTag.getTag().getName(), requestTag.getTag().getTagQuery()); + } + final PlcReadRequest rr = builder.build(); + + // Execute synchronously (required when working with the connection cache). + final PlcReadResponse apiReadResponse = rr.execute().get(); + + List<Plc4xTagValueResponse> tags = new ArrayList<>(apiReadResponse.getTagNames().size()); + for (Plc4xTagRequest plc4xRequestTag : request.getTags()) { + final PlcResponseCode responseCode = + apiReadResponse.getResponseCode(plc4xRequestTag.getTag().getName()); + Plc4xResponseCode resCode; + Plc4xValueType valueType; + PlcValue value; + if (responseCode == PlcResponseCode.OK) { + resCode = Plc4xResponseCode.OK; + value = apiReadResponse.getPlcValue(plc4xRequestTag.getTag().getName()); + final String valueTypeName = value.getClass().getSimpleName(); + // Cut off the "Plc" prefix to get the name of the PlcValueType. + valueType = Plc4xValueType.valueOf(valueTypeName.substring(3)); + } else { + resCode = Plc4xResponseCode.INVALID_ADDRESS; + value = null; + valueType = Plc4xValueType.NULL; } + tags.add(new Plc4xTagValueResponse(plc4xRequestTag.getTag(), resCode, valueType, value)); + } + send(new Plc4xReadResponse(request.getRequestId(), request.getConnectionId(), + Plc4xResponseCode.OK, tags)); + } catch (Exception e) { + logger.error("Error executing read request", e); + send(new Plc4xReadResponse(request.getRequestId(), request.getConnectionId(), + Plc4xResponseCode.INVALID_ADDRESS, Collections.emptyList())); + } + } - case WRITE_REQUEST: - final Plc4xWriteRequest plc4xWriteRequest = (Plc4xWriteRequest) plc4xMessage; - String connectionUrl = connectionUrls.get(plc4xWriteRequest.getConnectionId()); - try (final PlcConnection connection = connectionManager.getConnection(connectionUrl)) { - // Build a write request for all tags in the request. - final PlcWriteRequest.Builder builder = connection.writeRequestBuilder(); - for (Plc4xTagValueRequest plc4xRequestTag : plc4xWriteRequest.getTags()) { - builder.addTagAddress(plc4xRequestTag.getTag().getName(), - plc4xRequestTag.getTag().getTagQuery(), plc4xRequestTag.getValue().getObject()); - } - final PlcWriteRequest apiWriteRequest = builder.build(); - - // Execute the query - // (It has to be synchronously when working with the connection cache) - final PlcWriteResponse apiWriteResponse = apiWriteRequest.execute().get(); - - // Create the response. - List<Plc4xTagResponse> plc4xTags = - new ArrayList<>(apiWriteResponse.getTagNames().size()); - for (Plc4xTagValueRequest plc4xRequestTag : plc4xWriteRequest.getTags()) { - final PlcResponseCode apiResponseCode = - apiWriteResponse.getResponseCode(plc4xRequestTag.getTag().getName()); - Plc4xResponseCode resCode; - if(apiResponseCode == PlcResponseCode.OK) { - resCode = Plc4xResponseCode.OK; - } else { - resCode = Plc4xResponseCode.INVALID_ADDRESS; - } - plc4xTags.add(new Plc4xTagResponse(plc4xRequestTag.getTag(), resCode)); - } - Plc4xWriteResponse plc4xWriteResponse = new Plc4xWriteResponse( - plc4xWriteRequest.getRequestId(), plc4xWriteRequest.getConnectionId(), - Plc4xResponseCode.OK, plc4xTags); - - // Send the response. - ctx.writeAndFlush(plc4xWriteResponse); - } catch (Exception e) { - logger.error("Error executing request", e); - Plc4xWriteResponse response = new Plc4xWriteResponse( - plc4xWriteRequest.getRequestId(), plc4xWriteRequest.getConnectionId(), - Plc4xResponseCode.INVALID_ADDRESS, Collections.emptyList()); - ctx.writeAndFlush(response); - } - break; + private void handleWrite(Plc4xWriteRequest request) { + String connectionUrl = connectionUrls.get(request.getConnectionId()); + try (final PlcConnection connection = connectionManager.getConnection(connectionUrl)) { + final PlcWriteRequest.Builder builder = connection.writeRequestBuilder(); + for (Plc4xTagValueRequest plc4xRequestTag : request.getTags()) { + builder.addTagAddress(plc4xRequestTag.getTag().getName(), + plc4xRequestTag.getTag().getTagQuery(), plc4xRequestTag.getValue().getObject()); } + final PlcWriteRequest apiWriteRequest = builder.build(); + + // Execute synchronously (required when working with the connection cache). + final PlcWriteResponse apiWriteResponse = apiWriteRequest.execute().get(); + + List<Plc4xTagResponse> plc4xTags = new ArrayList<>(apiWriteResponse.getTagNames().size()); + for (Plc4xTagValueRequest plc4xRequestTag : request.getTags()) { + final PlcResponseCode apiResponseCode = + apiWriteResponse.getResponseCode(plc4xRequestTag.getTag().getName()); + Plc4xResponseCode resCode = apiResponseCode == PlcResponseCode.OK + ? Plc4xResponseCode.OK : Plc4xResponseCode.INVALID_ADDRESS; + plc4xTags.add(new Plc4xTagResponse(plc4xRequestTag.getTag(), resCode)); + } + send(new Plc4xWriteResponse(request.getRequestId(), request.getConnectionId(), + Plc4xResponseCode.OK, plc4xTags)); + } catch (Exception e) { + logger.error("Error executing write request", e); + send(new Plc4xWriteResponse(request.getRequestId(), request.getConnectionId(), + Plc4xResponseCode.INVALID_ADDRESS, Collections.emptyList())); + } + } + + private void send(Plc4xMessage message) { + try { + codec.send(message); + } catch (MessageCodecException e) { + logger.error("Failed to send response", e); } } + /** + * Length-aware constant-time comparison to avoid leaking credential length/content via timing. + */ + private static boolean constantTimeEquals(byte[] expected, byte[] actual) { + return MessageDigest.isEqual(expected, actual); + } + } diff --git a/plc4j/tools/plc4x-server/src/main/java/org/apache/plc4x/java/tools/plc4xserver/protocol/SocketTransportInstance.java b/plc4j/tools/plc4x-server/src/main/java/org/apache/plc4x/java/tools/plc4xserver/protocol/SocketTransportInstance.java new file mode 100644 index 0000000..cc05687 --- /dev/null +++ b/plc4j/tools/plc4x-server/src/main/java/org/apache/plc4x/java/tools/plc4xserver/protocol/SocketTransportInstance.java @@ -0,0 +1,121 @@ +/* + * 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.tools.plc4xserver.protocol; + +import org.apache.plc4x.java.spi.transports.api.RingBuffer; +import org.apache.plc4x.java.spi.transports.api.TransportInstance; +import org.apache.plc4x.java.spi.transports.api.config.TransportConfiguration; +import org.apache.plc4x.java.spi.transports.api.exceptions.TransportException; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.Socket; + +/** + * Adapts an accepted server-side {@link Socket} to the SPI3 {@link TransportInstance} contract + * so the regular PLC4X message codec can be reused on the server side. + * + * <p>SPI3 has no server transport (it is purely client/outbound), but a {@code MessageCodecBase} + * only needs the byte-level peek/read/write surface. Inbound bytes are pulled from the socket by + * {@link #fill()} into a {@link RingBuffer}; the codec then drains complete messages from that + * buffer via {@link #getNumBytesAvailable()} / {@link #peekReadableBytes(int)} / {@link #read(int)}. + * The maximum PLC4X proxy message is bounded by a {@code uint16} length, so a buffer slightly + * larger than 64 KiB always holds a full frame.</p> + */ +public class SocketTransportInstance implements TransportInstance<TransportConfiguration> { + + private static final int BUFFER_CAPACITY = 256 * 1024; + private static final int READ_CHUNK = 8 * 1024; + + private final Socket socket; + private final InputStream in; + private final OutputStream out; + private final RingBuffer ringBuffer = new RingBuffer(BUFFER_CAPACITY); + private final byte[] readChunk = new byte[READ_CHUNK]; + + public SocketTransportInstance(Socket socket) throws IOException { + this.socket = socket; + this.in = socket.getInputStream(); + this.out = socket.getOutputStream(); + } + + /** + * Blocks until at least one byte arrives from the socket and appends it to the ring buffer. + * + * @return {@code false} on end-of-stream (peer closed), {@code true} otherwise. + */ + public boolean fill() throws IOException { + int read = in.read(readChunk); + if (read == -1) { + return false; + } + if (read > 0) { + ringBuffer.write(readChunk, 0, read); + } + return true; + } + + @Override + public TransportConfiguration getConfiguration() { + return null; + } + + @Override + public boolean isOpen() { + return !socket.isClosed() && socket.isConnected(); + } + + @Override + public int getNumBytesAvailable() { + return ringBuffer.availableForReading(); + } + + @Override + public byte[] peekReadableBytes(int numBytes) { + return ringBuffer.peek(numBytes); + } + + @Override + public byte[] read(int numBytes) { + return ringBuffer.read(numBytes); + } + + @Override + public void write(byte[] bytes) throws TransportException { + try { + synchronized (out) { + out.write(bytes); + out.flush(); + } + } catch (IOException e) { + throw new TransportException("Failed to write to socket", e); + } + } + + @Override + public void close() throws TransportException { + try { + socket.close(); + } catch (IOException e) { + throw new TransportException("Failed to close socket", e); + } + } + +} diff --git a/plc4j/tools/plc4x-server/src/test/java/org/apache/plc4x/java/tools/plc4xserver/Plc4xServerTest.java b/plc4j/tools/plc4x-server/src/test/java/org/apache/plc4x/java/tools/plc4xserver/Plc4xServerTest.java index 0e35957..326bed3 100644 --- a/plc4j/tools/plc4x-server/src/test/java/org/apache/plc4x/java/tools/plc4xserver/Plc4xServerTest.java +++ b/plc4j/tools/plc4x-server/src/test/java/org/apache/plc4x/java/tools/plc4xserver/Plc4xServerTest.java @@ -22,6 +22,7 @@ package org.apache.plc4x.java.tools.plc4xserver; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; @@ -29,6 +30,7 @@ import java.util.concurrent.TimeoutException; 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.exceptions.PlcConnectionException; import org.apache.plc4x.java.api.messages.PlcReadRequest; import org.apache.plc4x.java.api.messages.PlcReadResponse; import org.apache.plc4x.java.api.messages.PlcWriteRequest; @@ -40,8 +42,13 @@ import org.junit.jupiter.api.Test; public class Plc4xServerTest { + private static final String USERNAME = "test-user"; + private static final String PASSWORD = "test-password"; + private static final Plc4xServer SERVER = new Plc4xServer(); - private static final String CONNECTION_STRING_TEMPLATE = "plc4x://localhost:%d?remote-connection-string=%s"; + // TLS (the default) with verify-ssl=false so the client trusts the server's self-signed cert. + private static final String CONNECTION_STRING_TEMPLATE = + "plc4x:tls://localhost:%d?remote-connection-string=%s&username=%s&password=%s&tls.verify-ssl=false"; private static final String CONNECTION_STRING_SIMULATED_ENCODED = "simulated%3A%2F%2Flocalhost"; private static final long TIMEOUT_VALUE = 10; private static final TimeUnit TIMEOUT_UNIT = TimeUnit.SECONDS; @@ -50,6 +57,8 @@ public class Plc4xServerTest { @BeforeAll public static void setUp() throws ExecutionException, InterruptedException, TimeoutException { + SERVER.setUsername(USERNAME); + SERVER.setPassword(PASSWORD); SERVER.start().get(TIMEOUT_VALUE, TIMEOUT_UNIT); } @@ -58,18 +67,18 @@ public class Plc4xServerTest { SERVER.stop(); } + private String connectionString(String username, String password) { + return String.format(CONNECTION_STRING_TEMPLATE, SERVER.getPort(), + CONNECTION_STRING_SIMULATED_ENCODED, username, password); + } + @Test public void testWrite() throws Exception { final PlcWriteResponse response; - try (PlcConnection connection = connectionManager.getConnection( - String.format(CONNECTION_STRING_TEMPLATE, SERVER.getPort(), CONNECTION_STRING_SIMULATED_ENCODED))) { + try (PlcConnection connection = connectionManager.getConnection(connectionString(USERNAME, PASSWORD))) { final PlcWriteRequest request = connection.writeRequestBuilder() - .addTagAddress( - "foo", - "STATE/foo:DINT", - 42 - ) + .addTagAddress("foo", "STATE/foo:DINT", 42) .build(); response = request.execute().get(TIMEOUT_VALUE, TIMEOUT_UNIT); } @@ -81,13 +90,9 @@ public class Plc4xServerTest { public void testRead() throws Exception { final PlcReadResponse response; - try (PlcConnection connection = connectionManager.getConnection( - String.format(CONNECTION_STRING_TEMPLATE, SERVER.getPort(), CONNECTION_STRING_SIMULATED_ENCODED))) { + try (PlcConnection connection = connectionManager.getConnection(connectionString(USERNAME, PASSWORD))) { final PlcReadRequest request = connection.readRequestBuilder() - .addTagAddress( - "foo", - "RANDOM/foo:DINT" - ) + .addTagAddress("foo", "RANDOM/foo:DINT") .build(); response = request.execute().get(TIMEOUT_VALUE, TIMEOUT_UNIT); } @@ -102,22 +107,14 @@ public class Plc4xServerTest { final PlcWriteResponse writeResponse; final PlcReadResponse readResponse; - try (PlcConnection connection = connectionManager.getConnection( - String.format(CONNECTION_STRING_TEMPLATE, SERVER.getPort(), CONNECTION_STRING_SIMULATED_ENCODED))) { + try (PlcConnection connection = connectionManager.getConnection(connectionString(USERNAME, PASSWORD))) { final PlcWriteRequest writeRequest = connection.writeRequestBuilder() - .addTagAddress( - "foo", - "STATE/foo:DINT", - 21 - ) + .addTagAddress("foo", "STATE/foo:DINT", 21) .build(); writeResponse = writeRequest.execute().get(TIMEOUT_VALUE, TIMEOUT_UNIT); final PlcReadRequest readRequest = connection.readRequestBuilder() - .addTagAddress( - "foo", - "STATE/foo:DINT" - ) + .addTagAddress("foo", "STATE/foo:DINT") .build(); readResponse = readRequest.execute().get(TIMEOUT_VALUE, TIMEOUT_UNIT); } @@ -128,4 +125,15 @@ public class Plc4xServerTest { assertInstanceOf(Integer.class, readResponse.getPlcValue("foo").getObject()); assertEquals(21, readResponse.getInteger("foo")); } + + @Test + public void testWrongPasswordIsRejected() { + // A connection with bad credentials must fail during the mandatory auth handshake. + assertThrows(PlcConnectionException.class, () -> { + try (PlcConnection ignored = connectionManager.getConnection( + connectionString(USERNAME, "wrong-password"))) { + // Should never get here. + } + }); + } }
