#!/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, as the user that owns # the rootless Podman units — tanshu: # # scripts/provision-site-env.sh ~/podman-data/menu-hopsngrains # # 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 [image] [--force] directory the app mounts as MENU_ROOT, e.g. ~/podman-data/menu-hopsngrains [image] app image to borrow Node from (default: the running app's) --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 podman >/dev/null 2>&1; then echo "podman is required" >&2 exit 1 fi # The image is only needed to borrow node for hashing, so take the one an app # container is already running rather than guessing a commit tag. The playbook # names every Site's container and unit menu-. All Sites share the image, # so which one answers does not matter. if [[ -z "$IMAGE" ]]; then IMAGE="$(podman ps --filter 'name=^menu-' --format '{{.Image}}' | head -n 1 || true)" 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:" >&2 exit 1 fi if ! podman 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:" >&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 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 HASH="$(printf '%s' "$PASSWORD" | podman 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="$(podman 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, so its ownership is right the first time and there is no "re-run the # playbook afterwards" step. # # Rootless Podman maps container uid 1000 onto a subuid by default, which would # produce a file the host user cannot read and a tar backup would drop. keep-id # maps it onto this script's user instead — the same mapping the app's Quadlet # unit uses (UserNS=keep-id in roles/menu-app), which is what keeps both sides # agreeing on who owns site.env. 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}" USERNS="keep-id:uid=$APP_UID,gid=$APP_GID" if ! printf 'ADMIN_PASSWORD_HASH=%s\nSESSION_SECRET=%s\n' "$HASH" "$SECRET" | podman run --rm -i \ --userns="$USERNS" -v "$DATA_ROOT:/data" --entrypoint sh "$IMAGE" \ -c 'umask 077 && cat > /data/site.env'; then echo "could not write $ENV_FILE as the app's uid" >&2 exit 1 fi unset PASSWORD REPEAT HASH SECRET podman run --rm --userns="$USERNS" -v "$DATA_ROOT:/data" --entrypoint sh "$IMAGE" \ -c 'ls -l /data/site.env' echo "wrote $ENV_FILE, owned by $(id -un)" echo "restart the app to pick it up: systemctl --user restart menu-" echo "changing the password later means running this again with --force"