├── 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 | 
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 |
--------------------------------------------------------------------------------