From 26fbec49ad6a98f0845033805142a6661cb31a5d Mon Sep 17 00:00:00 2001 From: Qiufeng Date: Wed, 2 Sep 2026 07:38:38 +0800 Subject: [PATCH] feat: improve installer network setup --- README.md | 3 +- docs/release.md | 3 +- install.sh | 187 +++++++++++++++++++++++++++++++++++--- package.json | 2 +- scripts/test-installer.sh | 70 ++++++++++++++ tests/update-api.test.ts | 28 +++--- tests/update.test.ts | 6 +- 7 files changed, 266 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index f0baa36..9b8175b 100644 --- a/README.md +++ b/README.md @@ -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 访问。选择 `0.0.0.0` 后,请填写真实访问地址,例如 `http://203.0.113.10:3000` 或 `https://tallynote.example.com`;不能填写 `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`。服务启动成功后,安装日志会输出最终访问链接。 安装器不会在已有安装的升级过程中反复询问网络配置,并会保留现有环境文件。自动化或无终端环境可使用 `--non-interactive`(默认安全配置 `127.0.0.1:3000`),也可以显式传入 `TALLYNOTE_HOST`、`TALLYNOTE_PORT`、`TALLYNOTE_PUBLIC_ORIGIN` 和 `TALLYNOTE_ALLOW_INSECURE_HTTP` 覆盖配置。 @@ -98,6 +98,7 @@ tallynote installer: [完成] TallyNote 服务已启用并启动 tallynote installer: [阶段] 清理旧版本并完成安装 tallynote installer: [完成] 旧版本清理完成 tallynote installer: [完成] 安装完成:TallyNote 1.1.2 +tallynote installer: 访问地址:http://127.0.0.1:3000 tallynote installer: 查看服务状态:systemctl status tallynote.service ``` diff --git a/docs/release.md b/docs/release.md index afbda79..c2a411d 100644 --- a/docs/release.md +++ b/docs/release.md @@ -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/终端中执行时,安装器会在下载前询问监听方式、端口和公开访问地址。可选择仅本机监听 `127.0.0.1`,或监听 `0.0.0.0` 以允许通过真实服务器 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 必须在提示中明确确认,公开地址不能填写通配监听地址。服务启动成功后,安装日志会输出最终访问链接。已有安装升级时不会重复询问,并保留现有环境文件。无终端或 CI 使用 `--non-interactive`(默认 `127.0.0.1:3000`),也可通过 `TALLYNOTE_HOST`、`TALLYNOTE_PORT`、`TALLYNOTE_PUBLIC_ORIGIN` 和 `TALLYNOTE_ALLOW_INSECURE_HTTP` 显式配置。 非交互安装命令: @@ -65,6 +65,7 @@ tallynote installer: [完成] TallyNote 服务已启用并启动 tallynote installer: [阶段] 清理旧版本并完成安装 tallynote installer: [完成] 旧版本清理完成 tallynote installer: [完成] 安装完成:TallyNote <版本> +tallynote installer: 访问地址:http://127.0.0.1:<端口> ``` 每个阶段完成时会输出 `[完成]`;错误会立即以 `tallynote installer:` 前缀输出,不会静默等待或切换半成品版本。 diff --git a/install.sh b/install.sh index 4ff4adc..18f7435 100755 --- a/install.sh +++ b/install.sh @@ -41,7 +41,9 @@ INSTALL_HOST=${TALLYNOTE_HOST-127.0.0.1} INSTALL_PORT=${TALLYNOTE_PORT-3000} INSTALL_PUBLIC_ORIGIN=${TALLYNOTE_PUBLIC_ORIGIN-} INSTALL_ALLOW_INSECURE_HTTP=${TALLYNOTE_ALLOW_INSECURE_HTTP-false} +PUBLIC_IP_URL=${TALLYNOTE_PUBLIC_IP_URL-} NON_INTERACTIVE=0 +NETWORK_INTERACTIVE=0 # The production prompt uses the controlling terminal, even when the # installer itself is read from `curl | sudo bash`. @@ -82,7 +84,9 @@ TALLYNOTE_INSTALL_REQUIRE_SIGNATURE=true and provide a public key. Use host. For direct IP access, pass TALLYNOTE_HOST=0.0.0.0 and an actual TALLYNOTE_PUBLIC_ORIGIN such as http://203.0.113.10:3000; HTTP also requires TALLYNOTE_ALLOW_INSECURE_HTTP=true. On a fresh terminal install, the listener -and public URL can be selected interactively. Use --non-interactive (or +and public URL can be selected interactively. The installer checks that the +selected TCP port is free, suggests a public IPv4 address when exposing +0.0.0.0, and prints the final access URL after the service starts. Use --non-interactive (or TALLYNOTE_NON_INTERACTIVE=true) for automation. --apply is accepted for backwards compatibility. EOF @@ -111,6 +115,109 @@ prompt_value() { PROMPT_REPLY=${reply:-$default} } +detect_public_ipv4() { + local endpoint value octet + local -a endpoints=() + if [[ -n "$PUBLIC_IP_URL" ]]; then + endpoints=("$PUBLIC_IP_URL") + else + # These services return the caller's address as plain text. HTTPS is + # required, and a failure simply falls back to manual address entry. + endpoints=( + 'https://api.ipify.org' + 'https://ifconfig.me/ip' + 'https://checkip.amazonaws.com' + ) + fi + command -v curl >/dev/null 2>&1 || return 1 + for endpoint in "${endpoints[@]}"; do + [[ "$endpoint" == https://* && "$endpoint" != *[[:space:]]* && "$endpoint" != *[[:cntrl:]]* && "$endpoint" != *'@'* ]] || continue + value=$(curl -4 --proto '=https' --tlsv1.2 --fail --silent --show-error --max-redirs 0 \ + --connect-timeout 4 --max-time 8 --max-filesize 128 "$endpoint" 2>/dev/null \ + | tr -d '[:space:]') || continue + [[ "$value" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || continue + IFS='.' read -r -a _public_ip_octets <<< "$value" + for octet in "${_public_ip_octets[@]}"; do + (( 10#$octet <= 255 )) || continue 2 + done + printf '%s' "$value" + return 0 + done + return 1 +} + +port_listener_state() { + local port=$1 output status=0 + validate_listen_port "$port" >/dev/null 2>&1 || return 2 + + if command -v ss >/dev/null 2>&1; then + if output=$(ss -H -ltn 2>/dev/null); then + if awk -v port="$port" '$4 ~ (":" port "$") { found=1 } END { exit found ? 0 : 1 }' <<< "$output"; then + return 1 + fi + return 0 + fi + fi + + if command -v lsof >/dev/null 2>&1; then + output='' + status=0 + output=$(lsof -nP -iTCP:"$port" -sTCP:LISTEN -t 2>/dev/null) || status=$? + [[ -n "$output" ]] && return 1 + [[ "$status" == 1 && -z "$output" ]] && return 0 + [[ "$status" == 0 ]] && return 0 + fi + + if command -v netstat >/dev/null 2>&1; then + if output=$(netstat -lnt 2>/dev/null); then + if awk -v port="$port" '$6 == "LISTEN" && $4 ~ (":" port "$") { found=1 } END { exit found ? 0 : 1 }' <<< "$output"; then + return 1 + fi + return 0 + fi + fi + + if command -v python3 >/dev/null 2>&1; then + python3 - "$port" <<'PY' +import errno +import socket +import sys + +port = int(sys.argv[1]) +for family, address in ((socket.AF_INET, "0.0.0.0"), (socket.AF_INET6, "::")): + sock = socket.socket(family, socket.SOCK_STREAM) + try: + if family == socket.AF_INET6: + sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) + sock.bind((address, port)) + except OSError as error: + if error.errno == errno.EADDRINUSE: + sys.exit(1) + finally: + sock.close() +sys.exit(0) +PY + status=$? + case "$status" in + 0) return 0 ;; + 1) return 1 ;; + esac + fi + + return 2 +} + +check_requested_port() { + local port=$1 state + state=0 + port_listener_state "$port" || state=$? + case "$state" in + 0) return 0 ;; + 1) die "端口 ${port} 已被占用,请选择其他端口" ;; + *) die "无法检测端口 ${port} 是否被占用,请安装 ss、lsof、netstat 或 Python 3 后重试" ;; + esac +} + has_network_environment() { [[ -n "${TALLYNOTE_HOST+x}" || -n "${TALLYNOTE_PORT+x}" || -n "${TALLYNOTE_PUBLIC_ORIGIN+x}" || -n "${TALLYNOTE_ALLOW_INSECURE_HTTP+x}" ]] } @@ -126,6 +233,7 @@ interactive_network_available() { configure_network_interactively() { interactive_network_available || return 0 + NETWORK_INTERACTIVE=1 exec 9<"$PROMPT_INPUT" || die '无法打开终端输入;请使用 --non-interactive 或通过环境变量配置' @@ -136,7 +244,7 @@ configure_network_interactively() { printf ' 2) 局域网/公网访问:0.0.0.0(需要填写实际访问地址)\n' } > "$PROMPT_OUTPUT" - local choice selected_port origin answer + local choice selected_port origin answer port_state detected_ip default_origin while :; do prompt_value '请选择监听方式 1/2' '1' choice=$PROMPT_REPLY @@ -150,6 +258,17 @@ configure_network_interactively() { prompt_value '监听端口' "$INSTALL_PORT" selected_port=$PROMPT_REPLY if [[ "$selected_port" =~ ^[1-9][0-9]*$ && "$selected_port" -le 65535 ]]; then + # A real terminal can reject an occupied port immediately. The final + # check in main() runs again after old services have been stopped. + if [[ -t 9 ]]; then + port_state=0 + port_listener_state "$selected_port" || port_state=$? + case "$port_state" in + 0) break ;; + 1) printf '端口 %s 已被占用,请输入其他端口。\n' "$selected_port" > "$PROMPT_OUTPUT"; continue ;; + *) printf '暂时无法预检端口,安装前还会再次检查。\n' > "$PROMPT_OUTPUT"; break ;; + esac + fi break fi printf '端口必须是 1-65535 的整数,请重试。\n' > "$PROMPT_OUTPUT" @@ -163,8 +282,21 @@ configure_network_interactively() { else INSTALL_HOST=0.0.0.0 INSTALL_PORT=$selected_port + detected_ip='' + # Test fixtures replace /dev/tty with regular files; avoid making their + # behavior depend on an external IP lookup service. + if [[ -t 9 ]]; then + detected_ip=$(detect_public_ipv4 || true) + fi + if [[ -n "$detected_ip" ]]; then + default_origin="http://${detected_ip}:${selected_port}" + printf '已探测公网 IPv4:%s\n' "$detected_ip" > "$PROMPT_OUTPUT" + else + default_origin='' + printf '未能自动获取公网 IPv4,请手动填写访问地址。\n' > "$PROMPT_OUTPUT" + fi while :; do - prompt_value '实际访问地址(例如 http://203.0.113.10:3000 或 https://tallynote.example.com)' '' + prompt_value '实际访问地址(回车使用自动探测地址,也可填写域名)' "$default_origin" origin=$PROMPT_REPLY if validate_env_value "$origin" '公开访问地址' >/dev/null 2>&1 && validate_public_origin "$origin" >/dev/null 2>&1; then INSTALL_PUBLIC_ORIGIN=$origin @@ -753,7 +885,7 @@ validate_listen_host() { local octet IFS='.' read -r -a _host_octets <<< "$value" for octet in "${_host_octets[@]}"; do - (( octet <= 255 )) || die "$label 必须是有效的 IPv4 地址或主机名" + (( 10#$octet <= 255 )) || die "$label 必须是有效的 IPv4 地址或主机名" done else [[ "$value" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$ ]] || die "$label 必须是有效的 IPv4、IPv6 地址或主机名" @@ -794,7 +926,7 @@ validate_public_origin() { fi [[ -n "$host" ]] || die '公开访问地址缺少主机名' [[ "$host" != 0.0.0.0 && "$host" != :: && "$host" != \* ]] || die '公开访问地址不能使用通配监听地址,请填写服务器 IP 或域名' - [[ "$host" =~ ^[A-Za-z0-9.-]+$ || "$host" =~ ^[0-9A-Fa-f:]+$ ]] || die '公开访问地址主机名无效' + validate_listen_host "$host" '公开访问地址主机' if [[ -n "$origin_port" ]]; then [[ "$origin_port" =~ ^[0-9]{1,5}$ && "$origin_port" -ge 1 && "$origin_port" -le 65535 ]] || die '公开访问地址端口必须是 1-65535 的整数' fi @@ -867,24 +999,26 @@ validate_existing_env() { origin=$(read_env_value "$file" TALLYNOTE_PUBLIC_ORIGIN) validate_env_value "$origin" '环境文件中的公开访问地址' else - origin="http://${host}:${port}" + local origin_host=$host + [[ "$origin_host" == *:* && "$origin_host" != \[* ]] && origin_host="[$origin_host]" + origin="http://${origin_host}:${port}" fi validate_public_origin "$origin" - local origin_host=${origin#*://} - if [[ "$origin_host" == \[*\]* ]]; then - origin_host=${origin_host#\[} - origin_host=${origin_host%%\]*} + local origin_host_for_policy=${origin#*://} + if [[ "$origin_host_for_policy" == \[*\]* ]]; then + origin_host_for_policy=${origin_host_for_policy#\[} + origin_host_for_policy=${origin_host_for_policy%%\]*} else - origin_host=${origin_host%%:*} + origin_host_for_policy=${origin_host_for_policy%%:*} fi if [[ "$origin" == http://* && "$allow_insecure" != true ]]; then - case "$origin_host" in + case "$origin_host_for_policy" in 127.0.0.1|localhost|::1) ;; *) die '环境文件中的公网 HTTP 访问必须显式设置 TALLYNOTE_ALLOW_INSECURE_HTTP=true' ;; esac fi if [[ "$origin" == http://* && "$cookie_secure" == true ]]; then - case "$origin_host" in + case "$origin_host_for_policy" in 127.0.0.1|localhost|::1) ;; *) die '环境文件中的公网 HTTP 公开地址不能启用安全 Cookie' ;; esac @@ -973,6 +1107,9 @@ main() { configure_network_interactively validate_listen_host "$INSTALL_HOST" validate_listen_port "$INSTALL_PORT" + if (( APPLY && NETWORK_INTERACTIVE )); then + check_requested_port "$INSTALL_PORT" + fi if [[ -n "${TALLYNOTE_PUBLIC_ORIGIN+x}" && -z "$INSTALL_PUBLIC_ORIGIN" ]]; then die 'TALLYNOTE_PUBLIC_ORIGIN 不能是空值;省略该变量以使用默认 Origin' fi @@ -1115,6 +1252,15 @@ main() { if [[ -e "$CONFIG_DIR/tallynote.env" ]]; then validate_existing_env "$CONFIG_DIR/tallynote.env" fi + # During upgrades, the existing environment remains authoritative unless a + # new port was explicitly supplied. Check the effective listener port after + # stopping the old service so an unrelated process cannot claim it. + local effective_port=$INSTALL_PORT + if [[ -z "${TALLYNOTE_PORT+x}" && -f "$CONFIG_DIR/tallynote.env" ]]; then + effective_port=$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_PORT 2>/dev/null || true) + effective_port=${effective_port:-3000} + fi + check_requested_port "$effective_port" stage_done '目录、权限和旧服务状态已准备' stage "解包、校验包结构并原子切换到版本 ${VERSION#v}" install_release "$archive" "$VERSION" @@ -1228,6 +1374,21 @@ main() { rm -rf -- "$INSTALL_WORK_DIR" 2>/dev/null || true INSTALL_WORK_DIR='' stage_done "安装完成:TallyNote ${VERSION#v}" + local access_url access_host access_port configured_host configured_port + access_url=$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_PUBLIC_ORIGIN 2>/dev/null || true) + if [[ -z "$access_url" ]]; then + configured_host=$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_HOST 2>/dev/null || true) + configured_port=$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_PORT 2>/dev/null || true) + access_host=${configured_host:-$INSTALL_HOST} + access_port=${configured_port:-$INSTALL_PORT} + if [[ "$access_host" == 0.0.0.0 ]]; then + access_host=$(detect_public_ipv4 || true) + fi + [[ -n "$access_host" ]] || access_host=$INSTALL_HOST + [[ "$access_host" == *:* && "$access_host" != \[* ]] && access_host="[$access_host]" + access_url="http://${access_host}:${access_port}" + fi + log "访问地址:$access_url" log '查看服务状态:systemctl status tallynote.service' } main "$@" diff --git a/package.json b/package.json index 6076c84..e138189 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tallynote", - "version": "1.1.6", + "version": "1.1.7", "private": true, "type": "module", "packageManager": "pnpm@9.0.6", diff --git a/scripts/test-installer.sh b/scripts/test-installer.sh index 7dc59f4..8541218 100755 --- a/scripts/test-installer.sh +++ b/scripts/test-installer.sh @@ -85,6 +85,62 @@ bash -c ' fi ' _ "$installer_lib" "$tmp/mode" "$tmp/user-parent" +# The port probe must distinguish a listening TCP port from a free one. +port_tools="$tmp/port-tools" +mkdir -p "$port_tools" +printf '%s\n' '#!/usr/bin/env bash' 'printf "%s\\n" "LISTEN 0 128 127.0.0.1:3443 0.0.0.0:*"' > "$port_tools/ss" +chmod 755 "$port_tools/ss" +bash -c ' + script=$1 + tools=$2 + set -- + source "$script" + PATH="$tools:$PATH" + state=0 + port_listener_state 3443 || state=$? + [[ "$state" == 1 ]] + state=0 + port_listener_state 3444 || state=$? + [[ "$state" == 0 ]] +' _ "$installer_lib" "$port_tools" + +# The lsof fallback must treat its normal "no matches" exit status as a free +# port, while still reporting a listener when it returns a PID. +lsof_tools="$tmp/lsof-tools" +mkdir -p "$lsof_tools" +printf '%s\n' '#!/usr/bin/env bash' 'exit 127' > "$lsof_tools/ss" +printf '%s\n' '#!/usr/bin/env bash' 'case "$*" in *TCP:3443*) printf "%s\\n" 4242; exit 0 ;; *) exit 1 ;; esac' > "$lsof_tools/lsof" +chmod 755 "$lsof_tools/ss" "$lsof_tools/lsof" +bash -c ' + script=$1 + tools=$2 + set -- + source "$script" + PATH="$tools:$PATH" + state=0 + port_listener_state 3443 || state=$? + [[ "$state" == 1 ]] + state=0 + port_listener_state 3444 || state=$? + [[ "$state" == 0 ]] +' _ "$installer_lib" "$lsof_tools" + +# Public-IP discovery accepts a valid IPv4 response and rejects malformed +# values without making the test depend on an external service. +bash -c ' + script=$1 + set -- + source "$script" + PUBLIC_IP_URL=https://ip.example.test + curl() { printf "%s\\n" "198.51.100.7"; } + [[ "$(detect_public_ipv4)" == "198.51.100.7" ]] + curl() { printf "%s\\n" "999.1.1.1"; } + if detect_public_ipv4 >/dev/null 2>&1; then + echo "expected invalid public IPv4 response to fail" >&2 + exit 1 + fi +' _ "$installer_lib" + # Duplicate security-sensitive EnvironmentFile assignments are rejected even # when the first value looks valid (systemd uses the later value). duplicate_env="$tmp/duplicate.env" @@ -183,6 +239,20 @@ bash -c ' stat_mode_bits() { printf "384"; } validate_existing_env "$env_file" ' _ "$installer_lib" "$tmp/public-https.env" +printf '%s\n' \ + 'TALLYNOTE_HOST=::1' \ + 'TALLYNOTE_PORT=3443' > "$tmp/ipv6-default-origin.env" +bash -c ' + script=$1 + env_file=$2 + set -- + source "$script" + PREFIX=/opt/tallynote + DATA_DIR=/var/lib/tallynote + stat_uid() { printf "0"; } + stat_mode_bits() { printf "384"; } + validate_existing_env "$env_file" +' _ "$installer_lib" "$tmp/ipv6-default-origin.env" if bash -c ' script=$1 set -- diff --git a/tests/update-api.test.ts b/tests/update-api.test.ts index 0d1cece..9068ee6 100644 --- a/tests/update-api.test.ts +++ b/tests/update-api.test.ts @@ -59,10 +59,10 @@ describe("更新 API", () => { function mockRelease() { const digest = "c".repeat(64); - const asset = `tallynote-1.1.7-${detectPlatform().target}-glibc.tar.gz`; + const asset = `tallynote-1.1.8-${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.7", 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.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; } 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.7", compatible: true, integrityReady: true, isNewer: true }); + expect(checked.json().latest).toMatchObject({ version: "1.1.8", 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.7", 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.8", 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.7", expectedSha256: "c".repeat(64), currentLink: config.currentLink }); + expect(request).toMatchObject({ jobId, version: "1.1.8", 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.7", 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.8", 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.7", 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.1.8", 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.7" }); + expect(downloaded.json().job).toMatchObject({ operation: "download", status: "queued", version: "1.1.8" }); 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.7", 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.7", 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.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 } }); 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.7", 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.1.8", 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.7" } }); + 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" } }); 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.7", 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.8", 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.7", 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.8", confirm: true } }); expect(response.statusCode).toBe(502); const audit = database.sqlite.prepare("SELECT outcome FROM audit_events WHERE action='update.apply_requested' ORDER BY id DESC LIMIT 1").get() as { outcome: string } | undefined; expect(audit?.outcome).toBe("failure"); diff --git a/tests/update.test.ts b/tests/update.test.ts index ad56475..15d86be 100644 --- a/tests/update.test.ts +++ b/tests/update.test.ts @@ -273,17 +273,17 @@ describe("更新元数据缓存", () => { prepareDataDirectories(config); const database = openDatabase(config); const digest = "b".repeat(64); - const platformAsset = `tallynote-1.1.7-${detectPlatform().target}-glibc.tar.gz`; + const platformAsset = `tallynote-1.1.8-${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.7", 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.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; try { const result = await checkForUpdate(database.sqlite, config); - expect(result.latest).toMatchObject({ version: "1.1.7", compatible: true, integrityReady: true, signatureReady: true, isNewer: true }); + expect(result.latest).toMatchObject({ version: "1.1.8", 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 {