Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37ffc27ff5 | ||
|
|
5dcf9d0f61 |
@@ -63,7 +63,7 @@ curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/ra
|
||||
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
监听方式有两个选项:`127.0.0.1` 仅本机访问(默认、更安全),或 `0.0.0.0` 允许通过局域网/公网 IP 访问。安装时可输入自定义端口(直接回车使用默认端口),安装器会检查 TCP 端口是否已被占用;选择 `0.0.0.0` 时会尝试通过 HTTPS 自动获取公网 IPv4,并将 `http://公网IP:端口` 作为默认访问地址,也可以改填域名。不能填写 `http://0.0.0.0:3000`。直连 HTTP 未加密,安装器会要求明确确认,只适合受控网络。绑定域名后应改为 HTTPS 反向代理,设置真实的 `TALLYNOTE_PUBLIC_ORIGIN`、`TALLYNOTE_COOKIE_SECURE=true`、`TALLYNOTE_ALLOW_INSECURE_HTTP=false`,然后执行 `sudo systemctl restart tallynote.service`。服务启动成功后,安装日志会输出最终访问链接。
|
||||
监听方式有两个选项:`127.0.0.1` 仅本机访问(默认、更安全),或 `0.0.0.0` 允许通过局域网/公网 IP 访问。安装时可输入自定义端口(直接回车使用默认端口),安装器会检查 TCP 端口是否已被占用;选择 `0.0.0.0` 时会尝试通过 HTTPS 自动获取公网 IPv4,并将 `http://公网IP:端口` 作为默认访问地址,也可以改填域名。不能填写 `http://0.0.0.0:3000`。直连 HTTP 未加密,安装器会要求明确确认,只适合受控网络。绑定域名后应改为 HTTPS 反向代理,设置真实的 `TALLYNOTE_PUBLIC_ORIGIN`、`TALLYNOTE_COOKIE_SECURE=true`、`TALLYNOTE_ALLOW_INSECURE_HTTP=false`,然后执行 `sudo systemctl restart tallynote.service`。服务启动后,安装器会先请求本机 `/health`;只有健康检查通过才会报告安装完成并输出最终访问链接。监听 `127.0.0.1` 时该链接只对服务器本机有效;需要公网或其他设备访问时请选择 `0.0.0.0`。健康检查失败时会输出 systemd 状态和最近日志并回滚本次切换。
|
||||
|
||||
安装器不会在已有安装的升级过程中反复询问网络配置,并会保留现有环境文件。自动化或无终端环境可使用 `--non-interactive`(默认安全配置 `127.0.0.1:3000`),也可以显式传入 `TALLYNOTE_HOST`、`TALLYNOTE_PORT`、`TALLYNOTE_PUBLIC_ORIGIN` 和 `TALLYNOTE_ALLOW_INSECURE_HTTP` 覆盖配置。
|
||||
|
||||
@@ -137,6 +137,8 @@ sudo /usr/local/sbin/tallynote-uninstall --purge-data --yes --purge-config
|
||||
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/uninstall.sh | sudo bash
|
||||
```
|
||||
|
||||
卸载器会逐项输出停止、禁用和删除进度;每次 systemd/dbus 调用默认最多等待 30 秒,避免终端无限无响应。可通过 `TALLYNOTE_UNINSTALL_SYSTEMCTL_TIMEOUT_SECONDS` 调整超时时间。
|
||||
|
||||
公网反代必须使用 HTTPS,并在环境文件中设置真实的 `TALLYNOTE_PUBLIC_ORIGIN=https://...`、`TALLYNOTE_COOKIE_SECURE=true` 和明确的 `TALLYNOTE_TRUST_PROXY` 跳数(不要使用生产值 `true`)。
|
||||
|
||||
### 构建发布包
|
||||
|
||||
+3
-1
@@ -38,7 +38,7 @@ GITEA_TOKEN=... \
|
||||
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
首次在交互式 SSH/终端中执行时,安装器会在下载前询问监听方式和端口(端口可直接回车使用默认值),并检查所选 TCP 端口是否已被占用。可选择仅本机监听 `127.0.0.1`,或监听 `0.0.0.0` 以允许通过真实服务器 IP/域名访问;选择公网监听时会尝试通过 HTTPS 自动获取公网 IPv4,将 `http://公网IP:端口` 作为默认访问地址,也可以手动改填域名。公网 HTTP 必须在提示中明确确认,公开地址不能填写通配监听地址。服务启动成功后,安装日志会输出最终访问链接。已有安装升级时不会重复询问,并保留现有环境文件。无终端或 CI 使用 `--non-interactive`(默认 `127.0.0.1:3000`),也可通过 `TALLYNOTE_HOST`、`TALLYNOTE_PORT`、`TALLYNOTE_PUBLIC_ORIGIN` 和 `TALLYNOTE_ALLOW_INSECURE_HTTP` 显式配置。
|
||||
首次在交互式 SSH/终端中执行时,安装器会在下载前询问监听方式和端口(端口可直接回车使用默认值),并检查所选 TCP 端口是否已被占用。可选择仅本机监听 `127.0.0.1`,或监听 `0.0.0.0` 以允许通过真实服务器 IP/域名访问;选择公网监听时会尝试通过 HTTPS 自动获取公网 IPv4,将 `http://公网IP:端口` 作为默认访问地址,也可以手动改填域名。公网 HTTP 必须在提示中明确确认,公开地址不能填写通配监听地址。服务启动后,安装器会先请求本机 `/health`,只有健康检查通过才会报告安装完成并输出最终访问链接。监听 `127.0.0.1` 时链接只对服务器本机有效;需要公网或其他设备访问时请选择 `0.0.0.0`。健康检查失败时会输出 systemd 状态和最近日志并回滚本次切换。已有安装升级时不会重复询问,并保留现有环境文件。无终端或 CI 使用 `--non-interactive`(默认 `127.0.0.1:3000`),也可通过 `TALLYNOTE_HOST`、`TALLYNOTE_PORT`、`TALLYNOTE_PUBLIC_ORIGIN` 和 `TALLYNOTE_ALLOW_INSECURE_HTTP` 显式配置。
|
||||
|
||||
非交互安装命令:
|
||||
|
||||
@@ -98,6 +98,8 @@ sudo /usr/local/sbin/tallynote-uninstall
|
||||
|
||||
只有显式 `--purge-data --yes` 才会删除 SQLite、附件、暂存、导出、更新队列和备份;`--purge-config` 可在确认配置目录中没有其他文件后移除空配置目录。卸载器不会自动删除 `tallynote` 系统用户,也不会跟随符号链接删除目录。检测到 `.update-state` 或 `update-request.json` 时会拒绝执行,确认更新已经停止后使用 `--force`。
|
||||
|
||||
卸载过程中会输出每个 systemd 单元的检查、停止、禁用和删除阶段;systemd/dbus 调用默认 30 秒超时,避免长时间无反馈。可用 `TALLYNOTE_UNINSTALL_SYSTEMCTL_TIMEOUT_SECONDS` 调整。
|
||||
|
||||
## 后台一键更新
|
||||
|
||||
将环境文件中的 `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 签名。
|
||||
|
||||
+71
-2
@@ -60,6 +60,10 @@ INSTALL_BACKUP_DIR=''
|
||||
INSTALL_WAS_ACTIVE=0
|
||||
INSTALL_PATH_WAS_ACTIVE=0
|
||||
INSTALL_UPDATE_WAS_ACTIVE=0
|
||||
INSTALL_WAS_ENABLED=0
|
||||
INSTALL_PATH_WAS_ENABLED=0
|
||||
INSTALL_UPDATE_WAS_ENABLED=0
|
||||
INSTALL_SYSTEMD_TOUCHED=0
|
||||
DATA_DIR_TEMP_ROOT=0
|
||||
DATA_DIR_ORIGINAL_OWNER=''
|
||||
|
||||
@@ -218,6 +222,24 @@ check_requested_port() {
|
||||
esac
|
||||
}
|
||||
|
||||
wait_for_service_health() {
|
||||
local host=$1 port=$2 health_host health_url attempt
|
||||
health_host=$host
|
||||
case "$health_host" in
|
||||
0.0.0.0) health_host=127.0.0.1 ;;
|
||||
::) health_host=::1 ;;
|
||||
esac
|
||||
if [[ "$health_host" == *:* && "$health_host" != \[* ]]; then health_host="[$health_host]"; fi
|
||||
health_url="http://${health_host}:${port}/health"
|
||||
for attempt in 1 2 3 4 5 6 7 8 9 10 11 12; do
|
||||
if curl --proto '=http' --connect-timeout 2 --max-time 3 --fail --silent "$health_url" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
(( attempt < 12 )) && sleep 1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
has_network_environment() {
|
||||
[[ -n "${TALLYNOTE_HOST+x}" || -n "${TALLYNOTE_PORT+x}" || -n "${TALLYNOTE_PUBLIC_ORIGIN+x}" || -n "${TALLYNOTE_ALLOW_INSECURE_HTTP+x}" ]]
|
||||
}
|
||||
@@ -770,6 +792,17 @@ stop_existing_services() {
|
||||
# Stop the path trigger first so it cannot launch the privileged updater while
|
||||
# the data tree is being repaired.
|
||||
for unit in tallynote-update.path tallynote-update.service tallynote.service; do
|
||||
case "$unit" in
|
||||
tallynote.service)
|
||||
if systemctl is-enabled --quiet "$unit"; then INSTALL_WAS_ENABLED=1; fi
|
||||
;;
|
||||
tallynote-update.path)
|
||||
if systemctl is-enabled --quiet "$unit"; then INSTALL_PATH_WAS_ENABLED=1; fi
|
||||
;;
|
||||
tallynote-update.service)
|
||||
if systemctl is-enabled --quiet "$unit"; then INSTALL_UPDATE_WAS_ENABLED=1; fi
|
||||
;;
|
||||
esac
|
||||
if systemctl is-active --quiet "$unit"; then
|
||||
case "$unit" in
|
||||
tallynote.service) INSTALL_WAS_ACTIVE=1 ;;
|
||||
@@ -783,6 +816,17 @@ stop_existing_services() {
|
||||
|
||||
rollback_install_if_needed() {
|
||||
local result=$? rollback_tmp
|
||||
if (( INSTALL_COMMITTED == 0 && INSTALL_SYSTEMD_TOUCHED == 1 )) && command -v systemctl >/dev/null 2>&1; then
|
||||
# The failed install may have started units that were inactive before the
|
||||
# attempt. Stop them before restoring files so systemd never keeps running
|
||||
# code from a release directory that rollback is about to remove.
|
||||
for unit in tallynote-update.path tallynote-update.service tallynote.service; do
|
||||
systemctl stop "$unit" >/dev/null 2>&1 || true
|
||||
done
|
||||
if (( INSTALL_WAS_ENABLED == 0 )); then systemctl disable tallynote.service >/dev/null 2>&1 || true; fi
|
||||
if (( INSTALL_PATH_WAS_ENABLED == 0 )); then systemctl disable tallynote-update.path >/dev/null 2>&1 || true; fi
|
||||
if (( INSTALL_UPDATE_WAS_ENABLED == 0 )); then systemctl disable tallynote-update.service >/dev/null 2>&1 || true; fi
|
||||
fi
|
||||
if (( INSTALL_SWITCHED == 1 && INSTALL_COMMITTED == 0 )); then
|
||||
if [[ -n "$INSTALL_PREVIOUS_TARGET" && -d "$INSTALL_PREVIOUS_TARGET" ]]; then
|
||||
rollback_tmp="$PREFIX/.current-rollback-$$-${RANDOM}.tmp"
|
||||
@@ -821,6 +865,7 @@ rollback_install_if_needed() {
|
||||
done
|
||||
fi
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
if (( INSTALL_SYSTEMD_TOUCHED == 1 )); then systemctl daemon-reload >/dev/null 2>&1 || true; fi
|
||||
if (( INSTALL_WAS_ACTIVE == 1 )); then systemctl start tallynote.service 2>/dev/null || true; fi
|
||||
if (( INSTALL_UPDATE_WAS_ACTIVE == 1 )); then systemctl start tallynote-update.service 2>/dev/null || true; fi
|
||||
if (( INSTALL_PATH_WAS_ACTIVE == 1 )); then systemctl start tallynote-update.path 2>/dev/null || true; fi
|
||||
@@ -1038,7 +1083,10 @@ validate_existing_env() {
|
||||
install_release() {
|
||||
local archive=$1 version=$2 tmp release_dir current_tmp=''
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp" "$current_tmp" 2>/dev/null || true' RETURN
|
||||
# RETURN traps survive the function that installs them. Clear the trap from
|
||||
# inside its first invocation so a later function cannot evaluate the local
|
||||
# temporary path after it has gone out of scope under `set -u`.
|
||||
trap 'trap - RETURN; if [[ -n "${tmp-}" ]]; then rm -rf -- "$tmp" 2>/dev/null || true; fi; if [[ -n "${current_tmp-}" ]]; then rm -f -- "$current_tmp" 2>/dev/null || true; fi' RETURN
|
||||
safe_extract "$archive" "$tmp/unpacked"
|
||||
normalize_release_tree "$tmp/unpacked"
|
||||
[[ -d "$tmp/unpacked/dist" ]] || die 'release archive must contain dist/ at its root'
|
||||
@@ -1302,7 +1350,7 @@ main() {
|
||||
fi
|
||||
}
|
||||
set_env_key() {
|
||||
local key=$1 value=$2 escaped tmp
|
||||
local key=$1 value=$2 escaped
|
||||
[[ "$key" =~ ^[A-Z0-9_]+$ ]] || die '环境变量名无效'
|
||||
validate_env_value "$value" "$key"
|
||||
escaped=${value//\\/\\\\}
|
||||
@@ -1364,7 +1412,28 @@ main() {
|
||||
chown root:root "$CONFIG_DIR/tallynote.env"
|
||||
chmod 640 "$CONFIG_DIR/tallynote.env"
|
||||
systemctl daemon-reload
|
||||
INSTALL_SYSTEMD_TOUCHED=1
|
||||
systemctl enable --now tallynote.service tallynote-update.path
|
||||
local health_host health_port
|
||||
health_host=$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_HOST 2>/dev/null || true)
|
||||
health_port=$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_PORT 2>/dev/null || true)
|
||||
health_host=${health_host:-$INSTALL_HOST}
|
||||
health_port=${health_port:-$INSTALL_PORT}
|
||||
stage "检查本机健康接口(${health_host}:${health_port})"
|
||||
if ! wait_for_service_health "$health_host" "$health_port"; then
|
||||
log "本机健康检查失败:http://${health_host}:${health_port}/health"
|
||||
systemctl status tallynote.service --no-pager -l || true
|
||||
if command -v journalctl >/dev/null 2>&1; then
|
||||
journalctl -u tallynote.service -n 30 --no-pager || true
|
||||
fi
|
||||
die 'TallyNote 服务未通过健康检查;安装未完成,请根据上面的 systemd 日志修复后重试'
|
||||
fi
|
||||
stage_done '本机健康检查通过,服务正在监听'
|
||||
if [[ "$health_host" == 127.0.0.1 || "$health_host" == localhost || "$health_host" == ::1 ]]; then
|
||||
log '当前监听仅限本机;公网或其他设备无法直接访问,请重新安装并选择 0.0.0.0,或配置 HTTPS 反向代理'
|
||||
else
|
||||
log '当前监听已绑定非本机地址;若外部仍无法连接,请检查云安全组、主机防火墙和公网 IP/NAT'
|
||||
fi
|
||||
stage_done 'TallyNote 服务已启用并启动'
|
||||
stage '清理旧版本并完成安装'
|
||||
prune_releases
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tallynote",
|
||||
"version": "1.1.7",
|
||||
"version": "1.1.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@9.0.6",
|
||||
|
||||
@@ -141,6 +141,52 @@ bash -c '
|
||||
fi
|
||||
' _ "$installer_lib"
|
||||
|
||||
# The service health probe maps wildcard listeners to loopback and must return
|
||||
# promptly when the local endpoint is healthy.
|
||||
bash -c '
|
||||
script=$1
|
||||
set --
|
||||
source "$script"
|
||||
curl() { [[ "$*" == *"http://127.0.0.1:3011/health"* ]] || return 1; }
|
||||
wait_for_service_health 0.0.0.0 3011
|
||||
' _ "$installer_lib"
|
||||
|
||||
# A RETURN trap installed by install_release must be cleared while its local
|
||||
# temporary variables still exist; otherwise set -u fails at the end of main.
|
||||
release_fixture="$tmp/release-fixture"
|
||||
mkdir -p "$release_fixture/dist/server" "$release_fixture/dist/web" "$release_fixture/bin" \
|
||||
"$release_fixture/scripts" "$release_fixture/runtime/bin" "$release_fixture/systemd"
|
||||
printf '%s\n' '{"version":"1.0.0"}' > "$release_fixture/package.json"
|
||||
printf '%s\n' server > "$release_fixture/dist/server/index.js"
|
||||
printf '%s\n' web > "$release_fixture/dist/web/index.html"
|
||||
printf '%s\n' '#!/bin/sh' > "$release_fixture/bin/tallynote"
|
||||
printf '%s\n' '#!/bin/sh' > "$release_fixture/scripts/tallynote-update.sh"
|
||||
printf '%s\n' '#!/bin/sh' > "$release_fixture/scripts/tallynote-update-runner.sh"
|
||||
printf '%s\n' '#!/bin/sh' > "$release_fixture/uninstall.sh"
|
||||
printf '%s\n' '[Unit]' > "$release_fixture/systemd/tallynote.service"
|
||||
printf '%s\n' '[Unit]' > "$release_fixture/systemd/tallynote-update.service"
|
||||
printf '%s\n' '[Unit]' > "$release_fixture/systemd/tallynote-update.path"
|
||||
printf '%s\n' 'TALLYNOTE_HOST=127.0.0.1' > "$release_fixture/systemd/tallynote.env.example"
|
||||
chmod 755 "$release_fixture/bin/tallynote" "$release_fixture/scripts"/*.sh "$release_fixture/uninstall.sh"
|
||||
release_archive="$tmp/release-fixture.tar.gz"
|
||||
tar -C "$release_fixture" -czf "$release_archive" .
|
||||
bash -c '
|
||||
script=$1
|
||||
archive=$2
|
||||
destination=$3
|
||||
set --
|
||||
source "$script"
|
||||
PREFIX="$destination/prefix"
|
||||
ensure_root_directory() { mkdir -p "$1"; }
|
||||
chown() { :; }
|
||||
mv() {
|
||||
if [[ "${1:-}" == -Tf ]]; then shift; /bin/mv -f "$@"; else /bin/mv "$@"; fi
|
||||
}
|
||||
install_release "$archive" 1.0.0
|
||||
set_env_key() { local key=$1 value=$2 escaped; :; }
|
||||
set_env_key test value
|
||||
' _ "$installer_lib" "$release_archive" "$tmp/install-release"
|
||||
|
||||
# Duplicate security-sensitive EnvironmentFile assignments are rejected even
|
||||
# when the first value looks valid (systemd uses the later value).
|
||||
duplicate_env="$tmp/duplicate.env"
|
||||
|
||||
+14
-14
@@ -59,10 +59,10 @@ describe("更新 API", () => {
|
||||
|
||||
function mockRelease() {
|
||||
const digest = "c".repeat(64);
|
||||
const asset = `tallynote-1.1.8-${detectPlatform().target}-glibc.tar.gz`;
|
||||
const asset = `tallynote-1.2.0-${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.8", 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.2.0", 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.8", compatible: true, integrityReady: true, isNewer: true });
|
||||
expect(checked.json().latest).toMatchObject({ version: "1.2.0", 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.8", 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.2.0", 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.8", expectedSha256: "c".repeat(64), currentLink: config.currentLink });
|
||||
expect(request).toMatchObject({ jobId, version: "1.2.0", 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.8", 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.2.0", 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 } });
|
||||
@@ -98,10 +98,10 @@ 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);
|
||||
const downloaded = await app.inject({ method: "POST", url: "/api/update/download", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.8", confirm: true } });
|
||||
const downloaded = await app.inject({ method: "POST", url: "/api/update/download", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.2.0", confirm: true } });
|
||||
expect(downloaded.statusCode).toBe(202);
|
||||
const downloadJobId = downloaded.json().job.id as string;
|
||||
expect(downloaded.json().job).toMatchObject({ operation: "download", status: "queued", version: "1.1.8" });
|
||||
expect(downloaded.json().job).toMatchObject({ operation: "download", status: "queued", version: "1.2.0" });
|
||||
const downloadRequest = JSON.parse(readFileSync(config.updateRequestPath, "utf8")) as { jobId: string; operation: string };
|
||||
expect(downloadRequest).toMatchObject({ jobId: downloadJobId, operation: "download" });
|
||||
expect(database.sqlite.prepare("SELECT operation, status FROM update_jobs WHERE id=?").get(downloadJobId)).toEqual({ operation: "download", status: "queued" });
|
||||
@@ -110,21 +110,21 @@ describe("更新 API", () => {
|
||||
const stagedId = randomUUID();
|
||||
const now = Date.now();
|
||||
database.sqlite.prepare(`INSERT INTO update_jobs(id, admin_id, session_hash, request_id, requested_at, operation, status, version, platform, release_url, asset_name, asset_url, expected_sha256, actual_sha256, download_path, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'download', 'staged', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run(stagedId, (await database.sqlite.prepare("SELECT id FROM admins WHERE username=?").get("update-staged") as { id: string }).id, "session", "staged-request", now, "1.1.8", detectPlatform().target, config.updateMetadataUrl, "release.tar.gz", "https://updates.example/release.tar.gz", "c".repeat(64), "c".repeat(64), path.join(config.dataDir, "staged-workspace"), now, now);
|
||||
const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { jobId: stagedId, version: "1.1.8", confirm: true } });
|
||||
.run(stagedId, (await database.sqlite.prepare("SELECT id FROM admins WHERE username=?").get("update-staged") as { id: string }).id, "session", "staged-request", now, "1.2.0", detectPlatform().target, config.updateMetadataUrl, "release.tar.gz", "https://updates.example/release.tar.gz", "c".repeat(64), "c".repeat(64), path.join(config.dataDir, "staged-workspace"), now, now);
|
||||
const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { jobId: stagedId, version: "1.2.0", confirm: true } });
|
||||
expect(applied.statusCode).toBe(202);
|
||||
expect(applied.json().job).toMatchObject({ id: stagedId, operation: "apply", status: "staged" });
|
||||
expect(database.sqlite.prepare("SELECT operation, status FROM update_jobs WHERE id=?").get(stagedId)).toEqual({ operation: "apply", status: "staged" });
|
||||
const applyRequest = JSON.parse(readFileSync(config.updateRequestPath, "utf8")) as { jobId: string; operation: string; assetUrl: string; expectedSha256: string };
|
||||
expect(applyRequest).toMatchObject({ jobId: stagedId, operation: "apply", assetUrl: "https://updates.example/release.tar.gz", expectedSha256: "c".repeat(64) });
|
||||
const duplicate = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { jobId: stagedId, version: "1.1.8", 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: { jobId: stagedId, version: "1.2.0", confirm: true } });
|
||||
expect(duplicate.statusCode).toBe(409);
|
||||
expect(duplicate.json().error.code).toBe("UPDATE_IN_PROGRESS");
|
||||
});
|
||||
|
||||
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.8" } });
|
||||
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.2.0" } });
|
||||
expect(invalid.statusCode).toBe(400);
|
||||
process.env.TALLYNOTE_UPDATE_STRATEGY = "disabled";
|
||||
const disabledConfig = loadConfig();
|
||||
@@ -137,7 +137,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.8", 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.2.0", 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);
|
||||
@@ -155,7 +155,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.8", 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.2.0", confirm: true } });
|
||||
expect(response.statusCode).toBe(502);
|
||||
const audit = database.sqlite.prepare("SELECT outcome FROM audit_events WHERE action='update.apply_requested' ORDER BY id DESC LIMIT 1").get() as { outcome: string } | undefined;
|
||||
expect(audit?.outcome).toBe("failure");
|
||||
|
||||
@@ -273,17 +273,17 @@ describe("更新元数据缓存", () => {
|
||||
prepareDataDirectories(config);
|
||||
const database = openDatabase(config);
|
||||
const digest = "b".repeat(64);
|
||||
const platformAsset = `tallynote-1.1.8-${detectPlatform().target}-glibc.tar.gz`;
|
||||
const platformAsset = `tallynote-1.2.0-${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.8", 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.2.0", 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.8", compatible: true, integrityReady: true, signatureReady: true, isNewer: true });
|
||||
expect(result.latest).toMatchObject({ version: "1.2.0", 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 {
|
||||
|
||||
+18
-2
@@ -19,6 +19,7 @@ SBIN_DIR=${TALLYNOTE_SBIN_DIR:-/usr/local/sbin}
|
||||
LIBEXEC_DIR=${TALLYNOTE_LIBEXEC_DIR:-/usr/local/libexec}
|
||||
SYSTEMCTL_BIN=systemctl
|
||||
SYSTEMCTL_AVAILABLE=0
|
||||
SYSTEMCTL_TIMEOUT_SECONDS=${TALLYNOTE_UNINSTALL_SYSTEMCTL_TIMEOUT_SECONDS:-30}
|
||||
PURGE_DATA=0
|
||||
PURGE_CONFIG=0
|
||||
YES=0
|
||||
@@ -52,6 +53,7 @@ if [[ "$TEST_MODE" != true && "$TEST_MODE" != false && "$TEST_MODE" != 1 && "$TE
|
||||
fi
|
||||
if [[ "$TEST_MODE" == 1 ]]; then TEST_MODE=true; fi
|
||||
if [[ "$TEST_MODE" == 0 ]]; then TEST_MODE=false; fi
|
||||
[[ "$SYSTEMCTL_TIMEOUT_SECONDS" =~ ^[1-9][0-9]*$ ]] || die 'TALLYNOTE_UNINSTALL_SYSTEMCTL_TIMEOUT_SECONDS must be a positive integer'
|
||||
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
@@ -296,7 +298,13 @@ run_systemctl() {
|
||||
else
|
||||
command -v "$SYSTEMCTL_BIN" >/dev/null 2>&1 || return 0
|
||||
fi
|
||||
"$SYSTEMCTL_BIN" "$@"
|
||||
# A stuck systemd/dbus call must not leave the uninstaller looking frozen.
|
||||
# Test fixtures intentionally bypass the external timeout command.
|
||||
if [[ "$TEST_MODE" != true ]] && command -v timeout >/dev/null 2>&1; then
|
||||
timeout "$SYSTEMCTL_TIMEOUT_SECONDS" "$SYSTEMCTL_BIN" "$@"
|
||||
else
|
||||
"$SYSTEMCTL_BIN" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
stop_services() {
|
||||
@@ -311,8 +319,10 @@ stop_services() {
|
||||
done
|
||||
return 0
|
||||
fi
|
||||
log "正在停止 TallyNote 服务(systemd 操作超时 ${SYSTEMCTL_TIMEOUT_SECONDS} 秒)"
|
||||
for unit in tallynote-update.path tallynote-update.service tallynote.service; do
|
||||
active=0
|
||||
log "检查服务:$unit"
|
||||
if run_systemctl is-active --quiet "$unit" >/dev/null 2>&1; then
|
||||
active=1
|
||||
else
|
||||
@@ -323,13 +333,17 @@ stop_services() {
|
||||
esac
|
||||
fi
|
||||
if (( active )); then
|
||||
run_systemctl stop "$unit" || die "无法停止服务:$unit"
|
||||
log "停止服务:$unit"
|
||||
run_systemctl stop "$unit" || die "无法停止服务:$unit(如果 systemd 正在等待进程退出,请稍后重试)"
|
||||
log "已停止服务:$unit"
|
||||
fi
|
||||
if [[ -e "$UNIT_DIR/$unit" ]]; then
|
||||
log "禁用服务:$unit"
|
||||
run_systemctl disable "$unit" >/dev/null 2>&1 || die "无法禁用服务:$unit"
|
||||
fi
|
||||
done
|
||||
run_systemctl daemon-reload >/dev/null 2>&1 || die 'systemd daemon-reload 失败'
|
||||
log 'systemd 服务已停止并禁用'
|
||||
}
|
||||
|
||||
validate_systemctl() {
|
||||
@@ -464,8 +478,10 @@ main() {
|
||||
die '检测到未完成的更新状态;确认更新已停止后使用 --force 重试'
|
||||
fi
|
||||
log "target: prefix=$PREFIX data=$DATA_DIR config=$CONFIG_DIR"
|
||||
log '开始移除 TallyNote 文件和服务配置'
|
||||
stop_services
|
||||
remove_prefix
|
||||
log '发布文件和更新组件已移除'
|
||||
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'
|
||||
|
||||
Reference in New Issue
Block a user