Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3ff9be315 |
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: test vet check build package
|
.PHONY: test vet shell-check check build package ui-check
|
||||||
|
|
||||||
test:
|
test:
|
||||||
(cd plugins/plugin-admin && go test -race ./... -count=1)
|
(cd plugins/plugin-admin && go test -race ./... -count=1)
|
||||||
@@ -8,11 +8,18 @@ vet:
|
|||||||
(cd plugins/plugin-admin && go vet ./...)
|
(cd plugins/plugin-admin && go vet ./...)
|
||||||
(cd plugins/subscription-admin && go vet ./...)
|
(cd plugins/subscription-admin && go vet ./...)
|
||||||
|
|
||||||
check: test vet
|
shell-check:
|
||||||
|
bash -n deploy/install.sh deploy/uninstall.sh scripts/install-local.sh tests/deploy-path-safety.sh
|
||||||
|
sh -n plugins/plugin-admin/build.sh plugins/plugin-admin/build-ui.sh plugins/plugin-admin/test/run-browser-check.sh
|
||||||
|
sh -n plugins/subscription-admin/build.sh plugins/subscription-admin/package.sh plugins/subscription-admin/test/run-browser-check.sh
|
||||||
|
bash tests/deploy-path-safety.sh
|
||||||
|
|
||||||
|
ui-check:
|
||||||
|
(cd plugins/plugin-admin/ui-vue && npm ci --ignore-scripts && npm run typecheck && npm run build)
|
||||||
|
|
||||||
|
check: test vet shell-check ui-check
|
||||||
node --check plugins/plugin-admin/ui/app.js
|
node --check plugins/plugin-admin/ui/app.js
|
||||||
node --check plugins/subscription-admin/ui/app.js
|
node --check plugins/subscription-admin/ui/app.js
|
||||||
sh -n plugins/plugin-admin/build.sh plugins/plugin-admin/test/run-browser-check.sh
|
|
||||||
sh -n plugins/subscription-admin/build.sh plugins/subscription-admin/package.sh plugins/subscription-admin/test/run-browser-check.sh
|
|
||||||
(cd plugins/subscription-admin && go run ./tools/manifestcheck)
|
(cd plugins/subscription-admin && go run ./tools/manifestcheck)
|
||||||
git diff --check
|
git diff --check
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ HTTP API、管理员鉴权和 `custom_menu_items` 接入 Core;插件不导入
|
|||||||
|
|
||||||
## 目录
|
## 目录
|
||||||
|
|
||||||
- `plugins/plugin-admin`:通用插件管理控制面,负责清单、签名、插件市场、
|
- `plugins/plugin-admin`:通用插件管理控制面,负责清单、业务包签名校验、插件市场、
|
||||||
下载入库、启用、停用、升级、回滚、删除、配置、健康检查、审计和菜单注入。
|
下载入库、启用、停用、升级、回滚、删除、配置、健康检查、审计和菜单注入。
|
||||||
- `plugins/subscription-admin`:可选的订阅管理业务模块后端。它不是插件管理
|
- `plugins/subscription-admin`:可选的订阅管理业务模块后端。它不是插件管理
|
||||||
控制面;只有安装并启用后才会挂载到 Plugin Admin 的统一导航中。
|
控制面;只有安装并启用后才会挂载到 Plugin Admin 的统一导航中。
|
||||||
@@ -19,8 +19,10 @@ TypeScript/Vite 构建,并把本地打包的 TDesign、线性图标和 ECharts
|
|||||||
Go 服务;官方 Core 的 `frontend/` 不参与构建。
|
Go 服务;官方 Core 的 `frontend/` 不参与构建。
|
||||||
|
|
||||||
控制面和业务模块后端可以独立构建和发布,但浏览器端只有一个 Plugin Admin
|
控制面和业务模块后端可以独立构建和发布,但浏览器端只有一个 Plugin Admin
|
||||||
登录入口。生产环境应使用独立的
|
登录入口。生产环境应使用独立的低权限服务账号、HTTPS 反向代理和稳定的
|
||||||
低权限服务账号、HTTPS 反向代理、签名包和稳定的 Core API 兼容基线。
|
Core API 兼容基线。业务 `.s2plugin` 归档必须使用受信发布者签名;Plugin
|
||||||
|
Admin 本身是独立的控制面服务,使用 immutable commit checkout 后本地编译,
|
||||||
|
不通过 `.s2plugin` 发布。
|
||||||
|
|
||||||
## 推荐部署顺序
|
## 推荐部署顺序
|
||||||
|
|
||||||
@@ -44,7 +46,7 @@ Core 仓库,也不要让插件连接 Core PostgreSQL/Redis。
|
|||||||
(cd plugins/subscription-admin && go run ./tools/manifestcheck)
|
(cd plugins/subscription-admin && go run ./tools/manifestcheck)
|
||||||
```
|
```
|
||||||
|
|
||||||
生成订阅插件包:
|
生成订阅业务插件包(Plugin Admin 本身不生成 `.s2plugin`):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
(cd plugins/subscription-admin && ./package.sh)
|
(cd plugins/subscription-admin && ./package.sh)
|
||||||
@@ -57,7 +59,10 @@ Core 仓库,也不要让插件连接 Core PostgreSQL/Redis。
|
|||||||
1. 启动 `plugin-admin` 和需要的业务模块后端,各自监听独立端口。
|
1. 启动 `plugin-admin` 和需要的业务模块后端,各自监听独立端口。
|
||||||
2. 使用 Core 管理员账号登录 Plugin Admin 一次;普通账号被拒绝,业务模块不再单独登录。
|
2. 使用 Core 管理员账号登录 Plugin Admin 一次;普通账号被拒绝,业务模块不再单独登录。
|
||||||
3. 在 `plugin-admin` 上传或从插件市场下载并校验业务插件包;包只进入“已入库,待启用”状态。
|
3. 在 `plugin-admin` 上传或从插件市场下载并校验业务插件包;包只进入“已入库,待启用”状态。
|
||||||
4. 配置 loopback `service_url`,点击启用并完成健康检查后,插件才会启动。
|
4. 对托管 command 插件,点击启用后由控制面启动进程并完成健康检查;对
|
||||||
|
`subscription-admin` 这类 external 插件,必须先由部署者或 systemd 启动
|
||||||
|
后端,再配置 loopback `service_url`,启用只负责探测和挂载,不会替外部服务
|
||||||
|
创建进程。
|
||||||
5. 预览、确认并应用插件声明的管理员菜单;停用后可删除插件。
|
5. 预览、确认并应用插件声明的管理员菜单;停用后可删除插件。
|
||||||
|
|
||||||
订阅模块进入统一控制面后使用 `/modules/subscription/*` 路由。Plugin Admin
|
订阅模块进入统一控制面后使用 `/modules/subscription/*` 路由。Plugin Admin
|
||||||
@@ -74,15 +79,29 @@ Linux + systemd 环境可直接使用仓库内的安装脚本:
|
|||||||
```sh
|
```sh
|
||||||
RELEASE_SHA=COMMIT_SHA_40_HEX
|
RELEASE_SHA=COMMIT_SHA_40_HEX
|
||||||
curl -fsSL "https://git.awaioi.com/awaioi/sub2api-add/raw/commit/${RELEASE_SHA}/deploy/install.sh" \
|
curl -fsSL "https://git.awaioi.com/awaioi/sub2api-add/raw/commit/${RELEASE_SHA}/deploy/install.sh" \
|
||||||
| sudo env PLUGIN_REF=v1.1.0 PLUGIN_COMMIT_SHA="$RELEASE_SHA" bash -s -- --plugin all
|
| sudo env PLUGIN_REF=v1.1.1 PLUGIN_COMMIT_SHA="$RELEASE_SHA" bash -s -- --plugin all
|
||||||
```
|
```
|
||||||
|
|
||||||
生产安装必须提供发布提交的 `PLUGIN_COMMIT_SHA`;上面的 `RELEASE_SHA`
|
生产安装必须提供发布提交的 `PLUGIN_COMMIT_SHA`;上面的 `RELEASE_SHA`
|
||||||
应从受信任的发布记录中复制,并与 `PLUGIN_REF` 对应。脚本默认使用
|
应从受信任的发布记录中复制,并与 `PLUGIN_REF` 对应。脚本默认使用
|
||||||
`v1.1.0` tag,但 tag 本身不作为完整性证明。可变分支和未 pin 的 tag
|
`v1.1.1` tag,但 tag 本身不作为完整性证明。可变分支和未 pin 的 tag
|
||||||
仅能在开发环境分别显式开启 `PLUGIN_ALLOW_MUTABLE_REF=true` 或
|
仅能在开发环境分别显式开启 `PLUGIN_ALLOW_MUTABLE_REF=true` 或
|
||||||
`PLUGIN_ALLOW_UNPINNED_TAG=true`。
|
`PLUGIN_ALLOW_UNPINNED_TAG=true`。
|
||||||
|
|
||||||
|
控制面回滚(保留旧 revision):
|
||||||
|
|
||||||
|
在插件详情的“版本”页选择目标 revision 执行回滚。等价 API 请求为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/plugins/{plugin_id}/rollback
|
||||||
|
X-CSRF-Token: <plugin csrf token>
|
||||||
|
Idempotency-Key: <unique key>
|
||||||
|
{"revision":"<retained revision id>"}
|
||||||
|
```
|
||||||
|
|
||||||
|
回滚会先健康检查目标 revision,成功后切换活动版本并更新菜单;失败时保留
|
||||||
|
当前活动版本。external 插件回滚前仍须确保其 `service_url` 对应服务已运行。
|
||||||
|
|
||||||
默认卸载并保留配置/数据:
|
默认卸载并保留配置/数据:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
+24
-5
@@ -24,7 +24,7 @@ docker compose -f docker-compose.yml up -d
|
|||||||
```sh
|
```sh
|
||||||
RELEASE_SHA=COMMIT_SHA_40_HEX
|
RELEASE_SHA=COMMIT_SHA_40_HEX
|
||||||
curl -fsSL "https://git.awaioi.com/awaioi/sub2api-add/raw/commit/${RELEASE_SHA}/deploy/install.sh" \
|
curl -fsSL "https://git.awaioi.com/awaioi/sub2api-add/raw/commit/${RELEASE_SHA}/deploy/install.sh" \
|
||||||
| sudo env PLUGIN_REF=v1.1.0 PLUGIN_COMMIT_SHA="$RELEASE_SHA" bash -s -- --plugin all
|
| sudo env PLUGIN_REF=v1.1.1 PLUGIN_COMMIT_SHA="$RELEASE_SHA" bash -s -- --plugin all
|
||||||
```
|
```
|
||||||
|
|
||||||
生产安装必须固定 `PLUGIN_COMMIT_SHA`,并从受信任的发布记录复制
|
生产安装必须固定 `PLUGIN_COMMIT_SHA`,并从受信任的发布记录复制
|
||||||
@@ -42,7 +42,7 @@ curl -fsSL https://git.awaioi.com/awaioi/sub2api-add/raw/branch/main/deploy/inst
|
|||||||
```sh
|
```sh
|
||||||
RELEASE_SHA=COMMIT_SHA_40_HEX
|
RELEASE_SHA=COMMIT_SHA_40_HEX
|
||||||
curl -fsSL "https://git.awaioi.com/awaioi/sub2api-add/raw/commit/${RELEASE_SHA}/deploy/install.sh" \
|
curl -fsSL "https://git.awaioi.com/awaioi/sub2api-add/raw/commit/${RELEASE_SHA}/deploy/install.sh" \
|
||||||
| sudo env PLUGIN_REF=v1.1.0 PLUGIN_COMMIT_SHA="$RELEASE_SHA" bash -s -- --plugin plugin-admin
|
| sudo env PLUGIN_REF=v1.1.1 PLUGIN_COMMIT_SHA="$RELEASE_SHA" bash -s -- --plugin plugin-admin
|
||||||
```
|
```
|
||||||
|
|
||||||
安装订阅插件:
|
安装订阅插件:
|
||||||
@@ -50,7 +50,7 @@ curl -fsSL "https://git.awaioi.com/awaioi/sub2api-add/raw/commit/${RELEASE_SHA}/
|
|||||||
```sh
|
```sh
|
||||||
RELEASE_SHA=COMMIT_SHA_40_HEX
|
RELEASE_SHA=COMMIT_SHA_40_HEX
|
||||||
curl -fsSL "https://git.awaioi.com/awaioi/sub2api-add/raw/commit/${RELEASE_SHA}/deploy/install.sh" \
|
curl -fsSL "https://git.awaioi.com/awaioi/sub2api-add/raw/commit/${RELEASE_SHA}/deploy/install.sh" \
|
||||||
| sudo env PLUGIN_REF=v1.1.0 PLUGIN_COMMIT_SHA="$RELEASE_SHA" bash -s -- --plugin subscription-admin
|
| sudo env PLUGIN_REF=v1.1.1 PLUGIN_COMMIT_SHA="$RELEASE_SHA" bash -s -- --plugin subscription-admin
|
||||||
```
|
```
|
||||||
|
|
||||||
默认安装位置:
|
默认安装位置:
|
||||||
@@ -85,11 +85,28 @@ sudo systemctl restart sub2api-plugin-admin sub2api-subscription-admin
|
|||||||
与 `127.0.0.1:8091`。
|
与 `127.0.0.1:8091`。
|
||||||
2. 登录 plugin-admin,上传业务插件包,或在插件市场选择目录版本。
|
2. 登录 plugin-admin,上传业务插件包,或在插件市场选择目录版本。
|
||||||
3. 控制面完成签名、哈希和 Core 兼容性校验后,仅将包登记为“已入库,待启用”,不会启动进程。
|
3. 控制面完成签名、哈希和 Core 兼容性校验后,仅将包登记为“已入库,待启用”,不会启动进程。
|
||||||
4. 配置业务插件的 loopback `service_url`,再点击启用;健康检查通过后才算安装完成。
|
4. 对 external 业务插件,先由部署者启动对应后端,再配置 loopback `service_url`;
|
||||||
|
对 command 插件,点击启用时控制面才会启动进程。两种模式都必须通过健康和
|
||||||
|
就绪检查后才算安装完成。
|
||||||
5. 预览、确认并应用插件声明的管理员菜单。
|
5. 预览、确认并应用插件声明的管理员菜单。
|
||||||
|
|
||||||
订阅插件是可选项;未安装或未应用菜单时,Core 管理员菜单不会出现“订阅管理”。
|
订阅插件是可选项;未安装或未应用菜单时,Core 管理员菜单不会出现“订阅管理”。
|
||||||
|
|
||||||
|
## 版本回滚
|
||||||
|
|
||||||
|
Plugin Admin 会在升级时保留旧 revision。生产回滚建议在插件详情的“版本”页
|
||||||
|
选择目标 revision;也可以调用下面的受保护 API(需要 Plugin Admin 会话的
|
||||||
|
CSRF token 和唯一 `Idempotency-Key`):
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/plugins/{plugin_id}/rollback
|
||||||
|
{"revision":"<retained revision id>"}
|
||||||
|
```
|
||||||
|
|
||||||
|
控制面会先启动并探测目标 revision,健康后再停止当前版本并原子切换注册表。
|
||||||
|
探测失败不会替换当前活动版本。external 插件回滚前必须先确保目标版本的
|
||||||
|
独立服务已经监听 `service_url`;command 插件由控制面负责启动和停止。
|
||||||
|
|
||||||
## 一键卸载
|
## 一键卸载
|
||||||
|
|
||||||
默认卸载服务和二进制,但保留配置与插件数据,便于重新安装:
|
默认卸载服务和二进制,但保留配置与插件数据,便于重新安装:
|
||||||
@@ -121,4 +138,6 @@ sudo ./deploy/uninstall.sh --plugin subscription-admin
|
|||||||
```
|
```
|
||||||
|
|
||||||
再次运行安装脚本会重新构建并重启选定插件。安装脚本发现源码有未提交修改
|
再次运行安装脚本会重新构建并重启选定插件。安装脚本发现源码有未提交修改
|
||||||
时会中止,避免升级覆盖本地改动。
|
时会中止,避免升级覆盖本地改动。该脚本从 pinned commit 构建控制面和示例
|
||||||
|
业务后端;业务 `.s2plugin` 的生产签名由 `plugins/subscription-admin/package.sh`
|
||||||
|
和受信发布者密钥负责,Plugin Admin 不以 `.s2plugin` 归档交付。
|
||||||
|
|||||||
+13
-1
@@ -3,7 +3,7 @@ set -Eeuo pipefail
|
|||||||
|
|
||||||
REPO_URL=${PLUGIN_REPO_URL:-https://git.awaioi.com/awaioi/sub2api-add.git}
|
REPO_URL=${PLUGIN_REPO_URL:-https://git.awaioi.com/awaioi/sub2api-add.git}
|
||||||
SOURCE_DIR=${PLUGIN_SOURCE_DIR:-/opt/sub2api-add}
|
SOURCE_DIR=${PLUGIN_SOURCE_DIR:-/opt/sub2api-add}
|
||||||
REF=${PLUGIN_REF:-v1.1.0}
|
REF=${PLUGIN_REF:-v1.1.1}
|
||||||
EXPECTED_SHA=${PLUGIN_COMMIT_SHA:-}
|
EXPECTED_SHA=${PLUGIN_COMMIT_SHA:-}
|
||||||
ALLOW_MUTABLE_REF=${PLUGIN_ALLOW_MUTABLE_REF:-false}
|
ALLOW_MUTABLE_REF=${PLUGIN_ALLOW_MUTABLE_REF:-false}
|
||||||
DEPLOY_ENV=${PLUGIN_ENV:-production}
|
DEPLOY_ENV=${PLUGIN_ENV:-production}
|
||||||
@@ -22,10 +22,18 @@ validate_path_components() {
|
|||||||
[[ "$component" != "." && "$component" != ".." ]] || die "$label 不能包含 . 或 .. 路径组件"
|
[[ "$component" != "." && "$component" != ".." ]] || die "$label 不能包含 . 或 .. 路径组件"
|
||||||
current="$current/$component"
|
current="$current/$component"
|
||||||
[[ ! -L "$current" ]] || die "$label 的路径组件不能是符号链接:$current"
|
[[ ! -L "$current" ]] || die "$label 的路径组件不能是符号链接:$current"
|
||||||
|
[[ ! -e "$current" || -d "$current" ]] || die "$label 的路径组件不是目录:$current"
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensure_real_parent() {
|
||||||
|
local path=$1 label=$2 parent
|
||||||
|
parent=$(dirname -- "$path")
|
||||||
|
[[ -d "$parent" && ! -L "$parent" ]] || die "$label 的父目录必须是已存在的真实目录:$parent"
|
||||||
|
}
|
||||||
|
|
||||||
validate_path_components "$SOURCE_DIR" PLUGIN_SOURCE_DIR
|
validate_path_components "$SOURCE_DIR" PLUGIN_SOURCE_DIR
|
||||||
|
ensure_real_parent "$SOURCE_DIR" PLUGIN_SOURCE_DIR
|
||||||
if [[ "$SOURCE_DIR" != */sub2api-add && "${PLUGIN_ALLOW_CUSTOM_PATHS:-false}" != "true" ]]; then
|
if [[ "$SOURCE_DIR" != */sub2api-add && "${PLUGIN_ALLOW_CUSTOM_PATHS:-false}" != "true" ]]; then
|
||||||
die "PLUGIN_SOURCE_DIR 必须以 sub2api-add 结尾;如确需自定义路径请显式设置 PLUGIN_ALLOW_CUSTOM_PATHS=true"
|
die "PLUGIN_SOURCE_DIR 必须以 sub2api-add 结尾;如确需自定义路径请显式设置 PLUGIN_ALLOW_CUSTOM_PATHS=true"
|
||||||
fi
|
fi
|
||||||
@@ -57,6 +65,10 @@ if [[ -e "$SOURCE_DIR" && ! -d "$SOURCE_DIR/.git" ]]; then
|
|||||||
die "$SOURCE_DIR 已存在但不是本插件仓库;请设置 PLUGIN_SOURCE_DIR"
|
die "$SOURCE_DIR 已存在但不是本插件仓库;请设置 PLUGIN_SOURCE_DIR"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ ! -e "$SOURCE_DIR" ]]; then
|
||||||
|
ensure_real_parent "$SOURCE_DIR" PLUGIN_SOURCE_DIR
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ -d "$SOURCE_DIR/.git" ]]; then
|
if [[ -d "$SOURCE_DIR/.git" ]]; then
|
||||||
if [[ -n "$(git -C "$SOURCE_DIR" status --porcelain)" ]]; then
|
if [[ -n "$(git -C "$SOURCE_DIR" status --porcelain)" ]]; then
|
||||||
die "$SOURCE_DIR 有未提交修改,先清理后再升级"
|
die "$SOURCE_DIR 有未提交修改,先清理后再升级"
|
||||||
|
|||||||
+46
-4
@@ -22,12 +22,40 @@ validate_managed_root() {
|
|||||||
[[ "$component" != "." && "$component" != ".." ]] || die "$label 不能包含 . 或 .. 路径组件"
|
[[ "$component" != "." && "$component" != ".." ]] || die "$label 不能包含 . 或 .. 路径组件"
|
||||||
current="$current/$component"
|
current="$current/$component"
|
||||||
[[ ! -L "$current" ]] || die "$label 的路径组件不能是符号链接:$current"
|
[[ ! -L "$current" ]] || die "$label 的路径组件不能是符号链接:$current"
|
||||||
|
[[ ! -e "$current" || -d "$current" ]] || die "$label 的路径组件不是目录:$current"
|
||||||
done
|
done
|
||||||
if [[ "$value" != */sub2api-add && "${PLUGIN_ALLOW_CUSTOM_PATHS:-false}" != "true" ]]; then
|
if [[ "$value" != */sub2api-add && "${PLUGIN_ALLOW_CUSTOM_PATHS:-false}" != "true" ]]; then
|
||||||
die "$label 必须位于受管的 sub2api-add 目录;如确需自定义路径请显式设置 PLUGIN_ALLOW_CUSTOM_PATHS=true"
|
die "$label 必须位于受管的 sub2api-add 目录;如确需自定义路径请显式设置 PLUGIN_ALLOW_CUSTOM_PATHS=true"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensure_real_parent() {
|
||||||
|
local path=$1 label=$2 parent component current="" parts=()
|
||||||
|
parent=$(dirname -- "$path")
|
||||||
|
IFS='/' read -r -a parts <<< "${parent#/}"
|
||||||
|
for component in "${parts[@]}"; do
|
||||||
|
[[ -z "$component" ]] && continue
|
||||||
|
current="$current/$component"
|
||||||
|
[[ -d "$current" && ! -L "$current" ]] || die "$label 的父目录必须是已存在的真实目录:$current"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_real_dir() {
|
||||||
|
local path=$1 label=$2 component current="" parts=()
|
||||||
|
[[ -n "$path" && "$path" = /* ]] || die "$label 路径无效"
|
||||||
|
IFS='/' read -r -a parts <<< "${path#/}"
|
||||||
|
for component in "${parts[@]}"; do
|
||||||
|
[[ -z "$component" ]] && continue
|
||||||
|
current="$current/$component"
|
||||||
|
[[ -d "$current" && ! -L "$current" ]] || die "$label 必须是已存在的真实目录:$current"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_regular_target() {
|
||||||
|
local path=$1 label=$2
|
||||||
|
[[ ! -L "$path" ]] || die "$label 不能是符号链接:$path"
|
||||||
|
}
|
||||||
|
|
||||||
die() { printf '错误:%s\n' "$*" >&2; exit 1; }
|
die() { printf '错误:%s\n' "$*" >&2; exit 1; }
|
||||||
[[ $EUID -eq 0 ]] || die "请使用 root 或 sudo 运行"
|
[[ $EUID -eq 0 ]] || die "请使用 root 或 sudo 运行"
|
||||||
command -v systemctl >/dev/null 2>&1 || die "缺少 systemd/systemctl"
|
command -v systemctl >/dev/null 2>&1 || die "缺少 systemd/systemctl"
|
||||||
@@ -52,6 +80,11 @@ validate_managed_root "$ETC_DIR" PLUGIN_ETC_DIR
|
|||||||
validate_managed_root "$VAR_DIR" PLUGIN_VAR_DIR
|
validate_managed_root "$VAR_DIR" PLUGIN_VAR_DIR
|
||||||
validate_managed_root "$PREFIX" PLUGIN_INSTALL_PREFIX
|
validate_managed_root "$PREFIX" PLUGIN_INSTALL_PREFIX
|
||||||
validate_managed_root "$SOURCE_DIR" PLUGIN_SOURCE_DIR
|
validate_managed_root "$SOURCE_DIR" PLUGIN_SOURCE_DIR
|
||||||
|
ensure_real_parent "$ETC_DIR" PLUGIN_ETC_DIR
|
||||||
|
ensure_real_parent "$VAR_DIR" PLUGIN_VAR_DIR
|
||||||
|
ensure_real_parent "$PREFIX" PLUGIN_INSTALL_PREFIX
|
||||||
|
ensure_real_parent "$SOURCE_DIR" PLUGIN_SOURCE_DIR
|
||||||
|
ensure_real_dir /etc/systemd/system SYSTEMD_UNIT_DIR
|
||||||
if $PURGE && [[ "${PLUGIN_PURGE_SOURCE:-true}" == "true" && "$SOURCE_DIR" != "$PREFIX" && "${PLUGIN_ALLOW_CUSTOM_PATHS:-false}" != "true" ]]; then
|
if $PURGE && [[ "${PLUGIN_PURGE_SOURCE:-true}" == "true" && "$SOURCE_DIR" != "$PREFIX" && "${PLUGIN_ALLOW_CUSTOM_PATHS:-false}" != "true" ]]; then
|
||||||
die "为避免误删,--purge 默认要求 PLUGIN_SOURCE_DIR 与 PLUGIN_INSTALL_PREFIX 相同"
|
die "为避免误删,--purge 默认要求 PLUGIN_SOURCE_DIR 与 PLUGIN_INSTALL_PREFIX 相同"
|
||||||
fi
|
fi
|
||||||
@@ -64,28 +97,37 @@ fi
|
|||||||
|
|
||||||
for plugin in "${PLUGINS[@]}"; do
|
for plugin in "${PLUGINS[@]}"; do
|
||||||
unit="sub2api-$plugin.service"
|
unit="sub2api-$plugin.service"
|
||||||
|
unit_path="/etc/systemd/system/$unit"
|
||||||
|
if [[ -e "$unit_path" || -L "$unit_path" ]]; then
|
||||||
|
ensure_regular_target "$unit_path" SYSTEMD_UNIT
|
||||||
|
fi
|
||||||
systemctl disable --now "$unit" 2>/dev/null || true
|
systemctl disable --now "$unit" 2>/dev/null || true
|
||||||
rm -f -- "/etc/systemd/system/$unit"
|
rm -f -- "$unit_path"
|
||||||
# Keep the checked-out source clean so a later install can fast-forward it.
|
# Keep the checked-out source clean so a later install can fast-forward it.
|
||||||
# Only generated binaries are removed unless --purge is explicitly used.
|
# Only generated binaries are removed unless --purge is explicitly used.
|
||||||
|
if [[ -e "$PREFIX/$plugin/bin/$plugin" || -L "$PREFIX/$plugin/bin/$plugin" ]]; then
|
||||||
|
ensure_real_parent "$PREFIX/$plugin/bin/$plugin" PLUGIN_INSTALL_PREFIX
|
||||||
rm -f -- "$PREFIX/$plugin/bin/$plugin"
|
rm -f -- "$PREFIX/$plugin/bin/$plugin"
|
||||||
|
fi
|
||||||
if $PURGE; then
|
if $PURGE; then
|
||||||
plugin_root="$PREFIX/$plugin"
|
plugin_root="$PREFIX/$plugin"
|
||||||
if [[ -d "$plugin_root" && ! -L "$plugin_root" ]]; then
|
if [[ -d "$plugin_root" && ! -L "$plugin_root" ]]; then
|
||||||
|
ensure_real_parent "$plugin_root" PLUGIN_INSTALL_PREFIX
|
||||||
rm -rf -- "${plugin_root:?}"
|
rm -rf -- "${plugin_root:?}"
|
||||||
fi
|
fi
|
||||||
rm -f -- "$ETC_DIR/$plugin.env"
|
rm -f -- "$ETC_DIR/$plugin.env"
|
||||||
plugin_data="$VAR_DIR/$plugin"
|
plugin_data="$VAR_DIR/$plugin"
|
||||||
if [[ -d "$plugin_data" && ! -L "$plugin_data" ]]; then
|
if [[ -d "$plugin_data" && ! -L "$plugin_data" ]]; then
|
||||||
|
ensure_real_parent "$plugin_data" PLUGIN_VAR_DIR
|
||||||
rm -rf -- "${plugin_data:?}"
|
rm -rf -- "${plugin_data:?}"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
if $PURGE && [[ -d "$ETC_DIR" ]]; then rmdir "$ETC_DIR" 2>/dev/null || true; fi
|
if $PURGE && [[ -d "$ETC_DIR" && ! -L "$ETC_DIR" ]]; then rmdir "$ETC_DIR" 2>/dev/null || true; fi
|
||||||
if $PURGE && [[ -d "$VAR_DIR" ]]; then rmdir "$VAR_DIR" 2>/dev/null || true; fi
|
if $PURGE && [[ -d "$VAR_DIR" && ! -L "$VAR_DIR" ]]; then rmdir "$VAR_DIR" 2>/dev/null || true; fi
|
||||||
if $PURGE && [[ "${PLUGIN_PURGE_SOURCE:-true}" == "true" && "$SOURCE_DIR" == "$PREFIX" && -d "$SOURCE_DIR" && ! -L "$SOURCE_DIR" ]]; then rm -rf -- "$SOURCE_DIR"; fi
|
if $PURGE && [[ "${PLUGIN_PURGE_SOURCE:-true}" == "true" && "$SOURCE_DIR" == "$PREFIX" && -d "$SOURCE_DIR" && ! -L "$SOURCE_DIR" ]]; then ensure_real_parent "$SOURCE_DIR" PLUGIN_SOURCE_DIR; rm -rf -- "$SOURCE_DIR"; fi
|
||||||
|
|
||||||
if $PURGE; then
|
if $PURGE; then
|
||||||
userdel "$RUN_USER" 2>/dev/null || true
|
userdel "$RUN_USER" 2>/dev/null || true
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
| SEC-02 | 出站 | Core URL、重定向、代理和 SSRF | `TestHealthProbeRejectsRedirectAndRequiresReadiness`;loopback URL 校验 | passed |
|
| SEC-02 | 出站 | Core URL、重定向、代理和 SSRF | `TestHealthProbeRejectsRedirectAndRequiresReadiness`;loopback URL 校验 | passed |
|
||||||
| SEC-02A | 市场出站 | 索引/归档 HTTPS、精确主机 allowlist、DNS 私网拒绝、体积和重定向门禁 | `main_test.go:TestRemoteMarketplaceRequiresAllowlistAndExpiry`;`marketplace.go` 出站策略 | passed |
|
| SEC-02A | 市场出站 | 索引/归档 HTTPS、精确主机 allowlist、DNS 私网拒绝、体积和重定向门禁 | `main_test.go:TestRemoteMarketplaceRequiresAllowlistAndExpiry`;`marketplace.go` 出站策略 | passed |
|
||||||
| SEC-03 | 脱敏 | Core 响应和错误 | token/password/secret/cookie 不出现在响应和日志 | passed |
|
| SEC-03 | 脱敏 | Core 响应和错误 | token/password/secret/cookie 不出现在响应和日志 | passed |
|
||||||
| DEPLOY-01 | 发布完整性 | 生产安装提交 pin | `deploy/install.sh` 对 tag 要求 `PLUGIN_COMMIT_SHA`,并校验检出 commit;脚本可从 immutable commit URL 获取 | passed |
|
| DEPLOY-01 | 发布完整性 | 安装提交 pin | `deploy/install.sh` 对 tag 要求 `PLUGIN_COMMIT_SHA`,并校验检出 commit;脚本可从 immutable commit URL 获取 | passed (local) |
|
||||||
| DEPLOY-02 | 路径安全 | 安装/卸载根目录与父路径 | 安装路径拒绝根目录、`.`/`..` 和任一级符号链接父路径 | passed |
|
| DEPLOY-02 | 路径安全 | 安装/卸载根目录与父路径 | 安装路径拒绝根目录、`.`/`..` 和任一级符号链接父路径 | passed |
|
||||||
| MAN-01 | 清单 | 未知字段、尾随 JSON、路径跳转 | `plugins/plugin-admin/internal/manifest/manifest_test.go`;包上传 smoke | passed |
|
| MAN-01 | 清单 | 未知字段、尾随 JSON、路径跳转 | `plugins/plugin-admin/internal/manifest/manifest_test.go`;包上传 smoke | passed |
|
||||||
| MAN-02 | 签名 | Ed25519、key ID、哈希 | `manifest_test.go:TestSignatureAndKeyID`;生产不受信发布者路径 | passed |
|
| MAN-02 | 签名 | Ed25519、key ID、哈希 | `manifest_test.go:TestSignatureAndKeyID`;生产不受信发布者路径 | passed |
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
go test ./... -count=1
|
go test ./... -count=1
|
||||||
go vet ./...
|
go vet ./...
|
||||||
node --check <all-ui-scripts>
|
node --check <all-ui-scripts>
|
||||||
production build
|
release build (signed `.s2plugin` for business packages)
|
||||||
manifest verification
|
manifest verification
|
||||||
bash -n <all-shell-scripts>
|
bash -n <all-shell-scripts>
|
||||||
git diff --check
|
git diff --check
|
||||||
@@ -51,10 +51,10 @@ git diff --check
|
|||||||
|
|
||||||
## 本轮证据
|
## 本轮证据
|
||||||
|
|
||||||
- `plugins/plugin-admin` 和 `plugins/subscription-admin`:`go test -race ./...`、`go vet ./...`、`node --check ui/app.js` 均通过。
|
- `plugins/plugin-admin` 和 `plugins/subscription-admin`:`go test -race ./...`、`go vet ./...`、`node --check ui/app.js` 均通过;`make check` 还覆盖 UI typecheck/build、全部 shell 语法和部署路径安全回归。
|
||||||
- `plugins/subscription-admin/package.sh` 生成的 `.s2plugin` 已通过 `unzip -t`,并通过控制面真实上传接口进入 `disabled` 状态。
|
- `plugins/subscription-admin/package.sh` 生成的 `.s2plugin` 已通过 `unzip -t`,并通过控制面真实上传接口进入 `disabled` 状态。
|
||||||
- 本地浏览器登录后,控制面首页、插件市场和操作记录可访问;普通登录与 Turnstile 模拟登录在 425、900、1440 视口均通过,无横向溢出或敏感响应字段。
|
- 本地浏览器登录后,控制面首页、插件市场和操作记录可访问;普通登录与 Turnstile 模拟登录在 425、900、1440 视口均通过,无横向溢出或敏感响应字段。
|
||||||
- 仍需部署环境追加:真实生产签名密钥、跨实例共享会话、真实 Core iframe 刷新和跨节点升级演练;这些属于部署级验证,不改变本地 V1 控制面契约。
|
- 仍需部署环境追加:真实生产签名密钥、发布归档/市场索引、跨实例共享会话、真实 Core iframe 刷新和跨节点升级演练;这些属于部署级门禁,不宣称已在本地完成,也不改变本地 V1 控制面契约。
|
||||||
|
|
||||||
浏览器脚本通过 `PLUGIN_SCREENSHOT_DIR` 输出 425px、900px、1440px 截图;本轮使用系统 Chrome 运行普通控制面、Turnstile fixture 和订阅模块三套验收,截图保留在本机临时证据目录。
|
浏览器脚本通过 `PLUGIN_SCREENSHOT_DIR` 输出 425px、900px、1440px 截图;本轮使用系统 Chrome 运行普通控制面、Turnstile fixture 和订阅模块三套验收,截图保留在本机临时证据目录。
|
||||||
|
|
||||||
@@ -65,4 +65,6 @@ git diff --check
|
|||||||
- `plugins/plugin-admin/main_test.go:TestPluginLockSerializesLifecycleMutations` 验证同一插件生命周期互斥。
|
- `plugins/plugin-admin/main_test.go:TestPluginLockSerializesLifecycleMutations` 验证同一插件生命周期互斥。
|
||||||
- `plugins/plugin-admin/main_test.go:TestIdempotencyKeyRejectsDifferentOperationHash` 和 `TestIdempotencyKeyReplaysSameBodyAndRetainsFailedOperation` 验证服务端请求体指纹、失败终态保留与冲突拒绝。
|
- `plugins/plugin-admin/main_test.go:TestIdempotencyKeyRejectsDifferentOperationHash` 和 `TestIdempotencyKeyReplaysSameBodyAndRetainsFailedOperation` 验证服务端请求体指纹、失败终态保留与冲突拒绝。
|
||||||
- `plugins/plugin-admin/main_test.go:TestUpgradeDoesNotCarryEndpointAcrossServiceModes` 验证 command/external 模式不继承错误端点。
|
- `plugins/plugin-admin/main_test.go:TestUpgradeDoesNotCarryEndpointAcrossServiceModes` 验证 command/external 模式不继承错误端点。
|
||||||
|
- `tests/deploy-path-safety.sh` 验证安装、卸载和本地安装器拒绝符号链接父路径及缺失父目录,避免 root 操作越界。
|
||||||
|
- `plugins/plugin-admin/main_test.go:TestRecoverCommandPluginAfterRestart` 使用有界 5 秒启动窗口,验证解释器型 command 插件在慢启动环境下仍可恢复,超时会清理进程。
|
||||||
- `go test -race ./... -count=1`、`go vet ./...`、全部 UI/测试脚本 `node --check`、包构建、`manifestcheck`、`unzip -t` 和 `git diff --check` 已通过。
|
- `go test -race ./... -count=1`、`go vet ./...`、全部 UI/测试脚本 `node --check`、包构建、`manifestcheck`、`unzip -t` 和 `git diff --check` 已通过。
|
||||||
|
|||||||
@@ -24,12 +24,14 @@ PLUGIN_CONFIG_KEY=local-development-secret-at-least-32-chars \
|
|||||||
go run .
|
go run .
|
||||||
```
|
```
|
||||||
|
|
||||||
`PLUGIN_ALLOW_UNSIGNED=true` is a development-only switch. Production
|
`PLUGIN_ALLOW_UNSIGNED=true` is a development-only switch for business package
|
||||||
packages must contain `signature.json`, use Ed25519, and match a trusted key
|
uploads. Production `.s2plugin` packages must contain `signature.json`, use
|
||||||
from `PLUGIN_TRUSTED_PUBLISHERS` (a JSON object of key ID to base64 public
|
Ed25519, and match a trusted key from `PLUGIN_TRUSTED_PUBLISHERS` (a JSON
|
||||||
key). `PLUGIN_CONFIG_KEY` is required in every environment; use a randomly
|
object of key ID to base64 public key). Plugin Admin itself is deployed as a
|
||||||
generated secret in production and keep it stable across restarts so encrypted
|
pinned source checkout and binary, not as a `.s2plugin` package. `PLUGIN_CONFIG_KEY`
|
||||||
plugin configuration remains decryptable.
|
is required in every environment; use a randomly generated secret in production
|
||||||
|
and keep it stable across restarts so encrypted plugin configuration remains
|
||||||
|
decryptable.
|
||||||
|
|
||||||
When the optional subscription module is enabled, set `PLUGIN_PUBLIC_URL` to
|
When the optional subscription module is enabled, set `PLUGIN_PUBLIC_URL` to
|
||||||
the externally reachable Plugin Admin base URL (without `/admin`), for example
|
the externally reachable Plugin Admin base URL (without `/admin`), for example
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"plugin_id": "qiu.plugin-admin",
|
"plugin_id": "qiu.plugin-admin",
|
||||||
"name": "Business Plugin Control Plane",
|
"name": "Business Plugin Control Plane",
|
||||||
"version": "1.1.0",
|
"version": "1.1.1",
|
||||||
"core_api_baseline": "sub2api-0.1.183",
|
"core_api_baseline": "sub2api-0.1.183",
|
||||||
"tested_core_versions": ["0.1.183"],
|
"tested_core_versions": ["0.1.183"],
|
||||||
"capabilities": ["plugin.admin.v1"],
|
"capabilities": ["plugin.admin.v1"],
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
pluginID = "qiu.plugin-admin"
|
pluginID = "qiu.plugin-admin"
|
||||||
subscriptionPluginID = "qiu.subscription-admin"
|
subscriptionPluginID = "qiu.subscription-admin"
|
||||||
pluginVersion = "1.1.0"
|
pluginVersion = "1.1.1"
|
||||||
sessionCookieName = "plugin_admin_session"
|
sessionCookieName = "plugin_admin_session"
|
||||||
maxJSONBytes = 2 << 20
|
maxJSONBytes = 2 << 20
|
||||||
maxPackageBytes = 128 << 20
|
maxPackageBytes = 128 << 20
|
||||||
@@ -2668,18 +2668,25 @@ func (a *app) startRevision(p *pluginRecord, revisionID, processKey string) (str
|
|||||||
probe.Endpoint = endpoint
|
probe.Endpoint = endpoint
|
||||||
var probeErr error
|
var probeErr error
|
||||||
attempts := 1
|
attempts := 1
|
||||||
|
probeDelay := 50 * time.Millisecond
|
||||||
|
var startupDeadline time.Time
|
||||||
if pluginManifest.Backend.Command != "" {
|
if pluginManifest.Backend.Command != "" {
|
||||||
// A freshly spawned process can need a short interval before binding its
|
// A freshly spawned interpreter-backed process can need several seconds
|
||||||
// port. Retry the probe within the bounded startup window.
|
// before binding its port (notably on macOS under concurrent test load).
|
||||||
attempts = 20
|
// Keep the window bounded while avoiding a fixed one-second flake.
|
||||||
|
attempts = 100
|
||||||
|
startupDeadline = time.Now().Add(5 * time.Second)
|
||||||
}
|
}
|
||||||
for attempt := 0; attempt < attempts; attempt++ {
|
for attempt := 0; attempt < attempts; attempt++ {
|
||||||
|
if !startupDeadline.IsZero() && time.Now().After(startupDeadline) {
|
||||||
|
break
|
||||||
|
}
|
||||||
probeErr = a.checkPluginHealth(&probe)
|
probeErr = a.checkPluginHealth(&probe)
|
||||||
if probeErr == nil {
|
if probeErr == nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if attempt+1 < attempts {
|
if attempt+1 < attempts && (startupDeadline.IsZero() || time.Now().Add(probeDelay).Before(startupDeadline)) {
|
||||||
time.Sleep(50 * time.Millisecond)
|
time.Sleep(probeDelay)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if probeErr != nil {
|
if probeErr != nil {
|
||||||
|
|||||||
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@awaioi/plugin-admin-ui",
|
"name": "@awaioi/plugin-admin-ui",
|
||||||
"version": "1.1.0",
|
"version": "1.1.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@awaioi/plugin-admin-ui",
|
"name": "@awaioi/plugin-admin-ui",
|
||||||
"version": "1.1.0",
|
"version": "1.1.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"echarts": "^6.1.0",
|
"echarts": "^6.1.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@awaioi/plugin-admin-ui",
|
"name": "@awaioi/plugin-admin-ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.0",
|
"version": "1.1.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 127.0.0.1 --port 3002",
|
"dev": "vite --host 127.0.0.1 --port 3002",
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
</t-menu>
|
</t-menu>
|
||||||
<div class="aside-footer">
|
<div class="aside-footer">
|
||||||
<t-tag theme="success" variant="light" class="status-tag"><CheckCircleIcon size="14px" /> Core 会话正常</t-tag>
|
<t-tag theme="success" variant="light" class="status-tag"><CheckCircleIcon size="14px" /> Core 会话正常</t-tag>
|
||||||
<span class="version-text">Plugin Admin v1.1.0</span>
|
<span class="version-text">Plugin Admin v1.1.1</span>
|
||||||
</div>
|
</div>
|
||||||
</t-aside>
|
</t-aside>
|
||||||
<t-layout class="main-layout">
|
<t-layout class="main-layout">
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -13,7 +13,7 @@ PLUGIN_PORT=8091 \
|
|||||||
go run .
|
go run .
|
||||||
```
|
```
|
||||||
|
|
||||||
本地后端可打开 `http://127.0.0.1:8091/healthz` 进行服务契约调试;生产浏览器入口应从 Plugin Admin 的订阅模块路由进入。生产环境应通过 HTTPS 反向代理,并设置 `PLUGIN_COOKIE_SECURE=true`。默认情况下本服务只暴露健康检查、就绪检查和交接页,不暴露第二套登录、Cookie 或 Core 数据 API。`PLUGIN_STANDALONE_AUTH=true` 仅在 `PLUGIN_ENV=development` 且监听地址为 loopback 时生效,生产必须保持关闭。
|
本地后端可打开 `http://127.0.0.1:8091/healthz` 进行服务契约调试;生产浏览器入口应从 Plugin Admin 的订阅模块路由进入。生产环境应通过 HTTPS 反向代理,并设置 `PLUGIN_COOKIE_SECURE=true`。默认情况下本服务只暴露健康检查、就绪检查和交接页,不暴露第二套登录、Cookie 或 Core 数据 API。`PLUGIN_STANDALONE_AUTH=true` 仅在 `PLUGIN_ENV=development` 且监听地址为 loopback 时生效;该开发兼容模式会创建模块 Cookie,生产必须保持关闭。
|
||||||
|
|
||||||
## V1 范围
|
## V1 范围
|
||||||
|
|
||||||
@@ -78,8 +78,9 @@ node --check ui/app.js
|
|||||||
```
|
```
|
||||||
|
|
||||||
脚本生成 `dist/qiu.subscription-admin.s2plugin`,包内根文件名为
|
脚本生成 `dist/qiu.subscription-admin.s2plugin`,包内根文件名为
|
||||||
`manifest.json`,并包含清单声明哈希的 UI 文件。该模块采用外部服务模式:
|
`manifest.json`,并包含清单声明哈希的 UI 文件。该模块采用外部服务模式;
|
||||||
安装后先独立启动 `subscription-admin`,再在 `plugin-admin` 的配置中填写
|
归档只携带清单和 UI 资源,不会替部署者启动后端。安装后先独立启动
|
||||||
|
`subscription-admin`(或由 systemd 持续运行),再在 `plugin-admin` 的配置中填写
|
||||||
`service_url`(插件 loopback 地址),并为 Plugin Admin 设置
|
`service_url`(插件 loopback 地址),并为 Plugin Admin 设置
|
||||||
`PLUGIN_PUBLIC_URL`(例如 `https://CORE_ORIGIN/extensions/qiu.plugin-admin`)。
|
`PLUGIN_PUBLIC_URL`(例如 `https://CORE_ORIGIN/extensions/qiu.plugin-admin`)。
|
||||||
然后执行启用、健康检查和菜单应用。浏览器前端由 `plugin-admin` 统一挂载并共享
|
然后执行启用、健康检查和菜单应用。浏览器前端由 `plugin-admin` 统一挂载并共享
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
|
|
||||||
ROOT=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
ROOT=$(CDPATH=; cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
||||||
PREFIX=${PLUGIN_INSTALL_PREFIX:-/opt/sub2api-add}
|
PREFIX=${PLUGIN_INSTALL_PREFIX:-/opt/sub2api-add}
|
||||||
ETC_DIR=${PLUGIN_ETC_DIR:-/etc/sub2api-add}
|
ETC_DIR=${PLUGIN_ETC_DIR:-/etc/sub2api-add}
|
||||||
VAR_DIR=${PLUGIN_VAR_DIR:-/var/lib/sub2api-add}
|
VAR_DIR=${PLUGIN_VAR_DIR:-/var/lib/sub2api-add}
|
||||||
@@ -94,28 +94,67 @@ validate_install_root() {
|
|||||||
[[ "$component" != "." && "$component" != ".." ]] || die "$label 不能包含 . 或 .. 路径组件"
|
[[ "$component" != "." && "$component" != ".." ]] || die "$label 不能包含 . 或 .. 路径组件"
|
||||||
current="$current/$component"
|
current="$current/$component"
|
||||||
[[ ! -L "$current" ]] || die "$label 的路径组件不能是符号链接:$current"
|
[[ ! -L "$current" ]] || die "$label 的路径组件不能是符号链接:$current"
|
||||||
|
[[ ! -e "$current" || -d "$current" ]] || die "$label 的路径组件不是目录:$current"
|
||||||
done
|
done
|
||||||
if [[ "$value" != */sub2api-add && "${PLUGIN_ALLOW_CUSTOM_PATHS:-false}" != "true" ]]; then
|
if [[ "$value" != */sub2api-add && "${PLUGIN_ALLOW_CUSTOM_PATHS:-false}" != "true" ]]; then
|
||||||
die "$label 必须位于受管的 sub2api-add 目录;如确需自定义路径请显式设置 PLUGIN_ALLOW_CUSTOM_PATHS=true"
|
die "$label 必须位于受管的 sub2api-add 目录;如确需自定义路径请显式设置 PLUGIN_ALLOW_CUSTOM_PATHS=true"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensure_real_dir_tree() {
|
||||||
|
local value=$1 label=$2 component current=""
|
||||||
|
IFS='/' read -r -a parts <<< "${value#/}"
|
||||||
|
for component in "${parts[@]}"; do
|
||||||
|
[[ -z "$component" ]] && continue
|
||||||
|
current="$current/$component"
|
||||||
|
if [[ -L "$current" ]]; then
|
||||||
|
die "$label 的路径组件不能是符号链接:$current"
|
||||||
|
elif [[ -e "$current" ]]; then
|
||||||
|
[[ -d "$current" ]] || die "$label 的路径组件不是目录:$current"
|
||||||
|
else
|
||||||
|
mkdir -- "$current"
|
||||||
|
[[ -d "$current" && ! -L "$current" ]] || die "$label 创建了不安全的路径组件:$current"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_real_parent() {
|
||||||
|
local path=$1 label=$2 parent
|
||||||
|
parent=$(dirname -- "$path")
|
||||||
|
[[ -d "$parent" && ! -L "$parent" ]] || die "$label 的父目录必须是已存在的真实目录:$parent"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_regular_target() {
|
||||||
|
local path=$1 label=$2
|
||||||
|
[[ ! -L "$path" ]] || die "$label 不能是符号链接:$path"
|
||||||
|
}
|
||||||
|
|
||||||
validate_install_root "$PREFIX" PLUGIN_INSTALL_PREFIX
|
validate_install_root "$PREFIX" PLUGIN_INSTALL_PREFIX
|
||||||
validate_install_root "$ETC_DIR" PLUGIN_ETC_DIR
|
validate_install_root "$ETC_DIR" PLUGIN_ETC_DIR
|
||||||
validate_install_root "$VAR_DIR" PLUGIN_VAR_DIR
|
validate_install_root "$VAR_DIR" PLUGIN_VAR_DIR
|
||||||
|
ensure_real_dir_tree "$PREFIX" PLUGIN_INSTALL_PREFIX
|
||||||
|
ensure_real_dir_tree "$ETC_DIR" PLUGIN_ETC_DIR
|
||||||
|
ensure_real_dir_tree "$VAR_DIR" PLUGIN_VAR_DIR
|
||||||
|
ensure_real_dir_tree /etc/systemd/system SYSTEMD_UNIT_DIR
|
||||||
|
|
||||||
install_one() {
|
install_one() {
|
||||||
local name=$1 source="$ROOT/plugins/$1" env_file="$ETC_DIR/$1.env"
|
local name=$1 source="$ROOT/plugins/$1" env_file="$ETC_DIR/$1.env"
|
||||||
local binary_dir="$PREFIX/$1/bin" data_dir="$VAR_DIR/$1"
|
local binary_dir="$PREFIX/$1/bin" data_dir="$VAR_DIR/$1"
|
||||||
[[ -d "$source" ]] || die "插件目录不存在:$source"
|
[[ -d "$source" ]] || die "插件目录不存在:$source"
|
||||||
install -d -m 0755 "$binary_dir" "$data_dir" "$ETC_DIR"
|
ensure_real_dir_tree "$binary_dir" PLUGIN_INSTALL_PREFIX
|
||||||
|
ensure_real_dir_tree "$data_dir" PLUGIN_VAR_DIR
|
||||||
|
ensure_real_parent "$env_file" PLUGIN_ETC_DIR
|
||||||
|
ensure_regular_target "$env_file" PLUGIN_ETC_DIR
|
||||||
|
chmod 0755 "$binary_dir" "$data_dir"
|
||||||
if [[ ! -f "$env_file" ]]; then
|
if [[ ! -f "$env_file" ]]; then
|
||||||
install -m 0600 "$source/.env.example" "$env_file"
|
install -m 0600 "$source/.env.example" "$env_file"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$name" == plugin-admin ]]; then
|
if [[ "$name" == plugin-admin ]]; then
|
||||||
ensure_env_value "$env_file" PLUGIN_REGISTRY_DIR "$data_dir"
|
ensure_env_value "$env_file" PLUGIN_REGISTRY_DIR "$data_dir"
|
||||||
install -d -m 0700 "$data_dir/marketplace"
|
ensure_real_dir_tree "$data_dir/marketplace" PLUGIN_VAR_DIR
|
||||||
|
chmod 0700 "$data_dir/marketplace"
|
||||||
|
ensure_regular_target "$data_dir/marketplace/index.json" PLUGIN_MARKETPLACE_INDEX
|
||||||
if [[ ! -f "$data_dir/marketplace/index.json" && -f "$source/marketplace/index.example.json" ]]; then
|
if [[ ! -f "$data_dir/marketplace/index.json" && -f "$source/marketplace/index.example.json" ]]; then
|
||||||
install -m 0600 "$source/marketplace/index.example.json" "$data_dir/marketplace/index.json"
|
install -m 0600 "$source/marketplace/index.example.json" "$data_dir/marketplace/index.json"
|
||||||
fi
|
fi
|
||||||
@@ -126,14 +165,19 @@ install_one() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
local tmp="$binary_dir/.${name}.tmp"
|
local tmp="$binary_dir/.${name}.tmp"
|
||||||
|
ensure_real_parent "$tmp" PLUGIN_INSTALL_PREFIX
|
||||||
|
ensure_regular_target "$tmp" PLUGIN_INSTALL_PREFIX
|
||||||
info "构建 $name"
|
info "构建 $name"
|
||||||
(cd "$source" && CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o "$tmp" .)
|
(cd "$source" && CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o "$tmp" .)
|
||||||
chmod 0755 "$tmp"
|
chmod 0755 "$tmp"
|
||||||
|
ensure_regular_target "$binary_dir/$name" PLUGIN_INSTALL_PREFIX
|
||||||
mv -f "$tmp" "$binary_dir/$name"
|
mv -f "$tmp" "$binary_dir/$name"
|
||||||
chown -R "$RUN_USER:$RUN_USER" "$data_dir"
|
chown -R "$RUN_USER:$RUN_USER" "$data_dir"
|
||||||
chown "$RUN_USER:$RUN_USER" "$binary_dir/$name"
|
chown "$RUN_USER:$RUN_USER" "$binary_dir/$name"
|
||||||
chmod 0600 "$env_file"
|
chmod 0600 "$env_file"
|
||||||
|
|
||||||
|
ensure_real_parent "/etc/systemd/system/sub2api-$name.service" SYSTEMD_UNIT_DIR
|
||||||
|
ensure_regular_target "/etc/systemd/system/sub2api-$name.service" SYSTEMD_UNIT_DIR
|
||||||
cat > "/etc/systemd/system/sub2api-$name.service" <<EOF
|
cat > "/etc/systemd/system/sub2api-$name.service" <<EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Sub2API ${name} business plugin
|
Description=Sub2API ${name} business plugin
|
||||||
|
|||||||
Executable
+54
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
ROOT=$(CDPATH=; cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
||||||
|
TMP=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
|
||||||
|
expect_fail() {
|
||||||
|
local label=$1; shift
|
||||||
|
if "$@" >"$TMP/stdout" 2>"$TMP/stderr"; then
|
||||||
|
printf 'FAIL: %s unexpectedly succeeded\n' "$label" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
printf 'ok: %s\n' "$label"
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdir -p "$TMP/real" "$TMP/bin"
|
||||||
|
ln -s "$TMP/real" "$TMP/path-link"
|
||||||
|
|
||||||
|
# deploy/install.sh must reject an existing symlink component and a missing
|
||||||
|
# parent before git clone can create or write the checkout.
|
||||||
|
expect_fail 'deploy install rejects symlink component' \
|
||||||
|
env PLUGIN_SOURCE_DIR="$TMP/path-link/sub2api-add" PLUGIN_ALLOW_CUSTOM_PATHS=true \
|
||||||
|
bash "$ROOT/deploy/install.sh"
|
||||||
|
expect_fail 'deploy install rejects missing parent' \
|
||||||
|
env PLUGIN_SOURCE_DIR="$TMP/missing/sub2api-add" PLUGIN_ALLOW_CUSTOM_PATHS=true \
|
||||||
|
bash "$ROOT/deploy/install.sh"
|
||||||
|
|
||||||
|
# Stub host tools so install-local reaches path validation without requiring
|
||||||
|
# Go or a running systemd instance.
|
||||||
|
cat >"$TMP/bin/go" <<'EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
if [[ ${1:-} == version ]]; then printf 'go version go1.23.0 linux/amd64\n'; else exit 0; fi
|
||||||
|
EOF
|
||||||
|
cat >"$TMP/bin/systemctl" <<'EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
exit 0
|
||||||
|
EOF
|
||||||
|
chmod +x "$TMP/bin/go" "$TMP/bin/systemctl"
|
||||||
|
|
||||||
|
expect_fail 'install-local rejects symlink component' \
|
||||||
|
env PATH="$TMP/bin:/usr/bin:/bin" PLUGIN_ALLOW_CUSTOM_PATHS=true \
|
||||||
|
PLUGIN_INSTALL_PREFIX="$TMP/path-link/sub2api-add" \
|
||||||
|
PLUGIN_ETC_DIR="$TMP/etc/sub2api-add" PLUGIN_VAR_DIR="$TMP/var/sub2api-add" \
|
||||||
|
bash "$ROOT/scripts/install-local.sh" --plugin plugin-admin
|
||||||
|
|
||||||
|
expect_fail 'uninstall rejects symlink component' \
|
||||||
|
env PATH="$TMP/bin:/usr/bin:/bin" PLUGIN_ALLOW_CUSTOM_PATHS=true \
|
||||||
|
PLUGIN_INSTALL_PREFIX="$TMP/path-link/sub2api-add" \
|
||||||
|
PLUGIN_ETC_DIR="$TMP/etc/sub2api-add" PLUGIN_VAR_DIR="$TMP/var/sub2api-add" \
|
||||||
|
PLUGIN_SOURCE_DIR="$TMP/source/sub2api-add" \
|
||||||
|
bash "$ROOT/deploy/uninstall.sh" --plugin plugin-admin --yes
|
||||||
|
|
||||||
|
printf '%s\n' 'deploy path safety checks passed'
|
||||||
Reference in New Issue
Block a user