422 lines
14 KiB
Go
422 lines
14 KiB
Go
// 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 := map[string]interface{}{}
|
||
for k, v := range cfg {
|
||
out[k] = v
|
||
}
|
||
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"
|
||
}
|
||
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.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)
|
||
if err == nil && strings.Contains(out, child) && strings.Contains(out, "INSTALLED") {
|
||
st := service.NowStatus(true, "running", "IKE/Child SA 已建立")
|
||
return st, nil
|
||
}
|
||
if err != nil {
|
||
return service.NowStatus(false, "error", "无法读取 strongSwan SA 状态: "+err.Error()), nil
|
||
}
|
||
return service.NowStatus(false, "stopped", "IPSec Child SA 未建立"), 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 {
|
||
// 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)
|
||
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
|
||
}
|