Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions .github/workflows/smoke-mastodon-strict.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions test/smoke/harness/backdoor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
13 changes: 8 additions & 5 deletions test/smoke/harness/federation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const federation = createFederation<void>({
kv: new MemoryKvStore(),
origin: ORIGIN,
allowPrivateAddress: true,
skipSignatureVerification: true,
skipSignatureVerification: !Deno.env.get("STRICT_MODE"),
});

federation
Expand Down Expand Up @@ -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({
Expand Down
8 changes: 8 additions & 0 deletions test/smoke/mastodon/Caddyfile.fedify-harness
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
auto_https off
}

:443 {
tls /certs/fedify-harness.crt /certs/fedify-harness.key
reverse_proxy fedify-harness-backend:3001
}
8 changes: 8 additions & 0 deletions test/smoke/mastodon/Caddyfile.mastodon
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
auto_https off
}

:443 {
tls /certs/mastodon.crt /certs/mastodon.key
reverse_proxy mastodon-web-backend:3000
}
159 changes: 159 additions & 0 deletions test/smoke/mastodon/docker-compose.strict.yml
Original file line number Diff line number Diff line change
@@ -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 }
51 changes: 51 additions & 0 deletions test/smoke/mastodon/generate-certs.sh
Original file line number Diff line number Diff line change
@@ -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" <<EOF
authorityKeyIdentifier=keyid,issuer
basicConstraints=CA:FALSE
subjectAltName=DNS:$HOST,DNS:localhost
EOF

openssl x509 -req \
-in "$OUT/$HOST.csr" \
-CA "$OUT/ca.crt" -CAkey "$OUT/ca.key" -CAcreateserial \
-days 1 -sha256 \
-extfile "$OUT/$HOST.ext" \
-out "$OUT/$HOST.crt" 2>/dev/null

rm -f "$OUT/$HOST.csr" "$OUT/$HOST.ext"
done

rm -f "$OUT/ca.srl"

echo "✓ Certificates written to $OUT/"
ls -la "$OUT"
Loading
Loading