From fea3fc62f7f875f659c070653c5e72070d7ba599 Mon Sep 17 00:00:00 2001 From: kevin Date: Fri, 10 Jul 2026 13:01:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=E8=B8=A2=E4=B8=8B=E7=BA=BF=20VPN=20=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - vpn/service.go: ClientInfo 增加 user_id 字段,新增 KickUser 方法 用读锁收集目标连接后逐个关闭 WebSocket,返回断开数量 - vpn/tunnel.go: info() 填充 UserID - handler/vpn.go: 新增 KickUserClient handler,校验用户存在后调用 KickUser - router.go: 注册 DELETE /api/admin/vpn/clients/:id - VpnView.vue: 在线客户端表格新增操作列与踢下线按钮,handleKick 确认后调用 API 并刷新 - zh.ts/en.ts: 新增 kick/confirmKick/kickFailed 国际化 key --- frontend/src/locales/en.ts | 3 +++ frontend/src/locales/zh.ts | 3 +++ frontend/src/views/VpnView.vue | 27 ++++++++++++++++++++++++++- internal/handler/vpn.go | 23 +++++++++++++++++++++++ internal/router/router.go | 1 + internal/vpn/service.go | 16 ++++++++++++++++ internal/vpn/tunnel.go | 1 + 7 files changed, 73 insertions(+), 1 deletion(-) diff --git a/frontend/src/locales/en.ts b/frontend/src/locales/en.ts index 61010a4..e11cd79 100644 --- a/frontend/src/locales/en.ts +++ b/frontend/src/locales/en.ts @@ -148,6 +148,9 @@ export default { ipv6Address: 'IPv6 Address (optional)', selectUserAndIp: 'Please select a user and fill in at least one IP address', confirmDeleteReservation: 'Delete this reservation?', + kick: 'Kick', + confirmKick: 'Disconnect all VPN connections for user {username}?', + kickFailed: 'Failed to kick user', }, footer: { lastCommit: 'Last commit', diff --git a/frontend/src/locales/zh.ts b/frontend/src/locales/zh.ts index c055660..d281c58 100644 --- a/frontend/src/locales/zh.ts +++ b/frontend/src/locales/zh.ts @@ -147,6 +147,9 @@ export default { ipv6Address: 'IPv6 地址 (可选)', selectUserAndIp: '请选择用户并至少填写一个 IP 地址', confirmDeleteReservation: '确认删除该预留?', + kick: '踢下线', + confirmKick: '确定要断开用户 {username} 的所有 VPN 连接吗?', + kickFailed: '踢下线失败', }, footer: { lastCommit: '最后提交', diff --git a/frontend/src/views/VpnView.vue b/frontend/src/views/VpnView.vue index 321312b..22636f9 100644 --- a/frontend/src/views/VpnView.vue +++ b/frontend/src/views/VpnView.vue @@ -18,6 +18,7 @@ interface Settings { do_remote_ip_config: boolean } interface ClientInfo { + user_id: number username: string ip: string ip6?: string @@ -201,6 +202,21 @@ async function handleDeleteResv(id: number) { } } +async function handleKick(userId: number, username: string) { + if (!confirm(t('vpn.confirmKick', { username }))) return + try { + const res = await fetch(`/api/admin/vpn/clients/${userId}`, { + method: 'DELETE', + headers: authHeader(), + }) + const data = await res.json() + if (!res.ok) throw new Error(data.error || t('vpn.kickFailed')) + await fetchStatus() + } catch (e: any) { + error.value = e.message + } +} + function checkTunCreate(): boolean | null { if (!diag.value) return null return diag.value.tun_create.startsWith('ok') @@ -425,17 +441,26 @@ onMounted(() => { {{ t('vpn.ipv4') }} {{ t('vpn.ipv6') }} {{ t('vpn.connectTime') }} + {{ t('common.actions') }} - {{ t('vpn.noOnlineClients') }} + {{ t('vpn.noOnlineClients') }} {{ c.username }} {{ c.ip }} {{ c.ip6 || '—' }} {{ c.connected_at }} + + + diff --git a/internal/handler/vpn.go b/internal/handler/vpn.go index d69cf2b..148bd92 100644 --- a/internal/handler/vpn.go +++ b/internal/handler/vpn.go @@ -406,3 +406,26 @@ func DeleteVpnReservation(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"message": "删除成功"}) } + +func KickUserClient(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) + return + } + + var user model.User + if err := db.DB.First(&user, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"}) + return + } + + if vpn.VPN == nil || !vpn.VPN.Running() { + c.JSON(http.StatusOK, gin.H{"message": "VPN 服务未运行", "kicked": 0}) + return + } + + n := vpn.VPN.KickUser(uint(id)) + c.JSON(http.StatusOK, gin.H{"message": "已断开用户连接", "kicked": n}) +} diff --git a/internal/router/router.go b/internal/router/router.go index 34188ff..8b161d6 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -46,6 +46,7 @@ func Setup(r *gin.Engine) { admin.GET("/vpn/reservations", handler.ListVpnReservations) admin.POST("/vpn/reservations", handler.CreateVpnReservation) admin.DELETE("/vpn/reservations/:id", handler.DeleteVpnReservation) + admin.DELETE("/vpn/clients/:id", handler.KickUserClient) } distDir := http.Dir("./dist") diff --git a/internal/vpn/service.go b/internal/vpn/service.go index af09059..5492a30 100644 --- a/internal/vpn/service.go +++ b/internal/vpn/service.go @@ -374,10 +374,26 @@ func (s *VpnService) ClientList() []ClientInfo { } type ClientInfo struct { + UserID uint `json:"user_id"` Username string `json:"username"` IP string `json:"ip"` IP6 string `json:"ip6,omitempty"` ConnectedAt string `json:"connected_at"` } +func (s *VpnService) KickUser(userID uint) int { + s.mu.RLock() + var toClose []*tunnelConn + for c := range s.clients { + if c.user.ID == userID { + toClose = append(toClose, c) + } + } + s.mu.RUnlock() + for _, c := range toClose { + c.close() + } + return len(toClose) +} + var VPN *VpnService diff --git a/internal/vpn/tunnel.go b/internal/vpn/tunnel.go index 6b3ce4f..30a7480 100644 --- a/internal/vpn/tunnel.go +++ b/internal/vpn/tunnel.go @@ -79,6 +79,7 @@ func (c *tunnelConn) close() { func (c *tunnelConn) info() ClientInfo { ci := ClientInfo{ + UserID: c.user.ID, Username: c.user.Username, IP: c.assignedIP.String(), ConnectedAt: c.connectedAt.Format("2006-01-02 15:04:05"),