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:
+20
-24
@@ -53,35 +53,31 @@
|
|||||||
|
|
||||||
## P2 - 中优先(迭代内)
|
## P2 - 中优先(迭代内)
|
||||||
|
|
||||||
- [ ] **T8 LLM 会话按 (bot, peer) 隔离**
|
- [x] **T8 LLM 会话按 (bot, peer) 隔离(2026-08-20 完成)**
|
||||||
- 位置:`internal/conversation/store.go:87-98`(`peerNodeID` 参数被忽略,所有 DM 对端共享上下文)
|
- `message.Conversation` 增加 `peer_node_id`;`GetOrCreateForBot` 按 (bot, peer) 匹配会话,不同对端互不可见上下文;`AddMessage` 历史上限 50 条;无 peer 调用维持旧行为
|
||||||
- 方案:`GetOrCreateForBot` 以 `(botID, peerNodeID)` 为键;历史消息数量设上限(如最近 50 条)
|
- 验收:不同 peer DM 得到独立会话;A 的注入不影响 B ✓(单测)
|
||||||
- 验收:不同 peer DM 得到独立会话;A 的注入不会影响 B 的回复
|
|
||||||
|
|
||||||
- [ ] **T9 LLM 入队限流/白名单**
|
- [x] **T9 LLM 入队限流(2026-08-20 完成)**
|
||||||
- 位置:`internal/store/llm_store.go:545-578`;`internal/autoreply/service.go:29,178`
|
- `EnqueueLLMMessage` 增加 (bot, from_node) 窗口限流:60 秒内 pending/processing 超过 5 条拒绝(ErrLLMQueueRateLimited);频道/私聊两条入队路径对限流静默跳过
|
||||||
- 方案:按 from 节点维度限流(现仅有 bot 级 10 msg/5s);可选 allowlist 只响应已登记节点
|
- 验收:单一来源高频消息只消耗有限 LLM 调用 ✓
|
||||||
- 验收:单一来源高频消息只消耗有限 LLM 调用
|
|
||||||
|
|
||||||
- [ ] **T10 瓦片磁盘缓存设上限**
|
- [x] **T10 瓦片磁盘缓存配额(2026-08-20 完成)**
|
||||||
- 位置:`internal/web/map_tile_proxy_routes.go:174-196`
|
- 单源 3000 文件 / 300MB 双上限,每写 20 个文件触发一次按 mtime 淘汰最旧;防止匿名遍历坐标打满磁盘
|
||||||
- 方案:按 sourceHash 限制总字节数/文件数,超限 LRU 淘汰;每 IP 瓦片请求限速
|
- 验收:缓存目录超配额被收敛 ✓
|
||||||
- 验收:遍历坐标脚本无法使缓存目录超过配额
|
|
||||||
|
|
||||||
- [ ] **T11 sign 强制触发的污染治理**
|
- [x] **T11 sign 污染治理(2026-08-20 完成)**
|
||||||
- 位置:`internal/toolrouter/loop.go:116-161,221-283`
|
- 核实:每节点每日去重与 admin 删除已存在;补充:签到墙 `/api/signs`、`/api/signs/daily` 加 IP 限速(60 次/分钟);签到工具增加全站每日 1000 条总量封顶
|
||||||
- 方案:同一 from 节点每日限 1 条(后端已有?核实);`/api/signs` 增加频率限制与管理员删除
|
- 验收:伪造节点刷签到受每日总量限制;公开墙读接口有限速 ✓
|
||||||
- 验收:伪造大量 node_num 刷签到无法批量入库公开墙
|
|
||||||
|
|
||||||
- [ ] **T12 敏感数据落盘加密**
|
- [x] **T12 敏感数据落盘加密(2026-08-20 完成)**
|
||||||
- 位置:`internal/store/db.go:216-224`(forwarder 密码)、`:263`(bot 私钥)、`:488-498`(LLM api_key)
|
- 新增 `internal/secrets`(AES-256-GCM,密钥由 `MESH_SECRET_KEY` SHA-256 派生,enc:v1: 前缀,无密钥时明文兼容)
|
||||||
- 方案:AES-GCM 加密存储,主密钥来自环境变量 `MESH_SECRET_KEY`;API 层维持现有脱敏
|
- 覆盖:LLM provider api_key、MQTT forwarder source/target 密码、bot X25519 私钥(在 store 写入点加密,读取点透明解密)
|
||||||
- 验收:直接读 SQLite 文件无法得到可用明文密钥
|
- 验收:DB 中为 enc:v1: 密文,明文不可见;旧明文数据可读;未配置密钥时行为不变 ✓(冒烟验证)
|
||||||
|
|
||||||
- [ ] **T13 admin 密码修改需验证自身当前密码 + session 可撤销**
|
- [x] **T13 改密需验证当前密码 + session 可撤销(2026-08-20 完成)**
|
||||||
- 位置:`internal/web/web.go:372-393`;`internal/auth/auth.go:89-149`
|
- `PUT /api/admin/users/:id/password` 必须携带请求方自己的 `current_password`,错误返回 403;users 表新增 `pwd_version`,改密自增,session claims 携带 pwd_ver,中间件对照 DB,改密后所有旧会话立即 401
|
||||||
- 方案:改他人密码前要求请求方验证自己的密码;claims 增加 `pwd_ver`(密码 hash 版本号),改密后旧 cookie 全部失效
|
- 前端 AdminUsers 增加"当前登录密码"输入
|
||||||
- 验收:改密后所有已登录会话返回 401
|
- 验收:改密后旧会话 401、新密码可登录、旧密码不可 ✓(冒烟验证)
|
||||||
|
|
||||||
## P3 - 低优先(择机)
|
## P3 - 低优先(择机)
|
||||||
|
|
||||||
|
|||||||
@@ -25,12 +25,16 @@ import (
|
|||||||
type SignStore interface {
|
type SignStore interface {
|
||||||
CreateSign(nodeID string, longName, shortName *string, signText string, signTime time.Time) (*storepkg.SignRecord, error)
|
CreateSign(nodeID string, longName, shortName *string, signText string, signTime time.Time) (*storepkg.SignRecord, error)
|
||||||
HasSignedOnDay(nodeID string, day time.Time) (bool, error)
|
HasSignedOnDay(nodeID string, day time.Time) (bool, error)
|
||||||
|
CountSignsOnDay(day time.Time) (int64, error)
|
||||||
GetNodeInfo(nodeID string) (*storepkg.NodeInfoRecord, error)
|
GetNodeInfo(nodeID string) (*storepkg.NodeInfoRecord, error)
|
||||||
CountSigns(opts storepkg.ListOptions) (int64, error)
|
CountSigns(opts storepkg.ListOptions) (int64, error)
|
||||||
CountSignsByDay(opts storepkg.ListOptions) ([]storepkg.SignDayCount, error)
|
CountSignsByDay(opts storepkg.ListOptions) ([]storepkg.SignDayCount, error)
|
||||||
ListSigns(opts storepkg.ListOptions) ([]storepkg.SignRecord, error)
|
ListSigns(opts storepkg.ListOptions) ([]storepkg.SignRecord, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxSignsPerDay 全站每日签到记录总量封顶,防止伪造节点刷公开签到墙。
|
||||||
|
const maxSignsPerDay = 1000
|
||||||
|
|
||||||
// Tool 是签到工具。
|
// Tool 是签到工具。
|
||||||
type Tool struct {
|
type Tool struct {
|
||||||
enabled bool
|
enabled bool
|
||||||
@@ -164,6 +168,15 @@ func (t *Tool) executeSign(ctx context.Context, params signParams, runtime agent
|
|||||||
return fmt.Sprintf("%s 今天已经签到过了,每个节点每天只能签到一次。", displayName(node)), nil
|
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)
|
signText := buildSignText(params)
|
||||||
if signText == "" {
|
if signText == "" {
|
||||||
// 结构化字段缺失时回退到用户原始文本
|
// 结构化字段缺失时回退到用户原始文本
|
||||||
|
|||||||
+11
-2
@@ -43,6 +43,7 @@ type SessionClaims struct {
|
|||||||
UserID uint64 `json:"user_id"`
|
UserID uint64 `json:"user_id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
|
PwdVer int64 `json:"pwd_ver"`
|
||||||
Expires int64 `json:"expires"`
|
Expires int64 `json:"expires"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +88,7 @@ func AdminUserResponse(user store.UserRecord) AdminUserDTO {
|
|||||||
|
|
||||||
// NewCookie 为已登录用户构造一份带签名的 session cookie。
|
// NewCookie 为已登录用户构造一份带签名的 session cookie。
|
||||||
func (sm *Manager) NewCookie(user store.UserRecord) (*http.Cookie, error) {
|
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)
|
data, err := json.Marshal(claims)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -155,7 +156,9 @@ func (sm *Manager) sign(payload string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RequireAdmin 是把校验结果挂在 c.Set(AdminClaimsKey, claims) 上的中间件。
|
// 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) {
|
return func(c *gin.Context) {
|
||||||
claims, err := sm.ClaimsFromRequest(c)
|
claims, err := sm.ClaimsFromRequest(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -163,6 +166,12 @@ func RequireAdmin(sm *Manager) gin.HandlerFunc {
|
|||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
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.Set(AdminClaimsKey, claims)
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"meshtastic_mqtt_server/internal/secrets"
|
||||||
storepkg "meshtastic_mqtt_server/internal/store"
|
storepkg "meshtastic_mqtt_server/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,7 +24,7 @@ func NewPKIKeyResolver(s *storepkg.Store) func(toNodeNum, fromNodeNum uint32) ([
|
|||||||
if privateKeyB64 == "" {
|
if privateKeyB64 == "" {
|
||||||
return nil, nil, false
|
return nil, nil, false
|
||||||
}
|
}
|
||||||
privateKey, err := base64.StdEncoding.DecodeString(privateKeyB64)
|
privateKey, err := base64.StdEncoding.DecodeString(secrets.Decrypt(privateKeyB64))
|
||||||
if err != nil || len(privateKey) != 32 {
|
if err != nil || len(privateKey) != 32 {
|
||||||
return nil, nil, false
|
return nil, nil, false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
|
|
||||||
storepkg "meshtastic_mqtt_server/internal/store"
|
storepkg "meshtastic_mqtt_server/internal/store"
|
||||||
"meshtastic_mqtt_server/internal/mqtpp"
|
"meshtastic_mqtt_server/internal/mqtpp"
|
||||||
|
"meshtastic_mqtt_server/internal/secrets"
|
||||||
)
|
)
|
||||||
|
|
||||||
const botMaxTextBytes = 200
|
const botMaxTextBytes = 200
|
||||||
@@ -116,6 +117,7 @@ func (s *Service) buildPKIAck(bot *storepkg.BotNodeRecord, toNum, ackPacketID, r
|
|||||||
if privateKeyB64 == "" {
|
if privateKeyB64 == "" {
|
||||||
return nil, fmt.Errorf("bot has no private key")
|
return nil, fmt.Errorf("bot has no private key")
|
||||||
}
|
}
|
||||||
|
privateKeyB64 = secrets.Decrypt(privateKeyB64)
|
||||||
privateKey, err := base64.StdEncoding.DecodeString(privateKeyB64)
|
privateKey, err := base64.StdEncoding.DecodeString(privateKeyB64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -312,6 +314,7 @@ func (s *Service) sendPKIDirect(bot *storepkg.BotNodeRecord, fromNodeNum, toNode
|
|||||||
if privateKeyB64 == "" {
|
if privateKeyB64 == "" {
|
||||||
return nil, fmt.Errorf("bot has no private key, regenerate keys first")
|
return nil, fmt.Errorf("bot has no private key, regenerate keys first")
|
||||||
}
|
}
|
||||||
|
privateKeyB64 = secrets.Decrypt(privateKeyB64)
|
||||||
privateKey, err := base64.StdEncoding.DecodeString(privateKeyB64)
|
privateKey, err := base64.StdEncoding.DecodeString(privateKeyB64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid bot private key: %w", err)
|
return nil, fmt.Errorf("invalid bot private key: %w", err)
|
||||||
|
|||||||
@@ -84,17 +84,35 @@ func (s *Store) Get(id string) (*message.Conversation, error) {
|
|||||||
return &conv, nil
|
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) {
|
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)
|
convs, err := s.ListForBot(botID)
|
||||||
if err == nil && len(convs) > 0 {
|
if err == nil && len(convs) > 0 {
|
||||||
// Use the most recent conversation (List already sorts by UpdatedAt desc)
|
if peerNodeID != "" {
|
||||||
// Note: List returns convs with Messages = nil, so we need to reload
|
// List 按 UpdatedAt 倒序,取该对端最近一次会话。
|
||||||
return s.Get(convs[0].ID)
|
for _, conv := range convs {
|
||||||
|
if conv.PeerNodeID == peerNodeID {
|
||||||
|
return s.Get(conv.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return s.Get(convs[0].ID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Create a new conversation
|
conv, err := s.Create(botID, botNodeID)
|
||||||
return 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
|
// List returns all conversations
|
||||||
@@ -167,6 +185,9 @@ func (s *Store) DeleteForBot(botID uint64) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxConversationMessages 限制单会话保留的历史消息数,防止上下文无限增长。
|
||||||
|
const maxConversationMessages = 50
|
||||||
|
|
||||||
// AddMessage adds a message to a conversation
|
// AddMessage adds a message to a conversation
|
||||||
func (s *Store) AddMessage(convID string, msg message.ChatMessage) error {
|
func (s *Store) AddMessage(convID string, msg message.ChatMessage) error {
|
||||||
conv, err := s.Get(convID)
|
conv, err := s.Get(convID)
|
||||||
@@ -174,6 +195,9 @@ func (s *Store) AddMessage(convID string, msg message.ChatMessage) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
conv.Messages = append(conv.Messages, msg)
|
conv.Messages = append(conv.Messages, msg)
|
||||||
|
if len(conv.Messages) > maxConversationMessages {
|
||||||
|
conv.Messages = conv.Messages[len(conv.Messages)-maxConversationMessages:]
|
||||||
|
}
|
||||||
if conv.Title == "" || conv.Title == "新对话" {
|
if conv.Title == "" || conv.Title == "新对话" {
|
||||||
conv.Title = GenerateTitle(conv.Messages)
|
conv.Title = GenerateTitle(conv.Messages)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ type Conversation struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
BotID uint64 `json:"bot_id"`
|
BotID uint64 `json:"bot_id"`
|
||||||
BotNodeID string `json:"bot_node_id"`
|
BotNodeID string `json:"bot_node_id"`
|
||||||
|
PeerNodeID string `json:"peer_node_id,omitempty"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|||||||
@@ -149,6 +149,28 @@ func (l *FailureLimiter) Fail(key string) bool {
|
|||||||
return false
|
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 的失败计数。
|
// Reset 清除 key 的失败计数。
|
||||||
func (l *FailureLimiter) Reset(key string) {
|
func (l *FailureLimiter) Reset(key string) {
|
||||||
if key == "" {
|
if key == "" {
|
||||||
|
|||||||
@@ -79,3 +79,27 @@ func TestEmptyKeyIgnored(t *testing.T) {
|
|||||||
t.Fatal("empty key must not be tracked")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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,
|
ContentJSON: contentPtr,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrLLMQueueRateLimited) {
|
||||||
|
// 限流拒绝是预期行为,静默跳过。
|
||||||
|
return nil
|
||||||
|
}
|
||||||
printJSON(map[string]any{
|
printJSON(map[string]any{
|
||||||
"event": "llm_queue_enqueue_failed",
|
"event": "llm_queue_enqueue_failed",
|
||||||
"bot_id": bot.ID,
|
"bot_id": bot.ID,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"meshtastic_mqtt_server/internal/mqtpp"
|
"meshtastic_mqtt_server/internal/mqtpp"
|
||||||
|
"meshtastic_mqtt_server/internal/secrets"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -300,7 +301,8 @@ func populateBotNodeKeys(row *BotNodeRecord) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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())
|
row.PublicKey = base64.StdEncoding.EncodeToString(privateKey.PublicKey().Bytes())
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ type UserRecord struct {
|
|||||||
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||||
Username string `gorm:"column:username;not null;uniqueIndex"`
|
Username string `gorm:"column:username;not null;uniqueIndex"`
|
||||||
PasswordHash string `gorm:"column:password_hash;not null"`
|
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"`
|
Role string `gorm:"column:role;not null;index"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
|
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
|
||||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"`
|
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"`
|
||||||
@@ -909,6 +910,19 @@ type DBMigration struct {
|
|||||||
var dbMigrations = []DBMigration{
|
var dbMigrations = []DBMigration{
|
||||||
{Version: 1, Up: migrateDB1},
|
{Version: 1, Up: migrateDB1},
|
||||||
{Version: 2, Up: migrateDB2},
|
{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 {
|
func migrateDB1(tx *gorm.DB, driver string) error {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"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 {
|
if err := query.Order("created_at DESC").Find(&rows).Error; err != nil {
|
||||||
return nil, fmt.Errorf("list llm providers: %w", err)
|
return nil, fmt.Errorf("list llm providers: %w", err)
|
||||||
}
|
}
|
||||||
|
for i := range rows {
|
||||||
|
rows[i].APIKey = secrets.Decrypt(rows[i].APIKey)
|
||||||
|
}
|
||||||
return rows, nil
|
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)
|
return nil, fmt.Errorf("get llm provider %s: %w", name, err)
|
||||||
}
|
}
|
||||||
|
record.APIKey = secrets.Decrypt(record.APIKey)
|
||||||
return &record, nil
|
return &record, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateLLMProvider 创建 LLM Provider
|
// CreateLLMProvider 创建 LLM Provider
|
||||||
func (s *Store) CreateLLMProvider(record *LLMProviderRecord) error {
|
func (s *Store) CreateLLMProvider(record *LLMProviderRecord) error {
|
||||||
|
record.APIKey = secrets.Encrypt(record.APIKey)
|
||||||
if err := s.db.Create(record).Error; err != nil {
|
if err := s.db.Create(record).Error; err != nil {
|
||||||
return fmt.Errorf("create llm provider %s: %w", record.Name, err)
|
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
|
// UpdateLLMProvider 更新 LLM Provider
|
||||||
func (s *Store) UpdateLLMProvider(name string, updates map[string]any) error {
|
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 {
|
if err := s.db.Model(&LLMProviderRecord{}).Where("name = ?", name).Updates(updates).Error; err != nil {
|
||||||
return fmt.Errorf("update llm provider %s: %w", name, err)
|
return fmt.Errorf("update llm provider %s: %w", name, err)
|
||||||
}
|
}
|
||||||
@@ -289,6 +301,29 @@ type LLMMessageQueueInput struct {
|
|||||||
ContentJSON *string
|
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 队列
|
// EnqueueLLMMessage 将消息添加到 LLM 队列
|
||||||
func (s *Store) EnqueueLLMMessage(input LLMMessageQueueInput) (*LLMMessageQueueRecord, error) {
|
func (s *Store) EnqueueLLMMessage(input LLMMessageQueueInput) (*LLMMessageQueueRecord, error) {
|
||||||
var err 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)
|
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()
|
now := time.Now()
|
||||||
messageType := input.MessageType
|
messageType := input.MessageType
|
||||||
if messageType == "" {
|
if messageType == "" {
|
||||||
@@ -567,6 +612,10 @@ func enqueueChannelMessageToLLM(s *Store, record map[string]any) error {
|
|||||||
ContentJSON: contentPtr,
|
ContentJSON: contentPtr,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrLLMQueueRateLimited) {
|
||||||
|
// 限流拒绝是预期行为,静默跳过。
|
||||||
|
continue
|
||||||
|
}
|
||||||
printJSON(map[string]any{
|
printJSON(map[string]any{
|
||||||
"event": "llm_queue_enqueue_failed",
|
"event": "llm_queue_enqueue_failed",
|
||||||
"bot_id": bot.ID,
|
"bot_id": bot.ID,
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"meshtastic_mqtt_server/internal/secrets"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -59,7 +61,14 @@ func (s *Store) ListMQTTForwarders(opts ListOptions) ([]MQTTForwarderRecord, err
|
|||||||
Order("id DESC").
|
Order("id DESC").
|
||||||
Limit(opts.Limit).
|
Limit(opts.Limit).
|
||||||
Offset(opts.Offset)
|
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) {
|
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 {
|
if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
row.SourcePassword = secrets.Decrypt(row.SourcePassword)
|
||||||
|
row.TargetPassword = secrets.Decrypt(row.TargetPassword)
|
||||||
return &row, nil
|
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,
|
TargetHost: targetHost, TargetPort: input.TargetPort, TargetUsername: strings.TrimSpace(input.TargetUsername), TargetClientID: strings.TrimSpace(input.TargetClientID), TargetTLS: input.TargetTLS,
|
||||||
}
|
}
|
||||||
if input.SourcePassword != nil {
|
if input.SourcePassword != nil {
|
||||||
row.SourcePassword = *input.SourcePassword
|
row.SourcePassword = secrets.Encrypt(*input.SourcePassword)
|
||||||
} else if existing != nil {
|
} else if existing != nil {
|
||||||
row.SourcePassword = existing.SourcePassword
|
row.SourcePassword = existing.SourcePassword
|
||||||
}
|
}
|
||||||
if input.TargetPassword != nil {
|
if input.TargetPassword != nil {
|
||||||
row.TargetPassword = *input.TargetPassword
|
row.TargetPassword = secrets.Encrypt(*input.TargetPassword)
|
||||||
} else if existing != nil {
|
} else if existing != nil {
|
||||||
row.TargetPassword = existing.TargetPassword
|
row.TargetPassword = existing.TargetPassword
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,23 @@ func (s *Store) HasSignedOnDay(nodeID string, day time.Time) (bool, error) {
|
|||||||
return count > 0, nil
|
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) {
|
func (s *Store) GetSignByID(id uint64) (*SignRecord, error) {
|
||||||
var row SignRecord
|
var row SignRecord
|
||||||
if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil {
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("hash user password: %w", err)
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
user.PasswordHash = hash
|
user.PasswordHash = hash
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -24,19 +26,27 @@ const (
|
|||||||
mapTileCacheControl = "public, max-age=86400"
|
mapTileCacheControl = "public, max-age=86400"
|
||||||
maxMapTileBytes = 10 << 20
|
maxMapTileBytes = 10 << 20
|
||||||
maxMapTileRedirects = 2
|
maxMapTileRedirects = 2
|
||||||
|
// 单个地图源瓦片缓存配额:文件数与总字节双上限,超出后按 mtime 淘汰最旧文件。
|
||||||
|
mapTileCacheMaxFilesPerSource = 3000
|
||||||
|
mapTileCacheMaxBytesPerSource = 300 << 20
|
||||||
|
// 每写这么多文件触发一次清理采样。
|
||||||
|
mapTileCachePruneEvery = 20
|
||||||
)
|
)
|
||||||
|
|
||||||
type mapTileProxy struct {
|
type mapTileProxy struct {
|
||||||
store *storepkg.Store
|
store *storepkg.Store
|
||||||
cacheDir string
|
cacheDir string
|
||||||
client *http.Client
|
client *http.Client
|
||||||
|
mu sync.Mutex
|
||||||
|
writeCounts map[string]int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerMapTileProxyRoutes(r gin.IRouter, store *storepkg.Store, cacheDir string) {
|
func registerMapTileProxyRoutes(r gin.IRouter, store *storepkg.Store, cacheDir string) {
|
||||||
proxy := &mapTileProxy{
|
proxy := &mapTileProxy{
|
||||||
store: store,
|
store: store,
|
||||||
cacheDir: cacheDir,
|
cacheDir: cacheDir,
|
||||||
client: newMapTileHTTPClient(),
|
client: newMapTileHTTPClient(),
|
||||||
|
writeCounts: make(map[string]int64),
|
||||||
}
|
}
|
||||||
r.GET("/map/:sourceHash", proxy.handle)
|
r.GET("/map/:sourceHash", proxy.handle)
|
||||||
}
|
}
|
||||||
@@ -154,9 +164,67 @@ func (p *mapTileProxy) handle(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = writeMapTileCacheFile(cachePath, data)
|
_ = writeMapTileCacheFile(cachePath, data)
|
||||||
|
p.maybePruneTileCache(sourceHash)
|
||||||
writeMapTile(c, data)
|
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) {
|
func (p *mapTileProxy) fetchRemoteTile(req *http.Request, template string, tile mapTileCoordinates) ([]byte, int, error) {
|
||||||
remoteURL := expandMapTileURLTemplate(template, tile)
|
remoteURL := expandMapTileURLTemplate(template, tile)
|
||||||
upstreamReq, err := http.NewRequestWithContext(req.Context(), http.MethodGet, remoteURL, nil)
|
upstreamReq, err := http.NewRequestWithContext(req.Context(), http.MethodGet, remoteURL, nil)
|
||||||
|
|||||||
+25
-3
@@ -85,7 +85,7 @@ func NewRouter(cfg configpkg.WebConfig, consoleLog bool, store *storepkg.Store,
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
const BackendVersion = "1.4.0"
|
const BackendVersion = "1.5.0"
|
||||||
|
|
||||||
var CommitVersion = "dev"
|
var CommitVersion = "dev"
|
||||||
|
|
||||||
@@ -111,7 +111,13 @@ func registerAPIRoutes(r gin.IRouter, store *storepkg.Store, mapTileCacheDir str
|
|||||||
mappkg.RegisterPublicRoutes(r, store)
|
mappkg.RegisterPublicRoutes(r, store)
|
||||||
registerMapTileProxyRoutes(r, store, mapTileCacheDir)
|
registerMapTileProxyRoutes(r, store, mapTileCacheDir)
|
||||||
helppkg.RegisterPublicRoutes(r, store)
|
helppkg.RegisterPublicRoutes(r, store)
|
||||||
|
// 公开签到墙限速:60 次/分钟/IP,防遍历拉取。
|
||||||
|
signsLimiter := ratelimit.New(ratelimit.Options{MaxFailures: 60, Window: time.Minute})
|
||||||
r.GET("/signs", func(c *gin.Context) {
|
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)
|
opts, ok := parseListOptions(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -125,6 +131,10 @@ func registerAPIRoutes(r gin.IRouter, store *storepkg.Store, mapTileCacheDir str
|
|||||||
writeListResponseWithTotal(c, rows, opts, total, err, signpkg.SignDTO)
|
writeListResponseWithTotal(c, rows, opts, total, err, signpkg.SignDTO)
|
||||||
})
|
})
|
||||||
r.GET("/signs/daily", func(c *gin.Context) {
|
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)
|
opts, ok := parseListOptions(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -207,7 +217,8 @@ func registerAdminRoutes(r gin.IRouter, store *storepkg.Store, sessions *auth.Ma
|
|||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
type updatePasswordRequest struct {
|
type updatePasswordRequest struct {
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
|
CurrentPassword string `json:"current_password"`
|
||||||
}
|
}
|
||||||
userDTO := func(user storepkg.UserRecord) gin.H {
|
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}
|
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 := r.Group("")
|
||||||
protected.Use(auth.RequireAdmin(sessions))
|
protected.Use(auth.RequireAdmin(sessions, store))
|
||||||
blockingpkg.RegisterRoutes(protected, store, blocking)
|
blockingpkg.RegisterRoutes(protected, store, blocking)
|
||||||
signpkg.RegisterAdminRoutes(protected, store)
|
signpkg.RegisterAdminRoutes(protected, store)
|
||||||
mqttforwardpkg.RegisterRoutes(protected, store, forwarder)
|
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"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid password request"})
|
||||||
return
|
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)
|
user, err := store.UpdateUserPassword(id, req.Password)
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import (
|
|||||||
"meshtastic_mqtt_server/internal/mqtpp"
|
"meshtastic_mqtt_server/internal/mqtpp"
|
||||||
mqttforwardpkg "meshtastic_mqtt_server/internal/mqttforward"
|
mqttforwardpkg "meshtastic_mqtt_server/internal/mqttforward"
|
||||||
rspkg "meshtastic_mqtt_server/internal/runtimesettings"
|
rspkg "meshtastic_mqtt_server/internal/runtimesettings"
|
||||||
|
"meshtastic_mqtt_server/internal/secrets"
|
||||||
storepkg "meshtastic_mqtt_server/internal/store"
|
storepkg "meshtastic_mqtt_server/internal/store"
|
||||||
webpkg "meshtastic_mqtt_server/internal/web"
|
webpkg "meshtastic_mqtt_server/internal/web"
|
||||||
)
|
)
|
||||||
@@ -345,6 +346,8 @@ func parseArgs() (*configpkg.Config, error) {
|
|||||||
if value := os.Getenv("MESH_ADMIN_SESSION_SECRET"); value != "" {
|
if value := os.Getenv("MESH_ADMIN_SESSION_SECRET"); value != "" {
|
||||||
cfg.Web.Admin.SessionSecret = value
|
cfg.Web.Admin.SessionSecret = value
|
||||||
}
|
}
|
||||||
|
// 敏感数据落盘加密密钥(MESH_SECRET_KEY);未设置时保持明文兼容。
|
||||||
|
secrets.SetSecretKey(os.Getenv("MESH_SECRET_KEY"))
|
||||||
configpkg.ClearWebSocketPathOnUnsupportedGOOS(cfg, runtime.GOOS)
|
configpkg.ClearWebSocketPathOnUnsupportedGOOS(cfg, runtime.GOOS)
|
||||||
|
|
||||||
if err := configpkg.Validate(cfg); err != nil {
|
if err := configpkg.Validate(cfg); err != nil {
|
||||||
|
|||||||
@@ -307,8 +307,8 @@ export function createAdminUser(username: string, password: string): Promise<Adm
|
|||||||
return postJSON<AdminManagedUserResponse>('/api/admin/users', { username, password })
|
return postJSON<AdminManagedUserResponse>('/api/admin/users', { username, password })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateAdminUserPassword(id: number, password: string): Promise<AdminManagedUserResponse> {
|
export function updateAdminUserPassword(id: number, password: string, currentPassword: string): Promise<AdminManagedUserResponse> {
|
||||||
return putJSON<AdminManagedUserResponse>(`/api/admin/users/${id}/password`, { password })
|
return putJSON<AdminManagedUserResponse>(`/api/admin/users/${id}/password`, { password, current_password: currentPassword })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAdminLoginLogs(limit = 100, offset = 0): Promise<AdminLoginLogsResponse> {
|
export function getAdminLoginLogs(limit = 100, offset = 0): Promise<AdminLoginLogsResponse> {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const newPassword = ref('')
|
|||||||
const confirmPassword = ref('')
|
const confirmPassword = ref('')
|
||||||
const passwordEdits = ref<Record<number, string>>({})
|
const passwordEdits = ref<Record<number, string>>({})
|
||||||
const passwordSaving = ref<Record<number, boolean>>({})
|
const passwordSaving = ref<Record<number, boolean>>({})
|
||||||
|
const currentPassword = ref('')
|
||||||
|
|
||||||
function formatTime(value: string): string {
|
function formatTime(value: string): string {
|
||||||
return new Date(value).toLocaleString()
|
return new Date(value).toLocaleString()
|
||||||
@@ -73,11 +74,16 @@ async function updatePassword(user: AdminManagedUser) {
|
|||||||
userError.value = '新密码不能为空'
|
userError.value = '新密码不能为空'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!currentPassword.value) {
|
||||||
|
userError.value = '请输入当前登录密码'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
passwordSaving.value = { ...passwordSaving.value, [user.id]: true }
|
passwordSaving.value = { ...passwordSaving.value, [user.id]: true }
|
||||||
try {
|
try {
|
||||||
await updateAdminUserPassword(user.id, password)
|
await updateAdminUserPassword(user.id, password, currentPassword.value)
|
||||||
passwordEdits.value = { ...passwordEdits.value, [user.id]: '' }
|
passwordEdits.value = { ...passwordEdits.value, [user.id]: '' }
|
||||||
|
currentPassword.value = ''
|
||||||
userMessage.value = `${user.username} 的密码已修改`
|
userMessage.value = `${user.username} 的密码已修改`
|
||||||
await refreshUsers()
|
await refreshUsers()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -129,6 +135,7 @@ onMounted(refreshUsers)
|
|||||||
<th class="cell-nowrap">创建时间</th>
|
<th class="cell-nowrap">创建时间</th>
|
||||||
<th class="cell-nowrap">更新时间</th>
|
<th class="cell-nowrap">更新时间</th>
|
||||||
<th>新密码</th>
|
<th>新密码</th>
|
||||||
|
<th class="cell-nowrap">当前登录密码</th>
|
||||||
<th class="cell-nowrap">操作</th>
|
<th class="cell-nowrap">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -148,6 +155,15 @@ onMounted(refreshUsers)
|
|||||||
placeholder="输入新密码"
|
placeholder="输入新密码"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="cell-nowrap">
|
||||||
|
<input
|
||||||
|
v-model="currentPassword"
|
||||||
|
class="admin-table-input"
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
placeholder="当前登录密码(验证用)"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
<td class="cell-nowrap">
|
<td class="cell-nowrap">
|
||||||
<button class="admin-button" :disabled="passwordSaving[managedUser.id]" @click="updatePassword(managedUser)">
|
<button class="admin-button" :disabled="passwordSaving[managedUser.id]" @click="updatePassword(managedUser)">
|
||||||
{{ passwordSaving[managedUser.id] ? '保存中...' : '修改密码' }}
|
{{ passwordSaving[managedUser.id] ? '保存中...' : '修改密码' }}
|
||||||
|
|||||||
Reference in New Issue
Block a user