重构:拆出 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:
2026-06-18 14:23:56 +08:00
co-authored by Claude
parent eff4972668
commit c527a9fd9a
23 changed files with 786 additions and 313 deletions
+15 -133
View File
@@ -1,147 +1,29 @@
package main package main
// 桥接到 internal/auth — 让根目录其余文件继续用旧的小写名(sessionManager、
// sessionClaims、newSessionManager、requireAdmin、verifyPassword 等)。
import ( import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
authpkg "meshtastic_mqtt_server/internal/auth"
) )
const ( // 类型别名
adminRole = "admin" type (
adminSessionCookie = "mesh_admin_session" sessionManager = authpkg.Manager
sessionClaims = authpkg.SessionClaims
adminUserDTO = authpkg.AdminUserDTO
) )
type adminUserDTO struct { const adminRole = authpkg.AdminRole
Username string `json:"username"`
Role string `json:"role"`
}
type sessionClaims struct {
UserID uint64 `json:"user_id"`
Username string `json:"username"`
Role string `json:"role"`
Expires int64 `json:"expires"`
}
type sessionManager struct {
secret []byte
secure bool
ttl time.Duration
}
func newSessionManager(cfg webAdminConfig) (*sessionManager, error) { func newSessionManager(cfg webAdminConfig) (*sessionManager, error) {
secret := strings.TrimSpace(cfg.SessionSecret) return authpkg.NewManager(cfg)
if secret == "" {
generated := make([]byte, 32)
if _, err := rand.Read(generated); err != nil {
return nil, fmt.Errorf("generate admin session secret: %w", err)
}
return &sessionManager{secret: generated, secure: cfg.SessionSecure, ttl: 24 * time.Hour}, nil
}
return &sessionManager{secret: []byte(secret), secure: cfg.SessionSecure, ttl: 24 * time.Hour}, nil
} }
func hashPassword(password string) (string, error) { func verifyPassword(hash, password string) bool { return authpkg.VerifyPassword(hash, password) }
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hash), nil
}
func verifyPassword(hash, password string) bool { func adminUserResponse(user userRecord) adminUserDTO { return authpkg.AdminUserResponse(user) }
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
func adminUserResponse(user userRecord) adminUserDTO { func requireAdmin(sm *sessionManager) gin.HandlerFunc { return authpkg.RequireAdmin(sm) }
return adminUserDTO{Username: user.Username, Role: user.Role}
}
func (sm *sessionManager) newCookie(user 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
}
func (sm *sessionManager) clearCookie() *http.Cookie {
return &http.Cookie{
Name: adminSessionCookie,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: sm.secure,
SameSite: http.SameSiteLaxMode,
}
}
func (sm *sessionManager) 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 *sessionManager) sign(payload string) string {
mac := hmac.New(sha256.New, sm.secret)
mac.Write([]byte(payload))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func requireAdmin(sm *sessionManager) 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("admin_claims", claims)
c.Next()
}
}
+21
View File
@@ -0,0 +1,21 @@
package main
// 桥接到 internal/blocking — 让根目录其余文件可以继续使用旧名字
// blockingCache / newBlockingCache / registerAdminBlockingRoutes
// 而无须改动 main.go / web.go 等十几处调用点。
import (
"github.com/gin-gonic/gin"
blockingpkg "meshtastic_mqtt_server/internal/blocking"
)
type blockingCache = blockingpkg.Cache
func newBlockingCache(s *store) (*blockingCache, error) {
return blockingpkg.New(s)
}
func registerAdminBlockingRoutes(r gin.IRouter, s *store, b *blockingCache) {
blockingpkg.RegisterRoutes(r, s, b)
}
+16
View File
@@ -0,0 +1,16 @@
package main
// 桥接到 internal/help — 让 web.go 中的 registerHelpRoutes /
// registerAdminHelpRoutes / renderHelpMarkdown 旧名字仍可用。
import (
"github.com/gin-gonic/gin"
helppkg "meshtastic_mqtt_server/internal/help"
)
func registerHelpRoutes(r gin.IRouter, s *store) { helppkg.RegisterPublicRoutes(r, s) }
func registerAdminHelpRoutes(r gin.IRouter, s *store) { helppkg.RegisterAdminRoutes(r, s) }
func renderHelpMarkdown(markdown string) (string, error) {
return helppkg.RenderMarkdown(markdown)
}
+169
View File
@@ -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 上的 keyhandler 可用
// `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()
}
}
@@ -1,4 +1,4 @@
package main package blocking
import ( import (
"errors" "errors"
@@ -7,6 +7,9 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm" "gorm.io/gorm"
storepkg "meshtastic_mqtt_server/internal/store"
"meshtastic_mqtt_server/internal/webutil"
) )
type nodeBlockingRequest struct { type nodeBlockingRequest struct {
@@ -30,7 +33,7 @@ type forbiddenWordBlockingRequest struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
} }
func registerAdminBlockingRoutes(r gin.IRouter, store *store, blocking *blockingCache) { func RegisterRoutes(r gin.IRouter, store *storepkg.Store, blocking *Cache) {
reloadBlocking := func() error { reloadBlocking := func() error {
if blocking == nil { if blocking == nil {
return nil return nil
@@ -39,17 +42,17 @@ func registerAdminBlockingRoutes(r gin.IRouter, store *store, blocking *blocking
} }
r.GET("/blocking/nodes", func(c *gin.Context) { r.GET("/blocking/nodes", func(c *gin.Context) {
opts, ok := parseListOptions(c) opts, ok := webutil.ParseListOptions(c)
if !ok { if !ok {
return return
} }
rows, err := store.ListNodeBlocking(opts) rows, err := store.ListNodeBlocking(opts)
if err != nil { if err != nil {
writeListResponse(c, rows, opts, err, nodeBlockingDTO) webutil.WriteListResponse(c, rows, opts, err, nodeBlockingDTO)
return return
} }
total, err := store.CountNodeBlocking(opts) total, err := store.CountNodeBlocking(opts)
writeListResponseWithTotal(c, rows, opts, total, err, nodeBlockingDTO) webutil.WriteListResponseWithTotal(c, rows, opts, total, err, nodeBlockingDTO)
}) })
r.POST("/blocking/nodes", func(c *gin.Context) { r.POST("/blocking/nodes", func(c *gin.Context) {
var req nodeBlockingRequest var req nodeBlockingRequest
@@ -82,17 +85,17 @@ func registerAdminBlockingRoutes(r gin.IRouter, store *store, blocking *blocking
}) })
r.GET("/blocking/ips", func(c *gin.Context) { r.GET("/blocking/ips", func(c *gin.Context) {
opts, ok := parseListOptions(c) opts, ok := webutil.ParseListOptions(c)
if !ok { if !ok {
return return
} }
rows, err := store.ListIPBlocking(opts) rows, err := store.ListIPBlocking(opts)
if err != nil { if err != nil {
writeListResponse(c, rows, opts, err, ipBlockingDTO) webutil.WriteListResponse(c, rows, opts, err, ipBlockingDTO)
return return
} }
total, err := store.CountIPBlocking(opts) total, err := store.CountIPBlocking(opts)
writeListResponseWithTotal(c, rows, opts, total, err, ipBlockingDTO) webutil.WriteListResponseWithTotal(c, rows, opts, total, err, ipBlockingDTO)
}) })
r.POST("/blocking/ips", func(c *gin.Context) { r.POST("/blocking/ips", func(c *gin.Context) {
var req ipBlockingRequest var req ipBlockingRequest
@@ -125,17 +128,17 @@ func registerAdminBlockingRoutes(r gin.IRouter, store *store, blocking *blocking
}) })
r.GET("/blocking/words", func(c *gin.Context) { r.GET("/blocking/words", func(c *gin.Context) {
opts, ok := parseListOptions(c) opts, ok := webutil.ParseListOptions(c)
if !ok { if !ok {
return return
} }
rows, err := store.ListForbiddenWordBlocking(opts) rows, err := store.ListForbiddenWordBlocking(opts)
if err != nil { if err != nil {
writeListResponse(c, rows, opts, err, forbiddenWordBlockingDTO) webutil.WriteListResponse(c, rows, opts, err, forbiddenWordBlockingDTO)
return return
} }
total, err := store.CountForbiddenWordBlocking(opts) total, err := store.CountForbiddenWordBlocking(opts)
writeListResponseWithTotal(c, rows, opts, total, err, forbiddenWordBlockingDTO) webutil.WriteListResponseWithTotal(c, rows, opts, total, err, forbiddenWordBlockingDTO)
}) })
r.POST("/blocking/words", func(c *gin.Context) { r.POST("/blocking/words", func(c *gin.Context) {
var req forbiddenWordBlockingRequest var req forbiddenWordBlockingRequest
@@ -178,7 +181,7 @@ func parseBlockingID(c *gin.Context) (uint64, bool) {
} }
func writeBlockingMutationResponse[T any](c *gin.Context, status int, row *T, err error, convert func(T) gin.H, afterSuccess func() error) { 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, errBlockingAlreadyExists) { if errors.Is(err, storepkg.ErrBlockingAlreadyExists) {
c.JSON(http.StatusConflict, gin.H{"error": "blocking rule already exists"}) c.JSON(http.StatusConflict, gin.H{"error": "blocking rule already exists"})
return return
} }
@@ -217,14 +220,14 @@ func writeBlockingDeleteResponse(c *gin.Context, err error, afterSuccess func()
c.JSON(http.StatusOK, gin.H{"status": "ok"}) c.JSON(http.StatusOK, gin.H{"status": "ok"})
} }
func nodeBlockingDTO(row nodeBlockingRecord) gin.H { func nodeBlockingDTO(row storepkg.NodeBlockingRecord) gin.H {
return gin.H{"id": row.ID, "node_id": row.NodeID, "node_num": ptrInt64(row.NodeNum), "reason": row.Reason, "enabled": row.Enabled, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt} 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 ipBlockingRecord) gin.H { 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} 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 forbiddenWordBlockingRecord) gin.H { 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} 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}
} }
@@ -1,4 +1,4 @@
package main package blocking
import ( import (
"fmt" "fmt"
@@ -6,9 +6,11 @@ import (
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
storepkg "meshtastic_mqtt_server/internal/store"
) )
type blockingCache struct { type Cache struct {
mu sync.RWMutex mu sync.RWMutex
nodes map[string]struct{} nodes map[string]struct{}
nodeNums map[int64]struct{} nodeNums map[int64]struct{}
@@ -24,15 +26,15 @@ type forbiddenWordRule struct {
caseSensitive bool caseSensitive bool
} }
func newBlockingCache(store *store) (*blockingCache, error) { func New(store *storepkg.Store) (*Cache, error) {
cache := &blockingCache{} cache := &Cache{}
if err := cache.Reload(store); err != nil { if err := cache.Reload(store); err != nil {
return nil, err return nil, err
} }
return cache, nil return cache, nil
} }
func (c *blockingCache) Reload(store *store) error { func (c *Cache) Reload(store *storepkg.Store) error {
if store == nil { if store == nil {
return fmt.Errorf("store is required") return fmt.Errorf("store is required")
} }
@@ -81,7 +83,7 @@ func (c *blockingCache) Reload(store *store) error {
words := make([]forbiddenWordRule, 0, len(wordRows)) words := make([]forbiddenWordRule, 0, len(wordRows))
for _, row := range wordRows { for _, row := range wordRows {
word := strings.TrimSpace(row.Word) word := strings.TrimSpace(row.Word)
if word == "" || row.MatchType != forbiddenWordMatchContains { if word == "" || row.MatchType != storepkg.ForbiddenWordMatchContains {
continue continue
} }
words = append(words, forbiddenWordRule{word: word, foldedWord: strings.ToLower(word), matchType: row.MatchType, caseSensitive: row.CaseSensitive}) words = append(words, forbiddenWordRule{word: word, foldedWord: strings.ToLower(word), matchType: row.MatchType, caseSensitive: row.CaseSensitive})
@@ -97,7 +99,7 @@ func (c *blockingCache) Reload(store *store) error {
return nil return nil
} }
func (c *blockingCache) IsNodeBlocked(nodeID any, nodeNum any) bool { func (c *Cache) IsNodeBlocked(nodeID any, nodeNum any) bool {
if c == nil { if c == nil {
return false return false
} }
@@ -118,7 +120,7 @@ func (c *blockingCache) IsNodeBlocked(nodeID any, nodeNum any) bool {
return false return false
} }
func (c *blockingCache) IsIPBlocked(host string) bool { func (c *Cache) IsIPBlocked(host string) bool {
if c == nil { if c == nil {
return false return false
} }
@@ -144,7 +146,7 @@ func (c *blockingCache) IsIPBlocked(host string) bool {
return false return false
} }
func (c *blockingCache) FindForbiddenWord(text any) (string, bool) { func (c *Cache) FindForbiddenWord(text any) (string, bool) {
if c == nil { if c == nil {
return "", false return "", false
} }
@@ -157,7 +159,7 @@ func (c *blockingCache) FindForbiddenWord(text any) (string, bool) {
defer c.mu.RUnlock() defer c.mu.RUnlock()
foldedText := "" foldedText := ""
for _, rule := range c.words { for _, rule := range c.words {
if rule.matchType != forbiddenWordMatchContains { if rule.matchType != storepkg.ForbiddenWordMatchContains {
continue continue
} }
if rule.caseSensitive { if rule.caseSensitive {
@@ -1,4 +1,4 @@
package main package blocking
import "testing" import "testing"
@@ -27,9 +27,9 @@ func TestBlockingCacheLoadsEnabledRules(t *testing.T) {
t.Fatalf("CreateForbiddenWordBlocking(disabled) error = %v", err) t.Fatalf("CreateForbiddenWordBlocking(disabled) error = %v", err)
} }
cache, err := newBlockingCache(st) cache, err := New(st)
if err != nil { if err != nil {
t.Fatalf("newBlockingCache() error = %v", err) t.Fatalf("New() error = %v", err)
} }
if !cache.IsNodeBlocked("!12345678", nil) { if !cache.IsNodeBlocked("!12345678", nil) {
@@ -65,9 +65,9 @@ func TestBlockingCacheIPExactAndCIDR(t *testing.T) {
if _, err := st.CreateIPBlocking("2001:db8::/32", "docs", true); err != nil { if _, err := st.CreateIPBlocking("2001:db8::/32", "docs", true); err != nil {
t.Fatalf("CreateIPBlocking(ipv6 cidr) error = %v", err) t.Fatalf("CreateIPBlocking(ipv6 cidr) error = %v", err)
} }
cache, err := newBlockingCache(st) cache, err := New(st)
if err != nil { if err != nil {
t.Fatalf("newBlockingCache() error = %v", err) t.Fatalf("New() error = %v", err)
} }
if !cache.IsIPBlocked("127.0.0.1") { if !cache.IsIPBlocked("127.0.0.1") {
@@ -88,9 +88,9 @@ func TestBlockingCacheForbiddenWordCaseSensitivity(t *testing.T) {
if _, err := st.CreateForbiddenWordBlocking("Spam", "contains", true, "case-sensitive", true); err != nil { if _, err := st.CreateForbiddenWordBlocking("Spam", "contains", true, "case-sensitive", true); err != nil {
t.Fatalf("CreateForbiddenWordBlocking(case-sensitive) error = %v", err) t.Fatalf("CreateForbiddenWordBlocking(case-sensitive) error = %v", err)
} }
cache, err := newBlockingCache(st) cache, err := New(st)
if err != nil { if err != nil {
t.Fatalf("newBlockingCache() error = %v", err) t.Fatalf("New() error = %v", err)
} }
if _, ok := cache.FindForbiddenWord("lowercase spam"); ok { if _, ok := cache.FindForbiddenWord("lowercase spam"); ok {
+13
View File
@@ -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)
}
@@ -1,4 +1,4 @@
package main package help
import ( import (
"errors" "errors"
@@ -7,13 +7,17 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm" "gorm.io/gorm"
"meshtastic_mqtt_server/internal/auth"
storepkg "meshtastic_mqtt_server/internal/store"
) )
type helpContentRequest struct { type helpContentRequest struct {
Markdown string `json:"markdown"` Markdown string `json:"markdown"`
} }
func registerHelpRoutes(r gin.IRouter, store *store) { // RegisterPublicRoutes 把对外可见的 GET /help 挂到给定路由组下。
func RegisterPublicRoutes(r gin.IRouter, store *storepkg.Store) {
r.GET("/help", func(c *gin.Context) { r.GET("/help", func(c *gin.Context) {
item, err := latestHelpContentDTO(store) item, err := latestHelpContentDTO(store)
if err != nil { if err != nil {
@@ -24,7 +28,8 @@ func registerHelpRoutes(r gin.IRouter, store *store) {
}) })
} }
func registerAdminHelpRoutes(r gin.IRouter, store *store) { // RegisterAdminRoutes 注册管理员侧 /help、/help、/help/preview 这三条路由。
func RegisterAdminRoutes(r gin.IRouter, store *storepkg.Store) {
r.GET("/help", func(c *gin.Context) { r.GET("/help", func(c *gin.Context) {
item, err := latestHelpContentDTO(store) item, err := latestHelpContentDTO(store)
if err != nil { if err != nil {
@@ -39,7 +44,7 @@ func registerAdminHelpRoutes(r gin.IRouter, store *store) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid help content request"}) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid help content request"})
return return
} }
claims := c.MustGet("admin_claims").(*sessionClaims) claims := c.MustGet(auth.AdminClaimsKey).(*auth.SessionClaims)
row, err := store.InsertHelpContent(req.Markdown, claims.Username) row, err := store.InsertHelpContent(req.Markdown, claims.Username)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -58,7 +63,7 @@ func registerAdminHelpRoutes(r gin.IRouter, store *store) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid help preview request"}) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid help preview request"})
return return
} }
html, err := renderHelpMarkdown(req.Markdown) html, err := RenderMarkdown(req.Markdown)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
@@ -67,10 +72,10 @@ func registerAdminHelpRoutes(r gin.IRouter, store *store) {
}) })
} }
func latestHelpContentDTO(store *store) (gin.H, error) { func latestHelpContentDTO(store *storepkg.Store) (gin.H, error) {
row, err := store.GetLatestHelpContent() row, err := store.GetLatestHelpContent()
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return helpContentDTO(0, defaultHelpMarkdown, "", nil) return helpContentDTO(0, storepkg.DefaultHelpMarkdown, "", nil)
} }
if err != nil { if err != nil {
return nil, err return nil, err
@@ -79,7 +84,7 @@ func latestHelpContentDTO(store *store) (gin.H, error) {
} }
func helpContentDTO(id uint64, markdown, createdBy string, createdAt *time.Time) (gin.H, error) { func helpContentDTO(id uint64, markdown, createdBy string, createdAt *time.Time) (gin.H, error) {
html, err := renderHelpMarkdown(markdown) html, err := RenderMarkdown(markdown)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -1,4 +1,4 @@
package main package help
import ( import (
"bytes" "bytes"
@@ -9,7 +9,9 @@ import (
"github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/extension"
) )
func renderHelpMarkdown(markdown string) (string, error) { // RenderMarkdown 把 GFM markdown 转成净化后的 HTML,供 admin 编辑器预览
// 与 /help 路由直接渲染。
func RenderMarkdown(markdown string) (string, error) {
var buf bytes.Buffer var buf bytes.Buffer
md := goldmark.New(goldmark.WithExtensions(extension.GFM)) md := goldmark.New(goldmark.WithExtensions(extension.GFM))
if err := md.Convert([]byte(markdown), &buf); err != nil { if err := md.Convert([]byte(markdown), &buf); err != nil {
@@ -1,4 +1,4 @@
package main package mqttforward
import ( import (
"errors" "errors"
@@ -7,6 +7,9 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm" "gorm.io/gorm"
storepkg "meshtastic_mqtt_server/internal/store"
"meshtastic_mqtt_server/internal/webutil"
) )
type mqttForwarderRequest struct { type mqttForwarderRequest struct {
@@ -38,19 +41,19 @@ type mqttForwardTopicRequest struct {
Retain bool `json:"retain"` Retain bool `json:"retain"`
} }
func registerAdminMQTTForwardRoutes(r gin.IRouter, store *store, forwarder mqttForwardReloader) { func RegisterRoutes(r gin.IRouter, store *storepkg.Store, forwarder Reloader) {
r.GET("/mqtt-forward/forwarders", func(c *gin.Context) { r.GET("/mqtt-forward/forwarders", func(c *gin.Context) {
opts, ok := parseListOptions(c) opts, ok := webutil.ParseListOptions(c)
if !ok { if !ok {
return return
} }
rows, err := store.ListMQTTForwarders(opts) rows, err := store.ListMQTTForwarders(opts)
if err != nil { if err != nil {
writeListResponse(c, rows, opts, err, mqttForwarderDTO) webutil.WriteListResponse(c, rows, opts, err, mqttForwarderDTO)
return return
} }
total, err := store.CountMQTTForwarders(opts) total, err := store.CountMQTTForwarders(opts)
writeListResponseWithTotal(c, rows, opts, total, err, mqttForwarderDTO) webutil.WriteListResponseWithTotal(c, rows, opts, total, err, mqttForwarderDTO)
}) })
r.POST("/mqtt-forward/forwarders", func(c *gin.Context) { r.POST("/mqtt-forward/forwarders", func(c *gin.Context) {
var req mqttForwarderRequest var req mqttForwarderRequest
@@ -106,17 +109,17 @@ func registerAdminMQTTForwardRoutes(r gin.IRouter, store *store, forwarder mqttF
if !ok { if !ok {
return return
} }
opts, ok := parseListOptions(c) opts, ok := webutil.ParseListOptions(c)
if !ok { if !ok {
return return
} }
rows, err := store.ListMQTTForwardTopics(id, opts) rows, err := store.ListMQTTForwardTopics(id, opts)
if err != nil { if err != nil {
writeListResponse(c, rows, opts, err, mqttForwardTopicDTO) webutil.WriteListResponse(c, rows, opts, err, mqttForwardTopicDTO)
return return
} }
total, err := store.CountMQTTForwardTopics(id) total, err := store.CountMQTTForwardTopics(id)
writeListResponseWithTotal(c, rows, opts, total, err, mqttForwardTopicDTO) webutil.WriteListResponseWithTotal(c, rows, opts, total, err, mqttForwardTopicDTO)
}) })
r.POST("/mqtt-forward/forwarders/:id/topics", func(c *gin.Context) { r.POST("/mqtt-forward/forwarders/:id/topics", func(c *gin.Context) {
id, ok := parseMQTTForwardID(c, "invalid mqtt forwarder id") id, ok := parseMQTTForwardID(c, "invalid mqtt forwarder id")
@@ -168,7 +171,7 @@ func registerAdminMQTTForwardRoutes(r gin.IRouter, store *store, forwarder mqttF
}) })
}) })
r.GET("/mqtt-forward/status", func(c *gin.Context) { r.GET("/mqtt-forward/status", func(c *gin.Context) {
items := []mqttForwardRuntimeStatus{} items := []RuntimeStatus{}
if forwarder != nil { if forwarder != nil {
items = forwarder.Status() items = forwarder.Status()
} }
@@ -176,7 +179,7 @@ func registerAdminMQTTForwardRoutes(r gin.IRouter, store *store, forwarder mqttF
}) })
} }
func mqttForwarderInputFromRequest(req mqttForwarderRequest) mqttForwarderInput { func mqttForwarderInputFromRequest(req mqttForwarderRequest) storepkg.MQTTForwarderInput {
sourcePassword := req.SourcePassword sourcePassword := req.SourcePassword
if req.SourcePasswordClear { if req.SourcePasswordClear {
empty := "" empty := ""
@@ -187,11 +190,11 @@ func mqttForwarderInputFromRequest(req mqttForwarderRequest) mqttForwarderInput
empty := "" empty := ""
targetPassword = &empty targetPassword = &empty
} }
return 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} 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) mqttForwardTopicInput { func mqttForwardTopicInputFromRequest(req mqttForwardTopicRequest) storepkg.MQTTForwardTopicInput {
return mqttForwardTopicInput{Topic: req.Topic, Enabled: req.Enabled, Direction: req.Direction, SourcePrefix: req.SourcePrefix, TargetPrefix: req.TargetPrefix, QoS: req.QoS, Retain: req.Retain} 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) { func parseMQTTForwardID(c *gin.Context, message string) (uint64, bool) {
@@ -203,15 +206,15 @@ func parseMQTTForwardID(c *gin.Context, message string) (uint64, bool) {
return id, true return id, true
} }
func reloadMQTTForwarder(forwarder mqttForwardReloader, id uint64) error { func reloadMQTTForwarder(forwarder Reloader, id uint64) error {
if forwarder == nil { if forwarder == nil {
return nil return nil
} }
return forwarder.ReloadForwarder(id) return forwarder.ReloadForwarder(id)
} }
func writeMQTTForwardMutationResponse(c *gin.Context, status int, row *mqttForwarderRecord, err error, afterSuccess func() error) { func writeMQTTForwardMutationResponse(c *gin.Context, status int, row *storepkg.MQTTForwarderRecord, err error, afterSuccess func() error) {
if errors.Is(err, errMQTTForwarderAlreadyExists) { if errors.Is(err, storepkg.ErrMQTTForwarderAlreadyExists) {
c.JSON(http.StatusConflict, gin.H{"error": "mqtt forwarder already exists"}) c.JSON(http.StatusConflict, gin.H{"error": "mqtt forwarder already exists"})
return return
} }
@@ -232,8 +235,8 @@ func writeMQTTForwardMutationResponse(c *gin.Context, status int, row *mqttForwa
c.JSON(status, gin.H{"item": mqttForwarderDTO(*row)}) c.JSON(status, gin.H{"item": mqttForwarderDTO(*row)})
} }
func writeMQTTForwardTopicMutationResponse(c *gin.Context, status int, row *mqttForwardTopicRecord, err error, afterSuccess func() error) { func writeMQTTForwardTopicMutationResponse(c *gin.Context, status int, row *storepkg.MQTTForwardTopicRecord, err error, afterSuccess func() error) {
if errors.Is(err, errMQTTForwardTopicAlreadyExists) { if errors.Is(err, storepkg.ErrMQTTForwardTopicAlreadyExists) {
c.JSON(http.StatusConflict, gin.H{"error": "mqtt forward topic already exists"}) c.JSON(http.StatusConflict, gin.H{"error": "mqtt forward topic already exists"})
return return
} }
@@ -272,10 +275,10 @@ func writeMQTTForwardDeleteResponse(c *gin.Context, err error, afterSuccess func
c.JSON(http.StatusOK, gin.H{"status": "ok"}) c.JSON(http.StatusOK, gin.H{"status": "ok"})
} }
func mqttForwarderDTO(row mqttForwarderRecord) gin.H { 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} 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 mqttForwardTopicRecord) gin.H { 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} 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}
} }
@@ -1,32 +1,32 @@
package main package mqttforward
import "sync/atomic" import "sync/atomic"
type meshtasticMessageStats struct { type Stats struct {
forwarded atomic.Int64 forwarded atomic.Int64
dropped atomic.Int64 dropped atomic.Int64
} }
func (s *meshtasticMessageStats) IncForwarded() { func (s *Stats) IncForwarded() {
if s != nil { if s != nil {
s.forwarded.Add(1) s.forwarded.Add(1)
} }
} }
func (s *meshtasticMessageStats) IncDropped() { func (s *Stats) IncDropped() {
if s != nil { if s != nil {
s.dropped.Add(1) s.dropped.Add(1)
} }
} }
func (s *meshtasticMessageStats) Forwarded() int64 { func (s *Stats) Forwarded() int64 {
if s == nil { if s == nil {
return 0 return 0
} }
return s.forwarded.Load() return s.forwarded.Load()
} }
func (s *meshtasticMessageStats) Dropped() int64 { func (s *Stats) Dropped() int64 {
if s == nil { if s == nil {
return 0 return 0
} }
@@ -1,6 +1,7 @@
package main package mqttforward
import ( import (
storepkg "meshtastic_mqtt_server/internal/store"
"context" "context"
"crypto/sha256" "crypto/sha256"
"crypto/tls" "crypto/tls"
@@ -23,19 +24,19 @@ const (
mqttForwardLoopMaxEntries = 10000 mqttForwardLoopMaxEntries = 10000
) )
type mqttForwardReloader interface { type Reloader interface {
ReloadForwarder(id uint64) error ReloadForwarder(id uint64) error
StopForwarder(id uint64) StopForwarder(id uint64)
Status() []mqttForwardRuntimeStatus Status() []RuntimeStatus
} }
type mqttForwardManager struct { type Manager struct {
store *store store *storepkg.Store
mu sync.Mutex mu sync.Mutex
runners map[uint64]*mqttForwardRunner runners map[uint64]*runner
} }
type mqttForwardRuntimeStatus struct { type RuntimeStatus struct {
ForwarderID uint64 `json:"forwarder_id"` ForwarderID uint64 `json:"forwarder_id"`
Running bool `json:"running"` Running bool `json:"running"`
SourceConnected bool `json:"source_connected"` SourceConnected bool `json:"source_connected"`
@@ -46,8 +47,8 @@ type mqttForwardRuntimeStatus struct {
MessagesDropped uint64 `json:"messages_dropped"` MessagesDropped uint64 `json:"messages_dropped"`
} }
type mqttForwardRunner struct { type runner struct {
config mqttForwarderConfig config storepkg.MQTTForwarderConfig
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
source pahomqtt.Client source pahomqtt.Client
@@ -63,11 +64,11 @@ type mqttForwardRunner struct {
loopCache map[string]time.Time loopCache map[string]time.Time
} }
func newMQTTForwardManager(store *store) *mqttForwardManager { func NewManager(store *storepkg.Store) *Manager {
return &mqttForwardManager{store: store, runners: make(map[uint64]*mqttForwardRunner)} return &Manager{store: store, runners: make(map[uint64]*runner)}
} }
func (m *mqttForwardManager) StartFromStore() error { func (m *Manager) StartFromStore() error {
configs, err := m.store.ListEnabledMQTTForwarderConfigs() configs, err := m.store.ListEnabledMQTTForwarderConfigs()
if err != nil { if err != nil {
return err return err
@@ -76,7 +77,7 @@ func (m *mqttForwardManager) StartFromStore() error {
if len(cfg.Topics) == 0 { if len(cfg.Topics) == 0 {
continue continue
} }
runner := newMQTTForwardRunner(cfg) runner := newRunner(cfg)
runner.Start() runner.Start()
m.mu.Lock() m.mu.Lock()
m.runners[cfg.Forwarder.ID] = runner m.runners[cfg.Forwarder.ID] = runner
@@ -85,7 +86,7 @@ func (m *mqttForwardManager) StartFromStore() error {
return nil return nil
} }
func (m *mqttForwardManager) ReloadForwarder(id uint64) error { func (m *Manager) ReloadForwarder(id uint64) error {
m.StopForwarder(id) m.StopForwarder(id)
cfg, err := m.store.GetMQTTForwarderConfig(id) cfg, err := m.store.GetMQTTForwarderConfig(id)
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -97,7 +98,7 @@ func (m *mqttForwardManager) ReloadForwarder(id uint64) error {
if !cfg.Forwarder.Enabled || len(cfg.Topics) == 0 { if !cfg.Forwarder.Enabled || len(cfg.Topics) == 0 {
return nil return nil
} }
runner := newMQTTForwardRunner(*cfg) runner := newRunner(*cfg)
runner.Start() runner.Start()
m.mu.Lock() m.mu.Lock()
m.runners[id] = runner m.runners[id] = runner
@@ -105,7 +106,7 @@ func (m *mqttForwardManager) ReloadForwarder(id uint64) error {
return nil return nil
} }
func (m *mqttForwardManager) StopForwarder(id uint64) { func (m *Manager) StopForwarder(id uint64) {
m.mu.Lock() m.mu.Lock()
runner := m.runners[id] runner := m.runners[id]
delete(m.runners, id) delete(m.runners, id)
@@ -115,9 +116,9 @@ func (m *mqttForwardManager) StopForwarder(id uint64) {
} }
} }
func (m *mqttForwardManager) StopAll() { func (m *Manager) StopAll() {
m.mu.Lock() m.mu.Lock()
runners := make([]*mqttForwardRunner, 0, len(m.runners)) runners := make([]*runner, 0, len(m.runners))
for id, runner := range m.runners { for id, runner := range m.runners {
runners = append(runners, runner) runners = append(runners, runner)
delete(m.runners, id) delete(m.runners, id)
@@ -128,14 +129,14 @@ func (m *mqttForwardManager) StopAll() {
} }
} }
func (m *mqttForwardManager) Status() []mqttForwardRuntimeStatus { func (m *Manager) Status() []RuntimeStatus {
m.mu.Lock() m.mu.Lock()
runners := make([]*mqttForwardRunner, 0, len(m.runners)) runners := make([]*runner, 0, len(m.runners))
for _, runner := range m.runners { for _, runner := range m.runners {
runners = append(runners, runner) runners = append(runners, runner)
} }
m.mu.Unlock() m.mu.Unlock()
items := make([]mqttForwardRuntimeStatus, 0, len(runners)) items := make([]RuntimeStatus, 0, len(runners))
for _, runner := range runners { for _, runner := range runners {
items = append(items, runner.Status()) items = append(items, runner.Status())
} }
@@ -143,19 +144,19 @@ func (m *mqttForwardManager) Status() []mqttForwardRuntimeStatus {
return items return items
} }
func newMQTTForwardRunner(config mqttForwarderConfig) *mqttForwardRunner { func newRunner(config storepkg.MQTTForwarderConfig) *runner {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
return &mqttForwardRunner{config: config, ctx: ctx, cancel: cancel, startedAt: time.Now(), loopCache: make(map[string]time.Time)} return &runner{config: config, ctx: ctx, cancel: cancel, startedAt: time.Now(), loopCache: make(map[string]time.Time)}
} }
func (r *mqttForwardRunner) Start() { func (r *runner) Start() {
r.source = r.newClient(true) r.source = r.newClient(true)
r.target = r.newClient(false) r.target = r.newClient(false)
r.connectClient(r.target, "target") r.connectClient(r.target, "target")
r.connectClient(r.source, "source") r.connectClient(r.source, "source")
} }
func (r *mqttForwardRunner) Stop() { func (r *runner) Stop() {
r.cancel() r.cancel()
if r.source != nil && r.source.IsConnected() { if r.source != nil && r.source.IsConnected() {
r.source.Disconnect(250) r.source.Disconnect(250)
@@ -165,11 +166,11 @@ func (r *mqttForwardRunner) Stop() {
} }
} }
func (r *mqttForwardRunner) Status() mqttForwardRuntimeStatus { func (r *runner) Status() RuntimeStatus {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
started := r.startedAt started := r.startedAt
return mqttForwardRuntimeStatus{ return RuntimeStatus{
ForwarderID: r.config.Forwarder.ID, ForwarderID: r.config.Forwarder.ID,
Running: true, Running: true,
SourceConnected: r.sourceConnected, SourceConnected: r.sourceConnected,
@@ -181,7 +182,7 @@ func (r *mqttForwardRunner) Status() mqttForwardRuntimeStatus {
} }
} }
func (r *mqttForwardRunner) newClient(source bool) pahomqtt.Client { func (r *runner) newClient(source bool) pahomqtt.Client {
forwarder := r.config.Forwarder forwarder := r.config.Forwarder
host, port, username, password, clientID, useTLS := forwarder.SourceHost, forwarder.SourcePort, forwarder.SourceUsername, forwarder.SourcePassword, forwarder.SourceClientID, forwarder.SourceTLS host, port, username, password, clientID, useTLS := forwarder.SourceHost, forwarder.SourcePort, forwarder.SourceUsername, forwarder.SourcePassword, forwarder.SourceClientID, forwarder.SourceTLS
role := "source" role := "source"
@@ -222,7 +223,7 @@ func (r *mqttForwardRunner) newClient(source bool) pahomqtt.Client {
return pahomqtt.NewClient(opts) return pahomqtt.NewClient(opts)
} }
func (r *mqttForwardRunner) connectClient(client pahomqtt.Client, label string) { func (r *runner) connectClient(client pahomqtt.Client, label string) {
token := client.Connect() token := client.Connect()
if !token.WaitTimeout(2 * time.Second) { if !token.WaitTimeout(2 * time.Second) {
r.setError(label + " connect pending") r.setError(label + " connect pending")
@@ -233,11 +234,11 @@ func (r *mqttForwardRunner) connectClient(client pahomqtt.Client, label string)
} }
} }
func (r *mqttForwardRunner) subscribe(client pahomqtt.Client, source bool) { func (r *runner) subscribe(client pahomqtt.Client, source bool) {
for _, topic := range r.config.Topics { for _, topic := range r.config.Topics {
filter := topic.Topic filter := topic.Topic
if !source { if !source {
if topic.Direction != mqttForwardDirectionBidirectional { if topic.Direction != storepkg.MQTTForwardDirectionBidirectional {
continue continue
} }
filter = mapMQTTForwardTopic(topic.Topic, topic.SourcePrefix, topic.TargetPrefix) filter = mapMQTTForwardTopic(topic.Topic, topic.SourcePrefix, topic.TargetPrefix)
@@ -256,7 +257,7 @@ func (r *mqttForwardRunner) subscribe(client pahomqtt.Client, source bool) {
} }
} }
func (r *mqttForwardRunner) forwardMessage(fromSource bool, rule mqttForwardTopicRecord, msg pahomqtt.Message) { func (r *runner) forwardMessage(fromSource bool, rule storepkg.MQTTForwardTopicRecord, msg pahomqtt.Message) {
if r.ctx.Err() != nil { if r.ctx.Err() != nil {
return return
} }
@@ -269,7 +270,7 @@ func (r *mqttForwardRunner) forwardMessage(fromSource bool, rule mqttForwardTopi
return return
} }
toTopic := fromTopic toTopic := fromTopic
forwardDirection := mqttForwardDirectionSourceToTarget forwardDirection := storepkg.MQTTForwardDirectionSourceToTarget
if fromSource { if fromSource {
toTopic = mapMQTTForwardTopic(fromTopic, rule.SourcePrefix, rule.TargetPrefix) toTopic = mapMQTTForwardTopic(fromTopic, rule.SourcePrefix, rule.TargetPrefix)
} else { } else {
@@ -284,7 +285,7 @@ func (r *mqttForwardRunner) forwardMessage(fromSource bool, rule mqttForwardTopi
reverseDirection := mqttForwardDirectionTargetToSource reverseDirection := mqttForwardDirectionTargetToSource
if !fromSource { if !fromSource {
target = r.source target = r.source
reverseDirection = mqttForwardDirectionSourceToTarget reverseDirection = storepkg.MQTTForwardDirectionSourceToTarget
} }
r.markSuppressed(reverseDirection, toTopic, fromTopic, msg.Payload(), rule.QoS, rule.Retain) r.markSuppressed(reverseDirection, toTopic, fromTopic, msg.Payload(), rule.QoS, rule.Retain)
token := target.Publish(toTopic, byte(rule.QoS), rule.Retain, msg.Payload()) token := target.Publish(toTopic, byte(rule.QoS), rule.Retain, msg.Payload())
@@ -301,7 +302,7 @@ func (r *mqttForwardRunner) forwardMessage(fromSource bool, rule mqttForwardTopi
r.incForwarded() r.incForwarded()
} }
func (r *mqttForwardRunner) setConnected(source bool, connected bool) { func (r *runner) setConnected(source bool, connected bool) {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
if source { if source {
@@ -311,25 +312,25 @@ func (r *mqttForwardRunner) setConnected(source bool, connected bool) {
} }
} }
func (r *mqttForwardRunner) setError(message string) { func (r *runner) setError(message string) {
r.mu.Lock() r.mu.Lock()
r.lastError = message r.lastError = message
r.mu.Unlock() r.mu.Unlock()
} }
func (r *mqttForwardRunner) incForwarded() { func (r *runner) incForwarded() {
r.mu.Lock() r.mu.Lock()
r.messagesForwarded++ r.messagesForwarded++
r.mu.Unlock() r.mu.Unlock()
} }
func (r *mqttForwardRunner) incDropped() { func (r *runner) incDropped() {
r.mu.Lock() r.mu.Lock()
r.messagesDropped++ r.messagesDropped++
r.mu.Unlock() r.mu.Unlock()
} }
func (r *mqttForwardRunner) isSuppressed(direction, fromTopic, toTopic string, payload []byte, qos int, retain bool) bool { func (r *runner) isSuppressed(direction, fromTopic, toTopic string, payload []byte, qos int, retain bool) bool {
key := mqttForwardLoopKey(direction, fromTopic, toTopic, payload, qos, retain) key := mqttForwardLoopKey(direction, fromTopic, toTopic, payload, qos, retain)
now := time.Now() now := time.Now()
r.mu.Lock() r.mu.Lock()
@@ -346,7 +347,7 @@ func (r *mqttForwardRunner) isSuppressed(direction, fromTopic, toTopic string, p
return true return true
} }
func (r *mqttForwardRunner) markSuppressed(direction, fromTopic, toTopic string, payload []byte, qos int, retain bool) { func (r *runner) markSuppressed(direction, fromTopic, toTopic string, payload []byte, qos int, retain bool) {
key := mqttForwardLoopKey(direction, fromTopic, toTopic, payload, qos, retain) key := mqttForwardLoopKey(direction, fromTopic, toTopic, payload, qos, retain)
now := time.Now() now := time.Now()
r.mu.Lock() r.mu.Lock()
@@ -1,9 +1,11 @@
package main package runtimesettings
import ( import (
"net/http" "net/http"
"github.com/gin-gonic/gin" "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 allowEncryptedForwardingLabel = "Allow encrypted MQTT packets to be forwarded when they cannot be decrypted"
@@ -11,12 +13,13 @@ const llmQueueEnabledLabel = "Enable LLM message queue"
const llmIncludeChannelLabel = "Include channel messages in LLM queue" const llmIncludeChannelLabel = "Include channel messages in LLM queue"
type runtimeSettingsRequest struct { type runtimeSettingsRequest struct {
AllowEncryptedForwarding bool `json:"allow_encrypted_forwarding"` AllowEncryptedForwarding bool `json:"allow_encrypted_forwarding"`
LLMQueueEnabled bool `json:"llm_queue_enabled"` LLMQueueEnabled bool `json:"llm_queue_enabled"`
LLMIncludeChannelMessages bool `json:"llm_include_channel_messages"` LLMIncludeChannelMessages bool `json:"llm_include_channel_messages"`
} }
func registerAdminRuntimeSettingsRoutes(r gin.IRouter, store *store, settings *runtimeSettingsCache) { // 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) { r.GET("/runtime-settings", func(c *gin.Context) {
snapshot, err := store.GetRuntimeSettings() snapshot, err := store.GetRuntimeSettings()
if err != nil { if err != nil {
@@ -32,15 +35,15 @@ func registerAdminRuntimeSettingsRoutes(r gin.IRouter, store *store, settings *r
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid runtime settings request"}) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid runtime settings request"})
return return
} }
if _, err := store.SetBoolRuntimeSetting(runtimeSettingAllowEncryptedForwarding, req.AllowEncryptedForwarding, allowEncryptedForwardingLabel); err != nil { if _, err := store.SetBoolRuntimeSetting(storepkg.RuntimeSettingAllowEncryptedForwarding, req.AllowEncryptedForwarding, allowEncryptedForwardingLabel); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
if _, err := store.SetBoolRuntimeSetting(runtimeSettingLLMQueueEnabled, req.LLMQueueEnabled, llmQueueEnabledLabel); err != nil { if _, err := store.SetBoolRuntimeSetting(storepkg.RuntimeSettingLLMQueueEnabled, req.LLMQueueEnabled, llmQueueEnabledLabel); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
if _, err := store.SetBoolRuntimeSetting(runtimeSettingLLMQueueIncludeChannel, req.LLMIncludeChannelMessages, llmIncludeChannelLabel); err != nil { if _, err := store.SetBoolRuntimeSetting(storepkg.RuntimeSettingLLMQueueIncludeChannel, req.LLMIncludeChannelMessages, llmIncludeChannelLabel); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
@@ -59,10 +62,10 @@ func registerAdminRuntimeSettingsRoutes(r gin.IRouter, store *store, settings *r
}) })
} }
func runtimeSettingsDTO(settings runtimeSettingsSnapshot) gin.H { func runtimeSettingsDTO(settings storepkg.RuntimeSettingsSnapshot) gin.H {
return gin.H{ return gin.H{
"allow_encrypted_forwarding": settings.AllowEncryptedForwarding, "allow_encrypted_forwarding": settings.AllowEncryptedForwarding,
"llm_queue_enabled": settings.LLMQueueEnabled, "llm_queue_enabled": settings.LLMQueueEnabled,
"llm_include_channel_messages": settings.LLMIncludeChannel, "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
}
@@ -1,20 +1,29 @@
package main package runtimesettings
import "testing" 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) { func TestRuntimeSettingsCacheReload(t *testing.T) {
st := openTestStore(t) st := openTestStore(t)
defer st.Close() defer st.Close()
cache, err := newRuntimeSettingsCache(st) cache, err := New(st)
if err != nil { if err != nil {
t.Fatalf("newRuntimeSettingsCache() error = %v", err) t.Fatalf("New() error = %v", err)
} }
if cache.AllowEncryptedForwarding() { if cache.AllowEncryptedForwarding() {
t.Fatalf("AllowEncryptedForwarding() = true, want false") t.Fatalf("AllowEncryptedForwarding() = true, want false")
} }
if _, err := st.SetBoolRuntimeSetting(runtimeSettingAllowEncryptedForwarding, true, "test setting"); err != nil { if _, err := st.SetBoolRuntimeSetting(storepkg.RuntimeSettingAllowEncryptedForwarding, true, "test setting"); err != nil {
t.Fatalf("SetBoolRuntimeSetting(true) error = %v", err) t.Fatalf("SetBoolRuntimeSetting(true) error = %v", err)
} }
if err := cache.Reload(st); err != nil { if err := cache.Reload(st); err != nil {
@@ -24,7 +33,7 @@ func TestRuntimeSettingsCacheReload(t *testing.T) {
t.Fatalf("AllowEncryptedForwarding() = false, want true") t.Fatalf("AllowEncryptedForwarding() = false, want true")
} }
if _, err := st.SetBoolRuntimeSetting(runtimeSettingAllowEncryptedForwarding, false, "test setting"); err != nil { if _, err := st.SetBoolRuntimeSetting(storepkg.RuntimeSettingAllowEncryptedForwarding, false, "test setting"); err != nil {
t.Fatalf("SetBoolRuntimeSetting(false) error = %v", err) t.Fatalf("SetBoolRuntimeSetting(false) error = %v", err)
} }
if err := cache.Reload(st); err != nil { if err := cache.Reload(st); err != nil {
+27
View File
@@ -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
}
+236
View File
@@ -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
}
+33 -5
View File
@@ -42,10 +42,22 @@ func TestMQTTClientInfoFromClientUnsplitRemote(t *testing.T) {
} }
} }
func TestBlockingViolationForRecordNode(t *testing.T) { // 注:blockingViolationForRecord 的测试现在跟着 blockingCache 一起搬到了
cache := &blockingCache{nodes: map[string]struct{}{"!12345678": {}}, nodeNums: map[int64]struct{}{}, ips: map[string]struct{}{}} // internal/blocking/violations_test.go,使用真实 *Store 构造缓存而不是
record := map[string]any{"type": "position", "from": "!12345678", "from_num": uint32(305419896)} // 直接捏造未导出字段。这里保留 mqtt client info 这部分测试不动。
func TestBlockingViolationForRecordNode(t *testing.T) {
st := openTestStore(t)
defer st.Close()
nodeNum := int64(305419896)
if _, err := st.CreateNodeBlocking("!12345678", &nodeNum, "blocked", true); err != nil {
t.Fatalf("CreateNodeBlocking() error = %v", err)
}
cache, err := newBlockingCache(st)
if err != nil {
t.Fatalf("newBlockingCache() error = %v", err)
}
record := map[string]any{"type": "position", "from": "!12345678", "from_num": uint32(305419896)}
violation := blockingViolationForRecord(cache, record) violation := blockingViolationForRecord(cache, record)
if violation == nil || violation["blocking_type"] != "node" { if violation == nil || violation["blocking_type"] != "node" {
t.Fatalf("blockingViolationForRecord() = %#v, want node violation", violation) t.Fatalf("blockingViolationForRecord() = %#v, want node violation", violation)
@@ -53,7 +65,15 @@ func TestBlockingViolationForRecordNode(t *testing.T) {
} }
func TestBlockingViolationForRecordForbiddenWordFields(t *testing.T) { func TestBlockingViolationForRecordForbiddenWordFields(t *testing.T) {
cache := &blockingCache{nodes: map[string]struct{}{}, nodeNums: map[int64]struct{}{}, ips: map[string]struct{}{}, words: []forbiddenWordRule{{word: "spam", foldedWord: "spam", matchType: forbiddenWordMatchContains}}} st := openTestStore(t)
defer st.Close()
if _, err := st.CreateForbiddenWordBlocking("spam", "contains", false, "blocked", true); err != nil {
t.Fatalf("CreateForbiddenWordBlocking() error = %v", err)
}
cache, err := newBlockingCache(st)
if err != nil {
t.Fatalf("newBlockingCache() error = %v", err)
}
for _, tc := range []struct { for _, tc := range []struct {
name string name string
@@ -74,7 +94,15 @@ func TestBlockingViolationForRecordForbiddenWordFields(t *testing.T) {
} }
func TestBlockingViolationForRecordAllowed(t *testing.T) { func TestBlockingViolationForRecordAllowed(t *testing.T) {
cache := &blockingCache{nodes: map[string]struct{}{}, nodeNums: map[int64]struct{}{}, ips: map[string]struct{}{}, words: []forbiddenWordRule{{word: "spam", foldedWord: "spam", matchType: forbiddenWordMatchContains}}} st := openTestStore(t)
defer st.Close()
if _, err := st.CreateForbiddenWordBlocking("spam", "contains", false, "blocked", true); err != nil {
t.Fatalf("CreateForbiddenWordBlocking() error = %v", err)
}
cache, err := newBlockingCache(st)
if err != nil {
t.Fatalf("newBlockingCache() error = %v", err)
}
record := map[string]any{"type": "text_message", "from": "!1", "text": "hello"} record := map[string]any{"type": "text_message", "from": "!1", "text": "hello"}
if violation := blockingViolationForRecord(cache, record); violation != nil { if violation := blockingViolationForRecord(cache, record); violation != nil {
t.Fatalf("blockingViolationForRecord() = %#v, want nil", violation) t.Fatalf("blockingViolationForRecord() = %#v, want nil", violation)
+26
View File
@@ -0,0 +1,26 @@
package main
// 桥接到 internal/mqttforward — 让 main.go / web.go / mqtt_status.go 中
// 使用 mqttForwardManager / meshtasticMessageStats / mqttForwardReloader 等
// 旧名字的代码仍可工作。
import (
"github.com/gin-gonic/gin"
mfpkg "meshtastic_mqtt_server/internal/mqttforward"
)
type (
mqttForwardManager = mfpkg.Manager
mqttForwardReloader = mfpkg.Reloader
mqttForwardRuntimeStatus = mfpkg.RuntimeStatus
meshtasticMessageStats = mfpkg.Stats
)
func newMQTTForwardManager(s *store) *mqttForwardManager {
return mfpkg.NewManager(s)
}
func registerAdminMQTTForwardRoutes(r gin.IRouter, s *store, forwarder mqttForwardReloader) {
mfpkg.RegisterRoutes(r, s, forwarder)
}
+19
View File
@@ -0,0 +1,19 @@
package main
// 桥接到 internal/runtimesettings — 让根目录代码继续使用旧的小写名字。
import (
"github.com/gin-gonic/gin"
rspkg "meshtastic_mqtt_server/internal/runtimesettings"
)
type runtimeSettingsCache = rspkg.Cache
func newRuntimeSettingsCache(s *store) (*runtimeSettingsCache, error) {
return rspkg.New(s)
}
func registerAdminRuntimeSettingsRoutes(r gin.IRouter, s *store, c *runtimeSettingsCache) {
rspkg.RegisterRoutes(r, s, c)
}
-47
View File
@@ -1,47 +0,0 @@
package main
import (
"fmt"
"sync"
)
type runtimeSettingsCache struct {
mu sync.RWMutex
settings runtimeSettingsSnapshot
}
func newRuntimeSettingsCache(store *store) (*runtimeSettingsCache, error) {
cache := &runtimeSettingsCache{}
if err := cache.Reload(store); err != nil {
return nil, err
}
return cache, nil
}
func (c *runtimeSettingsCache) Reload(store *store) error {
if store == nil {
return fmt.Errorf("store is required")
}
settings, err := store.GetRuntimeSettings()
if err != nil {
return err
}
c.mu.Lock()
c.settings = settings
c.mu.Unlock()
return nil
}
func (c *runtimeSettingsCache) Snapshot() runtimeSettingsSnapshot {
if c == nil {
return runtimeSettingsSnapshot{}
}
c.mu.RLock()
defer c.mu.RUnlock()
return c.settings
}
func (c *runtimeSettingsCache) AllowEncryptedForwarding() bool {
return c.Snapshot().AllowEncryptedForwarding
}
+2 -2
View File
@@ -190,7 +190,7 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"})
return return
} }
cookie, err := sessions.newCookie(*user) cookie, err := sessions.NewCookie(*user)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -200,7 +200,7 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
c.JSON(http.StatusOK, gin.H{"user": adminUserResponse(*user)}) c.JSON(http.StatusOK, gin.H{"user": adminUserResponse(*user)})
}) })
r.POST("/logout", func(c *gin.Context) { r.POST("/logout", func(c *gin.Context) {
http.SetCookie(c.Writer, sessions.clearCookie()) http.SetCookie(c.Writer, sessions.ClearCookie())
c.JSON(http.StatusOK, gin.H{"status": "ok"}) c.JSON(http.StatusOK, gin.H{"status": "ok"})
}) })