import concurrent.futures import csv import ipaddress import re import socket import threading import time from dataclasses import asdict, dataclass, field from typing import Callable, Iterable, Optional from core.Function.common import parse_ports, validate_host, validate_port OutputCallback = Callable[[str, Optional[str]], None] DoneCallback = Callable[[], None] StatusCallback = Callable[[dict], None] ResultCallback = Callable[[dict], None] STATUS_TEXT = { "open": "开放", "closed": "关闭", "timeout": "超时", "unreachable": "不可达", "dns_error": "解析失败", "cancelled": "已取消", "error": "错误", } COMMON_SERVICES = { 20: "FTP-DATA", 21: "FTP", 22: "SSH", 23: "TELNET", 25: "SMTP", 53: "DNS", 67: "DHCP", 68: "DHCP", 80: "HTTP", 110: "POP3", 123: "NTP", 135: "MSRPC", 137: "NETBIOS", 138: "NETBIOS", 139: "NETBIOS", 143: "IMAP", 389: "LDAP", 443: "HTTPS", 445: "SMB", 465: "SMTPS", 587: "SMTP", 636: "LDAPS", 993: "IMAPS", 995: "POP3S", 1433: "MSSQL", 1521: "ORACLE", 3306: "MYSQL", 3389: "RDP", 5432: "POSTGRES", 5900: "VNC", 5985: "WINRM", 5986: "WINRM-SSL", 6379: "REDIS", 8000: "HTTP-ALT", 8080: "HTTP-ALT", 8443: "HTTPS-ALT", 9200: "ELASTIC", 27017: "MONGODB", } HTTP_BANNER_PORTS = {80, 8000, 8008, 8080, 8081, 8888, 9000} @dataclass class ScanOptions: timeout_ms: int = 800 workers: int = 128 show_closed: bool = False banner_probe: bool = False @dataclass class PortResult: host: str resolved_ip: str ip_version: str port: int service: str status: str status_text: str latency_ms: float = 0.0 banner: str = "" error: str = "" checked_at: str = field(default_factory=lambda: time.strftime("%Y-%m-%d %H:%M:%S")) def to_dict(self) -> dict: data = asdict(self) data["latency_ms"] = round(self.latency_ms, 1) return data @dataclass class ScanStats: total: int = 0 scanned: int = 0 open_count: int = 0 closed_count: int = 0 timeout_count: int = 0 unreachable_count: int = 0 dns_error_count: int = 0 error_count: int = 0 cancelled_count: int = 0 started_at: float = field(default_factory=time.perf_counter) def record(self, status: str, count: int = 1) -> None: self.scanned += count if status == "open": self.open_count += count elif status == "closed": self.closed_count += count elif status == "timeout": self.timeout_count += count elif status == "unreachable": self.unreachable_count += count elif status == "dns_error": self.dns_error_count += count elif status == "cancelled": self.cancelled_count += count else: self.error_count += count def snapshot(self, state: str) -> dict: elapsed = time.perf_counter() - self.started_at progress = self.scanned / self.total * 100 if self.total else 0.0 return { "state": state, "total": self.total, "scanned": self.scanned, "open": self.open_count, "closed": self.closed_count, "timeout": self.timeout_count, "unreachable": self.unreachable_count, "dns_error": self.dns_error_count, "error": self.error_count, "cancelled": self.cancelled_count, "progress": progress, "elapsed": elapsed, } class PortScanner: def __init__( self, output: OutputCallback, done: Optional[DoneCallback] = None, status: Optional[StatusCallback] = None, result: Optional[ResultCallback] = None, ): self.output = output self.done = done or (lambda: None) self.status = status or (lambda _stats: None) self.result = result or (lambda _result: None) self.stop_event = threading.Event() self.scan_thread = None self.executor = None self.last_results: list[dict] = [] def test_connect(self, host: str, port: int, timeout: float = 1.0, options: Optional[dict] = None) -> None: options = self.normalize_options(options or {"timeout_ms": int(timeout * 1000), "workers": 1, "show_closed": True}) host = validate_host(host) port = validate_port(port) self._start_scan([host], [port], options, "单端口测试") def start_text_scan(self, host: str, ports_text: str, timeout: float = 0.8, options: Optional[dict] = None) -> None: options = self.normalize_options(options or {"timeout_ms": int(timeout * 1000)}) self.start_list_scan(host, parse_ports(ports_text), timeout=timeout, options=options) def start_range_scan(self, host: str, start_port: int, end_port: int, timeout: float = 0.8, options: Optional[dict] = None) -> None: start_port = validate_port(start_port, "起始端口") end_port = validate_port(end_port, "结束端口") if start_port > end_port: raise ValueError("起始端口不能大于结束端口") options = self.normalize_options(options or {"timeout_ms": int(timeout * 1000)}) self.start_list_scan(host, range(start_port, end_port + 1), timeout=timeout, options=options) def start_list_scan( self, host: str, ports: Iterable[int], timeout: float = 0.8, options: Optional[dict] = None, ) -> None: host = validate_host(host) ports = normalize_ports(ports) options = self.normalize_options(options or {"timeout_ms": int(timeout * 1000)}) self._start_scan([host], ports, options, "端口扫描") def start_scan(self, host: str, ports_text: str, options: Optional[dict] = None) -> None: host = validate_host(host) ports = parse_ports(ports_text) self._start_scan([host], ports, self.normalize_options(options), "端口扫描") def start_batch_scan(self, hosts_text: str, ports_text: str, options: Optional[dict] = None) -> None: hosts = parse_scan_hosts(hosts_text) ports = parse_ports(ports_text) self._start_scan(hosts, ports, self.normalize_options(options), "批量主机扫描") def _start_scan(self, hosts: list[str], ports: list[int], options: ScanOptions, title: str) -> None: if self.is_scanning(): raise RuntimeError("端口扫描正在运行,请先停止当前任务") if not ports: raise ValueError("请输入至少一个端口") self.stop_event.clear() self.last_results = [] total = len(hosts) * len(ports) self.output(f"开始{title}: {len(hosts)} 个目标,{len(ports)} 个端口,共 {total} 次连接\n", "muted") self.output(self.describe_options(options), "muted") self.status(ScanStats(total=total).snapshot("运行中")) self.scan_thread = threading.Thread(target=self._scan, args=(hosts, ports, options, title), daemon=True) self.scan_thread.start() def _scan(self, hosts: list[str], ports: list[int], options: ScanOptions, title: str) -> None: stats = ScanStats(total=len(hosts) * len(ports)) resolved_hosts = [] try: for host in hosts: if self.stop_event.is_set(): break info = resolve_host(host) if info["ok"]: resolved_hosts.append(info) suffix = "" if info["resolved_ip"] == host else f" -> {info['resolved_ip']}" self.output(f"解析: {host}{suffix} ({info['ip_version']})\n", "muted") else: self._record_host_error(host, info["error"], len(ports), stats) workers = min(options.workers, max(1, stats.total)) pair_iter = iter((host_info, port) for host_info in resolved_hosts for port in ports) futures = {} exhausted = False self.executor = concurrent.futures.ThreadPoolExecutor(max_workers=workers) while not self.stop_event.is_set() and (futures or not exhausted): while not self.stop_event.is_set() and not exhausted and len(futures) < workers * 2: try: host_info, port = next(pair_iter) except StopIteration: exhausted = True break futures[self.executor.submit(self._scan_port, host_info, port, options)] = (host_info, port) if not futures: break done, _pending = concurrent.futures.wait( futures, timeout=0.15, return_when=concurrent.futures.FIRST_COMPLETED, ) for future in done: context = futures.pop(future, None) try: item = future.result() except Exception as exc: host_info, port = context or ({"host": "", "resolved_ip": "", "ip_version": ""}, 0) item = PortResult( host=host_info["host"], resolved_ip=host_info["resolved_ip"], ip_version=host_info["ip_version"], port=port, service=service_name(port), status="error", status_text=STATUS_TEXT["error"], error=str(exc), ) self._record_result(item, stats, options) if self.stop_event.is_set(): for future in futures: future.cancel() finally: if self.executor: self.executor.shutdown(wait=False, cancel_futures=True) self.executor = None state = "已停止" if self.stop_event.is_set() else "已完成" self.status(stats.snapshot(state)) self.last_results = sorted(self.last_results, key=result_sort_key) self._write_summary(title, stats, state) self.done() def _record_host_error(self, host: str, error: str, port_count: int, stats: ScanStats) -> None: item = PortResult( host=host, resolved_ip="", ip_version="", port=0, service="", status="dns_error", status_text=STATUS_TEXT["dns_error"], error=error, ) stats.record("dns_error", port_count) self.last_results.append(item.to_dict()) self.result(item.to_dict()) self.output(f"{host} 解析失败: {error}\n", "warning") self.status(stats.snapshot("运行中")) def _record_result(self, item: PortResult, stats: ScanStats, options: ScanOptions) -> None: stats.record(item.status) row = item.to_dict() self.last_results.append(row) self.result(row) should_print = options.show_closed or item.status == "open" or item.status in {"dns_error", "error"} if should_print: tag = "success" if item.status == "open" else "warning" if item.status != "closed" else None latency = f"{item.latency_ms:.0f} ms" if item.latency_ms else "-" detail = item.banner or item.error detail = f" {detail}" if detail else "" self.output( f"[{stats.scanned}/{stats.total}] {item.host}:{item.port:<5} " f"{item.service:<10} {item.status_text:<6} {latency}{detail}\n", tag, ) self.status(stats.snapshot("运行中")) def _scan_port(self, host_info: dict, port: int, options: ScanOptions) -> PortResult: if self.stop_event.is_set(): return PortResult( host=host_info["host"], resolved_ip=host_info["resolved_ip"], ip_version=host_info["ip_version"], port=port, service=service_name(port), status="cancelled", status_text=STATUS_TEXT["cancelled"], ) started = time.perf_counter() timeout = options.timeout_ms / 1000 try: with socket.create_connection((host_info["resolved_ip"], port), timeout=timeout) as sock: elapsed = (time.perf_counter() - started) * 1000 banner = self._read_banner(sock, host_info["host"], port, timeout) if options.banner_probe else "" return PortResult( host=host_info["host"], resolved_ip=host_info["resolved_ip"], ip_version=host_info["ip_version"], port=port, service=service_name(port), status="open", status_text=STATUS_TEXT["open"], latency_ms=elapsed, banner=banner, ) except socket.timeout: return self._closed_result(host_info, port, "timeout", "连接超时") except OSError as exc: status = classify_os_error(exc) return self._closed_result(host_info, port, status, clean_error(exc)) def _closed_result(self, host_info: dict, port: int, status: str, error: str) -> PortResult: return PortResult( host=host_info["host"], resolved_ip=host_info["resolved_ip"], ip_version=host_info["ip_version"], port=port, service=service_name(port), status=status, status_text=STATUS_TEXT.get(status, STATUS_TEXT["error"]), error=error, ) def _read_banner(self, sock: socket.socket, host: str, port: int, timeout: float) -> str: try: sock.settimeout(min(max(timeout, 0.25), 1.0)) if port in HTTP_BANNER_PORTS: request = f"HEAD / HTTP/1.0\r\nHost: {host}\r\nConnection: close\r\n\r\n" sock.sendall(request.encode("ascii", errors="ignore")) data = sock.recv(256) except Exception: return "" text = data.decode("utf-8", errors="replace") text = re.sub(r"\s+", " ", text.replace("\x00", " ")).strip() return text[:160] def stop_scan(self) -> None: if not self.is_scanning(): raise RuntimeError("当前没有正在运行的端口扫描") self.stop_event.set() if self.executor: self.executor.shutdown(wait=False, cancel_futures=True) self.output("\n正在停止端口扫描...\n", "warning") def export_results(self, path: str) -> None: if not self.last_results: raise RuntimeError("还没有可导出的端口扫描结果") fields = [ "host", "resolved_ip", "ip_version", "port", "service", "status", "status_text", "latency_ms", "banner", "error", "checked_at", ] with open(path, "w", newline="", encoding="utf-8-sig") as file: writer = csv.DictWriter(file, fieldnames=fields) writer.writeheader() for row in self.last_results: writer.writerow({field_name: row.get(field_name, "") for field_name in fields}) def open_ports_summary(self) -> str: open_items = [item for item in self.last_results if item.get("status") == "open"] if not open_items: return "" grouped: dict[str, list[str]] = {} for item in sorted(open_items, key=result_sort_key): grouped.setdefault(item["host"], []).append(str(item["port"])) return "\n".join(f"{host}: {', '.join(ports)}" for host, ports in grouped.items()) def describe_options(self, options: ScanOptions) -> str: closed = "显示" if options.show_closed else "只显示开放端口" banner = "开启" if options.banner_probe else "关闭" return f"超时: {options.timeout_ms}ms 并发: {options.workers} 输出: {closed} Banner 探测: {banner}\n\n" def normalize_options(self, options: Optional[dict]) -> ScanOptions: options = options or {} return ScanOptions( timeout_ms=clamp_int(options.get("timeout_ms", 800), 100, 60000, "超时"), workers=clamp_int(options.get("workers", 128), 1, 512, "并发数"), show_closed=bool(options.get("show_closed", False)), banner_probe=bool(options.get("banner_probe", False)), ) def _write_summary(self, title: str, stats: ScanStats, state: str) -> None: snapshot = stats.snapshot(state) self.output(f"\n==== {title}统计 ====\n", "muted") self.output( f"状态: {state} 已扫: {snapshot['scanned']}/{snapshot['total']} " f"开放: {snapshot['open']} 关闭: {snapshot['closed']} 超时: {snapshot['timeout']} " f"不可达: {snapshot['unreachable']} 错误: {snapshot['error'] + snapshot['dns_error']} " f"耗时: {snapshot['elapsed']:.1f}s\n", "success" if snapshot["open"] else "warning", ) summary = self.open_ports_summary() if summary: self.output("开放端口汇总:\n" + summary + "\n", "success") def is_scanning(self) -> bool: return bool(self.scan_thread and self.scan_thread.is_alive()) def normalize_ports(ports: Iterable[int]) -> list[int]: unique = sorted({validate_port(port) for port in ports}) if not unique: raise ValueError("请输入至少一个端口") return unique def parse_scan_hosts(text: str) -> list[str]: raw = text.strip() if not raw: raise ValueError("请输入扫描目标") targets = [] for part in re.split(r"[,,;\s]+", raw): item = part.strip() if not item: continue if "/" in item: network = ipaddress.ip_network(item, strict=False) targets.extend(str(ip) for ip in network.hosts()) elif re.match(r"^\d{1,3}(?:\.\d{1,3}){3}-\d{1,3}$", item): prefix, tail = item.rsplit(".", 1) start_text, end_text = tail.split("-", 1) start = int(start_text) end = int(end_text) if start > end: raise ValueError("IP 范围起始值不能大于结束值") targets.extend(str(ipaddress.ip_address(f"{prefix}.{value}")) for value in range(start, end + 1)) elif re.match(r"^\d{1,3}(?:\.\d{1,3}){3}-\d{1,3}(?:\.\d{1,3}){3}$", item): start_text, end_text = item.split("-", 1) start_ip = ipaddress.ip_address(start_text) end_ip = ipaddress.ip_address(end_text) if start_ip.version != end_ip.version: raise ValueError("IP 范围两端必须是同一 IP 版本") if int(start_ip) > int(end_ip): raise ValueError("IP 范围起始值不能大于结束值") targets.extend(str(ipaddress.ip_address(value)) for value in range(int(start_ip), int(end_ip) + 1)) else: targets.append(validate_host(item)) unique = list(dict.fromkeys(targets)) if not unique: raise ValueError("没有解析到有效目标") return unique def resolve_host(host: str) -> dict: try: parsed = ipaddress.ip_address(host) return {"ok": True, "host": host, "resolved_ip": str(parsed), "ip_version": f"IPv{parsed.version}", "error": ""} except ValueError: pass try: infos = socket.getaddrinfo(host, None, type=socket.SOCK_STREAM) except socket.gaierror as exc: return {"ok": False, "host": host, "resolved_ip": "", "ip_version": "", "error": clean_error(exc)} addresses = [] for family, _socktype, _proto, _canonname, sockaddr in infos: if family not in (socket.AF_INET, socket.AF_INET6): continue ip = sockaddr[0] if ip not in addresses: addresses.append(ip) if not addresses: return {"ok": False, "host": host, "resolved_ip": "", "ip_version": "", "error": "没有可用的 TCP 地址"} selected = addresses[0] version = "IPv6" if ":" in selected else "IPv4" return {"ok": True, "host": host, "resolved_ip": selected, "ip_version": version, "error": ""} def service_name(port: int) -> str: if port <= 0: return "" if port in COMMON_SERVICES: return COMMON_SERVICES[port] try: return socket.getservbyport(port, "tcp").upper() except OSError: return "" def classify_os_error(exc: OSError) -> str: code = getattr(exc, "winerror", None) or getattr(exc, "errno", None) text = str(exc).lower() if code in {10061, 111, 61} or "refused" in text or "拒绝" in text: return "closed" if code in {10051, 10064, 10065, 101, 113} or "unreachable" in text or "不可达" in text: return "unreachable" if "timed out" in text or "超时" in text: return "timeout" return "error" def clean_error(exc: BaseException) -> str: if isinstance(exc, OSError): return exc.strerror or str(exc) return str(exc) def result_sort_key(item: dict) -> tuple: host_key = item.get("resolved_ip") or item.get("host") or "" try: host_key = f"{int(ipaddress.ip_address(host_key)):039d}" except ValueError: pass return (host_key, int(item.get("port") or 0)) def clamp_int(value, min_value: int, max_value: int, label: str) -> int: try: number = int(str(value).strip()) except ValueError as exc: raise ValueError(f"{label}必须是整数") from exc if number < min_value or number > max_value: raise ValueError(f"{label}必须在 {min_value}-{max_value} 之间") return number