Compare commits

...

3 Commits

Author SHA1 Message Date
525620b0f8 fix: 自定义服务保存时添加port字段,修复发现扫描排除失效
之前 quickAddService 保存自定义服务时遗漏了 port 字段,
导致 scanServices 的 exclude_ports 取不到有效端口值
2026-06-01 18:28:44 +08:00
ebc8d687d0 fix: 服务发现排除前端已注册的自定义服务
- API 新增 exclude_ports 查询参数,接受逗号分隔端口列表
- 前端 scanServices() 读取 localStorage 自定义服务端口传给后端
- 已添加的服务不再出现在扫描结果中
2026-06-01 18:23:23 +08:00
294526b403 feat: 服务发现 - 自动扫描本机未注册的新服务
方案C: 端口扫描 + 进程信息增强

后端 /api/discovery/services:
- ss -tlnp 扫描所有TCP监听端口
- 自动排除已注册服务(projects.json) + Docker容器端口 + 系统端口(SSH等)
- 读取 /proc/<pid>/cmdline 获取进程命令行
- 读取 /proc/<pid>/cwd 获取工作目录
- 智能推测服务名(匹配python/node/java等框架特征)
- 智能推测服务类型(web/external)

前端 Web服务区底部:
- 发现未注册服务区块,点击「扫描」探测
- 每个未注册服务显示: 端口/PID/进程名/cmdline
- 一键添加按钮,自动预填弹框(名称/端口/URL/描述)
2026-06-01 18:18:26 +08:00
3 changed files with 319 additions and 0 deletions

2
app.py
View File

@@ -14,6 +14,7 @@ from routes_config import register_config_routes
from routes_email import register_email_routes from routes_email import register_email_routes
from routes_project import register_project_routes from routes_project import register_project_routes
from routes_docker import register_docker_routes from routes_docker import register_docker_routes
from routes_discovery import register_discovery_routes
from templates import get_html_template from templates import get_html_template
@@ -33,6 +34,7 @@ def init_app():
register_email_routes(app) register_email_routes(app)
register_project_routes(app) register_project_routes(app)
register_docker_routes(app) register_docker_routes(app)
register_discovery_routes(app)
# 主页路由 # 主页路由
@app.route('/') @app.route('/')

244
routes_discovery.py Normal file
View File

@@ -0,0 +1,244 @@
#!/usr/bin/env python3
"""
服务发现 API 路由 - 扫描本机未注册的服务
方案C: 端口扫描 + 进程信息增强
"""
import os
import re
import json
import subprocess
from flask import jsonify, request
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. 前端传入的额外排除端口(自定义服务等)
exclude_str = request.args.get('exclude_ports', '')
if exclude_str:
try:
for p in exclude_str.split(','):
known_ports.add(int(p.strip()))
except:
pass
# 3. 获取 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),
})

View File

@@ -180,6 +180,24 @@
<div id="projectsList" class="space-y-4"> <div id="projectsList" class="space-y-4">
<div class="text-center py-12 text-gray-400"><i class="ri-loader-4-line text-4xl animate-spin"></i><p class="mt-2">加载中...</p></div> <div class="text-center py-12 text-gray-400"><i class="ri-loader-4-line text-4xl animate-spin"></i><p class="mt-2">加载中...</p></div>
</div> </div>
<!-- 发现新服务 -->
<div id="discoverySection" class="hidden mt-4 pt-4 border-t border-gray-600">
<div class="flex items-center justify-between mb-3">
<h3 class="text-gray-200 text-sm font-semibold flex items-center gap-2">
<i class="ri-radar-line text-cyan-400"></i> 发现未注册服务
</h3>
<div class="flex items-center gap-2">
<span id="discoveryCount" class="text-xs text-gray-500"></span>
<button onclick="scanServices()" class="btn bg-cyan-600 hover:bg-cyan-700 px-2 py-1 rounded text-xs flex items-center gap-1">
<i class="ri-scan-line"></i> 扫描
</button>
</div>
</div>
<div id="discoveryList" class="space-y-2">
<div class="text-gray-500 text-xs text-center py-2">点击「扫描」发现本机未注册的新服务</div>
</div>
</div>
</div> </div>
<!-- 系统资源 Tab --> <!-- 系统资源 Tab -->
@@ -2220,6 +2238,7 @@
const newService = { const newService = {
id: 'custom_' + Date.now(), id: 'custom_' + Date.now(),
name: name, name: name,
port: port || '',
url: mainUrl, url: mainUrl,
admin_url: adminUrl || null, admin_url: adminUrl || null,
description: desc || '自定义外部服务', description: desc || '自定义外部服务',
@@ -2861,6 +2880,60 @@
} catch (e) { alert('失败: ' + e.message); } } catch (e) { alert('失败: ' + e.message); }
} }
// ==================== 服务发现 ====================
async function scanServices() {
const list = document.getElementById('discoveryList');
list.innerHTML = '<div class="text-gray-400 text-xs text-center py-2"><i class="ri-loader-4-line animate-spin"></i> 扫描中...</div>';
try {
// 收集前端已注册的自定义服务端口,传给后端排除
const customServices = getCustomServices();
const customPorts = customServices.map(s => parseInt(s.port)).filter(p => !isNaN(p));
const excludeParam = customPorts.length > 0 ? `?exclude_ports=${customPorts.join(',')}` : '';
const res = await fetch(`/api/discovery/services${excludeParam}`);
const data = await res.json();
const services = data.services || [];
document.getElementById('discoveryCount').textContent = `发现 ${services.length} 个`;
if (services.length === 0) {
list.innerHTML = '<div class="text-green-400 text-xs text-center py-2"><i class="ri-checkbox-circle-line"></i> 所有服务已注册,没有遗漏</div>';
} else {
list.innerHTML = services.map(s => `
<div class="flex items-center justify-between bg-gray-700/30 px-3 py-2 rounded-lg">
<div class="flex items-center gap-3 flex-1 min-w-0">
<span class="text-xs text-cyan-400 font-mono">:${s.port}</span>
<span class="text-gray-300 text-sm truncate">${s.suggested_name}</span>
<span class="text-gray-500 text-xs">PID ${s.pid}</span>
<span class="text-gray-500 text-xs truncate hidden md:inline">${(s.cmdline || '').substring(0, 60)}</span>
</div>
<button onclick="quickAddService(${s.port}, '${s.suggested_name.replace(/'/g, "\\'")}', '${s.suggested_type}', '${(s.cwd || '').replace(/'/g, "\\'")}')" class="btn bg-green-600/50 hover:bg-green-600 px-2 py-1 rounded text-xs shrink-0 ml-2">
<i class="ri-add-line"></i> 添加
</button>
</div>
`).join('');
}
} catch (e) {
list.innerHTML = `<div class="text-red-400 text-xs text-center py-2">扫描失败: ${e.message}</div>`;
}
}
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);
</script> </script>
</body> </body>
</html> </html>