This is an automated email from the ASF dual-hosted git repository. cdutz pushed a commit to branch refactor/spi3 in repository https://gitbox.apache.org/repos/asf/plc4x.git
commit 0238c9f7ef7719ecf3a366126c357dea47682942 Author: Christofer Dutz <[email protected]> AuthorDate: Fri Mar 20 09:08:53 2026 +0100 feat: Re-Implemented the pcap-replay transport. --- .../java/bacnetip/readwrite/BACnetVendorId.java | 22 + .../java/knxnetip/readwrite/KnxManufacturer.java | 22 +- plc4j/transports/pcap-replay/pom.xml | 60 +- .../DefaultPcapReplayTransportConfiguration.java | 77 -- .../java/transport/pcapreplay/PcapFilePlayer.java | 329 +++++++++ .../pcapreplay/PcapReplayChannelFactory.java | 78 -- .../transport/pcapreplay/PcapReplayTransport.java | 42 +- .../PcapReplayTransportConfiguration.java | 31 - .../pcapreplay/PcapReplayTransportInstance.java | 488 +++++++++++++ .../config/PcapReplayTransportConfiguration.java | 149 ++++ .../transport/pcapreplay/PcapFilePlayerTest.java | 688 ++++++++++++++++++ .../PcapReplayTransportInstanceTest.java | 782 +++++++++++++++++++++ .../pcapreplay/PcapReplayTransportTest.java | 206 ++++++ .../PcapReplayTransportConfigurationTest.java | 298 ++++++++ .../src/test/resources/generate_test_pcap.py | 123 ++++ .../pcap-replay/src/test/resources/test.pcap | Bin 0 -> 328 bytes plc4j/transports/pom.xml | 4 +- 17 files changed, 3170 insertions(+), 229 deletions(-) diff --git a/plc4j/drivers/bacnet/src/main/generated/org/apache/plc4x/java/bacnetip/readwrite/BACnetVendorId.java b/plc4j/drivers/bacnet/src/main/generated/org/apache/plc4x/java/bacnetip/readwrite/BACnetVendorId.java index 510bcd7d8a..3ea55e3736 100644 --- a/plc4j/drivers/bacnet/src/main/generated/org/apache/plc4x/java/bacnetip/readwrite/BACnetVendorId.java +++ b/plc4j/drivers/bacnet/src/main/generated/org/apache/plc4x/java/bacnetip/readwrite/BACnetVendorId.java @@ -3195,6 +3195,28 @@ public enum BACnetVendorId { ALLBEEBV((int) 1594, (int) 1594, "Allbee B.V."), + KAMPMANN_GMBH_COKG((int) 1595, (int) 1595, "Kampmann GmbH & Co. KG"), + + COSEL_ELEKTRONIK_OTOMASYON_SISTEMLERI_SAN_TIC_LTD_STI((int) 1596, (int) 1596, "Cosel Elektronik Otomasyon Sistemleri San. Tic. Ltd. Sti."), + + RACTL_DESIGNS((int) 1597, (int) 1597, "Ractl Designs"), + + DATABRIDGE_DYNAMIC_PVT_LTD((int) 1598, (int) 1598, "Databridge Dynamic Pvt Ltd"), + + EVOLO_TECHNOLOGIESAS((int) 1599, (int) 1599, "Evolo Technologies AS"), + + ENERGY_PLUS_TEKNOLOJI_IMALAT_ITHALATVE_IHRACAT_SANAYIAS((int) 1600, (int) 1600, "EnergyPlus Teknoloji Imalat Ithalat ve Ihracat Sanayi AS"), + + GAIAMESH_INC((int) 1601, (int) 1601, "Gaiamesh Inc."), + + LOVATO_ELECTRICSPA((int) 1602, (int) 1602, "Lovato Electric S.P.A."), + + QPAC_SYSTEMS_INC((int) 1603, (int) 1603, "Q-PAC Systems, Inc."), + + YOBIIQ_INTELLIGENCEBV((int) 1604, (int) 1604, "Yobiiq Intelligence B.V."), + + CAMFILUSA_INC((int) 1605, (int) 1605, "Camfil USA Inc."), + UNKNOWN_VENDOR((int) 0xFFFF, (int) 0xFFFF, "Unknown"); private static final Map<Integer, BACnetVendorId> map; diff --git a/plc4j/drivers/knxnetip/src/main/generated/org/apache/plc4x/java/knxnetip/readwrite/KnxManufacturer.java b/plc4j/drivers/knxnetip/src/main/generated/org/apache/plc4x/java/knxnetip/readwrite/KnxManufacturer.java index 07de37d900..d13bbd7439 100644 --- a/plc4j/drivers/knxnetip/src/main/generated/org/apache/plc4x/java/knxnetip/readwrite/KnxManufacturer.java +++ b/plc4j/drivers/knxnetip/src/main/generated/org/apache/plc4x/java/knxnetip/readwrite/KnxManufacturer.java @@ -1579,7 +1579,7 @@ public enum KnxManufacturer { M_LEXA_LIFESTYLE((int) 776, (int) 837, "Lexa LIFESTYLE"), - M_CASAMBI((int) 777, (int) 838, "casambi"), + M_CASAMBI_TECHNOLOGIES_OY((int) 777, (int) 838, "Casambi Technologies Oy"), M_BELCAB_UK_LTD((int) 778, (int) 839, "BELCAB UK LTD"), @@ -1591,9 +1591,25 @@ public enum KnxManufacturer { M_OCCHIO_GMBH((int) 782, (int) 843, "Occhio GmbH"), - M_ABB___RESERVED((int) 783, (int) 43954, "ABB - reserved"), + M_INSTELL((int) 783, (int) 844, "Instell"), - M_BUSCH_JAEGER_ELEKTRO___RESERVED((int) 784, (int) 43959, "Busch-Jaeger Elektro - reserved"); + M_VOKA_VOGTLAENDISCHES_KABELWERK_GMBH((int) 784, (int) 845, "VOKA Vogtländisches Kabelwerk GmbH"), + + M_QINGDAO_MAISI_INTELLIGENT_TECHNOLOGY_CO__LTD((int) 785, (int) 846, "Qingdao Maisi Intelligent Technology Co.,Ltd"), + + M_CHONGQING_MEXIN_MESSON_DOORS_INDUSTRY_CO___LTD__((int) 786, (int) 847, "Chongqing Mexin Messon Doors Industry Co., Ltd.,"), + + M_HOLFMANN_CULTURE_ART_CO__LTD((int) 787, (int) 848, "Holfmann Culture Art Co.,LTD"), + + M_GUANGZHOU_BAITE_IOT_TECHNOLOGY_CO___LTD((int) 788, (int) 849, "Guangzhou Baite IoT Technology Co., Ltd"), + + M_OWL_AUTOMATA((int) 789, (int) 850, "OWL Automata"), + + M_AUTOMATED_PTY_LTD((int) 790, (int) 851, "Automated Pty Ltd"), + + M_ABB___RESERVED((int) 791, (int) 43954, "ABB - reserved"), + + M_BUSCH_JAEGER_ELEKTRO___RESERVED((int) 792, (int) 43959, "Busch-Jaeger Elektro - reserved"); private static final Map<Integer, KnxManufacturer> map; diff --git a/plc4j/transports/pcap-replay/pom.xml b/plc4j/transports/pcap-replay/pom.xml index 89ddb0e30c..e454f4f92f 100644 --- a/plc4j/transports/pcap-replay/pom.xml +++ b/plc4j/transports/pcap-replay/pom.xml @@ -38,50 +38,70 @@ <dependencies> <dependency> <groupId>org.apache.plc4x</groupId> - <artifactId>plc4j-api</artifactId> + <artifactId>plc4j-transports-api</artifactId> <version>0.14.0-SNAPSHOT</version> </dependency> <dependency> <groupId>org.apache.plc4x</groupId> - <artifactId>plc4j-spi</artifactId> + <artifactId>plc4j-spi-config</artifactId> <version>0.14.0-SNAPSHOT</version> </dependency> <dependency> <groupId>org.apache.plc4x</groupId> - <artifactId>plc4j-transport-pcap-shared</artifactId> + <artifactId>plc4j-spi-utils</artifactId> <version>0.14.0-SNAPSHOT</version> </dependency> <dependency> <groupId>org.apache.plc4x</groupId> - <artifactId>plc4j-utils-pcap-shared</artifactId> + <artifactId>plc4j-utils-audit-log-api</artifactId> <version>0.14.0-SNAPSHOT</version> </dependency> + + <!-- Logging --> <dependency> - <groupId>org.apache.plc4x</groupId> - <artifactId>plc4j-utils-pcap-replay</artifactId> - <version>0.14.0-SNAPSHOT</version> + <groupId>org.slf4j</groupId> + <artifactId>slf4j-api</artifactId> + </dependency> + + <!-- Pcap4J for reading pcap files --> + <dependency> + <groupId>org.pcap4j</groupId> + <artifactId>pcap4j-core</artifactId> + </dependency> + <dependency> + <groupId>org.pcap4j</groupId> + <artifactId>pcap4j-packetfactory-static</artifactId> </dependency> + <!-- Test Dependencies --> <dependency> - <groupId>io.netty</groupId> - <artifactId>netty-transport</artifactId> + <groupId>org.mockito</groupId> + <artifactId>mockito-core</artifactId> + <scope>test</scope> + </dependency> + + <dependency> + <groupId>ch.qos.logback</groupId> + <artifactId>logback-classic</artifactId> + <scope>test</scope> + </dependency> + + <dependency> + <groupId>org.junit.jupiter</groupId> + <artifactId>junit-jupiter-api</artifactId> + <scope>test</scope> </dependency> </dependencies> + <build> <plugins> - <!-- - Generate an OSGI compatible MANIFEST file. - --> <plugin> - <groupId>org.apache.felix</groupId> - <artifactId>maven-bundle-plugin</artifactId> - <extensions>true</extensions> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-dependency-plugin</artifactId> <configuration> - <instructions> - <Bundle-SymbolicName>${project.groupId}.${project.artifactId}</Bundle-SymbolicName> - <Bundle-Activator>org.apache.plc4x.java.osgi.TransportActivator</Bundle-Activator> - <Export-Service>org.apache.plc4x.java.spi.transport.Transport,org.apache.plc4x.java.transport.pcapreplay.PcapReplayTransport</Export-Service> - </instructions> + <usedDependencies> + <usedDependency>org.pcap4j:pcap4j-packetfactory-static</usedDependency> + </usedDependencies> </configuration> </plugin> </plugins> diff --git a/plc4j/transports/pcap-replay/src/main/java/org/apache/plc4x/java/transport/pcapreplay/DefaultPcapReplayTransportConfiguration.java b/plc4j/transports/pcap-replay/src/main/java/org/apache/plc4x/java/transport/pcapreplay/DefaultPcapReplayTransportConfiguration.java deleted file mode 100644 index 3994dfbb6c..0000000000 --- a/plc4j/transports/pcap-replay/src/main/java/org/apache/plc4x/java/transport/pcapreplay/DefaultPcapReplayTransportConfiguration.java +++ /dev/null @@ -1,77 +0,0 @@ -/* - * 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.transport.pcapreplay; - -import org.apache.plc4x.java.spi.configuration.annotations.ConfigurationParameter; -import org.apache.plc4x.java.spi.configuration.annotations.Description; -import org.apache.plc4x.java.spi.configuration.annotations.defaults.BooleanDefaultValue; -import org.apache.plc4x.java.spi.configuration.annotations.defaults.FloatDefaultValue; -import org.apache.plc4x.java.transport.pcap.DefaultPcapTransportConfiguration; - -public abstract class DefaultPcapReplayTransportConfiguration extends DefaultPcapTransportConfiguration implements PcapReplayTransportConfiguration { - - @ConfigurationParameter("replay-speed-factor") - @FloatDefaultValue(1.0f) - @Description("Numeric value for changing the replay speed:\n" + - "- 1 = Normal speed (default)\n" + - "- 0 = Maximum speed\n" + - "- 0.5 = Half speed\n" + - "- 2 = Double speed") - private float replaySpeedFactor; - - @ConfigurationParameter("loop") - @BooleanDefaultValue(false) - @Description("Should the replay start at the beginning of the file as soon as the end is reached?\n" + - "- true = Automatically start again\n" + - "- false = Stop at the end (default)") - private boolean loop; - - @ConfigurationParameter("filter") - @Description("Filter expression used to filter out unwanted packets from the replay.") - private String filter; - - @Override - public float getReplaySpeedFactor() { - return replaySpeedFactor; - } - - public void setReplaySpeedFactor(float replaySpeedFactor) { - this.replaySpeedFactor = replaySpeedFactor; - } - - @Override - public boolean isLoop() { - return loop; - } - - public void setLoop(boolean loop) { - this.loop = loop; - } - - @Override - public String getFilter() { - return filter; - } - - public void setFilter(String filter) { - this.filter = filter; - } - -} diff --git a/plc4j/transports/pcap-replay/src/main/java/org/apache/plc4x/java/transport/pcapreplay/PcapFilePlayer.java b/plc4j/transports/pcap-replay/src/main/java/org/apache/plc4x/java/transport/pcapreplay/PcapFilePlayer.java new file mode 100644 index 0000000000..583f6b4bb7 --- /dev/null +++ b/plc4j/transports/pcap-replay/src/main/java/org/apache/plc4x/java/transport/pcapreplay/PcapFilePlayer.java @@ -0,0 +1,329 @@ +/* + * 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.transport.pcapreplay; + +import org.pcap4j.core.*; +import org.pcap4j.packet.EthernetPacket; +import org.pcap4j.packet.Packet; +import org.pcap4j.util.MacAddress; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.EOFException; +import java.io.File; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Plays back packets from a PCAP file, simulating network traffic. + * Supports speed control, looping, and MAC-address/protocol filtering. + * Can read from both filesystem paths and classpath resources. + */ +public class PcapFilePlayer { + + private static final Logger LOGGER = LoggerFactory.getLogger(PcapFilePlayer.class); + + private final String pcapFilePath; + private final MacAddress localMac; + private final MacAddress remoteMac; + private final int protocolId; + private final double speedFactor; + private final boolean loop; + private final boolean onlyIncoming; + private final boolean onlyOutgoing; + private final int queueSize; + private Path tempPcapFile; // Temporary file for classpath resources + + private final BlockingQueue<byte[]> packetQueue; + private final AtomicBoolean playing = new AtomicBoolean(false); + private final AtomicBoolean stopped = new AtomicBoolean(false); + private final AtomicLong packetsReplayed = new AtomicLong(0); + private Thread playerThread; + + public PcapFilePlayer(String pcapFilePath, MacAddress localMac, MacAddress remoteMac, + int protocolId, double speedFactor, boolean loop, + boolean onlyIncoming, boolean onlyOutgoing, int queueSize) { + LOGGER.debug("PcapFilePlayer: Pre Java 21 version"); + this.pcapFilePath = resolvePcapFile(pcapFilePath); + this.localMac = localMac; + this.remoteMac = remoteMac; + this.protocolId = protocolId; + this.speedFactor = speedFactor; + this.loop = loop; + this.onlyIncoming = onlyIncoming; + this.onlyOutgoing = onlyOutgoing; + this.queueSize = queueSize; + this.packetQueue = new LinkedBlockingQueue<>(queueSize); + } + + /** + * Starts replaying the PCAP file. + */ + public void start() { + if (playing.compareAndSet(false, true)) { + stopped.set(false); + playerThread = new Thread(this::playbackLoop); + playerThread.start(); + LOGGER.info("Started PCAP replay from: {}", pcapFilePath); + } + } + + /** + * Stops replaying the PCAP file. + */ + public void stop() { + if (stopped.compareAndSet(false, true)) { + playing.set(false); + if (playerThread != null) { + playerThread.interrupt(); + } + cleanupTempFile(); + LOGGER.info("Stopped PCAP replay (replayed {} packets)", packetsReplayed.get()); + } + } + + /** + * Checks if the player is currently playing. + */ + public boolean isPlaying() { + return playing.get(); + } + + /** + * Gets the next packet from the queue (blocking). + */ + public byte[] getNextPacket(long timeout, TimeUnit unit) throws InterruptedException { + return packetQueue.poll(timeout, unit); + } + + /** + * Gets the number of packets currently in the queue. + */ + public int getQueueSize() { + return packetQueue.size(); + } + + /** + * Main playback loop. + */ + private void playbackLoop() { + try { + do { + replayPcapFile(); + } while (loop && !stopped.get()); + + } catch (Exception e) { + if (!stopped.get()) { + LOGGER.error("Error during PCAP replay", e); + } + } finally { + playing.set(false); + LOGGER.debug("PCAP playback thread stopped"); + } + } + + /** + * Replays the PCAP file once. + */ + private void replayPcapFile() throws Exception { + PcapHandle handle = null; + + try { + handle = Pcaps.openOffline(pcapFilePath, PcapHandle.TimestampPrecision.MICRO); + + long previousTimestamp = -1; + Packet packet; + + while (!stopped.get() && (packet = handle.getNextPacketEx()) != null) { + PcapHandle.TimestampPrecision precision = handle.getTimestampPrecision(); + long timestamp = handle.getTimestamp().getTime() * 1000; // Convert to microseconds + + // Handle timing for realistic replay + if (speedFactor > 0 && previousTimestamp >= 0) { + long delay = (long) ((timestamp - previousTimestamp) / speedFactor); + if (delay > 0) { + TimeUnit.MICROSECONDS.sleep(delay); + } + } + previousTimestamp = timestamp; + + // Process Ethernet packet + if (packet.contains(EthernetPacket.class)) { + EthernetPacket ethPacket = packet.get(EthernetPacket.class); + + if (matchesFilter(ethPacket)) { + byte[] payload = extractPayload(ethPacket); + if (payload != null && payload.length > 0) { + // Try to add to queue, drop if full + if (!packetQueue.offer(payload)) { + LOGGER.warn("Packet queue full, dropping packet"); + } else { + packetsReplayed.incrementAndGet(); + LOGGER.trace("Replayed packet: {} bytes", payload.length); + } + } + } + } + } + + } catch (EOFException e) { + LOGGER.debug("Reached end of PCAP file"); + } finally { + if (handle != null && handle.isOpen()) { + handle.close(); + } + } + } + + /** + * Checks if a packet matches filter criteria. + */ + private boolean matchesFilter(EthernetPacket ethPacket) { + EthernetPacket.EthernetHeader header = ethPacket.getHeader(); + + // Check protocol if specified + if (protocolId > 0) { + // Mask to 16 bits to avoid sign-extension from EtherType's signed short + int packetProto = header.getType().value() & 0xFFFF; + if (packetProto != protocolId) { + return false; + } + } + + MacAddress src = header.getSrcAddr(); + MacAddress dst = header.getDstAddr(); + + // Filter by direction + if (onlyIncoming && onlyOutgoing) { + // Both flags set - this is contradictory, reject + return false; + } + + if (onlyIncoming) { + // Only packets from remote to local + return src.equals(remoteMac) && dst.equals(localMac); + } else if (onlyOutgoing) { + // Only packets from local to remote + return src.equals(localMac) && dst.equals(remoteMac); + } else { + // Any packet involving local or remote MAC addresses + return src.equals(localMac) || src.equals(remoteMac) || + dst.equals(localMac) || dst.equals(remoteMac); + } + } + + /** + * Extracts payload from an Ethernet packet. + */ + private byte[] extractPayload(EthernetPacket ethPacket) { + Packet payload = ethPacket.getPayload(); + if (payload != null) { + return payload.getRawData(); + } + return null; + } + + /** + * Gets statistics about replay. + */ + public long getPacketsReplayed() { + return packetsReplayed.get(); + } + + /** + * Resolves a PCAP file path, handling both filesystem paths and classpath resources. + * If the path starts with "/", it's treated as a classpath resource and extracted to a temp file. + * + * @param pcapFile The PCAP file path (filesystem or classpath) + * @return The resolved filesystem path + */ + private String resolvePcapFile(String pcapFile) { + // Check if it's a classpath resource (starts with '/') + if (pcapFile.startsWith("/")) { + return extractClasspathResource(pcapFile); + } + + // Treat as a filesystem path + File file = new File(pcapFile); + if (!file.exists()) { + LOGGER.warn("PCAP file not found: {}", pcapFile); + return pcapFile; // Return as-is, error will occur when trying to open + } + return file.getAbsolutePath(); + } + + /** + * Extracts a classpath resource to a temporary file. + * + * @param resourcePath The classpath resource path + * @return The absolute path to the temporary file + */ + private String extractClasspathResource(String resourcePath) { + try { + // Load resource from the classpath + InputStream resourceStream = getClass().getResourceAsStream(resourcePath); + if (resourceStream == null) { + LOGGER.error("Classpath resource not found: {}", resourcePath); + return resourcePath; // Return as-is, error will occur when trying to open + } + + // Create temporary file + String fileName = resourcePath.substring(resourcePath.lastIndexOf('/') + 1); + String baseName = fileName.contains(".") ? fileName.substring(0, fileName.lastIndexOf('.')) : fileName; + String extension = fileName.contains(".") ? fileName.substring(fileName.lastIndexOf('.')) : ".pcap"; + + tempPcapFile = Files.createTempFile(baseName + "_", extension); + Files.copy(resourceStream, tempPcapFile, StandardCopyOption.REPLACE_EXISTING); + resourceStream.close(); + + String tempPath = tempPcapFile.toAbsolutePath().toString(); + LOGGER.debug("Extracted classpath resource {} to temporary file: {}", resourcePath, tempPath); + + return tempPath; + + } catch (Exception e) { + LOGGER.error("Failed to extract classpath resource: " + resourcePath, e); + return resourcePath; // Return as-is, error will occur when trying to open + } + } + + /** + * Cleans up temporary files created from classpath resources. + */ + private void cleanupTempFile() { + if (tempPcapFile != null) { + try { + if (Files.exists(tempPcapFile)) { + Files.delete(tempPcapFile); + LOGGER.debug("Deleted temporary PCAP file: {}", tempPcapFile); + } + } catch (Exception e) { + LOGGER.warn("Failed to delete temporary PCAP file: {}", tempPcapFile, e); + } + tempPcapFile = null; + } + } +} diff --git a/plc4j/transports/pcap-replay/src/main/java/org/apache/plc4x/java/transport/pcapreplay/PcapReplayChannelFactory.java b/plc4j/transports/pcap-replay/src/main/java/org/apache/plc4x/java/transport/pcapreplay/PcapReplayChannelFactory.java deleted file mode 100644 index ee1f37730e..0000000000 --- a/plc4j/transports/pcap-replay/src/main/java/org/apache/plc4x/java/transport/pcapreplay/PcapReplayChannelFactory.java +++ /dev/null @@ -1,78 +0,0 @@ -/* - * 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.transport.pcapreplay; - -import io.netty.bootstrap.Bootstrap; -import io.netty.channel.*; -import io.netty.channel.oio.OioEventLoopGroup; -import org.apache.plc4x.java.spi.configuration.HasConfiguration; -import org.apache.plc4x.java.spi.connection.NettyChannelFactory; -import org.apache.plc4x.java.utils.pcap.netty.address.PcapSocketAddress; -import org.apache.plc4x.java.utils.pcap.netty.config.PcapChannelOption; -import org.apache.plc4x.java.utils.pcapreplay.netty.PcapReplayChannel; -import org.apache.plc4x.java.utils.pcapreplay.netty.config.PcapReplayChannelOption; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class PcapReplayChannelFactory extends NettyChannelFactory implements HasConfiguration<PcapReplayTransportConfiguration> { - - private static final Logger logger = LoggerFactory.getLogger(PcapReplayChannelFactory.class); - - private PcapReplayTransportConfiguration configuration; - - public PcapReplayChannelFactory(PcapSocketAddress address) { - super(address); - } - - @Override - public void setConfiguration(PcapReplayTransportConfiguration configuration) { - this.configuration = configuration; - } - - @Override - public Class<? extends Channel> getChannel() { - return PcapReplayChannel.class; - } - - @Override - public boolean isPassive() { - return true; - } - - @Override - public void configureBootstrap(Bootstrap bootstrap) { - if(configuration != null) { - logger.info("Configuring Bootstrap with {}", configuration); - bootstrap.option(PcapChannelOption.SUPPORT_VLANS, configuration.getSupportVlans()); - bootstrap.option(PcapChannelOption.PORT, configuration.getDefaultPort()); - bootstrap.option(PcapChannelOption.PROTOCOL_ID, configuration.getProtocolId()); - if(configuration.getPcapPacketHandler() != null) { - bootstrap.option(PcapChannelOption.PACKET_HANDLER, configuration.getPcapPacketHandler()); - } - bootstrap.option(PcapReplayChannelOption.SPEED_FACTOR, configuration.getReplaySpeedFactor()); - bootstrap.option(PcapReplayChannelOption.FILTER, configuration.getFilter()); - } - } - - @Override - public EventLoopGroup getEventLoopGroup() { - return new OioEventLoopGroup(); - } - -} diff --git a/plc4j/transports/pcap-replay/src/main/java/org/apache/plc4x/java/transport/pcapreplay/PcapReplayTransport.java b/plc4j/transports/pcap-replay/src/main/java/org/apache/plc4x/java/transport/pcapreplay/PcapReplayTransport.java index 877327b498..e31746bee7 100644 --- a/plc4j/transports/pcap-replay/src/main/java/org/apache/plc4x/java/transport/pcapreplay/PcapReplayTransport.java +++ b/plc4j/transports/pcap-replay/src/main/java/org/apache/plc4x/java/transport/pcapreplay/PcapReplayTransport.java @@ -18,39 +18,45 @@ */ package org.apache.plc4x.java.transport.pcapreplay; -import org.apache.plc4x.java.spi.configuration.PlcTransportConfiguration; -import org.apache.plc4x.java.api.exceptions.PlcRuntimeException; -import org.apache.plc4x.java.spi.connection.ChannelFactory; -import org.apache.plc4x.java.spi.transport.Transport; -import org.apache.plc4x.java.utils.pcapreplay.netty.address.PcapReplayAddress; +import org.apache.plc4x.java.spi.transports.api.Transport; +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 org.apache.plc4x.java.transport.pcapreplay.config.PcapReplayTransportConfiguration; +import org.apache.plc4x.java.utils.auditlog.api.AuditLog; +import org.pcap4j.core.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; -import java.io.File; +public class PcapReplayTransport implements Transport<PcapReplayTransportConfiguration> { -public class PcapReplayTransport implements Transport { + private static final Logger LOGGER = LoggerFactory.getLogger(PcapReplayTransport.class); @Override public String getTransportCode() { - return "pcap"; + return "pcap-replay"; } @Override public String getTransportName() { - return "PCAP(NG) Playback Transport"; + return "PCAP Replay"; } @Override - public ChannelFactory createChannelFactory(String transportConfig) { - File pcapFile = new File(transportConfig); - if(!pcapFile.exists() || !pcapFile.isFile()) { - throw new PlcRuntimeException("File not found at " + transportConfig); - } - PcapReplayAddress address = new PcapReplayAddress(pcapFile); - return new PcapReplayChannelFactory(address); + public Class<PcapReplayTransportConfiguration> getTransportConfigType() { + return PcapReplayTransportConfiguration.class; } @Override - public Class<? extends PlcTransportConfiguration> getTransportConfigType() { - return DefaultPcapReplayTransportConfiguration.class; + public TransportInstance<PcapReplayTransportConfiguration> createTransportInstance( + String transportUrl, TransportConfiguration configuration, AuditLog auditLog) throws TransportException { + if (!(configuration instanceof PcapReplayTransportConfiguration pcapReplayTransportConfiguration)) { + throw new IllegalArgumentException(String.format("Expected configuration of type %s but got %s", + PcapReplayTransportConfiguration.class.getSimpleName(), configuration.getClass().getSimpleName())); + } + + LOGGER.debug("Creating PCAP replay transport for file: {}", pcapReplayTransportConfiguration.pcapFile); + return new PcapReplayTransportInstance(pcapReplayTransportConfiguration, auditLog); } } diff --git a/plc4j/transports/pcap-replay/src/main/java/org/apache/plc4x/java/transport/pcapreplay/PcapReplayTransportConfiguration.java b/plc4j/transports/pcap-replay/src/main/java/org/apache/plc4x/java/transport/pcapreplay/PcapReplayTransportConfiguration.java deleted file mode 100644 index 1f9c9a1b8f..0000000000 --- a/plc4j/transports/pcap-replay/src/main/java/org/apache/plc4x/java/transport/pcapreplay/PcapReplayTransportConfiguration.java +++ /dev/null @@ -1,31 +0,0 @@ -/* - * 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.transport.pcapreplay; - -import org.apache.plc4x.java.transport.pcap.PcapTransportConfiguration; - -public interface PcapReplayTransportConfiguration extends PcapTransportConfiguration { - - float getReplaySpeedFactor(); - - boolean isLoop(); - - String getFilter(); - -} diff --git a/plc4j/transports/pcap-replay/src/main/java/org/apache/plc4x/java/transport/pcapreplay/PcapReplayTransportInstance.java b/plc4j/transports/pcap-replay/src/main/java/org/apache/plc4x/java/transport/pcapreplay/PcapReplayTransportInstance.java new file mode 100644 index 0000000000..d5d83f9bbe --- /dev/null +++ b/plc4j/transports/pcap-replay/src/main/java/org/apache/plc4x/java/transport/pcapreplay/PcapReplayTransportInstance.java @@ -0,0 +1,488 @@ +/* + * 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.transport.pcapreplay; + +import org.apache.plc4x.java.spi.transports.api.BaseTransportInstance; +import org.apache.plc4x.java.spi.transports.api.RingBuffer; +import org.apache.plc4x.java.spi.transports.api.exceptions.TransportException; +import org.apache.plc4x.java.spi.utils.StaticHelper; +import org.apache.plc4x.java.transport.pcapreplay.config.PcapReplayTransportConfiguration; +import org.apache.plc4x.java.utils.auditlog.api.AuditLog; +import org.apache.plc4x.java.utils.auditlog.api.AuditLogEventType; +import org.pcap4j.core.PcapHandle; +import org.pcap4j.core.Pcaps; +import org.pcap4j.packet.EthernetPacket; +import org.pcap4j.packet.Packet; +import org.pcap4j.util.MacAddress; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Java 21 PCAP Replay transport instance that simulates a real Ethernet device + * by replaying packets from a Wireshark capture file. + */ +public class PcapReplayTransportInstance extends BaseTransportInstance<PcapReplayTransportConfiguration> { + + private static final Logger LOGGER = LoggerFactory.getLogger(PcapReplayTransportInstance.class); + + private final PcapFilePlayer player; + private final MacAddress localMac; + private final MacAddress remoteMac; + private final RingBuffer ringBuffer; + private final Lock readLock = new ReentrantLock(); + private final Lock writeLock = new ReentrantLock(); + private volatile boolean open = true; + private Path tempPcapFile; // Temporary file for classpath resources + + public PcapReplayTransportInstance(PcapReplayTransportConfiguration configuration, AuditLog auditLog) throws TransportException { + super(configuration, auditLog); + this.ringBuffer = new RingBuffer(configuration.maxFrameSize); + + try { + // Resolve PCAP file from the filesystem or classpath + String pcapFilePath = resolvePcapFile(configuration.pcapFile); + + // Auto-detect MAC addresses if needed + MacAddress tempLocalMac; + MacAddress tempRemoteMac; + + if (configuration.autoDetectMacAddresses) { + MacAddress[] detectedMacs = detectMacAddresses(pcapFilePath, configuration.protocolId); + tempLocalMac = configuration.localAddress != null && !configuration.localAddress.isEmpty() + ? MacAddress.getByName(configuration.localAddress) + : detectedMacs[0]; + tempRemoteMac = configuration.remoteAddress != null && !configuration.remoteAddress.isEmpty() + ? MacAddress.getByName(configuration.remoteAddress) + : detectedMacs[1]; + } else { + if (configuration.localAddress == null || configuration.localAddress.isEmpty()) { + throw new TransportException("Local MAC address must be specified when auto-detect is disabled"); + } + if (configuration.remoteAddress == null || configuration.remoteAddress.isEmpty()) { + throw new TransportException("Remote MAC address must be specified when auto-detect is disabled"); + } + tempLocalMac = MacAddress.getByName(configuration.localAddress); + tempRemoteMac = MacAddress.getByName(configuration.remoteAddress); + } + + LOGGER.debug("PCAP replay - Local MAC: {}, Remote MAC: {}", tempLocalMac, tempRemoteMac); + + if (configuration.mockPlayer == null) { + // Create player + PcapFilePlayer tempPlayer = new PcapFilePlayer( + pcapFilePath, + tempLocalMac, + tempRemoteMac, + configuration.protocolId, + configuration.speedFactor, + configuration.loop, + configuration.onlyIncomingPackets, + configuration.onlyOutgoingPackets, + configuration.packetQueueSize + ); + + LOGGER.info("PCAP replay transport created for {} (speed: {}x, loop: {})", + configuration.pcapFile, + configuration.speedFactor > 0 ? configuration.speedFactor : "max", + configuration.loop); + + this.player = tempPlayer; + } else { + this.player = configuration.mockPlayer; + } + this.localMac = tempLocalMac; + this.remoteMac = tempRemoteMac; + + // Auto-start if configured + if (configuration.autoStart) { + player.start(); + } + + getAuditLog().write(AuditLogEventType.CONNECT, String.format( + "PCAP replay transport created for %s (speed: %sx, loop: %s)", + configuration.pcapFile, + configuration.speedFactor > 0 ? String.valueOf(configuration.speedFactor) : "max", + configuration.loop)); + } catch (Exception e) { + String errorMsg = String.format("Failed to create PCAP replay transport for %s - %s", + configuration.pcapFile, e.getMessage()); + LOGGER.error(errorMsg, e); + getAuditLog().write(AuditLogEventType.ERROR, "Error in constructor: " + errorMsg); + throw new TransportException(errorMsg, e); + } + } + + /** + * Detects local and remote MAC addresses from the first suitable packet in the PCAP. + */ + private MacAddress[] detectMacAddresses(String pcapFile, int protocolId) throws Exception { + PcapHandle handle = null; + try { + handle = Pcaps.openOffline(pcapFile, PcapHandle.TimestampPrecision.MICRO); + + Packet packet; + while ((packet = handle.getNextPacketEx()) != null) { + if (packet.contains(EthernetPacket.class)) { + EthernetPacket ethPacket = packet.get(EthernetPacket.class); + EthernetPacket.EthernetHeader header = ethPacket.getHeader(); + + // Check if matches protocol filter + if (protocolId > 0) { + // Mask to 16 bits to avoid sign-extension from EtherType's signed short + int packetProto = header.getType().value() & 0xFFFF; + if (packetProto != protocolId) { + continue; + } + } + + // Use first packet's addresses + MacAddress src = header.getSrcAddr(); + MacAddress dst = header.getDstAddr(); + + // Avoid broadcast addresses + if (dst.equals(MacAddress.ETHER_BROADCAST_ADDRESS)) { + continue; + } + + LOGGER.debug("Auto-detected MAC addresses from PCAP: local={}, remote={}", dst, src); + return new MacAddress[]{dst, src}; // Assume dst is local, src is remote + } + } + + throw new TransportException("No suitable packets found in PCAP file for MAC detection"); + + } finally { + if (handle != null && handle.isOpen()) { + handle.close(); + } + } + } + + @Override + public boolean isOpen() { + return open; + } + + @Override + public int getNumBytesAvailable() { + readLock.lock(); + try { + if (!isOpen()) { + return 0; + } + + return ringBuffer.availableForReading(); + } finally { + readLock.unlock(); + } + } + + @Override + public byte[] peekReadableBytes(int numBytes) throws TransportException { + if (numBytes <= 0) { + return new byte[0]; + } + + readLock.lock(); + try { + ensureOpen(); + + // Ensure we have enough data in the buffer + while (ringBuffer.availableForReading() < numBytes && isOpen()) { + int beforeFill = ringBuffer.availableForReading(); + fillRingBuffer(numBytes - beforeFill); + if (ringBuffer.availableForReading() == beforeFill) { + // No progress — player has no more data to provide + break; + } + } + + if (ringBuffer.availableForReading() < numBytes) { + throw new TransportException( + String.format("Requested %d bytes but only %d available", numBytes, ringBuffer.availableForReading()) + ); + } + + // Peek without consuming + return ringBuffer.peek(numBytes); + } catch (TransportException e) { + getAuditLog().write(AuditLogEventType.ERROR, "Error in peekReadableBytes: " + e.getMessage()); + throw e; + } finally { + readLock.unlock(); + } + } + + @Override + public byte[] read(int numBytes) throws TransportException { + if (numBytes <= 0) { + return new byte[0]; + } + + readLock.lock(); + try { + ensureOpen(); + + // Ensure we have enough data in the buffer + while (ringBuffer.availableForReading() < numBytes && isOpen()) { + int beforeFill = ringBuffer.availableForReading(); + fillRingBuffer(numBytes - beforeFill); + if (ringBuffer.availableForReading() == beforeFill) { + // No progress — player has no more data to provide + break; + } + } + + if (ringBuffer.availableForReading() < numBytes) { + throw new TransportException( + String.format("Requested %d bytes but only %d available", numBytes, ringBuffer.availableForReading()) + ); + } + + // Read and consume bytes + byte[] bytes = ringBuffer.read(numBytes); + + // Log the bytes to the audit log + if (getAuditLog().isEnabled()) { + getAuditLog().write(AuditLogEventType.INCOMING_BYTES, StaticHelper.ENCODE_HEX(bytes)); + } + + return bytes; + } catch (TransportException e) { + getAuditLog().write(AuditLogEventType.ERROR, "Error in read: " + e.getMessage()); + throw e; + } finally { + readLock.unlock(); + } + } + + @Override + public void write(byte[] bytes) throws TransportException { + if (bytes == null || bytes.length == 0) { + return; + } + + writeLock.lock(); + try { + ensureOpen(); + + // PCAP replay is read-only - writing is a no-op or could be logged + LOGGER.debug("Write operation on PCAP replay transport (simulated, {} bytes)", bytes.length); + + // Log the write attempt to audit log + if (getAuditLog().isEnabled()) { + getAuditLog().write(AuditLogEventType.OUTGOING_BYTES, "Write (simulated): " + StaticHelper.ENCODE_HEX(bytes)); + } + + // In a real implementation, you might want to: + // 1. Store written data for validation + // 2. Trigger specific responses from the PCAP based on written data + // 3. Log the write operation for debugging purposes + throw new TransportException("PCAP replay transport is read-only"); + } catch (TransportException e) { + getAuditLog().write(AuditLogEventType.ERROR, "Error in write: " + e.getMessage()); + throw e; + } finally { + writeLock.unlock(); + } + } + + @Override + public void close() throws TransportException { + if (!open) { + return; + } + + writeLock.lock(); + try { + readLock.lock(); + try { + open = false; + player.stop(); + LOGGER.debug("Closed PCAP replay transport (replayed {} packets)", player.getPacketsReplayed()); + + // Clean up temporary file if created + if (tempPcapFile != null) { + try { + Files.deleteIfExists(tempPcapFile); + LOGGER.debug("Deleted temporary PCAP file: {}", tempPcapFile); + } catch (Exception e) { + LOGGER.warn("Failed to delete temporary PCAP file: {}", e.getMessage()); + } + } + + getAuditLog().write(AuditLogEventType.CLOSE, String.format( + "Closed PCAP replay transport (replayed %d packets)", player.getPacketsReplayed())); + } finally { + readLock.unlock(); + } + } finally { + writeLock.unlock(); + } + } + + /** + * Starts the PCAP replay if not already started. + */ + public void startReplay() { + if (!player.isPlaying()) { + player.start(); + } + } + + /** + * Stops the PCAP replay. + */ + public void stopReplay() { + if (player.isPlaying()) { + player.stop(); + } + } + + /** + * Checks if replay is currently active. + */ + public boolean isReplaying() { + return player.isPlaying(); + } + + /** + * Gets the number of packets replayed so far. + */ + public long getPacketsReplayed() { + return player.getPacketsReplayed(); + } + + /** + * Fills the ring buffer with at least minBytes from the player. + */ + private void fillRingBuffer(int minBytes) throws TransportException { + try { + int totalRead = 0; + + while (totalRead < minBytes && isOpen()) { + // Get packet from player + byte[] packet = getConfiguration().readTimeout > 0 + ? player.getNextPacket(getConfiguration().readTimeout, TimeUnit.MILLISECONDS) + : player.getNextPacket(Long.MAX_VALUE, TimeUnit.DAYS); + + if (packet == null) { + // Timeout or no more packets + break; + } + + // Write packet data to the ring buffer + int written = ringBuffer.write(packet); + totalRead += written; + + // If a packet didn't fit completely, this is a problem + if (written < packet.length) { + LOGGER.warn("Packet truncated: {} bytes lost", packet.length - written); + } + } + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new TransportException("Read interrupted", e); + } + } + + /** + * Ensures the transport is still open, throws exception otherwise. + */ + private void ensureOpen() throws TransportException { + if (!isOpen()) { + throw new TransportException("Transport is closed"); + } + } + + /** + * Resolves a PCAP file path, supporting both filesystem and classpath resources. + * If the path starts with '/', it's treated as a classpath resource and extracted + * to a temporary file. Otherwise, it's treated as a filesystem path. + * + * @param pcapFile The PCAP file path (filesystem or classpath resource starting with '/') + * @return The resolved absolute file path + * @throws TransportException If the file cannot be found or accessed + */ + private String resolvePcapFile(String pcapFile) throws TransportException { + // Check if it's a classpath resource (starts with '/') + if (pcapFile.startsWith("/")) { + return extractClasspathResource(pcapFile); + } + + // Treat as a filesystem path + File file = new File(pcapFile); + if (!file.exists()) { + throw new TransportException("PCAP file not found: " + pcapFile); + } + if (!file.canRead()) { + throw new TransportException("Cannot read PCAP file: " + pcapFile); + } + + return file.getAbsolutePath(); + } + + /** + * Extracts a classpath resource to a temporary file. + * + * @param resourcePath The classpath resource path + * @return The absolute path to the temporary file + * @throws TransportException If the resource cannot be found or extracted + */ + private String extractClasspathResource(String resourcePath) throws TransportException { + try { + // Load resource from the classpath + InputStream resourceStream = getClass().getResourceAsStream(resourcePath); + if (resourceStream == null) { + throw new TransportException("Classpath resource not found: " + resourcePath); + } + + // Create a temporary file + String fileName = resourcePath.substring(resourcePath.lastIndexOf('/') + 1); + String prefix = fileName.contains(".") ? fileName.substring(0, fileName.lastIndexOf('.')) : fileName; + String suffix = fileName.contains(".") ? fileName.substring(fileName.lastIndexOf('.')) : ".tmp"; + + tempPcapFile = Files.createTempFile(prefix + "_", suffix); + + // Copy resource to the temporary file + Files.copy(resourceStream, tempPcapFile, StandardCopyOption.REPLACE_EXISTING); + resourceStream.close(); + + // Mark for deletion on JVM exit (backup cleanup) + tempPcapFile.toFile().deleteOnExit(); + + LOGGER.debug("Extracted classpath resource {} to temporary file: {}", resourcePath, tempPcapFile); + + return tempPcapFile.toAbsolutePath().toString(); + + } catch (Exception e) { + throw new TransportException("Failed to extract classpath resource " + resourcePath + ": " + e.getMessage(), e); + } + } +} diff --git a/plc4j/transports/pcap-replay/src/main/java/org/apache/plc4x/java/transport/pcapreplay/config/PcapReplayTransportConfiguration.java b/plc4j/transports/pcap-replay/src/main/java/org/apache/plc4x/java/transport/pcapreplay/config/PcapReplayTransportConfiguration.java new file mode 100644 index 0000000000..cb9d78f30f --- /dev/null +++ b/plc4j/transports/pcap-replay/src/main/java/org/apache/plc4x/java/transport/pcapreplay/config/PcapReplayTransportConfiguration.java @@ -0,0 +1,149 @@ +/* + * 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.transport.pcapreplay.config; + + +import org.apache.plc4x.java.spi.config.annotations.ConfigurationParameter; +import org.apache.plc4x.java.spi.config.annotations.Description; +import org.apache.plc4x.java.spi.config.annotations.Required; +import org.apache.plc4x.java.spi.config.annotations.defaults.BooleanDefaultValue; +import org.apache.plc4x.java.spi.config.annotations.defaults.IntDefaultValue; +import org.apache.plc4x.java.spi.transports.api.config.TransportConfiguration; +import org.apache.plc4x.java.transport.pcapreplay.PcapFilePlayer; + +public class PcapReplayTransportConfiguration implements TransportConfiguration { + + /** + * Path to the PCAP file to replay. + */ + @ConfigurationParameter("pcap-file") + @Description("Path to the PCAP file to replay.") + @Required + public String pcapFile; + + /** + * Local MAC address to simulate (acts as this device). + * If not set, uses the first MAC found in the PCAP. + */ + @ConfigurationParameter("local-address") + @Description( "Local MAC address to simulate (acts as this device).") + public String localAddress; + + /** + * Remote MAC address to communicate with. + * If not set, uses the first different MAC found in the PCAP. + */ + @ConfigurationParameter("remote-address") + @Description( "Remote MAC address to communicate with.") + public String remoteAddress; + + /** + * EtherType / Protocol number to filter (e.g., 0x88B5 for PROFINET). + * If 0, accepts all EtherTypes. + */ + @ConfigurationParameter( "protocol-id") + @Description( "EtherType / Protocol number to filter (e.g., 0x88B5 for PROFINET).") + @IntDefaultValue(0) + public int protocolId; + + /** + * Replay speed multiplier. 1.0 = real-time, 0.0 = as fast as possible, 2.0 = double speed. + */ + @ConfigurationParameter( "speed-factor") + @Description( "Replay speed multiplier. 1.0 = real-time, 0.0 = as fast as possible, 2.0 = double speed.") + @IntDefaultValue(0) + public double speedFactor; + + /** + * Loop the PCAP file when it reaches the end. + */ + @ConfigurationParameter( "loop") + @Description( "Loop the PCAP file when it reaches the end.") + @BooleanDefaultValue(false) + public boolean loop; + + /** + * Only replay packets from remote to local (simulates device responses). + * When false, replays all packets. + */ + @ConfigurationParameter( "only-incoming-packets") + @Description("Only replay packets from remote to local (simulates device responses). When false, replays all packets.") + @BooleanDefaultValue(true) + public boolean onlyIncomingPackets; + + /** + * Only replay packets from local to remote (simulates device requests). + * When false, replays all packets. + */ + @ConfigurationParameter( "only-outgoing-packets") + @Description("Only replay packets from local to remote (simulates device requests).") + @BooleanDefaultValue(false) + public boolean onlyOutgoingPackets; + + /** + * Maximum frame size in bytes. + */ + @ConfigurationParameter( "max-frame-size") + @Description("Maximum frame size in bytes.") + @IntDefaultValue(1500) + public int maxFrameSize; + + /** + * Read timeout for blocking reads in milliseconds. 0 means no timeout. + */ + @ConfigurationParameter("read-timeout") + @Description("Read timeout for blocking reads in milliseconds.") + @IntDefaultValue(0) + public int readTimeout; + + /** + * Buffer size for packet queue. + */ + @ConfigurationParameter( "packet-queue-size") + @Description("Buffer size for packet queue.") + @IntDefaultValue(1000) + public int packetQueueSize; + + /** + * Auto-detect MAC addresses from PCAP file based on first packet. + */ + @ConfigurationParameter( "auto-detect-mac-addresses") + @Description("Auto-detect MAC addresses from PCAP file based on first packet.") + @BooleanDefaultValue(true) + public boolean autoDetectMacAddresses; + + /** + * Start replay automatically when transport is created. + */ + @ConfigurationParameter( "auto-start") + @Description("Start replay automatically when transport is created.") + @BooleanDefaultValue(true) + public boolean autoStart; + + /** + * Filter by VLAN ID (0 means no VLAN filtering). + */ + @ConfigurationParameter( "vlan-id") + @Description("Filter by VLAN ID (0 means no VLAN filtering).") + @IntDefaultValue(0) + public int vlanId; + + public PcapFilePlayer mockPlayer; + +} diff --git a/plc4j/transports/pcap-replay/src/test/java/org/apache/plc4x/java/transport/pcapreplay/PcapFilePlayerTest.java b/plc4j/transports/pcap-replay/src/test/java/org/apache/plc4x/java/transport/pcapreplay/PcapFilePlayerTest.java new file mode 100644 index 0000000000..c4397eed63 --- /dev/null +++ b/plc4j/transports/pcap-replay/src/test/java/org/apache/plc4x/java/transport/pcapreplay/PcapFilePlayerTest.java @@ -0,0 +1,688 @@ +/* + * 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.transport.pcapreplay; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.pcap4j.packet.factory.PacketFactories; +import org.pcap4j.packet.namednumber.DataLinkType; +import org.pcap4j.util.MacAddress; + +import java.io.File; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for PcapFilePlayer. + */ +class PcapFilePlayerTest { + + private PcapFilePlayer player; + private MacAddress localMac; + private MacAddress remoteMac; + + @BeforeAll + static void initPacketFactory() { + // Force pcap4j to eagerly load the PacketFactoryBinder. + // Without this, the factory loads lazily on the first player thread, + // causing a race where early tests finish before parsing is available. + PacketFactories.getFactory(org.pcap4j.packet.Packet.class, DataLinkType.class); + } + + @BeforeEach + void setUp() { + try { + // For some reason it doesn't work if we pass this in from the outside. + //if (os == "mac") { + // On my Intel Mac I found the libs in: "/usr/local/Cellar/libpcap/1.10.1/lib" + // On my M1 Mac I found the libs in: "/opt/homebrew/Cellar/libpcap/1.10.1/lib" + if (new File("/usr/local/Cellar/libpcap/1.10.1/lib").exists()) { + System.getProperties().setProperty("jna.library.path", "/usr/local/Cellar/libpcap/1.10.1/lib"); + } else if (new File("/usr/local/Cellar/libpcap/1.10.5/lib").exists()) { + System.getProperties().setProperty("jna.library.path", "/usr/local/Cellar/libpcap/1.10.5/lib"); + } else if (new File("/opt/homebrew/opt/libpcap/lib").exists()) { + System.getProperties().setProperty("jna.library.path", "/opt/homebrew/opt/libpcap/lib"); + } + //} + } catch (Error e) { + e.printStackTrace(); + } + + localMac = MacAddress.getByName("00:11:22:33:44:55"); + remoteMac = MacAddress.getByName("AA:BB:CC:DD:EE:FF"); + } + + @AfterEach + void tearDown() { + if (player != null) { + player.stop(); + } + } + + @Test + void testInitialState() { + player = new PcapFilePlayer( + "/path/to/test.pcap", + localMac, + remoteMac, + 0x88B5, + 1.0, + false, + true, + false, + 100 + ); + + assertFalse(player.isPlaying()); + assertEquals(0, player.getPacketsReplayed()); + } + + @Test + void testStart_changesPlayingState() throws InterruptedException { + // Use valid test pcap with looping to ensure player stays running + player = new PcapFilePlayer( + "/test.pcap", + localMac, + remoteMac, + 0, // No protocol filter + 0.0, + true, // Enable looping so player doesn't stop immediately + false, + false, + 100 + ); + + player.start(); + // Give the thread time to start and stabilize + Thread.sleep(50); + assertTrue(player.isPlaying()); + } + + @Test + void testStop_changesPlayingState() throws InterruptedException { + // Use valid test pcap with looping to ensure player stays running + player = new PcapFilePlayer( + "/test.pcap", + localMac, + remoteMac, + 0, // No protocol filter + 0.0, + true, // Enable looping so player doesn't stop immediately + false, + false, + 100 + ); + + player.start(); + // Give the thread time to start and stabilize + Thread.sleep(50); + assertTrue(player.isPlaying()); + + player.stop(); + assertFalse(player.isPlaying()); + } + + @Test + void testGetNextPacket_timeout() throws InterruptedException { + player = new PcapFilePlayer( + "/path/to/test.pcap", + localMac, + remoteMac, + 0x88B5, + 0.0, + false, + true, + false, + 100 + ); + + // Should timeout since no valid file + byte[] packet = player.getNextPacket(100, TimeUnit.MILLISECONDS); + assertNull(packet); // Timeout + } + + @Test + void testGetQueueSize_initially() { + player = new PcapFilePlayer( + "/path/to/test.pcap", + localMac, + remoteMac, + 0x88B5, + 0.0, + false, + true, + false, + 100 + ); + + assertEquals(0, player.getQueueSize()); + } + + @Test + void testPlayback_withValidPcap() throws Exception { + // test.pcap contains 4 packets (2 incoming, 2 outgoing) with EtherType 0x88B5 + // and the same MAC addresses used in this test class + player = new PcapFilePlayer( + "/test.pcap", + localMac, + remoteMac, + 0x88B5, + 0.0, // As fast as possible + false, + true, // Only incoming + false, + 100 + ); + + player.start(); + Thread.sleep(200); // Let it play for a bit + + assertTrue(player.getPacketsReplayed() > 0); + + byte[] packet = player.getNextPacket(1, TimeUnit.SECONDS); + assertNotNull(packet); + assertTrue(packet.length > 0); + } + + @Test + void testPlayback_withLoop() throws Exception { + // test.pcap has 2 incoming 0x88B5 packets; with looping, count grows over time. + // Use a large queue so it doesn't fill up and stall the counter. + player = new PcapFilePlayer( + "/test.pcap", + localMac, + remoteMac, + 0x88B5, + 0.0, + true, // Loop + true, + false, + 100000 + ); + + player.start(); + Thread.sleep(200); // Let it loop + + long packets1 = player.getPacketsReplayed(); + Thread.sleep(200); + long packets2 = player.getPacketsReplayed(); + + // Should have replayed more packets (looping) + assertTrue(packets2 > packets1, + "Expected more packets after looping: packets1=" + packets1 + ", packets2=" + packets2); + } + + @Test + void testPlayback_speedFactor() throws Exception { + // test.pcap has 2 incoming 0x88B5 packets 20ms apart (at indices 0 and 2). + // At real-time speed (1.0), replaying should take at least ~20ms. + player = new PcapFilePlayer( + "/test.pcap", + localMac, + remoteMac, + 0x88B5, + 1.0, // Real-time + false, + true, + false, + 100 + ); + + long start = System.currentTimeMillis(); + player.start(); + + // Wait for the 2 incoming packets to be replayed (with timeout to avoid hang) + long deadline = start + 5000; + while (player.getPacketsReplayed() < 2 && System.currentTimeMillis() < deadline) { + Thread.sleep(10); + } + + long elapsed = System.currentTimeMillis() - start; + + // At real-time speed, the 20ms inter-packet gap means replay takes nonzero time + assertTrue(elapsed > 10, "Real-time replay should take measurable time"); + assertTrue(player.getPacketsReplayed() >= 2, "Should have replayed both incoming packets"); + } + + @Test + void testGetPacketsReplayed_initially() { + player = new PcapFilePlayer( + "/path/to/test.pcap", + localMac, + remoteMac, + 0x88B5, + 0.0, + false, + true, + false, + 100 + ); + + assertEquals(0, player.getPacketsReplayed()); + } + + @Test + void testConstructor_withAllParameters() { + player = new PcapFilePlayer( + "/path/to/test.pcap", + localMac, + remoteMac, + 0x88B5, + 2.0, // Double speed + true, // Loop + false, // Not only incoming + true, // Only outgoing + 500 // Queue size + ); + + assertNotNull(player); + assertFalse(player.isPlaying()); + assertEquals(0, player.getPacketsReplayed()); + assertEquals(0, player.getQueueSize()); + } + + @Test + void testStart_alreadyPlaying() throws InterruptedException { + // Use valid test pcap with looping to ensure player stays running + player = new PcapFilePlayer( + "/test.pcap", + localMac, + remoteMac, + 0, // No protocol filter + 0.0, + true, // Enable looping so player doesn't stop immediately + false, + false, + 100 + ); + + player.start(); + // Give the thread time to start and stabilize + Thread.sleep(50); + assertTrue(player.isPlaying()); + + // Start again - should be idempotent + player.start(); + assertTrue(player.isPlaying()); + } + + @Test + void testStop_notPlaying() { + player = new PcapFilePlayer( + "/path/to/test.pcap", + localMac, + remoteMac, + 0x88B5, + 0.0, + false, + true, + false, + 100 + ); + + // Stop without starting - should not throw + assertDoesNotThrow(() -> player.stop()); + assertFalse(player.isPlaying()); + } + + @Test + void testStop_idempotent() throws InterruptedException { + // Use valid test pcap with looping to ensure player stays running + player = new PcapFilePlayer( + "/test.pcap", + localMac, + remoteMac, + 0, // No protocol filter to ensure packets match + 0.0, + true, // Enable looping so player doesn't stop immediately + false, + false, + 100 + ); + + player.start(); + // Give the thread time to start and stabilize + Thread.sleep(50); + assertTrue(player.isPlaying()); + + player.stop(); + assertFalse(player.isPlaying()); + + // Stop again - should be idempotent + player.stop(); + assertFalse(player.isPlaying()); + } + + @Test + void testConstructor_withSmallQueueSize() { + player = new PcapFilePlayer( + "/path/to/test.pcap", + localMac, + remoteMac, + 0, // No protocol filter + 0.0, + false, + false, // Not only incoming + false, // Not only outgoing + 1 // Minimal queue size + ); + + assertNotNull(player); + assertEquals(0, player.getQueueSize()); + } + + @Test + void testGetNextPacket_withoutStart() throws InterruptedException { + player = new PcapFilePlayer( + "/path/to/test.pcap", + localMac, + remoteMac, + 0x88B5, + 0.0, + false, + true, + false, + 100 + ); + + // Should timeout immediately since no packets are being played + byte[] packet = player.getNextPacket(50, TimeUnit.MILLISECONDS); + assertNull(packet); + } + + @Test + void testConstructor_withBothDirectionFlags() { + player = new PcapFilePlayer( + "/path/to/test.pcap", + localMac, + remoteMac, + 0x88B5, + 1.5, + false, + true, // Only incoming + true, // Only outgoing (contradictory) + 100 + ); + + assertNotNull(player); + // The contradictory flags are handled in the filter logic + } + + @Test + void testGetQueueSize_afterStarting() throws InterruptedException { + player = new PcapFilePlayer( + "/path/to/test.pcap", + localMac, + remoteMac, + 0x88B5, + 0.0, + false, + true, + false, + 100 + ); + + player.start(); + Thread.sleep(50); // Give it time to process + + // Queue size should be >= 0 + int size = player.getQueueSize(); + assertTrue(size >= 0); + } + + @Test + void testPlayback_withTestPcap() throws Exception { + // Use the test.pcap from classpath (will be extracted automatically by PcapFilePlayer) + player = new PcapFilePlayer( + "/test.pcap", + localMac, + remoteMac, + 0, // No protocol filter + 0.0, // As fast as possible + false, + false, // Accept all packets + false, + 1000 + ); + + player.start(); + assertTrue(player.isPlaying()); + + // Wait for replay to complete + Thread.sleep(200); + + // Should have processed some packets + long packetsReplayed = player.getPacketsReplayed(); + assertTrue(packetsReplayed >= 0); + + player.stop(); + assertFalse(player.isPlaying()); + } + + @Test + void testPlayback_withProtocolFilter() throws Exception { + // Test with protocol filter + player = new PcapFilePlayer( + "/test.pcap", + localMac, + remoteMac, + 0x0800, // IPv4 protocol + 0.0, + false, + false, + false, + 1000 + ); + + player.start(); + Thread.sleep(100); + + long packetsReplayed = player.getPacketsReplayed(); + assertTrue(packetsReplayed >= 0); + + player.stop(); + } + + @Test + void testPlayback_onlyIncomingPackets() throws Exception { + player = new PcapFilePlayer( + "/test.pcap", + localMac, + remoteMac, + 0, + 0.0, + false, + true, // Only incoming + false, + 1000 + ); + + player.start(); + Thread.sleep(100); + + player.stop(); + assertFalse(player.isPlaying()); + } + + @Test + void testPlayback_onlyOutgoingPackets() throws Exception { + player = new PcapFilePlayer( + "/test.pcap", + localMac, + remoteMac, + 0, + 0.0, + false, + false, + true, // Only outgoing + 1000 + ); + + player.start(); + Thread.sleep(100); + + player.stop(); + assertFalse(player.isPlaying()); + } + + @Test + void testPlayback_withBothDirectionFilters() throws Exception { + // Both flags set - should reject all packets + player = new PcapFilePlayer( + "/test.pcap", + localMac, + remoteMac, + 0, + 0.0, + false, + true, // Only incoming + true, // Only outgoing (contradictory) + 1000 + ); + + player.start(); + Thread.sleep(100); + + // Should not have replayed any packets due to contradictory filters + long packetsReplayed = player.getPacketsReplayed(); + assertEquals(0, packetsReplayed); + + player.stop(); + } + + @Test + void testPlayback_withLooping() throws Exception { + player = new PcapFilePlayer( + "/test.pcap", + localMac, + remoteMac, + 0, + 0.0, + true, // Loop enabled + false, + false, + 1000 + ); + + player.start(); + Thread.sleep(300); // Give time to loop + + long packetsAfterDelay = player.getPacketsReplayed(); + + player.stop(); + assertFalse(player.isPlaying()); + } + + @Test + void testPlayback_withSpeedFactor() throws Exception { + player = new PcapFilePlayer( + "/test.pcap", + localMac, + remoteMac, + 0, + 0.5, // Half speed + false, + false, + false, + 1000 + ); + + long startTime = System.currentTimeMillis(); + player.start(); + Thread.sleep(200); + + player.stop(); + } + + @Test + void testGetNextPacket_withActualData() throws Exception { + player = new PcapFilePlayer( + "/test.pcap", + localMac, + remoteMac, + 0, + 0.0, + false, + false, + false, + 1000 + ); + + player.start(); + Thread.sleep(100); // Wait for some packets + + // Try to get a packet with timeout + byte[] packet = player.getNextPacket(500, TimeUnit.MILLISECONDS); + // May be null if no matching packets, or contain data + + player.stop(); + } + + @Test + void testStop_duringPlayback() throws Exception { + player = new PcapFilePlayer( + "/test.pcap", + localMac, + remoteMac, + 0, + 0.0, + true, // Loop - will run indefinitely + false, + false, + 1000 + ); + + player.start(); + assertTrue(player.isPlaying()); + + Thread.sleep(50); + + player.stop(); + assertFalse(player.isPlaying()); + + // Wait for thread to fully stop + Thread.sleep(50); + assertFalse(player.isPlaying()); + } + + @Test + void testPlayback_invalidPcapFile() throws Exception { + player = new PcapFilePlayer( + "/nonexistent/invalid.pcap", + localMac, + remoteMac, + 0, + 0.0, + false, + false, + false, + 1000 + ); + + player.start(); + Thread.sleep(100); + + // Should handle gracefully - player might stop due to error + // but shouldn't crash + player.stop(); + } +} diff --git a/plc4j/transports/pcap-replay/src/test/java/org/apache/plc4x/java/transport/pcapreplay/PcapReplayTransportInstanceTest.java b/plc4j/transports/pcap-replay/src/test/java/org/apache/plc4x/java/transport/pcapreplay/PcapReplayTransportInstanceTest.java new file mode 100644 index 0000000000..b3310f01bb --- /dev/null +++ b/plc4j/transports/pcap-replay/src/test/java/org/apache/plc4x/java/transport/pcapreplay/PcapReplayTransportInstanceTest.java @@ -0,0 +1,782 @@ +/* + * 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.transport.pcapreplay; + +import org.apache.plc4x.java.spi.transports.api.exceptions.TransportException; +import org.apache.plc4x.java.transport.pcapreplay.config.PcapReplayTransportConfiguration; +import org.apache.plc4x.java.utils.auditlog.api.AuditLog; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.pcap4j.util.MacAddress; + +import java.io.File; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * Tests for PcapReplayTransportInstance. + */ +class PcapReplayTransportInstanceTest { + + private PcapFilePlayer mockPlayer; + private PcapReplayTransportInstance transportInstance; + private PcapReplayTransportConfiguration config; + private MacAddress localMac; + private MacAddress remoteMac; + + @BeforeEach + void setUp() throws TransportException { + try { + // For some reason it doesn't work if we pass this in from the outside. + //if (os == "mac") { + // On my Intel Mac I found the libs in: "/usr/local/Cellar/libpcap/1.10.1/lib" + // On my M1 Mac I found the libs in: "/opt/homebrew/Cellar/libpcap/1.10.1/lib" + if (new File("/usr/local/Cellar/libpcap/1.10.1/lib").exists()) { + System.getProperties().setProperty("jna.library.path", "/usr/local/Cellar/libpcap/1.10.1/lib"); + } else if (new File("/usr/local/Cellar/libpcap/1.10.5/lib").exists()) { + System.getProperties().setProperty("jna.library.path", "/usr/local/Cellar/libpcap/1.10.5/lib"); + } else if (new File("/opt/homebrew/opt/libpcap/lib").exists()) { + System.getProperties().setProperty("jna.library.path", "/opt/homebrew/opt/libpcap/lib"); + } + //} + } catch (Error e) { + e.printStackTrace(); + } + + // Create a mock player + mockPlayer = mock(PcapFilePlayer.class); + when(mockPlayer.isPlaying()).thenReturn(true); + when(mockPlayer.getPacketsReplayed()).thenReturn(0L); + + // Set up MAC addresses + localMac = MacAddress.getByName("00:11:22:33:44:55"); + remoteMac = MacAddress.getByName("AA:BB:CC:DD:EE:FF"); + + // Create configuration + config = new PcapReplayTransportConfiguration(); + config.pcapFile = "/test.pcap"; + config.maxFrameSize = 1500; + config.readTimeout = 100; + config.localAddress = localMac.toString(); + config.remoteAddress = remoteMac.toString(); + config.packetQueueSize = 100; + config.maxFrameSize = 1500; + config.autoStart = false; // Don't auto-start in tests + config.mockPlayer = mockPlayer; + + // Create transport-instance + transportInstance = new PcapReplayTransportInstance(config, AuditLog.builder().build()); + } + + @AfterEach + void tearDown() throws Exception { + if (transportInstance != null && transportInstance.isOpen()) { + transportInstance.close(); + } + } + + @Test + void testGetConfiguration() { + PcapReplayTransportConfiguration config = transportInstance.getConfiguration(); + assertNotNull(config); + } + + @Test + void testIsOpen_initially() { + assertTrue(transportInstance.isOpen()); + } + + @Test + void testIsOpen_whenClosed() throws TransportException { + transportInstance.close(); + assertFalse(transportInstance.isOpen()); + } + + @Test + void testGetNumBytesAvailable() throws TransportException { + when(mockPlayer.getQueueSize()).thenReturn(5); + + int available = transportInstance.getNumBytesAvailable(); + assertTrue(available >= 0); + } + + @Test + void testWrite_simulatedOperation() throws TransportException { + byte[] data = new byte[]{0x01, 0x02, 0x03, 0x04}; + + // Should not throw (write is simulated in replay mode) + assertThrows(TransportException.class, () -> transportInstance.write(data)); + } + + @Test + void testWrite_emptyArray() throws TransportException { + assertDoesNotThrow(() -> transportInstance.write(new byte[0])); + } + + @Test + void testWrite_nullArray() throws TransportException { + assertDoesNotThrow(() -> transportInstance.write(null)); + } + + @Test + void testRead_zeroBytes() throws TransportException { + byte[] result = transportInstance.read(0); + assertEquals(0, result.length); + } + + @Test + void testRead_whenClosed_throwsException() throws TransportException { + transportInstance.close(); + + assertThrows(TransportException.class, () -> + transportInstance.read(10) + ); + } + + @Test + void testWrite_whenClosed_throwsException() throws TransportException { + transportInstance.close(); + + assertThrows(TransportException.class, () -> + transportInstance.write(new byte[]{0x01}) + ); + } + + @Test + void testStartReplay() { + when(mockPlayer.isPlaying()).thenReturn(false); + transportInstance.startReplay(); + verify(mockPlayer, atLeastOnce()).start(); + } + + @Test + void testStopReplay() { + transportInstance.stopReplay(); + verify(mockPlayer).stop(); + } + + @Test + void testIsReplaying() { + when(mockPlayer.isPlaying()).thenReturn(true); + assertTrue(transportInstance.isReplaying()); + + when(mockPlayer.isPlaying()).thenReturn(false); + assertFalse(transportInstance.isReplaying()); + } + + @Test + void testGetPacketsReplayed() { + when(mockPlayer.getPacketsReplayed()).thenReturn(42L); + assertEquals(42L, transportInstance.getPacketsReplayed()); + } + + @Test + void testClose_idempotent() throws TransportException { + transportInstance.close(); + assertFalse(transportInstance.isOpen()); + + // Should not throw + assertDoesNotThrow(() -> transportInstance.close()); + assertFalse(transportInstance.isOpen()); + + verify(mockPlayer, atLeastOnce()).stop(); // Called twice + } + + @Test + void testClose_stopsPlayer() throws TransportException { + transportInstance.close(); + verify(mockPlayer).stop(); + } + + @Test + void testRead_withData() throws Exception { + byte[] testData = new byte[]{0x01, 0x02, 0x03, 0x04}; + + // Mock player to return data when polled + when(mockPlayer.getNextPacket(anyLong(), any(TimeUnit.class))).thenReturn(testData); + + byte[] result = transportInstance.read(4); + assertArrayEquals(testData, result); + } + + @Test + void testPeekReadableBytes_doesNotConsume() throws Exception { + byte[] testData = new byte[]{0x01, 0x02, 0x03, 0x04}; + + // Mock player to return data when polled + when(mockPlayer.getNextPacket(anyLong(), any(TimeUnit.class))).thenReturn(testData); + + // Peek twice — should return the same data both times + byte[] peek1 = transportInstance.peekReadableBytes(4); + byte[] peek2 = transportInstance.peekReadableBytes(4); + + assertArrayEquals(peek1, peek2); + + // Read should still return same data and consume it + byte[] read = transportInstance.read(4); + assertArrayEquals(testData, read); + } + + @Test + void testPeekReadableBytes_zeroBytes() throws TransportException { + byte[] result = transportInstance.peekReadableBytes(0); + assertNotNull(result); + assertEquals(0, result.length); + } + + @Test + void testPeekReadableBytes_whenClosed() throws TransportException { + transportInstance.close(); + + assertThrows(TransportException.class, () -> + transportInstance.peekReadableBytes(10) + ); + } + + @Test + void testStartReplay_multipleTimes() { + // First call: isPlaying returns false, so start() is called + when(mockPlayer.isPlaying()).thenReturn(false); + transportInstance.startReplay(); + + // Second call: isPlaying returns true, so start() is not called again + when(mockPlayer.isPlaying()).thenReturn(true); + transportInstance.startReplay(); + + // Should have called start once (when player was not playing) + verify(mockPlayer, times(1)).start(); + } + + @Test + void testStopReplay_multipleTimes() { + transportInstance.stopReplay(); + transportInstance.stopReplay(); + // Should handle multiple stops gracefully + verify(mockPlayer, times(2)).stop(); + } + + @Test + void testConstructor_withAutoStart() throws TransportException { + config.autoStart = true; + PcapFilePlayer autoStartPlayer = mock(PcapFilePlayer.class); + config.mockPlayer = autoStartPlayer; + + PcapReplayTransportInstance autoStartInstance = new PcapReplayTransportInstance(config, AuditLog.builder().build()); + + // Should have started automatically + verify(autoStartPlayer).start(); + + autoStartInstance.close(); + } + + @Test + void testConstructor_withoutMockPlayer() throws TransportException { + config.mockPlayer = null; + config.pcapFile = "/test.pcap"; + + // Should create actual player (will use classpath resource) + PcapReplayTransportInstance instance = new PcapReplayTransportInstance(config, AuditLog.builder().build()); + assertNotNull(instance); + assertTrue(instance.isOpen()); + + instance.close(); + } + + @Test + void testConstructor_withProtocolFilter() throws TransportException { + config.protocolId = 0x88B5; // PROFINET + PcapReplayTransportInstance instance = new PcapReplayTransportInstance(config, AuditLog.builder().build()); + + assertNotNull(instance); + instance.close(); + } + + @Test + void testConstructor_withVlanId() throws TransportException { + config.vlanId = 100; + PcapReplayTransportInstance instance = new PcapReplayTransportInstance(config, AuditLog.builder().build()); + + assertNotNull(instance); + instance.close(); + } + + @Test + void testConstructor_withSpeedFactor() throws TransportException { + config.speedFactor = 2.0; // Double speed + PcapReplayTransportInstance instance = new PcapReplayTransportInstance(config, AuditLog.builder().build()); + + assertNotNull(instance); + instance.close(); + } + + @Test + void testConstructor_withLoopEnabled() throws TransportException { + config.loop = true; + PcapReplayTransportInstance instance = new PcapReplayTransportInstance(config, AuditLog.builder().build()); + + assertNotNull(instance); + instance.close(); + } + + @Test + void testConstructor_withOnlyIncomingPackets() throws TransportException { + config.onlyIncomingPackets = true; + config.onlyOutgoingPackets = false; + PcapReplayTransportInstance instance = new PcapReplayTransportInstance(config, AuditLog.builder().build()); + + assertNotNull(instance); + instance.close(); + } + + @Test + void testConstructor_withOnlyOutgoingPackets() throws TransportException { + config.onlyIncomingPackets = false; + config.onlyOutgoingPackets = true; + PcapReplayTransportInstance instance = new PcapReplayTransportInstance(config, AuditLog.builder().build()); + + assertNotNull(instance); + instance.close(); + } + + @Test + void testGetNumBytesAvailable_withZeroBytes() { + when(mockPlayer.getQueueSize()).thenReturn(0); + + assertDoesNotThrow(() -> { + int available = transportInstance.getNumBytesAvailable(); + assertEquals(0, available); + }); + } + + @Test + void testGetNumBytesAvailable_whenClosed() throws TransportException { + transportInstance.close(); + + // Should return 0 when closed + int available = transportInstance.getNumBytesAvailable(); + assertEquals(0, available); + } + + @Test + void testWrite_largeArray() throws TransportException { + byte[] largeData = new byte[10000]; + for (int i = 0; i < largeData.length; i++) { + largeData[i] = (byte) (i % 256); + } + + PcapReplayTransportInstance instance = new PcapReplayTransportInstance(config, AuditLog.builder().build()); + + // Writing should throw an exception as this is a replay-only transport + assertThrows(TransportException.class, () -> instance.write(largeData)); + + instance.close(); + } + + @Test + void testIsReplaying_afterClose() throws TransportException { + when(mockPlayer.isPlaying()).thenReturn(true); + transportInstance.close(); + + // Player should be stopped after close + when(mockPlayer.isPlaying()).thenReturn(false); + assertFalse(transportInstance.isReplaying()); + } + + @Test + void testGetPacketsReplayed_afterMultipleOperations() { + when(mockPlayer.getPacketsReplayed()).thenReturn(10L); + assertEquals(10L, transportInstance.getPacketsReplayed()); + + when(mockPlayer.getPacketsReplayed()).thenReturn(25L); + assertEquals(25L, transportInstance.getPacketsReplayed()); + } + + @Test + void testConstructor_withManualMacAddresses() throws TransportException { + config.autoDetectMacAddresses = false; + config.localAddress = "00:11:22:33:44:55"; + config.remoteAddress = "AA:BB:CC:DD:EE:FF"; + + PcapReplayTransportInstance instance = new PcapReplayTransportInstance(config, AuditLog.builder().build()); + assertNotNull(instance); + assertTrue(instance.isOpen()); + instance.close(); + } + + @Test + void testRead_withActualPcapPlayback() throws Exception { + // Create a real player instance that uses test.pcap + config.mockPlayer = null; // Use real player + config.pcapFile = "/test.pcap"; + config.autoStart = true; + config.onlyIncomingPackets = false; + config.onlyOutgoingPackets = false; + + PcapReplayTransportInstance instance = new PcapReplayTransportInstance(config, AuditLog.builder().build()); + + // Wait for some packets to be queued + Thread.sleep(200); + + // Try to read - may get data or timeout + try { + byte[] data = instance.read(0); + assertNotNull(data); + } catch (TransportException e) { + // Timeout is acceptable + } + + instance.close(); + } + + @Test + void testPeekReadableBytes_withActualData() throws Exception { + config.mockPlayer = null; + config.pcapFile = "/test.pcap"; + config.autoStart = true; + + PcapReplayTransportInstance instance = new PcapReplayTransportInstance(config, AuditLog.builder().build()); + + Thread.sleep(200); + + // Try to peek + try { + byte[] peeked = instance.peekReadableBytes(0); + assertNotNull(peeked); + } catch (TransportException e) { + // Timeout is acceptable + } + + instance.close(); + } + + @Test + void testGetNumBytesAvailable_withRealPlayer() throws Exception { + config.mockPlayer = null; + config.pcapFile = "/test.pcap"; + config.autoStart = true; + + PcapReplayTransportInstance instance = new PcapReplayTransportInstance(config, AuditLog.builder().build()); + + Thread.sleep(150); + + int available = instance.getNumBytesAvailable(); + assertTrue(available >= 0); + + instance.close(); + } + + @Test + void testStartStopReplay_withRealPlayer() throws Exception { + config.mockPlayer = null; + config.pcapFile = "/test.pcap"; + config.autoStart = false; + config.loop = true; // Enable looping so player keeps running + + PcapReplayTransportInstance instance = new PcapReplayTransportInstance(config, AuditLog.builder().build()); + + assertFalse(instance.isReplaying()); + + instance.startReplay(); + Thread.sleep(100); // Give it more time to start + assertTrue(instance.isReplaying()); + + instance.stopReplay(); + Thread.sleep(50); + assertFalse(instance.isReplaying()); + + instance.close(); + } + + @Test + void testGetPacketsReplayed_withRealPlayer() throws Exception { + config.mockPlayer = null; + config.pcapFile = "/test.pcap"; + config.autoStart = true; + + PcapReplayTransportInstance instance = new PcapReplayTransportInstance(config, AuditLog.builder().build()); + + Thread.sleep(200); + + long packets = instance.getPacketsReplayed(); + assertTrue(packets >= 0); + + instance.close(); + } + + @Test + void testConstructor_withReadTimeout() throws TransportException { + config.readTimeout = 1000; // 1 second timeout + + PcapReplayTransportInstance instance = new PcapReplayTransportInstance(config, AuditLog.builder().build()); + assertNotNull(instance); + instance.close(); + } + + @Test + void testConstructor_withDifferentPacketQueueSizes() throws TransportException { + config.packetQueueSize = 10; + + PcapReplayTransportInstance instance = new PcapReplayTransportInstance(config, AuditLog.builder().build()); + assertNotNull(instance); + instance.close(); + + config.packetQueueSize = 5000; + instance = new PcapReplayTransportInstance(config, AuditLog.builder().build()); + assertNotNull(instance); + instance.close(); + } + + @Test + void testMultipleReadsInSequence() throws Exception { + config.mockPlayer = null; + config.pcapFile = "/test.pcap"; + config.autoStart = true; + + PcapReplayTransportInstance instance = new PcapReplayTransportInstance(config, AuditLog.builder().build()); + + Thread.sleep(200); + + // Try multiple reads + for (int i = 0; i < 3; i++) { + try { + byte[] data = instance.read(0); + assertNotNull(data); + } catch (TransportException e) { + // Timeout acceptable + } + } + + instance.close(); + } + + @Test + void testRead_afterStoppingReplay() throws Exception { + config.mockPlayer = null; + config.pcapFile = "/test.pcap"; + config.autoStart = true; + + PcapReplayTransportInstance instance = new PcapReplayTransportInstance(config, AuditLog.builder().build()); + + Thread.sleep(100); + + instance.stopReplay(); + Thread.sleep(50); + + // Read should still work but likely timeout + try { + byte[] data = instance.read(0); + } catch (TransportException e) { + // Expected timeout + } + + instance.close(); + } + + @Test + void testConstructor_allConfigurationCombinations() throws TransportException { + // Test various configuration combinations + config.protocolId = 0x88B5; + config.speedFactor = 1.0; + config.loop = true; + config.vlanId = 100; + config.onlyIncomingPackets = true; + config.maxFrameSize = 9000; + + PcapReplayTransportInstance instance = new PcapReplayTransportInstance(config, AuditLog.builder().build()); + assertNotNull(instance); + assertTrue(instance.isOpen()); + instance.close(); + } + + @Test + void testWrite_throwsException() throws TransportException { + config.mockPlayer = mockPlayer; + + PcapReplayTransportInstance instance = new PcapReplayTransportInstance(config, AuditLog.builder().build()); + + // Writing should throw an exception as this is a replay-only transport + assertThrows(TransportException.class, () -> instance.write(new byte[]{1, 2, 3})); + + instance.close(); + } + + @Test + void testPeekReadableBytes_withZeroBytes() throws TransportException { + config.mockPlayer = mockPlayer; + + PcapReplayTransportInstance instance = new PcapReplayTransportInstance(config, AuditLog.builder().build()); + + byte[] result = instance.peekReadableBytes(0); + assertNotNull(result); + assertEquals(0, result.length); + + instance.close(); + } + + @Test + void testPeekReadableBytes_withNegativeBytes() throws TransportException { + config.mockPlayer = mockPlayer; + + PcapReplayTransportInstance instance = new PcapReplayTransportInstance(config, AuditLog.builder().build()); + + // Negative bytes should throw an exception + assertArrayEquals(new byte[0], instance.peekReadableBytes(-1)); + + instance.close(); + } + + @Test + void testRead_withZeroBytes() throws TransportException { + config.mockPlayer = mockPlayer; + + PcapReplayTransportInstance instance = new PcapReplayTransportInstance(config, AuditLog.builder().build()); + + byte[] result = instance.read(0); + assertNotNull(result); + assertEquals(0, result.length); + + instance.close(); + } + + @Test + void testRead_withNegativeBytes() throws TransportException { + config.mockPlayer = mockPlayer; + + PcapReplayTransportInstance instance = new PcapReplayTransportInstance(config, AuditLog.builder().build()); + + // Negative bytes should throw an exception + assertArrayEquals(new byte[0], instance.read(-1)); + + instance.close(); + } + + @Test + void testMultipleClose() throws TransportException { + config.mockPlayer = mockPlayer; + + PcapReplayTransportInstance instance = new PcapReplayTransportInstance(config, AuditLog.builder().build()); + + instance.close(); + assertFalse(instance.isOpen()); + + // Closing again should be idempotent + instance.close(); + assertFalse(instance.isOpen()); + } + + @Test + void testIsOpen_initialState() throws TransportException { + config.mockPlayer = mockPlayer; + + PcapReplayTransportInstance instance = new PcapReplayTransportInstance(config, AuditLog.builder().build()); + + assertTrue(instance.isOpen()); + + instance.close(); + } + + @Test + void testGetNumBytesAvailable_initially() throws TransportException { + config.mockPlayer = mockPlayer; + + PcapReplayTransportInstance instance = new PcapReplayTransportInstance(config, AuditLog.builder().build()); + + // Initially should be 0 + assertEquals(0, instance.getNumBytesAvailable()); + + instance.close(); + } + + @Test + void testStartReplay_alreadyPlaying() throws TransportException { + config.mockPlayer = mockPlayer; + when(mockPlayer.isPlaying()).thenReturn(true); + + PcapReplayTransportInstance instance = new PcapReplayTransportInstance(config, AuditLog.builder().build()); + + // Start when already playing should not call start again + instance.startReplay(); + verify(mockPlayer, times(0)).start(); + + instance.close(); + } + + @Test + void testStopReplay_notPlaying() throws TransportException { + config.mockPlayer = mockPlayer; + when(mockPlayer.isPlaying()).thenReturn(false); + + PcapReplayTransportInstance instance = new PcapReplayTransportInstance(config, AuditLog.builder().build()); + + // Stop when not playing should not call stop + instance.stopReplay(); + verify(mockPlayer, times(0)).stop(); + + instance.close(); + } + + @Test + void testConstructor_withInvalidConfiguration() { + config.pcapFile = null; + config.mockPlayer = null; + + // Should throw exception with invalid configuration + assertThrows(TransportException.class, () -> new PcapReplayTransportInstance(config, AuditLog.builder().build())); + } + + @Test + void testConstructor_withEmptyMacAddress() { + config.mockPlayer = null; + config.pcapFile = "/test.pcap"; + config.localAddress = ""; + config.remoteAddress = ""; + config.autoDetectMacAddresses = false; + + // Should throw exception with empty MAC addresses + assertThrows(TransportException.class, () -> new PcapReplayTransportInstance(config, AuditLog.builder().build())); + } + + @Test + void testPeekReadableBytes_moreThanAvailable() throws TransportException { + config.mockPlayer = mockPlayer; + + PcapReplayTransportInstance instance = new PcapReplayTransportInstance(config, AuditLog.builder().build()); + + // Requesting more bytes than available should throw (player returns no data) + assertThrows(TransportException.class, () -> instance.peekReadableBytes(100)); + + instance.close(); + } + + @Test + void testRead_moreThanAvailable() throws TransportException { + config.mockPlayer = mockPlayer; + + PcapReplayTransportInstance instance = new PcapReplayTransportInstance(config, AuditLog.builder().build()); + + // Requesting more bytes than available should throw (player returns no data) + assertThrows(TransportException.class, () -> instance.read(100)); + + instance.close(); + } + +} diff --git a/plc4j/transports/pcap-replay/src/test/java/org/apache/plc4x/java/transport/pcapreplay/PcapReplayTransportTest.java b/plc4j/transports/pcap-replay/src/test/java/org/apache/plc4x/java/transport/pcapreplay/PcapReplayTransportTest.java new file mode 100644 index 0000000000..84e85eb90c --- /dev/null +++ b/plc4j/transports/pcap-replay/src/test/java/org/apache/plc4x/java/transport/pcapreplay/PcapReplayTransportTest.java @@ -0,0 +1,206 @@ +/* + * 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.transport.pcapreplay; + +import org.apache.plc4x.java.spi.transports.api.TransportInstance; +import org.apache.plc4x.java.spi.transports.api.exceptions.TransportException; +import org.apache.plc4x.java.transport.pcapreplay.config.PcapReplayTransportConfiguration; +import org.apache.plc4x.java.utils.auditlog.api.AuditLog; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.pcap4j.packet.factory.PacketFactories; +import org.pcap4j.packet.namednumber.DataLinkType; + +import java.io.File; +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.*; + +class PcapReplayTransportTest { + + private PcapReplayTransport transport; + private File tempPcapFile; + + @BeforeAll + static void initPacketFactory() { + // Force pcap4j to eagerly load the PacketFactoryBinder. + // Without this, the factory loads lazily on the first player thread, + // causing a race where early tests finish before parsing is available. + PacketFactories.getFactory(org.pcap4j.packet.Packet.class, DataLinkType.class); + } + + @BeforeEach + void setUp() throws IOException { + try { + // For some reason it doesn't work if we pass this in from the outside. + //if (os == "mac") { + // On my Intel Mac I found the libs in: "/usr/local/Cellar/libpcap/1.10.1/lib" + // On my M1 Mac I found the libs in: "/opt/homebrew/Cellar/libpcap/1.10.1/lib" + if (new File("/usr/local/Cellar/libpcap/1.10.1/lib").exists()) { + System.getProperties().setProperty("jna.library.path", "/usr/local/Cellar/libpcap/1.10.1/lib"); + } else if (new File("/usr/local/Cellar/libpcap/1.10.5/lib").exists()) { + System.getProperties().setProperty("jna.library.path", "/usr/local/Cellar/libpcap/1.10.5/lib"); + } else if (new File("/opt/homebrew/opt/libpcap/lib").exists()) { + System.getProperties().setProperty("jna.library.path", "/opt/homebrew/opt/libpcap/lib"); + } + //} + } catch (Error e) { + e.printStackTrace(); + } + + transport = new PcapReplayTransport(); + + // Create a minimal test PCAP file + tempPcapFile = File.createTempFile("test", ".pcap"); + // Note: For real tests, you'd need a valid PCAP file + // This is just for basic structure testing + } + + @AfterEach + void tearDown() { + if (tempPcapFile != null && tempPcapFile.exists()) { + tempPcapFile.delete(); + } + } + + @Test + void testGetTransportCode() { + assertEquals("pcap-replay", transport.getTransportCode()); + } + + @Test + void testGetTransportName() { + assertEquals("PCAP Replay", transport.getTransportName()); + } + + @Test + void testGetTransportConfigType() { + assertEquals(PcapReplayTransportConfiguration.class, transport.getTransportConfigType()); + } + + @Test + void testCreateTransportInstance_fileNotFound() { + PcapReplayTransportConfiguration config = new PcapReplayTransportConfiguration(); + config.pcapFile = "/nonexistent/path/to/file.pcap"; + config.autoDetectMacAddresses = false; + config.localAddress = "00:11:22:33:44:55"; + config.remoteAddress = "AA:BB:CC:DD:EE:FF"; + config.protocolId = 0x88B5; + config.maxFrameSize = 1500; + + assertThrows(TransportException.class, () -> + transport.createTransportInstance("pcap-replay://test", config, AuditLog.builder().build()) + ); + } + + @Test + void testCreateTransportInstance_missingMacAddresses() { + PcapReplayTransportConfiguration config = new PcapReplayTransportConfiguration(); + config.pcapFile = tempPcapFile.getAbsolutePath(); + config.autoDetectMacAddresses = false; + config.maxFrameSize = 1500; + // Missing MAC addresses + + assertThrows(TransportException.class, () -> + transport.createTransportInstance("pcap-replay://test", config, AuditLog.builder().build()) + ); + } + + @Test + void testCreateTransportInstance_withManualMacAddresses() { + // This test would require a valid PCAP file + // Skipping actual creation due to need for valid PCAP content + + PcapReplayTransportConfiguration config = new PcapReplayTransportConfiguration(); + config.pcapFile = tempPcapFile.getAbsolutePath(); + config.autoDetectMacAddresses = false; + config.localAddress = "00:11:22:33:44:55"; + config.remoteAddress = "AA:BB:CC:DD:EE:FF"; + config.protocolId = 0x88B5; + config.autoStart = false; + config.maxFrameSize = 1500; + + // Would fail due to invalid PCAP format, but tests configuration + assertThrows(TransportException.class, () -> + transport.createTransportInstance("pcap-replay://test", config, AuditLog.builder().build()) + ); + } + + @Test + void testCreateTransportInstance_withValidPcap() throws Exception { + PcapReplayTransportConfiguration config = new PcapReplayTransportConfiguration(); + config.pcapFile = "/test.pcap"; + config.autoDetectMacAddresses = true; + config.protocolId = 0x88B5; + config.speedFactor = 0; // As fast as possible + config.loop = false; + config.autoStart = true; + config.maxFrameSize = 1500; + config.packetQueueSize = 1000; + + TransportInstance<PcapReplayTransportConfiguration> instance = + transport.createTransportInstance("pcap-replay://test", config, AuditLog.builder().build()); + + assertNotNull(instance); + assertTrue(instance.isOpen()); + + instance.close(); + } + + @Test + void testCreateTransportInstance_withLoop() throws Exception { + PcapReplayTransportConfiguration config = new PcapReplayTransportConfiguration(); + config.pcapFile = "/test.pcap"; + config.autoDetectMacAddresses = true; + config.protocolId = 0x88B5; + config.speedFactor = 2.0; // Double speed + config.loop = true; + config.autoStart = true; + config.maxFrameSize = 1500; + config.packetQueueSize = 1000; + + TransportInstance<PcapReplayTransportConfiguration> instance = + transport.createTransportInstance("pcap-replay://test", config, AuditLog.builder().build()); + + assertNotNull(instance); + assertTrue(instance.isOpen()); + + instance.close(); + } + + @Test + void testCreateTransportInstance_onlyIncoming() throws Exception { + PcapReplayTransportConfiguration config = new PcapReplayTransportConfiguration(); + config.pcapFile = "/test.pcap"; + config.autoDetectMacAddresses = true; + config.protocolId = 0x88B5; + config.onlyIncomingPackets = true; + config.onlyOutgoingPackets = false; + config.maxFrameSize = 1500; + config.packetQueueSize = 1000; + + TransportInstance<PcapReplayTransportConfiguration> instance = + transport.createTransportInstance("pcap-replay://test", config, AuditLog.builder().build()); + + assertNotNull(instance); + instance.close(); + } +} diff --git a/plc4j/transports/pcap-replay/src/test/java/org/apache/plc4x/java/transport/pcapreplay/config/PcapReplayTransportConfigurationTest.java b/plc4j/transports/pcap-replay/src/test/java/org/apache/plc4x/java/transport/pcapreplay/config/PcapReplayTransportConfigurationTest.java new file mode 100644 index 0000000000..d1ea6d2318 --- /dev/null +++ b/plc4j/transports/pcap-replay/src/test/java/org/apache/plc4x/java/transport/pcapreplay/config/PcapReplayTransportConfigurationTest.java @@ -0,0 +1,298 @@ +/* + * 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.transport.pcapreplay.config; + +import org.apache.plc4x.java.spi.transports.api.config.TransportConfiguration; +import org.apache.plc4x.java.transport.pcapreplay.PcapFilePlayer; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import static org.junit.jupiter.api.Assertions.*; + +class PcapReplayTransportConfigurationTest { + + @Test + void testInstantiation() { + PcapReplayTransportConfiguration config = new PcapReplayTransportConfiguration(); + assertNotNull(config); + } + + @Test + void testImplementsTransportConfiguration() { + PcapReplayTransportConfiguration config = new PcapReplayTransportConfiguration(); + assertTrue(config instanceof TransportConfiguration); + } + + @Test + void testPcapFileField() { + PcapReplayTransportConfiguration config = new PcapReplayTransportConfiguration(); + assertNull(config.pcapFile); + + config.pcapFile = "/path/to/test.pcap"; + assertEquals("/path/to/test.pcap", config.pcapFile); + } + + @Test + void testLocalAddressField() { + PcapReplayTransportConfiguration config = new PcapReplayTransportConfiguration(); + assertNull(config.localAddress); + + config.localAddress = "00:11:22:33:44:55"; + assertEquals("00:11:22:33:44:55", config.localAddress); + } + + @Test + void testRemoteAddressField() { + PcapReplayTransportConfiguration config = new PcapReplayTransportConfiguration(); + assertNull(config.remoteAddress); + + config.remoteAddress = "AA:BB:CC:DD:EE:FF"; + assertEquals("AA:BB:CC:DD:EE:FF", config.remoteAddress); + } + + @Test + void testProtocolIdField() { + PcapReplayTransportConfiguration config = new PcapReplayTransportConfiguration(); + assertEquals(0, config.protocolId); + + config.protocolId = 0x88B5; // PROFINET + assertEquals(0x88B5, config.protocolId); + } + + @Test + void testSpeedFactorField() { + PcapReplayTransportConfiguration config = new PcapReplayTransportConfiguration(); + assertEquals(0.0, config.speedFactor, 0.001); + + config.speedFactor = 1.0; // Real-time + assertEquals(1.0, config.speedFactor, 0.001); + + config.speedFactor = 2.0; // Double speed + assertEquals(2.0, config.speedFactor, 0.001); + + config.speedFactor = 0.5; // Half speed + assertEquals(0.5, config.speedFactor, 0.001); + } + + @Test + void testLoopField() { + PcapReplayTransportConfiguration config = new PcapReplayTransportConfiguration(); + assertFalse(config.loop); + + config.loop = true; + assertTrue(config.loop); + + config.loop = false; + assertFalse(config.loop); + } + + @Test + void testOnlyIncomingPacketsField() { + PcapReplayTransportConfiguration config = new PcapReplayTransportConfiguration(); + assertFalse(config.onlyIncomingPackets); + + config.onlyIncomingPackets = true; + assertTrue(config.onlyIncomingPackets); + + config.onlyIncomingPackets = false; + assertFalse(config.onlyIncomingPackets); + } + + @Test + void testOnlyOutgoingPacketsField() { + PcapReplayTransportConfiguration config = new PcapReplayTransportConfiguration(); + assertFalse(config.onlyOutgoingPackets); + + config.onlyOutgoingPackets = true; + assertTrue(config.onlyOutgoingPackets); + + config.onlyOutgoingPackets = false; + assertFalse(config.onlyOutgoingPackets); + } + + @Test + void testMaxFrameSizeField() { + PcapReplayTransportConfiguration config = new PcapReplayTransportConfiguration(); + assertEquals(0, config.maxFrameSize); + + config.maxFrameSize = 1500; // Standard Ethernet MTU + assertEquals(1500, config.maxFrameSize); + + config.maxFrameSize = 9000; // Jumbo frame + assertEquals(9000, config.maxFrameSize); + } + + @Test + void testReadTimeoutField() { + PcapReplayTransportConfiguration config = new PcapReplayTransportConfiguration(); + assertEquals(0, config.readTimeout); + + config.readTimeout = 5000; // 5 seconds + assertEquals(5000, config.readTimeout); + + config.readTimeout = 0; // No timeout + assertEquals(0, config.readTimeout); + } + + @Test + void testPacketQueueSizeField() { + PcapReplayTransportConfiguration config = new PcapReplayTransportConfiguration(); + assertEquals(0, config.packetQueueSize); + + config.packetQueueSize = 1000; + assertEquals(1000, config.packetQueueSize); + + config.packetQueueSize = 100; + assertEquals(100, config.packetQueueSize); + } + + @Test + void testAutoDetectMacAddressesField() { + PcapReplayTransportConfiguration config = new PcapReplayTransportConfiguration(); + assertFalse(config.autoDetectMacAddresses); + + config.autoDetectMacAddresses = true; + assertTrue(config.autoDetectMacAddresses); + + config.autoDetectMacAddresses = false; + assertFalse(config.autoDetectMacAddresses); + } + + @Test + void testAutoStartField() { + PcapReplayTransportConfiguration config = new PcapReplayTransportConfiguration(); + assertFalse(config.autoStart); + + config.autoStart = true; + assertTrue(config.autoStart); + + config.autoStart = false; + assertFalse(config.autoStart); + } + + @Test + void testVlanIdField() { + PcapReplayTransportConfiguration config = new PcapReplayTransportConfiguration(); + assertEquals(0, config.vlanId); + + config.vlanId = 100; + assertEquals(100, config.vlanId); + + config.vlanId = 4095; // Max VLAN ID + assertEquals(4095, config.vlanId); + } + + @Test + void testMockPlayerField() { + PcapReplayTransportConfiguration config = new PcapReplayTransportConfiguration(); + assertNull(config.mockPlayer); + + PcapFilePlayer mockPlayer = Mockito.mock(PcapFilePlayer.class); + config.mockPlayer = mockPlayer; + assertEquals(mockPlayer, config.mockPlayer); + } + + @Test + void testCompleteConfiguration() { + PcapReplayTransportConfiguration config = new PcapReplayTransportConfiguration(); + + // Set all fields + config.pcapFile = "/path/to/capture.pcap"; + config.localAddress = "00:11:22:33:44:55"; + config.remoteAddress = "AA:BB:CC:DD:EE:FF"; + config.protocolId = 0x88B5; + config.speedFactor = 1.5; + config.loop = true; + config.onlyIncomingPackets = false; + config.onlyOutgoingPackets = true; + config.maxFrameSize = 1500; + config.readTimeout = 3000; + config.packetQueueSize = 500; + config.autoDetectMacAddresses = false; + config.autoStart = false; + config.vlanId = 200; + PcapFilePlayer mockPlayer = Mockito.mock(PcapFilePlayer.class); + config.mockPlayer = mockPlayer; + + // Verify all fields + assertEquals("/path/to/capture.pcap", config.pcapFile); + assertEquals("00:11:22:33:44:55", config.localAddress); + assertEquals("AA:BB:CC:DD:EE:FF", config.remoteAddress); + assertEquals(0x88B5, config.protocolId); + assertEquals(1.5, config.speedFactor, 0.001); + assertTrue(config.loop); + assertFalse(config.onlyIncomingPackets); + assertTrue(config.onlyOutgoingPackets); + assertEquals(1500, config.maxFrameSize); + assertEquals(3000, config.readTimeout); + assertEquals(500, config.packetQueueSize); + assertFalse(config.autoDetectMacAddresses); + assertFalse(config.autoStart); + assertEquals(200, config.vlanId); + assertEquals(mockPlayer, config.mockPlayer); + } + + @Test + void testMutuallyExclusivePacketDirection() { + PcapReplayTransportConfiguration config = new PcapReplayTransportConfiguration(); + + // Test that both can be set (validation would be done elsewhere) + config.onlyIncomingPackets = true; + config.onlyOutgoingPackets = true; + + assertTrue(config.onlyIncomingPackets); + assertTrue(config.onlyOutgoingPackets); + } + + @Test + void testNegativeValues() { + PcapReplayTransportConfiguration config = new PcapReplayTransportConfiguration(); + + // Test that negative values can be set (validation would be done elsewhere) + config.protocolId = -1; + config.maxFrameSize = -100; + config.readTimeout = -1000; + config.packetQueueSize = -50; + config.vlanId = -10; + + assertEquals(-1, config.protocolId); + assertEquals(-100, config.maxFrameSize); + assertEquals(-1000, config.readTimeout); + assertEquals(-50, config.packetQueueSize); + assertEquals(-10, config.vlanId); + } + + @Test + void testEdgeCaseSpeedFactors() { + PcapReplayTransportConfiguration config = new PcapReplayTransportConfiguration(); + + // Test edge cases + config.speedFactor = 0.0; // As fast as possible + assertEquals(0.0, config.speedFactor, 0.001); + + config.speedFactor = Double.MAX_VALUE; + assertEquals(Double.MAX_VALUE, config.speedFactor, 0.001); + + config.speedFactor = Double.MIN_VALUE; + assertEquals(Double.MIN_VALUE, config.speedFactor, 0.001); + + config.speedFactor = -1.0; // Negative (validation would handle this) + assertEquals(-1.0, config.speedFactor, 0.001); + } +} diff --git a/plc4j/transports/pcap-replay/src/test/resources/generate_test_pcap.py b/plc4j/transports/pcap-replay/src/test/resources/generate_test_pcap.py new file mode 100644 index 0000000000..7cf580aa2b --- /dev/null +++ b/plc4j/transports/pcap-replay/src/test/resources/generate_test_pcap.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +""" + 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. + +Generate a test PCAP file with sample Ethernet frames. +This script creates a minimal PCAP file for testing the pcap-replay transport. +""" + +import struct +import time + +def write_pcap_header(f): + """Write PCAP global header.""" + magic = 0xa1b2c3d4 # Magic number (microsecond precision) + version_major = 2 + version_minor = 4 + thiszone = 0 # GMT to local correction + sigfigs = 0 # Accuracy of timestamps + snaplen = 65535 # Max length of captured packets + network = 1 # Data link type (1 = Ethernet) + + f.write(struct.pack('<IHHIIII', magic, version_major, version_minor, + thiszone, sigfigs, snaplen, network)) + +def write_packet(f, data, timestamp=None): + """Write a packet to the PCAP file.""" + if timestamp is None: + timestamp = time.time() + + ts_sec = int(timestamp) + ts_usec = int((timestamp - ts_sec) * 1000000) + incl_len = len(data) + orig_len = len(data) + + # Write packet header + f.write(struct.pack('<IIII', ts_sec, ts_usec, incl_len, orig_len)) + # Write packet data + f.write(data) + +def create_ethernet_frame(dst_mac, src_mac, ethertype, payload): + """Create an Ethernet frame.""" + frame = bytearray() + + # Destination MAC (6 bytes) + frame.extend(bytes.fromhex(dst_mac.replace(':', ''))) + + # Source MAC (6 bytes) + frame.extend(bytes.fromhex(src_mac.replace(':', ''))) + + # EtherType (2 bytes) + frame.extend(struct.pack('>H', ethertype)) + + # Payload + frame.extend(payload) + + # Padding to minimum frame size (60 bytes without FCS) + while len(frame) < 60: + frame.append(0) + + return bytes(frame) + +def main(): + """Generate test.pcap file.""" + + # MAC addresses matching the test + local_mac = '00:11:22:33:44:55' + remote_mac = 'AA:BB:CC:DD:EE:FF' + + # Custom EtherType for testing (using a test protocol ID) + ethertype = 0x88B5 # Example custom protocol + + # Create test payloads + payloads = [ + b'\x01\x02\x03\x04', # Simple test data + b'\x05\x06\x07\x08\x09\x0A', # Another test packet + b'Hello World!', # Text payload + bytes(range(32)), # Sequential bytes + ] + + output_file = 'test.pcap' + + with open(output_file, 'wb') as f: + # Write PCAP header + write_pcap_header(f) + + base_time = time.time() + + # Write packets with different directions + for i, payload in enumerate(payloads): + timestamp = base_time + (i * 0.01) # 10ms apart + + # Alternate between incoming and outgoing + if i % 2 == 0: + # Incoming: from remote to local + frame = create_ethernet_frame(local_mac, remote_mac, ethertype, payload) + else: + # Outgoing: from local to remote + frame = create_ethernet_frame(remote_mac, local_mac, ethertype, payload) + + write_packet(f, frame, timestamp) + + print(f'Created {output_file} with {len(payloads)} packets') + print(f'Local MAC: {local_mac}') + print(f'Remote MAC: {remote_mac}') + print(f'EtherType: 0x{ethertype:04X}') + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/plc4j/transports/pcap-replay/src/test/resources/test.pcap b/plc4j/transports/pcap-replay/src/test/resources/test.pcap new file mode 100644 index 0000000000..006123a142 Binary files /dev/null and b/plc4j/transports/pcap-replay/src/test/resources/test.pcap differ diff --git a/plc4j/transports/pom.xml b/plc4j/transports/pom.xml index a64de57529..0425563f30 100644 --- a/plc4j/transports/pom.xml +++ b/plc4j/transports/pom.xml @@ -39,9 +39,9 @@ <modules> <module>api</module> - <!--module>can</module> + <!--module>can</module--> <module>pcap-replay</module> - <module>pcap-shared</module> + <!--module>pcap-shared</module> <module>raw-socket</module> <module>socketcan</module--> <module>serial</module>
