Add per-instance/user access whitelist
Features:
- New Instance.AccessMode: "open" (default) or "whitelist"
- New Instance.AllowNetworks + VPNUser.AllowNetworks: list of CIDRs
- Effective whitelist = instance allow_networks ∪ user allow_networks (dedup)
- Auto-generates client-connect.sh / client-disconnect.sh for OpenVPN:
* Reads ccd/<cn> to extract CIDRs
* Pushes "route <ip> <mask>" to client (client side)
* Inserts iptables ACCEPT rules in FORWARD chain (server side, defense in depth)
* Cleans up rules on disconnect
- server.conf auto-includes client-connect / client-disconnect directives
and push "redirect-gateway def1 bypass-dhcp" in whitelist mode
- ccd/<cn> file format: first line ifconfig-push (static IP), then one CIDR per line
- Editing instance allow_networks refreshes all users' ccd automatically
- New PUT /api/instances/:id/users/:uid endpoint
- CIDR format validation; reject malformed inputs with friendly errors
- Dashboard shows whitelist_instances count and per-instance allow_networks table
Docs:
- README: new section "三、访问控制(白名单模式)" with usage, validation, pitfalls
- docs/API.md: updated Instance / VPNUser model + create/update payloads
- Renumbered client usage section as 四
This commit is contained in:
@@ -44,6 +44,7 @@ export const Inst = {
|
||||
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),
|
||||
updateUser: (id, uid, data) => api.put(`/instances/${id}/users/${uid}`, 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||'')}`,
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div class="netlist">
|
||||
<el-tag
|
||||
v-for="(item, idx) in list"
|
||||
:key="idx"
|
||||
:type="valid(item) ? 'success' : 'danger'"
|
||||
closable
|
||||
style="margin: 0 6px 6px 0"
|
||||
@close="remove(idx)"
|
||||
>
|
||||
{{ item }}
|
||||
</el-tag>
|
||||
<el-input
|
||||
v-if="adding"
|
||||
ref="inputRef"
|
||||
v-model="draft"
|
||||
size="small"
|
||||
placeholder="如 192.168.1.0/24,回车确认"
|
||||
style="width: 200px"
|
||||
@keyup.enter="commit"
|
||||
@blur="commit"
|
||||
/>
|
||||
<el-button v-else size="small" type="primary" plain @click="startAdd">
|
||||
<el-icon><Plus /></el-icon>添加
|
||||
</el-button>
|
||||
<div v-if="invalidMsg" class="invalid">{{ invalidMsg }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Array, default: () => [] }
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const list = ref([...(props.modelValue || [])])
|
||||
const adding = ref(false)
|
||||
const draft = ref('')
|
||||
const invalidMsg = ref('')
|
||||
const inputRef = ref(null)
|
||||
|
||||
function valid(s) {
|
||||
if (!s) return false
|
||||
const cidr = /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(s.trim())
|
||||
return cidr
|
||||
}
|
||||
|
||||
function sync() {
|
||||
emit('update:modelValue', [...list.value])
|
||||
}
|
||||
|
||||
function remove(i) {
|
||||
list.value.splice(i, 1)
|
||||
sync()
|
||||
}
|
||||
|
||||
async function startAdd() {
|
||||
adding.value = true
|
||||
draft.value = ''
|
||||
invalidMsg.value = ''
|
||||
await nextTick()
|
||||
inputRef.value?.focus?.()
|
||||
}
|
||||
|
||||
function commit() {
|
||||
const v = draft.value.trim()
|
||||
if (!v) { adding.value = false; return }
|
||||
if (!valid(v)) {
|
||||
invalidMsg.value = `非法 CIDR: ${v} (格式 192.168.1.0/24)`
|
||||
draft.value = ''
|
||||
return
|
||||
}
|
||||
if (list.value.includes(v)) {
|
||||
invalidMsg.value = `已存在: ${v}`
|
||||
draft.value = ''
|
||||
return
|
||||
}
|
||||
list.value.push(v)
|
||||
sync()
|
||||
draft.value = ''
|
||||
invalidMsg.value = ''
|
||||
adding.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.netlist { display: flex; flex-wrap: wrap; align-items: center; }
|
||||
.invalid { color: #f56c6c; font-size: 12px; margin-top: 4px; width: 100%; }
|
||||
</style>
|
||||
@@ -2,11 +2,34 @@
|
||||
<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.instances || 0 }}</div><div class="muted">运行中 {{ data.running || 0 }} · 白名单 {{ data.whitelist_instances || 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.access_overview || []" size="small" max-height="240">
|
||||
<el-table-column prop="name" label="实例" />
|
||||
<el-table-column label="模式" width="100">
|
||||
<template #default="{row}">
|
||||
<el-tag :type="row.access_mode==='whitelist'?'warning':'info'" size="small">
|
||||
{{ row.access_mode==='whitelist' ? '白名单' : '开放' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="allow_networks" label="允许网段" min-width="240">
|
||||
<template #default="{row}">
|
||||
<span v-if="!row.allow_networks || !row.allow_networks.length" class="muted">无(全拒绝)</span>
|
||||
<el-tag v-for="n in row.allow_networks" :key="n" size="small" style="margin:0 4px 4px 0">{{ n }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="16" style="margin-top:16px">
|
||||
<el-col :span="12">
|
||||
|
||||
@@ -12,6 +12,16 @@
|
||||
<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 label="访问控制" width="110">
|
||||
<template #default="{row}">
|
||||
<el-tag :type="row.access_mode==='whitelist'?'warning':'info'" size="small">
|
||||
{{ row.access_mode==='whitelist' ? '白名单' : '开放' }}
|
||||
</el-tag>
|
||||
<div class="muted" v-if="row.allow_networks && row.allow_networks.length">
|
||||
{{ row.allow_networks.length }} 个网段
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<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>
|
||||
@@ -51,6 +61,25 @@
|
||||
</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-select v-model="form.access_mode" style="width:200px">
|
||||
<el-option label="开放(open)" value="open"/>
|
||||
<el-option label="白名单(whitelist)" value="whitelist"/>
|
||||
</el-select>
|
||||
<div class="muted" style="margin-top:4px">
|
||||
<span v-if="form.access_mode==='whitelist'">
|
||||
客户端只能访问下方列出的内网网段;其他流量在服务端被丢弃。需要 root + iptables。
|
||||
</span>
|
||||
<span v-else>不限制,客户端可访问所有可达网段。</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="允许网段">
|
||||
<NetworkList v-model="form.allow_networks" />
|
||||
<div class="muted" style="margin-top:4px">
|
||||
每行一个 CIDR(如 <code>192.168.1.0/24</code>),仅在"白名单"模式下生效。
|
||||
留空则所有内网都被拒绝(最严格)。
|
||||
</div>
|
||||
</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>
|
||||
@@ -65,6 +94,7 @@ import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Inst } from '@/api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import NetworkList from '@/components/NetworkList.vue'
|
||||
|
||||
const list = ref([])
|
||||
const dlg = ref(false)
|
||||
@@ -73,11 +103,11 @@ 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:'' })
|
||||
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:'', access_mode:'open', allow_networks:[], id:'' })
|
||||
dlg.value = true
|
||||
}
|
||||
function openEdit(row) {
|
||||
Object.assign(form, row)
|
||||
Object.assign(form, row, { allow_networks: row.allow_networks || [] })
|
||||
dlg.value = true
|
||||
}
|
||||
async function submit() {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<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-button type="primary" :disabled="!instanceId" @click="openCreate"><el-icon><Plus /></el-icon>新建用户</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
@@ -19,6 +19,12 @@
|
||||
<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 label="允许网段" width="180">
|
||||
<template #default="{row}">
|
||||
<span v-if="!row.allow_networks || !row.allow_networks.length" class="muted">继承实例</span>
|
||||
<el-tag v-for="n in row.allow_networks" :key="n" size="small" style="margin:0 4px 4px 0">{{ n }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="enabled" label="状态" width="100">
|
||||
<template #default="{row}">
|
||||
<el-tag :type="row.enabled?'success':'danger'" size="small">{{ row.enabled?'启用':'已吊销' }}</el-tag>
|
||||
@@ -27,9 +33,10 @@
|
||||
<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">
|
||||
<el-table-column label="操作" width="300">
|
||||
<template #default="{row}">
|
||||
<el-button size="small" @click="download(row)">下载 .ovpn</el-button>
|
||||
<el-button size="small" @click="openEdit(row)">编辑</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>
|
||||
@@ -38,16 +45,24 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="dlg" title="新建用户" width="500px">
|
||||
<el-dialog v-model="dlg" :title="form.id?'编辑用户':'新建用户'" width="560px">
|
||||
<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="用户名(CN)">
|
||||
<el-input v-model="form.username" :disabled="!!form.id" 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-item label="额外允许网段">
|
||||
<NetworkList v-model="form.allow_networks" />
|
||||
<div class="muted" style="margin-top:4px">
|
||||
这些网段会在实例级基础上<b>额外</b>开放给该用户。留空则只继承实例级。
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dlg=false">取消</el-button>
|
||||
<el-button type="primary" @click="submit">生成并签发</el-button>
|
||||
<el-button type="primary" @click="submit">{{ form.id ? '保存' : '生成并签发' }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
@@ -57,6 +72,7 @@ import { onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Inst } from '@/api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import NetworkList from '@/components/NetworkList.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const instances = ref([])
|
||||
@@ -64,7 +80,7 @@ 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:'' })
|
||||
const form = reactive({ id:'', username:'', real_name:'', email:'', static_ip:'', allow_networks:[] })
|
||||
|
||||
async function loadInstances() {
|
||||
instances.value = await Inst.list()
|
||||
@@ -79,13 +95,27 @@ async function loadUsers() {
|
||||
}
|
||||
watch(() => route.query.instance, v => { if (v) { instanceId.value = v; loadUsers() } })
|
||||
|
||||
function openCreate() {
|
||||
Object.assign(form, { id:'', username:'', real_name:'', email:'', static_ip:'', allow_networks:[] })
|
||||
dlg.value = true
|
||||
}
|
||||
function openEdit(row) {
|
||||
Object.assign(form, row, { allow_networks: row.allow_networks || [] })
|
||||
dlg.value = true
|
||||
}
|
||||
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()
|
||||
try {
|
||||
if (form.id) {
|
||||
await Inst.updateUser(instanceId.value, form.id, form)
|
||||
ElMessage.success('已保存')
|
||||
} else {
|
||||
await Inst.createUser(instanceId.value, { ...form })
|
||||
ElMessage.success('已创建并签发证书')
|
||||
}
|
||||
dlg.value = false
|
||||
loadUsers()
|
||||
} catch(e) {}
|
||||
}
|
||||
async function revoke(row) {
|
||||
await Inst.revokeUser(instanceId.value, row.id)
|
||||
|
||||
Reference in New Issue
Block a user