Files
opsx-vpn/backend/internal/service/ipsec/ipsec.go
T
opsx ef7eb378d2 feat: 新增 GRE 隧道协议,管理端口调整为 18080
- backend: 新增 GRE 隧道实现(ip tunnel 点对点互联),binmgr 支持 iproute2
- backend: api/identity 调整,各协议 service(gre/ikev2/ipsec/l2tp/openvpn)统一改动
- backend: OpenVPN 认证辅助与用户口令同步逻辑完善
- frontend: 协议图标/向导/编辑器支持 GRE,VPN 用户页重构(234 行)
- frontend: 新增 RouteListInput 路由列表输入组件,Dashboard/Layout 优化
- i18n: 中英文文案同步更新
- test: 新增 binmgr/common/gre 单测,openvpn 测试补充
- docs: README 补充 GRE 说明,默认监听端口 8080 → 18080
- chore: .gitignore 增加 opsx-vpn.previous(构建备份二进制)
2026-10-20 20:58:58 +08:00

501 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}