Files
opsx-vpn/backend/internal/service/common.go
T

306 lines
8.0 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 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
}
}
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 fmt.Errorf("无法启动 %s:请先执行 sysctl -w net.ipv4.ip_forward=1", component)
}
return nil
}
// 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
}
logFile, err := os.OpenFile(filepath.Join(InstanceDir(t), "instance.log"), 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 停止实例进程
func KillPid(t *model.Tunnel) error {
pid, alive := ReadPid(t)
if !alive {
return nil
}
proc, err := os.FindProcess(pid)
if err != nil {
return nil
}
_ = proc.Kill()
_, _ = proc.Wait()
_ = os.Remove(PidFile(t))
return nil
}
// 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))
}
// 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
}