feat: 支持主动触发 NAS 安全更新
CI / config (push) Canceled after 0s
CI / test-frontend (push) Canceled after 0s
CI / test-rust (push) Canceled after 0s
CI / test-e2e (push) Canceled after 0s
CI / test-e2e-release (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / release (push) Canceled after 0s
CI / docker (push) Canceled after 0s
CI / docker-website (push) Canceled after 0s

Co-Authored-By: Codex <noreply@anthropic.com>
This commit is contained in:
2026-08-12 23:07:46 +08:00
co-authored by Codex
parent 16988e75b6
commit a9e653009a
7 changed files with 309 additions and 39 deletions
+84 -38
View File
@@ -1,54 +1,100 @@
# Plainleaf on ZSpace NAS
# Plainleaf 极空间部署
This deployment runs the account-managed Plainleaf server on the ZSpace NAS.
Build the amd64 image on the development machine, then transfer and load it on
the NAS; the NAS does not need Docker Hub access. Server configuration,
deployment files, and Markdown data remain separate:
Plainleaf 使用 Gitea Container Registry 发布镜像。本机发布脚本推送 `edge` 后会
立即通过 SSH 主动触发 NAS 上的更新服务,不运行定时器,也不会轮询 Registry。
只有镜像 ID 变化时才会重建 `plainleaf` 容器,不会执行 `compose down`、Docker
清理或删除数据卷。
- Deployment: `个人空间/docker/plainleaf`
- Server configuration: `个人空间/docker/plainleaf/data`
- Markdown space: `个人空间/笔记管理/Plainleaf`
固定目录和端口:
The server listens on NAS port `31230`, runs as the NAS account's UID/GID, and
has shell execution disabled. Accounts are stored as password hashes in the
server configuration directory. No password belongs in `.env`.
- 部署目录:`/data_s001/data/udata/real/13616066635/docker/plainleaf`
- root 更新配置:`/etc/plainleaf`
- 服务配置:`<部署目录>/data`,容器内为 `/data`
- Markdown:`/data_s001/data/udata/real/13616066635/笔记管理/Plainleaf`,容器内为 `/notes`
- NAS 端口:`31230`
- 镜像:`gitea.aichickenfarm.cn/wushenghua/plainleaf:edge`
The intended `.env` keys are:
Markdown 目录始终是数据真源,发布和更新都不会移动、重命名或改写已有文档。
## 日常发布
本机只需运行:
```bash
./scripts/publish-nas-image.sh
```
脚本要求 Git 工作区干净,然后构建 `linux/amd64` 镜像并同时推送两个标签:
- 当前 Git 短提交号,用于定位和人工回滚
- `edge`,供 NAS 自动更新
脚本不保存 Gitea 密码或令牌。首次使用时,如果 Docker 尚未登录 Registry,先在
本机可见终端执行一次:
```bash
docker login gitea.aichickenfarm.cn
```
建议使用 Gitea 访问令牌,不要把密码或令牌写入仓库、脚本或聊天。
## NAS 首次启用
首次安装需要一次 SSH 和 `sudo`,之后日常发布不再上传镜像包,也不需要重复输入
SSH 或 `sudo` 密码。安装时要完成以下动作:
1. 将 `compose.yaml` 和现有 `.env` 复制到 root 持有的 `/etc/plainleaf`,配置
文件名为 `/etc/plainleaf/plainleaf.env`,并把 `PLAINLEAF_IMAGE_TAG` 设为
`edge`。更新服务只读取该目录,个人账号不能修改 root 更新流程。
2. 将 `update-plainleaf.sh` 安装为 `/usr/local/sbin/plainleaf-update`。
3. 将 systemd 单元安装到 `/etc/systemd/system/plainleaf-update.service`。
4. 安装本机 SSH 公钥,让发布脚本可用 `BatchMode` 连接 NAS。
5. 安装 `plainleaf-update.sudoers`,其中只有一条受限规则,只允许 NAS 账号免密启动
`plainleaf-update.service`,不能免密执行其他 root 命令。
6. 执行 `systemctl daemon-reload`,再手动启动一次服务,确认拉取、重建和健康检查
成功。服务不会常驻,只有本机发布脚本主动调用时才运行。
部署 `.env` 不存放账号密码,保留以下字段:
```dotenv
PLAINLEAF_IMAGE_TAG=<git-short-sha>
PLAINLEAF_IMAGE_TAG=edge
PLAINLEAF_UID=1001
PLAINLEAF_GID=1001
PLAINLEAF_PORT=31230
PLAINLEAF_SERVER_PATH=/tmp/zfsv3/sata11/13616066635/data/docker/plainleaf/data
PLAINLEAF_SPACE_PATH=/tmp/zfsv3/sata11/13616066635/data/笔记管理/Plainleaf
PLAINLEAF_SERVER_PATH=/data_s001/data/udata/real/13616066635/docker/plainleaf/data
PLAINLEAF_SPACE_PATH=/data_s001/data/udata/real/13616066635/笔记管理/Plainleaf
```
## Single-user to account-managed migration
## 更新与回滚行为
1. Keep the existing Markdown directory unchanged and take a NAS snapshot or
file-level backup before deployment.
2. Create the empty `PLAINLEAF_SERVER_PATH` directory. It must be writable by
`PLAINLEAF_UID:PLAINLEAF_GID`.
3. Remove the old `PLAINLEAF_USER` line from `.env` and add
`PLAINLEAF_SERVER_PATH`.
4. Start this Compose project. An empty `/data` opens the setup wizard at
`https://<plainleaf-domain>/.setup/`.
5. In the wizard, create the administrator account. For the first space use
name `Plainleaf`, URL path `/`, and data directory `/notes`.
6. After setup, open `/.spaces/users` to add users and `/.spaces` to assign
space access. Administrators always retain access.
`plainleaf-update` 每次只做以下事情:
The migration does not move, rename, or rewrite any Markdown. `users.json`,
`spaces.json`, and the server session secret are written only below
`PLAINLEAF_SERVER_PATH`.
1. 验证 Compose 中只有 `plainleaf` 服务,且镜像来自固定 Gitea 仓库。
2. 给当前容器镜像保留 `rollback` 标签。
3. 仅执行 `docker compose pull plainleaf`。
4. 镜像 ID 未变化时直接退出;变化时仅重建 `plainleaf`。
5. 等待容器健康;失败时重新标记旧镜像并恢复旧容器。
## Rollback
新镜像启动失败时不会删除,方便后续排查。更新日志可通过以下命令只读查看:
Stop only the `plainleaf` container, restore the previous Compose file and its
`PLAINLEAF_USER`, and mount `PLAINLEAF_SPACE_PATH` at `/space` again. Do not
delete either data directory. Because the Markdown path never moved, the old
single-user container can read it immediately.
```bash
sudo systemctl status plainleaf-update.service
sudo journalctl -u plainleaf-update.service -n 100 --no-pager
```
Do not commit the real `.env` file. Keep HTTPS enabled before exposing the
account-managed server publicly.
## 账号模式初始化
空的 `/data` 会打开 `https://<Plainleaf 域名>/.setup/`。首个空间建议使用:
- 名称:`Plainleaf`
- URL 路径:`/`
- 数据目录:`/notes`
管理员可在 `/.spaces/users` 管理用户,在 `/.spaces` 分配空间访问权限。初始化和
更新都不会移动 Markdown;`users.json`、`spaces.json` 和会话密钥只写入 `/data`。
## 人工回滚
自动回滚失败时,可把 `.env` 的 `PLAINLEAF_IMAGE_TAG` 改为之前发布的 Git 短提交
号,再仅重建 `plainleaf` 服务。不要删除 `/data`、`/notes`、Docker volume,
也不要运行 `docker system prune` 或 `docker compose down`。
+1 -1
View File
@@ -2,7 +2,7 @@ name: plainleaf
services:
plainleaf:
image: plainleaf:${PLAINLEAF_IMAGE_TAG:-local}
image: gitea.aichickenfarm.cn/wushenghua/plainleaf:${PLAINLEAF_IMAGE_TAG:-edge}
container_name: plainleaf
restart: unless-stopped
user: "${PLAINLEAF_UID:-1001}:${PLAINLEAF_GID:-1001}"
+50
View File
@@ -3,6 +3,16 @@ import { expect, test } from "vitest";
const dockerfile = readFileSync("Dockerfile.nas", "utf8");
const compose = readFileSync("deploy/nas/compose.yaml", "utf8");
const publisher = readFileSync("scripts/publish-nas-image.sh", "utf8");
const updater = readFileSync("deploy/nas/update-plainleaf.sh", "utf8");
const updateService = readFileSync(
"deploy/nas/plainleaf-update.service",
"utf8",
);
const updateSudoers = readFileSync(
"deploy/nas/plainleaf-update.sudoers",
"utf8",
);
test("NAS image allows automatic setup, multi-space, and legacy mode detection", () => {
expect(dockerfile).toContain('ENTRYPOINT ["/usr/local/bin/plainleaf"]');
@@ -19,3 +29,43 @@ test("account-managed NAS compose separates server data from Markdown", () => {
expect(compose).toContain(":/data");
expect(compose).toContain(":/notes");
});
test("NAS deployment follows the Gitea edge image", () => {
expect(compose).toContain(
"gitea.aichickenfarm.cn/wushenghua/plainleaf:${PLAINLEAF_IMAGE_TAG:-edge}",
);
expect(publisher).toContain("--platform linux/amd64");
expect(publisher).toContain('--tag "${IMAGE_REPOSITORY}:${short_sha}"');
expect(publisher).toContain('--tag "${IMAGE_REPOSITORY}:edge"');
expect(publisher).toContain("--push");
expect(publisher).toContain("-o BatchMode=yes");
expect(publisher).toContain(
"sudo -n /usr/bin/systemctl start plainleaf-update.service",
);
});
test("automatic updates only recreate Plainleaf and preserve persistent data", () => {
expect(updater).toContain("pull plainleaf");
expect(updater).toContain("up --detach --no-deps --force-recreate plainleaf");
expect(updater).toContain("restore_previous_image");
expect(updater).not.toMatch(/compose(?:\[.*?\])?[^\n]*\bdown\b/);
expect(updater).not.toContain("prune");
expect(updater).not.toMatch(/\bvolume\s+(?:rm|remove)\b/);
expect(compose).toContain(":/data");
expect(compose).toContain(":/notes");
});
test("root service only runs the Plainleaf updater when explicitly triggered", () => {
expect(updateService).toContain("User=root");
expect(updateService).toContain("PLAINLEAF_DEPLOY_DIR=/etc/plainleaf");
expect(updateService).toContain("ExecStart=/usr/local/sbin/plainleaf-update");
expect(updater).toContain(
'readonly DEPLOY_DIR="${PLAINLEAF_DEPLOY_DIR:-/etc/plainleaf}"',
);
expect(updateSudoers.trim()).toBe(
"13616066635 ALL=(root) NOPASSWD: /usr/bin/systemctl start plainleaf-update.service",
);
expect(updateSudoers).not.toMatch(/NOPASSWD:\s*ALL/);
expect(publisher).not.toContain("StrictHostKeyChecking=no");
expect(publisher).not.toContain("sshpass");
});
+16
View File
@@ -0,0 +1,16 @@
[Unit]
Description=Update Plainleaf from the private Gitea registry
After=docker.service network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=root
Group=root
UMask=0077
Environment=PLAINLEAF_DEPLOY_DIR=/etc/plainleaf
ExecStart=/usr/local/sbin/plainleaf-update
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=read-only
+1
View File
@@ -0,0 +1 @@
13616066635 ALL=(root) NOPASSWD: /usr/bin/systemctl start plainleaf-update.service
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env bash
set -Eeuo pipefail
readonly DEPLOY_DIR="${PLAINLEAF_DEPLOY_DIR:-/etc/plainleaf}"
readonly COMPOSE_FILE="${DEPLOY_DIR}/compose.yaml"
readonly ENV_FILE="${DEPLOY_DIR}/plainleaf.env"
readonly EXPECTED_IMAGE_PREFIX="gitea.aichickenfarm.cn/wushenghua/plainleaf:"
readonly HEALTH_TIMEOUT_SECONDS="${PLAINLEAF_HEALTH_TIMEOUT_SECONDS:-120}"
compose=(docker compose --env-file "$ENV_FILE" --file "$COMPOSE_FILE")
log() {
printf '[%s] %s\n' "$(date '+%F %T')" "$*"
}
wait_for_healthy_container() {
local deadline=$((SECONDS + HEALTH_TIMEOUT_SECONDS))
local container_id state
while ((SECONDS < deadline)); do
container_id="$("${compose[@]}" ps --quiet plainleaf 2>/dev/null || true)"
if [[ -n "$container_id" ]]; then
state="$(docker inspect \
--format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' \
"$container_id" 2>/dev/null || true)"
case "$state" in
healthy | running)
return 0
;;
exited | dead)
return 1
;;
esac
fi
sleep 3
done
return 1
}
restore_previous_image() {
local previous_image_id="$1"
local target_image="$2"
if [[ -z "$previous_image_id" ]]; then
log "首次部署没有可回滚的旧镜像,请检查 Plainleaf 日志。"
return 1
fi
log "新版本启动失败,正在恢复旧镜像 ${previous_image_id}。"
docker image tag "$previous_image_id" "$target_image"
"${compose[@]}" up --detach --no-deps --force-recreate plainleaf
wait_for_healthy_container
}
if [[ ! -r "$COMPOSE_FILE" || ! -r "$ENV_FILE" ]]; then
log "缺少部署文件:${COMPOSE_FILE} 或 ${ENV_FILE}。"
exit 1
fi
mapfile -t services < <("${compose[@]}" config --services)
if [[ "${#services[@]}" -ne 1 || "${services[0]}" != "plainleaf" ]]; then
log "安全检查失败:该 Compose 必须只包含 plainleaf 服务。"
exit 1
fi
target_image="$("${compose[@]}" config --images)"
if [[ "$target_image" != "${EXPECTED_IMAGE_PREFIX}"* ]]; then
log "安全检查失败:不允许更新镜像 ${target_image}。"
exit 1
fi
current_container_id="$("${compose[@]}" ps --quiet plainleaf 2>/dev/null || true)"
current_image_id=""
if [[ -n "$current_container_id" ]]; then
current_image_id="$(docker inspect --format '{{.Image}}' "$current_container_id")"
rollback_image="${EXPECTED_IMAGE_PREFIX}rollback"
docker image tag "$current_image_id" "$rollback_image"
log "已保留回滚镜像 ${rollback_image}。"
fi
log "检查 ${target_image} 是否有新版本。"
"${compose[@]}" pull plainleaf
target_image_id="$(docker image inspect --format '{{.Id}}' "$target_image")"
if [[ -n "$current_image_id" && "$current_image_id" == "$target_image_id" ]]; then
log "当前已经是最新镜像,无需重建容器。"
exit 0
fi
log "发现新镜像 ${target_image_id},仅重建 plainleaf 服务。"
if ! "${compose[@]}" up --detach --no-deps --force-recreate plainleaf; then
restore_previous_image "$current_image_id" "$target_image"
exit 1
fi
if wait_for_healthy_container; then
log "Plainleaf 已更新并通过健康检查。"
exit 0
fi
docker logs --tail 100 plainleaf >&2 2>/dev/null || true
if restore_previous_image "$current_image_id" "$target_image"; then
log "已恢复旧版本;新版本仍保留在本机供排查。"
else
log "自动回滚失败,需要人工检查 Plainleaf。"
fi
exit 1
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -Eeuo pipefail
readonly IMAGE_REPOSITORY="gitea.aichickenfarm.cn/wushenghua/plainleaf"
readonly NAS_HOST="${PLAINLEAF_NAS_HOST:-192.168.31.68}"
readonly NAS_PORT="${PLAINLEAF_NAS_PORT:-10000}"
readonly NAS_USER="${PLAINLEAF_NAS_USER:-13616066635}"
repo_root="$(git rev-parse --show-toplevel)"
cd "$repo_root"
if [[ -n "$(git status --porcelain)" ]]; then
echo "发布已取消:工作区存在未提交修改。请先提交代码再发布。" >&2
exit 1
fi
if ! docker buildx version >/dev/null 2>&1; then
echo "发布已取消:当前 Docker 未安装或未启用 buildx。" >&2
exit 1
fi
git_sha="$(git rev-parse HEAD)"
short_sha="$(git rev-parse --short=8 HEAD)"
echo "正在构建并推送 Plainleaf 镜像:"
echo " ${IMAGE_REPOSITORY}:${short_sha}"
echo " ${IMAGE_REPOSITORY}:edge"
docker buildx build \
--platform linux/amd64 \
--build-arg "GITHUB_SHA=${git_sha}" \
--file Dockerfile.nas \
--tag "${IMAGE_REPOSITORY}:${short_sha}" \
--tag "${IMAGE_REPOSITORY}:edge" \
--provenance=false \
--push \
.
echo "镜像推送完成,正在触发 NAS 更新。"
ssh \
-o BatchMode=yes \
-o ConnectTimeout=8 \
-p "$NAS_PORT" \
"${NAS_USER}@${NAS_HOST}" \
"sudo -n /usr/bin/systemctl start plainleaf-update.service"
echo "发布完成,NAS 上的 Plainleaf 已更新并通过健康检查。"