UI/UX 优化: - 登录页分屏布局 + 可访问性修复 + 内联错误提示 - 侧栏菜单分组(专线运维/资源管理/系统) + 图标修正 + 子路由高亮 - Dashboard 统计卡渐变图标 + 空状态引导 + 统计卡可点击 - 创建向导: 依赖检测门控 + 高级折叠 + 必填校验 + WireGuard推荐标 - 专线列表: 空状态CTA + 操作列「更多」下拉 + 状态筛选 + SSE日志流 - 协议品牌SVG图标(ProtocolIcon组件)替换emoji - 设计令牌系统(tokens.css)统一颜色/间距/圆角/阴影 功能增强: - 在线会话管理(sessionmgr): 跨协议会话同步 + 踢下线 + 连接审计日志 - 流量配额引擎(trafficmgr): 配额检查 + 超额动作(block/warn/throttle) + 周期重置 - 异常流量告警: 突发流量检测(>90MB/15s) + 审计记录 - 节点SSH凭证管理: 密码/私钥认证 + 即时测试连接 + AES-GCM加密存储 - 协议二进制管理(binmgr): FRP/OpenVPN GitHub下载 + 系统包安装引导(apt/yum/dnf) - 右上角修改密码弹窗 + MFA独立验证页 + 密码强度提示 - 用户管理: 批量导入 + LDAP浏览导入 + 平台用户导入VPN拨号用户 - 404路由 + IPSec对接页可达性修复 - MFA备用码持久化弹窗(替代Toast) - 轮询可见性守卫 + ECharts resize修复 后端: - 3张新表: VPNSession/ConnectionLog/TrafficPolicy - monitor增强: 会话同步+配额检查+异常告警+到期审计 - changePassword统一8位+新旧不同校验 - openvpn-otp文案专业化(移除抄袭感) - SSE日志流(token降级query参数)
428 lines
13 KiB
Go
428 lines
13 KiB
Go
// 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 := map[string]interface{}{}
|
||
for k, v := range cfg {
|
||
out[k] = v
|
||
}
|
||
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"] = "223.5.5.5"
|
||
}
|
||
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 := 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 []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", "223.5.5.5"),
|
||
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", "223.5.5.5"),
|
||
)
|
||
}
|
||
|
||
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.Run(bin, "--unload-conns", "--file", confPath)
|
||
_, _ = service.Run(bin, "--unload-creds", "--file", confPath)
|
||
return nil
|
||
}
|
||
|
||
type dialUser struct {
|
||
Username string
|
||
Password string
|
||
}
|
||
|
||
func loadDialUsers(tunnelID uint) ([]dialUser, error) {
|
||
if store.DB == nil {
|
||
return nil, nil
|
||
}
|
||
var users []model.VPNUser
|
||
if err := store.DB.Where("tunnel_id = ? AND enabled = ? AND source = ?", tunnelID, true, "local").Find(&users).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
out := make([]dialUser, 0, len(users))
|
||
for _, u := range users {
|
||
if u.DialSecret == "" {
|
||
continue
|
||
}
|
||
pw, err := store.DecryptSecret(u.DialSecret)
|
||
if err != nil || pw == "" {
|
||
continue
|
||
}
|
||
out = append(out, dialUser{Username: u.Username, Password: pw})
|
||
}
|
||
return out, 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
|
||
}
|