- 新增日志中心功能 (api/logapi.go + Logs.vue) - 新增GRE协议服务支持 (service/gre/) - 新增流量监控组件 (TrafficChart.vue) - 新增L2TP/IPSec测试用例 - 新增日志轮转服务 (logrotate.go) - 新增部署脚本 (scripts/install.sh, uninstall.sh, service文件) - 更新i18n国际化文件 - 优化后端API和前端路由
437 lines
12 KiB
Go
437 lines
12 KiB
Go
// 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
|
||
}
|
||
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))
|
||
}
|
||
|
||
// 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...)
|
||
}
|
||
}
|