release: simplify unsigned installation
This commit is contained in:
+3
-3
@@ -27,9 +27,9 @@ TALLYNOTE_INSTALL_PREFIX=./
|
||||
TALLYNOTE_UPDATE_METADATA_URL=https://git.awaioi.com/api/v1/repos/awaioi/TallyNote/releases/latest
|
||||
TALLYNOTE_UPDATE_ALLOWED_HOSTS=git.awaioi.com
|
||||
TALLYNOTE_UPDATE_MAX_MB=512
|
||||
# One-click/systemd updates require an Ed25519 signature over SHA256SUMS.
|
||||
# Keep this file root-readable and point to a root-managed public key.
|
||||
TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=true
|
||||
# SHA-256 is always required. Detached Ed25519 signatures are optional; set
|
||||
# this to true only when a root-managed public key is configured below.
|
||||
TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=false
|
||||
# TALLYNOTE_UPDATE_PUBLIC_KEY_FILE=/etc/tallynote/update-signing-key.pub
|
||||
TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS=60
|
||||
TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS=15
|
||||
|
||||
@@ -32,10 +32,9 @@ jobs:
|
||||
pnpm test
|
||||
- name: Build Linux release
|
||||
run: pnpm release:build "${GITHUB_REF_NAME#v}" ./release
|
||||
- name: Create and publish signed Gitea Release
|
||||
- name: Create and publish Gitea Release
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
TALLYNOTE_RELEASE_SIGNING_KEY: ${{ secrets.TALLYNOTE_RELEASE_SIGNING_KEY }}
|
||||
run: ./scripts/publish-gitea-release.sh "$GITHUB_REF_NAME" ./release
|
||||
|
||||
# Linux x86 (i386/i686) is intentionally not published: Node.js 24 and the
|
||||
|
||||
@@ -51,17 +51,15 @@ pnpm build:next
|
||||
|
||||
安装器正式支持 **Linux x86_64(x64)**,脚本和运行时也支持在对应原生 runner 上发布 **aarch64(arm64)**;当前仓库内置 workflow 只生成 x64,arm64 需要在原生 ARM64 runner 上单独构建并发布。ARMv7/ARM32 仅实验性支持;Linux x86 32 位(`i386`、`i686`、`ia32`)明确不支持,因为 Node.js 24 和项目原生依赖没有可维护的官方构建。不要在 32 位系统上强行安装。
|
||||
|
||||
发布包必须包含 `dist/`、生产依赖、匹配架构的 Node runtime、systemd 单元,以及 `SHA256SUMS` 和 `SHA256SUMS.sig`。安装器默认 dry-run,只有显式 `--apply` 才会下载或写盘;正式安装必须提供独立核对过的 Ed25519 公钥:
|
||||
发布包必须包含 `dist/`、生产依赖、匹配架构的 Node runtime、systemd 单元,以及 `SHA256SUMS`。签名文件 `SHA256SUMS.sig` 是可选增强校验,不需要为普通安装准备公钥。安装器默认直接安装最新版本:
|
||||
|
||||
```bash
|
||||
curl --proto '=https' --tlsv1.2 -fsSL \
|
||||
https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh \
|
||||
| sudo bash -s -- --apply --version 1.1.0 \
|
||||
--signing-key /root/tallynote-update.pub \
|
||||
--update-public-key-file /root/tallynote-update.pub
|
||||
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
指定版本时,脚本会从 `https://git.awaioi.com/awaioi/TallyNote/releases/download/v<版本>/` 获取归档、`SHA256SUMS` 和签名。也可以通过 `TALLYNOTE_REPOSITORY_URL`、`TALLYNOTE_RELEASE_API_URL`、`TALLYNOTE_RELEASE_ALLOWED_HOSTS` 和 `--release-base-url` 指向自己的仓库或受信 CDN。`--allow-unsigned` 仅供隔离开发机测试,不能用于公网或真实财务数据。
|
||||
脚本会从公开仓库的 latest Release 获取当前架构归档和 `SHA256SUMS`,并在安装前始终校验 SHA-256。也可以通过 `TALLYNOTE_REPOSITORY_URL`、`TALLYNOTE_RELEASE_API_URL`、`TALLYNOTE_RELEASE_ALLOWED_HOSTS` 和 `--release-base-url` 指向自己的仓库或受信 CDN。需要固定版本或预览时,仍可使用 `TALLYNOTE_VERSION`、`--version` 或 `--dry-run` 等高级选项。
|
||||
|
||||
如需额外启用签名校验,在环境中设置 `TALLYNOTE_INSTALL_REQUIRE_SIGNATURE=true` 并提供 `--signing-key`;不设置时不会要求公钥或 `SHA256SUMS.sig`。
|
||||
|
||||
已有安装默认拒绝降级到不高于当前版本;确需回退时显式使用 `--allow-downgrade`,正常更新不会覆盖当前或更高版本。
|
||||
|
||||
@@ -69,7 +67,7 @@ curl --proto '=https' --tlsv1.2 -fsSL \
|
||||
|
||||
升级有两种方式:
|
||||
|
||||
1. 后台进入“系统更新”,点击“检查更新”后确认版本。应用只会把经过 HTTPS、主机白名单、SHA-256 和 Ed25519 签名校验的请求写入队列;root 权限的 `tallynote-update.path`/`tallynote-update.service` 会重新获取配置源、验证签名,再执行停机、备份、切换和健康检查。Web 进程没有 `systemctl` 权限,队列中的 URL、文件地址和摘要不会直接驱动 root 下载。
|
||||
1. 后台进入“系统更新”,点击“检查更新”后确认版本。应用只会把经过 HTTPS、主机白名单和 SHA-256 校验的请求写入队列;如果显式配置了公钥,再额外验证 Ed25519 签名。root 权限的 `tallynote-update.path`/`tallynote-update.service` 会重新获取配置源,再执行停机、备份、切换和健康检查。Web 进程没有 `systemctl` 权限,队列中的 URL、文件地址和摘要不会直接驱动 root 下载。
|
||||
2. 手动执行 `sudo /usr/local/sbin/tallynote-update --rollback` 可切回上一份 release。更新失败会自动保留旧版本并尝试恢复;不要删除 `/var/lib/tallynote`。
|
||||
|
||||
更新任务详情按发起管理员隔离;失败信息在浏览器中使用固定提示,不暴露服务器路径、命令输出或上游响应。系统同一时刻只允许一个更新任务。
|
||||
@@ -82,17 +80,17 @@ curl --proto '=https' --tlsv1.2 -fsSL \
|
||||
|
||||
```bash
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm release:build 1.1.0 ./release
|
||||
pnpm release:build 1.1.1 ./release
|
||||
```
|
||||
|
||||
将生成的 `tallynote-<版本>-linux-<架构>-<libc>.tar.gz` 上传到同一个 Gitea Release。推荐由 `.gitea/workflows/release.yml` 自动执行 `scripts/publish-gitea-release.sh`,统一生成并上传 `SHA256SUMS` 与 `SHA256SUMS.sig`;当前仓库还没有首个 tag/release 时,后台会明确显示不可用,不会下载未验证文件。CI 需要 `GITEA_TOKEN` 和 `TALLYNOTE_RELEASE_SIGNING_KEY` secrets。
|
||||
将生成的 `tallynote-<版本>-linux-<架构>-<libc>.tar.gz` 上传到同一个 Gitea Release。推荐由 `.gitea/workflows/release.yml` 自动执行 `scripts/publish-gitea-release.sh`,统一生成并上传 `SHA256SUMS`;如果 CI 提供签名私钥,还会额外上传 `SHA256SUMS.sig`。CI 只需要 `GITEA_TOKEN`;签名私钥属于可选增强。
|
||||
|
||||
版本由 `package.json` 和 Git tag 双重约束:两者必须相同(例如 `1.1.0` 与 `v1.1.0`),workflow 会在构建前拒绝不一致的 tag。发布一个版本:
|
||||
版本由 `package.json` 和 Git tag 双重约束:两者必须相同(例如 `1.1.1` 与 `v1.1.1`),workflow 会在构建前拒绝不一致的 tag。发布一个版本:
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "release: 1.1.0"
|
||||
git tag -a v1.1.0 -m "TallyNote 1.1.0"
|
||||
git commit -m "release: 1.1.1"
|
||||
git tag -a v1.1.1 -m "TallyNote 1.1.1"
|
||||
git push origin main --follow-tags
|
||||
```
|
||||
|
||||
@@ -109,4 +107,4 @@ docker compose run --rm --no-deps tallynote node dist/server/cli/admin-init.js -
|
||||
|
||||
业务导出不是系统备份。停服后复制完整数据目录(数据库、WAL/SHM、`files/`、`staging/`、`exports/` 和更新任务文件),恢复时保持目录 `0700`、文件 `0600` 权限,并在启动前确保没有其他 TallyNote 进程使用该目录。更新器会在切换前额外写入 `/var/lib/tallynote-backups/`,但仍建议保留服务器级备份。
|
||||
|
||||
应用层会拒绝非 HTTPS 更新源、未匹配主机、无 SHA-256/签名的归档、路径穿越、特殊文件和符号链接;附件与导出下载需要登录并写入审计。拥有服务器文件权限的人仍然可以直接读取 SQLite 和附件,部署时应限制 SSH、备份和磁盘权限,并通过 HTTPS 反代访问。
|
||||
应用层会拒绝非 HTTPS 更新源、未匹配主机、无 SHA-256 的归档、路径穿越、特殊文件和符号链接;启用签名要求时也会拒绝无有效签名的归档。附件与导出下载需要登录并写入审计。拥有服务器文件权限的人仍然可以直接读取 SQLite 和附件,部署时应限制 SSH、备份和磁盘权限,并通过 HTTPS 反代访问。
|
||||
|
||||
+14
-17
@@ -6,48 +6,45 @@ TallyNote 的发布包必须在目标 Linux 架构上构建。`better-sqlite3`
|
||||
|
||||
## 自动发布
|
||||
|
||||
向 Gitea 推送符合 SemVer 的 tag(例如 `v1.1.0`)会触发 `.gitea/workflows/release.yml`:
|
||||
向 Gitea 推送符合 SemVer 的 tag(例如 `v1.1.1`)会触发 `.gitea/workflows/release.yml`:
|
||||
|
||||
1. 在 Linux runner 上安装依赖,执行 `pnpm check`、`pnpm test` 和 `pnpm release:build`。
|
||||
2. 由 `scripts/publish-gitea-release.sh` 计算所有归档的 `SHA256SUMS`。
|
||||
3. 用 Ed25519 私钥生成 `SHA256SUMS.sig`,通过 Gitea Releases API 创建/复用对应 Release,并幂等上传归档、清单和签名。
|
||||
3. 如果提供 Ed25519 私钥则生成 `SHA256SUMS.sig`,通过 Gitea Releases API 创建/复用对应 Release,并幂等上传归档、清单和可选签名。
|
||||
|
||||
在仓库的 Actions secrets 配置:
|
||||
|
||||
- `GITEA_TOKEN`:仅授予当前仓库 Release 写权限的 token。
|
||||
- `TALLYNOTE_RELEASE_SIGNING_KEY`:Ed25519 私钥 PEM。它只作为 CI secret 使用,绝不能提交到 Git。
|
||||
- `TALLYNOTE_RELEASE_SIGNING_KEY`:可选的 Ed25519 私钥 PEM。它只作为 CI secret 使用,绝不能提交到 Git。
|
||||
|
||||
也可以在 Linux 发布机上手动执行:
|
||||
|
||||
```bash
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm check && pnpm test
|
||||
pnpm release:build 1.1.0 ./release
|
||||
pnpm release:build 1.1.1 ./release
|
||||
GITHUB_REPOSITORY=awaioi/TallyNote \
|
||||
GITEA_TOKEN=... \
|
||||
TALLYNOTE_RELEASE_SIGNING_KEY_FILE=/root/secrets/tallynote-release.key \
|
||||
./scripts/publish-gitea-release.sh v1.1.0 ./release
|
||||
./scripts/publish-gitea-release.sh v1.1.1 ./release
|
||||
```
|
||||
|
||||
发布资产名称必须包含当前平台,例如 `tallynote-1.1.0-linux-x64-glibc.tar.gz`。同一个 Release 只保留一个 `SHA256SUMS` 和一个 `SHA256SUMS.sig`,清单签名覆盖其完整原文。
|
||||
发布资产名称必须包含当前平台,例如 `tallynote-1.1.1-linux-x64-glibc.tar.gz`。同一个 Release 只保留一个 `SHA256SUMS`;有签名时再保留一个 `SHA256SUMS.sig`,签名覆盖清单完整原文。
|
||||
|
||||
## curl 安装
|
||||
|
||||
安装器默认只做 dry-run;只有显式 `--apply` 才会下载或写盘。正式安装必须同时提供 Ed25519 公钥和 `SHA256SUMS.sig`,公钥应通过独立的受信渠道核对指纹。下面示例假设公钥已安全放在服务器 `/root/tallynote-update.pub`:
|
||||
安装器默认直接获取并安装 latest Release。它始终校验 `SHA256SUMS` 中的 SHA-256,不要求公钥或签名文件:
|
||||
|
||||
```bash
|
||||
curl --proto '=https' --tlsv1.2 -fsSL \
|
||||
https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh \
|
||||
| sudo bash -s -- --apply --version 1.1.0 \
|
||||
--signing-key /root/tallynote-update.pub \
|
||||
--update-public-key-file /root/tallynote-update.pub
|
||||
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
脚本会从 `https://git.awaioi.com/awaioi/TallyNote/releases/download/v<版本>/` 下载当前架构归档、`SHA256SUMS` 和 `SHA256SUMS.sig`,限制 HTTPS 重定向只能落在配置的受信主机,校验压缩/展开大小、条目数量、路径和特殊文件,再原子切换 `/opt/tallynote/current`。自定义仓库时同时设置 `TALLYNOTE_REPOSITORY_URL`、`TALLYNOTE_RELEASE_API_URL` 和 `TALLYNOTE_RELEASE_ALLOWED_HOSTS`;若使用独立 CDN,必须把 CDN 主机显式加入白名单。
|
||||
脚本会从 `https://git.awaioi.com/awaioi/TallyNote/releases/download/v<版本>/` 下载当前架构归档和 `SHA256SUMS`,限制 HTTPS 重定向只能落在配置的受信主机,校验压缩/展开大小、条目数量、路径和特殊文件,再原子切换 `/opt/tallynote/current`。自定义仓库时同时设置 `TALLYNOTE_REPOSITORY_URL`、`TALLYNOTE_RELEASE_API_URL` 和 `TALLYNOTE_RELEASE_ALLOWED_HOSTS`;若使用独立 CDN,必须把 CDN 主机显式加入白名单。需要预览时显式加 `--dry-run`,需要固定版本时使用 `--version`。
|
||||
|
||||
如需启用签名校验,设置 `TALLYNOTE_INSTALL_REQUIRE_SIGNATURE=true` 并提供 `--signing-key`;后台更新同样可通过 `TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=true` 和 `TALLYNOTE_UPDATE_PUBLIC_KEY_FILE` 开启。默认关闭签名要求,方便公开自维护仓库直接更新。
|
||||
|
||||
已有安装默认拒绝安装不高于当前版本的 release;只有在明确执行 `--allow-downgrade`(或设置 `TALLYNOTE_ALLOW_DOWNGRADE=true`)时才允许回退版本。
|
||||
|
||||
`--allow-unsigned` 只用于隔离的开发/测试主机,不能用于公网或保存真实财务数据的服务器。安装器拒绝预先存在的符号链接、非 root 拥有或对组/其他用户可写的安装、配置和备份目录。
|
||||
安装器拒绝预先存在的符号链接、非 root 拥有或对组/其他用户可写的安装、配置和备份目录。`--allow-unsigned` 作为旧版本兼容参数保留。
|
||||
|
||||
安装布局:
|
||||
|
||||
@@ -63,9 +60,9 @@ curl --proto '=https' --tlsv1.2 -fsSL \
|
||||
|
||||
## 后台一键更新
|
||||
|
||||
将环境文件中的 `TALLYNOTE_UPDATE_STRATEGY=systemd`、`TALLYNOTE_UPDATE_METADATA_URL`、`TALLYNOTE_UPDATE_ALLOWED_HOSTS` 和 `TALLYNOTE_UPDATE_PUBLIC_KEY_FILE` 配好后,后台“系统更新”会读取 Gitea 的 `/api/v1/repos/<owner>/<repo>/releases/latest`。检查结果只显示当前平台匹配且同时通过 SHA-256 与 Ed25519 签名验证的资产;缺少任一项时“更新”按钮保持禁用。
|
||||
将环境文件中的 `TALLYNOTE_UPDATE_STRATEGY=systemd`、`TALLYNOTE_UPDATE_METADATA_URL` 和 `TALLYNOTE_UPDATE_ALLOWED_HOSTS` 配好后,后台“系统更新”会读取 Gitea 的 `/api/v1/repos/<owner>/<repo>/releases/latest`。检查结果只显示当前平台匹配且通过 SHA-256 校验的资产;如果配置了 `TALLYNOTE_UPDATE_PUBLIC_KEY_FILE` 并启用签名要求,再额外验证 Ed25519 签名。
|
||||
|
||||
浏览器只能提交版本号和确认标志。Web 进程把受保护的任务文件交给 root 的 `tallynote-update.path`/`tallynote-update.service`,root runner 会重新读取配置源、重新下载并验证 metadata、清单和签名,不信任队列文件中的 URL 或摘要。更新前会备份数据,切换失败或健康检查失败会恢复旧版本;手动回滚:
|
||||
浏览器只能提交版本号和确认标志。Web 进程把受保护的任务文件交给 root 的 `tallynote-update.path`/`tallynote-update.service`,root runner 会重新读取配置源、重新下载并验证 metadata 和清单,不信任队列文件中的 URL 或摘要。更新前会备份数据,切换失败或健康检查失败会恢复旧版本;手动回滚:
|
||||
|
||||
```bash
|
||||
sudo /usr/local/sbin/tallynote-update --rollback
|
||||
|
||||
+32
-27
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
# TallyNote native installer. Dry-run by default; pass --apply to mutate the host.
|
||||
# TallyNote native installer. Installs the latest release by default; use
|
||||
# --dry-run to preview without changing the host.
|
||||
PATH=/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/bin
|
||||
export PATH
|
||||
umask 077
|
||||
@@ -20,9 +21,9 @@ SIGNING_KEY=${TALLYNOTE_SIGNING_KEY:-}
|
||||
SIGNATURE_FORMAT=${TALLYNOTE_SIGNATURE_FORMAT:-ed25519}
|
||||
SHA256_FILE=${TALLYNOTE_SHA256_FILE:-}
|
||||
UPDATE_PUBLIC_KEY_FILE=${TALLYNOTE_UPDATE_PUBLIC_KEY_FILE:-}
|
||||
APPLY=0
|
||||
APPLY=1
|
||||
KEEP_RELEASES=${TALLYNOTE_KEEP_RELEASES:-3}
|
||||
REQUIRE_SIGNATURE=${TALLYNOTE_INSTALL_REQUIRE_SIGNATURE:-true}
|
||||
REQUIRE_SIGNATURE=${TALLYNOTE_INSTALL_REQUIRE_SIGNATURE:-false}
|
||||
ALLOW_DOWNGRADE=${TALLYNOTE_ALLOW_DOWNGRADE:-false}
|
||||
ALLOW_UNSIGNED=0
|
||||
MAX_RELEASE_MB=${TALLYNOTE_MAX_RELEASE_MB:-512}
|
||||
@@ -51,17 +52,19 @@ RELEASE_API_URL=${RELEASE_API_URL%/}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: install.sh [--apply] [--version VERSION] [--release-base-url HTTPS_URL]
|
||||
Usage: install.sh [--dry-run] [--version VERSION] [--release-base-url HTTPS_URL]
|
||||
[--release-file FILE] [--sha256-url HTTPS_URL|--sha256-file FILE]
|
||||
[--signature-url HTTPS_URL] [--signing-key PUBLIC_KEY_FILE]
|
||||
[--signature-format ed25519|gpg]
|
||||
[--update-public-key-file FILE]
|
||||
[--keep-releases N] [--allow-downgrade] [--allow-unsigned] [--dry-run]
|
||||
[--keep-releases N] [--allow-downgrade] [--allow-unsigned] [--apply]
|
||||
|
||||
The default is --dry-run. Network downloads and filesystem changes happen only
|
||||
with --apply. Production installs require a detached signature (Ed25519 over
|
||||
SHA256SUMS by default; legacy GPG archive signatures are opt-in); --allow-unsigned
|
||||
is for isolated development hosts only.
|
||||
Without arguments, the installer resolves the latest compatible release and
|
||||
installs it. SHA-256 from SHA256SUMS is always required. Detached signature
|
||||
verification is optional by default; enable it with
|
||||
TALLYNOTE_INSTALL_REQUIRE_SIGNATURE=true and provide a public key. Use
|
||||
--dry-run to inspect the selected release without downloading or changing the
|
||||
host. --apply is accepted for backwards compatibility.
|
||||
EOF
|
||||
}
|
||||
die() { printf 'tallynote installer: %s\n' "$*" >&2; exit 1; }
|
||||
@@ -275,9 +278,9 @@ verify_archive() {
|
||||
[[ -n "$expected" ]] || die "checksum file has no entry for $archive_name"
|
||||
[[ "$expected" =~ ^[A-Fa-f0-9]{64}$ ]] || die 'checksum file does not contain a SHA-256 digest'
|
||||
printf '%s %s\n' "$expected" "$archive" | sha256sum -c - >/dev/null || die 'SHA-256 verification failed'
|
||||
if [[ "$REQUIRE_SIGNATURE" == true ]]; then
|
||||
[[ -n "$signature" && -s "$signature" ]] || die '发布包缺少 SHA256SUMS.sig;生产安装必须使用签名'
|
||||
[[ -n "$key" && -f "$key" && ! -L "$key" ]] || die '生产安装必须提供签名公钥(--signing-key FILE)'
|
||||
if [[ "$REQUIRE_SIGNATURE" == true || ( -n "$signature" && -n "$key" ) ]]; then
|
||||
[[ -n "$signature" && -s "$signature" ]] || die '发布包缺少签名文件(SHA256SUMS.sig 或 .asc)'
|
||||
[[ -n "$key" && -f "$key" && ! -L "$key" ]] || die '签名校验需要有效的公钥文件(--signing-key FILE)'
|
||||
[[ "$(stat_uid "$key")" == 0 ]] || die '更新公钥必须由 root 拥有'
|
||||
[[ "$(wc -c < "$key" | tr -d '[:space:]')" -le 16384 ]] || die '更新公钥文件过大'
|
||||
local key_bits
|
||||
@@ -314,7 +317,7 @@ verify_archive() {
|
||||
fi
|
||||
fi
|
||||
elif [[ -n "$signature" || -n "$key" ]]; then
|
||||
log 'warning: signature verification disabled by explicit --allow-unsigned'
|
||||
log 'warning: signature verification skipped; provide both a signature and public key, or enable TALLYNOTE_INSTALL_REQUIRE_SIGNATURE=true'
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -636,7 +639,7 @@ validate_existing_env() {
|
||||
value=$(read_env_value "$file" TALLYNOTE_DATA_DIR)
|
||||
[[ -z "$value" || "${value%/}" == "${DATA_DIR%/}" ]] || die '环境文件中的数据目录与本次安装不一致'
|
||||
value=$(read_env_value "$file" TALLYNOTE_UPDATE_REQUIRE_SIGNATURE)
|
||||
[[ -z "$value" || "$value" == true ]] || die '环境文件禁止关闭发布签名校验'
|
||||
[[ -z "$value" || "$value" == true || "$value" == false ]] || die '环境文件中的签名校验配置必须是 true 或 false'
|
||||
value=$(read_env_value "$file" TALLYNOTE_UPDATE_METADATA_URL)
|
||||
if [[ -n "$value" ]]; then
|
||||
validate_env_value "$value" '环境文件更新源'
|
||||
@@ -710,7 +713,7 @@ main() {
|
||||
if (( APPLY )) || [[ -n "${TALLYNOTE_UNAME_BIN+x}" ]]; then
|
||||
validate_trusted_tool "$UNAME_BIN" 'uname'
|
||||
fi
|
||||
if (( APPLY )) || [[ -n "${TALLYNOTE_OPENSSL_BIN+x}" ]]; then
|
||||
if [[ "$REQUIRE_SIGNATURE" == true || -n "$SIGNATURE_URL" || -n "$SIGNING_KEY" || -n "$UPDATE_PUBLIC_KEY_FILE" || -n "${TALLYNOTE_OPENSSL_BIN+x}" ]]; then
|
||||
validate_trusted_tool "$OPENSSL_BIN" 'openssl'
|
||||
fi
|
||||
detect_platform
|
||||
@@ -730,7 +733,7 @@ main() {
|
||||
if [[ "$VERSION" == "latest" ]]; then
|
||||
if (( ! APPLY )); then
|
||||
[[ -z "$RELEASE_BASE_URL" ]] || require_https "$RELEASE_BASE_URL"
|
||||
log 'version: latest (release lookup happens with --apply)'
|
||||
log 'version: latest (release lookup skipped in dry-run)'
|
||||
log 'dry-run: pass --version VERSION to preview an exact artifact'
|
||||
return 0
|
||||
fi
|
||||
@@ -753,14 +756,15 @@ main() {
|
||||
artifact_url="$RELEASE_BASE_URL/$artifact"
|
||||
log "platform: ${TALLYNOTE_ARCH}/${TALLYNOTE_LIBC}; release: ${VERSION#v}"
|
||||
log "layout: $PREFIX/releases + atomic $PREFIX/current; data: $DATA_DIR"
|
||||
if (( ! APPLY )); then log 'dry-run: pass --apply to download, verify, extract, and configure systemd'; return 0; fi
|
||||
[[ "$REQUIRE_SIGNATURE" == true || "$ALLOW_UNSIGNED" -eq 1 ]] || die '生产安装必须校验发布签名;仅隔离开发环境可使用 --allow-unsigned'
|
||||
[[ "$("$UNAME_BIN" -s)" == Linux ]] || die '安装器只允许在 Linux 上执行 --apply'
|
||||
[[ $EUID -eq 0 ]] || die '--apply must run as root'
|
||||
if (( ! APPLY )); then log 'dry-run: no download, extraction, or systemd changes'; return 0; fi
|
||||
[[ "$("$UNAME_BIN" -s)" == Linux ]] || die '安装器只允许在 Linux 上执行'
|
||||
[[ $EUID -eq 0 ]] || die '安装必须以 root 运行'
|
||||
for command_name in curl sha256sum tar install sed awk find systemctl; do
|
||||
command -v "$command_name" >/dev/null 2>&1 || die "$command_name is required"
|
||||
done
|
||||
command -v "$OPENSSL_BIN" >/dev/null 2>&1 || die 'openssl is required'
|
||||
if [[ "$REQUIRE_SIGNATURE" == true || -n "$SIGNATURE_URL" || -n "$SIGNING_KEY" || -n "$UPDATE_PUBLIC_KEY_FILE" ]]; then
|
||||
command -v "$OPENSSL_BIN" >/dev/null 2>&1 || die 'openssl is required when signature verification is enabled'
|
||||
fi
|
||||
work=$(mktemp -d)
|
||||
INSTALL_WORK_DIR=$work
|
||||
INSTALL_BACKUP_DIR="$work/original"
|
||||
@@ -784,8 +788,9 @@ main() {
|
||||
[[ -z "$SHA256_FILE" ]] || die '本地 SHA256SUMS 文件不存在或是符号链接'
|
||||
download "$SHA256_URL" "$checksum" $((2 * 1024 * 1024))
|
||||
fi
|
||||
SIGNING_KEY=${SIGNING_KEY:-$UPDATE_PUBLIC_KEY_FILE}
|
||||
signature=''
|
||||
if [[ "$REQUIRE_SIGNATURE" == true ]]; then
|
||||
if [[ "$REQUIRE_SIGNATURE" == true || -n "$SIGNATURE_URL" || -n "$SIGNING_KEY" ]]; then
|
||||
if [[ "$SIGNATURE_FORMAT" == gpg ]]; then
|
||||
SIGNATURE_URL=${SIGNATURE_URL:-$RELEASE_BASE_URL/$artifact.asc}
|
||||
signature="$work/$artifact.asc"
|
||||
@@ -794,11 +799,7 @@ main() {
|
||||
signature="$work/SHA256SUMS.sig"
|
||||
fi
|
||||
download "$SIGNATURE_URL" "$signature" $((64 * 1024))
|
||||
elif [[ -n "$SIGNATURE_URL" ]]; then
|
||||
signature="$work/SHA256SUMS.sig"
|
||||
download "$SIGNATURE_URL" "$signature" $((64 * 1024))
|
||||
fi
|
||||
SIGNING_KEY=${SIGNING_KEY:-$UPDATE_PUBLIC_KEY_FILE}
|
||||
verify_archive "$archive" "$checksum" "$signature" "$SIGNING_KEY"
|
||||
[[ "$PREFIX" = /* && "$DATA_DIR" = /* && "$CONFIG_DIR" = /* ]] || die '安装、数据和配置目录必须是绝对路径'
|
||||
[[ ! -L "$DATA_DIR" && ! -L "$PREFIX" && ! -L "$CONFIG_DIR" ]] || die 'installation/data/config paths must not be symlinks'
|
||||
@@ -851,10 +852,14 @@ main() {
|
||||
ensure_env_key TALLYNOTE_UPDATE_STRATEGY systemd
|
||||
ensure_env_key TALLYNOTE_UPDATE_METADATA_URL "$RELEASE_API_URL"
|
||||
ensure_env_key TALLYNOTE_UPDATE_ALLOWED_HOSTS "$RELEASE_ALLOWED_HOSTS"
|
||||
ensure_env_key TALLYNOTE_UPDATE_REQUIRE_SIGNATURE true
|
||||
# The bootstrap verification key is also the key used by the privileged
|
||||
# updater unless the operator already configured a separate one.
|
||||
UPDATE_PUBLIC_KEY_FILE=${UPDATE_PUBLIC_KEY_FILE:-$SIGNING_KEY}
|
||||
if [[ -n "$UPDATE_PUBLIC_KEY_FILE" ]]; then
|
||||
ensure_env_key TALLYNOTE_UPDATE_REQUIRE_SIGNATURE true
|
||||
else
|
||||
ensure_env_key TALLYNOTE_UPDATE_REQUIRE_SIGNATURE false
|
||||
fi
|
||||
if [[ -n "$UPDATE_PUBLIC_KEY_FILE" ]]; then
|
||||
validate_install_path "$UPDATE_PUBLIC_KEY_FILE" '更新公钥路径'
|
||||
[[ -f "$UPDATE_PUBLIC_KEY_FILE" && ! -L "$UPDATE_PUBLIC_KEY_FILE" ]] || die 'update public key file is invalid'
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tallynote",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@9.0.6",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
# Publish one immutable, signed release to a Gitea-compatible API. The script
|
||||
# is intentionally separate from the workflow so operators can dry-run the
|
||||
# exact same asset selection locally without ever exposing a signing key.
|
||||
# Publish one immutable release to a Gitea-compatible API. SHA256SUMS is
|
||||
# always generated; an Ed25519 detached signature is added when a signing key
|
||||
# is supplied. The script remains separate from the workflow so operators can
|
||||
# dry-run the exact same asset selection locally without exposing a key.
|
||||
PATH=/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/bin
|
||||
export PATH
|
||||
umask 077
|
||||
@@ -23,6 +24,7 @@ DRY_RUN=0
|
||||
AUTH_CONFIG=''
|
||||
SUMS_TMP=''
|
||||
SIG_TMP=''
|
||||
SIGNATURE_GENERATED=0
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
@@ -30,8 +32,12 @@ Usage: publish-gitea-release.sh TAG [ASSET_DIR] [--dry-run]
|
||||
|
||||
Required in publish mode:
|
||||
GITEA_TOKEN (or GITHUB_TOKEN) API token with release write access
|
||||
|
||||
Optional:
|
||||
TALLYNOTE_RELEASE_SIGNING_KEY_FILE Ed25519 private-key file
|
||||
or TALLYNOTE_RELEASE_SIGNING_KEY PEM value supplied by CI secret
|
||||
TALLYNOTE_RELEASE_SIGNING_KEY PEM value supplied by CI secret
|
||||
|
||||
Without a signing key, the release is published with SHA256SUMS only.
|
||||
EOF
|
||||
}
|
||||
die() { printf 'release publisher: %s\n' "$*" >&2; exit 1; }
|
||||
@@ -116,7 +122,9 @@ API_ROOT=${API_ROOT%/}
|
||||
validate_api_root "$API_ROOT"
|
||||
[[ -d "$ASSET_DIR" && ! -L "$ASSET_DIR" ]] || die "asset directory is invalid: $ASSET_DIR"
|
||||
command -v sha256sum >/dev/null 2>&1 || die 'sha256sum is required'
|
||||
command -v "$OPENSSL_BIN" >/dev/null 2>&1 || die 'openssl is required'
|
||||
if [[ -n "$SIGNING_KEY_FILE" || -n "$SIGNING_KEY_VALUE" ]]; then
|
||||
command -v "$OPENSSL_BIN" >/dev/null 2>&1 || die 'openssl is required when signing a release'
|
||||
fi
|
||||
[[ "$CURL_BIN" != *[[:space:]]* && "$CURL_BIN" != *[[:cntrl:]]* ]] || die 'curl executable path is invalid'
|
||||
command -v "$CURL_BIN" >/dev/null 2>&1 || die 'curl is required'
|
||||
|
||||
@@ -164,8 +172,6 @@ elif [[ -n "$SIGNING_KEY_VALUE" ]]; then
|
||||
chmod 600 "$temporary_key"
|
||||
printf '%s\n' "$SIGNING_KEY_VALUE" > "$temporary_key"
|
||||
unset SIGNING_KEY_VALUE
|
||||
else
|
||||
[[ "$DRY_RUN" -eq 1 ]] || die 'TALLYNOTE_RELEASE_SIGNING_KEY_FILE or TALLYNOTE_RELEASE_SIGNING_KEY is required'
|
||||
fi
|
||||
if [[ -n "$temporary_key" ]]; then
|
||||
"$OPENSSL_BIN" pkey -in "$temporary_key" -noout >/dev/null 2>&1 || die 'signing key is not a valid private key'
|
||||
@@ -174,16 +180,18 @@ if [[ -n "$temporary_key" ]]; then
|
||||
chmod 600 "$SIG_TMP"
|
||||
mv -f -- "$SIG_TMP" "$SIG_FILE"
|
||||
SIG_TMP=''
|
||||
SIGNATURE_GENERATED=1
|
||||
fi
|
||||
|
||||
log "tag: $TAG"
|
||||
log "assets: ${#assets[@]} archive(s), SHA256SUMS${temporary_key:+, SHA256SUMS.sig}"
|
||||
asset_summary="assets: ${#assets[@]} archive(s), SHA256SUMS"
|
||||
if (( SIGNATURE_GENERATED )); then asset_summary+=", SHA256SUMS.sig"; fi
|
||||
log "$asset_summary"
|
||||
if (( DRY_RUN )); then
|
||||
log 'dry-run: no API request was sent'
|
||||
exit 0
|
||||
fi
|
||||
[[ -n "$TOKEN" ]] || die 'GITEA_TOKEN (or GITHUB_TOKEN) is required'
|
||||
[[ -s "$SIG_FILE" ]] || die 'signature was not generated'
|
||||
command -v jq >/dev/null 2>&1 || die 'jq is required for Gitea API publishing'
|
||||
write_auth_config
|
||||
unset TOKEN
|
||||
@@ -230,7 +238,8 @@ assets_endpoint="$API_ROOT/repos/$repo_path/releases/$release_id/assets"
|
||||
existing=$(api_curl "$assets_endpoint") || die '无法读取现有 Release 资产'
|
||||
while IFS=$'\t' read -r existing_id existing_name; do
|
||||
[[ -n "$existing_id" && -n "$existing_name" ]] || continue
|
||||
for candidate in "${assets[@]}" "$SUMS_FILE" "$SIG_FILE"; do
|
||||
candidates=("${assets[@]}" "$SUMS_FILE" "$SIG_FILE")
|
||||
for candidate in "${candidates[@]}"; do
|
||||
[[ "$existing_name" == "$(basename -- "$candidate")" ]] || continue
|
||||
api_curl -X DELETE "$assets_endpoint/$existing_id" >/dev/null || die "无法删除旧资产:$existing_name"
|
||||
done
|
||||
@@ -245,5 +254,7 @@ upload_asset() {
|
||||
}
|
||||
for file in "${assets[@]}"; do upload_asset "$file"; done
|
||||
upload_asset "$SUMS_FILE"
|
||||
upload_asset "$SIG_FILE"
|
||||
if (( SIGNATURE_GENERATED )); then
|
||||
upload_asset "$SIG_FILE"
|
||||
fi
|
||||
log "published $TAG to $REPOSITORY"
|
||||
|
||||
@@ -37,6 +37,13 @@ fi
|
||||
# main invocation lets this subprocess source the exact production code.
|
||||
installer_lib="$tmp/install-lib.sh"
|
||||
sed '$d' "$root/install.sh" > "$installer_lib"
|
||||
bash -c '
|
||||
script=$1
|
||||
set --
|
||||
source "$script"
|
||||
[[ "$APPLY" -eq 1 ]]
|
||||
[[ "$REQUIRE_SIGNATURE" == false ]]
|
||||
' _ "$installer_lib"
|
||||
bash -c '
|
||||
script=$1
|
||||
mode_dir=$2
|
||||
@@ -94,6 +101,20 @@ bash -c '
|
||||
[[ "$(stat_mode "$destination/bin/tallynote")" == 755 ]]
|
||||
' _ "$installer_lib" "$archive_tmp" "$tmp/unpacked"
|
||||
|
||||
# A normal public-release install only needs the detached SHA-256 manifest;
|
||||
# absence of a signature and public key must not block archive verification.
|
||||
checksum_tmp="$tmp/SHA256SUMS"
|
||||
(cd "$(dirname -- "$archive_tmp")" && sha256sum "$(basename -- "$archive_tmp")") > "$checksum_tmp"
|
||||
bash -c '
|
||||
script=$1
|
||||
archive=$2
|
||||
checksum=$3
|
||||
set --
|
||||
source "$script"
|
||||
REQUIRE_SIGNATURE=false
|
||||
verify_archive "$archive" "$checksum" "" ""
|
||||
' _ "$installer_lib" "$archive_tmp" "$checksum_tmp"
|
||||
|
||||
# Newline/control characters in release configuration must never become extra
|
||||
# systemd EnvironmentFile assignments.
|
||||
if TALLYNOTE_RELEASE_API_URL=$'https://git.awaioi.com/api/v1\nEVIL=1' bash "$root/install.sh" --dry-run >/dev/null 2>&1; then
|
||||
|
||||
@@ -400,7 +400,7 @@ export async function main(config: AppConfig = loadConfig()): Promise<void> {
|
||||
...(deferCompletion ? { deferCompletion: true } : {}),
|
||||
...(request ? { jobId: request.jobId } : {}),
|
||||
publicKey: config.updatePublicKey,
|
||||
requireSignature: request ? true : config.updateRequireSignature,
|
||||
requireSignature: config.updateRequireSignature,
|
||||
sqlite: database.sqlite,
|
||||
});
|
||||
console.log(`更新完成:${result.version}`);
|
||||
|
||||
+4
-1
@@ -104,7 +104,10 @@ export function loadConfig() {
|
||||
|| "https://git.awaioi.com/api/v1/repos/awaioi/TallyNote/releases/latest";
|
||||
const updateAllowedHosts = csvEnv("TALLYNOTE_UPDATE_ALLOWED_HOSTS");
|
||||
const updatePublicKey = updatePublicKeyEnv();
|
||||
const updateRequireSignature = booleanEnv("TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", updateStrategyRaw === "systemd");
|
||||
// Public releases always require HTTPS, host allowlisting, and SHA-256.
|
||||
// Detached signatures remain an opt-in hardening layer so a self-hosted
|
||||
// public repository can use one-click updates without provisioning a key.
|
||||
const updateRequireSignature = booleanEnv("TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", false);
|
||||
if (!(updateStrategyRaw === "disabled" || updateStrategyRaw === "systemd")) {
|
||||
throw new Error("TALLYNOTE_UPDATE_STRATEGY 必须是 disabled 或 systemd");
|
||||
}
|
||||
|
||||
@@ -8,8 +8,9 @@ TALLYNOTE_TIMEZONE=Asia/Shanghai
|
||||
TALLYNOTE_UPDATE_STRATEGY=systemd
|
||||
TALLYNOTE_UPDATE_METADATA_URL=https://git.awaioi.com/api/v1/repos/awaioi/TallyNote/releases/latest
|
||||
TALLYNOTE_UPDATE_ALLOWED_HOSTS=git.awaioi.com
|
||||
TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=true
|
||||
TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=false
|
||||
TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS=60
|
||||
TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS=15
|
||||
# Configure a root-managed Ed25519 public key before enabling one-click updates.
|
||||
# Optional: configure a root-managed Ed25519 public key and set
|
||||
# TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=true to require detached signatures.
|
||||
# TALLYNOTE_UPDATE_PUBLIC_KEY_FILE=/etc/tallynote/update-signing-key.pub
|
||||
|
||||
@@ -27,14 +27,16 @@ describe("部署安全配置", () => {
|
||||
expect(loadConfig().trustProxy).toBe(1);
|
||||
});
|
||||
|
||||
it("systemd 更新必须绑定主机白名单并默认要求签名", () => {
|
||||
it("systemd 更新必须绑定主机白名单,签名校验默认关闭", () => {
|
||||
process.env.TALLYNOTE_UPDATE_STRATEGY = "systemd";
|
||||
process.env.TALLYNOTE_PUBLIC_ORIGIN = "https://example.test";
|
||||
process.env.TALLYNOTE_COOKIE_SECURE = "true";
|
||||
expect(() => loadConfig()).toThrow(/ALLOWED_HOSTS/);
|
||||
process.env.TALLYNOTE_UPDATE_ALLOWED_HOSTS = "updates.example";
|
||||
const config = loadConfig();
|
||||
expect(config.updateRequireSignature).toBe(true);
|
||||
expect(config.updateRequireSignature).toBe(false);
|
||||
process.env.TALLYNOTE_UPDATE_REQUIRE_SIGNATURE = "true";
|
||||
expect(loadConfig().updateRequireSignature).toBe(true);
|
||||
});
|
||||
|
||||
it("收紧已有数据目录和数据库文件权限,并拒绝符号链接", () => {
|
||||
|
||||
@@ -59,10 +59,10 @@ describe("更新 API", () => {
|
||||
|
||||
function mockRelease() {
|
||||
const digest = "c".repeat(64);
|
||||
const asset = `tallynote-1.1.1-${detectPlatform().target}-glibc.tar.gz`;
|
||||
const asset = `tallynote-1.1.2-${detectPlatform().target}-glibc.tar.gz`;
|
||||
globalThis.fetch = (async (input: string | URL) => input.toString().endsWith("SHA256SUMS")
|
||||
? new Response(`${digest} ${asset}\n`, { status: 200 })
|
||||
: new Response(JSON.stringify({ tag_name: "v1.1.1", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: asset, browser_download_url: `https://updates.example/${asset}` }] }), { status: 200 })) as typeof fetch;
|
||||
: new Response(JSON.stringify({ tag_name: "v1.1.2", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: asset, browser_download_url: `https://updates.example/${asset}` }] }), { status: 200 })) as typeof fetch;
|
||||
}
|
||||
|
||||
it("检查 release、创建受保护请求文件并拒绝重复任务", async () => {
|
||||
@@ -70,21 +70,21 @@ describe("更新 API", () => {
|
||||
mockRelease();
|
||||
const checked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} });
|
||||
expect(checked.statusCode).toBe(200);
|
||||
expect(checked.json().latest).toMatchObject({ version: "1.1.1", compatible: true, integrityReady: true, isNewer: true });
|
||||
expect(checked.json().latest).toMatchObject({ version: "1.1.2", compatible: true, integrityReady: true, isNewer: true });
|
||||
expect(checked.headers["cache-control"]).toBe("no-store");
|
||||
const tooSoon = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} });
|
||||
expect(tooSoon.statusCode).toBe(429);
|
||||
expect(tooSoon.headers["retry-after"]).toBeDefined();
|
||||
|
||||
const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.1", confirm: true } });
|
||||
const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.2", confirm: true } });
|
||||
expect(applied.statusCode).toBe(202);
|
||||
const jobId = applied.json().job.id as string;
|
||||
const request = JSON.parse(readFileSync(config.updateRequestPath, "utf8")) as { jobId: string; expectedSha256: string; currentLink: string };
|
||||
expect(request).toMatchObject({ jobId, version: "1.1.1", expectedSha256: "c".repeat(64), currentLink: config.currentLink });
|
||||
expect(request).toMatchObject({ jobId, version: "1.1.2", expectedSha256: "c".repeat(64), currentLink: config.currentLink });
|
||||
expect(statSync(config.updateRequestPath).mode & 0o777).toBe(0o600);
|
||||
|
||||
mockRelease();
|
||||
const duplicate = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.1", confirm: true } });
|
||||
const duplicate = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.2", confirm: true } });
|
||||
expect(duplicate.statusCode).toBe(409);
|
||||
expect(duplicate.json().error.code).toBe("UPDATE_IN_PROGRESS");
|
||||
const status = await app.inject({ method: "GET", url: "/api/update/status", headers: { cookie: session.cookies } });
|
||||
@@ -95,7 +95,7 @@ describe("更新 API", () => {
|
||||
|
||||
it("缺少确认或未启用 systemd 时不接受更新", async () => {
|
||||
const session = await login();
|
||||
const invalid = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.1" } });
|
||||
const invalid = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.2" } });
|
||||
expect(invalid.statusCode).toBe(400);
|
||||
process.env.TALLYNOTE_UPDATE_STRATEGY = "disabled";
|
||||
const disabledConfig = loadConfig();
|
||||
@@ -108,7 +108,7 @@ describe("更新 API", () => {
|
||||
mockRelease();
|
||||
const checked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: owner.cookies, "x-csrf-token": owner.csrf }, payload: {} });
|
||||
expect(checked.statusCode).toBe(200);
|
||||
const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: owner.cookies, "x-csrf-token": owner.csrf }, payload: { version: "1.1.1", confirm: true } });
|
||||
const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: owner.cookies, "x-csrf-token": owner.csrf }, payload: { version: "1.1.2", confirm: true } });
|
||||
expect(applied.statusCode).toBe(202);
|
||||
const jobId = applied.json().job.id as string;
|
||||
database.sqlite.prepare("UPDATE update_jobs SET error_message=? WHERE id=?").run("/var/lib/tallynote/secret-command-output", jobId);
|
||||
@@ -126,7 +126,7 @@ describe("更新 API", () => {
|
||||
it("应用前重新校验失败时写入失败审计", async () => {
|
||||
const session = await login("update-audit");
|
||||
globalThis.fetch = (async () => new Response("upstream unavailable", { status: 503 })) as typeof fetch;
|
||||
const response = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.1", confirm: true } });
|
||||
const response = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.2", confirm: true } });
|
||||
expect(response.statusCode).toBe(502);
|
||||
const audit = database.sqlite.prepare("SELECT outcome FROM audit_events WHERE action='update.apply_requested' ORDER BY id DESC LIMIT 1").get() as { outcome: string } | undefined;
|
||||
expect(audit?.outcome).toBe("failure");
|
||||
|
||||
@@ -273,17 +273,17 @@ describe("更新元数据缓存", () => {
|
||||
prepareDataDirectories(config);
|
||||
const database = openDatabase(config);
|
||||
const digest = "b".repeat(64);
|
||||
const platformAsset = `tallynote-1.1.1-${detectPlatform().target}-glibc.tar.gz`;
|
||||
const platformAsset = `tallynote-1.1.2-${detectPlatform().target}-glibc.tar.gz`;
|
||||
const sums = `${digest} ${platformAsset}\n`;
|
||||
const signature = sign(null, Buffer.from(sums), privateKey);
|
||||
globalThis.fetch = (async (input: string | URL) => input.toString().endsWith("SHA256SUMS.sig")
|
||||
? new Response(signature)
|
||||
: input.toString().endsWith("SHA256SUMS")
|
||||
? new Response(sums)
|
||||
: new Response(JSON.stringify({ tag_name: "v1.1.1", published_at: "2026-08-28T00:00:00Z", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: "SHA256SUMS.sig", browser_download_url: "https://updates.example/SHA256SUMS.sig" }, { name: platformAsset, browser_download_url: `https://updates.example/${platformAsset}` }] }), { status: 200 })) as typeof fetch;
|
||||
: new Response(JSON.stringify({ tag_name: "v1.1.2", published_at: "2026-08-28T00:00:00Z", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: "SHA256SUMS.sig", browser_download_url: "https://updates.example/SHA256SUMS.sig" }, { name: platformAsset, browser_download_url: `https://updates.example/${platformAsset}` }] }), { status: 200 })) as typeof fetch;
|
||||
try {
|
||||
const result = await checkForUpdate(database.sqlite, config);
|
||||
expect(result.latest).toMatchObject({ version: "1.1.1", compatible: true, integrityReady: true, signatureReady: true, isNewer: true });
|
||||
expect(result.latest).toMatchObject({ version: "1.1.2", compatible: true, integrityReady: true, signatureReady: true, isNewer: true });
|
||||
const cached = database.sqlite.prepare("SELECT value FROM system_settings WHERE key='update.release.v1'").get() as { value: string };
|
||||
expect(JSON.parse(cached.value).asset.sha256).toBe(digest);
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user