ssh host bash script gives no terminal, so read -s could neither show the prompt nor read it, and the script sat there looking frozen. It reads from /dev/tty now and, when there is no terminal, says so and prints the command that works: ssh -t <host> bash bash <data-root>. The deployment readme shows the -t form and uses the real paths.
142 lines
4.7 KiB
Bash
Executable File
142 lines
4.7 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# Writes the one file that never goes in git: the admin password hash and the
|
|
# session secret. Run this on the server, once per Site.
|
|
#
|
|
# scripts/provision-site-env.sh /var/lib/menu-app.hopsngrains.com
|
|
#
|
|
# The hash is produced by the app image itself, so the server needs no Node and
|
|
# the password is never visible in the process list.
|
|
set -euo pipefail
|
|
|
|
usage() {
|
|
cat <<'USAGE'
|
|
usage: provision-site-env.sh <data-root> [image] [--force]
|
|
|
|
<data-root> directory the app mounts as MENU_ROOT, e.g.
|
|
/var/lib/menu-app.hopsngrains.com
|
|
[image] app image to borrow Node from (default menu-admin:latest)
|
|
--force overwrite an existing site.env
|
|
USAGE
|
|
}
|
|
|
|
DATA_ROOT="${1:-}"
|
|
if [[ -z "$DATA_ROOT" || "$DATA_ROOT" == "-h" || "$DATA_ROOT" == "--help" ]]; then
|
|
usage
|
|
exit 1
|
|
fi
|
|
shift || true
|
|
|
|
IMAGE=""
|
|
FORCE=0
|
|
for argument in "$@"; do
|
|
case "$argument" in
|
|
--force) FORCE=1 ;;
|
|
*) IMAGE="$argument" ;;
|
|
esac
|
|
done
|
|
|
|
ENV_FILE="$DATA_ROOT/site.env"
|
|
|
|
if [[ -f "$ENV_FILE" && "$FORCE" -ne 1 ]]; then
|
|
echo "$ENV_FILE already exists; pass --force to replace it" >&2
|
|
exit 1
|
|
fi
|
|
|
|
if ! command -v docker >/dev/null 2>&1; then
|
|
echo "docker is required" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# The image is only needed to borrow node for hashing, so take the one the app
|
|
# is already running rather than guessing a name. The playbook names the
|
|
# container menu-app.<domain> for the web address menu.<domain>.
|
|
if [[ -z "$IMAGE" ]]; then
|
|
for candidate in "menu-app.${DATA_ROOT##*/menu.}" "menu-app.${DATA_ROOT##*/}"; do
|
|
IMAGE="$(docker inspect --format '{{.Config.Image}}' "$candidate" 2>/dev/null || true)"
|
|
[[ -n "$IMAGE" ]] && break
|
|
done
|
|
fi
|
|
|
|
if [[ -z "$IMAGE" ]]; then
|
|
echo "could not tell which image to use: no running app container to borrow one from." >&2
|
|
echo "Pass it explicitly: $0 $DATA_ROOT registry.tanshu.com/menu-admin:<tag>" >&2
|
|
exit 1
|
|
fi
|
|
|
|
if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then
|
|
echo "image $IMAGE is not present on this host." >&2
|
|
echo "Pass the one the app runs: $0 $DATA_ROOT registry.tanshu.com/menu-admin:<tag>" >&2
|
|
exit 1
|
|
fi
|
|
|
|
echo "hashing with the app's image: $IMAGE"
|
|
|
|
# The password is read from the terminal itself, so the script works when it is
|
|
# piped in, and refuses clearly instead of hanging when there is no terminal at
|
|
# all — which is what `ssh host bash script` gives you.
|
|
if ! read -r -s -p "Admin password: " PASSWORD < /dev/tty 2>/dev/null; then
|
|
echo "Could not read a password: this needs a terminal." >&2
|
|
echo "Run it with one: ssh -t <host> bash $0 $DATA_ROOT" >&2
|
|
exit 1
|
|
fi
|
|
echo
|
|
if ! read -r -s -p "Repeat password: " REPEAT < /dev/tty 2>/dev/null; then
|
|
echo "Could not read the password a second time." >&2
|
|
exit 1
|
|
fi
|
|
echo
|
|
|
|
if [[ -z "$PASSWORD" ]]; then
|
|
echo "the password cannot be empty" >&2
|
|
exit 1
|
|
fi
|
|
if [[ "$PASSWORD" != "$REPEAT" ]]; then
|
|
echo "the passwords do not match" >&2
|
|
exit 1
|
|
fi
|
|
if [[ ${#PASSWORD} -lt 10 ]]; then
|
|
echo "use at least 10 characters" >&2
|
|
exit 1
|
|
fi
|
|
|
|
HASH="$(printf '%s' "$PASSWORD" | docker run --rm -i --entrypoint node "$IMAGE" \
|
|
--input-type=module \
|
|
-e "let s='';process.stdin.setEncoding('utf8');process.stdin.on('data',c=>s+=c);process.stdin.on('end',async()=>{const {pathToFileURL}=await import('node:url');const m=await import(pathToFileURL('/app/src/auth.ts').href);process.stdout.write(await m.hashPassword(s))})")"
|
|
|
|
SECRET="$(docker run --rm --entrypoint node "$IMAGE" --input-type=module \
|
|
-e "import {randomBytes} from 'node:crypto';process.stdout.write(randomBytes(32).toString('base64url'))")"
|
|
|
|
if [[ -z "$HASH" || -z "$SECRET" ]]; then
|
|
echo "the image failed to produce the hash or the secret" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# The file is written by a container running as the app's own uid, not by the
|
|
# SSH user: the data root belongs to the app user (0750), so the SSH user
|
|
# usually cannot write into it, and a file they did write would be unreadable by
|
|
# the app. This way the ownership is right the first time and there is no
|
|
# "re-run the playbook afterwards" step.
|
|
if [[ ! -d "$DATA_ROOT" ]]; then
|
|
echo "$DATA_ROOT does not exist; run the menu-admin playbook first" >&2
|
|
exit 1
|
|
fi
|
|
|
|
APP_UID="${APP_UID:-1000}"
|
|
APP_GID="${APP_GID:-1000}"
|
|
|
|
if ! printf 'ADMIN_PASSWORD_HASH=%s\nSESSION_SECRET=%s\n' "$HASH" "$SECRET" | docker run --rm -i \
|
|
--user "$APP_UID:$APP_GID" -v "$DATA_ROOT:/data" --entrypoint sh "$IMAGE" \
|
|
-c 'umask 077 && cat > /data/site.env'; then
|
|
echo "could not write $ENV_FILE as $APP_UID:$APP_GID" >&2
|
|
exit 1
|
|
fi
|
|
|
|
unset PASSWORD REPEAT HASH SECRET
|
|
|
|
docker run --rm --user "$APP_UID:$APP_GID" -v "$DATA_ROOT:/data" --entrypoint sh "$IMAGE" \
|
|
-c 'ls -l /data/site.env'
|
|
|
|
echo "wrote $ENV_FILE, owned by $APP_UID:$APP_GID"
|
|
echo "restart the app container to pick it up; changing the password later means running this again with --force"
|