Ctrl K
ComunidadGuías…

docker-compose.yml

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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: