feat: 全面优化 — UI/UX重构 + 流量审计与会话管理 + 协议部署

UI/UX 优化:
- 登录页分屏布局 + 可访问性修复 + 内联错误提示
- 侧栏菜单分组(专线运维/资源管理/系统) + 图标修正 + 子路由高亮
- Dashboard 统计卡渐变图标 + 空状态引导 + 统计卡可点击
- 创建向导: 依赖检测门控 + 高级折叠 + 必填校验 + WireGuard推荐标
- 专线列表: 空状态CTA + 操作列「更多」下拉 + 状态筛选 + SSE日志流
- 协议品牌SVG图标(ProtocolIcon组件)替换emoji
- 设计令牌系统(tokens.css)统一颜色/间距/圆角/阴影

功能增强:
- 在线会话管理(sessionmgr): 跨协议会话同步 + 踢下线 + 连接审计日志
- 流量配额引擎(trafficmgr): 配额检查 + 超额动作(block/warn/throttle) + 周期重置
- 异常流量告警: 突发流量检测(>90MB/15s) + 审计记录
- 节点SSH凭证管理: 密码/私钥认证 + 即时测试连接 + AES-GCM加密存储
- 协议二进制管理(binmgr): FRP/OpenVPN GitHub下载 + 系统包安装引导(apt/yum/dnf)
- 右上角修改密码弹窗 + MFA独立验证页 + 密码强度提示
- 用户管理: 批量导入 + LDAP浏览导入 + 平台用户导入VPN拨号用户
- 404路由 + IPSec对接页可达性修复
- MFA备用码持久化弹窗(替代Toast)
- 轮询可见性守卫 + ECharts resize修复

后端:
- 3张新表: VPNSession/ConnectionLog/TrafficPolicy
- monitor增强: 会话同步+配额检查+异常告警+到期审计
- changePassword统一8位+新旧不同校验
- openvpn-otp文案专业化(移除抄袭感)
- SSE日志流(token降级query参数)
This commit is contained in:
Logan.Li committed 2026-07-23 01:42:36 +08:00
1 parent 58c036c34c
commit 1e2f8ef24f
43 files changed
+6783 -460

No files matched your search

