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:
cnbugs
2026-08-09 20:47:21 +08:00
parent 77f8b59290
commit 4c8b7b5188
11 changed files with 697 additions and 64 deletions
+1
View File
@@ -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||'')}`,
+92
View File
@@ -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>
+24 -1
View File
@@ -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">
+32 -2
View File
@@ -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() {
+41 -11
View File
@@ -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)