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:
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
||||
<title>OpenVPN 管理控制台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1877
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "openvpn-manager-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 4173"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"axios": "^1.7.7",
|
||||
"echarts": "^5.5.1",
|
||||
"element-plus": "^2.8.4",
|
||||
"pinia": "^2.2.4",
|
||||
"vite": "^5.4.8",
|
||||
"vue": "^3.5.10",
|
||||
"vue-echarts": "^7.0.3",
|
||||
"vue-router": "^4.4.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
<script setup></script>
|
||||
@@ -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
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: { '@': path.resolve(__dirname, 'src') }
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': { target: 'http://127.0.0.1:8089', changeOrigin: true }
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: '../dist',
|
||||
emptyOutDir: true,
|
||||
sourcemap: false,
|
||||
chunkSizeWarningLimit: 2000
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user