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
51 changes: 49 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -109,15 +109,17 @@ services:
- MAIL_PASSWORD=${MAIL_PASSWORD}
- MAIL_ADDRESS=${MAIL_ADDRESS}
- CORS_ORIGINS=${CORS_ORIGINS}
- DOCKER_USERNAME=${DOCKER_USERNAME}
- DOCKER_TOKEN=${DOCKER_TOKEN}
- DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}
- DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET}
- DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN}
- DISCORD_GUILD_ID=${DISCORD_GUILD_ID}
- INTERNET_FOR_ALL=${INTERNET_FOR_ALL}
- MAC_HOSTNAME=${MAC_HOSTNAME}
- MAC_USERNAME=${MAC_USERNAME}
- REGISTRY_API_SECRET=${REGISTRY_API_SECRET} #Random API SECRET
- REGISTRY_HOST=${REGISTRY_HOST}
- REGISTRY_USERNAME=${REGISTRY_USERNAME}
- REGISTRY_PASSWORD=$(REGISTRY_PASSWORD}
volumes:
- /data/CTFd/logs:/var/log/CTFd
- /data/CTFd/uploads:/var/uploads
Expand Down Expand Up @@ -236,6 +238,8 @@ services:
environment:
- DOJO_HOST=${DOJO_HOST}
- DOJO_ENV=${DOJO_ENV}
- REGISTRY_HOST=${REGISTRY_HOST}
- REGISTRY_AUTH_HOST=${REGISTRY_AUTH_HOST}
volumes:
- /data/acme:/var/cache/nginx/acme-letsencrypt
networks:
Expand All @@ -245,6 +249,49 @@ services:
- nginx
ipv4_address: 10.0.0.3

registry:
image: registry:2
container_name: registry
environment:
- VIRTUAL_HOST=${REGISTRY_HOST}
- VIRTUAL_PORT=5000
- HTTPS_METHOD=noredirect
- REGISTRY_AUTH=token
- REGISTRY_AUTH_TOKEN_REALM=https://${REGISTRY_HOST}/auth/token #https://registry.pwn.college/auth/token
- REGISTRY_AUTH_TOKEN_SERVICE=${REGISTRY_HOST} # registry.pwn.college
- REGISTRY_AUTH_TOKEN_ISSUER=${REGISTRY_AUTH_HOST} # If we change things, remember to change the auth service token issuer variable.
- REGISTRY_AUTH_TOKEN_ROOTCERTBUNDLE=/auth/jwt.crt
volumes:
- /data/registry:/var/lib/registry
networks:
- default
- workspace_net

registry-auth:
build: ./registry_auth
container_name: registry-auth
command: gunicorn -b 0.0.0.0:8080 auth:app --log-level debug --access-logfile - #/dev/null
environment:
- VIRTUAL_HOST=${REGISTRY_HOST}
- VIRTUAL_PORT=8080
- PRIVATE_KEY_PATH=/keys/private.key
- PUBLIC_KEY_PATH=/keys/public.key
- TOKEN_ISSUER=${REGISTRY_AUTH_HOST}
- TOKEN_SERVICE=${REGISTRY_HOST}
- TOKEN_TTL_SECONDS=300
- CTFD_URL=http://ctfd:8000
- REGISTRY_API_SECRET=${REGISTRY_API_SECRET} #Random API SECRET
volumes:
- ./auth-keys/wtf:/keys:ro
networks:
- default
- workspace_net
depends_on:
db:
condition: service_healthy



node-exporter:
container_name: node-exporter
hostname: node-exporter
Expand Down
163 changes: 163 additions & 0 deletions docs/registry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
# Docker Registry

This document describes the DOJO private Docker Registry, the token auth server, the CTFd verification endpoint, client usage, Image Puller, Production Checklist, troubleshooting, and future work.

## Overview

- Host: `registry.<domain>` served by `nginx-proxy`
- Auth server: `/auth/token` on the same host (path-based) backed by `registry-auth`
- Tokens: RS256 JWTs with a KID header that the registry uses to select the correct verification key
- CTFd verify: `registry-auth` validates user creds and authorization by calling CTFd at `/pwncollege_api/v1/registry/verify`


## Environment and Secrets

