package http import ( "context" _ "embed" "log" "meshgo/config" "meshgo/stats" "net/http" "time" "github.com/gin-gonic/gin" ) // Server HTTP 管理界面服务 type Server struct { srv *http.Server enabled bool } // New 创建 HTTP 服务(不启动) func New(cfg *config.AdminConfig) *Server { if !cfg.Enabled { return &Server{enabled: false} } gin.SetMode(gin.ReleaseMode) r := gin.New() r.Use(gin.Recovery()) // 预留认证中间件扩展点 r.Use(AuthMiddleware(cfg)) // 路由 admin := r.Group("/admin/mqtt") { admin.GET("/api/stats", handleStats) admin.GET("/api/clients", handleClients) admin.GET("/api/client/:id", handleClientDetail) admin.GET("/api/client/:id/subs", handleClientSubs) } r.GET("/admin/mqtt", serveAdmin) r.GET("/admin/mqtt/client/:id", serveClientDetail) r.GET("/", serveIndex) // Meshtastic 消息查看 meshtastic := r.Group("/admin/meshtastic") { meshtastic.GET("/", serveMeshtastic) meshtastic.GET("/api/messages", handleMessages) } addr := cfg.Port if addr == "" { addr = ":8080" } return &Server{ enabled: true, srv: &http.Server{ Addr: addr, Handler: r, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, }, } } // Start 启动 HTTP 服务(goroutine) func (s *Server) Start() { if !s.enabled { return } go func() { addr := s.srv.Addr log.Printf("[http] 管理界面已启动: http://%s/", addr) if err := s.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Printf("[http] 管理界面启动失败: %v", err) } }() } // Close 优雅关闭 func (s *Server) Close() error { if !s.enabled { return nil } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() return s.srv.Shutdown(ctx) } // Enabled 返回是否启用 func (s *Server) Enabled() bool { return s.enabled } // --------------------------------------------------------------------------- // 路由处理 // --------------------------------------------------------------------------- // serveIndex 返回管理页面 func serveIndex(c *gin.Context) { c.Header("Content-Type", "text/html; charset=utf-8") c.String(http.StatusOK, homeHTML) } // serveAdmin MQTT 管理页面 func serveAdmin(c *gin.Context) { c.Header("Content-Type", "text/html; charset=utf-8") c.String(http.StatusOK, adminHTML) } // serveClientDetail 客户端详情页面 func serveClientDetail(c *gin.Context) { c.Header("Content-Type", "text/html; charset=utf-8") c.String(http.StatusOK, clientDetailHTML) } // serveMeshtastic Meshtastic 消息查看页面 func serveMeshtastic(c *gin.Context) { c.Header("Content-Type", "text/html; charset=utf-8") c.String(http.StatusOK, meshtasticHTML) } // handleMessages 返回 msh 消息列表(JSON) func handleMessages(c *gin.Context) { messages := stats.GetMessages() pskIndex := stats.GetDefaultPSKIndex() for i := range messages { if isJsonTopic(messages[i].Topic) { continue } dec, err := stats.TryDecryptMessage(messages[i].Payload, pskIndex) if err != nil { // 打印详细错误 env, parseErr := stats.ParseServiceEnvelopeDebug(messages[i].Payload) if parseErr != nil { log.Printf("[decrypt] FAIL psk=%d topic=%s parse_err=%v decrypt_err=%v payload_len=%d", pskIndex, messages[i].Topic, parseErr, err, len(messages[i].Payload)) } else { packetId := uint64(0) from := uint32(0) variant := 0 encryptedLen := 0 if env.Packet != nil { packetId = env.Packet.Id from = env.Packet.From variant = env.Packet.WhichPayloadVariant encryptedLen = len(env.Packet.Encrypted) } log.Printf("[decrypt] FAIL psk=%d topic=%s channel=%s packetId=%d from=0x%x variant=%d encrypted_len=%d err=%v", pskIndex, messages[i].Topic, env.ChannelId, packetId, from, variant, encryptedLen, err) } messages[i].Decrypted = &stats.DecryptedMessage{ GatewayId: err.Error(), } } else if dec != nil { messages[i].Decrypted = dec log.Printf("[decrypt] OK topic=%s channel=%s packetId=%d from=0x%x port=%d payload_len=%d", messages[i].Topic, dec.ChannelId, dec.PacketId, dec.From, dec.PortNum, len(dec.Payload)) } } c.JSON(http.StatusOK, gin.H{ "code": 0, "data": messages, }) } func isJsonTopic(topic string) bool { return len(topic) > 0 && (topic[len(topic)-1] == 'n' || topic[len(topic)-5:len(topic)] == "/json") } // handleStats 返回实时统计(JSON) func handleStats(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "code": 0, "data": stats.GetStats(), }) } // handleClients 返回在线客户端列表(JSON) func handleClients(c *gin.Context) { st := stats.GetStats() c.JSON(http.StatusOK, gin.H{ "code": 0, "data": st.Clients, }) } // handleClientDetail 返回指定客户端详情(JSON) func handleClientDetail(c *gin.Context) { id := c.Param("id") info := stats.GetClient(id) if info == nil { c.JSON(http.StatusOK, gin.H{ "code": 404, "msg": "客户端不存在或已离线", }) return } c.JSON(http.StatusOK, gin.H{ "code": 0, "data": info, }) } // handleClientSubs 返回指定客户端的订阅主题列表(JSON) func handleClientSubs(c *gin.Context) { id := c.Param("id") subs := stats.GetClientSubs(id) c.JSON(http.StatusOK, gin.H{ "code": 0, "data": subs, }) } // --------------------------------------------------------------------------- // 认证中间件(预留扩展点) // AuthMiddleware 根据 cfg.AuthType 选择对应认证策略 // 当前支持:none // 预留支持:basic、token // --------------------------------------------------------------------------- // AuthMiddleware 认证中间件工厂 func AuthMiddleware(cfg *config.AdminConfig) gin.HandlerFunc { switch cfg.AuthType { case "basic": return basicAuth(cfg.Username, cfg.Password) case "token": // TODO: token 认证 return func(c *gin.Context) { c.Next() } default: // none:不做认证 return func(c *gin.Context) { c.Next() } } } // basicAuth Basic 认证 func basicAuth(user, pass string) gin.HandlerFunc { return func(c *gin.Context) { u, p, ok := c.Request.BasicAuth() if !ok || u != user || p != pass { c.Header("WWW-Authenticate", `Basic realm="meshgo"`) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ "code": 401, "msg": "未授权", }) return } c.Next() } } // --------------------------------------------------------------------------- // 模板(go:embed 内嵌) // --------------------------------------------------------------------------- //go:embed index.html var homeHTML string //go:embed admin_mqtt.html var adminHTML string //go:embed client_detail.html var clientDetailHTML string //go:embed meshtastic.html var meshtasticHTML string