From 294526b403aa5a52381db768d7700d4b2e9fc3f1 Mon Sep 17 00:00:00 2001 From: hz4th_coder Date: Mon, 1 Jun 2026 18:18:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9C=8D=E5=8A=A1=E5=8F=91=E7=8E=B0=20?= =?UTF-8?q?-=20=E8=87=AA=E5=8A=A8=E6=89=AB=E6=8F=8F=E6=9C=AC=E6=9C=BA?= =?UTF-8?q?=E6=9C=AA=E6=B3=A8=E5=86=8C=E7=9A=84=E6=96=B0=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 方案C: 端口扫描 + 进程信息增强 后端 /api/discovery/services: - ss -tlnp 扫描所有TCP监听端口 - 自动排除已注册服务(projects.json) + Docker容器端口 + 系统端口(SSH等) - 读取 /proc//cmdline 获取进程命令行 - 读取 /proc//cwd 获取工作目录 - 智能推测服务名(匹配python/node/java等框架特征) - 智能推测服务类型(web/external) 前端 Web服务区底部: - 发现未注册服务区块,点击「扫描」探测 - 每个未注册服务显示: 端口/PID/进程名/cmdline - 一键添加按钮,自动预填弹框(名称/端口/URL/描述) --- app.py | 2 + routes_discovery.py | 235 ++++++++++++++++++++++++++++++++++++++++++++ templates_full.txt | 67 +++++++++++++ 3 files changed, 304 insertions(+) create mode 100644 routes_discovery.py diff --git a/app.py b/app.py index 0b3c591..bad9e52 100644 --- a/app.py +++ b/app.py @@ -14,6 +14,7 @@ from routes_config import register_config_routes from routes_email import register_email_routes from routes_project import register_project_routes from routes_docker import register_docker_routes +from routes_discovery import register_discovery_routes from templates import get_html_template @@ -33,6 +34,7 @@ def init_app(): register_email_routes(app) register_project_routes(app) register_docker_routes(app) + register_discovery_routes(app) # 主页路由 @app.route('/') diff --git a/routes_discovery.py b/routes_discovery.py new file mode 100644 index 0000000..0f1b122 --- /dev/null +++ b/routes_discovery.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +""" +服务发现 API 路由 - 扫描本机未注册的服务 +方案C: 端口扫描 + 进程信息增强 +""" + +import os +import re +import json +import subprocess +from flask import jsonify +from utils import log_message +from config import PROJECTS_FILE + + +# 系统保留/常见基础服务端口,不纳入发现 +SYSTEM_PORTS = { + 22, # SSH + 25, # SMTP + 53, # DNS + 80, # HTTP (nginx/apache 前端) + 111, # portmapper + 135, # RPC + 137, # NetBIOS + 138, # NetBIOS + 139, # NetBIOS + 443, # HTTPS + 445, # SMB + 587, # SMTP submission + 631, # CUPS + 993, # IMAPS + 995, # POP3S + 3306, # MySQL + 3389, # RDP + 5432, # PostgreSQL + 6379, # Redis + 8080, # 常见代理 + 8443, # 常见 HTTPS 代理 + 9090, # Prometheus + 3000, # 常见开发端口 + 4200, # Angular dev + 5000, # Flask dev + 8000, # 常见开发 + 9000, # 常见开发 +} + + +def _get_listening_ports(): + """获取所有TCP监听端口及进程信息""" + services = [] + try: + result = subprocess.run( + ['ss', '-tlnp'], + capture_output=True, text=True, timeout=5 + ) + for line in result.stdout.split('\n'): + line = line.strip() + if not line or 'State' in line or 'Local' in line: + continue + + # 解析: LISTEN 0 128 0.0.0.0:16022 0.0.0.0:* users:(("python3.12",pid=123,fd=3)) + if '127.0.0.1' in line and '0.0.0.0' not in line.split()[3] and '*' not in line.split()[3]: + # 跳过仅监听 localhost 的 + pass + + # 提取端口和pid + addr_part = line.split()[3] if len(line.split()) > 3 else '' + port_match = re.search(r':(\d+)$', addr_part) + if not port_match: + continue + port = int(port_match.group(1)) + + pid_match = re.search(r'pid=(\d+)', line) + if not pid_match: + continue + pid = int(pid_match.group(1)) + + # 提取进程名 + proc_match = re.search(r'users:\(\(\"([^\"]+)\"', line) + proc_name = proc_match.group(1) if proc_match else 'unknown' + + services.append({ + 'port': port, + 'pid': pid, + 'process': proc_name, + }) + except Exception as e: + log_message(f"端口扫描失败: {e}") + + return services + + +def _get_process_cmdline(pid): + """读取进程命令行""" + try: + cmdline_path = f'/proc/{pid}/cmdline' + if os.path.exists(cmdline_path): + with open(cmdline_path, 'rb') as f: + raw = f.read() + # cmdline 以 \0 分隔 + args = raw.split(b'\x00') + # 解码,跳过空字符串 + cmd = ' '.join(a.decode('utf-8', errors='replace') for a in args if a) + return cmd + except: + pass + return '' + + +def _get_process_cwd(pid): + """读取进程工作目录""" + try: + cwd_path = f'/proc/{pid}/cwd' + if os.path.exists(cwd_path): + return os.readlink(cwd_path) + except: + pass + return '' + + +def _guess_service_name(pid, proc_name, cmdline): + """根据进程信息推测服务名""" + # 已知框架特征 + patterns = [ + (r'python.*?app\.py', 'Python Web 服务'), + (r'python.*?main\.py', 'Python 服务'), + (r'python.*?server\.py', 'Python Server'), + (r'node\s+.*?(server|app|index)\.js', 'Node.js 服务'), + (r'npm\s+(start|run)', 'Node.js 服务'), + (r'java\s+-jar\s+(\S+)', 'Java 服务'), + (r'gunicorn', 'Gunicorn 服务'), + (r'uvicorn', 'Uvicorn 服务'), + (r'nginx', 'Nginx'), + (r'php-fpm', 'PHP-FPM'), + (r'mysqld', 'MySQL'), + (r'redis-server', 'Redis'), + (r'docker-proxy', 'Docker 代理'), + (r'containerd', 'containerd'), + ] + + for pattern, name in patterns: + if re.search(pattern, cmdline, re.IGNORECASE): + return name + + # 默认使用进程名 + return proc_name + + +def _guess_service_type(cmdline): + """推测服务类型""" + if re.search(r'(python|gunicorn|uvicorn|flask|django)', cmdline, re.IGNORECASE): + return 'web' + if re.search(r'(node|npm|next|express)', cmdline, re.IGNORECASE): + return 'web' + if re.search(r'(java|spring|tomcat)', cmdline, re.IGNORECASE): + return 'web' + if re.search(r'(mysqld|postgres|redis|mongo)', cmdline, re.IGNORECASE): + return 'external' + return 'external' + + +def register_discovery_routes(app): + """注册服务发现路由""" + + @app.route('/api/discovery/services') + def api_discovery_services(): + """扫描本机未注册的新服务""" + # 1. 获取已知端口 + known_ports = set() + try: + with open(PROJECTS_FILE, 'r', encoding='utf-8') as f: + data = json.load(f) + for proj in data.get('projects', []): + for p in proj.get('ports', []): + known_ports.add(p) + for svc in data.get('external_services', []): + for p in svc.get('ports', []): + known_ports.add(p) + except: + pass + + # 2. 获取 Docker 容器端口 + try: + result = subprocess.run( + ['docker', 'ps', '--format', '{{.Ports}}'], + capture_output=True, text=True, timeout=5 + ) + for line in result.stdout.split('\n'): + # 匹配 0.0.0.0:16001->7700/tcp 格式 + for m in re.finditer(r':(\d+)->', line): + known_ports.add(int(m.group(1))) + except: + pass + + # 3. 扫描所有监听端口 + all_services = _get_listening_ports() + + # 4. 筛选未注册的 + undiscovered = [] + seen_ports = set() + + for svc in all_services: + port = svc['port'] + pid = svc['pid'] + + # 跳过已知/系统端口 + if port in known_ports or port in SYSTEM_PORTS: + continue + if port in seen_ports: + continue + seen_ports.add(port) + + cmdline = _get_process_cmdline(pid) + cwd = _get_process_cwd(pid) + name = _guess_service_name(pid, svc['process'], cmdline) + svc_type = _guess_service_type(cmdline) + + undiscovered.append({ + 'port': port, + 'pid': pid, + 'process': svc['process'], + 'cmdline': cmdline[:200] if cmdline else '', + 'cwd': cwd, + 'suggested_name': name, + 'suggested_type': svc_type, + }) + + # 按端口排序 + undiscovered.sort(key=lambda x: x['port']) + + return jsonify({ + 'services': undiscovered, + 'count': len(undiscovered), + 'known_ports': len(known_ports), + }) diff --git a/templates_full.txt b/templates_full.txt index bead823..8789278 100644 --- a/templates_full.txt +++ b/templates_full.txt @@ -180,6 +180,24 @@

