Compare commits

...
3 Commits
Author SHA1 Message Date
Qiufeng c518890fc3 fix: keep opted-in HTTP assets on HTTP
TallyNote release / linux-x64 (push) Successful in 6m7s
2026-09-02 15:11:48 +08:00
Qiufeng 1925676fc9 fix: allow netlink for wildcard listener startup
TallyNote release / linux-x64 (push) Successful in 6m4s
2026-09-02 14:06:49 +08:00
Qiufeng 37ffc27ff5 fix: verify service health after installation
TallyNote release / linux-x64 (push) Successful in 7m7s
2026-09-02 12:14:18 +08:00
11 changed files with 196 additions and 26 deletions
+1 -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 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` 覆盖配置。 安装器不会在已有安装的升级过程中反复询问网络配置,并会保留现有环境文件。自动化或无终端环境可使用 `--non-interactive`(默认安全配置 `127.0.0.1:3000`),也可以显式传入 `TALLYNOTE_HOST`、`TALLYNOTE_PORT`、`TALLYNOTE_PUBLIC_ORIGIN` 和 `TALLYNOTE_ALLOW_INSECURE_HTTP` 覆盖配置。
+1 -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 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` 显式配置。
非交互安装命令: 非交互安装命令:
+70 -1
View File
@@ -60,6 +60,10 @@ INSTALL_BACKUP_DIR=''
INSTALL_WAS_ACTIVE=0 INSTALL_WAS_ACTIVE=0
INSTALL_PATH_WAS_ACTIVE=0 INSTALL_PATH_WAS_ACTIVE=0
INSTALL_UPDATE_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_TEMP_ROOT=0
DATA_DIR_ORIGINAL_OWNER='' DATA_DIR_ORIGINAL_OWNER=''
@@ -218,6 +222,24 @@ check_requested_port() {
esac 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() { has_network_environment() {
[[ -n "${TALLYNOTE_HOST+x}" || -n "${TALLYNOTE_PORT+x}" || -n "${TALLYNOTE_PUBLIC_ORIGIN+x}" || -n "${TALLYNOTE_ALLOW_INSECURE_HTTP+x}" ]] [[ -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 # Stop the path trigger first so it cannot launch the privileged updater while
# the data tree is being repaired. # the data tree is being repaired.
for unit in tallynote-update.path tallynote-update.service tallynote.service; do 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 if systemctl is-active --quiet "$unit"; then
case "$unit" in case "$unit" in
tallynote.service) INSTALL_WAS_ACTIVE=1 ;; tallynote.service) INSTALL_WAS_ACTIVE=1 ;;
@@ -783,6 +816,17 @@ stop_existing_services() {
rollback_install_if_needed() { rollback_install_if_needed() {
local result=$? rollback_tmp 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 (( INSTALL_SWITCHED == 1 && INSTALL_COMMITTED == 0 )); then
if [[ -n "$INSTALL_PREVIOUS_TARGET" && -d "$INSTALL_PREVIOUS_TARGET" ]]; then if [[ -n "$INSTALL_PREVIOUS_TARGET" && -d "$INSTALL_PREVIOUS_TARGET" ]]; then
rollback_tmp="$PREFIX/.current-rollback-$$-${RANDOM}.tmp" rollback_tmp="$PREFIX/.current-rollback-$$-${RANDOM}.tmp"
@@ -821,6 +865,7 @@ rollback_install_if_needed() {
done done
fi fi
if command -v systemctl >/dev/null 2>&1; then 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_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_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 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() { install_release() {
local archive=$1 version=$2 tmp release_dir current_tmp='' local archive=$1 version=$2 tmp release_dir current_tmp=''
tmp=$(mktemp -d) 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" safe_extract "$archive" "$tmp/unpacked"
normalize_release_tree "$tmp/unpacked" normalize_release_tree "$tmp/unpacked"
[[ -d "$tmp/unpacked/dist" ]] || die 'release archive must contain dist/ at its root' [[ -d "$tmp/unpacked/dist" ]] || die 'release archive must contain dist/ at its root'
@@ -1364,7 +1412,28 @@ main() {
chown root:root "$CONFIG_DIR/tallynote.env" chown root:root "$CONFIG_DIR/tallynote.env"
chmod 640 "$CONFIG_DIR/tallynote.env" chmod 640 "$CONFIG_DIR/tallynote.env"
systemctl daemon-reload systemctl daemon-reload
INSTALL_SYSTEMD_TOUCHED=1
systemctl enable --now tallynote.service tallynote-update.path 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_done 'TallyNote 服务已启用并启动'
stage '清理旧版本并完成安装' stage '清理旧版本并完成安装'
prune_releases prune_releases
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "tallynote", "name": "tallynote",
"version": "1.1.8", "version": "1.1.11",
"private": true, "private": true,
"type": "module", "type": "module",
"packageManager": "pnpm@9.0.6", "packageManager": "pnpm@9.0.6",
+48
View File
@@ -2,6 +2,8 @@
set -Eeuo pipefail set -Eeuo pipefail
root=$(cd "$(dirname "$0")/.." && pwd) root=$(cd "$(dirname "$0")/.." && pwd)
bash -n "$root/install.sh" "$root/scripts/tallynote-update.sh" bash -n "$root/install.sh" "$root/scripts/tallynote-update.sh"
grep -Eq '^RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK$' "$root/systemd/tallynote.service"
grep -Eq '^RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK$' "$root/systemd/tallynote-update.service"
output=$(bash "$root/install.sh" --dry-run --release-base-url https://releases.example.test/releases) output=$(bash "$root/install.sh" --dry-run --release-base-url https://releases.example.test/releases)
grep -q 'dry-run' <<<"$output" grep -q 'dry-run' <<<"$output"
grep -q '\[阶段\] 检查运行环境' <<<"$output" grep -q '\[阶段\] 检查运行环境' <<<"$output"
@@ -141,6 +143,52 @@ bash -c '
fi fi
' _ "$installer_lib" ' _ "$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 # Duplicate security-sensitive EnvironmentFile assignments are rejected even
# when the first value looks valid (systemd uses the later value). # when the first value looks valid (systemd uses the later value).
duplicate_env="$tmp/duplicate.env" duplicate_env="$tmp/duplicate.env"
+10 -3
View File
@@ -590,6 +590,13 @@ function conflict(database: DatabaseContext, id: string): never {
} }
export async function buildApp(database: DatabaseContext, config: AppConfig) { export async function buildApp(database: DatabaseContext, config: AppConfig) {
// Helmet's defaults include `upgrade-insecure-requests`, HSTS, COOP and
// Origin-Agent-Cluster. Those headers are appropriate for HTTPS, but an
// explicitly opted-in HTTP deployment must remain HTTP all the way through
// the asset graph; otherwise browsers upgrade `/assets/*` to HTTPS and the
// plain HTTP listener appears as a blank page. Keep the transport-sensitive
// headers protocol-aware while retaining the other hardening headers.
const secureOrigin = config.publicOrigin.startsWith("https:");
const app = Fastify({ const app = Fastify({
logger: config.isProduction ? { level: "info", redact: ["req.headers.cookie", "req.headers.x-csrf-token", "password", "temporaryPassword"] } : false, logger: config.isProduction ? { level: "info", redact: ["req.headers.cookie", "req.headers.x-csrf-token", "password", "temporaryPassword"] } : false,
// Fastify's runtime accepts a numeric hop count, while its v5 typings do // Fastify's runtime accepts a numeric hop count, while its v5 typings do
@@ -604,10 +611,10 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
await app.register(cookie); await app.register(cookie);
await app.register(helmet, { await app.register(helmet, {
...(config.isLocalOrigin ? { hsts: false } : {}), ...(!secureOrigin || config.isLocalOrigin ? { hsts: false } : {}),
frameguard: { action: "deny" }, frameguard: { action: "deny" },
referrerPolicy: { policy: "no-referrer" }, referrerPolicy: { policy: "no-referrer" },
crossOriginOpenerPolicy: { policy: "same-origin" }, ...(secureOrigin ? { crossOriginOpenerPolicy: { policy: "same-origin" }, originAgentCluster: true } : { crossOriginOpenerPolicy: false, originAgentCluster: false }),
crossOriginResourcePolicy: { policy: "same-origin" }, crossOriginResourcePolicy: { policy: "same-origin" },
contentSecurityPolicy: { contentSecurityPolicy: {
directives: { directives: {
@@ -618,7 +625,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
"frame-ancestors": ["'none'"], "frame-ancestors": ["'none'"],
"base-uri": ["'none'"], "base-uri": ["'none'"],
"form-action": ["'self'"], "form-action": ["'self'"],
...(config.isLocalOrigin ? { "upgrade-insecure-requests": null } : {}), ...(!secureOrigin ? { "upgrade-insecure-requests": null } : {}),
}, },
}, },
}); });
+3 -1
View File
@@ -15,7 +15,9 @@ Environment=PATH=/usr/sbin:/usr/bin:/sbin:/bin
NoNewPrivileges=true NoNewPrivileges=true
CapabilityBoundingSet= CapabilityBoundingSet=
AmbientCapabilities= AmbientCapabilities=
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 # Keep the updater compatible with the same Node/libuv interface discovery
# path while retaining an explicit socket-family allowlist.
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
PrivateTmp=true PrivateTmp=true
PrivateDevices=true PrivateDevices=true
ProtectHome=true ProtectHome=true
+3 -1
View File
@@ -20,7 +20,9 @@ ProtectSystem=strict
InaccessiblePaths=/opt/tallynote/.update-work InaccessiblePaths=/opt/tallynote/.update-work
ProtectHome=true ProtectHome=true
PrivateDevices=true PrivateDevices=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 # Fastify logs the addresses of wildcard listeners. Node's libuv uses the
# Linux netlink family while enumerating interfaces for that log message.
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
ProtectKernelTunables=true ProtectKernelTunables=true
ProtectKernelModules=true ProtectKernelModules=true
ProtectKernelLogs=true ProtectKernelLogs=true
+42
View File
@@ -87,6 +87,48 @@ describe("TallyNote API", () => {
expect(missing.json().error.requestId).toBeTruthy(); expect(missing.json().error.requestId).toBeTruthy();
}); });
it("显式允许的公网 HTTP 不会把静态资源升级到 HTTPS", async () => {
const publicHttpConfig = {
...config,
publicOrigin: "http://192.0.2.10:3999",
isLocalOrigin: false,
allowInsecureHttp: true,
cookieSecure: false,
};
const publicHttpApp = await buildApp(database, publicHttpConfig);
try {
const response = await publicHttpApp.inject({ method: "GET", url: "/health" });
expect(response.statusCode).toBe(200);
expect(response.headers["content-security-policy"]).not.toContain("upgrade-insecure-requests");
expect(response.headers["strict-transport-security"]).toBeUndefined();
expect(response.headers["cross-origin-opener-policy"]).toBeUndefined();
expect(response.headers["origin-agent-cluster"]).toBeUndefined();
} finally {
await publicHttpApp.close();
}
});
it("HTTPS 仍保留传输安全响应头", async () => {
const secureConfig = {
...config,
publicOrigin: "https://example.test:3999",
isLocalOrigin: false,
allowInsecureHttp: false,
cookieSecure: true,
};
const secureApp = await buildApp(database, secureConfig);
try {
const response = await secureApp.inject({ method: "GET", url: "/health" });
expect(response.statusCode).toBe(200);
expect(response.headers["content-security-policy"]).toContain("upgrade-insecure-requests");
expect(response.headers["strict-transport-security"]).toContain("max-age=");
expect(response.headers["cross-origin-opener-policy"]).toBe("same-origin");
expect(response.headers["origin-agent-cluster"]).toBe("?1");
} finally {
await secureApp.close();
}
});
it("拒绝没有 Origin 的写请求", async () => { it("拒绝没有 Origin 的写请求", async () => {
const response = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "x", password: "x" } }); const response = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "x", password: "x" } });
expect(response.statusCode).toBe(403); expect(response.statusCode).toBe(403);
+14 -14
View File
@@ -59,10 +59,10 @@ describe("更新 API", () => {
function mockRelease() { function mockRelease() {
const digest = "c".repeat(64); const digest = "c".repeat(64);
const asset = `tallynote-1.1.9-${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") globalThis.fetch = (async (input: string | URL) => input.toString().endsWith("SHA256SUMS")
? new Response(`${digest} ${asset}\n`, { status: 200 }) ? new Response(`${digest} ${asset}\n`, { status: 200 })
: new Response(JSON.stringify({ tag_name: "v1.1.9", 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 () => { it("检查 release、创建受保护请求文件并拒绝重复任务", async () => {
@@ -70,21 +70,21 @@ describe("更新 API", () => {
mockRelease(); 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: {} }); 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.statusCode).toBe(200);
expect(checked.json().latest).toMatchObject({ version: "1.1.9", 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"); 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: {} }); 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.statusCode).toBe(429);
expect(tooSoon.headers["retry-after"]).toBeDefined(); 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.9", 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); expect(applied.statusCode).toBe(202);
const jobId = applied.json().job.id as string; const jobId = applied.json().job.id as string;
const request = JSON.parse(readFileSync(config.updateRequestPath, "utf8")) as { jobId: string; expectedSha256: string; currentLink: string }; const request = JSON.parse(readFileSync(config.updateRequestPath, "utf8")) as { jobId: string; expectedSha256: string; currentLink: string };
expect(request).toMatchObject({ jobId, version: "1.1.9", 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); expect(statSync(config.updateRequestPath).mode & 0o777).toBe(0o600);
mockRelease(); 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.9", 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.statusCode).toBe(409);
expect(duplicate.json().error.code).toBe("UPDATE_IN_PROGRESS"); expect(duplicate.json().error.code).toBe("UPDATE_IN_PROGRESS");
const status = await app.inject({ method: "GET", url: "/api/update/status", headers: { cookie: session.cookies } }); const status = await app.inject({ method: "GET", url: "/api/update/status", headers: { cookie: session.cookies } });
@@ -98,10 +98,10 @@ describe("更新 API", () => {
mockRelease(); 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: {} }); 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.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.9", 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); expect(downloaded.statusCode).toBe(202);
const downloadJobId = downloaded.json().job.id as string; const downloadJobId = downloaded.json().job.id as string;
expect(downloaded.json().job).toMatchObject({ operation: "download", status: "queued", version: "1.1.9" }); 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 }; const downloadRequest = JSON.parse(readFileSync(config.updateRequestPath, "utf8")) as { jobId: string; operation: string };
expect(downloadRequest).toMatchObject({ jobId: downloadJobId, operation: "download" }); 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" }); 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 stagedId = randomUUID();
const now = Date.now(); 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', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) 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.9", 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); .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.1.9", 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: { jobId: stagedId, version: "1.2.0", confirm: true } });
expect(applied.statusCode).toBe(202); expect(applied.statusCode).toBe(202);
expect(applied.json().job).toMatchObject({ id: stagedId, operation: "apply", status: "staged" }); 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" }); 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 }; 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) }); 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.9", 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.statusCode).toBe(409);
expect(duplicate.json().error.code).toBe("UPDATE_IN_PROGRESS"); expect(duplicate.json().error.code).toBe("UPDATE_IN_PROGRESS");
}); });
it("缺少确认或未启用 systemd 时不接受更新", async () => { it("缺少确认或未启用 systemd 时不接受更新", async () => {
const session = await login(); 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.9" } }); 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); expect(invalid.statusCode).toBe(400);
process.env.TALLYNOTE_UPDATE_STRATEGY = "disabled"; process.env.TALLYNOTE_UPDATE_STRATEGY = "disabled";
const disabledConfig = loadConfig(); const disabledConfig = loadConfig();
@@ -137,7 +137,7 @@ describe("更新 API", () => {
mockRelease(); 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: {} }); 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); 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.9", 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); expect(applied.statusCode).toBe(202);
const jobId = applied.json().job.id as string; 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); 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 () => { it("应用前重新校验失败时写入失败审计", async () => {
const session = await login("update-audit"); const session = await login("update-audit");
globalThis.fetch = (async () => new Response("upstream unavailable", { status: 503 })) as typeof fetch; 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.9", 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); 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; 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"); expect(audit?.outcome).toBe("failure");
+3 -3
View File
@@ -273,17 +273,17 @@ describe("更新元数据缓存", () => {
prepareDataDirectories(config); prepareDataDirectories(config);
const database = openDatabase(config); const database = openDatabase(config);
const digest = "b".repeat(64); const digest = "b".repeat(64);
const platformAsset = `tallynote-1.1.9-${detectPlatform().target}-glibc.tar.gz`; const platformAsset = `tallynote-1.2.0-${detectPlatform().target}-glibc.tar.gz`;
const sums = `${digest} ${platformAsset}\n`; const sums = `${digest} ${platformAsset}\n`;
const signature = sign(null, Buffer.from(sums), privateKey); const signature = sign(null, Buffer.from(sums), privateKey);
globalThis.fetch = (async (input: string | URL) => input.toString().endsWith("SHA256SUMS.sig") globalThis.fetch = (async (input: string | URL) => input.toString().endsWith("SHA256SUMS.sig")
? new Response(signature) ? new Response(signature)
: input.toString().endsWith("SHA256SUMS") : input.toString().endsWith("SHA256SUMS")
? new Response(sums) ? new Response(sums)
: new Response(JSON.stringify({ tag_name: "v1.1.9", 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 { try {
const result = await checkForUpdate(database.sqlite, config); const result = await checkForUpdate(database.sqlite, config);
expect(result.latest).toMatchObject({ version: "1.1.9", 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 }; 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); expect(JSON.parse(cached.value).asset.sha256).toBe(digest);
} finally { } finally {