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

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

No files matched your search

+166 -16
View File
@@ -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