docker-compose.yml
Rama main · réplica de lectura del repositorio original.
services:
caddy:
image: caddy:2-alpine@sha256:6aeddd44c3078b0f9a35206472a11420648a79c184603ef95957d0a20044cb2b
restart: unless-stopped
ports: ["80:80", "443:443"]
volumes:
# Poll is the score transport (ADR 56, issue #377): one Caddyfile, no
# SCORE_INGEST knob to drift from a second copy of the same answer.
- ./caddy/Caddyfile.poll:/etc/caddy/Caddyfile:ro
- caddy-data:/data
environment:
EVENT_HOST: ${EVENT_URL:-http://localhost}
networks: [frontend]
app:
profiles: ["app"]
build:
context: .
dockerfile: apps/web/Dockerfile
args:
# Build stamp for GET /health. Both optional: unset reports
# "unknown"/null rather than failing, so a plain `docker compose build`
# keeps working. `dev-stack` and deploy/fly/deploy.sh fill them in.
APP_BUILD_REV: ${APP_BUILD_REV:-}
APP_BUILT_AT: ${APP_BUILT_AT:-}
restart: unless-stopped
environment:
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
BETTER_AUTH_URL: ${EVENT_URL:-http://localhost}
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID}
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET}
LEADERBOARD_SOURCE: lambda # plain-HTTP adapter; reads the local scorer
LEADERBOARD_API_URL: http://scorer:4000
UPSTASH_REDIS_REST_URL: http://srh:80
UPSTASH_REDIS_REST_TOKEN: ${SRH_TOKEN}
TEAM_WRITES_ENABLED: "true"
# The GitHub org contestants fork the target repos under (config v2,
# issue #386): a runtime read, not a build arg, so it must be passed
# through here or setting it in .env does nothing.
GITHUB_ORG: ${GITHUB_ORG:-}
# The /admin allowlist, comma-separated logins (config v2, issue #386):
# a runtime read too. Empty or unset refuses every login at /admin —
# see apps/web/src/lib/bootstrap-env.ts.
ADMIN_LOGINS: ${ADMIN_LOGINS:-}
# Runtime flag, not a build arg: non-empty means this deployment runs
# Secure Development (the scorer and sync containers exist), and that is
# the ONLY module enabled before an organizer switches others on in
# /admin (issue #386). Empty means no scorer image — the SD toggle is
# refused and nothing is enabled by default.
SCORE_IMAGE: ${SCORE_IMAGE:-}
# "=1" downgrades the http:// refusal on a non-loopback EVENT_URL to a
# startup warning — for deliberately TLS-less closed networks only
# (docs/hosting.md). Read at runtime by the app, so it must be passed
# through here or setting it in .env silently does nothing.
ALLOW_INSECURE_EVENT_URL: ${ALLOW_INSECURE_EVENT_URL:-}
# NOT depends_on scorer: the app reads the scorer over HTTP lazily, per
# request, and depends_on does not wait for readiness anyway — so this
# only ever expressed a start ORDER. Keeping it would have forced the
# (profiled) scorer into every `up` that starts the app, including a
# quiz-only event that has no scorer image to pull. See scorer's profiles
# below.
depends_on: [srh]
networks: [frontend]
scorer:
# The scorer belongs to the secure-development module, exactly as `sync`
# does — it exists to score PRs against forked targets. A quiz-only event
# has nothing for it to serve, so it is PROFILED rather than left in the
# default set: carrying no profile at all, it was pulled by every
# documented `up`, and a quiz-only organizer (who has no SCORE_IMAGE and
# no access to the private upstream fallback below) could not bring the
# documented stack up at all.
#
# `secdev` is the module's own profile (config v2, issue #386): whoever
# brings the stack up adds it iff SCORE_IMAGE is non-empty, so "does this
# event run Secure Development" is answered once, from one key, and never
# from a second knob — see scripts/dev-stack and
# deploy/fly/render-compose.sh. Quiz-only events pass neither.
# docs/hosting.md's "Which profiles do I need?" table is the
# organizer-facing version of this comment.
profiles: ["secdev"]
# Set SCORE_IMAGE in .env to your own build from scorer/ (docs/scorer.md).
# The fallback is the upstream image, which is private — it only pulls if
# the OWASP-CTF maintainers granted you access. Kept as a fallback (not
# required) so parse-time interpolation doesn't break the smoke override.
image: ${SCORE_IMAGE:-ghcr.io/owasp-ctf/score:latest}
command: ["score", "serve"]
restart: unless-stopped
environment:
UPSTASH_REDIS_REST_URL: http://srh:80
UPSTASH_REDIS_REST_TOKEN: ${SRH_TOKEN}
CTF_SCORE_BEARER_TOKEN: ${SCORER_TOKEN} # requires upstream scorer bearer-auth mode
depends_on: [srh]
networks: [frontend]
srh:
# Pinned by digest: this third-party image sits in the score read/write path,
# so a mutable :latest tag must not silently swap it out on a repull.
image: hiett/serverless-redis-http:latest@sha256:5b0bb9239fce53abf87b2018a7a0deb9ec7bd900c5360738fe5fbeeb426f9150
restart: unless-stopped
environment:
SRH_MODE: env
SRH_TOKEN: ${SRH_TOKEN}
# Empty username, password only — the RFC form for a Redis instance
# using `requirepass` without ACL users.
SRH_CONNECTION_STRING: redis://:${REDIS_PASSWORD:?set REDIS_PASSWORD in .env (setup/ctf-setup.sh secrets generates one; see docs/hosting.md)}@redis:6379
depends_on: [redis]
# The ONLY service on both networks. That is the isolation: `app`,
# `scorer` and `sync` can reach srh (and must, for the Upstash-REST
# contract) but have no route to redis:6379 at all, so the bearer token
# in front of it cannot be side-stepped from a compromised app container.
networks: [frontend, backend]
redis:
image: redis:8-alpine@sha256:3811787313eba226a2ef38658c6ccb91cd5e110edc89c37767de373120a0e5a0
restart: unless-stopped
# THE PASSWORD IS READ FROM THE ENVIRONMENT, NOT INTERPOLATED INTO ARGV.
#
# `--requirepass ${REDIS_PASSWORD}` put the live credential in three places
# it did not belong: the container's command line (so any `ps` inside the
# stack, and `docker inspect`, showed it), the output of `docker compose
# config` (which organizers are told to run, and which deploy/fly renders
# to a file), and every machine-config dump on a hosted deployment.
#
# `$$` is escaped for COMPOSE, not for the shell: compose turns `$$` into a
# literal `$`, so redis-server's own `sh` is what expands $REDIS_PASSWORD,
# at start-up, inside the container. `exec` keeps redis as PID 1 so it
# still receives signals directly rather than through a shell that ignores
# them. Without `sh -c` there is no expansion at all and redis would take
# the literal string "$REDIS_PASSWORD" as its password — an error that
# starts cleanly and reports healthy (this exact bug shipped once on Fly,
# where `[processes]` also runs no shell; see ADR 42).
# `mkdir -p` because `--dir` must already exist — redis-server does not
# create it and exits with "Can't chdir to ...". It only ever matters when
# REDIS_DIR is a SUBDIRECTORY of the mount, which is the Fly case below.
#
# `chown redis` because the mkdir runs as root and the entrypoint will NOT
# fix that for us. The image's permission pass only chowns the data dir
# when every entry in it is one redis recognises; anything else — and a
# fresh Fly volume ships a root-owned `lost+found`, while REDIS_DIR itself
# is a subdirectory it does not know — makes it print "Unknown file ...
# Permissions will not be modified" and leave ownership alone. Redis then
# starts as uid 999 inside a root-owned directory and dies with "Can't
# open or create append-only dir appendonlydir: Permission denied". That
# is what the first deploy of a renamed Fly app did; the old app only
# worked because an earlier image had chowned its volume back when the
# pass was unconditional. Reproduced on a docker named volume seeded with
# a root-owned lost+found: without the chown, exit 1; with it, ready.
#
# `exec docker-entrypoint.sh redis-server ...`, NOT `exec redis-server ...`.
#
# The image's entrypoint does two things that only happen when its first
# argument is literally `redis-server`:
#
# 1. drops privileges — `setpriv --reuid redis --regid redis
# --clear-groups`
# 2. sets `umask 0077`, so the AOF and RDB are not world-readable
#
# Running `sh -c 'exec redis-server ...'` skips BOTH: the entrypoint sees
# `sh`, falls through to `exec "$@"`, and redis ends up running as ROOT
# with a 0022 umask. Nothing about that is visible in `docker compose ps`
# or in the logs — it was found by diffing `ps -o user` against the
# previous command.
#
# So the shell is used only to expand the two variables, and then hands
# back to the entrypoint, which sees `redis-server` and behaves exactly as
# it did before this file used a shell at all.
command:
- sh
- -c
- mkdir -p "$$REDIS_DIR" && chown redis "$$REDIS_DIR" && exec docker-entrypoint.sh redis-server --appendonly yes --dir "$$REDIS_DIR" --requirepass "$$REDIS_PASSWORD"
environment:
# Where the append-only file lives, INSIDE the mounted volume.
#
# Defaults to the volume root, which is what compose has always done and
# keeps every local stack byte-identical. It is a knob because a Fly
# machine permits exactly ONE volume ("invalid config.mounts, only 1
# volume supported"), so redis and sync have to share it under separate
# directories — .env.fly sets this to /data/redis. See deploy/fly/fly.toml.
REDIS_DIR: ${REDIS_DIR:-/data}
# `:?` on purpose: an unset REDIS_PASSWORD fails the bring-up with this
# message rather than starting an unauthenticated Redis. A security
# control that silently degrades to "off" when a variable is missing is
# the failure mode this whole change exists to remove — and the empty
# string would be exactly that, since redis-server treats
# `--requirepass ""` as no password at all. `:?` rejects both unset AND
# empty, which is why it stays `:?` rather than `:-`.
REDIS_PASSWORD: ${REDIS_PASSWORD:?set REDIS_PASSWORD in .env (setup/ctf-setup.sh secrets generates one; see docs/hosting.md)}
# redis-cli reads REDISCLI_AUTH, so `docker compose exec redis redis-cli
# ...` keeps working unchanged — for organizers debugging by hand, and
# for smoke.sh/dev-stack, which drive Redis exactly that way. Passing
# `-a` at every call site instead would put the password in each
# command line (visible in `ps`) and print a warning on every
# invocation.
REDISCLI_AUTH: ${REDIS_PASSWORD}
volumes: [redis-data:/data]
networks: [backend]
sync:
# Same derivation as the scorer above — the profile is added iff
# SCORE_IMAGE is non-empty.
profiles: ["secdev"]
build: ./sync
# Config v2 (#386) removed the "nothing to poll" exit 0: whether sync runs
# at all is this profile's call, so if the container is up it polls. The
# only clean-exit case left is a refusal — a missing GITHUB_ORG exits 1
# naming the key (sync/src/index.js's main()) — and on-failure restarting
# that is what an organizer wants: the log line repeats until the key is
# set, instead of one message scrolling away in a container nothing brings
# back. unless-stopped would behave the same here; on-failure is kept as
# the narrower policy.
restart: on-failure
environment:
# The org the poller reads score comments from. sync refuses to start
# when it is empty, logging `ctf-sync: GITHUB_ORG is not set` — so it
# must be passed through here or setting it in .env does nothing.
GITHUB_ORG: ${GITHUB_ORG:-}
GITHUB_APP_ID: ${GITHUB_APP_ID:-}
GITHUB_APP_PRIVATE_KEY: ${GITHUB_APP_PRIVATE_KEY:-}
GITHUB_APP_INSTALLATION_ID: ${GITHUB_APP_INSTALLATION_ID:-}
SCORER_TOKEN: ${SCORER_TOKEN}
SCORER_URL: http://scorer:4000
# Same srh proxy as app/scorer above — lets sync read the organizer's
# pause flag (ctf:admin:settings) and publish its heartbeat
# (ctf:sync:status). Without this, sync's redis client stays null and
# the admin-panel freeze never reaches the poller.
UPSTASH_REDIS_REST_URL: http://srh:80
UPSTASH_REDIS_REST_TOKEN: ${SRH_TOKEN}
# The poller's LEGACY state file. The cursor itself lives in Redis
# (ctf:sync:state, through srh above); this file is read once, to carry
# an older box's cursor into Redis, then renamed state.json.migrated.
# It is the store only when sync has no Redis client. The volume stays
# so that migration has something to read. Same reasoning as redis's
# REDIS_DIR above: the default is exactly what compose has always used,
# and .env.fly moves it
# to /data/sync/state.json so both services fit the single volume a Fly
# machine allows. sync creates the parent directory itself.
STATE_PATH: ${STATE_PATH:-/state/state.json}
volumes:
- sync-state:/state
depends_on: [scorer, srh]
networks: [frontend]
# Two networks, and the split is the point: only `srh` sits on both, so the
# internet-facing `app` has no route to redis:6379 and cannot bypass srh's
# bearer token even if it were compromised (an SSRF or RCE in the app was the
# concrete worry — see ADR 41). `backend` is `internal`, so nothing on it can
# reach the outside world either.
networks:
frontend:
backend:
internal: true
volumes:
caddy-data:
redis-data:
sync-state: