安全加固: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
@@ -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