安全加固:console_log.meshtastic 默认关闭(不打印解密明文),session_secure 默认 true,config.yaml 回写权限改 0600,公开接口错误信息脱敏(/api/health、/api/channels、列表接口,text-messages 移除 mqtt_remote_host),bot PSK 不回显(psk_set+更新保持原值),前端 help 页 DOMPurify 客户端消毒,后端 v1.6.0

This commit is contained in:
2026-08-20 17:19:40 +08:00
parent ba9be5b68b
commit b0202062af
15 changed files with 84 additions and 35 deletions
+1 -1
View File
@@ -305,7 +305,7 @@ func writeBotNodeMutationResponse(c *gin.Context, status int, row *storepkg.BotN
}
func botNodeDTO(row storepkg.BotNodeRecord) gin.H {
return gin.H{"id": row.ID, "node_id": row.NodeID, "node_num": row.NodeNum, "long_name": row.LongName, "short_name": row.ShortName, "enabled": row.Enabled, "default_channel_id": row.DefaultChannelID, "topic_prefix": row.TopicPrefix, "psk": row.PSK, "public_key": row.PublicKey, "private_key_set": row.PrivateKey != "", "nodeinfo_broadcast_enabled": row.NodeInfoBroadcastEnabled, "nodeinfo_broadcast_interval_seconds": row.NodeInfoBroadcastIntervalSeconds, "last_nodeinfo_broadcast_at": row.LastNodeInfoBroadcastAt, "llm_queue_enabled": row.LLMQueueEnabled, "llm_include_channel_messages": row.LLMIncludeChannelMessages, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
return gin.H{"id": row.ID, "node_id": row.NodeID, "node_num": row.NodeNum, "long_name": row.LongName, "short_name": row.ShortName, "enabled": row.Enabled, "default_channel_id": row.DefaultChannelID, "topic_prefix": row.TopicPrefix, "psk_set": row.PSK != "", "public_key": row.PublicKey, "private_key_set": row.PrivateKey != "", "nodeinfo_broadcast_enabled": row.NodeInfoBroadcastEnabled, "nodeinfo_broadcast_interval_seconds": row.NodeInfoBroadcastIntervalSeconds, "last_nodeinfo_broadcast_at": row.LastNodeInfoBroadcastAt, "llm_queue_enabled": row.LLMQueueEnabled, "llm_include_channel_messages": row.LLMIncludeChannelMessages, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
}
func botMessageDTO(row storepkg.BotMessageRecord) gin.H {
+6 -3
View File
@@ -233,7 +233,7 @@ func Default() *Config {
Username: "admin",
Password: "admin",
SessionSecret: "",
SessionSecure: false,
SessionSecure: true,
},
},
AI: AIConfig{
@@ -245,7 +245,9 @@ func Default() *Config {
MQTT: true,
LLM: true,
SQL: true,
Meshtastic: true,
// 默认不打印解码后的 Meshtastic 数据包:其中包含私聊明文。
// 需要调试时可显式开启 console_log.meshtastic。
Meshtastic: false,
},
}
}
@@ -713,7 +715,8 @@ func Write(path string, cfg *Config) error {
if err != nil {
return fmt.Errorf("encode config file %s: %w", path, err)
}
if err := os.WriteFile(path, data, 0644); err != nil {
// 0600:配置文件含明文口令/密钥,只允许属主读写。
if err := os.WriteFile(path, data, 0600); err != nil {
return fmt.Errorf("write config file %s: %w", path, err)
}
return nil
+5
View File
@@ -116,6 +116,11 @@ func (s *Store) UpdateBotNode(id uint64, input BotNodeInput) (*BotNodeRecord, er
preserved := existing.NodeNum
input.NodeNum = &preserved
}
// PSK 不回显给前端后,更新表单不会携带原值;为空时保持现有 PSK,
// 避免用户只是改个名字就把频道密钥重置为默认 AQ==。
if strings.TrimSpace(input.PSK) == "" {
input.PSK = existing.PSK
}
row, err := s.normalizedBotNodeRecord(input)
if err != nil {
return nil, err
+8 -4
View File
@@ -2,6 +2,7 @@ package web
import (
"errors"
"fmt"
"net"
"net/http"
"os"
@@ -85,7 +86,7 @@ func NewRouter(cfg configpkg.WebConfig, consoleLog bool, store *storepkg.Store,
return r
}
const BackendVersion = "1.5.0"
const BackendVersion = "1.6.0"
var CommitVersion = "dev"
@@ -93,8 +94,9 @@ func registerAPIRoutes(r gin.IRouter, store *storepkg.Store, mapTileCacheDir str
r.GET("/health", func(c *gin.Context) {
status := gin.H{"status": "ok", "database": "ok"}
if err := store.Ping(); err != nil {
fmt.Fprintf(os.Stderr, "[web] health check database error: %v\n", err)
status["status"] = "error"
status["database"] = err.Error()
status["database"] = "unavailable"
c.JSON(http.StatusServiceUnavailable, status)
return
}
@@ -153,7 +155,8 @@ func registerAPIRoutes(r gin.IRouter, store *storepkg.Store, mapTileCacheDir str
r.GET("/channels", func(c *gin.Context) {
rows, err := store.ListChannels()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
fmt.Fprintf(os.Stderr, "[web] list channels error: %v\n", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
items := make([]gin.H, 0, len(rows))
@@ -684,7 +687,8 @@ func mapReportClusterDTO(row storepkg.MapReportClusterRecord) gin.H {
}
func textMessageDTO(row storepkg.TextMessageRecord) gin.H {
return gin.H{"id": row.ID, "from_id": row.FromID, "from_num": row.FromNum, "packet_id": ptrInt64(row.PacketID), "text": ptrString(row.Text), "topic": row.Topic, "channel_id": ptrString(row.ChannelID), "created_at": row.CreatedAt, "mqtt_remote_host": ptrString(row.MQTTRemoteHost), "content_json": row.ContentJSON}
// 不含 mqtt_remote_host:公开接口不暴露发布者 IP(见安全修复 T6 同类处理)。
return gin.H{"id": row.ID, "from_id": row.FromID, "from_num": row.FromNum, "packet_id": ptrInt64(row.PacketID), "text": ptrString(row.Text), "topic": row.Topic, "channel_id": ptrString(row.ChannelID), "created_at": row.CreatedAt, "content_json": row.ContentJSON}
}
func discardDetailsDTO(row storepkg.DiscardDetailsRecord) gin.H {
+12 -3
View File
@@ -7,13 +7,15 @@
package webutil
import (
"fmt"
"net/http"
"os"
"strconv"
"time"
"github.com/gin-gonic/gin"
"meshtastic_mqtt_server/internal/store"
store "meshtastic_mqtt_server/internal/store"
)
// ParseListOptions 从请求中读取 limit / offset / since / until / node_id /
@@ -175,7 +177,8 @@ func ParseOptionalFloatQuery(c *gin.Context, name string) (float64, bool, bool)
// WriteListResponse 把 rows 通过 convert 转成 gin.H 后包装成 {items, limit, offset}。
func WriteListResponse[T any](c *gin.Context, rows []T, opts store.ListOptions, err error, convert func(T) gin.H) {
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
logQueryError(c, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
items := make([]gin.H, 0, len(rows))
@@ -188,7 +191,8 @@ func WriteListResponse[T any](c *gin.Context, rows []T, opts store.ListOptions,
// WriteListResponseWithTotal 在 WriteListResponse 基础上额外携带 total 字段。
func WriteListResponseWithTotal[T any](c *gin.Context, rows []T, opts store.ListOptions, total int64, err error, convert func(T) gin.H) {
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
logQueryError(c, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
items := make([]gin.H, 0, len(rows))
@@ -198,6 +202,11 @@ func WriteListResponseWithTotal[T any](c *gin.Context, rows []T, opts store.List
c.JSON(http.StatusOK, gin.H{"items": items, "limit": opts.Limit, "offset": opts.Offset, "total": total})
}
// logQueryError 把查询错误详情打到 stderr,响应体只回固定文案,避免泄露库表结构。
func logQueryError(c *gin.Context, err error) {
fmt.Fprintf(os.Stderr, "[web] %s %s query error: %v\n", c.Request.Method, c.Request.URL.Path, err)
}
// PtrString / PtrInt64 / PtrUint64 / PtrFloat64 / PtrBool 把指针解引用成 any
// 用于把数据库可空字段转换成 JSON 时让 nil 序列化为 null。
func PtrString(value *string) any {