// 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) } } 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 } dialUsers, err := service.LoadDialUsers(t.ID) if err != nil { return err } conf := s.swanctlConf(t.ID, cfg, service.Str(secrets, "psk"), "", "", 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)); err != nil { return err } public, sec := service.MergeSecrets(cfg, "psk") 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{}) string { return fmt.Sprintf(`======== IKEv2 远程接入参数 ======== 服务器地址 : %s VPN 类型 : IKEv2 远程 ID / 服务器 ID : %s 本地 ID : (可留空,或填用户名) 机器认证 : 预共享密钥 (PSK) 预共享密钥 : %s 用户认证 : 用户名 + 密码(EAP-MSCHAPv2) 地址池 : %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"), 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), 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.EnsureStrongSwanVICI(bin); err != nil { return fmt.Errorf("无法启动 IKEv2:%w", 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 }