package openvpn import ( "bufio" "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/(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\n") b.WriteString(string(caCrt)) b.WriteString("\n\n") b.WriteString("\n\n") b.WriteString(string(crt)) b.WriteString("\n\n") b.WriteString("\n\n") b.WriteString(string(key)) b.WriteString("\n\n") b.WriteString("\n\n") b.WriteString(string(taKey)) b.WriteString("\n\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= // - 作用:读 ccd/,根据其白名单在 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/ 读取 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,([^,]+),`) reCli = regexp.MustCompile(`^CLIENT_LIST,([^,]+),([^,]+),([^,]+),(\d+),(\d+),`) reTime = regexp.MustCompile(`^Connected Since,([^,]+),`) ) func ParseStatusReader(r io.Reader) ([]StatusEntry, error) { sc := bufio.NewScanner(r) sc.Buffer(make([]byte, 64*1024), 1024*1024) var out []StatusEntry for sc.Scan() { line := sc.Text() switch { case strings.HasPrefix(line, "CLIENT_LIST,"): m := reCli.FindStringSubmatch(line) if m == nil { continue } connected, _ := time.Parse("Mon Jan 2 15:04:05 2006", m[2]) out = append(out, StatusEntry{ CommonName: m[1], RealAddress: m[3], VPNAddress: m[4], BytesRecv: atoi64(m[5]), BytesSent: atoi64(m[6]), ConnectedAt: connected, }) } } return out, sc.Err() } // 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 } // 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)) }