Files
opsx-vpn/backend/internal/service/ikev2/ikev2.go
T
opsx-tools 91974fc30f feat: 后端限速实现 + 前端i18n全面国际化 + 安全与代码质量修复
后端:
- trafficmgr: 实现 tc htb 限速(ApplyRateLimit/RemoveRateLimit),自动推导接口名
- 各协议服务: 代码质量改进与错误处理增强

前端:
- protocols.js: 170个硬编码中文字符串全部转为 i18n key,中英文双语翻译
- 提取 fmtBytes() 到 utils/format.js 公共模块(消除 4 处重复)
- router.js: 添加路由级权限守卫,非管理员无法直接 URL 访问管理页
- Tunnels.vue: SSE 日志流改用 fetch+ReadableStream,token 放 Authorization header
- VPNUsers.vue: 修复 catch 块中文硬编码 fallback
- 新增 TaskProgress 组件、taskmgr 后端任务管理
2026-07-23 12:57:46 +08:00

397 lines
13 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)
}
}
if service.Str(out, "username") != "" && strings.ContainsAny(service.Str(out, "username"), " \t\r\n\"") {
return nil, fmt.Errorf("username 不能包含空白或引号")
}
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
}
if pw := service.Str(cfg, "password"); pw != "" {
secrets["password"] = pw
}
username := service.StrOr(cfg, "username", "vpnuser")
dialUsers, err := service.LoadDialUsers(t.ID)
if err != nil {
return err
}
if service.Str(secrets, "password") == "" && len(dialUsers) == 0 {
// 自动生成一个默认拨号密码,避免空库无法联调。
pw, err := service.RandomSecret(12)
if err != nil {
return err
}
secrets["password"] = pw
}
conf := s.swanctlConf(t.ID, cfg, service.Str(secrets, "psk"), username, service.Str(secrets, "password"), 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, username)); err != nil {
return err
}
public, sec := service.MergeSecrets(cfg, "psk", "password")
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{}, username string) string {
return fmt.Sprintf(`======== IKEv2 远程接入参数 ========
服务器地址 : %s
VPN 类型 : IKEv2
远程 ID / 服务器 ID : %s
本地 ID : (可留空,或填用户名)
机器认证 : 预共享密钥 (PSK)
预共享密钥 : %s
用户认证 : 用户名 + 密码(EAP-MSCHAPv2)
默认用户名 : %s
默认密码 : %s
地址池 : %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"),
username,
service.Str(secrets, "password"),
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, service.StrOr(cfg, "username", "vpnuser")), 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.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
}