From 4434acf697d85a33614833928645549030943ca2 Mon Sep 17 00:00:00 2001 From: Qiufeng Date: Tue, 1 Sep 2026 00:10:35 +0800 Subject: [PATCH] release: simplify unsigned installation --- .env.example | 6 ++-- .gitea/workflows/release.yml | 3 +- README.md | 26 +++++++------- docs/release.md | 31 ++++++++--------- install.sh | 59 +++++++++++++++++--------------- package.json | 2 +- scripts/publish-gitea-release.sh | 33 ++++++++++++------ scripts/test-installer.sh | 21 ++++++++++++ server/cli/update.ts | 2 +- server/config.ts | 5 ++- systemd/tallynote.env.example | 5 +-- tests/security.test.ts | 6 ++-- tests/update-api.test.ts | 18 +++++----- tests/update.test.ts | 6 ++-- 14 files changed, 130 insertions(+), 93 deletions(-) diff --git a/.env.example b/.env.example index e317a66..3809d80 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 4b155c0..37a6703 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -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 diff --git a/README.md b/README.md index 383e6da..80c9da7 100644 --- a/README.md +++ b/README.md @@ -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-<架构>-.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-<架构>-.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 反代访问。 diff --git a/docs/release.md b/docs/release.md index f67d6e9..12435ab 100644 --- a/docs/release.md +++ b/docs/release.md @@ -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///releases/latest`。检查结果只显示当前平台匹配且同时通过 SHA-256 与 Ed25519 签名验证的资产;缺少任一项时“更新”按钮保持禁用。 +将环境文件中的 `TALLYNOTE_UPDATE_STRATEGY=systemd`、`TALLYNOTE_UPDATE_METADATA_URL` 和 `TALLYNOTE_UPDATE_ALLOWED_HOSTS` 配好后,后台“系统更新”会读取 Gitea 的 `/api/v1/repos///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 diff --git a/install.sh b/install.sh index a6e4bc3..0575c66 100755 --- a/install.sh +++ b/install.sh @@ -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' diff --git a/package.json b/package.json index 4405a12..56a3a40 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tallynote", - "version": "1.1.0", + "version": "1.1.1", "private": true, "type": "module", "packageManager": "pnpm@9.0.6", diff --git a/scripts/publish-gitea-release.sh b/scripts/publish-gitea-release.sh index 5a24f93..b1fe19e 100755 --- a/scripts/publish-gitea-release.sh +++ b/scripts/publish-gitea-release.sh @@ -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" diff --git a/scripts/test-installer.sh b/scripts/test-installer.sh index bf3fab8..b6f7a9a 100755 --- a/scripts/test-installer.sh +++ b/scripts/test-installer.sh @@ -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 diff --git a/server/cli/update.ts b/server/cli/update.ts index 65736e4..3f78d36 100644 --- a/server/cli/update.ts +++ b/server/cli/update.ts @@ -400,7 +400,7 @@ export async function main(config: AppConfig = loadConfig()): Promise { ...(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}`); diff --git a/server/config.ts b/server/config.ts index 222a1e9..b0ac98b 100644 --- a/server/config.ts +++ b/server/config.ts @@ -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"); } diff --git a/systemd/tallynote.env.example b/systemd/tallynote.env.example index d0ef014..8dae5c1 100644 --- a/systemd/tallynote.env.example +++ b/systemd/tallynote.env.example @@ -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 diff --git a/tests/security.test.ts b/tests/security.test.ts index 0f483ef..152fdd2 100644 --- a/tests/security.test.ts +++ b/tests/security.test.ts @@ -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("收紧已有数据目录和数据库文件权限,并拒绝符号链接", () => { diff --git a/tests/update-api.test.ts b/tests/update-api.test.ts index 2519a38..14be65e 100644 --- a/tests/update-api.test.ts +++ b/tests/update-api.test.ts @@ -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"); diff --git a/tests/update.test.ts b/tests/update.test.ts index 2a1e0ef..e1ec2cd 100644 --- a/tests/update.test.ts +++ b/tests/update.test.ts @@ -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 {