From 1cbb5b6ffd5afdf55ea6727ee23550796a57809b Mon Sep 17 00:00:00 2001 From: Matthias Jaros Date: Wed, 12 Aug 2026 16:36:28 +0200 Subject: Renamed jscanner package to jns --- src/main/java/com/it_jaros/jns/App.java | 100 ++++++++ src/main/java/com/it_jaros/jns/CliDefaults.java | 18 ++ src/main/java/com/it_jaros/jns/CliParser.java | 143 ++++++++++++ src/main/java/com/it_jaros/jns/CliPrinter.java | 258 +++++++++++++++++++++ src/main/java/com/it_jaros/jns/HelpRequested.java | 7 + src/main/java/com/it_jaros/jns/scan/Counter.java | 31 +++ .../java/com/it_jaros/jns/scan/ExceptionInfo.java | 13 ++ src/main/java/com/it_jaros/jns/scan/Scan.java | 173 ++++++++++++++ .../java/com/it_jaros/jns/scan/ScanException.java | 11 + .../java/com/it_jaros/jns/scan/ScanOptions.java | 18 ++ src/main/java/com/it_jaros/jns/scan/Scanner.java | 108 +++++++++ .../com/it_jaros/jns/scan/domain/PortList.java | 27 +++ .../com/it_jaros/jns/scan/domain/PortRange.java | 80 +++++++ .../com/it_jaros/jns/scan/domain/PortResult.java | 48 ++++ .../com/it_jaros/jns/scan/domain/PortState.java | 8 + .../com/it_jaros/jns/scan/domain/ScanFailure.java | 9 + .../com/it_jaros/jns/scan/domain/ScanResult.java | 26 +++ .../it_jaros/jns/scan/engine/CancelledToken.java | 15 ++ .../com/it_jaros/jns/scan/engine/PollState.java | 13 ++ .../jns/scan/engine/PortRangeIterator.java | 38 +++ .../jns/scan/engine/PortResultAccumulator.java | 49 ++++ .../jns/scan/engine/PortScanRateLimiter.java | 37 +++ .../it_jaros/jns/scan/engine/ProducerState.java | 13 ++ .../it_jaros/jns/scan/engine/ProducerThread.java | 83 +++++++ .../jns/scan/engine/ScanExecutionContext.java | 16 ++ .../com/it_jaros/jns/scan/engine/ScanHostTask.java | 123 ++++++++++ .../com/it_jaros/jns/scan/engine/ScanPortTask.java | 135 +++++++++++ .../it_jaros/jns/scan/service/ServiceDetector.java | 47 ++++ .../com/it_jaros/jns/scan/service/ServiceType.java | 5 + src/main/java/com/it_jaros/jscanner/App.java | 100 -------- .../java/com/it_jaros/jscanner/CliDefaults.java | 18 -- src/main/java/com/it_jaros/jscanner/CliParser.java | 143 ------------ .../java/com/it_jaros/jscanner/CliPrinter.java | 258 --------------------- .../java/com/it_jaros/jscanner/HelpRequested.java | 7 - .../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 | 108 --------- .../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 - 58 files changed, 1652 insertions(+), 1652 deletions(-) create mode 100644 src/main/java/com/it_jaros/jns/App.java create mode 100644 src/main/java/com/it_jaros/jns/CliDefaults.java create mode 100644 src/main/java/com/it_jaros/jns/CliParser.java create mode 100644 src/main/java/com/it_jaros/jns/CliPrinter.java create mode 100644 src/main/java/com/it_jaros/jns/HelpRequested.java create mode 100644 src/main/java/com/it_jaros/jns/scan/Counter.java create mode 100644 src/main/java/com/it_jaros/jns/scan/ExceptionInfo.java create mode 100644 src/main/java/com/it_jaros/jns/scan/Scan.java create mode 100644 src/main/java/com/it_jaros/jns/scan/ScanException.java create mode 100644 src/main/java/com/it_jaros/jns/scan/ScanOptions.java create mode 100644 src/main/java/com/it_jaros/jns/scan/Scanner.java create mode 100644 src/main/java/com/it_jaros/jns/scan/domain/PortList.java create mode 100644 src/main/java/com/it_jaros/jns/scan/domain/PortRange.java create mode 100644 src/main/java/com/it_jaros/jns/scan/domain/PortResult.java create mode 100644 src/main/java/com/it_jaros/jns/scan/domain/PortState.java create mode 100644 src/main/java/com/it_jaros/jns/scan/domain/ScanFailure.java create mode 100644 src/main/java/com/it_jaros/jns/scan/domain/ScanResult.java create mode 100644 src/main/java/com/it_jaros/jns/scan/engine/CancelledToken.java create mode 100644 src/main/java/com/it_jaros/jns/scan/engine/PollState.java create mode 100644 src/main/java/com/it_jaros/jns/scan/engine/PortRangeIterator.java create mode 100644 src/main/java/com/it_jaros/jns/scan/engine/PortResultAccumulator.java create mode 100644 src/main/java/com/it_jaros/jns/scan/engine/PortScanRateLimiter.java create mode 100644 src/main/java/com/it_jaros/jns/scan/engine/ProducerState.java create mode 100644 src/main/java/com/it_jaros/jns/scan/engine/ProducerThread.java create mode 100644 src/main/java/com/it_jaros/jns/scan/engine/ScanExecutionContext.java create mode 100644 src/main/java/com/it_jaros/jns/scan/engine/ScanHostTask.java create mode 100644 src/main/java/com/it_jaros/jns/scan/engine/ScanPortTask.java create mode 100644 src/main/java/com/it_jaros/jns/scan/service/ServiceDetector.java create mode 100644 src/main/java/com/it_jaros/jns/scan/service/ServiceType.java delete mode 100644 src/main/java/com/it_jaros/jscanner/App.java delete mode 100644 src/main/java/com/it_jaros/jscanner/CliDefaults.java delete mode 100644 src/main/java/com/it_jaros/jscanner/CliParser.java delete mode 100644 src/main/java/com/it_jaros/jscanner/CliPrinter.java delete mode 100644 src/main/java/com/it_jaros/jscanner/HelpRequested.java delete mode 100644 src/main/java/com/it_jaros/jscanner/scan/Counter.java delete mode 100644 src/main/java/com/it_jaros/jscanner/scan/ExceptionInfo.java delete mode 100644 src/main/java/com/it_jaros/jscanner/scan/Scan.java delete mode 100644 src/main/java/com/it_jaros/jscanner/scan/ScanException.java delete mode 100644 src/main/java/com/it_jaros/jscanner/scan/ScanOptions.java delete mode 100644 src/main/java/com/it_jaros/jscanner/scan/Scanner.java delete mode 100644 src/main/java/com/it_jaros/jscanner/scan/domain/PortList.java delete mode 100644 src/main/java/com/it_jaros/jscanner/scan/domain/PortRange.java delete mode 100644 src/main/java/com/it_jaros/jscanner/scan/domain/PortResult.java delete mode 100644 src/main/java/com/it_jaros/jscanner/scan/domain/PortState.java delete mode 100644 src/main/java/com/it_jaros/jscanner/scan/domain/ScanFailure.java delete mode 100644 src/main/java/com/it_jaros/jscanner/scan/domain/ScanResult.java delete mode 100644 src/main/java/com/it_jaros/jscanner/scan/engine/CancelledToken.java delete mode 100644 src/main/java/com/it_jaros/jscanner/scan/engine/PollState.java delete mode 100644 src/main/java/com/it_jaros/jscanner/scan/engine/PortRangeIterator.java delete mode 100644 src/main/java/com/it_jaros/jscanner/scan/engine/PortResultAccumulator.java delete mode 100644 src/main/java/com/it_jaros/jscanner/scan/engine/PortScanRateLimiter.java delete mode 100644 src/main/java/com/it_jaros/jscanner/scan/engine/ProducerState.java delete mode 100644 src/main/java/com/it_jaros/jscanner/scan/engine/ProducerThread.java delete mode 100644 src/main/java/com/it_jaros/jscanner/scan/engine/ScanExecutionContext.java delete mode 100644 src/main/java/com/it_jaros/jscanner/scan/engine/ScanHostTask.java delete mode 100644 src/main/java/com/it_jaros/jscanner/scan/engine/ScanPortTask.java delete mode 100644 src/main/java/com/it_jaros/jscanner/scan/service/ServiceDetector.java delete mode 100644 src/main/java/com/it_jaros/jscanner/scan/service/ServiceType.java (limited to 'src') diff --git a/src/main/java/com/it_jaros/jns/App.java b/src/main/java/com/it_jaros/jns/App.java new file mode 100644 index 0000000..57a5cf1 --- /dev/null +++ b/src/main/java/com/it_jaros/jns/App.java @@ -0,0 +1,100 @@ +package com.it_jaros.jns; + +import com.it_jaros.jns.scan.Scan; +import com.it_jaros.jns.scan.ScanOptions; +import com.it_jaros.jns.scan.Scanner; + +import java.io.IOException; +import java.util.concurrent.CountDownLatch; + +public class App { + + private static Thread shutdownHook; + private static final CountDownLatch shutdownLatch = new CountDownLatch(1); + private static int exitCode = 0; + + public static void main(String[] args) { + try { + run(args); + } catch (HelpRequested e) { + // Do nothing + } catch (Exception e) { + System.err.println(e.getMessage()); + exitCode = 1; + } finally { + shutdownLatch.countDown(); + removeShutdownHook(); + } + + System.exit(exitCode); + } + + private static void run(String[] args) throws Exception { + ScanOptions options = parseOptions(args); + try (Scanner scanner = new Scanner(options)) { + runScan(scanner, options); + } + } + + private static void runScan(Scanner scanner, ScanOptions options) throws IOException { + addShutdownHook(scanner); + Scan scan = Scan.create(options); + CliPrinter progressBar = new CliPrinter(scan, options.quiet()); + progressBar.start(); + scanner.runScan(scan, result -> progressBar.printResult(result, options.showFilteredPorts())); + progressBar.stop(); + } + + private static ScanOptions parseOptions(String[] args) { + CliParser cliParser = new CliParser(); + try { + return cliParser.parseArgs(args); + } catch (IllegalArgumentException e) { + cliParser.printUsageHelp(); + throw e; + } + } + + /** + * Notice user that the signal was received + * Start shutdown of scanner + * + * @param scanner + */ + private static void addShutdownHook(Scanner scanner) { + shutdownHook = new Thread(() -> { + System.err.printf("%nCtrl-C received. Shutting down...%n"); + shutdown(scanner); + }); + Runtime.getRuntime().addShutdownHook(shutdownHook); + } + + /** + * Try to gracefully shutdown scanner + * and wait + * + * @param scanner + */ + private static void shutdown(Scanner scanner) { + try { + scanner.cancel(); + shutdownLatch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (Exception e) { + // Time ran out + } + } + + private static void removeShutdownHook() { + if (shutdownHook == null) { + return; + } + + try { + Runtime.getRuntime().removeShutdownHook(shutdownHook); + } catch (IllegalStateException ignored) { + // JVM is already shutting down + } + } +} diff --git a/src/main/java/com/it_jaros/jns/CliDefaults.java b/src/main/java/com/it_jaros/jns/CliDefaults.java new file mode 100644 index 0000000..e000fc9 --- /dev/null +++ b/src/main/java/com/it_jaros/jns/CliDefaults.java @@ -0,0 +1,18 @@ +package com.it_jaros.jns; + +public class CliDefaults { + private CliDefaults() { + /* This utility class should not be instantiated */ + } + + public static final String PORTS = "1-1024"; + public static final boolean BANNER_RECOGNITION = false; + public static final boolean DISABLE_HOST_CHECK = false; + public static final boolean QUIET = false; + public static final boolean SHOW_FILTERED_PORTS = false; + public static final int DELAY_IN_MILLIS = 0; + public static final int MAX_HOSTS_LIMIT = 100; + public static final int MAX_WORKERS_PER_HOST = Integer.MAX_VALUE; + public static final int SOCKET_LIMIT = 2048; + public static final int TIMEOUT_IN_MILLIS = 3000; +} diff --git a/src/main/java/com/it_jaros/jns/CliParser.java b/src/main/java/com/it_jaros/jns/CliParser.java new file mode 100644 index 0000000..11ddef1 --- /dev/null +++ b/src/main/java/com/it_jaros/jns/CliParser.java @@ -0,0 +1,143 @@ +package com.it_jaros.jns; + +import com.it_jaros.jns.scan.ScanOptions; + +import java.util.HashSet; +import java.util.Set; + +public class CliParser { + + public ScanOptions parseArgs(String[] args) { + if (args.length < 1 || args[0].trim().isEmpty()) { + throw new IllegalArgumentException("No argument given"); + } + + Set hostsArgv = new HashSet<>(); + String hostsFile = null; + String ports = CliDefaults.PORTS; + boolean bannerRecognition = CliDefaults.BANNER_RECOGNITION; + boolean disableOnlineCheck = CliDefaults.DISABLE_HOST_CHECK; + boolean quiet = CliDefaults.QUIET; + boolean showFilteredPorts = CliDefaults.SHOW_FILTERED_PORTS; + int delayInMillis = CliDefaults.DELAY_IN_MILLIS; + int hostsLimit = CliDefaults.MAX_HOSTS_LIMIT; + int workersPerHostLimit = CliDefaults.MAX_WORKERS_PER_HOST; + int socketLimit = CliDefaults.SOCKET_LIMIT; + int timeoutInMillis = CliDefaults.TIMEOUT_IN_MILLIS; + + for (int i = 0; i < args.length; i++) { + final String currentArg = args[i]; + if (!currentArg.startsWith("-")) { + hostsArgv.add(currentArg); + continue; + } + + switch (currentArg) { + case "--bannerRecognition", "-b": + bannerRecognition = true; + break; + case "--delay", "-d": + delayInMillis = parseIntOption(args, ++i, currentArg); + break; + case "--disableOnlineCheck", "-do": + disableOnlineCheck = true; + break; + case "--help", "-h": + printUsageHelp(); + throw new HelpRequested(); + case "--input", "-i": + hostsFile = getArgument(args, ++i, currentArg); + break; + case "--hostsLimit", "-hl": + hostsLimit = parseIntOption(args, ++i, currentArg); + break; + case "--workersPerHostLimit", "-whl": + workersPerHostLimit = parseIntOption(args, ++i, currentArg); + break; + case "--ports", "-p": + ports = getArgument(args, ++i, currentArg); + break; + case "--quiet", "-q": + quiet = true; + break; + case "--showFilteredPorts", "-sf": + showFilteredPorts = true; + break; + case "--socketLimit", "-sl": + socketLimit = parseIntOption(args, ++i, currentArg); + break; + case "--timeout", "-t": + timeoutInMillis = parseIntOption(args, ++i, currentArg); + break; + default: + throw new IllegalArgumentException("No such param " + currentArg); + } + } + + if (hostsArgv.isEmpty() && hostsFile == null) { + throw new IllegalArgumentException("No input provided. Check with --help for available options."); + } + + return new ScanOptions( + hostsArgv.stream().toList(), + hostsFile, + ports, + bannerRecognition, + disableOnlineCheck, + quiet, + showFilteredPorts, + delayInMillis, + hostsLimit, + workersPerHostLimit, + socketLimit, + timeoutInMillis + ); + } + + private String getArgument(String[] args, int index, String option) { + if (index >= args.length) { + throw new IllegalArgumentException(String.format("Missing argument for option %s", option)); + } + return args[index]; + } + + private int parseIntOption(String[] args, int index, String option) { + String value = getArgument(args, index, option); + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + throw new IllegalArgumentException(String.format("Invalid argument %s for option %s", value, option)); + } + } + + public void printUsageHelp() { + StringBuilder usage = new StringBuilder(); + usage.append(""" + Usage: + java -jar jscanner.jar [OPTIONS] [,...] + + Options: + """); + usage.append(""" + --bannerRecognition, -b:\t\tEnable banner recognition to find out which service is running behind port (Default: %b) + --delay, -d:\t\t\tWaiting period in millis for connection attempts for a host (Default: %d) + --disableOnlineCheck, -do:\t\tDon't check if Host is reachable (Default: %b) + --input, -i:\t\t\tRead file to get host targets to scan. If set to "-" than Stdin is read (Default: null) + --hostsLimit, -hl:\t\t\tMax number of hosts that are scanned in parallel (Default: %d) + --workersPerHostLimit, -whl:\tMax workers per host. (Default: %d) + --ports, -p:\t\t\tDefine ports to be scanned (Default: %s) + --quiet, -q:\t\t\tDo not output progress bar (Default: %b) + --showFilteredPorts, -sf:\t\tShow also filtered ports (Default: %b) + --socketLimit, -sl:\t\t\tSocket limit in total. You can not scan more ports than allowed here (Default: %d) + --timeout, -t:\t\t\tTimeout in millis per connection attempt, 0 means infinite (Default: %d) + """.formatted(CliDefaults.BANNER_RECOGNITION, CliDefaults.DELAY_IN_MILLIS, CliDefaults.DISABLE_HOST_CHECK, CliDefaults.MAX_HOSTS_LIMIT, CliDefaults.MAX_WORKERS_PER_HOST, CliDefaults.PORTS, CliDefaults.QUIET, CliDefaults.SHOW_FILTERED_PORTS, CliDefaults.SOCKET_LIMIT, CliDefaults.TIMEOUT_IN_MILLIS) + ); + usage.append(""" + + Examples: + java -jar jscanner.jar localhost + java -jar jscanner.jar fd00::1 + """); + System.err.println(usage); + } +} diff --git a/src/main/java/com/it_jaros/jns/CliPrinter.java b/src/main/java/com/it_jaros/jns/CliPrinter.java new file mode 100644 index 0000000..c8969d9 --- /dev/null +++ b/src/main/java/com/it_jaros/jns/CliPrinter.java @@ -0,0 +1,258 @@ +package com.it_jaros.jns; + +import com.it_jaros.jns.scan.Scan; +import com.it_jaros.jns.scan.domain.ScanFailure; +import com.it_jaros.jns.scan.domain.ScanResult; +import com.it_jaros.jns.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/jns/HelpRequested.java b/src/main/java/com/it_jaros/jns/HelpRequested.java new file mode 100644 index 0000000..0208391 --- /dev/null +++ b/src/main/java/com/it_jaros/jns/HelpRequested.java @@ -0,0 +1,7 @@ +package com.it_jaros.jns; + +public class HelpRequested extends RuntimeException { + public HelpRequested() { + super(); + } +} diff --git a/src/main/java/com/it_jaros/jns/scan/Counter.java b/src/main/java/com/it_jaros/jns/scan/Counter.java new file mode 100644 index 0000000..be9cfaa --- /dev/null +++ b/src/main/java/com/it_jaros/jns/scan/Counter.java @@ -0,0 +1,31 @@ +package com.it_jaros.jns.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/jns/scan/ExceptionInfo.java b/src/main/java/com/it_jaros/jns/scan/ExceptionInfo.java new file mode 100644 index 0000000..b732c9c --- /dev/null +++ b/src/main/java/com/it_jaros/jns/scan/ExceptionInfo.java @@ -0,0 +1,13 @@ +package com.it_jaros.jns.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/jns/scan/Scan.java b/src/main/java/com/it_jaros/jns/scan/Scan.java new file mode 100644 index 0000000..1c6f658 --- /dev/null +++ b/src/main/java/com/it_jaros/jns/scan/Scan.java @@ -0,0 +1,173 @@ +package com.it_jaros.jns.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/jns/scan/ScanException.java b/src/main/java/com/it_jaros/jns/scan/ScanException.java new file mode 100644 index 0000000..6d48a57 --- /dev/null +++ b/src/main/java/com/it_jaros/jns/scan/ScanException.java @@ -0,0 +1,11 @@ +package com.it_jaros.jns.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/jns/scan/ScanOptions.java b/src/main/java/com/it_jaros/jns/scan/ScanOptions.java new file mode 100644 index 0000000..b6aeae2 --- /dev/null +++ b/src/main/java/com/it_jaros/jns/scan/ScanOptions.java @@ -0,0 +1,18 @@ +package com.it_jaros.jns.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/jns/scan/Scanner.java b/src/main/java/com/it_jaros/jns/scan/Scanner.java new file mode 100644 index 0000000..4dad6a8 --- /dev/null +++ b/src/main/java/com/it_jaros/jns/scan/Scanner.java @@ -0,0 +1,108 @@ +package com.it_jaros.jns.scan; + +import com.it_jaros.jns.scan.domain.ScanResult; +import com.it_jaros.jns.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"); + } + + long delayInNanos = TimeUnit.MILLISECONDS.toNanos(Math.max(0, scanOptions.delayInMillis())); + ScanExecutionContext context = new ScanExecutionContext( + scanOptions, + executor, + socketLimit, + cancelledToken, + scan, + new PortScanRateLimiter(cancelledToken, delayInNanos) + ); + + scan.start(); + 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.signal(); + executor.shutdown(); + } + + public void cancelNow() { + this.cancelledToken.signal(); + executor.shutdownNow(); + } + + @Override + public void close() throws Exception { + cancel(); + } + +} diff --git a/src/main/java/com/it_jaros/jns/scan/domain/PortList.java b/src/main/java/com/it_jaros/jns/scan/domain/PortList.java new file mode 100644 index 0000000..69a6bfc --- /dev/null +++ b/src/main/java/com/it_jaros/jns/scan/domain/PortList.java @@ -0,0 +1,27 @@ +package com.it_jaros.jns.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/jns/scan/domain/PortRange.java b/src/main/java/com/it_jaros/jns/scan/domain/PortRange.java new file mode 100644 index 0000000..2f8bab8 --- /dev/null +++ b/src/main/java/com/it_jaros/jns/scan/domain/PortRange.java @@ -0,0 +1,80 @@ +package com.it_jaros.jns.scan.domain; + +import com.it_jaros.jns.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/jns/scan/domain/PortResult.java b/src/main/java/com/it_jaros/jns/scan/domain/PortResult.java new file mode 100644 index 0000000..7f3bf1d --- /dev/null +++ b/src/main/java/com/it_jaros/jns/scan/domain/PortResult.java @@ -0,0 +1,48 @@ +package com.it_jaros.jns.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/jns/scan/domain/PortState.java b/src/main/java/com/it_jaros/jns/scan/domain/PortState.java new file mode 100644 index 0000000..0d15ef4 --- /dev/null +++ b/src/main/java/com/it_jaros/jns/scan/domain/PortState.java @@ -0,0 +1,8 @@ +package com.it_jaros.jns.scan.domain; + +public enum PortState { + CLOSED, + FILTERED, + OPEN, + UNKNOWN +} diff --git a/src/main/java/com/it_jaros/jns/scan/domain/ScanFailure.java b/src/main/java/com/it_jaros/jns/scan/domain/ScanFailure.java new file mode 100644 index 0000000..8a16a5d --- /dev/null +++ b/src/main/java/com/it_jaros/jns/scan/domain/ScanFailure.java @@ -0,0 +1,9 @@ +package com.it_jaros.jns.scan.domain; + +import com.it_jaros.jns.scan.ExceptionInfo; + +public record ScanFailure( + Integer port, + ExceptionInfo exception +) { +} diff --git a/src/main/java/com/it_jaros/jns/scan/domain/ScanResult.java b/src/main/java/com/it_jaros/jns/scan/domain/ScanResult.java new file mode 100644 index 0000000..ae3602d --- /dev/null +++ b/src/main/java/com/it_jaros/jns/scan/domain/ScanResult.java @@ -0,0 +1,26 @@ +package com.it_jaros.jns.scan.domain; + +import com.it_jaros.jns.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/jns/scan/engine/CancelledToken.java b/src/main/java/com/it_jaros/jns/scan/engine/CancelledToken.java new file mode 100644 index 0000000..0cde11e --- /dev/null +++ b/src/main/java/com/it_jaros/jns/scan/engine/CancelledToken.java @@ -0,0 +1,15 @@ +package com.it_jaros.jns.scan.engine; + +public class CancelledToken { + private volatile boolean cancelled = false; + + public void signal() { + if (!cancelled) { + this.cancelled = true; + } + } + + public boolean isCancelled() { + return cancelled; + } +} diff --git a/src/main/java/com/it_jaros/jns/scan/engine/PollState.java b/src/main/java/com/it_jaros/jns/scan/engine/PollState.java new file mode 100644 index 0000000..fdeebd7 --- /dev/null +++ b/src/main/java/com/it_jaros/jns/scan/engine/PollState.java @@ -0,0 +1,13 @@ +package com.it_jaros.jns.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/jns/scan/engine/PortRangeIterator.java b/src/main/java/com/it_jaros/jns/scan/engine/PortRangeIterator.java new file mode 100644 index 0000000..82c3971 --- /dev/null +++ b/src/main/java/com/it_jaros/jns/scan/engine/PortRangeIterator.java @@ -0,0 +1,38 @@ +package com.it_jaros.jns.scan.engine; + +import java.util.BitSet; +import java.util.Iterator; +import java.util.NoSuchElementException; + +import static com.it_jaros.jns.scan.domain.PortRange.MAX_PORT; +import static com.it_jaros.jns.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/jns/scan/engine/PortResultAccumulator.java b/src/main/java/com/it_jaros/jns/scan/engine/PortResultAccumulator.java new file mode 100644 index 0000000..bcb6432 --- /dev/null +++ b/src/main/java/com/it_jaros/jns/scan/engine/PortResultAccumulator.java @@ -0,0 +1,49 @@ +package com.it_jaros.jns.scan.engine; + +import com.it_jaros.jns.scan.ExceptionInfo; +import com.it_jaros.jns.scan.domain.*; +import com.it_jaros.jns.scan.service.ServiceDetector; +import com.it_jaros.jns.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/jns/scan/engine/PortScanRateLimiter.java b/src/main/java/com/it_jaros/jns/scan/engine/PortScanRateLimiter.java new file mode 100644 index 0000000..2283f0e --- /dev/null +++ b/src/main/java/com/it_jaros/jns/scan/engine/PortScanRateLimiter.java @@ -0,0 +1,37 @@ +package com.it_jaros.jns.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/jns/scan/engine/ProducerState.java b/src/main/java/com/it_jaros/jns/scan/engine/ProducerState.java new file mode 100644 index 0000000..598fcbd --- /dev/null +++ b/src/main/java/com/it_jaros/jns/scan/engine/ProducerState.java @@ -0,0 +1,13 @@ +package com.it_jaros.jns.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/jns/scan/engine/ProducerThread.java b/src/main/java/com/it_jaros/jns/scan/engine/ProducerThread.java new file mode 100644 index 0000000..0cae158 --- /dev/null +++ b/src/main/java/com/it_jaros/jns/scan/engine/ProducerThread.java @@ -0,0 +1,83 @@ +package com.it_jaros.jns.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/jns/scan/engine/ScanExecutionContext.java b/src/main/java/com/it_jaros/jns/scan/engine/ScanExecutionContext.java new file mode 100644 index 0000000..42b42f1 --- /dev/null +++ b/src/main/java/com/it_jaros/jns/scan/engine/ScanExecutionContext.java @@ -0,0 +1,16 @@ +package com.it_jaros.jns.scan.engine; + +import com.it_jaros.jns.scan.Scan; +import com.it_jaros.jns.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/jns/scan/engine/ScanHostTask.java b/src/main/java/com/it_jaros/jns/scan/engine/ScanHostTask.java new file mode 100644 index 0000000..0fbfef0 --- /dev/null +++ b/src/main/java/com/it_jaros/jns/scan/engine/ScanHostTask.java @@ -0,0 +1,123 @@ +package com.it_jaros.jns.scan.engine; + +import com.it_jaros.jns.scan.Scan; +import com.it_jaros.jns.scan.domain.PortRange; +import com.it_jaros.jns.scan.domain.PortResult; +import com.it_jaros.jns.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/jns/scan/engine/ScanPortTask.java b/src/main/java/com/it_jaros/jns/scan/engine/ScanPortTask.java new file mode 100644 index 0000000..c5591e7 --- /dev/null +++ b/src/main/java/com/it_jaros/jns/scan/engine/ScanPortTask.java @@ -0,0 +1,135 @@ +package com.it_jaros.jns.scan.engine; + +import com.it_jaros.jns.scan.Scan; +import com.it_jaros.jns.scan.domain.PortResult; +import com.it_jaros.jns.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/jns/scan/service/ServiceDetector.java b/src/main/java/com/it_jaros/jns/scan/service/ServiceDetector.java new file mode 100644 index 0000000..af8c04b --- /dev/null +++ b/src/main/java/com/it_jaros/jns/scan/service/ServiceDetector.java @@ -0,0 +1,47 @@ +package com.it_jaros.jns.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/jns/scan/service/ServiceType.java b/src/main/java/com/it_jaros/jns/scan/service/ServiceType.java new file mode 100644 index 0000000..dbe219a --- /dev/null +++ b/src/main/java/com/it_jaros/jns/scan/service/ServiceType.java @@ -0,0 +1,5 @@ +package com.it_jaros.jns.scan.service; + +public enum ServiceType { + HTTP, FTP, SMTP, IMAP, POP3, UNKNOWN, DNS, SSH +} diff --git a/src/main/java/com/it_jaros/jscanner/App.java b/src/main/java/com/it_jaros/jscanner/App.java deleted file mode 100644 index fd38ae0..0000000 --- a/src/main/java/com/it_jaros/jscanner/App.java +++ /dev/null @@ -1,100 +0,0 @@ -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; - -public class App { - - private static Thread shutdownHook; - private static final CountDownLatch shutdownLatch = new CountDownLatch(1); - private static int exitCode = 0; - - public static void main(String[] args) { - try { - run(args); - } catch (HelpRequested e) { - // Do nothing - } catch (Exception e) { - System.err.println(e.getMessage()); - exitCode = 1; - } finally { - shutdownLatch.countDown(); - removeShutdownHook(); - } - - System.exit(exitCode); - } - - private static void run(String[] args) throws Exception { - ScanOptions options = parseOptions(args); - try (Scanner scanner = new Scanner(options)) { - runScan(scanner, options); - } - } - - private static void runScan(Scanner scanner, ScanOptions options) throws IOException { - addShutdownHook(scanner); - Scan scan = Scan.create(options); - CliPrinter progressBar = new CliPrinter(scan, options.quiet()); - progressBar.start(); - scanner.runScan(scan, result -> progressBar.printResult(result, options.showFilteredPorts())); - progressBar.stop(); - } - - private static ScanOptions parseOptions(String[] args) { - CliParser cliParser = new CliParser(); - try { - return cliParser.parseArgs(args); - } catch (IllegalArgumentException e) { - cliParser.printUsageHelp(); - throw e; - } - } - - /** - * Notice user that the signal was received - * Start shutdown of scanner - * - * @param scanner - */ - private static void addShutdownHook(Scanner scanner) { - shutdownHook = new Thread(() -> { - System.err.printf("%nCtrl-C received. Shutting down...%n"); - shutdown(scanner); - }); - Runtime.getRuntime().addShutdownHook(shutdownHook); - } - - /** - * Try to gracefully shutdown scanner - * and wait - * - * @param scanner - */ - private static void shutdown(Scanner scanner) { - try { - scanner.cancel(); - shutdownLatch.await(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } catch (Exception e) { - // Time ran out - } - } - - private static void removeShutdownHook() { - if (shutdownHook == null) { - return; - } - - try { - Runtime.getRuntime().removeShutdownHook(shutdownHook); - } catch (IllegalStateException ignored) { - // JVM is already shutting down - } - } -} diff --git a/src/main/java/com/it_jaros/jscanner/CliDefaults.java b/src/main/java/com/it_jaros/jscanner/CliDefaults.java deleted file mode 100644 index 655a9ed..0000000 --- a/src/main/java/com/it_jaros/jscanner/CliDefaults.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.it_jaros.jscanner; - -public class CliDefaults { - private CliDefaults() { - /* This utility class should not be instantiated */ - } - - public static final String PORTS = "1-1024"; - public static final boolean BANNER_RECOGNITION = false; - public static final boolean DISABLE_HOST_CHECK = false; - public static final boolean QUIET = false; - public static final boolean SHOW_FILTERED_PORTS = false; - public static final int DELAY_IN_MILLIS = 0; - public static final int MAX_HOSTS_LIMIT = 100; - public static final int MAX_WORKERS_PER_HOST = Integer.MAX_VALUE; - public static final int SOCKET_LIMIT = 2048; - public static final int TIMEOUT_IN_MILLIS = 3000; -} diff --git a/src/main/java/com/it_jaros/jscanner/CliParser.java b/src/main/java/com/it_jaros/jscanner/CliParser.java deleted file mode 100644 index 7e27064..0000000 --- a/src/main/java/com/it_jaros/jscanner/CliParser.java +++ /dev/null @@ -1,143 +0,0 @@ -package com.it_jaros.jscanner; - -import com.it_jaros.jscanner.scan.ScanOptions; - -import java.util.HashSet; -import java.util.Set; - -public class CliParser { - - public ScanOptions parseArgs(String[] args) { - if (args.length < 1 || args[0].trim().isEmpty()) { - throw new IllegalArgumentException("No argument given"); - } - - Set hostsArgv = new HashSet<>(); - String hostsFile = null; - String ports = CliDefaults.PORTS; - boolean bannerRecognition = CliDefaults.BANNER_RECOGNITION; - boolean disableOnlineCheck = CliDefaults.DISABLE_HOST_CHECK; - boolean quiet = CliDefaults.QUIET; - boolean showFilteredPorts = CliDefaults.SHOW_FILTERED_PORTS; - int delayInMillis = CliDefaults.DELAY_IN_MILLIS; - int hostsLimit = CliDefaults.MAX_HOSTS_LIMIT; - int workersPerHostLimit = CliDefaults.MAX_WORKERS_PER_HOST; - int socketLimit = CliDefaults.SOCKET_LIMIT; - int timeoutInMillis = CliDefaults.TIMEOUT_IN_MILLIS; - - for (int i = 0; i < args.length; i++) { - final String currentArg = args[i]; - if (!currentArg.startsWith("-")) { - hostsArgv.add(currentArg); - continue; - } - - switch (currentArg) { - case "--bannerRecognition", "-b": - bannerRecognition = true; - break; - case "--delay", "-d": - delayInMillis = parseIntOption(args, ++i, currentArg); - break; - case "--disableOnlineCheck", "-do": - disableOnlineCheck = true; - break; - case "--help", "-h": - printUsageHelp(); - throw new HelpRequested(); - case "--input", "-i": - hostsFile = getArgument(args, ++i, currentArg); - break; - case "--hostsLimit", "-hl": - hostsLimit = parseIntOption(args, ++i, currentArg); - break; - case "--workersPerHostLimit", "-whl": - workersPerHostLimit = parseIntOption(args, ++i, currentArg); - break; - case "--ports", "-p": - ports = getArgument(args, ++i, currentArg); - break; - case "--quiet", "-q": - quiet = true; - break; - case "--showFilteredPorts", "-sf": - showFilteredPorts = true; - break; - case "--socketLimit", "-sl": - socketLimit = parseIntOption(args, ++i, currentArg); - break; - case "--timeout", "-t": - timeoutInMillis = parseIntOption(args, ++i, currentArg); - break; - default: - throw new IllegalArgumentException("No such param " + currentArg); - } - } - - if (hostsArgv.isEmpty() && hostsFile == null) { - throw new IllegalArgumentException("No input provided. Check with --help for available options."); - } - - return new ScanOptions( - hostsArgv.stream().toList(), - hostsFile, - ports, - bannerRecognition, - disableOnlineCheck, - quiet, - showFilteredPorts, - delayInMillis, - hostsLimit, - workersPerHostLimit, - socketLimit, - timeoutInMillis - ); - } - - private String getArgument(String[] args, int index, String option) { - if (index >= args.length) { - throw new IllegalArgumentException(String.format("Missing argument for option %s", option)); - } - return args[index]; - } - - private int parseIntOption(String[] args, int index, String option) { - String value = getArgument(args, index, option); - try { - return Integer.parseInt(value); - } catch (NumberFormatException e) { - throw new IllegalArgumentException(String.format("Invalid argument %s for option %s", value, option)); - } - } - - public void printUsageHelp() { - StringBuilder usage = new StringBuilder(); - usage.append(""" - Usage: - java -jar jscanner.jar [OPTIONS] [,...] - - Options: - """); - usage.append(""" - --bannerRecognition, -b:\t\tEnable banner recognition to find out which service is running behind port (Default: %b) - --delay, -d:\t\t\tWaiting period in millis for connection attempts for a host (Default: %d) - --disableOnlineCheck, -do:\t\tDon't check if Host is reachable (Default: %b) - --input, -i:\t\t\tRead file to get host targets to scan. If set to "-" than Stdin is read (Default: null) - --hostsLimit, -hl:\t\t\tMax number of hosts that are scanned in parallel (Default: %d) - --workersPerHostLimit, -whl:\tMax workers per host. (Default: %d) - --ports, -p:\t\t\tDefine ports to be scanned (Default: %s) - --quiet, -q:\t\t\tDo not output progress bar (Default: %b) - --showFilteredPorts, -sf:\t\tShow also filtered ports (Default: %b) - --socketLimit, -sl:\t\t\tSocket limit in total. You can not scan more ports than allowed here (Default: %d) - --timeout, -t:\t\t\tTimeout in millis per connection attempt, 0 means infinite (Default: %d) - """.formatted(CliDefaults.BANNER_RECOGNITION, CliDefaults.DELAY_IN_MILLIS, CliDefaults.DISABLE_HOST_CHECK, CliDefaults.MAX_HOSTS_LIMIT, CliDefaults.MAX_WORKERS_PER_HOST, CliDefaults.PORTS, CliDefaults.QUIET, CliDefaults.SHOW_FILTERED_PORTS, CliDefaults.SOCKET_LIMIT, CliDefaults.TIMEOUT_IN_MILLIS) - ); - usage.append(""" - - Examples: - java -jar jscanner.jar localhost - java -jar jscanner.jar fd00::1 - """); - System.err.println(usage); - } -} diff --git a/src/main/java/com/it_jaros/jscanner/CliPrinter.java b/src/main/java/com/it_jaros/jscanner/CliPrinter.java deleted file mode 100644 index 709c3dd..0000000 --- a/src/main/java/com/it_jaros/jscanner/CliPrinter.java +++ /dev/null @@ -1,258 +0,0 @@ -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/HelpRequested.java b/src/main/java/com/it_jaros/jscanner/HelpRequested.java deleted file mode 100644 index ec97a2a..0000000 --- a/src/main/java/com/it_jaros/jscanner/HelpRequested.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.it_jaros.jscanner; - -public class HelpRequested extends RuntimeException { - public HelpRequested() { - super(); - } -} diff --git a/src/main/java/com/it_jaros/jscanner/scan/Counter.java b/src/main/java/com/it_jaros/jscanner/scan/Counter.java deleted file mode 100644 index 0e1d5c9..0000000 --- a/src/main/java/com/it_jaros/jscanner/scan/Counter.java +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index f62caf1..0000000 --- a/src/main/java/com/it_jaros/jscanner/scan/ExceptionInfo.java +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index 0820abc..0000000 --- a/src/main/java/com/it_jaros/jscanner/scan/Scan.java +++ /dev/null @@ -1,173 +0,0 @@ -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 deleted file mode 100644 index d667346..0000000 --- a/src/main/java/com/it_jaros/jscanner/scan/ScanException.java +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index ce0d562..0000000 --- a/src/main/java/com/it_jaros/jscanner/scan/ScanOptions.java +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 4c47aef..0000000 --- a/src/main/java/com/it_jaros/jscanner/scan/Scanner.java +++ /dev/null @@ -1,108 +0,0 @@ -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"); - } - - long delayInNanos = TimeUnit.MILLISECONDS.toNanos(Math.max(0, scanOptions.delayInMillis())); - ScanExecutionContext context = new ScanExecutionContext( - scanOptions, - executor, - socketLimit, - cancelledToken, - scan, - new PortScanRateLimiter(cancelledToken, delayInNanos) - ); - - scan.start(); - 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.signal(); - executor.shutdown(); - } - - public void cancelNow() { - this.cancelledToken.signal(); - 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 deleted file mode 100644 index f145700..0000000 --- a/src/main/java/com/it_jaros/jscanner/scan/domain/PortList.java +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index 4434763..0000000 --- a/src/main/java/com/it_jaros/jscanner/scan/domain/PortRange.java +++ /dev/null @@ -1,80 +0,0 @@ -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 deleted file mode 100644 index 9e70cc5..0000000 --- a/src/main/java/com/it_jaros/jscanner/scan/domain/PortResult.java +++ /dev/null @@ -1,48 +0,0 @@ -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 deleted file mode 100644 index 6db4b2e..0000000 --- a/src/main/java/com/it_jaros/jscanner/scan/domain/PortState.java +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index db33465..0000000 --- a/src/main/java/com/it_jaros/jscanner/scan/domain/ScanFailure.java +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index e18e75d..0000000 --- a/src/main/java/com/it_jaros/jscanner/scan/domain/ScanResult.java +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 57993e5..0000000 --- a/src/main/java/com/it_jaros/jscanner/scan/engine/CancelledToken.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.it_jaros.jscanner.scan.engine; - -public class CancelledToken { - private volatile boolean cancelled = false; - - public void signal() { - 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 deleted file mode 100644 index 54ec767..0000000 --- a/src/main/java/com/it_jaros/jscanner/scan/engine/PollState.java +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index 0673f0f..0000000 --- a/src/main/java/com/it_jaros/jscanner/scan/engine/PortRangeIterator.java +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index 0cc26b0..0000000 --- a/src/main/java/com/it_jaros/jscanner/scan/engine/PortResultAccumulator.java +++ /dev/null @@ -1,49 +0,0 @@ -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 deleted file mode 100644 index 92b3bcb..0000000 --- a/src/main/java/com/it_jaros/jscanner/scan/engine/PortScanRateLimiter.java +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index c49ea3e..0000000 --- a/src/main/java/com/it_jaros/jscanner/scan/engine/ProducerState.java +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index e7bcbcc..0000000 --- a/src/main/java/com/it_jaros/jscanner/scan/engine/ProducerThread.java +++ /dev/null @@ -1,83 +0,0 @@ -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 deleted file mode 100644 index 38e9cdb..0000000 --- a/src/main/java/com/it_jaros/jscanner/scan/engine/ScanExecutionContext.java +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 7a01d54..0000000 --- a/src/main/java/com/it_jaros/jscanner/scan/engine/ScanHostTask.java +++ /dev/null @@ -1,123 +0,0 @@ -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 deleted file mode 100644 index 873ec95..0000000 --- a/src/main/java/com/it_jaros/jscanner/scan/engine/ScanPortTask.java +++ /dev/null @@ -1,135 +0,0 @@ -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 deleted file mode 100644 index 93e05d4..0000000 --- a/src/main/java/com/it_jaros/jscanner/scan/service/ServiceDetector.java +++ /dev/null @@ -1,47 +0,0 @@ -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 deleted file mode 100644 index 86b282a..0000000 --- a/src/main/java/com/it_jaros/jscanner/scan/service/ServiceType.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.it_jaros.jscanner.scan.service; - -public enum ServiceType { - HTTP, FTP, SMTP, IMAP, POP3, UNKNOWN, DNS, SSH -} -- cgit v1.3.1