后端: - 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 后端任务管理
438 lines
14 KiB
Go
438 lines
14 KiB
Go
// 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 {
|
||
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 {
|
||
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)
|
||
}
|
||
|
||
|
||
|
||
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
|
||
}
|