Files
opsx-vpn/backend/internal/service/common.go
T
opsx ef7eb378d2 feat: 新增 GRE 隧道协议,管理端口调整为 18080
- backend: 新增 GRE 隧道实现(ip tunnel 点对点互联),binmgr 支持 iproute2
- backend: api/identity 调整,各协议 service(gre/ikev2/ipsec/l2tp/openvpn)统一改动
- backend: OpenVPN 认证辅助与用户口令同步逻辑完善
- frontend: 协议图标/向导/编辑器支持 GRE,VPN 用户页重构(234 行)
- frontend: 新增 RouteListInput 路由列表输入组件,Dashboard/Layout 优化
- i18n: 中英文文案同步更新
- test: 新增 binmgr/common/gre 单测,openvpn 测试补充
- docs: README 补充 GRE 说明,默认监听端口 8080 → 18080
- chore: .gitignore 增加 opsx-vpn.previous(构建备份二进制)
2026-10-20 20:58:58 +08:00

549 lines
15 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
}
// 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...)
}
}