// 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 }