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

443 lines
14 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 (
"crypto/rand"
"encoding/hex"
"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") == "" {
// A per-tunnel key avoids accidentally sharing a weak UI default.
buf := make([]byte, 24)
if _, err := rand.Read(buf); err != nil {
return fmt.Errorf("生成 IPSec 预共享密钥失败: %w", err)
}
secrets["psk"] = hex.EncodeToString(buf)
}
dialUsers, err := service.LoadDialUsers(t.ID)
if err != nil {
return err
}
// 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("", "", 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")
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
客户端配置指引:
- iOS/macOS : 设置 → VPN → 添加 L2TP,填入服务器/账号/密码/密钥
- Windows : 设置 → VPN → L2TP/IPsec 预共享密钥,需注册表开启 NAT-T
- Android : 部分新机型已移除 L2TP,建议改用 WireGuard
- 路由器 : 选择 L2TP 拨号,填入上述参数
账号密码请在「VPN 拨号用户」中创建或导入后使用。
`,
service.StrOr(cfg, "public_endpoint", "<服务器公网 IP>"),
secrets["psk"],
), 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.EnsureStrongSwanVICI(swanctl); err != nil {
return fmt.Errorf("无法启动 L2TP/IPSec:%w", 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
}
// Source-built xl2tpd defaults its control socket to /var/run/xl2tpd,
// which is absent on minimal TencentOS images. Keep it instance-scoped.
if err := service.StartDaemon(t, xl2tpd, "-c", dir+"/xl2tpd.conf", "-p", dir+"/xl2tpd.pid", "-C", dir+"/l2tp-control", "-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
}