diff --git a/.env.prod.example b/.env.prod.example deleted file mode 100644 index a3a0730..0000000 --- a/.env.prod.example +++ /dev/null @@ -1,15 +0,0 @@ -# Copy to .env.prod, fill in, then: set -a && source .env.prod && set +a -# Or use: export $(grep -v '^#' .env.prod | xargs) - -POSTGRES_HOST=postgres -POSTGRES_DB=gc_index_relay_prod -POSTGRES_USER=postgres -POSTGRES_PASSWORD=change-me -POSTGRES_RUNTIME_USER=gc_index_relay -POSTGRES_RUNTIME_PASSWORD=change-me-runtime - -SECRET_KEY_BASE=generate-with-mix-phx-gen-secret -PHX_HOST=gc-http-relay.imwald.eu - -# Optional: pin release image (default latest) -# TAG=0.2.0 diff --git a/.gitignore b/.gitignore index 717a662..536c4b0 100644 --- a/.gitignore +++ b/.gitignore @@ -32,5 +32,13 @@ gc_index_relay-*.tar # Secrets -- copy from .env.example and fill in values .env +# Personal / production deploy layout (Docker Hub, server env, etc.) — keep locally, not in git +compose.prod.yml +compose.prod.override.yml +scripts/deploy_prod.sh +.env.prod +.env.prod.example +.env.production + # Local Postgres data /pgdata/ diff --git a/README.md b/README.md index 4f0e7ce..8112fff 100644 --- a/README.md +++ b/README.md @@ -64,13 +64,7 @@ source .env mix phx.server ``` -The relay is available at [http://localhost:4000](http://localhost:4000). - -During development, use `dev.sh` instead to get automatic server restarts when config files change: - -```bash -source .env && ./dev.sh -``` +The relay is available at [http://localhost:4000](http://localhost:4000). After edits to `config/config.exs`, `config/dev.exs`, or `config/runtime.exs`, restart the server manually; other code is hot-reloaded. ## API diff --git a/compose.prod.yml b/compose.prod.yml deleted file mode 100644 index c332c9e..0000000 --- a/compose.prod.yml +++ /dev/null @@ -1,118 +0,0 @@ -# Production stack: Apache (on the host) terminates TLS and proxies to 127.0.0.1:4000. -# -# Helper script: ./scripts/deploy_prod.sh --help -# -# --- Local: build and push --- -# cp .env.prod.example .env.prod && edit secrets -# export TAG=0.2.0 # optional; relay/migrator use :latest if unset -# docker login -# ./scripts/deploy_prod.sh build-push -# -# --- Remote: pull and run --- -# ./scripts/deploy_prod.sh deploy -# -# Images (repository: silberengel/gc-http-relay): -# :${TAG} — Phoenix release (relay + migrator) -# :setup — one-shot DB user bootstrap (tag is literal "setup") - -services: - postgres: - image: docker.io/apache/age:release_PG17_1.6.0 - restart: unless-stopped - user: 1000:1000 - volumes: - - pgdata:/var/lib/postgresql/data - environment: - POSTGRES_DB: ${POSTGRES_DB:?set POSTGRES_DB in .env.prod} - POSTGRES_USER: ${POSTGRES_USER:?set POSTGRES_USER in .env.prod} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env.prod} - command: > - postgres - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] - interval: 10s - timeout: 5s - retries: 5 - deploy: - resources: - limits: - cpus: "1.00" - memory: 1G - reservations: - cpus: "0.50" - memory: 512M - networks: - - internal - - setup: - image: docker.io/silberengel/gc-http-relay:setup - build: - context: . - dockerfile: ./docker/setup.Dockerfile - command: ["/usr/local/bin/usersetup.sh"] - restart: "no" - depends_on: - postgres: - condition: service_healthy - environment: - POSTGRES_HOST: ${POSTGRES_HOST:-postgres} - POSTGRES_USER: ${POSTGRES_USER:?set POSTGRES_USER in .env.prod} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env.prod} - POSTGRES_DB: ${POSTGRES_DB:?set POSTGRES_DB in .env.prod} - POSTGRES_RUNTIME_USER: ${POSTGRES_RUNTIME_USER:?set POSTGRES_RUNTIME_USER in .env.prod} - POSTGRES_RUNTIME_PASSWORD: ${POSTGRES_RUNTIME_PASSWORD:?set POSTGRES_RUNTIME_PASSWORD in .env.prod} - networks: - - internal - - migrator: - image: docker.io/silberengel/gc-http-relay:${TAG:-latest} - build: - context: . - dockerfile: ./docker/server.Dockerfile - command: ["/app/bin/migrate"] - restart: "no" - depends_on: - postgres: - condition: service_healthy - setup: - condition: service_completed_successfully - environment: - DATABASE_URL: "ecto://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST:-postgres}/${POSTGRES_DB}" - SECRET_KEY_BASE: ${SECRET_KEY_BASE:?set SECRET_KEY_BASE in .env.prod} - networks: - - internal - - relay: - image: docker.io/silberengel/gc-http-relay:${TAG:-latest} - build: - context: . - dockerfile: ./docker/server.Dockerfile - command: ["/app/bin/server"] - restart: unless-stopped - deploy: - resources: - limits: - cpus: "1.00" - memory: 1G - reservations: - cpus: "0.50" - memory: 512M - depends_on: - postgres: - condition: service_healthy - migrator: - condition: service_completed_successfully - ports: - - "127.0.0.1:4000:4000" - environment: - DATABASE_URL: "ecto://${POSTGRES_RUNTIME_USER}:${POSTGRES_RUNTIME_PASSWORD}@${POSTGRES_HOST:-postgres}/${POSTGRES_DB}" - SECRET_KEY_BASE: ${SECRET_KEY_BASE:?set SECRET_KEY_BASE in .env.prod} - PHX_HOST: ${PHX_HOST:?set PHX_HOST in .env.prod (public hostname, no scheme)} - networks: - - internal - -networks: - internal: - -volumes: - pgdata: diff --git a/compose.yaml b/compose.yaml index fa5407d..bc04026 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,7 +1,7 @@ services: postgres: image: docker.io/apache/age:release_PG17_1.6.0 - container_name: postgres_01 + container_name: postgress_01 restart: unless-stopped user: 1000:1000 # Should match host user ports: diff --git a/config/config.exs b/config/config.exs index 039dd6c..d08e411 100644 --- a/config/config.exs +++ b/config/config.exs @@ -11,6 +11,14 @@ config :gc_index_relay, ecto_repos: [GcIndexRelay.Repo], generators: [timestamp_type: :utc_datetime] +# In production, set `CORS_ENABLED=false` (runtime) or disable here when a reverse proxy +# adds CORS. Use `allow_origins: ["https://app.example.com"]` to restrict browser access. +config :gc_index_relay, :cors, + enabled: true, + allow_origins: "*", + allow_methods: "GET, POST, DELETE, OPTIONS", + allow_headers: "content-type, authorization" + # Configure the endpoint config :gc_index_relay, GcIndexRelayWeb.Endpoint, url: [host: "localhost"], diff --git a/config/prod.exs b/config/prod.exs index 8555e1a..bd1c8f1 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -24,5 +24,9 @@ config :swoosh, local: false # Do not print debug messages in production config :logger, level: :info +# In-app CORS: disable when your L7 proxy sets CORS headers, e.g.: +# config :gc_index_relay, :cors, enabled: false +# Or use CORS_ENABLED / CORS_ALLOW_ORIGINS in runtime.exs. + # Runtime production configuration, including reading # of environment variables, is done on config/runtime.exs. diff --git a/config/runtime.exs b/config/runtime.exs index f38c3cb..e597e05 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -75,6 +75,37 @@ if config_env() == :prod do ], secret_key_base: secret_key_base + cors_base = Application.get_env(:gc_index_relay, :cors) || [] + + cors_enabled = + case System.get_env("CORS_ENABLED") do + nil -> Keyword.get(cors_base, :enabled, true) + v -> String.downcase(String.trim(v)) in ~w(true 1 yes) + end + + allow_origins = + case System.get_env("CORS_ALLOW_ORIGINS") do + nil -> + Keyword.get(cors_base, :allow_origins, "*") + + raw -> + raw + |> String.split(",", trim: true) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> case do + [] -> "*" + ["*"] -> "*" + list -> list + end + end + + config :gc_index_relay, + :cors, + cors_base + |> Keyword.put(:enabled, cors_enabled) + |> Keyword.put(:allow_origins, allow_origins) + # ## SSL Support # # To get SSL working, you will need to add the `https` key diff --git a/dev.sh b/dev.sh deleted file mode 100755 index 1b2ae40..0000000 --- a/dev.sh +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env bash -# Development server with auto-restart on config file changes. -# -# Normal code changes (controllers, templates, etc.) are still hot-reloaded -# by Phoenix automatically. This script only handles the cases Phoenix can't: -# config/config.exs, config/dev.exs, and config/runtime.exs. -# -# Usage: -# chmod +x dev.sh -# source .env && ./dev.sh - -set -euo pipefail - -CONFIG_FILES=( - config/config.exs - config/dev.exs - config/runtime.exs -) - -cleanup() { - if [ -n "${SERVER_PID:-}" ] && kill -0 "$SERVER_PID" 2>/dev/null; then - echo "" - echo "[dev] Stopping server (pid $SERVER_PID)..." - kill "$SERVER_PID" - wait "$SERVER_PID" 2>/dev/null || true - fi - exit 0 -} - -trap cleanup INT TERM - -echo "[dev] Watching config files for changes: ${CONFIG_FILES[*]}" -echo "[dev] Normal code changes are still hot-reloaded automatically." -echo "[dev] Press Ctrl+C to stop." -echo "" - -while true; do - echo "[dev] Starting server..." - mix phx.server & - SERVER_PID=$! - - # Block until any config file is modified - inotifywait -q -e modify "${CONFIG_FILES[@]}" 2>/dev/null - - echo "" - echo "[dev] Config changed — restarting server..." - kill "$SERVER_PID" 2>/dev/null - wait "$SERVER_PID" 2>/dev/null || true - echo "" -done diff --git a/lib/gc_index_relay_web/plugs/cors.ex b/lib/gc_index_relay_web/plugs/cors.ex index af131da..9a3a110 100644 --- a/lib/gc_index_relay_web/plugs/cors.ex +++ b/lib/gc_index_relay_web/plugs/cors.ex @@ -2,9 +2,14 @@ defmodule GcIndexRelayWeb.Plugs.CORS do @moduledoc """ CORS plug for the Nostr relay REST API. - Nostr clients can be hosted on any origin, so this plug allows all origins - on all API routes. Preflight OPTIONS requests are halted here and returned - a 200 before they reach the router. + Configure under `:cors` for `:gc_index_relay` (see `config/config.exs`). + + * `enabled` — when `false`, the plug is a no-op (use when a reverse proxy handles CORS). + * `allow_origins` — `\"*\"` or a list of exact `Origin` values to echo back. + * `allow_methods` / `allow_headers` — forwarded as response headers when CORS applies. + + Preflight `OPTIONS` requests are halted with 200 when CORS is enabled; when disabled, + they continue to the router. """ import Plug.Conn @@ -12,13 +17,56 @@ defmodule GcIndexRelayWeb.Plugs.CORS do def init(opts), do: opts def call(conn, _opts) do - conn - |> put_resp_header("access-control-allow-origin", "*") - |> put_resp_header("access-control-allow-methods", "GET, POST, DELETE, OPTIONS") - |> put_resp_header("access-control-allow-headers", "content-type, authorization") - |> handle_preflight() + cors = Application.get_env(:gc_index_relay, :cors, []) + + if Keyword.get(cors, :enabled, true) do + allow_origins = Keyword.get(cors, :allow_origins, "*") + allow_methods = Keyword.get(cors, :allow_methods, "GET, POST, DELETE, OPTIONS") + allow_headers = Keyword.get(cors, :allow_headers, "content-type, authorization") + origin_value = resolve_allow_origin(conn, allow_origins) + + if origin_value == nil && restrictive_allowlist?(allow_origins) do + handle_preflight(conn) + else + conn + |> put_resp_header("access-control-allow-origin", origin_value) + |> put_resp_header("access-control-allow-methods", allow_methods) + |> put_resp_header("access-control-allow-headers", allow_headers) + |> handle_preflight() + end + else + conn + end end + defp resolve_allow_origin(_conn, "*"), do: "*" + + defp resolve_allow_origin(conn, allow_origins) when is_list(allow_origins) do + cond do + "*" in allow_origins -> + "*" + + Enum.any?(allow_origins, &(&1 == "*")) -> + "*" + + true -> + case get_req_header(conn, "origin") do + [origin] -> if Enum.member?(allow_origins, origin), do: origin, else: nil + _ -> nil + end + end + end + + defp resolve_allow_origin(_conn, _), do: "*" + + defp restrictive_allowlist?("*"), do: false + + defp restrictive_allowlist?(list) when is_list(list) do + "*" not in list + end + + defp restrictive_allowlist?(_), do: false + defp handle_preflight(%Plug.Conn{method: "OPTIONS"} = conn) do conn |> send_resp(200, "") diff --git a/lib/gc_index_relay_web/plugs/relay_info.ex b/lib/gc_index_relay_web/plugs/relay_info.ex index db81093..74e2748 100644 --- a/lib/gc_index_relay_web/plugs/relay_info.ex +++ b/lib/gc_index_relay_web/plugs/relay_info.ex @@ -58,8 +58,12 @@ defmodule GcIndexRelayWeb.Plugs.RelayInfo do defp resolve_field(map, key, base_url) do case Map.get(map, key) do - nil -> map - "" -> map + nil -> + map + + "" -> + map + url when is_binary(url) -> if String.starts_with?(url, ["http://", "https://"]) do map @@ -67,7 +71,9 @@ defmodule GcIndexRelayWeb.Plugs.RelayInfo do path = if String.starts_with?(url, "/"), do: url, else: "/#{url}" Map.put(map, key, "#{base_url}#{path}") end - _ -> map + + _ -> + map end end end diff --git a/scripts/deploy_prod.sh b/scripts/deploy_prod.sh deleted file mode 100755 index ba8afd5..0000000 --- a/scripts/deploy_prod.sh +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env bash -# Production Docker Compose helper — run ./scripts/deploy_prod.sh --help - -set -euo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$ROOT" - -COMPOSE="${COMPOSE:-docker compose}" -COMPOSE_FILE="${COMPOSE_FILE:-compose.prod.yml}" -ENV_FILE="${ENV_FILE:-.env.prod}" - -APP_IMAGES=(setup relay migrator) - -usage() { - cat <<'EOF' -Production Docker Compose helper (compose.prod.yml). - - ./scripts/deploy_prod.sh build build app images (setup, relay, migrator) - ./scripts/deploy_prod.sh push push those images (run docker login first) - ./scripts/deploy_prod.sh build-push build then push - ./scripts/deploy_prod.sh pull pull images - ./scripts/deploy_prod.sh up start stack (detached) - ./scripts/deploy_prod.sh deploy pull then up -d (typical on server) - ./scripts/deploy_prod.sh down stop stack - ./scripts/deploy_prod.sh ps docker compose ps - ./scripts/deploy_prod.sh logs [svc] follow logs (default: all services) - -Env: ENV_FILE (default .env.prod), COMPOSE_FILE, TAG, COMPOSE (default "docker compose") -EOF - exit "${1:-0}" -} - -require_env_file() { - if [[ ! -f "$ENV_FILE" ]]; then - echo "error: missing env file: $ENV_FILE" >&2 - echo " cp .env.prod.example .env.prod && edit, or set ENV_FILE=..." >&2 - exit 1 - fi -} - -compose() { - require_env_file - $COMPOSE --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@" -} - -cmd="${1:-}" -[[ -z "$cmd" ]] && usage 1 -[[ "$cmd" == "-h" || "$cmd" == "--help" ]] && usage 0 -shift || true - -case "$cmd" in - help) - usage 0 - ;; - build) - compose build "${APP_IMAGES[@]}" - ;; - push) - compose push "${APP_IMAGES[@]}" - ;; - build-push) - compose build "${APP_IMAGES[@]}" - compose push "${APP_IMAGES[@]}" - ;; - pull) - compose pull - ;; - up) - compose up -d - ;; - deploy) - compose pull - compose up -d - ;; - down) - compose down - ;; - ps) - compose ps "$@" - ;; - logs) - compose logs -f "$@" - ;; - *) - echo "error: unknown command: $cmd" >&2 - usage 1 - ;; -esac diff --git a/test/gc_index_relay_web/plugs/cors_plug_test.exs b/test/gc_index_relay_web/plugs/cors_plug_test.exs new file mode 100644 index 0000000..fa63cea --- /dev/null +++ b/test/gc_index_relay_web/plugs/cors_plug_test.exs @@ -0,0 +1,85 @@ +defmodule GcIndexRelayWeb.Plugs.CorsPlugTest do + @moduledoc false + use GcIndexRelayWeb.ConnCase, async: false + + @moduletag :unit + + setup do + previous = Application.get_env(:gc_index_relay, :cors) + on_exit(fn -> Application.put_env(:gc_index_relay, :cors, previous) end) + {:ok, previous_cors: previous} + end + + describe "allow_origins allowlist" do + setup %{previous_cors: previous} do + base = previous || [] + + Application.put_env( + :gc_index_relay, + :cors, + Keyword.merge(base, + enabled: true, + allow_origins: ["https://client.example.com"] + ) + ) + + :ok + end + + test "reflects Origin when it matches the allowlist", %{conn: conn} do + conn = + conn + |> put_req_header("accept", "application/json") + |> put_req_header("origin", "https://client.example.com") + |> get(~p"/api") + + assert get_resp_header(conn, "access-control-allow-origin") == [ + "https://client.example.com" + ] + end + + test "omits Access-Control-Allow-Origin when Origin does not match", %{conn: conn} do + conn = + conn + |> put_req_header("accept", "application/json") + |> put_req_header("origin", "https://evil.example.com") + |> get(~p"/api") + + assert get_resp_header(conn, "access-control-allow-origin") == [] + end + + test "preflight without matching Origin has no CORS allow headers", %{conn: conn} do + conn = + conn + |> put_req_header("origin", "https://evil.example.com") + |> options("/api/events") + + assert conn.status == 200 + assert conn.resp_body == "" + assert get_resp_header(conn, "access-control-allow-origin") == [] + end + end + + describe "CORS disabled" do + setup %{previous_cors: previous} do + base = previous || [] + Application.put_env(:gc_index_relay, :cors, Keyword.merge(base, enabled: false)) + :ok + end + + test "does not set CORS headers on GET", %{conn: conn} do + conn = + conn + |> put_req_header("accept", "application/json") + |> get(~p"/api") + + assert get_resp_header(conn, "access-control-allow-origin") == [] + end + + test "does not handle OPTIONS preflight (no CORS headers)", %{conn: conn} do + conn = options(conn, "/api/events") + + assert get_resp_header(conn, "access-control-allow-origin") == [] + end + end +end diff --git a/test/gc_index_relay_web/relay_unit_test.exs b/test/gc_index_relay_web/relay_unit_test.exs index 4eeb9c1..909b302 100644 --- a/test/gc_index_relay_web/relay_unit_test.exs +++ b/test/gc_index_relay_web/relay_unit_test.exs @@ -71,8 +71,11 @@ defmodule GcIndexRelayWeb.RelayUnitTest do end test "relay includes CORS headers on a POST response", %{conn: conn} do - conn = post(conn, ~p"/api/events/filter", %{"limit" => 10}) + # Reject before DB (NIP-70 protected) so unit tests need no running Repo. + event = valid_pub_event_fixture(%{tags: [["-"]]}) + conn = post(conn, ~p"/api/events", %{"event" => Map.from_struct(event)}) + assert json_response(conn, 400) assert get_resp_header(conn, "access-control-allow-origin") == ["*"] end