Files
opsx-vpn/backend/internal/service/l2tp/l2tp.go
T
Logan.Li 1e2f8ef24f feat: 全面优化 — UI/UX重构 + 流量审计与会话管理 + 协议部署
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参数)
2026-07-23 01:42:36 +08:00

466 lines
15 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 l2tp 实现 L2TP/IPSec 接入管理(StrongSwan + xl2tpd),兼容手机/老设备。
package l2tp
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 "l2tp" }
func (s *Service) DisplayName() string { return "L2TP/IPSec" }
func (s *Service) Available() (bool, string) {
_, hasSwan := service.LookBin("swanctl", "ipsec")
_, hasL2tp := service.LookBin("xl2tpd")
if hasSwan && hasL2tp {
return true, "strongSwan + xl2tpd"
}
return false, "未检测到 strongSwan/xl2tpd,可生成配置但无法启动"
}
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, "ip_range") == "" {
out["ip_range"] = "10.9.0.100-10.9.0.200"
}
if service.Str(out, "local_ip") == "" {
out["local_ip"] = "10.9.0.1"
}
if service.Str(out, "dns") == "" {
out["dns"] = "223.5.5.5"
}
if service.Str(out, "ike_proposal") == "" {
out["ike_proposal"] = "aes256-sha256-modp2048,aes128-sha1-modp1024"
}
if service.Str(out, "esp_proposal") == "" {
out["esp_proposal"] = "aes256-sha256,aes128-sha1"
}
local, err := netip.ParseAddr(service.Str(out, "local_ip"))
if err != nil || !local.Is4() {
return nil, fmt.Errorf("local_ip 必须是 IPv4 地址")
}
start, end, err := parseIPRange(service.Str(out, "ip_range"))
if err != nil || !start.Is4() || !end.Is4() || start.Compare(end) > 0 {
return nil, fmt.Errorf("ip_range 必须是递增的 IPv4 地址范围")
}
if local.Compare(start) >= 0 && local.Compare(end) <= 0 {
return nil, fmt.Errorf("local_ip 不能落在拨号客户端 ip_range 内")
}
if service.Str(out, "client_subnet") == "" {
out["client_subnet"] = netip.PrefixFrom(start, 24).Masked().String()
}
prefix, err := netip.ParsePrefix(service.Str(out, "client_subnet"))
if err != nil || !prefix.Addr().Is4() || !prefix.Contains(start) || !prefix.Contains(end) {
return nil, fmt.Errorf("client_subnet 必须覆盖整个 ip_range")
}
if username := service.Str(out, "username"); username != "" && strings.ContainsAny(username, " \t\r\n\"") {
return nil, fmt.Errorf("username 不能包含空白或引号")
}
for _, key := range []string{"ike_proposal", "esp_proposal"} {
if strings.ContainsAny(service.Str(out, key), "\r\n{}\"") {
return nil, fmt.Errorf("%s 包含配置文件不允许的字符", key)
}
}
return out, nil
}
func parseIPRange(raw string) (netip.Addr, netip.Addr, error) {
parts := strings.Split(raw, "-")
if len(parts) != 2 {
return netip.Addr{}, netip.Addr{}, fmt.Errorf("格式应为 起始IP-结束IP")
}
start, err := netip.ParseAddr(strings.TrimSpace(parts[0]))
if err != nil {
return netip.Addr{}, netip.Addr{}, err
}
end, err := netip.ParseAddr(strings.TrimSpace(parts[1]))
return start, end, err
}
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") == "" {
return fmt.Errorf("缺少 IPSec 预共享密钥 psk")
}
username := service.StrOr(cfg, "username", "vpnuser")
if pw := service.Str(cfg, "password"); pw != "" {
secrets["password"] = pw
}
// 允许仅依赖拨号用户表(VPNUser)而无隧道级默认密码。
dialUsers, err := loadDialUsers(t.ID)
if err != nil {
return err
}
if service.Str(secrets, "password") == "" && len(dialUsers) == 0 {
return fmt.Errorf("缺少拨号密码:请填写隧道默认 password,或在「VPN 拨号用户」中添加本隧道用户")
}
// 1) IPSec 层(transport 模式,L2TP over IPSec)
swanConf := fmt.Sprintf(`connections {
l2tp-%d {
type = transport
proposals = %s
local { auth = psk }
remote { auth = psk }
children {
l2tp-child-%d {
local_ts = dynamic[udp/l2tp]
remote_ts = dynamic[udp/l2tp]
esp_proposals = %s
start_action = trap
}
}
}
}
secrets {
ike-l2tp-%d {
secret = %s
}
}
`, t.ID, service.Str(cfg, "ike_proposal"), t.ID, service.Str(cfg, "esp_proposal"), t.ID, strconv.Quote(service.Str(secrets, "psk")))
// 2) L2TP 层
xl2tpdConf := fmt.Sprintf(`[global]
port = 1701
access control = yes
auth file = %s/chap-secrets
[lns default]
ip range = %s
local ip = %s
require chap = yes
refuse pap = yes
require authentication = yes
name = opsx-l2tp-%d
ppp debug = no
pppoptfile = %s/ppp-options
length bit = yes
`, service.InstanceDir(t), service.Str(cfg, "ip_range"), service.Str(cfg, "local_ip"), t.ID, service.InstanceDir(t))
pppOpts := fmt.Sprintf(`ms-dns %s
asyncmap 0
auth
ipcp-accept-local
ipcp-accept-remote
nodefaultroute
crtscts
lock
hide-password
modem
debug
name l2tpd
proxyarp
lcp-echo-interval 30
lcp-echo-failure 4
mtu 1400
mru 1400
require-mschap-v2
`, service.Str(cfg, "dns"))
chapSecrets := buildChapSecrets(username, service.Str(secrets, "password"), dialUsers)
for name, content := range map[string]string{
"swanctl-l2tp.conf": swanConf,
"xl2tpd.conf": xl2tpdConf,
"ppp-options": pppOpts,
"chap-secrets": chapSecrets,
} {
if _, err := service.WriteConfig(t, name, content); 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 chap-secrets after VPN dial users change.
func (s *Service) RefreshAuth(t *model.Tunnel) error {
return s.Create(t)
}
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 buildChapSecrets(defaultUser, defaultPass string, users []dialUser) string {
var b strings.Builder
b.WriteString("# client server secret IP addresses\n")
seen := map[string]bool{}
if defaultUser != "" && defaultPass != "" {
fmt.Fprintf(&b, "%s l2tpd %s *\n", defaultUser, strconv.Quote(defaultPass))
seen[defaultUser] = true
}
for _, u := range users {
if u.Username == "" || u.Password == "" || seen[u.Username] {
continue
}
fmt.Fprintf(&b, "%s l2tpd %s *\n", u.Username, strconv.Quote(u.Password))
seen[u.Username] = true
}
return b.String()
}
// GenerateClientConfig 导出客户端接入说明(手机/Windows 手动配置)
func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) {
cfg, err := store.TunnelConfig(t)
if err != nil {
return "", err
}
secrets, _ := store.TunnelSecrets(t)
return fmt.Sprintf(`======== L2TP/IPSec 客户端接入信息 ========
服务器地址 : %s
VPN 类型 : L2TP/IPSec PSK(预共享密钥)
IPSec PSK : %s
用户名 : %s
密码 : %s
客户端配置指引:
- iOS/macOS : 设置 → VPN → 添加 L2TP,填入服务器/账号/密码/密钥
- Windows : 设置 → VPN → L2TP/IPsec 预共享密钥,需注册表开启 NAT-T
- Android : 部分新机型已移除 L2TP,建议改用 WireGuard
- 路由器 : 选择 L2TP 拨号,填入上述参数
`,
service.StrOr(cfg, "public_endpoint", "<服务器公网 IP>"),
secrets["psk"],
service.StrOr(cfg, "username", "vpnuser"),
secrets["password"],
), nil
}
func (s *Service) Start(t *model.Tunnel) error {
dir := service.InstanceDir(t)
swanctl, hasSwan := service.LookBin("swanctl")
xl2tpd, hasL2tp := service.LookBin("xl2tpd")
if !hasSwan || !hasL2tp {
return fmt.Errorf("无法启动 L2TP/IPSec:需要 strongSwan(swanctl)和 xl2tpd")
}
if err := service.RequireRoot("L2TP/IPSec"); err != nil {
return err
}
if err := service.RequireIPv4Forwarding("L2TP/IPSec"); err != nil {
return err
}
if _, err := os.Stat(dir + "/swanctl-l2tp.conf"); err != nil {
return fmt.Errorf("无法启动 L2TP/IPSec:配置文件不存在,请先重新生成配置: %w", err)
}
if err := s.ensureExclusiveListener(t); err != nil {
return err
}
if err := service.RequireUDPPortAvailable(1701, "L2TP/IPSec"); err != nil {
return err
}
cfg, err := store.TunnelConfig(t)
if err != nil {
return err
}
if err := s.installFirewall(t, cfg); err != nil {
return err
}
_ = s.unload(swanctl, dir+"/swanctl-l2tp.conf")
if _, err := service.Run(swanctl, "--load-all", "--file", dir+"/swanctl-l2tp.conf"); err != nil {
_ = s.removeFirewall(t, cfg)
return err
}
if err := service.StartDaemon(t, xl2tpd, "-c", dir+"/xl2tpd.conf", "-p", dir+"/xl2tpd.pid", "-D"); err != nil {
_ = s.unload(swanctl, dir+"/swanctl-l2tp.conf")
_ = 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("l2tp-%d", t.ID))
if err := s.unload(bin, service.InstanceDir(t)+"/swanctl-l2tp.conf"); err != nil {
firstErr = err
}
}
if err := service.KillPid(t); err != nil && firstErr == 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 _, alive := service.ReadPid(t); alive {
detail := "L2TP 服务正在 UDP 1701 监听,等待客户端连接"
if _, ok := service.LookBin("xl2tpd"); !ok {
detail = "托管进程仍在运行,但 xl2tpd 当前不可用"
}
if swanctl, ok := service.LookBin("swanctl"); ok {
if out, err := service.Run(swanctl, "--list-sas"); err == nil && strings.Contains(out, fmt.Sprintf("l2tp-child-%d", t.ID)) {
detail = "L2TP/IPSec 服务运行中,已有已建立的 IPSec SA"
}
}
return service.NowStatus(true, "running", detail), nil
}
if ok, _ := s.Available(); !ok {
return service.NowStatus(false, "undeployed", "配置已生成,但缺少 strongSwan 或 xl2tpd,尚未部署"), nil
}
return service.NowStatus(false, "stopped", "未启动"), nil
}
func (s *Service) Delete(t *model.Tunnel) error { return s.Stop(t) }
// xl2tpd owns a host-wide UDP socket. Independent configs cannot safely share
// it; without this guard a later instance would look started but accept no VPN
// sessions. Network namespaces can deliberately lift this restriction later.
func (s *Service) ensureExclusiveListener(t *model.Tunnel) error {
var count int64
if err := store.DB.Model(&model.Tunnel{}).Where("protocol = ? AND state = ? AND id <> ?", "l2tp", "running", t.ID).Count(&count).Error; err != nil {
return fmt.Errorf("检查 L2TP 监听冲突失败: %w", err)
}
if count > 0 {
return fmt.Errorf("无法启动 L2TP/IPSec:本机已有 L2TP 实例运行;xl2tpd 共享 UDP 1701,每台主机仅支持一个实例")
}
return nil
}
func (s *Service) unload(bin, confPath string) error {
// swanctl reports an error when an object was never loaded. Treat that as
// success so Stop/Delete remain idempotent after a partial failed startup.
_, _ = service.Run(bin, "--unload-conns", "--file", confPath)
_, _ = service.Run(bin, "--unload-creds", "--file", confPath)
return nil
}
type firewallRule struct {
table, chain string
args []string
}
func (s *Service) firewallRules(t *model.Tunnel, cfg map[string]interface{}) ([]firewallRule, error) {
outbound := service.Str(cfg, "outbound_interface")
if outbound == "" {
var err error
outbound, err = service.DefaultRouteInterface()
if err != nil {
return nil, err
}
}
comment := fmt.Sprintf("opsx-vpn-l2tp-%d", t.ID)
return []firewallRule{
{"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", "udp", "--dport", "1701", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
{"filter", "INPUT", []string{"-p", "esp", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
{"filter", "FORWARD", []string{"-i", "ppp+", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
{"filter", "FORWARD", []string{"-o", "ppp+", "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
{"nat", "POSTROUTING", []string{"-s", service.Str(cfg, "client_subnet"), "-o", outbound, "-m", "comment", "--comment", comment, "-j", "MASQUERADE"}},
}, nil
}
func (s *Service) installFirewall(t *model.Tunnel, cfg map[string]interface{}) error {
iptables, ok := service.LookBin("iptables")
if !ok {
return fmt.Errorf("无法启动 L2TP/IPSec:未检测到 iptables,无法配置防火墙和 NAT")
}
rules, err := s.firewallRules(t, cfg)
if err != nil {
return err
}
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("无法添加 L2TP/IPSec 防火墙规则: %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
}
rules, err := s.firewallRules(t, cfg)
if err != nil {
return nil
}
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 fmt.Errorf("无法移除 L2TP/IPSec 防火墙规则: %w", err)
}
}
}
return nil
}