Files
opsx a15786ea84 feat: 主机环境一键初始化(页面内装依赖 + 修转发/GRE)
- 新增 GET /api/system/env:主机环境体检
  * 运行权限 / IPv4 转发 / GRE 内核模块 / 包管理器 / 各协议依赖 / xl2tpd
  * 返回 failed/blocking/ready 汇总,供前端渲染与门控
- 新增 POST /api/system/fix:白名单修复动作
  * ip_forward:sysctl 开启 + 持久化到 /etc/sysctl.d/99-opsx-vpn.conf
  * gre_module:modprobe + 持久化到 /etc/modules-load.d/opsx-vpn.conf
  * install:<协议>:复用 binmgr.InstallViaPackageManager(apt/yum/dnf/apk/pacman)
  * tool:xl2tpd:L2TP 依赖补齐
  * 仅 admin 可调用;actions 与协议名均走白名单,绝不接受调用方传入命令串;全部写审计日志
- Dashboard:协议「安装命令」按钮改为「一键安装」(调 /binaries/install,含包管理器降级)
- Dashboard:主机前提警告条新增「一键修复」按钮(按缺失项逐项修复)
- i18n:中英文补齐 oneClickInstall / oneClickFix / fixSuccess / fixFailed / fixNeedRoot / fixNothing
2026-10-20 21:32:53 +08:00

