批量删除节点,一键屏蔽ip

This commit is contained in:
2026-06-19 20:37:19 +08:00
parent b56cfb7b1e
commit 2afd890de4
6 changed files with 328 additions and 1 deletions
+42
View File
@@ -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
}
+61
View File
@@ -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 {
+52
View File
@@ -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<MapRenderable, { type: 'node' }> => 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"
/>
</section>
+15
View File
@@ -244,6 +244,21 @@ export function getAdminMqttStatus(): Promise<AdminMqttStatus> {
return getJSON<AdminMqttStatus>('/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<AdminRuntimeSettingsResponse> {
return getJSON<AdminRuntimeSettingsResponse>('/api/admin/runtime-settings')
}
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { getAdminMqttStatus, getAdminRuntimeSettings, updateAdminRuntimeSettings } from '../api'
import { disconnectAndBlockMqttClient, getAdminMqttStatus, getAdminRuntimeSettings, updateAdminRuntimeSettings } from '../api'
import ConfirmDeleteModal from './ConfirmDeleteModal.vue'
import type { AdminMqttClient, AdminMqttStatus, AdminRuntimeSettings } from '../types'
const status = ref<AdminMqttStatus | null>(null)
@@ -10,6 +11,10 @@ const settingsLoading = ref(false)
const error = ref('')
const settingsError = ref('')
const settingsMessage = ref('')
const clientActionMessage = ref('')
const clientActionError = ref('')
const pendingClient = ref<AdminMqttClient | null>(null)
const clientActionInProgress = ref(false)
let timer: number | undefined
type ClientSortKey = 'client_id' | 'username' | 'listener' | 'remote_addr' | 'packets_in' | 'packets_out'
@@ -100,6 +105,64 @@ async function saveEncryptedForwarding(value: boolean) {
}
}
function requestDisconnectAndBlock(client: AdminMqttClient) {
clientActionMessage.value = ''
clientActionError.value = ''
pendingClient.value = client
}
function cancelClientAction() {
if (clientActionInProgress.value) {
return
}
pendingClient.value = null
}
async function confirmClientAction(payload: { reason?: string }) {
const client = pendingClient.value
if (!client) {
return
}
const reason = payload.reason?.trim()
if (!reason) {
return
}
clientActionInProgress.value = true
clientActionError.value = ''
clientActionMessage.value = ''
try {
const result = await disconnectAndBlockMqttClient(client.client_id, { reason })
const ipText = result.ip_value || client.remote_addr || '-'
const ruleText = result.ip_rule_created ? '已新增屏蔽规则' : '屏蔽规则已存在'
const disconnectText = result.disconnected ? '已断开连接' : '连接已不存在'
clientActionMessage.value = `${disconnectText}IP ${ipText} ${ruleText}`
pendingClient.value = null
await refreshStatus()
} catch (err) {
clientActionError.value = err instanceof Error ? err.message : String(err)
} finally {
clientActionInProgress.value = false
}
}
const pendingClientLabel = computed(() => {
const client = pendingClient.value
if (!client) {
return ''
}
const ip = client.remote_addr || '-'
return `Client ID: ${client.client_id || '-'}\n远端: ${ip}`
})
const clientActionMessageText = computed(() => {
const client = pendingClient.value
if (!client) {
return ''
}
const ip = client.remote_addr ? client.remote_addr.replace(/:\d+$/, '') : '该客户端的 IP'
return `确定要立即断开 MQTT 客户端 “${client.client_id || '-'}” 并将 ${ip} 加入 IP 屏蔽表吗?此操作会立即生效,请输入屏蔽原因。\n\n${pendingClientLabel.value}`
})
onMounted(() => {
refreshStatus()
refreshRuntimeSettings()
@@ -199,6 +262,7 @@ onBeforeUnmount(() => {
<th class="sortable" @click="toggleClientSort('remote_addr')">Remote Addr <span class="sort-indicator">{{ sortIndicator('remote_addr') }}</span></th>
<th class="sortable" @click="toggleClientSort('packets_in')">客户端服务器 <span class="sort-indicator">{{ sortIndicator('packets_in') }}</span></th>
<th class="sortable" @click="toggleClientSort('packets_out')">服务器客户端 <span class="sort-indicator">{{ sortIndicator('packets_out') }}</span></th>
<th>操作</th>
</tr>
</thead>
<tbody>
@@ -209,12 +273,34 @@ onBeforeUnmount(() => {
<td>{{ client.remote_addr || '-' }}</td>
<td>{{ client.packets_in ?? 0 }}</td>
<td>{{ client.packets_out ?? 0 }}</td>
<td>
<button
type="button"
class="client-danger-action"
:disabled="clientActionInProgress"
@click="requestDisconnectAndBlock(client)"
>断开并屏蔽 IP</button>
</td>
</tr>
</tbody>
</table>
<div v-if="!status?.clients?.length" class="empty">暂无客户端连接</div>
<p v-if="clientActionMessage" class="success client-action-feedback">{{ clientActionMessage }}</p>
<p v-if="clientActionError" class="error client-action-feedback">{{ clientActionError }}</p>
</div>
</div>
<ConfirmDeleteModal
:open="!!pendingClient"
title="确认断开并屏蔽 IP"
:message="clientActionMessageText"
confirm-text="断开并屏蔽"
:require-reason="true"
reason-label="屏蔽原因"
reason-placeholder="请输入屏蔽原因必填"
@cancel="cancelClientAction"
@confirm="confirmClientAction"
/>
</section>
</template>
@@ -403,4 +489,31 @@ onBeforeUnmount(() => {
font-size: 11px;
color: var(--color-primary);
}
.client-danger-action {
border: 1px solid color-mix(in srgb, var(--color-danger, #d04848) 36%, white);
border-radius: 6px;
padding: 4px 10px;
background: color-mix(in srgb, var(--color-danger, #d04848) 10%, white);
color: var(--color-danger, #d04848);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.16s ease, border-color 0.16s ease, color 0.16s ease;
}
.client-danger-action:hover:not(:disabled) {
background: var(--color-danger, #d04848);
color: #fff;
}
.client-danger-action:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.client-action-feedback {
margin: 0.5rem 0 0;
white-space: pre-line;
}
</style>
@@ -25,6 +25,7 @@ const emit = defineEmits<{
'clear-node': []
'delete-node': [nodeId: string]
'purge-node': [nodeId: string]
'delete-displayed-nodes': []
'delete-and-block-node': [payload: { nodeId: string; nodeNum: number | null }]
'bounds-change': [payload: MapBoundsChangePayload]
'map-source-change': [sourceId: number]
@@ -32,6 +33,7 @@ const emit = defineEmits<{
const mapEl = ref<HTMLElement | null>(null)
const menuNode = ref<MapNode | null>(null)
const menuMap = ref(false)
const menuX = ref(0)
const menuY = ref(0)
const lastRaisedNodeId = ref<string | null>(null)
@@ -73,6 +75,12 @@ onMounted(async () => {
shuffledSelectedNodeIds.clear()
emit('clear-node')
})
// 地图空白处右键:在管理员视图下展示「删除所有显示的节点」入口。
// 必须在原生 contextmenu 上 preventDefaultLeaflet 的事件包装层不会阻止浏览器默认菜单。
mapEl.value.addEventListener('contextmenu', handleMapContextMenu)
map.on('contextmenu', (event) => {
L.DomEvent.stopPropagation(event)
})
map.on('moveend', emitBoundsChange)
markerLayer = L.layerGroup().addTo(map)
renderMarkers(true)
@@ -82,6 +90,7 @@ onMounted(async () => {
onBeforeUnmount(() => {
window.removeEventListener('click', closeNodeMenu)
window.removeEventListener('keydown', handleKeydown)
mapEl.value?.removeEventListener('contextmenu', handleMapContextMenu)
map?.remove()
map = null
tileLayer = null
@@ -125,6 +134,33 @@ function applyTileLayer() {
function closeNodeMenu() {
menuNode.value = null
menuMap.value = false
}
function openMapMenu(event: MouseEvent) {
if (!props.isAdmin) {
return
}
menuNode.value = null
menuMap.value = true
menuX.value = event.clientX
menuY.value = event.clientY
}
function handleMapContextMenu(event: MouseEvent) {
// 总是阻止浏览器默认菜单。即使非管理员也阻止,避免在 marker 上右键漏到地图后又弹默认菜单。
event.preventDefault()
// marker 自身已通过 marker.on('contextmenu') 处理;这里仅响应空白处。
const target = event.target as HTMLElement | null
if (target?.closest('.leaflet-marker-icon, .leaflet-popup, .map-source-control, .context-menu')) {
return
}
openMapMenu(event)
}
function deleteAllDisplayedNodes() {
emit('delete-displayed-nodes')
closeNodeMenu()
}
function nodeDetailHref(nodeId: string): string {
@@ -601,5 +637,13 @@ function escapeHTML(value: string): string {
<button v-if="isAdmin" class="danger" type="button" @click="purgeSelectedNode">删除节点</button>
<button v-if="isAdmin" class="danger" type="button" @click="deleteAndBlockSelectedNode">删除并屏蔽节点</button>
</div>
<div
v-if="menuMap && isAdmin"
class="context-menu"
:style="{ left: `${menuX}px`, top: `${menuY}px` }"
@click.stop
>
<button class="danger" type="button" @click="deleteAllDisplayedNodes">删除所有显示的节点</button>
</div>
</section>
</template>