安全加固:LLM 会话按 (bot,peer) 隔离+历史上限50条,LLM 入队按 (bot,from_node) 限流(60s内5条),瓦片磁盘缓存单源配额(3000文件/300MB 按mtime淘汰),签到墙读接口限速+全站每日1000条封顶,敏感数据落盘 AES-256-GCM 加密(MESH_SECRET_KEY, 兼容明文迁移),admin 改密需验证当前密码+pwd_version 会话撤销,后端 v1.5.0

This commit is contained in:
2026-08-20 17:13:13 +08:00
parent f21e8337af
commit ba9be5b68b
24 changed files with 619 additions and 51 deletions
+20 -24
View File
@@ -53,35 +53,31 @@
## P2 - 中优先(迭代内)
- [ ] **T8 LLM 会话按 (bot, peer) 隔离**
- 位置:`internal/conversation/store.go:87-98`(`peerNodeID` 参数被忽略,所有 DM 对端共享上下文)
- 方案:`GetOrCreateForBot``(botID, peerNodeID)` 为键;历史消息数量设上限(如最近 50 条)
- 验收:不同 peer DM 得到独立会话;A 的注入不会影响 B 的回复
- [x] **T8 LLM 会话按 (bot, peer) 隔离(2026-08-20 完成)**
- `message.Conversation` 增加 `peer_node_id`;`GetOrCreateForBot` 按 (bot, peer) 匹配会话,不同对端互不可见上下文;`AddMessage` 历史上限 50 条;无 peer 调用维持旧行为
- 验收:不同 peer DM 得到独立会话;A 的注入不影响 B ✓(单测)
- [ ] **T9 LLM 入队限流/白名单**
- 位置:`internal/store/llm_store.go:545-578`;`internal/autoreply/service.go:29,178`
- 方案:按 from 节点维度限流(现仅有 bot 级 10 msg/5s);可选 allowlist 只响应已登记节点
- 验收:单一来源高频消息只消耗有限 LLM 调用
- [x] **T9 LLM 入队限流(2026-08-20 完成)**
- `EnqueueLLMMessage` 增加 (bot, from_node) 窗口限流:60 秒内 pending/processing 超过 5 条拒绝(ErrLLMQueueRateLimited);频道/私聊两条入队路径对限流静默跳过
- 验收:单一来源高频消息只消耗有限 LLM 调用 ✓
- [ ] **T10 瓦片磁盘缓存设上限**
- 位置:`internal/web/map_tile_proxy_routes.go:174-196`
- 方案:按 sourceHash 限制总字节数/文件数,超限 LRU 淘汰;每 IP 瓦片请求限速
- 验收:遍历坐标脚本无法使缓存目录超过配额
- [x] **T10 瓦片磁盘缓存配额(2026-08-20 完成)**
- 单源 3000 文件 / 300MB 双上限,每写 20 个文件触发一次按 mtime 淘汰最旧;防止匿名遍历坐标打满磁盘
- 验收:缓存目录超配额被收敛 ✓
- [ ] **T11 sign 强制触发的污染治理**
- 位置:`internal/toolrouter/loop.go:116-161,221-283`
- 方案:同一 from 节点每日限 1 条(后端已有?核实);`/api/signs` 增加频率限制与管理员删除
- 验收:伪造大量 node_num 刷签到无法批量入库公开墙
- [x] **T11 sign 污染治理(2026-08-20 完成)**
- 核实:每节点每日去重与 admin 删除已存在;补充:签到墙 `/api/signs``/api/signs/daily` 加 IP 限速(60 次/分钟);签到工具增加全站每日 1000 条总量封顶
- 验收:伪造节点刷签到受每日总量限制;公开墙读接口有限速 ✓
- [ ] **T12 敏感数据落盘加密**
- 位置:`internal/store/db.go:216-224`(forwarder 密码)、`:263`(bot 私钥)、`:488-498`(LLM api_key)
- 方案:AES-GCM 加密存储,主密钥来自环境变量 `MESH_SECRET_KEY`;API 层维持现有脱敏
- 验收:直接读 SQLite 文件无法得到可用明文密钥
- [x] **T12 敏感数据落盘加密(2026-08-20 完成)**
- 新增 `internal/secrets`(AES-256-GCM,密钥由 `MESH_SECRET_KEY` SHA-256 派生,enc:v1: 前缀,无密钥时明文兼容)
- 覆盖:LLM provider api_key、MQTT forwarder source/target 密码、bot X25519 私钥(在 store 写入点加密,读取点透明解密)
- 验收:DB 中为 enc:v1: 密文,明文不可见;旧明文数据可读;未配置密钥时行为不变 ✓(冒烟验证)
- [ ] **T13 admin 密码修改需验证自身当前密码 + session 可撤销**
- 位置:`internal/web/web.go:372-393`;`internal/auth/auth.go:89-149`
- 方案:改他人密码前要求请求方验证自己的密码;claims 增加 `pwd_ver`(密码 hash 版本号),改密后旧 cookie 全部失效
- 验收:改密后所有已登录会话返回 401
- [x] **T13 改需验证当前密码 + session 可撤销(2026-08-20 完成)**
- `PUT /api/admin/users/:id/password` 必须携带请求方自己的 `current_password`,错误返回 403;users 表新增 `pwd_version`,改密自增,session claims 携带 pwd_ver,中间件对照 DB,改密后所有旧会话立即 401
- 前端 AdminUsers 增加"当前登录密码"输入
- 验收:改密后旧会话 401、新密码可登录、旧密码不可 ✓(冒烟验证)
## P3 - 低优先(择机)
+13
View File
@@ -25,12 +25,16 @@ import (
type SignStore interface {
CreateSign(nodeID string, longName, shortName *string, signText string, signTime time.Time) (*storepkg.SignRecord, error)
HasSignedOnDay(nodeID string, day time.Time) (bool, error)
CountSignsOnDay(day time.Time) (int64, error)
GetNodeInfo(nodeID string) (*storepkg.NodeInfoRecord, error)
CountSigns(opts storepkg.ListOptions) (int64, error)
CountSignsByDay(opts storepkg.ListOptions) ([]storepkg.SignDayCount, error)
ListSigns(opts storepkg.ListOptions) ([]storepkg.SignRecord, error)
}
// maxSignsPerDay 全站每日签到记录总量封顶,防止伪造节点刷公开签到墙。
const maxSignsPerDay = 1000
// Tool 是签到工具。
type Tool struct {
enabled bool
@@ -164,6 +168,15 @@ func (t *Tool) executeSign(ctx context.Context, params signParams, runtime agent
return fmt.Sprintf("%s 今天已经签到过了,每个节点每天只能签到一次。", displayName(node)), nil
}
// 全站每日签到总量封顶,防止伪造海量节点号刷爆公开签到墙。
todayCount, err := t.store.CountSignsOnDay(now)
if err != nil {
return fmt.Sprintf("签到失败:统计今日签到数时出错:%v", err), nil
}
if todayCount >= maxSignsPerDay {
return "今日签到人数已达上限,请明天再来。", nil
}
signText := buildSignText(params)
if signText == "" {
// 结构化字段缺失时回退到用户原始文本
+11 -2
View File
@@ -43,6 +43,7 @@ type SessionClaims struct {
UserID uint64 `json:"user_id"`
Username string `json:"username"`
Role string `json:"role"`
PwdVer int64 `json:"pwd_ver"`
Expires int64 `json:"expires"`
}
@@ -87,7 +88,7 @@ func AdminUserResponse(user store.UserRecord) AdminUserDTO {
// 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()}
claims := SessionClaims{UserID: user.ID, Username: user.Username, Role: user.Role, PwdVer: user.PwdVersion, Expires: time.Now().Add(sm.ttl).Unix()}
data, err := json.Marshal(claims)
if err != nil {
return nil, err
@@ -155,7 +156,9 @@ func (sm *Manager) sign(payload string) string {
}
// RequireAdmin 是把校验结果挂在 c.Set(AdminClaimsKey, claims) 上的中间件。
func RequireAdmin(sm *Manager) gin.HandlerFunc {
// 除签名/过期校验外,还会对照 DB 中用户当前的 pwd_version:
// 改密后旧会话(claims.PwdVer 低于当前版本)立即失效。
func RequireAdmin(sm *Manager, store *store.Store) gin.HandlerFunc {
return func(c *gin.Context) {
claims, err := sm.ClaimsFromRequest(c)
if err != nil {
@@ -163,6 +166,12 @@ func RequireAdmin(sm *Manager) gin.HandlerFunc {
c.Abort()
return
}
user, err := store.GetUserByUsername(claims.Username)
if err != nil || user.Role != AdminRole || user.PwdVersion != claims.PwdVer {
c.JSON(http.StatusUnauthorized, gin.H{"error": "admin login required"})
c.Abort()
return
}
c.Set(AdminClaimsKey, claims)
c.Next()
}
+2 -1
View File
@@ -4,6 +4,7 @@ import (
"encoding/base64"
"strings"
"meshtastic_mqtt_server/internal/secrets"
storepkg "meshtastic_mqtt_server/internal/store"
)
@@ -23,7 +24,7 @@ func NewPKIKeyResolver(s *storepkg.Store) func(toNodeNum, fromNodeNum uint32) ([
if privateKeyB64 == "" {
return nil, nil, false
}
privateKey, err := base64.StdEncoding.DecodeString(privateKeyB64)
privateKey, err := base64.StdEncoding.DecodeString(secrets.Decrypt(privateKeyB64))
if err != nil || len(privateKey) != 32 {
return nil, nil, false
}
+3
View File
@@ -17,6 +17,7 @@ import (
storepkg "meshtastic_mqtt_server/internal/store"
"meshtastic_mqtt_server/internal/mqtpp"
"meshtastic_mqtt_server/internal/secrets"
)
const botMaxTextBytes = 200
@@ -116,6 +117,7 @@ func (s *Service) buildPKIAck(bot *storepkg.BotNodeRecord, toNum, ackPacketID, r
if privateKeyB64 == "" {
return nil, fmt.Errorf("bot has no private key")
}
privateKeyB64 = secrets.Decrypt(privateKeyB64)
privateKey, err := base64.StdEncoding.DecodeString(privateKeyB64)
if err != nil {
return nil, err
@@ -312,6 +314,7 @@ func (s *Service) sendPKIDirect(bot *storepkg.BotNodeRecord, fromNodeNum, toNode
if privateKeyB64 == "" {
return nil, fmt.Errorf("bot has no private key, regenerate keys first")
}
privateKeyB64 = secrets.Decrypt(privateKeyB64)
privateKey, err := base64.StdEncoding.DecodeString(privateKeyB64)
if err != nil {
return nil, fmt.Errorf("invalid bot private key: %w", err)
+31 -7
View File
@@ -84,17 +84,35 @@ func (s *Store) Get(id string) (*message.Conversation, error) {
return &conv, nil
}
// GetOrCreateForBot gets or creates a conversation for a bot
// GetOrCreateForBot gets or creates a conversation for a bot.
// peerNodeID 非空时按 (bot, peer) 隔离会话:不同对端互不可见上下文,
// 防止 A 的私聊内容/提示注入影响 B;peerNodeID 为空时维持旧行为(取最近会话)。
func (s *Store) GetOrCreateForBot(botID uint64, botNodeID string, peerNodeID string) (*message.Conversation, error) {
// Try to find an existing conversation with this peer
peerNodeID = strings.TrimSpace(peerNodeID)
convs, err := s.ListForBot(botID)
if err == nil && len(convs) > 0 {
// Use the most recent conversation (List already sorts by UpdatedAt desc)
// Note: List returns convs with Messages = nil, so we need to reload
return s.Get(convs[0].ID)
if peerNodeID != "" {
// List 按 UpdatedAt 倒序,取该对端最近一次会话。
for _, conv := range convs {
if conv.PeerNodeID == peerNodeID {
return s.Get(conv.ID)
}
}
} else {
return s.Get(convs[0].ID)
}
}
// Create a new conversation
return s.Create(botID, botNodeID)
conv, err := s.Create(botID, botNodeID)
if err != nil {
return nil, err
}
if peerNodeID != "" {
conv.PeerNodeID = peerNodeID
if err := s.Save(conv); err != nil {
return nil, err
}
}
return conv, nil
}
// List returns all conversations
@@ -167,6 +185,9 @@ func (s *Store) DeleteForBot(botID uint64) error {
return nil
}
// maxConversationMessages 限制单会话保留的历史消息数,防止上下文无限增长。
const maxConversationMessages = 50
// AddMessage adds a message to a conversation
func (s *Store) AddMessage(convID string, msg message.ChatMessage) error {
conv, err := s.Get(convID)
@@ -174,6 +195,9 @@ func (s *Store) AddMessage(convID string, msg message.ChatMessage) error {
return err
}
conv.Messages = append(conv.Messages, msg)
if len(conv.Messages) > maxConversationMessages {
conv.Messages = conv.Messages[len(conv.Messages)-maxConversationMessages:]
}
if conv.Title == "" || conv.Title == "新对话" {
conv.Title = GenerateTitle(conv.Messages)
}
+87
View File
@@ -0,0 +1,87 @@
package conversation
import (
"testing"
"meshtastic_mqtt_server/internal/message"
)
func newTestStore(t *testing.T) *Store {
t.Helper()
return NewStore(t.TempDir())
}
func TestGetOrCreateForBotPeerIsolation(t *testing.T) {
s := newTestStore(t)
convA, err := s.GetOrCreateForBot(1, "bot1", "nodeA")
if err != nil {
t.Fatal(err)
}
convB, err := s.GetOrCreateForBot(1, "bot1", "nodeB")
if err != nil {
t.Fatal(err)
}
if convA.ID == convB.ID {
t.Fatal("different peers must get different conversations")
}
if convA.PeerNodeID != "nodeA" || convB.PeerNodeID != "nodeB" {
t.Fatalf("peer ids not set: %q %q", convA.PeerNodeID, convB.PeerNodeID)
}
// 再次获取应命中同一会话(内容互不可见)。
convA2, err := s.GetOrCreateForBot(1, "bot1", "nodeA")
if err != nil {
t.Fatal(err)
}
if convA2.ID != convA.ID {
t.Fatalf("peer A should reuse its conversation, got %s want %s", convA2.ID, convA.ID)
}
// 不同 bot 的会话互不影响。
convOtherBot, err := s.GetOrCreateForBot(2, "bot2", "nodeA")
if err != nil {
t.Fatal(err)
}
if convOtherBot.ID == convA.ID {
t.Fatal("different bots must get different conversations")
}
}
func TestGetOrCreateForBotLegacyNoPeer(t *testing.T) {
s := newTestStore(t)
c1, err := s.GetOrCreateForBot(1, "bot1", "")
if err != nil {
t.Fatal(err)
}
c2, err := s.GetOrCreateForBot(1, "bot1", "")
if err != nil {
t.Fatal(err)
}
if c1.ID != c2.ID {
t.Fatal("no-peer callers should share the most recent conversation")
}
}
func TestAddMessageHistoryCap(t *testing.T) {
s := newTestStore(t)
conv, err := s.GetOrCreateForBot(1, "bot1", "nodeA")
if err != nil {
t.Fatal(err)
}
for i := 0; i < 120; i++ {
if err := s.AddMessage(conv.ID, message.ChatMessage{Role: "user", Content: "m"}); err != nil {
t.Fatal(err)
}
}
conv, err = s.Get(conv.ID)
if err != nil {
t.Fatal(err)
}
if len(conv.Messages) != maxConversationMessages {
t.Fatalf("history must be capped at %d, got %d", maxConversationMessages, len(conv.Messages))
}
if conv.Messages[0].Content != "m" || conv.Messages[len(conv.Messages)-1].Content != "m" {
t.Fatal("messages content intact")
}
}
+1
View File
@@ -16,6 +16,7 @@ type Conversation struct {
ID string `json:"id"`
BotID uint64 `json:"bot_id"`
BotNodeID string `json:"bot_node_id"`
PeerNodeID string `json:"peer_node_id,omitempty"`
Title string `json:"title"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
+22
View File
@@ -149,6 +149,28 @@ func (l *FailureLimiter) Fail(key string) bool {
return false
}
// Exceeded 记录一次请求,窗口内请求数超过 max 时返回 true(限速,不产生封禁)。
// 用于读接口的轻量频率限制。
func (l *FailureLimiter) Exceeded(key string) bool {
if key == "" {
return false
}
now := l.now()
l.mu.Lock()
defer l.mu.Unlock()
st, ok := l.fails[key]
if !ok || now.Sub(st.windowStart) > l.window {
st = &failState{windowStart: now, count: 1}
l.fails[key] = st
if len(l.fails) > l.maxEntries {
l.purgeLocked(now)
}
return false
}
st.count++
return st.count > l.max
}
// Reset 清除 key 的失败计数。
func (l *FailureLimiter) Reset(key string) {
if key == "" {
+24
View File
@@ -79,3 +79,27 @@ func TestEmptyKeyIgnored(t *testing.T) {
t.Fatal("empty key must not be tracked")
}
}
func TestExceeded(t *testing.T) {
l := newTestLimiter(t, Options{MaxFailures: 3})
now := time.Unix(1700000000, 0)
l.now = func() time.Time { return now }
for i := 0; i < 3; i++ {
if l.Exceeded("ip") {
t.Fatalf("request %d should be allowed", i+1)
}
}
if !l.Exceeded("ip") {
t.Fatal("4th request should be limited")
}
// 窗口过后恢复。
now = now.Add(2 * time.Minute)
if l.Exceeded("ip") {
t.Fatal("request after window should be allowed")
}
// 不同 key 互不影响。
if l.Exceeded("other") {
t.Fatal("other key must not be limited")
}
}
+105
View File
@@ -0,0 +1,105 @@
// Package secrets 提供敏感字段(API key、MQTT 口令、bot 私钥)落盘前的
// AES-256-GCM 加密与读取时的透明解密。
//
// 用法:启动时以环境变量 MESH_SECRET_KEY 初始化(SetSecretKey)。
// 未配置密钥时 Encrypt/Decrypt 均原样透传(兼容旧部署的明文数据);
// 加密值带 "enc:v1:" 前缀,解密时对无前缀的历史明文直接返回。
package secrets
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"strings"
"sync"
)
const prefix = "enc:v1:"
var (
mu sync.RWMutex
key []byte
)
// SetSecretKey 用 MESH_SECRET_KEY 的内容派生 AES-256 密钥;传空则回到明文模式。
func SetSecretKey(secret string) {
mu.Lock()
defer mu.Unlock()
secret = strings.TrimSpace(secret)
if secret == "" {
key = nil
return
}
sum := sha256.Sum256([]byte(secret))
key = sum[:]
}
// Enabled 报告是否已配置加密密钥。
func Enabled() bool {
mu.RLock()
defer mu.RUnlock()
return len(key) > 0
}
// Encrypt 加密明文;未配置密钥时原样返回。
func Encrypt(plain string) string {
if plain == "" {
return plain
}
mu.RLock()
k := key
mu.RUnlock()
if len(k) == 0 {
return plain
}
block, err := aes.NewCipher(k)
if err != nil {
return plain
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return plain
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return plain
}
sealed := gcm.Seal(nonce, nonce, []byte(plain), nil)
return prefix + base64.RawStdEncoding.EncodeToString(sealed)
}
// Decrypt 解密 enc:v1: 前缀的值;无前缀(历史明文)或解密失败返回空串。
func Decrypt(value string) string {
if !strings.HasPrefix(value, prefix) {
return value
}
mu.RLock()
k := key
mu.RUnlock()
if len(k) == 0 {
return value
}
raw, err := base64.RawStdEncoding.DecodeString(strings.TrimPrefix(value, prefix))
if err != nil {
return ""
}
block, err := aes.NewCipher(k)
if err != nil {
return ""
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return ""
}
if len(raw) < gcm.NonceSize() {
return ""
}
nonce, ciphertext := raw[:gcm.NonceSize()], raw[gcm.NonceSize():]
plain, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return ""
}
return string(plain)
}
+72
View File
@@ -0,0 +1,72 @@
package secrets
import (
"strings"
"testing"
)
func TestRoundTrip(t *testing.T) {
SetSecretKey("test-secret-key")
t.Cleanup(func() { SetSecretKey("") })
enc := Encrypt("s3cret-value")
if enc == "s3cret-value" {
t.Fatal("value must be encrypted when key is set")
}
if !strings.HasPrefix(enc, "enc:v1:") {
t.Fatalf("unexpected prefix: %q", enc)
}
if got := Decrypt(enc); got != "s3cret-value" {
t.Fatalf("round trip failed: %q", got)
}
}
func TestPlaintextPassthroughWhenNoKey(t *testing.T) {
SetSecretKey("")
if Encrypt("plain") != "plain" {
t.Error("must pass through when no key")
}
if Decrypt("plain") != "plain" {
t.Error("must pass through when no key")
}
}
func TestLegacyPlaintextStillReadable(t *testing.T) {
SetSecretKey("k1")
t.Cleanup(func() { SetSecretKey("") })
// 旧数据无前缀,解密应原样返回。
if Decrypt("legacy-plaintext") != "legacy-plaintext" {
t.Error("legacy plaintext must be returned as-is")
}
}
func TestWrongKeyFailsClosed(t *testing.T) {
SetSecretKey("key-a")
enc := Encrypt("top-secret")
SetSecretKey("key-b")
if Decrypt(enc) != "" {
t.Error("decrypt with wrong key must return empty (fail closed)")
}
SetSecretKey("key-a")
if Decrypt(enc) != "top-secret" {
t.Error("decrypt with correct key must succeed")
}
}
func TestEmptyValue(t *testing.T) {
SetSecretKey("k")
t.Cleanup(func() { SetSecretKey("") })
if Encrypt("") != "" {
t.Error("empty value stays empty")
}
}
func TestUniqueCiphertexts(t *testing.T) {
SetSecretKey("k")
t.Cleanup(func() { SetSecretKey("") })
a := Encrypt("same")
b := Encrypt("same")
if a == b {
t.Error("random nonce should produce different ciphertexts")
}
}
@@ -308,6 +308,10 @@ func insertInboundBotDirectMessage(s *Store, record map[string]any, clientInfo M
ContentJSON: contentPtr,
})
if err != nil {
if errors.Is(err, ErrLLMQueueRateLimited) {
// 限流拒绝是预期行为,静默跳过。
return nil
}
printJSON(map[string]any{
"event": "llm_queue_enqueue_failed",
"bot_id": bot.ID,
+3 -1
View File
@@ -12,6 +12,7 @@ import (
"unicode/utf8"
"meshtastic_mqtt_server/internal/mqtpp"
"meshtastic_mqtt_server/internal/secrets"
"gorm.io/gorm"
)
@@ -300,7 +301,8 @@ func populateBotNodeKeys(row *BotNodeRecord) error {
if err != nil {
return err
}
row.PrivateKey = base64.StdEncoding.EncodeToString(privateKey.Bytes())
// 私钥落盘前加密(MESH_SECRET_KEY 未配置时原样存储,兼容旧部署)。
row.PrivateKey = secrets.Encrypt(base64.StdEncoding.EncodeToString(privateKey.Bytes()))
row.PublicKey = base64.StdEncoding.EncodeToString(privateKey.PublicKey().Bytes())
return nil
}
+14
View File
@@ -65,6 +65,7 @@ type UserRecord struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
Username string `gorm:"column:username;not null;uniqueIndex"`
PasswordHash string `gorm:"column:password_hash;not null"`
PwdVersion int64 `gorm:"column:pwd_version;not null;default:0"`
Role string `gorm:"column:role;not null;index"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"`
@@ -909,6 +910,19 @@ type DBMigration struct {
var dbMigrations = []DBMigration{
{Version: 1, Up: migrateDB1},
{Version: 2, Up: migrateDB2},
{Version: 3, Up: migrateDB3},
}
func migrateDB3(tx *gorm.DB, driver string) error {
// users.pwd_version:改密后旧 session 立即失效的版本号,默认 0。
// 新库 CreateTable 时已带该列,此处幂等处理老库。
if tx.Migrator().HasColumn(&UserRecord{}, "pwd_version") {
return nil
}
if err := tx.Exec("ALTER TABLE users ADD COLUMN pwd_version INTEGER NOT NULL DEFAULT 0").Error; err != nil {
return fmt.Errorf("add users.pwd_version: %w", err)
}
return nil
}
func migrateDB1(tx *gorm.DB, driver string) error {
+49
View File
@@ -7,6 +7,8 @@ import (
"time"
"gorm.io/gorm"
"meshtastic_mqtt_server/internal/secrets"
)
// ============================================
@@ -23,6 +25,9 @@ func (s *Store) ListLLMProviders(includeInactive bool) ([]LLMProviderRecord, err
if err := query.Order("created_at DESC").Find(&rows).Error; err != nil {
return nil, fmt.Errorf("list llm providers: %w", err)
}
for i := range rows {
rows[i].APIKey = secrets.Decrypt(rows[i].APIKey)
}
return rows, nil
}
@@ -35,11 +40,13 @@ func (s *Store) GetLLMProvider(name string) (*LLMProviderRecord, error) {
}
return nil, fmt.Errorf("get llm provider %s: %w", name, err)
}
record.APIKey = secrets.Decrypt(record.APIKey)
return &record, nil
}
// CreateLLMProvider 创建 LLM Provider
func (s *Store) CreateLLMProvider(record *LLMProviderRecord) error {
record.APIKey = secrets.Encrypt(record.APIKey)
if err := s.db.Create(record).Error; err != nil {
return fmt.Errorf("create llm provider %s: %w", record.Name, err)
}
@@ -48,6 +55,11 @@ func (s *Store) CreateLLMProvider(record *LLMProviderRecord) error {
// UpdateLLMProvider 更新 LLM Provider
func (s *Store) UpdateLLMProvider(name string, updates map[string]any) error {
if v, ok := updates["api_key"]; ok {
if s, ok := v.(string); ok {
updates["api_key"] = secrets.Encrypt(s)
}
}
if err := s.db.Model(&LLMProviderRecord{}).Where("name = ?", name).Updates(updates).Error; err != nil {
return fmt.Errorf("update llm provider %s: %w", name, err)
}
@@ -289,6 +301,29 @@ type LLMMessageQueueInput struct {
ContentJSON *string
}
// ErrLLMQueueRateLimited 表示 (bot, from_node) 在窗口期内入队超限被拒绝。
var ErrLLMQueueRateLimited = errors.New("llm queue rate limited")
const (
// llmQueuePerNodeWindow 是单 (bot, from_node) 的入队限流窗口。
llmQueuePerNodeWindow = time.Minute
// llmQueuePerNodeMax 是窗口期内允许的 pending/processing 消息上限。
llmQueuePerNodeMax = 5
)
// isLLMQueueRateLimited 统计窗口内 (bot, from_node) 的未消费消息数是否达上限。
func (s *Store) isLLMQueueRateLimited(botID uint64, fromNodeID string) (bool, error) {
var count int64
err := s.db.Model(&LLMMessageQueueRecord{}).
Where("bot_id = ? AND from_node_id = ? AND status IN (?, ?) AND received_at > ? AND deleted_at IS NULL",
botID, fromNodeID, LLMMessageStatusPending, LLMMessageStatusProcessing, time.Now().Add(-llmQueuePerNodeWindow)).
Count(&count).Error
if err != nil {
return false, err
}
return count >= llmQueuePerNodeMax, nil
}
// EnqueueLLMMessage 将消息添加到 LLM 队列
func (s *Store) EnqueueLLMMessage(input LLMMessageQueueInput) (*LLMMessageQueueRecord, error) {
var err error
@@ -348,6 +383,16 @@ func (s *Store) EnqueueLLMMessage(input LLMMessageQueueInput) (*LLMMessageQueueR
return nil, fmt.Errorf("check duplicate llm message: %w", err)
}
// (bot, from_node) 维度入队限流:窗口内 pending/processing 超过上限即拒绝,
// 防止未认证 mesh 用户高频消息烧光 LLM 配额。
limited, err := s.isLLMQueueRateLimited(input.BotID, input.FromNodeID)
if err != nil {
return nil, fmt.Errorf("check llm queue rate limit: %w", err)
}
if limited {
return nil, ErrLLMQueueRateLimited
}
now := time.Now()
messageType := input.MessageType
if messageType == "" {
@@ -567,6 +612,10 @@ func enqueueChannelMessageToLLM(s *Store, record map[string]any) error {
ContentJSON: contentPtr,
})
if err != nil {
if errors.Is(err, ErrLLMQueueRateLimited) {
// 限流拒绝是预期行为,静默跳过。
continue
}
printJSON(map[string]any{
"event": "llm_queue_enqueue_failed",
"bot_id": bot.ID,
+14 -3
View File
@@ -7,6 +7,8 @@ import (
"time"
"gorm.io/gorm"
"meshtastic_mqtt_server/internal/secrets"
)
const (
@@ -59,7 +61,14 @@ func (s *Store) ListMQTTForwarders(opts ListOptions) ([]MQTTForwarderRecord, err
Order("id DESC").
Limit(opts.Limit).
Offset(opts.Offset)
return rows, q.Find(&rows).Error
if err := q.Find(&rows).Error; err != nil {
return nil, err
}
for i := range rows {
rows[i].SourcePassword = secrets.Decrypt(rows[i].SourcePassword)
rows[i].TargetPassword = secrets.Decrypt(rows[i].TargetPassword)
}
return rows, nil
}
func (s *Store) CountMQTTForwarders(opts ListOptions) (int64, error) {
@@ -72,6 +81,8 @@ func (s *Store) GetMQTTForwarder(id uint64) (*MQTTForwarderRecord, error) {
if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil {
return nil, err
}
row.SourcePassword = secrets.Decrypt(row.SourcePassword)
row.TargetPassword = secrets.Decrypt(row.TargetPassword)
return &row, nil
}
@@ -304,12 +315,12 @@ func mqttForwarderFromInput(input MQTTForwarderInput, existing *MQTTForwarderRec
TargetHost: targetHost, TargetPort: input.TargetPort, TargetUsername: strings.TrimSpace(input.TargetUsername), TargetClientID: strings.TrimSpace(input.TargetClientID), TargetTLS: input.TargetTLS,
}
if input.SourcePassword != nil {
row.SourcePassword = *input.SourcePassword
row.SourcePassword = secrets.Encrypt(*input.SourcePassword)
} else if existing != nil {
row.SourcePassword = existing.SourcePassword
}
if input.TargetPassword != nil {
row.TargetPassword = *input.TargetPassword
row.TargetPassword = secrets.Encrypt(*input.TargetPassword)
} else if existing != nil {
row.TargetPassword = existing.TargetPassword
}
+17
View File
@@ -67,6 +67,23 @@ func (s *Store) HasSignedOnDay(nodeID string, day time.Time) (bool, error) {
return count > 0, nil
}
// CountSignsOnDay 统计某自然日(本地时区)的签到记录总数,用于全站每日总量封顶。
func (s *Store) CountSignsOnDay(day time.Time) (int64, error) {
loc := day.Location()
if loc == nil {
loc = time.Local
}
start := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, loc)
end := start.AddDate(0, 0, 1)
var count int64
if err := s.db.Model(&SignRecord{}).
Where("sign_time >= ? AND sign_time < ?", start, end).
Count(&count).Error; err != nil {
return 0, fmt.Errorf("count sign on day: %w", err)
}
return count, nil
}
func (s *Store) GetSignByID(id uint64) (*SignRecord, error) {
var row SignRecord
if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil {
+6 -1
View File
@@ -71,7 +71,12 @@ func (s *Store) UpdateUserPassword(id uint64, password string) (*UserRecord, err
if err != nil {
return nil, fmt.Errorf("hash user password: %w", err)
}
if err := s.db.Model(&UserRecord{}).Where("id = ?", id).Updates(map[string]any{"password_hash": hash, "updated_at": time.Now()}).Error; err != nil {
// 改密同时递增 pwd_version,使该用户所有已签发 session 立即失效。
if err := s.db.Model(&UserRecord{}).Where("id = ?", id).Updates(map[string]any{
"password_hash": hash,
"pwd_version": gorm.Expr("pwd_version + 1"),
"updated_at": time.Now(),
}).Error; err != nil {
return nil, err
}
user.PasswordHash = hash
+74 -6
View File
@@ -10,8 +10,10 @@ import (
"net/netip"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
@@ -24,19 +26,27 @@ const (
mapTileCacheControl = "public, max-age=86400"
maxMapTileBytes = 10 << 20
maxMapTileRedirects = 2
// 单个地图源瓦片缓存配额:文件数与总字节双上限,超出后按 mtime 淘汰最旧文件。
mapTileCacheMaxFilesPerSource = 3000
mapTileCacheMaxBytesPerSource = 300 << 20
// 每写这么多文件触发一次清理采样。
mapTileCachePruneEvery = 20
)
type mapTileProxy struct {
store *storepkg.Store
cacheDir string
client *http.Client
store *storepkg.Store
cacheDir string
client *http.Client
mu sync.Mutex
writeCounts map[string]int64
}
func registerMapTileProxyRoutes(r gin.IRouter, store *storepkg.Store, cacheDir string) {
proxy := &mapTileProxy{
store: store,
cacheDir: cacheDir,
client: newMapTileHTTPClient(),
store: store,
cacheDir: cacheDir,
client: newMapTileHTTPClient(),
writeCounts: make(map[string]int64),
}
r.GET("/map/:sourceHash", proxy.handle)
}
@@ -154,9 +164,67 @@ func (p *mapTileProxy) handle(c *gin.Context) {
return
}
_ = writeMapTileCacheFile(cachePath, data)
p.maybePruneTileCache(sourceHash)
writeMapTile(c, data)
}
// maybePruneTileCache 按采样频率对单个瓦片源做配额清理:
// 文件数或总字节超限时删除 mtime 最旧的瓦片,防止匿名请求打满磁盘。
func (p *mapTileProxy) maybePruneTileCache(sourceHash string) {
p.mu.Lock()
p.writeCounts[sourceHash]++
n := p.writeCounts[sourceHash]
p.mu.Unlock()
if n%mapTileCachePruneEvery != 0 {
return
}
pruneMapTileCache(filepath.Join(p.cacheDir, sourceHash), mapTileCacheMaxFilesPerSource, mapTileCacheMaxBytesPerSource)
}
// pruneMapTileCache 扫描目录下所有 .tile 文件,超限时按 mtime 升序删除最旧文件。
func pruneMapTileCache(dir string, maxFiles int, maxBytes int64) {
var files []tileCacheEntry
var totalBytes int64
err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
if !strings.HasSuffix(path, ".tile") {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
files = append(files, tileCacheEntry{path: path, size: info.Size(), mtime: info.ModTime()})
totalBytes += info.Size()
return nil
})
if err != nil || len(files) == 0 {
return
}
if len(files) <= maxFiles && totalBytes <= maxBytes {
return
}
sort.Slice(files, func(i, j int) bool { return files[i].mtime.Before(files[j].mtime) })
deleted := 0
for _, f := range files {
if len(files)-deleted <= maxFiles && totalBytes <= maxBytes {
break
}
if os.Remove(f.path) == nil {
deleted++
totalBytes -= f.size
}
}
}
type tileCacheEntry struct {
path string
size int64
mtime time.Time
}
func (p *mapTileProxy) fetchRemoteTile(req *http.Request, template string, tile mapTileCoordinates) ([]byte, int, error) {
remoteURL := expandMapTileURLTemplate(template, tile)
upstreamReq, err := http.NewRequestWithContext(req.Context(), http.MethodGet, remoteURL, nil)
+25 -3
View File
@@ -85,7 +85,7 @@ func NewRouter(cfg configpkg.WebConfig, consoleLog bool, store *storepkg.Store,
return r
}
const BackendVersion = "1.4.0"
const BackendVersion = "1.5.0"
var CommitVersion = "dev"
@@ -111,7 +111,13 @@ func registerAPIRoutes(r gin.IRouter, store *storepkg.Store, mapTileCacheDir str
mappkg.RegisterPublicRoutes(r, store)
registerMapTileProxyRoutes(r, store, mapTileCacheDir)
helppkg.RegisterPublicRoutes(r, store)
// 公开签到墙限速:60 次/分钟/IP,防遍历拉取。
signsLimiter := ratelimit.New(ratelimit.Options{MaxFailures: 60, Window: time.Minute})
r.GET("/signs", func(c *gin.Context) {
if signsLimiter.Exceeded(c.ClientIP()) {
c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many requests"})
return
}
opts, ok := parseListOptions(c)
if !ok {
return
@@ -125,6 +131,10 @@ func registerAPIRoutes(r gin.IRouter, store *storepkg.Store, mapTileCacheDir str
writeListResponseWithTotal(c, rows, opts, total, err, signpkg.SignDTO)
})
r.GET("/signs/daily", func(c *gin.Context) {
if signsLimiter.Exceeded(c.ClientIP()) {
c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many requests"})
return
}
opts, ok := parseListOptions(c)
if !ok {
return
@@ -207,7 +217,8 @@ func registerAdminRoutes(r gin.IRouter, store *storepkg.Store, sessions *auth.Ma
Password string `json:"password"`
}
type updatePasswordRequest struct {
Password string `json:"password"`
Password string `json:"password"`
CurrentPassword string `json:"current_password"`
}
userDTO := func(user storepkg.UserRecord) gin.H {
return gin.H{"id": user.ID, "username": user.Username, "role": user.Role, "created_at": user.CreatedAt, "updated_at": user.UpdatedAt}
@@ -280,7 +291,7 @@ func registerAdminRoutes(r gin.IRouter, store *storepkg.Store, sessions *auth.Ma
})
protected := r.Group("")
protected.Use(auth.RequireAdmin(sessions))
protected.Use(auth.RequireAdmin(sessions, store))
blockingpkg.RegisterRoutes(protected, store, blocking)
signpkg.RegisterAdminRoutes(protected, store)
mqttforwardpkg.RegisterRoutes(protected, store, forwarder)
@@ -408,6 +419,17 @@ func registerAdminRoutes(r gin.IRouter, store *storepkg.Store, sessions *auth.Ma
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid password request"})
return
}
// 修改任何用户的密码都必须验证请求方自己的当前密码,防止已登录会话被冒用改密。
claims := c.MustGet(auth.AdminClaimsKey).(*auth.SessionClaims)
requester, err := store.GetUserByUsername(claims.Username)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "admin login required"})
return
}
if !auth.VerifyPassword(requester.PasswordHash, req.CurrentPassword) {
c.JSON(http.StatusForbidden, gin.H{"error": "current password is incorrect"})
return
}
user, err := store.UpdateUserPassword(id, req.Password)
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
+3
View File
@@ -32,6 +32,7 @@ import (
"meshtastic_mqtt_server/internal/mqtpp"
mqttforwardpkg "meshtastic_mqtt_server/internal/mqttforward"
rspkg "meshtastic_mqtt_server/internal/runtimesettings"
"meshtastic_mqtt_server/internal/secrets"
storepkg "meshtastic_mqtt_server/internal/store"
webpkg "meshtastic_mqtt_server/internal/web"
)
@@ -345,6 +346,8 @@ func parseArgs() (*configpkg.Config, error) {
if value := os.Getenv("MESH_ADMIN_SESSION_SECRET"); value != "" {
cfg.Web.Admin.SessionSecret = value
}
// 敏感数据落盘加密密钥(MESH_SECRET_KEY);未设置时保持明文兼容。
secrets.SetSecretKey(os.Getenv("MESH_SECRET_KEY"))
configpkg.ClearWebSocketPathOnUnsupportedGOOS(cfg, runtime.GOOS)
if err := configpkg.Validate(cfg); err != nil {
+2 -2
View File
@@ -307,8 +307,8 @@ export function createAdminUser(username: string, password: string): Promise<Adm
return postJSON<AdminManagedUserResponse>('/api/admin/users', { username, password })
}
export function updateAdminUserPassword(id: number, password: string): Promise<AdminManagedUserResponse> {
return putJSON<AdminManagedUserResponse>(`/api/admin/users/${id}/password`, { password })
export function updateAdminUserPassword(id: number, password: string, currentPassword: string): Promise<AdminManagedUserResponse> {
return putJSON<AdminManagedUserResponse>(`/api/admin/users/${id}/password`, { password, current_password: currentPassword })
}
export function getAdminLoginLogs(limit = 100, offset = 0): Promise<AdminLoginLogsResponse> {
+17 -1
View File
@@ -16,6 +16,7 @@ const newPassword = ref('')
const confirmPassword = ref('')
const passwordEdits = ref<Record<number, string>>({})
const passwordSaving = ref<Record<number, boolean>>({})
const currentPassword = ref('')
function formatTime(value: string): string {
return new Date(value).toLocaleString()
@@ -73,11 +74,16 @@ async function updatePassword(user: AdminManagedUser) {
userError.value = '新密码不能为空'
return
}
if (!currentPassword.value) {
userError.value = '请输入当前登录密码'
return
}
passwordSaving.value = { ...passwordSaving.value, [user.id]: true }
try {
await updateAdminUserPassword(user.id, password)
await updateAdminUserPassword(user.id, password, currentPassword.value)
passwordEdits.value = { ...passwordEdits.value, [user.id]: '' }
currentPassword.value = ''
userMessage.value = `${user.username} 的密码已修改`
await refreshUsers()
} catch (err) {
@@ -129,6 +135,7 @@ onMounted(refreshUsers)
<th class="cell-nowrap">创建时间</th>
<th class="cell-nowrap">更新时间</th>
<th>新密码</th>
<th class="cell-nowrap">当前登录密码</th>
<th class="cell-nowrap">操作</th>
</tr>
</thead>
@@ -148,6 +155,15 @@ onMounted(refreshUsers)
placeholder="输入新密码"
/>
</td>
<td class="cell-nowrap">
<input
v-model="currentPassword"
class="admin-table-input"
type="password"
autocomplete="current-password"
placeholder="当前登录密码(验证用)"
/>
</td>
<td class="cell-nowrap">
<button class="admin-button" :disabled="passwordSaving[managedUser.id]" @click="updatePassword(managedUser)">
{{ passwordSaving[managedUser.id] ? '保存中...' : '修改密码' }}