feat: 新增 GRE 隧道协议,管理端口调整为 18080
- backend: 新增 GRE 隧道实现(ip tunnel 点对点互联),binmgr 支持 iproute2 - backend: api/identity 调整,各协议 service(gre/ikev2/ipsec/l2tp/openvpn)统一改动 - backend: OpenVPN 认证辅助与用户口令同步逻辑完善 - frontend: 协议图标/向导/编辑器支持 GRE,VPN 用户页重构(234 行) - frontend: 新增 RouteListInput 路由列表输入组件,Dashboard/Layout 优化 - i18n: 中英文文案同步更新 - test: 新增 binmgr/common/gre 单测,openvpn 测试补充 - docs: README 补充 GRE 说明,默认监听端口 8080 → 18080 - chore: .gitignore 增加 opsx-vpn.previous(构建备份二进制)
This commit is contained in:
1 parent
c7f59f2cbf
commit
ef7eb378d2
28 files changed
+1363
-439
No files matched your search
+120
-68
@@ -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 临时连接测试请求(不落库)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user