forked from kevin/meshtastic_mqtt_server
安全加固: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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user