From f73d79b7d4bd4e3e11250be5667409fda2398996 Mon Sep 17 00:00:00 2001 From: kevin Date: Thu, 4 Jun 2026 09:52:57 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9F=BA=E6=9C=AC=E5=8A=9F=E8=83=BD=E5=B7=AE?= =?UTF-8?q?=E4=B8=8D=E5=A4=9A=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 +- db.go | 21 +++++ db_test.go | 52 +++++++++- discard_store.go | 45 +++++++++ main.go | 5 + meshmap_frontend/src/App.vue | 20 +++- meshmap_frontend/src/api.ts | 5 + .../src/components/AdminDashboard.vue | 2 +- .../src/components/AdminDiscardDetails.vue | 94 +++++++++++++++++++ meshmap_frontend/src/components/HelpPage.vue | 39 ++++++++ meshmap_frontend/src/style.css | 51 +++++++++- meshmap_frontend/src/types.ts | 16 ++++ mqtpp/mqtpp.go | 3 +- store_query.go | 27 ++++++ web.go | 21 ++++- 15 files changed, 395 insertions(+), 14 deletions(-) create mode 100644 discard_store.go create mode 100644 meshmap_frontend/src/components/AdminDiscardDetails.vue create mode 100644 meshmap_frontend/src/components/HelpPage.vue diff --git a/README.md b/README.md index 5567919..5fad301 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ go run . 构建后的文件位于项目根目录 `dist/`,Gin 会提供静态文件服务;`/api` 路径保留给后端接口。 -管理页面位于 `/admin`,默认管理员账号为 `admin` / `admin`。生产环境请修改 `web.admin.password` 或设置 `MESH_ADMIN_PASSWORD`,并配置固定的 `web.admin.session_secret` 或 `MESH_ADMIN_SESSION_SECRET`;如果 `session_secret` 为空,程序会在启动时生成临时签名密钥,重启后需要重新登录。后台页面包括 `/admin` 服务状态、`/admin/users` 用户管理、`/admin/log/login` 登录日志。后台支持新增管理员用户和修改用户密码;密码使用 bcrypt hash 保存,API 不会返回密码 hash。修改密码不会立即使已签发 Session 失效,当前 Session 到期或退出登录后才需要使用新密码。登录成功和失败都会记录到登录日志,包含用户名、结果、原因、来源地址、User-Agent 和时间。管理员可在主页右键删除聊天消息、地图节点或节点列表记录;删除节点会删除 `nodeinfo` 和 `map_report` 当前状态,不会删除历史消息、位置、遥测等 append 记录,后续收到新的节点上报时可能重新出现。 +管理页面位于 `/admin`,默认管理员账号为 `admin` / `admin`。生产环境请修改 `web.admin.password` 或设置 `MESH_ADMIN_PASSWORD`,并配置固定的 `web.admin.session_secret` 或 `MESH_ADMIN_SESSION_SECRET`;如果 `session_secret` 为空,程序会在启动时生成临时签名密钥,重启后需要重新登录。后台页面包括 `/admin` 服务状态、`/admin/users` 用户管理、`/admin/log/login` 登录日志、`/admin/discard_details` 丢弃数据。`/admin` 中的“丢弃消息”统计来自 `discard_details` 表记录数,点击可进入丢弃数据分页页。后台支持新增管理员用户和修改用户密码;密码使用 bcrypt hash 保存,API 不会返回密码 hash。修改密码不会立即使已签发 Session 失效,当前 Session 到期或退出登录后才需要使用新密码。登录成功和失败都会记录到登录日志,包含用户名、结果、原因、来源地址、User-Agent 和时间。管理员可在主页右键删除聊天消息、地图节点或节点列表记录;删除节点会删除 `nodeinfo` 和 `map_report` 当前状态,不会删除历史消息、位置、遥测等 append 记录,后续收到新的节点上报时可能重新出现。 常用 API: @@ -140,6 +140,7 @@ GET /api/map-reports/:id GET /api/nodes # /api/nodeinfo 的兼容别名 GET /api/nodes/:id # /api/nodeinfo/:id 的兼容别名 GET /api/text-messages +GET /api/discard-details GET /api/positions GET /api/telemetry GET /api/routing @@ -167,6 +168,7 @@ meshtastic: 程序默认启用 SQLite,数据库表迁移和操作由 GORM 执行,并持久化以下数据: - `login_log`:追加保存后台登录成功和失败日志 +- `discard_details`:追加保存 `MQTTPP` 判定无效而被 broker 丢弃的数据,raw payload 使用 base64 保存 - `nodeinfo`:保存 `type == "nodeinfo"` 的节点身份和设备信息 - `map_report`:保存 `type == "map_report"` 的地图报告信息,前端地图从该表读取 - `text_message`:追加保存 `type == "text_message"` 的聊天消息 @@ -252,7 +254,9 @@ database: `empty_packet` 仍然属于 `valid == true`,会被转发;只是控制台默认不显示它。 -无法解密但能解析的加密包通常会输出为 `encrypted_packet`,仍然属于 `valid == true`,因此会被转发。 +无法解密的加密包会输出为 `encrypted_packet`,属于 `valid == false`,因此会被拒绝并丢弃。 + +丢弃的 publish 会写入 `discard_details`,记录 topic、错误原因、payload 长度、base64 raw payload、MQTT 客户端信息和完整 `content_json`。 ## 本地验证 diff --git a/db.go b/db.go index 93f25c6..8c5f5ed 100644 --- a/db.go +++ b/db.go @@ -91,6 +91,26 @@ func (loginLogRecord) TableName() string { return "login_log" } +type discardDetailsRecord struct { + ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` + Topic string `gorm:"column:topic"` + Error string `gorm:"column:error"` + PayloadLen int64 `gorm:"column:payload_len"` + RawBase64 string `gorm:"column:raw_base64;not null"` + ContentJSON string `gorm:"column:content_json;not null"` + MQTTClientID *string `gorm:"column:mqtt_client_id"` + MQTTUsername *string `gorm:"column:mqtt_username"` + MQTTListener *string `gorm:"column:mqtt_listener"` + MQTTRemoteAddr *string `gorm:"column:mqtt_remote_addr"` + MQTTRemoteHost *string `gorm:"column:mqtt_remote_host"` + MQTTRemotePort *string `gorm:"column:mqtt_remote_port"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index"` +} + +func (discardDetailsRecord) TableName() string { + return "discard_details" +} + type nodeInfoRecord struct { NodeID string `gorm:"column:node_id;primaryKey;not null"` NodeNum int64 `gorm:"column:node_num;not null;index"` @@ -285,6 +305,7 @@ func (s *store) migrate() error { }{ {label: "users", model: &userRecord{}}, {label: "login_log", model: &loginLogRecord{}}, + {label: "discard_details", model: &discardDetailsRecord{}}, {label: "nodeinfo", model: &nodeInfoRecord{}}, {label: "map_report", model: &mapReportRecord{}}, {label: "text_message", model: &textMessageRecord{}}, diff --git a/db_test.go b/db_test.go index 773c329..7171762 100644 --- a/db_test.go +++ b/db_test.go @@ -2,6 +2,7 @@ package main import ( "database/sql" + "encoding/base64" "errors" "path/filepath" "strings" @@ -14,7 +15,7 @@ func TestOpenStoreCreatesTables(t *testing.T) { st := openTestStore(t) defer st.Close() - for _, table := range []string{"users", "login_log", "nodeinfo", "map_report", "text_message", "position", "telemetry", "routing", "traceroute"} { + for _, table := range []string{"users", "login_log", "discard_details", "nodeinfo", "map_report", "text_message", "position", "telemetry", "routing", "traceroute"} { var name string if err := rawTestDB(t, st).QueryRow("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", table).Scan(&name); err != nil { t.Fatalf("%s table missing: %v", table, err) @@ -349,6 +350,55 @@ func TestInsertAndListLoginLogs(t *testing.T) { } } +func TestInsertDiscardDetailsStoresRawBase64AndClientInfo(t *testing.T) { + st := openTestStore(t) + defer st.Close() + + raw := []byte{0xff, 0x00, 0x01} + clientInfo := mqttClientInfo{ClientID: "client-1", Username: "user-1", Listener: "tcp", RemoteAddr: "127.0.0.1:54321", RemoteHost: "127.0.0.1", RemotePort: "54321"} + record := map[string]any{"topic": "msh/US/test", "error": "protobuf decode failed", "payload_len": len(raw)} + if err := st.InsertDiscardDetails(record, raw, clientInfo); err != nil { + t.Fatalf("InsertDiscardDetails() error = %v", err) + } + + var topic, errorText, rawBase64, clientID, username, listener, remoteAddr, remoteHost, remotePort, contentJSON string + var payloadLen int64 + if err := rawTestDB(t, st).QueryRow("SELECT topic, error, payload_len, raw_base64, mqtt_client_id, mqtt_username, mqtt_listener, mqtt_remote_addr, mqtt_remote_host, mqtt_remote_port, content_json FROM discard_details LIMIT 1").Scan(&topic, &errorText, &payloadLen, &rawBase64, &clientID, &username, &listener, &remoteAddr, &remoteHost, &remotePort, &contentJSON); err != nil { + t.Fatal(err) + } + if topic != "msh/US/test" || errorText != "protobuf decode failed" || payloadLen != int64(len(raw)) || rawBase64 != base64.StdEncoding.EncodeToString(raw) { + t.Fatalf("discard details row = topic %q error %q len %d raw %q", topic, errorText, payloadLen, rawBase64) + } + if clientID != "client-1" || username != "user-1" || listener != "tcp" || remoteAddr != "127.0.0.1:54321" || remoteHost != "127.0.0.1" || remotePort != "54321" { + t.Fatalf("client info = %q %q %q %q %q %q", clientID, username, listener, remoteAddr, remoteHost, remotePort) + } + if !strings.Contains(contentJSON, "protobuf decode failed") { + t.Fatalf("content_json = %q, want error", contentJSON) + } +} + +func TestListDiscardDetailsOrdersNewestFirst(t *testing.T) { + st := openTestStore(t) + defer st.Close() + + if err := st.InsertDiscardDetails(map[string]any{"topic": "first", "error": "first"}, []byte{1}, mqttClientInfo{}); err != nil { + t.Fatalf("first InsertDiscardDetails() error = %v", err) + } + if err := st.InsertDiscardDetails(map[string]any{"topic": "second", "error": "second"}, []byte{2}, mqttClientInfo{}); err != nil { + t.Fatalf("second InsertDiscardDetails() error = %v", err) + } + rows, err := st.ListDiscardDetails(listOptions{Limit: 10}) + if err != nil { + t.Fatalf("ListDiscardDetails() error = %v", err) + } + if len(rows) != 2 { + t.Fatalf("discard details len = %d, want 2", len(rows)) + } + if rows[0].ID <= rows[1].ID || rows[0].Topic != "second" { + t.Fatalf("discard details order = %#v", rows) + } +} + func TestInsertTextMessageAppendsRows(t *testing.T) { st := openTestStore(t) defer st.Close() diff --git a/discard_store.go b/discard_store.go new file mode 100644 index 0000000..89fd7ef --- /dev/null +++ b/discard_store.go @@ -0,0 +1,45 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "fmt" +) + +func (s *store) InsertDiscardDetails(record map[string]any, raw []byte, clientInfo mqttClientInfo) error { + details, err := discardDetailsFromRecord(record, raw, clientInfo) + if err != nil { + return err + } + if err := s.db.Create(details).Error; err != nil { + return fmt.Errorf("insert discard_details: %w", err) + } + return nil +} + +func discardDetailsFromRecord(record map[string]any, raw []byte, clientInfo mqttClientInfo) (*discardDetailsRecord, error) { + contentJSON, err := json.Marshal(record) + if err != nil { + return nil, fmt.Errorf("encode discard_details content_json: %w", err) + } + return &discardDetailsRecord{ + Topic: stringValue(record["topic"]), + Error: stringValue(record["error"]), + PayloadLen: int64(len(raw)), + RawBase64: base64.StdEncoding.EncodeToString(raw), + ContentJSON: string(contentJSON), + MQTTClientID: nullableStringValue(clientInfo.ClientID), + MQTTUsername: nullableStringValue(clientInfo.Username), + MQTTListener: nullableStringValue(clientInfo.Listener), + MQTTRemoteAddr: nullableStringValue(clientInfo.RemoteAddr), + MQTTRemoteHost: nullableStringValue(clientInfo.RemoteHost), + MQTTRemotePort: nullableStringValue(clientInfo.RemotePort), + }, nil +} + +func stringValue(value any) string { + if s := nullableStringValue(value); s != nil { + return *s + } + return "" +} diff --git a/main.go b/main.go index ff9d558..2e51deb 100644 --- a/main.go +++ b/main.go @@ -54,6 +54,11 @@ func (h *meshtasticFilterHook) OnPublish(cl *mqtt.Client, pk packets.Packet) (pa valid, _, record := mqtpp.MQTTPP(pk.TopicName, pk.Payload, h.key) if !valid { h.stats.IncDropped() + if h.store != nil { + if err := h.store.InsertDiscardDetails(record, pk.Payload, mqttClientInfoFromClient(cl)); err != nil { + printJSON(map[string]any{"event": "db_error", "type": "discard_details", "topic": pk.TopicName, "error": err.Error()}) + } + } return pk, packets.ErrRejectPacket } h.stats.IncForwarded() diff --git a/meshmap_frontend/src/App.vue b/meshmap_frontend/src/App.vue index 70874f9..2ddbdaf 100644 --- a/meshmap_frontend/src/App.vue +++ b/meshmap_frontend/src/App.vue @@ -2,10 +2,12 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { adminLogout, deleteNode, deleteTextMessage, getAdminMe, getHealth, getMapReports, getNodeInfo, getPositions, getTextMessages } from './api' import AdminDashboard from './components/AdminDashboard.vue' +import AdminDiscardDetails from './components/AdminDiscardDetails.vue' import AdminLogin from './components/AdminLogin.vue' import AdminLoginLogs from './components/AdminLoginLogs.vue' import AdminUsers from './components/AdminUsers.vue' import ChatPanel from './components/ChatPanel.vue' +import HelpPage from './components/HelpPage.vue' import MeshMap from './components/MeshMap.vue' import NodeDetailedPage from './components/NodeDetailedPage.vue' import NodeListPanel from './components/NodeListPanel.vue' @@ -17,6 +19,7 @@ const isAdminPage = adminPath.startsWith('/admin') const detailMatch = currentPath.match(/^\/detailed\/(.+)$/) const detailedNodeId = detailMatch ? decodeURIComponent(detailMatch[1]) : '' const isDetailedPage = !!detailedNodeId +const isHelpPage = currentPath === '/help' const adminUser = ref(null) const adminChecking = ref(false) @@ -217,7 +220,7 @@ onMounted(() => { return } checkAdminSession() - if (isDetailedPage) { + if (isDetailedPage || isHelpPage) { return } refresh() @@ -237,6 +240,7 @@ onBeforeUnmount(() => {

Meshtastic MQTT Server

节点详情

+

使用帮助

{{ isAdminPage ? 'Admin' : 'MeshMap' }}

@@ -245,6 +249,7 @@ onBeforeUnmount(() => { 服务状态 用户管理 登录日志 + 丢弃数据 返回地图 @@ -253,10 +258,12 @@ onBeforeUnmount(() => { 返回地图 管理 + @@ -285,6 +293,10 @@ onBeforeUnmount(() => { + +