729 lines
30 KiB
Bash
Executable File
729 lines
30 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"
|
|
grep -Eq '^RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK$' "$root/systemd/tallynote.service"
|
|
grep -Eq '^RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK$' "$root/systemd/tallynote-update.service"
|
|
grep -Eq '^PathExists=/opt/tallynote/\.update-state$' "$root/systemd/tallynote-update.path"
|
|
grep -Eq '^PathChanged=/opt/tallynote/\.update-state$' "$root/systemd/tallynote-update.path"
|
|
grep -Eq '^PathChanged=/opt/tallynote$' "$root/systemd/tallynote-update.path"
|
|
if grep -Eq '^ConditionPathExists=' "$root/systemd/tallynote-update.service"; then
|
|
echo 'update service must not require only the request file' >&2
|
|
exit 1
|
|
fi
|
|
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"
|
|
# CI runs this shell suite as root. Make the parent genuinely non-root in
|
|
# that environment so the assertion exercises the ownership guard instead
|
|
# of accidentally passing because root-owned parents are allowed.
|
|
if [[ "${EUID:-$(id -u)}" == 0 ]]; then
|
|
chown 65534:65534 "$owner_parent"
|
|
fi
|
|
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"
|
|
|
|
# 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"
|
|
|
|
# Exercise the privileged runner's normal apply hand-off with portable command
|
|
# shims. In particular, the freshly-created running marker must not be treated
|
|
# as stale state before the update CLI gets a chance to process the request.
|
|
runner_root="$tmp/runner"
|
|
runner_prefix="$runner_root/prefix"
|
|
runner_data="$runner_root/data"
|
|
runner_tools="$runner_root/tools"
|
|
mkdir -p "$runner_prefix/releases/1.0.0/dist/server/cli" "$runner_data" "$runner_tools"
|
|
ln -s "$runner_prefix/releases/1.0.0" "$runner_prefix/current"
|
|
printf '%s\n' '{"jobId":"00000000-0000-4000-8000-000000000001","operation":"apply"}' > "$runner_data/update-request.json"
|
|
printf '%s\n' '#!/usr/bin/env bash' 'if [[ "${1:-}" == "-p" ]]; then printf "%s\n" "24"; else printf "%s\n" "$*" >> "$TALLYNOTE_NODE_TRACE"; fi' 'exit 0' > "$runner_tools/node"
|
|
printf '%s\n' cli > "$runner_prefix/releases/1.0.0/dist/server/cli/update.js"
|
|
printf '%s\n' '#!/usr/bin/env bash' 'case "${1:-}" in is-active) exit 0;; *) exit 0;; esac' > "$runner_tools/systemctl"
|
|
printf '%s\n' '#!/usr/bin/env bash' 'if [[ "${1:-}" == "-f" ]]; then shift; [[ "${1:-}" == "--" ]] && shift; /bin/realpath "$1"; else /usr/bin/readlink "$@"; fi' > "$runner_tools/readlink"
|
|
printf '%s\n' '#!/usr/bin/env bash' 'if [[ "${1:-}" == "-Tf" ]]; then shift; /bin/mv -f "$@"; else /bin/mv "$@"; fi' > "$runner_tools/mv"
|
|
printf '%s\n' '#!/usr/bin/env bash' 'exit 0' > "$runner_tools/curl"
|
|
chmod 755 "$runner_tools/node" "$runner_tools/systemctl" "$runner_tools/readlink" "$runner_tools/mv" "$runner_tools/curl"
|
|
runner_script="$runner_root/runner.sh"
|
|
runner_path="$runner_tools:/usr/sbin:/usr/bin:/sbin:/bin"
|
|
sed "s#PATH=/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin#PATH=$runner_path#" "$root/scripts/tallynote-update-runner.sh" > "$runner_script"
|
|
chmod 755 "$runner_script"
|
|
runner_prefix_physical=$(cd "$runner_prefix" && pwd -P)
|
|
runner_data_physical=$(cd "$runner_data" && pwd -P)
|
|
env EUID=0 TALLYNOTE_INSTALL_PREFIX="$runner_prefix_physical" TALLYNOTE_DATA_DIR="$runner_data_physical" TALLYNOTE_NODE="$runner_tools/node" TALLYNOTE_NODE_TRACE="$runner_root/node.log" bash "$runner_script"
|
|
grep -q -- '--request-file' "$runner_root/node.log"
|
|
grep -q -- '--finalize-job' "$runner_root/node.log"
|
|
[[ ! -e "$runner_data/update-request.json" ]]
|
|
[[ ! -e "$runner_prefix/.update-state" ]]
|
|
|
|
# A stale download marker must be recoverable without finalizing the staged
|
|
# download as a failed apply. The next runner invocation should retry the
|
|
# request and let the CLI preserve/refresh its staged workspace.
|
|
download_runner_root="$tmp/download-runner"
|
|
download_runner_prefix="$download_runner_root/prefix"
|
|
download_runner_data="$download_runner_root/data"
|
|
download_runner_tools="$download_runner_root/tools"
|
|
mkdir -p "$download_runner_prefix/releases/1.0.0/dist/server/cli" "$download_runner_data" "$download_runner_tools"
|
|
ln -s "$download_runner_prefix/releases/1.0.0" "$download_runner_prefix/current"
|
|
# The request has already been consumed; only the stale download marker is
|
|
# left, which is the narrow recovery window covered by this fixture.
|
|
download_runner_prefix_physical=$(cd "$download_runner_prefix" && pwd -P)
|
|
download_runner_data_physical=$(cd "$download_runner_data" && pwd -P)
|
|
printf '%s\n' 'job_id=00000000-0000-4000-8000-000000000002' "old_target=$download_runner_prefix_physical/releases/1.0.0" 'phase=download' > "$download_runner_prefix/.update-state"
|
|
printf '%s\n' '#!/usr/bin/env bash' 'if [[ "${1:-}" == "-p" ]]; then printf "%s\n" "24"; else printf "%s\n" "$*" >> "$TALLYNOTE_DOWNLOAD_NODE_TRACE"; fi' 'exit 0' > "$download_runner_tools/node"
|
|
printf '%s\n' cli > "$download_runner_prefix/releases/1.0.0/dist/server/cli/update.js"
|
|
printf '%s\n' '#!/usr/bin/env bash' 'case "${1:-}" in is-active) exit 0;; *) exit 0;; esac' > "$download_runner_tools/systemctl"
|
|
printf '%s\n' '#!/usr/bin/env bash' 'if [[ "${1:-}" == "-f" ]]; then shift; [[ "${1:-}" == "--" ]] && shift; /bin/realpath "$1"; else /usr/bin/readlink "$@"; fi' > "$download_runner_tools/readlink"
|
|
cat >"$download_runner_tools/stat" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
case "$*" in
|
|
*"-c %u"*|*"-f %u"*) printf '0\n' ;;
|
|
*"-c %a"*|*"-f %Lp"*) printf '600\n' ;;
|
|
*) /usr/bin/stat "$@" ;;
|
|
esac
|
|
EOF
|
|
chmod 755 "$download_runner_tools/node" "$download_runner_tools/systemctl" "$download_runner_tools/readlink" "$download_runner_tools/stat"
|
|
download_runner_script="$download_runner_root/runner.sh"
|
|
sed "s#PATH=/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin#PATH=$download_runner_tools:/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin#" "$root/scripts/tallynote-update-runner.sh" > "$download_runner_script"
|
|
chmod 755 "$download_runner_script"
|
|
env EUID=0 TALLYNOTE_INSTALL_PREFIX="$download_runner_prefix_physical" TALLYNOTE_DATA_DIR="$download_runner_data_physical" TALLYNOTE_NODE="$download_runner_tools/node" TALLYNOTE_DOWNLOAD_NODE_TRACE="$download_runner_root/node.log" bash "$download_runner_script"
|
|
[[ ! -e "$download_runner_root/node.log" ]]
|
|
[[ ! -e "$download_runner_prefix/.update-state" ]]
|
|
|
|
# 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/cli" "$release_fixture/dist/web" "$release_fixture/bin" \
|
|
"$release_fixture/scripts" "$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' cli > "$release_fixture/dist/server/cli/admin-init.js"
|
|
printf '%s\n' web > "$release_fixture/dist/web/index.html"
|
|
printf '%s\n' '#!/bin/sh' > "$release_fixture/bin/tallynote"
|
|
cp "$root/bin/tallynote-admin-init" "$release_fixture/bin/tallynote-admin-init"
|
|
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/bin/tallynote-admin-init" "$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
|
|
[[ -x "$PREFIX/releases/1.0.0/bin/tallynote-admin-init" ]]
|
|
set_env_key() { local key=$1 value=$2 escaped; :; }
|
|
set_env_key test value
|
|
' _ "$installer_lib" "$release_archive" "$tmp/install-release"
|
|
|
|
# The installed path unit must watch both the data-directory request and the
|
|
# release-prefix recovery marker after custom paths are substituted.
|
|
rendered_path="$tmp/rendered-update.path"
|
|
sed "s#/opt/tallynote#$tmp/custom-prefix#g; s#/var/lib/tallynote#$tmp/custom-data#g" \
|
|
"$root/systemd/tallynote-update.path" > "$rendered_path"
|
|
grep -Fxq "PathExists=$tmp/custom-data/update-request.json" "$rendered_path"
|
|
grep -Fxq "PathChanged=$tmp/custom-data/update-request.json" "$rendered_path"
|
|
grep -Fxq "PathExists=$tmp/custom-prefix/.update-state" "$rendered_path"
|
|
grep -Fxq "PathChanged=$tmp/custom-prefix/.update-state" "$rendered_path"
|
|
grep -Fxq "PathChanged=$tmp/custom-prefix" "$rendered_path"
|
|
|
|
# The production admin wrapper must load a release-relative runtime, change to
|
|
# the release root, and forward CLI arguments without requiring pnpm.
|
|
wrapper_prefix="$tmp/wrapper-prefix"
|
|
mkdir -p "$wrapper_prefix/releases/1.0.0/dist/server/cli" "$tmp/wrapper-tools"
|
|
ln -s "$wrapper_prefix/releases/1.0.0" "$wrapper_prefix/current"
|
|
printf '%s\n' '#!/usr/bin/env bash' 'if [[ "${1:-}" == "-p" ]]; then printf "%s\n" "24"; else pwd -P > "$TALLYNOTE_WRAPPER_LOG"; printf "%s\n" "$@" >> "$TALLYNOTE_WRAPPER_LOG"; fi' > "$tmp/wrapper-tools/node"
|
|
chmod 755 "$tmp/wrapper-tools/node"
|
|
printf '%s\n' cli > "$wrapper_prefix/releases/1.0.0/dist/server/cli/admin-init.js"
|
|
# This fixture verifies release-relative execution and argument forwarding.
|
|
# Force the wrapper's non-root branch so the root CI runner does not need a
|
|
# real `tallynote` service account or a privileged runuser hand-off; that
|
|
# privilege boundary is validated by the production checks themselves.
|
|
env EUID=1000 TALLYNOTE_INSTALL_PREFIX="$wrapper_prefix" TALLYNOTE_CONFIG_DIR="$tmp/no-config" TALLYNOTE_NODE="$tmp/wrapper-tools/node" TALLYNOTE_WRAPPER_LOG="$tmp/wrapper.log" \
|
|
bash "$root/bin/tallynote-admin-init" --generate
|
|
wrapper_expected_root=$(cd "$wrapper_prefix/releases/1.0.0" && pwd -P)
|
|
grep -Fxq "$wrapper_expected_root" "$tmp/wrapper.log"
|
|
grep -Fxq -- '--generate' "$tmp/wrapper.log"
|
|
|
|
# The first-install prompt is optional and must support an explicit later
|
|
# initialization path without blocking the rest of the install.
|
|
admin_wizard_dir="$tmp/admin-wizard"
|
|
mkdir -p "$admin_wizard_dir"
|
|
printf '%s\n' yes remaining-input > "$admin_wizard_dir/input"
|
|
: > "$admin_wizard_dir/output"
|
|
cat > "$admin_wizard_dir/admin-init" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
if [[ "${1:-}" == --check ]]; then
|
|
printf '%s\n' empty
|
|
else
|
|
printf '%s\n' initialized > "$TALLYNOTE_ADMIN_WIZARD_RESULT"
|
|
fi
|
|
EOF
|
|
chmod 755 "$admin_wizard_dir/admin-init"
|
|
bash -c '
|
|
script=$1
|
|
dir=$2
|
|
set --
|
|
source "$script"
|
|
INSTALL_FIRST_INSTALL=1
|
|
NON_INTERACTIVE=0
|
|
PROMPT_INPUT="$dir/input"
|
|
PROMPT_OUTPUT="$dir/output"
|
|
ADMIN_INIT_PATH="$dir/admin-init"
|
|
TALLYNOTE_ADMIN_WIZARD_RESULT="$dir/result"
|
|
export TALLYNOTE_ADMIN_WIZARD_RESULT
|
|
run_initial_admin_wizard
|
|
[[ -f "$dir/result" ]]
|
|
' _ "$installer_lib" "$admin_wizard_dir"
|
|
|
|
# An upgrade must never reopen the first-admin wizard, even if a damaged or
|
|
# deliberately empty database would otherwise report an uninitialized state.
|
|
printf '%s\n' yes > "$admin_wizard_dir/upgrade-input"
|
|
rm -f "$admin_wizard_dir/upgrade-result"
|
|
bash -c '
|
|
script=$1
|
|
dir=$2
|
|
set --
|
|
source "$script"
|
|
INSTALL_FIRST_INSTALL=0
|
|
NON_INTERACTIVE=0
|
|
PROMPT_INPUT="$dir/upgrade-input"
|
|
PROMPT_OUTPUT="$dir/upgrade-output"
|
|
ADMIN_INIT_PATH="$dir/admin-init"
|
|
TALLYNOTE_ADMIN_WIZARD_RESULT="$dir/upgrade-result"
|
|
export TALLYNOTE_ADMIN_WIZARD_RESULT
|
|
run_initial_admin_wizard
|
|
[[ ! -e "$dir/upgrade-result" ]]
|
|
' _ "$installer_lib" "$admin_wizard_dir"
|
|
|
|
# Validation or password errors after the base service is committed must leave
|
|
# the installation usable and point the operator at the standalone command.
|
|
failed_wizard_dir="$tmp/failed-admin-wizard"
|
|
mkdir -p "$failed_wizard_dir"
|
|
printf '%s\n' yes > "$failed_wizard_dir/input"
|
|
: > "$failed_wizard_dir/output"
|
|
cat >"$failed_wizard_dir/admin-init" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
if [[ "${1:-}" == --check ]]; then
|
|
printf '%s\n' empty
|
|
exit 0
|
|
fi
|
|
exit 1
|
|
EOF
|
|
chmod 755 "$failed_wizard_dir/admin-init"
|
|
bash -c '
|
|
script=$1
|
|
dir=$2
|
|
set --
|
|
source "$script"
|
|
INSTALL_FIRST_INSTALL=1
|
|
NON_INTERACTIVE=0
|
|
PROMPT_INPUT="$dir/input"
|
|
PROMPT_OUTPUT="$dir/output"
|
|
ADMIN_INIT_PATH="$dir/admin-init"
|
|
run_initial_admin_wizard
|
|
[[ -x "$dir/admin-init" ]]
|
|
' _ "$installer_lib" "$failed_wizard_dir"
|
|
|
|
# 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"
|
|
# A standard HTTPS origin may omit its default port; this must remain valid
|
|
# under the installer strict unset-variable mode.
|
|
validate_public_origin "https://example.test"
|
|
' _ "$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"
|
|
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"
|
|
chmod 755 "$source_tmp/bin/tallynote" "$source_tmp/scripts/runner.sh"
|
|
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 ]]
|
|
[[ ! -e "$destination/runtime" ]]
|
|
' _ "$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'
|