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 --- src/main/java/com/it_jaros/jscanner/App.java | 6 +- src/main/java/com/it_jaros/jscanner/CliParser.java | 2 + .../java/com/it_jaros/jscanner/CliPrinter.java | 258 +++++++++++ src/main/java/com/it_jaros/jscanner/Counter.java | 31 -- .../java/com/it_jaros/jscanner/ExceptionInfo.java | 13 - src/main/java/com/it_jaros/jscanner/PollState.java | 13 - src/main/java/com/it_jaros/jscanner/PortList.java | 27 -- src/main/java/com/it_jaros/jscanner/PortRange.java | 78 ---- .../com/it_jaros/jscanner/PortRangeIterator.java | 38 -- .../java/com/it_jaros/jscanner/PortResult.java | 48 --- src/main/java/com/it_jaros/jscanner/PortState.java | 8 - .../java/com/it_jaros/jscanner/ProducerState.java | 13 - .../java/com/it_jaros/jscanner/ProgressBar.java | 253 ----------- src/main/java/com/it_jaros/jscanner/Scan.java | 173 -------- .../java/com/it_jaros/jscanner/ScanException.java | 11 - .../java/com/it_jaros/jscanner/ScanFailure.java | 7 - .../java/com/it_jaros/jscanner/ScanOptions.java | 18 - .../java/com/it_jaros/jscanner/ScanResult.java | 24 -- src/main/java/com/it_jaros/jscanner/Scanner.java | 476 --------------------- .../com/it_jaros/jscanner/ServiceDetector.java | 47 -- .../java/com/it_jaros/jscanner/ServiceType.java | 5 - .../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 + 45 files changed, 1393 insertions(+), 1284 deletions(-) create mode 100644 src/main/java/com/it_jaros/jscanner/CliPrinter.java delete mode 100644 src/main/java/com/it_jaros/jscanner/Counter.java delete mode 100644 src/main/java/com/it_jaros/jscanner/ExceptionInfo.java delete mode 100644 src/main/java/com/it_jaros/jscanner/PollState.java delete mode 100644 src/main/java/com/it_jaros/jscanner/PortList.java delete mode 100644 src/main/java/com/it_jaros/jscanner/PortRange.java delete mode 100644 src/main/java/com/it_jaros/jscanner/PortRangeIterator.java delete mode 100644 src/main/java/com/it_jaros/jscanner/PortResult.java delete mode 100644 src/main/java/com/it_jaros/jscanner/PortState.java delete mode 100644 src/main/java/com/it_jaros/jscanner/ProducerState.java delete mode 100644 src/main/java/com/it_jaros/jscanner/ProgressBar.java delete mode 100644 src/main/java/com/it_jaros/jscanner/Scan.java delete mode 100644 src/main/java/com/it_jaros/jscanner/ScanException.java delete mode 100644 src/main/java/com/it_jaros/jscanner/ScanFailure.java delete mode 100644 src/main/java/com/it_jaros/jscanner/ScanOptions.java delete mode 100644 src/main/java/com/it_jaros/jscanner/ScanResult.java delete mode 100644 src/main/java/com/it_jaros/jscanner/Scanner.java delete mode 100644 src/main/java/com/it_jaros/jscanner/ServiceDetector.java delete mode 100644 src/main/java/com/it_jaros/jscanner/ServiceType.java 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 diff --git a/src/main/java/com/it_jaros/jscanner/App.java b/src/main/java/com/it_jaros/jscanner/App.java index 67f8cdb..fdb9ce6 100644 --- a/src/main/java/com/it_jaros/jscanner/App.java +++ b/src/main/java/com/it_jaros/jscanner/App.java @@ -1,5 +1,9 @@ package com.it_jaros.jscanner; +import com.it_jaros.jscanner.scan.Scan; +import com.it_jaros.jscanner.scan.ScanOptions; +import com.it_jaros.jscanner.scan.Scanner; + import java.io.IOException; import java.util.concurrent.CountDownLatch; @@ -34,7 +38,7 @@ public class App { private static void runScan(Scanner scanner, ScanOptions options) throws IOException { addShutdownHook(scanner); Scan scan = Scan.create(options); - ProgressBar progressBar = new ProgressBar(scan, options.quiet()); + CliPrinter progressBar = new CliPrinter(scan, options.quiet()); progressBar.start(); scanner.runScan(scan, result -> progressBar.printResult(result, options.showFilteredPorts())); progressBar.stop(); diff --git a/src/main/java/com/it_jaros/jscanner/CliParser.java b/src/main/java/com/it_jaros/jscanner/CliParser.java index d7f5143..7e27064 100644 --- a/src/main/java/com/it_jaros/jscanner/CliParser.java +++ b/src/main/java/com/it_jaros/jscanner/CliParser.java @@ -1,5 +1,7 @@ package com.it_jaros.jscanner; +import com.it_jaros.jscanner.scan.ScanOptions; + import java.util.HashSet; import java.util.Set; diff --git a/src/main/java/com/it_jaros/jscanner/CliPrinter.java b/src/main/java/com/it_jaros/jscanner/CliPrinter.java new file mode 100644 index 0000000..709c3dd --- /dev/null +++ b/src/main/java/com/it_jaros/jscanner/CliPrinter.java @@ -0,0 +1,258 @@ +package com.it_jaros.jscanner; + +import com.it_jaros.jscanner.scan.Scan; +import com.it_jaros.jscanner.scan.domain.ScanFailure; +import com.it_jaros.jscanner.scan.domain.ScanResult; +import com.it_jaros.jscanner.scan.service.ServiceType; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +public class CliPrinter { + + private final ScheduledExecutorService ui = Executors.newSingleThreadScheduledExecutor(); + private final Scan scan; + private final boolean quiet; + private final Object outputLock = new Object(); + + public CliPrinter(Scan scan, boolean quiet) { + this.scan = scan; + this.quiet = quiet; + } + + private void clearStatusLine() { + System.err.print("\r\033[2K"); + System.err.flush(); + } + + public void printResult(ScanResult result, boolean showFilteredPorts) { + synchronized (outputLock) { + if (result.openPorts().isEmpty() + && (!showFilteredPorts || result.filteredPorts().isEmpty()) + && result.errors().isEmpty()) { + return; + } + + clearStatusLine(); + printErrors(result); + printPortsResults(result, showFilteredPorts); + printStatusLine(); + } + } + + private void printErrors(ScanResult result) { + if (result.errors().isEmpty()) return; + + StringBuilder sb = new StringBuilder(); + sb.append(String.format( + "%s | errors: %d%n", + result.host(), + result.errors().size() + )); + String errors = result.errors().stream().map((ScanFailure error) -> String.format( + "\t%s: %s, %s", + error.port(), + error.exception().type(), + error.exception().message() + )).collect(Collectors.joining("\n")); + sb.append(errors); + sb.append("\n"); + + System.err.println(sb); + System.err.flush(); + } + + private void printPortsResults(ScanResult result, boolean showFilteredPorts) { + if (result.openPorts().isEmpty() && (!showFilteredPorts || result.filteredPorts().isEmpty())) { + return; + } + + StringBuilder sb = new StringBuilder(); + sb.append(result.host()); + sb.append("\n"); + if (!result.openPorts().isEmpty()) { + sb.append( + String.format("\t (%d) open:\t%s%n", result.openPorts().size(), map(result.openPorts().getPorts(), result.bannerRecognition())) + ); + } + if (showFilteredPorts && !result.filteredPorts().isEmpty()) { + sb.append( + String.format("\t (%d) filtered:\t%s%n", result.filteredPorts().size(), map(result.filteredPorts().getPorts())) + ); + } + sb.append("\n"); + System.out.println(sb); + System.out.flush(); + } + + private static String map(List ports) { + return ports.stream().map(String::valueOf).collect(Collectors.joining(",")); + } + + private static String map(List ports, Map bannerRecognition) { + return ports.stream().map(port -> { + ServiceType serviceType = bannerRecognition.getOrDefault(port, ServiceType.UNKNOWN); + + if (serviceType == ServiceType.UNKNOWN) { + return String.valueOf(port); + } + + return port + "/" + serviceType.name().toLowerCase(); + }).collect(Collectors.joining(",")); + } + + private static int getColumnWidth() { + try { + Process process = new ProcessBuilder("sh", "-c", "stty size < /dev/tty").start(); + + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getInputStream()))) { + String line = reader.readLine(); + if (line != null && !line.isBlank()) { + Matcher matcher = Pattern.compile("^\\d+\\s+(\\d+)").matcher(line.trim()); + if (matcher.matches()) { + return Integer.parseInt(matcher.group(1)); + } + } + } + } catch (Exception ignored) { + // does not matter why it did not work + } + + return 120; + } + + public void start() { + if (quiet) { + return; + } + + // although we don't print anything, we still need to + // gc all the done hosts + System.err.print("Scanning targets...\n\n"); + ui.scheduleAtFixedRate(() -> { + synchronized (outputLock) { + printStatusLine(); + } + }, 1, 1000, TimeUnit.MILLISECONDS); + } + + public void stop() { + ui.shutdownNow(); + try { + ui.awaitTermination(1, TimeUnit.SECONDS); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + clearStatusLine(); + printStats(); + } + + private void printStats() { + Scan.Statistics stats = scan.getStatistics(); + System.err.println("\n--------------------"); + System.err.println("Stats:"); + System.err.println("Total hosts: " + stats.hostTotal()); + System.err.println("Total ports: " + stats.portTotal()); + System.err.println("Duration: " + formatDuration(stats.durationInMillis())); + System.err.println("Peak concurrent hosts: " + stats.hostMaxConcurrent()); + System.err.println("Peak concurrent sockets: " + stats.socketMaxConcurrent()); + System.err.println("Peak concurrent workers: " + stats.threadMaxConcurrent()); + System.err.println("--------------------\n"); + } + + private static String formatDuration(long millis) { + if (millis <= 0) return ""; + long seconds = millis / 1000 % 60; + long minutes = millis / 60000 % 60; + long hours = millis / 3600000 % 24; + long days = millis / 86400000; + + if (days > 0) return String.format("%dd %dh:%dm:%ds", days, hours, minutes, seconds); + if (hours > 0) return String.format("%dh:%dm:%ds", hours, minutes, seconds); + if (minutes > 0) return String.format("%dm:%ds", minutes, seconds); + return String.format("%ds", seconds); + } + + + private void printStatusLine() { + Scan.Statistics stats = scan.getStatistics(); + int total = stats.hostTotal(); + int running = stats.hostCurrent(); + int done = total - running; + long percent = total == 0 ? 100 : done * 100L / total; + + String hostStat = String.format( + "hosts: %d running, %d done", + running, + done + ); + String portStat = String.format( + " | ports: %d total", + stats.portTotal() + ); + String durationStat = String.format( + " | %s", + formatDuration(stats.durationInMillis()) + ); + String socketStat = String.format( + " | sockets: %d active, %d peak", + stats.socketCurrent(), + stats.socketMaxConcurrent() + ); + String workerStat = String.format( + " | workers: %d active, %d peak", + stats.threadCurrent(), + stats.threadMaxConcurrent() + ); + + int availableWidth = CliPrinter.getColumnWidth(); + String statusLine = appendIfEnoughSpace("", availableWidth, hostStat, portStat, durationStat, socketStat, workerStat); + + String barFormat = " [%s] %3d%%"; + int remainingWidth = availableWidth - statusLine.length() - barFormat.length(); + if (remainingWidth > 0) { + String progressBar = String.format( + barFormat, + progressBar(percent, remainingWidth), + percent + ); + statusLine = appendIfEnoughSpace(statusLine, availableWidth, progressBar); + } + + remainingWidth = availableWidth - statusLine.length(); + if (remainingWidth > 0) { + statusLine += " ".repeat(remainingWidth); + } + System.err.print("\r" + statusLine); + System.err.flush(); + } + + private String appendIfEnoughSpace(String current, int availableSize, String... additional) { + StringBuilder builder = new StringBuilder(current); + for (String add : additional) { + if ((builder.length() + add.length()) <= availableSize) { + builder.append(add); + } + } + + return builder.toString(); + } + + private static String progressBar(long percent, int width) { + if (width <= 0) { + return ""; + } + + int filled = (int) (percent * width / 100); + return "#".repeat(filled) + "-".repeat(width - filled); + } +} diff --git a/src/main/java/com/it_jaros/jscanner/Counter.java b/src/main/java/com/it_jaros/jscanner/Counter.java deleted file mode 100644 index af7966a..0000000 --- a/src/main/java/com/it_jaros/jscanner/Counter.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.it_jaros.jscanner; - -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/ExceptionInfo.java b/src/main/java/com/it_jaros/jscanner/ExceptionInfo.java deleted file mode 100644 index ab92a5b..0000000 --- a/src/main/java/com/it_jaros/jscanner/ExceptionInfo.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.it_jaros.jscanner; - -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/PollState.java b/src/main/java/com/it_jaros/jscanner/PollState.java deleted file mode 100644 index 474c055..0000000 --- a/src/main/java/com/it_jaros/jscanner/PollState.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.it_jaros.jscanner; - -/** - * 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/PortList.java b/src/main/java/com/it_jaros/jscanner/PortList.java deleted file mode 100644 index 574d3eb..0000000 --- a/src/main/java/com/it_jaros/jscanner/PortList.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.it_jaros.jscanner; - -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/PortRange.java b/src/main/java/com/it_jaros/jscanner/PortRange.java deleted file mode 100644 index 8969b8f..0000000 --- a/src/main/java/com/it_jaros/jscanner/PortRange.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.it_jaros.jscanner; - -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/PortRangeIterator.java b/src/main/java/com/it_jaros/jscanner/PortRangeIterator.java deleted file mode 100644 index 6690957..0000000 --- a/src/main/java/com/it_jaros/jscanner/PortRangeIterator.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.it_jaros.jscanner; - -import java.util.BitSet; -import java.util.Iterator; -import java.util.NoSuchElementException; - -import static com.it_jaros.jscanner.PortRange.MAX_PORT; -import static com.it_jaros.jscanner.PortRange.MIN_PORT; - -public class PortRangeIterator implements Iterator { - - private int currentPortCursor; - private int done = 0; - private final BitSet availablePorts = new BitSet(MAX_PORT); - - 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/PortResult.java b/src/main/java/com/it_jaros/jscanner/PortResult.java deleted file mode 100644 index f246302..0000000 --- a/src/main/java/com/it_jaros/jscanner/PortResult.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.it_jaros.jscanner; - -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/PortState.java b/src/main/java/com/it_jaros/jscanner/PortState.java deleted file mode 100644 index 656d530..0000000 --- a/src/main/java/com/it_jaros/jscanner/PortState.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.it_jaros.jscanner; - -public enum PortState { - CLOSED, - FILTERED, - OPEN, - UNKNOWN -} diff --git a/src/main/java/com/it_jaros/jscanner/ProducerState.java b/src/main/java/com/it_jaros/jscanner/ProducerState.java deleted file mode 100644 index 36a5841..0000000 --- a/src/main/java/com/it_jaros/jscanner/ProducerState.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.it_jaros.jscanner; - -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/ProgressBar.java b/src/main/java/com/it_jaros/jscanner/ProgressBar.java deleted file mode 100644 index 2c323d7..0000000 --- a/src/main/java/com/it_jaros/jscanner/ProgressBar.java +++ /dev/null @@ -1,253 +0,0 @@ -package com.it_jaros.jscanner; - -import java.io.BufferedReader; -import java.io.InputStreamReader; -import java.util.List; -import java.util.Map; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import java.util.stream.Collectors; - -public class ProgressBar { - - private final ScheduledExecutorService ui = Executors.newSingleThreadScheduledExecutor(); - private final Scan scan; - private final boolean quiet; - private final Object outputLock = new Object(); - - public ProgressBar(Scan scan, boolean quiet) { - this.scan = scan; - this.quiet = quiet; - } - - private void clearStatusLine() { - System.err.print("\r\033[2K"); - System.err.flush(); - } - - public void printResult(ScanResult result, boolean showFilteredPorts) { - synchronized (outputLock) { - if (result.openPorts().isEmpty() - && (!showFilteredPorts || result.filteredPorts().isEmpty()) - && result.errors().isEmpty()) { - return; - } - - clearStatusLine(); - printErrors(result); - printPortsResults(result, showFilteredPorts); - printStatusLine(); - } - } - - private void printErrors(ScanResult result) { - if (result.errors().isEmpty()) return; - - StringBuilder sb = new StringBuilder(); - sb.append(String.format( - "%s | errors: %d%n", - result.host(), - result.errors().size() - )); - String errors = result.errors().stream().map((ScanFailure error) -> String.format( - "\t%s: %s, %s", - error.port(), - error.exception().type(), - error.exception().message() - )).collect(Collectors.joining("\n")); - sb.append(errors); - sb.append("\n"); - - System.err.println(sb); - System.err.flush(); - } - - private void printPortsResults(ScanResult result, boolean showFilteredPorts) { - if (result.openPorts().isEmpty() && (!showFilteredPorts || result.filteredPorts().isEmpty())) { - return; - } - - StringBuilder sb = new StringBuilder(); - sb.append(result.host()); - sb.append("\n"); - if (!result.openPorts().isEmpty()) { - sb.append( - String.format("\t (%d) open:\t%s%n", result.openPorts().size(), map(result.openPorts().getPorts(), result.bannerRecognition())) - ); - } - if (showFilteredPorts && !result.filteredPorts().isEmpty()) { - sb.append( - String.format("\t (%d) filtered:\t%s%n", result.filteredPorts().size(), map(result.filteredPorts().getPorts())) - ); - } - sb.append("\n"); - System.out.println(sb); - System.out.flush(); - } - - private static String map(List ports) { - return ports.stream().map(String::valueOf).collect(Collectors.joining(",")); - } - - private static String map(List ports, Map bannerRecognition) { - return ports.stream().map(port -> { - ServiceType serviceType = bannerRecognition.getOrDefault(port, ServiceType.UNKNOWN); - - if (serviceType == ServiceType.UNKNOWN) { - return String.valueOf(port); - } - - return port + "/" + serviceType.name().toLowerCase(); - }).collect(Collectors.joining(",")); - } - - private static int getColumnWidth() { - try { - Process process = new ProcessBuilder("sh", "-c", "stty size < /dev/tty").start(); - - try (BufferedReader reader = new BufferedReader( - new InputStreamReader(process.getInputStream()))) { - String line = reader.readLine(); - if (line != null && !line.isBlank()) { - Matcher matcher = Pattern.compile("^\\d+\\s+(\\d+)").matcher(line.trim()); - if (matcher.matches()) { - return Integer.parseInt(matcher.group(1)); - } - } - } - } catch (Exception ignored) { - // does not matter why it did not work - } - - return 120; - } - - public void start() { - if (quiet) { - return; - } - - // although we don't print anything, we still need to - // gc all the done hosts - System.err.print("Scanning targets...\n\n"); - ui.scheduleAtFixedRate(() -> { - synchronized (outputLock) { - printStatusLine(); - } - }, 1, 1000, TimeUnit.MILLISECONDS); - } - - public void stop() { - ui.shutdownNow(); - try { - ui.awaitTermination(1, TimeUnit.SECONDS); - } catch (InterruptedException ignored) { - Thread.currentThread().interrupt(); - } - clearStatusLine(); - printStats(); - } - - private void printStats() { - Scan.Statistics stats = scan.getStatistics(); - System.err.println("\n--------------------"); - System.err.println("Stats:"); - System.err.println("Total hosts: " + stats.hostTotal()); - System.err.println("Total ports: " + stats.portTotal()); - System.err.println("Duration: " + formatDuration(stats.durationInMillis())); - System.err.println("Peak concurrent hosts: " + stats.hostMaxConcurrent()); - System.err.println("Peak concurrent sockets: " + stats.socketMaxConcurrent()); - System.err.println("Peak concurrent workers: " + stats.threadMaxConcurrent()); - System.err.println("--------------------\n"); - } - - private static String formatDuration(long millis) { - if (millis <= 0) return ""; - long seconds = millis / 1000 % 60; - long minutes = millis / 60000 % 60; - long hours = millis / 3600000 % 24; - long days = millis / 86400000; - - if (days > 0) return String.format("%dd %dh:%dm:%ds", days, hours, minutes, seconds); - if (hours > 0) return String.format("%dh:%dm:%ds", hours, minutes, seconds); - if (minutes > 0) return String.format("%dm:%ds", minutes, seconds); - return String.format("%ds", seconds); - } - - - private void printStatusLine() { - Scan.Statistics stats = scan.getStatistics(); - int total = stats.hostTotal(); - int running = stats.hostCurrent(); - int done = total - running; - long percent = total == 0 ? 100 : done * 100L / total; - - String hostStat = String.format( - "hosts: %d running, %d done", - running, - done - ); - String portStat = String.format( - " | ports: %d total", - stats.portTotal() - ); - String durationStat = String.format( - " | %s", - formatDuration(stats.durationInMillis()) - ); - String socketStat = String.format( - " | sockets: %d active, %d peak", - stats.socketCurrent(), - stats.socketMaxConcurrent() - ); - String workerStat = String.format( - " | workers: %d active, %d peak", - stats.threadCurrent(), - stats.threadMaxConcurrent() - ); - - int availableWidth = ProgressBar.getColumnWidth(); - String statusLine = appendIfEnoughSpace("", availableWidth, hostStat, portStat, durationStat, socketStat, workerStat); - - String barFormat = " [%s] %3d%%"; - int remainingWidth = availableWidth - statusLine.length() - barFormat.length(); - if (remainingWidth > 0) { - String progressBar = String.format( - barFormat, - progressBar(percent, remainingWidth), - percent - ); - statusLine = appendIfEnoughSpace(statusLine, availableWidth, progressBar); - } - - remainingWidth = availableWidth - statusLine.length(); - if (remainingWidth > 0) { - statusLine += " ".repeat(remainingWidth); - } - System.err.print("\r" + statusLine); - System.err.flush(); - } - - private String appendIfEnoughSpace(String current, int availableSize, String... additional) { - StringBuilder builder = new StringBuilder(current); - for (String add : additional) { - if ((builder.length() + add.length()) <= availableSize) { - builder.append(add); - } - } - - return builder.toString(); - } - - private static String progressBar(long percent, int width) { - if (width <= 0) { - return ""; - } - - int filled = (int) (percent * width / 100); - return "#".repeat(filled) + "-".repeat(width - filled); - } -} diff --git a/src/main/java/com/it_jaros/jscanner/Scan.java b/src/main/java/com/it_jaros/jscanner/Scan.java deleted file mode 100644 index 3428acf..0000000 --- a/src/main/java/com/it_jaros/jscanner/Scan.java +++ /dev/null @@ -1,173 +0,0 @@ -package com.it_jaros.jscanner; - -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/ScanException.java b/src/main/java/com/it_jaros/jscanner/ScanException.java deleted file mode 100644 index 3b2cf24..0000000 --- a/src/main/java/com/it_jaros/jscanner/ScanException.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.it_jaros.jscanner; - -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/ScanFailure.java b/src/main/java/com/it_jaros/jscanner/ScanFailure.java deleted file mode 100644 index 2c38790..0000000 --- a/src/main/java/com/it_jaros/jscanner/ScanFailure.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.it_jaros.jscanner; - -public record ScanFailure( - Integer port, - ExceptionInfo exception -) { -} diff --git a/src/main/java/com/it_jaros/jscanner/ScanOptions.java b/src/main/java/com/it_jaros/jscanner/ScanOptions.java deleted file mode 100644 index 5dcb822..0000000 --- a/src/main/java/com/it_jaros/jscanner/ScanOptions.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.it_jaros.jscanner; - -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/ScanResult.java b/src/main/java/com/it_jaros/jscanner/ScanResult.java deleted file mode 100644 index 3eb4d0d..0000000 --- a/src/main/java/com/it_jaros/jscanner/ScanResult.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.it_jaros.jscanner; - -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/Scanner.java b/src/main/java/com/it_jaros/jscanner/Scanner.java deleted file mode 100644 index 7f6b743..0000000 --- a/src/main/java/com/it_jaros/jscanner/Scanner.java +++ /dev/null @@ -1,476 +0,0 @@ -package com.it_jaros.jscanner; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.net.*; -import java.nio.ByteBuffer; -import java.nio.channels.SocketChannel; -import java.time.Duration; -import java.util.*; -import java.util.concurrent.*; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.locks.LockSupport; -import java.util.function.Consumer; -import java.util.function.Function; - -public class Scanner implements AutoCloseable { - - private static final Duration pollInterval = Duration.ofSeconds(1); - private static final int READ_BUFFER_SIZE = 1024; - - private volatile boolean cancelled = false; - - private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); - private final Semaphore socketLimit; - private final boolean bannerRecognition; - private final boolean disableOnlineCheck; - private final int maxHostsLimit; - private final int maxWorkersPerHost; - private final int timeoutInMillis; - private final long delayInNanos; - - public Scanner( - int socketLimit, - int timeoutInMillis, - int delayInMillis, - int maxWorkersPerHost, - int maxHostsLimit, - boolean disableOnlineCheck, - boolean bannerRecognition - ) { - this.delayInNanos = TimeUnit.MILLISECONDS.toNanos(Math.max(0, delayInMillis)); - this.bannerRecognition = bannerRecognition; - this.disableOnlineCheck = disableOnlineCheck; - this.maxHostsLimit = maxHostsLimit; - this.maxWorkersPerHost = maxWorkersPerHost; - this.socketLimit = new Semaphore(socketLimit); - this.timeoutInMillis = timeoutInMillis; - } - - public Scanner(ScanOptions options) { - this(options.socketLimit(), options.timeoutInMillis(), options.delayInMillis(), options.maxWorkersPerHost(), options.maxHostsLimit(), options.disableOnlineCheck(), options.bannerRecognition()); - } - - /** - * 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(); - - // start producer thread - scan.producerStart(); - final ProducerState state = startProducer( - scan.getHosts().iterator(), - maxHostsLimit, - host -> new ScanHostTask(scan, host) - ); - - // 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(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(); - } - } - - /** - * 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 - */ - private 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 (!cancelled && 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 (cancelled) { - 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); - } - - public boolean awaitTermination(Duration duration) throws InterruptedException { - return executor.awaitTermination(duration.toMillis(), TimeUnit.MILLISECONDS); - } - - public void cancel() { - if (!cancelled) { - cancelled = true; - } - executor.shutdown(); - } - - public void cancelNow() { - if (!cancelled) { - cancelled = true; - } - executor.shutdownNow(); - } - - @Override - public void close() throws Exception { - cancel(); - } - - - private final class ScanHostTask implements Callable { - private final PortScanRateLimiter rateLimiter = new PortScanRateLimiter(); - - private final Scan scan; - private final String host; // input parameter - - ScanHostTask(Scan scan, String host) { - this.scan = scan; - this.host = host; - } - - @Override - public ScanResult call() { - try { - scan.hostStart(); - if (cancelled) { - 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 = startProducer( - portRange.iterator(), - maxWorkersPerHost, - port -> new ScanPortTask(host, port, scan, rateLimiter) - ); - - // 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 (!cancelled && (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(%host): 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(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(); - } - } - } - - /** - * 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. - */ - private final class PortScanRateLimiter { - private final Object lock = new Object(); - private volatile long nextAllowedTime; - - void apply() { - if (delayInNanos <= 0) { - return; - } - synchronized (lock) { - if (cancelled) { - return; - } - long now = System.nanoTime(); - if (nextAllowedTime > now) { - LockSupport.parkNanos(nextAllowedTime - now); - now = System.nanoTime(); // re-read after waking - } - nextAllowedTime = now + delayInNanos; - } - } - } - - private final class ScanPortTask implements Callable { - private final Scan scan; - private final String host; - private final int port; - private final PortScanRateLimiter portScanRateLimiter; // per-host shared limiter - - private ScanPortTask(String host, int port, Scan scan, PortScanRateLimiter portScanRateLimiter) { - this.host = host; - this.port = port; - this.scan = scan; - this.portScanRateLimiter = portScanRateLimiter; - } - - @Override - public PortResult call() throws Exception { - try { - socketLimit.acquire(); - scan.portStart(); - portScanRateLimiter.apply(); - if (cancelled) { - 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() + TimeUnit.MILLISECONDS.toNanos(timeoutInMillis); - final long waitInNanos = TimeUnit.MILLISECONDS.toNanos(1000); - boolean isConnected = socketChannel.finishConnect(); - while (!cancelled && !isConnected) { - long remainingNanos = deadlineNanos - System.nanoTime(); - if (remainingNanos <= 0) { - break; - } - LockSupport.parkNanos(Math.min(waitInNanos, remainingNanos)); - isConnected = socketChannel.finishConnect(); - } - - if (cancelled) { - return result; - } - - if (isConnected) { - result.setState(PortState.OPEN); - if (bannerRecognition) { - result.setBanner(getBanner(socketChannel)); - } - } else { - result.setState(PortState.FILTERED); - } - } catch (SocketTimeoutException ignored) { - 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() + TimeUnit.MILLISECONDS.toNanos(timeoutInMillis); - 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(); - } - } - - private 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<>(); - - private 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/ServiceDetector.java b/src/main/java/com/it_jaros/jscanner/ServiceDetector.java deleted file mode 100644 index 4d4f76e..0000000 --- a/src/main/java/com/it_jaros/jscanner/ServiceDetector.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.it_jaros.jscanner; - -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/ServiceType.java b/src/main/java/com/it_jaros/jscanner/ServiceType.java deleted file mode 100644 index 3677faf..0000000 --- a/src/main/java/com/it_jaros/jscanner/ServiceType.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.it_jaros.jscanner; - -public enum ServiceType { - HTTP, FTP, SMTP, IMAP, POP3, UNKNOWN, DNS, SSH -} 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