diff --git a/Dockerfile b/Dockerfile index e6fc352ff..ca7dd2385 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM jumpserver/core-base:20260507_060403 AS stage-build +FROM jumpserver/core-base:20260511_022944 AS stage-build ARG VERSION @@ -36,7 +36,6 @@ ARG TOOLS=" \ postgresql-client \ openssh-client \ sshpass \ - nmap \ bubblewrap" ARG APT_MIRROR=http://deb.debian.org diff --git a/Dockerfile-ee b/Dockerfile-ee index 4d51a525c..c59ab222d 100644 --- a/Dockerfile-ee +++ b/Dockerfile-ee @@ -7,13 +7,9 @@ COPY --from=build-xpack /opt/xpack /opt/jumpserver/apps/xpack ARG TOOLS=" \ g++ \ - curl \ iputils-ping \ netcat-openbsd \ - nmap \ - telnet \ - wget \ - poppler-utils" + telnet" RUN set -ex \ && apt-get update \ diff --git a/apps/settings/tools/nmap.py b/apps/settings/tools/nmap.py index d58c46824..c45509c51 100644 --- a/apps/settings/tools/nmap.py +++ b/apps/settings/tools/nmap.py @@ -1,39 +1,91 @@ +import argparse import asyncio +import socket import time -import nmap from common.utils.timezone import local_now_display from settings.utils import generate_ips +_SCANNER_VERSION = '1.0' -def get_nmap_result(nm, ip, ports, timeout): - results = [] - nm.scan(ip, ports=ports, timeout=timeout) - tcp_port = nm[ip].get('tcp', {}) - udp_port = nm[ip].get('udp', {}) - results.append(f'PORT\tSTATE\tSERVICE') - for port, info in tcp_port.items(): - results.append(f"{port}\t{info.get('state', 'unknown')}\t{info.get('name', 'unknown')}") - for port, info in udp_port.items(): - results.append(f"{port}\t{info.get('state', 'unknown')}\t{info.get('name', 'unknown')}") - return results +# Fallback service name table for platforms where getservbyport is unavailable +_KNOWN_SERVICES = { + 21: 'ftp', 22: 'ssh', 23: 'telnet', 25: 'smtp', 53: 'domain', + 80: 'http', 110: 'pop3', 135: 'msrpc', 139: 'netbios-ssn', + 143: 'imap', 443: 'https', 445: 'microsoft-ds', 587: 'submission', + 993: 'imaps', 995: 'pop3s', 1433: 'ms-sql-s', 1521: 'oracle', + 3306: 'mysql', 3389: 'ms-wbt-server', 5432: 'postgresql', + 5900: 'vnc', 6379: 'redis', 8080: 'http-proxy', 8443: 'https-alt', + 27017: 'mongodb', +} -async def once_nmap(nm, ip, ports, timeout, display): +def _parse_ports(ports_str): + """Parse '22,80,443' or '22-100' or a mix into a sorted list of ints.""" + if not ports_str: + # mirror nmap's default: the 1000 most common ports; use 1-1024 as a + # reasonable approximation without requiring root privileges. + return list(range(1, 1025)) + ports = [] + for part in ports_str.split(','): + part = part.strip() + if '-' in part: + start, end = part.split('-', 1) + ports.extend(range(int(start), int(end) + 1)) + else: + ports.append(int(part)) + return sorted(set(ports)) + + +def _service_name(port: int, proto: str = 'tcp') -> str: + try: + return socket.getservbyport(port, proto) + except OSError: + return _KNOWN_SERVICES.get(port, 'unknown') + + +async def _scan_tcp_port(ip: str, port: int, timeout: float) -> str: + """Return 'open' or 'closed' for a single TCP port.""" + try: + _, writer = await asyncio.wait_for( + asyncio.open_connection(ip, port), timeout=timeout + ) + writer.close() + try: + await writer.wait_closed() + except Exception: + pass + return 'open' + except (asyncio.TimeoutError, ConnectionRefusedError, OSError): + return 'closed' + + +async def get_nmap_result(ip: str, ports_str, timeout) -> list[str]: + """Scan *ip* and return formatted result lines (PORT / STATE / SERVICE).""" + timeout = float(timeout) if timeout else 1.0 + ports = _parse_ports(ports_str) + + states = await asyncio.gather( + *[_scan_tcp_port(ip, p, timeout) for p in ports] + ) + + lines = ['PORT\tSTATE\tSERVICE'] + for port, state in zip(ports, states): + if state == 'open': + lines.append(f'{port}/tcp\t{state}\t{_service_name(port)}') + return lines + + +async def once_nmap(ip: str, ports_str, timeout, display) -> bool: await display(f'Starting Nmap at {local_now_display()} for {ip}') try: - is_ok = True - loop = asyncio.get_running_loop() - results = await loop.run_in_executor(None, get_nmap_result, nm, ip, ports, timeout) - for result in results: - await display(result) - - except KeyError: - is_ok = False - await display(f'Host seems down.') + results = await get_nmap_result(ip, ports_str, timeout) + for line in results: + await display(line) + is_ok = len(results) > 1 # at least one open port found except Exception as err: is_ok = False - await display(f"Error: %s" % err) + await display(f'Error: {err}') return is_ok @@ -44,14 +96,34 @@ async def verbose_nmap(dest_ips, dest_ports=None, timeout=None, display=None): ips = generate_ips(dest_ips) dest_port = ','.join(list(dest_ports)) if dest_ports else None - nm = nmap.PortScanner() success_num, start_time = 0, time.time() - nmap_version = '.'.join(map(lambda x: str(x), nm.nmap_version())) - await display(f'[Summary] Nmap (v{nmap_version}): {len(ips)} addresses were scanned') + await display(f'[Summary] Nmap (v{_SCANNER_VERSION}): {len(ips)} addresses were scanned') for ip in ips: - ok = await once_nmap(nm, str(ip), dest_port, timeout, display) + ok = await once_nmap(str(ip), dest_port, timeout, display) if ok: success_num += 1 await display() - await display(f'[Done] Nmap: {len(ips)} IP addresses ({success_num} hosts up) ' - f'scanned in {round(time.time() - start_time, 2)} seconds') + await display( + f'[Done] Nmap: {len(ips)} IP addresses ({success_num} hosts up) ' + f'scanned in {round(time.time() - start_time, 2)} seconds' + ) + + +async def _main(): + parser = argparse.ArgumentParser(description='Pure-Python TCP port scanner') + parser.add_argument('targets', nargs='+', help='IP / CIDR, e.g. 192.168.1.1 or 10.0.0.0/24') + parser.add_argument('-p', '--ports', default=None, + help='Ports to scan, e.g. 22,80,443 or 22-1024 (default: 1-1024)') + parser.add_argument('--timeout', type=float, default=1.0, + help='Per-port connect timeout in seconds (default: 1.0)') + args = parser.parse_args() + + async def display(msg=''): + print(msg) + + dest_ports = args.ports.split(',') if args.ports else None + await verbose_nmap(args.targets, dest_ports=dest_ports, timeout=args.timeout, display=display) + + +if __name__ == '__main__': + asyncio.run(_main()) diff --git a/pyproject.toml b/pyproject.toml index 5fb298357..776f2ec3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ 'six==1.17.0', 'sshtunnel==0.4.0', 'sshpubkeys==3.3.1', - 'urllib3==2.6.3', + 'urllib3==2.7.0', 'uritemplate==4.1.1', 'vine==5.1.0', 'werkzeug==3.0.6', @@ -68,7 +68,6 @@ dependencies = [ 'geoip2==4.8.0', 'ipip-ipdb==1.6.1', 'pywinrm==0.4.3', - 'python-nmap==0.7.1', 'django==5.2.13', 'django-bootstrap3==23.4', 'django-filter==24.3', diff --git a/uv.lock b/uv.lock index 49786ed3e..f4a4703ef 100644 --- a/uv.lock +++ b/uv.lock @@ -2214,7 +2214,6 @@ dependencies = [ { name = "python-daemon" }, { name = "python-dateutil" }, { name = "python-ldap" }, - { name = "python-nmap" }, { name = "python-redis-lock" }, { name = "python3-saml" }, { name = "pytz" }, @@ -2399,7 +2398,6 @@ requires-dist = [ { name = "python-daemon", specifier = "==3.0.1" }, { name = "python-dateutil", specifier = "==2.8.2" }, { name = "python-ldap", specifier = "==3.4.5" }, - { name = "python-nmap", specifier = "==0.7.1" }, { name = "python-redis-lock", specifier = "==4.0.0" }, { name = "python3-saml", specifier = "==1.16.0" }, { name = "pytz", specifier = "==2025.2" }, @@ -2422,7 +2420,7 @@ requires-dist = [ { name = "ua-parser", specifier = ">=0.18.0" }, { name = "unicodecsv", specifier = "==0.14.1" }, { name = "uritemplate", specifier = "==4.1.1" }, - { name = "urllib3", specifier = "==2.6.3" }, + { name = "urllib3", specifier = "==2.7.0" }, { name = "user-agents", specifier = ">=2.2.0" }, { name = "uvicorn", specifier = "==0.22.0" }, { name = "vine", specifier = "==5.1.0" }, @@ -3695,12 +3693,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/0c/88/8d2797decc42e1c1cdd926df4f005e938b0643d0d1219c08c2b5ee8ae0c0/python_ldap-3.4.5.tar.gz", hash = "sha256:b2f6ef1c37fe2c6a5a85212efe71311ee21847766a7d45fcb711f3b270a5f79a", size = 388482, upload-time = "2025-10-10T20:00:39.06Z" } -[[package]] -name = "python-nmap" -version = "0.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f7/1b/8e6b3d1461331e4e8600faf099e7c62ba3c1603987dafdd558681fb8ba37/python-nmap-0.7.1.tar.gz", hash = "sha256:f75af6b91dd8e3b0c31f869db32163f62ada686945e5b7c25f84bc0f7fad3b64", size = 44366, upload-time = "2021-10-26T18:40:26.488Z" } - [[package]] name = "python-novaclient" version = "18.3.0" @@ -4368,11 +4360,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]]