- `REGISTRY_API_SECRET`: shared secret used by `registry-auth` to call CTFd’s verify endpoint
- Must be identical in both `ctfd` and `registry-auth`
- `CTFD_URL`: URL of the CTFd service as seen from `registry-auth` (e.g., `http://ctfd:8000`)
- Token parameters (consistent between services, single-host example):
- Registry: `REGISTRY_AUTH_TOKEN_REALM=https://registry.pwn.college/auth/token`
- Registry: `REGISTRY_AUTH_TOKEN_SERVICE=registry.pwn.college`
- Registry: `REGISTRY_AUTH_TOKEN_ISSUER=registry.pwn.college`
- Auth: `TOKEN_SERVICE=registry.pwn.college`, `TOKEN_ISSUER=auth.registry.pwn.college`

## Nginx Proxy Routes

`dojo/nginx-proxy/etc/nginx/vhost.d/registry.localhost.pwn.college` illustrates path-based routing (replace with your domain in production):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably wrap all domain names used in the code in a variable that we can change in the docker-compose.yml.

More generally, we need to easily configure the registry to work in a dev environment (without HTTPS with localhost addresses) and also in prod (with HTTPS with publicly resolvable addresses).


- `/v2/` → `registry:5000`
- `/auth/token` → `registry-auth:8080`

This single host setup lets Docker clients talk to both the registry and the token server at `https://registry.pwn.college`.

## CTFd Verify Endpoint

- Path: `POST /pwncollege_api/v1/registry/verify`
- Headers: `Authorization: Bearer <REGISTRY_API_SECRET>`, `Content-Type: application/json`
- Body:
- `username` (pwn college username)
- `password` (pwn college password)
- `repository` (from the Docker auth scope; e.g., `adical/image`)
- `actions` (requested actions array; e.g., `["push","pull"]`)
- Behavior:
- AuthN: verifies the pwn.college user credentials against CTFd.
- Per-user namespace (push): the first path segment of `repository` must equal the authenticated user’s `name`.
- Example: user `adical` may push to `adical/anything` but not to `placeholder/anything`.
- Pull policy: any authenticated user may pull.
- Catalog/bootstrap: requests without a `repository` are allowed (used by the registry for catalog scope).
- Response:
- 200 `{ "success": true, "allowed": [ ... ] }` with the subset of requested actions that are permitted.
- 401 `{ "success": false, "error": "Invalid credentials" }` when username/password are incorrect.
- 403 with clear errors for authorization failures, for example:
- Namespace mismatch on push: `Push denied: repository namespace '<repo_ns>' does not match your username '<user>'. Tag the image as '<user>/<repo>' to push.`
## Image Naming Rules

- Using pwn college username to push an image. example `registry.localhost.pwn.college/adical/forensics:level1`

## Client Usage

Login to the registry and push:

```sh
docker login registry.pwn.college
docker tag yourimage registry.pwn.college/pwncollege_username/yourimage:latest
docker push registry.pwn.college/pwncollege_username/yourimage:latest
```

Pull:

```sh
docker pull registry.pwn.college/pwncollege_username/yourimage:latest
```

## Image Puller

I have made minimal changes to the current image puller implementation. Rather than pulling from docker hub, it now pulls from our registry. The future plan for the puller is listed at the end of the document.

## Production Checklist

- Set `DOJO_ENV=production`, `DOJO_HOST=<dojo.domain>`. The Registry and authe environement variable are under the DOJO_HOST.
- ACME: enable `acme-companion`, use `certs` volume, set `LETSENCRYPT_HOST` per service
- Ensure `REGISTRY_API_SECRET` is identical in `ctfd` and `registry-auth`
- Mount JWT public cert to the registry at `/auth/jwt.crt`. The correct format for creating is listed below.
- Confirm vhost routes for `/v2/` and `/auth/token`
- If you change `REGISTRY_AUTH_TOKEN_ISSUER`, remember to change the auth service token issuer variable.

## Private Docker Registry with Token Auth (Correct KID + Cert Flow)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Include link to relevant outside resources on registery auth process?


### Why these exact certificates and KID matter

### Two separate concerns

1. **TLS trust for the realm (`/auth/token`)**
The registry (and clients via your reverse proxy) must trust the HTTPS endpoint that issues tokens. We mount a CA (or the server cert itself) into the registry via `REGISTRY_AUTH_TOKEN_ROOTCERTBUNDLE=/auth/jwt.crt`. If this trust path is wrong, the registry won’t fetch tokens.

