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
This commit is contained in:
1 parent
ef7eb378d2
commit
a15786ea84
6 files changed
+450
-6
No files matched your search
@@ -5,6 +5,7 @@
|
|||||||
# 构建产物
|
# 构建产物
|
||||||
opsx-vpn
|
opsx-vpn
|
||||||
opsx-vpn.previous
|
opsx-vpn.previous
|
||||||
|
opsx-vpn.bak-*
|
||||||
backend/dist/
|
backend/dist/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
|
|
||||||
|
|||||||
@@ -280,6 +280,9 @@ func (s *Server) RegisterRoutes(r *gin.Engine) {
|
|||||||
// 平台管理 admin
|
// 平台管理 admin
|
||||||
admin := auth.Group("")
|
admin := auth.Group("")
|
||||||
admin.Use(requireRole("admin"))
|
admin.Use(requireRole("admin"))
|
||||||
|
// 主机环境体检与一键初始化(仅 admin)
|
||||||
|
admin.GET("/system/env", s.systemEnv)
|
||||||
|
admin.POST("/system/fix", s.systemFix)
|
||||||
admin.GET("/users", s.listUsers)
|
admin.GET("/users", s.listUsers)
|
||||||
admin.POST("/users", s.createUser)
|
admin.POST("/users", s.createUser)
|
||||||
admin.PUT("/users/:id", s.updateUser)
|
admin.PUT("/users/:id", s.updateUser)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -108,7 +108,13 @@
|
|||||||
"otherDistros": "Other Distros",
|
"otherDistros": "Other Distros",
|
||||||
"copyRecommended": "Copy Recommended",
|
"copyRecommended": "Copy Recommended",
|
||||||
"getInstallCmdFailed": "Failed to get install command",
|
"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": {
|
"tunnels": {
|
||||||
"searchPlaceholder": "Search by name",
|
"searchPlaceholder": "Search by name",
|
||||||
|
|||||||
@@ -108,7 +108,13 @@
|
|||||||
"otherDistros": "其他发行版",
|
"otherDistros": "其他发行版",
|
||||||
"copyRecommended": "复制推荐命令",
|
"copyRecommended": "复制推荐命令",
|
||||||
"getInstallCmdFailed": "获取安装命令失败",
|
"getInstallCmdFailed": "获取安装命令失败",
|
||||||
"aptDefault": "apt 默认"
|
"aptDefault": "apt 默认",
|
||||||
|
"oneClickInstall": "一键安装",
|
||||||
|
"oneClickFix": "一键修复",
|
||||||
|
"fixSuccess": "已修复:{items}",
|
||||||
|
"fixFailed": "修复失败:{items}",
|
||||||
|
"fixNeedRoot": "需要以 root 运行才能修改系统配置",
|
||||||
|
"fixNothing": "当前没有可自动修复的项"
|
||||||
},
|
},
|
||||||
"tunnels": {
|
"tunnels": {
|
||||||
"searchPlaceholder": "搜索名称",
|
"searchPlaceholder": "搜索名称",
|
||||||
|
|||||||
@@ -29,7 +29,15 @@
|
|||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
<el-alert v-if="readinessLoaded && !hostReady" class="host-readiness" type="warning" :closable="false" show-icon
|
<el-alert v-if="readinessLoaded && !hostReady" class="host-readiness" type="warning" :closable="false" show-icon
|
||||||
:title="$t('dashboard.hostReadinessWarning')" :description="readinessHint" />
|
:title="$t('dashboard.hostReadinessWarning')" :description="readinessHint">
|
||||||
|
<template #default>
|
||||||
|
<div class="readiness-actions">
|
||||||
|
<span class="readiness-detail">{{ readinessHint }}</span>
|
||||||
|
<el-button v-if="readiness.root" size="small" type="warning" plain
|
||||||
|
:loading="fixing" @click="fixHostEnv">{{ $t('dashboard.oneClickFix') }}</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-alert>
|
||||||
|
|
||||||
<el-row :gutter="16" style="margin-top:16px">
|
<el-row :gutter="16" style="margin-top:16px">
|
||||||
<el-col :xs="24" :lg="10">
|
<el-col :xs="24" :lg="10">
|
||||||
@@ -63,9 +71,9 @@
|
|||||||
<el-table-column :label="$t('common.operation')" width="120">
|
<el-table-column :label="$t('common.operation')" width="120">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<template v-if="!row.available && !row.custom">
|
<template v-if="!row.available && !row.custom">
|
||||||
<el-button v-if="deployableProtocols.includes(row.name)" size="small" type="primary"
|
<el-button size="small" type="primary"
|
||||||
:loading="deploying === row.name" @click="deployProtocol(row.name)">{{ $t('dashboard.deploy') }}</el-button>
|
:loading="deploying === row.name" @click="deployProtocol(row.name)">{{ $t('dashboard.oneClickInstall') }}</el-button>
|
||||||
<el-button v-else size="small" @click="showInstallCmd(row.name)">{{ $t('dashboard.installCmd') }}</el-button>
|
<el-button size="small" link @click="showInstallCmd(row.name)">{{ $t('dashboard.installCmd') }}</el-button>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -129,10 +137,44 @@ let chart = null
|
|||||||
const deployableProtocols = ['frp', 'openvpn']
|
const deployableProtocols = ['frp', 'openvpn']
|
||||||
const protocolToPkg = { wireguard: 'wireguard', ipsec: 'strongswan', ikev2: 'strongswan', l2tp: 'strongswan', openvpn: 'openvpn', frp: 'frp' }
|
const protocolToPkg = { wireguard: 'wireguard', ipsec: 'strongswan', ikev2: 'strongswan', l2tp: 'strongswan', openvpn: 'openvpn', frp: 'frp' }
|
||||||
const deploying = ref('')
|
const deploying = ref('')
|
||||||
|
const fixing = ref(false)
|
||||||
const installDialogVisible = ref(false)
|
const installDialogVisible = ref(false)
|
||||||
const installDialogTitle = ref('')
|
const installDialogTitle = ref('')
|
||||||
const installData = ref({ command: '', pm: '', detected: false, all_commands: {} })
|
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) {
|
async function deployProtocol(name) {
|
||||||
const pkg = protocolToPkg[name] || name
|
const pkg = protocolToPkg[name] || name
|
||||||
deploying.value = 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; }
|
.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; }
|
.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); }
|
.host-readiness { margin-top: var(--space-4); }
|
||||||
.install-command { margin-top: var(--space-4); }
|
.install-command { margin-top: var(--space-4); }
|
||||||
.install-command-head, .other-command { display:flex; align-items:center; gap:8px; }
|
.install-command-head, .other-command { display:flex; align-items:center; gap:8px; }
|
||||||
|
|||||||
Reference in new issue
Block a user