Initial commit: OpenVPN Manager v1.0

OpenVPN Web management console with multi-instance support, client cert
issuance, traffic/connection auditing, certificate expiry reminders,
auto backup/restore.

Stack:
- Backend: Go 1.21+ (Gin + JWT)
- Frontend: Vue 3 + Element Plus + ECharts + Vite
- Storage: JSON file (db.json) + filesystem (pki/, instances/, clients/, backups/)

Features:
- Multi-instance OpenVPN management (independent port/proto/subnet/PKI)
- One-click client certificate issuance with .ovpn (embedded certs)
- Certificate expiry reminders (30-day threshold)
- Connection log parsing (status-version 3)
- Auto backup/restore (tar.gz)
- Audit log for all write operations
- JWT auth (12h TTL)
- One-line install.sh for Ubuntu/Debian/RHEL/Fedora
This commit is contained in:
cnbugs
2026-08-09 20:32:37 +08:00
commit 77f8b59290
34 changed files with 6063 additions and 0 deletions
+381
View File
@@ -0,0 +1,381 @@
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.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)
}
// 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))
}