Files
opsx-vpn/backend/internal/service/ikev2/ikev2.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

380 lines
12 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 ikev2 实现 IKEv2 远程接入(Road Warrior):员工手机/笔记本拨号进内网。
// 依赖 strongSwan swanctl;认证为「服务器 PSK + 用户 EAP-MSCHAPv2」。
// 拨号用户来自 VPNUser.DialSecret(可逆加密)与隧道级默认用户。
package ikev2
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 "ikev2" }
func (s *Service) DisplayName() string { return "IKEv2 远程接入" }
func (s *Service) Available() (bool, string) {
if p, ok := service.LookBin("swanctl"); ok {
return true, p
}
return false, "未检测到 strongSwan(swanctl),可生成配置但无法启动"
}
func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, error) {
out := service.CloneConfig(cfg)
if service.Str(out, "address_pool") == "" {
out["address_pool"] = "10.12.0.0/24"
}
if _, err := netip.ParsePrefix(service.Str(out, "address_pool")); err != nil {
return nil, fmt.Errorf("address_pool 必须是 CIDR,例如 10.12.0.0/24")
}
if service.Str(out, "dns") == "" {
out["dns"] = service.DefaultDNS()
}
if service.Str(out, "local_id") == "" {
out["local_id"] = "@opsx-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, "local_ts") == "" {
out["local_ts"] = "0.0.0.0/0"
}
for _, key := range []string{"local_id", "ike_proposal", "esp_proposal", "local_ts"} {
if strings.ContainsAny(service.Str(out, key), "\r\n{}\"") {
return nil, fmt.Errorf("%s 包含配置文件不允许的字符", key)
}
}
return out, nil
}
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
} else if service.Str(secrets, "psk") == "" {
psk, err := service.RandomSecret(24)
if err != nil {
return err
}
secrets["psk"] = psk
}
dialUsers, err := service.LoadDialUsers(t.ID)
if err != nil {
return err
}
conf := s.swanctlConf(t.ID, cfg, service.Str(secrets, "psk"), "", "", dialUsers)
if _, err := service.WriteConfig(t, "swanctl-ikev2.conf", conf); err != nil {
return err
}
if _, err := service.WriteConfig(t, "client-guide.txt", s.clientGuide(cfg, secrets)); err != nil {
return err
}
public, sec := service.MergeSecrets(cfg, "psk")
for k, v := range secrets {
sec[k] = v
}
return service.SaveTunnelConfig(t, public, sec)
}
// RefreshAuth rewrites EAP secrets after dial-user changes.
func (s *Service) RefreshAuth(t *model.Tunnel) error { return s.Create(t) }
func (s *Service) swanctlConf(id uint, cfg map[string]interface{}, psk, defaultUser, defaultPass string, users []service.DialUser) string {
var secrets strings.Builder
fmt.Fprintf(&secrets, `secrets {
ike-rw-%d {
id = %s
secret = %s
}
`, id, service.Str(cfg, "local_id"), strconv.Quote(psk))
seen := map[string]bool{}
if defaultUser != "" && defaultPass != "" {
fmt.Fprintf(&secrets, ` eap-%s-%d {
id = %s
secret = %s
}
`, defaultUser, id, strconv.Quote(defaultUser), strconv.Quote(defaultPass))
seen[defaultUser] = true
}
for i, u := range users {
if u.Username == "" || u.Password == "" || seen[u.Username] {
continue
}
fmt.Fprintf(&secrets, ` eap-user-%d-%d {
id = %s
secret = %s
}
`, id, i, strconv.Quote(u.Username), strconv.Quote(u.Password))
seen[u.Username] = true
}
secrets.WriteString("}\n")
return fmt.Sprintf(`connections {
ikev2-rw-%d {
version = 2
pools = pool-%d
proposals = %s
dpd_delay = 30s
dpd_timeout = 120s
local {
auth = psk
id = %s
}
remote {
auth = eap-mschapv2
eap_id = %%any
}
children {
net-%d {
local_ts = %s
esp_proposals = %s
dpd_action = clear
start_action = none
}
}
}
}
pools {
pool-%d {
addrs = %s
dns = %s
}
}
%s`,
id,
id,
service.Str(cfg, "ike_proposal"),
service.Str(cfg, "local_id"),
id,
service.Str(cfg, "local_ts"),
service.Str(cfg, "esp_proposal"),
id,
service.Str(cfg, "address_pool"),
service.StrOr(cfg, "dns", service.DefaultDNS()),
secrets.String(),
)
}
func (s *Service) clientGuide(cfg, secrets map[string]interface{}) string {
return fmt.Sprintf(`======== IKEv2 远程接入参数 ========
服务器地址 : %s
VPN 类型 : IKEv2
远程 ID / 服务器 ID : %s
本地 ID : (可留空,或填用户名)
机器认证 : 预共享密钥 (PSK)
预共享密钥 : %s
用户认证 : 用户名 + 密码(EAP-MSCHAPv2)
地址池 : %s
DNS : %s
客户端配置指引:
- iOS/macOS : 设置 → 通用 → VPN 与设备管理 → VPN → 添加 IKEv2
服务器/远程 ID 填上表;认证用用户名密码;共享密钥填 PSK
- Windows : 设置 → 网络和 Internet → VPN → 添加 IKEv2
- Android : 系统 VPN 或 strongSwan 客户端,类型选 IKEv2 EAP
- 账号密码 : 在平台「VPN 拨号用户」中为本隧道创建后使用
注意:修改拨号用户后需在专线列表对该隧道执行「停止 → 启动」或重新保存配置以刷新 EAP 密钥。
`,
service.StrOr(cfg, "public_endpoint", "<服务器公网 IP 或域名>"),
service.Str(cfg, "local_id"),
service.Str(secrets, "psk"),
service.Str(cfg, "address_pool"),
service.StrOr(cfg, "dns", service.DefaultDNS()),
)
}
func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) {
cfg, err := store.TunnelConfig(t)
if err != nil {
return "", err
}
secrets, _ := store.TunnelSecrets(t)
return s.clientGuide(cfg, secrets), nil
}
func (s *Service) Start(t *model.Tunnel) error {
confPath := service.InstanceDir(t) + "/swanctl-ikev2.conf"
bin, ok := service.LookBin("swanctl")
if !ok {
return fmt.Errorf("无法启动 IKEv2:未检测到 swanctl,请先安装 strongSwan")
}
if err := service.RequireRoot("IKEv2"); err != nil {
return err
}
if err := service.EnsureStrongSwanVICI(bin); err != nil {
return fmt.Errorf("无法启动 IKEv2:%w", err)
}
if err := service.RequireIPv4Forwarding("IKEv2"); err != nil {
return err
}
if _, err := os.Stat(confPath); err != nil {
return fmt.Errorf("无法启动 IKEv2:配置文件不存在,请先重新生成配置: %w", err)
}
cfg, err := store.TunnelConfig(t)
if err != nil {
return err
}
if err := s.installFirewall(t, cfg); err != nil {
return err
}
_ = s.unload(bin, confPath)
if _, err := service.Run(bin, "--load-all", "--file", confPath); err != nil {
_ = s.removeFirewall(t, cfg)
return err
}
return nil
}
func (s *Service) Stop(t *model.Tunnel) error {
var firstErr error
if bin, ok := service.LookBin("swanctl"); ok {
_, _ = service.Run(bin, "--terminate", "--ike", fmt.Sprintf("ikev2-rw-%d", t.ID))
if err := s.unload(bin, service.InstanceDir(t)+"/swanctl-ikev2.conf"); err != nil {
firstErr = err
}
}
if cfg, err := store.TunnelConfig(t); err == nil {
if err := s.removeFirewall(t, cfg); 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 {
// Road Warrior 多数时候无固定 SA;配置已加载即视为服务就绪。
out, err := service.Run(bin, "--list-conns")
name := fmt.Sprintf("ikev2-rw-%d", t.ID)
if err != nil {
// list-conns 失败时再查 SA
if sas, e2 := service.Run(bin, "--list-sas"); e2 == nil && strings.Contains(sas, name) {
return service.NowStatus(true, "running", "IKEv2 远程接入运行中(有已建立会话)"), nil
}
return service.NowStatus(false, "stopped", "无法确认 IKEv2 连接定义是否已加载"), nil
}
if strings.Contains(out, name) {
detail := "IKEv2 远程接入已加载,等待客户端拨号"
if sas, e2 := service.Run(bin, "--list-sas"); e2 == nil {
// 粗略统计会话数
n := strings.Count(sas, name)
if n > 0 {
detail = fmt.Sprintf("IKEv2 远程接入运行中,约 %d 个相关 SA", n)
}
}
return service.NowStatus(true, "running", detail), nil
}
return service.NowStatus(false, "stopped", "IKEv2 连接定义未加载"), 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 {
service.SwanctlUnload(bin, confPath)
return nil
}
func (s *Service) installFirewall(t *model.Tunnel, cfg map[string]interface{}) error {
iptables, ok := service.LookBin("iptables")
if !ok {
return fmt.Errorf("无法启动 IKEv2:未检测到 iptables")
}
comment := fmt.Sprintf("opsx-vpn-ikev2-%d", t.ID)
outbound := service.Str(cfg, "outbound_interface")
if outbound == "" {
var err error
outbound, err = service.DefaultRouteInterface()
if err != nil {
return err
}
}
rules := []struct {
table, chain string
args []string
}{
{"filter", "INPUT", []string{"-p", "udp", "--dport", "500", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
{"filter", "INPUT", []string{"-p", "udp", "--dport", "4500", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
{"filter", "INPUT", []string{"-p", "esp", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
{"nat", "POSTROUTING", []string{"-s", service.Str(cfg, "address_pool"), "-o", outbound, "-m", "comment", "--comment", comment, "-j", "MASQUERADE"}},
}
for _, rule := range rules {
prefix := []string{}
if rule.table != "filter" {
prefix = append(prefix, "-t", rule.table)
}
check := append(append([]string{}, prefix...), "-C", rule.chain)
check = append(check, rule.args...)
if _, err := service.Run(iptables, check...); err == nil {
continue
}
add := append(append([]string{}, prefix...), "-A", rule.chain)
add = append(add, rule.args...)
if _, err := service.Run(iptables, add...); err != nil {
_ = s.removeFirewall(t, cfg)
return fmt.Errorf("无法添加 IKEv2 防火墙规则: %w", err)
}
}
return nil
}
func (s *Service) removeFirewall(t *model.Tunnel, cfg map[string]interface{}) error {
iptables, ok := service.LookBin("iptables")
if !ok {
return nil
}
comment := fmt.Sprintf("opsx-vpn-ikev2-%d", t.ID)
outbound := service.Str(cfg, "outbound_interface")
if outbound == "" {
outbound, _ = service.DefaultRouteInterface()
}
rules := []struct {
table, chain string
args []string
}{
{"filter", "INPUT", []string{"-p", "udp", "--dport", "500", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
{"filter", "INPUT", []string{"-p", "udp", "--dport", "4500", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
{"filter", "INPUT", []string{"-p", "esp", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
{"nat", "POSTROUTING", []string{"-s", service.Str(cfg, "address_pool"), "-o", outbound, "-m", "comment", "--comment", comment, "-j", "MASQUERADE"}},
}
for _, rule := range rules {
prefix := []string{}
if rule.table != "filter" {
prefix = append(prefix, "-t", rule.table)
}
for {
check := append(append([]string{}, prefix...), "-C", rule.chain)
check = append(check, rule.args...)
if _, err := service.Run(iptables, check...); err != nil {
break
}
remove := append(append([]string{}, prefix...), "-D", rule.chain)
remove = append(remove, rule.args...)
if _, err := service.Run(iptables, remove...); err != nil {
return err
}
}
}
return nil
}