2. **JWT verification key + KID**
The registry verifies JWTs using the token’s `kid` header to select the right public key material. The KID is derived from **only the public key DER** (SubjectPublicKeyInfo), not the full X.509 certificate. Hashing the whole certificate gives the wrong KID and you’ll loop on 401s forever.


Important Refrence: [https://github.com/TheChemicalWorkshop/Docker_Registry_Token_Generator.py/blob/main/kid_format_specification]

**KID algorithm (exactly what we implement):**

- Take the **DER of the public key** (not the cert!)

- SHA-256 hash → **truncate to 30 bytes (240 bits)**

- Base32 encode (uppercase A–Z, 2–7), **strip `=` padding**

- Insert `:` every 4 characters → 12 groups (48 chars total)

### JWT Key Management

- Generate a dedicated keypair (do not reuse TLS/ACME keys):
- `openssl genrsa -out auth-keys/jwt/private.key 2048`
- `openssl req -new -x509 -key auth-keys/jwt/private.key -out auth-keys/jwt/public.crt -days 365 -subj "/CN=registry-token"`
- Mounts:
- `registry-auth`: `./auth-keys/jwt:/keys:ro`
- `registry`: `./auth-keys/jwt:/auth:ro` (so `/auth/jwt.crt` is present)


## Troubleshooting

- `docker logs nginx-proxy` – reverse proxy and ACME logs
- `docker logs registry` – registry errors (JWT, token fetch, catalog)
- `docker logs registry-auth` – token server, CTFd verify responses
- `docker logs ctfd` – CTFd logs (verify endpoint).

## Migrations

To migrate legacy images previously pushed to Docker Hub into the new registry:

- Export a list of legacy images from the module.yml and the dojo admin username.
- For each image, construct the new repository name using the username:
Example:
In mmodule.yml image name: `adical1/forensics:level1`
Pwncollege username: `placeholder` → `placeholder/forensics:level1`.
- Authenticate to `registry.pwn.college` as a global admin (global admins may push to any repo).
- Pull, retag, and push each image to the new registry.
- This migration process would present a new problem where dojo admins have to modify their module.yml to refer to the naming convention. Many user's pwncollege username might not be the same as their dockerhub username.

## Things to verify:

- Duplicate Images Across Different Users
- As higlighted by robwaz there is an open question regarding the handling of duplicate images pushed by different users.
- For example, consider the following scenario:
- User `test` pushes an image named `test/legacy`.
- User `adical` pushes an image named `adical/legacy`.

- While both images are identical (e.g., equivalent to pwncollege/legacy), the registry would still store them as separate physical images because they were built on different machines at different timestamps.
- Storage optimization only occurs when images are exactly identical.

- Registry Login Flow
- The original login mechanism for the registry used `update_code` as the password. This approach has not been implemented in the current PR due to two primary concerns:
1. The administrator flow was functional but inefficient.
2. There was no clear mechanism to prevent a former dojo administrator (who has since been removed) from continuing to push images to the associated dojo.
- the second login flow was designed that used the reference ID(combination of dojo name and id) to tag the image. This flow was higlighted in the earlier commit. The minor concern here was official dojo do not have a dojo_hex_id dispalyed. The ID field exist in the database but since its not dispalyed adds a layer of complexity for the dojo admin. There is a fix for this, we can still verify without the hex ID but for a simple and uniform naming convention a new login was implemented as highlted above.

## Future work:

### Work on the Image puller:
- Once the registry is working on production, Work on a followup PR with a more efficient mechanism for pulling the images and a better approach to displaying the hash of the image to the dojo admin.
- Pull permissions: There could be a scenario where the dojo admin wants their image private and not pullable by any user. To address this we can have a private image field in the naming convention to only allow dojo_admins and global admins to pull an image. But this results in a complex naming rules to remember.
- One potential fix could be to add an option for dojo_admins to make the image private on the admin page and on the backend we can make the images to be pullable by only dojo and global admins.
4 changes: 2 additions & 2 deletions dojo/dojo
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ fi
case "$ACTION" in
# HELP: up: bring the dojo up
"up")
if [ -n "$DOCKER_USERNAME" ] && [ -n "$DOCKER_TOKEN" ]; then
echo "$DOCKER_TOKEN" | docker login --username "$DOCKER_USERNAME" --password-stdin
if [ -n "$REGISTRY_USERNAME" ] && [ -n "$REGISTRY_PASSWORD" ]; then
echo "$REGISTRY_USERNAME" | docker login --username "$REGISTRY_PASSWORD" --password-stdin
fi

