// Package service 提供协议实现共用的运行时工具函数 package service import ( "bytes" "crypto/rand" "encoding/base64" "encoding/json" "fmt" "net" "os" "os/exec" "path/filepath" "strconv" "strings" "syscall" "time" "vpn-platform/backend/internal/core" "vpn-platform/backend/internal/model" "vpn-platform/backend/internal/store" ) // BaseDir 实例配置与运行时根目录(可通过 VPN_DATA_DIR 覆盖) func BaseDir() string { if v := os.Getenv("VPN_DATA_DIR"); v != "" { return v } exe, _ := os.Executable() if exe != "" { return filepath.Join(filepath.Dir(exe), "data") } return "data" } // InstanceDir 专线实例目录 func InstanceDir(t *model.Tunnel) string { return filepath.Join(BaseDir(), "instances", fmt.Sprintf("%d-%s", t.ID, t.Protocol)) } // LookBin 检测系统依赖二进制 func LookBin(names ...string) (string, bool) { for _, n := range names { if p, err := exec.LookPath(n); err == nil { return p, true } // systemd services often omit /usr/local/{s,}bin from PATH. Locally // compiled dependencies such as xl2tpd are conventionally installed // there, so discover them without relying on the caller's environment. for _, dir := range []string{"/usr/local/sbin", "/usr/local/bin", "/usr/sbin", "/usr/bin"} { p := filepath.Join(dir, n) if info, err := os.Stat(p); err == nil && !info.IsDir() && info.Mode()&0o111 != 0 { return p, true } } } return "", false } // Run 执行命令并返回输出 func Run(name string, args ...string) (string, error) { cmd := exec.Command(name, args...) var out, errOut bytes.Buffer cmd.Stdout = &out cmd.Stderr = &errOut if err := cmd.Run(); err != nil { return out.String(), fmt.Errorf("%s %s 执行失败: %v %s", name, strings.Join(args, " "), err, errOut.String()) } return out.String(), nil } // RequireRoot rejects service operations that need kernel networking privileges. // Starting the web process as an unprivileged account must never be reported as // a running VPN service merely because configuration generation succeeded. func RequireRoot(component string) error { if os.Geteuid() != 0 { return fmt.Errorf("无法启动 %s:需要 root 权限以管理内核网络、IPSec SA 和防火墙规则", component) } return nil } // RequireIPv4Forwarding verifies the kernel prerequisite for forwarding traffic // between a VPN address pool and the LAN/Internet. func RequireIPv4Forwarding(component string) error { sysctl, ok := LookBin("sysctl") if !ok { return fmt.Errorf("无法启动 %s:未检测到 sysctl,无法确认 net.ipv4.ip_forward", component) } out, err := Run(sysctl, "-n", "net.ipv4.ip_forward") if err == nil && strings.TrimSpace(out) == "1" { return nil } // A VPN service needs host forwarding to route traffic from tun/xfrm // interfaces. Enable it at start instead of requiring a manual pre-step. if _, err := Run(sysctl, "-w", "net.ipv4.ip_forward=1"); err != nil { return fmt.Errorf("无法启动 %s:自动启用 net.ipv4.ip_forward 失败: %w", component, err) } out, err = Run(sysctl, "-n", "net.ipv4.ip_forward") if err != nil || strings.TrimSpace(out) != "1" { return fmt.Errorf("无法启动 %s:net.ipv4.ip_forward 未成功启用", component) } return nil } // EnsureStrongSwanVICI makes sure swanctl can reach charon's VICI socket. // RPM-based distributions commonly install the binary without enabling the // strongswan.service unit, which otherwise produces a misleading load error. func EnsureStrongSwanVICI(swanctl string) error { if _, err := Run(swanctl, "--list-conns"); err == nil { return nil } systemctl, ok := LookBin("systemctl") if !ok { return fmt.Errorf("strongSwan 守护进程未运行,且未检测到 systemctl 以启动 charon") } var lastErr error for _, unit := range []string{"strongswan.service", "strongswan-swanctl.service"} { if _, err := Run(systemctl, "enable", "--now", unit); err != nil { lastErr = err continue } // Give systemd a short interval to create charon's VICI socket. for range 10 { if _, err := Run(swanctl, "--list-conns"); err == nil { return nil } time.Sleep(100 * time.Millisecond) } } if lastErr != nil { return fmt.Errorf("无法启动 strongSwan 守护进程: %w", lastErr) } return fmt.Errorf("strongSwan 守护进程已启动但 VICI 接口不可用") } // RequireUDPPortAvailable checks a port before handing it to a daemon. This is // deliberately a real bind check instead of parsing process output, so it also // catches listeners started outside this platform. func RequireUDPPortAvailable(port int, component string) error { conn, err := net.ListenUDP("udp4", &net.UDPAddr{Port: port}) if err != nil { return fmt.Errorf("无法启动 %s:UDP %d 已被其他服务占用: %w", component, port, err) } return conn.Close() } // DefaultRouteInterface returns the interface used for IPv4 Internet egress. // It is used only when a VPN configuration does not explicitly set one. func DefaultRouteInterface() (string, error) { ip, ok := LookBin("ip") if !ok { return "", fmt.Errorf("未检测到 iproute2 的 ip 命令,无法确定 NAT 出口接口") } out, err := Run(ip, "-4", "route", "show", "default") if err != nil { return "", err } fields := strings.Fields(out) for i := 0; i+1 < len(fields); i++ { if fields[i] == "dev" { return fields[i+1], nil } } return "", fmt.Errorf("未找到 IPv4 默认路由,无法确定 NAT 出口接口") } // StartDaemon 以守护方式启动进程并记录 PID func StartDaemon(t *model.Tunnel, name string, args ...string) error { if err := os.MkdirAll(InstanceDir(t), 0o700); err != nil { return err } 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 } cmd := exec.Command(name, args...) cmd.Stdout = logFile cmd.Stderr = logFile cmd.Dir = InstanceDir(t) if err := cmd.Start(); err != nil { logFile.Close() return err } pid := cmd.Process.Pid _ = os.WriteFile(PidFile(t), []byte(strconv.Itoa(pid)), 0o600) go func() { _ = cmd.Wait(); logFile.Close() }() return nil } // PidFile 实例 PID 文件 func PidFile(t *model.Tunnel) string { return filepath.Join(InstanceDir(t), "instance.pid") } // ReadPid 读取实例 PID 并校验存活 func ReadPid(t *model.Tunnel) (int, bool) { raw, err := os.ReadFile(PidFile(t)) if err != nil { return 0, false } pid, err := strconv.Atoi(strings.TrimSpace(string(raw))) if err != nil || pid <= 0 { return 0, false } proc, err := os.FindProcess(pid) if err != nil { return 0, false } // signal 0 检测进程存活 if err := proc.Signal(syscall.Signal(0)); err != nil { return pid, false } return pid, true } // KillPid 优雅停止实例进程:先 SIGTERM,超时后再 SIGKILL。 func KillPid(t *model.Tunnel) error { pid, alive := ReadPid(t) if !alive { _ = os.Remove(PidFile(t)) return nil } proc, err := os.FindProcess(pid) if err != nil { _ = os.Remove(PidFile(t)) return nil } _ = proc.Signal(syscall.SIGTERM) deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { if err := proc.Signal(syscall.Signal(0)); err != nil { _ = os.Remove(PidFile(t)) return nil } time.Sleep(100 * time.Millisecond) } _ = proc.Kill() _, _ = proc.Wait() _ = os.Remove(PidFile(t)) return nil } // RequireTCPPortAvailable 启动前检查 TCP 端口是否空闲。 func RequireTCPPortAvailable(port int, component string) error { ln, err := net.Listen("tcp4", fmt.Sprintf(":%d", port)) if err != nil { return fmt.Errorf("无法启动 %s:TCP %d 已被其他服务占用: %w", component, port, err) } return ln.Close() } // WriteConfig 写入实例配置文件(0600 权限) func WriteConfig(t *model.Tunnel, name, content string) (string, error) { if err := os.MkdirAll(InstanceDir(t), 0o700); err != nil { return "", err } p := filepath.Join(InstanceDir(t), name) if err := os.WriteFile(p, []byte(content), 0o600); err != nil { return "", err } return p, nil } // RandomSecret 生成适合配置文件与 API 凭据使用的不可预测随机值。 func RandomSecret(bytesLen int) (string, error) { raw := make([]byte, bytesLen) if _, err := rand.Read(raw); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(raw), nil } // RemoveInstance 清理隧道的专属配置、日志与 PID 文件。 func RemoveInstance(t *model.Tunnel) error { return os.RemoveAll(InstanceDir(t)) } // SnapshotInstance captures one tunnel's generated files before a live // configuration update. It intentionally excludes no files: protocol-specific // keys, PID metadata and auxiliary scripts must be restored as one unit. func SnapshotInstance(t *model.Tunnel) (string, error) { source := InstanceDir(t) parent := filepath.Dir(source) if err := os.MkdirAll(parent, 0o700); err != nil { return "", err } backup, err := os.MkdirTemp(parent, ".opsx-backup-") if err != nil { return "", err } if _, err := os.Stat(source); os.IsNotExist(err) { return backup, nil } else if err != nil { _ = os.RemoveAll(backup) return "", err } if err := copyTree(source, backup); err != nil { _ = os.RemoveAll(backup) return "", err } return backup, nil } // RestoreInstanceSnapshot replaces generated files with a previous snapshot. func RestoreInstanceSnapshot(t *model.Tunnel, backup string) error { if err := os.RemoveAll(InstanceDir(t)); err != nil { return err } if err := os.MkdirAll(InstanceDir(t), 0o700); err != nil { return err } return copyTree(backup, InstanceDir(t)) } func CleanupInstanceSnapshot(backup string) { _ = os.RemoveAll(backup) } func copyTree(source, destination string) error { return filepath.Walk(source, func(path string, info os.FileInfo, err error) error { if err != nil { return err } rel, err := filepath.Rel(source, path) if err != nil { return err } target := filepath.Join(destination, rel) if info.IsDir() { return os.MkdirAll(target, info.Mode().Perm()) } if !info.Mode().IsRegular() { return nil } content, err := os.ReadFile(path) if err != nil { return err } return os.WriteFile(target, content, info.Mode().Perm()) }) } // Str / StrOr / Num / Bool 从配置 map 中安全取值 func Str(cfg map[string]interface{}, key string) string { if v, ok := cfg[key]; ok { if s, ok := v.(string); ok { return s } } return "" } func StrOr(cfg map[string]interface{}, key, def string) string { if s := Str(cfg, key); s != "" { return s } return def } func Num(cfg map[string]interface{}, key string, def float64) float64 { if v, ok := cfg[key]; ok { switch n := v.(type) { case float64: return n case int: return float64(n) case string: if f, err := strconv.ParseFloat(n, 64); err == nil { return f } } } return def } func Bool(cfg map[string]interface{}, key string, def bool) bool { if v, ok := cfg[key]; ok { if b, ok := v.(bool); ok { return b } } return def } // MustValidate 公共校验:配置必须是合法 JSON 对象 func MustValidate(cfg map[string]interface{}, required ...string) error { for _, k := range required { if Str(cfg, k) == "" && Num(cfg, k, 0) == 0 { return fmt.Errorf("缺少必填参数: %s", k) } } return nil } // ConfigJSON 序列化配置 func ConfigJSON(cfg map[string]interface{}) string { raw, _ := json.Marshal(cfg) return string(raw) } // NowStatus 构造状态对象 func NowStatus(running bool, state, detail string) core.Status { return core.Status{Running: running, State: state, Detail: detail, UpdatedAt: time.Now().Unix()} } // MergeSecrets 从 cfg 中提取敏感字段,返回 (脱敏后的公开配置, 敏感配置) func MergeSecrets(cfg map[string]interface{}, secretKeys ...string) (public map[string]interface{}, secrets map[string]interface{}) { public = map[string]interface{}{} secrets = map[string]interface{}{} for k, v := range cfg { isSecret := false for _, sk := range secretKeys { if k == sk { isSecret = true break } } if isSecret { secrets[k] = v } else { public[k] = v } } return } // SaveTunnelConfig 将公开配置与加密密钥写回数据库 func SaveTunnelConfig(t *model.Tunnel, public, secrets map[string]interface{}) error { t.Config = ConfigJSON(public) if len(secrets) > 0 { raw, _ := json.Marshal(secrets) enc, err := store.EncryptSecret(string(raw)) if err != nil { return err } t.Secrets = enc } return store.DB.Save(t).Error } // CloneConfig 深拷贝配置 map(避免修改原始数据) func CloneConfig(in map[string]interface{}) map[string]interface{} { out := make(map[string]interface{}, len(in)) for k, v := range in { out[k] = v } return out } // SwanctlUnload 卸载 strongSwan 连接与凭据 func SwanctlUnload(bin, confPath string) { _, _ = Run(bin, "--unload-conns", "--file", confPath) _, _ = Run(bin, "--unload-creds", "--file", confPath) } // DialUser 拨号用户凭证 type DialUser struct { Username string Password string } // LoadDialUsers 从数据库加载指定隧道的本地拨号用户 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 } // FirewallRule 描述一条 iptables 规则 type FirewallRule struct { Table string // "filter" or "nat" Chain string // "INPUT", "POSTROUTING", etc. Args []string } // ApplyFirewallRules 检查并添加 iptables 规则(已存在则跳过) func ApplyFirewallRules(rules []FirewallRule) error { iptables, ok := LookBin("iptables") if !ok { return fmt.Errorf("未检测到 iptables") } 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 := Run(iptables, check...); err == nil { continue } add := append(append([]string{}, prefix...), "-A", rule.Chain) add = append(add, rule.Args...) if _, err := Run(iptables, add...); err != nil { RemoveFirewallRules(rules) return fmt.Errorf("无法添加防火墙规则: %w", err) } } return nil } // DefaultDNS 返回默认 DNS 服务器地址(可通过 VPN_DNS_SERVER 环境变量覆盖) func DefaultDNS() string { if v := os.Getenv("VPN_DNS_SERVER"); v != "" { return v } return "223.5.5.5" } // RemoveFirewallRules 检查并删除 iptables 规则 func RemoveFirewallRules(rules []FirewallRule) { iptables, ok := LookBin("iptables") if !ok { return } 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 := Run(iptables, check...); err != nil { continue } del := append(append([]string{}, prefix...), "-D", rule.Chain) del = append(del, rule.Args...) _, _ = Run(iptables, del...) } }