refactor: 模块化重构 — 拆分 lib/ 基础设施层与 src/modules/ 功能模块

PVE-Tools.sh 从 ~14,700 行单文件重构为 150 行入口点,
所有函数按职责拆分到独立文件,通过 source 机制加载。

lib/ — 基础设施层(零业务逻辑)
  config.sh: 全局变量与常量
  core.sh: 日志、UI、确认、备份、GRUB、进度条
  network.sh: 网络检测、镜像选择
  runtime.sh: 运行时守卫、main() 入口

src/modules/ — 功能模块(10 个目录)
  01-optimization  02-sources  03-boot-kernel
  04-gpu-passthrough  05-vm-container  06-networking
  07-storage-disk  08-tools-about  09-security  10-third-party

新增 build.sh(合并编译)和 dev.sh(开发 source 入口),
release.yml 新增构建步骤,CI 适配模块化结构。
This commit is contained in:
Maple
2026-07-08 11:15:59 +08:00
parent 6e62bca246
commit 886e82f0cb
83 changed files with 15700 additions and 14874 deletions

View File

@@ -1,96 +1,187 @@
{
"meta": {
"project": "PVE-Tools-9",
"description": "Proxmox VE 9.x 一键运维脚本与文档站",
"version": "7.2.0",
"description": "Proxmox VE 9.x 模块化 Bash 运维工具集",
"version": "9.0.0",
"license": "GPL-3.0",
"generated_at": "2026-04-28T20:59:34+08:00",
"generated_at": "2026-07-08T10:41:03+08:00",
"generator": "claude-init 自适应架构师",
"truncated": false
"truncated": false,
"major_changes_since_last_scan": "项目完成模块化重构430KB 单文件拆分为 lib/ (4文件) + src/modules/ (10子模块, 66文件)。新增 build.sh/dev.sh 构建系统。Web/ VitePress 文档站已移除。"
},
"scan_coverage": {
"estimated_total_files": 75,
"scanned_files": 62,
"coverage_percent": 82.7,
"skipped_by_ignore": 8,
"skipped_binary_large": 2,
"skipped_other": 3,
"estimated_total_files": 140,
"scanned_files": 80,
"coverage_percent": 57.1,
"skipped_by_ignore": 20,
"skipped_binary_large": 28,
"skipped_other": 12,
"skip_reasons": {
"binary_large": [
"Modules/VGPU/libvgpu_unlock_rs_20230207_44d5bb3.so",
"Web/assets/images/Proxmox-Corporate-Brandguideline.pdf"
"images/ 下 27 个 PNG/JPG/SVG 文件(仅记录路径)",
"dist/PVE-Tools.sh构建产物已忽略"
],
"ignore_rules": [
"Web/bun.lock (类似 bun.lockb)",
"Web/.vitepress/dist/ (构建产物)",
"Web/.vitepress/cache/ (缓存)",
"node_modules/ (依赖)",
".codebuddy/ (项目忽略)",
".cache/ (缓存)",
".spec-workflow/ (项目忽略)",
".claude/ (项目忽略)"
"dist/ (构建产物)",
"node_modules/ (不存在于当前仓库)",
".git/ (版本控制)",
"test.txt"
],
"too_large_full_read": [
"PVE-Tools.sh (430KB使用分段读取覆盖率约60%)"
"too_large_or_not_prioritized": [
"src/modules/ 下 ~56 个非 init.sh 文件(仅 init.sh 已读,实现文件通过重构计划文档交叉验证)",
"Tools/ 下 13 个 .sh 脚本(仅记录文件名,内容已在上次扫描中确认来自 tteck 社区)"
]
}
},
"modules": [
{
"path": "/",
"name": "根模块 (脚本)",
"name": "根模块 (入口脚本)",
"language": "Bash",
"claude_md": "CLAUDE.md",
"entry_points": ["PVE-Tools.sh"],
"interfaces": ["主菜单 (9个一级选项多个二级/三级子菜单)"],
"dependencies": ["bash", "curl/wget", "qm/pct/pvesh (PVE CLI)"],
"data_models": ["VERSION文件", "legal_acceptance marker文件", "/var/lib/pve-tools/ 运行时数据"],
"tests": ["CI: shellcheck, bash -n, 版本一致性检查"],
"entry_points": ["PVE-Tools.sh (169行)", "build.sh", "dev.sh"],
"interfaces": [
"pve_tools_entry_source_tree() -- 本地模块加载",
"pve_tools_entry_prepare_remote_tree() -- 远程模块下载",
"main() -- 脚本主入口 (lib/runtime.sh)"
],
"dependencies": ["bash", "curl/wget", "find", "sort"],
"data_models": ["VERSION", "legalese acceptance marker"],
"tests": ["CI: shellcheck, bash -n (入口+构建产物), 版本一致性检查"],
"config_files": ["VERSION", "UPDATE"],
"scanned_sections": [
"颜色系统 (lines 23-55)",
"日志函数 (lines 103-137)",
"许可与风险体系 (lines 209-241)",
"配置文件管理 (lines 244-331)",
"GRUB 管理 (lines 333-410)",
"主菜单 (lines 5480-5499)",
"VM 高级运维 (lines 6065+)",
"宿主机网络 (lines 7873+)"
"PVE-Tools.sh 完整读取 (169行)",
"build.sh 完整读取 (50行)",
"dev.sh 完整读取 (22行)",
"AGENTS.md 完整读取"
],
"scan_gaps": [
"RDM/NVMe 直通完整逻辑 (lines 1954-2240 仅部分阅读)",
"引导配置辅助 (lines 2669+ 仅部分阅读)",
"所有子菜单实现函数体 (数量庞大,仅了解入口)",
"GPU 直通各子功能详细实现",
"存储/磁盘维护二级菜单实现"
],
"coverage": "medium"
},
{
"path": "Web/",
"name": "Web 文档站",
"language": "TypeScript/Vue/Markdown",
"claude_md": "Web/CLAUDE.md",
"entry_points": ["Web/index.md", "Web/.vitepress/config.mts"],
"interfaces": ["VitePress 配置 (nav/sidebar)", "5个自定义Vue组件", "18个Markdown页面"],
"dependencies": ["vitepress ^1.6.4", "vue ^3.5.27", "lucide-vue-next ^0.563.0", "bun"],
"data_models": ["todo-data.json (开发计划+时间线)"],
"tests": ["构建验证 (bun run build)"],
"config_files": ["package.json", "wrangler.jsonc", ".vitepress/config.mts"],
"scanned_files": [
"index.md", "guide.md", "features.md", "faq.md", "update.md",
"todo.md", "submit-plugin.md", "ula.md", "sponsor.md", "pay.md",
"config.mts", "theme/index.ts", "theme/custom.css",
"Announcement.vue", "CopyCodeBox.vue", "Giscus.vue",
"HomeFeaturesWithTimeline.vue", "TodoList.vue",
"package.json", "bun.lock", "wrangler.jsonc", "index.js",
"todo-data.json", "advanced/index.md"
],
"scan_gaps": [
"advanced/ 下 10 篇 Markdown 教程详情(仅读取了索引,未逐篇阅读内容)"
"README.md / README_EN.md / REAMDE-JP.md 未读取(项目说明文档)",
"UPDATE 文件未读取",
"proxmoxlib.js 未读取Proxmox Web UI 补丁)"
],
"coverage": "high"
},
{
"path": "lib/",
"name": "基础设施层",
"language": "Bash",
"claude_md": "lib/CLAUDE.md",
"entry_points": ["config.sh", "core.sh", "network.sh", "runtime.sh"],
"interfaces": [
"config.sh -- 全局变量(版本/镜像/URL/路径)",
"core.sh -- 颜色/日志/UI/确认/备份/GRUB28个函数",
"network.sh -- 网络检测/镜像选择/横幅21个函数",
"runtime.sh -- root守卫/调试/PVE检测/main()8个函数"
],
"dependencies": ["bash", "curl/wget", "pveversion (运行时)"],
"data_models": ["legal_acceptance marker 文件"],
"tests": ["CI: shellcheck, bash -n"],
"config_files": ["config.sh (全局变量定义)"],
"scanned_files": [
"config.sh 完整读取 (254行)",
"core.sh 完整读取 (507行)",
"network.sh 头部读取 (50行, 函数签名已确认)",
"runtime.sh 完整读取 (235行)"
],
"scan_gaps": [
"network.sh 后半部分 (~170行未读包含镜像选择UI函数体但函数签名已从重构计划获取)"
],
"coverage": "high"
},
{
"path": "src/modules/",
"name": "功能模块层",
"language": "Bash",
"claude_md": "src/CLAUDE.md",
"entry_points": ["各子模块的 init.sh"],
"interfaces": [
"10 个子模块的菜单入口函数",
"menu_optimization() / menu_sources_updates() / menu_boot_kernel() / ...",
"main() 中的 case 分发 (lib/runtime.sh)"
],
"dependencies": ["lib/config.sh", "lib/core.sh", "lib/network.sh (部分模块)"],
"data_models": ["无独立数据模型,操作 PVE 系统配置"],
"tests": ["CI: shellcheck, bash -n (通过构建产物)"],
"config_files": [],
"sub_modules": [
{
"path": "src/modules/01-optimization/",
"files": 6,
"init_read": true,
"coverage": "medium",
"description": "日常优化与通知:弹窗/温度/电源/邮件"
},
{
"path": "src/modules/02-sources/",
"files": 4,
"init_read": true,
"coverage": "medium",
"description": "软件源与系统升级:换源/更新/PVE8->9升级"
},
{
"path": "src/modules/03-boot-kernel/",
"files": 3,
"init_read": true,
"coverage": "medium",
"description": "启动与内核管理:内核切换/清理/GRUB"
},
{
"path": "src/modules/04-gpu-passthrough/",
"files": 12,
"init_read": true,
"coverage": "medium",
"description": "硬件直通与显卡Intel/AMD/NVIDIA GPU/RDM/NVMe",
"scan_gaps": ["11个非init.sh文件未逐文件读取函数归属已通过重构计划文档交叉验证"]
},
{
"path": "src/modules/05-vm-container/",
"files": 15,
"init_read": true,
"coverage": "medium",
"description": "虚拟机运维与导入:备份/恢复/克隆/Cloud-Init/快照/磁盘/迁移",
"scan_gaps": ["14个非init.sh文件未逐文件读取函数归属已通过重构计划文档交叉验证"]
},
{
"path": "src/modules/06-networking/",
"files": 10,
"init_read": true,
"coverage": "medium",
"description": "宿主机网络与防火墙:网桥/VLAN/Bond/MAC绑定/防火墙/IPv6",
"scan_gaps": ["9个非init.sh文件未逐文件读取函数归属已通过重构计划文档交叉验证"]
},
{
"path": "src/modules/07-storage-disk/",
"files": 6,
"init_read": true,
"coverage": "medium",
"description": "存储与磁盘维护:查询/挂载/LVM/Ceph/Swap"
},
{
"path": "src/modules/08-tools-about/",
"files": 3,
"init_read": true,
"coverage": "high",
"description": "诊断工具与项目信息:系统信息/救砖/更新/卸载"
},
{
"path": "src/modules/09-security/",
"files": 3,
"init_read": true,
"coverage": "high",
"description": "安全中心:风险检查/SSH加固"
},
{
"path": "src/modules/10-third-party/",
"files": 4,
"init_read": true,
"coverage": "high",
"description": "第三方工具:插件市场/CoolerControl/社区脚本"
}
],
"coverage": "medium"
},
{
"path": "Tools/",
"name": "第三方工具集",
@@ -102,9 +193,9 @@
"data_models": ["无持久化数据模型"],
"tests": ["无CI覆盖由tteck社区人工验证"],
"config_files": ["Tools/README.md"],
"scanned_files": ["README.md"],
"scanned_files": ["README.md (上次扫描)", "CLAUDE.md (本次更新)"],
"scan_gaps": [
"13个.sh脚本均未读取详细内容仅记录文件名和来源"
"13个.sh脚本均未读取详细内容仅记录文件名和来源,未发生变化"
],
"coverage": "low"
},
@@ -119,9 +210,9 @@
"data_models": ["插件元信息 (name/author/version/github)"],
"tests": ["无自动化测试"],
"config_files": [],
"scanned_files": ["install-zsh.sh (前30行)"],
"scanned_files": ["install-zsh.sh (上次扫描前30行)", "CLAUDE.md (本次更新)"],
"scan_gaps": [
"install-zsh.sh 完整实现 (仅读了前30行)",
"install-zsh.sh 完整实现 (未变化)",
"VGPU/*.so 二进制文件 (仅记录路径)"
],
"coverage": "low"
@@ -131,94 +222,137 @@
"name": "补充文档",
"language": "Markdown",
"claude_md": null,
"entry_points": ["Docs/future-guide.md", "Docs/README-EN.md"],
"interfaces": ["2个 Markdown 文档"],
"entry_points": [
"Docs/重构计划-PVE-Tools模块化拆分.md",
"Docs/future-guide.md",
"Docs/README-EN.md"
],
"interfaces": ["3个 Markdown 文档"],
"dependencies": [],
"data_models": [],
"tests": [],
"config_files": [],
"scanned_files": [],
"scanned_files": [
"重构计划-PVE-Tools模块化拆分.md (完整读取, 1362行 -- 关键架构文档)"
],
"scan_gaps": [
"future-guide.md 和 README-EN.md 均未读取"
],
"coverage": "none"
"coverage": "medium"
},
{
"path": ".github/",
"name": "CI/CD 与社区",
"language": "YAML",
"claude_md": null,
"entry_points": ["workflows/release.yml", "workflows/beta-release.yml", "workflows/pr-validation.yml"],
"interfaces": ["release流水线 (shc编译+GitHub Release)", "beta-release流水线", "PR验证 (shellcheck+版本检查+安全扫描)"],
"dependencies": ["GitHub Actions", "shc", "shellcheck"],
"claude_md": ".github/CLAUDE.md",
"entry_points": [
"workflows/release.yml",
"workflows/beta-release.yml",
"workflows/pr-validation.yml"
],
"interfaces": [
"release流水线 (build.sh + shc编译 + GitHub Release)",
"beta-release流水线",
"PR验证 (shellcheck + bash -n + 版本检查 + 安全扫描)"
],
"dependencies": ["GitHub Actions", "shc", "shellcheck", "build.sh"],
"data_models": [],
"tests": ["PR验证流水线自动执行"],
"config_files": ["FUNDING.yml", "ISSUE_TEMPLATE/*.yml"],
"config_files": ["FUNDING.yml", "ISSUE_TEMPLATE/config.yml"],
"scanned_files": [
"release.yml (前60行)", "beta-release.yml (前40行)", "pr-validation.yml (前50行)"
"release.yml (前50行)",
"pr-validation.yml (前50行)",
"FUNDING.yml",
"5个 Issue 模板文件"
],
"scan_gaps": [
"FUNDING.yml 未读取",
"4个 Issue 模板未读取"
"beta-release.yml 未读取",
"release.yml 后半部分 (release notes生成+上传逻辑)"
],
"coverage": "medium"
},
{
"path": "images/",
"name": "截图与图片资源",
"language": "二进制 (PNG/JPG/SVG)",
"claude_md": null,
"entry_points": [],
"interfaces": ["27个图片文件用于 README 和文档"],
"dependencies": [],
"data_models": [],
"tests": [],
"config_files": [],
"scanned_files": [],
"scan_gaps": ["全部为二进制图片,不读取内容"],
"coverage": "none (intentionally skipped)"
}
],
"modules_removed_since_last_scan": [
{
"path": "Web/",
"reason": "VitePress 文档站已从仓库移除 (git log: 'Web文档站移除'),相关 CLAUDE.md 成为孤立文件"
}
],
"next_steps": {
"priority": [
{
"module": "根模块",
"action": "深度扫描 PVE-Tools.sh 未覆盖区域",
"module": "src/modules/04-gpu-passthrough",
"action": "逐文件扫描非 init.sh 实现文件",
"paths": [
"RDM 裸磁盘映射完整实现 (约1954-2240行)",
"PCIe/NVMe 控制器直通 (约2240-2669行)",
"GPU 直通各子菜单 (Intel/NVIDIA/AMD)",
"存储与磁盘维护二级菜单",
"软件源与系统升级二级菜单",
"诊断工具与项目信息菜单"
"src/modules/04-gpu-passthrough/nvidia.sh",
"src/modules/04-gpu-passthrough/iommu.sh",
"src/modules/04-gpu-passthrough/controller.sh"
],
"reason": "主脚本是项目的核心但目前仅覆盖了约60%的关键区域"
"reason": "GPU 直通是高风险功能域,且文件最多(12个)、内部依赖最复杂"
},
{
"module": "Tools",
"action": "抽样阅读代表性脚本",
"module": "src/modules/05-vm-container",
"action": "逐文件扫描非 init.sh 实现文件",
"paths": [
"Tools/post-pve-install.sh",
"Tools/kernel-clean.sh",
"Tools/netdata.sh"
"src/modules/05-vm-container/backup.sh",
"src/modules/05-vm-container/garbage-cleanup.sh",
"src/modules/05-vm-container/disk.sh"
],
"reason": "了解第三方脚本的代码风格和风险等级"
"reason": "VM 运维为第二大模块(15文件),涉及数据安全"
},
{
"module": "Web",
"action": "补充阅读高级教程内容",
"module": "src/modules/06-networking",
"action": "逐文件扫描非 init.sh 实现文件",
"paths": [
"Web/advanced/gpu-passthrough.md",
"Web/advanced/host-network-firewall-ipv6.md",
"Web/advanced/vm-backup-migration-cloudinit.md"
"src/modules/06-networking/firewall.sh",
"src/modules/06-networking/addressing.sh",
"src/modules/06-networking/interface.sh"
],
"reason": "这些教程直接对应主脚本的高风险功能区域"
"reason": "网络模块文件最多(10文件)、子模块交叉引用最密"
},
{
"module": "Docs",
"action": "首次读取",
"module": "lib/",
"action": "完整读取 network.sh 后半部分",
"paths": [
"Docs/future-guide.md",
"Docs/README-EN.md"
"lib/network.sh (offset: 50)"
],
"reason": "此前未覆盖,含项目发展路线和英文文档"
"reason": "镜像选择UI函数体尚未完整读取"
}
],
"optional": [
{
"module": "Modules",
"action": "完整阅读 install-zsh.sh",
"reason": "作为插件规范参考示例"
"module": "Docs",
"action": "读取 future-guide.md 和 README-EN.md",
"reason": "了解项目发展路线和英文文档内容"
},
{
"module": "Tools",
"action": "抽样读取代表性脚本",
"paths": [
"Tools/post-pve-install.sh",
"Tools/kernel-clean.sh"
],
"reason": "验证脚本是否随项目模块化有所变化"
},
{
"module": ".github",
"action": "阅读 FUNDING.yml 和 Issue 模板",
"reason": "了解社区治理流程"
"action": "读取 beta-release.yml 和 ISSUE_TEMPLATE 文件",
"reason": "完整理解 CI/CD 和社区治理流程"
}
]
}

117
.github/CLAUDE.md vendored Normal file
View File

@@ -0,0 +1,117 @@
[根目录](../CLAUDE.md) > **.github**
# .github -- CI/CD 工作流与社区治理
## 模块职责
管理项目的持续集成/持续部署流水线和社区 Issue 模板。三条工作流覆盖 PR 验证、正式发布和测试版发布。
## 入口与启动
| 项目 | 说明 |
|---|---|
| 触发方式 | GitHub Actions由 push/pull_request 事件触发 |
| 运行环境 | `ubuntu-latest` |
| 权限 | `contents: write`Release 工作流需要) |
## 工作流清单
### release.yml -- 正式发布工作流
**触发条件**: 推送版本标签 (`v*.*.*`, `*.*.*`, `v*.*.*-stable`, `*.*.*-stable`)
**步骤**:
1. `actions/checkout@v4`fetch-depth: 0 以获取完整历史)
2. 从 tag 提取版本号
3. **`bash build.sh`** -- 将 lib/ + src/modules/ 组装为 `dist/PVE-Tools.sh`
4. 安装 `shc`via neurobin/ppa
5. `shc -f dist/PVE-Tools.sh -o pve-tools` -- 编译为二进制
6. 生成 release notes基于 git 提交历史)
7. `softprops/action-gh-release@v2` 创建 GitHub Release上传 `pve-tools` + `dist/PVE-Tools.sh`
**构建产物**:
- `pve-tools` -- 编译后的二进制文件
- `dist/PVE-Tools.sh` -- 拼接后的单文件脚本
### beta-release.yml -- 测试版发布工作流
**触发条件**: 推送 beta/alpha 标签
功能与 release.yml 类似,但标记为 prerelease。
### pr-validation.yml -- PR 验证工作流
**触发条件**: PR 合并到 `main``beta` 分支
**检查项**:
| 检查 | 命令 | 说明 |
|---|---|---|
| Shellcheck | `shellcheck -f gcc PVE-Tools.sh` | 静态分析,有 error/warning 则失败 |
| 语法检查 | `bash -n PVE-Tools.sh``bash -n dev.sh``bash -n dist/PVE-Tools.sh` | 先运行 `bash build.sh` 构建再检查 |
| 构建验证 | `bash build.sh` | 验证构建不报错 |
| 版本一致性 | 比较 `lib/config.sh` 中的 `CURRENT_VERSION``VERSION` 文件 | 不一致则失败 |
| 安全扫描 | grep 检测 `eval`/`source` 使用 | 发现则告警 |
## Issue 模板
| 文件 | 用途 |
|---|---|
| `fast-bugs-report.md` | 快速 Bug 报告 |
| `feature-request.md` | 功能请求 |
| `plugin-submit.md` | 插件提交 |
| `report-bugs.md` | 详细 Bug 报告 |
| `config.yml` | Issue 模板配置 |
## 其他文件
| 文件 | 用途 |
|---|---|
| `FUNDING.yml` | GitHub Sponsors 赞助配置 |
## 关键依赖与配置
- **GitHub Actions**: 免费额度,`ubuntu-latest` runner
- **shc**: 来自 `ppa:neurobin/ppa`,用于 Bash 编译
- **softprops/action-gh-release@v2**: 第三方 GitHub Action用于创建 Release
- **shellcheck**: Ubuntu 自带或通过 apt 安装
## 测试与质量
CI/CD 本身即为项目的测试与质量保障体系:
- 每次 PR 自动执行静态分析、语法检查、版本一致性校验、安全扫描
- Release 前自动构建并验证构建产物
## 常见问题 (FAQ)
**Q: 为什么 Release 要用 shc 编译?**
shc 将 Bash 脚本编译为二进制文件,提供基础的源码保护,同时便于分发。构建产物 `dist/PVE-Tools.sh` 同时发布以保持 `bash <(curl ...)` 兼容性。
**Q: PR 验证中 build.sh 会失败怎么办?**
检查 lib/ 和 src/modules/ 中的文件是否存在、语法是否正确。`bash -n` 仅检查语法不执行代码。
**Q: 如何添加新的 Issue 模板?**
`ISSUE_TEMPLATE/` 目录添加 `.md` 文件并更新 `config.yml` 即可GitHub 会自动识别。
## 相关文件清单
```
.github/
workflows/
release.yml # 正式发布工作流
beta-release.yml # 测试版发布工作流
pr-validation.yml # PR 验证工作流
ISSUE_TEMPLATE/
fast-bugs-report.md # 快速 Bug 报告模板
feature-request.md # 功能请求模板
plugin-submit.md # 插件提交模板
report-bugs.md # 详细 Bug 报告模板
config.yml # Issue 模板配置
FUNDING.yml # 赞助配置
```
## 变更记录 (Changelog)
| 日期 | 变更 |
|---|---|
| 2026-07-08 | 初始化 .github 模块 CLAUDE.md。PR 验证已适配模块化build.sh/build -n dist。Release 已添加 build.sh 步骤。 |

View File

@@ -21,10 +21,13 @@ jobs:
- name: Shell syntax check
run: |
bash -n PVE-Tools.sh
bash -n dev.sh
bash build.sh
bash -n dist/PVE-Tools.sh
- name: Version consistency check
run: |
SCRIPT_VERSION=$(grep "CURRENT_VERSION=" PVE-Tools.sh | cut -d'"' -f2)
SCRIPT_VERSION=$(grep "CURRENT_VERSION=" lib/config.sh | cut -d'"' -f2)
VERSION_FILE_VERSION=$(cat VERSION 2>/dev/null)
if [ "$SCRIPT_VERSION" != "$VERSION_FILE_VERSION" ]; then
echo "Version inconsistency: script($SCRIPT_VERSION) != version file($VERSION_FILE_VERSION)"

View File

@@ -25,6 +25,9 @@ jobs:
VERSION=${TAG#v}
echo "TAG=$TAG" >> $GITHUB_OUTPUT
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
- name: Build single script from modules
run: bash build.sh
- name: Install shc
run: |
@@ -34,7 +37,7 @@ jobs:
- name: Compile script to binary
run: |
shc -f PVE-Tools.sh -o pve-tools
shc -f dist/PVE-Tools.sh -o pve-tools
chmod +x pve-tools
- name: Generate release notes
@@ -81,7 +84,7 @@ jobs:
```
files: |
pve-tools
PVE-Tools.sh
dist/PVE-Tools.sh
draft: false
prerelease: false
env:

3
.gitignore vendored
View File

@@ -43,3 +43,6 @@ _bmad/
_bmad-output/
test.txt
# ignore docs
Docs/

164
CLAUDE.md
View File

@@ -8,38 +8,12 @@ PVE Tools Pro 是一个面向 Proxmox VE 9.x 的交互式 Bash 运维工具集
## 架构总览
```mermaid
graph TD
A["(根) PVE Tools Pro"] --> B["PVE-Tools.sh (主脚本)"]
A --> C["Web"]
A --> D["Tools"]
A --> E["Modules"]
A --> F["Docs"]
A --> G[".github"]
项目已完成模块化重构v9.0.0),从单一 430KB 脚本拆分为基础设施层lib/与功能模块层src/modules/),通过 build.sh 组装为单文件发布,用户侧零感知。
C --> C1["VitePress 文档站"]
C --> C2["Vue 组件 (5个)"]
C --> C3["高级教程 (10篇)"]
D --> D1["PVE 后安装配置"]
D --> D2["LXC 容器管理"]
D --> D3["系统维护工具"]
D --> D4["监控工具"]
E --> E1["插件市场"]
E --> E2["VGPU .so"]
G --> G1["CI/CD Workflows"]
G --> G2["Issue Templates"]
click B "./PVE-Tools.sh" "主入口脚本"
click C "./Web/CLAUDE.md" "查看 Web 模块文档"
click D "./Tools/CLAUDE.md" "查看 Tools 模块文档"
click E "./Modules/CLAUDE.md" "查看 Modules 模块文档"
```
- **核心入口**: `PVE-Tools.sh`(约 430KB单一 Bash 脚本),通过 `bash <(curl -sSL ...)` 方式分发执行。
- **文档站**: `Web/` 目录承载 VitePress 构建的官方文档网站,部署于 Cloudflare Pages。
- **入口层**: `PVE-Tools.sh`169 行)-- 本地开发时 source lib/ + src/modules/;远程 curl 运行时自动下载 dist/ 构建产物或逐个下载源码模块。
- **基础设施层**: `lib/` -- 全局变量(config.sh)、日志/UI/备份/GRUB(core.sh)、网络检测/镜像选择(network.sh)、运行时守卫(runtime.sh)。
- **功能模块层**: `src/modules/` -- 10 个子目录对应主菜单 1-10 项每个子目录内按功能拆分文件init.sh 为菜单入口)。
- **构建系统**: `build.sh` 按顺序拼接 lib/*.sh + src/modules/**/*.sh 为 `dist/PVE-Tools.sh``dev.sh` 直接 source 全部源码供开发调试。
- **辅助工具集**: `Tools/` 集成来自 tteck 社区的 13 个系统维护脚本。
- **插件市场**: `Modules/` 提供第三方脚本的自动发现与执行框架。
- **CI/CD**: `.github/workflows/` 提供 release、beta-release、pr-validation 三条流水线。
@@ -48,63 +22,76 @@ graph TD
```mermaid
graph TD
ROOT["PVE Tools Pro (根)"] --> MAIN["PVE-Tools.sh<br/>脚本 430KB"]
ROOT --> WEB["Web/"]
ROOT["PVE Tools Pro (根)"] --> MAIN["PVE-Tools.sh<br/>入口脚本 169行"]
ROOT --> LIB["lib/<br/>基础设施层"]
ROOT --> SRC["src/modules/<br/>功能模块层"]
ROOT --> TOOLS["Tools/"]
ROOT --> MODS["Modules/"]
ROOT --> DOCS["Docs/"]
ROOT --> GHA[".github/"]
ROOT --> BUILD["build.sh + dev.sh<br/>构建与开发入口"]
WEB --> VITEPRESS[".vitepress/<br/>主题/配置/组件"]
WEB --> PAGES["*.md 文档页面"]
WEB --> ADVANCED["advanced/<br/>高级教程 10篇"]
WEB --> ASSETS["assets/ + public/<br/>图片/Logo/SVG"]
LIB --> LIBC["config.sh<br/>全局变量/镜像/URL"]
LIB --> LIBCORE["core.sh<br/>颜色/日志/UI/备份/GRUB"]
LIB --> LIBNET["network.sh<br/>网络检测/镜像选择"]
LIB --> LIBRUN["runtime.sh<br/>root守卫/调试/PVE检测"]
TOOLS --> SYS["系统配置: 5个脚本"]
TOOLS --> LXC["容器管理: 3个脚本"]
TOOLS --> MAINT["系统维护: 4个脚本"]
TOOLS --> MONITOR["监控: 2个脚本"]
SRC --> M01["01-optimization<br/>日常优化与通知"]
SRC --> M02["02-sources<br/>软件源与系统升级"]
SRC --> M03["03-boot-kernel<br/>启动与内核管理"]
SRC --> M04["04-gpu-passthrough<br/>硬件直通与显卡"]
SRC --> M05["05-vm-container<br/>虚拟机运维与导入"]
SRC --> M06["06-networking<br/>宿主机网络与防火墙"]
SRC --> M07["07-storage-disk<br/>存储与磁盘维护"]
SRC --> M08["08-tools-about<br/>诊断工具与项目信息"]
SRC --> M09["09-security<br/>安全中心"]
SRC --> M10["10-third-party<br/>第三方工具"]
MODS --> PLUGINS["插件脚本 (.sh)"]
MODS --> VGPU["VGPU/libvgpu_*.so"]
TOOLS --> T1["系统配置: 5个脚本"]
TOOLS --> T2["容器管理: 3个脚本"]
TOOLS --> T3["系统维护: 4个脚本"]
TOOLS --> T4["监控: 1个脚本"]
GHA --> WF["workflows/<br/>release/beta/PR"]
MODS --> P1["install-zsh.sh"]
MODS --> P2["VGPU/*.so"]
GHA --> W1["workflows/<br/>release/beta/PR"]
click ROOT "./CLAUDE.md" "返回根文档"
click WEB "./Web/CLAUDE.md" "查看 Web 模块文档"
click LIB "./lib/CLAUDE.md" "查看 lib 模块文档"
click SRC "./src/CLAUDE.md" "查看 src 模块文档"
click TOOLS "./Tools/CLAUDE.md" "查看 Tools 模块文档"
click MODS "./Modules/CLAUDE.md" "查看 Modules 模块文档"
click GHA "./.github/CLAUDE.md" "查看 CI/CD 模块文档"
```
## 模块索引
| 模块路径 | 语言 | 职责 | 入口文件 | 文档 |
|---|---|---|---|---|
| `/` (根) | Bash | 脚本,全部功能入口 | `PVE-Tools.sh` | `README.md` |
| `Web/` | TypeScript/Vue/Markdown | VitePress 文档站 | `Web/index.md`, `Web/.vitepress/config.mts` | [Web/CLAUDE.md](./Web/CLAUDE.md) |
| `Tools/` | Bash | 第三方系统维护脚本集 | 各 `.sh` 文件 | [Tools/CLAUDE.md](./Tools/CLAUDE.md) |
| `/` (根) | Bash | 入口脚本,本地/远程模块加载 | `PVE-Tools.sh` (169行) | `README.md` |
| `lib/` | Bash | 基础设施层全局变量、日志、UI、网络、运行时 | `config.sh`, `core.sh`, `network.sh`, `runtime.sh` | [lib/CLAUDE.md](./lib/CLAUDE.md) |
| `src/modules/` | Bash | 功能模块层10 个子模块,对应主菜单 1-10 | 各 `*/init.sh` | [src/CLAUDE.md](./src/CLAUDE.md) |
| `Tools/` | Bash | 第三方系统维护脚本集13个 | 各 `.sh` 文件 | [Tools/CLAUDE.md](./Tools/CLAUDE.md) |
| `Modules/` | Bash/二进制 | 插件市场与模块 | `install-zsh.sh`, `VGPU/*.so` | [Modules/CLAUDE.md](./Modules/CLAUDE.md) |
| `Docs/` | Markdown | 补充文档 | `future-guide.md`, `README-EN.md` | -- |
| `.github/` | YAML | CI/CD 工作流与 Issue 模板 | `workflows/*.yml` | -- |
| `Docs/` | Markdown | 补充文档与重构计划 | `future-guide.md`, `重构计划-PVE-Tools模块化拆分.md` | -- |
| `.github/` | YAML | CI/CD 工作流与 Issue 模板 | `workflows/*.yml` | [.github/CLAUDE.md](./.github/CLAUDE.md) |
## 技术栈
| 层面 | 技术 | 版本/说明 |
|---|---|---|
| 运行环境 | Proxmox VE 9.x (Debian 13 Trixie) | 要求 root 权限 |
| 主脚本语言 | GNU Bash | 单一文件,通过 curl 管道分发执行 |
| 文档站构建 | VitePress | v1.6.4,部署于 Cloudflare Pages |
| 前端框架 | Vue 3 | v3.5.27,仅用于文档站主题组件 |
| 图标库 | lucide-vue-next | v0.563.0 |
| 运行时 | Bun | Web 目录依赖管理bun.lock |
| 主脚本语言 | GNU Bash | 模块化源码;通过 build.sh 组装为单文件分发 |
| 构建系统 | bash + find + sort | `build.sh` 顺序拼接 lib/ -> src/modules/ -> dist/PVE-Tools.sh |
| 开发模式 | bash dev.sh | 直接 source 全部源文件,无需构建 |
| CI/CD | GitHub Actions | release / beta-release / PR validation |
| 编译工具 | shc | 将 Bash 编译为二进制(仅 release 流程) |
| 编译工具 | shc | 将 dist/PVE-Tools.sh 编译为二进制(仅 release 流程) |
| 许可证 | GPL-3.0 | 详见 `LICENSE` |
| 分析 | Umami | 文档站匿名访问统计 |
## 运行与开发
### 使用脚本(用户侧)
### 用户使用
```bash
# Cloudflare 短域名(推荐)
@@ -113,45 +100,62 @@ bash <(curl -sSL https://pve.oowo.cc/PVE-Tools.sh)
# 中国大陆网络
bash <(curl -sSL https://ghfast.top/raw.githubusercontent.com/PVE-Tools/PVE-Tools-9/main/PVE-Tools.sh)
# 国际网络 / 本地
wget https://raw.githubusercontent.com/PVE-Tools/PVE-Tools-9/main/PVE-Tools.sh
chmod +x PVE-Tools.sh
sudo ./PVE-Tools.sh
# 本地开发
bash dev.sh
```
### 开发文档站Web 模块)
### 开发工作流
```bash
cd Web
bun install # 或 npm install
bun run dev # 启动本地开发服务器
bun run build # 构建到 .vitepress/dist/
bun run preview # 预览构建结果
# 改代码 -> 直接运行验证
bash dev.sh
# 确认改好了 -> 构建单文件
bash build.sh
# 验证构建产物
bash dist/PVE-Tools.sh
# 静态检查
bash -n PVE-Tools.sh
bash -n dist/PVE-Tools.sh
shellcheck -f gcc PVE-Tools.sh
shellcheck -f gcc dist/PVE-Tools.sh
```
### 构建原理
`build.sh` 按以下固定顺序拼接:
1. `lib/config.sh` -- 全局变量定义(必须最先加载)
2. `lib/core.sh` -- 日志/UI/备份/GRUB依赖 config.sh 中的变量)
3. `lib/network.sh` -- 网络检测/镜像选择
4. `lib/runtime.sh` -- 运行时守卫/main()函数
5. `src/modules/**/*.sh` -- 按路径名排序(`sort -z`),确保 init.sh 先于同目录其他文件加载
### CI/CD 流水线
- **PR 合并到 main/beta**: 触发 shellcheck、Bash 语法检查、版本一致性校验、安全扫描。
- **推送版本标签 (v*.*.*)**: 触发 Release 工作流,用 shc 编译二进制,自动生成 GitHub Release。
- **推送版本标签 (v*.*.*)**: 触发 Release 工作流,先执行 `bash build.sh` 构建,再用 shc 编译二进制,自动生成 GitHub Release。
- **推送 beta/alpha 标签**: 触发 Beta Release 工作流。
## 测试策略
| 类型 | 方式 | 说明 |
|---|---|---|
| 语法检查 | `bash -n PVE-Tools.sh` | CI 中强制通过 |
| 静态分析 | `shellcheck -f gcc PVE-Tools.sh` | CI 中强制通过 |
| 语法检查 | `bash -n PVE-Tools.sh``bash -n dist/PVE-Tools.sh` | CI 中强制通过 |
| 静态分析 | `shellcheck -f gcc PVE-Tools.sh` | CI 中强制通过,需额外关注多文件 source 模式 |
| 版本一致性 | 比较脚本内 `CURRENT_VERSION``VERSION` 文件 | CI 中强制通过 |
| 安全扫描 | 检测 `eval`/`source` 使用 | CI 中告警 |
| 功能测试 | 手动在 PVE 9.x 环境验证 | 无自动化 E2E 测试 |
**注意**: 本项目目前没有自动化单元测试或集成测试。所有功能验证依赖人工在真实或模拟的 PVE 9.x 环境中测试。
**注意**: 本项目目前没有自动化单元测试或集成测试。所有功能验证依赖人工在真实或模拟的 PVE 9.x 环境中测试。模块化后,建议在每次 PR 时同时验证 `bash dev.sh``bash build.sh && bash dist/PVE-Tools.sh` 的行为一致性。
## 编码规范
### Bash 脚本规范
- Shebang: `#!/bin/bash`
- 版权声明: 每个文件头部包含 `# SPDX-License-Identifier: GPL-3.0-only``# Copyright (C) 2026 Ciriu Networks`
- 缩进: 4 空格
- 函数命名: `snake_case`(如 `vm_validate_new_vmid``host_network_get_bridges`
- 变量命名: `UPPER_SNAKE`(全局配置常量)、`lower_case`(局部变量)
@@ -162,23 +166,21 @@ bun run preview # 预览构建结果
- 配置备份: 修改系统配置文件前调用 `backup_file()` 自动备份到 `/var/backups/pve-tools/`
- 幂等性: GRUB 参数等配置通过专用幂等管理函数修改,支持增删查
- 日志文件: 所有操作记录到 `/var/log/pve-tools.log`
### 文档站 (Vue/TypeScript) 规范
- 使用 VitePress 默认主题扩展
- 自定义组件放置在 `Web/.vitepress/theme/components/`
- 组件使用 `<script setup>` + TypeScript
- 样式使用 scoped CSS
- 模块加载: `lib/` 按 config -> core -> network -> runtime 固定顺序加载;`src/modules/` 按文件名排序
## AI 使用指引
- **主脚本分析**: `PVE-Tools.sh` 约 430KB读取时建议使用 offset/limit 分段读取。关键函数区域: 颜色系统(23-55行)、日志系统(103-137行)、主菜单(5480-5499行)、VM 运维(6065+行)、宿主机网络(7873+行)。
- **模块理解**: 优先阅读各模块的 `CLAUDE.md` 而非直接扫描源代码。
- **忽略的构建产物**: `.vitepress/dist/``.vitepress/cache/``node_modules/` 均被 `.gitignore` 忽略且不参与分析
- **入口分析**: `PVE-Tools.sh` 仅 169 行,可完整阅读。核心逻辑为 `pve_tools_entry_source_tree()` `pve_tools_entry_prepare_remote_tree()`
- **基础设施分析**: `lib/core.sh` 包含所有日志/UI/备份/GRUB 函数(约 500 行);`lib/runtime.sh` 包含 `main()` 函数(约 235 行)。
- **功能模块分析**: `src/modules/` 下每个子目录的 `init.sh` 为菜单入口函数,其他文件为具体功能实现。
- **忽略的构建产物**: `dist/``node_modules/``.vitepress/dist/``.vitepress/cache/` 均被 `.gitignore` 忽略且不参与分析。
- **二进制文件**: `Modules/VGPU/*.so` 只记录路径,不读取内容。
- **已移除模块**: `Web/` 目录及其 VitePress 文档站已于本次重构中移除。
## 变更记录 (Changelog)
| 日期 | 变更 | 来源 |
|---|---|---|
| 2026-07-08 | 重大更新项目模块化重构完成。430KB 单文件拆分为 lib/ (4文件) + src/modules/ (10子模块, ~60文件)。新增 build.sh/dev.sh 构建系统。Web/ 目录已移除。更新全部 CLAUDE.md 文档体系。 | claude-init 架构师(自适应版) |
| 2026-04-28 | 初始化 CLAUDE.md 体系(根 + Web + Tools + Modules | claude-init 架构师(自适应版) |

View File

@@ -100,4 +100,5 @@ Modules/
| 日期 | 变更 |
|---|---|
| 2026-07-08 | 更新:项目模块化后 Modules 目录无变化install-zsh.sh 和 VGPU 状态不变 |
| 2026-04-28 | 初始化 Modules 模块 CLAUDE.md |

14808
PVE-Tools.sh

File diff suppressed because it is too large Load Diff

View File

@@ -111,4 +111,5 @@ Tools/
| 日期 | 变更 |
|---|---|
| 2026-07-08 | 更新:项目模块化后 Tools 目录无变化,确认 13 个脚本状态不变 |
| 2026-04-28 | 初始化 Tools 模块 CLAUDE.md |

49
build.sh Normal file
View File

@@ -0,0 +1,49 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OUTPUT="${1:-$ROOT/dist/PVE-Tools.sh}"
VERSION="$(cat "$ROOT/VERSION")"
mkdir -p "$(dirname "$OUTPUT")"
{
echo '#!/bin/bash'
echo
echo "# PVE-Tools Pro v$VERSION"
echo "# Build: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "# SPDX-License-Identifier: GPL-3.0-only"
echo "# Copyright (C) 2026 Ciriu Networks"
echo
for lib_name in config.sh core.sh network.sh runtime.sh; do
lib_file="$ROOT/lib/$lib_name"
if [[ ! -f "$lib_file" ]]; then
echo "Missing lib file: $lib_file" >&2
exit 1
fi
echo "# [lib] $lib_name"
cat "$lib_file"
echo
done
while IFS= read -r -d '' module_file; do
rel="${module_file#$ROOT/src/modules/}"
echo "# [module] $rel"
cat "$module_file"
echo
done < <(find "$ROOT/src/modules" -name '*.sh' -print0 | sort -z)
echo 'main "$@"'
echo
} > "$OUTPUT"
chmod +x "$OUTPUT"
total_lines=$(wc -l < "$OUTPUT")
total_size=$(wc -c < "$OUTPUT")
echo "Built: $OUTPUT"
echo " Lines: $total_lines | Size: $(( total_size / 1024 ))KB"

21
dev.sh Normal file
View File

@@ -0,0 +1,21 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
for lib_file in \
"$SCRIPT_DIR/lib/config.sh" \
"$SCRIPT_DIR/lib/core.sh" \
"$SCRIPT_DIR/lib/network.sh" \
"$SCRIPT_DIR/lib/runtime.sh"; do
# shellcheck source=/dev/null
source "$lib_file"
done
while IFS= read -r -d '' module_file; do
# shellcheck source=/dev/null
source "$module_file"
done < <(find "$SCRIPT_DIR/src/modules" -name '*.sh' -print0 | sort -z)
main "$@"

119
lib/CLAUDE.md Normal file
View File

@@ -0,0 +1,119 @@
[根目录](../CLAUDE.md) > **lib**
# lib -- 基础设施层
## 模块职责
提供 PVE Tools Pro 全部功能模块所需的基础设施函数与全局变量零业务逻辑。按固定顺序加载config.sh -> core.sh -> network.sh -> runtime.sh。
## 入口与启动
| 项目 | 说明 |
|---|---|
| 加载方式 | 由 `PVE-Tools.sh`(生产)或 `dev.sh`(开发)按固定顺序 source |
| 加载顺序 | `config.sh` -> `core.sh` -> `network.sh` -> `runtime.sh`(严格) |
| 对外暴露 | 全部函数和全局变量对后续加载的 `src/modules/` 可见 |
## 文件清单
### config.sh -- 全局变量与常量(约 254 行)
| 类别 | 变量 | 说明 |
|---|---|---|
| 版本信息 | `CURRENT_VERSION`, `BUILD_NICKNAME`, `VERSION_FILE_URL`, `UPDATE_FILE_URL`, `PVE_TOOLS_SCRIPT_URL` | 版本号、更新 URL |
| 镜像源注册表 | `MIRROR_NAMES[]`, `MIRROR_IDS[]`, `MIRROR_DEBIAN_URIS[]`, `MIRROR_SECURITY_URIS[]`, `MIRROR_PVE_URIS[]`, `MIRROR_CEPH_URIS[]`, `MIRROR_CT_URIS[]` | 20+ 个镜像源(官方/阿里云/腾讯云/清华/中科大等) |
| 镜像选择状态 | `MIRROR_SELECTED_DEBIAN`, `MIRROR_SELECTED_SECURITY`, `MIRROR_SELECTED_PVE`, `MIRROR_SELECTED_CEPH`, `MIRROR_SELECTED_CT` | 当前选中的镜像索引 |
| 网络检测 | `CF_TRACE_URL`, `GITHUB_MIRROR_PREFIX`, `USER_COUNTRY_CODE`, `NETWORK_MODE`, `IS_OFFLINE_MODE`, `USE_MIRROR_FOR_UPDATE` | Cloudflare 地区检测、镜像加速 |
| URL 常量 | `FASTPVE_INSTALLER_URL`, `COOLERCONTROL_*_URL`, `NVIDIA_*_URL`, `THIRD_PARTY_*_URL` | 各功能的外部资源地址 |
| 路径常量 | `VM_CONFIG_EXPORT_DIR`, `VM_BACKUP_CRON_FILE`, `HOST_NETWORK_*_FILE`, `PVE_CLUSTER_FIREWALL_FILE`, `PVE_KVM_ROM_DIR` | 运行时文件路径 |
| PVE 版本 | `PVE_VERSION_DETECTED`, `PVE_MAJOR_VERSION` | 运行时检测结果 |
| 安全 | `RISK_ACK_BYPASS`, `DEBUG_MODE`, `LEGAL_VERSION` | 风险控制标志 |
### core.sh -- 核心工具函数(约 507 行)
| 函数 | 说明 |
|---|---|
| `setup_colors()` | 颜色系统初始化,支持 `NO_COLOR` 环境变量 |
| `log_info()`, `log_warn()`, `log_error()`, `log_step()`, `log_success()`, `log_tips()` | 统一日志(带时间戳,写入 `/var/log/pve-tools.log` |
| `display_error()`, `display_success()` | 带提示的增增强错误/成功反馈 |
| `confirm_action()` | 通用确认提示(输入 'yes' |
| `confirm_high_risk_action()` | 高风险确认(需输入指定确认词如 `CONFIRM` |
| `vm_show_data_risk_banner()` | VM 数据风险横幅 |
| `ensure_legal_acceptance()` | 许可条款首次接受检查 |
| `backup_file()` | 配置文件备份到 `/var/backups/pve-tools/` |
| `pve_tools_download_url()`, `pve_tools_download_file()` | curl/wget 封装的下载工具 |
| `pve_tools_choose_update_urls()`, `pve_tools_version_gt()` | 更新 URL 选择与版本比较 |
| `apply_block()`, `remove_block()` | 标记配置块写入/删除(`# PVE-TOOLS BEGIN/END` |
| `grub_add_param()`, `grub_remove_param()` | GRUB 内核参数幂等管理 |
| `show_progress()`, `update_progress()`, `show_status()`, `show_progress_bar()` | 进度指示与状态反馈 |
| `pause_function()` | "按任意键继续..." |
| `show_menu_header()`, `show_menu_footer()`, `show_menu_option()` | 统一菜单 UI |
### network.sh -- 网络基础设施(约 218 行,仅头部已读)
| 函数 | 说明 |
|---|---|
| `detect_network_region()` | Cloudflare Trace 检测用户地区CN 自动启用镜像) |
| `fetch_session_tip()` | 一言 API 获取每日提示 |
| `network_show_diagnostics()` | 网络诊断IP/路由/DNS |
| `network_can_access_internet()`, `network_offline_guard()` | 连通性检测与离线模式 |
| `disable_ups_service()`, `enable_ups_service()`, `show_ups_diagnostics()` | NUT UPS 服务管理 |
| `mirror_*()` 系列函数 | 镜像源选择系统URI 查询、选择、重置、汇总、推荐提示) |
| `select_mirror()` | 镜像统一选择入口 |
| `show_banner()` | 启动横幅显示 |
### runtime.sh -- 运行时守卫与主入口(约 235 行)
| 函数 | 说明 |
|---|---|
| `check_root()` | root 权限检查 |
| `check_debug_mode()` | 解析 `--i-know-what-i-do``--debug` 参数 |
| `check_packages()` | 依赖包检查sudo, curl |
| `check_pve_version()` | PVE 版本检测(非 PVE9 环境拦截高风险操作) |
| `block_non_pve9_destructive()` | 非 PVE9 环境拦截一键优化/换源/升级等破坏性操作 |
| `show_menu()` | 主菜单10 个功能入口 + 一言 Tips |
| `main()` | **脚本主入口**:权限检查 -> 许可确认 -> 调试模式 -> PVE 版本 -> 网络检测 -> 一言 -> 更新检查 -> 主循环 |
## 关键依赖与配置
- **运行环境**: Proxmox VE 9.x (Debian 13 Trixie)root 权限
- **外部依赖**: `curl``wget`(网络操作)、`sudo`(权限提升)
- **加载顺序严格**: `config.sh` 必须先于 `core.sh` 加载(全局变量依赖);`runtime.sh` 最后加载(包含 `main()` 函数)
- **全局变量**: 所有以大写字母开头的变量为全局变量,`src/modules/` 中所有脚本均可直接访问
## 数据模型
本模块不涉及持久化数据。全局变量在脚本运行期间存在于内存中。`/var/lib/pve-tools/legal_acceptance_*` 文件用于记录许可条款接受状态。
## 测试与质量
- **语法检查**: `bash -n` 对每个 `lib/*.sh` 文件
- **静态分析**: `shellcheck` 覆盖(需配合 `--source-path` 处理跨文件引用)
- **CI 覆盖**: 所有 lib 文件在 `pr-validation.yml` 的 shellcheck 范围内
## 常见问题 (FAQ)
**Q: 为什么加载顺序很重要?**
`config.sh` 定义全局变量,`core.sh``setup_colors()` 和日志函数依赖颜色变量,`network.sh` 的镜像选择函数依赖 `core.sh` 中的 UI 函数,`runtime.sh``main()` 调用 `network.sh``core.sh` 中的函数。
**Q: 可以在 src/modules/ 中重新加载 lib 文件吗?**
不需要。`PVE-Tools.sh``dev.sh` 在加载任何模块之前已经 source 了所有 lib 文件,所有函数和变量对后续加载的模块全局可见。
**Q: config.sh 中新增镜像源如何操作?**
在每个并行数组末尾追加对应元素即可。`MIRROR_NAMES``MIRROR_IDS``MIRROR_*_URIS` 数组的索引一一对应。
## 相关文件清单
```
lib/
config.sh # 全局变量与常量254行
core.sh # 核心工具函数507行
network.sh # 网络检测与镜像选择(~218行+镜像函数)
runtime.sh # 运行时守卫与 main()235行
```
## 变更记录 (Changelog)
| 日期 | 变更 |
|---|---|
| 2026-07-08 | 初始化 lib 模块 CLAUDE.md模块化重构新增模块 |

253
lib/config.sh Normal file
View File

@@ -0,0 +1,253 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
# 版本信息
CURRENT_VERSION="9.0.0"
BUILD_NICKNAME="Evanescia"
VERSION_FILE_URL="https://raw.githubusercontent.com/PVE-Tools/PVE-Tools-9/main/VERSION"
UPDATE_FILE_URL="https://raw.githubusercontent.com/PVE-Tools/PVE-Tools-9/main/UPDATE"
PVE_TOOLS_SCRIPT_URL="https://raw.githubusercontent.com/PVE-Tools/PVE-Tools-9/main/PVE-Tools.sh"
PVE_VERSION_DETECTED=""
PVE_MAJOR_VERSION=""
RISK_ACK_BYPASS=false
# 镜像源注册表(并行数组,索引一一对应)
# 展示顺序即推荐顺序:官方源 -> 商业云 -> 骨干高校 -> 其他高校/地区源。
# 新增镜像: 在每个数组末尾追加一个元素即可。
MIRROR_NAMES=()
MIRROR_IDS=()
MIRROR_DEBIAN_URIS=()
MIRROR_SECURITY_URIS=()
MIRROR_PVE_URIS=()
MIRROR_CEPH_URIS=()
MIRROR_CT_URIS=()
MIRROR_NAMES+=("官方源 (Debian/Proxmox)")
MIRROR_IDS+=("official")
MIRROR_DEBIAN_URIS+=("https://deb.debian.org/debian")
MIRROR_SECURITY_URIS+=("https://security.debian.org/debian-security")
MIRROR_PVE_URIS+=("http://download.proxmox.com/debian/pve")
MIRROR_CEPH_URIS+=("http://download.proxmox.com/debian/ceph-squid")
MIRROR_CT_URIS+=("http://download.proxmox.com")
MIRROR_NAMES+=("阿里云 (公网限速/私网不限速)")
MIRROR_IDS+=("aliyun")
MIRROR_DEBIAN_URIS+=("https://mirrors.aliyun.com/debian")
MIRROR_SECURITY_URIS+=("https://mirrors.aliyun.com/debian-security")
MIRROR_PVE_URIS+=("")
MIRROR_CEPH_URIS+=("")
MIRROR_CT_URIS+=("")
MIRROR_NAMES+=("腾讯云 (公网限速/VPC不限速)")
MIRROR_IDS+=("tencent")
MIRROR_DEBIAN_URIS+=("https://mirrors.cloud.tencent.com/debian")
MIRROR_SECURITY_URIS+=("https://mirrors.cloud.tencent.com/debian-security")
MIRROR_PVE_URIS+=("")
MIRROR_CEPH_URIS+=("")
MIRROR_CT_URIS+=("")
MIRROR_NAMES+=("华为云 (公网限速/私网不限速)")
MIRROR_IDS+=("huaweicloud")
MIRROR_DEBIAN_URIS+=("https://mirrors.huaweicloud.com/debian")
MIRROR_SECURITY_URIS+=("https://mirrors.huaweicloud.com/debian-security")
MIRROR_PVE_URIS+=("")
MIRROR_CEPH_URIS+=("")
MIRROR_CT_URIS+=("")
MIRROR_NAMES+=("网易 163 (限速单线程)")
MIRROR_IDS+=("netease163")
MIRROR_DEBIAN_URIS+=("http://mirrors.163.com/debian")
MIRROR_SECURITY_URIS+=("http://mirrors.163.com/debian-security")
MIRROR_PVE_URIS+=("")
MIRROR_CEPH_URIS+=("")
MIRROR_CT_URIS+=("")
MIRROR_NAMES+=("清华大学 (TUNA)")
MIRROR_IDS+=("tuna")
MIRROR_DEBIAN_URIS+=("https://mirrors.tuna.tsinghua.edu.cn/debian")
MIRROR_SECURITY_URIS+=("https://mirrors.tuna.tsinghua.edu.cn/debian-security")
MIRROR_PVE_URIS+=("https://mirrors.tuna.tsinghua.edu.cn/proxmox/debian/pve")
MIRROR_CEPH_URIS+=("https://mirrors.tuna.tsinghua.edu.cn/proxmox/debian/ceph-squid")
MIRROR_CT_URIS+=("https://mirrors.tuna.tsinghua.edu.cn/proxmox")
MIRROR_NAMES+=("中科大 (USTC)")
MIRROR_IDS+=("ustc")
MIRROR_DEBIAN_URIS+=("https://mirrors.ustc.edu.cn/debian")
MIRROR_SECURITY_URIS+=("https://mirrors.ustc.edu.cn/debian-security")
MIRROR_PVE_URIS+=("https://mirrors.ustc.edu.cn/proxmox/debian/pve")
MIRROR_CEPH_URIS+=("https://mirrors.ustc.edu.cn/proxmox/debian/ceph-squid")
MIRROR_CT_URIS+=("https://mirrors.ustc.edu.cn/proxmox")
MIRROR_NAMES+=("北京大学 (PKU)")
MIRROR_IDS+=("pku")
MIRROR_DEBIAN_URIS+=("https://mirrors.pku.edu.cn/debian")
MIRROR_SECURITY_URIS+=("https://mirrors.pku.edu.cn/debian-security")
MIRROR_PVE_URIS+=("")
MIRROR_CEPH_URIS+=("")
MIRROR_CT_URIS+=("")
MIRROR_NAMES+=("上海交大致远 (SJTUG-Zhiyuan)")
MIRROR_IDS+=("sjtug-zhiyuan")
MIRROR_DEBIAN_URIS+=("https://mirrors.sjtug.sjtu.edu.cn/debian")
MIRROR_SECURITY_URIS+=("https://mirrors.sjtug.sjtu.edu.cn/debian-security")
MIRROR_PVE_URIS+=("")
MIRROR_CEPH_URIS+=("")
MIRROR_CT_URIS+=("")
MIRROR_NAMES+=("上海交大思源 (SJTUG-Siyuan)")
MIRROR_IDS+=("sjtug-siyuan")
MIRROR_DEBIAN_URIS+=("https://mirror.sjtu.edu.cn/debian")
MIRROR_SECURITY_URIS+=("https://mirror.sjtu.edu.cn/debian-security")
MIRROR_PVE_URIS+=("")
MIRROR_CEPH_URIS+=("")
MIRROR_CT_URIS+=("")
MIRROR_NAMES+=("华中科技大学 (HUST)")
MIRROR_IDS+=("hust")
MIRROR_DEBIAN_URIS+=("https://mirrors.hust.edu.cn/debian")
MIRROR_SECURITY_URIS+=("https://mirrors.hust.edu.cn/debian-security")
MIRROR_PVE_URIS+=("")
MIRROR_CEPH_URIS+=("")
MIRROR_CT_URIS+=("")
MIRROR_NAMES+=("西安交通大学 (XJTU)")
MIRROR_IDS+=("xjtu")
MIRROR_DEBIAN_URIS+=("https://mirrors.xjtu.edu.cn/debian")
MIRROR_SECURITY_URIS+=("https://mirrors.xjtu.edu.cn/debian-security")
MIRROR_PVE_URIS+=("")
MIRROR_CEPH_URIS+=("")
MIRROR_CT_URIS+=("")
MIRROR_NAMES+=("南京大学 (NJUNJU)")
MIRROR_IDS+=("njunju")
MIRROR_DEBIAN_URIS+=("https://mirrors.nju.edu.cn/debian")
MIRROR_SECURITY_URIS+=("https://mirrors.nju.edu.cn/debian-security")
MIRROR_PVE_URIS+=("https://mirrors.nju.edu.cn/proxmox/debian/pve")
MIRROR_CEPH_URIS+=("https://mirrors.nju.edu.cn/proxmox/debian/ceph-squid")
MIRROR_CT_URIS+=("")
MIRROR_NAMES+=("浙江大学 (ZJU)")
MIRROR_IDS+=("zju")
MIRROR_DEBIAN_URIS+=("https://mirrors.zju.edu.cn/debian")
MIRROR_SECURITY_URIS+=("https://mirrors.zju.edu.cn/debian-security")
MIRROR_PVE_URIS+=("https://mirrors.zju.edu.cn/proxmox/debian/pve")
MIRROR_CEPH_URIS+=("https://mirrors.zju.edu.cn/proxmox/debian/ceph-squid")
MIRROR_CT_URIS+=("https://mirrors.zju.edu.cn/proxmox")
MIRROR_NAMES+=("北京外国语大学 (BFSU)")
MIRROR_IDS+=("bfsu")
MIRROR_DEBIAN_URIS+=("https://mirrors.bfsu.edu.cn/debian")
MIRROR_SECURITY_URIS+=("https://mirrors.bfsu.edu.cn/debian-security")
MIRROR_PVE_URIS+=("https://mirrors.bfsu.edu.cn/proxmox/debian/pve")
MIRROR_CEPH_URIS+=("https://mirrors.bfsu.edu.cn/proxmox/debian/ceph-squid")
MIRROR_CT_URIS+=("")
MIRROR_NAMES+=("兰州大学 (LZUOSS)")
MIRROR_IDS+=("lzuoss")
MIRROR_DEBIAN_URIS+=("https://mirror.lzu.edu.cn/debian")
MIRROR_SECURITY_URIS+=("https://mirror.lzu.edu.cn/debian-security")
MIRROR_PVE_URIS+=("https://mirror.lzu.edu.cn/proxmox/debian/pve")
MIRROR_CEPH_URIS+=("https://mirror.lzu.edu.cn/proxmox/debian/ceph-squid")
MIRROR_CT_URIS+=("")
MIRROR_NAMES+=("山东大学 (SDU)")
MIRROR_IDS+=("sdu")
MIRROR_DEBIAN_URIS+=("https://mirrors.sdu.edu.cn/debian")
MIRROR_SECURITY_URIS+=("https://mirrors.sdu.edu.cn/debian-security")
MIRROR_PVE_URIS+=("https://mirrors.sdu.edu.cn/proxmox/debian/pve")
MIRROR_CEPH_URIS+=("https://mirrors.sdu.edu.cn/proxmox/debian/ceph-squid")
MIRROR_CT_URIS+=("https://mirrors.sdu.edu.cn/proxmox")
MIRROR_NAMES+=("南阳理工学院 (NYIST)")
MIRROR_IDS+=("nyist")
MIRROR_DEBIAN_URIS+=("https://mirror.nyist.edu.cn/debian")
MIRROR_SECURITY_URIS+=("https://mirror.nyist.edu.cn/debian-security")
MIRROR_PVE_URIS+=("https://mirror.nyist.edu.cn/proxmox/debian/pve")
MIRROR_CEPH_URIS+=("")
MIRROR_CT_URIS+=("")
MIRROR_NAMES+=("重庆大学 (CQU)")
MIRROR_IDS+=("cqu")
MIRROR_DEBIAN_URIS+=("https://mirrors.cqu.edu.cn/debian")
MIRROR_SECURITY_URIS+=("https://mirrors.cqu.edu.cn/debian-security")
MIRROR_PVE_URIS+=("https://mirrors.cqu.edu.cn/proxmox/debian/pve")
MIRROR_CEPH_URIS+=("")
MIRROR_CT_URIS+=("")
MIRROR_NAMES+=("河南省教育科研网 (HERNET)")
MIRROR_IDS+=("hernet")
MIRROR_DEBIAN_URIS+=("https://mirrors.ha.edu.cn/debian")
MIRROR_SECURITY_URIS+=("https://mirrors.ha.edu.cn/debian-security")
MIRROR_PVE_URIS+=("https://mirrors.ha.edu.cn/proxmox/debian/pve")
MIRROR_CEPH_URIS+=("")
MIRROR_CT_URIS+=("")
MIRROR_NAMES+=("南方科技大学 (SUSTech)")
MIRROR_IDS+=("sustech")
MIRROR_DEBIAN_URIS+=("https://mirrors.sustech.edu.cn/debian")
MIRROR_SECURITY_URIS+=("https://mirrors.sustech.edu.cn/debian-security")
MIRROR_PVE_URIS+=("")
MIRROR_CEPH_URIS+=("")
MIRROR_CT_URIS+=("")
MIRROR_NAMES+=("吉林大学 (JLU)")
MIRROR_IDS+=("jlu")
MIRROR_DEBIAN_URIS+=("https://mirrors.jlu.edu.cn/debian")
MIRROR_SECURITY_URIS+=("https://mirrors.jlu.edu.cn/debian-security")
MIRROR_PVE_URIS+=("")
MIRROR_CEPH_URIS+=("")
MIRROR_CT_URIS+=("")
MIRROR_NAMES+=("南京工业大学 (NJTech)")
MIRROR_IDS+=("njtech")
MIRROR_DEBIAN_URIS+=("https://mirrors.njtech.edu.cn/debian")
MIRROR_SECURITY_URIS+=("https://mirrors.njtech.edu.cn/debian-security")
MIRROR_PVE_URIS+=("")
MIRROR_CEPH_URIS+=("")
MIRROR_CT_URIS+=("")
MIRROR_NAMES+=("西北农林科技大学 (NWAFU)")
MIRROR_IDS+=("nwafu")
MIRROR_DEBIAN_URIS+=("https://mirrors.nwafu.edu.cn/debian")
MIRROR_SECURITY_URIS+=("https://mirrors.nwafu.edu.cn/debian-security")
MIRROR_PVE_URIS+=("")
MIRROR_CEPH_URIS+=("")
MIRROR_CT_URIS+=("")
MIRROR_SELECTED_DEBIAN=-1
MIRROR_SELECTED_SECURITY=-1
MIRROR_SELECTED_PVE=-1
MIRROR_SELECTED_CEPH=-1
MIRROR_SELECTED_CT=-1
# 自动更新网络检测配置
CF_TRACE_URL="https://www.cloudflare.com/cdn-cgi/trace"
GITHUB_MIRROR_PREFIX="https://ghfast.top/"
USE_MIRROR_FOR_UPDATE=0
USER_COUNTRY_CODE=""
NETWORK_MODE="auto"
IS_OFFLINE_MODE=0
HITOKOTO_API_URL="https://v1.hitokoto.cn/?encode=json"
SESSION_TIP=""
PVE_KVM_ROM_DIR="/usr/share/kvm"
# 快速虚拟机下载脚本配置
FASTPVE_INSTALLER_URL="https://raw.githubusercontent.com/kspeeder/fastpve/main/fastpve-install.sh"
FASTPVE_PROJECT_URL="https://github.com/kspeeder/fastpve"
THIRD_PARTY_MODULES_TREE_API_MAIN_URL="https://api.github.com/repos/PVE-Tools/PVE-Tools-9/git/trees/main?recursive=1"
THIRD_PARTY_MODULES_TREE_API_MASTER_URL="https://api.github.com/repos/PVE-Tools/PVE-Tools-9/git/trees/master?recursive=1"
THIRD_PARTY_MODULES_RAW_BASE_URL="https://raw.githubusercontent.com/PVE-Tools/PVE-Tools-9/main/Modules"
COOLERCONTROL_PROJECT_URL="https://gitlab.com/coolercontrol/coolercontrol"
COOLERCONTROL_DOCS_URL="https://docs.coolercontrol.org/getting-started.html"
COOLERCONTROL_DEB_SETUP_URL="https://dl.cloudsmith.io/public/coolercontrol/coolercontrol/setup.deb.sh"
NVIDIA_ASSETS_BASE_URL="https://raw.githubusercontent.com/PVE-Tools/PVE-Tools-9/main/Modules/NVIDIA"
NVIDIA_VGPU_UNLOCK_SO_URL="${NVIDIA_ASSETS_BASE_URL}/libvgpu_unlock_rs.so"
VM_CONFIG_EXPORT_DIR="/var/lib/pve-tools/vm-config-exports"
VM_BACKUP_CRON_FILE="/etc/cron.d/pve-tools-vm-backup"
VM_DEFAULT_CLOUDINIT_BRIDGE="vmbr0"
HOST_NETWORK_INTERFACES_FILE="/etc/network/interfaces"
HOST_NETWORK_INTERFACES_STAGED_FILE="/etc/network/interfaces.new"
HOST_NETWORK_EXPORT_DIR="/var/lib/pve-tools/network-firewall-exports"
PVE_CLUSTER_FIREWALL_FILE="/etc/pve/firewall/cluster.fw"

506
lib/core.sh Normal file
View File

@@ -0,0 +1,506 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
setup_colors() {
if [[ -t 1 && -z "${NO_COLOR}" ]]; then
# 使用 printf 确保变量包含真实的转义字符,提高不同 shell 间的兼容性
RED=$(printf '\033[0;31m')
GREEN=$(printf '\033[0;32m')
YELLOW=$(printf '\033[1;33m')
BLUE=$(printf '\033[0;34m')
PINK=$(printf '\033[0;35m')
CYAN=$(printf '\033[0;36m')
MAGENTA=$(printf '\033[0;35m')
WHITE=$(printf '\033[1;37m')
ORANGE=$(printf '\033[0;33m')
NC=$(printf '\033[0m')
# UI 辅助色映射
PRIMARY="${CYAN}"
H1=$(printf '\033[1;36m')
H2=$(printf '\033[1;37m')
else
RED='' GREEN='' YELLOW='' BLUE='' CYAN='' MAGENTA='' WHITE='' ORANGE='' NC=''
PRIMARY='' H1='' H2=''
fi
# UI 界面一致性常量
UI_BORDER="${NC}═════════════════════════════════════════════════${NC}"
UI_DIVIDER="${NC}═════════════════════════════════════════════════${NC}"
UI_FOOTER="${NC}═════════════════════════════════════════════════${NC}"
UI_HEADER="${NC}═════════════════════════════════════════════════${NC}"
}
# 初始化颜色
setup_colors
log_info() {
local timestamp=$(date +'%H:%M:%S')
echo -e "${GREEN}[$timestamp]${NC} ${CYAN}INFO${NC} $1"
echo "[$timestamp] INFO $1" >> /var/log/pve-tools.log
}
log_warn() {
local timestamp=$(date +'%H:%M:%S')
echo -e "${YELLOW}[$timestamp]${NC} ${ORANGE}WARN${NC} $1"
echo "[$timestamp] WARN $1" >> /var/log/pve-tools.log
}
log_error() {
local timestamp=$(date +'%H:%M:%S')
echo -e "${RED}[$timestamp]${NC} ${RED}ERROR${NC} $1" >&2
echo "[$timestamp] ERROR $1" >> /var/log/pve-tools.log
}
log_step() {
local timestamp=$(date +'%H:%M:%S')
echo -e "${BLUE}[$timestamp]${NC} ${MAGENTA}STEP${NC} $1"
echo "[$timestamp] STEP $1" >> /var/log/pve-tools.log
}
log_success() {
local timestamp=$(date +'%H:%M:%S')
echo -e "${GREEN}[$timestamp]${NC} ${GREEN}OK${NC} $1"
echo "[$timestamp] OK $1" >> /var/log/pve-tools.log
}
log_tips(){
local timestamp=$(date +'%H:%M:%S')
echo -e "${CYAN}[$timestamp]${NC} ${MAGENTA}TIPS${NC} $1"
echo "[$timestamp] TIPS $1" >> /var/log/pve-tools.log
}
# Enhanced error handling function with consistent messaging
display_error() {
local error_msg="$1"
local suggestion="${2:-请检查输入或联系作者寻求帮助}"
log_error "$error_msg"
echo -e "${YELLOW}提示: $suggestion${NC}"
pause_function
}
# Enhanced success feedback
display_success() {
local success_msg="$1"
local next_step="${2:-}"
log_success "$success_msg"
if [[ -n "$next_step" ]]; then
echo -e "${GREEN}下一步: $next_step${NC}"
fi
}
# Confirmation prompt with consistent UI
confirm_action() {
local action_desc="$1"
local default_choice="${2:-N}"
echo -e "${YELLOW}确认操作: $action_desc${NC}"
read -p "请输入 'yes' 确认继续,其他任意键取消 [$default_choice]: " -r confirm
if [[ "$confirm" == "yes" || "$confirm" == "YES" ]]; then
return 0
else
log_info "操作已取消"
return 1
fi
}
confirm_high_risk_action() {
local action_desc="$1"
local risk_desc="$2"
local impact_desc="$3"
local backup_desc="$4"
local confirm_word="${5:-CONFIRM}"
echo -e "${RED}${UI_DIVIDER}${NC}"
echo -e "${RED}高风险数据操作警告${NC}"
echo -e "${YELLOW}操作:${NC} $action_desc"
echo -e "${YELLOW}风险:${NC} $risk_desc"
echo -e "${YELLOW}影响:${NC} $impact_desc"
echo -e "${YELLOW}建议:${NC} $backup_desc"
echo -e "${RED}请输入确认词 ${confirm_word} 继续,其他任意输入将取消。${NC}"
echo -e "${RED}${UI_DIVIDER}${NC}"
local confirm
read -p "确认词: " -r confirm
if [[ "$confirm" == "$confirm_word" ]]; then
return 0
fi
log_warn "未通过高风险确认,操作已取消。"
return 1
}
vm_show_data_risk_banner() {
echo -e "${RED}${UI_DIVIDER}${NC}"
echo -e "${RED}高风险提示:以下操作可能直接改写 VM 配置、磁盘、快照、克隆、恢复或迁移状态。${NC}"
echo -e "${YELLOW}开始前请确认:已有可验证备份、已核对 VMID/磁盘槽位/目标存储、业务已处于维护窗口。${NC}"
echo -e "${YELLOW}一旦误操作,数据恢复成功率通常取决于后续写入量、存储类型以及是否立即停止写入。${NC}"
echo -e "${RED}恢复参考: https://pve.oowo.cc/advanced/data-recovery-after-mistake${NC}"
echo -e "${RED}${UI_DIVIDER}${NC}"
}
LEGAL_VERSION="1.1"
LEGAL_EFFECTIVE_DATE="2026-04-05"
ensure_legal_acceptance() {
local dir="/var/lib/pve-tools"
local marker="${dir}/legal_acceptance_${LEGAL_VERSION}"
mkdir -p "$dir" >/dev/null 2>&1 || true
if [[ -f "$marker" ]]; then
return 0
fi
clear
show_menu_header "许可与服务条款"
echo -e "${CYAN}继续使用本脚本前,请先认真阅读并同意以下条款:${NC}"
echo -e " - ULA最终用户许可与使用协议: https://pve.oowo.cc/ula"
echo -e " - TOS服务条款: https://pve.oowo.cc/tos"
echo -e "${RED} 高风险提醒:涉及宿主机网络、桥接/Bond/VLAN、防火墙以及 VM、磁盘、快照、克隆、恢复、导入导出、迁移等操作时可能造成管理面失联、业务中断或不可逆的数据/配置损坏。${NC}"
echo -e "${RED} 请仅在已完成可验证备份、明确维护窗口并理解命令影响范围后继续;误操作导致的数据损失、恢复成本与第三方恢复费用均由使用者自行承担。${NC}"
echo -e "${RED} 您可以随时撤回同意,只需删除 ${marker} 文件即可。${NC}"
echo -e "${UI_DIVIDER}"
echo -n "是否同意协议并继续?(Y/N): "
local ans
read -n 1 -r ans
echo
if [[ "$ans" == "Y" || "$ans" == "y" ]]; then
printf '%s\n' "accepted_version=${LEGAL_VERSION}" "accepted_effective_date=${LEGAL_EFFECTIVE_DATE}" "accepted_time=$(date +%F\ %T)" > "$marker" 2>/dev/null || true
log_success "已记录同意条款,后续将自动跳过许可检查。"
return 0
fi
log_info "未同意条款,退出脚本。"
exit 0
}
# ============ 配置文件安全管理函数 ============
# 备份文件到 /var/backups/pve-tools/
backup_file() {
local file_path="$1"
local result_var="${2:-}"
local backup_dir="/var/backups/pve-tools"
if [[ -z "$file_path" ]]; then
log_error "backup_file: 缺少文件路径参数"
return 1
fi
if [[ ! -f "$file_path" ]]; then
log_warn "文件不存在,跳过备份: $file_path"
return 1
fi
mkdir -p "$backup_dir" >/dev/null 2>&1 || {
log_error "无法创建备份目录: $backup_dir"
return 1
}
local filename timestamp backup_path
filename="$(basename "$file_path")"
timestamp="$(date +%Y%m%d_%H%M%S)"
backup_path="${backup_dir}/${filename}.${timestamp}.bak"
if cp -a "$file_path" "$backup_path"; then
[[ -n "$result_var" ]] && printf -v "$result_var" '%s' "$backup_path"
log_success "文件已备份: $backup_path"
return 0
fi
log_error "备份失败: $file_path"
return 1
}
pve_tools_download_url() {
local url="$1"
local timeout="${2:-15}"
if [[ -z "$url" ]]; then
return 1
fi
if command -v curl >/dev/null 2>&1; then
curl -fsSL --connect-timeout "$timeout" --max-time "$timeout" "$url" 2>/dev/null
elif command -v wget >/dev/null 2>&1; then
wget -q -T "$timeout" -O - "$url" 2>/dev/null
else
return 1
fi
}
pve_tools_download_file() {
local url="$1"
local output_file="$2"
local timeout="${3:-60}"
if [[ -z "$url" || -z "$output_file" ]]; then
return 1
fi
if command -v curl >/dev/null 2>&1; then
curl -fsSL --connect-timeout 15 --max-time "$timeout" -o "$output_file" "$url" 2>/dev/null
elif command -v wget >/dev/null 2>&1; then
wget -q -T "$timeout" -O "$output_file" "$url" 2>/dev/null
else
return 1
fi
}
pve_tools_choose_update_urls() {
local prefer_mirror=0
local version_url="$VERSION_FILE_URL"
local update_url="$UPDATE_FILE_URL"
local script_url="$PVE_TOOLS_SCRIPT_URL"
if [[ -n "$USER_COUNTRY_CODE" ]]; then
prefer_mirror=$USE_MIRROR_FOR_UPDATE
elif detect_network_region >/dev/null 2>&1; then
prefer_mirror=$USE_MIRROR_FOR_UPDATE
fi
if [[ "$prefer_mirror" -eq 1 ]]; then
version_url="${GITHUB_MIRROR_PREFIX}${VERSION_FILE_URL}"
update_url="${GITHUB_MIRROR_PREFIX}${UPDATE_FILE_URL}"
script_url="${GITHUB_MIRROR_PREFIX}${PVE_TOOLS_SCRIPT_URL}"
fi
printf '%s|%s|%s|%s\n' "$prefer_mirror" "$version_url" "$update_url" "$script_url"
}
pve_tools_version_gt() {
local newer="$1"
local current="$2"
[[ -n "$newer" && -n "$current" ]] || return 1
[[ "$(printf '%s\n' "$newer" "$current" | sort -V | tail -n1)" == "$newer" && "$newer" != "$current" ]]
}
# 写入配置块(带标记)
# 用法: apply_block <file> <marker> <content>
apply_block() {
local file_path="$1"
local marker="$2"
local content="$3"
if [[ -z "$file_path" || -z "$marker" ]]; then
log_error "apply_block: 缺少必需参数"
return 1
fi
# 先备份文件
backup_file "$file_path"
# 移除旧的配置块(如果存在)
remove_block "$file_path" "$marker"
# 写入新的配置块
{
echo "# PVE-TOOLS BEGIN $marker"
echo "$content"
echo "# PVE-TOOLS END $marker"
} >> "$file_path"
log_success "配置块已写入: $file_path [$marker]"
}
# 删除配置块(精确匹配标记)
# 用法: remove_block <file> <marker>
remove_block() {
local file_path="$1"
local marker="$2"
if [[ -z "$file_path" || -z "$marker" ]]; then
log_error "remove_block: 缺少必需参数"
return 1
fi
if [[ ! -f "$file_path" ]]; then
log_warn "文件不存在,跳过删除: $file_path"
return 0
fi
# 使用 sed 删除标记之间的所有内容(包括标记行)
sed -i "/# PVE-TOOLS BEGIN $marker/,/# PVE-TOOLS END $marker/d" "$file_path"
log_info "配置块已删除: $file_path [$marker]"
}
# ============ 配置文件安全管理函数结束 ============
# ============ GRUB 参数幂等管理函数 ============
# 添加 GRUB 参数(幂等操作,不会重复添加)
# 用法: grub_add_param "intel_iommu=on"
grub_add_param() {
local param="$1"
if [[ -z "$param" ]]; then
log_error "grub_add_param: 缺少参数"
return 1
fi
# 备份 GRUB 配置
backup_file "/etc/default/grub"
# 读取当前的 GRUB_CMDLINE_LINUX_DEFAULT 值
local current_line=$(grep '^GRUB_CMDLINE_LINUX_DEFAULT=' /etc/default/grub)
if [[ -z "$current_line" ]]; then
log_error "未找到 GRUB_CMDLINE_LINUX_DEFAULT 配置"
return 1
fi
# 提取引号内的参数
local current_params=$(echo "$current_line" | sed 's/^GRUB_CMDLINE_LINUX_DEFAULT="\(.*\)"$/\1/')
# 检查参数是否已存在(支持 key=value 和 key 两种格式)
local param_key=$(echo "$param" | cut -d'=' -f1)
if echo "$current_params" | grep -qw "$param_key"; then
# 参数已存在,先删除旧值
current_params=$(echo "$current_params" | sed "s/\b${param_key}[^ ]*\b//g")
fi
# 添加新参数(去除多余空格)
local new_params=$(echo "$current_params $param" | sed 's/ */ /g' | sed 's/^ //;s/ $//')
# 写回配置文件
sed -i "s|^GRUB_CMDLINE_LINUX_DEFAULT=.*|GRUB_CMDLINE_LINUX_DEFAULT=\"$new_params\"|" /etc/default/grub
log_success "GRUB 参数已添加: $param"
}
# 删除 GRUB 参数(精确删除,不影响其他参数)
# 用法: grub_remove_param "intel_iommu=on"
grub_remove_param() {
local param="$1"
if [[ -z "$param" ]]; then
log_error "grub_remove_param: 缺少参数"
return 1
fi
# 备份 GRUB 配置
backup_file "/etc/default/grub"
# 读取当前的 GRUB_CMDLINE_LINUX_DEFAULT 值
local current_line=$(grep '^GRUB_CMDLINE_LINUX_DEFAULT=' /etc/default/grub)
if [[ -z "$current_line" ]]; then
log_error "未找到 GRUB_CMDLINE_LINUX_DEFAULT 配置"
return 1
fi
# 提取引号内的参数
local current_params=$(echo "$current_line" | sed 's/^GRUB_CMDLINE_LINUX_DEFAULT="\(.*\)"$/\1/')
# 删除指定参数(支持精确匹配和前缀匹配)
local param_key=$(echo "$param" | cut -d'=' -f1)
local new_params=$(echo "$current_params" | sed "s/\b${param_key}[^ ]*\b//g" | sed 's/ */ /g' | sed 's/^ //;s/ $//')
# 写回配置文件
sed -i "s|^GRUB_CMDLINE_LINUX_DEFAULT=.*|GRUB_CMDLINE_LINUX_DEFAULT=\"$new_params\"|" /etc/default/grub
log_success "GRUB 参数已删除: $param"
}
# ============ GRUB 参数幂等管理函数结束 ============
# 进度指示函数
show_progress() {
local message="$1"
local spinner="|/-\\"
local i=0
# Print initial message
echo -ne "${CYAN}[ ]${NC} $message\033[0K\r"
# Update the spinner position in the box
while true; do
i=$(( (i + 1) % 4 ))
echo -ne "\b\b\b\b\b${CYAN}[${spinner:$i:1}]${NC}\033[0K\r"
sleep 0.1
done &
# Store the background job ID to be killed later
SPINNER_PID=$!
}
update_progress() {
local message="$1"
# Kill the spinner if running
if [[ -n "$SPINNER_PID" ]]; then
kill $SPINNER_PID 2>/dev/null
fi
echo -ne "${GREEN}[ OK ]${NC} $message\033[0K\r"
echo
}
# Enhanced visual feedback function
show_status() {
local status="$1"
local message="$2"
local color="$3"
case $status in
"info")
echo -e "${CYAN}[INFO]${NC} $message"
;;
"success")
echo -e "${GREEN}[ OK! ]${NC} $message"
;;
"warning")
echo -e "${YELLOW}[WARN]${NC} $message"
;;
"error")
echo -e "${RED}[FAIL]${NC} $message"
;;
"step")
echo -e "${MAGENTA}[STEP]${NC} $message"
;;
*)
echo -e "${WHITE}[$status]${NC} $message"
;;
esac
}
# Progress bar function
show_progress_bar() {
local current="$1"
local total="$2"
local message="$3"
local width=40
local percentage=$(( current * 100 / total ))
local filled=$(( width * current / total ))
printf "${CYAN}[${NC}"
for ((i=0; i<filled; i++)); do
printf "█"
done
for ((i=filled; i<width; i++)); do
printf " "
done
printf "${CYAN}]${NC} ${percentage}%% $message\r"
}
# 通过 Cloudflare Trace 检测地区,决定是否启用镜像源
pause_function() {
echo -n "按任意键继续... "
read -n 1 -s input
if [[ -n ${input} ]]; then
echo -e "\b
"
fi
}
#--------------开启硬件直通----------------
# 开启硬件直通
show_menu_header() {
local title="$1"
echo -e "${UI_BORDER}"
echo -e " ${H2}${title}${NC}"
echo -e "${UI_DIVIDER}"
}
show_menu_footer() {
echo -e "${UI_FOOTER}"
}
show_menu_option() {
local num="$1"
local desc="$2"
if [[ -z "$desc" ]]; then
# 仅作为消息或标题显示
echo -e " ${H2}$num${NC}"
else
printf " ${PRIMARY}%-3s${NC}. %s\\n" "$num" "$desc"
fi
}
# 镜像源选择函数

444
lib/network.sh Normal file
View File

@@ -0,0 +1,444 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
detect_network_region() {
local timeout=5
USER_COUNTRY_CODE=""
USE_MIRROR_FOR_UPDATE=0
if ! command -v curl &> /dev/null; then
return 1
fi
local trace_output
trace_output=$(curl -s --connect-timeout $timeout --max-time $timeout "$CF_TRACE_URL" 2>/dev/null)
if [[ -z "$trace_output" ]]; then
return 1
fi
local loc
loc=$(echo "$trace_output" | awk -F= '/^loc=/{print $2}' | tr -d '\r')
if [[ -z "$loc" ]]; then
return 1
fi
USER_COUNTRY_CODE="$loc"
if [[ "$USER_COUNTRY_CODE" == "CN" ]]; then
USE_MIRROR_FOR_UPDATE=1
fi
return 0
}
fetch_session_tip() {
if [[ -n "$SESSION_TIP" ]]; then
return 0
fi
if [[ "$IS_OFFLINE_MODE" -eq 1 ]]; then
SESSION_TIP="离线模式已启用,本次会话不获取在线 Tips。"
return 0
fi
local timeout=5
local response=""
if command -v curl >/dev/null 2>&1; then
response=$(curl -s --connect-timeout "$timeout" --max-time "$timeout" "$HITOKOTO_API_URL" 2>/dev/null)
elif command -v wget >/dev/null 2>&1; then
response=$(wget -q -T "$timeout" -O - "$HITOKOTO_API_URL" 2>/dev/null)
else
SESSION_TIP="当前环境缺少 curl 或 wget无法获取在线 Tips。"
return 0
fi
if [[ -z "$response" ]]; then
SESSION_TIP="一言获取失败,本次会话不再重试。"
return 0
fi
local hitokoto from from_who
hitokoto=$(printf '%s' "$response" | sed -n 's/.*"hitokoto":"\([^"]*\)".*/\1/p' | head -n 1)
from=$(printf '%s' "$response" | sed -n 's/.*"from":"\([^"]*\)".*/\1/p' | head -n 1)
from_who=$(printf '%s' "$response" | sed -n 's/.*"from_who":\("[^"]*"\|null\).*/\1/p' | head -n 1 | sed 's/^"//; s/"$//')
hitokoto=$(printf '%s' "$hitokoto" | sed 's/\\"/"/g; s/\\\\/\\/g')
from=$(printf '%s' "$from" | sed 's/\\"/"/g; s/\\\\/\\/g')
from_who=$(printf '%s' "$from_who" | sed 's/\\"/"/g; s/\\\\/\\/g')
if [[ -z "$hitokoto" ]]; then
SESSION_TIP="一言解析失败,本次会话不再重试。"
return 0
fi
SESSION_TIP="$hitokoto"
if [[ -n "$from" ]]; then
SESSION_TIP="${SESSION_TIP} —— ${from}"
if [[ -n "$from_who" && "$from_who" != "null" ]]; then
SESSION_TIP="${SESSION_TIP} / ${from_who}"
fi
fi
}
network_show_diagnostics() {
echo "${UI_DIVIDER}"
echo -e "${CYAN}当前网络诊断信息:${NC}"
echo -e "${CYAN}IPv4 地址:${NC}"
ip -4 -o addr show scope global 2>/dev/null | awk '{print " "$2": "$4}' || true
echo -e "${CYAN}默认路由:${NC}"
ip route 2>/dev/null | sed -n '1,3p' | sed 's/^/ /' || true
echo -e "${CYAN}DNS 配置:${NC}"
grep -E '^\s*nameserver\s+' /etc/resolv.conf 2>/dev/null | sed 's/^/ /' || true
echo "${UI_DIVIDER}"
}
network_can_access_internet() {
local test_url="https://www.tencent.com/"
if command -v curl >/dev/null 2>&1; then
curl -fsSL --connect-timeout 5 --max-time 8 "$test_url" >/dev/null 2>&1
return $?
fi
if command -v wget >/dev/null 2>&1; then
wget -q --timeout=8 -O - "$test_url" >/dev/null 2>&1
return $?
fi
return 1
}
network_offline_guard() {
IS_OFFLINE_MODE=0
if [[ "$NETWORK_MODE" == "offline" ]]; then
IS_OFFLINE_MODE=1
log_warn "已配置为离线模式:将跳过在线更新检查与在线资源拉取。"
return 0
fi
if network_can_access_internet; then
log_success "网络连通性检测通过。"
return 0
fi
IS_OFFLINE_MODE=1
log_warn "检测到当前主机无法访问互联网,在线资源可能不可用。"
network_show_diagnostics
echo -e "${YELLOW}请先确认是否为本机网络问题网关、DNS、NAT、防火墙再继续。${NC}"
echo -e "${YELLOW}如果你确定当前环境需要离线使用,可继续,但涉及在线下载/更新的功能会失败。${NC}"
read -p "输入 'offline' 继续离线模式,其他任意键退出排查网络: " offline_confirm
if [[ "$offline_confirm" != "offline" ]]; then
log_info "已取消执行,请先修复网络后重试。"
exit 0
fi
return 0
}
disable_ups_service() {
local managed_any=false
local service
local services=("nut-monitor.service" "nut-server.service")
if ! command -v systemctl >/dev/null 2>&1; then
log_warn "系统不支持 systemctl无法自动管理 UPS 服务"
return 1
fi
for service in "${services[@]}"; do
if systemctl list-unit-files 2>/dev/null | grep -q "^${service}"; then
systemctl stop "${service%.service}" >/dev/null 2>&1 || true
systemctl disable "${service%.service}" >/dev/null 2>&1 || true
managed_any=true
fi
done
if [[ "$managed_any" != true ]]; then
log_info "未检测到可管理的 NUT 服务,跳过 UPS 服务管理"
return 0
fi
log_success "已执行 UPS 服务关闭: systemctl stop/disable nut-monitor nut-server"
return 0
}
enable_ups_service() {
local managed_any=false
local service
local services=("nut-server.service" "nut-monitor.service")
if ! command -v systemctl >/dev/null 2>&1; then
return 1
fi
for service in "${services[@]}"; do
if systemctl list-unit-files 2>/dev/null | grep -q "^${service}"; then
systemctl enable "${service%.service}" >/dev/null 2>&1 || true
systemctl start "${service%.service}" >/dev/null 2>&1 || true
managed_any=true
fi
done
[[ "$managed_any" == true ]]
}
show_ups_diagnostics() {
local service active_state enabled_state has_nut_service=false
local upsc_path ups_list
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "UPS / NUT 诊断信息"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
if upsc_path=$(command -v upsc 2>/dev/null); then
log_success "检测到 upsc: $upsc_path"
if command -v timeout >/dev/null 2>&1; then
ups_list=$(timeout --signal=TERM 3s upsc -l 2>/dev/null || true)
if [[ -n "$ups_list" ]]; then
echo "已发现 UPS 设备名:"
printf '%s\n' "$ups_list"
else
log_info "未列出 UPS 设备名;请确认 NUT 已由系统正确配置"
fi
else
log_warn "未检测到 timeout脚本不会在 Web UI 热路径里直接调用 upsc"
fi
else
log_warn "未检测到 upscnut-client 未安装),无法读取 UPS 数据"
fi
if command -v systemctl >/dev/null 2>&1; then
for service in nut-server.service nut-monitor.service; do
if systemctl list-unit-files 2>/dev/null | grep -q "^${service}"; then
active_state=$(systemctl is-active "${service%.service}" 2>/dev/null || echo unknown)
enabled_state=$(systemctl is-enabled "${service%.service}" 2>/dev/null || echo unknown)
echo "${service%.service} 状态: active=${active_state}, enabled=${enabled_state}"
has_nut_service=true
fi
done
if [[ "$has_nut_service" != true ]]; then
log_info "未检测到可管理的 NUT systemd 服务"
fi
else
log_info "系统不支持 systemctl跳过 NUT 服务状态检查"
fi
echo "说明:温度监控中的 UPS 展示仅做安全读取,不会自动启停 NUT 服务。"
}
# 显示横幅
show_banner() {
clear
echo -ne "${NC}"
cat << 'EOF'
██████╗ ██╗ ██╗███████╗ ████████╗ ██████╗ ██████╗ ██╗ ███████╗ ██████╗ ██████╗ ██████╗
██╔══██╗██║ ██║██╔════╝ ╚══██╔══╝██╔═══██╗██╔═══██╗██║ ██╔════╝ ██╔══██╗██╔══██╗██╔═══██╗
██████╔╝██║ ██║█████╗ ██║ ██║ ██║██║ ██║██║ ███████╗ ██████╔╝██████╔╝██║ ██║
██╔═══╝ ╚██╗ ██╔╝██╔══╝ ██║ ██║ ██║██║ ██║██║ ╚════██║ ██╔═══╝ ██╔══██╗██║ ██║
██║ ╚████╔╝ ███████╗ ██║ ╚██████╔╝╚██████╔╝███████╗███████║ ██║ ██║ ██║╚██████╔╝
╚═╝ ╚═══╝ ╚══════╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝
EOF
echo -ne "${NC}"
echo "$UI_BORDER"
echo -e " ${H1}PVE-Tools-Pro | ${BUILD_NICKNAME} Build | Support PVE 9.x.x${NC}"
echo " 让每个人都能体验虚拟化技术的的便利。"
echo -e " 作者: ${PINK}Maple${NC} | 交流Q群: ${CYAN}1031976463${NC}"
echo -e " 当前版本: ${GREEN}$CURRENT_VERSION${NC} | 最新版本: ${remote_version:-"Not Found"}"
echo "$UI_BORDER"
}
# 检查是否为 root 用户
mirror_uri_by_type() {
local source_type="$1"
local idx="$2"
case "$source_type" in
debian) echo "${MIRROR_DEBIAN_URIS[$idx]}" ;;
security) echo "${MIRROR_SECURITY_URIS[$idx]}" ;;
pve) echo "${MIRROR_PVE_URIS[$idx]}" ;;
ceph) echo "${MIRROR_CEPH_URIS[$idx]}" ;;
ct) echo "${MIRROR_CT_URIS[$idx]}" ;;
*) echo "" ;;
esac
}
mirror_set_selected() {
local source_type="$1"
local idx="$2"
case "$source_type" in
debian) MIRROR_SELECTED_DEBIAN="$idx" ;;
security) MIRROR_SELECTED_SECURITY="$idx" ;;
pve) MIRROR_SELECTED_PVE="$idx" ;;
ceph) MIRROR_SELECTED_CEPH="$idx" ;;
ct) MIRROR_SELECTED_CT="$idx" ;;
*) return 1 ;;
esac
}
mirror_reset_selection() {
MIRROR_SELECTED_DEBIAN=-1
MIRROR_SELECTED_SECURITY=-1
MIRROR_SELECTED_PVE=-1
MIRROR_SELECTED_CEPH=-1
MIRROR_SELECTED_CT=-1
}
mirror_selection_complete() {
[[ "$MIRROR_SELECTED_DEBIAN" =~ ^[0-9]+$ && "$MIRROR_SELECTED_DEBIAN" -ge 0 ]] || return 1
[[ "$MIRROR_SELECTED_SECURITY" =~ ^[0-9]+$ && "$MIRROR_SELECTED_SECURITY" -ge 0 ]] || return 1
[[ "$MIRROR_SELECTED_PVE" =~ ^[0-9]+$ && "$MIRROR_SELECTED_PVE" -ge 0 ]] || return 1
[[ "$MIRROR_SELECTED_CEPH" =~ ^[0-9]+$ && "$MIRROR_SELECTED_CEPH" -ge 0 ]] || return 1
[[ "$MIRROR_SELECTED_CT" =~ ^[0-9]+$ && "$MIRROR_SELECTED_CT" -ge 0 ]] || return 1
}
mirror_selected_index_by_type() {
local source_type="$1"
case "$source_type" in
debian) echo "$MIRROR_SELECTED_DEBIAN" ;;
security) echo "$MIRROR_SELECTED_SECURITY" ;;
pve) echo "$MIRROR_SELECTED_PVE" ;;
ceph) echo "$MIRROR_SELECTED_CEPH" ;;
ct) echo "$MIRROR_SELECTED_CT" ;;
*) echo "-1" ;;
esac
}
mirror_source_label() {
local source_type="$1"
case "$source_type" in
debian) echo "Debian 软件源" ;;
security) echo "Debian Security 安全源" ;;
pve) echo "Proxmox VE no-subscription 源" ;;
ceph) echo "Ceph Squid 源" ;;
ct) echo "CT 模板源" ;;
*) echo "$source_type" ;;
esac
}
mirror_print_selection_summary() {
echo "$UI_DIVIDER"
echo -e "${CYAN}当前镜像源配置:${NC}"
local source_type idx uri
for source_type in debian security pve ceph ct; do
idx="$(mirror_selected_index_by_type "$source_type")"
if [[ "$idx" =~ ^[0-9]+$ && "$idx" -ge 0 ]]; then
uri="$(mirror_uri_by_type "$source_type" "$idx")"
[[ -n "$uri" ]] || uri="官方源兜底"
printf " %-12s %-24s %s\n" "$(mirror_source_label "$source_type"):" "${MIRROR_NAMES[$idx]}" "$uri"
else
printf " %-12s %s\n" "$(mirror_source_label "$source_type"):" "未选择"
fi
done
echo "$UI_DIVIDER"
}
mirror_print_recommendation_notice() {
echo -e " ${YELLOW}推荐优先选择靠前的镜像源。地区性高校源可能会起到反向作用,例如:同步慢、高峰期丢包、随时不可用等。${NC}"
echo -e " ${YELLOW}如使用生产环境,请优先使用官方源或商业源(阿里云/腾讯云)。${NC}"
echo "$UI_DIVIDER"
}
select_mirror_for_source() {
local source_type="$1"
local label="$2"
local selected_var="$3"
local candidates=()
local idx uri pick selected_idx
while true; do
clear
show_menu_header "$label"
mirror_print_recommendation_notice
candidates=()
for idx in "${!MIRROR_NAMES[@]}"; do
uri="$(mirror_uri_by_type "$source_type" "$idx")"
[[ -n "$uri" ]] || continue
candidates+=("$idx")
printf " ${PRIMARY}%-3s${NC}. %-28s %s\n" "${#candidates[@]}" "${MIRROR_NAMES[$idx]}" "$uri"
done
echo "$UI_DIVIDER"
show_menu_option "0" "返回"
show_menu_footer
read -p "请选择 $label [0-${#candidates[@]}]: " pick
pick="${pick:-0}"
[[ "$pick" == "0" ]] && return 1
if [[ ! "$pick" =~ ^[0-9]+$ ]] || (( pick < 1 || pick > ${#candidates[@]} )); then
log_error "无效选择,请重新输入"
pause_function
continue
fi
selected_idx="${candidates[$((pick - 1))]}"
mirror_set_selected "$source_type" "$selected_idx" || return 1
[[ -n "$selected_var" ]] && printf -v "$selected_var" '%s' "$selected_idx"
log_success "$label 已选择: ${MIRROR_NAMES[$selected_idx]}"
return 0
done
}
select_mirror_unified() {
local candidates=()
local idx pick selected_idx
while true; do
clear
show_menu_header "全部使用同一镜像"
mirror_print_recommendation_notice
echo " 仅展示同时支持 Debian / Security / PVE / Ceph / CT 的镜像。"
echo "$UI_DIVIDER"
candidates=()
for idx in "${!MIRROR_NAMES[@]}"; do
[[ -n "${MIRROR_DEBIAN_URIS[$idx]}" ]] || continue
[[ -n "${MIRROR_SECURITY_URIS[$idx]}" ]] || continue
[[ -n "${MIRROR_PVE_URIS[$idx]}" ]] || continue
[[ -n "${MIRROR_CEPH_URIS[$idx]}" ]] || continue
[[ -n "${MIRROR_CT_URIS[$idx]}" ]] || continue
candidates+=("$idx")
printf " ${PRIMARY}%-3s${NC}. %s\n" "${#candidates[@]}" "${MIRROR_NAMES[$idx]}"
done
echo "$UI_DIVIDER"
show_menu_option "0" "返回"
show_menu_footer
read -p "请选择统一镜像 [0-${#candidates[@]}]: " pick
pick="${pick:-0}"
[[ "$pick" == "0" ]] && return 1
if [[ ! "$pick" =~ ^[0-9]+$ ]] || (( pick < 1 || pick > ${#candidates[@]} )); then
log_error "无效选择,请重新输入"
pause_function
continue
fi
selected_idx="${candidates[$((pick - 1))]}"
MIRROR_SELECTED_DEBIAN="$selected_idx"
MIRROR_SELECTED_SECURITY="$selected_idx"
MIRROR_SELECTED_PVE="$selected_idx"
MIRROR_SELECTED_CEPH="$selected_idx"
MIRROR_SELECTED_CT="$selected_idx"
mirror_print_selection_summary
return 0
done
}
select_mirror_per_source() {
mirror_reset_selection
select_mirror_for_source debian "Debian 软件源" || { mirror_reset_selection; return 1; }
select_mirror_for_source security "Debian Security 安全源" || { mirror_reset_selection; return 1; }
select_mirror_for_source pve "Proxmox VE no-subscription 源" || { mirror_reset_selection; return 1; }
select_mirror_for_source ceph "Ceph Squid 源" || { mirror_reset_selection; return 1; }
select_mirror_for_source ct "CT 模板源" || { mirror_reset_selection; return 1; }
clear
show_menu_header "镜像源选择摘要"
mirror_print_selection_summary
read -p "确认使用以上配置?输入 yes 确认,其他任意键返回: " confirm
[[ "$confirm" == "yes" || "$confirm" == "YES" ]] || { mirror_reset_selection; return 1; }
}
select_mirror() {
while true; do
clear
show_menu_header "镜像源配置"
mirror_print_recommendation_notice
show_menu_option "1" "全部使用同一镜像(快速)"
show_menu_option "2" "按源类型分别选择(推荐)"
show_menu_option "3" "跳过(稍后在菜单中配置)"
echo "$UI_DIVIDER"
echo " 跳过时不会写入软件源;再次执行更换软件源会重新进入本配置。"
show_menu_footer
local choice
read -p "请选择 [1-3]: " choice
case "$choice" in
1) select_mirror_unified && return 0 ;;
2) select_mirror_per_source && return 0 ;;
3) mirror_reset_selection; log_info "已跳过镜像源配置"; return 1 ;;
*) log_error "无效选择,请重新输入"; pause_function ;;
esac
done
}
# 版本检查函数

234
lib/runtime.sh Normal file
View File

@@ -0,0 +1,234 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
check_root() {
if [[ $EUID -ne 0 ]]; then
log_error "哎呀!需要超级管理员权限才能运行哦"
echo "请使用以下命令重新运行:"
echo "sudo $0"
exit 1
fi
}
# 检查调试模式
check_debug_mode() {
for arg in "$@"; do
if [[ "$arg" == "--i-know-what-i-do" ]]; then
RISK_ACK_BYPASS=true
fi
done
for arg in "$@"; do
if [[ "$arg" == "--debug" ]]; then
log_warn "警告:您正在使用调试模式!"
echo "此模式将跳过 PVE 系统版本检测"
echo "仅在开发和测试环境中使用"
echo "在非 PVE (Debian 系) 系统上使用可能导致系统损坏"
echo "您确定要继续吗?输入 'yes' 确认,其他任意键退出: "
read -r confirm
if [[ "$confirm" != "yes" ]]; then
log_info "已取消操作,退出脚本"
exit 0
fi
DEBUG_MODE=true
log_success "已启用调试模式"
return
fi
done
DEBUG_MODE=false
}
# 检查是否安装依赖软件包
check_packages() {
# 程序依赖的软件包: `sudo` `curl`
local packages=("sudo" "curl")
for pkg in "${packages[@]}"; do
if ! command -v "$pkg" &> /dev/null; then
log_error "哎呀!需要安装 $pkg 软件包才能运行哦"
echo "请使用以下命令安装apt install -y $pkg"
exit 1
fi
done
}
# 检查 PVE 版本
check_pve_version() {
# 如果在调试模式下,跳过 PVE 版本检测
if [[ "$DEBUG_MODE" == "true" ]]; then
log_warn "调试模式:跳过 PVE 版本检测"
echo "请注意:您正在非 PVE 系统上运行此脚本,某些功能可能无法正常工作,某些操作可能会导致系统损坏,请谨慎使用!"
PVE_VERSION_DETECTED="debug"
PVE_MAJOR_VERSION="debug"
return
fi
if ! command -v pveversion &> /dev/null; then
log_error "咦?这里好像不是 PVE 环境呢"
echo "请在 Proxmox VE 系统上运行此脚本"
exit 1
fi
local pve_version pkg_ver out
out="$(pveversion 2>/dev/null || true)"
if [[ "$out" =~ pve-manager/([0-9]+(\.[0-9]+)*) ]]; then
pve_version="${BASH_REMATCH[1]}"
else
pve_version=""
fi
if [[ -z "$pve_version" ]] && command -v dpkg-query >/dev/null 2>&1; then
pkg_ver="$(dpkg-query -W -f='${Version}' pve-manager 2>/dev/null || true)"
pve_version="$(echo "$pkg_ver" | grep -oE '^[0-9]+(\.[0-9]+)*' | head -n 1)"
fi
if [[ -z "$pve_version" ]]; then
pve_version="unknown"
fi
PVE_VERSION_DETECTED="$pve_version"
if [[ "$pve_version" =~ ^[0-9]+(\.[0-9]+)*$ ]]; then
PVE_MAJOR_VERSION="$(echo "$pve_version" | cut -d'.' -f1)"
else
PVE_MAJOR_VERSION="unknown"
fi
log_info "太好了!检测到 PVE 版本: $pve_version"
if [[ "$PVE_MAJOR_VERSION" != "9" && "$RISK_ACK_BYPASS" != "true" ]]; then
clear
show_menu_header "高风险提示:非 PVE9 环境"
echo -e "${RED}警告:检测到当前不是 PVE 9.x当前${PVE_VERSION_DETECTED})。${NC}"
echo -e "${RED}本脚本面向 PVE 9.xDebian 13 / trixie编写。${NC}"
echo -e "${RED}在 PVE 7/8 等系统上执行“换源/升级/一键优化”等自动化修改,可能是毁灭性的:${NC}"
echo -e "${RED}可能导致软件源错配、系统升级路径错误、依赖冲突、宿主机不可用。${NC}"
echo -e "${UI_DIVIDER}"
echo -e "${YELLOW}严禁在非 PVE9 上使用的选项(脚本将强制拦截):${NC}"
echo -e " - 一键优化(换源+删弹窗+更新)"
echo -e " - 软件源与更新(更换软件源/更新系统软件包/PVE 8 升级到 9"
echo -e "${UI_DIVIDER}"
echo -e "${CYAN}如你仍要继续使用脚本的其它功能,请手动输入以下任意一项以确认风险:${NC}"
echo -e " - 确认"
echo -e " - Confirm with Risks"
echo -e "${UI_DIVIDER}"
local ack ack_lc
read -r -p "请输入确认文本以继续(回车退出): " ack
if [[ -z "$ack" ]]; then
log_info "未确认风险,退出脚本"
exit 0
fi
ack_lc="$(echo "$ack" | tr 'A-Z' 'a-z' | sed -E 's/[[:space:]]+/ /g' | sed -E 's/^ +| +$//g')"
if [[ "$ack" != "确认" && "$ack_lc" != "confirm with risks" ]]; then
log_error "确认文本不匹配,已退出"
exit 1
fi
log_warn "已确认风险:当前为非 PVE9 环境,将拦截毁灭性自动化修改功能"
fi
}
block_non_pve9_destructive() {
local feature="$1"
if [[ "$DEBUG_MODE" == "true" ]]; then
return 0
fi
if [[ "$RISK_ACK_BYPASS" == "true" ]]; then
return 0
fi
if [[ "${PVE_MAJOR_VERSION:-}" != "9" ]]; then
display_error "已拦截:非 PVE9 环境禁止执行该自动化操作" "功能:${feature}。请在 PVE9 上使用,或手动参考文档/自行处理。如需强制执行,请加启动参数 --i-know-what-i-do"
return 1
fi
return 0
}
show_menu() {
show_banner
show_menu_option "" "请选择您需要的功能:"
show_menu_option "1" "日常优化与通知 ${CYAN}( 弹窗 / 温度 / 电源 / 邮件 )${NC}"
show_menu_option "2" "软件源与系统升级 ${CYAN}( 换源 / 更新 / PVE升级 )${NC}"
show_menu_option "3" "启动与内核管理 ${CYAN}( 内核 / GRUB )${NC}"
show_menu_option "4" "硬件直通与显卡 ${CYAN}( IOMMU / GPU / RDM / NVMe )${NC}"
show_menu_option "5" "虚拟机运维与导入 ${CYAN}( 镜像 / 备份 / 恢复 / Cloud-Init )${NC}"
show_menu_option "6" "宿主机网络与防火墙 ${CYAN}( 网桥 / VLAN / Bond / 规则 )${NC}"
show_menu_option "7" "存储与磁盘维护 ${CYAN}( 路径 / 挂载 / 清理 / Ceph )${NC}"
show_menu_option "8" "诊断工具与项目信息 ${CYAN}( 系统信息 / 救砖 / 脚本管理 )${NC}"
show_menu_option "9" "安全中心 ${CYAN}( 风险检查 / SSH加固 )${NC}"
show_menu_option "10" "第三方工具 ${CYAN}( CoolerControl / Modules / 社区脚本 )${NC}"
echo "$UI_DIVIDER"
show_menu_option "0" "${RED}退出脚本${NC}"
show_menu_footer
echo
echo -e " ${YELLOW}Tips: ${SESSION_TIP:-一言获取失败,本次会话不再重试。}${NC}"
echo
echo -ne " ${PRIMARY}请输入您的选择 [0-10]: ${NC}"
}
# 应急救砖工具箱菜单
main() {
check_root
ensure_legal_acceptance
check_debug_mode "$@"
check_pve_version
network_offline_guard
if [[ "$IS_OFFLINE_MODE" -eq 0 ]]; then
detect_network_region >/dev/null 2>&1 || true
fi
fetch_session_tip
if [[ "$IS_OFFLINE_MODE" -eq 1 ]]; then
log_warn "离线模式下将跳过更新检查与镜像自动策略。"
else
check_update
fi
while true; do
show_menu
read -r choice
echo
case $choice in
1)
menu_optimization
;;
2)
menu_sources_updates
;;
3)
menu_boot_kernel
;;
4)
menu_gpu_passthrough
;;
5)
menu_vm_container
;;
6)
menu_host_networking
;;
7)
menu_storage_disk
;;
8)
menu_tools_about
;;
9)
security_center_menu
;;
10)
third_party_tools_menu
;;
0)
echo "感谢使用,谢谢喵"
echo "再见!"
exit 0
;;
*)
log_error "哎呀,这个选项不存在呢"
log_warn "请输入 0-10 之间的数字"
;;
esac
echo
pause_function
done
}

264
src/CLAUDE.md Normal file
View File

@@ -0,0 +1,264 @@
[根目录](../CLAUDE.md) > [lib](../lib/) > **src/modules**
# src/modules -- 功能模块层
## 模块职责
承载 PVE Tools Pro 全部业务逻辑,按主菜单编号拆分为 10 个子模块。每个子模块的 `init.sh` 为菜单入口函数,其余文件按功能领域进一步拆分。所有脚本由 `PVE-Tools.sh``dev.sh` 在 lib/ 加载完成后统一 source。
## 入口与启动
| 项目 | 说明 |
|---|---|
| 加载方式 | `find src/modules -name '*.sh' -print0 | sort -z` 递归加载 |
| 加载顺序 | 按文件路径名排序(`sort -z`),同一子目录内 `init.sh` 通常先加载 |
| 构建时 | `build.sh` 按同样顺序拼接到 `dist/PVE-Tools.sh` |
## 子模块概览
| 编号 | 目录 | 主菜单项 | 文件数 | 复杂度 |
|---|---|---|---|---|
| 1 | `01-optimization/` | 日常优化与通知 | 6 | 中等 |
| 2 | `02-sources/` | 软件源与系统升级 | 4 | 低 |
| 3 | `03-boot-kernel/` | 启动与内核管理 | 3 | 中等 |
| 4 | `04-gpu-passthrough/` | 硬件直通与显卡 | 12 | **高** |
| 5 | `05-vm-container/` | 虚拟机运维与导入 | 15 | **高** |
| 6 | `06-networking/` | 宿主机网络与防火墙 | 10 | **高** |
| 7 | `07-storage-disk/` | 存储与磁盘维护 | 6 | 中等 |
| 8 | `08-tools-about/` | 诊断工具与项目信息 | 3 | 低 |
| 9 | `09-security/` | 安全中心 | 3 | 中等 |
| 10 | `10-third-party/` | 第三方工具 | 4 | 低 |
---
## 01-optimization -- 日常优化与通知
**菜单入口**: `menu_optimization()` 定义于 `init.sh`
| 文件 | 核心函数 | 功能 |
|---|---|---|
| `init.sh` | `menu_optimization()` | 二级菜单:弹窗/优化/温度/电源/邮件 |
| `popup.sh` | `remove_subscription_popup()`, `restore_proxmoxlib()`, `reinstall_pve_webui_packages()` | 删除/恢复订阅弹窗 |
| `tune.sh` | `quick_setup()` | 一键优化(换源+删弹窗+更新) |
| `cpupower.sh` | `cpupower()`, `cpu_add()`, `cpu_del()`, `show_grub_config()`, `backup_grub_with_note()`, `list_grub_backups()`, `restore_grub_backup()` | CPU 电源模式 + GRUB 备份管理 |
| `temperature.sh` | `temp_monitoring_menu()` | 温度监控管理CPU/硬盘) |
| `email.sh` | `pve_mail_notification_setup()`, `pve_mail_send_test()`, `pve_mail_configure_postfix_smtp()` | 邮件通知配置SMTP/Postfix |
**依赖**: 仅依赖 `lib/`,无跨模块依赖。
---
## 02-sources -- 软件源与系统升级
**菜单入口**: `menu_sources_updates()` 定义于 `init.sh`
| 文件 | 核心函数 | 功能 |
|---|---|---|
| `init.sh` | `menu_sources_updates()` | 二级菜单:换源/更新/升级 |
| `mirrors.sh` | `change_sources()` | 更换软件源(修改 `/etc/apt/sources.list` |
| `update.sh` | `update_system()` | 系统软件包更新 |
| `upgrade-pve.sh` | `pve8_to_pve9_upgrade()` | PVE 8.x 升级到 9.x |
**依赖**: `lib/` 中的镜像选择函数(`select_mirror*` 系列)和 `block_non_pve9_destructive()`
---
## 03-boot-kernel -- 启动与内核管理
**菜单入口**: `menu_boot_kernel()` 定义于 `init.sh`
| 文件 | 核心函数 | 功能 |
|---|---|---|
| `init.sh` | `menu_boot_kernel()` | 二级菜单:内核管理/GRUB 配置 |
| `kernel.sh` | `kernel_management_menu()`, `install_kernel()`, `set_default_kernel()`, `remove_old_kernels()`, `sync_kernel_update()` | 内核安装/切换/清理/同步 |
| `grub.sh` | `update_grub_config()` | GRUB 配置更新 |
**依赖**: `lib/core.sh` 中的 `grub_add_param()``grub_remove_param()``backup_file()`
---
## 04-gpu-passthrough -- 硬件直通与显卡
**菜单入口**: `menu_gpu_passthrough()` 定义于 `init.sh`
**复杂度**: 最高模块之一12 个文件,约 200+ 个函数
| 文件 | 核心函数 | 功能 |
|---|---|---|
| `init.sh` | `menu_gpu_passthrough()`, `menu_disk_controller_passthrough()` | 主菜单 + 磁盘/控制器子菜单 |
| `iommu.sh` | `iommu_is_enabled()`, `enable_pass()`, `disable_pass()`, `hw_passth()`, `list_storage_controllers()` | IOMMU 基础设施与硬件直通一键配置 |
| `intel-sriov.sh` | `igpu_sriov_setup()` | Intel 核显 SR-IOV 虚拟化 |
| `intel-gvtg.sh` | `igpu_gvtg_setup()` | Intel 核显 GVT-g 虚拟化 |
| `intel-legacy.sh` | `intel_gpu_passthrough()`, `restore_qemu_kvm()` | Intel 核显直通(修改版 QEMU |
| `igpu-shared.sh` | `igpu_management_menu()`, `igpu_verify()`, `igpu_remove()`, `restore_igpu_config()` | iGPU 共享管理(被 intel-sriov/gvtg 复用) |
| `nvidia.sh` | `nvidia_gpu_management_menu()`, `nvidia_gpu_passthrough_vm()`, `nvidia_driver_switch_menu()`, `nvidia_setup_vgpu_unlock()` | NVIDIA 显卡直通/驱动管理/vGPU |
| `amd-dgpu.sh` | `amd_gpu_management_menu()`, `amd_gpu_passthrough_vm()`, `amd_host_prepare_for_passthrough()` | AMD 独显直通 |
| `amd-igpu.sh` | `amd_igpu_management_menu()`, `amd_igpu_passthrough_vm()`, `amd_igpu_check_romfile()` | AMD 核显直通(需 ROM/vBIOS |
| `rdm.sh` | `rdm_single_disk_attach()`, `rdm_single_disk_detach()`, `rdm_discover_whole_disks()` | RDM裸磁盘映射直通 |
| `controller.sh` | `storage_controller_passthrough()`, `nvme_passthrough()` | PCIe/NVMe 控制器直通 |
| `boot-assist.sh` | `boot_config_assistant()`, `detect_disk_boot_mode()` | 引导配置辅助GRUB/systemd-boot 检测) |
**跨文件内部依赖**:
- `nvidia.sh` 中的 `nvidia_select_vmid()``nvidia_get_pci_ids()` 等被 `amd-dgpu.sh``amd-igpu.sh` 复用
- `iommu.sh` 中的 `iommu_is_enabled()``rdm.sh``controller.sh` 等复用
---
## 05-vm-container -- 虚拟机运维与导入
**菜单入口**: `menu_vm_container()` 定义于 `init.sh`
**复杂度**: 最高模块之二15 个文件
| 文件 | 核心函数 | 功能 |
|---|---|---|
| `init.sh` | `menu_vm_container()`, `vm_advanced_operations_menu()` | 主菜单 + 高级运维子菜单 |
| `fastpve.sh` | `fastpve_quick_download_menu()` | FastPVE 快速下载 VM |
| `schedule.sh` | `manage_vm_schedule()` | VM 定时开关机 |
| `img-import.sh` | `img_convert_import_menu()`, `img_convert_and_import_to_vm()` | IMG 镜像导入(转 QCOW2/RAW |
| `storage-helper.sh` | `vm_select_storage_by_content()`, `pve_storage_content_path()`, `vm_list_storages_by_content()` | 存储辅助函数(被多文件复用) |
| `backup.sh` | `vm_backup_restore_menu()`, `vm_backup_create()`, `vm_schedule_backup_menu()` | VM 备份与定时备份 |
| `restore.sh` | `vm_restore_from_backup()`, `vm_config_io_menu()`, `vm_export_config()`, `vm_import_config()` | VM 恢复与配置导入导出 |
| `clone.sh` | `vm_clone_vm()`, `vm_convert_to_template()` | VM 克隆与模板转换 |
| `cloudinit.sh` | `vm_template_cloudinit_menu()`, `vm_cloudinit_configure()`, `vm_cloud_image_to_template()` | Cloud-Init 配置与云镜像模板 |
| `snapshot.sh` | `vm_snapshot_menu()`, `vm_create_snapshot()`, `vm_rollback_snapshot()` | 快照管理(创建/删除/回滚) |
| `disk.sh` | `vm_disk_management_menu()`, `vm_add_disk()`, `vm_remove_disk()`, `vm_resize_disk()`, `vm_move_disk()` | VM 磁盘管理 |
| `network.sh` | `vm_startup_network_menu()`, `vm_add_network()`, `vm_remove_network()`, `vm_modify_network()` | VM 网络管理 |
| `migrate.sh` | `vm_cluster_migrate()` | 集群内 VM 迁移 |
| `garbage-cleanup.sh` | `garbage_cleanup_menu()`, `garbage_cleanup_basic()`, `garbage_cleanup_prune_backups()`, `garbage_cleanup_orphan_disk_report()` | 垃圾清理(缓存/备份/快照/孤立磁盘) |
**跨文件内部依赖**: `storage-helper.sh` 中的存储查询函数被 `backup.sh``disk.sh``cloudinit.sh``restore.sh``migrate.sh` 广泛复用。
---
## 06-networking -- 宿主机网络与防火墙
**菜单入口**: `menu_host_networking()` 定义于 `init.sh`
**复杂度**: 最高模块之三10 个文件
| 文件 | 核心函数 | 功能 |
|---|---|---|
| `init.sh` | `menu_host_networking()`, `host_network_show_risk_banner()` | 主菜单 + 风险横幅 |
| `interface.sh` | `host_network_get_all_interface_names()`, `host_network_show_current_overview()`, `host_network_select_interface_name()` | 网卡接口基础查询与选择 |
| `addressing.sh` | `host_network_configure_interface_addressing()`, `host_network_build_*_stanza()` | IP 配置(静态 IPv4/IPv6/DHCP/SLAAC |
| `bridge.sh` | `host_network_create_bridge()`, `host_network_delete_bridge()`, `host_network_bridge_menu()` | 网桥管理 |
| `vlan.sh` | `host_network_create_vlan()`, `host_network_delete_vlan()`, `host_network_vlan_menu()` | VLAN 子接口管理 |
| `bond.sh` | `host_network_create_bond()`, `host_network_delete_bond()`, `host_network_bond_menu()` | Bond 管理mode 0/1/4/6 |
| `mac-bind.sh` | `host_network_create_mac_binding()`, `host_network_delete_mac_binding()`, `host_network_mac_binding_menu()` | MAC 地址绑定systemd link |
| `firewall.sh` | `host_firewall_menu()`, `host_firewall_add_security_group_rule()`, `host_firewall_export_ruleset()` | PVE 防火墙管理 |
| `ipv6-helper.sh` | `ipv6_helper_menu()`, `ipv6_helper_configure_passthrough()`, `ipv6_helper_configure_nat6()` | IPv6 助手(透传/NAT6/连通性测试) |
| `diagnostic.sh` | `netdiag_toolbox_menu()`, `netdiag_run_traceroute()`, `netdiag_quick_stack_check()` | 网络诊断工具箱traceroute/mtr/nmap/tcpdump |
**跨文件内部依赖**: `interface.sh` 中的接口查询函数被 `addressing.sh``bridge.sh``vlan.sh``bond.sh``mac-bind.sh``ipv6-helper.sh` 广泛复用。所有网络修改操作通过 `host_network_commit_candidate()` 统一提交。
---
## 07-storage-disk -- 存储与磁盘维护
**菜单入口**: `menu_storage_disk()` 定义于 `init.sh`
| 文件 | 核心函数 | 功能 |
|---|---|---|
| `init.sh` | `menu_storage_disk()` | 二级菜单7 个选项) |
| `query.sh` | `pve_storage_location_panel()` | 存储位置查询面板 |
| `mount.sh` | `pve_storage_mount_wizard()` | 磁盘挂载向导(复用 ext4/xfs 分区) |
| `local-lvm.sh` | `merge_local_storage()` | 合并 local 与 local-lvm |
| `ceph.sh` | `ceph_management_menu()`, `pve9_ceph()`, `remove_ceph()` | Ceph 管理(安装/卸载/换源) |
| `swap.sh` | `remove_swap()` | 删除 Swap 分区 |
**依赖**: 仅依赖 `lib/`,垃圾清理的 `garbage_cleanup_menu()` 实际位于 `05-vm-container/garbage-cleanup.sh`
---
## 08-tools-about -- 诊断工具与项目信息
**菜单入口**: `menu_tools_about()` 定义于 `init.sh`
| 文件 | 核心函数 | 功能 |
|---|---|---|
| `init.sh` | `menu_tools_about()`, `show_menu_rescue()` | 二级菜单 + 救砖工具箱 |
| `sysinfo.sh` | `show_system_info()` | 系统信息概览 |
| `self-update.sh` | `check_update()`, `pve_tools_local_update()`, `pve_tools_local_uninstall()` | 本地脚本更新/卸载 |
**依赖**: 仅依赖 `lib/`
---
## 09-security -- 安全中心
**菜单入口**: `security_center_menu()` 定义于 `init.sh`
| 文件 | 核心函数 | 功能 |
|---|---|---|
| `init.sh` | `security_center_menu()` | 二级菜单 |
| `audit.sh` | `security_risk_check()`, `security_list_public_listeners()` | 安全风险检查(只读报告) |
| `ssh-hardening.sh` | `security_ssh_hardening()`, `security_install_fail2ban_if_needed()` | SSH 一键加固(端口/密钥/fail2ban |
**依赖**: 仅依赖 `lib/`
---
## 10-third-party -- 第三方工具
**菜单入口**: `third_party_tools_menu()` 定义于 `init.sh`
| 文件 | 核心函数 | 功能 |
|---|---|---|
| `init.sh` | `third_party_tools_menu()` | 二级菜单 |
| `marketplace.sh` | `third_party_market_menu()` | 第三方软件市场Modules 插件) |
| `coolercontrol.sh` | `coolercontrol_manager_menu()`, `coolercontrol_install()`, `coolercontrol_uninstall()` | CoolerControl 风扇控制管理 |
| `community.sh` | `third_party_community_scripts_info()` | 社区脚本集合信息 |
**依赖**: 仅依赖 `lib/`
---
## 关键依赖与配置
- **lib/ 先决条件**: 所有模块均依赖 `lib/config.sh`(全局变量)和 `lib/core.sh`(日志/UI/备份函数)
- **模块间交叉依赖**(仅 04/05/06 存在):
- `04-gpu-passthrough`: nvidia.sh 的辅助函数被 amd-*.sh 复用iommu.sh 被多个子模块复用
- `05-vm-container`: storage-helper.sh 被 7 个同模块文件复用
- `06-networking`: interface.sh 被 6 个同模块文件复用
- **无跨模块依赖**: 模块 01/02/03/07/08/09/10 仅依赖 lib/,不存在模块间引用
## 测试与质量
- **语法检查**: `bash -n` 对每个 `src/modules/**/*.sh` 文件
- **静态分析**: `shellcheck` 覆盖所有模块文件CI 中强制通过)
- **构建验证**: `bash build.sh && bash -n dist/PVE-Tools.sh` 验证拼接正确性
- **功能测试**: 人工在 PVE 9.x 环境验证 `bash dev.sh`
## 常见问题 (FAQ)
**Q: 新增功能模块如何操作?**
1.`src/modules/` 下创建新目录(如 `11-new-feature/`
2. 编写 `init.sh`(菜单入口函数)和功能文件
3.`lib/runtime.sh``show_menu()``main()` 的 case 中添加对应选项
4. `build.sh` 会自动扫描并包含新文件
**Q: 为什么 init.sh 和其他文件分开?**
`init.sh` 仅包含菜单入口函数,功能实现放在独立文件中。这确保了单个文件不至于过大,也便于独立维护和测试各功能模块。
**Q: 跨文件函数调用如何处理 source 顺序?**
不需要显式 source。所有模块文件在入口处一次性全部 source`sort -z` 保证 `init.sh` 优先加载,因此同模块内的交叉引用天然可用。
## 相关文件清单
```
src/modules/
01-optimization/ (6 文件) -- 日常优化与通知
02-sources/ (4 文件) -- 软件源与系统升级
03-boot-kernel/ (3 文件) -- 启动与内核管理
04-gpu-passthrough/ (12 文件) -- 硬件直通与显卡
05-vm-container/ (15 文件) -- 虚拟机运维与导入
06-networking/ (10 文件) -- 宿主机网络与防火墙
07-storage-disk/ (6 文件) -- 存储与磁盘维护
08-tools-about/ (3 文件) -- 诊断工具与项目信息
09-security/ (3 文件) -- 安全中心
10-third-party/ (4 文件) -- 第三方工具
```
## 变更记录 (Changelog)
| 日期 | 变更 |
|---|---|
| 2026-07-08 | 初始化 src/modules 模块 CLAUDE.md模块化重构新增模块 |

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,250 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
pve_mail_send_test() {
local from_addr="$1"
local to_addr="$2"
local subject="$3"
local body="$4"
if ! command -v sendmail >/dev/null 2>&1; then
display_error "未找到 sendmail" "请确认 postfix 已安装并提供 sendmail。"
return 1
fi
{
echo "From: ${from_addr}"
echo "To: ${to_addr}"
echo "Subject: ${subject}"
echo
echo "${body}"
} | sendmail -f "${from_addr}" -t >/dev/null 2>&1
}
pve_mail_configure_postfix_smtp() {
local relay_host="$1"
local relay_port="$2"
local tls_mode="$3"
local sasl_user="$4"
local sasl_pass="$5"
if ! command -v postconf >/dev/null 2>&1; then
display_error "未找到 postconf" "请先安装 postfix 并确保其命令可用。"
return 1
fi
local relay
relay="[${relay_host}]:${relay_port}"
backup_file "/etc/postfix/main.cf" >/dev/null 2>&1 || true
postconf -e "relayhost = ${relay}"
postconf -e "smtp_use_tls = yes"
postconf -e "smtp_tls_security_level = encrypt"
postconf -e "smtp_sasl_auth_enable = yes"
postconf -e "smtp_sasl_security_options ="
postconf -e "smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd"
postconf -e "smtp_tls_CApath = /etc/ssl/certs"
postconf -e "smtp_tls_session_cache_database = btree:/var/lib/postfix/smtp_tls_session_cache"
postconf -e "smtp_tls_session_cache_timeout = 3600s"
if [[ "$tls_mode" == "wrapper" ]]; then
postconf -e "smtp_tls_wrappermode = yes"
else
postconf -e "smtp_tls_wrappermode = no"
fi
local sasl_file="/etc/postfix/sasl_passwd"
backup_file "$sasl_file" >/dev/null 2>&1 || true
umask 077
printf '%s %s:%s\n' "${relay}" "${sasl_user}" "${sasl_pass}" > "$sasl_file"
chmod 600 "$sasl_file" >/dev/null 2>&1 || true
if ! command -v postmap >/dev/null 2>&1; then
display_error "未找到 postmap" "请确认 postfix 已安装完整。"
return 1
fi
postmap "hash:${sasl_file}" >/dev/null 2>&1 || {
display_error "postmap 执行失败" "请检查 /etc/postfix/sasl_passwd 格式与权限。"
return 1
}
postfix reload >/dev/null 2>&1 || {
systemctl reload postfix >/dev/null 2>&1 || systemctl restart postfix >/dev/null 2>&1 || true
}
return 0
}
pve_mail_configure_datacenter_emails() {
local from_addr="$1"
local root_addr="$2"
if ! command -v pvesh >/dev/null 2>&1; then
display_error "未找到 pvesh" "请确认当前环境为 PVE 宿主机。"
return 1
fi
pvesh set /cluster/options --email-from "$from_addr" >/dev/null 2>&1 || {
display_error "设置“来自…邮件”失败" "请在 WebUI数据中心 -> 选项 -> 电子邮件From中手动设置。"
return 1
}
pvesh set /access/users/root@pam --email "$root_addr" >/dev/null 2>&1 || {
display_error "设置 root 邮箱失败" "请在 WebUI数据中心 -> 权限 -> 用户 -> root@pam 中手动设置邮箱。"
return 1
}
return 0
}
pve_mail_configure_zed_mail() {
local from_addr="$1"
local to_addr="$2"
local zed_rc="/etc/zfs/zed.d/zed.rc"
if [[ ! -f "$zed_rc" ]]; then
log_warn "未找到 zed.rc跳过 ZFS ZED 邮件配置)"
return 0
fi
backup_file "$zed_rc" >/dev/null 2>&1 || true
if grep -qE '^ZED_EMAIL_ADDR=' "$zed_rc"; then
sed -i "s|^ZED_EMAIL_ADDR=.*|ZED_EMAIL_ADDR=\"${to_addr}\"|g" "$zed_rc"
else
printf '\nZED_EMAIL_ADDR="%s"\n' "$to_addr" >> "$zed_rc"
fi
if grep -qE '^ZED_EMAIL_OPTS=' "$zed_rc"; then
sed -i "s|^ZED_EMAIL_OPTS=.*|ZED_EMAIL_OPTS=\"-r ${from_addr}\"|g" "$zed_rc"
else
printf 'ZED_EMAIL_OPTS="-r %s"\n' "$from_addr" >> "$zed_rc"
fi
systemctl restart zfs-zed >/dev/null 2>&1 || true
return 0
}
pve_mail_notification_setup() {
block_non_pve9_destructive "配置邮件通知SMTP" || return 1
log_step "配置 PVE 邮件通知(商业邮箱 SMTP"
if ! command -v postfix >/dev/null 2>&1 && ! command -v postconf >/dev/null 2>&1; then
display_error "未检测到 postfix" "请先安装 postfix 后再配置(安装过程可能需要交互)。"
return 1
fi
local from_addr root_addr
read -p "请输入“来自…邮件”(发件人邮箱): " from_addr
if [[ -z "$from_addr" ]]; then
display_error "发件人邮箱不能为空"
return 1
fi
read -p "请输入 root 通知邮箱(收件人邮箱): " root_addr
if [[ -z "$root_addr" ]]; then
display_error "收件人邮箱不能为空"
return 1
fi
local preset
echo -e "${CYAN}请选择 SMTP 预设:${NC}"
echo " 1) QQ 邮箱smtp.qq.com:465 SSL"
echo " 2) 163 邮箱smtp.163.com:465 SSL"
echo " 3) Gmailsmtp.gmail.com:587 STARTTLS"
echo " 4) 自定义SMTP 兼容)"
read -p "请选择 [1-4] (默认: 1): " preset
preset="${preset:-1}"
local smtp_host smtp_port tls_mode
case "$preset" in
1) smtp_host="smtp.qq.com"; smtp_port="465"; tls_mode="wrapper" ;;
2) smtp_host="smtp.163.com"; smtp_port="465"; tls_mode="wrapper" ;;
3) smtp_host="smtp.gmail.com"; smtp_port="587"; tls_mode="starttls" ;;
4)
read -p "请输入 SMTP 服务器地址(如 smtp.xxx.com: " smtp_host
read -p "请输入 SMTP 端口(如 465/587: " smtp_port
read -p "TLS 模式wrapper/starttls[wrapper]: " tls_mode
tls_mode="${tls_mode:-wrapper}"
;;
*) smtp_host="smtp.qq.com"; smtp_port="465"; tls_mode="wrapper" ;;
esac
if [[ -z "$smtp_host" || -z "$smtp_port" ]]; then
display_error "SMTP 参数不完整"
return 1
fi
if [[ "$tls_mode" != "wrapper" && "$tls_mode" != "starttls" ]]; then
display_error "TLS 模式无效" "仅支持 wrapper 或 starttls"
return 1
fi
local smtp_user smtp_pass
read -p "请输入 SMTP 登录账号(通常为邮箱地址)[${from_addr}]: " smtp_user
smtp_user="${smtp_user:-$from_addr}"
if [[ -z "$smtp_user" ]]; then
display_error "SMTP 账号不能为空"
return 1
fi
echo -n "请输入 SMTP 密码/授权码(输入不回显): "
read -r -s smtp_pass
echo
if [[ -z "$smtp_pass" ]]; then
display_error "SMTP 密码/授权码不能为空"
return 1
fi
clear
show_menu_header "邮件通知配置确认"
echo -e "${YELLOW}发件人From:${NC} $from_addr"
echo -e "${YELLOW}收件人root 邮箱):${NC} $root_addr"
echo -e "${YELLOW}SMTP 服务器:${NC} ${smtp_host}:${smtp_port}"
echo -e "${YELLOW}TLS 模式:${NC} ${tls_mode}"
echo -e "${YELLOW}SMTP 账号:${NC} ${smtp_user}"
echo -e "${UI_DIVIDER}"
echo -e "${RED}提醒:此功能会修改 postfix 配置并写入 SMTP 凭据文件。${NC}"
echo -e "${RED}请确保你使用的是邮箱提供商的 SMTP 授权码/应用专用密码,而非登录密码。${NC}"
echo -e "${UI_DIVIDER}"
if ! confirm_action "开始应用配置并重载 postfix"; then
return 0
fi
log_step "配置 PVE 数据中心邮件选项"
pve_mail_configure_datacenter_emails "$from_addr" "$root_addr" || return 1
log_step "安装 SASL 模块libsasl2-modules"
apt-get update >/dev/null 2>&1 || true
if ! apt-get install -y libsasl2-modules >/dev/null 2>&1; then
display_error "安装 libsasl2-modules 失败" "请检查网络与软件源。"
return 1
fi
log_step "配置 postfix 通过 SMTP 中继发信"
pve_mail_configure_postfix_smtp "$smtp_host" "$smtp_port" "$tls_mode" "$smtp_user" "$smtp_pass" || return 1
local test_choice="yes"
read -p "是否发送测试邮件?(yes/no) [yes]: " test_choice
test_choice="${test_choice:-yes}"
if [[ "$test_choice" == "yes" || "$test_choice" == "YES" ]]; then
log_step "发送测试邮件"
if pve_mail_send_test "$from_addr" "$root_addr" "PVE-Tools 邮件测试" "这是一封测试邮件:如果你收到,说明 SMTP 中继已可用。"; then
log_success "测试邮件已提交发送队列(请检查收件箱与垃圾箱)"
else
log_warn "测试邮件发送失败,请检查 postfix 日志与 SMTP 配置"
log_tips "可查看journalctl -u postfix -n 200 或 tail -n 200 /var/log/mail.log"
fi
fi
local zed_choice="no"
read -p "是否额外配置 ZFS ZED 邮件ZFS 阵列事件通知)?(yes/no) [no]: " zed_choice
zed_choice="${zed_choice:-no}"
if [[ "$zed_choice" == "yes" || "$zed_choice" == "YES" ]]; then
log_step "配置 ZFS ZED 邮件参数"
pve_mail_configure_zed_mail "$from_addr" "$root_addr" || true
log_success "ZED 配置已处理(建议手动制造一次 ZFS 事件验证)"
fi
display_success "邮件通知配置完成" "建议在 WebUI 里触发一次通知或检查系统事件确认生效。"
return 0
}
# 获取已安装的 PVE 内核包(兼容 pve-kernel / proxmox-kernel 以及 -signed 后缀)

View File

@@ -0,0 +1,33 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
menu_optimization() {
while true; do
clear
echo "功能 1/2 请在外部SSH环境下使用该功能否则会导致PVE WebUi重启导致Shell断开连接修改失效"
echo "不要犟查看如何连接到PVE SSH教程https://pve.oowo.cc/advanced/how-to-connect-ssh.html"
show_menu_header "系统优化"
show_menu_option "1" "删除订阅弹窗"
show_menu_option "2" "${MAGENTA}一键优化 (换源+删弹窗+更新)${NC}"
show_menu_option "3" "温度监控管理 ${CYAN}(CPU/硬盘监控设置)${NC}"
show_menu_option "4" "CPU 电源模式配置"
show_menu_option "5" "配置邮件通知 ${CYAN}(SMTP/Postfix)${NC}"
echo "$UI_DIVIDER"
show_menu_option "0" "返回主菜单"
show_menu_footer
read -p "请选择操作 [0-6]: " choice
case $choice in
1) remove_subscription_popup ;;
2) quick_setup ;;
3) temp_monitoring_menu ;;
4) cpupower ;;
5) pve_mail_notification_setup ;;
0) return ;;
*) log_error "无效选择" ;;
esac
pause_function
done
}
# 二级菜单:软件源与更新

View File

@@ -0,0 +1,63 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
remove_subscription_popup() {
block_non_pve9_destructive "删除订阅弹窗" || return 1
log_step "正在消除那个烦人的订阅弹窗"
local js_file="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js"
if [[ -f "$js_file" ]]; then
backup_file "$js_file"
# 修复逻辑:
# 新版 PVE 的 proxmoxlib.js 在 Ext.Msg.show 调用前有大量换行和空格
# 原有的 sed 正则 "Ext.Msg.show\(\{\s+title" 可能因为换行符匹配失败
# 新方案:直接将判断条件中的 !== 'active' 改为 == 'active',从逻辑上短路
# 匹配模式res.data.status.toLowerCase() !== 'active'
# 这种方式比替换 Ext.Msg.show 更稳定,且代码侵入性更小
if grep -q "res.data.status.toLowerCase() !== 'active'" "$js_file"; then
sed -i "s/res.data.status.toLowerCase() !== 'active'/res.data.status.toLowerCase() == 'active'/g" "$js_file"
log_success "策略A生效修改了判断逻辑"
elif grep -q "Ext.Msg.show({" "$js_file"; then
# 备用方案:如果找不到特定判断逻辑,尝试旧方法的宽泛匹配,但增强兼容性
# 使用 perl 替代 sed 以更好地支持多行匹配
perl -i -0777 -pe "s/(Ext\.Msg\.show\(\{\s+title: gettext\('No valid sub)/void\(\{ \/\/\1/g" "$js_file"
log_success "策略B生效屏蔽了弹窗函数"
else
log_error "未找到匹配的代码片段,可能文件版本已更新"
return 1
fi
systemctl restart pveproxy.service
log_success "完美!再也不会有烦人的弹窗啦"
else
log_warn "咦?没找到弹窗文件,可能已经被处理过了"
fi
}
reinstall_pve_webui_packages() {
log_step "正在重新安装官方 Web UI 相关软件包"
if apt-get install --reinstall -y pve-manager proxmox-widget-toolkit; then
systemctl restart pveproxy.service
log_success "官方 Web UI 文件已恢复"
return 0
fi
log_error "重新安装失败请检查软件源或网络后重试apt-get install --reinstall -y pve-manager proxmox-widget-toolkit"
return 1
}
# 恢复 proxmoxlib.js 文件
restore_proxmoxlib() {
log_step "准备恢复官方 Web UI 文件"
log_warn "此操作会重新安装 pve-manager 和 proxmox-widget-toolkit并覆盖当前前端补丁"
if ! confirm_action "确定要恢复官方 Web UI 文件吗?"; then
return
fi
reinstall_pve_webui_packages
}
# 合并 local 与 local-lvm

View File

@@ -0,0 +1,45 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
temp_monitoring_menu() {
while true; do
clear
show_menu_header "温度监控管理"
show_menu_option "1" "配置温度监控 ${CYAN}(CPU/硬盘温度显示)${NC}"
show_menu_option "2" "${RED}移除温度监控${NC} (移除温度监控功能)"
show_menu_option "3" "UPS 状态诊断 ${CYAN}(NUT / upsc)${NC}"
echo "${UI_DIVIDER}"
show_menu_option "0" "返回上级菜单"
show_menu_footer
echo
read -p "请选择 [0-3]: " temp_choice
echo
case $temp_choice in
1)
cpu_add
;;
2)
cpu_del
;;
3)
show_ups_diagnostics
;;
0)
break
;;
*)
log_error "无效选择,请重新输入"
;;
esac
echo
pause_function
done
}
# 自定义温度监控配置
# 已经死了。
# Ceph管理菜单

View File

@@ -0,0 +1,20 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
quick_setup() {
block_non_pve9_destructive "一键优化(换源+删弹窗+更新)" || return 1
log_step "开始一键配置"
log_step "天涯若比邻,海内存知己,坐和放宽,让我来搞定一切。"
echo
change_sources || return 1
echo
remove_subscription_popup || return 1
echo
update_system || return 1
echo
log_success "一键配置全部完成!您的 PVE 已经完美优化"
echo -e "现在您可以愉快地使用 PVE 了!"
}
# 通用UI函数

View File

@@ -0,0 +1,27 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
menu_sources_updates() {
while true; do
clear
show_menu_header "软件源与更新"
show_menu_option "1" "更换软件源"
show_menu_option "2" "更新系统软件包"
show_menu_option "3" "${YELLOW}PVE 8.x 升级到 PVE 9.x${NC}"
echo "$UI_DIVIDER"
show_menu_option "0" "返回主菜单"
show_menu_footer
read -p "请选择操作 [0-3]: " choice
case $choice in
1) change_sources ;;
2) update_system ;;
3) pve8_to_pve9_upgrade ;;
0) return ;;
*) log_error "无效选择" ;;
esac
pause_function
done
}
# 二级菜单:启动与内核

View File

@@ -0,0 +1,113 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
change_sources() {
block_non_pve9_destructive "更换软件源" || return 1
log_step "开始为您的 PVE 换上飞速源"
if ! mirror_selection_complete; then
select_mirror || return 1
fi
local debian_mirror="${MIRROR_DEBIAN_URIS[$MIRROR_SELECTED_DEBIAN]}"
local debian_security_mirror="${MIRROR_SECURITY_URIS[$MIRROR_SELECTED_SECURITY]}"
local pve_mirror="${MIRROR_PVE_URIS[$MIRROR_SELECTED_PVE]}"
local ceph_mirror="${MIRROR_CEPH_URIS[$MIRROR_SELECTED_CEPH]}"
local ct_mirror="${MIRROR_CT_URIS[$MIRROR_SELECTED_CT]}"
[[ -z "$debian_mirror" ]] && debian_mirror="https://deb.debian.org/debian"
[[ -z "$debian_security_mirror" ]] && debian_security_mirror="https://security.debian.org/debian-security"
[[ -z "$pve_mirror" ]] && pve_mirror="http://download.proxmox.com/debian/pve"
[[ -z "$ceph_mirror" ]] && ceph_mirror="http://download.proxmox.com/debian/ceph-squid"
[[ -z "$ct_mirror" ]] && ct_mirror="http://download.proxmox.com"
log_info "镜像源配置:"
log_info " Debian: $debian_mirror"
log_info " Security: $debian_security_mirror"
log_info " PVE: $pve_mirror"
log_info " Ceph: $ceph_mirror"
log_info " CT 模板: $ct_mirror"
# 1. 更换 Debian 软件源 (DEB822 格式)
log_info "正在配置 Debian 镜像源..."
backup_file "/etc/apt/sources.list.d/debian.sources"
cat > /etc/apt/sources.list.d/debian.sources << EOF
Types: deb
URIs: $debian_mirror
Suites: trixie trixie-updates trixie-backports
Components: main contrib non-free non-free-firmware
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
# 默认注释了源码镜像以提高 apt update 速度,如有需要可自行取消注释
# Types: deb-src
# URIs: $debian_mirror
# Suites: trixie trixie-updates trixie-backports
# Components: main contrib non-free non-free-firmware
# Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
# 以下安全更新软件源包含了官方源与镜像站配置,如有需要可自行修改注释切换
Types: deb
URIs: $debian_security_mirror
Suites: trixie-security
Components: main contrib non-free non-free-firmware
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
# Types: deb-src
# URIs: $debian_security_mirror
# Suites: trixie-security
# Components: main contrib non-free non-free-firmware
# Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
EOF
# 2. 注释企业源
log_info "正在关闭企业源(我们用免费版就够啦)..."
if [[ -f "/etc/apt/sources.list.d/pve-enterprise.sources" ]]; then
backup_file "/etc/apt/sources.list.d/pve-enterprise.sources"
sed -i 's/^Types:/#Types:/g' /etc/apt/sources.list.d/pve-enterprise.sources
sed -i 's/^URIs:/#URIs:/g' /etc/apt/sources.list.d/pve-enterprise.sources
sed -i 's/^Suites:/#Suites:/g' /etc/apt/sources.list.d/pve-enterprise.sources
sed -i 's/^Components:/#Components:/g' /etc/apt/sources.list.d/pve-enterprise.sources
sed -i 's/^Signed-By:/#Signed-By:/g' /etc/apt/sources.list.d/pve-enterprise.sources
fi
# 3. 更换 Ceph 源
log_info "正在配置 Ceph 镜像源..."
if [[ -f "/etc/apt/sources.list.d/ceph.sources" ]]; then
backup_file "/etc/apt/sources.list.d/ceph.sources"
cat > /etc/apt/sources.list.d/ceph.sources << EOF
Types: deb
URIs: $ceph_mirror
Suites: trixie
Components: no-subscription
Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg
EOF
fi
# 4. 添加无订阅源
log_info "正在添加免费版专用源..."
cat > /etc/apt/sources.list.d/pve-no-subscription.sources << EOF
Types: deb
URIs: $pve_mirror
Suites: trixie
Components: pve-no-subscription
Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg
EOF
# 5. 更换 CT 模板源
log_info "正在加速 CT 模板下载..."
if [[ -f "/usr/share/perl5/PVE/APLInfo.pm" ]]; then
backup_file "/usr/share/perl5/PVE/APLInfo.pm"
local known_ct_uri
for known_ct_uri in "${MIRROR_CT_URIS[@]}"; do
[[ -n "$known_ct_uri" && "$known_ct_uri" != "http://download.proxmox.com" ]] || continue
sed -i "s|$known_ct_uri|http://download.proxmox.com|g" /usr/share/perl5/PVE/APLInfo.pm
done
sed -i "s|http://download.proxmox.com|$ct_mirror|g" /usr/share/perl5/PVE/APLInfo.pm
fi
log_success "软件源配置已完成"
}
# 删除订阅弹窗

View File

@@ -0,0 +1,21 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
update_system() {
block_non_pve9_destructive "更新系统软件包" || return 1
log_step "开始更新系统,让 PVE 保持最新状态 📦"
echo -e "${CYAN}正在更新软件包列表...${NC}"
apt update
echo -e "${CYAN}正在升级系统软件包...${NC}"
apt upgrade -y
echo -e "${CYAN}正在清理不需要的软件包...${NC}"
apt autoremove -y
log_success "系统更新完成!您的 PVE 现在是最新版本"
}
# 标准化暂停函数

View File

@@ -0,0 +1,245 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
pve8_to_pve9_upgrade() {
block_non_pve9_destructive "PVE 8.x 升级到 PVE 9.x" || return 1
log_step "开始 PVE 8.x 升级到 PVE 9.x"
# 检查当前 PVE 版本
local current_pve_version=$(pveversion | head -n1 | cut -d'/' -f2 | cut -d'-' -f1)
local major_version=$(echo $current_pve_version | cut -d'.' -f1)
if [[ "$major_version" != "8" ]]; then
log_error "当前 PVE 版本为 $current_pve_version,不是 PVE 8.x 版本,无法执行此升级"
log_info "PVE7 请先试用ISO或升级教程升级哦! https://pve.proxmox.com/wiki/Upgrade_from_7_to_8"
log_tips "如果你已经是PVE 9.x了你还来用这个脚本敲你额头"
return 1
fi
log_error "此操作将把 PVE 8.x 宿主机 不可逆的 升级到 PVE 9.x"
log_error "已知风险包括但不限于:"
log_error " • 系统无法启动(内核/引导变更)"
log_error " • 虚拟机/容器配置文件丢失或损坏"
log_error " • ZFS 池无法导入或数据集损坏"
log_error " • 网络配置被重置,导致失联"
log_error " • 集群节点脱离,需要手动修复"
log_error " • 第三方订阅/源被禁用,恢复困难"
log_error ""
log_error "【必须】完成以下准备工作,否则升级后无法恢复:"
echo " 1. 全系统备份(推荐使用 PBS 或 dd 备份系统盘)"
echo " 2. 手动备份 /etc/pve, /var/lib/pve-cluster, /etc/network"
echo " 3. 确保有 IPMI / iDRAC / 物理访问或急救系统可用"
echo " 4. 阅读官方升级指南https://pve.proxmox.com/wiki/Upgrade_from_8_to_9"
log_error ""
log_error "本脚本不提供任何回滚功能,不承担任何数据丢失责任"
log_error "本脚本不提供任何回滚功能,不承担任何数据丢失责任"
log_error "本脚本不提供任何回滚功能,不承担任何数据丢失责任"
# 确认用户要继续执行升级
echo "您确定要继续升级吗?本次任务执行以下操作:"
echo "注意:升级过程中可能会遇到一些警告或错误,请根据提示进行处理!脚本无法处理故障提示!(脚本只能把提示扔给你..) )"
read -p "输入 'yesido' 确认继续,其他任意键取消: " confirm
if [[ "$confirm" != "yesido" ]]; then
log_info "已取消升级操作,明智之举"
return 0
fi
# 1. 更新当前系统到最新 PVE 8.x 版本
log_info "更新当前系统到最新 PVE 8.x 版本..."
if ! apt update && apt dist-upgrade -y; then
log_error "更新 PVE 8.x 到最新版本失败了请检查网络连接或源配置或者前往作者的GitHub反馈issue.."
return 1
fi
# 再次检查当前版本
current_pve_version=$(pveversion | head -n1 | cut -d'/' -f2 | cut -d'-' -f1)
log_info "更新后 PVE 版本: ${GREEN}$current_pve_version${NC}"
# PVE8.4 自带这个包此处无需检查安装apt 源无此包会报错。
# 2. 安装和运行 pve8to9 检查工具
# log_info "安装 pve8to9 升级检查工具..."
# if ! apt install -y pve8to9; then
# log_warn "pve8to9 工具安装失败,尝试手动安装..."
# # 尝试手动添加 PVE 8 仓库安装 pve8to9
# if ! apt install -y pve8to9; then
# log_error "无法安装 pve8to9 检查工具,奇怪请检查网络连接或源配置或者前往作者的GitHub反馈issue.."
# return 1
# fi
# fi
log_info "运行升级前检查..."
echo -e "${CYAN}pve8to9 检查结果:${NC}"
# 运行 pve8to9 检查,但不直接退出,而是捕获输出并分析
echo -e "检查结果会保存到 /tmp/pve8to9_check.log 文件中,如出现故障建议查看该文件以获取详细信息"
echo -e "再次提示,脚本只能做到把错误扔给你,无法修复问题,请根据提示自行解决(或前往作者issue反馈问题)..."
local check_result=$(pve8to9 | tee /tmp/pve8to9_check.log)
echo "$check_result"
# 检查是否有 FAIL 标记(这意味着有严重错误需要修复)
if echo "$check_result" | grep -E -i "FAIL" > /dev/null; then
log_error "pve8to9 检查发现严重错误!! 一般是软件包冲突或是其他报错!建议修复后再进行升级!"
echo -e "${YELLOW}升级检查结果详情:${NC}"
cat /tmp/pve8to9_check.log
read -p "您确定要忽略这些错误并继续升级吗?这不是在开玩笑!(y/N): " force_upgrade
if [[ "$force_upgrade" != "y" && "$force_upgrade" != "Y" ]]; then
log_info "由于存在严重错误,已取消升级操作...返回主界面"
return 1
fi
else
log_success "pve8to9 检查通过,没有发现严重错误,太好了!"
# 检查是否有 WARNING 标记
if echo "$check_result" | grep -E -i "WARN" > /dev/null; then
log_warn "pve8to9 检查发现一些警告信息,请查看以上详情并根据需要处理。(有些可能是软件包没升级上去,不是关键软件包可以无视先升级喔)"
read -p "是否继续升级?(Y/n): " continue_check
if [[ "$continue_check" == "n" || "$continue_check" == "N" ]]; then
log_info "已取消升级操作"
return 0
fi
fi
fi
# 3. 安装 CPU 微码(如果提示需要)
log_info "检查是否需要安装 CPU 微码..."
if command -v lscpu &> /dev/null; then
local cpu_vendor=$(lscpu | grep "Vendor ID" | awk '{print $3}')
if [[ "$cpu_vendor" == "GenuineIntel" ]]; then
log_info "检测到 Intel CPU安装 Intel 微码..."
apt install -y intel-microcode
elif [[ "$cpu_vendor" == "AuthenticAMD" ]]; then
log_info "检测到 AMD CPU安装 AMD 微码..."
apt install -y amd64-microcode
fi
fi
# 4. 检查当前启动方式并更新引导配置
log_info "检查系统启动方式..."
local boot_method="unknown"
if [[ -d "/boot/efi" ]]; then
boot_method="efi"
log_info "检测到 EFI 启动模式"
# 为 EFI 系统配置 GRUB
echo 'grub-efi-amd64 grub2/force_efi_extra_removable boolean true' | debconf-set-selections -v -u
else
boot_method="bios"
log_info "检测到 BIOS 启动模式"
log_tips "怎么还在用BIOS启用呀建议升级到UEFI启动方式提升系统兼容性和安全性"
fi
# 5. 备份当前源文件
log_info "备份当前源文件..."
local backup_dir="/etc/pve-tools-9-bak"
mkdir -p "$backup_dir"
local timestamp=$(date +%Y%m%d_%H%M%S)
# 备份各种源文件
if [[ -f "/etc/apt/sources.list" ]]; then
cp /etc/apt/sources.list "${backup_dir}/sources.list.backup.${timestamp}"
fi
if [[ -f "/etc/apt/sources.list.d/pve-enterprise.list" ]]; then
cp /etc/apt/sources.list.d/pve-enterprise.list "${backup_dir}/pve-enterprise.list.backup.${timestamp}"
fi
# 备份 PVE 核心数据库
log_info "备份 PVE 核心数据库..."
if [[ -d "/var/lib/pve-cluster" ]]; then
cp -r /var/lib/pve-cluster "${backup_dir}/pve-cluster.backup.${timestamp}"
log_success "核心数据库已备份至 ${backup_dir}"
fi
# 6. 更新源到 Debian 13 (Trixie) 并添加 PVE 9.x 源
log_info "更新软件源到 Debian 13 (Trixie)..."
# 将所有 bookworm 源替换为 trixie
log_step "替换 sources.list 和 pve-enterprise.list 中的 bookworm 为 trixie"
sed -i 's/bookworm/trixie/g' /etc/apt/sources.list 2>/dev/null || true
sed -i 's/bookworm/trixie/g' /etc/apt/sources.list.d/pve-enterprise.list 2>/dev/null || true
# 创建 PVE 9.x 的 sources 配置文件
log_step "创建 PVE 9.x 的 sources 配置文件..."
cat > /etc/apt/sources.list.d/proxmox.sources << EOF
Types: deb
URIs: http://download.proxmox.com/debian/pve
Suites: trixie
Components: pve-no-subscription
Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg
EOF
# 创建 Ceph Squid 源配置文件
log_step "创建 Ceph Squid 源配置文件..."
cat > /etc/apt/sources.list.d/ceph.sources << EOF
Types: deb
URIs: http://download.proxmox.com/debian/ceph-squid
Suites: trixie
Components: no-subscription
Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg
EOF
log_info "软件源已更新到 Debian 13 (Trixie) 和 PVE 9.x 配置"
# 7. 再次运行升级前检查确认源更新无误
log_info "再次运行 pve8to9 检查以确认源配置..."
local final_check_result=$(pve8to9)
if echo "$final_check_result" | grep -E -i "FAIL" > /dev/null; then
log_error "pve8to9 最终检查发现错误,请手动检查源配置后再继续"
echo "$final_check_result"
return 1
else
log_success "源更新配置检查通过"
fi
# 8. 更新包列表并开始升级
log_info "更新包列表..."
if ! apt update; then
log_error "更新包列表失败,请检查网络连接和源配置"
return 1
fi
log_info "开始 PVE 9.x 升级过程,这可能需要较长时间..."
log_warn "如果你正在使用Web UI内置的终端建议改用SSH连接以防止连接中断"
echo -e "${YELLOW}升级过程中可能会出现多个提示,通常按回车键或选择默认选项即可${NC}"
# 使用非交互模式升级,自动回答问题
DEBIAN_FRONTEND=noninteractive apt dist-upgrade -y \
-o Dpkg::Options::="--force-confdef" \
-o Dpkg::Options::="--force-confold"
if [[ $? -ne 0 ]]; then
log_error "PVE 升级过程失败,请查看日志并手动处理...如果是在看不明白可以试试问AI或者提交issue"
return 1
fi
# 9. 清理无用包
log_info "清理无用软件包..."
apt autoremove -y
apt autoclean
# 10. 检查升级结果
local new_pve_version=$(pveversion | head -n1 | cut -d'/' -f2 | cut -d'-' -f1)
local new_major_version=$(echo $new_pve_version | cut -d'.' -f1)
if [[ "$new_major_version" == "9" ]]; then
log_success "撒花PVE 升级成功!新的 PVE 版本: ${GREEN}$new_pve_version${NC}"
# 运行最终的升级后检查
log_info "运行升级后检查..."
pve8to9 2>/dev/null || true
log_info "系统将在 30 秒后重启以完成升级..."
log_success "如果一切顺利重启后就能体验到PVE9啦"
log_warn "如果升级后出现问题例如卡内核卡Grub请先使用LiveCD抢修内核提取日志文件后联系作者寻求帮助"
echo -e "${YELLOW}按 Ctrl+C 可取消自动重启${NC}"
sleep 30
# 重启系统以完成升级
log_info "正在重启系统以完成 PVE 9.x 升级..."
reboot
else
log_error "升级完成后检查发现PVE 版本仍为 $new_pve_version,升级可能未完全成功"
log_tips "请手动检查系统状态,并确认是否需要重试升级"
return 1
fi
}
# 显示系统信息

View File

@@ -0,0 +1,41 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
update_grub_config() {
log_info "更新引导配置..."
# 检查是否是 UEFI 系统
local efi_dir="/boot/efi"
local grub_cfg=""
if [[ -d "$efi_dir" ]]; then
log_info "检测到 UEFI 启动模式"
grub_cfg="/boot/efi/EFI/proxmox/grub.cfg"
else
log_info "检测到 Legacy BIOS 启动模式"
grub_cfg="/boot/grub/grub.cfg"
fi
# 更新 GRUB
if command -v update-grub &> /dev/null; then
if update-grub; then
log_success "GRUB 配置更新成功"
else
log_warn "GRUB 配置更新过程中出现警告,但可能仍然成功,请手动检查确认!"
fi
elif command -v grub-mkconfig &> /dev/null; then
if grub-mkconfig -o "$grub_cfg"; then
log_success "GRUB 配置更新成功"
else
log_warn "GRUB 配置更新过程中出现警告"
fi
else
log_error "找不到 GRUB 更新工具"
return 1
fi
return 0
}
# 切换默认启动内核

View File

@@ -0,0 +1,50 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
menu_boot_kernel() {
while true; do
clear
show_menu_header "启动与内核"
show_menu_option "1" "内核管理 ${CYAN}(内核切换/更新/清理)${NC}"
show_menu_option "2" "查看/备份 GRUB 配置"
echo "$UI_DIVIDER"
show_menu_option "0" "返回主菜单"
show_menu_footer
read -p "请选择操作 [0-2]: " choice
case $choice in
1) kernel_management_menu ;;
2)
while true; do
clear
show_menu_header "GRUB 配置管理"
show_menu_option "1" "查看当前 GRUB 配置"
show_menu_option "2" "备份 GRUB 配置"
show_menu_option "3" "查看备份列表"
show_menu_option "4" "恢复 GRUB 备份"
show_menu_option "0" "返回上级菜单"
show_menu_footer
read -p "请选择操作 [0-4]: " grub_choice
case $grub_choice in
1) show_grub_config; pause_function ;;
2)
echo "请输入备份备注:"
read -p "> " note
backup_grub_with_note "${note:-手动备份}"
pause_function
;;
3) list_grub_backups; pause_function ;;
4) restore_grub_backup ;;
0) break ;;
*) log_error "无效选择" ;;
esac
done
;;
0) return ;;
*) log_error "无效选择" ;;
esac
pause_function
done
}
# 二级菜单:直通与显卡

View File

@@ -0,0 +1,449 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
get_installed_kernel_packages() {
local status_regex="${1:-ii|hi}"
dpkg -l 2>/dev/null | awk -v sr="$status_regex" '
$1 ~ ("^(" sr ")$") &&
$2 ~ /^(pve-kernel|proxmox-kernel)-[0-9].*-pve(-signed)?$/ {
print $2
}
' | sort -Vu
}
# 获取可用的真实内核包(优先 proxmox-kernel再回退 pve-kernel
get_available_kernel_packages_raw() {
local kernel_url="https://mirrors.tuna.tsinghua.edu.cn/proxmox/debian/pve/dists/trixie/pve-no-subscription/binary-amd64/Packages"
local packages_text=""
local available_kernels=""
packages_text="$(curl -fsSL "$kernel_url" 2>/dev/null || true)"
if [[ -n "$packages_text" ]]; then
available_kernels="$(
printf '%s\n' "$packages_text" | sed -nE 's/^Package: (proxmox-kernel-[0-9][0-9A-Za-z.+:~-]*-pve(-signed)?)$/\1/p' | sort -V | uniq
)"
if [[ -z "$available_kernels" ]]; then
available_kernels="$(
printf '%s\n' "$packages_text" | sed -nE 's/^Package: (pve-kernel-[0-9][0-9A-Za-z.+:~-]*-pve(-signed)?)$/\1/p' | sort -V | uniq
)"
fi
fi
if [[ -z "$available_kernels" ]]; then
available_kernels="$(apt-cache search --names-only '^proxmox-kernel-[0-9][0-9A-Za-z.+:~-]*-pve(-signed)?$' 2>/dev/null | awk '{print $1}' | sort -V | uniq)"
if [[ -z "$available_kernels" ]]; then
available_kernels="$(apt-cache search --names-only '^pve-kernel-[0-9][0-9A-Za-z.+:~-]*-pve(-signed)?$' 2>/dev/null | awk '{print $1}' | sort -V | uniq)"
fi
fi
[[ -n "$available_kernels" ]] || return 1
printf '%s\n' "$available_kernels"
}
kernel_package_is_valid() {
local package_name="$1"
[[ "$package_name" =~ ^(proxmox-kernel|pve-kernel)-[0-9][0-9A-Za-z.+:~-]*-pve(-signed)?$ ]]
}
kernel_package_release_from_name() {
local package_name="$1"
if [[ "$package_name" =~ ^(proxmox-kernel|pve-kernel)-([0-9][0-9A-Za-z.+:~-]*-pve)(-signed)?$ ]]; then
echo "${BASH_REMATCH[2]}"
return 0
fi
return 1
}
kernel_package_normalize_input() {
local kernel_input="$1"
local kernel_version=""
if [[ -z "$kernel_input" ]]; then
return 1
fi
if kernel_package_is_valid "$kernel_input"; then
echo "$kernel_input"
return 0
fi
case "$kernel_input" in
proxmox-kernel-*)
kernel_version="${kernel_input#proxmox-kernel-}"
;;
pve-kernel-*)
kernel_version="${kernel_input#pve-kernel-}"
;;
*)
kernel_version="$kernel_input"
;;
esac
if [[ "$kernel_version" != *-pve && "$kernel_version" != *-pve-signed ]]; then
kernel_version="${kernel_version}-pve"
fi
echo "proxmox-kernel-$kernel_version"
}
# 检测当前内核版本
check_kernel_version() {
log_info "检测当前内核信息..."
local current_kernel=$(uname -r)
local kernel_arch=$(uname -m)
local kernel_variant=""
# 检测内核变体(普通/企业版/测试版)
if [[ $current_kernel == *"pve"* ]]; then
kernel_variant="PVE标准内核"
elif [[ $current_kernel == *"edge"* ]]; then
kernel_variant="PVE边缘内核"
elif [[ $current_kernel == *"test"* ]]; then
kernel_variant="测试内核"
else
kernel_variant="未知类型"
fi
echo -e "${CYAN}当前内核信息:${NC}"
echo -e " 版本: ${GREEN}$current_kernel${NC}"
echo -e " 架构: ${GREEN}$kernel_arch${NC}"
echo -e " 类型: ${GREEN}$kernel_variant${NC}"
# 检测可用的内核版本
local installed_kernels=$(get_installed_kernel_packages)
if [[ -n "$installed_kernels" ]]; then
echo -e "${CYAN}已安装的内核版本:${NC}"
while IFS= read -r kernel; do
echo -e " ${GREEN}${NC} $kernel"
done <<< "$installed_kernels"
fi
return 0
}
# 获取可用内核列表
get_available_kernels() {
log_info "正在从 Tuna 镜像站获取可用内核列表..."
# 检查网络连接
if ! ping -c 1 mirrors.tuna.tsinghua.edu.cn &> /dev/null; then
log_error "网络连接失败,无法获取内核列表!请检查 https://mirrors.tuna.tsinghua.edu.cn 的链接状态!"
return 1
fi
local available_kernels
if ! available_kernels="$(get_available_kernel_packages_raw)"; then
log_error "无法获取可用内核列表"
return 1
fi
if [[ -n "$available_kernels" ]]; then
echo -e "${CYAN}可用内核版本:${NC}"
while IFS= read -r kernel; do
[[ -n "$kernel" ]] || continue
echo -e " ${BLUE}${NC} $kernel"
done <<< "$available_kernels"
else
log_error "无法找到可用内核"
return 1
fi
return 0
}
# 安装指定内核版本
install_kernel() {
local kernel_input=$1
local kernel_version=""
# 验证内核版本格式
if [[ -z "$kernel_input" ]]; then
log_error "请指定要安装的内核版本"
return 1
fi
if kernel_package_is_valid "$kernel_input"; then
if [[ "$kernel_input" == pve-kernel-* ]]; then
kernel_version="proxmox-kernel-${kernel_input#pve-kernel-}"
log_info "检测到旧包名格式,自动转换为: $kernel_version"
else
kernel_version="$kernel_input"
log_info "检测到完整包名格式: $kernel_version"
fi
else
kernel_version="$(kernel_package_normalize_input "$kernel_input")"
log_info "检测到版本号格式,自动补全包名为 $kernel_version"
fi
if ! kernel_package_is_valid "$kernel_version"; then
log_error "无效的内核包名: $kernel_version"
return 1
fi
log_info "开始安装内核: $kernel_version"
# 检查内核是否已安装
if dpkg -l 2>/dev/null | awk -v pkg="$kernel_version" '$1 == "ii" && $2 == pkg {found=1} END {exit !found}'; then
log_warn "内核 $kernel_version 已经安装"
read -p "是否重新安装?(y/N): " reinstall
if [[ "$reinstall" != "y" && "$reinstall" != "Y" ]]; then
return 0
fi
fi
# 更新软件包列表
log_info "更新软件包列表..."
if ! apt-get update; then
log_error "更新软件包列表失败"
return 1
fi
# 安装内核
log_info "正在安装内核 $kernel_version ..."
if ! apt-get install -y "$kernel_version"; then
log_error "内核安装失败"
return 1
fi
log_success "内核 $kernel_version 安装成功"
# 更新引导配置
update_grub_config
return 0
}
# 更新 GRUB 配置
set_default_kernel() {
local kernel_version=$1
if [[ -z "$kernel_version" ]]; then
log_error "请指定要设置为默认的内核版本"
return 1
fi
log_info "设置默认启动内核: ${GREEN}$kernel_version${NC}"
# 检查内核是否存在
if ! [[ -f "/boot/initrd.img-$kernel_version" && -f "/boot/vmlinuz-$kernel_version" ]]; then
log_error "内核文件不存在,请先安装该内核"
log_error "缺失文件: /boot/vmlinuz-$kernel_version 或 /boot/initrd.img-$kernel_version"
return 1
fi
# 使用 grub-set-default 设置默认内核
if command -v grub-set-default &> /dev/null; then
# 查找内核在 GRUB 菜单中的位置
local menu_entry=$(grep -n "$kernel_version" /boot/grub/grub.cfg | head -1 | cut -d: -f1)
if [[ -n "$menu_entry" ]]; then
# 计算 GRUB 菜单项索引从0开始
local grub_index=$(( (menu_entry - 1) / 2 ))
if grub-set-default "$grub_index"; then
log_success "默认启动内核设置成功"
return 0
fi
fi
fi
# 备用方法:手动编辑 GRUB 配置
log_warn "使用备用方法设置默认内核"
# 备份当前 GRUB 配置
cp /etc/default/grub /etc/default/grub.backup.$(date +%Y%m%d%H%M%S)
# 设置 GRUB_DEFAULT 为内核版本
if sed -i "s/^GRUB_DEFAULT=.*/GRUB_DEFAULT=\"Advanced options for Proxmox VE GNU\/Linux>Proxmox VE GNU\/Linux, with Linux $kernel_version\"/" /etc/default/grub; then
log_success "GRUB 配置更新成功"
update_grub_config
return 0
else
log_error "GRUB 配置更新失败"
return 1
fi
}
# 删除旧内核保留最近2个版本
remove_old_kernels() {
log_info "清理旧内核..."
# 获取所有已安装的内核
local installed_kernels
installed_kernels="$(get_installed_kernel_packages "ii")"
local -a kernel_list
mapfile -t kernel_list < <(printf '%s\n' "$installed_kernels" | sed '/^$/d')
local kernel_count=${#kernel_list[@]}
if [[ $kernel_count -le 2 ]]; then
log_info "当前只有 $kernel_count 个内核,无需清理"
return 0
fi
# 计算需要保留的内核数量保留最新的2个
local keep_count=2
local remove_count=$((kernel_count - keep_count))
echo -e "${YELLOW}将删除 $remove_count 个旧内核,保留最新的 $keep_count 个内核${NC}"
# 获取要删除的内核列表(最旧的几个)
local kernels_to_remove=("${kernel_list[@]:0:$remove_count}")
read -p "是否继续?(y/N): " confirm
if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then
log_info "取消内核清理"
return 0
fi
# 删除旧内核
for kernel in "${kernels_to_remove[@]}"; do
log_info "正在删除内核: $kernel"
if apt-get remove -y --purge "$kernel"; then
log_success "内核 $kernel 删除成功"
else
log_error "删除内核 $kernel 失败"
fi
done
# 更新引导配置
update_grub_config
log_success "旧内核清理完成"
return 0
}
# 内核管理主菜单
kernel_management_menu() {
while true; do
clear
show_menu_header "内核管理菜单"
show_menu_option "1" "显示当前内核信息"
show_menu_option "2" "查看可用内核列表"
show_menu_option "3" "安装新内核"
show_menu_option "4" "设置默认启动内核"
show_menu_option "5" "${RED}清理旧内核${NC}"
show_menu_option "6" "${YELLOW}重启系统应用新内核${NC}"
echo "${UI_DIVIDER}"
show_menu_option "0" "返回主菜单"
show_menu_footer
read -p "请选择操作 [0-6]: " choice
case $choice in
1)
check_kernel_version
;;
2)
get_available_kernels
;;
3)
echo "请输入要安装的内核版本:"
echo " - 完整包名格式 (推荐): 如 proxmox-kernel-6.14.8-2-pve"
echo " - 简化版本格式: 如 6.8.8-1 (将自动补全为 proxmox-kernel-6.8.8-1-pve)"
read -p "请输入内核标识: " kernel_ver
if [[ -n "$kernel_ver" ]]; then
install_kernel "$kernel_ver"
else
log_error "请输入有效的内核版本"
fi
;;
4)
read -p "请输入要设置为默认的内核版本 (例如: 6.8.8-1-pve): " kernel_ver
if [[ -n "$kernel_ver" ]]; then
set_default_kernel "$kernel_ver"
else
log_error "请输入有效的内核版本"
fi
;;
5)
remove_old_kernels
;;
6)
read -p "确认要重启系统吗?(y/N): " reboot_confirm
if [[ "$reboot_confirm" == "y" || "$reboot_confirm" == "Y" ]]; then
log_info "系统将在5秒后重启..."
echo "按 Ctrl+C 取消重启"
sleep 5
reboot
else
log_info "取消重启"
fi
;;
0)
break
;;
*)
log_error "无效的选择,请重新输入"
;;
esac
echo
pause_function
done
}
# 内核同步更新(自动检测并更新到最新稳定版)
sync_kernel_update() {
log_info "开始内核同步更新检查..."
# 获取当前内核版本
local current_kernel=$(uname -r)
log_info "当前内核版本: ${GREEN}$current_kernel${NC}"
# 获取最新可用内核包
local available_kernel_text=""
local -a available_kernel_packages=()
if ! available_kernel_text="$(get_available_kernel_packages_raw)"; then
log_error "无法获取最新内核信息"
return 1
fi
mapfile -t available_kernel_packages < <(printf '%s\n' "$available_kernel_text" | sed '/^$/d')
if [[ ${#available_kernel_packages[@]} -eq 0 ]]; then
log_error "无法获取最新内核信息"
return 1
fi
local latest_kernel_index=$(( ${#available_kernel_packages[@]} - 1 ))
local latest_kernel_package="${available_kernel_packages[$latest_kernel_index]}"
local latest_kernel_release=""
if ! latest_kernel_release="$(kernel_package_release_from_name "$latest_kernel_package")"; then
log_error "无法解析最新内核包名: $latest_kernel_package"
return 1
fi
log_info "最新可用内核包: ${GREEN}$latest_kernel_package${NC}"
log_info "最新可用内核版本: ${GREEN}$latest_kernel_release${NC}"
# 检查是否需要更新
if [[ "$current_kernel" == "$latest_kernel_release" ]]; then
log_success "当前已是最新内核,无需更新"
return 0
fi
echo -e "${YELLOW}发现新内核版本: $latest_kernel_release${NC}"
read -p "是否安装并更新到最新内核?(Y/n): " update_confirm
if [[ "$update_confirm" == "n" || "$update_confirm" == "N" ]]; then
log_info "取消内核更新"
return 0
fi
# 安装最新内核
if install_kernel "$latest_kernel_package"; then
# 设置新内核为默认启动项
if set_default_kernel "$latest_kernel_release"; then
log_success "内核同步更新完成"
echo -e "${YELLOW}建议重启系统以应用新内核${NC}"
return 0
else
log_warn "内核安装成功但设置默认启动项失败"
return 1
fi
else
log_error "内核更新失败"
return 1
fi
}
# 备份函数统一定义于顶部配置文件安全管理区域,避免后续重复覆盖。
# 换源功能

View File

@@ -0,0 +1,299 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
amd_list_gpus() {
lspci -Dnn 2>/dev/null | grep -Ei 'VGA compatible controller|3D controller|Display controller' | grep -iE 'AMD|ATI' | awk '{print $1 "|" $0}'
}
amd_select_gpu_bdf() {
local title="${1:-可用 AMD GPU 列表:}"
local prompt_label="${2:-请选择 AMD GPU 序号}"
local gpus
gpus="$(amd_list_gpus)"
if [[ -z "$gpus" ]]; then
log_error "未检测到 AMD GPU"
log_tips "请先确认 AMD 显卡已安装,并执行 lspci -Dnn 可见。"
return 1
fi
local cols max_line
cols="$(nvidia_get_cols)"
max_line=$((cols-6))
if [[ "$max_line" -lt 40 ]]; then
max_line=40
fi
{
echo -e "${CYAN}${title}${NC}"
echo "$gpus" | awk -F'|' -v w="$max_line" '{
line=$2;
if (length(line)>w) line=substr(line,1,w-3)"...";
printf " [%d] %s\n", NR, line
}'
echo -e "${UI_DIVIDER}"
} >&2
local pick
read -p "${prompt_label} (0 返回): " pick
pick="${pick:-0}"
if [[ "$pick" == "0" ]]; then
return 2
fi
if [[ ! "$pick" =~ ^[0-9]+$ ]]; then
log_error "序号必须是数字"
return 1
fi
local line bdf
line="$(echo "$gpus" | awk -v n="$pick" -F'|' 'NR==n{print $0}')"
bdf="$(echo "$line" | awk -F'|' '{print $1}')"
if [[ -z "$bdf" ]]; then
log_error "无效选择"
return 1
fi
echo "$bdf"
return 0
}
amd_try_write_vfio_ids_conf() {
local ids_csv="$1"
local file="/etc/modprobe.d/pve-tools-amd-vfio.conf"
local other
other="$(grep -RhsE '^\s*options\s+vfio-pci\s+ids=' /etc/modprobe.d 2>/dev/null | grep -vF 'pve-tools-amd-vfio.conf' || true)"
if [[ -n "$other" ]]; then
display_error "检测到系统已存在 vfio-pci ids 配置" "为避免冲突,本功能不会自动写入。请手工合并 vfio-pci ids 后再 update-initramfs -u。"
return 1
fi
if ! confirm_action "写入 AMD 的 VFIO 绑定配置($file)并要求重启宿主机?"; then
return 0
fi
local content
content="options vfio-pci ids=${ids_csv}"
apply_block "$file" "AMD_VFIO_IDS" "$content"
display_success "AMD 的 VFIO 绑定配置已写入" "请执行 update-initramfs -u 并重启宿主机后再进行直通。"
return 0
}
amd_host_prepare_for_passthrough() {
echo -e "${YELLOW}将执行以下操作:${NC}"
echo " 1) 写入 GRUB IOMMU 参数"
echo " 2) 写入 /etc/modules 的 VFIO 模块配置块"
echo " 3) 写入 AMD 显卡黑名单配置 (amdgpu / radeon)"
echo " 4) 执行 update-grub 与 update-initramfs"
echo
echo -e "${RED}重要提醒:如果宿主机当前依赖 AMD 核显或 AMD 独显输出,本地控制台画面可能在重启后消失。${NC}"
echo -e "${YELLOW}如遇 Windows Code 43 或黑屏,请优先检查 BIOS 中的 Resizable BAR / Smart Access Memory 是否已关闭。${NC}"
if lsmod 2>/dev/null | grep -Eq '^(amdgpu|radeon)\b'; then
echo -e "${YELLOW}检测到 amdgpu / radeon 当前已加载,说明宿主机很可能正在占用 AMD 显卡。${NC}"
fi
echo
if ! confirm_high_risk_action "为 AMD GPU 直通写入宿主机预配置" "会修改 GRUB、VFIO 模块和 AMD 显卡黑名单配置。" "错误配置可能导致宿主机本地输出消失、GPU 无法用于宿主机图形界面,甚至在重启后需要控制台修复。" "请确认已准备带外管理或物理控制台,并已理解回滚方式。" "AMD-HOST"; then
return 0
fi
local cpu_vendor
cpu_vendor="$(grep -m1 'vendor_id' /proc/cpuinfo 2>/dev/null | awk '{print $3}')"
if [[ "$cpu_vendor" == "GenuineIntel" ]]; then
grub_add_param "intel_iommu=on"
elif [[ "$cpu_vendor" == "AuthenticAMD" ]]; then
grub_add_param "amd_iommu=on"
else
log_warn "未识别 CPU 厂商,跳过厂商特定 IOMMU 参数"
fi
grub_add_param "iommu=pt"
grub_add_param "pcie_acs_override=downstream,multifunction"
local modules_content
modules_content=$(cat <<'EOF'
vfio
vfio_iommu_type1
vfio_pci
vfio_virqfd
EOF
)
apply_block "/etc/modules" "AMD_VFIO_MODULES" "$modules_content"
local blacklist_content
blacklist_content=$(cat <<'EOF'
blacklist amdgpu
blacklist radeon
options vfio_iommu_type1 allow_unsafe_interrupts=1
EOF
)
apply_block "/etc/modprobe.d/pve-tools-amd-blacklist.conf" "AMD_GPU_BLACKLIST" "$blacklist_content"
if command -v update-grub >/dev/null 2>&1; then
update-grub || log_warn "update-grub 执行失败,请手工检查"
elif command -v grub-mkconfig >/dev/null 2>&1; then
grub-mkconfig -o /boot/grub/grub.cfg || log_warn "grub-mkconfig 执行失败,请手工检查"
else
log_warn "未找到 update-grub/grub-mkconfig请手工更新 GRUB"
fi
update-initramfs -u -k all || log_warn "update-initramfs 执行失败,请手工检查"
display_success "AMD 宿主机预配置已完成" "建议重启宿主机后再执行 AMD 显卡或核显直通。"
if confirm_action "是否现在重启宿主机?"; then
reboot
fi
return 0
}
amd_gpu_passthrough_vm() {
log_step "AMD 独显直通虚拟机"
if ! iommu_is_enabled; then
display_error "未检测到 IOMMU 已开启" "请先在 BIOS 开启 VT-d/AMD-Vi并在 PVE 中启用 IOMMU可在“硬件直通一键配置(IOMMU)”里开启)。"
return 1
fi
local vmid
vmid="$(nvidia_select_vmid)"
local rc=$?
if [[ "$rc" -eq 2 ]]; then
return 0
fi
if [[ -z "$vmid" ]]; then
return 1
fi
local gpu_bdf
gpu_bdf="$(amd_select_gpu_bdf '可用 AMD 独显 / GPU 列表:' '请选择 AMD 独显序号')"
rc=$?
if [[ "$rc" -eq 2 ]]; then
return 0
fi
if [[ -z "$gpu_bdf" ]]; then
return 1
fi
clear
show_menu_header "AMD 独显直通虚拟机"
echo -e "${YELLOW}VMID: ${NC}$vmid"
echo -e "${YELLOW}GPU: ${NC}$gpu_bdf"
echo -e "${UI_DIVIDER}"
nvidia_show_passthrough_status "$gpu_bdf"
local audio_bdf=""
if nvidia_pci_has_function "$gpu_bdf" "1"; then
audio_bdf="${gpu_bdf%.*}.1"
echo -e "${UI_DIVIDER}"
nvidia_show_passthrough_status "$audio_bdf"
fi
local gpu_id audio_id ids_csv
gpu_id="$(nvidia_get_pci_ids "$gpu_bdf")"
audio_id=""
if [[ -n "$audio_bdf" ]]; then
audio_id="$(nvidia_get_pci_ids "$audio_bdf")"
fi
ids_csv="$gpu_id"
if [[ -n "$audio_id" ]]; then
ids_csv="${ids_csv},${audio_id}"
fi
echo -e "${UI_DIVIDER}"
if [[ -n "$ids_csv" ]]; then
echo -e "${CYAN}VFIO ids 建议: ${NC}$ids_csv"
fi
echo -e "${YELLOW}提示:若宿主机仍在使用 amdgpu / radeon直通可能失败。${NC}"
echo -e "${YELLOW}如 Windows 来宾报 Code 43请优先检查 BIOS 的 Resizable BAR / Smart Access Memory。${NC}"
echo -e "${UI_DIVIDER}"
local include_audio="no"
if [[ -n "$audio_bdf" ]]; then
read -p "是否同时直通显卡音频功能(${audio_bdf}(yes/no) [yes]: " include_audio
include_audio="${include_audio:-yes}"
fi
local enable_x_vga="yes"
read -p "是否为 AMD 显卡启用 x-vga=1Windows 常见)?(yes/no) [yes]: " enable_x_vga
enable_x_vga="${enable_x_vga:-yes}"
if qm_has_hostpci_bdf "$vmid" "$gpu_bdf"; then
display_error "该 AMD GPU 已存在于 VM 的 hostpci 配置中" "无需重复添加。"
return 1
fi
local idx0
idx0="$(qm_find_free_hostpci_index "$vmid" 2>/dev/null)" || {
display_error "未找到可用 hostpci 插槽" "请先释放 VM 的 hostpci0-hostpci15。"
return 1
}
local hostpci0_value="$gpu_bdf"
if qm_is_q35_machine "$vmid"; then
hostpci0_value="${hostpci0_value},pcie=1"
fi
if [[ "$enable_x_vga" == "yes" || "$enable_x_vga" == "YES" ]]; then
hostpci0_value="${hostpci0_value},x-vga=1"
fi
local conf_path
conf_path="$(get_qm_conf_path "$vmid")"
if [[ -f "$conf_path" ]]; then
backup_file "$conf_path" >/dev/null 2>&1 || true
fi
if ! confirm_action "为 VM $vmid 添加 AMD 独显直通hostpci${idx0} = ${hostpci0_value}"; then
return 0
fi
if ! qm set "$vmid" "-hostpci${idx0}" "$hostpci0_value" >/dev/null 2>&1; then
display_error "qm set 执行失败" "请检查 VM 是否锁定、IOMMU / IOMMU group或查看 /var/log/pve-tools.log。"
return 1
fi
if [[ "$include_audio" == "yes" || "$include_audio" == "YES" ]] && [[ -n "$audio_bdf" ]]; then
local idx1
idx1="$(qm_find_free_hostpci_index "$vmid" 2>/dev/null)" || {
display_error "显卡已添加,但未找到可用 hostpci 插槽添加音频功能" "请手工添加 $audio_bdf"
return 1
}
local hostpci1_value="$audio_bdf"
if qm_is_q35_machine "$vmid"; then
hostpci1_value="${hostpci1_value},pcie=1"
fi
if ! qm set "$vmid" "-hostpci${idx1}" "$hostpci1_value" >/dev/null 2>&1; then
log_warn "音频功能直通写入失败GPU 已写入)"
else
log_success "音频功能已写入: hostpci${idx1} = $hostpci1_value"
fi
fi
if [[ -n "$ids_csv" ]]; then
local set_vfio="no"
read -p "是否写入 AMD 的 VFIO ids 绑定配置(用于将设备绑定到 vfio-pciyes/no[no]: " set_vfio
set_vfio="${set_vfio:-no}"
if [[ "$set_vfio" == "yes" || "$set_vfio" == "YES" ]]; then
amd_try_write_vfio_ids_conf "$ids_csv" || true
fi
fi
display_success "AMD 独显直通已写入" "如 VM 正在运行中,请重启 VM如写入了 VFIO 配置,请按提示重启宿主机。"
return 0
}
amd_gpu_management_menu() {
while true; do
clear
show_menu_header "AMD 独显直通"
echo -e "${CYAN}提示:如宿主机仍在使用 amdgpu / radeon占用中的 AMD 独显通常无法直接直通。${NC}"
echo -e "${UI_DIVIDER}"
show_menu_option "1" "AMD 显卡直通虚拟机"
show_menu_option "2" "AMD 宿主机预配置 ( IOMMU / VFIO / 黑名单 )"
show_menu_option "0" "返回"
show_menu_footer
read -p "请选择操作 [0-2]: " choice
case "$choice" in
1) amd_gpu_passthrough_vm ;;
2) amd_host_prepare_for_passthrough ;;
0) return ;;
*) log_error "无效选择" ;;
esac
pause_function
done
}

View File

@@ -0,0 +1,275 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
amd_list_romfiles() {
if [[ ! -d "$PVE_KVM_ROM_DIR" ]]; then
return 0
fi
find "$PVE_KVM_ROM_DIR" -maxdepth 1 -type f \( -iname '*.rom' -o -iname '*.bin' \) 2>/dev/null | sort
}
amd_normalize_romfile_input() {
local input="$1"
local rom_path base
if [[ -z "$input" ]]; then
return 1
fi
if [[ "$input" == /* ]]; then
rom_path="$input"
else
rom_path="${PVE_KVM_ROM_DIR}/${input}"
fi
case "$rom_path" in
"${PVE_KVM_ROM_DIR}/"*) ;;
*)
log_error "ROM 文件路径必须位于 ${PVE_KVM_ROM_DIR}"
echo -e "${YELLOW}提示: 请先把用户自备的 AMD ROM / vBIOS 文件放入 ${PVE_KVM_ROM_DIR} 后再试。${NC}" >&2
return 1
;;
esac
if [[ ! -f "$rom_path" ]]; then
log_error "未找到 ROM 文件: $rom_path"
echo -e "${YELLOW}提示: 请确认文件已放入 ${PVE_KVM_ROM_DIR},并由用户自行提取、确认来源与兼容性。${NC}" >&2
return 1
fi
base="$(basename "$rom_path")"
if [[ ! "$base" =~ ^[A-Za-z0-9._+-]+$ ]]; then
log_error "ROM 文件名包含不安全字符: $base"
echo -e "${YELLOW}提示: 请将文件重命名为简单英文/数字文件名后再试。${NC}" >&2
return 1
fi
echo "$base"
return 0
}
amd_prompt_romfile_basename() {
local prompt="${1:-请输入 AMD ROM / vBIOS 文件路径或文件名}"
local roms
roms="$(amd_list_romfiles)"
{
echo -e "${CYAN}ROM 文件目录: ${NC}${PVE_KVM_ROM_DIR}"
if [[ -n "$roms" ]]; then
echo "$roms" | sed 's/^/ /'
else
echo " (当前未发现 .rom / .bin 文件)"
fi
echo -e "${YELLOW}ROM / vBIOS 提取通常需要由用户自行完成,本脚本只负责校验并写入 romfile。${NC}"
echo -e "${UI_DIVIDER}"
} >&2
local input
read -p "${prompt} (0 返回): " input
input="${input:-0}"
if [[ "$input" == "0" ]]; then
return 2
fi
amd_normalize_romfile_input "$input"
}
amd_igpu_show_guidance() {
clear
show_menu_header "AMD 核显直通说明"
echo -e "${CYAN}使用建议:${NC}"
echo " 1) AMD 核显直通通常比独显更依赖正确的 ROM / vBIOS 文件。"
echo " 2) 建议 VM 使用 q35 + OVMF并将核显作为主显示设备。"
echo " 3) ROM / vBIOS 提取一般交给用户自行完成,脚本不提供自动提取。"
echo " 4) 将 ROM 文件放入 ${PVE_KVM_ROM_DIR} 后,再通过本向导写入 romfile。"
echo " 5) 如 Windows 来宾报 Code 43 / 黑屏,请优先检查 BIOS 中的 Resizable BAR / SAM。"
echo
echo -e "${CYAN}参考:${NC}"
echo " 社区参考文章: https://diyforfun.cn/712.html"
echo " Proxmox 官方: https://pve.proxmox.com/wiki/PCI_Passthrough"
echo
echo -e "${RED}免责声明ROM / vBIOS 文件的提取、来源合法性、兼容性与由此导致的黑屏、Code 43、设备不可用等后果由用户自行承担。${NC}"
echo "$UI_DIVIDER"
}
amd_igpu_check_romfile() {
clear
show_menu_header "AMD 核显 ROM / vBIOS 检查"
local rom_base
rom_base="$(amd_prompt_romfile_basename '请输入要校验的 AMD ROM / vBIOS 文件路径或文件名')"
local rc=$?
if [[ "$rc" -eq 2 ]]; then
return 0
fi
if [[ -z "$rom_base" ]]; then
return 1
fi
display_success "ROM 文件校验通过" "可在 hostpci 中使用 romfile=${rom_base}"
return 0
}
amd_igpu_passthrough_vm() {
log_step "AMD 核显直通配置"
if ! iommu_is_enabled; then
display_error "未检测到 IOMMU 已开启" "请先在 BIOS 开启 VT-d/AMD-Vi并在 PVE 中启用 IOMMU可在“硬件直通一键配置(IOMMU)”里开启)。"
return 1
fi
local vmid
vmid="$(nvidia_select_vmid)"
local rc=$?
if [[ "$rc" -eq 2 ]]; then
return 0
fi
if [[ -z "$vmid" ]]; then
return 1
fi
local gpu_bdf
gpu_bdf="$(amd_select_gpu_bdf '可用 AMD GPU / 核显列表(请手工确认 APU 核显设备):' '请选择 AMD 核显序号')"
rc=$?
if [[ "$rc" -eq 2 ]]; then
return 0
fi
if [[ -z "$gpu_bdf" ]]; then
return 1
fi
local rom_base
rom_base="$(amd_prompt_romfile_basename '请输入 AMD 核显 ROM / vBIOS 文件路径或文件名')"
rc=$?
if [[ "$rc" -eq 2 ]]; then
return 0
fi
if [[ -z "$rom_base" ]]; then
return 1
fi
clear
show_menu_header "AMD 核显直通配置"
echo -e "${YELLOW}VMID: ${NC}$vmid"
echo -e "${YELLOW}iGPU: ${NC}$gpu_bdf"
echo -e "${YELLOW}ROM: ${NC}${PVE_KVM_ROM_DIR}/${rom_base}"
echo -e "${UI_DIVIDER}"
nvidia_show_passthrough_status "$gpu_bdf"
local audio_bdf=""
if nvidia_pci_has_function "$gpu_bdf" "1"; then
audio_bdf="${gpu_bdf%.*}.1"
echo -e "${UI_DIVIDER}"
nvidia_show_passthrough_status "$audio_bdf"
fi
local include_audio="no"
if [[ -n "$audio_bdf" ]]; then
read -p "是否同时直通核显音频功能(${audio_bdf}(yes/no) [yes]: " include_audio
include_audio="${include_audio:-yes}"
fi
local gpu_id audio_id ids_csv
gpu_id="$(nvidia_get_pci_ids "$gpu_bdf")"
audio_id=""
if [[ -n "$audio_bdf" ]]; then
audio_id="$(nvidia_get_pci_ids "$audio_bdf")"
fi
ids_csv="$gpu_id"
if [[ -n "$audio_id" ]]; then
ids_csv="${ids_csv},${audio_id}"
fi
echo -e "${UI_DIVIDER}"
if [[ -n "$ids_csv" ]]; then
echo -e "${CYAN}VFIO ids 建议: ${NC}$ids_csv"
fi
echo -e "${YELLOW}提示AMD 核显直通强依赖正确的 ROM / vBIOS本脚本不会自动提取 ROM。${NC}"
if ! qm_is_q35_machine "$vmid"; then
echo -e "${YELLOW}警告:当前 VM 不是 q35 机型。AMD 核显直通通常更推荐 q35 + OVMF。${NC}"
fi
echo -e "${UI_DIVIDER}"
if qm_has_hostpci_bdf "$vmid" "$gpu_bdf"; then
display_error "该 AMD 核显已存在于 VM 的 hostpci 配置中" "无需重复添加。"
return 1
fi
local idx0
idx0="$(qm_find_free_hostpci_index "$vmid" 2>/dev/null)" || {
display_error "未找到可用 hostpci 插槽" "请先释放 VM 的 hostpci0-hostpci15。"
return 1
}
local hostpci0_value="$gpu_bdf"
if qm_is_q35_machine "$vmid"; then
hostpci0_value="${hostpci0_value},pcie=1"
fi
hostpci0_value="${hostpci0_value},x-vga=1,romfile=${rom_base}"
local conf_path
conf_path="$(get_qm_conf_path "$vmid")"
if [[ -f "$conf_path" ]]; then
backup_file "$conf_path" >/dev/null 2>&1 || true
fi
if ! confirm_high_risk_action "为 VM $vmid 写入 AMD 核显直通hostpci${idx0} = ${hostpci0_value}" "错误的 ROM / vBIOS、错误的 BDF 或错误的 hostpci 配置可能导致 VM 黑屏、来宾驱动报错或设备无法初始化。" "如果宿主机当前仍依赖该 AMD 核显输出,后续黑名单和 VFIO 绑定还可能导致宿主机本地画面丢失。" "请确认 ROM 文件由用户自行提取并已放入 ${PVE_KVM_ROM_DIR},且已准备好回滚 hostpci 配置。" "AMD-iGPU"; then
return 0
fi
if ! qm set "$vmid" "-hostpci${idx0}" "$hostpci0_value" >/dev/null 2>&1; then
display_error "qm set 执行失败" "请检查 VM 是否锁定、IOMMU / IOMMU group或查看 /var/log/pve-tools.log。"
return 1
fi
if [[ "$include_audio" == "yes" || "$include_audio" == "YES" ]] && [[ -n "$audio_bdf" ]]; then
local idx1
idx1="$(qm_find_free_hostpci_index "$vmid" 2>/dev/null)" || {
display_error "核显已添加,但未找到可用 hostpci 插槽添加音频功能" "请手工添加 $audio_bdf"
return 1
}
local hostpci1_value="$audio_bdf"
if qm_is_q35_machine "$vmid"; then
hostpci1_value="${hostpci1_value},pcie=1"
fi
if ! qm set "$vmid" "-hostpci${idx1}" "$hostpci1_value" >/dev/null 2>&1; then
log_warn "核显音频功能直通写入失败(核显已写入)"
else
log_success "核显音频功能已写入: hostpci${idx1} = $hostpci1_value"
fi
fi
if [[ -n "$ids_csv" ]]; then
local set_vfio="no"
read -p "是否写入 AMD 的 VFIO ids 绑定配置(用于将设备绑定到 vfio-pciyes/no[no]: " set_vfio
set_vfio="${set_vfio:-no}"
if [[ "$set_vfio" == "yes" || "$set_vfio" == "YES" ]]; then
amd_try_write_vfio_ids_conf "$ids_csv" || true
fi
fi
display_success "AMD 核显直通已写入" "请在来宾中按需安装驱动;如写入了 VFIO 配置,请按提示重启宿主机。"
return 0
}
amd_igpu_management_menu() {
while true; do
clear
show_menu_header "AMD 核显直通"
echo -e "${RED}注意AMD 核显直通通常需要用户自备 ROM / vBIOS 文件,本脚本不负责提取。${NC}"
echo -e "${UI_DIVIDER}"
show_menu_option "1" "配置 AMD 核显直通"
show_menu_option "2" "检查 ROM / vBIOS 文件"
show_menu_option "3" "查看 AMD 核显直通说明"
show_menu_option "4" "AMD 宿主机预配置 ( IOMMU / VFIO / 黑名单 )"
show_menu_option "0" "返回"
show_menu_footer
read -p "请选择操作 [0-4]: " choice
case "$choice" in
1) amd_igpu_passthrough_vm ;;
2) amd_igpu_check_romfile ;;
3) amd_igpu_show_guidance ;;
4) amd_host_prepare_for_passthrough ;;
0) return ;;
*) log_error "无效选择" ;;
esac
pause_function
done
}
# 主程序

View File

@@ -0,0 +1,163 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
resolve_whole_disk() {
local input="$1"
if [[ -z "$input" ]]; then
return 1
fi
local real
if [[ "$input" == /dev/disk/by-id/* ]]; then
real="$(readlink -f "$input" 2>/dev/null || true)"
else
real="$input"
fi
if [[ ! -b "$real" ]]; then
return 1
fi
local t
t="$(lsblk -dn -o TYPE "$real" 2>/dev/null | head -n 1)"
if [[ "$t" == "disk" ]]; then
echo "$real"
return 0
fi
local pk
pk="$(lsblk -dn -o PKNAME "$real" 2>/dev/null | head -n 1)"
if [[ -n "$pk" && -b "/dev/$pk" ]]; then
echo "/dev/$pk"
return 0
fi
return 1
}
# 识别直通磁盘上的引导类型UEFI / Legacy / Unknown
detect_disk_boot_mode() {
local disk="$1"
if [[ -z "$disk" || ! -b "$disk" ]]; then
echo "Unknown"
return 1
fi
if command -v lsblk >/dev/null 2>&1; then
local esp_guid="c12a7328-f81f-11d2-ba4b-00a0c93ec93b"
local parts
parts="$(lsblk -rno NAME,PARTTYPE,FSTYPE "$disk" 2>/dev/null | awk 'NF>=2{print}')"
if echo "$parts" | grep -qi "$esp_guid"; then
echo "UEFI"
return 0
fi
if echo "$parts" | awk '{print $3}' | grep -qi '^vfat$'; then
if echo "$parts" | grep -Eqi 'EFI|esp'; then
echo "UEFI"
return 0
fi
fi
fi
if command -v parted >/dev/null 2>&1; then
local out
out="$(parted -s "$disk" print 2>/dev/null || true)"
if echo "$out" | grep -Eqi 'Partition Table:\s*gpt'; then
if echo "$out" | grep -Eqi '\besp\b|EFI System|boot, esp'; then
echo "UEFI"
return 0
fi
echo "Unknown"
return 0
fi
if echo "$out" | grep -Eqi 'Partition Table:\s*msdos'; then
echo "Legacy"
return 0
fi
fi
echo "Unknown"
return 0
}
# 根据磁盘引导类型与直通方式给出 VM 配置建议(仅提示,不修改配置)
boot_config_assistant() {
log_step "引导配置辅助"
local disk_input
read -p "请输入直通磁盘路径(/dev/disk/by-id/... 或 /dev/sdX /dev/nvme0n1返回请输入 0: " disk_input
disk_input="${disk_input:-0}"
if [[ "$disk_input" == "0" ]]; then
return 0
fi
local disk
disk="$(resolve_whole_disk "$disk_input" 2>/dev/null || true)"
if [[ -z "$disk" ]]; then
display_error "磁盘路径无效或不可访问: $disk_input" "请确认输入为块设备或 by-id 路径,并在宿主机上存在。"
return 1
fi
local boot_mode
boot_mode="$(detect_disk_boot_mode "$disk")"
echo -e "${CYAN}检测结果:${NC}"
echo " 磁盘: $disk"
echo " 引导类型: $boot_mode"
echo -e "${UI_DIVIDER}"
echo -e "${CYAN}直通方式选择(用于生成更贴近场景的建议):${NC}"
echo " 1) 单个磁盘直通RDM"
echo " 2) 整控制器直通SATA/SCSI/RAID"
echo " 3) NVMe 控制器直通"
local mode
read -p "请选择直通方式 [1-3] [1]: " mode
mode="${mode:-1}"
if [[ "$mode" != "1" && "$mode" != "2" && "$mode" != "3" ]]; then
display_error "无效选择: $mode" "请输入 1/2/3"
return 1
fi
local slot=""
if [[ "$mode" == "1" ]]; then
read -p "如果已知 VM 插槽(如 scsi0/sata1/ide0可输入用于 boot order回车跳过: " slot
if [[ -n "$slot" && ! "$slot" =~ ^(scsi|sata|ide)[0-9]+$ ]]; then
display_error "插槽格式不合法: $slot" "示例scsi0 / sata0 / ide0"
return 1
fi
fi
echo -e "${UI_DIVIDER}"
echo -e "${CYAN}配置建议(不自动修改):${NC}"
if [[ "$boot_mode" == "UEFI" ]]; then
echo " 1) 固件建议OVMFUEFI"
echo " 2) 额外建议:添加 efidisk0 用于 NVRAMPVE 界面可创建)"
if [[ "$mode" != "1" ]]; then
echo " 3) 机器类型建议q35PCIe 设备直通更友好)"
fi
elif [[ "$boot_mode" == "Legacy" ]]; then
echo " 1) 固件建议SeaBIOSLegacy"
else
echo " 1) 未能可靠判断 UEFI/Legacy建议检查磁盘分区表与是否存在 ESP"
echo " 2) 如果是 UEFI 系统:优先使用 OVMF + q35"
fi
if [[ "$mode" == "1" ]]; then
echo " 4) 总线类型建议:优先 scsi总线受限时使用 sata/ide"
if [[ -n "$slot" ]]; then
echo " 5) 启动顺序建议boot: order=${slot};ide2;net0按实际设备调整"
else
echo " 5) 启动顺序建议:确保直通磁盘所在插槽在 boot order 中靠前"
fi
else
echo " 4) 启动建议:控制器/NVMe 直通后,来宾系统会直接看到物理设备;建议使用 UEFI 启动管理器选择启动项"
fi
return 0
}
#--------------开启硬件直通----------------
#--------------设置CPU电源模式----------------
# 设置CPU电源模式

View File

@@ -0,0 +1,292 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
qm_is_q35_machine() {
local vmid="$1"
local machine
machine="$(qm config "$vmid" 2>/dev/null | awk -F': ' '/^machine:/{print $2}' | head -n 1)"
if echo "$machine" | grep -q 'q35'; then
return 0
fi
return 1
}
# 获取可用的 hostpci 插槽号0-15
qm_find_free_hostpci_index() {
local vmid="$1"
local cfg used
cfg="$(qm config "$vmid" 2>/dev/null)"
used="$(echo "$cfg" | awk -F'[: ]' '/^hostpci[0-9]+:/{gsub("hostpci","",$1); print $1}' | sort -n | uniq)"
local i
for ((i=0; i<=15; i++)); do
if ! echo "$used" | grep -qx "$i"; then
echo "$i"
return 0
fi
done
return 1
}
# 从 VM 配置中查找某个 BDF 是否已被直通
qm_has_hostpci_bdf() {
local vmid="$1"
local bdf="$2"
qm config "$vmid" 2>/dev/null | grep -qE "^hostpci[0-9]+:.*\\b${bdf}\\b"
}
# 直通整个 SATA/SCSI/RAID 控制器到 VM含系统盘控制器保护
storage_controller_passthrough() {
log_step "磁盘控制器直通 - 扫描控制器"
if ! iommu_is_enabled; then
display_error "未检测到 IOMMU 已开启" "请先在 BIOS 开启 VT-d/AMD-Vi并在 PVE 中启用 IOMMU可在“硬件直通一键配置(IOMMU)”里开启)。"
return 1
fi
local controllers
controllers="$(list_storage_controllers)"
if [[ -z "$controllers" ]]; then
display_error "未发现 SATA/SCSI/RAID 控制器" "可尝试手工执行 lspci -Dnn 确认控制器是否存在。"
return 1
fi
echo -e "${CYAN}可用控制器列表:${NC}"
echo "$controllers" | awk '{printf " [%d] %s\n", NR, $0}'
echo -e "${UI_DIVIDER}"
local pick
read -p "请选择控制器序号 (返回请输入 0): " pick
pick="${pick:-0}"
if [[ "$pick" == "0" ]]; then
return 0
fi
if [[ ! "$pick" =~ ^[0-9]+$ ]]; then
display_error "序号必须是数字"
return 1
fi
local line bdf
line="$(echo "$controllers" | awk -v n="$pick" 'NR==n{print $0}')"
if [[ -z "$line" ]]; then
display_error "无效的序号: $pick"
return 1
fi
bdf="$(echo "$line" | awk '{print $1}')"
echo -e "${CYAN}该控制器下识别到的整盘设备:${NC}"
show_disks_under_pci_bdf "$bdf"
echo -e "${UI_DIVIDER}"
local protected
protected="$(get_protected_pci_bdfs)"
if echo "$protected" | grep -qx "$bdf"; then
display_error "安全拦截:禁止直通系统盘所在控制器 $bdf" "请勿直通包含 PVE 系统盘的控制器,否则会导致宿主机不可用。"
return 1
fi
local vmid
read -p "请输入目标 VMID: " vmid
if ! validate_qm_vmid "$vmid"; then
return 1
fi
if qm_has_hostpci_bdf "$vmid" "$bdf"; then
display_error "该控制器已在 VM 配置中存在直通记录" "无需重复直通。"
return 1
fi
local idx
idx="$(qm_find_free_hostpci_index "$vmid" 2>/dev/null)" || {
display_error "未找到可用 hostpci 插槽" "请先释放 VM 的 hostpci0-hostpci15 后再试。"
return 1
}
local hostpci_value="$bdf"
if qm_is_q35_machine "$vmid"; then
hostpci_value="${hostpci_value},pcie=1"
fi
local conf_path
conf_path="$(get_qm_conf_path "$vmid")"
if [[ -f "$conf_path" ]]; then
log_tips "修改 VM 配置前建议备份原配置"
backup_file "$conf_path" >/dev/null 2>&1 || true
fi
if ! confirm_action "为 VM $vmid 直通控制器hostpci$idx = $hostpci_value"; then
return 0
fi
if qm set "$vmid" "-hostpci${idx}" "$hostpci_value" >/dev/null 2>&1; then
local status
status="$(qm status "$vmid" 2>/dev/null | awk '{print $2}' | head -n 1)"
display_success "控制器直通已写入 VM 配置" "当前 VM 状态: ${status:-unknown}(如在运行中,需重启 VM 后生效)"
return 0
else
display_error "qm set 执行失败" "请检查 IOMMU/IOMMU group、VM 是否锁定,或查看 /var/log/pve-tools.log。"
return 1
fi
}
# 判断 NVMe 设备是否建议启用 MSI-X 重定位(启发式:存在 MSI-X 且存在 BAR2/Region 2
nvme_should_enable_msix_relocation() {
local bdf="$1"
local vv
vv="$(lspci -vv -s "$bdf" 2>/dev/null || true)"
if echo "$vv" | grep -q 'MSI-X:' && echo "$vv" | grep -qE 'Region 2: Memory|Region 2:.*Memory'; then
return 0
fi
return 1
}
# 获取当前 VM args不存在则返回空
qm_get_args() {
local vmid="$1"
qm config "$vmid" 2>/dev/null | awk -F': ' '/^args:/{sub(/^args: /,""); print $0; exit}'
}
# 幂等追加 VM args 片段(通过 qm set -args 覆盖式写入,但内容基于现有 args 合并)
qm_append_args() {
local vmid="$1"
local token="$2"
if [[ -z "$token" ]]; then
return 1
fi
local current
current="$(qm_get_args "$vmid")"
if echo "$current" | grep -Fq "$token"; then
return 0
fi
local new_args
if [[ -z "$current" ]]; then
new_args="$token"
else
new_args="${current} ${token}"
fi
qm set "$vmid" -args "$new_args" >/dev/null 2>&1
}
# NVMe 控制器直通到 VM含系统盘控制器保护与 MSI-X 重定位 args
nvme_passthrough() {
log_step "NVMe 直通 - 扫描 NVMe 控制器"
if ! iommu_is_enabled; then
display_error "未检测到 IOMMU 已开启" "请先在 BIOS 开启 VT-d/AMD-Vi并在 PVE 中启用 IOMMU可在“硬件直通一键配置(IOMMU)”里开启)。"
return 1
fi
local controllers
controllers="$(list_nvme_controllers)"
if [[ -z "$controllers" ]]; then
display_error "未发现 NVMe 控制器" "可尝试手工执行 lspci -Dnn | grep -i NVMe 确认设备是否存在。"
return 1
fi
echo -e "${CYAN}可用 NVMe 控制器列表:${NC}"
echo "$controllers" | awk '{printf " [%d] %s\n", NR, $0}'
echo -e "${UI_DIVIDER}"
local pick
read -p "请选择 NVMe 控制器序号 (返回请输入 0): " pick
pick="${pick:-0}"
if [[ "$pick" == "0" ]]; then
return 0
fi
if [[ ! "$pick" =~ ^[0-9]+$ ]]; then
display_error "序号必须是数字"
return 1
fi
local line bdf
line="$(echo "$controllers" | awk -v n="$pick" 'NR==n{print $0}')"
if [[ -z "$line" ]]; then
display_error "无效的序号: $pick"
return 1
fi
bdf="$(echo "$line" | awk '{print $1}')"
echo -e "${CYAN}该 NVMe 控制器下识别到的整盘设备:${NC}"
show_disks_under_pci_bdf "$bdf"
echo -e "${UI_DIVIDER}"
local protected
protected="$(get_protected_pci_bdfs)"
if echo "$protected" | grep -qx "$bdf"; then
display_error "安全拦截:禁止直通系统盘所在 NVMe 控制器 $bdf" "请勿直通包含 PVE 系统盘的 NVMe 控制器,否则会导致宿主机不可用。"
return 1
fi
local vmid
read -p "请输入目标 VMID: " vmid
if ! validate_qm_vmid "$vmid"; then
return 1
fi
if qm_has_hostpci_bdf "$vmid" "$bdf"; then
display_error "该 NVMe 已在 VM 配置中存在直通记录" "无需重复直通。"
return 1
fi
local idx
idx="$(qm_find_free_hostpci_index "$vmid" 2>/dev/null)" || {
display_error "未找到可用 hostpci 插槽" "请先释放 VM 的 hostpci0-hostpci15 后再试。"
return 1
}
local hostpci_value="$bdf"
if qm_is_q35_machine "$vmid"; then
hostpci_value="${hostpci_value},pcie=1"
fi
local enable_msix="no"
if nvme_should_enable_msix_relocation "$bdf"; then
echo -e "${YELLOW}检测到该 NVMe 可能需要 MSI-X 重定位bar2以提高兼容性。${NC}"
local ans
read -p "是否写入 MSI-X 重定位 args(yes/no) [yes]: " ans
ans="${ans:-yes}"
if [[ "$ans" == "yes" || "$ans" == "YES" ]]; then
enable_msix="yes"
fi
fi
local conf_path
conf_path="$(get_qm_conf_path "$vmid")"
if [[ -f "$conf_path" ]]; then
log_tips "修改 VM 配置前建议备份原配置"
backup_file "$conf_path" >/dev/null 2>&1 || true
fi
if ! confirm_action "为 VM $vmid 直通 NVMehostpci$idx = $hostpci_value),并写入 MSI-X 重定位参数(${enable_msix}"; then
return 0
fi
if ! qm set "$vmid" "-hostpci${idx}" "$hostpci_value" >/dev/null 2>&1; then
display_error "qm set 执行失败" "请检查 IOMMU/IOMMU group、VM 是否锁定,或查看 /var/log/pve-tools.log。"
return 1
fi
if [[ "$enable_msix" == "yes" ]]; then
local token
token="-set device.hostpci${idx}.x-msix-relocation=bar2"
if qm_append_args "$vmid" "$token"; then
log_success "已写入 args: $token"
else
log_warn "args 写入失败(已完成 hostpci 直通)"
fi
fi
local status
status="$(qm status "$vmid" 2>/dev/null | awk '{print $2}' | head -n 1)"
display_success "NVMe 直通已写入 VM 配置" "当前 VM 状态: ${status:-unknown}(如在运行中,需重启 VM 后生效)"
return 0
}
# ============ 引导配置辅助 ============
# 解析用户输入的磁盘路径为真实整盘设备(返回 /dev/sdX 或 /dev/nvme0n1

View File

@@ -0,0 +1,348 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
igpu_management_menu_simple() {
while true; do
clear
show_menu_header "Intel 核显虚拟化管理"
show_menu_option "1" "Intel 11-15代 SR-IOV 配置 (DKMS)"
show_menu_option "2" "Intel 6-10代 GVT-g 配置 (传统模式)"
show_menu_option "3" "验证核显虚拟化状态"
show_menu_option "4" "清理核显虚拟化配置 (恢复默认)"
show_menu_option "0" "返回主菜单"
show_menu_footer
read -p "请选择操作 [0-4]: " choice
case $choice in
1) igpu_sriov_setup ;;
2) igpu_gvtg_setup ;;
3) igpu_verify ;;
4) restore_igpu_config ;;
0) return ;;
*) log_error "无效选择" ;;
esac
pause_function
done
}
# Intel 11-15代 SR-IOV 核显虚拟化配置
restore_igpu_config() {
log_step "开始清理核显虚拟化配置 (恢复默认)"
echo -e " 此操作将执行以下步骤:"
echo -e " 1. 移除 ${CYAN}GRUB${NC} 中的核显相关参数"
echo -e " 2. 从 ${CYAN}/etc/modules${NC} 移除核显相关模块"
echo -e " 3. 更新 ${CYAN}GRUB${NC}${CYAN}initramfs${NC}"
echo -e " 适用于因配置核显虚拟化导致系统异常或想要重置配置的情况。"
echo
if ! confirm_action "是否继续执行清理操作?"; then
return
fi
# 1. 恢复 GRUB 配置
log_info "正在清理 GRUB 参数..."
if [[ -f "/etc/default/grub" ]]; then
# 备份 GRUB 配置
backup_file "/etc/default/grub"
# 移除相关参数
sed -i 's/intel_iommu=on//g' /etc/default/grub
sed -i 's/iommu=pt//g' /etc/default/grub
sed -i 's/i915.enable_gvt=1//g' /etc/default/grub
sed -i 's/i915.enable_guc=[0-9]*//g' /etc/default/grub
sed -i 's/i915.max_vfs=[0-9]*//g' /etc/default/grub
# 清理多余空格
sed -i 's/GRUB_CMDLINE_LINUX_DEFAULT="[[:space:]]*/GRUB_CMDLINE_LINUX_DEFAULT="/g' /etc/default/grub
sed -i 's/[[:space:]]*"$/"/g' /etc/default/grub
sed -i 's/[[:space:]]\{2,\}/ /g' /etc/default/grub
log_success "GRUB 参数清理完成"
else
log_error "未找到 /etc/default/grub 文件"
fi
# 2. 恢复 /etc/modules
log_info "正在清理 /etc/modules..."
if [[ -f "/etc/modules" ]]; then
backup_file "/etc/modules"
sed -i '/vfio/d' /etc/modules
sed -i '/vfio_iommu_type1/d' /etc/modules
sed -i '/vfio_pci/d' /etc/modules
sed -i '/vfio_virqfd/d' /etc/modules
sed -i '/kvmgt/d' /etc/modules
log_success "/etc/modules 清理完成"
fi
# 3. 更新系统配置
log_info "正在更新 GRUB..."
update-grub
log_info "正在更新 initramfs..."
update-initramfs -u -k all
log_success "清理完成!核显虚拟化配置已重置。"
if confirm_action "是否现在重启系统?"; then
reboot
fi
}
# 验证核显虚拟化状态
igpu_verify() {
echo
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " 核显虚拟化状态检查"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo
# 检查 IOMMU
echo "1. 检查 IOMMU 状态..."
if dmesg | grep -qi "DMAR.*IOMMU\|iommu.*enabled"; then
echo -e " ✓ IOMMU 已启用"
echo " $(dmesg | grep -i "DMAR.*IOMMU\|iommu.*enabled" | head -3)"
else
echo -e " ✗ IOMMU 未启用"
echo " 提示: 请检查 BIOS 是否开启 VT-d"
echo " 提示: 请检查 GRUB 配置是否包含 intel_iommu=on"
fi
echo
# 检查 VFIO 模块
echo "2. 检查 VFIO 模块加载状态..."
if lsmod | grep -q vfio; then
echo -e " ✓ VFIO 模块已加载"
echo " $(lsmod | grep vfio)"
else
echo -e " ✗ VFIO 模块未加载"
echo " 提示: 请检查 /etc/modules 配置"
fi
echo
# 检查 SR-IOV
echo "3. 检查 SR-IOV 虚拟核显..."
if lspci | grep -i "VGA.*Intel" | wc -l | grep -q "^[2-9]"; then
vf_count=$(($(lspci | grep -i "VGA.*Intel" | wc -l) - 1))
echo -e " ✓ 检测到 $vf_count 个虚拟核显 (SR-IOV)"
echo
lspci | grep -i "VGA.*Intel"
echo
echo " 提示: 物理核显 00:02.0 不能直通"
echo " 提示: 虚拟核显 00:02.1 ~ 00:02.$vf_count 可直通给虚拟机"
else
echo -e " ! 未检测到 SR-IOV 虚拟核显"
fi
echo
# 检查 GVT-g
echo "4. 检查 GVT-g mdev 类型..."
if [ -d "/sys/bus/pci/devices/0000:00:02.0/mdev_supported_types" ]; then
mdev_types=$(ls /sys/bus/pci/devices/0000:00:02.0/mdev_supported_types 2>/dev/null | wc -l)
if [ "$mdev_types" -gt 0 ]; then
echo -e " ✓ GVT-g 已启用,可用 Mdev 类型: $mdev_types"
echo
ls -1 /sys/bus/pci/devices/0000:00:02.0/mdev_supported_types
else
echo -e " ! GVT-g 未正确配置"
fi
else
echo -e " ! 未检测到 GVT-g 支持"
echo " 提示: 此 CPU 可能不支持 GVT-g 或未配置"
fi
echo
# 检查 kvmgt 模块GVT-g 需要)
echo "5. 检查 kvmgt 模块GVT-g..."
if lsmod | grep -q kvmgt; then
echo -e " ✓ kvmgt 模块已加载GVT-g 模式)"
else
echo " kvmgt 模块未加载SR-IOV 模式或未配置 GVT-g"
fi
echo
# 检查 i915 驱动参数
echo "6. 检查 i915 驱动参数..."
if [ -f "/sys/module/i915/parameters/enable_guc" ]; then
guc_value=$(cat /sys/module/i915/parameters/enable_guc)
if [ "$guc_value" = "3" ]; then
echo -e " ✓ i915.enable_guc = 3 (SR-IOV 模式)"
else
echo " i915.enable_guc = $guc_value"
fi
fi
if [ -f "/sys/module/i915/parameters/enable_gvt" ]; then
gvt_value=$(cat /sys/module/i915/parameters/enable_gvt)
if [ "$gvt_value" = "Y" ]; then
echo -e " ✓ i915.enable_gvt = Y (GVT-g 模式)"
else
echo " i915.enable_gvt = $gvt_value"
fi
fi
echo
# 总结
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " 检查完成"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
pause_function
}
# 移除核显虚拟化配置
igpu_remove() {
echo
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo -e " 警告 - 移除核显虚拟化配置"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo
echo -e " 此操作将:"
echo " • 恢复 GRUB 配置为默认值"
echo " • 清理 /etc/modules 中的 VFIO 和 kvmgt 模块"
echo " • 删除 /etc/sysfs.conf 中的 VFs 配置"
echo " • 卸载 i915-sriov-dkms 驱动(如已安装)"
echo
echo -e " 注意:此操作不会自动重启系统"
echo
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
if ! confirm_action "确认移除核显虚拟化配置"; then
echo "用户取消操作"
return 0
fi
# 恢复 GRUB 配置
echo "恢复 GRUB 配置..."
backup_file "/etc/default/grub"
# 移除所有核显虚拟化参数
sed -i 's/intel_iommu=on//g; s/iommu=pt//g; s/i915.enable_guc=3//g; s/i915.max_vfs=7//g; s/module_blacklist=xe//g; s/i915.enable_gvt=1//g; s/pcie_acs_override=downstream,multifunction//g' /etc/default/grub
# 清理多余空格
sed -i 's/ */ /g' /etc/default/grub
update-grub
echo -e " ✓ GRUB 配置已恢复"
# 清理 /etc/modules
echo "清理内核模块配置..."
backup_file "/etc/modules"
sed -i '/^vfio$/d; /^vfio_iommu_type1$/d; /^vfio_pci$/d; /^vfio_virqfd$/d; /^kvmgt$/d' /etc/modules
echo -e " ✓ 内核模块配置已清理"
# 清理 /etc/sysfs.conf
if [ -f "/etc/sysfs.conf" ]; then
echo "清理 sysfs 配置..."
backup_file "/etc/sysfs.conf"
sed -i '/sriov_numvfs/d' /etc/sysfs.conf
echo -e " ✓ sysfs 配置已清理"
fi
# 卸载 i915-sriov-dkms
echo "检查 i915-sriov-dkms 驱动..."
if dpkg -l | grep -q i915-sriov-dkms; then
echo "卸载 i915-sriov-dkms 驱动..."
dpkg -P i915-sriov-dkms || echo -e "${YELLOW}警告: 卸载驱动失败,可能需要手动处理${NC}"
echo -e "✓ 驱动已卸载"
else
echo "未安装 i915-sriov-dkms 驱动,跳过"
fi
# 更新 initramfs
echo "更新 initramfs..."
update-initramfs -u -k all
echo
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo -e "✓ 核显虚拟化配置已移除"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo -e "提示: 请重启系统使更改生效"
if confirm_action "是否现在重启系统"; then
echo "正在重启系统..."
reboot
else
echo "请记得手动重启系统"
fi
}
# 核显高级功能菜单
igpu_management_menu() {
while true; do
clear
show_menu_header "核显虚拟化高级功能"
echo -e " ${RED}【危险警告】${NC} 核显虚拟化属于高危操作"
echo -e " 配置错误可能导致系统无法启动,请务必提前备份 GRUB 配置"
echo "${UI_DIVIDER}"
show_menu_option "1" "Intel 11-15代 SR-IOV 核显虚拟化"
echo -e " ${CYAN}支持:${NC} Rocket Lake, Alder Lake, Raptor Lake"
echo -e " ${CYAN}特性:${NC} 最多 7 个虚拟核显,性能较好"
show_menu_option "2" "Intel 6-10代 GVT-g 核显虚拟化"
echo -e " ${CYAN}支持:${NC} Skylake ~ Comet Lake"
echo -e " ${CYAN}特性:${NC} 最多 2-8 个虚拟核显(取决于型号)"
show_menu_option "3" "验证核显虚拟化状态"
echo -e " ${CYAN}检查:${NC} IOMMU、VFIO、SR-IOV/GVT-g 配置"
show_menu_option "4" "移除核显虚拟化配置"
echo -e " ${CYAN}恢复:${NC} 默认配置,移除所有核显虚拟化设置"
echo "${UI_DIVIDER}"
show_menu_option "" "GRUB 配置管理(强烈推荐使用)"
echo "${UI_DIVIDER}"
show_menu_option "5" "查看当前 GRUB 配置"
echo -e " ${CYAN}展示:${NC} 当前的 GRUB 引导参数和关键配置"
show_menu_option "6" "备份 GRUB 配置"
echo -e " ${CYAN}路径:${NC} /etc/pvetools9/backup/grub/"
show_menu_option "7" "查看 GRUB 备份列表"
show_menu_option "8" "恢复 GRUB 配置"
echo "${UI_DIVIDER}"
show_menu_option "0" "返回主菜单"
show_menu_footer
echo
read -p "请选择操作 [0-8]: " choice
case $choice in
1)
igpu_sriov_setup
;;
2)
igpu_gvtg_setup
;;
3)
igpu_verify
;;
4)
igpu_remove
;;
5)
show_grub_config
pause_function
;;
6)
echo
echo "请输入备份备注例如手动备份_测试"
read -p "> " backup_note
backup_note=${backup_note:-"手动备份"}
backup_grub_with_note "$backup_note"
pause_function
;;
7)
list_grub_backups
pause_function
;;
8)
restore_grub_backup
;;
0)
echo "返回主菜单"
return 0
;;
*)
echo -e "无效的选择,请输入 0-8"
pause_function
;;
esac
done
}
#--------------核显虚拟化管理----------------
#---------PVE8/9添加ceph-squid源-----------

View File

@@ -0,0 +1,62 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
menu_gpu_passthrough() {
while true; do
clear
show_menu_header "直通与显卡"
show_menu_option "1" "Intel 核显虚拟化管理 (SR-IOV/GVT-g)"
show_menu_option "2" "Intel 核显直通配置 (修改版 QEMU)"
show_menu_option "3" "NVIDIA 显卡直通/虚拟化"
show_menu_option "4" "AMD 独显直通"
show_menu_option "5" "AMD 核显直通 (需自备 ROM / vBIOS)"
show_menu_option "6" "硬件直通一键配置 (IOMMU)"
show_menu_option "7" "磁盘/控制器直通 (RDM/PCIe/NVMe)"
show_menu_option "0" "返回主菜单"
show_menu_footer
read -p "请选择操作 [0-7]: " choice
case $choice in
1) igpu_management_menu ;;
2) intel_gpu_passthrough ;;
3) nvidia_gpu_management_menu ;;
4) amd_gpu_management_menu ;;
5) amd_igpu_management_menu ;;
6) hw_passth ;;
7) menu_disk_controller_passthrough ;;
0) return ;;
*) log_error "无效选择" ;;
esac
pause_function
done
}
# 虚拟机/容器定时开关机管理
menu_disk_controller_passthrough() {
while true; do
clear
show_menu_header "磁盘/控制器直通"
show_menu_option "1" "RDM裸磁盘映射- 单个磁盘直通"
show_menu_option "2" "RDM 取消直通(--delete"
show_menu_option "3" "磁盘控制器直通PCIe"
show_menu_option "4" "NVMe 直通(含 MSI-X 重定位)"
show_menu_option "5" "引导配置辅助UEFI/Legacy"
show_menu_option "0" "返回"
show_menu_footer
read -p "请选择操作 [0-6]: " choice
case "$choice" in
1) rdm_single_disk_attach ;;
2) rdm_single_disk_detach ;;
3) storage_controller_passthrough ;;
4) nvme_passthrough ;;
5) boot_config_assistant ;;
0) return ;;
*) log_error "无效选择" ;;
esac
pause_function
done
}
# ============ RDM裸磁盘映射单盘直通 ============
# 获取 VM 配置文件路径(不保证一定存在,需调用方自行判断)

View File

@@ -0,0 +1,159 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
igpu_gvtg_setup() {
echo -e "${H2}开始配置 Intel 6-10代 GVT-g 核显虚拟化${NC}"
echo -e "详细原理与教程: ${CYAN}https://pve.oowo.cc/advanced/gpu-virtualization${NC}"
echo -e "如果配置失败,请访问文档站下方留言反馈。"
echo
# 展示当前 GRUB 配置
echo
show_grub_config
echo
# 危险性警告
echo "$UI_BORDER"
echo -e " ${RED}【高危操作警告】${NC} GVT-g 核显虚拟化配置"
echo "$UI_BORDER"
echo -e " 此操作属于${RED}【高危险性】${NC}系统配置,配置错误可能导致:"
echo -e " - ${YELLOW}系统无法正常启动${NC}GRUB 配置错误)"
echo -e " - ${YELLOW}核显完全不可用${NC}(参数配置错误)"
echo -e " - ${YELLOW}虚拟机黑屏或无法启动${NC}(直通配置错误)"
echo -e " - ${YELLOW}需要通过恢复模式修复系统${NC}"
echo "$UI_BORDER"
echo
echo -e " 此功能将修改以下系统配置:"
echo -e " 1. 修改 ${CYAN}GRUB 引导参数${NC}(启用 IOMMU 和 GVT-g"
echo -e " 2. 加载 ${CYAN}VFIO${NC}${CYAN}kvmgt${NC} 内核模块"
echo
echo -e " ${GREEN}前置要求(请确认已完成):${NC}"
echo -e " ${GREEN}${NC} BIOS 已开启 ${CYAN}VT-d${NC} 虚拟化"
echo -e " ${GREEN}${NC} BIOS 已开启 ${CYAN}SR-IOV${NC}(如有此选项)"
echo -e " ${GREEN}${NC} BIOS 已开启 ${CYAN}Above 4GB${NC}(如有此选项)"
echo -e " ${GREEN}${NC} BIOS 已关闭 ${CYAN}Secure Boot${NC} 安全启动"
echo -e " ${GREEN}${NC} CPU 为 ${CYAN}Intel 6-10 代${NC} 处理器"
echo
echo -e " ${PRIMARY}支持的处理器代号:${NC}"
echo -e " ${BLUE}${NC} Skylake (6代)"
echo -e " ${BLUE}${NC} Kaby Lake (7代)"
echo -e " ${BLUE}${NC} Coffee Lake (8代)"
echo -e " ${BLUE}${NC} Coffee Lake Refresh (9代)"
echo -e " ${BLUE}${NC} Comet Lake (10代)"
echo
echo -e " ${MAGENTA}特殊的处理器代号:${NC}"
echo -e " ${MAGENTA}${NC} Rocket Lake / Tiger Lake (11代) 因处在当前代与上一代交界"
echo -e " 部分型号支持,但是不保证兼容性,请谨慎使用"
echo "$UI_BORDER"
echo
echo -e "${YELLOW}强烈建议:${NC}"
echo -e " ${CYAN}提示 1:${NC} 在继续前先备份当前 GRUB 配置"
echo -e " ${CYAN}提示 2:${NC} 确保了解核显虚拟化的工作原理"
echo -e " ${CYAN}提示 3:${NC} 准备好通过 SSH 或物理访问恢复系统"
echo
# 询问是否要备份
if confirm_action "是否先备份当前 GRUB 配置(强烈推荐)"; then
echo
echo "请输入备份备注例如GVT-g配置前备份"
read -p "> " backup_note
backup_note=${backup_note:-"GVT-g配置前备份"}
backup_grub_with_note "$backup_note"
echo
fi
if ! confirm_action "确认继续配置 GVT-g 核显虚拟化"; then
echo "用户取消操作"
return 0
fi
# 备份并修改 GRUB 配置
echo "配置 GRUB 引导参数..."
backup_file "/etc/default/grub"
# 使用幂等的 GRUB 参数管理函数
echo "配置 GRUB 参数..."
# 移除旧的 SR-IOV 配置(如果有)
grub_remove_param "i915.enable_guc"
grub_remove_param "i915.max_vfs"
grub_remove_param "module_blacklist"
# 添加 GVT-g 参数(幂等操作,不会重复添加)
grub_add_param "intel_iommu=on"
grub_add_param "iommu=pt"
grub_add_param "i915.enable_gvt=1"
grub_add_param "pcie_acs_override=downstream,multifunction"
echo -e "✓ GRUB 配置已更新"
# 更新 GRUB
echo "更新 GRUB..."
update-grub || {
echo -e "更新 GRUB 失败"
pause_function
return 1
}
# 配置内核模块
echo "配置内核模块..."
backup_file "/etc/modules"
# 清理可能存在的 i915 及音视频相关黑名单 (GVT-g 需要 i915 驱动加载)
echo "清理可能存在的 i915 及音视频相关黑名单..."
for f in /etc/modprobe.d/blacklist.conf /etc/modprobe.d/pve-blacklist.conf; do
if [ -f "$f" ]; then
sed -i '/blacklist i915/d' "$f"
sed -i '/blacklist snd_hda_intel/d' "$f"
sed -i '/blacklist snd_hda_codec_hdmi/d' "$f"
fi
done
# 添加 VFIO 和 kvmgt 模块
for module in vfio vfio_iommu_type1 vfio_pci vfio_virqfd kvmgt; do
if ! grep -q "^$module$" /etc/modules; then
echo "$module" >> /etc/modules
echo "已添加模块: $module"
fi
done
echo -e "✓ 内核模块配置完成"
# 更新 initramfs
echo "更新 initramfs..."
update-initramfs -u -k all || {
echo -e "更新 initramfs 失败,但可以继续"
}
# 完成提示
echo
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo -e "✓ GVT-g 核显虚拟化配置完成!"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo
echo "配置摘要:"
echo " • 内核参数: intel_iommu=on iommu=pt i915.enable_gvt=1"
echo " • VFIO 模块: 已加载"
echo " • kvmgt 模块: 已加载"
echo
echo -e "下一步操作:"
echo -e " 1. 重启系统使配置生效"
echo " 2. 重启后使用 '验证核显虚拟化状态' 检查配置"
echo " 3. 在虚拟机配置中添加核显 GVT-g 设备Mdev 类型)"
echo
echo "常见 Mdev 类型:"
echo " • i915-GVTg_V5_4: 低性能,可创建更多虚拟机"
echo " • i915-GVTg_V5_8: 高性能推荐使用UHD630 最多 2 个)"
echo
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
if confirm_action "是否现在重启系统"; then
echo "正在重启系统..."
reboot
else
echo -e "请记得手动重启系统以使配置生效"
fi
}
# 清理 GVT-g 和 SR-IOV 配置 (恢复默认)

View File

@@ -0,0 +1,329 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
restore_qemu_kvm() {
log_step "开始恢复官方 pve-qemu-kvm"
echo "此操作将执行以下步骤:"
echo "1. 解除 pve-qemu-kvm 的版本锁定 (unhold)"
echo "2. 强制重新安装官方版本的 pve-qemu-kvm"
echo "3. 恢复官方的 initramfs 设置"
echo "适用于因安装修改版 QEMU 导致虚拟机无法启动或系统异常的情况。"
echo
if ! confirm_action "是否继续执行恢复操作?"; then
return
fi
# 1. 解除锁定
log_info "正在解除软件包锁定..."
apt-mark unhold pve-qemu-kvm
# 2. 强制重装官方版本
log_info "正在重新安装官方 pve-qemu-kvm..."
if apt-get update && apt-get install --reinstall -y pve-qemu-kvm; then
log_success "官方 pve-qemu-kvm 恢复成功"
else
log_error "恢复失败,请检查网络连接或手动尝试: apt-get install --reinstall pve-qemu-kvm"
return 1
fi
# 3. 清理黑名单 (可选)
if confirm_action "是否同时清理 Intel 核显相关的驱动黑名单?"; then
log_info "正在清理黑名单配置..."
sed -i '/blacklist i915/d' /etc/modprobe.d/pve-blacklist.conf
sed -i '/blacklist snd_hda_intel/d' /etc/modprobe.d/pve-blacklist.conf
sed -i '/blacklist snd_hda_codec_hdmi/d' /etc/modprobe.d/pve-blacklist.conf
log_info "正在更新 initramfs..."
update-initramfs -u -k all
log_success "黑名单清理完成"
fi
log_success "救砖操作完成!建议重启系统。"
if confirm_action "是否现在重启系统?"; then
reboot
fi
}
#英特尔核显直通
intel_gpu_passthrough() {
log_step "开始 Intel 核显直通配置"
echo "注意:此功能基于 AICodo 的修改版 QEMU 和 ROM"
echo "详细原理与教程https://pve.oowo.cc/advanced/gpu-passthrough"
echo "适用于需要将 Intel 核显直通给 Windows 虚拟机且遇到代码 43 或黑屏的情况"
echo "支持的 CPU 架构6代(Skylake) 到 14代(Raptor Lake Refresh)"
echo "项目地址https://github.com/AICodo/intel6-14rom"
echo
log_warn "警告"
log_warn "本功能并非能100%一次成功!"
echo
log_warn "由于 Intel 牙膏厂混乱的代号和半代升级策略(如 N5105 Jasper Lake 等)"
log_warn "通用 ROM 无法保证 100% 适用于所有 CPU 型号!"
log_warn "直通失败属于正常现象,请尝试更换其他版本的 ROM 或自行寻找专用 ROM"
log_warn "本功能仅提供自动化配置辅助,作者精力有限,无法提供免费的一对一排错服务"
log_warn "折腾有风险,入坑需谨慎!"
echo
log_tips "如果配置失败,请访问文档站查看详细教程并留言反馈:"
log_tips "🔗 https://pve.oowo.cc/advanced/gpu-passthrough"
echo
log_tips "如需要反馈或者请求更新ROM文件适配你的CPU请前往AICodo的GitHub仓库开ISSUE反馈不是找我。"
echo
echo "请选择操作:"
echo " 1) 开始配置 (安装修改版 QEMU + 下载 ROM)"
echo " 2) 救砖模式 (恢复官方 QEMU + 清理配置)"
echo " 0) 返回上级菜单"
read -p "请输入选择 [0-2]: " choice
case $choice in
1)
# 继续执行配置流程
;;
2)
restore_qemu_kvm
return
;;
0)
return
;;
*)
log_error "无效选择"
return
;;
esac
# 1. 配置黑名单
log_step "配置驱动黑名单 (屏蔽宿主机占用核显)"
if ! grep -q "blacklist i915" /etc/modprobe.d/pve-blacklist.conf; then
echo "blacklist i915" >> /etc/modprobe.d/pve-blacklist.conf
echo "blacklist snd_hda_intel" >> /etc/modprobe.d/pve-blacklist.conf
echo "blacklist snd_hda_codec_hdmi" >> /etc/modprobe.d/pve-blacklist.conf
log_success "已添加黑名单配置"
log_info "正在更新 initramfs..."
update-initramfs -u -k all
else
log_info "黑名单配置已存在,跳过"
fi
# 2. 安装修改版 QEMU
log_step "安装修改版 pve-qemu-kvm"
echo "正在获取最新 release 版本..."
# 尝试获取最新下载链接 (这里为了稳定性暂时写死或使用最新已知的逻辑,实际可爬虫获取)
# 根据用户提供的信息,修改版 QEMU 下载地址: https://github.com/AICodo/pve-anti-detection/releases
# 为了简化,我们使用 ghfast.top 加速下载最新的 release
# 注意:这里需要动态获取最新 deb 包链接,或者让用户手动输入链接
# 为方便起见,这里演示自动获取逻辑
local qemu_releases_url="https://api.github.com/repos/AICodo/pve-anti-detection/releases/latest"
local qemu_deb_url=$(curl -s $qemu_releases_url | grep "browser_download_url.*deb" | cut -d '"' -f 4 | head -n 1)
if [ -z "$qemu_deb_url" ]; then
log_warn "无法自动获取修改版 QEMU 下载链接,尝试使用备用链接或手动下载"
# 备用逻辑:提示用户手动下载
echo "请访问 https://github.com/AICodo/pve-anti-detection/releases 下载最新 deb 包"
echo "然后使用 dpkg -i 安装"
else
# 加速下载
local fast_qemu_url="https://ghfast.top/${qemu_deb_url}"
log_info "正在下载: $fast_qemu_url"
wget -O /tmp/pve-qemu-kvm.deb "$fast_qemu_url"
if [ -s "/tmp/pve-qemu-kvm.deb" ]; then
log_info "正在安装修改版 QEMU..."
dpkg -i /tmp/pve-qemu-kvm.deb
log_success "安装完成"
# 阻止更新
apt-mark hold pve-qemu-kvm
log_info "已锁定 pve-qemu-kvm 防止自动更新"
else
log_error "下载失败"
fi
fi
# 3. 下载 ROM 文件
log_step "下载核显 ROM 文件"
echo "正在检测 CPU 型号..."
local cpu_model=$(lscpu | grep "Model name" | awk -F: '{print $2}' | xargs)
echo "CPU 型号: $cpu_model"
# 优先推荐的通用 ROM
local recommended_rom="6-14-qemu10.rom"
# 特殊 CPU 型号映射表 (根据 release 信息整理)
# 格式: "关键字|ROM文件名"
local special_cpus=(
"J6412|11-J6412-q10.rom"
"N5095|11-n5095-q10.rom"
"1240P|12-1240p-q10.rom"
"N100|12-n100-q10.rom"
"J4125|j4125-q10.rom"
"N2930|N2930-q10.rom"
"N3350|N3350-q10.rom"
"11700H|nb-11-11700h-q10.rom"
"1185G7|nb-11-1185G7E-q10.rom"
"12700H|nb-12-12700h-q10.rom"
"13700H|nb-13-13700h-q10.rom"
)
# 检测是否为特殊 CPU
for item in "${special_cpus[@]}"; do
local keyword="${item%%|*}"
local rom_name="${item##*|}"
if echo "$cpu_model" | grep -qi "$keyword"; then
recommended_rom="$rom_name"
log_success "检测到特殊 CPU ($keyword),推荐使用专用 ROM: $recommended_rom"
break
fi
done
# 下载 ROM 文件
local rom_releases_url="https://api.github.com/repos/AICodo/intel6-14rom/releases/latest"
log_info "正在获取 ROM 列表..."
# 获取 release 信息
# 注意:这里我们使用 grep 简单提取下载链接和文件名
local release_info=$(curl -s $rom_releases_url)
local assets=$(echo "$release_info" | grep "browser_download_url" | cut -d '"' -f 4)
if [ -z "$assets" ]; then
log_error "无法获取 ROM 下载链接"
return
fi
# 显示 ROM 列表供用户选择
echo "------------------------------------------------"
echo "可用的 ROM 文件列表:"
local i=1
local rom_list=()
local recommended_index=0
for url in $assets; do
local fname=$(basename "$url")
# 过滤非 .rom 文件 (如 patch)
if [[ "$fname" != *.rom ]]; then
continue
fi
rom_list+=("$fname|$url")
if [[ "$fname" == "$recommended_rom" ]]; then
echo -e " $i) ${GREEN}$fname (推荐)${NC}"
recommended_index=$i
else
echo " $i) $fname"
fi
((i++))
done
echo "------------------------------------------------"
# 让用户选择
local choice
if [ $recommended_index -gt 0 ]; then
read -p "请输入序号选择 ROM [默认 $recommended_index]: " choice
choice=${choice:-$recommended_index}
else
read -p "请输入序号选择 ROM: " choice
fi
# 验证选择
if [[ ! "$choice" =~ ^[0-9]+$ ]] || [ "$choice" -lt 1 ] || [ "$choice" -ge $i ]; then
log_error "无效选择"
return
fi
# 获取选中的 ROM 信息
local selected_item="${rom_list[$((choice-1))]}"
local selected_fname="${selected_item%%|*}"
local selected_url="${selected_item##*|}"
# 下载选中的 ROM
local fast_url="https://ghfast.top/${selected_url}"
log_info "正在下载: $selected_fname"
wget -O "/usr/share/kvm/$selected_fname" "$fast_url"
if [ ! -s "/usr/share/kvm/$selected_fname" ]; then
log_error "下载失败"
return
fi
log_success "ROM 文件已就绪: $selected_fname"
local rom_filename="$selected_fname"
# 4. 自动配置虚拟机
log_step "配置虚拟机参数"
# 获取 VMID
echo "请选择要配置直通的虚拟机 ID (VMID):"
ls /etc/pve/qemu-server/*.conf | awk -F/ '{print $NF}' | sed 's/.conf//' | xargs -n1 echo " -"
read -p "请输入 VMID: " vmid
if [ -z "$vmid" ] || [ ! -f "/etc/pve/qemu-server/$vmid.conf" ]; then
log_error "无效的 VMID 或配置文件不存在"
return
fi
# 获取核显 PCI ID
echo "正在查找 Intel 核显设备..."
local igpu_pci=$(lspci -D | grep -i "VGA compatible controller" | grep -i "Intel" | head -n1 | awk '{print $1}')
if [ -z "$igpu_pci" ]; then
log_error "未找到 Intel 核显设备"
return
fi
echo "找到核显设备: $igpu_pci"
# 获取声卡 PCI ID (通常和核显在一起,但也可能分开)
local audio_pci=$(lspci -D | grep -i "Audio device" | grep -i "Intel" | head -n1 | awk '{print $1}')
if [ -n "$audio_pci" ]; then
echo "找到声卡设备: $audio_pci"
else
log_warn "未找到配套声卡设备,将只直通核显"
fi
if ! confirm_action "即将修改虚拟机 $vmid 的配置,是否继续?"; then
return
fi
# 备份配置文件
backup_file "/etc/pve/qemu-server/$vmid.conf"
# 修改 args
local args_line="-set device.hostpci0.bus=pcie.0 -set device.hostpci0.addr=0x02.0 -set device.hostpci0.x-igd-gms=0x2 -set device.hostpci0.x-igd-opregion=on -set device.hostpci0.x-igd-lpc=on"
# 如果有声卡,添加 hostpci1 的 args 配置
if [ -n "$audio_pci" ]; then
args_line="$args_line -set device.hostpci1.bus=pcie.0 -set device.hostpci1.addr=0x03.0"
fi
# 写入 args (先删除旧的 args)
sed -i '/^args:/d' "/etc/pve/qemu-server/$vmid.conf"
echo "args: $args_line" >> "/etc/pve/qemu-server/$vmid.conf"
# 写入 hostpci0 (核显)
# 先删除旧的 hostpci0
sed -i '/^hostpci0:/d' "/etc/pve/qemu-server/$vmid.conf"
# 格式: hostpci0: 0000:00:02.0,romfile=xxx.rom
# 注意:这里 PCI ID 使用 lspci 获取到的真实 ID通常是 0000:00:02.0
echo "hostpci0: $igpu_pci,romfile=$rom_filename" >> "/etc/pve/qemu-server/$vmid.conf"
# 写入 hostpci1 (声卡)
if [ -n "$audio_pci" ]; then
sed -i '/^hostpci1:/d' "/etc/pve/qemu-server/$vmid.conf"
echo "hostpci1: $audio_pci" >> "/etc/pve/qemu-server/$vmid.conf"
fi
log_success "虚拟机 $vmid 配置完成"
echo "已添加 args 参数和 hostpci 设备"
echo "请记得在虚拟机中安装驱动: https://downloadmirror.intel.com/854560/gfx_win_101.6793.exe"
echo
echo "注意:需要重启宿主机使黑名单生效"
if confirm_action "是否现在重启系统?"; then
reboot
fi
}
# NVIDIA显卡管理菜单

View File

@@ -0,0 +1,278 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
igpu_sriov_setup() {
echo -e "${H2}开始配置 Intel 11-15代 SR-IOV 核显虚拟化${NC}"
echo -e "详细原理与教程: ${CYAN}https://pve.oowo.cc/advanced/gpu-virtualization${NC}"
echo -e "如果配置失败,请访问文档站下方留言反馈。"
echo
# 检查内核版本
kernel_version=$(uname -r | awk -F'-' '{print $1}')
kernel_major=$(echo $kernel_version | cut -d'.' -f1)
kernel_minor=$(echo $kernel_version | cut -d'.' -f2)
if [ "$kernel_major" -lt 6 ] || ([ "$kernel_major" -eq 6 ] && [ "$kernel_minor" -lt 8 ]); then
echo -e "${RED}SR-IOV 需要内核版本 6.8 或更高${NC}"
echo -e " ${YELLOW}提示:${NC} 当前内核版本: $(uname -r)"
echo -e " ${YELLOW}提示:${NC} 请先使用内核管理功能升级到 6.8 内核"
pause_function
return 1
fi
echo -e "${GREEN}✓ 内核版本检查通过: $(uname -r)${NC}"
# 展示当前 GRUB 配置
echo
show_grub_config
echo
# 危险性警告
echo "$UI_BORDER"
echo -e " ${RED}【高危操作警告】${NC} SR-IOV 核显虚拟化配置"
echo "$UI_BORDER"
echo -e " 此操作属于${RED}【高危险性】${NC}系统配置,配置错误可能导致:"
echo -e " - ${YELLOW}系统无法正常启动${NC}GRUB 配置错误)"
echo -e " - ${YELLOW}核显完全不可用${NC}(参数配置错误)"
echo -e " - ${YELLOW}虚拟机黑屏或无法启动${NC}(直通配置错误)"
echo -e " - ${YELLOW}需要通过恢复模式修复系统${NC}"
echo "$UI_BORDER"
echo -e " 此功能将修改以下系统配置:"
echo -e " 1. 修改 ${CYAN}GRUB 引导参数${NC}(启用 IOMMU 和 SR-IOV"
echo -e " 2. 加载 ${CYAN}VFIO${NC} 内核模块"
echo -e " 3. 下载并安装 ${CYAN}i915-sriov-dkms${NC} 驱动(约 10MB"
echo -e " 4. 配置虚拟核显数量VFs"
echo
echo -e " ${GREEN}前置要求(请确认已完成):${NC}"
echo -e " ${GREEN}${NC} BIOS 已开启 ${CYAN}VT-d${NC} 虚拟化"
echo -e " ${GREEN}${NC} BIOS 已开启 ${CYAN}SR-IOV${NC}(如有此选项)"
echo -e " ${GREEN}${NC} BIOS 已开启 ${CYAN}Above 4GB${NC}(如有此选项)"
echo -e " ${GREEN}${NC} BIOS 已关闭 ${CYAN}Secure Boot${NC} 安全启动"
echo -e " ${GREEN}${NC} CPU 为 ${CYAN}Intel 11-15 代${NC} 处理器"
echo -e " ${RED}重要:${NC}物理核显 (00:02.0) 不能直通,否则所有虚拟核显将消失"
echo "$UI_BORDER"
echo
echo -e "${YELLOW}强烈建议:${NC}"
echo -e " ${CYAN}提示 1:${NC} 在继续前先备份当前 GRUB 配置"
echo -e " ${CYAN}提示 2:${NC} 确保了解核显虚拟化的工作原理"
echo -e " ${CYAN}提示 3:${NC} 准备好通过 SSH 或物理访问恢复系统"
echo
# 询问是否要备份
if confirm_action "是否先备份当前 GRUB 配置(强烈推荐)"; then
echo
echo "请输入备份备注例如SR-IOV配置前备份"
read -p "> " backup_note
backup_note=${backup_note:-"SR-IOV配置前备份"}
backup_grub_with_note "$backup_note"
echo
fi
if ! confirm_action "确认继续配置 SR-IOV 核显虚拟化"; then
echo "用户取消操作"
return 0
fi
# 安装必要的软件包
echo "安装必要的软件包..."
apt-get update
echo "安装 pve-headers..."
apt-get install -y "pve-headers-$(uname -r)" || {
echo -e "${RED}安装 pve-headers 失败${NC}"
pause_function
return 1
}
echo "安装构建工具..."
apt-get install -y build-essential dkms sysfsutils || {
echo -e "安装构建工具失败"
pause_function
return 1
}
echo -e "✓ 软件包安装完成"
# 备份并修改 GRUB 配置
echo "配置 GRUB 引导参数..."
backup_file "/etc/default/grub"
# 使用幂等的 GRUB 参数管理函数
echo "配置 GRUB 参数..."
# 移除旧的 GVT-g 配置(如果有)
grub_remove_param "i915.enable_gvt"
grub_remove_param "pcie_acs_override"
# 添加 SR-IOV 参数(幂等操作,不会重复添加)
# 针对 6.8+ 内核,必须屏蔽 xe 驱动以防止冲突
# 参考: https://github.com/strongtz/i915-sriov-dkms
grub_add_param "intel_iommu=on"
grub_add_param "iommu=pt"
grub_add_param "i915.enable_guc=3"
grub_add_param "i915.max_vfs=7"
grub_add_param "module_blacklist=xe"
echo -e "✓ GRUB 配置已更新 (已添加 module_blacklist=xe 以兼容 PVE 9.1)"
# 更新 GRUB
echo "更新 GRUB..."
update-grub || {
echo -e "更新 GRUB 失败"
pause_function
return 1
}
# 配置内核模块
echo "配置内核模块..."
backup_file "/etc/modules"
# 清理可能存在的 i915 及音视频相关黑名单 (SR-IOV 需要 i915 驱动加载)
echo "清理可能存在的 i915 及音视频相关黑名单..."
for f in /etc/modprobe.d/blacklist.conf /etc/modprobe.d/pve-blacklist.conf; do
if [ -f "$f" ]; then
sed -i '/blacklist i915/d' "$f"
sed -i '/blacklist snd_hda_intel/d' "$f"
sed -i '/blacklist snd_hda_codec_hdmi/d' "$f"
fi
done
# 添加 VFIO 模块(如果未添加)
for module in vfio vfio_iommu_type1 vfio_pci vfio_virqfd; do
if ! grep -q "^$module$" /etc/modules; then
echo "$module" >> /etc/modules
echo "已添加模块: $module"
fi
done
# 移除 kvmgt 模块(如果有 GVT-g 配置)
sed -i '/^kvmgt$/d' /etc/modules
echo -e "✓ 内核模块配置完成"
# 更新 initramfs
echo "更新 initramfs..."
update-initramfs -u -k all || {
echo -e "更新 initramfs 失败,但可以继续"
}
# 下载并安装 i915-sriov-dkms 驱动
echo "下载 i915-sriov-dkms 驱动..."
echo " 提示: 请在浏览器访问 https://github.com/strongtz/i915-sriov-dkms/releases 选择匹配的版本"
echo " 一般建议选择最新的 release 版本以兼容最新的内核版本"
echo " 输入格式例如2025.11.10"
echo " 不输入回车的默认版本为 2025.11.10,可能不兼容老版本内核,故障表现在无法虚拟出 VFs"
default_dkms_version="2025.11.10"
read -p "请输入要安装的 release 版本号 [默认: ${default_dkms_version}]: " dkms_version_input
dkms_version_input=$(echo "$dkms_version_input" | xargs)
if [ -z "$dkms_version_input" ]; then
dkms_version_input="$default_dkms_version"
fi
# release 标签可能以 v 打头,但 deb 文件名不包含 v
dkms_asset_version=$(echo "$dkms_version_input" | sed 's/^[vV]//')
dkms_tag="$dkms_version_input"
dkms_url="https://github.com/strongtz/i915-sriov-dkms/releases/download/${dkms_tag}/i915-sriov-dkms_${dkms_asset_version}_amd64.deb"
dkms_file="/tmp/i915-sriov-dkms_${dkms_asset_version}_amd64.deb"
# 检查是否已下载
if [ -f "$dkms_file" ]; then
echo "驱动文件已存在,跳过下载"
else
echo "从 GitHub 下载驱动..."
echo " 提示: 如果下载失败,请检查网络或手动下载后放到 /tmp/ 目录"
wget -O "$dkms_file" "$dkms_url" || {
echo -e "下载驱动失败"
echo " 提示: 请手动下载: $dkms_url"
echo " 提示: 并上传到 PVE 的 /tmp/ 目录后重试"
pause_function
return 1
}
fi
echo "安装 i915-sriov-dkms 驱动..."
echo -e "驱动安装可能需要较长时间,请耐心等待..."
dpkg -i "$dkms_file" || {
echo -e "安装驱动失败"
pause_function
return 1
}
# 验证驱动安装
echo "验证驱动安装..."
if modinfo i915 2>/dev/null | grep -q "max_vfs"; then
echo -e "✓ i915-sriov 驱动安装成功"
else
echo -e "驱动验证失败,请检查安装过程"
pause_function
return 1
fi
# 配置 VFs 数量
echo
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "配置虚拟核显VFs数量"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo
echo "虚拟核显数量范围: 1-7"
echo "推荐配置:"
echo " - 1 个 VF: 性能最强,适合单个高性能虚拟机"
echo " - 2-3 个 VF: 平衡性能,适合多个虚拟机"
echo " - 4-7 个 VF: 最多虚拟机数量,性能较弱"
echo
read -p "请输入 VFs 数量 [1-7, 默认: 3]: " vfs_num
# 验证输入
if [[ -z "$vfs_num" ]]; then
vfs_num=3
elif ! [[ "$vfs_num" =~ ^[1-7]$ ]]; then
echo -e "无效的 VFs 数量,必须是 1-7"
pause_function
return 1
fi
echo "配置 $vfs_num 个虚拟核显"
# 写入 sysfs.conf
echo "devices/pci0000:00/0000:00:02.0/sriov_numvfs = $vfs_num" > /etc/sysfs.conf
echo -e "✓ VFs 数量配置完成"
# 完成提示
echo
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo -e "✓ SR-IOV 核显虚拟化配置完成!"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo
echo "配置摘要:"
echo " • 内核参数: intel_iommu=on iommu=pt i915.enable_guc=3 i915.max_vfs=7"
echo " • VFIO 模块: 已加载"
echo " • i915-sriov 驱动: 已安装"
echo " • 虚拟核显数量: $vfs_num"
echo
echo -e "下一步操作:"
echo -e " 1. 重启系统使配置生效"
echo " 2. 重启后使用 '验证核显虚拟化状态' 检查配置"
echo " 3. 在虚拟机配置中添加核显 SR-IOV 设备"
echo
echo -e "重要提示:"
echo -e " • 物理核显 (00:02.0) 不能直通给虚拟机"
echo -e " • 只能直通虚拟核显 (00:02.1 ~ 00:02.$vfs_num)"
echo -e " • 虚拟机需要勾选 ROM-Bar 和 PCIE 选项"
echo
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
if confirm_action "是否现在重启系统"; then
echo "正在重启系统..."
reboot
else
echo -e "请记得手动重启系统以使配置生效"
fi
}
# Intel 6-10代 GVT-g 核显虚拟化配置

View File

@@ -0,0 +1,258 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
iommu_is_enabled() {
if [[ -d /sys/kernel/iommu_groups ]]; then
local group_count
group_count="$(find /sys/kernel/iommu_groups -maxdepth 1 -type d 2>/dev/null | wc -l)"
if [[ "${group_count:-0}" -gt 1 ]]; then
return 0
fi
fi
if dmesg 2>/dev/null | grep -Eiq 'DMAR: IOMMU enabled|IOMMU enabled|AMD-Vi:.*enabled'; then
return 0
fi
return 1
}
# 从 udev 路径中解析 PCI BDF格式0000:00:00.0
enable_pass() {
echo
log_step "开启硬件直通..."
if [ `dmesg | grep -e DMAR -e IOMMU|wc -l` = 0 ];then
log_error "您的硬件不支持直通不如检查一下主板的BIOS设置"
pause_function
return
fi
if [ `cat /proc/cpuinfo|grep Intel|wc -l` = 0 ];then
iommu="amd_iommu=on"
else
iommu="intel_iommu=on"
fi
if [ `grep $iommu /etc/default/grub|wc -l` = 0 ];then
backup_file "/etc/default/grub"
sed -i 's|quiet|quiet '$iommu'|' /etc/default/grub
update-grub
if [ `grep "vfio" /etc/modules|wc -l` = 0 ];then
cat <<-EOF >> /etc/modules
vfio
vfio_iommu_type1
vfio_pci
vfio_virqfd
kvmgt
EOF
fi
# 使用安全的配置块管理
blacklist_content="blacklist snd_hda_intel
blacklist snd_hda_codec_hdmi
blacklist i915"
apply_block "/etc/modprobe.d/blacklist.conf" "HARDWARE_PASSTHROUGH" "$blacklist_content"
# 使用安全的配置块管理
vfio_content="options vfio-pci ids=8086:3185"
apply_block "/etc/modprobe.d/vfio.conf" "HARDWARE_PASSTHROUGH" "$vfio_content"
log_success "开启设置后需要重启系统,请准备就绪后重启宿主机"
log_tips "重启后才可以应用对内核引导的修改哦!命令是 reboot"
else
log_warn "您已经配置过!"
fi
}
# 关闭硬件直通
disable_pass() {
echo
log_step "关闭硬件直通..."
if [ `dmesg | grep -e DMAR -e IOMMU|wc -l` = 0 ];then
log_error "您的硬件不支持直通!"
log_tips "不如检查一下主板的BIOS设置"
pause_function
return
fi
if [ `cat /proc/cpuinfo|grep Intel|wc -l` = 0 ];then
iommu="amd_iommu=on"
else
iommu="intel_iommu=on"
fi
if [ `grep $iommu /etc/default/grub|wc -l` = 0 ];then
log_warn "您还没有配置过该项"
else
backup_file "/etc/default/grub"
{
sed -i 's/ '$iommu'//g' /etc/default/grub
sed -i '/vfio/d' /etc/modules
# 使用安全的配置块删除,而不是直接删除整个文件
remove_block "/etc/modprobe.d/blacklist.conf" "HARDWARE_PASSTHROUGH"
remove_block "/etc/modprobe.d/vfio.conf" "HARDWARE_PASSTHROUGH"
sleep 1
}
log_success "关闭设置后需要重启系统,请准备就绪后重启宿主机。"
log_tips "重启后才可以应用对内核引导的修改哦!命令是 reboot"
sleep 1
update-grub
fi
}
# 硬件直通菜单
hw_passth() {
while :; do
clear
show_menu_header "配置硬件直通"
show_menu_option "1" "开启硬件直通"
show_menu_option "2" "关闭硬件直通"
echo "${UI_DIVIDER}"
show_menu_option "0" "返回"
show_menu_footer
read -p "请选择: [ ]" -n 1 hwmenuid
echo # New line after input
hwmenuid=${hwmenuid:-0}
case "${hwmenuid}" in
1)
enable_pass
pause_function
;;
2)
disable_pass
pause_function
;;
0)
break
;;
*)
log_error "无效选项!"
pause_function
;;
esac
done
}
#--------------磁盘/控制器直通----------------
# 磁盘/控制器直通总菜单
parse_pci_bdf_from_udev_path() {
local udev_path="$1"
if [[ "$udev_path" =~ ([0-9a-f]{4}:[0-9a-f]{2}:[0-9a-f]{2}\.[0-9a-f]) ]]; then
echo "${BASH_REMATCH[1]}"
return 0
fi
return 1
}
# 获取指定块设备所在的 PCI BDF用于系统盘控制器保护、控制器磁盘映射
get_blockdev_pci_bdf() {
local dev_path="$1"
if [[ -z "$dev_path" || ! -b "$dev_path" ]]; then
return 1
fi
local udev_path
udev_path="$(udevadm info --query=path --name="$dev_path" 2>/dev/null)"
if [[ -n "$udev_path" ]]; then
parse_pci_bdf_from_udev_path "$udev_path" && return 0
fi
return 1
}
# 获取 PVE 系统盘对应的“整盘设备名”列表sda / nvme0n1 等)
get_system_whole_disks() {
local -A disks=()
local mount_src
for mp in / /boot /boot/efi; do
mount_src="$(findmnt -n -o SOURCE "$mp" 2>/dev/null || true)"
if [[ -z "$mount_src" ]]; then
continue
fi
if [[ "$mount_src" == /dev/mapper/* ]]; then
if command -v pvs >/dev/null 2>&1; then
while IFS= read -r pv; do
pv="$(echo "$pv" | awk '{$1=$1;print}')"
if [[ -n "$pv" && -b "$pv" ]]; then
local pk
pk="$(lsblk -dn -o PKNAME "$pv" 2>/dev/null | head -n 1)"
if [[ -n "$pk" ]]; then
disks["$pk"]=1
else
disks["$(basename "$pv")"]=1
fi
fi
done < <(pvs --noheadings -o pv_name 2>/dev/null)
fi
continue
fi
if [[ -b "$mount_src" ]]; then
local pk
pk="$(lsblk -dn -o PKNAME "$mount_src" 2>/dev/null | head -n 1)"
if [[ -n "$pk" ]]; then
disks["$pk"]=1
else
disks["$(basename "$mount_src")"]=1
fi
fi
done
for d in "${!disks[@]}"; do
echo "$d"
done | sort
}
# 获取“必须保护”的 PCI BDF包含系统盘的控制器
get_protected_pci_bdfs() {
local -A bdfs=()
local disk
while IFS= read -r disk; do
local bdf
bdf="$(get_blockdev_pci_bdf "/dev/$disk" 2>/dev/null || true)"
if [[ -n "$bdf" ]]; then
bdfs["$bdf"]=1
fi
done < <(get_system_whole_disks)
for b in "${!bdfs[@]}"; do
echo "$b"
done | sort
}
# 列出系统内的 SATA/SCSI/RAID 控制器(用于整控制器直通)
list_storage_controllers() {
lspci -Dnn 2>/dev/null | grep -Eiin 'SATA controller|RAID bus controller|SCSI storage controller|Serial Attached SCSI controller' | sed 's/^[0-9]\+://'
}
# 列出系统内的 NVMe 控制器(用于 NVMe 直通)
list_nvme_controllers() {
lspci -Dnn 2>/dev/null | grep -Eiin 'Non-Volatile memory controller' | sed 's/^[0-9]\+://'
}
# 展示指定 PCI BDF 下的所有“整盘”设备(用于磁盘映射展示与保护提示)
show_disks_under_pci_bdf() {
local bdf="$1"
if [[ -z "$bdf" ]]; then
return 1
fi
local found=0
while IFS= read -r name; do
local dev_bdf
dev_bdf="$(get_blockdev_pci_bdf "/dev/$name" 2>/dev/null || true)"
if [[ "$dev_bdf" == "$bdf" ]]; then
local size model
size="$(lsblk -dn -o SIZE "/dev/$name" 2>/dev/null | head -n 1)"
model="$(lsblk -dn -o MODEL "/dev/$name" 2>/dev/null | head -n 1)"
echo " /dev/$name ${size:-?} ${model:-?}"
found=1
fi
done < <(lsblk -dn -o NAME,TYPE 2>/dev/null | awk '$2=="disk"{print $1}')
if [[ "$found" -eq 0 ]]; then
echo " (未能识别到该控制器下的磁盘,可能是映射方式不同或权限受限)"
fi
return 0
}
# 获取 VM 是否为 q35决定 hostpci 是否添加 pcie=1

View File

@@ -0,0 +1,685 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
nvidia_t() {
local key="$1"
case "$key" in
MENU_TITLE) echo "NVIDIA 显卡管理" ;;
MENU_DESC) echo "请选择功能模块(高风险操作会强制二次确认)" ;;
OPT_PT) echo "显卡直通虚拟机" ;;
OPT_DRV_INFO) echo "驱动信息与监控" ;;
OPT_DRV_SWITCH) echo "驱动切换(开源/闭源)" ;;
OPT_HOST_PREP) echo "宿主机预配置IOMMU/VFIO/黑名单)" ;;
OPT_UNLOCK) echo "部署 vGPU Unlock外部库" ;;
OPT_BACK) echo "返回" ;;
ERR_NO_GPU) echo "未检测到 NVIDIA GPU" ;;
ERR_IOMMU) echo "未检测到 IOMMU 已开启" ;;
TIP_ENABLE_IOMMU) echo "请先开启 BIOS 的 VT-d/AMD-Vi并在脚本中启用 IOMMU硬件直通一键配置。" ;;
INPUT_CHOICE) echo "请选择操作" ;;
INPUT_PICK) echo "请选择序号" ;;
WARN_HIGH_RISK) echo "高风险操作:不同驱动性能侧重点不同,误操作可能导致宿主机不可用。" ;;
OK_DONE) echo "操作完成" ;;
*) echo "$key" ;;
esac
}
nvidia_get_cols() {
tput cols 2>/dev/null || echo 80
}
nvidia_trunc() {
local s="$1"
local w="$2"
if [[ -z "$w" || "$w" -le 0 ]]; then
echo "$s"
return 0
fi
if [[ "${#s}" -le "$w" ]]; then
echo "$s"
return 0
fi
echo "${s:0:$((w-3))}..."
}
nvidia_list_vms() {
qm list 2>/dev/null | awk 'NR>1{print $1 "|" $2 "|" $3}'
}
nvidia_list_nvidia_gpus() {
lspci -Dnn 2>/dev/null | grep -Ei 'VGA compatible controller|3D controller' | grep -i 'NVIDIA' | awk '{bdf=$1; sub(/^[0-9a-f]{4}:/,"",bdf); print $1 "|" $0}'
}
nvidia_get_pci_ids() {
local bdf="$1"
lspci -n -s "$bdf" 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i ~ /^[0-9a-fA-F]{4}:[0-9a-fA-F]{4}$/){print tolower($i); exit}}'
}
nvidia_pci_has_function() {
local bdf="$1"
local func="$2"
local base
base="${bdf%.*}"
lspci -Dnn 2>/dev/null | awk '{print $1}' | grep -qx "${base}.${func}"
}
nvidia_pci_kernel_driver() {
local bdf="$1"
lspci -nnk -s "$bdf" 2>/dev/null | awk -F': ' '/Kernel driver in use:/{print $2; exit}'
}
nvidia_select_vmid() {
local vms
vms="$(nvidia_list_vms)"
if [[ -z "$vms" ]]; then
log_error "未发现虚拟机"
log_tips "请先创建虚拟机后再操作。"
return 1
fi
{
echo -e "${CYAN}可用虚拟机列表:${NC}"
echo "$vms" | awk -F'|' '{printf " [%d] VMID: %-6s Name: %-22s Status: %s\n", NR, $1, $2, $3}'
echo -e "${UI_DIVIDER}"
} >&2
local pick
read -p "$(nvidia_t INPUT_PICK) (0 返回): " pick
pick="${pick:-0}"
if [[ "$pick" == "0" ]]; then
return 2
fi
if [[ ! "$pick" =~ ^[0-9]+$ ]]; then
log_error "序号必须是数字"
return 1
fi
local line vmid
line="$(echo "$vms" | awk -v n="$pick" -F'|' 'NR==n{print $0}')"
vmid="$(echo "$line" | awk -F'|' '{print $1}')"
if [[ -z "$vmid" ]]; then
log_error "无效选择"
return 1
fi
if ! validate_qm_vmid "$vmid"; then
return 1
fi
echo "$vmid"
return 0
}
nvidia_select_gpu_bdf() {
local gpus
gpus="$(nvidia_list_nvidia_gpus)"
if [[ -z "$gpus" ]]; then
log_error "$(nvidia_t ERR_NO_GPU)"
log_tips "请先确认已安装 NVIDIA GPU 并执行 lspci 可见。"
return 1
fi
local cols
cols="$(nvidia_get_cols)"
local max_line=$((cols-6))
if [[ "$max_line" -lt 40 ]]; then
max_line=40
fi
{
echo -e "${CYAN}可用 NVIDIA GPU 列表:${NC}"
echo "$gpus" | awk -F'|' -v w="$max_line" '{
line=$2;
if (length(line)>w) line=substr(line,1,w-3)"...";
printf " [%d] %s\n", NR, line
}'
echo -e "${UI_DIVIDER}"
} >&2
local pick
read -p "$(nvidia_t INPUT_PICK) (0 返回): " pick
pick="${pick:-0}"
if [[ "$pick" == "0" ]]; then
return 2
fi
if [[ ! "$pick" =~ ^[0-9]+$ ]]; then
log_error "序号必须是数字"
return 1
fi
local line bdf
line="$(echo "$gpus" | awk -v n="$pick" -F'|' 'NR==n{print $0}')"
bdf="$(echo "$line" | awk -F'|' '{print $1}')"
if [[ -z "$bdf" ]]; then
log_error "无效选择"
return 1
fi
echo "$bdf"
return 0
}
nvidia_show_passthrough_status() {
local bdf="$1"
local drv
drv="$(nvidia_pci_kernel_driver "$bdf")"
echo -e "${CYAN}设备: ${NC}$bdf"
echo -e "${CYAN}Kernel driver in use: ${NC}${drv:-unknown}"
lspci -nnk -s "$bdf" 2>/dev/null | sed 's/^/ /'
}
nvidia_try_write_vfio_ids_conf() {
local ids_csv="$1"
local file="/etc/modprobe.d/pve-tools-nvidia-vfio.conf"
local other
other="$(grep -RhsE '^\s*options\s+vfio-pci\s+ids=' /etc/modprobe.d 2>/dev/null | grep -vF "pve-tools-nvidia-vfio.conf" || true)"
if [[ -n "$other" ]]; then
display_error "检测到系统已存在 vfio-pci ids 配置" "为避免冲突,本功能不会自动写入。请手工合并 vfio-pci ids 后再 update-initramfs -u。"
return 1
fi
if ! confirm_action "写入 VFIO 绑定配置($file)并要求重启宿主机?"; then
return 0
fi
local content
content="options vfio-pci ids=${ids_csv}"
apply_block "$file" "NVIDIA_VFIO_IDS" "$content"
display_success "VFIO 绑定配置已写入" "请执行 update-initramfs -u 并重启宿主机后再进行直通。"
return 0
}
nvidia_gpu_passthrough_vm() {
log_step "$(nvidia_t OPT_PT)"
if ! iommu_is_enabled; then
display_error "$(nvidia_t ERR_IOMMU)" "$(nvidia_t TIP_ENABLE_IOMMU)"
return 1
fi
local vmid
vmid="$(nvidia_select_vmid)"
local rc=$?
if [[ "$rc" -eq 2 ]]; then
return 0
fi
if [[ -z "$vmid" ]]; then
return 1
fi
local gpu_bdf
gpu_bdf="$(nvidia_select_gpu_bdf)"
rc=$?
if [[ "$rc" -eq 2 ]]; then
return 0
fi
if [[ -z "$gpu_bdf" ]]; then
return 1
fi
clear
show_menu_header "$(nvidia_t OPT_PT)"
echo -e "${YELLOW}VMID: ${NC}$vmid"
echo -e "${YELLOW}GPU: ${NC}$gpu_bdf"
echo -e "${UI_DIVIDER}"
nvidia_show_passthrough_status "$gpu_bdf"
local audio_bdf=""
if nvidia_pci_has_function "$gpu_bdf" "1"; then
audio_bdf="${gpu_bdf%.*}.1"
echo -e "${UI_DIVIDER}"
nvidia_show_passthrough_status "$audio_bdf"
fi
local gpu_id audio_id ids_csv
gpu_id="$(nvidia_get_pci_ids "$gpu_bdf")"
audio_id=""
if [[ -n "$audio_bdf" ]]; then
audio_id="$(nvidia_get_pci_ids "$audio_bdf")"
fi
ids_csv="$gpu_id"
if [[ -n "$audio_id" ]]; then
ids_csv="${ids_csv},${audio_id}"
fi
echo -e "${UI_DIVIDER}"
if [[ -n "$ids_csv" ]]; then
echo -e "${CYAN}VFIO ids 建议: ${NC}$ids_csv"
fi
echo -e "${YELLOW}提示:如果宿主机正在加载 nvidia/nouveau 驱动,直通可能失败。${NC}"
echo -e "${UI_DIVIDER}"
local include_audio="yes"
if [[ -n "$audio_bdf" ]]; then
read -p "是否同时直通显卡音频功能(${audio_bdf}(yes/no) [yes]: " include_audio
include_audio="${include_audio:-yes}"
else
include_audio="no"
fi
if qm_has_hostpci_bdf "$vmid" "$gpu_bdf"; then
display_error "该 GPU 已存在于 VM 的 hostpci 配置中" "无需重复添加。"
return 1
fi
local idx0
idx0="$(qm_find_free_hostpci_index "$vmid" 2>/dev/null)" || {
display_error "未找到可用 hostpci 插槽" "请先释放 VM 的 hostpci0-hostpci15。"
return 1
}
local hostpci0_value="${gpu_bdf}"
if qm_is_q35_machine "$vmid"; then
hostpci0_value="${hostpci0_value},pcie=1,x-vga=1"
else
hostpci0_value="${hostpci0_value},x-vga=1"
fi
local conf_path
conf_path="$(get_qm_conf_path "$vmid")"
if [[ -f "$conf_path" ]]; then
backup_file "$conf_path" >/dev/null 2>&1 || true
fi
if ! confirm_action "为 VM $vmid 添加 GPU 直通hostpci${idx0} = ${hostpci0_value}"; then
return 0
fi
if ! qm set "$vmid" "-hostpci${idx0}" "$hostpci0_value" >/dev/null 2>&1; then
display_error "qm set 执行失败" "请检查 VM 是否锁定,或查看 /var/log/pve-tools.log。"
return 1
fi
if [[ "$include_audio" == "yes" && -n "$audio_bdf" ]]; then
local idx1
idx1="$(qm_find_free_hostpci_index "$vmid" 2>/dev/null)" || {
display_error "显卡已添加,但未找到可用 hostpci 插槽添加音频功能" "请手工添加 $audio_bdf"
return 1
}
local hostpci1_value="${audio_bdf}"
if qm_is_q35_machine "$vmid"; then
hostpci1_value="${hostpci1_value},pcie=1"
fi
if ! qm set "$vmid" "-hostpci${idx1}" "$hostpci1_value" >/dev/null 2>&1; then
log_warn "音频功能直通写入失败GPU 已写入)"
else
log_success "音频功能已写入: hostpci${idx1} = $hostpci1_value"
fi
fi
local ignore_msrs="no"
read -p "是否写入 KVM ignore_msrsWindows/NVIDIA 常见告警缓解yes/no[no]: " ignore_msrs
ignore_msrs="${ignore_msrs:-no}"
if [[ "$ignore_msrs" == "yes" || "$ignore_msrs" == "YES" ]]; then
if confirm_action "写入 /etc/modprobe.d/kvm.conf 的 ignore_msrs 配置并要求重启?"; then
local kvm_content
kvm_content="options kvm ignore_msrs=1 report_ignored_msrs=0"
apply_block "/etc/modprobe.d/kvm.conf" "NVIDIA_IGNORE_MSRS" "$kvm_content"
log_success "已写入 KVM ignore_msrs 配置"
fi
fi
if [[ -n "$ids_csv" ]]; then
local set_vfio="no"
read -p "是否写入 VFIO ids 绑定配置(用于将设备绑定到 vfio-pciyes/no[no]: " set_vfio
set_vfio="${set_vfio:-no}"
if [[ "$set_vfio" == "yes" || "$set_vfio" == "YES" ]]; then
nvidia_try_write_vfio_ids_conf "$ids_csv" || true
fi
fi
display_success "$(nvidia_t OK_DONE)" "如 VM 正在运行中,请重启 VM如写入了 VFIO/kvm 配置,请按提示重启宿主机。"
return 0
}
nvidia_driver_info() {
clear
show_menu_header "$(nvidia_t OPT_DRV_INFO)"
local open_loaded="no"
local prop_loaded="no"
if lsmod 2>/dev/null | grep -q '^nouveau'; then
open_loaded="yes"
fi
if lsmod 2>/dev/null | grep -q '^nvidia'; then
prop_loaded="yes"
fi
echo -e "${CYAN}驱动状态:${NC}"
echo " nouveau 已加载: $open_loaded"
echo " nvidia 已加载: $prop_loaded"
echo -e "${UI_DIVIDER}"
if command -v nvidia-smi >/dev/null 2>&1; then
echo -e "${CYAN}nvidia-smi${NC}"
nvidia-smi 2>/dev/null | sed 's/^/ /' || true
echo -e "${UI_DIVIDER}"
echo -e "${CYAN}GPU 指标CSV${NC}"
nvidia-smi --query-gpu=index,name,driver_version,temperature.gpu,utilization.gpu,power.draw,power.limit,memory.used,memory.total --format=csv,noheader,nounits 2>/dev/null | sed 's/^/ /' || true
else
display_error "未找到 nvidia-smi" "如需查看驱动信息,请先安装 NVIDIA 驱动或确认 PATH。"
fi
}
nvidia_driver_export_report() {
local ts
ts="$(date +%Y%m%d_%H%M%S)"
local out="/var/log/pve-tools-nvidia-report-${ts}.txt"
{
echo "time: $(date)"
echo "pveversion: $(pveversion 2>/dev/null || true)"
echo "kernel: $(uname -r)"
echo
echo "lspci (nvidia):"
lspci -Dnn 2>/dev/null | grep -i nvidia || true
echo
echo "lsmod (nvidia/nouveau):"
lsmod 2>/dev/null | grep -E '^(nvidia|nouveau)\b' || true
echo
if command -v nvidia-smi >/dev/null 2>&1; then
echo "nvidia-smi:"
nvidia-smi 2>/dev/null || true
echo
echo "nvidia-smi -q (head):"
nvidia-smi -q 2>/dev/null | head -n 200 || true
fi
} > "$out" 2>/dev/null || {
display_error "导出失败" "请检查 /var/log 写入权限与磁盘空间。"
return 1
}
log_success "已导出: $out"
return 0
}
nvidia_driver_info_menu() {
while true; do
clear
show_menu_header "$(nvidia_t OPT_DRV_INFO)"
show_menu_option "1" "查看驱动与监控面板"
show_menu_option "2" "导出驱动诊断报告"
show_menu_option "0" "$(nvidia_t OPT_BACK)"
show_menu_footer
read -p "$(nvidia_t INPUT_CHOICE) [0-2]: " choice
case "$choice" in
1) nvidia_driver_info ;;
2) nvidia_driver_export_report ;;
0) return ;;
*) log_error "无效选择" ;;
esac
pause_function
done
}
nvidia_apt_has_pkg() {
local pkg="$1"
apt-cache show "$pkg" >/dev/null 2>&1
}
nvidia_driver_switch_to_proprietary() {
echo -e "${YELLOW}$(nvidia_t WARN_HIGH_RISK)${NC}"
if ! confirm_action "安装并启用官方 NVIDIA 驱动(闭源)?"; then
return 0
fi
log_step "更新软件包列表..."
apt-get update -y >/dev/null 2>&1 || true
if nvidia_apt_has_pkg "nvidia-driver"; then
log_step "安装 nvidia-driver..."
apt-get install -y nvidia-driver
else
display_error "未找到可用的 nvidia-driver 软件包" "请检查软件源,或使用 NVIDIA 官方安装方式。"
return 1
fi
if confirm_action "安装完成,是否现在重启宿主机?"; then
reboot
fi
return 0
}
nvidia_driver_switch_to_open() {
echo -e "${YELLOW}$(nvidia_t WARN_HIGH_RISK)${NC}"
if ! confirm_action "卸载 NVIDIA 驱动并切回开源驱动nouveau"; then
return 0
fi
log_step "卸载 NVIDIA 驱动..."
apt-get purge -y 'nvidia-*' || true
apt-get autoremove -y || true
if confirm_action "是否更新 initramfs推荐"; then
update-initramfs -u || true
fi
if confirm_action "操作完成,是否现在重启宿主机?"; then
reboot
fi
return 0
}
nvidia_restore_latest_backup_file() {
local target="$1"
local backup_dir="/var/backups/pve-tools"
local base
base="$(basename "$target")"
if [[ ! -d "$backup_dir" ]]; then
return 1
fi
local latest
latest="$(ls -1t "${backup_dir}/${base}."*.bak 2>/dev/null | head -n 1)"
if [[ -z "$latest" ]]; then
return 1
fi
backup_file "$target" >/dev/null 2>&1 || true
if cp -a "$latest" "$target" >/dev/null 2>&1; then
log_success "已回滚: $target"
log_info "使用备份: $latest"
return 0
fi
return 1
}
nvidia_driver_rollback() {
echo -e "${YELLOW}$(nvidia_t WARN_HIGH_RISK)${NC}"
if ! confirm_action "回滚最近一次驱动相关配置备份?"; then
return 0
fi
local files=(
"/etc/modprobe.d/pve-blacklist.conf"
"/etc/modprobe.d/kvm.conf"
"/etc/modprobe.d/pve-tools-nvidia-vfio.conf"
"/etc/modprobe.d/vfio.conf"
"/etc/default/grub"
"/etc/nvidia/gridd.conf"
)
local ok=0
local f
for f in "${files[@]}"; do
if nvidia_restore_latest_backup_file "$f"; then
ok=$((ok+1))
fi
done
if [[ "$ok" -le 0 ]]; then
display_error "未找到可用备份" "请确认之前确实产生过备份(/var/backups/pve-tools或手工回滚配置。"
return 1
fi
display_success "回滚完成" "建议执行 update-initramfs -u并按需重启宿主机。"
return 0
}
nvidia_driver_switch_menu() {
while true; do
clear
show_menu_header "$(nvidia_t OPT_DRV_SWITCH)"
echo -e "${YELLOW}$(nvidia_t WARN_HIGH_RISK)${NC}"
echo -e "${UI_DIVIDER}"
show_menu_option "1" "切换到闭源驱动(官方 NVIDIA"
show_menu_option "2" "切换到开源驱动nouveau"
show_menu_option "3" "回滚最近一次备份"
show_menu_option "0" "$(nvidia_t OPT_BACK)"
show_menu_footer
read -p "$(nvidia_t INPUT_CHOICE) [0-3]: " choice
case "$choice" in
1) nvidia_driver_switch_to_proprietary ;;
2) nvidia_driver_switch_to_open ;;
3) nvidia_driver_rollback ;;
0) return ;;
*) log_error "无效选择" ;;
esac
pause_function
done
}
nvidia_host_prepare_for_passthrough() {
echo -e "${YELLOW}将执行以下操作:${NC}"
echo " 1) 写入 GRUB IOMMU 参数"
echo " 2) 写入 /etc/modules 的 VFIO 模块配置块"
echo " 3) 写入 /etc/modprobe.d/pve-blacklist.conf 的 NVIDIA 黑名单配置块"
echo " 4) 执行 update-grub 与 update-initramfs"
echo
if ! confirm_action "确认执行宿主机预配置?"; then
return 0
fi
local cpu_vendor
cpu_vendor="$(grep -m1 'vendor_id' /proc/cpuinfo 2>/dev/null | awk '{print $3}')"
if [[ "$cpu_vendor" == "GenuineIntel" ]]; then
grub_add_param "intel_iommu=on"
elif [[ "$cpu_vendor" == "AuthenticAMD" ]]; then
grub_add_param "amd_iommu=on"
else
log_warn "未识别 CPU 厂商,跳过厂商特定 IOMMU 参数"
fi
grub_add_param "iommu=pt"
grub_add_param "pcie_acs_override=downstream,multifunction"
local modules_content
modules_content=$(cat <<'EOF'
vfio
vfio_iommu_type1
vfio_pci
vfio_virqfd
EOF
)
apply_block "/etc/modules" "NVIDIA_VFIO_MODULES" "$modules_content"
local blacklist_content
blacklist_content=$(cat <<'EOF'
blacklist nouveau
blacklist nvidia
blacklist nvidiafb
options vfio_iommu_type1 allow_unsafe_interrupts=1
EOF
)
apply_block "/etc/modprobe.d/pve-blacklist.conf" "NVIDIA_BLACKLIST" "$blacklist_content"
if command -v update-grub >/dev/null 2>&1; then
update-grub || log_warn "update-grub 执行失败,请手工检查"
elif command -v grub-mkconfig >/dev/null 2>&1; then
grub-mkconfig -o /boot/grub/grub.cfg || log_warn "grub-mkconfig 执行失败,请手工检查"
else
log_warn "未找到 update-grub/grub-mkconfig请手工更新 GRUB"
fi
update-initramfs -u -k all || log_warn "update-initramfs 执行失败,请手工检查"
display_success "宿主机预配置已完成" "建议重启宿主机后再执行直通或 vGPU 操作。"
if confirm_action "是否现在重启宿主机?"; then
reboot
fi
return 0
}
nvidia_setup_vgpu_unlock() {
clear
show_menu_header "vGPU Unlock 高风险提示"
echo -e "${RED} 请先阅读文档后再操作。${NC}"
echo " 本功能会修改 NVIDIA vGPU 服务启动参数并加载外部 .so 文件。"
echo " 驱动/内核/补丁版本不匹配可能导致服务异常、宿主机告警或 VM 无法使用 vGPU。"
echo
echo -e "${CYAN}推荐先阅读 Wiki${NC}"
echo " 对应文章: https://pve.oowo.cc/advanced/nvidia-vgpu-driver-notes"
echo "${UI_DIVIDER}"
read -p "请输入 '确认' 或 'Sure' 继续: " response
response=$(echo "$response" | xargs)
if [[ "$response" != "确认" && "$response" != "Sure" && "${response,,}" != "sure" ]]; then
echo "取消"
return 0
fi
local default_url="$NVIDIA_VGPU_UNLOCK_SO_URL"
local so_url
read -p "请输入 libvgpu_unlock_rs.so 下载地址 [$default_url]: " so_url
so_url="${so_url:-$default_url}"
if [[ -z "$so_url" ]]; then
display_error "下载地址不能为空"
return 1
fi
echo -e "${YELLOW}将创建并写入:${NC}"
echo " /etc/vgpu_unlock/profile_override.toml"
echo " /etc/systemd/system/nvidia-vgpud.service.d/vgpu_unlock.conf"
echo " /etc/systemd/system/nvidia-vgpu-mgr.service.d/vgpu_unlock.conf"
echo " /opt/vgpu_unlock-rs/target/release/libvgpu_unlock_rs.so"
echo
if ! confirm_action "确认部署 vGPU Unlock外部库"; then
return 0
fi
mkdir -p /etc/vgpu_unlock
touch /etc/vgpu_unlock/profile_override.toml
mkdir -p /etc/systemd/system/nvidia-vgpud.service.d
mkdir -p /etc/systemd/system/nvidia-vgpu-mgr.service.d
mkdir -p /opt/vgpu_unlock-rs/target/release
local unlock_conf
unlock_conf=$(cat <<'EOF'
[Service]
Environment=LD_PRELOAD=/opt/vgpu_unlock-rs/target/release/libvgpu_unlock_rs.so
EOF
)
apply_block "/etc/systemd/system/nvidia-vgpud.service.d/vgpu_unlock.conf" "NVIDIA_VGPU_UNLOCK" "$unlock_conf"
apply_block "/etc/systemd/system/nvidia-vgpu-mgr.service.d/vgpu_unlock.conf" "NVIDIA_VGPU_UNLOCK" "$unlock_conf"
local so_out="/opt/vgpu_unlock-rs/target/release/libvgpu_unlock_rs.so"
if command -v curl >/dev/null 2>&1; then
if ! curl -fsSL --connect-timeout 15 --max-time 300 -o "$so_out" "$so_url"; then
display_error "下载失败" "请检查 URL 与网络连接。"
return 1
fi
elif command -v wget >/dev/null 2>&1; then
if ! wget -q -O "$so_out" "$so_url"; then
display_error "下载失败" "请检查 URL 与网络连接。"
return 1
fi
else
display_error "未检测到 curl 或 wget" "无法下载外部库文件。"
return 1
fi
if [[ ! -s "$so_out" ]]; then
display_error "下载结果为空" "请检查 URL 是否可访问。"
return 1
fi
systemctl daemon-reload >/dev/null 2>&1 || true
systemctl restart nvidia-vgpud.service >/dev/null 2>&1 || true
systemctl restart nvidia-vgpu-mgr.service >/dev/null 2>&1 || true
display_success "vGPU Unlock 已部署" "可执行 systemctl status nvidia-vgpud nvidia-vgpu-mgr 验证状态。"
return 0
}
nvidia_gpu_management_menu() {
while true; do
clear
show_menu_header "$(nvidia_t MENU_TITLE)"
echo -e "${CYAN}$(nvidia_t MENU_DESC)${NC}"
echo -e "${UI_DIVIDER}"
show_menu_option "1" "$(nvidia_t OPT_PT)"
show_menu_option "2" "$(nvidia_t OPT_DRV_INFO)"
show_menu_option "3" "$(nvidia_t OPT_DRV_SWITCH)"
show_menu_option "4" "$(nvidia_t OPT_HOST_PREP)"
show_menu_option "5" "$(nvidia_t OPT_UNLOCK)"
show_menu_option "0" "$(nvidia_t OPT_BACK)"
show_menu_footer
read -p "$(nvidia_t INPUT_CHOICE) [0-5]: " choice
case "$choice" in
1) nvidia_gpu_passthrough_vm ;;
2) nvidia_driver_info_menu ;;
3) nvidia_driver_switch_menu ;;
4) nvidia_host_prepare_for_passthrough ;;
5) nvidia_setup_vgpu_unlock ;;
0) return ;;
*) log_error "无效选择" ;;
esac
pause_function
done
}

View File

@@ -0,0 +1,290 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
get_qm_conf_path() {
local vmid="$1"
echo "/etc/pve/qemu-server/${vmid}.conf"
}
# 校验 VMID 并确保 VM 存在
validate_qm_vmid() {
local vmid="$1"
if [[ -z "$vmid" || ! "$vmid" =~ ^[0-9]+$ ]]; then
log_error "VMID 必须是数字"
return 1
fi
if ! qm status "$vmid" >/dev/null 2>&1; then
log_error "VMID 不存在或无法访问: $vmid"
return 1
fi
return 0
}
# 将 /dev/disk/by-id 的链接解析为真实磁盘设备,并过滤不可直通设备
# 过滤规则:
# - 排除分区by-id 名称包含 -partX 或目标设备为分区lsblk TYPE=part
# - 排除 DM/LVM目标设备为 dm-* 或 /dev/mapper/*
# - 仅保留 TYPE=disk 的完整磁盘
rdm_discover_whole_disks() {
local byid_dir="/dev/disk/by-id"
if [[ ! -d "$byid_dir" ]]; then
log_error "未找到目录: $byid_dir"
return 1
fi
local -A best_id_for_dev=()
local -A best_pri_for_dev=()
local -A ata_id_for_dev=()
local link
while IFS= read -r -d '' link; do
local base_name real_dev dev_name dev_type pri
base_name="$(basename "$link")"
if [[ "$base_name" =~ -part[0-9]+$ ]]; then
continue
fi
real_dev="$(readlink -f "$link" 2>/dev/null)"
if [[ -z "$real_dev" ]]; then
continue
fi
if [[ "$real_dev" == /dev/mapper/* || "$(basename "$real_dev")" == dm-* ]]; then
continue
fi
if [[ ! -b "$real_dev" ]]; then
continue
fi
dev_type="$(lsblk -dn -o TYPE "$real_dev" 2>/dev/null | head -n 1)"
if [[ "$dev_type" != "disk" ]]; then
continue
fi
pri=50
if [[ "$base_name" =~ ^wwn- ]]; then pri=10; fi
if [[ "$base_name" =~ ^nvme-eui ]]; then pri=10; fi
if [[ "$base_name" =~ ^nvme-uuid ]]; then pri=15; fi
if [[ "$base_name" =~ ^ata- ]]; then pri=20; fi
if [[ "$base_name" =~ ^scsi- ]]; then pri=30; fi
if [[ "$base_name" =~ ^pci- ]]; then pri=40; fi
if [[ "$base_name" =~ ^ata- ]] && [[ -z "${ata_id_for_dev[$real_dev]:-}" ]]; then
ata_id_for_dev["$real_dev"]="$link"
fi
if [[ -z "${best_id_for_dev[$real_dev]:-}" || "$pri" -lt "${best_pri_for_dev[$real_dev]}" ]]; then
best_id_for_dev["$real_dev"]="$link"
best_pri_for_dev["$real_dev"]="$pri"
fi
done < <(find "$byid_dir" -maxdepth 1 -type l -print0 2>/dev/null)
local dev
for dev in "${!best_id_for_dev[@]}"; do
local id_path size model ata_path
id_path="${best_id_for_dev[$dev]}"
ata_path="${ata_id_for_dev[$dev]:-}"
size="$(lsblk -dn -o SIZE "$dev" 2>/dev/null | head -n 1)"
model="$(lsblk -dn -o MODEL "$dev" 2>/dev/null | head -n 1)"
printf '%s|%s|%s|%s|%s\n' "$id_path" "$dev" "${size:-?}" "${model:-?}" "$ata_path"
done | sort -t'|' -k2,2
}
# 自动查找总线类型下可用插槽sata 最多 6 个ide 最多 4 个)
rdm_find_free_slot() {
local vmid="$1"
local bus="$2"
local max_idx=0
case "$bus" in
sata) max_idx=5 ;;
ide) max_idx=3 ;;
scsi) max_idx=30 ;;
*) log_error "不支持的总线类型: $bus"; return 1 ;;
esac
local cfg
cfg="$(qm config "$vmid" 2>/dev/null)"
if [[ -z "$cfg" ]]; then
log_error "无法读取 VM 配置: $vmid"
return 1
fi
local i
for ((i=0; i<=max_idx; i++)); do
if ! echo "$cfg" | grep -qE "^${bus}${i}:"; then
echo "${bus}${i}"
return 0
fi
done
log_error "无可用插槽: $bus (0-$max_idx)"
return 1
}
# RDM 单盘直通(添加)
rdm_single_disk_attach() {
log_step "RDM 单盘直通 - 磁盘发现"
local disks
disks="$(rdm_discover_whole_disks)"
if [[ -z "$disks" ]]; then
display_error "未发现可直通的完整磁盘" "请检查 /dev/disk/by-id 是否存在可用磁盘,或确认磁盘未被 DM/LVM 接管。"
return 1
fi
echo -e "${CYAN}可直通磁盘列表(完整磁盘):${NC}"
echo "$disks" | awk -F'|' '{
ata=$5;
if (ata == "") ata="-";
else {
n=split(ata,a,"/");
ata=a[n];
}
printf " [%d] %-55s -> %-12s %-8s %-28s ATA:%s\n", NR, $1, $2, $3, $4, ata
}'
echo -e "${UI_DIVIDER}"
local pick
read -p "请选择磁盘序号 (返回请输入 0): " pick
pick="${pick:-0}"
if [[ "$pick" == "0" ]]; then
return 0
fi
if [[ ! "$pick" =~ ^[0-9]+$ ]]; then
display_error "磁盘序号必须是数字"
return 1
fi
local selected
selected="$(echo "$disks" | awk -F'|' -v n="$pick" 'NR==n{print $0}')"
if [[ -z "$selected" ]]; then
display_error "无效的磁盘序号: $pick"
return 1
fi
local id_path real_dev
id_path="$(echo "$selected" | awk -F'|' '{print $1}')"
real_dev="$(echo "$selected" | awk -F'|' '{print $2}')"
local vmid
read -p "请输入目标 VMID: " vmid
if ! validate_qm_vmid "$vmid"; then
pause_function
return 1
fi
local bus
read -p "请选择总线类型 (scsi/sata/ide) [scsi]: " bus
bus="${bus:-scsi}"
if [[ "$bus" != "scsi" && "$bus" != "sata" && "$bus" != "ide" ]]; then
display_error "不支持的总线类型: $bus" "仅支持 scsi/sata/ide"
return 1
fi
local cfg
cfg="$(qm config "$vmid" 2>/dev/null)"
if echo "$cfg" | grep -Fq "$id_path" || echo "$cfg" | grep -Fq "$real_dev"; then
display_error "该磁盘已在 VM 配置中存在直通记录" "请先执行取消直通,或选择其他磁盘。"
return 1
fi
local slot
slot="$(rdm_find_free_slot "$vmid" "$bus")" || return 1
log_info "将直通磁盘: $id_path -> $real_dev"
log_info "目标 VM: $vmid, 插槽: $slot"
local conf_path
conf_path="$(get_qm_conf_path "$vmid")"
if [[ -f "$conf_path" ]]; then
log_tips "修改 VM 配置前建议备份原配置"
backup_file "$conf_path" >/dev/null 2>&1 || true
fi
if ! confirm_action "为 VM $vmid 添加直通磁盘($slot = $id_path"; then
return 0
fi
if qm set "$vmid" "-$slot" "$id_path" >/dev/null 2>&1; then
display_success "直通配置已写入" "如需引导此磁盘,请在 VM 启动顺序中选择该磁盘。"
return 0
else
display_error "qm set 执行失败" "请检查磁盘是否被占用、VM 是否锁定,或查看 /var/log/pve-tools.log。"
return 1
fi
}
# RDM 取消直通(--delete
rdm_single_disk_detach() {
log_step "RDM 取消直通(--delete"
local vmid
read -p "请输入目标 VMID: " vmid
if ! validate_qm_vmid "$vmid"; then
return 1
fi
local cfg
cfg="$(qm config "$vmid" 2>/dev/null)"
if [[ -z "$cfg" ]]; then
display_error "无法读取 VM 配置: $vmid"
return 1
fi
local disks_lines
disks_lines="$(echo "$cfg" | grep -E '^(scsi|sata|ide)[0-9]+:')"
if [[ -z "$disks_lines" ]]; then
display_error "该 VM 未发现任何磁盘插槽配置" "如果只是没有直通盘,可忽略此提示。"
return 1
fi
echo -e "${CYAN}当前 VM 磁盘插槽:${NC}"
echo "$disks_lines" | awk '{printf " [%d] %s\n", NR, $0}'
echo -e "${UI_DIVIDER}"
local pick
read -p "请选择要删除的插槽序号 (返回请输入 0): " pick
pick="${pick:-0}"
if [[ "$pick" == "0" ]]; then
return 0
fi
if [[ ! "$pick" =~ ^[0-9]+$ ]]; then
display_error "序号必须是数字"
return 1
fi
local line slot
line="$(echo "$disks_lines" | awk -v n="$pick" 'NR==n{print $0}')"
if [[ -z "$line" ]]; then
display_error "无效的序号: $pick"
return 1
fi
slot="$(echo "$line" | cut -d':' -f1)"
local conf_path
conf_path="$(get_qm_conf_path "$vmid")"
if [[ -f "$conf_path" ]]; then
log_tips "修改 VM 配置前建议备份原配置"
backup_file "$conf_path" >/dev/null 2>&1 || true
fi
if ! confirm_action "从 VM $vmid 删除磁盘插槽(--delete $slot"; then
return 0
fi
if qm set "$vmid" --delete "$slot" >/dev/null 2>&1; then
display_success "插槽已删除: $slot"
return 0
else
display_error "qm set --delete 执行失败" "请检查 VM 是否锁定,或查看 /var/log/pve-tools.log。"
return 1
fi
}
# ============ PCIe 控制器 / NVMe 直通 ============
# 检查 IOMMU 是否已开启(用于 PCIe 设备直通的前置条件)

View File

@@ -0,0 +1,222 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
vm_backup_create() {
vm_require_commands qm vzdump pvesm || return 1
local vmids_text
vmids_text="$(vm_collect_target_vmids)"
local rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$vmids_text" ]] || return 1
mapfile -t vmids < <(printf '%s\n' "$vmids_text" | awk 'NF')
local store
store="$(vm_select_storage_by_content backup "请选择备份存储")"
rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$store" ]] || return 1
local compress mode keep_last
read -p "请选择压缩方式 (zstd/gzip/lzo) [zstd]: " compress
compress="${compress:-zstd}"
if [[ "$compress" != "zstd" && "$compress" != "gzip" && "$compress" != "lzo" ]]; then
display_error "不支持的压缩方式: $compress" "仅支持 zstd / gzip / lzo"
return 1
fi
read -p "请选择备份模式 (snapshot/suspend/stop) [snapshot]: " mode
mode="${mode:-snapshot}"
if [[ "$mode" != "snapshot" && "$mode" != "suspend" && "$mode" != "stop" ]]; then
display_error "不支持的备份模式: $mode" "仅支持 snapshot / suspend / stop"
return 1
fi
read -p "请输入保留份数0 表示不启用自动清理) [7]: " keep_last
keep_last="${keep_last:-7}"
if [[ ! "$keep_last" =~ ^[0-9]+$ ]]; then
display_error "保留份数必须是数字"
return 1
fi
clear
show_menu_header "VM 备份与恢复"
echo -e "${YELLOW}目标 VM:${NC} ${vmids[*]}"
echo -e "${YELLOW}备份存储:${NC} $store"
echo -e "${YELLOW}压缩方式:${NC} $compress"
echo -e "${YELLOW}备份模式:${NC} $mode"
echo -e "${YELLOW}保留份数:${NC} $keep_last"
echo -e "${UI_DIVIDER}"
if ! confirm_high_risk_action "为 VM ${vmids[*]} 执行 vzdump 备份" "备份任务会占用大量 IO 与备份存储空间,错误的保留策略可能挤占生产容量。" "可能触发快照/锁定/短暂性能抖动,存储空间不足时任务会失败。" "请确认目标存储可用空间、保留策略和维护窗口,再执行备份。" "BACKUP"; then
return 0
fi
local -a cmd=(vzdump)
cmd+=("${vmids[@]}")
cmd+=(--storage "$store" --compress "$compress" --mode "$mode")
if (( keep_last > 0 )); then
cmd+=(--prune-backups "keep-last=$keep_last")
fi
local output
if ! output="$("${cmd[@]}" 2>&1)"; then
echo "$output" | sed 's/^/ /'
display_error "vzdump 执行失败" "请检查目标存储空间、任务锁定状态或日志输出。"
return 1
fi
echo "$output" | sed 's/^/ /'
display_success "备份完成" "可在对应存储的 dump 目录中查看生成的备份文件。"
}
vm_schedule_add_backup_job() {
vm_require_commands qm vzdump pvesm || return 1
local scope job_targets target_label
{
show_menu_option "1" "单个 VM"
show_menu_option "2" "多个 VM"
show_menu_option "3" "全部 VM"
}
read -p "请选择定时备份范围 [1-3]: " scope
case "$scope" in
1|2)
job_targets="$(vm_collect_target_vmids)"
local rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$job_targets" ]] || return 1
target_label="$(echo "$job_targets" | tr '\n' '-' | sed 's/-$//')"
;;
3)
target_label="all"
;;
*)
log_error "无效选择"
return 1
;;
esac
local store
store="$(vm_select_storage_by_content backup "请选择备份存储")" || return 1
vm_validate_backup_storage_name "$store" || return 1
local compress mode keep_last run_time
read -p "请选择压缩方式 (zstd/gzip/lzo) [zstd]: " compress
compress="${compress:-zstd}"
vm_validate_backup_compress "$compress" || return 1
read -p "请选择备份模式 (snapshot/suspend/stop) [snapshot]: " mode
mode="${mode:-snapshot}"
vm_validate_backup_mode "$mode" || return 1
read -p "请输入保留份数0 表示不启用自动清理) [7]: " keep_last
keep_last="${keep_last:-7}"
vm_validate_backup_keep_last "$keep_last" || return 1
read -p "请输入每日执行时间 (HH:MM) [03:00]: " run_time
run_time="${run_time:-03:00}"
if [[ ! "$run_time" =~ ^([0-1]?[0-9]|2[0-3]):([0-5][0-9])$ ]]; then
display_error "时间格式错误: $run_time" "请使用 HH:MM 格式。"
return 1
fi
local hour minute
hour="$((10#${BASH_REMATCH[1]}))"
minute="$((10#${BASH_REMATCH[2]}))"
local command_text target_args vmid
command_text="/usr/sbin/vzdump"
if [[ "$scope" == "3" ]]; then
command_text+=" --all 1"
else
target_args=""
while IFS= read -r vmid; do
[[ "$vmid" =~ ^[0-9]+$ ]] || {
display_error "检测到非法 VMID: $vmid" "已拒绝将未经校验的文本写入 root cron。"
return 1
}
target_args+=" $vmid"
done <<< "$job_targets"
[[ -n "$target_args" ]] || {
display_error "未生成有效的 VMID 参数"
return 1
}
command_text+="$target_args"
fi
command_text+=" --storage $store --compress $compress --mode $mode"
if (( keep_last > 0 )); then
command_text+=" --prune-backups keep-last=$keep_last"
fi
if ! confirm_high_risk_action "写入 VM 定时备份任务" "计划任务会以 root 权限定期执行 vzdump并持续占用 IO、CPU 与备份存储容量。" "错误的 VMID、存储或保留策略会周期性影响生产负载问题会反复发生。" "请确认执行时间、目标范围、备份存储与保留策略均已核对。" "CRON-BACKUP"; then
return 0
fi
local marker="VMBACKUP_${target_label}_$(date +%Y%m%d%H%M%S)"
local cron_line="$minute $hour * * * root $command_text >/var/log/pve-tools-vm-backup.log 2>&1"
touch "$VM_BACKUP_CRON_FILE"
apply_block "$VM_BACKUP_CRON_FILE" "$marker" "$cron_line"
systemctl restart cron 2>/dev/null || service cron restart 2>/dev/null || true
display_success "定时备份任务已写入" "cron 标记: $marker"
}
vm_schedule_remove_backup_job() {
if [[ ! -f "$VM_BACKUP_CRON_FILE" ]]; then
display_error "当前没有定时备份任务"
return 1
fi
local markers
markers="$(grep '^# PVE-TOOLS BEGIN VMBACKUP_' "$VM_BACKUP_CRON_FILE" 2>/dev/null | awk '{print $4}')"
if [[ -z "$markers" ]]; then
display_error "当前没有定时备份任务"
return 1
fi
echo -e "${CYAN}当前定时备份任务:${NC}"
grep -E '^[^#]' "$VM_BACKUP_CRON_FILE" 2>/dev/null | sed 's/^/ /'
echo -e "${UI_DIVIDER}"
echo "$markers" | awk '{printf " [%d] %s\n", NR, $1}'
echo -e "${UI_DIVIDER}"
local pick marker
read -p "请选择要删除的任务序号 (0 返回): " pick
pick="${pick:-0}"
[[ "$pick" == "0" ]] && return 0
[[ "$pick" =~ ^[0-9]+$ ]] || return 1
marker="$(echo "$markers" | awk -v n="$pick" 'NR==n{print $1}')"
[[ -n "$marker" ]] || return 1
remove_block "$VM_BACKUP_CRON_FILE" "$marker"
systemctl restart cron 2>/dev/null || service cron restart 2>/dev/null || true
display_success "定时备份任务已删除" "$marker"
}
vm_schedule_backup_menu() {
while true; do
clear
show_menu_header "VM 定时备份"
echo -e "${YELLOW}当前任务:${NC}"
if [[ -f "$VM_BACKUP_CRON_FILE" ]]; then
grep -E '^[^#]' "$VM_BACKUP_CRON_FILE" 2>/dev/null | sed 's/^/ /' || true
else
echo " 暂无定时任务"
fi
echo -e "${UI_DIVIDER}"
show_menu_option "1" "新增定时备份任务"
show_menu_option "2" "删除定时备份任务"
show_menu_option "0" "返回"
show_menu_footer
local choice
read -p "请选择操作 [0-2]: " choice
case "$choice" in
1) vm_schedule_add_backup_job ;;
2) vm_schedule_remove_backup_job ;;
0) return ;;
*) log_error "无效选择" ;;
esac
pause_function
done
}

View File

@@ -0,0 +1,100 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
vm_list_template_records() {
local records vmid name status
records="$(vm_list_vm_records)"
[[ -n "$records" ]] || return 0
while IFS='|' read -r vmid name status; do
if vm_is_template "$vmid"; then
printf '%s|%s|%s\n' "$vmid" "$name" "$status"
fi
done <<< "$records"
}
vm_show_template_records() {
local templates
templates="$(vm_list_template_records)"
if [[ -z "$templates" ]]; then
echo -e "${YELLOW}当前没有模板虚拟机${NC}"
return 0
fi
echo -e "${CYAN}模板列表:${NC}"
echo "$templates" | awk -F'|' '{printf " VMID: %-6s Name: %-22s Status: %s\n", $1, $2, $3}'
}
vm_convert_to_template() {
vm_require_commands qm || return 1
local vmid
vmid="$(img_select_vmid)"
local rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$vmid" ]] || return 1
if vm_is_template "$vmid"; then
display_error "该 VM 已经是模板"
return 1
fi
vm_ensure_vm_config_backup "$vmid"
if ! confirm_high_risk_action "将 VM $vmid 转换为模板" "模板化会改变 VM 的交付语义,后续不应再把它当作普通生产实例直接运行。" "如果选错对象,可能误把正在使用的业务 VM 转为模板,影响后续运维与交付。" "请确认该 VM 已停机或处于预期状态,并已导出配置或留存快照。" "TEMPLATE"; then
return 0
fi
if ! qm template "$vmid" >/dev/null 2>&1; then
display_error "模板转换失败" "请检查 VM 状态和任务日志。"
return 1
fi
display_success "模板转换完成" "VMID: $vmid"
}
vm_clone_vm() {
vm_require_commands qm || return 1
local mode="$1"
local source_vmid
source_vmid="$(img_select_vmid)"
local rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$source_vmid" ]] || return 1
if [[ "$mode" == "linked" ]] && ! vm_is_template "$source_vmid"; then
display_error "链接克隆仅支持模板虚拟机" "请先将源 VM 转换为模板。"
return 1
fi
local new_vmid new_name full_flag store
read -p "请输入新的 VMID: " new_vmid
vm_validate_new_vmid "$new_vmid" || return 1
read -p "请输入新 VM 名称 [clone-$new_vmid]: " new_name
new_name="${new_name:-clone-$new_vmid}"
full_flag=1
if [[ "$mode" == "linked" ]]; then
full_flag=0
else
store="$(vm_select_storage_by_content images "请选择完整克隆目标存储")"
rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$store" ]] || return 1
fi
local -a cmd=(qm clone "$source_vmid" "$new_vmid" --name "$new_name" --full "$full_flag")
if [[ "$full_flag" -eq 1 && -n "$store" ]]; then
cmd+=(--storage "$store")
fi
if ! confirm_high_risk_action "从 VM $source_vmid 创建 ${mode} 克隆到 $new_vmid" "克隆会复制或引用源磁盘,完整克隆会大量占用空间,链接克隆依赖模板与底层存储能力。" "目标存储、模板状态或 VMID 选择错误时,可能产生错误副本或交付错误实例。" "请确认源 VM、目标 VMID、目标存储和交付计划均已核对。" "CLONE"; then
return 0
fi
local output
if ! output="$("${cmd[@]}" 2>&1)"; then
echo "$output" | sed 's/^/ /'
display_error "克隆失败" "请检查源 VM 状态、目标存储及日志输出。"
return 1
fi
echo "$output" | sed 's/^/ /'
display_success "克隆完成" "新 VMID: $new_vmid"
}

View File

@@ -0,0 +1,218 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
vm_ensure_vm_config_backup() {
local vmid="$1"
local conf_path
conf_path="$(get_qm_conf_path "$vmid")"
if [[ -f "$conf_path" ]]; then
backup_file "$conf_path" >/dev/null 2>&1 || true
fi
}
vm_ensure_cloudinit_drive() {
local vmid="$1"
local store="$2"
local cfg slot
cfg="$(qm config "$vmid" 2>/dev/null)"
if echo "$cfg" | grep -Eq '^(ide2|scsi2): .*cloudinit'; then
return 0
fi
slot="ide2"
if echo "$cfg" | grep -q '^ide2:'; then
slot="scsi2"
if echo "$cfg" | grep -q '^scsi2:'; then
display_error "无法自动添加 Cloud-Init 盘" "ide2 与 scsi2 都已被占用,请先释放一个插槽。"
return 1
fi
fi
if ! qm set "$vmid" "-$slot" "$store:cloudinit" >/dev/null 2>&1; then
display_error "添加 Cloud-Init 盘失败" "请检查存储 $store 是否支持 images 内容类型。"
return 1
fi
}
vm_validate_cicustom_volumes() {
local raw="$1"
local ref volume store
IFS=',' read -r -a refs <<< "$raw"
for ref in "${refs[@]}"; do
volume="${ref#*=}"
store="${volume%%:*}"
if [[ -z "$store" || "$store" == "$volume" ]]; then
log_error "cicustom 引用格式无效: $ref"
return 1
fi
if ! vm_storage_supports_content "$store" snippets; then
log_error "存储 $store 不支持 snippets 内容类型,无法作为 cicustom 来源"
return 1
fi
done
}
vm_cloudinit_configure_for_vmid() {
local vmid="$1"
vm_require_commands qm pvesm || return 1
local cfg ci_store
cfg="$(qm config "$vmid" 2>/dev/null)"
if ! echo "$cfg" | grep -Eq '^(ide2|scsi2): .*cloudinit'; then
ci_store="$(vm_select_storage_by_content images "请选择 Cloud-Init 盘存储")"
local rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$ci_store" ]] || return 1
vm_ensure_cloudinit_drive "$vmid" "$ci_store" || return 1
fi
local ciuser cipassword ipconfig0 nameserver searchdomain citype sshkeys_path cicustom console_mode
read -p "Cloud-Init 用户名(留空跳过): " ciuser
read -p "Cloud-Init 密码(留空跳过): " cipassword
read -p "网络配置 ipconfig0示例 ip=dhcp 或 ip=192.168.1.10/24,gw=192.168.1.1,留空跳过): " ipconfig0
read -p "nameserver留空跳过: " nameserver
read -p "searchdomain留空跳过: " searchdomain
read -p "citype (nocloud/configdrive2/opennebula留空跳过) [nocloud]: " citype
citype="${citype:-nocloud}"
read -p "SSH 公钥文件路径(留空跳过): " sshkeys_path
if [[ -n "$sshkeys_path" && ! -f "$sshkeys_path" ]]; then
display_error "SSH 公钥文件不存在: $sshkeys_path"
return 1
fi
read -p "cicustom示例 user=local:snippets/user.yaml留空跳过: " cicustom
if [[ -n "$cicustom" ]]; then
vm_validate_cicustom_volumes "$cicustom" || return 1
fi
read -p "是否启用串口控制台输出?(yes/no) [yes]: " console_mode
console_mode="${console_mode:-yes}"
local -a cmd=(qm set "$vmid")
[[ -n "$ciuser" ]] && cmd+=(--ciuser "$ciuser")
[[ -n "$cipassword" ]] && cmd+=(--cipassword "$cipassword")
[[ -n "$ipconfig0" ]] && cmd+=(--ipconfig0 "$ipconfig0")
[[ -n "$nameserver" ]] && cmd+=(--nameserver "$nameserver")
[[ -n "$searchdomain" ]] && cmd+=(--searchdomain "$searchdomain")
[[ -n "$citype" ]] && cmd+=(--citype "$citype")
[[ -n "$sshkeys_path" ]] && cmd+=(--sshkeys "$sshkeys_path")
[[ -n "$cicustom" ]] && cmd+=(--cicustom "$cicustom")
if (( ${#cmd[@]} > 2 )); then
if ! confirm_high_risk_action "写入 VM $vmid 的 Cloud-Init 参数" "会直接覆盖现有 Cloud-Init 用户、密码、网络、DNS、SSH 密钥或 cicustom 指向。" "后续启动、重新生成 cloud-init 数据或交付克隆时,实例身份与网络行为可能发生变化。" "请确认参数、snippets 来源与 SSH 公钥均正确,并已记录旧配置。" "CLOUDINIT"; then
return 0
fi
if ! "${cmd[@]}" >/dev/null 2>&1; then
display_error "Cloud-Init 参数写入失败" "请检查参数格式、snippets 存储和日志输出。"
return 1
fi
fi
if [[ "$console_mode" == "yes" || "$console_mode" == "YES" ]]; then
qm set "$vmid" --serial0 socket --vga serial0 >/dev/null 2>&1 || log_warn "串口控制台配置失败,可稍后手工设置。"
fi
display_success "Cloud-Init 配置已写入" "可使用 qm cloudinit dump $vmid user 查看生成结果。"
}
vm_cloudinit_configure() {
local vmid
vmid="$(img_select_vmid)"
local rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$vmid" ]] || return 1
vm_cloudinit_configure_for_vmid "$vmid"
}
vm_cloud_image_to_template() {
vm_require_commands qm pvesm qemu-img || return 1
local image_path
image_path="$(img_select_img_file)"
local rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$image_path" ]] || return 1
local vmid vm_name memory cores bridge image_store ci_store
read -p "请输入新的 VMID: " vmid
vm_validate_new_vmid "$vmid" || return 1
read -p "请输入 VM 名称 [cloud-template-$vmid]: " vm_name
vm_name="${vm_name:-cloud-template-$vmid}"
read -p "内存大小 MB [2048]: " memory
memory="${memory:-2048}"
read -p "CPU 核心数 [2]: " cores
cores="${cores:-2}"
read -p "默认桥接 [${VM_DEFAULT_CLOUDINIT_BRIDGE}]: " bridge
bridge="${bridge:-$VM_DEFAULT_CLOUDINIT_BRIDGE}"
image_store="$(vm_select_storage_by_content images "请选择系统盘存储")"
rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$image_store" ]] || return 1
ci_store="$(vm_select_storage_by_content images "请选择 Cloud-Init 盘存储")"
rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$ci_store" ]] || return 1
if ! confirm_high_risk_action "基于镜像 $image_path 创建 VM $vmid 并导入系统盘" "该流程会创建新 VM、写入磁盘卷并占用目标存储镜像、VMID 或目标存储选错时会把流程导向错误对象。" "可能产生错误模板、错误网络配置或额外占用大量存储空间。" "请确认镜像来源可信,目标 VMID 空闲,系统盘存储与 Cloud-Init 存储已核对。" "IMPORT-IMAGE"; then
return 0
fi
if ! qm create "$vmid" --name "$vm_name" --memory "$memory" --cores "$cores" --net0 "virtio,bridge=$bridge" >/dev/null 2>&1; then
display_error "基础 VM 创建失败" "请检查参数和当前集群状态。"
return 1
fi
local import_out vol
if ! import_out="$(qm importdisk "$vmid" "$image_path" "$image_store" 2>&1)"; then
echo "$import_out" | sed 's/^/ /'
display_error "云镜像导入失败" "请检查镜像格式、目标存储空间和日志输出。"
return 1
fi
vol="$(echo "$import_out" | sed -n "s/.*as '\([^']\+\)'.*/\1/p" | tail -n 1)"
[[ -z "$vol" ]] && vol="$(echo "$import_out" | grep -oE "${image_store}:[^ ]+" | tail -n 1)"
if [[ -z "$vol" ]]; then
display_error "无法解析导入后的卷 ID" "请手动查看 qm importdisk 输出后继续处理。"
return 1
fi
if ! qm set "$vmid" --scsihw virtio-scsi-pci --scsi0 "$vol" --boot order=scsi0 --ide2 "$ci_store:cloudinit" --serial0 socket --vga serial0 --agent 1 >/dev/null 2>&1; then
display_error "模板基础参数写入失败" "请检查存储、控制器类型与日志输出。"
return 1
fi
vm_cloudinit_configure_for_vmid "$vmid"
if confirm_high_risk_action "将 VM $vmid 转换为云镜像模板" "模板化后该 VM 会被视为母版,后续克隆将继承当前磁盘与 Cloud-Init 状态。" "如果模板内容未校验,错误会被批量复制到后续所有实例。" "请确认系统盘、Cloud-Init 与基础软件状态均已验证,再执行模板转换。" "TEMPLATE"; then
qm template "$vmid" >/dev/null 2>&1 || {
display_error "模板转换失败" "请检查当前任务状态。"
return 1
}
fi
display_success "云镜像模板准备完成" "VMID: $vmid"
}
vm_template_cloudinit_menu() {
while true; do
clear
show_menu_header "模板 / 克隆 / Cloud-Init"
vm_show_data_risk_banner
show_menu_option "1" "列出所有模板"
show_menu_option "2" "将现有 VM 转换为模板"
show_menu_option "3" "完整克隆 VM"
show_menu_option "4" "链接克隆模板"
show_menu_option "5" "导入云镜像并生成模板"
show_menu_option "6" "配置 Cloud-Init 参数"
show_menu_option "0" "返回"
show_menu_footer
local choice
read -p "请选择操作 [0-6]: " choice
case "$choice" in
1) vm_show_template_records ;;
2) vm_convert_to_template ;;
3) vm_clone_vm full ;;
4) vm_clone_vm linked ;;
5) vm_cloud_image_to_template ;;
6) vm_cloudinit_configure ;;
0) return ;;
*) log_error "无效选择" ;;
esac
pause_function
done
}

View File

@@ -0,0 +1,213 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
vm_discover_export_files() {
if [[ -d "$VM_CONFIG_EXPORT_DIR" ]]; then
find "$VM_CONFIG_EXPORT_DIR" -maxdepth 1 -type f -name 'vm-*.conf' -printf '%p|%s|%TY-%Tm-%Td %TH:%TM\n' 2>/dev/null | sort -u
fi
}
vm_select_export_file() {
local files
files="$(vm_discover_export_files)"
if [[ -z "$files" ]]; then
local manual
read -p "未自动发现导出文件,请手动输入配置文件完整路径: " manual
[[ -n "$manual" && -f "$manual" ]] || return 1
echo "$manual"
return 0
fi
{
echo -e "${CYAN}已发现 VM 配置导出文件:${NC}"
echo "$files" | awk -F'|' '{printf " [%d] %-10s %-16s %s\n", NR, $2, $3, $1}'
echo -e "${UI_DIVIDER}"
} >&2
local pick line path
read -p "请选择配置文件序号 (0 手动输入): " pick
pick="${pick:-0}"
if [[ "$pick" == "0" ]]; then
local manual
read -p "请输入配置文件完整路径: " manual
[[ -n "$manual" && -f "$manual" ]] || return 1
echo "$manual"
return 0
fi
[[ "$pick" =~ ^[0-9]+$ ]] || return 1
line="$(echo "$files" | awk -F'|' -v n="$pick" 'NR==n{print $0}')"
path="$(echo "$line" | awk -F'|' '{print $1}')"
[[ -n "$path" && -f "$path" ]] || return 1
echo "$path"
}
vm_export_config() {
vm_require_commands qm || return 1
local vmid
vmid="$(img_select_vmid)"
local rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$vmid" ]] || return 1
mkdir -p "$VM_CONFIG_EXPORT_DIR"
local output_file timestamp
timestamp="$(date +%Y%m%d_%H%M%S)"
output_file="$VM_CONFIG_EXPORT_DIR/vm-${vmid}-${timestamp}.conf"
{
echo "# PVE-Tools VM Export"
echo "# source_vmid=${vmid}"
echo "# source_node=$(hostname)"
echo "# exported_at=$(date +%F' '%T)"
qm config "$vmid"
} > "$output_file"
display_success "VM 配置已导出" "$output_file"
}
vm_import_config() {
vm_require_commands qm || return 1
local file
file="$(vm_select_export_file)"
local rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$file" ]] || return 1
local new_vmid
read -p "请输入新的 VMID: " new_vmid
vm_validate_new_vmid "$new_vmid" || return 1
local exported_name new_name import_mode regenerate_mac
exported_name="$(awk -F': ' '/^name: /{print $2; exit}' "$file")"
read -p "请输入新 VM 名称 [${exported_name:-vm-$new_vmid}]: " new_name
new_name="${new_name:-${exported_name:-vm-$new_vmid}}"
read -p "导入模式 (config/rebind-disks) [config]: " import_mode
import_mode="${import_mode:-config}"
case "$import_mode" in
config|rebind-disks) ;;
*)
display_error "不支持的导入模式: $import_mode" "仅支持 config 或 rebind-disks。"
return 1
;;
esac
read -p "是否重建网卡 MAC 地址?(yes/no) [yes]: " regenerate_mac
regenerate_mac="${regenerate_mac:-yes}"
case "$regenerate_mac" in
yes|YES|no|NO) ;;
*)
display_error "是否重建网卡 MAC 地址仅支持 yes/no"
return 1
;;
esac
if [[ "$import_mode" == "rebind-disks" ]]; then
if ! confirm_high_risk_action "以 rebind-disks 模式导入 VM $new_vmid" "该模式会把导出配置中的磁盘引用重新绑定到新 VM选错卷会直接指向现有数据。" "错误重绑可能造成数据卷误挂载、业务串卷或后续误删风险。" "请逐项核对导出文件中的磁盘卷 ID仅在确实理解每个卷来源时继续。" "REBIND-DISKS"; then
return 0
fi
fi
if ! confirm_high_risk_action "导入配置文件并创建新 VM $new_vmid" "配置回放会逐项写入新 VM如果选择 rebind-disks错误的磁盘引用可能绑定到不应接管的数据卷。" "可能造成新 VM 配置错误、网络冲突,或因错误重绑磁盘而影响现有数据卷识别。" "请确认导入文件来源可信,目标 VMID 空闲,并已核对磁盘引用与网卡规划。" "IMPORT-CONFIG"; then
return 0
fi
local -a option_lines disk_lines failed_keys attached_disk_keys
local bootdisk_value=""
while IFS= read -r line; do
[[ -z "$line" || "$line" =~ ^# ]] && continue
[[ "$line" != *': '* ]] && continue
local key="${line%%:*}"
local value="${line#*: }"
case "$key" in
name|template|digest|lock|meta|parent|vmgenid|unused*|snapstate|runningmachine|runningcpu)
continue
;;
bootdisk)
bootdisk_value="$value"
continue
;;
scsi*|sata*|virtio*|ide*|efidisk0|tpmstate0)
disk_lines+=("$key|$value")
continue
;;
net*)
if [[ "$regenerate_mac" == "yes" || "$regenerate_mac" == "YES" ]]; then
value="$(vm_network_strip_mac "$value")"
fi
option_lines+=("$key|$value")
;;
*)
option_lines+=("$key|$value")
;;
esac
done < "$file"
if ! qm create "$new_vmid" --name "$new_name" >/dev/null 2>&1; then
display_error "qm create 失败" "请检查 VMID 是否冲突,或查看任务日志。"
return 1
fi
local entry key value
for entry in "${option_lines[@]}"; do
key="${entry%%|*}"
value="${entry#*|}"
if ! qm set "$new_vmid" "-$key" "$value" >/dev/null 2>&1; then
failed_keys+=("$key")
fi
done
if [[ "$import_mode" == "rebind-disks" ]]; then
for entry in "${disk_lines[@]}"; do
key="${entry%%|*}"
value="${entry#*|}"
if qm set "$new_vmid" "-$key" "$value" >/dev/null 2>&1; then
attached_disk_keys+=("$key")
else
failed_keys+=("$key")
fi
done
if [[ -n "$bootdisk_value" ]]; then
if ! qm set "$new_vmid" --bootdisk "$bootdisk_value" >/dev/null 2>&1; then
failed_keys+=("bootdisk")
fi
fi
fi
if (( ${#failed_keys[@]} > 0 )); then
if [[ "$import_mode" == "rebind-disks" ]]; then
local attached_key
for attached_key in "${attached_disk_keys[@]}"; do
qm set "$new_vmid" --delete "$attached_key" >/dev/null 2>&1 || log_warn "回滚重绑磁盘槽位失败: $attached_key"
done
fi
if qm destroy "$new_vmid" --purge 1 >/dev/null 2>&1; then
display_error "VM 配置导入失败,已自动回滚" "失败项: ${failed_keys[*]}"
else
display_error "VM 配置导入失败" "失败项: ${failed_keys[*]};已尝试回滚,但自动清理未完成,请立即检查 VM $new_vmid"
fi
return 1
fi
display_success "VM 配置导入完成" "新 VMID: $new_vmid"
}
vm_config_io_menu() {
while true; do
clear
show_menu_header "VM 配置导入/导出"
vm_show_data_risk_banner
show_menu_option "1" "导出 VM 配置"
show_menu_option "2" "导入 VM 配置"
show_menu_option "0" "返回"
show_menu_footer
local choice
read -p "请选择操作 [0-2]: " choice
case "$choice" in
1) vm_export_config ;;
2) vm_import_config ;;
0) return ;;
*) log_error "无效选择" ;;
esac
pause_function
done
}

View File

@@ -0,0 +1,158 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
vm_resize_disk() {
vm_require_commands qm || return 1
local vmid slot size_change
vmid="$(img_select_vmid)"
local rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$vmid" ]] || return 1
slot="$(vm_select_disk_slot "$vmid")"
rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$slot" ]] || return 1
read -p "请输入扩容值(示例 +10G 或 64G: " size_change
[[ -n "$size_change" ]] || return 1
if ! confirm_high_risk_action "为 VM $vmid$slot 执行磁盘扩容" "扩容通常不可逆;访客系统内若未正确扩展分区/文件系统,可能导致识别异常。" "错误的磁盘槽位或大小参数会把变更写到错误磁盘对象。" "请确认磁盘槽位、目标容量和访客系统扩容方案已准备完毕。" "RESIZE"; then
return 0
fi
if qm disk resize "$vmid" "$slot" "$size_change" >/dev/null 2>&1 || qm resize "$vmid" "$slot" "$size_change" >/dev/null 2>&1; then
display_success "磁盘扩容完成" "$slot -> $size_change"
else
display_error "磁盘扩容失败" "请检查磁盘插槽、大小参数和日志输出。"
return 1
fi
}
vm_add_disk() {
vm_require_commands qm pvesm || return 1
local vmid store bus slot disk_size
vmid="$(img_select_vmid)"
local rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$vmid" ]] || return 1
store="$(vm_select_storage_by_content images "请选择新磁盘存储")"
rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$store" ]] || return 1
read -p "磁盘总线类型 (scsi/sata/virtio/ide) [scsi]: " bus
bus="${bus:-scsi}"
slot="$(vm_find_free_disk_slot "$vmid" "$bus")"
[[ -n "$slot" ]] || {
display_error "未找到可用磁盘插槽" "请先释放对应总线插槽后再试。"
return 1
}
read -p "磁盘大小(示例 32G / 512M: " disk_size
[[ "$disk_size" =~ ^[0-9]+[KMGTP]$ ]] || {
display_error "磁盘大小格式错误" "请使用类似 32G、512M 的格式。"
return 1
}
vm_ensure_vm_config_backup "$vmid"
if ! confirm_high_risk_action "为 VM $vmid 添加磁盘 $slot" "将立即在目标存储分配新卷并写入 VM 配置。" "错误的总线、存储或容量选择会造成资源浪费,甚至影响后续系统盘识别。" "请确认目标存储、总线类型与容量规划已核对。" "ADDDISK"; then
return 0
fi
if ! qm set "$vmid" "-$slot" "$store:$disk_size" >/dev/null 2>&1; then
display_error "添加磁盘失败" "请检查存储、容量与日志输出。"
return 1
fi
display_success "磁盘添加完成" "$slot = $store:$disk_size"
}
vm_remove_disk() {
vm_require_commands qm || return 1
local vmid slot
vmid="$(img_select_vmid)"
local rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$vmid" ]] || return 1
slot="$(vm_select_disk_slot "$vmid")"
rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$slot" ]] || return 1
vm_ensure_vm_config_backup "$vmid"
if ! confirm_high_risk_action "从 VM $vmid 删除磁盘插槽 $slot" "删除磁盘配置会让访客系统失去该磁盘引用,若误删系统盘或关键数据盘会导致业务中断。" "后续若继续写入或重新分配卷,数据恢复难度会快速上升。" "请确认该槽位不是系统关键盘,且已完成卷级备份或快照。" "DELETE"; then
return 0
fi
if ! qm set "$vmid" --delete "$slot" >/dev/null 2>&1; then
display_error "删除磁盘失败" "请检查 VM 锁定状态和日志输出。"
return 1
fi
display_success "磁盘已移除" "$slot"
}
vm_move_disk() {
vm_require_commands qm pvesm || return 1
local vmid slot target_store delete_source
vmid="$(img_select_vmid)"
local rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$vmid" ]] || return 1
slot="$(vm_select_disk_slot "$vmid")"
rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$slot" ]] || return 1
target_store="$(vm_select_storage_by_content images "请选择目标存储")"
rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$target_store" ]] || return 1
read -p "迁移后是否删除源磁盘?(yes/no) [yes]: " delete_source
delete_source="${delete_source:-yes}"
if ! confirm_high_risk_action "将 VM $vmid$slot 迁移到 $target_store" "迁移磁盘会复制或移动底层卷;若启用删除源盘,源卷在流程完成后会被清理。" "目标存储选错或空间不足时可能导致任务失败;删除源盘后回退复杂度更高。" "请确认目标存储、可用空间和是否删除源盘的策略已核对。" "MOVE-DISK"; then
return 0
fi
if [[ "$delete_source" == "yes" || "$delete_source" == "YES" ]]; then
qm disk move "$vmid" "$slot" "$target_store" --delete 1 >/dev/null 2>&1 || qm move_disk "$vmid" "$slot" "$target_store" --delete 1 >/dev/null 2>&1 || {
display_error "磁盘迁移失败" "请检查存储状态和日志输出。"
return 1
}
else
qm disk move "$vmid" "$slot" "$target_store" >/dev/null 2>&1 || qm move_disk "$vmid" "$slot" "$target_store" >/dev/null 2>&1 || {
display_error "磁盘迁移失败" "请检查存储状态和日志输出。"
return 1
}
fi
display_success "磁盘迁移完成" "$slot -> $target_store"
}
vm_disk_management_menu() {
while true; do
clear
show_menu_header "虚拟机磁盘管理"
vm_show_data_risk_banner
show_menu_option "1" "磁盘扩容"
show_menu_option "2" "添加磁盘"
show_menu_option "3" "移除磁盘"
show_menu_option "4" "迁移磁盘到其他存储"
show_menu_option "0" "返回"
show_menu_footer
local choice
read -p "请选择操作 [0-4]: " choice
case "$choice" in
1) vm_resize_disk ;;
2) vm_add_disk ;;
3) vm_remove_disk ;;
4) vm_move_disk ;;
0) return ;;
*) log_error "无效选择" ;;
esac
pause_function
done
}

View File

@@ -0,0 +1,104 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
fastpve_quick_download_menu() {
clear
show_banner
show_menu_header "PVE 虚拟机快速下载 (FastPVE)"
echo " FastPVE 由社区开发者 @kspeeder 维护,提供热门 PVE 虚拟机模板快速拉取能力。"
echo " 本功能将直接运行 FastPVE 官方脚本,请在执行前确保信任该来源。"
echo
echo " 项目地址: $FASTPVE_PROJECT_URL"
echo " 安装脚本: $FASTPVE_INSTALLER_URL"
echo
echo -e "${RED}⚠️ 重要提示:${NC} 这是第三方脚本,出现任何问题请前往 FastPVE 项目反馈,别找我喔~"
echo -e "${YELLOW} 我们只负责帮你下载并执行,后续操作和风险请自行承担。${NC}"
echo "${UI_DIVIDER}"
echo " 使用说明:"
echo " • FastPVE 会拉取独立菜单,按提示选择需要的虚拟机模板"
echo " • 需要互联网访问 GitHub大陆环境自动优先使用镜像源"
echo " • 本脚本仅负责下载并执行 FastPVE具体操作由 FastPVE 完成"
echo "${UI_DIVIDER}"
read -p "是否立即运行 FastPVE 脚本?(y/N): " confirm
confirm=${confirm:-N}
if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then
log_info "已取消执行 FastPVE"
return 0
fi
local fastpve_url="$FASTPVE_INSTALLER_URL"
local fastpve_mirror_url="${GITHUB_MIRROR_PREFIX}${FASTPVE_INSTALLER_URL}"
local preferred_url="$fastpve_url"
local fallback_url="$fastpve_mirror_url"
local preferred_label="GitHub"
local fallback_label="加速镜像"
if detect_network_region; then
if [[ $USE_MIRROR_FOR_UPDATE -eq 1 ]]; then
preferred_url="$fastpve_mirror_url"
fallback_url="$fastpve_url"
preferred_label="加速镜像"
fallback_label="GitHub"
log_info "检测到中国大陆网络环境,优先使用 FastPVE 加速镜像下载"
else
if [[ -n "$USER_COUNTRY_CODE" ]]; then
log_info "检测到当前地区: $USER_COUNTRY_CODE,将通过 GitHub 下载 FastPVE"
else
log_info "网络检测成功,将通过 GitHub 下载 FastPVE"
fi
fi
else
log_warn "无法检测网络地区,默认使用 GitHub 下载 FastPVE"
fi
local -a download_cmd
local downloader_name=""
if command -v curl &> /dev/null; then
download_cmd=(curl -fsSL --connect-timeout 10 --max-time 60 -o)
downloader_name="curl"
elif command -v wget &> /dev/null; then
download_cmd=(wget -q -O)
downloader_name="wget"
else
log_error "未检测到 curl 或 wget无法下载 FastPVE 脚本"
return 1
fi
local tmp_script
if ! tmp_script=$(mktemp /tmp/fastpve-install.XXXXXX.sh); then
log_error "无法创建临时文件FastPVE 启动失败"
return 1
fi
log_info "使用 $preferred_label 下载 FastPVE 安装脚本 (下载器: $downloader_name)..."
if ! "${download_cmd[@]}" "$tmp_script" "$preferred_url"; then
log_warn "$preferred_label 下载失败,尝试改用 $fallback_label..."
: > "$tmp_script"
if ! "${download_cmd[@]}" "$tmp_script" "$fallback_url"; then
log_error "FastPVE 安装脚本下载失败,请检查网络或稍后重试"
rm -f "$tmp_script"
return 1
fi
fi
chmod +x "$tmp_script"
echo
log_step "FastPVE 脚本即将运行,请根据 FastPVE 菜单提示选择虚拟机模板"
echo "${UI_BORDER}"
sh "$tmp_script"
local run_status=$?
echo "${UI_BORDER}"
rm -f "$tmp_script"
if [[ $run_status -eq 0 ]]; then
log_success "FastPVE 虚拟机快速下载脚本执行完成"
else
log_error "FastPVE 脚本执行失败 (退出码: $run_status)"
fi
return $run_status
}

View File

@@ -0,0 +1,488 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
garbage_cleanup_sum_sizes() {
awk -F'|' '{sum += $2} END {print sum + 0}'
}
garbage_cleanup_count_records() {
awk 'NF {count++} END {print count + 0}'
}
garbage_cleanup_temp_file_candidates() {
local age_days="${1:-3}"
[[ "$age_days" =~ ^[0-9]+$ ]] || age_days=3
if [[ -d /tmp ]]; then
find /tmp -maxdepth 1 -type f \( \
-name 'pve-tools-*' -o \
-name 'pve-third-party-*' -o \
-name 'fastpve-install.*.sh' -o \
-name 'pve8to9_check.log' -o \
-name 'pve-qemu-kvm.deb' \
\) -mtime +"$age_days" -printf '%p|%s|%TY-%Tm-%Td %TH:%TM|临时文件\n' 2>/dev/null || true
fi
}
garbage_cleanup_pve_tools_old_file_candidates() {
local age_days="${1:-90}"
local root
[[ "$age_days" =~ ^[0-9]+$ ]] || age_days=90
for root in "/var/backups/pve-tools" "$VM_CONFIG_EXPORT_DIR"; do
if [[ -d "$root" ]]; then
find "$root" -type f -mtime +"$age_days" -printf '%p|%s|%TY-%Tm-%Td %TH:%TM|PVE-Tools 旧文件\n' 2>/dev/null || true
fi
done
}
garbage_cleanup_print_file_records() {
local records="$1"
local idx=1 path size mtime reason
printf "%-5s %-10s %-16s %-18s %s\n" "序号" "大小" "时间" "类型" "文件"
echo "$UI_DIVIDER"
while IFS='|' read -r path size mtime reason; do
[[ -n "$path" ]] || continue
printf "%-5s %-10s %-16s %-18s %s\n" "$idx" "$(pve_tools_human_bytes "$size")" "$mtime" "$reason" "$path"
idx=$((idx + 1))
done <<< "$records"
}
garbage_cleanup_delete_file_records() {
local records="$1"
local success=0 failed=0 skipped=0 path size mtime reason mode answer
if [[ -z "$records" ]]; then
log_warn "没有可删除的候选文件。"
return 0
fi
read -p "删除模式:输入 all 批量删除,输入 item 逐项确认 [item]: " mode
mode="${mode:-item}"
[[ "$mode" == "all" || "$mode" == "item" ]] || mode="item"
while IFS='|' read -r path size mtime reason; do
[[ -n "$path" && -f "$path" ]] || {
skipped=$((skipped + 1))
continue
}
if [[ "$mode" == "item" ]]; then
read -p "删除 $path ? 输入 yes 确认: " answer
if [[ "$answer" != "yes" && "$answer" != "YES" ]]; then
skipped=$((skipped + 1))
continue
fi
fi
if rm -f -- "$path"; then
log_success "已删除: $path"
success=$((success + 1))
else
log_error "删除失败: $path"
failed=$((failed + 1))
fi
done <<< "$records"
log_info "删除完成:成功 $success,失败 $failed,跳过 $skipped"
}
garbage_cleanup_basic() {
block_non_pve9_destructive "垃圾清理(缓存/日志/临时文件)" || return 1
local temp_age journal_days pve_age auto_remove records total_size
read -p "清理 /tmp 中超过多少天的 PVE-Tools 临时文件 [3]: " temp_age
temp_age="${temp_age:-3}"
[[ "$temp_age" =~ ^[0-9]+$ ]] || temp_age=3
read -p "systemd-journal 保留天数 [14]: " journal_days
journal_days="${journal_days:-14}"
[[ "$journal_days" =~ ^[0-9]+$ ]] || journal_days=14
read -p "PVE-Tools 备份/导出文件保留天数 [90]: " pve_age
pve_age="${pve_age:-90}"
[[ "$pve_age" =~ ^[0-9]+$ ]] || pve_age=90
read -p "是否执行 apt autoremove 清理孤立依赖?输入 yes 启用 [no]: " auto_remove
auto_remove="${auto_remove:-no}"
records="$(
garbage_cleanup_temp_file_candidates "$temp_age"
garbage_cleanup_pve_tools_old_file_candidates "$pve_age"
)"
total_size="$(echo "$records" | garbage_cleanup_sum_sizes)"
clear
show_menu_header "垃圾清理预览"
echo -e "${CYAN}将执行:${NC}"
echo " - apt-get autoclean 清理过期软件包缓存"
echo " - journalctl --vacuum-time=${journal_days}d 压缩系统日志保留窗口"
echo " - 删除超过 ${temp_age} 天的 PVE-Tools 临时文件"
echo " - 删除超过 ${pve_age} 天的 PVE-Tools 备份/导出文件"
[[ "$auto_remove" == "yes" || "$auto_remove" == "YES" ]] && echo " - apt-get autoremove -y 清理孤立依赖"
echo "$UI_DIVIDER"
if [[ -n "$records" ]]; then
garbage_cleanup_print_file_records "$records"
echo "$UI_DIVIDER"
echo -e "${YELLOW}候选文件合计:${NC} $(pve_tools_human_bytes "$total_size")"
else
echo " 未发现符合年龄条件的 PVE-Tools 临时/旧文件。"
fi
if ! confirm_high_risk_action "执行垃圾清理" "会删除上方列出的本工具临时/旧文件,并清理 apt 缓存与 journal 日志窗口。" "文件删除不可逆apt autoremove 如果启用,可能移除系统认为不再需要的依赖包。" "请确认候选列表只包含可丢弃文件,重要备份已另存。" "CLEAN"; then
return 0
fi
log_step "清理 apt 过期缓存..."
apt-get autoclean -y || log_warn "apt-get autoclean 执行失败,请检查 apt 状态。"
if [[ "$auto_remove" == "yes" || "$auto_remove" == "YES" ]]; then
log_step "清理孤立依赖..."
apt-get autoremove -y || log_warn "apt-get autoremove 执行失败,请检查 apt 状态。"
fi
if command -v journalctl >/dev/null 2>&1; then
log_step "压缩 systemd journal 日志..."
journalctl --vacuum-time="${journal_days}d" || log_warn "journalctl 日志清理失败。"
fi
garbage_cleanup_delete_file_records "$records"
}
garbage_cleanup_backup_candidates() {
local mode="${1:-both}"
local age_days="${2:-180}"
local now cutoff path size mtime type vmid epoch old orphan reason
[[ "$age_days" =~ ^[0-9]+$ ]] || age_days=180
now="$(date +%s)"
cutoff=$((now - age_days * 86400))
while IFS='|' read -r path size mtime; do
[[ -n "$path" && -f "$path" ]] || continue
type="$(vm_backup_archive_guest_type "$path")"
vmid="$(vm_backup_archive_vmid "$path")"
epoch="$(stat -c '%Y' "$path" 2>/dev/null || echo 0)"
old=0
orphan=0
reason=""
if [[ "$epoch" =~ ^[0-9]+$ ]] && (( epoch > 0 && epoch < cutoff )); then
old=1
reason="超过 ${age_days}"
fi
if [[ -n "$vmid" && "$type" != "未知" ]] && ! pve_guest_exists "$type" "$vmid"; then
orphan=1
[[ -n "$reason" ]] && reason+=","
reason+="无对应 ${type} ${vmid}"
fi
case "$mode" in
old) (( old == 1 )) || continue ;;
orphan) (( orphan == 1 )) || continue ;;
both|*) (( old == 1 || orphan == 1 )) || continue ;;
esac
printf '%s|%s|%s|%s|%s|%s\n' "$path" "$size" "$mtime" "$type" "${vmid:-?}" "$reason"
done < <(vm_discover_all_backup_archives)
}
garbage_cleanup_print_backup_records() {
local records="$1"
local idx=1 path size mtime type vmid reason
printf "%-5s %-5s %-7s %-10s %-16s %-24s %s\n" "序号" "类型" "VMID" "大小" "时间" "原因" "文件"
echo "$UI_DIVIDER"
while IFS='|' read -r path size mtime type vmid reason; do
[[ -n "$path" ]] || continue
printf "%-5s %-5s %-7s %-10s %-16s %-24s %s\n" "$idx" "$type" "$vmid" "$(pve_tools_human_bytes "$size")" "$mtime" "$reason" "$path"
idx=$((idx + 1))
done <<< "$records"
}
garbage_cleanup_delete_backup_records() {
local records="$1"
local success=0 failed=0 skipped=0 path size mtime type vmid reason mode answer
if [[ -z "$records" ]]; then
log_warn "没有可删除的备份候选。"
return 0
fi
read -p "删除模式:输入 all 批量删除,输入 item 逐项确认 [item]: " mode
mode="${mode:-item}"
[[ "$mode" == "all" || "$mode" == "item" ]] || mode="item"
while IFS='|' read -r path size mtime type vmid reason; do
[[ -n "$path" && -f "$path" ]] || {
skipped=$((skipped + 1))
continue
}
if [[ "$mode" == "item" ]]; then
echo "备份: ${type:-?} ${vmid:-?} | $(pve_tools_human_bytes "$size") | ${reason:-未标注原因}"
read -p "删除 $path ? 输入 yes 确认: " answer
if [[ "$answer" != "yes" && "$answer" != "YES" ]]; then
skipped=$((skipped + 1))
continue
fi
fi
if rm -f -- "$path"; then
log_success "已删除备份: $path"
success=$((success + 1))
else
log_error "备份删除失败: $path"
failed=$((failed + 1))
fi
done <<< "$records"
log_info "备份清理完成:成功 $success,失败 $failed,跳过 $skipped"
}
garbage_cleanup_prune_backups() {
block_non_pve9_destructive "清理过期/无主备份文件" || return 1
vm_require_commands pvesm || return 1
local scope age_days records total_size
clear
show_menu_header "备份文件清理"
show_menu_option "1" "只列出无对应 VM/CT 的备份"
show_menu_option "2" "只列出超过指定天数的备份"
show_menu_option "3" "同时列出无主备份和过期备份"
echo "$UI_DIVIDER"
read -p "请选择筛选范围 [3]: " scope
scope="${scope:-3}"
case "$scope" in
1) scope="orphan" ;;
2) scope="old" ;;
*) scope="both" ;;
esac
read -p "过期备份阈值天数 [180]: " age_days
age_days="${age_days:-180}"
[[ "$age_days" =~ ^[0-9]+$ ]] || age_days=180
records="$(garbage_cleanup_backup_candidates "$scope" "$age_days")"
clear
show_menu_header "备份文件清理预览"
if [[ -z "$records" ]]; then
log_warn "未发现符合条件的备份文件。"
return 0
fi
garbage_cleanup_print_backup_records "$records"
total_size="$(echo "$records" | garbage_cleanup_sum_sizes)"
echo "$UI_DIVIDER"
echo -e "${YELLOW}候选备份合计:${NC} $(pve_tools_human_bytes "$total_size")"
echo -e "${YELLOW}提醒:${NC} NFS/CIFS 共享备份可能仍被其他节点使用,删除前请确认跨机恢复需求。"
if ! confirm_high_risk_action "删除上方列出的 vzdump 备份文件" "会永久删除备份文件,删除后无法通过这些备份恢复 VM/CT。" "如果误删最后一个可用备份,后续故障将缺少恢复点。" "请确认已有其他可用备份或确定这些备份不再需要。" "DELETE-BACKUP"; then
return 0
fi
garbage_cleanup_delete_backup_records "$records"
}
garbage_cleanup_snapshot_candidates() {
local age_days="${1:-90}"
local cutoff conf vmid guest_type
[[ "$age_days" =~ ^[0-9]+$ ]] || age_days=90
cutoff=$(($(date +%s) - age_days * 86400))
for conf in /etc/pve/qemu-server/*.conf /etc/pve/lxc/*.conf; do
[[ -f "$conf" ]] || continue
vmid="$(basename "$conf" .conf)"
guest_type="VM"
[[ "$conf" == /etc/pve/lxc/* ]] && guest_type="CT"
awk -v guest_type="$guest_type" -v vmid="$vmid" -v cutoff="$cutoff" '
/^\[[^]]+\]$/ {
snap = $0
gsub(/^\[/, "", snap)
gsub(/\]$/, "", snap)
next
}
snap != "" && /^snaptime:[[:space:]]*[0-9]+/ {
ts = $2
if (ts < cutoff) {
print guest_type "|" vmid "|" snap "|" ts
}
snap = ""
}
' "$conf"
done
}
garbage_cleanup_prune_snapshots() {
block_non_pve9_destructive "清理旧快照" || return 1
local age_days records count idx guest_type vmid snapshot_name epoch time_text mode answer success=0 failed=0 skipped=0
read -p "列出超过多少天的快照 [90]: " age_days
age_days="${age_days:-90}"
[[ "$age_days" =~ ^[0-9]+$ ]] || age_days=90
records="$(garbage_cleanup_snapshot_candidates "$age_days")"
clear
show_menu_header "旧快照清理预览"
if [[ -z "$records" ]]; then
log_warn "未发现超过 ${age_days} 天且带 snaptime 的 VM/CT 快照。"
return 0
fi
printf "%-5s %-5s %-7s %-24s %-20s\n" "序号" "类型" "VMID" "快照名" "创建时间"
echo "$UI_DIVIDER"
idx=1
while IFS='|' read -r guest_type vmid snapshot_name epoch; do
[[ -n "$snapshot_name" ]] || continue
time_text="$(date -d "@$epoch" '+%F %T' 2>/dev/null || echo "$epoch")"
printf "%-5s %-5s %-7s %-24s %-20s\n" "$idx" "$guest_type" "$vmid" "$snapshot_name" "$time_text"
idx=$((idx + 1))
done <<< "$records"
count="$(echo "$records" | garbage_cleanup_count_records)"
echo "$UI_DIVIDER"
echo -e "${YELLOW}候选快照数量:${NC} $count"
if ! confirm_high_risk_action "删除上方列出的旧快照" "删除快照后将失去对应时间点的快速回滚能力。" "如果快照是业务回退基线,误删后只能依赖外部备份恢复。" "请确认这些快照已经过期,且已有必要的外部备份。" "DELETE-SNAP"; then
return 0
fi
read -p "删除模式:输入 all 批量删除,输入 item 逐项确认 [item]: " mode
mode="${mode:-item}"
[[ "$mode" == "all" || "$mode" == "item" ]] || mode="item"
while IFS='|' read -r guest_type vmid snapshot_name epoch; do
[[ -n "$snapshot_name" ]] || continue
if [[ "$mode" == "item" ]]; then
read -p "删除 ${guest_type} ${vmid} 快照 ${snapshot_name} ? 输入 yes 确认: " answer
if [[ "$answer" != "yes" && "$answer" != "YES" ]]; then
skipped=$((skipped + 1))
continue
fi
fi
if [[ "$guest_type" == "VM" ]]; then
if qm delsnapshot "$vmid" "$snapshot_name" >/dev/null 2>&1; then
log_success "已删除 VM $vmid 快照: $snapshot_name"
success=$((success + 1))
else
log_error "删除 VM $vmid 快照失败: $snapshot_name"
failed=$((failed + 1))
fi
else
if pct delsnapshot "$vmid" "$snapshot_name" >/dev/null 2>&1; then
log_success "已删除 CT $vmid 快照: $snapshot_name"
success=$((success + 1))
else
log_error "删除 CT $vmid 快照失败: $snapshot_name"
failed=$((failed + 1))
fi
fi
done <<< "$records"
log_info "快照清理完成:成功 $success,失败 $failed,跳过 $skipped"
}
garbage_cleanup_collect_referenced_volumes() {
{
grep -hoE '[A-Za-z0-9_.-]+:(vm|base|subvol)-[0-9]+-[^,[:space:]]+' /etc/pve/qemu-server/*.conf 2>/dev/null || true
grep -hoE '[A-Za-z0-9_.-]+:(vm|base|subvol)-[0-9]+-[^,[:space:]]+' /etc/pve/lxc/*.conf 2>/dev/null || true
} | sort -u
}
garbage_cleanup_orphan_disk_report() {
vm_require_commands pvesm || return 1
local refs store type status total used avail percent content line volid size vmid owner_id records=""
refs="$(garbage_cleanup_collect_referenced_volumes)"
while IFS='|' read -r store type status total used avail percent; do
[[ -n "$store" ]] || continue
for content in images rootdir; do
vm_storage_supports_content "$store" "$content" || continue
while read -r line; do
[[ -n "$line" ]] || continue
volid="$(echo "$line" | awk '{print $1}')"
size="$(echo "$line" | awk '{print $4}')"
vmid="$(echo "$line" | awk '{print $5}')"
[[ "$volid" =~ :((vm|base|subvol)-[0-9]+-) ]] || continue
if echo "$refs" | grep -Fxq "$volid"; then
continue
fi
owner_id="$(echo "$volid" | sed -nE 's/.*:(vm|base|subvol)-([0-9]+)-.*/\2/p')"
if [[ -n "$owner_id" ]] && pve_guest_exists any "$owner_id"; then
continue
fi
records+="${volid}|${size:-0}|${vmid:-${owner_id:-?}}|${content}|${store}"$'\n'
done < <(pvesm list "$store" --content "$content" 2>/dev/null | awk 'NR>1')
done
done < <(pve_storage_status_records)
clear
show_menu_header "疑似孤立磁盘扫描(只读)"
echo -e "${YELLOW}说明:${NC} 该功能只扫描,不删除。底层卷误删风险很高,请先在 Web UI / VM 配置中二次核对。"
echo "$UI_DIVIDER"
if [[ -z "$records" ]]; then
log_success "未发现明显的孤立磁盘候选。"
return 0
fi
printf "%-5s %-18s %-8s %-10s %-8s %s\n" "序号" "存储" "VMID" "内容" "大小" "卷"
echo "$UI_DIVIDER"
local idx=1
while IFS='|' read -r volid size vmid content store; do
[[ -n "$volid" ]] || continue
printf "%-5s %-18s %-8s %-10s %-8s %s\n" "$idx" "$store" "$vmid" "$content" "$(pve_tools_human_bytes "$size")" "$volid"
echo " 人工核对后可用命令: pvesm free \"$volid\""
idx=$((idx + 1))
done <<< "$records"
echo "$UI_DIVIDER"
echo -e "${RED}未在脚本中提供自动删除孤立磁盘入口。${NC}"
}
garbage_cleanup_scan_report() {
local temp_records old_records backup_records snap_records
local temp_count old_count backup_count snap_count apt_cache_size pve_backup_size pve_export_size
temp_records="$(garbage_cleanup_temp_file_candidates 3)"
old_records="$(garbage_cleanup_pve_tools_old_file_candidates 90)"
backup_records="$(garbage_cleanup_backup_candidates both 180)"
snap_records="$(garbage_cleanup_snapshot_candidates 90)"
temp_count="$(echo "$temp_records" | garbage_cleanup_count_records)"
old_count="$(echo "$old_records" | garbage_cleanup_count_records)"
backup_count="$(echo "$backup_records" | garbage_cleanup_count_records)"
snap_count="$(echo "$snap_records" | garbage_cleanup_count_records)"
apt_cache_size="$(du -sh /var/cache/apt/archives 2>/dev/null | awk '{print $1}')"
pve_backup_size="$(du -sh /var/backups/pve-tools 2>/dev/null | awk '{print $1}')"
pve_export_size="$(du -sh "$VM_CONFIG_EXPORT_DIR" 2>/dev/null | awk '{print $1}')"
clear
show_menu_header "垃圾清理扫描报告"
echo -e "${CYAN}缓存与日志:${NC}"
echo " apt 缓存目录: ${apt_cache_size:-未发现}"
if command -v journalctl >/dev/null 2>&1; then
echo -n " systemd journal: "
journalctl --disk-usage 2>/dev/null || echo "无法读取"
fi
echo "$UI_DIVIDER"
echo -e "${CYAN}本工具旧文件:${NC}"
echo " /tmp 中超过 3 天的 PVE-Tools 临时文件: $temp_count"
echo " /var/backups/pve-tools 总大小: ${pve_backup_size:-未发现}"
echo " $VM_CONFIG_EXPORT_DIR 总大小: ${pve_export_size:-未发现}"
echo " 超过 90 天的 PVE-Tools 备份/导出文件: $old_count"
echo "$UI_DIVIDER"
echo -e "${CYAN}PVE 资源候选:${NC}"
echo " 无主或超过 180 天的 vzdump 备份: $backup_count"
echo " 超过 90 天且带 snaptime 的 VM/CT 快照: $snap_count"
echo " 疑似孤立磁盘请使用菜单中的只读扫描单独查看。"
}
garbage_cleanup_menu() {
while true; do
clear
show_menu_header "垃圾清理"
show_menu_option "1" "一键扫描报告 ${CYAN}(只读)${NC}"
show_menu_option "2" "清理缓存、日志与本工具旧文件"
show_menu_option "3" "清理过期/无主 vzdump 备份"
show_menu_option "4" "清理旧 VM/CT 快照"
show_menu_option "5" "扫描疑似孤立磁盘 ${YELLOW}(只读,不删除)${NC}"
echo "$UI_DIVIDER"
echo -e "${YELLOW}说明:${NC} 清理前会列出候选项;备份和快照删除均需要高风险确认。"
show_menu_option "0" "返回"
show_menu_footer
local choice
read -p "请选择操作 [0-5]: " choice
case "$choice" in
1) garbage_cleanup_scan_report ;;
2) garbage_cleanup_basic ;;
3) garbage_cleanup_prune_backups ;;
4) garbage_cleanup_prune_snapshots ;;
5) garbage_cleanup_orphan_disk_report ;;
0) return ;;
*) log_error "无效选择" ;;
esac
pause_function
done
}

View File

@@ -0,0 +1,307 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
img_bytes_to_human() {
local bytes="$1"
if [[ -z "$bytes" || ! "$bytes" =~ ^[0-9]+$ ]]; then
echo "?"
return 0
fi
awk -v b="$bytes" 'BEGIN{
split("B KB MB GB TB PB", u, " ");
i=1; x=b;
while (x>=1024 && i<6) {x/=1024; i++}
if (i==1) printf "%d%s", b, u[i];
else printf "%.1f%s", x, u[i];
}'
}
img_discover_img_files() {
vm_discover_disk_image_files
}
img_select_img_file() {
local files
files="$(img_discover_img_files)"
if [[ -z "$files" ]]; then
log_error "未发现磁盘镜像文件"
log_tips "已扫描目录:/root、/var/lib/vz/template/iso、/home支持 .img/.raw/.qcow2"
return 1
fi
{
echo -e "${CYAN}已发现磁盘镜像文件:${NC}"
echo "$files" | awk -F'|' '
function human(x, u,i){
split("B KB MB GB TB PB", u, " ");
i=1;
while (x>=1024 && i<6){x/=1024;i++}
if (i==1) return sprintf("%d%s", x, u[i]);
return sprintf("%.1f%s", x, u[i]);
}
{
printf " [%d] %-9s %-16s %s\n", NR, human($2), $3, $1
}'
echo -e "${UI_DIVIDER}"
} >&2
local pick
read -p "请选择镜像序号 (0 返回): " pick
pick="${pick:-0}"
if [[ "$pick" == "0" ]]; then
return 2
fi
if [[ ! "$pick" =~ ^[0-9]+$ ]]; then
log_error "序号必须是数字"
return 1
fi
local line path
line="$(echo "$files" | awk -F'|' -v n="$pick" 'NR==n{print $0}')"
path="$(echo "$line" | awk -F'|' '{print $1}')"
if [[ -z "$path" || ! -f "$path" ]]; then
log_error "无效选择"
return 1
fi
echo "$path"
return 0
}
img_select_vmid() {
local vms
vms="$(qm list 2>/dev/null | awk 'NR>1{print $1 "|" $2 "|" $3}')"
if [[ -z "$vms" ]]; then
log_error "未发现虚拟机"
log_tips "请先创建虚拟机后再操作。"
return 1
fi
{
echo -e "${CYAN}可用虚拟机列表:${NC}"
echo "$vms" | awk -F'|' '{printf " [%d] VMID: %-6s Name: %-22s Status: %s\n", NR, $1, $2, $3}'
echo -e "${UI_DIVIDER}"
} >&2
local pick
read -p "请选择虚拟机序号 (0 返回): " pick
pick="${pick:-0}"
if [[ "$pick" == "0" ]]; then
return 2
fi
if [[ ! "$pick" =~ ^[0-9]+$ ]]; then
log_error "序号必须是数字"
return 1
fi
local line vmid
line="$(echo "$vms" | awk -F'|' -v n="$pick" 'NR==n{print $0}')"
vmid="$(echo "$line" | awk -F'|' '{print $1}')"
if [[ -z "$vmid" ]]; then
log_error "无效选择"
return 1
fi
if ! validate_qm_vmid "$vmid"; then
return 1
fi
echo "$vmid"
return 0
}
img_select_storage() {
local stores
stores="$(pvesm status 2>/dev/null | awk 'NR>1{print $1 "|" $2}')"
if [[ -z "$stores" ]]; then
local manual
read -p "未能获取存储列表,请手动输入存储名(如 local-lvm: " manual
if [[ -z "$manual" ]]; then
log_error "存储名不能为空"
return 1
fi
echo "$manual"
return 0
fi
{
echo -e "${CYAN}可用存储列表:${NC}"
echo "$stores" | awk -F'|' '{printf " [%d] %-18s (%s)\n", NR, $1, $2}'
echo -e "${UI_DIVIDER}"
} >&2
local pick
read -p "请选择存储序号 (0 返回): " pick
pick="${pick:-0}"
if [[ "$pick" == "0" ]]; then
return 2
fi
if [[ ! "$pick" =~ ^[0-9]+$ ]]; then
log_error "序号必须是数字"
return 1
fi
local line store
line="$(echo "$stores" | awk -F'|' -v n="$pick" 'NR==n{print $0}')"
store="$(echo "$line" | awk -F'|' '{print $1}')"
if [[ -z "$store" ]]; then
log_error "无效选择"
return 1
fi
echo "$store"
return 0
}
img_convert_and_import_to_vm() {
log_step "磁盘镜像转换并导入虚拟机"
if ! command -v qemu-img >/dev/null 2>&1; then
display_error "未找到 qemu-img" "请先安装apt install -y qemu-utils"
return 1
fi
if ! command -v qm >/dev/null 2>&1; then
display_error "未找到 qm 命令" "请确认当前环境为 PVE 宿主机。"
return 1
fi
local img_path
img_path="$(img_select_img_file)"
local rc=$?
if [[ "$rc" -eq 2 ]]; then
return 0
fi
if [[ -z "$img_path" ]]; then
return 1
fi
local vmid
vmid="$(img_select_vmid)"
rc=$?
if [[ "$rc" -eq 2 ]]; then
return 0
fi
if [[ -z "$vmid" ]]; then
return 1
fi
local store
store="$(img_select_storage)"
rc=$?
if [[ "$rc" -eq 2 ]]; then
return 0
fi
if [[ -z "$store" ]]; then
return 1
fi
local out_fmt
read -p "请选择目标格式 (qcow2/raw) [qcow2]: " out_fmt
out_fmt="${out_fmt:-qcow2}"
if [[ "$out_fmt" != "qcow2" && "$out_fmt" != "raw" ]]; then
display_error "不支持的格式: $out_fmt" "仅支持 qcow2/raw"
return 1
fi
local ts ext out_path out_dir
local src_fmt
src_fmt="$(vm_detect_image_format "$img_path")"
if [[ -z "$src_fmt" ]]; then
display_error "无法识别镜像格式" "请确认文件可被 qemu-img 识别,且格式为 img/raw/qcow2。"
return 1
fi
ts="$(date +%Y%m%d_%H%M%S)"
ext="$out_fmt"
out_dir="$(dirname "$img_path")"
out_path="${out_dir}/vm-${vmid}-disk-import-${ts}.${ext}"
if [[ -e "$out_path" ]]; then
out_path="${out_dir}/vm-${vmid}-disk-import-${ts}-1.${ext}"
fi
clear
show_menu_header "磁盘镜像转换并导入虚拟机"
local sz
sz="$(stat -c '%s' "$img_path" 2>/dev/null || echo "")"
echo -e "${YELLOW}源镜像:${NC} $img_path"
echo -e "${YELLOW}源格式:${NC} $src_fmt"
if [[ -n "$sz" ]]; then
echo -e "${YELLOW}大小:${NC} $(img_bytes_to_human "$sz")"
fi
echo -e "${YELLOW}目标 VMID:${NC} $vmid"
echo -e "${YELLOW}目标存储:${NC} $store"
echo -e "${YELLOW}目标格式:${NC} $out_fmt"
echo -e "${YELLOW}临时输出:${NC} $out_path"
echo -e "${UI_DIVIDER}"
if ! confirm_action "开始转换并导入磁盘?"; then
return 0
fi
log_step "开始转换qemu-img convert"
if ! qemu-img convert -p -f "$src_fmt" -O "$out_fmt" "$img_path" "$out_path"; then
display_error "镜像转换失败" "请检查镜像文件是否损坏,或查看日志输出。"
return 1
fi
log_step "开始导入qm importdisk"
local import_out vol
if ! import_out="$(qm importdisk "$vmid" "$out_path" "$store" 2>&1)"; then
echo "$import_out" | sed 's/^/ /'
display_error "导入失败" "请检查存储名称与空间,或查看上方输出。"
return 1
fi
vol="$(echo "$import_out" | sed -n "s/.*as '\\([^']\\+\\)'.*/\\1/p" | tail -n 1)"
[[ -z "$vol" ]] && vol="$(echo "$import_out" | grep -oE "${store}:[^ ]+" | tail -n 1)"
if [[ -n "$vol" ]]; then
log_success "导入完成: $vol"
else
log_success "导入完成"
fi
local attach_bus attach_slot cfg
local auto_attach="yes"
read -p "是否自动挂载到 VM(yes/no) [yes]: " auto_attach
auto_attach="${auto_attach:-yes}"
if [[ "$auto_attach" == "yes" || "$auto_attach" == "YES" ]]; then
read -p "请选择总线类型 (scsi/sata/ide) [scsi]: " attach_bus
attach_bus="${attach_bus:-scsi}"
if [[ "$attach_bus" != "scsi" && "$attach_bus" != "sata" && "$attach_bus" != "ide" ]]; then
log_warn "不支持的总线类型,跳过自动挂载: $attach_bus"
else
cfg="$(qm config "$vmid" 2>/dev/null || true)"
if [[ -n "$vol" && -n "$cfg" ]] && echo "$cfg" | grep -Fq "$vol"; then
log_info "检测到该卷已写入 VM 配置(可能为 unusedX 或已挂载),跳过自动挂载。"
elif [[ -z "$vol" ]]; then
log_info "未能解析导入卷 ID跳过自动挂载。"
else
attach_slot="$(rdm_find_free_slot "$vmid" "$attach_bus" 2>/dev/null)" || true
if [[ -z "$attach_slot" ]]; then
log_warn "未找到可用插槽,跳过自动挂载"
else
if confirm_action "将磁盘挂载到 VM $vmid${attach_slot} = ${vol}"; then
if qm set "$vmid" "-$attach_slot" "$vol" >/dev/null 2>&1; then
log_success "已挂载: $attach_slot"
else
log_warn "自动挂载失败,请在 PVE WebUI 中手动添加该磁盘"
fi
fi
fi
fi
fi
fi
local del_tmp="yes"
read -p "是否删除临时输出文件 $out_path (yes/no) [yes]: " del_tmp
del_tmp="${del_tmp:-yes}"
if [[ "$del_tmp" == "yes" || "$del_tmp" == "YES" ]]; then
rm -f "$out_path" >/dev/null 2>&1 || true
fi
display_success "处理完成" "如需从该磁盘引导,请在 VM 启动顺序中选择对应磁盘。"
return 0
}
img_convert_import_menu() {
clear
show_menu_header "磁盘镜像导入(转换为 QCOW2/RAW"
echo -e "${CYAN}功能说明:${NC}"
echo -e " - 自动扫描:/root、/var/lib/vz/template/iso、/home 下的 .img/.raw/.qcow2 文件"
echo -e " - 自动识别源格式,使用 qemu-img 转换后,通过 qm importdisk 导入到指定 VM 与存储"
echo -e "${UI_DIVIDER}"
img_convert_and_import_to_vm
}
# ============ VM 高级运维功能 ============

View File

@@ -0,0 +1,62 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
vm_advanced_operations_menu() {
while true; do
clear
show_menu_header "虚拟机高级运维工具箱"
vm_show_data_risk_banner
show_menu_option "1" "VM 备份与恢复"
show_menu_option "2" "VM 配置导入/导出"
show_menu_option "3" "模板 / 克隆 / Cloud-Init"
show_menu_option "4" "虚拟机磁盘管理"
show_menu_option "5" "快照管理"
show_menu_option "6" "启动顺序与网络管理"
show_menu_option "7" "集群内迁移 VM"
echo -e "${RED}警告:涉及备份恢复、磁盘、快照、模板与迁移时,必须先确认备份可用,再核对 VMID / 槽位 / 目标存储。${NC}"
show_menu_option "0" "返回"
show_menu_footer
local choice
read -p "请选择操作 [0-7]: " choice
case "$choice" in
1) vm_backup_restore_menu ;;
2) vm_config_io_menu ;;
3) vm_template_cloudinit_menu ;;
4) vm_disk_management_menu ;;
5) vm_snapshot_menu ;;
6) vm_startup_network_menu ;;
7) vm_cluster_migrate ;;
0) return ;;
*) log_error "无效选择" ;;
esac
pause_function
done
}
# 二级菜单:虚拟机与容器
menu_vm_container() {
while true; do
clear
show_menu_header "虚拟机与容器"
show_menu_option "1" "${CYAN}FastPVE${NC} - 虚拟机快速下载"
show_menu_option "2" "虚拟机/容器定时开关机"
show_menu_option "3" "IMG 镜像导入(转 QCOW2/RAW"
show_menu_option "4" "虚拟机高级运维工具箱"
echo "$UI_DIVIDER"
show_menu_option "0" "返回主菜单"
show_menu_footer
read -p "请选择操作 [0-4]: " choice
case $choice in
1) fastpve_quick_download_menu ;;
2) manage_vm_schedule ;;
3) img_convert_import_menu ;;
4) vm_advanced_operations_menu ;;
0) return ;;
*) log_error "无效选择" ;;
esac
pause_function
done
}
# ============ 宿主机网络 / 防火墙 / IPv6 / 诊断工具箱 ============

View File

@@ -0,0 +1,79 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
vm_cluster_migrate() {
vm_require_commands qm || return 1
local vmid target_node with_local live_mode storage_mode target_storage cfg status
vmid="$(img_select_vmid)"
local rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$vmid" ]] || return 1
target_node="$(vm_select_target_node)"
rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$target_node" ]] || {
display_error "未发现可用的目标节点" "请确认当前处于多节点集群环境。"
return 1
}
cfg="$(qm config "$vmid" 2>/dev/null)"
if echo "$cfg" | grep -qE '^hostpci[0-9]+:'; then
log_warn "检测到该 VM 使用 PCI/直通设备,迁移前请确认目标节点拥有相同硬件。"
fi
read -p "是否携带本地磁盘一起迁移?(yes/no) [yes]: " with_local
with_local="${with_local:-yes}"
status="$(qm status "$vmid" 2>/dev/null | awk '{print $2}' | head -n 1)"
if [[ "$status" == "running" ]]; then
read -p "是否启用在线迁移?(yes/no) [yes]: " live_mode
live_mode="${live_mode:-yes}"
else
live_mode="no"
fi
{
show_menu_option "1" "目标节点同名存储映射(--targetstorage 1"
show_menu_option "2" "统一迁移到指定存储"
show_menu_option "3" "不指定 targetstorage"
}
read -p "请选择目标存储策略 [1-3]: " storage_mode
case "$storage_mode" in
1) target_storage='1' ;;
2)
target_storage="$(vm_select_storage_by_content images "请选择迁移目标存储")"
rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$target_storage" ]] || return 1
;;
3) target_storage='' ;;
*)
log_error "无效选择"
return 1
;;
esac
local -a cmd=(qm migrate "$vmid" "$target_node")
if [[ "$with_local" == "yes" || "$with_local" == "YES" ]]; then
cmd+=(--with-local-disks 1)
fi
if [[ "$live_mode" == "yes" || "$live_mode" == "YES" ]]; then
cmd+=(--online 1)
fi
[[ -n "$target_storage" ]] && cmd+=(--targetstorage "$target_storage")
if ! confirm_high_risk_action "将 VM $vmid 迁移到节点 $target_node" "迁移会改写 VM 所在节点与磁盘位置;带本地盘迁移时对网络、存储映射和目标节点能力要求更高。" "目标节点、目标存储或在线迁移条件判断错误时,可能造成任务失败、停机或业务抖动。" "请确认目标节点在线、存储映射正确,并已评估直通设备与维护窗口。" "MIGRATE"; then
return 0
fi
local output
if ! output="$("${cmd[@]}" 2>&1)"; then
echo "$output" | sed 's/^/ /'
display_error "迁移失败" "请检查节点连通性、存储映射和日志输出。"
return 1
fi
echo "$output" | sed 's/^/ /'
display_success "迁移任务已提交" "目标节点: $target_node"
}

View File

@@ -0,0 +1,138 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
vm_configure_startup_policy() {
vm_require_commands qm || return 1
local vmid onboot boot_order startup_cfg
vmid="$(img_select_vmid)"
local rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$vmid" ]] || return 1
read -p "是否开机自启?(yes/no/skip) [skip]: " onboot
onboot="${onboot:-skip}"
read -p "启动顺序(示例 scsi0;ide2;net0留空跳过: " boot_order
read -p "启动策略(示例 order=1,up=30,down=30留空跳过: " startup_cfg
if [[ "$onboot" == "yes" || "$onboot" == "YES" ]]; then
qm set "$vmid" --onboot 1 >/dev/null 2>&1 || log_warn "设置 onboot 失败"
elif [[ "$onboot" == "no" || "$onboot" == "NO" ]]; then
qm set "$vmid" --onboot 0 >/dev/null 2>&1 || log_warn "设置 onboot 失败"
fi
[[ -n "$boot_order" ]] && qm set "$vmid" --boot "order=$boot_order" >/dev/null 2>&1 || true
[[ -n "$startup_cfg" ]] && qm set "$vmid" --startup "$startup_cfg" >/dev/null 2>&1 || true
display_success "启动策略已更新" "VMID: $vmid"
}
vm_add_network() {
vm_require_commands qm || return 1
local vmid bridge vlan model idx net_value
vmid="$(img_select_vmid)"
local rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$vmid" ]] || return 1
idx="$(vm_find_free_net_index "$vmid")"
[[ -n "$idx" ]] || {
display_error "未找到可用网卡插槽"
return 1
}
read -p "网卡模型 (virtio/e1000/vmxnet3) [virtio]: " model
model="${model:-virtio}"
read -p "桥接名称 [vmbr0]: " bridge
bridge="${bridge:-vmbr0}"
read -p "VLAN Tag留空不设置: " vlan
net_value="$model,bridge=$bridge"
[[ -n "$vlan" ]] && net_value="$net_value,tag=$vlan"
if ! qm set "$vmid" "-net$idx" "$net_value" >/dev/null 2>&1; then
display_error "添加网卡失败" "请检查桥接、VLAN 和日志输出。"
return 1
fi
display_success "网卡添加完成" "net$idx = $net_value"
}
vm_remove_network() {
vm_require_commands qm || return 1
local vmid slot
vmid="$(img_select_vmid)"
local rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$vmid" ]] || return 1
slot="$(vm_select_net_slot "$vmid")"
rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$slot" ]] || return 1
if ! confirm_action "删除 VM $vmid 的网卡 $slot"; then
return 0
fi
if ! qm set "$vmid" --delete "$slot" >/dev/null 2>&1; then
display_error "删除网卡失败" "请检查 VM 状态和日志输出。"
return 1
fi
display_success "网卡已删除" "$slot"
}
vm_modify_network() {
vm_require_commands qm || return 1
local vmid slot current bridge current_bridge current_tag vlan_input updated
vmid="$(img_select_vmid)"
local rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$vmid" ]] || return 1
slot="$(vm_select_net_slot "$vmid")"
rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$slot" ]] || return 1
current="$(vm_get_qm_value "$vmid" "$slot")"
current_bridge="$(echo "$current" | sed -n 's/.*bridge=\([^,]*\).*/\1/p')"
current_tag="$(echo "$current" | sed -n 's/.*tag=\([^,]*\).*/\1/p')"
read -p "桥接名称 [${current_bridge:-vmbr0}]: " bridge
bridge="${bridge:-${current_bridge:-vmbr0}}"
read -p "VLAN Tag留空保持当前输入 none 清除) [${current_tag:-none}]: " vlan_input
updated="$(vm_network_set_option "$current" bridge "$bridge")"
if [[ "$vlan_input" == "none" || "$vlan_input" == "NONE" ]]; then
updated="$(vm_network_remove_option "$updated" tag)"
elif [[ -n "$vlan_input" ]]; then
updated="$(vm_network_set_option "$updated" tag "$vlan_input")"
fi
if ! qm set "$vmid" "-$slot" "$updated" >/dev/null 2>&1; then
display_error "更新网卡失败" "请检查 bridge/VLAN 参数和日志输出。"
return 1
fi
display_success "网卡参数已更新" "$slot = $updated"
}
vm_startup_network_menu() {
while true; do
clear
show_menu_header "启动顺序与网络管理"
vm_show_data_risk_banner
show_menu_option "1" "设置开机自启 / 启动顺序 / 启动延迟"
show_menu_option "2" "添加网卡"
show_menu_option "3" "移除网卡"
show_menu_option "4" "修改 bridge / VLAN"
show_menu_option "0" "返回"
show_menu_footer
local choice
read -p "请选择操作 [0-4]: " choice
case "$choice" in
1) vm_configure_startup_policy ;;
2) vm_add_network ;;
3) vm_remove_network ;;
4) vm_modify_network ;;
0) return ;;
*) log_error "无效选择" ;;
esac
pause_function
done
}

View File

@@ -0,0 +1,117 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
vm_select_backup_archive() {
local archives
archives="$(vm_discover_backup_archives)"
if [[ -z "$archives" ]]; then
local manual
read -p "未自动发现备份文件,请手动输入备份文件完整路径: " manual
[[ -n "$manual" && -f "$manual" ]] || return 1
echo "$manual"
return 0
fi
{
echo -e "${CYAN}已发现备份文件:${NC}"
echo "$archives" | awk -F'|' '{printf " [%d] %-10s %-16s %s\n", NR, $2, $3, $1}'
echo -e "${UI_DIVIDER}"
} >&2
local pick line path
read -p "请选择备份序号 (0 手动输入): " pick
pick="${pick:-0}"
if [[ "$pick" == "0" ]]; then
local manual
read -p "请输入备份文件完整路径: " manual
[[ -n "$manual" && -f "$manual" ]] || return 1
echo "$manual"
return 0
fi
[[ "$pick" =~ ^[0-9]+$ ]] || return 1
line="$(echo "$archives" | awk -F'|' -v n="$pick" 'NR==n{print $0}')"
path="$(echo "$line" | awk -F'|' '{print $1}')"
[[ -n "$path" && -f "$path" ]] || return 1
echo "$path"
}
vm_restore_from_backup() {
vm_require_commands qmrestore qm pvesm || return 1
local archive
archive="$(vm_select_backup_archive)"
local rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$archive" ]] || return 1
local new_vmid
read -p "请输入新的 VMID: " new_vmid
vm_validate_new_vmid "$new_vmid" || return 1
local store
store="$(vm_select_storage_by_content images "请选择恢复后的磁盘存储")"
rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$store" ]] || return 1
local unique start_after
read -p "是否重新生成唯一标识(推荐 yes?(yes/no) [yes]: " unique
unique="${unique:-yes}"
read -p "恢复后是否自动启动 VM(yes/no) [no]: " start_after
start_after="${start_after:-no}"
clear
show_menu_header "从备份恢复 VM"
echo -e "${YELLOW}备份文件:${NC} $archive"
echo -e "${YELLOW}新 VMID:${NC} $new_vmid"
echo -e "${YELLOW}目标存储:${NC} $store"
echo -e "${YELLOW}唯一标识重建:${NC} $unique"
echo -e "${UI_DIVIDER}"
if ! confirm_high_risk_action "从备份恢复为新 VM $new_vmid" "恢复会创建新的 VM 和磁盘卷;如果关闭唯一标识重建,还可能引入 MAC/系统标识冲突。" "可能大量占用目标存储,并在误选备份文件时恢复出错误业务数据。" "请确认备份文件来源、目标 VMID 与目标存储均已核对,并预留足够空间。" "RESTORE"; then
return 0
fi
local -a cmd=(qmrestore "$archive" "$new_vmid" --storage "$store")
if [[ "$unique" == "yes" || "$unique" == "YES" ]]; then
cmd+=(--unique 1)
fi
local output
if ! output="$("${cmd[@]}" 2>&1)"; then
echo "$output" | sed 's/^/ /'
display_error "qmrestore 执行失败" "请检查备份文件、目标存储和日志输出。"
return 1
fi
echo "$output" | sed 's/^/ /'
if [[ "$start_after" == "yes" || "$start_after" == "YES" ]]; then
qm start "$new_vmid" >/dev/null 2>&1 || log_warn "自动启动 VM 失败,请手动检查。"
fi
display_success "恢复完成" "新 VMID: $new_vmid"
}
vm_backup_restore_menu() {
while true; do
clear
show_menu_header "VM 备份与恢复"
vm_show_data_risk_banner
show_menu_option "1" "创建 VM 备份vzdump"
show_menu_option "2" "从备份恢复为新 VM"
show_menu_option "3" "定时备份任务管理"
show_menu_option "4" "备份文件跨机恢复引导"
show_menu_option "0" "返回"
show_menu_footer
local choice
read -p "请选择操作 [0-4]: " choice
case "$choice" in
1) vm_backup_create ;;
2) vm_restore_from_backup ;;
3) vm_schedule_backup_menu ;;
4) vm_backup_transfer_guide ;;
0) return ;;
*) log_error "无效选择" ;;
esac
pause_function
done
}

View File

@@ -0,0 +1,98 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
manage_vm_schedule() {
while true; do
clear
show_menu_header "虚拟机/容器定时开关机"
echo -e "${YELLOW}当前配置的任务:${NC}"
if [ -f "/etc/cron.d/pve-tools-schedule" ]; then
grep -E "^[^#]" /etc/cron.d/pve-tools-schedule | sed 's/root \/usr\/sbin\///g'
else
echo " 暂无定时任务"
fi
echo -e "${UI_DIVIDER}"
echo -e "${BLUE}可用虚拟机 (QM):${NC}"
qm list 2>/dev/null | awk 'NR>1 {printf " ID: %-8s Name: %-20s Status: %s\n", $1, $2, $3}' || echo " 未发现虚拟机"
echo -e "${BLUE}可用容器 (PCT):${NC}"
pct list 2>/dev/null | awk 'NR>1 {printf " ID: %-8s Name: %-20s Status: %s\n", $1, $4, $2}' || echo " 未发现容器"
echo -e "${UI_DIVIDER}"
read -p "请输入要操作的 ID (返回请输入 0): " target_id
target_id=${target_id:-0}
if [[ "$target_id" == "0" ]]; then
return
fi
local cmd=""
if qm status "$target_id" >/dev/null 2>&1; then
cmd="qm"
elif pct status "$target_id" >/dev/null 2>&1; then
cmd="pct"
else
log_error "无效的 ID: $target_id"
pause_function
continue
fi
echo -e "${CYAN}正在配置 $cmd $target_id${NC}"
show_menu_option "1" "设置/修改定时任务"
show_menu_option "2" "删除定时任务"
show_menu_option "0" "取消"
read -p "请选择操作 [0-2]: " sub_choice
case $sub_choice in
1)
read -p "请输入开机时间 (格式 HH:MM, 如 07:00, 直接回车跳过): " start_time
read -p "请输入关机时间 (格式 HH:MM, 如 00:00, 直接回车跳过): " stop_time
local cron_content=""
if [[ -n "$start_time" ]]; then
if [[ "$start_time" =~ ^([0-1]?[0-9]|2[0-3]):([0-5][0-9])$ ]]; then
local hour=${BASH_REMATCH[1]}
local min=${BASH_REMATCH[2]}
min=$((10#$min))
hour=$((10#$hour))
cron_content+="$min $hour * * * root /usr/sbin/$cmd start $target_id >/dev/null 2>&1\n"
else
log_error "开机时间格式错误: $start_time"
fi
fi
if [[ -n "$stop_time" ]]; then
if [[ "$stop_time" =~ ^([0-1]?[0-9]|2[0-3]):([0-5][0-9])$ ]]; then
local hour=${BASH_REMATCH[1]}
local min=${BASH_REMATCH[2]}
min=$((10#$min))
hour=$((10#$hour))
cron_content+="$min $hour * * * root /usr/sbin/$cmd stop $target_id >/dev/null 2>&1"
else
log_error "关机时间格式错误: $stop_time"
fi
fi
if [[ -n "$cron_content" ]]; then
apply_block "/etc/cron.d/pve-tools-schedule" "SCHEDULE_$target_id" "$(echo -e "$cron_content")"
log_success "ID $target_id 的定时任务已更新"
systemctl restart cron 2>/dev/null || service cron restart 2>/dev/null
else
log_warn "未设置任何有效时间,操作取消"
fi
;;
2)
remove_block "/etc/cron.d/pve-tools-schedule" "SCHEDULE_$target_id"
log_success "ID $target_id 的定时任务已删除"
systemctl restart cron 2>/dev/null || service cron restart 2>/dev/null
;;
0)
continue
;;
*)
log_error "无效选择"
;;
esac
pause_function
done
}

View File

@@ -0,0 +1,140 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
vm_get_snapshot_names() {
local vmid="$1"
qm listsnapshot "$vmid" 2>/dev/null | awk 'NR>1 && $1 != "current" {print $1}'
}
vm_select_snapshot_name() {
local vmid="$1"
local snapshots
snapshots="$(vm_get_snapshot_names "$vmid")"
[[ -n "$snapshots" ]] || return 1
{
echo -e "${CYAN}当前快照列表:${NC}"
echo "$snapshots" | awk '{printf " [%d] %s\n", NR, $1}'
echo -e "${UI_DIVIDER}"
} >&2
local pick name
read -p "请选择快照序号 (0 返回): " pick
pick="${pick:-0}"
[[ "$pick" == "0" ]] && return 2
[[ "$pick" =~ ^[0-9]+$ ]] || return 1
name="$(echo "$snapshots" | awk -v n="$pick" 'NR==n{print $1}')"
[[ -n "$name" ]] || return 1
echo "$name"
}
vm_create_snapshot() {
vm_require_commands qm || return 1
local vmids_text snapshot_name description
vmids_text="$(vm_collect_target_vmids)"
local rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$vmids_text" ]] || return 1
mapfile -t vmids < <(printf '%s\n' "$vmids_text" | awk 'NF')
read -p "请输入快照名称: " snapshot_name
[[ "$snapshot_name" =~ ^[A-Za-z0-9._-]+$ ]] || {
display_error "快照名称格式无效" "仅支持字母、数字、点、下划线和中划线。"
return 1
}
read -p "请输入快照描述(留空跳过): " description
local success=0 failed=0 vmid
for vmid in "${vmids[@]}"; do
if [[ -n "$description" ]]; then
qm snapshot "$vmid" "$snapshot_name" --description "$description" >/dev/null 2>&1 && ((success++)) || ((failed++))
else
qm snapshot "$vmid" "$snapshot_name" >/dev/null 2>&1 && ((success++)) || ((failed++))
fi
done
display_success "快照创建任务完成" "成功: $success, 失败: $failed"
}
vm_list_snapshots() {
vm_require_commands qm || return 1
local vmid
vmid="$(img_select_vmid)"
local rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$vmid" ]] || return 1
clear
show_menu_header "快照列表"
qm listsnapshot "$vmid" 2>/dev/null | sed 's/^/ /'
echo -e "${UI_DIVIDER}"
}
vm_delete_snapshot() {
vm_require_commands qm || return 1
local vmid snapshot_name
vmid="$(img_select_vmid)"
local rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$vmid" ]] || return 1
snapshot_name="$(vm_select_snapshot_name "$vmid")"
rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$snapshot_name" ]] || return 1
if ! confirm_high_risk_action "删除 VM $vmid 的快照 $snapshot_name" "删除快照后将失去对应时间点的快速回退能力。" "若该快照是重要恢复点,误删后只能依赖外部备份或更高成本的恢复手段。" "请确认该快照不再承担回滚基线,并已保留外部备份。" "DROP-SNAP"; then
return 0
fi
if ! qm delsnapshot "$vmid" "$snapshot_name" >/dev/null 2>&1; then
display_error "删除快照失败" "请检查快照名称和日志输出。"
return 1
fi
display_success "快照已删除" "$snapshot_name"
}
vm_rollback_snapshot() {
vm_require_commands qm || return 1
local vmid snapshot_name
vmid="$(img_select_vmid)"
local rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$vmid" ]] || return 1
snapshot_name="$(vm_select_snapshot_name "$vmid")"
rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$snapshot_name" ]] || return 1
if ! confirm_high_risk_action "将 VM $vmid 回滚到快照 $snapshot_name" "回滚会把磁盘与配置状态拉回到旧时间点,之后的数据写入可能丢失。" "如果当前业务数据尚未导出或备份,回滚可能造成不可逆的新数据丢失。" "请确认当前数据已备份,且业务方已批准回退到该时间点。" "ROLLBACK"; then
return 0
fi
if ! qm rollback "$vmid" "$snapshot_name" >/dev/null 2>&1; then
display_error "快照回滚失败" "请检查 VM 状态和日志输出。"
return 1
fi
display_success "快照回滚完成" "$snapshot_name"
}
vm_snapshot_menu() {
while true; do
clear
show_menu_header "快照管理"
vm_show_data_risk_banner
show_menu_option "1" "创建快照(支持批量)"
show_menu_option "2" "列出 VM 快照"
show_menu_option "3" "删除快照"
show_menu_option "4" "回滚到快照"
show_menu_option "0" "返回"
show_menu_footer
local choice
read -p "请选择操作 [0-4]: " choice
case "$choice" in
1) vm_create_snapshot ;;
2) vm_list_snapshots ;;
3) vm_delete_snapshot ;;
4) vm_rollback_snapshot ;;
0) return ;;
*) log_error "无效选择" ;;
esac
pause_function
done
}

View File

@@ -0,0 +1,574 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
vm_require_commands() {
local missing=()
local cmd
for cmd in "$@"; do
if ! command -v "$cmd" >/dev/null 2>&1; then
missing+=("$cmd")
fi
done
if (( ${#missing[@]} > 0 )); then
display_error "缺少命令: ${missing[*]}" "请确认当前运行环境为 PVE 宿主机,并安装缺失组件后重试。"
return 1
fi
}
vm_validate_new_vmid() {
local vmid="$1"
if [[ -z "$vmid" || ! "$vmid" =~ ^[0-9]+$ ]]; then
log_error "新 VMID 必须是数字"
return 1
fi
if qm status "$vmid" >/dev/null 2>&1; then
log_error "VMID 已被虚拟机占用: $vmid"
return 1
fi
if command -v pct >/dev/null 2>&1 && pct status "$vmid" >/dev/null 2>&1; then
log_error "VMID 已被容器占用: $vmid"
return 1
fi
return 0
}
vm_list_vm_records() {
qm list 2>/dev/null | awk 'NR>1{print $1 "|" $2 "|" $3}'
}
vm_show_vm_records() {
local records="$1"
{
echo -e "${CYAN}可用虚拟机列表:${NC}"
echo "$records" | awk -F'|' '{printf " VMID: %-6s Name: %-22s Status: %s\n", $1, $2, $3}'
echo -e "${UI_DIVIDER}"
} >&2
}
vm_normalize_vmid_input() {
printf '%s\n' "$1" | tr ', ' '\n\n' | awk 'NF' | sort -n -u
}
vm_collect_target_vmids() {
local records
records="$(vm_list_vm_records)"
if [[ -z "$records" ]]; then
log_error "未发现虚拟机"
return 1
fi
vm_show_vm_records "$records"
{
show_menu_option "1" "单个 VM"
show_menu_option "2" "多个 VM"
show_menu_option "3" "全部 VM"
} >&2
local scope
read -p "请选择目标范围 [1-3]: " scope
case "$scope" in
1)
local vmid
vmid="$(img_select_vmid)"
local rc=$?
[[ "$rc" -eq 2 ]] && return 2
[[ -n "$vmid" ]] || return 1
echo "$vmid"
;;
2)
local raw ids vmid
read -p "请输入 VMID 列表(逗号或空格分隔): " raw
ids="$(vm_normalize_vmid_input "$raw")"
if [[ -z "$ids" ]]; then
log_error "未提供有效 VMID"
return 1
fi
while IFS= read -r vmid; do
validate_qm_vmid "$vmid" || return 1
done <<< "$ids"
echo "$ids"
;;
3)
echo "$records" | awk -F'|' '{print $1}'
;;
*)
log_error "无效选择"
return 1
;;
esac
}
vm_validate_backup_compress() {
local compress="$1"
case "$compress" in
zstd|gzip|lzo) return 0 ;;
*)
display_error "不支持的压缩方式: $compress" "仅支持 zstd / gzip / lzo"
return 1
;;
esac
}
vm_validate_backup_mode() {
local mode="$1"
case "$mode" in
snapshot|suspend|stop) return 0 ;;
*)
display_error "不支持的备份模式: $mode" "仅支持 snapshot / suspend / stop"
return 1
;;
esac
}
vm_validate_backup_keep_last() {
local keep_last="$1"
if [[ ! "$keep_last" =~ ^[0-9]+$ ]]; then
display_error "保留份数必须是数字"
return 1
fi
}
vm_validate_backup_storage_name() {
local store="$1"
if [[ -z "$store" || ! "$store" =~ ^[A-Za-z0-9_.-]+$ ]]; then
display_error "备份存储名称不合法: $store" "请重新选择存储,避免将异常字符写入 root cron。"
return 1
fi
}
pve_tools_human_bytes() {
local bytes="$1"
if command -v numfmt >/dev/null 2>&1 && [[ "$bytes" =~ ^[0-9]+$ ]]; then
numfmt --to=iec --suffix=B "$bytes" 2>/dev/null && return 0
fi
echo "$bytes"
}
pve_storage_status_records() {
pvesm status 2>/dev/null | awk 'NR>1 {print $1 "|" $2 "|" $3 "|" $4 "|" $5 "|" $6 "|" $7}'
}
pve_storage_config_value() {
local store="$1"
local key="$2"
pvesm config "$store" 2>/dev/null | awk -v key="$key" '
{
line = $0
sub(/^[[:space:]]+/, "", line)
if (line ~ "^" key "([[:space:]]|:)") {
sub("^" key "[[:space:]]*:?[[:space:]]*", "", line)
print line
exit
}
}
'
}
pve_storage_file_backend() {
local type="$1"
case "$type" in
dir|nfs|cifs|cephfs|glusterfs) return 0 ;;
*) return 1 ;;
esac
}
pve_storage_mount_path() {
local store="$1"
local type="$2"
local path
path="$(pve_storage_config_value "$store" path)"
if [[ -n "$path" ]]; then
echo "$path"
return 0
fi
case "$type" in
nfs|cifs|cephfs|glusterfs)
echo "/mnt/pve/$store"
return 0
;;
esac
return 1
}
pve_storage_content_subdir() {
local content="$1"
case "$content" in
iso) echo "template/iso" ;;
backup) echo "dump" ;;
vztmpl) echo "template/cache" ;;
snippets) echo "snippets" ;;
images) echo "images" ;;
rootdir) echo "private" ;;
import) echo "import" ;;
*) return 1 ;;
esac
}
pve_storage_content_dir_override() {
local store="$1"
local content="$2"
local content_dirs entry key value
content_dirs="$(pve_storage_config_value "$store" content-dirs | tr -d ' ')"
[[ -n "$content_dirs" ]] || return 1
IFS=',' read -r -a entries <<< "$content_dirs"
for entry in "${entries[@]}"; do
key="${entry%%=*}"
value="${entry#*=}"
if [[ "$key" == "$content" && -n "$value" && "$value" != "$entry" ]]; then
echo "$value"
return 0
fi
done
return 1
}
pve_storage_content_path() {
local store="$1"
local type="$2"
local content="$3"
local root subdir
pve_storage_file_backend "$type" || return 1
root="$(pve_storage_mount_path "$store" "$type")" || return 1
subdir="$(pve_storage_content_dir_override "$store" "$content" || pve_storage_content_subdir "$content")" || return 1
printf '%s/%s\n' "${root%/}" "$subdir"
}
pve_storage_list_content_paths() {
local content="$1"
local store type status total used avail percent path
while IFS='|' read -r store type status total used avail percent; do
[[ -n "$store" ]] || continue
if vm_storage_supports_content "$store" "$content" && path="$(pve_storage_content_path "$store" "$type" "$content")"; then
printf '%s|%s|%s|%s\n' "$store" "$type" "$status" "$path"
fi
done < <(pve_storage_status_records)
}
pve_storage_usage_text() {
local path="$1"
local target="$path"
while [[ ! -e "$target" && "$target" != "/" ]]; do
target="$(dirname "$target")"
done
if [[ -e "$target" ]]; then
df -hP "$target" 2>/dev/null | awk 'NR==2 {printf "%s/%s 可用:%s 使用率:%s", $3, $2, $4, $5}'
else
echo "路径不存在或未挂载"
fi
}
pve_storage_find_owner_by_path() {
local file_path="$1"
local content="${2:-backup}"
local store type status path best_store="" best_len=0 len
while IFS='|' read -r store type status path; do
[[ -n "$path" ]] || continue
if [[ "$file_path" == "$path/"* || "$file_path" == "$path" ]]; then
len=${#path}
if (( len > best_len )); then
best_store="$store"
best_len=$len
fi
fi
done < <(pve_storage_list_content_paths "$content")
[[ -n "$best_store" ]] && echo "$best_store" || echo "unknown"
}
vm_storage_supports_content() {
local store="$1"
local content="$2"
local configured
configured="$(pve_storage_config_value "$store" content | tr -d '[:space:]')"
[[ -n "$configured" ]] || return 1
echo ",$configured," | grep -Fq ",$content,"
}
vm_list_storages_by_content() {
local content="$1"
while IFS='|' read -r store type active; do
[[ -n "$store" ]] || continue
if vm_storage_supports_content "$store" "$content"; then
printf '%s|%s|%s\n' "$store" "$type" "${active:-?}"
fi
done < <(pvesm status 2>/dev/null | awk 'NR>1{print $1 "|" $2 "|" $3}')
}
vm_select_storage_by_content() {
local content="$1"
local prompt="${2:-请选择存储}"
local stores
stores="$(vm_list_storages_by_content "$content")"
if [[ -z "$stores" ]]; then
local manual
read -p "未发现支持 ${content} 内容类型的存储,请手动输入存储名: " manual
[[ -n "$manual" ]] || return 1
echo "$manual"
return 0
fi
{
echo -e "${CYAN}${prompt}${NC}"
echo "$stores" | awk -F'|' '{printf " [%d] %-18s 类型:%-12s 状态:%s\n", NR, $1, $2, $3}'
echo -e "${UI_DIVIDER}"
} >&2
local pick
read -p "请选择存储序号 (0 返回): " pick
pick="${pick:-0}"
[[ "$pick" == "0" ]] && return 2
[[ "$pick" =~ ^[0-9]+$ ]] || return 1
local line store
line="$(echo "$stores" | awk -F'|' -v n="$pick" 'NR==n{print $0}')"
store="$(echo "$line" | awk -F'|' '{print $1}')"
[[ -n "$store" ]] || return 1
echo "$store"
}
vm_list_cluster_nodes() {
if [[ -d /etc/pve/nodes ]]; then
find /etc/pve/nodes -mindepth 1 -maxdepth 1 -type d -printf '%f\n' 2>/dev/null | sort
fi
}
vm_select_target_node() {
local current_node nodes filtered
current_node="$(hostname)"
nodes="$(vm_list_cluster_nodes)"
filtered="$(echo "$nodes" | grep -vx "$current_node" || true)"
[[ -n "$filtered" ]] || return 1
{
echo -e "${CYAN}可迁移目标节点:${NC}"
echo "$filtered" | awk '{printf " [%d] %s\n", NR, $1}'
echo -e "${UI_DIVIDER}"
} >&2
local pick line
read -p "请选择目标节点序号 (0 返回): " pick
pick="${pick:-0}"
[[ "$pick" == "0" ]] && return 2
[[ "$pick" =~ ^[0-9]+$ ]] || return 1
line="$(echo "$filtered" | awk -v n="$pick" 'NR==n{print $1}')"
[[ -n "$line" ]] || return 1
echo "$line"
}
vm_find_free_disk_slot() {
local vmid="$1"
local bus="$2"
local max_idx=0
case "$bus" in
scsi) max_idx=30 ;;
sata) max_idx=5 ;;
ide) max_idx=3 ;;
virtio) max_idx=15 ;;
*) return 1 ;;
esac
local cfg
cfg="$(qm config "$vmid" 2>/dev/null)"
[[ -n "$cfg" ]] || return 1
local i
for ((i=0; i<=max_idx; i++)); do
if ! echo "$cfg" | grep -qE "^${bus}${i}:"; then
echo "${bus}${i}"
return 0
fi
done
return 1
}
vm_find_free_net_index() {
local vmid="$1"
local cfg used i
cfg="$(qm config "$vmid" 2>/dev/null)"
used="$(echo "$cfg" | awk -F'[: ]' '/^net[0-9]+:/{gsub("net","",$1); print $1}' | sort -n | uniq)"
for ((i=0; i<=31; i++)); do
if ! echo "$used" | grep -qx "$i"; then
echo "$i"
return 0
fi
done
return 1
}
vm_select_disk_slot() {
local vmid="$1"
local slots
slots="$(qm config "$vmid" 2>/dev/null | grep -E '^(scsi|sata|virtio|ide)[0-9]+:' | grep -v 'cloudinit')"
[[ -n "$slots" ]] || return 1
{
echo -e "${CYAN}当前磁盘插槽:${NC}"
echo "$slots" | awk '{printf " [%d] %s\n", NR, $0}'
echo -e "${UI_DIVIDER}"
} >&2
local pick line slot
read -p "请选择磁盘序号 (0 返回): " pick
pick="${pick:-0}"
[[ "$pick" == "0" ]] && return 2
[[ "$pick" =~ ^[0-9]+$ ]] || return 1
line="$(echo "$slots" | awk -v n="$pick" 'NR==n{print $0}')"
slot="${line%%:*}"
[[ -n "$slot" ]] || return 1
echo "$slot"
}
vm_select_net_slot() {
local vmid="$1"
local nets
nets="$(qm config "$vmid" 2>/dev/null | grep -E '^net[0-9]+:')"
[[ -n "$nets" ]] || return 1
{
echo -e "${CYAN}当前网卡列表:${NC}"
echo "$nets" | awk '{printf " [%d] %s\n", NR, $0}'
echo -e "${UI_DIVIDER}"
} >&2
local pick line slot
read -p "请选择网卡序号 (0 返回): " pick
pick="${pick:-0}"
[[ "$pick" == "0" ]] && return 2
[[ "$pick" =~ ^[0-9]+$ ]] || return 1
line="$(echo "$nets" | awk -v n="$pick" 'NR==n{print $0}')"
slot="${line%%:*}"
[[ -n "$slot" ]] || return 1
echo "$slot"
}
vm_get_qm_value() {
local vmid="$1"
local key="$2"
qm config "$vmid" 2>/dev/null | awk -v key="$key" '$0 ~ "^" key ": " { sub("^[^:]+: ", "", $0); print; exit }'
}
vm_is_template() {
local vmid="$1"
[[ "$(vm_get_qm_value "$vmid" "template")" == "1" ]]
}
vm_network_strip_mac() {
echo "$1" | sed -E 's/^([A-Za-z0-9_-]+)=[0-9A-Fa-f:]{17}(,|$)/\1\2/' | sed -E 's/,,+/,/g; s/,$//'
}
vm_network_set_option() {
local current="$1"
local key="$2"
local value="$3"
if echo "$current" | grep -qE "(^|,)$key="; then
echo "$current" | sed -E "s/(^|,)$key=[^,]*/\1$key=$value/" | sed -E 's/^,//; s/,,+/,/g; s/,$//'
else
echo "$current,$key=$value" | sed -E 's/^,//; s/,,+/,/g; s/,$//'
fi
}
vm_network_remove_option() {
local current="$1"
local key="$2"
echo "$current" | sed -E "s/(^|,)$key=[^,]*//g" | sed -E 's/^,//; s/,,+/,/g; s/,$//'
}
vm_detect_image_format() {
local image_path="$1"
qemu-img info "$image_path" 2>/dev/null | awk -F': ' '/^file format:/{print $2; exit}'
}
vm_discover_disk_image_files() {
local roots=("/root" "/var/lib/vz/template/iso" "/home")
local root
for root in "${roots[@]}"; do
if [[ -d "$root" ]]; then
find "$root" -xdev -type f \( -iname '*.img' -o -iname '*.raw' -o -iname '*.qcow2' \) -printf '%p|%s|%TY-%Tm-%Td %TH:%TM\n' 2>/dev/null || true
fi
done | sort -u
}
vm_discover_backup_archives() {
local roots=("/var/lib/vz/dump" "/mnt/pve" "/backup" "/backups" "/root")
local root
for root in "${roots[@]}"; do
if [[ -d "$root" ]]; then
find "$root" -maxdepth 3 -type f \( -name 'vzdump-qemu-*.vma' -o -name 'vzdump-qemu-*.vma.gz' -o -name 'vzdump-qemu-*.vma.lzo' -o -name 'vzdump-qemu-*.vma.zst' \) -printf '%p|%s|%TY-%Tm-%Td %TH:%TM\n' 2>/dev/null || true
fi
done | sort -u
}
vm_discover_all_backup_archives() {
local roots=("/var/lib/vz/dump" "/mnt/pve" "/backup" "/backups" "/root")
local root
for root in "${roots[@]}"; do
if [[ -d "$root" ]]; then
find "$root" -maxdepth 4 -type f \( \
-name 'vzdump-qemu-*.vma' -o -name 'vzdump-qemu-*.vma.gz' -o -name 'vzdump-qemu-*.vma.lzo' -o -name 'vzdump-qemu-*.vma.zst' -o \
-name 'vzdump-lxc-*.tar' -o -name 'vzdump-lxc-*.tar.gz' -o -name 'vzdump-lxc-*.tar.lzo' -o -name 'vzdump-lxc-*.tar.zst' \
\) -printf '%p|%s|%TY-%Tm-%Td %TH:%TM\n' 2>/dev/null || true
fi
done | sort -u
}
vm_backup_archive_guest_type() {
local file_name="$1"
case "$(basename "$file_name")" in
vzdump-qemu-*) echo "VM" ;;
vzdump-lxc-*) echo "CT" ;;
*) echo "未知" ;;
esac
}
vm_backup_archive_vmid() {
local file_name
file_name="$(basename "$1")"
echo "$file_name" | sed -nE 's/^vzdump-(qemu|lxc)-([0-9]+)-.*/\2/p'
}
vm_backup_transfer_guide() {
if ! command -v pvesm >/dev/null 2>&1; then
display_error "未找到 pvesm" "请在 Proxmox VE 节点上运行。"
return 1
fi
local archives
archives="$(vm_discover_all_backup_archives)"
clear
show_menu_header "备份文件跨机恢复引导"
echo -e "${YELLOW}说明:${NC} PVE 备份文件通常放在支持 backup 内容类型的存储 dump 目录中;跨机迁移时把备份文件复制到目标节点对应 dump 目录后,再使用 Web UI 或 qmrestore/pct restore 恢复。"
echo "$UI_DIVIDER"
if [[ -z "$archives" ]]; then
log_warn "未发现常见 vzdump 备份文件。"
else
printf "%-5s %-6s %-8s %-10s %-16s %s\n" "序号" "类型" "VMID" "大小" "时间" "文件"
echo "$UI_DIVIDER"
local idx=1 path size mtime type vmid store
while IFS='|' read -r path size mtime; do
type="$(vm_backup_archive_guest_type "$path")"
vmid="$(vm_backup_archive_vmid "$path")"
store="$(pve_storage_find_owner_by_path "$path" backup)"
printf "%-5s %-6s %-8s %-10s %-16s %s\n" "$idx" "$type" "${vmid:-?}" "$(pve_tools_human_bytes "$size")" "$mtime" "$path"
echo " 存储: $store"
idx=$((idx + 1))
done <<< "$archives"
fi
echo "$UI_DIVIDER"
echo -e "${CYAN}当前节点可用于上传/恢复的备份目录:${NC}"
local path found=false
while IFS='|' read -r store type status path; do
found=true
echo " - $store ($type): $path"
echo " 从本地上传到目标 PVE: scp ./vzdump-qemu-100.vma.zst root@<目标PVE-IP>:\"$path/\""
done < <(pve_storage_list_content_paths backup)
[[ "$found" == true ]] || echo " 未发现支持 backup 内容类型的文件级存储。"
echo "$UI_DIVIDER"
echo -e "${CYAN}从当前机器下载到本地电脑示例:${NC}"
echo " scp root@<当前PVE-IP>:\"/var/lib/vz/dump/vzdump-qemu-100.vma.zst\" ./"
echo -e "${CYAN}目标机器恢复示例:${NC}"
echo " VM: qmrestore /var/lib/vz/dump/vzdump-qemu-100.vma.zst <新VMID> --storage <磁盘存储>"
echo " CT: pct restore <新CTID> /var/lib/vz/dump/vzdump-lxc-100.tar.zst --storage <rootfs存储>"
echo -e "${YELLOW}提示:${NC} NFS/CIFS 等共享备份存储在多节点间可能路径一致,但仍要确认目标节点能访问同一存储。"
}
pve_guest_exists() {
local guest_type="$1"
local vmid="$2"
[[ "$vmid" =~ ^[0-9]+$ ]] || return 1
case "$guest_type" in
VM|vm|qemu)
[[ -f "/etc/pve/qemu-server/${vmid}.conf" ]] && return 0
command -v qm >/dev/null 2>&1 && qm status "$vmid" >/dev/null 2>&1
;;
CT|ct|lxc)
[[ -f "/etc/pve/lxc/${vmid}.conf" ]] && return 0
command -v pct >/dev/null 2>&1 && pct status "$vmid" >/dev/null 2>&1
;;
*)
pve_guest_exists VM "$vmid" || pve_guest_exists CT "$vmid"
;;
esac
}

View File

@@ -0,0 +1,43 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
host_network_configure_interface_addressing() {
local iface_name ipv4_cfg ipv6_cfg tmp preserved block method
iface_name="$(host_network_select_interface_name)"
local rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$iface_name" ]] || return 1
echo -e "${CYAN}为接口 $iface_name 更新地址模式:${NC}"
ipv4_cfg="$(host_network_collect_family_config inet update)" || return 1
ipv6_cfg="$(host_network_collect_family_config inet6 update)" || return 1
tmp=$(mktemp)
cp "$HOST_NETWORK_INTERFACES_FILE" "$tmp"
IFS='|' read -r method _ <<< "$ipv4_cfg"
if [[ "$method" != "keep" ]]; then
preserved="$(host_network_collect_preserved_family_options "$HOST_NETWORK_INTERFACES_FILE" "$iface_name" inet)"
host_network_remove_iface_family_from_candidate "$tmp" "$iface_name" inet
if [[ "$method" != "remove" ]]; then
host_network_ensure_auto_line_in_candidate "$tmp" "$iface_name"
block="$(host_network_build_family_stanza "$iface_name" inet "$ipv4_cfg" "$preserved")"
host_network_append_text_to_candidate "$tmp" "$block"
fi
fi
IFS='|' read -r method _ <<< "$ipv6_cfg"
if [[ "$method" != "keep" ]]; then
preserved="$(host_network_collect_preserved_family_options "$HOST_NETWORK_INTERFACES_FILE" "$iface_name" inet6)"
host_network_remove_iface_family_from_candidate "$tmp" "$iface_name" inet6
if [[ "$method" != "remove" ]]; then
host_network_ensure_auto_line_in_candidate "$tmp" "$iface_name"
block="$(host_network_build_family_stanza "$iface_name" inet6 "$ipv6_cfg" "$preserved")"
host_network_append_text_to_candidate "$tmp" "$block"
fi
fi
host_network_commit_candidate "$tmp" "更新接口 $iface_name 的 IPv4/IPv6 地址模式" "会直接改写宿主机接口地址、网关和 RA/DHCP 行为。" "管理面 IP、默认路由和业务地址可能立即切换。" "请确认新的地址、网关、前缀和维护窗口都已校对。"
rm -f "$tmp"
}

View File

@@ -0,0 +1,129 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
host_network_build_bond_block() {
local iface_name="$1"
local slaves="$2"
local mode="$3"
local mtu="$4"
local ipv4_cfg="$5"
local ipv6_cfg="$6"
local mode_name=""
local v4_method v4_addr v4_gw v4_extra
local v6_method v6_addr v6_gw v6_extra
IFS='|' read -r v4_method v4_addr v4_gw v4_extra <<< "$ipv4_cfg"
IFS='|' read -r v6_method v6_addr v6_gw v6_extra <<< "$ipv6_cfg"
case "$mode" in
0) mode_name="balance-rr" ;;
1) mode_name="active-backup" ;;
4) mode_name="802.3ad" ;;
6) mode_name="balance-alb" ;;
*) return 1 ;;
esac
[[ "$v4_method" == "none" ]] && v4_method="manual"
printf 'auto %s\n' "$iface_name"
printf 'iface %s inet %s\n' "$iface_name" "$v4_method"
printf ' bond-slaves %s\n' "$slaves"
printf ' bond-mode %s\n' "$mode_name"
printf ' bond-miimon 100\n'
[[ "$mode_name" == "802.3ad" ]] && printf ' bond-xmit-hash-policy layer2+3\n bond-lacp-rate fast\n'
[[ -n "$mtu" ]] && printf ' mtu %s\n' "$mtu"
[[ "$v4_method" == "static" && -n "$v4_addr" ]] && printf ' address %s\n' "$v4_addr"
[[ "$v4_method" == "static" && -n "$v4_gw" ]] && printf ' gateway %s\n' "$v4_gw"
if [[ "$v6_method" != "none" ]]; then
printf '\niface %s inet6 %s\n' "$iface_name" "$v6_method"
[[ -n "$v6_addr" ]] && printf ' address %s\n' "$v6_addr"
[[ -n "$v6_gw" ]] && printf ' gateway %s\n' "$v6_gw"
[[ -n "$v6_extra" ]] && printf ' %s\n' "$v6_extra"
fi
}
host_network_create_bond() {
local default_name bond_name slaves mode mtu ipv4_cfg ipv6_cfg tmp block
default_name="$(host_network_guess_next_name bond)"
read -p "请输入 Bond 名称 [$default_name]: " bond_name
bond_name="${bond_name:-$default_name}"
host_network_validate_iface_name "$bond_name" || {
display_error "Bond 名称不合法: $bond_name" "接口名需为 1-15 位,且仅允许字母、数字、._:-。"
return 1
}
if host_network_get_all_interface_names | grep -qx "$bond_name"; then
display_error "接口已存在: $bond_name"
return 1
fi
echo -e "${CYAN}可加入 Bond 的接口(输入多个,以空格分隔):${NC}"
host_network_get_all_interface_names | sed 's/^/ - /'
read -p "bond-slaves: " slaves
host_network_validate_member_list "$slaves" "$bond_name" "bond-slaves" || return 1
echo " [0] mode 0 = balance-rr"
echo " [1] mode 1 = active-backup"
echo " [4] mode 4 = 802.3ad"
echo " [6] mode 6 = balance-alb"
read -p "请选择 Bond 模式 [0/1/4/6]: " mode
[[ "$mode" =~ ^(0|1|4|6)$ ]] || {
display_error "仅支持 Bond 模式 0/1/4/6"
return 1
}
read -p "MTU留空保持默认: " mtu
host_network_validate_mtu "$mtu" || return 1
echo "$UI_DIVIDER"
ipv4_cfg="$(host_network_collect_family_config inet create)" || return 1
ipv6_cfg="$(host_network_collect_family_config inet6 create)" || return 1
tmp=$(mktemp)
cp "$HOST_NETWORK_INTERFACES_FILE" "$tmp"
block="$(host_network_build_bond_block "$bond_name" "$slaves" "$mode" "$mtu" "$ipv4_cfg" "$ipv6_cfg")"
host_network_remove_iface_from_candidate "$tmp" "$bond_name"
host_network_append_text_to_candidate "$tmp" "# PVE-TOOLS HOST IFACE BEGIN $bond_name
$block
# PVE-TOOLS HOST IFACE END $bond_name"
host_network_commit_candidate "$tmp" "创建 Bond $bond_name" "Bond 会重组宿主机上联链路,错误的成员口或模式会导致管理面和业务流量异常。" "交换机 LACP/静态聚合不匹配时,链路可能抖动、黑洞或单向丢包。" "请确认交换机侧聚合模式、成员口、MTU 与回滚路径已经准备好。"
rm -f "$tmp"
}
host_network_delete_bond() {
local bond_name
bond_name="$(host_network_select_bond_name)"
local rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$bond_name" ]] || return 1
if grep -Eq "(bridge-ports|bond-slaves|vlan-raw-device)[[:space:]].*\b${bond_name}\b" "$HOST_NETWORK_INTERFACES_FILE" 2>/dev/null; then
display_error "检测到其他接口仍依赖 $bond_name" "请先解除 bridge、VLAN 或其他依赖后再删除。"
return 1
fi
local tmp
tmp=$(mktemp)
cp "$HOST_NETWORK_INTERFACES_FILE" "$tmp"
host_network_remove_iface_from_candidate "$tmp" "$bond_name"
host_network_commit_candidate "$tmp" "删除 Bond $bond_name" "删除 Bond 会让其上的 bridge、VLAN、地址和上联聚合失效。" "生产网络、存储网络、集群心跳都可能立即受影响。" "请确认已迁移上层依赖,并通过控制台执行。"
rm -f "$tmp"
}
host_network_bond_menu() {
while true; do
clear
show_menu_header "Bond 管理"
host_network_show_risk_banner
echo -e "${CYAN}当前 Bond${NC}"
if host_network_get_configured_bonds | awk 'NF{print " - "$0}'; then :; fi
echo "$UI_DIVIDER"
show_menu_option "1" "列出 Bond"
show_menu_option "2" "创建 Bond"
show_menu_option "3" "删除 Bond"
show_menu_option "0" "返回"
show_menu_footer
read -p "请选择操作 [0-3]: " choice
case "$choice" in
1) host_network_show_current_overview ;;
2) host_network_create_bond ;;
3) host_network_delete_bond ;;
0) return ;;
*) log_error "无效选择" ;;
esac
pause_function
done
}

View File

@@ -0,0 +1,128 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
host_network_build_bridge_block() {
local iface_name="$1"
local ports="$2"
local vlan_aware="$3"
local mtu="$4"
local ipv4_cfg="$5"
local ipv6_cfg="$6"
local v4_method v4_addr v4_gw v4_extra
local v6_method v6_addr v6_gw v6_extra
IFS='|' read -r v4_method v4_addr v4_gw v4_extra <<< "$ipv4_cfg"
IFS='|' read -r v6_method v6_addr v6_gw v6_extra <<< "$ipv6_cfg"
[[ "$v4_method" == "none" ]] && v4_method="manual"
printf 'auto %s\n' "$iface_name"
printf 'iface %s inet %s\n' "$iface_name" "$v4_method"
printf ' bridge-ports %s\n' "${ports:-none}"
printf ' bridge-stp off\n'
printf ' bridge-fd 0\n'
[[ "$vlan_aware" == "yes" || "$vlan_aware" == "YES" ]] && printf ' bridge-vlan-aware yes\n'
[[ -n "$mtu" ]] && printf ' mtu %s\n' "$mtu"
[[ "$v4_method" == "static" && -n "$v4_addr" ]] && printf ' address %s\n' "$v4_addr"
[[ "$v4_method" == "static" && -n "$v4_gw" ]] && printf ' gateway %s\n' "$v4_gw"
if [[ "$v6_method" != "none" ]]; then
printf '\niface %s inet6 %s\n' "$iface_name" "$v6_method"
[[ -n "$v6_addr" ]] && printf ' address %s\n' "$v6_addr"
[[ -n "$v6_gw" ]] && printf ' gateway %s\n' "$v6_gw"
[[ -n "$v6_extra" ]] && printf ' %s\n' "$v6_extra"
fi
}
host_network_create_bridge() {
host_network_show_current_overview
local default_name bridge_name ports vlan_aware mtu ipv4_cfg ipv6_cfg tmp block
default_name="$(host_network_guess_next_name vmbr)"
read -p "请输入桥接名称 [$default_name]: " bridge_name
bridge_name="${bridge_name:-$default_name}"
host_network_validate_iface_name "$bridge_name" || {
display_error "桥接名称不合法: $bridge_name" "接口名需为 1-15 位,且仅允许字母、数字、._:-。"
return 1
}
if host_network_get_all_interface_names | grep -qx "$bridge_name"; then
display_error "接口已存在: $bridge_name"
return 1
fi
echo -e "${CYAN}可作为 bridge-ports 的接口(可输入多个,以空格分隔;留空表示 none${NC}"
host_network_get_all_interface_names | sed 's/^/ - /'
read -p "bridge-ports [none]: " ports
ports="${ports:-none}"
if [[ "$ports" != "none" ]]; then
host_network_validate_member_list "$ports" "$bridge_name" "bridge-ports" || return 1
fi
read -p "是否启用 VLAN Aware(yes/no) [yes]: " vlan_aware
vlan_aware="${vlan_aware:-yes}"
case "$vlan_aware" in
yes|YES|no|NO) ;;
*)
display_error "VLAN Aware 仅支持 yes/no"
return 1
;;
esac
read -p "MTU留空保持默认: " mtu
host_network_validate_mtu "$mtu" || return 1
echo "$UI_DIVIDER"
ipv4_cfg="$(host_network_collect_family_config inet create)" || return 1
ipv6_cfg="$(host_network_collect_family_config inet6 create)" || return 1
tmp=$(mktemp)
cp "$HOST_NETWORK_INTERFACES_FILE" "$tmp"
block="$(host_network_build_bridge_block "$bridge_name" "$ports" "$vlan_aware" "$mtu" "$ipv4_cfg" "$ipv6_cfg")"
host_network_remove_iface_from_candidate "$tmp" "$bridge_name"
host_network_append_text_to_candidate "$tmp" "# PVE-TOOLS HOST IFACE BEGIN $bridge_name
$block
# PVE-TOOLS HOST IFACE END $bridge_name"
host_network_commit_candidate "$tmp" "创建桥接 $bridge_name" "将直接改写宿主机网桥配置,错误的桥接成员口、地址或网关会导致宿主机失联。" "SSH/WebUI、集群网络、VM 出口网络都可能受到影响。" "请确认控制台可用、bridge-ports 和网关正确,并已准备回滚。"
rm -f "$tmp"
}
host_network_delete_bridge() {
local bridge_name
bridge_name="$(host_network_select_bridge_name)"
local rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$bridge_name" ]] || return 1
if grep -Eq "(bridge-ports|bond-slaves|vlan-raw-device)[[:space:]].*\b${bridge_name}\b" "$HOST_NETWORK_INTERFACES_FILE" 2>/dev/null; then
display_error "检测到其他接口仍依赖 $bridge_name" "请先删除依赖它的 VLAN、bond 或 bridge 关系后再试。"
return 1
fi
local tmp
tmp=$(mktemp)
cp "$HOST_NETWORK_INTERFACES_FILE" "$tmp"
host_network_remove_iface_from_candidate "$tmp" "$bridge_name"
host_network_commit_candidate "$tmp" "删除桥接 $bridge_name" "删除桥接会切断与该 bridge 绑定的宿主机与 VM 网络配置。" "如果该 bridge 承载管理口或生产流量,宿主机会立即失联。" "请确认管理流量不走该桥接,且相关 VM 已迁移或停机。"
rm -f "$tmp"
}
host_network_bridge_menu() {
while true; do
clear
show_menu_header "桥接管理"
host_network_show_risk_banner
echo -e "${CYAN}当前 bridge${NC}"
if host_network_get_configured_bridges | awk 'NF{print " - "$0}'; then :; fi
echo "$UI_DIVIDER"
show_menu_option "1" "列出当前网卡与桥接"
show_menu_option "2" "创建桥接"
show_menu_option "3" "删除桥接"
show_menu_option "4" "MAC 地址绑定管理(防网卡顺序变化)"
show_menu_option "0" "返回"
show_menu_footer
read -p "请选择操作 [0-4]: " choice
case "$choice" in
1) host_network_show_current_overview ;;
2) host_network_create_bridge ;;
3) host_network_delete_bridge ;;
4) host_network_mac_binding_menu ;;
0) return ;;
*) log_error "无效选择" ;;
esac
pause_function
done
}

View File

@@ -0,0 +1,131 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
netdiag_require_cmd() {
local cmd="$1"
if ! command -v "$cmd" >/dev/null 2>&1; then
display_error "缺少命令: $cmd" "请先安装对应工具后再试。"
return 1
fi
}
netdiag_run_traceroute() {
netdiag_require_cmd traceroute || return 1
local target
read -p "请输入 traceroute 目标 [1.1.1.1]: " target
target="${target:-1.1.1.1}"
traceroute "$target"
}
netdiag_run_mtr() {
netdiag_require_cmd mtr || return 1
local target
read -p "请输入 mtr 目标 [1.1.1.1]: " target
target="${target:-1.1.1.1}"
mtr -rwzc 10 "$target"
}
netdiag_run_nmap() {
netdiag_require_cmd nmap || return 1
local target
read -p "请输入 nmap 扫描目标: " target
[[ -n "$target" ]] || return 1
nmap -Pn -T4 "$target"
}
netdiag_run_tcpdump() {
netdiag_require_cmd tcpdump || return 1
local iface_name filter_expr seconds
iface_name="$(host_network_select_interface_name)"
local rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$iface_name" ]] || return 1
read -p "请输入抓包过滤表达式(留空抓全部): " filter_expr
read -p "抓包秒数 [15]: " seconds
seconds="${seconds:-15}"
[[ "$seconds" =~ ^[0-9]+$ ]] || return 1
timeout "$seconds" tcpdump -ni "$iface_name" ${filter_expr:+$filter_expr}
}
netdiag_pick_vm_ip() {
local vmid ips vm_ip
vmid="$(host_firewall_select_guest vm)"
local rc=$?
[[ "$rc" -eq 2 ]] && return 2
[[ -n "$vmid" ]] || return 1
ips="$(qm guest cmd "$vmid" network-get-interfaces 2>/dev/null | grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}|([0-9a-fA-F]{0,4}:){2,}[0-9a-fA-F]{0,4}' | grep -v '^fe80' | sort -u)"
if [[ -z "$ips" ]]; then
read -p "Guest Agent 未返回 IP请手工输入 VM IP: " vm_ip
[[ -n "$vm_ip" ]] && printf '%s\n' "$vm_ip"
return 0
fi
host_network_select_from_text "VM $vmid 的可用 IP" "$ips"
}
netdiag_check_port_connectivity() {
local target_mode target port
echo " [1] 检查宿主机管理口"
echo " [2] 检查 VM 端口"
echo " [3] 自定义目标"
read -p "请选择目标类型 [1-3]: " target_mode
case "$target_mode" in
1)
target="$(ip -4 -o addr show scope global 2>/dev/null | awk 'NR==1 {print $4}' | cut -d'/' -f1)"
[[ -n "$target" ]] || target="127.0.0.1"
;;
2)
target="$(netdiag_pick_vm_ip)"
local rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$target" ]] || return 1
;;
3)
read -p "请输入目标 IP / 主机名: " target
[[ -n "$target" ]] || return 1
;;
*) return 1 ;;
esac
read -p "请输入端口号: " port
[[ "$port" =~ ^[0-9]+$ ]] || return 1
clear
show_menu_header "端口连通性测试"
echo -e "${CYAN}目标: ${target}:${port}${NC}"
if command -v nc >/dev/null 2>&1; then
nc -zvw 3 "$target" "$port"
else
timeout 3 bash -c "</dev/tcp/${target}/${port}" >/dev/null 2>&1 && echo "端口可达" || echo "端口不可达"
fi
echo "$UI_DIVIDER"
}
netdiag_quick_stack_check() {
clear
show_menu_header "网络诊断摘要"
network_show_diagnostics
echo -e "${CYAN}IPv6 地址:${NC}"
ip -6 -o addr show scope global 2>/dev/null | awk '{print " "$2": "$4}' || true
echo -e "${CYAN}监听端口(前 20 条):${NC}"
ss -lntup 2>/dev/null | sed -n '1,20p' | sed 's/^/ /' || true
echo "$UI_DIVIDER"
}
netdiag_toolbox_menu() {
while true; do
clear
show_menu_header "网络诊断工具箱"
show_menu_option "1" "网络摘要与监听端口"
show_menu_option "2" "traceroute"
show_menu_option "3" "mtr"
show_menu_option "4" "nmap"
show_menu_option "5" "tcpdump"
show_menu_option "6" "端口连通性检查(宿主机 / VM / 自定义)"
show_menu_option "0" "返回"
show_menu_footer
read -p "请选择操作 [0-6]: " choice
case "$choice" in
1) netdiag_quick_stack_check ;;
2) netdiag_run_traceroute ;;
3) netdiag_run_mtr ;;
4) netdiag_run_nmap ;;
5) netdiag_run_tcpdump ;;
6) netdiag_check_port_connectivity ;;
0) return ;;
*) log_error "无效选择" ;;
esac
pause_function
done
}

View File

@@ -0,0 +1,643 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
host_firewall_get_node_names() {
find /etc/pve/nodes -mindepth 1 -maxdepth 1 -type d -printf '%f\n' 2>/dev/null | sort
}
host_firewall_select_node_name() {
host_network_select_from_text "可用节点:" "$(host_firewall_get_node_names)"
}
host_firewall_select_guest() {
local kind="$1"
local list_text
if [[ "$kind" == "vm" ]]; then
list_text="$(qm list 2>/dev/null | awk 'NR>1 {print $1 "|" $2}')"
else
list_text="$(pct list 2>/dev/null | awk 'NR>1 {print $1 "|" $2}')"
fi
mapfile -t items < <(printf '%s\n' "$list_text" | awk 'NF')
(( ${#items[@]} > 0 )) || return 1
echo -e "${CYAN}请选择${kind^^}${NC}" >&2
local idx=1
local item id name
for item in "${items[@]}"; do
id="${item%%|*}"
name="${item#*|}"
printf ' [%d] %s (%s)\n' "$idx" "$id" "$name" >&2
idx=$((idx + 1))
done
echo "$UI_DIVIDER" >&2
local pick
read -p "请选择序号 (0 返回): " pick
pick="${pick:-0}"
[[ "$pick" == "0" ]] && return 2
[[ "$pick" =~ ^[0-9]+$ ]] || return 1
if (( pick < 1 || pick > ${#items[@]} )); then
return 1
fi
id="${items[$((pick - 1))]%%|*}"
printf '%s\n' "$id"
}
host_firewall_validate_group_name() {
local group_name="$1"
[[ -n "$group_name" && "$group_name" =~ ^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$ ]]
}
host_firewall_validate_identifier() {
local scope="$1"
local identifier="$2"
case "$scope" in
datacenter)
[[ "$identifier" == "cluster" ]]
;;
node)
[[ "$identifier" =~ ^[A-Za-z0-9][A-Za-z0-9.-]*$ ]]
;;
vm|ct)
[[ "$identifier" =~ ^[0-9]+$ ]]
;;
security-group)
host_firewall_validate_group_name "$identifier"
;;
*)
return 1
;;
esac
}
host_firewall_is_allowed_target_path() {
local file_path="$1"
case "$file_path" in
/etc/pve/firewall/cluster.fw|/etc/pve/nodes/*/host.fw|/etc/pve/firewall/*.fw)
return 0
;;
*)
return 1
;;
esac
}
host_firewall_target_path() {
local scope="$1"
local identifier="$2"
local path=""
host_firewall_validate_identifier "$scope" "$identifier" || return 1
case "$scope" in
datacenter) path="$PVE_CLUSTER_FIREWALL_FILE" ;;
node) printf -v path '/etc/pve/nodes/%s/host.fw' "$identifier" ;;
vm|ct) printf -v path '/etc/pve/firewall/%s.fw' "$identifier" ;;
*) return 1 ;;
esac
host_firewall_is_allowed_target_path "$path" || return 1
printf '%s\n' "$path"
}
host_firewall_validate_ruleset_content_for_target() {
local kind="$1"
local content="$2"
if [[ "$kind" == "security-group" ]]; then
printf '%s\n' "$content" | awk 'NF{exit !($0 ~ /^\[[Gg][Rr][Oo][Uu][Pp][[:space:]]+/)} END{if(NR==0) exit 1}'
return $?
fi
printf '%s\n' "$content" | grep -Eq '^\[[^]]+\]'
}
host_firewall_prepare_group_section() {
local group_name="$1"
local content="$2"
awk -v target="[group ${group_name}]" '
BEGIN { started=0 }
{
if (!started) {
if ($0 ~ /^\[[Gg][Rr][Oo][Uu][Pp][[:space:]]+/) {
print target
started=1
}
next
}
if ($0 ~ /^\[/) {
exit
}
print
}
END { if (!started) exit 1 }
' <<< "$content"
}
host_firewall_ensure_target_file() {
local file_path="$1"
mkdir -p "$(dirname "$file_path")" >/dev/null 2>&1 || true
if [[ ! -f "$file_path" ]]; then
cat > "$file_path" <<'EOF_FW'
[OPTIONS]
enable: 0
[RULES]
EOF_FW
fi
}
host_firewall_upsert_option() {
local file_path="$1"
local option_key="$2"
local option_value="$3"
local tmp
tmp=$(mktemp)
awk -v option_key="$option_key" -v option_value="$option_value" '
BEGIN { in_options=0; found_options=0; replaced=0 }
{
if ($0 == "[OPTIONS]") {
found_options=1
in_options=1
print
next
}
if (in_options && $0 ~ /^\[/) {
if (!replaced) {
printf "%s: %s\n", option_key, option_value
replaced=1
}
in_options=0
}
if (in_options && $0 ~ ("^" option_key ":[[:space:]]*")) {
printf "%s: %s\n", option_key, option_value
replaced=1
next
}
print
}
END {
if (!found_options) {
print "[OPTIONS]"
printf "%s: %s\n\n", option_key, option_value
print "[RULES]"
} else if (in_options && !replaced) {
printf "%s: %s\n", option_key, option_value
}
}
' "$file_path" > "$tmp"
mv "$tmp" "$file_path"
}
host_firewall_select_security_group() {
local allow_new="${1:-}"
mapfile -t groups < <(host_firewall_get_security_groups)
echo -e "${CYAN}当前安全组:${NC}"
local idx=1
local group
for group in "${groups[@]}"; do
printf ' [%d] %s\n' "$idx" "$group"
idx=$((idx + 1))
done
if [[ "$allow_new" == "allow_new" ]]; then
echo " [N] 新建安全组"
fi
echo "$UI_DIVIDER"
local pick
read -p "请选择安全组 (0 返回): " pick
[[ "$pick" == "0" ]] && return 2
if [[ "$allow_new" == "allow_new" && ( "$pick" == "N" || "$pick" == "n" ) ]]; then
read -p "请输入新的安全组名称: " group
host_firewall_validate_group_name "$group" || {
display_error "安全组名称不合法: $group" "仅允许字母、数字、._:-,且长度不超过 64。"
return 1
}
printf '%s\n' "$group"
return 0
fi
[[ "$pick" =~ ^[0-9]+$ ]] || return 1
if (( pick < 1 || pick > ${#groups[@]} )); then
return 1
fi
printf '%s\n' "${groups[$((pick - 1))]}"
}
host_firewall_get_security_groups() {
host_firewall_ensure_target_file "$PVE_CLUSTER_FIREWALL_FILE"
awk '/^\[[Gg][Rr][Oo][Uu][Pp][[:space:]]+/ {line=$0; sub(/^\[[Gg][Rr][Oo][Uu][Pp][[:space:]]+/, "", line); sub(/\]$/, "", line); print line}' "$PVE_CLUSTER_FIREWALL_FILE" 2>/dev/null | sort -u
}
host_firewall_get_group_section() {
local group_name="$1"
host_firewall_ensure_target_file "$PVE_CLUSTER_FIREWALL_FILE"
awk -v header="[group ${group_name}]" '
BEGIN { capture=0 }
{
if (capture) {
if ($0 ~ /^\[/ && $0 != header) {
exit
}
print
next
}
if ($0 == header) {
capture=1
print
}
}
' "$PVE_CLUSTER_FIREWALL_FILE"
}
host_firewall_replace_group_section_in_file() {
local group_name="$1"
local new_content="$2"
local tmp
tmp=$(mktemp)
awk -v header="[group ${group_name}]" -v new_content="$new_content" '
BEGIN { skip=0; replaced=0; split(new_content, repl, "\n") }
{
if (skip) {
if ($0 ~ /^\[/ && $0 != header) {
skip=0
} else {
next
}
}
if (!replaced && $0 == header) {
for (i=1; i in repl; i++) print repl[i]
replaced=1
skip=1
next
}
print
}
END {
if (!replaced) {
print ""
for (i=1; i in repl; i++) print repl[i]
}
}
' "$PVE_CLUSTER_FIREWALL_FILE" > "$tmp"
mv "$tmp" "$PVE_CLUSTER_FIREWALL_FILE"
}
host_firewall_select_ruleset_target() {
echo " [1] 数据中心 firewall"
echo " [2] 节点 firewall"
echo " [3] VM firewall"
echo " [4] CT firewall"
echo " [5] 安全组"
read -p "请选择目标 [1-5]: " choice
local node_name guest_id path group_name rc
case "$choice" in
1)
printf 'datacenter|cluster|%s|数据中心 firewall\n' "$PVE_CLUSTER_FIREWALL_FILE"
;;
2)
node_name="$(host_firewall_select_node_name)"
rc=$?
[[ "$rc" -eq 2 ]] && return 2
[[ -n "$node_name" ]] || return 1
path="$(host_firewall_target_path node "$node_name")"
printf 'node|%s|%s|节点 firewall (%s)\n' "$node_name" "$path" "$node_name"
;;
3)
guest_id="$(host_firewall_select_guest vm)"
rc=$?
[[ "$rc" -eq 2 ]] && return 2
[[ -n "$guest_id" ]] || return 1
path="$(host_firewall_target_path vm "$guest_id")"
printf 'vm|%s|%s|VM firewall (%s)\n' "$guest_id" "$path" "$guest_id"
;;
4)
guest_id="$(host_firewall_select_guest ct)"
rc=$?
[[ "$rc" -eq 2 ]] && return 2
[[ -n "$guest_id" ]] || return 1
path="$(host_firewall_target_path ct "$guest_id")"
printf 'ct|%s|%s|CT firewall (%s)\n' "$guest_id" "$path" "$guest_id"
;;
5)
group_name="$(host_firewall_select_security_group allow_new)"
rc=$?
[[ "$rc" -eq 2 ]] && return 2
[[ -n "$group_name" ]] || return 1
printf 'security-group|%s|%s|安全组 (%s)\n' "$group_name" "$PVE_CLUSTER_FIREWALL_FILE" "$group_name"
;;
*)
return 1
;;
esac
}
host_firewall_toggle_enable() {
local scope="$1"
local identifier="$2"
local label="$3"
local state file_path
file_path="$(host_firewall_target_path "$scope" "$identifier")" || return 1
host_firewall_ensure_target_file "$file_path"
read -p "是否启用 $label 防火墙?(yes/no) [yes]: " state
state="${state:-yes}"
if ! confirm_high_risk_action "切换 $label 防火墙状态" "错误的防火墙开关或默认策略可能导致管理口、集群通信或业务端口不可达。" "如果规则集本身有误,启用后可能立即造成 SSH/WebUI/业务中断。" "请确认已有控制台或带外管理手段,并已审查当前 firewall 规则。" "FIREWALL"; then
return 0
fi
backup_file "$file_path" >/dev/null 2>&1 || true
if [[ "$state" == "yes" || "$state" == "YES" ]]; then
host_firewall_upsert_option "$file_path" enable 1
else
host_firewall_upsert_option "$file_path" enable 0
fi
display_success "$label 防火墙状态已更新" "$file_path"
if [[ "$scope" == "vm" || "$scope" == "ct" ]]; then
log_warn "PVE 客体防火墙还依赖对应网卡开启 firewall=1如未开启请同步检查网卡配置。"
fi
}
host_firewall_toggle_menu() {
while true; do
clear
show_menu_header "PVE 防火墙开关"
host_network_show_risk_banner
show_menu_option "1" "数据中心级别开关"
show_menu_option "2" "节点级别开关"
show_menu_option "3" "VM 级别开关"
show_menu_option "4" "CT 级别开关"
show_menu_option "0" "返回"
show_menu_footer
read -p "请选择操作 [0-4]: " choice
case "$choice" in
1) host_firewall_toggle_enable datacenter cluster "数据中心" ;;
2)
local node_name rc
node_name="$(host_firewall_select_node_name)"
rc=$?
[[ "$rc" -eq 2 ]] && continue
[[ -n "$node_name" ]] && host_firewall_toggle_enable node "$node_name" "节点 $node_name"
;;
3)
local vmid rc
vmid="$(host_firewall_select_guest vm)"
rc=$?
[[ "$rc" -eq 2 ]] && continue
[[ -n "$vmid" ]] && host_firewall_toggle_enable vm "$vmid" "VM $vmid"
;;
4)
local ctid rc
ctid="$(host_firewall_select_guest ct)"
rc=$?
[[ "$rc" -eq 2 ]] && continue
[[ -n "$ctid" ]] && host_firewall_toggle_enable ct "$ctid" "CT $ctid"
;;
0) return ;;
*) log_error "无效选择" ;;
esac
pause_function
done
}
host_firewall_list_security_groups() {
clear
show_menu_header "安全组规则"
host_firewall_ensure_target_file "$PVE_CLUSTER_FIREWALL_FILE"
local groups_text group
groups_text="$(host_firewall_get_security_groups)"
if [[ -z "$groups_text" ]]; then
echo " 当前没有安全组。"
return 0
fi
while IFS= read -r group; do
[[ -z "$group" ]] && continue
echo -e "${CYAN}[group ${group}]${NC}"
host_firewall_get_group_section "$group" | awk 'NR>1 && NF {print " "$0}'
echo "$UI_DIVIDER"
done <<< "$groups_text"
}
host_firewall_add_security_group_rule() {
local group_name direction action rule_body existing new_section
group_name="$(host_firewall_select_security_group allow_new)"
local rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$group_name" ]] || return 1
echo " [1] IN"
echo " [2] OUT"
read -p "请选择方向 [1-2]: " direction
case "$direction" in
1) direction="IN" ;;
2) direction="OUT" ;;
*) return 1 ;;
esac
echo " [1] ACCEPT"
echo " [2] DROP"
echo " [3] REJECT"
read -p "请选择动作 [1-3]: " action
case "$action" in
1) action="ACCEPT" ;;
2) action="DROP" ;;
3) action="REJECT" ;;
*) return 1 ;;
esac
read -p "请输入规则主体(示例 -p tcp --dport 22 -source +management留空则仅写方向/动作): " rule_body
host_firewall_ensure_target_file "$PVE_CLUSTER_FIREWALL_FILE"
backup_file "$PVE_CLUSTER_FIREWALL_FILE" >/dev/null 2>&1 || true
existing="$(host_firewall_get_group_section "$group_name")"
if [[ -z "$existing" ]]; then
new_section="[group ${group_name}]"
else
new_section="$existing"
fi
new_section+=$'\n'
new_section+="${direction} ${action}"
[[ -n "$rule_body" ]] && new_section+=" ${rule_body}"
host_firewall_replace_group_section_in_file "$group_name" "$new_section"
display_success "安全组规则已写入" "group ${group_name}"
}
host_firewall_delete_security_group_rule() {
local group_name section idx pick new_section
group_name="$(host_firewall_select_security_group)"
local rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$group_name" ]] || return 1
section="$(host_firewall_get_group_section "$group_name")"
[[ -n "$section" ]] || {
display_error "安全组不存在或无规则: $group_name"
return 1
}
mapfile -t rules < <(printf '%s\n' "$section" | awk 'NR>1 && NF && $0 !~ /^#/ {print}')
(( ${#rules[@]} > 0 )) || {
display_error "安全组没有可删除的规则: $group_name"
return 1
}
echo -e "${CYAN}[group ${group_name}]${NC}"
idx=1
local rule
for rule in "${rules[@]}"; do
printf ' [%d] %s\n' "$idx" "$rule"
idx=$((idx + 1))
done
echo "$UI_DIVIDER"
read -p "请选择要删除的规则序号 (0 返回): " pick
pick="${pick:-0}"
[[ "$pick" == "0" ]] && return 0
[[ "$pick" =~ ^[0-9]+$ ]] || return 1
if (( pick < 1 || pick > ${#rules[@]} )); then
return 1
fi
new_section="[group ${group_name}]"
idx=1
for rule in "${rules[@]}"; do
if (( idx != pick )); then
new_section+=$'\n'
new_section+="$rule"
fi
idx=$((idx + 1))
done
backup_file "$PVE_CLUSTER_FIREWALL_FILE" >/dev/null 2>&1 || true
host_firewall_replace_group_section_in_file "$group_name" "$new_section"
display_success "安全组规则已删除" "group ${group_name}"
}
host_firewall_show_target_rules() {
local target_data kind identifier path label content
target_data="$(host_firewall_select_ruleset_target)"
local rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$target_data" ]] || return 1
IFS='|' read -r kind identifier path label <<< "$target_data"
clear
show_menu_header "$label"
if [[ "$kind" == "security-group" ]]; then
content="$(host_firewall_get_group_section "$identifier")"
[[ -n "$content" ]] && printf '%s\n' "$content" | sed 's/^/ /' || echo ' 当前安全组为空。'
else
host_firewall_ensure_target_file "$path"
sed 's/^/ /' "$path"
fi
echo "$UI_DIVIDER"
}
host_firewall_export_ruleset() {
local target_data kind identifier path label format export_file content b64 safe_name
target_data="$(host_firewall_select_ruleset_target)"
local rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$target_data" ]] || return 1
IFS='|' read -r kind identifier path label <<< "$target_data"
if [[ "$kind" == "security-group" ]]; then
content="$(host_firewall_get_group_section "$identifier")"
else
host_firewall_ensure_target_file "$path"
content="$(cat "$path")"
fi
mkdir -p "$HOST_NETWORK_EXPORT_DIR"
safe_name="$(echo "$identifier" | tr '/: ' '___')"
echo " [1] JSON"
echo " [2] CLI / raw"
read -p "请选择导出格式 [1-2]: " format
case "$format" in
1)
export_file="$HOST_NETWORK_EXPORT_DIR/${kind}-${safe_name}-$(date +%Y%m%d_%H%M%S).json"
b64="$(printf '%s' "$content" | base64 | tr -d '\n')"
cat > "$export_file" <<EOF_JSON
{
"format": "pve-tools-firewall-json",
"target_kind": "${kind}",
"identifier": "${identifier}",
"exported_at": "$(date +%F' '%T)",
"content_base64": "${b64}"
}
EOF_JSON
;;
2)
export_file="$HOST_NETWORK_EXPORT_DIR/${kind}-${safe_name}-$(date +%Y%m%d_%H%M%S).fw"
printf '%s\n' "$content" > "$export_file"
;;
*)
return 1
;;
esac
display_success "规则集已导出" "$export_file"
}
host_firewall_import_ruleset() {
local import_path source_kind source_identifier content b64 target_data kind identifier path label prepared_content rc
read -p "请输入要导入的规则集文件路径: " import_path
[[ -f "$import_path" ]] || {
display_error "文件不存在: $import_path"
return 1
}
if grep -q '"format": "pve-tools-firewall-json"' "$import_path" 2>/dev/null; then
source_kind="$(sed -n 's/.*"target_kind": "\([^"]*\)".*/\1/p' "$import_path" | head -n 1)"
source_identifier="$(sed -n 's/.*"identifier": "\([^"]*\)".*/\1/p' "$import_path" | head -n 1)"
b64="$(sed -n 's/.*"content_base64": "\([^"]*\)".*/\1/p' "$import_path" | head -n 1)"
content="$(printf '%s' "$b64" | base64 -d 2>/dev/null)"
else
content="$(cat "$import_path")"
fi
[[ -n "$content" ]] || {
display_error "导入内容为空或解析失败"
return 1
}
if [[ -n "$source_kind" || -n "$source_identifier" ]]; then
log_warn "导入文件携带的原始目标为 ${source_kind:-unknown}:${source_identifier:-unknown},实际写入目标仍需重新选择。"
fi
target_data="$(host_firewall_select_ruleset_target)"
rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$target_data" ]] || return 1
IFS='|' read -r kind identifier path label <<< "$target_data"
host_firewall_validate_identifier "$kind" "$identifier" || {
display_error "导入目标不合法: ${kind}:${identifier}"
return 1
}
if [[ "$kind" != "security-group" ]]; then
path="$(host_firewall_target_path "$kind" "$identifier")" || {
display_error "导入目标路径非法或超出允许范围"
return 1
}
fi
host_firewall_validate_ruleset_content_for_target "$kind" "$content" || {
display_error "规则集内容与目标类型不匹配" "请避免把整份 firewall 文件导入到安全组,或把安全组片段导入到数据中心/节点/客体 firewall。"
return 1
}
if ! confirm_high_risk_action "导入规则集到 $label" "导入会覆盖当前目标的规则或安全组内容。" "错误的规则集可能立即封死管理口、业务端口或集群通信。" "请确认已导出当前规则备份,并通过控制台进行高风险导入。" "IMPORT-FW"; then
return 0
fi
if [[ "$kind" == "security-group" ]]; then
prepared_content="$(host_firewall_prepare_group_section "$identifier" "$content")" || {
display_error "无法从导入文件中提取有效安全组段落"
return 1
}
host_firewall_ensure_target_file "$PVE_CLUSTER_FIREWALL_FILE"
backup_file "$PVE_CLUSTER_FIREWALL_FILE" >/dev/null 2>&1 || true
host_firewall_replace_group_section_in_file "$identifier" "$prepared_content"
display_success "安全组规则已导入" "group ${identifier}"
return 0
fi
host_firewall_ensure_target_file "$path"
backup_file "$path" >/dev/null 2>&1 || true
printf '%s\n' "$content" > "$path"
display_success "规则集已导入" "$path"
}
host_firewall_menu() {
while true; do
clear
show_menu_header "PVE 防火墙管理"
host_network_show_risk_banner
show_menu_option "1" "数据中心 / 节点 / VM / CT 防火墙开关"
show_menu_option "2" "查看目标规则集"
show_menu_option "3" "列出安全组规则"
show_menu_option "4" "新增安全组规则"
show_menu_option "5" "删除安全组规则"
show_menu_option "6" "导出规则集JSON / CLI"
show_menu_option "7" "导入规则集JSON / CLI"
show_menu_option "0" "返回"
show_menu_footer
read -p "请选择操作 [0-7]: " choice
case "$choice" in
1) host_firewall_toggle_menu ;;
2) host_firewall_show_target_rules ;;
3) host_firewall_list_security_groups ;;
4) host_firewall_add_security_group_rule ;;
5) host_firewall_delete_security_group_rule ;;
6) host_firewall_export_ruleset ;;
7) host_firewall_import_ruleset ;;
0) return ;;
*) log_error "无效选择" ;;
esac
pause_function
done
}

View File

@@ -0,0 +1,38 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
menu_host_networking() {
while true; do
clear
show_menu_header "宿主机网络配置向导"
host_network_show_risk_banner
show_menu_option "1" "列出当前网卡与桥接vmbr0~N"
show_menu_option "2" "桥接管理(创建 / 删除)"
show_menu_option "3" "配置接口静态 IPv4 / IPv6 / SLAAC / DHCP"
show_menu_option "4" "VLAN 子接口管理"
show_menu_option "5" "Bond 管理(模式 0 / 1 / 4 / 6"
show_menu_option "6" "PVE 防火墙管理"
show_menu_option "7" "IPv6 助手"
show_menu_option "8" "网络诊断工具箱"
echo -e "${RED}警告:应用宿主机网络修改时,建议在控制台或带外管理环境中执行,避免误断 SSH / WebUI。${NC}"
show_menu_option "0" "返回主菜单"
show_menu_footer
read -p "请选择操作 [0-8]: " choice
case "$choice" in
1) host_network_show_current_overview ;;
2) host_network_bridge_menu ;;
3) host_network_configure_interface_addressing ;;
4) host_network_vlan_menu ;;
5) host_network_bond_menu ;;
6) host_firewall_menu ;;
7) ipv6_helper_menu ;;
8) netdiag_toolbox_menu ;;
0) return ;;
*) log_error "无效选择" ;;
esac
pause_function
done
}
# 二级菜单:存储与硬盘

View File

@@ -0,0 +1,538 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
host_network_show_risk_banner() {
echo -e "${RED}${UI_DIVIDER}${NC}"
echo -e "${RED}高风险提示:以下功能会直接改写宿主机网络、防火墙和 IPv6 行为。${NC}"
echo -e "${YELLOW}请仅在控制台或带外管理可用、已确认维护窗口、已准备回滚方案时继续。${NC}"
echo -e "${YELLOW}错误的 bridge / bond / VLAN / 路由 / 防火墙规则可能导致 SSH 与 WebUI 断连。${NC}"
echo -e "${RED}${UI_DIVIDER}${NC}"
}
host_network_ensure_interfaces_file() {
if [[ ! -f "$HOST_NETWORK_INTERFACES_FILE" ]]; then
cat > "$HOST_NETWORK_INTERFACES_FILE" <<'EOF_INTERFACES'
auto lo
iface lo inet loopback
EOF_INTERFACES
fi
}
host_network_get_all_interface_names() {
host_network_ensure_interfaces_file
{
awk '/^iface[[:space:]]+/ {print $2}' "$HOST_NETWORK_INTERFACES_FILE" 2>/dev/null
ip -o link show 2>/dev/null | awk -F': ' '{print $2}' | cut -d'@' -f1
} | awk 'NF && $1 != "lo"' | sort -u
}
host_network_get_configured_bridges() {
host_network_ensure_interfaces_file
awk '/^iface[[:space:]]+vmbr[0-9]+[[:space:]]+/ {print $2}' "$HOST_NETWORK_INTERFACES_FILE" 2>/dev/null | sort -u
}
host_network_get_configured_vlans() {
host_network_ensure_interfaces_file
awk '/^iface[[:space:]]+[A-Za-z0-9_.:-]+\.[0-9]+[[:space:]]+/ {print $2}' "$HOST_NETWORK_INTERFACES_FILE" 2>/dev/null | sort -u
}
host_network_get_configured_bonds() {
host_network_ensure_interfaces_file
awk '/^iface[[:space:]]+bond[0-9]+[[:space:]]+/ {print $2}' "$HOST_NETWORK_INTERFACES_FILE" 2>/dev/null | sort -u
}
host_network_guess_next_name() {
local prefix="$1"
local idx=0
while :; do
if ! host_network_get_all_interface_names | grep -qx "${prefix}${idx}"; then
echo "${prefix}${idx}"
return 0
fi
idx=$((idx + 1))
done
}
host_network_validate_iface_name() {
local name="$1"
[[ -n "$name" && ${#name} -le 15 && "$name" =~ ^[A-Za-z0-9_.:-]+$ ]]
}
host_network_validate_mtu() {
local mtu="$1"
[[ -z "$mtu" ]] && return 0
if [[ ! "$mtu" =~ ^[0-9]+$ || "$mtu" -lt 576 || "$mtu" -gt 9216 ]]; then
display_error "MTU 不合法: $mtu" "请输入 576-9216 之间的整数,或留空保持默认。"
return 1
fi
}
host_network_get_iface_mac() {
local iface="$1"
local mac
mac="$(cat "/sys/class/net/${iface}/address" 2>/dev/null || true)"
[[ "$mac" =~ ^([0-9a-f]{2}:){5}[0-9a-f]{2}$ ]] && echo "$mac" || echo ""
}
host_network_get_physical_ifaces_with_mac() {
local iface mac
for iface in $(ip -o link show 2>/dev/null | awk -F': ' '{print $2}' | cut -d'@' -f1 | sort -u); do
[[ "$iface" == "lo" ]] && continue
# 跳过 bridge、bond、vlan 等虚拟接口
[[ -f "/sys/class/net/${iface}/device/vendor" ]] || continue
mac="$(host_network_get_iface_mac "$iface")"
[[ -n "$mac" ]] || continue
printf '%s|%s\n' "$iface" "$mac"
done
}
host_network_validate_mac() {
local mac="$1"
[[ "$mac" =~ ^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$ ]]
}
host_network_mac_to_iface() {
local target_mac="$1"
target_mac="$(echo "$target_mac" | tr '[:upper:]' '[:lower:]')"
local iface mac
for iface in $(ip -o link show 2>/dev/null | awk -F': ' '{print $2}' | cut -d'@' -f1 | sort -u); do
[[ "$iface" == "lo" ]] && continue
mac="$(host_network_get_iface_mac "$iface")"
[[ "$(echo "$mac" | tr '[:upper:]' '[:lower:]')" == "$target_mac" ]] && { echo "$iface"; return 0; }
done
return 1
}
host_network_validate_ipv4() {
local ip="$1"
[[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || return 1
awk -F'.' '{for(i=1;i<=4;i++) if($i < 0 || $i > 255) exit 1; exit 0}' <<< "$ip"
}
host_network_validate_ipv4_cidr() {
local value="$1"
local ip="${value%/*}"
local prefix="${value##*/}"
[[ "$value" == */* ]] || return 1
host_network_validate_ipv4 "$ip" || return 1
[[ "$prefix" =~ ^[0-9]+$ && "$prefix" -ge 0 && "$prefix" -le 32 ]]
}
host_network_validate_ipv6() {
local ip="$1"
[[ "$ip" == *:* ]] || return 1
[[ "$ip" =~ ^[0-9A-Fa-f:]+(%[A-Za-z0-9_.-]+)?$ ]]
}
host_network_validate_ipv6_cidr() {
local value="$1"
local ip="${value%/*}"
local prefix="${value##*/}"
[[ "$value" == */* ]] || return 1
host_network_validate_ipv6 "$ip" || return 1
[[ "$prefix" =~ ^[0-9]+$ && "$prefix" -ge 0 && "$prefix" -le 128 ]]
}
host_network_validate_static_address() {
local family="$1"
local address="$2"
case "$family" in
inet) host_network_validate_ipv4_cidr "$address" ;;
inet6) host_network_validate_ipv6_cidr "$address" ;;
*) return 1 ;;
esac
}
host_network_validate_gateway() {
local family="$1"
local gateway="$2"
[[ -z "$gateway" ]] && return 0
case "$family" in
inet) host_network_validate_ipv4 "$gateway" ;;
inet6) host_network_validate_ipv6 "$gateway" ;;
*) return 1 ;;
esac
}
host_network_iface_exists() {
local iface_name="$1"
host_network_get_all_interface_names | grep -qx "$iface_name"
}
host_network_interface_has_master_dependency() {
local iface_name="$1"
awk -v iface_name="$iface_name" '
/^[[:space:]]*(bridge-ports|bond-slaves)[[:space:]]+/ {
for (i=2; i<=NF; i++) {
if ($i == iface_name) {
print $0
exit
}
}
}
' "$HOST_NETWORK_INTERFACES_FILE" 2>/dev/null | grep -q .
}
host_network_validate_member_list() {
local members_text="$1"
local owner_name="$2"
local relation_label="$3"
local -A seen=()
local member count=0
while IFS= read -r member; do
[[ -n "$member" ]] || continue
host_network_validate_iface_name "$member" || {
display_error "$relation_label 中包含非法接口名: $member"
return 1
}
[[ "$member" != "$owner_name" ]] || {
display_error "$relation_label 不能引用自身接口: $owner_name"
return 1
}
if [[ -n "${seen[$member]:-}" ]]; then
display_error "$relation_label 中存在重复成员: $member"
return 1
fi
seen[$member]=1
host_network_iface_exists "$member" || {
display_error "接口不存在: $member" "请先确认该接口已经存在于宿主机链路或配置中。"
return 1
}
if host_network_interface_has_master_dependency "$member"; then
display_error "接口已被其他 bridge/bond 使用: $member" "请先解除现有从属关系,再重新编排宿主机网络。"
return 1
fi
count=$((count + 1))
done < <(printf '%s\n' "$members_text" | tr ' ' '\n' | awk 'NF')
if (( count == 0 )); then
display_error "$relation_label 不能为空"
return 1
fi
}
host_network_select_from_text() {
local title="$1"
local items_text="$2"
mapfile -t items < <(printf '%s\n' "$items_text" | awk 'NF')
if (( ${#items[@]} == 0 )); then
return 1
fi
echo -e "${CYAN}${title}${NC}" >&2
local i=1
for item in "${items[@]}"; do
printf ' [%d] %s\n' "$i" "$item" >&2
i=$((i + 1))
done
echo "$UI_DIVIDER" >&2
local pick
read -p "请选择序号 (0 返回): " pick
pick="${pick:-0}"
[[ "$pick" == "0" ]] && return 2
[[ "$pick" =~ ^[0-9]+$ ]] || return 1
if (( pick < 1 || pick > ${#items[@]} )); then
return 1
fi
printf '%s\n' "${items[$((pick - 1))]}"
}
host_network_select_interface_name() {
host_network_select_from_text "可用接口:" "$(host_network_get_all_interface_names)"
}
host_network_select_bridge_name() {
host_network_select_from_text "已配置桥接:" "$(host_network_get_configured_bridges)"
}
host_network_select_bond_name() {
host_network_select_from_text "已配置 Bond" "$(host_network_get_configured_bonds)"
}
host_network_select_vlan_name() {
host_network_select_from_text "已配置 VLAN 子接口:" "$(host_network_get_configured_vlans)"
}
host_network_show_current_overview() {
clear
show_menu_header "宿主机网络概览"
echo -e "${CYAN}运行时链路:${NC}"
ip -brief link 2>/dev/null | sed 's/^/ /' || true
echo -e "${CYAN}运行时地址:${NC}"
ip -brief addr 2>/dev/null | sed 's/^/ /' || true
echo -e "${CYAN}默认路由:${NC}"
ip route 2>/dev/null | sed 's/^/ /' || true
ip -6 route 2>/dev/null | sed 's/^/ /' || true
echo -e "${CYAN}当前配置中的 bridge / bond / VLAN${NC}"
awk '
/^iface[[:space:]]+/ {
name=$2
fam=$3
method=$4
if (name ~ /^vmbr[0-9]+$/ || name ~ /^bond[0-9]+$/ || name ~ /\.[0-9]+$/) {
printf " %s (%s %s)\n", name, fam, method
}
}
' "$HOST_NETWORK_INTERFACES_FILE" 2>/dev/null || true
echo "$UI_DIVIDER"
}
host_network_collect_family_config() {
local family="$1"
local phase="${2:-create}"
local choice method address gateway extra
if [[ "$family" == "inet" ]]; then
if [[ "$phase" == "update" ]]; then
echo " [1] 保持当前 IPv4" >&2
echo " [2] 静态 IPv4" >&2
echo " [3] DHCPv4" >&2
read -p "请选择 IPv4 模式 [1-3]: " choice
case "$choice" in
1|"") echo "keep|||"; return 0 ;;
2) method="static" ;;
3) method="dhcp" ;;
*) return 1 ;;
esac
else
echo " [1] 静态 IPv4" >&2
echo " [2] DHCPv4" >&2
echo " [3] 不配置 IPv4" >&2
read -p "请选择 IPv4 模式 [1-3]: " choice
case "$choice" in
1) method="static" ;;
2) method="dhcp" ;;
3|"") echo "none|||"; return 0 ;;
*) return 1 ;;
esac
fi
else
if [[ "$phase" == "update" ]]; then
echo " [1] 保持当前 IPv6" >&2
echo " [2] 静态 IPv6" >&2
echo " [3] DHCPv6" >&2
echo " [4] SLAAC" >&2
echo " [5] 移除 IPv6 stanza" >&2
read -p "请选择 IPv6 模式 [1-5]: " choice
case "$choice" in
1|"") echo "keep|||"; return 0 ;;
2) method="static" ;;
3) method="dhcp" ;;
4) method="auto"; extra="accept-ra 2" ;;
5) echo "remove|||"; return 0 ;;
*) return 1 ;;
esac
else
echo " [1] 静态 IPv6" >&2
echo " [2] DHCPv6" >&2
echo " [3] SLAAC" >&2
echo " [4] 不配置 IPv6" >&2
read -p "请选择 IPv6 模式 [1-4]: " choice
case "$choice" in
1) method="static" ;;
2) method="dhcp" ;;
3) method="auto"; extra="accept-ra 2" ;;
4|"") echo "none|||"; return 0 ;;
*) return 1 ;;
esac
fi
fi
if [[ "$method" == "static" ]]; then
if [[ "$family" == "inet" ]]; then
read -p "请输入静态 IPv4/CIDR示例 192.168.10.2/24: " address
else
read -p "请输入静态 IPv6/CIDR示例 2001:db8::2/64: " address
fi
[[ -n "$address" ]] || return 1
host_network_validate_static_address "$family" "$address" || {
display_error "静态地址格式无效: $address"
return 1
}
read -p "请输入网关(留空跳过): " gateway
host_network_validate_gateway "$family" "$gateway" || {
display_error "网关格式无效: $gateway"
return 1
}
fi
printf '%s|%s|%s|%s\n' "$method" "$address" "$gateway" "$extra"
}
host_network_extract_family_stanza() {
local file_path="$1"
local iface_name="$2"
local family="$3"
awk -v iface_name="$iface_name" -v family="$family" '
BEGIN { capture=0 }
{
if (capture) {
if ($0 !~ /^[[:space:]]/ && $0 ~ /^(iface|auto|allow-)/) {
exit
}
print
next
}
if ($0 ~ ("^iface[[:space:]]+" iface_name "[[:space:]]+" family "([[:space:]]+|$)")) {
capture=1
print
}
}
' "$file_path"
}
host_network_collect_preserved_family_options() {
local file_path="$1"
local iface_name="$2"
local family="$3"
host_network_extract_family_stanza "$file_path" "$iface_name" "$family" | awk '
NR == 1 { next }
/^[[:space:]]+/ {
line=$0
sub(/^[[:space:]]+/, "", line)
if (line ~ /^(address|gateway|netmask|broadcast|pointopoint|accept-ra|dns-nameservers|dns-search)([[:space:]]|$)/) next
if (line ~ /MASQUERADE/) next
if (line ~ /net\.ipv6\.conf\.all\.forwarding/) next
print line
}
'
}
host_network_remove_iface_family_from_candidate() {
local file_path="$1"
local iface_name="$2"
local family="$3"
local tmp
tmp=$(mktemp)
awk -v iface_name="$iface_name" -v family="$family" '
BEGIN { skip=0 }
{
if (skip) {
if ($0 !~ /^[[:space:]]/ && $0 !~ /^$/) {
skip=0
} else {
next
}
}
if ($0 ~ ("^iface[[:space:]]+" iface_name "[[:space:]]+" family "([[:space:]]+|$)")) {
skip=1
next
}
print
}
' "$file_path" > "$tmp"
mv "$tmp" "$file_path"
}
host_network_remove_iface_from_candidate() {
local file_path="$1"
local iface_name="$2"
local tmp
tmp=$(mktemp)
awk -v iface_name="$iface_name" '
BEGIN { skip=0 }
function rebuild_line(line, n, i, parts, out, kept) {
n=split(line, parts, /[[:space:]]+/)
out=parts[1]
kept=0
for (i=2; i<=n; i++) {
if (parts[i] == iface_name || parts[i] == "") continue
out=out " " parts[i]
kept=1
}
if (kept) print out
}
{
if (skip) {
if ($0 !~ /^[[:space:]]/ && $0 !~ /^$/) {
skip=0
} else {
next
}
}
if ($0 ~ ("^# PVE-TOOLS HOST IFACE (BEGIN|END) " iface_name "$")) next
if ($0 ~ /^(auto|allow-[^[:space:]]+)/) {
if ($0 ~ ("(^|[[:space:]])" iface_name "([[:space:]]|$)")) {
rebuild_line($0)
next
}
}
if ($0 ~ ("^iface[[:space:]]+" iface_name "[[:space:]]+(inet|inet6)([[:space:]]+|$)")) {
skip=1
next
}
print
}
' "$file_path" > "$tmp"
mv "$tmp" "$file_path"
}
host_network_ensure_auto_line_in_candidate() {
local file_path="$1"
local iface_name="$2"
if ! grep -Eq "^(auto|allow-[^[:space:]]+)[[:space:]].*\b${iface_name}\b" "$file_path"; then
printf '\nauto %s\n' "$iface_name" >> "$file_path"
fi
}
host_network_append_text_to_candidate() {
local file_path="$1"
local text="$2"
printf '\n%s\n' "$text" >> "$file_path"
}
host_network_build_family_stanza() {
local iface_name="$1"
local family="$2"
local cfg="$3"
local preserved_text="$4"
local method address gateway extra
IFS='|' read -r method address gateway extra <<< "$cfg"
[[ "$method" == "remove" ]] && return 0
[[ "$method" == "keep" ]] && return 0
printf 'iface %s %s %s\n' "$iface_name" "$family" "$method"
if [[ -n "$preserved_text" ]]; then
while IFS= read -r line; do
[[ -n "$line" ]] && printf ' %s\n' "$line"
done <<< "$preserved_text"
fi
[[ -n "$address" ]] && printf ' address %s\n' "$address"
[[ -n "$gateway" ]] && printf ' gateway %s\n' "$gateway"
[[ -n "$extra" ]] && printf ' %s\n' "$extra"
}
host_network_commit_candidate() {
local candidate_file="$1"
local action_desc="$2"
local risk_desc="$3"
local impact_desc="$4"
local backup_desc="$5"
local backup_path=""
mkdir -p "$(dirname "$HOST_NETWORK_INTERFACES_STAGED_FILE")" >/dev/null 2>&1 || true
cp "$candidate_file" "$HOST_NETWORK_INTERFACES_STAGED_FILE"
clear
show_menu_header "宿主机网络变更预览"
echo -e "${YELLOW}动作:${NC} $action_desc"
echo -e "${YELLOW}已写入 staged:${NC} $HOST_NETWORK_INTERFACES_STAGED_FILE"
echo "$UI_DIVIDER"
diff -u "$HOST_NETWORK_INTERFACES_FILE" "$candidate_file" 2>/dev/null | sed 's/^/ /' || true
echo "$UI_DIVIDER"
local stage_only
read -p "是否只写入 staged 文件而不立即应用?(yes/no) [yes]: " stage_only
stage_only="${stage_only:-yes}"
if [[ "$stage_only" == "yes" || "$stage_only" == "YES" ]]; then
display_success "候选网络配置已写入 staged 文件" "建议先在控制台或带外环境审阅后,再使用 pvenetcommit / ifreload 正式切换。"
return 0
fi
if ! confirm_high_risk_action "$action_desc" "$risk_desc" "$impact_desc" "$backup_desc" "APPLY-NET"; then
return 0
fi
backup_file "$HOST_NETWORK_INTERFACES_FILE" backup_path >/dev/null 2>&1 || true
if command -v pvenetcommit >/dev/null 2>&1; then
if pvenetcommit >/dev/null 2>&1; then
display_success "网络配置已通过 pvenetcommit 提交" "如 SSH 断连,请通过控制台确认新链路已生效。"
return 0
fi
log_warn "pvenetcommit 执行失败,准备回退到显式文件切换流程。"
fi
if ! command -v ifreload >/dev/null 2>&1; then
display_error "当前环境缺少 ifreload已拒绝直接覆盖正式网络配置" "请保留 staged 文件,并在控制台中使用 pvenetcommit 或人工审核后再应用。"
return 1
fi
cp "$candidate_file" "$HOST_NETWORK_INTERFACES_FILE"
if ifreload -a >/dev/null 2>&1; then
display_success "网络配置已应用" "如当前会话断连,请通过控制台确认 bridge / bond / VLAN 和路由状态。"
return 0
fi
if [[ -n "$backup_path" && -f "$backup_path" ]]; then
log_warn "新网络配置应用失败,正在尝试自动恢复备份。"
cp "$backup_path" "$HOST_NETWORK_INTERFACES_FILE"
if ifreload -a >/dev/null 2>&1; then
display_error "网络配置应用失败,已自动回滚" "请审阅 $HOST_NETWORK_INTERFACES_STAGED_FILE 与备份 $backup_path 后再重试。"
return 1
fi
display_error "网络配置应用失败,且自动回滚未能重新加载" "请立即通过控制台检查 $HOST_NETWORK_INTERFACES_FILE$HOST_NETWORK_INTERFACES_STAGED_FILE 与备份 $backup_path"
return 1
fi
display_error "网络配置应用失败" "未获取到可用备份,需立即通过控制台检查 $HOST_NETWORK_INTERFACES_FILE"
return 1
}

View File

@@ -0,0 +1,137 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
ipv6_helper_detect_host_readiness() {
clear
show_menu_header "IPv6 宿主机就绪度"
echo -e "${CYAN}全局 IPv6 地址:${NC}"
ip -6 -o addr show scope global 2>/dev/null | sed 's/^/ /' || true
echo -e "${CYAN}IPv6 默认路由:${NC}"
ip -6 route show default 2>/dev/null | sed 's/^/ /' || true
echo -e "${CYAN}IPv6 连通性测试:${NC}"
if ping -6 -c 2 -W 2 2606:4700:4700::1111 >/dev/null 2>&1; then
echo " Cloudflare DNS IPv6 连通正常"
else
echo " Cloudflare DNS IPv6 连通失败"
fi
echo "$UI_DIVIDER"
}
ipv6_helper_detect_vm_readiness() {
clear
show_menu_header "VM IPv6 就绪度Guest Agent 最佳)"
local vmid name ips
while read -r vmid name _; do
[[ -n "$vmid" && "$vmid" != "VMID" ]] || continue
ips="$(qm guest cmd "$vmid" network-get-interfaces 2>/dev/null | grep -oE '([0-9a-fA-F]{0,4}:){2,}[0-9a-fA-F]{0,4}(/[0-9]+)?' | grep -v '^fe80' | sort -u | tr '\n' ' ')"
if [[ -n "$ips" ]]; then
printf ' VM %s (%s): %s\n' "$vmid" "$name" "$ips"
else
printf ' VM %s (%s): 无法通过 Guest Agent 获取 IPv6可能未安装 agent 或未启动)\n' "$vmid" "$name"
fi
done < <(qm list 2>/dev/null)
echo "$UI_DIVIDER"
}
ipv6_helper_configure_passthrough() {
local bridge_name preserved tmp block
bridge_name="$(host_network_select_bridge_name)"
local rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$bridge_name" ]] || return 1
tmp=$(mktemp)
cp "$HOST_NETWORK_INTERFACES_FILE" "$tmp"
preserved="$(host_network_collect_preserved_family_options "$HOST_NETWORK_INTERFACES_FILE" "$bridge_name" inet6)"
host_network_remove_iface_family_from_candidate "$tmp" "$bridge_name" inet6
host_network_ensure_auto_line_in_candidate "$tmp" "$bridge_name"
block="$(host_network_build_family_stanza "$bridge_name" inet6 'auto|||accept-ra 2' "$preserved")"
host_network_append_text_to_candidate "$tmp" "$block"
host_network_commit_candidate "$tmp" "为桥接 $bridge_name 启用 IPv6 透传 / SLAAC" "会调整桥接的 IPv6 获取方式和 RA 行为。" "若上游 IPv6/RA 不可用或桥接承载管理口,可能导致地址和默认路由改变。" "请确认上游已提供 IPv6 RA并通过控制台执行。"
rm -f "$tmp"
}
ipv6_helper_configure_nat6() {
local bridge_name uplink prefix bridge_addr preserved tmp block
bridge_name="$(host_network_select_bridge_name)"
local rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$bridge_name" ]] || return 1
uplink="$(host_network_select_interface_name)"
rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$uplink" ]] || return 1
[[ "$uplink" != "$bridge_name" ]] || {
display_error "NAT6 上联接口不能与桥接接口相同"
return 1
}
command -v ip6tables >/dev/null 2>&1 || {
display_error "未检测到 ip6tables" "请先确认系统已安装并启用 IPv6 NAT 所需工具。"
return 1
}
host_network_iface_exists "$uplink" || {
display_error "上联接口不存在: $uplink"
return 1
}
read -p "请输入 NAT6 内网前缀(示例 fd10:10:10::/64: " prefix
host_network_validate_static_address inet6 "$prefix" || {
display_error "前缀格式无效: $prefix" "请使用类似 fd10:10:10::/64 的 IPv6 前缀。"
return 1
}
read -p "请输入桥接 IPv6 地址(示例 fd10:10:10::1/64: " bridge_addr
host_network_validate_static_address inet6 "$bridge_addr" || {
display_error "桥接 IPv6 地址格式无效: $bridge_addr"
return 1
}
tmp=$(mktemp)
cp "$HOST_NETWORK_INTERFACES_FILE" "$tmp"
preserved="$(host_network_collect_preserved_family_options "$HOST_NETWORK_INTERFACES_FILE" "$bridge_name" inet6)"
host_network_remove_iface_family_from_candidate "$tmp" "$bridge_name" inet6
host_network_ensure_auto_line_in_candidate "$tmp" "$bridge_name"
block=$(cat <<EOF_NAT6
iface $bridge_name inet6 static
$(while IFS= read -r line; do [[ -n "$line" ]] && printf ' %s\n' "$line"; done <<< "$preserved") address $bridge_addr
post-up sysctl -w net.ipv6.conf.all.forwarding=1 >/dev/null
post-up ip6tables -t nat -C POSTROUTING -s $prefix -o $uplink -j MASQUERADE || ip6tables -t nat -A POSTROUTING -s $prefix -o $uplink -j MASQUERADE
post-down ip6tables -t nat -D POSTROUTING -s $prefix -o $uplink -j MASQUERADE || true
EOF_NAT6
)
host_network_append_text_to_candidate "$tmp" "$block"
host_network_commit_candidate "$tmp" "为桥接 $bridge_name 配置 NAT6" "会开启 IPv6 转发并对 $prefix 执行 NAT6 出口伪装。" "错误的 uplink、前缀或防火墙策略会导致 IPv6 业务不可达。" "请确认上游具备 IPv6 出口、ip6tables 可用,并已在控制台中准备回滚。"
rm -f "$tmp"
}
ipv6_helper_test_connectivity() {
local target
read -p "请输入要测试的 IPv6 目标 [2606:4700:4700::1111]: " target
target="${target:-2606:4700:4700::1111}"
clear
show_menu_header "IPv6 连通性测试"
echo -e "${CYAN}ping -6 ${target}${NC}"
ping -6 -c 4 -W 2 "$target" 2>&1 | sed 's/^/ /'
echo "$UI_DIVIDER"
}
ipv6_helper_menu() {
while true; do
clear
show_menu_header "IPv6 助手"
host_network_show_risk_banner
show_menu_option "1" "检测宿主机 IPv6 就绪度"
show_menu_option "2" "检测 VM IPv6 就绪度Guest Agent"
show_menu_option "3" "一键配置桥接 IPv6 透传 / SLAAC"
show_menu_option "4" "一键配置桥接 NAT6"
show_menu_option "5" "测试 IPv6 连通性"
show_menu_option "0" "返回"
show_menu_footer
read -p "请选择操作 [0-5]: " choice
case "$choice" in
1) ipv6_helper_detect_host_readiness ;;
2) ipv6_helper_detect_vm_readiness ;;
3) ipv6_helper_configure_passthrough ;;
4) ipv6_helper_configure_nat6 ;;
5) ipv6_helper_test_connectivity ;;
0) return ;;
*) log_error "无效选择" ;;
esac
pause_function
done
}

View File

@@ -0,0 +1,503 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
host_network_validate_systemd_link_name() {
local name="$1"
[[ -n "$name" && ${#name} -le 15 ]] || return 1
[[ "$name" != "." && "$name" != ".." && "$name" != "all" && "$name" != "default" ]] || return 1
[[ "$name" =~ ^[A-Za-z0-9_.-]+$ ]] || return 1
[[ "$name" =~ ^[0-9]+$ ]] && return 1
}
host_network_systemd_link_dir() {
echo "/etc/systemd/network"
}
host_network_systemd_link_file_for_mac() {
local mac="$1"
local lower_mac
lower_mac="$(echo "$mac" | tr '[:upper:]' '[:lower:]')"
echo "$(host_network_systemd_link_dir)/10-pve-tools-${lower_mac//:/-}.link"
}
host_network_systemd_link_get_value() {
local file="$1"
local section="$2"
local key="$3"
[[ -f "$file" ]] || return 1
awk -v section="$section" -v key="$key" '
BEGIN { in_section=0 }
{
line=$0
sub(/[[:space:]]*#.*/, "", line)
sub(/^[[:space:]]+/, "", line)
sub(/[[:space:]]+$/, "", line)
if (line == "") next
if (line ~ /^\[[^]]+\]$/) {
in_section = (line == "[" section "]")
next
}
if (in_section && line ~ ("^" key "[[:space:]]*=[[:space:]]*")) {
sub("^" key "[[:space:]]*=[[:space:]]*", "", line)
gsub(/^"/, "", line)
gsub(/"$/, "", line)
print line
exit
}
}
' "$file"
}
host_network_systemd_link_file_has_binding() {
local file="$1"
local mac="$2"
local name="$3"
local lower_mac file_mac file_name
file_mac="$(host_network_systemd_link_get_value "$file" "Match" "MACAddress" 2>/dev/null || true)"
file_name="$(host_network_systemd_link_get_value "$file" "Link" "Name" 2>/dev/null || true)"
lower_mac="$(echo "$mac" | tr '[:upper:]' '[:lower:]')"
file_mac="$(echo "$file_mac" | tr '[:upper:]' '[:lower:]')"
[[ "$file_mac" == "$lower_mac" && "$file_name" == "$name" ]]
}
host_network_systemd_link_list_managed_files() {
local dir
dir="$(host_network_systemd_link_dir)"
[[ -d "$dir" ]] || return 0
find "$dir" -maxdepth 1 -type f -name '10-pve-tools-*.link' 2>/dev/null | sort
}
host_network_systemd_link_find_conflicts() {
local mac="$1"
local name="$2"
local target_file="$3"
local dir file file_mac file_name search_dirs=()
for dir in /etc/systemd/network /run/systemd/network /usr/local/lib/systemd/network /usr/lib/systemd/network; do
[[ -d "$dir" ]] && search_dirs+=("$dir")
done
(( ${#search_dirs[@]} > 0 )) || return 0
while IFS= read -r -d '' file; do
[[ "$file" == "$target_file" ]] && continue
file_mac="$(host_network_systemd_link_get_value "$file" "Match" "MACAddress" 2>/dev/null || true)"
file_name="$(host_network_systemd_link_get_value "$file" "Link" "Name" 2>/dev/null || true)"
file_mac="$(echo "$file_mac" | tr '[:upper:]' '[:lower:]')"
if [[ "$file_mac" == "$mac" ]]; then
printf '%s|MACAddress=%s\n' "$file" "$file_mac"
elif [[ "$file_name" == "$name" ]]; then
printf '%s|Name=%s\n' "$file" "$file_name"
fi
done < <(find "${search_dirs[@]}" -maxdepth 1 -type f -name '*.link' -print0 2>/dev/null)
}
host_network_systemd_link_write_binding() {
local mac="$1"
local name="$2"
local lower_mac target_dir target_file tmp backup_path=""
lower_mac="$(echo "$mac" | tr '[:upper:]' '[:lower:]')"
target_dir="$(host_network_systemd_link_dir)"
target_file="$(host_network_systemd_link_file_for_mac "$lower_mac")"
mkdir -p "$target_dir" 2>/dev/null || {
log_error "无法创建 systemd .link 目录: $target_dir"
return 1
}
if [[ -f "$target_file" ]]; then
if ! backup_file "$target_file" backup_path >/dev/null 2>&1; then
log_error "无法备份现有 systemd .link 文件: $target_file"
return 1
fi
fi
tmp="$(mktemp "${target_dir}/.pve-tools-link.XXXXXX")" || {
log_error "无法创建临时文件: $target_dir"
return 1
}
cat > "$tmp" <<EOF
# Generated by PVE-Tools
[Match]
MACAddress=$lower_mac
[Link]
Name=$name
EOF
chmod 0644 "$tmp" >/dev/null 2>&1 || true
if ! mv -f "$tmp" "$target_file"; then
rm -f "$tmp" >/dev/null 2>&1 || true
if [[ -n "$backup_path" && -f "$backup_path" ]]; then
cp -a "$backup_path" "$target_file" >/dev/null 2>&1 || true
else
rm -f "$target_file" >/dev/null 2>&1 || true
fi
log_error "写入 systemd .link 文件失败: $target_file"
return 1
fi
local verify_mac verify_name
verify_mac="$(host_network_systemd_link_get_value "$target_file" "Match" "MACAddress" 2>/dev/null || true)"
verify_name="$(host_network_systemd_link_get_value "$target_file" "Link" "Name" 2>/dev/null || true)"
verify_mac="$(echo "$verify_mac" | tr '[:upper:]' '[:lower:]')"
if [[ "$verify_mac" != "$lower_mac" || "$verify_name" != "$name" ]]; then
if [[ -n "$backup_path" && -f "$backup_path" ]]; then
cp -a "$backup_path" "$target_file" >/dev/null 2>&1 || rm -f "$target_file" >/dev/null 2>&1 || true
else
rm -f "$target_file" >/dev/null 2>&1 || true
fi
log_error "写入后的 systemd .link 规则校验失败: $target_file"
return 1
fi
printf '%s\n' "$target_file"
}
host_network_systemd_link_remove_binding_by_mac() {
local mac="$1"
local target_file backup_path=""
target_file="$(host_network_systemd_link_file_for_mac "$mac")"
[[ -f "$target_file" ]] || return 0
if ! backup_file "$target_file" backup_path >/dev/null 2>&1; then
log_error "无法备份待删除的 systemd .link 文件: $target_file"
return 1
fi
if ! rm -f "$target_file"; then
log_error "删除 systemd .link 文件失败: $target_file"
return 1
fi
[[ -e "$target_file" ]] && {
log_error "systemd .link 文件仍然存在: $target_file"
return 1
}
}
host_network_legacy_udev_rules_file() {
echo "/etc/udev/rules.d/70-persistent-net.rules"
}
host_network_legacy_udev_list_bindings() {
local file line mac legacy_name
file="$(host_network_legacy_udev_rules_file)"
[[ -f "$file" ]] || return 0
while IFS= read -r line; do
[[ -n "$line" ]] || continue
mac="$(echo "$line" | sed -n 's/.*ATTR{address}=="\([^"]*\)".*/\1/p')"
legacy_name="$(echo "$line" | sed -n 's/.*NAME="\([^"]*\)".*/\1/p')"
[[ -n "$mac" && -n "$legacy_name" ]] || continue
printf '%s|%s\n' "$mac" "$legacy_name"
done < "$file"
}
host_network_legacy_udev_name_conflicts() {
local mac="$1"
local name="$2"
local file line legacy_mac legacy_name lower_mac
file="$(host_network_legacy_udev_rules_file)"
[[ -f "$file" ]] || return 1
lower_mac="$(echo "$mac" | tr '[:upper:]' '[:lower:]')"
while IFS= read -r line; do
[[ -n "$line" ]] || continue
legacy_mac="$(echo "$line" | sed -n 's/.*ATTR{address}=="\([^"]*\)".*/\1/p')"
legacy_name="$(echo "$line" | sed -n 's/.*NAME="\([^"]*\)".*/\1/p')"
[[ -n "$legacy_mac" && -n "$legacy_name" ]] || continue
legacy_mac="$(echo "$legacy_mac" | tr '[:upper:]' '[:lower:]')"
if [[ "$legacy_name" == "$name" && "$legacy_mac" != "$lower_mac" ]]; then
return 0
fi
done < "$file"
return 1
}
host_network_legacy_udev_prune_binding() {
local mac="$1"
local file tmp backup_path="" lower_mac
file="$(host_network_legacy_udev_rules_file)"
[[ -f "$file" ]] || return 0
if ! backup_file "$file" backup_path >/dev/null 2>&1; then
log_error "无法备份 legacy udev 规则文件: $file"
return 1
fi
lower_mac="$(echo "$mac" | tr '[:upper:]' '[:lower:]')"
tmp="$(mktemp)" || {
log_error "无法创建临时文件用于清理 legacy udev 规则"
return 1
}
awk -v mac="$lower_mac" '
{
line=$0
line_lc=tolower(line)
if (index(line_lc, "ATTR{address}==\"" mac "\"") > 0) next
print
}
' "$file" > "$tmp"
if ! mv -f "$tmp" "$file"; then
rm -f "$tmp" >/dev/null 2>&1 || true
if [[ -n "$backup_path" && -f "$backup_path" ]]; then
cp -a "$backup_path" "$file" >/dev/null 2>&1 || true
fi
log_error "清理 legacy udev 规则失败: $file"
return 1
fi
[[ -s "$file" ]] || rm -f "$file"
}
host_network_interface_has_config_reference() {
local iface_name="$1"
awk -v iface_name="$iface_name" '
{
line=$0
sub(/[[:space:]]*#.*/, "", line)
sub(/^[[:space:]]+/, "", line)
sub(/[[:space:]]+$/, "", line)
if (line == "") next
n=split(line, fields, /[[:space:]]+/)
if (fields[1] == "iface" && n >= 2 && fields[2] == iface_name) {
print line
exit
}
if (fields[1] == "auto" || fields[1] ~ /^allow-/ || fields[1] == "bridge-ports" || fields[1] == "bond-slaves" || fields[1] == "vlan-raw-device") {
for (i=2; i<=n; i++) {
if (fields[i] == iface_name) {
print line
exit
}
}
}
}
' "$HOST_NETWORK_INTERFACES_FILE" 2>/dev/null | grep -q .
}
host_network_mac_binding_has_dependency() {
local iface_name="$1"
host_network_interface_has_master_dependency "$iface_name" && return 0
host_network_interface_has_config_reference "$iface_name"
}
host_network_show_mac_bindings() {
local file iface mac link_name
echo -e "${CYAN}当前 MAC 地址绑定systemd .link 规则):${NC}"
if host_network_systemd_link_list_managed_files | grep -q .; then
while IFS= read -r file; do
[[ -n "$file" ]] || continue
mac="$(host_network_systemd_link_get_value "$file" "Match" "MACAddress" 2>/dev/null || true)"
link_name="$(host_network_systemd_link_get_value "$file" "Link" "Name" 2>/dev/null || true)"
[[ -n "$mac" && -n "$link_name" ]] || continue
iface="$(host_network_mac_to_iface "$mac" 2>/dev/null || true)"
if [[ -n "$iface" ]]; then
printf ' %s → %s (当前接口: %s)\n' "$mac" "$link_name" "$iface"
else
printf ' %s → %s (当前未识别)\n' "$mac" "$link_name"
fi
done < <(host_network_systemd_link_list_managed_files)
else
echo " (无)"
fi
echo "$UI_DIVIDER"
echo -e "${YELLOW}旧式 udev 绑定(兼容清理中):${NC}"
if host_network_legacy_udev_list_bindings | grep -q .; then
while IFS='|' read -r mac link_name; do
[[ -n "$mac" ]] || continue
printf ' %s → %s\n' "$mac" "$link_name"
done < <(host_network_legacy_udev_list_bindings)
else
echo " (无)"
fi
echo "$UI_DIVIDER"
echo -e "${CYAN}物理网卡 MAC 地址列表:${NC}"
local entry iface_name iface_mac
while IFS='|' read -r iface_name iface_mac; do
[[ -n "$iface_name" ]] || continue
link_name="$(host_network_systemd_link_get_value "$(host_network_systemd_link_file_for_mac "$iface_mac")" "Link" "Name" 2>/dev/null || true)"
if [[ -n "$link_name" ]]; then
printf ' [%s] %s (已绑定为 %s)\n' "$iface_name" "$iface_mac" "$link_name"
else
printf ' [%s] %s\n' "$iface_name" "$iface_mac"
fi
done < <(host_network_get_physical_ifaces_with_mac)
}
host_network_create_mac_binding() {
local entries=()
local entry iface_name iface_mac idx pick fixed_name target_file backup_path link_name
while IFS='|' read -r iface_name iface_mac; do
[[ -n "$iface_name" ]] || continue
entries+=("$iface_name|$iface_mac")
done < <(host_network_get_physical_ifaces_with_mac)
if (( ${#entries[@]} == 0 )); then
display_error "未发现物理网卡" "请确认系统存在带有 PCI 设备的网络接口。"
return 1
fi
echo -e "${CYAN}可用物理网卡(按 MAC 地址固定命名):${NC}"
idx=1
for entry in "${entries[@]}"; do
iface_name="${entry%%|*}"
iface_mac="${entry##*|}"
link_name="$(host_network_systemd_link_get_value "$(host_network_systemd_link_file_for_mac "$iface_mac")" "Link" "Name" 2>/dev/null || true)"
if [[ -n "$link_name" ]]; then
printf ' [%d] %s | %s (已绑定: %s)\n' "$idx" "$iface_name" "$iface_mac" "$link_name"
else
printf ' [%d] %s | %s\n' "$idx" "$iface_name" "$iface_mac"
fi
idx=$((idx + 1))
done
echo "$UI_DIVIDER"
read -p "请选择要固定命名的网卡序号 (0 返回): " pick
[[ "$pick" == "0" ]] && return 0
[[ "$pick" =~ ^[0-9]+$ ]] || { display_error "无效选择"; return 1; }
if (( pick < 1 || pick > ${#entries[@]} )); then
display_error "选择超出范围"
return 1
fi
entry="${entries[$((pick - 1))]}"
iface_name="${entry%%|*}"
iface_mac="${entry##*|}"
target_file="$(host_network_systemd_link_file_for_mac "$iface_mac")"
read -r -p "请输入固定接口名称(如 lan0、pve-eth0: " fixed_name
[[ -n "$fixed_name" ]] || { display_error "接口名称不能为空"; return 1; }
host_network_validate_systemd_link_name "$fixed_name" || {
display_error "接口名称不合法: $fixed_name" "名称需为 1-15 位只允许字母、数字、_、.、-,且不能是纯数字或保留名。"
return 1
}
if [[ "$fixed_name" != "$iface_name" ]] && host_network_iface_exists "$fixed_name"; then
display_error "目标接口名称已被占用: $fixed_name" "请改用未被现有接口使用的名称。"
return 1
fi
if host_network_mac_binding_has_dependency "$iface_name"; then
display_error "该网卡已被宿主机网络配置引用" "请先迁移或删除依赖该接口的 bridge / bond / VLAN / direct iface 配置,再修改命名。"
return 1
fi
if host_network_legacy_udev_name_conflicts "$iface_mac" "$fixed_name"; then
display_error "旧式 udev 规则中已存在同名绑定" "请先人工处理 /etc/udev/rules.d/70-persistent-net.rules 中的同名规则,再继续迁移。"
return 1
fi
mapfile -t conflicts < <(host_network_systemd_link_find_conflicts "$iface_mac" "$fixed_name" "$target_file")
if (( ${#conflicts[@]} > 0 )); then
local conflict_report="检测到其他 systemd .link 文件已经匹配相同 MAC 或同名接口:"
local conflict
for conflict in "${conflicts[@]}"; do
conflict_report+=$'\n - '"${conflict%%|*} (${conflict##*|})"
done
display_error "检测到 systemd .link 规则冲突" "$conflict_report"
return 1
fi
if [[ -f "$target_file" ]] && host_network_systemd_link_file_has_binding "$target_file" "$iface_mac" "$fixed_name"; then
display_success "固定命名规则已存在" "文件: $target_file;重启宿主机后仍会保持该命名。"
return 0
fi
if ! confirm_high_risk_action "为 MAC 地址 ${iface_mac} 写入固定命名 ${fixed_name}" "将生成 ${target_file},让 systemd 根据 MAC 在启动时重命名接口。" "固定名与现有接口或其他 .link 文件冲突时,接口命名可能变得不可预测。" "请确认名称唯一,并准备好控制台或带外管理手段以便回滚。" "BIND-LINK"; then
return 0
fi
if ! host_network_legacy_udev_prune_binding "$iface_mac"; then
display_error "清理旧式 udev 绑定失败" "请先处理 /etc/udev/rules.d/70-persistent-net.rules 后重试。"
return 1
fi
if ! target_file="$(host_network_systemd_link_write_binding "$iface_mac" "$fixed_name")"; then
display_error "MAC 地址固定命名写入失败" "请先处理冲突提示,或检查 /etc/systemd/network 目录权限。"
return 1
fi
display_success "MAC 地址固定命名规则已写入" "文件: $target_file;建议重启宿主机后生效。"
}
host_network_delete_mac_binding() {
local file mac link_name entries=() idx pick file_list
file_list="$(host_network_systemd_link_list_managed_files)" || file_list=""
while IFS= read -r file; do
[[ -n "$file" ]] || continue
mac="$(host_network_systemd_link_get_value "$file" "Match" "MACAddress" 2>/dev/null || true)"
link_name="$(host_network_systemd_link_get_value "$file" "Link" "Name" 2>/dev/null || true)"
[[ -n "$mac" && -n "$link_name" ]] || continue
entries+=("$file|$mac|$link_name")
done <<< "$file_list"
if (( ${#entries[@]} == 0 )); then
display_error "没有可删除的 MAC 绑定规则" "当前没有 PVE-Tools 管理的 systemd .link 文件。"
return 1
fi
echo -e "${CYAN}已存在的 MAC 地址绑定:${NC}"
idx=1
for entry in "${entries[@]}"; do
file="${entry%%|*}"
mac="${entry#*|}"
mac="${mac%%|*}"
link_name="${entry##*|}"
printf ' [%d] %s → %s (%s)\n' "$idx" "$mac" "$link_name" "$(basename "$file")"
idx=$((idx + 1))
done
echo "$UI_DIVIDER"
read -p "请选择要删除的绑定序号 (0 返回): " pick
[[ "$pick" == "0" ]] && return 0
[[ "$pick" =~ ^[0-9]+$ ]] || { display_error "无效选择"; return 1; }
if (( pick < 1 || pick > ${#entries[@]} )); then
display_error "选择超出范围"
return 1
fi
entry="${entries[$((pick - 1))]}"
file="${entry%%|*}"
mac="${entry#*|}"
mac="${mac%%|*}"
link_name="${entry##*|}"
if ! confirm_action "删除 MAC 地址固定命名 ${mac}${link_name}"; then
return 0
fi
if ! host_network_legacy_udev_prune_binding "$mac"; then
display_error "清理旧式 udev 绑定失败" "请检查 /etc/udev/rules.d/70-persistent-net.rules 的权限与内容。"
return 1
fi
if ! host_network_systemd_link_remove_binding_by_mac "$mac"; then
display_error "删除 MAC 地址固定命名失败" "请检查文件权限或磁盘状态。"
return 1
fi
display_success "MAC 地址固定命名已删除: ${mac}${link_name}" "如需恢复,请重新创建对应 .link 文件。"
}
host_network_mac_binding_menu() {
while true; do
clear
show_menu_header "MAC 地址绑定管理"
echo -e "${YELLOW}说明:通过 MAC 地址而不是接口名来固定网卡命名,避免重启或硬件变更后网卡顺序变化导致网络配置失效。${NC}"
echo -e "${YELLOW}绑定后会在 /etc/systemd/network/ 中生成 .link 规则;若系统残留旧式 udev 规则,脚本会尝试同步清理。${NC}"
echo "$UI_DIVIDER"
host_network_show_mac_bindings
echo "$UI_DIVIDER"
show_menu_option "1" "创建 MAC 地址绑定"
show_menu_option "2" "删除 MAC 地址绑定"
show_menu_option "0" "返回"
show_menu_footer
read -p "请选择操作 [0-2]: " choice
case "$choice" in
1) host_network_create_mac_binding ;;
2) host_network_delete_mac_binding ;;
0) return ;;
*) log_error "无效选择" ;;
esac
pause_function
done
}

View File

@@ -0,0 +1,117 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
host_network_build_vlan_block() {
local iface_name="$1"
local raw_dev="$2"
local mtu="$3"
local ipv4_cfg="$4"
local ipv6_cfg="$5"
local v4_method v4_addr v4_gw v4_extra
local v6_method v6_addr v6_gw v6_extra
IFS='|' read -r v4_method v4_addr v4_gw v4_extra <<< "$ipv4_cfg"
IFS='|' read -r v6_method v6_addr v6_gw v6_extra <<< "$ipv6_cfg"
[[ "$v4_method" == "none" ]] && v4_method="manual"
printf 'auto %s\n' "$iface_name"
printf 'iface %s inet %s\n' "$iface_name" "$v4_method"
printf ' vlan-raw-device %s\n' "$raw_dev"
[[ -n "$mtu" ]] && printf ' mtu %s\n' "$mtu"
[[ "$v4_method" == "static" && -n "$v4_addr" ]] && printf ' address %s\n' "$v4_addr"
[[ "$v4_method" == "static" && -n "$v4_gw" ]] && printf ' gateway %s\n' "$v4_gw"
if [[ "$v6_method" != "none" ]]; then
printf '\niface %s inet6 %s\n' "$iface_name" "$v6_method"
[[ -n "$v6_addr" ]] && printf ' address %s\n' "$v6_addr"
[[ -n "$v6_gw" ]] && printf ' gateway %s\n' "$v6_gw"
[[ -n "$v6_extra" ]] && printf ' %s\n' "$v6_extra"
fi
}
host_network_create_vlan() {
local raw_dev vlan_id iface_name mtu ipv4_cfg ipv6_cfg tmp block
raw_dev="$(host_network_select_interface_name)"
local rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$raw_dev" ]] || return 1
host_network_iface_exists "$raw_dev" || {
display_error "上联接口不存在: $raw_dev"
return 1
}
read -p "请输入 VLAN ID: " vlan_id
[[ "$vlan_id" =~ ^[0-9]+$ && "$vlan_id" -ge 1 && "$vlan_id" -le 4094 ]] || {
display_error "VLAN ID 不合法: $vlan_id" "请输入 1-4094 之间的整数。"
return 1
}
iface_name="${raw_dev}.${vlan_id}"
read -p "请输入 VLAN 子接口名称 [$iface_name]: " iface_name
iface_name="${iface_name:-${raw_dev}.${vlan_id}}"
host_network_validate_iface_name "$iface_name" || {
display_error "接口名称不合法: $iface_name" "接口名需为 1-15 位,且仅允许字母、数字、._:-。"
return 1
}
if host_network_get_all_interface_names | grep -qx "$iface_name"; then
display_error "接口已存在: $iface_name"
return 1
fi
read -p "MTU留空保持默认: " mtu
host_network_validate_mtu "$mtu" || return 1
echo "$UI_DIVIDER"
ipv4_cfg="$(host_network_collect_family_config inet create)" || return 1
ipv6_cfg="$(host_network_collect_family_config inet6 create)" || return 1
tmp=$(mktemp)
cp "$HOST_NETWORK_INTERFACES_FILE" "$tmp"
block="$(host_network_build_vlan_block "$iface_name" "$raw_dev" "$mtu" "$ipv4_cfg" "$ipv6_cfg")"
host_network_remove_iface_from_candidate "$tmp" "$iface_name"
host_network_append_text_to_candidate "$tmp" "# PVE-TOOLS HOST IFACE BEGIN $iface_name
$block
# PVE-TOOLS HOST IFACE END $iface_name"
host_network_commit_candidate "$tmp" "创建 VLAN 子接口 $iface_name" "VLAN 子接口会改写宿主机链路与上联 VLAN 规划。" "VLAN ID、上联接口或网关错误时相关业务与管理流量会中断。" "请确认上联交换机配置、VLAN ID、地址规划和控制台回滚路径。"
rm -f "$tmp"
}
host_network_delete_vlan() {
local iface_name
iface_name="$(host_network_select_vlan_name)"
local rc=$?
[[ "$rc" -eq 2 ]] && return 0
[[ -n "$iface_name" ]] || return 1
if grep -Eq "(bridge-ports|bond-slaves|vlan-raw-device)[[:space:]].*\b${iface_name}\b" "$HOST_NETWORK_INTERFACES_FILE" 2>/dev/null; then
display_error "检测到其他接口仍依赖 $iface_name" "请先删除依赖关系后再试。"
return 1
fi
local tmp
tmp=$(mktemp)
cp "$HOST_NETWORK_INTERFACES_FILE" "$tmp"
host_network_remove_iface_from_candidate "$tmp" "$iface_name"
host_network_commit_candidate "$tmp" "删除 VLAN 子接口 $iface_name" "删除 VLAN 子接口会中断承载在该 VLAN 上的宿主机和 VM 网络。" "业务中断、管理口断连和路由丢失都可能立即发生。" "请先确认该 VLAN 不再承担管理面或生产流量。"
rm -f "$tmp"
}
host_network_vlan_menu() {
while true; do
clear
show_menu_header "VLAN 子接口管理"
host_network_show_risk_banner
echo -e "${CYAN}当前 VLAN 子接口:${NC}"
if host_network_get_configured_vlans | awk 'NF{print " - "$0}'; then :; fi
echo "$UI_DIVIDER"
show_menu_option "1" "列出 VLAN 子接口"
show_menu_option "2" "创建 VLAN 子接口"
show_menu_option "3" "删除 VLAN 子接口"
show_menu_option "0" "返回"
show_menu_footer
read -p "请选择操作 [0-3]: " choice
case "$choice" in
1) host_network_show_current_overview ;;
2) host_network_create_vlan ;;
3) host_network_delete_vlan ;;
0) return ;;
*) log_error "无效选择" ;;
esac
pause_function
done
}

View File

@@ -0,0 +1,143 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
pve9_ceph() {
sver=`cat /etc/debian_version |awk -F"." '{print $1}'`
case "$sver" in
13 )
sver="trixie"
;;
12 )
sver="bookworm"
;;
* )
sver=""
;;
esac
if [ ! $sver ];then
log_error "版本不支持!"
pause_function
return
fi
log_info "ceph-squid目前仅支持PVE8和9"
[[ ! -d /etc/apt/backup ]] && mkdir -p /etc/apt/backup
[[ ! -d /etc/apt/sources.list.d ]] && mkdir -p /etc/apt/sources.list.d
[[ -e /etc/apt/sources.list.d/ceph.sources ]] && mv /etc/apt/sources.list.d/ceph.sources /etc/apt/backup/ceph.sources.bak
[[ -e /etc/apt/sources.list.d/ceph.list ]] && mv /etc/apt/sources.list.d/ceph.list /etc/apt/backup/ceph.list.bak
[[ -e /usr/share/perl5/PVE/CLI/pveceph.pm ]] && cp -rf /usr/share/perl5/PVE/CLI/pveceph.pm /etc/apt/backup/pveceph.pm.bak
sed -i 's|http://download.proxmox.com|https://mirrors.tuna.tsinghua.edu.cn/proxmox|g' /usr/share/perl5/PVE/CLI/pveceph.pm
cat > /etc/apt/sources.list.d/ceph.list <<-EOF
deb https://mirrors.tuna.tsinghua.edu.cn/proxmox/debian/ceph-squid ${sver} no-subscription
EOF
log_success "添加ceph-squid源完成!"
}
#---------PVE8/9添加ceph-squid源-----------
#---------PVE7/8添加ceph-quincy源-----------
pve8_ceph() {
sver=`cat /etc/debian_version |awk -F"." '{print $1}'`
case "$sver" in
12 )
sver="bookworm"
;;
11 )
sver="bullseye"
;;
* )
sver=""
;;
esac
if [ ! $sver ];then
log_error "版本不支持!"
pause_function
return
fi
log_info "ceph-quincy目前仅支持PVE7和8"
[[ ! -d /etc/apt/backup ]] && mkdir -p /etc/apt/backup
[[ ! -d /etc/apt/sources.list.d ]] && mkdir -p /etc/apt/sources.list.d
[[ -e /etc/apt/sources.list.d/ceph.sources ]] && mv /etc/apt/sources.list.d/ceph.sources /etc/apt/backup/ceph.sources.bak
[[ -e /etc/apt/sources.list.d/ceph.list ]] && mv /etc/apt/sources.list.d/ceph.list /etc/apt/backup/ceph.list.bak
[[ -e /usr/share/perl5/PVE/CLI/pveceph.pm ]] && cp -rf /usr/share/perl5/PVE/CLI/pveceph.pm /etc/apt/backup/pveceph.pm.bak
sed -i 's|http://download.proxmox.com|https://mirrors.tuna.tsinghua.edu.cn/proxmox|g' /usr/share/perl5/PVE/CLI/pveceph.pm
cat > /etc/apt/sources.list.d/ceph.list <<-EOF
deb https://mirrors.tuna.tsinghua.edu.cn/proxmox/debian/ceph-quincy ${sver} main
EOF
log_success "添加ceph-quincy源完成!"
}
#---------PVE7/8添加ceph-quincy源-----------
# 待办
#---------PVE7/8添加ceph-quincy源-----------
#---------PVE一键卸载ceph-----------
remove_ceph() {
log_warn "会卸载ceph并删除所有ceph相关文件"
systemctl stop ceph-mon.target && systemctl stop ceph-mgr.target && systemctl stop ceph-mds.target && systemctl stop ceph-osd.target
rm -rf /etc/systemd/system/ceph*
killall -9 ceph-mon ceph-mgr ceph-mds ceph-osd
rm -rf /var/lib/ceph/mon/* && rm -rf /var/lib/ceph/mgr/* && rm -rf /var/lib/ceph/mds/* && rm -rf /var/lib/ceph/osd/*
pveceph purge
apt purge -y ceph-mon ceph-osd ceph-mgr ceph-mds
apt purge -y ceph-base ceph-mgr-modules-core
rm -rf /etc/ceph && rm -rf /etc/pve/ceph.conf && rm -rf /etc/pve/priv/ceph.* && rm -rf /var/log/ceph && rm -rf /etc/pve/ceph && rm -rf /var/lib/ceph
[[ -e /etc/apt/sources.list.d/ceph.sources ]] && mv /etc/apt/sources.list.d/ceph.sources /etc/apt/backup/ceph.sources.bak
log_success "已成功卸载ceph."
}
#---------PVE一键卸载ceph-----------
#---------第三方小工具管理-----------
# 小工具配置
# FastPVE - PVE 虚拟机快速下载
ceph_management_menu() {
while true; do
clear
show_menu_header "Ceph管理"
show_menu_option "1" "添加 ${CYAN}ceph-squid${NC} 源 (PVE8/9专用)"
show_menu_option "2" "添加 ${CYAN}ceph-quincy${NC} 源 (PVE7/8专用)"
show_menu_option "3" "${RED}卸载 Ceph${NC} (完全移除Ceph)"
echo "${UI_DIVIDER}"
show_menu_option "0" "返回主菜单"
show_menu_footer
echo
read -p "请选择 [0-3]: " ceph_choice
echo
case $ceph_choice in
1)
pve9_ceph
;;
2)
pve8_ceph
;;
3)
remove_ceph
;;
0)
break
;;
*)
log_error "无效选择,请重新输入"
;;
esac
echo
pause_function
done
}
# 救砖:恢复官方 pve-qemu-kvm

View File

@@ -0,0 +1,49 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
menu_storage_disk() {
while true; do
clear
show_menu_header "存储与硬盘"
show_menu_option "1" "存储位置查询面板 ${CYAN}(ISO/备份/SCP路径)${NC}"
show_menu_option "2" "磁盘挂载向导 ${CYAN}(复用已有 ext4/xfs 分区)${NC}"
show_menu_option "3" "合并 ${CYAN}local${NC}${CYAN}local-lvm${NC}"
show_menu_option "4" "${CYAN}Ceph${NC} 管理 (安装/卸载/换源)"
show_menu_option "5" "硬盘休眠配置 ${CYAN}(hdparm)${NC}"
show_menu_option "6" "垃圾清理 ${CYAN}(缓存/备份/快照扫描)${NC}"
show_menu_option "7" "${RED}删除 Swap 分区${NC}"
echo "$UI_DIVIDER"
show_menu_option "0" "返回主菜单"
show_menu_footer
read -p "请选择操作 [0-7]: " choice
case $choice in
1) pve_storage_location_panel ;;
2) pve_storage_mount_wizard ;;
3) merge_local_storage ;;
4) ceph_management_menu ;;
5)
lsblk -o NAME,MODEL,TYPE,SIZE,MOUNTPOINT | grep disk
read -p "请输入要配置休眠的硬盘盘符 (如 sdb, 不含/dev/): " disk_name
if [ -b "/dev/$disk_name" ]; then
read -p "请输入休眠时间 (1-255, 120=10分钟, 240=20分钟, 0=禁用): " sleep_val
if [[ "$sleep_val" =~ ^[0-9]+$ ]]; then
hdparm -S "$sleep_val" "/dev/$disk_name"
log_success "配置已应用到 /dev/$disk_name"
else
log_error "无效的时间值"
fi
else
log_error "未找到磁盘 /dev/$disk_name"
fi
;;
6) garbage_cleanup_menu ;;
7) remove_swap ;;
0) return ;;
*) log_error "无效选择" ;;
esac
pause_function
done
}
# 二级菜单:工具与关于

View File

@@ -0,0 +1,35 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
merge_local_storage() {
log_step "准备合并存储空间,让小硬盘发挥最大价值"
log_warn "重要提醒:此操作会删除 local-lvm请确保重要数据已备份"
echo -e "${YELLOW}您确定要继续吗?这个操作不可逆哦${NC}"
read -p "输入 'yes' 确认继续,其他任意键取消: " -r
if [[ ! $REPLY == "yes" ]]; then
log_info "明智的选择!操作已取消"
return
fi
# 检查 local-lvm 是否存在
if ! lvdisplay /dev/pve/data &> /dev/null; then
log_warn "没有找到 local-lvm 分区,可能已经合并过了"
return
fi
log_info "正在删除 local-lvm 分区..."
lvremove -f /dev/pve/data
log_info "正在扩容 local 分区..."
lvextend -l +100%FREE /dev/pve/root
log_info "正在扩展文件系统..."
resize2fs /dev/pve/root
log_success "存储合并完成!现在空间更充裕了"
log_warn "温馨提示:请在 Web UI 中删除 local-lvm 存储配置,并编辑 local 存储勾选所有内容类型"
}
# 删除 Swap 分配给主分区

View File

@@ -0,0 +1,156 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
pve_storage_mount_wizard_validate_storage_id() {
local storage_id="$1"
if [[ -z "$storage_id" || ! "$storage_id" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]{1,30}$ ]]; then
display_error "存储 ID 不合法: $storage_id" "请使用字母、数字、点、下划线或短横线,长度 2-31。"
return 1
fi
if pvesm status 2>/dev/null | awk 'NR>1{print $1}' | grep -qx "$storage_id"; then
display_error "存储 ID 已存在: $storage_id" "请换一个名称,避免覆盖现有 PVE 存储。"
return 1
fi
}
pve_storage_mount_wizard_validate_mountpoint() {
local mountpoint="$1"
if [[ -z "$mountpoint" || "$mountpoint" != /* ]]; then
display_error "挂载点必须是绝对路径"
return 1
fi
case "$mountpoint" in
/|/boot|/boot/*|/etc|/etc/*|/usr|/usr/*|/var|/var/lib/vz|/var/lib/vz/*|/root|/root/*)
display_error "挂载点过于危险: $mountpoint" "建议使用 /mnt/pve/<存储ID> 或 /mnt/data/<名称>。"
return 1
;;
esac
}
pve_storage_mount_wizard() {
block_non_pve9_destructive "磁盘挂载向导" || return 1
local candidates idx pick line dev fstype size uuid mountpoint storage_id content_types current_mount backup_path fstab_line output
if ! command -v lsblk >/dev/null 2>&1 || ! command -v pvesm >/dev/null 2>&1; then
display_error "缺少 lsblk 或 pvesm" "请在 Proxmox VE 节点上运行。"
return 1
fi
candidates="$(lsblk -rP -o NAME,TYPE,FSTYPE,SIZE,MOUNTPOINT,UUID 2>/dev/null | awk '
function val(key, pat, out) {
pat = key "=\"[^\"]*\""
if (match($0, pat)) {
out = substr($0, RSTART + length(key) + 2, RLENGTH - length(key) - 3)
return out
}
return ""
}
val("TYPE") == "part" && (val("FSTYPE") == "ext4" || val("FSTYPE") == "xfs") {
print val("NAME") "|" val("FSTYPE") "|" val("SIZE") "|" val("MOUNTPOINT") "|" val("UUID")
}
')"
clear
show_menu_header "磁盘挂载向导"
echo -e "${RED}高风险提醒:${NC} 该向导只挂载已有 ext4/xfs 分区并添加 dir 存储,不会格式化磁盘。仍请先确认数据已备份。"
echo -e "${YELLOW}已挂载分区、无文件系统分区、LVM/ZFS 成员不会作为候选。${NC}"
echo "$UI_DIVIDER"
if [[ -z "$candidates" ]]; then
display_error "未发现可挂载的 ext4/xfs 分区" "可用 lsblk -f 手动确认磁盘状态。"
return 1
fi
idx=1
while IFS='|' read -r dev fstype size current_mount uuid; do
printf " [%d] %-22s %-6s %-8s 当前挂载:%s UUID:%s\n" "$idx" "$dev" "$fstype" "$size" "${current_mount:--}" "${uuid:--}"
idx=$((idx + 1))
done <<< "$candidates"
echo "$UI_DIVIDER"
read -p "请选择要挂载的分区序号 (0 返回): " pick
pick="${pick:-0}"
[[ "$pick" == "0" ]] && return 0
[[ "$pick" =~ ^[0-9]+$ ]] || return 1
line="$(echo "$candidates" | awk -v n="$pick" 'NR==n{print}')"
[[ -n "$line" ]] || return 1
IFS='|' read -r dev fstype size current_mount uuid <<< "$line"
if [[ -n "$current_mount" && "$current_mount" != "-" ]]; then
display_error "该分区已挂载: $current_mount" "为避免误操作,请先人工确认是否已被使用。"
return 1
fi
if [[ -z "$uuid" || "$uuid" == "-" ]]; then
uuid="$(blkid -s UUID -o value "$dev" 2>/dev/null || true)"
fi
if [[ -z "$uuid" ]]; then
display_error "无法读取分区 UUID: $dev" "请检查 blkid 输出。"
return 1
fi
read -p "请输入新的 PVE 存储 ID [data-$(basename "$dev")]: " storage_id
storage_id="${storage_id:-data-$(basename "$dev")}"
pve_storage_mount_wizard_validate_storage_id "$storage_id" || return 1
read -p "请输入挂载点 [/mnt/pve/$storage_id]: " mountpoint
mountpoint="${mountpoint:-/mnt/pve/$storage_id}"
pve_storage_mount_wizard_validate_mountpoint "$mountpoint" || return 1
if findmnt -rn --target "$mountpoint" >/dev/null 2>&1; then
display_error "挂载点已被占用: $mountpoint" "请换一个空目录。"
return 1
fi
read -p "请输入 PVE 内容类型 [images,iso,backup,vztmpl,snippets]: " content_types
content_types="${content_types:-images,iso,backup,vztmpl,snippets}"
content_types="$(echo "$content_types" | tr -d ' ')"
if [[ ! "$content_types" =~ ^[A-Za-z0-9_,]+$ ]]; then
display_error "内容类型包含非法字符" "示例: images,iso,backup"
return 1
fi
clear
show_menu_header "确认挂载配置"
echo -e "${CYAN}分区:${NC} $dev"
echo -e "${CYAN}文件系统:${NC} $fstype"
echo -e "${CYAN}UUID:${NC} $uuid"
echo -e "${CYAN}挂载点:${NC} $mountpoint"
echo -e "${CYAN}PVE 存储 ID:${NC} $storage_id"
echo -e "${CYAN}内容类型:${NC} $content_types"
echo "$UI_DIVIDER"
if ! confirm_high_risk_action "挂载已有分区并添加 PVE dir 存储" "会修改 /etc/fstab、创建挂载点并向 PVE 添加新的 dir 存储。" "UUID 或挂载点选错可能导致启动时挂载失败;存储 ID 选错会造成 PVE 存储配置混乱。" "请确认该分区确实是要复用的数据盘,已完成外部备份,并准备控制台回滚方式。" "MOUNT-DIR"; then
return 0
fi
mkdir -p "$mountpoint" || {
display_error "无法创建挂载点: $mountpoint"
return 1
}
backup_file "/etc/fstab" backup_path >/dev/null 2>&1 || true
if ! grep -Eq "UUID=${uuid}[[:space:]]" /etc/fstab 2>/dev/null; then
fstab_line="UUID=${uuid} ${mountpoint} ${fstype} defaults,nofail 0 2"
printf '%s\n' "$fstab_line" >> /etc/fstab
log_success "已写入 /etc/fstab: $fstab_line"
else
log_warn "/etc/fstab 已存在 UUID=${uuid},跳过重复写入。"
fi
if ! output="$(mount "$mountpoint" 2>&1)"; then
[[ -n "$backup_path" && -f "$backup_path" ]] && cp -a "$backup_path" /etc/fstab >/dev/null 2>&1 || true
echo "$output" | sed 's/^/ /'
display_error "挂载失败,已尝试回滚 /etc/fstab" "请检查分区、文件系统和挂载点。"
return 1
fi
if ! pvesm add dir "$storage_id" --path "$mountpoint" --content "$content_types" >/dev/null 2>&1; then
umount "$mountpoint" >/dev/null 2>&1 || true
[[ -n "$backup_path" && -f "$backup_path" ]] && cp -a "$backup_path" /etc/fstab >/dev/null 2>&1 || true
display_error "PVE dir 存储添加失败,已尝试卸载并回滚 /etc/fstab" "请检查 pvesm 输出或手动添加存储。"
return 1
fi
display_success "已有分区挂载并添加为 PVE dir 存储完成" "存储 ID: $storage_id,路径: $mountpoint"
}

View File

@@ -0,0 +1,52 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
pve_storage_location_panel() {
if ! command -v pvesm >/dev/null 2>&1; then
display_error "未找到 pvesm" "请在 Proxmox VE 节点上运行。"
return 1
fi
clear
show_menu_header "存储位置查询面板"
echo -e "${YELLOW}说明:${NC} 文件级存储会显示可通过 SCP/SFTP 操作的目录LVM/ZFS/RBD 等块存储不适合直接上传 ISO/备份文件。"
echo "$UI_DIVIDER"
local store type status total used avail percent contents root usage
printf "%-18s %-10s %-8s %-18s %s\n" "存储" "类型" "状态" "内容类型" "根路径/说明"
echo "$UI_DIVIDER"
while IFS='|' read -r store type status total used avail percent; do
[[ -n "$store" ]] || continue
contents="$(pve_storage_config_value "$store" content | tr -d ' ')"
if pve_storage_file_backend "$type" && root="$(pve_storage_mount_path "$store" "$type")"; then
usage="$(pve_storage_usage_text "$root")"
printf "%-18s %-10s %-8s %-18s %s (%s)\n" "$store" "$type" "$status" "${contents:-unknown}" "$root" "$usage"
else
printf "%-18s %-10s %-8s %-18s %s\n" "$store" "$type" "$status" "${contents:-unknown}" "块/对象存储:请通过 PVE 管理卷,不直接 SCP 文件"
fi
done < <(pve_storage_status_records)
echo "$UI_DIVIDER"
echo -e "${CYAN}常用上传路径:${NC}"
local path
local found=false
while IFS='|' read -r store type status path; do
found=true
usage="$(pve_storage_usage_text "$path")"
echo -e " ${GREEN}[ISO]${NC} $store ($type): $path"
echo " 磁盘: $usage"
echo " scp ./file.iso root@<PVE-IP>:\"$path/\""
done < <(pve_storage_list_content_paths iso)
[[ "$found" == true ]] || echo " 未发现支持 iso 内容类型的文件级存储。"
found=false
while IFS='|' read -r store type status path; do
found=true
usage="$(pve_storage_usage_text "$path")"
echo -e " ${GREEN}[备份]${NC} $store ($type): $path"
echo " 磁盘: $usage"
echo " scp ./vzdump-qemu-100.vma.zst root@<PVE-IP>:\"$path/\""
done < <(pve_storage_list_content_paths backup)
[[ "$found" == true ]] || echo " 未发现支持 backup 内容类型的文件级存储。"
}

View File

@@ -0,0 +1,41 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
remove_swap() {
log_step "准备释放 Swap 空间给系统使用"
log_warn "注意:删除 Swap 后请确保内存充足!"
echo -e "${YELLOW}您确定要删除 Swap 分区吗?${NC}"
read -p "输入 'yes' 确认继续,其他任意键取消: " -r
if [[ ! $REPLY == "yes" ]]; then
log_info "好的,操作已取消"
return
fi
# 检查 swap 是否存在
if ! lvdisplay /dev/pve/swap &> /dev/null; then
log_warn "没有找到 swap 分区,可能已经删除过了"
return
fi
log_info "正在关闭 Swap..."
swapoff /dev/mapper/pve-swap
log_info "正在修改启动配置..."
backup_file "/etc/fstab"
sed -i 's|^/dev/pve/swap|# /dev/pve/swap|g' /etc/fstab
log_info "正在删除 swap 分区..."
lvremove -f /dev/pve/swap
log_info "正在扩展系统分区..."
lvextend -l +100%FREE /dev/mapper/pve-root
log_info "正在扩展文件系统..."
resize2fs /dev/mapper/pve-root
log_success "Swap 删除完成!系统空间更宽裕了"
}
# 更新系统

View File

@@ -0,0 +1,67 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
menu_tools_about() {
while true; do
clear
show_menu_header "工具与关于"
show_menu_option "1" "系统信息概览"
show_menu_option "2" "应急救砖工具箱"
show_menu_option "3" "本地脚本快捷更新"
show_menu_option "4" "${RED}本地脚本快捷卸载${NC}"
show_menu_option "5" "给作者点个 Star 吧"
show_menu_option "0" "返回主菜单"
show_menu_footer
read -p "请选择操作 [0-5]: " choice
case $choice in
1) show_system_info ;;
2) show_menu_rescue ;;
3) pve_tools_local_update ;;
4) pve_tools_local_uninstall ;;
5)
echo -e "${YELLOW}项目地址https://github.com/PVE-Tools/PVE-Tools-9${NC}"
echo -e "${GREEN}您的支持是我更新的最大动力,谢谢喵~${NC}"
;;
0) return ;;
*) log_error "无效选择" ;;
esac
pause_function
done
}
# 一键配置
show_menu_rescue() {
while true; do
clear
show_menu_header "应急救砖工具箱"
echo -e "${RED}警告:本工具箱用于修复因误操作导致的系统问题,请谨慎使用!${NC}"
echo
show_menu_option "1" "恢复官方 Web UI 文件 (重装 pve-manager / proxmox-widget-toolkit)"
show_menu_option "2" "恢复官方 pve-qemu-kvm (修复修改版 QEMU 问题)"
show_menu_option "3" "清理驱动黑名单 (i915/snd_hda_intel)"
show_menu_option "0" "返回主菜单"
show_menu_footer
read -p "请选择操作 [0-3]: " choice
case $choice in
1) restore_proxmoxlib ;;
2) restore_qemu_kvm ;;
3)
if confirm_action "确定要清理显卡和声卡驱动的黑名单设置吗?"; then
log_info "正在清理黑名单配置..."
sed -i '/blacklist i915/d' /etc/modprobe.d/pve-blacklist.conf
sed -i '/blacklist snd_hda_intel/d' /etc/modprobe.d/pve-blacklist.conf
sed -i '/blacklist snd_hda_codec_hdmi/d' /etc/modprobe.d/pve-blacklist.conf
log_info "正在更新 initramfs..."
update-initramfs -u -k all
log_success "黑名单清理完成,请重启系统"
fi
;;
0) return ;;
*) log_error "无效选择" ;;
esac
pause_function
done
}
# 二级菜单:系统优化

View File

@@ -0,0 +1,285 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
check_update() {
log_info "正在检查更新..."
# 显示进度提示
echo -ne "[....] 正在检查更新...\033[0K\r"
local update_urls prefer_mirror preferred_version_url preferred_update_url preferred_script_url
local mirror_version_url="${GITHUB_MIRROR_PREFIX}${VERSION_FILE_URL}"
local mirror_update_url="${GITHUB_MIRROR_PREFIX}${UPDATE_FILE_URL}"
update_urls="$(pve_tools_choose_update_urls)"
IFS='|' read -r prefer_mirror preferred_version_url preferred_update_url preferred_script_url <<< "$update_urls"
if [[ "$prefer_mirror" -eq 1 ]]; then
log_info "当前地区为: ${USER_COUNTRY_CODE:-unknown},使用镜像源检查更新..."
else
log_info "使用 GitHub 源检查更新"
fi
remote_content=$(pve_tools_download_url "$preferred_version_url" 10)
if [ -z "$remote_content" ]; then
if [[ $prefer_mirror -eq 1 ]]; then
log_warn "镜像源连接失败,尝试使用 GitHub 源..."
remote_content=$(pve_tools_download_url "$VERSION_FILE_URL" 10)
else
log_warn "GitHub 连接失败,尝试使用镜像源..."
remote_content=$(pve_tools_download_url "$mirror_version_url" 10)
fi
fi
# 清除进度显示
echo -ne "\033[0K\r"
# 如果下载失败
if [ -z "$remote_content" ]; then
log_warn "网络连接失败,跳过版本检查"
echo "提示:您可以手动访问以下地址检查更新:"
echo "https://github.com/PVE-Tools/PVE-Tools-9"
echo "按回车键继续..."
read -r
return
fi
# 提取版本号和更新日志
remote_version=$(echo "$remote_content" | head -1 | tr -d '[:space:]')
version_changelog=$(echo "$remote_content" | tail -n +2)
if [ -z "$remote_version" ]; then
log_warn "获取的版本信息格式不正确"
return
fi
detailed_changelog=$(pve_tools_download_url "$preferred_update_url" 10)
if [ -z "$detailed_changelog" ]; then
if [[ $prefer_mirror -eq 1 ]]; then
log_warn "镜像源更新日志获取失败,尝试使用 GitHub 源..."
detailed_changelog=$(pve_tools_download_url "$UPDATE_FILE_URL" 10)
else
log_warn "GitHub 更新日志获取失败,尝试使用镜像源..."
detailed_changelog=$(pve_tools_download_url "$mirror_update_url" 10)
fi
fi
# 比较版本
if pve_tools_version_gt "$remote_version" "$CURRENT_VERSION"; then
echo -e "${UI_HEADER}"
echo -e "${YELLOW}🚀 发现新版本!推荐更新以获取最新功能和修复喵${NC}"
echo -e "----------------------------------------------"
echo -e "当前版本: ${WHITE}$CURRENT_VERSION${NC}"
echo -e "最新版本: ${GREEN}$remote_version${NC}"
echo -e "${BLUE}更新日志:${NC}"
# 如果获取到了详细的更新日志
if [ -n "$detailed_changelog" ]; then
# 使用 sed 提取第一行作为标题,其余行缩进显示
local first_line=$(echo "$detailed_changelog" | head -n 1)
local rest_lines=$(echo "$detailed_changelog" | tail -n +2)
echo -e " ${CYAN}$first_line${NC}"
if [ -n "$rest_lines" ]; then
echo "$rest_lines" | sed 's/^/ /'
fi
else
# 格式化显示版本文件中的更新内容
if [ -n "$version_changelog" ] && [ "$version_changelog" != "$remote_version" ]; then
echo "$version_changelog" | sed 's/^/ /'
else
echo -e " ${YELLOW}- 请访问项目页面获取详细更新内容${NC}"
fi
fi
echo -e "----------------------------------------------"
echo -e "${CYAN}官方文档与最新脚本:${NC}"
echo -e "🔗 https://pve.oowo.cc (推荐)"
echo -e "🔗 https://github.com/PVE-Tools/PVE-Tools-9"
echo -e "${UI_FOOTER}"
echo -e "${GREEN}回车键${NC} 进入主菜单..."
read -r
else
log_success "当前已是最新版本 ($CURRENT_VERSION) 放心用吧"
fi
}
pve_tools_local_update() {
local current_script="${BASH_SOURCE[0]}"
local resolved_script backup_dir backup_path tmp_script update_urls prefer_mirror version_url update_url script_url
local remote_content remote_version detailed_changelog fallback_script_url
if [[ -z "$current_script" || ! -f "$current_script" ]]; then
display_error "无法定位当前脚本文件" "请使用本地文件方式运行脚本后再执行更新。"
return 1
fi
resolved_script="$(readlink -f "$current_script" 2>/dev/null || realpath "$current_script" 2>/dev/null || echo "$current_script")"
if [[ ! -w "$resolved_script" ]]; then
display_error "当前脚本不可写: $resolved_script" "请使用 root 或确认脚本文件权限后重试。"
return 1
fi
update_urls="$(pve_tools_choose_update_urls)"
IFS='|' read -r prefer_mirror version_url update_url script_url <<< "$update_urls"
remote_content="$(pve_tools_download_url "$version_url" 15)"
if [[ -z "$remote_content" ]]; then
if [[ "$prefer_mirror" -eq 1 ]]; then
log_warn "镜像源版本文件获取失败,尝试 GitHub 源。"
remote_content="$(pve_tools_download_url "$VERSION_FILE_URL" 15)"
else
log_warn "GitHub 版本文件获取失败,尝试镜像源。"
remote_content="$(pve_tools_download_url "${GITHUB_MIRROR_PREFIX}${VERSION_FILE_URL}" 15)"
fi
fi
if [[ -z "$remote_content" ]]; then
display_error "无法获取远程版本信息" "网络不通或 GitHub/镜像源不可用,已保持本地脚本不变。"
return 1
fi
remote_version="$(echo "$remote_content" | head -1 | tr -d '[:space:]')"
if [[ -z "$remote_version" ]]; then
display_error "远程版本文件格式异常" "已保持本地脚本不变。"
return 1
fi
detailed_changelog="$(pve_tools_download_url "$update_url" 15)"
if [[ -z "$detailed_changelog" ]]; then
if [[ "$prefer_mirror" -eq 1 ]]; then
detailed_changelog="$(pve_tools_download_url "$UPDATE_FILE_URL" 15)"
else
detailed_changelog="$(pve_tools_download_url "${GITHUB_MIRROR_PREFIX}${UPDATE_FILE_URL}" 15)"
fi
fi
clear
show_menu_header "本地脚本快捷更新"
echo -e "${CYAN}当前脚本:${NC} $resolved_script"
echo -e "${CYAN}当前版本:${NC} $CURRENT_VERSION"
echo -e "${CYAN}远程版本:${NC} $remote_version"
echo -e "${CYAN}下载来源:${NC} $script_url"
echo "$UI_DIVIDER"
if pve_tools_version_gt "$remote_version" "$CURRENT_VERSION"; then
echo -e "${GREEN}发现可更新版本。${NC}"
elif [[ "$remote_version" == "$CURRENT_VERSION" ]]; then
echo -e "${YELLOW}远程版本与当前版本一致,也可以选择强制覆盖本地脚本。${NC}"
else
echo -e "${YELLOW}远程版本看起来不高于当前版本,默认不建议覆盖。${NC}"
fi
echo "$UI_DIVIDER"
if [[ -n "$detailed_changelog" ]]; then
echo -e "${CYAN}更新日志预览:${NC}"
echo "$detailed_changelog" | head -n 30 | sed 's/^/ /'
echo "$UI_DIVIDER"
fi
read -p "是否下载并替换本地脚本?(yes/no) [no]: " confirm
confirm="${confirm:-no}"
if [[ "$confirm" != "yes" && "$confirm" != "YES" ]]; then
log_info "已取消脚本更新。"
return 0
fi
backup_dir="/var/backups/pve-tools"
mkdir -p "$backup_dir" || {
display_error "无法创建备份目录: $backup_dir" "已保持本地脚本不变。"
return 1
}
backup_path="${backup_dir}/PVE-Tools.sh.bak"
tmp_script="$(mktemp /tmp/pve-tools-update.XXXXXX)" || {
display_error "无法创建临时文件" "已保持本地脚本不变。"
return 1
}
if ! cp -a "$resolved_script" "$backup_path"; then
rm -f "$tmp_script"
display_error "备份当前脚本失败" "目标备份: $backup_path。已保持本地脚本不变。"
return 1
fi
log_success "当前脚本已备份: $backup_path"
if ! pve_tools_download_url "$script_url" 30 > "$tmp_script"; then
fallback_script_url="$PVE_TOOLS_SCRIPT_URL"
[[ "$script_url" == "$PVE_TOOLS_SCRIPT_URL" ]] && fallback_script_url="${GITHUB_MIRROR_PREFIX}${PVE_TOOLS_SCRIPT_URL}"
log_warn "首选脚本下载失败,尝试备用源: $fallback_script_url"
if ! pve_tools_download_url "$fallback_script_url" 30 > "$tmp_script"; then
rm -f "$tmp_script"
display_error "下载新脚本失败" "已保留原脚本,备份位于 $backup_path"
return 1
fi
fi
if ! grep -q '^CURRENT_VERSION=' "$tmp_script" || ! bash -n "$tmp_script"; then
rm -f "$tmp_script"
cp -a "$backup_path" "$resolved_script" >/dev/null 2>&1 || true
display_error "下载的新脚本校验失败,已自动回滚" "请稍后重试或手动检查下载源。"
return 1
fi
if ! cp -a "$tmp_script" "$resolved_script"; then
cp -a "$backup_path" "$resolved_script" >/dev/null 2>&1 || true
rm -f "$tmp_script"
display_error "替换脚本失败,已尝试自动回滚" "备份文件: $backup_path"
return 1
fi
chmod +x "$resolved_script" >/dev/null 2>&1 || true
rm -f "$tmp_script"
display_success "本地脚本更新完成" "备份文件: $backup_path;请重新运行脚本以加载新版本。"
}
pve_tools_local_uninstall() {
local current_script resolved_script clean_cron delete_targets=()
current_script="${BASH_SOURCE[0]}"
resolved_script="$(readlink -f "$current_script" 2>/dev/null || realpath "$current_script" 2>/dev/null || echo "$current_script")"
clear
show_menu_header "本地脚本快捷卸载"
echo -e "${RED}将删除 PVE-Tools 本地脚本及脚本产生的日志/备份/导出目录。${NC}"
echo -e "${YELLOW}不会删除 PVE 自身软件包、VM 磁盘或系统存储配置。${NC}"
echo "$UI_DIVIDER"
[[ -f "$resolved_script" ]] && delete_targets+=("$resolved_script")
[[ -f "/var/log/pve-tools.log" ]] && delete_targets+=("/var/log/pve-tools.log")
[[ -d "/var/backups/pve-tools" ]] && delete_targets+=("/var/backups/pve-tools/")
[[ -d "/var/lib/pve-tools" ]] && delete_targets+=("/var/lib/pve-tools/")
if [[ -f "$VM_BACKUP_CRON_FILE" ]]; then
read -p "是否同时清理 VM 定时备份任务 ${VM_BACKUP_CRON_FILE}(yes/no) [no]: " clean_cron
clean_cron="${clean_cron:-no}"
if [[ "$clean_cron" == "yes" || "$clean_cron" == "YES" ]]; then
delete_targets+=("$VM_BACKUP_CRON_FILE")
fi
fi
if (( ${#delete_targets[@]} == 0 )); then
log_warn "未发现可删除的 PVE-Tools 本地文件。"
return 0
fi
echo -e "${CYAN}将删除以下文件/目录:${NC}"
printf ' - %s\n' "${delete_targets[@]}"
echo "$UI_DIVIDER"
if ! confirm_high_risk_action "卸载 PVE-Tools 本地脚本及关联文件" "会永久删除上方列出的脚本、日志、备份和导出目录。" "误删备份目录会丢失脚本自动备份的历史配置副本;删除 cron 会停止后续定时备份。" "请确认已经导出仍需保留的备份/配置文件,并确认删除清单只包含 PVE-Tools 文件。" "UNINSTALL"; then
return 0
fi
local target
for target in "${delete_targets[@]}"; do
if [[ -d "${target%/}" ]]; then
rm -rf -- "${target%/}"
echo -e "${GREEN}已删除目录:${NC} ${target%/}"
elif [[ -e "$target" ]]; then
rm -f -- "$target"
echo -e "${GREEN}已删除文件:${NC} $target"
fi
done
if [[ "$clean_cron" == "yes" || "$clean_cron" == "YES" ]]; then
systemctl restart cron 2>/dev/null || service cron restart 2>/dev/null || true
fi
echo -e "${GREEN}卸载完成。当前脚本文件如已删除,本次会话结束后请直接退出。${NC}"
}

View File

@@ -0,0 +1,28 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
show_system_info() {
log_step "为您展示系统运行状况"
echo
echo "${UI_BORDER}"
echo -e " ${H1}系统信息概览${NC}"
echo "${UI_DIVIDER}"
echo -e " ${PRIMARY}PVE 版本:${NC} $(pveversion | head -n1)"
echo -e " ${PRIMARY}内核版本:${NC} $(uname -r)"
echo -e " ${PRIMARY}CPU 信息:${NC} $(lscpu | grep 'Model name' | sed 's/Model name:[ \t]*//')"
echo -e " ${PRIMARY}CPU 核心:${NC} $(nproc) 核心"
echo -e " ${PRIMARY}系统架构:${NC} $(dpkg --print-architecture)"
echo -e " ${PRIMARY}系统启动:${NC} $(uptime -p | sed 's/up //')"
echo -e " ${PRIMARY}引导类型:${NC} $(if [ -d /sys/firmware/efi ]; then echo UEFI; else echo BIOS; fi)"
echo -e " ${PRIMARY}系统负载:${NC} $(uptime | awk -F'load average:' '{print $2}')"
echo -e " ${PRIMARY}内存使用:${NC} $(free -h | grep Mem | awk '{print $3"/"$2}')"
echo -e " ${PRIMARY}磁盘使用:${NC}"
df -h | grep -E '^/dev/' | awk '{print " "$1" "$3"/"$2" ("$5")"}'
echo -e " ${PRIMARY}网络接口:${NC}"
ip -br addr show | awk '{print " "$1" "$3}'
echo -e " ${PRIMARY}当前时间:${NC} $(date)"
echo "${UI_FOOTER}"
}
# 主菜单

View File

@@ -0,0 +1,143 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
security_report_item() {
local level="$1"
local title="$2"
local detail="$3"
local advice="$4"
local color="$GREEN"
case "$level" in
) color="$RED"; SECURITY_HIGH_COUNT=$((SECURITY_HIGH_COUNT + 1)) ;;
) color="$YELLOW"; SECURITY_MEDIUM_COUNT=$((SECURITY_MEDIUM_COUNT + 1)) ;;
) color="$CYAN"; SECURITY_LOW_COUNT=$((SECURITY_LOW_COUNT + 1)) ;;
esac
echo -e "${color}[${level}]${NC} ${title}"
echo " 状态: $detail"
echo " 建议: $advice"
echo
}
security_pve_firewall_enabled() {
local file="$1"
[[ -f "$file" ]] || return 1
awk '
/^\[OPTIONS\]/ { in_options = 1; next }
/^\[/ { in_options = 0 }
in_options && $1 == "enable:" && $2 == "1" { found = 1 }
END { exit found ? 0 : 1 }
' "$file"
}
security_list_public_listeners() {
if ! command -v ss >/dev/null 2>&1; then
return 0
fi
ss -ltn 2>/dev/null | awk 'NR>1 {
addr=$4
n=split(addr, parts, ":")
port=parts[n]
if (addr ~ /^0\.0\.0\.0:/ || addr ~ /^\[::\]:/ || addr ~ /^\*:/ || addr ~ /^:::/) {
print port "|" addr
}
}' | sort -u
}
security_risk_check() {
local ssh_port password_auth empty_passwords fail2ban_state cluster_fw node_fw dangerous_ports upgradable uid0_extra loose_files
SECURITY_HIGH_COUNT=0
SECURITY_MEDIUM_COUNT=0
SECURITY_LOW_COUNT=0
clear
show_menu_header "安全风险检查"
echo -e "${YELLOW}本功能只读取配置和状态,不修改系统。${NC}"
echo "$UI_DIVIDER"
ssh_port="$(security_sshd_effective_option port)"
ssh_port="${ssh_port:-unknown}"
password_auth="$(security_sshd_effective_option passwordauthentication)"
password_auth="${password_auth:-unknown}"
if [[ "$ssh_port" == "22" ]]; then
security_report_item "中" "SSH 使用默认端口 22" "当前端口: $ssh_port" "可进入本菜单的 SSH 一键加固,改为高位端口并同步防火墙规则。"
else
security_report_item "低" "SSH 端口检查" "当前端口: $ssh_port" "保持资产台账和防火墙规则同步。"
fi
if [[ "$password_auth" == "yes" ]]; then
security_report_item "高" "SSH 允许密码登录" "PasswordAuthentication: yes" "确认密钥登录可用后,使用 SSH 一键加固禁用密码登录。"
else
security_report_item "低" "SSH 密码登录检查" "PasswordAuthentication: $password_auth" "定期检查 authorized_keys 和账号权限。"
fi
fail2ban_state="$(systemctl is-active fail2ban 2>/dev/null || echo inactive)"
if [[ "$fail2ban_state" != "active" ]]; then
security_report_item "中" "fail2ban 未运行" "状态: $fail2ban_state" "安装并启用 fail2ban至少保护 sshd jail。"
else
security_report_item "低" "fail2ban 状态" "状态: active" "可用 fail2ban-client status sshd 复查封禁策略。"
fi
if security_pve_firewall_enabled "$PVE_CLUSTER_FIREWALL_FILE"; then
cluster_fw="enabled"
else
cluster_fw="disabled"
fi
node_fw="/etc/pve/nodes/$(hostname)/host.fw"
if security_pve_firewall_enabled "$node_fw"; then
node_fw="enabled"
else
node_fw="disabled"
fi
if [[ "$cluster_fw" != "enabled" || "$node_fw" != "enabled" ]]; then
security_report_item "中" "PVE 防火墙未完全启用" "datacenter=$cluster_fw, node=$node_fw" "进入宿主机网络与防火墙菜单,审查规则后启用数据中心和节点防火墙。"
else
security_report_item "低" "PVE 防火墙状态" "datacenter=enabled, node=enabled" "继续保持规则最小开放。"
fi
dangerous_ports="$(security_list_public_listeners | awk -F'|' '$1 ~ /^(21|23|25|111|139|445|3306|5432|6379|9200|9300)$/ || ($1 >= 5900 && $1 <= 5999) {print $0}')"
if [[ -n "$dangerous_ports" ]]; then
security_report_item "中" "检测到常见高风险端口对外监听" "$(echo "$dangerous_ports" | tr '\n' ' ')" "确认是否确需暴露;不需要时关闭服务或用防火墙限制来源。"
else
security_report_item "低" "服务暴露检查" "未发现常见高风险端口对外监听" "仍建议人工审查 ss -ltnup 输出。"
fi
upgradable="$(apt list --upgradable 2>/dev/null | awk 'NR>1 {print}' | head -n 10)"
if [[ -n "$upgradable" ]]; then
security_report_item "中" "存在待升级软件包" "$(echo "$upgradable" | wc -l) 个以上待升级条目(仅展示前 10 个)" "在维护窗口执行 apt update && apt upgrade并重启到新内核后复查。"
echo "$upgradable" | sed 's/^/ /'
echo
else
security_report_item "低" "系统更新检查" "当前 apt 缓存未显示待升级包" "该检查不主动刷新 apt 缓存;建议定期运行 apt update。"
fi
empty_passwords="$(awk -F: '$2 == "" {print $1}' /etc/shadow 2>/dev/null | tr '\n' ' ')"
uid0_extra="$(awk -F: '$3 == 0 && $1 != "root" {print $1}' /etc/passwd 2>/dev/null | tr '\n' ' ')"
if [[ -n "$empty_passwords" || -n "$uid0_extra" ]]; then
security_report_item "高" "账户风险" "空密码账户: ${empty_passwords:-}; 额外 UID0: ${uid0_extra:-}" "立即锁定异常账户或设置强密码,并审查 /etc/passwd 与 /etc/shadow。"
else
security_report_item "低" "账户风险检查" "未发现空密码账户或额外 UID 0 账户" "继续保持最小账号集。"
fi
loose_files=""
for file in /etc/ssh/sshd_config /etc/shadow /etc/pve/storage.cfg "$PVE_CLUSTER_FIREWALL_FILE"; do
[[ -e "$file" ]] || continue
if [[ -w "$file" && ! -O "$file" ]]; then
loose_files+="$file(当前用户可写但非属主) "
fi
if find "$file" -maxdepth 0 -perm /022 2>/dev/null | grep -q .; then
loose_files+="$file(组/其他可写) "
fi
done
if [[ -n "$loose_files" ]]; then
security_report_item "高" "关键配置文件权限过宽" "$loose_files" "恢复 root/系统默认属主和最小写权限,避免普通用户改写关键配置。"
else
security_report_item "低" "关键文件权限检查" "未发现组/其他可写的关键配置文件" "后续变更后继续复查。"
fi
echo "$UI_DIVIDER"
echo -e "${CYAN}汇总:${NC} 高风险 $SECURITY_HIGH_COUNT / 中风险 $SECURITY_MEDIUM_COUNT / 低风险 $SECURITY_LOW_COUNT"
echo -e "${YELLOW}提示:${NC} 该报告不会自动修复系统;需要改配置时请从对应菜单进入并确认风险。"
}

View File

@@ -0,0 +1,26 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
security_center_menu() {
while true; do
clear
show_menu_header "安全中心"
show_menu_option "1" "安全风险检查 ${CYAN}(只读报告)${NC}"
show_menu_option "2" "SSH 一键加固 ${CYAN}(端口/密钥/fail2ban)${NC}"
show_menu_option "0" "返回"
show_menu_footer
local choice
read -p "请选择操作 [0-2]: " choice
case "$choice" in
1) security_risk_check ;;
2) security_ssh_hardening ;;
0) return ;;
*) log_error "无效选择" ;;
esac
pause_function
done
}
# 温度监控管理菜单

View File

@@ -0,0 +1,288 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
security_ssh_service_name() {
local service
for service in ssh.service sshd.service ssh; do
if systemctl list-unit-files "$service" >/dev/null 2>&1 || systemctl status "$service" >/dev/null 2>&1; then
echo "${service%.service}"
return 0
fi
done
echo "ssh"
}
security_sshd_effective_option() {
local key="$1"
local lower_key
lower_key="$(echo "$key" | tr 'A-Z' 'a-z')"
if command -v sshd >/dev/null 2>&1; then
sshd -T 2>/dev/null | awk -v key="$lower_key" '$1 == key {print $2; exit}'
fi
}
security_root_authorized_keys_ready() {
local auth_file="/root/.ssh/authorized_keys"
[[ -f "$auth_file" ]] || return 1
grep -Ev '^[[:space:]]*(#|$)' "$auth_file" >/dev/null 2>&1
}
security_validate_ssh_port() {
local port="$1"
if [[ ! "$port" =~ ^[0-9]+$ ]] || (( port < 1024 || port > 65535 )); then
display_error "SSH 端口不合法: $port" "请使用 1024-65535 之间的高位端口。"
return 1
fi
if (( port == 8006 )); then
display_error "端口 8006 是 PVE Web UI 常用端口" "请换一个端口。"
return 1
fi
}
security_random_ssh_port() {
local port
if command -v shuf >/dev/null 2>&1; then
for _ in {1..20}; do
port="$(shuf -i 20000-60999 -n 1)"
if ! ss -ltn 2>/dev/null | awk '{print $4}' | grep -Eq "[:.]${port}$"; then
echo "$port"
return 0
fi
done
fi
echo "22222"
}
security_ensure_sshd_include() {
local config_file="/etc/ssh/sshd_config"
if ! grep -Eiq '^[[:space:]]*Include[[:space:]]+/etc/ssh/sshd_config\.d/\*\.conf' "$config_file" 2>/dev/null; then
local tmp
tmp="$(mktemp)" || return 1
{
echo "Include /etc/ssh/sshd_config.d/*.conf"
cat "$config_file"
} > "$tmp"
cat "$tmp" > "$config_file"
rm -f "$tmp"
fi
}
security_comment_global_sshd_directives() {
local config_file="/etc/ssh/sshd_config"
local tmp
tmp="$(mktemp)" || return 1
awk '
BEGIN {
in_match = 0
keys["port"] = 1
keys["passwordauthentication"] = 1
keys["kbdinteractiveauthentication"] = 1
keys["challengeresponseauthentication"] = 1
keys["pubkeyauthentication"] = 1
keys["permitemptypasswords"] = 1
}
/^[[:space:]]*Match[[:space:]]/ { in_match = 1 }
{
line = $0
probe = line
sub(/^[[:space:]]*/, "", probe)
split(probe, parts, /[[:space:]]+/)
key = tolower(parts[1])
if (!in_match && line !~ /^[[:space:]]*#/ && keys[key]) {
print "# PVE-Tools disabled global duplicate: " line
} else {
print line
}
}
' "$config_file" > "$tmp" || {
rm -f "$tmp"
return 1
}
cat "$tmp" > "$config_file"
rm -f "$tmp"
}
security_write_sshd_hardening_dropin() {
local port="$1"
local dropin_dir="/etc/ssh/sshd_config.d"
local dropin_file="${dropin_dir}/99-pve-tools-hardening.conf"
mkdir -p "$dropin_dir" || return 1
cat > "$dropin_file" <<EOF
# Managed by PVE-Tools.
# Keep console or out-of-band access available before changing SSH policy.
Port $port
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no
PermitEmptyPasswords no
EOF
}
security_write_fail2ban_sshd_jail() {
local port="$1"
local maxretry="$2"
local bantime="$3"
local findtime="$4"
local jail_dir="/etc/fail2ban/jail.d"
local jail_file="${jail_dir}/pve-tools-sshd.conf"
mkdir -p "$jail_dir" || return 1
cat > "$jail_file" <<EOF
# Managed by PVE-Tools.
[sshd]
enabled = true
port = $port
maxretry = $maxretry
bantime = $bantime
findtime = $findtime
EOF
}
security_install_fail2ban_if_needed() {
if command -v fail2ban-client >/dev/null 2>&1; then
return 0
fi
log_warn "未检测到 fail2ban准备通过 apt 安装。"
if ! apt-get update; then
display_error "apt-get update 失败" "请检查软件源和网络后重试。"
return 1
fi
if ! apt-get install -y fail2ban; then
display_error "fail2ban 安装失败" "请检查软件源和网络后重试。"
return 1
fi
}
security_restore_hardening_backups() {
local ssh_backup="$1"
local config_file="$2"
local dropin_backup="$3"
local dropin_file="$4"
local jail_backup="$5"
local jail_file="$6"
[[ -n "$ssh_backup" && -f "$ssh_backup" ]] && cp -a "$ssh_backup" "$config_file" >/dev/null 2>&1 || true
if [[ -n "$dropin_backup" && -f "$dropin_backup" ]]; then
cp -a "$dropin_backup" "$dropin_file" >/dev/null 2>&1 || true
else
rm -f "$dropin_file" >/dev/null 2>&1 || true
fi
if [[ -n "$jail_backup" && -f "$jail_backup" ]]; then
cp -a "$jail_backup" "$jail_file" >/dev/null 2>&1 || true
else
rm -f "$jail_file" >/dev/null 2>&1 || true
fi
}
security_ssh_hardening() {
block_non_pve9_destructive "SSH 一键加固" || return 1
local current_port new_port maxretry bantime findtime ssh_service ssh_backup="" jail_backup="" dropin_backup=""
local config_file="/etc/ssh/sshd_config"
local jail_file="/etc/fail2ban/jail.d/pve-tools-sshd.conf"
local dropin_file="/etc/ssh/sshd_config.d/99-pve-tools-hardening.conf"
if [[ ! -f "$config_file" ]] || ! command -v sshd >/dev/null 2>&1; then
display_error "未找到 OpenSSH Server 配置" "请确认 openssh-server 已安装。"
return 1
fi
current_port="$(security_sshd_effective_option port)"
current_port="${current_port:-22}"
clear
show_menu_header "SSH 一键加固"
echo -e "${RED}重要:${NC} 禁用密码登录前,必须确认 SSH 密钥登录可用,或你有 PVE 控制台/带外管理方式。"
echo -e "${CYAN}当前 SSH 端口:${NC} $current_port"
echo -e "${CYAN}当前连接:${NC} ${SSH_CONNECTION:-未检测到 SSH_CONNECTION}"
if security_root_authorized_keys_ready; then
echo -e "${GREEN}检测到 /root/.ssh/authorized_keys 中存在公钥。${NC}"
else
echo -e "${RED}未检测到 root 公钥。继续后可能无法再通过密码 SSH 登录。${NC}"
fi
echo "$UI_DIVIDER"
read -p "请输入新的 SSH 端口(留空随机生成高位端口): " new_port
new_port="${new_port:-$(security_random_ssh_port)}"
security_validate_ssh_port "$new_port" || return 1
read -p "fail2ban 最大失败次数 [5]: " maxretry
maxretry="${maxretry:-5}"
[[ "$maxretry" =~ ^[0-9]+$ && "$maxretry" -ge 1 ]] || {
display_error "最大失败次数必须是正整数"
return 1
}
read -p "fail2ban 封禁时间 [1h]: " bantime
bantime="${bantime:-1h}"
[[ "$bantime" =~ ^[0-9]+[smhd]?$ ]] || {
display_error "封禁时间格式不合法" "示例: 3600 或 1h"
return 1
}
read -p "fail2ban 检测时间窗口 [10m]: " findtime
findtime="${findtime:-10m}"
[[ "$findtime" =~ ^[0-9]+[smhd]?$ ]] || {
display_error "检测窗口格式不合法" "示例: 600 或 10m"
return 1
}
clear
show_menu_header "SSH 加固确认"
echo -e "${CYAN}新 SSH 端口:${NC} $new_port"
echo -e "${CYAN}密码登录:${NC} 禁用"
echo -e "${CYAN}密钥登录:${NC} 启用"
echo -e "${CYAN}fail2ban:${NC} maxretry=$maxretry, bantime=$bantime, findtime=$findtime"
echo -e "${YELLOW}执行后请使用:${NC} ssh -p $new_port root@<PVE-IP>"
echo -e "${YELLOW}如启用 PVE/外部防火墙,请同步放行 TCP $new_port,并确认 8006 Web UI 或控制台可用。${NC}"
echo "$UI_DIVIDER"
if ! confirm_high_risk_action "修改 SSH 端口、禁用密码登录并配置 fail2ban" "错误配置可能导致 SSH 无法连接;未准备密钥时会失去密码登录入口。" "当前远程会话可能在 sshd 重启后无法重新连接,需要通过控制台修复。" "请确认密钥登录已测试成功,控制台/带外访问可用,并已记录原端口 $current_port" "SSH-HARDEN"; then
return 0
fi
backup_file "$config_file" ssh_backup >/dev/null 2>&1 || return 1
[[ -f "$jail_file" ]] && backup_file "$jail_file" jail_backup >/dev/null 2>&1 || true
[[ -f "$dropin_file" ]] && backup_file "$dropin_file" dropin_backup >/dev/null 2>&1 || true
if ! security_install_fail2ban_if_needed; then
return 1
fi
if ! security_ensure_sshd_include || ! security_comment_global_sshd_directives || ! security_write_sshd_hardening_dropin "$new_port"; then
security_restore_hardening_backups "$ssh_backup" "$config_file" "$dropin_backup" "$dropin_file" "$jail_backup" "$jail_file"
display_error "写入 SSH 配置失败,已尝试回滚" "备份文件: $ssh_backup"
return 1
fi
if ! sshd -t -f "$config_file" 2>/tmp/pve-tools-sshd-test.log; then
security_restore_hardening_backups "$ssh_backup" "$config_file" "$dropin_backup" "$dropin_file" "$jail_backup" "$jail_file"
sed 's/^/ /' /tmp/pve-tools-sshd-test.log 2>/dev/null || true
display_error "sshd 配置语法检查失败,已自动回滚" "请检查 $config_file"
return 1
fi
if ! security_write_fail2ban_sshd_jail "$new_port" "$maxretry" "$bantime" "$findtime"; then
security_restore_hardening_backups "$ssh_backup" "$config_file" "$dropin_backup" "$dropin_file" "$jail_backup" "$jail_file"
display_error "写入 fail2ban 配置失败,已尝试回滚 SSH/fail2ban 配置" "请人工检查 $config_file$jail_file"
return 1
fi
ssh_service="$(security_ssh_service_name)"
if ! systemctl restart "$ssh_service" 2>/dev/null; then
security_restore_hardening_backups "$ssh_backup" "$config_file" "$dropin_backup" "$dropin_file" "$jail_backup" "$jail_file"
systemctl restart "$ssh_service" 2>/dev/null || true
display_error "SSH 服务重启失败,已尝试回滚" "请通过控制台检查 SSH 状态。"
return 1
fi
if ! systemctl enable --now fail2ban >/dev/null 2>&1 && ! systemctl restart fail2ban >/dev/null 2>&1; then
security_restore_hardening_backups "$ssh_backup" "$config_file" "$dropin_backup" "$dropin_file" "$jail_backup" "$jail_file"
systemctl restart "$ssh_service" 2>/dev/null || true
display_error "fail2ban 启动失败,已尝试回滚 SSH/fail2ban 配置" "请检查 systemctl status fail2ban。"
return 1
fi
if command -v fail2ban-client >/dev/null 2>&1; then
fail2ban-client status sshd >/dev/null 2>&1 || log_warn "fail2ban sshd jail 暂未进入运行状态,请稍后用 fail2ban-client status sshd 检查。"
fi
display_success "SSH 加固已完成" "新连接命令: ssh -p $new_port root@<PVE-IP>;请立即新开终端验证后再关闭当前会话。"
}

23
src/modules/10-third-party/community.sh vendored Normal file
View File

@@ -0,0 +1,23 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
third_party_community_scripts_info() {
clear
show_menu_header "Community Scripts"
echo " 这里推荐一个由社区维护的庞大脚本集合,覆盖 Proxmox 安装、容器/虚拟机模版、监控等各种高级玩法。"
echo
echo " 项目主页: https://community-scripts.github.io/ProxmoxVE/"
echo " GitHub 仓库: https://github.com/community-scripts/ProxmoxVE"
echo
echo -e "${RED}重要提示:${NC} 该工具集完全由第三方维护,与 PVE-Tools 项目无关。"
echo " 如果脚本运行出现问题,请直接前往上述项目反馈。"
echo
echo " 使用建议:"
echo " - 全站为英文界面,可配合浏览器或翻译软件使用。"
echo " - 网站中包含大量脚本和功能说明,建议按需阅读说明后再执行。"
echo " - 执行任何第三方脚本前,请务必备份关键配置并了解潜在风险。"
echo "${UI_DIVIDER}"
}
#---------第三方工具集合-----------

View File

@@ -0,0 +1,241 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
coolercontrol_local_url() {
local first_ip
first_ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
if [[ -n "$first_ip" ]]; then
echo "http://${first_ip}:11987"
else
echo "http://<PVE-IP>:11987"
fi
}
coolercontrol_print_manual_install() {
clear
show_menu_header "CoolerControl 手动安装命令"
echo " 官方项目: $COOLERCONTROL_PROJECT_URL"
echo " 官方文档: $COOLERCONTROL_DOCS_URL"
echo
echo " # 1. 下载并运行 Cloudsmith 官方 Debian 源配置脚本"
echo " wget -O /tmp/coolercontrol-setup.deb.sh \"$COOLERCONTROL_DEB_SETUP_URL\""
echo " bash /tmp/coolercontrol-setup.deb.sh"
echo
echo " # 2. 安装守护进程并启动内置 Web UI"
echo " apt-get update"
echo " apt-get install -y coolercontrold"
echo " systemctl enable --now coolercontrold"
echo
echo " # 可选:桌面 UI 和更广硬件支持"
echo " apt-get install -y coolercontrol lm-sensors liquidctl"
echo
echo " Web UI: $(coolercontrol_local_url)"
}
coolercontrol_detect_status() {
local service_state="未安装/未找到进程信息"
if command -v dpkg-query >/dev/null 2>&1 && dpkg-query -W coolercontrold >/dev/null 2>&1; then
service_state="已安装/未找到进程信息"
if command -v systemctl >/dev/null 2>&1; then
service_state="$(systemctl is-active coolercontrold 2>/dev/null || echo inactive)"
case "$service_state" in
active) service_state="已安装/运行中" ;;
inactive) service_state="已安装/未运行" ;;
failed) service_state="已安装/服务异常" ;;
*) service_state="已安装/${service_state}" ;;
esac
fi
elif pgrep -x coolercontrold >/dev/null 2>&1; then
service_state="已安装/运行中"
fi
echo "$service_state"
}
coolercontrol_detect_version() {
local daemon_version desktop_version
if command -v dpkg-query >/dev/null 2>&1; then
daemon_version="$(dpkg-query -W -f='${Version}' coolercontrold 2>/dev/null || true)"
desktop_version="$(dpkg-query -W -f='${Version}' coolercontrol 2>/dev/null || true)"
fi
if [[ -n "$daemon_version" && -n "$desktop_version" && "$daemon_version" != "$desktop_version" ]]; then
echo "coolercontrold ${daemon_version} / coolercontrol ${desktop_version}"
elif [[ -n "$daemon_version" ]]; then
echo "$daemon_version"
elif [[ -n "$desktop_version" ]]; then
echo "$desktop_version"
else
echo "未安装/未找到进程信息"
fi
}
coolercontrol_install() {
block_non_pve9_destructive "安装 CoolerControl 第三方风扇控制工具" || return 1
local install_desktop install_optional tmp_script install_pkgs=()
if ! command -v apt-get >/dev/null 2>&1 || ! command -v systemctl >/dev/null 2>&1; then
display_error "缺少 apt-get 或 systemctl" "请在 Debian/PVE 宿主机环境中运行。"
return 1
fi
clear
show_menu_header "安装 CoolerControl"
echo -e "${CYAN}项目:${NC} $COOLERCONTROL_PROJECT_URL"
echo -e "${CYAN}文档:${NC} $COOLERCONTROL_DOCS_URL"
echo -e "${CYAN}源配置脚本:${NC} $COOLERCONTROL_DEB_SETUP_URL"
echo "$UI_DIVIDER"
echo " 将安装官方 Debian 包 coolercontrold。"
echo " coolercontrold 是系统守护进程,包含内置 Web UI。"
echo " PVE-Tools 不接管风扇曲线、不写入 CoolerControl 配置。"
echo "$UI_DIVIDER"
read -p "是否安装桌面 UI coolercontrolPVE 宿主机通常不需要 [no]: " install_desktop
install_desktop="${install_desktop:-no}"
read -p "是否安装可选硬件支持包 lm-sensors/liquidctl[yes]: " install_optional
install_optional="${install_optional:-yes}"
if ! confirm_high_risk_action "安装 CoolerControl 并添加第三方 APT 源" "会下载并运行 CoolerControl 官方 Cloudsmith 源配置脚本,并安装第三方软件包。" "第三方仓库或包异常可能影响 apt 源状态;风扇控制配置错误可能造成散热或噪音问题。" "建议先确认硬件支持情况,并保留 PVE 控制台访问;安装后先观察温度与风扇状态,再配置自动曲线。" "COOLER"; then
return 0
fi
if ! tmp_script=$(mktemp /tmp/coolercontrol-setup.XXXXXX.sh); then
display_error "无法创建临时文件" "请检查 /tmp 是否可写。"
return 1
fi
log_info "下载 CoolerControl 官方 Debian 源配置脚本..."
if ! pve_tools_download_file "$COOLERCONTROL_DEB_SETUP_URL" "$tmp_script" 60; then
rm -f "$tmp_script"
display_error "CoolerControl 源配置脚本下载失败" "请检查网络,或使用手动安装命令。"
return 1
fi
chmod +x "$tmp_script"
log_step "配置 CoolerControl APT 源..."
if ! bash "$tmp_script"; then
rm -f "$tmp_script"
display_error "CoolerControl APT 源配置失败" "请检查脚本输出,或参考官方文档手动配置。"
return 1
fi
rm -f "$tmp_script"
log_step "更新软件包索引..."
if ! apt-get update; then
display_error "apt-get update 失败" "第三方源可能未正确配置,已停止安装。"
return 1
fi
install_pkgs=(coolercontrold)
[[ "$install_desktop" == "yes" || "$install_desktop" == "YES" ]] && install_pkgs+=(coolercontrol)
[[ "$install_optional" == "yes" || "$install_optional" == "YES" ]] && install_pkgs+=(lm-sensors liquidctl)
log_step "安装软件包: ${install_pkgs[*]}"
if ! apt-get install -y "${install_pkgs[@]}"; then
display_error "CoolerControl 安装失败" "请检查包名、软件源和网络连接。"
return 1
fi
if systemctl enable --now coolercontrold; then
display_success "CoolerControl 安装完成" "打开 Web UI: $(coolercontrol_local_url)"
else
display_error "CoolerControl 已安装,但服务启动失败" "请运行 systemctl status coolercontrold 查看原因。"
return 1
fi
}
coolercontrol_update() {
block_non_pve9_destructive "更新 CoolerControl" || return 1
local upgrade_pkgs=()
if ! command -v apt-get >/dev/null 2>&1; then
display_error "缺少 apt-get" "请在 Debian/PVE 宿主机环境中运行。"
return 1
fi
if ! confirm_high_risk_action "更新 CoolerControl 软件包" "会刷新 apt 索引并升级 coolercontrold/coolercontrol 相关包。" "第三方源异常可能导致更新失败;服务重启期间风扇控制策略可能短暂不可用。" "请确认当前散热状态稳定,并保留控制台访问。" "COOLER-UPDATE"; then
return 0
fi
apt-get update || {
display_error "apt-get update 失败" "请检查 CoolerControl 源和网络连接。"
return 1
}
if command -v dpkg-query >/dev/null 2>&1; then
dpkg-query -W coolercontrold >/dev/null 2>&1 && upgrade_pkgs+=(coolercontrold)
dpkg-query -W coolercontrol >/dev/null 2>&1 && upgrade_pkgs+=(coolercontrol)
dpkg-query -W lm-sensors >/dev/null 2>&1 && upgrade_pkgs+=(lm-sensors)
dpkg-query -W liquidctl >/dev/null 2>&1 && upgrade_pkgs+=(liquidctl)
fi
if [[ ${#upgrade_pkgs[@]} -eq 0 ]]; then
display_error "未检测到已安装的 CoolerControl 相关包" "请先执行安装,或使用手动安装命令。"
return 1
fi
if ! apt-get install --only-upgrade -y "${upgrade_pkgs[@]}"; then
display_error "CoolerControl 更新失败" "请检查软件包是否已安装以及 apt 输出。"
return 1
fi
systemctl restart coolercontrold 2>/dev/null || log_warn "coolercontrold 重启失败,请手动检查服务状态。"
display_success "CoolerControl 更新完成" "Web UI: $(coolercontrol_local_url)"
}
coolercontrol_uninstall() {
block_non_pve9_destructive "卸载 CoolerControl" || return 1
if ! command -v apt-get >/dev/null 2>&1; then
display_error "缺少 apt-get" "请在 Debian/PVE 宿主机环境中运行。"
return 1
fi
clear
show_menu_header "卸载 CoolerControl"
echo " 将停止 coolercontrold 服务,并卸载 coolercontrol/coolercontrold 软件包。"
echo " 默认不会删除 Cloudsmith 源配置和用户配置目录,便于后续重新安装。"
echo "$UI_DIVIDER"
if ! confirm_high_risk_action "卸载 CoolerControl" "会停止第三方风扇控制服务并移除相关软件包。" "卸载后风扇控制将回到系统/固件默认行为,可能影响噪音和散热策略。" "请确认当前硬件默认风扇策略可接受,且已记录需要保留的 CoolerControl 配置。" "COOLER-REMOVE"; then
return 0
fi
systemctl disable --now coolercontrold 2>/dev/null || true
if apt-get remove -y coolercontrol coolercontrold; then
display_success "CoolerControl 已卸载" "如需清理配置,可按官方文档手动处理。"
else
display_error "CoolerControl 卸载失败" "请检查 apt 输出并手动处理。"
return 1
fi
}
coolercontrol_manager_menu() {
while true; do
clear
show_menu_header "Cooler Control 管理器"
echo -e "当前状态 $(coolercontrol_detect_status)"
echo -e "当前版本 $(coolercontrol_detect_version)"
echo "$UI_DIVIDER"
show_menu_option "1" "安装"
show_menu_option "2" "更新"
show_menu_option "3" "手动安装"
show_menu_option "4" "卸载"
echo "$UI_DIVIDER"
echo "注意:本功能仅作为安装服务和服务管理,如需反馈问题请前往项目官方开源仓库反馈!"
echo "$UI_DIVIDER"
echo "项目官网:$COOLERCONTROL_PROJECT_URL"
echo "开源协议GNU General Public License v3.0 or later."
show_menu_option "0" "返回"
show_menu_footer
local choice
read -p "请选择操作 [0-4]: " choice
case "$choice" in
1) coolercontrol_install ;;
2) coolercontrol_update ;;
3) coolercontrol_print_manual_install ;;
4) coolercontrol_uninstall ;;
0) return ;;
*) log_error "无效选择" ;;
esac
pause_function
done
}

28
src/modules/10-third-party/init.sh vendored Normal file
View File

@@ -0,0 +1,28 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
third_party_tools_menu() {
while true; do
clear
show_menu_header "第三方工具"
show_menu_option "1" "第三方软件市场 ${CYAN}(Modules)${NC}"
show_menu_option "2" "CoolerControl ${CYAN}(更好的管理风扇控制工具)${NC}"
show_menu_option "3" "Community Scripts ${CYAN}(社区脚本集合)${NC}"
show_menu_option "0" "返回"
show_menu_footer
local choice
read -p "请选择操作 [0-3]: " choice
case "$choice" in
1) third_party_market_menu ;;
2) coolercontrol_manager_menu ;;
3) third_party_community_scripts_info ;;
0) return ;;
*) log_error "无效选择" ;;
esac
pause_function
done
}
# PVE8 to PVE9 升级功能

View File

@@ -0,0 +1,230 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Ciriu Networks
third_party_market_menu() {
local -a download_cmd
if command -v curl &> /dev/null; then
download_cmd=(curl -fsSL --connect-timeout 10 --max-time 60 -o)
elif command -v wget &> /dev/null; then
download_cmd=(wget -q -O)
else
log_error "未检测到 curl 或 wget无法访问第三方软件市场"
return 1
fi
local tmp_index
if ! tmp_index=$(mktemp /tmp/pve-third-party-index.XXXXXX.json); then
log_error "无法创建临时文件,第三方软件市场启动失败"
return 1
fi
local api_main_url="$THIRD_PARTY_MODULES_TREE_API_MAIN_URL"
local api_master_url="$THIRD_PARTY_MODULES_TREE_API_MASTER_URL"
local index_ok=0
log_info "正在通过 GitHub API 拉取第三方软件列表..."
if command -v curl &> /dev/null; then
if curl -fsSL --connect-timeout 10 --max-time 60 \
-H "Accept: application/vnd.github+json" \
-H "User-Agent: pve-tools" \
-o "$tmp_index" "$api_main_url"; then
index_ok=1
else
log_warn "main 分支列表拉取失败,尝试使用 master 分支..."
: > "$tmp_index"
if curl -fsSL --connect-timeout 10 --max-time 60 \
-H "Accept: application/vnd.github+json" \
-H "User-Agent: pve-tools" \
-o "$tmp_index" "$api_master_url"; then
index_ok=1
fi
fi
else
if wget -q --timeout=60 \
--header="Accept: application/vnd.github+json" \
--user-agent="pve-tools" \
-O "$tmp_index" "$api_main_url"; then
index_ok=1
else
log_warn "main 分支列表拉取失败,尝试使用 master 分支..."
: > "$tmp_index"
if wget -q --timeout=60 \
--header="Accept: application/vnd.github+json" \
--user-agent="pve-tools" \
-O "$tmp_index" "$api_master_url"; then
index_ok=1
fi
fi
fi
if [[ $index_ok -ne 1 ]]; then
log_error "第三方软件列表拉取失败,请稍后重试"
rm -f "$tmp_index"
return 1
fi
local -a module_files
while IFS= read -r module_name; do
[[ -z "$module_name" ]] && continue
module_files+=("$module_name")
done < <(grep -oE '"path":[[:space:]]*"Modules/[^"]+\.sh"' "$tmp_index" | sed -E 's#.*"path":[[:space:]]*"Modules/([^"]+)".*#\1#')
rm -f "$tmp_index"
if [[ ${#module_files[@]} -eq 0 ]]; then
log_warn "未在 Modules 目录发现可用的 .sh 第三方脚本"
return 1
fi
local -a valid_files valid_names valid_authors valid_versions valid_githubs
for module_file in "${module_files[@]}"; do
local module_url="${THIRD_PARTY_MODULES_RAW_BASE_URL}/${module_file}"
local module_mirror_url="${GITHUB_MIRROR_PREFIX}${module_url}"
local module_preferred_url="$module_url"
local module_fallback_url="$module_mirror_url"
if [[ $USE_MIRROR_FOR_UPDATE -eq 1 ]]; then
module_preferred_url="$module_mirror_url"
module_fallback_url="$module_url"
fi
local tmp_module
if ! tmp_module=$(mktemp /tmp/pve-third-party-meta.XXXXXX.sh); then
continue
fi
if ! "${download_cmd[@]}" "$tmp_module" "$module_preferred_url"; then
: > "$tmp_module"
if ! "${download_cmd[@]}" "$tmp_module" "$module_fallback_url"; then
rm -f "$tmp_module"
continue
fi
fi
local meta_block
meta_block=$(sed -n '2,5p' "$tmp_module")
rm -f "$tmp_module"
local script_name script_author script_version script_github
script_name=$(echo "$meta_block" | grep -m1 '^## name:' | cut -d: -f2- | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
script_author=$(echo "$meta_block" | grep -m1 '^## author:' | cut -d: -f2- | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
script_version=$(echo "$meta_block" | grep -m1 '^## version:' | cut -d: -f2- | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
script_github=$(echo "$meta_block" | grep -m1 '^## github:' | cut -d: -f2- | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
if [[ -z "$script_name" || -z "$script_author" || -z "$script_version" || -z "$script_github" ]]; then
continue
fi
valid_files+=("$module_file")
valid_names+=("$script_name")
valid_authors+=("$script_author")
valid_versions+=("$script_version")
valid_githubs+=("$script_github")
done
if [[ ${#valid_files[@]} -eq 0 ]]; then
log_warn "已发现 .sh 文件但没有符合元信息规范第2-5行的脚本"
return 1
fi
while true; do
clear
show_menu_header "第三方软件市场 (Modules)"
echo " 数据源: $THIRD_PARTY_MODULES_RAW_BASE_URL"
echo " 共发现 ${#valid_files[@]} 个符合规范的脚本"
echo "${UI_DIVIDER}"
local idx=1
while [[ $idx -le ${#valid_files[@]} ]]; do
local arr_idx=$((idx - 1))
echo -e " ${CYAN}${idx}.${NC} ${valid_names[$arr_idx]}"
echo " 作者: ${valid_authors[$arr_idx]} | 版本: ${valid_versions[$arr_idx]}"
echo " 脚本: ${valid_files[$arr_idx]}"
echo " 仓库: ${valid_githubs[$arr_idx]}"
((idx++))
done
echo "${UI_DIVIDER}"
show_menu_option "0" "返回上级菜单"
show_menu_footer
local choice
read -p "请选择要运行的脚本 [0-${#valid_files[@]}]: " choice
if [[ "$choice" == "0" ]]; then
return 0
fi
if [[ ! "$choice" =~ ^[0-9]+$ ]] || (( choice < 1 || choice > ${#valid_files[@]} )); then
log_error "无效选择"
pause_function
continue
fi
local selected_idx=$((choice - 1))
local selected_file="${valid_files[$selected_idx]}"
local selected_name="${valid_names[$selected_idx]}"
local selected_author="${valid_authors[$selected_idx]}"
local selected_version="${valid_versions[$selected_idx]}"
local selected_url="${THIRD_PARTY_MODULES_RAW_BASE_URL}/${selected_file}"
local selected_mirror_url="${GITHUB_MIRROR_PREFIX}${selected_url}"
local selected_preferred_url="$selected_url"
local selected_fallback_url="$selected_mirror_url"
local selected_preferred_label="GitHub"
local selected_fallback_label="加速镜像"
if [[ $USE_MIRROR_FOR_UPDATE -eq 1 ]]; then
selected_preferred_url="$selected_mirror_url"
selected_fallback_url="$selected_url"
selected_preferred_label="加速镜像"
selected_fallback_label="GitHub"
fi
echo
echo -e "${RED}⚠️ 第三方脚本风险提示:${NC}"
echo " 名称: $selected_name"
echo " 作者: $selected_author"
echo " 版本: $selected_version"
echo " 来源: $selected_url"
echo " 本工具仅负责下载和执行,请确认你已审计脚本内容并接受风险。"
read -p "输入 'run' 确认执行,其他任意键取消: " confirm_run
if [[ "$confirm_run" != "run" ]]; then
log_info "已取消执行 $selected_name"
pause_function
continue
fi
local tmp_script
if ! tmp_script=$(mktemp /tmp/pve-third-party-run.XXXXXX.sh); then
log_error "无法创建临时脚本文件"
pause_function
continue
fi
log_info "使用 $selected_preferred_label 下载脚本 ($selected_file)..."
if ! "${download_cmd[@]}" "$tmp_script" "$selected_preferred_url"; then
log_warn "$selected_preferred_label 下载失败,尝试改用 $selected_fallback_label..."
: > "$tmp_script"
if ! "${download_cmd[@]}" "$tmp_script" "$selected_fallback_url"; then
log_error "脚本下载失败: $selected_file"
rm -f "$tmp_script"
pause_function
continue
fi
fi
chmod +x "$tmp_script"
echo "${UI_BORDER}"
sh "$tmp_script"
local run_status=$?
echo "${UI_BORDER}"
rm -f "$tmp_script"
if [[ $run_status -eq 0 ]]; then
log_success "$selected_name 执行完成"
else
log_error "$selected_name 执行失败 (退出码: $run_status)"
fi
pause_function
done
}
#---------FastPVE 虚拟机快速下载-----------
# Community Scripts 提示