// VPN 管理平台 —— 单二进制交付(前端静态资源内嵌) // Logan.Li package main import ( "embed" "fmt" "io/fs" "log" "net/http" "os" "strconv" "strings" "time" "github.com/gin-gonic/gin" "vpn-platform/backend/internal/api" "vpn-platform/backend/internal/core" "vpn-platform/backend/internal/monitor" "vpn-platform/backend/internal/service" "vpn-platform/backend/internal/service/custom" "vpn-platform/backend/internal/service/gre" "vpn-platform/backend/internal/service/frp" "vpn-platform/backend/internal/service/ikev2" "vpn-platform/backend/internal/service/ipsec" "vpn-platform/backend/internal/service/l2tp" "vpn-platform/backend/internal/service/openvpn" "vpn-platform/backend/internal/service/wireguard" "vpn-platform/backend/internal/store" ) //go:embed all:dist var webFS embed.FS func main() { gin.SetMode(gin.ReleaseMode) // 数据目录:默认 ./data,可用 VPN_DATA_DIR 覆盖 dataDir := service.BaseDir() if err := store.Init(dataDir); err != nil { log.Fatalf("初始化存储失败: %v", err) } // OpenVPN invokes this same, already-installed binary from its // auth-user-pass-verify hook. Keep it before HTTP startup so the hook has a // short-lived process and a meaningful exit status. if len(os.Args) > 1 && os.Args[1] == "openvpn-auth" { if err := openvpn.RunAuthHelper(os.Args[2:]); err != nil { log.Printf("OpenVPN 用户认证失败: %v", err) os.Exit(1) } return } // 注册内置协议 mgr := core.NewManager() mgr.Register(wireguard.New()) mgr.Register(ipsec.New()) mgr.Register(ikev2.New()) mgr.Register(l2tp.New()) mgr.Register(frp.New()) mgr.Register(gre.New()) mgr.Register(openvpn.New()) // 加载数据库中的自定义协议插件 custom.LoadAll(mgr) r := gin.New() // HTTP 访问日志(带轮转,默认 20MB x 3 文件) r.Use(accessLogger()) r.Use(gin.Recovery()) srv := api.NewServer(mgr) srv.RegisterRoutes(r) // 内嵌前端(SPA 回退到 index.html) dist, err := fs.Sub(webFS, "dist") if err != nil { log.Fatalf("内嵌资源损坏: %v", err) } fileServer := http.FileServer(http.FS(dist)) r.NoRoute(func(c *gin.Context) { path := strings.TrimPrefix(c.Request.URL.Path, "/") if path == "" { path = "index.html" } if f, err := dist.Open(path); err == nil { f.Close() // 带 hash 的静态资源(assets/*)可长期缓存;index.html 必须每次重新验证 if strings.HasPrefix(path, "assets/") { c.Header("Cache-Control", "public, max-age=31536000, immutable") } else { c.Header("Cache-Control", "no-cache") } fileServer.ServeHTTP(c.Writer, c.Request) return } // SPA fallback — 必须 no-cache,否则浏览器缓存旧 index.html 会白屏 c.Header("Cache-Control", "no-cache") c.Request.URL.Path = "/" fileServer.ServeHTTP(c.Writer, c.Request) }) // 启动时自动清理过期日志(默认保留 90 天,可通过 VPN_LOG_RETENTION_DAYS 覆盖) retentionDays := 90 if v := os.Getenv("VPN_LOG_RETENTION_DAYS"); v != "" { if d, err := strconv.Atoi(v); err == nil && d > 0 { retentionDays = d } } api.AutoCleanupLogs(retentionDays) // 状态采集 + 自启恢复 monitor.Start(mgr, 15*time.Second) monitor.AutoStartAll(mgr) addr := os.Getenv("VPN_LISTEN_ADDR") if addr == "" { addr = ":8080" } log.Printf("VPN 管理平台已启动: http://localhost%s (默认账号 admin / admin123)", addr) log.Printf("协议依赖检测:") for _, p := range mgr.List() { ok, dep := p.Available() mark := "✓" if !ok { mark = "✗" } log.Printf(" [%s] %-10s %s", mark, p.Name(), dep) } if err := r.Run(addr); err != nil { log.Fatal(err) } } // accessLogger 返回自定义的 HTTP 请求日志中间件(带日志轮转) // 格式: [2006-01-02 15:04:05] "GET /api/tunnels 200 12.5ms 192.168.1.1" func accessLogger() gin.HandlerFunc { // 构造日志文件路径 dataDir := service.BaseDir() logPath := fmt.Sprintf("%s/access.log", dataDir) // 轮转配置:默认 20MB x 3 文件,可通过环境变量覆盖 cfg := service.DefaultRotateConfig("VPN_ACCESS_LOG_MAX_SIZE", 20*1024*1024, 3) writer, err := service.NewRotatingWriter(logPath, cfg) if err != nil { // 降级到 stderr log.Printf("警告: 无法创建访问日志文件 %s: %v,降级到标准输出", logPath, err) return gin.Logger() } return func(c *gin.Context) { start := time.Now() path := c.Request.URL.Path c.Next() latency := time.Since(start) status := c.Writer.Status() line := fmt.Sprintf("[%s] %s %s %d %s %s\n", time.Now().Format("2006-01-02 15:04:05"), c.Request.Method, path, status, latency.Round(time.Millisecond), c.ClientIP(), ) _, _ = writer.Write([]byte(line)) } }