diff --git a/.env.example b/.env.example index 77c8ecd..f838443 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,10 @@ TALLYNOTE_TIMEZONE=Asia/Shanghai TALLYNOTE_PUBLIC_ORIGIN=http://127.0.0.1:3000 TALLYNOTE_TRUST_PROXY=false TALLYNOTE_COOKIE_SECURE=false +# Set TALLYNOTE_HOST=0.0.0.0 and the server's real IP Origin for direct +# access. HTTP on a non-local Origin is opt-in; use HTTPS behind a proxy in +# production. +TALLYNOTE_ALLOW_INSECURE_HTTP=false TALLYNOTE_SESSION_IDLE_HOURS=24 TALLYNOTE_SESSION_ABSOLUTE_HOURS=168 TALLYNOTE_EXPORT_TTL_MINUTES=15 diff --git a/README.md b/README.md index e890081..58aabe1 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,19 @@ pnpm build:next curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash ``` +需要安装后直接通过服务器 IP 访问时,在安装命令中指定监听地址和实际访问 Origin(把示例 IP 换成服务器公网 IP): + +```bash +SERVER_IP=203.0.113.10 +curl --proto '=https' --tlsv1.2 -fsSL \ + https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh \ + | sudo env TALLYNOTE_HOST=0.0.0.0 TALLYNOTE_PORT=3000 \ + TALLYNOTE_PUBLIC_ORIGIN="http://${SERVER_IP}:3000" \ + TALLYNOTE_ALLOW_INSECURE_HTTP=true bash +``` + +这会让 systemd 服务监听所有 IPv4 网卡,并可用 `http://服务器IP:3000` 打开。直连 HTTP 未加密,只适合受控网络或首次配置;绑定域名后应改为 HTTPS 反向代理:将 `TALLYNOTE_PUBLIC_ORIGIN` 改为 `https://你的域名`、`TALLYNOTE_COOKIE_SECURE=true`、`TALLYNOTE_ALLOW_INSECURE_HTTP=false`,然后执行 `sudo systemctl restart tallynote.service`。安装器升级时会保留已有网络配置,只有显式传入这些 `TALLYNOTE_*` 变量才会修改它们。 + 脚本会从公开仓库的 latest Release 获取当前架构归档和 `SHA256SUMS`,并在安装前始终校验 SHA-256。也可以通过 `TALLYNOTE_REPOSITORY_URL`、`TALLYNOTE_RELEASE_API_URL`、`TALLYNOTE_RELEASE_ALLOWED_HOSTS` 和 `--release-base-url` 指向自己的仓库或受信 CDN。需要固定版本或预览时,仍可使用 `TALLYNOTE_VERSION`、`--version` 或 `--dry-run` 等高级选项。 安装过程会持续输出带统一前缀的阶段日志,不会在下载、校验或启动服务时静默等待。交互式 SSH/终端中下载还会显示 curl 进度条;非交互式运行(例如 CI)只输出干净的阶段日志。典型输出如下(版本号、架构和耗时会按实际环境变化): @@ -91,7 +104,7 @@ tallynote installer: 查看服务状态:systemctl status tallynote.service 已有安装默认拒绝降级到不高于当前版本;确需回退时显式使用 `--allow-downgrade`,正常更新不会覆盖当前或更高版本。 -安装布局为 `/opt/tallynote/releases/` 加 `/opt/tallynote/current` 符号链接;切换通过临时链接和原子重命名完成。root 更新器使用前缀下独立的 `/opt/tallynote/.update-work`(`0700 root:root`)和 `.update-state` 恢复标记,不会把 root 解包工作区放进应用可写暂存目录。SQLite 数据、附件、暂存、导出和更新队列始终在外置 `/var/lib/tallynote`,不会随版本包删除。服务单元位于 `/etc/systemd/system/tallynote.service`,配置文件为 `/etc/tallynote/tallynote.env`,默认仅监听 `127.0.0.1:3000`。 +安装布局为 `/opt/tallynote/releases/` 加 `/opt/tallynote/current` 符号链接;切换通过临时链接和原子重命名完成。root 更新器使用前缀下独立的 `/opt/tallynote/.update-work`(`0700 root:root`)和 `.update-state` 恢复标记,不会把 root 解包工作区放进应用可写暂存目录。SQLite 数据、附件、暂存、导出和更新队列始终在外置 `/var/lib/tallynote`,不会随版本包删除。服务单元位于 `/etc/systemd/system/tallynote.service`,配置文件为 `/etc/tallynote/tallynote.env`;监听地址、端口和公开 Origin 由该环境文件控制,默认仍是 `127.0.0.1:3000`。 升级有两种方式: diff --git a/docs/release.md b/docs/release.md index d85d6d1..2f59489 100644 --- a/docs/release.md +++ b/docs/release.md @@ -101,7 +101,7 @@ Web 进程把受保护的任务文件交给 root 的 `tallynote-update.path`/`ta sudo /usr/local/sbin/tallynote-update --rollback ``` -更新检查、下载和应用接口分别带有冷却时间(可用 `TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS`、`TALLYNOTE_UPDATE_DOWNLOAD_COOLDOWN_SECONDS`、`TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS` 调整),避免反复触发外部请求或重复排队。服务单元默认仅监听 `127.0.0.1`,并使用最小化 systemd 权限;公网访问必须通过 HTTPS 反向代理,设置真实 `TALLYNOTE_PUBLIC_ORIGIN`、`TALLYNOTE_COOKIE_SECURE=true` 和明确的 `TALLYNOTE_TRUST_PROXY` 跳数。 +更新检查、下载和应用接口分别带有冷却时间(可用 `TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS`、`TALLYNOTE_UPDATE_DOWNLOAD_COOLDOWN_SECONDS`、`TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS` 调整),避免反复触发外部请求或重复排队。服务单元默认仅监听 `127.0.0.1`;需要直接 IP 访问时,可在安装命令中传入 `TALLYNOTE_HOST=0.0.0.0`、实际的 `TALLYNOTE_PUBLIC_ORIGIN=http://服务器IP:3000` 和 `TALLYNOTE_ALLOW_INSECURE_HTTP=true`。这会暴露未加密的 HTTP,只适合受控网络。绑定域名后必须改为 HTTPS 反向代理,设置真实 `TALLYNOTE_PUBLIC_ORIGIN`、`TALLYNOTE_COOKIE_SECURE=true`、`TALLYNOTE_ALLOW_INSECURE_HTTP=false` 和明确的 `TALLYNOTE_TRUST_PROXY` 跳数。 更新任务详情按发起管理员隔离,任务错误只返回固定提示,不会把服务器路径、命令输出或上游响应泄露到浏览器;同一时刻仍只允许一个系统更新任务。 diff --git a/install.sh b/install.sh index 63b5ea6..61c275a 100755 --- a/install.sh +++ b/install.sh @@ -34,6 +34,13 @@ MAX_TIME=${TALLYNOTE_INSTALL_MAX_TIME_SECONDS:-300} RELEASE_ALLOWED_HOSTS=${TALLYNOTE_RELEASE_ALLOWED_HOSTS:-} OPENSSL_BIN=${TALLYNOTE_OPENSSL_BIN:-openssl} UNAME_BIN=${TALLYNOTE_UNAME_BIN:-uname} +# Service network settings are written to the systemd EnvironmentFile on a +# fresh install. Existing values are preserved unless the corresponding +# TALLYNOTE_* variable is explicitly supplied to the installer. +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} INSTALL_SWITCHED=0 INSTALL_COMMITTED=0 @@ -64,7 +71,9 @@ installs it. SHA-256 from SHA256SUMS is always required. Detached signature verification is optional by default; enable it with TALLYNOTE_INSTALL_REQUIRE_SIGNATURE=true and provide a public key. Use --dry-run to inspect the selected release without downloading or changing the -host. --apply is accepted for backwards compatibility. +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. --apply is accepted for backwards compatibility. EOF } die() { printf 'tallynote installer: %s\n' "$*" >&2; exit 1; } @@ -621,6 +630,66 @@ validate_env_value() { [[ ${#value} -le 4096 ]] || die "$label 过长" } +validate_listen_host() { + local value=$1 label=${2:-监听地址} + validate_env_value "$value" "$label" + if [[ "$value" == *:* ]]; then + [[ "$value" =~ ^[0-9A-Fa-f:]+$ ]] || die "$label 必须是有效的 IPv6 地址或主机名" + elif [[ "$value" =~ ^[0-9.]+$ ]]; then + [[ "$value" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || die "$label 必须是有效的 IPv4 地址或主机名" + local octet + IFS='.' read -r -a _host_octets <<< "$value" + for octet in "${_host_octets[@]}"; do + (( octet <= 255 )) || die "$label 必须是有效的 IPv4 地址或主机名" + done + else + [[ "$value" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$ ]] || die "$label 必须是有效的 IPv4、IPv6 地址或主机名" + [[ "$value" != *..* && "$value" != *.-* && "$value" != *-.* ]] || die "$label 包含不受支持的主机名" + fi +} + +validate_listen_port() { + local value=$1 label=${2:-监听端口} + [[ "$value" =~ ^[1-9][0-9]*$ && "$value" -le 65535 ]] || die "$label 必须是 1-65535 的整数" +} + +validate_public_origin() { + local value=$1 authority host path_part port suffix + case "$value" in + http://*|https://*) ;; + *) die '公开访问地址必须是 http:// 或 https:// 地址' ;; + esac + [[ "$value" != *[[:space:]]* && "$value" != *[[:cntrl:]]* && "$value" != *'@'* && "$value" != *'?'* && "$value" != *'#'* ]] || die '公开访问地址包含不受支持的字符' + authority=${value#*://} + authority=${authority%%/*} + [[ -n "$authority" ]] || die '公开访问地址缺少主机名' + if [[ "$authority" == \[*\]* ]]; then + host=${authority#\[}; host=${host%%\]*} + suffix=${authority#*\]} + if [[ -n "$suffix" ]]; then + [[ "$suffix" =~ ^:([0-9]+)$ ]] || die '公开访问地址端口无效' + port=${BASH_REMATCH[1]} + fi + else + if [[ "$authority" == *:* ]]; then + [[ "$authority" =~ ^([^:]+):([0-9]+)$ ]] || die '公开访问地址端口无效' + host=${BASH_REMATCH[1]} + port=${BASH_REMATCH[2]} + else + host=$authority + fi + fi + [[ -n "$host" ]] || die '公开访问地址缺少主机名' + [[ "$host" != 0.0.0.0 && "$host" != :: && "$host" != \* ]] || die '公开访问地址不能使用通配监听地址,请填写服务器 IP 或域名' + [[ "$host" =~ ^[A-Za-z0-9.-]+$ || "$host" =~ ^[0-9A-Fa-f:]+$ ]] || die '公开访问地址主机名无效' + if [[ -n "$port" ]]; then + [[ "$port" =~ ^[0-9]{1,5}$ && "$port" -ge 1 && "$port" -le 65535 ]] || die '公开访问地址端口必须是 1-65535 的整数' + fi + path_part=${value#*://} + path_part=${path_part#"$authority"} + [[ -z "$path_part" || "$path_part" == "/" ]] || die '公开访问地址不能包含路径' +} + validate_semver() { local value=$1 prerelease part [[ "$value" =~ ^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]] || return 1 @@ -640,14 +709,14 @@ validate_install_path() { } validate_existing_env() { - local file=$1 value metadata_host + local file=$1 value metadata_host host port origin allow_insecure cookie_secure [[ ! -L "$file" && -f "$file" ]] || die '现有环境文件不是普通文件' [[ "$(stat_uid "$file")" == 0 ]] || die '现有环境文件必须由 root 拥有' local mode_bits mode_bits=$(stat_mode_bits "$file") (( (mode_bits & 18) == 0 )) || die '环境文件不能被组或其他用户写入' local key key_count - for key in TALLYNOTE_INSTALL_PREFIX TALLYNOTE_DATA_DIR TALLYNOTE_UPDATE_REQUIRE_SIGNATURE TALLYNOTE_UPDATE_METADATA_URL TALLYNOTE_UPDATE_ALLOWED_HOSTS TALLYNOTE_UPDATE_PUBLIC_KEY_FILE; do + for key in TALLYNOTE_INSTALL_PREFIX TALLYNOTE_DATA_DIR TALLYNOTE_HOST TALLYNOTE_PORT TALLYNOTE_PUBLIC_ORIGIN TALLYNOTE_ALLOW_INSECURE_HTTP TALLYNOTE_COOKIE_SECURE TALLYNOTE_UPDATE_REQUIRE_SIGNATURE TALLYNOTE_UPDATE_METADATA_URL TALLYNOTE_UPDATE_ALLOWED_HOSTS TALLYNOTE_UPDATE_PUBLIC_KEY_FILE; do key_count=$(env_key_count "$file" "$key") [[ "$key_count" =~ ^[0-9]+$ && "$key_count" -le 1 ]] || die "环境文件包含重复配置:$key" done @@ -657,6 +726,59 @@ validate_existing_env() { [[ -z "$value" || "${value%/}" == "${DATA_DIR%/}" ]] || die '环境文件中的数据目录与本次安装不一致' value=$(read_env_value "$file" TALLYNOTE_UPDATE_REQUIRE_SIGNATURE) [[ -z "$value" || "$value" == true || "$value" == false ]] || die '环境文件中的签名校验配置必须是 true 或 false' + if (( $(env_key_count "$file" TALLYNOTE_HOST) )); then + host=$(read_env_value "$file" TALLYNOTE_HOST) + validate_listen_host "$host" '环境文件中的监听地址' + else + host=127.0.0.1 + fi + if (( $(env_key_count "$file" TALLYNOTE_PORT) )); then + port=$(read_env_value "$file" TALLYNOTE_PORT) + validate_listen_port "$port" '环境文件中的监听端口' + else + port=3000 + fi + if (( $(env_key_count "$file" TALLYNOTE_ALLOW_INSECURE_HTTP) )); then + allow_insecure=$(read_env_value "$file" TALLYNOTE_ALLOW_INSECURE_HTTP) + [[ "$allow_insecure" == true || "$allow_insecure" == false ]] || die '环境文件中的公网 HTTP 开关必须是 true 或 false' + else + allow_insecure=false + fi + if (( $(env_key_count "$file" TALLYNOTE_COOKIE_SECURE) )); then + cookie_secure=$(read_env_value "$file" TALLYNOTE_COOKIE_SECURE) + [[ "$cookie_secure" == true || "$cookie_secure" == false ]] || die '环境文件中的安全 Cookie 配置必须是 true 或 false' + else + cookie_secure='' + fi + if (( $(env_key_count "$file" TALLYNOTE_PUBLIC_ORIGIN) )); then + origin=$(read_env_value "$file" TALLYNOTE_PUBLIC_ORIGIN) + validate_env_value "$origin" '环境文件中的公开访问地址' + else + origin="http://${host}:${port}" + fi + validate_public_origin "$origin" + local origin_host=${origin#*://} + if [[ "$origin_host" == \[*\]* ]]; then + origin_host=${origin_host#\[} + origin_host=${origin_host%%\]*} + else + origin_host=${origin_host%%:*} + fi + if [[ "$origin" == http://* && "$allow_insecure" != true ]]; then + case "$origin_host" 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 + 127.0.0.1|localhost|::1) ;; + *) die '环境文件中的公网 HTTP 公开地址不能启用安全 Cookie' ;; + esac + fi + if [[ "$origin" == https://* && "$cookie_secure" == false ]]; then + die '环境文件中的 HTTPS 公开地址必须启用安全 Cookie' + fi value=$(read_env_value "$file" TALLYNOTE_UPDATE_METADATA_URL) if [[ -n "$value" ]]; then validate_env_value "$value" '环境文件更新源' @@ -735,6 +857,31 @@ main() { validate_trusted_tool "$OPENSSL_BIN" 'openssl' fi detect_platform + validate_listen_host "$INSTALL_HOST" + validate_listen_port "$INSTALL_PORT" + if [[ -n "${TALLYNOTE_PUBLIC_ORIGIN+x}" && -z "$INSTALL_PUBLIC_ORIGIN" ]]; then + die 'TALLYNOTE_PUBLIC_ORIGIN 不能是空值;省略该变量以使用默认 Origin' + fi + [[ "$INSTALL_ALLOW_INSECURE_HTTP" == true || "$INSTALL_ALLOW_INSECURE_HTTP" == false ]] || die 'TALLYNOTE_ALLOW_INSECURE_HTTP 必须是 true 或 false' + if [[ -n "$INSTALL_PUBLIC_ORIGIN" ]]; then + validate_env_value "$INSTALL_PUBLIC_ORIGIN" '公开访问地址' + validate_public_origin "$INSTALL_PUBLIC_ORIGIN" + if [[ "$INSTALL_PUBLIC_ORIGIN" == http://* && "$INSTALL_ALLOW_INSECURE_HTTP" != true ]]; then + public_host=${INSTALL_PUBLIC_ORIGIN#http://} + if [[ "$public_host" == \[*\]* ]]; then + public_host=${public_host#\[} + public_host=${public_host%%\]*} + else + public_host=${public_host%%:*} + fi + case "$public_host" in + 127.0.0.1|localhost|::1) ;; + *) die '公网 HTTP 访问必须显式设置 TALLYNOTE_ALLOW_INSECURE_HTTP=true' ;; + esac + fi + elif [[ "$INSTALL_HOST" != 127.0.0.1 && "$INSTALL_HOST" != localhost && "$INSTALL_HOST" != ::1 ]]; then + die '监听非本机地址时必须提供 TALLYNOTE_PUBLIC_ORIGIN(例如 http://服务器IP:3000)' + fi [[ "$KEEP_RELEASES" =~ ^[1-9][0-9]*$ ]] || die '--keep-releases must be a positive integer' validate_install_path "$PREFIX" '安装目录' validate_install_path "$DATA_DIR" '数据目录' @@ -876,10 +1023,12 @@ main() { install -o root -g root -m 755 "$release_dir/scripts/tallynote-update-runner.sh" /usr/local/libexec/tallynote-update-runner install -o root -g root -m 755 "$release_dir/uninstall.sh" /usr/local/sbin/tallynote-uninstall ensure_root_directory "$(dirname -- "$DATA_DIR")/tallynote-backups" 700 + local env_created=0 if [[ ! -f "$CONFIG_DIR/tallynote.env" ]]; then sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g" "$release_dir/systemd/tallynote.env.example" > "$CONFIG_DIR/tallynote.env" chown root:root "$CONFIG_DIR/tallynote.env" chmod 640 "$CONFIG_DIR/tallynote.env" + env_created=1 fi ensure_env_key() { local key=$1 value=$2 @@ -892,6 +1041,40 @@ main() { printf '%s=%s\n' "$key" "$value" >> "$CONFIG_DIR/tallynote.env" fi } + set_env_key() { + local key=$1 value=$2 escaped tmp + [[ "$key" =~ ^[A-Z0-9_]+$ ]] || die '环境变量名无效' + validate_env_value "$value" "$key" + escaped=${value//\\/\\\\} + escaped=${escaped//&/\\&} + escaped=${escaped//|/\\|} + if grep -qE "^${key}=" "$CONFIG_DIR/tallynote.env"; then + sed -i "s|^${key}=.*|${key}=${escaped}|" "$CONFIG_DIR/tallynote.env" + else + if [[ -s "$CONFIG_DIR/tallynote.env" && "$(tail -c 1 "$CONFIG_DIR/tallynote.env")" != $'\n' ]]; then + printf '\n' >> "$CONFIG_DIR/tallynote.env" + fi + printf '%s=%s\n' "$key" "$value" >> "$CONFIG_DIR/tallynote.env" + fi + } + # A fresh install gets the requested network settings. On upgrades, only + # explicitly supplied values change the existing administrator config. + if (( env_created )) || [[ -n "${TALLYNOTE_HOST+x}" ]]; then set_env_key TALLYNOTE_HOST "$INSTALL_HOST"; fi + if (( env_created )) || [[ -n "${TALLYNOTE_PORT+x}" ]]; then set_env_key TALLYNOTE_PORT "$INSTALL_PORT"; fi + if (( env_created )); then + if [[ -n "$INSTALL_PUBLIC_ORIGIN" ]]; then + set_env_key TALLYNOTE_PUBLIC_ORIGIN "$INSTALL_PUBLIC_ORIGIN" + elif [[ -n "${TALLYNOTE_HOST+x}" || -n "${TALLYNOTE_PORT+x}" ]]; then + local generated_origin_host=$INSTALL_HOST + [[ "$generated_origin_host" == *:* && "$generated_origin_host" != \[* ]] && generated_origin_host="[$generated_origin_host]" + set_env_key TALLYNOTE_PUBLIC_ORIGIN "http://${generated_origin_host}:${INSTALL_PORT}" + fi + if [[ "$INSTALL_PUBLIC_ORIGIN" == https://* ]]; then set_env_key TALLYNOTE_COOKIE_SECURE true; fi + set_env_key TALLYNOTE_ALLOW_INSECURE_HTTP "$INSTALL_ALLOW_INSECURE_HTTP" + elif [[ -n "${TALLYNOTE_PUBLIC_ORIGIN+x}" ]]; then + set_env_key TALLYNOTE_PUBLIC_ORIGIN "$INSTALL_PUBLIC_ORIGIN" + fi + if [[ -n "${TALLYNOTE_ALLOW_INSECURE_HTTP+x}" ]]; then set_env_key TALLYNOTE_ALLOW_INSECURE_HTTP "$INSTALL_ALLOW_INSECURE_HTTP"; fi ensure_env_key TALLYNOTE_INSTALL_PREFIX "$PREFIX" ensure_env_key TALLYNOTE_DATA_DIR "$DATA_DIR" ensure_env_key TALLYNOTE_UPDATE_STRATEGY systemd diff --git a/package.json b/package.json index 691706e..1f1572b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tallynote", - "version": "1.1.3", + "version": "1.1.4", "private": true, "type": "module", "packageManager": "pnpm@9.0.6", diff --git a/scripts/tallynote-update-runner.sh b/scripts/tallynote-update-runner.sh index 4e134a7..9e7e0c6 100755 --- a/scripts/tallynote-update-runner.sh +++ b/scripts/tallynote-update-runner.sh @@ -13,6 +13,10 @@ STATE_FILE="$PREFIX/.update-state" SERVICE_NAME=${TALLYNOTE_SERVICE_NAME:-tallynote.service} HOST=${TALLYNOTE_HOST:-127.0.0.1} PORT=${TALLYNOTE_PORT:-3000} +HEALTH_HOST=$HOST +if [[ "$HEALTH_HOST" == 0.0.0.0 ]]; then HEALTH_HOST=127.0.0.1; fi +if [[ "$HEALTH_HOST" == :: ]]; then HEALTH_HOST=::1; fi +if [[ "$HEALTH_HOST" == *:* && "$HEALTH_HOST" != \[* ]]; then HEALTH_HOST="[$HEALTH_HOST]"; fi die() { printf 'tallynote update runner: %s\n' "$*" >&2; exit 1; } [[ ${EUID:-$(id -u)} -eq 0 ]] || die 'must run as root' @@ -222,7 +226,7 @@ write_update_state health-check || exit 1 systemctl start "$SERVICE_NAME" healthy=0 for _ in $(seq 1 30); do - if curl --proto '=http' --max-time 2 --silent --show-error "http://$HOST:$PORT/health" >/dev/null 2>&1; then healthy=1; break; fi + if curl --proto '=http' --max-time 2 --silent --show-error "http://$HEALTH_HOST:$PORT/health" >/dev/null 2>&1; then healthy=1; break; fi sleep 1 done diff --git a/scripts/test-installer.sh b/scripts/test-installer.sh index 1b26ca1..1301aab 100755 --- a/scripts/test-installer.sh +++ b/scripts/test-installer.sh @@ -13,6 +13,28 @@ if bash "$root/install.sh" --dry-run --release-base-url http://insecure.example. echo 'expected non-HTTPS URL to fail' >&2 exit 1 fi +if TALLYNOTE_HOST=0.0.0.0 bash "$root/install.sh" --dry-run --release-base-url https://releases.example.test/releases >/dev/null 2>&1; then + echo 'expected non-local listener without public origin to fail' >&2 + exit 1 +fi +output=$(TALLYNOTE_HOST=0.0.0.0 TALLYNOTE_PORT=3000 \ + TALLYNOTE_PUBLIC_ORIGIN=http://203.0.113.10:3000 \ + TALLYNOTE_ALLOW_INSECURE_HTTP=true \ + bash "$root/install.sh" --dry-run --version 1.2.3 --release-base-url https://releases.example.test/releases) +grep -q 'release: 1.2.3' <<<"$output" +output=$(TALLYNOTE_HOST=::1 TALLYNOTE_PORT=3443 \ + bash "$root/install.sh" --dry-run --version 1.2.3 --release-base-url https://releases.example.test/releases) +grep -q 'release: 1.2.3' <<<"$output" +if TALLYNOTE_HOST=0.0.0.0 TALLYNOTE_PORT=65536 TALLYNOTE_PUBLIC_ORIGIN=http://203.0.113.10:3000 TALLYNOTE_ALLOW_INSECURE_HTTP=true \ + bash "$root/install.sh" --dry-run --release-base-url https://releases.example.test/releases >/dev/null 2>&1; then + echo 'expected invalid listener port to fail' >&2 + exit 1 +fi +if TALLYNOTE_HOST=0.0.0.0 TALLYNOTE_PUBLIC_ORIGIN=http://203.0.113.10:3000 \ + bash "$root/install.sh" --dry-run --release-base-url https://releases.example.test/releases >/dev/null 2>&1; then + echo 'expected public HTTP without explicit opt-in to fail' >&2 + exit 1 +fi tmp=$(mktemp -d) cleanup_tmp() { if [[ -d "$tmp" ]]; then @@ -80,6 +102,81 @@ bash -c ' fi ' _ "$installer_lib" "$duplicate_env" +# Existing installations must validate the network settings they preserve on +# upgrade, including the direct-IP HTTP combination used by the documented +# installer command. +network_env="$tmp/network.env" +printf '%s\n' \ + 'TALLYNOTE_HOST=0.0.0.0' \ + 'TALLYNOTE_PORT=3000' \ + 'TALLYNOTE_PUBLIC_ORIGIN=http://203.0.113.10:3000' \ + 'TALLYNOTE_ALLOW_INSECURE_HTTP=true' > "$network_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" "$network_env" +if sed 's/^TALLYNOTE_PORT=.*/TALLYNOTE_PORT=65536/' "$network_env" > "$tmp/invalid-port.env"; then + if 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/invalid-port.env" >/dev/null 2>&1; then + echo 'expected invalid existing listener port to fail' >&2 + exit 1 + fi +fi +printf '%s\n' \ + 'TALLYNOTE_HOST=0.0.0.0' \ + 'TALLYNOTE_PORT=3000' \ + 'TALLYNOTE_PUBLIC_ORIGIN=http://203.0.113.10:3000' \ + 'TALLYNOTE_ALLOW_INSECURE_HTTP=false' > "$tmp/public-http-without-opt-in.env" +if 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/public-http-without-opt-in.env" >/dev/null 2>&1; then + echo 'expected public HTTP without opt-in in existing env to fail' >&2 + exit 1 +fi +bash -c ' + script=$1 + set -- + source "$script" + PREFIX=/opt/tallynote + DATA_DIR=/var/lib/tallynote + stat_uid() { printf "0"; } + stat_mode_bits() { printf "384"; } + validate_public_origin "http://[2001:db8::10]:3000" +' _ "$installer_lib" +if bash -c ' + script=$1 + set -- + source "$script" + validate_public_origin "http://example.test:65536" +' _ "$installer_lib" >/dev/null 2>&1; then + echo 'expected invalid public origin port to fail' >&2 + exit 1 +fi + # A release archive is extracted under umask 077, then explicitly normalized # so the tallynote system user can traverse and execute the shipped tree. source_tmp="$tmp/source" diff --git a/server/config.ts b/server/config.ts index 15a05b4..4440cb9 100644 --- a/server/config.ts +++ b/server/config.ts @@ -69,7 +69,8 @@ export function loadConfig() { const installPrefix = path.resolve(process.env.TALLYNOTE_INSTALL_PREFIX ?? (updateStrategyRaw === "systemd" ? path.dirname(projectRoot) : projectRoot)); const host = process.env.TALLYNOTE_HOST ?? "127.0.0.1"; const port = integerEnv("TALLYNOTE_PORT", 3000, 1); - const publicOrigin = process.env.TALLYNOTE_PUBLIC_ORIGIN ?? `http://${host}:${port}`; + const originHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; + const publicOrigin = process.env.TALLYNOTE_PUBLIC_ORIGIN ?? `http://${originHost}:${port}`; let parsedOrigin: URL; try { parsedOrigin = new URL(publicOrigin); @@ -88,7 +89,14 @@ export function loadConfig() { const isProduction = process.env.NODE_ENV === "production" || process.env.TALLYNOTE_ENV === "production"; const cookieSecure = booleanEnv("TALLYNOTE_COOKIE_SECURE", parsedOrigin.protocol === "https:"); + // Direct IP access is useful during a first deployment, but it is not + // encrypted. Keep this explicitly opt-in so a public install cannot + // accidentally expose session cookies over HTTP. + const allowInsecureHttp = booleanEnv("TALLYNOTE_ALLOW_INSECURE_HTTP", false); const publicHost = parsedOrigin.hostname.replace(/^\[|\]$/g, "").toLowerCase(); + if (["0.0.0.0", "::"].includes(publicHost)) { + throw new Error("TALLYNOTE_PUBLIC_ORIGIN 不能使用通配监听地址,请填写服务器 IP 或域名"); + } const localOrigin = ["127.0.0.1", "localhost", "::1"].includes(publicHost); const appVersion = (() => { try { @@ -122,6 +130,7 @@ export function loadConfig() { timezone, trustProxy: trustProxyEnv(), cookieSecure, + allowInsecureHttp, appVersion, updateMetadataUrl, updateAllowedHosts, @@ -166,7 +175,13 @@ export function loadConfig() { isProduction, }; - if (!localOrigin && (parsedOrigin.protocol !== "https:" || !cookieSecure)) { + if (!localOrigin && parsedOrigin.protocol !== "https:" && !allowInsecureHttp) { + throw new Error("公网 HTTP 访问必须显式启用 TALLYNOTE_ALLOW_INSECURE_HTTP=true;生产环境建议使用 HTTPS"); + } + if (!localOrigin && parsedOrigin.protocol !== "https:" && cookieSecure) { + throw new Error("HTTP public origin 不能启用安全 Cookie"); + } + if (!localOrigin && parsedOrigin.protocol === "https:" && !cookieSecure) { throw new Error("公网部署必须使用 HTTPS 并启用安全 Cookie"); } if (parsedOrigin.protocol === "https:" && !cookieSecure) { diff --git a/systemd/tallynote.env.example b/systemd/tallynote.env.example index 0e80b5b..1cc6d8c 100644 --- a/systemd/tallynote.env.example +++ b/systemd/tallynote.env.example @@ -4,6 +4,7 @@ TALLYNOTE_DATA_DIR=/var/lib/tallynote TALLYNOTE_INSTALL_PREFIX=/opt/tallynote TALLYNOTE_PUBLIC_ORIGIN=http://127.0.0.1:3000 TALLYNOTE_COOKIE_SECURE=false +TALLYNOTE_ALLOW_INSECURE_HTTP=false TALLYNOTE_TIMEZONE=Asia/Shanghai TALLYNOTE_UPDATE_STRATEGY=systemd TALLYNOTE_UPDATE_METADATA_URL=https://git.awaioi.com/api/v1/repos/awaioi/TallyNote/releases/latest diff --git a/tests/security.test.ts b/tests/security.test.ts index 152fdd2..5334648 100644 --- a/tests/security.test.ts +++ b/tests/security.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { loadConfig, prepareDataDirectories } from "../server/config.js"; -const keys = ["TALLYNOTE_DATA_DIR", "TALLYNOTE_PUBLIC_ORIGIN", "TALLYNOTE_COOKIE_SECURE", "TALLYNOTE_TRUST_PROXY", "NODE_ENV", "TALLYNOTE_ENV", "TALLYNOTE_UPDATE_STRATEGY", "TALLYNOTE_UPDATE_ALLOWED_HOSTS", "TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", "TALLYNOTE_UPDATE_PUBLIC_KEY", "TALLYNOTE_UPDATE_PUBLIC_KEY_FILE"]; +const keys = ["TALLYNOTE_DATA_DIR", "TALLYNOTE_HOST", "TALLYNOTE_PORT", "TALLYNOTE_PUBLIC_ORIGIN", "TALLYNOTE_COOKIE_SECURE", "TALLYNOTE_ALLOW_INSECURE_HTTP", "TALLYNOTE_TRUST_PROXY", "NODE_ENV", "TALLYNOTE_ENV", "TALLYNOTE_UPDATE_STRATEGY", "TALLYNOTE_UPDATE_ALLOWED_HOSTS", "TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", "TALLYNOTE_UPDATE_PUBLIC_KEY", "TALLYNOTE_UPDATE_PUBLIC_KEY_FILE"]; afterEach(() => { for (const key of keys) delete process.env[key]; }); @@ -13,11 +13,32 @@ describe("部署安全配置", () => { it("公网 HTTP 或 HTTPS 非安全 Cookie 一律拒绝", () => { process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://example.test"; expect(() => loadConfig()).toThrow(/HTTPS/); + process.env.TALLYNOTE_ALLOW_INSECURE_HTTP = "true"; + expect(loadConfig().allowInsecureHttp).toBe(true); + process.env.TALLYNOTE_COOKIE_SECURE = "true"; + expect(() => loadConfig()).toThrow(/安全 Cookie/); process.env.TALLYNOTE_PUBLIC_ORIGIN = "https://example.test"; process.env.TALLYNOTE_COOKIE_SECURE = "false"; expect(() => loadConfig()).toThrow(/安全 Cookie/); }); + it("允许显式配置服务器 IP 的直连 HTTP,并拒绝通配 Origin", () => { + process.env.TALLYNOTE_HOST = "0.0.0.0"; + process.env.TALLYNOTE_PORT = "3000"; + process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://192.0.2.10:3000"; + process.env.TALLYNOTE_ALLOW_INSECURE_HTTP = "true"; + process.env.TALLYNOTE_COOKIE_SECURE = "false"; + expect(loadConfig()).toMatchObject({ host: "0.0.0.0", port: 3000, publicOrigin: "http://192.0.2.10:3000", allowInsecureHttp: true }); + process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://0.0.0.0:3000"; + expect(() => loadConfig()).toThrow(/通配监听地址/); + }); + + it("为 IPv6 监听地址生成合法的默认 Origin", () => { + process.env.TALLYNOTE_HOST = "::1"; + process.env.TALLYNOTE_PORT = "3000"; + expect(loadConfig().publicOrigin).toBe("http://[::1]:3000"); + }); + it("生产环境不接受任意 trust proxy", () => { process.env.NODE_ENV = "production"; process.env.TALLYNOTE_TRUST_PROXY = "true"; diff --git a/tests/update-api.test.ts b/tests/update-api.test.ts index ce6d6cf..d0c90f6 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.4-${detectPlatform().target}-glibc.tar.gz`; + const asset = `tallynote-1.1.5-${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.4", 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.5", 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.4", compatible: true, integrityReady: true, isNewer: true }); + expect(checked.json().latest).toMatchObject({ version: "1.1.5", 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.4", 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.5", 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.4", expectedSha256: "c".repeat(64), currentLink: config.currentLink }); + expect(request).toMatchObject({ jobId, version: "1.1.5", 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.4", 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.5", 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.4", 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.5", 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.4" }); + expect(downloaded.json().job).toMatchObject({ operation: "download", status: "queued", version: "1.1.5" }); 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.4", 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.4", 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.5", 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.5", 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.4", 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.5", 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.4" } }); + 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.5" } }); 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.4", 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.5", 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.4", 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.5", 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 6eb23d9..2341e15 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.4-${detectPlatform().target}-glibc.tar.gz`; + const platformAsset = `tallynote-1.1.5-${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.4", 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.5", 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.4", compatible: true, integrityReady: true, signatureReady: true, isNewer: true }); + expect(result.latest).toMatchObject({ version: "1.1.5", 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 {