feat: 日志中心 + GRE协议支持 + 流量监控 + 部署脚本
- 新增日志中心功能 (api/logapi.go + Logs.vue) - 新增GRE协议服务支持 (service/gre/) - 新增流量监控组件 (TrafficChart.vue) - 新增L2TP/IPSec测试用例 - 新增日志轮转服务 (logrotate.go) - 新增部署脚本 (scripts/install.sh, uninstall.sh, service文件) - 更新i18n国际化文件 - 优化后端API和前端路由
This commit is contained in:
1 parent
7de1f70905
commit
a14d9e62e6
25 files changed
+2285
-25
No files matched your search
@@ -120,7 +120,11 @@ func StartDaemon(t *model.Tunnel, name string, args ...string) error {
|
||||
if err := os.MkdirAll(InstanceDir(t), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
logFile, err := os.OpenFile(filepath.Join(InstanceDir(t), "instance.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
||||
logPath := filepath.Join(InstanceDir(t), "instance.log")
|
||||
// 日志轮转:默认 50MB x 2 文件,可通过 VPN_INSTANCE_LOG_MAX_SIZE / VPN_INSTANCE_LOG_MAX_FILES 覆盖
|
||||
logRotateCfg := DefaultRotateConfig("VPN_INSTANCE_LOG_MAX_SIZE", 50*1024*1024, 2)
|
||||
_ = RotateIfNeeded(logPath, logRotateCfg)
|
||||
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
// Package gre 实现 GRE 隧道管理(Linux ip tunnel)。
|
||||
// GRE 是简单的 IP-over-IP 封装,依赖 iproute2 的 ip 命令。
|
||||
package gre
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"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 "gre" }
|
||||
func (s *Service) DisplayName() string { return "GRE 隧道" }
|
||||
|
||||
func (s *Service) Available() (bool, string) {
|
||||
if p, ok := service.LookBin("ip"); ok {
|
||||
return true, p
|
||||
}
|
||||
return false, "未检测到 iproute2(ip),GRE 隧道需要 ip 命令"
|
||||
}
|
||||
|
||||
func ifaceName(t *model.Tunnel) string {
|
||||
return fmt.Sprintf("gre%d", t.ID)
|
||||
}
|
||||
|
||||
func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, error) {
|
||||
if err := service.MustValidate(cfg, "local_endpoint", "remote_endpoint", "local_tunnel_ip", "remote_tunnel_ip"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := service.CloneConfig(cfg)
|
||||
|
||||
// Validate IPs
|
||||
if _, err := netip.ParseAddr(service.Str(out, "local_endpoint")); err != nil {
|
||||
return nil, fmt.Errorf("local_endpoint 必须是有效的 IP 地址")
|
||||
}
|
||||
if _, err := netip.ParseAddr(service.Str(out, "remote_endpoint")); err != nil {
|
||||
return nil, fmt.Errorf("remote_endpoint 必须是有效的 IP 地址")
|
||||
}
|
||||
if _, err := netip.ParsePrefix(service.Str(out, "local_tunnel_ip")); err != nil {
|
||||
return nil, fmt.Errorf("local_tunnel_ip 必须是 CIDR 格式(如 10.10.0.1/30)")
|
||||
}
|
||||
if _, err := netip.ParsePrefix(service.Str(out, "remote_tunnel_ip")); err != nil {
|
||||
return nil, fmt.Errorf("remote_tunnel_ip 必须是 CIDR 格式(如 10.10.0.2/30)")
|
||||
}
|
||||
|
||||
// Defaults
|
||||
if service.Num(out, "ttl", 0) == 0 {
|
||||
out["ttl"] = 255
|
||||
}
|
||||
if service.Num(out, "mtu", 0) == 0 {
|
||||
out["mtu"] = 1476
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) Create(t *model.Tunnel) error {
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err = s.Validate(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Save canonicalised config
|
||||
public, sec := service.MergeSecrets(cfg)
|
||||
return service.SaveTunnelConfig(t, public, sec)
|
||||
}
|
||||
|
||||
func (s *Service) Start(t *model.Tunnel) error {
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ip, ok := service.LookBin("ip")
|
||||
if !ok {
|
||||
return fmt.Errorf("无法启动 GRE:未检测到 ip 命令")
|
||||
}
|
||||
if err := service.RequireRoot("GRE"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ifName := ifaceName(t)
|
||||
localEP := service.Str(cfg, "local_endpoint")
|
||||
remoteEP := service.Str(cfg, "remote_endpoint")
|
||||
ttl := int(service.Num(cfg, "ttl", 255))
|
||||
mtu := int(service.Num(cfg, "mtu", 1476))
|
||||
localTunnelIP := service.Str(cfg, "local_tunnel_ip")
|
||||
|
||||
// Clean up any stale interface first
|
||||
_, _ = service.Run(ip, "tunnel", "del", ifName)
|
||||
|
||||
// ip tunnel add greN mode gre local X remote Y ttl T
|
||||
if _, err := service.Run(ip, "tunnel", "add", ifName,
|
||||
"mode", "gre",
|
||||
"local", localEP,
|
||||
"remote", remoteEP,
|
||||
"ttl", fmt.Sprintf("%d", ttl),
|
||||
); err != nil {
|
||||
return fmt.Errorf("创建 GRE 隧道接口失败: %w", err)
|
||||
}
|
||||
|
||||
// ip link set greN mtu M
|
||||
if _, err := service.Run(ip, "link", "set", ifName, "mtu", fmt.Sprintf("%d", mtu)); err != nil {
|
||||
_, _ = service.Run(ip, "tunnel", "del", ifName)
|
||||
return fmt.Errorf("设置 GRE MTU 失败: %w", err)
|
||||
}
|
||||
|
||||
// ip addr add local_tunnel_ip dev greN
|
||||
if _, err := service.Run(ip, "addr", "add", localTunnelIP, "dev", ifName); err != nil {
|
||||
_, _ = service.Run(ip, "tunnel", "del", ifName)
|
||||
return fmt.Errorf("分配 GRE 隧道 IP 失败: %w", err)
|
||||
}
|
||||
|
||||
// ip link set greN up
|
||||
if _, err := service.Run(ip, "link", "set", ifName, "up"); err != nil {
|
||||
_, _ = service.Run(ip, "tunnel", "del", ifName)
|
||||
return fmt.Errorf("启用 GRE 接口失败: %w", err)
|
||||
}
|
||||
|
||||
// Optional: route remote subnet via remote tunnel IP
|
||||
if remoteSubnet := service.Str(cfg, "remote_subnet"); remoteSubnet != "" {
|
||||
remoteTunnelIP := service.Str(cfg, "remote_tunnel_ip")
|
||||
// Extract just the IP (without prefix) for the route gateway
|
||||
if addr, err := netip.ParsePrefix(remoteTunnelIP); err == nil {
|
||||
gw := addr.Addr().String()
|
||||
_, _ = service.Run(ip, "route", "add", remoteSubnet, "via", gw, "dev", ifName)
|
||||
}
|
||||
}
|
||||
|
||||
// Optional: NAT / MASQUERADE
|
||||
if service.Bool(cfg, "enable_nat", false) {
|
||||
if err := s.installFirewall(t, cfg); err != nil {
|
||||
_, _ = service.Run(ip, "tunnel", "del", ifName)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Stop(t *model.Tunnel) error {
|
||||
ip, ok := service.LookBin("ip")
|
||||
if !ok {
|
||||
return fmt.Errorf("无法停止 GRE:未检测到 ip 命令")
|
||||
}
|
||||
ifName := ifaceName(t)
|
||||
_, _ = service.Run(ip, "tunnel", "del", ifName)
|
||||
s.removeFirewall(t)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
|
||||
ip, ok := service.LookBin("ip")
|
||||
if !ok {
|
||||
return service.NowStatus(false, "undeployed", "未检测到 ip 命令,GRE 隧道未部署"), nil
|
||||
}
|
||||
ifName := ifaceName(t)
|
||||
|
||||
// Check tunnel exists
|
||||
if _, err := service.Run(ip, "tunnel", "show", ifName); err != nil {
|
||||
return service.NowStatus(false, "stopped", "GRE 隧道接口不存在"), nil
|
||||
}
|
||||
|
||||
// Check link is UP
|
||||
out, err := service.Run(ip, "link", "show", ifName)
|
||||
if err != nil {
|
||||
return service.NowStatus(false, "stopped", "无法读取 GRE 接口状态"), nil
|
||||
}
|
||||
if strings.Contains(out, "UP") {
|
||||
cfg, _ := store.TunnelConfig(t)
|
||||
detail := fmt.Sprintf("GRE 隧道 %s 运行中", ifName)
|
||||
if le := service.Str(cfg, "local_endpoint"); le != "" {
|
||||
detail += fmt.Sprintf(",本端 %s → 对端 %s", le, service.Str(cfg, "remote_endpoint"))
|
||||
}
|
||||
return service.NowStatus(true, "running", detail), nil
|
||||
}
|
||||
return service.NowStatus(false, "stopped", "GRE 隧道接口存在但未 UP"), nil
|
||||
}
|
||||
|
||||
func (s *Service) Delete(t *model.Tunnel) error {
|
||||
_ = s.Stop(t)
|
||||
return service.RemoveInstance(t)
|
||||
}
|
||||
|
||||
func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) {
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ttl := int(service.Num(cfg, "ttl", 255))
|
||||
mtu := int(service.Num(cfg, "mtu", 1476))
|
||||
localEP := service.Str(cfg, "local_endpoint")
|
||||
remoteEP := service.Str(cfg, "remote_endpoint")
|
||||
localTunnelIP := service.Str(cfg, "local_tunnel_ip")
|
||||
remoteTunnelIP := service.Str(cfg, "remote_tunnel_ip")
|
||||
|
||||
// Extract just the address portion for the route gateway
|
||||
gw := remoteTunnelIP
|
||||
if prefix, err := netip.ParsePrefix(remoteTunnelIP); err == nil {
|
||||
gw = prefix.Addr().String()
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`======== GRE 隧道对端参考配置 ========
|
||||
|
||||
本端公网地址(local_endpoint) : %s
|
||||
对端公网地址(remote_endpoint) : %s
|
||||
本端隧道 IP(local_tunnel_ip) : %s
|
||||
对端隧道 IP(remote_tunnel_ip) : %s
|
||||
TTL : %d
|
||||
MTU : %d
|
||||
|
||||
----- 对端配置指引 -----
|
||||
ip tunnel add greN mode gre local %s remote %s ttl %d
|
||||
ip addr add %s dev greN
|
||||
ip link set greN up
|
||||
|
||||
注意:
|
||||
- 双端 local_endpoint / remote_endpoint 需交叉对应
|
||||
- 隧道 IP 建议使用同一 /30 子网
|
||||
- 如需 NAT 请在各自防火墙中添加 MASQUERADE 规则
|
||||
- remote_subnet 路由示例: ip route add <子网> via %s dev greN
|
||||
`,
|
||||
localEP,
|
||||
remoteEP,
|
||||
localTunnelIP,
|
||||
remoteTunnelIP,
|
||||
ttl,
|
||||
mtu,
|
||||
remoteEP,
|
||||
localEP,
|
||||
ttl,
|
||||
remoteTunnelIP,
|
||||
gw,
|
||||
), nil
|
||||
}
|
||||
|
||||
func (s *Service) installFirewall(t *model.Tunnel, cfg map[string]interface{}) error {
|
||||
outbound := service.Str(cfg, "outbound_interface")
|
||||
if outbound == "" {
|
||||
var err error
|
||||
outbound, err = service.DefaultRouteInterface()
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法确定 NAT 出口接口,请设置 outbound_interface: %w", err)
|
||||
}
|
||||
}
|
||||
comment := fmt.Sprintf("opsx-vpn-gre-%d", t.ID)
|
||||
remoteSubnet := service.Str(cfg, "remote_subnet")
|
||||
if remoteSubnet == "" {
|
||||
// Use the tunnel IP prefix as the source for MASQUERADE
|
||||
if prefix, err := netip.ParsePrefix(service.Str(cfg, "remote_tunnel_ip")); err == nil {
|
||||
remoteSubnet = prefix.Masked().String()
|
||||
}
|
||||
}
|
||||
if remoteSubnet != "" {
|
||||
rules := []service.FirewallRule{
|
||||
{Table: "filter", Chain: "FORWARD", Args: []string{"-i", ifaceName(t), "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
|
||||
{Table: "filter", Chain: "FORWARD", Args: []string{"-o", ifaceName(t), "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
|
||||
{Table: "nat", Chain: "POSTROUTING", Args: []string{"-s", remoteSubnet, "-o", outbound, "-m", "comment", "--comment", comment, "-j", "MASQUERADE"}},
|
||||
}
|
||||
return service.ApplyFirewallRules(rules)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) removeFirewall(t *model.Tunnel) {
|
||||
comment := fmt.Sprintf("opsx-vpn-gre-%d", t.ID)
|
||||
iptables, ok := service.LookBin("iptables")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// Remove rules matching our comment in FORWARD and POSTROUTING chains
|
||||
for _, chain := range []struct{ table, name string }{
|
||||
{"filter", "FORWARD"},
|
||||
{"nat", "POSTROUTING"},
|
||||
} {
|
||||
for {
|
||||
out, err := service.Run(iptables, "-t", chain.table, "-L", chain.name, "-n", "--line-numbers")
|
||||
if err != nil || !strings.Contains(out, comment) {
|
||||
break
|
||||
}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if !strings.Contains(line, comment) {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) > 0 {
|
||||
_, _ = service.Run(iptables, "-t", chain.table, "-D", chain.name, fields[0])
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package ipsec
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"vpn-platform/backend/internal/service"
|
||||
)
|
||||
|
||||
func TestValidateDefaults(t *testing.T) {
|
||||
cfg, err := New().Validate(map[string]interface{}{
|
||||
"remote_gateway": "203.0.113.1",
|
||||
"local_subnets": "192.168.1.0/24",
|
||||
"remote_subnets": "192.168.2.0/24",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defaults := []struct {
|
||||
key string
|
||||
expected string
|
||||
}{
|
||||
{"ike_version", "ikev2"},
|
||||
{"ike_proposal", "aes256-sha256-modp2048"},
|
||||
{"esp_proposal", "aes256-sha256"},
|
||||
{"dpd_action", "restart"},
|
||||
{"local_id", "@vpn-local"},
|
||||
{"remote_id", "@vpn-remote"},
|
||||
{"pfs_group", "modp2048"},
|
||||
}
|
||||
for _, d := range defaults {
|
||||
if got := service.Str(cfg, d.key); got != d.expected {
|
||||
t.Errorf("%s: got %q, want %q", d.key, got, d.expected)
|
||||
}
|
||||
}
|
||||
if service.Num(cfg, "ike_lifetime", 0) != 86400 {
|
||||
t.Errorf("ike_lifetime: got %v, want 86400", cfg["ike_lifetime"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsInvalid(t *testing.T) {
|
||||
s := New()
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg map[string]interface{}
|
||||
}{
|
||||
{"missing remote_gateway", map[string]interface{}{
|
||||
"local_subnets": "10.0.0.0/24", "remote_subnets": "10.1.0.0/24",
|
||||
}},
|
||||
{"missing local_subnets", map[string]interface{}{
|
||||
"remote_gateway": "1.2.3.4", "remote_subnets": "10.1.0.0/24",
|
||||
}},
|
||||
{"missing remote_subnets", map[string]interface{}{
|
||||
"remote_gateway": "1.2.3.4", "local_subnets": "10.0.0.0/24",
|
||||
}},
|
||||
{"invalid gateway", map[string]interface{}{
|
||||
"remote_gateway": "not a gateway{}", "local_subnets": "10.0.0.0/24", "remote_subnets": "10.1.0.0/24",
|
||||
}},
|
||||
{"invalid local_subnets", map[string]interface{}{
|
||||
"remote_gateway": "1.2.3.4", "local_subnets": "bad-cidr", "remote_subnets": "10.1.0.0/24",
|
||||
}},
|
||||
{"invalid remote_subnets", map[string]interface{}{
|
||||
"remote_gateway": "1.2.3.4", "local_subnets": "10.0.0.0/24", "remote_subnets": "nope",
|
||||
}},
|
||||
{"overlapping subnets", map[string]interface{}{
|
||||
"remote_gateway": "1.2.3.4", "local_subnets": "10.0.0.0/24", "remote_subnets": "10.0.0.0/24",
|
||||
}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if _, err := s.Validate(tc.cfg); err == nil {
|
||||
t.Errorf("%s: expected error, got nil", tc.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVendorTemplates(t *testing.T) {
|
||||
templates := Templates()
|
||||
if len(templates) == 0 {
|
||||
t.Fatal("Templates() returned empty slice")
|
||||
}
|
||||
for _, tmpl := range templates {
|
||||
if tmpl.Key == "" || tmpl.Name == "" {
|
||||
t.Errorf("template missing key/name: %#v", tmpl)
|
||||
}
|
||||
// Validate with vendor_template should fill in template values
|
||||
cfg, err := New().Validate(map[string]interface{}{
|
||||
"remote_gateway": "1.2.3.4",
|
||||
"local_subnets": "10.0.0.0/24",
|
||||
"remote_subnets": "10.1.0.0/24",
|
||||
"vendor_template": tmpl.Key,
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("template %q: Validate failed: %v", tmpl.Key, err)
|
||||
continue
|
||||
}
|
||||
if service.Str(cfg, "ike_version") != tmpl.IKEVersion {
|
||||
t.Errorf("template %q: ike_version %q != %q", tmpl.Key, service.Str(cfg, "ike_version"), tmpl.IKEVersion)
|
||||
}
|
||||
if service.Str(cfg, "ike_proposal") != tmpl.IKEProposal {
|
||||
t.Errorf("template %q: ike_proposal %q != %q", tmpl.Key, service.Str(cfg, "ike_proposal"), tmpl.IKEProposal)
|
||||
}
|
||||
if service.Str(cfg, "esp_proposal") != tmpl.ESPProposal {
|
||||
t.Errorf("template %q: esp_proposal %q != %q", tmpl.Key, service.Str(cfg, "esp_proposal"), tmpl.ESPProposal)
|
||||
}
|
||||
if service.Str(cfg, "dpd_action") != tmpl.DPDAction {
|
||||
t.Errorf("template %q: dpd_action %q != %q", tmpl.Key, service.Str(cfg, "dpd_action"), tmpl.DPDAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwanctlConf(t *testing.T) {
|
||||
s := New()
|
||||
cfg := map[string]interface{}{
|
||||
"remote_gateway": "203.0.113.1",
|
||||
"local_subnets": "192.168.1.0/24",
|
||||
"remote_subnets": "192.168.2.0/24",
|
||||
"ike_version": "ikev2",
|
||||
"ike_proposal": "aes256-sha256-modp2048",
|
||||
"esp_proposal": "aes256-sha256",
|
||||
"dpd_action": "restart",
|
||||
"local_id": "@vpn-local",
|
||||
"remote_id": "@vpn-remote",
|
||||
}
|
||||
conf := s.swanctlConf(42, cfg, "my-secret-psk")
|
||||
expected := []string{
|
||||
"tunnel-42",
|
||||
"remote_addrs = 203.0.113.1",
|
||||
"version = 2",
|
||||
"proposals = aes256-sha256-modp2048",
|
||||
"id = @vpn-local",
|
||||
"id = @vpn-remote",
|
||||
"site-42",
|
||||
"local_ts = 192.168.1.0/24",
|
||||
"remote_ts = 192.168.2.0/24",
|
||||
"esp_proposals = aes256-sha256",
|
||||
"dpd_action = restart",
|
||||
"start_action = start",
|
||||
"ike-tunnel-42",
|
||||
"my-secret-psk",
|
||||
}
|
||||
for _, e := range expected {
|
||||
if !strings.Contains(conf, e) {
|
||||
t.Errorf("swanctlConf missing %q:\n%s", e, conf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwanctlConfVIP(t *testing.T) {
|
||||
s := New()
|
||||
cfg := map[string]interface{}{
|
||||
"remote_gateway": "1.2.3.4",
|
||||
"local_subnets": "10.0.0.0/24",
|
||||
"remote_subnets": "10.1.0.0/24",
|
||||
"local_id": "@local",
|
||||
"remote_id": "@remote",
|
||||
"local_ts_vip": "10.255.0.1",
|
||||
}
|
||||
conf := s.swanctlConf(1, cfg, "psk")
|
||||
if !strings.Contains(conf, "vips = 10.255.0.1") {
|
||||
t.Errorf("swanctlConf should include vips when local_ts_vip is set:\n%s", conf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerReference(t *testing.T) {
|
||||
s := New()
|
||||
cfg := map[string]interface{}{
|
||||
"remote_gateway": "203.0.113.1",
|
||||
"ike_version": "ikev2",
|
||||
"ike_proposal": "aes256-sha256-modp2048",
|
||||
"esp_proposal": "aes256-sha256",
|
||||
"local_id": "@vpn-local",
|
||||
"remote_id": "@vpn-remote",
|
||||
"local_subnets": "192.168.1.0/24",
|
||||
"remote_subnets": "192.168.2.0/24",
|
||||
"pfs_group": "modp2048",
|
||||
"dpd_action": "restart",
|
||||
"public_endpoint": "198.51.100.1",
|
||||
}
|
||||
ref := s.PeerReference(cfg)
|
||||
for _, expected := range []string{
|
||||
"198.51.100.1",
|
||||
"203.0.113.1",
|
||||
"ikev2",
|
||||
"aes256-sha256-modp2048",
|
||||
"@vpn-local",
|
||||
"@vpn-remote",
|
||||
"192.168.1.0/24",
|
||||
"192.168.2.0/24",
|
||||
"modp2048",
|
||||
"restart",
|
||||
} {
|
||||
if !strings.Contains(ref, expected) {
|
||||
t.Errorf("PeerReference missing %q:\n%s", expected, ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComparePeerParams(t *testing.T) {
|
||||
local := map[string]interface{}{
|
||||
"ike_version": "ikev2",
|
||||
"ike_proposal": "aes256-sha256-modp2048",
|
||||
"esp_proposal": "aes256-sha256",
|
||||
"pfs_group": "modp2048",
|
||||
"local_subnets": "192.168.1.0/24",
|
||||
"remote_subnets": "192.168.2.0/24",
|
||||
}
|
||||
// Matching peer — no diffs expected
|
||||
peer := map[string]interface{}{
|
||||
"ike_version": "ikev2",
|
||||
"ike_proposal": "aes256-sha256-modp2048",
|
||||
"esp_proposal": "aes256-sha256",
|
||||
"pfs_group": "modp2048",
|
||||
"local_subnets": "192.168.2.0/24",
|
||||
}
|
||||
diffs := ComparePeerParams(local, peer)
|
||||
if len(diffs) != 0 {
|
||||
t.Errorf("expected no diffs, got %v", diffs)
|
||||
}
|
||||
|
||||
// Mismatching peer — should detect diffs
|
||||
peerBad := map[string]interface{}{
|
||||
"ike_version": "ikev1",
|
||||
"ike_proposal": "aes128-sha1-modp1024",
|
||||
"esp_proposal": "aes128-sha1",
|
||||
"pfs_group": "modp1024",
|
||||
"local_subnets": "10.0.0.0/8",
|
||||
}
|
||||
diffs = ComparePeerParams(local, peerBad)
|
||||
if len(diffs) < 3 {
|
||||
t.Errorf("expected multiple diffs, got %d: %v", len(diffs), diffs)
|
||||
}
|
||||
// Verify cross-subnet mismatch is detected
|
||||
found := false
|
||||
for _, d := range diffs {
|
||||
if strings.Contains(d, "网段交叉不匹配") || strings.Contains(d, "cross") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected subnet cross-mismatch in diffs: %v", diffs)
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,11 @@ func parseIPRange(raw string) (netip.Addr, netip.Addr, error) {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -116,12 +121,12 @@ func (s *Service) Create(t *model.Tunnel) error {
|
||||
// 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 {
|
||||
mode = transport
|
||||
local_ts = dynamic[udp/l2tp]
|
||||
remote_ts = dynamic[udp/l2tp]
|
||||
esp_proposals = %s
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
package l2tp
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"vpn-platform/backend/internal/model"
|
||||
"vpn-platform/backend/internal/service"
|
||||
"vpn-platform/backend/internal/store"
|
||||
)
|
||||
|
||||
func TestValidateDefaults(t *testing.T) {
|
||||
cfg, err := New().Validate(map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defaults := []struct {
|
||||
key string
|
||||
expected string
|
||||
}{
|
||||
{"ip_range", "10.9.0.100-10.9.0.200"},
|
||||
{"local_ip", "10.9.0.1"},
|
||||
{"ike_proposal", "aes256-sha256-modp2048,aes128-sha1-modp1024"},
|
||||
{"esp_proposal", "aes256-sha256,aes128-sha1"},
|
||||
}
|
||||
for _, d := range defaults {
|
||||
if got := service.Str(cfg, d.key); got != d.expected {
|
||||
t.Errorf("%s: got %q, want %q", d.key, got, d.expected)
|
||||
}
|
||||
}
|
||||
if service.Str(cfg, "dns") == "" {
|
||||
t.Error("dns default should not be empty")
|
||||
}
|
||||
if service.Str(cfg, "client_subnet") == "" {
|
||||
t.Error("client_subnet default should not be empty")
|
||||
}
|
||||
// Verify client_subnet covers the ip_range
|
||||
prefix, err := netip.ParsePrefix(service.Str(cfg, "client_subnet"))
|
||||
if err != nil {
|
||||
t.Fatalf("client_subnet parse: %v", err)
|
||||
}
|
||||
start, _, _ := parseIPRange(service.Str(cfg, "ip_range"))
|
||||
if !prefix.Contains(start) {
|
||||
t.Errorf("client_subnet %s does not contain start of ip_range %s", prefix, start)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsInvalid(t *testing.T) {
|
||||
s := New()
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg map[string]interface{}
|
||||
}{
|
||||
{"invalid local_ip", map[string]interface{}{
|
||||
"local_ip": "not-an-ip",
|
||||
}},
|
||||
{"invalid ip_range format", map[string]interface{}{
|
||||
"ip_range": "10.0.0.100",
|
||||
}},
|
||||
{"ip_range start > end", map[string]interface{}{
|
||||
"ip_range": "10.9.0.200-10.9.0.100",
|
||||
}},
|
||||
{"local_ip in ip_range", map[string]interface{}{
|
||||
"local_ip": "10.9.0.150",
|
||||
"ip_range": "10.9.0.100-10.9.0.200",
|
||||
}},
|
||||
{"client_subnet too small", map[string]interface{}{
|
||||
"ip_range": "10.9.0.100-10.9.0.200",
|
||||
"client_subnet": "10.9.0.0/30",
|
||||
}},
|
||||
{"bad username whitespace", map[string]interface{}{
|
||||
"username": "bad user",
|
||||
}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if _, err := s.Validate(tc.cfg); err == nil {
|
||||
t.Errorf("%s: expected error, got nil", tc.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildChapSecrets(t *testing.T) {
|
||||
// With default user only
|
||||
secrets := buildChapSecrets("vpnuser", "s3cret", nil)
|
||||
if !strings.Contains(secrets, "vpnuser") || !strings.Contains(secrets, "s3cret") {
|
||||
t.Errorf("chap-secrets missing default user:\n%s", secrets)
|
||||
}
|
||||
if !strings.Contains(secrets, "l2tpd") {
|
||||
t.Errorf("chap-secrets missing server name:\n%s", secrets)
|
||||
}
|
||||
|
||||
// With dial users
|
||||
users := []service.DialUser{
|
||||
{Username: "alice", Password: "pw1"},
|
||||
{Username: "bob", Password: "pw2"},
|
||||
}
|
||||
secrets = buildChapSecrets("admin", "pass", users)
|
||||
for _, u := range []string{"admin", "alice", "bob"} {
|
||||
if !strings.Contains(secrets, u) {
|
||||
t.Errorf("chap-secrets missing user %q:\n%s", u, secrets)
|
||||
}
|
||||
}
|
||||
|
||||
// Duplicate user should be deduplicated
|
||||
secrets = buildChapSecrets("alice", "override", []service.DialUser{{Username: "alice", Password: "ignored"}})
|
||||
count := strings.Count(secrets, "alice")
|
||||
if count != 1 {
|
||||
t.Errorf("expected alice once, got %d times in:\n%s", count, secrets)
|
||||
}
|
||||
|
||||
// Empty default user — dial users still appear
|
||||
secrets = buildChapSecrets("", "", []service.DialUser{{Username: "x", Password: "y"}})
|
||||
if !strings.Contains(secrets, "x") {
|
||||
t.Errorf("chap-secrets should still include dial users:\n%s", secrets)
|
||||
}
|
||||
if strings.Contains(secrets, `""`) {
|
||||
t.Errorf("empty default user should not produce empty-quoted entry:\n%s", secrets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIPRange(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
wantErr bool
|
||||
start string
|
||||
end string
|
||||
}{
|
||||
{"10.0.0.1-10.0.0.100", false, "10.0.0.1", "10.0.0.100"},
|
||||
{"10.0.0.1", true, "", ""},
|
||||
{"10.0.0.1-10.0.0.2-10.0.0.3", true, "", ""},
|
||||
{"not-ip-also-not", true, "", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
start, end, err := parseIPRange(tc.input)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("parseIPRange(%q): expected error", tc.input)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("parseIPRange(%q): unexpected error: %v", tc.input, err)
|
||||
continue
|
||||
}
|
||||
if start.String() != tc.start || end.String() != tc.end {
|
||||
t.Errorf("parseIPRange(%q): got %s-%s, want %s-%s", tc.input, start, end, tc.start, tc.end)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureExclusiveListener(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
t.Setenv("VPN_DATA_DIR", dataDir)
|
||||
if err := store.Init(dataDir); err != nil {
|
||||
t.Fatalf("store.Init: %v", err)
|
||||
}
|
||||
|
||||
// Create a running L2TP tunnel in DB
|
||||
running := &model.Tunnel{
|
||||
Name: "l2tp-existing",
|
||||
Protocol: "l2tp",
|
||||
State: "running",
|
||||
}
|
||||
if err := store.DB.Create(running).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := &Service{}
|
||||
newTunnel := &model.Tunnel{ID: running.ID + 1}
|
||||
if err := s.ensureExclusiveListener(newTunnel); err == nil {
|
||||
t.Fatal("expected error when another L2TP instance is running")
|
||||
}
|
||||
|
||||
// After marking the existing one as stopped, it should pass
|
||||
store.DB.Model(running).Update("state", "stopped")
|
||||
if err := s.ensureExclusiveListener(newTunnel); err != nil {
|
||||
t.Fatalf("expected no error after existing stopped: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// Package service 提供日志轮转工具
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// RotateConfig 配置日志轮转参数
|
||||
type RotateConfig struct {
|
||||
MaxSize int64 // 单个日志文件最大字节数
|
||||
MaxFiles int // 最多保留的轮转文件数
|
||||
}
|
||||
|
||||
// DefaultRotateConfig 从环境变量读取轮转配置
|
||||
func DefaultRotateConfig(envPrefix string, defaultMaxSize int64, defaultMaxFiles int) RotateConfig {
|
||||
cfg := RotateConfig{
|
||||
MaxSize: defaultMaxSize,
|
||||
MaxFiles: defaultMaxFiles,
|
||||
}
|
||||
if v := os.Getenv(envPrefix + "_MAX_SIZE"); v != "" {
|
||||
if n, err := parseSize(v); err == nil && n > 0 {
|
||||
cfg.MaxSize = n
|
||||
}
|
||||
}
|
||||
if v := os.Getenv(envPrefix + "_MAX_FILES"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
cfg.MaxFiles = n
|
||||
}
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// parseSize 解析带单位的字节数(如 "50MB", "20M", "1048576")
|
||||
func parseSize(s string) (int64, error) {
|
||||
suffixes := map[string]int64{
|
||||
"KB": 1024,
|
||||
"MB": 1024 * 1024,
|
||||
"GB": 1024 * 1024 * 1024,
|
||||
"K": 1024,
|
||||
"M": 1024 * 1024,
|
||||
"G": 1024 * 1024 * 1024,
|
||||
}
|
||||
for suffix, mult := range suffixes {
|
||||
if len(s) > len(suffix) && s[len(s)-len(suffix):] == suffix {
|
||||
n, err := strconv.ParseInt(s[:len(s)-len(suffix)], 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return n * mult, nil
|
||||
}
|
||||
}
|
||||
return strconv.ParseInt(s, 10, 64)
|
||||
}
|
||||
|
||||
// RotateIfNeeded 检查日志文件大小并在需要时轮转
|
||||
func RotateIfNeeded(logPath string, cfg RotateConfig) error {
|
||||
info, err := os.Stat(logPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if info.Size() < cfg.MaxSize {
|
||||
return nil
|
||||
}
|
||||
return rotateFiles(logPath, cfg.MaxFiles)
|
||||
}
|
||||
|
||||
// rotateFiles 执行日志轮转:logPath -> logPath.1, logPath.N-1 -> delete
|
||||
func rotateFiles(logPath string, maxFiles int) error {
|
||||
// 删除最旧的文件
|
||||
oldest := fmt.Sprintf("%s.%d", logPath, maxFiles)
|
||||
os.Remove(oldest) // 忽略不存在的错误
|
||||
|
||||
// 逐个重命名:N-1 -> N, N-2 -> N-1, ... 1 -> 2
|
||||
for i := maxFiles - 1; i >= 1; i-- {
|
||||
src := fmt.Sprintf("%s.%d", logPath, i)
|
||||
dst := fmt.Sprintf("%s.%d", logPath, i+1)
|
||||
if _, err := os.Stat(src); err == nil {
|
||||
os.Rename(src, dst)
|
||||
}
|
||||
}
|
||||
// 当前文件 -> .1
|
||||
if _, err := os.Stat(logPath); err == nil {
|
||||
return os.Rename(logPath, logPath+".1")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RotatingWriter 是一个带轮转功能的 io.Writer
|
||||
type RotatingWriter struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
cfg RotateConfig
|
||||
f *os.File
|
||||
}
|
||||
|
||||
// NewRotatingWriter 创建一个带轮转功能的写入器
|
||||
func NewRotatingWriter(path string, cfg RotateConfig) (*RotatingWriter, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rw := &RotatingWriter{path: path, cfg: cfg}
|
||||
if err := rw.open(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rw, nil
|
||||
}
|
||||
|
||||
func (w *RotatingWriter) open() error {
|
||||
// 先尝试轮转
|
||||
RotateIfNeeded(w.path, w.cfg)
|
||||
f, err := os.OpenFile(w.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.f = f
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *RotatingWriter) Write(p []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
n, err := w.f.Write(p)
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
// 每次写入后检查大小(懒轮转)
|
||||
if info, e := w.f.Stat(); e == nil && info.Size() >= w.cfg.MaxSize {
|
||||
w.f.Close()
|
||||
w.open()
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (w *RotatingWriter) Close() error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.f != nil {
|
||||
return w.f.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in new issue
Block a user