diff --git a/.dockerignore b/.dockerignore index fb5f7d1..977ea5a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -81,3 +81,4 @@ core.* /.env /.env.* !/.env.example +!/.env.edge.example diff --git a/.env.edge.example b/.env.edge.example new file mode 100644 index 0000000..794e1e6 --- /dev/null +++ b/.env.edge.example @@ -0,0 +1,10 @@ +# Copy to an ignored mode-0600 .env.edge on the public server. +COMPOSE_PROJECT_NAME=who_need_help_edge +PUBLIC_EDGE_NETWORK=who_need_help_public_edge +EDGE_BIND_ADDRESS=0.0.0.0 +EDGE_HTTP_PORT=80 +EDGE_HTTPS_PORT=443 +PRIMARY_DOMAIN=whoneedhelp.com +PRIMARY_UPSTREAM=who-need-help-production:4000 +STAGING_DOMAIN=staging.whoneedhelp.com +STAGING_UPSTREAM=who-need-help-staging:4000 diff --git a/.env.example b/.env.example index c3ff53e..70ba7f8 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,11 @@ HTTP_PORT=4010 # 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 +# Attach the selected app service to the separately managed public Caddy +# network. Keep disabled for ordinary local development. +PUBLIC_EDGE_ENABLED=false +PUBLIC_EDGE_NETWORK=who_need_help_public_edge +PUBLIC_UPSTREAM_NAME=who-need-help-local MAILPIT_PORT=8027 MAILPIT_BIND_ADDRESS=127.0.0.1 DOCKER_SOCKET_GID=REPLACE_WITH_DOCKER_SOCKET_NUMERIC_GID diff --git a/.gitignore b/.gitignore index cedad00..654bc4c 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ __pycache__/ /.env /.env.* !/.env.example +!/.env.edge.example !/.env.load.example !/.env.e2e.example diff --git a/Dockerfile.caddy b/Dockerfile.caddy new file mode 100644 index 0000000..6f1b656 --- /dev/null +++ b/Dockerfile.caddy @@ -0,0 +1,34 @@ +# syntax=docker/dockerfile:1.20.0 + +FROM golang:1.26.5-alpine3.23@sha256:622e56dbc11a8cfe87cafa2331e9a201877271cbff918af53d3be315f3da88cc AS builder + +ENV CGO_ENABLED=0 +ENV GOTOOLCHAIN=local + +RUN GOBIN=/out go install \ + -trimpath \ + -ldflags="-s -w -X github.com/caddyserver/caddy/v2.CustomVersion=v2.11.4" \ + github.com/caddyserver/caddy/v2/cmd/caddy@v2.11.4 + +RUN mkdir -p /rootfs/data/caddy /rootfs/config/caddy /rootfs/tmp \ + && chown -R 1000:1000 /rootfs + +FROM scratch + +ENV XDG_CONFIG_HOME=/config +ENV XDG_DATA_HOME=/data +ENV HOME=/tmp + +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +COPY --from=builder /out/caddy /usr/bin/caddy +COPY --from=builder --chown=1000:1000 /rootfs/data /data +COPY --from=builder --chown=1000:1000 /rootfs/config /config +COPY --from=builder --chown=1000:1000 /rootfs/tmp /tmp + +USER 1000:1000 +WORKDIR /srv + +EXPOSE 80 443 443/udp 2019 + +ENTRYPOINT ["/usr/bin/caddy"] +CMD ["run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"] diff --git a/README.md b/README.md index 9a37a26..578feaf 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,13 @@ policies are deliberately not claimed as complete. ## Fast start with Docker Compose +The public single-server path uses the compact application topology plus a +separately managed Caddy edge. Production and staging can run as isolated +Compose projects with distinct PostGIS volumes and secrets while sharing only a +Docker network used for HTTPS reverse proxying. See the +[operations runbook](docs/operations.md#public-https-edge-and-isolated-staging) +for the verified order of operations. Redis is not a project dependency. + Prerequisite: Docker with the Compose plugin. ```bash diff --git a/compose.edge.yaml b/compose.edge.yaml new file mode 100644 index 0000000..357aa80 --- /dev/null +++ b/compose.edge.yaml @@ -0,0 +1,46 @@ +name: who_need_help_edge + +services: + edge: + image: ${CADDY_IMAGE:-who-need-help:caddy-local} + build: + context: . + dockerfile: Dockerfile.caddy + user: "1000:1000" + environment: + PRIMARY_DOMAIN: ${PRIMARY_DOMAIN:?Set PRIMARY_DOMAIN in the edge environment} + PRIMARY_UPSTREAM: ${PRIMARY_UPSTREAM:?Set PRIMARY_UPSTREAM in the edge environment} + STAGING_DOMAIN: ${STAGING_DOMAIN:?Set STAGING_DOMAIN in the edge environment} + STAGING_UPSTREAM: ${STAGING_UPSTREAM:?Set STAGING_UPSTREAM in the edge environment} + ports: + - "${EDGE_BIND_ADDRESS:-0.0.0.0}:${EDGE_HTTP_PORT:-80}:80" + - "${EDGE_BIND_ADDRESS:-0.0.0.0}:${EDGE_HTTPS_PORT:-443}:443" + - "${EDGE_BIND_ADDRESS:-0.0.0.0}:${EDGE_HTTPS_PORT:-443}:443/udp" + volumes: + - ./deploy/caddy/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + networks: [public_edge] + extra_hosts: + - "host.docker.internal:host-gateway" + read_only: true + tmpfs: + - /tmp + cap_drop: [ALL] + cap_add: [NET_BIND_SERVICE] + security_opt: + - no-new-privileges:true + restart: unless-stopped + healthcheck: + test: ["CMD", "caddy", "validate", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"] + interval: 10s + timeout: 3s + retries: 5 + +networks: + public_edge: + name: ${PUBLIC_EDGE_NETWORK:?Set PUBLIC_EDGE_NETWORK in the edge environment} + +volumes: + caddy_data: + caddy_config: diff --git a/compose.public-app.yaml b/compose.public-app.yaml new file mode 100644 index 0000000..b681b3d --- /dev/null +++ b/compose.public-app.yaml @@ -0,0 +1,17 @@ +services: + app: + networks: + public_edge: + aliases: + - ${PUBLIC_UPSTREAM_NAME:?Set PUBLIC_UPSTREAM_NAME when PUBLIC_EDGE_ENABLED=true} + + web: + networks: + public_edge: + aliases: + - ${PUBLIC_UPSTREAM_NAME:?Set PUBLIC_UPSTREAM_NAME when PUBLIC_EDGE_ENABLED=true} + +networks: + public_edge: + name: ${PUBLIC_EDGE_NETWORK:?Set PUBLIC_EDGE_NETWORK when PUBLIC_EDGE_ENABLED=true} + external: true diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile new file mode 100644 index 0000000..22084e0 --- /dev/null +++ b/deploy/caddy/Caddyfile @@ -0,0 +1,27 @@ +{ + admin off +} + +{$PRIMARY_DOMAIN} { + encode zstd gzip + + header { + -Server + } + + reverse_proxy {$PRIMARY_UPSTREAM} +} + +www.{$PRIMARY_DOMAIN} { + redir https://{$PRIMARY_DOMAIN}{uri} permanent +} + +{$STAGING_DOMAIN} { + encode zstd gzip + + header { + -Server + } + + reverse_proxy {$STAGING_UPSTREAM} +} diff --git a/docs/operations.md b/docs/operations.md index 08329e9..1acf8e0 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -48,6 +48,67 @@ The deployment environment selects topology and database ownership: There is no Redis dependency. Queues, rate-limit counters, Oban leadership, and durable application state use PostgreSQL. +### Public HTTPS edge and isolated staging + +The first single-server deployment uses one separately managed Caddy container +for ports 80/443 and two independent application projects. Caddy 2.11.4 is +built as a static non-root binary with pinned Go 1.26.5 and a scratch runtime. +Its persistent volume holds ACME account and +certificate state; do not remove that volume during an ordinary application +deploy. Production and staging each have their own application secret file, +PostGIS volume, migrations, and Docker DNS alias. They share only the named +`who_need_help_public_edge` network, so load and browser records created in +staging cannot enter the production database. + +Generate and start the edge before either application project: + +```bash +./scripts/init-edge-env.sh \ + whoneedhelp.com staging.whoneedhelp.com .env.edge +./scripts/edge-up.sh .env.edge +``` + +The authoritative A records for `whoneedhelp.com`, `www.whoneedhelp.com`, and +`staging.whoneedhelp.com` must point to the observed server address, and inbound +TCP 80/443 plus UDP 443 must be permitted before public certificate issuance. +Caddy obtains and renews public certificates and redirects HTTP to HTTPS. The +Phoenix release remains plain HTTP on the internal shared Docker network; +Caddy replaces incoming forwarding headers and WebSocket proxying is automatic. +The compact app's optional host-published HTTP port stays bound to loopback for +operator health checks and is not a public entry point. + +Create staging with independent names and secrets: + +```bash +PRODUCTION_COMPOSE_PROJECT_NAME=who_need_help_staging \ +PRODUCTION_PUBLIC_UPSTREAM_NAME=who-need-help-staging \ +PRODUCTION_HTTP_PORT=4011 \ +./scripts/init-production-env.sh \ + staging.whoneedhelp.com .env.staging +``` + +Create production separately: + +```bash +PRODUCTION_COMPOSE_PROJECT_NAME=who_need_help_production \ +PRODUCTION_PUBLIC_UPSTREAM_NAME=who-need-help-production \ +PRODUCTION_HTTP_PORT=4010 \ +./scripts/init-production-env.sh \ + whoneedhelp.com .env.production +``` + +Configure and validate SMTP/OAuth independently in each ignored file. Start +staging first, run database/load/browser/Android verification there, and then +start the clean production project. Stopping staging does not stop Caddy or +production: + +```bash +./scripts/compose.sh .env.staging down +``` + +Do not add `--volumes` unless the exact staging database has been inspected and +its deletion is the intended operation. + Run the initializer on the target Docker host after its final public hostname is known: diff --git a/scripts/compose.sh b/scripts/compose.sh index c219c21..28bb60c 100755 --- a/scripts/compose.sh +++ b/scripts/compose.sh @@ -36,6 +36,7 @@ deployment_target=$(read_env_value DEPLOYMENT_TARGET 2>/dev/null || printf compo deployment_environment=$(read_env_value DEPLOYMENT_ENV 2>/dev/null || printf development) database_mode=$(read_env_value DATABASE_MODE 2>/dev/null || printf container) app_topology=$(read_env_value APP_TOPOLOGY 2>/dev/null || printf split) +public_edge_enabled=$(read_env_value PUBLIC_EDGE_ENABLED 2>/dev/null || printf false) project=$(read_env_value COMPOSE_PROJECT_NAME 2>/dev/null || printf who_need_help) [[ "$deployment_target" == compose ]] || { @@ -70,6 +71,11 @@ case "$app_topology" in *) echo "APP_TOPOLOGY must be compact or split." >&2; exit 1 ;; esac +case "$public_edge_enabled" in + true | false) ;; + *) echo "PUBLIC_EDGE_ENABLED must be true or false." >&2; exit 1 ;; +esac + compose=( docker compose --project-directory "$ROOT" @@ -86,6 +92,10 @@ if [[ "$app_topology" == compact ]]; then compose+=(--file "$ROOT/compose.compact.yaml" --profile compact) fi +if [[ "$public_edge_enabled" == true ]]; then + compose+=(--file "$ROOT/compose.public-app.yaml") +fi + if [[ "$deployment_environment" == production ]]; then compose+=(--file "$ROOT/compose.production.yaml") fi diff --git a/scripts/edge-up.sh b/scripts/edge-up.sh new file mode 100755 index 0000000..0aa9f01 --- /dev/null +++ b/scripts/edge-up.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +env_file=${1:-"$ROOT/.env.edge"} + +if [[ "$env_file" != /* ]]; then + env_file="$ROOT/$env_file" +fi + +if [[ ! -f "$env_file" ]]; then + echo "Edge environment does not exist: $env_file" >&2 + exit 1 +fi + +"$ROOT/scripts/validate-edge-env.sh" "$env_file" + +exec docker compose \ + --project-directory "$ROOT" \ + --env-file "$env_file" \ + --file "$ROOT/compose.edge.yaml" \ + up -d --build --wait --remove-orphans diff --git a/scripts/init-edge-env.sh b/scripts/init-edge-env.sh new file mode 100755 index 0000000..c386e93 --- /dev/null +++ b/scripts/init-edge-env.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +primary_domain=${1:-} +staging_domain=${2:-} +target=${3:-"$ROOT/.env.edge"} + +if [[ -z "$primary_domain" || -z "$staging_domain" ]]; then + echo "Usage: $0 PRIMARY_DOMAIN STAGING_DOMAIN [OUTPUT_FILE]" >&2 + exit 1 +fi + +if [[ "$target" != /* ]]; then + target="$ROOT/$target" +fi + +if [[ -e "$target" ]]; then + echo "Refusing to overwrite existing edge 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 + +tmp=$(mktemp "$target_dir/.edge-env.XXXXXX") +trap 'rm -f "$tmp"' EXIT HUP INT TERM +chmod 600 "$tmp" + +PRIMARY_DOMAIN_VALUE=$primary_domain \ +STAGING_DOMAIN_VALUE=$staging_domain \ + awk ' + BEGIN { + replacement["PRIMARY_DOMAIN"] = ENVIRON["PRIMARY_DOMAIN_VALUE"] + replacement["STAGING_DOMAIN"] = ENVIRON["STAGING_DOMAIN_VALUE"] + } + { + separator = index($0, "=") + key = separator > 1 ? substr($0, 1, separator - 1) : "" + + if (key in replacement) { + print key "=" replacement[key] + } else { + print + } + } + ' "$ROOT/.env.edge.example" >"$tmp" + +mv "$tmp" "$target" +chmod 600 "$target" +trap - EXIT HUP INT TERM + +"$ROOT/scripts/validate-edge-env.sh" "$target" +echo "Created mode-0600 edge environment: $target" diff --git a/scripts/init-production-env.sh b/scripts/init-production-env.sh index 28e4754..d003326 100755 --- a/scripts/init-production-env.sh +++ b/scripts/init-production-env.sh @@ -47,6 +47,9 @@ fi database_mode=${PRODUCTION_DATABASE_MODE:-container} app_topology=${PRODUCTION_APP_TOPOLOGY:-compact} compose_project_name=${PRODUCTION_COMPOSE_PROJECT_NAME:-who_need_help} +public_edge_enabled=${PRODUCTION_PUBLIC_EDGE_ENABLED:-true} +public_edge_network=${PRODUCTION_PUBLIC_EDGE_NETWORK:-who_need_help_public_edge} +public_upstream_name=${PRODUCTION_PUBLIC_UPSTREAM_NAME:-who-need-help-production} case "$compose_project_name" in *[!a-zA-Z0-9_-]* | '') @@ -79,6 +82,11 @@ case "$app_topology" in *) echo "PRODUCTION_APP_TOPOLOGY must be compact or split." >&2; exit 1 ;; esac +case "$public_edge_enabled" in + true | false) ;; + *) echo "PRODUCTION_PUBLIC_EDGE_ENABLED must be true or false." >&2; exit 1 ;; +esac + secret_key_base=$(openssl rand -hex 64) handover_secret=$(openssl rand -hex 64) release_cookie=$(openssl rand -hex 64) @@ -108,6 +116,9 @@ APP_TOPOLOGY_VALUE=$app_topology \ COMPOSE_PROJECT_NAME_VALUE=$compose_project_name \ HTTP_BIND_ADDRESS_VALUE=$http_bind_address \ HTTP_PORT_VALUE=$http_port \ +PUBLIC_EDGE_ENABLED_VALUE=$public_edge_enabled \ +PUBLIC_EDGE_NETWORK_VALUE=$public_edge_network \ +PUBLIC_UPSTREAM_NAME_VALUE=$public_upstream_name \ DOCKER_SOCKET_GID_VALUE=$docker_socket_gid \ TRUSTED_PROXY_IPS_VALUE=$trusted_proxy_ips \ POSTGRES_PASSWORD_VALUE=$postgres_password \ @@ -134,6 +145,9 @@ SUPPORT_INBOX_ADDRESS_VALUE=$support_inbox_address \ replacement["DATABASE_MODE"] = ENVIRON["DATABASE_MODE_VALUE"] replacement["HTTP_BIND_ADDRESS"] = ENVIRON["HTTP_BIND_ADDRESS_VALUE"] replacement["HTTP_PORT"] = ENVIRON["HTTP_PORT_VALUE"] + replacement["PUBLIC_EDGE_ENABLED"] = ENVIRON["PUBLIC_EDGE_ENABLED_VALUE"] + replacement["PUBLIC_EDGE_NETWORK"] = ENVIRON["PUBLIC_EDGE_NETWORK_VALUE"] + replacement["PUBLIC_UPSTREAM_NAME"] = ENVIRON["PUBLIC_UPSTREAM_NAME_VALUE"] replacement["DOCKER_SOCKET_GID"] = ENVIRON["DOCKER_SOCKET_GID_VALUE"] replacement["TRAEFIK_TRUSTED_IPS"] = ENVIRON["TRUSTED_PROXY_IPS_VALUE"] replacement["TRAEFIK_PROJECT_CONSTRAINT"] = ENVIRON["COMPOSE_PROJECT_NAME_VALUE"] diff --git a/scripts/quality.sh b/scripts/quality.sh index aab7075..821ea60 100755 --- a/scripts/quality.sh +++ b/scripts/quality.sh @@ -24,6 +24,7 @@ mc_image="who-need-help:mc-audit-$run_id" boundary_mock_image="who-need-help:boundary-mock-audit-$run_id" socket_proxy_image="who-need-help:socket-proxy-audit-$run_id" postgis_image="who-need-help:postgis-audit-$run_id" +caddy_image="who-need-help:caddy-audit-$run_id" socket_proxy_container="wnh-socket-proxy-audit-$run_id" scan_dir=$(mktemp -d "${TMPDIR:-/tmp}/wnh-quality-scan.XXXXXX") scan_list="${scan_dir}.files" @@ -44,6 +45,7 @@ cleanup() { docker image rm "$quality_image" "$assets_image" "$e2e_image" "$release_image" \ "$backup_image" "$minio_image" "$mc_image" \ "$boundary_mock_image" "$socket_proxy_image" "$postgis_image" \ + "$caddy_image" \ >/dev/null 2>&1 || true rm -rf "$scan_dir" "$scan_list" "$scan_tar" } @@ -90,6 +92,14 @@ grep -Fx '/act_runner' .dockerignore >/dev/null grep -Fx '/act_runner-data/' .dockerignore >/dev/null echo "Checking production environment initialization and validation" +edge_env="$scan_dir/.env.edge" +./scripts/init-edge-env.sh help.test staging.help.test "$edge_env" >/dev/null +test "$(stat -c '%a' "$edge_env")" = 600 +./scripts/validate-edge-env.sh "$edge_env" >/dev/null +if ./scripts/init-edge-env.sh help.test staging.help.test "$edge_env" >/dev/null 2>&1; then + echo "Edge environment initializer overwrote an existing file." >&2 + exit 1 +fi production_env="$scan_dir/.env.production" PRODUCTION_TRAEFIK_TRUSTED_IPS=172.20.0.1/32 \ PRODUCTION_SMTP_RELAY=smtp.help.test \ @@ -164,6 +174,22 @@ if ./scripts/validate-production-env.sh \ fi echo "Rendering every Docker Compose profile" +CADDY_IMAGE=who-need-help:caddy-local \ +docker compose --project-directory "$ROOT" --env-file "$edge_env" \ + --file compose.edge.yaml config --format json | + jq --exit-status ' + .services.edge.image == "who-need-help:caddy-local" and + .services.edge.user == "1000:1000" and + .services.edge.read_only == true and + .services.edge.cap_drop == ["ALL"] and + .services.edge.cap_add == ["NET_BIND_SERVICE"] and + .services.edge.security_opt == ["no-new-privileges:true"] and + (.services.edge.tmpfs | index("/tmp") != null) and + (.services.edge.ports | map(select(.target == 80 and .published == "80" and .protocol == "tcp")) | length) == 1 and + (.services.edge.ports | map(select(.target == 443 and .published == "443" and .protocol == "tcp")) | length) == 1 and + (.services.edge.ports | map(select(.target == 443 and .published == "443" and .protocol == "udp")) | length) == 1 and + .networks.public_edge.name == "who_need_help_public_edge" + ' >/dev/null ./scripts/compose.sh .env.example config --quiet ./scripts/compose.sh "$production_env" config --quiet ./scripts/compose.sh "$external_production_env" config --quiet @@ -219,6 +245,8 @@ echo "Rendering every Docker Compose profile" .services.app.environment.APP_ROLE == "combined" and .services.app.environment.DNS_CLUSTER_QUERY == "ignore" and .services.app.environment.POOL_SIZE == "4" and + .services.app.networks.public_edge.aliases == ["who-need-help-production"] and + .networks.public_edge.external == true and .services.app.ports[0].host_ip == "127.0.0.1" ' >/dev/null ./scripts/compose.sh "$external_production_env" config --format json | @@ -227,7 +255,8 @@ echo "Rendering every Docker Compose profile" (.services | has("db") | not) and (.services | has("web") | not) and (.services | has("worker") | not) and - (.services | has("proxy") | not) + (.services | has("proxy") | not) and + .services.app.networks.public_edge.aliases == ["who-need-help-production"] ' >/dev/null ./scripts/compose.sh "$external_split_production_env" config --format json | jq --exit-status ' @@ -236,6 +265,7 @@ echo "Rendering every Docker Compose profile" (.services | has("web")) and (.services | has("worker")) and (.services | has("proxy")) and + .services.web.networks.public_edge.aliases == ["who-need-help-production"] and .services.web.deploy.replicas == 2 and .services.worker.deploy.replicas == 2 ' >/dev/null @@ -383,8 +413,11 @@ docker run --rm \ echo "Building, smoke-testing, and scanning pinned runtime infrastructure images" docker build --tag "$socket_proxy_image" --file Dockerfile.socket-proxy . docker build --tag "$postgis_image" --file Dockerfile.postgis . +docker build --tag "$caddy_image" --file Dockerfile.caddy . test "$(docker image inspect --format '{{.Config.User}}' "$socket_proxy_image")" = "haproxy" test "$(docker image inspect --format '{{.Config.User}}' "$postgis_image")" = "postgres" +test "$(docker image inspect --format '{{.Config.User}}' "$caddy_image")" = "1000:1000" +docker run --rm "$caddy_image" version | grep -F 'v2.11.4' >/dev/null docker run --rm --entrypoint sh "$postgis_image" -euc ' test ! -e /usr/local/bin/gosu test "$(id -u)" = 70 @@ -439,6 +472,7 @@ for image in \ "$socket_proxy_image" \ "$postgis_image" \ "traefik:v3.7.8@sha256:4299bbed850421258fc5448c2e0e6ad350981d4d335a68de11b92448aedbefe5" \ + "$caddy_image" \ "axllent/mailpit:v1.30.4@sha256:5a49a77c5bdbe7c5474450b4f46348d09949df3695257729c93a30369382d4f6"; do docker run --rm \ --volume /var/run/docker.sock:/var/run/docker.sock \ diff --git a/scripts/validate-edge-env.sh b/scripts/validate-edge-env.sh new file mode 100755 index 0000000..e0120ee --- /dev/null +++ b/scripts/validate-edge-env.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +env_file=${1:-} + +if [[ -z "$env_file" || ! -f "$env_file" ]]; then + echo "Usage: $0 EDGE_ENV_FILE" >&2 + exit 1 +fi + +if [[ "$(stat -c '%a' "$env_file")" != 600 ]]; then + echo "Edge environment must have mode 0600: $env_file" >&2 + exit 1 +fi + +if [[ "$(stat -c '%u' "$env_file")" != "$(id -u)" ]]; then + echo "Edge 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 + exit + } + 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" +} + +validate_hostname() { + local key=$1 + local value=$2 + + if [[ ! "$value" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$ ]]; then + echo "$key must be a lowercase ASCII DNS hostname." >&2 + exit 1 + fi +} + +validate_upstream() { + local key=$1 + local value=$2 + + if [[ ! "$value" =~ ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?:[0-9]+$ ]]; then + echo "$key must be a lowercase host and numeric port." >&2 + exit 1 + fi +} + +compose_project_name=$(require_value COMPOSE_PROJECT_NAME) +public_edge_network=$(require_value PUBLIC_EDGE_NETWORK) +edge_bind_address=$(require_value EDGE_BIND_ADDRESS) +edge_http_port=$(require_value EDGE_HTTP_PORT) +edge_https_port=$(require_value EDGE_HTTPS_PORT) +primary_domain=$(require_value PRIMARY_DOMAIN) +primary_upstream=$(require_value PRIMARY_UPSTREAM) +staging_domain=$(require_value STAGING_DOMAIN) +staging_upstream=$(require_value STAGING_UPSTREAM) + +[[ "$compose_project_name" =~ ^[a-zA-Z0-9_-]+$ ]] || { + echo "COMPOSE_PROJECT_NAME contains unsupported characters." >&2 + exit 1 +} +[[ "$public_edge_network" =~ ^[a-zA-Z0-9_-]+$ ]] || { + echo "PUBLIC_EDGE_NETWORK contains unsupported characters." >&2 + exit 1 +} +[[ "$edge_bind_address" == "0.0.0.0" || "$edge_bind_address" == "127.0.0.1" ]] || { + echo "EDGE_BIND_ADDRESS must be 0.0.0.0 or 127.0.0.1." >&2 + exit 1 +} +for port_name in edge_http_port edge_https_port; do + port=${!port_name} + if [[ ! "$port" =~ ^[0-9]+$ ]] || ((port < 1 || port > 65535)); then + echo "${port_name^^} must be between 1 and 65535." >&2 + exit 1 + fi +done +[[ "$edge_http_port" != "$edge_https_port" ]] || { + echo "EDGE_HTTP_PORT and EDGE_HTTPS_PORT must be different." >&2 + exit 1 +} + +validate_hostname PRIMARY_DOMAIN "$primary_domain" +validate_hostname STAGING_DOMAIN "$staging_domain" +validate_upstream PRIMARY_UPSTREAM "$primary_upstream" +validate_upstream STAGING_UPSTREAM "$staging_upstream" + +[[ "$primary_domain" != "$staging_domain" ]] || { + echo "PRIMARY_DOMAIN and STAGING_DOMAIN must be different." >&2 + exit 1 +} +[[ "$primary_upstream" != "$staging_upstream" ]] || { + echo "PRIMARY_UPSTREAM and STAGING_UPSTREAM must be different." >&2 + exit 1 +} + +docker compose \ + --project-directory "$ROOT" \ + --env-file "$env_file" \ + --file "$ROOT/compose.edge.yaml" \ + config --quiet + +echo "Public edge environment passed structural validation." diff --git a/scripts/validate-production-env.sh b/scripts/validate-production-env.sh index c5531ee..2f5c026 100755 --- a/scripts/validate-production-env.sh +++ b/scripts/validate-production-env.sh @@ -82,6 +82,9 @@ 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) +public_edge_enabled=$(require_value PUBLIC_EDGE_ENABLED) +public_edge_network=$(require_value PUBLIC_EDGE_NETWORK) +public_upstream_name=$(require_value PUBLIC_UPSTREAM_NAME) trusted_proxy_ips=$(optional_value TRAEFIK_TRUSTED_IPS) postgres_password=$(optional_value POSTGRES_PASSWORD) database_url=$(require_value DATABASE_URL) @@ -121,6 +124,20 @@ google_oauth_client_secret=$(optional_value GOOGLE_OAUTH_CLIENT_SECRET) echo "DATABASE_MODE must be container or external." >&2 exit 1 } +[[ "$public_edge_enabled" =~ ^(true|false)$ ]] || { + echo "PUBLIC_EDGE_ENABLED must be true or false." >&2 + exit 1 +} +if [[ "$public_edge_enabled" == true ]]; then + [[ "$public_edge_network" =~ ^[a-zA-Z0-9_-]+$ ]] || { + echo "PUBLIC_EDGE_NETWORK contains unsupported characters." >&2 + exit 1 + } + [[ "$public_upstream_name" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]] || { + echo "PUBLIC_UPSTREAM_NAME must be a lowercase Docker DNS alias." >&2 + exit 1 + } +fi [[ "$phx_host" == "$expected_domain" ]] || { echo "PHX_HOST does not match EXPECTED_DOMAIN." >&2