feat: 新增管理员踢下线 VPN 客户端功能
- 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
This commit is contained in:
@@ -148,6 +148,9 @@ export default {
|
|||||||
ipv6Address: 'IPv6 Address (optional)',
|
ipv6Address: 'IPv6 Address (optional)',
|
||||||
selectUserAndIp: 'Please select a user and fill in at least one IP address',
|
selectUserAndIp: 'Please select a user and fill in at least one IP address',
|
||||||
confirmDeleteReservation: 'Delete this reservation?',
|
confirmDeleteReservation: 'Delete this reservation?',
|
||||||
|
kick: 'Kick',
|
||||||
|
confirmKick: 'Disconnect all VPN connections for user {username}?',
|
||||||
|
kickFailed: 'Failed to kick user',
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
lastCommit: 'Last commit',
|
lastCommit: 'Last commit',
|
||||||
|
|||||||
@@ -147,6 +147,9 @@ export default {
|
|||||||
ipv6Address: 'IPv6 地址 (可选)',
|
ipv6Address: 'IPv6 地址 (可选)',
|
||||||
selectUserAndIp: '请选择用户并至少填写一个 IP 地址',
|
selectUserAndIp: '请选择用户并至少填写一个 IP 地址',
|
||||||
confirmDeleteReservation: '确认删除该预留?',
|
confirmDeleteReservation: '确认删除该预留?',
|
||||||
|
kick: '踢下线',
|
||||||
|
confirmKick: '确定要断开用户 {username} 的所有 VPN 连接吗?',
|
||||||
|
kickFailed: '踢下线失败',
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
lastCommit: '最后提交',
|
lastCommit: '最后提交',
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface Settings {
|
|||||||
do_remote_ip_config: boolean
|
do_remote_ip_config: boolean
|
||||||
}
|
}
|
||||||
interface ClientInfo {
|
interface ClientInfo {
|
||||||
|
user_id: number
|
||||||
username: string
|
username: string
|
||||||
ip: string
|
ip: string
|
||||||
ip6?: 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 {
|
function checkTunCreate(): boolean | null {
|
||||||
if (!diag.value) return null
|
if (!diag.value) return null
|
||||||
return diag.value.tun_create.startsWith('ok')
|
return diag.value.tun_create.startsWith('ok')
|
||||||
@@ -425,17 +441,26 @@ onMounted(() => {
|
|||||||
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.ipv4') }}</th>
|
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.ipv4') }}</th>
|
||||||
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.ipv6') }}</th>
|
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.ipv6') }}</th>
|
||||||
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.connectTime') }}</th>
|
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.connectTime') }}</th>
|
||||||
|
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('common.actions') }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-if="!status?.clients?.length">
|
<tr v-if="!status?.clients?.length">
|
||||||
<td colspan="4" class="px-6 py-6 text-center text-gray-400">{{ t('vpn.noOnlineClients') }}</td>
|
<td colspan="5" class="px-6 py-6 text-center text-gray-400">{{ t('vpn.noOnlineClients') }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-for="(c, i) in status?.clients" :key="i" class="border-b border-gray-100 dark:border-gray-700/50">
|
<tr v-for="(c, i) in status?.clients" :key="i" class="border-b border-gray-100 dark:border-gray-700/50">
|
||||||
<td class="px-6 py-3 text-gray-900 dark:text-white font-medium">{{ c.username }}</td>
|
<td class="px-6 py-3 text-gray-900 dark:text-white font-medium">{{ c.username }}</td>
|
||||||
<td class="px-6 py-3 text-gray-700 dark:text-gray-300">{{ c.ip }}</td>
|
<td class="px-6 py-3 text-gray-700 dark:text-gray-300">{{ c.ip }}</td>
|
||||||
<td class="px-6 py-3 text-gray-700 dark:text-gray-300">{{ c.ip6 || '—' }}</td>
|
<td class="px-6 py-3 text-gray-700 dark:text-gray-300">{{ c.ip6 || '—' }}</td>
|
||||||
<td class="px-6 py-3 text-gray-500 dark:text-gray-400">{{ c.connected_at }}</td>
|
<td class="px-6 py-3 text-gray-500 dark:text-gray-400">{{ c.connected_at }}</td>
|
||||||
|
<td class="px-6 py-3">
|
||||||
|
<button
|
||||||
|
class="px-3 py-1 text-xs rounded-md font-medium text-red-700 bg-red-50 hover:bg-red-100 dark:text-red-400 dark:bg-red-900/20 transition-colors"
|
||||||
|
@click="handleKick(c.user_id, c.username)"
|
||||||
|
>
|
||||||
|
{{ t('vpn.kick') }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -406,3 +406,26 @@ func DeleteVpnReservation(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
|
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})
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ func Setup(r *gin.Engine) {
|
|||||||
admin.GET("/vpn/reservations", handler.ListVpnReservations)
|
admin.GET("/vpn/reservations", handler.ListVpnReservations)
|
||||||
admin.POST("/vpn/reservations", handler.CreateVpnReservation)
|
admin.POST("/vpn/reservations", handler.CreateVpnReservation)
|
||||||
admin.DELETE("/vpn/reservations/:id", handler.DeleteVpnReservation)
|
admin.DELETE("/vpn/reservations/:id", handler.DeleteVpnReservation)
|
||||||
|
admin.DELETE("/vpn/clients/:id", handler.KickUserClient)
|
||||||
}
|
}
|
||||||
|
|
||||||
distDir := http.Dir("./dist")
|
distDir := http.Dir("./dist")
|
||||||
|
|||||||
@@ -374,10 +374,26 @@ func (s *VpnService) ClientList() []ClientInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ClientInfo struct {
|
type ClientInfo struct {
|
||||||
|
UserID uint `json:"user_id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
IP6 string `json:"ip6,omitempty"`
|
IP6 string `json:"ip6,omitempty"`
|
||||||
ConnectedAt string `json:"connected_at"`
|
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
|
var VPN *VpnService
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ func (c *tunnelConn) close() {
|
|||||||
|
|
||||||
func (c *tunnelConn) info() ClientInfo {
|
func (c *tunnelConn) info() ClientInfo {
|
||||||
ci := ClientInfo{
|
ci := ClientInfo{
|
||||||
|
UserID: c.user.ID,
|
||||||
Username: c.user.Username,
|
Username: c.user.Username,
|
||||||
IP: c.assignedIP.String(),
|
IP: c.assignedIP.String(),
|
||||||
ConnectedAt: c.connectedAt.Format("2006-01-02 15:04:05"),
|
ConnectedAt: c.connectedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
|||||||
Reference in New Issue
Block a user