From bac10b6fdf4a21aabb7d2a839513009eb4371e6a Mon Sep 17 00:00:00 2001 From: Qiufeng Date: Wed, 2 Sep 2026 06:29:29 +0800 Subject: [PATCH] feat: add interactive installer network setup --- README.md | 21 ++++--- docs/release.md | 10 ++- install.sh | 126 ++++++++++++++++++++++++++++++++++++-- package.json | 2 +- scripts/test-installer.sh | 101 ++++++++++++++++++++++++++++++ tests/update-api.test.ts | 28 ++++----- tests/update.test.ts | 6 +- 7 files changed, 260 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 58aabe1..f0baa36 100644 --- a/README.md +++ b/README.md @@ -57,22 +57,25 @@ 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): +安装命令保持简洁。首次在 SSH/终端中安装时,安装器会交互询问监听方式、端口和公开访问地址: ```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 +curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo 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_*` 变量才会修改它们。 +监听方式有两个选项:`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`。 + +安装器不会在已有安装的升级过程中反复询问网络配置,并会保留现有环境文件。自动化或无终端环境可使用 `--non-interactive`(默认安全配置 `127.0.0.1:3000`),也可以显式传入 `TALLYNOTE_HOST`、`TALLYNOTE_PORT`、`TALLYNOTE_PUBLIC_ORIGIN` 和 `TALLYNOTE_ALLOW_INSECURE_HTTP` 覆盖配置。 + +非交互安装命令: + +```bash +curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash -s -- --non-interactive +``` 脚本会从公开仓库的 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)只输出干净的阶段日志。典型输出如下(版本号、架构和耗时会按实际环境变化): +安装过程会持续输出带统一前缀的阶段日志,不会在下载、校验或启动服务时静默等待。交互式 SSH/终端中会先显示网络配置选择,下载时还会显示 curl 进度条;非交互式运行(例如 CI)只输出干净的阶段日志。典型输出如下(版本号、架构和耗时会按实际环境变化): ```text tallynote installer: [阶段] 检查运行环境、权限和目标架构 diff --git a/docs/release.md b/docs/release.md index 2f59489..afbda79 100644 --- a/docs/release.md +++ b/docs/release.md @@ -38,6 +38,14 @@ 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` 显式配置。 + +非交互安装命令: + +```bash +curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash -s -- --non-interactive +``` + 脚本会从 `https://git.awaioi.com/awaioi/TallyNote/releases/download/v<版本>/` 下载当前架构归档和 `SHA256SUMS`,限制 HTTPS 重定向只能落在配置的受信主机,校验压缩/展开大小、条目数量、路径和特殊文件,再原子切换 `/opt/tallynote/current`。自定义仓库时同时设置 `TALLYNOTE_REPOSITORY_URL`、`TALLYNOTE_RELEASE_API_URL` 和 `TALLYNOTE_RELEASE_ALLOWED_HOSTS`;若使用独立 CDN,必须把 CDN 主机显式加入白名单。需要预览时显式加 `--dry-run`,需要固定版本时使用 `--version`。 安装器会在每个关键阶段输出统一格式的日志,便于在 SSH 或 systemd 安装会话中确认进度;交互式终端下载时还会显示 curl 进度条,CI 或日志重定向时则保持纯文本输出: @@ -101,7 +109,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`;需要直接 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` 跳数。 +更新检查、下载和应用接口分别带有冷却时间(可用 `TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS`、`TALLYNOTE_UPDATE_DOWNLOAD_COOLDOWN_SECONDS`、`TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS` 调整),避免反复触发外部请求或重复排队。服务单元默认仅监听 `127.0.0.1`;首次安装时可在交互提示中选择 `0.0.0.0` 和真实的服务器 IP/域名。直连 HTTP 会暴露未加密的会话和数据,只适合受控网络;绑定域名后必须改为 HTTPS 反向代理,设置真实 `TALLYNOTE_PUBLIC_ORIGIN`、`TALLYNOTE_COOKIE_SECURE=true`、`TALLYNOTE_ALLOW_INSECURE_HTTP=false` 和明确的 `TALLYNOTE_TRUST_PROXY` 跳数。自动化安装可使用 `--non-interactive` 或显式网络环境变量。 更新任务详情按发起管理员隔离,任务错误只返回固定提示,不会把服务器路径、命令输出或上游响应泄露到浏览器;同一时刻仍只允许一个系统更新任务。 diff --git a/install.sh b/install.sh index 61c275a..4ff4adc 100755 --- a/install.sh +++ b/install.sh @@ -41,6 +41,13 @@ 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} +NON_INTERACTIVE=0 + +# The production prompt uses the controlling terminal, even when the +# installer itself is read from `curl | sudo bash`. +PROMPT_INPUT=/dev/tty +PROMPT_OUTPUT=/dev/tty +PROMPT_REPLY='' INSTALL_SWITCHED=0 INSTALL_COMMITTED=0 @@ -65,6 +72,7 @@ Usage: install.sh [--dry-run] [--version VERSION] [--release-base-url HTTPS_URL] [--signature-format ed25519|gpg] [--update-public-key-file FILE] [--keep-releases N] [--allow-downgrade] [--allow-unsigned] [--apply] + [--non-interactive] Without arguments, the installer resolves the latest compatible release and installs it. SHA-256 from SHA256SUMS is always required. Detached signature @@ -73,7 +81,10 @@ TALLYNOTE_INSTALL_REQUIRE_SIGNATURE=true and provide a public key. Use --dry-run to inspect the selected release without downloading or changing the 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. +TALLYNOTE_ALLOW_INSECURE_HTTP=true. On a fresh terminal install, the listener +and public URL can be selected interactively. Use --non-interactive (or +TALLYNOTE_NON_INTERACTIVE=true) for automation. --apply is accepted for +backwards compatibility. EOF } die() { printf 'tallynote installer: %s\n' "$*" >&2; exit 1; } @@ -81,6 +92,107 @@ log() { printf 'tallynote installer: %s\n' "$*"; } stage() { log "[阶段] $*"; } stage_done() { log "[完成] $*"; } +case "${TALLYNOTE_NON_INTERACTIVE:-false}" in + true|1) NON_INTERACTIVE=1 ;; + false|0) ;; + *) die 'TALLYNOTE_NON_INTERACTIVE 必须是 true 或 false' ;; +esac + +prompt_value() { + local label=$1 default=${2-} reply + if [[ -n "$default" ]]; then + printf '%s [%s]: ' "$label" "$default" > "$PROMPT_OUTPUT" + else + printf '%s: ' "$label" > "$PROMPT_OUTPUT" + fi + if ! IFS= read -r reply <&9; then + die '无法读取终端输入;请使用 --non-interactive 或通过环境变量配置' + fi + PROMPT_REPLY=${reply:-$default} +} + +has_network_environment() { + [[ -n "${TALLYNOTE_HOST+x}" || -n "${TALLYNOTE_PORT+x}" || -n "${TALLYNOTE_PUBLIC_ORIGIN+x}" || -n "${TALLYNOTE_ALLOW_INSECURE_HTTP+x}" ]] +} + +interactive_network_available() { + (( APPLY )) || return 1 + (( NON_INTERACTIVE == 0 )) || return 1 + has_network_environment && return 1 + [[ ! -e "$CONFIG_DIR/tallynote.env" && ! -L "$CONFIG_DIR/tallynote.env" ]] || return 1 + [[ -r "$PROMPT_INPUT" && -w "$PROMPT_OUTPUT" ]] || return 1 + return 0 +} + +configure_network_interactively() { + interactive_network_available || return 0 + + exec 9<"$PROMPT_INPUT" || die '无法打开终端输入;请使用 --non-interactive 或通过环境变量配置' + + stage '配置服务网络监听(可直接回车使用默认值)' + { + printf '\nTallyNote 服务监听配置\n' + printf ' 1) 仅本机访问:127.0.0.1(更安全)\n' + printf ' 2) 局域网/公网访问:0.0.0.0(需要填写实际访问地址)\n' + } > "$PROMPT_OUTPUT" + + local choice selected_port origin answer + while :; do + prompt_value '请选择监听方式 1/2' '1' + choice=$PROMPT_REPLY + case "$choice" in + 1|2) break ;; + *) printf '请输入 1 或 2。\n' > "$PROMPT_OUTPUT" ;; + esac + done + + while :; do + prompt_value '监听端口' "$INSTALL_PORT" + selected_port=$PROMPT_REPLY + if [[ "$selected_port" =~ ^[1-9][0-9]*$ && "$selected_port" -le 65535 ]]; then + break + fi + printf '端口必须是 1-65535 的整数,请重试。\n' > "$PROMPT_OUTPUT" + done + + if [[ "$choice" == 1 ]]; then + INSTALL_HOST=127.0.0.1 + INSTALL_PORT=$selected_port + INSTALL_PUBLIC_ORIGIN="http://127.0.0.1:${selected_port}" + INSTALL_ALLOW_INSECURE_HTTP=false + else + INSTALL_HOST=0.0.0.0 + INSTALL_PORT=$selected_port + while :; do + prompt_value '实际访问地址(例如 http://203.0.113.10:3000 或 https://tallynote.example.com)' '' + 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 + break + fi + printf '地址无效:请输入不含路径、凭据或通配监听地址的 http:// 或 https:// 地址。\n' > "$PROMPT_OUTPUT" + done + INSTALL_ALLOW_INSECURE_HTTP=false + if [[ "$INSTALL_PUBLIC_ORIGIN" == http://* ]]; then + { + printf '\n警告:直连 HTTP 不加密,登录信息和账目数据可能被窃听。\n' + printf '仅在受控局域网或你明确接受风险时继续。\n' + } > "$PROMPT_OUTPUT" + while :; do + prompt_value '确认允许公网 HTTP?输入 yes 继续,其他内容取消' 'no' + answer=$PROMPT_REPLY + case "$answer" in + yes|YES|Yes|y|Y) INSTALL_ALLOW_INSECURE_HTTP=true; break ;; + no|NO|No|n|N|'') die '已取消:公网 HTTP 必须明确确认;请改用 HTTPS 或重新运行安装器' ;; + *) printf '请输入 yes 或 no。\n' > "$PROMPT_OUTPUT" ;; + esac + done + fi + fi + exec 9<&- + stage_done "网络配置已选择:${INSTALL_HOST}:${INSTALL_PORT}" +} + [[ "$REQUIRE_SIGNATURE" == true || "$REQUIRE_SIGNATURE" == false ]] || die 'TALLYNOTE_INSTALL_REQUIRE_SIGNATURE 必须是 true 或 false' [[ "$ALLOW_DOWNGRADE" == true || "$ALLOW_DOWNGRADE" == false ]] || die 'TALLYNOTE_ALLOW_DOWNGRADE 必须是 true 或 false' [[ "$SIGNATURE_FORMAT" == ed25519 || "$SIGNATURE_FORMAT" == gpg ]] || die '签名格式必须是 ed25519 或 gpg' @@ -115,6 +227,7 @@ while (($#)); do --keep-releases) KEEP_RELEASES=${2:?missing value for --keep-releases}; shift ;; --allow-downgrade) ALLOW_DOWNGRADE=true ;; --allow-unsigned) ALLOW_UNSIGNED=1; REQUIRE_SIGNATURE=false ;; + --non-interactive) NON_INTERACTIVE=1 ;; -h|--help) usage; exit 0 ;; *) die "unknown option: $1" ;; esac @@ -654,7 +767,7 @@ validate_listen_port() { } validate_public_origin() { - local value=$1 authority host path_part port suffix + local value=$1 authority host path_part origin_port suffix case "$value" in http://*|https://*) ;; *) die '公开访问地址必须是 http:// 或 https:// 地址' ;; @@ -668,13 +781,13 @@ validate_public_origin() { suffix=${authority#*\]} if [[ -n "$suffix" ]]; then [[ "$suffix" =~ ^:([0-9]+)$ ]] || die '公开访问地址端口无效' - port=${BASH_REMATCH[1]} + origin_port=${BASH_REMATCH[1]} fi else if [[ "$authority" == *:* ]]; then [[ "$authority" =~ ^([^:]+):([0-9]+)$ ]] || die '公开访问地址端口无效' host=${BASH_REMATCH[1]} - port=${BASH_REMATCH[2]} + origin_port=${BASH_REMATCH[2]} else host=$authority fi @@ -682,8 +795,8 @@ validate_public_origin() { [[ -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 的整数' + if [[ -n "$origin_port" ]]; then + [[ "$origin_port" =~ ^[0-9]{1,5}$ && "$origin_port" -ge 1 && "$origin_port" -le 65535 ]] || die '公开访问地址端口必须是 1-65535 的整数' fi path_part=${value#*://} path_part=${path_part#"$authority"} @@ -857,6 +970,7 @@ main() { validate_trusted_tool "$OPENSSL_BIN" 'openssl' fi detect_platform + configure_network_interactively validate_listen_host "$INSTALL_HOST" validate_listen_port "$INSTALL_PORT" if [[ -n "${TALLYNOTE_PUBLIC_ORIGIN+x}" && -z "$INSTALL_PUBLIC_ORIGIN" ]]; then diff --git a/package.json b/package.json index 1f1572b..2956d7b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tallynote", - "version": "1.1.4", + "version": "1.1.5", "private": true, "type": "module", "packageManager": "pnpm@9.0.6", diff --git a/scripts/test-installer.sh b/scripts/test-installer.sh index 1301aab..7dc59f4 100755 --- a/scripts/test-installer.sh +++ b/scripts/test-installer.sh @@ -167,6 +167,22 @@ bash -c ' stat_mode_bits() { printf "384"; } validate_public_origin "http://[2001:db8::10]:3000" ' _ "$installer_lib" +printf '%s\n' \ + 'TALLYNOTE_HOST=0.0.0.0' \ + 'TALLYNOTE_PORT=3000' \ + 'TALLYNOTE_PUBLIC_ORIGIN=https://tallynote.example.com' \ + 'TALLYNOTE_COOKIE_SECURE=true' > "$tmp/public-https.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/public-https.env" if bash -c ' script=$1 set -- @@ -177,6 +193,91 @@ if bash -c ' exit 1 fi +# A fresh interactive install reads from the controlling terminal even when +# the installer script itself is piped from curl. The test substitutes files +# for that terminal and verifies both listener choices without touching the +# host filesystem. +interactive_dir="$tmp/interactive" +mkdir -p "$interactive_dir/config" +printf '\n\n' > "$interactive_dir/local-input" +: > "$interactive_dir/local-output" +env -u TALLYNOTE_HOST -u TALLYNOTE_PORT -u TALLYNOTE_PUBLIC_ORIGIN -u TALLYNOTE_ALLOW_INSECURE_HTTP \ + bash -c ' + script=$1 + dir=$2 + set -- + source "$script" + APPLY=1 + NON_INTERACTIVE=0 + CONFIG_DIR="$dir/config" + PROMPT_INPUT="$dir/local-input" + PROMPT_OUTPUT="$dir/local-output" + configure_network_interactively + [[ "$INSTALL_HOST" == 127.0.0.1 ]] + [[ "$INSTALL_PORT" == 3000 ]] + [[ "$INSTALL_PUBLIC_ORIGIN" == http://127.0.0.1:3000 ]] + [[ "$INSTALL_ALLOW_INSECURE_HTTP" == false ]] + ' _ "$installer_lib" "$interactive_dir" + +printf '2\n3443\nhttp://203.0.113.10:3443\nyes\n' > "$interactive_dir/public-input" +: > "$interactive_dir/public-output" +env -u TALLYNOTE_HOST -u TALLYNOTE_PORT -u TALLYNOTE_PUBLIC_ORIGIN -u TALLYNOTE_ALLOW_INSECURE_HTTP \ + bash -c ' + script=$1 + dir=$2 + set -- + source "$script" + APPLY=1 + NON_INTERACTIVE=0 + CONFIG_DIR="$dir/config" + PROMPT_INPUT="$dir/public-input" + PROMPT_OUTPUT="$dir/public-output" + configure_network_interactively + [[ "$INSTALL_HOST" == 0.0.0.0 ]] + [[ "$INSTALL_PORT" == 3443 ]] + [[ "$INSTALL_PUBLIC_ORIGIN" == http://203.0.113.10:3443 ]] + [[ "$INSTALL_ALLOW_INSECURE_HTTP" == true ]] + ' _ "$installer_lib" "$interactive_dir" + +printf '2\n3000\nhttp://203.0.113.10:3000\nno\n' > "$interactive_dir/refuse-input" +: > "$interactive_dir/refuse-output" +if env -u TALLYNOTE_HOST -u TALLYNOTE_PORT -u TALLYNOTE_PUBLIC_ORIGIN -u TALLYNOTE_ALLOW_INSECURE_HTTP \ + bash -c ' + script=$1 + dir=$2 + set -- + source "$script" + APPLY=1 + NON_INTERACTIVE=0 + CONFIG_DIR="$dir/config" + PROMPT_INPUT="$dir/refuse-input" + PROMPT_OUTPUT="$dir/refuse-output" + configure_network_interactively + ' _ "$installer_lib" "$interactive_dir" >/dev/null 2>&1; then + echo 'expected public HTTP confirmation refusal to stop configuration' >&2 + exit 1 +fi + +# Explicit environment variables take precedence over the prompt, including +# when a terminal is available. +env -u TALLYNOTE_PUBLIC_ORIGIN -u TALLYNOTE_ALLOW_INSECURE_HTTP \ + TALLYNOTE_HOST=0.0.0.0 TALLYNOTE_PORT=3000 \ + bash -c ' + script=$1 + dir=$2 + set -- + source "$script" + APPLY=1 + NON_INTERACTIVE=0 + CONFIG_DIR="$dir/config" + PROMPT_INPUT="$dir/local-input" + PROMPT_OUTPUT="$dir/local-output" + if interactive_network_available; then + echo "expected explicit network environment to skip prompt" >&2 + exit 1 + fi + ' _ "$installer_lib" "$interactive_dir" + # 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/tests/update-api.test.ts b/tests/update-api.test.ts index d0c90f6..856db1b 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.5-${detectPlatform().target}-glibc.tar.gz`; + const asset = `tallynote-1.1.6-${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.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; + : new Response(JSON.stringify({ tag_name: "v1.1.6", 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.5", compatible: true, integrityReady: true, isNewer: true }); + expect(checked.json().latest).toMatchObject({ version: "1.1.6", 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.5", 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.6", 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.5", expectedSha256: "c".repeat(64), currentLink: config.currentLink }); + expect(request).toMatchObject({ jobId, version: "1.1.6", 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.5", 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.6", 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.5", 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.6", 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.5" }); + expect(downloaded.json().job).toMatchObject({ operation: "download", status: "queued", version: "1.1.6" }); 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.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 } }); + .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.6", 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.6", 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.5", 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.6", 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.5" } }); + 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.6" } }); 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.5", 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.6", 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.5", 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.6", 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 2341e15..41b18e3 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.5-${detectPlatform().target}-glibc.tar.gz`; + const platformAsset = `tallynote-1.1.6-${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.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; + : new Response(JSON.stringify({ tag_name: "v1.1.6", 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.5", compatible: true, integrityReady: true, signatureReady: true, isNewer: true }); + expect(result.latest).toMatchObject({ version: "1.1.6", 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 {