diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8da8bf6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,57 @@ +# See https://docs.docker.com/engine/reference/builder/#dockerignore-file for more about ignoring files. + +# Ignore git directory. +/.git/ +/.gitignore + +# Ignore bundler config. +/.bundle + +# Ignore gems installed into the project (bin/setup / CI put them here). They are +# built for the host's platform, and the image runs bundle install itself. +/vendor/bundle + +# Ignore all environment files. +/.env* + +# Ignore all default key files. +/config/master.key +/config/credentials/*.key + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep + +# Ignore pidfiles, but keep the directory. +/tmp/pids/* +!/tmp/pids/.keep + +# Ignore storage (uploaded files in development and any SQLite databases). +/storage/* +!/storage/.keep +/tmp/storage/* +!/tmp/storage/.keep + +# Ignore assets. +/node_modules/ +/app/assets/builds/* +!/app/assets/builds/.keep +/public/assets + +# Ignore CI service files. +/.github + +# Ignore Kamal files. +/config/deploy*.yml +/.kamal + +# Ignore Docker-related files +/.dockerignore +/Dockerfile* + +# Ignore agent and editor files +/.claude +/docs-private +.DS_Store diff --git a/.gitignore b/.gitignore index bd32048..0503704 100644 --- a/.gitignore +++ b/.gitignore @@ -40,7 +40,6 @@ # Ignore master key for decrypting credentials and more. /config/master.key -/config/credentials/staging.key /config/credentials/development.key /config/credentials/production.key diff --git a/.kamal/secrets b/.kamal/secrets new file mode 100644 index 0000000..d7bbe08 --- /dev/null +++ b/.kamal/secrets @@ -0,0 +1,32 @@ +# Secrets defined here are available for reference under registry/password, env/secret, builder/secrets, +# and accessories/*/env/secret in config/deploy.yml. All secrets should be pulled from either +# password manager, ENV, or a file. DO NOT ENTER RAW CREDENTIALS HERE! This file needs to be safe for git. + +# GitHub Container Registry password: a Personal Access Token (classic) with the +# write:packages scope, for the user/org in config/deploy.yml's registry section. +# export KAMAL_REGISTRY_PASSWORD=ghp_xxxxxxxx +KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD + +# Production credentials key. Decrypts config/credentials/production.yml.enc. +# Never check config/credentials/*.key into git (they're gitignored). +RAILS_MASTER_KEY=$(cat config/credentials/production.key) + +# Postgres password for the "db" accessory and the app's database connection. +# Read from an app-specific variable so it can't collide with another app's +# POSTGRES_PASSWORD in the same shell when several apps share one server. +# There is intentionally no fallback: a missing value fails the deploy instead +# of shipping an empty password. +# export NATIVEAPPTEMPLATEAPI_POSTGRES_PASSWORD=$(openssl rand -hex 32) +# It is baked into the Postgres data volume on the FIRST deploy. Changing it +# later requires also altering the role inside Postgres. +POSTGRES_PASSWORD=$NATIVEAPPTEMPLATEAPI_POSTGRES_PASSWORD + +# Cloudflare Origin certificate for kamal-proxy (proxy.ssl in config/deploy.yml). +# Read from files, not environment variables: a PEM is multi-line and does not +# survive `export` in a shell rc file. Create them on the deploying machine, +# mode 0600, outside the repo (README "Deployment" has the commands): +# ~/.config/nativeapptemplateapi/origin-cert.pem +# ~/.config/nativeapptemplateapi/origin-key.pem +# Kamal fails the deploy if either is missing or not a valid PEM. +NATIVEAPPTEMPLATEAPI_ORIGIN_CERT=$(cat $HOME/.config/nativeapptemplateapi/origin-cert.pem) +NATIVEAPPTEMPLATEAPI_ORIGIN_KEY=$(cat $HOME/.config/nativeapptemplateapi/origin-key.pem) diff --git a/AGENTS.md b/AGENTS.md index 5730e31..1d30b23 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,7 +77,7 @@ bin/rails dbconsole # Database console ### Background Processing - Solid Queue for background jobs (database-backed, no Redis needed) - Solid Cable for Action Cable (database-backed) -- Solid Cache for caching in production/staging +- Solid Cache for caching in production - Monitor jobs at `/madmin/jobs` (Mission Control) ### Push Notifications @@ -88,7 +88,7 @@ Cross-platform push via the `noticed` (v3) and `action_push_native` gems. APNs f - **Device registration**: `POST /api/v1/shopkeeper/devices` is idempotent on `(platform, token)` — re-POST updates `last_active_at`; a token bound to another shopkeeper is reassigned. - **Notifiers**: subclass `ApplicationNotifier` (`Noticed::Event`). `ItemTagNotifier` fires from the AASM `complete` event. In `deliver_by :action_push_native`, the `with_apple`, `with_google`, and `with_data` options must each return a **Hash** (use `{}` when empty) — passing `nil` raises `TypeError: no implicit conversion of nil into Hash`. - **Config & secrets**: `config/push.yml` reads everything from credentials (`bin/rails credentials:edit --environment `) under `action_push_native:apns:{key_id, team_id, topic, encryption_key}` and `action_push_native:fcm:{project_id, encryption_key}`. APNs `encryption_key` is the full `.p8` PEM; FCM `encryption_key` is the entire service-account JSON. `topic` stays in credentials (not source) because the agent's rename pipeline would otherwise desync the bundle id. -- **APNs sandbox vs production**: `connect_to_development_server: <%= Rails.env.development? %>`. The token's environment is fixed at iOS build time — Xcode debug builds get **sandbox** tokens, TestFlight/App Store get **production**. They must match the server: test Xcode debug builds against a **local development** server; use TestFlight to test against Render. A mismatch returns APNs `400 BadDeviceToken`, which the gem treats as `TokenError` and **destroys the device row** (the default `rescue_from`). FCM has no such split. +- **APNs sandbox vs production**: `connect_to_development_server: <%= Rails.env.development? %>`. The token's environment is fixed at iOS build time — Xcode debug builds get **sandbox** tokens, TestFlight/App Store get **production**. They must match the server: test Xcode debug builds against a **local development** server; use TestFlight to test against the deployed server. A mismatch returns APNs `400 BadDeviceToken`, which the gem treats as `TokenError` and **destroys the device row** (the default `rescue_from`). FCM has no such split. ### Testing Strategy - Minitest for all tests (models, controllers, integration, policies) @@ -119,11 +119,11 @@ Cross-platform push via the `noticed` (v3) and `action_push_native` gems. APNs f - Image processing with Active Storage and `image_processing` gem ### Deployment -- **This repo itself is never deployed.** It is the template that apps are generated from, so there is no production environment or production data. Don't weigh changes against deploy risk (migration downtime, existing records); do keep the Render config below correct, because generated apps inherit it. -- Configured for Render.com deployment (for generated apps) -- Build script: `bin/render-build.sh` -- Web server: `bin/render-start.sh` -- Solid Queue runs in Puma via `SOLID_QUEUE_IN_PUMA=true` +- **This repo itself is never deployed.** It is the template that apps are generated from, so there is no production environment or production data. Don't weigh changes against deploy risk (migration downtime, existing records); do keep the Kamal config below correct, because generated apps inherit it. +- Deployed with [Kamal 2](https://kamal-deploy.org) (`bin/kamal`): `Dockerfile` builds the image, `config/deploy.yml` describes the server, and `.kamal/secrets` reads secrets from the environment and `config/credentials/production.key`. Images go to GitHub Container Registry (`ghcr.io`). +- One server runs everything: the app (Puma behind Thruster on port 80, fronted by kamal-proxy serving a Cloudflare Origin Certificate with `forward_headers: true` and `ssl_redirect: false`; Cloudflare's ranges are in `config.action_dispatch.trusted_proxies` via `config/cloudflare_ips.yml` so `rate_limit` sees the caller, not the edge), and PostgreSQL as the `db` accessory on the private Docker network (`DB_HOST=-db`; never published to the internet). Solid Queue runs inside Puma via `SOLID_QUEUE_IN_PUMA=true`, so there is no separate job server. +- Everything named `nativeapptemplateapi` in `config/deploy.yml`, `.kamal/secrets` and `config/database.yml` is renamed together by the agent; the accessory's `POSTGRES_USER`/`POSTGRES_DB` must keep matching `database.yml`'s production `username`/`database` (`test/config/deploy_config_test.rb` checks this). +- The only environments are `development`, `test` and `production`. There is no staging: Render PR previews were the only thing that used it. ## Code Quality Checks Before Committing diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7e66f25 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,74 @@ +# syntax=docker/dockerfile:1 +# check=error=true + +# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand: +# docker build -t nativeapptemplateapi . +# docker run -d -p 80:80 -e RAILS_MASTER_KEY= --name nativeapptemplateapi nativeapptemplateapi + +# Make sure RUBY_VERSION matches the Ruby version in .ruby-version +ARG RUBY_VERSION=4.0.7 +FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base + +# Rails app lives here +WORKDIR /rails + +# Install base packages. libvips is for Active Storage variants; postgresql-client +# gives bin/rails dbconsole (and pg_dump) inside the container. +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y curl libjemalloc2 libvips postgresql-client && \ + ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Set production environment variables and enable jemalloc for reduced memory usage and latency. +ENV RAILS_ENV="production" \ + BUNDLE_DEPLOYMENT="1" \ + BUNDLE_PATH="/usr/local/bundle" \ + BUNDLE_WITHOUT="development:test" \ + LD_PRELOAD="/usr/local/lib/libjemalloc.so" + +# Throw-away build stage to reduce size of final image +FROM base AS build + +# Install packages needed to build gems +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y build-essential git libpq-dev libvips libyaml-dev pkg-config && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Install application gems +COPY vendor/* ./vendor/ +COPY Gemfile Gemfile.lock .ruby-version ./ + +RUN bundle install && \ + rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ + # -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495 + bundle exec bootsnap precompile -j 1 --gemfile + +# Copy application code +COPY . . + +# Precompile bootsnap code for faster boot times. +# -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495 +RUN bundle exec bootsnap precompile -j 1 app/ lib/ + +# Precompiling assets for production without requiring secret RAILS_MASTER_KEY +RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile + + +# Final stage for app image +FROM base + +# Run and own only the runtime files as a non-root user for security +RUN groupadd --system --gid 1000 rails && \ + useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash +USER 1000:1000 + +# Copy built artifacts: gems, application +COPY --chown=rails:rails --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" +COPY --chown=rails:rails --from=build /rails /rails + +# Entrypoint prepares the database. +ENTRYPOINT ["/rails/bin/docker-entrypoint"] + +# Start server via Thruster by default, this can be overwritten at runtime +EXPOSE 80 +CMD ["./bin/thrust", "./bin/rails", "server"] diff --git a/Gemfile b/Gemfile index 43c6627..181ddb4 100644 --- a/Gemfile +++ b/Gemfile @@ -14,6 +14,12 @@ gem "pg" # Use the Puma web server [https://github.com/puma/puma] gem "puma", "~> 8.0" +# Deploy this application anywhere as a Docker container [https://kamal-deploy.org] +gem "kamal", require: false + +# Add HTTP asset caching/compression and X-Sendfile acceleration to Puma [https://github.com/basecamp/thruster/] +gem "thruster", require: false + # Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev] gem "turbo-rails", "~> 2.0.3" diff --git a/Gemfile.lock b/Gemfile.lock index 72f2536..7b4ff1a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -94,6 +94,7 @@ GEM ast (2.4.3) base64 (0.3.0) bcrypt (3.1.22) + bcrypt_pbkdf (1.1.2) better_html (2.2.0) actionview (>= 7.0) activesupport (>= 7.0) @@ -138,7 +139,9 @@ GEM bcrypt (~> 3.0) devise (> 3.5.2, < 6) rails (>= 4.2.0, < 8.3) + dotenv (3.2.0) drb (2.2.3) + ed25519 (1.4.0) erb (6.0.7) erb_lint (0.9.0) activesupport @@ -206,6 +209,17 @@ GEM activesupport (>= 4.2) jwt (3.3.0) base64 + kamal (2.12.0) + activesupport (>= 7.0) + base64 (~> 0.2) + bcrypt_pbkdf (~> 1.0) + concurrent-ruby (~> 1.2) + dotenv (~> 3.1) + ed25519 (~> 1.4) + net-ssh (~> 7.3) + sshkit (>= 1.23.0, < 2.0) + thor (~> 1.3) + zeitwerk (>= 2.6.18, < 3.0) language_server-protocol (3.17.0.6) lint_roller (1.1.0) logger (1.7.0) @@ -256,8 +270,13 @@ GEM net-protocol net-protocol (0.4.0) timeout + net-scp (4.1.0) + net-ssh (>= 2.6.5, < 8.0.0) + net-sftp (4.0.0) + net-ssh (>= 5.0.0, < 8.0.0) net-smtp (0.5.2) net-protocol + net-ssh (7.3.6) nio4r (2.7.5) nokogiri (1.19.4) mini_portile2 (~> 2.8.2) @@ -422,6 +441,13 @@ GEM fugit (~> 1.11) railties (>= 7.1) thor (>= 1.3.1) + sshkit (1.25.1) + base64 + logger + net-scp (>= 1.1.2) + net-sftp (>= 2.1.2) + net-ssh (>= 2.8.0) + ostruct stimulus-rails (1.3.4) railties (>= 6.0.0) tailwindcss-rails (4.6.0) @@ -433,6 +459,11 @@ GEM tailwindcss-ruby (4.3.3-x86_64-darwin) tailwindcss-ruby (4.3.3-x86_64-linux-gnu) thor (1.5.0) + thruster (0.1.27) + thruster (0.1.27-aarch64-linux) + thruster (0.1.27-arm64-darwin) + thruster (0.1.27-x86_64-darwin) + thruster (0.1.27-x86_64-linux) timeout (0.6.1) tsort (0.2.0) turbo-rails (2.0.23) @@ -488,6 +519,7 @@ DEPENDENCIES image_processing (~> 2.0) importmap-rails jsonapi-serializer + kamal madmin (~> 3.1) mailbin minitest-mock @@ -508,6 +540,7 @@ DEPENDENCIES solid_queue stimulus-rails (~> 1.0, >= 1.0.2) tailwindcss-rails (~> 4.0) + thruster turbo-rails (~> 2.0.3) tzinfo-data valid_email2 diff --git a/README.md b/README.md index b027063..483160c 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,35 @@ bin/dev This starts up Overmind running the processes defined in `Procfile.dev`. We've configured this to run the Rails server out of the box. +## Deployment + +The API deploys with [Kamal 2](https://kamal-deploy.org) to any Linux server with SSH access: `Dockerfile` builds the image, `config/deploy.yml` describes the server, and images are pushed to GitHub Container Registry (ghcr.io). PostgreSQL runs on the same server as a Kamal accessory, and Solid Queue runs inside Puma, so one small VPS is enough. The server sits behind Cloudflare: the zone's SSL/TLS mode is "Full (strict)", kamal-proxy serves a Cloudflare Origin Certificate, and the server firewall admits port 443 from [Cloudflare's ranges](https://www.cloudflare.com/ips/) only. (Without Cloudflare, switch `proxy` in `config/deploy.yml` to the Let's Encrypt variant shown in its comment.) + +1. Edit `config/deploy.yml`: the server IP (`servers` and `accessories.db.host`), `image` and `registry.username` (your GitHub user or org), and `proxy.host` (also `app.domain` in `config/settings.yml`). +2. Export the secrets `.kamal/secrets` reads: `KAMAL_REGISTRY_PASSWORD` (a GitHub token with `write:packages`) and `NATIVEAPPTEMPLATEAPI_POSTGRES_PASSWORD`. `RAILS_MASTER_KEY` comes from `config/credentials/production.key`. +3. Create an Origin Certificate in the Cloudflare dashboard (SSL/TLS → Origin Server) and save both halves outside the repo. Copying from the dashboard can lose the line breaks, so rewrap while saving: + +```bash +pem() { ruby -e ' + s = STDIN.read + m = s.match(/-----BEGIN ([A-Z ]+)-----(.*?)-----END \1-----/m) or abort "not a PEM" + puts "-----BEGIN #{m[1]}-----", m[2].gsub(/\s+/, "").scan(/.{1,64}/), "-----END #{m[1]}-----" +' ; } +mkdir -p ~/.config/nativeapptemplateapi && chmod 700 ~/.config/nativeapptemplateapi +pbpaste | pem > ~/.config/nativeapptemplateapi/origin-cert.pem # "Origin Certificate" copied +pbpaste | pem > ~/.config/nativeapptemplateapi/origin-key.pem # "Private Key" copied +chmod 600 ~/.config/nativeapptemplateapi/origin-*.pem +openssl x509 -in ~/.config/nativeapptemplateapi/origin-cert.pem -noout -subject -enddate +``` + +4. First deploy: + +```bash +bin/kamal setup +``` + +Later deploys are `bin/kamal deploy`; `bin/kamal console`, `bin/kamal logs` and `bin/kamal dbc` are defined as aliases. + ## Contributing Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on reporting issues, proposing changes, and submitting pull requests. diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint index 67ef493..ed31659 100755 --- a/bin/docker-entrypoint +++ b/bin/docker-entrypoint @@ -1,7 +1,7 @@ #!/bin/bash -e # If running the rails server then create or migrate existing database -if [ "${1}" == "./bin/rails" ] && [ "${2}" == "server" ]; then +if [ "${@: -2:1}" == "./bin/rails" ] && [ "${@: -1:1}" == "server" ]; then ./bin/rails db:prepare fi diff --git a/bin/kamal b/bin/kamal new file mode 100755 index 0000000..d9ba276 --- /dev/null +++ b/bin/kamal @@ -0,0 +1,16 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'kamal' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("kamal", "kamal") diff --git a/bin/render-build.sh b/bin/render-build.sh deleted file mode 100755 index ba8689d..0000000 --- a/bin/render-build.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash - -# exit on error -set -o errexit - -while [ $# -gt 0 ] ; do - case $1 in - -s | --skip-migrations) SKIP_MIGRATE=true ;; - esac - shift -done - -bundle install -bundle exec rails assets:precompile -bundle exec rails assets:clean -[[ $SKIP_MIGRATE == true ]] || bundle exec rails db:migrate diff --git a/bin/render-seed.sh b/bin/render-seed.sh deleted file mode 100755 index 19f73d2..0000000 --- a/bin/render-seed.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash - -# exit on error -set -o errexit - -if [[ "${IS_PULL_REQUEST}" == "true" ]]; then - echo "IS_PULL_REQUEST is set. Setting staging environment variables and starting server." - export RAILS_ENV=staging - export RACK_ENV=staging - export RAILS_MASTER_KEY=${STAGING_RAILS_MASTER_KEY} - bundle exec rails db:seed_fu RAILS_ENV=staging -else - echo "IS_PULL_REQUEST is not set or is set to false. Setting production environment variables and starting server." - echo "Don't seed in production!" -fi diff --git a/bin/render-start.sh b/bin/render-start.sh deleted file mode 100755 index 8caad39..0000000 --- a/bin/render-start.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash - -# exit on error -set -o errexit - -export SOLID_QUEUE_IN_PUMA=${SOLID_QUEUE_IN_PUMA:-true} - -if [[ "${IS_PULL_REQUEST}" == "true" ]]; then - echo "IS_PULL_REQUEST is set. Setting staging environment variables and starting server." - export RAILS_ENV=staging - export RACK_ENV=staging - export RAILS_MASTER_KEY=${STAGING_RAILS_MASTER_KEY} - bundle exec rails s -e staging -else - echo "IS_PULL_REQUEST is not set or is set to false. Setting production environment variables and starting server." - export RAILS_ENV=production - export RACK_ENV=production - bundle exec rails s -e production -fi diff --git a/bin/thrust b/bin/thrust new file mode 100755 index 0000000..b1c45d1 --- /dev/null +++ b/bin/thrust @@ -0,0 +1,16 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'thrust' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("thruster", "thrust") diff --git a/config/application.rb b/config/application.rb index 547e78e..0d0f96c 100644 --- a/config/application.rb +++ b/config/application.rb @@ -45,6 +45,15 @@ class Application < Rails::Application # https://github.com/heartcombo/devise/issues/4825 config.wrap_parameters = false + # Cloudflare fronts the origin, so request.remote_ip must resolve past its + # edge to the caller (config/cloudflare_ips.yml explains what reads it and + # why: Rails' rate_limit keys on remote_ip). `+`, not `=`: assigning would + # drop the loopback and private ranges Rails trusts by default, and + # kamal-proxy reaches the container over exactly such an address. + config.action_dispatch.trusted_proxies = + ActionDispatch::RemoteIp::TRUSTED_PROXIES + + YAML.load_file(Rails.root.join("config/cloudflare_ips.yml")).values.flatten.map { |range| IPAddr.new(range) } + require "middleware/account_middleware" config.middleware.use AccountMiddleware diff --git a/config/cable.yml b/config/cable.yml index 8a83e49..fe1e6b3 100644 --- a/config/cable.yml +++ b/config/cable.yml @@ -4,14 +4,6 @@ development: test: adapter: test -staging: - adapter: solid_cable - connects_to: - database: - writing: cable - polling_interval: 0.1.seconds - message_retention: 1.day - production: adapter: solid_cable connects_to: diff --git a/config/cache.yml b/config/cache.yml index bd81f08..19d4908 100644 --- a/config/cache.yml +++ b/config/cache.yml @@ -11,10 +11,6 @@ development: test: <<: *default -staging: - database: cache - <<: *default - production: database: cache <<: *default diff --git a/config/cloudflare_ips.yml b/config/cloudflare_ips.yml new file mode 100644 index 0000000..420d800 --- /dev/null +++ b/config/cloudflare_ips.yml @@ -0,0 +1,47 @@ +# Cloudflare's published edge ranges, the proxy in front of this origin. +# +# Fetched 2026-10-10 from https://www.cloudflare.com/ips-v4 and +# https://www.cloudflare.com/ips-v6, verbatim. Re-fetch rather than edit by hand. +# +# config/application.rb adds these to config.action_dispatch.trusted_proxies so +# request.remote_ip resolves past the Cloudflare hop to the caller. Rails' +# rate_limit keys on remote_ip: without this, every caller behind one edge +# shares a single bucket (the API answers 429 to people who sent one request), +# and the sign-in limit stops being brute force protection (an attacker's +# attempts are spread across edges and never reach the limit). +# +# This is half of the fix. proxy.forward_headers: true in config/deploy.yml is +# the other half: kamal-proxy drops Cloudflare's X-Forwarded-For when ssl: is +# set, leaving no hop to resolve past. test/integration/trusted_proxies_test.rb +# asserts both halves. +# +# Why a list rather than CF-Connecting-IP: a header is only safe to trust if +# nothing but Cloudflare can reach the origin, and a request that does arrive +# directly is then attributed to its own address by this list (RemoteIp drops the +# trusted hops and keeps the rightmost untrusted one), not to whatever the sender +# wrote. Note 172.64.0.0/13 (172.64 to 172.71) is outside the private range +# 172.16.0.0/12 that Rails already trusts. +v4: + - 173.245.48.0/20 + - 103.21.244.0/22 + - 103.22.200.0/22 + - 103.31.4.0/22 + - 141.101.64.0/18 + - 108.162.192.0/18 + - 190.93.240.0/20 + - 188.114.96.0/20 + - 197.234.240.0/22 + - 198.41.128.0/17 + - 162.158.0.0/15 + - 104.16.0.0/13 + - 104.24.0.0/14 + - 172.64.0.0/13 + - 131.0.72.0/22 +v6: + - 2400:cb00::/32 + - 2606:4700::/32 + - 2803:f800::/32 + - 2405:b500::/32 + - 2405:8100::/32 + - 2a06:98c0::/29 + - 2c0f:f248::/32 diff --git a/config/credentials/staging.yml.enc b/config/credentials/staging.yml.enc deleted file mode 100644 index 7dae223..0000000 --- a/config/credentials/staging.yml.enc +++ /dev/null @@ -1 +0,0 @@ -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--PNOh893SsKp1IuXM--QsOBsWGH5actxGQhOleEJQ== \ No newline at end of file diff --git a/config/database.yml b/config/database.yml index 8b0ab40..f09cac8 100644 --- a/config/database.yml +++ b/config/database.yml @@ -45,29 +45,6 @@ test: <<: *default database: nativeapptemplateapi_test -staging: - primary: &primary_staging - <<: *default - database: nativeapptemplateapi_staging - username: nativeapptemplateapi - password: <%= ENV["NATIVEAPPTEMPLATEAPI_DATABASE_PASSWORD"] %> - url: <%= ENV["DATABASE_URL"] %> - cache: - <<: *primary_staging - database: nativeapptemplateapi_staging_cache - migrations_paths: db/cache_migrate - url: <%= ENV["CACHE_DATABASE_URL"] %> - queue: - <<: *primary_staging - database: nativeapptemplateapi_staging_queue - migrations_paths: db/queue_migrate - url: <%= ENV["QUEUE_DATABASE_URL"] %> - cable: - <<: *primary_staging - database: nativeapptemplateapi_staging_cable - migrations_paths: db/cable_migrate - url: <%= ENV["CABLE_DATABASE_URL"] %> - # As with config/credentials.yml, you never want to store sensitive information, # like your database password, in your source code. If your source code is # ever seen by anyone, they now have access to your database. @@ -77,25 +54,26 @@ staging: # for a full rundown on how to provide these environment variables in a # production deployment. # +# Kamal sets DB_HOST (the "db" accessory's container name) and POSTGRES_PASSWORD +# (from .kamal/secrets) in config/deploy.yml. The username and primary database +# must match the accessory's POSTGRES_USER / POSTGRES_DB there; the cache, queue +# and cable databases are created by db:prepare on first boot. production: primary: &primary_production <<: *default database: nativeapptemplateapi_production username: nativeapptemplateapi - password: <%= ENV['NATIVEAPPTEMPLATEAPI_DATABASE_PASSWORD'] %> - url: <%= ENV['DATABASE_URL'] %> + password: <%= ENV["POSTGRES_PASSWORD"] %> + host: <%= ENV["DB_HOST"] %> cache: <<: *primary_production database: nativeapptemplateapi_production_cache migrations_paths: db/cache_migrate - url: <%= ENV["CACHE_DATABASE_URL"] %> queue: <<: *primary_production database: nativeapptemplateapi_production_queue migrations_paths: db/queue_migrate - url: <%= ENV["QUEUE_DATABASE_URL"] %> cable: <<: *primary_production database: nativeapptemplateapi_production_cable migrations_paths: db/cable_migrate - url: <%= ENV["CABLE_DATABASE_URL"] %> diff --git a/config/deploy.yml b/config/deploy.yml new file mode 100644 index 0000000..2cb85cb --- /dev/null +++ b/config/deploy.yml @@ -0,0 +1,132 @@ +# Name of your application. Used to uniquely configure containers. +service: nativeapptemplateapi + +# Name of the container image: /. With the registry +# below the full name is ghcr.io/nativeapptemplate/nativeapptemplateapi. +image: nativeapptemplate/nativeapptemplateapi + +# Deploy to these servers. +servers: + web: + - 192.168.0.1 + # Jobs run inside Puma (SOLID_QUEUE_IN_PUMA below). When you outgrow one box, + # move them to a dedicated host and set SOLID_QUEUE_IN_PUMA: false. + # job: + # hosts: + # - 192.168.0.1 + # cmd: bin/jobs + +# TLS between Cloudflare and this origin uses a Cloudflare Origin Certificate +# (Cloudflare dashboard: SSL/TLS -> Origin Server -> Create Certificate; set the +# zone's SSL/TLS mode to "Full (strict)"). It is trusted only by Cloudflare, +# which is the point: the server's firewall should admit 443 from Cloudflare's +# ranges only, so nothing else ever reaches this origin. Nothing is validated to +# issue it and it is valid for up to 15 years, so there is no renewal to break. +# +# These settings are per app, not per proxy: several apps on one server each +# bring their own certificate and kamal-proxy routes by host. +# +# Without Cloudflare, use Let's Encrypt instead. It needs port 80 open to the +# world at issue time and at every 60-day renewal: +# proxy: +# ssl: true +# host: api.nativeapptemplate.com +proxy: + ssl: + # Both read from PEM files by .kamal/secrets. + certificate_pem: NATIVEAPPTEMPLATEAPI_ORIGIN_CERT + private_key_pem: NATIVEAPPTEMPLATEAPI_ORIGIN_KEY + host: api.nativeapptemplate.com + + # With ssl set, kamal-proxy stops forwarding X-Forwarded-For unless told to. + # Rails' rate_limit keys on request.remote_ip; without this every caller would + # look like the Cloudflare edge and share one bucket. config/cloudflare_ips.yml + # (trusted_proxies) is the other half: it lets Rails resolve past the edge. + forward_headers: true + + # kamal-proxy would otherwise answer every plain-HTTP request with a redirect, + # which loops while Cloudflare still reaches the origin over :80 (SSL/TLS mode + # "Flexible", or the few :80 requests "Full (strict)" still sends). Rails' + # force_ssl redirects on the scheme the visitor used (X-Forwarded-Proto), which + # kamal-proxy cannot see. + ssl_redirect: false + +# Credentials for your image host. +registry: + # GitHub Container Registry. + server: ghcr.io + username: nativeapptemplate + + # A GitHub Personal Access Token (classic) with the write:packages scope. + # Pulled from .kamal/secrets (never hard-code it here). + password: + - KAMAL_REGISTRY_PASSWORD + +# Configure builder setup. +builder: + # x86 servers (most VPS plans). From an Apple Silicon Mac this is a QEMU build, + # several times slower than native; the Dockerfile's `bootsnap precompile -j 1` + # works around a QEMU bug. ARM servers (Hetzner CAX, AWS Graviton) take + # `arm64`, which Apple Silicon builds natively. + arch: amd64 + +# Inject ENV variables into containers (secrets come from .kamal/secrets). +env: + secret: + - RAILS_MASTER_KEY + - POSTGRES_PASSWORD + clear: + # Run the Solid Queue supervisor inside Puma, including the recurring tasks in + # config/recurring.yml. No separate job container or cron. + SOLID_QUEUE_IN_PUMA: true + + # One Puma worker (RAILS_MAX_THREADS threads). Raise it on a box with spare cores. + WEB_CONCURRENCY: 1 + + # Postgres runs as the "db" accessory below. The app reaches it by container + # name ("-") over the private "kamal" Docker network, not + # the public IP, so the database is never exposed to the internet. + # config/database.yml reads this. + DB_HOST: nativeapptemplateapi-db + +# Aliases are triggered with "bin/kamal ". You can overwrite arguments on invocation: +# "bin/kamal logs -r job" will tail logs from the first server in the job section. +aliases: + console: app exec --interactive --reuse "bin/rails console" + shell: app exec --interactive --reuse "bash" + logs: app logs -f + dbc: app exec --interactive --reuse "bin/rails dbconsole" + +# Use a persistent storage volume for local Active Storage files. +volumes: + - "nativeapptemplateapi_storage:/rails/storage" + +# Bridge fingerprinted assets, like JS and CSS, between versions to avoid +# hitting 404 on in-flight requests. Combines all files from new and old +# version inside the asset_path. +asset_path: /rails/public/assets + +# Use accessory services (secrets come from .kamal/secrets). +accessories: + db: + # Same major as the CI service in .github/workflows/ci.yml. + image: postgres:18 + # Same server as the app. + host: 192.168.0.1 + # Publish only on the host's loopback interface, for server-side admin work + # (psql, pg_dump). The app does not use this; it connects over the Docker + # network (DB_HOST above). Without the 127.0.0.1 prefix Kamal would publish + # on 0.0.0.0 and expose Postgres publicly. Pick another host port if several + # apps with their own db accessory share one server. + port: "127.0.0.1:5432:5432" + env: + clear: + # Must match config/database.yml production: username + primary database. + # The cache/queue/cable databases are created by db:prepare on first boot. + POSTGRES_USER: nativeapptemplateapi + POSTGRES_DB: nativeapptemplateapi_production + secret: + - POSTGRES_PASSWORD + directories: + # postgres:18 images keep PGDATA under /var/lib/postgresql (not .../data). + - data:/var/lib/postgresql diff --git a/config/environments/production.rb b/config/environments/production.rb index 71c28fc..b6b10e5 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -36,8 +36,10 @@ # config.action_cable.allowed_request_origins = [ "http://example.com", /http:\/\/example.*/ ] # Assume all access to the app is happening through a SSL-terminating reverse proxy. - # Can be used together with config.force_ssl for Strict-Transport-Security and secure cookies. - # config.assume_ssl = true + # kamal-proxy terminates TLS and reaches the container over plain HTTP, so + # without this force_ssl would answer every request (including kamal-proxy's + # /up health check) with a redirect to https. + config.assume_ssl = true # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. config.force_ssl = true @@ -79,13 +81,13 @@ config.active_record.dump_schema_after_migration = false # Enable DNS rebinding protection and other `Host` header attacks. - # Render automatically sets RENDER_EXTERNAL_HOSTNAME to the service's hostname - # (e.g. nativeapptemplateapi.onrender.com). No manual configuration needed. - # APP_HOST is for the custom domain (set in Render env vars). + # The API domain is app.domain in config/settings.yml (also proxy.host in + # config/deploy.yml). APP_HOST admits one more hostname without a code change, + # e.g. while moving domains. config.hosts = [ - ENV["RENDER_EXTERNAL_HOSTNAME"], + ConfigSettings.app.domain, ENV["APP_HOST"] - ].compact + ].compact.uniq # Skip DNS rebinding protection for the default health check endpoint. config.host_authorization = {exclude: ->(request) { request.path == "/up" }} end diff --git a/config/environments/staging.rb b/config/environments/staging.rb deleted file mode 100644 index bec8207..0000000 --- a/config/environments/staging.rb +++ /dev/null @@ -1,109 +0,0 @@ -require "active_support/core_ext/integer/time" - -Rails.application.configure do - # Settings specified here will take precedence over those in config/application.rb. - - # Code is not reloaded between requests. - config.enable_reloading = false - - # Eager load code on boot for better performance and memory savings (ignored by Rake tasks). - config.eager_load = true - - # Full error reports are disabled. - config.consider_all_requests_local = false - - # Turn on fragment caching in view templates. - config.action_controller.perform_caching = true - - # Cache assets for far-future expiry since they are all digest stamped. - config.public_file_server.headers = {"cache-control" => "public, max-age=#{1.year.to_i}"} - - # Enable serving of images, stylesheets, and JavaScripts from an asset server. - # config.asset_host = "http://assets.example.com" - - # Compress CSS using a preprocessor. - config.assets.css_compressor = nil - - # Do not fallback to assets pipeline if a precompiled asset is missed. - config.assets.compile = false - - # Store uploaded files on the local file system (see config/storage.yml for options). - config.active_storage.service = :local - - # Mount Action Cable outside main process or domain. - # config.action_cable.mount_path = nil - # config.action_cable.url = 'wss://example.com/cable' - # config.action_cable.allowed_request_origins = [ 'http://example.com', /http:\/\/example.*/ ] - - # Assume all access to the app is happening through a SSL-terminating reverse proxy. - # Can be used together with config.force_ssl for Strict-Transport-Security and secure cookies. - # config.assume_ssl = true - - # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. - config.force_ssl = true - - # Skip http-to-https redirect for the default health check endpoint. - # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } - - # Log to STDOUT with the current request id as a default log tag. - config.log_tags = [:request_id] - config.logger = ActiveSupport::TaggedLogging.logger($stdout) - - # Change to "debug" to log everything (including potentially personally-identifiable information!) - config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") - - # Prevent health checks from clogging up the logs. - config.silence_healthcheck_path = "/up" - - # Don't log any deprecations. - config.active_support.report_deprecations = false - - # Use SolidQueue for Active Job (database-backed) - config.active_job.queue_adapter = :solid_queue - config.solid_queue.connects_to = {database: {writing: :queue}} - - # Use SolidCache for caching - config.cache_store = :solid_cache_store - - config.action_mailer.perform_caching = false - - # Ignore bad email addresses and do not raise email delivery errors. - # Set this to true and configure the email server for immediate delivery to raise delivery errors. - # config.action_mailer.raise_delivery_errors = false - - # Enable locale fallbacks for I18n (makes lookups for any locale fall back to - # the I18n.default_locale when a translation cannot be found). - config.i18n.fallbacks = true - - # Send deprecation notices to registered listeners. - config.active_support.deprecation = :notify - - # Do not dump schema after migrations. - config.active_record.dump_schema_after_migration = false - - # Enable DNS rebinding protection and other `Host` header attacks. - # config.hosts = [ - # "example.com", # Allow requests from example.com - # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` - # ] - # Skip DNS rebinding protection for the default health check endpoint. - # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } - - # https://www.cjav.dev/articles/setting-up-pr-previews-for-rails-on-render-com - service_name = ENV.fetch("RENDER_SERVICE_NAME", "nativeapptemplateapi-pr-101") - staging_host = ENV["RENDER_EXTERNAL_HOSTNAME"] - if staging_host.blank? - staging_host = "nativeapptemplateapi-#{service_name.match(/pr-\d+/)}.onrender.com" - end - staging_url = "https://#{staging_host}" - - puts "RENDER_EXTERNAL_HOSTNAME: #{ENV["RENDER_EXTERNAL_HOSTNAME"]}" - puts "RENDER_EXTERNAL_URL: #{ENV["RENDER_EXTERNAL_URL"]}" - puts "Using staging host #{staging_host} and staging url #{staging_url}" - - Rails.application.routes.default_url_options[:host] = staging_host - - config.action_controller.asset_host = staging_url - config.action_mailer.asset_host = staging_url - config.asset_host = staging_url -end diff --git a/config/initializers/mail.rb b/config/initializers/mail.rb index ca7ea36..9c164f0 100644 --- a/config/initializers/mail.rb +++ b/config/initializers/mail.rb @@ -2,11 +2,8 @@ Rails.application.reloader.to_prepare do ActionMailer::Base.default_options = {from: ConfigSettings.email.default_from} - if Rails.env.production? || Rails.env.staging? - # PR previews (staging) set their own Render host in staging.rb. Their emails - # must link back to that preview, whose tokens don't exist in production. - ActionMailer::Base.default_url_options[:host] = - Rails.application.routes.default_url_options[:host] || ConfigSettings.app.domain + if Rails.env.production? + ActionMailer::Base.default_url_options[:host] = ConfigSettings.app.domain ActionMailer::Base.default_url_options[:protocol] = "https" ActionMailer::Base.delivery_method = :resend diff --git a/config/queue.yml b/config/queue.yml index 917317c..9eace59 100644 --- a/config/queue.yml +++ b/config/queue.yml @@ -14,8 +14,5 @@ development: test: <<: *default -staging: - <<: *default - production: <<: *default diff --git a/config/recurring.yml b/config/recurring.yml index 7e1b1b8..a643a4c 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -8,8 +8,5 @@ default: &default command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)" schedule: every hour at minute 12 -staging: - <<: *default - production: <<: *default diff --git a/config/routes.rb b/config/routes.rb index f443a72..17db524 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,9 @@ Rails.application.routes.draw do + # Health check for kamal-proxy (and load balancers): 200 while the app boots + # and reaches the database, 500 otherwise. Exempt from host authorization and + # request logging in config/environments/production.rb. + get "up", to: "rails/health#show", as: :rails_health_check + draw :madmin require "admin_constraint" diff --git a/db/fixtures/staging/01_permissions.rb b/db/fixtures/staging/01_permissions.rb deleted file mode 100644 index ddb2852..0000000 --- a/db/fixtures/staging/01_permissions.rb +++ /dev/null @@ -1,9 +0,0 @@ -Permission.seed( - :tag, - {name: "create shops", tag: "create_shops", position: 1}, - {name: "update shops", tag: "update_shops", position: 2}, - {name: "delete shops", tag: "delete_shops", position: 3}, - {name: "update organizations", tag: "update_organizations", position: 4}, - {name: "invitation", tag: "invitation", position: 5}, - {name: "read data", tag: "read_data", position: 6} -) diff --git a/db/fixtures/staging/02_roles.rb b/db/fixtures/staging/02_roles.rb deleted file mode 100644 index 4d03ac4..0000000 --- a/db/fixtures/staging/02_roles.rb +++ /dev/null @@ -1,5 +0,0 @@ -Role.seed( - :tag, - {name: "admin", tag: "admin", position: 1}, - {name: "member", tag: "member", position: 2} -) diff --git a/db/fixtures/staging/03_roles_permissions.rb b/db/fixtures/staging/03_roles_permissions.rb deleted file mode 100644 index 8ed1845..0000000 --- a/db/fixtures/staging/03_roles_permissions.rb +++ /dev/null @@ -1,25 +0,0 @@ -admin_role = Role.find_by(tag: "admin") -member_role = Role.find_by(tag: "member") - -create_shops_permission = Permission.find_by(tag: "create_shops") -update_shops_permission = Permission.find_by(tag: "update_shops") -delete_shops_permission = Permission.find_by(tag: "delete_shops") -update_organizations_permission = Permission.find_by(tag: "update_organizations") -invitation_permission = Permission.find_by(tag: "invitation") -read_data_permission = Permission.find_by(tag: "read_data") - -RolesPermission.seed( - :role_id, :permission_id, - # admin: all permissions - {role_id: admin_role.id, permission_id: create_shops_permission.id}, - {role_id: admin_role.id, permission_id: update_shops_permission.id}, - {role_id: admin_role.id, permission_id: delete_shops_permission.id}, - {role_id: admin_role.id, permission_id: update_organizations_permission.id}, - {role_id: admin_role.id, permission_id: invitation_permission.id}, - {role_id: admin_role.id, permission_id: read_data_permission.id}, - # member: CRUD on shops + read_data (no organization management or invitation) - {role_id: member_role.id, permission_id: create_shops_permission.id}, - {role_id: member_role.id, permission_id: update_shops_permission.id}, - {role_id: member_role.id, permission_id: delete_shops_permission.id}, - {role_id: member_role.id, permission_id: read_data_permission.id} -) diff --git a/db/fixtures/staging/10_app_versions.rb b/db/fixtures/staging/10_app_versions.rb deleted file mode 100644 index 5e19e6b..0000000 --- a/db/fixtures/staging/10_app_versions.rb +++ /dev/null @@ -1,12 +0,0 @@ -require "csv" - -CSV.parse(File.readlines("db/data/app_versions.csv").drop(2).join) do |row| - AppVersion.seed(:platform, :version) do |app_version| - app_version.platform = row[0] - app_version.version = row[1].to_i - app_version.current_type = row[2].to_i - app_version.forced_update_type = row[3].to_i - app_version.title = row[4] - app_version.description = row[5] - end -end diff --git a/db/fixtures/staging/11_privacy_versions.rb b/db/fixtures/staging/11_privacy_versions.rb deleted file mode 100644 index c29b3c2..0000000 --- a/db/fixtures/staging/11_privacy_versions.rb +++ /dev/null @@ -1,11 +0,0 @@ -require "csv" - -CSV.parse(File.readlines("db/data/privacy_versions.csv").drop(2).join) do |row| - PrivacyVersion.seed(:version) do |privacy_version| - privacy_version.version = row[0].to_i - privacy_version.current_type = row[1].to_i - privacy_version.published_at = row[2] - privacy_version.title = row[3] - privacy_version.description = row[4] - end -end diff --git a/db/fixtures/staging/12_terms_versions.rb b/db/fixtures/staging/12_terms_versions.rb deleted file mode 100644 index 0d75ed7..0000000 --- a/db/fixtures/staging/12_terms_versions.rb +++ /dev/null @@ -1,11 +0,0 @@ -require "csv" - -CSV.parse(File.readlines("db/data/terms_versions.csv").drop(2).join) do |row| - TermsVersion.seed(:version) do |terms_version| - terms_version.version = row[0].to_i - terms_version.current_type = row[1].to_i - terms_version.published_at = row[2] - terms_version.title = row[3] - terms_version.description = row[4] - end -end diff --git a/docs/nativeapptemplate-substrate-v2-overview.md b/docs/nativeapptemplate-substrate-v2-overview.md index 51791c4..23a26be 100644 --- a/docs/nativeapptemplate-substrate-v2-overview.md +++ b/docs/nativeapptemplate-substrate-v2-overview.md @@ -496,7 +496,7 @@ The Rails API uses a custom fixture system in `db/fixtures//` (not standard ### 8.4 What to watch for (common pitfalls) -- Fixture files exist per-environment (`db/fixtures/{development,test,staging,production}/`). Changes must apply to all 4. +- Fixture files exist per-environment (`db/fixtures/{development,test,production}/`). Changes must apply to all 3. - `rolified.rb` concern may hard-code role tags — check before changing roles. - Shop model may reference ItemTag via `has_many` and instance methods (`reset!`, `latest_completed_item_tag`, `create_default_item_tags!`). Remove cleanly. - `full_reload_entire_page` on Shop uses `turbo_stream` — check all callers before removing. diff --git a/docs/phase1-rails-api.md b/docs/phase1-rails-api.md index fca6ff9..2070fed 100644 --- a/docs/phase1-rails-api.md +++ b/docs/phase1-rails-api.md @@ -553,7 +553,7 @@ git commit -m "Update madmin resource for new ItemTag schema" ### 12.1 Edit fixture files -For each environment (`development`, `test`, `staging`, `production`): +For each environment (`development`, `test`, `production`): **`db/fixtures//01_permissions.rb`** — replace with: diff --git a/render.yaml b/render.yaml deleted file mode 100644 index 9aaf2c8..0000000 --- a/render.yaml +++ /dev/null @@ -1,66 +0,0 @@ -# Example Render configuration. You will need to adjust this for the different services you run. -previews: - generation: manual - expireAfterDays: 3 -services: - - type: web - repo: https://github.com/nativeapptemplate/nativeapptemplateapi - name: nativeapptemplateapi - plan: standard - previews: - plan: standard - env: ruby - region: singapore # the region must be consistent across all services for the internal keys to be read - buildCommand: "./bin/render-build.sh" - startCommand: "./bin/render-start.sh" - initialDeployHook: "./bin/render-seed.sh" - envVars: - - key: RAILS_MASTER_KEY - sync: false - - key: WEB_CONCURRENCY - value: 1 - - key: SOLID_QUEUE_IN_PUMA - value: true - - key: DATABASE_URL - fromDatabase: - name: nativeapptemplateapi - property: connectionString - - key: CACHE_DATABASE_URL - fromDatabase: - name: nativeapptemplateapi-cache - property: connectionString - - key: QUEUE_DATABASE_URL - fromDatabase: - name: nativeapptemplateapi-queue - property: connectionString - - key: CABLE_DATABASE_URL - fromDatabase: - name: nativeapptemplateapi-cable - property: connectionString - - fromGroup: nativeapptemplateapi-env - -databases: - - name: nativeapptemplateapi - plan: basic-256mb - previewPlan: basic-256mb - region: singapore # the region must be consistent across all services for the internal keys to be read - ipAllowList: [] # only allow internal connections - diskSizeGB: 5 - - - name: nativeapptemplateapi-cache - plan: basic-256mb - previewPlan: basic-256mb - region: singapore - ipAllowList: [] - - - name: nativeapptemplateapi-queue - plan: basic-256mb - previewPlan: basic-256mb - region: singapore - ipAllowList: [] - - - name: nativeapptemplateapi-cable - plan: basic-256mb - previewPlan: basic-256mb - region: singapore - ipAllowList: [] diff --git a/test/config/deploy_config_test.rb b/test/config/deploy_config_test.rb new file mode 100644 index 0000000..ea5e73a --- /dev/null +++ b/test/config/deploy_config_test.rb @@ -0,0 +1,95 @@ +require "test_helper" + +# config/deploy.yml, .kamal/secrets, config/database.yml and the Dockerfile +# describe one deployment between them, and nothing checks them against each +# other until the first `kamal deploy`. These checks keep the halves in step. +class DeployConfigTest < ActiveSupport::TestCase + DEPLOY = YAML.load_file(Rails.root.join("config/deploy.yml")) + SERVICE = "nativeapptemplateapi" + + # The accessory boots Postgres with POSTGRES_USER / POSTGRES_DB; Active Record + # connects with database.yml's production username / database. They must agree + # or the first db:prepare fails with "role does not exist". + test "db accessory creates the role and database that database.yml connects to" do + db = ActiveRecord::Base.configurations.configs_for(env_name: "production", name: "primary").configuration_hash + accessory_env = DEPLOY.dig("accessories", "db", "env", "clear") + + assert_equal db[:username], accessory_env["POSTGRES_USER"] + assert_equal db[:database], accessory_env["POSTGRES_DB"] + end + + # Kamal names an accessory container "-" and the app + # reaches it by that name on the kamal Docker network (Kamal docs: Accessories). + test "DB_HOST is the db accessory's container name" do + assert_equal "#{DEPLOY["service"]}-db", DEPLOY.dig("env", "clear", "DB_HOST") + assert_equal SERVICE, DEPLOY["service"] + end + + # database.yml reads POSTGRES_PASSWORD and DB_HOST; both must arrive in the + # app container, and the accessory needs the same password. + test "app and db accessory receive the Postgres password" do + assert_includes DEPLOY.dig("env", "secret"), "POSTGRES_PASSWORD" + assert_includes DEPLOY.dig("env", "secret"), "RAILS_MASTER_KEY" + assert_includes DEPLOY.dig("accessories", "db", "env", "secret"), "POSTGRES_PASSWORD" + end + + # Kamal resolves every env.secret, registry password and accessory secret from + # .kamal/secrets; an unknown name aborts the deploy. + test ".kamal/secrets defines every secret deploy.yml references" do + defined = File.readlines(Rails.root.join(".kamal/secrets")).filter_map { |line| line[/\A([A-Z0-9_]+)=/, 1] } + referenced = DEPLOY.dig("env", "secret") + DEPLOY.dig("registry", "password") + + DEPLOY.dig("accessories", "db", "env", "secret") + DEPLOY.dig("proxy", "ssl").values + + assert_empty referenced - defined + end + + test "images are pushed to GitHub Container Registry" do + assert_equal "ghcr.io", DEPLOY.dig("registry", "server") + assert_match %r{\A[\w-]+/#{SERVICE}\z}, DEPLOY["image"] + end + + # Jobs and config/recurring.yml run inside Puma; without this flag and with no + # job server, nothing would process the queue. + test "Solid Queue runs in Puma because there is no job server" do + assert_equal true, DEPLOY.dig("env", "clear", "SOLID_QUEUE_IN_PUMA") + assert_nil DEPLOY.dig("servers", "job") + end + + # proxy.host is what kamal-proxy routes and gets a certificate for; Rails + # rejects any other Host header (config.hosts in production.rb). + test "proxy host is the API domain Rails accepts" do + assert_equal ConfigSettings.app.domain, DEPLOY.dig("proxy", "host") + end + + # Active Storage's :local service writes under storage/ (config/storage.yml), + # which is /rails/storage in the image; without a volume uploads vanish on deploy. + test "Active Storage files live on a persistent volume" do + assert_includes DEPLOY["volumes"], "#{SERVICE}_storage:/rails/storage" + end + + # The Postgres accessory only publishes a host port for server-side admin work; + # a bare "5432:5432" would bind 0.0.0.0 and expose the database. + test "db accessory is published on loopback only" do + assert_match %r{\A127\.0\.0\.1:\d+:5432\z}, DEPLOY.dig("accessories", "db", "port") + end + + # The image must match the server's CPU, or the container dies with "exec + # format error" after a successful push. Kamal's default and most VPS plans are + # x86; a deliberate change to arm64 for an ARM box should update this too. + test "image is built for x86 servers" do + assert_equal "amd64", DEPLOY.dig("builder", "arch") + end + + test "Dockerfile builds the Ruby in .ruby-version" do + ruby_version = Rails.root.join(".ruby-version").read.strip + dockerfile = Rails.root.join("Dockerfile").read + + assert_includes dockerfile, "ARG RUBY_VERSION=#{ruby_version}" + end + + # bin/setup and CI install gems into vendor/bundle for the host platform; copying + # them into the image breaks `bundle install` there and bloats the image. + test "Docker build context excludes vendor/bundle" do + assert_includes Rails.root.join(".dockerignore").readlines.map(&:strip), "/vendor/bundle" + end +end diff --git a/test/config/mailer_host_test.rb b/test/config/mailer_host_test.rb index dca28e0..aa08158 100644 --- a/test/config/mailer_host_test.rb +++ b/test/config/mailer_host_test.rb @@ -1,20 +1,11 @@ require "test_helper" require "open3" -# Confirmation and password reset emails link back to the API host. A PR -# preview (staging) must link to itself: its tokens don't exist in production. +# Confirmation and password reset emails link back to the API host. # -# Booting another environment needs its credentials key, which only developer -# machines have, so this skips in CI. +# Booting the production environment needs its credentials key, which only +# developer machines have, so this skips in CI. class MailerHostTest < ActiveSupport::TestCase - PREVIEW_HOST = "nativeapptemplateapi-pr-999.onrender.com" - - test "staging emails link to the preview's own host" do - skip "needs config/credentials/staging.key" unless Rails.root.join("config/credentials/staging.key").exist? - - assert_equal({host: PREVIEW_HOST, protocol: "https"}, mailer_url_options("staging", "RENDER_EXTERNAL_HOSTNAME" => PREVIEW_HOST)) - end - test "production emails link to the production API domain" do skip "needs config/credentials/production.key" unless Rails.root.join("config/credentials/production.key").exist? @@ -23,14 +14,14 @@ class MailerHostTest < ActiveSupport::TestCase private - def mailer_url_options(env, extra_env = {}) + def mailer_url_options(env) db = ActiveRecord::Base.connection_db_config.configuration_hash db_url = "postgres://#{db[:username] || ENV.fetch("USER")}@#{db[:host] || "localhost"}/#{db[:database]}" vars = { "RAILS_ENV" => env, "DATABASE_URL" => db_url, "CACHE_DATABASE_URL" => db_url, "QUEUE_DATABASE_URL" => db_url, "CABLE_DATABASE_URL" => db_url - }.merge(extra_env) + } out, status = Open3.capture2e(vars, "bin/rails", "runner", "puts ActionMailer::Base.default_url_options.to_json", chdir: Rails.root) assert status.success?, out diff --git a/test/config/recurring_tasks_test.rb b/test/config/recurring_tasks_test.rb index e93fc8d..cff62f4 100644 --- a/test/config/recurring_tasks_test.rb +++ b/test/config/recurring_tasks_test.rb @@ -10,14 +10,12 @@ class RecurringTasksTest < ActiveSupport::TestCase CONFIG = ActiveSupport::ConfigurationFile.parse(Rails.root.join("config/recurring.yml")) - %w[production staging].each do |env| - test "#{env} clears finished Solid Queue jobs" do - options = CONFIG.dig(env, "clear_solid_queue_finished_jobs") - assert options, "config/recurring.yml has no clear_solid_queue_finished_jobs task for #{env}" + test "production clears finished Solid Queue jobs" do + options = CONFIG.dig("production", "clear_solid_queue_finished_jobs") + assert options, "config/recurring.yml has no clear_solid_queue_finished_jobs task for production" - assert_equal "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)", options["command"] - assert_includes SolidQueue::Job.method(:clear_finished_in_batches).parameters, [:key, :sleep_between_batches] - end + assert_equal "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)", options["command"] + assert_includes SolidQueue::Job.method(:clear_finished_in_batches).parameters, [:key, :sleep_between_batches] end test "every recurring task has a command and a supported schedule" do diff --git a/test/integration/health_check_test.rb b/test/integration/health_check_test.rb new file mode 100644 index 0000000..e56d612 --- /dev/null +++ b/test/integration/health_check_test.rb @@ -0,0 +1,12 @@ +require "test_helper" + +# kamal-proxy only routes traffic to a container once GET /up answers 2xx/3xx +# (Kamal's default health check path), and config/environments/production.rb +# already exempts /up from host authorization and request logging. +class HealthCheckTest < ActionDispatch::IntegrationTest + test "GET /up answers 200 without authentication" do + get "/up" + + assert_response :ok + end +end diff --git a/test/integration/trusted_proxies_test.rb b/test/integration/trusted_proxies_test.rb new file mode 100644 index 0000000..2f9dc94 --- /dev/null +++ b/test/integration/trusted_proxies_test.rb @@ -0,0 +1,118 @@ +require "test_helper" + +# The throttles must count the caller, not the Cloudflare edge in front of this +# origin (config/application.rb, config/cloudflare_ips.yml). Rails' rate_limit +# keys on request.remote_ip, which trusted_proxies resolves past the edge. +# +# Asserted through 429s rather than by reading the config back: with the config +# absent everything still "works", it just counts the wrong party. What breaks +# is who gets locked out. +class TrustedProxiesTest < ActionDispatch::IntegrationTest + # Two Cloudflare edge addresses in different /24s of 172.64.0.0/13. + TOKYO_EDGE = "172.64.213.30".freeze + OTHER_EDGE = "172.71.24.21".freeze + + # Two callers, neither of them a Cloudflare address. + ALICE = "118.241.68.237".freeze + BOB = "203.0.113.9".freeze + + SIGN_IN = "/shopkeeper_auth/sign_in".freeze + + # Cloudflare sets X-Forwarded-For to the caller; kamal-proxy appends the + # address it received the connection from, which is Cloudflare's. + # + # The email is unique per attempt on purpose: `logins/email` also throttles + # five per twenty seconds, and reusing one would trip that rule instead. + def attempt_sign_in(caller_ip:, edge:) + post SIGN_IN, + params: {email: "#{SecureRandom.hex(6)}@example.com", password: "wrong-password"}, + headers: {"HTTP_X_FORWARDED_FOR" => "#{caller_ip}, #{edge}", + "REMOTE_ADDR" => edge} + response.status + end + + # `logins/ip` allows five attempts per twenty seconds (ShopkeeperAuth::SessionsController). + # Counted against the edge, five sign-ins from anyone behind it lock out everybody else behind it. + test "two callers behind one Cloudflare edge do not share a login bucket" do + 5.times { assert_not_equal 429, attempt_sign_in(caller_ip: ALICE, edge: TOKYO_EDGE) } + + assert_not_equal 429, attempt_sign_in(caller_ip: BOB, edge: TOKYO_EDGE), + "Bob's first attempt was refused because Alice used the allowance behind the same edge" + end + + # Brute force protection has to survive the attacker being routed through + # different edges, which Cloudflare does for them. + test "one caller is throttled even when routed through two Cloudflare edges" do + 3.times { attempt_sign_in(caller_ip: ALICE, edge: TOKYO_EDGE) } + 2.times { attempt_sign_in(caller_ip: ALICE, edge: OTHER_EDGE) } + + assert_equal 429, attempt_sign_in(caller_ip: ALICE, edge: OTHER_EDGE), + "the sixth attempt from one caller must be refused however Cloudflare routed it" + end + + # rate_limit's default `by:` is request.remote_ip, which RemoteIp resolves with + # trusted_proxies. Rack's own Request#ip ignores trusted_proxies and stops at the edge. + test "the RemoteIp middleware resolves past the Cloudflare edge to the caller" do + request = ActionDispatch::Request.new("REMOTE_ADDR" => TOKYO_EDGE, "HTTP_X_FORWARDED_FOR" => "#{ALICE}, #{TOKYO_EDGE}") + trusted = Rails.application.config.action_dispatch.trusted_proxies + + assert_equal ALICE, ActionDispatch::RemoteIp::GetIp.new(request, false, trusted).to_s + assert_equal TOKYO_EDGE, request.ip, "Rack's own ip still stops at the edge" + end + + # Rails already trusts the private range 172.16.0.0/12 (172.16 to 172.31). + # Cloudflare's 172.64.0.0/13 (172.64 to 172.71) looks covered and is not. + test "Cloudflare's 172.64.0.0/13 is trusted and Rails' private 172.16.0.0/12 does not cover it" do + trusted = Rails.application.config.action_dispatch.trusted_proxies + + assert trusted.any? { |proxy| proxy.include?(TOKYO_EDGE) }, "a Cloudflare edge address must be trusted" + assert_not IPAddr.new("172.16.0.0/12").include?(TOKYO_EDGE), + "if this ever passes, the private range grew and the near miss stopped being one" + end + + # `trusted_proxies =` replaces rather than appends, and kamal-proxy reaches the + # container over a private Docker address, so dropping the defaults would + # break the hop this exists to see past. + test "the private and loopback defaults are still trusted" do + trusted = Rails.application.config.action_dispatch.trusted_proxies + + ["127.0.0.1", "10.1.2.3", "192.168.1.1", "172.17.0.2"].each do |address| + assert trusted.any? { |proxy| proxy.include?(address) }, + "#{address} is a default Rails trusts, and replacing the list would have dropped it" + end + end + + # The half that lives outside the app. kamal-proxy stops forwarding + # X-Forwarded-For when `ssl:` is set, so without this the caller's address never + # reaches Rails and every test above still passes while production throttles Cloudflare. + test "kamal-proxy is told to forward headers, or everything above this is inert" do + proxy = YAML.safe_load_file(Rails.root.join("config/deploy.yml")).fetch("proxy") + + assert proxy["ssl"], "if ssl is ever removed, re-read kamal's default: forward_headers flips with it" + assert_equal true, proxy["forward_headers"] + end + + # Kamal docs: "By default, kamal-proxy will redirect all HTTP requests to HTTPS + # when SSL is enabled." While Cloudflare reaches the origin over :80 (SSL/TLS + # mode Flexible, or the residual :80 traffic under Full (strict)) that is an + # infinite redirect. Asserted as false, not merely "not true": with the key + # omitted kamal sends no flag and kamal-proxy applies its default. + test "kamal-proxy is told not to redirect HTTP to HTTPS" do + proxy = YAML.safe_load_file(Rails.root.join("config/deploy.yml")).fetch("proxy") + + assert_equal false, proxy["ssl_redirect"], "absent is not false here" + end + + # `ssl: true` would have kamal-proxy run an ACME challenge on :80, which a + # Cloudflare-only firewall blocks and "Always Use HTTPS" redirects away; it + # would fail at renewal, not at deploy time. + test "TLS uses the Cloudflare Origin certificate from secrets" do + ssl = YAML.safe_load_file(Rails.root.join("config/deploy.yml")).fetch("proxy").fetch("ssl") + secrets = File.read(Rails.root.join(".kamal/secrets")) + + assert_equal "NATIVEAPPTEMPLATEAPI_ORIGIN_CERT", ssl["certificate_pem"] + assert_equal "NATIVEAPPTEMPLATEAPI_ORIGIN_KEY", ssl["private_key_pem"] + assert_match(/^NATIVEAPPTEMPLATEAPI_ORIGIN_CERT=\$\(cat /, secrets, "the certificate must be read from a file") + assert_match(/^NATIVEAPPTEMPLATEAPI_ORIGIN_KEY=\$\(cat /, secrets, "the key must be read from a file") + end +end