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

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

摘要

本文提供了一个完整的、基于 Python Flask 和 socat 的端口转发 Web 管理工具实现。该工具通过一个简洁的 Web 界面,允许用户动态添加、启动、停止和删除 TCP/UDP 端口映射规则,所有配置自动持久化到 JSON 文件,并在服务重启后自动恢复运行状态。

核心功能:

  1. Web 管理界面:提供表单添加规则,并以表格形式展示所有映射的实时状态(运行/停止)。
  2. socat 进程管理:自动在后台启动/停止 socat 进程,实现端口转发。
  3. 数据持久化 :所有映射规则保存至 port_mappings.json 文件。
  4. 状态恢复:服务启动时自动恢复之前状态为"运行"的规则。
  5. 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)