OpenVPN自动化配置与访问日志

OpenVPN 是内网访问和安全隧道的核心工具。但随着用户数的增加,手动添加、分发证书、撤销权限、记录访问等操作变得既繁琐又容易出错。
本文将分享我实现的一个 OpenVPN 自动化管理方案

[前端表单] → [审批接口] → [后端 API] → [VPN 服务器脚本] → [.ovpn 文件 + 日志]

image-VfVT.png

在内网面板前端加了一个弹窗用来收集信息到数据库

image-dwGo.png

管理人员前端面板可以通过和拒绝,这部分需要自己根据情况来实现

核心实现

自动化创建用户脚本 create_ovpn.sh

主要功能:

  • 生成用户证书(Expect 自动化交互)
  • 分配静态 IP
  • 修改 OVPN 配置参数(路由 / 协议 / MTU)
  • 添加 iptables 规则记录访问日志
  • 配置 rsyslog 按用户分文件记录

关键点

  • 使用 expect 模拟输入,避免人工选择菜单
  • 通过 /etc/openvpn/ccd/$USERNAME 固定 IP
  • iptables -m conntrack --ctstate NEW -m limit 限制日志频率
  • rsyslog 动态创建配置文件,实时生效
#!/bin/bash

USERNAME=$1
PASSWORD=$2
VPN_IP=192.168.85.$3
SCRIPT_PATH="/home/ec2-user/openvpn-install.sh"
VPN_LOG_DIR="/var/log/vpn"
RSYSLOG_CONF="/etc/rsyslog.d/$USERNAME.conf"
IPTABLES_RULES_FILE="/etc/iptables/rules.v4"

# 检查 openvpn-install.sh 是否存在
if [ ! -f "$SCRIPT_PATH" ]; then
    echo "错误: $SCRIPT_PATH 不存在"
    exit 1
fi

chmod +x "$SCRIPT_PATH"

# 自动生成用户 OVPN
/usr/bin/expect <<EOF
spawn "$SCRIPT_PATH"
expect "Select an option \[1-4\]:"
send "1\r"
expect "Client name:"
send "$USERNAME\r"
expect "Select an option \[1-2\]:"
send "\0252\r"
expect "Enter the password for the client:"
send "$PASSWORD\r"
expect "Verifying \- Enter PEM pass phrase:"
send "$PASSWORD\r"
expect eof
EOF

