// Package ipsec 实现 IPSec 站点到站点对接管理(StrongSwan swanctl), // 内置 Cisco / FortiGate / 华为 / 华三 常用对接模板,重点服务第三方对接场景。 package ipsec 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 "ipsec" } func (s *Service) DisplayName() string { return "IPSec 站点互联" } func (s *Service) Available() (bool, string) { if p, ok := service.LookBin("swanctl"); ok { return true, p } return false, "未检测到 strongSwan(swanctl),可生成配置但无法启动" } // VendorTemplate 第三方设备对接模板 type VendorTemplate struct { Key string `json:"key"` Name string `json:"name"` IKEVersion string `json:"ike_version"` IKEProposal string `json:"ike_proposal"` ESPProposal string `json:"esp_proposal"` DPDAction string `json:"dpd_action"` Note string `json:"note"` } // Templates 常用第三方设备模板(Phase1 / Phase2 参数预置) func Templates() []VendorTemplate { return []VendorTemplate{ {"cisco", "Cisco ISR/ASR", "ikev2", "aes256-sha256-modp2048", "aes256-sha256", "restart", "思科设备建议 IKEv2 + DH group14,注意对端 lifetime 默认 86400s"}, {"fortigate", "FortiGate 防火墙", "ikev2", "aes256-sha256-modp1536", "aes256-sha256", "clear", "飞塔默认 DH group5,Phase2 建议开启 PFS group14"}, {"huawei", "华为 USG/AR", "ikev2", "aes256-sha1-modp1024", "aes256-sha1", "restart", "华为老设备常用 DH group2,建议升级到 modp2048"}, {"h3c", "H3C 防火墙", "ikev1", "aes128-sha1-modp1024", "aes128-sha1", "restart", "华三老型号仅支持 IKEv1 主模式,需关闭 DPD 周期性探测"}, {"generic", "通用标准(推荐)", "ikev2", "aes256gcm16-sha256-modp2048", "aes256gcm16", "restart", "现代设备首选:AES-GCM 认证加密,性能与安全兼顾"}, } } func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, error) { if err := service.MustValidate(cfg, "remote_gateway", "local_subnets", "remote_subnets"); err != nil { return nil, err } out := service.CloneConfig(cfg) // 厂商模板优先填空字段,再回落到通用默认值。 if key := service.Str(out, "vendor_template"); key != "" { for _, tmpl := range Templates() { if tmpl.Key != key { continue } if service.Str(out, "ike_version") == "" { out["ike_version"] = tmpl.IKEVersion } if service.Str(out, "ike_proposal") == "" { out["ike_proposal"] = tmpl.IKEProposal } if service.Str(out, "esp_proposal") == "" { out["esp_proposal"] = tmpl.ESPProposal } if service.Str(out, "dpd_action") == "" { out["dpd_action"] = tmpl.DPDAction } break } } if service.Str(out, "ike_version") == "" { out["ike_version"] = "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, "dpd_action") == "" { out["dpd_action"] = "restart" } if service.Str(out, "local_id") == "" { out["local_id"] = "@vpn-local" } if service.Str(out, "remote_id") == "" { out["remote_id"] = "@vpn-remote" } if service.Num(out, "ike_lifetime", 0) == 0 { out["ike_lifetime"] = 86400 } if service.Str(out, "pfs_group") == "" { out["pfs_group"] = "modp2048" } remoteGateway := service.Str(out, "remote_gateway") // StrongSwan accepts both literal addresses and DNS names. Keep DNS support // for peers with dynamic addresses, while rejecting syntax that can alter the // generated swanctl stanza. if _, err := netip.ParseAddr(remoteGateway); err != nil && (strings.ContainsAny(remoteGateway, " \t\r\n{}\"") || !strings.ContainsAny(remoteGateway, ".abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")) { return nil, fmt.Errorf("remote_gateway 必须是 IPv4/IPv6 地址或有效域名") } if err := validatePrefixes(service.Str(out, "local_subnets")); err != nil { return nil, fmt.Errorf("local_subnets 无效: %w", err) } if err := validatePrefixes(service.Str(out, "remote_subnets")); err != nil { return nil, fmt.Errorf("remote_subnets 无效: %w", err) } if intersects(service.Str(out, "local_subnets"), service.Str(out, "remote_subnets")) { return nil, fmt.Errorf("local_subnets 与 remote_subnets 不能重叠") } for _, key := range []string{"ike_proposal", "esp_proposal", "local_id", "remote_id", "dpd_action"} { if strings.ContainsAny(service.Str(out, key), "\r\n{}\"") { return nil, fmt.Errorf("%s 包含配置文件不允许的字符", key) } } return out, nil } func validatePrefixes(raw string) error { for _, item := range strings.Split(raw, ",") { if _, err := netip.ParsePrefix(strings.TrimSpace(item)); err != nil { return err } } return nil } func intersects(a, b string) bool { for _, left := range strings.Split(a, ",") { lp, err := netip.ParsePrefix(strings.TrimSpace(left)) if err != nil { continue } for _, right := range strings.Split(b, ",") { rp, err := netip.ParsePrefix(strings.TrimSpace(right)) if err == nil && (lp.Overlaps(rp)) { return true } } } return false } 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 // 显式提交新 PSK 时覆盖旧值 } else if service.Str(secrets, "psk") == "" { return fmt.Errorf("缺少预共享密钥 psk(第三方对接必填)") } conf := s.swanctlConf(t.ID, cfg, service.Str(secrets, "psk")) if _, err := service.WriteConfig(t, "swanctl.conf", conf); err != nil { return err } // 同时导出一份对端参考配置(方便发给第三方) peerRef := s.PeerReference(cfg) if _, err := service.WriteConfig(t, "peer-reference.txt", peerRef); err != nil { return err } public, sec := service.MergeSecrets(cfg, "psk") for k, v := range secrets { sec[k] = v } return service.SaveTunnelConfig(t, public, sec) } // swanctlConf 生成 StrongSwan swanctl 配置 func (s *Service) swanctlConf(id uint, cfg map[string]interface{}, psk string) string { vip := "" if v := service.Str(cfg, "local_ts_vip"); v != "" { vip = "\n vips = " + v } return fmt.Sprintf(`connections { tunnel-%d { remote_addrs = %s version = %s dpd_delay = 30s dpd_timeout = 120s proposals = %s local { auth = psk id = %s } remote { auth = psk id = %s } children { site-%d { local_ts = %s remote_ts = %s esp_proposals = %s dpd_action = %s start_action = start%s } } } } secrets { ike-tunnel-%d { id-local = %s id-remote = %s secret = %s } } `, id, service.Str(cfg, "remote_gateway"), ikeVersionNum(service.Str(cfg, "ike_version")), service.Str(cfg, "ike_proposal"), service.Str(cfg, "local_id"), service.Str(cfg, "remote_id"), id, service.Str(cfg, "local_subnets"), service.Str(cfg, "remote_subnets"), service.Str(cfg, "esp_proposal"), service.Str(cfg, "dpd_action"), vip, id, service.Str(cfg, "local_id"), service.Str(cfg, "remote_id"), strconv.Quote(psk), ) } func ikeVersionNum(v string) string { if strings.EqualFold(v, "ikev1") { return "1" } return "2" } // PeerReference 生成对端(第三方)参数参考文档 func (s *Service) PeerReference(cfg map[string]interface{}) string { return fmt.Sprintf(`======== IPSec 对接参数(请提供给对端管理员)======== 本端网关公网地址 : %s 对端网关地址 : %s IKE 版本 : %s 认证方式 : 预共享密钥 (PSK) ----- Phase 1 (IKE SA) ----- 加密/认证/DH : %s 本端标识 ID : %s 对端标识 ID : %s SA 生存期 : %ds DPD 策略 : %s (30s 探测 / 120s 超时) ----- Phase 2 (IPSec SA) ----- 封装协议 : ESP (隧道模式) 加密/认证 : %s 本端网段 : %s 对端网段 : %s PFS : %s 注意:双方 Phase1/Phase2 的加密算法、认证算法、DH 组必须完全一致, 两端 ID 需交叉对应(本端 local_id = 对端 remote_id)。 `, service.StrOr(cfg, "public_endpoint", "<本端公网 IP>"), service.Str(cfg, "remote_gateway"), service.Str(cfg, "ike_version"), service.Str(cfg, "ike_proposal"), service.Str(cfg, "local_id"), service.Str(cfg, "remote_id"), int(service.Num(cfg, "ike_lifetime", 86400)), service.Str(cfg, "dpd_action"), service.Str(cfg, "esp_proposal"), service.Str(cfg, "local_subnets"), service.Str(cfg, "remote_subnets"), service.StrOr(cfg, "pfs_group", "modp2048"), ) } // ComparePeerParams 双方参数一致性检查(返回不一致项列表) func ComparePeerParams(local, peer map[string]interface{}) []string { var diffs []string checks := []struct{ key, label string }{ {"ike_version", "IKE 版本"}, {"ike_proposal", "Phase1 加密/认证/DH 提议"}, {"esp_proposal", "Phase2 ESP 提议"}, {"pfs_group", "PFS DH 组"}, } for _, c := range checks { lv, pv := service.Str(local, c.key), service.Str(peer, c.key) if pv != "" && lv != pv { diffs = append(diffs, fmt.Sprintf("%s 不一致:本端 [%s] vs 对端 [%s]", c.label, lv, pv)) } } // 网段交叉检查:本端 local 应等于对端 remote if service.Str(peer, "local_subnets") != "" && service.Str(local, "remote_subnets") != service.Str(peer, "local_subnets") { diffs = append(diffs, "网段交叉不匹配:本端 remote_subnets 应等于对端 local_subnets") } return diffs } // GenerateClientConfig 导出对端参考配置(第三方对接场景) func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) { cfg, err := store.TunnelConfig(t) if err != nil { return "", err } return s.PeerReference(cfg), nil } func (s *Service) Start(t *model.Tunnel) error { confPath := fmt.Sprintf("%s/swanctl.conf", service.InstanceDir(t)) bin, ok := service.LookBin("swanctl") if !ok { return fmt.Errorf("无法启动 IPSec:未检测到 swanctl,请先安装 strongSwan") } if err := service.RequireRoot("IPSec"); err != nil { return err } if err := service.EnsureStrongSwanVICI(bin); err != nil { return fmt.Errorf("无法启动 IPSec:%w", err) } if err := service.RequireIPv4Forwarding("IPSec"); err != nil { return err } if _, err := os.Stat(confPath); err != nil { return fmt.Errorf("无法启动 IPSec:配置文件不存在,请先重新生成配置: %w", err) } if err := s.installFirewall(t); err != nil { return err } // Loading a revised configuration without unloading the previous connection // can leave stale traffic selectors and credentials in charon. _ = s.unload(bin, confPath) if _, err := service.Run(bin, "--load-all", "--file", confPath); err != nil { _ = s.removeFirewall(t) return err } if _, err := service.Run(bin, "--initiate", "--child", fmt.Sprintf("site-%d", t.ID)); err != nil { _ = s.unload(bin, confPath) _ = s.removeFirewall(t) return err } return nil } func (s *Service) Stop(t *model.Tunnel) error { var firstErr error if bin, ok := service.LookBin("swanctl"); ok { if _, err := service.Run(bin, "--terminate", "--ike", fmt.Sprintf("tunnel-%d", t.ID)); err != nil { firstErr = err } if err := s.unload(bin, fmt.Sprintf("%s/swanctl.conf", service.InstanceDir(t))); err != nil && firstErr == nil { firstErr = err } } if err := s.removeFirewall(t); 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 { out, err := service.Run(bin, "--list-sas") child := fmt.Sprintf("site-%d", t.ID) ike := fmt.Sprintf("tunnel-%d", t.ID) if err != nil { return service.NowStatus(false, "error", "无法读取 strongSwan SA 状态: "+err.Error()), nil } if strings.Contains(out, child) && strings.Contains(out, "INSTALLED") { detail := "IKE/Child SA 已建立" // 从 swanctl 输出中尽量提取对端地址与字节计数(格式因版本略有差异)。 if peer := extractSwanPeer(out, ike); peer != "" { detail += ",对端 " + peer } if rx, tx, ok := extractSwanBytes(out, child); ok { st := service.NowStatus(true, "running", detail) st.RxBytes = rx st.TxBytes = tx return st, nil } return service.NowStatus(true, "running", detail), nil } if strings.Contains(out, ike) { return service.NowStatus(false, "stopped", "IKE SA 存在但 Child SA 未 INSTALLED"), nil } return service.NowStatus(false, "stopped", "IPSec Child SA 未建立"), nil } return service.NowStatus(false, "undeployed", "配置已生成,但未检测到 strongSwan(swanctl),尚未部署"), nil } func extractSwanPeer(out, ikeName string) string { // 典型行: tunnel-1: #1, ESTABLISHED, ... 1.2.3.4[... ]...5.6.7.8[...] for _, line := range strings.Split(out, "\n") { if !strings.Contains(line, ikeName) { continue } fields := strings.Fields(line) for _, f := range fields { if strings.Contains(f, "[") && strings.Count(f, ".") >= 3 { return strings.Split(f, "[")[0] } } } return "" } func extractSwanBytes(out, childName string) (rx, tx int64, ok bool) { // 寻找含 child 名与 bytes_i/bytes_o 的行 for _, line := range strings.Split(out, "\n") { if !strings.Contains(line, childName) { continue } // 兼容 "123 bytes_i, 456 bytes_o" 与 "bytes_i/bytes_o" parts := strings.Fields(line) for i, p := range parts { if strings.Contains(p, "bytes_i") && i > 0 { fmt.Sscanf(parts[i-1], "%d", &rx) ok = true } if strings.Contains(p, "bytes_o") && i > 0 { fmt.Sscanf(parts[i-1], "%d", &tx) ok = true } } if ok { return } } return 0, 0, false } 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) error { iptables, ok := service.LookBin("iptables") if !ok { return fmt.Errorf("无法启动 IPSec:未检测到 iptables,无法放行 UDP 500/4500 与 ESP") } comment := fmt.Sprintf("opsx-vpn-ipsec-%d", t.ID) rules := [][]string{ {"-p", "udp", "--dport", "500", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}, {"-p", "udp", "--dport", "4500", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}, {"-p", "esp", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}, } for _, rule := range rules { check := append([]string{"-C", "INPUT"}, rule...) if _, err := service.Run(iptables, check...); err == nil { continue } add := append([]string{"-A", "INPUT"}, rule...) if _, err := service.Run(iptables, add...); err != nil { _ = s.removeFirewall(t) return fmt.Errorf("无法添加 IPSec 防火墙规则: %w", err) } } return nil } func (s *Service) removeFirewall(t *model.Tunnel) error { iptables, ok := service.LookBin("iptables") if !ok { return nil // Service removal must still work after a package uninstall. } comment := fmt.Sprintf("opsx-vpn-ipsec-%d", t.ID) rules := [][]string{ {"-p", "udp", "--dport", "500", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}, {"-p", "udp", "--dport", "4500", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}, {"-p", "esp", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}, } for _, rule := range rules { for { check := append([]string{"-C", "INPUT"}, rule...) if _, err := service.Run(iptables, check...); err != nil { break } remove := append([]string{"-D", "INPUT"}, rule...) if _, err := service.Run(iptables, remove...); err != nil { return fmt.Errorf("无法移除 IPSec 防火墙规则: %w", err) } } } return nil }