diff --git a/.gitignore b/.gitignore index 1182e89..9b36e00 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ # 构建产物 opsx-vpn +opsx-vpn.previous backend/dist/ frontend/dist/ diff --git a/README.md b/README.md index aea8daa..4d0d895 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Gin + Vue3 + SQLite 的多协议 VPN 专线 Web 管理平台:**单二进制交 | IKEv2 远程接入 | StrongSwan Road Warrior,PSK + EAP-MSCHAPv2 | 员工手机/笔记本原生拨号 | | L2TP/IPSec | StrongSwan + xl2tpd,支持多拨号用户 | 手机/老设备兼容 | | FRP | 直接管理 `frps` / `frpc`(TOML),含 STCP/XTCP | 内网穿透、端口映射 | +| GRE | Linux `ip tunnel` 管理标准 GRE 隧道 | 点对点网站互联 | | OpenVPN | 进程管理 + PKI;UDP/TCP 可切换;证书/密码 + TOTP MFA | 老项目兼容 | | 自定义协议 | 插件命令钩子(Create/Start/Stop/Status/Config) | 任意协议扩展 | @@ -52,11 +53,11 @@ scp opsx-vpn user@server:/opt/opsx-vpn/ cd /opt/opsx-vpn && chmod +x opsx-vpn # 3. 安装系统依赖(按需) -apt install -y wireguard-tools openvpn strongswan strongswan-swanctl iptables +apt install -y wireguard-tools openvpn strongswan strongswan-swanctl iproute2 iptables # FRP 无需系统包 —— 平台 UI 内点「下载部署」自动从 GitHub 下载到 data/bin/ # 4. 启动(生产建议用 systemd 管理) -./opsx-vpn # 监听 :8080,数据目录 ./data +VPN_LISTEN_ADDR=:18080 ./opsx-vpn # 生产管理端口 :18080,数据目录 ./data ``` ### systemd 服务(生产推荐) @@ -74,7 +75,7 @@ ExecStart=/opt/opsx-vpn/opsx-vpn Restart=always RestartSec=5 # 环境变量按需调整 -Environment=VPN_LISTEN_ADDR=:8080 +Environment=VPN_LISTEN_ADDR=:18080 Environment=VPN_DATA_DIR=/opt/opsx-vpn/data # WireGuard/OpenVPN 需要 root 权限(创建 TUN 设备 + iptables) # 如需降权,需配置 NET_ADMIN capability @@ -104,12 +105,13 @@ journalctl -u opsx-vpn -f # 查看日志 | UDP 4500 | IPSec / IKEv2 / L2TP | UDP | NAT-T 穿透 | | UDP 1701 | L2TP | UDP | L2TP 隧道 | | TCP 7000 | FRP | TCP | frps 绑定端口(客户端连接用) | +| IP 协议 47 | GRE | GRE | GRE 封装(无 TCP/UDP 端口) | ### 管理(仅限你的 IP 或内网) | 端口 | 协议 | 类型 | 用途 | |---|---|---|---| -| TCP 8080 | 管理平台 | TCP | Web UI + API(**勿对公网开放**,建议 Nginx HTTPS 后改 443) | +| TCP 18080 | 管理平台 | TCP | Web UI + API(**勿对公网开放**,建议 Nginx HTTPS 后改 443) | | TCP 22 | SSH | TCP | 远程管理 | ### 安全组配置示例 @@ -122,13 +124,15 @@ UDP 1194 0.0.0.0/0 # OpenVPN UDP TCP 1194 0.0.0.0/0 # OpenVPN TCP(如启用) UDP 1701 0.0.0.0/0 # L2TP TCP 7000 0.0.0.0/0 # FRP server +# GRE 不是 TCP/UDP 端口,需在安全组中放行 GRE / IP 协议 47 +GRE 协议 47 对端公网 IP/32 # 建议只允许对端地址 # 管理(仅限你的 IP) -TCP 8080 你的IP/32 # 管理 UI +TCP 18080 你的IP/32 # 管理 UI TCP 22 你的IP/32 # SSH ``` -> **注意**:IPSec 必须同时开放 UDP 500 + 4500(缺一个连不上)。管理端口 8080 切勿对公网开放。仅用 WireGuard 最简部署只需 3 个端口:UDP 51820 + TCP 8080 + TCP 22。 +> **注意**:IPSec 必须同时开放 UDP 500 + 4500(缺一个连不上)。GRE 需要**本端、对端及中间云安全组**双向允许 **IP 协议 47**,不是端口号;Linux 端依赖 `iproute2` 的 `ip tunnel`。管理端口 18080 切勿对公网开放。仅用 WireGuard 最简部署只需 3 个端口:UDP 51820 + TCP 18080 + TCP 22。 ## 协议依赖安装 @@ -137,6 +141,7 @@ TCP 22 你的IP/32 # SSH | WireGuard | `apt install -y wireguard-tools` | ❌ 系统包 | | OpenVPN | `apt install -y openvpn` | ✅ GitHub Release / 系统包 | | StrongSwan (IPSec/IKEv2/L2TP) | `apt install -y strongswan strongswan-swanctl` | ❌ 系统包 | +| GRE | `apt install -y iproute2`(多数系统默认已安装) | ❌ 系统包 | | FRP | 无需系统包 | ✅ 平台 UI「下载部署」自动从 GitHub 下载 | 在「总览 → 协议引擎状态」表中,未就绪的协议会显示「下载部署」或「安装命令」按钮。 @@ -145,7 +150,7 @@ TCP 22 你的IP/32 # SSH | 变量 | 默认 | 说明 | |---|---|---| -| `VPN_LISTEN_ADDR` | `:8080` | 监听地址 | +| `VPN_LISTEN_ADDR` | `:18080` | 监听地址 | | `VPN_DATA_DIR` | `./data` | 数据目录(SQLite、密钥、实例配置、下载的二进制) | ## 常用命令 diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index 536ba39..4078745 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -31,14 +31,14 @@ import ( "vpn-platform/backend/internal/sessionmgr" "vpn-platform/backend/internal/sshutil" "vpn-platform/backend/internal/store" - "vpn-platform/backend/internal/trafficmgr" "vpn-platform/backend/internal/taskmgr" + "vpn-platform/backend/internal/trafficmgr" ) // Server API 服务器 type Server struct { - mgr *core.Manager - taskMgr *taskmgr.Manager + mgr *core.Manager + taskMgr *taskmgr.Manager } func NewServer(mgr *core.Manager) *Server { return &Server{mgr: mgr, taskMgr: taskmgr.NewManager()} } @@ -227,6 +227,7 @@ func (s *Server) RegisterRoutes(r *gin.Engine) { // 只读 auth.GET("/overview", s.overview) + auth.GET("/readiness", s.readiness) auth.GET("/protocols", s.listProtocols) auth.GET("/tunnels", s.listTunnels) auth.GET("/tunnels/:id", s.getTunnel) @@ -292,6 +293,8 @@ func (s *Server) RegisterRoutes(r *gin.Engine) { admin.POST("/sessions/:id/disconnect", s.disconnectSession) admin.GET("/vpn-users", s.listVPNUsers) admin.POST("/vpn-users", s.createVPNUser) + admin.POST("/vpn-users/import-platform", s.importPlatformVPNUsers) + admin.POST("/vpn-users/batch-delete", s.batchDeleteVPNUsers) admin.PUT("/vpn-users/:id", s.updateVPNUser) admin.DELETE("/vpn-users/:id", s.deleteVPNUser) admin.POST("/vpn-users/:id/mfa/setup", s.vpnUserMFASetup) @@ -600,11 +603,26 @@ func (s *Server) overview(c *gin.Context) { c.JSON(200, gin.H{ "total": total, "running": running, "nodes": nodeCount, "plugins": pluginCount, "online_sessions": sessionmgr.CountActive(), - "rx_bytes": sum.Rx, "tx_bytes": sum.Tx, + "rx_bytes": sum.Rx, "tx_bytes": sum.Tx, "protocol_distribution": dist, "expiring": expiring, }) } +// readiness reports host prerequisites that directly affect VPN forwarding. +func (s *Server) readiness(c *gin.Context) { + forwarding := false + if raw, err := os.ReadFile("/proc/sys/net/ipv4/ip_forward"); err == nil { + forwarding = strings.TrimSpace(string(raw)) == "1" + } + iface, ifaceErr := service.DefaultRouteInterface() + c.JSON(200, gin.H{ + "root": os.Geteuid() == 0, + "ipv4_forwarding": forwarding, + "default_interface": iface, + "default_route_ready": ifaceErr == nil, + }) +} + func (s *Server) listProtocols(c *gin.Context) { type item struct { Name string `json:"name"` @@ -836,11 +854,14 @@ func (s *Server) getTunnel(c *gin.Context) { return } // 客户端配置预览 - var clientConf string + var clientConf, clientConfError string if p, err := s.mgr.Get(t.Protocol); err == nil { - clientConf, _ = p.GenerateClientConfig(t) + clientConf, err = p.GenerateClientConfig(t) + if err != nil { + clientConfError = err.Error() + } } - c.JSON(200, gin.H{"tunnel": t, "client_config": clientConf}) + c.JSON(200, gin.H{"tunnel": t, "client_config": clientConf, "client_config_error": clientConfError}) } func (s *Server) updateTunnel(c *gin.Context) { @@ -855,63 +876,94 @@ func (s *Server) updateTunnel(c *gin.Context) { c.JSON(400, gin.H{"error": "参数错误"}) return } - var ( - p core.Protocol - newRaw string - hasCfg = req.Config != nil - wasRunning = t.State == "running" - ) - if hasCfg { - var err error - p, err = s.mgr.Get(t.Protocol) - if err != nil { - c.JSON(400, gin.H{"error": err.Error()}) + hasCfg := req.Config != nil + if !hasCfg { + if req.Name != "" { + t.Name = req.Name + } + t.Remark, t.AutoStart, t.ExpireAt = req.Remark, req.AutoStart, req.ExpireAt + if err := store.DB.Model(t).Updates(map[string]interface{}{ + "name": t.Name, "remark": t.Remark, "auto_start": t.AutoStart, "expire_at": t.ExpireAt, + }).Error; err != nil { + c.JSON(500, gin.H{"error": "保存失败: " + err.Error()}) return } - cfg, err := p.Validate(req.Config) - if err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - raw, _ := json.Marshal(cfg) - newRaw = string(raw) - if wasRunning { - if err := p.Stop(t); err != nil { - c.JSON(500, gin.H{"error": "停止旧配置失败: " + err.Error()}) - return - } - } - } - if req.Name != "" { - t.Name = req.Name - } - t.Remark = req.Remark - t.AutoStart = req.AutoStart - t.ExpireAt = req.ExpireAt - // 不通过 Save(t) 写入 Config,避免更新请求中的密钥明文落库。 - if err := store.DB.Model(t).Updates(map[string]interface{}{ - "name": t.Name, "remark": t.Remark, "auto_start": t.AutoStart, "expire_at": t.ExpireAt, - }).Error; err != nil { - c.JSON(500, gin.H{"error": "保存失败: " + err.Error()}) + store.Audit(c.GetString("username"), "update_tunnel", t.Name, "") + c.JSON(200, t) return } - if hasCfg { - t.Config = newRaw - if err := p.Create(t); err != nil { - c.JSON(500, gin.H{"error": "生成配置失败: " + err.Error()}) - return + + p, err := s.mgr.Get(t.Protocol) + if err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + cfg, err := p.Validate(req.Config) + if err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + raw, _ := json.Marshal(cfg) + original := *t + candidate := original + candidate.Config = string(raw) + if req.Name != "" { + candidate.Name = req.Name + } + candidate.Remark, candidate.AutoStart, candidate.ExpireAt = req.Remark, req.AutoStart, req.ExpireAt + wasRunning := original.State == "running" + backup, err := service.SnapshotInstance(&original) + if err != nil { + c.JSON(500, gin.H{"error": "无法备份旧配置,已取消更新: " + err.Error()}) + return + } + defer service.CleanupInstanceSnapshot(backup) + + rollback := func(cause error) error { + _ = service.RestoreInstanceSnapshot(&original, backup) + if err := store.DB.Save(&original).Error; err != nil { + return fmt.Errorf("恢复数据库配置失败: %w", err) } if wasRunning { - if err := p.Start(t); err != nil { - t.State = "error" - t.LastError = err.Error() - } else { - t.State = "running" - t.LastError = "" + if err := p.Start(&original); err != nil { + return fmt.Errorf("恢复旧服务失败: %w", err) } - store.DB.Model(t).Updates(map[string]interface{}{"state": t.State, "last_error": t.LastError}) + } + *t = original + return cause + } + + if wasRunning { + if err := p.Stop(&original); err != nil { + c.JSON(500, gin.H{"error": "停止旧配置失败: " + err.Error()}) + return } } + if err := p.Create(&candidate); err != nil { + if restoreErr := rollback(err); restoreErr != err { + c.JSON(500, gin.H{"error": "生成新配置失败,且回滚异常: " + restoreErr.Error()}) + return + } + c.JSON(500, gin.H{"error": "生成新配置失败,已恢复旧配置: " + err.Error()}) + return + } + if wasRunning { + if err := p.Start(&candidate); err != nil { + if restoreErr := rollback(err); restoreErr != err { + c.JSON(500, gin.H{"error": "启动新配置失败,且回滚异常: " + restoreErr.Error()}) + return + } + c.JSON(500, gin.H{"error": "启动新配置失败,已恢复旧服务: " + err.Error()}) + return + } + candidate.State, candidate.LastError = "running", "" + if err := store.DB.Model(&candidate).Updates(map[string]interface{}{"state": candidate.State, "last_error": ""}).Error; err != nil { + _ = rollback(err) + c.JSON(500, gin.H{"error": "保存运行状态失败,已恢复旧服务: " + err.Error()}) + return + } + } + *t = candidate store.Audit(c.GetString("username"), "update_tunnel", t.Name, "") c.JSON(200, t) } @@ -1063,11 +1115,11 @@ func (s *Server) tunnelTrafficHistory(c *gin.Context) { since := time.Now().Add(-time.Duration(hours) * time.Hour) type row struct { - Time time.Time `json:"time"` - Rx int64 `json:"rx"` - Tx int64 `json:"tx"` - DeltaRx int64 `json:"delta_rx"` - DeltaTx int64 `json:"delta_tx"` + Time time.Time `json:"time"` + Rx int64 `json:"rx"` + Tx int64 `json:"tx"` + DeltaRx int64 `json:"delta_rx"` + DeltaTx int64 `json:"delta_tx"` } var results []row @@ -1338,15 +1390,15 @@ func (s *Server) ipsecCompare(c *gin.Context) { // nodeReq 节点创建/更新请求(含明文凭证,仅传输用,不入库明文) type nodeReq struct { - Name string `json:"name"` - Address string `json:"address"` - Port int `json:"port"` - Kind string `json:"kind"` - Username string `json:"username"` - AuthType string `json:"auth_type"` - Password string `json:"password"` + Name string `json:"name"` + Address string `json:"address"` + Port int `json:"port"` + Kind string `json:"kind"` + Username string `json:"username"` + AuthType string `json:"auth_type"` + Password string `json:"password"` PrivateKey string `json:"private_key"` - Remark string `json:"remark"` + Remark string `json:"remark"` } // nodeTestReq 临时连接测试请求(不落库) diff --git a/backend/internal/api/identity.go b/backend/internal/api/identity.go index e4be81b..0faba44 100644 --- a/backend/internal/api/identity.go +++ b/backend/internal/api/identity.go @@ -419,16 +419,17 @@ func (s *Server) listVPNUsers(c *gin.Context) { } func (s *Server) createVPNUser(c *gin.Context) { var req struct { - TunnelID uint `json:"tunnel_id"` - Username string `json:"username"` - DisplayName string `json:"display_name"` - Email string `json:"email"` - Source string `json:"source"` - LDAPDN string `json:"ldap_dn"` - Password string `json:"password"` - Enabled *bool `json:"enabled"` + TunnelID uint `json:"tunnel_id"` + PlatformUserID uint `json:"platform_user_id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Email string `json:"email"` + Source string `json:"source"` + LDAPDN string `json:"ldap_dn"` + Password string `json:"password"` + Enabled *bool `json:"enabled"` } - if c.ShouldBindJSON(&req) != nil || req.TunnelID == 0 || req.Username == "" { + if c.ShouldBindJSON(&req) != nil || req.TunnelID == 0 || (req.Username == "" && req.PlatformUserID == 0) { c.JSON(400, gin.H{"error": "隧道和用户名必填"}) return } @@ -439,6 +440,12 @@ func (s *Server) createVPNUser(c *gin.Context) { c.JSON(400, gin.H{"error": "来源必须为 local 或 ldap"}) return } + // 手工创建仅支持独立本地拨号账号。LDAP 身份必须从用户管理批量导入, + // 这样可以复用平台身份源,并由服务端查找、固定对应的 LDAP DN。 + if req.PlatformUserID == 0 && req.Source == "ldap" { + c.JSON(400, gin.H{"error": "LDAP 用户请通过“批量导入”从用户管理导入"}) + return + } t, err := store.GetTunnel(req.TunnelID) if err != nil { c.JSON(404, gin.H{"error": "隧道不存在"}) @@ -450,20 +457,55 @@ func (s *Server) createVPNUser(c *gin.Context) { c.JSON(400, gin.H{"error": "该协议不支持拨号用户(支持 OpenVPN / L2TP / IKEv2)"}) return } + if req.PlatformUserID != 0 { + if t.Protocol != "openvpn" { + c.JSON(400, gin.H{"error": "平台用户导入仅支持 OpenVPN;L2TP/IKEv2 需要独立拨号密码"}) + return + } + var account model.User + if err := store.DB.First(&account, req.PlatformUserID).Error; err != nil { + c.JSON(404, gin.H{"error": "平台用户不存在"}) + return + } + if !account.Enabled { + c.JSON(400, gin.H{"error": "平台用户已禁用,无法导入"}) + return + } + req.Username, req.Email, req.Source = account.Username, account.Email, account.Source + if req.DisplayName == "" { + req.DisplayName = account.Username + } + if req.Source == "ldap" { + dn, err := findLDAPUserDN(account.Username) + if err != nil { + c.JSON(400, gin.H{"error": "LDAP 平台用户导入失败: " + err.Error()}) + return + } + req.LDAPDN = dn + } + } user := model.VPNUser{TunnelID: req.TunnelID, Username: req.Username, DisplayName: req.DisplayName, Email: req.Email, Source: req.Source, LDAPDN: req.LDAPDN, Enabled: true} if req.Enabled != nil { user.Enabled = *req.Enabled } if user.Source == "local" { - if len(req.Password) < 8 { + if req.PlatformUserID != 0 { + var account model.User + if err := store.DB.First(&account, req.PlatformUserID).Error; err != nil || account.Password == "" { + c.JSON(400, gin.H{"error": "平台本地用户密码不可用"}) + return + } + user.Password = account.Password + } else if len(req.Password) < 8 { c.JSON(400, gin.H{"error": "本地拨号用户密码至少 8 位"}) return - } - hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) - user.Password = string(hash) - // L2TP/IKEv2 需要可逆拨号密钥;OpenVPN 仅用 bcrypt,同时存一份便于统一导出说明。 - if enc, err := store.EncryptSecret(req.Password); err == nil { - user.DialSecret = enc + } else { + hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + user.Password = string(hash) + // L2TP/IKEv2 需要可逆拨号密钥;OpenVPN 仅用 bcrypt,同时存一份便于统一导出说明。 + if enc, err := store.EncryptSecret(req.Password); err == nil { + user.DialSecret = enc + } } } else if user.LDAPDN == "" { c.JSON(400, gin.H{"error": "LDAP 拨号用户必须填写 LDAP DN"}) @@ -476,6 +518,114 @@ func (s *Server) createVPNUser(c *gin.Context) { _ = refreshDialAuth(t) c.JSON(200, user) } + +// importPlatformVPNUsers imports selected platform local/LDAP accounts into an +// OpenVPN tunnel. L2TP/IKEv2 use CHAP/EAP and require a dedicated dial-in +// secret, so they intentionally remain manual-user only. +func (s *Server) importPlatformVPNUsers(c *gin.Context) { + var req struct { + TunnelID uint `json:"tunnel_id"` + PlatformUserIDs []uint `json:"platform_user_ids"` + } + if c.ShouldBindJSON(&req) != nil || req.TunnelID == 0 || len(req.PlatformUserIDs) == 0 { + c.JSON(400, gin.H{"error": "请选择目标 OpenVPN 隧道和至少一个平台用户"}) + return + } + t, err := store.GetTunnel(req.TunnelID) + if err != nil { + c.JSON(404, gin.H{"error": "隧道不存在"}) + return + } + if t.Protocol != "openvpn" { + c.JSON(400, gin.H{"error": "平台用户批量导入仅支持 OpenVPN;L2TP/IKEv2 请创建独立拨号账号"}) + return + } + var accounts []model.User + if err := store.DB.Where("id IN ? AND enabled = ?", req.PlatformUserIDs, true).Find(&accounts).Error; err != nil { + c.JSON(500, gin.H{"error": "读取平台用户失败: " + err.Error()}) + return + } + created, skipped := 0, []string{} + for _, account := range accounts { + var exists int64 + store.DB.Model(&model.VPNUser{}).Where("tunnel_id = ? AND username = ?", t.ID, account.Username).Count(&exists) + if exists > 0 { + skipped = append(skipped, account.Username+"(已存在)") + continue + } + user := model.VPNUser{TunnelID: t.ID, Username: account.Username, DisplayName: account.Username, Email: account.Email, Source: account.Source, Enabled: true} + if account.Source == "ldap" { + dn, err := findLDAPUserDN(account.Username) + if err != nil { + skipped = append(skipped, account.Username+"(LDAP DN 获取失败)") + continue + } + user.LDAPDN = dn + } else { + user.Password = account.Password + } + if err := store.DB.Create(&user).Error; err != nil { + skipped = append(skipped, account.Username+"(创建失败)") + continue + } + created++ + } + store.Audit(c.GetString("username"), "import_vpn_users", t.Name, fmt.Sprintf("成功 %d,跳过 %d", created, len(skipped))) + c.JSON(200, gin.H{"created": created, "skipped": skipped}) +} + +func (s *Server) batchDeleteVPNUsers(c *gin.Context) { + var req struct { + IDs []uint `json:"ids"` + } + if c.ShouldBindJSON(&req) != nil || len(req.IDs) == 0 { + c.JSON(400, gin.H{"error": "请选择要删除的拨号用户"}) + return + } + var users []model.VPNUser + store.DB.Where("id IN ?", req.IDs).Find(&users) + tunnels := map[uint]bool{} + for _, user := range users { + revokeOpenVPNCert(&user) + tunnels[user.TunnelID] = true + } + if err := store.DB.Delete(&model.VPNUser{}, req.IDs).Error; err != nil { + c.JSON(500, gin.H{"error": "批量删除失败: " + err.Error()}) + return + } + for tunnelID := range tunnels { + if t, err := store.GetTunnel(tunnelID); err == nil { + _ = refreshDialAuth(t) + } + } + store.Audit(c.GetString("username"), "batch_delete_vpn_users", "VPN 拨号用户", fmt.Sprintf("删除 %d 个", len(users))) + c.JSON(200, gin.H{"deleted": len(users)}) +} + +// findLDAPUserDN resolves a platform LDAP account to the DN required by the +// OpenVPN authentication helper. The password is never read or stored here. +func findLDAPUserDN(username string) (string, error) { + cfg, err := ldapSettings() + if err != nil { + return "", err + } + conn, err := ldapConnect(cfg) + if err != nil { + return "", err + } + defer conn.Close() + filter := cfg.UserFilter + if strings.Contains(filter, "%s") { + filter = fmt.Sprintf(filter, ldap.EscapeFilter(username)) + } else { + filter = fmt.Sprintf("(&%s(%s=%s))", filter, cfg.UsernameAttribute, ldap.EscapeFilter(username)) + } + result, err := conn.Search(ldap.NewSearchRequest(cfg.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 1, 5, false, filter, []string{"dn"}, nil)) + if err != nil || len(result.Entries) != 1 { + return "", fmt.Errorf("未找到 LDAP 用户 %q", username) + } + return result.Entries[0].DN, nil +} func (s *Server) updateVPNUser(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) var user model.VPNUser diff --git a/backend/internal/binmgr/binmgr.go b/backend/internal/binmgr/binmgr.go index 087edf1..943c2a6 100644 --- a/backend/internal/binmgr/binmgr.go +++ b/backend/internal/binmgr/binmgr.go @@ -381,34 +381,32 @@ func InstallHint(protocol string) map[string]interface{} { "pacman": "pacman -S --noconfirm wireguard-tools", }, "openvpn": { - "apt-get": "apt-get install -y openvpn", - "yum": fmt.Sprintf("yum install -y epel-release && sed -i 's|^metalink=|#metalink=|g; s|^#baseurl=https://download.example/pub/epel|baseurl=%s|g' /etc/yum.repos.d/epel*.repo 2>/dev/null; yum install -y openvpn", epelMirror), - "dnf": fmt.Sprintf("dnf install -y epel-release && sed -i 's|^metalink=|#metalink=|g; s|^#baseurl=https://download.example/pub/epel|baseurl=%s|g' /etc/yum.repos.d/epel*.repo 2>/dev/null; dnf install -y openvpn", epelMirror), - "apk": "apk add openvpn", - "pacman": "pacman -S --noconfirm openvpn", + "apt-get": "apt-get install -y openvpn easy-rsa", + "yum": fmt.Sprintf("yum install -y epel-release && sed -i 's|^metalink=|#metalink=|g; s|^#baseurl=https://download.example/pub/epel|baseurl=%s|g' /etc/yum.repos.d/epel*.repo 2>/dev/null; yum install -y openvpn easy-rsa", epelMirror), + "dnf": fmt.Sprintf("dnf install -y epel-release && sed -i 's|^metalink=|#metalink=|g; s|^#baseurl=https://download.example/pub/epel|baseurl=%s|g' /etc/yum.repos.d/epel*.repo 2>/dev/null; dnf install -y openvpn easy-rsa", epelMirror), + "apk": "apk add openvpn easy-rsa", + "pacman": "pacman -S --noconfirm openvpn easy-rsa", }, "strongswan": { "apt-get": "apt-get install -y strongswan strongswan-swanctl", - "yum": fmt.Sprintf("yum install -y epel-release && sed -i 's|^metalink=|#metalink=|g; s|^#baseurl=https://download.example/pub/epel|baseurl=%s|g' /etc/yum.repos.d/epel*.repo 2>/dev/null; yum install -y strongswan", epelMirror), - "dnf": fmt.Sprintf("dnf install -y epel-release && sed -i 's|^metalink=|#metalink=|g; s|^#baseurl=https://download.example/pub/epel|baseurl=%s|g' /etc/yum.repos.d/epel*.repo 2>/dev/null; dnf install -y strongswan", epelMirror), + "yum": "yum install -y strongswan", + "dnf": "dnf install -y strongswan", "apk": "apk add strongswan", "pacman": "pacman -S --noconfirm strongswan", }, } - // L2TP 额外需要 xl2tpd - if protocol == "l2tp" || protocol == "strongswan" { - // 追加 xl2tpd 包(L2TP 协议必需) + // L2TP 额外需要 xl2tpd。TencentOS 4 的 EPOL 没有该包,需回退到官方源码编译。 + if protocol == "l2tp" { for pm, cmd := range hints["strongswan"] { - if pm == "apt-get" { + switch pm { + case "apt-get": hints["strongswan"][pm] = cmd + " xl2tpd" - } else { - hints["strongswan"][pm] = strings.Replace(cmd, "strongswan\"", "strongswan xl2tpd\"", 1) - if !strings.Contains(cmd, "xl2tpd") { - hints["strongswan"][pm] = cmd + " && " + map[string]string{ - "yum": "yum install -y xl2tpd", "dnf": "dnf install -y xl2tpd", - "apk": "apk add xl2tpd", "pacman": "pacman -S --noconfirm xl2tpd", - }[pm] - } + case "yum", "dnf": + hints["strongswan"][pm] = cmd + " && " + xl2tpdInstallCommand(pm) + default: + hints["strongswan"][pm] = cmd + " && " + map[string]string{ + "apk": "apk add xl2tpd", "pacman": "pacman -S --noconfirm xl2tpd", + }[pm] } } } @@ -442,6 +440,11 @@ func InstallHint(protocol string) map[string]interface{} { } } +func xl2tpdInstallCommand(pm string) string { + pkg := pm + return fmt.Sprintf(`if %s list --available xl2tpd >/dev/null 2>&1; then %s install -y xl2tpd; else %s install -y gcc make libpcap-devel git && workdir=$(mktemp -d) && git clone --depth 1 https://github.com/xelerance/xl2tpd.git "$workdir/xl2tpd" && make -C "$workdir/xl2tpd" && install -m 0755 "$workdir/xl2tpd/xl2tpd" /usr/local/sbin/xl2tpd && rm -rf "$workdir"; fi`, pkg, pkg, pkg) +} + // ---- 一键包管理器安装 ---- // InstallViaPackageManager 通过系统包管理器一键安装协议依赖。 @@ -494,7 +497,7 @@ type BinaryStatus struct { Name string `json:"name"` Installed bool `json:"installed"` Path string `json:"path"` - Source string `json:"source"` // managed / system / undeployed + Source string `json:"source"` // managed / system / undeployed Version string `json:"version"` CanDeploy bool `json:"can_deploy"` // 是否支持平台下载(仅 frp) InstallCmd string `json:"install_cmd"` // 包管理器安装命令(非 frp) diff --git a/backend/internal/binmgr/binmgr_test.go b/backend/internal/binmgr/binmgr_test.go new file mode 100644 index 0000000..b5bc309 --- /dev/null +++ b/backend/internal/binmgr/binmgr_test.go @@ -0,0 +1,17 @@ +package binmgr + +import ( + "strings" + "testing" +) + +func TestInstallHintL2TPDnfFallback(t *testing.T) { + hint := InstallHint("l2tp") + commands, _ := hint["all_commands"].(map[string]string) + cmd := commands["dnf"] + for _, want := range []string{"dnf install -y strongswan", "xelerance/xl2tpd", "libpcap-devel"} { + if !strings.Contains(cmd, want) { + t.Fatalf("L2TP dnf install command missing %q: %s", want, cmd) + } + } +} diff --git a/backend/internal/service/common.go b/backend/internal/service/common.go index e8e7612..ed690b8 100644 --- a/backend/internal/service/common.go +++ b/backend/internal/service/common.go @@ -44,6 +44,15 @@ func LookBin(names ...string) (string, bool) { if p, err := exec.LookPath(n); err == nil { return p, true } + // systemd services often omit /usr/local/{s,}bin from PATH. Locally + // compiled dependencies such as xl2tpd are conventionally installed + // there, so discover them without relying on the caller's environment. + for _, dir := range []string{"/usr/local/sbin", "/usr/local/bin", "/usr/sbin", "/usr/bin"} { + p := filepath.Join(dir, n) + if info, err := os.Stat(p); err == nil && !info.IsDir() && info.Mode()&0o111 != 0 { + return p, true + } + } } return "", false } @@ -78,12 +87,52 @@ func RequireIPv4Forwarding(component string) error { return fmt.Errorf("无法启动 %s:未检测到 sysctl,无法确认 net.ipv4.ip_forward", component) } out, err := Run(sysctl, "-n", "net.ipv4.ip_forward") + if err == nil && strings.TrimSpace(out) == "1" { + return nil + } + // A VPN service needs host forwarding to route traffic from tun/xfrm + // interfaces. Enable it at start instead of requiring a manual pre-step. + if _, err := Run(sysctl, "-w", "net.ipv4.ip_forward=1"); err != nil { + return fmt.Errorf("无法启动 %s:自动启用 net.ipv4.ip_forward 失败: %w", component, err) + } + out, err = Run(sysctl, "-n", "net.ipv4.ip_forward") if err != nil || strings.TrimSpace(out) != "1" { - return fmt.Errorf("无法启动 %s:请先执行 sysctl -w net.ipv4.ip_forward=1", component) + return fmt.Errorf("无法启动 %s:net.ipv4.ip_forward 未成功启用", component) } return nil } +// EnsureStrongSwanVICI makes sure swanctl can reach charon's VICI socket. +// RPM-based distributions commonly install the binary without enabling the +// strongswan.service unit, which otherwise produces a misleading load error. +func EnsureStrongSwanVICI(swanctl string) error { + if _, err := Run(swanctl, "--list-conns"); err == nil { + return nil + } + systemctl, ok := LookBin("systemctl") + if !ok { + return fmt.Errorf("strongSwan 守护进程未运行,且未检测到 systemctl 以启动 charon") + } + var lastErr error + for _, unit := range []string{"strongswan.service", "strongswan-swanctl.service"} { + if _, err := Run(systemctl, "enable", "--now", unit); err != nil { + lastErr = err + continue + } + // Give systemd a short interval to create charon's VICI socket. + for range 10 { + if _, err := Run(swanctl, "--list-conns"); err == nil { + return nil + } + time.Sleep(100 * time.Millisecond) + } + } + if lastErr != nil { + return fmt.Errorf("无法启动 strongSwan 守护进程: %w", lastErr) + } + return fmt.Errorf("strongSwan 守护进程已启动但 VICI 接口不可用") +} + // RequireUDPPortAvailable checks a port before handing it to a daemon. This is // deliberately a real bind check instead of parsing process output, so it also // catches listeners started outside this platform. @@ -230,6 +279,69 @@ func RemoveInstance(t *model.Tunnel) error { return os.RemoveAll(InstanceDir(t)) } +// SnapshotInstance captures one tunnel's generated files before a live +// configuration update. It intentionally excludes no files: protocol-specific +// keys, PID metadata and auxiliary scripts must be restored as one unit. +func SnapshotInstance(t *model.Tunnel) (string, error) { + source := InstanceDir(t) + parent := filepath.Dir(source) + if err := os.MkdirAll(parent, 0o700); err != nil { + return "", err + } + backup, err := os.MkdirTemp(parent, ".opsx-backup-") + if err != nil { + return "", err + } + if _, err := os.Stat(source); os.IsNotExist(err) { + return backup, nil + } else if err != nil { + _ = os.RemoveAll(backup) + return "", err + } + if err := copyTree(source, backup); err != nil { + _ = os.RemoveAll(backup) + return "", err + } + return backup, nil +} + +// RestoreInstanceSnapshot replaces generated files with a previous snapshot. +func RestoreInstanceSnapshot(t *model.Tunnel, backup string) error { + if err := os.RemoveAll(InstanceDir(t)); err != nil { + return err + } + if err := os.MkdirAll(InstanceDir(t), 0o700); err != nil { + return err + } + return copyTree(backup, InstanceDir(t)) +} + +func CleanupInstanceSnapshot(backup string) { _ = os.RemoveAll(backup) } + +func copyTree(source, destination string) error { + return filepath.Walk(source, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(source, path) + if err != nil { + return err + } + target := filepath.Join(destination, rel) + if info.IsDir() { + return os.MkdirAll(target, info.Mode().Perm()) + } + if !info.Mode().IsRegular() { + return nil + } + content, err := os.ReadFile(path) + if err != nil { + return err + } + return os.WriteFile(target, content, info.Mode().Perm()) + }) +} + // Str / StrOr / Num / Bool 从配置 map 中安全取值 func Str(cfg map[string]interface{}, key string) string { if v, ok := cfg[key]; ok { diff --git a/backend/internal/service/common_test.go b/backend/internal/service/common_test.go new file mode 100644 index 0000000..5090d4f --- /dev/null +++ b/backend/internal/service/common_test.go @@ -0,0 +1,39 @@ +package service + +import ( + "os" + "path/filepath" + "testing" + + "vpn-platform/backend/internal/model" +) + +func TestInstanceSnapshotRestore(t *testing.T) { + base := t.TempDir() + t.Setenv("VPN_DATA_DIR", base) + tunnel := &model.Tunnel{ID: 42, Protocol: "openvpn"} + if _, err := WriteConfig(tunnel, "server.conf", "old-config"); err != nil { + t.Fatal(err) + } + backup, err := SnapshotInstance(tunnel) + if err != nil { + t.Fatal(err) + } + defer CleanupInstanceSnapshot(backup) + if _, err := WriteConfig(tunnel, "server.conf", "new-config"); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(InstanceDir(tunnel), "temporary"), []byte("new"), 0o600); err != nil { + t.Fatal(err) + } + if err := RestoreInstanceSnapshot(tunnel, backup); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(filepath.Join(InstanceDir(tunnel), "server.conf")) + if err != nil || string(got) != "old-config" { + t.Fatalf("restored config = %q, err=%v", got, err) + } + if _, err := os.Stat(filepath.Join(InstanceDir(tunnel), "temporary")); !os.IsNotExist(err) { + t.Fatalf("temporary file remained after restore: %v", err) + } +} diff --git a/backend/internal/service/gre/gre.go b/backend/internal/service/gre/gre.go index a40e101..306094a 100644 --- a/backend/internal/service/gre/gre.go +++ b/backend/internal/service/gre/gre.go @@ -5,6 +5,7 @@ package gre import ( "fmt" "net/netip" + "regexp" "strings" "vpn-platform/backend/internal/core" @@ -15,6 +16,8 @@ import ( type Service struct{} +var interfaceNamePattern = regexp.MustCompile(`^[a-zA-Z0-9_.-]{1,15}$`) + func New() *Service { return &Service{} } func (s *Service) Name() string { return "gre" } @@ -38,18 +41,34 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, out := service.CloneConfig(cfg) // Validate IPs - if _, err := netip.ParseAddr(service.Str(out, "local_endpoint")); err != nil { + localEndpoint, err := netip.ParseAddr(service.Str(out, "local_endpoint")) + if err != nil || !localEndpoint.Is4() { return nil, fmt.Errorf("local_endpoint 必须是有效的 IP 地址") } - if _, err := netip.ParseAddr(service.Str(out, "remote_endpoint")); err != nil { + remoteEndpoint, err := netip.ParseAddr(service.Str(out, "remote_endpoint")) + if err != nil || !remoteEndpoint.Is4() { return nil, fmt.Errorf("remote_endpoint 必须是有效的 IP 地址") } - if _, err := netip.ParsePrefix(service.Str(out, "local_tunnel_ip")); err != nil { + localTunnel, err := netip.ParsePrefix(service.Str(out, "local_tunnel_ip")) + if err != nil || !localTunnel.Addr().Is4() { return nil, fmt.Errorf("local_tunnel_ip 必须是 CIDR 格式(如 10.10.0.1/30)") } - if _, err := netip.ParsePrefix(service.Str(out, "remote_tunnel_ip")); err != nil { + remoteTunnel, err := netip.ParsePrefix(service.Str(out, "remote_tunnel_ip")) + if err != nil || !remoteTunnel.Addr().Is4() { return nil, fmt.Errorf("remote_tunnel_ip 必须是 CIDR 格式(如 10.10.0.2/30)") } + if localTunnel == remoteTunnel || localTunnel.Masked() != remoteTunnel.Masked() || localTunnel.Bits() != remoteTunnel.Bits() { + return nil, fmt.Errorf("local_tunnel_ip 与 remote_tunnel_ip 必须位于同一隧道子网") + } + if remoteSubnet := service.Str(out, "remote_subnet"); remoteSubnet != "" { + prefix, err := netip.ParsePrefix(remoteSubnet) + if err != nil || !prefix.Addr().Is4() { + return nil, fmt.Errorf("remote_subnet 必须是 IPv4 CIDR") + } + } + if outbound := service.Str(out, "outbound_interface"); outbound != "" && !interfaceNamePattern.MatchString(outbound) { + return nil, fmt.Errorf("outbound_interface 包含非法字符") + } // Defaults if service.Num(out, "ttl", 0) == 0 { @@ -58,6 +77,12 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, if service.Num(out, "mtu", 0) == 0 { out["mtu"] = 1476 } + if ttl := int(service.Num(out, "ttl", 255)); ttl < 1 || ttl > 255 { + return nil, fmt.Errorf("ttl 必须在 1-255") + } + if mtu := int(service.Num(out, "mtu", 1476)); mtu < 576 || mtu > 9000 { + return nil, fmt.Errorf("mtu 必须在 576-9000") + } return out, nil } diff --git a/backend/internal/service/gre/gre_test.go b/backend/internal/service/gre/gre_test.go new file mode 100644 index 0000000..80ffa9b --- /dev/null +++ b/backend/internal/service/gre/gre_test.go @@ -0,0 +1,31 @@ +package gre + +import "testing" + +func TestValidateGRE(t *testing.T) { + cfg, err := New().Validate(map[string]interface{}{ + "local_endpoint": "198.51.100.10", + "remote_endpoint": "203.0.113.20", + "local_tunnel_ip": "10.255.0.1/30", + "remote_tunnel_ip": "10.255.0.2/30", + "remote_subnet": "192.168.10.0/24", + }) + if err != nil { + t.Fatal(err) + } + if cfg["ttl"] != 255 || cfg["mtu"] != 1476 { + t.Fatalf("defaults not applied: %#v", cfg) + } +} + +func TestValidateGRERejectsDifferentTunnelNetworks(t *testing.T) { + _, err := New().Validate(map[string]interface{}{ + "local_endpoint": "198.51.100.10", + "remote_endpoint": "203.0.113.20", + "local_tunnel_ip": "10.255.0.1/30", + "remote_tunnel_ip": "10.255.1.2/30", + }) + if err == nil { + t.Fatal("expected tunnel subnet validation error") + } +} diff --git a/backend/internal/service/ikev2/ikev2.go b/backend/internal/service/ikev2/ikev2.go index 9ef0c78..006c4ef 100644 --- a/backend/internal/service/ikev2/ikev2.go +++ b/backend/internal/service/ikev2/ikev2.go @@ -57,9 +57,6 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, return nil, fmt.Errorf("%s 包含配置文件不允许的字符", key) } } - if service.Str(out, "username") != "" && strings.ContainsAny(service.Str(out, "username"), " \t\r\n\"") { - return nil, fmt.Errorf("username 不能包含空白或引号") - } return out, nil } @@ -78,31 +75,18 @@ func (s *Service) Create(t *model.Tunnel) error { } secrets["psk"] = psk } - if pw := service.Str(cfg, "password"); pw != "" { - secrets["password"] = pw - } - username := service.StrOr(cfg, "username", "vpnuser") dialUsers, err := service.LoadDialUsers(t.ID) if err != nil { return err } - if service.Str(secrets, "password") == "" && len(dialUsers) == 0 { - // 自动生成一个默认拨号密码,避免空库无法联调。 - pw, err := service.RandomSecret(12) - if err != nil { - return err - } - secrets["password"] = pw - } - - conf := s.swanctlConf(t.ID, cfg, service.Str(secrets, "psk"), username, service.Str(secrets, "password"), dialUsers) + conf := s.swanctlConf(t.ID, cfg, service.Str(secrets, "psk"), "", "", dialUsers) if _, err := service.WriteConfig(t, "swanctl-ikev2.conf", conf); err != nil { return err } - if _, err := service.WriteConfig(t, "client-guide.txt", s.clientGuide(cfg, secrets, username)); err != nil { + if _, err := service.WriteConfig(t, "client-guide.txt", s.clientGuide(cfg, secrets)); err != nil { return err } - public, sec := service.MergeSecrets(cfg, "psk", "password") + public, sec := service.MergeSecrets(cfg, "psk") for k, v := range secrets { sec[k] = v } @@ -188,7 +172,7 @@ pools { ) } -func (s *Service) clientGuide(cfg, secrets map[string]interface{}, username string) string { +func (s *Service) clientGuide(cfg, secrets map[string]interface{}) string { return fmt.Sprintf(`======== IKEv2 远程接入参数 ======== 服务器地址 : %s @@ -198,8 +182,6 @@ VPN 类型 : IKEv2 机器认证 : 预共享密钥 (PSK) 预共享密钥 : %s 用户认证 : 用户名 + 密码(EAP-MSCHAPv2) -默认用户名 : %s -默认密码 : %s 地址池 : %s DNS : %s @@ -208,15 +190,13 @@ DNS : %s 服务器/远程 ID 填上表;认证用用户名密码;共享密钥填 PSK - Windows : 设置 → 网络和 Internet → VPN → 添加 IKEv2 - Android : 系统 VPN 或 strongSwan 客户端,类型选 IKEv2 EAP -- 更多账号 : 在平台「VPN 拨号用户」中为本隧道添加用户后重新生成配置 +- 账号密码 : 在平台「VPN 拨号用户」中为本隧道创建后使用 注意:修改拨号用户后需在专线列表对该隧道执行「停止 → 启动」或重新保存配置以刷新 EAP 密钥。 `, service.StrOr(cfg, "public_endpoint", "<服务器公网 IP 或域名>"), service.Str(cfg, "local_id"), service.Str(secrets, "psk"), - username, - service.Str(secrets, "password"), service.Str(cfg, "address_pool"), service.StrOr(cfg, "dns", service.DefaultDNS()), ) @@ -228,7 +208,7 @@ func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) { return "", err } secrets, _ := store.TunnelSecrets(t) - return s.clientGuide(cfg, secrets, service.StrOr(cfg, "username", "vpnuser")), nil + return s.clientGuide(cfg, secrets), nil } func (s *Service) Start(t *model.Tunnel) error { @@ -240,6 +220,9 @@ func (s *Service) Start(t *model.Tunnel) error { if err := service.RequireRoot("IKEv2"); err != nil { return err } + if err := service.EnsureStrongSwanVICI(bin); err != nil { + return fmt.Errorf("无法启动 IKEv2:%w", err) + } if err := service.RequireIPv4Forwarding("IKEv2"); err != nil { return err } diff --git a/backend/internal/service/ipsec/ipsec.go b/backend/internal/service/ipsec/ipsec.go index d2571ba..6ec6c7a 100644 --- a/backend/internal/service/ipsec/ipsec.go +++ b/backend/internal/service/ipsec/ipsec.go @@ -327,6 +327,9 @@ func (s *Service) Start(t *model.Tunnel) error { if err := service.RequireRoot("IPSec"); err != nil { return err } + if err := service.EnsureStrongSwanVICI(bin); err != nil { + return fmt.Errorf("无法启动 IPSec:%w", err) + } if err := service.RequireIPv4Forwarding("IPSec"); err != nil { return err } diff --git a/backend/internal/service/l2tp/l2tp.go b/backend/internal/service/l2tp/l2tp.go index c137793..5170fd3 100644 --- a/backend/internal/service/l2tp/l2tp.go +++ b/backend/internal/service/l2tp/l2tp.go @@ -2,6 +2,8 @@ package l2tp import ( + "crypto/rand" + "encoding/hex" "fmt" "net/netip" "os" @@ -103,20 +105,17 @@ func (s *Service) Create(t *model.Tunnel) error { if psk := service.Str(cfg, "psk"); psk != "" { secrets["psk"] = psk } else if service.Str(secrets, "psk") == "" { - return fmt.Errorf("缺少 IPSec 预共享密钥 psk") + // A per-tunnel key avoids accidentally sharing a weak UI default. + buf := make([]byte, 24) + if _, err := rand.Read(buf); err != nil { + return fmt.Errorf("生成 IPSec 预共享密钥失败: %w", err) + } + secrets["psk"] = hex.EncodeToString(buf) } - username := service.StrOr(cfg, "username", "vpnuser") - if pw := service.Str(cfg, "password"); pw != "" { - secrets["password"] = pw - } - // 允许仅依赖拨号用户表(VPNUser)而无隧道级默认密码。 dialUsers, err := service.LoadDialUsers(t.ID) if err != nil { return err } - if service.Str(secrets, "password") == "" && len(dialUsers) == 0 { - return fmt.Errorf("缺少拨号密码:请填写隧道默认 password,或在「VPN 拨号用户」中添加本隧道用户") - } // 1) IPSec 层(transport 模式,L2TP over IPSec) swanConf := fmt.Sprintf(`connections { @@ -180,7 +179,7 @@ mru 1400 require-mschap-v2 `, service.Str(cfg, "dns")) - chapSecrets := buildChapSecrets(username, service.Str(secrets, "password"), dialUsers) + chapSecrets := buildChapSecrets("", "", dialUsers) for name, content := range map[string]string{ "swanctl-l2tp.conf": swanConf, @@ -192,7 +191,7 @@ require-mschap-v2 return err } } - public, sec := service.MergeSecrets(cfg, "psk", "password") + public, sec := service.MergeSecrets(cfg, "psk") for k, v := range secrets { sec[k] = v } @@ -204,8 +203,6 @@ func (s *Service) RefreshAuth(t *model.Tunnel) error { return s.Create(t) } - - func buildChapSecrets(defaultUser, defaultPass string, users []service.DialUser) string { var b strings.Builder b.WriteString("# client server secret IP addresses\n") @@ -236,19 +233,17 @@ func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) { 服务器地址 : %s VPN 类型 : L2TP/IPSec PSK(预共享密钥) IPSec PSK : %s -用户名 : %s -密码 : %s 客户端配置指引: - iOS/macOS : 设置 → VPN → 添加 L2TP,填入服务器/账号/密码/密钥 - Windows : 设置 → VPN → L2TP/IPsec 预共享密钥,需注册表开启 NAT-T - Android : 部分新机型已移除 L2TP,建议改用 WireGuard - 路由器 : 选择 L2TP 拨号,填入上述参数 + +账号密码请在「VPN 拨号用户」中创建或导入后使用。 `, service.StrOr(cfg, "public_endpoint", "<服务器公网 IP>"), secrets["psk"], - service.StrOr(cfg, "username", "vpnuser"), - secrets["password"], ), nil } @@ -262,6 +257,9 @@ func (s *Service) Start(t *model.Tunnel) error { if err := service.RequireRoot("L2TP/IPSec"); err != nil { return err } + if err := service.EnsureStrongSwanVICI(swanctl); err != nil { + return fmt.Errorf("无法启动 L2TP/IPSec:%w", err) + } if err := service.RequireIPv4Forwarding("L2TP/IPSec"); err != nil { return err } @@ -286,7 +284,9 @@ func (s *Service) Start(t *model.Tunnel) error { _ = s.removeFirewall(t, cfg) return err } - if err := service.StartDaemon(t, xl2tpd, "-c", dir+"/xl2tpd.conf", "-p", dir+"/xl2tpd.pid", "-D"); err != nil { + // Source-built xl2tpd defaults its control socket to /var/run/xl2tpd, + // which is absent on minimal TencentOS images. Keep it instance-scoped. + if err := service.StartDaemon(t, xl2tpd, "-c", dir+"/xl2tpd.conf", "-p", dir+"/xl2tpd.pid", "-C", dir+"/l2tp-control", "-D"); err != nil { _ = s.unload(swanctl, dir+"/swanctl-l2tp.conf") _ = s.removeFirewall(t, cfg) return err diff --git a/backend/internal/service/openvpn/openvpn.go b/backend/internal/service/openvpn/openvpn.go index 3cc8a27..3861a61 100644 --- a/backend/internal/service/openvpn/openvpn.go +++ b/backend/internal/service/openvpn/openvpn.go @@ -40,6 +40,7 @@ const ( otpPeriod = 30 otpDigits = 6 otpDefaultSlop = 90 + pkiExpireDays = 99 * 365 ) type Service struct{} @@ -75,7 +76,7 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, return nil, fmt.Errorf("port 必须是 1-65535 的整数") } if service.Str(out, "proto") == "" { - out["proto"] = "udp" + out["proto"] = "tcp-server" } // 前端/API 可用 udp | tcp | tcp-server;服务端统一写成 udp 或 tcp-server。 switch strings.ToLower(service.Str(out, "proto")) { @@ -105,7 +106,7 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, return nil, fmt.Errorf("dns 必须是 IP 地址") } if service.Str(out, "auth_mode") == "" { - out["auth_mode"] = "certificate" + out["auth_mode"] = "password" } switch service.Str(out, "auth_mode") { case "certificate", "password", "certificate+password": @@ -151,25 +152,23 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, // 默认 password_is_cr=1:期望 static-challenge / SCRV1 响应格式。 out["otp_password_is_cr"] = true } - if service.Str(out, "push_routes") != "" { - for _, r := range strings.Split(service.Str(out, "push_routes"), ",") { - r = strings.TrimSpace(r) - if r == "" { - continue - } - if _, _, err := net.ParseCIDR(r); err != nil { - return nil, fmt.Errorf("push_routes 含非法 CIDR: %s", r) - } - } + if normalized, err := normalizePushRoutes(service.Str(out, "push_routes")); err != nil { + return nil, err + } else { + out["push_routes"] = normalized } if _, ok := out["redirect_gateway"]; !ok { - // 未填 push_routes 时默认全流量;有 push_routes 时默认仅推内网。 - out["redirect_gateway"] = service.Str(out, "push_routes") == "" + // 未填有效路由时默认全流量;只有注释不应改变默认出口。 + routes, _ := parsePushRoutes(service.Str(out, "push_routes")) + out["redirect_gateway"] = len(routes) == 0 } if _, ok := out["pki_auto_init"]; !ok { out["pki_auto_init"] = true } - if service.Bool(out, "enable_nat", false) && service.Str(out, "egress_interface") == "" { + if _, ok := out["enable_nat"]; !ok { + out["enable_nat"] = true + } + if service.Bool(out, "enable_nat", true) && service.Str(out, "egress_interface") == "" { if iface, err := service.DefaultRouteInterface(); err == nil { out["egress_interface"] = iface } @@ -235,6 +234,66 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, return out, nil } +func normalizePushRoutes(raw string) (string, error) { + lines := make([]string, 0) + for _, line := range strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + comment, routePart := "", trimmed + if idx := strings.Index(routePart, "#"); idx >= 0 { + comment, routePart = strings.TrimSpace(routePart[idx:]), strings.TrimSpace(routePart[:idx]) + } + routes, err := parsePushRoutes(routePart) + if err != nil { + return "", err + } + if len(routes) == 0 && comment != "" { + lines = append(lines, comment) + continue + } + if len(routes) > 0 { + line = strings.Join(routes, ", ") + if comment != "" { + line += " " + comment + } + lines = append(lines, line) + } + } + return strings.Join(lines, "\n"), nil +} + +func parsePushRoutes(raw string) ([]string, error) { + routes := make([]string, 0) + for _, line := range strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n") { + if idx := strings.Index(line, "#"); idx >= 0 { + line = line[:idx] + } + for _, route := range strings.Split(line, ",") { + route = strings.TrimSpace(route) + if route == "" { + continue + } + if !strings.Contains(route, "/") { + ip := net.ParseIP(route).To4() + if ip == nil { + return nil, fmt.Errorf("push_routes 含非法 IP 或 CIDR: %s", route) + } + route = ip.String() + "/32" + } + ip, network, err := net.ParseCIDR(route) + if err != nil || ip.To4() == nil { + return nil, fmt.Errorf("push_routes 含非法 IPv4 CIDR: %s", route) + } + routes = append(routes, ip.Mask(network.Mask).String()+"/"+strconv.Itoa(maskSize(network.Mask))) + } + } + return routes, nil +} + +func maskSize(mask net.IPMask) int { size, _ := mask.Size(); return size } + func parseSubnet(v string) (net.IP, net.IPMask, error) { f := strings.Fields(v) if len(f) != 2 { @@ -296,17 +355,14 @@ func (s *Service) serverConfig(t *model.Tunnel, cfg map[string]interface{}) stri crl := filepath.Join(dir, "pki", "crl.pem") fmt.Fprintf(&b, "ca %q\ncert %q\nkey %q\ndh %q\ntls-crypt %q\ncrl-verify %q\n", ca, cert, key, dh, ta, crl) // 地址池与拓扑 - 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")) + fmt.Fprintf(&b, "topology subnet\nserver %s\n", service.StrOr(cfg, "subnet", "10.10.0.0 255.255.255.0")) // push:DNS / 全流量 / 内网路由 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") } - for _, r := range strings.Split(service.Str(cfg, "push_routes"), ",") { - r = strings.TrimSpace(r) - if r == "" { - continue - } + routes, _ := parsePushRoutes(service.Str(cfg, "push_routes")) + for _, r := range routes { ip, network, err := net.ParseCIDR(r) if err != nil { continue @@ -316,7 +372,7 @@ func (s *Service) serverConfig(t *model.Tunnel, cfg map[string]interface{}) stri } // 会话保活(生产 keepalive 5 30) fmt.Fprintf(&b, "keepalive %d %d\n", int(service.Num(cfg, "keepalive_interval", 5)), int(service.Num(cfg, "keepalive_timeout", 30))) - fmt.Fprintf(&b, "data-ciphers %s:AES-128-GCM\npersist-key\npersist-tun\n", service.StrOr(cfg, "cipher", "AES-256-GCM")) + fmt.Fprintf(&b, "data-ciphers %s:AES-128-GCM\ndata-ciphers-fallback %s\npersist-tun\n", service.StrOr(cfg, "cipher", "AES-256-GCM"), service.StrOr(cfg, "cipher", "AES-256-GCM")) // reneg-sec:生产/MFA 常 0,避免周期性 TLS 重协商 fmt.Fprintf(&b, "reneg-sec %d\n", int(service.Num(cfg, "reneg_sec", 0))) if service.Bool(cfg, "duplicate_cn", false) { @@ -372,6 +428,10 @@ func (s *Service) InitializePKI(t *model.Tunnel) error { if err := os.MkdirAll(dir, 0o700); err != nil { return err } + // Any PKI that failed the readiness check cannot start a server safely. + if err := os.RemoveAll(filepath.Join(dir, "pki")); err != nil { + return fmt.Errorf("清理不完整的 OpenVPN PKI 失败: %w", err) + } if err := runEasyRSA(dir, easy, "init-pki"); err != nil { return err } @@ -403,9 +463,16 @@ func easyRSAPath() string { if p, ok := service.LookBin("easyrsa"); ok { return p } - for _, p := range []string{"/usr/share/easy-rsa/easyrsa", "/usr/local/share/easy-rsa/easyrsa"} { - if st, err := os.Stat(p); err == nil && st.Mode().IsRegular() { - return p + for _, pattern := range []string{ + "/usr/share/easy-rsa/easyrsa", + "/usr/share/easy-rsa/*/easyrsa", + "/usr/local/share/easy-rsa/easyrsa", + } { + paths, _ := filepath.Glob(pattern) + for _, p := range paths { + if st, err := os.Stat(p); err == nil && st.Mode().IsRegular() { + return p + } } } return "" @@ -413,7 +480,17 @@ func easyRSAPath() string { func runEasyRSA(dir, binary string, args ...string) error { cmd := exec.Command(binary, args...) cmd.Dir = dir - cmd.Env = append(os.Environ(), "EASYRSA_BATCH=1", "EASYRSA_REQ_CN=OPSX-VPN-CA") + cmd.Env = append(os.Environ(), + "EASYRSA_BATCH=1", + fmt.Sprintf("EASYRSA_CA_EXPIRE=%d", pkiExpireDays), + fmt.Sprintf("EASYRSA_CERT_EXPIRE=%d", pkiExpireDays), + ) + // Easy-RSA 3.1 rejects EASYRSA_REQ_CN for build-server/client-full because + // those commands derive the common name from their positional filename. + // It is only needed when the CA is created. + if len(args) >= 2 && args[len(args)-2] == "build-ca" { + cmd.Env = append(cmd.Env, "EASYRSA_REQ_CN=OPSX-VPN-CA") + } out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("easy-rsa %s 失败: %v: %s", strings.Join(args, " "), err, out) @@ -430,7 +507,8 @@ func pkiReady(t *model.Tunnel) error { } func pkiScript(t *model.Tunnel) string { easy := shellQuote(easyRSAPathOrDefault()) - return "#!/bin/sh\nset -eu\ncd " + shellQuote(service.InstanceDir(t)) + "\nexport EASYRSA_BATCH=1 EASYRSA_REQ_CN=OPSX-VPN-CA\n" + easy + " init-pki\n" + easy + " --batch build-ca nopass\n" + easy + " --batch build-server-full server nopass\n" + easy + " gen-dh\n" + easy + " gen-crl\nopenvpn --genkey secret ta.key\necho 'OpenVPN PKI initialized'\n" + expires := "EASYRSA_BATCH=1 EASYRSA_CA_EXPIRE=" + strconv.Itoa(pkiExpireDays) + " EASYRSA_CERT_EXPIRE=" + strconv.Itoa(pkiExpireDays) + return "#!/bin/sh\nset -eu\ncd " + shellQuote(service.InstanceDir(t)) + "\n" + easy + " init-pki\nEASYRSA_REQ_CN=OPSX-VPN-CA " + expires + " " + easy + " --batch build-ca nopass\n" + expires + " " + easy + " --batch build-server-full server nopass\n" + expires + " " + easy + " gen-dh\n" + expires + " " + easy + " gen-crl\nopenvpn --genkey secret ta.key\necho 'OpenVPN PKI initialized'\n" } func easyRSAPathOrDefault() string { if p := easyRSAPath(); p != "" { @@ -560,7 +638,8 @@ func (s *Service) GenerateUserConfig(t *model.Tunnel, user *model.VPNUser) (stri } } var b strings.Builder - fmt.Fprintf(&b, "client\ndev tun\nproto %s\nremote %s %d\nresolv-retry infinite\nnobind\npersist-key\npersist-tun\nremote-cert-tls server\ndata-ciphers %s:AES-128-GCM\nverb 3\n", clientProto(service.StrOr(cfg, "proto", "udp")), service.StrOr(cfg, "public_endpoint", "your-server-ip"), int(service.Num(cfg, "port", 1194)), service.StrOr(cfg, "cipher", "AES-256-GCM")) + cipher := service.StrOr(cfg, "cipher", "AES-256-GCM") + fmt.Fprintf(&b, "client\ndev tun\nproto %s\nremote %s %d\nresolv-retry infinite\nnobind\npersist-tun\nremote-cert-tls server\ndata-ciphers %s:AES-128-GCM\ndata-ciphers-fallback %s\nverb 3\n", clientProto(service.StrOr(cfg, "proto", "udp")), service.StrOr(cfg, "public_endpoint", "your-server-ip"), int(service.Num(cfg, "port", 1194)), cipher, cipher) if mode == "password" || mode == "certificate+password" { b.WriteString("auth-user-pass\n") b.WriteString("auth-nocache\n") @@ -596,6 +675,11 @@ func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) { } username := service.Str(cfg, "client_username") if username == "" { + // Password-only authentication has no per-user certificate. Generate a + // reusable profile so operators can distribute it before creating users. + if service.StrOr(cfg, "auth_mode", "certificate") == "password" { + return s.GenerateUserConfig(t, &model.VPNUser{}) + } return "", fmt.Errorf("请指定 client_username 或通过拨号用户下载其专属 .ovpn 配置") } var user model.VPNUser @@ -609,8 +693,17 @@ func (s *Service) Start(t *model.Tunnel) error { if _, ok := findOpenVPNBin(); !ok { return fmt.Errorf("无法启动 OpenVPN:未检测到 openvpn") } + cfg, err := store.TunnelConfig(t) + if err != nil { + return err + } if err := pkiReady(t); err != nil { - return fmt.Errorf("无法启动 OpenVPN:PKI 尚未就绪:%w", err) + if !service.Bool(cfg, "pki_auto_init", true) { + return fmt.Errorf("无法启动 OpenVPN:PKI 尚未就绪:%w", err) + } + if initErr := s.InitializePKI(t); initErr != nil { + return fmt.Errorf("无法启动 OpenVPN:自动初始化 PKI 失败: %w", initErr) + } } if err := service.RequireRoot("OpenVPN"); err != nil { return err @@ -618,10 +711,6 @@ func (s *Service) Start(t *model.Tunnel) error { if err := service.RequireIPv4Forwarding("OpenVPN"); err != nil { return err } - cfg, err := store.TunnelConfig(t) - if err != nil { - return err - } port := int(service.Num(cfg, "port", 1194)) if service.StrOr(cfg, "proto", "udp") == "udp" { if err := service.RequireUDPPortAvailable(port, "OpenVPN"); err != nil { @@ -630,7 +719,7 @@ func (s *Service) Start(t *model.Tunnel) error { } else if err := service.RequireTCPPortAvailable(port, "OpenVPN"); err != nil { return err } - if service.Bool(cfg, "enable_nat", false) { + if service.Bool(cfg, "enable_nat", true) { if err := s.installNAT(t, cfg); err != nil { return err } @@ -647,7 +736,7 @@ func (s *Service) Stop(t *model.Tunnel) error { if err := service.KillPid(t); err != nil { firstErr = err } - if cfg, err := store.TunnelConfig(t); err == nil && service.Bool(cfg, "enable_nat", false) { + if cfg, err := store.TunnelConfig(t); err == nil && service.Bool(cfg, "enable_nat", true) { if err := s.removeNAT(t, cfg); err != nil && firstErr == nil { firstErr = err } @@ -677,8 +766,8 @@ func (s *Service) installNAT(t *model.Tunnel, cfg map[string]interface{}) error comment := fmt.Sprintf("opsx-vpn-openvpn-%d", t.ID) rules := [][]string{ {"-t", "nat", "-A", "POSTROUTING", "-s", subnet, "-o", egress, "-m", "comment", "--comment", comment, "-j", "MASQUERADE"}, - {"-A", "FORWARD", "-s", subnet, "-m", "comment", "--comment", comment, "-j", "ACCEPT"}, - {"-A", "FORWARD", "-d", subnet, "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}, + {"-A", "FORWARD", "-i", "tun+", "-o", egress, "-s", subnet, "-m", "comment", "--comment", comment, "-j", "ACCEPT"}, + {"-A", "FORWARD", "-i", egress, "-o", "tun+", "-d", subnet, "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}, } for _, rule := range rules { check := append([]string{}, rule...) @@ -718,6 +807,9 @@ func (s *Service) removeNAT(t *model.Tunnel, cfg map[string]interface{}) error { comment := fmt.Sprintf("opsx-vpn-openvpn-%d", t.ID) candidates := [][]string{ {"-t", "nat", "-D", "POSTROUTING", "-s", subnet, "-o", egress, "-m", "comment", "--comment", comment, "-j", "MASQUERADE"}, + {"-D", "FORWARD", "-i", "tun+", "-o", egress, "-s", subnet, "-m", "comment", "--comment", comment, "-j", "ACCEPT"}, + {"-D", "FORWARD", "-i", egress, "-o", "tun+", "-d", subnet, "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}, + // Rules produced by releases before interface-scoped forwarding. {"-D", "FORWARD", "-s", subnet, "-m", "comment", "--comment", comment, "-j", "ACCEPT"}, {"-D", "FORWARD", "-d", subnet, "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}, } @@ -775,7 +867,7 @@ func (s *Service) collectTraffic(t *model.Tunnel) (int64, int64) { // 收集在线客户端信息(供 sessionmgr 同步会话) var onlineClients []sessionmgr.ClientInfo for _, line := range strings.Split(string(raw), "\n") { - f := strings.Split(line, ",") + f := splitStatusFields(line) if len(f) < 7 || f[0] != "CLIENT_LIST" { continue } @@ -821,6 +913,16 @@ func (s *Service) collectTraffic(t *model.Tunnel) (int64, int64) { sessionmgr.SyncSessions(t.ID, "openvpn", onlineClients) return totalRx, totalTx } + +// splitStatusFields supports OpenVPN status-version 3 (tab-separated) and +// legacy comma-separated status files. +func splitStatusFields(line string) []string { + if strings.Contains(line, "\t") { + return strings.Split(line, "\t") + } + return strings.Split(line, ",") +} + func (s *Service) Delete(t *model.Tunnel) error { return s.Stop(t) } // RunAuthHelper is called by the same opsx-vpn binary from OpenVPN's diff --git a/backend/internal/service/openvpn/openvpn_test.go b/backend/internal/service/openvpn/openvpn_test.go index 0c4df3a..4aca29c 100644 --- a/backend/internal/service/openvpn/openvpn_test.go +++ b/backend/internal/service/openvpn/openvpn_test.go @@ -16,9 +16,12 @@ func TestValidateOpenVPNModes(t *testing.T) { if err != nil { t.Fatal(err) } - if cfg["proto"] != "udp" || cfg["port"] != 1194 { + if cfg["proto"] != "tcp-server" || cfg["port"] != 1194 || cfg["auth_mode"] != "password" { t.Fatalf("defaults not applied: %#v", cfg) } + if cfg["enable_nat"] != true { + t.Fatalf("NAT should default to enabled: %#v", cfg) + } if _, err := New().Validate(map[string]interface{}{"auth_mode": "unknown"}); err == nil { t.Fatal("expected invalid auth mode error") } @@ -31,7 +34,7 @@ func TestValidateOpenVPNModes(t *testing.T) { t.Fatalf("tcp not normalized: %#v", cfg2) } cfg3, err := New().Validate(map[string]interface{}{ - "subnet": "10.8.0.0 255.255.255.0", "push_routes": "192.168.1.0/24", + "subnet": "10.8.0.0 255.255.255.0", "push_routes": "# VPC\n192.168.1.0/24\n# staging\n10.0.0.10", }) if err != nil { t.Fatal(err) @@ -39,6 +42,12 @@ func TestValidateOpenVPNModes(t *testing.T) { if cfg3["redirect_gateway"] != false { t.Fatalf("push_routes should default redirect_gateway off: %#v", cfg3) } + if cfg3["push_routes"] != "# VPC\n192.168.1.0/24\n# staging\n10.0.0.10/32" { + t.Fatalf("push_routes not normalized: %#v", cfg3) + } + if _, err := New().Validate(map[string]interface{}{"subnet": "10.8.0.0 255.255.255.0", "push_routes": "10.0.0.999"}); err == nil { + t.Fatal("expected invalid route to fail") + } // MFA 仅允许密码相关模式 if _, err := New().Validate(map[string]interface{}{ "auth_mode": "certificate", "mfa_mode": "required", "subnet": "10.8.0.0 255.255.255.0", @@ -102,6 +111,7 @@ func TestServerConfigIncludesProductionTunings(t *testing.T) { "max-clients 1000", "duplicate-cn", "via-file", + "data-ciphers-fallback AES-256-GCM", } { if !strings.Contains(raw, want) { t.Fatalf("server.conf missing %q\n%s", want, raw) @@ -110,6 +120,11 @@ func TestServerConfigIncludesProductionTunings(t *testing.T) { if strings.Contains(raw, "via-env") { t.Fatal("should use via-file not via-env") } + for _, obsolete := range []string{"persist-key", "ifconfig-pool-persist"} { + if strings.Contains(raw, obsolete) { + t.Fatalf("server.conf should not contain obsolete %q\n%s", obsolete, raw) + } + } } func TestValidateTOTPSkew(t *testing.T) { @@ -134,3 +149,26 @@ func TestCertificateCNIsSafeAndStable(t *testing.T) { t.Fatalf("unsafe CN: %q", cn) } } + +func TestPKIScriptUses99YearCertificates(t *testing.T) { + script := pkiScript(&model.Tunnel{ID: 1, Protocol: "openvpn"}) + for _, want := range []string{"EASYRSA_CA_EXPIRE=36135", "EASYRSA_CERT_EXPIRE=36135"} { + if !strings.Contains(script, want) { + t.Fatalf("PKI script missing %q\n%s", want, script) + } + } + if !strings.Contains(script, "EASYRSA_REQ_CN=OPSX-VPN-CA") { + t.Fatalf("PKI script should set a CA name: %s", script) + } + if strings.Contains(script, "export EASYRSA_BATCH=1 EASYRSA_REQ_CN") { + t.Fatalf("PKI script must not export the CA CN to server/client certificate commands: %s", script) + } +} + +func TestSplitStatusFieldsSupportsOpenVPNStatusV3(t *testing.T) { + line := "CLIENT_LIST\tadmin\ttcp4-server:203.0.113.1:1194\t10.8.0.2\t\t7161451\t8489433\t2026-07-25 23:32:58\t1784993578\tadmin" + fields := splitStatusFields(line) + if len(fields) != 10 || fields[5] != "7161451" || fields[9] != "admin" { + t.Fatalf("status-version 3 fields parsed incorrectly: %#v", fields) + } +} diff --git a/backend/main.go b/backend/main.go index 1276b17..0ebe7dc 100644 --- a/backend/main.go +++ b/backend/main.go @@ -19,8 +19,8 @@ import ( "vpn-platform/backend/internal/monitor" "vpn-platform/backend/internal/service" "vpn-platform/backend/internal/service/custom" - "vpn-platform/backend/internal/service/gre" "vpn-platform/backend/internal/service/frp" + "vpn-platform/backend/internal/service/gre" "vpn-platform/backend/internal/service/ikev2" "vpn-platform/backend/internal/service/ipsec" "vpn-platform/backend/internal/service/l2tp" @@ -114,9 +114,9 @@ func main() { addr := os.Getenv("VPN_LISTEN_ADDR") if addr == "" { - addr = ":8080" + addr = ":18080" } - log.Printf("VPN 管理平台已启动: http://localhost%s (默认账号 admin / admin123)", addr) + log.Printf("VPN 管理平台已启动: http://localhost%s", addr) log.Printf("协议依赖检测:") for _, p := range mgr.List() { ok, dep := p.Available() diff --git a/frontend/src/components/ProtocolIcon.vue b/frontend/src/components/ProtocolIcon.vue index fad9b4c..c6e176d 100644 --- a/frontend/src/components/ProtocolIcon.vue +++ b/frontend/src/components/ProtocolIcon.vue @@ -33,6 +33,7 @@ const NAMES = computed(() => ({ ipsec: 'IPSec', ikev2: 'IKEv2', l2tp: 'L2TP', + gre: 'GRE', frp: 'FRP', openvpn: 'OpenVPN', custom: t('protocolIcon.custom'), @@ -62,6 +63,9 @@ const ICON_PATHS = { // L2TP — 手机+信号波(移动设备拨号语义) l2tp: 'M7 1C5.9 1 5 1.9 5 3v18c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V3c0-1.1-.9-2-2-2H7zm5 19c-.83 0-1.5-.67-1.5-1.5S11.17 17 12 17s1.5.67 1.5 1.5S12.83 20 12 20zm5-4H7V4h10v12z M9.5 6.5l2 2-2 2-1-1 1-1-1-1 1-1z M14.5 6.5l1 1-1 1 1 1-1 1-2-2 2-2z', + // GRE — two routed sites connected by a tunnel + gre: 'M3 4h6v3H6v10h3v3H3V4zm12 0h6v16h-6v-3h3V7h-3V4zM9 10h6v2H9v-2zm0 4h6v2H9v-2z', + // FRP — 隧道+箭头穿透(内网穿透语义) frp: 'M3 8h2v8H3V8zm16 0h2v8h-2V8zM7 10h10v4H7v-4zm2 1.5l2 1.5-2 1.5v-3zm6 0v3l-2-1.5 2-1.5z M12 6l3 2H9l3-2zm0 12l-3-2h6l-3 2z', diff --git a/frontend/src/components/RouteListInput.vue b/frontend/src/components/RouteListInput.vue new file mode 100644 index 0000000..200e69b --- /dev/null +++ b/frontend/src/components/RouteListInput.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/frontend/src/locales/en-US.json b/frontend/src/locales/en-US.json index c7b1e59..aa5772c 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", @@ -57,9 +57,9 @@ "login": { "title": "VPN Management Platform", "subtitle": "Unified multi-protocol VPN management — simple, secure, and controllable", - "protocols": "WireGuard · IPSec · L2TP · FRP · OpenVPN · Custom Protocol Plugins", + "protocols": "WireGuard · IPSec · IKEv2 · L2TP · GRE · FRP · OpenVPN", "feature1Title": "Multi-Protocol Unified Management", - "feature1Desc": "WireGuard / IPSec / L2TP / FRP / OpenVPN", + "feature1Desc": "WireGuard / IPSec / IKEv2 / L2TP / GRE / OpenVPN", "feature2Title": "Enterprise-Grade Security", "feature2Desc": "AES-GCM encrypted keys + Two-Factor Authentication", "feature3Title": "One-Click Configuration", @@ -89,6 +89,10 @@ "totalTraffic": "Total Traffic ↓↑", "protocolDistribution": "Protocol Distribution", "protocolEngineStatus": "Protocol Engine Status", + "hostReadinessWarning": "Host prerequisites are incomplete. VPN forwarding or NAT may not work correctly.", + "readinessRoot": "Service is not running as root", + "readinessForwarding": "IPv4 forwarding is disabled", + "readinessRoute": "No default egress route detected", "depCheckResult": "System dependency check results", "depReady": "Ready", "demoMode": "Demo Mode", @@ -217,6 +221,15 @@ "sensitiveFieldHint": "Sensitive fields like passwords, PSK, and tokens are not displayed. Leave empty to keep the current value.", "tunnelName": "Tunnel Name", "protocolType": "Protocol Type", + "coreOptions": "Core Connection Settings", + "advancedOptions": "Detailed Settings", + "trafficMode": "Traffic Egress", + "trafficGateway": "All Traffic via VPN Gateway", + "trafficStatic": "Static Routes Only", + "trafficCustom": "Custom", + "trafficGatewayTip": "Routes all client traffic through the VPN gateway with NAT egress.", + "trafficStaticTip": "Pushes only the target networks below and preserves the client's default Internet route.", + "trafficCustomTip": "Control the default route, static routes, and NAT separately in detailed settings.", "keepOriginal": "Leave empty to keep original", "expireTime": "Expiry Time", "expirePlaceholder": "Leave empty for permanent", @@ -241,6 +254,14 @@ "namePlaceholder": "e.g., HQ-Branch Interconnect", "protocolType": "Protocol Type", "reselect": "Reselect", + "coreOptions": "Core Connection Settings", + "trafficMode": "Traffic Egress", + "trafficGateway": "All Traffic via VPN Gateway", + "trafficStatic": "Static Routes Only", + "trafficCustom": "Custom", + "trafficGatewayTip": "Routes all client traffic through the VPN gateway with NAT egress.", + "trafficStaticTip": "Pushes only the target networks below and preserves the client's default Internet route.", + "trafficCustomTip": "Control the default route, static routes, and NAT separately in detailed settings.", "advancedOptions": "Advanced Options", "expireTime": "Expiry Time", "expirePlaceholder": "Leave empty for permanent", @@ -337,6 +358,20 @@ "title": "VPN Dial-in Users", "mfaHint": "OpenVPN Two-Factor Auth (TOTP / Static Challenge / PIN+OTP)", "addUser": "Add User", + "bulkImport": "Bulk Import", + "bulkImportTitle": "Import Platform Users", + "bulkImportHint": "Only OpenVPN supports platform local or LDAP account imports. Create dedicated accounts for L2TP/IKEv2.", + "importSelected": "Import Selected", + "importedCount": "Imported {count} users", + "importFailed": "Bulk import failed", + "selectOpenVPNTunnel": "Select OpenVPN Tunnel", + "selectTunnelAndUsers": "Select an OpenVPN tunnel and platform users", + "searchPlaceholder": "Search username, name, or email", + "allTunnels": "All Tunnels", + "selectedUsers": "{count} users selected", + "batchDelete": "Bulk Delete", + "batchDeleteConfirm": "Delete the selected {count} VPN dial-in users?", + "batchDeleteDone": "Selected VPN dial-in users deleted", "displayName": "Name", "tunnel": "Tunnel", "otpMfa": "OTP / MFA", @@ -359,6 +394,14 @@ "clickRowHint": "Click a row to select and auto-fill", "otpDialogTitle": "Two-Factor Auth — {username}", "otpHint": "TOTP-based two-factor authentication (SHA1 / base32 / 30s / 6 digits), compatible with major Authenticator apps. Supports static challenge and PIN+OTP combo mode.", + "mfaNotEnabled": "Two-factor authentication is not enabled", + "mfaDisabledHint": "Bind an Authenticator app to require a one-time code when this user signs in to VPN.", + "mfaEnabledHint": "This user must provide both their password and a one-time code to sign in to VPN.", + "mfaSetupIntro": "Bind an Authenticator app to protect this user's VPN dial-in access.", + "manualSetup": "Can't scan? Use a manual key", + "verifySetup": "Confirm Binding", + "verifySetupHint": "After scanning in Authenticator, enter the current 6-digit code to verify the binding.", + "securityDetails": "Security Details", "bindStatus": "Bind Status", "boundTotp": "TOTP Bound", "staticPin": "Static PIN", @@ -766,13 +809,6 @@ "label": "Machine Auth PSK", "tip": "Leave empty to auto-generate; shared key for iOS/Android" }, - "username": { - "label": "Default Dial-in User" - }, - "password": { - "label": "Default Dial-in Password", - "tip": "Leave empty to auto-generate; add more users in VPN Dial-in Users" - }, "address_pool": { "label": "Client Address Pool" }, @@ -800,15 +836,8 @@ "label": "Server Public IP" }, "psk": { - "label": "IPSec Pre-Shared Key" - }, - "username": { - "label": "Default Dial-in Username", - "tip": "Add more users for this tunnel in VPN Dial-in Users" - }, - "password": { - "label": "Default Dial-in Password", - "tip": "Leave empty if using only the dial-in user table (at least one method required at creation)" + "label": "IPSec Pre-Shared Key", + "tip": "Leave blank to generate a high-strength random key; retrieve it securely from client configuration after creation" }, "ip_range": { "label": "Client Address Pool" @@ -821,6 +850,22 @@ } } }, + "gre": { + "name": "GRE Tunnel", + "desc": "Standard IP protocol 47 tunnel for point-to-point Layer 3 connectivity between Linux, routers, and firewalls", + "scenes": "Network Device Interconnect / Site-to-Site Routing", + "fields": { + "local_endpoint": { "label": "Local Public IP" }, + "remote_endpoint": { "label": "Remote Public IP" }, + "local_tunnel_ip": { "label": "Local Tunnel IP", "tip": "Use a /30, for example 10.255.0.1/30" }, + "remote_tunnel_ip": { "label": "Remote Tunnel IP", "tip": "Must share the local tunnel IP subnet" }, + "remote_subnet": { "label": "Remote LAN Subnet", "tip": "Adds a static route through the GRE peer" }, + "enable_nat": { "label": "NAT Remote Traffic", "tip": "Enable only when the remote subnet needs Internet egress via this host" }, + "outbound_interface": { "label": "NAT Egress Interface" }, + "ttl": { "label": "TTL" }, + "mtu": { "label": "GRE MTU", "tip": "1476 is typical over public Internet GRE; lower it for additional encapsulation" } + } + }, "frp": { "name": "FRP NAT Traversal", "desc": "TCP/UDP/HTTP port forwarding and reverse proxy for NAT traversal", @@ -941,7 +986,7 @@ "tip": "Password mode aligns with verify-client-cert none + username-as-common-name", "options": { "certificate": "Certificate Only", - "password": "***", + "password": "Username + Password", "certificate+password": "Certificate + Password" } }, @@ -974,8 +1019,10 @@ "tip": "When off, only pushes internal routes below (common in production)" }, "push_routes": { - "label": "Push Internal Routes", - "tip": "CIDR, comma-separated" + "label": "Push Routes", + "tip": "One IP or CIDR per line; # comments are supported", + "placeholder": "# VPC networks\n172.16.0.0/16\n# Staging\n43.129.24.233", + "format_error": "Invalid route format: {route}" }, "enable_nat": { "label": "Enable NAT Outbound" diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json index 53283c6..eef5a9e 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": "本地", @@ -57,9 +57,9 @@ "login": { "title": "VPN 专线管理平台", "subtitle": "统一管理多协议 VPN,配置简单、安全可控", - "protocols": "WireGuard · IPSec · L2TP · FRP · OpenVPN · 自定义协议插件", + "protocols": "WireGuard · IPSec · IKEv2 · L2TP · GRE · FRP · OpenVPN", "feature1Title": "多协议统一管理", - "feature1Desc": "WireGuard / IPSec / L2TP / FRP / OpenVPN", + "feature1Desc": "WireGuard / IPSec / IKEv2 / L2TP / GRE / OpenVPN", "feature2Title": "企业级安全", "feature2Desc": "密钥 AES-GCM 加密 + 双因素认证", "feature3Title": "一键创建配置", @@ -89,6 +89,10 @@ "totalTraffic": "累计流量 ↓↑", "protocolDistribution": "协议分布", "protocolEngineStatus": "协议引擎状态", + "hostReadinessWarning": "主机运行前提未完全满足,VPN 转发或 NAT 可能无法正常工作", + "readinessRoot": "服务未以 root 运行", + "readinessForwarding": "IPv4 转发未启用", + "readinessRoute": "未检测到默认出口路由", "depCheckResult": "系统依赖检测结果", "depReady": "已就绪", "demoMode": "演示模式", @@ -217,6 +221,15 @@ "sensitiveFieldHint": "密码、PSK、Token 等敏感字段不会回显;留空表示继续使用原值。", "tunnelName": "专线名称", "protocolType": "协议类型", + "coreOptions": "核心接入配置", + "advancedOptions": "详细配置", + "trafficMode": "流量出口", + "trafficGateway": "全流量经 VPN 网关", + "trafficStatic": "仅静态路由", + "trafficCustom": "自定义", + "trafficGatewayTip": "客户端全部流量经 VPN 网关转发,并使用 NAT 出网。", + "trafficStaticTip": "仅向客户端推送下方填写的目标网段,不改变其默认互联网出口。", + "trafficCustomTip": "在详细配置中分别控制默认路由、静态路由与 NAT。", "keepOriginal": "留空保持原值", "expireTime": "到期时间", "expirePlaceholder": "留空为永久", @@ -241,6 +254,14 @@ "namePlaceholder": "例如:总部-分支互联", "protocolType": "协议类型", "reselect": "重新选择", + "coreOptions": "核心接入配置", + "trafficMode": "流量出口", + "trafficGateway": "全流量经 VPN 网关", + "trafficStatic": "仅静态路由", + "trafficCustom": "自定义", + "trafficGatewayTip": "客户端全部流量经 VPN 网关转发,并使用 NAT 出网。", + "trafficStaticTip": "仅向客户端推送下方填写的目标网段,不改变其默认互联网出口。", + "trafficCustomTip": "在详细配置中分别控制默认路由、静态路由与 NAT。", "advancedOptions": "高级选项", "expireTime": "到期时间", "expirePlaceholder": "留空为永久", @@ -337,6 +358,20 @@ "title": "VPN 拨号用户", "mfaHint": "OpenVPN 双因素认证(TOTP / 静态挑战 / PIN+OTP)", "addUser": "添加用户", + "bulkImport": "批量导入", + "bulkImportTitle": "批量导入平台用户", + "bulkImportHint": "仅 OpenVPN 支持导入平台本地或 LDAP 用户;L2TP/IKEv2 请创建独立拨号账号。", + "importSelected": "导入选中用户", + "importedCount": "已导入 {count} 个用户", + "importFailed": "批量导入失败", + "selectOpenVPNTunnel": "选择 OpenVPN 隧道", + "selectTunnelAndUsers": "请选择 OpenVPN 隧道和平台用户", + "searchPlaceholder": "搜索用户名、姓名或邮箱", + "allTunnels": "全部隧道", + "selectedUsers": "已选择 {count} 个用户", + "batchDelete": "批量删除", + "batchDeleteConfirm": "确认删除选中的 {count} 个拨号用户?", + "batchDeleteDone": "已删除选中拨号用户", "displayName": "姓名", "tunnel": "隧道", "otpMfa": "OTP / MFA", @@ -359,6 +394,14 @@ "clickRowHint": "点击行即选中并自动填充", "otpDialogTitle": "双因素认证 — {username}", "otpHint": "基于 TOTP 的双因素认证(SHA1 / base32 / 30s / 6 位),兼容主流 Authenticator 应用。支持静态挑战二次输入与 PIN+OTP 组合模式。", + "mfaNotEnabled": "尚未启用双因素认证", + "mfaDisabledHint": "绑定验证器后,此用户登录 VPN 时需要额外输入动态验证码。", + "mfaEnabledHint": "该用户登录 VPN 时需要密码和动态验证码。", + "mfaSetupIntro": "为该用户绑定一个 Authenticator 应用,保护 VPN 拨号登录。", + "manualSetup": "无法扫码?使用手动密钥", + "verifySetup": "确认绑定", + "verifySetupHint": "在 Authenticator 中扫描后,输入当前显示的 6 位验证码完成验证。", + "securityDetails": "安全详情", "bindStatus": "绑定状态", "boundTotp": "已绑定 TOTP", "staticPin": "静态 PIN", @@ -766,13 +809,6 @@ "label": "机器认证 PSK", "tip": "留空自动生成;iOS/Android 共享密钥" }, - "username": { - "label": "默认拨号用户" - }, - "password": { - "label": "默认拨号密码", - "tip": "留空自动生成;更多账号在拨号用户页添加" - }, "address_pool": { "label": "客户端地址池" }, @@ -800,15 +836,8 @@ "label": "服务器公网 IP" }, "psk": { - "label": "IPSec 预共享密钥" - }, - "username": { - "label": "默认拨号用户名", - "tip": "可在「VPN 拨号用户」为本隧道添加更多账号" - }, - "password": { - "label": "默认拨号密码", - "tip": "若只使用拨号用户表可留空(创建时需至少有一种)" + "label": "IPSec 预共享密钥", + "tip": "留空将自动生成高强度随机密钥;请在创建后从客户端配置中安全获取" }, "ip_range": { "label": "客户端地址池" @@ -821,6 +850,22 @@ } } }, + "gre": { + "name": "GRE 隧道", + "desc": "标准 IP 协议 47 隧道,用于 Linux 与路由器、防火墙的点对点三层互联", + "scenes": "网络设备互联 / 跨站点路由", + "fields": { + "local_endpoint": { "label": "本端公网 IP" }, + "remote_endpoint": { "label": "对端公网 IP" }, + "local_tunnel_ip": { "label": "本端隧道 IP", "tip": "建议使用 /30,例如 10.255.0.1/30" }, + "remote_tunnel_ip": { "label": "对端隧道 IP", "tip": "需与本端隧道 IP 位于同一子网" }, + "remote_subnet": { "label": "对端业务网段", "tip": "填写后自动添加经 GRE 对端的静态路由" }, + "enable_nat": { "label": "对端流量 NAT 出网", "tip": "仅当对端业务网段需要经本机访问互联网时启用" }, + "outbound_interface": { "label": "NAT 出站网卡" }, + "ttl": { "label": "TTL" }, + "mtu": { "label": "GRE MTU", "tip": "公网 GRE 常用 1476;链路有额外封装时可继续降低" } + } + }, "frp": { "name": "FRP 内网穿透", "desc": "国内刚需,TCP/UDP/HTTP 端口映射与反向代理", @@ -941,7 +986,7 @@ "tip": "密码模式对齐生产 verify-client-cert none + username-as-common-name", "options": { "certificate": "仅证书", - "password": "***", + "password": "用户名 + 密码", "certificate+password": "证书 + 密码" } }, @@ -974,8 +1019,10 @@ "tip": "关闭后仅推送下方内网路由(生产常只推指定路由)" }, "push_routes": { - "label": "推送内网路由", - "tip": "CIDR 逗号分隔" + "label": "推送路由", + "tip": "IP 或 CIDR,每行一个;支持 # 注释", + "placeholder": "# VPC 网段\n172.16.0.0/16\n# 预发环境\n43.129.24.233", + "format_error": "路由格式无效:{route}" }, "enable_nat": { "label": "启用 NAT 出网" diff --git a/frontend/src/protocols.js b/frontend/src/protocols.js index 7844c88..ca6ee98 100644 --- a/frontend/src/protocols.js +++ b/frontend/src/protocols.js @@ -9,16 +9,16 @@ export const PROTOCOL_META = { icon: 'wireguard', scenes: 'protocols.wireguard.scenes', fields: [ - { key: 'address', label: 'protocols.wireguard.fields.address.label', placeholder: '10.8.0.1/24', required: true, tip: 'protocols.wireguard.fields.address.tip' }, - { key: 'listen_port', label: 'protocols.wireguard.fields.listen_port.label', type: 'number', default: 51820 }, - { key: 'client_address', label: 'protocols.wireguard.fields.client_address.label', placeholder: '10.8.0.2/32', tip: 'protocols.wireguard.fields.client_address.tip' }, - { key: 'public_endpoint', label: 'protocols.wireguard.fields.public_endpoint.label', placeholder: 'vpn.example.com:51820', tip: 'protocols.wireguard.fields.public_endpoint.tip' }, - { key: 'dns', label: 'protocols.wireguard.fields.dns.label', default: '223.5.5.5' }, - { key: 'allowed_ips', label: 'protocols.wireguard.fields.allowed_ips.label', default: '0.0.0.0/0, ::/0', tip: 'protocols.wireguard.fields.allowed_ips.tip' }, - { key: 'keepalive', label: 'protocols.wireguard.fields.keepalive.label', type: 'number', default: 25, tip: 'protocols.wireguard.fields.keepalive.tip' }, - { key: 'mtu', label: 'protocols.wireguard.fields.mtu.label', type: 'number', default: 0, tip: 'protocols.wireguard.fields.mtu.tip' }, - { key: 'enable_nat', label: 'protocols.wireguard.fields.enable_nat.label', type: 'switch', default: false, tip: 'protocols.wireguard.fields.enable_nat.tip' }, - { key: 'egress_interface', label: 'protocols.wireguard.fields.egress_interface.label', placeholder: 'eth0', tip: 'protocols.wireguard.fields.egress_interface.tip', showIf: { enable_nat: true } } + { key: 'address', label: 'protocols.wireguard.fields.address.label', placeholder: '10.8.0.1/24', required: true, tip: 'protocols.wireguard.fields.address.tip', group: 'basic' }, + { key: 'listen_port', label: 'protocols.wireguard.fields.listen_port.label', type: 'number', default: 51820, group: 'basic' }, + { key: 'client_address', label: 'protocols.wireguard.fields.client_address.label', placeholder: '10.8.0.2/32', tip: 'protocols.wireguard.fields.client_address.tip', group: 'basic' }, + { key: 'public_endpoint', label: 'protocols.wireguard.fields.public_endpoint.label', placeholder: 'vpn.example.com:51820', tip: 'protocols.wireguard.fields.public_endpoint.tip', group: 'basic' }, + { key: 'dns', label: 'protocols.wireguard.fields.dns.label', default: '223.5.5.5', group: 'basic' }, + { key: 'allowed_ips', label: 'protocols.wireguard.fields.allowed_ips.label', default: '0.0.0.0/0, ::/0', tip: 'protocols.wireguard.fields.allowed_ips.tip', group: 'routing' }, + { key: 'keepalive', label: 'protocols.wireguard.fields.keepalive.label', type: 'number', default: 25, tip: 'protocols.wireguard.fields.keepalive.tip', group: 'advanced' }, + { key: 'mtu', label: 'protocols.wireguard.fields.mtu.label', type: 'number', default: 0, tip: 'protocols.wireguard.fields.mtu.tip', group: 'advanced' }, + { key: 'enable_nat', label: 'protocols.wireguard.fields.enable_nat.label', type: 'switch', default: false, tip: 'protocols.wireguard.fields.enable_nat.tip', group: 'routing' }, + { key: 'egress_interface', label: 'protocols.wireguard.fields.egress_interface.label', placeholder: 'eth0', tip: 'protocols.wireguard.fields.egress_interface.tip', showIf: { enable_nat: true }, group: 'routing' } ] }, ipsec: { @@ -34,22 +34,22 @@ export const PROTOCOL_META = { { value: 'fortigate', label: 'protocols.ipsec.fields.vendor_template.options.fortigate' }, { value: 'huawei', label: 'protocols.ipsec.fields.vendor_template.options.huawei' }, { value: 'h3c', label: 'protocols.ipsec.fields.vendor_template.options.h3c' } - ], default: 'generic', tip: 'protocols.ipsec.fields.vendor_template.tip' }, - { key: 'public_endpoint', label: 'protocols.ipsec.fields.public_endpoint.label', placeholder: '1.2.3.4', required: true }, - { key: 'remote_gateway', label: 'protocols.ipsec.fields.remote_gateway.label', placeholder: '5.6.7.8', required: true }, - { key: 'local_subnets', label: 'protocols.ipsec.fields.local_subnets.label', placeholder: '192.168.1.0/24', required: true, tip: 'protocols.ipsec.fields.local_subnets.tip' }, - { key: 'remote_subnets', label: 'protocols.ipsec.fields.remote_subnets.label', placeholder: '10.0.0.0/24', required: true }, - { key: 'psk', label: 'protocols.ipsec.fields.psk.label', type: 'password', required: true, secret: true }, + ], default: 'generic', tip: 'protocols.ipsec.fields.vendor_template.tip', group: 'basic' }, + { key: 'public_endpoint', label: 'protocols.ipsec.fields.public_endpoint.label', placeholder: '1.2.3.4', required: true, group: 'basic' }, + { key: 'remote_gateway', label: 'protocols.ipsec.fields.remote_gateway.label', placeholder: '5.6.7.8', required: true, group: 'basic' }, + { key: 'local_subnets', label: 'protocols.ipsec.fields.local_subnets.label', placeholder: '192.168.1.0/24', required: true, tip: 'protocols.ipsec.fields.local_subnets.tip', group: 'routing' }, + { key: 'remote_subnets', label: 'protocols.ipsec.fields.remote_subnets.label', placeholder: '10.0.0.0/24', required: true, group: 'routing' }, + { key: 'psk', label: 'protocols.ipsec.fields.psk.label', type: 'password', required: true, secret: true, group: 'basic' }, { key: 'ike_version', label: 'protocols.ipsec.fields.ike_version.label', type: 'select', options: [ { value: 'ikev2', label: 'protocols.ipsec.fields.ike_version.options.ikev2' }, { value: 'ikev1', label: 'protocols.ipsec.fields.ike_version.options.ikev1' } - ], default: 'ikev2' }, - { key: 'ike_proposal', label: 'protocols.ipsec.fields.ike_proposal.label', placeholder: 'aes256-sha256-modp2048', tip: 'protocols.ipsec.fields.ike_proposal.tip' }, - { key: 'esp_proposal', label: 'protocols.ipsec.fields.esp_proposal.label', placeholder: 'aes256-sha256' }, - { key: 'pfs_group', label: 'protocols.ipsec.fields.pfs_group.label', default: 'modp2048' }, - { key: 'ike_lifetime', label: 'protocols.ipsec.fields.ike_lifetime.label', type: 'number', default: 86400 }, - { key: 'local_id', label: 'protocols.ipsec.fields.local_id.label', placeholder: '@vpn-local' }, - { key: 'remote_id', label: 'protocols.ipsec.fields.remote_id.label', placeholder: '@vpn-remote' } + ], default: 'ikev2', group: 'crypto' }, + { key: 'ike_proposal', label: 'protocols.ipsec.fields.ike_proposal.label', placeholder: 'aes256-sha256-modp2048', tip: 'protocols.ipsec.fields.ike_proposal.tip', group: 'crypto' }, + { key: 'esp_proposal', label: 'protocols.ipsec.fields.esp_proposal.label', placeholder: 'aes256-sha256', group: 'crypto' }, + { key: 'pfs_group', label: 'protocols.ipsec.fields.pfs_group.label', default: 'modp2048', group: 'crypto' }, + { key: 'ike_lifetime', label: 'protocols.ipsec.fields.ike_lifetime.label', type: 'number', default: 86400, group: 'advanced' }, + { key: 'local_id', label: 'protocols.ipsec.fields.local_id.label', placeholder: '@vpn-local', group: 'advanced' }, + { key: 'remote_id', label: 'protocols.ipsec.fields.remote_id.label', placeholder: '@vpn-remote', group: 'advanced' } ] }, ikev2: { @@ -59,16 +59,14 @@ export const PROTOCOL_META = { icon: 'ikev2', scenes: 'protocols.ikev2.scenes', fields: [ - { key: 'public_endpoint', label: 'protocols.ikev2.fields.public_endpoint.label', placeholder: 'vpn.example.com', required: true }, - { key: 'local_id', label: 'protocols.ikev2.fields.local_id.label', placeholder: '@opsx-ikev2', default: '@opsx-ikev2', tip: 'protocols.ikev2.fields.local_id.tip' }, - { key: 'psk', label: 'protocols.ikev2.fields.psk.label', type: 'password', secret: true, tip: 'protocols.ikev2.fields.psk.tip' }, - { key: 'username', label: 'protocols.ikev2.fields.username.label', default: 'vpnuser' }, - { key: 'password', label: 'protocols.ikev2.fields.password.label', type: 'password', secret: true, tip: 'protocols.ikev2.fields.password.tip' }, - { key: 'address_pool', label: 'protocols.ikev2.fields.address_pool.label', default: '10.12.0.0/24' }, - { key: 'dns', label: 'protocols.ikev2.fields.dns.label', default: '223.5.5.5' }, - { key: 'local_ts', label: 'protocols.ikev2.fields.local_ts.label', default: '0.0.0.0/0', tip: 'protocols.ikev2.fields.local_ts.tip' }, - { key: 'ike_proposal', label: 'protocols.ikev2.fields.ike_proposal.label', default: 'aes256-sha256-modp2048' }, - { key: 'esp_proposal', label: 'protocols.ikev2.fields.esp_proposal.label', default: 'aes256-sha256' } + { key: 'public_endpoint', label: 'protocols.ikev2.fields.public_endpoint.label', placeholder: 'vpn.example.com', required: true, group: 'basic' }, + { key: 'local_id', label: 'protocols.ikev2.fields.local_id.label', placeholder: '@opsx-ikev2', default: '@opsx-ikev2', tip: 'protocols.ikev2.fields.local_id.tip', group: 'basic' }, + { key: 'psk', label: 'protocols.ikev2.fields.psk.label', type: 'password', secret: true, tip: 'protocols.ikev2.fields.psk.tip', group: 'basic' }, + { key: 'address_pool', label: 'protocols.ikev2.fields.address_pool.label', default: '10.12.0.0/24', group: 'basic' }, + { key: 'dns', label: 'protocols.ikev2.fields.dns.label', default: '223.5.5.5', group: 'basic' }, + { key: 'local_ts', label: 'protocols.ikev2.fields.local_ts.label', default: '0.0.0.0/0', tip: 'protocols.ikev2.fields.local_ts.tip', group: 'routing' }, + { key: 'ike_proposal', label: 'protocols.ikev2.fields.ike_proposal.label', default: 'aes256-sha256-modp2048', group: 'crypto' }, + { key: 'esp_proposal', label: 'protocols.ikev2.fields.esp_proposal.label', default: 'aes256-sha256', group: 'crypto' } ] }, l2tp: { @@ -78,13 +76,29 @@ export const PROTOCOL_META = { icon: 'l2tp', scenes: 'protocols.l2tp.scenes', fields: [ - { key: 'public_endpoint', label: 'protocols.l2tp.fields.public_endpoint.label', placeholder: '1.2.3.4', required: true }, - { key: 'psk', label: 'protocols.l2tp.fields.psk.label', type: 'password', required: true, secret: true }, - { key: 'username', label: 'protocols.l2tp.fields.username.label', default: 'vpnuser', tip: 'protocols.l2tp.fields.username.tip' }, - { key: 'password', label: 'protocols.l2tp.fields.password.label', type: 'password', secret: true, tip: 'protocols.l2tp.fields.password.tip' }, - { key: 'ip_range', label: 'protocols.l2tp.fields.ip_range.label', default: '10.9.0.100-10.9.0.200' }, - { key: 'local_ip', label: 'protocols.l2tp.fields.local_ip.label', default: '10.9.0.1' }, - { key: 'dns', label: 'protocols.l2tp.fields.dns.label', default: '223.5.5.5' } + { key: 'public_endpoint', label: 'protocols.l2tp.fields.public_endpoint.label', placeholder: '1.2.3.4', required: true, group: 'basic' }, + { key: 'psk', label: 'protocols.l2tp.fields.psk.label', type: 'password', secret: true, tip: 'protocols.l2tp.fields.psk.tip', group: 'basic' }, + { key: 'ip_range', label: 'protocols.l2tp.fields.ip_range.label', default: '10.9.0.100-10.9.0.200', group: 'routing' }, + { key: 'local_ip', label: 'protocols.l2tp.fields.local_ip.label', default: '10.9.0.1', group: 'routing' }, + { key: 'dns', label: 'protocols.l2tp.fields.dns.label', default: '223.5.5.5', group: 'routing' } + ] + }, + gre: { + name: 'protocols.gre.name', + desc: 'protocols.gre.desc', + color: '#357a8b', + icon: 'gre', + scenes: 'protocols.gre.scenes', + fields: [ + { key: 'local_endpoint', label: 'protocols.gre.fields.local_endpoint.label', placeholder: '134.175.167.191', required: true, group: 'basic' }, + { key: 'remote_endpoint', label: 'protocols.gre.fields.remote_endpoint.label', placeholder: '203.0.113.10', required: true, group: 'basic' }, + { key: 'local_tunnel_ip', label: 'protocols.gre.fields.local_tunnel_ip.label', placeholder: '10.255.0.1/30', required: true, group: 'basic' }, + { key: 'remote_tunnel_ip', label: 'protocols.gre.fields.remote_tunnel_ip.label', placeholder: '10.255.0.2/30', required: true, group: 'basic' }, + { key: 'remote_subnet', label: 'protocols.gre.fields.remote_subnet.label', placeholder: '192.168.10.0/24', tip: 'protocols.gre.fields.remote_subnet.tip', group: 'routing' }, + { key: 'enable_nat', label: 'protocols.gre.fields.enable_nat.label', type: 'switch', default: false, tip: 'protocols.gre.fields.enable_nat.tip', group: 'routing' }, + { key: 'outbound_interface', label: 'protocols.gre.fields.outbound_interface.label', placeholder: 'eth0', showIf: { enable_nat: true }, group: 'routing' }, + { key: 'ttl', label: 'protocols.gre.fields.ttl.label', type: 'number', default: 255, group: 'advanced' }, + { key: 'mtu', label: 'protocols.gre.fields.mtu.label', type: 'number', default: 1476, tip: 'protocols.gre.fields.mtu.tip', group: 'advanced' } ] }, frp: { @@ -97,19 +111,19 @@ export const PROTOCOL_META = { { key: 'mode', label: 'protocols.frp.fields.mode.label', type: 'select', options: [ { value: 'server', label: 'protocols.frp.fields.mode.options.server' }, { value: 'client', label: 'protocols.frp.fields.mode.options.client' } - ], default: 'server', tip: 'protocols.frp.fields.mode.tip' }, - { key: 'bind_port', label: 'protocols.frp.fields.bind_port.label', type: 'number', default: 7000, showIf: { mode: 'server' } }, - { key: 'public_endpoint', label: 'protocols.frp.fields.public_endpoint.label', placeholder: '1.2.3.4', showIf: { mode: 'server' } }, - { key: 'dashboard_port', label: 'protocols.frp.fields.dashboard_port.label', type: 'number', default: 7500, showIf: { mode: 'server' } }, - { key: 'dashboard_user', label: 'protocols.frp.fields.dashboard_user.label', default: 'admin', showIf: { mode: 'server' } }, - { key: 'dashboard_password', label: 'protocols.frp.fields.dashboard_password.label', type: 'password', secret: true, showIf: { mode: 'server' }, tip: 'protocols.frp.fields.dashboard_password.tip' }, - { key: 'vhost_http_port', label: 'protocols.frp.fields.vhost_http_port.label', type: 'number', default: 0, showIf: { mode: 'server' }, tip: 'protocols.frp.fields.vhost_http_port.tip' }, - { key: 'vhost_https_port', label: 'protocols.frp.fields.vhost_https_port.label', type: 'number', default: 0, showIf: { mode: 'server' }, tip: 'protocols.frp.fields.vhost_https_port.tip' }, - { key: 'allow_ports', label: 'protocols.frp.fields.allow_ports.label', placeholder: '6000-6100,7001', showIf: { mode: 'server' }, tip: 'protocols.frp.fields.allow_ports.tip' }, - { key: 'server_addr', label: 'protocols.frp.fields.server_addr.label', placeholder: '1.2.3.4', showIf: { mode: 'client' } }, - { key: 'server_port', label: 'protocols.frp.fields.server_port.label', type: 'number', default: 7000, showIf: { mode: 'client' } }, - { key: 'token', label: 'protocols.frp.fields.token.label', type: 'password', secret: true, tip: 'protocols.frp.fields.token.tip' }, - { key: 'proxy_name', label: 'protocols.frp.fields.proxy_name.label', placeholder: 'ssh', default: 'ssh' }, + ], default: 'server', tip: 'protocols.frp.fields.mode.tip', group: 'basic' }, + { key: 'bind_port', label: 'protocols.frp.fields.bind_port.label', type: 'number', default: 7000, showIf: { mode: 'server' }, group: 'basic' }, + { key: 'public_endpoint', label: 'protocols.frp.fields.public_endpoint.label', placeholder: '1.2.3.4', showIf: { mode: 'server' }, group: 'basic' }, + { key: 'dashboard_port', label: 'protocols.frp.fields.dashboard_port.label', type: 'number', default: 7500, showIf: { mode: 'server' }, group: 'advanced' }, + { key: 'dashboard_user', label: 'protocols.frp.fields.dashboard_user.label', default: 'admin', showIf: { mode: 'server' }, group: 'advanced' }, + { key: 'dashboard_password', label: 'protocols.frp.fields.dashboard_password.label', type: 'password', secret: true, showIf: { mode: 'server' }, tip: 'protocols.frp.fields.dashboard_password.tip', group: 'advanced' }, + { key: 'vhost_http_port', label: 'protocols.frp.fields.vhost_http_port.label', type: 'number', default: 0, showIf: { mode: 'server' }, tip: 'protocols.frp.fields.vhost_http_port.tip', group: 'advanced' }, + { key: 'vhost_https_port', label: 'protocols.frp.fields.vhost_https_port.label', type: 'number', default: 0, showIf: { mode: 'server' }, tip: 'protocols.frp.fields.vhost_https_port.tip', group: 'advanced' }, + { key: 'allow_ports', label: 'protocols.frp.fields.allow_ports.label', placeholder: '6000-6100,7001', showIf: { mode: 'server' }, tip: 'protocols.frp.fields.allow_ports.tip', group: 'advanced' }, + { key: 'server_addr', label: 'protocols.frp.fields.server_addr.label', placeholder: '1.2.3.4', showIf: { mode: 'client' }, group: 'basic' }, + { key: 'server_port', label: 'protocols.frp.fields.server_port.label', type: 'number', default: 7000, showIf: { mode: 'client' }, group: 'basic' }, + { key: 'token', label: 'protocols.frp.fields.token.label', type: 'password', secret: true, tip: 'protocols.frp.fields.token.tip', group: 'basic' }, + { key: 'proxy_name', label: 'protocols.frp.fields.proxy_name.label', placeholder: 'ssh', default: 'ssh', group: 'basic' }, { key: 'proxy_type', label: 'protocols.frp.fields.proxy_type.label', type: 'select', options: [ { value: 'tcp', label: 'protocols.frp.fields.proxy_type.options.tcp' }, { value: 'udp', label: 'protocols.frp.fields.proxy_type.options.udp' }, @@ -117,12 +131,12 @@ export const PROTOCOL_META = { { value: 'https', label: 'protocols.frp.fields.proxy_type.options.https' }, { value: 'stcp', label: 'protocols.frp.fields.proxy_type.options.stcp' }, { value: 'xtcp', label: 'protocols.frp.fields.proxy_type.options.xtcp' } - ], default: 'tcp' }, - { key: 'local_ip', label: 'protocols.frp.fields.local_ip.label', default: '127.0.0.1' }, - { key: 'local_port', label: 'protocols.frp.fields.local_port.label', type: 'number', default: 22 }, - { key: 'remote_port', label: 'protocols.frp.fields.remote_port.label', type: 'number', default: 6000, showIf: { proxy_type: ['tcp', 'udp'] } }, - { key: 'custom_domains', label: 'protocols.frp.fields.custom_domains.label', placeholder: 'app.example.com', showIf: { proxy_type: ['http', 'https'] }, tip: 'protocols.frp.fields.custom_domains.tip' }, - { key: 'secret_key', label: 'protocols.frp.fields.secret_key.label', type: 'password', secret: true, showIf: { proxy_type: ['stcp', 'xtcp'] }, tip: 'protocols.frp.fields.secret_key.tip' } + ], default: 'tcp', group: 'basic' }, + { key: 'local_ip', label: 'protocols.frp.fields.local_ip.label', default: '127.0.0.1', group: 'basic' }, + { key: 'local_port', label: 'protocols.frp.fields.local_port.label', type: 'number', default: 22, group: 'basic' }, + { key: 'remote_port', label: 'protocols.frp.fields.remote_port.label', type: 'number', default: 6000, showIf: { proxy_type: ['tcp', 'udp'] }, group: 'basic' }, + { key: 'custom_domains', label: 'protocols.frp.fields.custom_domains.label', placeholder: 'app.example.com', showIf: { proxy_type: ['http', 'https'] }, tip: 'protocols.frp.fields.custom_domains.tip', group: 'basic' }, + { key: 'secret_key', label: 'protocols.frp.fields.secret_key.label', type: 'password', secret: true, showIf: { proxy_type: ['stcp', 'xtcp'] }, tip: 'protocols.frp.fields.secret_key.tip', group: 'basic' } ] }, openvpn: { @@ -137,7 +151,7 @@ export const PROTOCOL_META = { { key: 'proto', label: 'protocols.openvpn.fields.proto.label', type: 'select', options: [ { value: 'udp', label: 'protocols.openvpn.fields.proto.options.udp' }, { value: 'tcp-server', label: 'protocols.openvpn.fields.proto.options.tcp-server' } - ], default: 'udp', tip: 'protocols.openvpn.fields.proto.tip' }, + ], default: 'tcp-server', tip: 'protocols.openvpn.fields.proto.tip' }, { key: 'subnet', label: 'protocols.openvpn.fields.subnet.label', default: '10.10.0.0 255.255.255.0', tip: 'protocols.openvpn.fields.subnet.tip' }, { key: 'cipher', label: 'protocols.openvpn.fields.cipher.label', type: 'select', options: [ { value: 'AES-256-GCM', label: 'protocols.openvpn.fields.cipher.options.AES-256-GCM' }, @@ -148,7 +162,7 @@ export const PROTOCOL_META = { { value: 'certificate', label: 'protocols.openvpn.fields.auth_mode.options.certificate' }, { value: 'password', label: 'protocols.openvpn.fields.auth_mode.options.password' }, { value: 'certificate+password', label: 'protocols.openvpn.fields.auth_mode.options.certificate+password' } - ], default: 'certificate', tip: 'protocols.openvpn.fields.auth_mode.tip' }, + ], default: 'password', tip: 'protocols.openvpn.fields.auth_mode.tip' }, { key: 'mfa_mode', label: 'protocols.openvpn.fields.mfa_mode.label', type: 'select', options: [ { value: 'off', label: 'protocols.openvpn.fields.mfa_mode.options.off' }, { value: 'optional', label: 'protocols.openvpn.fields.mfa_mode.options.optional' }, @@ -161,7 +175,7 @@ export const PROTOCOL_META = { { key: 'dns', label: 'protocols.openvpn.fields.dns.label', default: '223.5.5.5' }, { key: 'redirect_gateway', label: 'protocols.openvpn.fields.redirect_gateway.label', type: 'switch', default: true, tip: 'protocols.openvpn.fields.redirect_gateway.tip' }, { key: 'push_routes', label: 'protocols.openvpn.fields.push_routes.label', placeholder: '192.168.0.0/16,10.0.0.0/8', tip: 'protocols.openvpn.fields.push_routes.tip' }, - { key: 'enable_nat', label: 'protocols.openvpn.fields.enable_nat.label', type: 'switch', default: false }, + { key: 'enable_nat', label: 'protocols.openvpn.fields.enable_nat.label', type: 'switch', default: true }, { key: 'max_clients', label: 'protocols.openvpn.fields.max_clients.label', type: 'number', default: 1000, tip: 'protocols.openvpn.fields.max_clients.tip' }, { key: 'keepalive_interval', label: 'protocols.openvpn.fields.keepalive_interval.label', type: 'number', default: 5, tip: 'protocols.openvpn.fields.keepalive_interval.tip' }, { key: 'keepalive_timeout', label: 'protocols.openvpn.fields.keepalive_timeout.label', type: 'number', default: 30 }, @@ -188,6 +202,7 @@ export const PROTOCOL_COLORS = { ipsec: '#1f6fb2', ikev2: '#0d7377', l2tp: '#4a8a2c', + gre: '#357a8b', frp: '#b8651b', openvpn: '#cc6529' } diff --git a/frontend/src/router.js b/frontend/src/router.js index 9c9c36f..24edeb0 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -16,10 +16,8 @@ const routes = [ { path: 'tunnels/create', component: () => import('./views/TunnelWizard.vue'), meta: { titleKey: 'router.createTunnel' } }, { path: 'tunnels/:id/edit', component: () => import('./views/TunnelEditor.vue'), meta: { titleKey: 'router.editTunnel' } }, { path: 'ipsec-peer', component: () => import('./views/IpsecPeer.vue'), meta: { titleKey: 'router.ipsecPeer' } }, - { path: 'nodes', component: () => import('./views/Nodes.vue'), meta: { titleKey: 'router.nodes', requiresAdmin: true } }, { path: 'users', component: () => import('./views/Users.vue'), meta: { titleKey: 'router.users', requiresAdmin: true } }, { path: 'vpn-users', component: () => import('./views/VPNUsers.vue'), meta: { titleKey: 'router.vpnUsers', requiresAdmin: true } }, - { path: 'plugins', component: () => import('./views/Plugins.vue'), meta: { titleKey: 'router.plugins', requiresAdmin: true } }, { path: 'settings', component: () => import('./views/Settings.vue'), meta: { titleKey: 'router.settings' } }, { path: ':pathMatch(.*)*', component: () => import('./views/NotFound.vue'), meta: { titleKey: 'router.notFound' } } ] diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index c88c637..9ddc471 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -28,6 +28,9 @@ + + @@ -83,6 +86,23 @@ + + + +
+
{{ $t('dashboard.recommendedCmd') }}{{ installData.pm || $t('dashboard.aptDefault') }}
+
{{ installData.command }}
+ {{ $t('common.copy') }} +
+ + +
@@ -94,11 +114,13 @@ import { Connection, Monitor, Share, DataLine, Plus } from '@element-plus/icons- import api from '../api' import { fmtBytes } from '../utils/format' import { protocolColor, protocolDisplay } from '../protocols' -import { ElMessage, ElMessageBox } from 'element-plus' +import { ElMessage } from 'element-plus' const { t } = useI18n() const ov = ref({}) const protocols = ref([]) +const readiness = ref({}) +const readinessLoaded = ref(false) const loadingProto = ref(true) const pieRef = ref(null) let chart = null @@ -107,6 +129,9 @@ let chart = null const deployableProtocols = ['frp', 'openvpn'] const protocolToPkg = { wireguard: 'wireguard', ipsec: 'strongswan', ikev2: 'strongswan', l2tp: 'strongswan', openvpn: 'openvpn', frp: 'frp' } const deploying = ref('') +const installDialogVisible = ref(false) +const installDialogTitle = ref('') +const installData = ref({ command: '', pm: '', detected: false, all_commands: {} }) async function deployProtocol(name) { const pkg = protocolToPkg[name] || name @@ -122,29 +147,18 @@ async function deployProtocol(name) { async function showInstallCmd(name) { const pkg = protocolToPkg[name] || name + installDialogTitle.value = name try { const { data } = await api.get(`/binaries/install-hint/${pkg}`) - const cmd = data.command || t('dashboard.contactAdmin') - const pmLabel = data.pm ? `(${data.pm})` : `(${t('dashboard.aptDefault')})` - // 构建其他平台的命令列表 - let otherCmds = '' - if (data.all_commands) { - for (const [pm, c] of Object.entries(data.all_commands)) { - if (c !== cmd) { - otherCmds += `
${pm}: ${c}
` - } - } - } - const otherSection = otherCmds ? `
${t('dashboard.otherDistros')}
${otherCmds}` : '' - ElMessageBox.alert( - `
${t('dashboard.recommendedCmd')}${pmLabel}
${cmd}
${otherSection}`, - `${name} ${t('dashboard.installCmd')}`, { dangerouslyUseHTMLString: true, confirmButtonText: t('dashboard.copyRecommended'), - callback: (action) => { if (action === 'confirm') navigator.clipboard.writeText(cmd) } }) + installData.value = { command: data.command || '', pm: data.pm || '', detected: !!data.detected, all_commands: data.all_commands || {} } + installDialogVisible.value = true } catch { ElMessage.error(t('dashboard.getInstallCmdFailed')) } } +function copyText(text) { navigator.clipboard.writeText(text).then(() => ElMessage.success(t('common.copied'))) } + function fmtTime(t) { return t ? new Date(t).toLocaleString() : '-' } const statCards = computed(() => [ @@ -153,14 +167,24 @@ const statCards = computed(() => [ { label: t('dashboard.onlineUsers'), value: ov.value.online_sessions ?? 0, icon: Share, iconBg: 'linear-gradient(135deg,#e0935a,#c0762b)', to: '/sessions' }, { label: t('dashboard.totalTraffic'), value: `${fmtBytes(ov.value.rx_bytes)} / ${fmtBytes(ov.value.tx_bytes)}`, icon: DataLine, iconBg: 'linear-gradient(135deg,#7b5ea7,#5a4080)', numStyle: { fontSize: '18px' } }, ]) +const hostReady = computed(() => readiness.value.root && readiness.value.ipv4_forwarding && readiness.value.default_route_ready) +const readinessHint = computed(() => { + const missing = [] + if (!readiness.value.root) missing.push(t('dashboard.readinessRoot')) + if (!readiness.value.ipv4_forwarding) missing.push(t('dashboard.readinessForwarding')) + if (!readiness.value.default_route_ready) missing.push(t('dashboard.readinessRoute')) + return missing.join(' / ') +}) async function load() { // 轮询可见性守卫:标签页隐藏时跳过(P1 修复) if (document.visibilityState !== 'visible') return try { - const [{ data: o }, { data: p }] = await Promise.all([api.get('/overview'), api.get('/protocols')]) + const [{ data: o }, { data: p }, { data: r }] = await Promise.all([api.get('/overview'), api.get('/protocols'), api.get('/readiness')]) ov.value = o protocols.value = p + readiness.value = r || {} + readinessLoaded.value = true renderPie() } catch { /* 忽略轮询错误 */ } finally { loadingProto.value = false } } @@ -235,4 +259,11 @@ onUnmounted(() => { .stat-num { font-size: 26px; font-weight: 700; color: var(--text-primary); line-height: 1.2; } .dep-hint { font-size: 12px; color: var(--text-secondary); font-weight: normal; } +.host-readiness { margin-top: var(--space-4); } +.install-command { margin-top: var(--space-4); } +.install-command-head, .other-command { display:flex; align-items:center; gap:8px; } +.install-command-head { justify-content:space-between; } +.command-block { margin:8px 0; padding:12px; background:var(--bg-inset); border:1px solid var(--border-base); white-space:pre-wrap; overflow:auto; } +.other-command { margin-bottom:8px; } +.other-command code { flex:1; min-width:0; overflow-wrap:anywhere; font-size:12px; } diff --git a/frontend/src/views/Layout.vue b/frontend/src/views/Layout.vue index 511376d..1f2775c 100644 --- a/frontend/src/views/Layout.vue +++ b/frontend/src/views/Layout.vue @@ -14,9 +14,7 @@ {{ $t('router.logs') }} - {{ $t('router.nodes') }} {{ $t('router.vpnUsers') }} - {{ $t('router.plugins') }} {{ $t('router.users') }} @@ -94,7 +92,7 @@ import { ref, computed } from 'vue' import { useRouter, useRoute } from 'vue-router' import { ElMessage } from 'element-plus' import { useI18n } from 'vue-i18n' -import { DataAnalysis, Connection, Coin, Grid, Setting, Avatar, ArrowDown, UserFilled, Iphone, Lock, Monitor, Promotion, Document } from '@element-plus/icons-vue' +import { DataAnalysis, Connection, Setting, Avatar, ArrowDown, UserFilled, Iphone, Lock, Monitor, Promotion, Document } from '@element-plus/icons-vue' import api, { auth } from '../api' const sideBg = 'var(--bg-sidebar)' diff --git a/frontend/src/views/TunnelEditor.vue b/frontend/src/views/TunnelEditor.vue index c477ea0..5435326 100644 --- a/frontend/src/views/TunnelEditor.vue +++ b/frontend/src/views/TunnelEditor.vue @@ -17,7 +17,8 @@ {{ $t(protocolDisplay(protocol)) }} - + {{ $t('tunnelEditor.coreOptions') }} + @@ -28,6 +29,34 @@
{{ $t(field.tip) }}
+ + + + + + + + + + + +
{{ $t(field.tip) }}
+
+
+
@@ -47,6 +76,7 @@ import { useRoute, useRouter } from 'vue-router' import { ElMessage } from 'element-plus' import { useI18n } from 'vue-i18n' import api from '../api' +import RouteListInput from '../components/RouteListInput.vue' import { CUSTOM_FIELDS, PROTOCOL_META, protocolColor, protocolDisplay, optionValue, optionLabel } from '../protocols' const { t } = useI18n() @@ -56,6 +86,8 @@ const loading = ref(true) const saving = ref(false) const protocol = ref('') const form = ref(null) +const advancedOpen = ref([]) +const routeListRef = ref(null) const fields = computed(() => PROTOCOL_META[protocol.value]?.fields || CUSTOM_FIELDS) const visibleFields = computed(() => fields.value.filter(field => { @@ -64,6 +96,33 @@ const visibleFields = computed(() => fields.value.filter(field => { ? value.includes(form.value.config[key]) : form.value.config[key] === value) })) +const isOpenVPN = computed(() => protocol.value === 'openvpn') +const openVPNCoreKeys = new Set(['public_endpoint', 'port', 'proto', 'auth_mode', 'mfa_mode']) +const openVPNTrafficKeys = new Set(['redirect_gateway', 'push_routes', 'enable_nat']) +const trafficMode = computed({ + get() { + const cfg = form.value?.config || {} + return cfg.redirect_gateway === false ? 'static' : 'gateway' + }, + set(mode) { + if (!form.value) return + if (mode === 'gateway') { + form.value.config.redirect_gateway = true + form.value.config.enable_nat = true + form.value.config.push_routes = '' + } else if (mode === 'static') { + form.value.config.redirect_gateway = false + form.value.config.enable_nat = false + } + }, +}) +const coreFields = computed(() => isOpenVPN.value + ? visibleFields.value.filter(field => openVPNCoreKeys.has(field.key)) + : visibleFields.value.filter(field => field.group !== 'advanced' && field.group !== 'crypto')) +const advancedFields = computed(() => { + if (!isOpenVPN.value) return visibleFields.value.filter(field => field.group === 'advanced' || field.group === 'crypto') + return visibleFields.value.filter(field => !openVPNCoreKeys.has(field.key) && !openVPNTrafficKeys.has(field.key)) +}) function parseConfig(raw) { try { return JSON.parse(raw || '{}') } catch { return {} } @@ -95,6 +154,8 @@ async function load() { async function save() { if (!form.value.name) return ElMessage.warning(t('tunnelEditor.fillName')) + trafficMode.value = trafficMode.value + if (isOpenVPN.value && routeListRef.value && !routeListRef.value.validate()) return saving.value = true try { await api.put(`/tunnels/${route.params.id}`, form.value) @@ -112,4 +173,6 @@ onMounted(load) diff --git a/frontend/src/views/TunnelWizard.vue b/frontend/src/views/TunnelWizard.vue index ca0b719..225e29c 100644 --- a/frontend/src/views/TunnelWizard.vue +++ b/frontend/src/views/TunnelWizard.vue @@ -47,6 +47,9 @@ {{ $t(currentName) }} {{ $t('tunnelWizard.reselect') }}
+ + {{ $t('tunnelWizard.coreOptions') }} + @@ -59,6 +62,19 @@
{{ $t(f.tip) }}
+ @@ -43,9 +48,7 @@ + +