- Split SSH sync from ping loop into independent service - Ping service: serial polling, 30s interval, only updates online status - SSH sync service: runs every 10 minutes for all online machines - Global semaphore limits concurrent SSH to 2 (prevents resource exhaustion) - SSH command timeout 8s prevents hanging on unresponsive hosts - Offline machines are skipped for SSH sync - Ping fallback: if ping fails but SSH succeeds, mark as online (SSH sync handles info)
202 lines
5.6 KiB
Go
202 lines
5.6 KiB
Go
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 SSH sync service (independent 10-minute timer per machine)
|
|
services.StartSSHSyncService()
|
|
|
|
// 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")
|
|
}
|