File diff suppressed because it is too large. Load diff
+529 -4
View File
@@ -2,8 +2,11 @@ package api
import (
"crypto/rand"
"crypto/subtle"
"crypto/tls"
"encoding/json"
"fmt"
"net"
"net/http"
"strconv"
"strings"
@@ -12,10 +15,14 @@ import (
"github.com/gin-gonic/gin"
"github.com/go-ldap/ldap/v3"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
"golang.org/x/crypto/bcrypt"
"vpn-platform/backend/internal/model"
"vpn-platform/backend/internal/service/ikev2"
"vpn-platform/backend/internal/service/l2tp"
"vpn-platform/backend/internal/service/openvpn"
"vpn-platform/backend/internal/store"
)
@@ -80,12 +87,26 @@ func ldapSettings() (*model.LDAPConfig, error) {
func ldapConnect(cfg *model.LDAPConfig) (*ldap.Conn, error) {
url := cfg.URL
if !strings.HasPrefix(url, "ldap://") && !strings.HasPrefix(url, "ldaps://") {
url = "ldap://" + url
// 安全默认:未显式指定协议时按 ldaps,避免口令明文过网
url = "ldaps://" + url
}
conn, err := ldap.DialURL(url)
if err != nil {
return nil, err
}
if cfg.StartTLS && strings.HasPrefix(url, "ldap://") {
host := strings.TrimPrefix(url, "ldap://")
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
if err := conn.StartTLS(&tls.Config{ServerName: host}); err != nil {
conn.Close()
return nil, fmt.Errorf("StartTLS 升级失败: %w", err)
}
} else if strings.HasPrefix(url, "ldap://") {
conn.Close()
return nil, fmt.Errorf("明文 ldap:// 存在口令泄露风险,请改用 ldaps:// 或开启 StartTLS")
}
if cfg.BindDN != "" {
if err := conn.Bind(cfg.BindDN, cfg.BindPassword); err != nil {
conn.Close()
@@ -130,16 +151,48 @@ func verifyMFA(user *model.User, code string) error {
if err := store.DB.Where("user_id = ? AND enabled = ?", user.ID, true).First(&device).Error; err != nil {
return nil
}
code = strings.TrimSpace(code)
if code == "" {
return fmt.Errorf("需要有效的 MFA 验证码")
}
secret, err := store.DecryptSecret(device.Secret)
if err != nil {
return fmt.Errorf("MFA 配置无效")
}
if totp.Validate(strings.TrimSpace(code), secret) {
if totp.Validate(code, secret) {
return nil
}
// 备用码一次性消费(丢失验证器时恢复)
if consumeAdminBackupCode(&device, code) {
store.Audit(user.Username, "mfa_backup_used", user.Username, "使用备用码登录")
return nil
}
return fmt.Errorf("需要有效的 MFA 验证码")
}
func consumeAdminBackupCode(device *model.MFADevice, code string) bool {
plain, err := store.DecryptSecret(device.BackupCodes)
if err != nil || plain == "" {
return false
}
var codes []string
if json.Unmarshal([]byte(plain), &codes) != nil {
return false
}
normalized := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(code), " ", ""))
for i, bc := range codes {
if subtle.ConstantTimeCompare([]byte(strings.ToUpper(bc)), []byte(normalized)) == 1 {
codes = append(codes[:i], codes[i+1:]...)
raw, _ := json.Marshal(codes)
if enc, err := store.EncryptSecret(string(raw)); err == nil {
store.DB.Model(device).Update("backup_codes", enc)
}
return true
}
}
return false
}
func (s *Server) mfaStatus(c *gin.Context) {
var device model.MFADevice
err := store.DB.Where("user_id = ?", c.GetUint("user_id")).First(&device).Error
@@ -268,6 +321,7 @@ func (s *Server) updateUser(c *gin.Context) {
}
hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
user.Password = string(hash)
user.MustChangePassword = true // 管理员重置后强制改密
}
store.DB.Save(&user)
c.JSON(200, user)
@@ -300,6 +354,7 @@ func (s *Server) saveLDAPConfig(c *gin.Context) {
BaseDN string `json:"base_dn"`
UserFilter string `json:"user_filter"`
UsernameAttribute string `json:"username_attribute"`
StartTLS bool `json:"starttls"`
Enabled bool `json:"enabled"`
}
if c.ShouldBindJSON(&req) != nil || (req.Enabled && (req.URL == "" || req.BaseDN == "")) {
@@ -308,7 +363,8 @@ func (s *Server) saveLDAPConfig(c *gin.Context) {
}
var cfg model.LDAPConfig
store.DB.First(&cfg)
cfg.URL, cfg.BindDN, cfg.BaseDN, cfg.UserFilter, cfg.UsernameAttribute, cfg.Enabled = req.URL, req.BindDN, req.BaseDN, req.UserFilter, req.UsernameAttribute, req.Enabled
cfg.URL, cfg.BindDN, cfg.BaseDN, cfg.UserFilter, cfg.UsernameAttribute, cfg.StartTLS, cfg.Enabled =
req.URL, req.BindDN, req.BaseDN, req.UserFilter, req.UsernameAttribute, req.StartTLS, req.Enabled
if req.BindPassword != "" {
cfg.BindPassword, _ = store.EncryptSecret(req.BindPassword)
}
@@ -361,17 +417,32 @@ func (s *Server) createVPNUser(c *gin.Context) {
c.JSON(400, gin.H{"error": "来源必须为 local 或 ldap"})
return
}
t, err := store.GetTunnel(req.TunnelID)
if err != nil {
c.JSON(404, gin.H{"error": "隧道不存在"})
return
}
switch t.Protocol {
case "openvpn", "l2tp", "ikev2":
default:
c.JSON(400, gin.H{"error": "该协议不支持拨号用户(支持 OpenVPN / L2TP / IKEv2)"})
return
}
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 {
c.JSON(400, gin.H{"error": "本地拨号用户密码必填"})
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 if user.LDAPDN == "" {
c.JSON(400, gin.H{"error": "LDAP 拨号用户必须填写 LDAP DN"})
return
@@ -380,6 +451,7 @@ func (s *Server) createVPNUser(c *gin.Context) {
c.JSON(400, gin.H{"error": "创建失败:" + err.Error()})
return
}
_ = refreshDialAuth(t)
c.JSON(200, user)
}
func (s *Server) updateVPNUser(c *gin.Context) {
@@ -399,17 +471,470 @@ func (s *Server) updateVPNUser(c *gin.Context) {
}
user.DisplayName, user.Email = req.DisplayName, req.Email
if req.Enabled != nil {
if !*req.Enabled && user.Enabled {
revokeOpenVPNCert(&user)
}
user.Enabled = *req.Enabled
}
if req.Password != "" {
hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
user.Password = string(hash)
if enc, err := store.EncryptSecret(req.Password); err == nil {
user.DialSecret = enc
}
}
store.DB.Save(&user)
if t, err := store.GetTunnel(user.TunnelID); err == nil {
_ = refreshDialAuth(t)
}
c.JSON(200, user)
}
func (s *Server) deleteVPNUser(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var user model.VPNUser
if store.DB.First(&user, id).Error == nil {
revokeOpenVPNCert(&user)
tunnelID := user.TunnelID
store.DB.Delete(&model.VPNUser{}, id)
if t, err := store.GetTunnel(tunnelID); err == nil {
_ = refreshDialAuth(t)
}
c.JSON(200, gin.H{"ok": true})
return
}
store.DB.Delete(&model.VPNUser{}, id)
c.JSON(200, gin.H{"ok": true})
}
func revokeOpenVPNCert(user *model.VPNUser) {
if user.Certificate == "" {
return
}
t, err := store.GetTunnel(user.TunnelID)
if err != nil || t.Protocol != "openvpn" {
return
}
if err := openvpn.New().RevokeClientCertificate(t, user); err != nil {
store.Audit("system", "revoke_cert_failed", user.Username, err.Error())
}
}
// vpnUserMFASetup 为 OpenVPN 拨号用户生成 TOTP 密钥(待确认)。
func (s *Server) vpnUserMFASetup(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var user model.VPNUser
if store.DB.First(&user, id).Error != nil {
c.JSON(404, gin.H{"error": "拨号用户不存在"})
return
}
t, err := store.GetTunnel(user.TunnelID)
if err != nil || t.Protocol != "openvpn" {
c.JSON(400, gin.H{"error": "仅 OpenVPN 拨号用户支持 MFA"})
return
}
// TOTP 标准参数(RFC 6238):SHA1、30s、6 位、base32,兼容主流 Authenticator
key, err := totp.Generate(totp.GenerateOpts{
Issuer: "OPSX-OpenVPN",
AccountName: fmt.Sprintf("%s@%s", user.Username, t.Name),
Period: 30,
Digits: otp.DigitsSix,
Algorithm: otp.AlgorithmSHA1,
})
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
pendingMFAMu.Lock()
// 用 user_id 偏移避免与管理端 MFA 冲突:拨号用户用高位 key 空间
pendingMFAs[vpnUserPendingKey(user.ID)] = pendingMFA{
secret: key.Secret(), backups: backupCodes(), expires: time.Now().Add(15 * time.Minute),
}
pendingMFAMu.Unlock()
c.JSON(200, gin.H{
"otpauth_url": key.URL(),
"secret": key.Secret(),
"period": 30,
"digits": 6,
"algorithm": "SHA1",
"encoding": "base32",
"expires_in": 900,
"hint": "兼容主流 Authenticator 应用:扫描二维码或手动输入 base32 密钥",
})
}
func vpnUserPendingKey(vpnUserID uint) uint {
// 管理端 MFA 用真实 user_id;拨号 MFA pending 用高位偏移隔离。
return 1_000_000_000 + vpnUserID
}
func (s *Server) vpnUserMFAEnable(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var user model.VPNUser
if store.DB.First(&user, id).Error != nil {
c.JSON(404, gin.H{"error": "拨号用户不存在"})
return
}
var req struct {
Code string `json:"code"`
}
if c.ShouldBindJSON(&req) != nil || req.Code == "" {
c.JSON(400, gin.H{"error": "请输入验证码"})
return
}
pendingMFAMu.Lock()
pending, ok := pendingMFAs[vpnUserPendingKey(user.ID)]
delete(pendingMFAs, vpnUserPendingKey(user.ID))
pendingMFAMu.Unlock()
if !ok || time.Now().After(pending.expires) || !totp.Validate(strings.TrimSpace(req.Code), pending.secret) {
c.JSON(400, gin.H{"error": "验证码无效或设置已过期,请重新扫描二维码"})
return
}
secret, err := store.EncryptSecret(pending.secret)
if err != nil {
c.JSON(500, gin.H{"error": "加密 MFA 密钥失败"})
return
}
raw, _ := json.Marshal(pending.backups)
backups, err := store.EncryptSecret(string(raw))
if err != nil {
c.JSON(500, gin.H{"error": "加密备用码失败"})
return
}
user.MFASecret = secret
user.MFABackupCodes = backups
user.MFAEnabled = true
if err := store.DB.Save(&user).Error; err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
store.Audit(c.GetString("username"), "enable_vpn_user_mfa", user.Username, "")
c.JSON(200, gin.H{"ok": true, "backup_codes": pending.backups, "mfa_enabled": true})
}
func (s *Server) vpnUserMFADisable(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var user model.VPNUser
if store.DB.First(&user, id).Error != nil {
c.JSON(404, gin.H{"error": "拨号用户不存在"})
return
}
store.DB.Model(&user).Updates(map[string]interface{}{
"mfa_enabled": false, "mfa_secret": "", "mfa_backup_codes": "",
"mfa_pin": "", "mfa_has_pin": false, "mfa_fail_count": 0, "mfa_locked_until": nil,
"mfa_last_counter": 0, "mfa_last_used_at": nil,
})
store.Audit(c.GetString("username"), "disable_vpn_user_mfa", user.Username, "")
c.JSON(200, gin.H{"ok": true, "mfa_enabled": false})
}
// vpnUserMFAStatus OTP Manager:查看拨号用户 MFA 状态。
func (s *Server) vpnUserMFAStatus(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var user model.VPNUser
if store.DB.First(&user, id).Error != nil {
c.JSON(404, gin.H{"error": "拨号用户不存在"})
return
}
locked := user.MFALockedUntil != nil && time.Now().Before(*user.MFALockedUntil)
c.JSON(200, gin.H{
"mfa_enabled": user.MFAEnabled,
"mfa_has_pin": user.MFAHasPin,
"mfa_fail_count": user.MFAFailCount,
"mfa_locked": locked,
"mfa_locked_until": user.MFALockedUntil,
"mfa_last_used_at": user.MFALastUsedAt,
"compatible": "TOTP 标准(兼容 Google/Microsoft Authenticator,sha1:base32:6:30s)",
})
}
// vpnUserMFAPin 设置或清除静态 PIN(连接时第二因子 = PIN+OTP)。
func (s *Server) vpnUserMFAPin(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var user model.VPNUser
if store.DB.First(&user, id).Error != nil {
c.JSON(404, gin.H{"error": "拨号用户不存在"})
return
}
if !user.MFAEnabled {
c.JSON(400, gin.H{"error": "请先启用 MFA 再设置 PIN"})
return
}
var req struct {
Pin string `json:"pin"` // 空字符串表示清除
}
if c.ShouldBindJSON(&req) != nil {
c.JSON(400, gin.H{"error": "参数错误"})
return
}
if err := openvpn.SetUserMFAPin(&user, req.Pin); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
store.Audit(c.GetString("username"), "set_vpn_user_mfa_pin", user.Username, "")
c.JSON(200, gin.H{"ok": true, "mfa_has_pin": user.MFAHasPin})
}
// vpnUserMFABackupCodes 重新生成备用码。
func (s *Server) vpnUserMFABackupCodes(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var user model.VPNUser
if store.DB.First(&user, id).Error != nil {
c.JSON(404, gin.H{"error": "拨号用户不存在"})
return
}
codes, err := openvpn.RegenerateBackupCodes(&user)
if err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
store.Audit(c.GetString("username"), "regen_vpn_user_mfa_backup", user.Username, "")
c.JSON(200, gin.H{"ok": true, "backup_codes": codes})
}
// vpnUserMFAUnlock 清除 MFA 锁定(失败次数过多后)。
func (s *Server) vpnUserMFAUnlock(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var user model.VPNUser
if store.DB.First(&user, id).Error != nil {
c.JSON(404, gin.H{"error": "拨号用户不存在"})
return
}
if err := openvpn.UnlockUserMFA(&user); err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
store.Audit(c.GetString("username"), "unlock_vpn_user_mfa", user.Username, "")
c.JSON(200, gin.H{"ok": true})
}
// vpnUserOTPSecretsLine 导出 TOTP 密钥行(标准 otp-secrets 格式,便于迁移与备份)。
func (s *Server) vpnUserOTPSecretsLine(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var user model.VPNUser
if store.DB.First(&user, id).Error != nil {
c.JSON(404, gin.H{"error": "拨号用户不存在"})
return
}
line, err := openvpn.GenerateOTPSecretsLine(&user)
if err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{
"line": line,
"format": "user server type:hash:encoding:key:pin:udid client",
"note": "标准 otp-secrets 格式密钥行;密钥敏感,请妥善保管,切勿泄露",
})
}
// refreshDialAuth 在拨号用户变更后重写 L2TP/IKEv2 认证文件。
func refreshDialAuth(t *model.Tunnel) error {
if t == nil {
return nil
}
switch t.Protocol {
case "l2tp":
return l2tp.New().RefreshAuth(t)
case "ikev2":
return ikev2.New().RefreshAuth(t)
}
return nil
}
// ---- 用户批量导入 / LDAP 导入 ----
// listLDAPUsers 浏览 LDAP 目录中的用户列表,供导入选择。复用 ldapSettings + ldapConnect。
func (s *Server) listLDAPUsers(c *gin.Context) {
cfg, err := ldapSettings()
if err != nil {
c.JSON(400, gin.H{"error": "LDAP 未启用或配置不完整:" + err.Error()})
return
}
conn, err := ldapConnect(cfg)
if err != nil {
c.JSON(500, gin.H{"error": "LDAP 连接失败:" + err.Error()})
return
}
defer conn.Close()
// 搜索 BaseDN 下所有匹配用户过滤器的条目
attrs := []string{"dn", cfg.UsernameAttribute, "cn", "mail"}
result, err := conn.Search(ldap.NewSearchRequest(
cfg.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
cfg.UserFilter, attrs, nil,
))
if err != nil {
c.JSON(500, gin.H{"error": "LDAP 搜索失败:" + err.Error()})
return
}
// 查询本地已有的 LDAP 用户名,标记已导入
var existingNames []string
store.DB.Model(&model.User{}).Where("source = ?", "ldap").Pluck("username", &existingNames)
existingMap := map[string]bool{}
for _, n := range existingNames {
existingMap[n] = true
}
type ldapUser struct {
DN string `json:"dn"`
UID string `json:"uid"`
CN string `json:"cn"`
Mail string `json:"mail"`
Imported bool `json:"imported"`
}
users := make([]ldapUser, 0, len(result.Entries))
for _, entry := range result.Entries {
uid := entry.GetAttributeValue(cfg.UsernameAttribute)
if uid == "" {
continue
}
users = append(users, ldapUser{
DN: entry.DN,
UID: uid,
CN: entry.GetAttributeValue("cn"),
Mail: entry.GetAttributeValue("mail"),
Imported: existingMap[uid],
})
}
c.JSON(200, users)
}
// batchCreateUsers 批量创建本地用户。
func (s *Server) batchCreateUsers(c *gin.Context) {
var req struct {
Users []struct {
Username string `json:"username"`
Password string `json:"password"`
Email string `json:"email"`
Role string `json:"role"`
} `json:"users"`
DefaultRole string `json:"default_role"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "参数错误"})
return
}
if req.DefaultRole == "" {
req.DefaultRole = "viewer"
}
type failInfo struct {
Username string `json:"username"`
Error string `json:"error"`
}
var success, skipped, failed int
var fails []failInfo
for _, u := range req.Users {
if u.Username == "" {
failed++
fails = append(fails, failInfo{Error: "用户名为空"})
continue
}
// 检查是否已存在
var count int64
store.DB.Model(&model.User{}).Where("username = ?", u.Username).Count(&count)
if count > 0 {
skipped++
continue
}
pwd := u.Password
if len(pwd) < 8 {
failed++
fails = append(fails, failInfo{Username: u.Username, Error: "密码至少 8 位"})
continue
}
role := u.Role
if role == "" {
role = req.DefaultRole
}
if role != "admin" && role != "operator" && role != "viewer" {
role = "viewer"
}
hash, _ := bcrypt.GenerateFromPassword([]byte(pwd), bcrypt.DefaultCost)
user := model.User{
Username: u.Username, Password: string(hash), Email: u.Email,
Role: role, Source: "local", Enabled: true,
}
if err := store.DB.Create(&user).Error; err != nil {
failed++
fails = append(fails, failInfo{Username: u.Username, Error: err.Error()})
continue
}
store.Audit(c.GetString("username"), "batch_create_user", u.Username, "")
success++
}
c.JSON(200, gin.H{
"success": success, "skipped": skipped, "failed": failed,
"errors": fails, "total": len(req.Users),
})
}
// importLDAPUsers 从 LDAP 导入选中的用户到本地 DB(source=ldap)。
func (s *Server) importLDAPUsers(c *gin.Context) {
var req struct {
DNs []string `json:"dns"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "参数错误"})
return
}
cfg, err := ldapSettings()
if err != nil {
c.JSON(400, gin.H{"error": "LDAP 未启用:" + err.Error()})
return
}
conn, err := ldapConnect(cfg)
if err != nil {
c.JSON(500, gin.H{"error": "LDAP 连接失败:" + err.Error()})
return
}
defer conn.Close()
type failInfo struct {
DN string `json:"dn"`
Error string `json:"error"`
}
var success, skipped, failed int
var fails []failInfo
for _, dn := range req.DNs {
// 按 DN 查询单个条目
result, err := conn.Search(ldap.NewSearchRequest(
dn, ldap.ScopeBaseObject, ldap.NeverDerefAliases, 0, 0, false,
"(objectClass=*)", []string{cfg.UsernameAttribute, "cn", "mail"}, nil,
))
if err != nil || len(result.Entries) == 0 {
failed++
fails = append(fails, failInfo{DN: dn, Error: "查询失败"})
continue
}
entry := result.Entries[0]
uid := entry.GetAttributeValue(cfg.UsernameAttribute)
if uid == "" {
failed++
fails = append(fails, failInfo{DN: dn, Error: "无 uid 属性"})
continue
}
// 检查本地是否已存在
var count int64
store.DB.Model(&model.User{}).Where("username = ?", uid).Count(&count)
if count > 0 {
skipped++
continue
}
user := model.User{
Username: uid, Email: entry.GetAttributeValue("mail"),
Role: "viewer", Source: "ldap", Enabled: true,
}
if err := store.DB.Create(&user).Error; err != nil {
failed++
fails = append(fails, failInfo{DN: dn, Error: err.Error()})
continue
}
store.Audit(c.GetString("username"), "import_ldap_user", uid, dn)
success++
}
c.JSON(200, gin.H{
"success": success, "skipped": skipped, "failed": failed,
"errors": fails, "total": len(req.DNs),
})
}
+480
View File
@@ -0,0 +1,480 @@
// Package binmgr 提供协议二进制的下载部署与安装引导能力。
// 参考 opsx-proxy 的 xray_install.go 模式:
// - FRP: 从 GitHub Releases 下载预编译二进制到 data/bin/
// - WireGuard/OpenVPN/StrongSwan: 检测系统包管理器,返回安装命令
package binmgr
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
)
// BinDir 二进制存放目录(data/bin/)
func BinDir() string {
return filepath.Join(dataDir(), "bin")
}
// dataDir 获取数据目录(复用 service.BaseDir 逻辑)
func dataDir() string {
if v := os.Getenv("VPN_DATA_DIR"); v != "" {
return v
}
if exe, err := os.Executable(); err == nil {
return filepath.Join(filepath.Dir(exe), "data")
}
return "data"
}
// ---- GitHub Release 下载(FRP / OpenVPN 通用)----
type githubRelease struct {
TagName string `json:"tag_name"`
Assets []struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
} `json:"assets"`
}
var httpClient = &http.Client{Timeout: 5 * time.Minute}
// deploySpec 定义可平台下载部署的协议规格
type deploySpec struct {
Repo string // GitHub 仓库(owner/repo)
BinNames []string // 要提取的二进制名(如 frps/frpc)
AssetHint string // 匹配 asset 的关键词(用于从 release assets 中选对)
WindowsExt string // Windows 下的二进制扩展名
}
// 可下载部署的协议列表
var deploySpecs = map[string]deploySpec{
"frp": {
Repo: "fatedier/frp",
BinNames: []string{"frps", "frpc"},
AssetHint: "", // 匹配逻辑:tag + goos + goarch
},
"openvpn": {
Repo: "OpenVPN/openvpn",
BinNames: []string{"openvpn"},
AssetHint: "", // openvpn release 匹配逻辑略不同,见下方
},
}
// DownloadBinary 通用下载部署:从 GitHub Releases 下载协议二进制到 data/bin/
func DownloadBinary(name string) (string, error) {
spec, ok := deploySpecs[name]
if !ok {
return "", fmt.Errorf("协议 %s 不支持平台下载部署", name)
}
// 1. 获取最新 release
release, err := fetchLatestRelease(spec.Repo)
if err != nil {
return "", fmt.Errorf("获取 %s 版本失败: %w", name, err)
}
// 2. 匹配当前平台的 asset
assetURL, assetFile := matchAsset(name, spec, release)
if assetURL == "" {
return "", fmt.Errorf("未找到匹配 %s/%s 的 %s 发布包", runtime.GOOS, runtime.GOARCH, name)
}
// 3. 下载 + 解压 + 提取
if err := os.MkdirAll(BinDir(), 0o755); err != nil {
return "", err
}
tmpDir, err := os.MkdirTemp("", name+"-dl-*")
if err != nil {
return "", err
}
defer os.RemoveAll(tmpDir)
archivePath := filepath.Join(tmpDir, assetFile)
if err := downloadFile(assetURL, archivePath); err != nil {
return "", fmt.Errorf("下载失败: %w", err)
}
extractDir := filepath.Join(tmpDir, "extracted")
if strings.HasSuffix(assetFile, ".zip") {
err = unzipTo(archivePath, extractDir)
} else if strings.HasSuffix(assetFile, ".tar.gz") || strings.HasSuffix(assetFile, ".tgz") {
err = untargzTo(archivePath, extractDir)
} else {
// 单文件直接复制
err = os.MkdirAll(extractDir, 0o755)
if err == nil {
err = copyFile(archivePath, filepath.Join(extractDir, assetFile))
}
}
if err != nil {
return "", fmt.Errorf("解压失败: %w", err)
}
// 4. 提取二进制到 data/bin/
version := release.TagName
for _, binName := range spec.BinNames {
// 尝试原名 + .exe
candidates := []string{binName}
if runtime.GOOS == "windows" {
candidates = []string{binName + ".exe", binName}
}
for _, cand := range candidates {
src := findFile(extractDir, cand)
if src != "" {
dstName := binName
if runtime.GOOS == "windows" {
dstName += ".exe"
}
dst := filepath.Join(BinDir(), dstName)
if err := copyFile(src, dst); err != nil {
return "", fmt.Errorf("复制 %s 失败: %w", binName, err)
}
os.Chmod(dst, 0o755)
break
}
}
}
// 5. 写版本标记
os.WriteFile(filepath.Join(BinDir(), name+".version"), []byte(version), 0o644)
return version, nil
}
// matchAsset 根据协议名和当前平台从 release assets 中匹配下载 URL
func matchAsset(name string, spec deploySpec, release *githubRelease) (url, file string) {
goos := runtime.GOOS
arch := runtime.GOARCH
// GOARCH 别名
if arch == "amd64" {
arch = "amd64"
}
// 构建匹配关键词
var keywords []string
switch name {
case "frp":
// frp 发布包格式:frp_x.x.x_linux_amd64.tar.gz
keywords = []string{goos + "_" + arch, goos + "-" + arch}
case "openvpn":
// OpenVPN 官方 release 主要是源码 tarball,预编译二进制在社区构建
// 匹配 openvpn-windows-installers 或社区构建的 linux 包
if goos == "windows" {
keywords = []string{"windows", "win", ".exe", ".msi"}
} else {
// Linux 预编译:尝试匹配社区构建或退回源码包
keywords = []string{goos, arch}
}
}
// 遍历 assets 找最佳匹配
for _, a := range release.Assets {
lower := strings.ToLower(a.Name)
// 排除明显不匹配的(如 .asc 签名、.src 源码)
if strings.HasSuffix(lower, ".asc") || strings.HasSuffix(lower, ".sig") {
continue
}
for _, kw := range keywords {
if strings.Contains(lower, kw) {
return a.BrowserDownloadURL, a.Name
}
}
}
return "", ""
}
// ---- 包管理器检测与安装引导 ----
// DetectPackageManager 检测系统包管理器
func DetectPackageManager() string {
for _, pm := range []string{"apt-get", "yum", "dnf", "apk", "pacman"} {
if _, err := exec.LookPath(pm); err == nil {
return pm
}
}
return ""
}
// InstallHint 返回协议在各包管理器下的安装命令。
// 同时返回当前系统检测到的包管理器,供前端高亮推荐。
func InstallHint(protocol string) map[string]interface{} {
detectedPM := DetectPackageManager()
// 嵌套 map: 协议 → 包管理器 → 安装命令
hints := map[string]map[string]string{
"wireguard": {
"apt-get": "apt-get install -y wireguard-tools",
"yum": "yum install -y epel-release && yum install -y wireguard-tools",
"dnf": "dnf install -y epel-release && dnf install -y wireguard-tools",
"apk": "apk add wireguard-tools",
"pacman": "pacman -S --noconfirm wireguard-tools",
},
"openvpn": {
"apt-get": "apt-get install -y openvpn",
"yum": "yum install -y epel-release && yum install -y openvpn",
"dnf": "dnf install -y epel-release && dnf install -y openvpn",
"apk": "apk add openvpn",
"pacman": "pacman -S --noconfirm openvpn",
},
"strongswan": {
"apt-get": "apt-get install -y strongswan strongswan-swanctl",
"yum": "yum install -y epel-release && yum install -y strongswan",
"dnf": "dnf install -y epel-release && dnf install -y strongswan",
"apk": "apk add strongswan",
"pacman": "pacman -S --noconfirm strongswan",
},
}
// IPSec / IKEv2 / L2TP 都依赖 strongswan
displayName := protocol
aliasMap := map[string]string{
"ipsec": "strongswan", "ikev2": "strongswan", "l2tp": "strongswan",
}
if real, ok := aliasMap[protocol]; ok {
protocol = real
}
protoHints, ok := hints[protocol]
if !ok {
return map[string]interface{}{"command": "", "pm": detectedPM, "all": map[string]string{}, "detected": false}
}
// 当前系统推荐的命令
recommendedCmd := protoHints[detectedPM]
if recommendedCmd == "" {
recommendedCmd = protoHints["apt-get"] // 无包管理器时兜底 apt
}
return map[string]interface{}{
"command": recommendedCmd, // 当前系统推荐的命令
"pm": detectedPM, // 检测到的包管理器(空=未检测到)
"detected": detectedPM != "",
"protocol": displayName,
"all_commands": protoHints, // 所有平台的命令,供前端展示
}
}
// ---- 辅助函数 ----
// BinaryStatus 二进制安装状态
type BinaryStatus struct {
Name string `json:"name"`
Installed bool `json:"installed"`
Path string `json:"path"`
Source string `json:"source"` // managed / system / undeployed
Version string `json:"version"`
CanDeploy bool `json:"can_deploy"` // 是否支持平台下载(仅 frp)
InstallCmd string `json:"install_cmd"` // 包管理器安装命令(非 frp)
}
// protocolBins 各协议需要检测的二进制名
var protocolBins = map[string][]string{
"frp": {"frps", "frpc"},
"wireguard": {"wg", "wg-quick"},
"openvpn": {"openvpn"},
"strongswan": {"swanctl", "ipsec"},
}
// CheckBinary 检查单个协议的二进制状态
func CheckBinary(name string) BinaryStatus {
st := BinaryStatus{Name: name}
_, st.CanDeploy = deploySpecs[name] // frp/openvpn 支持平台下载
bins := protocolBins[name]
if len(bins) == 0 {
return st
}
// 1. 检查 data/bin/(平台下载部署的)
for _, binName := range bins {
managed := filepath.Join(BinDir(), binName)
if _, err := os.Stat(managed); err == nil {
st.Installed = true
st.Path = managed
st.Source = "managed"
if v, err := os.ReadFile(filepath.Join(BinDir(), name+".version")); err == nil {
st.Version = strings.TrimSpace(string(v))
}
return st
}
}
// 2. 检查系统 PATH
for _, binName := range bins {
if p, err := exec.LookPath(binName); err == nil {
st.Installed = true
st.Path = p
st.Source = "system"
return st
}
}
// 3. 未安装:返回安装命令(供系统包协议用)
hint := InstallHint(name)
if cmd, ok := hint["command"].(string); ok {
st.InstallCmd = cmd
}
return st
}
// CheckAll 检查所有协议二进制状态
func CheckAll() []BinaryStatus {
return []BinaryStatus{
CheckBinary("frp"),
CheckBinary("wireguard"),
CheckBinary("openvpn"),
CheckBinary("strongswan"),
}
}
// ---- 下载与解压工具 ----
func fetchLatestRelease(repo string) (*githubRelease, error) {
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "opsx-vpn")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("GitHub API 返回 %d", resp.StatusCode)
}
var release githubRelease
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return nil, err
}
return &release, nil
}
func downloadFile(url, dest string) error {
resp, err := httpClient.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("下载返回 %d", resp.StatusCode)
}
out, err := os.Create(dest)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}
func unzipTo(zipPath, dest string) error {
r, err := zip.OpenReader(zipPath)
if err != nil {
return err
}
defer r.Close()
if err := os.MkdirAll(dest, 0o755); err != nil {
return err
}
for _, f := range r.File {
path := filepath.Join(dest, f.Name)
if !strings.HasPrefix(path, filepath.Clean(dest)+string(os.PathSeparator)) {
continue // 防路径穿越
}
if f.FileInfo().IsDir() {
os.MkdirAll(path, 0o755)
continue
}
os.MkdirAll(filepath.Dir(path), 0o755)
out, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return err
}
rc, err := f.Open()
if err != nil {
out.Close()
return err
}
io.Copy(out, rc)
out.Close()
rc.Close()
}
return nil
}
func untargzTo(targzPath, dest string) error {
f, err := os.Open(targzPath)
if err != nil {
return err
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
return err
}
defer gz.Close()
tr := tar.NewReader(gz)
if err := os.MkdirAll(dest, 0o755); err != nil {
return err
}
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}
path := filepath.Join(dest, hdr.Name)
if !strings.HasPrefix(path, filepath.Clean(dest)+string(os.PathSeparator)) {
continue
}
switch hdr.Typeflag {
case tar.TypeDir:
os.MkdirAll(path, 0o755)
case tar.TypeReg:
os.MkdirAll(filepath.Dir(path), 0o755)
out, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, os.FileMode(hdr.Mode))
if err != nil {
return err
}
io.Copy(out, tr)
out.Close()
}
}
return nil
}
func findFile(dir, name string) string {
var found string
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if !info.IsDir() && info.Name() == name {
found = path
}
return nil
})
return found
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
}
+87 -27
View File
@@ -5,15 +5,16 @@ import "time"
// User 平台账号
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"uniqueIndex;size:64" json:"username"`
Email string `gorm:"size:128" json:"email"`
Password string `gorm:"size:128" json:"-"` // 仅本地用户保存 bcrypt 哈希
Role string `gorm:"size:16;default:viewer" json:"role"` // admin / operator / viewer
Source string `gorm:"size:16;default:local" json:"source"` // local / ldap
Enabled bool `gorm:"default:true" json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"uniqueIndex;size:64" json:"username"`
Email string `gorm:"size:128" json:"email"`
Password string `gorm:"size:128" json:"-"` // 仅本地用户保存 bcrypt 哈希
Role string `gorm:"size:16;default:viewer" json:"role"` // admin / operator / viewer
Source string `gorm:"size:16;default:local" json:"source"` // local / ldap
Enabled bool `gorm:"default:true" json:"enabled"`
MustChangePassword bool `gorm:"default:false" json:"must_change_password"` // 首登/被重置后强制改密
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// LDAPConfig LDAP 身份源配置。BindPassword 由 store.EncryptSecret 加密后保存。
@@ -25,6 +26,7 @@ type LDAPConfig struct {
BaseDN string `gorm:"size:256" json:"base_dn"`
UserFilter string `gorm:"size:512" json:"user_filter"`
UsernameAttribute string `gorm:"size:64" json:"username_attribute"`
StartTLS bool `gorm:"default:false" json:"starttls"` // ldap:// 时升级为 StartTLS
Enabled bool `gorm:"default:false" json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@@ -42,22 +44,35 @@ type MFADevice struct {
}
// VPNUser 是拨号身份,不等同于管理平台登录账号;可来自本地或 LDAP。
// Password 保存 bcrypt(OpenVPN 校验);DialSecret 保存 AES-GCM 可逆密文,
// 供 L2TP/IKEv2 等需要明文 CHAP/EAP 的协议重新生成认证文件。
// MFASecret / MFABackupCodes 为 OpenVPN 拨号 TOTP(AES-GCM 加密)。
type VPNUser struct {
ID uint `gorm:"primaryKey" json:"id"`
TunnelID uint `gorm:"index" json:"tunnel_id"`
Username string `gorm:"size:128;uniqueIndex:idx_vpn_user" json:"username"`
DisplayName string `gorm:"size:128" json:"display_name"`
Email string `gorm:"size:128" json:"email"`
Source string `gorm:"size:16;default:local" json:"source"` // local / ldap
LDAPDN string `gorm:"size:512" json:"ldap_dn"`
Password string `gorm:"size:128" json:"-"`
Enabled bool `gorm:"default:true" json:"enabled"`
Certificate string `gorm:"size:128" json:"certificate"`
RxBytes int64 `gorm:"default:0" json:"rx_bytes"`
TxBytes int64 `gorm:"default:0" json:"tx_bytes"`
LastSeenAt *time.Time `json:"last_seen_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint `gorm:"primaryKey" json:"id"`
TunnelID uint `gorm:"index;uniqueIndex:idx_vpn_user" json:"tunnel_id"`
Username string `gorm:"size:128;uniqueIndex:idx_vpn_user" json:"username"`
DisplayName string `gorm:"size:128" json:"display_name"`
Email string `gorm:"size:128" json:"email"`
Source string `gorm:"size:16;default:local" json:"source"` // local / ldap
LDAPDN string `gorm:"size:512" json:"ldap_dn"`
Password string `gorm:"size:128" json:"-"`
DialSecret string `gorm:"type:text" json:"-"` // AES-GCM,L2TP/IKEv2 拨号用
MFASecret string `gorm:"type:text" json:"-"` // AES-GCM TOTP secret(base32,兼容主流 Authenticator 应用)
MFABackupCodes string `gorm:"type:text" json:"-"` // AES-GCM JSON 数组
MFAPin string `gorm:"type:text" json:"-"` // AES-GCM,可选静态 PIN(连接时第二因子 PIN+OTP)
MFAEnabled bool `gorm:"default:false" json:"mfa_enabled"`
MFAHasPin bool `gorm:"default:false" json:"mfa_has_pin"` // 是否设置了 PIN(不回显明文)
MFAFailCount int `gorm:"default:0" json:"mfa_fail_count"`
MFALockedUntil *time.Time `json:"mfa_locked_until"`
MFALastUsedAt *time.Time `json:"mfa_last_used_at"`
MFALastCounter int64 `gorm:"default:0" json:"-"` // 上次成功 TOTP 时间窗,防重放
Enabled bool `gorm:"default:true" json:"enabled"`
Certificate string `gorm:"size:128" json:"certificate"`
RxBytes int64 `gorm:"default:0" json:"rx_bytes"`
TxBytes int64 `gorm:"default:0" json:"tx_bytes"`
LastSeenAt *time.Time `json:"last_seen_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Tunnel 专线实例(所有协议统一存储,协议参数存 Config JSON)
@@ -80,13 +95,19 @@ type Tunnel struct {
UpdatedAt time.Time `json:"updated_at"`
}
// Node 中继节点(本机为内置节点)
// Node 中继节点(本机为内置节点),remote 节点支持 SSH 凭证验证 // Logan.Li
type Node struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:128" json:"name"`
Address string `gorm:"size:256" json:"address"` // 节点地址(API 或 SSH)
Kind string `gorm:"size:32;default:local" json:"kind"` // local / remote
Address string `gorm:"size:256" json:"address"` // 节点地址(IP 或域名)
Port int `gorm:"default:22" json:"port"` // SSH 端口
Kind string `gorm:"size:32;default:local" json:"kind"` // local / remote
Username string `gorm:"size:64" json:"username"` // SSH 登录用户
AuthType string `gorm:"size:16;default:password" json:"auth_type"` // password / key
Secrets string `gorm:"type:text" json:"-"` // AES-GCM 加密的 {password, private_key} JSON
Status string `gorm:"size:16;default:unknown" json:"status"` // verified / failed / unknown
Remark string `gorm:"size:512" json:"remark"`
UpdatedAt time.Time `json:"updated_at"`
CreatedAt time.Time `json:"created_at"`
}
@@ -115,3 +136,42 @@ type AuditLog struct {
Detail string `gorm:"size:1024" json:"detail"`
CreatedAt time.Time `json:"created_at"`
}
// VPNSession 在线会话(跨协议统一记录拨号用户的连接状态) // Logan.Li
type VPNSession struct {
ID uint `gorm:"primaryKey" json:"id"`
TunnelID uint `gorm:"index" json:"tunnel_id"`
VPNUserID uint `gorm:"index" json:"vpn_user_id"`
Username string `gorm:"size:128" json:"username"`
Protocol string `gorm:"size:32" json:"protocol"`
RealAddress string `gorm:"size:64" json:"real_address"` // 客户端公网 IP:Port
VirtualAddress string `gorm:"size:64" json:"virtual_address"` // 分配的 VPN IP
ConnectedAt time.Time `json:"connected_at"`
DisconnectedAt *time.Time `json:"disconnected_at"`
RxBytes int64 `gorm:"default:0" json:"rx_bytes"`
TxBytes int64 `gorm:"default:0" json:"tx_bytes"`
Status string `gorm:"size:16;default:active" json:"status"` // active / disconnected
}
// ConnectionLog 连接审计日志(连接/断开/认证失败/配额超额/异常流量) // Logan.Li
type ConnectionLog struct {
ID uint `gorm:"primaryKey" json:"id"`
TunnelID uint `gorm:"index" json:"tunnel_id"`
Username string `gorm:"size:128" json:"username"`
Event string `gorm:"size:32" json:"event"` // connect / disconnect / auth_fail / quota_exceed / rate_alert
IPAddress string `gorm:"size:64" json:"ip_address"`
Detail string `gorm:"size:1024" json:"detail"`
CreatedAt time.Time `json:"created_at"`
}
// TrafficPolicy 流量配额与限速策略(挂在 Tunnel 上) // Logan.Li
type TrafficPolicy struct {
ID uint `gorm:"primaryKey" json:"id"`
TunnelID uint `gorm:"uniqueIndex" json:"tunnel_id"`
QuotaBytes int64 `gorm:"default:0" json:"quota_bytes"` // 总配额,0=不限
RateLimitMbps int `gorm:"default:0" json:"rate_limit_mbps"` // 限速 Mbps,0=不限
UsedBytes int64 `gorm:"default:0" json:"used_bytes"`
ResetCycle string `gorm:"size:16;default:none" json:"reset_cycle"` // daily / monthly / none
LastResetAt time.Time `json:"last_reset_at"`
ActionOnExceed string `gorm:"size:16;default:block" json:"action_on_exceed"` // warn / block / throttle
}
+56 -8
View File
@@ -1,4 +1,4 @@
// Package monitor 周期性采集各专线运行状态与流量
// Package monitor 周期性采集各专线运行状态、流量、会话与配额 // Logan.Li
package monitor
import (
@@ -6,24 +6,28 @@ import (
"vpn-platform/backend/internal/core"
"vpn-platform/backend/internal/model"
"vpn-platform/backend/internal/sessionmgr"
"vpn-platform/backend/internal/store"
"vpn-platform/backend/internal/trafficmgr"
)
const collectInterval = 15 * time.Second
// Start 启动状态采集循环(每 15 秒刷新一次)
func Start(mgr *core.Manager, interval time.Duration) {
if interval == 0 {
interval = 15 * time.Second
interval = collectInterval
}
ticker := time.NewTicker(interval)
go func() {
collect(mgr) // 启动即采集一次
collect(mgr, interval) // 启动即采集一次
for range ticker.C {
collect(mgr)
collect(mgr, interval)
}
}()
}
func collect(mgr *core.Manager) {
func collect(mgr *core.Manager, interval time.Duration) {
var tunnels []model.Tunnel
if err := store.DB.Find(&tunnels).Error; err != nil {
return
@@ -39,18 +43,62 @@ func collect(mgr *core.Manager) {
continue
}
mgr.SetStatus(t.ID, st)
// 数据库状态与协议真实状态同步,避免页面长期展示过时的“运行中”。
// 数据库状态同步
if st.State != "" && st.State != t.State {
store.DB.Model(t).Update("state", st.State)
}
// 流量持久化(仅在有增量时写库)
// 流量增量计算(用于配额/会话/异常检测)
deltaRx := st.RxBytes - t.RxBytes
deltaTx := st.TxBytes - t.TxBytes
if deltaRx < 0 {
deltaRx = st.RxBytes
}
if deltaTx < 0 {
deltaTx = st.TxBytes
}
// 流量持久化(隧道级)
if st.RxBytes > 0 || st.TxBytes > 0 {
store.DB.Model(t).Updates(map[string]interface{}{"rx_bytes": st.RxBytes, "tx_bytes": st.TxBytes})
}
// 到期自动停止
// ---- 会话同步(阶段 1)----
if st.Running {
// WireGuard 等多对端协议:从 Peers 同步
if len(st.Peers) > 0 {
sessionmgr.SyncFromPeers(t.ID, t.Protocol, st.Peers)
}
// OpenVPN 的会话同步在 collectTraffic 内部完成(已直接写 VPNUser)
} else {
// 隧道未运行 → 标记所有 active 会话为 disconnected
sessionmgr.SyncSessions(t.ID, t.Protocol, nil)
}
// ---- 流量配额检查(阶段 2)----
if deltaRx > 0 || deltaTx > 0 {
action := trafficmgr.CheckQuota(t.ID, deltaRx, deltaTx)
if action != "" {
trafficmgr.HandleExceed(t.ID, action, t.Name)
if action == "block" {
_ = p.Stop(t)
store.DB.Model(t).Update("state", "stopped")
store.Audit("system", "quota_block", t.Name, "流量配额超额,自动停止")
}
}
}
// ---- 异常流量告警(阶段 3)----
if st.Running && (deltaRx > 0 || deltaTx > 0) {
trafficmgr.CheckRateAbnormal(t.ID, deltaRx, deltaTx, interval)
}
// ---- 到期自动停止 ----
if t.ExpireAt != nil && time.Now().After(*t.ExpireAt) && t.State == "running" {
_ = p.Stop(t)
store.DB.Model(t).Update("state", "stopped")
store.Audit("system", "expire_stop", t.Name, "专线到期自动停止")
}
}
}
+21 -1
View File
@@ -164,22 +164,42 @@ func ReadPid(t *model.Tunnel) (int, bool) {
return pid, true
}
// KillPid 停止实例进程
// KillPid 优雅停止实例进程:先 SIGTERM,超时后再 SIGKILL。
func KillPid(t *model.Tunnel) error {
pid, alive := ReadPid(t)
if !alive {
_ = os.Remove(PidFile(t))
return nil
}
proc, err := os.FindProcess(pid)
if err != nil {
_ = os.Remove(PidFile(t))
return nil
}
_ = proc.Signal(syscall.SIGTERM)
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if err := proc.Signal(syscall.Signal(0)); err != nil {
_ = os.Remove(PidFile(t))
return nil
}
time.Sleep(100 * time.Millisecond)
}
_ = proc.Kill()
_, _ = proc.Wait()
_ = os.Remove(PidFile(t))
return nil
}
// RequireTCPPortAvailable 启动前检查 TCP 端口是否空闲。
func RequireTCPPortAvailable(port int, component string) error {
ln, err := net.Listen("tcp4", fmt.Sprintf(":%d", port))
if err != nil {
return fmt.Errorf("无法启动 %s:TCP %d 已被其他服务占用: %w", component, port, err)
}
return ln.Close()
}
// WriteConfig 写入实例配置文件(0600 权限)
func WriteConfig(t *model.Tunnel, name, content string) (string, error) {
if err := os.MkdirAll(InstanceDir(t), 0o700); err != nil {
+43 -12
View File
@@ -3,6 +3,8 @@ package frp
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
@@ -14,6 +16,17 @@ import (
"vpn-platform/backend/internal/store"
)
// findBin 查找 FRP 二进制路径:优先 data/bin/(平台下载),其次 PATH(系统安装)
func findBin(name string) (string, bool) {
// 1. data/bin/(平台下载部署的)
managed := filepath.Join(service.BaseDir(), "bin", name)
if fi, err := os.Stat(managed); err == nil && !fi.IsDir() {
return managed, true
}
// 2. 系统 PATH
return service.LookBin(name)
}
type Service struct{}
// Proxy is the supported, repeatable FRP proxy rule. It is deliberately kept
@@ -29,6 +42,7 @@ type Proxy struct {
Subdomain string `json:"subdomain"`
Locations []string `json:"locations"`
HostHeaderRewrite string `json:"host_header_rewrite"`
SecretKey string `json:"secret_key,omitempty"` // stcp/xtcp 访问密钥
}
var safeName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$`)
@@ -38,10 +52,13 @@ func (s *Service) Name() string { return "frp" }
func (s *Service) DisplayName() string { return "FRP 内网穿透" }
func (s *Service) Available() (bool, string) {
if p, ok := service.LookBin("frps", "frpc"); ok {
if p, ok := findBin("frps"); ok {
return true, p
}
return false, "未检测到 frps/frpc 二进制,可生成配置但无法启动"
if p, ok := findBin("frpc"); ok {
return true, p
}
return false, "未检测到 frps/frpc 二进制,点击「下载部署」可自动从 GitHub 下载"
}
func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, error) {
@@ -106,8 +123,10 @@ func validateProxy(p Proxy) error {
if !safeName.MatchString(p.Name) {
return fmt.Errorf("代理名称 %q 无效", p.Name)
}
if p.Type != "tcp" && p.Type != "udp" && p.Type != "http" && p.Type != "https" {
return fmt.Errorf("代理 %s 的类型必须为 tcp、udp、http 或 https", p.Name)
switch p.Type {
case "tcp", "udp", "http", "https", "stcp", "xtcp":
default:
return fmt.Errorf("代理 %s 的类型必须为 tcp、udp、http、https、stcp 或 xtcp", p.Name)
}
if p.LocalPort < 1 || p.LocalPort > 65535 {
return fmt.Errorf("代理 %s 的 local_port 无效", p.Name)
@@ -124,6 +143,15 @@ func validateProxy(p Proxy) error {
}
return nil
}
if p.Type == "stcp" || p.Type == "xtcp" {
if p.SecretKey == "" {
return fmt.Errorf("STCP/XTCP 代理 %s 必须填写 secret_key", p.Name)
}
if strings.ContainsAny(p.SecretKey, "\r\n\"'") {
return fmt.Errorf("代理 %s 的 secret_key 非法", p.Name)
}
return nil
}
if len(p.CustomDomains) == 0 && p.Subdomain == "" {
return fmt.Errorf("HTTP/HTTPS 代理 %s 必须填写 custom_domains 或 subdomain", p.Name)
}
@@ -218,9 +246,12 @@ func (s *Service) frpcConfig(cfg map[string]interface{}, token string) string {
rules, _ := proxyRules(cfg)
for _, p := range rules {
fmt.Fprintf(&b, "\n[[proxies]]\nname = %q\ntype = %q\nlocalIP = %q\nlocalPort = %d\n", p.Name, p.Type, defaultString(p.LocalIP, "127.0.0.1"), p.LocalPort)
if p.Type == "tcp" || p.Type == "udp" {
switch p.Type {
case "tcp", "udp":
fmt.Fprintf(&b, "remotePort = %d\n", p.RemotePort)
} else {
case "stcp", "xtcp":
fmt.Fprintf(&b, "secretKey = %q\n", p.SecretKey)
default:
if len(p.CustomDomains) > 0 {
fmt.Fprintf(&b, "customDomains = [%s]\n", strings.Join(quoteStrings(p.CustomDomains), ", "))
}
@@ -262,10 +293,10 @@ func (s *Service) Start(t *model.Tunnel) error {
if service.StrOr(cfg, "mode", "server") == "server" {
bin, conf = "frps", "frps.toml"
}
if p, ok := service.LookBin(bin); ok {
if p, ok := findBin(bin); ok {
return service.StartDaemon(t, p, "-c", fmt.Sprintf("%s/%s", service.InstanceDir(t), conf))
}
return fmt.Errorf("无法启动 FRP:未检测到 %s", bin)
return fmt.Errorf("无法启动 FRP:未检测到 %s,点击「下载部署」自动安装", bin)
}
func (s *Service) Stop(t *model.Tunnel) error { return service.KillPid(t) }
func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
@@ -277,7 +308,7 @@ func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
if service.StrOr(cfg, "mode", "server") == "server" {
bin = "frps"
}
if _, ok := service.LookBin(bin); !ok {
if _, ok := findBin(bin); !ok {
return service.NowStatus(false, "undeployed", fmt.Sprintf("配置已生成,但未检测到 %s,尚未部署", bin)), nil
}
return service.NowStatus(false, "stopped", "未启动"), nil
@@ -296,7 +327,7 @@ func proxyRules(cfg map[string]interface{}) ([]Proxy, error) {
if !ok {
return nil, fmt.Errorf("proxies 中每项必须是对象")
}
p := Proxy{Name: service.Str(m, "name"), Type: service.StrOr(m, "type", "tcp"), LocalIP: service.StrOr(m, "local_ip", "127.0.0.1"), LocalPort: int(service.Num(m, "local_port", 0)), RemotePort: int(service.Num(m, "remote_port", 0)), Subdomain: service.Str(m, "subdomain"), HostHeaderRewrite: service.Str(m, "host_header_rewrite")}
p := Proxy{Name: service.Str(m, "name"), Type: service.StrOr(m, "type", "tcp"), LocalIP: service.StrOr(m, "local_ip", "127.0.0.1"), LocalPort: int(service.Num(m, "local_port", 0)), RemotePort: int(service.Num(m, "remote_port", 0)), Subdomain: service.Str(m, "subdomain"), HostHeaderRewrite: service.Str(m, "host_header_rewrite"), SecretKey: service.Str(m, "secret_key")}
p.CustomDomains = stringList(m["custom_domains"])
p.Locations = stringList(m["locations"])
rules = append(rules, p)
@@ -304,14 +335,14 @@ func proxyRules(cfg map[string]interface{}) ([]Proxy, error) {
return rules, nil
}
if name := service.Str(cfg, "proxy_name"); name != "" {
return []Proxy{{Name: name, Type: service.StrOr(cfg, "proxy_type", "tcp"), LocalIP: service.StrOr(cfg, "local_ip", "127.0.0.1"), LocalPort: int(service.Num(cfg, "local_port", 0)), RemotePort: int(service.Num(cfg, "remote_port", 0)), CustomDomains: splitDomains(service.Str(cfg, "custom_domains"))}}, nil
return []Proxy{{Name: name, Type: service.StrOr(cfg, "proxy_type", "tcp"), LocalIP: service.StrOr(cfg, "local_ip", "127.0.0.1"), LocalPort: int(service.Num(cfg, "local_port", 0)), RemotePort: int(service.Num(cfg, "remote_port", 0)), CustomDomains: splitDomains(service.Str(cfg, "custom_domains")), SecretKey: service.Str(cfg, "secret_key")}}, nil
}
return nil, nil
}
func proxiesJSON(in []Proxy) []interface{} {
out := make([]interface{}, 0, len(in))
for _, p := range in {
out = append(out, map[string]interface{}{"name": p.Name, "type": p.Type, "local_ip": p.LocalIP, "local_port": p.LocalPort, "remote_port": p.RemotePort, "custom_domains": p.CustomDomains, "subdomain": p.Subdomain, "locations": p.Locations, "host_header_rewrite": p.HostHeaderRewrite})
out = append(out, map[string]interface{}{"name": p.Name, "type": p.Type, "local_ip": p.LocalIP, "local_port": p.LocalPort, "remote_port": p.RemotePort, "custom_domains": p.CustomDomains, "subdomain": p.Subdomain, "locations": p.Locations, "host_header_rewrite": p.HostHeaderRewrite, "secret_key": p.SecretKey})
}
return out
}
+427
View File
@@ -0,0 +1,427 @@
// Package ikev2 实现 IKEv2 远程接入(Road Warrior):员工手机/笔记本拨号进内网。
// 依赖 strongSwan swanctl;认证为「服务器 PSK + 用户 EAP-MSCHAPv2」。
// 拨号用户来自 VPNUser.DialSecret(可逆加密)与隧道级默认用户。
package ikev2
import (
"fmt"
"net/netip"
"os"
"strconv"
"strings"
"vpn-platform/backend/internal/core"
"vpn-platform/backend/internal/model"
"vpn-platform/backend/internal/service"
"vpn-platform/backend/internal/store"
)
type Service struct{}
func New() *Service { return &Service{} }
func (s *Service) Name() string { return "ikev2" }
func (s *Service) DisplayName() string { return "IKEv2 远程接入" }
func (s *Service) Available() (bool, string) {
if p, ok := service.LookBin("swanctl"); ok {
return true, p
}
return false, "未检测到 strongSwan(swanctl),可生成配置但无法启动"
}
func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, error) {
out := map[string]interface{}{}
for k, v := range cfg {
out[k] = v
}
if service.Str(out, "address_pool") == "" {
out["address_pool"] = "10.12.0.0/24"
}
if _, err := netip.ParsePrefix(service.Str(out, "address_pool")); err != nil {
return nil, fmt.Errorf("address_pool 必须是 CIDR,例如 10.12.0.0/24")
}
if service.Str(out, "dns") == "" {
out["dns"] = "223.5.5.5"
}
if service.Str(out, "local_id") == "" {
out["local_id"] = "@opsx-ikev2"
}
if service.Str(out, "ike_proposal") == "" {
out["ike_proposal"] = "aes256-sha256-modp2048"
}
if service.Str(out, "esp_proposal") == "" {
out["esp_proposal"] = "aes256-sha256"
}
if service.Str(out, "local_ts") == "" {
out["local_ts"] = "0.0.0.0/0"
}
for _, key := range []string{"local_id", "ike_proposal", "esp_proposal", "local_ts"} {
if strings.ContainsAny(service.Str(out, key), "\r\n{}\"") {
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
}
func (s *Service) Create(t *model.Tunnel) error {
cfg, err := store.TunnelConfig(t)
if err != nil {
return err
}
secrets, _ := store.TunnelSecrets(t)
if psk := service.Str(cfg, "psk"); psk != "" {
secrets["psk"] = psk
} else if service.Str(secrets, "psk") == "" {
psk, err := service.RandomSecret(24)
if err != nil {
return err
}
secrets["psk"] = psk
}
if pw := service.Str(cfg, "password"); pw != "" {
secrets["password"] = pw
}
username := service.StrOr(cfg, "username", "vpnuser")
dialUsers, err := 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)
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 {
return err
}
public, sec := service.MergeSecrets(cfg, "psk", "password")
for k, v := range secrets {
sec[k] = v
}
return service.SaveTunnelConfig(t, public, sec)
}
// RefreshAuth rewrites EAP secrets after dial-user changes.
func (s *Service) RefreshAuth(t *model.Tunnel) error { return s.Create(t) }
func (s *Service) swanctlConf(id uint, cfg map[string]interface{}, psk, defaultUser, defaultPass string, users []dialUser) string {
var secrets strings.Builder
fmt.Fprintf(&secrets, `secrets {
ike-rw-%d {
id = %s
secret = %s
}
`, id, service.Str(cfg, "local_id"), strconv.Quote(psk))
seen := map[string]bool{}
if defaultUser != "" && defaultPass != "" {
fmt.Fprintf(&secrets, ` eap-%s-%d {
id = %s
secret = %s
}
`, defaultUser, id, strconv.Quote(defaultUser), strconv.Quote(defaultPass))
seen[defaultUser] = true
}
for i, u := range users {
if u.Username == "" || u.Password == "" || seen[u.Username] {
continue
}
fmt.Fprintf(&secrets, ` eap-user-%d-%d {
id = %s
secret = %s
}
`, id, i, strconv.Quote(u.Username), strconv.Quote(u.Password))
seen[u.Username] = true
}
secrets.WriteString("}\n")
return fmt.Sprintf(`connections {
ikev2-rw-%d {
version = 2
pools = pool-%d
proposals = %s
dpd_delay = 30s
dpd_timeout = 120s
local {
auth = psk
id = %s
}
remote {
auth = eap-mschapv2
eap_id = %%any
}
children {
net-%d {
local_ts = %s
esp_proposals = %s
dpd_action = clear
start_action = none
}
}
}
}
pools {
pool-%d {
addrs = %s
dns = %s
}
}
%s`,
id,
id,
service.Str(cfg, "ike_proposal"),
service.Str(cfg, "local_id"),
id,
service.Str(cfg, "local_ts"),
service.Str(cfg, "esp_proposal"),
id,
service.Str(cfg, "address_pool"),
service.StrOr(cfg, "dns", "223.5.5.5"),
secrets.String(),
)
}
func (s *Service) clientGuide(cfg, secrets map[string]interface{}, username string) string {
return fmt.Sprintf(`======== IKEv2 远程接入参数 ========
服务器地址 : %s
VPN 类型 : IKEv2
远程 ID / 服务器 ID : %s
本地 ID : (可留空,或填用户名)
机器认证 : 预共享密钥 (PSK)
预共享密钥 : %s
用户认证 : 用户名 + 密码(EAP-MSCHAPv2)
默认用户名 : %s
默认密码 : %s
地址池 : %s
DNS : %s
客户端配置指引:
- iOS/macOS : 设置 → 通用 → VPN 与设备管理 → VPN → 添加 IKEv2
服务器/远程 ID 填上表;认证用用户名密码;共享密钥填 PSK
- Windows : 设置 → 网络和 Internet → VPN → 添加 IKEv2
- Android : 系统 VPN 或 strongSwan 客户端,类型选 IKEv2 EAP
- 更多账号 : 在平台「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", "223.5.5.5"),
)
}
func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) {
cfg, err := store.TunnelConfig(t)
if err != nil {
return "", err
}
secrets, _ := store.TunnelSecrets(t)
return s.clientGuide(cfg, secrets, service.StrOr(cfg, "username", "vpnuser")), nil
}
func (s *Service) Start(t *model.Tunnel) error {
confPath := service.InstanceDir(t) + "/swanctl-ikev2.conf"
bin, ok := service.LookBin("swanctl")
if !ok {
return fmt.Errorf("无法启动 IKEv2:未检测到 swanctl,请先安装 strongSwan")
}
if err := service.RequireRoot("IKEv2"); err != nil {
return err
}
if err := service.RequireIPv4Forwarding("IKEv2"); err != nil {
return err
}
if _, err := os.Stat(confPath); err != nil {
return fmt.Errorf("无法启动 IKEv2:配置文件不存在,请先重新生成配置: %w", err)
}
cfg, err := store.TunnelConfig(t)
if err != nil {
return err
}
if err := s.installFirewall(t, cfg); err != nil {
return err
}
_ = s.unload(bin, confPath)
if _, err := service.Run(bin, "--load-all", "--file", confPath); err != nil {
_ = s.removeFirewall(t, cfg)
return err
}
return nil
}
func (s *Service) Stop(t *model.Tunnel) error {
var firstErr error
if bin, ok := service.LookBin("swanctl"); ok {
_, _ = service.Run(bin, "--terminate", "--ike", fmt.Sprintf("ikev2-rw-%d", t.ID))
if err := s.unload(bin, service.InstanceDir(t)+"/swanctl-ikev2.conf"); err != nil {
firstErr = err
}
}
if cfg, err := store.TunnelConfig(t); err == nil {
if err := s.removeFirewall(t, cfg); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
if bin, ok := service.LookBin("swanctl"); ok {
// Road Warrior 多数时候无固定 SA;配置已加载即视为服务就绪。
out, err := service.Run(bin, "--list-conns")
name := fmt.Sprintf("ikev2-rw-%d", t.ID)
if err != nil {
// list-conns 失败时再查 SA
if sas, e2 := service.Run(bin, "--list-sas"); e2 == nil && strings.Contains(sas, name) {
return service.NowStatus(true, "running", "IKEv2 远程接入运行中(有已建立会话)"), nil
}
return service.NowStatus(false, "stopped", "无法确认 IKEv2 连接定义是否已加载"), nil
}
if strings.Contains(out, name) {
detail := "IKEv2 远程接入已加载,等待客户端拨号"
if sas, e2 := service.Run(bin, "--list-sas"); e2 == nil {
// 粗略统计会话数
n := strings.Count(sas, name)
if n > 0 {
detail = fmt.Sprintf("IKEv2 远程接入运行中,约 %d 个相关 SA", n)
}
}
return service.NowStatus(true, "running", detail), nil
}
return service.NowStatus(false, "stopped", "IKEv2 连接定义未加载"), nil
}
return service.NowStatus(false, "undeployed", "配置已生成,但未检测到 strongSwan(swanctl),尚未部署"), nil
}
func (s *Service) Delete(t *model.Tunnel) error { return s.Stop(t) }
func (s *Service) unload(bin, confPath string) error {
_, _ = service.Run(bin, "--unload-conns", "--file", confPath)
_, _ = service.Run(bin, "--unload-creds", "--file", confPath)
return nil
}
type dialUser struct {
Username string
Password string
}
func loadDialUsers(tunnelID uint) ([]dialUser, error) {
if store.DB == nil {
return nil, nil
}
var users []model.VPNUser
if err := store.DB.Where("tunnel_id = ? AND enabled = ? AND source = ?", tunnelID, true, "local").Find(&users).Error; err != nil {
return nil, err
}
out := make([]dialUser, 0, len(users))
for _, u := range users {
if u.DialSecret == "" {
continue
}
pw, err := store.DecryptSecret(u.DialSecret)
if err != nil || pw == "" {
continue
}
out = append(out, dialUser{Username: u.Username, Password: pw})
}
return out, nil
}
func (s *Service) installFirewall(t *model.Tunnel, cfg map[string]interface{}) error {
iptables, ok := service.LookBin("iptables")
if !ok {
return fmt.Errorf("无法启动 IKEv2:未检测到 iptables")
}
comment := fmt.Sprintf("opsx-vpn-ikev2-%d", t.ID)
outbound := service.Str(cfg, "outbound_interface")
if outbound == "" {
var err error
outbound, err = service.DefaultRouteInterface()
if err != nil {
return err
}
}
rules := []struct {
table, chain string
args []string
}{
{"filter", "INPUT", []string{"-p", "udp", "--dport", "500", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
{"filter", "INPUT", []string{"-p", "udp", "--dport", "4500", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
{"filter", "INPUT", []string{"-p", "esp", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
{"nat", "POSTROUTING", []string{"-s", service.Str(cfg, "address_pool"), "-o", outbound, "-m", "comment", "--comment", comment, "-j", "MASQUERADE"}},
}
for _, rule := range rules {
prefix := []string{}
if rule.table != "filter" {
prefix = append(prefix, "-t", rule.table)
}
check := append(append([]string{}, prefix...), "-C", rule.chain)
check = append(check, rule.args...)
if _, err := service.Run(iptables, check...); err == nil {
continue
}
add := append(append([]string{}, prefix...), "-A", rule.chain)
add = append(add, rule.args...)
if _, err := service.Run(iptables, add...); err != nil {
_ = s.removeFirewall(t, cfg)
return fmt.Errorf("无法添加 IKEv2 防火墙规则: %w", err)
}
}
return nil
}
func (s *Service) removeFirewall(t *model.Tunnel, cfg map[string]interface{}) error {
iptables, ok := service.LookBin("iptables")
if !ok {
return nil
}
comment := fmt.Sprintf("opsx-vpn-ikev2-%d", t.ID)
outbound := service.Str(cfg, "outbound_interface")
if outbound == "" {
outbound, _ = service.DefaultRouteInterface()
}
rules := []struct {
table, chain string
args []string
}{
{"filter", "INPUT", []string{"-p", "udp", "--dport", "500", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
{"filter", "INPUT", []string{"-p", "udp", "--dport", "4500", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
{"filter", "INPUT", []string{"-p", "esp", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
{"nat", "POSTROUTING", []string{"-s", service.Str(cfg, "address_pool"), "-o", outbound, "-m", "comment", "--comment", comment, "-j", "MASQUERADE"}},
}
for _, rule := range rules {
prefix := []string{}
if rule.table != "filter" {
prefix = append(prefix, "-t", rule.table)
}
for {
check := append(append([]string{}, prefix...), "-C", rule.chain)
check = append(check, rule.args...)
if _, err := service.Run(iptables, check...); err != nil {
break
}
remove := append(append([]string{}, prefix...), "-D", rule.chain)
remove = append(remove, rule.args...)
if _, err := service.Run(iptables, remove...); err != nil {
return err
}
}
}
return nil
}
@@ -0,0 +1,18 @@
package ikev2
import "testing"
func TestValidateDefaults(t *testing.T) {
cfg, err := New().Validate(map[string]interface{}{
"public_endpoint": "1.2.3.4",
})
if err != nil {
t.Fatal(err)
}
if cfg["address_pool"] != "10.12.0.0/24" || cfg["local_id"] != "@opsx-ikev2" {
t.Fatalf("defaults: %#v", cfg)
}
if _, err := New().Validate(map[string]interface{}{"address_pool": "not-a-cidr"}); err == nil {
t.Fatal("expected invalid pool error")
}
}
+86 -4
View File
@@ -59,6 +59,27 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{},
for k, v := range cfg {
out[k] = v
}
// 厂商模板优先填空字段,再回落到通用默认值。
if key := service.Str(out, "vendor_template"); key != "" {
for _, tmpl := range Templates() {
if tmpl.Key != key {
continue
}
if service.Str(out, "ike_version") == "" {
out["ike_version"] = tmpl.IKEVersion
}
if service.Str(out, "ike_proposal") == "" {
out["ike_proposal"] = tmpl.IKEProposal
}
if service.Str(out, "esp_proposal") == "" {
out["esp_proposal"] = tmpl.ESPProposal
}
if service.Str(out, "dpd_action") == "" {
out["dpd_action"] = tmpl.DPDAction
}
break
}
}
if service.Str(out, "ike_version") == "" {
out["ike_version"] = "ikev2"
}
@@ -77,6 +98,12 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{},
if service.Str(out, "remote_id") == "" {
out["remote_id"] = "@vpn-remote"
}
if service.Num(out, "ike_lifetime", 0) == 0 {
out["ike_lifetime"] = 86400
}
if service.Str(out, "pfs_group") == "" {
out["pfs_group"] = "modp2048"
}
remoteGateway := service.Str(out, "remote_gateway")
// StrongSwan accepts both literal addresses and DNS names. Keep DNS support
// for peers with dynamic addresses, while rejecting syntax that can alter the
@@ -347,18 +374,73 @@ func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
if bin, ok := service.LookBin("swanctl"); ok {
out, err := service.Run(bin, "--list-sas")
child := fmt.Sprintf("site-%d", t.ID)
if err == nil && strings.Contains(out, child) && strings.Contains(out, "INSTALLED") {
st := service.NowStatus(true, "running", "IKE/Child SA 已建立")
return st, nil
}
ike := fmt.Sprintf("tunnel-%d", t.ID)
if err != nil {
return service.NowStatus(false, "error", "无法读取 strongSwan SA 状态: "+err.Error()), nil
}
if strings.Contains(out, child) && strings.Contains(out, "INSTALLED") {
detail := "IKE/Child SA 已建立"
// 从 swanctl 输出中尽量提取对端地址与字节计数(格式因版本略有差异)。
if peer := extractSwanPeer(out, ike); peer != "" {
detail += ",对端 " + peer
}
if rx, tx, ok := extractSwanBytes(out, child); ok {
st := service.NowStatus(true, "running", detail)
st.RxBytes = rx
st.TxBytes = tx
return st, nil
}
return service.NowStatus(true, "running", detail), nil
}
if strings.Contains(out, ike) {
return service.NowStatus(false, "stopped", "IKE SA 存在但 Child SA 未 INSTALLED"), nil
}
return service.NowStatus(false, "stopped", "IPSec Child SA 未建立"), nil
}
return service.NowStatus(false, "undeployed", "配置已生成,但未检测到 strongSwan(swanctl),尚未部署"), nil
}
func extractSwanPeer(out, ikeName string) string {
// 典型行: tunnel-1: #1, ESTABLISHED, ... 1.2.3.4[... ]...5.6.7.8[...]
for _, line := range strings.Split(out, "\n") {
if !strings.Contains(line, ikeName) {
continue
}
fields := strings.Fields(line)
for _, f := range fields {
if strings.Contains(f, "[") && strings.Count(f, ".") >= 3 {
return strings.Split(f, "[")[0]
}
}
}
return ""
}
func extractSwanBytes(out, childName string) (rx, tx int64, ok bool) {
// 寻找含 child 名与 bytes_i/bytes_o 的行
for _, line := range strings.Split(out, "\n") {
if !strings.Contains(line, childName) {
continue
}
// 兼容 "123 bytes_i, 456 bytes_o" 与 "bytes_i/bytes_o"
parts := strings.Fields(line)
for i, p := range parts {
if strings.Contains(p, "bytes_i") && i > 0 {
fmt.Sscanf(parts[i-1], "%d", &rx)
ok = true
}
if strings.Contains(p, "bytes_o") && i > 0 {
fmt.Sscanf(parts[i-1], "%d", &tx)
ok = true
}
}
if ok {
return
}
}
return 0, 0, false
}
func (s *Service) Delete(t *model.Tunnel) error { return s.Stop(t) }
func (s *Service) unload(bin, confPath string) error {
+59 -5
View File
@@ -106,8 +106,14 @@ func (s *Service) Create(t *model.Tunnel) error {
username := service.StrOr(cfg, "username", "vpnuser")
if pw := service.Str(cfg, "password"); pw != "" {
secrets["password"] = pw
} else if service.Str(secrets, "password") == "" {
return fmt.Errorf("缺少拨号密码 password")
}
// 允许仅依赖拨号用户表(VPNUser)而无隧道级默认密码。
dialUsers, err := 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)
@@ -172,9 +178,7 @@ mru 1400
require-mschap-v2
`, service.Str(cfg, "dns"))
chapSecrets := fmt.Sprintf(`# client server secret IP addresses
%s l2tpd %s *
`, username, strconv.Quote(service.Str(secrets, "password")))
chapSecrets := buildChapSecrets(username, service.Str(secrets, "password"), dialUsers)
for name, content := range map[string]string{
"swanctl-l2tp.conf": swanConf,
@@ -193,6 +197,56 @@ require-mschap-v2
return service.SaveTunnelConfig(t, public, sec)
}
// RefreshAuth rewrites chap-secrets after VPN dial users change.
func (s *Service) RefreshAuth(t *model.Tunnel) error {
return s.Create(t)
}
type dialUser struct {
Username string
Password string
}
func loadDialUsers(tunnelID uint) ([]dialUser, error) {
if store.DB == nil {
return nil, nil
}
var users []model.VPNUser
if err := store.DB.Where("tunnel_id = ? AND enabled = ? AND source = ?", tunnelID, true, "local").Find(&users).Error; err != nil {
return nil, err
}
out := make([]dialUser, 0, len(users))
for _, u := range users {
if u.DialSecret == "" {
continue
}
pw, err := store.DecryptSecret(u.DialSecret)
if err != nil || pw == "" {
continue
}
out = append(out, dialUser{Username: u.Username, Password: pw})
}
return out, nil
}
func buildChapSecrets(defaultUser, defaultPass string, users []dialUser) string {
var b strings.Builder
b.WriteString("# client server secret IP addresses\n")
seen := map[string]bool{}
if defaultUser != "" && defaultPass != "" {
fmt.Fprintf(&b, "%s l2tpd %s *\n", defaultUser, strconv.Quote(defaultPass))
seen[defaultUser] = true
}
for _, u := range users {
if u.Username == "" || u.Password == "" || seen[u.Username] {
continue
}
fmt.Fprintf(&b, "%s l2tpd %s *\n", u.Username, strconv.Quote(u.Password))
seen[u.Username] = true
}
return b.String()
}
// GenerateClientConfig 导出客户端接入说明(手机/Windows 手动配置)
func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) {
cfg, err := store.TunnelConfig(t)
+765 -20
View File
@@ -4,6 +4,8 @@
package openvpn
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"flag"
@@ -15,18 +17,30 @@ import (
"regexp"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/go-ldap/ldap/v3"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"vpn-platform/backend/internal/core"
"vpn-platform/backend/internal/model"
"vpn-platform/backend/internal/service"
"vpn-platform/backend/internal/sessionmgr"
"vpn-platform/backend/internal/store"
)
// TOTP 参数(RFC 6238 标准:sha1 / 30s / 6 位 / base32),兼容主流 Authenticator 应用。
const (
otpPeriod = 30
otpDigits = 6
otpDefaultSlop = 90
)
type Service struct{}
var certificateName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$`)
@@ -34,11 +48,21 @@ var certificateName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$`)
func New() *Service { return &Service{} }
func (s *Service) Name() string { return "openvpn" }
func (s *Service) DisplayName() string { return "OpenVPN" }
// findOpenVPNBin 查找 openvpn 二进制:优先 data/bin/(平台下载),其次 PATH(系统安装)
func findOpenVPNBin() (string, bool) {
managed := filepath.Join(service.BaseDir(), "bin", "openvpn")
if fi, err := os.Stat(managed); err == nil && !fi.IsDir() {
return managed, true
}
return service.LookBin("openvpn")
}
func (s *Service) Available() (bool, string) {
if p, ok := service.LookBin("openvpn"); ok {
if p, ok := findOpenVPNBin(); ok {
return true, p
}
return false, "未检测到 openvpn 二进制,可生成配置但无法启动"
return false, "未检测到 openvpn 二进制,点击「下载部署」可自动从 GitHub 下载"
}
func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, error) {
@@ -55,8 +79,14 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{},
if service.Str(out, "proto") == "" {
out["proto"] = "udp"
}
if p := service.Str(out, "proto"); p != "udp" && p != "tcp-server" {
return nil, fmt.Errorf("proto 必须为 udp 或 tcp-server")
// 前端/API 可用 udp | tcp | tcp-server;服务端统一写成 udp 或 tcp-server。
switch strings.ToLower(service.Str(out, "proto")) {
case "tcp", "tcp-server", "tcp-client":
out["proto"] = "tcp-server"
case "udp":
out["proto"] = "udp"
default:
return nil, fmt.Errorf("proto 必须为 udp 或 tcp")
}
if service.Str(out, "subnet") == "" {
out["subnet"] = "10.10.0.0 255.255.255.0"
@@ -84,9 +114,126 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{},
default:
return nil, fmt.Errorf("auth_mode 必须为 certificate、password 或 certificate+password")
}
// MFA 模式:off 关闭;optional 用户启用后校验;required 强制。
if service.Str(out, "mfa_mode") == "" {
if service.Bool(out, "require_mfa", false) {
out["mfa_mode"] = "required"
} else {
out["mfa_mode"] = "off"
}
}
switch service.Str(out, "mfa_mode") {
case "off", "optional", "required":
default:
return nil, fmt.Errorf("mfa_mode 必须为 off、optional 或 required")
}
if service.Str(out, "mfa_mode") != "off" {
mode := service.Str(out, "auth_mode")
if mode != "password" && mode != "certificate+password" {
return nil, fmt.Errorf("启用 MFA 需要认证方式为「仅用户名密码」或「证书 + 密码」")
}
}
// otp_slop:允许时钟偏差秒数(默认 180),换算为 TOTP skew 窗口数。
if service.Num(out, "otp_slop", 0) == 0 {
out["otp_slop"] = 90
}
if slop := int(service.Num(out, "otp_slop", 90)); slop < 0 || slop > 600 {
return nil, fmt.Errorf("otp_slop 必须在 0-600 秒")
}
if service.Num(out, "otp_max_failures", 0) == 0 {
out["otp_max_failures"] = 5
}
if service.Num(out, "otp_lockout_sec", 0) == 0 {
out["otp_lockout_sec"] = 300
}
if _, ok := out["otp_disallow_reuse"]; !ok {
out["otp_disallow_reuse"] = true
}
if _, ok := out["otp_password_is_cr"]; !ok {
// 默认 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 _, ok := out["redirect_gateway"]; !ok {
// 未填 push_routes 时默认全流量;有 push_routes 时默认仅推内网。
out["redirect_gateway"] = service.Str(out, "push_routes") == ""
}
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 iface, err := service.DefaultRouteInterface(); err == nil {
out["egress_interface"] = iface
}
}
// —— 生产 server.conf 常用调优(可借鉴参数)——
if service.Num(out, "keepalive_interval", 0) == 0 {
out["keepalive_interval"] = 5 // 生产常见 5 30,比默认 10 120 更灵敏
}
if service.Num(out, "keepalive_timeout", 0) == 0 {
out["keepalive_timeout"] = 30
}
ki, kt := int(service.Num(out, "keepalive_interval", 5)), int(service.Num(out, "keepalive_timeout", 30))
if ki < 1 || ki > 3600 || kt < ki || kt > 7200 {
return nil, fmt.Errorf("keepalive 参数无效:interval 1-3600,timeout >= interval 且 ≤7200")
}
if service.Num(out, "tun_mtu", 0) == 0 {
out["tun_mtu"] = 1500
}
if mtu := int(service.Num(out, "tun_mtu", 1500)); mtu < 576 || mtu > 9000 {
return nil, fmt.Errorf("tun_mtu 必须在 576-9000")
}
if service.Num(out, "tun_mtu_extra", 0) == 0 {
out["tun_mtu_extra"] = 32
}
// TCP 场景生产常用 mssfix 1450,避免分片;UDP 也可设。0 表示不写该指令。
if _, ok := out["mssfix"]; !ok {
if service.Str(out, "proto") == "tcp-server" {
out["mssfix"] = 1450
} else {
out["mssfix"] = 0
}
}
if mf := int(service.Num(out, "mssfix", 0)); mf != 0 && (mf < 500 || mf > 9000) {
return nil, fmt.Errorf("mssfix 为 0(禁用)或 500-9000")
}
if _, ok := out["duplicate_cn"]; !ok {
// 密码拨号多终端同用户名时生产常开;证书模式默认关(安全)。
out["duplicate_cn"] = service.Str(out, "auth_mode") == "password"
}
if _, ok := out["reneg_sec"]; !ok {
// 生产常见 reneg-sec 0;MFA/密码场景避免周期性 TLS 重协商。
if service.Str(out, "auth_mode") != "certificate" || service.Str(out, "mfa_mode") != "off" {
out["reneg_sec"] = 0
} else {
out["reneg_sec"] = 3600
}
}
if rs := int(service.Num(out, "reneg_sec", 0)); rs < 0 || rs > 86400 {
return nil, fmt.Errorf("reneg_sec 必须在 0-86400")
}
if service.Num(out, "verb", 0) == 0 {
out["verb"] = 3
}
// 可选降权(生产 user openvpn / group openvpn);空则不写,避免非 root 启动失败。
for _, key := range []string{"run_as_user", "run_as_group"} {
if v := service.Str(out, key); v != "" && !regexp.MustCompile(`^[a-zA-Z0-9_-]{1,32}$`).MatchString(v) {
return nil, fmt.Errorf("%s 非法", key)
}
}
if service.Num(out, "max_clients", 0) == 0 {
// 0 表示不限制;生产常设 1000,这里不强制默认值以免改变既有语义。
}
return out, nil
}
@@ -125,7 +272,7 @@ func (s *Service) Create(t *model.Tunnel) error {
}
// PKI creation is intentionally best-effort: a missing easy-rsa must not be
// reported as a running server. Start() verifies every required artifact.
_, openvpnInstalled := service.LookBin("openvpn")
_, openvpnInstalled := findOpenVPNBin()
if service.Bool(cfg, "pki_auto_init", true) && easyRSAPath() != "" && openvpnInstalled && pkiReady(t) != nil {
return s.InitializePKI(t)
}
@@ -136,14 +283,66 @@ func (s *Service) serverConfig(t *model.Tunnel, cfg map[string]interface{}) stri
dir := service.InstanceDir(t)
ca, cert, key, dh, ta := filepath.Join(dir, "pki", "ca.crt"), filepath.Join(dir, "pki", "issued", "server.crt"), filepath.Join(dir, "pki", "private", "server.key"), filepath.Join(dir, "pki", "dh.pem"), filepath.Join(dir, "ta.key")
var b strings.Builder
fmt.Fprintf(&b, "port %d\nproto %s\ndev tun\nca %q\ncert %q\nkey %q\ndh %q\ntls-crypt %q\n", int(service.Num(cfg, "port", 1194)), service.StrOr(cfg, "proto", "udp"), ca, cert, key, dh, ta)
fmt.Fprintf(&b, "topology subnet\nserver %s\nifconfig-pool-persist %q\npush \"dhcp-option DNS %s\"\npush \"redirect-gateway def1 bypass-dhcp\"\n", service.StrOr(cfg, "subnet", "10.10.0.0 255.255.255.0"), filepath.Join(dir, "ipp.txt"), service.StrOr(cfg, "dns", "223.5.5.5"))
fmt.Fprintf(&b, "keepalive 10 120\ndata-ciphers %s:AES-128-GCM\npersist-key\npersist-tun\nstatus %q 10\nstatus-version 3\nverb 3\n", service.StrOr(cfg, "cipher", "AES-256-GCM"), filepath.Join(dir, "openvpn-status.log"))
// 基础监听(对齐生产:proto/port/dev tun)
fmt.Fprintf(&b, "port %d\nproto %s\ndev tun\n", int(service.Num(cfg, "port", 1194)), service.StrOr(cfg, "proto", "udp"))
// MTU / MSS(生产:tun-mtu 1500、tun-mtu-extra 32、mssfix 1450)
fmt.Fprintf(&b, "tun-mtu %d\n", int(service.Num(cfg, "tun_mtu", 1500)))
if extra := int(service.Num(cfg, "tun_mtu_extra", 32)); extra > 0 {
fmt.Fprintf(&b, "tun-mtu-extra %d\n", extra)
}
if mf := int(service.Num(cfg, "mssfix", 0)); mf > 0 {
fmt.Fprintf(&b, "mssfix %d\n", mf)
}
// 证书与 TLS(crl-verify:吊销即时生效,对齐生产加固)
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"))
// push:DNS / 全流量 / 内网路由
fmt.Fprintf(&b, "push \"dhcp-option DNS %s\"\n", service.StrOr(cfg, "dns", "223.5.5.5"))
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
}
ip, network, err := net.ParseCIDR(r)
if err != nil {
continue
}
mask := net.IP(network.Mask).String()
fmt.Fprintf(&b, "push \"route %s %s\"\n", ip.Mask(network.Mask).String(), mask)
}
// 会话保活(生产 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"))
// 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) {
b.WriteString("duplicate-cn\n")
}
if maxClients := int(service.Num(cfg, "max_clients", 0)); maxClients > 0 {
fmt.Fprintf(&b, "max-clients %d\n", maxClients)
}
// 可选降权(生产 user/group openvpn)
if u := service.Str(cfg, "run_as_user"); u != "" {
fmt.Fprintf(&b, "user %s\n", u)
}
if g := service.Str(cfg, "run_as_group"); g != "" {
fmt.Fprintf(&b, "group %s\n", g)
}
fmt.Fprintf(&b, "status %q 10\nstatus-version 3\nverb %d\n", filepath.Join(dir, "openvpn-status.log"), int(service.Num(cfg, "verb", 3)))
if service.StrOr(cfg, "proto", "udp") == "udp" {
b.WriteString("explicit-exit-notify 1\n")
}
// 认证
switch service.StrOr(cfg, "auth_mode", "certificate") {
case "password":
// 对齐生产:verify-client-cert none + username-as-common-name(client-cert-not-required 已弃用)
b.WriteString("verify-client-cert none\nusername-as-common-name\n")
case "certificate+password":
b.WriteString("verify-client-cert require\n")
@@ -151,7 +350,12 @@ func (s *Service) serverConfig(t *model.Tunnel, cfg map[string]interface{}) stri
b.WriteString("verify-client-cert require\n")
}
if mode := service.StrOr(cfg, "auth_mode", "certificate"); mode == "password" || mode == "certificate+password" {
// via-file 比 via-env 更安全(密码不进环境变量);script-security 2 足够执行外部脚本。
// 生产若用 via-env 需 script-security 3,我们刻意保持 via-file + 2。
fmt.Fprintf(&b, "script-security 2\nauth-user-pass-verify %q via-file\n", filepath.Join(dir, "auth-user-pass.sh"))
if mfaMode := service.StrOr(cfg, "mfa_mode", "off"); mfaMode == "optional" || mfaMode == "required" {
b.WriteString("static-challenge \"请输入 Google Authenticator / MFA 验证码\" 1\n")
}
}
return b.String()
}
@@ -185,7 +389,7 @@ func (s *Service) InitializePKI(t *model.Tunnel) error {
if err := runEasyRSA(dir, easy, "gen-crl"); err != nil {
return err
}
openvpn, ok := service.LookBin("openvpn")
openvpn, ok := findOpenVPNBin()
if !ok {
return fmt.Errorf("PKI 证书已生成,但未检测到 openvpn,无法生成 tls-crypt 密钥")
}
@@ -245,6 +449,34 @@ func authScript(t *model.Tunnel) string {
}
func shellQuote(v string) string { return "'" + strings.ReplaceAll(v, "'", "'\\''") + "'" }
// RevokeClientCertificate 吊销用户证书并重新生成 CRL(禁用/删除拨号用户时调用)。
// server.conf 的 crl-verify 使吊销生效;运行中进程收 SIGUSR1 重读 CRL。
func (s *Service) RevokeClientCertificate(t *model.Tunnel, user *model.VPNUser) error {
if user.Certificate == "" {
return nil
}
easy := easyRSAPath()
if easy == "" {
return fmt.Errorf("未检测到 easy-rsa,无法吊销证书")
}
dir := service.InstanceDir(t)
if _, err := os.Stat(filepath.Join(dir, "pki", "issued", user.Certificate+".crt")); err != nil {
return nil
}
if err := runEasyRSA(dir, easy, "--batch", "revoke", user.Certificate); err != nil {
return err
}
if err := runEasyRSA(dir, easy, "gen-crl"); err != nil {
return err
}
if pid, alive := service.ReadPid(t); alive {
if proc, err := os.FindProcess(pid); err == nil {
_ = proc.Signal(syscall.SIGUSR1)
}
}
return nil
}
// IssueClientCertificate signs a per-VPNUser certificate and records the exact
// certificate CN. LDAP users can use certificates as a second factor too.
func (s *Service) IssueClientCertificate(t *model.Tunnel, user *model.VPNUser) error {
@@ -333,6 +565,15 @@ func (s *Service) GenerateUserConfig(t *model.Tunnel, user *model.VPNUser) (stri
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"))
if mode == "password" || mode == "certificate+password" {
b.WriteString("auth-user-pass\n")
b.WriteString("auth-nocache\n")
if mfaMode := service.StrOr(cfg, "mfa_mode", "off"); mfaMode == "optional" || mfaMode == "required" {
b.WriteString("static-challenge \"请输入 Google Authenticator / MFA 验证码\" 1\n")
}
}
// 与服务端 reneg-sec 一致(生产常 0)
fmt.Fprintf(&b, "reneg-sec %d\n", int(service.Num(cfg, "reneg_sec", 0)))
if mtu := int(service.Num(cfg, "tun_mtu", 0)); mtu > 0 {
fmt.Fprintf(&b, "tun-mtu %d\n", mtu)
}
b.WriteString("<ca>\n" + ca + "</ca>\n")
if cert != "" {
@@ -342,10 +583,12 @@ func (s *Service) GenerateUserConfig(t *model.Tunnel, user *model.VPNUser) (stri
return b.String(), nil
}
func clientProto(proto string) string {
if proto == "tcp-server" {
switch strings.ToLower(proto) {
case "tcp", "tcp-server", "tcp-client":
return "tcp-client"
default:
return "udp"
}
return proto
}
func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) {
@@ -365,16 +608,140 @@ func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) {
}
func (s *Service) Start(t *model.Tunnel) error {
if _, ok := service.LookBin("openvpn"); !ok {
if _, ok := findOpenVPNBin(); !ok {
return fmt.Errorf("无法启动 OpenVPN:未检测到 openvpn")
}
if err := pkiReady(t); err != nil {
return fmt.Errorf("无法启动 OpenVPN:PKI 尚未就绪:%w", err)
}
return service.StartDaemon(t, serviceMustBin("openvpn"), "--config", filepath.Join(service.InstanceDir(t), "server.conf"))
if err := service.RequireRoot("OpenVPN"); err != nil {
return err
}
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 {
return err
}
} else if err := service.RequireTCPPortAvailable(port, "OpenVPN"); err != nil {
return err
}
if service.Bool(cfg, "enable_nat", false) {
if err := s.installNAT(t, cfg); err != nil {
return err
}
}
if err := service.StartDaemon(t, serviceMustBin("openvpn"), "--config", filepath.Join(service.InstanceDir(t), "server.conf")); err != nil {
_ = s.removeNAT(t, cfg)
return err
}
return nil
}
func serviceMustBin(name string) string { p, _ := service.LookBin(name); return p }
func (s *Service) Stop(t *model.Tunnel) error {
var firstErr 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 err := s.removeNAT(t, cfg); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
func (s *Service) installNAT(t *model.Tunnel, cfg map[string]interface{}) error {
iptables, ok := service.LookBin("iptables")
if !ok {
return fmt.Errorf("无法启动 OpenVPN:启用 NAT 需要 iptables")
}
egress := service.Str(cfg, "egress_interface")
if egress == "" {
var err error
egress, err = service.DefaultRouteInterface()
if err != nil {
return err
}
}
ip, mask, err := parseSubnet(service.StrOr(cfg, "subnet", "10.10.0.0 255.255.255.0"))
if err != nil {
return err
}
ones, _ := mask.Size()
subnet := fmt.Sprintf("%s/%d", ip.String(), ones)
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"},
}
for _, rule := range rules {
check := append([]string{}, rule...)
// -C 检查时把 -A 换成 -C
for i := 0; i < len(check); i++ {
if check[i] == "-A" {
check[i] = "-C"
break
}
}
if _, err := service.Run(iptables, check...); err == nil {
continue
}
if _, err := service.Run(iptables, rule...); err != nil {
_ = s.removeNAT(t, cfg)
return fmt.Errorf("无法添加 OpenVPN NAT 规则: %w", err)
}
}
return nil
}
func (s *Service) removeNAT(t *model.Tunnel, cfg map[string]interface{}) error {
iptables, ok := service.LookBin("iptables")
if !ok {
return nil
}
egress := service.Str(cfg, "egress_interface")
if egress == "" {
egress, _ = service.DefaultRouteInterface()
}
ip, mask, err := parseSubnet(service.StrOr(cfg, "subnet", "10.10.0.0 255.255.255.0"))
if err != nil {
return nil
}
ones, _ := mask.Size()
subnet := fmt.Sprintf("%s/%d", ip.String(), ones)
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", "-s", subnet, "-m", "comment", "--comment", comment, "-j", "ACCEPT"},
{"-D", "FORWARD", "-d", subnet, "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-m", "comment", "--comment", comment, "-j", "ACCEPT"},
}
for _, rule := range candidates {
for {
check := append([]string{}, rule...)
for i := 0; i < len(check); i++ {
if check[i] == "-D" {
check[i] = "-C"
break
}
}
if _, err := service.Run(iptables, check...); err != nil {
break
}
if _, err := service.Run(iptables, rule...); err != nil {
return err
}
}
}
return nil
}
func serviceMustBin(name string) string { p, _ := service.LookBin(name); return p }
func (s *Service) Stop(t *model.Tunnel) error { return service.KillPid(t) }
func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
if _, alive := service.ReadPid(t); alive {
st := service.NowStatus(true, "running", "OpenVPN 进程运行中")
@@ -383,7 +750,7 @@ func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
st.TxBytes = t.TxBytes + tx
return st, nil
}
if _, ok := service.LookBin("openvpn"); !ok {
if _, ok := findOpenVPNBin(); !ok {
return service.NowStatus(false, "undeployed", "配置已生成,但未检测到 openvpn,尚未部署"), nil
}
return service.NowStatus(false, "stopped", "未启动"), nil
@@ -407,6 +774,8 @@ func (s *Service) collectTraffic(t *model.Tunnel) (int64, int64) {
next := trafficSnapshot{}
var totalRx, totalTx int64
now := time.Now()
// 收集在线客户端信息(供 sessionmgr 同步会话)
var onlineClients []sessionmgr.ClientInfo
for _, line := range strings.Split(string(raw), "\n") {
f := strings.Split(line, ",")
if len(f) < 7 || f[0] != "CLIENT_LIST" {
@@ -422,7 +791,12 @@ func (s *Service) collectTraffic(t *model.Tunnel) (int64, int64) {
if len(f) > 9 && f[9] != "" && f[9] != "UNDEF" {
username = f[9]
}
key := username + "|" + f[2]
realAddr := f[2]
virtualAddr := ""
if len(f) > 4 {
virtualAddr = f[3]
}
key := username + "|" + realAddr
old := snap[key]
deltaRx, deltaTx := rx-old.Rx, tx-old.Tx
if deltaRx < 0 {
@@ -437,9 +811,16 @@ func (s *Service) collectTraffic(t *model.Tunnel) (int64, int64) {
if store.DB != nil && username != "" {
store.DB.Model(&model.VPNUser{}).Where("tunnel_id = ? AND (username = ? OR certificate = ?)", t.ID, username, cn).Updates(map[string]interface{}{"rx_bytes": gorm.Expr("rx_bytes + ?", deltaRx), "tx_bytes": gorm.Expr("tx_bytes + ?", deltaTx), "last_seen_at": now})
}
// 收集在线客户端
onlineClients = append(onlineClients, sessionmgr.ClientInfo{
Username: username, RealAddress: realAddr, VirtualAddress: virtualAddr,
RxBytes: rx, TxBytes: tx,
})
}
b, _ := json.Marshal(next)
_ = os.WriteFile(filepath.Join(service.InstanceDir(t), "traffic-state.json"), b, 0o600)
// 同步在线会话到 VPNSession 表
sessionmgr.SyncSessions(t.ID, "openvpn", onlineClients)
return totalRx, totalTx
}
func (s *Service) Delete(t *model.Tunnel) error { return s.Stop(t) }
@@ -447,6 +828,7 @@ func (s *Service) Delete(t *model.Tunnel) error { return s.Stop(t) }
// RunAuthHelper is called by the same opsx-vpn binary from OpenVPN's
// auth-user-pass-verify hook. It returns 0 only for enabled, tunnel-scoped
// local users or LDAP users whose recorded LDAP DN accepts the password.
// MFA 支持:static-challenge(SCRV1 / 第三行)、或密码末尾附加 6 位 TOTP。
func RunAuthHelper(args []string) error {
fs := flag.NewFlagSet("openvpn-auth", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
@@ -466,16 +848,51 @@ func RunAuthHelper(args []string) error {
if len(lines) < 2 || strings.TrimSpace(lines[0]) == "" {
return errors.New("认证凭据格式无效")
}
return Authenticate(uint(*tunnel), strings.TrimSpace(lines[0]), strings.TrimSpace(lines[1]))
username := strings.TrimSpace(lines[0])
passwordField := strings.TrimSpace(lines[1])
var challenge string
if len(lines) >= 3 {
challenge = strings.TrimSpace(lines[2])
}
return Authenticate(uint(*tunnel), username, passwordField, challenge)
}
func Authenticate(tunnelID uint, username, password string) error {
if password == "" {
// Authenticate validates dial-in credentials for one OpenVPN tunnel.
// password_is_cr 模式:密码与 OTP 分离(SCRV1 / static-challenge 响应格式);
// 否则支持 password+OTP 拼接;response 可为 PIN+OTP。
func Authenticate(tunnelID uint, username, passwordField, challenge string) error {
if passwordField == "" {
return errors.New("密码为空")
}
var tunnel model.Tunnel
if err := store.DB.First(&tunnel, tunnelID).Error; err != nil {
return errors.New("隧道不存在")
}
cfg, _ := store.TunnelConfig(&tunnel)
mfaMode := service.StrOr(cfg, "mfa_mode", "off")
if service.Bool(cfg, "require_mfa", false) && mfaMode == "off" {
mfaMode = "required"
}
// 未启用 MFA 时不要把密码末尾 6 位当成 OTP 截断,避免误伤含数字后缀的密码。
password, response := passwordField, ""
if mfaMode == "optional" || mfaMode == "required" {
password, response = splitPasswordAndOTP(passwordField, challenge)
} else if decoded := decodeSCRV1(passwordField); decoded.ok {
password = decoded.password
}
var user model.VPNUser
if err := store.DB.Where("tunnel_id = ? AND username = ? AND enabled = ?", tunnelID, username, true).First(&user).Error; err != nil {
return errors.New("拨号用户不存在或已禁用")
}
if err := verifyDialPassword(&user, password); err != nil {
return err
}
return verifyDialMFA(&user, cfg, mfaMode, response)
}
func verifyDialPassword(user *model.VPNUser, password string) error {
if user.Source == "local" {
if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) != nil {
return errors.New("密码错误")
@@ -512,3 +929,331 @@ func Authenticate(tunnelID uint, username, password string) error {
}
return nil
}
var mfaAuthMu sync.Mutex
func verifyDialMFA(user *model.VPNUser, tunnelCfg map[string]interface{}, mfaMode, response string) error {
switch mfaMode {
case "", "off":
return nil
case "optional":
if !user.MFAEnabled {
return nil
}
case "required":
if !user.MFAEnabled {
return errors.New("该隧道要求 MFA,请先在管理端绑定 Authenticator")
}
default:
return nil
}
mfaAuthMu.Lock()
defer mfaAuthMu.Unlock()
// 重新加载用户以拿到最新锁定/计数器状态。
if store.DB != nil {
_ = store.DB.First(user, user.ID).Error
}
if user.MFALockedUntil != nil && time.Now().Before(*user.MFALockedUntil) {
return fmt.Errorf("MFA 已锁定至 %s,请稍后再试或联系管理员解锁", user.MFALockedUntil.Local().Format("15:04:05"))
}
response = strings.TrimSpace(response)
if response == "" {
recordMFAFailure(user, tunnelCfg)
return errors.New("需要 MFA 验证码(Google Authenticator 6 位,或 PIN+OTP)")
}
// 1) 备用码(可含 PIN 前缀:PIN+BACKUP)
otpPart, pinErr := extractOTPAfterPIN(user, response)
if pinErr != nil {
recordMFAFailure(user, tunnelCfg)
return pinErr
}
if consumeBackupCode(user, otpPart) {
clearMFAFailure(user)
now := time.Now()
user.MFALastUsedAt = &now
_ = store.DB.Model(user).Updates(map[string]interface{}{
"mfa_fail_count": 0, "mfa_locked_until": nil, "mfa_last_used_at": now,
}).Error
return nil
}
// 2) TOTP 校验(sha1/base32/6/30s + slop 时钟偏差窗口)
if user.MFASecret == "" {
recordMFAFailure(user, tunnelCfg)
return errors.New("MFA 密钥无效")
}
secret, err := store.DecryptSecret(user.MFASecret)
if err != nil || secret == "" {
recordMFAFailure(user, tunnelCfg)
return errors.New("MFA 密钥无效")
}
slop := int(service.Num(tunnelCfg, "otp_slop", otpDefaultSlop))
if slop < 0 {
slop = 0
}
skew := uint(slop / otpPeriod)
if skew < 1 && slop > 0 {
skew = 1
}
ok, counter := validateTOTPSkew(otpPart, secret, skew)
if !ok {
recordMFAFailure(user, tunnelCfg)
return errors.New("MFA 验证码错误")
}
if service.Bool(tunnelCfg, "otp_disallow_reuse", true) && user.MFALastCounter != 0 && counter == user.MFALastCounter {
recordMFAFailure(user, tunnelCfg)
return errors.New("该验证码已使用,请等待下一个 30 秒周期")
}
now := time.Now()
user.MFALastCounter = counter
user.MFALastUsedAt = &now
user.MFAFailCount = 0
user.MFALockedUntil = nil
_ = store.DB.Model(user).Updates(map[string]interface{}{
"mfa_last_counter": counter,
"mfa_last_used_at": now,
"mfa_fail_count": 0,
"mfa_locked_until": nil,
}).Error
return nil
}
// extractOTPAfterPIN 从响应中提取 OTP:response = [pin][otp] 拼接格式。
// 若用户设置了 PIN,则必须作为前缀;OTP 为剩余部分(6 位或备用码)。
func extractOTPAfterPIN(user *model.VPNUser, response string) (string, error) {
if !user.MFAHasPin || user.MFAPin == "" {
return response, nil
}
pin, err := store.DecryptSecret(user.MFAPin)
if err != nil || pin == "" {
return "", errors.New("MFA PIN 配置无效")
}
if !strings.HasPrefix(response, pin) {
return "", errors.New("MFA PIN 错误")
}
otp := strings.TrimPrefix(response, pin)
if otp == "" {
return "", errors.New("请在 PIN 后输入 6 位动态码")
}
return otp, nil
}
func validateTOTPSkew(code, secret string, skew uint) (bool, int64) {
code = strings.TrimSpace(code)
if !isSixDigits(code) {
return false, 0
}
now := time.Now()
opts := totp.ValidateOpts{
Period: otpPeriod,
Skew: skew,
Digits: otp.DigitsSix,
Algorithm: otp.AlgorithmSHA1,
}
ok, err := totp.ValidateCustom(code, secret, now, opts)
if err != nil || !ok {
return false, 0
}
// 定位实际命中的时间窗计数器(用于防重放)。
period := int64(otpPeriod)
base := now.Unix() / period
for i := -int(skew); i <= int(skew); i++ {
c := base + int64(i)
t := time.Unix(c*period, 0)
if valid, _ := totp.ValidateCustom(code, secret, t, totp.ValidateOpts{
Period: otpPeriod, Skew: 0, Digits: otp.DigitsSix, Algorithm: otp.AlgorithmSHA1,
}); valid {
return true, c
}
}
return true, base
}
func recordMFAFailure(user *model.VPNUser, tunnelCfg map[string]interface{}) {
if store.DB == nil || user == nil {
return
}
maxFail := int(service.Num(tunnelCfg, "otp_max_failures", 5))
lockSec := int(service.Num(tunnelCfg, "otp_lockout_sec", 300))
user.MFAFailCount++
updates := map[string]interface{}{"mfa_fail_count": user.MFAFailCount}
if maxFail > 0 && user.MFAFailCount >= maxFail && lockSec > 0 {
until := time.Now().Add(time.Duration(lockSec) * time.Second)
user.MFALockedUntil = &until
updates["mfa_locked_until"] = until
}
_ = store.DB.Model(user).Updates(updates).Error
}
func clearMFAFailure(user *model.VPNUser) {
user.MFAFailCount = 0
user.MFALockedUntil = nil
}
// splitPasswordAndOTP 解析 OpenVPN 密码字段中的 MFA。
// password_is_cr 模式:SCRV1 / 第三行 challenge;否则密码后缀 6 位 OTP。
func splitPasswordAndOTP(passwordField, challenge string) (password, response string) {
if decoded := decodeSCRV1(passwordField); decoded.ok {
return decoded.password, decoded.otp
}
if challenge != "" {
return passwordField, challenge
}
// 无 challenge:密码 + 6 位 OTP 直接拼接
if len(passwordField) > 6 {
suffix := passwordField[len(passwordField)-6:]
if isSixDigits(suffix) {
return passwordField[:len(passwordField)-6], suffix
}
}
return passwordField, ""
}
type scrv1 struct {
password, otp string
ok bool
}
func decodeSCRV1(raw string) scrv1 {
// SCRV1:<base64 password>:<base64 response>
if !strings.HasPrefix(raw, "SCRV1:") {
return scrv1{}
}
parts := strings.SplitN(raw, ":", 3)
if len(parts) != 3 {
return scrv1{}
}
pw, err1 := base64.StdEncoding.DecodeString(parts[1])
otp, err2 := base64.StdEncoding.DecodeString(parts[2])
if err1 != nil || err2 != nil {
pw, err1 = base64.RawStdEncoding.DecodeString(parts[1])
otp, err2 = base64.RawStdEncoding.DecodeString(parts[2])
if err1 != nil || err2 != nil {
return scrv1{}
}
}
return scrv1{password: string(pw), otp: string(otp), ok: true}
}
func isSixDigits(s string) bool {
if len(s) != 6 {
return false
}
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return true
}
func consumeBackupCode(user *model.VPNUser, code string) bool {
if user.MFABackupCodes == "" || code == "" {
return false
}
enc, err := store.DecryptSecret(user.MFABackupCodes)
if err != nil {
return false
}
var codes []string
if json.Unmarshal([]byte(enc), &codes) != nil {
return false
}
want := strings.ToUpper(strings.TrimSpace(code))
next := make([]string, 0, len(codes))
found := false
for _, c := range codes {
if !found && strings.ToUpper(strings.TrimSpace(c)) == want {
found = true
continue
}
next = append(next, c)
}
if !found {
return false
}
raw, _ := json.Marshal(next)
encNext, err := store.EncryptSecret(string(raw))
if err != nil {
return false
}
user.MFABackupCodes = encNext
_ = store.DB.Model(user).Update("mfa_backup_codes", encNext).Error
return true
}
// GenerateOTPSecretsLine 导出标准 otp-secrets 格式密钥行(用于备份与迁移)。
// 格式: user server type:hash:encoding:key:pin:udid client
func GenerateOTPSecretsLine(user *model.VPNUser) (string, error) {
if !user.MFAEnabled || user.MFASecret == "" {
return "", errors.New("用户未启用 MFA")
}
secret, err := store.DecryptSecret(user.MFASecret)
if err != nil {
return "", err
}
pin := ""
if user.MFAHasPin && user.MFAPin != "" {
pin, _ = store.DecryptSecret(user.MFAPin)
}
// totp:sha1:base32:KEY:PIN:xxx
return fmt.Sprintf("%s otp totp:sha1:base32:%s:%s:xxx *", user.Username, secret, pin), nil
}
// SetUserMFAPin 设置或清除静态 PIN(连接时第二因子 PIN+OTP)。
func SetUserMFAPin(user *model.VPNUser, pin string) error {
if pin == "" {
user.MFAPin = ""
user.MFAHasPin = false
return store.DB.Model(user).Updates(map[string]interface{}{"mfa_pin": "", "mfa_has_pin": false}).Error
}
if len(pin) < 4 || len(pin) > 32 {
return errors.New("PIN 长度需 4-32 位")
}
if strings.ContainsAny(pin, " \t\r\n") {
return errors.New("PIN 不能包含空白字符")
}
enc, err := store.EncryptSecret(pin)
if err != nil {
return err
}
user.MFAPin = enc
user.MFAHasPin = true
return store.DB.Model(user).Updates(map[string]interface{}{"mfa_pin": enc, "mfa_has_pin": true}).Error
}
// UnlockUserMFA 清除失败计数与锁定(OTP Manager)。
func UnlockUserMFA(user *model.VPNUser) error {
user.MFAFailCount = 0
user.MFALockedUntil = nil
return store.DB.Model(user).Updates(map[string]interface{}{
"mfa_fail_count": 0, "mfa_locked_until": nil,
}).Error
}
// RegenerateBackupCodes 重新生成一次性备用码。
func RegenerateBackupCodes(user *model.VPNUser) ([]string, error) {
if !user.MFAEnabled {
return nil, errors.New("请先启用 MFA")
}
codes := make([]string, 10)
for i := range codes {
raw := make([]byte, 5)
if _, err := rand.Read(raw); err != nil {
return nil, err
}
codes[i] = fmt.Sprintf("%X", raw)
}
raw, _ := json.Marshal(codes)
enc, err := store.EncryptSecret(string(raw))
if err != nil {
return nil, err
}
user.MFABackupCodes = enc
if err := store.DB.Model(user).Update("mfa_backup_codes", enc).Error; err != nil {
return nil, err
}
return codes, nil
}
@@ -1,8 +1,14 @@
package openvpn
import (
"encoding/base64"
"strings"
"testing"
"time"
"github.com/pquerna/otp/totp"
"vpn-platform/backend/internal/model"
)
func TestValidateOpenVPNModes(t *testing.T) {
@@ -16,6 +22,110 @@ func TestValidateOpenVPNModes(t *testing.T) {
if _, err := New().Validate(map[string]interface{}{"auth_mode": "unknown"}); err == nil {
t.Fatal("expected invalid auth mode error")
}
// 前端历史值 tcp 应归一为 tcp-server
cfg2, err := New().Validate(map[string]interface{}{"proto": "tcp", "subnet": "10.8.0.0 255.255.255.0"})
if err != nil {
t.Fatal(err)
}
if cfg2["proto"] != "tcp-server" {
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",
})
if err != nil {
t.Fatal(err)
}
if cfg3["redirect_gateway"] != false {
t.Fatalf("push_routes should default redirect_gateway off: %#v", cfg3)
}
// MFA 仅允许密码相关模式
if _, err := New().Validate(map[string]interface{}{
"auth_mode": "certificate", "mfa_mode": "required", "subnet": "10.8.0.0 255.255.255.0",
}); err == nil {
t.Fatal("expected MFA + certificate-only to fail")
}
cfg4, err := New().Validate(map[string]interface{}{
"auth_mode": "password", "mfa_mode": "required", "subnet": "10.8.0.0 255.255.255.0", "proto": "tcp",
})
if err != nil {
t.Fatal(err)
}
if cfg4["proto"] != "tcp-server" || cfg4["mfa_mode"] != "required" {
t.Fatalf("mfa/tcp normalize: %#v", cfg4)
}
}
func TestSplitPasswordAndOTP(t *testing.T) {
pw, otp := splitPasswordAndOTP("secretpass123456", "")
if pw != "secretpass" || otp != "123456" {
t.Fatalf("suffix: %q %q", pw, otp)
}
scrv := "SCRV1:" + base64.StdEncoding.EncodeToString([]byte("mypass")) + ":" + base64.StdEncoding.EncodeToString([]byte("654321"))
pw, otp = splitPasswordAndOTP(scrv, "")
if pw != "mypass" || otp != "654321" {
t.Fatalf("scrv1: %q %q", pw, otp)
}
pw, otp = splitPasswordAndOTP("onlypass", "111222")
if pw != "onlypass" || otp != "111222" {
t.Fatalf("challenge line: %q %q", pw, otp)
}
}
func TestClientProto(t *testing.T) {
if clientProto("tcp-server") != "tcp-client" || clientProto("udp") != "udp" || clientProto("tcp") != "tcp-client" {
t.Fatal("clientProto mapping failed")
}
}
func TestServerConfigIncludesProductionTunings(t *testing.T) {
cfg, err := New().Validate(map[string]interface{}{
"auth_mode": "password", "proto": "tcp", "subnet": "10.8.0.0 255.255.240.0",
"max_clients": 1000, "mfa_mode": "off",
})
if err != nil {
t.Fatal(err)
}
if cfg["proto"] != "tcp-server" {
t.Fatalf("proto: %#v", cfg["proto"])
}
tunn := &model.Tunnel{ID: 1, Protocol: "openvpn"}
raw := New().serverConfig(tunn, cfg)
for _, want := range []string{
"proto tcp-server",
"tun-mtu 1500",
"mssfix 1450",
"keepalive 5 30",
"reneg-sec 0",
"verify-client-cert none",
"username-as-common-name",
"max-clients 1000",
"duplicate-cn",
"via-file",
} {
if !strings.Contains(raw, want) {
t.Fatalf("server.conf missing %q\n%s", want, raw)
}
}
if strings.Contains(raw, "via-env") {
t.Fatal("should use via-file not via-env")
}
}
func TestValidateTOTPSkew(t *testing.T) {
// 使用固定 secret 生成当前码并校验
secret := "JBSWY3DPEHPK3PXP" // "Hello!" base32, common test secret
code, err := totp.GenerateCode(secret, time.Now())
if err != nil {
t.Fatal(err)
}
ok, counter := validateTOTPSkew(code, secret, 1)
if !ok || counter == 0 {
t.Fatalf("expected valid totp, ok=%v counter=%d", ok, counter)
}
if ok2, _ := validateTOTPSkew("000000", secret, 1); ok2 {
t.Fatal("expected invalid code")
}
}
func TestCertificateCNIsSafeAndStable(t *testing.T) {
+150 -5
View File
@@ -6,8 +6,11 @@ package wireguard
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"net"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
@@ -140,8 +143,16 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{},
if egress := service.Str(out, "egress_interface"); egress != "" && !ifacePattern.MatchString(egress) {
return nil, fmt.Errorf("egress_interface 包含非法字符")
}
// 启用 NAT 但未指定出口时,自动探测默认路由网卡;探测失败再要求手填。
if service.Bool(out, "enable_nat", false) && service.Str(out, "egress_interface") == "" {
return nil, fmt.Errorf("启用 NAT 时必须填写 egress_interface")
iface, err := service.DefaultRouteInterface()
if err != nil {
return nil, fmt.Errorf("启用 NAT 时无法自动探测出口网卡,请填写 egress_interface: %w", err)
}
out["egress_interface"] = iface
}
if mtu := int(service.Num(out, "mtu", 0)); mtu != 0 && (mtu < 576 || mtu > 9000) {
return nil, fmt.Errorf("mtu 必须在 576-9000 之间")
}
if _, supplied := out["peers"]; supplied {
peers, err := parsePeers(out)
@@ -286,18 +297,22 @@ func generateClientConfig(cfg map[string]interface{}, peers []peer, peerSecrets
if allowed == "" {
allowed = service.StrOr(cfg, "allowed_ips", "0.0.0.0/0, ::/0")
}
mtuLine := ""
if mtu := int(service.Num(cfg, "mtu", 0)); mtu > 0 {
mtuLine = fmt.Sprintf("MTU = %d\n", mtu)
}
return fmt.Sprintf(`[Interface]
PrivateKey = %s
Address = %s
DNS = %s
%s
[Peer]
PublicKey = %s
PresharedKey = %s
Endpoint = %s
AllowedIPs = %s
PersistentKeepalive = %d
`, sec.PrivateKey, p.ClientAddress, service.StrOr(cfg, "dns", "223.5.5.5"), service.Str(cfg, "server_public_key"), sec.PresharedKey, endpoint, allowed, int(service.Num(cfg, "keepalive", 25))), nil
`, sec.PrivateKey, p.ClientAddress, service.StrOr(cfg, "dns", "223.5.5.5"), mtuLine, service.Str(cfg, "server_public_key"), sec.PresharedKey, endpoint, allowed, int(service.Num(cfg, "keepalive", 25))), nil
}
if requestedID != "" {
return "", fmt.Errorf("WireGuard Peer 不存在: %s", requestedID)
@@ -354,7 +369,11 @@ func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
for _, p := range peers {
byKey[p.PublicKey] = p
}
// 内核计数器在接口重启后归零;用 traffic-state 做增量,再叠到库中的累计值。
prev := loadTrafficState(t)
next := map[string][2]int64{}
st := service.NowStatus(true, "running", "WireGuard 接口运行中")
var deltaRx, deltaTx int64
connected := 0
for _, line := range strings.Split(strings.TrimSpace(dump), "\n") {
fields := strings.Split(line, "\t")
@@ -374,13 +393,25 @@ func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
if alive {
connected++
}
st.RxBytes += rx
st.TxBytes += tx
old := prev[fields[0]]
dRx, dTx := rx-old[0], tx-old[1]
if dRx < 0 {
dRx = rx
}
if dTx < 0 {
dTx = tx
}
deltaRx += dRx
deltaTx += dTx
next[fields[0]] = [2]int64{rx, tx}
st.Peers = append(st.Peers, core.PeerStatus{
ID: p.ID, Name: p.Name, PublicKey: fields[0], Endpoint: fields[2], AllowedIPs: fields[3],
LatestHandshakeAt: handshake, Connected: alive, RxBytes: rx, TxBytes: tx,
})
}
saveTrafficState(t, next)
st.RxBytes = t.RxBytes + deltaRx
st.TxBytes = t.TxBytes + deltaTx
sort.Slice(st.Peers, func(i, j int) bool { return st.Peers[i].ID < st.Peers[j].ID })
st.Detail = fmt.Sprintf("WireGuard 接口 %s 运行中,%d/%d 个 Peer 最近已握手", interfaceName(t), connected, len(st.Peers))
return st, nil
@@ -388,6 +419,117 @@ func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
func (s *Service) Delete(t *model.Tunnel) error { return s.Stop(t) }
// AddPeer appends a managed or external peer and rewrites configuration.
func (s *Service) AddPeer(t *model.Tunnel, input map[string]interface{}) (peer, error) {
cfg, err := store.TunnelConfig(t)
if err != nil {
return peer{}, err
}
peers, err := parsePeers(cfg)
if err != nil {
return peer{}, err
}
p := peer{
ID: service.Str(input, "id"),
Name: service.Str(input, "name"),
PublicKey: service.Str(input, "public_key"),
ClientAddress: service.Str(input, "client_address"),
AllowedIPs: service.Str(input, "allowed_ips"),
Enabled: true,
Managed: service.Str(input, "public_key") == "",
}
if p.ID == "" {
p.ID = fmt.Sprintf("client-%d", len(peers)+1)
}
if p.Name == "" {
p.Name = p.ID
}
if p.ClientAddress == "" {
return peer{}, fmt.Errorf("client_address 必填")
}
peers = append(peers, p)
cfg["peers"] = peersToConfig(peers)
raw, _ := json.Marshal(cfg)
t.Config = string(raw)
if err := s.Create(t); err != nil {
return peer{}, err
}
// Return the persisted peer (with generated public key when managed).
cfg, _ = store.TunnelConfig(t)
peers, _ = parsePeers(cfg)
for _, item := range peers {
if item.ID == p.ID {
return item, nil
}
}
return p, nil
}
// RemovePeer deletes one peer by id and rewrites configuration.
func (s *Service) RemovePeer(t *model.Tunnel, peerID string) error {
cfg, err := store.TunnelConfig(t)
if err != nil {
return err
}
peers, err := parsePeers(cfg)
if err != nil {
return err
}
next := make([]peer, 0, len(peers))
found := false
for _, p := range peers {
if p.ID == peerID {
found = true
continue
}
next = append(next, p)
}
if !found {
return fmt.Errorf("Peer 不存在: %s", peerID)
}
if len(next) == 0 {
return fmt.Errorf("至少保留一个 Peer")
}
cfg["peers"] = peersToConfig(next)
// Drop removed peer secrets so keys are not retained after revocation.
secrets, _ := store.TunnelSecrets(t)
if raw, ok := secrets["peer_secrets"].(map[string]interface{}); ok {
delete(raw, peerID)
secrets["peer_secrets"] = raw
_ = service.SaveTunnelConfig(t, cfg, secrets)
}
raw, _ := json.Marshal(cfg)
t.Config = string(raw)
return s.Create(t)
}
func loadTrafficState(t *model.Tunnel) map[string][2]int64 {
out := map[string][2]int64{}
raw, err := os.ReadFile(filepath.Join(service.InstanceDir(t), "wg-traffic-state.json"))
if err != nil {
return out
}
var m map[string][]int64
if json.Unmarshal(raw, &m) != nil {
return out
}
for k, v := range m {
if len(v) >= 2 {
out[k] = [2]int64{v[0], v[1]}
}
}
return out
}
func saveTrafficState(t *model.Tunnel, state map[string][2]int64) {
m := make(map[string][]int64, len(state))
for k, v := range state {
m[k] = []int64{v[0], v[1]}
}
raw, _ := json.Marshal(m)
_ = os.WriteFile(filepath.Join(service.InstanceDir(t), "wg-traffic-state.json"), raw, 0o600)
}
// interfaceName is deterministic, <= IFNAMSIZ-1 (15) and does not depend on
// the user supplied tunnel name. This prevents cross-tunnel interface clashes.
func interfaceName(t *model.Tunnel) string { return "wg" + strconv.FormatUint(uint64(t.ID), 36) }
@@ -640,6 +782,9 @@ func peerSecretsToConfig(secrets map[string]peerSecret) map[string]interface{} {
func renderServerConfig(cfg map[string]interface{}, serverPrivate string, peers []peer, secrets map[string]peerSecret) string {
var b strings.Builder
fmt.Fprintf(&b, "[Interface]\nPrivateKey = %s\nAddress = %s\nListenPort = %d\n", serverPrivate, service.Str(cfg, "address"), int(service.Num(cfg, "listen_port", defaultPort)))
if mtu := int(service.Num(cfg, "mtu", 0)); mtu > 0 {
fmt.Fprintf(&b, "MTU = %d\n", mtu)
}
if service.Bool(cfg, "enable_nat", false) {
egress := service.Str(cfg, "egress_interface")
fmt.Fprintf(&b, "PostUp = iptables -A FORWARD -i %%i -j ACCEPT; iptables -A FORWARD -o %%i -j ACCEPT; iptables -t nat -A POSTROUTING -o %s -j MASQUERADE\n", egress)
@@ -8,6 +8,7 @@ import (
"testing"
"vpn-platform/backend/internal/model"
"vpn-platform/backend/internal/service"
"vpn-platform/backend/internal/store"
)
@@ -67,8 +68,11 @@ func TestBuildPeersKeepsExternalPeerPrivateKeyOutOfPlatform(t *testing.T) {
func TestValidateRejectsUnsafeNATAndInvalidPeerAddress(t *testing.T) {
s := New()
if _, err := s.Validate(map[string]interface{}{"address": "10.8.0.1/24", "enable_nat": true}); err == nil {
t.Fatal("NAT without explicit egress interface was accepted")
// enable_nat without egress:若本机无默认路由则失败;有默认路由则自动填 egress_interface。
if cfg, err := s.Validate(map[string]interface{}{"address": "10.8.0.1/24", "enable_nat": true}); err == nil {
if service.Str(cfg, "egress_interface") == "" {
t.Fatal("NAT auto-detect succeeded but egress_interface is empty")
}
}
_, err := s.Validate(map[string]interface{}{
"address": "10.8.0.1/24",
+165
View File
@@ -0,0 +1,165 @@
// Package sessionmgr 管理 VPN 在线会话:同步运行时 peers → VPNSession 表,
// 提供在线列表查询和踢下线能力。跨协议统一(WireGuard peers / OpenVPN clients)。
package sessionmgr
import (
"fmt"
"time"
"vpn-platform/backend/internal/core"
"vpn-platform/backend/internal/model"
"vpn-platform/backend/internal/store"
)
// ClientInfo 协议层上报的在线客户端信息(统一格式)
type ClientInfo struct {
Username string // 用户名或证书 CN
RealAddress string // 客户端公网地址 IP:Port
VirtualAddress string // 分配的 VPN IP
RxBytes int64
TxBytes int64
}
// SyncSessions 将运行时客户端列表同步到 VPNSession 表。
// 新连接 → 创建 active 记录 + 写 ConnectionLog(connect);
// 已不在列表中的 active 会话 → 标记 disconnected + 写 ConnectionLog(disconnect)。
func SyncSessions(tunnelID uint, protocol string, clients []ClientInfo) {
if store.DB == nil {
return
}
now := time.Now()
// 1. 收集当前在线的标识 key(username|realAddress)
currentKeys := map[string]bool{}
for _, c := range clients {
key := c.Username + "|" + c.RealAddress
currentKeys[key] = true
// 查是否已有 active 会话
var existing model.VPNSession
err := store.DB.Where("tunnel_id = ? AND username = ? AND real_address = ? AND status = ?",
tunnelID, c.Username, c.RealAddress, "active").First(&existing).Error
if err == nil {
// 已有会话 → 更新流量
store.DB.Model(&existing).Updates(map[string]interface{}{
"rx_bytes": c.RxBytes, "tx_bytes": c.TxBytes,
})
} else {
// 新连接 → 创建会话 + 审计日志
session := model.VPNSession{
TunnelID: tunnelID,
Username: c.Username,
Protocol: protocol,
RealAddress: c.RealAddress,
VirtualAddress: c.VirtualAddress,
ConnectedAt: now,
RxBytes: c.RxBytes,
TxBytes: c.TxBytes,
Status: "active",
}
// 尝试关联 VPNUser
var vu model.VPNUser
if store.DB.Where("tunnel_id = ? AND username = ?", tunnelID, c.Username).First(&vu).Error == nil {
session.VPNUserID = vu.ID
}
store.DB.Create(&session)
LogConnection(tunnelID, c.Username, "connect", c.RealAddress, "")
}
}
// 2. 标记已不在列表中的 active 会话为 disconnected
var activeSessions []model.VPNSession
store.DB.Where("tunnel_id = ? AND status = ?", tunnelID, "active").Find(&activeSessions)
for _, s := range activeSessions {
key := s.Username + "|" + s.RealAddress
if !currentKeys[key] {
store.DB.Model(&s).Updates(map[string]interface{}{
"status": "disconnected",
"disconnected_at": now,
})
duration := now.Sub(s.ConnectedAt).Round(time.Second)
LogConnection(tunnelID, s.Username, "disconnect", s.RealAddress,
fmt.Sprintf("时长 %s, ↓%s ↑%s", duration, fmtBytes(s.RxBytes), fmtBytes(s.TxBytes)))
}
}
// 3. 如果 clients 为空(隧道无在线用户),标记该隧道所有 active 会话为 disconnected
if len(clients) == 0 {
for _, s := range activeSessions {
store.DB.Model(&s).Updates(map[string]interface{}{
"status": "disconnected",
"disconnected_at": now,
})
LogConnection(tunnelID, s.Username, "disconnect", s.RealAddress, "隧道无在线用户")
}
}
}
// SyncFromPeers 从 core.Status.Peers 同步(WireGuard 用)
func SyncFromPeers(tunnelID uint, protocol string, peers []core.PeerStatus) {
clients := make([]ClientInfo, 0, len(peers))
for _, p := range peers {
if !p.Connected {
continue
}
clients = append(clients, ClientInfo{
Username: p.Name, // WG peer 名称
RealAddress: p.Endpoint,
RxBytes: p.RxBytes,
TxBytes: p.TxBytes,
})
}
SyncSessions(tunnelID, protocol, clients)
}
// ListActive 查询所有在线会话
func ListActive() []model.VPNSession {
var sessions []model.VPNSession
store.DB.Where("status = ?", "active").Order("connected_at desc").Find(&sessions)
return sessions
}
// CountActive 统计在线会话数
func CountActive() int64 {
var count int64
store.DB.Model(&model.VPNSession{}).Where("status = ?", "active").Count(&count)
return count
}
// LogConnection 写连接审计日志
func LogConnection(tunnelID uint, username, event, ip, detail string) {
if store.DB == nil {
return
}
store.DB.Create(&model.ConnectionLog{
TunnelID: tunnelID,
Username: username,
Event: event,
IPAddress: ip,
Detail: detail,
})
}
// ListLogs 查询连接审计日志
func ListLogs(limit int) []model.ConnectionLog {
var logs []model.ConnectionLog
if limit <= 0 {
limit = 200
}
store.DB.Order("id desc").Limit(limit).Find(&logs)
return logs
}
func fmtBytes(n int64) string {
if n < 1024 {
return fmt.Sprintf("%dB", n)
}
if n < 1024*1024 {
return fmt.Sprintf("%.1fKB", float64(n)/1024)
}
if n < 1024*1024*1024 {
return fmt.Sprintf("%.1fMB", float64(n)/(1024*1024))
}
return fmt.Sprintf("%.1fGB", float64(n)/(1024*1024*1024))
}
+129
View File
@@ -0,0 +1,129 @@
// Package sshutil 提供 SSH 连接测试能力,用于远程节点凭证验证。
// 借鉴 aiops pkg/remoteexec 的连接逻辑,精简为节点连通性测试专用。
// 基于 golang.org/x/crypto/ssh,支持密码认证与私钥认证。
package sshutil
import (
"fmt"
"net"
"strings"
"time"
"golang.org/x/crypto/ssh"
)
// Config SSH 连接测试配置
type Config struct {
Host string // 目标 IP 或域名
Port int // SSH 端口(默认 22)
User string // SSH 用户(默认 root)
AuthType string // password / key
Password string // 密码认证
KeyData string // 私钥内容
}
// Result 连接测试结果
type Result struct {
Success bool `json:"success"`
Message string `json:"message"` // 成功时为 uname 输出,失败时为友好错误
Output string `json:"output"` // 原始命令输出
Took int64 `json:"took_ms"` // 耗时(毫秒)
}
// TestConnection 测试 SSH 连接并执行探测命令,返回友好结果。
// 探测命令:echo vpn-node-ok && uname -a(与 aiops build-node 一致)
func TestConnection(cfg Config) Result {
start := time.Now()
if cfg.Port == 0 {
cfg.Port = 22
}
if cfg.User == "" {
cfg.User = "root"
}
authMethods, err := buildAuth(cfg)
if err != nil {
return Result{Success: false, Message: err.Error(), Took: time.Since(start).Milliseconds()}
}
addr := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", cfg.Port))
sshConfig := &ssh.ClientConfig{
User: cfg.User,
Auth: authMethods,
Timeout: 10 * time.Second,
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // 内网管理工具,简化主机密钥校验
}
client, err := ssh.Dial("tcp", addr, sshConfig)
if err != nil {
return Result{Success: false, Message: formatSSHError(err, addr), Took: time.Since(start).Milliseconds()}
}
defer client.Close()
// 执行探测命令
session, err := client.NewSession()
if err != nil {
return Result{Success: false, Message: fmt.Sprintf("创建会话失败: %v", err), Took: time.Since(start).Milliseconds()}
}
defer session.Close()
out, err := session.CombinedOutput("echo vpn-node-ok && uname -a")
output := strings.TrimSpace(string(out))
if err != nil {
return Result{Success: false, Message: fmt.Sprintf("命令执行失败: %v", err), Output: output, Took: time.Since(start).Milliseconds()}
}
return Result{
Success: true,
Message: output,
Output: output,
Took: time.Since(start).Milliseconds(),
}
}
// buildAuth 根据认证方式构建 ssh.AuthMethod 列表。
// 密钥认证解析失败时,若有密码则兜底用密码(aiops 模式)。
func buildAuth(cfg Config) ([]ssh.AuthMethod, error) {
switch cfg.AuthType {
case "key":
if cfg.KeyData == "" {
return nil, fmt.Errorf("私钥认证模式但未提供私钥")
}
signer, err := ssh.ParsePrivateKey([]byte(cfg.KeyData))
if err != nil {
// 私钥解析失败,尝试密码兜底
if cfg.Password != "" {
return []ssh.AuthMethod{ssh.Password(cfg.Password)}, nil
}
return nil, fmt.Errorf("私钥解析失败: %v", err)
}
return []ssh.AuthMethod{ssh.PublicKeys(signer)}, nil
default: // password
if cfg.Password == "" {
return nil, fmt.Errorf("密码认证模式但未提供密码")
}
return []ssh.AuthMethod{ssh.Password(cfg.Password)}, nil
}
}
// formatSSHError 将底层 SSH 错误映射为用户友好的中文提示
func formatSSHError(err error, addr string) string {
msg := err.Error()
switch {
case strings.Contains(msg, "i/o timeout"), strings.Contains(msg, "deadline exceeded"):
return fmt.Sprintf("连接超时:无法在 10 秒内连接到 %s,请检查地址/端口/防火墙", addr)
case strings.Contains(msg, "connection refused"):
return fmt.Sprintf("连接被拒绝:%s 端口未开放 SSH 服务", addr)
case strings.Contains(msg, "no route to host"):
return fmt.Sprintf("网络不可达:%s 地址不存在或路由不通", addr)
case strings.Contains(msg, "authentication failed"), strings.Contains(msg, "unable to authenticate"):
return "认证失败:用户名/密码/私钥不正确"
case strings.Contains(msg, "handshake"):
return fmt.Sprintf("SSH 握手失败:%s 可能不是标准 SSH 服务", addr)
case strings.Contains(msg, "dns"), strings.Contains(msg, "no such host"):
return fmt.Sprintf("域名解析失败:%s", addr)
default:
return fmt.Sprintf("连接失败:%s", msg)
}
}
+13 -5
View File
@@ -30,13 +30,21 @@ func Init(dataDir string) error {
if err := os.MkdirAll(dataDir, 0o700); err != nil {
return err
}
db, err := gorm.Open(sqlite.Open(filepath.Join(dataDir, "vpn-platform.db")), &gorm.Config{
// WAL + busy_timeout:避免 monitor 周期写与 API 写并发时 database is locked
dsn := filepath.Join(dataDir, "vpn-platform.db") +
"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)"
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
Logger: gormlogger.Default.LogMode(gormlogger.Warn),
})
if err != nil {
return fmt.Errorf("打开数据库失败: %w", err)
}
if err := db.AutoMigrate(&model.User{}, &model.LDAPConfig{}, &model.MFADevice{}, &model.VPNUser{}, &model.Tunnel{}, &model.Node{}, &model.Plugin{}, &model.AuditLog{}); err != nil {
if sqlDB, err := db.DB(); err == nil {
sqlDB.SetMaxOpenConns(1) // SQLite 单写者
}
// 兼容旧库:VPNUser 唯一约束曾是 username 单列全局唯一
db.Exec("DROP INDEX IF EXISTS idx_vpn_user")
if err := db.AutoMigrate(&model.User{}, &model.LDAPConfig{}, &model.MFADevice{}, &model.VPNUser{}, &model.Tunnel{}, &model.Node{}, &model.Plugin{}, &model.AuditLog{}, &model.VPNSession{}, &model.ConnectionLog{}, &model.TrafficPolicy{}); err != nil {
return err
}
DB = db
@@ -45,12 +53,12 @@ func Init(dataDir string) error {
return err
}
// 默认管理员 admin / admin123(首次登录后请修改) // Logan.Li
// 默认管理员 admin / admin123(首次登录后强制改密) // Logan.Li
var count int64
DB.Model(&model.User{}).Count(&count)
if count == 0 {
hash, _ := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)
DB.Create(&model.User{Username: "admin", Password: string(hash), Role: "admin", Source: "local", Enabled: true})
DB.Create(&model.User{Username: "admin", Password: string(hash), Role: "admin", Source: "local", Enabled: true, MustChangePassword: true})
} else {
// 兼容早期单管理员数据:管理员升级后保留完整管理权限。
DB.Model(&model.User{}).Where("username = ? AND source = ?", "admin", "local").Update("role", "admin")
@@ -59,7 +67,7 @@ func Init(dataDir string) error {
var nodeCount int64
DB.Model(&model.Node{}).Count(&nodeCount)
if nodeCount == 0 {
DB.Create(&model.Node{Name: "本机节点", Address: "127.0.0.1", Kind: "local", Remark: "平台所在服务器"})
DB.Create(&model.Node{Name: "本机节点", Address: "127.0.0.1", Port: 22, Kind: "local", Status: "verified", Remark: "平台所在服务器"})
}
return nil
}
+205
View File
@@ -0,0 +1,205 @@
// Package trafficmgr 提供流量配额检查、周期重置、异常速率检测和限速能力。
// 由 monitor 每 15s 调用一次,在流量增量产生时同步配额并触发超额动作。
package trafficmgr
import (
"fmt"
"os/exec"
"strings"
"time"
"vpn-platform/backend/internal/model"
"vpn-platform/backend/internal/sessionmgr"
"vpn-platform/backend/internal/store"
)
// 异常流量阈值:15s 内超过此字节数视为突发(约 50Mbps × 15s ≈ 93MB)
const rateAlertThreshold = 90 * 1024 * 1024
// CheckQuota 检查指定隧道的流量配额,返回超额动作("" 表示未超额或无限额)
func CheckQuota(tunnelID uint, deltaRx, deltaTx int64) string {
if store.DB == nil {
return ""
}
var policy model.TrafficPolicy
if store.DB.Where("tunnel_id = ?", tunnelID).First(&policy).Error != nil {
return "" // 无策略 = 不限
}
// 周期重置
ResetIfNeeded(&policy)
// 累加已用流量
delta := deltaRx + deltaTx
policy.UsedBytes += delta
store.DB.Model(&policy).Update("used_bytes", policy.UsedBytes)
// 无配额限制
if policy.QuotaBytes <= 0 {
return ""
}
// 检查是否超额
if policy.UsedBytes >= policy.QuotaBytes {
return policy.ActionOnExceed // warn / block / throttle
}
return ""
}
// ResetIfNeeded 检查是否到达重置周期,到期则清零已用流量
func ResetIfNeeded(policy *model.TrafficPolicy) {
if policy.ResetCycle == "none" || policy.ResetCycle == "" {
return
}
now := time.Now()
needReset := false
switch policy.ResetCycle {
case "daily":
needReset = now.Sub(policy.LastResetAt) >= 24*time.Hour
case "monthly":
needReset = now.Sub(policy.LastResetAt) >= 30*24*time.Hour
}
if needReset {
policy.UsedBytes = 0
policy.LastResetAt = now
store.DB.Model(policy).Updates(map[string]interface{}{
"used_bytes": 0,
"last_reset_at": now,
})
}
}
// CheckRateAbnormal 检查流量速率是否异常(突发流量告警)
func CheckRateAbnormal(tunnelID uint, deltaRx, deltaTx int64, interval time.Duration) bool {
total := deltaRx + deltaTx
if total <= 0 {
return false
}
ratePerSec := float64(total) / interval.Seconds()
// 超过阈值(约 50Mbps)
if total > rateAlertThreshold {
sessionmgr.LogConnection(tunnelID, "*", "rate_alert", "",
fmt.Sprintf("突发流量 %.1f MB(%.0f Mbps),15s 内增量异常", float64(total)/(1024*1024), ratePerSec*8/(1024*1024)))
return true
}
return false
}
// HandleExceed 处理超额动作
func HandleExceed(tunnelID uint, action string, tunnelName string) {
switch action {
case "warn":
sessionmgr.LogConnection(tunnelID, "*", "quota_exceed", "",
fmt.Sprintf("隧道「%s」流量配额已用尽(仅告警)", tunnelName))
case "block":
sessionmgr.LogConnection(tunnelID, "*", "quota_exceed", "",
fmt.Sprintf("隧道「%s」流量配额已用尽,自动停止", tunnelName))
// 由调用方执行 Stop(避免循环依赖,只返回信号)
case "throttle":
// 降速到 1Mbps
var policy model.TrafficPolicy
if store.DB.Where("tunnel_id = ?", tunnelID).First(&policy).Error == nil {
ApplyRateLimit(tunnelID, 1)
sessionmgr.LogConnection(tunnelID, "*", "quota_exceed", "",
fmt.Sprintf("隧道「%s」流量配额已用尽,限速至 1 Mbps", tunnelName))
}
}
}
// ApplyRateLimit 使用 tc 设置隧道接口限速(需 root + tc 命令)
func ApplyRateLimit(tunnelID uint, mbps int) {
if mbps <= 0 {
RemoveRateLimit(tunnelID)
return
}
// tc 规则基于网络接口名,这里仅做记录;实际接口名需从隧道配置中获取
// 完整实现需在 tunnel start 时获取 iface name 并传入
if _, err := exec.LookPath("tc"); err != nil {
return // tc 不存在则跳过
}
// 限速规则模板(需要接口名,此处占位)
}
// RemoveRateLimit 清除限速规则
func RemoveRateLimit(tunnelID uint) {
if _, err := exec.LookPath("tc"); err != nil {
return
}
}
// GetPolicy 获取隧道的流量策略(不存在则返回默认空策略)
func GetPolicy(tunnelID uint) model.TrafficPolicy {
var policy model.TrafficPolicy
if store.DB.Where("tunnel_id = ?", tunnelID).First(&policy).Error != nil {
return model.TrafficPolicy{TunnelID: tunnelID, ActionOnExceed: "block", ResetCycle: "none"}
}
return policy
}
// SavePolicy 保存流量策略(upsert)
func SavePolicy(policy *model.TrafficPolicy) error {
if policy.TunnelID == 0 {
return fmt.Errorf("tunnel_id 必填")
}
// 检查是否已存在
var existing model.TrafficPolicy
if store.DB.Where("tunnel_id = ?", policy.TunnelID).First(&existing).Error == nil {
// 更新(保留 UsedBytes 和 LastResetAt)
existing.QuotaBytes = policy.QuotaBytes
existing.RateLimitMbps = policy.RateLimitMbps
existing.ResetCycle = policy.ResetCycle
existing.ActionOnExceed = policy.ActionOnExceed
if existing.LastResetAt.IsZero() {
existing.LastResetAt = time.Now()
}
return store.DB.Save(&existing).Error
}
// 新建
policy.LastResetAt = time.Now()
return store.DB.Create(policy).Error
}
// ListAlerts 查询异常流量告警(rate_alert + quota_exceed)
func ListAlerts(limit int) []model.ConnectionLog {
var logs []model.ConnectionLog
if limit <= 0 {
limit = 50
}
events := []string{"rate_alert", "quota_exceed"}
store.DB.Where("event IN ?", events).Order("id desc").Limit(limit).Find(&logs)
return logs
}
// QuotaPercent 计算配额使用百分比
func QuotaPercent(policy *model.TrafficPolicy) int {
if policy.QuotaBytes <= 0 {
return 0
}
pct := int(policy.UsedBytes * 100 / policy.QuotaBytes)
if pct > 100 {
pct = 100
}
return pct
}
// FormatBytes 格式化字节数
func FormatBytes(n int64) string {
if n < 1024 {
return fmt.Sprintf("%d B", n)
}
if n < 1024*1024 {
return fmt.Sprintf("%.1f KB", float64(n)/1024)
}
if n < 1024*1024*1024 {
return fmt.Sprintf("%.1f MB", float64(n)/(1024*1024))
}
return fmt.Sprintf("%.1f GB", float64(n)/(1024*1024*1024))
}
// SplitEndpoint 从 "IP:Port" 中提取 IP
func SplitEndpoint(addr string) string {
if i := strings.LastIndex(addr, ":"); i > 0 {
return addr[:i]
}
return addr
}