Compare commits

...
2 Commits
Author SHA1 Message Date
Qiufeng 4b9c80cc3a feat: add safe one-click uninstall
TallyNote release / linux-x64 (push) Successful in 6m16s
2026-09-01 06:57:52 +08:00
Qiufeng 4434acf697 release: simplify unsigned installation
TallyNote release / linux-x64 (push) Successful in 6m24s
2026-09-01 00:10:35 +08:00
17 changed files with 830 additions and 98 deletions
+3 -3
View File
@@ -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
+1 -2
View File
@@ -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
+32 -14
View File
@@ -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 单元、`uninstall.sh`,以及 `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,11 +67,31 @@ 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`。
更新任务详情按发起管理员隔离;失败信息在浏览器中使用固定提示,不暴露服务器路径、命令输出或上游响应。系统同一时刻只允许一个更新任务。
### 卸载
安装完成后会提供 `/usr/local/sbin/tallynote-uninstall`。普通卸载会停止并禁用 TallyNote 的 systemd 单元,删除当前版本、更新辅助程序和已知配置,但保留 `/var/lib/tallynote` 以及更新备份,方便以后重新安装:
```bash
sudo /usr/local/sbin/tallynote-uninstall
```
如果确认不再需要数据库、附件、暂存、导出和更新备份,必须显式同时提供 `--purge-data --yes`:
```bash
sudo /usr/local/sbin/tallynote-uninstall --purge-data --yes --purge-config
```
卸载检测到未完成的更新状态时会停止并要求人工确认;确认更新已停止后再加 `--force`。也可以直接从公开仓库获取同一脚本执行普通卸载:
```bash
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/uninstall.sh | sudo bash
```
公网反代必须使用 HTTPS,并在环境文件中设置真实的 `TALLYNOTE_PUBLIC_ORIGIN=https://...`、`TALLYNOTE_COOKIE_SECURE=true` 和明确的 `TALLYNOTE_TRUST_PROXY` 跳数(不要使用生产值 `true`)。
### 构建发布包
@@ -82,17 +100,17 @@ curl --proto '=https' --tlsv1.2 -fsSL \
```bash
pnpm install --frozen-lockfile
pnpm release:build 1.1.0 ./release
pnpm release:build 1.1.2 ./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.2` 与 `v1.1.2`),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.2"
git tag -a v1.1.2 -m "TallyNote 1.1.2"
git push origin main --follow-tags
```
@@ -109,4 +127,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 反代访问。
+24 -17
View File
@@ -6,48 +6,45 @@ TallyNote 的发布包必须在目标 Linux 架构上构建。`better-sqlite3`
## 自动发布
向 Gitea 推送符合 SemVer 的 tag(例如 `v1.1.0`)会触发 `.gitea/workflows/release.yml`:
向 Gitea 推送符合 SemVer 的 tag(例如 `v1.1.2`)会触发 `.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.2 ./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.2 ./release
```
发布资产名称必须包含当前平台,例如 `tallynote-1.1.0-linux-x64-glibc.tar.gz`。同一个 Release 只保留一个 `SHA256SUMS` 和一个 `SHA256SUMS.sig`,清单签名覆盖其完整原文。
发布资产名称必须包含当前平台,例如 `tallynote-1.1.2-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 拥有或对组/其他用户可写的安装、配置和备份目录。发布包同时携带 `uninstall.sh`,安装后会落到 `/usr/local/sbin/tallynote-uninstall`。`--allow-unsigned` 作为旧版本兼容参数保留。
安装布局:
@@ -61,11 +58,21 @@ curl --proto '=https' --tlsv1.2 -fsSL \
/etc/tallynote/tallynote.env
```
## 卸载与数据保留
默认卸载只移除发布代码、systemd 单元、更新辅助程序和已知配置,数据目录与更新备份不会删除:
```bash
sudo /usr/local/sbin/tallynote-uninstall
```
只有显式 `--purge-data --yes` 才会删除 SQLite、附件、暂存、导出、更新队列和备份;`--purge-config` 可在确认配置目录中没有其他文件后移除空配置目录。卸载器不会自动删除 `tallynote` 系统用户,也不会跟随符号链接删除目录。检测到 `.update-state` 或 `update-request.json` 时会拒绝执行,确认更新已经停止后使用 `--force`。
## 后台一键更新
将环境文件中的 `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
+44 -31
View File
@@ -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
}
@@ -370,7 +373,7 @@ normalize_release_tree() {
fi
find "$root" -type d -exec chmod 755 {} +
find "$root" -type f -exec chmod 644 {} +
for item in "$root/bin"/* "$root/scripts"/*.sh "$root/runtime/bin"/*; do
for item in "$root/bin"/* "$root/scripts"/*.sh "$root/runtime/bin"/* "$root/uninstall.sh"; do
[[ -f "$item" && ! -L "$item" ]] || continue
chmod 755 "$item"
done
@@ -538,10 +541,11 @@ rollback_install_if_needed() {
fi
if (( INSTALL_COMMITTED == 0 )) && [[ -n "$INSTALL_BACKUP_DIR" && -d "$INSTALL_BACKUP_DIR" ]]; then
local backup_name target
for backup_name in tallynote.service tallynote-update.service tallynote-update.path tallynote.env update-signing-key.pub; do
for backup_name in tallynote.service tallynote-update.service tallynote-update.path tallynote-uninstall tallynote.env update-signing-key.pub; do
case "$backup_name" in
tallynote.env) target="$CONFIG_DIR/tallynote.env" ;;
update-signing-key.pub) target="$CONFIG_DIR/update-signing-key.pub" ;;
tallynote-uninstall) target="/usr/local/sbin/tallynote-uninstall" ;;
*) target="/etc/systemd/system/$backup_name" ;;
esac
[[ ! -L "$target" ]] || continue
@@ -583,6 +587,12 @@ backup_install_files() {
cp -a -- "$target" "$directory/$name"
fi
done
target="/usr/local/sbin/tallynote-uninstall"
[[ ! -L "$target" ]] || die "现有卸载器不能是符号链接:$target"
if [[ -e "$target" ]]; then
[[ -f "$target" ]] || die "现有卸载器不是普通文件:$target"
cp -a -- "$target" "$directory/tallynote-uninstall"
fi
}
read_env_value() {
@@ -636,7 +646,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" '环境文件更新源'
@@ -656,7 +666,7 @@ install_release() {
[[ -x "$tmp/unpacked/bin/tallynote" ]] || die 'release archive must contain executable bin/tallynote'
[[ -f "$tmp/unpacked/package.json" && -f "$tmp/unpacked/dist/server/index.js" && -f "$tmp/unpacked/dist/web/index.html" ]] || die 'release archive is incomplete'
[[ -f "$tmp/unpacked/systemd/tallynote.service" && -f "$tmp/unpacked/systemd/tallynote-update.service" && -f "$tmp/unpacked/systemd/tallynote-update.path" ]] || die 'release archive is missing systemd units'
[[ -f "$tmp/unpacked/systemd/tallynote.env.example" && -x "$tmp/unpacked/scripts/tallynote-update.sh" && -x "$tmp/unpacked/scripts/tallynote-update-runner.sh" ]] || die 'release archive is missing update support files'
[[ -f "$tmp/unpacked/systemd/tallynote.env.example" && -x "$tmp/unpacked/scripts/tallynote-update.sh" && -x "$tmp/unpacked/scripts/tallynote-update-runner.sh" && -x "$tmp/unpacked/uninstall.sh" ]] || die 'release archive is missing update/uninstall support files'
grep -Eq '"version"[[:space:]]*:[[:space:]]*"'"$version"'"([,}]|[[:space:]])' "$tmp/unpacked/package.json" || die 'release package version does not match requested version'
ensure_root_directory "$PREFIX" 755
ensure_root_directory "$PREFIX/releases" 755
@@ -710,7 +720,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 +740,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 +763,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 +795,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 +806,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'
@@ -816,7 +824,7 @@ main() {
install_release "$archive" "$VERSION"
release_dir="$PREFIX/releases/$VERSION"
[[ -f "$release_dir/systemd/tallynote.service" && -f "$release_dir/systemd/tallynote-update.service" && -f "$release_dir/systemd/tallynote-update.path" ]] || die 'release package is missing systemd unit files'
[[ -f "$release_dir/systemd/tallynote.env.example" && -f "$release_dir/scripts/tallynote-update-runner.sh" ]] || die 'release package is missing update support files'
[[ -f "$release_dir/systemd/tallynote.env.example" && -f "$release_dir/scripts/tallynote-update-runner.sh" && -x "$release_dir/uninstall.sh" ]] || die 'release package is missing update/uninstall support files'
install -d -m 755 /usr/local/libexec /etc/systemd/system
local unit_tmp
unit_tmp=$(mktemp -d)
@@ -829,6 +837,7 @@ main() {
rm -rf "$unit_tmp"
install -o root -g root -m 755 "$release_dir/scripts/tallynote-update.sh" /usr/local/sbin/tallynote-update
install -o root -g root -m 755 "$release_dir/scripts/tallynote-update-runner.sh" /usr/local/libexec/tallynote-update-runner
install -o root -g root -m 755 "$release_dir/uninstall.sh" /usr/local/sbin/tallynote-uninstall
ensure_root_directory "$(dirname -- "$DATA_DIR")/tallynote-backups" 700
if [[ ! -f "$CONFIG_DIR/tallynote.env" ]]; then
sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g" "$release_dir/systemd/tallynote.env.example" > "$CONFIG_DIR/tallynote.env"
@@ -851,10 +860,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'
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tallynote",
"version": "1.1.0",
"version": "1.1.2",
"private": true,
"type": "module",
"packageManager": "pnpm@9.0.6",
@@ -20,6 +20,7 @@
"check": "tsc -p tsconfig.server.json --noEmit && tsc -p tsconfig.web-next.json --noEmit",
"check:next": "tsc -p tsconfig.web-next.json --noEmit",
"test": "vitest run",
"test:installer": "bash scripts/test-installer.sh && bash scripts/test-uninstaller.sh",
"test:watch": "vitest",
"test:e2e": "playwright test"
},
+2 -1
View File
@@ -32,10 +32,11 @@ cp -a migrations/. "$stage/migrations/"
cp package.json pnpm-lock.yaml "$stage/"
cp -a bin/. "$stage/bin/"
cp -a scripts/tallynote-update.sh scripts/tallynote-update-runner.sh "$stage/scripts/"
cp uninstall.sh "$stage/uninstall.sh"
cp -a systemd/tallynote.service systemd/tallynote-update.service systemd/tallynote-update.path systemd/tallynote.env.example "$stage/systemd/"
node_path=$(command -v node)
cp -L "$node_path" "$stage/runtime/bin/node"
chmod 755 "$stage/bin/tallynote" "$stage/scripts"/*.sh "$stage/runtime/bin/node"
chmod 755 "$stage/bin/tallynote" "$stage/scripts"/*.sh "$stage/runtime/bin/node" "$stage/uninstall.sh"
# pnpm's default linker creates symlinks. A release archive is deliberately
# symlink-free so the installer can reject traversal links deterministically.
+22 -11
View File
@@ -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"
+24
View File
@@ -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
@@ -75,10 +82,12 @@ bash -c '
source_tmp="$tmp/source"
mkdir -p "$source_tmp/dist/server" "$source_tmp/bin" "$source_tmp/scripts" "$source_tmp/runtime/bin"
printf '%s\n' 'server' > "$source_tmp/dist/server/index.js"
printf '%s\n' '#!/bin/sh' > "$source_tmp/uninstall.sh"
printf '%s\n' '#!/bin/sh' > "$source_tmp/bin/tallynote"
printf '%s\n' '#!/bin/sh' > "$source_tmp/scripts/runner.sh"
printf '%s\n' 'node' > "$source_tmp/runtime/bin/node"
chmod 755 "$source_tmp/bin/tallynote" "$source_tmp/scripts/runner.sh" "$source_tmp/runtime/bin/node"
chmod 755 "$source_tmp/uninstall.sh"
archive_tmp="$tmp/release.tar.gz"
tar -C "$source_tmp" -czf "$archive_tmp" .
bash -c '
@@ -92,8 +101,23 @@ bash -c '
[[ "$(stat_mode "$destination/dist")" == 755 ]]
[[ "$(stat_mode "$destination/dist/server/index.js")" == 644 ]]
[[ "$(stat_mode "$destination/bin/tallynote")" == 755 ]]
[[ "$(stat_mode "$destination/uninstall.sh")" == 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
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env bash
set -Eeuo pipefail
root=$(cd -- "$(dirname -- "$0")/.." && pwd -P)
bash -n "$root/uninstall.sh"
tmp=$(cd "$(mktemp -d)" && pwd -P)
cleanup() { rm -rf -- "$tmp" 2>/dev/null || true; }
trap cleanup EXIT
make_fixture() {
local fixture=$1
mkdir -p "$fixture/opt/tallynote/releases/1.1.1/dist" \
"$fixture/opt/tallynote/.update-work" \
"$fixture/var/lib/tallynote/files" \
"$fixture/var/lib/tallynote/staging" \
"$fixture/var/lib/tallynote/exports" \
"$fixture/var/lib/tallynote-backups" \
"$fixture/etc/tallynote" \
"$fixture/etc/systemd/system" \
"$fixture/usr/local/sbin" \
"$fixture/usr/local/libexec"
printf '%s\n' 'release' > "$fixture/opt/tallynote/releases/1.1.1/dist/index.js"
ln -s "$fixture/opt/tallynote/releases/1.1.1" "$fixture/opt/tallynote/current"
printf '%s\n' \
"TALLYNOTE_INSTALL_PREFIX=$fixture/opt/tallynote" \
"TALLYNOTE_DATA_DIR=$fixture/var/lib/tallynote" \
"TALLYNOTE_UPDATE_PUBLIC_KEY_FILE=$fixture/etc/tallynote/update-signing-key.pub" \
> "$fixture/etc/tallynote/tallynote.env"
chmod 600 "$fixture/etc/tallynote/tallynote.env"
printf '%s\n' 'fake public key' > "$fixture/etc/tallynote/update-signing-key.pub"
for unit in tallynote.service tallynote-update.service tallynote-update.path; do
printf '%s\n' "Description=TallyNote $unit" "WorkingDirectory=$fixture/opt/tallynote/current" "PathExists=$fixture/var/lib/tallynote/update-request.json" > "$fixture/etc/systemd/system/$unit"
done
printf '%s\n' '#!/usr/bin/env bash' 'PREFIX=${TALLYNOTE_INSTALL_PREFIX:-/opt/tallynote}' 'echo TallyNote' > "$fixture/usr/local/sbin/tallynote-update"
printf '%s\n' '#!/usr/bin/env bash' 'PREFIX=${TALLYNOTE_INSTALL_PREFIX:-/opt/tallynote}' 'echo TallyNote' > "$fixture/usr/local/libexec/tallynote-update-runner"
cp "$root/uninstall.sh" "$fixture/usr/local/sbin/tallynote-uninstall"
chmod 755 "$fixture/usr/local/sbin/tallynote-update" "$fixture/usr/local/libexec/tallynote-update-runner" "$fixture/usr/local/sbin/tallynote-uninstall"
printf '%s\n' 'sqlite' > "$fixture/var/lib/tallynote/tallynote.db"
printf '%s\n' 'backup' > "$fixture/var/lib/tallynote-backups/backup.db"
}
make_systemctl() {
local fixture=$1
cat > "$fixture/systemctl" <<'EOF'
#!/usr/bin/env bash
set -u
printf '%s\n' "$*" >> "$TALLYNOTE_TEST_SYSTEMCTL_LOG"
case "${1:-}" in
is-active) exit 0 ;;
stop|disable|daemon-reload) exit 0 ;;
*) exit 0 ;;
esac
EOF
chmod 755 "$fixture/systemctl"
}
run_uninstall() {
local fixture=$1
TALLYNOTE_UNINSTALL_TEST_MODE=true \
TALLYNOTE_UNINSTALL_ROOT="$fixture" \
TALLYNOTE_SYSTEMCTL_BIN="$fixture/systemctl" \
TALLYNOTE_TEST_SYSTEMCTL_LOG="$fixture/systemctl.log" \
bash "$root/uninstall.sh" "${@:2}"
}
fixture="$tmp/normal"
make_fixture "$fixture"
make_systemctl "$fixture"
run_uninstall "$fixture"
[[ -d "$fixture/var/lib/tallynote" && -f "$fixture/var/lib/tallynote/tallynote.db" ]]
[[ -d "$fixture/var/lib/tallynote-backups" && -f "$fixture/var/lib/tallynote-backups/backup.db" ]]
[[ ! -e "$fixture/opt/tallynote" || -z "$(find "$fixture/opt/tallynote" -mindepth 1 -print -quit 2>/dev/null)" ]]
[[ ! -e "$fixture/etc/systemd/system/tallynote.service" ]]
[[ ! -e "$fixture/usr/local/sbin/tallynote-update" ]]
grep -n '^is-active.*tallynote-update.path' "$fixture/systemctl.log" >/dev/null
grep -n '^stop tallynote-update.path' "$fixture/systemctl.log" >/dev/null
path_stop=$(grep -n '^stop tallynote-update.path' "$fixture/systemctl.log" | head -n1 | cut -d: -f1)
update_stop=$(grep -n '^stop tallynote-update.service' "$fixture/systemctl.log" | head -n1 | cut -d: -f1)
main_stop=$(grep -n '^stop tallynote.service' "$fixture/systemctl.log" | head -n1 | cut -d: -f1)
(( path_stop < update_stop && update_stop < main_stop ))
# Re-running after the first uninstall is harmless and does not touch data.
run_uninstall "$fixture"
[[ -f "$fixture/var/lib/tallynote/tallynote.db" ]]
# Purge requires the explicit acknowledgement flag and must fail before any
# application files are removed.
fixture="$tmp/purge"
make_fixture "$fixture"
make_systemctl "$fixture"
if run_uninstall "$fixture" --purge-data >/dev/null 2>&1; then
echo 'expected --purge-data without --yes to fail' >&2
exit 1
fi
[[ -e "$fixture/opt/tallynote/current" && -f "$fixture/var/lib/tallynote/tallynote.db" ]]
run_uninstall "$fixture" --purge-data --yes --purge-config
[[ ! -e "$fixture/var/lib/tallynote" && ! -e "$fixture/var/lib/tallynote-backups" ]]
[[ ! -e "$fixture/etc/tallynote" ]]
# A custom data path must not overlap the release prefix; otherwise removing
# releases could destroy data that the default uninstall promises to keep.
fixture="$tmp/overlap"
make_fixture "$fixture"
make_systemctl "$fixture"
printf '%s\n' \
"TALLYNOTE_INSTALL_PREFIX=$fixture/opt/tallynote" \
"TALLYNOTE_DATA_DIR=$fixture/opt/tallynote/releases/data" \
> "$fixture/etc/tallynote/tallynote.env"
mkdir -p "$fixture/opt/tallynote/releases/data"
printf '%s\n' protected > "$fixture/opt/tallynote/releases/data/keep.db"
if run_uninstall "$fixture" >/dev/null 2>&1; then
echo 'expected overlapping data path to fail' >&2
exit 1
fi
[[ -f "$fixture/opt/tallynote/releases/data/keep.db" ]]
# Trailing-slash aliases are rejected before the lexical overlap guard can be
# bypassed.
fixture="$tmp/trailing"
make_fixture "$fixture"
make_systemctl "$fixture"
printf '%s\n' \
"TALLYNOTE_INSTALL_PREFIX=$fixture/opt/tallynote/" \
"TALLYNOTE_DATA_DIR=$fixture/var/lib/tallynote" \
> "$fixture/etc/tallynote/tallynote.env"
if run_uninstall "$fixture" >/dev/null 2>&1; then
echo 'expected trailing slash path to fail' >&2
exit 1
fi
[[ -e "$fixture/opt/tallynote/current" && -f "$fixture/var/lib/tallynote/tallynote.db" ]]
# Dot-component aliases are rejected as well; textual paths must be canonical
# before the managed-directory containment checks run.
fixture="$tmp/dot"
make_fixture "$fixture"
make_systemctl "$fixture"
printf '%s\n' \
"TALLYNOTE_INSTALL_PREFIX=$fixture/opt/tallynote/." \
"TALLYNOTE_DATA_DIR=$fixture/var/lib/tallynote" \
> "$fixture/etc/tallynote/tallynote.env"
if run_uninstall "$fixture" >/dev/null 2>&1; then
echo 'expected dot path component to fail' >&2
exit 1
fi
[[ -e "$fixture/opt/tallynote/current" && -f "$fixture/var/lib/tallynote/tallynote.db" ]]
# Pending update state blocks destructive work until an operator overrides it.
fixture="$tmp/pending"
make_fixture "$fixture"
make_systemctl "$fixture"
printf '%s\n' pending > "$fixture/opt/tallynote/.update-state"
if run_uninstall "$fixture" >/dev/null 2>&1; then
echo 'expected pending update state to block uninstall' >&2
exit 1
fi
[[ -e "$fixture/opt/tallynote/current" && -f "$fixture/var/lib/tallynote/tallynote.db" ]]
# A current link escaping the release tree is rejected without deleting data.
fixture="$tmp/link"
make_fixture "$fixture"
make_systemctl "$fixture"
rm -f "$fixture/opt/tallynote/current"
ln -s "$fixture/outside" "$fixture/opt/tallynote/current"
if run_uninstall "$fixture" >/dev/null 2>&1; then
echo 'expected unsafe current symlink to fail' >&2
exit 1
fi
[[ -L "$fixture/opt/tallynote/current" && -f "$fixture/var/lib/tallynote/tallynote.db" ]]
# dry-run must not call systemctl or remove files.
fixture="$tmp/dry-run"
make_fixture "$fixture"
make_systemctl "$fixture"
run_uninstall "$fixture" --dry-run >/dev/null
[[ -e "$fixture/opt/tallynote/current" && -f "$fixture/var/lib/tallynote/tallynote.db" ]]
[[ ! -e "$fixture/systemctl.log" ]]
printf '%s\n' 'uninstaller shell tests passed'
+1 -1
View File
@@ -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
View File
@@ -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");
}
+3 -2
View File
@@ -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
+4 -2
View File
@@ -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("收紧已有数据目录和数据库文件权限,并拒绝符号链接", () => {
+9 -9
View File
@@ -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.3-${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.3", 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.3", 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.3", 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.3", 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.3", 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.3" } });
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.3", 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.3", 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");
+3 -3
View File
@@ -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.3-${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.3", 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.3", 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 {
Executable
+473
View File
@@ -0,0 +1,473 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# TallyNote native uninstaller. The default operation removes only the
# application and service integration; the database and attachments stay in
# place until --purge-data --yes is explicitly requested.
PATH=/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/bin
export PATH
umask 077
TEST_MODE=${TALLYNOTE_UNINSTALL_TEST_MODE:-false}
TEST_ROOT=${TALLYNOTE_UNINSTALL_ROOT:-}
PREFIX=${TALLYNOTE_PREFIX:-/opt/tallynote}
DATA_DIR=${TALLYNOTE_DATA_DIR:-/var/lib/tallynote}
CONFIG_DIR=${TALLYNOTE_CONFIG_DIR:-/etc/tallynote}
UNIT_DIR=${TALLYNOTE_SYSTEMD_UNIT_DIR:-/etc/systemd/system}
SBIN_DIR=${TALLYNOTE_SBIN_DIR:-/usr/local/sbin}
LIBEXEC_DIR=${TALLYNOTE_LIBEXEC_DIR:-/usr/local/libexec}
SYSTEMCTL_BIN=systemctl
SYSTEMCTL_AVAILABLE=0
PURGE_DATA=0
PURGE_CONFIG=0
YES=0
DRY_RUN=0
FORCE=0
EXPLICIT_PREFIX=0
EXPLICIT_DATA=0
EXPLICIT_CONFIG=0
die() { printf 'tallynote uninstaller: %s\n' "$*" >&2; exit 1; }
log() { printf 'tallynote uninstaller: %s\n' "$*"; }
usage() {
cat <<'EOF'
Usage: tallynote-uninstall [--yes] [--purge-data] [--purge-config]
[--dry-run] [--force]
[--prefix PATH] [--data-dir PATH] [--config-dir PATH]
By default, remove the TallyNote release tree, systemd units, update helpers,
and known configuration files. The database, attachments, staging, exports,
update queue, and update backups are preserved. Data removal requires both
--purge-data and --yes. --force is only for an operator who has verified that
no update is in progress; it overrides the pending-update guard.
EOF
}
is_true() { [[ "$1" == true || "$1" == 1 ]]; }
if [[ "$TEST_MODE" != true && "$TEST_MODE" != false && "$TEST_MODE" != 1 && "$TEST_MODE" != 0 ]]; then
die 'TALLYNOTE_UNINSTALL_TEST_MODE must be true or false'
fi
if [[ "$TEST_MODE" == 1 ]]; then TEST_MODE=true; fi
if [[ "$TEST_MODE" == 0 ]]; then TEST_MODE=false; fi
while (($#)); do
case "$1" in
--yes) YES=1 ;;
--purge-data) PURGE_DATA=1 ;;
--purge-config) PURGE_CONFIG=1 ;;
--dry-run) DRY_RUN=1 ;;
--force) FORCE=1 ;;
--prefix) PREFIX=${2:?missing value for --prefix}; EXPLICIT_PREFIX=1; shift ;;
--data-dir) DATA_DIR=${2:?missing value for --data-dir}; EXPLICIT_DATA=1; shift ;;
--config-dir) CONFIG_DIR=${2:?missing value for --config-dir}; EXPLICIT_CONFIG=1; shift ;;
-h|--help) usage; exit 0 ;;
*) die "unknown option: $1" ;;
esac
shift
done
if [[ "$TEST_MODE" == true ]]; then
[[ -n "$TEST_ROOT" ]] || die 'test mode requires TALLYNOTE_UNINSTALL_ROOT'
[[ "$TEST_ROOT" = /* && "$TEST_ROOT" != *'..'* && "$TEST_ROOT" != *'//'* && "$TEST_ROOT" != *$'\n'* && "$TEST_ROOT" != *$'\r'* ]] || die 'test root is invalid'
(( EXPLICIT_PREFIX )) || PREFIX=${TALLYNOTE_PREFIX:-$TEST_ROOT/opt/tallynote}
(( EXPLICIT_DATA )) || DATA_DIR=${TALLYNOTE_DATA_DIR:-$TEST_ROOT/var/lib/tallynote}
(( EXPLICIT_CONFIG )) || CONFIG_DIR=${TALLYNOTE_CONFIG_DIR:-$TEST_ROOT/etc/tallynote}
UNIT_DIR=${TALLYNOTE_SYSTEMD_UNIT_DIR:-$TEST_ROOT/etc/systemd/system}
SBIN_DIR=${TALLYNOTE_SBIN_DIR:-$TEST_ROOT/usr/local/sbin}
LIBEXEC_DIR=${TALLYNOTE_LIBEXEC_DIR:-$TEST_ROOT/usr/local/libexec}
SYSTEMCTL_BIN=${TALLYNOTE_SYSTEMCTL_BIN:-systemctl}
fi
stat_uid() { stat -c '%u' "$1" 2>/dev/null || stat -f '%u' "$1"; }
stat_mode() { stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1"; }
stat_mode_bits() {
local mode
mode=$(stat_mode "$1")
[[ "$mode" =~ ^[0-7]+$ ]] || die "无法读取路径权限:$1"
printf '%d' "$((8#$mode))"
}
allowed_owner() {
local path=$1 uid
uid=$(stat_uid "$path")
if [[ "$TEST_MODE" == true ]]; then
[[ "$uid" == "$(id -u)" || "$uid" == 0 ]]
else
[[ "$uid" == 0 ]]
fi
}
allowed_data_owner() {
local path=$1 uid tallynote_uid
uid=$(stat_uid "$path")
if [[ "$TEST_MODE" == true ]]; then
[[ "$uid" == "$(id -u)" || "$uid" == 0 ]]
return
fi
[[ "$uid" == 0 ]] && return 0
tallynote_uid=$(id -u tallynote 2>/dev/null || true)
[[ -n "$tallynote_uid" && "$uid" == "$tallynote_uid" ]]
}
validate_path_value() {
local value=$1 label=$2
[[ "$value" = /* && "$value" != *$'\n'* && "$value" != *$'\r'* ]] || die "$label 必须是绝对路径"
[[ "$value" =~ ^/[A-Za-z0-9._/-]+$ && "$value" != *"//"* && "$value" != *"/../"* && "$value" != */.. && "$value" != *"/./"* && "$value" != */. && "$value" != / && "$value" != */ ]] || die "$label 包含不受支持的路径字符"
case "$value" in
/opt|/var|/etc|/usr|/usr/local|/bin|/sbin|/home|/root|/tmp) die "$label 不能指向系统顶层目录" ;;
esac
}
validate_parent_chain() {
local target=$1 current=/ component relative
relative=${target#/}
IFS='/' read -r -a _parts <<< "$relative"
for component in "${_parts[@]}"; do
[[ -n "$component" ]] || continue
current="${current%/}/$component"
if [[ -L "$current" ]]; then die "路径不能包含符号链接:$current"; fi
if [[ -e "$current" ]]; then
[[ -d "$current" ]] || die "路径不是目录:$current"
allowed_owner "$current" || die "路径目录的所有者不受信任:$current"
local mode_bits
mode_bits=$(stat_mode_bits "$current")
(( (mode_bits & 18) == 0 || (mode_bits & 512) != 0 )) || die "路径目录权限过宽:$current"
fi
done
}
validate_target() {
local target=$1 label=$2 owner_check=allowed_owner
[[ "${3:-}" == data ]] && owner_check=allowed_data_owner
validate_path_value "$target" "$label"
validate_parent_chain "$target"
if [[ -e "$target" || -L "$target" ]]; then
"$owner_check" "$target" || die "$label 的所有者不受信任:$target"
fi
}
read_env_value() {
local file=$1 key=$2
sed -n "s/^${key}=//p" "$file" | head -n 1
}
env_key_count() {
local file=$1 key=$2
awk -v key="$key" 'index($0, key "=") == 1 { count += 1 } END { print count + 0 }' "$file"
}
load_config() {
local env_file=$CONFIG_DIR/tallynote.env value key count
[[ -e "$env_file" || -L "$env_file" ]] || return 0
[[ -f "$env_file" && ! -L "$env_file" ]] || die '环境文件不是普通文件'
allowed_owner "$env_file" || die '环境文件的所有者不受信任'
local mode_bits
mode_bits=$(stat_mode_bits "$env_file")
(( (mode_bits & 18) == 0 )) || die '环境文件不能被组或其他用户写入'
for key in TALLYNOTE_INSTALL_PREFIX TALLYNOTE_DATA_DIR; do
count=$(env_key_count "$env_file" "$key")
[[ "$count" == 0 || "$count" == 1 ]] || die "环境文件包含重复配置:$key"
done
if (( ! EXPLICIT_PREFIX )); then
value=$(read_env_value "$env_file" TALLYNOTE_INSTALL_PREFIX)
[[ -z "$value" ]] || PREFIX=$value
fi
if (( ! EXPLICIT_DATA )); then
value=$(read_env_value "$env_file" TALLYNOTE_DATA_DIR)
[[ -z "$value" ]] || DATA_DIR=$value
fi
}
path_inside() {
local child=$1 parent=$2
[[ "$child" == "$parent"/* ]]
}
assert_disjoint_paths() {
local left left_label right right_label
local -a labels=(prefix data config unit sbin libexec)
for left_label in "${labels[@]}"; do
case "$left_label" in
prefix) left=$PREFIX ;;
data) left=$DATA_DIR ;;
config) left=$CONFIG_DIR ;;
unit) left=$UNIT_DIR ;;
sbin) left=$SBIN_DIR ;;
libexec) left=$LIBEXEC_DIR ;;
esac
for right_label in "${labels[@]}"; do
[[ "$left_label" == "$right_label" ]] && continue
case "$right_label" in
prefix) right=$PREFIX ;;
data) right=$DATA_DIR ;;
config) right=$CONFIG_DIR ;;
unit) right=$UNIT_DIR ;;
sbin) right=$SBIN_DIR ;;
libexec) right=$LIBEXEC_DIR ;;
esac
if [[ "$left" == "$right" ]] || path_inside "$left" "$right" || path_inside "$right" "$left"; then
die "卸载目录不能互相嵌套:$left 与 $right"
fi
done
done
}
assert_test_scope() {
[[ "$TEST_MODE" == true ]] || return 0
[[ -d "$TEST_ROOT" && ! -L "$TEST_ROOT" ]] || die 'test root must be an existing directory'
validate_parent_chain "$TEST_ROOT"
allowed_owner "$TEST_ROOT" || die 'test root owner is not trusted'
local value label
for label in PREFIX DATA_DIR CONFIG_DIR UNIT_DIR SBIN_DIR LIBEXEC_DIR; do
case "$label" in
PREFIX) value=$PREFIX ;;
DATA_DIR) value=$DATA_DIR ;;
CONFIG_DIR) value=$CONFIG_DIR ;;
UNIT_DIR) value=$UNIT_DIR ;;
SBIN_DIR) value=$SBIN_DIR ;;
LIBEXEC_DIR) value=$LIBEXEC_DIR ;;
esac
[[ "$value" == "$TEST_ROOT"/* ]] || die "test mode path escapes TALLYNOTE_UNINSTALL_ROOT: $value"
done
}
managed_file() {
local target=$1 label=$2
case "$label" in
service\ unit|updater\ unit|path\ unit|update\ helper|update\ runner|uninstaller)
grep -Eiq 'tallynote|TallyNote' "$target" || return 1
if [[ "$label" == 'path unit' ]]; then
grep -Fq "$DATA_DIR" "$target" || return 1
elif [[ "$label" == *unit ]]; then
grep -Fq "$PREFIX" "$target" || return 1
else
grep -Eq 'TALLYNOTE_INSTALL_PREFIX|/opt/tallynote' "$target" || return 1
fi
;;
environment\ file)
grep -q '^TALLYNOTE_INSTALL_PREFIX=' "$target" || return 1
grep -q '^TALLYNOTE_DATA_DIR=' "$target" || return 1
[[ "$(read_env_value "$target" TALLYNOTE_INSTALL_PREFIX)" == "$PREFIX" ]] || return 1
[[ "$(read_env_value "$target" TALLYNOTE_DATA_DIR)" == "$DATA_DIR" ]] || return 1
;;
update\ public\ key)
[[ -f "$CONFIG_DIR/tallynote.env" ]] || return 1
[[ "$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_UPDATE_PUBLIC_KEY_FILE)" == "$target" ]] || return 1
;;
*) return 0 ;;
esac
}
validate_release_tree() {
local tree=$1 owner_check=${2:-allowed_owner}
[[ -d "$tree" && ! -L "$tree" ]] || die "发布目录无效:$tree"
"$owner_check" "$tree" || die "发布目录的所有者不受信任:$tree"
if find "$tree" -type l -print -quit | grep -q .; then
die "发布目录包含符号链接:$tree"
fi
if find "$tree" ! -type d ! -type f -print -quit | grep -q .; then
die "发布目录包含不支持的文件类型:$tree"
fi
local node mode_bits
while IFS= read -r node; do
"$owner_check" "$node" || die "发布目录节点的所有者不受信任:$node"
mode_bits=$(stat_mode_bits "$node")
(( (mode_bits & 18) == 0 )) || die "发布目录节点权限过宽:$node"
done < <(find "$tree" -print)
}
pending_update() {
[[ -e "$PREFIX/.update-state" || -L "$PREFIX/.update-state" || -e "$DATA_DIR/update-request.json" || -L "$DATA_DIR/update-request.json" ]]
}
run_systemctl() {
(( DRY_RUN )) && return 0
if [[ "$SYSTEMCTL_BIN" == */* ]]; then
[[ -x "$SYSTEMCTL_BIN" ]] || return 0
else
command -v "$SYSTEMCTL_BIN" >/dev/null 2>&1 || return 0
fi
"$SYSTEMCTL_BIN" "$@"
}
stop_services() {
local unit active status
if (( DRY_RUN )); then
log 'dry-run: would stop/disable systemd units in path -> updater -> app order'
return 0
fi
if (( ! SYSTEMCTL_AVAILABLE )); then
for unit in tallynote-update.path tallynote-update.service tallynote.service; do
[[ ! -e "$UNIT_DIR/$unit" ]] || die 'systemctl 不可用,无法安全停止已安装服务'
done
return 0
fi
for unit in tallynote-update.path tallynote-update.service tallynote.service; do
active=0
if run_systemctl is-active --quiet "$unit" >/dev/null 2>&1; then
active=1
else
status=$?
case "$status" in
3|4) ;;
*) die "无法读取服务状态:$unit" ;;
esac
fi
if (( active )); then
run_systemctl stop "$unit" || die "无法停止服务:$unit"
fi
if [[ -e "$UNIT_DIR/$unit" ]]; then
run_systemctl disable "$unit" >/dev/null 2>&1 || die "无法禁用服务:$unit"
fi
done
run_systemctl daemon-reload >/dev/null 2>&1 || die 'systemd daemon-reload 失败'
}
validate_systemctl() {
local resolved uid mode_bits
if [[ "$TEST_MODE" == true ]]; then
if [[ "$SYSTEMCTL_BIN" == */* && -x "$SYSTEMCTL_BIN" ]]; then
SYSTEMCTL_AVAILABLE=1
fi
return 0
fi
resolved=$(command -v systemctl 2>/dev/null || true)
if [[ -z "$resolved" ]]; then
SYSTEMCTL_AVAILABLE=0
return 0
fi
[[ -x "$resolved" && ! -L "$resolved" ]] || die 'systemctl 必须是可信的普通可执行文件'
uid=$(stat_uid "$resolved")
mode_bits=$(stat_mode_bits "$resolved")
[[ "$uid" == 0 && $((mode_bits & 18)) -eq 0 ]] || die 'systemctl 必须由 root 拥有且不可被其他用户写入'
SYSTEMCTL_BIN=$resolved
SYSTEMCTL_AVAILABLE=1
}
remove_file_if_owned() {
local target=$1 label=$2
[[ -e "$target" || -L "$target" ]] || return 0
if [[ -L "$target" || ! -f "$target" ]]; then
log "warning: 保留非普通文件:$target"
return 0
fi
if ! allowed_owner "$target"; then
log "warning: 保留非本安装创建的文件:$target"
return 0
fi
if ! managed_file "$target" "$label"; then
log "warning: 保留内容不匹配的文件:$target"
return 0
fi
if (( DRY_RUN )); then
log "dry-run: remove $label $target"
else
rm -f -- "$target"
fi
}
remove_tree() {
local target=$1 label=$2 owner_check=${3:-allowed_owner}
[[ -e "$target" || -L "$target" ]] || return 0
[[ -d "$target" && ! -L "$target" ]] || die "$label 不是安全目录:$target"
"$owner_check" "$target" || die "$label 的所有者不受信任:$target"
validate_release_tree "$target" "$owner_check"
if (( DRY_RUN )); then
log "dry-run: remove $label $target"
else
rm -rf -- "$target"
fi
}
remove_prefix() {
local current=$PREFIX/current current_target releases=$PREFIX/releases
if [[ -L "$current" ]]; then
current_target=$(readlink "$current")
[[ "$current_target" = "$PREFIX/releases/"* && "$current_target" != *'..'* ]] || die 'current 符号链接指向安装目录之外'
[[ -d "$current_target" && ! -L "$current_target" ]] || die 'current 目标不是安全目录'
if (( DRY_RUN )); then
log "dry-run: remove current link $current"
else
rm -f -- "$current"
fi
elif [[ -e "$current" ]]; then
log "warning: 保留非符号链接 current:$current"
fi
remove_tree "$releases" 'releases'
remove_tree "$PREFIX/.update-work" 'update work'
remove_file_if_owned "$PREFIX/.update-state" 'update state'
if [[ -d "$PREFIX" && ! -L "$PREFIX" ]]; then
allowed_owner "$PREFIX" || die "安装目录的所有者不受信任:$PREFIX"
if (( DRY_RUN )); then
log "dry-run: remove empty install directory if empty: $PREFIX"
else
rmdir -- "$PREFIX" 2>/dev/null || true
fi
fi
}
remove_config() {
remove_file_if_owned "$CONFIG_DIR/update-signing-key.pub" 'update public key'
remove_file_if_owned "$CONFIG_DIR/tallynote.env" 'environment file'
if (( PURGE_CONFIG )) && [[ -d "$CONFIG_DIR" && ! -L "$CONFIG_DIR" ]]; then
allowed_owner "$CONFIG_DIR" || die '配置目录的所有者不受信任'
if (( DRY_RUN )); then log "dry-run: remove config directory if safe: $CONFIG_DIR"; else rmdir -- "$CONFIG_DIR" 2>/dev/null || true; fi
fi
}
remove_data() {
local backup_dir
backup_dir=$(dirname -- "$DATA_DIR")/tallynote-backups
if (( PURGE_DATA )); then
(( YES )) || die '--purge-data 必须同时提供 --yes'
remove_tree "$DATA_DIR" 'data' allowed_data_owner
remove_tree "$backup_dir" 'backup data'
else
log "保留数据目录:$DATA_DIR"
if [[ -d "$backup_dir" ]]; then
log "保留备份目录:$backup_dir"
fi
fi
return 0
}
main() {
if [[ "$TEST_MODE" != true ]]; then
[[ $EUID -eq 0 ]] || die '卸载必须以 root 运行(请使用 sudo)'
fi
if (( PURGE_DATA && ! YES )); then
die '--purge-data 必须同时提供 --yes'
fi
validate_path_value "$CONFIG_DIR" '配置目录'
validate_target "$CONFIG_DIR" '配置目录'
assert_test_scope
load_config
validate_target "$PREFIX" '安装目录'
validate_target "$DATA_DIR" '数据目录' data
validate_target "$CONFIG_DIR" '配置目录'
validate_target "$UNIT_DIR" 'systemd 单元目录'
validate_target "$SBIN_DIR" 'sbin 目录'
validate_target "$LIBEXEC_DIR" 'libexec 目录'
assert_test_scope
assert_disjoint_paths
validate_systemctl
if (( ! FORCE )) && pending_update; then
die '检测到未完成的更新状态;确认更新已停止后使用 --force 重试'
fi
log "target: prefix=$PREFIX data=$DATA_DIR config=$CONFIG_DIR"
stop_services
remove_prefix
remove_file_if_owned "$UNIT_DIR/tallynote.service" 'service unit'
remove_file_if_owned "$UNIT_DIR/tallynote-update.service" 'updater unit'
remove_file_if_owned "$UNIT_DIR/tallynote-update.path" 'path unit'
remove_file_if_owned "$SBIN_DIR/tallynote-update" 'update helper'
remove_file_if_owned "$LIBEXEC_DIR/tallynote-update-runner" 'update runner'
remove_file_if_owned "$SBIN_DIR/tallynote-uninstall" 'uninstaller'
remove_config
remove_data
log 'uninstall complete'
}
main "$@"