Socat 端口转发 Web 管理工具:Python Flask 实现详解

摘要
本文提供了一个完整的、基于 Python Flask 和 socat 的端口转发 Web 管理工具实现。该工具通过一个简洁的 Web 界面,允许用户动态添加、启动、停止和删除 TCP/UDP 端口映射规则,所有配置自动持久化到 JSON 文件,并在服务重启后自动恢复运行状态。
核心功能:
- Web 管理界面:提供表单添加规则,并以表格形式展示所有映射的实时状态(运行/停止)。
- socat 进程管理:自动在后台启动/停止 socat 进程,实现端口转发。
- 数据持久化 :所有映射规则保存至
port_mappings.json文件。 - 状态恢复:服务启动时自动恢复之前状态为"运行"的规则。
- RESTful API:提供完整的后端 API 供前端调用,实现增删改查及启停操作。
技术栈: Python, Flask, socat, subprocess, JSON。
适用场景: 需要临时或长期端口转发、内网穿透、服务调试,且希望通过 Web 界面便捷管理的场景。
python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import os
import json
import subprocess
import signal
import time
from flask import Flask, render_template_string, request, jsonify
app = Flask(__name__)
# 配置文件路径
CONFIG_FILE = "port_mappings.json"
# 存放 socat 进程 PID 的字典 { rule_id: pid }
processes = {}
# ==================== 数据持久化 ====================
def load_mappings():
"""从 JSON 文件加载映射规则"""
if os.path.exists(CONFIG_FILE):
with open(CONFIG_FILE, "r") as f:
return json.load(f)
return []
def save_mappings(mappings):
"""保存映射规则到 JSON 文件"""
with open(CONFIG_FILE, "w") as f:
json.dump(mappings, f, indent=2)
# ==================== socat 进程管理 ====================
def start_socat(mapping):
"""
启动一个 socat 端口转发进程
mapping: { id, listen_port, target_host, target_port, protocol, status }
返回: pid 或 None
"""
mapping_id = mapping["id"]
listen_port = mapping["listen_port"]
target_host = mapping["target_host"]
target_port = mapping["target_port"]
protocol = mapping.get("protocol", "tcp")
# 构造 socat 命令
# 例如: socat TCP4-LISTEN:8080,reuseaddr,fork TCP4:192.168.1.100:80
listen_addr = f"{protocol.upper()}4-LISTEN:{listen_port},reuseaddr,fork"
target_addr = f"{protocol.upper()}4:{target_host}:{target_port}"
cmd = ["socat", listen_addr, target_addr]
try:
# 启动子进程,将输出重定向到日志文件
log_file = open(f"socat_{mapping_id}.log", "a")
proc = subprocess.Popen(
cmd,
stdout=log_file,
stderr=subprocess.STDOUT,
preexec_fn=os.setsid # 创建新的进程组,便于杀死整个进程树
)
processes[mapping_id] = proc.pid
mapping["status"] = "running"
return proc.pid
except Exception as e:
print(f"启动 socat 失败 (id={mapping_id}): {e}")
mapping["status"] = "stopped"
return None
def stop_socat(mapping_id):
"""停止一个 socat 进程"""
if mapping_id in processes:
pid = processes[mapping_id]
try:
# 杀死整个进程组
os.killpg(os.getpgid(pid), signal.SIGTERM)
time.sleep(0.5)
# 如果还没死,强制杀死
try:
os.killpg(os.getpgid(pid), signal.SIGKILL)
except:
pass
except ProcessLookupError:
pass
del processes[mapping_id]
return True
return False
def restart_socat(mapping):
"""重启一个 socat 进程"""
stop_socat(mapping["id"])
return start_socat(mapping)
def stop_all_socat():
"""停止所有 socat 进程(程序退出时调用)"""
for mapping_id in list(processes.keys()):
stop_socat(mapping_id)
# ==================== 恢复已有映射 ====================
def restore_mappings():
"""程序启动时恢复之前保存的映射"""
mappings = load_mappings()
for m in mappings:
if m.get("status") == "running":
start_socat(m)
return mappings
# ==================== Flask 路由 ====================
HTML_TEMPLATE = """
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Socat 端口映射管理</title>
<style>
* { box-sizing: border-box; font-family: 'Segoe UI', Tahoma, sans-serif; }
body { background: #f0f2f5; padding: 20px; }
.container { max-width: 1100px; margin: 0 auto; }
h1 { color: #1a1a2e; }
.card { background: white; border-radius: 10px; padding: 20px; margin-bottom: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.08); }
.form-row { display: flex; flex-wrap: wrap; gap: 10px; align-items: end; }
.form-group { display: flex; flex-direction: column; }
.form-group label { font-size: 13px; color: #555; margin-bottom: 3px; }
.form-group input, .form-group select { padding: 8px 12px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; width: 140px; }
.form-group input:focus, .form-group select:focus { outline: none; border-color: #4a90d9; }
.btn { padding: 8px 20px; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; transition: 0.2s; }
.btn-primary { background: #4a90d9; color: white; }
.btn-primary:hover { background: #357abd; }
.btn-danger { background: #e74c3c; color: white; }
.btn-danger:hover { background: #c0392b; }
.btn-success { background: #27ae60; color: white; }
.btn-success:hover { background: #219a52; }
.btn-warning { background: #f39c12; color: white; }
.btn-warning:hover { background: #d68910; }
.btn-sm { padding: 4px 12px; font-size: 12px; }
table { width: 100%; border-collapse: collapse; }
th { text-align: left; padding: 10px 12px; background: #f8f9fa; border-bottom: 2px solid #e9ecef; }
td { padding: 10px 12px; border-bottom: 1px solid #e9ecef; }
.status-badge { padding: 3px 12px; border-radius: 20px; font-size: 12px; font-weight: 600; }
.status-running { background: #d4edda; color: #155724; }
.status-stopped { background: #f8d7da; color: #721c24; }
.action-btns { display: flex; gap: 6px; flex-wrap: wrap; }
.empty-msg { color: #999; text-align: center; padding: 30px 0; }
.toast { position: fixed; top: 20px; right: 20px; background: #333; color: white; padding: 12px 24px; border-radius: 8px; opacity: 0; transition: opacity 0.3s; z-index: 999; }
.toast.show { opacity: 1; }
.toast.success { background: #27ae60; }
.toast.error { background: #e74c3c; }
.footer { text-align: center; color: #999; font-size: 13px; margin-top: 30px; }
@media (max-width: 600px) { .form-group input, .form-group select { width: 100%; } .form-row { flex-direction: column; } }
</style>
</head>
<body>
<div class="container">
<h1>🔀 Socat 端口映射管理</h1>
<!-- 添加映射表单 -->
<div class="card">
<h3>➕ 添加端口映射</h3>
<form id="addForm" class="form-row">
<div class="form-group">
<label>监听端口</label>
<input type="number" id="listen_port" placeholder="8080" required min="1" max="65535">
</div>
<div class="form-group">
<label>目标主机</label>
<input type="text" id="target_host" placeholder="192.168.1.100" required>
</div>
<div class="form-group">
<label>目标端口</label>
<input type="number" id="target_port" placeholder="80" required min="1" max="65535">
</div>
<div class="form-group">
<label>协议</label>
<select id="protocol">
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
</select>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary">添加并启动</button>
</div>
</form>
</div>
<!-- 映射列表 -->
<div class="card">
<h3>📋 当前映射列表</h3>
<table>
<thead>
<tr>
<th>ID</th>
<th>监听端口</th>
<th>目标地址</th>
<th>协议</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody id="mappingTableBody">
<!-- 由 JavaScript 动态渲染 -->
</tbody>
</table>
<div id="emptyMsg" class="empty-msg">暂无映射规则</div>
</div>
<div class="footer">
Socat 端口映射管理 · <a href="#" onclick="refreshList(); return false;">刷新</a>
</div>
</div>
<!-- Toast 消息 -->
<div id="toast" class="toast"></div>
<script>
// ===== API 调用 =====
async function api(method, url, data) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (data) opts.body = JSON.stringify(data);
const resp = await fetch(url, opts);
return resp.json();
}
// ===== 渲染列表 =====
async function refreshList() {
const data = await api('GET', '/api/mappings');
const tbody = document.getElementById('mappingTableBody');
const emptyMsg = document.getElementById('emptyMsg');
if (!data.mappings || data.mappings.length === 0) {
tbody.innerHTML = '';
emptyMsg.style.display = 'block';
return;
}
emptyMsg.style.display = 'none';
let html = '';
for (const m of data.mappings) {
const statusClass = m.status === 'running' ? 'status-running' : 'status-stopped';
const target = m.target_host + ':' + m.target_port;
html += `
<tr>
<td>${m.id}</td>
<td>${m.listen_port}</td>
<td>${target}</td>
<td>${m.protocol.toUpperCase()}</td>
<td><span class="status-badge ${statusClass}">${m.status}</span></td>
<td>
<div class="action-btns">
${m.status === 'running'
? `<button class="btn btn-warning btn-sm" onclick="stopMapping(${m.id})">停止</button>`
: `<button class="btn btn-success btn-sm" onclick="startMapping(${m.id})">启动</button>`
}
<button class="btn btn-danger btn-sm" onclick="deleteMapping(${m.id})">删除</button>
</div>
</td>
</tr>
`;
}
tbody.innerHTML = html;
}
// ===== 添加映射 =====
document.getElementById('addForm').addEventListener('submit', async (e) => {
e.preventDefault();
const data = {
listen_port: parseInt(document.getElementById('listen_port').value),
target_host: document.getElementById('target_host').value.trim(),
target_port: parseInt(document.getElementById('target_port').value),
protocol: document.getElementById('protocol').value
};
const result = await api('POST', '/api/mappings', data);
if (result.success) {
showToast('✅ 映射添加成功', 'success');
document.getElementById('addForm').reset();
refreshList();
} else {
showToast('❌ ' + result.error, 'error');
}
});
// ===== 启动映射 =====
async function startMapping(id) {
const result = await api('POST', `/api/mappings/${id}/start`);
if (result.success) {
showToast('✅ 已启动', 'success');
refreshList();
} else {
showToast('❌ ' + result.error, 'error');
}
}
// ===== 停止映射 =====
async function stopMapping(id) {
const result = await api('POST', `/api/mappings/${id}/stop`);
if (result.success) {
showToast('⏹️ 已停止', 'success');
refreshList();
} else {
showToast('❌ ' + result.error, 'error');
}
}
// ===== 删除映射 =====
async function deleteMapping(id) {
if (!confirm('确定要删除这条映射规则吗?')) return;
const result = await api('DELETE', `/api/mappings/${id}`);
if (result.success) {
showToast('🗑️ 已删除', 'success');
refreshList();
} else {
showToast('❌ ' + result.error, 'error');
}
}
// ===== Toast 消息 =====
function showToast(msg, type) {
const toast = document.getElementById('toast');
toast.textContent = msg;
toast.className = 'toast ' + (type || '') + ' show';
clearTimeout(toast._timer);
toast._timer = setTimeout(() => { toast.classList.remove('show'); }, 3000);
}
// ===== 自动刷新 =====
refreshList();
setInterval(refreshList, 5000);
</script>
</body>
</html>
"""
@app.route('/')
def index():
return render_template_string(HTML_TEMPLATE)
@app.route('/api/mappings', methods=['GET'])
def get_mappings():
"""获取所有映射规则"""
mappings = load_mappings()
# 同步进程状态
for m in mappings:
if m["id"] in processes:
# 检查进程是否还活着
try:
os.kill(processes[m["id"]], 0)
m["status"] = "running"
except OSError:
m["status"] = "stopped"
del processes[m["id"]]
else:
m["status"] = "stopped"
return jsonify({"mappings": mappings})
@app.route('/api/mappings', methods=['POST'])
def add_mapping():
"""添加新的端口映射"""
data = request.json
listen_port = data.get("listen_port")
target_host = data.get("target_host")
target_port = data.get("target_port")
protocol = data.get("protocol", "tcp")
# 基本校验
if not all([listen_port, target_host, target_port]):
return jsonify({"success": False, "error": "缺少必要参数"})
if not (1 <= listen_port <= 65535 and 1 <= target_port <= 65535):
return jsonify({"success": False, "error": "端口范围必须在 1-65535 之间"})
mappings = load_mappings()
# 检查端口是否已被占用
for m in mappings:
if m["listen_port"] == listen_port:
return jsonify({"success": False, "error": f"监听端口 {listen_port} 已被占用"})
# 生成 ID
new_id = 1
if mappings:
new_id = max(m["id"] for m in mappings) + 1
new_mapping = {
"id": new_id,
"listen_port": listen_port,
"target_host": target_host,
"target_port": target_port,
"protocol": protocol,
"status": "stopped"
}
mappings.append(new_mapping)
save_mappings(mappings)
# 自动启动
pid = start_socat(new_mapping)
if pid:
new_mapping["status"] = "running"
save_mappings(mappings)
return jsonify({"success": True, "mapping": new_mapping})
else:
return jsonify({"success": False, "error": "启动 socat 失败,请检查目标地址是否可达"})
@app.route('/api/mappings/<int:mapping_id>/start', methods=['POST'])
def start_mapping(mapping_id):
"""启动指定的映射"""
mappings = load_mappings()
for m in mappings:
if m["id"] == mapping_id:
if m["id"] in processes:
return jsonify({"success": False, "error": "已在运行中"})
pid = start_socat(m)
if pid:
m["status"] = "running"
save_mappings(mappings)
return jsonify({"success": True})
else:
return jsonify({"success": False, "error": "启动失败"})
return jsonify({"success": False, "error": "映射不存在"})
@app.route('/api/mappings/<int:mapping_id>/stop', methods=['POST'])
def stop_mapping(mapping_id):
"""停止指定的映射"""
mappings = load_mappings()
for m in mappings:
if m["id"] == mapping_id:
if stop_socat(mapping_id):
m["status"] = "stopped"
save_mappings(mappings)
return jsonify({"success": True})
else:
return jsonify({"success": False, "error": "进程不存在或已停止"})
return jsonify({"success": False, "error": "映射不存在"})
@app.route('/api/mappings/<int:mapping_id>', methods=['DELETE'])
def delete_mapping(mapping_id):
"""删除映射规则"""
mappings = load_mappings()
for i, m in enumerate(mappings):
if m["id"] == mapping_id:
# 先停止进程
stop_socat(mapping_id)
del mappings[i]
save_mappings(mappings)
return jsonify({"success": True})
return jsonify({"success": False, "error": "映射不存在"})
# ==================== 程序入口 ====================
if __name__ == '__main__':
import atexit
# 注册退出时的清理函数
atexit.register(stop_all_socat)
# 恢复之前运行的映射
restore_mappings()
# 启动 Web 服务
print("Socat 端口映射管理已启动")
print(f"请访问 http://localhost:5000")
app.run(host='0.0.0.0', port=5000, debug=False)