├── requirements.txt
├── docker-compose.yml
├── Dockerfile
├── README.md
└── app
├── templates
└── index.html
├── app.py
└── static
├── js
└── script.js
└── css
└── style.css
/requirements.txt:
--------------------------------------------------------------------------------
1 | Flask==2.0.1
2 | gunicorn==20.1.0
3 | flask-socketio==5.1.1
4 | gevent==21.8.0
5 | gevent-websocket==0.10.1
6 | ipaddress==1.0.23
7 | Werkzeug==2.0.1
8 | zope.event==4.5.0
9 | zope.interface==5.4.0
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 |
3 | services:
4 | nmap-web:
5 | build: .
6 | container_name: nmap-online
7 | ports:
8 | - "8080:5000"
9 | restart: unless-stopped
10 | deploy:
11 | resources:
12 | limits:
13 | cpus: '8'
14 | memory: 4G
15 | reservations:
16 | cpus: '2'
17 | memory: 1G
18 | ulimits:
19 | nproc: 65535
20 | nofile:
21 | soft: 20000
22 | hard: 40000
23 | environment:
24 | - PYTHONUNBUFFERED=1
25 | - GUNICORN_WORKERS=2
26 | - GUNICORN_THREADS=4
27 | volumes:
28 | - ./app:/app/app
29 |
30 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.9-slim
2 |
3 | # 安装nmap和其他必要的包
4 | RUN apt-get update && apt-get install -y \
5 | nmap \
6 | procps \
7 | && rm -rf /var/lib/apt/lists/*
8 |
9 | # 创建工作目录
10 | WORKDIR /app
11 |
12 | # 复制依赖文件并安装
13 | COPY requirements.txt .
14 | RUN pip install --no-cache-dir -r requirements.txt
15 |
16 | # 复制应用代码
17 | COPY app/ ./app/
18 |
19 | # 设置环境变量
20 | ENV PYTHONPATH=/app
21 | ENV PYTHONUNBUFFERED=1
22 | ENV PYTHONDONTWRITEBYTECODE=1
23 | ENV GEVENT_SUPPORT=True
24 |
25 | # 暴露端口
26 | EXPOSE 5000
27 |
28 | # 启动命令,使用gevent-websocket作为WSGI服务器,支持WebSocket和多线程
29 | CMD ["gunicorn", "--worker-class", "geventwebsocket.gunicorn.workers.GeventWebSocketWorker", "--workers", "2", "--threads", "4", "--bind", "0.0.0.0:5000", "--timeout", "120", "app.app:app"]
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Nmap Web - 在线端口扫描工具
2 |
3 | 这是一个基于Docker的Web应用,提供用户友好的界面来执行Nmap端口扫描。该应用使用Flask作为后端框架,并通过WebSocket提供实时扫描进度和结果显示。
4 |
5 | ## 功能特性
6 |
7 | - 美观现代的用户界面
8 | - 支持多种Nmap扫描选项
9 | - SYN扫描
10 | - TCP连接扫描
11 | - UDP扫描
12 | - 服务版本检测
13 | - 操作系统检测
14 | - 综合扫描
15 | - 不同级别的扫描速度
16 | - 支持全端口扫描 (1-65535)
17 | - **多线程扫描支持** - 使用多达16个线程并行扫描,显著提升扫描速度
18 | - 实时扫描进度和结果展示
19 | - 支持网段和IP范围扫描
20 | - 通过WebSocket技术避免长时间扫描导致的请求超时
21 | - 完全Docker容器化部署
22 |
23 | ## 硬件要求
24 |
25 | 建议的系统配置:
26 |
27 | - CPU: 4核心及以上
28 | - 内存: 4GB及以上
29 | - 磁盘空间: 1GB可用空间
30 |
31 | ## 安装与部署
32 |
33 | 1. 确保你的系统已安装Docker和Docker Compose
34 |
35 | 2. 克隆代码库或下载项目文件
36 |
37 | 3. 在项目根目录执行以下命令启动应用:
38 |
39 | ```bash
40 | docker-compose up -d
41 | ```
42 |
43 | 4. 访问 http://localhost:5000 即可使用
44 |
45 | ## 多线程扫描说明
46 |
47 | 应用支持使用多线程并行执行Nmap扫描任务,针对大型网络或全端口扫描可显著提高扫描速度。
48 |
49 | ### 线程数选择指南:
50 |
51 | - **低负载(1线程)**: 适合低性能设备或避免网络拥堵
52 | - **平衡(4线程)**: 默认设置,适合大多数场景
53 | - **快速(8线程)**: 适合4核及以上CPU,扫描大型网络
54 | - **极速(16线程)**: 适合8核及以上CPU,扫描速度最快
55 |
56 | 在界面上可以通过滑块或预设选项轻松调整线程数量。系统会根据您的设置自动分割扫描任务并显示每个子任务的执行状态。
57 |
58 | ### 工作原理:
59 |
60 | 多线程扫描通过以下方式提升性能:
61 |
62 | 1. **目标分割**: 自动将IP范围或网段分割为多个子目标
63 | 2. **端口分割**: 对单一目标的端口范围进行分割
64 | 3. **并行执行**: 多个Nmap进程同时运行,充分利用多核CPU
65 |
66 | ## 安全说明
67 |
68 | - 本工具仅用于授权的网络安全测试和教育目的
69 | - 未经授权对他人网络进行扫描可能违反相关法律法规
70 | - 作者不对任何滥用此工具造成的后果负责
71 |
72 | ## 环境变量
73 |
74 | 可以通过环境变量调整应用配置:
75 |
76 | - `GUNICORN_WORKERS`: Gunicorn工作进程数 (默认: 2)
77 | - `GUNICORN_THREADS`: 每个工作进程的线程数 (默认: 4)
78 |
79 | ## 技术栈
80 |
81 | - **后端**: Flask, Flask-SocketIO, Gunicorn, Gevent
82 | - **前端**: HTML5, CSS3, JavaScript
83 | - **容器化**: Docker, Docker Compose
84 |
--------------------------------------------------------------------------------
/app/templates/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | 在线Nmap端口扫描
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
22 |
23 |
208 |
209 |
210 |
211 |
214 |
正在扫描中
215 |
216 | 8 个线程正在工作中
217 |
218 |
请耐心等待扫描完成...
219 |
220 | 全端口扫描可能需要较长时间,请耐心等待
221 |
222 |
223 |
227 |
228 |
229 |
230 |
240 |
241 |
249 |
250 |
251 |
256 |
257 |
258 |
259 |
260 |
261 |
262 |
263 |
--------------------------------------------------------------------------------
/app/app.py:
--------------------------------------------------------------------------------
1 | from flask import Flask, render_template, request, jsonify
2 | import subprocess
3 | import json
4 | import re
5 | import os
6 | import time
7 | import threading
8 | import uuid
9 | import ipaddress
10 | import math
11 | import queue
12 | from werkzeug.middleware.proxy_fix import ProxyFix
13 | from flask_socketio import SocketIO, emit
14 |
15 | app = Flask(__name__)
16 | app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1)
17 | app.config['SECRET_KEY'] = os.urandom(24)
18 | socketio = SocketIO(app,
19 | cors_allowed_origins="*",
20 | async_mode='gevent',
21 | logger=True,
22 | engineio_logger=True,
23 | ping_timeout=60,
24 | ping_interval=25,
25 | always_connect=True)
26 |
27 | # 存储活动扫描任务
28 | active_scans = {}
29 |
30 | # 默认并行任务数
31 | DEFAULT_PARALLEL_TASKS = 8
32 | MIN_PARALLEL_TASKS = 4
33 | MAX_PARALLEL_TASKS = 16
34 |
35 | # 安全考虑:限制允许扫描的端口范围和常见选项
36 | ALLOWED_OPTIONS = {
37 | # 扫描类型
38 | "-sS": "SYN扫描",
39 | "-sT": "TCP连接扫描",
40 | "-sU": "UDP扫描",
41 | "-sV": "服务版本检测",
42 | "-O": "操作系统检测",
43 | "-A": "综合扫描",
44 |
45 | # 扫描速度
46 | "-T0": "偷偷摸摸扫描",
47 | "-T1": "鬼鬼祟祟扫描",
48 | "-T2": "礼貌扫描",
49 | "-T3": "普通扫描",
50 | "-T4": "激进扫描"
51 | }
52 |
53 | @app.route('/')
54 | def index():
55 | return render_template('index.html')
56 |
57 | def is_valid_target(target):
58 | pattern = r'^[a-zA-Z0-9][a-zA-Z0-9\.\-\/]+$'
59 | return bool(re.match(pattern, target))
60 |
61 | def is_valid_ports(ports):
62 | if not ports:
63 | return True
64 |
65 | if ports == "all" or ports == "-":
66 | return True
67 |
68 | pattern = r'^(?:\d+(?:-\d+)?(?:,\d+(?:-\d+)?)*)?$'
69 | return bool(re.match(pattern, ports))
70 |
71 | def split_ip_range(target, num_chunks):
72 | try:
73 | if '/' in target:
74 | network = ipaddress.ip_network(target, strict=False)
75 | total_ips = network.num_addresses
76 | if total_ips < num_chunks:
77 | num_chunks = max(1, total_ips)
78 | ips_per_chunk = math.ceil(total_ips / num_chunks)
79 |
80 | chunks = []
81 | start_ip = int(network.network_address)
82 | for i in range(num_chunks):
83 | chunk_start = start_ip + i * ips_per_chunk
84 | chunk_end = min(start_ip + (i + 1) * ips_per_chunk - 1, int(network.broadcast_address))
85 |
86 | if chunk_start <= chunk_end:
87 | start_ip_obj = ipaddress.ip_address(chunk_start)
88 | end_ip_obj = ipaddress.ip_address(chunk_end)
89 |
90 | if chunk_start == chunk_end:
91 | chunks.append(str(start_ip_obj))
92 | else:
93 | chunks.append(f"{start_ip_obj}-{end_ip_obj}")
94 |
95 | return chunks
96 |
97 | elif '-' in target:
98 | start_ip, end_ip = target.split('-')
99 | start_ip = ipaddress.ip_address(start_ip.strip())
100 | end_ip = ipaddress.ip_address(end_ip.strip())
101 |
102 | total_ips = int(end_ip) - int(start_ip) + 1
103 | if total_ips < num_chunks:
104 | num_chunks = max(1, total_ips)
105 |
106 | ips_per_chunk = math.ceil(total_ips / num_chunks)
107 |
108 | chunks = []
109 | for i in range(num_chunks):
110 | chunk_start = int(start_ip) + i * ips_per_chunk
111 | chunk_end = min(int(start_ip) + (i + 1) * ips_per_chunk - 1, int(end_ip))
112 |
113 | if chunk_start <= chunk_end:
114 | start_ip_obj = ipaddress.ip_address(chunk_start)
115 | end_ip_obj = ipaddress.ip_address(chunk_end)
116 |
117 | if chunk_start == chunk_end:
118 | chunks.append(str(start_ip_obj))
119 | else:
120 | chunks.append(f"{start_ip_obj}-{end_ip_obj}")
121 |
122 | return chunks
123 | except:
124 | pass
125 |
126 | return [target]
127 |
128 | def split_port_range(ports, num_chunks):
129 | if not ports or ports == "all" or ports == "-":
130 | total_ports = 65535
131 | ports_per_chunk = math.ceil(total_ports / num_chunks)
132 |
133 | chunks = []
134 | for i in range(num_chunks):
135 | start_port = i * ports_per_chunk + 1
136 | end_port = min((i + 1) * ports_per_chunk, 65535)
137 | chunks.append(f"{start_port}-{end_port}")
138 |
139 | return chunks
140 |
141 | port_list = []
142 | for port_range in ports.split(','):
143 | if '-' in port_range:
144 | start, end = map(int, port_range.split('-'))
145 | port_list.extend(range(start, end + 1))
146 | else:
147 | port_list.append(int(port_range))
148 |
149 | if len(port_list) < num_chunks:
150 | num_chunks = max(1, len(port_list))
151 |
152 | chunks = []
153 | ports_per_chunk = math.ceil(len(port_list) / num_chunks)
154 |
155 | for i in range(num_chunks):
156 | start_idx = i * ports_per_chunk
157 | end_idx = min((i + 1) * ports_per_chunk, len(port_list))
158 |
159 | if start_idx < len(port_list):
160 | chunk_ports = sorted(port_list[start_idx:end_idx])
161 | if not chunk_ports:
162 | continue
163 |
164 | ranges = []
165 | range_start = chunk_ports[0]
166 | prev = chunk_ports[0]
167 |
168 | for port in chunk_ports[1:]:
169 | if port == prev + 1:
170 | prev = port
171 | continue
172 |
173 | if range_start == prev:
174 | ranges.append(str(range_start))
175 | else:
176 | ranges.append(f"{range_start}-{prev}")
177 |
178 | range_start = port
179 | prev = port
180 |
181 | if range_start == prev:
182 | ranges.append(str(range_start))
183 | else:
184 | ranges.append(f"{range_start}-{prev}")
185 |
186 | chunks.append(','.join(ranges))
187 |
188 | return chunks
189 |
190 | def execute_nmap_scan(scan_id, target, ports, options, task_id, result_queue):
191 | try:
192 | command = ["nmap"] + options
193 |
194 | if ports:
195 | command.extend(["-p", ports])
196 |
197 | command.append(target)
198 | command_str = " ".join(command)
199 |
200 | socketio.emit('scan_update', {
201 | 'scan_id': scan_id,
202 | 'task_id': task_id,
203 | 'status': 'task_running',
204 | 'message': f'子任务 {task_id}: 扫描 {target} 端口 {ports if ports else "默认"}...',
205 | 'command': command_str
206 | }, room=scan_id)
207 |
208 | process = subprocess.Popen(
209 | command,
210 | stdout=subprocess.PIPE,
211 | stderr=subprocess.PIPE,
212 | text=True,
213 | bufsize=1
214 | )
215 |
216 | results = []
217 | errors = []
218 |
219 | for line in iter(process.stdout.readline, ''):
220 | results.append(line)
221 | if len(results) % 10 == 0:
222 | socketio.emit('scan_update', {
223 | 'scan_id': scan_id,
224 | 'task_id': task_id,
225 | 'status': 'task_progress',
226 | 'partial_result': line
227 | }, room=scan_id)
228 |
229 | for line in iter(process.stderr.readline, ''):
230 | errors.append(line)
231 |
232 | process.wait()
233 |
234 | result_data = {
235 | 'task_id': task_id,
236 | 'target': target,
237 | 'ports': ports,
238 | 'command': command_str,
239 | 'success': process.returncode == 0,
240 | 'result': ''.join(results),
241 | 'error': ''.join(errors)
242 | }
243 |
244 | result_queue.put(result_data)
245 |
246 | socketio.emit('scan_update', {
247 | 'scan_id': scan_id,
248 | 'task_id': task_id,
249 | 'status': 'task_completed',
250 | 'message': f'子任务 {task_id} 已完成'
251 | }, room=scan_id)
252 |
253 | except Exception as e:
254 | error_msg = str(e)
255 | socketio.emit('scan_update', {
256 | 'scan_id': scan_id,
257 | 'task_id': task_id,
258 | 'status': 'task_error',
259 | 'message': f'子任务 {task_id} 出错: {error_msg}'
260 | }, room=scan_id)
261 |
262 | result_queue.put({
263 | 'task_id': task_id,
264 | 'target': target,
265 | 'ports': ports,
266 | 'success': False,
267 | 'error': error_msg
268 | })
269 |
270 | def run_nmap_scan(scan_id, target, ports, selected_options, scan_all_ports, parallel_tasks):
271 | try:
272 | num_threads = min(parallel_tasks, MAX_PARALLEL_TASKS)
273 |
274 | options = list(selected_options)
275 |
276 | if scan_all_ports:
277 | ports = "-"
278 |
279 | socketio.emit('scan_update', {
280 | 'scan_id': scan_id,
281 | 'status': 'starting',
282 | 'message': f'正在使用 {num_threads} 个线程开始扫描...',
283 | 'threads': num_threads
284 | }, room=scan_id)
285 |
286 | targets = split_ip_range(target, num_threads)
287 |
288 | if len(targets) == 1:
289 | port_chunks = split_port_range(ports, num_threads)
290 | tasks = [(targets[0], port) for port in port_chunks]
291 | else:
292 | tasks = [(t, ports) for t in targets]
293 |
294 | result_queue = queue.Queue()
295 |
296 | threads = []
297 |
298 | active_scans[scan_id]['total_tasks'] = len(tasks)
299 | active_scans[scan_id]['completed_tasks'] = 0
300 | active_scans[scan_id]['tasks'] = {}
301 |
302 | task_info = []
303 | for i, (task_target, task_ports) in enumerate(tasks):
304 | task_id = f"task_{i+1}"
305 | task_info.append({
306 | 'task_id': task_id,
307 | 'target': task_target,
308 | 'ports': task_ports if task_ports else '默认端口'
309 | })
310 |
311 | active_scans[scan_id]['tasks'][task_id] = {
312 | 'status': 'pending',
313 | 'target': task_target,
314 | 'ports': task_ports
315 | }
316 |
317 | socketio.emit('scan_update', {
318 | 'scan_id': scan_id,
319 | 'status': 'tasks_created',
320 | 'message': f'已创建 {len(tasks)} 个扫描子任务',
321 | 'tasks': task_info
322 | }, room=scan_id)
323 |
324 | for i, (task_target, task_ports) in enumerate(tasks):
325 | task_id = f"task_{i+1}"
326 | thread = threading.Thread(
327 | target=execute_nmap_scan,
328 | args=(scan_id, task_target, task_ports, options, task_id, result_queue)
329 | )
330 | thread.daemon = True
331 | threads.append(thread)
332 |
333 | for thread in threads:
334 | thread.start()
335 | time.sleep(0.2)
336 |
337 | for thread in threads:
338 | thread.join()
339 |
340 | all_results = []
341 | all_errors = []
342 |
343 | while not result_queue.empty():
344 | result = result_queue.get()
345 |
346 | if result.get('success', False):
347 | all_results.append({
348 | 'target': result.get('target', ''),
349 | 'ports': result.get('ports', ''),
350 | 'result': result.get('result', '')
351 | })
352 | else:
353 | all_errors.append(result.get('error', ''))
354 |
355 | if all_errors:
356 | socketio.emit('scan_update', {
357 | 'scan_id': scan_id,
358 | 'status': 'error',
359 | 'message': '扫描过程中出现错误',
360 | 'error': '\n'.join(all_errors)
361 | }, room=scan_id)
362 | else:
363 | combined_result = merge_scan_results(all_results)
364 |
365 | socketio.emit('scan_update', {
366 | 'scan_id': scan_id,
367 | 'status': 'completed',
368 | 'message': '扫描完成',
369 | 'result': combined_result
370 | }, room=scan_id)
371 |
372 | except Exception as e:
373 | socketio.emit('scan_update', {
374 | 'scan_id': scan_id,
375 | 'status': 'error',
376 | 'message': f'扫描过程中出错: {str(e)}'
377 | }, room=scan_id)
378 |
379 | if scan_id in active_scans:
380 | del active_scans[scan_id]
381 |
382 | def merge_scan_results(results):
383 | if not results:
384 | return ""
385 |
386 | if len(results) == 1:
387 | return results[0]['result']
388 |
389 | open_ports = {}
390 | scan_headers = {}
391 | scan_footers = {}
392 |
393 | header_pattern = re.compile(r'Starting Nmap.*?(?=PORT)', re.DOTALL)
394 | ports_pattern = re.compile(r'PORT\s+STATE\s+SERVICE.*?(?=\n\n|\nNmap done:)', re.DOTALL)
395 | footer_pattern = re.compile(r'Nmap done:.*$', re.DOTALL)
396 | port_line_pattern = re.compile(r'^(\d+/\w+)\s+(\w+)\s+(.*)$', re.MULTILINE)
397 |
398 | for result_data in results:
399 | result_text = result_data['result']
400 | target = result_data['target']
401 |
402 | header_match = header_pattern.search(result_text)
403 | if header_match:
404 | header = header_match.group(0).strip()
405 | scan_headers[target] = header
406 |
407 | ports_match = ports_pattern.search(result_text)
408 | if ports_match:
409 | ports_section = ports_match.group(0)
410 | port_lines = ports_section.split('\n')[1:]
411 |
412 | for line in port_lines:
413 | port_match = port_line_pattern.match(line.strip())
414 | if port_match:
415 | port, state, service = port_match.groups()
416 | if target not in open_ports:
417 | open_ports[target] = {}
418 | open_ports[target][port] = {'state': state, 'service': service}
419 |
420 | footer_match = footer_pattern.search(result_text)
421 | if footer_match:
422 | footer = footer_match.group(0).strip()
423 | scan_footers[target] = footer
424 |
425 | combined_output = []
426 |
427 | if scan_headers:
428 | main_header = next(iter(scan_headers.values()))
429 | combined_output.append(main_header)
430 | combined_output.append("\nPORT\tSTATE\tSERVICE")
431 |
432 | for target, ports in open_ports.items():
433 | if len(open_ports) > 1:
434 | combined_output.append(f"\n目标: {target}")
435 |
436 | for port, info in sorted(ports.items(), key=lambda x: int(x[0].split('/')[0])):
437 | combined_output.append(f"{port}\t{info['state']}\t{info['service']}")
438 |
439 | if scan_footers:
440 | total_time = 0
441 | total_hosts = 0
442 | for footer in scan_footers.values():
443 | time_match = re.search(r'scanned in ([\d\.]+) seconds', footer)
444 | if time_match:
445 | total_time += float(time_match.group(1))
446 |
447 | hosts_match = re.search(r'(\d+) IP address', footer)
448 | if hosts_match:
449 | total_hosts += int(hosts_match.group(1))
450 |
451 | combined_output.append(f"\nNmap 多线程扫描完成: 总共扫描 {total_hosts} 个IP地址,用时 {total_time:.2f} 秒")
452 |
453 | return "\n".join(combined_output)
454 |
455 | @socketio.on('connect')
456 | def handle_connect():
457 | print(f"客户端已连接: {request.sid}, 传输方式: {request.environ.get('wsgi.websocket') and 'WebSocket' or '轮询'}")
458 | emit('connection_response', {
459 | 'status': 'connected',
460 | 'transport': request.environ.get('wsgi.websocket') and 'websocket' or 'polling',
461 | 'sid': request.sid
462 | })
463 |
464 | @socketio.on('join_scan')
465 | def handle_join(data):
466 | scan_id = data['scan_id']
467 | if scan_id:
468 | print(f"Client joined scan room: {scan_id}")
469 | socketio.server.enter_room(request.sid, scan_id)
470 |
471 | @socketio.on('start_scan')
472 | def handle_scan_request(data):
473 | if not data:
474 | emit('scan_update', {'status': 'error', 'message': '没有提供数据'})
475 | return
476 |
477 | target = data.get('target', '')
478 | ports = data.get('ports', '')
479 | selected_options = data.get('options', [])
480 | scan_all_ports = data.get('scan_all_ports', False)
481 | parallel_tasks = data.get('parallel_tasks', DEFAULT_PARALLEL_TASKS)
482 |
483 | try:
484 | parallel_tasks = int(parallel_tasks)
485 | parallel_tasks = max(MIN_PARALLEL_TASKS, min(parallel_tasks, MAX_PARALLEL_TASKS))
486 | except:
487 | parallel_tasks = DEFAULT_PARALLEL_TASKS
488 |
489 | if not target or not is_valid_target(target):
490 | emit('scan_update', {'status': 'error', 'message': '无效的目标格式'})
491 | return
492 |
493 | if not is_valid_ports(ports):
494 | emit('scan_update', {'status': 'error', 'message': '无效的端口格式'})
495 | return
496 |
497 | valid_options = []
498 | for option in selected_options:
499 | if option in ALLOWED_OPTIONS:
500 | valid_options.append(option)
501 | else:
502 | emit('scan_update', {'status': 'error', 'message': f'不允许的选项: {option}'})
503 | return
504 |
505 | scan_id = str(uuid.uuid4())
506 |
507 | socketio.server.enter_room(request.sid, scan_id)
508 |
509 | scan_thread = threading.Thread(
510 | target=run_nmap_scan,
511 | args=(scan_id, target, ports, valid_options, scan_all_ports, parallel_tasks)
512 | )
513 | scan_thread.daemon = True
514 |
515 | active_scans[scan_id] = {
516 | 'thread': scan_thread,
517 | 'start_time': time.time(),
518 | 'target': target,
519 | 'parallel_tasks': parallel_tasks
520 | }
521 |
522 | scan_thread.start()
523 |
524 | emit('scan_started', {
525 | 'scan_id': scan_id,
526 | 'parallel_tasks': parallel_tasks
527 | })
528 |
529 | @socketio.on('cancel_scan')
530 | def handle_cancel_scan(data):
531 | scan_id = data.get('scan_id')
532 | if scan_id in active_scans:
533 | active_scans[scan_id]['cancelled'] = True
534 | emit('scan_update', {
535 | 'scan_id': scan_id,
536 | 'status': 'cancelled',
537 | 'message': '扫描已取消'
538 | }, room=scan_id)
539 |
540 | @app.route('/scan', methods=['POST'])
541 | def scan():
542 | data = request.get_json()
543 |
544 | if not data:
545 | return jsonify({"error": "没有提供数据"}), 400
546 |
547 | target = data.get('target', '')
548 | ports = data.get('ports', '')
549 | selected_options = data.get('options', [])
550 | scan_all_ports = data.get('scan_all_ports', False)
551 |
552 | if not target or not is_valid_target(target):
553 | return jsonify({"error": "无效的目标格式"}), 400
554 |
555 | if not is_valid_ports(ports):
556 | return jsonify({"error": "无效的端口格式"}), 400
557 |
558 | for option in selected_options:
559 | if option not in ALLOWED_OPTIONS:
560 | return jsonify({"error": f"不允许的选项: {option}"}), 400
561 |
562 | command = ["nmap"]
563 |
564 | for option in selected_options:
565 | command.append(option)
566 |
567 | if scan_all_ports:
568 | command.extend(["-p-"])
569 | elif ports:
570 | command.extend(["-p", ports])
571 |
572 | command.append(target)
573 |
574 | try:
575 | process = subprocess.run(
576 | command,
577 | capture_output=True,
578 | text=True,
579 | timeout=600
580 | )
581 |
582 | if process.returncode != 0:
583 | return jsonify({
584 | "error": "扫描失败",
585 | "message": process.stderr
586 | }), 500
587 |
588 | return jsonify({
589 | "result": process.stdout,
590 | "command": " ".join(command)
591 | })
592 |
593 | except subprocess.TimeoutExpired:
594 | return jsonify({"error": "扫描超时"}), 408
595 | except Exception as e:
596 | return jsonify({"error": f"扫描过程中出错: {str(e)}"}), 500
597 |
598 | if __name__ == '__main__':
599 | socketio.run(app, host='0.0.0.0', port=5000, debug=False, allow_unsafe_werkzeug=True)
--------------------------------------------------------------------------------
/app/static/js/script.js:
--------------------------------------------------------------------------------
1 | document.addEventListener('DOMContentLoaded', function() {
2 | const scanForm = document.getElementById('scanForm');
3 | const scanButton = document.getElementById('scanButton');
4 | const clearButton = document.getElementById('clearButton');
5 | const loading = document.getElementById('loading');
6 | const results = document.getElementById('results');
7 | const error = document.getElementById('error');
8 | const resultsContent = document.getElementById('resultsContent');
9 | const errorMessage = document.getElementById('errorMessage');
10 | const scanWarning = document.getElementById('scanWarning');
11 | const portsInput = document.getElementById('ports');
12 | const scanAllPortsCheckbox = document.getElementById('scanAllPorts');
13 | const target = document.getElementById('target');
14 | const optionCheckboxes = document.querySelectorAll('input[name="options"]');
15 | const tasksGrid = document.getElementById('tasksGrid');
16 | const threadsInUse = document.getElementById('threadsInUse');
17 | const parallelTasksSlider = document.getElementById('parallelTasksSlider');
18 | const parallelTasks = document.getElementById('parallelTasks');
19 | const threadModes = document.querySelectorAll('.thread-mode');
20 |
21 | const DEFAULT_THREADS = 8;
22 | const MIN_THREADS = 4;
23 | const MAX_THREADS = 16;
24 |
25 | const TASK_STATUS = {
26 | 'pending': '等待中',
27 | 'task_running': '扫描中',
28 | 'task_progress': '扫描中',
29 | 'task_completed': '已完成',
30 | 'task_error': '出错'
31 | };
32 |
33 | let socket;
34 | let currentScanId = null;
35 | let reconnectAttempts = 0;
36 | let isConnected = false;
37 | const maxReconnectAttempts = 5;
38 |
39 | let activeTasks = {};
40 | let totalTasks = 0;
41 | let completedTasks = 0;
42 |
43 | const progressContainer = document.createElement('div');
44 | progressContainer.className = 'progress-container';
45 | progressContainer.innerHTML = `
46 |
49 |
50 |
扫描进度: 0%
51 |
0/0 任务
52 |
53 | `;
54 | loading.insertBefore(progressContainer, document.querySelector('#tasksOverview'));
55 |
56 | const progressFill = document.getElementById('progressFill');
57 | const progressText = document.getElementById('progressText');
58 | const completedTasksCount = document.getElementById('completedTasksCount');
59 | const totalTasksCount = document.getElementById('totalTasksCount');
60 |
61 | const liveOutputContainer = document.createElement('div');
62 | liveOutputContainer.className = 'live-output';
63 | liveOutputContainer.innerHTML = `
64 | 实时扫描输出
65 |
66 | `;
67 | loading.appendChild(liveOutputContainer);
68 |
69 | const liveOutput = document.getElementById('liveOutput');
70 |
71 | const cancelButton = document.createElement('button');
72 | cancelButton.type = 'button';
73 | cancelButton.id = 'cancelScanButton';
74 | cancelButton.className = 'btn cancel-btn';
75 | cancelButton.innerHTML = ' 取消扫描';
76 | cancelButton.style.display = 'none';
77 | loading.appendChild(cancelButton);
78 |
79 | const formElements = document.querySelectorAll('.form-group, .form-actions');
80 | formElements.forEach((element, index) => {
81 | element.style.opacity = '0';
82 | element.style.transform = 'translateY(20px)';
83 | element.style.transition = 'opacity 0.4s ease, transform 0.4s ease';
84 |
85 | setTimeout(() => {
86 | element.style.opacity = '1';
87 | element.style.transform = 'translateY(0)';
88 | }, 100 + index * 100);
89 | });
90 |
91 | function initThreadControl() {
92 | parallelTasksSlider.addEventListener('input', function() {
93 | parallelTasks.value = this.value;
94 | updateThreadModeSelection(this.value);
95 | threadsInUse.textContent = this.value;
96 | });
97 |
98 | parallelTasks.addEventListener('input', function() {
99 | let value = parseInt(this.value);
100 | if (isNaN(value) || value < MIN_THREADS) value = MIN_THREADS;
101 | if (value > MAX_THREADS) value = MAX_THREADS;
102 |
103 | this.value = value;
104 | parallelTasksSlider.value = value;
105 | updateThreadModeSelection(value);
106 | threadsInUse.textContent = value;
107 | });
108 |
109 | threadModes.forEach(mode => {
110 | mode.addEventListener('click', function() {
111 | const value = parseInt(this.dataset.value);
112 | parallelTasksSlider.value = value;
113 | parallelTasks.value = value;
114 | updateThreadModeSelection(value);
115 | threadsInUse.textContent = value;
116 | });
117 | });
118 |
119 | updateThreadModeSelection(DEFAULT_THREADS);
120 | }
121 |
122 | function updateThreadModeSelection(value) {
123 | threadModes.forEach(mode => {
124 | if (parseInt(mode.dataset.value) === parseInt(value)) {
125 | mode.classList.add('active');
126 | } else {
127 | mode.classList.remove('active');
128 | }
129 | });
130 | }
131 |
132 | function initWebSocket() {
133 | if (socket && socket.connected) {
134 | return;
135 | }
136 | const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
137 | console.log(`使用WebSocket协议: ${protocol}`);
138 |
139 | const host = window.location.host;
140 | const wsUrl = `${protocol}//${host}`;
141 | console.log(`WebSocket连接URL: ${wsUrl}`);
142 |
143 | socket = io(wsUrl, {
144 | reconnection: true,
145 | reconnectionDelay: 1000,
146 | reconnectionDelayMax: 5000,
147 | reconnectionAttempts: maxReconnectAttempts,
148 | transports: ['websocket'],
149 | upgrade: false
150 | });
151 |
152 | socket.on('connect', function() {
153 | console.log('WebSocket连接已建立');
154 | isConnected = true;
155 | reconnectAttempts = 0;
156 |
157 | updateSocketStatus(true, protocol === 'wss:');
158 |
159 | if (currentScanId) {
160 | joinScanRoom(currentScanId);
161 | }
162 | });
163 |
164 | socket.on('connection_response', function(data) {
165 | console.log('连接状态:', data.status);
166 | console.log('传输方式:', data.transport);
167 | console.log('会话ID:', data.sid);
168 |
169 | const isWebSocket = data.transport === 'websocket';
170 | const isSecure = window.location.protocol === 'https:';
171 |
172 | updateSocketStatus(true, isWebSocket && isSecure);
173 |
174 | showInfo(`已连接到服务器`);
175 | });
176 |
177 | socket.on('scan_started', function(data) {
178 | console.log('扫描已开始,ID:', data.scan_id, '线程数:', data.parallel_tasks);
179 | currentScanId = data.scan_id;
180 | threadsInUse.textContent = data.parallel_tasks;
181 |
182 | sessionStorage.setItem('currentScanId', currentScanId);
183 | });
184 |
185 | socket.on('scan_update', function(data) {
186 | console.log('扫描更新:', data.status, data);
187 |
188 | switch(data.status) {
189 | case 'starting':
190 | updateProgress(5);
191 | updateLiveOutput(`正在使用 ${data.threads} 个线程开始扫描...\n`);
192 | break;
193 |
194 | case 'tasks_created':
195 | activeTasks = {};
196 | totalTasks = data.tasks.length;
197 | completedTasks = 0;
198 |
199 | totalTasksCount.textContent = totalTasks;
200 | completedTasksCount.textContent = completedTasks;
201 |
202 | createTaskCards(data.tasks);
203 | updateLiveOutput(`已创建 ${totalTasks} 个扫描子任务\n`);
204 | break;
205 |
206 | case 'task_running':
207 | updateTaskStatus(data.task_id, 'running');
208 | updateLiveOutput(`${data.message}\n`);
209 | break;
210 |
211 | case 'task_progress':
212 | if (data.partial_result) {
213 | updateLiveOutput(data.partial_result);
214 | }
215 | increaseProgress();
216 | break;
217 |
218 | case 'task_completed':
219 | updateTaskStatus(data.task_id, 'completed');
220 | completedTasks++;
221 | completedTasksCount.textContent = completedTasks;
222 |
223 | const percentComplete = Math.round((completedTasks / totalTasks) * 100);
224 | updateProgress(Math.min(percentComplete, 99));
225 | break;
226 |
227 | case 'task_error':
228 | updateTaskStatus(data.task_id, 'error');
229 | updateLiveOutput(`错误: ${data.message}\n`);
230 | break;
231 |
232 | case 'completed':
233 | hideLoading();
234 | showResults(data.result);
235 | resetScanButton();
236 | currentScanId = null;
237 | sessionStorage.removeItem('currentScanId');
238 | break;
239 |
240 | case 'error':
241 | hideLoading();
242 | showError(data.message || data.error);
243 | resetScanButton();
244 | currentScanId = null;
245 | sessionStorage.removeItem('currentScanId');
246 | break;
247 |
248 | case 'cancelled':
249 | hideLoading();
250 | showInfo('扫描已取消');
251 | resetScanButton();
252 | currentScanId = null;
253 | sessionStorage.removeItem('currentScanId');
254 | break;
255 | }
256 | });
257 |
258 | socket.on('disconnect', function() {
259 | console.log('WebSocket连接已断开');
260 | isConnected = false;
261 | updateSocketStatus(false, false);
262 |
263 | if (reconnectAttempts < maxReconnectAttempts) {
264 | console.log(`尝试重新连接 (${reconnectAttempts + 1}/${maxReconnectAttempts})...`);
265 | reconnectAttempts++;
266 | } else if (currentScanId) {
267 | hideLoading();
268 | showError('与服务器的连接已丢失,无法接收实时扫描更新。请检查您的网络连接并重试。');
269 | resetScanButton();
270 | }
271 | });
272 | }
273 |
274 | function updateSocketStatus(connected, isSecure) {
275 | const statusIndicator = document.getElementById('socketStatus');
276 | if (connected) {
277 | statusIndicator.classList.add('connected');
278 | statusIndicator.title = isSecure ?
279 | '已建立安全WebSocket连接 (WSS)' :
280 | '已建立WebSocket连接 (WS)';
281 |
282 | if (isSecure) {
283 | statusIndicator.classList.add('secure');
284 | } else {
285 | statusIndicator.classList.remove('secure');
286 | }
287 | } else {
288 | statusIndicator.classList.remove('connected', 'secure');
289 | statusIndicator.title = '未连接到服务器';
290 | }
291 | }
292 |
293 | function createTaskCards(tasks) {
294 | tasksGrid.innerHTML = '';
295 |
296 | tasks.forEach(task => {
297 | const taskCard = document.createElement('div');
298 | taskCard.className = 'task-item';
299 | taskCard.id = `task-${task.task_id}`;
300 |
301 | taskCard.innerHTML = `
302 |
303 | ${task.task_id}
304 |
305 |
306 |
307 | ${task.target}
308 |
309 |
310 | ${task.ports}
311 |
312 | `;
313 |
314 | tasksGrid.appendChild(taskCard);
315 |
316 | // 记录任务状态
317 | activeTasks[task.task_id] = {
318 | status: 'pending',
319 | element: taskCard
320 | };
321 | });
322 | }
323 |
324 | // 更新任务状态
325 | function updateTaskStatus(taskId, status) {
326 | if (activeTasks[taskId]) {
327 | activeTasks[taskId].status = status;
328 |
329 | const taskCard = document.getElementById(`task-${taskId}`);
330 | if (taskCard) {
331 | const statusElement = taskCard.querySelector('.task-status');
332 |
333 | // 移除所有状态类
334 | statusElement.classList.remove('pending', 'running', 'completed', 'error');
335 |
336 | // 添加新状态类
337 | statusElement.classList.add(status);
338 |
339 | // 更新提示文本
340 | statusElement.title = TASK_STATUS[status] || status;
341 | }
342 | }
343 | }
344 |
345 | // 加入扫描房间
346 | function joinScanRoom(scanId) {
347 | if (socket && socket.connected) {
348 | socket.emit('join_scan', { scan_id: scanId });
349 | }
350 | }
351 |
352 | // 端口输入与全端口扫描选项互斥
353 | scanAllPortsCheckbox.addEventListener('change', function() {
354 | if (this.checked) {
355 | portsInput.disabled = true;
356 | portsInput.placeholder = "已选择全端口扫描";
357 | portsInput.parentElement.classList.add('disabled');
358 | } else {
359 | portsInput.disabled = false;
360 | portsInput.placeholder = "例如: 80,443 或 1-1000";
361 | portsInput.parentElement.classList.remove('disabled');
362 | }
363 | });
364 |
365 | // 为选项添加动画效果
366 | const scanTypeRadios = document.querySelectorAll('input[name="scan_type"]');
367 | const scanSpeedRadios = document.querySelectorAll('input[name="scan_speed"]');
368 |
369 | function addRadioAnimations(radioButtons) {
370 | // 清除所有选中样式的函数
371 | function clearSelectionStyles(name) {
372 | document.querySelectorAll(`input[name="${name}"]`).forEach(radio => {
373 | const item = radio.closest('.option-item');
374 | if (item) {
375 | item.classList.remove('option-selected');
376 | }
377 | });
378 | }
379 |
380 | radioButtons.forEach(radio => {
381 | radio.addEventListener('change', function() {
382 | // 清除同组中所有单选按钮的选中样式
383 | clearSelectionStyles(this.name);
384 |
385 | if (this.checked) {
386 | const optionItem = this.closest('.option-item');
387 |
388 | // 添加选中样式
389 | optionItem.classList.add('option-selected');
390 |
391 | // 添加一个简单的选中动画
392 | const ripple = document.createElement('span');
393 | ripple.classList.add('option-ripple');
394 | optionItem.appendChild(ripple);
395 |
396 | setTimeout(() => {
397 | ripple.remove();
398 | }, 500);
399 | }
400 | });
401 |
402 | // 初始化选中状态
403 | if (radio.checked) {
404 | radio.closest('.option-item').classList.add('option-selected');
405 | }
406 | });
407 | }
408 |
409 | // 初始化单选按钮动画
410 | addRadioAnimations(scanTypeRadios);
411 | addRadioAnimations(scanSpeedRadios);
412 |
413 | // 表单验证
414 | target.addEventListener('input', validateForm);
415 | portsInput.addEventListener('input', validateForm);
416 | scanAllPortsCheckbox.addEventListener('change', validateForm);
417 |
418 | function validateForm() {
419 | const targetValue = target.value.trim();
420 | const isValid = targetValue.length > 0;
421 |
422 | if (isValid) {
423 | scanButton.disabled = false;
424 | target.classList.remove('input-error');
425 | } else {
426 | scanButton.disabled = true;
427 | if (targetValue === '' && target.classList.contains('was-validated')) {
428 | target.classList.add('input-error');
429 | }
430 | }
431 | }
432 |
433 | target.addEventListener('blur', function() {
434 | this.classList.add('was-validated');
435 | validateForm();
436 | });
437 |
438 | // 表单提交处理
439 | scanForm.addEventListener('submit', function(e) {
440 | e.preventDefault();
441 |
442 | // 检查WebSocket连接
443 | if (!socket || !socket.connected) {
444 | showError('未能连接到扫描服务器,请刷新页面重试');
445 | return;
446 | }
447 |
448 | // 添加表单提交动画
449 | scanButton.innerHTML = ' 扫描中...';
450 |
451 | // 收集表单数据
452 | const targetValue = target.value.trim();
453 | const ports = portsInput.value.trim();
454 | const scanAllPorts = scanAllPortsCheckbox.checked;
455 | const threadCount = parseInt(parallelTasks.value) || DEFAULT_THREADS;
456 |
457 | // 获取选中的扫描类型和速度
458 | const selectedScanType = document.querySelector('input[name="scan_type"]:checked')?.value || "-sS";
459 | const selectedScanSpeed = document.querySelector('input[name="scan_speed"]:checked')?.value || "-T3";
460 | const selectedOptions = [selectedScanType, selectedScanSpeed];
461 |
462 | // 验证目标
463 | if (!targetValue) {
464 | showError('请输入有效的目标地址');
465 | resetScanButton();
466 | return;
467 | }
468 |
469 | // 显示加载状态与取消按钮
470 | showLoading(scanAllPorts);
471 | cancelButton.style.display = 'inline-block';
472 |
473 | // 重置进度和状态
474 | updateProgress(0);
475 | completedTasks = 0;
476 | totalTasks = 0;
477 | completedTasksCount.textContent = "0";
478 | totalTasksCount.textContent = "0";
479 | tasksGrid.innerHTML = '';
480 | liveOutput.textContent = '等待扫描开始...\n';
481 |
482 | // 准备请求数据
483 | const requestData = {
484 | target: targetValue,
485 | ports: ports,
486 | options: selectedOptions,
487 | scan_all_ports: scanAllPorts,
488 | parallel_tasks: threadCount
489 | };
490 |
491 | // 记住最近扫描的目标 (本地存储)
492 | saveRecentScan(targetValue);
493 |
494 | // 使用WebSocket发送扫描请求
495 | socket.emit('start_scan', requestData);
496 | });
497 |
498 | // 更新进度条
499 | function updateProgress(percentage) {
500 | progressFill.style.width = `${percentage}%`;
501 | progressText.textContent = `${percentage}%`;
502 | }
503 |
504 | // 缓慢增加进度,模拟扫描进展
505 | function increaseProgress() {
506 | if (totalTasks === 0) return; // 防止除以零
507 |
508 | const currentCompleted = completedTasks;
509 | const taskPercentage = (currentCompleted / totalTasks) * 100;
510 |
511 | // 在任务完成百分比和当前进度条之间找一个中间值
512 | const currentWidth = parseFloat(progressFill.style.width) || 0;
513 |
514 | // 如果实际完成的任务百分比大于进度条,直接更新到任务百分比
515 | if (taskPercentage > currentWidth) {
516 | updateProgress(Math.round(taskPercentage));
517 | return;
518 | }
519 |
520 | // 否则微微增加当前进度
521 | let increment;
522 | if (currentWidth < 30) {
523 | increment = 0.5;
524 | } else if (currentWidth < 60) {
525 | increment = 0.3;
526 | } else if (currentWidth < 80) {
527 | increment = 0.2;
528 | } else if (currentWidth < 90) {
529 | increment = 0.1;
530 | } else {
531 | increment = 0.05;
532 | }
533 |
534 | // 不要超过99%,留给最终完成状态
535 | const newWidth = Math.min(99, currentWidth + increment);
536 | updateProgress(Math.round(newWidth));
537 | }
538 |
539 | // 更新实时输出
540 | function updateLiveOutput(text) {
541 | // 将新文本添加到当前内容
542 | liveOutput.textContent += text;
543 |
544 | // 自动滚动到底部
545 | liveOutput.scrollTop = liveOutput.scrollHeight;
546 | }
547 |
548 | function resetScanButton() {
549 | scanButton.innerHTML = ' 开始扫描';
550 | cancelButton.style.display = 'none';
551 | }
552 |
553 | // 取消扫描
554 | cancelButton.addEventListener('click', function() {
555 | if (currentScanId && socket && socket.connected) {
556 | socket.emit('cancel_scan', { scan_id: currentScanId });
557 | }
558 | });
559 |
560 | // 保存最近扫描的目标
561 | function saveRecentScan(targetValue) {
562 | let recentScans = JSON.parse(localStorage.getItem('recentScans') || '[]');
563 | // 避免重复
564 | recentScans = recentScans.filter(scan => scan !== targetValue);
565 | recentScans.unshift(targetValue); // 添加到开头
566 | // 最多保存5个
567 | recentScans = recentScans.slice(0, 5);
568 | localStorage.setItem('recentScans', JSON.stringify(recentScans));
569 | }
570 |
571 | // 清除结果按钮
572 | clearButton.addEventListener('click', function() {
573 | hideResults();
574 | hideError();
575 |
576 | // 添加清除动画效果
577 | this.classList.add('btn-active');
578 | setTimeout(() => {
579 | this.classList.remove('btn-active');
580 | }, 300);
581 | });
582 |
583 | // 展示信息提示
584 | function showInfo(message) {
585 | // 创建一个临时消息提示
586 | const infoToast = document.createElement('div');
587 | infoToast.className = 'info-toast';
588 | infoToast.innerHTML = ` ${message}`;
589 | document.body.appendChild(infoToast);
590 |
591 | // 显示动画
592 | setTimeout(() => {
593 | infoToast.classList.add('show');
594 | }, 10);
595 |
596 | // 自动消失
597 | setTimeout(() => {
598 | infoToast.classList.remove('show');
599 | setTimeout(() => {
600 | document.body.removeChild(infoToast);
601 | }, 300);
602 | }, 3000);
603 | }
604 |
605 | // 展示加载状态
606 | function showLoading(isAllPorts) {
607 | hideResults();
608 | hideError();
609 | loading.style.display = 'block';
610 | scanButton.disabled = true;
611 |
612 | // 淡入动画
613 | loading.style.opacity = 0;
614 | setTimeout(() => {
615 | loading.style.opacity = 1;
616 | }, 10);
617 |
618 | // 如果是全端口扫描,显示额外警告
619 | if (isAllPorts) {
620 | scanWarning.style.display = 'block';
621 | } else {
622 | scanWarning.style.display = 'none';
623 | }
624 | }
625 |
626 | // 隐藏加载状态
627 | function hideLoading() {
628 | loading.style.opacity = 0;
629 | setTimeout(() => {
630 | loading.style.display = 'none';
631 | }, 300);
632 | scanButton.disabled = false;
633 | }
634 |
635 | // 展示结果
636 | function showResults(resultText) {
637 | results.style.display = 'block';
638 | resultsContent.textContent = resultText;
639 |
640 | // 淡入动画
641 | results.style.opacity = 0;
642 | setTimeout(() => {
643 | results.style.opacity = 1;
644 | }, 10);
645 |
646 | // 为结果添加语法高亮
647 | highlightScanResults();
648 |
649 | // 滚动到结果区域
650 | results.scrollIntoView({ behavior: 'smooth', block: 'start' });
651 | }
652 |
653 | // 隐藏结果
654 | function hideResults() {
655 | if (results.style.display !== 'none') {
656 | results.style.opacity = 0;
657 | setTimeout(() => {
658 | results.style.display = 'none';
659 | }, 300);
660 | }
661 | }
662 |
663 | // 展示错误
664 | function showError(message) {
665 | error.style.display = 'block';
666 | errorMessage.textContent = message;
667 |
668 | // 淡入动画
669 | error.style.opacity = 0;
670 | setTimeout(() => {
671 | error.style.opacity = 1;
672 | }, 10);
673 |
674 | // 滚动到错误区域
675 | error.scrollIntoView({ behavior: 'smooth', block: 'start' });
676 | }
677 |
678 | // 隐藏错误
679 | function hideError() {
680 | if (error.style.display !== 'none') {
681 | error.style.opacity = 0;
682 | setTimeout(() => {
683 | error.style.display = 'none';
684 | }, 300);
685 | }
686 | }
687 |
688 | // 简单的结果高亮
689 | function highlightScanResults() {
690 | const content = resultsContent.textContent;
691 | let highlighted = content;
692 |
693 | // 高亮扫描头部
694 | highlighted = highlighted.replace(/(Starting Nmap.*?)(?=\n)/g,
695 | '');
696 |
697 | // 高亮端口状态表头
698 | highlighted = highlighted.replace(/(PORT\s+STATE\s+SERVICE)/g,
699 | '');
700 |
701 | // 高亮目标标记
702 | highlighted = highlighted.replace(/(目标: .*?)(?=\n)/g,
703 | '$1');
704 |
705 | // 高亮端口状态
706 | highlighted = highlighted.replace(/(\d+\/\w+)\s+(open|closed|filtered)\s+(.*?)(?=\n|$)/g, function(match, port, state, service) {
707 | let stateClass = 'hl-state-' + state;
708 | return `${port} ${state} ${service}`;
709 | });
710 |
711 | // 高亮IP地址
712 | highlighted = highlighted.replace(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/g,
713 | '$1');
714 |
715 | // 高亮域名
716 | highlighted = highlighted.replace(/([a-zA-Z0-9][-a-zA-Z0-9]*\.)+[a-zA-Z0-9][-a-zA-Z0-9]*/g, function(match) {
717 | // 避免重复高亮已经处理过的元素
718 | if (match.includes('${match}`;
720 | });
721 |
722 | // 高亮服务版本信息
723 | highlighted = highlighted.replace(/(Running|Service Info):(.*?)(?=\n|$)/g,
724 | '$1:$2');
725 |
726 | // 高亮总结信息
727 | highlighted = highlighted.replace(/(Nmap 多线程扫描完成:.*?)(?=$)/g,
728 | '$1');
729 |
730 | if (highlighted !== content) {
731 | resultsContent.innerHTML = highlighted;
732 | }
733 | }
734 |
735 | // 检查是否有之前的扫描
736 | function checkPreviousScan() {
737 | const savedScanId = sessionStorage.getItem('currentScanId');
738 | if (savedScanId) {
739 | currentScanId = savedScanId;
740 | showLoading(false);
741 | updateLiveOutput('正在恢复之前的扫描状态...\n');
742 |
743 | // 当WebSocket连接建立后会自动加入此房间
744 | }
745 | }
746 |
747 | // 初始化
748 | function init() {
749 | initWebSocket();
750 | initThreadControl();
751 | validateForm();
752 | checkPreviousScan();
753 | }
754 |
755 | // 启动初始化
756 | init();
757 | });
--------------------------------------------------------------------------------
/app/static/css/style.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --primary-color: #2563eb;
3 | --primary-dark: #1d4ed8;
4 | --secondary-color: #3b82f6;
5 | --accent-color: #ef4444;
6 | --dark-bg: #1e293b;
7 | --light-bg: #f1f5f9;
8 | --card-bg: #ffffff;
9 | --text-primary: #1e293b;
10 | --text-secondary: #64748b;
11 | --text-light: #f8fafc;
12 | --border-color: #e2e8f0;
13 | --success-color: #22c55e;
14 | --warning-color: #f59e0b;
15 | --error-color: #ef4444;
16 | }
17 |
18 | * {
19 | margin: 0;
20 | padding: 0;
21 | box-sizing: border-box;
22 | }
23 |
24 | body {
25 | font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Oxygen, Ubuntu, Cantarell, sans-serif;
26 | font-size: 16px;
27 | line-height: 1.6;
28 | color: var(--text-primary);
29 | background-color: var(--light-bg);
30 | background-image: linear-gradient(135deg, #f0f4f8 0%, #d9e2ec 100%);
31 | min-height: 100vh;
32 | }
33 |
34 | .main-wrapper {
35 | min-height: 100vh;
36 | padding: 2rem 1rem;
37 | }
38 |
39 | .container {
40 | max-width: 1100px;
41 | margin: 0 auto;
42 | }
43 |
44 | /* 卡片样式 */
45 | .card {
46 | background-color: var(--card-bg);
47 | border-radius: 12px;
48 | box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
49 | margin-bottom: 2rem;
50 | overflow: hidden;
51 | transition: transform 0.3s ease, box-shadow 0.3s ease;
52 | }
53 |
54 | .card:hover {
55 | transform: translateY(-2px);
56 | box-shadow: 0 6px 24px rgba(0, 0, 0, 0.12);
57 | }
58 |
59 | .card-header {
60 | padding: 1.25rem 1.5rem;
61 | background-color: var(--primary-color);
62 | color: var(--text-light);
63 | font-weight: 600;
64 | font-size: 1.25rem;
65 | display: flex;
66 | align-items: center;
67 | gap: 0.75rem;
68 | }
69 |
70 | .card-body {
71 | padding: 1.5rem;
72 | }
73 |
74 | /* 头部样式 */
75 | header {
76 | text-align: center;
77 | margin-bottom: 2.5rem;
78 | }
79 |
80 | .logo-area {
81 | display: flex;
82 | align-items: center;
83 | justify-content: center;
84 | margin-bottom: 1rem;
85 | gap: 1rem;
86 | }
87 |
88 | .logo-icon {
89 | font-size: 2.5rem;
90 | color: var(--primary-color);
91 | background-color: var(--card-bg);
92 | width: 5rem;
93 | height: 5rem;
94 | border-radius: 50%;
95 | display: flex;
96 | align-items: center;
97 | justify-content: center;
98 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
99 | }
100 |
101 | header h1 {
102 | font-size: 2.5rem;
103 | font-weight: 700;
104 | color: var(--primary-color);
105 | margin: 0;
106 | letter-spacing: -0.5px;
107 | }
108 |
109 | .subtitle {
110 | font-size: 1.25rem;
111 | color: var(--text-secondary);
112 | margin-top: 0.5rem;
113 | }
114 |
115 | /* 表单样式 */
116 | .form-group {
117 | margin-bottom: 1.75rem;
118 | }
119 |
120 | .form-group label {
121 | display: block;
122 | font-size: 1.1rem;
123 | font-weight: 600;
124 | margin-bottom: 0.75rem;
125 | color: var(--text-primary);
126 | display: flex;
127 | align-items: center;
128 | gap: 0.5rem;
129 | }
130 |
131 | .form-group label i {
132 | color: var(--primary-color);
133 | }
134 |
135 | .input-wrapper {
136 | position: relative;
137 | }
138 |
139 | input[type="text"] {
140 | width: 100%;
141 | padding: 0.875rem 1rem;
142 | font-size: 1rem;
143 | border: 2px solid var(--border-color);
144 | border-radius: 8px;
145 | background-color: var(--card-bg);
146 | transition: all 0.2s ease;
147 | color: var(--text-primary);
148 | }
149 |
150 | input[type="text"]:focus {
151 | border-color: var(--secondary-color);
152 | box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
153 | outline: none;
154 | }
155 |
156 | input[type="text"]::placeholder {
157 | color: var(--text-secondary);
158 | opacity: 0.7;
159 | }
160 |
161 | .input-hint {
162 | font-size: 0.875rem;
163 | color: var(--text-secondary);
164 | margin-top: 0.5rem;
165 | }
166 |
167 | /* 端口选项 */
168 | .port-option {
169 | margin-top: 1rem;
170 | display: flex;
171 | align-items: center;
172 | flex-wrap: wrap;
173 | gap: 0.5rem;
174 | padding: 1rem;
175 | background-color: rgba(239, 68, 68, 0.07);
176 | border-radius: 8px;
177 | border-left: 3px solid var(--accent-color);
178 | }
179 |
180 | .port-option input[type="checkbox"] {
181 | width: 1.25rem;
182 | height: 1.25rem;
183 | cursor: pointer;
184 | accent-color: var(--accent-color);
185 | }
186 |
187 | .port-option label {
188 | display: flex;
189 | align-items: center;
190 | margin-bottom: 0;
191 | font-weight: 500;
192 | color: var(--accent-color);
193 | font-size: 1rem;
194 | gap: 0.5rem;
195 | }
196 |
197 | .option-hint {
198 | color: var(--text-secondary);
199 | font-size: 0.875rem;
200 | margin-top: 0.5rem;
201 | margin-left: 1.75rem;
202 | width: 100%;
203 | }
204 |
205 | /* 扫描选项样式 */
206 | .options-container {
207 | display: grid;
208 | grid-template-columns: repeat(3, 1fr);
209 | gap: 0.5rem;
210 | padding: 0.5rem;
211 | background-color: #f8fafc;
212 | border-radius: 8px;
213 | border: 1px solid var(--border-color);
214 | }
215 |
216 | .speed-options {
217 | grid-template-columns: repeat(5, 1fr);
218 | }
219 |
220 | .option-item {
221 | position: relative;
222 | padding: 0.5rem;
223 | border-radius: 6px;
224 | background-color: white;
225 | border: 1px solid var(--border-color);
226 | transition: all 0.2s ease;
227 | }
228 |
229 | .option-item:hover {
230 | border-color: var(--primary-color);
231 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
232 | }
233 |
234 | .option-item.option-selected,
235 | .option-item input[type="radio"]:checked + label {
236 | background-color: #e6f0ff;
237 | border-color: #3b82f6;
238 | }
239 |
240 | .option-item input[type="radio"] {
241 | position: absolute;
242 | opacity: 0;
243 | width: 0;
244 | height: 0;
245 | }
246 |
247 | .option-item input[type="radio"] + label {
248 | display: flex;
249 | flex-direction: column;
250 | cursor: pointer;
251 | width: 100%;
252 | padding-left: 1.25rem;
253 | position: relative;
254 | border-radius: 5px;
255 | transition: background-color 0.2s ease;
256 | }
257 |
258 | .option-item input[type="radio"] + label::before {
259 | content: '';
260 | position: absolute;
261 | left: 0;
262 | top: 0.25rem;
263 | width: 14px;
264 | height: 14px;
265 | border: 2px solid #cbd5e1;
266 | border-radius: 50%;
267 | background-color: white;
268 | transition: all 0.2s ease;
269 | }
270 |
271 | .option-item input[type="radio"]:checked + label::before {
272 | border-color: var(--primary-color);
273 | background-color: var(--primary-color);
274 | box-shadow: inset 0 0 0 2px white;
275 | }
276 |
277 | .option-item input[type="radio"]:checked + label .option-code,
278 | .option-item input[type="radio"]:checked + label .option-desc {
279 | color: var(--primary-color);
280 | font-weight: 600;
281 | }
282 |
283 | .option-code {
284 | font-family: 'Courier New', monospace;
285 | font-weight: bold;
286 | color: var(--primary-color);
287 | margin-bottom: 0.15rem;
288 | font-size: 0.9rem;
289 | text-align: center;
290 | }
291 |
292 | .option-desc {
293 | font-weight: 500;
294 | color: var(--text-primary);
295 | margin-bottom: 0.15rem;
296 | font-size: 0.9rem;
297 | text-align: center;
298 | }
299 |
300 | .option-detail {
301 | font-size: 0.75rem;
302 | color: var(--text-secondary);
303 | line-height: 1.3;
304 | text-align: center;
305 | }
306 |
307 | .option-item input[type="radio"]:checked + label .option-desc {
308 | color: var(--primary-color);
309 | }
310 |
311 | .option-ripple {
312 | position: absolute;
313 | top: 50%;
314 | left: 50%;
315 | transform: translate(-50%, -50%) scale(0);
316 | width: 100%;
317 | height: 100%;
318 | background-color: rgba(59, 130, 246, 0.1);
319 | border-radius: 6px;
320 | animation: ripple 0.5s ease-out forwards;
321 | }
322 |
323 | @keyframes ripple {
324 | to {
325 | transform: translate(-50%, -50%) scale(1);
326 | opacity: 0;
327 | }
328 | }
329 |
330 | /* 按钮样式 */
331 | .form-actions {
332 | display: flex;
333 | gap: 1rem;
334 | margin-top: 2rem;
335 | }
336 |
337 | .btn {
338 | padding: 0.875rem 1.5rem;
339 | font-size: 1rem;
340 | font-weight: 600;
341 | border-radius: 8px;
342 | cursor: pointer;
343 | display: flex;
344 | align-items: center;
345 | justify-content: center;
346 | gap: 0.5rem;
347 | transition: all 0.2s ease;
348 | border: none;
349 | }
350 |
351 | .primary-btn {
352 | background-color: var(--primary-color);
353 | color: white;
354 | min-width: 160px;
355 | }
356 |
357 | .primary-btn:hover {
358 | background-color: var(--primary-dark);
359 | transform: translateY(-2px);
360 | box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);
361 | }
362 |
363 | .primary-btn:disabled {
364 | opacity: 0.7;
365 | cursor: not-allowed;
366 | }
367 |
368 | .secondary-btn {
369 | background-color: #f1f5f9;
370 | color: var(--text-secondary);
371 | border: 1px solid var(--border-color);
372 | }
373 |
374 | .secondary-btn:hover {
375 | background-color: #e2e8f0;
376 | }
377 |
378 | /* 结果区域 */
379 | .results-container {
380 | margin-top: 2rem;
381 | }
382 |
383 | /* 加载动画 */
384 | .loading {
385 | text-align: center;
386 | padding: 2.5rem;
387 | background-color: white;
388 | border-radius: 12px;
389 | box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
390 | }
391 |
392 | .spinner-container {
393 | margin-bottom: 1.5rem;
394 | }
395 |
396 | .spinner {
397 | border: 4px solid rgba(0, 0, 0, 0.1);
398 | border-radius: 50%;
399 | border-top: 4px solid var(--primary-color);
400 | width: 50px;
401 | height: 50px;
402 | animation: spin 1s linear infinite;
403 | margin: 0 auto;
404 | }
405 |
406 | .loading h3 {
407 | color: var(--primary-color);
408 | margin-bottom: 0.75rem;
409 | display: flex;
410 | align-items: center;
411 | justify-content: center;
412 | gap: 0.75rem;
413 | }
414 |
415 | .scan-warning {
416 | color: var(--accent-color);
417 | font-weight: 500;
418 | margin-top: 1rem;
419 | padding: 0.75rem 1rem;
420 | background-color: rgba(239, 68, 68, 0.07);
421 | border-radius: 6px;
422 | display: flex;
423 | align-items: center;
424 | justify-content: center;
425 | gap: 0.5rem;
426 | }
427 |
428 | @keyframes spin {
429 | 0% { transform: rotate(0deg); }
430 | 100% { transform: rotate(360deg); }
431 | }
432 |
433 | /* 结果显示 */
434 | .results-card {
435 | overflow: hidden;
436 | }
437 |
438 | .command-used {
439 | background-color: var(--light-bg);
440 | padding: 1rem 1.25rem;
441 | border-radius: 8px;
442 | margin-bottom: 1.5rem;
443 | font-family: 'Courier New', monospace;
444 | display: flex;
445 | flex-direction: column;
446 | gap: 0.75rem;
447 | }
448 |
449 | .command-label {
450 | font-weight: 600;
451 | color: var(--text-secondary);
452 | display: flex;
453 | align-items: center;
454 | gap: 0.5rem;
455 | }
456 |
457 | .command-used code {
458 | padding: 0.5rem 0.75rem;
459 | background-color: var(--dark-bg);
460 | color: #10b981;
461 | border-radius: 4px;
462 | font-family: 'Courier New', monospace;
463 | overflow-x: auto;
464 | white-space: nowrap;
465 | }
466 |
467 | .results-content {
468 | margin-top: 1rem;
469 | }
470 |
471 | pre {
472 | background-color: var(--dark-bg);
473 | color: var(--text-light);
474 | padding: 1.5rem;
475 | border-radius: 8px;
476 | overflow-x: auto;
477 | white-space: pre-wrap;
478 | word-wrap: break-word;
479 | font-family: 'Courier New', Courier, monospace;
480 | font-size: 0.9rem;
481 | line-height: 1.5;
482 | max-height: 500px;
483 | overflow-y: auto;
484 | }
485 |
486 | /* 错误显示 */
487 | .error-card .card-header {
488 | background-color: var(--error-color);
489 | }
490 |
491 | #errorMessage {
492 | background-color: rgba(239, 68, 68, 0.07);
493 | border-left: 4px solid var(--error-color);
494 | padding: 1rem 1.25rem;
495 | border-radius: 4px;
496 | font-weight: 500;
497 | }
498 |
499 | /* 底部 */
500 | footer {
501 | text-align: center;
502 | padding: 1.5rem 0;
503 | color: var(--text-secondary);
504 | font-size: 0.9rem;
505 | }
506 |
507 | .footer-content {
508 | display: flex;
509 | align-items: center;
510 | justify-content: center;
511 | gap: 0.5rem;
512 | }
513 |
514 | .footer-content i {
515 | color: var(--primary-color);
516 | }
517 |
518 | /* 响应式设计 */
519 | @media (max-width: 768px) {
520 | .main-wrapper {
521 | padding: 1rem;
522 | }
523 |
524 | .form-actions {
525 | flex-direction: column;
526 | }
527 |
528 | .options-container {
529 | grid-template-columns: repeat(2, 1fr);
530 | }
531 |
532 | .speed-options {
533 | grid-template-columns: repeat(3, 1fr);
534 | }
535 |
536 | header h1 {
537 | font-size: 2rem;
538 | }
539 |
540 | .subtitle {
541 | font-size: 1rem;
542 | }
543 |
544 | .btn {
545 | width: 100%;
546 | }
547 | }
548 |
549 | @media (max-width: 480px) {
550 | .options-container {
551 | grid-template-columns: 1fr;
552 | }
553 |
554 | .speed-options {
555 | grid-template-columns: repeat(2, 1fr);
556 | }
557 |
558 | .port-option {
559 | padding: 0.75rem;
560 | }
561 |
562 | .option-hint {
563 | margin-left: 0;
564 | }
565 |
566 | .card-header {
567 | padding: 1rem;
568 | }
569 |
570 | .card-body {
571 | padding: 1rem;
572 | }
573 |
574 | pre {
575 | padding: 1rem;
576 | font-size: 0.8rem;
577 | }
578 | }
579 |
580 | /* 输入验证样式 */
581 | .input-error {
582 | border-color: var(--error-color) !important;
583 | box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.2) !important;
584 | }
585 |
586 | .was-validated .input-wrapper::after {
587 | content: '✓';
588 | position: absolute;
589 | right: 12px;
590 | top: 50%;
591 | transform: translateY(-50%);
592 | color: var(--success-color);
593 | font-weight: bold;
594 | opacity: 0;
595 | transition: opacity 0.3s;
596 | }
597 |
598 | .was-validated input:valid ~ .input-wrapper::after {
599 | opacity: 1;
600 | }
601 |
602 | .disabled {
603 | opacity: 0.6;
604 | }
605 |
606 | /* 选项选中样式 */
607 | .option-selected {
608 | background-color: rgba(37, 99, 235, 0.08);
609 | border-left: 3px solid var(--primary-color);
610 | }
611 |
612 | .option-ripple {
613 | position: absolute;
614 | width: 20px;
615 | height: 20px;
616 | background-color: rgba(37, 99, 235, 0.3);
617 | border-radius: 50%;
618 | transform: scale(0);
619 | animation: ripple 0.5s linear;
620 | left: 10px;
621 | top: 15px;
622 | }
623 |
624 | @keyframes ripple {
625 | to {
626 | transform: scale(3);
627 | opacity: 0;
628 | }
629 | }
630 |
631 | /* 按钮动画 */
632 | .btn-spinner {
633 | width: 16px;
634 | height: 16px;
635 | border: 2px solid rgba(255, 255, 255, 0.3);
636 | border-radius: 50%;
637 | border-top-color: white;
638 | animation: spin 0.8s linear infinite;
639 | margin-right: 8px;
640 | }
641 |
642 | .btn-active {
643 | transform: scale(0.95);
644 | }
645 |
646 | /* 结果高亮 */
647 | .hl-port {
648 | font-weight: 600;
649 | color: #8b5cf6;
650 | }
651 |
652 | .hl-status-open {
653 | color: var(--success-color);
654 | font-weight: 600;
655 | }
656 |
657 | .hl-status-closed {
658 | color: var(--error-color);
659 | }
660 |
661 | .hl-status-filtered {
662 | color: var(--warning-color);
663 | }
664 |
665 | .hl-ip {
666 | font-weight: 600;
667 | color: #ec4899;
668 | }
669 |
670 | .hl-service-label {
671 | font-weight: 600;
672 | color: #f59e0b;
673 | }
674 |
675 | .hl-service {
676 | color: #10b981;
677 | }
678 |
679 | /* 动画和过渡效果 */
680 | .results-card,
681 | .error-card,
682 | .loading {
683 | transition: opacity 0.3s ease;
684 | }
685 |
686 | /* 滚动条美化 */
687 | ::-webkit-scrollbar {
688 | width: 8px;
689 | height: 8px;
690 | }
691 |
692 | ::-webkit-scrollbar-track {
693 | background: #f1f5f9;
694 | border-radius: 4px;
695 | }
696 |
697 | ::-webkit-scrollbar-thumb {
698 | background: #94a3b8;
699 | border-radius: 4px;
700 | }
701 |
702 | ::-webkit-scrollbar-thumb:hover {
703 | background: #64748b;
704 | }
705 |
706 | /* 进度条样式 */
707 | .progress-container {
708 | margin: 1.5rem 0;
709 | }
710 |
711 | .progress-bar {
712 | width: 100%;
713 | height: 12px;
714 | background-color: #e2e8f0;
715 | border-radius: 6px;
716 | overflow: hidden;
717 | }
718 |
719 | .progress-fill {
720 | height: 100%;
721 | width: 0%;
722 | background-color: var(--primary-color);
723 | background-image: linear-gradient(90deg, var(--primary-color), var(--secondary-color));
724 | transition: width 0.3s ease;
725 | border-radius: 6px;
726 | }
727 |
728 | .progress-text {
729 | margin-top: 0.5rem;
730 | font-size: 0.9rem;
731 | color: var(--text-secondary);
732 | text-align: center;
733 | }
734 |
735 | /* 实时输出区域 */
736 | .live-output {
737 | margin-top: 1.5rem;
738 | background-color: var(--dark-bg);
739 | border-radius: 6px;
740 | overflow: hidden;
741 | }
742 |
743 | .live-output h4 {
744 | background-color: rgba(0, 0, 0, 0.3);
745 | color: var(--text-light);
746 | padding: 0.75rem 1rem;
747 | margin: 0;
748 | font-size: 0.9rem;
749 | font-weight: 500;
750 | }
751 |
752 | .live-output-content {
753 | padding: 1rem;
754 | margin: 0;
755 | height: 150px;
756 | overflow-y: auto;
757 | font-family: 'Courier New', Courier, monospace;
758 | font-size: 0.85rem;
759 | line-height: 1.5;
760 | color: #a3e635;
761 | background-color: transparent;
762 | }
763 |
764 | /* 取消扫描按钮 */
765 | .cancel-btn {
766 | background-color: var(--error-color);
767 | color: white;
768 | margin-top: 1rem;
769 | }
770 |
771 | .cancel-btn:hover {
772 | background-color: #dc2626;
773 | }
774 |
775 | /* 全屏模式下的样式调整 */
776 | .fullscreen-mode .live-output-content {
777 | height: 300px;
778 | }
779 |
780 | /* 消息提示 */
781 | .info-toast {
782 | position: fixed;
783 | bottom: 30px;
784 | left: 50%;
785 | transform: translateX(-50%) translateY(100px);
786 | background-color: var(--dark-bg);
787 | color: var(--text-light);
788 | padding: 12px 20px;
789 | border-radius: 8px;
790 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
791 | z-index: 1000;
792 | opacity: 0;
793 | transition: transform 0.4s ease, opacity 0.4s ease;
794 | max-width: 80%;
795 | text-align: center;
796 | display: flex;
797 | align-items: center;
798 | gap: 8px;
799 | }
800 |
801 | .info-toast.show {
802 | transform: translateX(-50%) translateY(0);
803 | opacity: 1;
804 | }
805 |
806 | .info-toast i {
807 | color: var(--secondary-color);
808 | }
809 |
810 | /* WebSocket连接状态指示器 */
811 | .socket-status {
812 | position: fixed;
813 | bottom: 10px;
814 | right: 10px;
815 | width: 12px;
816 | height: 12px;
817 | border-radius: 50%;
818 | background-color: #ef4444;
819 | box-shadow: 0 0 5px rgba(0, 0, 0, 0.3);
820 | z-index: 1000;
821 | transition: all 0.3s ease;
822 | }
823 |
824 | .socket-status.connected {
825 | background-color: #10b981;
826 | animation: pulse-green 2s infinite;
827 | }
828 |
829 | .socket-status.secure {
830 | background-color: #3b82f6;
831 | animation: pulse-blue 2s infinite;
832 | }
833 |
834 | .socket-status:hover::after {
835 | content: attr(title);
836 | position: absolute;
837 | right: 20px;
838 | top: -5px;
839 | background-color: rgba(0, 0, 0, 0.8);
840 | color: white;
841 | padding: 5px 10px;
842 | border-radius: 4px;
843 | font-size: 12px;
844 | white-space: nowrap;
845 | }
846 |
847 | @keyframes pulse-green {
848 | 0% {
849 | box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
850 | }
851 | 70% {
852 | box-shadow: 0 0 0 5px rgba(16, 185, 129, 0);
853 | }
854 | 100% {
855 | box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
856 | }
857 | }
858 |
859 | @keyframes pulse-blue {
860 | 0% {
861 | box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.7);
862 | }
863 | 70% {
864 | box-shadow: 0 0 0 5px rgba(59, 130, 246, 0);
865 | }
866 | 100% {
867 | box-shadow: 0 0 0 0 rgba(59, 130, 246, 0);
868 | }
869 | }
870 |
871 | /* socket.io 必要引入 */
872 | #app-template-section {
873 | display: none;
874 | }
875 |
876 | /* 线程选择样式 */
877 | .threads-container {
878 | padding: 1rem;
879 | background-color: #f8fafc;
880 | border-radius: 8px;
881 | border: 1px solid var(--border-color);
882 | }
883 |
884 | .threads-slider-container {
885 | display: flex;
886 | align-items: center;
887 | gap: 1rem;
888 | margin-bottom: 0.75rem;
889 | }
890 |
891 | input[type="range"] {
892 | flex-grow: 1;
893 | height: 6px;
894 | appearance: none;
895 | background: linear-gradient(to right, var(--primary-color), var(--secondary-color));
896 | outline: none;
897 | border-radius: 3px;
898 | }
899 |
900 | input[type="range"]::-webkit-slider-thumb {
901 | appearance: none;
902 | width: 20px;
903 | height: 20px;
904 | background: #fff;
905 | border: 2px solid var(--primary-color);
906 | border-radius: 50%;
907 | cursor: pointer;
908 | box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
909 | }
910 |
911 | input[type="range"]::-moz-range-thumb {
912 | width: 20px;
913 | height: 20px;
914 | background: #fff;
915 | border: 2px solid var(--primary-color);
916 | border-radius: 50%;
917 | cursor: pointer;
918 | box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
919 | }
920 |
921 | .threads-display {
922 | display: flex;
923 | align-items: center;
924 | gap: 0.5rem;
925 | min-width: 100px;
926 | }
927 |
928 | .threads-display input[type="number"] {
929 | width: 50px;
930 | padding: 0.5rem;
931 | text-align: center;
932 | border: 1px solid var(--border-color);
933 | border-radius: 4px;
934 | }
935 |
936 | .threads-display span {
937 | color: var(--text-secondary);
938 | }
939 |
940 | input[type="number"]::-webkit-inner-spin-button,
941 | input[type="number"]::-webkit-outer-spin-button {
942 | opacity: 1;
943 | }
944 |
945 | .threads-modes {
946 | display: flex;
947 | justify-content: space-between;
948 | margin-top: 1rem;
949 | }
950 |
951 | .thread-mode {
952 | text-align: center;
953 | cursor: pointer;
954 | padding: 0.75rem 0.5rem;
955 | border-radius: 6px;
956 | transition: all 0.2s ease;
957 | flex: 1;
958 | margin: 0 0.25rem;
959 | }
960 |
961 | .thread-mode:hover {
962 | background-color: rgba(37, 99, 235, 0.08);
963 | }
964 |
965 | .thread-mode.active {
966 | background-color: rgba(37, 99, 235, 0.12);
967 | }
968 |
969 | .mode-icon {
970 | font-size: 1.25rem;
971 | margin-bottom: 0.3rem;
972 | color: var(--primary-color);
973 | }
974 |
975 | .mode-label {
976 | font-size: 0.85rem;
977 | color: var(--text-secondary);
978 | }
979 |
980 | .threads-hint {
981 | margin: 0.75rem 0;
982 | color: var(--text-secondary);
983 | display: flex;
984 | align-items: center;
985 | gap: 0.5rem;
986 | }
987 |
988 | /* 任务状态显示 */
989 | .thread-count-display {
990 | font-size: 1rem;
991 | color: var(--primary-color);
992 | margin: 0.5rem 0;
993 | display: flex;
994 | align-items: center;
995 | justify-content: center;
996 | gap: 0.5rem;
997 | }
998 |
999 | .tasks-overview {
1000 | margin-top: 1.5rem;
1001 | background-color: white;
1002 | border-radius: 8px;
1003 | border: 1px solid var(--border-color);
1004 | overflow: hidden;
1005 | }
1006 |
1007 | .tasks-overview h4 {
1008 | background-color: #f1f5f9;
1009 | padding: 0.75rem 1rem;
1010 | margin: 0;
1011 | font-size: 0.95rem;
1012 | color: var(--text-secondary);
1013 | font-weight: 500;
1014 | display: flex;
1015 | align-items: center;
1016 | gap: 0.5rem;
1017 | border-bottom: 1px solid var(--border-color);
1018 | }
1019 |
1020 | .tasks-grid {
1021 | padding: 1rem;
1022 | display: grid;
1023 | grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
1024 | gap: 0.75rem;
1025 | max-height: 200px;
1026 | overflow-y: auto;
1027 | }
1028 |
1029 | .task-item {
1030 | padding: 0.75rem;
1031 | background-color: #f8fafc;
1032 | border-radius: 6px;
1033 | border-left: 3px solid var(--primary-color);
1034 | font-size: 0.85rem;
1035 | position: relative;
1036 | }
1037 |
1038 | .task-item .task-id {
1039 | font-weight: 600;
1040 | margin-bottom: 0.3rem;
1041 | color: var(--primary-color);
1042 | display: flex;
1043 | align-items: center;
1044 | justify-content: space-between;
1045 | }
1046 |
1047 | .task-item .task-target,
1048 | .task-item .task-ports {
1049 | margin: 0.2rem 0;
1050 | white-space: nowrap;
1051 | overflow: hidden;
1052 | text-overflow: ellipsis;
1053 | color: var(--text-secondary);
1054 | font-family: 'Courier New', monospace;
1055 | }
1056 |
1057 | .task-status {
1058 | position: absolute;
1059 | top: 0.75rem;
1060 | right: 0.75rem;
1061 | width: 12px;
1062 | height: 12px;
1063 | border-radius: 50%;
1064 | }
1065 |
1066 | .task-status.pending {
1067 | background-color: #94a3b8;
1068 | }
1069 |
1070 | .task-status.running {
1071 | background-color: #3b82f6;
1072 | animation: pulse 1.5s infinite;
1073 | }
1074 |
1075 | .task-status.completed {
1076 | background-color: #10b981;
1077 | }
1078 |
1079 | .task-status.error {
1080 | background-color: #ef4444;
1081 | }
1082 |
1083 | @keyframes pulse {
1084 | 0% {
1085 | transform: scale(0.95);
1086 | box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.5);
1087 | }
1088 |
1089 | 70% {
1090 | transform: scale(1);
1091 | box-shadow: 0 0 0 5px rgba(59, 130, 246, 0);
1092 | }
1093 |
1094 | 100% {
1095 | transform: scale(0.95);
1096 | box-shadow: 0 0 0 0 rgba(59, 130, 246, 0);
1097 | }
1098 | }
1099 |
1100 | /* 增强进度条样式 */
1101 | .progress-container {
1102 | margin: 1.5rem 0;
1103 | }
1104 |
1105 | .progress-bar {
1106 | position: relative;
1107 | width: 100%;
1108 | height: 12px;
1109 | background-color: #e2e8f0;
1110 | border-radius: 6px;
1111 | overflow: hidden;
1112 | }
1113 |
1114 | .progress-fill {
1115 | height: 100%;
1116 | width: 0%;
1117 | background-image: linear-gradient(90deg, var(--primary-color), var(--secondary-color));
1118 | transition: width 0.3s ease;
1119 | border-radius: 6px;
1120 | position: relative;
1121 | }
1122 |
1123 | .progress-fill::after {
1124 | content: '';
1125 | position: absolute;
1126 | top: 0;
1127 | left: 0;
1128 | right: 0;
1129 | bottom: 0;
1130 | background-image: linear-gradient(
1131 | -45deg,
1132 | rgba(255, 255, 255, 0.2) 25%,
1133 | transparent 25%,
1134 | transparent 50%,
1135 | rgba(255, 255, 255, 0.2) 50%,
1136 | rgba(255, 255, 255, 0.2) 75%,
1137 | transparent 75%,
1138 | transparent
1139 | );
1140 | background-size: 50px 50px;
1141 | animation: move 2s linear infinite;
1142 | }
1143 |
1144 | @keyframes move {
1145 | 0% {
1146 | background-position: 0 0;
1147 | }
1148 | 100% {
1149 | background-position: 50px 50px;
1150 | }
1151 | }
1152 |
1153 | .progress-text {
1154 | display: flex;
1155 | justify-content: space-between;
1156 | margin-top: 0.5rem;
1157 | font-size: 0.9rem;
1158 | color: var(--text-secondary);
1159 | }
1160 |
1161 | .progress-text .completed-tasks {
1162 | font-weight: 500;
1163 | color: var(--primary-color);
1164 | }
1165 |
1166 | /* 响应式调整 */
1167 | @media (max-width: 768px) {
1168 | .threads-slider-container {
1169 | flex-direction: column;
1170 | gap: 0.5rem;
1171 | }
1172 |
1173 | .threads-display {
1174 | width: 100%;
1175 | justify-content: center;
1176 | }
1177 |
1178 | .threads-modes {
1179 | flex-wrap: wrap;
1180 | }
1181 |
1182 | .thread-mode {
1183 | flex: 0 0 calc(50% - 0.5rem);
1184 | margin-bottom: 0.5rem;
1185 | }
1186 |
1187 | .tasks-grid {
1188 | grid-template-columns: 1fr;
1189 | }
1190 | }
1191 |
1192 | /* 扫描结果高亮样式 */
1193 | .results-content pre {
1194 | font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
1195 | line-height: 1.5;
1196 | white-space: pre-wrap;
1197 | word-break: break-all;
1198 | }
1199 |
1200 | .hl-header {
1201 | color: #3b82f6;
1202 | font-weight: bold;
1203 | display: block;
1204 | margin-bottom: 8px;
1205 | padding-bottom: 4px;
1206 | border-bottom: 1px solid rgba(59, 130, 246, 0.3);
1207 | }
1208 |
1209 | .hl-table-header {
1210 | color: #6b7280;
1211 | font-weight: bold;
1212 | display: block;
1213 | margin: 8px 0;
1214 | padding: 4px 0;
1215 | background-color: rgba(243, 244, 246, 0.5);
1216 | }
1217 |
1218 | .hl-target {
1219 | color: #8b5cf6;
1220 | font-weight: bold;
1221 | display: block;
1222 | margin-top: 12px;
1223 | margin-bottom: 4px;
1224 | padding: 4px 0;
1225 | border-top: 1px dashed rgba(139, 92, 246, 0.3);
1226 | }
1227 |
1228 | .hl-port {
1229 | color: #2563eb;
1230 | font-weight: bold;
1231 | display: inline-block;
1232 | min-width: 90px;
1233 | }
1234 |
1235 | .hl-state-open {
1236 | color: #10b981;
1237 | font-weight: bold;
1238 | display: inline-block;
1239 | min-width: 70px;
1240 | }
1241 |
1242 | .hl-state-closed {
1243 | color: #ef4444;
1244 | font-weight: normal;
1245 | display: inline-block;
1246 | min-width: 70px;
1247 | }
1248 |
1249 | .hl-state-filtered {
1250 | color: #f59e0b;
1251 | font-weight: normal;
1252 | display: inline-block;
1253 | min-width: 70px;
1254 | }
1255 |
1256 | .hl-service {
1257 | color: #6b7280;
1258 | display: inline-block;
1259 | }
1260 |
1261 | .hl-ip {
1262 | color: #ec4899;
1263 | font-weight: bold;
1264 | }
1265 |
1266 | .hl-domain {
1267 | color: #8b5cf6;
1268 | font-weight: bold;
1269 | }
1270 |
1271 | .hl-service-label {
1272 | color: #6b7280;
1273 | font-weight: bold;
1274 | margin-right: 5px;
1275 | }
1276 |
1277 | .hl-service-info {
1278 | color: #059669;
1279 | }
1280 |
1281 | .hl-summary {
1282 | display: block;
1283 | margin-top: 12px;
1284 | padding-top: 8px;
1285 | color: #3b82f6;
1286 | font-weight: bold;
1287 | border-top: 1px solid rgba(59, 130, 246, 0.3);
1288 | }
1289 |
1290 | /* 表格式显示 */
1291 | .results-content pre {
1292 | tab-size: 4;
1293 | -moz-tab-size: 4;
1294 | }
--------------------------------------------------------------------------------