feat: implement VPN platform management and real protocol services

This commit is contained in:
Logan.Li committed 2026-07-22 21:49:13 +08:00
commit 58c036c34c
45 files changed
+9244

No files matched your search

+421
View File
@@ -0,0 +1,421 @@
// 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
}