feat: implement VPN platform management and real protocol services
This commit is contained in:
commit
58c036c34c
45 files changed
+9244
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user