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:
opsx-tools committed 2026-07-23 12:57:46 +08:00
1 parent 021d012478
commit 91974fc30f
26 files changed
+1977 -377

No files matched your search

+107
View File
@@ -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...)
}
}
+3 -9
View File
@@ -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{}
+7 -38
View File
@@ -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 {
+2 -8
View File
@@ -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
}
+8 -36
View File
@@ -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
}
+3 -6
View File
@@ -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
}