From ff14e0fc4e3fe4f5b8a67640d28850064661d7ab Mon Sep 17 00:00:00 2001 From: Matthias Jaros Date: Wed, 12 Aug 2026 13:24:27 +0200 Subject: Major refactoring of package structure --- .../java/com/it_jaros/jscanner/scan/Counter.java | 31 ++++ .../com/it_jaros/jscanner/scan/ExceptionInfo.java | 13 ++ src/main/java/com/it_jaros/jscanner/scan/Scan.java | 173 +++++++++++++++++++++ .../com/it_jaros/jscanner/scan/ScanException.java | 11 ++ .../com/it_jaros/jscanner/scan/ScanOptions.java | 18 +++ .../java/com/it_jaros/jscanner/scan/Scanner.java | 110 +++++++++++++ .../it_jaros/jscanner/scan/domain/PortList.java | 27 ++++ .../it_jaros/jscanner/scan/domain/PortRange.java | 80 ++++++++++ .../it_jaros/jscanner/scan/domain/PortResult.java | 48 ++++++ .../it_jaros/jscanner/scan/domain/PortState.java | 8 + .../it_jaros/jscanner/scan/domain/ScanFailure.java | 9 ++ .../it_jaros/jscanner/scan/domain/ScanResult.java | 26 ++++ .../jscanner/scan/engine/CancelledToken.java | 15 ++ .../it_jaros/jscanner/scan/engine/PollState.java | 13 ++ .../jscanner/scan/engine/PortRangeIterator.java | 38 +++++ .../scan/engine/PortResultAccumulator.java | 49 ++++++ .../jscanner/scan/engine/PortScanRateLimiter.java | 37 +++++ .../jscanner/scan/engine/ProducerState.java | 13 ++ .../jscanner/scan/engine/ProducerThread.java | 83 ++++++++++ .../jscanner/scan/engine/ScanExecutionContext.java | 16 ++ .../jscanner/scan/engine/ScanHostTask.java | 123 +++++++++++++++ .../jscanner/scan/engine/ScanPortTask.java | 135 ++++++++++++++++ .../jscanner/scan/service/ServiceDetector.java | 47 ++++++ .../jscanner/scan/service/ServiceType.java | 5 + 24 files changed, 1128 insertions(+) create mode 100644 src/main/java/com/it_jaros/jscanner/scan/Counter.java create mode 100644 src/main/java/com/it_jaros/jscanner/scan/ExceptionInfo.java create mode 100644 src/main/java/com/it_jaros/jscanner/scan/Scan.java create mode 100644 src/main/java/com/it_jaros/jscanner/scan/ScanException.java create mode 100644 src/main/java/com/it_jaros/jscanner/scan/ScanOptions.java create mode 100644 src/main/java/com/it_jaros/jscanner/scan/Scanner.java create mode 100644 src/main/java/com/it_jaros/jscanner/scan/domain/PortList.java create mode 100644 src/main/java/com/it_jaros/jscanner/scan/domain/PortRange.java create mode 100644 src/main/java/com/it_jaros/jscanner/scan/domain/PortResult.java create mode 100644 src/main/java/com/it_jaros/jscanner/scan/domain/PortState.java create mode 100644 src/main/java/com/it_jaros/jscanner/scan/domain/ScanFailure.java create mode 100644 src/main/java/com/it_jaros/jscanner/scan/domain/ScanResult.java create mode 100644 src/main/java/com/it_jaros/jscanner/scan/engine/CancelledToken.java create mode 100644 src/main/java/com/it_jaros/jscanner/scan/engine/PollState.java create mode 100644 src/main/java/com/it_jaros/jscanner/scan/engine/PortRangeIterator.java create mode 100644 src/main/java/com/it_jaros/jscanner/scan/engine/PortResultAccumulator.java create mode 100644 src/main/java/com/it_jaros/jscanner/scan/engine/PortScanRateLimiter.java create mode 100644 src/main/java/com/it_jaros/jscanner/scan/engine/ProducerState.java create mode 100644 src/main/java/com/it_jaros/jscanner/scan/engine/ProducerThread.java create mode 100644 src/main/java/com/it_jaros/jscanner/scan/engine/ScanExecutionContext.java create mode 100644 src/main/java/com/it_jaros/jscanner/scan/engine/ScanHostTask.java create mode 100644 src/main/java/com/it_jaros/jscanner/scan/engine/ScanPortTask.java create mode 100644 src/main/java/com/it_jaros/jscanner/scan/service/ServiceDetector.java create mode 100644 src/main/java/com/it_jaros/jscanner/scan/service/ServiceType.java (limited to 'src/main/java/com/it_jaros/jscanner/scan') 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 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 hosts, String ports) { + this(hosts, ports, () -> {}); + } + + private Scan(Stream 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 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 hostsArgv, String ports) { + return new Scan(hostsArgv.stream(), ports); + } + + public Stream 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 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 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 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 poll = getHostResult(state); + if (poll instanceof PollState.Success(ScanResult value)) { + consumer.accept(value); + } else if (poll instanceof PollState.Failure(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 getHostResult(ProducerState state) throws InterruptedException { + Future 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 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 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 bannerRecognition, + List 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 { + record Success(T value) implements PollState {} + record Failure(Throwable error) implements PollState {} + record Unavailable() implements PollState {} +} 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 { + + 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 serviceTypes = new HashMap<>(); + private final List 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( + AtomicBoolean running, + AtomicInteger inPipeline, + Semaphore activeWorkers, + CompletionService 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 + * @param + * @return + */ + public ProducerState startProducer( + Iterator queue, + int maxWorkers, + Function> taskFactory + ) { + final AtomicInteger inPipeline = new AtomicInteger(0); + final AtomicBoolean running = new AtomicBoolean(true); + final Semaphore activeWorkers = new Semaphore(maxWorkers); + CompletionService 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 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 { + + 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 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 poll = getPortResult(state); + if (poll instanceof PollState.Success(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 getPortResult(ProducerState state) throws InterruptedException { + Future 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 { + + 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 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 +} -- cgit v1.3.1