Files
openvpn-manager/backend/pkg/openvpn/manager.go
T
cnbugs 4c8b7b5188 Add per-instance/user access whitelist
Features:
- New Instance.AccessMode: "open" (default) or "whitelist"
- New Instance.AllowNetworks + VPNUser.AllowNetworks: list of CIDRs
- Effective whitelist = instance allow_networks ∪ user allow_networks (dedup)
- Auto-generates client-connect.sh / client-disconnect.sh for OpenVPN:
    * Reads ccd/<cn> to extract CIDRs
    * Pushes "route <ip> <mask>" to client (client side)
    * Inserts iptables ACCEPT rules in FORWARD chain (server side, defense in depth)
    * Cleans up rules on disconnect
- server.conf auto-includes client-connect / client-disconnect directives
  and push "redirect-gateway def1 bypass-dhcp" in whitelist mode
- ccd/<cn> file format: first line ifconfig-push (static IP), then one CIDR per line
- Editing instance allow_networks refreshes all users' ccd automatically
- New PUT /api/instances/:id/users/:uid endpoint
- CIDR format validation; reject malformed inputs with friendly errors
- Dashboard shows whitelist_instances count and per-instance allow_networks table

Docs:
- README: new section "三、访问控制(白名单模式)" with usage, validation, pitfalls
- docs/API.md: updated Instance / VPNUser model + create/update payloads
- Renumbered client usage section as 四
2026-08-09 20:47:21 +08:00

567 lines
20 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"
"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 参数(轻量: 1024,生产可改为 2048/4096
if _, err := os.Stat(filepath.Join(m.pkiDir, "dh.pem")); os.IsNotExist(err) {
if err := runShell(`openssl dhparam -out "` + m.pkiDir + `/dh.pem" 1024 2>/dev/null`); err != nil {
return fmt.Errorf("generate DH: %w", err)
}
}
// 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")
conf.WriteString("dh " + filepath.Join(m.pkiDir, "dh.pem") + "\n")
conf.WriteString("tls-auth " + filepath.Join(m.pkiDir, "ta.key") + " 0\n")
conf.WriteString("topology subnet\n")
conf.WriteString("server " + 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
}
conf.WriteString("push \"" + line + "\"\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 != "" {
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
}
func pemTrim(b []byte) string {
return strings.TrimSpace(string(b))
}