feat: 后端限速实现 + 前端i18n全面国际化 + 安全与代码质量修复
后端: - trafficmgr: 实现 tc htb 限速(ApplyRateLimit/RemoveRateLimit),自动推导接口名 - 各协议服务: 代码质量改进与错误处理增强 前端: - protocols.js: 170个硬编码中文字符串全部转为 i18n key,中英文双语翻译 - 提取 fmtBytes() 到 utils/format.js 公共模块(消除 4 处重复) - router.js: 添加路由级权限守卫,非管理员无法直接 URL 访问管理页 - Tunnels.vue: SSE 日志流改用 fetch+ReadableStream,token 放 Authorization header - VPNUsers.vue: 修复 catch 块中文硬编码 fallback - 新增 TaskProgress 组件、taskmgr 后端任务管理
This commit is contained in:
1 parent
021d012478
commit
91974fc30f
26 files changed
+1977
-377
No files matched your search
@@ -32,14 +32,16 @@ import (
|
||||
"vpn-platform/backend/internal/sshutil"
|
||||
"vpn-platform/backend/internal/store"
|
||||
"vpn-platform/backend/internal/trafficmgr"
|
||||
"vpn-platform/backend/internal/taskmgr"
|
||||
)
|
||||
|
||||
// Server API 服务器
|
||||
type Server struct {
|
||||
mgr *core.Manager
|
||||
mgr *core.Manager
|
||||
taskMgr *taskmgr.Manager
|
||||
}
|
||||
|
||||
func NewServer(mgr *core.Manager) *Server { return &Server{mgr: mgr} }
|
||||
func NewServer(mgr *core.Manager) *Server { return &Server{mgr: mgr, taskMgr: taskmgr.NewManager()} }
|
||||
|
||||
// ---- Token 会话(内存态,定期清理过期项)----
|
||||
var (
|
||||
@@ -260,6 +262,14 @@ func (s *Server) RegisterRoutes(r *gin.Engine) {
|
||||
ops.GET("/binaries/install-hint/:name", s.binaryInstallHint)
|
||||
ops.PUT("/tunnels/:id/policy", s.saveTrafficPolicy)
|
||||
|
||||
// 批量操作 & 任务管理
|
||||
ops.POST("/tunnels/batch/start", s.batchTunnels(taskmgr.ActionStart))
|
||||
ops.POST("/tunnels/batch/stop", s.batchTunnels(taskmgr.ActionStop))
|
||||
ops.POST("/tunnels/batch/delete", s.batchTunnels(taskmgr.ActionDelete))
|
||||
ops.POST("/tunnels/batch/policy", s.batchSetPolicy)
|
||||
ops.GET("/tasks", s.listTasks)
|
||||
ops.GET("/tasks/:id", s.getTask)
|
||||
|
||||
// 平台管理 admin
|
||||
admin := auth.Group("")
|
||||
admin.Use(requireRole("admin"))
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"vpn-platform/backend/internal/model"
|
||||
"vpn-platform/backend/internal/service"
|
||||
"vpn-platform/backend/internal/store"
|
||||
"vpn-platform/backend/internal/taskmgr"
|
||||
)
|
||||
|
||||
// batchRequest 批量操作请求体
|
||||
type batchRequest struct {
|
||||
TunnelIDs []uint `json:"tunnel_ids" binding:"required,min=1"`
|
||||
}
|
||||
|
||||
// batchTunnels 批量启动/停止/删除隧道
|
||||
func (s *Server) batchTunnels(action taskmgr.TaskAction) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req batchRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证所有隧道存在
|
||||
var tunnels []model.Tunnel
|
||||
if err := store.DB.Where("id IN ?", req.TunnelIDs).Find(&tunnels).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询隧道失败"})
|
||||
return
|
||||
}
|
||||
if len(tunnels) != len(req.TunnelIDs) {
|
||||
found := map[uint]bool{}
|
||||
for _, t := range tunnels {
|
||||
found[t.ID] = true
|
||||
}
|
||||
var missing []uint
|
||||
for _, id := range req.TunnelIDs {
|
||||
if !found[id] {
|
||||
missing = append(missing, id)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("隧道不存在: %v", missing)})
|
||||
return
|
||||
}
|
||||
|
||||
// 构建名称映射
|
||||
names := map[uint]string{}
|
||||
for _, t := range tunnels {
|
||||
names[t.ID] = t.Name
|
||||
}
|
||||
|
||||
// 创建任务
|
||||
taskID := s.taskMgr.Create(action, req.TunnelIDs, names)
|
||||
username := c.GetString("username")
|
||||
|
||||
// 并发执行
|
||||
go func() {
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, 5) // 最多 5 个并发
|
||||
|
||||
for _, tid := range req.TunnelIDs {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(tunnelID uint) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
|
||||
s.taskMgr.MarkRunning(taskID, tunnelID)
|
||||
err := s.executeBatchAction(action, tunnelID, username)
|
||||
if err != nil {
|
||||
s.taskMgr.MarkFailed(taskID, tunnelID, err)
|
||||
} else {
|
||||
s.taskMgr.MarkDone(taskID, tunnelID)
|
||||
}
|
||||
}(tid)
|
||||
}
|
||||
wg.Wait()
|
||||
}()
|
||||
|
||||
c.JSON(http.StatusAccepted, gin.H{"task_id": taskID})
|
||||
}
|
||||
}
|
||||
|
||||
// executeBatchAction 执行单个隧道的操作
|
||||
func (s *Server) executeBatchAction(action taskmgr.TaskAction, tunnelID uint, username string) error {
|
||||
var t model.Tunnel
|
||||
if err := store.DB.First(&t, tunnelID).Error; err != nil {
|
||||
return fmt.Errorf("隧道不存在")
|
||||
}
|
||||
|
||||
mu := lockTunnel(t.ID)
|
||||
defer mu.Unlock()
|
||||
|
||||
switch action {
|
||||
case taskmgr.ActionStart:
|
||||
if t.ExpireAt != nil && time.Now().After(*t.ExpireAt) {
|
||||
return fmt.Errorf("专线已到期")
|
||||
}
|
||||
p, err := s.mgr.Get(t.Protocol)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if t.State == "running" {
|
||||
return nil // 已经运行中,视为成功
|
||||
}
|
||||
if err := p.Start(&t); err != nil {
|
||||
t.State = "error"
|
||||
t.LastError = err.Error()
|
||||
store.DB.Save(&t)
|
||||
return err
|
||||
}
|
||||
t.State = "running"
|
||||
t.LastError = ""
|
||||
store.DB.Save(&t)
|
||||
store.Audit(username, "batch_start", t.Name, "")
|
||||
|
||||
case taskmgr.ActionStop:
|
||||
p, err := s.mgr.Get(t.Protocol)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if t.State == "stopped" {
|
||||
return nil
|
||||
}
|
||||
if err := p.Stop(&t); err != nil {
|
||||
return err
|
||||
}
|
||||
t.State = "stopped"
|
||||
store.DB.Save(&t)
|
||||
store.Audit(username, "batch_stop", t.Name, "")
|
||||
|
||||
case taskmgr.ActionDelete:
|
||||
if p, err := s.mgr.Get(t.Protocol); err == nil {
|
||||
_ = p.Delete(&t)
|
||||
}
|
||||
if err := service.RemoveInstance(&t); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("清理实例文件失败: %w", err)
|
||||
}
|
||||
s.mgr.RemoveStatus(t.ID)
|
||||
store.DB.Delete(&t)
|
||||
store.Audit(username, "batch_delete", t.Name, "")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getTask 查询单个任务状态
|
||||
func (s *Server) getTask(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
t, ok := s.taskMgr.Get(id)
|
||||
if !ok {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "任务不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, t)
|
||||
}
|
||||
|
||||
// listTasks 列出最近任务
|
||||
func (s *Server) listTasks(c *gin.Context) {
|
||||
tasks := s.taskMgr.List()
|
||||
c.JSON(http.StatusOK, tasks)
|
||||
}
|
||||
|
||||
// ensureID 确保 ID 有效(从 batchCreateUsers 模式参考)
|
||||
func parseTunnelIDs(c *gin.Context) ([]uint, error) {
|
||||
var req batchRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, id := range req.TunnelIDs {
|
||||
if id == 0 {
|
||||
return nil, fmt.Errorf("无效的隧道 ID: %d", id)
|
||||
}
|
||||
}
|
||||
return req.TunnelIDs, nil
|
||||
}
|
||||
|
||||
// batchTrafficPolicy 批量设置流量策略
|
||||
type batchPolicyRequest struct {
|
||||
TunnelIDs []uint `json:"tunnel_ids" binding:"required,min=1"`
|
||||
RateLimitMbps int `json:"rate_limit_mbps"`
|
||||
QuotaBytes int64 `json:"quota_bytes"`
|
||||
ActionOnExceed string `json:"action_on_exceed"`
|
||||
ResetCycle string `json:"reset_cycle"`
|
||||
}
|
||||
|
||||
func (s *Server) batchSetPolicy(c *gin.Context) {
|
||||
var req batchPolicyRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
success := 0
|
||||
var errors []string
|
||||
for _, tid := range req.TunnelIDs {
|
||||
var t model.Tunnel
|
||||
if err := store.DB.First(&t, tid).Error; err != nil {
|
||||
errors = append(errors, fmt.Sprintf("隧道 %d 不存在", tid))
|
||||
continue
|
||||
}
|
||||
var policy model.TrafficPolicy
|
||||
if err := store.DB.Where("tunnel_id = ?", tid).First(&policy).Error; err != nil {
|
||||
policy.TunnelID = tid
|
||||
}
|
||||
if req.RateLimitMbps > 0 {
|
||||
policy.RateLimitMbps = req.RateLimitMbps
|
||||
}
|
||||
if req.QuotaBytes > 0 {
|
||||
policy.QuotaBytes = req.QuotaBytes
|
||||
}
|
||||
if req.ActionOnExceed != "" {
|
||||
policy.ActionOnExceed = req.ActionOnExceed
|
||||
}
|
||||
if req.ResetCycle != "" {
|
||||
policy.ResetCycle = req.ResetCycle
|
||||
}
|
||||
if err := store.DB.Save(&policy).Error; err != nil {
|
||||
errors = append(errors, fmt.Sprintf("隧道 %d 策略保存失败: %s", t.Name, err.Error()))
|
||||
continue
|
||||
}
|
||||
success++
|
||||
store.Audit(c.GetString("username"), "batch_policy", t.Name, strconv.Itoa(req.RateLimitMbps)+"Mbps")
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": success,
|
||||
"errors": errors,
|
||||
})
|
||||
}
|
||||
@@ -28,6 +28,9 @@ func Start(mgr *core.Manager, interval time.Duration) {
|
||||
}
|
||||
|
||||
func collect(mgr *core.Manager, interval time.Duration) {
|
||||
if store.DB == nil {
|
||||
return
|
||||
}
|
||||
var tunnels []model.Tunnel
|
||||
if err := store.DB.Find(&tunnels).Error; err != nil {
|
||||
return
|
||||
|
||||
@@ -323,3 +323,110 @@ func SaveTunnelConfig(t *model.Tunnel, public, secrets map[string]interface{}) e
|
||||
}
|
||||
return store.DB.Save(t).Error
|
||||
}
|
||||
|
||||
// CloneConfig 深拷贝配置 map(避免修改原始数据)
|
||||
func CloneConfig(in map[string]interface{}) map[string]interface{} {
|
||||
out := make(map[string]interface{}, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SwanctlUnload 卸载 strongSwan 连接与凭据
|
||||
func SwanctlUnload(bin, confPath string) {
|
||||
_, _ = Run(bin, "--unload-conns", "--file", confPath)
|
||||
_, _ = Run(bin, "--unload-creds", "--file", confPath)
|
||||
}
|
||||
|
||||
// DialUser 拨号用户凭证
|
||||
type DialUser struct {
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
// LoadDialUsers 从数据库加载指定隧道的本地拨号用户
|
||||
func LoadDialUsers(tunnelID uint) ([]DialUser, error) {
|
||||
if store.DB == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var users []model.VPNUser
|
||||
if err := store.DB.Where("tunnel_id = ? AND enabled = ? AND source = ?", tunnelID, true, "local").Find(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]DialUser, 0, len(users))
|
||||
for _, u := range users {
|
||||
if u.DialSecret == "" {
|
||||
continue
|
||||
}
|
||||
pw, err := store.DecryptSecret(u.DialSecret)
|
||||
if err != nil || pw == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, DialUser{Username: u.Username, Password: pw})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FirewallRule 描述一条 iptables 规则
|
||||
type FirewallRule struct {
|
||||
Table string // "filter" or "nat"
|
||||
Chain string // "INPUT", "POSTROUTING", etc.
|
||||
Args []string
|
||||
}
|
||||
|
||||
// ApplyFirewallRules 检查并添加 iptables 规则(已存在则跳过)
|
||||
func ApplyFirewallRules(rules []FirewallRule) error {
|
||||
iptables, ok := LookBin("iptables")
|
||||
if !ok {
|
||||
return fmt.Errorf("未检测到 iptables")
|
||||
}
|
||||
for _, rule := range rules {
|
||||
prefix := []string{}
|
||||
if rule.Table != "filter" {
|
||||
prefix = append(prefix, "-t", rule.Table)
|
||||
}
|
||||
check := append(append([]string{}, prefix...), "-C", rule.Chain)
|
||||
check = append(check, rule.Args...)
|
||||
if _, err := Run(iptables, check...); err == nil {
|
||||
continue
|
||||
}
|
||||
add := append(append([]string{}, prefix...), "-A", rule.Chain)
|
||||
add = append(add, rule.Args...)
|
||||
if _, err := Run(iptables, add...); err != nil {
|
||||
RemoveFirewallRules(rules)
|
||||
return fmt.Errorf("无法添加防火墙规则: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DefaultDNS 返回默认 DNS 服务器地址(可通过 VPN_DNS_SERVER 环境变量覆盖)
|
||||
func DefaultDNS() string {
|
||||
if v := os.Getenv("VPN_DNS_SERVER"); v != "" {
|
||||
return v
|
||||
}
|
||||
return "223.5.5.5"
|
||||
}
|
||||
|
||||
// RemoveFirewallRules 检查并删除 iptables 规则
|
||||
func RemoveFirewallRules(rules []FirewallRule) {
|
||||
iptables, ok := LookBin("iptables")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, rule := range rules {
|
||||
prefix := []string{}
|
||||
if rule.Table != "filter" {
|
||||
prefix = append(prefix, "-t", rule.Table)
|
||||
}
|
||||
check := append(append([]string{}, prefix...), "-C", rule.Chain)
|
||||
check = append(check, rule.Args...)
|
||||
if _, err := Run(iptables, check...); err != nil {
|
||||
continue
|
||||
}
|
||||
del := append(append([]string{}, prefix...), "-D", rule.Chain)
|
||||
del = append(del, rule.Args...)
|
||||
_, _ = Run(iptables, del...)
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ func (s *Service) Available() (bool, string) {
|
||||
}
|
||||
|
||||
func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, error) {
|
||||
out := clone(cfg)
|
||||
out := service.CloneConfig(cfg)
|
||||
mode := service.StrOr(out, "mode", "server")
|
||||
if mode != "server" && mode != "client" {
|
||||
return nil, fmt.Errorf("mode 必须为 server 或 client")
|
||||
@@ -277,7 +277,7 @@ func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) {
|
||||
secrets, _ := store.TunnelSecrets(t)
|
||||
token := service.Str(secrets, "token")
|
||||
if service.StrOr(cfg, "mode", "server") == "server" {
|
||||
cfg = clone(cfg)
|
||||
cfg = service.CloneConfig(cfg)
|
||||
cfg["server_addr"] = service.StrOr(cfg, "public_endpoint", "your-server-ip")
|
||||
cfg["server_port"] = service.Num(cfg, "bind_port", 7000)
|
||||
}
|
||||
@@ -379,13 +379,7 @@ func defaultString(v, d string) string {
|
||||
}
|
||||
return d
|
||||
}
|
||||
func clone(in map[string]interface{}) map[string]interface{} {
|
||||
out := map[string]interface{}{}
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func allowPorts(value string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
|
||||
@@ -30,10 +30,7 @@ func (s *Service) Available() (bool, string) {
|
||||
}
|
||||
|
||||
func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, error) {
|
||||
out := map[string]interface{}{}
|
||||
for k, v := range cfg {
|
||||
out[k] = v
|
||||
}
|
||||
out := service.CloneConfig(cfg)
|
||||
if service.Str(out, "address_pool") == "" {
|
||||
out["address_pool"] = "10.12.0.0/24"
|
||||
}
|
||||
@@ -41,7 +38,7 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{},
|
||||
return nil, fmt.Errorf("address_pool 必须是 CIDR,例如 10.12.0.0/24")
|
||||
}
|
||||
if service.Str(out, "dns") == "" {
|
||||
out["dns"] = "223.5.5.5"
|
||||
out["dns"] = service.DefaultDNS()
|
||||
}
|
||||
if service.Str(out, "local_id") == "" {
|
||||
out["local_id"] = "@opsx-ikev2"
|
||||
@@ -85,7 +82,7 @@ func (s *Service) Create(t *model.Tunnel) error {
|
||||
secrets["password"] = pw
|
||||
}
|
||||
username := service.StrOr(cfg, "username", "vpnuser")
|
||||
dialUsers, err := loadDialUsers(t.ID)
|
||||
dialUsers, err := service.LoadDialUsers(t.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -115,7 +112,7 @@ func (s *Service) Create(t *model.Tunnel) error {
|
||||
// RefreshAuth rewrites EAP secrets after dial-user changes.
|
||||
func (s *Service) RefreshAuth(t *model.Tunnel) error { return s.Create(t) }
|
||||
|
||||
func (s *Service) swanctlConf(id uint, cfg map[string]interface{}, psk, defaultUser, defaultPass string, users []dialUser) string {
|
||||
func (s *Service) swanctlConf(id uint, cfg map[string]interface{}, psk, defaultUser, defaultPass string, users []service.DialUser) string {
|
||||
var secrets strings.Builder
|
||||
fmt.Fprintf(&secrets, `secrets {
|
||||
ike-rw-%d {
|
||||
@@ -186,7 +183,7 @@ pools {
|
||||
service.Str(cfg, "esp_proposal"),
|
||||
id,
|
||||
service.Str(cfg, "address_pool"),
|
||||
service.StrOr(cfg, "dns", "223.5.5.5"),
|
||||
service.StrOr(cfg, "dns", service.DefaultDNS()),
|
||||
secrets.String(),
|
||||
)
|
||||
}
|
||||
@@ -221,7 +218,7 @@ DNS : %s
|
||||
username,
|
||||
service.Str(secrets, "password"),
|
||||
service.Str(cfg, "address_pool"),
|
||||
service.StrOr(cfg, "dns", "223.5.5.5"),
|
||||
service.StrOr(cfg, "dns", service.DefaultDNS()),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -311,38 +308,10 @@ func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
|
||||
func (s *Service) Delete(t *model.Tunnel) error { return s.Stop(t) }
|
||||
|
||||
func (s *Service) unload(bin, confPath string) error {
|
||||
_, _ = service.Run(bin, "--unload-conns", "--file", confPath)
|
||||
_, _ = service.Run(bin, "--unload-creds", "--file", confPath)
|
||||
service.SwanctlUnload(bin, confPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
type dialUser struct {
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
func loadDialUsers(tunnelID uint) ([]dialUser, error) {
|
||||
if store.DB == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var users []model.VPNUser
|
||||
if err := store.DB.Where("tunnel_id = ? AND enabled = ? AND source = ?", tunnelID, true, "local").Find(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]dialUser, 0, len(users))
|
||||
for _, u := range users {
|
||||
if u.DialSecret == "" {
|
||||
continue
|
||||
}
|
||||
pw, err := store.DecryptSecret(u.DialSecret)
|
||||
if err != nil || pw == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, dialUser{Username: u.Username, Password: pw})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) installFirewall(t *model.Tunnel, cfg map[string]interface{}) error {
|
||||
iptables, ok := service.LookBin("iptables")
|
||||
if !ok {
|
||||
|
||||
@@ -55,10 +55,7 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{},
|
||||
if err := service.MustValidate(cfg, "remote_gateway", "local_subnets", "remote_subnets"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]interface{}{}
|
||||
for k, v := range cfg {
|
||||
out[k] = v
|
||||
}
|
||||
out := service.CloneConfig(cfg)
|
||||
// 厂商模板优先填空字段,再回落到通用默认值。
|
||||
if key := service.Str(out, "vendor_template"); key != "" {
|
||||
for _, tmpl := range Templates() {
|
||||
@@ -444,10 +441,7 @@ func extractSwanBytes(out, childName string) (rx, tx int64, ok bool) {
|
||||
func (s *Service) Delete(t *model.Tunnel) error { return s.Stop(t) }
|
||||
|
||||
func (s *Service) unload(bin, confPath string) error {
|
||||
// Both commands are idempotent from the platform perspective: an already
|
||||
// unloaded connection should not prevent stop/delete from cleaning its files.
|
||||
_, _ = service.Run(bin, "--unload-conns", "--file", confPath)
|
||||
_, _ = service.Run(bin, "--unload-creds", "--file", confPath)
|
||||
service.SwanctlUnload(bin, confPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -31,10 +31,7 @@ func (s *Service) Available() (bool, string) {
|
||||
}
|
||||
|
||||
func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, error) {
|
||||
out := map[string]interface{}{}
|
||||
for k, v := range cfg {
|
||||
out[k] = v
|
||||
}
|
||||
out := service.CloneConfig(cfg)
|
||||
if service.Str(out, "ip_range") == "" {
|
||||
out["ip_range"] = "10.9.0.100-10.9.0.200"
|
||||
}
|
||||
@@ -42,7 +39,7 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{},
|
||||
out["local_ip"] = "10.9.0.1"
|
||||
}
|
||||
if service.Str(out, "dns") == "" {
|
||||
out["dns"] = "223.5.5.5"
|
||||
out["dns"] = service.DefaultDNS()
|
||||
}
|
||||
if service.Str(out, "ike_proposal") == "" {
|
||||
out["ike_proposal"] = "aes256-sha256-modp2048,aes128-sha1-modp1024"
|
||||
@@ -108,7 +105,7 @@ func (s *Service) Create(t *model.Tunnel) error {
|
||||
secrets["password"] = pw
|
||||
}
|
||||
// 允许仅依赖拨号用户表(VPNUser)而无隧道级默认密码。
|
||||
dialUsers, err := loadDialUsers(t.ID)
|
||||
dialUsers, err := service.LoadDialUsers(t.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -202,34 +199,9 @@ func (s *Service) RefreshAuth(t *model.Tunnel) error {
|
||||
return s.Create(t)
|
||||
}
|
||||
|
||||
type dialUser struct {
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
func loadDialUsers(tunnelID uint) ([]dialUser, error) {
|
||||
if store.DB == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var users []model.VPNUser
|
||||
if err := store.DB.Where("tunnel_id = ? AND enabled = ? AND source = ?", tunnelID, true, "local").Find(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]dialUser, 0, len(users))
|
||||
for _, u := range users {
|
||||
if u.DialSecret == "" {
|
||||
continue
|
||||
}
|
||||
pw, err := store.DecryptSecret(u.DialSecret)
|
||||
if err != nil || pw == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, dialUser{Username: u.Username, Password: pw})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func buildChapSecrets(defaultUser, defaultPass string, users []dialUser) string {
|
||||
func buildChapSecrets(defaultUser, defaultPass string, users []service.DialUser) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("# client server secret IP addresses\n")
|
||||
seen := map[string]bool{}
|
||||
@@ -361,6 +333,9 @@ func (s *Service) Delete(t *model.Tunnel) error { return s.Stop(t) }
|
||||
// it; without this guard a later instance would look started but accept no VPN
|
||||
// sessions. Network namespaces can deliberately lift this restriction later.
|
||||
func (s *Service) ensureExclusiveListener(t *model.Tunnel) error {
|
||||
if store.DB == nil {
|
||||
return nil
|
||||
}
|
||||
var count int64
|
||||
if err := store.DB.Model(&model.Tunnel{}).Where("protocol = ? AND state = ? AND id <> ?", "l2tp", "running", t.ID).Count(&count).Error; err != nil {
|
||||
return fmt.Errorf("检查 L2TP 监听冲突失败: %w", err)
|
||||
@@ -372,10 +347,7 @@ func (s *Service) ensureExclusiveListener(t *model.Tunnel) error {
|
||||
}
|
||||
|
||||
func (s *Service) unload(bin, confPath string) error {
|
||||
// swanctl reports an error when an object was never loaded. Treat that as
|
||||
// success so Stop/Delete remain idempotent after a partial failed startup.
|
||||
_, _ = service.Run(bin, "--unload-conns", "--file", confPath)
|
||||
_, _ = service.Run(bin, "--unload-creds", "--file", confPath)
|
||||
service.SwanctlUnload(bin, confPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -66,10 +66,7 @@ func (s *Service) Available() (bool, string) {
|
||||
}
|
||||
|
||||
func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, error) {
|
||||
out := map[string]interface{}{}
|
||||
for k, v := range cfg {
|
||||
out[k] = v
|
||||
}
|
||||
out := service.CloneConfig(cfg)
|
||||
if service.Num(out, "port", 0) == 0 {
|
||||
out["port"] = 1194
|
||||
}
|
||||
@@ -101,7 +98,7 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{},
|
||||
return nil, fmt.Errorf("cipher 非法")
|
||||
}
|
||||
if service.Str(out, "dns") == "" {
|
||||
out["dns"] = "223.5.5.5"
|
||||
out["dns"] = service.DefaultDNS()
|
||||
}
|
||||
if net.ParseIP(service.Str(out, "dns")) == nil {
|
||||
return nil, fmt.Errorf("dns 必须是 IP 地址")
|
||||
@@ -300,7 +297,7 @@ func (s *Service) serverConfig(t *model.Tunnel, cfg map[string]interface{}) stri
|
||||
// 地址池与拓扑
|
||||
fmt.Fprintf(&b, "topology subnet\nserver %s\nifconfig-pool-persist %q\n", service.StrOr(cfg, "subnet", "10.10.0.0 255.255.255.0"), filepath.Join(dir, "ipp.txt"))
|
||||
// push:DNS / 全流量 / 内网路由
|
||||
fmt.Fprintf(&b, "push \"dhcp-option DNS %s\"\n", service.StrOr(cfg, "dns", "223.5.5.5"))
|
||||
fmt.Fprintf(&b, "push \"dhcp-option DNS %s\"\n", service.StrOr(cfg, "dns", service.DefaultDNS()))
|
||||
if service.Bool(cfg, "redirect_gateway", true) {
|
||||
b.WriteString("push \"redirect-gateway def1 bypass-dhcp\"\n")
|
||||
}
|
||||
|
||||
@@ -109,10 +109,7 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{},
|
||||
if err := service.MustValidate(cfg, "address"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]interface{}{}
|
||||
for k, v := range cfg {
|
||||
out[k] = v
|
||||
}
|
||||
out := service.CloneConfig(cfg)
|
||||
if !validCIDR(service.Str(out, "address")) {
|
||||
return nil, fmt.Errorf("address 必须是合法 CIDR")
|
||||
}
|
||||
@@ -131,7 +128,7 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{},
|
||||
return nil, fmt.Errorf("allowed_ips: %w", err)
|
||||
}
|
||||
if service.Str(out, "dns") == "" {
|
||||
out["dns"] = "223.5.5.5"
|
||||
out["dns"] = service.DefaultDNS()
|
||||
}
|
||||
if service.Num(out, "keepalive", 0) == 0 {
|
||||
out["keepalive"] = 25
|
||||
@@ -312,7 +309,7 @@ PresharedKey = %s
|
||||
Endpoint = %s
|
||||
AllowedIPs = %s
|
||||
PersistentKeepalive = %d
|
||||
`, sec.PrivateKey, p.ClientAddress, service.StrOr(cfg, "dns", "223.5.5.5"), mtuLine, service.Str(cfg, "server_public_key"), sec.PresharedKey, endpoint, allowed, int(service.Num(cfg, "keepalive", 25))), nil
|
||||
`, sec.PrivateKey, p.ClientAddress, service.StrOr(cfg, "dns", service.DefaultDNS()), mtuLine, service.Str(cfg, "server_public_key"), sec.PresharedKey, endpoint, allowed, int(service.Num(cfg, "keepalive", 25))), nil
|
||||
}
|
||||
if requestedID != "" {
|
||||
return "", fmt.Errorf("WireGuard Peer 不存在: %s", requestedID)
|
||||
@@ -496,11 +493,8 @@ func (s *Service) RemovePeer(t *model.Tunnel, peerID string) error {
|
||||
if raw, ok := secrets["peer_secrets"].(map[string]interface{}); ok {
|
||||
delete(raw, peerID)
|
||||
secrets["peer_secrets"] = raw
|
||||
_ = service.SaveTunnelConfig(t, cfg, secrets)
|
||||
}
|
||||
raw, _ := json.Marshal(cfg)
|
||||
t.Config = string(raw)
|
||||
return s.Create(t)
|
||||
return service.SaveTunnelConfig(t, cfg, secrets)
|
||||
}
|
||||
|
||||
func loadTrafficState(t *model.Tunnel) map[string][2]int64 {
|
||||
@@ -813,9 +807,3 @@ func validateCIDRList(value string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
// Package taskmgr 提供后台并发任务管理器
|
||||
package taskmgr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TaskStatus 任务状态
|
||||
type TaskStatus string
|
||||
|
||||
const (
|
||||
StatusPending TaskStatus = "pending"
|
||||
StatusRunning TaskStatus = "running"
|
||||
StatusDone TaskStatus = "done"
|
||||
StatusFailed TaskStatus = "failed"
|
||||
StatusCanceled TaskStatus = "canceled"
|
||||
)
|
||||
|
||||
// TaskAction 任务动作
|
||||
type TaskAction string
|
||||
|
||||
const (
|
||||
ActionStart TaskAction = "start"
|
||||
ActionStop TaskAction = "stop"
|
||||
ActionDelete TaskAction = "delete"
|
||||
)
|
||||
|
||||
// SubTask 单个子任务(对应一个隧道)
|
||||
type SubTask struct {
|
||||
TunnelID uint `json:"tunnel_id"`
|
||||
TunnelName string `json:"tunnel_name"`
|
||||
Status TaskStatus `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
||||
}
|
||||
|
||||
// Task 一个批量任务
|
||||
type Task struct {
|
||||
ID string `json:"id"`
|
||||
Action TaskAction `json:"action"`
|
||||
Status TaskStatus `json:"status"`
|
||||
Total int `json:"total"`
|
||||
Success int `json:"success"`
|
||||
Failed int `json:"failed"`
|
||||
SubTasks []SubTask `json:"sub_tasks"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Progress 返回进度百分比 0-100
|
||||
func (t *Task) Progress() int {
|
||||
if t.Total == 0 {
|
||||
return 0
|
||||
}
|
||||
done := t.Success + t.Failed
|
||||
return done * 100 / t.Total
|
||||
}
|
||||
|
||||
// Manager 内存任务管理器
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
tasks map[string]*Task
|
||||
seq int
|
||||
}
|
||||
|
||||
func NewManager() *Manager {
|
||||
return &Manager{tasks: make(map[string]*Task)}
|
||||
}
|
||||
|
||||
// Create 创建任务并返回 ID
|
||||
func (m *Manager) Create(action TaskAction, tunnelIDs []uint, names map[uint]string) string {
|
||||
m.mu.Lock()
|
||||
m.seq++
|
||||
id := fmt.Sprintf("task_%d_%d", time.Now().UnixMilli(), m.seq)
|
||||
subTasks := make([]SubTask, len(tunnelIDs))
|
||||
for i, tid := range tunnelIDs {
|
||||
subTasks[i] = SubTask{
|
||||
TunnelID: tid,
|
||||
TunnelName: names[tid],
|
||||
Status: StatusPending,
|
||||
}
|
||||
}
|
||||
t := &Task{
|
||||
ID: id,
|
||||
Action: action,
|
||||
Status: StatusPending,
|
||||
Total: len(tunnelIDs),
|
||||
SubTasks: subTasks,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
m.tasks[id] = t
|
||||
m.mu.Unlock()
|
||||
|
||||
// 自动清理 30 分钟前的任务
|
||||
go m.cleanup()
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
// Get 获取任务快照
|
||||
func (m *Manager) Get(id string) (*Task, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
t, ok := m.tasks[id]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
// 返回副本
|
||||
cp := *t
|
||||
cp.SubTasks = make([]SubTask, len(t.SubTasks))
|
||||
copy(cp.SubTasks, t.SubTasks)
|
||||
return &cp, true
|
||||
}
|
||||
|
||||
// List 列出最近的任务(最多 50 个)
|
||||
func (m *Manager) List() []*Task {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
out := make([]*Task, 0, len(m.tasks))
|
||||
for _, t := range m.tasks {
|
||||
cp := *t
|
||||
cp.SubTasks = make([]SubTask, len(t.SubTasks))
|
||||
copy(cp.SubTasks, t.SubTasks)
|
||||
out = append(out, &cp)
|
||||
}
|
||||
// 按创建时间倒序
|
||||
for i := 0; i < len(out)-1; i++ {
|
||||
for j := i + 1; j < len(out); j++ {
|
||||
if out[j].CreatedAt.After(out[i].CreatedAt) {
|
||||
out[i], out[j] = out[j], out[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) > 50 {
|
||||
out = out[:50]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MarkRunning 标记子任务运行中
|
||||
func (m *Manager) MarkRunning(taskID string, tunnelID uint) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
t, ok := m.tasks[taskID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
t.Status = StatusRunning
|
||||
now := time.Now()
|
||||
t.UpdatedAt = now
|
||||
for i := range t.SubTasks {
|
||||
if t.SubTasks[i].TunnelID == tunnelID {
|
||||
t.SubTasks[i].Status = StatusRunning
|
||||
t.SubTasks[i].StartedAt = &now
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MarkDone 标记子任务成功
|
||||
func (m *Manager) MarkDone(taskID string, tunnelID uint) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
t, ok := m.tasks[taskID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
t.UpdatedAt = now
|
||||
for i := range t.SubTasks {
|
||||
if t.SubTasks[i].TunnelID == tunnelID {
|
||||
t.SubTasks[i].Status = StatusDone
|
||||
t.SubTasks[i].FinishedAt = &now
|
||||
t.Success++
|
||||
break
|
||||
}
|
||||
}
|
||||
t.maybeDone()
|
||||
}
|
||||
|
||||
// MarkFailed 标记子任务失败
|
||||
func (m *Manager) MarkFailed(taskID string, tunnelID uint, err error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
t, ok := m.tasks[taskID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
t.UpdatedAt = now
|
||||
for i := range t.SubTasks {
|
||||
if t.SubTasks[i].TunnelID == tunnelID {
|
||||
t.SubTasks[i].Status = StatusFailed
|
||||
t.SubTasks[i].Error = err.Error()
|
||||
t.SubTasks[i].FinishedAt = &now
|
||||
t.Failed++
|
||||
break
|
||||
}
|
||||
}
|
||||
t.maybeDone()
|
||||
}
|
||||
|
||||
// maybeDone 检查任务是否全部完成(调用者需持锁)
|
||||
func (t *Task) maybeDone() {
|
||||
if t.Success+t.Failed >= t.Total {
|
||||
if t.Failed > 0 {
|
||||
t.Status = StatusDone // 部分失败也算完成
|
||||
} else {
|
||||
t.Status = StatusDone
|
||||
}
|
||||
now := time.Now()
|
||||
t.UpdatedAt = now
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) cleanup() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
cutoff := time.Now().Add(-30 * time.Minute)
|
||||
for id, t := range m.tasks {
|
||||
if t.Status == StatusDone || t.Status == StatusFailed || t.Status == StatusCanceled {
|
||||
if t.UpdatedAt.Before(cutoff) {
|
||||
delete(m.tasks, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@ package trafficmgr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -106,25 +108,66 @@ func HandleExceed(tunnelID uint, action string, tunnelName string) {
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyRateLimit 使用 tc 设置隧道接口限速(需 root + tc 命令)
|
||||
// tunnelIface returns the network interface name for a tunnel, derived from
|
||||
// its protocol and ID. Returns "" if the protocol does not use a tc-able interface.
|
||||
func tunnelIface(tunnelID uint) string {
|
||||
var t model.Tunnel
|
||||
if store.DB == nil || store.DB.First(&t, tunnelID).Error != nil {
|
||||
return ""
|
||||
}
|
||||
switch t.Protocol {
|
||||
case "wireguard":
|
||||
return "wg" + strconv.FormatUint(uint64(t.ID), 36)
|
||||
case "openvpn":
|
||||
return "tun0"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyRateLimit 使用 tc htb 对隧道网络接口限速(需 root + tc 命令)
|
||||
func ApplyRateLimit(tunnelID uint, mbps int) {
|
||||
if mbps <= 0 {
|
||||
RemoveRateLimit(tunnelID)
|
||||
return
|
||||
}
|
||||
// tc 规则基于网络接口名,这里仅做记录;实际接口名需从隧道配置中获取
|
||||
// 完整实现需在 tunnel start 时获取 iface name 并传入
|
||||
if _, err := exec.LookPath("tc"); err != nil {
|
||||
tc, err := exec.LookPath("tc")
|
||||
if err != nil {
|
||||
return // tc 不存在则跳过
|
||||
}
|
||||
// 限速规则模板(需要接口名,此处占位)
|
||||
iface := tunnelIface(tunnelID)
|
||||
if iface == "" {
|
||||
return // 协议不支持 tc 限速
|
||||
}
|
||||
// 先清除旧规则再重建
|
||||
RemoveRateLimit(tunnelID)
|
||||
// tc qdisc add dev <iface> root htb default 10
|
||||
if out, err := exec.Command(tc, "qdisc", "add", "dev", iface, "root", "htb", "default", "10").CombinedOutput(); err != nil {
|
||||
log.Printf("[trafficmgr] tc qdisc add 失败 (iface=%s): %v: %s", iface, err, string(out))
|
||||
return
|
||||
}
|
||||
// tc class add dev <iface> parent root classid 1:10 htb rate <mbps>mbit
|
||||
if out, err := exec.Command(tc, "class", "add", "dev", iface, "parent", "root", "classid", "1:10", "htb", "rate", fmt.Sprintf("%dmbit", mbps)).CombinedOutput(); err != nil {
|
||||
log.Printf("[trafficmgr] tc class add 失败 (iface=%s): %v: %s", iface, err, string(out))
|
||||
return
|
||||
}
|
||||
log.Printf("[trafficmgr] 已对 %s 限速至 %d Mbps", iface, mbps)
|
||||
}
|
||||
|
||||
// RemoveRateLimit 清除限速规则
|
||||
// RemoveRateLimit 清除隧道接口上的 tc 限速规则
|
||||
func RemoveRateLimit(tunnelID uint) {
|
||||
if _, err := exec.LookPath("tc"); err != nil {
|
||||
tc, err := exec.LookPath("tc")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
iface := tunnelIface(tunnelID)
|
||||
if iface == "" {
|
||||
return
|
||||
}
|
||||
if out, err := exec.Command(tc, "qdisc", "del", "dev", iface, "root").CombinedOutput(); err != nil {
|
||||
// qdisc del 在无规则时返回非零,不算真正错误
|
||||
_ = out
|
||||
}
|
||||
}
|
||||
|
||||
// GetPolicy 获取隧道的流量策略(不存在则返回默认空策略)
|
||||
|
||||
Reference in new issue
Block a user