From 91974fc30ffad4f2a4dc858c1df0670a122720dd Mon Sep 17 00:00:00 2001 From: opsx-tools Date: Thu, 23 Jul 2026 12:57:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=90=8E=E7=AB=AF=E9=99=90=E9=80=9F?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20+=20=E5=89=8D=E7=AB=AFi18n=E5=85=A8?= =?UTF-8?q?=E9=9D=A2=E5=9B=BD=E9=99=85=E5=8C=96=20+=20=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E4=B8=8E=E4=BB=A3=E7=A0=81=E8=B4=A8=E9=87=8F=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - 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 后端任务管理 --- backend/internal/api/api.go | 14 +- backend/internal/api/batch.go | 238 ++++++++++ backend/internal/monitor/monitor.go | 3 + backend/internal/service/common.go | 107 +++++ backend/internal/service/frp/frp.go | 12 +- backend/internal/service/ikev2/ikev2.go | 45 +- backend/internal/service/ipsec/ipsec.go | 10 +- backend/internal/service/l2tp/l2tp.go | 44 +- backend/internal/service/openvpn/openvpn.go | 9 +- .../internal/service/wireguard/wireguard.go | 20 +- backend/internal/taskmgr/taskmgr.go | 232 ++++++++++ backend/internal/trafficmgr/trafficmgr.go | 57 ++- frontend/src/components/TaskProgress.vue | 128 ++++++ frontend/src/locales/en-US.json | 426 +++++++++++++++++- frontend/src/locales/zh-CN.json | 426 +++++++++++++++++- frontend/src/protocols.js | 274 +++++------ frontend/src/router.js | 9 +- frontend/src/utils/format.js | 12 + frontend/src/views/Dashboard.vue | 14 +- frontend/src/views/IpsecPeer.vue | 14 +- frontend/src/views/Sessions.vue | 10 +- frontend/src/views/TunnelEditor.vue | 8 +- frontend/src/views/TunnelWizard.vue | 22 +- frontend/src/views/Tunnels.vue | 182 ++++++-- frontend/src/views/Users.vue | 4 +- frontend/src/views/VPNUsers.vue | 34 +- 26 files changed, 1977 insertions(+), 377 deletions(-) create mode 100644 backend/internal/api/batch.go create mode 100644 backend/internal/taskmgr/taskmgr.go create mode 100644 frontend/src/components/TaskProgress.vue create mode 100644 frontend/src/utils/format.js diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index 2c398ac..6e05c16 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -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")) diff --git a/backend/internal/api/batch.go b/backend/internal/api/batch.go new file mode 100644 index 0000000..e17c913 --- /dev/null +++ b/backend/internal/api/batch.go @@ -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, + }) +} diff --git a/backend/internal/monitor/monitor.go b/backend/internal/monitor/monitor.go index 2dea1bb..697f4c4 100644 --- a/backend/internal/monitor/monitor.go +++ b/backend/internal/monitor/monitor.go @@ -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 diff --git a/backend/internal/service/common.go b/backend/internal/service/common.go index 840d181..baddc12 100644 --- a/backend/internal/service/common.go +++ b/backend/internal/service/common.go @@ -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...) + } +} diff --git a/backend/internal/service/frp/frp.go b/backend/internal/service/frp/frp.go index 6bfd0ad..a231728 100644 --- a/backend/internal/service/frp/frp.go +++ b/backend/internal/service/frp/frp.go @@ -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{} diff --git a/backend/internal/service/ikev2/ikev2.go b/backend/internal/service/ikev2/ikev2.go index d782463..9ef0c78 100644 --- a/backend/internal/service/ikev2/ikev2.go +++ b/backend/internal/service/ikev2/ikev2.go @@ -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 { diff --git a/backend/internal/service/ipsec/ipsec.go b/backend/internal/service/ipsec/ipsec.go index 373670b..d2571ba 100644 --- a/backend/internal/service/ipsec/ipsec.go +++ b/backend/internal/service/ipsec/ipsec.go @@ -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 } diff --git a/backend/internal/service/l2tp/l2tp.go b/backend/internal/service/l2tp/l2tp.go index c633bda..11ad2ac 100644 --- a/backend/internal/service/l2tp/l2tp.go +++ b/backend/internal/service/l2tp/l2tp.go @@ -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 } diff --git a/backend/internal/service/openvpn/openvpn.go b/backend/internal/service/openvpn/openvpn.go index 1d2a777..acdd189 100644 --- a/backend/internal/service/openvpn/openvpn.go +++ b/backend/internal/service/openvpn/openvpn.go @@ -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") } diff --git a/backend/internal/service/wireguard/wireguard.go b/backend/internal/service/wireguard/wireguard.go index 5901b51..68f9e75 100644 --- a/backend/internal/service/wireguard/wireguard.go +++ b/backend/internal/service/wireguard/wireguard.go @@ -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 -} diff --git a/backend/internal/taskmgr/taskmgr.go b/backend/internal/taskmgr/taskmgr.go new file mode 100644 index 0000000..0d1827a --- /dev/null +++ b/backend/internal/taskmgr/taskmgr.go @@ -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) + } + } + } +} diff --git a/backend/internal/trafficmgr/trafficmgr.go b/backend/internal/trafficmgr/trafficmgr.go index 56cd5f7..99c23f4 100644 --- a/backend/internal/trafficmgr/trafficmgr.go +++ b/backend/internal/trafficmgr/trafficmgr.go @@ -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 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 parent root classid 1:10 htb rate 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 获取隧道的流量策略(不存在则返回默认空策略) diff --git a/frontend/src/components/TaskProgress.vue b/frontend/src/components/TaskProgress.vue new file mode 100644 index 0000000..da427e3 --- /dev/null +++ b/frontend/src/components/TaskProgress.vue @@ -0,0 +1,128 @@ + + + diff --git a/frontend/src/locales/en-US.json b/frontend/src/locales/en-US.json index 3f9cd0f..b6eaea8 100644 --- a/frontend/src/locales/en-US.json +++ b/frontend/src/locales/en-US.json @@ -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)" + } + } + } } } \ No newline at end of file diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json index ab96277..0688122 100644 --- a/frontend/src/locales/zh-CN.json +++ b/frontend/src/locales/zh-CN.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)" + } + } + } } } \ No newline at end of file diff --git a/frontend/src/protocols.js b/frontend/src/protocols.js index 05a401a..7844c88 100644 --- a/frontend/src/protocols.js +++ b/frontend/src/protocols.js @@ -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 = { diff --git a/frontend/src/router.js b/frontend/src/router.js index a34d8c5..7dd946c 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -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 diff --git a/frontend/src/utils/format.js b/frontend/src/utils/format.js new file mode 100644 index 0000000..4f8ea1d --- /dev/null +++ b/frontend/src/utils/format.js @@ -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] +} diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index 5eff096..c88c637 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -76,7 +76,7 @@ - + @@ -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) } })) diff --git a/frontend/src/views/IpsecPeer.vue b/frontend/src/views/IpsecPeer.vue index b68897f..ed0d55f 100644 --- a/frontend/src/views/IpsecPeer.vue +++ b/frontend/src/views/IpsecPeer.vue @@ -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() }) diff --git a/frontend/src/views/Sessions.vue b/frontend/src/views/Sessions.vue index ec5b3f0..c5558d4 100644 --- a/frontend/src/views/Sessions.vue +++ b/frontend/src/views/Sessions.vue @@ -15,7 +15,7 @@ @@ -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) diff --git a/frontend/src/views/TunnelEditor.vue b/frontend/src/views/TunnelEditor.vue index 8897cc6..c477ea0 100644 --- a/frontend/src/views/TunnelEditor.vue +++ b/frontend/src/views/TunnelEditor.vue @@ -15,18 +15,18 @@ - {{ protocolDisplay(protocol) }} + {{ $t(protocolDisplay(protocol)) }} - + - + -
{{ field.tip }}
+
{{ $t(field.tip) }}
diff --git a/frontend/src/views/TunnelWizard.vue b/frontend/src/views/TunnelWizard.vue index 368e13d..ca0b719 100644 --- a/frontend/src/views/TunnelWizard.vue +++ b/frontend/src/views/TunnelWizard.vue @@ -14,9 +14,9 @@ @click="selectProtocol(key)"> {{ $t('tunnelWizard.recommend') }}
-
{{ meta.name }}
-
{{ meta.desc }}
-
{{ meta.scenes }}
+
{{ $t(meta.name) }}
+
{{ $t(meta.desc) }}
+
{{ $t(meta.scenes) }}
{{ depStatus[key].available ? $t('tunnelWizard.depReady') : $t('tunnelWizard.notDeployed') }} @@ -44,36 +44,36 @@ - {{ currentName }} + {{ $t(currentName) }} {{ $t('tunnelWizard.reselect') }} - + - + -
{{ f.tip }}
+
{{ $t(f.tip) }}
- + +
+ {{ $t('tunnels.selected', { count: selectedRows.length }) }} + {{ $t('tunnels.batchStart') }} + {{ $t('tunnels.batchStop') }} + {{ $t('tunnels.batchDelete') }} +
+ + + - + @@ -60,7 +60,7 @@ + :label="`${t.name} (${$t(protocolDisplay(t.protocol))})`" /> @@ -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') } } }