Files
TallyNote/scripts/test-installer.sh
T
Qiufeng 26fbec49ad
TallyNote release / linux-x64 (push) Successful in 5m48s
feat: improve installer network setup
2026-09-02 07:38:38 +08:00

490 lines
17 KiB
Bash
Executable File

#!/usr/bin/env bash
set -Eeuo pipefail
root=$(cd "$(dirname "$0")/.." && pwd)
bash -n "$root/install.sh" "$root/scripts/tallynote-update.sh"
output=$(bash "$root/install.sh" --dry-run --release-base-url https://releases.example.test/releases)
grep -q 'dry-run' <<<"$output"
grep -q '\[阶段\] 检查运行环境' <<<"$output"
grep -q '\[完成\] dry-run 预览完成' <<<"$output"
output=$(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"
grep -q '\[阶段\] 使用指定版本:1.2.3' <<<"$output"
if bash "$root/install.sh" --dry-run --release-base-url http://insecure.example.test/releases >/dev/null 2>&1; then
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
rm -r "$tmp" 2>/dev/null || true
fi
}
trap cleanup_tmp EXIT
cat >"$tmp/uname" <<'EOF'
#!/usr/bin/env bash
printf 'i686\n'
EOF
chmod +x "$tmp/uname"
if TALLYNOTE_UNAME_BIN="$tmp/uname" bash "$root/install.sh" --dry-run >/dev/null 2>&1; then
echo 'expected ia32 to fail' >&2
exit 1
fi
if [[ "$(uname -s)" != Linux ]]; then
if bash "$root/scripts/build-release.sh" 1.0.0 /tmp/tallynote-installer-release-test >/dev/null 2>&1; then
echo 'expected non-Linux release build to fail on this host' >&2
exit 1
fi
fi
# Exercise installer helpers without mutating the host. Removing the final
# main invocation lets this subprocess source the exact production code.
installer_lib="$tmp/install-lib.sh"
sed '$d' "$root/install.sh" > "$installer_lib"
bash -c '
script=$1
set --
source "$script"
[[ "$APPLY" -eq 1 ]]
[[ "$REQUIRE_SIGNATURE" == false ]]
' _ "$installer_lib"
bash -c '
script=$1
mode_dir=$2
owner_parent=$3
set --
source "$script"
mkdir -p "$mode_dir"
chmod 700 "$mode_dir"
[[ "$(stat_mode_bits "$mode_dir")" == 448 ]]
mkdir -p "$owner_parent"
if (assert_path_chain "$owner_parent/child") >/dev/null 2>&1; then
echo "expected non-root path parent to fail" >&2
exit 1
fi
' _ "$installer_lib" "$tmp/mode" "$tmp/user-parent"
# The port probe must distinguish a listening TCP port from a free one.
port_tools="$tmp/port-tools"
mkdir -p "$port_tools"
printf '%s\n' '#!/usr/bin/env bash' 'printf "%s\\n" "LISTEN 0 128 127.0.0.1:3443 0.0.0.0:*"' > "$port_tools/ss"
chmod 755 "$port_tools/ss"
bash -c '
script=$1
tools=$2
set --
source "$script"
PATH="$tools:$PATH"
state=0
port_listener_state 3443 || state=$?
[[ "$state" == 1 ]]
state=0
port_listener_state 3444 || state=$?
[[ "$state" == 0 ]]
' _ "$installer_lib" "$port_tools"
# The lsof fallback must treat its normal "no matches" exit status as a free
# port, while still reporting a listener when it returns a PID.
lsof_tools="$tmp/lsof-tools"
mkdir -p "$lsof_tools"
printf '%s\n' '#!/usr/bin/env bash' 'exit 127' > "$lsof_tools/ss"
printf '%s\n' '#!/usr/bin/env bash' 'case "$*" in *TCP:3443*) printf "%s\\n" 4242; exit 0 ;; *) exit 1 ;; esac' > "$lsof_tools/lsof"
chmod 755 "$lsof_tools/ss" "$lsof_tools/lsof"
bash -c '
script=$1
tools=$2
set --
source "$script"
PATH="$tools:$PATH"
state=0
port_listener_state 3443 || state=$?
[[ "$state" == 1 ]]
state=0
port_listener_state 3444 || state=$?
[[ "$state" == 0 ]]
' _ "$installer_lib" "$lsof_tools"
# Public-IP discovery accepts a valid IPv4 response and rejects malformed
# values without making the test depend on an external service.
bash -c '
script=$1
set --
source "$script"
PUBLIC_IP_URL=https://ip.example.test
curl() { printf "%s\\n" "198.51.100.7"; }
[[ "$(detect_public_ipv4)" == "198.51.100.7" ]]
curl() { printf "%s\\n" "999.1.1.1"; }
if detect_public_ipv4 >/dev/null 2>&1; then
echo "expected invalid public IPv4 response to fail" >&2
exit 1
fi
' _ "$installer_lib"
# Duplicate security-sensitive EnvironmentFile assignments are rejected even
# when the first value looks valid (systemd uses the later value).
duplicate_env="$tmp/duplicate.env"
printf '%s\n' 'TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=true' 'TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=false' > "$duplicate_env"
bash -c '
script=$1
env_file=$2
set --
source "$script"
stat_uid() { printf "0"; }
stat_mode_bits() { printf "384"; }
if (validate_existing_env "$env_file") >/dev/null 2>&1; then
echo "expected duplicate environment assignment to fail" >&2
exit 1
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"
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 --
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 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"
mkdir -p "$source_tmp/dist/server" "$source_tmp/bin" "$source_tmp/scripts" "$source_tmp/runtime/bin"
printf '%s\n' 'server' > "$source_tmp/dist/server/index.js"
printf '%s\n' '#!/bin/sh' > "$source_tmp/uninstall.sh"
printf '%s\n' '#!/bin/sh' > "$source_tmp/bin/tallynote"
printf '%s\n' '#!/bin/sh' > "$source_tmp/scripts/runner.sh"
printf '%s\n' 'node' > "$source_tmp/runtime/bin/node"
chmod 755 "$source_tmp/bin/tallynote" "$source_tmp/scripts/runner.sh" "$source_tmp/runtime/bin/node"
chmod 755 "$source_tmp/uninstall.sh"
archive_tmp="$tmp/release.tar.gz"
tar -C "$source_tmp" -czf "$archive_tmp" .
bash -c '
script=$1
archive=$2
destination=$3
set --
source "$script"
safe_extract "$archive" "$destination"
normalize_release_tree "$destination"
[[ "$(stat_mode "$destination/dist")" == 755 ]]
[[ "$(stat_mode "$destination/dist/server/index.js")" == 644 ]]
[[ "$(stat_mode "$destination/bin/tallynote")" == 755 ]]
[[ "$(stat_mode "$destination/uninstall.sh")" == 755 ]]
' _ "$installer_lib" "$archive_tmp" "$tmp/unpacked"
# A normal public-release install only needs the detached SHA-256 manifest;
# absence of a signature and public key must not block archive verification.
checksum_tmp="$tmp/SHA256SUMS"
(cd "$(dirname -- "$archive_tmp")" && sha256sum "$(basename -- "$archive_tmp")") > "$checksum_tmp"
bash -c '
script=$1
archive=$2
checksum=$3
set --
source "$script"
REQUIRE_SIGNATURE=false
verify_archive "$archive" "$checksum" "" ""
' _ "$installer_lib" "$archive_tmp" "$checksum_tmp"
# Newline/control characters in release configuration must never become extra
# systemd EnvironmentFile assignments.
if TALLYNOTE_RELEASE_API_URL=$'https://git.awaioi.com/api/v1\nEVIL=1' bash "$root/install.sh" --dry-run >/dev/null 2>&1; then
echo 'expected control characters in release URL to fail' >&2
exit 1
fi
# The publisher is safe to exercise on every host in dry-run mode. When an
# OpenSSL build supports Ed25519, also verify the exact detached signature.
publisher_tmp=$(mktemp -d)
printf 'test-release' > "$publisher_tmp/tallynote-1.0.0-linux-x64-glibc.tar.gz"
if "$root/scripts/publish-gitea-release.sh" v1.0.0 "$publisher_tmp" --dry-run >/dev/null 2>&1; then
test -s "$publisher_tmp/SHA256SUMS"
else
echo 'publisher dry-run failed' >&2
exit 1
fi
openssl_test_bin=${TALLYNOTE_OPENSSL_BIN:-$(command -v openssl || true)}
if [[ -n "$openssl_test_bin" ]] && "$openssl_test_bin" genpkey -algorithm ED25519 -out "$publisher_tmp/key" >/dev/null 2>&1; then
TALLYNOTE_RELEASE_SIGNING_KEY_FILE="$publisher_tmp/key" TALLYNOTE_OPENSSL_BIN="$openssl_test_bin" \
"$root/scripts/publish-gitea-release.sh" v1.0.0 "$publisher_tmp" --dry-run >/dev/null 2>&1
"$openssl_test_bin" pkey -in "$publisher_tmp/key" -pubout -out "$publisher_tmp/pub" >/dev/null 2>&1
"$openssl_test_bin" pkeyutl -verify -pubin -inkey "$publisher_tmp/pub" -rawin \
-in "$publisher_tmp/SHA256SUMS" -sigfile "$publisher_tmp/SHA256SUMS.sig" >/dev/null 2>&1
# Exercise the 404 -> create -> assets -> upload flow with a local curl
# shim. The shim records argv and verifies the secret only arrives through
# the temporary curl config file, never as a process argument.
if command -v jq >/dev/null 2>&1; then
fake_curl="$publisher_tmp/fake-curl"
fake_trace="$publisher_tmp/curl-args"
fake_config_seen="$publisher_tmp/curl-config-seen"
cat > "$fake_curl" <<'EOF'
#!/usr/bin/env bash
set -Eeuo pipefail
out=''; format=''; method='GET'; url=''; previous=''; config=''
for arg in "$@"; do
case "$previous" in
out) out=$arg; previous=''; continue ;;
format) format=$arg; previous=''; continue ;;
method) method=$arg; previous=''; continue ;;
config) config=$arg; previous=''; continue ;;
esac
case "$arg" in
-o) previous=out ;;
-w) previous=format ;;
-X) previous=method ;;
--config) previous=config ;;
-d*|-F*) method=POST ;;
http://*|https://*) url=$arg ;;
esac
done
printf '%s\n' "$*" >> "$TALLYNOTE_FAKE_CURL_TRACE"
[[ "$*" != *"$TALLYNOTE_FAKE_TOKEN"* ]] || { echo 'token leaked in curl argv' >&2; exit 91; }
[[ -n "$config" && -s "$config" ]] || { echo 'curl auth config missing' >&2; exit 92; }
grep -q "Authorization: token $TALLYNOTE_FAKE_TOKEN" "$config"
printf '%s\n' seen > "$TALLYNOTE_FAKE_CURL_CONFIG_SEEN"
code=200; body='{}'
if [[ "$url" == */releases/tags/* ]]; then
if [[ ! -f "$TALLYNOTE_FAKE_RELEASE_CREATED" ]]; then code=404; body='{}'; else code=200; body='{"id":42}'; fi
elif [[ "$url" == */releases && "$method" == POST ]]; then
printf '%s' created > "$TALLYNOTE_FAKE_RELEASE_CREATED"
code=201; body='{"id":42}'
elif [[ "$url" == */assets && "$method" == GET ]]; then
code=200; body='[]'
elif [[ "$url" == */assets\?name=* ]]; then
code=201; body='{"id":1}'
elif [[ "$method" == DELETE ]]; then
code=204; body=''
fi
if [[ -n "$out" ]]; then
printf '%s' "$body" > "$out"
else
printf '%s' "$body"
fi
if [[ "$format" == '%{http_code}' ]]; then
printf '%s' "$code"
fi
EOF
chmod 700 "$fake_curl"
TALLYNOTE_FAKE_CURL_TRACE="$fake_trace" TALLYNOTE_FAKE_CURL_CONFIG_SEEN="$fake_config_seen" \
TALLYNOTE_FAKE_RELEASE_CREATED="$publisher_tmp/release-created" TALLYNOTE_FAKE_TOKEN='secret-token' \
TALLYNOTE_CURL_BIN="$fake_curl" GITEA_API_URL='https://gitea.example/api/v1' \
GITHUB_REPOSITORY='awaioi/TallyNote' GITEA_TOKEN='secret-token' \
TALLYNOTE_RELEASE_SIGNING_KEY_FILE="$publisher_tmp/key" \
TALLYNOTE_OPENSSL_BIN="$openssl_test_bin" \
"$root/scripts/publish-gitea-release.sh" v1.0.0 "$publisher_tmp" >/dev/null
if grep -q 'secret-token' "$fake_trace"; then
echo 'token leaked in curl argv' >&2
exit 1
fi
test -s "$fake_config_seen"
fi
fi
if [[ -d "$publisher_tmp" ]]; then
rm -r "$publisher_tmp" 2>/dev/null || true
fi
printf '%s\n' 'installer shell tests passed'