方案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/描述)
236 lines
7.0 KiB
Python
236 lines
7.0 KiB
Python
#!/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),
|
|
})
|