271 lines
6.6 KiB
Go
271 lines
6.6 KiB
Go
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
|