feat: 新增 GRE 隧道协议,管理端口调整为 18080

- backend: 新增 GRE 隧道实现(ip tunnel 点对点互联),binmgr 支持 iproute2
- backend: api/identity 调整,各协议 service(gre/ikev2/ipsec/l2tp/openvpn)统一改动
- backend: OpenVPN 认证辅助与用户口令同步逻辑完善
- frontend: 协议图标/向导/编辑器支持 GRE,VPN 用户页重构(234 行)
- frontend: 新增 RouteListInput 路由列表输入组件,Dashboard/Layout 优化
- i18n: 中英文文案同步更新
- test: 新增 binmgr/common/gre 单测,openvpn 测试补充
- docs: README 补充 GRE 说明,默认监听端口 8080 → 18080
- chore: .gitignore 增加 opsx-vpn.previous(构建备份二进制)
This commit is contained in:
opsx committed 2026-10-20 20:58:58 +08:00
1 parent c7f59f2cbf
commit ef7eb378d2
28 files changed
+1363 -439

No files matched your search

+64 -1
View File
@@ -17,7 +17,8 @@
<el-form-item :label="$t('tunnelEditor.protocolType')">
<el-tag :color="protocolColor(protocol)" effect="dark" style="border:none">{{ $t(protocolDisplay(protocol)) }}</el-tag>
</el-form-item>
<el-form-item v-for="field in visibleFields" :key="field.key" :label="$t(field.label)" :required="field.required">
<el-divider v-if="isOpenVPN" content-position="left" class="config-section-divider">{{ $t('tunnelEditor.coreOptions') }}</el-divider>
<el-form-item v-for="field in coreFields" :key="field.key" :label="$t(field.label)" :required="field.required">
<el-input-number v-if="field.type === 'number'" v-model="form.config[field.key]" :min="0" :max="65535" />
<el-switch v-else-if="field.type === 'switch'" v-model="form.config[field.key]" />
<el-select v-else-if="field.type === 'select'" v-model="form.config[field.key]" style="width:100%">
@@ -28,6 +29,34 @@
<el-input v-else v-model="form.config[field.key]" :placeholder="field.placeholder || field.default" />
<div v-if="field.tip" class="field-tip">{{ $t(field.tip) }}</div>
</el-form-item>
<template v-if="isOpenVPN">
<el-form-item :label="$t('tunnelEditor.trafficMode')">
<el-radio-group v-model="trafficMode">
<el-radio-button value="gateway">{{ $t('tunnelEditor.trafficGateway') }}</el-radio-button>
<el-radio-button value="static">{{ $t('tunnelEditor.trafficStatic') }}</el-radio-button>
</el-radio-group>
<div class="field-tip">{{ $t(`tunnelEditor.traffic${trafficMode[0].toUpperCase()}${trafficMode.slice(1)}Tip`) }}</div>
</el-form-item>
<el-form-item v-if="trafficMode === 'static'" :label="$t('protocols.openvpn.fields.push_routes.label')">
<RouteListInput ref="routeListRef" v-model="form.config.push_routes" />
<div class="field-tip">{{ $t('protocols.openvpn.fields.push_routes.tip') }}</div>
</el-form-item>
</template>
<el-collapse v-if="advancedFields.length" v-model="advancedOpen" class="advanced-options">
<el-collapse-item :title="$t('tunnelEditor.advancedOptions')" name="advanced">
<el-form-item v-for="field in advancedFields" :key="field.key" :label="$t(field.label)" :required="field.required">
<el-input-number v-if="field.type === 'number'" v-model="form.config[field.key]" :min="0" :max="65535" />
<el-switch v-else-if="field.type === 'switch'" v-model="form.config[field.key]" />
<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="optionValue(option)" :label="$t(optionLabel(option))" :value="optionValue(option)" />
</el-select>
<el-input v-else-if="field.type === 'password'" v-model="form.config[field.key]" type="password"
show-password :placeholder="field.placeholder || $t('tunnelEditor.keepOriginal')" />
<el-input v-else v-model="form.config[field.key]" :placeholder="field.placeholder || field.default" />
<div v-if="field.tip" class="field-tip">{{ $t(field.tip) }}</div>
</el-form-item>
</el-collapse-item>
</el-collapse>
<el-form-item :label="$t('tunnelEditor.expireTime')">
<el-date-picker v-model="form.expire_at" type="datetime" clearable :placeholder="$t('tunnelEditor.expirePlaceholder')" style="width:100%" />
</el-form-item>
@@ -47,6 +76,7 @@ import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import api from '../api'
import RouteListInput from '../components/RouteListInput.vue'
import { CUSTOM_FIELDS, PROTOCOL_META, protocolColor, protocolDisplay, optionValue, optionLabel } from '../protocols'
const { t } = useI18n()
@@ -56,6 +86,8 @@ const loading = ref(true)
const saving = ref(false)
const protocol = ref('')
const form = ref(null)
const advancedOpen = ref([])
const routeListRef = ref(null)
const fields = computed(() => PROTOCOL_META[protocol.value]?.fields || CUSTOM_FIELDS)
const visibleFields = computed(() => fields.value.filter(field => {
@@ -64,6 +96,33 @@ const visibleFields = computed(() => fields.value.filter(field => {
? value.includes(form.value.config[key])
: form.value.config[key] === value)
}))
const isOpenVPN = computed(() => protocol.value === 'openvpn')
const openVPNCoreKeys = new Set(['public_endpoint', 'port', 'proto', 'auth_mode', 'mfa_mode'])
const openVPNTrafficKeys = new Set(['redirect_gateway', 'push_routes', 'enable_nat'])
const trafficMode = computed({
get() {
const cfg = form.value?.config || {}
return cfg.redirect_gateway === false ? 'static' : 'gateway'
},
set(mode) {
if (!form.value) return
if (mode === 'gateway') {
form.value.config.redirect_gateway = true
form.value.config.enable_nat = true
form.value.config.push_routes = ''
} else if (mode === 'static') {
form.value.config.redirect_gateway = false
form.value.config.enable_nat = false
}
},
})
const coreFields = computed(() => isOpenVPN.value
? visibleFields.value.filter(field => openVPNCoreKeys.has(field.key))
: visibleFields.value.filter(field => field.group !== 'advanced' && field.group !== 'crypto'))
const advancedFields = computed(() => {
if (!isOpenVPN.value) return visibleFields.value.filter(field => field.group === 'advanced' || field.group === 'crypto')
return visibleFields.value.filter(field => !openVPNCoreKeys.has(field.key) && !openVPNTrafficKeys.has(field.key))
})
function parseConfig(raw) {
try { return JSON.parse(raw || '{}') } catch { return {} }
@@ -95,6 +154,8 @@ async function load() {
async function save() {
if (!form.value.name) return ElMessage.warning(t('tunnelEditor.fillName'))
trafficMode.value = trafficMode.value
if (isOpenVPN.value && routeListRef.value && !routeListRef.value.validate()) return
saving.value = true
try {
await api.put(`/tunnels/${route.params.id}`, form.value)
@@ -112,4 +173,6 @@ onMounted(load)
<style scoped>
.field-tip { margin-top: 4px; color: #909399; font-size: 12px; line-height: 1.4; }
.config-section-divider { margin: 4px 0 18px; }
.advanced-options { margin: 12px 0 18px; }
</style>