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
+402
View File
@@ -0,0 +1,402 @@
package api
import (
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"openvpn-manager/internal/config"
"openvpn-manager/internal/middleware"
"openvpn-manager/internal/model"
"openvpn-manager/internal/service"
)
type Server struct {
Cfg *config.Config
Svc *service.Service
}
func NewServer(cfg *config.Config, svc *service.Service) *Server {
return &Server{Cfg: cfg, Svc: svc}
}
func (s *Server) Router(distDir string) *gin.Engine {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(gin.Recovery())
r.Use(cors.New(cors.Config{
AllowAllOrigins: true,
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Authorization", "Content-Type"},
MaxAge: 12 * time.Hour,
}))
// 公共
r.POST("/api/login", s.login)
r.GET("/api/health", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
auth := r.Group("/api", middleware.JWTAuth(s.Cfg.JWTSecret))
{
auth.GET("/me", s.me)
auth.POST("/logout", s.logout)
auth.GET("/dashboard", s.dashboard)
auth.GET("/instances", s.listInstances)
auth.POST("/instances", s.createInstance)
auth.GET("/instances/:id", s.getInstance)
auth.PUT("/instances/:id", s.updateInstance)
auth.DELETE("/instances/:id", s.deleteInstance)
auth.POST("/instances/:id/start", s.startInstance)
auth.POST("/instances/:id/stop", s.stopInstance)
auth.GET("/instances/:id/online", s.onlineClients)
auth.GET("/instances/:id/users", s.listUsers)
auth.POST("/instances/:id/users", s.createUser)
auth.POST("/instances/:id/users/:uid/revoke", s.revokeUser)
auth.DELETE("/instances/:id/users/:uid", s.deleteUser)
auth.GET("/instances/:id/users/:uid/ovpn", s.downloadOVPN)
auth.GET("/certs", s.listCerts)
auth.GET("/connlogs", s.listConnLogs)
auth.GET("/backups", s.listBackups)
auth.POST("/backups", s.createBackup)
auth.POST("/backups/:id/restore", s.restoreBackup)
auth.DELETE("/backups/:id", s.deleteBackup)
auth.GET("/audits", s.listAudits)
}
// 静态前端
if distDir != "" {
if _, err := os.Stat(distDir); err == nil {
r.NoRoute(func(c *gin.Context) {
path := filepath.Join(distDir, c.Request.URL.Path)
if !fileExists(path) || strings.HasSuffix(c.Request.URL.Path, "/") {
c.File(filepath.Join(distDir, "index.html"))
return
}
c.File(path)
})
}
}
return r
}
func fileExists(p string) bool {
fi, err := os.Stat(p)
return err == nil && !fi.IsDir()
}
// ---------- handlers ----------
func (s *Server) login(c *gin.Context) {
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
return
}
if req.Username != s.Cfg.AdminUser || req.Password != s.Cfg.AdminPass {
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
return
}
tok, err := middleware.IssueToken(s.Cfg.JWTSecret, req.Username, "admin", 12*time.Hour)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "issue token failed"})
return
}
c.JSON(200, gin.H{"token": tok, "username": req.Username})
}
func (s *Server) logout(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }
func (s *Server) me(c *gin.Context) {
u, _ := c.Get("user")
c.JSON(200, gin.H{"username": u})
}
// dashboard 汇总统计
func (s *Server) dashboard(c *gin.Context) {
instances := s.Svc.Store.ListInstances()
users := s.Svc.Store.ListUsers("")
certs, _ := s.Svc.CertInfos()
expiring := 0
for _, ct := range certs {
if ct.DaysLeft <= 30 {
expiring++
}
}
online := 0
for _, in := range instances {
cl, _ := s.Svc.ListOnline(in.ID)
online += len(cl)
}
c.JSON(200, gin.H{
"instances": len(instances),
"running": countByStatus(instances, "running"),
"users": len(users),
"active_users": countEnabled(users),
"online": online,
"expiring_certs": expiring,
"recent_audits": s.Svc.Store.ListAudits(20),
"recent_conn_logs": s.Svc.Store.ListConnLogs("", 20),
})
}
// ---- instances ----
func (s *Server) listInstances(c *gin.Context) {
c.JSON(200, s.Svc.Store.ListInstances())
}
func (s *Server) getInstance(c *gin.Context) {
in, err := s.Svc.Store.GetInstance(c.Param("id"))
if err != nil {
c.JSON(404, gin.H{"error": err.Error()})
return
}
c.JSON(200, in)
}
func (s *Server) createInstance(c *gin.Context) {
var in model.Instance
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(400, gin.H{"error": "bad request"})
return
}
out, err := s.Svc.CreateInstance(in)
if err != nil {
s.Svc.AuditForAPI(c, "create_instance", in.Name, err.Error(), "failed")
c.JSON(400, gin.H{"error": err.Error()})
return
}
s.Svc.AuditForAPI(c, "create_instance", in.Name, "port="+itoa(out.Port), "ok")
c.JSON(200, out)
}
func (s *Server) updateInstance(c *gin.Context) {
var in model.Instance
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(400, gin.H{"error": "bad request"})
return
}
in.ID = c.Param("id")
if err := s.Svc.UpdateInstance(in); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
s.Svc.AuditForAPI(c, "update_instance", in.Name, "", "ok")
c.JSON(200, in)
}
func (s *Server) deleteInstance(c *gin.Context) {
id := c.Param("id")
if err := s.Svc.DeleteInstance(id); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
s.Svc.AuditForAPI(c, "delete_instance", id, "", "ok")
c.JSON(200, gin.H{"ok": true})
}
func (s *Server) startInstance(c *gin.Context) {
id := c.Param("id")
if err := s.Svc.StartInstance(id); err != nil {
s.Svc.AuditForAPI(c, "start_instance", id, err.Error(), "failed")
c.JSON(400, gin.H{"error": err.Error()})
return
}
s.Svc.AuditForAPI(c, "start_instance", id, "", "ok")
c.JSON(200, gin.H{"ok": true})
}
func (s *Server) stopInstance(c *gin.Context) {
id := c.Param("id")
if err := s.Svc.StopInstance(id); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
s.Svc.AuditForAPI(c, "stop_instance", id, "", "ok")
c.JSON(200, gin.H{"ok": true})
}
func (s *Server) onlineClients(c *gin.Context) {
cl, err := s.Svc.ListOnline(c.Param("id"))
if err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
c.JSON(200, cl)
}
// ---- users ----
func (s *Server) listUsers(c *gin.Context) {
c.JSON(200, s.Svc.Store.ListUsers(c.Param("id")))
}
func (s *Server) createUser(c *gin.Context) {
var u model.VPNUser
if err := c.ShouldBindJSON(&u); err != nil {
c.JSON(400, gin.H{"error": "bad request"})
return
}
u.InstanceID = c.Param("id")
out, err := s.Svc.CreateUser(u)
if err != nil {
s.Svc.AuditForAPI(c, "create_user", u.Username, err.Error(), "failed")
c.JSON(400, gin.H{"error": err.Error()})
return
}
s.Svc.AuditForAPI(c, "create_user", u.Username, "instance="+u.InstanceID, "ok")
c.JSON(200, out)
}
func (s *Server) revokeUser(c *gin.Context) {
uid := c.Param("uid")
if err := s.Svc.RevokeUser(uid); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
s.Svc.AuditForAPI(c, "revoke_user", uid, "", "ok")
c.JSON(200, gin.H{"ok": true})
}
func (s *Server) deleteUser(c *gin.Context) {
uid := c.Param("uid")
if err := s.Svc.DeleteUser(uid); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
s.Svc.AuditForAPI(c, "delete_user", uid, "", "ok")
c.JSON(200, gin.H{"ok": true})
}
func (s *Server) downloadOVPN(c *gin.Context) {
host := c.Query("host")
if host == "" {
host = c.Request.Host
}
p, err := s.Svc.GenerateOVPN(c.Param("uid"), host)
if err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
c.Header("Content-Disposition", "attachment; filename="+filepath.Base(p))
c.File(p)
}
// ---- certs ----
func (s *Server) listCerts(c *gin.Context) {
certs, err := s.Svc.CertInfos()
if err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
c.JSON(200, certs)
}
// ---- conn logs ----
func (s *Server) listConnLogs(c *gin.Context) {
c.JSON(200, s.Svc.Store.ListConnLogs(c.Query("instance"), 200))
}
// ---- backups ----
func (s *Server) listBackups(c *gin.Context) {
c.JSON(200, s.Svc.Store.ListBackups())
}
func (s *Server) createBackup(c *gin.Context) {
var req struct{ Note string `json:"note"` }
_ = c.ShouldBindJSON(&req)
b, err := s.Svc.Backup(req.Note)
if err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
s.Svc.AuditForAPI(c, "create_backup", b.ID, "", "ok")
c.JSON(200, b)
}
func (s *Server) restoreBackup(c *gin.Context) {
id := c.Param("id")
if err := s.Svc.Restore(id); err != nil {
s.Svc.AuditForAPI(c, "restore_backup", id, err.Error(), "failed")
c.JSON(400, gin.H{"error": err.Error()})
return
}
s.Svc.AuditForAPI(c, "restore_backup", id, "", "ok")
c.JSON(200, gin.H{"ok": true})
}
func (s *Server) deleteBackup(c *gin.Context) {
id := c.Param("id")
if err := s.Svc.DeleteBackup(id); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"ok": true})
}
// ---- audits ----
func (s *Server) listAudits(c *gin.Context) {
c.JSON(200, s.Svc.Store.ListAudits(500))
}
// ---- helpers ----
func countByStatus(in []model.Instance, status string) int {
n := 0
for _, x := range in {
if x.Status == status {
n++
}
}
return n
}
func countEnabled(u []model.VPNUser) int {
n := 0
for _, x := range u {
if x.Enabled {
n++
}
}
return n
}
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := false
if n < 0 {
neg = true
n = -n
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
+78
View File
@@ -0,0 +1,78 @@
package config
import (
"os"
"path/filepath"
"strconv"
)
// Config 包含 Web 管理界面的运行时配置。
// 数据目录结构:
// <DataDir>/
// config.yaml - 全局配置
// db.json - 用户/实例元数据(简化存储)
// pki/ - CA 与证书(PKI 基础设施)
// ca.crt / ca.key / dh.pem / ta.key
// instances/<name>/ - 每个 OpenVPN 实例的目录
// server.conf
// pki/{issued,private,csd}
// ccd/ - 客户端配置目录
// status.log - openvpn --status 周期输出
// logs/ - openvpn 运行日志
// clients/<inst>/<user>/ - 生成的 .ovpn 文件
// backups/ - 备份归档
type Config struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
DataDir string `yaml:"data_dir"`
JWTSecret string `yaml:"jwt_secret"`
AdminUser string `yaml:"admin_user"`
AdminPass string `yaml:"admin_pass"`
LogLevel string `yaml:"log_level"`
OpenVPNBin string `yaml:"openvpn_bin"`
EasyrsaBin string `yaml:"easyrsa_bin"`
}
func Load(dataDir string) *Config {
c := &Config{
Host: getenv("OVPNMGR_HOST", "0.0.0.0"),
Port: getenvInt("OVPNMGR_PORT", 8089),
DataDir: dataDir,
JWTSecret: getenv("OVPNMGR_JWT_SECRET", "change-me-in-prod-please"),
AdminUser: getenv("OVPNMGR_ADMIN_USER", "admin"),
AdminPass: getenv("OVPNMGR_ADMIN_PASS", "admin123"),
LogLevel: getenv("OVPNMGR_LOG_LEVEL", "info"),
OpenVPNBin: getenv("OVPNMGR_OPENVPN_BIN", "openvpn"),
EasyrsaBin: getenv("OVPNMGR_EASYRSA_BIN", "easyrsa"),
}
return c
}
func (c *Config) PKIDir() string { return filepath.Join(c.DataDir, "pki") }
func (c *Config) InstancesDir() string { return filepath.Join(c.DataDir, "instances") }
func (c *Config) InstanceDir(name string) string {
return filepath.Join(c.InstancesDir(), name)
}
func (c *Config) ClientsDir() string { return filepath.Join(c.DataDir, "clients") }
func (c *Config) BackupsDir() string { return filepath.Join(c.DataDir, "backups") }
func (c *Config) DBFile() string { return filepath.Join(c.DataDir, "db.json") }
func getenv(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func getenvInt(k string, def int) int {
v := os.Getenv(k)
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil {
return def
}
return n
}
+57
View File
@@ -0,0 +1,57 @@
package middleware
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
Username string `json:"username"`
Role string `json:"role"`
jwt.RegisteredClaims
}
func IssueToken(secret, username, role string, ttl time.Duration) (string, error) {
c := Claims{
Username: username,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
t := jwt.NewWithClaims(jwt.SigningMethodHS256, c)
return t.SignedString([]byte(secret))
}
func JWTAuth(secret string) gin.HandlerFunc {
return func(c *gin.Context) {
h := c.GetHeader("Authorization")
if h == "" {
h = c.Query("token")
}
const prefix = "Bearer "
if strings.HasPrefix(h, prefix) {
h = strings.TrimPrefix(h, prefix)
}
if h == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
return
}
claims := &Claims{}
_, err := jwt.ParseWithClaims(h, claims, func(t *jwt.Token) (interface{}, error) {
return []byte(secret), nil
})
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
c.Set("user", claims.Username)
c.Set("role", claims.Role)
c.Next()
}
}
+84
View File
@@ -0,0 +1,84 @@
package model
import "time"
// Instance 一个 OpenVPN 服务端实例。
// 每个实例使用独立端口与 PKI,运行在自己的 server.conf 下,
// 由 systemd 单元(或后台进程)托管,本服务通过 management 接口与之通信。
type Instance struct {
ID string `json:"id"`
Name string `json:"name"` // 唯一名,作为目录名
Port int `json:"port"` // openvpn 监听端口
Proto string `json:"proto"` // udp / tcp
Dev string `json:"dev"` // tun / tap
Subnet string `json:"subnet"` // 客户端子网,如 10.8.0.0/24
Cipher string `json:"cipher"` // 加密算法
AuthDigest string `json:"auth_digest"` // 摘要算法
PushDNS string `json:"push_dns"` // push "dhcp-option DNS x.x.x.x"
PushRoutes string `json:"push_routes"` // 多行
Extra string `json:"extra"` // 用户追加配置
Status string `json:"status"` // running/stopped/error
PID int `json:"pid"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// VPNUser 一个 OpenVPN 客户端用户。
// 证书从对应实例的 PKI 中签发,可下载 .ovpn 客户端配置。
type VPNUser struct {
ID string `json:"id"`
InstanceID string `json:"instance_id"`
Username string `json:"username"` // 证书 CN
RealName string `json:"real_name"` // 备注
Email string `json:"email"`
Enabled bool `json:"enabled"` // 是否启用, false 即吊销/禁用
StaticIP string `json:"static_ip"` // ccd 固定地址, 空表示动态
CreatedAt time.Time `json:"created_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
}
// AuditLog 操作审计日志
type AuditLog struct {
ID string `json:"id"`
Time time.Time `json:"time"`
User string `json:"user"` // 操作者(管理用户)
Action string `json:"action"` // create_instance, revoke_user, ...
Target string `json:"target"` // 目标对象
Result string `json:"result"` // ok / failed
Detail string `json:"detail"`
IP string `json:"ip"`
}
// ConnectionLog 来自 OpenVPN status 的实时/历史连接记录。
// 周期由 OpenVPN 自身写入 status.log,本服务周期性读取解析后入库。
type ConnectionLog struct {
InstanceID string `json:"instance_id"`
CommonName string `json:"common_name"`
RealIP string `json:"real_ip"` // 客户端公网 IP
VPNIP string `json:"vpn_ip"` // 分配的 VPN 内网 IP
BytesIn int64 `json:"bytes_in"`
BytesOut int64 `json:"bytes_out"`
ConnectedAt time.Time `json:"connected_at"`
DisconnectedAt *time.Time `json:"disconnected_at,omitempty"`
}
// Backup 一份备份归档
type Backup struct {
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
Size int64 `json:"size"`
Note string `json:"note"`
Filename string `json:"filename"`
Includes []string `json:"includes"`
}
// CertInfo 证书元数据(用于证书到期提醒)。
type CertInfo struct {
InstanceID string `json:"instance_id"`
Username string `json:"username"`
Subject string `json:"subject"`
NotBefore time.Time `json:"not_before"`
NotAfter time.Time `json:"not_after"`
DaysLeft int `json:"days_left"`
}
+489
View File
@@ -0,0 +1,489 @@
package service
import (
"archive/tar"
"compress/gzip"
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"openvpn-manager/internal/config"
"openvpn-manager/internal/model"
"openvpn-manager/internal/store"
"openvpn-manager/pkg/openvpn"
)
// Service 业务逻辑聚合,供 API 层调用。
// 任何对实例/用户/证书/备份的变更都应经过这里,从而写入审计日志。
type Service struct {
Cfg *config.Config
Store *store.Store
Ovm *openvpn.Manager
}
func New(cfg *config.Config, st *store.Store, ovm *openvpn.Manager) *Service {
return &Service{Cfg: cfg, Store: st, Ovm: ovm}
}
func (s *Service) audit(c context.Context, action, target, detail, result, ip string) {
username, _ := c.Value("user").(string)
if username == "" {
username = "system"
}
_ = s.Store.AppendAudit(model.AuditLog{
ID: uuid.NewString(),
Time: time.Now(),
User: username,
Action: action,
Target: target,
Result: result,
Detail: detail,
IP: ip,
})
}
// AuditForAPI 在 API 层被调用时手动写入(因为 gin context 转为 context.Context)。
func (s *Service) AuditForAPI(c *gin.Context, action, target, detail, result string) {
username, _ := c.Get("user")
un, _ := username.(string)
if un == "" {
un = "system"
}
_ = s.Store.AppendAudit(model.AuditLog{
ID: uuid.NewString(),
Time: time.Now(),
User: un,
Action: action,
Target: target,
Result: result,
Detail: detail,
IP: c.ClientIP(),
})
}
// CreateInstance 新建一个 OpenVPN 实例,并签发服务端证书、生成 server.conf。
func (s *Service) CreateInstance(in model.Instance) (*model.Instance, error) {
if in.Name == "" {
return nil, fmt.Errorf("name required")
}
if in.Port == 0 {
return nil, fmt.Errorf("port required")
}
if in.Proto == "" {
in.Proto = "udp"
}
if in.Dev == "" {
in.Dev = "tun"
}
if in.Subnet == "" {
in.Subnet = "10.8.0.0/24"
}
// 名称查重
if _, err := s.Store.GetInstanceByName(in.Name); err == nil {
return nil, fmt.Errorf("instance %s already exists", in.Name)
}
// 确保 CA
if err := s.Ovm.EnsureCA(); err != nil {
return nil, err
}
in.ID = uuid.NewString()
in.Status = "stopped"
in.CreatedAt = time.Now()
in.UpdatedAt = time.Now()
// 创建实例目录
if err := os.MkdirAll(s.Cfg.InstanceDir(in.Name), 0o755); err != nil {
return nil, err
}
if err := os.MkdirAll(filepath.Join(s.Cfg.InstanceDir(in.Name), "logs"), 0o755); err != nil {
return nil, err
}
// 签发服务端证书
if err := s.Ovm.IssueServerCert(in.Name); err != nil {
return nil, fmt.Errorf("issue server cert: %w", err)
}
// 写 server.conf
ccdDir := filepath.Join(s.Cfg.InstanceDir(in.Name), "ccd")
if err := s.Ovm.WriteServerConf(&in, ccdDir); err != nil {
return nil, fmt.Errorf("write conf: %w", err)
}
if err := s.Store.UpsertInstance(in); err != nil {
return nil, err
}
return &in, nil
}
// UpdateInstance 仅更新可热改字段(端口/子网需要重启生效)。
func (s *Service) UpdateInstance(in model.Instance) error {
old, err := s.Store.GetInstance(in.ID)
if err != nil {
return err
}
in.CreatedAt = old.CreatedAt
in.UpdatedAt = time.Now()
in.Status = old.Status
in.PID = old.PID
ccdDir := filepath.Join(s.Cfg.InstanceDir(in.Name), "ccd")
if err := s.Ovm.WriteServerConf(&in, ccdDir); err != nil {
return err
}
return s.Store.UpsertInstance(in)
}
// DeleteInstance 移除实例及其 PKI/配置。
func (s *Service) DeleteInstance(id string) error {
in, err := s.Store.GetInstance(id)
if err != nil {
return err
}
if in.Status == "running" {
_ = s.StopInstance(id)
}
// 清理用户记录与目录
for _, u := range s.Store.ListUsers(id) {
_ = s.Store.DeleteUser(u.ID)
}
_ = os.RemoveAll(s.Cfg.InstanceDir(in.Name))
_ = os.RemoveAll(filepath.Join(s.Cfg.ClientsDir(), in.Name))
return s.Store.DeleteInstance(id)
}
// StartInstance 在前台启动 openvpn。
// 注意:本服务应以 root 运行;非 root 场景下应通过 systemd 单元托管。
func (s *Service) StartInstance(id string) error {
in, err := s.Store.GetInstance(id)
if err != nil {
return err
}
if in.Status == "running" {
return fmt.Errorf("already running")
}
conf := s.Ovm.InstanceConf(in.Name)
if _, err := os.Stat(conf); err != nil {
return fmt.Errorf("conf missing: %w", err)
}
cmd := exec.Command(s.Cfg.OpenVPNBin,
"--cd", s.Cfg.InstanceDir(in.Name),
"--config", conf)
logf, _ := os.OpenFile(filepath.Join(s.Cfg.InstanceDir(in.Name), "logs", "openvpn-stdout.log"),
os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if logf != nil {
cmd.Stdout = logf
cmd.Stderr = logf
}
if err := cmd.Start(); err != nil {
return err
}
in.Status = "running"
in.PID = cmd.Process.Pid
in.UpdatedAt = time.Now()
_ = s.Store.UpsertInstance(*in)
// 后台释放
go func() {
_ = cmd.Wait()
// 进程退出时回写状态(简单模型)
cur, err := s.Store.GetInstance(id)
if err == nil && cur.PID == cmd.Process.Pid {
cur.Status = "stopped"
cur.PID = 0
cur.UpdatedAt = time.Now()
_ = s.Store.UpsertInstance(*cur)
}
}()
return nil
}
// StopInstance 通过 SIGTERM 停止实例。
func (s *Service) StopInstance(id string) error {
in, err := s.Store.GetInstance(id)
if err != nil {
return err
}
if in.PID == 0 {
in.Status = "stopped"
return s.Store.UpsertInstance(*in)
}
proc, err := os.FindProcess(in.PID)
if err != nil {
return err
}
if err := proc.Signal(os.Interrupt); err != nil {
// 兜底:直接 Kill
_ = proc.Signal(os.Kill)
}
in.Status = "stopped"
in.PID = 0
in.UpdatedAt = time.Now()
return s.Store.UpsertInstance(*in)
}
// CreateUser 新建客户端用户并签发证书。
func (s *Service) CreateUser(u model.VPNUser) (*model.VPNUser, error) {
if u.Username == "" {
return nil, fmt.Errorf("username required")
}
in, err := s.Store.GetInstance(u.InstanceID)
if err != nil {
return nil, err
}
if _, err := s.Store.GetUserByCN(u.InstanceID, u.Username); err == nil {
return nil, fmt.Errorf("user %s already exists", u.Username)
}
u.ID = uuid.NewString()
u.Enabled = true
u.CreatedAt = time.Now()
// 签发证书(Manager 按实例名索引 PKI
if _, _, err := s.Ovm.IssueCert(in.Name, u.Username); err != nil {
return nil, err
}
// CCD
if u.StaticIP != "" {
ccd := "ifconfig-push " + u.StaticIP + " 255.255.255.0\n"
if err := s.Ovm.WriteCCD(in.Name, u.Username, ccd); err != nil {
return nil, err
}
}
// 预生成 ovpn(以空 host 生成占位,用户在 UI 上下载)
if _, err := s.Ovm.GenerateClientOVPNFor(&u, in, "vpn.example.com"); err != nil {
return nil, err
}
if err := s.Store.UpsertUser(u); err != nil {
return nil, err
}
return &u, nil
}
// GenerateOVPN 下载/重新生成 .ovpnremoteHost 由前端传入。
func (s *Service) GenerateOVPN(userID, remoteHost string) (string, error) {
u, err := s.Store.GetUser(userID)
if err != nil {
return "", err
}
in, err := s.Store.GetInstance(u.InstanceID)
if err != nil {
return "", err
}
return s.Ovm.GenerateClientOVPNFor(u, in, remoteHost)
}
// RevokeUser 吊销用户:禁用 + 标记。
func (s *Service) RevokeUser(userID string) error {
u, err := s.Store.GetUser(userID)
if err != nil {
return err
}
u.Enabled = false
now := time.Now()
u.RevokedAt = &now
if err := s.Store.UpsertUser(*u); err != nil {
return err
}
// 在 ccd 写入禁用标记
in, err2 := s.Store.GetInstance(u.InstanceID)
if err2 == nil {
body := "# revoked by manager\n"
_ = s.Ovm.WriteCCD(in.Name, u.Username, body)
}
return nil
}
// DeleteUser 删除用户及证书。
func (s *Service) DeleteUser(userID string) error {
u, err := s.Store.GetUser(userID)
if err != nil {
return err
}
in, _ := s.Store.GetInstance(u.InstanceID)
if in != nil {
_ = s.Ovm.DeleteCCD(in.Name, u.Username)
_ = os.Remove(filepath.Join(s.Ovm.PKIPath(in.Name), "issued", u.Username+".crt"))
_ = os.Remove(filepath.Join(s.Ovm.PKIPath(in.Name), "private", u.Username+".key"))
_ = os.Remove(filepath.Join(s.Cfg.ClientsDir(), in.Name, u.Username+".ovpn"))
}
return s.Store.DeleteUser(userID)
}
// CertInfos 汇总所有用户证书的过期时间。
func (s *Service) CertInfos() ([]model.CertInfo, error) {
var out []model.CertInfo
now := time.Now()
for _, in := range s.Store.ListInstances() {
pki := s.Ovm.PKIPath(in.Name)
issuedDir := filepath.Join(pki, "issued")
entries, err := os.ReadDir(issuedDir)
if err != nil {
continue
}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".crt") {
continue
}
cn := strings.TrimSuffix(e.Name(), ".crt")
if cn == "server" {
continue
}
notAfter, err := s.Ovm.CertNotAfter(filepath.Join(issuedDir, e.Name()))
if err != nil {
continue
}
out = append(out, model.CertInfo{
InstanceID: in.ID,
Username: cn,
NotAfter: notAfter,
DaysLeft: int(notAfter.Sub(now).Hours() / 24),
})
}
}
return out, nil
}
// ListOnline 解析 status 文件获取在线客户端。
func (s *Service) ListOnline(instanceID string) ([]openvpn.StatusEntry, error) {
in, err := s.Store.GetInstance(instanceID)
if err != nil {
return nil, err
}
statusPath := filepath.Join(s.Cfg.InstanceDir(in.Name), "status.log")
return s.Ovm.ParseStatus(statusPath)
}
// Backup 创建 tar.gz 备份。
func (s *Service) Backup(note string) (*model.Backup, error) {
if err := os.MkdirAll(s.Cfg.BackupsDir(), 0o755); err != nil {
return nil, err
}
id := uuid.NewString()
ts := time.Now().Format("20060102-150405")
fp := filepath.Join(s.Cfg.BackupsDir(), "backup-"+ts+"-"+id[:8]+".tar.gz")
f, err := os.Create(fp)
if err != nil {
return nil, err
}
defer f.Close()
gz := gzip.NewWriter(f)
defer gz.Close()
tw := tar.NewWriter(gz)
defer tw.Close()
add := func(rel string) error {
abs := filepath.Join(s.Cfg.DataDir, rel)
return filepath.Walk(abs, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
return nil
}
hdr, err := tar.FileInfoHeader(info, "")
if err != nil {
return nil
}
hdr.Name = filepath.ToSlash(filepath.Join(rel, strings.TrimPrefix(path, abs)))
if err := tw.WriteHeader(hdr); err != nil {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil
}
_, _ = tw.Write(data)
return nil
})
}
for _, sub := range []string{"pki", "instances", "clients"} {
_ = add(sub)
}
b := &model.Backup{
ID: id,
CreatedAt: time.Now(),
Filename: filepath.Base(fp),
Note: note,
Includes: []string{"pki", "instances", "clients"},
}
fi, _ := os.Stat(fp)
if fi != nil {
b.Size = fi.Size()
}
_ = s.Store.AddBackup(*b)
return b, nil
}
// Restore 从备份恢复。会覆盖现有数据。
func (s *Service) Restore(backupID string) error {
var bk *model.Backup
for _, b := range s.Store.ListBackups() {
if b.ID == backupID {
b := b
bk = &b
break
}
}
if bk == nil {
return fmt.Errorf("backup not found")
}
src := filepath.Join(s.Cfg.BackupsDir(), bk.Filename)
f, err := os.Open(src)
if err != nil {
return err
}
defer f.Close()
var r io.Reader = f
if strings.HasSuffix(src, ".gz") {
gz, err := gzip.NewReader(f)
if err != nil {
return err
}
defer gz.Close()
r = gz
}
tr := tar.NewReader(r)
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}
target := filepath.Join(s.Cfg.DataDir, hdr.Name)
if hdr.FileInfo().IsDir() {
_ = os.MkdirAll(target, 0o755)
continue
}
_ = os.MkdirAll(filepath.Dir(target), 0o755)
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return err
}
_, _ = io.Copy(out, tr)
_ = out.Close()
}
return nil
}
// DeleteBackup 删除备份文件与索引。
func (s *Service) DeleteBackup(id string) error {
for _, b := range s.Store.ListBackups() {
if b.ID == id {
_ = os.Remove(filepath.Join(s.Cfg.BackupsDir(), b.Filename))
return s.Store.DeleteBackup(id)
}
}
return fmt.Errorf("not found")
}
// RandomToken 生成短随机串。
func RandomToken(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
+320
View File
@@ -0,0 +1,320 @@
package store
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"openvpn-manager/internal/model"
)
// Store 简易 JSON 文件存储:
// 适合中小规模运维工具,无需引入数据库。所有变更通过 RWMutex 保护。
type Store struct {
mu sync.RWMutex
path string
data Data
writeCh chan struct{}
}
type Data struct {
Instances []model.Instance `json:"instances"`
Users []model.VPNUser `json:"users"`
Audits []model.AuditLog `json:"audits"`
ConnLogs []model.ConnectionLog `json:"conn_logs"`
Backups []model.Backup `json:"backups"`
}
func Open(path string) (*Store, error) {
s := &Store{path: path}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, err
}
if _, err := os.Stat(path); os.IsNotExist(err) {
s.data = Data{}
if err := s.flush(); err != nil {
return nil, err
}
return s, nil
}
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
if len(b) == 0 {
s.data = Data{}
return s, nil
}
if err := json.Unmarshal(b, &s.data); err != nil {
return nil, fmt.Errorf("parse db: %w", err)
}
return s, nil
}
func (s *Store) flush() error {
b, err := json.MarshalIndent(s.data, "", " ")
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
// ---- Instances ----
func (s *Store) ListInstances() []model.Instance {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]model.Instance, len(s.data.Instances))
copy(out, s.data.Instances)
return out
}
func (s *Store) GetInstance(id string) (*model.Instance, error) {
s.mu.RLock()
defer s.mu.RUnlock()
for i := range s.data.Instances {
if s.data.Instances[i].ID == id {
in := s.data.Instances[i]
return &in, nil
}
}
return nil, fmt.Errorf("instance %s not found", id)
}
func (s *Store) GetInstanceByName(name string) (*model.Instance, error) {
s.mu.RLock()
defer s.mu.RUnlock()
for i := range s.data.Instances {
if s.data.Instances[i].Name == name {
in := s.data.Instances[i]
return &in, nil
}
}
return nil, fmt.Errorf("instance %s not found", name)
}
func (s *Store) UpsertInstance(in model.Instance) error {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.data.Instances {
if s.data.Instances[i].ID == in.ID {
s.data.Instances[i] = in
return s.flush()
}
}
s.data.Instances = append(s.data.Instances, in)
return s.flush()
}
func (s *Store) DeleteInstance(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
idx := -1
for i := range s.data.Instances {
if s.data.Instances[i].ID == id {
idx = i
break
}
}
if idx < 0 {
return fmt.Errorf("instance %s not found", id)
}
s.data.Instances = append(s.data.Instances[:idx], s.data.Instances[idx+1:]...)
// 同步删除其用户
users := s.data.Users[:0]
for _, u := range s.data.Users {
if u.InstanceID != id {
users = append(users, u)
}
}
s.data.Users = users
return s.flush()
}
// ---- Users ----
func (s *Store) ListUsers(instanceID string) []model.VPNUser {
s.mu.RLock()
defer s.mu.RUnlock()
out := []model.VPNUser{}
for _, u := range s.data.Users {
if instanceID == "" || u.InstanceID == instanceID {
out = append(out, u)
}
}
return out
}
func (s *Store) GetUser(id string) (*model.VPNUser, error) {
s.mu.RLock()
defer s.mu.RUnlock()
for i := range s.data.Users {
if s.data.Users[i].ID == id {
u := s.data.Users[i]
return &u, nil
}
}
return nil, fmt.Errorf("user %s not found", id)
}
func (s *Store) GetUserByCN(instanceID, cn string) (*model.VPNUser, error) {
s.mu.RLock()
defer s.mu.RUnlock()
for i := range s.data.Users {
if s.data.Users[i].InstanceID == instanceID && s.data.Users[i].Username == cn {
u := s.data.Users[i]
return &u, nil
}
}
return nil, fmt.Errorf("user %s/%s not found", instanceID, cn)
}
func (s *Store) UpsertUser(u model.VPNUser) error {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.data.Users {
if s.data.Users[i].ID == u.ID {
s.data.Users[i] = u
return s.flush()
}
}
s.data.Users = append(s.data.Users, u)
return s.flush()
}
func (s *Store) DeleteUser(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
idx := -1
for i := range s.data.Users {
if s.data.Users[i].ID == id {
idx = i
break
}
}
if idx < 0 {
return fmt.Errorf("user %s not found", id)
}
s.data.Users = append(s.data.Users[:idx], s.data.Users[idx+1:]...)
return s.flush()
}
// ---- Audit ----
func (s *Store) AppendAudit(a model.AuditLog) error {
s.mu.Lock()
defer s.mu.Unlock()
s.data.Audits = append(s.data.Audits, a)
// 仅保留最近 5000 条
if len(s.data.Audits) > 5000 {
s.data.Audits = s.data.Audits[len(s.data.Audits)-5000:]
}
return s.flush()
}
func (s *Store) ListAudits(limit int) []model.AuditLog {
s.mu.RLock()
defer s.mu.RUnlock()
if limit <= 0 || limit > len(s.data.Audits) {
limit = len(s.data.Audits)
}
out := make([]model.AuditLog, limit)
copy(out, s.data.Audits[len(s.data.Audits)-limit:])
// 倒序
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
out[i], out[j] = out[j], out[i]
}
return out
}
// ---- Connection Logs ----
func (s *Store) AppendConnLog(c model.ConnectionLog) error {
s.mu.Lock()
defer s.mu.Unlock()
s.data.ConnLogs = append(s.data.ConnLogs, c)
if len(s.data.ConnLogs) > 20000 {
s.data.ConnLogs = s.data.ConnLogs[len(s.data.ConnLogs)-20000:]
}
return s.flush()
}
func (s *Store) ListConnLogs(instanceID string, limit int) []model.ConnectionLog {
s.mu.RLock()
defer s.mu.RUnlock()
out := []model.ConnectionLog{}
for i := len(s.data.ConnLogs) - 1; i >= 0 && len(out) < limit; i-- {
c := s.data.ConnLogs[i]
if instanceID == "" || c.InstanceID == instanceID {
out = append(out, c)
}
}
return out
}
func (s *Store) FindActiveConn(instanceID, commonName string) *model.ConnectionLog {
s.mu.RLock()
defer s.mu.RUnlock()
for i := len(s.data.ConnLogs) - 1; i >= 0; i-- {
c := s.data.ConnLogs[i]
if c.InstanceID == instanceID && c.CommonName == commonName && c.DisconnectedAt == nil {
cc := c
return &cc
}
}
return nil
}
func (s *Store) CloseActiveConn(instanceID, commonName string, at time.Time) error {
s.mu.Lock()
defer s.mu.Unlock()
for i := len(s.data.ConnLogs) - 1; i >= 0; i-- {
c := &s.data.ConnLogs[i]
if c.InstanceID == instanceID && c.CommonName == commonName && c.DisconnectedAt == nil {
c.DisconnectedAt = &at
return s.flush()
}
}
return nil
}
// ---- Backups ----
func (s *Store) ListBackups() []model.Backup {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]model.Backup, len(s.data.Backups))
copy(out, s.data.Backups)
return out
}
func (s *Store) AddBackup(b model.Backup) error {
s.mu.Lock()
defer s.mu.Unlock()
s.data.Backups = append(s.data.Backups, b)
return s.flush()
}
func (s *Store) DeleteBackup(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
idx := -1
for i := range s.data.Backups {
if s.data.Backups[i].ID == id {
idx = i
break
}
}
if idx < 0 {
return fmt.Errorf("backup %s not found", id)
}
s.data.Backups = append(s.data.Backups[:idx], s.data.Backups[idx+1:]...)
return s.flush()
}