feat(client): add authorized WireGuard device enrollment and desktop connection
This commit is contained in:
1 parent
d2f1f5040e
commit
6dd3c7dd45
25 files changed
+3274
-131
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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": "日志中心",
|
||||
|
||||
@@ -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' } }
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user