From d8b3d9fbdf017eb9bbf957194b1601a0b2280148 Mon Sep 17 00:00:00 2001 From: Maple Date: Tue, 28 Jul 2026 14:43:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20release=20v11.0.0=20Liino=20=E2=80=94?= =?UTF-8?q?=20menu=20framework,=20risk=20guards,=20CI=20gates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unify all menus on lib/menu.sh (run_menu), harden GPU/GRUB writes with two-tier confirms and markers, fix remote/self-update to Releases assets, and close real CI quality gates with SHA256SUMS. Bump to 11.0.0 / Liino. --- .github/CLAUDE.md | 96 +- .github/workflows/beta-release.yml | 14 + .github/workflows/pr-validation.yml | 46 +- .github/workflows/release.yml | 36 + .gitignore | 14 - AGENTS.md | 40 +- CLAUDE.md | 153 +- Docs/重构计划-PVE-Tools模块化拆分.md | 1361 + PVE-Tools.sh | 11 +- REAMDE-JP.md => README-JP.md | 2 +- README.md | 4 +- README_EN.md | 6 +- UPDATE | 38 +- VERSION | 2 +- build.sh | 2 +- dev.sh | 1 + home-full.png | Bin 558341 -> 0 bytes images/image.old.png | Bin 1606189 -> 0 bytes images/main-shot-3.0.png | Bin 1713791 -> 0 bytes images/main-shot-3.1.png | Bin 1780708 -> 0 bytes images/main-shot-4.0.png | Bin 1520340 -> 0 bytes images/main-shot-4.1.png | Bin 1559009 -> 0 bytes images/main-shot-4.3.png | Bin 1589643 -> 0 bytes images/main-shot-6.6.png | Bin 238161 -> 0 bytes images/main-shot.5.png | Bin 1122786 -> 0 bytes images/main-v7.png | Bin 3290750 -> 0 bytes lib/CLAUDE.md | 106 +- lib/config.sh | 13 +- lib/core.sh | 114 +- lib/menu.sh | 186 + lib/network.sh | 6 +- lib/runtime.sh | 42 +- proxmoxlib.js | 25686 ---------------- release ROM | 691 - src/CLAUDE.md | 28 +- src/modules/01-optimization/cpupower.sh | 4 + src/modules/01-optimization/init.sh | 51 +- src/modules/01-optimization/temperature.sh | 49 +- src/modules/02-sources/init.sh | 36 +- src/modules/02-sources/mirrors.sh | 6 +- src/modules/03-boot-kernel/init.sh | 86 +- src/modules/03-boot-kernel/kernel.sh | 141 +- src/modules/04-gpu-passthrough/amd-dgpu.sh | 35 +- src/modules/04-gpu-passthrough/amd-igpu.sh | 41 +- src/modules/04-gpu-passthrough/igpu-shared.sh | 211 +- src/modules/04-gpu-passthrough/init.sh | 95 +- src/modules/04-gpu-passthrough/intel-gvtg.sh | 19 +- .../04-gpu-passthrough/intel-legacy.sh | 98 +- src/modules/04-gpu-passthrough/intel-sriov.sh | 21 +- src/modules/04-gpu-passthrough/iommu.sh | 199 +- src/modules/04-gpu-passthrough/nvidia.sh | 125 +- src/modules/04-gpu-passthrough/rdm.sh | 7 +- src/modules/05-vm-container/backup.sh | 47 +- src/modules/05-vm-container/cloudinit.sh | 51 +- src/modules/05-vm-container/config-io.sh | 47 +- src/modules/05-vm-container/disk.sh | 43 +- .../05-vm-container/garbage-cleanup.sh | 49 +- src/modules/05-vm-container/init.sh | 100 +- src/modules/05-vm-container/network.sh | 43 +- src/modules/05-vm-container/restore.sh | 55 +- src/modules/05-vm-container/schedule.sh | 4 + src/modules/05-vm-container/snapshot.sh | 43 +- src/modules/06-networking/bond.sh | 43 +- src/modules/06-networking/bridge.sh | 47 +- src/modules/06-networking/diagnostic.sh | 47 +- src/modules/06-networking/firewall.sh | 133 +- src/modules/06-networking/init.sh | 59 +- src/modules/06-networking/interface.sh | 5 + src/modules/06-networking/ipv6-helper.sh | 45 +- src/modules/06-networking/mac-bind.sh | 41 +- src/modules/06-networking/vlan.sh | 43 +- src/modules/07-storage-disk/ceph.sh | 103 +- src/modules/07-storage-disk/init.sh | 87 +- src/modules/07-storage-disk/local-lvm.sh | 68 +- src/modules/07-storage-disk/swap.sh | 54 +- src/modules/08-tools-about/init.sh | 131 +- src/modules/08-tools-about/self-update.sh | 8 +- src/modules/09-security/cve-fix.sh | 193 +- src/modules/09-security/init.sh | 37 +- src/modules/10-third-party/coolercontrol.sh | 57 +- src/modules/10-third-party/init.sh | 41 +- src/modules/10-third-party/it87-driver.sh | 93 +- src/modules/10-third-party/marketplace.sh | 199 +- 83 files changed, 3606 insertions(+), 28332 deletions(-) create mode 100644 Docs/重构计划-PVE-Tools模块化拆分.md rename REAMDE-JP.md => README-JP.md (94%) delete mode 100644 home-full.png delete mode 100644 images/image.old.png delete mode 100644 images/main-shot-3.0.png delete mode 100644 images/main-shot-3.1.png delete mode 100644 images/main-shot-4.0.png delete mode 100644 images/main-shot-4.1.png delete mode 100644 images/main-shot-4.3.png delete mode 100644 images/main-shot-6.6.png delete mode 100644 images/main-shot.5.png delete mode 100644 images/main-v7.png create mode 100644 lib/menu.sh delete mode 100644 proxmoxlib.js delete mode 100644 release ROM diff --git a/.github/CLAUDE.md b/.github/CLAUDE.md index eaa3d85..8e05744 100644 --- a/.github/CLAUDE.md +++ b/.github/CLAUDE.md @@ -4,54 +4,52 @@ ## 模块职责 -管理项目的持续集成/持续部署流水线和社区 Issue 模板。三条工作流覆盖 PR 验证、正式发布和测试版发布。 +管理项目的持续集成/持续部署流水线和社区 Issue 模板。三条工作流覆盖 PR 验证、正式发布和测试版发布。发布物为 `dist/PVE-Tools.sh` 单文件 + `SHA256SUMS.txt`(不使用 shc 编译二进制)。 ## 入口与启动 | 项目 | 说明 | |---|---| -| 触发方式 | GitHub Actions,由 push/pull_request 事件触发 | +| 触发方式 | GitHub Actions,由 push(tag)/pull_request 事件触发 | | 运行环境 | `ubuntu-latest` | -| 权限 | `contents: write`(Release 工作流需要) | +| 权限 | `contents: write`(两个 Release 工作流需要) | ## 工作流清单 +### pr-validation.yml -- PR 验证工作流 + +**触发条件**: PR 到 `main` 或 `beta` 分支 + +| 检查 | 说明 | +|---|---| +| Shellcheck(入口) | `shellcheck -f gcc PVE-Tools.sh`,error/warning 均失败 | +| Shellcheck(全源码) | `find lib src/modules -name '*.sh' \| xargs shellcheck --severity=error`,error 级卡死 | +| 语法检查 | `bash -n` 入口/dev.sh,`bash build.sh` 后 `bash -n dist` | +| Shellcheck(产物) | dist 的 error/warning 均失败 | +| 构建一致性 | 源码与 dist 的函数集合双向 diff,不一致即失败 | +| 版本一致性 | `CURRENT_VERSION`(lib/config.sh)== `VERSION` 文件 | +| UPDATE 新鲜度 | `UPDATE` 首行必须包含当前版本号(防更新日志脱节) | +| 安全扫描 | dist 中禁 `eval`、未加引号变量开头的 `rm -rf`、`source` 语句,命中即失败 | + ### release.yml -- 正式发布工作流 -**触发条件**: 推送版本标签 (`v*.*.*`, `*.*.*`, `v*.*.*-stable`, `*.*.*-stable`) +**触发条件**: 推送 `v*.*.*` / `*.*.*` / `*-stable` 标签;**排除** `-beta*` / `-alpha*` / `-rc*`(预发布标签只归 beta-release.yml,避免双流水线竞争同一 tag) **步骤**: -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` +1. checkout(fetch-depth: 0)-> 从 tag 提取版本号 +2. `bash build.sh` 构建 dist +3. **发布前校验**(打 tag 直发不绕过质量闸门):`bash -n dist`、`shellcheck --severity=error dist`、函数集合一致性 diff、版本三方一致(tag == config == VERSION) +4. 生成 `SHA256SUMS.txt` +5. 基于 git 提交历史生成 release notes +6. `softprops/action-gh-release@v2` 创建 Release,上传 `dist/PVE-Tools.sh` + `dist/SHA256SUMS.txt` -**构建产物**: -- `pve-tools` -- 编译后的二进制文件 -- `dist/PVE-Tools.sh` -- 拼接后的单文件脚本 +**注意**: 远程安装链路依赖 `releases/latest/download/PVE-Tools.sh`,正式发版后新用户方能获取最新版。 ### beta-release.yml -- 测试版发布工作流 -**触发条件**: 推送 beta/alpha 标签 +**触发条件**: 推送 `-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` 使用 | 发现则告警 | +与 release.yml 类似但 `prerelease: true`;同样执行构建质量校验(语法/shellcheck/函数一致性),版本号断言放宽(beta 标签允许与 config 版本不同步)。 ## Issue 模板 @@ -71,42 +69,29 @@ ## 关键依赖与配置 -- **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 前自动构建并验证构建产物 +- **GitHub Actions**: `ubuntu-latest` runner(自带 shellcheck) +- **softprops/action-gh-release@v2**: 创建 Release 与上传资产 ## 常见问题 (FAQ) -**Q: 为什么 Release 要用 shc 编译?** -shc 将 Bash 脚本编译为二进制文件,提供基础的源码保护,同时便于分发。构建产物 `dist/PVE-Tools.sh` 同时发布以保持 `bash <(curl ...)` 兼容性。 +**Q: 发版时需要同步哪些版本号?** +三处:`lib/config.sh` 的 `CURRENT_VERSION`、`VERSION` 文件、`UPDATE` 首行条目,且正式发布 tag 必须与之一致——任何一处不同步都会被 CI 拦下。 -**Q: PR 验证中 build.sh 会失败怎么办?** -检查 lib/ 和 src/modules/ 中的文件是否存在、语法是否正确。`bash -n` 仅检查语法不执行代码。 +**Q: PR 验证中 build.sh 失败怎么办?** +检查 lib/ 与 src/modules/ 文件是否齐全、语法是否正确;新增 lib 文件需同步 build.sh / dev.sh / PVE-Tools.sh 三处加载列表。 **Q: 如何添加新的 Issue 模板?** -在 `ISSUE_TEMPLATE/` 目录添加 `.md` 文件并更新 `config.yml` 即可,GitHub 会自动识别。 +在 `ISSUE_TEMPLATE/` 目录添加 `.md` 文件并更新 `config.yml`。 ## 相关文件清单 ``` .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 模板配置 + release.yml # 正式发布(含发布前校验与 SHA256SUMS) + beta-release.yml # 测试版发布(含构建校验) + pr-validation.yml # PR 验证(8 项检查) + ISSUE_TEMPLATE/ # Issue 模板 5 件 FUNDING.yml # 赞助配置 ``` @@ -114,4 +99,5 @@ shc 将 Bash 脚本编译为二进制文件,提供基础的源码保护,同 | 日期 | 变更 | |---|---| -| 2026-07-08 | 初始化 .github 模块 CLAUDE.md。PR 验证已适配模块化(build.sh/build -n dist)。Release 已添加 build.sh 步骤。 | +| 2026-07-26 | 按现实重写:移除 shc 描述;补充发布前校验/SHA256SUMS/UPDATE 新鲜度/安全扫描细节;记录正式与预发布标签互斥规则 | +| 2026-07-08 | 初始化 .github 模块 CLAUDE.md | diff --git a/.github/workflows/beta-release.yml b/.github/workflows/beta-release.yml index 62fb14b..981c2bf 100644 --- a/.github/workflows/beta-release.yml +++ b/.github/workflows/beta-release.yml @@ -29,6 +29,19 @@ jobs: - name: Build single script from modules run: bash build.sh + - name: Validate build output before release + run: | + # beta 同样不跳过构建质量校验(版本号断言放宽,beta 标签允许与 config 不同步) + bash -n dist/PVE-Tools.sh + shellcheck --severity=error -f gcc dist/PVE-Tools.sh + diff <(grep -rhoE '^[a-zA-Z_][a-zA-Z0-9_]*\(\)' lib src/modules --include='*.sh' | sort -u) \ + <(grep -hoE '^[a-zA-Z_][a-zA-Z0-9_]*\(\)' dist/PVE-Tools.sh | sort -u) \ + || { echo "构建产物与源码函数集合不一致,中止发布"; exit 1; } + + - name: Generate checksums + run: | + cd dist && sha256sum PVE-Tools.sh > SHA256SUMS.txt && cat SHA256SUMS.txt + - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: @@ -47,6 +60,7 @@ jobs: ``` files: | dist/PVE-Tools.sh + dist/SHA256SUMS.txt draft: false prerelease: true env: diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index ef94521..cd479b9 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -18,6 +18,11 @@ jobs: exit 1 fi + - name: Shellcheck (lib and modules, error level) + run: | + # 全量源码 error 级静态检查(入口与构建产物维持 error+warning 严格档) + find lib src/modules -name '*.sh' -print0 | xargs -0 shellcheck --severity=error -f gcc + - name: Shell syntax check run: | bash -n PVE-Tools.sh @@ -34,6 +39,13 @@ jobs: exit 1 fi + - name: Build output consistency check + run: | + # 断言 lib/ 与 src/modules/ 中定义的每个函数都进入了构建产物(双向一致) + diff <(grep -rhoE '^[a-zA-Z_][a-zA-Z0-9_]*\(\)' lib src/modules --include='*.sh' | sort -u) \ + <(grep -hoE '^[a-zA-Z_][a-zA-Z0-9_]*\(\)' dist/PVE-Tools.sh | sort -u) \ + || { echo "构建产物与源码的函数集合不一致,见上方 diff(< 仅源码有 / > 仅产物有)"; exit 1; } + - name: Version consistency check run: | SCRIPT_VERSION=$(grep "CURRENT_VERSION=" lib/config.sh | cut -d'"' -f2) @@ -42,12 +54,34 @@ jobs: echo "Version inconsistency: script($SCRIPT_VERSION) != version file($VERSION_FILE_VERSION)" exit 1 fi + + - name: UPDATE changelog freshness check + run: | + # UPDATE 是 check_update 展示给线上用户的更新日志,首行必须包含当前版本号,防止再次脱节 + VERSION_FILE_VERSION=$(cat VERSION) + if ! head -1 UPDATE | grep -qF "$VERSION_FILE_VERSION"; then + echo "UPDATE 首行未包含当前版本 $VERSION_FILE_VERSION —— 更新日志已脱节,请同步 UPDATE 文件" + exit 1 + fi - name: Security scan run: | - # 检查是否有潜在的安全问题 - if grep -q "eval\|source" PVE-Tools.sh; then - echo "Potential security issues found: eval or source commands detected" - else - echo "No security issues detected" - fi \ No newline at end of file + # 对全量构建产物扫描真实危险模式,命中即失败 + # (旧版对入口 grep eval/source 永远命中且从不失败,属于空转检查) + status=0 + if grep -nE '(^|[^a-zA-Z_.])eval([^a-zA-Z_]|$)' dist/PVE-Tools.sh; then + echo "检测到 eval 使用:本项目约定禁止 eval,请改写后再合并" + status=1 + fi + if grep -nE 'rm -rf +\$' dist/PVE-Tools.sh; then + echo "检测到未加引号且以变量开头的 rm -rf 路径:变量为空或含空格时行为不可控,请加引号与前置校验" + status=1 + fi + if grep -nE '^[[:space:]]*(source|\.)[[:space:]]' dist/PVE-Tools.sh; then + echo "构建产物中不应存在 source 语句:dist 是自包含单文件,出现 source 说明拼接进了开发态加载代码" + status=1 + fi + if [ "$status" -eq 0 ]; then + echo "Security scan passed" + fi + exit $status \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 76f84db..6521403 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,6 +6,14 @@ on: - '*.*.*' - 'v*.*.*-stable' - '*.*.*-stable' + # 排除预发布标签,否则 v*.*.* 会同时命中 beta/alpha/rc, + # 导致预发布被当成正式版发布并与 beta-release.yml 竞争同一 tag + - '!v*.*.*-beta*' + - '!*.*.*-beta*' + - '!v*.*.*-alpha*' + - '!*.*.*-alpha*' + - '!v*.*.*-rc*' + - '!*.*.*-rc*' permissions: contents: write @@ -29,6 +37,33 @@ jobs: - name: Build single script from modules run: bash build.sh + - name: Validate build output before release + run: | + # 发版前置校验:打 tag 直发不再绕过 PR 的质量闸门 + bash -n dist/PVE-Tools.sh + shellcheck --severity=error -f gcc dist/PVE-Tools.sh + # 函数集合一致性(源码 vs 产物 双向) + diff <(grep -rhoE '^[a-zA-Z_][a-zA-Z0-9_]*\(\)' lib src/modules --include='*.sh' | sort -u) \ + <(grep -hoE '^[a-zA-Z_][a-zA-Z0-9_]*\(\)' dist/PVE-Tools.sh | sort -u) \ + || { echo "构建产物与源码函数集合不一致,中止发布"; exit 1; } + # 版本三方一致:tag == lib/config.sh == VERSION,防止打错标签发出版本混乱的 Release + SCRIPT_VERSION=$(grep "CURRENT_VERSION=" lib/config.sh | cut -d'"' -f2) + VERSION_FILE_VERSION=$(cat VERSION) + TAG_VERSION="${{ steps.get_version.outputs.VERSION }}" + TAG_VERSION="${TAG_VERSION%-stable}" + if [ "$SCRIPT_VERSION" != "$VERSION_FILE_VERSION" ]; then + echo "版本不一致: config($SCRIPT_VERSION) != VERSION($VERSION_FILE_VERSION),中止发布" + exit 1 + fi + if [ "$TAG_VERSION" != "$SCRIPT_VERSION" ]; then + echo "标签版本($TAG_VERSION)与脚本版本($SCRIPT_VERSION)不一致,中止发布" + exit 1 + fi + + - name: Generate checksums + run: | + cd dist && sha256sum PVE-Tools.sh > SHA256SUMS.txt && cat SHA256SUMS.txt + - name: Generate release notes id: release_notes run: | @@ -66,6 +101,7 @@ jobs: ``` files: | dist/PVE-Tools.sh + dist/SHA256SUMS.txt draft: false prerelease: false env: diff --git a/.gitignore b/.gitignore index 3d1459f..2f67937 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ # Ignore dependencies node_modules/ **/node_modules/ -bun.lockb # Ignore build outputs dist/ @@ -9,10 +8,6 @@ dist/ build/ out/ .temp/ -.vitepress/dist/ -.vitepress/cache/ -**/.vitepress/dist/ -**/.vitepress/cache/ # Ignore system files .DS_Store @@ -37,12 +32,3 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json # Python __pycache__/ *.pyc - -# BMad Method V6 -_bmad/ -_bmad-output/ - -test.txt - -# ignore docs -Docs/ diff --git a/AGENTS.md b/AGENTS.md index 8e5879d..969d8a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,41 +2,39 @@ ## Project Structure & Module Organization -`PVE-Tools.sh` is the main Bash entry point for Proxmox VE 9.x operations. `Tools/` contains standalone maintenance scripts for host setup, LXC cleanup, monitoring, kernel handling, and backups. `Modules/` contains plugin-market modules and bundled binary assets such as `Modules/VGPU/*.so`; do not edit binary files unless the change is intentional and documented. `Web/` is the VitePress documentation site, with pages in Markdown and static assets under `Web/public/` and `Web/assets/`. `Docs/` stores supplemental guides, `images/` stores README and site screenshots, and `.github/` contains issue templates and CI/release workflows. +- `PVE-Tools.sh` — bootstrap entry (~330 lines). Local mode sources `lib/` + `src/modules/`; remote mode (`bash <(curl ...)`) downloads the prebuilt single file from GitHub Releases (`releases/latest/download/PVE-Tools.sh`). +- `lib/` — infrastructure layer, loaded in strict order: `config.sh` (globals) → `core.sh` (logging/UI/backup/GRUB helpers) → `menu.sh` (menu framework) → `network.sh` (region detection, mirror selection) → `runtime.sh` (guards + `main()`). +- `src/modules/` — 10 numbered feature modules matching main-menu items 1-10; each has `init.sh` (menu entry) plus feature files. +- `build.sh` concatenates lib + modules into `dist/PVE-Tools.sh` (gitignored; built by CI). `dev.sh` sources everything for local development. +- `Tools/` — standalone community maintenance scripts (not part of the build). `Modules/` — plugin-market assets incl. `Modules/VGPU/*.so` (binary asset referenced by the vGPU unlock download URL — do not edit or remove). `Docs/` — supplementary docs incl. the modularization design doc. `images/` — README screenshots. `.github/` — issue templates and CI workflows. +- The former `Web/` VitePress documentation site has been removed from this repository. ## Build, Test, and Development Commands -Run Bash checks from the repository root: - ```bash -bash -n PVE-Tools.sh -shellcheck -f gcc PVE-Tools.sh +bash dev.sh # run from source +bash build.sh # build dist/PVE-Tools.sh +bash -n PVE-Tools.sh && bash -n dist/PVE-Tools.sh # syntax check +shellcheck -f gcc PVE-Tools.sh dist/PVE-Tools.sh # strict lint (entry + artifact) +find lib src/modules -name '*.sh' -print0 | xargs -0 shellcheck --severity=error -f gcc ``` -For the documentation site: - -```bash -cd Web -bun install -bun run dev -bun run build -bun run preview -``` - -`bun run build` copies `VERSION`, `UPDATE`, and `PVE-Tools.sh` into the generated site output. +CI (`pr-validation.yml`) additionally asserts: source-vs-dist function-set consistency, `CURRENT_VERSION` ↔ `VERSION` match, `UPDATE` first line contains the current version, and a security scan (no `eval`, no unquoted `rm -rf $var`, no `source` in dist). Release workflows re-run these gates before publishing and attach `SHA256SUMS.txt`. ## Coding Style & Naming Conventions -Bash scripts use `#!/bin/bash`, 4-space indentation, and `snake_case` function names. Use `UPPER_SNAKE` for constants and global configuration, and `lower_case` for local variables. Prefer existing helpers in `PVE-Tools.sh`, especially logging, confirmation, backup, UI, and idempotent configuration functions. Web components should follow VitePress/Vue 3 conventions with ` -

Recovery Keys for '${userid}' - ${title} (${host})

-

-${keyString} -

- `; - - printFrame.src = 'data:text/html;base64,' + btoa(html); - document.body.appendChild(printFrame); - me.on('destroy', () => document.body.removeChild(printFrame)); - }, -}); -/*global QRCode*/ -Ext.define('Proxmox.window.AddTotp', { - extend: 'Proxmox.window.Edit', - alias: 'widget.pmxAddTotp', - mixins: ['Proxmox.Mixin.CBind'], - - onlineHelp: 'user_mgmt', - - modal: true, - resizable: false, - title: gettext('Add a TOTP login factor'), - width: 512, - layout: { - type: 'vbox', - align: 'stretch', - }, - - isAdd: true, - userid: undefined, - tfa_id: undefined, - issuerName: `Proxmox - ${document?.location?.hostname || 'unknown'}`, - fixedUser: false, - - updateQrCode: function () { - let me = this; - let values = me.lookup('totp_form').getValues(); - let algorithm = values.algorithm; - if (!algorithm) { - algorithm = 'SHA1'; - } - - let otpuri = - 'otpauth://totp/' + - encodeURIComponent(values.issuer) + - ':' + - encodeURIComponent(values.userid) + - '?secret=' + - values.secret + - '&period=' + - values.step + - '&digits=' + - values.digits + - '&algorithm=' + - algorithm + - '&issuer=' + - encodeURIComponent(values.issuer); - - me.getController().getViewModel().set('otpuri', otpuri); - me.qrcode.makeCode(otpuri); - me.lookup('challenge').setVisible(true); - me.down('#qrbox').setVisible(true); - }, - - viewModel: { - data: { - valid: false, - secret: '', - otpuri: '', - userid: null, - }, - - formulas: { - secretEmpty: function (get) { - return get('secret').length === 0; - }, - }, - }, - - controller: { - xclass: 'Ext.app.ViewController', - control: { - 'field[qrupdate=true]': { - change: function () { - this.getView().updateQrCode(); - }, - }, - field: { - validitychange: function (field, valid) { - let me = this; - let viewModel = me.getViewModel(); - let form = me.lookup('totp_form'); - let challenge = me.lookup('challenge'); - let password = me.lookup('password'); - viewModel.set( - 'valid', - form.isValid() && challenge.isValid() && password.isValid(), - ); - }, - }, - '#': { - show: function () { - let me = this; - let view = me.getView(); - - view.qrdiv = document.createElement('div'); - view.qrcode = new QRCode(view.qrdiv, { - width: 256, - height: 256, - correctLevel: QRCode.CorrectLevel.M, - }); - view.down('#qrbox').getEl().appendChild(view.qrdiv); - - view.getController().randomizeSecret(); - }, - }, - }, - - randomizeSecret: function () { - let me = this; - let rnd = new Uint8Array(32); - window.crypto.getRandomValues(rnd); - let data = ''; - rnd.forEach(function (b) { - // secret must be base32, so just use the first 5 bits - b = b & 0x1f; - if (b < 26) { - // A..Z - data += String.fromCharCode(b + 0x41); - } else { - // 2..7 - data += String.fromCharCode(b - 26 + 0x32); - } - }); - me.getViewModel().set('secret', data); - }, - }, - - items: [ - { - xtype: 'form', - layout: 'anchor', - border: false, - reference: 'totp_form', - fieldDefaults: { - anchor: '100%', - }, - items: [ - { - xtype: 'pmxDisplayEditField', - name: 'userid', - cbind: { - editable: (get) => get('isAdd') && !get('fixedUser'), - value: () => Proxmox.UserName, - }, - fieldLabel: gettext('User'), - editConfig: { - xtype: 'pmxUserSelector', - allowBlank: false, - }, - renderer: Ext.String.htmlEncode, - listeners: { - change: function (field, newValue, oldValue) { - let vm = this.up('window').getViewModel(); - vm.set('userid', newValue); - }, - }, - qrupdate: true, - }, - { - xtype: 'textfield', - fieldLabel: gettext('Description'), - emptyText: gettext( - 'For example: TFA device ID, required to identify multiple factors.', - ), - allowBlank: false, - name: 'description', - maxLength: 256, - }, - { - layout: 'hbox', - border: false, - padding: '0 0 5 0', - items: [ - { - xtype: 'textfield', - fieldLabel: gettext('Secret'), - emptyText: gettext('Unchanged'), - name: 'secret', - reference: 'tfa_secret', - regex: /^[A-Z2-7=]+$/, - regexText: 'Must be base32 [A-Z2-7=]', - maskRe: /[A-Z2-7=]/, - qrupdate: true, - bind: { - value: '{secret}', - }, - flex: 4, - padding: '0 5 0 0', - }, - { - xtype: 'button', - text: gettext('Randomize'), - reference: 'randomize_button', - handler: 'randomizeSecret', - flex: 1, - }, - ], - }, - { - xtype: 'numberfield', - fieldLabel: gettext('Time period'), - name: 'step', - // Google Authenticator ignores this and generates bogus data - hidden: true, - value: 30, - minValue: 10, - qrupdate: true, - }, - { - xtype: 'numberfield', - fieldLabel: gettext('Digits'), - name: 'digits', - value: 6, - // Google Authenticator ignores this and generates bogus data - hidden: true, - minValue: 6, - maxValue: 8, - qrupdate: true, - }, - { - xtype: 'textfield', - fieldLabel: gettext('Issuer Name'), - name: 'issuer', - cbind: { - value: '{issuerName}', - }, - qrupdate: true, - }, - { - xtype: 'box', - itemId: 'qrbox', - visible: false, // will be enabled when generating a qr code - bind: { - visible: '{!secretEmpty}', - }, - style: { - margin: '16px auto', - padding: '16px', - width: '288px', - height: '288px', - 'background-color': 'white', - }, - }, - { - xtype: 'textfield', - fieldLabel: gettext('Verify Code'), - allowBlank: false, - reference: 'challenge', - name: 'challenge', - bind: { - disabled: '{!showTOTPVerifiction}', - visible: '{showTOTPVerifiction}', - }, - emptyText: gettext('Scan QR code in a TOTP app and enter an auth. code here'), - }, - { - xtype: 'textfield', - name: 'password', - reference: 'password', - fieldLabel: gettext('Verify Password'), - inputType: 'password', - minLength: 5, - allowBlank: false, - validateBlank: true, - cbind: { - hidden: () => Proxmox.UserName === 'root@pam', - disabled: () => Proxmox.UserName === 'root@pam', - emptyText: () => - Ext.String.format( - gettext('Confirm your ({0}) password'), - Proxmox.UserName, - ), - }, - }, - ], - }, - ], - - initComponent: function () { - let me = this; - me.url = '/api2/extjs/access/tfa/'; - me.method = 'POST'; - me.callParent(); - }, - - getValues: function (dirtyOnly) { - let me = this; - let viewmodel = me.getController().getViewModel(); - - let values = me.callParent(arguments); - - let uid = encodeURIComponent(values.userid); - me.url = `/api2/extjs/access/tfa/${uid}`; - delete values.userid; - - let data = { - description: values.description, - type: 'totp', - totp: viewmodel.get('otpuri'), - value: values.challenge, - }; - - if (values.password) { - data.password = values.password; - } - - return data; - }, -}); -Ext.define('Proxmox.window.AddWebauthn', { - extend: 'Ext.window.Window', - alias: 'widget.pmxAddWebauthn', - mixins: ['Proxmox.Mixin.CBind'], - - onlineHelp: 'user_mgmt', - - modal: true, - resizable: false, - title: gettext('Add a Webauthn login token'), - width: 512, - - user: undefined, - fixedUser: false, - - initComponent: function () { - let me = this; - me.callParent(); - Ext.GlobalEvents.fireEvent('proxmoxShowHelp', me.onlineHelp); - }, - - viewModel: { - data: { - valid: false, - userid: null, - }, - }, - - controller: { - xclass: 'Ext.app.ViewController', - - control: { - field: { - validitychange: function (field, valid) { - let me = this; - let viewmodel = me.getViewModel(); - let form = me.lookup('webauthn_form'); - viewmodel.set('valid', form.isValid()); - }, - }, - '#': { - show: function () { - let me = this; - let view = me.getView(); - - if (Proxmox.UserName === 'root@pam') { - view.lookup('password').setVisible(false); - view.lookup('password').setDisabled(true); - } - }, - }, - }, - - registerWebauthn: async function () { - let me = this; - let values = me.lookup('webauthn_form').getValues(); - values.type = 'webauthn'; - - let userid = values.user; - delete values.user; - - me.getView().mask(gettext('Please wait...'), 'x-mask-loading'); - - try { - let register_response = await Proxmox.Async.api2({ - url: `/api2/extjs/access/tfa/${userid}`, - method: 'POST', - params: values, - }); - - let data = register_response.result.data; - if (!data.challenge) { - throw 'server did not respond with a challenge'; - } - - let creds = JSON.parse(data.challenge); - - // Fix this up before passing it to the browser, but keep a copy of the original - // string to pass in the response: - let challenge_str = creds.publicKey.challenge; - creds.publicKey.challenge = Proxmox.Utils.base64url_to_bytes(challenge_str); - creds.publicKey.user.id = Proxmox.Utils.base64url_to_bytes(creds.publicKey.user.id); - - // convert existing authenticators structure - creds.publicKey.excludeCredentials = (creds.publicKey.excludeCredentials || []).map( - (credential) => ({ - id: Proxmox.Utils.base64url_to_bytes(credential.id), - type: credential.type, - }), - ); - - let msg = Ext.Msg.show({ - title: `Webauthn: ${gettext('Setup')}`, - message: gettext('Please press the button on your Webauthn Device'), - buttons: [], - }); - - let token_response; - try { - token_response = await navigator.credentials.create(creds); - } catch (error) { - let errmsg = error.message; - if (error.name === 'InvalidStateError') { - errmsg = gettext('Is this token already registered?'); - } - throw ( - gettext('An error occurred during token registration.') + - `
${error.name}: ${errmsg}` - ); - } - - // We cannot pass ArrayBuffers to the API, so extract & convert the data. - let response = { - id: token_response.id, - type: token_response.type, - rawId: Proxmox.Utils.bytes_to_base64url(token_response.rawId), - response: { - attestationObject: Proxmox.Utils.bytes_to_base64url( - token_response.response.attestationObject, - ), - clientDataJSON: Proxmox.Utils.bytes_to_base64url( - token_response.response.clientDataJSON, - ), - }, - }; - - msg.close(); - - let params = { - type: 'webauthn', - challenge: challenge_str, - value: JSON.stringify(response), - }; - - if (values.password) { - params.password = values.password; - } - - await Proxmox.Async.api2({ - url: `/api2/extjs/access/tfa/${userid}`, - method: 'POST', - params, - }); - } catch (response) { - let error = response; - console.error(error); // for debugging if it's not displayable... - if (typeof error === 'object') { - // in case it came from an api request: - error = error.result?.message; - } - - Ext.Msg.alert(gettext('Error'), error); - } - - me.getView().close(); - }, - }, - - items: [ - { - xtype: 'form', - reference: 'webauthn_form', - layout: 'anchor', - border: false, - bodyPadding: 10, - fieldDefaults: { - anchor: '100%', - }, - items: [ - { - xtype: 'pmxDisplayEditField', - name: 'user', - cbind: { - editable: (get) => !get('fixedUser'), - value: () => Proxmox.UserName, - }, - fieldLabel: gettext('User'), - editConfig: { - xtype: 'pmxUserSelector', - allowBlank: false, - }, - renderer: Ext.String.htmlEncode, - listeners: { - change: function (field, newValue, oldValue) { - let vm = this.up('window').getViewModel(); - vm.set('userid', newValue); - }, - }, - }, - { - xtype: 'textfield', - fieldLabel: gettext('Description'), - allowBlank: false, - name: 'description', - maxLength: 256, - emptyText: gettext( - 'For example: TFA device ID, required to identify multiple factors.', - ), - }, - { - xtype: 'textfield', - name: 'password', - reference: 'password', - fieldLabel: gettext('Verify Password'), - inputType: 'password', - minLength: 5, - allowBlank: false, - validateBlank: true, - cbind: { - hidden: () => Proxmox.UserName === 'root@pam', - disabled: () => Proxmox.UserName === 'root@pam', - emptyText: () => - Ext.String.format( - gettext('Confirm your ({0}) password'), - Proxmox.UserName, - ), - }, - }, - ], - }, - ], - - buttons: [ - { - xtype: 'proxmoxHelpButton', - }, - '->', - { - xtype: 'button', - text: gettext('Register Webauthn Device'), - handler: 'registerWebauthn', - bind: { - disabled: '{!valid}', - }, - }, - ], -}); -Ext.define('Proxmox.window.AddYubico', { - extend: 'Proxmox.window.Edit', - alias: 'widget.pmxAddYubico', - mixins: ['Proxmox.Mixin.CBind'], - - onlineHelp: 'user_mgmt', - - modal: true, - resizable: false, - title: gettext('Add a Yubico OTP key'), - width: 512, - - isAdd: true, - userid: undefined, - fixedUser: false, - - initComponent: function () { - let me = this; - me.url = '/api2/extjs/access/tfa/'; - me.method = 'POST'; - me.callParent(); - }, - - viewModel: { - data: { - valid: false, - userid: null, - }, - }, - - controller: { - xclass: 'Ext.app.ViewController', - - control: { - field: { - validitychange: function (field, valid) { - let me = this; - let viewmodel = me.getViewModel(); - let form = me.lookup('yubico_form'); - viewmodel.set('valid', form.isValid()); - }, - }, - '#': { - show: function () { - let me = this; - let view = me.getView(); - - if (Proxmox.UserName === 'root@pam') { - view.lookup('password').setVisible(false); - view.lookup('password').setDisabled(true); - } - }, - }, - }, - }, - - items: [ - { - xtype: 'form', - reference: 'yubico_form', - layout: 'anchor', - border: false, - bodyPadding: 10, - fieldDefaults: { - anchor: '100%', - }, - items: [ - { - xtype: 'pmxDisplayEditField', - name: 'userid', - cbind: { - editable: (get) => !get('fixedUser'), - value: () => Proxmox.UserName, - }, - fieldLabel: gettext('User'), - editConfig: { - xtype: 'pmxUserSelector', - allowBlank: false, - }, - renderer: Ext.String.htmlEncode, - listeners: { - change: function (field, newValue, oldValue) { - let vm = this.up('window').getViewModel(); - vm.set('userid', newValue); - }, - }, - }, - { - xtype: 'textfield', - fieldLabel: gettext('Description'), - allowBlank: false, - name: 'description', - maxLength: 256, - emptyText: gettext( - 'For example: TFA device ID, required to identify multiple factors.', - ), - }, - { - xtype: 'textfield', - fieldLabel: gettext('Yubico OTP Key'), - emptyText: gettext('A currently valid Yubico OTP value'), - name: 'otp_value', - maxLength: 44, - enforceMaxLength: true, - regex: /^[a-zA-Z0-9]{44}$/, - regexText: '44 characters', - maskRe: /^[a-zA-Z0-9]$/, - }, - { - xtype: 'textfield', - name: 'password', - reference: 'password', - fieldLabel: gettext('Verify Password'), - inputType: 'password', - minLength: 5, - allowBlank: false, - validateBlank: true, - cbind: { - hidden: () => Proxmox.UserName === 'root@pam', - disabled: () => Proxmox.UserName === 'root@pam', - emptyText: () => - Ext.String.format( - gettext('Confirm your ({0}) password'), - Proxmox.UserName, - ), - }, - }, - { - xtype: 'box', - html: - `${gettext('Tip:')} ` + - gettext( - 'YubiKeys also support WebAuthn, which is often a better alternative.', - ), - }, - ], - }, - ], - - getValues: function (dirtyOnly) { - let me = this; - - let values = me.callParent(arguments); - - let uid = encodeURIComponent(values.userid); - me.url = `/api2/extjs/access/tfa/${uid}`; - delete values.userid; - - let data = { - description: values.description, - type: 'yubico', - value: values.otp_value, - }; - - if (values.password) { - data.password = values.password; - } - - return data; - }, -}); -Ext.define('Proxmox.window.TfaEdit', { - extend: 'Proxmox.window.Edit', - alias: 'widget.pmxTfaEdit', - mixins: ['Proxmox.Mixin.CBind'], - - onlineHelp: 'user_mgmt', - - modal: true, - resizable: false, - title: gettext("Modify a TFA entry's description"), - width: 512, - - layout: { - type: 'vbox', - align: 'stretch', - }, - - cbindData: function (initialConfig) { - let me = this; - - let tfa_id = initialConfig['tfa-id']; - me.tfa_id = tfa_id; - me.defaultFocus = 'textfield[name=description]'; - me.url = `/api2/extjs/access/tfa/${tfa_id}`; - me.method = 'PUT'; - me.autoLoad = true; - return {}; - }, - - initComponent: function () { - let me = this; - me.callParent(); - - if (Proxmox.UserName === 'root@pam') { - me.lookup('password').setVisible(false); - me.lookup('password').setDisabled(true); - } - - let userid = me.tfa_id.split('/')[0]; - me.lookup('userid').setValue(userid); - }, - - items: [ - { - xtype: 'displayfield', - reference: 'userid', - editable: false, - fieldLabel: gettext('User'), - editConfig: { - xtype: 'pmxUserSelector', - allowBlank: false, - }, - cbind: { - value: () => Proxmox.UserName, - }, - }, - { - xtype: 'proxmoxtextfield', - name: 'description', - allowBlank: false, - fieldLabel: gettext('Description'), - }, - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Enabled'), - name: 'enable', - uncheckedValue: 0, - defaultValue: 1, - checked: true, - }, - { - xtype: 'textfield', - inputType: 'password', - fieldLabel: gettext('Password'), - minLength: 5, - reference: 'password', - name: 'password', - allowBlank: false, - validateBlank: true, - emptyText: gettext('verify current password'), - }, - ], - - getValues: function () { - var me = this; - - var values = me.callParent(arguments); - - delete values.userid; - - return values; - }, -}); - -Ext.define('Proxmox.tfa.confirmRemove', { - extend: 'Proxmox.window.Edit', - mixins: ['Proxmox.Mixin.CBind'], - - title: gettext('Confirm TFA Removal'), - - modal: true, - resizable: false, - width: 600, - isCreate: true, // logic - isRemove: true, - - url: '/access/tfa', - - initComponent: function () { - let me = this; - - if (typeof me.type !== 'string') { - throw 'missing type'; - } - - if (!me.callback) { - throw 'missing callback'; - } - - me.callParent(); - - if (Proxmox.UserName === 'root@pam') { - me.lookup('password').setVisible(false); - me.lookup('password').setDisabled(true); - } - }, - - submit: function () { - let me = this; - if (Proxmox.UserName === 'root@pam') { - me.callback(null); - } else { - me.callback(me.lookup('password').getValue()); - } - me.close(); - }, - - items: [ - { - xtype: 'box', - padding: '0 0 10 0', - html: Ext.String.format( - gettext('Are you sure you want to remove this {0} entry?'), - 'TFA', - ), - }, - { - xtype: 'container', - layout: { - type: 'hbox', - align: 'begin', - }, - defaults: { - border: false, - layout: 'anchor', - flex: 1, - padding: 5, - }, - items: [ - { - xtype: 'container', - layout: { - type: 'vbox', - }, - padding: '0 10 0 0', - items: [ - { - xtype: 'displayfield', - fieldLabel: gettext('User'), - cbind: { - value: '{userid}', - }, - }, - { - xtype: 'displayfield', - fieldLabel: gettext('Type'), - cbind: { - value: '{type}', - }, - }, - ], - }, - { - xtype: 'container', - layout: { - type: 'vbox', - }, - padding: '0 0 0 10', - items: [ - { - xtype: 'displayfield', - fieldLabel: gettext('Created'), - renderer: (v) => Proxmox.Utils.render_timestamp(v), - cbind: { - value: '{created}', - }, - }, - { - xtype: 'textfield', - fieldLabel: gettext('Description'), - cbind: { - value: '{description}', - }, - emptyText: Proxmox.Utils.NoneText, - submitValue: false, - editable: false, - }, - ], - }, - ], - }, - { - xtype: 'textfield', - inputType: 'password', - fieldLabel: gettext('Password'), - minLength: 5, - reference: 'password', - name: 'password', - allowBlank: false, - validateBlank: true, - padding: '10 0 0 0', - cbind: { - emptyText: () => - Ext.String.format(gettext('Confirm your ({0}) password'), Proxmox.UserName), - }, - }, - ], -}); -Ext.define('Proxmox.window.NotesEdit', { - extend: 'Proxmox.window.Edit', - - title: gettext('Notes'), - onlineHelp: 'markdown_basics', - - width: 800, - height: 600, - - resizable: true, - layout: 'fit', - - autoLoad: true, - defaultButton: undefined, - - setMaxLength: function (maxLength) { - let me = this; - - let area = me.down('textarea[name="description"]'); - area.maxLength = maxLength; - area.validate(); - - return me; - }, - - items: { - xtype: 'textarea', - name: 'description', - height: '100%', - value: '', - hideLabel: true, - emptyText: gettext('You can use Markdown for rich text formatting.'), - fieldStyle: { - 'white-space': 'pre-wrap', - 'font-family': 'monospace', - }, - }, -}); -Ext.define('Proxmox.window.ThemeEditWindow', { - extend: 'Ext.window.Window', - alias: 'widget.pmxThemeEditWindow', - - viewModel: { - parent: null, - data: {}, - }, - controller: { - xclass: 'Ext.app.ViewController', - init: function (view) { - let theme = '__default__'; - - let savedTheme = Ext.util.Cookies.get(view.cookieName); - if (savedTheme && savedTheme in Proxmox.Utils.theme_map) { - theme = savedTheme; - } - this.getViewModel().set('theme', theme); - }, - applyTheme: function (button) { - let view = this.getView(); - let vm = this.getViewModel(); - - let expire = Ext.Date.add(new Date(), Ext.Date.YEAR, 10); - Ext.util.Cookies.set(view.cookieName, vm.get('theme'), expire); - view.mask(gettext('Please wait...'), 'x-mask-loading'); - window.location.reload(); - }, - }, - - cookieName: 'PVEThemeCookie', - - title: gettext('Color Theme'), - modal: true, - bodyPadding: 10, - resizable: false, - items: [ - { - xtype: 'proxmoxThemeSelector', - fieldLabel: gettext('Color Theme'), - bind: { - value: '{theme}', - }, - }, - ], - buttons: [ - { - text: gettext('Apply'), - handler: 'applyTheme', - }, - ], -}); -Ext.define('Proxmox.window.SyncWindow', { - extend: 'Ext.window.Window', - - title: gettext('Realm Sync'), - - width: 600, - bodyPadding: 10, - modal: true, - resizable: false, - - controller: { - xclass: 'Ext.app.ViewController', - - control: { - form: { - validitychange: function (field, valid) { - this.lookup('preview_btn').setDisabled(!valid); - this.lookup('sync_btn').setDisabled(!valid); - }, - }, - button: { - click: function (btn) { - this.sync_realm(btn.reference === 'preview_btn'); - }, - }, - }, - - sync_realm: function (is_preview) { - let view = this.getView(); - let ipanel = this.lookup('ipanel'); - let params = ipanel.getValues(); - - let vanished_opts = []; - ['acl', 'entry', 'properties'].forEach((prop) => { - if (params[`remove-vanished-${prop}`]) { - vanished_opts.push(prop); - } - delete params[`remove-vanished-${prop}`]; - }); - if (vanished_opts.length > 0) { - params['remove-vanished'] = vanished_opts.join(';'); - } - - params['dry-run'] = is_preview ? 1 : 0; - Proxmox.Utils.API2Request({ - url: `/access/domains/${view.realm}/sync`, - waitMsgTarget: view, - method: 'POST', - params, - failure: (response) => { - view.show(); - Ext.Msg.alert(gettext('Error'), response.htmlStatus); - }, - success: (response) => { - view.hide(); - Ext.create('Proxmox.window.TaskViewer', { - upid: response.result.data, - listeners: { - destroy: () => { - if (is_preview) { - view.show(); - } else { - view.close(); - } - }, - }, - }).show(); - }, - }); - }, - }, - - items: [ - { - xtype: 'form', - reference: 'form', - border: false, - fieldDefaults: { - labelWidth: 100, - anchor: '100%', - }, - items: [ - { - xtype: 'inputpanel', - reference: 'ipanel', - column1: [ - { - xtype: 'proxmoxKVComboBox', - value: 'true', - deleteEmpty: false, - allowBlank: false, - comboItems: [ - ['true', Proxmox.Utils.yesText], - ['false', Proxmox.Utils.noText], - ], - name: 'enable-new', - fieldLabel: gettext('Enable new'), - }, - ], - - column2: [], - - columnB: [ - { - xtype: 'fieldset', - title: gettext('Remove Vanished Options'), - items: [ - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('ACL'), - name: 'remove-vanished-acl', - boxLabel: gettext('Remove ACLs of vanished users and groups.'), - }, - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Entry'), - name: 'remove-vanished-entry', - boxLabel: gettext('Remove vanished user and group entries.'), - }, - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Properties'), - name: 'remove-vanished-properties', - boxLabel: gettext( - 'Remove vanished properties from synced users.', - ), - }, - ], - }, - { - xtype: 'displayfield', - reference: 'defaulthint', - value: gettext('Default sync options can be set by editing the realm.'), - userCls: 'pmx-hint', - hidden: true, - }, - ], - }, - ], - }, - ], - - buttons: [ - '->', - { - text: gettext('Preview'), - reference: 'preview_btn', - }, - { - text: gettext('Sync'), - reference: 'sync_btn', - }, - ], - - initComponent: function () { - if (!this.realm) { - throw 'no realm defined'; - } - - if (!this.type) { - throw 'no realm type defined'; - } - - this.callParent(); - - Proxmox.Utils.API2Request({ - url: `/config/access/${this.type}/${this.realm}`, - waitMsgTarget: this, - method: 'GET', - failure: (response) => { - Ext.Msg.alert(gettext('Error'), response.htmlStatus); - this.close(); - }, - success: (response) => { - let default_options = response.result.data['sync-defaults-options']; - if (default_options) { - let options = Proxmox.Utils.parsePropertyString(default_options); - if (options['remove-vanished']) { - let opts = options['remove-vanished'].split(';'); - for (const opt of opts) { - options[`remove-vanished-${opt}`] = 1; - } - } - let ipanel = this.lookup('ipanel'); - ipanel.setValues(options); - } else { - this.lookup('defaulthint').setVisible(true); - } - - // check validity for button state - this.lookup('form').isValid(); - }, - }); - }, -}); -Ext.define('apt-pkglist', { - extend: 'Ext.data.Model', - fields: [ - 'Package', - 'Title', - 'Description', - 'Section', - 'Arch', - 'Priority', - 'Version', - 'OldVersion', - 'Origin', - ], - idProperty: 'Package', -}); - -Ext.define('Proxmox.node.APT', { - extend: 'Ext.grid.GridPanel', - - xtype: 'proxmoxNodeAPT', - - upgradeBtn: undefined, - - columns: [ - { - header: gettext('Package'), - width: 200, - sortable: true, - dataIndex: 'Package', - }, - { - text: gettext('Version'), - columns: [ - { - header: gettext('current'), - width: 100, - sortable: false, - dataIndex: 'OldVersion', - }, - { - header: gettext('new'), - width: 100, - sortable: false, - dataIndex: 'Version', - }, - ], - }, - { - header: gettext('Description'), - sortable: false, - dataIndex: 'Title', - flex: 1, - }, - ], - - initComponent: function () { - let me = this; - - if (!me.nodename) { - throw 'no node name specified'; - } - - let store = Ext.create('Ext.data.Store', { - model: 'apt-pkglist', - groupField: 'Origin', - proxy: { - type: 'proxmox', - url: `/api2/json/nodes/${me.nodename}/apt/update`, - }, - sorters: [ - { - property: 'Package', - direction: 'ASC', - }, - ], - }); - Proxmox.Utils.monStoreErrors(me, store, true); - - let groupingFeature = Ext.create('Ext.grid.feature.Grouping', { - groupHeaderTpl: - '{[ "Origin: " + values.name ]} ({rows.length} Item{[values.rows.length > 1 ? "s" : ""]})', - enableGroupingMenu: false, - }); - - let rowBodyFeature = Ext.create('Ext.grid.feature.RowBody', { - getAdditionalData: function (data, rowIndex, record, orig) { - let headerCt = this.view.headerCt; - let colspan = headerCt.getColumnCount(); - return { - rowBody: `
${Ext.htmlEncode(data.Description)}
`, - rowBodyCls: me.full_description - ? '' - : Ext.baseCSSPrefix + 'grid-row-body-hidden', - rowBodyColspan: colspan, - }; - }, - }); - - let apt_command = function (cmd) { - Proxmox.Utils.API2Request({ - url: `/nodes/${me.nodename}/apt/${cmd}`, - method: 'POST', - success: ({ result }) => - Ext.create('Proxmox.window.TaskViewer', { - autoShow: true, - upid: result.data, - listeners: { - close: () => store.load(), - }, - }), - }); - }; - - let sm = Ext.create('Ext.selection.RowModel', {}); - - let update_btn = new Ext.Button({ - text: gettext('Refresh'), - handler: () => - Proxmox.Utils.checked_command(function () { - apt_command('update'); - }), - }); - - let show_changelog = function (rec) { - if (!rec?.data?.Package) { - console.debug('cannot show changelog, missing Package', rec); - return; - } - - let view = Ext.createWidget('component', { - autoScroll: true, - style: { - 'white-space': 'pre', - 'font-family': 'monospace', - padding: '5px', - }, - }); - - let win = Ext.create('Ext.window.Window', { - title: gettext('Changelog') + ': ' + rec.data.Package, - width: 800, - height: 600, - layout: 'fit', - modal: true, - items: [view], - }); - - Proxmox.Utils.API2Request({ - waitMsgTarget: me, - url: '/nodes/' + me.nodename + '/apt/changelog', - params: { - name: rec.data.Package, - version: rec.data.Version, - }, - method: 'GET', - failure: function (response, opts) { - win.close(); - Ext.Msg.alert(gettext('Error'), response.htmlStatus); - }, - success: function (response, opts) { - win.show(); - view.update(Ext.htmlEncode(response.result.data)); - }, - }); - }; - - let changelog_btn = new Proxmox.button.Button({ - text: gettext('Changelog'), - selModel: sm, - disabled: true, - enableFn: (rec) => !!rec?.data?.Package, - handler: (b, e, rec) => show_changelog(rec), - }); - - let verbose_desc_checkbox = new Ext.form.field.Checkbox({ - boxLabel: gettext('Show details'), - value: false, - listeners: { - change: (f, val) => { - me.full_description = val; - me.getView().refresh(); - }, - }, - }); - - if (me.upgradeBtn) { - me.tbar = [update_btn, me.upgradeBtn, changelog_btn, '->', verbose_desc_checkbox]; - } else { - me.tbar = [update_btn, changelog_btn, '->', verbose_desc_checkbox]; - } - - Ext.apply(me, { - store: store, - stateful: true, - stateId: 'grid-update', - selModel: sm, - viewConfig: { - stripeRows: false, - emptyText: `

${gettext('No updates available.')}

`, - }, - features: [groupingFeature, rowBodyFeature], - listeners: { - activate: () => store.load(), - itemdblclick: (v, rec) => show_changelog(rec), - }, - }); - - me.callParent(); - }, -}); -Ext.define('apt-repolist', { - extend: 'Ext.data.Model', - fields: [ - 'Path', - 'Index', - 'Origin', - 'FileType', - 'Enabled', - 'Comment', - 'Types', - 'URIs', - 'Suites', - 'Components', - 'Options', - ], -}); - -Ext.define('Proxmox.window.APTRepositoryAdd', { - extend: 'Proxmox.window.Edit', - alias: 'widget.pmxAPTRepositoryAdd', - - isCreate: true, - isAdd: true, - - subject: gettext('Repository'), - width: 600, - - initComponent: function () { - let me = this; - - if (!me.repoInfo || me.repoInfo.length === 0) { - throw 'repository information not initialized'; - } - - let description = Ext.create('Ext.form.field.Display', { - fieldLabel: gettext('Description'), - name: 'description', - }); - - let status = Ext.create('Ext.form.field.Display', { - fieldLabel: gettext('Status'), - name: 'status', - renderer: function (value) { - let statusText = gettext('Not yet configured'); - if (value !== '') { - statusText = Ext.String.format( - '{0}: {1}', - gettext('Configured'), - value ? gettext('enabled') : gettext('disabled'), - ); - } - - return statusText; - }, - }); - - let repoSelector = Ext.create('Proxmox.form.KVComboBox', { - fieldLabel: gettext('Repository'), - xtype: 'proxmoxKVComboBox', - name: 'handle', - allowBlank: false, - comboItems: me.repoInfo.map((info) => [info.handle, info.name]), - validator: function (renderedValue) { - let handle = this.value; - // we cannot use this.callParent in instantiations - let valid = Proxmox.form.KVComboBox.prototype.validator.call(this, renderedValue); - - if (!valid || !handle) { - return false; - } - - const info = me.repoInfo.find((elem) => elem.handle === handle); - if (!info) { - return false; - } - - if (info.status) { - return Ext.String.format(gettext('{0} is already configured'), renderedValue); - } - return valid; - }, - listeners: { - change: function (f, value) { - const info = me.repoInfo.find((elem) => elem.handle === value); - description.setValue(info.description); - status.setValue(info.status); - }, - }, - }); - - repoSelector.setValue(me.repoInfo[0].handle); - - Ext.apply(me, { - items: [repoSelector, description, status], - repoSelector: repoSelector, - }); - - me.callParent(); - }, -}); - -Ext.define('Proxmox.node.APTRepositoriesErrors', { - extend: 'Ext.grid.GridPanel', - - xtype: 'proxmoxNodeAPTRepositoriesErrors', - - store: {}, - - scrollable: true, - - viewConfig: { - stripeRows: false, - getRowClass: (record) => { - switch (record.data.status) { - case 'warning': - return 'proxmox-warning-row'; - case 'critical': - return 'proxmox-invalid-row'; - default: - return ''; - } - }, - }, - - hideHeaders: true, - - columns: [ - { - dataIndex: 'status', - renderer: (value) => - ``, - width: 50, - }, - { - dataIndex: 'message', - flex: 1, - }, - ], -}); - -Ext.define('Proxmox.node.APTRepositoriesGrid', { - extend: 'Ext.grid.GridPanel', - xtype: 'proxmoxNodeAPTRepositoriesGrid', - mixins: ['Proxmox.Mixin.CBind'], - - title: gettext('APT Repositories'), - - cls: 'proxmox-apt-repos', // to allow applying styling to general components with local effect - - border: false, - - tbar: [ - { - text: gettext('Reload'), - iconCls: 'fa fa-refresh', - handler: function () { - let me = this; - me.up('proxmoxNodeAPTRepositories').reload(); - }, - }, - { - text: gettext('Add'), - name: 'addRepo', - disabled: true, - repoInfo: undefined, - cbind: { - onlineHelp: '{onlineHelp}', - }, - handler: function (button, event, record) { - Proxmox.Utils.checked_command(() => { - let me = this; - let panel = me.up('proxmoxNodeAPTRepositories'); - - let extraParams = {}; - if (panel.digest !== undefined) { - extraParams.digest = panel.digest; - } - - Ext.create('Proxmox.window.APTRepositoryAdd', { - repoInfo: me.repoInfo, - url: `/api2/extjs/nodes/${panel.nodename}/apt/repositories`, - method: 'PUT', - extraRequestParams: extraParams, - onlineHelp: me.onlineHelp, - listeners: { - destroy: function () { - panel.reload(); - }, - }, - }).show(); - }); - }, - }, - '-', - { - xtype: 'proxmoxAltTextButton', - defaultText: gettext('Enable'), - altText: gettext('Disable'), - name: 'repoEnable', - disabled: true, - bind: { - text: '{enableButtonText}', - }, - handler: function (button, event, record) { - let me = this; - let panel = me.up('proxmoxNodeAPTRepositories'); - - let params = { - path: record.data.Path, - index: record.data.Index, - enabled: record.data.Enabled ? 0 : 1, // invert - }; - - if (panel.digest !== undefined) { - params.digest = panel.digest; - } - - Proxmox.Utils.API2Request({ - url: `/nodes/${panel.nodename}/apt/repositories`, - method: 'POST', - params: params, - failure: function (response, opts) { - Ext.Msg.alert(gettext('Error'), response.htmlStatus); - panel.reload(); - }, - success: function (response, opts) { - panel.reload(); - }, - }); - }, - }, - ], - - sortableColumns: false, - viewConfig: { - stripeRows: false, - getRowClass: (record, index) => (record.get('Enabled') ? '' : 'proxmox-disabled-row'), - }, - - columns: [ - { - header: gettext('Enabled'), - dataIndex: 'Enabled', - align: 'center', - renderer: Proxmox.Utils.renderEnabledIcon, - width: 90, - }, - { - header: gettext('Types'), - dataIndex: 'Types', - renderer: function (types, cell, record) { - return types.join(' '); - }, - width: 100, - }, - { - header: gettext('URIs'), - dataIndex: 'URIs', - renderer: function (uris, cell, record) { - return uris.join(' '); - }, - width: 350, - }, - { - header: gettext('Suites'), - dataIndex: 'Suites', - renderer: function (suites, metaData, record) { - let err = ''; - if (record.data.warnings && record.data.warnings.length > 0) { - let txt = [ngettext('Warning', 'Warnings', 1)]; - record.data.warnings.forEach((warning) => { - if (warning.property === 'Suites') { - txt.push(Ext.htmlEncode(warning.message)); - } - }); - metaData.tdAttr = `data-qtip="${Ext.htmlEncode(txt.join('
'))}"`; - if (record.data.Enabled) { - metaData.tdCls = 'proxmox-invalid-row'; - err = ' '; - } else { - metaData.tdCls = 'proxmox-warning-row'; - err = ' '; - } - } - return suites.join(' ') + err; - }, - width: 130, - }, - { - header: gettext('Components'), - dataIndex: 'Components', - renderer: function (components, metaData, record) { - if (components === undefined) { - return ''; - } - let err = ''; - if (components.length === 1) { - // FIXME: this should be a flag set to the actual repsotiories, i.e., a tristate - // like production-ready = (Option) - if (components[0].match(/\w+(-no-subscription|test)\s*$/i)) { - metaData.tdCls = 'proxmox-warning-row'; - err = ' '; - - let qtip = components[0].match(/no-subscription/) - ? gettext('The no-subscription repository is NOT production-ready') - : gettext('The test repository may contain unstable updates'); - metaData.tdAttr = `data-qtip="${Ext.htmlEncode(Ext.htmlEncode(qtip))}"`; - } - } - return components.join(' ') + err; - }, - width: 170, - }, - { - header: gettext('Options'), - dataIndex: 'Options', - renderer: function (options, cell, record) { - if (!options) { - return ''; - } - - let filetype = record.data.FileType; - let text = ''; - - options.forEach(function (option) { - let key = option.Key; - if (filetype === 'list') { - let values = option.Values.join(','); - text += `${key}=${values} `; - } else if (filetype === 'sources') { - let values = option.Values.join(' '); - text += `${key}: ${values}
`; - } else { - throw 'unknown file type'; - } - }); - return text; - }, - flex: 1, - }, - { - header: gettext('Origin'), - dataIndex: 'Origin', - width: 120, - renderer: function (value, meta, rec) { - if (typeof value !== 'string' || value.length === 0) { - value = gettext('Other'); - } - let cls = 'fa fa-fw fa-question-circle-o'; - let originType = this.up('proxmoxNodeAPTRepositories').classifyOrigin(value); - if (originType === 'Proxmox') { - cls = 'pmx-itype-icon pmx-itype-icon-proxmox-x'; - } else if (originType === 'Debian') { - cls = 'pmx-itype-icon pmx-itype-icon-debian-swirl'; - } - return ` ${value}`; - }, - }, - { - header: gettext('Comment'), - dataIndex: 'Comment', - flex: 2, - renderer: Ext.String.htmlEncode, - }, - ], - - features: [ - { - ftype: 'grouping', - groupHeaderTpl: - '{[ "File: " + values.name ]} ({rows.length} repositor{[values.rows.length > 1 ? "ies" : "y"]})', - enableGroupingMenu: false, - }, - ], - - store: { - model: 'apt-repolist', - groupField: 'Path', - sorters: [ - { - property: 'Index', - direction: 'ASC', - }, - ], - }, - - initComponent: function () { - let me = this; - - if (!me.nodename) { - throw 'no node name specified'; - } - - me.callParent(); - }, -}); - -Ext.define('Proxmox.node.APTRepositories', { - extend: 'Ext.panel.Panel', - xtype: 'proxmoxNodeAPTRepositories', - mixins: ['Proxmox.Mixin.CBind'], - - digest: undefined, - - onlineHelp: undefined, - - product: 'Proxmox VE', // default - - classifyOrigin: function (origin) { - origin ||= ''; - if (origin.match(/^\s*Proxmox\s*$/i)) { - return 'Proxmox'; - } else if (origin.match(/^\s*Debian\s*(:?Backports)?$/i)) { - return 'Debian'; - } - return 'Other'; - }, - - controller: { - xclass: 'Ext.app.ViewController', - - selectionChange: function (grid, selection) { - let me = this; - if (!selection || selection.length < 1) { - return; - } - let rec = selection[0]; - let vm = me.getViewModel(); - vm.set('selectionenabled', rec.get('Enabled')); - vm.notify(); - }, - - updateState: function () { - let me = this; - let vm = me.getViewModel(); - - let store = vm.get('errorstore'); - store.removeAll(); - - let status = 'good'; // start with best, the helper below will downgrade if needed - let text = gettext('All OK, you have production-ready repositories configured!'); - - let addGood = (message) => store.add({ status: 'good', message }); - let addWarn = (message, important) => { - if (status !== 'critical') { - status = 'warning'; - text = important ? message : ngettext('Warning', 'Warnings', 1); - } - store.add({ status: 'warning', message }); - }; - let addCritical = (message, important) => { - status = 'critical'; - text = important ? message : gettext('Error'); - store.add({ status: 'critical', message }); - }; - - let errors = vm.get('errors'); - errors.forEach((error) => addCritical(`${error.path} - ${error.error}`)); - - let activeSubscription = vm.get('subscriptionActive'); - let enterprise = vm.get('enterpriseRepo'); - let nosubscription = vm.get('noSubscriptionRepo'); - let test = vm.get('testRepo'); - let cephRepos = { - enterprise: vm.get('cephEnterpriseRepo'), - nosubscription: vm.get('cephNoSubscriptionRepo'), - test: vm.get('cephTestRepo'), - }; - let wrongSuites = vm.get('suitesWarning'); - let mixedSuites = vm.get('mixedSuites'); - - if (!enterprise && !nosubscription && !test) { - addCritical( - Ext.String.format( - gettext('No {0} repository is enabled, you do not get any updates!'), - vm.get('product'), - ), - ); - } else if (errors.length > 0) { - // nothing extra, just avoid that we show "get updates" - } else if (enterprise && !nosubscription && !test && activeSubscription) { - addGood( - Ext.String.format( - gettext('You get supported updates for {0}'), - vm.get('product'), - ), - ); - } else if (nosubscription || test) { - addGood(Ext.String.format(gettext('You get updates for {0}'), vm.get('product'))); - } - - if (wrongSuites) { - addWarn(gettext('Some suites are misconfigured')); - } - - if (mixedSuites) { - addWarn(gettext('Detected mixed suites before upgrade')); - } - - let productionReadyCheck = (repos, type, noSubAlternateName) => { - if (!activeSubscription && repos.enterprise) { - addWarn( - Ext.String.format( - gettext( - 'The {0}enterprise repository is enabled, but there is no active subscription!', - ), - type, - ), - ); - } - - if (repos.nosubscription) { - addWarn( - Ext.String.format( - gettext( - 'The {0}no-subscription{1} repository is not recommended for production use!', - ), - type, - noSubAlternateName, - ), - ); - } - - if (repos.test) { - addWarn( - Ext.String.format( - gettext( - 'The {0}test repository may pull in unstable updates and is not recommended for production use!', - ), - type, - ), - ); - } - }; - - productionReadyCheck({ enterprise, nosubscription, test }, '', ''); - // TODO drop alternate 'main' name when no longer relevant - productionReadyCheck(cephRepos, 'Ceph ', '/main'); - - if (errors.length > 0) { - text = gettext('Fatal parsing error for at least one repository'); - } - - let iconCls = Proxmox.Utils.get_health_icon(status, true); - - vm.set('state', { - iconCls, - text, - }); - }, - }, - - viewModel: { - data: { - product: 'Proxmox VE', // default - errors: [], - suitesWarning: false, - mixedSuites: false, // used before major upgrade - subscriptionActive: '', - noSubscriptionRepo: '', - enterpriseRepo: '', - testRepo: '', - cephEnterpriseRepo: '', - cephNoSubscriptionRepo: '', - cephTestRepo: '', - selectionenabled: false, - state: {}, - }, - formulas: { - enableButtonText: (get) => - get('selectionenabled') ? gettext('Disable') : gettext('Enable'), - }, - stores: { - errorstore: { - fields: ['status', 'message'], - }, - }, - }, - - scrollable: true, - layout: { - type: 'vbox', - align: 'stretch', - }, - - items: [ - { - xtype: 'panel', - border: false, - layout: { - type: 'hbox', - align: 'stretch', - }, - height: 200, - title: gettext('Status'), - items: [ - { - xtype: 'box', - flex: 2, - margin: 10, - data: { - iconCls: Proxmox.Utils.get_health_icon(undefined, true), - text: '', - }, - bind: { - data: '{state}', - }, - tpl: [ - '
', - '', - '{text}', - '
', - ], - }, - { - xtype: 'proxmoxNodeAPTRepositoriesErrors', - name: 'repositoriesErrors', - flex: 7, - margin: 10, - bind: { - store: '{errorstore}', - }, - }, - ], - }, - { - xtype: 'proxmoxNodeAPTRepositoriesGrid', - name: 'repositoriesGrid', - flex: 1, - cbind: { - nodename: '{nodename}', - onlineHelp: '{onlineHelp}', - }, - majorUpgradeAllowed: false, // TODO get release information from an API call? - listeners: { - selectionchange: 'selectionChange', - }, - }, - ], - - check_subscription: function () { - let me = this; - let vm = me.getViewModel(); - - Proxmox.Utils.API2Request({ - url: `/nodes/${me.nodename}/subscription`, - method: 'GET', - failure: (response, opts) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), - success: function (response, opts) { - const res = response.result; - const subscription = !( - !res || - !res.data || - res.data.status.toLowerCase() !== 'active' - ); - vm.set('subscriptionActive', subscription); - me.getController().updateState(); - }, - }); - }, - - updateStandardRepos: function (standardRepos) { - let me = this; - let vm = me.getViewModel(); - - let addButton = me.down('button[name=addRepo]'); - - addButton.repoInfo = []; - for (const standardRepo of standardRepos) { - const handle = standardRepo.handle; - const status = standardRepo.status; - - if (handle === 'enterprise') { - vm.set('enterpriseRepo', status); - } else if (handle === 'no-subscription') { - vm.set('noSubscriptionRepo', status); - } else if (handle === 'test') { - vm.set('testRepo', status); - } else if (handle.match(/^ceph-[a-zA-Z]+-enterprise$/)) { - vm.set('cephEnterpriseRepo', status); - } else if (handle.match(/^ceph-[a-zA-Z]+-no-subscription$/)) { - vm.set('cephNoSubscriptionRepo', status); - } else if (handle.match(/^ceph-[a-zA-Z]+-test$/)) { - vm.set('cephTestRepo', status); - } - me.getController().updateState(); - - addButton.repoInfo.push(standardRepo); - addButton.digest = me.digest; - } - - addButton.setDisabled(false); - }, - - reload: function () { - let me = this; - let vm = me.getViewModel(); - let repoGrid = me.down('proxmoxNodeAPTRepositoriesGrid'); - - me.store.load(function (records, operation, success) { - let gridData = []; - let errors = []; - let digest; - let suitesWarning = false; - - // Usually different suites will give errors anyways, but before a major upgrade the - // current and the next suite are allowed, so it makes sense to check for mixed suites. - let checkMixedSuites = false; - let mixedSuites = false; - - if (success && records.length > 0) { - let data = records[0].data; - let files = data.files; - errors = data.errors; - digest = data.digest; - - let infos = {}; - for (const info of data.infos) { - let path = info.path; - let idx = info.index; - - if (!infos[path]) { - infos[path] = {}; - } - if (!infos[path][idx]) { - infos[path][idx] = { - origin: '', - warnings: [], - // Used as a heuristic to detect mixed repositories pre-upgrade. The - // warning is set on all repositories that do configure the next suite. - gotIgnorePreUpgradeWarning: false, - }; - } - - if (info.kind === 'origin') { - infos[path][idx].origin = info.message; - } else if (info.kind === 'warning') { - infos[path][idx].warnings.push(info); - } else if (info.kind === 'ignore-pre-upgrade-warning') { - infos[path][idx].gotIgnorePreUpgradeWarning = true; - if (!repoGrid.majorUpgradeAllowed) { - infos[path][idx].warnings.push(info); - } else { - checkMixedSuites = true; - } - } - } - - files.forEach(function (file) { - for (let n = 0; n < file.repositories.length; n++) { - let repo = file.repositories[n]; - repo.Path = file.path; - repo.Index = n; - if (infos[file.path] && infos[file.path][n]) { - repo.Origin = infos[file.path][n].origin || Proxmox.Utils.unknownText; - repo.warnings = infos[file.path][n].warnings || []; - - if (repo.Enabled) { - if (repo.warnings.some((w) => w.property === 'Suites')) { - suitesWarning = true; - } - - let originType = me.classifyOrigin(repo.Origin); - // Only Proxmox and Debian repositories checked here, because the - // warning can be missing for others for a different reason (e.g. - // using 'stable' or non-Debian code names). - if ( - checkMixedSuites && - repo.Types.includes('deb') && - (originType === 'Proxmox' || originType === 'Debian') && - !infos[file.path][n].gotIgnorePreUpgradeWarning - ) { - mixedSuites = true; - } - } - } - gridData.push(repo); - } - }); - - repoGrid.store.loadData(gridData); - - me.updateStandardRepos(data['standard-repos']); - } - - me.digest = digest; - - vm.set('errors', errors); - vm.set('suitesWarning', suitesWarning); - vm.set('mixedSuites', mixedSuites); - me.getController().updateState(); - }); - - me.check_subscription(); - }, - - listeners: { - activate: function () { - let me = this; - me.reload(); - }, - }, - - initComponent: function () { - let me = this; - - if (!me.nodename) { - throw 'no node name specified'; - } - - let store = Ext.create('Ext.data.Store', { - proxy: { - type: 'proxmox', - url: `/api2/json/nodes/${me.nodename}/apt/repositories`, - }, - }); - - Ext.apply(me, { store: store }); - - Proxmox.Utils.monStoreErrors(me, me.store, true); - - me.callParent(); - - me.getViewModel().set('product', me.product); - }, -}); -Ext.define('Proxmox.node.NetworkEdit', { - extend: 'Proxmox.window.Edit', - alias: ['widget.proxmoxNodeNetworkEdit'], - - // Enable to show the VLAN ID field - enableBridgeVlanIds: false, - - // Show alternative names below normal name - showAltNames: false, - - initComponent: function () { - let me = this; - - if (!me.nodename) { - throw 'no node name specified'; - } - - if (!me.iftype) { - throw 'no network device type specified'; - } - - me.isCreate = !me.iface; - - let iface_vtype; - - if (me.iftype === 'bridge') { - iface_vtype = 'BridgeName'; - } else if (me.iftype === 'bond') { - iface_vtype = 'BondName'; - } else if (me.iftype === 'eth' && !me.isCreate) { - iface_vtype = 'InterfaceName'; - } else if (me.iftype === 'vlan') { - iface_vtype = 'VlanName'; - } else if (me.iftype === 'OVSBridge') { - iface_vtype = 'BridgeName'; - } else if (me.iftype === 'OVSBond') { - iface_vtype = 'BondName'; - } else if (me.iftype === 'OVSIntPort') { - iface_vtype = 'InterfaceName'; - } else if (me.iftype === 'OVSPort') { - iface_vtype = 'InterfaceName'; - } else { - console.log(me.iftype); - throw 'unknown network device type specified'; - } - - me.subject = Proxmox.Utils.render_network_iface_type(me.iftype); - - let column1 = [], - column2 = [], - columnB = [], - advancedColumn1 = [], - advancedColumn2 = []; - - if (!(me.iftype === 'OVSIntPort' || me.iftype === 'OVSPort' || me.iftype === 'OVSBond')) { - column2.push({ - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Autostart'), - name: 'autostart', - uncheckedValue: 0, - checked: me.isCreate ? true : undefined, - }); - } - - if (me.iftype === 'bridge') { - let vlanIdsField = !me.enableBridgeVlanIds - ? undefined - : Ext.create('Ext.form.field.Text', { - fieldLabel: gettext('VLAN IDs'), - name: 'bridge_vids', - emptyText: '2-4094', - disabled: true, - autoEl: { - tag: 'div', - 'data-qtip': gettext( - "List of VLAN IDs and ranges, useful for NICs with restricted VLAN offloading support. For example: '2 4 100-200'", - ), - }, - validator: function (value) { - if (!value) { - // empty - return true; - } - - for (const vid of value.split(/\s+[,;]?/)) { - if (!vid) { - continue; - } - let res = vid.match(/^(\d+)(?:-(\d+))?$/); - if (!res) { - return Ext.String.format( - gettext('not a valid bridge VLAN ID entry: {0}'), - vid, - ); - } - let start = Number(res[1]), - end = Number(res[2] ?? res[1]); // end=start for single IDs - - if (Number.isNaN(start) || Number.isNaN(end)) { - return Ext.String.format( - gettext('VID range includes not-a-number: {0}'), - vid, - ); - } else if (start > end) { - return Ext.String.format( - gettext('VID range must go from lower to higher tag: {0}'), - vid, - ); - } else if (start < 2 || end > 4094) { - // check just one each, we already ensured start < end - return Ext.String.format( - gettext('VID range outside of allowed 2 and 4094 limit: {0}'), - vid, - ); - } - } - return true; - }, - }); - column2.push({ - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('VLAN aware'), - name: 'bridge_vlan_aware', - deleteEmpty: !me.isCreate, - listeners: { - change: function (f, newVal) { - if (vlanIdsField) { - vlanIdsField.setDisabled(!newVal); - } - }, - }, - }); - column2.push({ - xtype: 'textfield', - fieldLabel: gettext('Bridge ports'), - name: 'bridge_ports', - autoEl: { - tag: 'div', - 'data-qtip': gettext( - 'Space-separated list of interfaces, for example: enp0s0 enp1s0', - ), - }, - }); - if (vlanIdsField) { - advancedColumn2.push(vlanIdsField); - } - } else if (me.iftype === 'OVSBridge') { - column2.push({ - xtype: 'textfield', - fieldLabel: gettext('Bridge ports'), - name: 'ovs_ports', - autoEl: { - tag: 'div', - 'data-qtip': gettext( - 'Space-separated list of interfaces, for example: enp0s0 enp1s0', - ), - }, - }); - column2.push({ - xtype: 'textfield', - fieldLabel: gettext('OVS options'), - name: 'ovs_options', - }); - } else if (me.iftype === 'OVSPort' || me.iftype === 'OVSIntPort') { - column2.push({ - xtype: me.isCreate ? 'PVE.form.BridgeSelector' : 'displayfield', - fieldLabel: Proxmox.Utils.render_network_iface_type('OVSBridge'), - allowBlank: false, - nodename: me.nodename, - bridgeType: 'OVSBridge', - name: 'ovs_bridge', - }); - column2.push({ - xtype: 'proxmoxvlanfield', - deleteEmpty: !me.isCreate, - name: 'ovs_tag', - value: '', - }); - column2.push({ - xtype: 'textfield', - fieldLabel: gettext('OVS options'), - name: 'ovs_options', - }); - } else if (me.iftype === 'vlan') { - if (!me.isCreate) { - me.disablevlanid = false; - me.disablevlanrawdevice = false; - me.vlanrawdevicevalue = ''; - me.vlanidvalue = ''; - - if (Proxmox.Utils.VlanInterface_match.test(me.iface)) { - me.disablevlanid = true; - me.disablevlanrawdevice = true; - let arr = Proxmox.Utils.VlanInterface_match.exec(me.iface); - me.vlanrawdevicevalue = arr[1]; - me.vlanidvalue = arr[2]; - } else if (Proxmox.Utils.Vlan_match.test(me.iface)) { - me.disablevlanid = true; - let arr = Proxmox.Utils.Vlan_match.exec(me.iface); - me.vlanidvalue = arr[1]; - } - } else { - me.disablevlanid = true; - me.disablevlanrawdevice = true; - } - - column2.push({ - xtype: 'textfield', - fieldLabel: gettext('Vlan raw device'), - name: 'vlan-raw-device', - value: me.vlanrawdevicevalue, - disabled: me.disablevlanrawdevice, - allowBlank: false, - }); - - column2.push({ - xtype: 'proxmoxvlanfield', - name: 'vlan-id', - value: me.vlanidvalue, - disabled: me.disablevlanid, - }); - - columnB.push({ - xtype: 'label', - userCls: 'pmx-hint', - text: 'Either add the VLAN number to an existing interface name, or choose your own name and set the VLAN raw device (for the latter ifupdown1 supports vlanXY naming only)', - }); - } else if (me.iftype === 'bond') { - column2.push({ - xtype: 'textfield', - fieldLabel: gettext('Slaves'), - name: 'slaves', - }); - - let policySelector = Ext.createWidget('bondPolicySelector', { - fieldLabel: gettext('Hash policy'), - name: 'bond_xmit_hash_policy', - deleteEmpty: !me.isCreate, - disabled: true, - }); - - let primaryfield = Ext.createWidget('textfield', { - fieldLabel: 'bond-primary', - name: 'bond-primary', - value: '', - disabled: true, - }); - - column2.push({ - xtype: 'bondModeSelector', - fieldLabel: gettext('Mode'), - name: 'bond_mode', - value: me.isCreate ? 'balance-rr' : undefined, - listeners: { - change: function (f, value) { - if (value === 'balance-xor' || value === '802.3ad') { - policySelector.setDisabled(false); - primaryfield.setDisabled(true); - primaryfield.setValue(''); - } else if (value === 'active-backup') { - primaryfield.setDisabled(false); - policySelector.setDisabled(true); - policySelector.setValue(''); - } else { - policySelector.setDisabled(true); - policySelector.setValue(''); - primaryfield.setDisabled(true); - primaryfield.setValue(''); - } - }, - }, - allowBlank: false, - }); - - column2.push(policySelector); - column2.push(primaryfield); - } else if (me.iftype === 'OVSBond') { - column2.push({ - xtype: me.isCreate ? 'PVE.form.BridgeSelector' : 'displayfield', - fieldLabel: Proxmox.Utils.render_network_iface_type('OVSBridge'), - allowBlank: false, - nodename: me.nodename, - bridgeType: 'OVSBridge', - name: 'ovs_bridge', - }); - column2.push({ - xtype: 'proxmoxvlanfield', - deleteEmpty: !me.isCreate, - name: 'ovs_tag', - value: '', - }); - column2.push({ - xtype: 'textfield', - fieldLabel: gettext('OVS options'), - name: 'ovs_options', - }); - } - - column2.push({ - xtype: 'textfield', - fieldLabel: gettext('Comment'), - allowBlank: true, - nodename: me.nodename, - name: 'comments', - }); - - let url; - let method; - - if (me.isCreate) { - url = '/api2/extjs/nodes/' + me.nodename + '/network'; - method = 'POST'; - } else { - url = '/api2/extjs/nodes/' + me.nodename + '/network/' + me.iface; - method = 'PUT'; - } - - column1.push( - { - xtype: 'hiddenfield', - name: 'type', - value: me.iftype, - }, - { - xtype: me.isCreate ? 'textfield' : 'displayfield', - fieldLabel: gettext('Name'), - name: 'iface', - value: me.iface, - vtype: iface_vtype, - allowBlank: false, - maxLength: iface_vtype === 'BridgeName' ? 10 : 15, - autoEl: { - tag: 'div', - 'data-qtip': gettext('For example, vmbr0.100, vmbr0, vlan0.100, vlan0'), - }, - listeners: { - change: function (f, value) { - if (me.isCreate && iface_vtype === 'VlanName') { - let vlanidField = me.down('field[name=vlan-id]'); - let vlanrawdeviceField = me.down('field[name=vlan-raw-device]'); - if (Proxmox.Utils.VlanInterface_match.test(value)) { - vlanidField.setDisabled(true); - vlanrawdeviceField.setDisabled(true); - // User defined those values in the `iface` (Name) - // field. Match them (instead of leaving the - // previous value) to make clear what is submitted - // and how the fields `iface`, `vlan-id` and - // `vlan-raw-device` are connected - vlanidField.setValue( - value.match(Proxmox.Utils.VlanInterface_match)[2], - ); - vlanrawdeviceField.setValue( - value.match(Proxmox.Utils.VlanInterface_match)[1], - ); - } else if (Proxmox.Utils.Vlan_match.test(value)) { - vlanidField.setDisabled(true); - vlanidField.setValue(value.match(Proxmox.Utils.Vlan_match)[1]); - vlanrawdeviceField.setDisabled(false); - } else { - vlanidField.setDisabled(false); - vlanrawdeviceField.setDisabled(false); - } - } - }, - }, - }, - ); - - if (me.showAltNames && !me.isCreate) { - column1.push({ - xtype: 'displayfield', - name: 'altnames', - fieldLabel: gettext("Alternative Names"), - }); - } - - if (me.iftype === 'OVSBond') { - column1.push( - { - xtype: 'bondModeSelector', - fieldLabel: gettext('Mode'), - name: 'bond_mode', - openvswitch: true, - value: me.isCreate ? 'active-backup' : undefined, - allowBlank: false, - }, - { - xtype: 'textfield', - fieldLabel: gettext('Slaves'), - name: 'ovs_bonds', - }, - ); - } else { - column1.push( - { - xtype: 'proxmoxtextfield', - deleteEmpty: !me.isCreate, - fieldLabel: 'IPv4/CIDR', - vtype: 'IPCIDRAddress', - name: 'cidr', - }, - { - xtype: 'proxmoxtextfield', - deleteEmpty: !me.isCreate, - fieldLabel: gettext('Gateway') + ' (IPv4)', - vtype: 'IPAddress', - name: 'gateway', - }, - { - xtype: 'proxmoxtextfield', - deleteEmpty: !me.isCreate, - fieldLabel: 'IPv6/CIDR', - vtype: 'IP6CIDRAddress', - name: 'cidr6', - }, - { - xtype: 'proxmoxtextfield', - deleteEmpty: !me.isCreate, - fieldLabel: gettext('Gateway') + ' (IPv6)', - vtype: 'IP6Address', - name: 'gateway6', - }, - ); - } - advancedColumn1.push({ - xtype: 'proxmoxintegerfield', - minValue: 1280, - maxValue: 65520, - deleteEmpty: !me.isCreate, - emptyText: 1500, - fieldLabel: 'MTU', - name: 'mtu', - }); - - Ext.applyIf(me, { - url: url, - method: method, - items: { - xtype: 'inputpanel', - column1: column1, - column2: column2, - columnB: columnB, - advancedColumn1: advancedColumn1, - advancedColumn2: advancedColumn2, - }, - }); - - me.callParent(); - - if (me.isCreate) { - me.down('field[name=iface]').setValue(me.iface_default); - } else { - me.load({ - success: function (response, options) { - let data = response.result.data; - if (data.type !== me.iftype) { - let msg = 'Got unexpected device type'; - Ext.Msg.alert(gettext('Error'), msg, function () { - me.close(); - }); - return; - } - - if (data.altnames) { - if (Ext.isArray(data.altnames)) { - data.altnames = data.altnames.join('
'); - } - } else if (me.showAltNames && !me.isCreate) { - me.down('field[name=altnames]').setVisible(false); - } - me.setValues(data); - me.isValid(); // trigger validation - }, - }); - } - }, -}); -Ext.define('proxmox-networks', { - extend: 'Ext.data.Model', - fields: [ - 'active', - 'address', - 'address6', - 'altnames', - 'autostart', - 'bridge_ports', - 'cidr', - 'cidr6', - 'comments', - 'gateway', - 'gateway6', - 'iface', - 'netmask', - 'netmask6', - 'slaves', - 'type', - 'vlan-id', - 'vlan-raw-device', - ], - idProperty: 'iface', -}); - -Ext.define('Proxmox.node.NetworkView', { - extend: 'Ext.panel.Panel', - - alias: ['widget.proxmoxNodeNetworkView'], - - // defines what types of network devices we want to create - // order is always the same - types: ['bridge', 'bond', 'vlan', 'ovs'], - - showApplyBtn: false, - - // if true, the altnames column will be shown by default - showAltNames: false, - - // for options passed down to the network edit window - editOptions: {}, - - initComponent: function () { - let me = this; - - if (!me.nodename) { - throw 'no node name specified'; - } - - let baseUrl = `/nodes/${me.nodename}/network`; - - let store = Ext.create('Ext.data.Store', { - model: 'proxmox-networks', - proxy: { - type: 'proxmox', - url: '/api2/json' + baseUrl, - }, - sorters: [ - { - property: 'iface', - direction: 'ASC', - }, - ], - }); - - let reload = function () { - let changeitem = me.down('#changes'); - let apply_btn = me.down('#apply'); - let revert_btn = me.down('#revert'); - Proxmox.Utils.API2Request({ - url: baseUrl, - failure: function (response, opts) { - store.loadData({}); - Proxmox.Utils.setErrorMask(me, response.htmlStatus); - changeitem.update(''); - changeitem.setHidden(true); - }, - success: function (response, opts) { - let result = Ext.decode(response.responseText); - store.loadData(result.data); - let changes = result.changes; - if (changes === undefined || changes === '') { - changes = gettext('No changes'); - changeitem.setHidden(true); - apply_btn.setDisabled(true); - revert_btn.setDisabled(true); - } else { - changeitem.update('
' + Ext.htmlEncode(changes) + '
'); - changeitem.setHidden(false); - apply_btn.setDisabled(false); - revert_btn.setDisabled(false); - } - }, - }); - }; - - let run_editor = function () { - let grid = me.down('gridpanel'); - let sm = grid.getSelectionModel(); - let rec = sm.getSelection()[0]; - if (!rec) { - return; - } - - Ext.create('Proxmox.node.NetworkEdit', { - autoShow: true, - nodename: me.nodename, - iface: rec.data.iface, - iftype: rec.data.type, - showAltNames: me.showAltNames, - ...me.editOptions, - listeners: { - destroy: () => reload(), - }, - }); - }; - - let edit_btn = new Ext.Button({ - text: gettext('Edit'), - disabled: true, - handler: run_editor, - }); - - let sm = Ext.create('Ext.selection.RowModel', {}); - - let del_btn = new Proxmox.button.StdRemoveButton({ - selModel: sm, - getUrl: ({ data }) => `${baseUrl}/${data.iface}`, - callback: () => reload(), - }); - - let apply_btn = Ext.create('Proxmox.button.Button', { - text: gettext('Apply Configuration'), - itemId: 'apply', - disabled: true, - confirmMsg: 'Do you want to apply pending network changes?', - hidden: !me.showApplyBtn, - handler: function () { - Proxmox.Utils.API2Request({ - url: baseUrl, - method: 'PUT', - waitMsgTarget: me, - success: function ({ result }, opts) { - Ext.create('Proxmox.window.TaskProgress', { - autoShow: true, - taskDone: reload, - upid: result.data, - }); - }, - failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), - }); - }, - }); - - let set_button_status = function () { - let rec = sm.getSelection()[0]; - - edit_btn.setDisabled(!rec); - del_btn.setDisabled(!rec); - }; - - let findNextFreeInterfaceId = function (prefix) { - for (let next = 0; next <= 9999; next++) { - let id = `${prefix}${next.toString()}`; - if (!store.getById(id)) { - return id; - } - } - Ext.Msg.alert('Error', `No free ID for ${prefix} found!`); - return ''; - }; - - let menu_items = []; - let addEditWindowToMenu = (iType, iDefault) => { - menu_items.push({ - text: Proxmox.Utils.render_network_iface_type(iType), - handler: () => - Ext.create('Proxmox.node.NetworkEdit', { - autoShow: true, - nodename: me.nodename, - iftype: iType, - iface_default: findNextFreeInterfaceId(iDefault ?? iType), - ...me.editOptions, - onlineHelp: 'sysadmin_network_configuration', - listeners: { - destroy: () => reload(), - }, - }), - }); - }; - - if (me.types.indexOf('bridge') !== -1) { - addEditWindowToMenu('bridge', 'vmbr'); - } - - if (me.types.indexOf('bond') !== -1) { - addEditWindowToMenu('bond'); - } - - if (me.types.indexOf('vlan') !== -1) { - addEditWindowToMenu('vlan'); - } - - if (me.types.indexOf('ovs') !== -1) { - if (menu_items.length > 0) { - menu_items.push({ xtype: 'menuseparator' }); - } - - addEditWindowToMenu('OVSBridge', 'vmbr'); - addEditWindowToMenu('OVSBond', 'bond'); - - menu_items.push({ - text: Proxmox.Utils.render_network_iface_type('OVSIntPort'), - handler: () => - Ext.create('Proxmox.node.NetworkEdit', { - autoShow: true, - nodename: me.nodename, - iftype: 'OVSIntPort', - listeners: { - destroy: () => reload(), - }, - }), - }); - } - - let renderer_generator = function (fieldname) { - return function (val, metaData, rec) { - let tmp = []; - if (rec.data[fieldname]) { - tmp.push(rec.data[fieldname]); - } - if (rec.data[fieldname + '6']) { - tmp.push(rec.data[fieldname + '6']); - } - return tmp.join('
') || ''; - }; - }; - - Ext.apply(me, { - layout: 'border', - tbar: [ - { - text: gettext('Create'), - menu: { - plain: true, - items: menu_items, - }, - }, - '-', - { - text: gettext('Revert'), - itemId: 'revert', - handler: function () { - Proxmox.Utils.API2Request({ - url: baseUrl, - method: 'DELETE', - waitMsgTarget: me, - callback: function () { - reload(); - }, - failure: (response) => - Ext.Msg.alert(gettext('Error'), response.htmlStatus), - }); - }, - }, - edit_btn, - del_btn, - '-', - apply_btn, - ], - items: [ - { - xtype: 'gridpanel', - stateful: true, - stateId: 'grid-node-network', - store: store, - selModel: sm, - region: 'center', - border: false, - columns: [ - { - header: gettext('Name'), - sortable: true, - dataIndex: 'iface', - }, - { - header: gettext("Alternative Names"), - dataIndex: 'altnames', - hidden: !me.showAltNames, - width: 140, // enough space for 'enx' - renderer: (value) => { - if (!value) { - return ''; - } - if (Ext.isArray(value)) { - return value.map(Ext.htmlEncode).join('
'); - } - return Ext.htmlEncode(value); - }, - }, - { - header: gettext('Type'), - sortable: true, - width: 120, - renderer: Proxmox.Utils.render_network_iface_type, - dataIndex: 'type', - }, - { - xtype: 'booleancolumn', - header: gettext('Active'), - width: 80, - sortable: true, - dataIndex: 'active', - trueText: Proxmox.Utils.yesText, - falseText: Proxmox.Utils.noText, - undefinedText: Proxmox.Utils.noText, - }, - { - xtype: 'booleancolumn', - header: gettext('Autostart'), - width: 80, - sortable: true, - dataIndex: 'autostart', - trueText: Proxmox.Utils.yesText, - falseText: Proxmox.Utils.noText, - undefinedText: Proxmox.Utils.noText, - }, - { - xtype: 'booleancolumn', - header: gettext('VLAN aware'), - width: 80, - sortable: true, - dataIndex: 'bridge_vlan_aware', - trueText: Proxmox.Utils.yesText, - falseText: Proxmox.Utils.noText, - undefinedText: Proxmox.Utils.noText, - }, - { - header: gettext('Ports/Slaves'), - dataIndex: 'type', - renderer: (value, metaData, { data }) => { - if (value === 'bridge') { - return data.bridge_ports; - } else if (value === 'bond') { - return data.slaves; - } else if (value === 'OVSBridge') { - return data.ovs_ports; - } else if (value === 'OVSBond') { - return data.ovs_bonds; - } - return ''; - }, - }, - { - header: gettext('Bond Mode'), - dataIndex: 'bond_mode', - renderer: Proxmox.Utils.render_bond_mode, - }, - { - header: gettext('Hash Policy'), - hidden: true, - dataIndex: 'bond_xmit_hash_policy', - }, - { - header: gettext('IP address'), - sortable: true, - width: 120, - hidden: true, - dataIndex: 'address', - renderer: renderer_generator('address'), - }, - { - header: gettext('Subnet mask'), - width: 120, - sortable: true, - hidden: true, - dataIndex: 'netmask', - renderer: renderer_generator('netmask'), - }, - { - header: gettext('CIDR'), - width: 150, - sortable: true, - dataIndex: 'cidr', - renderer: renderer_generator('cidr'), - }, - { - header: gettext('Gateway'), - width: 150, - sortable: true, - dataIndex: 'gateway', - renderer: renderer_generator('gateway'), - }, - { - header: gettext('VLAN ID'), - hidden: true, - sortable: true, - dataIndex: 'vlan-id', - }, - { - header: gettext('VLAN raw device'), - hidden: true, - sortable: true, - dataIndex: 'vlan-raw-device', - }, - { - header: 'MTU', - hidden: true, - sortable: true, - dataIndex: 'mtu', - }, - { - header: gettext('Comment'), - dataIndex: 'comments', - flex: 1, - renderer: Ext.String.htmlEncode, - }, - ], - listeners: { - selectionchange: set_button_status, - itemdblclick: run_editor, - }, - }, - { - border: false, - region: 'south', - autoScroll: true, - hidden: true, - itemId: 'changes', - tbar: [ - gettext('Pending changes') + - ' (' + - gettext( - "Either reboot or use 'Apply Configuration' (needs ifupdown2) to activate", - ) + - ')', - ], - split: true, - bodyPadding: 5, - flex: 0.6, - html: gettext('No changes'), - }, - ], - }); - - me.callParent(); - reload(); - }, -}); -Ext.define('Proxmox.node.DNSEdit', { - extend: 'Proxmox.window.Edit', - alias: ['widget.proxmoxNodeDNSEdit'], - - // Some longer existing APIs use a brittle "replace whole config" style, you can set this option - // if the DNSEdit component is used in an API that has more modern, granular update semantics. - deleteEmpty: false, - - initComponent: function () { - let me = this; - - if (!me.nodename) { - throw 'no node name specified'; - } - - me.items = [ - { - xtype: 'textfield', - fieldLabel: gettext('Search domain'), - name: 'search', - allowBlank: false, - }, - { - xtype: 'proxmoxtextfield', - fieldLabel: gettext('DNS server') + ' 1', - vtype: 'IP64Address', - skipEmptyText: true, - deleteEmpty: me.deleteEmpty, - name: 'dns1', - }, - { - xtype: 'proxmoxtextfield', - fieldLabel: gettext('DNS server') + ' 2', - vtype: 'IP64Address', - skipEmptyText: true, - deleteEmpty: me.deleteEmpty, - name: 'dns2', - }, - { - xtype: 'proxmoxtextfield', - fieldLabel: gettext('DNS server') + ' 3', - vtype: 'IP64Address', - skipEmptyText: true, - deleteEmpty: me.deleteEmpty, - name: 'dns3', - }, - ]; - - Ext.applyIf(me, { - subject: gettext('DNS'), - url: '/api2/extjs/nodes/' + me.nodename + '/dns', - fieldDefaults: { - labelWidth: 120, - }, - }); - - me.callParent(); - - me.load(); - }, -}); -Ext.define('Proxmox.node.HostsView', { - extend: 'Ext.panel.Panel', - xtype: 'proxmoxNodeHostsView', - - reload: function () { - let me = this; - me.store.load(); - }, - - tbar: [ - { - text: gettext('Save'), - disabled: true, - itemId: 'savebtn', - handler: function () { - let view = this.up('panel'); - Proxmox.Utils.API2Request({ - params: { - digest: view.digest, - data: view.down('#hostsfield').getValue(), - }, - method: 'POST', - url: '/nodes/' + view.nodename + '/hosts', - waitMsgTarget: view, - success: function (response, opts) { - view.reload(); - }, - failure: function (response, opts) { - Ext.Msg.alert('Error', response.htmlStatus); - }, - }); - }, - }, - { - text: gettext('Revert'), - disabled: true, - itemId: 'resetbtn', - handler: function () { - let view = this.up('panel'); - view.down('#hostsfield').reset(); - }, - }, - ], - - layout: 'fit', - - items: [ - { - xtype: 'textarea', - itemId: 'hostsfield', - fieldStyle: { - 'font-family': 'monospace', - 'white-space': 'pre', - }, - listeners: { - dirtychange: function (ta, dirty) { - let view = this.up('panel'); - view.down('#savebtn').setDisabled(!dirty); - view.down('#resetbtn').setDisabled(!dirty); - }, - }, - }, - ], - - initComponent: function () { - let me = this; - - if (!me.nodename) { - throw 'no node name specified'; - } - - me.store = Ext.create('Ext.data.Store', { - proxy: { - type: 'proxmox', - url: '/api2/json/nodes/' + me.nodename + '/hosts', - }, - }); - - me.callParent(); - - Proxmox.Utils.monStoreErrors(me, me.store); - - me.mon(me.store, 'load', function (store, records, success) { - if (!success || records.length < 1) { - return; - } - me.digest = records[0].data.digest; - let data = records[0].data.data; - me.down('#hostsfield').setValue(data); - me.down('#hostsfield').resetOriginalValue(); - }); - - me.reload(); - }, -}); -Ext.define('Proxmox.node.DNSView', { - extend: 'Proxmox.grid.ObjectGrid', - alias: ['widget.proxmoxNodeDNSView'], - - // Some longer existing APIs use a brittle "replace whole config" style, you can set this option - // if the DNSView component is used in an API that has more modern, granular update semantics. - deleteEmpty: false, - - initComponent: function () { - let me = this; - - if (!me.nodename) { - throw 'no node name specified'; - } - - let run_editor = () => - Ext.create('Proxmox.node.DNSEdit', { - autoShow: true, - nodename: me.nodename, - deleteEmpty: me.deleteEmpty, - }); - - Ext.apply(me, { - url: `/api2/json/nodes/${me.nodename}/dns`, - cwidth1: 130, - interval: 10 * 1000, - run_editor: run_editor, - rows: { - search: { - header: gettext('Search domain'), - required: true, - renderer: Ext.htmlEncode, - }, - dns1: { - header: gettext('DNS server') + ' 1', - required: true, - renderer: Ext.htmlEncode, - }, - dns2: { - header: gettext('DNS server') + ' 2', - renderer: Ext.htmlEncode, - }, - dns3: { - header: gettext('DNS server') + ' 3', - renderer: Ext.htmlEncode, - }, - }, - tbar: [ - { - text: gettext('Edit'), - handler: run_editor, - }, - ], - listeners: { - itemdblclick: run_editor, - }, - }); - - me.callParent(); - - me.on('activate', me.rstore.startUpdate); - me.on('deactivate', me.rstore.stopUpdate); - me.on('destroy', me.rstore.stopUpdate); - }, -}); -Ext.define('Proxmox.node.Tasks', { - extend: 'Ext.grid.GridPanel', - - alias: 'widget.proxmoxNodeTasks', - - stateful: true, - stateId: 'pve-grid-node-tasks', - - loadMask: true, - sortableColumns: false, - - // set extra filter components, must have a 'name' property for the parameter, and must - // trigger a 'change' event if the value is 'undefined', it will not be sent to the api - extraFilter: [], - - // fixed filters which cannot be changed after instantiation, for example: - // { vmid: 100 } - preFilter: {}, - - controller: { - xclass: 'Ext.app.ViewController', - - showTaskLog: function () { - let me = this; - let selection = me.getView().getSelection(); - if (selection.length < 1) { - return; - } - - let rec = selection[0]; - - Ext.create('Proxmox.window.TaskViewer', { - upid: rec.data.upid, - endtime: rec.data.endtime, - }).show(); - }, - - updateLayout: function (store, records, success, operation) { - let me = this; - let view = me.getView().getView(); // the table view, not the whole grid - Proxmox.Utils.setErrorMask(view, false); - // update the scrollbar on every store load since the total count might be different. - // the buffered grid plugin does this only on (user) scrolling itself and even reduces - // the scrollheight again when scrolling up - me.getView().updateLayout(); - - if (!success) { - Proxmox.Utils.setErrorMask( - view, - Proxmox.Utils.getResponseErrorMessage(operation.getError()), - ); - } - }, - - refresh: function () { - let me = this; - let view = me.getView(); - - let selection = view.getSelection(); - let store = me.getViewModel().get('bufferedstore'); - if (selection && selection.length > 0) { - // deselect if selection is not there anymore - if (!store.contains(selection[0])) { - view.setSelection(undefined); - } - } - }, - - sinceChange: function (field, newval) { - let me = this; - let vm = me.getViewModel(); - - vm.set('since', newval); - }, - - untilChange: function (field, newval, oldval) { - let me = this; - let vm = me.getViewModel(); - - vm.set('until', newval); - }, - - reload: function () { - let me = this; - let view = me.getView(); - view.getStore().load(); - }, - - showFilter: function (btn, pressed) { - let me = this; - let vm = me.getViewModel(); - vm.set('showFilter', pressed); - }, - - clearFilter: function () { - let me = this; - me.lookup('filtertoolbar') - .query('field') - .forEach((field) => { - field.setValue(undefined); - }); - }, - }, - - listeners: { - itemdblclick: 'showTaskLog', - }, - - viewModel: { - data: { - typefilter: '', - statusfilter: '', - showFilter: false, - extraFilter: {}, - since: null, - until: null, - }, - - formulas: { - filterIcon: (get) => 'fa fa-filter' + (get('showFilter') ? ' info-blue' : ''), - extraParams: function (get) { - let me = this; - let params = {}; - if (get('typefilter')) { - params.typefilter = get('typefilter'); - } - if (get('statusfilter')) { - params.statusfilter = get('statusfilter'); - } - - if (get('extraFilter')) { - let extraFilter = get('extraFilter'); - for (const [name, value] of Object.entries(extraFilter)) { - if (value !== undefined && value !== null && value !== '') { - params[name] = value; - } - } - } - - if (get('since')) { - params.since = get('since').valueOf() / 1000; - } - - if (get('until')) { - let until = new Date(get('until').getTime()); // copy object - until.setDate(until.getDate() + 1); // end of the day - params.until = until.valueOf() / 1000; - } - - me.getView().getStore().load(); - - return params; - }, - filterCount: function (get) { - let count = 0; - if (get('typefilter')) { - count++; - } - let status = get('statusfilter'); - if ( - (Ext.isArray(status) && status.length > 0) || - (!Ext.isArray(status) && status) - ) { - count++; - } - if (get('since')) { - count++; - } - if (get('until')) { - count++; - } - - if (get('extraFilter')) { - let preFilter = get('preFilter') || {}; - let extraFilter = get('extraFilter'); - for (const [name, value] of Object.entries(extraFilter)) { - if ( - value !== undefined && - value !== null && - value !== '' && - preFilter[name] === undefined - ) { - count++; - } - } - } - - return count; - }, - clearFilterText: function (get) { - let count = get('filterCount'); - let fieldMsg = ''; - if (count > 1) { - fieldMsg = ` (${count} ${gettext('Fields')})`; - } else if (count > 0) { - fieldMsg = ` (1 ${gettext('Field')})`; - } - return gettext('Clear Filter') + fieldMsg; - }, - }, - - stores: { - bufferedstore: { - type: 'buffered', - pageSize: 500, - autoLoad: true, - remoteFilter: true, - model: 'proxmox-tasks', - proxy: { - type: 'proxmox', - startParam: 'start', - limitParam: 'limit', - extraParams: '{extraParams}', - url: '{url}', - }, - listeners: { - prefetch: 'updateLayout', - refresh: 'refresh', - }, - }, - }, - }, - - bind: { - store: '{bufferedstore}', - }, - - dockedItems: [ - { - xtype: 'toolbar', - items: [ - { - xtype: 'proxmoxButton', - text: gettext('View Task'), - iconCls: 'fa fa-window-restore', - disabled: true, - handler: 'showTaskLog', - }, - { - xtype: 'button', - text: gettext('Reload'), - iconCls: 'fa fa-refresh', - handler: 'reload', - }, - '->', - { - xtype: 'button', - bind: { - text: '{clearFilterText}', - disabled: '{!filterCount}', - }, - text: gettext('Clear Filter'), - enabled: false, - handler: 'clearFilter', - }, - { - xtype: 'button', - enableToggle: true, - bind: { - iconCls: '{filterIcon}', - }, - text: gettext('Filter'), - stateful: true, - stateId: 'task-showfilter', - stateEvents: ['toggle'], - applyState: function (state) { - if (state.pressed !== undefined) { - this.setPressed(state.pressed); - } - }, - getState: function () { - return { - pressed: this.pressed, - }; - }, - listeners: { - toggle: 'showFilter', - }, - }, - ], - }, - { - xtype: 'toolbar', - dock: 'top', - reference: 'filtertoolbar', - layout: { - type: 'hbox', - align: 'top', - }, - bind: { - hidden: '{!showFilter}', - }, - items: [ - { - xtype: 'container', - padding: 10, - layout: { - type: 'vbox', - align: 'stretch', - }, - defaults: { - labelWidth: 80, - }, - // cannot bind the values directly, as it then changes also - // on blur, causing wrong reloads of the store - items: [ - { - xtype: 'datefield', - fieldLabel: gettext('Since'), - format: 'Y-m-d', - bind: { - maxValue: '{until}', - }, - listeners: { - change: 'sinceChange', - }, - }, - { - xtype: 'datefield', - fieldLabel: gettext('Until'), - format: 'Y-m-d', - bind: { - minValue: '{since}', - }, - listeners: { - change: 'untilChange', - }, - }, - ], - }, - { - xtype: 'container', - padding: 10, - layout: { - type: 'vbox', - align: 'stretch', - }, - defaults: { - labelWidth: 80, - }, - items: [ - { - xtype: 'pmxTaskTypeSelector', - fieldLabel: gettext('Task Type'), - emptyText: gettext('All'), - bind: { - value: '{typefilter}', - }, - }, - { - xtype: 'combobox', - fieldLabel: gettext('Task Result'), - emptyText: gettext('All'), - multiSelect: true, - store: [ - ['ok', gettext('OK')], - ['unknown', Proxmox.Utils.unknownText], - ['warning', ngettext('Warning', 'Warnings', 1)], // TODO: use count - ['error', gettext('Errors')], - ], - bind: { - value: '{statusfilter}', - }, - }, - ], - }, - ], - }, - ], - - viewConfig: { - trackOver: false, - stripeRows: false, // does not work with getRowClass() - emptyText: gettext('No Tasks found'), - - getRowClass: function (record, index) { - let status = record.get('status'); - - if (status) { - let parsed = Proxmox.Utils.parse_task_status(status); - if (parsed === 'error') { - return 'proxmox-invalid-row'; - } else if (parsed === 'warning') { - return 'proxmox-warning-row'; - } - } - return ''; - }, - }, - - columns: [ - { - header: gettext('Start Time'), - dataIndex: 'starttime', - width: 130, - renderer: function (value) { - return Ext.Date.format(value, 'M d H:i:s'); - }, - }, - { - header: gettext('End Time'), - dataIndex: 'endtime', - width: 130, - renderer: function (value, metaData, record) { - if (!value) { - metaData.tdCls = 'x-grid-row-loading'; - return ''; - } - return Ext.Date.format(value, 'M d H:i:s'); - }, - }, - { - header: gettext('Duration'), - hidden: true, - width: 80, - renderer: function (value, metaData, record) { - let start = record.data.starttime; - if (start) { - let end = record.data.endtime || Date.now(); - let duration = end - start; - if (duration > 0) { - duration /= 1000; - } - return Proxmox.Utils.format_duration_human(duration); - } - return Proxmox.Utils.unknownText; - }, - }, - { - header: gettext('User name'), - dataIndex: 'user', - width: 150, - }, - { - header: gettext('Description'), - dataIndex: 'upid', - flex: 1, - renderer: Proxmox.Utils.render_upid, - }, - { - header: gettext('Status'), - dataIndex: 'status', - width: 200, - renderer: function (value, metaData, record) { - if (value === undefined && !record.data.endtime) { - metaData.tdCls = 'x-grid-row-loading'; - return ''; - } - - return Proxmox.Utils.format_task_status(value); - }, - }, - { - xtype: 'actioncolumn', - width: 30, - align: 'center', - tooltip: gettext('Actions'), - items: [ - { - iconCls: 'fa fa-chevron-right', - tooltip: gettext('View Task'), - handler: function (_grid, _rowIndex, _colIndex, _item, _e, rec) { - Ext.create('Proxmox.window.TaskViewer', { - autoShow: true, - upid: rec.data.upid, - endtime: rec.data.endtime, - }); - }, - }, - ], - }, - ], - - initComponent: function () { - const me = this; - - let nodename = me.nodename || 'localhost'; - let url = me.url || `/api2/json/nodes/${nodename}/tasks`; - me.getViewModel().set('url', url); - - let updateExtraFilters = function (name, value) { - let vm = me.getViewModel(); - let extraFilter = Ext.clone(vm.get('extraFilter')); - extraFilter[name] = value; - vm.set('extraFilter', extraFilter); - }; - - for (const [name, value] of Object.entries(me.preFilter)) { - updateExtraFilters(name, value); - } - - me.getViewModel().set('preFilter', me.preFilter); - - me.callParent(); - - let addFields = function (items) { - me.lookup('filtertoolbar').add({ - xtype: 'container', - padding: 10, - layout: { - type: 'vbox', - align: 'stretch', - }, - defaults: { - labelWidth: 80, - }, - items, - }); - }; - - // start with a userfilter - me.extraFilter = [ - { - xtype: 'textfield', - fieldLabel: gettext('User name'), - changeOptions: { - buffer: 500, - }, - name: 'userfilter', - }, - ...me.extraFilter, - ]; - let items = []; - for (const filterTemplate of me.extraFilter) { - let filter = Ext.clone(filterTemplate); - - filter.listeners = filter.listeners || {}; - filter.listeners.change = Ext.apply(filter.changeOptions || {}, { - fn: function (field, value) { - updateExtraFilters(filter.name, value); - }, - }); - - items.push(filter); - if (items.length === 2) { - addFields(items); - items = []; - } - } - - addFields(items); - }, -}); -Ext.define('proxmox-services', { - extend: 'Ext.data.Model', - fields: ['service', 'name', 'desc', 'state', 'unit-state', 'active-state'], - idProperty: 'service', -}); - -Ext.define('Proxmox.node.ServiceView', { - extend: 'Ext.grid.GridPanel', - - alias: ['widget.proxmoxNodeServiceView'], - - startOnlyServices: {}, - - restartCommand: 'restart', // TODO: default to reload once everywhere supported - - initComponent: function () { - let me = this; - - if (!me.nodename) { - throw 'no node name specified'; - } - - let rstore = Ext.create('Proxmox.data.UpdateStore', { - interval: 1000, - model: 'proxmox-services', - proxy: { - type: 'proxmox', - url: `/api2/json/nodes/${me.nodename}/services`, - }, - }); - - let filterInstalledOnly = (record) => record.get('unit-state') !== 'not-found'; - - let store = Ext.create('Proxmox.data.DiffStore', { - rstore: rstore, - sortAfterUpdate: true, - sorters: [ - { - property: 'name', - direction: 'ASC', - }, - ], - filters: [filterInstalledOnly], - }); - - let unHideCB = Ext.create('Ext.form.field.Checkbox', { - boxLabel: gettext('Show only installed services'), - value: true, - boxLabelAlign: 'before', - listeners: { - change: function (_cb, value) { - if (value) { - store.addFilter([filterInstalledOnly]); - } else { - store.clearFilter(); - } - }, - }, - }); - - let view_service_log = function () { - let { - data: { service }, - } = me.getSelectionModel().getSelection()[0]; - - let viewSize = Ext.getBody().getViewSize(); - - Ext.create('Ext.window.Window', { - title: gettext('Syslog') + ': ' + service, - modal: true, - width: viewSize.width > 1600 ? 1000 : 800, - height: viewSize.height > 900 ? 800 : 600, - layout: 'fit', - items: { - xtype: 'proxmoxLogView', - url: `/api2/extjs/nodes/${me.nodename}/syslog?service=${service}`, - log_select_timespan: 1, - }, - autoShow: true, - }); - }; - - let service_cmd = function (cmd) { - let { - data: { service }, - } = me.getSelectionModel().getSelection()[0]; - Proxmox.Utils.API2Request({ - url: `/nodes/${me.nodename}/services/${service}/${cmd}`, - method: 'POST', - failure: function (response, opts) { - Ext.Msg.alert(gettext('Error'), response.htmlStatus); - me.loading = true; - }, - success: function (response, opts) { - rstore.startUpdate(); - Ext.create('Proxmox.window.TaskProgress', { - upid: response.result.data, - autoShow: true, - }); - }, - }); - }; - - let start_btn = new Ext.Button({ - text: gettext('Start'), - disabled: true, - handler: () => service_cmd('start'), - }); - let stop_btn = new Ext.Button({ - text: gettext('Stop'), - disabled: true, - handler: () => service_cmd('stop'), - }); - let restart_btn = new Ext.Button({ - text: gettext('Restart'), - disabled: true, - handler: () => service_cmd(me.restartCommand || 'restart'), - }); - let syslog_btn = new Ext.Button({ - text: gettext('Service System Log'), - disabled: true, - handler: view_service_log, - }); - - let set_button_status = function () { - let sm = me.getSelectionModel(); - let rec = sm.getSelection()[0]; - - if (!rec) { - start_btn.disable(); - stop_btn.disable(); - restart_btn.disable(); - syslog_btn.disable(); - return; - } - let service = rec.data.service; - let state = rec.data.state; - let unit = rec.data['unit-state']; - - syslog_btn.enable(); - - if (state === 'running') { - if (me.startOnlyServices[service]) { - stop_btn.disable(); - restart_btn.enable(); - } else { - stop_btn.enable(); - restart_btn.enable(); - start_btn.disable(); - } - } else if ( - unit !== undefined && - (unit === 'masked' || unit === 'unknown' || unit === 'not-found') - ) { - start_btn.disable(); - restart_btn.disable(); - stop_btn.disable(); - } else { - start_btn.enable(); - stop_btn.disable(); - restart_btn.disable(); - } - }; - - me.mon(store, 'refresh', set_button_status); - - Proxmox.Utils.monStoreErrors(me, rstore); - - Ext.apply(me, { - viewConfig: { - trackOver: false, - stripeRows: false, // does not work with getRowClass() - getRowClass: function (record, index) { - let unitState = record.get('unit-state'); - if (!unitState) { - return ''; - } - if (unitState === 'masked' || unitState === 'not-found') { - return 'proxmox-disabled-row'; - } else if (unitState === 'unknown') { - if ( - record.get('name') === 'syslog' || // replaced by journal on most hosts - record.get('name') === 'systemd-timesyncd' || // chrony is preferred - record.get('name') === 'proxmox-firewall' // opt-in NFT based variant - ) { - return 'proxmox-disabled-row'; - } - return 'proxmox-warning-row'; - } - return ''; - }, - }, - store: store, - stateful: false, - tbar: [start_btn, stop_btn, restart_btn, '-', syslog_btn, '->', unHideCB], - columns: [ - { - header: gettext('Name'), - flex: 1, - sortable: true, - dataIndex: 'name', - }, - { - header: gettext('Status'), - width: 100, - sortable: true, - dataIndex: 'state', - renderer: (value, meta, rec) => { - const unitState = rec.get('unit-state'); - if (unitState === 'masked') { - return gettext('disabled'); - } else if (unitState === 'not-found') { - return gettext('not installed'); - } else { - return value; - } - }, - }, - { - header: gettext('Active'), - width: 100, - sortable: true, - hidden: true, - dataIndex: 'active-state', - }, - { - header: gettext('Unit'), - width: 120, - sortable: true, - hidden: !Ext.Array.contains( - ['PVEAuthCookie', 'PBSAuthCookie'], - Proxmox?.Setup?.auth_cookie_name, - ), - dataIndex: 'unit-state', - }, - { - header: gettext('Description'), - renderer: Ext.String.htmlEncode, - dataIndex: 'desc', - flex: 2, - }, - ], - listeners: { - selectionchange: set_button_status, - itemdblclick: view_service_log, - activate: rstore.startUpdate, - destroy: rstore.stopUpdate, - }, - }); - - me.callParent(); - }, -}); -Ext.define('Proxmox.node.TimeEdit', { - extend: 'Proxmox.window.Edit', - alias: ['widget.proxmoxNodeTimeEdit'], - - subject: gettext('Time zone'), - - width: 400, - - autoLoad: true, - - fieldDefaults: { - labelWidth: 70, - }, - - items: { - xtype: 'combo', - fieldLabel: gettext('Time zone'), - name: 'timezone', - queryMode: 'local', - store: Ext.create('Proxmox.data.TimezoneStore'), - displayField: 'zone', - editable: true, - anyMatch: true, - forceSelection: true, - allowBlank: false, - }, - - initComponent: function () { - let me = this; - - if (!me.nodename) { - throw 'no node name specified'; - } - me.url = '/api2/extjs/nodes/' + me.nodename + '/time'; - - me.callParent(); - }, -}); -Ext.define('Proxmox.node.TimeView', { - extend: 'Proxmox.grid.ObjectGrid', - alias: ['widget.proxmoxNodeTimeView'], - - initComponent: function () { - let me = this; - - if (!me.nodename) { - throw 'no node name specified'; - } - - let tzOffset = new Date().getTimezoneOffset() * 60000; - let renderLocaltime = function (value) { - let servertime = new Date(value * 1000 + tzOffset); - return Ext.Date.format(servertime, 'Y-m-d H:i:s'); - }; - - let run_editor = () => - Ext.create('Proxmox.node.TimeEdit', { - autoShow: true, - nodename: me.nodename, - }); - - Ext.apply(me, { - url: `/api2/json/nodes/${me.nodename}/time`, - cwidth1: 150, - interval: 1000, - run_editor: run_editor, - rows: { - timezone: { - header: gettext('Time zone'), - required: true, - }, - localtime: { - header: gettext('Server time'), - required: true, - renderer: renderLocaltime, - }, - }, - tbar: [ - { - text: gettext('Edit'), - handler: run_editor, - }, - ], - listeners: { - itemdblclick: run_editor, - }, - }); - - me.callParent(); - - me.on('activate', me.rstore.startUpdate); - me.on('deactivate', me.rstore.stopUpdate); - me.on('destroy', me.rstore.stopUpdate); - }, -}); -/** - * marked - a markdown parser - * Copyright (c) 2011-2025, Christopher Jeffrey. (MIT Licensed) - * https://github.com/markedjs/marked - */ - -/** - * DO NOT EDIT THIS FILE - * The code in this file is generated from files in ./src/ - */ - -(function (global, factory) { - typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : - typeof define === 'function' && define.amd ? define(['exports'], factory) : - (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.marked = {})); -})(this, (function (exports) { 'use strict'; - - function _defineProperties(target, props) { - for (var i = 0; i < props.length; i++) { - var descriptor = props[i]; - descriptor.enumerable = descriptor.enumerable || false; - descriptor.configurable = true; - if ("value" in descriptor) descriptor.writable = true; - Object.defineProperty(target, _toPropertyKey(descriptor.key), descriptor); - } - } - function _createClass(Constructor, protoProps, staticProps) { - if (protoProps) _defineProperties(Constructor.prototype, protoProps); - if (staticProps) _defineProperties(Constructor, staticProps); - Object.defineProperty(Constructor, "prototype", { - writable: false - }); - return Constructor; - } - function _unsupportedIterableToArray(o, minLen) { - if (!o) return; - if (typeof o === "string") return _arrayLikeToArray(o, minLen); - var n = Object.prototype.toString.call(o).slice(8, -1); - if (n === "Object" && o.constructor) n = o.constructor.name; - if (n === "Map" || n === "Set") return Array.from(o); - if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); - } - function _arrayLikeToArray(arr, len) { - if (len == null || len > arr.length) len = arr.length; - for (var i = 0, arr2 = new Array(len); i < len; i++) arr2[i] = arr[i]; - return arr2; - } - function _createForOfIteratorHelperLoose(o, allowArrayLike) { - var it = typeof Symbol !== "undefined" && o[Symbol.iterator] || o["@@iterator"]; - if (it) return (it = it.call(o)).next.bind(it); - if (Array.isArray(o) || (it = _unsupportedIterableToArray(o)) || allowArrayLike && o && typeof o.length === "number") { - if (it) o = it; - var i = 0; - return function () { - if (i >= o.length) return { - done: true - }; - return { - done: false, - value: o[i++] - }; - }; - } - throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); - } - function _toPrimitive(input, hint) { - if (typeof input !== "object" || input === null) return input; - var prim = input[Symbol.toPrimitive]; - if (prim !== undefined) { - var res = prim.call(input, hint || "default"); - if (typeof res !== "object") return res; - throw new TypeError("@@toPrimitive must return a primitive value."); - } - return (hint === "string" ? String : Number)(input); - } - function _toPropertyKey(arg) { - var key = _toPrimitive(arg, "string"); - return typeof key === "symbol" ? key : String(key); - } - - function getDefaults() { - return { - async: false, - baseUrl: null, - breaks: false, - extensions: null, - gfm: true, - headerIds: true, - headerPrefix: '', - highlight: null, - langPrefix: 'language-', - mangle: true, - pedantic: false, - renderer: null, - sanitize: false, - sanitizer: null, - silent: false, - smartypants: false, - tokenizer: null, - walkTokens: null, - xhtml: false - }; - } - exports.defaults = getDefaults(); - function changeDefaults(newDefaults) { - exports.defaults = newDefaults; - } - - /** - * Helpers - */ - var escapeTest = /[&<>"']/; - var escapeReplace = new RegExp(escapeTest.source, 'g'); - var escapeTestNoEncode = /[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/; - var escapeReplaceNoEncode = new RegExp(escapeTestNoEncode.source, 'g'); - var escapeReplacements = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''' - }; - var getEscapeReplacement = function getEscapeReplacement(ch) { - return escapeReplacements[ch]; - }; - function escape(html, encode) { - if (encode) { - if (escapeTest.test(html)) { - return html.replace(escapeReplace, getEscapeReplacement); - } - } else { - if (escapeTestNoEncode.test(html)) { - return html.replace(escapeReplaceNoEncode, getEscapeReplacement); - } - } - return html; - } - var unescapeTest = /&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/ig; - - /** - * @param {string} html - */ - function unescape(html) { - // explicitly match decimal, hex, and named HTML entities - return html.replace(unescapeTest, function (_, n) { - n = n.toLowerCase(); - if (n === 'colon') return ':'; - if (n.charAt(0) === '#') { - return n.charAt(1) === 'x' ? String.fromCharCode(parseInt(n.substring(2), 16)) : String.fromCharCode(+n.substring(1)); - } - return ''; - }); - } - var caret = /(^|[^\[])\^/g; - - /** - * @param {string | RegExp} regex - * @param {string} opt - */ - function edit(regex, opt) { - regex = typeof regex === 'string' ? regex : regex.source; - opt = opt || ''; - var obj = { - replace: function replace(name, val) { - val = val.source || val; - val = val.replace(caret, '$1'); - regex = regex.replace(name, val); - return obj; - }, - getRegex: function getRegex() { - return new RegExp(regex, opt); - } - }; - return obj; - } - var nonWordAndColonTest = /[^\w:]/g; - var originIndependentUrl = /^$|^[a-z][a-z0-9+.-]*:|^[?#]/i; - - /** - * @param {boolean} sanitize - * @param {string} base - * @param {string} href - */ - function cleanUrl(sanitize, base, href) { - if (sanitize) { - var prot; - try { - prot = decodeURIComponent(unescape(href)).replace(nonWordAndColonTest, '').toLowerCase(); - } catch (e) { - return null; - } - if (prot.indexOf('javascript:') === 0 || prot.indexOf('vbscript:') === 0 || prot.indexOf('data:') === 0) { - return null; - } - } - if (base && !originIndependentUrl.test(href)) { - href = resolveUrl(base, href); - } - try { - href = encodeURI(href).replace(/%25/g, '%'); - } catch (e) { - return null; - } - return href; - } - var baseUrls = {}; - var justDomain = /^[^:]+:\/*[^/]*$/; - var protocol = /^([^:]+:)[\s\S]*$/; - var domain = /^([^:]+:\/*[^/]*)[\s\S]*$/; - - /** - * @param {string} base - * @param {string} href - */ - function resolveUrl(base, href) { - if (!baseUrls[' ' + base]) { - // we can ignore everything in base after the last slash of its path component, - // but we might need to add _that_ - // https://tools.ietf.org/html/rfc3986#section-3 - if (justDomain.test(base)) { - baseUrls[' ' + base] = base + '/'; - } else { - baseUrls[' ' + base] = rtrim(base, '/', true); - } - } - base = baseUrls[' ' + base]; - var relativeBase = base.indexOf(':') === -1; - if (href.substring(0, 2) === '//') { - if (relativeBase) { - return href; - } - return base.replace(protocol, '$1') + href; - } else if (href.charAt(0) === '/') { - if (relativeBase) { - return href; - } - return base.replace(domain, '$1') + href; - } else { - return base + href; - } - } - var noopTest = { - exec: function noopTest() {} - }; - function merge(obj) { - var i = 1, - target, - key; - for (; i < arguments.length; i++) { - target = arguments[i]; - for (key in target) { - if (Object.prototype.hasOwnProperty.call(target, key)) { - obj[key] = target[key]; - } - } - } - return obj; - } - function splitCells(tableRow, count) { - // ensure that every cell-delimiting pipe has a space - // before it to distinguish it from an escaped pipe - var row = tableRow.replace(/\|/g, function (match, offset, str) { - var escaped = false, - curr = offset; - while (--curr >= 0 && str[curr] === '\\') escaped = !escaped; - if (escaped) { - // odd number of slashes means | is escaped - // so we leave it alone - return '|'; - } else { - // add space before unescaped | - return ' |'; - } - }), - cells = row.split(/ \|/); - var i = 0; - - // First/last cell in a row cannot be empty if it has no leading/trailing pipe - if (!cells[0].trim()) { - cells.shift(); - } - if (cells.length > 0 && !cells[cells.length - 1].trim()) { - cells.pop(); - } - if (cells.length > count) { - cells.splice(count); - } else { - while (cells.length < count) cells.push(''); - } - for (; i < cells.length; i++) { - // leading or trailing whitespace is ignored per the gfm spec - cells[i] = cells[i].trim().replace(/\\\|/g, '|'); - } - return cells; - } - - /** - * Remove trailing 'c's. Equivalent to str.replace(/c*$/, ''). - * /c*$/ is vulnerable to REDOS. - * - * @param {string} str - * @param {string} c - * @param {boolean} invert Remove suffix of non-c chars instead. Default falsey. - */ - function rtrim(str, c, invert) { - var l = str.length; - if (l === 0) { - return ''; - } - - // Length of suffix matching the invert condition. - var suffLen = 0; - - // Step left until we fail to match the invert condition. - while (suffLen < l) { - var currChar = str.charAt(l - suffLen - 1); - if (currChar === c && !invert) { - suffLen++; - } else if (currChar !== c && invert) { - suffLen++; - } else { - break; - } - } - return str.slice(0, l - suffLen); - } - function findClosingBracket(str, b) { - if (str.indexOf(b[1]) === -1) { - return -1; - } - var l = str.length; - var level = 0, - i = 0; - for (; i < l; i++) { - if (str[i] === '\\') { - i++; - } else if (str[i] === b[0]) { - level++; - } else if (str[i] === b[1]) { - level--; - if (level < 0) { - return i; - } - } - } - return -1; - } - function checkSanitizeDeprecation(opt) { - if (opt && opt.sanitize && !opt.silent) { - console.warn('marked(): sanitize and sanitizer parameters are deprecated since version 0.7.0, should not be used and will be removed in the future. Read more here: https://marked.js.org/#/USING_ADVANCED.md#options'); - } - } - - // copied from https://stackoverflow.com/a/5450113/806777 - /** - * @param {string} pattern - * @param {number} count - */ - function repeatString(pattern, count) { - if (count < 1) { - return ''; - } - var result = ''; - while (count > 1) { - if (count & 1) { - result += pattern; - } - count >>= 1; - pattern += pattern; - } - return result + pattern; - } - - function outputLink(cap, link, raw, lexer) { - var href = link.href; - var title = link.title ? escape(link.title) : null; - var text = cap[1].replace(/\\([\[\]])/g, '$1'); - if (cap[0].charAt(0) !== '!') { - lexer.state.inLink = true; - var token = { - type: 'link', - raw: raw, - href: href, - title: title, - text: text, - tokens: lexer.inlineTokens(text) - }; - lexer.state.inLink = false; - return token; - } - return { - type: 'image', - raw: raw, - href: href, - title: title, - text: escape(text) - }; - } - function indentCodeCompensation(raw, text) { - var matchIndentToCode = raw.match(/^(\s+)(?:```)/); - if (matchIndentToCode === null) { - return text; - } - var indentToCode = matchIndentToCode[1]; - return text.split('\n').map(function (node) { - var matchIndentInNode = node.match(/^\s+/); - if (matchIndentInNode === null) { - return node; - } - var indentInNode = matchIndentInNode[0]; - if (indentInNode.length >= indentToCode.length) { - return node.slice(indentToCode.length); - } - return node; - }).join('\n'); - } - - /** - * Tokenizer - */ - var Tokenizer = /*#__PURE__*/function () { - function Tokenizer(options) { - this.options = options || exports.defaults; - } - var _proto = Tokenizer.prototype; - _proto.space = function space(src) { - var cap = this.rules.block.newline.exec(src); - if (cap && cap[0].length > 0) { - return { - type: 'space', - raw: cap[0] - }; - } - }; - _proto.code = function code(src) { - var cap = this.rules.block.code.exec(src); - if (cap) { - var text = cap[0].replace(/^ {1,4}/gm, ''); - return { - type: 'code', - raw: cap[0], - codeBlockStyle: 'indented', - text: !this.options.pedantic ? rtrim(text, '\n') : text - }; - } - }; - _proto.fences = function fences(src) { - var cap = this.rules.block.fences.exec(src); - if (cap) { - var raw = cap[0]; - var text = indentCodeCompensation(raw, cap[3] || ''); - return { - type: 'code', - raw: raw, - lang: cap[2] ? cap[2].trim().replace(this.rules.inline._escapes, '$1') : cap[2], - text: text - }; - } - }; - _proto.heading = function heading(src) { - var cap = this.rules.block.heading.exec(src); - if (cap) { - var text = cap[2].trim(); - - // remove trailing #s - if (/#$/.test(text)) { - var trimmed = rtrim(text, '#'); - if (this.options.pedantic) { - text = trimmed.trim(); - } else if (!trimmed || / $/.test(trimmed)) { - // CommonMark requires space before trailing #s - text = trimmed.trim(); - } - } - return { - type: 'heading', - raw: cap[0], - depth: cap[1].length, - text: text, - tokens: this.lexer.inline(text) - }; - } - }; - _proto.hr = function hr(src) { - var cap = this.rules.block.hr.exec(src); - if (cap) { - return { - type: 'hr', - raw: cap[0] - }; - } - }; - _proto.blockquote = function blockquote(src) { - var cap = this.rules.block.blockquote.exec(src); - if (cap) { - var text = cap[0].replace(/^ *>[ \t]?/gm, ''); - return { - type: 'blockquote', - raw: cap[0], - tokens: this.lexer.blockTokens(text, []), - text: text - }; - } - }; - _proto.list = function list(src) { - var cap = this.rules.block.list.exec(src); - if (cap) { - var raw, istask, ischecked, indent, i, blankLine, endsWithBlankLine, line, nextLine, rawLine, itemContents, endEarly; - var bull = cap[1].trim(); - var isordered = bull.length > 1; - var list = { - type: 'list', - raw: '', - ordered: isordered, - start: isordered ? +bull.slice(0, -1) : '', - loose: false, - items: [] - }; - bull = isordered ? "\\d{1,9}\\" + bull.slice(-1) : "\\" + bull; - if (this.options.pedantic) { - bull = isordered ? bull : '[*+-]'; - } - - // Get next list item - var itemRegex = new RegExp("^( {0,3}" + bull + ")((?:[\t ][^\\n]*)?(?:\\n|$))"); - - // Check if current bullet point can start a new List Item - while (src) { - endEarly = false; - if (!(cap = itemRegex.exec(src))) { - break; - } - if (this.rules.block.hr.test(src)) { - // End list if bullet was actually HR (possibly move into itemRegex?) - break; - } - raw = cap[0]; - src = src.substring(raw.length); - line = cap[2].split('\n', 1)[0]; - nextLine = src.split('\n', 1)[0]; - if (this.options.pedantic) { - indent = 2; - itemContents = line.trimLeft(); - } else { - indent = cap[2].search(/[^ ]/); // Find first non-space char - indent = indent > 4 ? 1 : indent; // Treat indented code blocks (> 4 spaces) as having only 1 indent - itemContents = line.slice(indent); - indent += cap[1].length; - } - blankLine = false; - if (!line && /^ *$/.test(nextLine)) { - // Items begin with at most one blank line - raw += nextLine + '\n'; - src = src.substring(nextLine.length + 1); - endEarly = true; - } - if (!endEarly) { - var nextBulletRegex = new RegExp("^ {0," + Math.min(3, indent - 1) + "}(?:[*+-]|\\d{1,9}[.)])((?: [^\\n]*)?(?:\\n|$))"); - var hrRegex = new RegExp("^ {0," + Math.min(3, indent - 1) + "}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)"); - var fencesBeginRegex = new RegExp("^ {0," + Math.min(3, indent - 1) + "}(?:```|~~~)"); - var headingBeginRegex = new RegExp("^ {0," + Math.min(3, indent - 1) + "}#"); - - // Check if following lines should be included in List Item - while (src) { - rawLine = src.split('\n', 1)[0]; - line = rawLine; - - // Re-align to follow commonmark nesting rules - if (this.options.pedantic) { - line = line.replace(/^ {1,4}(?=( {4})*[^ ])/g, ' '); - } - - // End list item if found code fences - if (fencesBeginRegex.test(line)) { - break; - } - - // End list item if found start of new heading - if (headingBeginRegex.test(line)) { - break; - } - - // End list item if found start of new bullet - if (nextBulletRegex.test(line)) { - break; - } - - // Horizontal rule found - if (hrRegex.test(src)) { - break; - } - if (line.search(/[^ ]/) >= indent || !line.trim()) { - // Dedent if possible - itemContents += '\n' + line.slice(indent); - } else if (!blankLine) { - // Until blank line, item doesn't need indentation - itemContents += '\n' + line; - } else { - // Otherwise, improper indentation ends this item - break; - } - if (!blankLine && !line.trim()) { - // Check if current line is blank - blankLine = true; - } - raw += rawLine + '\n'; - src = src.substring(rawLine.length + 1); - } - } - if (!list.loose) { - // If the previous item ended with a blank line, the list is loose - if (endsWithBlankLine) { - list.loose = true; - } else if (/\n *\n *$/.test(raw)) { - endsWithBlankLine = true; - } - } - - // Check for task list items - if (this.options.gfm) { - istask = /^\[[ xX]\] /.exec(itemContents); - if (istask) { - ischecked = istask[0] !== '[ ] '; - itemContents = itemContents.replace(/^\[[ xX]\] +/, ''); - } - } - list.items.push({ - type: 'list_item', - raw: raw, - task: !!istask, - checked: ischecked, - loose: false, - text: itemContents - }); - list.raw += raw; - } - - // Do not consume newlines at end of final item. Alternatively, make itemRegex *start* with any newlines to simplify/speed up endsWithBlankLine logic - list.items[list.items.length - 1].raw = raw.trimRight(); - list.items[list.items.length - 1].text = itemContents.trimRight(); - list.raw = list.raw.trimRight(); - var l = list.items.length; - - // Item child tokens handled here at end because we needed to have the final item to trim it first - for (i = 0; i < l; i++) { - this.lexer.state.top = false; - list.items[i].tokens = this.lexer.blockTokens(list.items[i].text, []); - var spacers = list.items[i].tokens.filter(function (t) { - return t.type === 'space'; - }); - var hasMultipleLineBreaks = spacers.every(function (t) { - var chars = t.raw.split(''); - var lineBreaks = 0; - for (var _iterator = _createForOfIteratorHelperLoose(chars), _step; !(_step = _iterator()).done;) { - var _char = _step.value; - if (_char === '\n') { - lineBreaks += 1; - } - if (lineBreaks > 1) { - return true; - } - } - return false; - }); - if (!list.loose && spacers.length && hasMultipleLineBreaks) { - // Having a single line break doesn't mean a list is loose. A single line break is terminating the last list item - list.loose = true; - list.items[i].loose = true; - } - } - return list; - } - }; - _proto.html = function html(src) { - var cap = this.rules.block.html.exec(src); - if (cap) { - var token = { - type: 'html', - raw: cap[0], - pre: !this.options.sanitizer && (cap[1] === 'pre' || cap[1] === 'script' || cap[1] === 'style'), - text: cap[0] - }; - if (this.options.sanitize) { - var text = this.options.sanitizer ? this.options.sanitizer(cap[0]) : escape(cap[0]); - token.type = 'paragraph'; - token.text = text; - token.tokens = this.lexer.inline(text); - } - return token; - } - }; - _proto.def = function def(src) { - var cap = this.rules.block.def.exec(src); - if (cap) { - var tag = cap[1].toLowerCase().replace(/\s+/g, ' '); - var href = cap[2] ? cap[2].replace(/^<(.*)>$/, '$1').replace(this.rules.inline._escapes, '$1') : ''; - var title = cap[3] ? cap[3].substring(1, cap[3].length - 1).replace(this.rules.inline._escapes, '$1') : cap[3]; - return { - type: 'def', - tag: tag, - raw: cap[0], - href: href, - title: title - }; - } - }; - _proto.table = function table(src) { - var cap = this.rules.block.table.exec(src); - if (cap) { - var item = { - type: 'table', - header: splitCells(cap[1]).map(function (c) { - return { - text: c - }; - }), - align: cap[2].replace(/^ *|\| *$/g, '').split(/ *\| */), - rows: cap[3] && cap[3].trim() ? cap[3].replace(/\n[ \t]*$/, '').split('\n') : [] - }; - if (item.header.length === item.align.length) { - item.raw = cap[0]; - var l = item.align.length; - var i, j, k, row; - for (i = 0; i < l; i++) { - if (/^ *-+: *$/.test(item.align[i])) { - item.align[i] = 'right'; - } else if (/^ *:-+: *$/.test(item.align[i])) { - item.align[i] = 'center'; - } else if (/^ *:-+ *$/.test(item.align[i])) { - item.align[i] = 'left'; - } else { - item.align[i] = null; - } - } - l = item.rows.length; - for (i = 0; i < l; i++) { - item.rows[i] = splitCells(item.rows[i], item.header.length).map(function (c) { - return { - text: c - }; - }); - } - - // parse child tokens inside headers and cells - - // header child tokens - l = item.header.length; - for (j = 0; j < l; j++) { - item.header[j].tokens = this.lexer.inline(item.header[j].text); - } - - // cell child tokens - l = item.rows.length; - for (j = 0; j < l; j++) { - row = item.rows[j]; - for (k = 0; k < row.length; k++) { - row[k].tokens = this.lexer.inline(row[k].text); - } - } - return item; - } - } - }; - _proto.lheading = function lheading(src) { - var cap = this.rules.block.lheading.exec(src); - if (cap) { - return { - type: 'heading', - raw: cap[0], - depth: cap[2].charAt(0) === '=' ? 1 : 2, - text: cap[1], - tokens: this.lexer.inline(cap[1]) - }; - } - }; - _proto.paragraph = function paragraph(src) { - var cap = this.rules.block.paragraph.exec(src); - if (cap) { - var text = cap[1].charAt(cap[1].length - 1) === '\n' ? cap[1].slice(0, -1) : cap[1]; - return { - type: 'paragraph', - raw: cap[0], - text: text, - tokens: this.lexer.inline(text) - }; - } - }; - _proto.text = function text(src) { - var cap = this.rules.block.text.exec(src); - if (cap) { - return { - type: 'text', - raw: cap[0], - text: cap[0], - tokens: this.lexer.inline(cap[0]) - }; - } - }; - _proto.escape = function escape$1(src) { - var cap = this.rules.inline.escape.exec(src); - if (cap) { - return { - type: 'escape', - raw: cap[0], - text: escape(cap[1]) - }; - } - }; - _proto.tag = function tag(src) { - var cap = this.rules.inline.tag.exec(src); - if (cap) { - if (!this.lexer.state.inLink && /^/i.test(cap[0])) { - this.lexer.state.inLink = false; - } - if (!this.lexer.state.inRawBlock && /^<(pre|code|kbd|script)(\s|>)/i.test(cap[0])) { - this.lexer.state.inRawBlock = true; - } else if (this.lexer.state.inRawBlock && /^<\/(pre|code|kbd|script)(\s|>)/i.test(cap[0])) { - this.lexer.state.inRawBlock = false; - } - return { - type: this.options.sanitize ? 'text' : 'html', - raw: cap[0], - inLink: this.lexer.state.inLink, - inRawBlock: this.lexer.state.inRawBlock, - text: this.options.sanitize ? this.options.sanitizer ? this.options.sanitizer(cap[0]) : escape(cap[0]) : cap[0] - }; - } - }; - _proto.link = function link(src) { - var cap = this.rules.inline.link.exec(src); - if (cap) { - var trimmedUrl = cap[2].trim(); - if (!this.options.pedantic && /^$/.test(trimmedUrl)) { - return; - } - - // ending angle bracket cannot be escaped - var rtrimSlash = rtrim(trimmedUrl.slice(0, -1), '\\'); - if ((trimmedUrl.length - rtrimSlash.length) % 2 === 0) { - return; - } - } else { - // find closing parenthesis - var lastParenIndex = findClosingBracket(cap[2], '()'); - if (lastParenIndex > -1) { - var start = cap[0].indexOf('!') === 0 ? 5 : 4; - var linkLen = start + cap[1].length + lastParenIndex; - cap[2] = cap[2].substring(0, lastParenIndex); - cap[0] = cap[0].substring(0, linkLen).trim(); - cap[3] = ''; - } - } - var href = cap[2]; - var title = ''; - if (this.options.pedantic) { - // split pedantic href and title - var link = /^([^'"]*[^\s])\s+(['"])(.*)\2/.exec(href); - if (link) { - href = link[1]; - title = link[3]; - } - } else { - title = cap[3] ? cap[3].slice(1, -1) : ''; - } - href = href.trim(); - if (/^$/.test(trimmedUrl)) { - // pedantic allows starting angle bracket without ending angle bracket - href = href.slice(1); - } else { - href = href.slice(1, -1); - } - } - return outputLink(cap, { - href: href ? href.replace(this.rules.inline._escapes, '$1') : href, - title: title ? title.replace(this.rules.inline._escapes, '$1') : title - }, cap[0], this.lexer); - } - }; - _proto.reflink = function reflink(src, links) { - var cap; - if ((cap = this.rules.inline.reflink.exec(src)) || (cap = this.rules.inline.nolink.exec(src))) { - var link = (cap[2] || cap[1]).replace(/\s+/g, ' '); - link = links[link.toLowerCase()]; - if (!link) { - var text = cap[0].charAt(0); - return { - type: 'text', - raw: text, - text: text - }; - } - return outputLink(cap, link, cap[0], this.lexer); - } - }; - _proto.emStrong = function emStrong(src, maskedSrc, prevChar) { - if (prevChar === void 0) { - prevChar = ''; - } - var match = this.rules.inline.emStrong.lDelim.exec(src); - if (!match) return; - - // _ can't be between two alphanumerics. \p{L}\p{N} includes non-english alphabet/numbers as well - if (match[3] && prevChar.match(/(?:[0-9A-Za-z\xAA\xB2\xB3\xB5\xB9\xBA\xBC-\xBE\xC0-\xD6\xD8-\xF6\xF8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377\u037A-\u037D\u037F\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0481\u048A-\u052F\u0531-\u0556\u0559\u0560-\u0588\u05D0-\u05EA\u05EF-\u05F2\u0620-\u064A\u0660-\u0669\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07C0-\u07EA\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u0860-\u086A\u0870-\u0887\u0889-\u088E\u08A0-\u08C9\u0904-\u0939\u093D\u0950\u0958-\u0961\u0966-\u096F\u0971-\u0980\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09E6-\u09F1\u09F4-\u09F9\u09FC\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A59-\u0A5C\u0A5E\u0A66-\u0A6F\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0AE6-\u0AEF\u0AF9\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C\u0B5D\u0B5F-\u0B61\u0B66-\u0B6F\u0B71-\u0B77\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0\u0BE6-\u0BF2\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C39\u0C3D\u0C58-\u0C5A\u0C5D\u0C60\u0C61\u0C66-\u0C6F\u0C78-\u0C7E\u0C80\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDD\u0CDE\u0CE0\u0CE1\u0CE6-\u0CEF\u0CF1\u0CF2\u0D04-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D\u0D4E\u0D54-\u0D56\u0D58-\u0D61\u0D66-\u0D78\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0DE6-\u0DEF\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E50-\u0E59\u0E81\u0E82\u0E84\u0E86-\u0E8A\u0E8C-\u0EA3\u0EA5\u0EA7-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0ED0-\u0ED9\u0EDC-\u0EDF\u0F00\u0F20-\u0F33\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A\u103F-\u1049\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081\u108E\u1090-\u1099\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1369-\u137C\u1380-\u138F\u13A0-\u13F5\u13F8-\u13FD\u1401-\u166C\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u16EE-\u16F8\u1700-\u1711\u171F-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7\u17DC\u17E0-\u17E9\u17F0-\u17F9\u1810-\u1819\u1820-\u1878\u1880-\u1884\u1887-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191E\u1946-\u196D\u1970-\u1974\u1980-\u19AB\u19B0-\u19C9\u19D0-\u19DA\u1A00-\u1A16\u1A20-\u1A54\u1A80-\u1A89\u1A90-\u1A99\u1AA7\u1B05-\u1B33\u1B45-\u1B4C\u1B50-\u1B59\u1B83-\u1BA0\u1BAE-\u1BE5\u1C00-\u1C23\u1C40-\u1C49\u1C4D-\u1C7D\u1C80-\u1C88\u1C90-\u1CBA\u1CBD-\u1CBF\u1CE9-\u1CEC\u1CEE-\u1CF3\u1CF5\u1CF6\u1CFA\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2070\u2071\u2074-\u2079\u207F-\u2089\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2150-\u2189\u2460-\u249B\u24EA-\u24FF\u2776-\u2793\u2C00-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3\u2CFD\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005-\u3007\u3021-\u3029\u3031-\u3035\u3038-\u303C\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312F\u3131-\u318E\u3192-\u3195\u31A0-\u31BF\u31F0-\u31FF\u3220-\u3229\u3248-\u324F\u3251-\u325F\u3280-\u3289\u32B1-\u32BF\u3400-\u4DBF\u4E00-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA62B\uA640-\uA66E\uA67F-\uA69D\uA6A0-\uA6EF\uA717-\uA71F\uA722-\uA788\uA78B-\uA7CA\uA7D0\uA7D1\uA7D3\uA7D5-\uA7D9\uA7F2-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA822\uA830-\uA835\uA840-\uA873\uA882-\uA8B3\uA8D0-\uA8D9\uA8F2-\uA8F7\uA8FB\uA8FD\uA8FE\uA900-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF-\uA9D9\uA9E0-\uA9E4\uA9E6-\uA9FE\uAA00-\uAA28\uAA40-\uAA42\uAA44-\uAA4B\uAA50-\uAA59\uAA60-\uAA76\uAA7A\uAA7E-\uAAAF\uAAB1\uAAB5\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uAB30-\uAB5A\uAB5C-\uAB69\uAB70-\uABE2\uABF0-\uABF9\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC\uFF10-\uFF19\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC]|\uD800[\uDC00-\uDC0B\uDC0D-\uDC26\uDC28-\uDC3A\uDC3C\uDC3D\uDC3F-\uDC4D\uDC50-\uDC5D\uDC80-\uDCFA\uDD07-\uDD33\uDD40-\uDD78\uDD8A\uDD8B\uDE80-\uDE9C\uDEA0-\uDED0\uDEE1-\uDEFB\uDF00-\uDF23\uDF2D-\uDF4A\uDF50-\uDF75\uDF80-\uDF9D\uDFA0-\uDFC3\uDFC8-\uDFCF\uDFD1-\uDFD5]|\uD801[\uDC00-\uDC9D\uDCA0-\uDCA9\uDCB0-\uDCD3\uDCD8-\uDCFB\uDD00-\uDD27\uDD30-\uDD63\uDD70-\uDD7A\uDD7C-\uDD8A\uDD8C-\uDD92\uDD94\uDD95\uDD97-\uDDA1\uDDA3-\uDDB1\uDDB3-\uDDB9\uDDBB\uDDBC\uDE00-\uDF36\uDF40-\uDF55\uDF60-\uDF67\uDF80-\uDF85\uDF87-\uDFB0\uDFB2-\uDFBA]|\uD802[\uDC00-\uDC05\uDC08\uDC0A-\uDC35\uDC37\uDC38\uDC3C\uDC3F-\uDC55\uDC58-\uDC76\uDC79-\uDC9E\uDCA7-\uDCAF\uDCE0-\uDCF2\uDCF4\uDCF5\uDCFB-\uDD1B\uDD20-\uDD39\uDD80-\uDDB7\uDDBC-\uDDCF\uDDD2-\uDE00\uDE10-\uDE13\uDE15-\uDE17\uDE19-\uDE35\uDE40-\uDE48\uDE60-\uDE7E\uDE80-\uDE9F\uDEC0-\uDEC7\uDEC9-\uDEE4\uDEEB-\uDEEF\uDF00-\uDF35\uDF40-\uDF55\uDF58-\uDF72\uDF78-\uDF91\uDFA9-\uDFAF]|\uD803[\uDC00-\uDC48\uDC80-\uDCB2\uDCC0-\uDCF2\uDCFA-\uDD23\uDD30-\uDD39\uDE60-\uDE7E\uDE80-\uDEA9\uDEB0\uDEB1\uDF00-\uDF27\uDF30-\uDF45\uDF51-\uDF54\uDF70-\uDF81\uDFB0-\uDFCB\uDFE0-\uDFF6]|\uD804[\uDC03-\uDC37\uDC52-\uDC6F\uDC71\uDC72\uDC75\uDC83-\uDCAF\uDCD0-\uDCE8\uDCF0-\uDCF9\uDD03-\uDD26\uDD36-\uDD3F\uDD44\uDD47\uDD50-\uDD72\uDD76\uDD83-\uDDB2\uDDC1-\uDDC4\uDDD0-\uDDDA\uDDDC\uDDE1-\uDDF4\uDE00-\uDE11\uDE13-\uDE2B\uDE3F\uDE40\uDE80-\uDE86\uDE88\uDE8A-\uDE8D\uDE8F-\uDE9D\uDE9F-\uDEA8\uDEB0-\uDEDE\uDEF0-\uDEF9\uDF05-\uDF0C\uDF0F\uDF10\uDF13-\uDF28\uDF2A-\uDF30\uDF32\uDF33\uDF35-\uDF39\uDF3D\uDF50\uDF5D-\uDF61]|\uD805[\uDC00-\uDC34\uDC47-\uDC4A\uDC50-\uDC59\uDC5F-\uDC61\uDC80-\uDCAF\uDCC4\uDCC5\uDCC7\uDCD0-\uDCD9\uDD80-\uDDAE\uDDD8-\uDDDB\uDE00-\uDE2F\uDE44\uDE50-\uDE59\uDE80-\uDEAA\uDEB8\uDEC0-\uDEC9\uDF00-\uDF1A\uDF30-\uDF3B\uDF40-\uDF46]|\uD806[\uDC00-\uDC2B\uDCA0-\uDCF2\uDCFF-\uDD06\uDD09\uDD0C-\uDD13\uDD15\uDD16\uDD18-\uDD2F\uDD3F\uDD41\uDD50-\uDD59\uDDA0-\uDDA7\uDDAA-\uDDD0\uDDE1\uDDE3\uDE00\uDE0B-\uDE32\uDE3A\uDE50\uDE5C-\uDE89\uDE9D\uDEB0-\uDEF8]|\uD807[\uDC00-\uDC08\uDC0A-\uDC2E\uDC40\uDC50-\uDC6C\uDC72-\uDC8F\uDD00-\uDD06\uDD08\uDD09\uDD0B-\uDD30\uDD46\uDD50-\uDD59\uDD60-\uDD65\uDD67\uDD68\uDD6A-\uDD89\uDD98\uDDA0-\uDDA9\uDEE0-\uDEF2\uDF02\uDF04-\uDF10\uDF12-\uDF33\uDF50-\uDF59\uDFB0\uDFC0-\uDFD4]|\uD808[\uDC00-\uDF99]|\uD809[\uDC00-\uDC6E\uDC80-\uDD43]|\uD80B[\uDF90-\uDFF0]|[\uD80C\uD81C-\uD820\uD822\uD840-\uD868\uD86A-\uD86C\uD86F-\uD872\uD874-\uD879\uD880-\uD883\uD885-\uD887][\uDC00-\uDFFF]|\uD80D[\uDC00-\uDC2F\uDC41-\uDC46]|\uD811[\uDC00-\uDE46]|\uD81A[\uDC00-\uDE38\uDE40-\uDE5E\uDE60-\uDE69\uDE70-\uDEBE\uDEC0-\uDEC9\uDED0-\uDEED\uDF00-\uDF2F\uDF40-\uDF43\uDF50-\uDF59\uDF5B-\uDF61\uDF63-\uDF77\uDF7D-\uDF8F]|\uD81B[\uDE40-\uDE96\uDF00-\uDF4A\uDF50\uDF93-\uDF9F\uDFE0\uDFE1\uDFE3]|\uD821[\uDC00-\uDFF7]|\uD823[\uDC00-\uDCD5\uDD00-\uDD08]|\uD82B[\uDFF0-\uDFF3\uDFF5-\uDFFB\uDFFD\uDFFE]|\uD82C[\uDC00-\uDD22\uDD32\uDD50-\uDD52\uDD55\uDD64-\uDD67\uDD70-\uDEFB]|\uD82F[\uDC00-\uDC6A\uDC70-\uDC7C\uDC80-\uDC88\uDC90-\uDC99]|\uD834[\uDEC0-\uDED3\uDEE0-\uDEF3\uDF60-\uDF78]|\uD835[\uDC00-\uDC54\uDC56-\uDC9C\uDC9E\uDC9F\uDCA2\uDCA5\uDCA6\uDCA9-\uDCAC\uDCAE-\uDCB9\uDCBB\uDCBD-\uDCC3\uDCC5-\uDD05\uDD07-\uDD0A\uDD0D-\uDD14\uDD16-\uDD1C\uDD1E-\uDD39\uDD3B-\uDD3E\uDD40-\uDD44\uDD46\uDD4A-\uDD50\uDD52-\uDEA5\uDEA8-\uDEC0\uDEC2-\uDEDA\uDEDC-\uDEFA\uDEFC-\uDF14\uDF16-\uDF34\uDF36-\uDF4E\uDF50-\uDF6E\uDF70-\uDF88\uDF8A-\uDFA8\uDFAA-\uDFC2\uDFC4-\uDFCB\uDFCE-\uDFFF]|\uD837[\uDF00-\uDF1E\uDF25-\uDF2A]|\uD838[\uDC30-\uDC6D\uDD00-\uDD2C\uDD37-\uDD3D\uDD40-\uDD49\uDD4E\uDE90-\uDEAD\uDEC0-\uDEEB\uDEF0-\uDEF9]|\uD839[\uDCD0-\uDCEB\uDCF0-\uDCF9\uDFE0-\uDFE6\uDFE8-\uDFEB\uDFED\uDFEE\uDFF0-\uDFFE]|\uD83A[\uDC00-\uDCC4\uDCC7-\uDCCF\uDD00-\uDD43\uDD4B\uDD50-\uDD59]|\uD83B[\uDC71-\uDCAB\uDCAD-\uDCAF\uDCB1-\uDCB4\uDD01-\uDD2D\uDD2F-\uDD3D\uDE00-\uDE03\uDE05-\uDE1F\uDE21\uDE22\uDE24\uDE27\uDE29-\uDE32\uDE34-\uDE37\uDE39\uDE3B\uDE42\uDE47\uDE49\uDE4B\uDE4D-\uDE4F\uDE51\uDE52\uDE54\uDE57\uDE59\uDE5B\uDE5D\uDE5F\uDE61\uDE62\uDE64\uDE67-\uDE6A\uDE6C-\uDE72\uDE74-\uDE77\uDE79-\uDE7C\uDE7E\uDE80-\uDE89\uDE8B-\uDE9B\uDEA1-\uDEA3\uDEA5-\uDEA9\uDEAB-\uDEBB]|\uD83C[\uDD00-\uDD0C]|\uD83E[\uDFF0-\uDFF9]|\uD869[\uDC00-\uDEDF\uDF00-\uDFFF]|\uD86D[\uDC00-\uDF39\uDF40-\uDFFF]|\uD86E[\uDC00-\uDC1D\uDC20-\uDFFF]|\uD873[\uDC00-\uDEA1\uDEB0-\uDFFF]|\uD87A[\uDC00-\uDFE0]|\uD87E[\uDC00-\uDE1D]|\uD884[\uDC00-\uDF4A\uDF50-\uDFFF]|\uD888[\uDC00-\uDFAF])/)) return; - var nextChar = match[1] || match[2] || ''; - if (!nextChar || nextChar && (prevChar === '' || this.rules.inline.punctuation.exec(prevChar))) { - var lLength = match[0].length - 1; - var rDelim, - rLength, - delimTotal = lLength, - midDelimTotal = 0; - var endReg = match[0][0] === '*' ? this.rules.inline.emStrong.rDelimAst : this.rules.inline.emStrong.rDelimUnd; - endReg.lastIndex = 0; - - // Clip maskedSrc to same section of string as src (move to lexer?) - maskedSrc = maskedSrc.slice(-1 * src.length + lLength); - while ((match = endReg.exec(maskedSrc)) != null) { - rDelim = match[1] || match[2] || match[3] || match[4] || match[5] || match[6]; - if (!rDelim) continue; // skip single * in __abc*abc__ - - rLength = rDelim.length; - if (match[3] || match[4]) { - // found another Left Delim - delimTotal += rLength; - continue; - } else if (match[5] || match[6]) { - // either Left or Right Delim - if (lLength % 3 && !((lLength + rLength) % 3)) { - midDelimTotal += rLength; - continue; // CommonMark Emphasis Rules 9-10 - } - } - - delimTotal -= rLength; - if (delimTotal > 0) continue; // Haven't found enough closing delimiters - - // Remove extra characters. *a*** -> *a* - rLength = Math.min(rLength, rLength + delimTotal + midDelimTotal); - var raw = src.slice(0, lLength + match.index + (match[0].length - rDelim.length) + rLength); - - // Create `em` if smallest delimiter has odd char count. *a*** - if (Math.min(lLength, rLength) % 2) { - var _text = raw.slice(1, -1); - return { - type: 'em', - raw: raw, - text: _text, - tokens: this.lexer.inlineTokens(_text) - }; - } - - // Create 'strong' if smallest delimiter has even char count. **a*** - var text = raw.slice(2, -2); - return { - type: 'strong', - raw: raw, - text: text, - tokens: this.lexer.inlineTokens(text) - }; - } - } - }; - _proto.codespan = function codespan(src) { - var cap = this.rules.inline.code.exec(src); - if (cap) { - var text = cap[2].replace(/\n/g, ' '); - var hasNonSpaceChars = /[^ ]/.test(text); - var hasSpaceCharsOnBothEnds = /^ /.test(text) && / $/.test(text); - if (hasNonSpaceChars && hasSpaceCharsOnBothEnds) { - text = text.substring(1, text.length - 1); - } - text = escape(text, true); - return { - type: 'codespan', - raw: cap[0], - text: text - }; - } - }; - _proto.br = function br(src) { - var cap = this.rules.inline.br.exec(src); - if (cap) { - return { - type: 'br', - raw: cap[0] - }; - } - }; - _proto.del = function del(src) { - var cap = this.rules.inline.del.exec(src); - if (cap) { - return { - type: 'del', - raw: cap[0], - text: cap[2], - tokens: this.lexer.inlineTokens(cap[2]) - }; - } - }; - _proto.autolink = function autolink(src, mangle) { - var cap = this.rules.inline.autolink.exec(src); - if (cap) { - var text, href; - if (cap[2] === '@') { - text = escape(this.options.mangle ? mangle(cap[1]) : cap[1]); - href = 'mailto:' + text; - } else { - text = escape(cap[1]); - href = text; - } - return { - type: 'link', - raw: cap[0], - text: text, - href: href, - tokens: [{ - type: 'text', - raw: text, - text: text - }] - }; - } - }; - _proto.url = function url(src, mangle) { - var cap; - if (cap = this.rules.inline.url.exec(src)) { - var text, href; - if (cap[2] === '@') { - text = escape(this.options.mangle ? mangle(cap[0]) : cap[0]); - href = 'mailto:' + text; - } else { - // do extended autolink path validation - var prevCapZero; - do { - prevCapZero = cap[0]; - cap[0] = this.rules.inline._backpedal.exec(cap[0])[0]; - } while (prevCapZero !== cap[0]); - text = escape(cap[0]); - if (cap[1] === 'www.') { - href = 'http://' + text; - } else { - href = text; - } - } - return { - type: 'link', - raw: cap[0], - text: text, - href: href, - tokens: [{ - type: 'text', - raw: text, - text: text - }] - }; - } - }; - _proto.inlineText = function inlineText(src, smartypants) { - var cap = this.rules.inline.text.exec(src); - if (cap) { - var text; - if (this.lexer.state.inRawBlock) { - text = this.options.sanitize ? this.options.sanitizer ? this.options.sanitizer(cap[0]) : escape(cap[0]) : cap[0]; - } else { - text = escape(this.options.smartypants ? smartypants(cap[0]) : cap[0]); - } - return { - type: 'text', - raw: cap[0], - text: text - }; - } - }; - return Tokenizer; - }(); - - /** - * Block-Level Grammar - */ - var block = { - newline: /^(?: *(?:\n|$))+/, - code: /^( {4}[^\n]+(?:\n(?: *(?:\n|$))*)?)+/, - fences: /^ {0,3}(`{3,}(?=[^`\n]*\n)|~{3,})([^\n]*)\n(?:|([\s\S]*?)\n)(?: {0,3}\1[~`]* *(?=\n|$)|$)/, - hr: /^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/, - heading: /^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/, - blockquote: /^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/, - list: /^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/, - html: '^ {0,3}(?:' // optional indentation - + '<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)' // (1) - + '|comment[^\\n]*(\\n+|$)' // (2) - + '|<\\?[\\s\\S]*?(?:\\?>\\n*|$)' // (3) - + '|\\n*|$)' // (4) - + '|\\n*|$)' // (5) - + '|)[\\s\\S]*?(?:(?:\\n *)+\\n|$)' // (6) - + '|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$)' // (7) open tag - + '|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$)' // (7) closing tag - + ')', - def: /^ {0,3}\[(label)\]: *(?:\n *)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n *)?| *\n *)(title))? *(?:\n+|$)/, - table: noopTest, - lheading: /^((?:.|\n(?!\n))+?)\n {0,3}(=+|-+) *(?:\n+|$)/, - // regex template, placeholders will be replaced according to different paragraph - // interruption rules of commonmark and the original markdown spec: - _paragraph: /^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/, - text: /^[^\n]+/ - }; - block._label = /(?!\s*\])(?:\\.|[^\[\]\\])+/; - block._title = /(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/; - block.def = edit(block.def).replace('label', block._label).replace('title', block._title).getRegex(); - block.bullet = /(?:[*+-]|\d{1,9}[.)])/; - block.listItemStart = edit(/^( *)(bull) */).replace('bull', block.bullet).getRegex(); - block.list = edit(block.list).replace(/bull/g, block.bullet).replace('hr', '\\n+(?=\\1?(?:(?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$))').replace('def', '\\n+(?=' + block.def.source + ')').getRegex(); - block._tag = 'address|article|aside|base|basefont|blockquote|body|caption' + '|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption' + '|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe' + '|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option' + '|p|param|section|source|summary|table|tbody|td|tfoot|th|thead|title|tr' + '|track|ul'; - block._comment = /|$)/; - block.html = edit(block.html, 'i').replace('comment', block._comment).replace('tag', block._tag).replace('attribute', / +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(); - block.paragraph = edit(block._paragraph).replace('hr', block.hr).replace('heading', ' {0,3}#{1,6} ').replace('|lheading', '') // setex headings don't interrupt commonmark paragraphs - .replace('|table', '').replace('blockquote', ' {0,3}>').replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n').replace('list', ' {0,3}(?:[*+-]|1[.)]) ') // only lists starting from 1 can interrupt - .replace('html', ')|<(?:script|pre|style|textarea|!--)').replace('tag', block._tag) // pars can be interrupted by type (6) html blocks - .getRegex(); - block.blockquote = edit(block.blockquote).replace('paragraph', block.paragraph).getRegex(); - - /** - * Normal Block Grammar - */ - - block.normal = merge({}, block); - - /** - * GFM Block Grammar - */ - - block.gfm = merge({}, block.normal, { - table: '^ *([^\\n ].*\\|.*)\\n' // Header - + ' {0,3}(?:\\| *)?(:?-+:? *(?:\\| *:?-+:? *)*)(?:\\| *)?' // Align - + '(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)' // Cells - }); - - block.gfm.table = edit(block.gfm.table).replace('hr', block.hr).replace('heading', ' {0,3}#{1,6} ').replace('blockquote', ' {0,3}>').replace('code', ' {4}[^\\n]').replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n').replace('list', ' {0,3}(?:[*+-]|1[.)]) ') // only lists starting from 1 can interrupt - .replace('html', ')|<(?:script|pre|style|textarea|!--)').replace('tag', block._tag) // tables can be interrupted by type (6) html blocks - .getRegex(); - block.gfm.paragraph = edit(block._paragraph).replace('hr', block.hr).replace('heading', ' {0,3}#{1,6} ').replace('|lheading', '') // setex headings don't interrupt commonmark paragraphs - .replace('table', block.gfm.table) // interrupt paragraphs with table - .replace('blockquote', ' {0,3}>').replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n').replace('list', ' {0,3}(?:[*+-]|1[.)]) ') // only lists starting from 1 can interrupt - .replace('html', ')|<(?:script|pre|style|textarea|!--)').replace('tag', block._tag) // pars can be interrupted by type (6) html blocks - .getRegex(); - /** - * Pedantic grammar (original John Gruber's loose markdown specification) - */ - - block.pedantic = merge({}, block.normal, { - html: edit('^ *(?:comment *(?:\\n|\\s*$)' + '|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)' // closed tag - + '|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))').replace('comment', block._comment).replace(/tag/g, '(?!(?:' + 'a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub' + '|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)' + '\\b)\\w+(?!:|[^\\w\\s@]*@)\\b').getRegex(), - def: /^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/, - heading: /^(#{1,6})(.*)(?:\n+|$)/, - fences: noopTest, - // fences not supported - lheading: /^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/, - paragraph: edit(block.normal._paragraph).replace('hr', block.hr).replace('heading', ' *#{1,6} *[^\n]').replace('lheading', block.lheading).replace('blockquote', ' {0,3}>').replace('|fences', '').replace('|list', '').replace('|html', '').getRegex() - }); - - /** - * Inline-Level Grammar - */ - var inline = { - escape: /^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/, - autolink: /^<(scheme:[^\s\x00-\x1f<>]*|email)>/, - url: noopTest, - tag: '^comment' + '|^' // self-closing tag - + '|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>' // open tag - + '|^<\\?[\\s\\S]*?\\?>' // processing instruction, e.g. - + '|^' // declaration, e.g. - + '|^', - // CDATA section - link: /^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/, - reflink: /^!?\[(label)\]\[(ref)\]/, - nolink: /^!?\[(ref)\](?:\[\])?/, - reflinkSearch: 'reflink|nolink(?!\\()', - emStrong: { - lDelim: /^(?:\*+(?:([punct_])|[^\s*]))|^_+(?:([punct*])|([^\s_]))/, - // (1) and (2) can only be a Right Delimiter. (3) and (4) can only be Left. (5) and (6) can be either Left or Right. - // () Skip orphan inside strong () Consume to delim (1) #*** (2) a***#, a*** (3) #***a, ***a (4) ***# (5) #***# (6) a***a - rDelimAst: /^(?:[^_*\\]|\\.)*?\_\_(?:[^_*\\]|\\.)*?\*(?:[^_*\\]|\\.)*?(?=\_\_)|(?:[^*\\]|\\.)+(?=[^*])|[punct_](\*+)(?=[\s]|$)|(?:[^punct*_\s\\]|\\.)(\*+)(?=[punct_\s]|$)|[punct_\s](\*+)(?=[^punct*_\s])|[\s](\*+)(?=[punct_])|[punct_](\*+)(?=[punct_])|(?:[^punct*_\s\\]|\\.)(\*+)(?=[^punct*_\s])/, - rDelimUnd: /^(?:[^_*\\]|\\.)*?\*\*(?:[^_*\\]|\\.)*?\_(?:[^_*\\]|\\.)*?(?=\*\*)|(?:[^_\\]|\\.)+(?=[^_])|[punct*](\_+)(?=[\s]|$)|(?:[^punct*_\s\\]|\\.)(\_+)(?=[punct*\s]|$)|[punct*\s](\_+)(?=[^punct*_\s])|[\s](\_+)(?=[punct*])|[punct*](\_+)(?=[punct*])/ // ^- Not allowed for _ - }, - - code: /^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/, - br: /^( {2,}|\\)\n(?!\s*$)/, - del: noopTest, - text: /^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\?@\\[\\]`^{|}~'; - inline.punctuation = edit(inline.punctuation).replace(/punctuation/g, inline._punctuation).getRegex(); - - // sequences em should skip over [title](link), `code`, - inline.blockSkip = /\[[^\]]*?\]\([^\)]*?\)|`[^`]*?`|<[^>]*?>/g; - // lookbehind is not available on Safari as of version 16 - // inline.escapedEmSt = /(?<=(?:^|[^\\)(?:\\[^])*)\\[*_]/g; - inline.escapedEmSt = /(?:^|[^\\])(?:\\\\)*\\[*_]/g; - inline._comment = edit(block._comment).replace('(?:-->|$)', '-->').getRegex(); - inline.emStrong.lDelim = edit(inline.emStrong.lDelim).replace(/punct/g, inline._punctuation).getRegex(); - inline.emStrong.rDelimAst = edit(inline.emStrong.rDelimAst, 'g').replace(/punct/g, inline._punctuation).getRegex(); - inline.emStrong.rDelimUnd = edit(inline.emStrong.rDelimUnd, 'g').replace(/punct/g, inline._punctuation).getRegex(); - inline._escapes = /\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/g; - inline._scheme = /[a-zA-Z][a-zA-Z0-9+.-]{1,31}/; - inline._email = /[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/; - inline.autolink = edit(inline.autolink).replace('scheme', inline._scheme).replace('email', inline._email).getRegex(); - inline._attribute = /\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/; - inline.tag = edit(inline.tag).replace('comment', inline._comment).replace('attribute', inline._attribute).getRegex(); - inline._label = /(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/; - inline._href = /<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/; - inline._title = /"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/; - inline.link = edit(inline.link).replace('label', inline._label).replace('href', inline._href).replace('title', inline._title).getRegex(); - inline.reflink = edit(inline.reflink).replace('label', inline._label).replace('ref', block._label).getRegex(); - inline.nolink = edit(inline.nolink).replace('ref', block._label).getRegex(); - inline.reflinkSearch = edit(inline.reflinkSearch, 'g').replace('reflink', inline.reflink).replace('nolink', inline.nolink).getRegex(); - - /** - * Normal Inline Grammar - */ - - inline.normal = merge({}, inline); - - /** - * Pedantic Inline Grammar - */ - - inline.pedantic = merge({}, inline.normal, { - strong: { - start: /^__|\*\*/, - middle: /^__(?=\S)([\s\S]*?\S)__(?!_)|^\*\*(?=\S)([\s\S]*?\S)\*\*(?!\*)/, - endAst: /\*\*(?!\*)/g, - endUnd: /__(?!_)/g - }, - em: { - start: /^_|\*/, - middle: /^()\*(?=\S)([\s\S]*?\S)\*(?!\*)|^_(?=\S)([\s\S]*?\S)_(?!_)/, - endAst: /\*(?!\*)/g, - endUnd: /_(?!_)/g - }, - link: edit(/^!?\[(label)\]\((.*?)\)/).replace('label', inline._label).getRegex(), - reflink: edit(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace('label', inline._label).getRegex() - }); - - /** - * GFM Inline Grammar - */ - - inline.gfm = merge({}, inline.normal, { - escape: edit(inline.escape).replace('])', '~|])').getRegex(), - _extended_email: /[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/, - url: /^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/, - _backpedal: /(?:[^?!.,:;*_~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_~)]+(?!$))+/, - del: /^(~~?)(?=[^\s~])([\s\S]*?[^\s~])\1(?=[^~]|$)/, - text: /^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\ 0.5) { - ch = 'x' + ch.toString(16); - } - out += '&#' + ch + ';'; - } - return out; - } - - /** - * Block Lexer - */ - var Lexer = /*#__PURE__*/function () { - function Lexer(options) { - this.tokens = []; - this.tokens.links = Object.create(null); - this.options = options || exports.defaults; - this.options.tokenizer = this.options.tokenizer || new Tokenizer(); - this.tokenizer = this.options.tokenizer; - this.tokenizer.options = this.options; - this.tokenizer.lexer = this; - this.inlineQueue = []; - this.state = { - inLink: false, - inRawBlock: false, - top: true - }; - var rules = { - block: block.normal, - inline: inline.normal - }; - if (this.options.pedantic) { - rules.block = block.pedantic; - rules.inline = inline.pedantic; - } else if (this.options.gfm) { - rules.block = block.gfm; - if (this.options.breaks) { - rules.inline = inline.breaks; - } else { - rules.inline = inline.gfm; - } - } - this.tokenizer.rules = rules; - } - - /** - * Expose Rules - */ - /** - * Static Lex Method - */ - Lexer.lex = function lex(src, options) { - var lexer = new Lexer(options); - return lexer.lex(src); - } - - /** - * Static Lex Inline Method - */; - Lexer.lexInline = function lexInline(src, options) { - var lexer = new Lexer(options); - return lexer.inlineTokens(src); - } - - /** - * Preprocessing - */; - var _proto = Lexer.prototype; - _proto.lex = function lex(src) { - src = src.replace(/\r\n|\r/g, '\n'); - this.blockTokens(src, this.tokens); - var next; - while (next = this.inlineQueue.shift()) { - this.inlineTokens(next.src, next.tokens); - } - return this.tokens; - } - - /** - * Lexing - */; - _proto.blockTokens = function blockTokens(src, tokens) { - var _this = this; - if (tokens === void 0) { - tokens = []; - } - if (this.options.pedantic) { - src = src.replace(/\t/g, ' ').replace(/^ +$/gm, ''); - } else { - src = src.replace(/^( *)(\t+)/gm, function (_, leading, tabs) { - return leading + ' '.repeat(tabs.length); - }); - } - var token, lastToken, cutSrc, lastParagraphClipped; - var _loop = function _loop() { - if (_this.options.extensions && _this.options.extensions.block && _this.options.extensions.block.some(function (extTokenizer) { - if (token = extTokenizer.call({ - lexer: _this - }, src, tokens)) { - src = src.substring(token.raw.length); - tokens.push(token); - return true; - } - return false; - })) { - return "continue"; - } - - // newline - if (token = _this.tokenizer.space(src)) { - src = src.substring(token.raw.length); - if (token.raw.length === 1 && tokens.length > 0) { - // if there's a single \n as a spacer, it's terminating the last line, - // so move it there so that we don't get unecessary paragraph tags - tokens[tokens.length - 1].raw += '\n'; - } else { - tokens.push(token); - } - return "continue"; - } - - // code - if (token = _this.tokenizer.code(src)) { - src = src.substring(token.raw.length); - lastToken = tokens[tokens.length - 1]; - // An indented code block cannot interrupt a paragraph. - if (lastToken && (lastToken.type === 'paragraph' || lastToken.type === 'text')) { - lastToken.raw += '\n' + token.raw; - lastToken.text += '\n' + token.text; - _this.inlineQueue[_this.inlineQueue.length - 1].src = lastToken.text; - } else { - tokens.push(token); - } - return "continue"; - } - - // fences - if (token = _this.tokenizer.fences(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - return "continue"; - } - - // heading - if (token = _this.tokenizer.heading(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - return "continue"; - } - - // hr - if (token = _this.tokenizer.hr(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - return "continue"; - } - - // blockquote - if (token = _this.tokenizer.blockquote(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - return "continue"; - } - - // list - if (token = _this.tokenizer.list(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - return "continue"; - } - - // html - if (token = _this.tokenizer.html(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - return "continue"; - } - - // def - if (token = _this.tokenizer.def(src)) { - src = src.substring(token.raw.length); - lastToken = tokens[tokens.length - 1]; - if (lastToken && (lastToken.type === 'paragraph' || lastToken.type === 'text')) { - lastToken.raw += '\n' + token.raw; - lastToken.text += '\n' + token.raw; - _this.inlineQueue[_this.inlineQueue.length - 1].src = lastToken.text; - } else if (!_this.tokens.links[token.tag]) { - _this.tokens.links[token.tag] = { - href: token.href, - title: token.title - }; - } - return "continue"; - } - - // table (gfm) - if (token = _this.tokenizer.table(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - return "continue"; - } - - // lheading - if (token = _this.tokenizer.lheading(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - return "continue"; - } - - // top-level paragraph - // prevent paragraph consuming extensions by clipping 'src' to extension start - cutSrc = src; - if (_this.options.extensions && _this.options.extensions.startBlock) { - var startIndex = Infinity; - var tempSrc = src.slice(1); - var tempStart; - _this.options.extensions.startBlock.forEach(function (getStartIndex) { - tempStart = getStartIndex.call({ - lexer: this - }, tempSrc); - if (typeof tempStart === 'number' && tempStart >= 0) { - startIndex = Math.min(startIndex, tempStart); - } - }); - if (startIndex < Infinity && startIndex >= 0) { - cutSrc = src.substring(0, startIndex + 1); - } - } - if (_this.state.top && (token = _this.tokenizer.paragraph(cutSrc))) { - lastToken = tokens[tokens.length - 1]; - if (lastParagraphClipped && lastToken.type === 'paragraph') { - lastToken.raw += '\n' + token.raw; - lastToken.text += '\n' + token.text; - _this.inlineQueue.pop(); - _this.inlineQueue[_this.inlineQueue.length - 1].src = lastToken.text; - } else { - tokens.push(token); - } - lastParagraphClipped = cutSrc.length !== src.length; - src = src.substring(token.raw.length); - return "continue"; - } - - // text - if (token = _this.tokenizer.text(src)) { - src = src.substring(token.raw.length); - lastToken = tokens[tokens.length - 1]; - if (lastToken && lastToken.type === 'text') { - lastToken.raw += '\n' + token.raw; - lastToken.text += '\n' + token.text; - _this.inlineQueue.pop(); - _this.inlineQueue[_this.inlineQueue.length - 1].src = lastToken.text; - } else { - tokens.push(token); - } - return "continue"; - } - if (src) { - var errMsg = 'Infinite loop on byte: ' + src.charCodeAt(0); - if (_this.options.silent) { - console.error(errMsg); - return "break"; - } else { - throw new Error(errMsg); - } - } - }; - while (src) { - var _ret = _loop(); - if (_ret === "continue") continue; - if (_ret === "break") break; - } - this.state.top = true; - return tokens; - }; - _proto.inline = function inline(src, tokens) { - if (tokens === void 0) { - tokens = []; - } - this.inlineQueue.push({ - src: src, - tokens: tokens - }); - return tokens; - } - - /** - * Lexing/Compiling - */; - _proto.inlineTokens = function inlineTokens(src, tokens) { - var _this2 = this; - if (tokens === void 0) { - tokens = []; - } - var token, lastToken, cutSrc; - - // String with links masked to avoid interference with em and strong - var maskedSrc = src; - var match; - var keepPrevChar, prevChar; - - // Mask out reflinks - if (this.tokens.links) { - var links = Object.keys(this.tokens.links); - if (links.length > 0) { - while ((match = this.tokenizer.rules.inline.reflinkSearch.exec(maskedSrc)) != null) { - if (links.includes(match[0].slice(match[0].lastIndexOf('[') + 1, -1))) { - maskedSrc = maskedSrc.slice(0, match.index) + '[' + repeatString('a', match[0].length - 2) + ']' + maskedSrc.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex); - } - } - } - } - // Mask out other blocks - while ((match = this.tokenizer.rules.inline.blockSkip.exec(maskedSrc)) != null) { - maskedSrc = maskedSrc.slice(0, match.index) + '[' + repeatString('a', match[0].length - 2) + ']' + maskedSrc.slice(this.tokenizer.rules.inline.blockSkip.lastIndex); - } - - // Mask out escaped em & strong delimiters - while ((match = this.tokenizer.rules.inline.escapedEmSt.exec(maskedSrc)) != null) { - maskedSrc = maskedSrc.slice(0, match.index + match[0].length - 2) + '++' + maskedSrc.slice(this.tokenizer.rules.inline.escapedEmSt.lastIndex); - this.tokenizer.rules.inline.escapedEmSt.lastIndex--; - } - var _loop2 = function _loop2() { - if (!keepPrevChar) { - prevChar = ''; - } - keepPrevChar = false; - - // extensions - if (_this2.options.extensions && _this2.options.extensions.inline && _this2.options.extensions.inline.some(function (extTokenizer) { - if (token = extTokenizer.call({ - lexer: _this2 - }, src, tokens)) { - src = src.substring(token.raw.length); - tokens.push(token); - return true; - } - return false; - })) { - return "continue"; - } - - // escape - if (token = _this2.tokenizer.escape(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - return "continue"; - } - - // tag - if (token = _this2.tokenizer.tag(src)) { - src = src.substring(token.raw.length); - lastToken = tokens[tokens.length - 1]; - if (lastToken && token.type === 'text' && lastToken.type === 'text') { - lastToken.raw += token.raw; - lastToken.text += token.text; - } else { - tokens.push(token); - } - return "continue"; - } - - // link - if (token = _this2.tokenizer.link(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - return "continue"; - } - - // reflink, nolink - if (token = _this2.tokenizer.reflink(src, _this2.tokens.links)) { - src = src.substring(token.raw.length); - lastToken = tokens[tokens.length - 1]; - if (lastToken && token.type === 'text' && lastToken.type === 'text') { - lastToken.raw += token.raw; - lastToken.text += token.text; - } else { - tokens.push(token); - } - return "continue"; - } - - // em & strong - if (token = _this2.tokenizer.emStrong(src, maskedSrc, prevChar)) { - src = src.substring(token.raw.length); - tokens.push(token); - return "continue"; - } - - // code - if (token = _this2.tokenizer.codespan(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - return "continue"; - } - - // br - if (token = _this2.tokenizer.br(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - return "continue"; - } - - // del (gfm) - if (token = _this2.tokenizer.del(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - return "continue"; - } - - // autolink - if (token = _this2.tokenizer.autolink(src, mangle)) { - src = src.substring(token.raw.length); - tokens.push(token); - return "continue"; - } - - // url (gfm) - if (!_this2.state.inLink && (token = _this2.tokenizer.url(src, mangle))) { - src = src.substring(token.raw.length); - tokens.push(token); - return "continue"; - } - - // text - // prevent inlineText consuming extensions by clipping 'src' to extension start - cutSrc = src; - if (_this2.options.extensions && _this2.options.extensions.startInline) { - var startIndex = Infinity; - var tempSrc = src.slice(1); - var tempStart; - _this2.options.extensions.startInline.forEach(function (getStartIndex) { - tempStart = getStartIndex.call({ - lexer: this - }, tempSrc); - if (typeof tempStart === 'number' && tempStart >= 0) { - startIndex = Math.min(startIndex, tempStart); - } - }); - if (startIndex < Infinity && startIndex >= 0) { - cutSrc = src.substring(0, startIndex + 1); - } - } - if (token = _this2.tokenizer.inlineText(cutSrc, smartypants)) { - src = src.substring(token.raw.length); - if (token.raw.slice(-1) !== '_') { - // Track prevChar before string of ____ started - prevChar = token.raw.slice(-1); - } - keepPrevChar = true; - lastToken = tokens[tokens.length - 1]; - if (lastToken && lastToken.type === 'text') { - lastToken.raw += token.raw; - lastToken.text += token.text; - } else { - tokens.push(token); - } - return "continue"; - } - if (src) { - var errMsg = 'Infinite loop on byte: ' + src.charCodeAt(0); - if (_this2.options.silent) { - console.error(errMsg); - return "break"; - } else { - throw new Error(errMsg); - } - } - }; - while (src) { - var _ret2 = _loop2(); - if (_ret2 === "continue") continue; - if (_ret2 === "break") break; - } - return tokens; - }; - _createClass(Lexer, null, [{ - key: "rules", - get: function get() { - return { - block: block, - inline: inline - }; - } - }]); - return Lexer; - }(); - - /** - * Renderer - */ - var Renderer = /*#__PURE__*/function () { - function Renderer(options) { - this.options = options || exports.defaults; - } - var _proto = Renderer.prototype; - _proto.code = function code(_code, infostring, escaped) { - var lang = (infostring || '').match(/\S*/)[0]; - if (this.options.highlight) { - var out = this.options.highlight(_code, lang); - if (out != null && out !== _code) { - escaped = true; - _code = out; - } - } - _code = _code.replace(/\n$/, '') + '\n'; - if (!lang) { - return '
' + (escaped ? _code : escape(_code, true)) + '
\n'; - } - return '
' + (escaped ? _code : escape(_code, true)) + '
\n'; - } - - /** - * @param {string} quote - */; - _proto.blockquote = function blockquote(quote) { - return "
\n" + quote + "
\n"; - }; - _proto.html = function html(_html) { - return _html; - } - - /** - * @param {string} text - * @param {string} level - * @param {string} raw - * @param {any} slugger - */; - _proto.heading = function heading(text, level, raw, slugger) { - if (this.options.headerIds) { - var id = this.options.headerPrefix + slugger.slug(raw); - return "" + text + "\n"; - } - - // ignore IDs - return "" + text + "\n"; - }; - _proto.hr = function hr() { - return this.options.xhtml ? '
\n' : '
\n'; - }; - _proto.list = function list(body, ordered, start) { - var type = ordered ? 'ol' : 'ul', - startatt = ordered && start !== 1 ? ' start="' + start + '"' : ''; - return '<' + type + startatt + '>\n' + body + '\n'; - } - - /** - * @param {string} text - */; - _proto.listitem = function listitem(text) { - return "
  • " + text + "
  • \n"; - }; - _proto.checkbox = function checkbox(checked) { - return ' '; - } - - /** - * @param {string} text - */; - _proto.paragraph = function paragraph(text) { - return "

    " + text + "

    \n"; - } - - /** - * @param {string} header - * @param {string} body - */; - _proto.table = function table(header, body) { - if (body) body = "" + body + ""; - return '\n' + '\n' + header + '\n' + body + '
    \n'; - } - - /** - * @param {string} content - */; - _proto.tablerow = function tablerow(content) { - return "\n" + content + "\n"; - }; - _proto.tablecell = function tablecell(content, flags) { - var type = flags.header ? 'th' : 'td'; - var tag = flags.align ? "<" + type + " align=\"" + flags.align + "\">" : "<" + type + ">"; - return tag + content + ("\n"); - } - - /** - * span level renderer - * @param {string} text - */; - _proto.strong = function strong(text) { - return "" + text + ""; - } - - /** - * @param {string} text - */; - _proto.em = function em(text) { - return "" + text + ""; - } - - /** - * @param {string} text - */; - _proto.codespan = function codespan(text) { - return "" + text + ""; - }; - _proto.br = function br() { - return this.options.xhtml ? '
    ' : '
    '; - } - - /** - * @param {string} text - */; - _proto.del = function del(text) { - return "" + text + ""; - } - - /** - * @param {string} href - * @param {string} title - * @param {string} text - */; - _proto.link = function link(href, title, text) { - href = cleanUrl(this.options.sanitize, this.options.baseUrl, href); - if (href === null) { - return text; - } - var out = '
    '; - return out; - } - - /** - * @param {string} href - * @param {string} title - * @param {string} text - */; - _proto.image = function image(href, title, text) { - href = cleanUrl(this.options.sanitize, this.options.baseUrl, href); - if (href === null) { - return text; - } - var out = "\""' : '>'; - return out; - }; - _proto.text = function text(_text) { - return _text; - }; - return Renderer; - }(); - - /** - * TextRenderer - * returns only the textual part of the token - */ - var TextRenderer = /*#__PURE__*/function () { - function TextRenderer() {} - var _proto = TextRenderer.prototype; - // no need for block level renderers - _proto.strong = function strong(text) { - return text; - }; - _proto.em = function em(text) { - return text; - }; - _proto.codespan = function codespan(text) { - return text; - }; - _proto.del = function del(text) { - return text; - }; - _proto.html = function html(text) { - return text; - }; - _proto.text = function text(_text) { - return _text; - }; - _proto.link = function link(href, title, text) { - return '' + text; - }; - _proto.image = function image(href, title, text) { - return '' + text; - }; - _proto.br = function br() { - return ''; - }; - return TextRenderer; - }(); - - /** - * Slugger generates header id - */ - var Slugger = /*#__PURE__*/function () { - function Slugger() { - this.seen = {}; - } - - /** - * @param {string} value - */ - var _proto = Slugger.prototype; - _proto.serialize = function serialize(value) { - return value.toLowerCase().trim() - // remove html tags - .replace(/<[!\/a-z].*?>/ig, '') - // remove unwanted chars - .replace(/[\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*+,./:;<=>?@[\]^`{|}~]/g, '').replace(/\s/g, '-'); - } - - /** - * Finds the next safe (unique) slug to use - * @param {string} originalSlug - * @param {boolean} isDryRun - */; - _proto.getNextSafeSlug = function getNextSafeSlug(originalSlug, isDryRun) { - var slug = originalSlug; - var occurenceAccumulator = 0; - if (this.seen.hasOwnProperty(slug)) { - occurenceAccumulator = this.seen[originalSlug]; - do { - occurenceAccumulator++; - slug = originalSlug + '-' + occurenceAccumulator; - } while (this.seen.hasOwnProperty(slug)); - } - if (!isDryRun) { - this.seen[originalSlug] = occurenceAccumulator; - this.seen[slug] = 0; - } - return slug; - } - - /** - * Convert string to unique id - * @param {object} [options] - * @param {boolean} [options.dryrun] Generates the next unique slug without - * updating the internal accumulator. - */; - _proto.slug = function slug(value, options) { - if (options === void 0) { - options = {}; - } - var slug = this.serialize(value); - return this.getNextSafeSlug(slug, options.dryrun); - }; - return Slugger; - }(); - - /** - * Parsing & Compiling - */ - var Parser = /*#__PURE__*/function () { - function Parser(options) { - this.options = options || exports.defaults; - this.options.renderer = this.options.renderer || new Renderer(); - this.renderer = this.options.renderer; - this.renderer.options = this.options; - this.textRenderer = new TextRenderer(); - this.slugger = new Slugger(); - } - - /** - * Static Parse Method - */ - Parser.parse = function parse(tokens, options) { - var parser = new Parser(options); - return parser.parse(tokens); - } - - /** - * Static Parse Inline Method - */; - Parser.parseInline = function parseInline(tokens, options) { - var parser = new Parser(options); - return parser.parseInline(tokens); - } - - /** - * Parse Loop - */; - var _proto = Parser.prototype; - _proto.parse = function parse(tokens, top) { - if (top === void 0) { - top = true; - } - var out = '', - i, - j, - k, - l2, - l3, - row, - cell, - header, - body, - token, - ordered, - start, - loose, - itemBody, - item, - checked, - task, - checkbox, - ret; - var l = tokens.length; - for (i = 0; i < l; i++) { - token = tokens[i]; - - // Run any renderer extensions - if (this.options.extensions && this.options.extensions.renderers && this.options.extensions.renderers[token.type]) { - ret = this.options.extensions.renderers[token.type].call({ - parser: this - }, token); - if (ret !== false || !['space', 'hr', 'heading', 'code', 'table', 'blockquote', 'list', 'html', 'paragraph', 'text'].includes(token.type)) { - out += ret || ''; - continue; - } - } - switch (token.type) { - case 'space': - { - continue; - } - case 'hr': - { - out += this.renderer.hr(); - continue; - } - case 'heading': - { - out += this.renderer.heading(this.parseInline(token.tokens), token.depth, unescape(this.parseInline(token.tokens, this.textRenderer)), this.slugger); - continue; - } - case 'code': - { - out += this.renderer.code(token.text, token.lang, token.escaped); - continue; - } - case 'table': - { - header = ''; - - // header - cell = ''; - l2 = token.header.length; - for (j = 0; j < l2; j++) { - cell += this.renderer.tablecell(this.parseInline(token.header[j].tokens), { - header: true, - align: token.align[j] - }); - } - header += this.renderer.tablerow(cell); - body = ''; - l2 = token.rows.length; - for (j = 0; j < l2; j++) { - row = token.rows[j]; - cell = ''; - l3 = row.length; - for (k = 0; k < l3; k++) { - cell += this.renderer.tablecell(this.parseInline(row[k].tokens), { - header: false, - align: token.align[k] - }); - } - body += this.renderer.tablerow(cell); - } - out += this.renderer.table(header, body); - continue; - } - case 'blockquote': - { - body = this.parse(token.tokens); - out += this.renderer.blockquote(body); - continue; - } - case 'list': - { - ordered = token.ordered; - start = token.start; - loose = token.loose; - l2 = token.items.length; - body = ''; - for (j = 0; j < l2; j++) { - item = token.items[j]; - checked = item.checked; - task = item.task; - itemBody = ''; - if (item.task) { - checkbox = this.renderer.checkbox(checked); - if (loose) { - if (item.tokens.length > 0 && item.tokens[0].type === 'paragraph') { - item.tokens[0].text = checkbox + ' ' + item.tokens[0].text; - if (item.tokens[0].tokens && item.tokens[0].tokens.length > 0 && item.tokens[0].tokens[0].type === 'text') { - item.tokens[0].tokens[0].text = checkbox + ' ' + item.tokens[0].tokens[0].text; - } - } else { - item.tokens.unshift({ - type: 'text', - text: checkbox - }); - } - } else { - itemBody += checkbox; - } - } - itemBody += this.parse(item.tokens, loose); - body += this.renderer.listitem(itemBody, task, checked); - } - out += this.renderer.list(body, ordered, start); - continue; - } - case 'html': - { - // TODO parse inline content if parameter markdown=1 - out += this.renderer.html(token.text); - continue; - } - case 'paragraph': - { - out += this.renderer.paragraph(this.parseInline(token.tokens)); - continue; - } - case 'text': - { - body = token.tokens ? this.parseInline(token.tokens) : token.text; - while (i + 1 < l && tokens[i + 1].type === 'text') { - token = tokens[++i]; - body += '\n' + (token.tokens ? this.parseInline(token.tokens) : token.text); - } - out += top ? this.renderer.paragraph(body) : body; - continue; - } - default: - { - var errMsg = 'Token with "' + token.type + '" type was not found.'; - if (this.options.silent) { - console.error(errMsg); - return; - } else { - throw new Error(errMsg); - } - } - } - } - return out; - } - - /** - * Parse Inline Tokens - */; - _proto.parseInline = function parseInline(tokens, renderer) { - renderer = renderer || this.renderer; - var out = '', - i, - token, - ret; - var l = tokens.length; - for (i = 0; i < l; i++) { - token = tokens[i]; - - // Run any renderer extensions - if (this.options.extensions && this.options.extensions.renderers && this.options.extensions.renderers[token.type]) { - ret = this.options.extensions.renderers[token.type].call({ - parser: this - }, token); - if (ret !== false || !['escape', 'html', 'link', 'image', 'strong', 'em', 'codespan', 'br', 'del', 'text'].includes(token.type)) { - out += ret || ''; - continue; - } - } - switch (token.type) { - case 'escape': - { - out += renderer.text(token.text); - break; - } - case 'html': - { - out += renderer.html(token.text); - break; - } - case 'link': - { - out += renderer.link(token.href, token.title, this.parseInline(token.tokens, renderer)); - break; - } - case 'image': - { - out += renderer.image(token.href, token.title, token.text); - break; - } - case 'strong': - { - out += renderer.strong(this.parseInline(token.tokens, renderer)); - break; - } - case 'em': - { - out += renderer.em(this.parseInline(token.tokens, renderer)); - break; - } - case 'codespan': - { - out += renderer.codespan(token.text); - break; - } - case 'br': - { - out += renderer.br(); - break; - } - case 'del': - { - out += renderer.del(this.parseInline(token.tokens, renderer)); - break; - } - case 'text': - { - out += renderer.text(token.text); - break; - } - default: - { - var errMsg = 'Token with "' + token.type + '" type was not found.'; - if (this.options.silent) { - console.error(errMsg); - return; - } else { - throw new Error(errMsg); - } - } - } - } - return out; - }; - return Parser; - }(); - - /** - * Marked - */ - function marked(src, opt, callback) { - // throw error in case of non string input - if (typeof src === 'undefined' || src === null) { - throw new Error('marked(): input parameter is undefined or null'); - } - if (typeof src !== 'string') { - throw new Error('marked(): input parameter is of type ' + Object.prototype.toString.call(src) + ', string expected'); - } - if (typeof opt === 'function') { - callback = opt; - opt = null; - } - opt = merge({}, marked.defaults, opt || {}); - checkSanitizeDeprecation(opt); - if (callback) { - var highlight = opt.highlight; - var tokens; - try { - tokens = Lexer.lex(src, opt); - } catch (e) { - return callback(e); - } - var done = function done(err) { - var out; - if (!err) { - try { - if (opt.walkTokens) { - marked.walkTokens(tokens, opt.walkTokens); - } - out = Parser.parse(tokens, opt); - } catch (e) { - err = e; - } - } - opt.highlight = highlight; - return err ? callback(err) : callback(null, out); - }; - if (!highlight || highlight.length < 3) { - return done(); - } - delete opt.highlight; - if (!tokens.length) return done(); - var pending = 0; - marked.walkTokens(tokens, function (token) { - if (token.type === 'code') { - pending++; - setTimeout(function () { - highlight(token.text, token.lang, function (err, code) { - if (err) { - return done(err); - } - if (code != null && code !== token.text) { - token.text = code; - token.escaped = true; - } - pending--; - if (pending === 0) { - done(); - } - }); - }, 0); - } - }); - if (pending === 0) { - done(); - } - return; - } - function onError(e) { - e.message += '\nPlease report this to https://github.com/markedjs/marked.'; - if (opt.silent) { - return '

    An error occurred:

    ' + escape(e.message + '', true) + '
    '; - } - throw e; - } - try { - var _tokens = Lexer.lex(src, opt); - if (opt.walkTokens) { - if (opt.async) { - return Promise.all(marked.walkTokens(_tokens, opt.walkTokens)).then(function () { - return Parser.parse(_tokens, opt); - })["catch"](onError); - } - marked.walkTokens(_tokens, opt.walkTokens); - } - return Parser.parse(_tokens, opt); - } catch (e) { - onError(e); - } - } - - /** - * Options - */ - - marked.options = marked.setOptions = function (opt) { - merge(marked.defaults, opt); - changeDefaults(marked.defaults); - return marked; - }; - marked.getDefaults = getDefaults; - marked.defaults = exports.defaults; - - /** - * Use Extension - */ - - marked.use = function () { - var extensions = marked.defaults.extensions || { - renderers: {}, - childTokens: {} - }; - for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) { - args[_key] = arguments[_key]; - } - args.forEach(function (pack) { - // copy options to new object - var opts = merge({}, pack); - - // set async to true if it was set to true before - opts.async = marked.defaults.async || opts.async; - - // ==-- Parse "addon" extensions --== // - if (pack.extensions) { - pack.extensions.forEach(function (ext) { - if (!ext.name) { - throw new Error('extension name required'); - } - if (ext.renderer) { - // Renderer extensions - var prevRenderer = extensions.renderers[ext.name]; - if (prevRenderer) { - // Replace extension with func to run new extension but fall back if false - extensions.renderers[ext.name] = function () { - for (var _len2 = arguments.length, args = new Array(_len2), _key2 = 0; _key2 < _len2; _key2++) { - args[_key2] = arguments[_key2]; - } - var ret = ext.renderer.apply(this, args); - if (ret === false) { - ret = prevRenderer.apply(this, args); - } - return ret; - }; - } else { - extensions.renderers[ext.name] = ext.renderer; - } - } - if (ext.tokenizer) { - // Tokenizer Extensions - if (!ext.level || ext.level !== 'block' && ext.level !== 'inline') { - throw new Error("extension level must be 'block' or 'inline'"); - } - if (extensions[ext.level]) { - extensions[ext.level].unshift(ext.tokenizer); - } else { - extensions[ext.level] = [ext.tokenizer]; - } - if (ext.start) { - // Function to check for start of token - if (ext.level === 'block') { - if (extensions.startBlock) { - extensions.startBlock.push(ext.start); - } else { - extensions.startBlock = [ext.start]; - } - } else if (ext.level === 'inline') { - if (extensions.startInline) { - extensions.startInline.push(ext.start); - } else { - extensions.startInline = [ext.start]; - } - } - } - } - if (ext.childTokens) { - // Child tokens to be visited by walkTokens - extensions.childTokens[ext.name] = ext.childTokens; - } - }); - opts.extensions = extensions; - } - - // ==-- Parse "overwrite" extensions --== // - if (pack.renderer) { - var renderer = marked.defaults.renderer || new Renderer(); - var _loop = function _loop(prop) { - var prevRenderer = renderer[prop]; - // Replace renderer with func to run extension, but fall back if false - renderer[prop] = function () { - for (var _len3 = arguments.length, args = new Array(_len3), _key3 = 0; _key3 < _len3; _key3++) { - args[_key3] = arguments[_key3]; - } - var ret = pack.renderer[prop].apply(renderer, args); - if (ret === false) { - ret = prevRenderer.apply(renderer, args); - } - return ret; - }; - }; - for (var prop in pack.renderer) { - _loop(prop); - } - opts.renderer = renderer; - } - if (pack.tokenizer) { - var tokenizer = marked.defaults.tokenizer || new Tokenizer(); - var _loop2 = function _loop2(_prop) { - var prevTokenizer = tokenizer[_prop]; - // Replace tokenizer with func to run extension, but fall back if false - tokenizer[_prop] = function () { - for (var _len4 = arguments.length, args = new Array(_len4), _key4 = 0; _key4 < _len4; _key4++) { - args[_key4] = arguments[_key4]; - } - var ret = pack.tokenizer[_prop].apply(tokenizer, args); - if (ret === false) { - ret = prevTokenizer.apply(tokenizer, args); - } - return ret; - }; - }; - for (var _prop in pack.tokenizer) { - _loop2(_prop); - } - opts.tokenizer = tokenizer; - } - - // ==-- Parse WalkTokens extensions --== // - if (pack.walkTokens) { - var _walkTokens = marked.defaults.walkTokens; - opts.walkTokens = function (token) { - var values = []; - values.push(pack.walkTokens.call(this, token)); - if (_walkTokens) { - values = values.concat(_walkTokens.call(this, token)); - } - return values; - }; - } - marked.setOptions(opts); - }); - }; - - /** - * Run callback for every token - */ - - marked.walkTokens = function (tokens, callback) { - var values = []; - var _loop3 = function _loop3() { - var token = _step.value; - values = values.concat(callback.call(marked, token)); - switch (token.type) { - case 'table': - { - for (var _iterator2 = _createForOfIteratorHelperLoose(token.header), _step2; !(_step2 = _iterator2()).done;) { - var cell = _step2.value; - values = values.concat(marked.walkTokens(cell.tokens, callback)); - } - for (var _iterator3 = _createForOfIteratorHelperLoose(token.rows), _step3; !(_step3 = _iterator3()).done;) { - var row = _step3.value; - for (var _iterator4 = _createForOfIteratorHelperLoose(row), _step4; !(_step4 = _iterator4()).done;) { - var _cell = _step4.value; - values = values.concat(marked.walkTokens(_cell.tokens, callback)); - } - } - break; - } - case 'list': - { - values = values.concat(marked.walkTokens(token.items, callback)); - break; - } - default: - { - if (marked.defaults.extensions && marked.defaults.extensions.childTokens && marked.defaults.extensions.childTokens[token.type]) { - // Walk any extensions - marked.defaults.extensions.childTokens[token.type].forEach(function (childTokens) { - values = values.concat(marked.walkTokens(token[childTokens], callback)); - }); - } else if (token.tokens) { - values = values.concat(marked.walkTokens(token.tokens, callback)); - } - } - } - }; - for (var _iterator = _createForOfIteratorHelperLoose(tokens), _step; !(_step = _iterator()).done;) { - _loop3(); - } - return values; - }; - - /** - * Parse Inline - * @param {string} src - */ - marked.parseInline = function (src, opt) { - // throw error in case of non string input - if (typeof src === 'undefined' || src === null) { - throw new Error('marked.parseInline(): input parameter is undefined or null'); - } - if (typeof src !== 'string') { - throw new Error('marked.parseInline(): input parameter is of type ' + Object.prototype.toString.call(src) + ', string expected'); - } - opt = merge({}, marked.defaults, opt || {}); - checkSanitizeDeprecation(opt); - try { - var tokens = Lexer.lexInline(src, opt); - if (opt.walkTokens) { - marked.walkTokens(tokens, opt.walkTokens); - } - return Parser.parseInline(tokens, opt); - } catch (e) { - e.message += '\nPlease report this to https://github.com/markedjs/marked.'; - if (opt.silent) { - return '

    An error occurred:

    ' + escape(e.message + '', true) + '
    '; - } - throw e; - } - }; - - /** - * Expose - */ - marked.Parser = Parser; - marked.parser = Parser.parse; - marked.Renderer = Renderer; - marked.TextRenderer = TextRenderer; - marked.Lexer = Lexer; - marked.lexer = Lexer.lex; - marked.Tokenizer = Tokenizer; - marked.Slugger = Slugger; - marked.parse = marked; - var options = marked.options; - var setOptions = marked.setOptions; - var use = marked.use; - var walkTokens = marked.walkTokens; - var parseInline = marked.parseInline; - var parse = marked; - var parser = Parser.parse; - var lexer = Lexer.lex; - - exports.Lexer = Lexer; - exports.Parser = Parser; - exports.Renderer = Renderer; - exports.Slugger = Slugger; - exports.TextRenderer = TextRenderer; - exports.Tokenizer = Tokenizer; - exports.getDefaults = getDefaults; - exports.lexer = lexer; - exports.marked = marked; - exports.options = options; - exports.parse = parse; - exports.parseInline = parseInline; - exports.parser = parser; - exports.setOptions = setOptions; - exports.use = use; - exports.walkTokens = walkTokens; - -})); diff --git a/release ROM b/release ROM deleted file mode 100644 index 00f3dba..0000000 --- a/release ROM +++ /dev/null @@ -1,691 +0,0 @@ -{ - "url": "https://api.github.com/repos/lixiaoliu666/intel6-14rom/releases/226870174", - "assets_url": "https://api.github.com/repos/lixiaoliu666/intel6-14rom/releases/226870174/assets", - "upload_url": "https://uploads.github.com/repos/lixiaoliu666/intel6-14rom/releases/226870174/assets{?name,label}", - "html_url": "https://github.com/lixiaoliu666/intel6-14rom/releases/tag/v2.0-20250622-100999", - "id": 226870174, - "author": { - "login": "github-actions[bot]", - "id": 41898282, - "node_id": "MDM6Qm90NDE4OTgyODI=", - "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/github-actions%5Bbot%5D", - "html_url": "https://github.com/apps/github-actions", - "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", - "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", - "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", - "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", - "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", - "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", - "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", - "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", - "type": "Bot", - "user_view_type": "public", - "site_admin": false - }, - "node_id": "RE_kwDONZWCFM4NhcOe", - "tag_name": "v2.0-20250622-100999", - "target_commitish": "839cc6f71daa31a64e86d9f19ade100bc7cb1cd7", - "name": "v2.0 build-20250622-100999", - "draft": false, - "immutable": false, - "prerelease": false, - "created_at": "2025-06-22T02:05:22Z", - "updated_at": "2025-06-22T02:09:33Z", - "published_at": "2025-06-22T02:09:26Z", - "assets": [ - { - "url": "https://api.github.com/repos/lixiaoliu666/intel6-14rom/releases/assets/266130624", - "id": 266130624, - "node_id": "RA_kwDONZWCFM4P3NTA", - "name": "11-J6412-q10.rom", - "label": "", - "uploader": { - "login": "github-actions[bot]", - "id": 41898282, - "node_id": "MDM6Qm90NDE4OTgyODI=", - "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/github-actions%5Bbot%5D", - "html_url": "https://github.com/apps/github-actions", - "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", - "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", - "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", - "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", - "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", - "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", - "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", - "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", - "type": "Bot", - "user_view_type": "public", - "site_admin": false - }, - "content_type": "application/octet-stream", - "state": "uploaded", - "size": 143360, - "digest": "sha256:259355031d3ad00d5100a376381bde76b3f64399a7b6df0d9f2a3539859e3036", - "download_count": 33, - "created_at": "2025-06-22T02:09:26Z", - "updated_at": "2025-06-22T02:09:27Z", - "browser_download_url": "https://github.com/lixiaoliu666/intel6-14rom/releases/download/v2.0-20250622-100999/11-J6412-q10.rom" - }, - { - "url": "https://api.github.com/repos/lixiaoliu666/intel6-14rom/releases/assets/266130626", - "id": 266130626, - "node_id": "RA_kwDONZWCFM4P3NTC", - "name": "11-n5095-q10.rom", - "label": "", - "uploader": { - "login": "github-actions[bot]", - "id": 41898282, - "node_id": "MDM6Qm90NDE4OTgyODI=", - "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/github-actions%5Bbot%5D", - "html_url": "https://github.com/apps/github-actions", - "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", - "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", - "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", - "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", - "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", - "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", - "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", - "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", - "type": "Bot", - "user_view_type": "public", - "site_admin": false - }, - "content_type": "application/octet-stream", - "state": "uploaded", - "size": 144896, - "digest": "sha256:b16781d77b1f34b2cda032eb0aa6790776ec1519cd503ba23b7b547deb999682", - "download_count": 51, - "created_at": "2025-06-22T02:09:27Z", - "updated_at": "2025-06-22T02:09:27Z", - "browser_download_url": "https://github.com/lixiaoliu666/intel6-14rom/releases/download/v2.0-20250622-100999/11-n5095-q10.rom" - }, - { - "url": "https://api.github.com/repos/lixiaoliu666/intel6-14rom/releases/assets/266130632", - "id": 266130632, - "node_id": "RA_kwDONZWCFM4P3NTI", - "name": "12-1240p-q10.rom", - "label": "", - "uploader": { - "login": "github-actions[bot]", - "id": 41898282, - "node_id": "MDM6Qm90NDE4OTgyODI=", - "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/github-actions%5Bbot%5D", - "html_url": "https://github.com/apps/github-actions", - "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", - "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", - "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", - "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", - "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", - "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", - "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", - "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", - "type": "Bot", - "user_view_type": "public", - "site_admin": false - }, - "content_type": "application/octet-stream", - "state": "uploaded", - "size": 182784, - "digest": "sha256:1a46e0b55bc241607a93652c3d8de48fc7ff805a00ee906b8bd8d7062449bbd3", - "download_count": 65, - "created_at": "2025-06-22T02:09:27Z", - "updated_at": "2025-06-22T02:09:27Z", - "browser_download_url": "https://github.com/lixiaoliu666/intel6-14rom/releases/download/v2.0-20250622-100999/12-1240p-q10.rom" - }, - { - "url": "https://api.github.com/repos/lixiaoliu666/intel6-14rom/releases/assets/266130633", - "id": 266130633, - "node_id": "RA_kwDONZWCFM4P3NTJ", - "name": "12-n100-q10.rom", - "label": "", - "uploader": { - "login": "github-actions[bot]", - "id": 41898282, - "node_id": "MDM6Qm90NDE4OTgyODI=", - "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/github-actions%5Bbot%5D", - "html_url": "https://github.com/apps/github-actions", - "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", - "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", - "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", - "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", - "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", - "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", - "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", - "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", - "type": "Bot", - "user_view_type": "public", - "site_admin": false - }, - "content_type": "application/octet-stream", - "state": "uploaded", - "size": 221184, - "digest": "sha256:54a04b0e56d35b23515751893d8e94d4336eb5ba29d1011e84bf288d3f71769d", - "download_count": 974, - "created_at": "2025-06-22T02:09:27Z", - "updated_at": "2025-06-22T02:09:28Z", - "browser_download_url": "https://github.com/lixiaoliu666/intel6-14rom/releases/download/v2.0-20250622-100999/12-n100-q10.rom" - }, - { - "url": "https://api.github.com/repos/lixiaoliu666/intel6-14rom/releases/assets/266130635", - "id": 266130635, - "node_id": "RA_kwDONZWCFM4P3NTL", - "name": "6-14-qemu10.rom", - "label": "", - "uploader": { - "login": "github-actions[bot]", - "id": 41898282, - "node_id": "MDM6Qm90NDE4OTgyODI=", - "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/github-actions%5Bbot%5D", - "html_url": "https://github.com/apps/github-actions", - "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", - "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", - "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", - "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", - "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", - "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", - "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", - "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", - "type": "Bot", - "user_view_type": "public", - "site_admin": false - }, - "content_type": "application/octet-stream", - "state": "uploaded", - "size": 2014720, - "digest": "sha256:2ac4874197d3d2d242e739d9587baffbdd44d238c280ef15cfc8425a3c552a8a", - "download_count": 1893, - "created_at": "2025-06-22T02:09:28Z", - "updated_at": "2025-06-22T02:09:28Z", - "browser_download_url": "https://github.com/lixiaoliu666/intel6-14rom/releases/download/v2.0-20250622-100999/6-14-qemu10.rom" - }, - { - "url": "https://api.github.com/repos/lixiaoliu666/intel6-14rom/releases/assets/266130644", - "id": 266130644, - "node_id": "RA_kwDONZWCFM4P3NTU", - "name": "edk2-autoGenPatch.patch", - "label": "", - "uploader": { - "login": "github-actions[bot]", - "id": 41898282, - "node_id": "MDM6Qm90NDE4OTgyODI=", - "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/github-actions%5Bbot%5D", - "html_url": "https://github.com/apps/github-actions", - "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", - "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", - "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", - "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", - "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", - "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", - "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", - "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", - "type": "Bot", - "user_view_type": "public", - "site_admin": false - }, - "content_type": "application/octet-stream", - "state": "uploaded", - "size": 627, - "digest": "sha256:63e1dabd376772cea4b33900762b50ad7f0d51dd4e770da1f76b49a508ff7790", - "download_count": 36, - "created_at": "2025-06-22T02:09:29Z", - "updated_at": "2025-06-22T02:09:29Z", - "browser_download_url": "https://github.com/lixiaoliu666/intel6-14rom/releases/download/v2.0-20250622-100999/edk2-autoGenPatch.patch" - }, - { - "url": "https://api.github.com/repos/lixiaoliu666/intel6-14rom/releases/assets/266130646", - "id": 266130646, - "node_id": "RA_kwDONZWCFM4P3NTW", - "name": "j4125-q10.rom", - "label": "", - "uploader": { - "login": "github-actions[bot]", - "id": 41898282, - "node_id": "MDM6Qm90NDE4OTgyODI=", - "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/github-actions%5Bbot%5D", - "html_url": "https://github.com/apps/github-actions", - "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", - "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", - "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", - "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", - "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", - "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", - "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", - "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", - "type": "Bot", - "user_view_type": "public", - "site_admin": false - }, - "content_type": "application/octet-stream", - "state": "uploaded", - "size": 120320, - "digest": "sha256:7f556ecf4b4a4d86c55a3ca0c0c4dd402ab4f0328f62eef0c190d75a479b7c4f", - "download_count": 70, - "created_at": "2025-06-22T02:09:29Z", - "updated_at": "2025-06-22T02:09:29Z", - "browser_download_url": "https://github.com/lixiaoliu666/intel6-14rom/releases/download/v2.0-20250622-100999/j4125-q10.rom" - }, - { - "url": "https://api.github.com/repos/lixiaoliu666/intel6-14rom/releases/assets/266130638", - "id": 266130638, - "node_id": "RA_kwDONZWCFM4P3NTO", - "name": "N2930-q10.rom", - "label": "", - "uploader": { - "login": "github-actions[bot]", - "id": 41898282, - "node_id": "MDM6Qm90NDE4OTgyODI=", - "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/github-actions%5Bbot%5D", - "html_url": "https://github.com/apps/github-actions", - "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", - "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", - "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", - "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", - "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", - "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", - "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", - "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", - "type": "Bot", - "user_view_type": "public", - "site_admin": false - }, - "content_type": "application/octet-stream", - "state": "uploaded", - "size": 96256, - "digest": "sha256:87e542ee9b20fd49d620e6ce61ad455f2d57347d2fe05eea16ba0b26d4cc2ab1", - "download_count": 25, - "created_at": "2025-06-22T02:09:28Z", - "updated_at": "2025-06-22T02:09:28Z", - "browser_download_url": "https://github.com/lixiaoliu666/intel6-14rom/releases/download/v2.0-20250622-100999/N2930-q10.rom" - }, - { - "url": "https://api.github.com/repos/lixiaoliu666/intel6-14rom/releases/assets/266130640", - "id": 266130640, - "node_id": "RA_kwDONZWCFM4P3NTQ", - "name": "N3350-q10.rom", - "label": "", - "uploader": { - "login": "github-actions[bot]", - "id": 41898282, - "node_id": "MDM6Qm90NDE4OTgyODI=", - "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/github-actions%5Bbot%5D", - "html_url": "https://github.com/apps/github-actions", - "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", - "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", - "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", - "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", - "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", - "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", - "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", - "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", - "type": "Bot", - "user_view_type": "public", - "site_admin": false - }, - "content_type": "application/octet-stream", - "state": "uploaded", - "size": 109568, - "digest": "sha256:cf7956cb369b193275bd4741279ef12e1fb41ed46620c30ee084961b49d1b784", - "download_count": 28, - "created_at": "2025-06-22T02:09:29Z", - "updated_at": "2025-06-22T02:09:29Z", - "browser_download_url": "https://github.com/lixiaoliu666/intel6-14rom/releases/download/v2.0-20250622-100999/N3350-q10.rom" - }, - { - "url": "https://api.github.com/repos/lixiaoliu666/intel6-14rom/releases/assets/266130650", - "id": 266130650, - "node_id": "RA_kwDONZWCFM4P3NTa", - "name": "nb-11-11700h-q10.rom", - "label": "", - "uploader": { - "login": "github-actions[bot]", - "id": 41898282, - "node_id": "MDM6Qm90NDE4OTgyODI=", - "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/github-actions%5Bbot%5D", - "html_url": "https://github.com/apps/github-actions", - "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", - "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", - "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", - "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", - "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", - "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", - "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", - "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", - "type": "Bot", - "user_view_type": "public", - "site_admin": false - }, - "content_type": "application/octet-stream", - "state": "uploaded", - "size": 190976, - "digest": "sha256:de42ff52d3de24c9cb07be84bbe19f464d45b3a44a1a77040cad97653d22ca6a", - "download_count": 23, - "created_at": "2025-06-22T02:09:30Z", - "updated_at": "2025-06-22T02:09:30Z", - "browser_download_url": "https://github.com/lixiaoliu666/intel6-14rom/releases/download/v2.0-20250622-100999/nb-11-11700h-q10.rom" - }, - { - "url": "https://api.github.com/repos/lixiaoliu666/intel6-14rom/releases/assets/266130651", - "id": 266130651, - "node_id": "RA_kwDONZWCFM4P3NTb", - "name": "nb-11-1185G7E-q10.rom", - "label": "", - "uploader": { - "login": "github-actions[bot]", - "id": 41898282, - "node_id": "MDM6Qm90NDE4OTgyODI=", - "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/github-actions%5Bbot%5D", - "html_url": "https://github.com/apps/github-actions", - "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", - "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", - "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", - "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", - "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", - "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", - "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", - "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", - "type": "Bot", - "user_view_type": "public", - "site_admin": false - }, - "content_type": "application/octet-stream", - "state": "uploaded", - "size": 192000, - "digest": "sha256:30d250c1de6cab611a59339bc4259a534e6c7d1802b72f063de09cee3374a288", - "download_count": 21, - "created_at": "2025-06-22T02:09:30Z", - "updated_at": "2025-06-22T02:09:30Z", - "browser_download_url": "https://github.com/lixiaoliu666/intel6-14rom/releases/download/v2.0-20250622-100999/nb-11-1185G7E-q10.rom" - }, - { - "url": "https://api.github.com/repos/lixiaoliu666/intel6-14rom/releases/assets/266130653", - "id": 266130653, - "node_id": "RA_kwDONZWCFM4P3NTd", - "name": "nb-12-12700h-q10.rom", - "label": "", - "uploader": { - "login": "github-actions[bot]", - "id": 41898282, - "node_id": "MDM6Qm90NDE4OTgyODI=", - "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/github-actions%5Bbot%5D", - "html_url": "https://github.com/apps/github-actions", - "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", - "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", - "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", - "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", - "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", - "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", - "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", - "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", - "type": "Bot", - "user_view_type": "public", - "site_admin": false - }, - "content_type": "application/octet-stream", - "state": "uploaded", - "size": 224768, - "digest": "sha256:559f5412be52f6cb7dcb3fe9021c6700ee84c6881aa5421c2b39994479e548d7", - "download_count": 41, - "created_at": "2025-06-22T02:09:30Z", - "updated_at": "2025-06-22T02:09:31Z", - "browser_download_url": "https://github.com/lixiaoliu666/intel6-14rom/releases/download/v2.0-20250622-100999/nb-12-12700h-q10.rom" - }, - { - "url": "https://api.github.com/repos/lixiaoliu666/intel6-14rom/releases/assets/266130655", - "id": 266130655, - "node_id": "RA_kwDONZWCFM4P3NTf", - "name": "nb-13-13700h-q10.rom", - "label": "", - "uploader": { - "login": "github-actions[bot]", - "id": 41898282, - "node_id": "MDM6Qm90NDE4OTgyODI=", - "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/github-actions%5Bbot%5D", - "html_url": "https://github.com/apps/github-actions", - "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", - "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", - "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", - "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", - "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", - "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", - "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", - "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", - "type": "Bot", - "user_view_type": "public", - "site_admin": false - }, - "content_type": "application/octet-stream", - "state": "uploaded", - "size": 188416, - "digest": "sha256:dcc6e1a6e0137a5a4179f7298810f6b97fd85c2b912e19d9905b0f152f0325fd", - "download_count": 47, - "created_at": "2025-06-22T02:09:31Z", - "updated_at": "2025-06-22T02:09:31Z", - "browser_download_url": "https://github.com/lixiaoliu666/intel6-14rom/releases/download/v2.0-20250622-100999/nb-13-13700h-q10.rom" - }, - { - "url": "https://api.github.com/repos/lixiaoliu666/intel6-14rom/releases/assets/266130658", - "id": 266130658, - "node_id": "RA_kwDONZWCFM4P3NTi", - "name": "pc-10-q10.rom", - "label": "", - "uploader": { - "login": "github-actions[bot]", - "id": 41898282, - "node_id": "MDM6Qm90NDE4OTgyODI=", - "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/github-actions%5Bbot%5D", - "html_url": "https://github.com/apps/github-actions", - "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", - "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", - "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", - "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", - "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", - "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", - "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", - "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", - "type": "Bot", - "user_view_type": "public", - "site_admin": false - }, - "content_type": "application/octet-stream", - "state": "uploaded", - "size": 104960, - "digest": "sha256:852c5b7c268c56aebdfc0c55dfcb6115f7a5c51f4fa2ddfb045a383d327c81ed", - "download_count": 39, - "created_at": "2025-06-22T02:09:31Z", - "updated_at": "2025-06-22T02:09:31Z", - "browser_download_url": "https://github.com/lixiaoliu666/intel6-14rom/releases/download/v2.0-20250622-100999/pc-10-q10.rom" - }, - { - "url": "https://api.github.com/repos/lixiaoliu666/intel6-14rom/releases/assets/266130666", - "id": 266130666, - "node_id": "RA_kwDONZWCFM4P3NTq", - "name": "pc-11-q10.rom", - "label": "", - "uploader": { - "login": "github-actions[bot]", - "id": 41898282, - "node_id": "MDM6Qm90NDE4OTgyODI=", - "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/github-actions%5Bbot%5D", - "html_url": "https://github.com/apps/github-actions", - "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", - "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", - "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", - "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", - "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", - "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", - "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", - "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", - "type": "Bot", - "user_view_type": "public", - "site_admin": false - }, - "content_type": "application/octet-stream", - "state": "uploaded", - "size": 183296, - "digest": "sha256:34e8af911f2dcdfd75b945fd16f32f1557bd5f2ee10693b5c35a6560dc3761a0", - "download_count": 34, - "created_at": "2025-06-22T02:09:32Z", - "updated_at": "2025-06-22T02:09:32Z", - "browser_download_url": "https://github.com/lixiaoliu666/intel6-14rom/releases/download/v2.0-20250622-100999/pc-11-q10.rom" - }, - { - "url": "https://api.github.com/repos/lixiaoliu666/intel6-14rom/releases/assets/266130668", - "id": 266130668, - "node_id": "RA_kwDONZWCFM4P3NTs", - "name": "pc-12-13-14-q10.rom", - "label": "", - "uploader": { - "login": "github-actions[bot]", - "id": 41898282, - "node_id": "MDM6Qm90NDE4OTgyODI=", - "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/github-actions%5Bbot%5D", - "html_url": "https://github.com/apps/github-actions", - "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", - "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", - "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", - "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", - "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", - "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", - "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", - "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", - "type": "Bot", - "user_view_type": "public", - "site_admin": false - }, - "content_type": "application/octet-stream", - "state": "uploaded", - "size": 193536, - "digest": "sha256:b73c83330a80a18e1780e64e964f1e7b477904316f5362667c648737f40da92f", - "download_count": 164, - "created_at": "2025-06-22T02:09:32Z", - "updated_at": "2025-06-22T02:09:32Z", - "browser_download_url": "https://github.com/lixiaoliu666/intel6-14rom/releases/download/v2.0-20250622-100999/pc-12-13-14-q10.rom" - }, - { - "url": "https://api.github.com/repos/lixiaoliu666/intel6-14rom/releases/assets/266130670", - "id": 266130670, - "node_id": "RA_kwDONZWCFM4P3NTu", - "name": "pc-4-5-q10.rom", - "label": "", - "uploader": { - "login": "github-actions[bot]", - "id": 41898282, - "node_id": "MDM6Qm90NDE4OTgyODI=", - "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/github-actions%5Bbot%5D", - "html_url": "https://github.com/apps/github-actions", - "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", - "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", - "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", - "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", - "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", - "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", - "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", - "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", - "type": "Bot", - "user_view_type": "public", - "site_admin": false - }, - "content_type": "application/octet-stream", - "state": "uploaded", - "size": 77312, - "digest": "sha256:e705307a443c2bd8fe308d445e6b66aa16b6229a266daddbf5f14454413325ee", - "download_count": 35, - "created_at": "2025-06-22T02:09:32Z", - "updated_at": "2025-06-22T02:09:32Z", - "browser_download_url": "https://github.com/lixiaoliu666/intel6-14rom/releases/download/v2.0-20250622-100999/pc-4-5-q10.rom" - }, - { - "url": "https://api.github.com/repos/lixiaoliu666/intel6-14rom/releases/assets/266130672", - "id": 266130672, - "node_id": "RA_kwDONZWCFM4P3NTw", - "name": "pc-6-7-8-9-q10.rom", - "label": "", - "uploader": { - "login": "github-actions[bot]", - "id": 41898282, - "node_id": "MDM6Qm90NDE4OTgyODI=", - "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/github-actions%5Bbot%5D", - "html_url": "https://github.com/apps/github-actions", - "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", - "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", - "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", - "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", - "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", - "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", - "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", - "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", - "type": "Bot", - "user_view_type": "public", - "site_admin": false - }, - "content_type": "application/octet-stream", - "state": "uploaded", - "size": 101888, - "digest": "sha256:84b15dfd1ca7ad731c041efa87a5ae5a42fb260bf3e114f2e69c60e94ef308c3", - "download_count": 119, - "created_at": "2025-06-22T02:09:32Z", - "updated_at": "2025-06-22T02:09:33Z", - "browser_download_url": "https://github.com/lixiaoliu666/intel6-14rom/releases/download/v2.0-20250622-100999/pc-6-7-8-9-q10.rom" - } - ], - "tarball_url": "https://api.github.com/repos/lixiaoliu666/intel6-14rom/tarball/v2.0-20250622-100999", - "zipball_url": "https://api.github.com/repos/lixiaoliu666/intel6-14rom/zipball/v2.0-20250622-100999", - "body": "build 6-14 all roms 只适用于qemu10及以上版本(不支持qemu7 8 9)ovmf+i440fx 10核显直通请看本项目说明,ovmf+q35 10请阅读此教程 https://www.bilibili.com/read/cv41702099" -} \ No newline at end of file diff --git a/src/CLAUDE.md b/src/CLAUDE.md index 742e3a4..953c366 100644 --- a/src/CLAUDE.md +++ b/src/CLAUDE.md @@ -11,7 +11,7 @@ | 项目 | 说明 | |---|---| | 加载方式 | `find src/modules -name '*.sh' -print0 | sort -z` 递归加载 | -| 加载顺序 | 按文件路径名排序(`sort -z`),同一子目录内 `init.sh` 通常先加载 | +| 加载顺序 | build.sh/dev.sh 按文件路径名排序(`sort -z`,无 init.sh 特判);PVE-Tools.sh 本地模式显式先 source 各目录 init.sh。bash 函数定义顺序不影响调用(模块文件只定义函数) | | 构建时 | `build.sh` 按同样顺序拼接到 `dist/PVE-Tools.sh` | ## 子模块概览 @@ -26,8 +26,8 @@ | 6 | `06-networking/` | 宿主机网络与防火墙 | 10 | **高** | | 7 | `07-storage-disk/` | 存储与磁盘维护 | 6 | 中等 | | 8 | `08-tools-about/` | 诊断工具与项目信息 | 3 | 低 | -| 9 | `09-security/` | 安全中心 | 3 | 中等 | -| 10 | `10-third-party/` | 第三方工具 | 4 | 低 | +| 9 | `09-security/` | 安全中心 | 4 | 中等 | +| 10 | `10-third-party/` | 第三方工具 | 5 | 低 | --- @@ -37,7 +37,7 @@ | 文件 | 核心函数 | 功能 | |---|---|---| -| `init.sh` | `menu_optimization()` | 二级菜单:弹窗/优化/温度/电源/邮件 | +| `init.sh` | `menu_optimization()` | 二级菜单:弹窗/优化/温度/电源/邮件/UPS 诊断 | | `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 备份管理 | @@ -85,11 +85,11 @@ | 文件 | 核心函数 | 功能 | |---|---|---| | `init.sh` | `menu_gpu_passthrough()`, `menu_disk_controller_passthrough()` | 主菜单 + 磁盘/控制器子菜单 | -| `iommu.sh` | `iommu_is_enabled()`, `enable_pass()`, `disable_pass()`, `hw_passth()`, `list_storage_controllers()` | IOMMU 基础设施与硬件直通一键配置 | +| `iommu.sh` | `iommu_is_enabled()`, `gpu_detect_active_stacks()`, `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 复用) | +| `igpu-shared.sh` | `igpu_management_menu()`, `igpu_verify()`, `igpu_remove()` | iGPU 共享管理(igpu_remove 为唯一清理路径,被 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) | @@ -110,13 +110,14 @@ | 文件 | 核心函数 | 功能 | |---|---|---| -| `init.sh` | `menu_vm_container()`, `vm_advanced_operations_menu()` | 主菜单 + 高级运维子菜单 | +| `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 恢复与配置导入导出 | +| `backup.sh` | `vm_backup_create()`, `vm_schedule_backup_menu()` | VM 备份与定时备份 | +| `restore.sh` | `vm_backup_restore_menu()`, `vm_restore_from_backup()` | VM 备份恢复菜单与恢复流程 | +| `config-io.sh` | `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()` | 快照管理(创建/删除/回滚) | @@ -191,6 +192,7 @@ | `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) | +| `cve-fix.sh` | `security_cve_menu()`, `security_cve_batch_mitigate()`, `security_cve_lpe_kernel_fix()` | CVE 漏洞修补中心(Januscape/DirtyFrag/CopyFail) | **依赖**: 仅依赖 `lib/`。 @@ -206,6 +208,7 @@ | `marketplace.sh` | `third_party_market_menu()` | 第三方软件市场(Modules 插件) | | `coolercontrol.sh` | `coolercontrol_manager_menu()`, `coolercontrol_install()`, `coolercontrol_uninstall()` | CoolerControl 风扇控制管理 | | `community.sh` | `third_party_community_scripts_info()` | 社区脚本集合信息 | +| `it87-driver.sh` | `it87_manager_menu()`, `it87_force_id_menu()` | IT87/IT87XX 传感器驱动管理(DKMS,PGP 签名提交锁定) | **依赖**: 仅依赖 `lib/`。 @@ -223,7 +226,7 @@ ## 测试与质量 - **语法检查**: `bash -n` 对每个 `src/modules/**/*.sh` 文件 -- **静态分析**: `shellcheck` 覆盖所有模块文件(CI 中强制通过) +- **静态分析**: CI 对全部模块文件执行 `shellcheck --severity=error`(error 级强制通过) - **构建验证**: `bash build.sh && bash -n dist/PVE-Tools.sh` 验证拼接正确性 - **功能测试**: 人工在 PVE 9.x 环境验证 `bash dev.sh` @@ -231,7 +234,7 @@ **Q: 新增功能模块如何操作?** 1. 在 `src/modules/` 下创建新目录(如 `11-new-feature/`) -2. 编写 `init.sh`(菜单入口函数)和功能文件 +2. 编写 `init.sh`:菜单入口用 `run_menu` 框架(`<菜单名>` + `_render` + `_dispatch` 三函数,规范见根 CLAUDE.md「交互层规范」),功能实现放独立文件 3. 在 `lib/runtime.sh` 的 `show_menu()` 和 `main()` 的 case 中添加对应选项 4. `build.sh` 会自动扫描并包含新文件 @@ -239,7 +242,7 @@ `init.sh` 仅包含菜单入口函数,功能实现放在独立文件中。这确保了单个文件不至于过大,也便于独立维护和测试各功能模块。 **Q: 跨文件函数调用如何处理 source 顺序?** -不需要显式 source。所有模块文件在入口处一次性全部 source,且 `sort -z` 保证 `init.sh` 优先加载,因此同模块内的交叉引用天然可用。 +不需要显式 source。所有模块文件在入口处一次性全部加载;bash 中函数定义顺序与调用无关(模块文件只定义函数,执行入口是末尾的 `main "$@"`),因此交叉引用天然可用。不要编写"顶层立即执行且依赖其他模块函数"的代码。 ## 相关文件清单 @@ -261,4 +264,5 @@ src/modules/ | 日期 | 变更 | |---|---| +| 2026-07-26 | 全部菜单迁移至 lib/menu.sh 框架;快照/备份提升为 05 直达项;UPS 诊断提升为 01 顶级项;CVE 菜单编号连续化;删除 igpu 死代码;文件数与函数索引按现实修正 | | 2026-07-08 | 初始化 src/modules 模块 CLAUDE.md(模块化重构新增模块) | diff --git a/src/modules/01-optimization/cpupower.sh b/src/modules/01-optimization/cpupower.sh index 3dcd015..890d9d3 100644 --- a/src/modules/01-optimization/cpupower.sh +++ b/src/modules/01-optimization/cpupower.sh @@ -77,6 +77,10 @@ cpupower_add() { echo "查看当前CPU模式" cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor + if ! confirm_action "写入 @reboot 计划任务(开机自动应用 CPU 模式 ${GOVERNOR})"; then + log_info "已跳过开机任务写入,本次修改仅当前开机周期生效" + return 0 + fi echo "正在添加开机任务" NEW_CRONTAB_COMMAND="sleep 10 && echo "${GOVERNOR}" | tee /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor >/dev/null #CPU Power Mode" EXISTING_CRONTAB=$(crontab -l 2>/dev/null) diff --git a/src/modules/01-optimization/init.sh b/src/modules/01-optimization/init.sh index e12813d..8603c32 100644 --- a/src/modules/01-optimization/init.sh +++ b/src/modules/01-optimization/init.sh @@ -3,31 +3,30 @@ # Copyright (C) 2026 Ciriu Networks menu_optimization() { - while true; do - clear - echo "功能 1/2 请在外部SSH环境下使用该功能!否则会导致PVE WebUi重启导致Shell断开连接修改失效!" - echo "不要犟!查看如何连接到PVE SSH教程:https://pve.u3u.icu/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 + run_menu "系统优化" menu_optimization_render menu_optimization_dispatch "0-6" } -# 二级菜单:软件源与更新 +menu_optimization_render() { + echo -e " ${YELLOW}功能 1/2 请在外部 SSH 环境下使用!否则 PVE WebUI 重启会导致 Shell 断连、修改失效。${NC}" + echo -e " ${YELLOW}SSH 连接教程: https://pve.u3u.icu/advanced/how-to-connect-ssh.html${NC}" + echo "$UI_DIVIDER" + 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}" + show_menu_option "6" "UPS 电源诊断 ${CYAN}(NUT / upsc)${NC}" +} + +menu_optimization_dispatch() { + case "$1" in + 1) remove_subscription_popup ;; + 2) quick_setup ;; + 3) temp_monitoring_menu ;; + 4) cpupower ;; + 5) pve_mail_notification_setup ;; + 6) show_ups_diagnostics ;; + *) return 1 ;; + esac + return 0 +} diff --git a/src/modules/01-optimization/temperature.sh b/src/modules/01-optimization/temperature.sh index deaa71b..58894e6 100644 --- a/src/modules/01-optimization/temperature.sh +++ b/src/modules/01-optimization/temperature.sh @@ -3,40 +3,21 @@ # 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 + run_menu "温度监控管理" temp_monitoring_menu_render temp_monitoring_menu_dispatch "0-2" +} + +temp_monitoring_menu_render() { + show_menu_option "1" "配置温度监控 ${CYAN}(CPU/硬盘温度显示)${NC}" + show_menu_option "2" "${RED}移除温度监控${NC} (移除温度监控功能)" +} + +temp_monitoring_menu_dispatch() { + case "$1" in + 1) cpu_add ;; + 2) cpu_del ;; + *) return 1 ;; + esac + return 0 } # 自定义温度监控配置 diff --git a/src/modules/02-sources/init.sh b/src/modules/02-sources/init.sh index 233afb9..5619665 100644 --- a/src/modules/02-sources/init.sh +++ b/src/modules/02-sources/init.sh @@ -3,25 +3,21 @@ # 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 + run_menu "软件源与更新" menu_sources_updates_render menu_sources_updates_dispatch "0-3" } -# 二级菜单:启动与内核 +menu_sources_updates_render() { + show_menu_option "1" "更换软件源" + show_menu_option "2" "更新系统软件包" + show_menu_option "3" "${YELLOW}PVE 8.x 升级到 PVE 9.x${NC}" +} + +menu_sources_updates_dispatch() { + case "$1" in + 1) change_sources ;; + 2) update_system ;; + 3) pve8_to_pve9_upgrade ;; + *) return 1 ;; + esac + return 0 +} diff --git a/src/modules/02-sources/mirrors.sh b/src/modules/02-sources/mirrors.sh index 67d9443..3ab6acb 100644 --- a/src/modules/02-sources/mirrors.sh +++ b/src/modules/02-sources/mirrors.sh @@ -28,7 +28,11 @@ change_sources() { log_info " PVE: $pve_mirror" log_info " Ceph: $ceph_mirror" log_info " CT 模板: $ct_mirror" - + + if ! confirm_action "按以上配置重写 APT 源文件(debian/ceph/pve-no-subscription 等)"; then + return 0 + fi + # 1. 更换 Debian 软件源 (DEB822 格式) log_info "正在配置 Debian 镜像源..." backup_file "/etc/apt/sources.list.d/debian.sources" diff --git a/src/modules/03-boot-kernel/init.sh b/src/modules/03-boot-kernel/init.sh index cd3498e..93f5a4e 100644 --- a/src/modules/03-boot-kernel/init.sh +++ b/src/modules/03-boot-kernel/init.sh @@ -3,48 +3,48 @@ # 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 + run_menu "启动与内核" menu_boot_kernel_render menu_boot_kernel_dispatch "0-2" } -# 二级菜单:直通与显卡 +menu_boot_kernel_render() { + show_menu_option "1" "内核管理 ${CYAN}(内核切换/更新/清理)${NC}" + show_menu_option "2" "查看/备份 GRUB 配置" +} + +menu_boot_kernel_dispatch() { + case "$1" in + 1) kernel_management_menu ;; + 2) grub_config_menu ;; + *) return 1 ;; + esac + return 0 +} + +# GRUB 配置管理子菜单(查看/备份/恢复,底层函数在 01-optimization/cpupower.sh) +grub_config_menu() { + run_menu "GRUB 配置管理" grub_config_menu_render grub_config_menu_dispatch "0-4" +} + +grub_config_menu_render() { + show_menu_option "1" "查看当前 GRUB 配置" + show_menu_option "2" "备份 GRUB 配置" + show_menu_option "3" "查看备份列表" + show_menu_option "4" "恢复 GRUB 备份" +} + +grub_config_menu_dispatch() { + case "$1" in + 1) show_grub_config ;; + 2) grub_backup_with_note_prompt ;; + 3) list_grub_backups ;; + 4) restore_grub_backup ;; + *) return 1 ;; + esac + return 0 +} + +grub_backup_with_note_prompt() { + local note="" + prompt_value note "请输入备份备注" "手动备份" || return 0 + backup_grub_with_note "$note" +} diff --git a/src/modules/03-boot-kernel/kernel.sh b/src/modules/03-boot-kernel/kernel.sh index aeb93fc..b24ecbd 100644 --- a/src/modules/03-boot-kernel/kernel.sh +++ b/src/modules/03-boot-kernel/kernel.sh @@ -236,8 +236,18 @@ set_default_kernel() { return 1 fi - # 使用 grub-set-default 设置默认内核 - if command -v grub-set-default &> /dev/null; then + # 首选 proxmox-boot-tool kernel pin:PVE 官方机制,GRUB 与 systemd-boot(UEFI/ZFS)环境均适用 + if command -v proxmox-boot-tool >/dev/null 2>&1; then + if proxmox-boot-tool kernel pin "$kernel_version"; then + log_success "默认启动内核已通过 proxmox-boot-tool 固定 (pin)" + log_tips "如需恢复自动选择最新内核,可执行: proxmox-boot-tool kernel unpin" + return 0 + fi + log_warn "proxmox-boot-tool kernel pin 执行失败,尝试 GRUB 备用方法" + fi + + # 备用:grub-set-default(仅适用于直接由 GRUB 引导且存在 grub.cfg 的环境) + if command -v grub-set-default &> /dev/null && [[ -f /boot/grub/grub.cfg ]]; then # 查找内核在 GRUB 菜单中的位置 local menu_entry=$(grep -n "$kernel_version" /boot/grub/grub.cfg | head -1 | cut -d: -f1) if [[ -n "$menu_entry" ]]; then @@ -317,76 +327,63 @@ remove_old_kernels() { # 内核管理主菜单 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) - if confirm_high_risk_action \ - "重启宿主机" \ - "将立即重启当前 Proxmox VE 宿主机,所有运行中的 VM/CT 将被中断。" \ - "重启过程中管理面不可用,请确保维护窗口内执行。" \ - "请先正常关机或迁移所有 VM/CT。" \ - "REBOOT"; then - log_info "系统将在5秒后重启..." - echo "按 Ctrl+C 取消重启" - sleep 5 - reboot - else - log_info "取消重启" - fi - ;; - 0) - break - ;; - *) - log_error "无效的选择,请重新输入" - ;; - esac - - echo - pause_function - done + run_menu "内核管理菜单" kernel_management_menu_render kernel_management_menu_dispatch "0-6" +} + +kernel_management_menu_render() { + 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}" +} + +kernel_management_menu_dispatch() { + case "$1" in + 1) check_kernel_version ;; + 2) get_available_kernels ;; + 3) install_kernel_prompt ;; + 4) set_default_kernel_prompt ;; + 5) remove_old_kernels ;; + 6) kernel_reboot_prompt ;; + *) return 1 ;; + esac + return 0 +} + +# 交互收集内核标识后安装 +install_kernel_prompt() { + local kernel_ver="" + echo "请输入要安装的内核版本:" + echo " - 完整包名格式 (推荐): 如 proxmox-kernel-6.14.8-2-pve" + echo " - 简化版本格式: 如 6.8.8-1 (将自动补全为 proxmox-kernel-6.8.8-1-pve)" + prompt_value kernel_ver "请输入内核标识" || return 0 + install_kernel "$kernel_ver" +} + +# 交互收集内核版本后设为默认启动项 +set_default_kernel_prompt() { + local kernel_ver="" + prompt_value kernel_ver "请输入要设置为默认的内核版本 (例如: 6.8.8-1-pve)" || return 0 + set_default_kernel "$kernel_ver" +} + +# 高风险确认后重启宿主机 +kernel_reboot_prompt() { + if confirm_high_risk_action \ + "重启宿主机" \ + "将立即重启当前 Proxmox VE 宿主机,所有运行中的 VM/CT 将被中断。" \ + "重启过程中管理面不可用,请确保维护窗口内执行。" \ + "请先正常关机或迁移所有 VM/CT。" \ + "REBOOT"; then + log_info "系统将在5秒后重启..." + echo "按 Ctrl+C 取消重启" + sleep 5 + reboot + else + log_info "取消重启" + fi } # 内核同步更新(自动检测并更新到最新稳定版) diff --git a/src/modules/04-gpu-passthrough/amd-dgpu.sh b/src/modules/04-gpu-passthrough/amd-dgpu.sh index a5aa0c8..b0116a2 100644 --- a/src/modules/04-gpu-passthrough/amd-dgpu.sh +++ b/src/modules/04-gpu-passthrough/amd-dgpu.sh @@ -89,6 +89,8 @@ amd_host_prepare_for_passthrough() { fi echo + gpu_warn_active_stacks + if ! confirm_high_risk_action "为 AMD GPU 直通写入宿主机预配置" "会修改 GRUB、VFIO 模块和 AMD 显卡黑名单配置。" "错误配置可能导致宿主机本地输出消失、GPU 无法用于宿主机图形界面,甚至在重启后需要控制台修复。" "请确认已准备带外管理或物理控制台,并已理解回滚方式。" "AMD-HOST"; then return 0 fi @@ -278,22 +280,19 @@ amd_gpu_passthrough_vm() { 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 + run_menu "AMD 独显直通" amd_gpu_management_menu_render amd_gpu_management_menu_dispatch "0-2" +} +amd_gpu_management_menu_render() { + echo -e "${CYAN}提示:如宿主机仍在使用 amdgpu / radeon,占用中的 AMD 独显通常无法直接直通。${NC}" + echo -e "${UI_DIVIDER}" + show_menu_option "1" "AMD 显卡直通虚拟机" + show_menu_option "2" "AMD 宿主机预配置 ( IOMMU / VFIO / 黑名单 )" +} +amd_gpu_management_menu_dispatch() { + case "$1" in + 1) amd_gpu_passthrough_vm ;; + 2) amd_host_prepare_for_passthrough ;; + *) return 1 ;; + esac + return 0 } diff --git a/src/modules/04-gpu-passthrough/amd-igpu.sh b/src/modules/04-gpu-passthrough/amd-igpu.sh index 4e61bc4..c230962 100644 --- a/src/modules/04-gpu-passthrough/amd-igpu.sh +++ b/src/modules/04-gpu-passthrough/amd-igpu.sh @@ -248,28 +248,25 @@ amd_igpu_passthrough_vm() { 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 + run_menu "AMD 核显直通" amd_igpu_management_menu_render amd_igpu_management_menu_dispatch "0-4" +} +amd_igpu_management_menu_render() { + 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 / 黑名单 )" +} +amd_igpu_management_menu_dispatch() { + case "$1" in + 1) amd_igpu_passthrough_vm ;; + 2) amd_igpu_check_romfile ;; + 3) amd_igpu_show_guidance ;; + 4) amd_host_prepare_for_passthrough ;; + *) return 1 ;; + esac + return 0 } # 主程序 diff --git a/src/modules/04-gpu-passthrough/igpu-shared.sh b/src/modules/04-gpu-passthrough/igpu-shared.sh index 6320551..595cc59 100644 --- a/src/modules/04-gpu-passthrough/igpu-shared.sh +++ b/src/modules/04-gpu-passthrough/igpu-shared.sh @@ -2,83 +2,6 @@ # 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_remove_param "intel_iommu" - grub_remove_param "iommu" - grub_remove_param "i915.enable_gvt" - grub_remove_param "i915.enable_guc" - grub_remove_param "i915.max_vfs" - - 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 @@ -176,8 +99,6 @@ igpu_verify() { echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo " 检查完成" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - - pause_function } # 移除核显虚拟化配置 @@ -191,6 +112,7 @@ igpu_remove() { echo " • 恢复 GRUB 配置为默认值" echo " • 清理 /etc/modules 中的 VFIO 和 kvmgt 模块" echo " • 删除 /etc/sysfs.conf 中的 VFs 配置" + echo " • 清理本工具写入的 i915 相关驱动黑名单(如有)" echo " • 卸载 i915-sriov-dkms 驱动(如已安装)" echo echo -e " 注意:此操作不会自动重启系统" @@ -216,10 +138,12 @@ igpu_remove() { update-grub echo -e " ✓ GRUB 配置已恢复" - # 清理 /etc/modules + # 清理 /etc/modules(先移除 marker 配置块,再清理旧版本裸行) echo "清理内核模块配置..." backup_file "/etc/modules" + remove_block "/etc/modules" "INTEL_SRIOV_MODULES" + remove_block "/etc/modules" "INTEL_GVTG_MODULES" sed -i '/^vfio$/d; /^vfio_iommu_type1$/d; /^vfio_pci$/d; /^vfio_virqfd$/d; /^kvmgt$/d' /etc/modules echo -e " ✓ 内核模块配置已清理" @@ -231,6 +155,14 @@ igpu_remove() { echo -e " ✓ sysfs 配置已清理" fi + # 清理本工具写入的 i915 相关驱动黑名单(如有),避免恢复后核显仍被屏蔽 + for f in /etc/modprobe.d/blacklist.conf /etc/modprobe.d/pve-blacklist.conf; do + if [ -f "$f" ]; then + remove_block "$f" "HARDWARE_PASSTHROUGH" + remove_block "$f" "INTEL_LEGACY_BLACKLIST" + fi + done + # 卸载 i915-sriov-dkms echo "检查 i915-sriov-dkms 驱动..." if dpkg -l | grep -q i915-sriov-dkms; then @@ -259,81 +191,56 @@ igpu_remove() { fi } +# GRUB 备份(输入备注后调用 backup_grub_with_note) +igpu_grub_backup_prompt() { + local backup_note="" + prompt_value backup_note "请输入备份备注(例如:手动备份_测试)" "手动备份" || return 0 + backup_grub_with_note "$backup_note" +} + # 核显高级功能菜单 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 + run_menu "核显虚拟化高级功能" igpu_management_menu_render igpu_management_menu_dispatch "0-8" +} - 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 +igpu_management_menu_render() { + 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 配置" +} + +igpu_management_menu_dispatch() { + case "$1" in + 1) igpu_sriov_setup ;; + 2) igpu_gvtg_setup ;; + 3) igpu_verify ;; + 4) igpu_remove ;; + 5) show_grub_config ;; + 6) igpu_grub_backup_prompt ;; + 7) list_grub_backups ;; + 8) restore_grub_backup ;; + *) return 1 ;; + esac + return 0 } #--------------核显虚拟化管理---------------- diff --git a/src/modules/04-gpu-passthrough/init.sh b/src/modules/04-gpu-passthrough/init.sh index d361bb0..c709cfc 100644 --- a/src/modules/04-gpu-passthrough/init.sh +++ b/src/modules/04-gpu-passthrough/init.sh @@ -3,60 +3,53 @@ # 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 + run_menu "直通与显卡" menu_gpu_passthrough_render menu_gpu_passthrough_dispatch "0-7" +} + +menu_gpu_passthrough_render() { + 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)" +} + +menu_gpu_passthrough_dispatch() { + case "$1" 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 ;; + *) return 1 ;; + esac + return 0 } -# 虚拟机/容器定时开关机管理 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 + run_menu "磁盘/控制器直通" menu_disk_controller_passthrough_render menu_disk_controller_passthrough_dispatch "0-5" } -# ============ RDM(裸磁盘映射)单盘直通 ============ +menu_disk_controller_passthrough_render() { + 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)" +} -# 获取 VM 配置文件路径(不保证一定存在,需调用方自行判断) +menu_disk_controller_passthrough_dispatch() { + case "$1" in + 1) rdm_single_disk_attach ;; + 2) rdm_single_disk_detach ;; + 3) storage_controller_passthrough ;; + 4) nvme_passthrough ;; + 5) boot_config_assistant ;; + *) return 1 ;; + esac + return 0 +} diff --git a/src/modules/04-gpu-passthrough/intel-gvtg.sh b/src/modules/04-gpu-passthrough/intel-gvtg.sh index da158c8..074d9d6 100644 --- a/src/modules/04-gpu-passthrough/intel-gvtg.sh +++ b/src/modules/04-gpu-passthrough/intel-gvtg.sh @@ -53,6 +53,8 @@ igpu_gvtg_setup() { echo -e " ${CYAN}提示 3:${NC} 准备好通过 SSH 或物理访问恢复系统" echo + gpu_warn_active_stacks + # 询问是否要备份 if confirm_action "是否先备份当前 GRUB 配置(强烈推荐)"; then echo @@ -104,19 +106,22 @@ igpu_gvtg_setup() { echo "清理可能存在的 i915 及音视频相关黑名单..." for f in /etc/modprobe.d/blacklist.conf /etc/modprobe.d/pve-blacklist.conf; do if [ -f "$f" ]; then + remove_block "$f" "HARDWARE_PASSTHROUGH" + remove_block "$f" "INTEL_LEGACY_BLACKLIST" 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 + # 添加 VFIO 和 kvmgt 模块(marker 配置块写入,自动备份并幂等;同时清掉 SR-IOV 的模块块与旧版裸行) + remove_block "/etc/modules" "INTEL_SRIOV_MODULES" + sed -i -E '/^(vfio|vfio_iommu_type1|vfio_pci|vfio_virqfd|kvmgt)[[:space:]]*$/d' /etc/modules + apply_block "/etc/modules" "INTEL_GVTG_MODULES" "vfio +vfio_iommu_type1 +vfio_pci +vfio_virqfd +kvmgt" echo -e "✓ 内核模块配置完成" diff --git a/src/modules/04-gpu-passthrough/intel-legacy.sh b/src/modules/04-gpu-passthrough/intel-legacy.sh index 9c629a0..9b9a48a 100644 --- a/src/modules/04-gpu-passthrough/intel-legacy.sh +++ b/src/modules/04-gpu-passthrough/intel-legacy.sh @@ -31,6 +31,7 @@ restore_qemu_kvm() { # 3. 清理黑名单 (可选) if confirm_action "是否同时清理 Intel 核显相关的驱动黑名单?"; then log_info "正在清理黑名单配置..." + remove_block "/etc/modprobe.d/pve-blacklist.conf" "INTEL_LEGACY_BLACKLIST" 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 @@ -93,12 +94,23 @@ intel_gpu_passthrough() { ;; esac - # 1. 配置黑名单 + gpu_warn_active_stacks + + if ! confirm_high_risk_action \ + "安装第三方修改版 QEMU 并配置核显直通" \ + "将屏蔽宿主机 i915 驱动(本地控制台会黑屏),并安装来自 AICodo 仓库的非官方 pve-qemu-kvm" \ + "第三方 deb 包无官方签名校验,且会被 apt-mark hold 锁定版本、无法收到官方安全更新" \ + "请确认已有可用的 SSH/Web 管理通道;出问题可用本菜单的救砖模式恢复官方 QEMU" \ + "QEMU-MOD"; then + return 1 + fi + + # 1. 配置黑名单(marker 配置块写入,apply_block 自动备份并幂等) 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 + apply_block "/etc/modprobe.d/pve-blacklist.conf" "INTEL_LEGACY_BLACKLIST" "blacklist i915 +blacklist snd_hda_intel +blacklist snd_hda_codec_hdmi" log_success "已添加黑名单配置" log_info "正在更新 initramfs..." @@ -118,8 +130,9 @@ intel_gpu_passthrough() { # 为方便起见,这里演示自动获取逻辑 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) - + local qemu_deb_url + 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 下载链接,尝试使用备用链接或手动下载" # 备用逻辑:提示用户手动下载 @@ -128,28 +141,41 @@ intel_gpu_passthrough() { else # 加速下载 local fast_qemu_url="https://ghfast.top/${qemu_deb_url}" - local qemu_deb_file + local qemu_deb_file qemu_deb_sha256 qemu_deb_file="$(mktemp --suffix=.deb)" log_info "正在下载: $fast_qemu_url" - wget -O "$qemu_deb_file" "$fast_qemu_url" - - if [[ -s "$qemu_deb_file" ]]; then - log_info "正在安装修改版 QEMU..." - if dpkg -i "$qemu_deb_file"; then + if ! wget -O "$qemu_deb_file" "$fast_qemu_url"; then + log_warn "加速源下载失败,改用 GitHub 原始地址重试..." + if ! wget -O "$qemu_deb_file" "$qemu_deb_url"; then rm -f "$qemu_deb_file" - log_success "安装完成" - - # 阻止更新 - apt-mark hold pve-qemu-kvm - log_info "已锁定 pve-qemu-kvm 防止自动更新" - else - rm -f "$qemu_deb_file" - log_error "安装修改版 QEMU 失败,请检查 deb 包完整性" + log_error "下载失败,已中止配置(未修改 QEMU)" return 1 fi + fi + + if [[ ! -s "$qemu_deb_file" ]]; then + rm -f "$qemu_deb_file" + log_error "下载结果为空,已中止配置(未修改 QEMU)" + return 1 + fi + + # 上游无官方签名可验,至少展示摘要与来源,供用户与 release 页核对及事后追溯 + qemu_deb_sha256="$(sha256sum "$qemu_deb_file" | awk '{print $1}')" + log_info "deb 包 SHA256: ${qemu_deb_sha256}" + log_info "下载来源: ${qemu_deb_url}" + + log_info "正在安装修改版 QEMU..." + if dpkg -i "$qemu_deb_file"; then + rm -f "$qemu_deb_file" + log_success "安装完成" + + # 阻止更新 + apt-mark hold pve-qemu-kvm + log_info "已锁定 pve-qemu-kvm 防止自动更新(救砖模式可解除)" else rm -f "$qemu_deb_file" - log_error "下载失败" + log_error "安装修改版 QEMU 失败,请检查 deb 包完整性" + return 1 fi fi @@ -307,25 +333,29 @@ intel_gpu_passthrough() { args_line="$args_line -set device.hostpci1.bus=pcie.0 -set device.hostpci1.addr=0x03.0" fi - # 写入 args (保留用户已有的 args 参数) + # 通过 qm set 写入:直接追加/就地 sed 会绕过 pmxcfs 加锁, + # 且在带快照的 conf 上会把配置行写进快照段导致配置损坏 if grep -q '^args:' "/etc/pve/qemu-server/$vmid.conf"; then log_warn "检测到 VM $vmid 已有 args 配置,将被新 args 覆盖" log_warn "原 args 内容: $(grep '^args:' "/etc/pve/qemu-server/$vmid.conf")" - sed -i '/^args:/d' "/etc/pve/qemu-server/$vmid.conf" fi - 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" - + if ! qm set "$vmid" --args "$args_line"; then + log_error "写入 args 失败,虚拟机配置未完成" + return 1 + fi + + # 写入 hostpci0 (核显),格式: 0000:00:02.0,romfile=xxx.rom + if ! qm set "$vmid" --hostpci0 "$igpu_pci,romfile=$rom_filename"; then + log_error "写入 hostpci0 失败,虚拟机配置未完成" + return 1 + fi + # 写入 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" + if ! qm set "$vmid" --hostpci1 "$audio_pci"; then + log_error "写入 hostpci1 失败,请手动检查虚拟机配置" + return 1 + fi fi log_success "虚拟机 $vmid 配置完成" diff --git a/src/modules/04-gpu-passthrough/intel-sriov.sh b/src/modules/04-gpu-passthrough/intel-sriov.sh index b8558f9..5f0020b 100644 --- a/src/modules/04-gpu-passthrough/intel-sriov.sh +++ b/src/modules/04-gpu-passthrough/intel-sriov.sh @@ -59,6 +59,8 @@ igpu_sriov_setup() { echo -e " ${CYAN}提示 3:${NC} 准备好通过 SSH 或物理访问恢复系统" echo + gpu_warn_active_stacks + # 询问是否要备份 if confirm_action "是否先备份当前 GRUB 配置(强烈推荐)"; then echo @@ -132,22 +134,21 @@ igpu_sriov_setup() { echo "清理可能存在的 i915 及音视频相关黑名单..." for f in /etc/modprobe.d/blacklist.conf /etc/modprobe.d/pve-blacklist.conf; do if [ -f "$f" ]; then + remove_block "$f" "HARDWARE_PASSTHROUGH" + remove_block "$f" "INTEL_LEGACY_BLACKLIST" 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 + # 添加 VFIO 模块(marker 配置块写入,自动备份并幂等;同时清掉 GVT-g 的模块块、kvmgt 与旧版裸行) + remove_block "/etc/modules" "INTEL_GVTG_MODULES" + sed -i -E '/^(vfio|vfio_iommu_type1|vfio_pci|vfio_virqfd|kvmgt)[[:space:]]*$/d' /etc/modules + apply_block "/etc/modules" "INTEL_SRIOV_MODULES" "vfio +vfio_iommu_type1 +vfio_pci +vfio_virqfd" echo -e "✓ 内核模块配置完成" diff --git a/src/modules/04-gpu-passthrough/iommu.sh b/src/modules/04-gpu-passthrough/iommu.sh index 860707a..ddc70cd 100644 --- a/src/modules/04-gpu-passthrough/iommu.sh +++ b/src/modules/04-gpu-passthrough/iommu.sh @@ -18,115 +18,178 @@ iommu_is_enabled() { return 1 } -# 从 udev 路径中解析 PCI BDF(格式:0000:00:00.0) +# 检测本机已存在的直通/虚拟化配置来源(只读),每行输出一个已配置方案名 +gpu_detect_active_stacks() { + local grub_cmdline="" + if [[ -f /etc/default/grub ]]; then + grub_cmdline="$(grep '^GRUB_CMDLINE_LINUX_DEFAULT=' /etc/default/grub 2>/dev/null || true)" + fi + + grep -qs "PVE-TOOLS BEGIN IOMMU_BASE_MODULES" /etc/modules && echo "一键硬件直通基础配置 (IOMMU)" + grep -qs "PVE-TOOLS BEGIN NVIDIA_VFIO_MODULES" /etc/modules && echo "NVIDIA 宿主机直通预配置" + grep -qs "PVE-TOOLS BEGIN AMD_VFIO_MODULES" /etc/modules && echo "AMD 宿主机直通预配置" + grep -qs "PVE-TOOLS BEGIN INTEL_SRIOV_MODULES" /etc/modules && echo "Intel 核显 SR-IOV" + grep -qs "PVE-TOOLS BEGIN INTEL_GVTG_MODULES" /etc/modules && echo "Intel 核显 GVT-g" + grep -qs "PVE-TOOLS BEGIN INTEL_LEGACY_BLACKLIST" /etc/modprobe.d/pve-blacklist.conf && echo "Intel 核显直通黑名单 (修改版 QEMU 方案)" + grep -qs "PVE-TOOLS BEGIN HARDWARE_PASSTHROUGH" /etc/modprobe.d/blacklist.conf && echo "一键直通可选驱动屏蔽 (i915/snd_hda)" + + # 兼容旧版本裸写入(无 marker):按 GRUB 特征参数兜底识别 + if [[ "$grub_cmdline" == *"i915.enable_gvt"* ]] && ! grep -qs "PVE-TOOLS BEGIN INTEL_GVTG_MODULES" /etc/modules; then + echo "Intel 核显 GVT-g (旧版本写入的 GRUB 参数)" + fi + if [[ "$grub_cmdline" == *"i915.max_vfs"* ]] && ! grep -qs "PVE-TOOLS BEGIN INTEL_SRIOV_MODULES" /etc/modules; then + echo "Intel 核显 SR-IOV (旧版本写入的 GRUB 参数)" + fi + return 0 +} + +# 在进入任一直通方案配置前提示已存在的其他方案,避免多方案叠加冲突 +gpu_warn_active_stacks() { + local found + found="$(gpu_detect_active_stacks)" + if [[ -n "$found" ]]; then + echo + log_warn "检测到本机已存在以下直通/虚拟化配置:" + while IFS= read -r line; do + echo -e " ${YELLOW}- ${line}${NC}" + done <<< "$found" + log_warn "多套方案同时修改 GRUB/内核模块可能互相冲突;如需切换方案,建议先用对应菜单清理旧配置。" + echo + fi + return 0 +} + enable_pass() { + local grub_changed blacklist_content echo log_step "开启硬件直通..." - if [ `dmesg | grep -e DMAR -e IOMMU|wc -l` = 0 ];then + if ! dmesg 2>/dev/null | grep -q -e DMAR -e IOMMU; then log_error "您的硬件不支持直通!不如检查一下主板的BIOS设置?" pause_function return fi - if [ `cat /proc/cpuinfo|grep Intel|wc -l` = 0 ];then - iommu="amd_iommu=on" - else + if grep -q Intel /proc/cpuinfo; then iommu="intel_iommu=on" + else + iommu="amd_iommu=on" fi - if ! grep -qw "$(echo "$iommu" | cut -d'=' -f1)" /etc/default/grub; then + + gpu_warn_active_stacks + + if ! confirm_high_risk_action \ + "开启 IOMMU 硬件直通基础配置" \ + "会修改 GRUB 内核参数并向 /etc/modules 写入 vfio 模块,需重启生效" \ + "配置错误可能导致宿主机启动异常;与已有显卡直通方案叠加时请确认参数不冲突" \ + "脚本会自动备份 /etc/default/grub 与 /etc/modules,可通过 GRUB 备份恢复功能回滚" \ + "IOMMU-ON"; then + return 1 + fi + + grub_changed=0 + if ! grub_has_param "$iommu"; then if grub_add_param "$iommu"; then - update-grub + grub_changed=1 else log_error "GRUB 参数添加失败,无法继续配置硬件直通" return 1 fi - if [ `grep "vfio" /etc/modules|wc -l` = 0 ];then - cat <<-EOF >> /etc/modules -vfio + else + log_info "GRUB 中已存在 IOMMU 参数,跳过内核参数修改。" + fi + + # GRUB 已配置过时也要补齐 vfio 模块,避免"以为配了其实没配" + # marker 配置块写入(apply_block 内部自动备份并保证幂等) + if ! grep -q "vfio" /etc/modules 2>/dev/null; then + apply_block "/etc/modules" "IOMMU_BASE_MODULES" "vfio vfio_iommu_type1 vfio_pci vfio_virqfd -kvmgt -EOF - fi - - # 使用安全的配置块管理 +kvmgt" + log_success "已写入 vfio 内核模块到 /etc/modules" + else + log_info "/etc/modules 中已存在 vfio 模块,跳过。" + fi + + # 屏蔽核显/声卡驱动仅适用于"把核显直通给虚拟机"的场景; + # 宿主机还需要本地显示输出时开启会导致控制台黑屏,因此默认不开启。 + # 设备级 vfio-pci ids 绑定由各显卡专用直通菜单按实际硬件写入,此处不再写死。 + echo -e "${YELLOW}是否同时屏蔽宿主机核显与核显音频驱动 (i915/snd_hda_*)?${NC}" + echo -e "${RED}仅在准备把核显直通给虚拟机时才需要;开启后宿主机本地屏幕将黑屏,只能通过 SSH/Web 管理。${NC}" + if confirm_action "屏蔽 i915 与核显音频驱动(一般不需要)"; then 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 "您已经配置过!" + log_info "已跳过驱动屏蔽。如需针对具体显卡配置,请使用对应显卡的直通菜单。" fi + + if [[ "$grub_changed" -eq 1 ]]; then + update-grub + fi + log_success "开启设置后需要重启系统,请准备就绪后重启宿主机" + log_tips "重启后才可以应用对内核引导的修改哦!命令是 reboot" } # 关闭硬件直通 disable_pass() { echo log_step "关闭硬件直通..." - if [ `dmesg | grep -e DMAR -e IOMMU|wc -l` = 0 ];then + if ! dmesg 2>/dev/null | grep -q -e DMAR -e IOMMU; 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 + if grep -q Intel /proc/cpuinfo; then iommu="intel_iommu=on" - fi - if [ `grep $iommu /etc/default/grub|wc -l` = 0 ];then - log_warn "您还没有配置过该项" else - grub_remove_param "$iommu" - backup_file "/etc/modules" - sed -i '/vfio/d' /etc/modules - # 使用安全的配置块删除,而不是直接删除整个文件 - remove_block "/etc/modprobe.d/blacklist.conf" "HARDWARE_PASSTHROUGH" - remove_block "/etc/modprobe.d/vfio.conf" "HARDWARE_PASSTHROUGH" - update-grub - log_success "关闭设置后需要重启系统,请准备就绪后重启宿主机。" - log_tips "重启后才可以应用对内核引导的修改哦!命令是 reboot" + iommu="amd_iommu=on" fi + if ! grub_has_param "$iommu"; then + log_warn "您还没有配置过该项" + return + fi + + if ! confirm_high_risk_action \ + "关闭 IOMMU 硬件直通基础配置" \ + "会移除 GRUB 中的 IOMMU 参数并删除 /etc/modules 中所有 vfio 行" \ + "依赖 IOMMU 的其他直通方案 (核显 SR-IOV / NVIDIA / AMD / 磁盘控制器直通) 将在重启后全部失效" \ + "如仍有虚拟机挂载直通设备,请先在对应菜单解除直通再关闭本配置" \ + "IOMMU-OFF"; then + return 1 + fi + + grub_remove_param "$iommu" + backup_file "/etc/modules" + # 先移除本工具的 marker 配置块,再精确清理历史版本写入的裸模块行(含 kvmgt,不误删其它行) + remove_block "/etc/modules" "IOMMU_BASE_MODULES" + sed -i -E '/^(vfio|vfio_iommu_type1|vfio_pci|vfio_virqfd|kvmgt)[[:space:]]*$/d' /etc/modules + # 使用安全的配置块删除,而不是直接删除整个文件(vfio.conf 为历史版本可能写入的位置) + remove_block "/etc/modprobe.d/blacklist.conf" "HARDWARE_PASSTHROUGH" + remove_block "/etc/modprobe.d/vfio.conf" "HARDWARE_PASSTHROUGH" + update-grub + log_success "关闭设置后需要重启系统,请准备就绪后重启宿主机。" + log_tips "重启后才可以应用对内核引导的修改哦!命令是 reboot" } # 硬件直通菜单 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 + run_menu "配置硬件直通" hw_passth_render hw_passth_dispatch "0-2" +} + +hw_passth_render() { + show_menu_option "1" "开启硬件直通" + show_menu_option "2" "关闭硬件直通" +} + +hw_passth_dispatch() { + case "$1" in + 1) enable_pass ;; + 2) disable_pass ;; + *) return 1 ;; + esac + return 0 } #--------------磁盘/控制器直通---------------- diff --git a/src/modules/04-gpu-passthrough/nvidia.sh b/src/modules/04-gpu-passthrough/nvidia.sh index 70a295a..e3f44fc 100644 --- a/src/modules/04-gpu-passthrough/nvidia.sh +++ b/src/modules/04-gpu-passthrough/nvidia.sh @@ -377,22 +377,19 @@ nvidia_driver_export_report() { 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 + run_menu "$(nvidia_t OPT_DRV_INFO)" nvidia_driver_info_menu_render nvidia_driver_info_menu_dispatch "0-2" +} +nvidia_driver_info_menu_render() { + show_menu_option "1" "查看驱动与监控面板" + show_menu_option "2" "导出驱动诊断报告" +} +nvidia_driver_info_menu_dispatch() { + case "$1" in + 1) nvidia_driver_info ;; + 2) nvidia_driver_export_report ;; + *) return 1 ;; + esac + return 0 } nvidia_apt_has_pkg() { local pkg="$1" @@ -495,26 +492,23 @@ nvidia_driver_rollback() { 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 + run_menu "$(nvidia_t OPT_DRV_SWITCH)" nvidia_driver_switch_menu_render nvidia_driver_switch_menu_dispatch "0-3" +} +nvidia_driver_switch_menu_render() { + 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" "回滚最近一次备份" +} +nvidia_driver_switch_menu_dispatch() { + case "$1" in + 1) nvidia_driver_switch_to_proprietary ;; + 2) nvidia_driver_switch_to_open ;; + 3) nvidia_driver_rollback ;; + *) return 1 ;; + esac + return 0 } nvidia_host_prepare_for_passthrough() { echo -e "${YELLOW}将执行以下操作:${NC}" @@ -524,7 +518,14 @@ nvidia_host_prepare_for_passthrough() { echo " 4) 执行 update-grub 与 update-initramfs" echo - if ! confirm_action "确认执行宿主机预配置?"; then + gpu_warn_active_stacks + + if ! confirm_high_risk_action \ + "NVIDIA 宿主机直通预配置" \ + "将修改 GRUB 内核参数、/etc/modules 与显卡驱动黑名单,并重建 initramfs。" \ + "配置错误可能导致宿主机本地显示输出丢失或无法进入图形环境。" \ + "操作前请确认已有带外管理(IPMI/iKVM)或 SSH 通道,必要时先备份 /etc/default/grub。" \ + "NVIDIA-HOST"; then return 0 fi @@ -587,10 +588,7 @@ nvidia_setup_vgpu_unlock() { echo -e "${CYAN}推荐先阅读 Wiki:${NC}" echo " 对应文章: https://pve.u3u.icu/advanced/nvidia-vgpu-driver-notes" echo "${UI_DIVIDER}" - read -p "请输入 '确认' 或 'Sure' 继续: " response - response=$(echo "$response" | xargs) - if [[ "$response" != "确认" && "$response" != "Sure" && "${response,,}" != "sure" ]]; then - echo "取消" + if ! confirm_action "已阅读文档并了解 vGPU Unlock 风险,继续部署流程"; then return 0 fi @@ -658,28 +656,25 @@ EOF 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 + run_menu "$(nvidia_t MENU_TITLE)" nvidia_gpu_management_menu_render nvidia_gpu_management_menu_dispatch "0-5" +} +nvidia_gpu_management_menu_render() { + 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)" +} +nvidia_gpu_management_menu_dispatch() { + case "$1" 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 ;; + *) return 1 ;; + esac + return 0 } diff --git a/src/modules/04-gpu-passthrough/rdm.sh b/src/modules/04-gpu-passthrough/rdm.sh index 91957fe..469eedc 100644 --- a/src/modules/04-gpu-passthrough/rdm.sh +++ b/src/modules/04-gpu-passthrough/rdm.sh @@ -272,7 +272,12 @@ rdm_single_disk_detach() { backup_file "$conf_path" >/dev/null 2>&1 || true fi - if ! confirm_action "从 VM $vmid 删除磁盘插槽(--delete $slot)"; then + if ! confirm_high_risk_action \ + "从 VM $vmid 删除磁盘插槽 $slot" \ + "上方列表包含该 VM 的全部磁盘插槽(不仅是直通盘),删错会移除系统盘/数据盘的引用。" \ + "qm set --delete 只解除引用不删除数据,但 VM 可能因缺盘无法启动。" \ + "请再次核对插槽内容确为要取消直通的磁盘:$line" \ + "DETACH"; then return 0 fi diff --git a/src/modules/05-vm-container/backup.sh b/src/modules/05-vm-container/backup.sh index d109c28..a8cebcd 100644 --- a/src/modules/05-vm-container/backup.sh +++ b/src/modules/05-vm-container/backup.sh @@ -195,29 +195,26 @@ vm_schedule_remove_backup_job() { 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 + run_menu "VM 定时备份" vm_schedule_backup_menu_render vm_schedule_backup_menu_dispatch "0-2" +} + +vm_schedule_backup_menu_render() { + 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" "删除定时备份任务" +} + +vm_schedule_backup_menu_dispatch() { + case "$1" in + 1) vm_schedule_add_backup_job ;; + 2) vm_schedule_remove_backup_job ;; + *) return 1 ;; + esac + return 0 } diff --git a/src/modules/05-vm-container/cloudinit.sh b/src/modules/05-vm-container/cloudinit.sh index f3dcbb0..36a0f31 100644 --- a/src/modules/05-vm-container/cloudinit.sh +++ b/src/modules/05-vm-container/cloudinit.sh @@ -188,31 +188,28 @@ vm_cloud_image_to_template() { 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 + run_menu "模板 / 克隆 / Cloud-Init" vm_template_cloudinit_menu_render vm_template_cloudinit_menu_dispatch "0-6" +} + +vm_template_cloudinit_menu_render() { + 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 参数" +} + +vm_template_cloudinit_menu_dispatch() { + case "$1" 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 ;; + *) return 1 ;; + esac + return 0 } diff --git a/src/modules/05-vm-container/config-io.sh b/src/modules/05-vm-container/config-io.sh index 8190773..353bc1d 100644 --- a/src/modules/05-vm-container/config-io.sh +++ b/src/modules/05-vm-container/config-io.sh @@ -18,23 +18,29 @@ vm_select_export_file() { return 0 fi + local count manual_idx + count="$(echo "$files" | awk 'NF{c++} END{print c+0}')" + manual_idx=$((count + 1)) + { echo -e "${CYAN}已发现 VM 配置导出文件:${NC}" echo "$files" | awk -F'|' '{printf " [%d] %-10s %-16s %s\n", NR, $2, $3, $1}' + printf ' [%d] 手动输入配置文件路径\n' "$manual_idx" echo -e "${UI_DIVIDER}" } >&2 local pick line path - read -p "请选择配置文件序号 (0 手动输入): " pick + read -p "请选择配置文件序号 (0 返回): " pick pick="${pick:-0}" - if [[ "$pick" == "0" ]]; then + [[ "$pick" == "0" ]] && return 2 + [[ "$pick" =~ ^[0-9]+$ ]] || return 1 + if [[ "$pick" -eq "$manual_idx" ]]; 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 @@ -191,23 +197,20 @@ vm_import_config() { 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 + run_menu "VM 配置导入/导出" vm_config_io_menu_render vm_config_io_menu_dispatch "0-2" +} + +vm_config_io_menu_render() { + vm_show_data_risk_banner + show_menu_option "1" "导出 VM 配置" + show_menu_option "2" "导入 VM 配置" +} + +vm_config_io_menu_dispatch() { + case "$1" in + 1) vm_export_config ;; + 2) vm_import_config ;; + *) return 1 ;; + esac + return 0 } diff --git a/src/modules/05-vm-container/disk.sh b/src/modules/05-vm-container/disk.sh index 4143c05..cd98e6b 100644 --- a/src/modules/05-vm-container/disk.sh +++ b/src/modules/05-vm-container/disk.sh @@ -132,27 +132,24 @@ vm_move_disk() { 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 + run_menu "虚拟机磁盘管理" vm_disk_management_menu_render vm_disk_management_menu_dispatch "0-4" +} + +vm_disk_management_menu_render() { + vm_show_data_risk_banner + show_menu_option "1" "磁盘扩容" + show_menu_option "2" "添加磁盘" + show_menu_option "3" "移除磁盘" + show_menu_option "4" "迁移磁盘到其他存储" +} + +vm_disk_management_menu_dispatch() { + case "$1" in + 1) vm_resize_disk ;; + 2) vm_add_disk ;; + 3) vm_remove_disk ;; + 4) vm_move_disk ;; + *) return 1 ;; + esac + return 0 } diff --git a/src/modules/05-vm-container/garbage-cleanup.sh b/src/modules/05-vm-container/garbage-cleanup.sh index 7b95a31..724ca43 100644 --- a/src/modules/05-vm-container/garbage-cleanup.sh +++ b/src/modules/05-vm-container/garbage-cleanup.sh @@ -459,30 +459,27 @@ garbage_cleanup_scan_report() { 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 + run_menu "垃圾清理" garbage_cleanup_menu_render garbage_cleanup_menu_dispatch "0-5" +} + +garbage_cleanup_menu_render() { + 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} 清理前会列出候选项;备份和快照删除均需要高风险确认。" +} + +garbage_cleanup_menu_dispatch() { + case "$1" 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 ;; + *) return 1 ;; + esac + return 0 } diff --git a/src/modules/05-vm-container/init.sh b/src/modules/05-vm-container/init.sh index 8c0f4c4..e074afc 100644 --- a/src/modules/05-vm-container/init.sh +++ b/src/modules/05-vm-container/init.sh @@ -3,60 +3,54 @@ # 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 + run_menu "虚拟机高级运维工具箱" vm_advanced_operations_render vm_advanced_operations_dispatch "0-5" } -# 二级菜单:虚拟机与容器 + +vm_advanced_operations_render() { + vm_show_data_risk_banner + show_menu_option "1" "VM 配置导入/导出" + show_menu_option "2" "模板 / 克隆 / Cloud-Init" + show_menu_option "3" "虚拟机磁盘管理" + show_menu_option "4" "启动顺序与网络管理" + show_menu_option "5" "集群内迁移 VM" + echo -e " ${RED}警告:涉及磁盘、模板与迁移时,必须先确认备份可用,再核对 VMID / 槽位 / 目标存储。${NC}" +} + +vm_advanced_operations_dispatch() { + case "$1" in + 1) vm_config_io_menu ;; + 2) vm_template_cloudinit_menu ;; + 3) vm_disk_management_menu ;; + 4) vm_startup_network_menu ;; + 5) vm_cluster_migrate ;; + *) return 1 ;; + esac + return 0 +} + +# 二级菜单:虚拟机与容器(快照/备份为高频操作,直达本级菜单,不再埋在工具箱内) 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 + run_menu "虚拟机与容器" menu_vm_container_render menu_vm_container_dispatch "0-6" } -# ============ 宿主机网络 / 防火墙 / IPv6 / 诊断工具箱 ============ +menu_vm_container_render() { + show_menu_option "1" "${CYAN}FastPVE${NC} - 虚拟机快速下载" + show_menu_option "2" "虚拟机/容器定时开关机" + show_menu_option "3" "IMG 镜像导入(转 QCOW2/RAW)" + show_menu_option "4" "快照管理" + show_menu_option "5" "VM 备份与恢复" + show_menu_option "6" "虚拟机高级运维工具箱" +} + +menu_vm_container_dispatch() { + case "$1" in + 1) fastpve_quick_download_menu ;; + 2) manage_vm_schedule ;; + 3) img_convert_import_menu ;; + 4) vm_snapshot_menu ;; + 5) vm_backup_restore_menu ;; + 6) vm_advanced_operations_menu ;; + *) return 1 ;; + esac + return 0 +} diff --git a/src/modules/05-vm-container/network.sh b/src/modules/05-vm-container/network.sh index 451f486..97af0fe 100644 --- a/src/modules/05-vm-container/network.sh +++ b/src/modules/05-vm-container/network.sh @@ -112,27 +112,24 @@ vm_modify_network() { 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 + run_menu "启动顺序与网络管理" vm_startup_network_menu_render vm_startup_network_menu_dispatch "0-4" +} + +vm_startup_network_menu_render() { + vm_show_data_risk_banner + show_menu_option "1" "设置开机自启 / 启动顺序 / 启动延迟" + show_menu_option "2" "添加网卡" + show_menu_option "3" "移除网卡" + show_menu_option "4" "修改 bridge / VLAN" +} + +vm_startup_network_menu_dispatch() { + case "$1" in + 1) vm_configure_startup_policy ;; + 2) vm_add_network ;; + 3) vm_remove_network ;; + 4) vm_modify_network ;; + *) return 1 ;; + esac + return 0 } diff --git a/src/modules/05-vm-container/restore.sh b/src/modules/05-vm-container/restore.sh index 031c4d5..352c056 100644 --- a/src/modules/05-vm-container/restore.sh +++ b/src/modules/05-vm-container/restore.sh @@ -13,23 +13,29 @@ vm_select_backup_archive() { return 0 fi + local count manual_idx + count="$(echo "$archives" | awk 'NF{c++} END{print c+0}')" + manual_idx=$((count + 1)) + { echo -e "${CYAN}已发现备份文件:${NC}" echo "$archives" | awk -F'|' '{printf " [%d] %-10s %-16s %s\n", NR, $2, $3, $1}' + printf ' [%d] 手动输入备份文件路径\n' "$manual_idx" echo -e "${UI_DIVIDER}" } >&2 local pick line path - read -p "请选择备份序号 (0 手动输入): " pick + read -p "请选择备份序号 (0 返回): " pick pick="${pick:-0}" - if [[ "$pick" == "0" ]]; then + [[ "$pick" == "0" ]] && return 2 + [[ "$pick" =~ ^[0-9]+$ ]] || return 1 + if [[ "$pick" -eq "$manual_idx" ]]; 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 @@ -91,27 +97,24 @@ vm_restore_from_backup() { 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 + run_menu "VM 备份与恢复" vm_backup_restore_menu_render vm_backup_restore_menu_dispatch "0-4" +} + +vm_backup_restore_menu_render() { + vm_show_data_risk_banner + show_menu_option "1" "创建 VM 备份(vzdump)" + show_menu_option "2" "从备份恢复为新 VM" + show_menu_option "3" "定时备份任务管理" + show_menu_option "4" "备份文件跨机恢复引导" +} + +vm_backup_restore_menu_dispatch() { + case "$1" in + 1) vm_backup_create ;; + 2) vm_restore_from_backup ;; + 3) vm_schedule_backup_menu ;; + 4) vm_backup_transfer_guide ;; + *) return 1 ;; + esac + return 0 } diff --git a/src/modules/05-vm-container/schedule.sh b/src/modules/05-vm-container/schedule.sh index 113c6d4..9747c81 100644 --- a/src/modules/05-vm-container/schedule.sh +++ b/src/modules/05-vm-container/schedule.sh @@ -74,6 +74,10 @@ manage_vm_schedule() { fi if [[ -n "$cron_content" ]]; then + if ! confirm_action "将定时开关机任务写入 /etc/cron.d/pve-tools-schedule(以 root 身份定时执行)"; then + log_info "已取消写入" + continue + fi 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 diff --git a/src/modules/05-vm-container/snapshot.sh b/src/modules/05-vm-container/snapshot.sh index 95ad47f..65fce70 100644 --- a/src/modules/05-vm-container/snapshot.sh +++ b/src/modules/05-vm-container/snapshot.sh @@ -123,27 +123,24 @@ vm_rollback_snapshot() { 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 + run_menu "快照管理" vm_snapshot_menu_render vm_snapshot_menu_dispatch "0-4" +} + +vm_snapshot_menu_render() { + vm_show_data_risk_banner + show_menu_option "1" "创建快照(支持批量)" + show_menu_option "2" "列出 VM 快照" + show_menu_option "3" "删除快照" + show_menu_option "4" "回滚到快照" +} + +vm_snapshot_menu_dispatch() { + case "$1" in + 1) vm_create_snapshot ;; + 2) vm_list_snapshots ;; + 3) vm_delete_snapshot ;; + 4) vm_rollback_snapshot ;; + *) return 1 ;; + esac + return 0 } diff --git a/src/modules/06-networking/bond.sh b/src/modules/06-networking/bond.sh index e6cad2e..1b41180 100644 --- a/src/modules/06-networking/bond.sh +++ b/src/modules/06-networking/bond.sh @@ -104,26 +104,25 @@ host_network_delete_bond() { 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 + run_menu "Bond 管理" host_network_bond_menu_render host_network_bond_menu_dispatch "0-3" +} + +host_network_bond_menu_render() { + 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" +} + +host_network_bond_menu_dispatch() { + case "$1" in + 1) host_network_show_current_overview ;; + 2) host_network_create_bond ;; + 3) host_network_delete_bond ;; + *) return 1 ;; + esac + return 0 } diff --git a/src/modules/06-networking/bridge.sh b/src/modules/06-networking/bridge.sh index 991f662..d851afb 100644 --- a/src/modules/06-networking/bridge.sh +++ b/src/modules/06-networking/bridge.sh @@ -101,28 +101,27 @@ host_network_delete_bridge() { 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 + run_menu "桥接管理" host_network_bridge_menu_render host_network_bridge_menu_dispatch "0-4" +} + +host_network_bridge_menu_render() { + 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 地址绑定管理(防网卡顺序变化)" +} + +host_network_bridge_menu_dispatch() { + case "$1" in + 1) host_network_show_current_overview ;; + 2) host_network_create_bridge ;; + 3) host_network_delete_bridge ;; + 4) host_network_mac_binding_menu ;; + *) return 1 ;; + esac + return 0 } diff --git a/src/modules/06-networking/diagnostic.sh b/src/modules/06-networking/diagnostic.sh index 94f89b1..14cd247 100644 --- a/src/modules/06-networking/diagnostic.sh +++ b/src/modules/06-networking/diagnostic.sh @@ -104,28 +104,27 @@ netdiag_quick_stack_check() { 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 + run_menu "网络诊断工具箱" netdiag_toolbox_menu_render netdiag_toolbox_menu_dispatch "0-6" +} + +netdiag_toolbox_menu_render() { + 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 / 自定义)" +} + +netdiag_toolbox_menu_dispatch() { + case "$1" 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 ;; + *) return 1 ;; + esac + return 0 } diff --git a/src/modules/06-networking/firewall.sh b/src/modules/06-networking/firewall.sh index 399637a..b26dd11 100644 --- a/src/modules/06-networking/firewall.sh +++ b/src/modules/06-networking/firewall.sh @@ -335,46 +335,48 @@ host_firewall_toggle_enable() { log_warn "PVE 客体防火墙还依赖对应网卡开启 firewall=1;如未开启,请同步检查网卡配置。" fi } +host_firewall_toggle_node() { + local node_name rc + node_name="$(host_firewall_select_node_name)" + rc=$? + [[ "$rc" -eq 2 ]] && return 0 + [[ -n "$node_name" ]] && host_firewall_toggle_enable node "$node_name" "节点 $node_name" +} +host_firewall_toggle_vm() { + local vmid rc + vmid="$(host_firewall_select_guest vm)" + rc=$? + [[ "$rc" -eq 2 ]] && return 0 + [[ -n "$vmid" ]] && host_firewall_toggle_enable vm "$vmid" "VM $vmid" +} +host_firewall_toggle_ct() { + local ctid rc + ctid="$(host_firewall_select_guest ct)" + rc=$? + [[ "$rc" -eq 2 ]] && return 0 + [[ -n "$ctid" ]] && host_firewall_toggle_enable ct "$ctid" "CT $ctid" +} 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 + run_menu "PVE 防火墙开关" host_firewall_toggle_menu_render host_firewall_toggle_menu_dispatch "0-4" +} + +host_firewall_toggle_menu_render() { + host_network_show_risk_banner + show_menu_option "1" "数据中心级别开关" + show_menu_option "2" "节点级别开关" + show_menu_option "3" "VM 级别开关" + show_menu_option "4" "CT 级别开关" +} + +host_firewall_toggle_menu_dispatch() { + case "$1" in + 1) host_firewall_toggle_enable datacenter cluster "数据中心" ;; + 2) host_firewall_toggle_node ;; + 3) host_firewall_toggle_vm ;; + 4) host_firewall_toggle_ct ;; + *) return 1 ;; + esac + return 0 } host_firewall_list_security_groups() { clear @@ -613,31 +615,30 @@ host_firewall_import_ruleset() { 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 + run_menu "PVE 防火墙管理" host_firewall_menu_render host_firewall_menu_dispatch "0-7" +} + +host_firewall_menu_render() { + 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)" +} + +host_firewall_menu_dispatch() { + case "$1" 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 ;; + *) return 1 ;; + esac + return 0 } diff --git a/src/modules/06-networking/init.sh b/src/modules/06-networking/init.sh index 790cf8d..51810f0 100644 --- a/src/modules/06-networking/init.sh +++ b/src/modules/06-networking/init.sh @@ -3,36 +3,33 @@ # 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 + run_menu "宿主机网络配置向导" menu_host_networking_render menu_host_networking_dispatch "0-8" } -# 二级菜单:存储与硬盘 +menu_host_networking_render() { + 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}" +} + +menu_host_networking_dispatch() { + case "$1" 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 ;; + *) return 1 ;; + esac + return 0 +} diff --git a/src/modules/06-networking/interface.sh b/src/modules/06-networking/interface.sh index 2328099..cce2c05 100644 --- a/src/modules/06-networking/interface.sh +++ b/src/modules/06-networking/interface.sh @@ -3,6 +3,11 @@ # Copyright (C) 2026 Ciriu Networks host_network_show_risk_banner() { + # 每会话仅完整展示一次,避免每次重绘菜单都整屏刷同一段风险横幅 + if [[ "${HOST_NET_RISK_BANNER_SHOWN:-0}" -eq 1 ]]; then + return 0 + fi + HOST_NET_RISK_BANNER_SHOWN=1 echo -e "${RED}${UI_DIVIDER}${NC}" echo -e "${RED}高风险提示:以下功能会直接改写宿主机网络、防火墙和 IPv6 行为。${NC}" echo -e "${YELLOW}请仅在控制台或带外管理可用、已确认维护窗口、已准备回滚方案时继续。${NC}" diff --git a/src/modules/06-networking/ipv6-helper.sh b/src/modules/06-networking/ipv6-helper.sh index 8a1f93a..f0e2ba3 100644 --- a/src/modules/06-networking/ipv6-helper.sh +++ b/src/modules/06-networking/ipv6-helper.sh @@ -111,27 +111,26 @@ ipv6_helper_test_connectivity() { 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 + run_menu "IPv6 助手" ipv6_helper_menu_render ipv6_helper_menu_dispatch "0-5" +} + +ipv6_helper_menu_render() { + 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 连通性" +} + +ipv6_helper_menu_dispatch() { + case "$1" 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 ;; + *) return 1 ;; + esac + return 0 } diff --git a/src/modules/06-networking/mac-bind.sh b/src/modules/06-networking/mac-bind.sh index 1c55971..a6d267a 100644 --- a/src/modules/06-networking/mac-bind.sh +++ b/src/modules/06-networking/mac-bind.sh @@ -479,25 +479,24 @@ host_network_delete_mac_binding() { 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 + run_menu "MAC 地址绑定管理" host_network_mac_binding_menu_render host_network_mac_binding_menu_dispatch "0-2" +} + +host_network_mac_binding_menu_render() { + 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 地址绑定" +} + +host_network_mac_binding_menu_dispatch() { + case "$1" in + 1) host_network_create_mac_binding ;; + 2) host_network_delete_mac_binding ;; + *) return 1 ;; + esac + return 0 } diff --git a/src/modules/06-networking/vlan.sh b/src/modules/06-networking/vlan.sh index d6de514..40e04c1 100644 --- a/src/modules/06-networking/vlan.sh +++ b/src/modules/06-networking/vlan.sh @@ -92,26 +92,25 @@ host_network_delete_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 + run_menu "VLAN 子接口管理" host_network_vlan_menu_render host_network_vlan_menu_dispatch "0-3" +} + +host_network_vlan_menu_render() { + 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 子接口" +} + +host_network_vlan_menu_dispatch() { + case "$1" in + 1) host_network_show_current_overview ;; + 2) host_network_create_vlan ;; + 3) host_network_delete_vlan ;; + *) return 1 ;; + esac + return 0 } diff --git a/src/modules/07-storage-disk/ceph.sh b/src/modules/07-storage-disk/ceph.sh index ec9fd19..90c7f0e 100644 --- a/src/modules/07-storage-disk/ceph.sh +++ b/src/modules/07-storage-disk/ceph.sh @@ -28,7 +28,8 @@ pve9_ceph() { [[ -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 修改过的版本覆盖掉好备份 + [[ -e /usr/share/perl5/PVE/CLI/pveceph.pm && ! -e /etc/apt/backup/pveceph.pm.bak ]] && cp -a /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 @@ -65,7 +66,8 @@ pve8_ceph() { [[ -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 修改过的版本覆盖掉好备份 + [[ -e /usr/share/perl5/PVE/CLI/pveceph.pm && ! -e /etc/apt/backup/pveceph.pm.bak ]] && cp -a /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 @@ -78,22 +80,53 @@ EOF #---------PVE7/8添加ceph-quincy源----------- #---------PVE一键卸载ceph----------- remove_ceph() { - log_warn "会卸载ceph,并删除所有ceph相关文件!" + local unit osd_dirs - systemctl stop ceph-mon.target && systemctl stop ceph-mgr.target && systemctl stop ceph-mds.target && systemctl stop ceph-osd.target + if ! command -v pveceph >/dev/null 2>&1 && [[ ! -d /var/lib/ceph && ! -e /etc/pve/ceph.conf ]]; then + log_info "未检测到 Ceph 安装,无需卸载。" + return 0 + fi + + echo -e "${RED}即将完全卸载 Ceph:停止全部 Ceph 服务、purge 软件包,并删除以下数据目录:${NC}" + echo " /var/lib/ceph (含全部 mon/mgr/mds/osd 数据)" + echo " /etc/ceph、/etc/pve/ceph.conf、/etc/pve/priv/ceph.*、/var/log/ceph" + + osd_dirs="$(ls -A /var/lib/ceph/osd 2>/dev/null)" + if [[ -n "$osd_dirs" ]] || pgrep -x ceph-osd >/dev/null 2>&1; then + echo -e "${RED}警告:检测到本机存在 OSD(数据盘)!卸载会销毁其上的所有存储数据,且不可恢复。${NC}" + fi + + if ! confirm_high_risk_action \ + "完全卸载本机 Ceph 并删除全部相关数据" \ + "所有 mon/mgr/mds/osd 数据目录会被永久删除,OSD 上的存储数据不可恢复" \ + "使用该 Ceph 存储的 VM/CT 磁盘将全部丢失;集群其他节点的 Ceph 状态也会受影响" \ + "请确认已迁移或备份 Ceph 上的所有数据,且该节点已按官方流程移出 Ceph 集群" \ + "DESTROY-CEPH"; then + return 1 + fi + + for unit in ceph-mon.target ceph-mgr.target ceph-mds.target ceph-osd.target; do + systemctl stop "$unit" 2>/dev/null || log_warn "停止 $unit 失败或该服务不存在,继续。" + done 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/* + killall -9 ceph-mon ceph-mgr ceph-mds ceph-osd 2>/dev/null + rm -rf /var/lib/ceph/mon/* /var/lib/ceph/mgr/* /var/lib/ceph/mds/* /var/lib/ceph/osd/* - pveceph purge + if command -v pveceph >/dev/null 2>&1; then + pveceph purge || log_warn "pveceph purge 未完全成功,继续清理残留文件。" + fi - apt purge -y ceph-mon ceph-osd ceph-mgr ceph-mds - apt purge -y ceph-base ceph-mgr-modules-core + if ! apt purge -y ceph-mon ceph-osd ceph-mgr ceph-mds; then + log_warn "部分 Ceph 核心包卸载失败,请稍后手动检查 dpkg 状态。" + fi + apt purge -y ceph-base ceph-mgr-modules-core || log_warn "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 + rm -rf /etc/ceph /etc/pve/ceph.conf /etc/pve/priv/ceph.* /var/log/ceph /etc/pve/ceph /var/lib/ceph + mkdir -p /etc/apt/backup [[ -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 log_success "已成功卸载ceph." } @@ -103,41 +136,23 @@ remove_ceph() { # 小工具配置 # FastPVE - PVE 虚拟机快速下载 ceph_management_menu() { - while true; do - clear + run_menu "Ceph管理" ceph_management_menu_render ceph_management_menu_dispatch "0-3" +} - 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 +ceph_management_menu_render() { + 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)" +} + +ceph_management_menu_dispatch() { + case "$1" in + 1) pve9_ceph ;; + 2) pve8_ceph ;; + 3) remove_ceph ;; + *) return 1 ;; + esac + return 0 } # 救砖:恢复官方 pve-qemu-kvm diff --git a/src/modules/07-storage-disk/init.sh b/src/modules/07-storage-disk/init.sh index 22be6ff..127d6fb 100644 --- a/src/modules/07-storage-disk/init.sh +++ b/src/modules/07-storage-disk/init.sh @@ -3,47 +3,50 @@ # 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 + run_menu "存储与硬盘" menu_storage_disk_render menu_storage_disk_dispatch "0-7" } -# 二级菜单:工具与关于 +menu_storage_disk_render() { + 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}" +} + +menu_storage_disk_dispatch() { + case "$1" in + 1) pve_storage_location_panel ;; + 2) pve_storage_mount_wizard ;; + 3) merge_local_storage ;; + 4) ceph_management_menu ;; + 5) storage_hdparm_sleep_config ;; + 6) garbage_cleanup_menu ;; + 7) remove_swap ;; + *) return 1 ;; + esac + return 0 +} + +# 硬盘休眠配置(hdparm -S 写入磁盘休眠参数) +storage_hdparm_sleep_config() { + lsblk -o NAME,MODEL,TYPE,SIZE,MOUNTPOINT | grep disk + local disk_name="" sleep_val="" + prompt_value disk_name "请输入要配置休眠的硬盘盘符 (如 sdb, 不含/dev/)" || return 0 + if [[ ! -b "/dev/$disk_name" ]]; then + display_error "未找到磁盘 /dev/$disk_name" + return 1 + fi + prompt_value sleep_val "请输入休眠时间 (1-255, 120=10分钟, 240=20分钟, 0=禁用)" || return 0 + if [[ ! "$sleep_val" =~ ^[0-9]+$ ]] || (( sleep_val > 255 )); then + display_error "无效的时间值: $sleep_val" "请输入 0-255 之间的数字" + return 1 + fi + if ! confirm_action "执行 hdparm -S $sleep_val /dev/$disk_name(向磁盘写入休眠参数)"; then + return 0 + fi + hdparm -S "$sleep_val" "/dev/$disk_name" + log_success "配置已应用到 /dev/$disk_name" +} diff --git a/src/modules/07-storage-disk/local-lvm.sh b/src/modules/07-storage-disk/local-lvm.sh index 747e8ee..bcd44f3 100644 --- a/src/modules/07-storage-disk/local-lvm.sh +++ b/src/modules/07-storage-disk/local-lvm.sh @@ -2,35 +2,73 @@ # SPDX-License-Identifier: GPL-3.0-only # Copyright (C) 2026 Ciriu Networks +# 根文件系统扩容:按实际文件系统类型分派,不写死 ext4(swap.sh 的 remove_swap 也复用) +storage_grow_root_fs() { + local device="$1" + local fstype + fstype="$(findmnt -no FSTYPE / 2>/dev/null)" + case "$fstype" in + ext2|ext3|ext4) + resize2fs "$device" + ;; + xfs) + xfs_growfs / + ;; + *) + log_error "不支持自动扩容的根文件系统类型: ${fstype:-未知},请手动扩容 $device" + return 1 + ;; + esac +} + merge_local_storage() { + local thin_lvs log_step "准备合并存储空间,让小硬盘发挥最大价值" log_warn "重要提醒:此操作会删除 local-lvm,请确保重要数据已备份!" - + + # 检查 local-lvm 是否存在(先检查再确认,避免对无操作场景要求确认词) + if ! lvdisplay /dev/pve/data &> /dev/null; then + log_warn "没有找到 local-lvm 分区,可能已经合并过了" + return + fi + + # thin pool 上仍有 VM 磁盘时禁止删除 + thin_lvs="$(lvs --noheadings -o lv_name -S 'pool_lv=data' pve 2>/dev/null | xargs)" + if [[ -n "$thin_lvs" ]]; then + log_error "local-lvm (pve/data) 上仍存在以下逻辑卷,禁止删除:" + echo " $thin_lvs" + log_tips "请先在 Web UI 迁移或删除这些 VM 磁盘后再执行合并。" + return 1 + fi + if ! confirm_high_risk_action \ "合并 local-lvm 到 local 存储" \ "将删除 /dev/pve/data 逻辑卷,所有 LVM-thin 上的 VM 磁盘和数据将被永久销毁。" \ - "执行 lvremove、lvextend、resize2fs,不可逆。仅在 root 为 ext 文件系统时有效。" \ + "执行 lvremove、lvextend、文件系统扩容,不可逆。" \ "请确保已将 local-lvm 上的所有 VM 磁盘迁移或备份。" \ "CONFIRM"; 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 - + if ! lvremove -f /dev/pve/data; then + log_error "删除 pve/data 失败,操作中止(未做任何扩容)" + return 1 + fi + log_info "正在扩容 local 分区..." - lvextend -l +100%FREE /dev/pve/root - + if ! lvextend -l +100%FREE /dev/pve/root; then + log_error "扩容 pve/root 失败,请手动执行: lvextend -l +100%FREE /dev/pve/root" + return 1 + fi + log_info "正在扩展文件系统..." - resize2fs /dev/pve/root - + if ! storage_grow_root_fs /dev/pve/root; then + log_error "LV 已扩容但文件系统扩展未完成,请按上方提示手动处理" + return 1 + fi + log_success "存储合并完成!现在空间更充裕了" log_warn "温馨提示:请在 Web UI 中删除 local-lvm 存储配置,并编辑 local 存储勾选所有内容类型" } diff --git a/src/modules/07-storage-disk/swap.sh b/src/modules/07-storage-disk/swap.sh index c055a41..9c240ee 100644 --- a/src/modules/07-storage-disk/swap.sh +++ b/src/modules/07-storage-disk/swap.sh @@ -3,41 +3,59 @@ # Copyright (C) 2026 Ciriu Networks remove_swap() { + local swap_dev log_step "准备释放 Swap 空间给系统使用" log_warn "注意:删除 Swap 后请确保内存充足!" - + + # 检查 swap 是否存在(先检查再确认,避免对无操作场景要求确认词) + if ! lvdisplay /dev/pve/swap &> /dev/null; then + log_warn "没有找到 swap 分区,可能已经删除过了" + return + fi + if ! confirm_high_risk_action \ "删除 Swap 分区并扩展 root 文件系统" \ "删除 /dev/pve/swap 逻辑卷,可能导致内存不足场景下系统不稳定。" \ - "将执行 swapoff、lvremove、lvextend、resize2fs,不可逆。" \ + "将执行 swapoff、lvremove、lvextend、文件系统扩容,不可逆。" \ "请确保内存充足(建议 >= 8GB),并已备份重要数据。" \ "CONFIRM"; 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 - + swap_dev="$(readlink -f /dev/mapper/pve-swap 2>/dev/null)" + if [[ -n "$swap_dev" ]] && grep -q "^${swap_dev} " /proc/swaps; then + if ! swapoff /dev/mapper/pve-swap; then + log_error "关闭 Swap 失败(可能内存不足以容纳换出页),操作中止,未做任何修改" + return 1 + fi + else + log_info "Swap 已处于关闭状态,跳过 swapoff" + fi + 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 - + if ! lvremove -f /dev/pve/swap; then + log_error "删除 swap 逻辑卷失败;Swap 已关闭且 fstab 已注释,可稍后重试或手动处理" + return 1 + fi + log_info "正在扩展系统分区..." - lvextend -l +100%FREE /dev/mapper/pve-root - + if ! lvextend -l +100%FREE /dev/mapper/pve-root; then + log_error "扩容 pve-root 失败,请手动执行: lvextend -l +100%FREE /dev/mapper/pve-root" + return 1 + fi + log_info "正在扩展文件系统..." - resize2fs /dev/mapper/pve-root - + if ! storage_grow_root_fs /dev/mapper/pve-root; then + log_error "LV 已扩容但文件系统扩展未完成,请按上方提示手动处理" + return 1 + fi + log_success "Swap 删除完成!系统空间更宽裕了" } diff --git a/src/modules/08-tools-about/init.sh b/src/modules/08-tools-about/init.sh index 1caceb1..5e3adb3 100644 --- a/src/modules/08-tools-about/init.sh +++ b/src/modules/08-tools-about/init.sh @@ -3,31 +3,30 @@ # 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 + run_menu "工具与关于" menu_tools_about_render menu_tools_about_dispatch "0-5" +} + +menu_tools_about_render() { + show_menu_option "1" "系统信息概览" + show_menu_option "2" "应急救砖工具箱" + show_menu_option "3" "本地脚本快捷更新" + show_menu_option "4" "${RED}本地脚本快捷卸载${NC}" + show_menu_option "5" "给作者点个 Star 吧" +} + +menu_tools_about_dispatch() { + case "$1" 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}" + ;; + *) return 1 ;; + esac + return 0 } # NOTE: show_menu_rescue() intentionally calls restore_proxmoxlib() (defined in @@ -36,48 +35,46 @@ menu_tools_about() { # dependency for the rescue/repair menu. Since all modules are sourced # globally, these symbols are always available. Moving to lib/ is the ideal # long-term approach but deferred. -# 一键配置 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_high_risk_action \ - "清理驱动黑名单" \ - "将修改 /etc/modprobe.d/pve-blacklist.conf 并运行 update-initramfs -u -k all。" \ - "错误还原可能导致显卡/声卡驱动冲突或系统无法启动。" \ - "请确认你需要移除黑名单中的显卡和声卡驱动限制。" \ - "CONFIRM"; then - log_info "正在清理黑名单配置..." - if ! backup_file "/etc/modprobe.d/pve-blacklist.conf"; then - log_error "无法备份 /etc/modprobe.d/pve-blacklist.conf,操作中止" - pause_function - break - fi - 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 + run_menu "应急救砖工具箱" show_menu_rescue_render show_menu_rescue_dispatch "0-3" } -# 二级菜单:系统优化 +show_menu_rescue_render() { + 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_rescue_dispatch() { + case "$1" in + 1) restore_proxmoxlib ;; + 2) restore_qemu_kvm ;; + 3) rescue_clear_driver_blacklist ;; + *) return 1 ;; + esac + return 0 +} + +rescue_clear_driver_blacklist() { + if ! confirm_high_risk_action \ + "清理驱动黑名单" \ + "将修改 /etc/modprobe.d/pve-blacklist.conf 并运行 update-initramfs -u -k all。" \ + "错误还原可能导致显卡/声卡驱动冲突或系统无法启动。" \ + "请确认你需要移除黑名单中的显卡和声卡驱动限制。" \ + "CONFIRM"; then + return 0 + fi + log_info "正在清理黑名单配置..." + if ! backup_file "/etc/modprobe.d/pve-blacklist.conf"; then + display_error "无法备份 /etc/modprobe.d/pve-blacklist.conf,操作中止" + return 1 + fi + 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 "黑名单清理完成,请重启系统" +} diff --git a/src/modules/08-tools-about/self-update.sh b/src/modules/08-tools-about/self-update.sh index 41f0e66..77a6ab3 100644 --- a/src/modules/08-tools-about/self-update.sh +++ b/src/modules/08-tools-about/self-update.sh @@ -108,7 +108,7 @@ check_update() { 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 + local remote_content remote_version detailed_changelog fallback_script_url downloaded_version if [[ -z "$current_script" || ! -f "$current_script" ]]; then display_error "无法定位当前脚本文件" "请使用本地文件方式运行脚本后再执行更新。" @@ -218,6 +218,12 @@ pve_tools_local_update() { return 1 fi + # VERSION 文件跟随 main 分支,Release 资产可能尚未同步发版,两者不一致时提示但不阻断 + downloaded_version="$(grep -m1 '^CURRENT_VERSION=' "$tmp_script" | cut -d'"' -f2)" + if [[ -n "$downloaded_version" && "$downloaded_version" != "$remote_version" ]]; then + log_warn "下载脚本版本为 ${downloaded_version},与远程 VERSION (${remote_version}) 不一致,可能 Release 尚未同步发布。" + fi + if ! cp -a "$tmp_script" "$resolved_script"; then cp -a "$backup_path" "$resolved_script" >/dev/null 2>&1 || true rm -f "$tmp_script" diff --git a/src/modules/09-security/cve-fix.sh b/src/modules/09-security/cve-fix.sh index 8eafc1b..5600ab3 100644 --- a/src/modules/09-security/cve-fix.sh +++ b/src/modules/09-security/cve-fix.sh @@ -673,125 +673,108 @@ security_cve_batch_mitigate() { # ============================================================ security_cve_menu() { - while true; do - clear - show_menu_header "CVE 漏洞修补中心" + run_menu "CVE 漏洞修补中心" security_cve_menu_render security_cve_menu_dispatch "0-5" +} - echo -e "${CYAN}已收录漏洞:${NC}" - echo " Januscape (CVE-2026-53359) KVM Guest-to-Host Escape" - echo " Dirty Frag (CVE-2026-43284/43500) Page-Cache Write LPE" - echo " Copy Fail Page-Cache Write LPE" - echo "$UI_DIVIDER" - show_menu_option "1" "Januscape ${CYAN}(KVM 逃逸 — 检测/修补/恢复)${NC}" - show_menu_option "2" "Dirty Frag ${RED}(本地提权 — 检测/修补/恢复)${NC}" - show_menu_option "3" "Copy Fail ${YELLOW}(本地提权 — 检测/修补/恢复)${NC}" - echo "$UI_DIVIDER" - show_menu_option "8" "一键批量修补 ${RED}(同时应用全部缓解措施)${NC}" - show_menu_option "9" "升级内核 ${GREEN}(永久修复所有 LPE 漏洞)${NC}" - echo "$UI_DIVIDER" - show_menu_option "0" "返回安全中心" - show_menu_footer +security_cve_menu_render() { + echo -e "${CYAN}已收录漏洞:${NC}" + echo " Januscape (CVE-2026-53359) KVM Guest-to-Host Escape" + echo " Dirty Frag (CVE-2026-43284/43500) Page-Cache Write LPE" + echo " Copy Fail Page-Cache Write LPE" + echo "$UI_DIVIDER" + show_menu_option "1" "Januscape ${CYAN}(KVM 逃逸 — 检测/修补/恢复)${NC}" + show_menu_option "2" "Dirty Frag ${RED}(本地提权 — 检测/修补/恢复)${NC}" + show_menu_option "3" "Copy Fail ${YELLOW}(本地提权 — 检测/修补/恢复)${NC}" + echo "$UI_DIVIDER" + show_menu_option "4" "一键批量修补 ${RED}(同时应用全部缓解措施)${NC}" + show_menu_option "5" "升级内核 ${GREEN}(永久修复所有 LPE 漏洞)${NC}" +} - local choice - read -rp "请选择操作 [0-9]: " choice - case "$choice" in - 1) security_cve_januscape_menu ;; - 2) security_cve_dirtyfrag_menu ;; - 3) security_cve_copyfail_menu ;; - 8) security_cve_batch_mitigate ;; - 9) security_cve_lpe_kernel_fix ;; - 0) return ;; - *) log_error "无效选择" ;; - esac - pause_function - done +security_cve_menu_dispatch() { + case "$1" in + 1) security_cve_januscape_menu ;; + 2) security_cve_dirtyfrag_menu ;; + 3) security_cve_copyfail_menu ;; + 4) security_cve_batch_mitigate ;; + 5) security_cve_lpe_kernel_fix ;; + *) return 1 ;; + esac + return 0 } # Januscape 子菜单 security_cve_januscape_menu() { - while true; do - clear - show_menu_header "Januscape (CVE-2026-53359) — KVM Guest-to-Host Escape" - echo " 影响: 2010-08 ~ 2026-06-19 | 修复: 81ccda30b4e8" - echo "$UI_DIVIDER" - show_menu_option "1" "漏洞检测 ${CYAN}(嵌套虚拟化 + 内核版本)${NC}" - show_menu_option "2" "临时修补 ${YELLOW}(禁用嵌套虚拟化,重启生效)${NC}" - show_menu_option "3" "升级内核修补 ${GREEN}(安装已修复内核,推荐)${NC}" - show_menu_option "4" "恢复嵌套虚拟化 ${RED}(确认内核已修复后使用)${NC}" - echo "$UI_DIVIDER" - show_menu_option "0" "返回" - show_menu_footer + run_menu "Januscape (CVE-2026-53359) — KVM Guest-to-Host Escape" security_cve_januscape_menu_render security_cve_januscape_menu_dispatch "0-4" +} - local choice - read -rp "请选择操作 [0-4]: " choice - case "$choice" in - 1) security_cve_januscape_detect ;; - 2) security_cve_januscape_mitigate ;; - 3) security_cve_januscape_kernel_fix ;; - 4) security_cve_januscape_restore ;; - 0) return ;; - *) log_error "无效选择" ;; - esac - pause_function - done +security_cve_januscape_menu_render() { + echo " 影响: 2010-08 ~ 2026-06-19 | 修复: 81ccda30b4e8" + echo "$UI_DIVIDER" + show_menu_option "1" "漏洞检测 ${CYAN}(嵌套虚拟化 + 内核版本)${NC}" + show_menu_option "2" "临时修补 ${YELLOW}(禁用嵌套虚拟化,重启生效)${NC}" + show_menu_option "3" "升级内核修补 ${GREEN}(安装已修复内核,推荐)${NC}" + show_menu_option "4" "恢复嵌套虚拟化 ${RED}(确认内核已修复后使用)${NC}" +} + +security_cve_januscape_menu_dispatch() { + case "$1" in + 1) security_cve_januscape_detect ;; + 2) security_cve_januscape_mitigate ;; + 3) security_cve_januscape_kernel_fix ;; + 4) security_cve_januscape_restore ;; + *) return 1 ;; + esac + return 0 } # Dirty Frag 子菜单 security_cve_dirtyfrag_menu() { - while true; do - clear - show_menu_header "Dirty Frag (CVE-2026-43284 + CVE-2026-43500) — LPE" - echo " 影响: 2017-01 ~ 2026-05 | 修复: f4c50a4034e6 / aa54b1d27fe0" - echo "$UI_DIVIDER" - show_menu_option "1" "漏洞检测 ${CYAN}(esp4/esp6/rxrpc 模块)${NC}" - show_menu_option "2" "临时修补 ${YELLOW}(禁用 esp4/esp6/rxrpc,立即生效)${NC}" - show_menu_option "3" "升级内核修补 ${GREEN}(安装已修复内核,推荐)${NC}" - show_menu_option "4" "恢复模块黑名单 ${RED}(确认内核已修复后使用)${NC}" - echo "$UI_DIVIDER" - show_menu_option "0" "返回" - show_menu_footer + run_menu "Dirty Frag (CVE-2026-43284 + CVE-2026-43500) — LPE" security_cve_dirtyfrag_menu_render security_cve_dirtyfrag_menu_dispatch "0-4" +} - local choice - read -rp "请选择操作 [0-4]: " choice - case "$choice" in - 1) security_cve_dirtyfrag_detect ;; - 2) security_cve_dirtyfrag_mitigate ;; - 3) security_cve_lpe_kernel_fix ;; - 4) security_cve_lpe_restore ;; - 0) return ;; - *) log_error "无效选择" ;; - esac - pause_function - done +security_cve_dirtyfrag_menu_render() { + echo " 影响: 2017-01 ~ 2026-05 | 修复: f4c50a4034e6 / aa54b1d27fe0" + echo "$UI_DIVIDER" + show_menu_option "1" "漏洞检测 ${CYAN}(esp4/esp6/rxrpc 模块)${NC}" + show_menu_option "2" "临时修补 ${YELLOW}(禁用 esp4/esp6/rxrpc,立即生效)${NC}" + show_menu_option "3" "升级内核修补 ${GREEN}(安装已修复内核,推荐)${NC}" + show_menu_option "4" "恢复模块黑名单 ${RED}(确认内核已修复后使用)${NC}" +} + +security_cve_dirtyfrag_menu_dispatch() { + case "$1" in + 1) security_cve_dirtyfrag_detect ;; + 2) security_cve_dirtyfrag_mitigate ;; + 3) security_cve_lpe_kernel_fix ;; + 4) security_cve_lpe_restore ;; + *) return 1 ;; + esac + return 0 } # Copy Fail 子菜单 security_cve_copyfail_menu() { - while true; do - clear - show_menu_header "Copy Fail — Page-Cache Write LPE" - echo " Dirty Pipe/Dirty Frag 同类漏洞 | 参考: https://copy.fail" - echo "$UI_DIVIDER" - show_menu_option "1" "漏洞检测 ${CYAN}(algif_aead 模块)${NC}" - show_menu_option "2" "临时修补 ${YELLOW}(禁用 algif_aead,立即生效)${NC}" - show_menu_option "3" "升级内核修补 ${GREEN}(安装已修复内核,推荐)${NC}" - show_menu_option "4" "恢复模块黑名单 ${RED}(确认内核已修复后使用)${NC}" - echo "$UI_DIVIDER" - echo -e "${RED}注意:Copy Fail 缓解可被 Dirty Frag 绕过,建议同时修补!${NC}" - echo "$UI_DIVIDER" - show_menu_option "0" "返回" - show_menu_footer - - local choice - read -rp "请选择操作 [0-4]: " choice - case "$choice" in - 1) security_cve_copyfail_detect ;; - 2) security_cve_copyfail_mitigate ;; - 3) security_cve_lpe_kernel_fix ;; - 4) security_cve_lpe_restore ;; - 0) return ;; - *) log_error "无效选择" ;; - esac - pause_function - done + run_menu "Copy Fail — Page-Cache Write LPE" security_cve_copyfail_menu_render security_cve_copyfail_menu_dispatch "0-4" +} + +security_cve_copyfail_menu_render() { + echo " Dirty Pipe/Dirty Frag 同类漏洞 | 参考: https://copy.fail" + echo "$UI_DIVIDER" + show_menu_option "1" "漏洞检测 ${CYAN}(algif_aead 模块)${NC}" + show_menu_option "2" "临时修补 ${YELLOW}(禁用 algif_aead,立即生效)${NC}" + show_menu_option "3" "升级内核修补 ${GREEN}(安装已修复内核,推荐)${NC}" + show_menu_option "4" "恢复模块黑名单 ${RED}(确认内核已修复后使用)${NC}" + echo "$UI_DIVIDER" + echo -e "${RED}注意:Copy Fail 缓解可被 Dirty Frag 绕过,建议同时修补!${NC}" +} + +security_cve_copyfail_menu_dispatch() { + case "$1" in + 1) security_cve_copyfail_detect ;; + 2) security_cve_copyfail_mitigate ;; + 3) security_cve_lpe_kernel_fix ;; + 4) security_cve_lpe_restore ;; + *) return 1 ;; + esac + return 0 } diff --git a/src/modules/09-security/init.sh b/src/modules/09-security/init.sh index 53f746e..f2f651a 100644 --- a/src/modules/09-security/init.sh +++ b/src/modules/09-security/init.sh @@ -3,26 +3,21 @@ # 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 "3" "CVE 漏洞修补 ${RED}(Januscape/内核漏洞)${NC}" - show_menu_option "0" "返回" - show_menu_footer - - local choice - read -p "请选择操作 [0-3]: " choice - case "$choice" in - 1) security_risk_check ;; - 2) security_ssh_hardening ;; - 3) security_cve_menu ;; - 0) return ;; - *) log_error "无效选择" ;; - esac - pause_function - done + run_menu "安全中心" security_center_menu_render security_center_menu_dispatch "0-3" } -# 温度监控管理菜单 +security_center_menu_render() { + show_menu_option "1" "安全风险检查 ${CYAN}(只读报告)${NC}" + show_menu_option "2" "SSH 一键加固 ${CYAN}(端口/密钥/fail2ban)${NC}" + show_menu_option "3" "CVE 漏洞修补 ${RED}(Januscape/内核漏洞)${NC}" +} + +security_center_menu_dispatch() { + case "$1" in + 1) security_risk_check ;; + 2) security_ssh_hardening ;; + 3) security_cve_menu ;; + *) return 1 ;; + esac + return 0 +} diff --git a/src/modules/10-third-party/coolercontrol.sh b/src/modules/10-third-party/coolercontrol.sh index f388bed..c6768a1 100644 --- a/src/modules/10-third-party/coolercontrol.sh +++ b/src/modules/10-third-party/coolercontrol.sh @@ -208,34 +208,31 @@ coolercontrol_uninstall() { 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 + run_menu "Cooler Control 管理器" coolercontrol_manager_menu_render coolercontrol_manager_menu_dispatch "0-4" +} + +coolercontrol_manager_menu_render() { + 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." +} + +coolercontrol_manager_menu_dispatch() { + case "$1" in + 1) coolercontrol_install ;; + 2) coolercontrol_update ;; + 3) coolercontrol_print_manual_install ;; + 4) coolercontrol_uninstall ;; + *) return 1 ;; + esac + return 0 } diff --git a/src/modules/10-third-party/init.sh b/src/modules/10-third-party/init.sh index 925bd05..c3230ad 100644 --- a/src/modules/10-third-party/init.sh +++ b/src/modules/10-third-party/init.sh @@ -3,28 +3,23 @@ # 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 "4" "IT87 Driver ${CYAN}(ITE 芯片风扇/传感器驱动)${NC}" - show_menu_option "0" "返回" - show_menu_footer - - local choice - read -p "请选择操作 [0-4]: " choice - case "$choice" in - 1) third_party_market_menu ;; - 2) coolercontrol_manager_menu ;; - 3) third_party_community_scripts_info ;; - 4) it87_manager_menu ;; - 0) return ;; - *) log_error "无效选择" ;; - esac - pause_function - done + run_menu "第三方工具" third_party_tools_menu_render third_party_tools_menu_dispatch "0-4" } -# PVE8 to PVE9 升级功能 +third_party_tools_menu_render() { + 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 "4" "IT87 Driver ${CYAN}(ITE 芯片风扇/传感器驱动)${NC}" +} + +third_party_tools_menu_dispatch() { + case "$1" in + 1) third_party_market_menu ;; + 2) coolercontrol_manager_menu ;; + 3) third_party_community_scripts_info ;; + 4) it87_manager_menu ;; + *) return 1 ;; + esac + return 0 +} diff --git a/src/modules/10-third-party/it87-driver.sh b/src/modules/10-third-party/it87-driver.sh index 081f115..4cdf49a 100644 --- a/src/modules/10-third-party/it87-driver.sh +++ b/src/modules/10-third-party/it87-driver.sh @@ -624,53 +624,48 @@ it87_uninstall() { # ---------- 主菜单 ---------- it87_manager_menu() { - while true; do - clear - show_menu_header "IT87 Driver 管理器" - - local status version chip - status=$(it87_detect_status) - version=$(it87_detect_version) - chip=$(it87_get_force_id) - - echo -e " 当前状态 : ${CYAN}${status}${NC}" - echo -e " 当前版本 : ${CYAN}${version}${NC}" - if [[ -n "$chip" ]]; then - echo -e " 芯片型号 : ${CYAN}force_id=${chip}${NC}" - else - echo -e " 芯片型号 : ${ORANGE}未设置${NC}" - fi - echo "$UI_DIVIDER" - show_menu_option "1" "安装 (DKMS 编译)" - show_menu_option "2" "安装 (直接编译, DKMS 备选)" - echo "$UI_DIVIDER" - show_menu_option "3" "选择芯片型号 (force_id)" - show_menu_option "4" "启用开机自启" - show_menu_option "5" "禁用开机自启" - show_menu_option "6" "查看传感器信息" - echo "$UI_DIVIDER" - show_menu_option "7" "卸载" - echo "$UI_DIVIDER" - echo " 安装后需要重启或 modprobe it87 加载驱动。" - echo " 传感器数据通过 sensors 命令查看。" - echo " 项目仓库: ${IT87_REPO_URL:-https://github.com/shauno8/it87.git}" - echo "$UI_DIVIDER" - show_menu_option "0" "返回" - show_menu_footer - - local choice - read -r -p "请选择操作 [0-7]: " choice - case "$choice" in - 1) it87_install_dkms ;; - 2) it87_install_direct ;; - 3) it87_force_id_menu ;; - 4) it87_enable_autostart ;; - 5) it87_disable_autostart ;; - 6) it87_show_sensors ;; - 7) it87_uninstall ;; - 0) return ;; - *) log_error "无效选择" ;; - esac - pause_function - done + run_menu "IT87 Driver 管理器" it87_manager_menu_render it87_manager_menu_dispatch "0-7" +} + +it87_manager_menu_render() { + local status version chip + status=$(it87_detect_status) + version=$(it87_detect_version) + chip=$(it87_get_force_id) + + echo -e " 当前状态 : ${CYAN}${status}${NC}" + echo -e " 当前版本 : ${CYAN}${version}${NC}" + if [[ -n "$chip" ]]; then + echo -e " 芯片型号 : ${CYAN}force_id=${chip}${NC}" + else + echo -e " 芯片型号 : ${ORANGE}未设置${NC}" + fi + echo "$UI_DIVIDER" + show_menu_option "1" "安装 (DKMS 编译)" + show_menu_option "2" "安装 (直接编译, DKMS 备选)" + echo "$UI_DIVIDER" + show_menu_option "3" "选择芯片型号 (force_id)" + show_menu_option "4" "启用开机自启" + show_menu_option "5" "禁用开机自启" + show_menu_option "6" "查看传感器信息" + echo "$UI_DIVIDER" + show_menu_option "7" "卸载" + echo "$UI_DIVIDER" + echo " 安装后需要重启或 modprobe it87 加载驱动。" + echo " 传感器数据通过 sensors 命令查看。" + echo " 项目仓库: ${IT87_REPO_URL:-https://github.com/shauno8/it87.git}" +} + +it87_manager_menu_dispatch() { + case "$1" in + 1) it87_install_dkms ;; + 2) it87_install_direct ;; + 3) it87_force_id_menu ;; + 4) it87_enable_autostart ;; + 5) it87_disable_autostart ;; + 6) it87_show_sensors ;; + 7) it87_uninstall ;; + *) return 1 ;; + esac + return 0 } diff --git a/src/modules/10-third-party/marketplace.sh b/src/modules/10-third-party/marketplace.sh index 364c929..f6b22b6 100644 --- a/src/modules/10-third-party/marketplace.sh +++ b/src/modules/10-third-party/marketplace.sh @@ -128,111 +128,106 @@ third_party_market_menu() { 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 + run_menu "第三方软件市场 (Modules)" third_party_market_menu_render third_party_market_menu_dispatch "0-${#valid_files[@]}" +} - 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 - - clear - show_menu_header "第三方脚本执行确认" - echo " 脚本名称: $selected_name" - echo " 作者: $selected_author" - echo " 版本: $selected_version" - echo " 来源: $selected_url" - echo "${UI_DIVIDER}" - echo " 本工具仅负责下载和执行,不审计脚本内容。" - echo " 第三方脚本可能修改系统配置、安装/卸载软件、访问网络。" - echo " 执行前请务必前往上述仓库审计脚本内容,并备份关键配置。" - echo "${UI_DIVIDER}" - if ! confirm_high_risk_action \ - "执行第三方脚本: $selected_name" \ - "将下载并直接执行该第三方脚本,PVE-Tools 不对其内容负责。" \ - "第三方脚本可能包含任意操作,包括修改系统配置、安装或卸载软件、访问网络资源。" \ - "建议先前往脚本仓库审计源码,备份关键配置,并保留控制台访问。" \ - "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 +# 渲染脚本列表(valid_* 数组与 download_cmd 来自 third_party_market_menu 的调用栈局部变量) +third_party_market_menu_render() { + 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 } + +third_party_market_menu_dispatch() { + local choice="$1" + if [[ ! "$choice" =~ ^[0-9]+$ ]] || (( choice < 1 || choice > ${#valid_files[@]} )); then + return 1 + fi + third_party_market_run_script "$((choice - 1))" + return 0 +} + +# 确认、下载并执行选中的第三方脚本(入参为 valid_* 数组下标) +third_party_market_run_script() { + local selected_idx="$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 + + clear + show_menu_header "第三方脚本执行确认" + echo " 脚本名称: $selected_name" + echo " 作者: $selected_author" + echo " 版本: $selected_version" + echo " 来源: $selected_url" + echo "${UI_DIVIDER}" + echo " 本工具仅负责下载和执行,不审计脚本内容。" + echo " 第三方脚本可能修改系统配置、安装/卸载软件、访问网络。" + echo " 执行前请务必前往上述仓库审计脚本内容,并备份关键配置。" + echo "${UI_DIVIDER}" + if ! confirm_high_risk_action \ + "执行第三方脚本: $selected_name" \ + "将下载并直接执行该第三方脚本,PVE-Tools 不对其内容负责。" \ + "第三方脚本可能包含任意操作,包括修改系统配置、安装或卸载软件、访问网络资源。" \ + "建议先前往脚本仓库审计源码,备份关键配置,并保留控制台访问。" \ + "RUN"; then + log_info "已取消执行 $selected_name" + return 0 + fi + + local tmp_script + if ! tmp_script=$(mktemp /tmp/pve-third-party-run.XXXXXX.sh); then + log_error "无法创建临时脚本文件" + return 1 + 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" + return 1 + 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 +} #---------FastPVE 虚拟机快速下载----------- # Community Scripts 提示