Files
openvpn-manager/backend/pkg/openvpn/manager.go
T
cnbugs 5725149546 Fix v3 parser: scan for numeric fields (IPv6 field position varies)
The user's OpenVPN 2.5.11 outputs CLIENT_LIST with 13 tab-separated
fields:
  CLIENT_LIST CN RealAddr VPNAddr IPv6(empty) (empty) BytesRecv
              BytesSent ConnectedSince (time_t) Username ClientID
              PeerID Cipher

But OpenVPN 2.6+ drops the IPv6 and Cipher fields (11 columns), and
some versions include 'Connected Since (time_t)' which moves all
positions.

Solution: parse by content type, not position:
  - parts[0] = 'CLIENT_LIST'
  - parts[1] = CN (string, may be empty)
  - parts[2] = RealAddr (string with ':')
  - parts[3] = VPNAddr (IP, may be empty)
  - parts[4..] = scan for first pure-numeric field → BytesRecv
  - BytesRecv+1 = BytesSent
  - BytesRecv+2 = ConnectedSince

Verified against the user's real status.log:
  CN=test RealAddr=123.118.73.196:17564 VPNAddr=10.8.0.2
  bytesIn=629025 bytesOut=539217
2026-08-10 00:22:02 +08:00

877 lines
32 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package openvpn
import (
"bufio"
"bytes"
"crypto/x509"
"encoding/pem"
"fmt"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"openvpn-manager/internal/model"
)
// Manager 封装与本地 OpenVPN 的交互:生成 server.conf、生成证书、解析 status。
// 本服务不直接以 root 启动 OpenVPN 进程(避免安全/权限问题),而是在用户机器上
// 由 systemd / launchctl / 自定义脚本拉起。Manager 负责:
// 1. 生成易用的 server.conf / client.ovpn / ccd
// 2. 解析 openvpn --status 输出
// 3. 调用 openssl/easyrsa 完成证书签发与吊销
// 4. 提供 ping 命令检查进程可达性
type Manager struct {
binary string
dataDir string
pkiDir string
port int // manager HTTP port (for verify script callback)
}
func NewManager(binary, dataDir string) *Manager {
return &Manager{
binary: binary,
dataDir: dataDir,
pkiDir: filepath.Join(dataDir, "pki"),
port: 8089,
}
}
// SetPort 设置 manager 的 HTTP 端口,供 verify.sh 回调使用。
func (m *Manager) SetPort(port int) {
m.port = port
}
// PKIPath 返回实例的 PKI 目录(用于存放 issued/private 等子目录)。
func (m *Manager) PKIPath(instanceName string) string {
return filepath.Join(m.dataDir, "instances", instanceName, "pki")
}
// InstanceConf 返回实例 server.conf 路径。
func (m *Manager) InstanceConf(instanceName string) string {
return filepath.Join(m.dataDir, "instances", instanceName, "server.conf")
}
// EnsureCA 初始化全局 CA。幂等。
func (m *Manager) EnsureCA() error {
if _, err := os.Stat(filepath.Join(m.pkiDir, "ca.crt")); err == nil {
return nil
}
if err := os.MkdirAll(m.pkiDir, 0o700); err != nil {
return err
}
// 使用 openssl 直接生成自签 CA,避免依赖 easyrsa。
// 必须带 X.509 扩展 (basicConstraints + keyUsage)
// 否则 OpenVPN 2.7 / OpenSSL 3.x 会报 "Certificate does not have key usage extension"。
caExt := filepath.Join(m.pkiDir, "ca-ext.cnf")
if err := os.WriteFile(caExt, []byte(`[req]
distinguished_name = req_dn
prompt = no
[req_dn]
CN = OpenVPN-Manager-CA
[v3_ca]
basicConstraints = critical,CA:TRUE
keyUsage = critical,keyCertSign,cRLSign
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always,issuer
`), 0o600); err != nil {
return fmt.Errorf("write ca ext: %w", err)
}
if err := runShell(`openssl req -x509 -newkey rsa:2048 -nodes \
-keyout "`+m.pkiDir+`/ca.key" \
-out "`+m.pkiDir+`/ca.crt" \
-days 3650 -sha256 \
-subj "/CN=OpenVPN-Manager-CA" \
-extensions v3_ca -config "` + caExt + `" 2>/dev/null`); err != nil {
return fmt.Errorf("generate CA: %w", err)
}
// DH 参数:用 "dh none" 走 ECDHE,不再需要 dh.pem 文件。
// (保留目录以兼容旧配置)
// TLS-Auth key
if _, err := os.Stat(filepath.Join(m.pkiDir, "ta.key")); os.IsNotExist(err) {
if err := runShell(`openvpn --genkey secret "` + m.pkiDir + `/ta.key"`); err != nil {
return fmt.Errorf("generate ta.key: %w", err)
}
}
return nil
}
// WriteServerConf 生成 server.conf。
func (m *Manager) WriteServerConf(in *model.Instance, extraDir string) error {
dir := filepath.Dir(m.InstanceConf(in.Name))
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
conf := strings.Builder{}
conf.WriteString("# Generated by openvpn-manager at " + time.Now().Format(time.RFC3339) + "\n")
conf.WriteString("port " + strconv.Itoa(in.Port) + "\n")
conf.WriteString("proto " + in.Proto + "\n")
conf.WriteString("dev " + in.Dev + "\n")
conf.WriteString("ca " + filepath.Join(m.pkiDir, "ca.crt") + "\n")
conf.WriteString("cert " + filepath.Join(m.PKIPath(in.Name), "issued", "server.crt") + "\n")
conf.WriteString("key " + filepath.Join(m.PKIPath(in.Name), "private", "server.key") + "\n")
// 用 "dh none" 让 OpenVPN 走 ECDHE 密钥交换(OpenVPN 2.4+),
// 不需要 DH 参数文件,既安全又避免 openssl dhparam 2048 的长等待。
conf.WriteString("dh none\n")
conf.WriteString("tls-auth " + filepath.Join(m.pkiDir, "ta.key") + " 0\n")
conf.WriteString("topology subnet\n")
conf.WriteString("server " + cidrToServerDirective(in.Subnet) + "\n")
conf.WriteString("ifconfig-pool-persist " + filepath.Join(dir, "ipp.txt") + "\n")
conf.WriteString("keepalive 10 120\n")
conf.WriteString("persist-tun\n")
conf.WriteString("data-ciphers " + orDefault(in.Cipher, "AES-256-GCM") + ":AES-128-GCM\n")
conf.WriteString("status " + filepath.Join(dir, "status.log") + " 10\n")
conf.WriteString("status-version 3\n")
conf.WriteString("log " + filepath.Join(dir, "logs", "openvpn.log") + "\n")
conf.WriteString("verb 3\n")
conf.WriteString("auth " + orDefault(in.AuthDigest, "SHA256") + "\n")
if in.PushDNS != "" {
for _, line := range strings.Split(in.PushDNS, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// 支持 "1.1.1.1 8.8.8.8" 或 "dhcp-option DNS 1.1.1.1" 两种写法
if strings.HasPrefix(line, "dhcp-option") {
conf.WriteString("push \"" + line + "\"\n")
} else {
for _, ip := range strings.Fields(line) {
conf.WriteString("push \"dhcp-option DNS " + ip + "\"\n")
}
}
}
}
if in.PushRoutes != "" {
for _, line := range strings.Split(in.PushRoutes, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
conf.WriteString("push \"route " + line + "\"\n")
}
}
if extraDir != "" {
_ = os.MkdirAll(extraDir, 0o755)
conf.WriteString("client-config-dir " + extraDir + "\n")
}
if in.AccessMode == model.AccessWhitelist {
// 白名单模式:openVPN 在每次客户端连接时调用脚本应用 iptables
// 该脚本由 manager.AllowNetworksScript() 生成,负责:
// 1. 写入合并后的白名单到 ccd/<cn>(push route)
// 2. 在 FORWARD 链中按白名单过滤 tun->eth 的流量
conf.WriteString("client-connect " + filepath.Join(m.InstanceDir(in.Name), "client-connect.sh") + "\n")
conf.WriteString("client-disconnect " + filepath.Join(m.InstanceDir(in.Name), "client-disconnect.sh") + "\n")
// 强制客户端把流量全部走 VPN(否则白名单的 push route 没意义)
// 仍允许 bypass-dhcp 保留局域网 DNS
conf.WriteString("push \"redirect-gateway def1 bypass-dhcp\"\n")
}
if in.AuthMode == model.AuthCertPassword {
// 双因素认证:证书 + 用户名密码
// OpenVPN 以 via-file 方式调用 verify 脚本,$1 为临时文件路径:
// 第 1 行 = VPN用户名(=证书CN)
// 第 2 行 = 用户输入的明文密码
// 脚本退出码 0 = 允许,非 0 = 拒绝
verifyScript := filepath.Join(m.InstanceDir(in.Name), "verify.sh")
conf.WriteString("script-security 2\n")
conf.WriteString("auth-user-pass-verify \"" + verifyScript + "\" via-file\n")
conf.WriteString("verify-client-cert require\n")
_ = m.WriteVerifyScriptWithPort(in.Name, verifyScript, m.port)
}
if in.Extra != "" {
conf.WriteString("\n# --- custom ---\n")
conf.WriteString(in.Extra + "\n")
}
return os.WriteFile(m.InstanceConf(in.Name), []byte(conf.String()), 0o644)
}
// IssueCert 为客户端签发证书。clientName = CN。
// 返回 (certPath, keyPath, error)。
// 当 clientName == "server" 时签发带 serverAuth EKU 的服务端证书,
// 否则签发带 clientAuth EKU 的客户端证书。
func (m *Manager) IssueCert(instanceName, clientName string) (string, string, error) {
pki := m.PKIPath(instanceName)
issuedDir := filepath.Join(pki, "issued")
privDir := filepath.Join(pki, "private")
certsDir := filepath.Join(pki, "certs")
if err := os.MkdirAll(issuedDir, 0o755); err != nil {
return "", "", err
}
if err := os.MkdirAll(privDir, 0o700); err != nil {
return "", "", err
}
if err := os.MkdirAll(certsDir, 0o755); err != nil {
return "", "", err
}
crt := filepath.Join(issuedDir, clientName+".crt")
key := filepath.Join(privDir, clientName+".key")
csr := filepath.Join(pki, clientName+".csr")
// 已存在则跳过
if _, err := os.Stat(crt); err == nil {
return crt, key, nil
}
// 生成私钥
if err := runShell(fmt.Sprintf(`openssl genrsa -out "%s" 2048 2>/dev/null`, key)); err != nil {
return "", "", fmt.Errorf("gen key: %w", err)
}
// 生成 CSR
if err := runShell(fmt.Sprintf(`openssl req -new -key "%s" -out "%s" -subj "/CN=%s" 2>/dev/null`,
key, csr, clientName)); err != nil {
return "", "", fmt.Errorf("gen csr: %w", err)
}
// 写签发扩展配置(区分 server / client)
extFile := filepath.Join(pki, clientName+"-ext.cnf")
extContent := `[v3]
basicConstraints = critical,CA:FALSE
keyUsage = critical,digitalSignature,keyEncipherment
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer
`
if clientName == "server" {
extContent += "extendedKeyUsage = serverAuth\n"
} else {
extContent += "extendedKeyUsage = clientAuth\n"
}
if err := os.WriteFile(extFile, []byte(extContent), 0o600); err != nil {
return "", "", fmt.Errorf("write ext: %w", err)
}
// 用 CA 签发(带扩展)
caCrt := filepath.Join(m.pkiDir, "ca.crt")
caKey := filepath.Join(m.pkiDir, "ca.key")
if err := runShell(fmt.Sprintf(`openssl x509 -req -in "%s" -CA "%s" -CAkey "%s" -CAcreateserial \
-out "%s" -days 3650 -sha256 \
-extfile "%s" -extensions v3 2>/dev/null`,
csr, caCrt, caKey, crt, extFile)); err != nil {
return "", "", fmt.Errorf("sign cert: %w", err)
}
_ = os.Remove(csr)
return crt, key, nil
}
// IssueServerCert 为实例本身签发服务端证书。
func (m *Manager) IssueServerCert(instanceName string) error {
_, _, err := m.IssueCert(instanceName, "server")
return err
}
// RevokeCert 通过 CA 吊销证书(生成 CRL)。
// 由于完整 CRL 链路较重,本管理器使用更简单的"禁用"模型:保留吊销标记,
// 并通过 ccd 与 enabled=false 让 openvpn 拒绝连接。
func (m *Manager) RevokeCert(instanceName, clientName string) error {
_ = instanceName
_ = clientName
// 简化模型:不做 OpenSSL 吊销,仅通过 Store 标记 + 强制策略。
// 真正的吊销可通过 `openssl ca -revoke` 扩展。
return nil
}
// GenerateClientOVPN 生成 .ovpn 客户端配置,含内嵌证书便于分发。
func (m *Manager) GenerateClientOVPN(in *model.Instance, username, remoteHost string) (string, error) {
pki := m.PKIPath(in.Name)
crt, err := os.ReadFile(filepath.Join(pki, "issued", username+".crt"))
if err != nil {
return "", err
}
key, err := os.ReadFile(filepath.Join(pki, "private", username+".key"))
if err != nil {
return "", err
}
caCrt, err := os.ReadFile(filepath.Join(m.pkiDir, "ca.crt"))
if err != nil {
return "", err
}
taKey, err := os.ReadFile(filepath.Join(m.pkiDir, "ta.key"))
if err != nil {
return "", err
}
if remoteHost == "" {
remoteHost = "vpn.example.com"
}
var b strings.Builder
b.WriteString("client\n")
b.WriteString("dev " + in.Dev + "\n")
b.WriteString("proto " + in.Proto + "\n")
b.WriteString("remote " + remoteHost + " " + strconv.Itoa(in.Port) + "\n")
b.WriteString("resolv-retry infinite\n")
b.WriteString("nobind\n")
b.WriteString("persist-tun\n")
b.WriteString("data-ciphers " + orDefault(in.Cipher, "AES-256-GCM") + ":AES-128-GCM\n")
b.WriteString("auth " + orDefault(in.AuthDigest, "SHA256") + "\n")
b.WriteString("remote-cert-tls server\n")
b.WriteString("verb 3\n")
// 双因素模式:客户端需要输入用户名密码
if in.AuthMode == model.AuthCertPassword {
b.WriteString("auth-user-pass\n")
}
b.WriteString("\n<ca>\n")
b.WriteString(string(caCrt))
b.WriteString("\n</ca>\n")
b.WriteString("\n<cert>\n")
b.WriteString(string(crt))
b.WriteString("\n</cert>\n")
b.WriteString("\n<key>\n")
b.WriteString(string(key))
b.WriteString("\n</key>\n")
b.WriteString("\n<tls-auth>\n")
b.WriteString(string(taKey))
b.WriteString("\n</tls-auth>\n")
b.WriteString("key-direction 1\n")
out := filepath.Join(m.dataDir, "clients", in.Name, username+".ovpn")
if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
return "", err
}
if err := os.WriteFile(out, []byte(b.String()), 0o600); err != nil {
return "", err
}
return out, nil
}
// GenerateClientOVPNFor 是基于 VPNUser + Instance 的便捷封装。
func (m *Manager) GenerateClientOVPNFor(u *model.VPNUser, in *model.Instance, remoteHost string) (string, error) {
return m.GenerateClientOVPN(in, u.Username, remoteHost)
}
// WriteCCD 写入客户端静态配置(固定 IP 等)。
func (m *Manager) WriteCCD(instanceName, username, body string) error {
dir := filepath.Join(m.dataDir, "instances", instanceName, "ccd")
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, username), []byte(body+"\n"), 0o644)
}
// ValidateCIDR 校验 CIDR 是否合法(IP + /prefix 长度)。
func ValidateCIDR(s string) error {
ip, ipnet, err := net.ParseCIDR(strings.TrimSpace(s))
if err != nil {
return fmt.Errorf("invalid CIDR %q: %w", s, err)
}
if ip.To4() == nil && ip.To16() == nil {
return fmt.Errorf("invalid IP in CIDR %q", s)
}
_ = ipnet
return nil
}
// MergeAllowNetworks 合并实例级与用户级白名单,去重去空。
func MergeAllowNetworks(a, b []string) []string {
seen := map[string]bool{}
out := []string{}
for _, s := range append(append([]string{}, a...), b...) {
s = strings.TrimSpace(s)
if s == "" || seen[s] {
continue
}
seen[s] = true
out = append(out, s)
}
return out
}
// CIDRToRoute 将 CIDR 转为 OpenVPN push route 用的字符串(IP + Netmask)。
// 仅支持 IPv4。IPv6 用 /128 全 0 掩码(占位,本项目主要面向 IPv4)。
func CIDRToRoute(s string) (string, error) {
if err := ValidateCIDR(s); err != nil {
return "", err
}
_, ipnet, _ := net.ParseCIDR(strings.TrimSpace(s))
mask := ipnet.Mask
ones, _ := ipnet.Mask.Size()
// 转 4 字节点分十进制
if len(mask) == 4 {
return fmt.Sprintf("%s %d.%d.%d.%d", ipnet.IP.String(), mask[0], mask[1], mask[2], mask[3]), nil
}
// IPv6: 暂用 /128 占位,openVPN 走 route-ipv6 单独处理
_ = ones
return "", fmt.Errorf("IPv6 not yet supported: %s", s)
}
// AllowNetworksScript 生成 client-connect 脚本:
// - 输入: $1 = 连接上下文, $common_name=<CN>
// - 作用:读 ccd/<cn>,根据其白名单在 FORWARD 链插入 ACCEPT
//
// client-connect 脚本的 stdin 是 OpenVPN 提供的环境变量,我们打印
// "push route ..." 行让 openvpn 推给客户端。
func (m *Manager) AllowNetworksScript(instanceName string, nets []string) (string, error) {
dir := filepath.Join(m.dataDir, "instances", instanceName)
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
script := filepath.Join(dir, "client-connect.sh")
var b strings.Builder
b.WriteString("#!/bin/bash\n")
b.WriteString("# Generated by openvpn-manager at " + time.Now().Format(time.RFC3339) + "\n")
b.WriteString("set -e\n\n")
b.WriteString("# OpenVPN 客户端连接脚本: 接受 ccd 之外额外的 push 指令\n")
b.WriteString("# 我们使用 stdin 提供的环境变量(由 OpenVPN 注入),按白名单允许的网段\n")
b.WriteString("# 写入 FORWARD 链,然后通过 echo 'push \"route ...\"' 回传给 OpenVPN。\n\n")
b.WriteString("CN=\"${common_name:-}\"\n")
b.WriteString("VPN_IP=\"${ifconfig_pool_remote_ip:-${trusted_ip:-}}\"\n\n")
b.WriteString("# 仅处理已启用的用户:从 ccd/<cn> 读取 allow_networks(若文件不存在则拒绝推送任何路由)\n")
b.WriteString("CCD_FILE=\"$(dirname \"$0\")/ccd/${CN}\"\n")
b.WriteString("if [[ ! -f \"$CCD_FILE\" ]]; then\n")
b.WriteString(" echo \"# ccd file missing for $CN, denying\" >&2\n")
b.WriteString(" exit 1\n")
b.WriteString("fi\n\n")
b.WriteString("# 解析 allow_networks(grep + awk)\n")
b.WriteString("split_cidr() {\n")
b.WriteString(" local cidr=\"$1\"\n")
b.WriteString(" local ip mask\n")
b.WriteString(" ip=\"${cidr%/*}\"\n")
b.WriteString(" local prefix=\"${cidr##*/}\"\n")
b.WriteString(" case \"$prefix\" in\n")
b.WriteString(" 32) mask=\"255.255.255.255\" ;;\n")
b.WriteString(" 31) mask=\"255.255.255.254\" ;;\n")
b.WriteString(" 30) mask=\"255.255.255.252\" ;;\n")
b.WriteString(" 29) mask=\"255.255.255.248\" ;;\n")
b.WriteString(" 28) mask=\"255.255.255.240\" ;;\n")
b.WriteString(" 27) mask=\"255.255.255.224\" ;;\n")
b.WriteString(" 26) mask=\"255.255.255.192\" ;;\n")
b.WriteString(" 25) mask=\"255.255.255.128\" ;;\n")
b.WriteString(" 24) mask=\"255.255.255.0\" ;;\n")
b.WriteString(" 23) mask=\"255.255.254.0\" ;;\n")
b.WriteString(" 22) mask=\"255.255.252.0\" ;;\n")
b.WriteString(" 21) mask=\"255.255.248.0\" ;;\n")
b.WriteString(" 20) mask=\"255.255.240.0\" ;;\n")
b.WriteString(" 19) mask=\"255.255.224.0\" ;;\n")
b.WriteString(" 18) mask=\"255.255.192.0\" ;;\n")
b.WriteString(" 17) mask=\"255.255.128.0\" ;;\n")
b.WriteString(" 16) mask=\"255.255.0.0\" ;;\n")
b.WriteString(" 15) mask=\"255.254.0.0\" ;;\n")
b.WriteString(" 14) mask=\"255.252.0.0\" ;;\n")
b.WriteString(" 13) mask=\"255.248.0.0\" ;;\n")
b.WriteString(" 12) mask=\"255.240.0.0\" ;;\n")
b.WriteString(" 11) mask=\"255.224.0.0\" ;;\n")
b.WriteString(" 10) mask=\"255.192.0.0\" ;;\n")
b.WriteString(" 9) mask=\"255.128.0.0\" ;;\n")
b.WriteString(" 8) mask=\"255.0.0.0\" ;;\n")
b.WriteString(" *) echo \"unsupported prefix /$prefix for $cidr\" >&2; return 1 ;;\n")
b.WriteString(" esac\n")
b.WriteString(" echo \"$ip $mask\"\n")
b.WriteString("}\n\n")
b.WriteString("while IFS= read -r net; do\n")
b.WriteString(" [[ -z \"$net\" || \"$net\" =~ ^# ]] && continue\n")
b.WriteString(" pair=\"$(split_cidr \"$net\")\" || continue\n")
b.WriteString(" echo \"push \\\"route ${pair}\\\"\"\n")
b.WriteString("done < <(grep -E '^[0-9]' \"$CCD_FILE\" || true)\n\n")
b.WriteString("# iptables:放行此客户端 VPN_IP -> 白名单网段的 FORWARD\n")
b.WriteString("if [[ -n \"$VPN_IP\" ]]; then\n")
b.WriteString(" while IFS= read -r net; do\n")
b.WriteString(" [[ -z \"$net\" || \"$net\" =~ ^# ]] && continue\n")
b.WriteString(" /sbin/iptables -I FORWARD -s \"$VPN_IP\" -d \"$net\" -j ACCEPT 2>/dev/null || true\n")
b.WriteString(" /sbin/iptables -I FORWARD -d \"$VPN_IP\" -s \"$net\" -m state --state ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || true\n")
b.WriteString(" done < <(grep -E '^[0-9]' \"$CCD_FILE\" || true)\n")
b.WriteString("fi\n\n")
b.WriteString("exit 0\n")
if err := os.WriteFile(script, []byte(b.String()), 0o755); err != nil {
return "", err
}
// 同样的逻辑写到 disconnect 脚本:清理 iptables 规则
dis := filepath.Join(dir, "client-disconnect.sh")
var d strings.Builder
d.WriteString("#!/bin/bash\n")
d.WriteString("# openvpn-manager generated\n")
d.WriteString("VPN_IP=\"${ifconfig_pool_remote_ip:-${trusted_ip:-}}\"\n")
d.WriteString("CN=\"${common_name:-}\"\n")
d.WriteString("if [[ -n \"$VPN_IP\" ]]; then\n")
d.WriteString(" /sbin/iptables -D FORWARD -s \"$VPN_IP\" -j ACCEPT 2>/dev/null || true\n")
d.WriteString(" /sbin/iptables -D FORWARD -d \"$VPN_IP\" -m state --state ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || true\n")
d.WriteString("fi\n")
d.WriteString("exit 0\n")
if err := os.WriteFile(dis, []byte(d.String()), 0o755); err != nil {
return "", err
}
return script, nil
}
// InstanceDir 返回实例的根目录(供脚本路径使用)。
func (m *Manager) InstanceDir(name string) string {
return filepath.Join(m.dataDir, "instances", name)
}
// WriteVerifyScript 生成 auth-user-pass-verify 认证脚本。
// OpenVPN 以 via-env 方式调用此脚本:
// 环境变量 username = 证书 CN
// 环境变量 password = 用户输入的明文密码
//
// 脚本通过 curl 调用 manager 的 /api/vpn/verify 接口验证密码,
// manager 用 Go bcrypt 库验证,无需 Python 模块或 htpasswd。
func (m *Manager) WriteVerifyScript(instanceName, scriptPath string) error {
// 内嵌占位符,实际端口由 WriteServerConf 调用前从 config 获取
return m.WriteVerifyScriptWithPort(instanceName, scriptPath, 8089)
}
// WriteVerifyScriptWithPort 生成带指定端口的认证脚本。
func (m *Manager) WriteVerifyScriptWithPort(instanceName, scriptPath string, port int) error {
script := fmt.Sprintf(`#!/bin/bash
# OpenVPN auth-user-pass-verify script (via-file).
# Generated by openvpn-manager — do NOT edit manually.
# OpenVPN writes credentials to a temp file passed as $1:
# line 1 = username, line 2 = password
LOG="/tmp/openvpn-verify.log"
PORT=%d
INSTANCE="%s"
CREDS_FILE="$1"
echo "$(date '+%%Y-%%m-%%d %%H:%%M:%%S') VERIFY instance=$INSTANCE creds_file=$CREDS_FILE" >> "$LOG"
if [ -z "$CREDS_FILE" ] || [ ! -f "$CREDS_FILE" ]; then
echo "$(date '+%%Y-%%m-%%d %%H:%%M:%%S') DENY no credentials file" >> "$LOG"
exit 1
fi
user=$(sed -n '1p' "$CREDS_FILE")
pass=$(sed -n '2p' "$CREDS_FILE")
echo "$(date '+%%Y-%%m-%%d %%H:%%M:%%S') user=$user pass_len=${#pass}" >> "$LOG"
if [ -z "$user" ] || [ -z "$pass" ]; then
echo "$(date '+%%Y-%%m-%%d %%H:%%M:%%S') DENY empty credentials" >> "$LOG"
exit 1
fi
CODE=$(curl -s -o /tmp/ovpn-verify-resp.txt -w "%%{http_code}" \
--connect-timeout 3 --max-time 5 \
-X POST "http://127.0.0.1:${PORT}/api/vpn/verify" \
-H "Content-Type: application/json" \
-d "{\"instance\":\"${INSTANCE}\",\"username\":\"${user}\",\"password\":\"${pass}\"}" \
2>>"$LOG" || echo "000")
BODY=$(cat /tmp/ovpn-verify-resp.txt 2>/dev/null)
echo "$(date '+%%Y-%%m-%%d %%H:%%M:%%S') HTTP=$CODE BODY=$BODY" >> "$LOG"
if [ "$CODE" = "200" ]; then
echo "$(date '+%%Y-%%m-%%d %%H:%%M:%%S') ALLOW" >> "$LOG"
exit 0
else
echo "$(date '+%%Y-%%m-%%d %%H:%%M:%%S') DENY" >> "$LOG"
exit 1
fi
`, port, instanceName)
if err := os.MkdirAll(filepath.Dir(scriptPath), 0o755); err != nil {
return err
}
return os.WriteFile(scriptPath, []byte(script), 0o755)
}
// SetIptablesForInstance 应用一个实例的默认 FORWARD 策略。
// 在白名单模式下,我们希望"默认拒绝 tun->eth 的转发",再由 client-connect 脚本按需放行。
// 这个函数在实例启动时调用一次。
//
// 注意: iptables 修改宿主机的防火墙,需要 root 权限。脚本会以 noop 模式尝试运行,
// 失败仅记录日志,不会阻塞实例启动(允许开发环境无 iptables)。
func (m *Manager) SetIptablesForInstance(in *model.Instance) error {
if in.AccessMode != model.AccessWhitelist {
return nil
}
tunName := in.Dev // tun0 / tun1 ...
if in.Dev == "tap" {
tunName = "tap0"
}
// 默认策略:FORWARD 链中,凡是从 tun 设备出去的,如果未命中 ACCEPT 一律 DROP
// 这条规则加到 FORWARD 链末尾,优先级低于 client-connect 插入的 ACCEPT
cmd := exec.Command("/sbin/iptables", "-A", "FORWARD", "-i", tunName, "-j", "REJECT",
"--reject-with", "icmp-net-unreachable")
out, err := cmd.CombinedOutput()
if err != nil {
// 不致命:开发机可能没装 iptables 或没权限
return fmt.Errorf("iptables: %v: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
// DeleteCCD 移除 ccd 文件。
func (m *Manager) DeleteCCD(instanceName, username string) error {
p := filepath.Join(m.dataDir, "instances", instanceName, "ccd", username)
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// ParseStatus 解析 openvpn --status 输出。
// 输入是 status-version 3 的文本,格式详见 OpenVPN 文档。
func (m *Manager) ParseStatus(path string) ([]StatusEntry, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
return ParseStatusReader(f)
}
// StatusEntry 是 status 文件中的一条 CLIENT 记录。
type StatusEntry struct {
CommonName string
RealAddress string // IP:port
VPNAddress string // 客户端 VPN 内网 IP
BytesRecv int64
BytesSent int64
ConnectedAt time.Time
}
var (
reHdr = regexp.MustCompile(`^Updated,([^,]+),`)
// status-version 3 CLIENT_LIST 格式(11个字段):
// CLIENT_LIST,CommonName,RealAddress,VirtualAddress,VirtualIPv6,BytesRecv,BytesSent,ConnectedSince,Username,ClientID,PeerID
reCli3 = regexp.MustCompile(`^CLIENT_LIST,([^,]+),([^,]+),([^,]+),([^,]*),(\d+),(\d+),([^,]+),`)
// status-version 1 CLIENT 数据行(5 列):
// Common Name,Real Address,Bytes Received,Bytes Sent,Connected Since
// test,1.2.3.4:1234,1024,2048,"Sat Aug 9 16:14:10 2025"
reCli1 = regexp.MustCompile(`^([^,]+),([^,]+),(\d+),(\d+),"?([^",]+)"?,?`)
reTime = regexp.MustCompile(`^Connected Since,([^,]+),`)
)
// ParseStatusReader 解析 OpenVPN status 文件。
// 同时支持 status-version 1(人类可读列名)和 status-version 3CSV/TAB)。
//
// v1 格式(默认,OpenVPN 未指定 status-version 时使用):
// Common Name,Real Address,Bytes Received,Bytes Sent,Connected Since
// test,1.2.3.4:1234,1024,2048,Sat Aug 9 16:14:10 2025
//
// v3 格式(status-version 311-13 字段):
// CLIENT_LIST,CN,RealAddr,VPNAddr,IPv6,BytesRecv,BytesSent,ConnectedSince[,Username,ClientID,PeerID[,Cipher]]
// 注意不同 OpenVPN 版本 IPv6 字段可能存在/缺失/为空,不能依赖固定位置,改为按字段名匹配。
func ParseStatusReader(r io.Reader) ([]StatusEntry, error) {
// 先全读入内存,方便做两次扫描(v1 需要从 ROUTING_TABLE 取 VPN IP
b, err := io.ReadAll(r)
if err != nil {
return nil, err
}
sc := bufio.NewScanner(bytes.NewReader(b))
sc.Buffer(make([]byte, 64*1024), 1024*1024)
var out []StatusEntry
mode := "" // "" / "v1" / "v3"
for sc.Scan() {
line := sc.Text()
if mode == "" {
if strings.HasPrefix(line, "TITLE,") || strings.HasPrefix(line, "TITLE ") {
mode = "v3"
} else if strings.HasPrefix(line, "OpenVPN CLIENT LIST") {
mode = "v1"
} else {
continue
}
continue
}
switch mode {
case "v3":
// 解析 HEADER 行得到列名(以便按位置名取值)。但因为 OpenVPN 2.5 的 HEADER
// 没有"Virtual IPv6 Address"和"Cipher"两个空字段(或反之),位置经常错位。
// 退而求其次:不用 HEADER,直接固定按 parts[1..] 取,加上 IPv6 字段探测:
// - 标准 11 列(无 IPv6、无 Cipher):CN,RealAddr,VPNAddr,BytesRecv,
// BytesSent,Time,TimeT,User,ClientID,PeerID
// - 12 列(无 IPv6、有 Cipher):...,Cipher
// - 13 列(都有):CN,RealAddr,VPNAddr,IPv6(empty),?,BytesRecv,
// BytesSent,Time,TimeT,User,ClientID,PeerID,Cipher
if !strings.HasPrefix(line, "CLIENT_LIST") {
continue
}
sep := " "
if !strings.Contains(line, " ") {
sep = ","
}
parts := strings.Split(line, sep)
if len(parts) < 7 {
continue
}
// 检测是否有 "Virtual IPv6 Address" 字段:扫描整行,
// CLIENT_LIST 后面第 4 个 tab 段如果是空字符串 -> 有 IPv6 字段
data := parts[1:]
// 找第二个非空 tab 段:如果 [3] 是空且 [4] 也是空 -> IPv6 在 [3];否则 IPv6 不存在
// 用更简单的策略:按数据值猜测
// [1]=RealAddr 必含 ":" 或纯数字IP; [2]=VPNAddr 必为 IP(可能是空 IPv4,IPv4 全数字)
// bytes 字段是数字。先找到第一个纯数字段作为 BytesRecv
e := StatusEntry{CommonName: strings.TrimSpace(data[0])}
if len(data) > 1 {
e.RealAddress = strings.TrimSpace(data[1])
}
// 寻找第一个看起来像数字的字段位置 = Bytes Received
// 排除 RealAddress(包含 ":")、VPNAddress(纯 IP)、ConnectedSince(包含 "-")
bytesIdx := -1
for i := 2; i < len(data); i++ {
v := strings.TrimSpace(data[i])
if v == "" {
continue
}
// 纯数字(可能很长,如 1786292298)
if _, err := strconv.ParseInt(v, 10, 64); err == nil {
bytesIdx = i
break
}
}
if bytesIdx == -1 || bytesIdx+1 >= len(data) {
continue
}
e.BytesRecv = atoi64(data[bytesIdx])
e.BytesSent = atoi64(data[bytesIdx+1])
// VPNAddress:如果有 IPv6 字段,它在 [3];否则在 [3] 是直接 IP
// 这里已用 CN+RealAddr 标识,VPNAddress 是次要的,从 [2] 取或留空
if len(data) > 2 {
e.VPNAddress = strings.TrimSpace(data[2])
}
// ConnectedSince:在 BytesRecv+2 位置(后面跟着 time_t)
if bytesIdx+2 < len(data) {
e.ConnectedAt = parseConnTime(strings.TrimSpace(data[bytesIdx+2]))
}
if e.CommonName != "" {
out = append(out, e)
}
case "v1":
// 跳过表头和节标记
if strings.HasPrefix(line, "Common Name,") || strings.HasPrefix(line, "HEADER,") ||
strings.HasPrefix(line, "ROUTING TABLE") || strings.HasPrefix(line, "Virtual Address,") ||
strings.HasPrefix(line, "GLOBAL STATS") || strings.HasPrefix(line, "END") ||
strings.HasPrefix(line, "Updated,") {
continue
}
parts := strings.Split(line, ",")
if len(parts) < 5 {
continue
}
out = append(out, StatusEntry{
CommonName: strings.TrimSpace(parts[0]),
RealAddress: strings.TrimSpace(parts[1]),
VPNAddress: "",
BytesRecv: atoi64(parts[2]),
BytesSent: atoi64(parts[3]),
ConnectedAt: parseConnTime(strings.TrimSpace(parts[4])),
})
}
}
// v1 模式:用 ROUTING_TABLE 补充 VPNAddress
if mode == "v1" {
v1VPN := parseV1RoutingTable(bytes.NewReader(b))
for i := range out {
if ip, ok := v1VPN[out[i].CommonName]; ok {
out[i].VPNAddress = ip
}
}
}
return out, sc.Err()
}
// parseV1RoutingTable 从 v1 status 中解析 ROUTING_TABLE 部分,返回 CN→VPNIP。
func parseV1RoutingTable(r io.Reader) map[string]string {
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 64*1024), 1024*1024)
out := map[string]string{}
inRouting := false
for sc.Scan() {
line := sc.Text()
if strings.HasPrefix(line, "ROUTING TABLE") {
inRouting = true
continue
}
if !inRouting {
continue
}
if strings.HasPrefix(line, "GLOBAL STATS") || strings.HasPrefix(line, "END") {
break
}
parts := strings.Split(line, ",")
if len(parts) < 2 {
continue
}
out[parts[1]] = parts[0]
}
return out
}
// IsRunning 通过 TCP 探测 openvpn 端口是否可连,仅供参考。
func (m *Manager) IsRunning(host string, port int) bool {
addr := fmt.Sprintf("%s:%d", host, port)
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
if err != nil {
return false
}
_ = conn.Close()
return true
}
// CertNotAfter 解析证书的过期时间。
func (m *Manager) CertNotAfter(certPath string) (time.Time, error) {
b, err := os.ReadFile(certPath)
if err != nil {
return time.Time{}, err
}
block, _ := pem.Decode(b)
if block == nil {
return time.Time{}, fmt.Errorf("not a pem file")
}
c, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return time.Time{}, err
}
return c.NotAfter, nil
}
// ----- helpers -----
func runShell(s string) error {
cmd := exec.Command("bash", "-c", s)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func orDefault(s, def string) string {
if strings.TrimSpace(s) == "" {
return def
}
return s
}
func atoi64(s string) int64 {
n, _ := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
return n
}
// parseConnTime 解析 OpenVPN status.log 中的时间字段。
// status-version 3 格式: "Mon Jan 2 15:04:05 2006" (注意可能有双空格)
// 部分版本使用 Unix 时间戳。
func parseConnTime(s string) time.Time {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}
}
// 先尝试 Unix 时间戳
if ts, err := strconv.ParseInt(s, 10, 64); err == nil && ts > 1000000000 {
return time.Unix(ts, 0)
}
// 标准格式
formats := []string{
"Mon Jan 2 15:04:05 2006",
"Mon Jan 2 15:04:05 2006",
"2006-01-02 15:04:05",
time.RFC3339,
}
for _, f := range formats {
if t, err := time.Parse(f, s); err == nil {
return t
}
}
return time.Time{}
}
// cidrToServerDirective 把 "10.8.0.0/24" 转成 OpenVPN server 指令需要的
// "10.8.0.0 255.255.255.0" 格式(网络地址 + 点分掩码)。
// 如果输入不含 "/"(已经是 "ip mask" 形式),原样返回。
func cidrToServerDirective(subnet string) string {
subnet = strings.TrimSpace(subnet)
if !strings.Contains(subnet, "/") {
return subnet
}
_, ipnet, err := net.ParseCIDR(subnet)
if err != nil {
return subnet // 让 OpenVPN 报错,总比生成错误格式好
}
return ipnet.IP.String() + " " + net.IP(ipnet.Mask).String()
}
func pemTrim(b []byte) string {
return strings.TrimSpace(string(b))
}