385 lines
11 KiB
Go
Raw Permalink 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 api 提供 REST API 路由与处理器
//
// system.go —— 主机环境体检与一键初始化
//
// 设计原则:
// 1. 只读检测(GET /system/env)与白名单修复(POST /system/fix)分离
// 2. 修复动作全部走预定义白名单,绝不接受调用方传入的命令串
// 3. 所有动作仅 admin 可执行,均写审计日志
package api
import (
"fmt"
"log"
"os"
"os/exec"
"runtime"
"strings"
"github.com/gin-gonic/gin"
"vpn-platform/backend/internal/binmgr"
"vpn-platform/backend/internal/store"
)
// sysctlDropIn / modulesDropIn 持久化路径(重启后仍生效)
const (
sysctlDropIn = "/etc/sysctl.d/99-opsx-vpn.conf"
modulesDropIn = "/etc/modules-load.d/opsx-vpn.conf"
)
// envItem 单项环境检查结果
type envItem struct {
Key string `json:"key"` // 唯一标识(修复时回传)
Name string `json:"name"` // 展示名
OK bool `json:"ok"` // 是否满足
Detail string `json:"detail"` // 当前状态说明
Fixable bool `json:"fixable"` // 是否支持一键修复
FixHint string `json:"fix_hint"` // 修复动作的用户可读说明
Severity string `json:"severity"` // block / warn / info
}
// readProc 读取 /proc 下的单值文件
func readProc(path string) string {
if raw, err := os.ReadFile(path); err == nil {
return strings.TrimSpace(string(raw))
}
return ""
}
// moduleLoaded 判断内核模块是否已加载(读 /proc/modules,不依赖 lsmod)
func moduleLoaded(name string) bool {
raw, err := os.ReadFile("/proc/modules")
if err != nil {
return false
}
for _, line := range strings.Split(string(raw), "\n") {
if strings.HasPrefix(line, name+" ") {
return true
}
}
return false
}
// moduleBuiltin 判断模块是否编进内核(/sys/module/x 存在但 /proc/modules 无记录)
func moduleBuiltin(name string) bool {
if _, err := os.Stat("/sys/module/" + name); err == nil {
return !moduleLoaded(name)
}
return false
}
// writeDropIn 写入 drop-in 配置(幂等:内容相同则不重写)
func writeDropIn(path, content string) error {
if cur, err := os.ReadFile(path); err == nil && strings.TrimSpace(string(cur)) == strings.TrimSpace(content) {
return nil
}
if err := os.MkdirAll("/etc/sysctl.d", 0o755); err != nil {
return err
}
if err := os.MkdirAll("/etc/modules-load.d", 0o755); err != nil {
return err
}
return os.WriteFile(path, []byte(content+"\n"), 0o644)
}
// systemEnv 主机环境体检:返回系统信息 + 逐项检查结果
//
// GET /api/system/env
func (s *Server) systemEnv(c *gin.Context) {
isRoot := os.Geteuid() == 0
items := make([]envItem, 0, 12)
// 1) root 权限 —— VPN 平台要管理 iptables/隧道,非 root 无法工作
rootDetail := "以 root 运行,可管理系统网络"
if !isRoot {
rootDetail = "当前非 root,无法管理 iptables / 隧道;请用 root 或给二进制加 CAP_NET_ADMIN"
}
items = append(items, envItem{
Key: "root", Name: "运行权限", OK: isRoot, Detail: rootDetail,
Fixable: false, Severity: "block",
})
// 2) IPv4 转发 —— 客户端拨上来要能出网,必须为 1
fwd := readProc("/proc/sys/net/ipv4/ip_forward") == "1"
fwdDetail := "net.ipv4.ip_forward = 1(已开启)"
if !fwd {
fwdDetail = "net.ipv4.ip_forward = 0,客户端拨入后无法转发流量 / NAT 不生效"
}
items = append(items, envItem{
Key: "ip_forward", Name: "IPv4 转发", OK: fwd, Detail: fwdDetail,
Fixable: true, FixHint: "开启 net.ipv4.ip_forward 并写入 /etc/sysctl.d/ 持久化",
Severity: "block",
})
// 3) GRE 内核模块(GRE 隧道依赖)
greOK := moduleLoaded("gre") || moduleBuiltin("gre")
greDetail := "gre 模块已加载"
if !greOK {
greDetail = "gre 模块未加载,创建 GRE 隧道会失败"
}
items = append(items, envItem{
Key: "gre_module", Name: "GRE 内核模块", OK: greOK, Detail: greDetail,
Fixable: true, FixHint: "modprobe gre 并写入 /etc/modules-load.d/ 持久化",
Severity: "warn",
})
// 4) 包管理器
pm := binmgr.DetectPackageManager()
pmOK := pm != ""
pmDetail := "检测到包管理器: " + pm
if !pmOK {
pmDetail = "未检测到 apt/yum/dnf/apk/pacman,无法自动安装系统包"
}
items = append(items, envItem{
Key: "package_manager", Name: "包管理器", OK: pmOK, Detail: pmDetail,
Fixable: false, Severity: "block",
})
// 5) 各协议引擎依赖(复用 binmgr,含 frp/wireguard/openvpn/strongswan)
for _, b := range binmgr.CheckAll() {
detail := "未安装"
if b.Installed {
detail = "已就绪"
if b.Path != "" {
detail += "(" + b.Path + ")"
}
if b.Version != "" {
detail += " " + b.Version
}
} else if b.InstallCmd != "" {
detail = "未安装,可通过包管理器安装"
}
// l2tp 额外依赖 xl2tpd(strongswan 装了但 xl2tpd 缺失时 L2TP 不可用)
items = append(items, envItem{
Key: "bin:" + b.Name, Name: protoDisplayName(b.Name), OK: b.Installed, Detail: detail,
Fixable: !b.Installed, FixHint: "一键安装 " + b.Name + " 依赖",
Severity: "warn",
})
}
// 6) xl2tpd(L2TP 专用,binmgr 不单独检测)
xOK := binExists("xl2tpd")
xDetail := "xl2tpd 已就绪"
if !xOK {
xDetail = "xl2tpd 未安装,L2TP 协议不可用"
}
items = append(items, envItem{
Key: "tool:xl2tpd", Name: "L2TP (xl2tpd)", OK: xOK, Detail: xDetail,
Fixable: true, FixHint: "安装 xl2tpd", Severity: "info",
})
// 汇总
failed := 0
blocking := 0
for _, it := range items {
if !it.OK {
failed++
if it.Severity == "block" {
blocking++
}
}
}
c.JSON(200, gin.H{
"os": readOSName(),
"kernel": readProc("/proc/sys/kernel/osrelease"),
"arch": runtime.GOARCH,
"root": isRoot,
"package_manager": pm,
"items": items,
"failed": failed,
"blocking": blocking,
"ready": blocking == 0,
})
}
// systemFix 一键修复(白名单动作)
//
// POST /api/system/fix
// body: {"action": "ip_forward" | "gre_module" | "install:openvpn" | ...}
func (s *Server) systemFix(c *gin.Context) {
var req struct {
Action string `json:"action"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Action == "" {
c.JSON(400, gin.H{"error": "缺少 action 参数"})
return
}
if os.Geteuid() != 0 {
c.JSON(403, gin.H{"error": "需要 root 权限才能修改系统配置"})
return
}
action := strings.TrimSpace(req.Action)
var output string
var err error
switch {
case action == "ip_forward":
output, err = fixIPv4Forwarding()
case action == "gre_module":
output, err = fixGREModule()
case strings.HasPrefix(action, "install:"):
// 协议名走白名单(与 installBinary 保持一致)
name := strings.TrimPrefix(action, "install:")
if !validInstallTarget(name) {
c.JSON(400, gin.H{"error": fmt.Sprintf("不支持的安装目标: %s", name)})
return
}
output, err = binmgr.InstallViaPackageManager(name)
case strings.HasPrefix(action, "tool:"):
// 仅支持 xl2tpd(L2TP)
name := strings.TrimPrefix(action, "tool:")
if name != "xl2tpd" {
c.JSON(400, gin.H{"error": fmt.Sprintf("不支持的工具: %s", name)})
return
}
output, err = installPackages("xl2tpd")
default:
c.JSON(400, gin.H{"error": fmt.Sprintf("不支持的操作: %s", action)})
return
}
if err != nil {
log.Printf("[system] 修复失败 action=%s err=%v", action, err)
c.JSON(500, gin.H{"error": err.Error(), "output": output})
return
}
store.Audit(c.GetString("username"), "system_fix", action, "ok")
c.JSON(200, gin.H{"ok": true, "action": action, "output": output})
}
// ---- 修复实现 ----
// fixIPv4Forwarding 开启 IPv4 转发并持久化
func fixIPv4Forwarding() (string, error) {
var sb strings.Builder
out, err := exec.Command("sysctl", "-w", "net.ipv4.ip_forward=1").CombinedOutput()
sb.WriteString(string(out))
if err != nil {
return sb.String(), fmt.Errorf("sysctl 设置失败: %w", err)
}
if err := writeDropIn(sysctlDropIn, "# OpsX VPN: VPN 网关需要转发\nnet.ipv4.ip_forward = 1"); err != nil {
sb.WriteString("\n⚠️ 持久化写入失败(重启后失效): " + err.Error())
} else {
sb.WriteString("\n✅ 已持久化到 " + sysctlDropIn)
}
// 校验
if readProc("/proc/sys/net/ipv4/ip_forward") != "1" {
return sb.String(), fmt.Errorf("设置后校验仍为 0")
}
sb.WriteString("\n✅ 当前 net.ipv4.ip_forward = 1")
return sb.String(), nil
}
// fixGREModule 加载 gre 模块并持久化
func fixGREModule() (string, error) {
var sb strings.Builder
out, err := exec.Command("modprobe", "gre").CombinedOutput()
sb.WriteString(string(out))
if err != nil {
// 模块可能已内建,检查 /sys/module
if _, statErr := os.Stat("/sys/module/gre"); statErr != nil {
return sb.String(), fmt.Errorf("modprobe gre 失败: %w", err)
}
sb.WriteString("(模块可能已内建)")
}
if err := writeDropIn(modulesDropIn, "# OpsX VPN: GRE 隧道依赖\ngre"); err != nil {
sb.WriteString("\n⚠️ 持久化写入失败(重启后失效): " + err.Error())
} else {
sb.WriteString("\n✅ 已持久化到 " + modulesDropIn)
}
if !moduleLoaded("gre") && !moduleBuiltin("gre") {
return sb.String(), fmt.Errorf("加载后仍检测不到 gre 模块")
}
sb.WriteString("\n✅ gre 模块已就绪")
return sb.String(), nil
}
// installPackages 用当前包管理器安装指定包
func installPackages(pkgs ...string) (string, error) {
pm := binmgr.DetectPackageManager()
if pm == "" {
return "", fmt.Errorf("未检测到包管理器")
}
var args []string
switch pm {
case "apt-get":
args = append([]string{"install", "-y"}, pkgs...)
case "yum", "dnf":
args = append([]string{"install", "-y"}, pkgs...)
case "apk":
args = append([]string{"add"}, pkgs...)
case "pacman":
args = append([]string{"-S", "--noconfirm"}, pkgs...)
default:
return "", fmt.Errorf("不支持的包管理器: %s", pm)
}
cmd := exec.Command(pm, args...)
cmd.Env = append(os.Environ(), "DEBIAN_FRONTEND=noninteractive")
out, err := cmd.CombinedOutput()
if err != nil {
return string(out), fmt.Errorf("%s 安装失败: %w", pm, err)
}
return string(out), nil
}
// ---- 小工具 ----
// validInstallTarget 安装目标白名单(与 installBinary 保持一致)
func validInstallTarget(name string) bool {
switch name {
case "frp", "openvpn", "wireguard", "strongswan", "ipsec", "ikev2", "l2tp":
return true
}
return false
}
// binExists 判断可执行文件是否在 PATH 中
func binExists(name string) bool {
_, err := exec.LookPath(name)
return err == nil
}
// readOSName 读取发行版名称
func readOSName() string {
raw, err := os.ReadFile("/etc/os-release")
if err != nil {
return runtime.GOOS
}
for _, line := range strings.Split(string(raw), "\n") {
if strings.HasPrefix(line, "PRETTY_NAME=") {
return strings.Trim(strings.TrimPrefix(line, "PRETTY_NAME="), `"`)
}
}
return runtime.GOOS
}
// protoDisplayName 协议展示名(与前端保持一致)
func protoDisplayName(name string) string {
switch name {
case "frp":
return "FRP 内网穿透"
case "wireguard":
return "WireGuard"
case "openvpn":
return "OpenVPN"
case "strongswan":
return "StrongSwan (IPSec/IKEv2)"
}
return name
}