feat(client): add authorized WireGuard device enrollment and desktop connection

This commit is contained in:
opsx committed 2026-10-21 14:53:55 +08:00
1 parent d2f1f5040e
commit 6dd3c7dd45
25 files changed
+3274 -131

No files matched your search

+16
View File
@@ -334,6 +334,7 @@
},
"users": {
"title": "User Management",
"clientLoginHint": "These accounts sign in to the admin console. Desktop VPN clients use VPN dial-in users assigned to a WireGuard line.",
"batchImport": "Batch Import",
"ldapImport": "LDAP Import",
"addUser": "Add User",
@@ -396,6 +397,20 @@
"emptyUsername": "(empty)",
"errorDetails": "Error details:"
},
"clientDevices": {
"title": "Client Devices",
"hint": "Devices registered by desktop/mobile clients; revocation takes effect immediately",
"deviceName": "Device",
"platform": "Platform",
"tunnel": "Line",
"address": "Assigned IP",
"lastHandshake": "Last Handshake",
"revoked": "Revoked",
"revoke": "Revoke",
"revokeConfirm": "The device will be disconnected immediately and cannot reconnect. Continue?",
"revokeDone": "Device revoked",
"empty": "No client devices registered"
},
"vpnUsers": {
"title": "VPN Dial-in Users",
"mfaHint": "OpenVPN Two-Factor Auth (TOTP / Static Challenge / PIN+OTP)",
@@ -718,6 +733,7 @@
"nodes": "Nodes",
"users": "Users",
"vpnUsers": "VPN Dial-in Users",
"clientDevices": "Client Devices",
"plugins": "Protocol Plugins",
"settings": "Settings",
"logs": "Log Center",
+16
View File
@@ -334,6 +334,7 @@
},
"users": {
"title": "管理用户",
"clientLoginHint": "此处账号用于登录管理后台;桌面 VPN 客户端请使用“VPN 拨号用户”中的账号,并分配 WireGuard 专线。",
"batchImport": "批量导入",
"ldapImport": "LDAP 导入",
"addUser": "添加用户",
@@ -396,6 +397,20 @@
"emptyUsername": "(空)",
"errorDetails": "失败详情:"
},
"clientDevices": {
"title": "客户端设备",
"hint": "桌面/移动客户端登记的接入设备;撤销后立即失去接入能力",
"deviceName": "设备名",
"platform": "平台",
"tunnel": "所属线路",
"address": "分配地址",
"lastHandshake": "最近握手",
"revoked": "已撤销",
"revoke": "撤销",
"revokeConfirm": "撤销后该设备立即断开且无法重连,确认撤销?",
"revokeDone": "设备已撤销",
"empty": "暂无客户端设备登记"
},
"vpnUsers": {
"title": "VPN 拨号用户",
"mfaHint": "OpenVPN 双因素认证(TOTP / 静态挑战 / PIN+OTP)",
@@ -718,6 +733,7 @@
"nodes": "节点管理",
"users": "用户管理",
"vpnUsers": "VPN 拨号用户",
"clientDevices": "客户端设备",
"plugins": "协议插件",
"settings": "系统设置",
"logs": "日志中心",
+1
View File
@@ -18,6 +18,7 @@ const routes = [
{ path: 'ipsec-peer', component: () => import('./views/IpsecPeer.vue'), meta: { titleKey: 'router.ipsecPeer' } },
{ path: 'users', component: () => import('./views/Users.vue'), meta: { titleKey: 'router.users', requiresAdmin: true } },
{ path: 'vpn-users', component: () => import('./views/VPNUsers.vue'), meta: { titleKey: 'router.vpnUsers', requiresAdmin: true } },
{ path: 'client-devices', component: () => import('./views/ClientDevices.vue'), meta: { titleKey: 'router.clientDevices', requiresAdmin: true } },
{ path: 'settings', component: () => import('./views/Settings.vue'), meta: { titleKey: 'router.settings' } },
{ path: ':pathMatch(.*)*', component: () => import('./views/NotFound.vue'), meta: { titleKey: 'router.notFound' } }
]
+77
View File
@@ -0,0 +1,77 @@
<template>
<el-card class="page-card" shadow="never">
<template #header>
<div class="vpn-header">
<div>
<b>{{ $t('clientDevices.title') }}</b>
<span style="margin-left:8px;color:#909399;font-size:12px">
{{ $t('clientDevices.hint') }}
</span>
</div>
<el-button :icon="Refresh" circle :loading="loading" @click="load" />
</div>
</template>
<el-table :data="devices" size="small" v-loading="loading">
<el-table-column prop="username" :label="$t('common.username')" width="140" />
<el-table-column prop="name" :label="$t('clientDevices.deviceName')" min-width="150" />
<el-table-column prop="platform" :label="$t('clientDevices.platform')" width="100" />
<el-table-column prop="tunnel_name" :label="$t('clientDevices.tunnel')" min-width="140" />
<el-table-column prop="client_address" :label="$t('clientDevices.address')" width="130" />
<el-table-column :label="$t('common.traffic')" width="170">
<template #default="{ row }">{{ fmtBytes(row.rx_bytes) }} / {{ fmtBytes(row.tx_bytes) }}</template>
</el-table-column>
<el-table-column :label="$t('clientDevices.lastHandshake')" width="170">
<template #default="{ row }">{{ row.last_seen_at ? new Date(row.last_seen_at).toLocaleString() : '-' }}</template>
</el-table-column>
<el-table-column :label="$t('common.status')" width="90">
<template #default="{ row }">
<el-tag :type="row.revoked ? 'info' : 'success'" size="small">
{{ row.revoked ? $t('clientDevices.revoked') : $t('common.enabled') }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="$t('common.operation')" width="110" fixed="right">
<template #default="{ row }">
<el-popconfirm v-if="!row.revoked" :title="$t('clientDevices.revokeConfirm')" @confirm="revoke(row)">
<template #reference>
<el-button type="danger" size="small" plain>{{ $t('clientDevices.revoke') }}</el-button>
</template>
</el-popconfirm>
<span v-else style="color:#c0c4cc">—</span>
</template>
</el-table-column>
<template #empty>{{ $t('clientDevices.empty') }}</template>
</el-table>
</el-card>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { Refresh } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import api from '../api'
import { fmtBytes } from '../utils/format'
const { t } = useI18n()
const devices = ref([])
const loading = ref(false)
async function load() {
loading.value = true
try {
const { data } = await api.get('/client-devices')
devices.value = data.devices || []
} finally {
loading.value = false
}
}
async function revoke(row) {
await api.post(`/client-devices/${row.id}/revoke`)
ElMessage.success(t('clientDevices.revokeDone'))
load()
}
onMounted(load)
</script>
+1
View File
@@ -15,6 +15,7 @@
</el-menu-item-group>
<el-menu-item-group v-if="auth.isAdmin()" :title="$t('router.sidebarResource')">
<el-menu-item index="/vpn-users"><el-icon><Iphone /></el-icon>{{ $t('router.vpnUsers') }}</el-menu-item>
<el-menu-item index="/client-devices"><el-icon><Monitor /></el-icon>{{ $t('router.clientDevices') }}</el-menu-item>
</el-menu-item-group>
<el-menu-item-group :title="$t('router.sidebarSystem')">
<el-menu-item v-if="auth.isAdmin()" index="/users"><el-icon><UserFilled /></el-icon>{{ $t('router.users') }}</el-menu-item>
+3
View File
@@ -2,6 +2,9 @@
<el-row :gutter="16">
<el-col :xs="24" :lg="15">
<el-card class="page-card" shadow="never">
<el-alert :title="$t('users.clientLoginHint')" type="info" :closable="false" style="margin-bottom:12px">
<template #default><router-link to="/vpn-users">{{ $t('router.vpnUsers') }} →</router-link></template>
</el-alert>
<template #header>
<div class="row-between">
<b>{{ $t('users.title') }}</b>
+5 -4
View File
@@ -38,7 +38,7 @@
<el-table-column prop="source" :label="$t('common.source')" width="90" />
<el-table-column :label="$t('vpnUsers.otpMfa')" width="130">
<template #default="{ row }">
<template v-if="isOpenVPN(row.tunnel_id)">
<template v-if="supportsMFA(row.tunnel_id)">
<el-tag :type="row.mfa_enabled ? 'success' : 'info'" size="small">
{{ row.mfa_enabled ? $t('vpnUsers.bound') : $t('vpnUsers.unbound') }}
</el-tag>
@@ -61,8 +61,8 @@
</el-table-column>
<el-table-column :label="$t('common.operation')" width="280">
<template #default="{ row }">
<el-button link size="small" @click="download(row)">{{ $t('vpnUsers.downloadConfig') }}</el-button>
<el-button v-if="isOpenVPN(row.tunnel_id)" link size="small" type="primary" @click="openOTPManager(row)">{{ $t('vpnUsers.otpManagement') }}</el-button>
<el-button v-if="tunnels.find(x => x.id === row.tunnel_id)?.protocol !== 'wireguard'" link size="small" @click="download(row)">{{ $t('vpnUsers.downloadConfig') }}</el-button>
<el-button v-if="supportsMFA(row.tunnel_id)" link size="small" type="primary" @click="openOTPManager(row)">{{ $t('vpnUsers.otpManagement') }}</el-button>
<el-button link size="small" @click="toggle(row)">{{ row.enabled ? $t('common.disabled') : $t('common.enabled') }}</el-button>
<el-button type="danger" link size="small" @click="remove(row)">{{ $t('common.delete') }}</el-button>
</template>
@@ -201,6 +201,7 @@ const tunnelProto = id => {
return tun ? t(protocolDisplay(tun.protocol)) : ''
}
const isOpenVPN = id => tunnels.value.find(x => x.id === id)?.protocol === 'openvpn'
const supportsMFA = id => ['openvpn', 'wireguard'].includes(tunnels.value.find(x => x.id === id)?.protocol)
const isLocked = row => row.mfa_locked_until && new Date(row.mfa_locked_until) > new Date()
const filteredUsers = computed(() => users.value.filter(user => {
const matchKeyword = !keyword.value || [user.username, user.display_name, user.email].some(v => (v || '').toLowerCase().includes(keyword.value.toLowerCase()))
@@ -212,7 +213,7 @@ async function load() {
try {
const [u, all] = await Promise.all([api.get('/vpn-users'), api.get('/tunnels')])
users.value = u.data || []
tunnels.value = (all.data || []).filter(tun => ['openvpn', 'l2tp', 'ikev2'].includes(tun.protocol))
tunnels.value = (all.data || []).filter(tun => ['openvpn', 'l2tp', 'ikev2', 'wireguard'].includes(tun.protocol))
} finally { loading.value = false }
}