Files
openvpn-manager/backend/pkg/openvpn/manager.go
T
cnbugs ae02b60d67 Fix OpenVPN start: CIDR→netmask, dhcp-option DNS, ccd dir, dh none ECDHE
Four bugs prevented OpenVPN from actually listening on its UDP port:

1. server.conf line 'server 10.8.0.0/24' — OpenVPN 2.5 rejects CIDR.
   Fix: cidrToServerDirective() converts CIDR to 'NETWORK NETMASK'
   (e.g. '10.8.0.0 255.255.255.0') using net.ParseCIDR.

2. push_dns '1.1.1.1 8.8.8.8' was emitted as a single push directive
   instead of multiple 'push dhcp-option DNS x' lines.
   Fix: split space-separated IPs into individual push directives;
   pass through if already 'dhcp-option ...' form.

3. client-config-dir referenced a ccd/ directory that was never created.
   Fix: CreateInstance now MkdirAll(ccd); WriteServerConf also
   defensively MkdirAll(extraDir) before writing the directive.

4. dh.pem generated with 1024-bit DH params → OpenSSL 3.0 refuses
   with 'dh key too small'. Fix: use 'dh none' in server.conf so
   OpenVPN 2.4+ uses ECDHE key exchange — no DH params needed at all.
   Removed the slow openssl dhparam generation from EnsureCA.

Verified: instance starts, OpenVPN parses config successfully,
now fails only at TUNSETIFF (expected: requires root/CAP_NET_ADMIN).
2026-08-09 22:17:29 +08:00

588 lines
21 KiB
Go

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
}
func NewManager(binary, dataDir string) *Manager {
return &Manager{
binary: binary,
dataDir: dataDir,
pkiDir: filepath.Join(dataDir, "pki"),
}
}
// 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
if err := runShell(`openssl req -x509 -newkey rsa:2048 -nodes \
-keyout "`+m.pkiDir+`/ca.key" \
-out "`+m.pkiDir+`/ca.crt" \
-days 3650 -subj "/CN=OpenVPN-Manager-CA" 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-key\npersist-tun\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("cipher " + orDefault(in.Cipher, "AES-256-GCM") + "\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.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)。
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)
}
// 用 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 2>/dev/null`, csr, caCrt, caKey, crt)); 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-key\npersist-tun\n")
b.WriteString("cipher " + orDefault(in.Cipher, "AES-256-GCM") + "\n")
b.WriteString("auth " + orDefault(in.AuthDigest, "SHA256") + "\n")
b.WriteString("remote-cert-tls server\n")
b.WriteString("verb 3\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)
}
// 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))
}