Initial commit: OpenVPN Manager v1.0

OpenVPN Web management console with multi-instance support, client cert
issuance, traffic/connection auditing, certificate expiry reminders,
auto backup/restore.

Stack:
- Backend: Go 1.21+ (Gin + JWT)
- Frontend: Vue 3 + Element Plus + ECharts + Vite
- Storage: JSON file (db.json) + filesystem (pki/, instances/, clients/, backups/)

Features:
- Multi-instance OpenVPN management (independent port/proto/subnet/PKI)
- One-click client certificate issuance with .ovpn (embedded certs)
- Certificate expiry reminders (30-day threshold)
- Connection log parsing (status-version 3)
- Auto backup/restore (tar.gz)
- Audit log for all write operations
- JWT auth (12h TTL)
- One-line install.sh for Ubuntu/Debian/RHEL/Fedora
This commit is contained in:
cnbugs
2026-08-09 20:32:37 +08:00
commit 77f8b59290
34 changed files with 6063 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
<template>
<router-view />
</template>
<script setup></script>
+65
View File
@@ -0,0 +1,65 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
const api = axios.create({ baseURL: '/api', timeout: 30000 })
api.interceptors.request.use(cfg => {
const t = localStorage.getItem('token')
if (t) cfg.headers.Authorization = `Bearer ${t}`
return cfg
})
api.interceptors.response.use(
r => r,
err => {
if (err.response?.status === 401) {
localStorage.removeItem('token')
if (router.currentRoute.value.path !== '/login') {
router.replace('/login')
}
} else {
ElMessage.error(err.response?.data?.error || err.message || '请求失败')
}
return Promise.reject(err)
}
)
export const Auth = {
login: (username, password) => api.post('/login', { username, password }).then(r => r.data),
me: () => api.get('/me').then(r => r.data),
logout: () => api.post('/logout').then(r => r.data),
}
export const Dash = {
get: () => api.get('/dashboard').then(r => r.data),
}
export const Inst = {
list: () => api.get('/instances').then(r => r.data),
get: id => api.get(`/instances/${id}`).then(r => r.data),
create: data => api.post('/instances', data).then(r => r.data),
update: (id, data) => api.put(`/instances/${id}`, data).then(r => r.data),
delete: id => api.delete(`/instances/${id}`).then(r => r.data),
start: id => api.post(`/instances/${id}/start`).then(r => r.data),
stop: id => api.post(`/instances/${id}/stop`).then(r => r.data),
online: id => api.get(`/instances/${id}/online`).then(r => r.data),
listUsers: id => api.get(`/instances/${id}/users`).then(r => r.data),
createUser: (id, data) => api.post(`/instances/${id}/users`, data).then(r => r.data),
revokeUser: (id, uid) => api.post(`/instances/${id}/users/${uid}/revoke`).then(r => r.data),
deleteUser: (id, uid) => api.delete(`/instances/${id}/users/${uid}`).then(r => r.data),
ovpnUrl: (id, uid, host) => `/api/instances/${id}/users/${uid}/ovpn?host=${encodeURIComponent(host||'')}`,
}
export const Certs = {
list: () => api.get('/certs').then(r => r.data),
}
export const Logs = {
conns: instanceId => api.get('/connlogs', { params: { instance: instanceId || '' }}).then(r => r.data),
audits: () => api.get('/audits').then(r => r.data),
}
export const Backup = {
list: () => api.get('/backups').then(r => r.data),
create: note => api.post('/backups', { note }).then(r => r.data),
restore: id => api.post(`/backups/${id}/restore`).then(r => r.data),
delete: id => api.delete(`/backups/${id}`).then(r => r.data),
}
export default api
+12
View File
@@ -0,0 +1,12 @@
html, body, #app { height: 100%; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, "PingFang SC", "Microsoft YaHei", sans-serif; background: #f5f7fa; }
* { box-sizing: border-box; }
.layout-header { background: linear-gradient(90deg,#1d4ed8,#0ea5e9); color:#fff; display:flex; align-items:center; justify-content:space-between; padding:0 24px; }
.layout-header .brand { font-size:18px; font-weight:600; }
.layout-aside { background:#001529; }
.layout-aside .el-menu { border-right:0; }
.card-stat { background:#fff; padding:18px 22px; border-radius:8px; box-shadow:0 1px 4px rgba(0,0,0,.06); }
.card-stat .label { color:#909399; font-size:13px; }
.card-stat .num { font-size:28px; font-weight:600; margin-top:6px; color:#1f2937; }
.muted { color:#909399; font-size:12px; }
.chart-card { background:#fff; padding:14px; border-radius:8px; box-shadow:0 1px 4px rgba(0,0,0,.06); height:340px; }
+59
View File
@@ -0,0 +1,59 @@
<template>
<el-container style="height:100vh">
<el-aside width="220px" class="layout-aside">
<div style="height:60px;color:#fff;display:flex;align-items:center;justify-content:center;font-size:16px;font-weight:600;">
<el-icon style="margin-right:8px"><Promotion /></el-icon>OpenVPN
</div>
<el-menu :default-active="route.path" background-color="#001529" text-color="#fff" router>
<el-menu-item index="/dashboard"><el-icon><Odometer /></el-icon><span>仪表盘</span></el-menu-item>
<el-menu-item index="/instances"><el-icon><Connection /></el-icon><span>实例管理</span></el-menu-item>
<el-menu-item index="/users"><el-icon><User /></el-icon><span>用户管理</span></el-menu-item>
<el-menu-item index="/certificates"><el-icon><Lock /></el-icon><span>证书管理</span></el-menu-item>
<el-menu-item index="/logs"><el-icon><Document /></el-icon><span>连接日志</span></el-menu-item>
<el-menu-item index="/backups"><el-icon><FolderOpened /></el-icon><span>备份与恢复</span></el-menu-item>
<el-menu-item index="/audits"><el-icon><Tickets /></el-icon><span>审计日志</span></el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header class="layout-header">
<div class="brand">
<el-icon style="margin-right:8px"><Promotion /></el-icon>OpenVPN Web 管理控制台
</div>
<div>
<el-dropdown @command="cmd">
<span style="color:#fff;cursor:pointer">
<el-icon><UserFilled /></el-icon> {{ user }} <el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<el-main style="background:#f5f7fa">
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<script setup>
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Auth } from '@/api'
import { ElMessage } from 'element-plus'
const route = useRoute()
const router = useRouter()
const user = ref(localStorage.getItem('username') || 'admin')
async function cmd(c) {
if (c === 'logout') {
await Auth.logout()
localStorage.removeItem('token')
ElMessage.success('已退出登录')
router.replace('/login')
}
}
</script>
+18
View File
@@ -0,0 +1,18 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
import './assets/main.css'
const app = createApp(App)
for (const [k,v] of Object.entries(ElementPlusIconsVue)) {
app.component(k, v)
}
app.use(createPinia())
app.use(router)
app.use(ElementPlus, { locale: zhCn })
app.mount('#app')
+38
View File
@@ -0,0 +1,38 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import Login from '@/views/Login.vue'
import Layout from '@/layout/Index.vue'
import Dashboard from '@/views/Dashboard.vue'
import Instances from '@/views/Instances.vue'
import Users from '@/views/Users.vue'
import Certificates from '@/views/Certificates.vue'
import Logs from '@/views/Logs.vue'
import Backups from '@/views/Backups.vue'
import Audits from '@/views/Audits.vue'
const router = createRouter({
history: createWebHashHistory(),
routes: [
{ path: '/login', component: Login },
{
path: '/', component: Layout,
children: [
{ path: '', redirect: '/dashboard' },
{ path: 'dashboard', component: Dashboard, meta: { title: '仪表盘' } },
{ path: 'instances', component: Instances, meta: { title: '实例管理' } },
{ path: 'users', component: Users, meta: { title: '用户管理' } },
{ path: 'certificates', component: Certificates, meta: { title: '证书管理' } },
{ path: 'logs', component: Logs, meta: { title: '连接日志' } },
{ path: 'backups', component: Backups, meta: { title: '备份与恢复' } },
{ path: 'audits', component: Audits, meta: { title: '审计日志' } },
]
}
]
})
router.beforeEach((to, from, next) => {
const t = localStorage.getItem('token')
if (!t && to.path !== '/login') return next('/login')
next()
})
export default router
+27
View File
@@ -0,0 +1,27 @@
<template>
<div>
<h2 style="margin-top:0">审计日志</h2>
<el-table :data="list" stripe max-height="700">
<el-table-column prop="time" label="时间" width="170">
<template #default="{row}">{{ row.time?.slice(0,19) }}</template>
</el-table-column>
<el-table-column prop="user" label="操作人" width="100" />
<el-table-column prop="action" label="动作" width="180" />
<el-table-column prop="target" label="对象" width="240" />
<el-table-column prop="result" label="结果" width="80">
<template #default="{row}">
<el-tag :type="row.result==='ok'?'success':'danger'" size="small">{{ row.result }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="detail" label="详情" />
<el-table-column prop="ip" label="IP" width="130" />
</el-table>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { Logs } from '@/api'
const list = ref([])
async function load() { list.value = await Logs.audits() }
onMounted(load)
</script>
+57
View File
@@ -0,0 +1,57 @@
<template>
<div>
<h2 style="margin-top:0">备份与恢复</h2>
<el-row :gutter="12" style="margin-bottom:12px">
<el-col :span="12"><el-input v-model="note" placeholder="备份备注" /></el-col>
<el-col :span="6"><el-button type="primary" @click="create"><el-icon><Plus /></el-icon>立即备份</el-button></el-col>
</el-row>
<el-table :data="list" stripe>
<el-table-column prop="created_at" label="创建时间" width="170">
<template #default="{row}">{{ row.created_at?.slice(0,19) }}</template>
</el-table-column>
<el-table-column prop="filename" label="文件名" />
<el-table-column prop="size" label="大小" width="100">
<template #default="{row}">{{ fmt(row.size) }}</template>
</el-table-column>
<el-table-column prop="note" label="备注" />
<el-table-column label="操作" width="240">
<template #default="{row}">
<el-popconfirm title="恢复将覆盖现有数据,确定继续?" @confirm="restore(row)">
<template #reference><el-button size="small" type="warning">恢复</el-button></template>
</el-popconfirm>
<el-popconfirm title="删除后不可恢复?" @confirm="del(row)">
<template #reference><el-button size="small" type="danger">删除</el-button></template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { Backup } from '@/api'
import { ElMessage } from 'element-plus'
const list = ref([])
const note = ref('')
async function load() { list.value = await Backup.list() }
async function create() {
await Backup.create(note.value)
ElMessage.success('备份创建成功')
note.value = ''
load()
}
async function restore(row) {
await Backup.restore(row.id)
ElMessage.success('已恢复,部分更改需要重启实例')
load()
}
async function del(row) { await Backup.delete(row.id); ElMessage.success('已删除'); load() }
function fmt(n) {
if (!n) return '0'
const u = ['B','KB','MB','GB']; let i=0,v=n
while (v>=1024 && i<u.length-1) { v/=1024; i++ }
return v.toFixed(1)+' '+u[i]
}
onMounted(load)
</script>
+27
View File
@@ -0,0 +1,27 @@
<template>
<div>
<h2 style="margin-top:0">证书管理</h2>
<el-table :data="certs" stripe>
<el-table-column prop="username" label="用户" />
<el-table-column prop="instance_id" label="实例 ID" width="220" />
<el-table-column prop="not_after" label="到期时间" width="200">
<template #default="{row}">{{ row.not_after?.slice(0,10) }}</template>
</el-table-column>
<el-table-column prop="days_left" label="剩余天数" width="120">
<template #default="{row}">
<el-tag :type="row.days_left<0?'danger':(row.days_left<30?'warning':'success')">
{{ row.days_left }}
</el-tag>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { Certs } from '@/api'
const certs = ref([])
async function load() { certs.value = await Certs.list() }
onMounted(load)
</script>
+69
View File
@@ -0,0 +1,69 @@
<template>
<div>
<h2 style="margin-top:0">仪表盘</h2>
<el-row :gutter="16">
<el-col :span="6"><div class="card-stat"><div class="label">实例数</div><div class="num">{{ data.instances || 0 }}</div><div class="muted">运行中 {{ data.running || 0 }}</div></div></el-col>
<el-col :span="6"><div class="card-stat"><div class="label">用户数</div><div class="num">{{ data.users || 0 }}</div><div class="muted">启用 {{ data.active_users || 0 }}</div></div></el-col>
<el-col :span="6"><div class="card-stat"><div class="label">当前在线</div><div class="num">{{ data.online || 0 }}</div><div class="muted">来自 status.log</div></div></el-col>
<el-col :span="6"><div class="card-stat"><div class="label">即将到期证书(30)</div><div class="num" :style="{color:(data.expiring_certs>0)?'#f56c6c':'#1f2937'}">{{ data.expiring_certs || 0 }}</div><div class="muted">需及时续签</div></div></el-col>
</el-row>
<el-row :gutter="16" style="margin-top:16px">
<el-col :span="12">
<div class="chart-card">
<h4 style="margin:0 0 8px">最近审计</h4>
<el-table :data="data.recent_audits || []" size="small" max-height="280">
<el-table-column prop="time" label="时间" width="170">
<template #default="{row}">{{ row.time?.slice(0,19) }}</template>
</el-table-column>
<el-table-column prop="user" label="用户" width="90" />
<el-table-column prop="action" label="操作" width="160" />
<el-table-column prop="result" label="结果" width="70">
<template #default="{row}">
<el-tag :type="row.result==='ok'?'success':'danger'" size="small">{{ row.result }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="ip" label="IP" />
</el-table>
</div>
</el-col>
<el-col :span="12">
<div class="chart-card">
<h4 style="margin:0 0 8px">最近连接</h4>
<el-table :data="data.recent_conn_logs || []" size="small" max-height="280">
<el-table-column prop="common_name" label="用户" />
<el-table-column prop="real_ip" label="来源 IP" width="160" />
<el-table-column prop="vpn_ip" label="VPN IP" width="140" />
<el-table-column prop="bytes_in" label="下行" width="100">
<template #default="{row}">{{ fmtBytes(row.bytes_in) }}</template>
</el-table-column>
<el-table-column prop="bytes_out" label="上行" width="100">
<template #default="{row}">{{ fmtBytes(row.bytes_out) }}</template>
</el-table-column>
</el-table>
</div>
</el-col>
</el-row>
</div>
</template>
<script setup>
import { onMounted, onBeforeUnmount, ref } from 'vue'
import { Dash } from '@/api'
const data = ref({})
let timer = null
async function refresh() {
data.value = await Dash.get()
}
function fmtBytes(n) {
if (!n) return '0'
const u = ['B','KB','MB','GB','TB']
let i = 0, v = n
while (v >= 1024 && i < u.length-1) { v /= 1024; i++ }
return v.toFixed(1)+' '+u[i]
}
onMounted(() => { refresh(); timer = setInterval(refresh, 10000) })
onBeforeUnmount(() => { if (timer) clearInterval(timer) })
</script>
+102
View File
@@ -0,0 +1,102 @@
<template>
<div>
<h2 style="margin-top:0">实例管理</h2>
<div style="margin-bottom:12px">
<el-button type="primary" @click="openCreate"><el-icon><Plus /></el-icon>新建实例</el-button>
<el-button @click="load"><el-icon><Refresh /></el-icon>刷新</el-button>
</div>
<el-table :data="list" stripe>
<el-table-column prop="name" label="名称" />
<el-table-column prop="port" label="端口" width="80" />
<el-table-column prop="proto" label="协议" width="80" />
<el-table-column prop="dev" label="设备" width="80" />
<el-table-column prop="subnet" label="子网" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{row}">
<el-tag :type="row.status==='running'?'success':'info'" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="320">
<template #default="{row}">
<el-button size="small" type="primary" :disabled="row.status==='running'" @click="start(row)">启动</el-button>
<el-button size="small" type="warning" :disabled="row.status!=='running'" @click="stop(row)">停止</el-button>
<el-button size="small" @click="goUsers(row)">用户</el-button>
<el-button size="small" @click="openEdit(row)">编辑</el-button>
<el-popconfirm title="确定删除实例及其所有用户/证书?" @confirm="del(row)">
<template #reference><el-button size="small" type="danger">删除</el-button></template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dlg" :title="form.id?'编辑实例':'新建实例'" width="560px">
<el-form :model="form" label-width="100px">
<el-form-item label="名称">
<el-input v-model="form.name" :disabled="!!form.id" placeholder="字母数字下划线,作为目录名" />
</el-form-item>
<el-form-item label="端口"><el-input-number v-model="form.port" :min="1" :max="65535" /></el-form-item>
<el-form-item label="协议">
<el-select v-model="form.proto"><el-option label="udp" value="udp"/><el-option label="tcp" value="tcp"/></el-select>
</el-form-item>
<el-form-item label="设备">
<el-select v-model="form.dev"><el-option label="tun" value="tun"/><el-option label="tap" value="tap"/></el-select>
</el-form-item>
<el-form-item label="子网"><el-input v-model="form.subnet" placeholder="10.8.0.0/24"/></el-form-item>
<el-form-item label="加密">
<el-select v-model="form.cipher"><el-option label="AES-256-GCM" value="AES-256-GCM"/><el-option label="AES-128-GCM" value="AES-128-GCM"/><el-option label="CHACHA20-POLY1305" value="CHACHA20-POLY1305"/></el-select>
</el-form-item>
<el-form-item label="摘要">
<el-select v-model="form.auth_digest"><el-option label="SHA256" value="SHA256"/><el-option label="SHA512" value="SHA512"/></el-select>
</el-form-item>
<el-form-item label="推送 DNS"><el-input v-model="form.push_dns" type="textarea" :rows="3" placeholder="每行一条,例如 dhcp-option DNS 8.8.8.8"/></el-form-item>
<el-form-item label="推送路由"><el-input v-model="form.push_routes" type="textarea" :rows="3" placeholder="每行一条 CIDR"/></el-form-item>
<el-form-item label="自定义"><el-input v-model="form.extra" type="textarea" :rows="3" placeholder="追加到 server.conf 末尾"/></el-form-item>
</el-form>
<template #footer>
<el-button @click="dlg=false">取消</el-button>
<el-button type="primary" @click="submit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Inst } from '@/api'
import { ElMessage } from 'element-plus'
const list = ref([])
const dlg = ref(false)
const form = reactive({})
const router = useRouter()
async function load() { list.value = await Inst.list() }
function openCreate() {
Object.assign(form, { name:'', port:1194, proto:'udp', dev:'tun', subnet:'10.8.0.0/24', cipher:'AES-256-GCM', auth_digest:'SHA256', push_dns:'', push_routes:'', extra:'', id:'' })
dlg.value = true
}
function openEdit(row) {
Object.assign(form, row)
dlg.value = true
}
async function submit() {
try {
if (form.id) {
await Inst.update(form.id, form)
ElMessage.success('已保存')
} else {
await Inst.create(form)
ElMessage.success('已创建')
}
dlg.value = false
load()
} catch(e){}
}
async function start(row) { await Inst.start(row.id); ElMessage.success('已发送启动指令'); load() }
async function stop(row) { await Inst.stop(row.id); ElMessage.success('已停止'); load() }
async function del(row) { await Inst.delete(row.id); ElMessage.success('已删除'); load() }
function goUsers(row) { router.push({ path:'/users', query: { instance: row.id } }) }
onMounted(load)
</script>
+46
View File
@@ -0,0 +1,46 @@
<template>
<div class="login-page">
<div class="login-card">
<h2>OpenVPN 管理控制台</h2>
<p class="muted">请使用管理员账号登录</p>
<el-form :model="form" label-width="80px" @submit.prevent="submit">
<el-form-item label="用户名">
<el-input v-model="form.username" placeholder="admin" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="form.password" type="password" show-password @keyup.enter="submit" />
</el-form-item>
<el-button type="primary" :loading="loading" style="width:100%" @click="submit">登录</el-button>
</el-form>
<p class="muted">首次部署默认账号 admin / admin123请在系统中修改</p>
</div>
</div>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Auth } from '@/api'
import { ElMessage } from 'element-plus'
const router = useRouter()
const form = reactive({ username: 'admin', password: 'admin123' })
const loading = ref(false)
async function submit() {
loading.value = true
try {
const r = await Auth.login(form.username, form.password)
localStorage.setItem('token', r.token)
ElMessage.success('登录成功')
router.replace('/dashboard')
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-page { min-height:100vh; background:linear-gradient(135deg,#1d4ed8,#0ea5e9); display:flex; align-items:center; justify-content:center; }
.login-card { width:380px; background:#fff; padding:32px; border-radius:10px; box-shadow:0 8px 24px rgba(0,0,0,.12); }
h2 { margin:0 0 4px; }
.muted { color:#909399; font-size:13px; margin:8px 0 22px; }
</style>
+45
View File
@@ -0,0 +1,45 @@
<template>
<div>
<h2 style="margin-top:0">连接日志</h2>
<div style="margin-bottom:12px">
<el-select v-model="instanceId" placeholder="选择实例" clearable @change="load" style="width:240px">
<el-option v-for="i in instances" :key="i.id" :value="i.id" :label="i.name" />
</el-select>
</div>
<el-table :data="list" stripe>
<el-table-column prop="common_name" label="用户" />
<el-table-column prop="real_ip" label="来源 IP" width="160" />
<el-table-column prop="vpn_ip" label="VPN IP" width="140" />
<el-table-column prop="bytes_in" label="下行" width="100">
<template #default="{row}">{{ fmt(row.bytes_in) }}</template>
</el-table-column>
<el-table-column prop="bytes_out" label="上行" width="100">
<template #default="{row}">{{ fmt(row.bytes_out) }}</template>
</el-table-column>
<el-table-column prop="connected_at" label="开始时间" width="170">
<template #default="{row}">{{ row.connected_at?.slice(0,19) }}</template>
</el-table-column>
<el-table-column prop="disconnected_at" label="结束时间" width="170">
<template #default="{row}">{{ row.disconnected_at?.slice(0,19) || '-' }}</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { Logs, Inst } from '@/api'
const list = ref([])
const instances = ref([])
const instanceId = ref('')
async function load() { list.value = await Logs.conns(instanceId.value) }
function fmt(n) {
if (!n) return '0'
const u = ['B','KB','MB','GB','TB']; let i=0,v=n
while (v>=1024 && i<u.length-1) { v/=1024; i++ }
return v.toFixed(1)+' '+u[i]
}
onMounted(async () => { instances.value = await Inst.list(); load() })
</script>
+110
View File
@@ -0,0 +1,110 @@
<template>
<div>
<h2 style="margin-top:0">用户管理</h2>
<el-row :gutter="12" style="margin-bottom:12px">
<el-col :span="8">
<el-select v-model="instanceId" placeholder="选择实例" filterable @change="loadUsers" style="width:100%">
<el-option v-for="i in instances" :key="i.id" :value="i.id" :label="`${i.name} (${i.proto}/${i.port})`" />
</el-select>
</el-col>
<el-col :span="6"><el-input v-model="host" placeholder="客户端连接的远端域名/IP" /></el-col>
<el-col :span="4"><el-button @click="loadUsers">刷新</el-button></el-col>
<el-col :span="6" style="text-align:right">
<el-button type="primary" :disabled="!instanceId" @click="dlg=true"><el-icon><Plus /></el-icon>新建用户</el-button>
</el-col>
</el-row>
<el-table :data="users" stripe>
<el-table-column prop="username" label="用户名(CN)" />
<el-table-column prop="real_name" label="备注" />
<el-table-column prop="email" label="邮箱" />
<el-table-column prop="static_ip" label="固定 IP" width="140" />
<el-table-column prop="enabled" label="状态" width="100">
<template #default="{row}">
<el-tag :type="row.enabled?'success':'danger'" size="small">{{ row.enabled?'启用':'已吊销' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="170">
<template #default="{row}">{{ row.created_at?.slice(0,19) }}</template>
</el-table-column>
<el-table-column label="操作" width="260">
<template #default="{row}">
<el-button size="small" @click="download(row)">下载 .ovpn</el-button>
<el-button v-if="row.enabled" size="small" type="warning" @click="revoke(row)">吊销</el-button>
<el-popconfirm title="确定删除此用户及其证书?" @confirm="del(row)">
<template #reference><el-button size="small" type="danger">删除</el-button></template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dlg" title="新建用户" width="500px">
<el-form :model="form" label-width="100px">
<el-form-item label="用户名(CN)"><el-input v-model="form.username" placeholder="字母数字下划线"/></el-form-item>
<el-form-item label="备注"><el-input v-model="form.real_name" /></el-form-item>
<el-form-item label="邮箱"><el-input v-model="form.email" /></el-form-item>
<el-form-item label="固定 IP"><el-input v-model="form.static_ip" placeholder="可选, 如 10.8.0.10" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="dlg=false">取消</el-button>
<el-button type="primary" @click="submit">生成并签发</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { Inst } from '@/api'
import { ElMessage } from 'element-plus'
const route = useRoute()
const instances = ref([])
const users = ref([])
const instanceId = ref('')
const host = ref(window.location.hostname)
const dlg = ref(false)
const form = reactive({ username:'', real_name:'', email:'', static_ip:'' })
async function loadInstances() {
instances.value = await Inst.list()
if (!instanceId.value && instances.value.length) {
instanceId.value = route.query.instance || instances.value[0].id
loadUsers()
}
}
async function loadUsers() {
if (!instanceId.value) return
users.value = await Inst.listUsers(instanceId.value)
}
watch(() => route.query.instance, v => { if (v) { instanceId.value = v; loadUsers() } })
async function submit() {
if (!form.username) return ElMessage.warning('请输入用户名')
await Inst.createUser(instanceId.value, { ...form })
ElMessage.success('已创建并签发证书')
dlg.value = false
Object.assign(form, { username:'', real_name:'', email:'', static_ip:'' })
loadUsers()
}
async function revoke(row) {
await Inst.revokeUser(instanceId.value, row.id)
ElMessage.success('已吊销')
loadUsers()
}
async function del(row) {
await Inst.deleteUser(instanceId.value, row.id)
ElMessage.success('已删除')
loadUsers()
}
async function download(row) {
const tok = localStorage.getItem('token')
const url = Inst.ovpnUrl(instanceId.value, row.id, host.value)
const a = document.createElement('a')
a.href = url + (url.includes('?')?'&':'?') + `token=${encodeURIComponent(tok)}`
a.download = `${row.username}.ovpn`
document.body.appendChild(a); a.click(); a.remove()
}
onMounted(loadInstances)
</script>