From a15786ea846272980d0ba8e088f0e9332826afbd Mon Sep 17 00:00:00 2001 From: opsx Date: Tue, 20 Oct 2026 21:32:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=BB=E6=9C=BA=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=E4=B8=80=E9=94=AE=E5=88=9D=E5=A7=8B=E5=8C=96=EF=BC=88=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E5=86=85=E8=A3=85=E4=BE=9D=E8=B5=96=20+=20=E4=BF=AE?= =?UTF-8?q?=E8=BD=AC=E5=8F=91/GRE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 --- .gitignore | 1 + backend/internal/api/api.go | 3 + backend/internal/api/system.go | 384 +++++++++++++++++++++++++++++++ frontend/src/locales/en-US.json | 8 +- frontend/src/locales/zh-CN.json | 8 +- frontend/src/views/Dashboard.vue | 52 ++++- 6 files changed, 450 insertions(+), 6 deletions(-) create mode 100644 backend/internal/api/system.go diff --git a/.gitignore b/.gitignore index 9b36e00..e2ba517 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ # 构建产物 opsx-vpn opsx-vpn.previous +opsx-vpn.bak-* backend/dist/ frontend/dist/ diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index 4078745..6dd2897 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -280,6 +280,9 @@ func (s *Server) RegisterRoutes(r *gin.Engine) { // 平台管理 admin admin := auth.Group("") admin.Use(requireRole("admin")) + // 主机环境体检与一键初始化(仅 admin) + admin.GET("/system/env", s.systemEnv) + admin.POST("/system/fix", s.systemFix) admin.GET("/users", s.listUsers) admin.POST("/users", s.createUser) admin.PUT("/users/:id", s.updateUser) diff --git a/backend/internal/api/system.go b/backend/internal/api/system.go new file mode 100644 index 0000000..b606dde --- /dev/null +++ b/backend/internal/api/system.go @@ -0,0 +1,384 @@ +// 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 +} diff --git a/frontend/src/locales/en-US.json b/frontend/src/locales/en-US.json index aa5772c..bdf3006 100644 --- a/frontend/src/locales/en-US.json +++ b/frontend/src/locales/en-US.json @@ -108,7 +108,13 @@ "otherDistros": "Other Distros", "copyRecommended": "Copy Recommended", "getInstallCmdFailed": "Failed to get install command", - "aptDefault": "apt default" + "aptDefault": "apt default", + "oneClickInstall": "Install", + "oneClickFix": "Auto Fix", + "fixSuccess": "Fixed: {items}", + "fixFailed": "Fix failed: {items}", + "fixNeedRoot": "Root privileges required to modify system config", + "fixNothing": "Nothing to auto-fix" }, "tunnels": { "searchPlaceholder": "Search by name", diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json index eef5a9e..ce600ae 100644 --- a/frontend/src/locales/zh-CN.json +++ b/frontend/src/locales/zh-CN.json @@ -108,7 +108,13 @@ "otherDistros": "其他发行版", "copyRecommended": "复制推荐命令", "getInstallCmdFailed": "获取安装命令失败", - "aptDefault": "apt 默认" + "aptDefault": "apt 默认", + "oneClickInstall": "一键安装", + "oneClickFix": "一键修复", + "fixSuccess": "已修复:{items}", + "fixFailed": "修复失败:{items}", + "fixNeedRoot": "需要以 root 运行才能修改系统配置", + "fixNothing": "当前没有可自动修复的项" }, "tunnels": { "searchPlaceholder": "搜索名称", diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index 9ddc471..c6a98bf 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -29,7 +29,15 @@ + :title="$t('dashboard.hostReadinessWarning')" :description="readinessHint"> + + @@ -63,9 +71,9 @@ @@ -129,10 +137,44 @@ let chart = null const deployableProtocols = ['frp', 'openvpn'] const protocolToPkg = { wireguard: 'wireguard', ipsec: 'strongswan', ikev2: 'strongswan', l2tp: 'strongswan', openvpn: 'openvpn', frp: 'frp' } const deploying = ref('') +const fixing = ref(false) const installDialogVisible = ref(false) const installDialogTitle = ref('') const installData = ref({ command: '', pm: '', detected: false, all_commands: {} }) +// 一键修复主机环境(IPv4 转发 / GRE 模块) +async function fixHostEnv() { + if (!readiness.value.root) { + ElMessage.warning(t('dashboard.fixNeedRoot')) + return + } + fixing.value = true + const done = [] + const failed = [] + try { + const actions = [] + if (!readiness.value.ipv4_forwarding) actions.push(['ip_forward', t('dashboard.readinessForwarding')]) + if (readiness.value.gre_loaded === false) actions.push(['gre_module', 'GRE']) + if (!actions.length) { + ElMessage.info(t('dashboard.fixNothing')) + return + } + for (const [action, label] of actions) { + try { + await api.post('/system/fix', { action }) + done.push(label) + } catch (e) { + failed.push(`${label}: ${e.response?.data?.error || e.message}`) + } + } + if (done.length) ElMessage.success(t('dashboard.fixSuccess', { items: done.join('、') })) + if (failed.length) ElMessage.error(t('dashboard.fixFailed', { items: failed.join('; ') })) + await load() + } finally { + fixing.value = false + } +} + async function deployProtocol(name) { const pkg = protocolToPkg[name] || name deploying.value = name @@ -259,6 +301,8 @@ onUnmounted(() => { .stat-num { font-size: 26px; font-weight: 700; color: var(--text-primary); line-height: 1.2; } .dep-hint { font-size: 12px; color: var(--text-secondary); font-weight: normal; } +.readiness-actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 4px; } +.readiness-detail { font-size: 13px; color: var(--el-text-color-secondary); } .host-readiness { margin-top: var(--space-4); } .install-command { margin-top: var(--space-4); } .install-command-head, .other-command { display:flex; align-items:center; gap:8px; }