feat: 后端限速实现 + 前端i18n全面国际化 + 安全与代码质量修复
后端: - trafficmgr: 实现 tc htb 限速(ApplyRateLimit/RemoveRateLimit),自动推导接口名 - 各协议服务: 代码质量改进与错误处理增强 前端: - protocols.js: 170个硬编码中文字符串全部转为 i18n key,中英文双语翻译 - 提取 fmtBytes() 到 utils/format.js 公共模块(消除 4 处重复) - router.js: 添加路由级权限守卫,非管理员无法直接 URL 访问管理页 - Tunnels.vue: SSE 日志流改用 fetch+ReadableStream,token 放 Authorization header - VPNUsers.vue: 修复 catch 块中文硬编码 fallback - 新增 TaskProgress 组件、taskmgr 后端任务管理
This commit is contained in:
1 parent
021d012478
commit
91974fc30f
26 files changed
+1977
-377
No files matched your search
@@ -32,14 +32,16 @@ import (
|
||||
"vpn-platform/backend/internal/sshutil"
|
||||
"vpn-platform/backend/internal/store"
|
||||
"vpn-platform/backend/internal/trafficmgr"
|
||||
"vpn-platform/backend/internal/taskmgr"
|
||||
)
|
||||
|
||||
// Server API 服务器
|
||||
type Server struct {
|
||||
mgr *core.Manager
|
||||
mgr *core.Manager
|
||||
taskMgr *taskmgr.Manager
|
||||
}
|
||||
|
||||
func NewServer(mgr *core.Manager) *Server { return &Server{mgr: mgr} }
|
||||
func NewServer(mgr *core.Manager) *Server { return &Server{mgr: mgr, taskMgr: taskmgr.NewManager()} }
|
||||
|
||||
// ---- Token 会话(内存态,定期清理过期项)----
|
||||
var (
|
||||
@@ -260,6 +262,14 @@ func (s *Server) RegisterRoutes(r *gin.Engine) {
|
||||
ops.GET("/binaries/install-hint/:name", s.binaryInstallHint)
|
||||
ops.PUT("/tunnels/:id/policy", s.saveTrafficPolicy)
|
||||
|
||||
// 批量操作 & 任务管理
|
||||
ops.POST("/tunnels/batch/start", s.batchTunnels(taskmgr.ActionStart))
|
||||
ops.POST("/tunnels/batch/stop", s.batchTunnels(taskmgr.ActionStop))
|
||||
ops.POST("/tunnels/batch/delete", s.batchTunnels(taskmgr.ActionDelete))
|
||||
ops.POST("/tunnels/batch/policy", s.batchSetPolicy)
|
||||
ops.GET("/tasks", s.listTasks)
|
||||
ops.GET("/tasks/:id", s.getTask)
|
||||
|
||||
// 平台管理 admin
|
||||
admin := auth.Group("")
|
||||
admin.Use(requireRole("admin"))
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"vpn-platform/backend/internal/model"
|
||||
"vpn-platform/backend/internal/service"
|
||||
"vpn-platform/backend/internal/store"
|
||||
"vpn-platform/backend/internal/taskmgr"
|
||||
)
|
||||
|
||||
// batchRequest 批量操作请求体
|
||||
type batchRequest struct {
|
||||
TunnelIDs []uint `json:"tunnel_ids" binding:"required,min=1"`
|
||||
}
|
||||
|
||||
// batchTunnels 批量启动/停止/删除隧道
|
||||
func (s *Server) batchTunnels(action taskmgr.TaskAction) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req batchRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证所有隧道存在
|
||||
var tunnels []model.Tunnel
|
||||
if err := store.DB.Where("id IN ?", req.TunnelIDs).Find(&tunnels).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询隧道失败"})
|
||||
return
|
||||
}
|
||||
if len(tunnels) != len(req.TunnelIDs) {
|
||||
found := map[uint]bool{}
|
||||
for _, t := range tunnels {
|
||||
found[t.ID] = true
|
||||
}
|
||||
var missing []uint
|
||||
for _, id := range req.TunnelIDs {
|
||||
if !found[id] {
|
||||
missing = append(missing, id)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("隧道不存在: %v", missing)})
|
||||
return
|
||||
}
|
||||
|
||||
// 构建名称映射
|
||||
names := map[uint]string{}
|
||||
for _, t := range tunnels {
|
||||
names[t.ID] = t.Name
|
||||
}
|
||||
|
||||
// 创建任务
|
||||
taskID := s.taskMgr.Create(action, req.TunnelIDs, names)
|
||||
username := c.GetString("username")
|
||||
|
||||
// 并发执行
|
||||
go func() {
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, 5) // 最多 5 个并发
|
||||
|
||||
for _, tid := range req.TunnelIDs {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(tunnelID uint) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
|
||||
s.taskMgr.MarkRunning(taskID, tunnelID)
|
||||
err := s.executeBatchAction(action, tunnelID, username)
|
||||
if err != nil {
|
||||
s.taskMgr.MarkFailed(taskID, tunnelID, err)
|
||||
} else {
|
||||
s.taskMgr.MarkDone(taskID, tunnelID)
|
||||
}
|
||||
}(tid)
|
||||
}
|
||||
wg.Wait()
|
||||
}()
|
||||
|
||||
c.JSON(http.StatusAccepted, gin.H{"task_id": taskID})
|
||||
}
|
||||
}
|
||||
|
||||
// executeBatchAction 执行单个隧道的操作
|
||||
func (s *Server) executeBatchAction(action taskmgr.TaskAction, tunnelID uint, username string) error {
|
||||
var t model.Tunnel
|
||||
if err := store.DB.First(&t, tunnelID).Error; err != nil {
|
||||
return fmt.Errorf("隧道不存在")
|
||||
}
|
||||
|
||||
mu := lockTunnel(t.ID)
|
||||
defer mu.Unlock()
|
||||
|
||||
switch action {
|
||||
case taskmgr.ActionStart:
|
||||
if t.ExpireAt != nil && time.Now().After(*t.ExpireAt) {
|
||||
return fmt.Errorf("专线已到期")
|
||||
}
|
||||
p, err := s.mgr.Get(t.Protocol)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if t.State == "running" {
|
||||
return nil // 已经运行中,视为成功
|
||||
}
|
||||
if err := p.Start(&t); err != nil {
|
||||
t.State = "error"
|
||||
t.LastError = err.Error()
|
||||
store.DB.Save(&t)
|
||||
return err
|
||||
}
|
||||
t.State = "running"
|
||||
t.LastError = ""
|
||||
store.DB.Save(&t)
|
||||
store.Audit(username, "batch_start", t.Name, "")
|
||||
|
||||
case taskmgr.ActionStop:
|
||||
p, err := s.mgr.Get(t.Protocol)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if t.State == "stopped" {
|
||||
return nil
|
||||
}
|
||||
if err := p.Stop(&t); err != nil {
|
||||
return err
|
||||
}
|
||||
t.State = "stopped"
|
||||
store.DB.Save(&t)
|
||||
store.Audit(username, "batch_stop", t.Name, "")
|
||||
|
||||
case taskmgr.ActionDelete:
|
||||
if p, err := s.mgr.Get(t.Protocol); err == nil {
|
||||
_ = p.Delete(&t)
|
||||
}
|
||||
if err := service.RemoveInstance(&t); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("清理实例文件失败: %w", err)
|
||||
}
|
||||
s.mgr.RemoveStatus(t.ID)
|
||||
store.DB.Delete(&t)
|
||||
store.Audit(username, "batch_delete", t.Name, "")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getTask 查询单个任务状态
|
||||
func (s *Server) getTask(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
t, ok := s.taskMgr.Get(id)
|
||||
if !ok {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "任务不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, t)
|
||||
}
|
||||
|
||||
// listTasks 列出最近任务
|
||||
func (s *Server) listTasks(c *gin.Context) {
|
||||
tasks := s.taskMgr.List()
|
||||
c.JSON(http.StatusOK, tasks)
|
||||
}
|
||||
|
||||
// ensureID 确保 ID 有效(从 batchCreateUsers 模式参考)
|
||||
func parseTunnelIDs(c *gin.Context) ([]uint, error) {
|
||||
var req batchRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, id := range req.TunnelIDs {
|
||||
if id == 0 {
|
||||
return nil, fmt.Errorf("无效的隧道 ID: %d", id)
|
||||
}
|
||||
}
|
||||
return req.TunnelIDs, nil
|
||||
}
|
||||
|
||||
// batchTrafficPolicy 批量设置流量策略
|
||||
type batchPolicyRequest struct {
|
||||
TunnelIDs []uint `json:"tunnel_ids" binding:"required,min=1"`
|
||||
RateLimitMbps int `json:"rate_limit_mbps"`
|
||||
QuotaBytes int64 `json:"quota_bytes"`
|
||||
ActionOnExceed string `json:"action_on_exceed"`
|
||||
ResetCycle string `json:"reset_cycle"`
|
||||
}
|
||||
|
||||
func (s *Server) batchSetPolicy(c *gin.Context) {
|
||||
var req batchPolicyRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
success := 0
|
||||
var errors []string
|
||||
for _, tid := range req.TunnelIDs {
|
||||
var t model.Tunnel
|
||||
if err := store.DB.First(&t, tid).Error; err != nil {
|
||||
errors = append(errors, fmt.Sprintf("隧道 %d 不存在", tid))
|
||||
continue
|
||||
}
|
||||
var policy model.TrafficPolicy
|
||||
if err := store.DB.Where("tunnel_id = ?", tid).First(&policy).Error; err != nil {
|
||||
policy.TunnelID = tid
|
||||
}
|
||||
if req.RateLimitMbps > 0 {
|
||||
policy.RateLimitMbps = req.RateLimitMbps
|
||||
}
|
||||
if req.QuotaBytes > 0 {
|
||||
policy.QuotaBytes = req.QuotaBytes
|
||||
}
|
||||
if req.ActionOnExceed != "" {
|
||||
policy.ActionOnExceed = req.ActionOnExceed
|
||||
}
|
||||
if req.ResetCycle != "" {
|
||||
policy.ResetCycle = req.ResetCycle
|
||||
}
|
||||
if err := store.DB.Save(&policy).Error; err != nil {
|
||||
errors = append(errors, fmt.Sprintf("隧道 %d 策略保存失败: %s", t.Name, err.Error()))
|
||||
continue
|
||||
}
|
||||
success++
|
||||
store.Audit(c.GetString("username"), "batch_policy", t.Name, strconv.Itoa(req.RateLimitMbps)+"Mbps")
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": success,
|
||||
"errors": errors,
|
||||
})
|
||||
}
|
||||
@@ -28,6 +28,9 @@ func Start(mgr *core.Manager, interval time.Duration) {
|
||||
}
|
||||
|
||||
func collect(mgr *core.Manager, interval time.Duration) {
|
||||
if store.DB == nil {
|
||||
return
|
||||
}
|
||||
var tunnels []model.Tunnel
|
||||
if err := store.DB.Find(&tunnels).Error; err != nil {
|
||||
return
|
||||
|
||||
@@ -323,3 +323,110 @@ func SaveTunnelConfig(t *model.Tunnel, public, secrets map[string]interface{}) e
|
||||
}
|
||||
return store.DB.Save(t).Error
|
||||
}
|
||||
|
||||
// CloneConfig 深拷贝配置 map(避免修改原始数据)
|
||||
func CloneConfig(in map[string]interface{}) map[string]interface{} {
|
||||
out := make(map[string]interface{}, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SwanctlUnload 卸载 strongSwan 连接与凭据
|
||||
func SwanctlUnload(bin, confPath string) {
|
||||
_, _ = Run(bin, "--unload-conns", "--file", confPath)
|
||||
_, _ = Run(bin, "--unload-creds", "--file", confPath)
|
||||
}
|
||||
|
||||
// DialUser 拨号用户凭证
|
||||
type DialUser struct {
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
// LoadDialUsers 从数据库加载指定隧道的本地拨号用户
|
||||
func LoadDialUsers(tunnelID uint) ([]DialUser, error) {
|
||||
if store.DB == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var users []model.VPNUser
|
||||
if err := store.DB.Where("tunnel_id = ? AND enabled = ? AND source = ?", tunnelID, true, "local").Find(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]DialUser, 0, len(users))
|
||||
for _, u := range users {
|
||||
if u.DialSecret == "" {
|
||||
continue
|
||||
}
|
||||
pw, err := store.DecryptSecret(u.DialSecret)
|
||||
if err != nil || pw == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, DialUser{Username: u.Username, Password: pw})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FirewallRule 描述一条 iptables 规则
|
||||
type FirewallRule struct {
|
||||
Table string // "filter" or "nat"
|
||||
Chain string // "INPUT", "POSTROUTING", etc.
|
||||
Args []string
|
||||
}
|
||||
|
||||
// ApplyFirewallRules 检查并添加 iptables 规则(已存在则跳过)
|
||||
func ApplyFirewallRules(rules []FirewallRule) error {
|
||||
iptables, ok := LookBin("iptables")
|
||||
if !ok {
|
||||
return fmt.Errorf("未检测到 iptables")
|
||||
}
|
||||
for _, rule := range rules {
|
||||
prefix := []string{}
|
||||
if rule.Table != "filter" {
|
||||
prefix = append(prefix, "-t", rule.Table)
|
||||
}
|
||||
check := append(append([]string{}, prefix...), "-C", rule.Chain)
|
||||
check = append(check, rule.Args...)
|
||||
if _, err := Run(iptables, check...); err == nil {
|
||||
continue
|
||||
}
|
||||
add := append(append([]string{}, prefix...), "-A", rule.Chain)
|
||||
add = append(add, rule.Args...)
|
||||
if _, err := Run(iptables, add...); err != nil {
|
||||
RemoveFirewallRules(rules)
|
||||
return fmt.Errorf("无法添加防火墙规则: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DefaultDNS 返回默认 DNS 服务器地址(可通过 VPN_DNS_SERVER 环境变量覆盖)
|
||||
func DefaultDNS() string {
|
||||
if v := os.Getenv("VPN_DNS_SERVER"); v != "" {
|
||||
return v
|
||||
}
|
||||
return "223.5.5.5"
|
||||
}
|
||||
|
||||
// RemoveFirewallRules 检查并删除 iptables 规则
|
||||
func RemoveFirewallRules(rules []FirewallRule) {
|
||||
iptables, ok := LookBin("iptables")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, rule := range rules {
|
||||
prefix := []string{}
|
||||
if rule.Table != "filter" {
|
||||
prefix = append(prefix, "-t", rule.Table)
|
||||
}
|
||||
check := append(append([]string{}, prefix...), "-C", rule.Chain)
|
||||
check = append(check, rule.Args...)
|
||||
if _, err := Run(iptables, check...); err != nil {
|
||||
continue
|
||||
}
|
||||
del := append(append([]string{}, prefix...), "-D", rule.Chain)
|
||||
del = append(del, rule.Args...)
|
||||
_, _ = Run(iptables, del...)
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ func (s *Service) Available() (bool, string) {
|
||||
}
|
||||
|
||||
func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, error) {
|
||||
out := clone(cfg)
|
||||
out := service.CloneConfig(cfg)
|
||||
mode := service.StrOr(out, "mode", "server")
|
||||
if mode != "server" && mode != "client" {
|
||||
return nil, fmt.Errorf("mode 必须为 server 或 client")
|
||||
@@ -277,7 +277,7 @@ func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) {
|
||||
secrets, _ := store.TunnelSecrets(t)
|
||||
token := service.Str(secrets, "token")
|
||||
if service.StrOr(cfg, "mode", "server") == "server" {
|
||||
cfg = clone(cfg)
|
||||
cfg = service.CloneConfig(cfg)
|
||||
cfg["server_addr"] = service.StrOr(cfg, "public_endpoint", "your-server-ip")
|
||||
cfg["server_port"] = service.Num(cfg, "bind_port", 7000)
|
||||
}
|
||||
@@ -379,13 +379,7 @@ func defaultString(v, d string) string {
|
||||
}
|
||||
return d
|
||||
}
|
||||
func clone(in map[string]interface{}) map[string]interface{} {
|
||||
out := map[string]interface{}{}
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func allowPorts(value string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
|
||||
@@ -30,10 +30,7 @@ func (s *Service) Available() (bool, string) {
|
||||
}
|
||||
|
||||
func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, error) {
|
||||
out := map[string]interface{}{}
|
||||
for k, v := range cfg {
|
||||
out[k] = v
|
||||
}
|
||||
out := service.CloneConfig(cfg)
|
||||
if service.Str(out, "address_pool") == "" {
|
||||
out["address_pool"] = "10.12.0.0/24"
|
||||
}
|
||||
@@ -41,7 +38,7 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{},
|
||||
return nil, fmt.Errorf("address_pool 必须是 CIDR,例如 10.12.0.0/24")
|
||||
}
|
||||
if service.Str(out, "dns") == "" {
|
||||
out["dns"] = "223.5.5.5"
|
||||
out["dns"] = service.DefaultDNS()
|
||||
}
|
||||
if service.Str(out, "local_id") == "" {
|
||||
out["local_id"] = "@opsx-ikev2"
|
||||
@@ -85,7 +82,7 @@ func (s *Service) Create(t *model.Tunnel) error {
|
||||
secrets["password"] = pw
|
||||
}
|
||||
username := service.StrOr(cfg, "username", "vpnuser")
|
||||
dialUsers, err := loadDialUsers(t.ID)
|
||||
dialUsers, err := service.LoadDialUsers(t.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -115,7 +112,7 @@ func (s *Service) Create(t *model.Tunnel) error {
|
||||
// RefreshAuth rewrites EAP secrets after dial-user changes.
|
||||
func (s *Service) RefreshAuth(t *model.Tunnel) error { return s.Create(t) }
|
||||
|
||||
func (s *Service) swanctlConf(id uint, cfg map[string]interface{}, psk, defaultUser, defaultPass string, users []dialUser) string {
|
||||
func (s *Service) swanctlConf(id uint, cfg map[string]interface{}, psk, defaultUser, defaultPass string, users []service.DialUser) string {
|
||||
var secrets strings.Builder
|
||||
fmt.Fprintf(&secrets, `secrets {
|
||||
ike-rw-%d {
|
||||
@@ -186,7 +183,7 @@ pools {
|
||||
service.Str(cfg, "esp_proposal"),
|
||||
id,
|
||||
service.Str(cfg, "address_pool"),
|
||||
service.StrOr(cfg, "dns", "223.5.5.5"),
|
||||
service.StrOr(cfg, "dns", service.DefaultDNS()),
|
||||
secrets.String(),
|
||||
)
|
||||
}
|
||||
@@ -221,7 +218,7 @@ DNS : %s
|
||||
username,
|
||||
service.Str(secrets, "password"),
|
||||
service.Str(cfg, "address_pool"),
|
||||
service.StrOr(cfg, "dns", "223.5.5.5"),
|
||||
service.StrOr(cfg, "dns", service.DefaultDNS()),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -311,38 +308,10 @@ func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
|
||||
func (s *Service) Delete(t *model.Tunnel) error { return s.Stop(t) }
|
||||
|
||||
func (s *Service) unload(bin, confPath string) error {
|
||||
_, _ = service.Run(bin, "--unload-conns", "--file", confPath)
|
||||
_, _ = service.Run(bin, "--unload-creds", "--file", confPath)
|
||||
service.SwanctlUnload(bin, confPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
type dialUser struct {
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
func loadDialUsers(tunnelID uint) ([]dialUser, error) {
|
||||
if store.DB == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var users []model.VPNUser
|
||||
if err := store.DB.Where("tunnel_id = ? AND enabled = ? AND source = ?", tunnelID, true, "local").Find(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]dialUser, 0, len(users))
|
||||
for _, u := range users {
|
||||
if u.DialSecret == "" {
|
||||
continue
|
||||
}
|
||||
pw, err := store.DecryptSecret(u.DialSecret)
|
||||
if err != nil || pw == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, dialUser{Username: u.Username, Password: pw})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) installFirewall(t *model.Tunnel, cfg map[string]interface{}) error {
|
||||
iptables, ok := service.LookBin("iptables")
|
||||
if !ok {
|
||||
|
||||
@@ -55,10 +55,7 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{},
|
||||
if err := service.MustValidate(cfg, "remote_gateway", "local_subnets", "remote_subnets"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]interface{}{}
|
||||
for k, v := range cfg {
|
||||
out[k] = v
|
||||
}
|
||||
out := service.CloneConfig(cfg)
|
||||
// 厂商模板优先填空字段,再回落到通用默认值。
|
||||
if key := service.Str(out, "vendor_template"); key != "" {
|
||||
for _, tmpl := range Templates() {
|
||||
@@ -444,10 +441,7 @@ func extractSwanBytes(out, childName string) (rx, tx int64, ok bool) {
|
||||
func (s *Service) Delete(t *model.Tunnel) error { return s.Stop(t) }
|
||||
|
||||
func (s *Service) unload(bin, confPath string) error {
|
||||
// Both commands are idempotent from the platform perspective: an already
|
||||
// unloaded connection should not prevent stop/delete from cleaning its files.
|
||||
_, _ = service.Run(bin, "--unload-conns", "--file", confPath)
|
||||
_, _ = service.Run(bin, "--unload-creds", "--file", confPath)
|
||||
service.SwanctlUnload(bin, confPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -31,10 +31,7 @@ func (s *Service) Available() (bool, string) {
|
||||
}
|
||||
|
||||
func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, error) {
|
||||
out := map[string]interface{}{}
|
||||
for k, v := range cfg {
|
||||
out[k] = v
|
||||
}
|
||||
out := service.CloneConfig(cfg)
|
||||
if service.Str(out, "ip_range") == "" {
|
||||
out["ip_range"] = "10.9.0.100-10.9.0.200"
|
||||
}
|
||||
@@ -42,7 +39,7 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{},
|
||||
out["local_ip"] = "10.9.0.1"
|
||||
}
|
||||
if service.Str(out, "dns") == "" {
|
||||
out["dns"] = "223.5.5.5"
|
||||
out["dns"] = service.DefaultDNS()
|
||||
}
|
||||
if service.Str(out, "ike_proposal") == "" {
|
||||
out["ike_proposal"] = "aes256-sha256-modp2048,aes128-sha1-modp1024"
|
||||
@@ -108,7 +105,7 @@ func (s *Service) Create(t *model.Tunnel) error {
|
||||
secrets["password"] = pw
|
||||
}
|
||||
// 允许仅依赖拨号用户表(VPNUser)而无隧道级默认密码。
|
||||
dialUsers, err := loadDialUsers(t.ID)
|
||||
dialUsers, err := service.LoadDialUsers(t.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -202,34 +199,9 @@ func (s *Service) RefreshAuth(t *model.Tunnel) error {
|
||||
return s.Create(t)
|
||||
}
|
||||
|
||||
type dialUser struct {
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
func loadDialUsers(tunnelID uint) ([]dialUser, error) {
|
||||
if store.DB == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var users []model.VPNUser
|
||||
if err := store.DB.Where("tunnel_id = ? AND enabled = ? AND source = ?", tunnelID, true, "local").Find(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]dialUser, 0, len(users))
|
||||
for _, u := range users {
|
||||
if u.DialSecret == "" {
|
||||
continue
|
||||
}
|
||||
pw, err := store.DecryptSecret(u.DialSecret)
|
||||
if err != nil || pw == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, dialUser{Username: u.Username, Password: pw})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func buildChapSecrets(defaultUser, defaultPass string, users []dialUser) string {
|
||||
func buildChapSecrets(defaultUser, defaultPass string, users []service.DialUser) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("# client server secret IP addresses\n")
|
||||
seen := map[string]bool{}
|
||||
@@ -361,6 +333,9 @@ func (s *Service) Delete(t *model.Tunnel) error { return s.Stop(t) }
|
||||
// it; without this guard a later instance would look started but accept no VPN
|
||||
// sessions. Network namespaces can deliberately lift this restriction later.
|
||||
func (s *Service) ensureExclusiveListener(t *model.Tunnel) error {
|
||||
if store.DB == nil {
|
||||
return nil
|
||||
}
|
||||
var count int64
|
||||
if err := store.DB.Model(&model.Tunnel{}).Where("protocol = ? AND state = ? AND id <> ?", "l2tp", "running", t.ID).Count(&count).Error; err != nil {
|
||||
return fmt.Errorf("检查 L2TP 监听冲突失败: %w", err)
|
||||
@@ -372,10 +347,7 @@ func (s *Service) ensureExclusiveListener(t *model.Tunnel) error {
|
||||
}
|
||||
|
||||
func (s *Service) unload(bin, confPath string) error {
|
||||
// swanctl reports an error when an object was never loaded. Treat that as
|
||||
// success so Stop/Delete remain idempotent after a partial failed startup.
|
||||
_, _ = service.Run(bin, "--unload-conns", "--file", confPath)
|
||||
_, _ = service.Run(bin, "--unload-creds", "--file", confPath)
|
||||
service.SwanctlUnload(bin, confPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -66,10 +66,7 @@ func (s *Service) Available() (bool, string) {
|
||||
}
|
||||
|
||||
func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, error) {
|
||||
out := map[string]interface{}{}
|
||||
for k, v := range cfg {
|
||||
out[k] = v
|
||||
}
|
||||
out := service.CloneConfig(cfg)
|
||||
if service.Num(out, "port", 0) == 0 {
|
||||
out["port"] = 1194
|
||||
}
|
||||
@@ -101,7 +98,7 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{},
|
||||
return nil, fmt.Errorf("cipher 非法")
|
||||
}
|
||||
if service.Str(out, "dns") == "" {
|
||||
out["dns"] = "223.5.5.5"
|
||||
out["dns"] = service.DefaultDNS()
|
||||
}
|
||||
if net.ParseIP(service.Str(out, "dns")) == nil {
|
||||
return nil, fmt.Errorf("dns 必须是 IP 地址")
|
||||
@@ -300,7 +297,7 @@ func (s *Service) serverConfig(t *model.Tunnel, cfg map[string]interface{}) stri
|
||||
// 地址池与拓扑
|
||||
fmt.Fprintf(&b, "topology subnet\nserver %s\nifconfig-pool-persist %q\n", service.StrOr(cfg, "subnet", "10.10.0.0 255.255.255.0"), filepath.Join(dir, "ipp.txt"))
|
||||
// push:DNS / 全流量 / 内网路由
|
||||
fmt.Fprintf(&b, "push \"dhcp-option DNS %s\"\n", service.StrOr(cfg, "dns", "223.5.5.5"))
|
||||
fmt.Fprintf(&b, "push \"dhcp-option DNS %s\"\n", service.StrOr(cfg, "dns", service.DefaultDNS()))
|
||||
if service.Bool(cfg, "redirect_gateway", true) {
|
||||
b.WriteString("push \"redirect-gateway def1 bypass-dhcp\"\n")
|
||||
}
|
||||
|
||||
@@ -109,10 +109,7 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{},
|
||||
if err := service.MustValidate(cfg, "address"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]interface{}{}
|
||||
for k, v := range cfg {
|
||||
out[k] = v
|
||||
}
|
||||
out := service.CloneConfig(cfg)
|
||||
if !validCIDR(service.Str(out, "address")) {
|
||||
return nil, fmt.Errorf("address 必须是合法 CIDR")
|
||||
}
|
||||
@@ -131,7 +128,7 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{},
|
||||
return nil, fmt.Errorf("allowed_ips: %w", err)
|
||||
}
|
||||
if service.Str(out, "dns") == "" {
|
||||
out["dns"] = "223.5.5.5"
|
||||
out["dns"] = service.DefaultDNS()
|
||||
}
|
||||
if service.Num(out, "keepalive", 0) == 0 {
|
||||
out["keepalive"] = 25
|
||||
@@ -312,7 +309,7 @@ PresharedKey = %s
|
||||
Endpoint = %s
|
||||
AllowedIPs = %s
|
||||
PersistentKeepalive = %d
|
||||
`, sec.PrivateKey, p.ClientAddress, service.StrOr(cfg, "dns", "223.5.5.5"), mtuLine, service.Str(cfg, "server_public_key"), sec.PresharedKey, endpoint, allowed, int(service.Num(cfg, "keepalive", 25))), nil
|
||||
`, sec.PrivateKey, p.ClientAddress, service.StrOr(cfg, "dns", service.DefaultDNS()), mtuLine, service.Str(cfg, "server_public_key"), sec.PresharedKey, endpoint, allowed, int(service.Num(cfg, "keepalive", 25))), nil
|
||||
}
|
||||
if requestedID != "" {
|
||||
return "", fmt.Errorf("WireGuard Peer 不存在: %s", requestedID)
|
||||
@@ -496,11 +493,8 @@ func (s *Service) RemovePeer(t *model.Tunnel, peerID string) error {
|
||||
if raw, ok := secrets["peer_secrets"].(map[string]interface{}); ok {
|
||||
delete(raw, peerID)
|
||||
secrets["peer_secrets"] = raw
|
||||
_ = service.SaveTunnelConfig(t, cfg, secrets)
|
||||
}
|
||||
raw, _ := json.Marshal(cfg)
|
||||
t.Config = string(raw)
|
||||
return s.Create(t)
|
||||
return service.SaveTunnelConfig(t, cfg, secrets)
|
||||
}
|
||||
|
||||
func loadTrafficState(t *model.Tunnel) map[string][2]int64 {
|
||||
@@ -813,9 +807,3 @@ func validateCIDRList(value string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
// Package taskmgr 提供后台并发任务管理器
|
||||
package taskmgr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TaskStatus 任务状态
|
||||
type TaskStatus string
|
||||
|
||||
const (
|
||||
StatusPending TaskStatus = "pending"
|
||||
StatusRunning TaskStatus = "running"
|
||||
StatusDone TaskStatus = "done"
|
||||
StatusFailed TaskStatus = "failed"
|
||||
StatusCanceled TaskStatus = "canceled"
|
||||
)
|
||||
|
||||
// TaskAction 任务动作
|
||||
type TaskAction string
|
||||
|
||||
const (
|
||||
ActionStart TaskAction = "start"
|
||||
ActionStop TaskAction = "stop"
|
||||
ActionDelete TaskAction = "delete"
|
||||
)
|
||||
|
||||
// SubTask 单个子任务(对应一个隧道)
|
||||
type SubTask struct {
|
||||
TunnelID uint `json:"tunnel_id"`
|
||||
TunnelName string `json:"tunnel_name"`
|
||||
Status TaskStatus `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
||||
}
|
||||
|
||||
// Task 一个批量任务
|
||||
type Task struct {
|
||||
ID string `json:"id"`
|
||||
Action TaskAction `json:"action"`
|
||||
Status TaskStatus `json:"status"`
|
||||
Total int `json:"total"`
|
||||
Success int `json:"success"`
|
||||
Failed int `json:"failed"`
|
||||
SubTasks []SubTask `json:"sub_tasks"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Progress 返回进度百分比 0-100
|
||||
func (t *Task) Progress() int {
|
||||
if t.Total == 0 {
|
||||
return 0
|
||||
}
|
||||
done := t.Success + t.Failed
|
||||
return done * 100 / t.Total
|
||||
}
|
||||
|
||||
// Manager 内存任务管理器
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
tasks map[string]*Task
|
||||
seq int
|
||||
}
|
||||
|
||||
func NewManager() *Manager {
|
||||
return &Manager{tasks: make(map[string]*Task)}
|
||||
}
|
||||
|
||||
// Create 创建任务并返回 ID
|
||||
func (m *Manager) Create(action TaskAction, tunnelIDs []uint, names map[uint]string) string {
|
||||
m.mu.Lock()
|
||||
m.seq++
|
||||
id := fmt.Sprintf("task_%d_%d", time.Now().UnixMilli(), m.seq)
|
||||
subTasks := make([]SubTask, len(tunnelIDs))
|
||||
for i, tid := range tunnelIDs {
|
||||
subTasks[i] = SubTask{
|
||||
TunnelID: tid,
|
||||
TunnelName: names[tid],
|
||||
Status: StatusPending,
|
||||
}
|
||||
}
|
||||
t := &Task{
|
||||
ID: id,
|
||||
Action: action,
|
||||
Status: StatusPending,
|
||||
Total: len(tunnelIDs),
|
||||
SubTasks: subTasks,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
m.tasks[id] = t
|
||||
m.mu.Unlock()
|
||||
|
||||
// 自动清理 30 分钟前的任务
|
||||
go m.cleanup()
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
// Get 获取任务快照
|
||||
func (m *Manager) Get(id string) (*Task, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
t, ok := m.tasks[id]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
// 返回副本
|
||||
cp := *t
|
||||
cp.SubTasks = make([]SubTask, len(t.SubTasks))
|
||||
copy(cp.SubTasks, t.SubTasks)
|
||||
return &cp, true
|
||||
}
|
||||
|
||||
// List 列出最近的任务(最多 50 个)
|
||||
func (m *Manager) List() []*Task {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
out := make([]*Task, 0, len(m.tasks))
|
||||
for _, t := range m.tasks {
|
||||
cp := *t
|
||||
cp.SubTasks = make([]SubTask, len(t.SubTasks))
|
||||
copy(cp.SubTasks, t.SubTasks)
|
||||
out = append(out, &cp)
|
||||
}
|
||||
// 按创建时间倒序
|
||||
for i := 0; i < len(out)-1; i++ {
|
||||
for j := i + 1; j < len(out); j++ {
|
||||
if out[j].CreatedAt.After(out[i].CreatedAt) {
|
||||
out[i], out[j] = out[j], out[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) > 50 {
|
||||
out = out[:50]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MarkRunning 标记子任务运行中
|
||||
func (m *Manager) MarkRunning(taskID string, tunnelID uint) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
t, ok := m.tasks[taskID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
t.Status = StatusRunning
|
||||
now := time.Now()
|
||||
t.UpdatedAt = now
|
||||
for i := range t.SubTasks {
|
||||
if t.SubTasks[i].TunnelID == tunnelID {
|
||||
t.SubTasks[i].Status = StatusRunning
|
||||
t.SubTasks[i].StartedAt = &now
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MarkDone 标记子任务成功
|
||||
func (m *Manager) MarkDone(taskID string, tunnelID uint) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
t, ok := m.tasks[taskID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
t.UpdatedAt = now
|
||||
for i := range t.SubTasks {
|
||||
if t.SubTasks[i].TunnelID == tunnelID {
|
||||
t.SubTasks[i].Status = StatusDone
|
||||
t.SubTasks[i].FinishedAt = &now
|
||||
t.Success++
|
||||
break
|
||||
}
|
||||
}
|
||||
t.maybeDone()
|
||||
}
|
||||
|
||||
// MarkFailed 标记子任务失败
|
||||
func (m *Manager) MarkFailed(taskID string, tunnelID uint, err error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
t, ok := m.tasks[taskID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
t.UpdatedAt = now
|
||||
for i := range t.SubTasks {
|
||||
if t.SubTasks[i].TunnelID == tunnelID {
|
||||
t.SubTasks[i].Status = StatusFailed
|
||||
t.SubTasks[i].Error = err.Error()
|
||||
t.SubTasks[i].FinishedAt = &now
|
||||
t.Failed++
|
||||
break
|
||||
}
|
||||
}
|
||||
t.maybeDone()
|
||||
}
|
||||
|
||||
// maybeDone 检查任务是否全部完成(调用者需持锁)
|
||||
func (t *Task) maybeDone() {
|
||||
if t.Success+t.Failed >= t.Total {
|
||||
if t.Failed > 0 {
|
||||
t.Status = StatusDone // 部分失败也算完成
|
||||
} else {
|
||||
t.Status = StatusDone
|
||||
}
|
||||
now := time.Now()
|
||||
t.UpdatedAt = now
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) cleanup() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
cutoff := time.Now().Add(-30 * time.Minute)
|
||||
for id, t := range m.tasks {
|
||||
if t.Status == StatusDone || t.Status == StatusFailed || t.Status == StatusCanceled {
|
||||
if t.UpdatedAt.Before(cutoff) {
|
||||
delete(m.tasks, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@ package trafficmgr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -106,25 +108,66 @@ func HandleExceed(tunnelID uint, action string, tunnelName string) {
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyRateLimit 使用 tc 设置隧道接口限速(需 root + tc 命令)
|
||||
// tunnelIface returns the network interface name for a tunnel, derived from
|
||||
// its protocol and ID. Returns "" if the protocol does not use a tc-able interface.
|
||||
func tunnelIface(tunnelID uint) string {
|
||||
var t model.Tunnel
|
||||
if store.DB == nil || store.DB.First(&t, tunnelID).Error != nil {
|
||||
return ""
|
||||
}
|
||||
switch t.Protocol {
|
||||
case "wireguard":
|
||||
return "wg" + strconv.FormatUint(uint64(t.ID), 36)
|
||||
case "openvpn":
|
||||
return "tun0"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyRateLimit 使用 tc htb 对隧道网络接口限速(需 root + tc 命令)
|
||||
func ApplyRateLimit(tunnelID uint, mbps int) {
|
||||
if mbps <= 0 {
|
||||
RemoveRateLimit(tunnelID)
|
||||
return
|
||||
}
|
||||
// tc 规则基于网络接口名,这里仅做记录;实际接口名需从隧道配置中获取
|
||||
// 完整实现需在 tunnel start 时获取 iface name 并传入
|
||||
if _, err := exec.LookPath("tc"); err != nil {
|
||||
tc, err := exec.LookPath("tc")
|
||||
if err != nil {
|
||||
return // tc 不存在则跳过
|
||||
}
|
||||
// 限速规则模板(需要接口名,此处占位)
|
||||
iface := tunnelIface(tunnelID)
|
||||
if iface == "" {
|
||||
return // 协议不支持 tc 限速
|
||||
}
|
||||
// 先清除旧规则再重建
|
||||
RemoveRateLimit(tunnelID)
|
||||
// tc qdisc add dev <iface> root htb default 10
|
||||
if out, err := exec.Command(tc, "qdisc", "add", "dev", iface, "root", "htb", "default", "10").CombinedOutput(); err != nil {
|
||||
log.Printf("[trafficmgr] tc qdisc add 失败 (iface=%s): %v: %s", iface, err, string(out))
|
||||
return
|
||||
}
|
||||
// tc class add dev <iface> parent root classid 1:10 htb rate <mbps>mbit
|
||||
if out, err := exec.Command(tc, "class", "add", "dev", iface, "parent", "root", "classid", "1:10", "htb", "rate", fmt.Sprintf("%dmbit", mbps)).CombinedOutput(); err != nil {
|
||||
log.Printf("[trafficmgr] tc class add 失败 (iface=%s): %v: %s", iface, err, string(out))
|
||||
return
|
||||
}
|
||||
log.Printf("[trafficmgr] 已对 %s 限速至 %d Mbps", iface, mbps)
|
||||
}
|
||||
|
||||
// RemoveRateLimit 清除限速规则
|
||||
// RemoveRateLimit 清除隧道接口上的 tc 限速规则
|
||||
func RemoveRateLimit(tunnelID uint) {
|
||||
if _, err := exec.LookPath("tc"); err != nil {
|
||||
tc, err := exec.LookPath("tc")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
iface := tunnelIface(tunnelID)
|
||||
if iface == "" {
|
||||
return
|
||||
}
|
||||
if out, err := exec.Command(tc, "qdisc", "del", "dev", iface, "root").CombinedOutput(); err != nil {
|
||||
// qdisc del 在无规则时返回非零,不算真正错误
|
||||
_ = out
|
||||
}
|
||||
}
|
||||
|
||||
// GetPolicy 获取隧道的流量策略(不存在则返回默认空策略)
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" :title="$t('tunnels.taskProgress')" width="560px" align-center @close="onClose">
|
||||
<div v-if="!task" style="text-align:center;padding:20px;color:var(--text-secondary)">
|
||||
{{ $t('tunnels.noTasks') }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<div style="margin-bottom:16px">
|
||||
<el-tag :type="statusTag" size="small" style="margin-right:8px">{{ statusLabel }}</el-tag>
|
||||
<span style="font-size:13px;color:var(--text-secondary)">
|
||||
{{ task.action === 'start' ? $t('tunnels.batchStart') : task.action === 'stop' ? $t('tunnels.batchStop') : $t('tunnels.batchDelete') }}
|
||||
· {{ task.total }} {{ $t('tunnels.stateTunnel') || 'tunnels' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<el-progress
|
||||
:percentage="progress"
|
||||
:status="task.status === 'done' ? (task.failed > 0 ? 'warning' : 'success') : undefined"
|
||||
:stroke-width="12"
|
||||
style="margin-bottom:16px"
|
||||
/>
|
||||
|
||||
<div style="display:flex;gap:16px;margin-bottom:12px;font-size:13px">
|
||||
<span style="color:#67c23a">✓ {{ task.success }}</span>
|
||||
<span style="color:#f56c6c">✗ {{ task.failed }}</span>
|
||||
<span style="color:#909399">⏳ {{ task.total - task.success - task.failed }}</span>
|
||||
</div>
|
||||
|
||||
<el-table :data="task.sub_tasks" size="small" max-height="260">
|
||||
<el-table-column prop="tunnel_name" :label="$t('common.name')" min-width="120" />
|
||||
<el-table-column :label="$t('common.status')" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="subTag(row.status)" size="small">{{ subLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="error" :label="$t('common.error')" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.error" style="color:#f56c6c;font-size:12px">{{ row.error }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">{{ $t('common.close') }}</el-button>
|
||||
<el-button v-if="task?.status !== 'done'" type="primary" @click="refresh">
|
||||
<el-icon v-if="polling" class="is-loading"><Loading /></el-icon>
|
||||
{{ $t('tunnels.refresh') || '刷新' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
import api from '../api'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const visible = ref(false)
|
||||
const task = ref(null)
|
||||
const taskId = ref('')
|
||||
const polling = ref(false)
|
||||
let pollTimer = null
|
||||
|
||||
const progress = computed(() => {
|
||||
if (!task.value || task.value.total === 0) return 0
|
||||
return Math.round((task.value.success + task.value.failed) * 100 / task.value.total)
|
||||
})
|
||||
|
||||
const statusTag = computed(() => {
|
||||
if (!task.value) return 'info'
|
||||
if (task.value.status === 'done') return task.value.failed > 0 ? 'warning' : 'success'
|
||||
return 'primary'
|
||||
})
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
if (!task.value) return ''
|
||||
if (task.value.status === 'pending') return t('tunnels.taskPending')
|
||||
if (task.value.status === 'running') return t('tunnels.taskInProgress', { done: task.value.success + task.value.failed, total: task.value.total })
|
||||
if (task.value.status === 'done') return task.value.failed > 0 ? t('tunnels.taskPartialFail') : t('tunnels.taskComplete')
|
||||
return task.value.status
|
||||
})
|
||||
|
||||
function subTag(s) {
|
||||
return { pending: 'info', running: 'primary', done: 'success', failed: 'danger' }[s] || 'info'
|
||||
}
|
||||
function subLabel(s) {
|
||||
return { pending: t('tunnels.taskPending'), running: t('tunnels.taskRunning'), done: t('tunnels.taskDone'), failed: t('tunnels.taskFailed') }[s] || s
|
||||
}
|
||||
|
||||
async function open(id) {
|
||||
taskId.value = id
|
||||
visible.value = true
|
||||
await refresh()
|
||||
startPolling()
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const { data } = await api.get(`/tasks/${taskId.value}`)
|
||||
task.value = data
|
||||
if (data.status === 'done') stopPolling()
|
||||
} catch { /* task may not exist yet */ }
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling()
|
||||
polling.value = true
|
||||
pollTimer = setInterval(refresh, 1000)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
polling.value = false
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
stopPolling()
|
||||
task.value = null
|
||||
taskId.value = ''
|
||||
}
|
||||
|
||||
onUnmounted(stopPolling)
|
||||
|
||||
defineExpose({ open })
|
||||
</script>
|
||||
@@ -23,7 +23,7 @@
|
||||
"name": "Name",
|
||||
"description": "Description",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"password": "***",
|
||||
"email": "Email",
|
||||
"source": "Source",
|
||||
"local": "Local",
|
||||
@@ -51,7 +51,8 @@
|
||||
"noData": "No data",
|
||||
"never": "Never",
|
||||
"copiedToClipboard": "Copied to clipboard",
|
||||
"logout": "Logout"
|
||||
"logout": "Logout",
|
||||
"batchOperation": "Batch Operation"
|
||||
},
|
||||
"login": {
|
||||
"title": "VPN Management Platform",
|
||||
@@ -102,7 +103,8 @@
|
||||
"recommendedCmd": "Recommended Command",
|
||||
"otherDistros": "Other Distros",
|
||||
"copyRecommended": "Copy Recommended",
|
||||
"getInstallCmdFailed": "Failed to get install command"
|
||||
"getInstallCmdFailed": "Failed to get install command",
|
||||
"aptDefault": "apt default"
|
||||
},
|
||||
"tunnels": {
|
||||
"searchPlaceholder": "Search by name",
|
||||
@@ -184,7 +186,28 @@
|
||||
"qrFailed": "Failed to generate QR code",
|
||||
"noClientConfigWarn": "No client config available",
|
||||
"deleteTunnelConfirm": "Delete tunnel \"{name}\"? Configuration and keys will be permanently removed.",
|
||||
"peerAlreadyAdded": "Peer already added"
|
||||
"peerAlreadyAdded": "Peer already added",
|
||||
"stopFailed": "Stop failed",
|
||||
"selected": "{count} selected",
|
||||
"batchStart": "Batch Start",
|
||||
"batchStop": "Batch Stop",
|
||||
"batchDelete": "Batch Delete",
|
||||
"batchStartConfirm": "Confirm batch start {count} tunnels?",
|
||||
"batchStopConfirm": "Confirm batch stop {count} tunnels?",
|
||||
"batchDeleteConfirm": "Confirm batch delete {count} tunnels? This cannot be undone!",
|
||||
"batchStarted": "Batch operation submitted, executing in background",
|
||||
"taskProgress": "Task Progress",
|
||||
"taskPending": "Pending",
|
||||
"taskRunning": "Running",
|
||||
"taskDone": "Done",
|
||||
"taskFailed": "Failed",
|
||||
"taskSuccess": "Success {count}/{total}",
|
||||
"taskFailedCount": "Failed {count}",
|
||||
"taskInProgress": "In progress ({done}/{total})",
|
||||
"taskComplete": "All done",
|
||||
"taskPartialFail": "Partial failure",
|
||||
"viewTasks": "View Tasks",
|
||||
"noTasks": "No task history"
|
||||
},
|
||||
"tunnelEditor": {
|
||||
"title": "Edit Tunnel",
|
||||
@@ -295,7 +318,9 @@
|
||||
"ldapLoadFailed": "LDAP load failed",
|
||||
"ldapConfigSaved": "LDAP configuration saved",
|
||||
"ldapConnected": "LDAP connection successful",
|
||||
"ldapConnectFailed": "Connection failed"
|
||||
"ldapConnectFailed": "Connection failed",
|
||||
"emptyUsername": "(empty)",
|
||||
"errorDetails": "Error details:"
|
||||
},
|
||||
"vpnUsers": {
|
||||
"title": "VPN Dial-in Users",
|
||||
@@ -333,6 +358,7 @@
|
||||
"lockedUntil": "Yes (until {time})",
|
||||
"lastOtpSuccess": "Last OTP Success",
|
||||
"compatible": "Compatible",
|
||||
"totpCompatible": "TOTP standard (compatible with Google/Microsoft Authenticator)",
|
||||
"bindAuthenticator": "Bind Authenticator",
|
||||
"disableMfa": "Disable MFA",
|
||||
"regenBackupCodes": "Regenerate Backup Codes",
|
||||
@@ -580,5 +606,395 @@
|
||||
},
|
||||
"protocolIcon": {
|
||||
"custom": "Custom Protocol"
|
||||
},
|
||||
"protocols": {
|
||||
"wireguard": {
|
||||
"name": "WireGuard",
|
||||
"desc": "High-performance modern VPN with kernel-level implementation, roaming-friendly",
|
||||
"scenes": "Internal Access / Mobile Office",
|
||||
"fields": {
|
||||
"address": {
|
||||
"label": "Server VPN Subnet Address",
|
||||
"tip": "Server's address within the VPN subnet (CIDR)"
|
||||
},
|
||||
"listen_port": {
|
||||
"label": "Listen Port"
|
||||
},
|
||||
"client_address": {
|
||||
"label": "First Client Address",
|
||||
"tip": "Add more peers in the detail page after creation"
|
||||
},
|
||||
"public_endpoint": {
|
||||
"label": "Public Endpoint",
|
||||
"tip": "For client connections — public IP:port"
|
||||
},
|
||||
"dns": {
|
||||
"label": "DNS"
|
||||
},
|
||||
"allowed_ips": {
|
||||
"label": "Client AllowedIPs",
|
||||
"tip": "Use 0.0.0.0/0 for full tunnel; specify internal CIDRs for split tunnel"
|
||||
},
|
||||
"keepalive": {
|
||||
"label": "Keepalive (sec)",
|
||||
"tip": "Recommended 15–25 for clients behind NAT"
|
||||
},
|
||||
"mtu": {
|
||||
"label": "MTU",
|
||||
"tip": "0 for default; common range 1280–1420 over public networks"
|
||||
},
|
||||
"enable_nat": {
|
||||
"label": "Enable NAT Outbound",
|
||||
"tip": "Enable when clients need internet or other subnet access"
|
||||
},
|
||||
"egress_interface": {
|
||||
"label": "NAT Egress Interface",
|
||||
"tip": "Leave empty to auto-detect the default route interface"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ipsec": {
|
||||
"name": "IPSec Site-to-Site",
|
||||
"desc": "Site-to-site connectivity with third-party devices (Cisco/Huawei/FortiGate, etc.)",
|
||||
"scenes": "Third-Party Integration / Site-to-Site",
|
||||
"fields": {
|
||||
"vendor_template": {
|
||||
"label": "Vendor Template",
|
||||
"tip": "Auto-fills Phase 1/Phase 2 proposals, still manually editable",
|
||||
"options": {
|
||||
"generic": "Generic Standard (Recommended)",
|
||||
"cisco": "Cisco ISR/ASR",
|
||||
"fortigate": "FortiGate",
|
||||
"huawei": "Huawei USG/AR",
|
||||
"h3c": "H3C"
|
||||
}
|
||||
},
|
||||
"public_endpoint": {
|
||||
"label": "Local Public IP"
|
||||
},
|
||||
"remote_gateway": {
|
||||
"label": "Remote Gateway"
|
||||
},
|
||||
"local_subnets": {
|
||||
"label": "Local Subnets",
|
||||
"tip": "Separate multiple subnets with commas"
|
||||
},
|
||||
"remote_subnets": {
|
||||
"label": "Remote Subnets"
|
||||
},
|
||||
"psk": {
|
||||
"label": "Pre-Shared Key (PSK)"
|
||||
},
|
||||
"ike_version": {
|
||||
"label": "IKE Version",
|
||||
"options": {
|
||||
"ikev2": "IKEv2",
|
||||
"ikev1": "IKEv1"
|
||||
}
|
||||
},
|
||||
"ike_proposal": {
|
||||
"label": "Phase 1 Proposal",
|
||||
"tip": "Encryption-Auth-DH group, must match remote side"
|
||||
},
|
||||
"esp_proposal": {
|
||||
"label": "Phase 2 Proposal"
|
||||
},
|
||||
"pfs_group": {
|
||||
"label": "PFS DH Group"
|
||||
},
|
||||
"ike_lifetime": {
|
||||
"label": "IKE Lifetime (sec)"
|
||||
},
|
||||
"local_id": {
|
||||
"label": "Local ID"
|
||||
},
|
||||
"remote_id": {
|
||||
"label": "Remote ID"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ikev2": {
|
||||
"name": "IKEv2 Remote Access",
|
||||
"desc": "Native VPN dial-in from phones and laptops, more modern than L2TP",
|
||||
"scenes": "Remote Office / Native VPN",
|
||||
"fields": {
|
||||
"public_endpoint": {
|
||||
"label": "Server Public Address"
|
||||
},
|
||||
"local_id": {
|
||||
"label": "Server ID / Remote ID",
|
||||
"tip": "Must match the client's Remote ID"
|
||||
},
|
||||
"psk": {
|
||||
"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"
|
||||
},
|
||||
"dns": {
|
||||
"label": "Push DNS"
|
||||
},
|
||||
"local_ts": {
|
||||
"label": "Allowed Subnets",
|
||||
"tip": "0.0.0.0/0 for full tunnel; change to internal CIDR for split tunnel"
|
||||
},
|
||||
"ike_proposal": {
|
||||
"label": "IKE Proposal"
|
||||
},
|
||||
"esp_proposal": {
|
||||
"label": "ESP Proposal"
|
||||
}
|
||||
}
|
||||
},
|
||||
"l2tp": {
|
||||
"name": "L2TP/IPSec",
|
||||
"desc": "Best compatibility — natively supported by phones, legacy devices, and routers",
|
||||
"scenes": "Mobile Devices / Legacy Compatibility",
|
||||
"fields": {
|
||||
"public_endpoint": {
|
||||
"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)"
|
||||
},
|
||||
"ip_range": {
|
||||
"label": "Client Address Pool"
|
||||
},
|
||||
"local_ip": {
|
||||
"label": "Server Tunnel Address"
|
||||
},
|
||||
"dns": {
|
||||
"label": "Push DNS"
|
||||
}
|
||||
}
|
||||
},
|
||||
"frp": {
|
||||
"name": "FRP NAT Traversal",
|
||||
"desc": "TCP/UDP/HTTP port forwarding and reverse proxy for NAT traversal",
|
||||
"scenes": "NAT Traversal / Port Forwarding",
|
||||
"fields": {
|
||||
"mode": {
|
||||
"label": "Mode",
|
||||
"tip": "server = frps (public side), client = frpc (LAN side)",
|
||||
"options": {
|
||||
"server": "server (frps — public side)",
|
||||
"client": "client (frpc — LAN side)"
|
||||
}
|
||||
},
|
||||
"bind_port": {
|
||||
"label": "Bind Port (server)"
|
||||
},
|
||||
"public_endpoint": {
|
||||
"label": "Public Address (server)"
|
||||
},
|
||||
"dashboard_port": {
|
||||
"label": "Dashboard Port (server)"
|
||||
},
|
||||
"dashboard_user": {
|
||||
"label": "Dashboard User (server)"
|
||||
},
|
||||
"dashboard_password": {
|
||||
"label": "Dashboard Password (server)",
|
||||
"tip": "Auto-generates a strong password if left empty; keeps current password when editing"
|
||||
},
|
||||
"vhost_http_port": {
|
||||
"label": "HTTP VHost Port",
|
||||
"tip": "0 = disabled; must be >0 for HTTP proxying"
|
||||
},
|
||||
"vhost_https_port": {
|
||||
"label": "HTTPS VHost Port",
|
||||
"tip": "0 = disabled"
|
||||
},
|
||||
"allow_ports": {
|
||||
"label": "Allowed Port Range",
|
||||
"tip": "Restricts which remotePorts frpc can request"
|
||||
},
|
||||
"server_addr": {
|
||||
"label": "frps Address (client)"
|
||||
},
|
||||
"server_port": {
|
||||
"label": "frps Port (client)"
|
||||
},
|
||||
"token": {
|
||||
"label": "Auth Token",
|
||||
"tip": "Leave empty to auto-generate"
|
||||
},
|
||||
"proxy_name": {
|
||||
"label": "Proxy Name"
|
||||
},
|
||||
"proxy_type": {
|
||||
"label": "Proxy Type",
|
||||
"options": {
|
||||
"tcp": "TCP",
|
||||
"udp": "UDP",
|
||||
"http": "HTTP",
|
||||
"https": "HTTPS",
|
||||
"stcp": "STCP (Secure P2P)",
|
||||
"xtcp": "XTCP (P2P)"
|
||||
}
|
||||
},
|
||||
"local_ip": {
|
||||
"label": "Local Target IP"
|
||||
},
|
||||
"local_port": {
|
||||
"label": "Local Target Port"
|
||||
},
|
||||
"remote_port": {
|
||||
"label": "Public Mapped Port"
|
||||
},
|
||||
"custom_domains": {
|
||||
"label": "Custom Domains",
|
||||
"tip": "Required for HTTP/HTTPS proxy"
|
||||
},
|
||||
"secret_key": {
|
||||
"label": "STCP/XTCP Secret Key",
|
||||
"tip": "Must match between visitor and server"
|
||||
}
|
||||
}
|
||||
},
|
||||
"openvpn": {
|
||||
"name": "OpenVPN",
|
||||
"desc": "Most mature open-source VPN, compatible with legacy systems and projects",
|
||||
"scenes": "Legacy Compatibility / General Access",
|
||||
"fields": {
|
||||
"public_endpoint": {
|
||||
"label": "Public Endpoint"
|
||||
},
|
||||
"port": {
|
||||
"label": "Port"
|
||||
},
|
||||
"proto": {
|
||||
"label": "Transport Protocol",
|
||||
"tip": "Can be switched in edit mode; requires tunnel restart and client re-download",
|
||||
"options": {
|
||||
"udp": "UDP (Recommended, better performance)",
|
||||
"tcp-server": "TCP (More stable through restrictive networks)"
|
||||
}
|
||||
},
|
||||
"subnet": {
|
||||
"label": "VPN Subnet",
|
||||
"tip": "e.g., 10.8.0.0 255.255.240.0 for more clients (/20)"
|
||||
},
|
||||
"cipher": {
|
||||
"label": "Cipher",
|
||||
"options": {
|
||||
"AES-256-GCM": "AES-256-GCM",
|
||||
"AES-128-GCM": "AES-128-GCM",
|
||||
"AES-256-CBC": "AES-256-CBC"
|
||||
}
|
||||
},
|
||||
"auth_mode": {
|
||||
"label": "Authentication Mode",
|
||||
"tip": "Password mode aligns with verify-client-cert none + username-as-common-name",
|
||||
"options": {
|
||||
"certificate": "Certificate Only",
|
||||
"password": "***",
|
||||
"certificate+password": "Certificate + Password"
|
||||
}
|
||||
},
|
||||
"mfa_mode": {
|
||||
"label": "Dial-in MFA (2FA)",
|
||||
"tip": "static-challenge + TOTP; manage OTP in VPN Dial-in Users",
|
||||
"options": {
|
||||
"off": "Disabled",
|
||||
"optional": "Optional (verified after user binds)",
|
||||
"required": "Required (all dial-in users must use MFA)"
|
||||
}
|
||||
},
|
||||
"otp_slop": {
|
||||
"label": "OTP Clock Tolerance (sec)"
|
||||
},
|
||||
"otp_max_failures": {
|
||||
"label": "OTP Lockout Threshold"
|
||||
},
|
||||
"otp_lockout_sec": {
|
||||
"label": "OTP Lockout Duration (sec)"
|
||||
},
|
||||
"otp_disallow_reuse": {
|
||||
"label": "Disallow OTP Reuse"
|
||||
},
|
||||
"dns": {
|
||||
"label": "Push DNS"
|
||||
},
|
||||
"redirect_gateway": {
|
||||
"label": "Full Tunnel",
|
||||
"tip": "When off, only pushes internal routes below (common in production)"
|
||||
},
|
||||
"push_routes": {
|
||||
"label": "Push Internal Routes",
|
||||
"tip": "CIDR, comma-separated"
|
||||
},
|
||||
"enable_nat": {
|
||||
"label": "Enable NAT Outbound"
|
||||
},
|
||||
"max_clients": {
|
||||
"label": "Max Clients",
|
||||
"tip": "0 = unlimited; 1000 is common in production"
|
||||
},
|
||||
"keepalive_interval": {
|
||||
"label": "Keepalive Interval",
|
||||
"tip": "Production typical: keepalive 5 30"
|
||||
},
|
||||
"keepalive_timeout": {
|
||||
"label": "Keepalive Timeout"
|
||||
},
|
||||
"tun_mtu": {
|
||||
"label": "tun-mtu",
|
||||
"tip": "Production typical: 1500"
|
||||
},
|
||||
"tun_mtu_extra": {
|
||||
"label": "tun-mtu-extra"
|
||||
},
|
||||
"mssfix": {
|
||||
"label": "mssfix",
|
||||
"tip": "0 = unset; TCP production typical 1450; defaults to 1450 when TCP is selected"
|
||||
},
|
||||
"duplicate_cn": {
|
||||
"label": "Allow duplicate-cn",
|
||||
"tip": "Same username/cert on multiple devices simultaneously; auto-enabled in password mode"
|
||||
},
|
||||
"reneg_sec": {
|
||||
"label": "reneg-sec",
|
||||
"tip": "0 = disable TLS re-negotiation (recommended for production/MFA); cert-only default can be 3600"
|
||||
},
|
||||
"run_as_user": {
|
||||
"label": "Drop Privileges User",
|
||||
"tip": "Maps to 'user openvpn'; user must exist on the system; leave empty to run as root"
|
||||
},
|
||||
"run_as_group": {
|
||||
"label": "Drop Privileges Group",
|
||||
"tip": "Maps to 'group openvpn'"
|
||||
}
|
||||
}
|
||||
},
|
||||
"custom": {
|
||||
"fields": {
|
||||
"endpoint": {
|
||||
"label": "Endpoint"
|
||||
},
|
||||
"token": {
|
||||
"label": "Auth Credential"
|
||||
},
|
||||
"extra": {
|
||||
"label": "Extra Parameters (JSON)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@
|
||||
"name": "名称",
|
||||
"description": "说明",
|
||||
"username": "用户名",
|
||||
"password": "密码",
|
||||
"password": "***",
|
||||
"email": "邮箱",
|
||||
"source": "来源",
|
||||
"local": "本地",
|
||||
@@ -51,7 +51,8 @@
|
||||
"noData": "暂无数据",
|
||||
"never": "不",
|
||||
"copiedToClipboard": "已复制到剪贴板",
|
||||
"logout": "退出登录"
|
||||
"logout": "退出登录",
|
||||
"batchOperation": "批量操作"
|
||||
},
|
||||
"login": {
|
||||
"title": "VPN 专线管理平台",
|
||||
@@ -102,7 +103,8 @@
|
||||
"recommendedCmd": "推荐命令",
|
||||
"otherDistros": "其他发行版",
|
||||
"copyRecommended": "复制推荐命令",
|
||||
"getInstallCmdFailed": "获取安装命令失败"
|
||||
"getInstallCmdFailed": "获取安装命令失败",
|
||||
"aptDefault": "apt 默认"
|
||||
},
|
||||
"tunnels": {
|
||||
"searchPlaceholder": "搜索名称",
|
||||
@@ -184,7 +186,28 @@
|
||||
"qrFailed": "生成二维码失败",
|
||||
"noClientConfigWarn": "无客户端配置",
|
||||
"deleteTunnelConfirm": "确定删除专线「{name}」?配置与密钥将一并清除。",
|
||||
"peerAlreadyAdded": "Peer 已添加"
|
||||
"peerAlreadyAdded": "Peer 已添加",
|
||||
"stopFailed": "停止失败",
|
||||
"selected": "已选择 {count} 项",
|
||||
"batchStart": "批量启动",
|
||||
"batchStop": "批量停止",
|
||||
"batchDelete": "批量删除",
|
||||
"batchStartConfirm": "确认批量启动 {count} 条隧道?",
|
||||
"batchStopConfirm": "确认批量停止 {count} 条隧道?",
|
||||
"batchDeleteConfirm": "确认批量删除 {count} 条隧道?此操作不可撤销!",
|
||||
"batchStarted": "批量操作已提交,正在后台执行",
|
||||
"taskProgress": "任务进度",
|
||||
"taskPending": "等待中",
|
||||
"taskRunning": "执行中",
|
||||
"taskDone": "已完成",
|
||||
"taskFailed": "失败",
|
||||
"taskSuccess": "成功 {count}/{total}",
|
||||
"taskFailedCount": "失败 {count}",
|
||||
"taskInProgress": "进行中 ({done}/{total})",
|
||||
"taskComplete": "全部完成",
|
||||
"taskPartialFail": "部分失败",
|
||||
"viewTasks": "查看任务",
|
||||
"noTasks": "暂无任务记录"
|
||||
},
|
||||
"tunnelEditor": {
|
||||
"title": "编辑专线",
|
||||
@@ -295,7 +318,9 @@
|
||||
"ldapLoadFailed": "LDAP 加载失败",
|
||||
"ldapConfigSaved": "LDAP 配置已保存",
|
||||
"ldapConnected": "LDAP 连接成功",
|
||||
"ldapConnectFailed": "连接失败"
|
||||
"ldapConnectFailed": "连接失败",
|
||||
"emptyUsername": "(空)",
|
||||
"errorDetails": "失败详情:"
|
||||
},
|
||||
"vpnUsers": {
|
||||
"title": "VPN 拨号用户",
|
||||
@@ -333,6 +358,7 @@
|
||||
"lockedUntil": "是(至 {time})",
|
||||
"lastOtpSuccess": "最近 OTP 成功",
|
||||
"compatible": "兼容",
|
||||
"totpCompatible": "TOTP 标准(兼容 Google/Microsoft Authenticator)",
|
||||
"bindAuthenticator": "绑定 Authenticator",
|
||||
"disableMfa": "关闭 MFA",
|
||||
"regenBackupCodes": "重新生成备用码",
|
||||
@@ -580,5 +606,395 @@
|
||||
},
|
||||
"protocolIcon": {
|
||||
"custom": "自定义协议"
|
||||
},
|
||||
"protocols": {
|
||||
"wireguard": {
|
||||
"name": "WireGuard",
|
||||
"desc": "性能最好的现代 VPN,内核级实现,漫游友好",
|
||||
"scenes": "内部接入 / 移动办公",
|
||||
"fields": {
|
||||
"address": {
|
||||
"label": "服务端 VPN 网段地址",
|
||||
"tip": "服务端在 VPN 网段中的地址(CIDR)"
|
||||
},
|
||||
"listen_port": {
|
||||
"label": "监听端口"
|
||||
},
|
||||
"client_address": {
|
||||
"label": "首个客户端地址",
|
||||
"tip": "创建后可在详情中继续添加 Peer"
|
||||
},
|
||||
"public_endpoint": {
|
||||
"label": "公网接入点",
|
||||
"tip": "客户端连接用,公网 IP:端口"
|
||||
},
|
||||
"dns": {
|
||||
"label": "DNS"
|
||||
},
|
||||
"allowed_ips": {
|
||||
"label": "客户端 AllowedIPs",
|
||||
"tip": "全流量代理用 0.0.0.0/0;仅内网填内网网段"
|
||||
},
|
||||
"keepalive": {
|
||||
"label": "Keepalive(秒)",
|
||||
"tip": "NAT 后客户端建议 15–25"
|
||||
},
|
||||
"mtu": {
|
||||
"label": "MTU",
|
||||
"tip": "0 表示默认;跨公网常见 1280–1420"
|
||||
},
|
||||
"enable_nat": {
|
||||
"label": "启用 NAT 出网",
|
||||
"tip": "客户端需访问公网/其它网段时开启"
|
||||
},
|
||||
"egress_interface": {
|
||||
"label": "NAT 出口网卡",
|
||||
"tip": "留空则自动探测默认路由网卡"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ipsec": {
|
||||
"name": "IPSec 站点互联",
|
||||
"desc": "与第三方设备(Cisco/华为/飞塔等)做站点到站点对接",
|
||||
"scenes": "第三方对接 / 站点互联",
|
||||
"fields": {
|
||||
"vendor_template": {
|
||||
"label": "厂商模板",
|
||||
"tip": "自动预填 Phase1/Phase2 提议,仍可手动改",
|
||||
"options": {
|
||||
"generic": "通用标准(推荐)",
|
||||
"cisco": "Cisco ISR/ASR",
|
||||
"fortigate": "FortiGate",
|
||||
"huawei": "华为 USG/AR",
|
||||
"h3c": "H3C"
|
||||
}
|
||||
},
|
||||
"public_endpoint": {
|
||||
"label": "本端公网 IP"
|
||||
},
|
||||
"remote_gateway": {
|
||||
"label": "对端网关地址"
|
||||
},
|
||||
"local_subnets": {
|
||||
"label": "本端网段",
|
||||
"tip": "多个网段用逗号分隔"
|
||||
},
|
||||
"remote_subnets": {
|
||||
"label": "对端网段"
|
||||
},
|
||||
"psk": {
|
||||
"label": "预共享密钥 PSK"
|
||||
},
|
||||
"ike_version": {
|
||||
"label": "IKE 版本",
|
||||
"options": {
|
||||
"ikev2": "IKEv2",
|
||||
"ikev1": "IKEv1"
|
||||
}
|
||||
},
|
||||
"ike_proposal": {
|
||||
"label": "Phase1 提议",
|
||||
"tip": "加密-认证-DH组,需与对端一致"
|
||||
},
|
||||
"esp_proposal": {
|
||||
"label": "Phase2 提议"
|
||||
},
|
||||
"pfs_group": {
|
||||
"label": "PFS DH 组"
|
||||
},
|
||||
"ike_lifetime": {
|
||||
"label": "IKE 生存期(秒)"
|
||||
},
|
||||
"local_id": {
|
||||
"label": "本端标识 ID"
|
||||
},
|
||||
"remote_id": {
|
||||
"label": "对端标识 ID"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ikev2": {
|
||||
"name": "IKEv2 远程接入",
|
||||
"desc": "手机/笔记本原生拨号进内网,比 L2TP 更现代",
|
||||
"scenes": "员工远程办公 / 原生 VPN",
|
||||
"fields": {
|
||||
"public_endpoint": {
|
||||
"label": "服务器公网地址"
|
||||
},
|
||||
"local_id": {
|
||||
"label": "服务器 ID / 远程 ID",
|
||||
"tip": "客户端「远程 ID」与此一致"
|
||||
},
|
||||
"psk": {
|
||||
"label": "机器认证 PSK",
|
||||
"tip": "留空自动生成;iOS/Android 共享密钥"
|
||||
},
|
||||
"username": {
|
||||
"label": "默认拨号用户"
|
||||
},
|
||||
"password": {
|
||||
"label": "默认拨号密码",
|
||||
"tip": "留空自动生成;更多账号在拨号用户页添加"
|
||||
},
|
||||
"address_pool": {
|
||||
"label": "客户端地址池"
|
||||
},
|
||||
"dns": {
|
||||
"label": "下发 DNS"
|
||||
},
|
||||
"local_ts": {
|
||||
"label": "放行网段",
|
||||
"tip": "0.0.0.0/0 为全流量;可改为内网 CIDR"
|
||||
},
|
||||
"ike_proposal": {
|
||||
"label": "IKE 提议"
|
||||
},
|
||||
"esp_proposal": {
|
||||
"label": "ESP 提议"
|
||||
}
|
||||
}
|
||||
},
|
||||
"l2tp": {
|
||||
"name": "L2TP/IPSec",
|
||||
"desc": "兼容性最好,手机/老设备/路由器原生支持",
|
||||
"scenes": "移动设备 / 老设备兼容",
|
||||
"fields": {
|
||||
"public_endpoint": {
|
||||
"label": "服务器公网 IP"
|
||||
},
|
||||
"psk": {
|
||||
"label": "IPSec 预共享密钥"
|
||||
},
|
||||
"username": {
|
||||
"label": "默认拨号用户名",
|
||||
"tip": "可在「VPN 拨号用户」为本隧道添加更多账号"
|
||||
},
|
||||
"password": {
|
||||
"label": "默认拨号密码",
|
||||
"tip": "若只使用拨号用户表可留空(创建时需至少有一种)"
|
||||
},
|
||||
"ip_range": {
|
||||
"label": "客户端地址池"
|
||||
},
|
||||
"local_ip": {
|
||||
"label": "服务端隧道地址"
|
||||
},
|
||||
"dns": {
|
||||
"label": "下发 DNS"
|
||||
}
|
||||
}
|
||||
},
|
||||
"frp": {
|
||||
"name": "FRP 内网穿透",
|
||||
"desc": "国内刚需,TCP/UDP/HTTP 端口映射与反向代理",
|
||||
"scenes": "内网穿透 / 端口映射",
|
||||
"fields": {
|
||||
"mode": {
|
||||
"label": "运行模式",
|
||||
"tip": "server=frps(有公网),client=frpc(内网侧)",
|
||||
"options": {
|
||||
"server": "server (frps 公网侧)",
|
||||
"client": "client (frpc 内网侧)"
|
||||
}
|
||||
},
|
||||
"bind_port": {
|
||||
"label": "绑定端口(server)"
|
||||
},
|
||||
"public_endpoint": {
|
||||
"label": "公网地址(server)"
|
||||
},
|
||||
"dashboard_port": {
|
||||
"label": "管理面板端口(server)"
|
||||
},
|
||||
"dashboard_user": {
|
||||
"label": "管理面板账号(server)"
|
||||
},
|
||||
"dashboard_password": {
|
||||
"label": "管理面板密码(server)",
|
||||
"tip": "留空时自动生成强随机密码;编辑时留空保持原密码"
|
||||
},
|
||||
"vhost_http_port": {
|
||||
"label": "HTTP 虚拟主机端口",
|
||||
"tip": "0 表示不启用;HTTP 代理需 >0"
|
||||
},
|
||||
"vhost_https_port": {
|
||||
"label": "HTTPS 虚拟主机端口",
|
||||
"tip": "0 表示不启用"
|
||||
},
|
||||
"allow_ports": {
|
||||
"label": "允许映射端口范围",
|
||||
"tip": "限制 frpc 可申请的 remotePort"
|
||||
},
|
||||
"server_addr": {
|
||||
"label": "frps 地址(client)"
|
||||
},
|
||||
"server_port": {
|
||||
"label": "frps 端口(client)"
|
||||
},
|
||||
"token": {
|
||||
"label": "认证 Token",
|
||||
"tip": "留空自动生成"
|
||||
},
|
||||
"proxy_name": {
|
||||
"label": "代理名称"
|
||||
},
|
||||
"proxy_type": {
|
||||
"label": "代理类型",
|
||||
"options": {
|
||||
"tcp": "TCP",
|
||||
"udp": "UDP",
|
||||
"http": "HTTP",
|
||||
"https": "HTTPS",
|
||||
"stcp": "STCP(安全点对点)",
|
||||
"xtcp": "XTCP(P2P)"
|
||||
}
|
||||
},
|
||||
"local_ip": {
|
||||
"label": "内网目标 IP"
|
||||
},
|
||||
"local_port": {
|
||||
"label": "内网目标端口"
|
||||
},
|
||||
"remote_port": {
|
||||
"label": "公网映射端口"
|
||||
},
|
||||
"custom_domains": {
|
||||
"label": "绑定域名",
|
||||
"tip": "HTTP/HTTPS 代理必填"
|
||||
},
|
||||
"secret_key": {
|
||||
"label": "STCP/XTCP 密钥",
|
||||
"tip": "访问端与提供端需一致"
|
||||
}
|
||||
}
|
||||
},
|
||||
"openvpn": {
|
||||
"name": "OpenVPN",
|
||||
"desc": "最成熟的开源 VPN,兼容各类老项目与系统",
|
||||
"scenes": "老项目兼容 / 通用接入",
|
||||
"fields": {
|
||||
"public_endpoint": {
|
||||
"label": "公网接入地址"
|
||||
},
|
||||
"port": {
|
||||
"label": "端口"
|
||||
},
|
||||
"proto": {
|
||||
"label": "传输协议",
|
||||
"tip": "可随时在编辑中切换;切换后需重启专线,客户端需重新下载配置",
|
||||
"options": {
|
||||
"udp": "UDP(推荐,性能更好)",
|
||||
"tcp-server": "TCP(穿透限制网络更稳)"
|
||||
}
|
||||
},
|
||||
"subnet": {
|
||||
"label": "VPN 网段",
|
||||
"tip": "如 10.8.0.0 255.255.240.0 可容纳更多客户端(/20)"
|
||||
},
|
||||
"cipher": {
|
||||
"label": "加密算法",
|
||||
"options": {
|
||||
"AES-256-GCM": "AES-256-GCM",
|
||||
"AES-128-GCM": "AES-128-GCM",
|
||||
"AES-256-CBC": "AES-256-CBC"
|
||||
}
|
||||
},
|
||||
"auth_mode": {
|
||||
"label": "认证方式",
|
||||
"tip": "密码模式对齐生产 verify-client-cert none + username-as-common-name",
|
||||
"options": {
|
||||
"certificate": "仅证书",
|
||||
"password": "***",
|
||||
"certificate+password": "证书 + 密码"
|
||||
}
|
||||
},
|
||||
"mfa_mode": {
|
||||
"label": "拨号 MFA (2FA)",
|
||||
"tip": "static-challenge + TOTP;在拨号用户页管理 OTP",
|
||||
"options": {
|
||||
"off": "关闭",
|
||||
"optional": "可选(用户绑定后才校验)",
|
||||
"required": "强制(所有拨号用户必须 MFA)"
|
||||
}
|
||||
},
|
||||
"otp_slop": {
|
||||
"label": "OTP 时钟容差(秒)"
|
||||
},
|
||||
"otp_max_failures": {
|
||||
"label": "OTP 失败锁定次数"
|
||||
},
|
||||
"otp_lockout_sec": {
|
||||
"label": "OTP 锁定时长(秒)"
|
||||
},
|
||||
"otp_disallow_reuse": {
|
||||
"label": "禁止重复使用同码"
|
||||
},
|
||||
"dns": {
|
||||
"label": "下发 DNS"
|
||||
},
|
||||
"redirect_gateway": {
|
||||
"label": "全流量代理",
|
||||
"tip": "关闭后仅推送下方内网路由(生产常只推指定路由)"
|
||||
},
|
||||
"push_routes": {
|
||||
"label": "推送内网路由",
|
||||
"tip": "CIDR 逗号分隔"
|
||||
},
|
||||
"enable_nat": {
|
||||
"label": "启用 NAT 出网"
|
||||
},
|
||||
"max_clients": {
|
||||
"label": "最大客户端数",
|
||||
"tip": "0 不限制;生产常见 1000"
|
||||
},
|
||||
"keepalive_interval": {
|
||||
"label": "Keepalive 间隔",
|
||||
"tip": "生产常用 keepalive 5 30"
|
||||
},
|
||||
"keepalive_timeout": {
|
||||
"label": "Keepalive 超时"
|
||||
},
|
||||
"tun_mtu": {
|
||||
"label": "tun-mtu",
|
||||
"tip": "生产常用 1500"
|
||||
},
|
||||
"tun_mtu_extra": {
|
||||
"label": "tun-mtu-extra"
|
||||
},
|
||||
"mssfix": {
|
||||
"label": "mssfix",
|
||||
"tip": "0=不设置;TCP 生产常用 1450,选 TCP 时默认 1450"
|
||||
},
|
||||
"duplicate_cn": {
|
||||
"label": "允许 duplicate-cn",
|
||||
"tip": "同用户名/证书多终端同时在线;密码模式默认会自动开启"
|
||||
},
|
||||
"reneg_sec": {
|
||||
"label": "reneg-sec",
|
||||
"tip": "0=禁用 TLS 重协商(生产/MFA 推荐);证书-only 默认可 3600"
|
||||
},
|
||||
"run_as_user": {
|
||||
"label": "降权用户",
|
||||
"tip": "对应 user openvpn;需系统已有该用户,留空不降权"
|
||||
},
|
||||
"run_as_group": {
|
||||
"label": "降权组",
|
||||
"tip": "对应 group openvpn"
|
||||
}
|
||||
}
|
||||
},
|
||||
"custom": {
|
||||
"fields": {
|
||||
"endpoint": {
|
||||
"label": "接入地址"
|
||||
},
|
||||
"token": {
|
||||
"label": "认证凭据"
|
||||
},
|
||||
"extra": {
|
||||
"label": "扩展参数(JSON)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+138
-136
@@ -1,184 +1,186 @@
|
||||
// 各协议的创建向导表单定义(前端动态渲染)
|
||||
// label / tip / name / desc / scenes / option labels 存放 i18n key,
|
||||
// 在模板中通过 $t() 翻译;placeholder / default / key 等保持原始值。
|
||||
export const PROTOCOL_META = {
|
||||
wireguard: {
|
||||
name: 'WireGuard',
|
||||
desc: '性能最好的现代 VPN,内核级实现,漫游友好',
|
||||
name: 'protocols.wireguard.name',
|
||||
desc: 'protocols.wireguard.desc',
|
||||
color: '#c0392b',
|
||||
icon: 'wireguard',
|
||||
scenes: '内部接入 / 移动办公',
|
||||
scenes: 'protocols.wireguard.scenes',
|
||||
fields: [
|
||||
{ key: 'address', label: '服务端 VPN 网段地址', placeholder: '10.8.0.1/24', required: true, tip: '服务端在 VPN 网段中的地址(CIDR)' },
|
||||
{ key: 'listen_port', label: '监听端口', type: 'number', default: 51820 },
|
||||
{ key: 'client_address', label: '首个客户端地址', placeholder: '10.8.0.2/32', tip: '创建后可在详情中继续添加 Peer' },
|
||||
{ key: 'public_endpoint', label: '公网接入点', placeholder: 'vpn.example.com:51820', tip: '客户端连接用,公网 IP:端口' },
|
||||
{ key: 'dns', label: 'DNS', default: '223.5.5.5' },
|
||||
{ key: 'allowed_ips', label: '客户端 AllowedIPs', default: '0.0.0.0/0, ::/0', tip: '全流量代理用 0.0.0.0/0;仅内网填内网网段' },
|
||||
{ key: 'keepalive', label: 'Keepalive(秒)', type: 'number', default: 25, tip: 'NAT 后客户端建议 15–25' },
|
||||
{ key: 'mtu', label: 'MTU', type: 'number', default: 0, tip: '0 表示默认;跨公网常见 1280–1420' },
|
||||
{ key: 'enable_nat', label: '启用 NAT 出网', type: 'switch', default: false, tip: '客户端需访问公网/其它网段时开启' },
|
||||
{ key: 'egress_interface', label: 'NAT 出口网卡', placeholder: 'eth0', 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' },
|
||||
{ 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 } }
|
||||
]
|
||||
},
|
||||
ipsec: {
|
||||
name: 'IPSec 站点互联',
|
||||
desc: '与第三方设备(Cisco/华为/飞塔等)做站点到站点对接',
|
||||
name: 'protocols.ipsec.name',
|
||||
desc: 'protocols.ipsec.desc',
|
||||
color: '#1f6fb2',
|
||||
icon: 'ipsec',
|
||||
scenes: '第三方对接 / 站点互联',
|
||||
scenes: 'protocols.ipsec.scenes',
|
||||
fields: [
|
||||
{ key: 'vendor_template', label: '厂商模板', type: 'select', options: [
|
||||
{ value: 'generic', label: '通用标准(推荐)' },
|
||||
{ value: 'cisco', label: 'Cisco ISR/ASR' },
|
||||
{ value: 'fortigate', label: 'FortiGate' },
|
||||
{ value: 'huawei', label: '华为 USG/AR' },
|
||||
{ value: 'h3c', label: 'H3C' }
|
||||
], default: 'generic', tip: '自动预填 Phase1/Phase2 提议,仍可手动改' },
|
||||
{ key: 'public_endpoint', label: '本端公网 IP', placeholder: '1.2.3.4', required: true },
|
||||
{ key: 'remote_gateway', label: '对端网关地址', placeholder: '5.6.7.8', required: true },
|
||||
{ key: 'local_subnets', label: '本端网段', placeholder: '192.168.1.0/24', required: true, tip: '多个网段用逗号分隔' },
|
||||
{ key: 'remote_subnets', label: '对端网段', placeholder: '10.0.0.0/24', required: true },
|
||||
{ key: 'psk', label: '预共享密钥 PSK', type: 'password', required: true, secret: true },
|
||||
{ key: 'ike_version', label: 'IKE 版本', type: 'select', options: [
|
||||
{ value: 'ikev2', label: 'IKEv2' },
|
||||
{ value: 'ikev1', label: 'IKEv1' }
|
||||
{ key: 'vendor_template', label: 'protocols.ipsec.fields.vendor_template.label', type: 'select', options: [
|
||||
{ value: 'generic', label: 'protocols.ipsec.fields.vendor_template.options.generic' },
|
||||
{ value: 'cisco', label: 'protocols.ipsec.fields.vendor_template.options.cisco' },
|
||||
{ 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 },
|
||||
{ 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: 'Phase1 提议', placeholder: 'aes256-sha256-modp2048', tip: '加密-认证-DH组,需与对端一致' },
|
||||
{ key: 'esp_proposal', label: 'Phase2 提议', placeholder: 'aes256-sha256' },
|
||||
{ key: 'pfs_group', label: 'PFS DH 组', default: 'modp2048' },
|
||||
{ key: 'ike_lifetime', label: 'IKE 生存期(秒)', type: 'number', default: 86400 },
|
||||
{ key: 'local_id', label: '本端标识 ID', placeholder: '@vpn-local' },
|
||||
{ key: 'remote_id', label: '对端标识 ID', placeholder: '@vpn-remote' }
|
||||
{ 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' }
|
||||
]
|
||||
},
|
||||
ikev2: {
|
||||
name: 'IKEv2 远程接入',
|
||||
desc: '手机/笔记本原生拨号进内网,比 L2TP 更现代',
|
||||
name: 'protocols.ikev2.name',
|
||||
desc: 'protocols.ikev2.desc',
|
||||
color: '#0d7377',
|
||||
icon: 'ikev2',
|
||||
scenes: '员工远程办公 / 原生 VPN',
|
||||
scenes: 'protocols.ikev2.scenes',
|
||||
fields: [
|
||||
{ key: 'public_endpoint', label: '服务器公网地址', placeholder: 'vpn.example.com', required: true },
|
||||
{ key: 'local_id', label: '服务器 ID / 远程 ID', placeholder: '@opsx-ikev2', default: '@opsx-ikev2', tip: '客户端「远程 ID」与此一致' },
|
||||
{ key: 'psk', label: '机器认证 PSK', type: 'password', secret: true, tip: '留空自动生成;iOS/Android 共享密钥' },
|
||||
{ key: 'username', label: '默认拨号用户', default: 'vpnuser' },
|
||||
{ key: 'password', label: '默认拨号密码', type: 'password', secret: true, tip: '留空自动生成;更多账号在拨号用户页添加' },
|
||||
{ key: 'address_pool', label: '客户端地址池', default: '10.12.0.0/24' },
|
||||
{ key: 'dns', label: '下发 DNS', default: '223.5.5.5' },
|
||||
{ key: 'local_ts', label: '放行网段', default: '0.0.0.0/0', tip: '0.0.0.0/0 为全流量;可改为内网 CIDR' },
|
||||
{ key: 'ike_proposal', label: 'IKE 提议', default: 'aes256-sha256-modp2048' },
|
||||
{ key: 'esp_proposal', label: 'ESP 提议', default: 'aes256-sha256' }
|
||||
{ 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' }
|
||||
]
|
||||
},
|
||||
l2tp: {
|
||||
name: 'L2TP/IPSec',
|
||||
desc: '兼容性最好,手机/老设备/路由器原生支持',
|
||||
name: 'protocols.l2tp.name',
|
||||
desc: 'protocols.l2tp.desc',
|
||||
color: '#5a8a3c',
|
||||
icon: 'l2tp',
|
||||
scenes: '移动设备 / 老设备兼容',
|
||||
scenes: 'protocols.l2tp.scenes',
|
||||
fields: [
|
||||
{ key: 'public_endpoint', label: '服务器公网 IP', placeholder: '1.2.3.4', required: true },
|
||||
{ key: 'psk', label: 'IPSec 预共享密钥', type: 'password', required: true, secret: true },
|
||||
{ key: 'username', label: '默认拨号用户名', default: 'vpnuser', tip: '可在「VPN 拨号用户」为本隧道添加更多账号' },
|
||||
{ key: 'password', label: '默认拨号密码', type: 'password', secret: true, tip: '若只使用拨号用户表可留空(创建时需至少有一种)' },
|
||||
{ key: 'ip_range', label: '客户端地址池', default: '10.9.0.100-10.9.0.200' },
|
||||
{ key: 'local_ip', label: '服务端隧道地址', default: '10.9.0.1' },
|
||||
{ key: 'dns', label: '下发 DNS', default: '223.5.5.5' }
|
||||
{ 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' }
|
||||
]
|
||||
},
|
||||
frp: {
|
||||
name: 'FRP 内网穿透',
|
||||
desc: '国内刚需,TCP/UDP/HTTP 端口映射与反向代理',
|
||||
name: 'protocols.frp.name',
|
||||
desc: 'protocols.frp.desc',
|
||||
color: '#c0762b',
|
||||
icon: 'frp',
|
||||
scenes: '内网穿透 / 端口映射',
|
||||
scenes: 'protocols.frp.scenes',
|
||||
fields: [
|
||||
{ key: 'mode', label: '运行模式', type: 'select', options: [
|
||||
{ value: 'server', label: 'server (frps 公网侧)' },
|
||||
{ value: 'client', label: 'client (frpc 内网侧)' }
|
||||
], default: 'server', tip: 'server=frps(有公网),client=frpc(内网侧)' },
|
||||
{ key: 'bind_port', label: '绑定端口(server)', type: 'number', default: 7000, showIf: { mode: 'server' } },
|
||||
{ key: 'public_endpoint', label: '公网地址(server)', placeholder: '1.2.3.4', showIf: { mode: 'server' } },
|
||||
{ key: 'dashboard_port', label: '管理面板端口(server)', type: 'number', default: 7500, showIf: { mode: 'server' } },
|
||||
{ key: 'dashboard_user', label: '管理面板账号(server)', default: 'admin', showIf: { mode: 'server' } },
|
||||
{ key: 'dashboard_password', label: '管理面板密码(server)', type: 'password', secret: true, showIf: { mode: 'server' }, tip: '留空时自动生成强随机密码;编辑时留空保持原密码' },
|
||||
{ key: 'vhost_http_port', label: 'HTTP 虚拟主机端口', type: 'number', default: 0, showIf: { mode: 'server' }, tip: '0 表示不启用;HTTP 代理需 >0' },
|
||||
{ key: 'vhost_https_port', label: 'HTTPS 虚拟主机端口', type: 'number', default: 0, showIf: { mode: 'server' }, tip: '0 表示不启用' },
|
||||
{ key: 'allow_ports', label: '允许映射端口范围', placeholder: '6000-6100,7001', showIf: { mode: 'server' }, tip: '限制 frpc 可申请的 remotePort' },
|
||||
{ key: 'server_addr', label: 'frps 地址(client)', placeholder: '1.2.3.4', showIf: { mode: 'client' } },
|
||||
{ key: 'server_port', label: 'frps 端口(client)', type: 'number', default: 7000, showIf: { mode: 'client' } },
|
||||
{ key: 'token', label: '认证 Token', type: 'password', secret: true, tip: '留空自动生成' },
|
||||
{ key: 'proxy_name', label: '代理名称', placeholder: 'ssh', default: 'ssh' },
|
||||
{ key: 'proxy_type', label: '代理类型', type: 'select', options: [
|
||||
{ value: 'tcp', label: 'TCP' },
|
||||
{ value: 'udp', label: 'UDP' },
|
||||
{ value: 'http', label: 'HTTP' },
|
||||
{ value: 'https', label: 'HTTPS' },
|
||||
{ value: 'stcp', label: 'STCP(安全点对点)' },
|
||||
{ value: 'xtcp', label: 'XTCP(P2P)' }
|
||||
{ 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' },
|
||||
{ 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' },
|
||||
{ value: 'http', label: 'protocols.frp.fields.proxy_type.options.http' },
|
||||
{ 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: '内网目标 IP', default: '127.0.0.1' },
|
||||
{ key: 'local_port', label: '内网目标端口', type: 'number', default: 22 },
|
||||
{ key: 'remote_port', label: '公网映射端口', type: 'number', default: 6000, showIf: { proxy_type: ['tcp', 'udp'] } },
|
||||
{ key: 'custom_domains', label: '绑定域名', placeholder: 'app.example.com', showIf: { proxy_type: ['http', 'https'] }, tip: 'HTTP/HTTPS 代理必填' },
|
||||
{ key: 'secret_key', label: 'STCP/XTCP 密钥', type: 'password', secret: true, showIf: { proxy_type: ['stcp', 'xtcp'] }, tip: '访问端与提供端需一致' }
|
||||
{ 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' }
|
||||
]
|
||||
},
|
||||
openvpn: {
|
||||
name: 'OpenVPN',
|
||||
desc: '最成熟的开源 VPN,兼容各类老项目与系统',
|
||||
name: 'protocols.openvpn.name',
|
||||
desc: 'protocols.openvpn.desc',
|
||||
color: '#e07b39',
|
||||
icon: 'openvpn',
|
||||
scenes: '老项目兼容 / 通用接入',
|
||||
scenes: 'protocols.openvpn.scenes',
|
||||
fields: [
|
||||
{ key: 'public_endpoint', label: '公网接入地址', placeholder: 'vpn.example.com', required: true },
|
||||
{ key: 'port', label: '端口', type: 'number', default: 1194 },
|
||||
{ key: 'proto', label: '传输协议', type: 'select', options: [
|
||||
{ value: 'udp', label: 'UDP(推荐,性能更好)' },
|
||||
{ value: 'tcp-server', label: 'TCP(穿透限制网络更稳)' }
|
||||
], default: 'udp', tip: '可随时在编辑中切换;切换后需重启专线,客户端需重新下载配置' },
|
||||
{ key: 'subnet', label: 'VPN 网段', default: '10.10.0.0 255.255.255.0', tip: '如 10.8.0.0 255.255.240.0 可容纳更多客户端(/20)' },
|
||||
{ key: 'cipher', label: '加密算法', type: 'select', options: [
|
||||
{ value: 'AES-256-GCM', label: 'AES-256-GCM' },
|
||||
{ value: 'AES-128-GCM', label: 'AES-128-GCM' },
|
||||
{ value: 'AES-256-CBC', label: 'AES-256-CBC' }
|
||||
{ key: 'public_endpoint', label: 'protocols.openvpn.fields.public_endpoint.label', placeholder: 'vpn.example.com', required: true },
|
||||
{ key: 'port', label: 'protocols.openvpn.fields.port.label', type: 'number', default: 1194 },
|
||||
{ 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' },
|
||||
{ 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' },
|
||||
{ value: 'AES-128-GCM', label: 'protocols.openvpn.fields.cipher.options.AES-128-GCM' },
|
||||
{ value: 'AES-256-CBC', label: 'protocols.openvpn.fields.cipher.options.AES-256-CBC' }
|
||||
], default: 'AES-256-GCM' },
|
||||
{ key: 'auth_mode', label: '认证方式', type: 'select', options: [
|
||||
{ value: 'certificate', label: '仅证书' },
|
||||
{ value: 'password', label: '仅用户名密码' },
|
||||
{ value: 'certificate+password', label: '证书 + 密码' }
|
||||
], default: 'certificate', tip: '密码模式对齐生产 verify-client-cert none + username-as-common-name' },
|
||||
{ key: 'mfa_mode', label: '拨号 MFA (2FA)', type: 'select', options: [
|
||||
{ value: 'off', label: '关闭' },
|
||||
{ value: 'optional', label: '可选(用户绑定后才校验)' },
|
||||
{ value: 'required', label: '强制(所有拨号用户必须 MFA)' }
|
||||
], default: 'off', tip: 'static-challenge + TOTP;在拨号用户页管理 OTP', showIf: { auth_mode: ['password', 'certificate+password'] } },
|
||||
{ key: 'otp_slop', label: 'OTP 时钟容差(秒)', type: 'number', default: 90, showIf: { auth_mode: ['password', 'certificate+password'] } },
|
||||
{ key: 'otp_max_failures', label: 'OTP 失败锁定次数', type: 'number', default: 5, showIf: { auth_mode: ['password', 'certificate+password'] } },
|
||||
{ key: 'otp_lockout_sec', label: 'OTP 锁定时长(秒)', type: 'number', default: 300, showIf: { auth_mode: ['password', 'certificate+password'] } },
|
||||
{ key: 'otp_disallow_reuse', label: '禁止重复使用同码', type: 'switch', default: true, showIf: { auth_mode: ['password', 'certificate+password'] } },
|
||||
{ key: 'dns', label: '下发 DNS', default: '223.5.5.5' },
|
||||
{ key: 'redirect_gateway', label: '全流量代理', type: 'switch', default: true, tip: '关闭后仅推送下方内网路由(生产常只推指定路由)' },
|
||||
{ key: 'push_routes', label: '推送内网路由', placeholder: '192.168.0.0/16,10.0.0.0/8', tip: 'CIDR 逗号分隔' },
|
||||
{ key: 'enable_nat', label: '启用 NAT 出网', type: 'switch', default: false },
|
||||
{ key: 'max_clients', label: '最大客户端数', type: 'number', default: 1000, tip: '0 不限制;生产常见 1000' },
|
||||
{ key: 'keepalive_interval', label: 'Keepalive 间隔', type: 'number', default: 5, tip: '生产常用 keepalive 5 30' },
|
||||
{ key: 'keepalive_timeout', label: 'Keepalive 超时', type: 'number', default: 30 },
|
||||
{ key: 'tun_mtu', label: 'tun-mtu', type: 'number', default: 1500, tip: '生产常用 1500' },
|
||||
{ key: 'tun_mtu_extra', label: 'tun-mtu-extra', type: 'number', default: 32 },
|
||||
{ key: 'mssfix', label: 'mssfix', type: 'number', default: 0, tip: '0=不设置;TCP 生产常用 1450,选 TCP 时默认 1450' },
|
||||
{ key: 'duplicate_cn', label: '允许 duplicate-cn', type: 'switch', default: false, tip: '同用户名/证书多终端同时在线;密码模式默认会自动开启' },
|
||||
{ key: 'reneg_sec', label: 'reneg-sec', type: 'number', default: 0, tip: '0=禁用 TLS 重协商(生产/MFA 推荐);证书-only 默认可 3600' },
|
||||
{ key: 'run_as_user', label: '降权用户', placeholder: 'openvpn', tip: '对应 user openvpn;需系统已有该用户,留空不降权' },
|
||||
{ key: 'run_as_group', label: '降权组', placeholder: 'openvpn', tip: '对应 group openvpn' }
|
||||
{ key: 'auth_mode', label: 'protocols.openvpn.fields.auth_mode.label', type: 'select', options: [
|
||||
{ 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' },
|
||||
{ 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' },
|
||||
{ value: 'required', label: 'protocols.openvpn.fields.mfa_mode.options.required' }
|
||||
], default: 'off', tip: 'protocols.openvpn.fields.mfa_mode.tip', showIf: { auth_mode: ['password', 'certificate+password'] } },
|
||||
{ key: 'otp_slop', label: 'protocols.openvpn.fields.otp_slop.label', type: 'number', default: 90, showIf: { auth_mode: ['password', 'certificate+password'] } },
|
||||
{ key: 'otp_max_failures', label: 'protocols.openvpn.fields.otp_max_failures.label', type: 'number', default: 5, showIf: { auth_mode: ['password', 'certificate+password'] } },
|
||||
{ key: 'otp_lockout_sec', label: 'protocols.openvpn.fields.otp_lockout_sec.label', type: 'number', default: 300, showIf: { auth_mode: ['password', 'certificate+password'] } },
|
||||
{ key: 'otp_disallow_reuse', label: 'protocols.openvpn.fields.otp_disallow_reuse.label', type: 'switch', default: true, showIf: { auth_mode: ['password', 'certificate+password'] } },
|
||||
{ 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: '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 },
|
||||
{ key: 'tun_mtu', label: 'protocols.openvpn.fields.tun_mtu.label', type: 'number', default: 1500, tip: 'protocols.openvpn.fields.tun_mtu.tip' },
|
||||
{ key: 'tun_mtu_extra', label: 'protocols.openvpn.fields.tun_mtu_extra.label', type: 'number', default: 32 },
|
||||
{ key: 'mssfix', label: 'protocols.openvpn.fields.mssfix.label', type: 'number', default: 0, tip: 'protocols.openvpn.fields.mssfix.tip' },
|
||||
{ key: 'duplicate_cn', label: 'protocols.openvpn.fields.duplicate_cn.label', type: 'switch', default: false, tip: 'protocols.openvpn.fields.duplicate_cn.tip' },
|
||||
{ key: 'reneg_sec', label: 'protocols.openvpn.fields.reneg_sec.label', type: 'number', default: 0, tip: 'protocols.openvpn.fields.reneg_sec.tip' },
|
||||
{ key: 'run_as_user', label: 'protocols.openvpn.fields.run_as_user.label', placeholder: 'openvpn', tip: 'protocols.openvpn.fields.run_as_user.tip' },
|
||||
{ key: 'run_as_group', label: 'protocols.openvpn.fields.run_as_group.label', placeholder: 'openvpn', tip: 'protocols.openvpn.fields.run_as_group.tip' }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义协议插件的默认表单(插件参数完全自由)
|
||||
export const CUSTOM_FIELDS = [
|
||||
{ key: 'endpoint', label: '接入地址', placeholder: 'host:port' },
|
||||
{ key: 'token', label: '认证凭据', type: 'password', secret: true },
|
||||
{ key: 'extra', label: '扩展参数(JSON)', placeholder: '{"key":"value"}' }
|
||||
{ key: 'endpoint', label: 'protocols.custom.fields.endpoint.label', placeholder: 'host:port' },
|
||||
{ key: 'token', label: 'protocols.custom.fields.token.label', type: 'password', secret: true },
|
||||
{ key: 'extra', label: 'protocols.custom.fields.extra.label', placeholder: '{"key":"value"}' }
|
||||
]
|
||||
|
||||
export const PROTOCOL_COLORS = {
|
||||
|
||||
@@ -15,10 +15,10 @@ 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' } },
|
||||
{ path: 'users', component: () => import('./views/Users.vue'), meta: { titleKey: 'router.users' } },
|
||||
{ path: 'vpn-users', component: () => import('./views/VPNUsers.vue'), meta: { titleKey: 'router.vpnUsers' } },
|
||||
{ path: 'plugins', component: () => import('./views/Plugins.vue'), meta: { titleKey: 'router.plugins' } },
|
||||
{ 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' } }
|
||||
]
|
||||
@@ -30,6 +30,7 @@ const router = createRouter({ history: createWebHistory(), routes })
|
||||
router.beforeEach(to => {
|
||||
if (!to.meta.public && !auth.token) return '/login'
|
||||
if (to.path === '/login' && auth.token) return '/'
|
||||
if (to.meta.requiresAdmin && !auth.isAdmin()) return '/'
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Format a byte count into a human-readable string (B/KB/MB/GB/TB).
|
||||
* @param {number} n - bytes
|
||||
* @returns {string}
|
||||
*/
|
||||
export function fmtBytes(n) {
|
||||
if (!n) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let i = 0
|
||||
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++ }
|
||||
return n.toFixed(1) + ' ' + units[i]
|
||||
}
|
||||
@@ -76,7 +76,7 @@
|
||||
<el-table :data="ov.expiring" size="small">
|
||||
<el-table-column prop="name" :label="$t('common.name')" />
|
||||
<el-table-column :label="$t('common.protocol')">
|
||||
<template #default="{ row }">{{ protocolDisplay(row.protocol) }}</template>
|
||||
<template #default="{ row }">{{ $t(protocolDisplay(row.protocol)) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('dashboard.expiryTime')">
|
||||
<template #default="{ row }">{{ fmtTime(row.expire_at) }}</template>
|
||||
@@ -92,6 +92,7 @@ import * as echarts from 'echarts'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Connection, Monitor, Share, DataLine, Plus } from '@element-plus/icons-vue'
|
||||
import api from '../api'
|
||||
import { fmtBytes } from '../utils/format'
|
||||
import { protocolColor, protocolDisplay } from '../protocols'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
@@ -124,7 +125,7 @@ async function showInstallCmd(name) {
|
||||
try {
|
||||
const { data } = await api.get(`/binaries/install-hint/${pkg}`)
|
||||
const cmd = data.command || t('dashboard.contactAdmin')
|
||||
const pmLabel = data.pm ? `(${data.pm})` : '(apt 默认)'
|
||||
const pmLabel = data.pm ? `(${data.pm})` : `(${t('dashboard.aptDefault')})`
|
||||
// 构建其他平台的命令列表
|
||||
let otherCmds = ''
|
||||
if (data.all_commands) {
|
||||
@@ -144,13 +145,6 @@ async function showInstallCmd(name) {
|
||||
}
|
||||
}
|
||||
|
||||
function fmtBytes(n) {
|
||||
if (!n) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let i = 0
|
||||
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++ }
|
||||
return n.toFixed(1) + ' ' + units[i]
|
||||
}
|
||||
function fmtTime(t) { return t ? new Date(t).toLocaleString() : '-' }
|
||||
|
||||
const statCards = computed(() => [
|
||||
@@ -175,7 +169,7 @@ function renderPie() {
|
||||
if (!pieRef.value) return
|
||||
if (!chart) chart = echarts.init(pieRef.value)
|
||||
const dist = (ov.value.protocol_distribution || []).map(d => ({
|
||||
name: protocolDisplay(d.Protocol || d.protocol),
|
||||
name: t(protocolDisplay(d.Protocol || d.protocol)),
|
||||
value: d.Count || d.count,
|
||||
itemStyle: { color: protocolColor(d.Protocol || d.protocol) }
|
||||
}))
|
||||
|
||||
@@ -155,12 +155,16 @@ async function compare() {
|
||||
try {
|
||||
const { data } = await api.post('/ipsec/compare', { local: local.value, peer: peer.value })
|
||||
compareResult.value = data
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || t('common.operationFailed'))
|
||||
} finally { checking.value = false }
|
||||
}
|
||||
|
||||
async function loadTunnels() {
|
||||
const { data } = await api.get('/tunnels', { params: { protocol: 'ipsec' } })
|
||||
ipsecTunnels.value = data || []
|
||||
try {
|
||||
const { data } = await api.get('/tunnels', { params: { protocol: 'ipsec' } })
|
||||
ipsecTunnels.value = data || []
|
||||
} catch { ipsecTunnels.value = [] }
|
||||
}
|
||||
|
||||
async function testConnectivity() {
|
||||
@@ -175,8 +179,10 @@ async function testConnectivity() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const { data } = await api.get('/ipsec/templates')
|
||||
templates.value = data
|
||||
try {
|
||||
const { data } = await api.get('/ipsec/templates')
|
||||
templates.value = data
|
||||
} catch { /* templates optional */ }
|
||||
loadTunnels()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<el-table-column prop="username" :label="$t('common.username')" min-width="120" />
|
||||
<el-table-column :label="$t('common.protocol')" width="100">
|
||||
<template #default="{ row }">
|
||||
<span class="protocol-tag" :style="{ color: protocolColor(row.protocol) }">{{ protocolDisplay(row.protocol) }}</span>
|
||||
<span class="protocol-tag" :style="{ color: protocolColor(row.protocol) }">{{ $t(protocolDisplay(row.protocol)) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="real_address" :label="$t('sessions.publicAddress')" min-width="140" />
|
||||
@@ -81,6 +81,7 @@ import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '../api'
|
||||
import { fmtBytes } from '../utils/format'
|
||||
import { protocolColor, protocolDisplay } from '../protocols'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -89,13 +90,6 @@ const logs = ref([])
|
||||
const alerts = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
function fmtBytes(n) {
|
||||
if (!n) return '0 B'
|
||||
const u = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let i = 0
|
||||
while (n >= 1024 && i < 4) { n /= 1024; i++ }
|
||||
return n.toFixed(1) + ' ' + u[i]
|
||||
}
|
||||
function fmtDuration(dur) {
|
||||
if (!dur) return '-'
|
||||
const sec = Math.floor((Date.now() - new Date(dur).getTime()) / 1000)
|
||||
|
||||
@@ -15,18 +15,18 @@
|
||||
<el-input v-model="form.name" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('tunnelEditor.protocolType')">
|
||||
<el-tag :color="protocolColor(protocol)" effect="dark" style="border:none">{{ protocolDisplay(protocol) }}</el-tag>
|
||||
<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="field.label" :required="field.required">
|
||||
<el-form-item v-for="field in visibleFields" :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="optionLabel(option)" :value="optionValue(option)" />
|
||||
<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">{{ field.tip }}</div>
|
||||
<div v-if="field.tip" class="field-tip">{{ $t(field.tip) }}</div>
|
||||
</el-form-item>
|
||||
<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%" />
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
@click="selectProtocol(key)">
|
||||
<el-tag v-if="key === 'wireguard'" type="danger" size="small" effect="dark" class="recommend-badge">{{ $t('tunnelWizard.recommend') }}</el-tag>
|
||||
<div class="proto-icon"><ProtocolIcon :protocol="key" :size="36" /></div>
|
||||
<div class="proto-name" :style="{ color: meta.color }">{{ meta.name }}</div>
|
||||
<div class="proto-desc">{{ meta.desc }}</div>
|
||||
<div class="proto-scene">{{ meta.scenes }}</div>
|
||||
<div class="proto-name" :style="{ color: meta.color }">{{ $t(meta.name) }}</div>
|
||||
<div class="proto-desc">{{ $t(meta.desc) }}</div>
|
||||
<div class="proto-scene">{{ $t(meta.scenes) }}</div>
|
||||
<div class="dep-badge" v-if="depStatus[key]">
|
||||
<el-tag :type="depStatus[key].available ? 'success' : 'warning'" size="small">
|
||||
{{ depStatus[key].available ? $t('tunnelWizard.depReady') : $t('tunnelWizard.notDeployed') }}
|
||||
@@ -44,36 +44,36 @@
|
||||
<el-input v-model="form.name" :placeholder="$t('tunnelWizard.namePlaceholder')" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('tunnelWizard.protocolType')">
|
||||
<el-tag :color="currentColor" effect="dark" style="border:none">{{ currentName }}</el-tag>
|
||||
<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-form-item v-for="f in coreFields" :key="f.key" :label="f.label" :required="f.required">
|
||||
<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" />
|
||||
<el-switch v-else-if="f.type === 'switch'" v-model="form.config[f.key]" />
|
||||
<el-select v-else-if="f.type === 'select'" v-model="form.config[f.key]" style="width:100%">
|
||||
<el-option v-for="o in f.options" :key="optionValue(o)" :label="optionLabel(o)" :value="optionValue(o)" />
|
||||
<el-option v-for="o in f.options" :key="optionValue(o)" :label="$t(optionLabel(o))" :value="optionValue(o)" />
|
||||
</el-select>
|
||||
<el-input v-else-if="f.type === 'password'" v-model="form.config[f.key]" type="password"
|
||||
show-password :placeholder="f.placeholder" />
|
||||
<el-input v-else v-model="form.config[f.key]" :placeholder="f.placeholder || f.default" />
|
||||
<div class="field-tip" v-if="f.tip">{{ f.tip }}</div>
|
||||
<div class="field-tip" v-if="f.tip">{{ $t(f.tip) }}</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 高级字段(可折叠) -->
|
||||
<template v-if="advancedFields.length">
|
||||
<el-collapse v-model="advCollapsed" style="margin-top:8px">
|
||||
<el-collapse-item :title="$t('tunnelWizard.advancedOptions')" name="adv">
|
||||
<el-form-item v-for="f in advancedFields" :key="f.key" :label="f.label" :required="f.required">
|
||||
<el-form-item v-for="f in advancedFields" :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" />
|
||||
<el-switch v-else-if="f.type === 'switch'" v-model="form.config[f.key]" />
|
||||
<el-select v-else-if="f.type === 'select'" v-model="form.config[f.key]" style="width:100%">
|
||||
<el-option v-for="o in f.options" :key="optionValue(o)" :label="optionLabel(o)" :value="optionValue(o)" />
|
||||
<el-option v-for="o in f.options" :key="optionValue(o)" :label="$t(optionLabel(o))" :value="optionValue(o)" />
|
||||
</el-select>
|
||||
<el-input v-else-if="f.type === 'password'" v-model="form.config[f.key]" type="password"
|
||||
show-password :placeholder="f.placeholder" />
|
||||
<el-input v-else v-model="form.config[f.key]" :placeholder="f.placeholder || f.default" />
|
||||
<div class="field-tip" v-if="f.tip">{{ f.tip }}</div>
|
||||
<div class="field-tip" v-if="f.tip">{{ $t(f.tip) }}</div>
|
||||
</el-form-item>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
@@ -192,7 +192,7 @@ async function create() {
|
||||
if (f.required) {
|
||||
const val = form.value.config[f.key]
|
||||
if (val === undefined || val === '' || val === null) {
|
||||
return ElMessage.warning(t('tunnelWizard.fillRequired', { label: f.label }))
|
||||
return ElMessage.warning(t('tunnelWizard.fillRequired', { label: t(f.label) }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+132
-50
@@ -5,7 +5,7 @@
|
||||
<div>
|
||||
<el-input v-model="keyword" :placeholder="$t('tunnels.searchPlaceholder')" clearable style="width:200px" @change="load" />
|
||||
<el-select v-model="protocolFilter" :placeholder="$t('tunnels.allProtocols')" clearable style="width:160px;margin-left:10px" @change="load">
|
||||
<el-option v-for="p in protocolOptions" :key="p" :label="protocolDisplay(p)" :value="p" />
|
||||
<el-option v-for="p in protocolOptions" :key="p" :label="$t(protocolDisplay(p))" :value="p" />
|
||||
</el-select>
|
||||
<el-select v-model="stateFilter" :placeholder="$t('tunnels.allStates')" clearable style="width:140px;margin-left:10px" @change="load">
|
||||
<el-option :label="$t('tunnels.stateRunning')" value="running" />
|
||||
@@ -17,7 +17,16 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="list" v-loading="loading" size="large">
|
||||
<!-- 批量操作栏 -->
|
||||
<div v-if="selectedRows.length > 0" class="batch-bar">
|
||||
<span class="batch-info">{{ $t('tunnels.selected', { count: selectedRows.length }) }}</span>
|
||||
<el-button type="success" size="small" @click="batchAction('start')">{{ $t('tunnels.batchStart') }}</el-button>
|
||||
<el-button type="warning" size="small" @click="batchAction('stop')">{{ $t('tunnels.batchStop') }}</el-button>
|
||||
<el-button type="danger" size="small" @click="batchAction('delete')">{{ $t('tunnels.batchDelete') }}</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="list" v-loading="loading" size="large" @selection-change="handleSelectionChange">
|
||||
<el-table-column type="selection" width="45" />
|
||||
<el-table-column prop="name" :label="$t('common.name')" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<b>{{ row.name }}</b>
|
||||
@@ -28,7 +37,7 @@
|
||||
<template #default="{ row }">
|
||||
<span class="proto-cell">
|
||||
<ProtocolIcon :protocol="row.protocol" :size="16" :style="{ color: protocolColor(row.protocol) }" />
|
||||
{{ protocolDisplay(row.protocol) }}
|
||||
{{ $t(protocolDisplay(row.protocol)) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -86,7 +95,7 @@
|
||||
<el-drawer v-model="detailVisible" :title="detail?.tunnel?.name" size="560px">
|
||||
<template v-if="detail">
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item :label="$t('common.protocol')">{{ protocolDisplay(detail.tunnel.protocol) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('common.protocol')">{{ $t(protocolDisplay(detail.tunnel.protocol)) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('common.status')">{{ stateText(detail.tunnel) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('tunnels.createTime')">{{ fmtTime(detail.tunnel.created_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('tunnels.autoStart')">{{ detail.tunnel.auto_start ? $t('common.yes') : $t('common.no') }}</el-descriptions-item>
|
||||
@@ -204,17 +213,22 @@
|
||||
</div>
|
||||
<div class="log-view mono" ref="logView">{{ logContent || $t('tunnels.waitingLog') }}</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 批量任务进度 -->
|
||||
<TaskProgress ref="taskProgressRef" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
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 QRCode from 'qrcode'
|
||||
import { useRouter } from 'vue-router'
|
||||
import api from '../api'
|
||||
import { fmtBytes } from '../utils/format'
|
||||
import ProtocolIcon from '../components/ProtocolIcon.vue'
|
||||
import TaskProgress from '../components/TaskProgress.vue'
|
||||
import { protocolDisplay, protocolColor } from '../protocols'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -240,13 +254,35 @@ const peerDialog = ref(false)
|
||||
const peerSaving = ref(false)
|
||||
const peerForm = ref({ id: '', name: '', client_address: '', allowed_ips: '', public_key: '' })
|
||||
|
||||
function fmtBytes(n) {
|
||||
if (!n) return '0 B'
|
||||
const u = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let i = 0
|
||||
while (n >= 1024 && i < 4) { n /= 1024; i++ }
|
||||
return n.toFixed(1) + ' ' + u[i]
|
||||
// 批量操作
|
||||
const selectedRows = ref([])
|
||||
const taskProgressRef = ref(null)
|
||||
|
||||
function handleSelectionChange(rows) {
|
||||
selectedRows.value = rows
|
||||
}
|
||||
const selectedIds = computed(() => selectedRows.value.map(r => r.id))
|
||||
|
||||
async function batchAction(action) {
|
||||
const ids = selectedIds.value
|
||||
if (ids.length === 0) return
|
||||
const confirmKey = `tunnels.batch${action.charAt(0).toUpperCase() + action.slice(1)}Confirm`
|
||||
try {
|
||||
await ElMessageBox.confirm(t(confirmKey, { count: ids.length }), t('common.confirm'), { type: 'warning' })
|
||||
} catch { return }
|
||||
try {
|
||||
const { data } = await api.post(`/tunnels/batch/${action}`, { tunnel_ids: ids })
|
||||
ElMessage.success(t('tunnels.batchStarted'))
|
||||
selectedRows.value = []
|
||||
if (data.task_id && taskProgressRef.value) {
|
||||
taskProgressRef.value.open(data.task_id)
|
||||
}
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || t('common.operationFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
function fmtTime(time) { return time ? new Date(time).toLocaleString() : '-' }
|
||||
function expiringSoon(row) {
|
||||
return row.expire_at && new Date(row.expire_at) - Date.now() < 7 * 864e5
|
||||
@@ -289,13 +325,13 @@ function onMoreCommand(cmd, row) {
|
||||
else if (cmd === 'logs') openLogStream(row)
|
||||
}
|
||||
|
||||
// ---- 实时日志流(SSE)----
|
||||
// ---- 实时日志流(SSE via fetch + ReadableStream,避免 token 暴露在 URL)----
|
||||
const logVisible = ref(false)
|
||||
const logTunnel = ref(null)
|
||||
const logContent = ref('')
|
||||
const logStreaming = ref(false)
|
||||
const logView = ref(null)
|
||||
let logEventSource = null
|
||||
let logAbortController = null
|
||||
|
||||
function openLogStream(row) {
|
||||
logTunnel.value = row
|
||||
@@ -304,31 +340,49 @@ function openLogStream(row) {
|
||||
resumeLogStream()
|
||||
}
|
||||
|
||||
function resumeLogStream() {
|
||||
async function resumeLogStream() {
|
||||
if (!logTunnel.value) return
|
||||
stopLogStream()
|
||||
const token = localStorage.getItem('vpn_token')
|
||||
logEventSource = new EventSource(`/api/tunnels/${logTunnel.value.id}/logs?token=${token}`)
|
||||
logAbortController = new AbortController()
|
||||
logStreaming.value = true
|
||||
logEventSource.onmessage = (e) => {
|
||||
logContent.value += e.data + '\n'
|
||||
nextTick(() => {
|
||||
if (logView.value) logView.value.scrollTop = logView.value.scrollHeight
|
||||
try {
|
||||
const resp = await fetch(`/api/tunnels/${logTunnel.value.id}/logs`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
signal: logAbortController.signal
|
||||
})
|
||||
if (logContent.value.length > 100000) {
|
||||
logContent.value = logContent.value.slice(-50000)
|
||||
if (!resp.ok) { logStreaming.value = false; return }
|
||||
const reader = resp.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop()
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
logContent.value += line.slice(6) + '\n'
|
||||
}
|
||||
}
|
||||
nextTick(() => {
|
||||
if (logView.value) logView.value.scrollTop = logView.value.scrollHeight
|
||||
})
|
||||
if (logContent.value.length > 100000) {
|
||||
logContent.value = logContent.value.slice(-50000)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') console.error('log stream error', e)
|
||||
}
|
||||
logEventSource.onerror = () => {
|
||||
logStreaming.value = false
|
||||
logEventSource?.close()
|
||||
}
|
||||
logStreaming.value = false
|
||||
}
|
||||
|
||||
function stopLogStream() {
|
||||
logStreaming.value = false
|
||||
logEventSource?.close()
|
||||
logEventSource = null
|
||||
logAbortController?.abort()
|
||||
logAbortController = null
|
||||
}
|
||||
|
||||
function closeLogStream() {
|
||||
@@ -378,27 +432,39 @@ async function start(row) {
|
||||
load()
|
||||
}
|
||||
async function stop(row) {
|
||||
await api.post(`/tunnels/${row.id}/stop`)
|
||||
ElMessage.success(t('tunnels.stopped'))
|
||||
try {
|
||||
await api.post(`/tunnels/${row.id}/stop`)
|
||||
ElMessage.success(t('tunnels.stopped'))
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || t('tunnels.stopFailed'))
|
||||
}
|
||||
load()
|
||||
}
|
||||
async function remove(row) {
|
||||
await ElMessageBox.confirm(t('tunnels.deleteTunnelConfirm', { name: row.name }), t('common.deleteConfirm'), { type: 'warning' })
|
||||
await api.delete(`/tunnels/${row.id}`)
|
||||
ElMessage.success(t('common.deleteSuccess'))
|
||||
load()
|
||||
try {
|
||||
await ElMessageBox.confirm(t('tunnels.deleteTunnelConfirm', { name: row.name }), t('common.deleteConfirm'), { type: 'warning' })
|
||||
await api.delete(`/tunnels/${row.id}`)
|
||||
ElMessage.success(t('common.deleteSuccess'))
|
||||
load()
|
||||
} catch (e) {
|
||||
if (e !== 'cancel') ElMessage.error(e.response?.data?.error || t('common.deleteFailed'))
|
||||
}
|
||||
}
|
||||
async function showDetail(row) {
|
||||
const { data } = await api.get(`/tunnels/${row.id}`)
|
||||
detail.value = data
|
||||
detailVisible.value = true
|
||||
if (row.protocol === 'wireguard') {
|
||||
await loadPeers(row.id)
|
||||
} else {
|
||||
wgPeers.value = []
|
||||
runtimePeers.value = []
|
||||
try {
|
||||
const { data } = await api.get(`/tunnels/${row.id}`)
|
||||
detail.value = data
|
||||
detailVisible.value = true
|
||||
if (row.protocol === 'wireguard') {
|
||||
await loadPeers(row.id)
|
||||
} else {
|
||||
wgPeers.value = []
|
||||
runtimePeers.value = []
|
||||
}
|
||||
loadPolicy(row.id)
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || t('common.loadFailed'))
|
||||
}
|
||||
loadPolicy(row.id)
|
||||
}
|
||||
|
||||
async function loadPeers(tunnelId) {
|
||||
@@ -490,7 +556,8 @@ async function qrPeer(row) {
|
||||
|
||||
function download(row) {
|
||||
fetch(`/api/tunnels/${row.id}/config`, { headers: { Authorization: `Bearer ${localStorage.getItem('vpn_token')}` } })
|
||||
.then(r => r.blob()).then(b => {
|
||||
.then(r => { if (!r.ok) throw new Error(r.statusText); return r.blob() })
|
||||
.then(b => {
|
||||
const a = document.createElement('a')
|
||||
const url = URL.createObjectURL(b)
|
||||
a.href = url
|
||||
@@ -498,21 +565,28 @@ function download(row) {
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
})
|
||||
.catch(e => ElMessage.error(e.message || t('tunnels.downloadFailed')))
|
||||
}
|
||||
async function showQr(row) {
|
||||
const { data } = await api.get(`/tunnels/${row.id}`)
|
||||
if (!data.client_config) return ElMessage.warning(t('tunnels.noClientConfigWarn'))
|
||||
qrVisible.value = true
|
||||
await nextTick()
|
||||
QRCode.toCanvas(qrCanvas.value, data.client_config, { width: 280, margin: 1 })
|
||||
try {
|
||||
const { data } = await api.get(`/tunnels/${row.id}`)
|
||||
if (!data.client_config) return ElMessage.warning(t('tunnels.noClientConfigWarn'))
|
||||
qrVisible.value = true
|
||||
await nextTick()
|
||||
QRCode.toCanvas(qrCanvas.value, data.client_config, { width: 280, margin: 1 })
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || t('tunnels.qrFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
let timer
|
||||
onMounted(async () => {
|
||||
load()
|
||||
timer = setInterval(load, 15000)
|
||||
const { data } = await api.get('/protocols')
|
||||
protocolOptions.value = data.map(p => p.name)
|
||||
try {
|
||||
const { data } = await api.get('/protocols')
|
||||
protocolOptions.value = data.map(p => p.name)
|
||||
} catch { /* protocols load optional */ }
|
||||
})
|
||||
onUnmounted(() => clearInterval(timer))
|
||||
</script>
|
||||
@@ -526,4 +600,12 @@ onUnmounted(() => clearInterval(timer))
|
||||
background: #1e1e1e; color: #d4d4d4; border-radius: var(--radius-sm); padding: 16px;
|
||||
height: 400px; overflow-y: auto; font-size: 13px; line-height: 1.6; white-space: pre-wrap; word-break: break-all;
|
||||
}
|
||||
.batch-bar {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 16px; margin-bottom: 12px;
|
||||
background: var(--el-color-primary-light-9);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--el-color-primary-light-5);
|
||||
}
|
||||
.batch-info { font-size: 13px; font-weight: 600; color: var(--el-color-primary); margin-right: auto; }
|
||||
</style>
|
||||
@@ -249,8 +249,8 @@ async function doBatchImport() {
|
||||
const { data } = await api.post('/users/batch', { users: usersToCreate, default_role: batchRole.value })
|
||||
const msg = t('users.importResult', { success: data.success, skipped: data.skipped, failed: data.failed })
|
||||
if (data.failed > 0 && data.errors?.length) {
|
||||
const errDetail = data.errors.map(e => `${e.username || '(空)'}: ${e.error}`).join('\n')
|
||||
ElMessageBox.alert(`${msg}\n\n失败详情:\n${errDetail}`, t('users.importResultTitle'), { type: 'warning' })
|
||||
const errDetail = data.errors.map(e => `${e.username || t('users.emptyUsername')}: ${e.error}`).join('\n')
|
||||
ElMessageBox.alert(`${msg}\n\n${t('users.errorDetails')}\n${errDetail}`, t('users.importResultTitle'), { type: 'warning' })
|
||||
} else {
|
||||
ElMessage.success(msg)
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('common.traffic')" width="170">
|
||||
<template #default="{ row }">{{ bytes(row.rx_bytes) }} / {{ bytes(row.tx_bytes) }}</template>
|
||||
<template #default="{ row }">{{ fmtBytes(row.rx_bytes) }} / {{ fmtBytes(row.tx_bytes) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('vpnUsers.lastOnline')" width="180">
|
||||
<template #default="{ row }">{{ row.last_seen_at ? new Date(row.last_seen_at).toLocaleString() : '-' }}</template>
|
||||
@@ -60,7 +60,7 @@
|
||||
<el-form-item :label="$t('vpnUsers.dialTunnel')" required>
|
||||
<el-select v-model="form.tunnel_id" style="width:100%" filterable>
|
||||
<el-option v-for="t in tunnels" :key="t.id" :value="t.id"
|
||||
:label="`${t.name} (${protocolDisplay(t.protocol)})`" />
|
||||
:label="`${t.name} (${$t(protocolDisplay(t.protocol))})`" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('common.username')" required>
|
||||
@@ -175,6 +175,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import QRCode from 'qrcode'
|
||||
import api from '../api'
|
||||
import { fmtBytes } from '../utils/format'
|
||||
import { protocolDisplay } from '../protocols'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -199,17 +200,10 @@ const mfaSaving = ref(false)
|
||||
const pinVisible = ref(false)
|
||||
const pinValue = ref('')
|
||||
|
||||
const bytes = n => {
|
||||
if (!n) return '0 B'
|
||||
const u = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let i = 0
|
||||
while (n >= 1024 && i < 4) { n /= 1024; i++ }
|
||||
return `${n.toFixed(1)} ${u[i]}`
|
||||
}
|
||||
const tunnelName = id => tunnels.value.find(t => t.id === id)?.name || `#${id}`
|
||||
const tunnelProto = id => {
|
||||
const tun = tunnels.value.find(x => x.id === id)
|
||||
return tun ? protocolDisplay(tun.protocol) : ''
|
||||
return tun ? t(protocolDisplay(tun.protocol)) : ''
|
||||
}
|
||||
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()
|
||||
@@ -259,14 +253,22 @@ async function create() {
|
||||
}
|
||||
|
||||
async function toggle(row) {
|
||||
await api.put(`/vpn-users/${row.id}`, { enabled: !row.enabled, display_name: row.display_name, email: row.email })
|
||||
load()
|
||||
try {
|
||||
await api.put(`/vpn-users/${row.id}`, { enabled: !row.enabled, display_name: row.display_name, email: row.email })
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || t('common.operationFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(row) {
|
||||
await ElMessageBox.confirm(t('vpnUsers.deleteVpnUserConfirm', { username: row.username }), t('common.confirm'))
|
||||
await api.delete(`/vpn-users/${row.id}`)
|
||||
load()
|
||||
try {
|
||||
await ElMessageBox.confirm(t('vpnUsers.deleteVpnUserConfirm', { username: row.username }), t('common.confirm'))
|
||||
await api.delete(`/vpn-users/${row.id}`)
|
||||
load()
|
||||
} catch (e) {
|
||||
if (e !== 'cancel') ElMessage.error(e.response?.data?.error || t('common.deleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
async function download(row) {
|
||||
@@ -311,7 +313,7 @@ async function refreshOTPStatus() {
|
||||
mfa_locked: isLocked(otpUser.value),
|
||||
mfa_locked_until: otpUser.value.mfa_locked_until,
|
||||
mfa_last_used_at: otpUser.value.mfa_last_used_at,
|
||||
compatible: 'TOTP 标准(兼容 Google/Microsoft Authenticator)'
|
||||
compatible: t('vpnUsers.totpCompatible')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user