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/(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\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) } // 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)) }