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:
1 parent
c7f59f2cbf
commit
ef7eb378d2
28 files changed
+1363
-439
No files matched your search
@@ -33,6 +33,7 @@ const NAMES = computed(() => ({
|
||||
ipsec: 'IPSec',
|
||||
ikev2: 'IKEv2',
|
||||
l2tp: 'L2TP',
|
||||
gre: 'GRE',
|
||||
frp: 'FRP',
|
||||
openvpn: 'OpenVPN',
|
||||
custom: t('protocolIcon.custom'),
|
||||
@@ -62,6 +63,9 @@ const ICON_PATHS = {
|
||||
// L2TP — 手机+信号波(移动设备拨号语义)
|
||||
l2tp: 'M7 1C5.9 1 5 1.9 5 3v18c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V3c0-1.1-.9-2-2-2H7zm5 19c-.83 0-1.5-.67-1.5-1.5S11.17 17 12 17s1.5.67 1.5 1.5S12.83 20 12 20zm5-4H7V4h10v12z M9.5 6.5l2 2-2 2-1-1 1-1-1-1 1-1z M14.5 6.5l1 1-1 1 1 1-1 1-2-2 2-2z',
|
||||
|
||||
// GRE — two routed sites connected by a tunnel
|
||||
gre: 'M3 4h6v3H6v10h3v3H3V4zm12 0h6v16h-6v-3h3V7h-3V4zM9 10h6v2H9v-2zm0 4h6v2H9v-2z',
|
||||
|
||||
// FRP — 隧道+箭头穿透(内网穿透语义)
|
||||
frp: 'M3 8h2v8H3V8zm16 0h2v8h-2V8zM7 10h10v4H7v-4zm2 1.5l2 1.5-2 1.5v-3zm6 0v3l-2-1.5 2-1.5z M12 6l3 2H9l3-2zm0 12l-3-2h6l-3 2z',
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<div class="route-list">
|
||||
<el-input v-model="content" type="textarea" :rows="7" resize="vertical"
|
||||
:placeholder="t('protocols.openvpn.fields.push_routes.placeholder')" @blur="validate" />
|
||||
<span v-if="error" class="route-error">{{ error }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = defineProps({ modelValue: { type: String, default: '' } })
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const { t } = useI18n()
|
||||
const content = ref(props.modelValue || '')
|
||||
const error = ref('')
|
||||
|
||||
watch(() => props.modelValue, value => {
|
||||
if (value !== content.value) content.value = value || ''
|
||||
})
|
||||
watch(content, value => emit('update:modelValue', value))
|
||||
function validRoute(value) {
|
||||
const [ip, prefix] = value.split('/')
|
||||
const parts = ip.split('.')
|
||||
if (parts.length !== 4 || parts.some(part => !/^\d+$/.test(part) || Number(part) > 255)) return false
|
||||
return prefix === undefined || (/^\d+$/.test(prefix) && Number(prefix) >= 0 && Number(prefix) <= 32)
|
||||
}
|
||||
function validate() {
|
||||
const filled = String(content.value).split('\n').flatMap(line => line.split('#', 1)[0].split(',')).map(item => item.trim()).filter(Boolean)
|
||||
const invalid = filled.find(item => !validRoute(item))
|
||||
error.value = invalid ? t('protocols.openvpn.fields.push_routes.format_error', { route: invalid }) : ''
|
||||
if (!invalid && content.value !== content.value.trim()) {
|
||||
content.value = content.value.trim()
|
||||
}
|
||||
return !invalid
|
||||
}
|
||||
defineExpose({ validate })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.route-list { display:grid; gap:8px; width:100%; }
|
||||
.route-error { color:var(--el-color-danger); font-size:12px; }
|
||||
</style>
|
||||
@@ -23,7 +23,7 @@
|
||||
"name": "Name",
|
||||
"description": "Description",
|
||||
"username": "Username",
|
||||
"password": "***",
|
||||
"password": "Password",
|
||||
"email": "Email",
|
||||
"source": "Source",
|
||||
"local": "Local",
|
||||
@@ -57,9 +57,9 @@
|
||||
"login": {
|
||||
"title": "VPN Management Platform",
|
||||
"subtitle": "Unified multi-protocol VPN management — simple, secure, and controllable",
|
||||
"protocols": "WireGuard · IPSec · L2TP · FRP · OpenVPN · Custom Protocol Plugins",
|
||||
"protocols": "WireGuard · IPSec · IKEv2 · L2TP · GRE · FRP · OpenVPN",
|
||||
"feature1Title": "Multi-Protocol Unified Management",
|
||||
"feature1Desc": "WireGuard / IPSec / L2TP / FRP / OpenVPN",
|
||||
"feature1Desc": "WireGuard / IPSec / IKEv2 / L2TP / GRE / OpenVPN",
|
||||
"feature2Title": "Enterprise-Grade Security",
|
||||
"feature2Desc": "AES-GCM encrypted keys + Two-Factor Authentication",
|
||||
"feature3Title": "One-Click Configuration",
|
||||
@@ -89,6 +89,10 @@
|
||||
"totalTraffic": "Total Traffic ↓↑",
|
||||
"protocolDistribution": "Protocol Distribution",
|
||||
"protocolEngineStatus": "Protocol Engine Status",
|
||||
"hostReadinessWarning": "Host prerequisites are incomplete. VPN forwarding or NAT may not work correctly.",
|
||||
"readinessRoot": "Service is not running as root",
|
||||
"readinessForwarding": "IPv4 forwarding is disabled",
|
||||
"readinessRoute": "No default egress route detected",
|
||||
"depCheckResult": "System dependency check results",
|
||||
"depReady": "Ready",
|
||||
"demoMode": "Demo Mode",
|
||||
@@ -217,6 +221,15 @@
|
||||
"sensitiveFieldHint": "Sensitive fields like passwords, PSK, and tokens are not displayed. Leave empty to keep the current value.",
|
||||
"tunnelName": "Tunnel Name",
|
||||
"protocolType": "Protocol Type",
|
||||
"coreOptions": "Core Connection Settings",
|
||||
"advancedOptions": "Detailed Settings",
|
||||
"trafficMode": "Traffic Egress",
|
||||
"trafficGateway": "All Traffic via VPN Gateway",
|
||||
"trafficStatic": "Static Routes Only",
|
||||
"trafficCustom": "Custom",
|
||||
"trafficGatewayTip": "Routes all client traffic through the VPN gateway with NAT egress.",
|
||||
"trafficStaticTip": "Pushes only the target networks below and preserves the client's default Internet route.",
|
||||
"trafficCustomTip": "Control the default route, static routes, and NAT separately in detailed settings.",
|
||||
"keepOriginal": "Leave empty to keep original",
|
||||
"expireTime": "Expiry Time",
|
||||
"expirePlaceholder": "Leave empty for permanent",
|
||||
@@ -241,6 +254,14 @@
|
||||
"namePlaceholder": "e.g., HQ-Branch Interconnect",
|
||||
"protocolType": "Protocol Type",
|
||||
"reselect": "Reselect",
|
||||
"coreOptions": "Core Connection Settings",
|
||||
"trafficMode": "Traffic Egress",
|
||||
"trafficGateway": "All Traffic via VPN Gateway",
|
||||
"trafficStatic": "Static Routes Only",
|
||||
"trafficCustom": "Custom",
|
||||
"trafficGatewayTip": "Routes all client traffic through the VPN gateway with NAT egress.",
|
||||
"trafficStaticTip": "Pushes only the target networks below and preserves the client's default Internet route.",
|
||||
"trafficCustomTip": "Control the default route, static routes, and NAT separately in detailed settings.",
|
||||
"advancedOptions": "Advanced Options",
|
||||
"expireTime": "Expiry Time",
|
||||
"expirePlaceholder": "Leave empty for permanent",
|
||||
@@ -337,6 +358,20 @@
|
||||
"title": "VPN Dial-in Users",
|
||||
"mfaHint": "OpenVPN Two-Factor Auth (TOTP / Static Challenge / PIN+OTP)",
|
||||
"addUser": "Add User",
|
||||
"bulkImport": "Bulk Import",
|
||||
"bulkImportTitle": "Import Platform Users",
|
||||
"bulkImportHint": "Only OpenVPN supports platform local or LDAP account imports. Create dedicated accounts for L2TP/IKEv2.",
|
||||
"importSelected": "Import Selected",
|
||||
"importedCount": "Imported {count} users",
|
||||
"importFailed": "Bulk import failed",
|
||||
"selectOpenVPNTunnel": "Select OpenVPN Tunnel",
|
||||
"selectTunnelAndUsers": "Select an OpenVPN tunnel and platform users",
|
||||
"searchPlaceholder": "Search username, name, or email",
|
||||
"allTunnels": "All Tunnels",
|
||||
"selectedUsers": "{count} users selected",
|
||||
"batchDelete": "Bulk Delete",
|
||||
"batchDeleteConfirm": "Delete the selected {count} VPN dial-in users?",
|
||||
"batchDeleteDone": "Selected VPN dial-in users deleted",
|
||||
"displayName": "Name",
|
||||
"tunnel": "Tunnel",
|
||||
"otpMfa": "OTP / MFA",
|
||||
@@ -359,6 +394,14 @@
|
||||
"clickRowHint": "Click a row to select and auto-fill",
|
||||
"otpDialogTitle": "Two-Factor Auth — {username}",
|
||||
"otpHint": "TOTP-based two-factor authentication (SHA1 / base32 / 30s / 6 digits), compatible with major Authenticator apps. Supports static challenge and PIN+OTP combo mode.",
|
||||
"mfaNotEnabled": "Two-factor authentication is not enabled",
|
||||
"mfaDisabledHint": "Bind an Authenticator app to require a one-time code when this user signs in to VPN.",
|
||||
"mfaEnabledHint": "This user must provide both their password and a one-time code to sign in to VPN.",
|
||||
"mfaSetupIntro": "Bind an Authenticator app to protect this user's VPN dial-in access.",
|
||||
"manualSetup": "Can't scan? Use a manual key",
|
||||
"verifySetup": "Confirm Binding",
|
||||
"verifySetupHint": "After scanning in Authenticator, enter the current 6-digit code to verify the binding.",
|
||||
"securityDetails": "Security Details",
|
||||
"bindStatus": "Bind Status",
|
||||
"boundTotp": "TOTP Bound",
|
||||
"staticPin": "Static PIN",
|
||||
@@ -766,13 +809,6 @@
|
||||
"label": "Machine Auth PSK",
|
||||
"tip": "Leave empty to auto-generate; shared key for iOS/Android"
|
||||
},
|
||||
"username": {
|
||||
"label": "Default Dial-in User"
|
||||
},
|
||||
"password": {
|
||||
"label": "Default Dial-in Password",
|
||||
"tip": "Leave empty to auto-generate; add more users in VPN Dial-in Users"
|
||||
},
|
||||
"address_pool": {
|
||||
"label": "Client Address Pool"
|
||||
},
|
||||
@@ -800,15 +836,8 @@
|
||||
"label": "Server Public IP"
|
||||
},
|
||||
"psk": {
|
||||
"label": "IPSec Pre-Shared Key"
|
||||
},
|
||||
"username": {
|
||||
"label": "Default Dial-in Username",
|
||||
"tip": "Add more users for this tunnel in VPN Dial-in Users"
|
||||
},
|
||||
"password": {
|
||||
"label": "Default Dial-in Password",
|
||||
"tip": "Leave empty if using only the dial-in user table (at least one method required at creation)"
|
||||
"label": "IPSec Pre-Shared Key",
|
||||
"tip": "Leave blank to generate a high-strength random key; retrieve it securely from client configuration after creation"
|
||||
},
|
||||
"ip_range": {
|
||||
"label": "Client Address Pool"
|
||||
@@ -821,6 +850,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"gre": {
|
||||
"name": "GRE Tunnel",
|
||||
"desc": "Standard IP protocol 47 tunnel for point-to-point Layer 3 connectivity between Linux, routers, and firewalls",
|
||||
"scenes": "Network Device Interconnect / Site-to-Site Routing",
|
||||
"fields": {
|
||||
"local_endpoint": { "label": "Local Public IP" },
|
||||
"remote_endpoint": { "label": "Remote Public IP" },
|
||||
"local_tunnel_ip": { "label": "Local Tunnel IP", "tip": "Use a /30, for example 10.255.0.1/30" },
|
||||
"remote_tunnel_ip": { "label": "Remote Tunnel IP", "tip": "Must share the local tunnel IP subnet" },
|
||||
"remote_subnet": { "label": "Remote LAN Subnet", "tip": "Adds a static route through the GRE peer" },
|
||||
"enable_nat": { "label": "NAT Remote Traffic", "tip": "Enable only when the remote subnet needs Internet egress via this host" },
|
||||
"outbound_interface": { "label": "NAT Egress Interface" },
|
||||
"ttl": { "label": "TTL" },
|
||||
"mtu": { "label": "GRE MTU", "tip": "1476 is typical over public Internet GRE; lower it for additional encapsulation" }
|
||||
}
|
||||
},
|
||||
"frp": {
|
||||
"name": "FRP NAT Traversal",
|
||||
"desc": "TCP/UDP/HTTP port forwarding and reverse proxy for NAT traversal",
|
||||
@@ -941,7 +986,7 @@
|
||||
"tip": "Password mode aligns with verify-client-cert none + username-as-common-name",
|
||||
"options": {
|
||||
"certificate": "Certificate Only",
|
||||
"password": "***",
|
||||
"password": "Username + Password",
|
||||
"certificate+password": "Certificate + Password"
|
||||
}
|
||||
},
|
||||
@@ -974,8 +1019,10 @@
|
||||
"tip": "When off, only pushes internal routes below (common in production)"
|
||||
},
|
||||
"push_routes": {
|
||||
"label": "Push Internal Routes",
|
||||
"tip": "CIDR, comma-separated"
|
||||
"label": "Push Routes",
|
||||
"tip": "One IP or CIDR per line; # comments are supported",
|
||||
"placeholder": "# VPC networks\n172.16.0.0/16\n# Staging\n43.129.24.233",
|
||||
"format_error": "Invalid route format: {route}"
|
||||
},
|
||||
"enable_nat": {
|
||||
"label": "Enable NAT Outbound"
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"name": "名称",
|
||||
"description": "说明",
|
||||
"username": "用户名",
|
||||
"password": "***",
|
||||
"password": "密码",
|
||||
"email": "邮箱",
|
||||
"source": "来源",
|
||||
"local": "本地",
|
||||
@@ -57,9 +57,9 @@
|
||||
"login": {
|
||||
"title": "VPN 专线管理平台",
|
||||
"subtitle": "统一管理多协议 VPN,配置简单、安全可控",
|
||||
"protocols": "WireGuard · IPSec · L2TP · FRP · OpenVPN · 自定义协议插件",
|
||||
"protocols": "WireGuard · IPSec · IKEv2 · L2TP · GRE · FRP · OpenVPN",
|
||||
"feature1Title": "多协议统一管理",
|
||||
"feature1Desc": "WireGuard / IPSec / L2TP / FRP / OpenVPN",
|
||||
"feature1Desc": "WireGuard / IPSec / IKEv2 / L2TP / GRE / OpenVPN",
|
||||
"feature2Title": "企业级安全",
|
||||
"feature2Desc": "密钥 AES-GCM 加密 + 双因素认证",
|
||||
"feature3Title": "一键创建配置",
|
||||
@@ -89,6 +89,10 @@
|
||||
"totalTraffic": "累计流量 ↓↑",
|
||||
"protocolDistribution": "协议分布",
|
||||
"protocolEngineStatus": "协议引擎状态",
|
||||
"hostReadinessWarning": "主机运行前提未完全满足,VPN 转发或 NAT 可能无法正常工作",
|
||||
"readinessRoot": "服务未以 root 运行",
|
||||
"readinessForwarding": "IPv4 转发未启用",
|
||||
"readinessRoute": "未检测到默认出口路由",
|
||||
"depCheckResult": "系统依赖检测结果",
|
||||
"depReady": "已就绪",
|
||||
"demoMode": "演示模式",
|
||||
@@ -217,6 +221,15 @@
|
||||
"sensitiveFieldHint": "密码、PSK、Token 等敏感字段不会回显;留空表示继续使用原值。",
|
||||
"tunnelName": "专线名称",
|
||||
"protocolType": "协议类型",
|
||||
"coreOptions": "核心接入配置",
|
||||
"advancedOptions": "详细配置",
|
||||
"trafficMode": "流量出口",
|
||||
"trafficGateway": "全流量经 VPN 网关",
|
||||
"trafficStatic": "仅静态路由",
|
||||
"trafficCustom": "自定义",
|
||||
"trafficGatewayTip": "客户端全部流量经 VPN 网关转发,并使用 NAT 出网。",
|
||||
"trafficStaticTip": "仅向客户端推送下方填写的目标网段,不改变其默认互联网出口。",
|
||||
"trafficCustomTip": "在详细配置中分别控制默认路由、静态路由与 NAT。",
|
||||
"keepOriginal": "留空保持原值",
|
||||
"expireTime": "到期时间",
|
||||
"expirePlaceholder": "留空为永久",
|
||||
@@ -241,6 +254,14 @@
|
||||
"namePlaceholder": "例如:总部-分支互联",
|
||||
"protocolType": "协议类型",
|
||||
"reselect": "重新选择",
|
||||
"coreOptions": "核心接入配置",
|
||||
"trafficMode": "流量出口",
|
||||
"trafficGateway": "全流量经 VPN 网关",
|
||||
"trafficStatic": "仅静态路由",
|
||||
"trafficCustom": "自定义",
|
||||
"trafficGatewayTip": "客户端全部流量经 VPN 网关转发,并使用 NAT 出网。",
|
||||
"trafficStaticTip": "仅向客户端推送下方填写的目标网段,不改变其默认互联网出口。",
|
||||
"trafficCustomTip": "在详细配置中分别控制默认路由、静态路由与 NAT。",
|
||||
"advancedOptions": "高级选项",
|
||||
"expireTime": "到期时间",
|
||||
"expirePlaceholder": "留空为永久",
|
||||
@@ -337,6 +358,20 @@
|
||||
"title": "VPN 拨号用户",
|
||||
"mfaHint": "OpenVPN 双因素认证(TOTP / 静态挑战 / PIN+OTP)",
|
||||
"addUser": "添加用户",
|
||||
"bulkImport": "批量导入",
|
||||
"bulkImportTitle": "批量导入平台用户",
|
||||
"bulkImportHint": "仅 OpenVPN 支持导入平台本地或 LDAP 用户;L2TP/IKEv2 请创建独立拨号账号。",
|
||||
"importSelected": "导入选中用户",
|
||||
"importedCount": "已导入 {count} 个用户",
|
||||
"importFailed": "批量导入失败",
|
||||
"selectOpenVPNTunnel": "选择 OpenVPN 隧道",
|
||||
"selectTunnelAndUsers": "请选择 OpenVPN 隧道和平台用户",
|
||||
"searchPlaceholder": "搜索用户名、姓名或邮箱",
|
||||
"allTunnels": "全部隧道",
|
||||
"selectedUsers": "已选择 {count} 个用户",
|
||||
"batchDelete": "批量删除",
|
||||
"batchDeleteConfirm": "确认删除选中的 {count} 个拨号用户?",
|
||||
"batchDeleteDone": "已删除选中拨号用户",
|
||||
"displayName": "姓名",
|
||||
"tunnel": "隧道",
|
||||
"otpMfa": "OTP / MFA",
|
||||
@@ -359,6 +394,14 @@
|
||||
"clickRowHint": "点击行即选中并自动填充",
|
||||
"otpDialogTitle": "双因素认证 — {username}",
|
||||
"otpHint": "基于 TOTP 的双因素认证(SHA1 / base32 / 30s / 6 位),兼容主流 Authenticator 应用。支持静态挑战二次输入与 PIN+OTP 组合模式。",
|
||||
"mfaNotEnabled": "尚未启用双因素认证",
|
||||
"mfaDisabledHint": "绑定验证器后,此用户登录 VPN 时需要额外输入动态验证码。",
|
||||
"mfaEnabledHint": "该用户登录 VPN 时需要密码和动态验证码。",
|
||||
"mfaSetupIntro": "为该用户绑定一个 Authenticator 应用,保护 VPN 拨号登录。",
|
||||
"manualSetup": "无法扫码?使用手动密钥",
|
||||
"verifySetup": "确认绑定",
|
||||
"verifySetupHint": "在 Authenticator 中扫描后,输入当前显示的 6 位验证码完成验证。",
|
||||
"securityDetails": "安全详情",
|
||||
"bindStatus": "绑定状态",
|
||||
"boundTotp": "已绑定 TOTP",
|
||||
"staticPin": "静态 PIN",
|
||||
@@ -766,13 +809,6 @@
|
||||
"label": "机器认证 PSK",
|
||||
"tip": "留空自动生成;iOS/Android 共享密钥"
|
||||
},
|
||||
"username": {
|
||||
"label": "默认拨号用户"
|
||||
},
|
||||
"password": {
|
||||
"label": "默认拨号密码",
|
||||
"tip": "留空自动生成;更多账号在拨号用户页添加"
|
||||
},
|
||||
"address_pool": {
|
||||
"label": "客户端地址池"
|
||||
},
|
||||
@@ -800,15 +836,8 @@
|
||||
"label": "服务器公网 IP"
|
||||
},
|
||||
"psk": {
|
||||
"label": "IPSec 预共享密钥"
|
||||
},
|
||||
"username": {
|
||||
"label": "默认拨号用户名",
|
||||
"tip": "可在「VPN 拨号用户」为本隧道添加更多账号"
|
||||
},
|
||||
"password": {
|
||||
"label": "默认拨号密码",
|
||||
"tip": "若只使用拨号用户表可留空(创建时需至少有一种)"
|
||||
"label": "IPSec 预共享密钥",
|
||||
"tip": "留空将自动生成高强度随机密钥;请在创建后从客户端配置中安全获取"
|
||||
},
|
||||
"ip_range": {
|
||||
"label": "客户端地址池"
|
||||
@@ -821,6 +850,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"gre": {
|
||||
"name": "GRE 隧道",
|
||||
"desc": "标准 IP 协议 47 隧道,用于 Linux 与路由器、防火墙的点对点三层互联",
|
||||
"scenes": "网络设备互联 / 跨站点路由",
|
||||
"fields": {
|
||||
"local_endpoint": { "label": "本端公网 IP" },
|
||||
"remote_endpoint": { "label": "对端公网 IP" },
|
||||
"local_tunnel_ip": { "label": "本端隧道 IP", "tip": "建议使用 /30,例如 10.255.0.1/30" },
|
||||
"remote_tunnel_ip": { "label": "对端隧道 IP", "tip": "需与本端隧道 IP 位于同一子网" },
|
||||
"remote_subnet": { "label": "对端业务网段", "tip": "填写后自动添加经 GRE 对端的静态路由" },
|
||||
"enable_nat": { "label": "对端流量 NAT 出网", "tip": "仅当对端业务网段需要经本机访问互联网时启用" },
|
||||
"outbound_interface": { "label": "NAT 出站网卡" },
|
||||
"ttl": { "label": "TTL" },
|
||||
"mtu": { "label": "GRE MTU", "tip": "公网 GRE 常用 1476;链路有额外封装时可继续降低" }
|
||||
}
|
||||
},
|
||||
"frp": {
|
||||
"name": "FRP 内网穿透",
|
||||
"desc": "国内刚需,TCP/UDP/HTTP 端口映射与反向代理",
|
||||
@@ -941,7 +986,7 @@
|
||||
"tip": "密码模式对齐生产 verify-client-cert none + username-as-common-name",
|
||||
"options": {
|
||||
"certificate": "仅证书",
|
||||
"password": "***",
|
||||
"password": "用户名 + 密码",
|
||||
"certificate+password": "证书 + 密码"
|
||||
}
|
||||
},
|
||||
@@ -974,8 +1019,10 @@
|
||||
"tip": "关闭后仅推送下方内网路由(生产常只推指定路由)"
|
||||
},
|
||||
"push_routes": {
|
||||
"label": "推送内网路由",
|
||||
"tip": "CIDR 逗号分隔"
|
||||
"label": "推送路由",
|
||||
"tip": "IP 或 CIDR,每行一个;支持 # 注释",
|
||||
"placeholder": "# VPC 网段\n172.16.0.0/16\n# 预发环境\n43.129.24.233",
|
||||
"format_error": "路由格式无效:{route}"
|
||||
},
|
||||
"enable_nat": {
|
||||
"label": "启用 NAT 出网"
|
||||
|
||||
+77
-62
@@ -9,16 +9,16 @@ export const PROTOCOL_META = {
|
||||
icon: 'wireguard',
|
||||
scenes: 'protocols.wireguard.scenes',
|
||||
fields: [
|
||||
{ key: 'address', label: 'protocols.wireguard.fields.address.label', placeholder: '10.8.0.1/24', required: true, tip: 'protocols.wireguard.fields.address.tip' },
|
||||
{ key: 'listen_port', label: 'protocols.wireguard.fields.listen_port.label', type: 'number', default: 51820 },
|
||||
{ key: 'client_address', label: 'protocols.wireguard.fields.client_address.label', placeholder: '10.8.0.2/32', tip: 'protocols.wireguard.fields.client_address.tip' },
|
||||
{ key: 'public_endpoint', label: 'protocols.wireguard.fields.public_endpoint.label', placeholder: 'vpn.example.com:51820', tip: 'protocols.wireguard.fields.public_endpoint.tip' },
|
||||
{ key: 'dns', label: 'protocols.wireguard.fields.dns.label', default: '223.5.5.5' },
|
||||
{ key: 'allowed_ips', label: 'protocols.wireguard.fields.allowed_ips.label', default: '0.0.0.0/0, ::/0', tip: 'protocols.wireguard.fields.allowed_ips.tip' },
|
||||
{ key: 'keepalive', label: 'protocols.wireguard.fields.keepalive.label', type: 'number', default: 25, tip: 'protocols.wireguard.fields.keepalive.tip' },
|
||||
{ key: 'mtu', label: 'protocols.wireguard.fields.mtu.label', type: 'number', default: 0, tip: 'protocols.wireguard.fields.mtu.tip' },
|
||||
{ key: 'enable_nat', label: 'protocols.wireguard.fields.enable_nat.label', type: 'switch', default: false, tip: 'protocols.wireguard.fields.enable_nat.tip' },
|
||||
{ key: 'egress_interface', label: 'protocols.wireguard.fields.egress_interface.label', placeholder: 'eth0', tip: 'protocols.wireguard.fields.egress_interface.tip', showIf: { enable_nat: true } }
|
||||
{ key: 'address', label: 'protocols.wireguard.fields.address.label', placeholder: '10.8.0.1/24', required: true, tip: 'protocols.wireguard.fields.address.tip', group: 'basic' },
|
||||
{ key: 'listen_port', label: 'protocols.wireguard.fields.listen_port.label', type: 'number', default: 51820, group: 'basic' },
|
||||
{ key: 'client_address', label: 'protocols.wireguard.fields.client_address.label', placeholder: '10.8.0.2/32', tip: 'protocols.wireguard.fields.client_address.tip', group: 'basic' },
|
||||
{ key: 'public_endpoint', label: 'protocols.wireguard.fields.public_endpoint.label', placeholder: 'vpn.example.com:51820', tip: 'protocols.wireguard.fields.public_endpoint.tip', group: 'basic' },
|
||||
{ key: 'dns', label: 'protocols.wireguard.fields.dns.label', default: '223.5.5.5', group: 'basic' },
|
||||
{ key: 'allowed_ips', label: 'protocols.wireguard.fields.allowed_ips.label', default: '0.0.0.0/0, ::/0', tip: 'protocols.wireguard.fields.allowed_ips.tip', group: 'routing' },
|
||||
{ key: 'keepalive', label: 'protocols.wireguard.fields.keepalive.label', type: 'number', default: 25, tip: 'protocols.wireguard.fields.keepalive.tip', group: 'advanced' },
|
||||
{ key: 'mtu', label: 'protocols.wireguard.fields.mtu.label', type: 'number', default: 0, tip: 'protocols.wireguard.fields.mtu.tip', group: 'advanced' },
|
||||
{ key: 'enable_nat', label: 'protocols.wireguard.fields.enable_nat.label', type: 'switch', default: false, tip: 'protocols.wireguard.fields.enable_nat.tip', group: 'routing' },
|
||||
{ key: 'egress_interface', label: 'protocols.wireguard.fields.egress_interface.label', placeholder: 'eth0', tip: 'protocols.wireguard.fields.egress_interface.tip', showIf: { enable_nat: true }, group: 'routing' }
|
||||
]
|
||||
},
|
||||
ipsec: {
|
||||
@@ -34,22 +34,22 @@ export const PROTOCOL_META = {
|
||||
{ value: 'fortigate', label: 'protocols.ipsec.fields.vendor_template.options.fortigate' },
|
||||
{ value: 'huawei', label: 'protocols.ipsec.fields.vendor_template.options.huawei' },
|
||||
{ value: 'h3c', label: 'protocols.ipsec.fields.vendor_template.options.h3c' }
|
||||
], default: 'generic', tip: 'protocols.ipsec.fields.vendor_template.tip' },
|
||||
{ key: 'public_endpoint', label: 'protocols.ipsec.fields.public_endpoint.label', placeholder: '1.2.3.4', required: true },
|
||||
{ key: 'remote_gateway', label: 'protocols.ipsec.fields.remote_gateway.label', placeholder: '5.6.7.8', required: true },
|
||||
{ key: 'local_subnets', label: 'protocols.ipsec.fields.local_subnets.label', placeholder: '192.168.1.0/24', required: true, tip: 'protocols.ipsec.fields.local_subnets.tip' },
|
||||
{ key: 'remote_subnets', label: 'protocols.ipsec.fields.remote_subnets.label', placeholder: '10.0.0.0/24', required: true },
|
||||
{ key: 'psk', label: 'protocols.ipsec.fields.psk.label', type: 'password', required: true, secret: true },
|
||||
], default: 'generic', tip: 'protocols.ipsec.fields.vendor_template.tip', group: 'basic' },
|
||||
{ key: 'public_endpoint', label: 'protocols.ipsec.fields.public_endpoint.label', placeholder: '1.2.3.4', required: true, group: 'basic' },
|
||||
{ key: 'remote_gateway', label: 'protocols.ipsec.fields.remote_gateway.label', placeholder: '5.6.7.8', required: true, group: 'basic' },
|
||||
{ key: 'local_subnets', label: 'protocols.ipsec.fields.local_subnets.label', placeholder: '192.168.1.0/24', required: true, tip: 'protocols.ipsec.fields.local_subnets.tip', group: 'routing' },
|
||||
{ key: 'remote_subnets', label: 'protocols.ipsec.fields.remote_subnets.label', placeholder: '10.0.0.0/24', required: true, group: 'routing' },
|
||||
{ key: 'psk', label: 'protocols.ipsec.fields.psk.label', type: 'password', required: true, secret: true, group: 'basic' },
|
||||
{ key: 'ike_version', label: 'protocols.ipsec.fields.ike_version.label', type: 'select', options: [
|
||||
{ value: 'ikev2', label: 'protocols.ipsec.fields.ike_version.options.ikev2' },
|
||||
{ value: 'ikev1', label: 'protocols.ipsec.fields.ike_version.options.ikev1' }
|
||||
], default: 'ikev2' },
|
||||
{ key: 'ike_proposal', label: 'protocols.ipsec.fields.ike_proposal.label', placeholder: 'aes256-sha256-modp2048', tip: 'protocols.ipsec.fields.ike_proposal.tip' },
|
||||
{ key: 'esp_proposal', label: 'protocols.ipsec.fields.esp_proposal.label', placeholder: 'aes256-sha256' },
|
||||
{ key: 'pfs_group', label: 'protocols.ipsec.fields.pfs_group.label', default: 'modp2048' },
|
||||
{ key: 'ike_lifetime', label: 'protocols.ipsec.fields.ike_lifetime.label', type: 'number', default: 86400 },
|
||||
{ key: 'local_id', label: 'protocols.ipsec.fields.local_id.label', placeholder: '@vpn-local' },
|
||||
{ key: 'remote_id', label: 'protocols.ipsec.fields.remote_id.label', placeholder: '@vpn-remote' }
|
||||
], default: 'ikev2', group: 'crypto' },
|
||||
{ key: 'ike_proposal', label: 'protocols.ipsec.fields.ike_proposal.label', placeholder: 'aes256-sha256-modp2048', tip: 'protocols.ipsec.fields.ike_proposal.tip', group: 'crypto' },
|
||||
{ key: 'esp_proposal', label: 'protocols.ipsec.fields.esp_proposal.label', placeholder: 'aes256-sha256', group: 'crypto' },
|
||||
{ key: 'pfs_group', label: 'protocols.ipsec.fields.pfs_group.label', default: 'modp2048', group: 'crypto' },
|
||||
{ key: 'ike_lifetime', label: 'protocols.ipsec.fields.ike_lifetime.label', type: 'number', default: 86400, group: 'advanced' },
|
||||
{ key: 'local_id', label: 'protocols.ipsec.fields.local_id.label', placeholder: '@vpn-local', group: 'advanced' },
|
||||
{ key: 'remote_id', label: 'protocols.ipsec.fields.remote_id.label', placeholder: '@vpn-remote', group: 'advanced' }
|
||||
]
|
||||
},
|
||||
ikev2: {
|
||||
@@ -59,16 +59,14 @@ export const PROTOCOL_META = {
|
||||
icon: 'ikev2',
|
||||
scenes: 'protocols.ikev2.scenes',
|
||||
fields: [
|
||||
{ key: 'public_endpoint', label: 'protocols.ikev2.fields.public_endpoint.label', placeholder: 'vpn.example.com', required: true },
|
||||
{ key: 'local_id', label: 'protocols.ikev2.fields.local_id.label', placeholder: '@opsx-ikev2', default: '@opsx-ikev2', tip: 'protocols.ikev2.fields.local_id.tip' },
|
||||
{ key: 'psk', label: 'protocols.ikev2.fields.psk.label', type: 'password', secret: true, tip: 'protocols.ikev2.fields.psk.tip' },
|
||||
{ key: 'username', label: 'protocols.ikev2.fields.username.label', default: 'vpnuser' },
|
||||
{ key: 'password', label: 'protocols.ikev2.fields.password.label', type: 'password', secret: true, tip: 'protocols.ikev2.fields.password.tip' },
|
||||
{ key: 'address_pool', label: 'protocols.ikev2.fields.address_pool.label', default: '10.12.0.0/24' },
|
||||
{ key: 'dns', label: 'protocols.ikev2.fields.dns.label', default: '223.5.5.5' },
|
||||
{ key: 'local_ts', label: 'protocols.ikev2.fields.local_ts.label', default: '0.0.0.0/0', tip: 'protocols.ikev2.fields.local_ts.tip' },
|
||||
{ key: 'ike_proposal', label: 'protocols.ikev2.fields.ike_proposal.label', default: 'aes256-sha256-modp2048' },
|
||||
{ key: 'esp_proposal', label: 'protocols.ikev2.fields.esp_proposal.label', default: 'aes256-sha256' }
|
||||
{ key: 'public_endpoint', label: 'protocols.ikev2.fields.public_endpoint.label', placeholder: 'vpn.example.com', required: true, group: 'basic' },
|
||||
{ key: 'local_id', label: 'protocols.ikev2.fields.local_id.label', placeholder: '@opsx-ikev2', default: '@opsx-ikev2', tip: 'protocols.ikev2.fields.local_id.tip', group: 'basic' },
|
||||
{ key: 'psk', label: 'protocols.ikev2.fields.psk.label', type: 'password', secret: true, tip: 'protocols.ikev2.fields.psk.tip', group: 'basic' },
|
||||
{ key: 'address_pool', label: 'protocols.ikev2.fields.address_pool.label', default: '10.12.0.0/24', group: 'basic' },
|
||||
{ key: 'dns', label: 'protocols.ikev2.fields.dns.label', default: '223.5.5.5', group: 'basic' },
|
||||
{ key: 'local_ts', label: 'protocols.ikev2.fields.local_ts.label', default: '0.0.0.0/0', tip: 'protocols.ikev2.fields.local_ts.tip', group: 'routing' },
|
||||
{ key: 'ike_proposal', label: 'protocols.ikev2.fields.ike_proposal.label', default: 'aes256-sha256-modp2048', group: 'crypto' },
|
||||
{ key: 'esp_proposal', label: 'protocols.ikev2.fields.esp_proposal.label', default: 'aes256-sha256', group: 'crypto' }
|
||||
]
|
||||
},
|
||||
l2tp: {
|
||||
@@ -78,13 +76,29 @@ export const PROTOCOL_META = {
|
||||
icon: 'l2tp',
|
||||
scenes: 'protocols.l2tp.scenes',
|
||||
fields: [
|
||||
{ key: 'public_endpoint', label: 'protocols.l2tp.fields.public_endpoint.label', placeholder: '1.2.3.4', required: true },
|
||||
{ key: 'psk', label: 'protocols.l2tp.fields.psk.label', type: 'password', required: true, secret: true },
|
||||
{ key: 'username', label: 'protocols.l2tp.fields.username.label', default: 'vpnuser', tip: 'protocols.l2tp.fields.username.tip' },
|
||||
{ key: 'password', label: 'protocols.l2tp.fields.password.label', type: 'password', secret: true, tip: 'protocols.l2tp.fields.password.tip' },
|
||||
{ key: 'ip_range', label: 'protocols.l2tp.fields.ip_range.label', default: '10.9.0.100-10.9.0.200' },
|
||||
{ key: 'local_ip', label: 'protocols.l2tp.fields.local_ip.label', default: '10.9.0.1' },
|
||||
{ key: 'dns', label: 'protocols.l2tp.fields.dns.label', default: '223.5.5.5' }
|
||||
{ key: 'public_endpoint', label: 'protocols.l2tp.fields.public_endpoint.label', placeholder: '1.2.3.4', required: true, group: 'basic' },
|
||||
{ key: 'psk', label: 'protocols.l2tp.fields.psk.label', type: 'password', secret: true, tip: 'protocols.l2tp.fields.psk.tip', group: 'basic' },
|
||||
{ key: 'ip_range', label: 'protocols.l2tp.fields.ip_range.label', default: '10.9.0.100-10.9.0.200', group: 'routing' },
|
||||
{ key: 'local_ip', label: 'protocols.l2tp.fields.local_ip.label', default: '10.9.0.1', group: 'routing' },
|
||||
{ key: 'dns', label: 'protocols.l2tp.fields.dns.label', default: '223.5.5.5', group: 'routing' }
|
||||
]
|
||||
},
|
||||
gre: {
|
||||
name: 'protocols.gre.name',
|
||||
desc: 'protocols.gre.desc',
|
||||
color: '#357a8b',
|
||||
icon: 'gre',
|
||||
scenes: 'protocols.gre.scenes',
|
||||
fields: [
|
||||
{ key: 'local_endpoint', label: 'protocols.gre.fields.local_endpoint.label', placeholder: '134.175.167.191', required: true, group: 'basic' },
|
||||
{ key: 'remote_endpoint', label: 'protocols.gre.fields.remote_endpoint.label', placeholder: '203.0.113.10', required: true, group: 'basic' },
|
||||
{ key: 'local_tunnel_ip', label: 'protocols.gre.fields.local_tunnel_ip.label', placeholder: '10.255.0.1/30', required: true, group: 'basic' },
|
||||
{ key: 'remote_tunnel_ip', label: 'protocols.gre.fields.remote_tunnel_ip.label', placeholder: '10.255.0.2/30', required: true, group: 'basic' },
|
||||
{ key: 'remote_subnet', label: 'protocols.gre.fields.remote_subnet.label', placeholder: '192.168.10.0/24', tip: 'protocols.gre.fields.remote_subnet.tip', group: 'routing' },
|
||||
{ key: 'enable_nat', label: 'protocols.gre.fields.enable_nat.label', type: 'switch', default: false, tip: 'protocols.gre.fields.enable_nat.tip', group: 'routing' },
|
||||
{ key: 'outbound_interface', label: 'protocols.gre.fields.outbound_interface.label', placeholder: 'eth0', showIf: { enable_nat: true }, group: 'routing' },
|
||||
{ key: 'ttl', label: 'protocols.gre.fields.ttl.label', type: 'number', default: 255, group: 'advanced' },
|
||||
{ key: 'mtu', label: 'protocols.gre.fields.mtu.label', type: 'number', default: 1476, tip: 'protocols.gre.fields.mtu.tip', group: 'advanced' }
|
||||
]
|
||||
},
|
||||
frp: {
|
||||
@@ -97,19 +111,19 @@ export const PROTOCOL_META = {
|
||||
{ key: 'mode', label: 'protocols.frp.fields.mode.label', type: 'select', options: [
|
||||
{ value: 'server', label: 'protocols.frp.fields.mode.options.server' },
|
||||
{ value: 'client', label: 'protocols.frp.fields.mode.options.client' }
|
||||
], default: 'server', tip: 'protocols.frp.fields.mode.tip' },
|
||||
{ key: 'bind_port', label: 'protocols.frp.fields.bind_port.label', type: 'number', default: 7000, showIf: { mode: 'server' } },
|
||||
{ key: 'public_endpoint', label: 'protocols.frp.fields.public_endpoint.label', placeholder: '1.2.3.4', showIf: { mode: 'server' } },
|
||||
{ key: 'dashboard_port', label: 'protocols.frp.fields.dashboard_port.label', type: 'number', default: 7500, showIf: { mode: 'server' } },
|
||||
{ key: 'dashboard_user', label: 'protocols.frp.fields.dashboard_user.label', default: 'admin', showIf: { mode: 'server' } },
|
||||
{ key: 'dashboard_password', label: 'protocols.frp.fields.dashboard_password.label', type: 'password', secret: true, showIf: { mode: 'server' }, tip: 'protocols.frp.fields.dashboard_password.tip' },
|
||||
{ key: 'vhost_http_port', label: 'protocols.frp.fields.vhost_http_port.label', type: 'number', default: 0, showIf: { mode: 'server' }, tip: 'protocols.frp.fields.vhost_http_port.tip' },
|
||||
{ key: 'vhost_https_port', label: 'protocols.frp.fields.vhost_https_port.label', type: 'number', default: 0, showIf: { mode: 'server' }, tip: 'protocols.frp.fields.vhost_https_port.tip' },
|
||||
{ key: 'allow_ports', label: 'protocols.frp.fields.allow_ports.label', placeholder: '6000-6100,7001', showIf: { mode: 'server' }, tip: 'protocols.frp.fields.allow_ports.tip' },
|
||||
{ key: 'server_addr', label: 'protocols.frp.fields.server_addr.label', placeholder: '1.2.3.4', showIf: { mode: 'client' } },
|
||||
{ key: 'server_port', label: 'protocols.frp.fields.server_port.label', type: 'number', default: 7000, showIf: { mode: 'client' } },
|
||||
{ key: 'token', label: 'protocols.frp.fields.token.label', type: 'password', secret: true, tip: 'protocols.frp.fields.token.tip' },
|
||||
{ key: 'proxy_name', label: 'protocols.frp.fields.proxy_name.label', placeholder: 'ssh', default: 'ssh' },
|
||||
], default: 'server', tip: 'protocols.frp.fields.mode.tip', group: 'basic' },
|
||||
{ key: 'bind_port', label: 'protocols.frp.fields.bind_port.label', type: 'number', default: 7000, showIf: { mode: 'server' }, group: 'basic' },
|
||||
{ key: 'public_endpoint', label: 'protocols.frp.fields.public_endpoint.label', placeholder: '1.2.3.4', showIf: { mode: 'server' }, group: 'basic' },
|
||||
{ key: 'dashboard_port', label: 'protocols.frp.fields.dashboard_port.label', type: 'number', default: 7500, showIf: { mode: 'server' }, group: 'advanced' },
|
||||
{ key: 'dashboard_user', label: 'protocols.frp.fields.dashboard_user.label', default: 'admin', showIf: { mode: 'server' }, group: 'advanced' },
|
||||
{ key: 'dashboard_password', label: 'protocols.frp.fields.dashboard_password.label', type: 'password', secret: true, showIf: { mode: 'server' }, tip: 'protocols.frp.fields.dashboard_password.tip', group: 'advanced' },
|
||||
{ key: 'vhost_http_port', label: 'protocols.frp.fields.vhost_http_port.label', type: 'number', default: 0, showIf: { mode: 'server' }, tip: 'protocols.frp.fields.vhost_http_port.tip', group: 'advanced' },
|
||||
{ key: 'vhost_https_port', label: 'protocols.frp.fields.vhost_https_port.label', type: 'number', default: 0, showIf: { mode: 'server' }, tip: 'protocols.frp.fields.vhost_https_port.tip', group: 'advanced' },
|
||||
{ key: 'allow_ports', label: 'protocols.frp.fields.allow_ports.label', placeholder: '6000-6100,7001', showIf: { mode: 'server' }, tip: 'protocols.frp.fields.allow_ports.tip', group: 'advanced' },
|
||||
{ key: 'server_addr', label: 'protocols.frp.fields.server_addr.label', placeholder: '1.2.3.4', showIf: { mode: 'client' }, group: 'basic' },
|
||||
{ key: 'server_port', label: 'protocols.frp.fields.server_port.label', type: 'number', default: 7000, showIf: { mode: 'client' }, group: 'basic' },
|
||||
{ key: 'token', label: 'protocols.frp.fields.token.label', type: 'password', secret: true, tip: 'protocols.frp.fields.token.tip', group: 'basic' },
|
||||
{ key: 'proxy_name', label: 'protocols.frp.fields.proxy_name.label', placeholder: 'ssh', default: 'ssh', group: 'basic' },
|
||||
{ key: 'proxy_type', label: 'protocols.frp.fields.proxy_type.label', type: 'select', options: [
|
||||
{ value: 'tcp', label: 'protocols.frp.fields.proxy_type.options.tcp' },
|
||||
{ value: 'udp', label: 'protocols.frp.fields.proxy_type.options.udp' },
|
||||
@@ -117,12 +131,12 @@ export const PROTOCOL_META = {
|
||||
{ value: 'https', label: 'protocols.frp.fields.proxy_type.options.https' },
|
||||
{ value: 'stcp', label: 'protocols.frp.fields.proxy_type.options.stcp' },
|
||||
{ value: 'xtcp', label: 'protocols.frp.fields.proxy_type.options.xtcp' }
|
||||
], default: 'tcp' },
|
||||
{ key: 'local_ip', label: 'protocols.frp.fields.local_ip.label', default: '127.0.0.1' },
|
||||
{ key: 'local_port', label: 'protocols.frp.fields.local_port.label', type: 'number', default: 22 },
|
||||
{ key: 'remote_port', label: 'protocols.frp.fields.remote_port.label', type: 'number', default: 6000, showIf: { proxy_type: ['tcp', 'udp'] } },
|
||||
{ key: 'custom_domains', label: 'protocols.frp.fields.custom_domains.label', placeholder: 'app.example.com', showIf: { proxy_type: ['http', 'https'] }, tip: 'protocols.frp.fields.custom_domains.tip' },
|
||||
{ key: 'secret_key', label: 'protocols.frp.fields.secret_key.label', type: 'password', secret: true, showIf: { proxy_type: ['stcp', 'xtcp'] }, tip: 'protocols.frp.fields.secret_key.tip' }
|
||||
], default: 'tcp', group: 'basic' },
|
||||
{ key: 'local_ip', label: 'protocols.frp.fields.local_ip.label', default: '127.0.0.1', group: 'basic' },
|
||||
{ key: 'local_port', label: 'protocols.frp.fields.local_port.label', type: 'number', default: 22, group: 'basic' },
|
||||
{ key: 'remote_port', label: 'protocols.frp.fields.remote_port.label', type: 'number', default: 6000, showIf: { proxy_type: ['tcp', 'udp'] }, group: 'basic' },
|
||||
{ key: 'custom_domains', label: 'protocols.frp.fields.custom_domains.label', placeholder: 'app.example.com', showIf: { proxy_type: ['http', 'https'] }, tip: 'protocols.frp.fields.custom_domains.tip', group: 'basic' },
|
||||
{ key: 'secret_key', label: 'protocols.frp.fields.secret_key.label', type: 'password', secret: true, showIf: { proxy_type: ['stcp', 'xtcp'] }, tip: 'protocols.frp.fields.secret_key.tip', group: 'basic' }
|
||||
]
|
||||
},
|
||||
openvpn: {
|
||||
@@ -137,7 +151,7 @@ export const PROTOCOL_META = {
|
||||
{ key: 'proto', label: 'protocols.openvpn.fields.proto.label', type: 'select', options: [
|
||||
{ value: 'udp', label: 'protocols.openvpn.fields.proto.options.udp' },
|
||||
{ value: 'tcp-server', label: 'protocols.openvpn.fields.proto.options.tcp-server' }
|
||||
], default: 'udp', tip: 'protocols.openvpn.fields.proto.tip' },
|
||||
], default: 'tcp-server', tip: 'protocols.openvpn.fields.proto.tip' },
|
||||
{ key: 'subnet', label: 'protocols.openvpn.fields.subnet.label', default: '10.10.0.0 255.255.255.0', tip: 'protocols.openvpn.fields.subnet.tip' },
|
||||
{ key: 'cipher', label: 'protocols.openvpn.fields.cipher.label', type: 'select', options: [
|
||||
{ value: 'AES-256-GCM', label: 'protocols.openvpn.fields.cipher.options.AES-256-GCM' },
|
||||
@@ -148,7 +162,7 @@ export const PROTOCOL_META = {
|
||||
{ value: 'certificate', label: 'protocols.openvpn.fields.auth_mode.options.certificate' },
|
||||
{ value: 'password', label: 'protocols.openvpn.fields.auth_mode.options.password' },
|
||||
{ value: 'certificate+password', label: 'protocols.openvpn.fields.auth_mode.options.certificate+password' }
|
||||
], default: 'certificate', tip: 'protocols.openvpn.fields.auth_mode.tip' },
|
||||
], default: 'password', tip: 'protocols.openvpn.fields.auth_mode.tip' },
|
||||
{ key: 'mfa_mode', label: 'protocols.openvpn.fields.mfa_mode.label', type: 'select', options: [
|
||||
{ value: 'off', label: 'protocols.openvpn.fields.mfa_mode.options.off' },
|
||||
{ value: 'optional', label: 'protocols.openvpn.fields.mfa_mode.options.optional' },
|
||||
@@ -161,7 +175,7 @@ export const PROTOCOL_META = {
|
||||
{ key: 'dns', label: 'protocols.openvpn.fields.dns.label', default: '223.5.5.5' },
|
||||
{ key: 'redirect_gateway', label: 'protocols.openvpn.fields.redirect_gateway.label', type: 'switch', default: true, tip: 'protocols.openvpn.fields.redirect_gateway.tip' },
|
||||
{ key: 'push_routes', label: 'protocols.openvpn.fields.push_routes.label', placeholder: '192.168.0.0/16,10.0.0.0/8', tip: 'protocols.openvpn.fields.push_routes.tip' },
|
||||
{ key: 'enable_nat', label: 'protocols.openvpn.fields.enable_nat.label', type: 'switch', default: false },
|
||||
{ key: 'enable_nat', label: 'protocols.openvpn.fields.enable_nat.label', type: 'switch', default: true },
|
||||
{ key: 'max_clients', label: 'protocols.openvpn.fields.max_clients.label', type: 'number', default: 1000, tip: 'protocols.openvpn.fields.max_clients.tip' },
|
||||
{ key: 'keepalive_interval', label: 'protocols.openvpn.fields.keepalive_interval.label', type: 'number', default: 5, tip: 'protocols.openvpn.fields.keepalive_interval.tip' },
|
||||
{ key: 'keepalive_timeout', label: 'protocols.openvpn.fields.keepalive_timeout.label', type: 'number', default: 30 },
|
||||
@@ -188,6 +202,7 @@ export const PROTOCOL_COLORS = {
|
||||
ipsec: '#1f6fb2',
|
||||
ikev2: '#0d7377',
|
||||
l2tp: '#4a8a2c',
|
||||
gre: '#357a8b',
|
||||
frp: '#b8651b',
|
||||
openvpn: '#cc6529'
|
||||
}
|
||||
|
||||
@@ -16,10 +16,8 @@ const routes = [
|
||||
{ path: 'tunnels/create', component: () => import('./views/TunnelWizard.vue'), meta: { titleKey: 'router.createTunnel' } },
|
||||
{ path: 'tunnels/:id/edit', component: () => import('./views/TunnelEditor.vue'), meta: { titleKey: 'router.editTunnel' } },
|
||||
{ path: 'ipsec-peer', component: () => import('./views/IpsecPeer.vue'), meta: { titleKey: 'router.ipsecPeer' } },
|
||||
{ path: 'nodes', component: () => import('./views/Nodes.vue'), meta: { titleKey: 'router.nodes', requiresAdmin: true } },
|
||||
{ path: 'users', component: () => import('./views/Users.vue'), meta: { titleKey: 'router.users', requiresAdmin: true } },
|
||||
{ path: 'vpn-users', component: () => import('./views/VPNUsers.vue'), meta: { titleKey: 'router.vpnUsers', requiresAdmin: true } },
|
||||
{ path: 'plugins', component: () => import('./views/Plugins.vue'), meta: { titleKey: 'router.plugins', requiresAdmin: true } },
|
||||
{ path: 'settings', component: () => import('./views/Settings.vue'), meta: { titleKey: 'router.settings' } },
|
||||
{ path: ':pathMatch(.*)*', component: () => import('./views/NotFound.vue'), meta: { titleKey: 'router.notFound' } }
|
||||
]
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-alert v-if="readinessLoaded && !hostReady" class="host-readiness" type="warning" :closable="false" show-icon
|
||||
:title="$t('dashboard.hostReadinessWarning')" :description="readinessHint" />
|
||||
|
||||
<el-row :gutter="16" style="margin-top:16px">
|
||||
<el-col :xs="24" :lg="10">
|
||||
<el-card class="page-card" shadow="never">
|
||||
@@ -83,6 +86,23 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="installDialogVisible" :title="`${installDialogTitle} ${$t('dashboard.installCmd')}`" width="620px" align-center>
|
||||
<el-alert :type="installData.detected ? 'success' : 'warning'" :closable="false" show-icon
|
||||
:title="installData.detected ? $t('dashboard.depReady') : $t('dashboard.contactAdmin')" />
|
||||
<div v-if="installData.command" class="install-command">
|
||||
<div class="install-command-head"><b>{{ $t('dashboard.recommendedCmd') }}</b><el-tag size="small">{{ installData.pm || $t('dashboard.aptDefault') }}</el-tag></div>
|
||||
<pre class="mono command-block">{{ installData.command }}</pre>
|
||||
<el-button size="small" @click="copyText(installData.command)">{{ $t('common.copy') }}</el-button>
|
||||
</div>
|
||||
<template v-if="Object.keys(installData.all_commands).length">
|
||||
<el-divider>{{ $t('dashboard.otherDistros') }}</el-divider>
|
||||
<div v-for="(cmd, pm) in installData.all_commands" :key="pm" v-show="cmd !== installData.command" class="other-command">
|
||||
<el-tag size="small">{{ pm }}</el-tag><code>{{ cmd }}</code><el-button link size="small" @click="copyText(cmd)">{{ $t('common.copy') }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer><el-button type="primary" @click="installDialogVisible = false">{{ $t('common.close') }}</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -94,11 +114,13 @@ import { Connection, Monitor, Share, DataLine, Plus } from '@element-plus/icons-
|
||||
import api from '../api'
|
||||
import { fmtBytes } from '../utils/format'
|
||||
import { protocolColor, protocolDisplay } from '../protocols'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const { t } = useI18n()
|
||||
const ov = ref({})
|
||||
const protocols = ref([])
|
||||
const readiness = ref({})
|
||||
const readinessLoaded = ref(false)
|
||||
const loadingProto = ref(true)
|
||||
const pieRef = ref(null)
|
||||
let chart = null
|
||||
@@ -107,6 +129,9 @@ let chart = null
|
||||
const deployableProtocols = ['frp', 'openvpn']
|
||||
const protocolToPkg = { wireguard: 'wireguard', ipsec: 'strongswan', ikev2: 'strongswan', l2tp: 'strongswan', openvpn: 'openvpn', frp: 'frp' }
|
||||
const deploying = ref('')
|
||||
const installDialogVisible = ref(false)
|
||||
const installDialogTitle = ref('')
|
||||
const installData = ref({ command: '', pm: '', detected: false, all_commands: {} })
|
||||
|
||||
async function deployProtocol(name) {
|
||||
const pkg = protocolToPkg[name] || name
|
||||
@@ -122,29 +147,18 @@ async function deployProtocol(name) {
|
||||
|
||||
async function showInstallCmd(name) {
|
||||
const pkg = protocolToPkg[name] || name
|
||||
installDialogTitle.value = name
|
||||
try {
|
||||
const { data } = await api.get(`/binaries/install-hint/${pkg}`)
|
||||
const cmd = data.command || t('dashboard.contactAdmin')
|
||||
const pmLabel = data.pm ? `(${data.pm})` : `(${t('dashboard.aptDefault')})`
|
||||
// 构建其他平台的命令列表
|
||||
let otherCmds = ''
|
||||
if (data.all_commands) {
|
||||
for (const [pm, c] of Object.entries(data.all_commands)) {
|
||||
if (c !== cmd) {
|
||||
otherCmds += `<div style="margin:4px 0"><span style="display:inline-block;min-width:70px;font-weight:600">${pm}:</span> <code style="font-size:12px">${c}</code></div>`
|
||||
}
|
||||
}
|
||||
}
|
||||
const otherSection = otherCmds ? `<hr style="margin:12px 0;border:none;border-top:1px solid #e8eaee"><div style="font-size:12px;color:#909399;margin-bottom:6px">${t('dashboard.otherDistros')}</div>${otherCmds}` : ''
|
||||
ElMessageBox.alert(
|
||||
`<div style="margin-bottom:8px"><b>${t('dashboard.recommendedCmd')}${pmLabel}</b></div><pre style="background:#f0f9eb;border:1px solid #67c23a;padding:12px;border-radius:6px;font-family:monospace;white-space:pre-wrap;font-size:13px">${cmd}</pre>${otherSection}`,
|
||||
`${name} ${t('dashboard.installCmd')}`, { dangerouslyUseHTMLString: true, confirmButtonText: t('dashboard.copyRecommended'),
|
||||
callback: (action) => { if (action === 'confirm') navigator.clipboard.writeText(cmd) } })
|
||||
installData.value = { command: data.command || '', pm: data.pm || '', detected: !!data.detected, all_commands: data.all_commands || {} }
|
||||
installDialogVisible.value = true
|
||||
} catch {
|
||||
ElMessage.error(t('dashboard.getInstallCmdFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
function copyText(text) { navigator.clipboard.writeText(text).then(() => ElMessage.success(t('common.copied'))) }
|
||||
|
||||
function fmtTime(t) { return t ? new Date(t).toLocaleString() : '-' }
|
||||
|
||||
const statCards = computed(() => [
|
||||
@@ -153,14 +167,24 @@ const statCards = computed(() => [
|
||||
{ label: t('dashboard.onlineUsers'), value: ov.value.online_sessions ?? 0, icon: Share, iconBg: 'linear-gradient(135deg,#e0935a,#c0762b)', to: '/sessions' },
|
||||
{ label: t('dashboard.totalTraffic'), value: `${fmtBytes(ov.value.rx_bytes)} / ${fmtBytes(ov.value.tx_bytes)}`, icon: DataLine, iconBg: 'linear-gradient(135deg,#7b5ea7,#5a4080)', numStyle: { fontSize: '18px' } },
|
||||
])
|
||||
const hostReady = computed(() => readiness.value.root && readiness.value.ipv4_forwarding && readiness.value.default_route_ready)
|
||||
const readinessHint = computed(() => {
|
||||
const missing = []
|
||||
if (!readiness.value.root) missing.push(t('dashboard.readinessRoot'))
|
||||
if (!readiness.value.ipv4_forwarding) missing.push(t('dashboard.readinessForwarding'))
|
||||
if (!readiness.value.default_route_ready) missing.push(t('dashboard.readinessRoute'))
|
||||
return missing.join(' / ')
|
||||
})
|
||||
|
||||
async function load() {
|
||||
// 轮询可见性守卫:标签页隐藏时跳过(P1 修复)
|
||||
if (document.visibilityState !== 'visible') return
|
||||
try {
|
||||
const [{ data: o }, { data: p }] = await Promise.all([api.get('/overview'), api.get('/protocols')])
|
||||
const [{ data: o }, { data: p }, { data: r }] = await Promise.all([api.get('/overview'), api.get('/protocols'), api.get('/readiness')])
|
||||
ov.value = o
|
||||
protocols.value = p
|
||||
readiness.value = r || {}
|
||||
readinessLoaded.value = true
|
||||
renderPie()
|
||||
} catch { /* 忽略轮询错误 */ } finally { loadingProto.value = false }
|
||||
}
|
||||
@@ -235,4 +259,11 @@ onUnmounted(() => {
|
||||
.stat-num { font-size: 26px; font-weight: 700; color: var(--text-primary); line-height: 1.2; }
|
||||
|
||||
.dep-hint { font-size: 12px; color: var(--text-secondary); font-weight: normal; }
|
||||
.host-readiness { margin-top: var(--space-4); }
|
||||
.install-command { margin-top: var(--space-4); }
|
||||
.install-command-head, .other-command { display:flex; align-items:center; gap:8px; }
|
||||
.install-command-head { justify-content:space-between; }
|
||||
.command-block { margin:8px 0; padding:12px; background:var(--bg-inset); border:1px solid var(--border-base); white-space:pre-wrap; overflow:auto; }
|
||||
.other-command { margin-bottom:8px; }
|
||||
.other-command code { flex:1; min-width:0; overflow-wrap:anywhere; font-size:12px; }
|
||||
</style>
|
||||
@@ -14,9 +14,7 @@
|
||||
<el-menu-item index="/logs"><el-icon><Document /></el-icon>{{ $t('router.logs') }}</el-menu-item>
|
||||
</el-menu-item-group>
|
||||
<el-menu-item-group v-if="auth.isAdmin()" :title="$t('router.sidebarResource')">
|
||||
<el-menu-item index="/nodes"><el-icon><Coin /></el-icon>{{ $t('router.nodes') }}</el-menu-item>
|
||||
<el-menu-item index="/vpn-users"><el-icon><Iphone /></el-icon>{{ $t('router.vpnUsers') }}</el-menu-item>
|
||||
<el-menu-item index="/plugins"><el-icon><Grid /></el-icon>{{ $t('router.plugins') }}</el-menu-item>
|
||||
</el-menu-item-group>
|
||||
<el-menu-item-group :title="$t('router.sidebarSystem')">
|
||||
<el-menu-item v-if="auth.isAdmin()" index="/users"><el-icon><UserFilled /></el-icon>{{ $t('router.users') }}</el-menu-item>
|
||||
@@ -94,7 +92,7 @@ import { ref, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { DataAnalysis, Connection, Coin, Grid, Setting, Avatar, ArrowDown, UserFilled, Iphone, Lock, Monitor, Promotion, Document } from '@element-plus/icons-vue'
|
||||
import { DataAnalysis, Connection, Setting, Avatar, ArrowDown, UserFilled, Iphone, Lock, Monitor, Promotion, Document } from '@element-plus/icons-vue'
|
||||
import api, { auth } from '../api'
|
||||
|
||||
const sideBg = 'var(--bg-sidebar)'
|
||||
|
||||
@@ -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>
|
||||
@@ -47,6 +47,9 @@
|
||||
<el-tag :color="currentColor" effect="dark" style="border:none">{{ $t(currentName) }}</el-tag>
|
||||
<el-button link style="margin-left:10px" @click="step = 0">{{ $t('tunnelWizard.reselect') }}</el-button>
|
||||
</el-form-item>
|
||||
<el-divider v-if="isOpenVPN" content-position="left" class="config-section-divider">
|
||||
{{ $t('tunnelWizard.coreOptions') }}
|
||||
</el-divider>
|
||||
<!-- 基础字段 -->
|
||||
<el-form-item v-for="f in coreFields" :key="f.key" :label="$t(f.label)" :required="f.required">
|
||||
<el-input-number v-if="f.type === 'number'" v-model="form.config[f.key]" :min="0" :max="65535" />
|
||||
@@ -59,6 +62,19 @@
|
||||
<el-input v-else v-model="form.config[f.key]" :placeholder="f.placeholder || f.default" />
|
||||
<div class="field-tip" v-if="f.tip">{{ $t(f.tip) }}</div>
|
||||
</el-form-item>
|
||||
<template v-if="isOpenVPN">
|
||||
<el-form-item :label="$t('tunnelWizard.trafficMode')">
|
||||
<el-radio-group v-model="trafficMode">
|
||||
<el-radio-button value="gateway">{{ $t('tunnelWizard.trafficGateway') }}</el-radio-button>
|
||||
<el-radio-button value="static">{{ $t('tunnelWizard.trafficStatic') }}</el-radio-button>
|
||||
</el-radio-group>
|
||||
<div class="field-tip">{{ $t(`tunnelWizard.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>
|
||||
|
||||
<!-- 高级字段(可折叠) -->
|
||||
<template v-if="advancedFields.length">
|
||||
@@ -142,6 +158,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import QRCode from 'qrcode'
|
||||
import api from '../api'
|
||||
import ProtocolIcon from '../components/ProtocolIcon.vue'
|
||||
import RouteListInput from '../components/RouteListInput.vue'
|
||||
import { PROTOCOL_META, CUSTOM_FIELDS, protocolColor, optionValue, optionLabel } from '../protocols'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -154,6 +171,7 @@ const depStatus = ref({})
|
||||
const customProtocols = ref([])
|
||||
const qrCanvas = ref(null)
|
||||
const qrShown = ref(false)
|
||||
const routeListRef = ref(null)
|
||||
|
||||
const builtin = PROTOCOL_META
|
||||
|
||||
@@ -167,13 +185,32 @@ const visibleFields = computed(() => currentFields.value.filter(f => {
|
||||
? v.includes(form.value.config[k])
|
||||
: form.value.config[k] === v)
|
||||
}))
|
||||
const isOpenVPN = computed(() => form.value.protocol === '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 (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(() => {
|
||||
if (visibleFields.value.length <= 6) return visibleFields.value
|
||||
return visibleFields.value.filter(f => f.required)
|
||||
if (isOpenVPN.value) return visibleFields.value.filter(f => openVPNCoreKeys.has(f.key))
|
||||
return visibleFields.value.filter(f => f.group !== 'advanced' && f.group !== 'crypto')
|
||||
})
|
||||
const advancedFields = computed(() => {
|
||||
if (visibleFields.value.length <= 6) return []
|
||||
return visibleFields.value.filter(f => !f.required)
|
||||
if (isOpenVPN.value) return visibleFields.value.filter(f => !openVPNCoreKeys.has(f.key) && !openVPNTrafficKeys.has(f.key))
|
||||
return visibleFields.value.filter(f => f.group === 'advanced' || f.group === 'crypto')
|
||||
})
|
||||
const advCollapsed = ref([])
|
||||
|
||||
@@ -188,6 +225,8 @@ function selectProtocol(key) {
|
||||
|
||||
async function create() {
|
||||
if (!form.value.name) return ElMessage.warning(t('tunnelWizard.fillName'))
|
||||
trafficMode.value = trafficMode.value
|
||||
if (isOpenVPN.value && routeListRef.value && !routeListRef.value.validate()) return
|
||||
for (const f of visibleFields.value) {
|
||||
if (f.required) {
|
||||
const val = form.value.config[f.key]
|
||||
@@ -208,21 +247,30 @@ async function create() {
|
||||
})
|
||||
created.value = data
|
||||
const detail = await api.get(`/tunnels/${data.id}`)
|
||||
clientConfig.value = detail.data.client_config || ''
|
||||
clientConfig.value = detail.data.client_config || detail.data.client_config_error || ''
|
||||
step.value = 2
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || t('tunnelWizard.createFailed'))
|
||||
} finally { creating.value = false }
|
||||
}
|
||||
|
||||
function downloadCreated() {
|
||||
fetch(`/api/tunnels/${created.value.id}/config`, { headers: { Authorization: `Bearer ${localStorage.getItem('vpn_token')}` } })
|
||||
.then(r => r.blob()).then(b => {
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(b)
|
||||
a.download = `${created.value.protocol}-${created.value.name}.conf`
|
||||
a.click()
|
||||
})
|
||||
async function downloadCreated() {
|
||||
if (!created.value) return
|
||||
try {
|
||||
const res = await fetch(`/api/tunnels/${created.value.id}/config`, { headers: { Authorization: `Bearer ${localStorage.getItem('vpn_token')}` } })
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.error || t('tunnelWizard.createFailed'))
|
||||
}
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(await res.blob())
|
||||
const ext = created.value.protocol === 'openvpn' ? 'ovpn' : 'conf'
|
||||
a.download = `${created.value.protocol}-${created.value.name}.${ext}`
|
||||
a.click()
|
||||
URL.revokeObjectURL(a.href)
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || t('tunnelWizard.createFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
async function showQr() {
|
||||
@@ -316,6 +364,7 @@ onMounted(async () => {
|
||||
.proto-scene { font-size: 11px; color: #909399; margin-top: 6px; }
|
||||
.dep-badge { margin-top: 8px; }
|
||||
.field-tip { font-size: 12px; color: var(--text-secondary); line-height: 1.4; }
|
||||
.config-section-divider { margin: 8px 0 20px; }
|
||||
.conf-box { background: var(--bg-inset); border: 1px solid var(--border-base); border-radius: var(--radius-sm); padding: 14px; max-height: 320px; overflow: auto; }
|
||||
.cmd-label { display: flex; align-items: center; margin-bottom: 6px; }
|
||||
.cmd-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||
|
||||
@@ -13,7 +13,12 @@
|
||||
<el-option :label="$t('tunnels.stateError')" value="error" />
|
||||
</el-select>
|
||||
</div>
|
||||
<el-button type="primary" :icon="Plus" @click="$router.push('/tunnels/create')">{{ $t('tunnels.createTunnel') }}</el-button>
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<el-tooltip :content="$t('common.refresh')" placement="top">
|
||||
<el-button :icon="Refresh" circle :loading="loading" @click="load" />
|
||||
</el-tooltip>
|
||||
<el-button type="primary" :icon="Plus" @click="$router.push('/tunnels/create')">{{ $t('tunnels.createTunnel') }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -43,9 +48,7 @@
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('common.status')" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-badge is-dot :type="dotType(row)" style="padding-left:12px">
|
||||
<span :style="{ color: stateColor(row) }">{{ stateText(row) }}</span>
|
||||
</el-badge>
|
||||
<span :style="{ color: stateColor(row) }">{{ stateText(row) }}</span>
|
||||
<el-tooltip v-if="row.runtime?.detail" :content="row.runtime.detail" placement="top">
|
||||
<el-icon style="margin-left:4px;color:#909399;cursor:help"><InfoFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
@@ -225,7 +228,7 @@
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Plus, InfoFilled, ArrowDown } from '@element-plus/icons-vue'
|
||||
import { Plus, InfoFilled, ArrowDown, Refresh } from '@element-plus/icons-vue'
|
||||
import QRCode from 'qrcode'
|
||||
import { useRouter } from 'vue-router'
|
||||
import api from '../api'
|
||||
@@ -297,9 +300,6 @@ function stateText(row) {
|
||||
function stateColor(row) {
|
||||
return { running: '#67c23a', stopped: '#909399', error: '#f56c6c', undeployed: '#e6a23c' }[stateOf(row)]
|
||||
}
|
||||
function dotType(row) {
|
||||
return { running: 'success', stopped: 'info', error: 'danger', undeployed: 'warning' }[stateOf(row)] || 'info'
|
||||
}
|
||||
function stateOf(row) { return row.runtime?.state || row.state }
|
||||
function prettyConfig(c) {
|
||||
try { return JSON.stringify(JSON.parse(c), null, 2) } catch { return c }
|
||||
@@ -565,7 +565,8 @@ function download(row) {
|
||||
const a = document.createElement('a')
|
||||
const url = URL.createObjectURL(b)
|
||||
a.href = url
|
||||
a.download = `${row.protocol}-${row.name}.conf`
|
||||
const ext = row.protocol === 'openvpn' ? 'ovpn' : 'conf'
|
||||
a.download = `${row.protocol}-${row.name}.${ext}`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
})
|
||||
|
||||
+150
-84
@@ -1,17 +1,32 @@
|
||||
<template>
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<div class="vpn-header">
|
||||
<div>
|
||||
<b>{{ $t('vpnUsers.title') }}</b>
|
||||
<span style="margin-left:8px;color:#909399;font-size:12px">
|
||||
{{ $t('vpnUsers.mfaHint') }}
|
||||
</span>
|
||||
</div>
|
||||
<el-button type="primary" @click="openCreate">{{ $t('vpnUsers.addUser') }}</el-button>
|
||||
<div class="vpn-header-actions">
|
||||
<el-button :icon="Refresh" circle :loading="loading" @click="load" />
|
||||
<el-button @click="openBulkImport">{{ $t('vpnUsers.bulkImport') }}</el-button>
|
||||
<el-button type="primary" @click="openCreate">{{ $t('vpnUsers.addUser') }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="users" size="small">
|
||||
<div class="vpn-toolbar">
|
||||
<el-input v-model="keyword" :placeholder="$t('vpnUsers.searchPlaceholder')" clearable style="width:220px" />
|
||||
<el-select v-model="tunnelFilter" clearable :placeholder="$t('vpnUsers.allTunnels')" style="width:210px">
|
||||
<el-option v-for="t in tunnels" :key="t.id" :value="t.id" :label="t.name" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div v-if="selected.length" class="vpn-batch-bar">
|
||||
<span>{{ $t('vpnUsers.selectedUsers', { count: selected.length }) }}</span>
|
||||
<el-button type="danger" size="small" @click="batchRemove">{{ $t('vpnUsers.batchDelete') }}</el-button>
|
||||
</div>
|
||||
<el-table :data="filteredUsers" size="small" v-loading="loading" @selection-change="rows => selected = rows">
|
||||
<el-table-column type="selection" width="44" />
|
||||
<el-table-column prop="username" :label="$t('common.username')" />
|
||||
<el-table-column prop="display_name" :label="$t('vpnUsers.displayName')" />
|
||||
<el-table-column :label="$t('vpnUsers.tunnel')" min-width="160">
|
||||
@@ -64,24 +79,11 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('common.username')" required>
|
||||
<el-input v-model="form.username">
|
||||
<template #append>
|
||||
<el-button @click="openPlatformPicker">{{ $t('vpnUsers.pickFromUsers') }}</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-input v-model="form.username" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('vpnUsers.displayNameLabel')"><el-input v-model="form.display_name" /></el-form-item>
|
||||
<el-form-item :label="$t('vpnUsers.authSource')">
|
||||
<el-radio-group v-model="form.source">
|
||||
<el-radio-button value="local">{{ $t('common.local') }}</el-radio-button>
|
||||
<el-radio-button value="ldap">{{ $t('common.ldap') }}</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.source === 'local'" :label="$t('common.password')" required>
|
||||
<el-input v-model="form.password" type="password" show-password :placeholder="$t('vpnUsers.min8Chars')" />
|
||||
</el-form-item>
|
||||
<el-form-item v-else :label="$t('vpnUsers.ldapDn')" required>
|
||||
<el-input v-model="form.ldap_dn" placeholder="uid=user,ou=people,dc=example,dc=com" />
|
||||
<el-form-item :label="$t('common.password')" required>
|
||||
<el-input v-model="form.password" type="password" show-password :placeholder="$t('vpnUsers.min8Chars')" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('common.email')"><el-input v-model="form.email" /></el-form-item>
|
||||
</el-form>
|
||||
@@ -91,10 +93,14 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 从平台用户选择对话框 -->
|
||||
<el-dialog v-model="pickerVisible" :title="$t('vpnUsers.pickUserTitle')" width="560px" align-center>
|
||||
<el-dialog v-model="pickerVisible" :title="$t('vpnUsers.bulkImportTitle')" width="680px" align-center>
|
||||
<el-alert type="info" :closable="false" style="margin-bottom:12px" :title="$t('vpnUsers.bulkImportHint')" />
|
||||
<el-select v-model="form.tunnel_id" style="width:100%;margin-bottom:12px" :placeholder="$t('vpnUsers.selectOpenVPNTunnel')">
|
||||
<el-option v-for="t in tunnels.filter(item => item.protocol === 'openvpn')" :key="t.id" :value="t.id" :label="t.name" />
|
||||
</el-select>
|
||||
<el-table :data="platformUsers" size="small" max-height="360" v-loading="pickerLoading"
|
||||
@row-click="pickPlatformUser">
|
||||
@selection-change="rows => platformSelected = rows">
|
||||
<el-table-column type="selection" width="44" />
|
||||
<el-table-column prop="username" :label="$t('common.username')" width="120" />
|
||||
<el-table-column prop="email" :label="$t('common.email')" min-width="160" />
|
||||
<el-table-column :label="$t('common.source')" width="80">
|
||||
@@ -104,58 +110,39 @@
|
||||
</el-table-column>
|
||||
<template #empty><el-empty :description="$t('vpnUsers.noPlatformUsers')" /></template>
|
||||
</el-table>
|
||||
<p style="text-align:center;color:var(--text-secondary);font-size:12px;margin-top:8px">{{ $t('vpnUsers.clickRowHint') }}</p>
|
||||
<template #footer>
|
||||
<el-button @click="pickerVisible = false">{{ $t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" :loading="importing" @click="importPlatformUsers">{{ $t('vpnUsers.importSelected') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- OTP 双因素认证管理 -->
|
||||
<el-dialog v-model="otpVisible" :title="$t('vpnUsers.otpDialogTitle', { username: otpUser?.username || '' })" width="520px">
|
||||
<el-alert type="info" :closable="false" style="margin-bottom:12px"
|
||||
:title="$t('vpnUsers.otpHint')" />
|
||||
|
||||
<el-descriptions :column="1" border size="small" v-if="otpStatus">
|
||||
<el-descriptions-item :label="$t('vpnUsers.bindStatus')">
|
||||
<el-tag :type="otpStatus.mfa_enabled ? 'success' : 'info'" size="small">
|
||||
{{ otpStatus.mfa_enabled ? $t('vpnUsers.boundTotp') : $t('vpnUsers.unbound') }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('vpnUsers.staticPin')">{{ otpStatus.mfa_has_pin ? $t('vpnUsers.pinSet') : $t('vpnUsers.pinNotSet') }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('vpnUsers.failCount')">{{ otpStatus.mfa_fail_count || 0 }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('vpnUsers.lockStatus')">
|
||||
<span v-if="otpStatus.mfa_locked" style="color:#f56c6c">
|
||||
{{ $t('vpnUsers.lockedUntil', { time: otpStatus.mfa_locked_until ? new Date(otpStatus.mfa_locked_until).toLocaleString() : '' }) }}
|
||||
</span>
|
||||
<span v-else>{{ $t('common.no') }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('vpnUsers.lastOtpSuccess')">
|
||||
{{ otpStatus.mfa_last_used_at ? new Date(otpStatus.mfa_last_used_at).toLocaleString() : '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('vpnUsers.compatible')">{{ otpStatus.compatible }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div style="margin-top:16px;display:flex;flex-wrap:wrap;gap:8px">
|
||||
<el-button v-if="!otpStatus?.mfa_enabled" type="primary" @click="setupMFA">{{ $t('vpnUsers.bindAuthenticator') }}</el-button>
|
||||
<el-button v-else type="danger" plain @click="disableMFA">{{ $t('vpnUsers.disableMfa') }}</el-button>
|
||||
<el-button v-if="otpStatus?.mfa_enabled" @click="regenBackup">{{ $t('vpnUsers.regenBackupCodes') }}</el-button>
|
||||
<el-button v-if="otpStatus?.mfa_enabled" @click="showPinDialog">{{ $t('vpnUsers.setClearPin') }}</el-button>
|
||||
<el-button v-if="otpStatus?.mfa_locked" type="warning" @click="unlockMFA">{{ $t('vpnUsers.unlock') }}</el-button>
|
||||
<el-button v-if="otpStatus?.mfa_enabled" @click="exportOtpSecrets">{{ $t('vpnUsers.exportKeys') }}</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="bindStep" style="margin-top:16px;text-align:center;border-top:1px solid #ebeef5;padding-top:16px">
|
||||
<p style="font-size:13px;color:#606266">{{ $t('vpnUsers.scanHint') }}</p>
|
||||
<canvas ref="mfaCanvas"></canvas>
|
||||
<div v-if="mfaSecret" style="margin-top:8px;font-size:12px;color:#909399;word-break:break-all">
|
||||
{{ $t('vpnUsers.base32Key', { key: mfaSecret }) }}
|
||||
<el-dialog v-model="otpVisible" width="620px" class="mfa-dialog" destroy-on-close>
|
||||
<template #header>
|
||||
<div class="mfa-dialog-title">
|
||||
<div class="mfa-title-icon"><el-icon><Key /></el-icon></div>
|
||||
<div><div class="mfa-title-text">{{ $t('vpnUsers.otpDialogTitle', { username: otpUser?.username || '' }) }}</div><div class="mfa-title-subtitle">{{ tunnelName(otpUser?.tunnel_id) }}</div></div>
|
||||
</div>
|
||||
<el-input v-model="mfaCode" :placeholder="$t('vpnUsers.input6DigitCode')" style="margin-top:12px;max-width:280px" maxlength="8" />
|
||||
<div style="margin-top:10px">
|
||||
<el-button type="primary" :loading="mfaSaving" @click="enableMFA">{{ $t('vpnUsers.verifyAndEnable') }}</el-button>
|
||||
<el-button @click="bindStep = false">{{ $t('common.cancel') }}</el-button>
|
||||
</template>
|
||||
<div v-if="otpStatus" class="mfa-content">
|
||||
<div class="mfa-status-strip" :class="{ enabled: otpStatus.mfa_enabled }">
|
||||
<el-icon class="mfa-status-icon"><CircleCheckFilled v-if="otpStatus.mfa_enabled" /><Key v-else /></el-icon>
|
||||
<div class="mfa-status-copy"><strong>{{ otpStatus.mfa_enabled ? $t('vpnUsers.boundTotp') : $t('vpnUsers.mfaNotEnabled') }}</strong><span>{{ otpStatus.mfa_enabled ? $t('vpnUsers.mfaEnabledHint') : $t('vpnUsers.mfaDisabledHint') }}</span></div>
|
||||
<el-tag :type="otpStatus.mfa_enabled ? 'success' : 'info'" effect="plain">{{ otpStatus.mfa_enabled ? $t('vpnUsers.bound') : $t('vpnUsers.unbound') }}</el-tag>
|
||||
</div>
|
||||
<template v-if="!otpStatus.mfa_enabled">
|
||||
<div v-if="!bindStep" class="mfa-empty-state"><div class="mfa-empty-icon"><el-icon><Key /></el-icon></div><p>{{ $t('vpnUsers.mfaSetupIntro') }}</p><el-button type="primary" size="large" @click="setupMFA">{{ $t('vpnUsers.bindAuthenticator') }}</el-button></div>
|
||||
<div v-else class="mfa-enrollment">
|
||||
<div class="mfa-qr-panel"><div class="mfa-panel-label">{{ $t('vpnUsers.scanHint') }}</div><div class="mfa-qr"><canvas ref="mfaCanvas"></canvas></div><el-collapse v-if="mfaSecret" v-model="manualSecretOpen" class="mfa-manual-secret"><el-collapse-item :title="$t('vpnUsers.manualSetup')" name="secret"><code>{{ mfaSecret }}</code></el-collapse-item></el-collapse></div>
|
||||
<div class="mfa-verify-panel"><div class="mfa-panel-label">{{ $t('vpnUsers.verifySetup') }}</div><p>{{ $t('vpnUsers.verifySetupHint') }}</p><el-input v-model="mfaCode" inputmode="numeric" maxlength="6" :placeholder="$t('vpnUsers.input6DigitCode')" /><div class="mfa-action-row"><el-button type="primary" :loading="mfaSaving" @click="enableMFA">{{ $t('vpnUsers.verifyAndEnable') }}</el-button><el-button @click="cancelMFASetup">{{ $t('common.cancel') }}</el-button></div></div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="mfa-metrics"><div class="mfa-metric"><span>{{ $t('vpnUsers.staticPin') }}</span><strong>{{ otpStatus.mfa_has_pin ? $t('vpnUsers.pinSet') : $t('vpnUsers.pinNotSet') }}</strong></div><div class="mfa-metric"><span>{{ $t('vpnUsers.lockStatus') }}</span><strong :class="{ danger: otpStatus.mfa_locked }">{{ otpStatus.mfa_locked ? $t('vpnUsers.locked') : $t('common.no') }}</strong></div><div class="mfa-metric"><span>{{ $t('vpnUsers.lastOtpSuccess') }}</span><strong>{{ otpStatus.mfa_last_used_at ? new Date(otpStatus.mfa_last_used_at).toLocaleString() : '—' }}</strong></div></div>
|
||||
<div class="mfa-actions"><el-button @click="showPinDialog">{{ $t('vpnUsers.setClearPin') }}</el-button><el-button @click="regenBackup">{{ $t('vpnUsers.regenBackupCodes') }}</el-button><el-button v-if="otpStatus.mfa_locked" type="warning" @click="unlockMFA">{{ $t('vpnUsers.unlock') }}</el-button><el-button @click="exportOtpSecrets">{{ $t('vpnUsers.exportKeys') }}</el-button><el-button type="danger" plain @click="disableMFA">{{ $t('vpnUsers.disableMfa') }}</el-button></div>
|
||||
<el-collapse class="mfa-details"><el-collapse-item :title="$t('vpnUsers.securityDetails')" name="details"><div class="mfa-detail-line"><span>{{ $t('vpnUsers.failCount') }}</span><b>{{ otpStatus.mfa_fail_count || 0 }}</b></div><div class="mfa-detail-line"><span>{{ $t('vpnUsers.compatible') }}</span><b>{{ otpStatus.compatible }}</b></div></el-collapse-item></el-collapse>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<el-alert v-if="otpStatus?.mfa_enabled" type="success" :closable="false" style="margin-top:16px"
|
||||
:title="$t('vpnUsers.clientConnectHint')" />
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="pinVisible" :title="$t('vpnUsers.setPinTitle')" width="400px">
|
||||
@@ -170,8 +157,9 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { CircleCheckFilled, Key, Refresh } from '@element-plus/icons-vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import QRCode from 'qrcode'
|
||||
import api from '../api'
|
||||
@@ -181,13 +169,19 @@ import { protocolDisplay } from '../protocols'
|
||||
const { t } = useI18n()
|
||||
const users = ref([])
|
||||
const tunnels = ref([])
|
||||
const loading = ref(false)
|
||||
const keyword = ref('')
|
||||
const tunnelFilter = ref(null)
|
||||
const selected = ref([])
|
||||
const visible = ref(false)
|
||||
const form = ref({ tunnel_id: null, username: '', display_name: '', source: 'local', password: '', ldap_dn: '', email: '', enabled: true })
|
||||
const form = ref({ tunnel_id: null, username: '', display_name: '', password: '', email: '', enabled: true })
|
||||
|
||||
// 平台用户选择器
|
||||
const pickerVisible = ref(false)
|
||||
const pickerLoading = ref(false)
|
||||
const platformUsers = ref([])
|
||||
const platformSelected = ref([])
|
||||
const importing = ref(false)
|
||||
|
||||
const otpVisible = ref(false)
|
||||
const otpUser = ref(null)
|
||||
@@ -197,6 +191,7 @@ const mfaCode = ref('')
|
||||
const mfaSecret = ref('')
|
||||
const mfaCanvas = ref(null)
|
||||
const mfaSaving = ref(false)
|
||||
const manualSecretOpen = ref([])
|
||||
const pinVisible = ref(false)
|
||||
const pinValue = ref('')
|
||||
|
||||
@@ -207,23 +202,31 @@ const tunnelProto = id => {
|
||||
}
|
||||
const isOpenVPN = id => tunnels.value.find(x => x.id === id)?.protocol === 'openvpn'
|
||||
const isLocked = row => row.mfa_locked_until && new Date(row.mfa_locked_until) > new Date()
|
||||
const filteredUsers = computed(() => users.value.filter(user => {
|
||||
const matchKeyword = !keyword.value || [user.username, user.display_name, user.email].some(v => (v || '').toLowerCase().includes(keyword.value.toLowerCase()))
|
||||
return matchKeyword && (!tunnelFilter.value || user.tunnel_id === tunnelFilter.value)
|
||||
}))
|
||||
|
||||
async function load() {
|
||||
const [u, all] = await Promise.all([api.get('/vpn-users'), api.get('/tunnels')])
|
||||
users.value = u.data || []
|
||||
tunnels.value = (all.data || []).filter(tun => ['openvpn', 'l2tp', 'ikev2'].includes(tun.protocol))
|
||||
loading.value = true
|
||||
try {
|
||||
const [u, all] = await Promise.all([api.get('/vpn-users'), api.get('/tunnels')])
|
||||
users.value = u.data || []
|
||||
tunnels.value = (all.data || []).filter(tun => ['openvpn', 'l2tp', 'ikev2'].includes(tun.protocol))
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
form.value = {
|
||||
tunnel_id: tunnels.value[0]?.id || null,
|
||||
username: '', display_name: '', source: 'local', password: '', ldap_dn: '', email: '', enabled: true
|
||||
username: '', display_name: '', password: '', email: '', enabled: true
|
||||
}
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
// 从平台用户选择导入
|
||||
async function openPlatformPicker() {
|
||||
async function openBulkImport() {
|
||||
platformSelected.value = []
|
||||
form.value.tunnel_id = tunnels.value.find(tunnel => tunnel.protocol === 'openvpn')?.id || null
|
||||
pickerVisible.value = true
|
||||
pickerLoading.value = true
|
||||
try {
|
||||
@@ -231,14 +234,16 @@ async function openPlatformPicker() {
|
||||
platformUsers.value = data || []
|
||||
} catch { platformUsers.value = [] } finally { pickerLoading.value = false }
|
||||
}
|
||||
function pickPlatformUser(row) {
|
||||
form.value.username = row.username
|
||||
form.value.display_name = row.email ? row.email.split('@')[0] : row.username
|
||||
form.value.email = row.email || ''
|
||||
form.value.source = row.source === 'ldap' ? 'ldap' : 'local'
|
||||
form.value.ldap_dn = row.source === 'ldap' ? `uid=${row.username}` : ''
|
||||
pickerVisible.value = false
|
||||
ElMessage.success(t('vpnUsers.userFilled', { username: row.username }))
|
||||
|
||||
async function importPlatformUsers() {
|
||||
if (!form.value.tunnel_id || !platformSelected.value.length) return ElMessage.warning(t('vpnUsers.selectTunnelAndUsers'))
|
||||
importing.value = true
|
||||
try {
|
||||
const { data } = await api.post('/vpn-users/import-platform', { tunnel_id: form.value.tunnel_id, platform_user_ids: platformSelected.value.map(row => row.id) })
|
||||
ElMessage.success(t('vpnUsers.importedCount', { count: data.created || 0 }))
|
||||
pickerVisible.value = false
|
||||
await load()
|
||||
} catch (e) { ElMessage.error(e.response?.data?.error || t('vpnUsers.importFailed')) } finally { importing.value = false }
|
||||
}
|
||||
|
||||
async function create() {
|
||||
@@ -271,6 +276,16 @@ async function remove(row) {
|
||||
}
|
||||
}
|
||||
|
||||
async function batchRemove() {
|
||||
try {
|
||||
await ElMessageBox.confirm(t('vpnUsers.batchDeleteConfirm', { count: selected.value.length }), t('common.confirm'), { type: 'warning' })
|
||||
await api.post('/vpn-users/batch-delete', { ids: selected.value.map(row => row.id) })
|
||||
selected.value = []
|
||||
ElMessage.success(t('vpnUsers.batchDeleteDone'))
|
||||
await load()
|
||||
} catch (e) { if (e !== 'cancel') ElMessage.error(e.response?.data?.error || t('common.deleteFailed')) }
|
||||
}
|
||||
|
||||
async function download(row) {
|
||||
try {
|
||||
const res = await fetch(`/api/vpn-users/${row.id}/config`, {
|
||||
@@ -296,6 +311,7 @@ async function download(row) {
|
||||
async function openOTPManager(row) {
|
||||
otpUser.value = row
|
||||
bindStep.value = false
|
||||
manualSecretOpen.value = []
|
||||
otpVisible.value = true
|
||||
await refreshOTPStatus()
|
||||
}
|
||||
@@ -323,6 +339,7 @@ async function setupMFA() {
|
||||
const { data } = await api.post(`/vpn-users/${otpUser.value.id}/mfa/setup`)
|
||||
mfaCode.value = ''
|
||||
mfaSecret.value = data.secret || ''
|
||||
manualSecretOpen.value = []
|
||||
bindStep.value = true
|
||||
await nextTick()
|
||||
if (mfaCanvas.value && data.otpauth_url) {
|
||||
@@ -333,6 +350,13 @@ async function setupMFA() {
|
||||
}
|
||||
}
|
||||
|
||||
function cancelMFASetup() {
|
||||
bindStep.value = false
|
||||
mfaCode.value = ''
|
||||
mfaSecret.value = ''
|
||||
manualSecretOpen.value = []
|
||||
}
|
||||
|
||||
async function enableMFA() {
|
||||
if (!mfaCode.value) return ElMessage.warning(t('vpnUsers.inputCode'))
|
||||
mfaSaving.value = true
|
||||
@@ -418,3 +442,45 @@ async function exportOtpSecrets() {
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mfa-dialog-title { display: flex; align-items: center; gap: 12px; }
|
||||
.vpn-header, .vpn-header-actions, .vpn-toolbar, .vpn-batch-bar { display:flex; align-items:center; gap:8px; }
|
||||
.vpn-header { justify-content:space-between; }
|
||||
.vpn-toolbar { padding: 0 0 12px; }
|
||||
.vpn-batch-bar { justify-content:space-between; margin: 0 0 10px; padding: 8px 10px; background:#fef3c7; color:#92400e; border:1px solid #fde68a; font-size:13px; }
|
||||
.mfa-title-icon, .mfa-empty-icon { display: grid; place-items: center; color: #2563eb; background: #eff6ff; }
|
||||
.mfa-title-icon { width: 36px; height: 36px; border-radius: 6px; font-size: 19px; }
|
||||
.mfa-title-text { font-size: 16px; font-weight: 650; color: #1f2937; }
|
||||
.mfa-title-subtitle { margin-top: 2px; color: #6b7280; font-size: 12px; }
|
||||
.mfa-content { display: grid; gap: 16px; }
|
||||
.mfa-status-strip { display: flex; align-items: center; gap: 12px; padding: 13px 14px; border: 1px solid #d1d5db; background: #f9fafb; }
|
||||
.mfa-status-strip.enabled { border-color: #a7f3d0; background: #ecfdf5; }
|
||||
.mfa-status-icon { color: #64748b; font-size: 22px; }
|
||||
.mfa-status-strip.enabled .mfa-status-icon { color: #16a34a; }
|
||||
.mfa-status-copy { display: grid; gap: 2px; flex: 1; }
|
||||
.mfa-status-copy strong { color: #1f2937; font-size: 14px; }
|
||||
.mfa-status-copy span { color: #6b7280; font-size: 12px; }
|
||||
.mfa-empty-state { display: grid; justify-items: center; gap: 12px; padding: 26px 16px 20px; text-align: center; }
|
||||
.mfa-empty-icon { width: 48px; height: 48px; border-radius: 50%; font-size: 22px; }
|
||||
.mfa-empty-state p { margin: 0; color: #4b5563; font-size: 13px; }
|
||||
.mfa-enrollment { display: grid; grid-template-columns: minmax(220px, 1fr) minmax(220px, 1fr); border: 1px solid #e5e7eb; }
|
||||
.mfa-qr-panel, .mfa-verify-panel { padding: 18px; }
|
||||
.mfa-qr-panel { display: grid; justify-items: center; border-right: 1px solid #e5e7eb; }
|
||||
.mfa-panel-label { color: #374151; font-weight: 600; font-size: 13px; }
|
||||
.mfa-qr { margin: 14px 0 8px; padding: 10px; background: #fff; border: 1px solid #e5e7eb; }
|
||||
.mfa-manual-secret { width: 100%; }
|
||||
.mfa-manual-secret code { display: block; overflow-wrap: anywhere; color: #334155; font-size: 12px; }
|
||||
.mfa-verify-panel { display: grid; align-content: center; gap: 12px; }
|
||||
.mfa-verify-panel p { margin: 0; color: #6b7280; font-size: 12px; line-height: 1.55; }
|
||||
.mfa-action-row, .mfa-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.mfa-metrics { display: grid; grid-template-columns: repeat(3, 1fr); border: 1px solid #e5e7eb; }
|
||||
.mfa-metric { display: grid; gap: 5px; padding: 13px; border-right: 1px solid #e5e7eb; }
|
||||
.mfa-metric:last-child { border-right: 0; }
|
||||
.mfa-metric span { color: #6b7280; font-size: 12px; }
|
||||
.mfa-metric strong { color: #1f2937; font-size: 13px; word-break: break-word; }
|
||||
.mfa-metric .danger { color: #dc2626; }
|
||||
.mfa-details { margin-top: -4px; }
|
||||
.mfa-detail-line { display: flex; justify-content: space-between; padding: 4px 0; color: #4b5563; font-size: 13px; }
|
||||
@media (max-width: 640px) { .mfa-enrollment { grid-template-columns: 1fr; } .mfa-qr-panel { border-right: 0; border-bottom: 1px solid #e5e7eb; } .mfa-metrics { grid-template-columns: 1fr; } .mfa-metric { border-right: 0; border-bottom: 1px solid #e5e7eb; } .mfa-metric:last-child { border-bottom: 0; } }
|
||||
</style>
|
||||
Reference in new issue
Block a user