feat: implement VPN platform management and real protocol services
This commit is contained in:
commit
58c036c34c
45 files changed
+9244
No files matched your search
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
@@ -0,0 +1,40 @@
|
||||
import axios from 'axios'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
// 极简全局状态
|
||||
export const auth = reactive({
|
||||
token: localStorage.getItem('vpn_token') || '',
|
||||
username: localStorage.getItem('vpn_user') || '',
|
||||
login(token, username) {
|
||||
this.token = token
|
||||
this.username = username
|
||||
localStorage.setItem('vpn_token', token)
|
||||
localStorage.setItem('vpn_user', username)
|
||||
},
|
||||
logout() {
|
||||
this.token = ''
|
||||
this.username = ''
|
||||
localStorage.removeItem('vpn_token')
|
||||
localStorage.removeItem('vpn_user')
|
||||
}
|
||||
})
|
||||
|
||||
const api = axios.create({ baseURL: '/api', timeout: 30000 })
|
||||
|
||||
api.interceptors.request.use(cfg => {
|
||||
if (auth.token) cfg.headers.Authorization = `Bearer ${auth.token}`
|
||||
return cfg
|
||||
})
|
||||
|
||||
api.interceptors.response.use(
|
||||
res => res,
|
||||
err => {
|
||||
if (err.response?.status === 401) {
|
||||
auth.logout()
|
||||
if (location.pathname !== '/login') location.href = '/login'
|
||||
}
|
||||
return Promise.reject(err)
|
||||
}
|
||||
)
|
||||
|
||||
export default api
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import ElementPlus from 'element-plus'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import 'element-plus/dist/index.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(ElementPlus, { locale: zhCn })
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,117 @@
|
||||
// 各协议的创建向导表单定义(前端动态渲染)
|
||||
export const PROTOCOL_META = {
|
||||
wireguard: {
|
||||
name: 'WireGuard',
|
||||
desc: '性能最好的现代 VPN,内核级实现,漫游友好',
|
||||
color: '#88171a',
|
||||
icon: '⚡',
|
||||
scenes: '内部接入 / 移动办公',
|
||||
fields: [
|
||||
{ key: 'address', label: '服务端 VPN 网段地址', placeholder: '10.8.0.1/24', required: true, tip: '服务端在 VPN 网段中的地址(CIDR)' },
|
||||
{ key: 'listen_port', label: '监听端口', type: 'number', default: 51820 },
|
||||
{ key: 'client_address', label: '客户端地址', placeholder: '10.8.0.2/32', tip: '分配给首个客户端的地址' },
|
||||
{ key: 'public_endpoint', label: '公网接入点', placeholder: 'vpn.example.com:51820', tip: '客户端连接用,公网 IP:端口' },
|
||||
{ key: 'dns', label: 'DNS', default: '223.5.5.5' },
|
||||
{ key: 'allowed_ips', label: '客户端 AllowedIPs', default: '0.0.0.0/0, ::/0', tip: '全流量代理用 0.0.0.0/0;仅内网填内网网段' }
|
||||
]
|
||||
},
|
||||
ipsec: {
|
||||
name: 'IPSec 站点互联',
|
||||
desc: '与第三方设备(Cisco/华为/飞塔等)做站点到站点对接',
|
||||
color: '#1f6fb2',
|
||||
icon: '🔗',
|
||||
scenes: '第三方对接 / 站点互联',
|
||||
fields: [
|
||||
{ key: 'public_endpoint', label: '本端公网 IP', placeholder: '1.2.3.4', required: true },
|
||||
{ key: 'remote_gateway', label: '对端网关地址', placeholder: '5.6.7.8', required: true },
|
||||
{ key: 'local_subnets', label: '本端网段', placeholder: '192.168.1.0/24', required: true, tip: '多个网段用逗号分隔' },
|
||||
{ key: 'remote_subnets', label: '对端网段', placeholder: '10.0.0.0/24', required: true },
|
||||
{ key: 'psk', label: '预共享密钥 PSK', type: 'password', required: true, secret: true },
|
||||
{ key: 'ike_version', label: 'IKE 版本', type: 'select', options: ['ikev2', 'ikev1'], default: 'ikev2' },
|
||||
{ key: 'ike_proposal', label: 'Phase1 提议', placeholder: 'aes256-sha256-modp2048', tip: '加密-认证-DH组,需与对端一致' },
|
||||
{ key: 'esp_proposal', label: 'Phase2 提议', placeholder: 'aes256-sha256' },
|
||||
{ key: 'local_id', label: '本端标识 ID', placeholder: '@vpn-local' },
|
||||
{ key: 'remote_id', label: '对端标识 ID', placeholder: '@vpn-remote' }
|
||||
]
|
||||
},
|
||||
l2tp: {
|
||||
name: 'L2TP/IPSec',
|
||||
desc: '兼容性最好,手机/老设备/路由器原生支持',
|
||||
color: '#5a8a3c',
|
||||
icon: '📱',
|
||||
scenes: '移动设备 / 老设备兼容',
|
||||
fields: [
|
||||
{ key: 'public_endpoint', label: '服务器公网 IP', placeholder: '1.2.3.4', required: true },
|
||||
{ key: 'psk', label: 'IPSec 预共享密钥', type: 'password', required: true, secret: true },
|
||||
{ key: 'username', label: '拨号用户名', default: 'vpnuser' },
|
||||
{ key: 'password', label: '拨号密码', type: 'password', required: true, secret: true },
|
||||
{ key: 'ip_range', label: '客户端地址池', default: '10.9.0.100-10.9.0.200' },
|
||||
{ key: 'local_ip', label: '服务端隧道地址', default: '10.9.0.1' },
|
||||
{ key: 'dns', label: '下发 DNS', default: '223.5.5.5' }
|
||||
]
|
||||
},
|
||||
frp: {
|
||||
name: 'FRP 内网穿透',
|
||||
desc: '国内刚需,TCP/UDP/HTTP 端口映射与反向代理',
|
||||
color: '#c0762b',
|
||||
icon: '🚇',
|
||||
scenes: '内网穿透 / 端口映射',
|
||||
fields: [
|
||||
{ key: 'mode', label: '运行模式', type: 'select', options: ['server', 'client'], default: 'server', tip: 'server=frps(有公网),client=frpc(内网侧)' },
|
||||
{ key: 'bind_port', label: '绑定端口(server)', type: 'number', default: 7000, showIf: { mode: 'server' } },
|
||||
{ key: 'public_endpoint', label: '公网地址(server)', placeholder: '1.2.3.4', showIf: { mode: 'server' } },
|
||||
{ key: 'dashboard_port', label: '管理面板端口(server)', type: 'number', default: 7500, showIf: { mode: 'server' } },
|
||||
{ key: 'dashboard_user', label: '管理面板账号(server)', default: 'admin', showIf: { mode: 'server' } },
|
||||
{ key: 'dashboard_password', label: '管理面板密码(server)', type: 'password', secret: true, showIf: { mode: 'server' }, tip: '留空时自动生成强随机密码;编辑时留空保持原密码' },
|
||||
{ key: 'server_addr', label: 'frps 地址(client)', placeholder: '1.2.3.4', showIf: { mode: 'client' } },
|
||||
{ key: 'server_port', label: 'frps 端口(client)', type: 'number', default: 7000, showIf: { mode: 'client' } },
|
||||
{ key: 'token', label: '认证 Token', type: 'password', secret: true, tip: '留空自动生成' },
|
||||
{ key: 'proxy_name', label: '代理名称', placeholder: 'ssh' },
|
||||
{ key: 'proxy_type', label: '代理类型', type: 'select', options: ['tcp', 'udp', 'http', 'https'], default: 'tcp' },
|
||||
{ key: 'local_ip', label: '内网目标 IP', default: '127.0.0.1' },
|
||||
{ key: 'local_port', label: '内网目标端口', type: 'number', default: 22 },
|
||||
{ key: 'remote_port', label: '公网映射端口', type: 'number', default: 6000, showIf: { proxy_type: ['tcp', 'udp'] } },
|
||||
{ key: 'custom_domains', label: '绑定域名', placeholder: 'app.example.com, www.example.com', showIf: { proxy_type: ['http', 'https'] }, tip: 'HTTP/HTTPS 代理必填,多个域名用逗号分隔' }
|
||||
]
|
||||
},
|
||||
openvpn: {
|
||||
name: 'OpenVPN',
|
||||
desc: '最成熟的开源 VPN,兼容各类老项目与系统',
|
||||
color: '#e07b39',
|
||||
icon: '🛡️',
|
||||
scenes: '老项目兼容 / 通用接入',
|
||||
fields: [
|
||||
{ key: 'public_endpoint', label: '公网接入地址', placeholder: 'vpn.example.com', required: true },
|
||||
{ key: 'port', label: '端口', type: 'number', default: 1194 },
|
||||
{ key: 'proto', label: '传输协议', type: 'select', options: ['udp', 'tcp'], default: 'udp' },
|
||||
{ key: 'subnet', label: 'VPN 网段', default: '10.10.0.0 255.255.255.0' },
|
||||
{ key: 'cipher', label: '加密算法', type: 'select', options: ['AES-256-GCM', 'AES-128-GCM', 'AES-256-CBC'], default: 'AES-256-GCM' },
|
||||
{ key: 'dns', label: '下发 DNS', default: '223.5.5.5' }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义协议插件的默认表单(插件参数完全自由)
|
||||
export const CUSTOM_FIELDS = [
|
||||
{ key: 'endpoint', label: '接入地址', placeholder: 'host:port' },
|
||||
{ key: 'token', label: '认证凭据', type: 'password', secret: true },
|
||||
{ key: 'extra', label: '扩展参数(JSON)', placeholder: '{"key":"value"}' }
|
||||
]
|
||||
|
||||
export const PROTOCOL_COLORS = {
|
||||
wireguard: '#88171a',
|
||||
ipsec: '#1f6fb2',
|
||||
l2tp: '#5a8a3c',
|
||||
frp: '#c0762b',
|
||||
openvpn: '#e07b39'
|
||||
}
|
||||
|
||||
export function protocolDisplay(t) {
|
||||
if (t.startsWith('custom:')) return t.slice(7)
|
||||
return PROTOCOL_META[t]?.name || t
|
||||
}
|
||||
|
||||
export function protocolColor(t) {
|
||||
if (t.startsWith('custom:')) return '#7b5ea7'
|
||||
return PROTOCOL_COLORS[t] || '#606266'
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { auth } from './api'
|
||||
|
||||
const routes = [
|
||||
{ path: '/login', component: () => import('./views/Login.vue'), meta: { public: true } },
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('./views/Layout.vue'),
|
||||
redirect: '/dashboard',
|
||||
children: [
|
||||
{ path: 'dashboard', component: () => import('./views/Dashboard.vue'), meta: { title: '总览' } },
|
||||
{ path: 'tunnels', component: () => import('./views/Tunnels.vue'), meta: { title: '专线列表' } },
|
||||
{ path: 'tunnels/create', component: () => import('./views/TunnelWizard.vue'), meta: { title: '创建专线' } },
|
||||
{ path: 'tunnels/:id/edit', component: () => import('./views/TunnelEditor.vue'), meta: { title: '编辑专线' } },
|
||||
{ path: 'ipsec-peer', component: () => import('./views/IpsecPeer.vue'), meta: { title: 'IPSec 第三方对接' } },
|
||||
{ path: 'nodes', component: () => import('./views/Nodes.vue'), meta: { title: '节点管理' } },
|
||||
{ path: 'users', component: () => import('./views/Users.vue'), meta: { title: '用户与 LDAP' } },
|
||||
{ path: 'vpn-users', component: () => import('./views/VPNUsers.vue'), meta: { title: 'VPN 拨号用户' } },
|
||||
{ path: 'plugins', component: () => import('./views/Plugins.vue'), meta: { title: '协议插件' } },
|
||||
{ path: 'settings', component: () => import('./views/Settings.vue'), meta: { title: '系统设置' } }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({ history: createWebHistory(), routes })
|
||||
|
||||
router.beforeEach(to => {
|
||||
if (!to.meta.public && !auth.token) return '/login'
|
||||
if (to.path === '/login' && auth.token) return '/'
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,13 @@
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #app { height: 100%; margin: 0; font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif; }
|
||||
body { background: #f5f7fa; }
|
||||
|
||||
.stat-card { border-radius: 10px; padding: 20px; color: #fff; position: relative; overflow: hidden; }
|
||||
.stat-card .num { font-size: 32px; font-weight: 700; margin-top: 8px; }
|
||||
.stat-card .label { opacity: .85; font-size: 14px; }
|
||||
|
||||
.page-card { border-radius: 10px; }
|
||||
|
||||
.mono { font-family: 'JetBrains Mono', Consolas, monospace; font-size: 12px; white-space: pre-wrap; word-break: break-all; }
|
||||
|
||||
.protocol-tag { font-weight: 600; }
|
||||
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="6"><div class="stat-card" style="background:linear-gradient(135deg,#4a6fa5,#2b4a7a)">
|
||||
<div class="label">专线总数</div><div class="num">{{ ov.total ?? '-' }}</div></div></el-col>
|
||||
<el-col :span="6"><div class="stat-card" style="background:linear-gradient(135deg,#4caf7d,#2e7d5b)">
|
||||
<div class="label">运行中</div><div class="num">{{ ov.running ?? '-' }}</div></div></el-col>
|
||||
<el-col :span="6"><div class="stat-card" style="background:linear-gradient(135deg,#e0935a,#c0762b)">
|
||||
<div class="label">节点 / 插件</div><div class="num">{{ ov.nodes ?? 0 }} / {{ ov.plugins ?? 0 }}</div></div></el-col>
|
||||
<el-col :span="6"><div class="stat-card" style="background:linear-gradient(135deg,#7b5ea7,#5a4080)">
|
||||
<div class="label">累计流量 ↓↑</div><div class="num" style="font-size:22px">{{ fmtBytes(ov.rx_bytes) }} / {{ fmtBytes(ov.tx_bytes) }}</div></div></el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="16" style="margin-top:16px">
|
||||
<el-col :span="10">
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header><b>协议分布</b></template>
|
||||
<div ref="pieRef" style="height:280px"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="14">
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header><b>协议引擎状态</b> <span class="dep-hint">系统依赖检测结果</span></template>
|
||||
<el-table :data="protocols" size="large">
|
||||
<el-table-column label="协议" width="180">
|
||||
<template #default="{ row }">
|
||||
<span class="protocol-tag" :style="{ color: protocolColor(row.name) }">{{ row.display_name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="依赖状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.available ? 'success' : 'warning'" size="small">
|
||||
{{ row.available ? '已就绪' : '未部署' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="dependency" label="说明" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-card class="page-card" shadow="never" style="margin-top:16px" v-if="ov.expiring?.length">
|
||||
<template #header><b style="color:#e6a23c">⚠ 即将到期的专线(7 天内)</b></template>
|
||||
<el-table :data="ov.expiring" size="small">
|
||||
<el-table-column prop="name" label="名称" />
|
||||
<el-table-column label="协议">
|
||||
<template #default="{ row }">{{ protocolDisplay(row.protocol) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="到期时间">
|
||||
<template #default="{ row }">{{ fmtTime(row.expire_at) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import api from '../api'
|
||||
import { protocolColor, protocolDisplay } from '../protocols'
|
||||
|
||||
const ov = ref({})
|
||||
const protocols = ref([])
|
||||
const pieRef = ref(null)
|
||||
let chart = null
|
||||
|
||||
function fmtBytes(n) {
|
||||
if (!n) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let i = 0
|
||||
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++ }
|
||||
return n.toFixed(1) + ' ' + units[i]
|
||||
}
|
||||
function fmtTime(t) { return t ? new Date(t).toLocaleString('zh-CN') : '-' }
|
||||
|
||||
async function load() {
|
||||
const [{ data: o }, { data: p }] = await Promise.all([api.get('/overview'), api.get('/protocols')])
|
||||
ov.value = o
|
||||
protocols.value = p
|
||||
renderPie()
|
||||
}
|
||||
|
||||
function renderPie() {
|
||||
if (!pieRef.value) return
|
||||
if (!chart) chart = echarts.init(pieRef.value)
|
||||
const dist = (ov.value.protocol_distribution || []).map(d => ({
|
||||
name: protocolDisplay(d.Protocol || d.protocol),
|
||||
value: d.Count || d.count,
|
||||
itemStyle: { color: protocolColor(d.Protocol || d.protocol) }
|
||||
}))
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { bottom: 0 },
|
||||
series: [{
|
||||
type: 'pie', radius: ['45%', '70%'], center: ['50%', '45%'],
|
||||
label: { formatter: '{b}: {c}' },
|
||||
data: dist.length ? dist : [{ name: '暂无专线', value: 1, itemStyle: { color: '#e0e3e8' } }]
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
let timer
|
||||
onMounted(() => { load(); timer = setInterval(load, 15000) })
|
||||
onUnmounted(() => { clearInterval(timer); chart?.dispose() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dep-hint { font-size: 12px; color: #909399; font-weight: normal; margin-left: 8px; }
|
||||
</style>
|
||||
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-row :gutter="16">
|
||||
<!-- 左:本端配置 -->
|
||||
<el-col :span="12">
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header>
|
||||
<b>本端 IPSec 参数</b>
|
||||
<el-select v-model="selectedTemplate" placeholder="载入设备模板" size="small" style="width:200px;float:right"
|
||||
@change="applyTemplate">
|
||||
<el-option v-for="t in templates" :key="t.key" :label="t.name" :value="t.key" />
|
||||
</el-select>
|
||||
</template>
|
||||
<el-alert v-if="templateNote" :title="templateNote" type="info" :closable="false" style="margin-bottom:14px" />
|
||||
<el-form label-width="130px" size="default">
|
||||
<el-form-item label="本端公网 IP" required><el-input v-model="local.public_endpoint" /></el-form-item>
|
||||
<el-form-item label="对端网关" required><el-input v-model="local.remote_gateway" /></el-form-item>
|
||||
<el-form-item label="本端网段" required><el-input v-model="local.local_subnets" placeholder="192.168.1.0/24" /></el-form-item>
|
||||
<el-form-item label="对端网段" required><el-input v-model="local.remote_subnets" placeholder="10.0.0.0/24" /></el-form-item>
|
||||
<el-form-item label="预共享密钥" required><el-input v-model="local.psk" type="password" show-password /></el-form-item>
|
||||
<el-divider content-position="left">Phase 1(IKE SA)</el-divider>
|
||||
<el-form-item label="IKE 版本">
|
||||
<el-radio-group v-model="local.ike_version">
|
||||
<el-radio-button value="ikev2">IKEv2</el-radio-button>
|
||||
<el-radio-button value="ikev1">IKEv1</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="提议">
|
||||
<el-input v-model="local.ike_proposal" placeholder="aes256-sha256-modp2048" />
|
||||
</el-form-item>
|
||||
<el-form-item label="本端 ID"><el-input v-model="local.local_id" placeholder="@vpn-local" /></el-form-item>
|
||||
<el-form-item label="对端 ID"><el-input v-model="local.remote_id" placeholder="@vpn-remote" /></el-form-item>
|
||||
<el-divider content-position="left">Phase 2(IPSec SA)</el-divider>
|
||||
<el-form-item label="ESP 提议"><el-input v-model="local.esp_proposal" placeholder="aes256-sha256" /></el-form-item>
|
||||
<el-form-item label="PFS 组"><el-input v-model="local.pfs_group" placeholder="modp2048" /></el-form-item>
|
||||
<el-form-item label="DPD 策略">
|
||||
<el-select v-model="local.dpd_action" style="width:100%">
|
||||
<el-option label="restart(断线重连)" value="restart" />
|
||||
<el-option label="clear(清除)" value="clear" />
|
||||
<el-option label="trap(按需触发)" value="trap" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-button type="primary" style="width:100%" :loading="creating" @click="createTunnel">
|
||||
创建 IPSec 专线
|
||||
</el-button>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 右:对端参数对比 + 连通性测试 -->
|
||||
<el-col :span="12">
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header><b>双方参数一致性检查</b></template>
|
||||
<el-form label-width="130px" size="default">
|
||||
<el-form-item label="对端 IKE 版本">
|
||||
<el-radio-group v-model="peer.ike_version">
|
||||
<el-radio-button value="ikev2">IKEv2</el-radio-button>
|
||||
<el-radio-button value="ikev1">IKEv1</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="对端 Phase1"><el-input v-model="peer.ike_proposal" placeholder="对端的 IKE 提议" /></el-form-item>
|
||||
<el-form-item label="对端 Phase2"><el-input v-model="peer.esp_proposal" placeholder="对端的 ESP 提议" /></el-form-item>
|
||||
<el-form-item label="对端 PFS 组"><el-input v-model="peer.pfs_group" /></el-form-item>
|
||||
<el-form-item label="对端本端网段"><el-input v-model="peer.local_subnets" placeholder="对端视角的 local(=我方 remote)" /></el-form-item>
|
||||
</el-form>
|
||||
<el-button style="width:100%" :loading="checking" @click="compare">开始对比检查</el-button>
|
||||
<div v-if="compareResult" style="margin-top:14px">
|
||||
<el-alert v-if="compareResult.consistent" type="success" title="✓ 双方参数一致,可以建立隧道" :closable="false" />
|
||||
<div v-else>
|
||||
<el-alert type="error" title="发现参数不一致:" :closable="false" />
|
||||
<ul class="diff-list">
|
||||
<li v-for="(d, i) in compareResult.differences" :key="i">{{ d }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="page-card" shadow="never" style="margin-top:16px">
|
||||
<template #header><b>一键连通性测试</b></template>
|
||||
<el-select v-model="testTunnelId" placeholder="选择已有 IPSec 专线" style="width:100%">
|
||||
<el-option v-for="t in ipsecTunnels" :key="t.id" :label="`${t.name}(${t.state})`" :value="t.id" />
|
||||
</el-select>
|
||||
<el-button type="primary" style="width:100%;margin-top:12px" :loading="testing" :disabled="!testTunnelId"
|
||||
@click="testConnectivity">测试与对端网关连通性</el-button>
|
||||
<div v-if="testResult" style="margin-top:14px">
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="目标">{{ testResult.target }}</el-descriptions-item>
|
||||
<el-descriptions-item label="结果">
|
||||
<el-tag :type="testResult.reachable ? 'success' : 'danger'">
|
||||
{{ testResult.reachable ? '可达' : '不可达' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="耗时">{{ testResult.latency_ms }} ms</el-descriptions-item>
|
||||
<el-descriptions-item label="说明">{{ testResult.note || testResult.ike_udp500_note || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import api from '../api'
|
||||
|
||||
const templates = ref([])
|
||||
const selectedTemplate = ref('')
|
||||
const templateNote = ref('')
|
||||
const local = ref({ ike_version: 'ikev2', dpd_action: 'restart' })
|
||||
const peer = ref({ ike_version: 'ikev2' })
|
||||
const compareResult = ref(null)
|
||||
const checking = ref(false)
|
||||
const creating = ref(false)
|
||||
const ipsecTunnels = ref([])
|
||||
const testTunnelId = ref(null)
|
||||
const testing = ref(false)
|
||||
const testResult = ref(null)
|
||||
|
||||
function applyTemplate(key) {
|
||||
const t = templates.value.find(x => x.key === key)
|
||||
if (!t) return
|
||||
local.value.ike_version = t.ike_version
|
||||
local.value.ike_proposal = t.ike_proposal
|
||||
local.value.esp_proposal = t.esp_proposal
|
||||
local.value.dpd_action = t.dpd_action
|
||||
templateNote.value = `已载入「${t.name}」模板:${t.note}`
|
||||
}
|
||||
|
||||
async function createTunnel() {
|
||||
const l = local.value
|
||||
if (!l.remote_gateway || !l.local_subnets || !l.remote_subnets || !l.psk) {
|
||||
return ElMessage.warning('请填写完整的必填项(网关/网段/PSK)')
|
||||
}
|
||||
creating.value = true
|
||||
try {
|
||||
await api.post('/tunnels', {
|
||||
name: `IPSec-${l.remote_gateway}`,
|
||||
protocol: 'ipsec',
|
||||
auto_start: true,
|
||||
config: l
|
||||
})
|
||||
ElMessage.success('IPSec 专线已创建并启动')
|
||||
loadTunnels()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || '创建失败')
|
||||
} finally { creating.value = false }
|
||||
}
|
||||
|
||||
async function compare() {
|
||||
checking.value = true
|
||||
try {
|
||||
const { data } = await api.post('/ipsec/compare', { local: local.value, peer: peer.value })
|
||||
compareResult.value = data
|
||||
} finally { checking.value = false }
|
||||
}
|
||||
|
||||
async function loadTunnels() {
|
||||
const { data } = await api.get('/tunnels', { params: { protocol: 'ipsec' } })
|
||||
ipsecTunnels.value = data || []
|
||||
}
|
||||
|
||||
async function testConnectivity() {
|
||||
testing.value = true
|
||||
testResult.value = null
|
||||
try {
|
||||
const { data } = await api.post(`/tunnels/${testTunnelId.value}/test`)
|
||||
testResult.value = data
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || '测试失败')
|
||||
} finally { testing.value = false }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const { data } = await api.get('/ipsec/templates')
|
||||
templates.value = data
|
||||
loadTunnels()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.diff-list { margin: 10px 0 0; padding-left: 20px; color: #f56c6c; font-size: 13px; }
|
||||
.diff-list li { margin-bottom: 6px; }
|
||||
</style>
|
||||
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<el-container class="layout">
|
||||
<el-aside width="220px" class="aside">
|
||||
<div class="logo">
|
||||
<span class="logo-icon">🔐</span>
|
||||
<span class="logo-text">VPN 管理平台</span>
|
||||
</div>
|
||||
<el-menu :default-active="$route.path" router background-color="#1a2a4a" text-color="#a8b8d8"
|
||||
active-text-color="#ffffff" class="menu">
|
||||
<el-menu-item index="/dashboard"><el-icon><DataAnalysis /></el-icon>总览</el-menu-item>
|
||||
<el-menu-item index="/tunnels"><el-icon><Connection /></el-icon>专线列表</el-menu-item>
|
||||
<el-menu-item index="/tunnels/create"><el-icon><CirclePlus /></el-icon>创建专线</el-menu-item>
|
||||
<el-menu-item index="/ipsec-peer"><el-icon><Link /></el-icon>IPSec 对接</el-menu-item>
|
||||
<el-menu-item index="/nodes"><el-icon><Share /></el-icon>节点管理</el-menu-item>
|
||||
<el-menu-item index="/users"><el-icon><UserFilled /></el-icon>用户与 LDAP</el-menu-item>
|
||||
<el-menu-item index="/vpn-users"><el-icon><User /></el-icon>VPN 拨号用户</el-menu-item>
|
||||
<el-menu-item index="/plugins"><el-icon><Grid /></el-icon>协议插件</el-menu-item>
|
||||
<el-menu-item index="/settings"><el-icon><Setting /></el-icon>系统设置</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
<el-container>
|
||||
<el-header class="header">
|
||||
<span class="page-title">{{ $route.meta.title }}</span>
|
||||
<el-dropdown @command="onCommand">
|
||||
<span class="user">
|
||||
<el-icon><Avatar /></el-icon> {{ auth.username }} <el-icon><ArrowDown /></el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="password">修改密码</el-dropdown-item>
|
||||
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</el-header>
|
||||
<el-main class="main">
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import { DataAnalysis, Connection, CirclePlus, Link, Share, Grid, Setting, Avatar, ArrowDown, UserFilled, User } from '@element-plus/icons-vue'
|
||||
import { auth } from '../api'
|
||||
|
||||
const router = useRouter()
|
||||
function onCommand(cmd) {
|
||||
if (cmd === 'logout') { auth.logout(); router.push('/login') }
|
||||
if (cmd === 'password') router.push('/settings')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout { height: 100vh; }
|
||||
.aside { background: #1a2a4a; }
|
||||
.logo { display: flex; align-items: center; gap: 10px; padding: 18px 20px; color: #fff; font-size: 17px; font-weight: 700; }
|
||||
.logo-icon { font-size: 22px; }
|
||||
.menu { border-right: none; }
|
||||
.header {
|
||||
background: #fff; display: flex; align-items: center; justify-content: space-between;
|
||||
border-bottom: 1px solid #e8eaee; box-shadow: 0 1px 4px rgba(0,0,0,.04);
|
||||
}
|
||||
.page-title { font-size: 16px; font-weight: 600; color: #303133; }
|
||||
.user { cursor: pointer; color: #606266; display: flex; align-items: center; gap: 4px; }
|
||||
.main { background: #f5f7fa; padding: 20px; overflow-y: auto; }
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div class="login-wrap">
|
||||
<div class="login-box">
|
||||
<div class="brand">
|
||||
<div class="logo">🔐</div>
|
||||
<h2>VPN 专线管理平台</h2>
|
||||
<p>WireGuard · IPSec · L2TP · FRP · OpenVPN</p>
|
||||
</div>
|
||||
<el-form @submit.prevent="doLogin">
|
||||
<el-form-item>
|
||||
<el-input v-model="username" placeholder="用户名" size="large" :prefix-icon="User" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-input v-model="password" type="password" placeholder="密码" size="large" show-password
|
||||
:prefix-icon="Lock" @keyup.enter="doLogin" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-input v-model="mfaCode" placeholder="MFA 验证码(未启用可留空)" size="large" @keyup.enter="doLogin" />
|
||||
</el-form-item>
|
||||
<el-button type="primary" size="large" style="width:100%" :loading="loading" @click="doLogin">
|
||||
登 录
|
||||
</el-button>
|
||||
</el-form>
|
||||
<p class="hint">默认账号 admin / admin123,登录后请立即修改</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { User, Lock } from '@element-plus/icons-vue'
|
||||
import api, { auth } from '../api'
|
||||
|
||||
const username = ref('admin')
|
||||
const password = ref('')
|
||||
const mfaCode = ref('')
|
||||
const loading = ref(false)
|
||||
const router = useRouter()
|
||||
|
||||
async function doLogin() {
|
||||
if (!username.value || !password.value) return ElMessage.warning('请输入用户名和密码')
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.post('/login', { username: username.value, password: password.value, mfa_code: mfaCode.value })
|
||||
auth.login(data.token, data.username)
|
||||
router.push('/')
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || '登录失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-wrap {
|
||||
height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
background: linear-gradient(135deg, #1a2a4a 0%, #2b5876 100%);
|
||||
}
|
||||
.login-box {
|
||||
width: 380px; background: #fff; border-radius: 14px; padding: 40px 36px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,.3);
|
||||
}
|
||||
.brand { text-align: center; margin-bottom: 28px; }
|
||||
.logo { font-size: 44px; }
|
||||
.brand h2 { margin: 10px 0 6px; color: #1a2a4a; }
|
||||
.brand p { color: #909399; font-size: 13px; margin: 0; }
|
||||
.hint { text-align: center; color: #c0c4cc; font-size: 12px; margin-top: 18px; }
|
||||
</style>
|
||||
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<b>中继节点</b>
|
||||
<el-button type="primary" :icon="Plus" @click="dialogVisible = true">添加节点</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-alert type="info" :closable="false" style="margin-bottom:14px"
|
||||
title="节点用于将专线部署到中继服务器。本机节点为默认节点;远程节点需在目标机部署 agent(规划中)。" />
|
||||
<el-table :data="nodes" size="large">
|
||||
<el-table-column prop="name" label="名称" />
|
||||
<el-table-column prop="address" label="地址" />
|
||||
<el-table-column label="类型" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.kind === 'local' ? 'success' : 'primary'" size="small">
|
||||
{{ row.kind === 'local' ? '本机' : '远程' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.kind !== 'local'" type="danger" size="small" link @click="remove(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="dialogVisible" title="添加节点" width="420px">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="名称" required><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item label="地址" required><el-input v-model="form.address" placeholder="agent 地址或 SSH 地址" /></el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="form.remark" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="create">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import api from '../api'
|
||||
|
||||
const nodes = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const form = ref({ name: '', address: '', kind: 'remote', remark: '' })
|
||||
|
||||
async function load() {
|
||||
const { data } = await api.get('/nodes')
|
||||
nodes.value = data || []
|
||||
}
|
||||
async function create() {
|
||||
if (!form.value.name || !form.value.address) return ElMessage.warning('请填写名称与地址')
|
||||
await api.post('/nodes', form.value)
|
||||
dialogVisible.value = false
|
||||
form.value = { name: '', address: '', kind: 'remote', remark: '' }
|
||||
load()
|
||||
}
|
||||
async function remove(row) {
|
||||
await api.delete(`/nodes/${row.id}`)
|
||||
load()
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<b>自定义协议插件</b>
|
||||
<el-button type="primary" :icon="Plus" @click="openCreate">注册插件</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-alert type="info" :closable="false" style="margin-bottom:14px">
|
||||
<p style="margin:0">插件通过<b>命令钩子</b>实现统一协议契约:平台将专线配置 JSON 通过 stdin 传给钩子命令。</p>
|
||||
<p style="margin:4px 0 0;font-size:12px">契约:<code>Create / Start / Stop / Status / Config</code>;Status 钩子 stdout 输出 <code>running</code> 或 <code>stopped</code>;Config 钩子 stdout 输出客户端配置文本。</p>
|
||||
</el-alert>
|
||||
<el-table :data="plugins" size="large">
|
||||
<el-table-column prop="display_name" label="名称" width="160" />
|
||||
<el-table-column label="协议标识" width="140">
|
||||
<template #default="{ row }"><code>custom:{{ row.name }}</code></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="说明" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.enabled ? 'success' : 'info'" size="small">{{ row.enabled ? '启用' : '停用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" link @click="edit(row)">编辑</el-button>
|
||||
<el-button type="danger" size="small" link @click="remove(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editing ? '编辑插件' : '注册协议插件'" width="560px">
|
||||
<el-form label-width="110px">
|
||||
<el-form-item label="协议标识" required>
|
||||
<el-input v-model="form.name" :disabled="!!editing" placeholder="如 myproto(字母数字)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="显示名称"><el-input v-model="form.display_name" /></el-form-item>
|
||||
<el-form-item label="说明"><el-input v-model="form.description" /></el-form-item>
|
||||
<el-form-item label="Start 钩子" required>
|
||||
<el-input v-model="form.start_hook" placeholder="/opt/plugins/myproto start" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Stop 钩子"><el-input v-model="form.stop_hook" placeholder="/opt/plugins/myproto stop" /></el-form-item>
|
||||
<el-form-item label="Status 钩子"><el-input v-model="form.status_hook" placeholder="/opt/plugins/myproto status" /></el-form-item>
|
||||
<el-form-item label="Config 钩子"><el-input v-model="form.config_hook" placeholder="/opt/plugins/myproto config" /></el-form-item>
|
||||
<el-form-item label="Create 钩子"><el-input v-model="form.create_hook" placeholder="可选" /></el-form-item>
|
||||
<el-form-item label="启用"><el-switch v-model="form.enabled" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import api from '../api'
|
||||
|
||||
const plugins = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const editing = ref(null)
|
||||
const form = ref(emptyForm())
|
||||
|
||||
function emptyForm() {
|
||||
return { name: '', display_name: '', description: '', create_hook: '', start_hook: '', stop_hook: '', status_hook: '', config_hook: '', enabled: true }
|
||||
}
|
||||
function openCreate() { editing.value = null; form.value = emptyForm(); dialogVisible.value = true }
|
||||
function edit(row) { editing.value = row; form.value = { ...row }; dialogVisible.value = true }
|
||||
|
||||
async function load() {
|
||||
const { data } = await api.get('/plugins')
|
||||
plugins.value = data || []
|
||||
}
|
||||
async function save() {
|
||||
if (!form.value.name || !form.value.start_hook) return ElMessage.warning('协议标识与 Start 钩子必填')
|
||||
try {
|
||||
if (editing.value) await api.put(`/plugins/${editing.value.id}`, form.value)
|
||||
else await api.post('/plugins', form.value)
|
||||
dialogVisible.value = false
|
||||
ElMessage.success('已保存')
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || '保存失败')
|
||||
}
|
||||
}
|
||||
async function remove(row) {
|
||||
await ElMessageBox.confirm(`删除插件「${row.display_name}」?`, '确认', { type: 'warning' })
|
||||
await api.delete(`/plugins/${row.id}`)
|
||||
load()
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="10">
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header><b>修改密码</b></template>
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="原密码"><el-input v-model="pwd.old_password" type="password" show-password /></el-form-item>
|
||||
<el-form-item label="新密码"><el-input v-model="pwd.new_password" type="password" show-password /></el-form-item>
|
||||
<el-form-item label="确认新密码"><el-input v-model="pwd.confirm" type="password" show-password /></el-form-item>
|
||||
<el-button type="primary" style="width:100%" @click="changePwd">修改</el-button>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<el-card class="page-card" shadow="never" style="margin-top:16px">
|
||||
<template #header><b>双因素认证(MFA)</b></template>
|
||||
<el-alert type="info" :closable="false" title="使用 Microsoft Authenticator、Google Authenticator 等应用扫描二维码。" style="margin-bottom:10px" />
|
||||
<el-button v-if="!mfa.enabled" type="primary" @click="setupMFA">设置 MFA</el-button>
|
||||
<el-button v-else type="danger" @click="disableMFA">关闭 MFA</el-button>
|
||||
<div v-if="mfa.uri" style="margin-top:12px"><canvas ref="mfaCanvas"></canvas><el-input v-model="mfa.code" placeholder="输入 App 显示的 6 位验证码" style="margin-top:8px" /><el-button type="success" style="margin-top:8px" @click="enableMFA">验证并启用</el-button></div>
|
||||
</el-card>
|
||||
<el-card class="page-card" shadow="never" style="margin-top:16px">
|
||||
<template #header><b>部署说明</b></template>
|
||||
<el-descriptions :column="1" size="small" border>
|
||||
<el-descriptions-item label="交付形态">单二进制(前端已内嵌)或 Docker</el-descriptions-item>
|
||||
<el-descriptions-item label="数据目录">./data(可用 VPN_DATA_DIR 覆盖)</el-descriptions-item>
|
||||
<el-descriptions-item label="监听地址">:8080(VPN_LISTEN_ADDR 覆盖)</el-descriptions-item>
|
||||
<el-descriptions-item label="密钥存储">PSK / 私钥 AES-GCM 加密落库,配置文件 0600 权限</el-descriptions-item>
|
||||
<el-descriptions-item label="系统依赖">wireguard-tools / strongswan / xl2tpd / openvpn / frp(缺失时标记为未部署,无法启动)</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="14">
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header><b>操作审计(最近 100 条)</b></template>
|
||||
<el-table :data="logs" size="small" max-height="560">
|
||||
<el-table-column prop="created_at" label="时间" width="170">
|
||||
<template #default="{ row }">{{ new Date(row.created_at).toLocaleString('zh-CN') }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="username" label="用户" width="90" />
|
||||
<el-table-column prop="action" label="动作" width="140" />
|
||||
<el-table-column prop="target" label="对象" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import QRCode from 'qrcode'
|
||||
import api from '../api'
|
||||
|
||||
const pwd = ref({ old_password: '', new_password: '', confirm: '' })
|
||||
const logs = ref([])
|
||||
const mfa = ref({ enabled: false, uri: '', code: '' })
|
||||
const mfaCanvas = ref(null)
|
||||
|
||||
async function changePwd() {
|
||||
if (pwd.value.new_password !== pwd.value.confirm) return ElMessage.warning('两次新密码不一致')
|
||||
try {
|
||||
await api.post('/password', pwd.value)
|
||||
ElMessage.success('密码已修改')
|
||||
pwd.value = { old_password: '', new_password: '', confirm: '' }
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || '修改失败')
|
||||
}
|
||||
}
|
||||
onMounted(async () => {
|
||||
const [audit, status] = await Promise.all([api.get('/audit'), api.get('/mfa/status')])
|
||||
logs.value = audit.data || []
|
||||
mfa.value.enabled = status.data.enabled
|
||||
})
|
||||
async function setupMFA() { const { data } = await api.post('/mfa/setup'); mfa.value.uri = data.otpauth_url; await nextTick(); QRCode.toCanvas(mfaCanvas.value, data.otpauth_url, { width: 180 }) }
|
||||
async function enableMFA() { try { const { data } = await api.post('/mfa/enable', { code: mfa.value.code }); mfa.value = { enabled: true, uri: '', code: '' }; ElMessage.success(`MFA 已启用,请保存备用码:${data.backup_codes.join(' ')}`) } catch (e) { ElMessage.error(e.response?.data?.error || '验证失败') } }
|
||||
async function disableMFA() { await api.post('/mfa/disable'); mfa.value = { enabled: false, uri: '', code: '' }; ElMessage.success('MFA 已关闭') }
|
||||
</script>
|
||||
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<el-card class="page-card" shadow="never" v-loading="loading">
|
||||
<template #header>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<b>编辑专线</b>
|
||||
<el-button @click="router.push('/tunnels')">返回列表</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-alert type="info" :closable="false" style="margin-bottom:18px"
|
||||
title="密码、PSK、Token 等敏感字段不会回显;留空表示继续使用原值。" />
|
||||
|
||||
<el-form v-if="form" label-width="170px" label-position="left" style="max-width:720px">
|
||||
<el-form-item label="专线名称" required>
|
||||
<el-input v-model="form.name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="协议类型">
|
||||
<el-tag :color="protocolColor(protocol)" effect="dark" style="border:none">{{ protocolDisplay(protocol) }}</el-tag>
|
||||
</el-form-item>
|
||||
<el-form-item v-for="field in visibleFields" :key="field.key" :label="field.label" :required="field.required">
|
||||
<el-input-number v-if="field.type === 'number'" v-model="form.config[field.key]" :min="0" :max="65535" />
|
||||
<el-select v-else-if="field.type === 'select'" v-model="form.config[field.key]" style="width:100%">
|
||||
<el-option v-for="option in field.options" :key="option" :label="option" :value="option" />
|
||||
</el-select>
|
||||
<el-input v-else-if="field.type === 'password'" v-model="form.config[field.key]" type="password"
|
||||
show-password :placeholder="field.placeholder || '留空保持原值'" />
|
||||
<el-input v-else v-model="form.config[field.key]" :placeholder="field.placeholder || field.default" />
|
||||
<div v-if="field.tip" class="field-tip">{{ field.tip }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="到期时间">
|
||||
<el-date-picker v-model="form.expire_at" type="datetime" clearable placeholder="留空为永久" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="自动启动"><el-switch v-model="form.auto_start" /></el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" /></el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="saving" @click="save">保存配置</el-button>
|
||||
<el-button @click="router.push('/tunnels')">取消</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import api from '../api'
|
||||
import { CUSTOM_FIELDS, PROTOCOL_META, protocolColor, protocolDisplay } from '../protocols'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const protocol = ref('')
|
||||
const form = ref(null)
|
||||
|
||||
const fields = computed(() => PROTOCOL_META[protocol.value]?.fields || CUSTOM_FIELDS)
|
||||
const visibleFields = computed(() => fields.value.filter(field => {
|
||||
if (!field.showIf) return true
|
||||
return Object.entries(field.showIf).every(([key, value]) => Array.isArray(value)
|
||||
? value.includes(form.value.config[key])
|
||||
: form.value.config[key] === value)
|
||||
}))
|
||||
|
||||
function parseConfig(raw) {
|
||||
try { return JSON.parse(raw || '{}') } catch { return {} }
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const { data } = await api.get(`/tunnels/${route.params.id}`)
|
||||
const tunnel = data.tunnel
|
||||
protocol.value = tunnel.protocol
|
||||
const config = parseConfig(tunnel.config)
|
||||
for (const field of (PROTOCOL_META[protocol.value]?.fields || CUSTOM_FIELDS)) {
|
||||
if (config[field.key] === undefined && field.default !== undefined) config[field.key] = field.default
|
||||
}
|
||||
form.value = {
|
||||
name: tunnel.name,
|
||||
remark: tunnel.remark,
|
||||
auto_start: tunnel.auto_start,
|
||||
expire_at: tunnel.expire_at,
|
||||
config,
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.error || '加载专线失败')
|
||||
router.push('/tunnels')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.value.name) return ElMessage.warning('请填写专线名称')
|
||||
saving.value = true
|
||||
try {
|
||||
await api.put(`/tunnels/${route.params.id}`, form.value)
|
||||
ElMessage.success('配置已保存')
|
||||
router.push('/tunnels')
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.error || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.field-tip { margin-top: 4px; color: #909399; font-size: 12px; line-height: 1.4; }
|
||||
</style>
|
||||
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<el-card class="page-card" shadow="never">
|
||||
<el-steps :active="step" align-center style="margin-bottom:32px">
|
||||
<el-step title="选择协议" />
|
||||
<el-step title="填写参数" />
|
||||
<el-step title="生成配置" />
|
||||
</el-steps>
|
||||
|
||||
<!-- 第一步:协议选择 -->
|
||||
<div v-if="step === 0" class="proto-grid">
|
||||
<div v-for="(meta, key) in builtin" :key="key" class="proto-card" :class="{ active: form.protocol === key }"
|
||||
@click="selectProtocol(key)">
|
||||
<div class="proto-icon">{{ meta.icon }}</div>
|
||||
<div class="proto-name" :style="{ color: meta.color }">{{ meta.name }}</div>
|
||||
<div class="proto-desc">{{ meta.desc }}</div>
|
||||
<div class="proto-scene">{{ meta.scenes }}</div>
|
||||
<div class="dep-badge">
|
||||
<el-tag v-if="depStatus[key]" :type="depStatus[key].available ? 'success' : 'warning'" size="small">
|
||||
{{ depStatus[key].available ? '依赖就绪' : '未部署' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div v-for="p in customProtocols" :key="p.name" class="proto-card" :class="{ active: form.protocol === p.name }"
|
||||
@click="selectProtocol(p.name)">
|
||||
<div class="proto-icon">🧩</div>
|
||||
<div class="proto-name" style="color:#7b5ea7">{{ p.display_name }}</div>
|
||||
<div class="proto-desc">自定义协议插件</div>
|
||||
<div class="proto-scene">自定义对接</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第二步:参数表单 -->
|
||||
<div v-if="step === 1" style="max-width:640px;margin:0 auto">
|
||||
<el-form label-width="150px" label-position="left">
|
||||
<el-form-item label="专线名称" required>
|
||||
<el-input v-model="form.name" placeholder="例如:总部-分支互联" />
|
||||
</el-form-item>
|
||||
<el-form-item label="协议类型">
|
||||
<el-tag :color="currentColor" effect="dark" style="border:none">{{ currentName }}</el-tag>
|
||||
<el-button link style="margin-left:10px" @click="step = 0">重新选择</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item v-for="f in visibleFields" :key="f.key" :label="f.label" :required="f.required">
|
||||
<el-input-number v-if="f.type === 'number'" v-model="form.config[f.key]" :min="0" :max="65535" />
|
||||
<el-select v-else-if="f.type === 'select'" v-model="form.config[f.key]" style="width:100%">
|
||||
<el-option v-for="o in f.options" :key="o" :label="o" :value="o" />
|
||||
</el-select>
|
||||
<el-input v-else-if="f.type === 'password'" v-model="form.config[f.key]" type="password"
|
||||
show-password :placeholder="f.placeholder" />
|
||||
<el-input v-else v-model="form.config[f.key]" :placeholder="f.placeholder || f.default" />
|
||||
<div class="field-tip" v-if="f.tip">{{ f.tip }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="到期时间">
|
||||
<el-date-picker v-model="form.expire_at" type="datetime" placeholder="留空为永久" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="创建后自动启动">
|
||||
<el-switch v-model="form.auto_start" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div style="text-align:center;margin-top:24px">
|
||||
<el-button @click="step = 0">上一步</el-button>
|
||||
<el-button type="primary" :loading="creating" @click="create">生成配置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第三步:结果 -->
|
||||
<div v-if="step === 2" style="max-width:720px;margin:0 auto">
|
||||
<el-result icon="success" title="专线创建成功" :sub-title="`「${created?.name}」配置已生成`" />
|
||||
<h4>客户端配置</h4>
|
||||
<pre class="mono conf-box">{{ clientConfig }}</pre>
|
||||
<div style="display:flex;gap:12px;justify-content:center;margin-top:16px">
|
||||
<el-button type="primary" @click="downloadCreated">下载配置文件</el-button>
|
||||
<el-button v-if="created?.protocol === 'wireguard'" @click="showQr">显示二维码</el-button>
|
||||
<el-button v-if="created?.protocol === 'ipsec'" @click="$router.push('/ipsec-peer')">前往 IPSec 对接页</el-button>
|
||||
<el-button @click="$router.push('/tunnels')">返回列表</el-button>
|
||||
</div>
|
||||
<canvas ref="qrCanvas" style="display:block;margin:16px auto" v-show="qrShown"></canvas>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import QRCode from 'qrcode'
|
||||
import api from '../api'
|
||||
import { PROTOCOL_META, CUSTOM_FIELDS, protocolColor } from '../protocols'
|
||||
|
||||
const step = ref(0)
|
||||
const form = ref({ name: '', protocol: '', config: {}, remark: '', auto_start: true, expire_at: null })
|
||||
const creating = ref(false)
|
||||
const created = ref(null)
|
||||
const clientConfig = ref('')
|
||||
const depStatus = ref({})
|
||||
const customProtocols = ref([])
|
||||
const qrCanvas = ref(null)
|
||||
const qrShown = ref(false)
|
||||
|
||||
const builtin = PROTOCOL_META
|
||||
|
||||
const currentMeta = computed(() => PROTOCOL_META[form.value.protocol])
|
||||
const currentName = computed(() => currentMeta.value?.name || form.value.protocol.replace('custom:', ''))
|
||||
const currentColor = computed(() => protocolColor(form.value.protocol))
|
||||
const currentFields = computed(() => currentMeta.value?.fields || CUSTOM_FIELDS)
|
||||
const visibleFields = computed(() => currentFields.value.filter(f => {
|
||||
if (!f.showIf) return true
|
||||
return Object.entries(f.showIf).every(([k, v]) => Array.isArray(v)
|
||||
? v.includes(form.value.config[k])
|
||||
: form.value.config[k] === v)
|
||||
}))
|
||||
|
||||
function selectProtocol(key) {
|
||||
form.value.protocol = key
|
||||
form.value.config = {}
|
||||
// 填充默认值
|
||||
for (const f of (PROTOCOL_META[key]?.fields || CUSTOM_FIELDS)) {
|
||||
if (f.default !== undefined) form.value.config[f.key] = f.default
|
||||
}
|
||||
step.value = 1
|
||||
}
|
||||
|
||||
async function create() {
|
||||
if (!form.value.name) return ElMessage.warning('请填写专线名称')
|
||||
creating.value = true
|
||||
try {
|
||||
const { data } = await api.post('/tunnels', {
|
||||
name: form.value.name,
|
||||
protocol: form.value.protocol,
|
||||
remark: form.value.remark,
|
||||
auto_start: form.value.auto_start,
|
||||
expire_at: form.value.expire_at,
|
||||
config: form.value.config
|
||||
})
|
||||
created.value = data
|
||||
const detail = await api.get(`/tunnels/${data.id}`)
|
||||
clientConfig.value = detail.data.client_config || ''
|
||||
step.value = 2
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || '创建失败')
|
||||
} finally { creating.value = false }
|
||||
}
|
||||
|
||||
function downloadCreated() {
|
||||
fetch(`/api/tunnels/${created.value.id}/config`, { headers: { Authorization: `Bearer ${localStorage.getItem('vpn_token')}` } })
|
||||
.then(r => r.blob()).then(b => {
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(b)
|
||||
a.download = `${created.value.protocol}-${created.value.name}.conf`
|
||||
a.click()
|
||||
})
|
||||
}
|
||||
|
||||
async function showQr() {
|
||||
qrShown.value = true
|
||||
await nextTick()
|
||||
QRCode.toCanvas(qrCanvas.value, clientConfig.value, { width: 260, margin: 1 })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const { data } = await api.get('/protocols')
|
||||
for (const p of data) {
|
||||
if (p.custom) customProtocols.value.push({ name: p.name, display_name: p.display_name })
|
||||
else depStatus.value[p.name] = p
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.proto-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; max-width: 1000px; margin: 0 auto; }
|
||||
.proto-card {
|
||||
border: 2px solid #e8eaee; border-radius: 12px; padding: 22px 16px; text-align: center;
|
||||
cursor: pointer; transition: all .2s; background: #fff;
|
||||
}
|
||||
.proto-card:hover { border-color: #4a6fa5; transform: translateY(-2px); box-shadow: 0 6px 16px rgba(0,0,0,.08); }
|
||||
.proto-card.active { border-color: #4a6fa5; background: #f0f5fc; }
|
||||
.proto-icon { font-size: 34px; }
|
||||
.proto-name { font-size: 16px; font-weight: 700; margin: 8px 0 4px; }
|
||||
.proto-desc { font-size: 12px; color: #606266; min-height: 34px; }
|
||||
.proto-scene { font-size: 11px; color: #909399; margin-top: 6px; }
|
||||
.dep-badge { margin-top: 8px; }
|
||||
.field-tip { font-size: 12px; color: #909399; line-height: 1.4; }
|
||||
.conf-box { background: #f6f8fa; border: 1px solid #e8eaee; border-radius: 6px; padding: 14px; max-height: 320px; overflow: auto; }
|
||||
</style>
|
||||
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<div>
|
||||
<el-input v-model="keyword" placeholder="搜索名称" clearable style="width:200px" @change="load" />
|
||||
<el-select v-model="protocolFilter" placeholder="全部协议" clearable style="width:160px;margin-left:10px" @change="load">
|
||||
<el-option v-for="p in protocolOptions" :key="p" :label="protocolDisplay(p)" :value="p" />
|
||||
</el-select>
|
||||
</div>
|
||||
<el-button type="primary" :icon="Plus" @click="$router.push('/tunnels/create')">创建专线</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="list" v-loading="loading" size="large">
|
||||
<el-table-column prop="name" label="名称" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<b>{{ row.name }}</b>
|
||||
<div class="remark" v-if="row.remark">{{ row.remark }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="协议" width="130">
|
||||
<template #default="{ row }">
|
||||
<el-tag :color="protocolColor(row.protocol)" effect="dark" style="border:none">
|
||||
{{ protocolDisplay(row.protocol) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-badge is-dot :type="dotType(row)" style="padding-left:12px">
|
||||
<span :style="{ color: stateColor(row) }">{{ stateText(row) }}</span>
|
||||
</el-badge>
|
||||
<el-tooltip v-if="row.runtime?.detail" :content="row.runtime.detail" placement="top">
|
||||
<el-icon style="margin-left:4px;color:#909399;cursor:help"><InfoFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="流量 ↓ / ↑" width="140">
|
||||
<template #default="{ row }">
|
||||
<span style="font-size:12px">{{ fmtBytes(row.rx_bytes) }} / {{ fmtBytes(row.tx_bytes) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="到期时间" width="150">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: expiringSoon(row) ? '#e6a23c' : '' }">{{ row.expire_at ? fmtTime(row.expire_at) : '永久' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="节点" width="110">
|
||||
<template #default="{ row }">{{ row.node?.name || '本机节点' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="300" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="stateOf(row) !== 'running'" type="success" size="small" link @click="start(row)">启动</el-button>
|
||||
<el-button v-else type="warning" size="small" link @click="stop(row)">停止</el-button>
|
||||
<el-button size="small" link @click="showDetail(row)">详情</el-button>
|
||||
<el-button size="small" link @click="$router.push(`/tunnels/${row.id}/edit`)">编辑</el-button>
|
||||
<el-button size="small" link @click="download(row)">下载配置</el-button>
|
||||
<el-button v-if="row.protocol === 'wireguard'" size="small" link @click="showQr(row)">二维码</el-button>
|
||||
<el-button type="danger" size="small" link @click="remove(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="还没有专线,点击右上角创建第一条" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<!-- 详情抽屉 -->
|
||||
<el-drawer v-model="detailVisible" :title="detail?.tunnel?.name" size="520px">
|
||||
<template v-if="detail">
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="协议">{{ protocolDisplay(detail.tunnel.protocol) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">{{ stateText(detail.tunnel) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ fmtTime(detail.tunnel.created_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="自动启动">{{ detail.tunnel.auto_start ? '是' : '否' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<h4>运行参数</h4>
|
||||
<pre class="mono conf-box">{{ prettyConfig(detail.tunnel.config) }}</pre>
|
||||
<h4>客户端配置</h4>
|
||||
<pre class="mono conf-box">{{ detail.client_config || '该协议暂无客户端配置' }}</pre>
|
||||
<el-button type="primary" style="width:100%" @click="download(detail.tunnel)">下载客户端配置</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
|
||||
<!-- WireGuard 二维码 -->
|
||||
<el-dialog v-model="qrVisible" title="扫码导入 WireGuard 客户端" width="360px" align-center>
|
||||
<div style="text-align:center">
|
||||
<canvas ref="qrCanvas"></canvas>
|
||||
<p style="color:#909399;font-size:12px">使用 WireGuard App「从二维码添加」</p>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, InfoFilled } from '@element-plus/icons-vue'
|
||||
import QRCode from 'qrcode'
|
||||
import api from '../api'
|
||||
import { protocolDisplay, protocolColor } from '../protocols'
|
||||
|
||||
const list = ref([])
|
||||
const loading = ref(false)
|
||||
const keyword = ref('')
|
||||
const protocolFilter = ref('')
|
||||
const protocolOptions = ref([])
|
||||
const detailVisible = ref(false)
|
||||
const detail = ref(null)
|
||||
const qrVisible = ref(false)
|
||||
const qrCanvas = ref(null)
|
||||
|
||||
function fmtBytes(n) {
|
||||
if (!n) return '0 B'
|
||||
const u = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let i = 0
|
||||
while (n >= 1024 && i < 4) { n /= 1024; i++ }
|
||||
return n.toFixed(1) + ' ' + u[i]
|
||||
}
|
||||
function fmtTime(t) { return t ? new Date(t).toLocaleString('zh-CN') : '-' }
|
||||
function expiringSoon(row) {
|
||||
return row.expire_at && new Date(row.expire_at) - Date.now() < 7 * 864e5
|
||||
}
|
||||
function stateText(row) {
|
||||
return { running: '运行中', stopped: '已停止', error: '异常', undeployed: '未部署' }[stateOf(row)] || stateOf(row)
|
||||
}
|
||||
function stateColor(row) {
|
||||
return { running: '#67c23a', stopped: '#909399', error: '#f56c6c', undeployed: '#e6a23c' }[stateOf(row)]
|
||||
}
|
||||
function dotType(row) {
|
||||
return { running: 'success', stopped: 'info', error: 'danger', undeployed: 'warning' }[stateOf(row)] || 'info'
|
||||
}
|
||||
function stateOf(row) { return row.runtime?.state || row.state }
|
||||
function prettyConfig(c) {
|
||||
try { return JSON.stringify(JSON.parse(c), null, 2) } catch { return c }
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/tunnels', { params: { keyword: keyword.value, protocol: protocolFilter.value } })
|
||||
list.value = data || []
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function start(row) {
|
||||
try {
|
||||
await api.post(`/tunnels/${row.id}/start`)
|
||||
ElMessage.success('已启动')
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || '启动失败')
|
||||
}
|
||||
load()
|
||||
}
|
||||
async function stop(row) {
|
||||
await api.post(`/tunnels/${row.id}/stop`)
|
||||
ElMessage.success('已停止')
|
||||
load()
|
||||
}
|
||||
async function remove(row) {
|
||||
await ElMessageBox.confirm(`确定删除专线「${row.name}」?配置与密钥将一并清除。`, '删除确认', { type: 'warning' })
|
||||
await api.delete(`/tunnels/${row.id}`)
|
||||
ElMessage.success('已删除')
|
||||
load()
|
||||
}
|
||||
async function showDetail(row) {
|
||||
const { data } = await api.get(`/tunnels/${row.id}`)
|
||||
detail.value = data
|
||||
detailVisible.value = true
|
||||
}
|
||||
function download(row) {
|
||||
// 带鉴权下载
|
||||
fetch(`/api/tunnels/${row.id}/config`, { headers: { Authorization: `Bearer ${localStorage.getItem('vpn_token')}` } })
|
||||
.then(r => r.blob()).then(b => {
|
||||
const a = document.createElement('a')
|
||||
const url = URL.createObjectURL(b)
|
||||
a.href = url
|
||||
a.download = `${row.protocol}-${row.name}.conf`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
})
|
||||
}
|
||||
async function showQr(row) {
|
||||
const { data } = await api.get(`/tunnels/${row.id}`)
|
||||
if (!data.client_config) return ElMessage.warning('无客户端配置')
|
||||
qrVisible.value = true
|
||||
await nextTick()
|
||||
QRCode.toCanvas(qrCanvas.value, data.client_config, { width: 280, margin: 1 })
|
||||
}
|
||||
|
||||
let timer
|
||||
onMounted(async () => {
|
||||
load()
|
||||
timer = setInterval(load, 15000)
|
||||
const { data } = await api.get('/protocols')
|
||||
protocolOptions.value = data.map(p => p.name)
|
||||
})
|
||||
onUnmounted(() => clearInterval(timer))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.remark { font-size: 12px; color: #909399; }
|
||||
.conf-box { background: #f6f8fa; border: 1px solid #e8eaee; border-radius: 6px; padding: 12px; max-height: 260px; overflow: auto; }
|
||||
</style>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="15">
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header><div style="display:flex;justify-content:space-between"><b>管理用户</b><el-button type="primary" @click="openCreate">添加本地用户</el-button></div></template>
|
||||
<el-table :data="users" size="small">
|
||||
<el-table-column prop="username" label="用户名" />
|
||||
<el-table-column prop="email" label="邮箱" />
|
||||
<el-table-column label="来源" width="90"><template #default="{row}"><el-tag size="small">{{ row.source }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="角色" width="100"><template #default="{row}"><el-tag :type="row.role === 'admin' ? 'danger' : row.role === 'operator' ? 'warning' : 'info'" size="small">{{ row.role }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="状态" width="80"><template #default="{row}"><el-tag :type="row.enabled ? 'success' : 'info'" size="small">{{ row.enabled ? '启用' : '禁用' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="操作" width="130"><template #default="{row}"><el-button link size="small" @click="toggle(row)">{{ row.enabled ? '禁用' : '启用' }}</el-button><el-button v-if="row.username !== auth.username" type="danger" link size="small" @click="remove(row)">删除</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="9">
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header><b>LDAP 身份源</b></template>
|
||||
<el-alert type="info" :closable="false" title="LDAP 用户首次登录后同步为只读管理用户;可由管理员调整角色。" style="margin-bottom:12px" />
|
||||
<el-form label-width="88px" size="small">
|
||||
<el-form-item label="启用"><el-switch v-model="ldap.enabled" /></el-form-item>
|
||||
<el-form-item label="LDAP URL"><el-input v-model="ldap.url" placeholder="ldaps://ldap.example.com:636" /></el-form-item>
|
||||
<el-form-item label="Bind DN"><el-input v-model="ldap.bind_dn" /></el-form-item>
|
||||
<el-form-item label="Bind 密码"><el-input v-model="ldap.bind_password" type="password" show-password placeholder="留空保持原值" /></el-form-item>
|
||||
<el-form-item label="Base DN"><el-input v-model="ldap.base_dn" placeholder="dc=example,dc=com" /></el-form-item>
|
||||
<el-form-item label="用户过滤"><el-input v-model="ldap.user_filter" placeholder="(objectClass=inetOrgPerson)" /></el-form-item>
|
||||
<el-form-item label="用户名属性"><el-input v-model="ldap.username_attribute" placeholder="uid" /></el-form-item>
|
||||
<el-button type="primary" @click="saveLDAP">保存</el-button><el-button @click="testLDAP">测试连接</el-button>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-dialog v-model="visible" title="添加本地管理用户" width="420px">
|
||||
<el-form label-width="80px"><el-form-item label="用户名"><el-input v-model="form.username" /></el-form-item><el-form-item label="邮箱"><el-input v-model="form.email" /></el-form-item><el-form-item label="密码"><el-input v-model="form.password" type="password" show-password /></el-form-item><el-form-item label="角色"><el-select v-model="form.role"><el-option value="admin" label="管理员" /><el-option value="operator" label="运维员" /><el-option value="viewer" label="只读" /></el-select></el-form-item></el-form>
|
||||
<template #footer><el-button @click="visible=false">取消</el-button><el-button type="primary" @click="create">创建</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import api, { auth } from '../api'
|
||||
const users = ref([]), visible = ref(false)
|
||||
const form = ref({ username: '', email: '', password: '', role: 'viewer', enabled: true })
|
||||
const ldap = ref({ enabled: false, url: '', bind_dn: '', bind_password: '', base_dn: '', user_filter: '(objectClass=inetOrgPerson)', username_attribute: 'uid' })
|
||||
async function load () { const [u, l] = await Promise.all([api.get('/users'), api.get('/ldap')]); users.value = u.data || []; ldap.value = { ...ldap.value, ...l.data, bind_password: '' } }
|
||||
function openCreate () { form.value = { username: '', email: '', password: '', role: 'viewer', enabled: true }; visible.value = true }
|
||||
async function create () { try { await api.post('/users', form.value); visible.value = false; ElMessage.success('用户已创建'); load() } catch (e) { ElMessage.error(e.response?.data?.error || '创建失败') } }
|
||||
async function toggle (row) { await api.put(`/users/${row.id}`, { enabled: !row.enabled }); load() }
|
||||
async function remove (row) { await ElMessageBox.confirm(`删除用户 ${row.username}?`, '确认'); await api.delete(`/users/${row.id}`); load() }
|
||||
async function saveLDAP () { try { await api.put('/ldap', ldap.value); ldap.value.bind_password = ''; ElMessage.success('LDAP 配置已保存') } catch (e) { ElMessage.error(e.response?.data?.error || '保存失败') } }
|
||||
async function testLDAP () { try { await api.post('/ldap/test'); ElMessage.success('LDAP 连接成功') } catch (e) { ElMessage.error(e.response?.data?.error || '连接失败') } }
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header><div style="display:flex;justify-content:space-between;align-items:center"><div><b>VPN 拨号用户</b><span style="margin-left:8px;color:#909399;font-size:12px">本地密码或 LDAP 身份,按 OpenVPN 用户统计流量</span></div><el-button type="primary" @click="openCreate">添加用户</el-button></div></template>
|
||||
<el-table :data="users" size="small">
|
||||
<el-table-column prop="username" label="用户名" /><el-table-column prop="display_name" label="姓名" /><el-table-column label="隧道"><template #default="{row}">{{ tunnelName(row.tunnel_id) }}</template></el-table-column>
|
||||
<el-table-column prop="source" label="来源" width="90" /><el-table-column label="流量 ↓ / ↑" width="170"><template #default="{row}">{{ bytes(row.rx_bytes) }} / {{ bytes(row.tx_bytes) }}</template></el-table-column>
|
||||
<el-table-column label="最近在线" width="180"><template #default="{row}">{{ row.last_seen_at ? new Date(row.last_seen_at).toLocaleString('zh-CN') : '-' }}</template></el-table-column>
|
||||
<el-table-column label="状态" width="90"><template #default="{row}"><el-tag :type="row.enabled ? 'success':'info'" size="small">{{ row.enabled ? '启用':'禁用' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="操作" width="180"><template #default="{row}"><el-button link size="small" @click="download(row)">下载配置</el-button><el-button link size="small" @click="toggle(row)">{{ row.enabled ? '禁用':'启用' }}</el-button><el-button type="danger" link size="small" @click="remove(row)">删除</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
<el-dialog v-model="visible" title="添加 VPN 拨号用户" width="480px"><el-form label-width="100px"><el-form-item label="OpenVPN 隧道" required><el-select v-model="form.tunnel_id" style="width:100%"><el-option v-for="t in tunnels" :key="t.id" :value="t.id" :label="t.name" /></el-select></el-form-item><el-form-item label="用户名" required><el-input v-model="form.username" /></el-form-item><el-form-item label="显示名"><el-input v-model="form.display_name" /></el-form-item><el-form-item label="认证来源"><el-radio-group v-model="form.source"><el-radio-button value="local">本地</el-radio-button><el-radio-button value="ldap">LDAP</el-radio-button></el-radio-group></el-form-item><el-form-item v-if="form.source==='local'" label="密码" required><el-input v-model="form.password" type="password" show-password placeholder="至少 8 位" /></el-form-item><el-form-item v-else label="LDAP DN" required><el-input v-model="form.ldap_dn" placeholder="uid=user,ou=people,dc=example,dc=com" /></el-form-item><el-form-item label="邮箱"><el-input v-model="form.email" /></el-form-item></el-form><template #footer><el-button @click="visible=false">取消</el-button><el-button type="primary" @click="create">创建</el-button></template></el-dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import api from '../api'
|
||||
const users=ref([]), tunnels=ref([]), visible=ref(false)
|
||||
const form=ref({tunnel_id:null,username:'',display_name:'',source:'local',password:'',ldap_dn:'',email:'',enabled:true})
|
||||
const bytes=n=>{if(!n)return '0 B';const u=['B','KB','MB','GB','TB'];let i=0;while(n>=1024&&i<4){n/=1024;i++}return `${n.toFixed(1)} ${u[i]}`}
|
||||
const tunnelName=id=>tunnels.value.find(t=>t.id===id)?.name||`#${id}`
|
||||
async function load(){const [u,t]=await Promise.all([api.get('/vpn-users'),api.get('/tunnels',{params:{protocol:'openvpn'}})]);users.value=u.data||[];tunnels.value=t.data||[]}
|
||||
function openCreate(){form.value={tunnel_id:tunnels.value[0]?.id||null,username:'',display_name:'',source:'local',password:'',ldap_dn:'',email:'',enabled:true};visible.value=true}
|
||||
async function create(){try{await api.post('/vpn-users',form.value);visible.value=false;ElMessage.success('拨号用户已创建');load()}catch(e){ElMessage.error(e.response?.data?.error||'创建失败')}}
|
||||
async function toggle(row){await api.put(`/vpn-users/${row.id}`,{enabled:!row.enabled,display_name:row.display_name,email:row.email});load()}
|
||||
async function remove(row){await ElMessageBox.confirm(`删除拨号用户 ${row.username}?`,'确认');await api.delete(`/vpn-users/${row.id}`);load()}
|
||||
async function download(row){try{const res=await fetch(`/api/vpn-users/${row.id}/config`,{headers:{Authorization:`Bearer ${localStorage.getItem('vpn_token')}`}});if(!res.ok){const data=await res.json();throw new Error(data.error)}const url=URL.createObjectURL(await res.blob());const a=document.createElement('a');a.href=url;a.download=`openvpn-${row.username}.ovpn`;a.click();URL.revokeObjectURL(url)}catch(e){ElMessage.error(e.message||'下载失败')}}
|
||||
onMounted(load)
|
||||
</script>
|
||||
Reference in new issue
Block a user