- 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(构建备份二进制)
501 lines
16 KiB
Go
501 lines
16 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 := 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
|
||
}
|