加载中...

+ + + @@ -2861,6 +2879,55 @@ } catch (e) { alert('失败: ' + e.message); } } + // ==================== 服务发现 ==================== + + async function scanServices() { + const list = document.getElementById('discoveryList'); + list.innerHTML = '
扫描中...
'; + + try { + const res = await fetch('/api/discovery/services'); + const data = await res.json(); + const services = data.services || []; + document.getElementById('discoveryCount').textContent = `发现 ${services.length} 个`; + + if (services.length === 0) { + list.innerHTML = '
所有服务已注册,没有遗漏
'; + } else { + list.innerHTML = services.map(s => ` +
+
+ :${s.port} + ${s.suggested_name} + PID ${s.pid} + +
+ +
+ `).join(''); + } + } catch (e) { + list.innerHTML = `
扫描失败: ${e.message}
`; + } + } + + function quickAddService(port, name, type, cwd) { + showAddServiceModal(); + document.getElementById('serviceName').value = name; + document.getElementById('servicePort').value = port; + document.getElementById('serviceFullUrl').value = `http://${externalIp}:${port}`; + document.getElementById('serviceDesc').value = `自动发现 | ${type} | PID路径: ${cwd || '未知'}`; + updateServicePreview(); + } + + // 页面加载后显示发现区块 + function showDiscoverySection() { + document.getElementById('discoverySection').classList.remove('hidden'); + } + setTimeout(showDiscoverySection, 500); + \ No newline at end of file