diff --git a/.github/workflows/smoke-mastodon-strict.yml b/.github/workflows/smoke-mastodon-strict.yml new file mode 100644 index 000000000..05d5802dd --- /dev/null +++ b/.github/workflows/smoke-mastodon-strict.yml @@ -0,0 +1,105 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json +# +# Strict-mode interoperability smoke tests (HTTPS + HTTP signature verification). +# Uses a standalone Docker Compose file with Caddy TLS proxies to verify that +# Fedify correctly signs and verifies requests over HTTPS. +# See: https://github.com/fedify-dev/fedify/issues/481 +name: smoke-mastodon-strict + +on: + schedule: + - cron: "0 6 * * *" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + smoke: + runs-on: ubuntu-latest + timeout-minutes: 25 + + env: + COMPOSE: >- + docker compose + -f test/smoke/mastodon/docker-compose.strict.yml + + steps: + - uses: actions/checkout@v4 + + - uses: ./.github/actions/setup-mise + + - name: Generate TLS certificates + run: bash test/smoke/mastodon/generate-certs.sh test/smoke/mastodon/.certs + + - name: Verify certificates + run: | + openssl verify -CAfile test/smoke/mastodon/.certs/ca.crt \ + test/smoke/mastodon/.certs/fedify-harness.crt + openssl verify -CAfile test/smoke/mastodon/.certs/ca.crt \ + test/smoke/mastodon/.certs/mastodon.crt + + - name: Generate Mastodon secrets + run: | + IMAGE=ghcr.io/mastodon/mastodon:v4.3.9 + docker pull "$IMAGE" + + SECRET1=$(docker run --rm "$IMAGE" bundle exec rails secret) + SECRET2=$(docker run --rm "$IMAGE" bundle exec rails secret) + + { + echo "SECRET_KEY_BASE=$SECRET1" + echo "OTP_SECRET=$SECRET2" + docker run --rm "$IMAGE" bundle exec rails mastodon:webpush:generate_vapid_key \ + | grep -E '^[A-Z_]+=.+' + docker run --rm "$IMAGE" bundle exec rails db:encryption:init \ + | grep -E '^[A-Z_]+=.+' + } >> test/smoke/mastodon/mastodon-strict.env + + - name: Start database and redis + run: | + $COMPOSE up -d db redis + $COMPOSE exec -T db \ + sh -c 'until pg_isready -U mastodon; do sleep 1; done' + + - name: Run DB setup and migrations + run: | + $COMPOSE run --rm -T \ + mastodon-web-backend bundle exec rails db:setup + timeout-minutes: 5 + + - name: Start Mastodon stack + run: $COMPOSE up --wait + timeout-minutes: 12 + + - name: Provision Mastodon + run: bash test/smoke/mastodon/provision-strict.sh + + - name: Verify connectivity + run: | + echo "=== Harness health (from mastodon-web-backend, via Caddy TLS) ===" + $COMPOSE exec -T mastodon-web-backend \ + curl -sf https://fedify-harness/_test/health + echo " OK" + + echo "=== Harness health (from mastodon-sidekiq, via Caddy TLS) ===" + $COMPOSE exec -T mastodon-sidekiq \ + curl -sf https://fedify-harness/_test/health + echo " OK" + + - name: Run smoke tests + run: | + set -a && source test/smoke/.env.test && set +a + deno run --allow-net --allow-env --unstable-temporal \ + test/smoke/orchestrator.ts + + - name: Collect logs on failure + if: failure() + run: | + echo "=== Docker Compose logs ===" + $COMPOSE logs --tail=500 + + - name: Teardown + if: always() + run: $COMPOSE down -v diff --git a/.gitignore b/.gitignore index 006d3b532..dad1a3cfd 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,9 @@ node_modules/ package-lock.json repomix-output.xml test/smoke/.env.test +test/smoke/mastodon/.certs/ test/smoke/mastodon/mastodon.env +test/smoke/mastodon/mastodon-strict.env smoke.log t.ts t2.ts diff --git a/test/smoke/harness/backdoor.ts b/test/smoke/harness/backdoor.ts index 316e826c2..091ff8192 100644 --- a/test/smoke/harness/backdoor.ts +++ b/test/smoke/harness/backdoor.ts @@ -9,14 +9,15 @@ function json(data: unknown, status = 200): Response { }); } -// Build recipient manually — Mastodon's WebFinger requires HTTPS but our -// harness only has HTTP. Parse the handle (user@domain) to construct the -// actor URI and inbox URL directly. +// Build recipient manually — in non-strict mode Mastodon's WebFinger requires +// HTTPS but our harness only has HTTP, so we use http:// for the inbox URL. +// In strict mode, Caddy terminates TLS, so we use https:// everywhere. function parseRecipient( handle: string, ): { inboxId: URL; actorId: URL } { const [user, domain] = handle.split("@"); - const inboxId = new URL(`http://${domain}/users/${user}/inbox`); + const scheme = Deno.env.get("STRICT_MODE") ? "https" : "http"; + const inboxId = new URL(`${scheme}://${domain}/users/${user}/inbox`); // Mastodon generates https:// actor URIs; use that as the canonical id const actorId = new URL(`https://${domain}/users/${user}`); return { inboxId, actorId }; diff --git a/test/smoke/harness/federation.ts b/test/smoke/harness/federation.ts index 5332681e1..25ecafb22 100644 --- a/test/smoke/harness/federation.ts +++ b/test/smoke/harness/federation.ts @@ -12,7 +12,7 @@ const federation = createFederation({ kv: new MemoryKvStore(), origin: ORIGIN, allowPrivateAddress: true, - skipSignatureVerification: true, + skipSignatureVerification: !Deno.env.get("STRICT_MODE"), }); federation @@ -48,12 +48,15 @@ federation if (!ctx.recipient || !followerUri) return; // Build the recipient manually instead of calling getActor(), because - // Mastodon generates https:// actor URIs but only serves HTTP. - // Rewrite the scheme so sendActivity POSTs over plain HTTP. - const httpActorUri = followerUri.href.replace(/^https:\/\//, "http://"); + // in non-strict mode Mastodon generates https:// actor URIs but only + // serves HTTP. In strict mode the Caddy proxy handles TLS, so we + // keep the original https:// scheme. + const actorUri = Deno.env.get("STRICT_MODE") + ? followerUri.href + : followerUri.href.replace(/^https:\/\//, "http://"); const recipient = { id: followerUri, - inboxId: new URL(`${httpActorUri}/inbox`), + inboxId: new URL(`${actorUri}/inbox`), }; const accept = new Accept({ diff --git a/test/smoke/mastodon/Caddyfile.fedify-harness b/test/smoke/mastodon/Caddyfile.fedify-harness new file mode 100644 index 000000000..b8f1ca64b --- /dev/null +++ b/test/smoke/mastodon/Caddyfile.fedify-harness @@ -0,0 +1,8 @@ +{ + auto_https off +} + +:443 { + tls /certs/fedify-harness.crt /certs/fedify-harness.key + reverse_proxy fedify-harness-backend:3001 +} diff --git a/test/smoke/mastodon/Caddyfile.mastodon b/test/smoke/mastodon/Caddyfile.mastodon new file mode 100644 index 000000000..86dc74d4f --- /dev/null +++ b/test/smoke/mastodon/Caddyfile.mastodon @@ -0,0 +1,8 @@ +{ + auto_https off +} + +:443 { + tls /certs/mastodon.crt /certs/mastodon.key + reverse_proxy mastodon-web-backend:3000 +} diff --git a/test/smoke/mastodon/docker-compose.strict.yml b/test/smoke/mastodon/docker-compose.strict.yml new file mode 100644 index 000000000..70bfa096f --- /dev/null +++ b/test/smoke/mastodon/docker-compose.strict.yml @@ -0,0 +1,159 @@ +# Standalone Docker Compose for strict-mode smoke tests (HTTPS + signatures). +# Usage: docker compose -f docker-compose.strict.yml ... +# +# This is a standalone file (NOT an override) because Docker Compose merges +# network aliases additively and cannot remove a service's own DNS name from +# a network. Using an override would cause both the backend service and its +# Caddy proxy to resolve to the same hostname, breaking TLS routing. +# +# Architecture: +# - Backend services are renamed (e.g. fedify-harness-backend) so they +# don't collide with the TLS hostnames on the network +# - Caddy proxies claim the canonical hostnames (fedify-harness, mastodon) +# via network aliases and terminate TLS +# - All services share a single network for simplicity; DNS resolution +# is unambiguous because backend names differ from Caddy aliases + +volumes: + harness-node-modules: + +networks: + smoke: + driver: bridge + +services: + db: + image: postgres:15-alpine + environment: + POSTGRES_DB: mastodon + POSTGRES_USER: mastodon + POSTGRES_PASSWORD: mastodon + networks: [smoke] + healthcheck: + test: ["CMD", "pg_isready", "-U", "mastodon"] + interval: 5s + retries: 10 + + redis: + image: redis:7-alpine + networks: [smoke] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + retries: 10 + + # Fedify test harness — renamed to avoid colliding with the Caddy alias. + fedify-harness-backend: + image: denoland/deno:2.7.1 + working_dir: /workspace + volumes: + - ../../../:/workspace + - harness-node-modules:/workspace/node_modules + - ./.certs:/certs:ro + command: + - run + - --allow-net + - --allow-env + - --allow-read + - --allow-write + - --unstable-temporal + - test/smoke/harness/main.ts + environment: + HARNESS_ORIGIN: "https://fedify-harness" + STRICT_MODE: "1" + DENO_CERT: "/certs/ca.crt" + networks: [smoke] + ports: ["3001:3001"] + healthcheck: + test: + [ + "CMD", + "deno", + "eval", + "const r = await fetch('http://localhost:3001/_test/health'); if (!r.ok) Deno.exit(1);", + ] + interval: 5s + retries: 30 + + # Caddy TLS proxy for the Fedify harness. + # Owns the "fedify-harness" hostname so other containers reach TLS. + caddy-harness: + image: caddy:2.11.2-alpine + volumes: + - ./Caddyfile.fedify-harness:/etc/caddy/Caddyfile:ro + - ./.certs:/certs:ro + networks: + smoke: + aliases: [fedify-harness] + depends_on: + fedify-harness-backend: { condition: service_healthy } + healthcheck: + test: ["CMD", "caddy", "version"] + interval: 5s + retries: 5 + + # Mastodon web — renamed to avoid colliding with the Caddy alias. + mastodon-web-backend: + image: ghcr.io/mastodon/mastodon:v4.3.9 + command: + - bash + - -c + - | + cat /usr/lib/ssl/cert.pem /certs/ca.crt > /tmp/ca-bundle.crt + bundle exec rails s -p 3000 -b 0.0.0.0 + env_file: mastodon-strict.env + environment: + SSL_CERT_FILE: /tmp/ca-bundle.crt + volumes: + - ./disable_force_ssl.rb:/opt/mastodon/config/initializers/zz_disable_force_ssl.rb:ro + - ./.certs:/certs:ro + networks: [smoke] + ports: ["3000:3000"] + depends_on: + db: { condition: service_healthy } + redis: { condition: service_healthy } + healthcheck: + test: + [ + "CMD-SHELL", + "curl -sf http://localhost:3000/health | grep -q OK", + ] + interval: 10s + retries: 18 + + # Caddy TLS proxy for Mastodon. + # Owns the "mastodon" hostname so other containers reach TLS. + caddy-mastodon: + image: caddy:2.11.2-alpine + volumes: + - ./Caddyfile.mastodon:/etc/caddy/Caddyfile:ro + - ./.certs:/certs:ro + networks: + smoke: + aliases: [mastodon] + ports: ["4443:443"] + depends_on: + mastodon-web-backend: { condition: service_healthy } + healthcheck: + test: ["CMD", "caddy", "version"] + interval: 5s + retries: 5 + + mastodon-sidekiq: + image: ghcr.io/mastodon/mastodon:v4.3.9 + command: + - bash + - -c + - | + cat /usr/lib/ssl/cert.pem /certs/ca.crt > /tmp/ca-bundle.crt + bundle exec sidekiq -q ingress -q default -q push + env_file: mastodon-strict.env + environment: + SSL_CERT_FILE: /tmp/ca-bundle.crt + volumes: + - ./disable_force_ssl.rb:/opt/mastodon/config/initializers/zz_disable_force_ssl.rb:ro + - ./.certs:/certs:ro + networks: [smoke] + depends_on: + mastodon-web-backend: { condition: service_healthy } + caddy-harness: { condition: service_healthy } diff --git a/test/smoke/mastodon/generate-certs.sh b/test/smoke/mastodon/generate-certs.sh new file mode 100755 index 000000000..98e4c4838 --- /dev/null +++ b/test/smoke/mastodon/generate-certs.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Generate a self-signed CA and leaf certificates for strict-mode smoke tests. +# Usage: bash generate-certs.sh [output-dir] +# +# Output directory defaults to .certs/ (relative to this script). +# The CA is ephemeral — generated fresh each run. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +OUT="${1:-$SCRIPT_DIR/.certs}" +mkdir -p "$OUT" + +HOSTS=(fedify-harness mastodon) + +echo "→ Generating CA key + certificate..." +openssl genrsa -out "$OUT/ca.key" 2048 2>/dev/null +openssl req -x509 -new -nodes \ + -key "$OUT/ca.key" \ + -sha256 -days 1 \ + -subj "/CN=Smoke Test CA" \ + -out "$OUT/ca.crt" 2>/dev/null + +for HOST in "${HOSTS[@]}"; do + echo "→ Generating certificate for $HOST..." + openssl genrsa -out "$OUT/$HOST.key" 2048 2>/dev/null + openssl req -new \ + -key "$OUT/$HOST.key" \ + -subj "/CN=$HOST" \ + -out "$OUT/$HOST.csr" 2>/dev/null + + # Create a SAN extension config so the cert is valid for the hostname + cat > "$OUT/$HOST.ext" </dev/null + + rm -f "$OUT/$HOST.csr" "$OUT/$HOST.ext" +done + +rm -f "$OUT/ca.srl" + +echo "✓ Certificates written to $OUT/" +ls -la "$OUT" diff --git a/test/smoke/mastodon/mastodon-strict.env b/test/smoke/mastodon/mastodon-strict.env new file mode 100644 index 000000000..3219bb72c --- /dev/null +++ b/test/smoke/mastodon/mastodon-strict.env @@ -0,0 +1,24 @@ +# Mastodon configuration for strict-mode smoke tests (HTTPS). +# SECRET_KEY_BASE, OTP_SECRET, VAPID_*, and ACTIVE_RECORD_ENCRYPTION_* +# are appended by CI (see .github/workflows/smoke-mastodon-strict.yml). + +LOCAL_DOMAIN=mastodon +ALTERNATE_DOMAINS=localhost:3000,localhost:4443 +LOCAL_HTTPS=true +RAILS_ENV=production +DB_HOST=db +DB_PORT=5432 +DB_NAME=mastodon +DB_USER=mastodon +DB_PASS=mastodon +REDIS_HOST=redis +REDIS_PORT=6379 +SMTP_SERVER=localhost +SMTP_PORT=25 +SMTP_FROM_ADDRESS=noreply@localhost +SMTP_AUTH_METHOD=none +SMTP_OPENSSL_VERIFY_MODE=none +SMTP_DELIVERY_METHOD=none +ES_ENABLED=false +RAILS_LOG_TO_STDOUT=true +ALLOWED_PRIVATE_ADDRESSES=0.0.0.0/0 diff --git a/test/smoke/mastodon/provision-strict.sh b/test/smoke/mastodon/provision-strict.sh new file mode 100755 index 000000000..e738c5200 --- /dev/null +++ b/test/smoke/mastodon/provision-strict.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# Provision Mastodon for strict-mode smoke tests (HTTPS + signature verification). +# +# Differences from provision.sh: +# - Uses WebFinger discovery (ResolveAccountService) instead of DB pre-registration +# - Writes HTTPS URLs to .env.test +# - Talks to mastodon-web backend directly (HTTP on port 3000) for API calls +set -euo pipefail + +COMPOSE="docker compose -f test/smoke/mastodon/docker-compose.strict.yml" + +echo "→ Creating test user..." +$COMPOSE exec -T mastodon-web-backend bin/tootctl accounts create \ + testuser --email=test@localhost --confirmed \ + || true # may already exist on re-run + +echo "→ Approving and activating test user..." +$COMPOSE exec -T mastodon-web-backend bin/rails runner - <<'RUBY' +user = Account.find_local('testuser').user +user.update!(approved: true, confirmed_at: Time.now.utc) +user.approve! if user.respond_to?(:approve!) +RUBY + +echo "→ Generating API token via Rails..." +RAW=$($COMPOSE exec -T mastodon-web-backend bin/rails runner - <<'RUBY' 2>&1 | tr -d '\r' +user = Account.find_local('testuser').user +app = Doorkeeper::Application.find_or_create_by!(name: 'smoke-test') do |a| + a.redirect_uri = 'urn:ietf:wg:oauth:2.0:oob' + a.scopes = 'read write follow' +end +token = Doorkeeper::AccessToken.find_or_create_for( + application: app, + resource_owner: user, + scopes: Doorkeeper::OAuth::Scopes.from_string('read write follow'), + expires_in: nil, + use_refresh_token: false +) +print "SMOKE_TOKEN=#{token.token}" +RUBY +) + +TOKEN=$(echo "$RAW" | grep -oP 'SMOKE_TOKEN=\K\S+' | tail -1) + +if [ -z "$TOKEN" ]; then + echo "✗ Failed to generate API token" + exit 1 +fi + +# Verify token works — talk directly to the backend (HTTP, port 3000) +echo "→ Verifying token..." +HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' \ + -H "Authorization: Bearer $TOKEN" \ + http://localhost:3000/api/v1/accounts/verify_credentials) +echo " verify_credentials → HTTP $HTTP_CODE" +if [ "$HTTP_CODE" != "200" ]; then + echo "✗ Token verification failed (HTTP $HTTP_CODE)" + exit 1 +fi + +echo "→ Resolving Fedify account via WebFinger (ResolveAccountService)..." +# Use Mastodon's built-in account resolution, which performs WebFinger over +# HTTPS to the Caddy-fronted harness. This validates that the full TLS + +# WebFinger chain works. +$COMPOSE exec -T mastodon-web-backend bin/rails runner - <<'RUBY' +account = ResolveAccountService.new.call('testuser@fedify-harness') +if account.nil? + abort "✗ ResolveAccountService returned nil — WebFinger discovery failed" +end +print "RESOLVED=#{account.id} (#{account.uri})" +RUBY + +echo "→ Creating follow relationship (Fedify → Mastodon) in DB..." +$COMPOSE exec -T mastodon-web-backend bin/rails runner - <<'RUBY' +fedify_account = Account.find_by!(username: 'testuser', domain: 'fedify-harness') +local_account = Account.find_local('testuser') +follow = Follow.find_or_create_by!(account: fedify_account, target_account: local_account) +print "FOLLOW=#{follow.id}" +RUBY + +echo "→ Writing test env..." +cat > test/smoke/.env.test <