summaryrefslogtreecommitdiff
path: root/src/main/java/com/it_jaros/jscanner/scan
diff options
context:
space:
mode:
authorGravatar Matthias Jaros <jarlucmat@mailbox.org>2026-08-12 13:24:27 +0200
committerGravatar Matthias Jaros <jarlucmat@mailbox.org>2026-08-12 13:50:46 +0200
commitff14e0fc4e3fe4f5b8a67640d28850064661d7ab (patch)
treef141a2d34d902d344207cfbbc6ac614ec1b3840d /src/main/java/com/it_jaros/jscanner/scan
parent540b09b1a019f95a9322a6d19e8928369bb20fcb (diff)
Major refactoring of package structure
Diffstat (limited to 'src/main/java/com/it_jaros/jscanner/scan')
-rw-r--r--src/main/java/com/it_jaros/jscanner/scan/Counter.java31
-rw-r--r--src/main/java/com/it_jaros/jscanner/scan/ExceptionInfo.java13
-rw-r--r--src/main/java/com/it_jaros/jscanner/scan/Scan.java173
-rw-r--r--src/main/java/com/it_jaros/jscanner/scan/ScanException.java11
-rw-r--r--src/main/java/com/it_jaros/jscanner/scan/ScanOptions.java18
-rw-r--r--src/main/java/com/it_jaros/jscanner/scan/Scanner.java110
-rw-r--r--src/main/java/com/it_jaros/jscanner/scan/domain/PortList.java27
-rw-r--r--src/main/java/com/it_jaros/jscanner/scan/domain/PortRange.java80
-rw-r--r--src/main/java/com/it_jaros/jscanner/scan/domain/PortResult.java48
-rw-r--r--src/main/java/com/it_jaros/jscanner/scan/domain/PortState.java8
-rw-r--r--src/main/java/com/it_jaros/jscanner/scan/domain/ScanFailure.java9
-rw-r--r--src/main/java/com/it_jaros/jscanner/scan/domain/ScanResult.java26
-rw-r--r--src/main/java/com/it_jaros/jscanner/scan/engine/CancelledToken.java15
-rw-r--r--src/main/java/com/it_jaros/jscanner/scan/engine/PollState.java13
-rw-r--r--src/main/java/com/it_jaros/jscanner/scan/engine/PortRangeIterator.java38
-rw-r--r--src/main/java/com/it_jaros/jscanner/scan/engine/PortResultAccumulator.java49
-rw-r--r--src/main/java/com/it_jaros/jscanner/scan/engine/PortScanRateLimiter.java37
-rw-r--r--src/main/java/com/it_jaros/jscanner/scan/engine/ProducerState.java13
-rw-r--r--src/main/java/com/it_jaros/jscanner/scan/engine/ProducerThread.java83
-rw-r--r--src/main/java/com/it_jaros/jscanner/scan/engine/ScanExecutionContext.java16
-rw-r--r--src/main/java/com/it_jaros/jscanner/scan/engine/ScanHostTask.java123
-rw-r--r--src/main/java/com/it_jaros/jscanner/scan/engine/ScanPortTask.java135
-rw-r--r--src/main/java/com/it_jaros/jscanner/scan/service/ServiceDetector.java47
-rw-r--r--src/main/java/com/it_jaros/jscanner/scan/service/ServiceType.java5
24 files changed, 1128 insertions, 0 deletions
diff --git a/src/main/java/com/it_jaros/jscanner/scan/Counter.java b/src/main/java/com/it_jaros/jscanner/scan/Counter.java
new file mode 100644
index 0000000..0e1d5c9
--- /dev/null
+++ b/src/main/java/com/it_jaros/jscanner/scan/Counter.java
@@ -0,0 +1,31 @@
+package com.it_jaros.jscanner.scan;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+public final class Counter {
+ private final AtomicInteger current = new AtomicInteger(0);
+ private final AtomicInteger max = new AtomicInteger(0);
+ private final AtomicInteger total = new AtomicInteger(0);
+
+ public void inc() {
+ total.incrementAndGet();
+ int now = current.incrementAndGet();
+ max.accumulateAndGet(now, Math::max);
+ }
+
+ public void dec() {
+ current.decrementAndGet();
+ }
+
+ public int current() {
+ return current.get();
+ }
+
+ public int max() {
+ return max.get();
+ }
+
+ public int total() {
+ return total.get();
+ }
+}
diff --git a/src/main/java/com/it_jaros/jscanner/scan/ExceptionInfo.java b/src/main/java/com/it_jaros/jscanner/scan/ExceptionInfo.java
new file mode 100644
index 0000000..f62caf1
--- /dev/null
+++ b/src/main/java/com/it_jaros/jscanner/scan/ExceptionInfo.java
@@ -0,0 +1,13 @@
+package com.it_jaros.jscanner.scan;
+
+public record ExceptionInfo(
+ String type,
+ String message
+) {
+ public static ExceptionInfo from(Throwable t) {
+ return new ExceptionInfo(
+ t.getClass().getName(),
+ t.getMessage()
+ );
+ }
+} \ No newline at end of file
diff --git a/src/main/java/com/it_jaros/jscanner/scan/Scan.java b/src/main/java/com/it_jaros/jscanner/scan/Scan.java
new file mode 100644
index 0000000..0820abc
--- /dev/null
+++ b/src/main/java/com/it_jaros/jscanner/scan/Scan.java
@@ -0,0 +1,173 @@
+package com.it_jaros.jscanner.scan;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.stream.Stream;
+
+/**
+ * Object holder for stateful volatile data during scan
+ */
+public class Scan {
+ private final Stream<String> hosts;
+ private final String ports;
+ private final Runnable onDone;
+ private final Counter hostCounter;
+ private final Counter portCounter;
+ private final Counter socketCounter;
+ private final Counter threadCounter;
+ private long start = 0;
+ private long stop = 0;
+
+ private Scan(Stream<String> hosts, String ports) {
+ this(hosts, ports, () -> {});
+ }
+
+ private Scan(Stream<String> hosts, String ports, Runnable onDone) {
+ this.hosts = hosts;
+ this.ports = ports;
+ this.onDone = onDone;
+ this.hostCounter = new Counter();
+ this.portCounter = new Counter();
+ this.socketCounter = new Counter();
+ this.threadCounter = new Counter();
+ }
+
+ public static Scan create(ScanOptions options) throws IOException {
+ if (options.hostsFile() != null) {
+ return create(options.hostsFile(), options.ports());
+ }
+ return create(options.hostsArgv(), options.ports());
+ }
+
+ /**
+ * Create Scan based on either a source file or stdin
+ *
+ * @param sourceFile
+ * @param ports
+ * @return
+ * @throws IOException
+ */
+ public static Scan create(String sourceFile, String ports) {
+ Stream<String> hosts;
+ if (sourceFile == null || sourceFile.isEmpty()) {
+ throw new IllegalArgumentException("Source file is null or empty");
+ }
+
+ if ("-".equals(sourceFile)) {
+ BufferedReader stdinReader = new BufferedReader(new InputStreamReader(System.in));
+ hosts = stdinReader.lines();
+ return new Scan(hosts, ports);
+ }
+
+ try {
+ BufferedReader reader = Files.newBufferedReader(Path.of(sourceFile));
+ return new Scan(reader.lines(), ports, () -> {
+ try {
+ reader.close();
+ } catch (IOException ignore) {
+ // we ignore it because at this moment the program is shutting down anyway
+ }
+ });
+ } catch (IOException e) {
+ throw new ScanException("Error while trying to open source File", e);
+ }
+ }
+
+ public static Scan create(List<String> hostsArgv, String ports) {
+ return new Scan(hostsArgv.stream(), ports);
+ }
+
+ public Stream<String> getHosts() {
+ return hosts;
+ }
+
+ public String getPorts() {
+ return ports;
+ }
+
+ public void stop() {
+ stop = System.currentTimeMillis();
+ onDone.run();
+ }
+
+ public void start() {
+ start = System.currentTimeMillis();
+ }
+
+ private long getDurationMillis() {
+ if (stop == 0) {
+ if (start == 0) {
+ return 0;
+ }
+ return System.currentTimeMillis() - start;
+ }
+ return stop - start;
+ }
+
+ public void portStart() {
+ this.portCounter.inc();
+ this.threadCounter.inc();
+ this.socketCounter.inc();
+ }
+
+ public void portFinish() {
+ this.portCounter.dec();
+ this.threadCounter.dec();
+ this.socketCounter.dec();
+ }
+
+ public void hostStart() {
+ this.hostCounter.inc();
+ this.threadCounter.inc();
+ }
+
+ public void hostFinish() {
+ this.threadCounter.dec();
+ this.hostCounter.dec();
+ }
+
+ public void producerStart() {
+ this.threadCounter.inc();
+ }
+
+ public void producerStop() {
+ this.threadCounter.dec();
+ }
+
+ /**
+ * This is not an atomic 100% correct snapshot but
+ * more a relative snapshot optimized more for performance
+ * than exactness
+ */
+ public Statistics getStatistics() {
+ return new Statistics(
+ this.getDurationMillis(),
+ this.hostCounter.total(),
+ this.hostCounter.current(),
+ this.hostCounter.max(),
+ this.portCounter.total(),
+ this.portCounter.current(),
+ this.threadCounter.current(),
+ this.threadCounter.max(),
+ this.socketCounter.current(),
+ this.socketCounter.max()
+ );
+ }
+
+ public record Statistics(
+ long durationInMillis,
+ int hostTotal,
+ int hostCurrent,
+ int hostMaxConcurrent,
+ int portTotal,
+ int portCurrent,
+ int threadCurrent,
+ int threadMaxConcurrent,
+ int socketCurrent,
+ int socketMaxConcurrent
+ ) {}
+}
diff --git a/src/main/java/com/it_jaros/jscanner/scan/ScanException.java b/src/main/java/com/it_jaros/jscanner/scan/ScanException.java
new file mode 100644
index 0000000..d667346
--- /dev/null
+++ b/src/main/java/com/it_jaros/jscanner/scan/ScanException.java
@@ -0,0 +1,11 @@
+package com.it_jaros.jscanner.scan;
+
+public class ScanException extends java.lang.RuntimeException {
+ public ScanException(String message) {
+ super(message);
+ }
+
+ public ScanException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/src/main/java/com/it_jaros/jscanner/scan/ScanOptions.java b/src/main/java/com/it_jaros/jscanner/scan/ScanOptions.java
new file mode 100644
index 0000000..ce0d562
--- /dev/null
+++ b/src/main/java/com/it_jaros/jscanner/scan/ScanOptions.java
@@ -0,0 +1,18 @@
+package com.it_jaros.jscanner.scan;
+
+import java.util.List;
+
+public record ScanOptions(
+ List<String> hostsArgv,
+ String hostsFile,
+ String ports,
+ boolean bannerRecognition,
+ boolean disableOnlineCheck,
+ boolean quiet,
+ boolean showFilteredPorts,
+ int delayInMillis,
+ int maxHostsLimit,
+ int maxWorkersPerHost,
+ int socketLimit,
+ int timeoutInMillis
+) {}
diff --git a/src/main/java/com/it_jaros/jscanner/scan/Scanner.java b/src/main/java/com/it_jaros/jscanner/scan/Scanner.java
new file mode 100644
index 0000000..e0fbb3a
--- /dev/null
+++ b/src/main/java/com/it_jaros/jscanner/scan/Scanner.java
@@ -0,0 +1,110 @@
+package com.it_jaros.jscanner.scan;
+
+import com.it_jaros.jscanner.scan.domain.ScanResult;
+import com.it_jaros.jscanner.scan.engine.*;
+
+import java.time.Duration;
+import java.util.concurrent.*;
+import java.util.function.Consumer;
+
+public class Scanner implements AutoCloseable {
+
+ private final CancelledToken cancelledToken = new CancelledToken();
+ private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
+ private final ScanOptions scanOptions;
+ private final Semaphore socketLimit;
+
+ public Scanner(ScanOptions options) {
+ this.scanOptions = options;
+ this.socketLimit = new Semaphore(scanOptions.socketLimit());
+ }
+
+ /**
+ * Starts a given scan.
+ *
+ * @param scan
+ */
+ public void runScan(final Scan scan, final Consumer<ScanResult> consumer) {
+ if (scan == null) {
+ throw new IllegalArgumentException("Scan argument cannot be null");
+ }
+
+ scan.start();
+ long delayInNanos = TimeUnit.MILLISECONDS.toNanos(Math.max(0, scanOptions.delayInMillis()));
+ ScanExecutionContext context = new ScanExecutionContext(
+ scanOptions,
+ executor,
+ socketLimit,
+ cancelledToken,
+ scan,
+ new PortScanRateLimiter(cancelledToken, delayInNanos)
+ );
+
+ // start producer thread
+ scan.producerStart();
+ final ProducerState<ScanResult> state = new ProducerThread(context).startProducer(
+ scan.getHosts().iterator(),
+ scanOptions.maxHostsLimit(),
+ host -> new ScanHostTask(scan, host, context)
+ );
+
+ // the main thread is the consumer
+ // Let the consumer run as long as the producer runs
+ // or if still tasks are pending in pipeline
+ // we do not listen to canceled here because we want
+ // all results (also partial) collected for the consumer
+ // with whatever is there already
+ while (state.running().get() || state.inPipeline().get() > 0) {
+ try {
+ PollState<ScanResult> poll = getHostResult(state);
+ if (poll instanceof PollState.Success<ScanResult>(ScanResult value)) {
+ consumer.accept(value);
+ } else if (poll instanceof PollState.Failure<ScanResult>(Throwable error)) {
+ System.err.printf("runScan(): ScanHostTask() failed with error %s -> %s%n", error.getClass().getSimpleName(), error.getMessage());
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ scan.producerStop();
+
+ scan.stop();
+ }
+
+ private PollState<ScanResult> getHostResult(ProducerState<ScanResult> state) throws InterruptedException {
+ Future<ScanResult> finishedHost = state.completionService().poll(ProducerThread.pollInterval.toMillis(), TimeUnit.MILLISECONDS);
+ if (finishedHost == null) {
+ return new PollState.Unavailable<>();
+ }
+
+ try {
+ ScanResult result = finishedHost.get();
+ return new PollState.Success<>(result);
+ } catch (ExecutionException e) {
+ return new PollState.Failure<>(e.getCause());
+ } finally {
+ state.activeWorkers().release();
+ state.inPipeline().decrementAndGet();
+ }
+ }
+
+ public boolean awaitTermination(Duration duration) throws InterruptedException {
+ return executor.awaitTermination(duration.toMillis(), TimeUnit.MILLISECONDS);
+ }
+
+ public void cancel() {
+ this.cancelledToken.cancel();
+ executor.shutdown();
+ }
+
+ public void cancelNow() {
+ this.cancelledToken.cancel();
+ executor.shutdownNow();
+ }
+
+ @Override
+ public void close() throws Exception {
+ cancel();
+ }
+
+}
diff --git a/src/main/java/com/it_jaros/jscanner/scan/domain/PortList.java b/src/main/java/com/it_jaros/jscanner/scan/domain/PortList.java
new file mode 100644
index 0000000..f145700
--- /dev/null
+++ b/src/main/java/com/it_jaros/jscanner/scan/domain/PortList.java
@@ -0,0 +1,27 @@
+package com.it_jaros.jscanner.scan.domain;
+
+import java.util.BitSet;
+import java.util.List;
+
+/**
+ * Encapsulates a port scan result and is immutable
+ */
+public class PortList {
+ private final BitSet ports;
+
+ public PortList(BitSet ports) {
+ this.ports = ports;
+ }
+
+ public List<Integer> getPorts() {
+ return ports.stream().boxed().toList();
+ }
+
+ public boolean isEmpty() {
+ return ports.isEmpty();
+ }
+
+ public int size() {
+ return ports.cardinality();
+ }
+}
diff --git a/src/main/java/com/it_jaros/jscanner/scan/domain/PortRange.java b/src/main/java/com/it_jaros/jscanner/scan/domain/PortRange.java
new file mode 100644
index 0000000..4434763
--- /dev/null
+++ b/src/main/java/com/it_jaros/jscanner/scan/domain/PortRange.java
@@ -0,0 +1,80 @@
+package com.it_jaros.jscanner.scan.domain;
+
+import com.it_jaros.jscanner.scan.engine.PortRangeIterator;
+
+import java.util.BitSet;
+import java.util.Iterator;
+
+public class PortRange {
+
+ public static final int MIN_PORT = 1;
+ public static final int MAX_PORT = 65535;
+ private final BitSet specifiedPorts = new BitSet(MAX_PORT);
+
+ public PortRange(String ports) {
+ if (ports == null || "".equals(ports)) {
+ specifiedPorts.set(MIN_PORT, 1024);
+ } else if ("all".equalsIgnoreCase(ports)) {
+ specifiedPorts.set(MIN_PORT, MAX_PORT + 1);
+ } else {
+ parsePortRange(ports);
+ }
+ }
+
+ /**
+ * Possible values are 1,10 or 1-10 or a mix of 1,5-10
+ * So we treat , stronger and handle them first and then check for -
+ *
+ * @param ports
+ */
+ private void parsePortRange(String ports) {
+ String[] splitComma = ports.split(",");
+ for (String commaValue : splitComma) {
+ String[] rangeValue = commaValue.split("-");
+
+ // more then two values are not possible
+ if (rangeValue.length > 2) {
+ throw new IllegalArgumentException(String.format("Argument contains too many '-'' %s", commaValue));
+ }
+
+ // no range given, only single port
+ int port = Integer.parseInt(rangeValue[0]);
+ if (rangeValue.length == 1) {
+ checkValue(port);
+ specifiedPorts.set(port);
+ continue;
+ }
+
+ // range given
+ int end = Integer.parseInt(rangeValue[1]);
+ checkValues(port, end);
+ specifiedPorts.set(port, end + 1);
+ }
+ }
+
+ private void checkValues(int start, int end) {
+ checkValue(start);
+ checkValue(end);
+
+ if (start >= end) {
+ throw new IllegalArgumentException(
+ String.format("Start value cannot be equal or bigger than end value '%s >= %s'", start, end));
+ }
+ }
+
+ private void checkValue(int port) {
+ if (port < MIN_PORT) {
+ throw new IllegalArgumentException(
+ String.format("Port value is too low: %s < %s", port, MIN_PORT));
+ }
+
+ if (port > MAX_PORT) {
+ throw new IllegalArgumentException(
+ String.format("Port value is too high: %s > %s", port, MAX_PORT));
+ }
+ }
+
+ public Iterator<Integer> iterator() {
+ return new PortRangeIterator(specifiedPorts);
+ }
+} \ No newline at end of file
diff --git a/src/main/java/com/it_jaros/jscanner/scan/domain/PortResult.java b/src/main/java/com/it_jaros/jscanner/scan/domain/PortResult.java
new file mode 100644
index 0000000..9e70cc5
--- /dev/null
+++ b/src/main/java/com/it_jaros/jscanner/scan/domain/PortResult.java
@@ -0,0 +1,48 @@
+package com.it_jaros.jscanner.scan.domain;
+
+import java.io.IOException;
+
+public class PortResult {
+ private int port;
+ private PortState state;
+ private byte[] banner;
+ private Exception exception;
+
+ public int getPort() {
+ return port;
+ }
+
+ public void setPort(int port) {
+ this.port = port;
+ }
+
+ public PortState getState() {
+ return state;
+ }
+
+ public void setState(PortState state) {
+ this.state = state;
+ }
+
+ public byte[] getBanner() {
+ return banner;
+ }
+
+ public void setBanner(byte[] banner) {
+ this.banner = banner;
+ }
+
+ public void setException(IOException e) {
+ exception = e;
+ }
+
+ public Exception getException() {
+ return exception;
+ }
+
+ public static PortResult empty() {
+ PortResult portResult = new PortResult();
+ portResult.setState(PortState.UNKNOWN);
+ return portResult;
+ }
+}
diff --git a/src/main/java/com/it_jaros/jscanner/scan/domain/PortState.java b/src/main/java/com/it_jaros/jscanner/scan/domain/PortState.java
new file mode 100644
index 0000000..6db4b2e
--- /dev/null
+++ b/src/main/java/com/it_jaros/jscanner/scan/domain/PortState.java
@@ -0,0 +1,8 @@
+package com.it_jaros.jscanner.scan.domain;
+
+public enum PortState {
+ CLOSED,
+ FILTERED,
+ OPEN,
+ UNKNOWN
+}
diff --git a/src/main/java/com/it_jaros/jscanner/scan/domain/ScanFailure.java b/src/main/java/com/it_jaros/jscanner/scan/domain/ScanFailure.java
new file mode 100644
index 0000000..db33465
--- /dev/null
+++ b/src/main/java/com/it_jaros/jscanner/scan/domain/ScanFailure.java
@@ -0,0 +1,9 @@
+package com.it_jaros.jscanner.scan.domain;
+
+import com.it_jaros.jscanner.scan.ExceptionInfo;
+
+public record ScanFailure(
+ Integer port,
+ ExceptionInfo exception
+) {
+}
diff --git a/src/main/java/com/it_jaros/jscanner/scan/domain/ScanResult.java b/src/main/java/com/it_jaros/jscanner/scan/domain/ScanResult.java
new file mode 100644
index 0000000..e18e75d
--- /dev/null
+++ b/src/main/java/com/it_jaros/jscanner/scan/domain/ScanResult.java
@@ -0,0 +1,26 @@
+package com.it_jaros.jscanner.scan.domain;
+
+import com.it_jaros.jscanner.scan.service.ServiceType;
+
+import java.util.BitSet;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+public record ScanResult(
+ String host,
+ PortList openPorts,
+ PortList filteredPorts,
+ Map<Integer, ServiceType> bannerRecognition,
+ List<ScanFailure> errors
+) {
+ public static ScanResult empty(String host) {
+ return new ScanResult(
+ host,
+ new PortList(new BitSet(PortRange.MAX_PORT)),
+ new PortList(new BitSet(PortRange.MAX_PORT)),
+ Collections.emptyMap(),
+ Collections.emptyList()
+ );
+ }
+}
diff --git a/src/main/java/com/it_jaros/jscanner/scan/engine/CancelledToken.java b/src/main/java/com/it_jaros/jscanner/scan/engine/CancelledToken.java
new file mode 100644
index 0000000..1834a12
--- /dev/null
+++ b/src/main/java/com/it_jaros/jscanner/scan/engine/CancelledToken.java
@@ -0,0 +1,15 @@
+package com.it_jaros.jscanner.scan.engine;
+
+public class CancelledToken {
+ private volatile boolean cancelled = false;
+
+ public void cancel() {
+ if (!cancelled) {
+ this.cancelled = true;
+ }
+ }
+
+ public boolean isCancelled() {
+ return cancelled;
+ }
+}
diff --git a/src/main/java/com/it_jaros/jscanner/scan/engine/PollState.java b/src/main/java/com/it_jaros/jscanner/scan/engine/PollState.java
new file mode 100644
index 0000000..54ec767
--- /dev/null
+++ b/src/main/java/com/it_jaros/jscanner/scan/engine/PollState.java
@@ -0,0 +1,13 @@
+package com.it_jaros.jscanner.scan.engine;
+
+/**
+ * A sealed interface representing the three possible outcomes of a CompletionService
+ * poll operation: successful result, task failure, or not ready yet.
+ * Makes error handling explicit — Failure and Unavailable are distinct and compile-time
+ * required to handle via exhaustiveness checking in switch statements.
+ */
+public sealed interface PollState<T> {
+ record Success<T>(T value) implements PollState<T> {}
+ record Failure<T>(Throwable error) implements PollState<T> {}
+ record Unavailable<T>() implements PollState<T> {}
+}
diff --git a/src/main/java/com/it_jaros/jscanner/scan/engine/PortRangeIterator.java b/src/main/java/com/it_jaros/jscanner/scan/engine/PortRangeIterator.java
new file mode 100644
index 0000000..0673f0f
--- /dev/null
+++ b/src/main/java/com/it_jaros/jscanner/scan/engine/PortRangeIterator.java
@@ -0,0 +1,38 @@
+package com.it_jaros.jscanner.scan.engine;
+
+import java.util.BitSet;
+import java.util.Iterator;
+import java.util.NoSuchElementException;
+
+import static com.it_jaros.jscanner.scan.domain.PortRange.MAX_PORT;
+import static com.it_jaros.jscanner.scan.domain.PortRange.MIN_PORT;
+
+public class PortRangeIterator implements Iterator<Integer> {
+
+ private int currentPortCursor;
+ private int done = 0;
+ private final BitSet availablePorts = new BitSet(MAX_PORT);
+
+ public PortRangeIterator(BitSet specifiedPorts) {
+ availablePorts.or(specifiedPorts);
+ currentPortCursor = availablePorts.nextSetBit(MIN_PORT);
+ }
+
+ @Override
+ public Integer next() {
+ int p = availablePorts.nextSetBit(currentPortCursor);
+ if (p > 0) {
+ availablePorts.clear(p);
+ currentPortCursor = p + 1;
+ done++;
+ return p;
+ }
+
+ throw new NoSuchElementException("Reached end of port range");
+ }
+
+ @Override
+ public boolean hasNext() {
+ return availablePorts.nextSetBit(currentPortCursor) > 0;
+ }
+}
diff --git a/src/main/java/com/it_jaros/jscanner/scan/engine/PortResultAccumulator.java b/src/main/java/com/it_jaros/jscanner/scan/engine/PortResultAccumulator.java
new file mode 100644
index 0000000..0cc26b0
--- /dev/null
+++ b/src/main/java/com/it_jaros/jscanner/scan/engine/PortResultAccumulator.java
@@ -0,0 +1,49 @@
+package com.it_jaros.jscanner.scan.engine;
+
+import com.it_jaros.jscanner.scan.ExceptionInfo;
+import com.it_jaros.jscanner.scan.domain.*;
+import com.it_jaros.jscanner.scan.service.ServiceDetector;
+import com.it_jaros.jscanner.scan.service.ServiceType;
+
+import java.util.*;
+
+final class PortResultAccumulator {
+ private final String host;
+ private final BitSet openPorts = new BitSet(PortRange.MAX_PORT);
+ private final BitSet filteredPorts = new BitSet(PortRange.MAX_PORT);
+ private final Map<Integer, ServiceType> serviceTypes = new HashMap<>();
+ private final List<ScanFailure> scanFailures = new ArrayList<>();
+
+ PortResultAccumulator(String host) {
+ this.host = host;
+ }
+
+ void add(PortResult portResult) {
+ switch (portResult.getState()) {
+ case OPEN -> {
+ openPorts.set(portResult.getPort());
+ serviceTypes.put(portResult.getPort(), ServiceDetector.detect(portResult.getBanner()));
+ }
+ case FILTERED -> {
+ filteredPorts.set(portResult.getPort());
+ }
+ default -> {
+ // intentional no-op for uncovered port states
+ }
+ }
+ Exception e = portResult.getException();
+ if (e != null) {
+ scanFailures.add(new ScanFailure(portResult.getPort(), ExceptionInfo.from(e)));
+ }
+ }
+
+ ScanResult build() {
+ return new ScanResult(
+ host,
+ new PortList(openPorts),
+ new PortList(filteredPorts),
+ Collections.unmodifiableMap(serviceTypes),
+ Collections.unmodifiableList(scanFailures)
+ );
+ }
+}
diff --git a/src/main/java/com/it_jaros/jscanner/scan/engine/PortScanRateLimiter.java b/src/main/java/com/it_jaros/jscanner/scan/engine/PortScanRateLimiter.java
new file mode 100644
index 0000000..92b3bcb
--- /dev/null
+++ b/src/main/java/com/it_jaros/jscanner/scan/engine/PortScanRateLimiter.java
@@ -0,0 +1,37 @@
+package com.it_jaros.jscanner.scan.engine;
+
+import java.util.concurrent.locks.LockSupport;
+
+/**
+ * Per-host rate limiter. Each ScanHostTask creates its own instance and
+ * shares it with all its ScanPortTasks via constructor.
+ *
+ * Java allows one inner class to access another's private members, so this works.
+ */
+public class PortScanRateLimiter {
+ private final Object lock = new Object();
+ private volatile long nextAllowedTime;
+ private final long delayInNanos;
+ private final CancelledToken cancelledToken;
+
+ public PortScanRateLimiter(CancelledToken cancelledToken, long delayInNanos) {
+ this.cancelledToken = cancelledToken;
+ this.delayInNanos = delayInNanos;
+ }
+
+ void apply() { if (delayInNanos <= 0) {
+ return;
+ }
+ synchronized (lock) {
+ if (cancelledToken.isCancelled()) {
+ return;
+ }
+ long now = System.nanoTime();
+ if (nextAllowedTime > now) {
+ LockSupport.parkNanos(nextAllowedTime - now);
+ now = System.nanoTime(); // re-read after waking
+ }
+ nextAllowedTime = now + delayInNanos;
+ }
+ }
+}
diff --git a/src/main/java/com/it_jaros/jscanner/scan/engine/ProducerState.java b/src/main/java/com/it_jaros/jscanner/scan/engine/ProducerState.java
new file mode 100644
index 0000000..c49ea3e
--- /dev/null
+++ b/src/main/java/com/it_jaros/jscanner/scan/engine/ProducerState.java
@@ -0,0 +1,13 @@
+package com.it_jaros.jscanner.scan.engine;
+
+import java.util.concurrent.CompletionService;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+public record ProducerState<OUTPUT>(
+ AtomicBoolean running,
+ AtomicInteger inPipeline,
+ Semaphore activeWorkers,
+ CompletionService<OUTPUT> completionService
+) {}
diff --git a/src/main/java/com/it_jaros/jscanner/scan/engine/ProducerThread.java b/src/main/java/com/it_jaros/jscanner/scan/engine/ProducerThread.java
new file mode 100644
index 0000000..e7bcbcc
--- /dev/null
+++ b/src/main/java/com/it_jaros/jscanner/scan/engine/ProducerThread.java
@@ -0,0 +1,83 @@
+package com.it_jaros.jscanner.scan.engine;
+
+import java.time.Duration;
+import java.util.Iterator;
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Function;
+
+public class ProducerThread {
+
+ public static final Duration pollInterval = Duration.ofSeconds(1);
+
+ private final ExecutorService executor;
+ private final CancelledToken cancelledToken;
+
+ public ProducerThread(ScanExecutionContext context) {
+ this.executor = context.executorService();
+ this.cancelledToken = context.cancelledToken();
+ }
+
+ /**
+ * This method helps to cleanup the code a bit and remove redundancy
+ * The producer for providing hosts and the one for providing ports
+ * are similar and the small differences can be handled using a function
+ *
+ * @param queue
+ * @param maxWorkers
+ * @param taskFactory
+ * @param <INPUT>
+ * @param <OUTPUT>
+ * @return
+ */
+ public <INPUT, OUTPUT> ProducerState<OUTPUT> startProducer(
+ Iterator<INPUT> queue,
+ int maxWorkers,
+ Function<INPUT, Callable<OUTPUT>> taskFactory
+ ) {
+ final AtomicInteger inPipeline = new AtomicInteger(0);
+ final AtomicBoolean running = new AtomicBoolean(true);
+ final Semaphore activeWorkers = new Semaphore(maxWorkers);
+ CompletionService<OUTPUT> completionService = new ExecutorCompletionService<>(executor);
+ executor.submit(() -> {
+ try {
+ while (!cancelledToken.isCancelled() && queue.hasNext()) {
+ // get semaphore and remember if task got submitted
+ // so in case we fail to submit we release the semaphore
+ activeWorkers.acquire();
+ boolean isTaskSubmitted = false;
+ try {
+ // just in case something
+ // changed while waiting
+ if (cancelledToken.isCancelled()) {
+ break;
+ }
+
+ // get next item and create callable
+ // using lambda expression
+ final INPUT item = queue.next();
+ Callable<OUTPUT> task = taskFactory.apply(item);
+ inPipeline.incrementAndGet();
+ try {
+ completionService.submit(task);
+ isTaskSubmitted = true;
+ } catch (Throwable e) {
+ inPipeline.decrementAndGet();
+ throw e;
+ }
+ } finally {
+ if (!isTaskSubmitted) {
+ activeWorkers.release();
+ }
+ }
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ } finally {
+ running.set(false);
+ }
+ });
+ return new ProducerState<>(running, inPipeline, activeWorkers, completionService);
+ }
+}
diff --git a/src/main/java/com/it_jaros/jscanner/scan/engine/ScanExecutionContext.java b/src/main/java/com/it_jaros/jscanner/scan/engine/ScanExecutionContext.java
new file mode 100644
index 0000000..38e9cdb
--- /dev/null
+++ b/src/main/java/com/it_jaros/jscanner/scan/engine/ScanExecutionContext.java
@@ -0,0 +1,16 @@
+package com.it_jaros.jscanner.scan.engine;
+
+import com.it_jaros.jscanner.scan.Scan;
+import com.it_jaros.jscanner.scan.ScanOptions;
+
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Semaphore;
+
+public record ScanExecutionContext(
+ ScanOptions scanOptions,
+ ExecutorService executorService,
+ Semaphore socketLimit,
+ CancelledToken cancelledToken,
+ Scan scan,
+ PortScanRateLimiter portScanRateLimiter
+) {}
diff --git a/src/main/java/com/it_jaros/jscanner/scan/engine/ScanHostTask.java b/src/main/java/com/it_jaros/jscanner/scan/engine/ScanHostTask.java
new file mode 100644
index 0000000..7a01d54
--- /dev/null
+++ b/src/main/java/com/it_jaros/jscanner/scan/engine/ScanHostTask.java
@@ -0,0 +1,123 @@
+package com.it_jaros.jscanner.scan.engine;
+
+import com.it_jaros.jscanner.scan.Scan;
+import com.it_jaros.jscanner.scan.domain.PortRange;
+import com.it_jaros.jscanner.scan.domain.PortResult;
+import com.it_jaros.jscanner.scan.domain.ScanResult;
+
+import java.io.IOException;
+import java.net.InetAddress;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.LockSupport;
+
+public class ScanHostTask implements Callable<ScanResult> {
+
+ private final Scan scan;
+ private final String host; // input parameter
+ private final ScanExecutionContext context;
+ private final CancelledToken cancelledToken;
+ private final boolean disableOnlineCheck;
+ private final int maxWorkersPerHost;
+ private final int timeoutInMillis;
+ private final long delayInNanos;
+
+ public ScanHostTask(Scan scan, String host, ScanExecutionContext context) {
+ this.scan = scan;
+ this.host = host;
+ this.context = context;
+ this.cancelledToken = context.cancelledToken();
+ this.delayInNanos = TimeUnit.MILLISECONDS.toNanos(Math.max(0, context.scanOptions().delayInMillis()));
+ this.disableOnlineCheck = context.scanOptions().disableOnlineCheck();
+ this.maxWorkersPerHost = context.scanOptions().maxWorkersPerHost();
+ this.timeoutInMillis = context.scanOptions().timeoutInMillis();
+ }
+
+ @Override
+ public ScanResult call() {
+ try {
+ scan.hostStart();
+ if (context.cancelledToken().isCancelled()) {
+ return ScanResult.empty(host);
+ }
+ return scanHostPorts();
+ } finally {
+ scan.hostFinish();
+ }
+ }
+
+ private ScanResult scanHostPorts() {
+ if (!disableOnlineCheck) {
+ boolean isHostOnline = checkHostOnline();
+ if (!isHostOnline) {
+ // Unreachable host
+ return ScanResult.empty(host);
+ }
+ // online check also sends packets to the target system.
+ // in order not to violate set delay time
+ // we wait here too
+ LockSupport.parkNanos(delayInNanos);
+ }
+
+ final PortRange portRange = new PortRange(scan.getPorts());
+ // producer thread
+ scan.producerStart();
+ ProducerState<PortResult> state = new ProducerThread(context).startProducer(
+ portRange.iterator(),
+ maxWorkersPerHost,
+ port -> new ScanPortTask(host, port, context)
+ );
+
+ // consumer is the main thread
+ // we run as long as the producer is running OR
+ // as long as things are in pipeline waiting to be processed
+ // ONLY exception is when cancelled is set
+ final PortResultAccumulator accumulator = new PortResultAccumulator(host);
+ while (!cancelledToken.isCancelled() && (state.running().get() || state.inPipeline().get() > 0)) {
+ try {
+ PollState<PortResult> poll = getPortResult(state);
+ if (poll instanceof PollState.Success<PortResult>(PortResult value)) {
+ accumulator.add(value);
+ } else if (poll instanceof PollState.Failure(Throwable error)) {
+ System.err.printf("scanHostPorts(%s): ScanPortTask() failed for with error %s -> %s%n", host, error.getClass().getSimpleName(), error.getMessage());
+ }
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ scan.producerStop();
+
+ return accumulator.build();
+ }
+
+ private boolean checkHostOnline() {
+ try {
+ return InetAddress.getByName(host).isReachable(timeoutInMillis);
+ } catch (IOException e) {
+ // we ignore this error because it means that the host is probably not online
+ }
+
+ return false;
+ }
+
+ private PollState<PortResult> getPortResult(ProducerState<PortResult> state) throws InterruptedException {
+ Future<PortResult> portResultFuture = state.completionService().poll(ProducerThread.pollInterval.toMillis(), TimeUnit.MILLISECONDS);
+ if (portResultFuture == null) {
+ return new PollState.Unavailable<>();
+ }
+
+ PortResult portResult;
+ try {
+ portResult = portResultFuture.get();
+ return new PollState.Success<>(portResult);
+ } catch (ExecutionException e) {
+ Throwable cause = e.getCause();
+ return new PollState.Failure<>(cause);
+ } finally {
+ state.activeWorkers().release();
+ state.inPipeline().decrementAndGet();
+ }
+ }
+}
diff --git a/src/main/java/com/it_jaros/jscanner/scan/engine/ScanPortTask.java b/src/main/java/com/it_jaros/jscanner/scan/engine/ScanPortTask.java
new file mode 100644
index 0000000..873ec95
--- /dev/null
+++ b/src/main/java/com/it_jaros/jscanner/scan/engine/ScanPortTask.java
@@ -0,0 +1,135 @@
+package com.it_jaros.jscanner.scan.engine;
+
+import com.it_jaros.jscanner.scan.Scan;
+import com.it_jaros.jscanner.scan.domain.PortResult;
+import com.it_jaros.jscanner.scan.domain.PortState;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.net.ConnectException;
+import java.net.InetSocketAddress;
+import java.net.NoRouteToHostException;
+import java.nio.ByteBuffer;
+import java.nio.channels.SocketChannel;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.LockSupport;
+
+public class ScanPortTask implements Callable<PortResult> {
+
+ private static final int READ_BUFFER_SIZE = 1024;
+
+ private final CancelledToken cancelledToken;
+ private final PortScanRateLimiter portScanRateLimiter; // per-host shared limiter
+ private final Scan scan;
+ private final Semaphore socketLimit;
+ private final String host;
+ private final boolean bannerRecognition;
+ private final int port;
+ private final long timeoutInNanos;
+
+ ScanPortTask(String host, int port, ScanExecutionContext context) {
+ this.host = host;
+ this.port = port;
+ this.bannerRecognition = context.scanOptions().bannerRecognition();
+ this.cancelledToken = context.cancelledToken();
+ this.portScanRateLimiter = context.portScanRateLimiter();
+ this.scan = context.scan();
+ this.socketLimit = context.socketLimit();
+ this.timeoutInNanos = TimeUnit.MILLISECONDS.toNanos(context.scanOptions().timeoutInMillis());
+ }
+
+ @Override
+ public PortResult call() throws Exception {
+ try {
+ socketLimit.acquire();
+ scan.portStart();
+ portScanRateLimiter.apply();
+ if (cancelledToken.isCancelled()) {
+ return PortResult.empty();
+ }
+ return checkPort();
+ } finally {
+ scan.portFinish();
+ socketLimit.release();
+ }
+ }
+
+ private PortResult checkPort() {
+ PortResult result = new PortResult();
+ result.setPort(port);
+ result.setState(PortState.UNKNOWN);
+ try(SocketChannel socketChannel = SocketChannel.open()) {
+ socketChannel.configureBlocking(false);
+ socketChannel.connect(new InetSocketAddress(host, port));
+ final long deadlineNanos = System.nanoTime() + timeoutInNanos;
+ final long waitInNanos = TimeUnit.MILLISECONDS.toNanos(1000);
+ boolean isConnected = socketChannel.finishConnect();
+ while (!cancelledToken.isCancelled() && !isConnected) {
+ long remainingNanos = deadlineNanos - System.nanoTime();
+ if (remainingNanos <= 0) {
+ break;
+ }
+ LockSupport.parkNanos(Math.min(waitInNanos, remainingNanos));
+ isConnected = socketChannel.finishConnect();
+ }
+
+ if (cancelledToken.isCancelled()) {
+ return result;
+ }
+
+ if (isConnected) {
+ result.setState(PortState.OPEN);
+ if (bannerRecognition) {
+ result.setBanner(getBanner(socketChannel));
+ }
+ } else {
+ result.setState(PortState.FILTERED);
+ }
+ } catch (ConnectException ignored) {
+ result.setState(PortState.CLOSED);
+ } catch (NoRouteToHostException ignored) {
+ // NoRouteToHostException: this can be safely ignored because the port is closed if a host is unreachable
+ // Will happen a lot when scanning for open ports, so not needed
+ } catch (IOException e) {
+ result.setException(e);
+ }
+
+ return result;
+ }
+
+ private byte[] getBanner(SocketChannel socketChannel) {
+ try {
+ return tryReadFrom(socketChannel);
+ } catch (IOException ignore) {
+ // ignore
+ }
+
+ return null;
+ }
+
+ private byte[] tryReadFrom(SocketChannel socketChannel) throws IOException {
+ byte[] buffer = new byte[READ_BUFFER_SIZE];
+ ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
+
+ boolean timedout = false;
+ long deadlineNanos = System.nanoTime() + timeoutInNanos;
+ while (!timedout) {
+ int bytesRead = socketChannel.read(ByteBuffer.wrap(buffer));
+ if (bytesRead == -1) {
+ break;
+ }
+ if (bytesRead > 0) {
+ byteArrayOutputStream.write(buffer, 0, bytesRead);
+ }
+ long remaining = deadlineNanos - System.nanoTime();
+ if (remaining <= 0) {
+ timedout = true;
+ } else {
+ LockSupport.parkNanos(Math.min(remaining, TimeUnit.MILLISECONDS.toNanos(1000)));
+ }
+ }
+ return byteArrayOutputStream.toByteArray();
+ }
+}
diff --git a/src/main/java/com/it_jaros/jscanner/scan/service/ServiceDetector.java b/src/main/java/com/it_jaros/jscanner/scan/service/ServiceDetector.java
new file mode 100644
index 0000000..93e05d4
--- /dev/null
+++ b/src/main/java/com/it_jaros/jscanner/scan/service/ServiceDetector.java
@@ -0,0 +1,47 @@
+package com.it_jaros.jscanner.scan.service;
+
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.regex.Pattern;
+
+public class ServiceDetector {
+ private static final List<ServicePattern> patterns = List.of(
+ new ServicePattern(ServiceType.DNS, Pattern.compile("(?i)\\bDNS\\b")),
+ new ServicePattern(ServiceType.DNS, Pattern.compile("(?i)\\bBIND\\b")),
+ new ServicePattern(ServiceType.DNS, Pattern.compile("(?i)\\bNSD\\b")),
+ new ServicePattern(ServiceType.DNS, Pattern.compile("(?i)\\bPowerDNS\\b")),
+ new ServicePattern(ServiceType.DNS, Pattern.compile("(?i)\\bUnbound\\b")),
+ new ServicePattern(ServiceType.FTP, Pattern.compile("(?i)^220.*\\bFTP\\b")),
+ new ServicePattern(ServiceType.HTTP, Pattern.compile("(?i)\\bServer:\\s*")),
+ new ServicePattern(ServiceType.HTTP, Pattern.compile("(?i)^HTTP/")),
+ new ServicePattern(ServiceType.IMAP, Pattern.compile("(?i)^\\* OK.*\\bIMAP\\b")),
+ new ServicePattern(ServiceType.POP3, Pattern.compile("(?i)^\\+OK.*\\bPOP3\\b")),
+ new ServicePattern(ServiceType.SMTP, Pattern.compile("(?i)^220.*\\bESMTP\\b")),
+ new ServicePattern(ServiceType.SMTP, Pattern.compile("(?i)^220.*\\bSMTP\\b")),
+ new ServicePattern(ServiceType.SSH, Pattern.compile("(?i)^SSH-"))
+ );
+
+ public static ServiceType detect(byte[] banner) {
+ if (banner == null || banner.length == 0) {
+ return ServiceType.UNKNOWN;
+ }
+
+ String bannerString = new String(banner, StandardCharsets.UTF_8);
+ for (ServicePattern servicePattern : patterns) {
+ if (servicePattern.matches(bannerString)) {
+ return servicePattern.serviceType();
+ }
+ }
+
+ return ServiceType.UNKNOWN;
+ }
+
+ private record ServicePattern(
+ ServiceType serviceType,
+ Pattern pattern
+ ) {
+ boolean matches(String banner) {
+ return pattern.matcher(banner).find();
+ }
+ }
+} \ No newline at end of file
diff --git a/src/main/java/com/it_jaros/jscanner/scan/service/ServiceType.java b/src/main/java/com/it_jaros/jscanner/scan/service/ServiceType.java
new file mode 100644
index 0000000..86b282a
--- /dev/null
+++ b/src/main/java/com/it_jaros/jscanner/scan/service/ServiceType.java
@@ -0,0 +1,5 @@
+package com.it_jaros.jscanner.scan.service;
+
+public enum ServiceType {
+ HTTP, FTP, SMTP, IMAP, POP3, UNKNOWN, DNS, SSH
+}