├── netscanner_screenshot.png ├── LICENSE ├── README.md ├── .gitignore └── light-net-scanner.py /netscanner_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mennylevinski/light-net-scanner/HEAD/netscanner_screenshot.png -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Menny Levinski 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Light Network Scanner 2 | 3 | A cross-platform, lightweight, open-source Python tool designed to discover devices on your local IPv4 network. 4 | Built for ethical diagnostics, security awareness and administrative auditing. 5 | 6 | --- 7 | 8 | ## ✔️ Legal Use 9 | 10 | This tool is intended solely for lawful and authorized use. 11 | You must obtain explicit permission from the network owner before scanning, auditing, or testing any systems. 12 | The author assumes no liability for misuse or for actions that violate applicable laws or organizational policies. 13 | Use responsibly and in compliance with your local governance. 14 | 15 | --- 16 | 17 | ## ✨ Features 18 | 19 | - **LAN Detection Mode**, detects your IPv4 subnet and scans the local network 20 | - **Custom Scan Mode**, user can select to target a spesific IP address or IP ranges 21 | - **Fast & Accurate**, combines ICMP, ARP, and socket checks and auto discovery 22 | - **Port Detection**, scans common service ports (FTP, SSH, SMB, HTTP, RDP, etc) 23 | - **Console / CLI**, clean “black console” output, stays open after completion 24 | - **Logging system**, exportable log file (TXT format) for more detailed output 25 | 26 | --- 27 | 28 | ## ⚙️ Installation 29 | 30 | ### 1️ Requirements 31 | - Python **3.0+** 32 | - Works on **Windows**, **Linux** 33 | - No external packages required 34 | 35 | ### 2️ Download & Run 36 | - Windows: `python light-net-scanner.py` 37 | - Linux:
38 | 1. `chmod +x light-net-scanner.py`
39 | 2. `python3 light-net-scanner.py` 40 | 41 | --- 42 | ## 🖼️ Screenshot 43 | ![App Screenshot](netscanner_screenshot.png) 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[codz] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | #poetry.toml 110 | 111 | # pdm 112 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 113 | # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. 114 | # https://pdm-project.org/en/latest/usage/project/#working-with-version-control 115 | #pdm.lock 116 | #pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # pixi 121 | # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. 122 | #pixi.lock 123 | # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one 124 | # in the .venv directory. It is recommended not to include this directory in version control. 125 | .pixi 126 | 127 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 128 | __pypackages__/ 129 | 130 | # Celery stuff 131 | celerybeat-schedule 132 | celerybeat.pid 133 | 134 | # SageMath parsed files 135 | *.sage.py 136 | 137 | # Environments 138 | .env 139 | .envrc 140 | .venv 141 | env/ 142 | venv/ 143 | ENV/ 144 | env.bak/ 145 | venv.bak/ 146 | 147 | # Spyder project settings 148 | .spyderproject 149 | .spyproject 150 | 151 | # Rope project settings 152 | .ropeproject 153 | 154 | # mkdocs documentation 155 | /site 156 | 157 | # mypy 158 | .mypy_cache/ 159 | .dmypy.json 160 | dmypy.json 161 | 162 | # Pyre type checker 163 | .pyre/ 164 | 165 | # pytype static type analyzer 166 | .pytype/ 167 | 168 | # Cython debug symbols 169 | cython_debug/ 170 | 171 | # PyCharm 172 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 173 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 174 | # and can be added to the global gitignore or merged into this file. For a more nuclear 175 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 176 | #.idea/ 177 | 178 | # Abstra 179 | # Abstra is an AI-powered process automation framework. 180 | # Ignore directories containing user credentials, local state, and settings. 181 | # Learn more at https://abstra.io/docs 182 | .abstra/ 183 | 184 | # Visual Studio Code 185 | # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore 186 | # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore 187 | # and can be added to the global gitignore or merged into this file. However, if you prefer, 188 | # you could uncomment the following to ignore the entire vscode folder 189 | # .vscode/ 190 | 191 | # Ruff stuff: 192 | .ruff_cache/ 193 | 194 | # PyPI configuration file 195 | .pypirc 196 | 197 | # Cursor 198 | # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to 199 | # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data 200 | # refer to https://docs.cursor.com/context/ignore-files 201 | .cursorignore 202 | .cursorindexingignore 203 | 204 | # Marimo 205 | marimo/_static/ 206 | marimo/_lsp/ 207 | __marimo__/ 208 | -------------------------------------------------------------------------------- /light-net-scanner.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*-import subprocess 3 | 4 | """ 5 | Author: Menny Levinski 6 | 7 | Light Network Scanner 8 | Lightweight LAN discovery & port audit tool. 9 | 10 | Windows: 11 | python light-net-scanner.py 12 | 13 | Linux: 14 | chmod +x light-net-scanner.py 15 | python3 light-net-scanner.py 16 | """ 17 | 18 | import io 19 | import os 20 | import re 21 | import sys 22 | import time 23 | import platform 24 | import socket 25 | import logging 26 | import datetime 27 | import ipaddress 28 | import subprocess 29 | import concurrent.futures 30 | import threading 31 | import itertools 32 | from typing import List, Dict, Iterable, Optional 33 | from io import StringIO 34 | from typing import Optional 35 | 36 | log_buffer = io.StringIO() 37 | now = datetime.datetime.now().replace(microsecond=0) 38 | 39 | # ====== Logger Setup ======= 40 | def setup_logger(level=logging.INFO, logfile: Optional[str] = None): 41 | """Configure root logger. Call once at program start.""" 42 | 43 | ch = logging.StreamHandler() 44 | ch.setFormatter(logging.Formatter("%(message)s")) 45 | 46 | handlers = [ch] 47 | 48 | fh = None 49 | if logfile: 50 | fh = logging.FileHandler(logfile) 51 | fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S")) 52 | handlers.append(fh) 53 | 54 | sh = logging.StreamHandler(log_buffer) 55 | sh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S")) 56 | handlers.append(sh) 57 | 58 | logging.basicConfig(level=level, handlers=handlers) 59 | 60 | # ======= Console helper ======= 61 | def ensure_console(title: str = "Network Scanner"): 62 | """Ensure a console is available on Windows with black background / white text.""" 63 | if sys.platform.startswith("win"): 64 | try: 65 | import ctypes 66 | kernel32 = ctypes.windll.kernel32 67 | 68 | ATTACH_PARENT_PROCESS = -1 69 | if not kernel32.AttachConsole(ATTACH_PARENT_PROCESS): 70 | kernel32.AllocConsole() 71 | 72 | sys.stdout = open("CONOUT$", "w", buffering=1, encoding="utf-8", errors="ignore") 73 | sys.stderr = open("CONOUT$", "w", buffering=1, encoding="utf-8", errors="ignore") 74 | sys.stdin = open("CONIN$", "r", encoding="utf-8", errors="ignore") 75 | 76 | try: 77 | kernel32.SetConsoleTitleW(str(title)) 78 | except Exception: 79 | pass 80 | 81 | try: 82 | os.system("color 07") 83 | except Exception: 84 | pass 85 | except Exception: 86 | pass 87 | 88 | # ======= Spinner (moving dots) ======= 89 | class Spinner: 90 | """Simple console spinner/dots animation in a separate thread.""" 91 | def __init__(self, message: str = "Running scan"): 92 | self.message = message 93 | self._stop_event = threading.Event() 94 | self.thread = threading.Thread(target=self._spin, daemon=True) 95 | 96 | def _spin(self): 97 | for dots in itertools.cycle(["", ".", "..", "...", "....", "....."]): 98 | if self._stop_event.is_set(): 99 | break 100 | print(f"\r{self.message}{dots} ", end="", flush=True) 101 | time.sleep(0.5) 102 | 103 | print("\r" + " " * (len(self.message) + 10) + "\r", end="", flush=True) 104 | 105 | def start(self): 106 | self.thread.start() 107 | 108 | def stop(self): 109 | self._stop_event.set() 110 | self.thread.join() 111 | 112 | # Default common ports to check quickly 113 | COMMON_PORTS = [21, 22, 23, 67, 68, 69, 80, 123, 53, 161, 389, 443, 445, 1433, 3389, 5900] 114 | 115 | # OS detection 116 | IS_WINDOWS = platform.system().lower().startswith("win") 117 | 118 | # Windows-specific flags to hide console windows for subprocess children 119 | if IS_WINDOWS: 120 | WINDOWS_CREATE_NO_WINDOW = 0x08000000 121 | try: 122 | STARTUPINFO = subprocess.STARTUPINFO() 123 | STARTUPINFO.dwFlags |= subprocess.STARTF_USESHOWWINDOW 124 | STARTUPINFO.wShowWindow = subprocess.SW_HIDE 125 | except Exception: 126 | STARTUPINFO = None 127 | else: 128 | WINDOWS_CREATE_NO_WINDOW = 0 129 | STARTUPINFO = None 130 | 131 | def _subproc_kwargs_hide_window() -> dict: 132 | if IS_WINDOWS: 133 | kwargs = {"creationflags": WINDOWS_CREATE_NO_WINDOW} 134 | if STARTUPINFO is not None: 135 | kwargs["startupinfo"] = STARTUPINFO 136 | return kwargs 137 | return {} 138 | 139 | def _run_check_output(cmd, shell=False, **kwargs) -> str: 140 | base_kwargs = {"text": True, "encoding": "utf-8", "errors": "ignore", "shell": shell} 141 | base_kwargs.update(_subproc_kwargs_hide_window()) 142 | base_kwargs.update(kwargs) 143 | return subprocess.check_output(cmd, **base_kwargs) 144 | 145 | def _run_subprocess_run(cmd, shell=False, **kwargs) -> subprocess.CompletedProcess: 146 | base_kwargs = {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "shell": shell} 147 | base_kwargs.update(_subproc_kwargs_hide_window()) 148 | base_kwargs.update(kwargs) 149 | return subprocess.run(cmd, **base_kwargs) 150 | 151 | def _local_ip() -> Optional[str]: 152 | try: 153 | s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 154 | s.settimeout(0.5) 155 | s.connect(("8.8.8.8", 80)) 156 | ip = s.getsockname()[0] 157 | s.close() 158 | return ip 159 | except Exception: 160 | return None 161 | 162 | def guess_subnet(ip: Optional[str], mask_bits: int = 24) -> ipaddress.IPv4Network: 163 | if not ip: 164 | return ipaddress.ip_network("0.0.0.0/0") 165 | return ipaddress.ip_network(f"{ip}/{mask_bits}", strict=False) 166 | 167 | def _ping(ip: str, timeout_ms: int = 100, rate: int = 50) -> bool: 168 | 169 | try: 170 | if IS_WINDOWS: 171 | cmd = ["ping", "-n", "1", "-w", str(timeout_ms), ip] 172 | proc = _run_subprocess_run(cmd, shell=False) 173 | else: 174 | timeout_s = max(1, int((timeout_ms + 999) // 1000)) 175 | cmd = ["ping", "-c", "1", "-W", str(timeout_s), ip] 176 | proc = _run_subprocess_run(cmd, shell=False) 177 | 178 | # throttle ping frequency 179 | if rate > 0: 180 | time.sleep(1 / rate) 181 | 182 | return proc.returncode == 0 183 | except Exception: 184 | return False 185 | 186 | def _parse_arp_table() -> Dict[str, str]: 187 | ip_to_mac = {} 188 | try: 189 | out = _run_check_output(["arp", "-a"], shell=False) 190 | if IS_WINDOWS: 191 | for line in out.splitlines(): 192 | m = re.search(r"(\d+\.\d+\.\d+\.\d+)\s+([0-9a-fA-F-]{14,17})", line) 193 | if m: 194 | ip, mac = m.group(1), m.group(2).replace("–", ":").lower() 195 | ip_to_mac[ip] = mac 196 | else: 197 | for line in out.splitlines(): 198 | m = re.search(r"\((\d+\.\d+\.\d+\.\d+)\)\s+at\s+([0-9a-fA-F:]{17})", line) 199 | if m: 200 | ip, mac = m.group(1), m.group(2).lower() 201 | ip_to_mac[ip] = mac 202 | except Exception: 203 | pass 204 | return ip_to_mac 205 | 206 | def _resolve_hostname(ip: str, timeout: float = 0.5) -> str: 207 | # 1) Reverse DNS (cross-platform, safest) 208 | try: 209 | with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex: 210 | fut = ex.submit(socket.gethostbyaddr, ip) 211 | return fut.result(timeout=timeout)[0] 212 | except Exception: 213 | pass 214 | 215 | # Linux-only methods here 216 | if platform.system().lower() == "windows": 217 | return "N/A" 218 | 219 | # Helper: check if a command exists 220 | def cmd_exists(cmd: str) -> bool: 221 | return subprocess.call( 222 | f"command -v {cmd} >/dev/null 2>&1", shell=True 223 | ) == 0 224 | 225 | # 2) NetBIOS (nmblookup) 226 | if cmd_exists("nmblookup"): 227 | try: 228 | result = subprocess.check_output( 229 | ["nmblookup", "-A", ip], 230 | stderr=subprocess.DEVNULL, 231 | timeout=timeout, 232 | text=True 233 | ) 234 | for line in result.splitlines(): 235 | if "<00>" in line and "GROUP" not in line: 236 | return line.split()[0] 237 | except Exception: 238 | pass 239 | 240 | # 3) mDNS (avahi-resolve) 241 | if cmd_exists("avahi-resolve-address"): 242 | try: 243 | result = subprocess.check_output( 244 | ["avahi-resolve-address", ip], 245 | stderr=subprocess.DEVNULL, 246 | timeout=timeout, 247 | text=True 248 | ).strip() 249 | 250 | if result and " " in result: 251 | return result.split()[-1] 252 | except Exception: 253 | pass 254 | 255 | return "N/A" 256 | 257 | def _scan_ports(ip: str, ports: Iterable[int], timeout: float = 0.12, max_workers: int = 200) -> List[int]: 258 | open_ports = [] 259 | ports_list = list(ports) 260 | if not ports_list: 261 | return [] 262 | 263 | def _try_port(port: int) -> Optional[int]: 264 | try: 265 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: 266 | s.settimeout(timeout) 267 | if s.connect_ex((ip, port)) == 0: 268 | return port 269 | except: 270 | pass 271 | return None 272 | 273 | worker_count = min(max_workers, len(ports_list)) 274 | 275 | with concurrent.futures.ThreadPoolExecutor(max_workers=worker_count) as ex: 276 | futures = {ex.submit(_try_port, p): p for p in ports_list} 277 | for fut in concurrent.futures.as_completed(futures): 278 | res = fut.result() 279 | if res is not None: 280 | open_ports.append(res) 281 | 282 | return sorted(open_ports) 283 | 284 | def discover_hosts(subnet: ipaddress.IPv4Network, max_workers: int = 400, tcp_ports=None, timeout: float = 0.1) -> List[str]: 285 | if tcp_ports is None: 286 | tcp_ports = [22, 53, 139, 161, 443, 445] # common ports for alive detection 287 | 288 | ips = [str(ip) for ip in subnet.hosts()] 289 | if not ips: 290 | return [] 291 | 292 | def _fast_alive(ip): 293 | for port in tcp_ports: 294 | try: 295 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: 296 | s.settimeout(timeout) 297 | if s.connect_ex((ip, port)) == 0: 298 | return ip 299 | except: 300 | continue 301 | return None 302 | 303 | with concurrent.futures.ThreadPoolExecutor(max_workers=min(max_workers, len(ips))) as ex: 304 | results = list(ex.map(_fast_alive, ips)) 305 | 306 | alive = [ip for ip in results if ip] 307 | return sorted(alive, key=lambda x: socket.inet_aton(x)) 308 | 309 | def discover_network(subnet: Optional[ipaddress.IPv4Network] = None, 310 | ports: Optional[Iterable[int]] = None, 311 | do_port_scan: bool = True, 312 | fast: bool = True) -> List[Dict]: 313 | 314 | if ports is None: 315 | ports = COMMON_PORTS 316 | 317 | local_ip = _local_ip() # Detect local IP 318 | if subnet is None: 319 | subnet = guess_subnet(local_ip, 24) 320 | 321 | port_timeout = 0.4 if fast else 1.2 322 | tcp_timeout = 0.10 if fast else 0.3 # for fast host detection 323 | 324 | # Discover alive hosts using TCP connect (much faster than ping) 325 | alive_ips = discover_hosts(subnet, max_workers=400, timeout=tcp_timeout) 326 | 327 | # --- Skip local IP --- 328 | if local_ip in alive_ips: 329 | alive_ips.remove(local_ip) 330 | 331 | # Get ARP table for MAC addresses 332 | ip_mac = _parse_arp_table() 333 | 334 | devices = [] 335 | with concurrent.futures.ThreadPoolExecutor(max_workers=100) as ex: 336 | port_futures = {} 337 | for ip in alive_ips: 338 | if do_port_scan: 339 | port_futures[ip] = ex.submit(_scan_ports, ip, ports, port_timeout, 200) 340 | 341 | for ip in alive_ips: 342 | hostname = "N/A" if fast else _resolve_hostname(ip, timeout=1.0) 343 | mac = ip_mac.get(ip) 344 | open_ports = [] 345 | if do_port_scan and ip in port_futures: 346 | try: 347 | open_ports = port_futures[ip].result(timeout=10) 348 | except Exception: 349 | open_ports = [] 350 | 351 | devices.append({ 352 | "ip": ip, 353 | "hostname": hostname, 354 | "mac": mac, 355 | "alive": True, 356 | "open_ports": open_ports 357 | }) 358 | 359 | return devices 360 | 361 | def _highlight_risky_ports(ports: Iterable[int]) -> List[str]: 362 | mapping = {} 363 | return [f"{p}({mapping.get(p,'')})" if p in mapping else str(p) for p in ports] 364 | 365 | # ======= Print Setup ======= 366 | def test_print( 367 | subnet: Optional[ipaddress.IPv4Network] = None, 368 | do_port_scan: bool = True, 369 | fast: bool = True, 370 | ports: Optional[Iterable[int]] = None, 371 | silent: bool = False, 372 | ) -> list: 373 | if subnet is None: 374 | local = _local_ip() 375 | subnet = guess_subnet(local, 24) 376 | 377 | devices = discover_network(subnet=subnet, do_port_scan=do_port_scan, fast=fast, ports=ports) 378 | 379 | if silent: 380 | return devices 381 | 382 | # --- Print table --- 383 | headers = ["IP Address", "Hostname", "MAC", "Alive", "Open Ports"] 384 | col_widths = [15, 20, 20, 10, 40] 385 | 386 | def _trim(s: str, w: int) -> str: 387 | return (s[: w - 3] + "...") if len(s) > w else s 388 | 389 | header_line = " ".join(h.center(w) for h, w in zip(headers, col_widths)) 390 | sep_line = "–" * len(header_line) 391 | 392 | logging.info(f"\n\nScanned subnet: {subnet} — found {len(devices)} devices") 393 | logging.info(sep_line) 394 | logging.info(header_line) 395 | logging.info(sep_line) 396 | 397 | seen_ips = set() 398 | for d in devices: 399 | ip = d["ip"] 400 | if ip in seen_ips: 401 | continue 402 | seen_ips.add(ip) 403 | 404 | host = _trim(d.get("hostname") or "N/A", col_widths[1]) 405 | mac = _trim(d.get("mac") or "N/A", col_widths[2]) 406 | alive = str(d["alive"]) 407 | if d.get("open_ports"): 408 | ports_str = ", ".join(_highlight_risky_ports(d["open_ports"])) 409 | else: 410 | ports_str = "N/A" 411 | ports_str = _trim(ports_str, col_widths[4]) 412 | 413 | logging.info( 414 | f"{ip.ljust(col_widths[0])} {host.center(col_widths[1])} " 415 | f"{mac.center(col_widths[2])} {alive.center(col_widths[3])} {ports_str.center(col_widths[4])}" 416 | ) 417 | 418 | logging.info(sep_line) 419 | return devices 420 | 421 | def print_devices(devices: list): 422 | # Same logic as test_print, just the printing part 423 | headers = ["IP Address", "Hostname", "MAC", "Alive", "Open Ports"] 424 | col_widths = [15, 20, 20, 10, 40] 425 | 426 | def _trim(s: str, w: int) -> str: 427 | return (s[: w - 3] + "...") if len(s) > w else s 428 | 429 | header_line = " ".join(h.center(w) for h, w in zip(headers, col_widths)) 430 | sep_line = "–" * len(header_line) 431 | 432 | logging.info(sep_line) 433 | logging.info(header_line) 434 | logging.info(sep_line) 435 | 436 | seen_ips = set() 437 | for d in devices: 438 | ip = d["ip"] 439 | if ip in seen_ips: 440 | continue 441 | seen_ips.add(ip) 442 | 443 | host = _trim(d.get("hostname") or "N/A", col_widths[1]) 444 | mac = _trim(d.get("mac") or "N/A", col_widths[2]) 445 | alive = str(d["alive"]) 446 | ports_str = _trim( 447 | ", ".join(_highlight_risky_ports(d.get("open_ports", []))) if d.get("open_ports") else "N/A", 448 | col_widths[4] 449 | ) 450 | 451 | logging.info( 452 | f"{ip.ljust(col_widths[0])} {host.center(col_widths[1])} " 453 | f"{mac.center(col_widths[2])} {alive.center(col_widths[3])} {ports_str.center(col_widths[4])}" 454 | ) 455 | 456 | logging.info(sep_line) 457 | 458 | # --- Define private IP check --- 459 | def is_private_ip(ip: str) -> bool: 460 | try: 461 | return ipaddress.ip_address(ip).is_private 462 | except ValueError: 463 | return False 464 | 465 | # ======= Main ======= 466 | if __name__ == "__main__": 467 | ensure_console("Light Network Scanner") 468 | 469 | log_level = logging.DEBUG if "--debug" in sys.argv else logging.INFO 470 | log_file = None 471 | if "--log" in sys.argv: 472 | try: 473 | idx = sys.argv.index("--log") 474 | log_file = sys.argv[idx + 1] 475 | except Exception: 476 | log_file = "scan.log" 477 | 478 | setup_logger(level=log_level, logfile=log_file) 479 | 480 | # --- Detect local IP and subnet --- 481 | logging.info(f"{now}") 482 | logging.info("Detecting local IP...") 483 | local = _local_ip() 484 | logging.info(f"Local IP: {local}") 485 | subnet = guess_subnet(local, 24) 486 | logging.info(f"Guessed subnet: {subnet}") 487 | 488 | # --- Initialize target_ips variable --- 489 | target_ips = None 490 | 491 | # --- Ask user which scan to perform (LAN or custom) --- 492 | MAX_RANGE_SIZE = 512 493 | scan_results = [] 494 | 495 | # --- helper functions --- 496 | def _ip_range_from_full_ips(start_ip: str, end_ip: str) -> List[str]: 497 | a = ipaddress.IPv4Address(start_ip) 498 | b = ipaddress.IPv4Address(end_ip) 499 | if int(b) < int(a): 500 | raise ValueError("End IP is smaller than start IP") 501 | size = int(b) - int(a) + 1 502 | if size > MAX_RANGE_SIZE: 503 | raise ValueError(f"Range too large ({size} IPs); max is {MAX_RANGE_SIZE}") 504 | return [str(ipaddress.IPv4Address(int(a) + i)) for i in range(size)] 505 | 506 | # ---- main scan loop ---- 507 | while True: 508 | print("\nSelect scan type:") 509 | print("1) LAN scanning") 510 | print("2) Custom IP range") 511 | print("3) Exit") 512 | scan_mode = input("\nEnter choice: ").strip() 513 | 514 | if scan_mode not in {"1", "2", "3"}: 515 | print("Invalid choice!") 516 | continue 517 | 518 | if scan_mode == "1": 519 | choice = input("\nStart full LAN scan? (Y/N): ").strip().upper() 520 | if choice not in ("Y", "N"): 521 | print("Invalid choice!") 522 | continue 523 | if choice != "Y": 524 | print("LAN scan cancelled.") 525 | input("Press Enter to exit...") 526 | break 527 | 528 | spinner = Spinner("Running LAN scan") 529 | spinner.start() 530 | start = time.time() 531 | try: 532 | results = test_print(subnet=subnet, do_port_scan=True, fast=True) 533 | scan_results.extend(results) 534 | finally: 535 | spinner.stop() 536 | elapsed = time.time() - start 537 | logging.info(f"LAN scan finished in {elapsed:.1f}s") 538 | 539 | elif scan_mode == "2": 540 | local_ip = _local_ip() 541 | print("\nCustom scan options:") 542 | print("Format options:") 543 | print(" - Single IP (example: 1.1.1.1)") 544 | print(" - IP Range (example: 1.1.1.1-1.1.1.50)\n") 545 | 546 | raw = input("Enter target (single IP or range): ").strip() 547 | target_ips = [] 548 | 549 | try: 550 | if "-" in raw: 551 | start_ip, end_ip = map(str.strip, raw.split("-", 1)) 552 | ipaddress.ip_address(start_ip) 553 | ipaddress.ip_address(end_ip) 554 | target_ips = _ip_range_from_full_ips(start_ip, end_ip) 555 | else: 556 | ipaddress.ip_address(raw) 557 | target_ips = [raw] 558 | except Exception as e: 559 | print(f"Invalid input: {e}") 560 | continue 561 | 562 | print(f"\nTarget IPs: {len(target_ips)}") 563 | print(", ".join(target_ips[:10]) + (" ..." if len(target_ips) > 10 else "")) 564 | 565 | choice = input("Start custom scan? (Y/N): ").strip().upper() 566 | if choice != "Y": 567 | print("Custom scan cancelled.") 568 | input("Press Enter to exit...") 569 | break 570 | 571 | logging.info(f"\nStarting custom scan for {len(target_ips)} targets...") 572 | scan_results = {} 573 | 574 | # Fixed: remove \n from spinner message 575 | spinner = Spinner("Running custom scan") 576 | spinner.start() 577 | start = time.time() 578 | 579 | try: 580 | for ip in target_ips: 581 | # 0 Skip local IP 582 | if ip == local_ip: 583 | logging.info(f"\n[SKIP] {ip} is local — skipping.") 584 | continue 585 | # 1 Only scan private IPs 586 | if not is_private_ip(ip): 587 | logging.info(f"\n[SKIP] {ip} is not private — skipping.") 588 | break 589 | # 2 Ping before scanning 590 | if not _ping(ip, timeout_ms=400): 591 | continue 592 | # 3 Convert single IP to /32 subnet for test_print 593 | single_subnet = ipaddress.ip_network(f"{ip}/32") 594 | # 4 Run silent scan 595 | try: 596 | results = test_print( 597 | subnet=single_subnet, 598 | do_port_scan=True, 599 | fast=True, 600 | silent=True 601 | ) 602 | except Exception as e: 603 | logging.error(f"[ERROR] Scan failed for {ip}: {e}") 604 | continue 605 | # 5 Collect unique results 606 | if results: 607 | for d in results: 608 | key = d.get("ip") or d.get("mac") 609 | if key and key not in scan_results: 610 | scan_results[key] = d 611 | finally: 612 | spinner.stop() 613 | elapsed = time.time() - start 614 | if scan_results: # True only if NOT empty 615 | logging.info(f"Custom scan finished in {elapsed:.1f}s — found {len(scan_results)} devices.") 616 | print_devices(list(scan_results.values())) 617 | else: 618 | print(f"Custom scan finished in {elapsed:.1f}s - No devices found.") 619 | 620 | elif scan_mode == "3": 621 | input("\nGoodby! Press Enter to exit...") 622 | sys.exit(0) 623 | 624 | export = input("\nExport logs to text file? (Y to export, Enter to skip): ").strip().lower() 625 | if export == "y": 626 | export_path = "scan_log_export.txt" 627 | with open(export_path, "w", encoding="utf-8") as f: 628 | f.write(log_buffer.getvalue()) 629 | print(f"Logs exported → {export_path}") 630 | continue 631 | --------------------------------------------------------------------------------