// 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 := service.CloneConfig(cfg) 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"] = service.DefaultDNS() } 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 { // Ensure strongSwan certificate directories exist (required by swanctl --load-all) dir := service.InstanceDir(t) for _, sub := range []string{"x509", "x509ca", "x509ocsp", "x509aa", "x509ac", "x509crl", "pubkey", "private", "rsa", "ecdsa", "pkcs8", "pkcs12"} { _ = os.MkdirAll(dir+"/"+sub, 0700) } 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 := service.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 { proposals = %s local { auth = psk } remote { auth = psk } children { l2tp-child-%d { mode = transport 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) } func buildChapSecrets(defaultUser, defaultPass string, users []service.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 { if store.DB == nil { return nil } 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 { service.SwanctlUnload(bin, 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 }