dojo sync
Expand Down
7 changes: 5 additions & 2 deletions dojo/dojo-init
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ define () {
DEFAULT_DOJO_HOST=localhost.pwn.college

define DOJO_HOST "${DEFAULT_DOJO_HOST}"
define REGISTRY_HOST "registry.${DEFAULT_DOJO_HOST}"
define REGISTRY_AUTH_HOST "auth.${DEFAULT_DOJO_HOST}"
define REGISTRY_API_SECRET $(openssl rand -hex 32)
define DOJO_ENV development
define DOJO_WORKSPACE core
define WORKSPACE_KEY
Expand All @@ -48,8 +51,8 @@ define MAIL_USERNAME
define MAIL_PASSWORD
define MAIL_ADDRESS
define CORS_ORIGINS
define DOCKER_USERNAME
define DOCKER_TOKEN
define REGISTRY_USERNAME
define REGISTRY_PASSWORD
define DISCORD_CLIENT_ID
define DISCORD_CLIENT_SECRET
define DISCORD_BOT_TOKEN
Expand Down
2 changes: 2 additions & 0 deletions dojo_plugin/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from .v1.search import search_namespace
from .v1.test_error import test_error_namespace
from .v1.user import user_namespace
from .v1.registry import registry_namespace

api = Blueprint("pwncollege_api", __name__)

Expand Down Expand Up @@ -69,3 +70,4 @@ def handle_api_exception(error):
api_v1.add_namespace(workspace_namespace, "/workspace")
api_v1.add_namespace(search_namespace, "/search")
api_v1.add_namespace(test_error_namespace, "/test_error")
api_v1.add_namespace(registry_namespace, "/registry")
94 changes: 94 additions & 0 deletions dojo_plugin/api/v1/registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
from flask import request
from flask_restx import Namespace, Resource

from ...config import REGISTRY_API_SECRET, REGISTRY_USERNAME, REGISTRY_PASSWORD
from CTFd.models import Users, Admins
from CTFd.utils.crypto import verify_password


registry_namespace = Namespace(
"registry", description="Endpoint to support registry auth checks"
)


def auth_check(authorization):
if not authorization or not authorization.startswith("Bearer "):
return {"success": False, "error": "Unauthorized"}, 401

token = authorization.split(" ")[1]
if not (REGISTRY_API_SECRET and token == REGISTRY_API_SECRET):
return {"success": False, "error": "Unauthorized"}, 401

return None, None


@registry_namespace.route("/verify")
class RegistryVerify(Resource):
def post(self):
authorization = request.headers.get("Authorization")
res, code = auth_check(authorization)
if res:
return res, code

data = request.get_json() or {}
username = data.get("username")
password = data.get("password")
repository = data.get("repository")
actions = data.get("actions") or []

if not username or not password:
return {"success": False, "error": "Missing credentials"}, 400

# Dedicated puller account: allow pulling any repository
requested = set(a.strip() for a in actions if a)
if (
repository
and "pull" in requested
and REGISTRY_USERNAME
and REGISTRY_PASSWORD
and username == REGISTRY_USERNAME
and password == REGISTRY_PASSWORD
):
return {"success": True, "allowed": ["pull"]}

user = Users.query.filter((Users.name == username) | (Users.email == username)).first()
if not user or not verify_password(password, user.password):
return {"success": False, "error": "Invalid credentials"}, 401

if not repository:
return {"success": True}


repo_namespace = repository.split("/", 1)[0]

allowed = set()

if "push" in requested:
if repo_namespace == user.name:
allowed.add("push")
else:
return {
"success": False,
"error": (
f"Push denied: repository namespace '{repo_namespace}' does not match your username '{user.name}'. "
f"Tag the image as '{user.name}/<repo>' to push."
),
}, 403

if "pull" in requested:
if repo_namespace == user.name:
allowed.add("pull")
else:
return {
"success": False,
"error": (
f"Pull denied: repository namespace '{repo_namespace}' does not match your username '{user.name}'. "
f"Pull images tagged as '{user.name}/<repo>'."
),
}, 403


if not allowed and requested:
return {"success": False, "error": "Not authorized for requested actions"}, 403

return {"success": True, "allowed": sorted(allowed)}
Loading