secured-remote-docker-build/README.md
Lukas Schaefer 7aeef62a39
Implement auth for git repo
Signed-off-by: Lukas Schaefer <lukas@lschaefer.xyz>
2026-09-03 21:16:26 -04:00

205 lines
7.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Docker Build Service
Small HTTP service for CI (e.g. Forgejo Actions) that builds images with **BuildKit** and pushes them to a registry. Callers authorize builds with a JWT signed by a shared secret.
The API container does **not** mount the host Docker socket. It talks to BuildKit only over a **Unix socket** on a shared Docker volume (`buildkit-socket`). Nothing else on the host or compose project mounts that volume, so other containers cannot reach BuildKit. Build steps use BuildKits default isolated network.
Flow:
1. Prepare the build context
2. Verify `sha256(Dockerfile)` matches the JWT claim
3. `buildctl build` (BuildKit) with `push=true` using registry credentials from the request
## Quick start
```bash
cp .env.example .env
# set JWT_SECRET to a long random value
docker compose up --build
```
Health check: `GET http://localhost:8080/health`
## Configuration
| Variable | Default | Description |
|---|---|---|
| `JWT_SECRET` | *(required)* | HS256 secret used to verify build tokens |
| `GIT_HOST` | *(required)* | Only host used for clones |
| `BUILDKIT_DNS` | *(optional)* | Real upstream DNS IP for `RUN` steps (replaces Dockers `127.0.0.11`, which BuildKit otherwise turns into `8.8.8.8`) |
| `BUILDKIT_HOST` | `unix:///run/buildkit/buildkitd.sock` | BuildKit socket address |
| `BUILDKIT_PRUNE_KEEP_DURATION` | `168h` | Keep cache used within this window; older unused cache is pruned by `buildkit-helper` |
| `BUILDKIT_PRUNE_INTERVAL_SECONDS` | `86400` | How often `buildkit-helper` runs prune (`86400` = daily) |
| `BUILD_SERVICE_PORT` | `8080` | Host port mapped to the API |
| `GIT_CLONE_TIMEOUT_SECONDS` | `300` | Max clone duration |
| `BUILD_TIMEOUT_SECONDS` | `1800` | Max build/push duration |
| `JWT_ALGORITHM` | `HS256` | JWT algorithm |
| `MAX_UPLOAD_BYTES` | `209715200` | Max total uploaded file bytes (200 MB) |
| `MAX_FILE_BYTES` | `52428800` | Max single uploaded file bytes (50 MB) |
| `MAX_MANIFEST_FILES` | `512` | Max paths in JWT `files` claim |
## JWT
Tokens must be signed with `JWT_SECRET` and include:
| Claim | Description |
|---|---|
| `repo` | `owner/name` (cloned from `https://<git_host>/<owner>/<name>.git` when a git commit is used) |
| `dockerfile` | Path to the Dockerfile inside the build context (no `..`) |
| `dockerfile_sha256` | Hex SHA-256 of the Dockerfile bytes that will be built |
| `exp` | Expiry (standard JWT claim) |
For requests that include file uploads, the JWT must also include:
| Claim | Description |
|---|---|
| `files` | Non-empty list of relative file paths. Uploaded paths must match this list exactly. |
File parts sent without a JWT `files` claim are ignored.
Arbitrary git URLs are rejected. Optional but recommended: keep TTL short (e.g. 15 minutes). Mint tokens from a trusted issuer that has the secret — not from untrusted CI jobs if you can avoid it.
### Mint a token
```bash
pip install PyJWT
JWT_SECRET='your-secret' python scripts/mint_token.py \
--repo 'org/app' \
--dockerfile Dockerfile \
--hash-from ./Dockerfile \
--ttl-minutes 15
```
For file uploads, add `--files` with the paths you will send:
```bash
JWT_SECRET='your-secret' python scripts/mint_token.py \
--repo 'org/app' \
--dockerfile Dockerfile \
--hash-from ./Dockerfile \
--files Dockerfile src/app.py \
--ttl-minutes 15
```
`--hash-from` can also be an http(s) URL to the Dockerfile. Or pass the digest directly with `--dockerfile-sha256` instead of `--hash-from`:
```bash
JWT_SECRET='your-secret' python scripts/mint_token.py \
--repo 'org/app' \
--dockerfile Dockerfile \
--dockerfile-sha256 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456' \
--ttl-minutes 15
```
With docker:
```bash
docker exec -it build-service python scripts/mint_token.py \
--repo 'org/app' \
--dockerfile Dockerfile \
--hash-from https://git.lschaefer.xyz/lukasdotcom/actions-tester/raw/branch/main/Dockerfile \
--ttl-minutes 600000
```
## CI action (other repos)
Composite action for Forgejo / GitHub Actions — call the build API with `uses:` instead of hand-rolled `curl`. Repo: `https://git.lschaefer.xyz/lukasdotcom/docker-build-container`.
```yaml
- name: Trigger buildkit build
uses: https://git.lschaefer.xyz/lukasdotcom/docker-build-container@<sha-or-tag>
with:
url: http://build-kit.local:8080/build
token: ${{ secrets.BUILD }}
commit: ${{ needs.build-caddy.outputs.dist_commit }}
tags: |
lukasdotcom/image:latest
lukasdotcom/image:${{ needs.build-caddy.outputs.dist_commit }}
username: lukasdotcom
password: ${{ secrets.DOCKER }}
# optional — private repo clone
repo_username: oauth2
repo_password: ${{ secrets.GIT_TOKEN }}
```
`tags` is a newline- or comma-separated list of full image refs. For private repos, pass `repo_username` and `repo_password` together (git credentials for the clone; not the same as registry `username` / `password`).
To overlay files (multipart), pass `files` — newline-separated paths used as both the local file and the context path (JWT must include a matching `files` claim):
```yaml
files: |
Dockerfile
dist/app
commit: ${{ github.sha }} # optional with uploads
```
The action only needs `bash` and `curl` (no Python).
## API
### `POST /build`
#### Git commit
Shallow-fetches `commit` from `https://<git_host>/<owner>/<name>.git`.
```json
{
"token": "<jwt>",
"commit": "a1b2c3d4e5f6789012345678901234567890abcd",
"tags": [
"lukasdotcom/app:1.0.0",
"lukasdotcom/app:latest"
],
"username": "registry-user",
"password": "registry-password",
"repo_username": "oauth2",
"repo_password": "<git-token>"
}
```
```bash
curl -N -sS http://localhost:8080/build \
-H 'Content-Type: application/json' \
-d '{
"token": "'"$TOKEN"'",
"commit": "'"$COMMIT"'",
"tags": ["lukasdotcom/app:latest"],
"username": "user",
"password": "pass",
"repo_username": "oauth2",
"repo_password": "'"$GIT_TOKEN"'"
}'
```
| Field | Description |
|---|---|
| `token` | JWT with `repo`, `dockerfile`, and `dockerfile_sha256` |
| `commit` | Git commit SHA to build (740 hex chars) |
| `tags` | One or more image refs to build and push (`registry/name:tag`; safe charset only; max 16) |
| `remote` | Optional. Registry host for auth. Defaults to `https://index.docker.io/v1/` (Docker Hub) |
| `username` / `password` | Registry credentials |
| `repo_username` / `repo_password` | Optional. Git credentials for private clones (both required together) |
#### File upload
`Content-Type: multipart/form-data`. Clones `commit`, then overlays the uploaded files. The JWT `files` claim must list exactly the uploaded paths. Optional `repo_username` / `repo_password` form fields work the same as in JSON.
```bash
curl -N -sS http://localhost:8080/build \
-F "token=$TOKEN" \
-F "commit=$COMMIT" \
-F 'tags=["lukasdotcom/app:latest"]' \
-F "username=$USER" \
-F "password=$PASS" \
-F "files=@./Dockerfile;filename=Dockerfile" \
-F "files=@./src/app.py;filename=src/app.py"
```
Each `files` part uses its multipart `filename` as the relative path.
On success, the response is a chunked `text/plain` stream of `buildctl` progress (`--progress=plain`) as the build and push run. Use `curl -N` (or similar) so the client does not buffer the body.
JWT / clone / upload / Dockerfile path or hash errors return normal JSON error responses before streaming starts. If the BuildKit build fails after the stream has begun, the HTTP status stays `200` and the stream ends with a line like `[build-service] ERROR: ...`.