package main import ( "context" "embed" "fmt" "io/fs" "net/http" "os" "os/signal" "strings" "syscall" "time" "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/cookie" "github.com/gin-gonic/gin" "lan-manager/server/config" "lan-manager/server/db" "lan-manager/server/handlers" "lan-manager/server/middleware" "lan-manager/server/services" ) //go:embed all:static var webFS embed.FS func main() { cfg := config.Load() if err := db.Init(cfg); err != nil { fmt.Fprintf(os.Stderr, "db init failed: %v\n", err) os.Exit(1) } config.LoadPasswordFromDB(cfg, db.DB) gin.SetMode(gin.ReleaseMode) r := gin.New() r.RedirectTrailingSlash = false r.RedirectFixedPath = false r.Use(gin.Recovery()) store := cookie.NewStore([]byte(cfg.SessionSecret)) store.Options(sessions.Options{ Path: "/", MaxAge: 86400 * 7, HttpOnly: true, SameSite: http.SameSiteLaxMode, }) r.Use(sessions.Sessions("lanmgr_session", store)) // API routes api := r.Group("/api") { authH := handlers.NewAuthHandler(cfg) api.POST("/auth/login", authH.Login) api.POST("/auth/logout", authH.Logout) api.GET("/auth/me", authH.Me) api.POST("/auth/change-password", middleware.AuthRequired(), authH.ChangePassword) api.GET("/health", handlers.NewHealthHandler().Check) // Guest + Admin read-only routes guest := api.Group("/") guest.Use(middleware.OptionalAuth()) { mh := handlers.NewMachineHandler() guest.GET("/machines", mh.List) } // Admin only routes admin := api.Group("/") admin.Use(middleware.AuthRequired()) { mh := handlers.NewMachineHandler() admin.GET("/machines/:id", mh.Get) admin.POST("/machines", mh.Create) admin.PUT("/machines/:id", mh.Update) admin.DELETE("/machines/:id", mh.Delete) admin.POST("/machines/:id/ssh-info", mh.SSHInfo) admin.POST("/machines/:id/sync-ssh", mh.SyncSSH) admin.GET("/machines/:id/offline-logs", mh.OfflineLogs) sh := handlers.NewServiceHandler() admin.GET("/machines/:id/services", sh.ListByMachine) admin.POST("/machines/:id/services", sh.Create) admin.PUT("/services/:id", sh.Update) admin.DELETE("/services/:id", sh.Delete) admin.GET("/services", sh.ListAll) rh := handlers.NewRelationshipHandler() admin.GET("/relationships", rh.List) admin.POST("/relationships", rh.Create) admin.PUT("/relationships/:id", rh.Update) admin.DELETE("/relationships/:id", rh.Delete) admin.GET("/logs", handlers.NewLogHandler().List) // PVE 主机管理 pveH := handlers.NewPVEHandler() admin.GET("/pve/hosts", pveH.List) admin.GET("/pve/hosts/:id", pveH.Get) admin.POST("/pve/hosts", pveH.Create) admin.PUT("/pve/hosts/:id", pveH.Update) admin.DELETE("/pve/hosts/:id", pveH.Delete) admin.GET("/pve/hosts/:id/status", pveH.HostStatus) admin.GET("/pve/hosts/:id/vms", pveH.HostVMList) // 虚拟机操作 admin.GET("/machines/:id/vm-status", pveH.VMStatus) admin.POST("/machines/:id/vm-start", pveH.VMStart) admin.POST("/machines/:id/vm-stop", pveH.VMStop) eh := handlers.NewExportHandler() admin.GET("/export", eh.Export) admin.POST("/import", eh.Import) } } // Static files if cfg.WebStaticPath != "" { staticFS := os.DirFS(cfg.WebStaticPath) fileServer := http.FileServer(http.FS(staticFS)) r.NoRoute(func(c *gin.Context) { path := c.Request.URL.Path // Handle assets at any path depth if idx := strings.Index(path, "/assets/"); idx >= 0 { c.Request.URL.Path = path[idx:] fileServer.ServeHTTP(c.Writer, c.Request) return } if path == "/favicon.ico" || path == "/logo.svg" || path == "/favicon.png" || path == "/apple-touch-icon.png" { fileServer.ServeHTTP(c.Writer, c.Request) return } // SPA fallback: all other paths serve index.html c.Request.URL.Path = "/" fileServer.ServeHTTP(c.Writer, c.Request) }) } else { staticFS, err := fs.Sub(webFS, "static") if err != nil { fmt.Fprintf(os.Stderr, "static embed failed: %v\n", err) os.Exit(1) } fileServer := http.FileServer(http.FS(staticFS)) r.NoRoute(func(c *gin.Context) { path := c.Request.URL.Path // Handle assets at any path depth (e.g. /logs/assets/... from relative dynamic imports) if idx := strings.Index(path, "/assets/"); idx >= 0 { c.Request.URL.Path = path[idx:] fileServer.ServeHTTP(c.Writer, c.Request) return } if path == "/favicon.ico" || path == "/logo.svg" || path == "/favicon.png" || path == "/apple-touch-icon.png" { fileServer.ServeHTTP(c.Writer, c.Request) return } // SPA fallback: all other paths serve index.html c.Request.URL.Path = "/" fileServer.ServeHTTP(c.Writer, c.Request) }) } // Start ping service services.StartPingService(cfg.PingInterval) // Start log cleanup ctx, cancel := context.WithCancel(context.Background()) defer cancel() services.CleanupLogs(ctx, cfg.LogRetentionDays) srv := &http.Server{ Addr: cfg.Host + ":" + cfg.Port, Handler: r, } go func() { fmt.Printf("Server listening on %s:%s\n", cfg.Host, cfg.Port) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { fmt.Fprintf(os.Stderr, "server error: %v\n", err) os.Exit(1) } }() quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit fmt.Println("Shutting down server...") shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) defer shutdownCancel() if err := srv.Shutdown(shutdownCtx); err != nil { fmt.Fprintf(os.Stderr, "server shutdown error: %v\n", err) } fmt.Println("Server exited") }