diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e65157705a..5e81979cf1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,6 +84,10 @@ jobs: scripts/test-mobile-release-candidate-publisher.sh - name: Mobile worktree identity contract run: scripts/test-mobile-worktree-overrides.sh + - name: Production Compose environment initialization contract + run: | + shellcheck deploy/compose/run.sh deploy/compose/test-init.sh + deploy/compose/test-init.sh - name: File size ratchet unit tests run: node --test scripts/check-file-sizes-core.test.mjs diff --git a/.gitignore b/.gitignore index f26e74136c..49e81794ee 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ __pycache__/ .env .env.local .env.*.local +.env.init.* # Editor / IDE .idea/ diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example index f6ab4fcab9..2ecaef73e2 100644 --- a/deploy/compose/.env.example +++ b/deploy/compose/.env.example @@ -1,6 +1,6 @@ # Buzz production Docker Compose environment. -# Copy to .env and replace every CHANGE_ME value before running. -# The bootstrap script should generate this file for normal users. +# Run `./run.sh init ` to create `.env`, or install +# this file as mode 600 and replace every CHANGE_ME value before running. # Image published by the public image pipeline. Use `:main` for pre-release testing. Pin `:sha-<7>` or a semver release tag for production. BUZZ_IMAGE=ghcr.io/block/buzz:main diff --git a/deploy/compose/README.md b/deploy/compose/README.md index bb0e63fe15..c28487bf0c 100644 --- a/deploy/compose/README.md +++ b/deploy/compose/README.md @@ -7,8 +7,8 @@ the root `docker-compose.yml`, which remains local development infrastructure. ```bash cd deploy/compose -cp .env.example .env -$EDITOR .env # replace every CHANGE_ME value +./run.sh init buzz.example.com <64-character-owner-pubkey-hex> +$EDITOR .env # review the image tag and optional ports ./run.sh start ``` @@ -19,9 +19,17 @@ cd deploy/compose BUZZ_COMPOSE_TLS=true ./run.sh start ``` -The bootstrap script should eventually replace manual `.env` editing for normal -users. It is responsible for generating stable secrets and, optionally, an owner -keypair. +`init` refuses to overwrite an existing `.env`. It uses the public key from an +existing Nostr identity and generates the relay, hook, database, Redis, and +MinIO secrets once. The resulting file is mode 600. Back it up securely before +starting the stack; running `init` again is not a secret-rotation workflow. + +To configure the file manually instead: + +```bash +install -m 600 .env.example .env +$EDITOR .env # replace every CHANGE_ME value +``` ## Production notes @@ -52,8 +60,7 @@ Before sharing an install link publicly, verify a fresh install with: ```bash cd deploy/compose -cp .env.example .env -$EDITOR .env +./run.sh init buzz.example.com <64-character-owner-pubkey-hex> ./run.sh config ./run.sh start curl -fsS "http://127.0.0.1:$(grep -E '^BUZZ_HTTP_PORT=' .env | cut -d= -f2-)/_liveness" diff --git a/deploy/compose/run.sh b/deploy/compose/run.sh index d5465ea1f5..17a4914ad5 100755 --- a/deploy/compose/run.sh +++ b/deploy/compose/run.sh @@ -16,13 +16,114 @@ compose() { docker compose --env-file .env "${COMPOSE_FILES[@]}" "$@" } +valid_domain() { + local domain="$1" + local label + local -a labels + + if ((${#domain} > 253)) || + [[ "$domain" != *.* ]] || + [[ "$domain" == .* ]] || + [[ "$domain" == *. ]] || + [[ "$domain" == *..* ]]; then + return 1 + fi + + IFS=. read -r -a labels <<<"$domain" + for label in "${labels[@]}"; do + if [[ ! "$label" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]]; then + return 1 + fi + done +} + +init_env() ( + if (($# != 2)); then + printf 'Usage: ./run.sh init \n' >&2 + exit 1 + fi + if ! command -v openssl >/dev/null 2>&1; then + printf 'Environment initialization requires openssl\n' >&2 + exit 1 + fi + + local domain="${1,,}" + local owner_pubkey="${2,,}" + if ! valid_domain "$domain"; then + printf 'Domain must be a valid DNS name such as buzz.example.com\n' >&2 + exit 1 + fi + if [[ ! "$owner_pubkey" =~ ^[0-9a-f]{64}$ ]]; then + printf 'Owner public key must be 64 hexadecimal characters\n' >&2 + exit 1 + fi + if [[ -e .env ]] || [[ -L .env ]]; then + printf 'Refusing to overwrite deploy/compose/.env\n' >&2 + exit 1 + fi + + umask 077 + local init_tmp + init_tmp="$(mktemp ./.env.init.XXXXXXXXXX)" + trap 'if [[ -n "${init_tmp:-}" ]] && [[ -f "$init_tmp" ]]; then rm -f -- "$init_tmp"; fi' EXIT + + local relay_private_key + local hook_secret + local postgres_password + local redis_password + local s3_access_key + local s3_secret_key + relay_private_key="$(openssl rand -hex 32)" + hook_secret="$(openssl rand -hex 32)" + postgres_password="$(openssl rand -hex 32)" + redis_password="$(openssl rand -hex 32)" + s3_access_key="buzz$(openssl rand -hex 12)" + s3_secret_key="$(openssl rand -hex 32)" + + while IFS= read -r line || [[ -n "$line" ]]; do + case "$line" in + BUZZ_DOMAIN=*) printf 'BUZZ_DOMAIN=%s\n' "$domain" ;; + RELAY_URL=*) printf 'RELAY_URL=wss://%s\n' "$domain" ;; + BUZZ_MEDIA_BASE_URL=*) printf 'BUZZ_MEDIA_BASE_URL=https://%s/media\n' "$domain" ;; + BUZZ_MEDIA_SERVER_DOMAIN=*) printf 'BUZZ_MEDIA_SERVER_DOMAIN=%s\n' "$domain" ;; + BUZZ_CORS_ORIGINS=*) printf 'BUZZ_CORS_ORIGINS=https://%s\n' "$domain" ;; + RELAY_OWNER_PUBKEY=*) printf 'RELAY_OWNER_PUBKEY=%s\n' "$owner_pubkey" ;; + BUZZ_RELAY_PRIVATE_KEY=*) printf 'BUZZ_RELAY_PRIVATE_KEY=%s\n' "$relay_private_key" ;; + BUZZ_GIT_HOOK_HMAC_SECRET=*) printf 'BUZZ_GIT_HOOK_HMAC_SECRET=%s\n' "$hook_secret" ;; + POSTGRES_PASSWORD=*) printf 'POSTGRES_PASSWORD=%s\n' "$postgres_password" ;; + REDIS_PASSWORD=*) printf 'REDIS_PASSWORD=%s\n' "$redis_password" ;; + BUZZ_S3_ACCESS_KEY=*) printf 'BUZZ_S3_ACCESS_KEY=%s\n' "$s3_access_key" ;; + BUZZ_S3_SECRET_KEY=*) printf 'BUZZ_S3_SECRET_KEY=%s\n' "$s3_secret_key" ;; + *) printf '%s\n' "$line" ;; + esac + done <.env.example >"$init_tmp" + chmod 0600 "$init_tmp" + + if ! ln -- "$init_tmp" .env; then + printf 'Refusing to overwrite deploy/compose/.env\n' >&2 + exit 1 + fi + rm -f -- "$init_tmp" + init_tmp= + unset \ + relay_private_key \ + hook_secret \ + postgres_password \ + redis_password \ + s3_access_key \ + s3_secret_key + + printf 'Created deploy/compose/.env with mode 600; review and back it up before starting Buzz\n' +) + require_env() { if [[ ! -f .env ]]; then cat >&2 <<'MSG' Missing deploy/compose/.env. -Copy .env.example to .env and replace every CHANGE_ME value, or run the bootstrap -script once it lands. Do not start production with generated secrets missing. +Run ./run.sh init , or install .env.example as a +mode-600 .env and replace every CHANGE_ME value manually. Do not start +production with generated secrets missing. MSG exit 1 fi @@ -51,6 +152,10 @@ MSG } case "${1:-help}" in + init) + shift + init_env "$@" + ;; start|up) require_env compose up -d --wait @@ -100,6 +205,8 @@ case "${1:-help}" in Usage: ./run.sh Commands: + init + Create a new mode-600 .env with stable generated secrets start Start Buzz with docker compose up -d --wait stop Stop containers without deleting volumes restart Recreate the relay after env/image changes diff --git a/deploy/compose/test-init.sh b/deploy/compose/test-init.sh new file mode 100755 index 0000000000..3426999359 --- /dev/null +++ b/deploy/compose/test-init.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WORK_DIR="$(mktemp -d /tmp/buzz-compose-init-test.XXXXXXXXXX)" + +cleanup() { + case "${WORK_DIR}" in + /tmp/buzz-compose-init-test.*) rm -rf -- "${WORK_DIR}" ;; + esac +} +trap cleanup EXIT + +make_case() { + local name="$1" + local case_dir="${WORK_DIR}/${name}" + install -d "$case_dir" + cp "${SCRIPT_DIR}/run.sh" "${SCRIPT_DIR}/.env.example" "$case_dir/" + printf '%s\n' "$case_dir" +} + +owner=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + +success_dir="$(make_case success)" +( + cd "$success_dir" + ./run.sh init Buzz.Example.Com "$owner" >stdout 2>stderr + [[ ! -s stderr ]] + grep -qx 'Created deploy/compose/.env with mode 600; review and back it up before starting Buzz' stdout + [[ "$(stat -c '%a' .env)" == 600 ]] + grep -qx 'BUZZ_DOMAIN=buzz.example.com' .env + grep -qx 'RELAY_URL=wss://buzz.example.com' .env + grep -qx 'BUZZ_MEDIA_BASE_URL=https://buzz.example.com/media' .env + grep -qx "RELAY_OWNER_PUBKEY=$owner" .env + if grep -Eq '^[A-Za-z_][A-Za-z0-9_]*=.*CHANGE_ME' .env; then + printf 'init left a CHANGE_ME assignment in .env\n' >&2 + exit 1 + fi + grep -Eq '^BUZZ_RELAY_PRIVATE_KEY=[0-9a-f]{64}$' .env + grep -Eq '^BUZZ_GIT_HOOK_HMAC_SECRET=[0-9a-f]{64}$' .env + grep -Eq '^POSTGRES_PASSWORD=[0-9a-f]{64}$' .env + grep -Eq '^REDIS_PASSWORD=[0-9a-f]{64}$' .env + grep -Eq '^BUZZ_S3_ACCESS_KEY=buzz[0-9a-f]{24}$' .env + grep -Eq '^BUZZ_S3_SECRET_KEY=[0-9a-f]{64}$' .env + + before="$(sha256sum .env)" + if ./run.sh init buzz.example.com "$owner" >/dev/null 2>overwrite.stderr; then + printf 'init overwrote an existing .env\n' >&2 + exit 1 + fi + grep -qx 'Refusing to overwrite deploy/compose/.env' overwrite.stderr + [[ "$(sha256sum .env)" == "$before" ]] +) + +invalid_domain_dir="$(make_case invalid-domain)" +( + cd "$invalid_domain_dir" + if ./run.sh init 'https://buzz.example.com' "$owner" >/dev/null 2>stderr; then + printf 'init accepted an invalid domain\n' >&2 + exit 1 + fi + grep -qx 'Domain must be a valid DNS name such as buzz.example.com' stderr + [[ ! -e .env ]] +) + +invalid_owner_dir="$(make_case invalid-owner)" +( + cd "$invalid_owner_dir" + if ./run.sh init buzz.example.com not-a-pubkey >/dev/null 2>stderr; then + printf 'init accepted an invalid owner public key\n' >&2 + exit 1 + fi + grep -qx 'Owner public key must be 64 hexadecimal characters' stderr + [[ ! -e .env ]] +) + +symlink_dir="$(make_case symlink)" +( + cd "$symlink_dir" + touch target + ln -s target .env + if ./run.sh init buzz.example.com "$owner" >/dev/null 2>stderr; then + printf 'init replaced an .env symlink\n' >&2 + exit 1 + fi + grep -qx 'Refusing to overwrite deploy/compose/.env' stderr + [[ -L .env ]] + [[ ! -s target ]] +) + +missing_env_dir="$(make_case missing-env)" +( + cd "$missing_env_dir" + if ./run.sh config >/dev/null 2>stderr; then + printf 'config accepted a missing .env\n' >&2 + exit 1 + fi + grep -Fqx 'Run ./run.sh init , or install .env.example as a' stderr + grep -Fqx 'mode-600 .env and replace every CHANGE_ME value manually. Do not start' stderr +) + +printf 'Production Compose environment initialization checks passed\n'