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"),