Files
lan-manager/server/main.go
shirainbown 1c0fce6a17 feat: independent SSH sync service with concurrency limit
- 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)
2026-06-19 18:21:36 +08:00

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")
}