From ef9dc811a7c521821e93576667952df84bb157d5 Mon Sep 17 00:00:00 2001 From: Vlastislav Svatek Date: Wed, 27 May 2026 09:30:58 +0200 Subject: [PATCH] add watchdog and max scans --- .env.example | 9 ++++++ README.md | 4 +++ ipscan-v2.py | 80 ++++++++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 81 insertions(+), 12 deletions(-) diff --git a/.env.example b/.env.example index 7169ff5..26e8920 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,12 @@ NETBOX_PREFIX_STATUS=active # NetBox tenant name to assign to discovered IPs TENANT=Your Tenant Name + +# Parallelism +MAX_SCAN_WORKERS=5 +MAX_IMPORT_WORKERS=5 + +# Watchdog: comma-separated IPs that must stay reachable during the scan +# If any becomes unreachable the scan stops immediately +WATCHDOG_IPS=192.168.1.1,192.168.1.2 +WATCHDOG_INTERVAL=10 diff --git a/README.md b/README.md index 651a1c8..a3e0c03 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,10 @@ All configuration is done via environment variables. Copy `.env.example` to `.en | `NETWORKS` | — | Comma-separated CIDR networks (used when `SCAN_SOURCE=env` or `mixed`) | | `NETBOX_PREFIX_STATUS` | _(all)_ | Filter NetBox prefixes by status, e.g. `active`, `reserved` (used when `SCAN_SOURCE=netbox` or `mixed`) | | `TENANT` | — | NetBox tenant name to assign to imported IPs | +| `MAX_SCAN_WORKERS` | `5` | Number of networks scanned in parallel | +| `MAX_IMPORT_WORKERS` | `5` | Number of NetBox import calls made in parallel | +| `WATCHDOG_IPS` | _(none)_ | Comma-separated IPs to monitor during the scan — if any go down, the scan stops immediately | +| `WATCHDOG_INTERVAL` | `10` | Seconds between watchdog ping checks | **`SCAN_SOURCE` values:** diff --git a/ipscan-v2.py b/ipscan-v2.py index 2c6812f..38d122d 100644 --- a/ipscan-v2.py +++ b/ipscan-v2.py @@ -3,6 +3,8 @@ import nmap import pynetbox import requests import socket +import subprocess +import threading from concurrent.futures import ThreadPoolExecutor, as_completed # Disable SSL Warnings @@ -22,6 +24,15 @@ networks = [network.strip() for network in networks_env.split(",") if network.st scan_source = os.getenv("SCAN_SOURCE", "env").strip().lower() netbox_prefix_status = os.getenv("NETBOX_PREFIX_STATUS", "").strip().lower() +# Parallelism +max_scan_workers = int(os.getenv("MAX_SCAN_WORKERS", "5")) +max_import_workers = int(os.getenv("MAX_IMPORT_WORKERS", "5")) + +# Watchdog: comma-separated IPs that must stay reachable during the scan +watchdog_ips_env = os.getenv("WATCHDOG_IPS", "").strip() +watchdog_ips = [ip.strip() for ip in watchdog_ips_env.split(",") if ip.strip()] +watchdog_interval = int(os.getenv("WATCHDOG_INTERVAL", "10")) + # NetBox configuration netbox_url = os.getenv("NETBOX_URL", "https://netbox.xxxxx.xx/") netbox_token = os.getenv("NETBOX_TOKEN", "xxxxx") @@ -31,6 +42,31 @@ netbox.http_session.verify = ssl_verify tenant = os.getenv("TENANT", "Xxxxx Praha") +# Shared abort flag set by the watchdog when a monitored IP goes down +abort_event = threading.Event() + + +def ping(ip): + result = subprocess.run( + ["ping", "-c", "1", "-W", "2", ip], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return result.returncode == 0 + + +def watchdog_loop(): + if not watchdog_ips: + return + print(f"Watchdog monitoring: {', '.join(watchdog_ips)} (interval: {watchdog_interval}s)") + while not abort_event.is_set(): + for ip in watchdog_ips: + if not ping(ip): + print(f"[WATCHDOG] {ip} is unreachable — aborting scan.") + abort_event.set() + return + abort_event.wait(watchdog_interval) + def load_networks_from_netbox(): print("Loading networks from NetBox...") @@ -52,11 +88,17 @@ elif scan_source == 'mixed': if not networks: raise ValueError('No networks configured to scan. Set NETWORKS or SCAN_SOURCE to include NetBox prefixes.') + def scan_network(network): + if abort_event.is_set(): + print(f"Skipping network {network} — abort signalled.") + return [] print(f"Scanning network: {network}") - nm.scan(hosts=network, arguments='-p 1-32768 -T4 --host-timeout 2m') # Adding a host-timeout of 2 minutes + nm.scan(hosts=network, arguments='-p 1-32768 -T4 --host-timeout 2m') host_results = [] for host in nm.all_hosts(): + if abort_event.is_set(): + break if 'tcp' in nm[host]: ports = [port for port in nm[host]['tcp'] if nm[host]['tcp'][port]['state'] == 'open'] ports_str = ' '.join(str(port) for port in ports) @@ -66,12 +108,13 @@ def scan_network(network): print(f"Host: {host}, Status: {nm[host]['status']['state']}, Open Ports: {ports_str}") return host_results + def add_ip_to_netbox(host, status, ports): if status == 'up': try: hostname = socket.gethostbyaddr(host)[0] except socket.herror: - hostname = host # Use the IP if the hostname couldn't be resolved + hostname = host ip_data = { "address": f"{host}/24", @@ -79,16 +122,19 @@ def add_ip_to_netbox(host, status, ports): "status": "active", "tenant": tenant, "comments": f"Open ports: {ports}", - # Add other fields as needed } - # Create or update the IP address in NetBox print(f"Adding IP address: {ip_data['address']}, Hostname: {hostname}, Open Ports: {ports}") netbox.ipam.ip_addresses.create(ip_data) -# Create a thread pool and scan networks in parallel + +# Start watchdog in background thread +watchdog_thread = threading.Thread(target=watchdog_loop, daemon=True) +watchdog_thread.start() + +# Scan networks in parallel hosts_list = [] -with ThreadPoolExecutor(max_workers=5) as executor: # Adjust number of workers as needed +with ThreadPoolExecutor(max_workers=max_scan_workers) as executor: future_to_network = {executor.submit(scan_network, network): network for network in networks} for future in as_completed(future_to_network): network = future_to_network[future] @@ -97,9 +143,20 @@ with ThreadPoolExecutor(max_workers=5) as executor: # Adjust number of workers hosts_list.extend(data) except Exception as exc: print(f"{network} generated an exception: {exc}") + if abort_event.is_set(): + print("Abort flag set — cancelling remaining scan futures.") + for f in future_to_network: + f.cancel() + break -# Add each IP address to NetBox -with ThreadPoolExecutor(max_workers=5) as executor: # Adjust number of workers as needed +# Stop watchdog +abort_event.set() + +if abort_event.is_set() and not hosts_list: + raise SystemExit("Scan aborted by watchdog before any results were collected.") + +# Import collected results into NetBox +with ThreadPoolExecutor(max_workers=max_import_workers) as executor: futures = [executor.submit(add_ip_to_netbox, host, status, ports) for host, status, ports in hosts_list] for future in as_completed(futures): future.result() @@ -107,19 +164,18 @@ with ThreadPoolExecutor(max_workers=5) as executor: # Adjust number of workers # Get all IP addresses from NetBox all_ip_addresses = netbox.ipam.ip_addresses.all() -# Create a list of hostnames from the scanned hosts +# Build list of hostnames found during this scan scanned_hosts = [] for host, status, ports in hosts_list: if status == 'up': try: hostname = socket.gethostbyaddr(host)[0] except socket.herror: - hostname = host # Use the IP if the hostname couldn't be resolved + hostname = host scanned_hosts.append(hostname) -# Check each IP address in NetBox +# Mark IPs not seen in this scan as offline for ip_address in all_ip_addresses: - # If the IP address's hostname was not found in the scan results, mark it as offline if ip_address.description not in scanned_hosts: ip_address.status = 'offline' ip_address.save()