From 2afd890de427123c8ee5fc6106aa06540c7d5891 Mon Sep 17 00:00:00 2001 From: kevin Date: Fri, 19 Jun 2026 20:37:19 +0800 Subject: [PATCH] =?UTF-8?q?=E6=89=B9=E9=87=8F=E5=88=A0=E9=99=A4=E8=8A=82?= =?UTF-8?q?=E7=82=B9=EF=BC=8C=E4=B8=80=E9=94=AE=E5=B1=8F=E8=94=BDip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/web/mqtt_status.go | 42 +++++++ internal/web/web.go | 61 ++++++++++ meshmap_frontend/src/App.vue | 52 ++++++++ meshmap_frontend/src/api.ts | 15 +++ .../src/components/AdminDashboard.vue | 115 +++++++++++++++++- meshmap_frontend/src/components/MeshMap.vue | 44 +++++++ 6 files changed, 328 insertions(+), 1 deletion(-) diff --git a/internal/web/mqtt_status.go b/internal/web/mqtt_status.go index e1d0e45..5b750c8 100644 --- a/internal/web/mqtt_status.go +++ b/internal/web/mqtt_status.go @@ -1,7 +1,10 @@ package web import ( + "net" + mqtt "github.com/mochi-mqtt/server/v2" + "github.com/mochi-mqtt/server/v2/packets" mqttforwardpkg "meshtastic_mqtt_server/internal/mqttforward" storepkg "meshtastic_mqtt_server/internal/store" @@ -11,6 +14,11 @@ import ( // 实现一般由 main 包传入(持有真正的 mqtt.Server / 写队列 / 统计器)。 type MQTTStatusProvider interface { Status() AdminMQTTStatus + // DisconnectClient 立即踢掉指定的 MQTT 客户端。clientID 不存在时返回 false。 + DisconnectClient(clientID string) bool + // LookupClientRemoteHost 根据 clientID 查询当前连接的远端主机(不带端口), + // 用于把该 IP 加入屏蔽表。clientID 不存在时返回空字符串与 false。 + LookupClientRemoteHost(clientID string) (string, bool) } // MQTTRuntimeStatus 把 mqtt.Server / 写队列 / 转发统计三个上下文打包成 @@ -127,3 +135,37 @@ func mqttClientInfo(c *mqtt.Client) mqttClientInfoView { RemoteAddr: c.Net.Remote, } } + +// DisconnectClient 实现 MQTTStatusProvider:发送 Disconnect 报文并关闭连接。 +// 使用 ErrAdministrativeAction 作为断开理由,便于日志区分。 +func (m MQTTRuntimeStatus) DisconnectClient(clientID string) bool { + if m.Server == nil || clientID == "" { + return false + } + client, ok := m.Server.Clients.Get(clientID) + if !ok || client == nil { + return false + } + _ = m.Server.DisconnectClient(client, packets.ErrAdministrativeAction) + return true +} + +// LookupClientRemoteHost 实现 MQTTStatusProvider:根据 clientID 查询当前连接的远端 IP。 +// Net.Remote 通常是 "host:port",这里只返回主机部分;解析失败时回退为整个值。 +func (m MQTTRuntimeStatus) LookupClientRemoteHost(clientID string) (string, bool) { + if m.Server == nil || clientID == "" { + return "", false + } + client, ok := m.Server.Clients.Get(clientID) + if !ok || client == nil { + return "", false + } + remote := client.Net.Remote + if remote == "" { + return "", false + } + if host, _, err := net.SplitHostPort(remote); err == nil && host != "" { + return host, true + } + return remote, true +} diff --git a/internal/web/web.go b/internal/web/web.go index eaf9013..9b7c0d8 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -249,6 +249,67 @@ func registerAdminRoutes(r gin.IRouter, store *storepkg.Store, sessions *auth.Ma status.MessagesDropped = discardCount c.JSON(http.StatusOK, status) }) + // 一键断开 MQTT 客户端并把它的远端 IP 加入屏蔽表。reason 由前端传入;写库前先查 IP 避免连接断开后查不到。 + protected.POST("/mqtt/clients/:client_id/disconnect-and-block", func(c *gin.Context) { + if mqttStatus == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "mqtt server not available"}) + return + } + clientID := c.Param("client_id") + if clientID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid client id"}) + return + } + var req struct { + Reason string `json:"reason"` + } + // 请求体允许为空——若没传 reason 就走默认占位。 + _ = c.ShouldBindJSON(&req) + reason := strings.TrimSpace(req.Reason) + if reason == "" { + reason = "manual disconnect from admin dashboard" + } + + host, ok := mqttStatus.LookupClientRemoteHost(clientID) + if !ok || host == "" { + c.JSON(http.StatusNotFound, gin.H{"error": "mqtt client not found"}) + return + } + + // 先写屏蔽规则,再断开连接:万一断开后客户端立刻重连,新规则已经生效。 + var ipRule *storepkg.IPBlockingRecord + row, err := store.CreateIPBlocking(host, reason, true) + switch { + case err == nil: + ipRule = row + case errors.Is(err, storepkg.ErrBlockingAlreadyExists): + // 已经存在则忽略——只确保是启用状态。 + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if blocking != nil { + if err := blocking.Reload(store); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } + + disconnected := mqttStatus.DisconnectClient(clientID) + resp := gin.H{ + "status": "ok", + "client_id": clientID, + "ip_value": host, + "disconnected": disconnected, + } + if ipRule != nil { + resp["ip_rule_id"] = ipRule.ID + resp["ip_rule_created"] = true + } else { + resp["ip_rule_created"] = false + } + c.JSON(http.StatusOK, resp) + }) protected.GET("/users", func(c *gin.Context) { users, err := store.ListUsers() if err != nil { diff --git a/meshmap_frontend/src/App.vue b/meshmap_frontend/src/App.vue index 1cd48d0..5d2e574 100644 --- a/meshmap_frontend/src/App.vue +++ b/meshmap_frontend/src/App.vue @@ -76,6 +76,7 @@ type PendingDeleteAction = | { kind: 'delete-message'; message: DeletableTextMessage } | { kind: 'delete-node'; nodeId: string } | { kind: 'purge-node'; nodeId: string } + | { kind: 'delete-displayed-nodes'; nodeIds: string[] } | ({ kind: 'delete-and-block-node' } & NodeActionRequest) let refreshTimer: number | undefined let mapBoundsTimer: number | undefined @@ -206,6 +207,9 @@ const deleteModalTitle = computed(() => { if (action.kind === 'purge-node') { return '确认删除节点' } + if (action.kind === 'delete-displayed-nodes') { + return '确认删除所有显示的节点' + } return '确认删除并屏蔽节点' }) @@ -226,6 +230,9 @@ const deleteModalMessage = computed(() => { if (action.kind === 'purge-node') { return '确定要删除这个节点吗?将同时清理该节点的聊天消息和位置/遥测/路由/路径追踪等数据包记录,此操作不可撤销。' } + if (action.kind === 'delete-displayed-nodes') { + return `确定要删除当前地图上显示的全部 ${action.nodeIds.length} 个节点吗?将逐个调用删除接口,此操作不可撤销。` + } if (!action.message) { return '确定要删除并屏蔽这个节点吗?请输入屏蔽原因。' } @@ -453,6 +460,22 @@ function requestPurgeNode(nodeId: string) { pendingDeleteAction.value = { kind: 'purge-node', nodeId } } +function requestDeleteDisplayedNodes() { + // 收集当前地图上正在显示的节点 ID(仅 type=node,跳过聚合点),并按筛选后的视图顺序去重。 + const nodeIds = Array.from( + new Set( + mapItems.value + .filter((item): item is Extract => item.type === 'node') + .map((item) => item.node_id), + ), + ) + if (nodeIds.length === 0) { + error.value = '当前地图上没有可删除的节点。' + return + } + pendingDeleteAction.value = { kind: 'delete-displayed-nodes', nodeIds } +} + function requestDeleteAndBlockNode(payload: NodeActionRequest) { pendingDeleteAction.value = { kind: 'delete-and-block-node', ...payload } } @@ -483,6 +506,11 @@ async function confirmDeleteModal(payload: { reason?: string }) { return } + if (action.kind === 'delete-displayed-nodes') { + await deleteDisplayedNodes(action.nodeIds) + return + } + const reason = payload.reason?.trim() if (!reason) { return @@ -571,6 +599,29 @@ async function purgeNodeById(nodeId: string) { } } +// 逐个调用 deleteNode 串行删除当前地图上显示的节点;某一个失败不阻断后续,最后汇总错误。 +async function deleteDisplayedNodes(nodeIds: string[]) { + const failures: string[] = [] + let lastErrorMessage = '' + for (const nodeId of nodeIds) { + try { + await deleteNode(nodeId) + await removeNodeFromLocalState(nodeId) + } catch (err) { + if (isNodeNotFoundError(err)) { + // 已经不在了,本地状态也同步移除即可 + await removeNodeFromLocalState(nodeId) + continue + } + failures.push(nodeId) + lastErrorMessage = err instanceof Error ? err.message : String(err) + } + } + if (failures.length > 0) { + error.value = `部分节点删除失败(${failures.length}/${nodeIds.length}):${lastErrorMessage}` + } +} + async function deleteAndBlockNode(payload: NodeActionPayload) { try { if (payload.message) { @@ -765,6 +816,7 @@ onBeforeUnmount(() => { @clear-node="clearSelectedNode" @delete-node="requestDeleteNode" @purge-node="requestPurgeNode" + @delete-displayed-nodes="requestDeleteDisplayedNodes" @delete-and-block-node="requestDeleteAndBlockNode" /> diff --git a/meshmap_frontend/src/api.ts b/meshmap_frontend/src/api.ts index 24cb3de..64eaf23 100644 --- a/meshmap_frontend/src/api.ts +++ b/meshmap_frontend/src/api.ts @@ -244,6 +244,21 @@ export function getAdminMqttStatus(): Promise { return getJSON('/api/admin/mqtt/status') } +// 一键断开 MQTT 客户端并把它的远端 IP 加入屏蔽表。 +export function disconnectAndBlockMqttClient( + clientId: string, + payload: { reason?: string } = {}, +): Promise<{ + status: string + client_id: string + ip_value: string + disconnected: boolean + ip_rule_created: boolean + ip_rule_id?: number +}> { + return postJSON(`/api/admin/mqtt/clients/${encodeURIComponent(clientId)}/disconnect-and-block`, payload) +} + export function getAdminRuntimeSettings(): Promise { return getJSON('/api/admin/runtime-settings') } diff --git a/meshmap_frontend/src/components/AdminDashboard.vue b/meshmap_frontend/src/components/AdminDashboard.vue index 1de7074..f63b78b 100644 --- a/meshmap_frontend/src/components/AdminDashboard.vue +++ b/meshmap_frontend/src/components/AdminDashboard.vue @@ -1,6 +1,7 @@