feat: improve installer network setup
TallyNote release / linux-x64 (push) Successful in 5m48s

This commit is contained in:
Qiufeng
2026-09-02 07:38:38 +08:00
parent 3cedcb901b
commit 26fbec49ad
7 changed files with 266 additions and 33 deletions
+2 -1
View File
@@ -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
```
+2 -1
View File
@@ -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:` 前缀输出,不会静默等待或切换半成品版本。
+174 -13
View File
@@ -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 "$@"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tallynote",
"version": "1.1.6",
"version": "1.1.7",
"private": true,
"type": "module",
"packageManager": "pnpm@9.0.6",
+70
View File
@@ -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 --
+14 -14
View File
@@ -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");
+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.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 {