From 14987bb21bdcacd7ad599e02c8046ccc08b1c8a4 Mon Sep 17 00:00:00 2001 From: SimpleTest Date: Mon, 20 Jul 2026 21:41:33 +0300 Subject: [PATCH] feat: prepare first production deployment --- .env.example | 4 + README.md | 28 +++ compose.production.yaml | 6 + compose.yaml | 2 +- docs/operations.md | 47 +++++ .../user_settings_html/edit.html.heex | 11 ++ scripts/init-production-env.sh | 133 +++++++++++++ scripts/quality.sh | 34 ++++ scripts/validate-production-env.sh | 175 ++++++++++++++++++ .../user_settings_controller_test.exs | 2 + 10 files changed, 441 insertions(+), 1 deletion(-) create mode 100644 compose.production.yaml create mode 100755 scripts/init-production-env.sh create mode 100755 scripts/validate-production-env.sh diff --git a/.env.example b/.env.example index a2ea219..8e2df3b 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,10 @@ # Copy this file to .env. Compose intentionally refuses to start without the # required values. Replace every credential before any public deployment. HTTP_PORT=4010 +# Bind the public Compose proxy to loopback when a reverse proxy runs on the +# same host. The current VPN staging path needs an address reachable by its +# verified tunnel topology, so choose this per deployment. +HTTP_BIND_ADDRESS=0.0.0.0 MAILPIT_PORT=8027 MAILPIT_BIND_ADDRESS=127.0.0.1 DOCKER_SOCKET_GID=REPLACE_WITH_DOCKER_SOCKET_NUMERIC_GID diff --git a/README.md b/README.md index 9a78a3a..d52e52c 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,34 @@ set `app.host`, `app.scheme`, and `app.urlPort` to the public URL used in email links, and must set `app.mapTileUrl` to a tile service whose policy and capacity fit the deployment. +For a first Compose deployment, generate an ignored environment on the target +Docker host. The command derives that host's Docker socket group, generates +independent database, Phoenix, handover, cluster, and metrics secrets without +printing them, writes mode `0600`, and refuses to replace an existing file: + +```bash +./scripts/init-production-env.sh whoneedhelp.com .env.production +``` + +Configure the verified reverse-proxy source IP/CIDR and transactional SMTP +provider in that file, then validate its structure and the production Compose +render: + +```bash +./scripts/validate-production-env.sh .env.production whoneedhelp.com +docker compose \ + --env-file .env.production \ + -f compose.yaml \ + -f compose.production.yaml \ + up -d --wait --build +``` + +`compose.production.yaml` leaves local Mailpit stopped. Validation deliberately +fails while the relay still points to Mailpit or a template marker remains. +It does not claim to test DNS, certificates, actual mail delivery, the +deployment's observed proxy source address, or capacity; verify those on the +target host before opening registration. + Rotate all local application secrets and the existing local PostgreSQL role without printing the generated values: diff --git a/compose.production.yaml b/compose.production.yaml new file mode 100644 index 0000000..6d3dce4 --- /dev/null +++ b/compose.production.yaml @@ -0,0 +1,6 @@ +services: + # Public deployments use the configured transactional SMTP relay. Mailpit is + # retained in the base file for local development but is not started by this + # production override unless the operator explicitly enables local-mail. + mailpit: + profiles: [local-mail] diff --git a/compose.yaml b/compose.yaml index 97854bf..794b962 100644 --- a/compose.yaml +++ b/compose.yaml @@ -87,7 +87,7 @@ services: - --entrypoints.websecure.address=:443 - --entrypoints.web.forwardedheaders.trustedips=${TRAEFIK_TRUSTED_IPS:-127.0.0.1/32} ports: - - "${HTTP_PORT:-4010}:80" + - "${HTTP_BIND_ADDRESS:-0.0.0.0}:${HTTP_PORT:-4010}:80" depends_on: - docker-api-proxy networks: [docker-api, edge, ingress] diff --git a/docs/operations.md b/docs/operations.md index eaa71fd..b39c3f9 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -6,6 +6,53 @@ time objective, retention period, storage capacity, or high-availability model; those values require product policy and measurements from the eventual production environment. +## First production Compose environment + +Run the initializer on the target Docker host after its final public hostname +is known: + +```bash +./scripts/init-production-env.sh whoneedhelp.com .env.production +``` + +The initializer reads `.env.example`, derives the numeric group of that host's +Docker socket, generates independent random values for PostgreSQL, +`SECRET_KEY_BASE`, handover codes, the BEAM release cookie, and metrics access, +and writes an ignored mode-`0600` file. It does not print those values and +refuses to overwrite an existing destination. + +By default, the generated public proxy port binds to `127.0.0.1`, which is +appropriate only when the verified reverse proxy reaches the application on +the same host. Set `PRODUCTION_HTTP_BIND_ADDRESS` when generating the file, or +edit `HTTP_BIND_ADDRESS` afterward, to match the observed target topology. +Replace `TRAEFIK_TRUSTED_IPS` with the exact source IP/CIDR observed at Traefik; +do not copy the temporary VPN value into an unrelated server. + +Configure the transactional SMTP relay and sender accepted by that provider. +Provider-specific auth, TLS, ports, and credentials can be supplied to the +initializer through the documented `PRODUCTION_SMTP_*` environment values or +edited in the resulting ignored file. Then run: + +```bash +./scripts/validate-production-env.sh .env.production whoneedhelp.com +docker compose \ + --env-file .env.production \ + -f compose.yaml \ + -f compose.production.yaml \ + up -d --wait --build +``` + +The production override keeps Mailpit behind its inactive `local-mail` profile, +so public registration cannot appear to succeed while mail is only retained +locally. The validator checks file ownership/mode, origin consistency, +template markers, independent generated secrets, supported SMTP values, the +generated database URL, and the final Compose render without printing secrets. +It does not contact DNS, TLS, SMTP, the reverse proxy, or the application. +After deployment, verify `/healthz/ready`, inspect all replica health and logs, +register a unique address through the public browser, receive its message at +the real mailbox, follow the HTTPS confirmation link, and remove only that +run-scoped account. + ## Compose database backup Create a PostgreSQL 18 custom-format archive, validate its table of contents, diff --git a/lib/who_need_help_web/controllers/user_settings_html/edit.html.heex b/lib/who_need_help_web/controllers/user_settings_html/edit.html.heex index 7193e9f..d2efc09 100644 --- a/lib/who_need_help_web/controllers/user_settings_html/edit.html.heex +++ b/lib/who_need_help_web/controllers/user_settings_html/edit.html.heex @@ -27,6 +27,17 @@ <.form :let={f} for={@password_changeset} action={~p"/users/settings"} id="update_password"> + <.input field={f[:password]} diff --git a/scripts/init-production-env.sh b/scripts/init-production-env.sh new file mode 100755 index 0000000..917b054 --- /dev/null +++ b/scripts/init-production-env.sh @@ -0,0 +1,133 @@ +#!/bin/sh +set -eu +umask 077 + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +domain=${1:-} +target=${2:-"$ROOT/.env.production"} + +usage() { + echo "Usage: $0 DOMAIN [OUTPUT_FILE]" >&2 +} + +if [ -z "$domain" ]; then + usage + exit 1 +fi + +if ! printf '%s\n' "$domain" | + grep -Eq '^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$'; then + echo "DOMAIN must be a lowercase ASCII DNS hostname without a scheme, port, or path." >&2 + exit 1 +fi + +for command in awk docker grep mktemp openssl stat; do + if ! command -v "$command" >/dev/null 2>&1; then + echo "Required command is unavailable: $command" >&2 + exit 1 + fi +done + +if [ ! -S /var/run/docker.sock ]; then + echo "/var/run/docker.sock is unavailable; run this on the target Docker host." >&2 + exit 1 +fi + +if [ -e "$target" ]; then + echo "Refusing to overwrite existing deployment environment: $target" >&2 + exit 1 +fi + +target_dir=$(dirname -- "$target") +if [ ! -d "$target_dir" ]; then + echo "Output directory does not exist: $target_dir" >&2 + exit 1 +fi + +postgres_password=$(openssl rand -hex 32) +secret_key_base=$(openssl rand -hex 64) +handover_secret=$(openssl rand -hex 64) +release_cookie=$(openssl rand -hex 64) +metrics_token=$(openssl rand -hex 32) +docker_socket_gid=$(stat -c '%g' /var/run/docker.sock) + +http_bind_address=${PRODUCTION_HTTP_BIND_ADDRESS:-127.0.0.1} +trusted_proxy_ips=${PRODUCTION_TRAEFIK_TRUSTED_IPS:-REPLACE_WITH_VERIFIED_PROXY_IP_OR_CIDR} +smtp_relay=${PRODUCTION_SMTP_RELAY:-REPLACE_WITH_TRANSACTIONAL_SMTP_RELAY} +smtp_port=${PRODUCTION_SMTP_PORT:-587} +smtp_username=${PRODUCTION_SMTP_USERNAME:-} +smtp_password=${PRODUCTION_SMTP_PASSWORD:-} +smtp_auth=${PRODUCTION_SMTP_AUTH:-always} +smtp_tls=${PRODUCTION_SMTP_TLS:-always} +smtp_ssl=${PRODUCTION_SMTP_SSL:-false} +email_from_address=${PRODUCTION_EMAIL_FROM_ADDRESS:-"contact@$domain"} + +tmp=$(mktemp "$target_dir/.production-env.XXXXXX") +trap 'rm -f "$tmp"' EXIT HUP INT TERM +chmod 600 "$tmp" + +DOMAIN=$domain \ +HTTP_BIND_ADDRESS_VALUE=$http_bind_address \ +DOCKER_SOCKET_GID_VALUE=$docker_socket_gid \ +TRUSTED_PROXY_IPS_VALUE=$trusted_proxy_ips \ +POSTGRES_PASSWORD_VALUE=$postgres_password \ +DATABASE_URL_VALUE="ecto://postgres:$postgres_password@db/who_need_help" \ +SECRET_KEY_BASE_VALUE=$secret_key_base \ +HANDOVER_SECRET_VALUE=$handover_secret \ +RELEASE_COOKIE_VALUE=$release_cookie \ +METRICS_TOKEN_VALUE=$metrics_token \ +SMTP_RELAY_VALUE=$smtp_relay \ +SMTP_PORT_VALUE=$smtp_port \ +SMTP_USERNAME_VALUE=$smtp_username \ +SMTP_PASSWORD_VALUE=$smtp_password \ +SMTP_AUTH_VALUE=$smtp_auth \ +SMTP_TLS_VALUE=$smtp_tls \ +SMTP_SSL_VALUE=$smtp_ssl \ +EMAIL_FROM_ADDRESS_VALUE=$email_from_address \ + awk ' + BEGIN { + replacement["HTTP_BIND_ADDRESS"] = ENVIRON["HTTP_BIND_ADDRESS_VALUE"] + replacement["DOCKER_SOCKET_GID"] = ENVIRON["DOCKER_SOCKET_GID_VALUE"] + replacement["TRAEFIK_TRUSTED_IPS"] = ENVIRON["TRUSTED_PROXY_IPS_VALUE"] + replacement["PHX_HOST"] = ENVIRON["DOMAIN"] + replacement["PHX_SCHEME"] = "https" + replacement["PHX_URL_PORT"] = "443" + replacement["WNH_DEBUG_BASE_URL"] = "https://" ENVIRON["DOMAIN"] + replacement["WNH_BASE_URL"] = "https://" ENVIRON["DOMAIN"] + replacement["POSTGRES_PASSWORD"] = ENVIRON["POSTGRES_PASSWORD_VALUE"] + replacement["DATABASE_URL"] = ENVIRON["DATABASE_URL_VALUE"] + replacement["SECRET_KEY_BASE"] = ENVIRON["SECRET_KEY_BASE_VALUE"] + replacement["HANDOVER_SECRET"] = ENVIRON["HANDOVER_SECRET_VALUE"] + replacement["RELEASE_COOKIE"] = ENVIRON["RELEASE_COOKIE_VALUE"] + replacement["METRICS_TOKEN"] = ENVIRON["METRICS_TOKEN_VALUE"] + replacement["SMTP_RELAY"] = ENVIRON["SMTP_RELAY_VALUE"] + replacement["SMTP_PORT"] = ENVIRON["SMTP_PORT_VALUE"] + replacement["SMTP_USERNAME"] = ENVIRON["SMTP_USERNAME_VALUE"] + replacement["SMTP_PASSWORD"] = ENVIRON["SMTP_PASSWORD_VALUE"] + replacement["SMTP_AUTH"] = ENVIRON["SMTP_AUTH_VALUE"] + replacement["SMTP_TLS"] = ENVIRON["SMTP_TLS_VALUE"] + replacement["SMTP_SSL"] = ENVIRON["SMTP_SSL_VALUE"] + replacement["EMAIL_FROM_ADDRESS"] = ENVIRON["EMAIL_FROM_ADDRESS_VALUE"] + replacement["CODEX_SESSION_ID"] = "not-configured" + } + { + separator = index($0, "=") + key = separator > 1 ? substr($0, 1, separator - 1) : "" + + if (key in replacement) { + print key "=" replacement[key] + } else { + print + } + } + ' "$ROOT/.env.example" >"$tmp" + +mv "$tmp" "$target" +chmod 600 "$target" +trap - EXIT HUP INT TERM + +unset postgres_password secret_key_base handover_secret release_cookie metrics_token + +echo "Generated independent deployment secrets without printing them." +echo "Created mode-0600 environment: $target" +echo "Run scripts/validate-production-env.sh '$target' '$domain' after configuring the verified proxy and SMTP values." diff --git a/scripts/quality.sh b/scripts/quality.sh index efa6c86..3bb33eb 100755 --- a/scripts/quality.sh +++ b/scripts/quality.sh @@ -78,8 +78,36 @@ echo "Checking crash dumps are excluded from the Docker build context" grep -Fx 'core' .dockerignore >/dev/null grep -Fx 'core.*' .dockerignore >/dev/null +echo "Checking production environment initialization and validation" +production_env="$scan_dir/.env.production" +PRODUCTION_TRAEFIK_TRUSTED_IPS=172.20.0.1/32 \ +PRODUCTION_SMTP_RELAY=smtp.help.test \ +PRODUCTION_SMTP_PORT=587 \ +PRODUCTION_SMTP_USERNAME=quality-user \ +PRODUCTION_SMTP_PASSWORD=quality-password \ +PRODUCTION_SMTP_AUTH=always \ +PRODUCTION_SMTP_TLS=always \ +PRODUCTION_SMTP_SSL=false \ +PRODUCTION_EMAIL_FROM_ADDRESS=contact@help.test \ + ./scripts/init-production-env.sh help.test "$production_env" >/dev/null +test "$(stat -c '%a' "$production_env")" = 600 +./scripts/validate-production-env.sh "$production_env" help.test >/dev/null +if ./scripts/init-production-env.sh help.test "$production_env" >/dev/null 2>&1; then + echo "Production environment initializer overwrote an existing file." >&2 + exit 1 +fi +incomplete_production_env="$scan_dir/.env.production.incomplete" +./scripts/init-production-env.sh help.test "$incomplete_production_env" >/dev/null +if ./scripts/validate-production-env.sh \ + "$incomplete_production_env" help.test >/dev/null 2>&1; then + echo "Production environment validator accepted unresolved deployment inputs." >&2 + exit 1 +fi + echo "Rendering every Docker Compose profile" docker compose --env-file .env.example -f compose.yaml config --quiet +docker compose --env-file "$production_env" \ + -f compose.yaml -f compose.production.yaml config --quiet docker compose --env-file .env.example -f compose.yaml config --format json | jq --exit-status ' . as $root @@ -115,8 +143,14 @@ docker compose --env-file .env.example -f compose.yaml config --format json | and $root.networks.internal.internal == true and ($root.networks.egress.internal // false) == false and $root.services.db.security_opt == ["no-new-privileges:true"] + and $root.services.proxy.ports[0].host_ip == "0.0.0.0" and $root.services.mailpit.ports[0].host_ip == "127.0.0.1" ' >/dev/null +HTTP_BIND_ADDRESS=127.0.0.1 \ + docker compose --env-file .env.example -f compose.yaml config --format json | + jq --exit-status ' + .services.proxy.ports[0].host_ip == "127.0.0.1" + ' >/dev/null WEB_REPLICAS=1 WORKER_REPLICAS=1 \ docker compose --env-file .env.example -f compose.yaml config --format json | jq --exit-status ' diff --git a/scripts/validate-production-env.sh b/scripts/validate-production-env.sh new file mode 100755 index 0000000..11eaec8 --- /dev/null +++ b/scripts/validate-production-env.sh @@ -0,0 +1,175 @@ +#!/bin/bash +set -euo pipefail + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +env_file=${1:-} +expected_domain=${2:-} + +usage() { + echo "Usage: $0 ENV_FILE EXPECTED_DOMAIN" >&2 +} + +if [[ -z "$env_file" || -z "$expected_domain" ]]; then + usage + exit 1 +fi + +if [[ ! -f "$env_file" ]]; then + echo "Deployment environment does not exist: $env_file" >&2 + exit 1 +fi + +if [[ "$(stat -c '%a' "$env_file")" != 600 ]]; then + echo "Deployment environment must have mode 0600: $env_file" >&2 + exit 1 +fi + +if [[ "$(stat -c '%u' "$env_file")" != "$(id -u)" ]]; then + echo "Deployment environment must be owned by the current operator." >&2 + exit 1 +fi + +read_value() { + local key=$1 + + awk -v key="$key" ' + index($0, key "=") == 1 { + print substr($0, length(key) + 2) + found = 1 + } + END { if (!found) exit 1 } + ' "$env_file" +} + +require_value() { + local key=$1 + local value + + if ! value=$(read_value "$key") || [[ -z "$value" ]]; then + echo "$key is missing or empty in $env_file." >&2 + exit 1 + fi + + printf '%s' "$value" +} + +reject_marker() { + local key=$1 + local value=$2 + + case "$value" in + *REPLACE* | *GENERATE* | *example.com*) + echo "$key still contains a template value." >&2 + exit 1 + ;; + esac +} + +phx_host=$(require_value PHX_HOST) +phx_scheme=$(require_value PHX_SCHEME) +phx_url_port=$(require_value PHX_URL_PORT) +base_url=$(require_value WNH_BASE_URL) +debug_base_url=$(require_value WNH_DEBUG_BASE_URL) +http_bind_address=$(require_value HTTP_BIND_ADDRESS) +trusted_proxy_ips=$(require_value TRAEFIK_TRUSTED_IPS) +postgres_password=$(require_value POSTGRES_PASSWORD) +database_url=$(require_value DATABASE_URL) +secret_key_base=$(require_value SECRET_KEY_BASE) +handover_secret=$(require_value HANDOVER_SECRET) +release_cookie=$(require_value RELEASE_COOKIE) +metrics_token=$(require_value METRICS_TOKEN) +smtp_relay=$(require_value SMTP_RELAY) +smtp_port=$(require_value SMTP_PORT) +smtp_auth=$(require_value SMTP_AUTH) +smtp_tls=$(require_value SMTP_TLS) +smtp_ssl=$(require_value SMTP_SSL) +email_from_address=$(require_value EMAIL_FROM_ADDRESS) + +[[ "$phx_host" == "$expected_domain" ]] || { + echo "PHX_HOST does not match EXPECTED_DOMAIN." >&2 + exit 1 +} +[[ "$phx_scheme" == https && "$phx_url_port" == 443 ]] || { + echo "Production PHX_SCHEME/PHX_URL_PORT must describe the public HTTPS origin." >&2 + exit 1 +} +[[ "$base_url" == "https://$expected_domain" ]] || { + echo "WNH_BASE_URL does not match the public HTTPS origin." >&2 + exit 1 +} +[[ "$debug_base_url" == "$base_url" ]] || { + echo "WNH_DEBUG_BASE_URL and WNH_BASE_URL must use the same deployment origin." >&2 + exit 1 +} + +for pair in \ + "HTTP_BIND_ADDRESS:$http_bind_address" \ + "TRAEFIK_TRUSTED_IPS:$trusted_proxy_ips" \ + "POSTGRES_PASSWORD:$postgres_password" \ + "DATABASE_URL:$database_url" \ + "SECRET_KEY_BASE:$secret_key_base" \ + "HANDOVER_SECRET:$handover_secret" \ + "RELEASE_COOKIE:$release_cookie" \ + "METRICS_TOKEN:$metrics_token" \ + "SMTP_RELAY:$smtp_relay" \ + "EMAIL_FROM_ADDRESS:$email_from_address" +do + reject_marker "${pair%%:*}" "${pair#*:}" +done + +[[ "$smtp_relay" != mailpit ]] || { + echo "SMTP_RELAY still targets local Mailpit; public registration needs a transactional relay." >&2 + exit 1 +} +[[ "$smtp_port" =~ ^[0-9]+$ ]] || { + echo "SMTP_PORT must be numeric." >&2 + exit 1 +} +[[ "$smtp_auth" =~ ^(always|never|if_available)$ ]] || { + echo "SMTP_AUTH has an unsupported value." >&2 + exit 1 +} +[[ "$smtp_tls" =~ ^(always|never|if_available)$ ]] || { + echo "SMTP_TLS has an unsupported value." >&2 + exit 1 +} +[[ "$smtp_ssl" =~ ^(true|false|0|1)$ ]] || { + echo "SMTP_SSL has an unsupported value." >&2 + exit 1 +} +[[ "$email_from_address" == *@* ]] || { + echo "EMAIL_FROM_ADDRESS is not an email address." >&2 + exit 1 +} + +secrets=( + "$postgres_password" + "$secret_key_base" + "$handover_secret" + "$release_cookie" + "$metrics_token" +) + +for ((left = 0; left < ${#secrets[@]}; left++)); do + for ((right = left + 1; right < ${#secrets[@]}; right++)); do + if [[ "${secrets[$left]}" == "${secrets[$right]}" ]]; then + echo "Deployment secrets must be independent." >&2 + exit 1 + fi + done +done + +expected_database_url="ecto://postgres:$postgres_password@db/who_need_help" +[[ "$database_url" == "$expected_database_url" ]] || { + echo "DATABASE_URL does not match the generated PostgreSQL role/password/database." >&2 + exit 1 +} + +docker compose \ + --env-file "$env_file" \ + --file "$ROOT/compose.yaml" \ + --file "$ROOT/compose.production.yaml" \ + config --quiet + +echo "Production environment structure passed validation without printing secrets." +echo "This does not test DNS, TLS, SMTP reachability/delivery, proxy source IPs, or server capacity." diff --git a/test/who_need_help_web/controllers/user_settings_controller_test.exs b/test/who_need_help_web/controllers/user_settings_controller_test.exs index 1a380b7..4a9959b 100644 --- a/test/who_need_help_web/controllers/user_settings_controller_test.exs +++ b/test/who_need_help_web/controllers/user_settings_controller_test.exs @@ -12,6 +12,8 @@ defmodule WhoNeedHelpWeb.UserSettingsControllerTest do conn = get(conn, ~p"/users/settings") response = html_response(conn, 200) assert response =~ "Settings" + assert response =~ ~s(id="update_password_username") + assert response =~ ~s(autocomplete="username") end test "redirects if user is not logged in" do