重构:拆出 auth / blocking / runtimesettings / help / mqttforward 包
第二批:把根目录中纯逻辑领域文件(cache、service、admin route)按业务边界
迁到 internal/ 下的子包。各子包暴露 RegisterRoutes 给 web 包调用,根目录
只留下一行 bridge 文件保留旧名字别名。
新增包
- internal/auth/ SessionClaims / Manager / RequireAdmin / HashPassword /
VerifyPassword / AdminUserResponse 等。原 auth.go 中
被两个 admin route 依赖的 sessionClaims 现在以 auth.
SessionClaims 形式被它们 import;不再被锁在 main 包。
- internal/blocking/ Cache + RegisterRoutes,以前散在 blocking_cache.go
和 admin_blocking_routes.go 里。
- internal/runtimesettings/ Cache + RegisterRoutes。
- internal/help/ RenderMarkdown / RegisterPublicRoutes /
RegisterAdminRoutes(拆分原来的 registerHelpRoutes
和 registerAdminHelpRoutes 两条入口)。
- internal/mqttforward/ Manager / Reloader / Stats / RegisterRoutes。
forwarder runner、循环抑制 cache 等运行时逻辑随之迁入。
- internal/webutil/ ParseListOptions / WriteListResponse[WithTotal] /
ParseMapReportListOptions / ParseMapReportViewportOptions
以及 PtrString/PtrInt64/... 等指针解引用 helper。
以前散在 web.go 中,现在被各 admin route 子包共享,
避免 internal/blocking → internal/web → internal/blocking
的循环依赖。
- internal/store/testutil/ OpenStore(t) helper,让其它包测试零样板拿到 store。
根目录新增 bridge 文件
- blocking_bridge.go / runtime_settings_bridge.go / help_bridge.go /
mqttforward_bridge.go:用 type alias + thin wrapper 把上述子包的导出
名映射到旧的小写名(blockingCache、registerAdminBlockingRoutes 等),
让 main.go / web.go 等仍未迁出的文件无须改动。
修改
- auth.go 改为对 internal/auth 的 bridge;web.go 中 sessions.newCookie /
clearCookie 改为 NewCookie / ClearCookie。
- main_test.go 中 BlockingViolationForRecord* 测试不再直接构造未导出字段,
改成走 store.CreateNodeBlocking → newBlockingCache 的真实路径。
- internal/mqttforward 把以前 *_store.go 中没有方法依赖的运行时类型
(forwarder runner、loop cache)和 admin route 一并归位;mqtt_status.go
暂时仍留在根目录(依赖 main 中的 mqttClientInfoFromClient)。
go build ./... / go test ./... 全部通过;测试数量未变。
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
// Package auth 实现 admin 端 cookie session 与密码散列。
|
||||
//
|
||||
// 拆离自原来 main 包的 auth.go:让所有 admin route 包都可以直接 import 这里的
|
||||
// SessionClaims / Manager / RequireAdmin,而不是被锁在根 main 包里。
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"meshtastic_mqtt_server/internal/config"
|
||||
"meshtastic_mqtt_server/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
AdminRole = "admin"
|
||||
adminSessionCookie = "mesh_admin_session"
|
||||
|
||||
// AdminClaimsKey 是中间件挂在 gin.Context 上的 key,handler 可用
|
||||
// `c.MustGet(auth.AdminClaimsKey).(*auth.SessionClaims)` 取出。
|
||||
AdminClaimsKey = "admin_claims"
|
||||
)
|
||||
|
||||
// AdminUserDTO 是 /me /login 等接口返回给前端的最小用户视图。
|
||||
type AdminUserDTO struct {
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// SessionClaims 是 cookie 中持久化的会话内容。
|
||||
type SessionClaims struct {
|
||||
UserID uint64 `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
Expires int64 `json:"expires"`
|
||||
}
|
||||
|
||||
// Manager 持有签名密钥与 cookie 配置,是发布 / 校验 cookie 的入口。
|
||||
type Manager struct {
|
||||
secret []byte
|
||||
secure bool
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
// NewManager 根据配置构造 Manager。如果 SessionSecret 空,会随机生成 32 字节。
|
||||
func NewManager(cfg config.WebAdminConfig) (*Manager, error) {
|
||||
secret := strings.TrimSpace(cfg.SessionSecret)
|
||||
if secret == "" {
|
||||
generated := make([]byte, 32)
|
||||
if _, err := rand.Read(generated); err != nil {
|
||||
return nil, fmt.Errorf("generate admin session secret: %w", err)
|
||||
}
|
||||
return &Manager{secret: generated, secure: cfg.SessionSecure, ttl: 24 * time.Hour}, nil
|
||||
}
|
||||
return &Manager{secret: []byte(secret), secure: cfg.SessionSecure, ttl: 24 * time.Hour}, nil
|
||||
}
|
||||
|
||||
// HashPassword 用 bcrypt 默认 cost 散列;用于建账号、改密码。
|
||||
func HashPassword(password string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
// VerifyPassword 校验明文密码是否与散列匹配。
|
||||
func VerifyPassword(hash, password string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
}
|
||||
|
||||
// AdminUserResponse 把 store.UserRecord 转成对外 DTO。
|
||||
func AdminUserResponse(user store.UserRecord) AdminUserDTO {
|
||||
return AdminUserDTO{Username: user.Username, Role: user.Role}
|
||||
}
|
||||
|
||||
// NewCookie 为已登录用户构造一份带签名的 session cookie。
|
||||
func (sm *Manager) NewCookie(user store.UserRecord) (*http.Cookie, error) {
|
||||
claims := SessionClaims{UserID: user.ID, Username: user.Username, Role: user.Role, Expires: time.Now().Add(sm.ttl).Unix()}
|
||||
data, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload := base64.RawURLEncoding.EncodeToString(data)
|
||||
signature := sm.sign(payload)
|
||||
return &http.Cookie{
|
||||
Name: adminSessionCookie,
|
||||
Value: payload + "." + signature,
|
||||
Path: "/",
|
||||
MaxAge: int(sm.ttl.Seconds()),
|
||||
HttpOnly: true,
|
||||
Secure: sm.secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ClearCookie 返回一个把 cookie 立即清掉的 *http.Cookie,供 logout 使用。
|
||||
func (sm *Manager) ClearCookie() *http.Cookie {
|
||||
return &http.Cookie{
|
||||
Name: adminSessionCookie,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: sm.secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
}
|
||||
|
||||
// ClaimsFromRequest 校验请求 cookie 的签名 / 过期 / 角色。
|
||||
func (sm *Manager) ClaimsFromRequest(c *gin.Context) (*SessionClaims, error) {
|
||||
cookie, err := c.Cookie(adminSessionCookie)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parts := strings.Split(cookie, ".")
|
||||
if len(parts) != 2 {
|
||||
return nil, errors.New("invalid session")
|
||||
}
|
||||
if !hmac.Equal([]byte(parts[1]), []byte(sm.sign(parts[0]))) {
|
||||
return nil, errors.New("invalid session signature")
|
||||
}
|
||||
data, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var claims SessionClaims
|
||||
if err := json.Unmarshal(data, &claims); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if claims.Expires <= time.Now().Unix() {
|
||||
return nil, errors.New("session expired")
|
||||
}
|
||||
if claims.Role != AdminRole {
|
||||
return nil, errors.New("admin required")
|
||||
}
|
||||
return &claims, nil
|
||||
}
|
||||
|
||||
func (sm *Manager) sign(payload string) string {
|
||||
mac := hmac.New(sha256.New, sm.secret)
|
||||
mac.Write([]byte(payload))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// RequireAdmin 是把校验结果挂在 c.Set(AdminClaimsKey, claims) 上的中间件。
|
||||
func RequireAdmin(sm *Manager) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
claims, err := sm.ClaimsFromRequest(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "admin login required"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set(AdminClaimsKey, claims)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package blocking
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
storepkg "meshtastic_mqtt_server/internal/store"
|
||||
"meshtastic_mqtt_server/internal/webutil"
|
||||
)
|
||||
|
||||
type nodeBlockingRequest struct {
|
||||
NodeID string `json:"node_id"`
|
||||
NodeNum *int64 `json:"node_num"`
|
||||
Reason string `json:"reason"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type ipBlockingRequest struct {
|
||||
IPValue string `json:"ip_value"`
|
||||
Reason string `json:"reason"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type forbiddenWordBlockingRequest struct {
|
||||
Word string `json:"word"`
|
||||
MatchType string `json:"match_type"`
|
||||
CaseSensitive bool `json:"case_sensitive"`
|
||||
Reason string `json:"reason"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func RegisterRoutes(r gin.IRouter, store *storepkg.Store, blocking *Cache) {
|
||||
reloadBlocking := func() error {
|
||||
if blocking == nil {
|
||||
return nil
|
||||
}
|
||||
return blocking.Reload(store)
|
||||
}
|
||||
|
||||
r.GET("/blocking/nodes", func(c *gin.Context) {
|
||||
opts, ok := webutil.ParseListOptions(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := store.ListNodeBlocking(opts)
|
||||
if err != nil {
|
||||
webutil.WriteListResponse(c, rows, opts, err, nodeBlockingDTO)
|
||||
return
|
||||
}
|
||||
total, err := store.CountNodeBlocking(opts)
|
||||
webutil.WriteListResponseWithTotal(c, rows, opts, total, err, nodeBlockingDTO)
|
||||
})
|
||||
r.POST("/blocking/nodes", func(c *gin.Context) {
|
||||
var req nodeBlockingRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid node blocking request"})
|
||||
return
|
||||
}
|
||||
row, err := store.CreateNodeBlocking(req.NodeID, req.NodeNum, req.Reason, req.Enabled)
|
||||
writeBlockingMutationResponse(c, http.StatusCreated, row, err, nodeBlockingDTO, reloadBlocking)
|
||||
})
|
||||
r.PUT("/blocking/nodes/:id", func(c *gin.Context) {
|
||||
id, ok := parseBlockingID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req nodeBlockingRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid node blocking request"})
|
||||
return
|
||||
}
|
||||
row, err := store.UpdateNodeBlocking(id, req.NodeID, req.NodeNum, req.Reason, req.Enabled)
|
||||
writeBlockingMutationResponse(c, http.StatusOK, row, err, nodeBlockingDTO, reloadBlocking)
|
||||
})
|
||||
r.DELETE("/blocking/nodes/:id", func(c *gin.Context) {
|
||||
id, ok := parseBlockingID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeBlockingDeleteResponse(c, store.DeleteNodeBlocking(id), reloadBlocking)
|
||||
})
|
||||
|
||||
r.GET("/blocking/ips", func(c *gin.Context) {
|
||||
opts, ok := webutil.ParseListOptions(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := store.ListIPBlocking(opts)
|
||||
if err != nil {
|
||||
webutil.WriteListResponse(c, rows, opts, err, ipBlockingDTO)
|
||||
return
|
||||
}
|
||||
total, err := store.CountIPBlocking(opts)
|
||||
webutil.WriteListResponseWithTotal(c, rows, opts, total, err, ipBlockingDTO)
|
||||
})
|
||||
r.POST("/blocking/ips", func(c *gin.Context) {
|
||||
var req ipBlockingRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ip blocking request"})
|
||||
return
|
||||
}
|
||||
row, err := store.CreateIPBlocking(req.IPValue, req.Reason, req.Enabled)
|
||||
writeBlockingMutationResponse(c, http.StatusCreated, row, err, ipBlockingDTO, reloadBlocking)
|
||||
})
|
||||
r.PUT("/blocking/ips/:id", func(c *gin.Context) {
|
||||
id, ok := parseBlockingID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req ipBlockingRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ip blocking request"})
|
||||
return
|
||||
}
|
||||
row, err := store.UpdateIPBlocking(id, req.IPValue, req.Reason, req.Enabled)
|
||||
writeBlockingMutationResponse(c, http.StatusOK, row, err, ipBlockingDTO, reloadBlocking)
|
||||
})
|
||||
r.DELETE("/blocking/ips/:id", func(c *gin.Context) {
|
||||
id, ok := parseBlockingID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeBlockingDeleteResponse(c, store.DeleteIPBlocking(id), reloadBlocking)
|
||||
})
|
||||
|
||||
r.GET("/blocking/words", func(c *gin.Context) {
|
||||
opts, ok := webutil.ParseListOptions(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := store.ListForbiddenWordBlocking(opts)
|
||||
if err != nil {
|
||||
webutil.WriteListResponse(c, rows, opts, err, forbiddenWordBlockingDTO)
|
||||
return
|
||||
}
|
||||
total, err := store.CountForbiddenWordBlocking(opts)
|
||||
webutil.WriteListResponseWithTotal(c, rows, opts, total, err, forbiddenWordBlockingDTO)
|
||||
})
|
||||
r.POST("/blocking/words", func(c *gin.Context) {
|
||||
var req forbiddenWordBlockingRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid forbidden word blocking request"})
|
||||
return
|
||||
}
|
||||
row, err := store.CreateForbiddenWordBlocking(req.Word, req.MatchType, req.CaseSensitive, req.Reason, req.Enabled)
|
||||
writeBlockingMutationResponse(c, http.StatusCreated, row, err, forbiddenWordBlockingDTO, reloadBlocking)
|
||||
})
|
||||
r.PUT("/blocking/words/:id", func(c *gin.Context) {
|
||||
id, ok := parseBlockingID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req forbiddenWordBlockingRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid forbidden word blocking request"})
|
||||
return
|
||||
}
|
||||
row, err := store.UpdateForbiddenWordBlocking(id, req.Word, req.MatchType, req.CaseSensitive, req.Reason, req.Enabled)
|
||||
writeBlockingMutationResponse(c, http.StatusOK, row, err, forbiddenWordBlockingDTO, reloadBlocking)
|
||||
})
|
||||
r.DELETE("/blocking/words/:id", func(c *gin.Context) {
|
||||
id, ok := parseBlockingID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeBlockingDeleteResponse(c, store.DeleteForbiddenWordBlocking(id), reloadBlocking)
|
||||
})
|
||||
}
|
||||
|
||||
func parseBlockingID(c *gin.Context) (uint64, bool) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid blocking rule id"})
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func writeBlockingMutationResponse[T any](c *gin.Context, status int, row *T, err error, convert func(T) gin.H, afterSuccess func() error) {
|
||||
if errors.Is(err, storepkg.ErrBlockingAlreadyExists) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "blocking rule already exists"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "blocking rule not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if afterSuccess != nil {
|
||||
if err := afterSuccess(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "blocking rule saved but cache reload failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(status, gin.H{"item": convert(*row)})
|
||||
}
|
||||
|
||||
func writeBlockingDeleteResponse(c *gin.Context, err error, afterSuccess func() error) {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "blocking rule not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if afterSuccess != nil {
|
||||
if err := afterSuccess(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "blocking rule deleted but cache reload failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
func nodeBlockingDTO(row storepkg.NodeBlockingRecord) gin.H {
|
||||
return gin.H{"id": row.ID, "node_id": row.NodeID, "node_num": webutil.PtrInt64(row.NodeNum), "reason": row.Reason, "enabled": row.Enabled, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
|
||||
}
|
||||
|
||||
func ipBlockingDTO(row storepkg.IPBlockingRecord) gin.H {
|
||||
return gin.H{"id": row.ID, "ip_value": row.IPValue, "reason": row.Reason, "enabled": row.Enabled, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
|
||||
}
|
||||
|
||||
func forbiddenWordBlockingDTO(row storepkg.ForbiddenWordBlockingRecord) gin.H {
|
||||
return gin.H{"id": row.ID, "word": row.Word, "match_type": row.MatchType, "case_sensitive": row.CaseSensitive, "reason": row.Reason, "enabled": row.Enabled, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package blocking
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
storepkg "meshtastic_mqtt_server/internal/store"
|
||||
)
|
||||
|
||||
type Cache struct {
|
||||
mu sync.RWMutex
|
||||
nodes map[string]struct{}
|
||||
nodeNums map[int64]struct{}
|
||||
ips map[string]struct{}
|
||||
cidrs []*net.IPNet
|
||||
words []forbiddenWordRule
|
||||
}
|
||||
|
||||
type forbiddenWordRule struct {
|
||||
word string
|
||||
foldedWord string
|
||||
matchType string
|
||||
caseSensitive bool
|
||||
}
|
||||
|
||||
func New(store *storepkg.Store) (*Cache, error) {
|
||||
cache := &Cache{}
|
||||
if err := cache.Reload(store); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cache, nil
|
||||
}
|
||||
|
||||
func (c *Cache) Reload(store *storepkg.Store) error {
|
||||
if store == nil {
|
||||
return fmt.Errorf("store is required")
|
||||
}
|
||||
|
||||
nodeRows, err := store.ListEnabledNodeBlocking()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ipRows, err := store.ListEnabledIPBlocking()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wordRows, err := store.ListEnabledForbiddenWordBlocking()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
nodes := make(map[string]struct{}, len(nodeRows))
|
||||
nodeNums := make(map[int64]struct{}, len(nodeRows))
|
||||
for _, row := range nodeRows {
|
||||
nodeID := strings.TrimSpace(row.NodeID)
|
||||
if nodeID != "" {
|
||||
nodes[nodeID] = struct{}{}
|
||||
}
|
||||
if row.NodeNum != nil {
|
||||
nodeNums[*row.NodeNum] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
ips := make(map[string]struct{}, len(ipRows))
|
||||
cidrs := make([]*net.IPNet, 0, len(ipRows))
|
||||
for _, row := range ipRows {
|
||||
value := strings.TrimSpace(row.IPValue)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if ip := net.ParseIP(value); ip != nil {
|
||||
ips[ip.String()] = struct{}{}
|
||||
continue
|
||||
}
|
||||
if _, ipNet, err := net.ParseCIDR(value); err == nil {
|
||||
cidrs = append(cidrs, ipNet)
|
||||
}
|
||||
}
|
||||
|
||||
words := make([]forbiddenWordRule, 0, len(wordRows))
|
||||
for _, row := range wordRows {
|
||||
word := strings.TrimSpace(row.Word)
|
||||
if word == "" || row.MatchType != storepkg.ForbiddenWordMatchContains {
|
||||
continue
|
||||
}
|
||||
words = append(words, forbiddenWordRule{word: word, foldedWord: strings.ToLower(word), matchType: row.MatchType, caseSensitive: row.CaseSensitive})
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.nodes = nodes
|
||||
c.nodeNums = nodeNums
|
||||
c.ips = ips
|
||||
c.cidrs = cidrs
|
||||
c.words = words
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Cache) IsNodeBlocked(nodeID any, nodeNum any) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
id, _ := nodeID.(string)
|
||||
num, hasNum := blockingInt64FromAny(nodeNum)
|
||||
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
if id != "" {
|
||||
if _, ok := c.nodes[id]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if hasNum {
|
||||
_, ok := c.nodeNums[num]
|
||||
return ok
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *Cache) IsIPBlocked(host string) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
host = strings.TrimSpace(host)
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
if _, ok := c.ips[ip.String()]; ok {
|
||||
return true
|
||||
}
|
||||
for _, ipNet := range c.cidrs {
|
||||
if ipNet.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *Cache) FindForbiddenWord(text any) (string, bool) {
|
||||
if c == nil {
|
||||
return "", false
|
||||
}
|
||||
value, ok := text.(string)
|
||||
if !ok || value == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
foldedText := ""
|
||||
for _, rule := range c.words {
|
||||
if rule.matchType != storepkg.ForbiddenWordMatchContains {
|
||||
continue
|
||||
}
|
||||
if rule.caseSensitive {
|
||||
if strings.Contains(value, rule.word) {
|
||||
return rule.word, true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if foldedText == "" {
|
||||
foldedText = strings.ToLower(value)
|
||||
}
|
||||
if strings.Contains(foldedText, rule.foldedWord) {
|
||||
return rule.word, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func blockingInt64FromAny(value any) (int64, bool) {
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
return int64(v), true
|
||||
case int8:
|
||||
return int64(v), true
|
||||
case int16:
|
||||
return int64(v), true
|
||||
case int32:
|
||||
return int64(v), true
|
||||
case int64:
|
||||
return v, true
|
||||
case uint:
|
||||
return int64(v), true
|
||||
case uint8:
|
||||
return int64(v), true
|
||||
case uint16:
|
||||
return int64(v), true
|
||||
case uint32:
|
||||
return int64(v), true
|
||||
case uint64:
|
||||
if v > uint64(^uint64(0)>>1) {
|
||||
return 0, false
|
||||
}
|
||||
return int64(v), true
|
||||
case float64:
|
||||
return int64(v), v == float64(int64(v))
|
||||
case string:
|
||||
n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
|
||||
return n, err == nil
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package blocking
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBlockingCacheLoadsEnabledRules(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
nodeNum := int64(305419896)
|
||||
if _, err := st.CreateNodeBlocking("!12345678", &nodeNum, "enabled", true); err != nil {
|
||||
t.Fatalf("CreateNodeBlocking(enabled) error = %v", err)
|
||||
}
|
||||
disabledNodeNum := int64(7)
|
||||
if _, err := st.CreateNodeBlocking("!00000007", &disabledNodeNum, "disabled", false); err != nil {
|
||||
t.Fatalf("CreateNodeBlocking(disabled) error = %v", err)
|
||||
}
|
||||
if _, err := st.CreateIPBlocking("192.168.1.0/24", "lan", true); err != nil {
|
||||
t.Fatalf("CreateIPBlocking(cidr) error = %v", err)
|
||||
}
|
||||
if _, err := st.CreateIPBlocking("10.0.0.1", "disabled", false); err != nil {
|
||||
t.Fatalf("CreateIPBlocking(disabled) error = %v", err)
|
||||
}
|
||||
if _, err := st.CreateForbiddenWordBlocking("spam", "contains", false, "enabled", true); err != nil {
|
||||
t.Fatalf("CreateForbiddenWordBlocking(enabled) error = %v", err)
|
||||
}
|
||||
if _, err := st.CreateForbiddenWordBlocking("blocked", "contains", false, "disabled", false); err != nil {
|
||||
t.Fatalf("CreateForbiddenWordBlocking(disabled) error = %v", err)
|
||||
}
|
||||
|
||||
cache, err := New(st)
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
|
||||
if !cache.IsNodeBlocked("!12345678", nil) {
|
||||
t.Fatal("IsNodeBlocked(enabled node id) = false, want true")
|
||||
}
|
||||
if !cache.IsNodeBlocked("", uint32(nodeNum)) {
|
||||
t.Fatal("IsNodeBlocked(enabled node num) = false, want true")
|
||||
}
|
||||
if cache.IsNodeBlocked("!00000007", disabledNodeNum) {
|
||||
t.Fatal("IsNodeBlocked(disabled node) = true, want false")
|
||||
}
|
||||
if !cache.IsIPBlocked("192.168.1.42") {
|
||||
t.Fatal("IsIPBlocked(CIDR member) = false, want true")
|
||||
}
|
||||
if cache.IsIPBlocked("10.0.0.1") {
|
||||
t.Fatal("IsIPBlocked(disabled IP) = true, want false")
|
||||
}
|
||||
if word, ok := cache.FindForbiddenWord("This is SPAM text"); !ok || word != "spam" {
|
||||
t.Fatalf("FindForbiddenWord(case-insensitive) = %q, %v, want spam, true", word, ok)
|
||||
}
|
||||
if _, ok := cache.FindForbiddenWord("disabled blocked text"); ok {
|
||||
t.Fatal("FindForbiddenWord(disabled word) = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockingCacheIPExactAndCIDR(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
if _, err := st.CreateIPBlocking("127.0.0.1", "loopback", true); err != nil {
|
||||
t.Fatalf("CreateIPBlocking(ip) error = %v", err)
|
||||
}
|
||||
if _, err := st.CreateIPBlocking("2001:db8::/32", "docs", true); err != nil {
|
||||
t.Fatalf("CreateIPBlocking(ipv6 cidr) error = %v", err)
|
||||
}
|
||||
cache, err := New(st)
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
|
||||
if !cache.IsIPBlocked("127.0.0.1") {
|
||||
t.Fatal("IsIPBlocked(exact IPv4) = false, want true")
|
||||
}
|
||||
if !cache.IsIPBlocked("2001:db8::1") {
|
||||
t.Fatal("IsIPBlocked(IPv6 CIDR) = false, want true")
|
||||
}
|
||||
if cache.IsIPBlocked("localhost") {
|
||||
t.Fatal("IsIPBlocked(hostname) = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockingCacheForbiddenWordCaseSensitivity(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
if _, err := st.CreateForbiddenWordBlocking("Spam", "contains", true, "case-sensitive", true); err != nil {
|
||||
t.Fatalf("CreateForbiddenWordBlocking(case-sensitive) error = %v", err)
|
||||
}
|
||||
cache, err := New(st)
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
|
||||
if _, ok := cache.FindForbiddenWord("lowercase spam"); ok {
|
||||
t.Fatal("FindForbiddenWord(lowercase) = true, want false")
|
||||
}
|
||||
if word, ok := cache.FindForbiddenWord("contains Spam"); !ok || word != "Spam" {
|
||||
t.Fatalf("FindForbiddenWord(exact case) = %q, %v, want Spam, true", word, ok)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package blocking
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"meshtastic_mqtt_server/internal/store"
|
||||
"meshtastic_mqtt_server/internal/store/testutil"
|
||||
)
|
||||
|
||||
// openTestStore 委托到 store/testutil,让本包的测试代码保持简洁。
|
||||
func openTestStore(t *testing.T) *store.Store {
|
||||
return testutil.OpenStore(t)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package help
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"meshtastic_mqtt_server/internal/auth"
|
||||
storepkg "meshtastic_mqtt_server/internal/store"
|
||||
)
|
||||
|
||||
type helpContentRequest struct {
|
||||
Markdown string `json:"markdown"`
|
||||
}
|
||||
|
||||
// RegisterPublicRoutes 把对外可见的 GET /help 挂到给定路由组下。
|
||||
func RegisterPublicRoutes(r gin.IRouter, store *storepkg.Store) {
|
||||
r.GET("/help", func(c *gin.Context) {
|
||||
item, err := latestHelpContentDTO(store)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"item": item})
|
||||
})
|
||||
}
|
||||
|
||||
// RegisterAdminRoutes 注册管理员侧 /help、/help、/help/preview 这三条路由。
|
||||
func RegisterAdminRoutes(r gin.IRouter, store *storepkg.Store) {
|
||||
r.GET("/help", func(c *gin.Context) {
|
||||
item, err := latestHelpContentDTO(store)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"item": item})
|
||||
})
|
||||
r.POST("/help", func(c *gin.Context) {
|
||||
var req helpContentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid help content request"})
|
||||
return
|
||||
}
|
||||
claims := c.MustGet(auth.AdminClaimsKey).(*auth.SessionClaims)
|
||||
row, err := store.InsertHelpContent(req.Markdown, claims.Username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
item, err := helpContentDTO(row.ID, row.Markdown, row.CreatedBy, &row.CreatedAt)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"item": item})
|
||||
})
|
||||
r.POST("/help/preview", func(c *gin.Context) {
|
||||
var req helpContentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid help preview request"})
|
||||
return
|
||||
}
|
||||
html, err := RenderMarkdown(req.Markdown)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"html": html})
|
||||
})
|
||||
}
|
||||
|
||||
func latestHelpContentDTO(store *storepkg.Store) (gin.H, error) {
|
||||
row, err := store.GetLatestHelpContent()
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return helpContentDTO(0, storepkg.DefaultHelpMarkdown, "", nil)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return helpContentDTO(row.ID, row.Markdown, row.CreatedBy, &row.CreatedAt)
|
||||
}
|
||||
|
||||
func helpContentDTO(id uint64, markdown, createdBy string, createdAt *time.Time) (gin.H, error) {
|
||||
html, err := RenderMarkdown(markdown)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gin.H{"id": ptrHelpID(id), "markdown": markdown, "html": html, "created_by": createdBy, "created_at": ptrTime(createdAt)}, nil
|
||||
}
|
||||
|
||||
func ptrHelpID(id uint64) any {
|
||||
if id == 0 {
|
||||
return nil
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func ptrTime(value *time.Time) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return *value
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package help
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
)
|
||||
|
||||
// RenderMarkdown 把 GFM markdown 转成净化后的 HTML,供 admin 编辑器预览
|
||||
// 与 /help 路由直接渲染。
|
||||
func RenderMarkdown(markdown string) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
md := goldmark.New(goldmark.WithExtensions(extension.GFM))
|
||||
if err := md.Convert([]byte(markdown), &buf); err != nil {
|
||||
return "", fmt.Errorf("render markdown: %w", err)
|
||||
}
|
||||
policy := bluemonday.UGCPolicy()
|
||||
policy.RequireNoFollowOnLinks(false)
|
||||
return policy.Sanitize(buf.String()), nil
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package mqttforward
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
storepkg "meshtastic_mqtt_server/internal/store"
|
||||
"meshtastic_mqtt_server/internal/webutil"
|
||||
)
|
||||
|
||||
type mqttForwarderRequest struct {
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
SourceHost string `json:"source_host"`
|
||||
SourcePort int `json:"source_port"`
|
||||
SourceUsername string `json:"source_username"`
|
||||
SourcePassword *string `json:"source_password"`
|
||||
SourcePasswordClear bool `json:"source_password_clear"`
|
||||
SourceClientID string `json:"source_client_id"`
|
||||
SourceTLS bool `json:"source_tls"`
|
||||
TargetHost string `json:"target_host"`
|
||||
TargetPort int `json:"target_port"`
|
||||
TargetUsername string `json:"target_username"`
|
||||
TargetPassword *string `json:"target_password"`
|
||||
TargetPasswordClear bool `json:"target_password_clear"`
|
||||
TargetClientID string `json:"target_client_id"`
|
||||
TargetTLS bool `json:"target_tls"`
|
||||
}
|
||||
|
||||
type mqttForwardTopicRequest struct {
|
||||
Topic string `json:"topic"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Direction string `json:"direction"`
|
||||
SourcePrefix string `json:"source_prefix"`
|
||||
TargetPrefix string `json:"target_prefix"`
|
||||
QoS int `json:"qos"`
|
||||
Retain bool `json:"retain"`
|
||||
}
|
||||
|
||||
func RegisterRoutes(r gin.IRouter, store *storepkg.Store, forwarder Reloader) {
|
||||
r.GET("/mqtt-forward/forwarders", func(c *gin.Context) {
|
||||
opts, ok := webutil.ParseListOptions(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := store.ListMQTTForwarders(opts)
|
||||
if err != nil {
|
||||
webutil.WriteListResponse(c, rows, opts, err, mqttForwarderDTO)
|
||||
return
|
||||
}
|
||||
total, err := store.CountMQTTForwarders(opts)
|
||||
webutil.WriteListResponseWithTotal(c, rows, opts, total, err, mqttForwarderDTO)
|
||||
})
|
||||
r.POST("/mqtt-forward/forwarders", func(c *gin.Context) {
|
||||
var req mqttForwarderRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid mqtt forwarder request"})
|
||||
return
|
||||
}
|
||||
input := mqttForwarderInputFromRequest(req)
|
||||
row, err := store.CreateMQTTForwarder(input)
|
||||
writeMQTTForwardMutationResponse(c, http.StatusCreated, row, err, func() error {
|
||||
return reloadMQTTForwarder(forwarder, row.ID)
|
||||
})
|
||||
})
|
||||
r.PUT("/mqtt-forward/forwarders/:id", func(c *gin.Context) {
|
||||
id, ok := parseMQTTForwardID(c, "invalid mqtt forwarder id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req mqttForwarderRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid mqtt forwarder request"})
|
||||
return
|
||||
}
|
||||
input := mqttForwarderInputFromRequest(req)
|
||||
row, err := store.UpdateMQTTForwarder(id, input)
|
||||
writeMQTTForwardMutationResponse(c, http.StatusOK, row, err, func() error {
|
||||
return reloadMQTTForwarder(forwarder, id)
|
||||
})
|
||||
})
|
||||
r.DELETE("/mqtt-forward/forwarders/:id", func(c *gin.Context) {
|
||||
id, ok := parseMQTTForwardID(c, "invalid mqtt forwarder id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if forwarder != nil {
|
||||
forwarder.StopForwarder(id)
|
||||
}
|
||||
writeMQTTForwardDeleteResponse(c, store.DeleteMQTTForwarder(id), nil)
|
||||
})
|
||||
r.POST("/mqtt-forward/forwarders/:id/restart", func(c *gin.Context) {
|
||||
id, ok := parseMQTTForwardID(c, "invalid mqtt forwarder id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := reloadMQTTForwarder(forwarder, id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
r.GET("/mqtt-forward/forwarders/:id/topics", func(c *gin.Context) {
|
||||
id, ok := parseMQTTForwardID(c, "invalid mqtt forwarder id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
opts, ok := webutil.ParseListOptions(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := store.ListMQTTForwardTopics(id, opts)
|
||||
if err != nil {
|
||||
webutil.WriteListResponse(c, rows, opts, err, mqttForwardTopicDTO)
|
||||
return
|
||||
}
|
||||
total, err := store.CountMQTTForwardTopics(id)
|
||||
webutil.WriteListResponseWithTotal(c, rows, opts, total, err, mqttForwardTopicDTO)
|
||||
})
|
||||
r.POST("/mqtt-forward/forwarders/:id/topics", func(c *gin.Context) {
|
||||
id, ok := parseMQTTForwardID(c, "invalid mqtt forwarder id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req mqttForwardTopicRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid mqtt forward topic request"})
|
||||
return
|
||||
}
|
||||
row, err := store.CreateMQTTForwardTopic(id, mqttForwardTopicInputFromRequest(req))
|
||||
writeMQTTForwardTopicMutationResponse(c, http.StatusCreated, row, err, func() error {
|
||||
return reloadMQTTForwarder(forwarder, id)
|
||||
})
|
||||
})
|
||||
r.PUT("/mqtt-forward/topics/:id", func(c *gin.Context) {
|
||||
id, ok := parseMQTTForwardID(c, "invalid mqtt forward topic id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req mqttForwardTopicRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid mqtt forward topic request"})
|
||||
return
|
||||
}
|
||||
row, err := store.UpdateMQTTForwardTopic(id, mqttForwardTopicInputFromRequest(req))
|
||||
writeMQTTForwardTopicMutationResponse(c, http.StatusOK, row, err, func() error {
|
||||
return reloadMQTTForwarder(forwarder, row.ForwarderID)
|
||||
})
|
||||
})
|
||||
r.DELETE("/mqtt-forward/topics/:id", func(c *gin.Context) {
|
||||
id, ok := parseMQTTForwardID(c, "invalid mqtt forward topic id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
row, err := store.GetMQTTForwardTopic(id)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "mqtt forward topic not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
parentID := row.ForwarderID
|
||||
writeMQTTForwardDeleteResponse(c, store.DeleteMQTTForwardTopic(id), func() error {
|
||||
return reloadMQTTForwarder(forwarder, parentID)
|
||||
})
|
||||
})
|
||||
r.GET("/mqtt-forward/status", func(c *gin.Context) {
|
||||
items := []RuntimeStatus{}
|
||||
if forwarder != nil {
|
||||
items = forwarder.Status()
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
})
|
||||
}
|
||||
|
||||
func mqttForwarderInputFromRequest(req mqttForwarderRequest) storepkg.MQTTForwarderInput {
|
||||
sourcePassword := req.SourcePassword
|
||||
if req.SourcePasswordClear {
|
||||
empty := ""
|
||||
sourcePassword = &empty
|
||||
}
|
||||
targetPassword := req.TargetPassword
|
||||
if req.TargetPasswordClear {
|
||||
empty := ""
|
||||
targetPassword = &empty
|
||||
}
|
||||
return storepkg.MQTTForwarderInput{Name: req.Name, Enabled: req.Enabled, SourceHost: req.SourceHost, SourcePort: req.SourcePort, SourceUsername: req.SourceUsername, SourcePassword: sourcePassword, SourceClientID: req.SourceClientID, SourceTLS: req.SourceTLS, TargetHost: req.TargetHost, TargetPort: req.TargetPort, TargetUsername: req.TargetUsername, TargetPassword: targetPassword, TargetClientID: req.TargetClientID, TargetTLS: req.TargetTLS}
|
||||
}
|
||||
|
||||
func mqttForwardTopicInputFromRequest(req mqttForwardTopicRequest) storepkg.MQTTForwardTopicInput {
|
||||
return storepkg.MQTTForwardTopicInput{Topic: req.Topic, Enabled: req.Enabled, Direction: req.Direction, SourcePrefix: req.SourcePrefix, TargetPrefix: req.TargetPrefix, QoS: req.QoS, Retain: req.Retain}
|
||||
}
|
||||
|
||||
func parseMQTTForwardID(c *gin.Context, message string) (uint64, bool) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": message})
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func reloadMQTTForwarder(forwarder Reloader, id uint64) error {
|
||||
if forwarder == nil {
|
||||
return nil
|
||||
}
|
||||
return forwarder.ReloadForwarder(id)
|
||||
}
|
||||
|
||||
func writeMQTTForwardMutationResponse(c *gin.Context, status int, row *storepkg.MQTTForwarderRecord, err error, afterSuccess func() error) {
|
||||
if errors.Is(err, storepkg.ErrMQTTForwarderAlreadyExists) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "mqtt forwarder already exists"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "mqtt forwarder not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if afterSuccess != nil {
|
||||
if err := afterSuccess(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "mqtt forwarder saved but reload failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(status, gin.H{"item": mqttForwarderDTO(*row)})
|
||||
}
|
||||
|
||||
func writeMQTTForwardTopicMutationResponse(c *gin.Context, status int, row *storepkg.MQTTForwardTopicRecord, err error, afterSuccess func() error) {
|
||||
if errors.Is(err, storepkg.ErrMQTTForwardTopicAlreadyExists) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "mqtt forward topic already exists"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "mqtt forward topic not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if afterSuccess != nil {
|
||||
if err := afterSuccess(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "mqtt forward topic saved but reload failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(status, gin.H{"item": mqttForwardTopicDTO(*row)})
|
||||
}
|
||||
|
||||
func writeMQTTForwardDeleteResponse(c *gin.Context, err error, afterSuccess func() error) {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "mqtt forward item not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if afterSuccess != nil {
|
||||
if err := afterSuccess(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "mqtt forward item deleted but reload failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
func mqttForwarderDTO(row storepkg.MQTTForwarderRecord) gin.H {
|
||||
return gin.H{"id": row.ID, "name": row.Name, "enabled": row.Enabled, "source_host": row.SourceHost, "source_port": row.SourcePort, "source_username": row.SourceUsername, "source_password_set": row.SourcePassword != "", "source_client_id": row.SourceClientID, "source_tls": row.SourceTLS, "target_host": row.TargetHost, "target_port": row.TargetPort, "target_username": row.TargetUsername, "target_password_set": row.TargetPassword != "", "target_client_id": row.TargetClientID, "target_tls": row.TargetTLS, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
|
||||
}
|
||||
|
||||
func mqttForwardTopicDTO(row storepkg.MQTTForwardTopicRecord) gin.H {
|
||||
return gin.H{"id": row.ID, "forwarder_id": row.ForwarderID, "topic": row.Topic, "enabled": row.Enabled, "direction": row.Direction, "source_prefix": row.SourcePrefix, "target_prefix": row.TargetPrefix, "qos": row.QoS, "retain": row.Retain, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package mqttforward
|
||||
|
||||
import "sync/atomic"
|
||||
|
||||
type Stats struct {
|
||||
forwarded atomic.Int64
|
||||
dropped atomic.Int64
|
||||
}
|
||||
|
||||
func (s *Stats) IncForwarded() {
|
||||
if s != nil {
|
||||
s.forwarded.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Stats) IncDropped() {
|
||||
if s != nil {
|
||||
s.dropped.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Stats) Forwarded() int64 {
|
||||
if s == nil {
|
||||
return 0
|
||||
}
|
||||
return s.forwarded.Load()
|
||||
}
|
||||
|
||||
func (s *Stats) Dropped() int64 {
|
||||
if s == nil {
|
||||
return 0
|
||||
}
|
||||
return s.dropped.Load()
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
package mqttforward
|
||||
|
||||
import (
|
||||
storepkg "meshtastic_mqtt_server/internal/store"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
pahomqtt "github.com/eclipse/paho.mqtt.golang"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
mqttForwardDirectionTargetToSource = "target_to_source"
|
||||
mqttForwardLoopTTL = 15 * time.Second
|
||||
mqttForwardLoopMaxEntries = 10000
|
||||
)
|
||||
|
||||
type Reloader interface {
|
||||
ReloadForwarder(id uint64) error
|
||||
StopForwarder(id uint64)
|
||||
Status() []RuntimeStatus
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
store *storepkg.Store
|
||||
mu sync.Mutex
|
||||
runners map[uint64]*runner
|
||||
}
|
||||
|
||||
type RuntimeStatus struct {
|
||||
ForwarderID uint64 `json:"forwarder_id"`
|
||||
Running bool `json:"running"`
|
||||
SourceConnected bool `json:"source_connected"`
|
||||
TargetConnected bool `json:"target_connected"`
|
||||
LastError string `json:"last_error"`
|
||||
StartedAt *time.Time `json:"started_at"`
|
||||
MessagesForwarded uint64 `json:"messages_forwarded"`
|
||||
MessagesDropped uint64 `json:"messages_dropped"`
|
||||
}
|
||||
|
||||
type runner struct {
|
||||
config storepkg.MQTTForwarderConfig
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
source pahomqtt.Client
|
||||
target pahomqtt.Client
|
||||
|
||||
mu sync.Mutex
|
||||
lastError string
|
||||
startedAt time.Time
|
||||
sourceConnected bool
|
||||
targetConnected bool
|
||||
messagesForwarded uint64
|
||||
messagesDropped uint64
|
||||
loopCache map[string]time.Time
|
||||
}
|
||||
|
||||
func NewManager(store *storepkg.Store) *Manager {
|
||||
return &Manager{store: store, runners: make(map[uint64]*runner)}
|
||||
}
|
||||
|
||||
func (m *Manager) StartFromStore() error {
|
||||
configs, err := m.store.ListEnabledMQTTForwarderConfigs()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, cfg := range configs {
|
||||
if len(cfg.Topics) == 0 {
|
||||
continue
|
||||
}
|
||||
runner := newRunner(cfg)
|
||||
runner.Start()
|
||||
m.mu.Lock()
|
||||
m.runners[cfg.Forwarder.ID] = runner
|
||||
m.mu.Unlock()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) ReloadForwarder(id uint64) error {
|
||||
m.StopForwarder(id)
|
||||
cfg, err := m.store.GetMQTTForwarderConfig(id)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !cfg.Forwarder.Enabled || len(cfg.Topics) == 0 {
|
||||
return nil
|
||||
}
|
||||
runner := newRunner(*cfg)
|
||||
runner.Start()
|
||||
m.mu.Lock()
|
||||
m.runners[id] = runner
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) StopForwarder(id uint64) {
|
||||
m.mu.Lock()
|
||||
runner := m.runners[id]
|
||||
delete(m.runners, id)
|
||||
m.mu.Unlock()
|
||||
if runner != nil {
|
||||
runner.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) StopAll() {
|
||||
m.mu.Lock()
|
||||
runners := make([]*runner, 0, len(m.runners))
|
||||
for id, runner := range m.runners {
|
||||
runners = append(runners, runner)
|
||||
delete(m.runners, id)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
for _, runner := range runners {
|
||||
runner.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Status() []RuntimeStatus {
|
||||
m.mu.Lock()
|
||||
runners := make([]*runner, 0, len(m.runners))
|
||||
for _, runner := range m.runners {
|
||||
runners = append(runners, runner)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
items := make([]RuntimeStatus, 0, len(runners))
|
||||
for _, runner := range runners {
|
||||
items = append(items, runner.Status())
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].ForwarderID < items[j].ForwarderID })
|
||||
return items
|
||||
}
|
||||
|
||||
func newRunner(config storepkg.MQTTForwarderConfig) *runner {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &runner{config: config, ctx: ctx, cancel: cancel, startedAt: time.Now(), loopCache: make(map[string]time.Time)}
|
||||
}
|
||||
|
||||
func (r *runner) Start() {
|
||||
r.source = r.newClient(true)
|
||||
r.target = r.newClient(false)
|
||||
r.connectClient(r.target, "target")
|
||||
r.connectClient(r.source, "source")
|
||||
}
|
||||
|
||||
func (r *runner) Stop() {
|
||||
r.cancel()
|
||||
if r.source != nil && r.source.IsConnected() {
|
||||
r.source.Disconnect(250)
|
||||
}
|
||||
if r.target != nil && r.target.IsConnected() {
|
||||
r.target.Disconnect(250)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *runner) Status() RuntimeStatus {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
started := r.startedAt
|
||||
return RuntimeStatus{
|
||||
ForwarderID: r.config.Forwarder.ID,
|
||||
Running: true,
|
||||
SourceConnected: r.sourceConnected,
|
||||
TargetConnected: r.targetConnected,
|
||||
LastError: r.lastError,
|
||||
StartedAt: &started,
|
||||
MessagesForwarded: r.messagesForwarded,
|
||||
MessagesDropped: r.messagesDropped,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *runner) newClient(source bool) pahomqtt.Client {
|
||||
forwarder := r.config.Forwarder
|
||||
host, port, username, password, clientID, useTLS := forwarder.SourceHost, forwarder.SourcePort, forwarder.SourceUsername, forwarder.SourcePassword, forwarder.SourceClientID, forwarder.SourceTLS
|
||||
role := "source"
|
||||
if !source {
|
||||
host, port, username, password, clientID, useTLS = forwarder.TargetHost, forwarder.TargetPort, forwarder.TargetUsername, forwarder.TargetPassword, forwarder.TargetClientID, forwarder.TargetTLS
|
||||
role = "target"
|
||||
}
|
||||
if clientID == "" {
|
||||
clientID = fmt.Sprintf("mesh-forward-%d-%s", forwarder.ID, role)
|
||||
}
|
||||
scheme := "tcp"
|
||||
if useTLS {
|
||||
scheme = "ssl"
|
||||
}
|
||||
opts := pahomqtt.NewClientOptions().
|
||||
AddBroker(fmt.Sprintf("%s://%s", scheme, net.JoinHostPort(host, fmt.Sprint(port)))).
|
||||
SetClientID(clientID).
|
||||
SetAutoReconnect(true).
|
||||
SetConnectRetry(true).
|
||||
SetKeepAlive(60 * time.Second).
|
||||
SetConnectionLostHandler(func(_ pahomqtt.Client, err error) {
|
||||
r.setConnected(source, false)
|
||||
r.setError(fmt.Sprintf("%s connection lost: %v", role, err))
|
||||
}).
|
||||
SetOnConnectHandler(func(client pahomqtt.Client) {
|
||||
r.setConnected(source, true)
|
||||
r.subscribe(client, source)
|
||||
})
|
||||
if username != "" {
|
||||
opts.SetUsername(username)
|
||||
}
|
||||
if password != "" {
|
||||
opts.SetPassword(password)
|
||||
}
|
||||
if useTLS {
|
||||
opts.SetTLSConfig(&tls.Config{MinVersion: tls.VersionTLS12})
|
||||
}
|
||||
return pahomqtt.NewClient(opts)
|
||||
}
|
||||
|
||||
func (r *runner) connectClient(client pahomqtt.Client, label string) {
|
||||
token := client.Connect()
|
||||
if !token.WaitTimeout(2 * time.Second) {
|
||||
r.setError(label + " connect pending")
|
||||
return
|
||||
}
|
||||
if err := token.Error(); err != nil {
|
||||
r.setError(fmt.Sprintf("%s connect failed: %v", label, err))
|
||||
}
|
||||
}
|
||||
|
||||
func (r *runner) subscribe(client pahomqtt.Client, source bool) {
|
||||
for _, topic := range r.config.Topics {
|
||||
filter := topic.Topic
|
||||
if !source {
|
||||
if topic.Direction != storepkg.MQTTForwardDirectionBidirectional {
|
||||
continue
|
||||
}
|
||||
filter = mapMQTTForwardTopic(topic.Topic, topic.SourcePrefix, topic.TargetPrefix)
|
||||
}
|
||||
topicRule := topic
|
||||
token := client.Subscribe(filter, byte(topic.QoS), func(_ pahomqtt.Client, msg pahomqtt.Message) {
|
||||
r.forwardMessage(source, topicRule, msg)
|
||||
})
|
||||
if !token.WaitTimeout(2 * time.Second) {
|
||||
r.setError("subscribe pending: " + filter)
|
||||
continue
|
||||
}
|
||||
if err := token.Error(); err != nil {
|
||||
r.setError(fmt.Sprintf("subscribe %s failed: %v", filter, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *runner) forwardMessage(fromSource bool, rule storepkg.MQTTForwardTopicRecord, msg pahomqtt.Message) {
|
||||
if r.ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
fromTopic := msg.Topic()
|
||||
if fromSource {
|
||||
if !mqttTopicFilterMatches(rule.Topic, fromTopic) {
|
||||
return
|
||||
}
|
||||
} else if !mqttTopicFilterMatches(mapMQTTForwardTopic(rule.Topic, rule.SourcePrefix, rule.TargetPrefix), fromTopic) {
|
||||
return
|
||||
}
|
||||
toTopic := fromTopic
|
||||
forwardDirection := storepkg.MQTTForwardDirectionSourceToTarget
|
||||
if fromSource {
|
||||
toTopic = mapMQTTForwardTopic(fromTopic, rule.SourcePrefix, rule.TargetPrefix)
|
||||
} else {
|
||||
forwardDirection = mqttForwardDirectionTargetToSource
|
||||
toTopic = mapMQTTForwardTopic(fromTopic, rule.TargetPrefix, rule.SourcePrefix)
|
||||
}
|
||||
if r.isSuppressed(forwardDirection, fromTopic, toTopic, msg.Payload(), rule.QoS, rule.Retain) {
|
||||
r.incDropped()
|
||||
return
|
||||
}
|
||||
target := r.target
|
||||
reverseDirection := mqttForwardDirectionTargetToSource
|
||||
if !fromSource {
|
||||
target = r.source
|
||||
reverseDirection = storepkg.MQTTForwardDirectionSourceToTarget
|
||||
}
|
||||
r.markSuppressed(reverseDirection, toTopic, fromTopic, msg.Payload(), rule.QoS, rule.Retain)
|
||||
token := target.Publish(toTopic, byte(rule.QoS), rule.Retain, msg.Payload())
|
||||
if !token.WaitTimeout(2 * time.Second) {
|
||||
r.setError("publish pending: " + toTopic)
|
||||
r.incDropped()
|
||||
return
|
||||
}
|
||||
if err := token.Error(); err != nil {
|
||||
r.setError(fmt.Sprintf("publish %s failed: %v", toTopic, err))
|
||||
r.incDropped()
|
||||
return
|
||||
}
|
||||
r.incForwarded()
|
||||
}
|
||||
|
||||
func (r *runner) setConnected(source bool, connected bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if source {
|
||||
r.sourceConnected = connected
|
||||
} else {
|
||||
r.targetConnected = connected
|
||||
}
|
||||
}
|
||||
|
||||
func (r *runner) setError(message string) {
|
||||
r.mu.Lock()
|
||||
r.lastError = message
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *runner) incForwarded() {
|
||||
r.mu.Lock()
|
||||
r.messagesForwarded++
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *runner) incDropped() {
|
||||
r.mu.Lock()
|
||||
r.messagesDropped++
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *runner) isSuppressed(direction, fromTopic, toTopic string, payload []byte, qos int, retain bool) bool {
|
||||
key := mqttForwardLoopKey(direction, fromTopic, toTopic, payload, qos, retain)
|
||||
now := time.Now()
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
expires, ok := r.loopCache[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if now.After(expires) {
|
||||
delete(r.loopCache, key)
|
||||
return false
|
||||
}
|
||||
delete(r.loopCache, key)
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *runner) markSuppressed(direction, fromTopic, toTopic string, payload []byte, qos int, retain bool) {
|
||||
key := mqttForwardLoopKey(direction, fromTopic, toTopic, payload, qos, retain)
|
||||
now := time.Now()
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if len(r.loopCache) >= mqttForwardLoopMaxEntries {
|
||||
for existing, expires := range r.loopCache {
|
||||
if now.After(expires) || len(r.loopCache) >= mqttForwardLoopMaxEntries {
|
||||
delete(r.loopCache, existing)
|
||||
}
|
||||
if len(r.loopCache) < mqttForwardLoopMaxEntries {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
r.loopCache[key] = now.Add(mqttForwardLoopTTL)
|
||||
}
|
||||
|
||||
func mqttForwardLoopKey(direction, fromTopic, toTopic string, payload []byte, qos int, retain bool) string {
|
||||
sum := sha256.Sum256(payload)
|
||||
return fmt.Sprintf("%s\x00%s\x00%s\x00%d\x00%t\x00%s", direction, fromTopic, toTopic, qos, retain, hex.EncodeToString(sum[:]))
|
||||
}
|
||||
|
||||
func mapMQTTForwardTopic(topic, fromPrefix, toPrefix string) string {
|
||||
fromPrefix = strings.Trim(fromPrefix, "/")
|
||||
toPrefix = strings.Trim(toPrefix, "/")
|
||||
if fromPrefix == "" {
|
||||
return topic
|
||||
}
|
||||
if topic == fromPrefix {
|
||||
return toPrefix
|
||||
}
|
||||
if strings.HasPrefix(topic, fromPrefix+"/") {
|
||||
if toPrefix == "" {
|
||||
return strings.TrimPrefix(topic, fromPrefix+"/")
|
||||
}
|
||||
return toPrefix + strings.TrimPrefix(topic, fromPrefix)
|
||||
}
|
||||
return topic
|
||||
}
|
||||
|
||||
func mqttTopicFilterMatches(filter, topic string) bool {
|
||||
filterParts := strings.Split(filter, "/")
|
||||
topicParts := strings.Split(topic, "/")
|
||||
for i, filterPart := range filterParts {
|
||||
if filterPart == "#" {
|
||||
return i == len(filterParts)-1
|
||||
}
|
||||
if i >= len(topicParts) {
|
||||
return false
|
||||
}
|
||||
if filterPart == "+" {
|
||||
continue
|
||||
}
|
||||
if filterPart != topicParts[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return len(filterParts) == len(topicParts)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package runtimesettings
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
storepkg "meshtastic_mqtt_server/internal/store"
|
||||
)
|
||||
|
||||
const allowEncryptedForwardingLabel = "Allow encrypted MQTT packets to be forwarded when they cannot be decrypted"
|
||||
const llmQueueEnabledLabel = "Enable LLM message queue"
|
||||
const llmIncludeChannelLabel = "Include channel messages in LLM queue"
|
||||
|
||||
type runtimeSettingsRequest struct {
|
||||
AllowEncryptedForwarding bool `json:"allow_encrypted_forwarding"`
|
||||
LLMQueueEnabled bool `json:"llm_queue_enabled"`
|
||||
LLMIncludeChannelMessages bool `json:"llm_include_channel_messages"`
|
||||
}
|
||||
|
||||
// RegisterRoutes 把 GET /runtime-settings 与 PUT /runtime-settings 挂到给定路由组下。
|
||||
func RegisterRoutes(r gin.IRouter, store *storepkg.Store, settings *Cache) {
|
||||
r.GET("/runtime-settings", func(c *gin.Context) {
|
||||
snapshot, err := store.GetRuntimeSettings()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"item": runtimeSettingsDTO(snapshot)})
|
||||
})
|
||||
|
||||
r.PUT("/runtime-settings", func(c *gin.Context) {
|
||||
var req runtimeSettingsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid runtime settings request"})
|
||||
return
|
||||
}
|
||||
if _, err := store.SetBoolRuntimeSetting(storepkg.RuntimeSettingAllowEncryptedForwarding, req.AllowEncryptedForwarding, allowEncryptedForwardingLabel); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if _, err := store.SetBoolRuntimeSetting(storepkg.RuntimeSettingLLMQueueEnabled, req.LLMQueueEnabled, llmQueueEnabledLabel); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if _, err := store.SetBoolRuntimeSetting(storepkg.RuntimeSettingLLMQueueIncludeChannel, req.LLMIncludeChannelMessages, llmIncludeChannelLabel); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if settings != nil {
|
||||
if err := settings.Reload(store); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
snapshot, err := store.GetRuntimeSettings()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"item": runtimeSettingsDTO(snapshot)})
|
||||
})
|
||||
}
|
||||
|
||||
func runtimeSettingsDTO(settings storepkg.RuntimeSettingsSnapshot) gin.H {
|
||||
return gin.H{
|
||||
"allow_encrypted_forwarding": settings.AllowEncryptedForwarding,
|
||||
"llm_queue_enabled": settings.LLMQueueEnabled,
|
||||
"llm_include_channel_messages": settings.LLMIncludeChannel,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package runtimesettings
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
storepkg "meshtastic_mqtt_server/internal/store"
|
||||
)
|
||||
|
||||
// Cache 把 runtime_settings 表中常用的开关缓存到内存中,避免每次拦截热路径
|
||||
// 都查 DB。AdminRoute 修改后通过 Reload 重新加载。
|
||||
type Cache struct {
|
||||
mu sync.RWMutex
|
||||
settings storepkg.RuntimeSettingsSnapshot
|
||||
}
|
||||
|
||||
// New 从 store 中加载初始快照并返回缓存。
|
||||
func New(s *storepkg.Store) (*Cache, error) {
|
||||
cache := &Cache{}
|
||||
if err := cache.Reload(s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cache, nil
|
||||
}
|
||||
|
||||
// Reload 重新读取数据库快照覆盖当前值。
|
||||
func (c *Cache) Reload(s *storepkg.Store) error {
|
||||
if s == nil {
|
||||
return fmt.Errorf("store is required")
|
||||
}
|
||||
settings, err := s.GetRuntimeSettings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.settings = settings
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Snapshot 返回当前快照的副本(结构体拷贝,调用方可以安全持有)。
|
||||
func (c *Cache) Snapshot() storepkg.RuntimeSettingsSnapshot {
|
||||
if c == nil {
|
||||
return storepkg.RuntimeSettingsSnapshot{}
|
||||
}
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.settings
|
||||
}
|
||||
|
||||
// AllowEncryptedForwarding 是 mqtt 转发热路径上常被检查的标志位的快捷读法。
|
||||
func (c *Cache) AllowEncryptedForwarding() bool {
|
||||
return c.Snapshot().AllowEncryptedForwarding
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package runtimesettings
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
storepkg "meshtastic_mqtt_server/internal/store"
|
||||
"meshtastic_mqtt_server/internal/store/testutil"
|
||||
)
|
||||
|
||||
func openTestStore(t *testing.T) *storepkg.Store {
|
||||
return testutil.OpenStore(t)
|
||||
}
|
||||
|
||||
func TestRuntimeSettingsCacheReload(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
cache, err := New(st)
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
if cache.AllowEncryptedForwarding() {
|
||||
t.Fatalf("AllowEncryptedForwarding() = true, want false")
|
||||
}
|
||||
|
||||
if _, err := st.SetBoolRuntimeSetting(storepkg.RuntimeSettingAllowEncryptedForwarding, true, "test setting"); err != nil {
|
||||
t.Fatalf("SetBoolRuntimeSetting(true) error = %v", err)
|
||||
}
|
||||
if err := cache.Reload(st); err != nil {
|
||||
t.Fatalf("Reload() after true error = %v", err)
|
||||
}
|
||||
if !cache.AllowEncryptedForwarding() {
|
||||
t.Fatalf("AllowEncryptedForwarding() = false, want true")
|
||||
}
|
||||
|
||||
if _, err := st.SetBoolRuntimeSetting(storepkg.RuntimeSettingAllowEncryptedForwarding, false, "test setting"); err != nil {
|
||||
t.Fatalf("SetBoolRuntimeSetting(false) error = %v", err)
|
||||
}
|
||||
if err := cache.Reload(st); err != nil {
|
||||
t.Fatalf("Reload() after false error = %v", err)
|
||||
}
|
||||
if cache.AllowEncryptedForwarding() {
|
||||
t.Fatalf("AllowEncryptedForwarding() = true, want false")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Package testutil 提供给其它包测试使用的 store 临时实例工厂。
|
||||
//
|
||||
// 重构前 db_test.go 中的 openTestStore helper 被 8+ 个测试文件复用;
|
||||
// 现在抽到这里,让 store 包外的测试也可以零样板地拿到一个临时 SQLite store。
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"meshtastic_mqtt_server/internal/config"
|
||||
"meshtastic_mqtt_server/internal/store"
|
||||
)
|
||||
|
||||
// OpenStore 返回一个写在 t.TempDir() 中的临时 SQLite store。
|
||||
// 测试结束时调用方需要 defer st.Close()。
|
||||
func OpenStore(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
st, err := store.OpenStore(config.DatabaseConfig{
|
||||
Driver: config.DriverSQLite,
|
||||
SQLite: config.SQLiteConfig{Path: filepath.Join(t.TempDir(), "mesh_mqtt_go.db")},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore() error = %v", err)
|
||||
}
|
||||
return st
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
// Package webutil 收集 admin/路由层共享的 HTTP 解析与响应工具。
|
||||
//
|
||||
// 这些函数原本散落在 web.go 中(parseListOptions、writeListResponse、
|
||||
// parseMapReportListOptions 等),任何注册 admin 路由的领域包都依赖它们。
|
||||
// 把它们抽离出来可以避免 internal/web 同时被 internal/blocking、
|
||||
// internal/bot 等包反向引用造成循环依赖。
|
||||
package webutil
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"meshtastic_mqtt_server/internal/store"
|
||||
)
|
||||
|
||||
// ParseListOptions 从请求中读取 limit / offset / since / until / node_id /
|
||||
// channel_id 等通用过滤参数;解析失败时它会写入 400 响应并返回 false。
|
||||
func ParseListOptions(c *gin.Context) (store.ListOptions, bool) {
|
||||
limit, ok := ParseIntQuery(c, "limit", 100)
|
||||
if !ok {
|
||||
return store.ListOptions{}, false
|
||||
}
|
||||
offset, ok := ParseIntQuery(c, "offset", 0)
|
||||
if !ok {
|
||||
return store.ListOptions{}, false
|
||||
}
|
||||
nodeID := c.Query("node_id")
|
||||
if nodeID == "" {
|
||||
nodeID = c.Query("from")
|
||||
}
|
||||
channelID := c.Query("channel_id")
|
||||
var since, until *time.Time
|
||||
if value := c.Query("since"); value != "" {
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid since: use RFC3339"})
|
||||
return store.ListOptions{}, false
|
||||
}
|
||||
since = &parsed
|
||||
}
|
||||
if value := c.Query("until"); value != "" {
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid until: use RFC3339"})
|
||||
return store.ListOptions{}, false
|
||||
}
|
||||
until = &parsed
|
||||
}
|
||||
return store.NormalizeListOptions(store.ListOptions{Limit: limit, Offset: offset, NodeID: nodeID, ChannelID: channelID, Since: since, Until: until}), true
|
||||
}
|
||||
|
||||
// ParseMapReportListOptions 在 ParseListOptions 的基础上解析 4 个地图边界。
|
||||
func ParseMapReportListOptions(c *gin.Context) (store.ListOptions, bool) {
|
||||
opts, ok := ParseListOptions(c)
|
||||
if !ok {
|
||||
return store.ListOptions{}, false
|
||||
}
|
||||
minLat, hasMinLat, ok := ParseOptionalFloatQuery(c, "min_lat")
|
||||
if !ok {
|
||||
return store.ListOptions{}, false
|
||||
}
|
||||
maxLat, hasMaxLat, ok := ParseOptionalFloatQuery(c, "max_lat")
|
||||
if !ok {
|
||||
return store.ListOptions{}, false
|
||||
}
|
||||
minLng, hasMinLng, ok := ParseOptionalFloatQuery(c, "min_lng")
|
||||
if !ok {
|
||||
return store.ListOptions{}, false
|
||||
}
|
||||
maxLng, hasMaxLng, ok := ParseOptionalFloatQuery(c, "max_lng")
|
||||
if !ok {
|
||||
return store.ListOptions{}, false
|
||||
}
|
||||
boundsCount := 0
|
||||
for _, present := range []bool{hasMinLat, hasMaxLat, hasMinLng, hasMaxLng} {
|
||||
if present {
|
||||
boundsCount++
|
||||
}
|
||||
}
|
||||
if boundsCount == 0 {
|
||||
return opts, true
|
||||
}
|
||||
if boundsCount != 4 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "map bounds require min_lat, max_lat, min_lng, and max_lng"})
|
||||
return store.ListOptions{}, false
|
||||
}
|
||||
if minLat < -90 || minLat > 90 || maxLat < -90 || maxLat > 90 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "latitude bounds must be between -90 and 90"})
|
||||
return store.ListOptions{}, false
|
||||
}
|
||||
if minLat > maxLat {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "min_lat must be <= max_lat"})
|
||||
return store.ListOptions{}, false
|
||||
}
|
||||
if minLng < -180 || minLng > 180 || maxLng < -180 || maxLng > 180 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "longitude bounds must be between -180 and 180"})
|
||||
return store.ListOptions{}, false
|
||||
}
|
||||
opts.MinLat = &minLat
|
||||
opts.MaxLat = &maxLat
|
||||
opts.MinLng = &minLng
|
||||
opts.MaxLng = &maxLng
|
||||
return opts, true
|
||||
}
|
||||
|
||||
// ParseMapReportViewportOptions 在 ParseMapReportListOptions 之上解析 zoom /
|
||||
// cluster_threshold / target_cells 等额外字段。
|
||||
func ParseMapReportViewportOptions(c *gin.Context) (store.MapReportViewportOptions, bool) {
|
||||
opts, ok := ParseMapReportListOptions(c)
|
||||
if !ok {
|
||||
return store.MapReportViewportOptions{}, false
|
||||
}
|
||||
if opts.MinLat == nil || opts.MaxLat == nil || opts.MinLng == nil || opts.MaxLng == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "viewport bounds are required"})
|
||||
return store.MapReportViewportOptions{}, false
|
||||
}
|
||||
zoom, ok := ParseIntQuery(c, "zoom", 0)
|
||||
if !ok {
|
||||
return store.MapReportViewportOptions{}, false
|
||||
}
|
||||
if zoom < 0 || zoom > 24 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "zoom must be between 0 and 24"})
|
||||
return store.MapReportViewportOptions{}, false
|
||||
}
|
||||
limit, ok := ParseIntQuery(c, "limit", 1000)
|
||||
if !ok {
|
||||
return store.MapReportViewportOptions{}, false
|
||||
}
|
||||
clusterThreshold, ok := ParseIntQuery(c, "cluster_threshold", 500)
|
||||
if !ok {
|
||||
return store.MapReportViewportOptions{}, false
|
||||
}
|
||||
targetCells, ok := ParseIntQuery(c, "target_cells", 64)
|
||||
if !ok {
|
||||
return store.MapReportViewportOptions{}, false
|
||||
}
|
||||
if limit <= 0 || clusterThreshold <= 0 || targetCells <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "limit, cluster_threshold, and target_cells must be positive"})
|
||||
return store.MapReportViewportOptions{}, false
|
||||
}
|
||||
return store.NormalizeMapReportViewportOptions(store.MapReportViewportOptions{ListOptions: opts, Zoom: zoom, Limit: limit, ClusterThreshold: clusterThreshold, TargetCells: targetCells}), true
|
||||
}
|
||||
|
||||
// ParseIntQuery 从请求查询字符串解析整数;缺省时返回 defaultValue。
|
||||
func ParseIntQuery(c *gin.Context, name string, defaultValue int) (int, bool) {
|
||||
value := c.Query(name)
|
||||
if value == "" {
|
||||
return defaultValue, true
|
||||
}
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid " + name})
|
||||
return 0, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
// ParseOptionalFloatQuery 解析可选浮点查询参数;返回 (value, present, ok)。
|
||||
func ParseOptionalFloatQuery(c *gin.Context, name string) (float64, bool, bool) {
|
||||
value := c.Query(name)
|
||||
if value == "" {
|
||||
return 0, false, true
|
||||
}
|
||||
parsed, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid " + name})
|
||||
return 0, true, false
|
||||
}
|
||||
return parsed, true, true
|
||||
}
|
||||
|
||||
// WriteListResponse 把 rows 通过 convert 转成 gin.H 后包装成 {items, limit, offset}。
|
||||
func WriteListResponse[T any](c *gin.Context, rows []T, opts store.ListOptions, err error, convert func(T) gin.H) {
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
items := make([]gin.H, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, convert(row))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "limit": opts.Limit, "offset": opts.Offset})
|
||||
}
|
||||
|
||||
// WriteListResponseWithTotal 在 WriteListResponse 基础上额外携带 total 字段。
|
||||
func WriteListResponseWithTotal[T any](c *gin.Context, rows []T, opts store.ListOptions, total int64, err error, convert func(T) gin.H) {
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
items := make([]gin.H, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, convert(row))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "limit": opts.Limit, "offset": opts.Offset, "total": total})
|
||||
}
|
||||
|
||||
// PtrString / PtrInt64 / PtrUint64 / PtrFloat64 / PtrBool 把指针解引用成 any,
|
||||
// 用于把数据库可空字段转换成 JSON 时让 nil 序列化为 null。
|
||||
func PtrString(value *string) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func PtrInt64(value *int64) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func PtrUint64(value *uint64) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func PtrFloat64(value *float64) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func PtrBool(value *bool) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return *value
|
||||
}
|
||||
Reference in New Issue
Block a user