Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37ffc27ff5 | ||
|
|
5dcf9d0f61 | ||
|
|
26fbec49ad | ||
|
|
3cedcb901b | ||
|
|
bac10b6fdf |
@@ -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 访问。安装时可输入自定义端口(直接回车使用默认端口),安装器会检查 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` 覆盖配置。
|
||||
|
||||
非交互安装命令:
|
||||
|
||||
```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: [阶段] 检查运行环境、权限和目标架构
|
||||
@@ -95,6 +98,7 @@ tallynote installer: [完成] TallyNote 服务已启用并启动
|
||||
tallynote installer: [阶段] 清理旧版本并完成安装
|
||||
tallynote installer: [完成] 旧版本清理完成
|
||||
tallynote installer: [完成] 安装完成:TallyNote 1.1.2
|
||||
tallynote installer: 访问地址:http://127.0.0.1:3000
|
||||
tallynote installer: 查看服务状态:systemctl status tallynote.service
|
||||
```
|
||||
|
||||
@@ -133,6 +137,8 @@ sudo /usr/local/sbin/tallynote-uninstall --purge-data --yes --purge-config
|
||||
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/uninstall.sh | sudo bash
|
||||
```
|
||||
|
||||
卸载器会逐项输出停止、禁用和删除进度;每次 systemd/dbus 调用默认最多等待 30 秒,避免终端无限无响应。可通过 `TALLYNOTE_UNINSTALL_SYSTEMCTL_TIMEOUT_SECONDS` 调整超时时间。
|
||||
|
||||
公网反代必须使用 HTTPS,并在环境文件中设置真实的 `TALLYNOTE_PUBLIC_ORIGIN=https://...`、`TALLYNOTE_COOKIE_SECURE=true` 和明确的 `TALLYNOTE_TRUST_PROXY` 跳数(不要使用生产值 `true`)。
|
||||
|
||||
### 构建发布包
|
||||
|
||||
+12
-1
@@ -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/终端中执行时,安装器会在下载前询问监听方式和端口(端口可直接回车使用默认值),并检查所选 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` 显式配置。
|
||||
|
||||
非交互安装命令:
|
||||
|
||||
```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 或日志重定向时则保持纯文本输出:
|
||||
@@ -57,6 +65,7 @@ tallynote installer: [完成] TallyNote 服务已启用并启动
|
||||
tallynote installer: [阶段] 清理旧版本并完成安装
|
||||
tallynote installer: [完成] 旧版本清理完成
|
||||
tallynote installer: [完成] 安装完成:TallyNote <版本>
|
||||
tallynote installer: 访问地址:http://127.0.0.1:<端口>
|
||||
```
|
||||
|
||||
每个阶段完成时会输出 `[完成]`;错误会立即以 `tallynote installer:` 前缀输出,不会静默等待或切换半成品版本。
|
||||
@@ -89,6 +98,8 @@ sudo /usr/local/sbin/tallynote-uninstall
|
||||
|
||||
只有显式 `--purge-data --yes` 才会删除 SQLite、附件、暂存、导出、更新队列和备份;`--purge-config` 可在确认配置目录中没有其他文件后移除空配置目录。卸载器不会自动删除 `tallynote` 系统用户,也不会跟随符号链接删除目录。检测到 `.update-state` 或 `update-request.json` 时会拒绝执行,确认更新已经停止后使用 `--force`。
|
||||
|
||||
卸载过程中会输出每个 systemd 单元的检查、停止、禁用和删除阶段;systemd/dbus 调用默认 30 秒超时,避免长时间无反馈。可用 `TALLYNOTE_UNINSTALL_SYSTEMCTL_TIMEOUT_SECONDS` 调整。
|
||||
|
||||
## 后台一键更新
|
||||
|
||||
将环境文件中的 `TALLYNOTE_UPDATE_STRATEGY=systemd`、`TALLYNOTE_UPDATE_METADATA_URL` 和 `TALLYNOTE_UPDATE_ALLOWED_HOSTS` 配好后,后台“系统更新”会读取 Gitea 的 `/api/v1/repos/<owner>/<repo>/releases/latest`。检查结果只显示当前平台匹配且通过 SHA-256 校验的资产;如果配置了 `TALLYNOTE_UPDATE_PUBLIC_KEY_FILE` 并启用签名要求,再额外验证 Ed25519 签名。
|
||||
@@ -101,7 +112,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` 或显式网络环境变量。
|
||||
|
||||
更新任务详情按发起管理员隔离,任务错误只返回固定提示,不会把服务器路径、命令输出或上游响应泄露到浏览器;同一时刻仍只允许一个系统更新任务。
|
||||
|
||||
|
||||
+362
-18
@@ -41,6 +41,15 @@ INSTALL_HOST=${TALLYNOTE_HOST-127.0.0.1}
|
||||
INSTALL_PORT=${TALLYNOTE_PORT-3000}
|
||||
INSTALL_PUBLIC_ORIGIN=${TALLYNOTE_PUBLIC_ORIGIN-}
|
||||
INSTALL_ALLOW_INSECURE_HTTP=${TALLYNOTE_ALLOW_INSECURE_HTTP-false}
|
||||
PUBLIC_IP_URL=${TALLYNOTE_PUBLIC_IP_URL-}
|
||||
NON_INTERACTIVE=0
|
||||
NETWORK_INTERACTIVE=0
|
||||
|
||||
# The production prompt uses the controlling terminal, even when the
|
||||
# installer itself is read from `curl | sudo bash`.
|
||||
PROMPT_INPUT=/dev/tty
|
||||
PROMPT_OUTPUT=/dev/tty
|
||||
PROMPT_REPLY=''
|
||||
|
||||
INSTALL_SWITCHED=0
|
||||
INSTALL_COMMITTED=0
|
||||
@@ -51,6 +60,10 @@ INSTALL_BACKUP_DIR=''
|
||||
INSTALL_WAS_ACTIVE=0
|
||||
INSTALL_PATH_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_ORIGINAL_OWNER=''
|
||||
|
||||
@@ -65,6 +78,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 +87,12 @@ 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. The installer checks that the
|
||||
selected TCP port is free, suggests a public IPv4 address when exposing
|
||||
0.0.0.0, and prints the final access URL after the service starts. Use --non-interactive (or
|
||||
TALLYNOTE_NON_INTERACTIVE=true) for automation. --apply is accepted for
|
||||
backwards compatibility.
|
||||
EOF
|
||||
}
|
||||
die() { printf 'tallynote installer: %s\n' "$*" >&2; exit 1; }
|
||||
@@ -81,6 +100,253 @@ 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}
|
||||
}
|
||||
|
||||
detect_public_ipv4() {
|
||||
local endpoint value octet
|
||||
local -a endpoints=()
|
||||
if [[ -n "$PUBLIC_IP_URL" ]]; then
|
||||
endpoints=("$PUBLIC_IP_URL")
|
||||
else
|
||||
# These services return the caller's address as plain text. HTTPS is
|
||||
# required, and a failure simply falls back to manual address entry.
|
||||
endpoints=(
|
||||
'https://api.ipify.org'
|
||||
'https://ifconfig.me/ip'
|
||||
'https://checkip.amazonaws.com'
|
||||
)
|
||||
fi
|
||||
command -v curl >/dev/null 2>&1 || return 1
|
||||
for endpoint in "${endpoints[@]}"; do
|
||||
[[ "$endpoint" == https://* && "$endpoint" != *[[:space:]]* && "$endpoint" != *[[:cntrl:]]* && "$endpoint" != *'@'* ]] || continue
|
||||
value=$(curl -4 --proto '=https' --tlsv1.2 --fail --silent --show-error --max-redirs 0 \
|
||||
--connect-timeout 4 --max-time 8 --max-filesize 128 "$endpoint" 2>/dev/null \
|
||||
| tr -d '[:space:]') || continue
|
||||
[[ "$value" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || continue
|
||||
IFS='.' read -r -a _public_ip_octets <<< "$value"
|
||||
for octet in "${_public_ip_octets[@]}"; do
|
||||
(( 10#$octet <= 255 )) || continue 2
|
||||
done
|
||||
printf '%s' "$value"
|
||||
return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
port_listener_state() {
|
||||
local port=$1 output status=0
|
||||
validate_listen_port "$port" >/dev/null 2>&1 || return 2
|
||||
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
if output=$(ss -H -ltn 2>/dev/null); then
|
||||
if awk -v port="$port" '$4 ~ (":" port "$") { found=1 } END { exit found ? 0 : 1 }' <<< "$output"; then
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if command -v lsof >/dev/null 2>&1; then
|
||||
output=''
|
||||
status=0
|
||||
output=$(lsof -nP -iTCP:"$port" -sTCP:LISTEN -t 2>/dev/null) || status=$?
|
||||
[[ -n "$output" ]] && return 1
|
||||
[[ "$status" == 1 && -z "$output" ]] && return 0
|
||||
[[ "$status" == 0 ]] && return 0
|
||||
fi
|
||||
|
||||
if command -v netstat >/dev/null 2>&1; then
|
||||
if output=$(netstat -lnt 2>/dev/null); then
|
||||
if awk -v port="$port" '$6 == "LISTEN" && $4 ~ (":" port "$") { found=1 } END { exit found ? 0 : 1 }' <<< "$output"; then
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
python3 - "$port" <<'PY'
|
||||
import errno
|
||||
import socket
|
||||
import sys
|
||||
|
||||
port = int(sys.argv[1])
|
||||
for family, address in ((socket.AF_INET, "0.0.0.0"), (socket.AF_INET6, "::")):
|
||||
sock = socket.socket(family, socket.SOCK_STREAM)
|
||||
try:
|
||||
if family == socket.AF_INET6:
|
||||
sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
|
||||
sock.bind((address, port))
|
||||
except OSError as error:
|
||||
if error.errno == errno.EADDRINUSE:
|
||||
sys.exit(1)
|
||||
finally:
|
||||
sock.close()
|
||||
sys.exit(0)
|
||||
PY
|
||||
status=$?
|
||||
case "$status" in
|
||||
0) return 0 ;;
|
||||
1) return 1 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
return 2
|
||||
}
|
||||
|
||||
check_requested_port() {
|
||||
local port=$1 state
|
||||
state=0
|
||||
port_listener_state "$port" || state=$?
|
||||
case "$state" in
|
||||
0) return 0 ;;
|
||||
1) die "端口 ${port} 已被占用,请选择其他端口" ;;
|
||||
*) die "无法检测端口 ${port} 是否被占用,请安装 ss、lsof、netstat 或 Python 3 后重试" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
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() {
|
||||
[[ -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
|
||||
NETWORK_INTERACTIVE=1
|
||||
|
||||
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 port_state detected_ip default_origin
|
||||
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
|
||||
# A real terminal can reject an occupied port immediately. The final
|
||||
# check in main() runs again after old services have been stopped.
|
||||
if [[ -t 9 ]]; then
|
||||
port_state=0
|
||||
port_listener_state "$selected_port" || port_state=$?
|
||||
case "$port_state" in
|
||||
0) break ;;
|
||||
1) printf '端口 %s 已被占用,请输入其他端口。\n' "$selected_port" > "$PROMPT_OUTPUT"; continue ;;
|
||||
*) printf '暂时无法预检端口,安装前还会再次检查。\n' > "$PROMPT_OUTPUT"; break ;;
|
||||
esac
|
||||
fi
|
||||
break
|
||||
fi
|
||||
printf '端口必须是 1-65535 的整数,请重试。\n' > "$PROMPT_OUTPUT"
|
||||
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
|
||||
detected_ip=''
|
||||
# Test fixtures replace /dev/tty with regular files; avoid making their
|
||||
# behavior depend on an external IP lookup service.
|
||||
if [[ -t 9 ]]; then
|
||||
detected_ip=$(detect_public_ipv4 || true)
|
||||
fi
|
||||
if [[ -n "$detected_ip" ]]; then
|
||||
default_origin="http://${detected_ip}:${selected_port}"
|
||||
printf '已探测公网 IPv4:%s\n' "$detected_ip" > "$PROMPT_OUTPUT"
|
||||
else
|
||||
default_origin=''
|
||||
printf '未能自动获取公网 IPv4,请手动填写访问地址。\n' > "$PROMPT_OUTPUT"
|
||||
fi
|
||||
while :; do
|
||||
prompt_value '实际访问地址(回车使用自动探测地址,也可填写域名)' "$default_origin"
|
||||
origin=$PROMPT_REPLY
|
||||
if validate_env_value "$origin" '公开访问地址' >/dev/null 2>&1 && validate_public_origin "$origin" >/dev/null 2>&1; then
|
||||
INSTALL_PUBLIC_ORIGIN=$origin
|
||||
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 +381,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
|
||||
@@ -525,6 +792,17 @@ stop_existing_services() {
|
||||
# Stop the path trigger first so it cannot launch the privileged updater while
|
||||
# the data tree is being repaired.
|
||||
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
|
||||
case "$unit" in
|
||||
tallynote.service) INSTALL_WAS_ACTIVE=1 ;;
|
||||
@@ -538,6 +816,17 @@ stop_existing_services() {
|
||||
|
||||
rollback_install_if_needed() {
|
||||
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 [[ -n "$INSTALL_PREVIOUS_TARGET" && -d "$INSTALL_PREVIOUS_TARGET" ]]; then
|
||||
rollback_tmp="$PREFIX/.current-rollback-$$-${RANDOM}.tmp"
|
||||
@@ -576,6 +865,7 @@ rollback_install_if_needed() {
|
||||
done
|
||||
fi
|
||||
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_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
|
||||
@@ -640,7 +930,7 @@ validate_listen_host() {
|
||||
local octet
|
||||
IFS='.' read -r -a _host_octets <<< "$value"
|
||||
for octet in "${_host_octets[@]}"; do
|
||||
(( octet <= 255 )) || die "$label 必须是有效的 IPv4 地址或主机名"
|
||||
(( 10#$octet <= 255 )) || die "$label 必须是有效的 IPv4 地址或主机名"
|
||||
done
|
||||
else
|
||||
[[ "$value" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$ ]] || die "$label 必须是有效的 IPv4、IPv6 地址或主机名"
|
||||
@@ -654,7 +944,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,22 +958,22 @@ 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
|
||||
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 的整数'
|
||||
validate_listen_host "$host" '公开访问地址主机'
|
||||
if [[ -n "$origin_port" ]]; then
|
||||
[[ "$origin_port" =~ ^[0-9]{1,5}$ && "$origin_port" -ge 1 && "$origin_port" -le 65535 ]] || die '公开访问地址端口必须是 1-65535 的整数'
|
||||
fi
|
||||
path_part=${value#*://}
|
||||
path_part=${path_part#"$authority"}
|
||||
@@ -754,24 +1044,26 @@ validate_existing_env() {
|
||||
origin=$(read_env_value "$file" TALLYNOTE_PUBLIC_ORIGIN)
|
||||
validate_env_value "$origin" '环境文件中的公开访问地址'
|
||||
else
|
||||
origin="http://${host}:${port}"
|
||||
local origin_host=$host
|
||||
[[ "$origin_host" == *:* && "$origin_host" != \[* ]] && origin_host="[$origin_host]"
|
||||
origin="http://${origin_host}:${port}"
|
||||
fi
|
||||
validate_public_origin "$origin"
|
||||
local origin_host=${origin#*://}
|
||||
if [[ "$origin_host" == \[*\]* ]]; then
|
||||
origin_host=${origin_host#\[}
|
||||
origin_host=${origin_host%%\]*}
|
||||
local origin_host_for_policy=${origin#*://}
|
||||
if [[ "$origin_host_for_policy" == \[*\]* ]]; then
|
||||
origin_host_for_policy=${origin_host_for_policy#\[}
|
||||
origin_host_for_policy=${origin_host_for_policy%%\]*}
|
||||
else
|
||||
origin_host=${origin_host%%:*}
|
||||
origin_host_for_policy=${origin_host_for_policy%%:*}
|
||||
fi
|
||||
if [[ "$origin" == http://* && "$allow_insecure" != true ]]; then
|
||||
case "$origin_host" in
|
||||
case "$origin_host_for_policy" in
|
||||
127.0.0.1|localhost|::1) ;;
|
||||
*) die '环境文件中的公网 HTTP 访问必须显式设置 TALLYNOTE_ALLOW_INSECURE_HTTP=true' ;;
|
||||
esac
|
||||
fi
|
||||
if [[ "$origin" == http://* && "$cookie_secure" == true ]]; then
|
||||
case "$origin_host" in
|
||||
case "$origin_host_for_policy" in
|
||||
127.0.0.1|localhost|::1) ;;
|
||||
*) die '环境文件中的公网 HTTP 公开地址不能启用安全 Cookie' ;;
|
||||
esac
|
||||
@@ -791,7 +1083,10 @@ validate_existing_env() {
|
||||
install_release() {
|
||||
local archive=$1 version=$2 tmp release_dir current_tmp=''
|
||||
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"
|
||||
normalize_release_tree "$tmp/unpacked"
|
||||
[[ -d "$tmp/unpacked/dist" ]] || die 'release archive must contain dist/ at its root'
|
||||
@@ -857,8 +1152,12 @@ main() {
|
||||
validate_trusted_tool "$OPENSSL_BIN" 'openssl'
|
||||
fi
|
||||
detect_platform
|
||||
configure_network_interactively
|
||||
validate_listen_host "$INSTALL_HOST"
|
||||
validate_listen_port "$INSTALL_PORT"
|
||||
if (( APPLY && NETWORK_INTERACTIVE )); then
|
||||
check_requested_port "$INSTALL_PORT"
|
||||
fi
|
||||
if [[ -n "${TALLYNOTE_PUBLIC_ORIGIN+x}" && -z "$INSTALL_PUBLIC_ORIGIN" ]]; then
|
||||
die 'TALLYNOTE_PUBLIC_ORIGIN 不能是空值;省略该变量以使用默认 Origin'
|
||||
fi
|
||||
@@ -1001,6 +1300,15 @@ main() {
|
||||
if [[ -e "$CONFIG_DIR/tallynote.env" ]]; then
|
||||
validate_existing_env "$CONFIG_DIR/tallynote.env"
|
||||
fi
|
||||
# During upgrades, the existing environment remains authoritative unless a
|
||||
# new port was explicitly supplied. Check the effective listener port after
|
||||
# stopping the old service so an unrelated process cannot claim it.
|
||||
local effective_port=$INSTALL_PORT
|
||||
if [[ -z "${TALLYNOTE_PORT+x}" && -f "$CONFIG_DIR/tallynote.env" ]]; then
|
||||
effective_port=$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_PORT 2>/dev/null || true)
|
||||
effective_port=${effective_port:-3000}
|
||||
fi
|
||||
check_requested_port "$effective_port"
|
||||
stage_done '目录、权限和旧服务状态已准备'
|
||||
stage "解包、校验包结构并原子切换到版本 ${VERSION#v}"
|
||||
install_release "$archive" "$VERSION"
|
||||
@@ -1042,7 +1350,7 @@ main() {
|
||||
fi
|
||||
}
|
||||
set_env_key() {
|
||||
local key=$1 value=$2 escaped tmp
|
||||
local key=$1 value=$2 escaped
|
||||
[[ "$key" =~ ^[A-Z0-9_]+$ ]] || die '环境变量名无效'
|
||||
validate_env_value "$value" "$key"
|
||||
escaped=${value//\\/\\\\}
|
||||
@@ -1104,7 +1412,28 @@ main() {
|
||||
chown root:root "$CONFIG_DIR/tallynote.env"
|
||||
chmod 640 "$CONFIG_DIR/tallynote.env"
|
||||
systemctl daemon-reload
|
||||
INSTALL_SYSTEMD_TOUCHED=1
|
||||
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 '清理旧版本并完成安装'
|
||||
prune_releases
|
||||
@@ -1114,6 +1443,21 @@ main() {
|
||||
rm -rf -- "$INSTALL_WORK_DIR" 2>/dev/null || true
|
||||
INSTALL_WORK_DIR=''
|
||||
stage_done "安装完成:TallyNote ${VERSION#v}"
|
||||
local access_url access_host access_port configured_host configured_port
|
||||
access_url=$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_PUBLIC_ORIGIN 2>/dev/null || true)
|
||||
if [[ -z "$access_url" ]]; then
|
||||
configured_host=$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_HOST 2>/dev/null || true)
|
||||
configured_port=$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_PORT 2>/dev/null || true)
|
||||
access_host=${configured_host:-$INSTALL_HOST}
|
||||
access_port=${configured_port:-$INSTALL_PORT}
|
||||
if [[ "$access_host" == 0.0.0.0 ]]; then
|
||||
access_host=$(detect_public_ipv4 || true)
|
||||
fi
|
||||
[[ -n "$access_host" ]] || access_host=$INSTALL_HOST
|
||||
[[ "$access_host" == *:* && "$access_host" != \[* ]] && access_host="[$access_host]"
|
||||
access_url="http://${access_host}:${access_port}"
|
||||
fi
|
||||
log "访问地址:$access_url"
|
||||
log '查看服务状态:systemctl status tallynote.service'
|
||||
}
|
||||
main "$@"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tallynote",
|
||||
"version": "1.1.4",
|
||||
"version": "1.1.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@9.0.6",
|
||||
|
||||
@@ -85,6 +85,108 @@ bash -c '
|
||||
fi
|
||||
' _ "$installer_lib" "$tmp/mode" "$tmp/user-parent"
|
||||
|
||||
# The port probe must distinguish a listening TCP port from a free one.
|
||||
port_tools="$tmp/port-tools"
|
||||
mkdir -p "$port_tools"
|
||||
printf '%s\n' '#!/usr/bin/env bash' 'printf "%s\\n" "LISTEN 0 128 127.0.0.1:3443 0.0.0.0:*"' > "$port_tools/ss"
|
||||
chmod 755 "$port_tools/ss"
|
||||
bash -c '
|
||||
script=$1
|
||||
tools=$2
|
||||
set --
|
||||
source "$script"
|
||||
PATH="$tools:$PATH"
|
||||
state=0
|
||||
port_listener_state 3443 || state=$?
|
||||
[[ "$state" == 1 ]]
|
||||
state=0
|
||||
port_listener_state 3444 || state=$?
|
||||
[[ "$state" == 0 ]]
|
||||
' _ "$installer_lib" "$port_tools"
|
||||
|
||||
# The lsof fallback must treat its normal "no matches" exit status as a free
|
||||
# port, while still reporting a listener when it returns a PID.
|
||||
lsof_tools="$tmp/lsof-tools"
|
||||
mkdir -p "$lsof_tools"
|
||||
printf '%s\n' '#!/usr/bin/env bash' 'exit 127' > "$lsof_tools/ss"
|
||||
printf '%s\n' '#!/usr/bin/env bash' 'case "$*" in *TCP:3443*) printf "%s\\n" 4242; exit 0 ;; *) exit 1 ;; esac' > "$lsof_tools/lsof"
|
||||
chmod 755 "$lsof_tools/ss" "$lsof_tools/lsof"
|
||||
bash -c '
|
||||
script=$1
|
||||
tools=$2
|
||||
set --
|
||||
source "$script"
|
||||
PATH="$tools:$PATH"
|
||||
state=0
|
||||
port_listener_state 3443 || state=$?
|
||||
[[ "$state" == 1 ]]
|
||||
state=0
|
||||
port_listener_state 3444 || state=$?
|
||||
[[ "$state" == 0 ]]
|
||||
' _ "$installer_lib" "$lsof_tools"
|
||||
|
||||
# Public-IP discovery accepts a valid IPv4 response and rejects malformed
|
||||
# values without making the test depend on an external service.
|
||||
bash -c '
|
||||
script=$1
|
||||
set --
|
||||
source "$script"
|
||||
PUBLIC_IP_URL=https://ip.example.test
|
||||
curl() { printf "%s\\n" "198.51.100.7"; }
|
||||
[[ "$(detect_public_ipv4)" == "198.51.100.7" ]]
|
||||
curl() { printf "%s\\n" "999.1.1.1"; }
|
||||
if detect_public_ipv4 >/dev/null 2>&1; then
|
||||
echo "expected invalid public IPv4 response to fail" >&2
|
||||
exit 1
|
||||
fi
|
||||
' _ "$installer_lib"
|
||||
|
||||
# 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
|
||||
# when the first value looks valid (systemd uses the later value).
|
||||
duplicate_env="$tmp/duplicate.env"
|
||||
@@ -167,6 +269,36 @@ 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"
|
||||
printf '%s\n' \
|
||||
'TALLYNOTE_HOST=::1' \
|
||||
'TALLYNOTE_PORT=3443' > "$tmp/ipv6-default-origin.env"
|
||||
bash -c '
|
||||
script=$1
|
||||
env_file=$2
|
||||
set --
|
||||
source "$script"
|
||||
PREFIX=/opt/tallynote
|
||||
DATA_DIR=/var/lib/tallynote
|
||||
stat_uid() { printf "0"; }
|
||||
stat_mode_bits() { printf "384"; }
|
||||
validate_existing_env "$env_file"
|
||||
' _ "$installer_lib" "$tmp/ipv6-default-origin.env"
|
||||
if bash -c '
|
||||
script=$1
|
||||
set --
|
||||
@@ -177,6 +309,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"
|
||||
|
||||
@@ -98,6 +98,20 @@ run_uninstall "$fixture" --purge-data --yes --purge-config
|
||||
[[ ! -e "$fixture/var/lib/tallynote" && ! -e "$fixture/var/lib/tallynote-backups" ]]
|
||||
[[ ! -e "$fixture/etc/tallynote" ]]
|
||||
|
||||
# In production the service user owns the data directory. The target itself
|
||||
# must be accepted while its parent directories remain root-owned. This test
|
||||
# is meaningful only when the suite runs as root on a host with that account;
|
||||
# ordinary developer runs continue with the portable fixture coverage above.
|
||||
tallynote_uid=$(id -u tallynote 2>/dev/null || true)
|
||||
if [[ "$EUID" == 0 && -n "$tallynote_uid" && "$tallynote_uid" != "$(id -u)" ]]; then
|
||||
fixture="$tmp/service-user-data"
|
||||
make_fixture "$fixture"
|
||||
make_systemctl "$fixture"
|
||||
chown -R "$tallynote_uid" "$fixture/var/lib/tallynote"
|
||||
TALLYNOTE_UNINSTALL_TEST_DATA_OWNER_UID="$tallynote_uid" run_uninstall "$fixture" --purge-data --yes
|
||||
[[ ! -e "$fixture/var/lib/tallynote" ]]
|
||||
fi
|
||||
|
||||
# A custom data path must not overlap the release prefix; otherwise removing
|
||||
# releases could destroy data that the default uninstall promises to keep.
|
||||
fixture="$tmp/overlap"
|
||||
|
||||
+14
-14
@@ -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.2.0-${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.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 () => {
|
||||
@@ -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.2.0", 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.2.0", 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.2.0", 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.2.0", 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.2.0", 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.2.0" });
|
||||
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.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.2.0", 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.2.0", 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.2.0" } });
|
||||
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.2.0", 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.2.0", 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");
|
||||
|
||||
@@ -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.2.0-${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.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 {
|
||||
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.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 };
|
||||
expect(JSON.parse(cached.value).asset.sha256).toBe(digest);
|
||||
} finally {
|
||||
|
||||
+25
-2
@@ -10,6 +10,7 @@ umask 077
|
||||
|
||||
TEST_MODE=${TALLYNOTE_UNINSTALL_TEST_MODE:-false}
|
||||
TEST_ROOT=${TALLYNOTE_UNINSTALL_ROOT:-}
|
||||
TEST_DATA_OWNER_UID=${TALLYNOTE_UNINSTALL_TEST_DATA_OWNER_UID:-}
|
||||
PREFIX=${TALLYNOTE_PREFIX:-/opt/tallynote}
|
||||
DATA_DIR=${TALLYNOTE_DATA_DIR:-/var/lib/tallynote}
|
||||
CONFIG_DIR=${TALLYNOTE_CONFIG_DIR:-/etc/tallynote}
|
||||
@@ -18,6 +19,7 @@ SBIN_DIR=${TALLYNOTE_SBIN_DIR:-/usr/local/sbin}
|
||||
LIBEXEC_DIR=${TALLYNOTE_LIBEXEC_DIR:-/usr/local/libexec}
|
||||
SYSTEMCTL_BIN=systemctl
|
||||
SYSTEMCTL_AVAILABLE=0
|
||||
SYSTEMCTL_TIMEOUT_SECONDS=${TALLYNOTE_UNINSTALL_SYSTEMCTL_TIMEOUT_SECONDS:-30}
|
||||
PURGE_DATA=0
|
||||
PURGE_CONFIG=0
|
||||
YES=0
|
||||
@@ -51,6 +53,7 @@ if [[ "$TEST_MODE" != true && "$TEST_MODE" != false && "$TEST_MODE" != 1 && "$TE
|
||||
fi
|
||||
if [[ "$TEST_MODE" == 1 ]]; then TEST_MODE=true; fi
|
||||
if [[ "$TEST_MODE" == 0 ]]; then TEST_MODE=false; fi
|
||||
[[ "$SYSTEMCTL_TIMEOUT_SECONDS" =~ ^[1-9][0-9]*$ ]] || die 'TALLYNOTE_UNINSTALL_SYSTEMCTL_TIMEOUT_SECONDS must be a positive integer'
|
||||
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
@@ -103,7 +106,7 @@ allowed_data_owner() {
|
||||
local path=$1 uid tallynote_uid
|
||||
uid=$(stat_uid "$path")
|
||||
if [[ "$TEST_MODE" == true ]]; then
|
||||
[[ "$uid" == "$(id -u)" || "$uid" == 0 ]]
|
||||
[[ "$uid" == "$(id -u)" || "$uid" == 0 || ( -n "$TEST_DATA_OWNER_UID" && "$uid" == "$TEST_DATA_OWNER_UID" ) ]]
|
||||
return
|
||||
fi
|
||||
[[ "$uid" == 0 ]] && return 0
|
||||
@@ -130,7 +133,13 @@ validate_parent_chain() {
|
||||
if [[ -L "$current" ]]; then die "路径不能包含符号链接:$current"; fi
|
||||
if [[ -e "$current" ]]; then
|
||||
[[ -d "$current" ]] || die "路径不是目录:$current"
|
||||
# The target itself is checked by validate_target with its path-specific
|
||||
# owner policy (data may belong to the tallynote service user). Keep all
|
||||
# ancestor directories root-owned, but do not apply that policy twice to
|
||||
# the final target.
|
||||
if [[ "$current" != "$target" ]]; then
|
||||
allowed_owner "$current" || die "路径目录的所有者不受信任:$current"
|
||||
fi
|
||||
local mode_bits
|
||||
mode_bits=$(stat_mode_bits "$current")
|
||||
(( (mode_bits & 18) == 0 || (mode_bits & 512) != 0 )) || die "路径目录权限过宽:$current"
|
||||
@@ -289,7 +298,13 @@ run_systemctl() {
|
||||
else
|
||||
command -v "$SYSTEMCTL_BIN" >/dev/null 2>&1 || return 0
|
||||
fi
|
||||
# A stuck systemd/dbus call must not leave the uninstaller looking frozen.
|
||||
# Test fixtures intentionally bypass the external timeout command.
|
||||
if [[ "$TEST_MODE" != true ]] && command -v timeout >/dev/null 2>&1; then
|
||||
timeout "$SYSTEMCTL_TIMEOUT_SECONDS" "$SYSTEMCTL_BIN" "$@"
|
||||
else
|
||||
"$SYSTEMCTL_BIN" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
stop_services() {
|
||||
@@ -304,8 +319,10 @@ stop_services() {
|
||||
done
|
||||
return 0
|
||||
fi
|
||||
log "正在停止 TallyNote 服务(systemd 操作超时 ${SYSTEMCTL_TIMEOUT_SECONDS} 秒)"
|
||||
for unit in tallynote-update.path tallynote-update.service tallynote.service; do
|
||||
active=0
|
||||
log "检查服务:$unit"
|
||||
if run_systemctl is-active --quiet "$unit" >/dev/null 2>&1; then
|
||||
active=1
|
||||
else
|
||||
@@ -316,13 +333,17 @@ stop_services() {
|
||||
esac
|
||||
fi
|
||||
if (( active )); then
|
||||
run_systemctl stop "$unit" || die "无法停止服务:$unit"
|
||||
log "停止服务:$unit"
|
||||
run_systemctl stop "$unit" || die "无法停止服务:$unit(如果 systemd 正在等待进程退出,请稍后重试)"
|
||||
log "已停止服务:$unit"
|
||||
fi
|
||||
if [[ -e "$UNIT_DIR/$unit" ]]; then
|
||||
log "禁用服务:$unit"
|
||||
run_systemctl disable "$unit" >/dev/null 2>&1 || die "无法禁用服务:$unit"
|
||||
fi
|
||||
done
|
||||
run_systemctl daemon-reload >/dev/null 2>&1 || die 'systemd daemon-reload 失败'
|
||||
log 'systemd 服务已停止并禁用'
|
||||
}
|
||||
|
||||
validate_systemctl() {
|
||||
@@ -457,8 +478,10 @@ main() {
|
||||
die '检测到未完成的更新状态;确认更新已停止后使用 --force 重试'
|
||||
fi
|
||||
log "target: prefix=$PREFIX data=$DATA_DIR config=$CONFIG_DIR"
|
||||
log '开始移除 TallyNote 文件和服务配置'
|
||||
stop_services
|
||||
remove_prefix
|
||||
log '发布文件和更新组件已移除'
|
||||
remove_file_if_owned "$UNIT_DIR/tallynote.service" 'service unit'
|
||||
remove_file_if_owned "$UNIT_DIR/tallynote-update.service" 'updater unit'
|
||||
remove_file_if_owned "$UNIT_DIR/tallynote-update.path" 'path unit'
|
||||
|
||||
Reference in New Issue
Block a user