批量删除节点,一键屏蔽ip
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 上 preventDefault,Leaflet 的事件包装层不会阻止浏览器默认菜单。
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user