mv /root/*.ovpn /home/ec2-user/

OVPN_FILE="/home/ec2-user/$USERNAME.ovpn"
if [ -f "$OVPN_FILE" ]; then
    echo "成功生成 OVPN 文件: $OVPN_FILE"
    sed -i "/^verb 3$/r /dev/stdin" "$OVPN_FILE" <<EOF
route-nopull
max-routes 1000
route 172.30.0.0 255.255.0.0 vpn_gateway
route 172.31.0.0 255.255.0.0 vpn_gateway
tun-mtu 1500
mssfix 1400
EOF
    sed -i 's/^proto udp/proto tcp/' "$OVPN_FILE"
    sed -i 's/13\.251\.110\.51/47.129.111.202/' "$OVPN_FILE"
else
    echo "错误: OVPN 文件生成失败"
    exit 1
fi

# 配置静态IP
echo "ifconfig-push $VPN_IP 255.255.255.0" > /etc/openvpn/ccd/$USERNAME

# 优化后的 iptables 日志规则(仅记录新连接,并限制速率)
if ! iptables -C FORWARD -s "$VPN_IP" -m conntrack --ctstate NEW -m limit --limit 10/sec --limit-burst 20 -j LOG --log-prefix "${USERNAME}_ACCESS: " --log-level 4 2>/dev/null; then
    iptables -I FORWARD -s "$VPN_IP" -m conntrack --ctstate NEW -m limit --limit 10/sec --limit-burst 20 -j LOG --log-prefix "${USERNAME}_ACCESS: " --log-level 4
    echo "已添加 iptables 日志规则: $VPN_IP"
else
    echo "iptables 日志规则已存在: $VPN_IP"
fi

# 保存 iptables
iptables-save > "$IPTABLES_RULES_FILE"
if [ $? -eq 0 ]; then
    echo "成功保存 iptables 规则到 $IPTABLES_RULES_FILE"
else
    echo "错误: 保存 iptables 规则失败"
    exit 1
fi

# 创建日志目录
mkdir -p "$VPN_LOG_DIR"

# 创建 rsyslog 配置
echo '' > "$RSYSLOG_CONF"
printf ':msg, contains, "%s" %s/%s.log\n' "${USERNAME}_ACCESS" "$VPN_LOG_DIR" "$USERNAME" >> "$RSYSLOG_CONF"
echo '& stop' >> "$RSYSLOG_CONF"

# 重启 rsyslog
echo "重启 rsyslog 服务以应用新配置"
systemctl restart rsyslog
if [ $? -eq 0 ]; then
    echo "成功配置 rsyslog 日志到 $VPN_LOG_DIR/$USERNAME.log"
else
    echo "错误: rsyslog 服务重启失败"
    exit 1
fi


自动化删除用户脚本 delete.sh

主要功能:

  • Expect 自动选择菜单“撤销用户”
  • 如果用户不存在,也会清理 index.txt 避免残留
  • 脚本健壮性:超时 / 格式检查 / 异常提示

关键点

  • 正则匹配菜单选项找到用户索引
  • 确保删除证书后清理 Easy-RSA 索引文件
#!/usr/bin/expect -f

set timeout 30
set SCRIPT "/home/ec2-user/openvpn-install.sh"
set USER_TO_REVOKE [lindex $argv 0]

# 检查是否传递了用户名参数
if { [llength $argv] == 0 } {
    send_user "Usage: $argv0 <username>\n"
    exit 1
}

# 验证用户名格式
if { ![regexp {^[a-zA-Z0-9_-]+$} $USER_TO_REVOKE] } {
    send_user "Error: Username must consist of alphanumeric characters, underscore, or dash.\n"
    exit 1
}

# 启动脚本
spawn $SCRIPT

expect {
    "Welcome to OpenVPN-install!" {
        send_user "OpenVPN-install started successfully.\n"
    }
    eof {
        send_user "openvpn-install.sh terminated unexpectedly.\n"
        exit 1
    }
    timeout {
        send_user "Timeout waiting for openvpn-install.sh to start.\n"
        exit 1
    }
}

# 选择“Revoke existing user”
expect {
    -re "What do you want to do\\?" {
        send_user "Matched 'What do you want to do?' prompt.\n"
        send "2\r"
    }
    timeout {
        send_user "Timeout waiting for 'What do you want to do?' prompt.\n"
        exit 1
    }
}

# 处理无用户情况
expect {
    "You have no existing clients!" {
        send_user "No existing clients found.\n"
        # 清理 index.txt 即使没有用户
        send_user "Cleaning up $USER_TO_REVOKE from index.txt...\n"
        set cmd "sed -i '/\\/CN=${USER_TO_REVOKE}\$/d' /etc/openvpn/easy-rsa/pki/index.txt"
        exec bash -c $cmd
        send_user "Cleanup completed.\n"
        exit 0
    }
    -re "Select the existing client certificate you want to revoke" {
        send_user "Client list displayed successfully.\n"
    }
    timeout {
        send_user "Timeout waiting for client list.\n"
        exit 1
    }
}

# 捕获用户列表
expect {
    -re "\r\n(.+)\r\nSelect" {
        set user_list $expect_out(1,string)
        send_user "Captured user list:\n$user_list\n"
    }
    timeout {
        send_user "Timeout waiting for user list.\n"
        exit 1
    }
}

# 查找目标用户名
set user_index -1
set lines [split $user_list "\n"]
foreach line $lines {
    if {[string match "*$USER_TO_REVOKE*" $line]} {
        if {[regexp {^\s*(\d+)\)\s+.*$} $line match user_number]} {
            set user_index $user_number
            send_user "Found user '$USER_TO_REVOKE' with index $user_index\n"
        }
    }
}

# 如果找不到用户
if {$user_index == -1} {
    send_user "User '$USER_TO_REVOKE' not found in the list.\n"
    send_user "Cleaning up $USER_TO_REVOKE from index.txt...\n"
    set cmd "sed -i '/\\/CN=${USER_TO_REVOKE}\$/d' /etc/openvpn/easy-rsa/pki/index.txt"
    exec bash -c $cmd
    send_user "Cleanup completed.\n"
    exit 0
}

# 输入编号
send "$user_index\r"

# 等待撤销确认
expect {
    -re "Certificate for client $USER_TO_REVOKE revoked\\." {
        send_user "User '$USER_TO_REVOKE' has been revoked successfully.\n"
        send_user "Cleaning up $USER_TO_REVOKE from index.txt...\n"
        set cmd "sed -i '/\\/CN=${USER_TO_REVOKE}\$/d' /etc/openvpn/easy-rsa/pki/index.txt"
        exec bash -c $cmd
        send_user "Cleanup completed.\n"
    }
    timeout {
        send_user "Timeout waiting for revocation confirmation.\n"
        exit 1
    }
}

后端 API(Flask)

提供两个主要接口:

  • POST /create-vpn → 调用 create_ovpn.sh
  • POST /delete → 调用 delete.sh

实现细节:

  • 使用 subprocess.run 调用外部脚本,限制执行时间
  • 捕获 stdout / stderr 返回给前端
  • 状态码区分(200 成功 / 400 用户已存在 / 404 用户不存在 / 500 失败)

代码核心部分:

from flask import Flask, request, jsonify
from flask_cors import CORS
import subprocess
import logging
import os

# 配置日志
logging.basicConfig(
    level=logging.DEBUG,
    format='[%(asctime)s] %(levelname)s in %(module)s: %(message)s'
)

app = Flask(__name__)

# 配置 CORS
CORS(app, resources={
    r"/create-vpn": {"origins": "http://localhost:4200"},
    r"/delete": {"origins": "http://localhost:4200"}
})

@app.route('/create-vpn', methods=['POST'])
def create_vpn():
    data = request.get_json()
    logging.debug(f"收到请求体: {data}")

    username = data.get('username')
    password = data.get('password')
    vpn_ip = data.get('vpn_ip')

    if not username or not password or not vpn_ip:
        logging.warning("缺少用户名、密码或 VPN IP")
        return jsonify({'message': '用户名、密码和 VPN IP 是必需的'}), 400

    script_path = '/root/create_ovpn.sh'
    command = f"bash {script_path} {username} {password} {vpn_ip}"

    logging.info(f"准备执行命令: {command}")

    try:
        env = os.environ.copy()
        env['PATH'] = f"/usr/bin:/bin:{env.get('PATH', '')}"

        result = subprocess.run(
            command,
            shell=True,
            capture_output=True,
            text=True,
            timeout=60,
            cwd='/home/ec2-user',
            env=env
        )

        logging.debug(f"stdout: {result.stdout}")
        logging.debug(f"stderr: {result.stderr}")

        if result.returncode == 0:
            logging.info("脚本执行成功")
            return jsonify({
                'message': 'VPN 配置生成成功',
                'vpnFilePath': f'/home/ec2-user/{username}.ovpn',
                'stdout': result.stdout
            }), 200
        elif result.returncode == 2:
            logging.warning(f"用户 {username} 已存在")
            return jsonify({
                'message': f'用户 {username} 已存在',
                'stderr': result.stderr,
                'stdout': result.stdout
            }), 400
        else:
            logging.error("脚本执行失败")
            return jsonify({
                'message': '脚本执行失败',
                'stderr': result.stderr,
                'stdout': result.stdout
            }), 500

    except subprocess.TimeoutExpired:
        logging.error("脚本执行超时")
        return jsonify({'message': '脚本执行超时'}), 500
    except Exception as e:
        logging.exception("执行脚本发生异常")
        return jsonify({'message': '调用脚本失败', 'error': str(e)}), 500

@app.route('/delete', methods=['POST'])
def delete_vpn():
    data = request.get_json()
    logging.debug(f"收到删除请求体: {data}")

    username = data.get('username')
    if not username:
        logging.warning("缺少用户名")
        return jsonify({'message': '用户名是必需的'}), 400

    script_path = '/root/delete.sh'
    command = f"{script_path} {username}"

    logging.info(f"准备执行删除命令: {command}")

    try:
        env = os.environ.copy()
        env['PATH'] = f"/usr/bin:/bin:{env.get('PATH', '')}"

        result = subprocess.run(
            command,
            shell=True,
            capture_output=True,
            text=True,
            timeout=60,
            cwd='/home/ec2-user',
            env=env
        )

        logging.debug(f"stdout: {result.stdout}")
        logging.debug(f"stderr: {result.stderr}")

        if result.returncode == 0:
            logging.info(f"成功删除用户 {username}")
            return jsonify({
                'message': f'用户 {username} 删除成功',
                'stdout': result.stdout
            }), 200
        elif result.returncode == 2:
            logging.warning(f"用户 {username} 不存在")
            return jsonify({
                'message': f'用户 {username} 不存在',
                'stderr': result.stderr,
                'stdout': result.stdout
            }), 404
        else:
            logging.error(f"删除用户 {username} 失败")
            return jsonify({
                'message': f'删除用户 {username} 失败',
                'stderr': result.stderr,
                'stdout': result.stdout
            }), 500

    except subprocess.TimeoutExpired:
        logging.error(f"删除用户 {username} 脚本执行超时")
        return jsonify({'message': '脚本执行超时'}), 500
    except Exception as e:
        logging.exception(f"删除用户 {username} 发生异常")
        return jsonify({'message': '调用删除脚本失败', 'error': str(e)}), 500

if __name__ == '__main__':
    logging.info("VPN 服务启动中...监听 0.0.0.0:5000")
    app.run(host='0.0.0.0', port=5000)

日志与审计

每个用户的访问日志会记录在:

/var/log/vpn/{username}.log

内容大概是这样

image-hBvU.png

SRC是使用openvpn用户的ip,DST是他访问的地址

自动生ovpn文件后,在后端接口还可以加入scp或者其他方式从线上服务器直接将ovpn文件拉倒线下,实现自动化方式