后端: - 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 后端任务管理
397 lines
13 KiB
Go
397 lines
13 KiB
Go
// 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 := service.CloneConfig(cfg)
|
||
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"] = service.DefaultDNS()
|
||
}
|
||
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 := service.LoadDialUsers(t.ID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if service.Str(secrets, "password") == "" && len(dialUsers) == 0 {
|
||
// 自动生成一个默认拨号密码,避免空库无法联调。
|
||
pw, err := service.RandomSecret(12)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
secrets["password"] = pw
|
||
}
|
||
|
||
conf := s.swanctlConf(t.ID, cfg, service.Str(secrets, "psk"), username, service.Str(secrets, "password"), dialUsers)
|
||
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 []service.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", service.DefaultDNS()),
|
||
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", service.DefaultDNS()),
|
||
)
|
||
}
|
||
|
||
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.SwanctlUnload(bin, confPath)
|
||
return 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
|
||
}
|