diff --git a/docker-compose.dokploy.yml b/docker-compose.dokploy.yml index 9d546c9..e549b1f 100644 --- a/docker-compose.dokploy.yml +++ b/docker-compose.dokploy.yml @@ -16,10 +16,12 @@ services: - dokploy-network labels: - traefik.enable=true - - traefik.http.services.trupu.loadbalancer.server.port=3000 + - traefik.http.services.trupu-server.loadbalancer.server.port=3000 registry: image: registry:2 + ports: + - '127.0.0.1:6000:5000' environment: REGISTRY_STORAGE_DELETE_ENABLED: 'true' volumes: @@ -29,12 +31,13 @@ services: labels: - traefik.enable=true # TODO: Change registry.example.com to your registry domain - - traefik.http.routers.registry.rule=Host(`registry.example.com`) - - traefik.http.routers.registry.entrypoints=websecure - - traefik.http.routers.registry.tls=true - - traefik.http.routers.registry.tls.certresolver=letsencrypt - - traefik.http.routers.registry.middlewares=trupu-auth - - traefik.http.services.registry.loadbalancer.server.port=5000 + - traefik.http.routers.trupu-registry.rule=Host(`registry.example.com`) + - traefik.http.routers.trupu-registry.entrypoints=websecure + - traefik.http.routers.trupu-registry.tls=true + - traefik.http.routers.trupu-registry.tls.certresolver=letsencrypt + - traefik.http.routers.trupu-registry.middlewares=trupu-auth + - traefik.http.routers.trupu-registry.service=trupu-registry-svc + - traefik.http.services.trupu-registry-svc.loadbalancer.server.port=5000 # ForwardAuth middleware - traefik.http.middlewares.trupu-auth.forwardauth.address=http://trupu:3000/auth - traefik.http.middlewares.trupu-auth.forwardauth.authResponseHeaders=X-Trupu-Repository,X-Trupu-Workflow,X-Trupu-Ref,Www-Authenticate diff --git a/llms.txt b/llms.txt new file mode 100644 index 0000000..a16d63b --- /dev/null +++ b/llms.txt @@ -0,0 +1,115 @@ +# trupu + +> Trusted Publishing for Docker registries using GitHub Actions OIDC. + +trupu is an authentication server that enables passwordless Docker image pushes from GitHub Actions to a self-hosted Docker registry. It uses OpenID Connect (OIDC) tokens instead of long-lived credentials, implementing the OpenSSF Trusted Publishers standard. + +## Architecture + +GitHub Actions → Traefik (ForwardAuth) → trupu (/auth) → Docker Registry + +- Traefik intercepts every request to the registry and forwards it to trupu's /auth endpoint +- trupu verifies the GitHub OIDC JWT against GitHub's JWKS +- On success, the request is proxied to the registry +- On failure, 401/403 is returned + +## Project structure + +This is a pnpm monorepo with two packages: + +- packages/server — The Hono-based auth server (published as @trupu/server on npm and ghcr.io/nmerget/trupu on Docker) +- packages/docs — Astro Starlight documentation site (deployed to GitHub Pages) + +## Server + +The server is built with Hono on Node.js. It uses esbuild to bundle into a single minified file (~54kb). + +### Key files + +- src/index.ts — Entry point, wires provider + app + serve() +- src/app.ts — createApp(provider, config) factory, decoupled from serve() for testability +- src/config.ts — General server config (PORT, DEV_MODE, DEV_TOKEN) +- src/provider/types.ts — Provider interface and VerifiedIdentity type +- src/provider/github/config.ts — GitHub-specific config (ALLOWED_PUBLISHERS, ALLOWED_REFS, OIDC_AUDIENCE), parsePublishers() +- src/provider/github/index.ts — createGitHubProvider(), extractWorkflow(), matchRef() + +### Provider interface + +```typescript +interface VerifiedIdentity { + repository: string; + workflow: string; + ref: string; +} + +interface Provider { + verifyToken(token: string): Promise; +} +``` + +The provider pattern allows adding new CI providers (e.g. GitLab) by implementing this interface in a new provider/ subdirectory. + +### Endpoints + +- GET /auth — Traefik ForwardAuth endpoint. Accepts Bearer (OIDC token) and Basic (docker login) auth schemes. Returns 200 with identity headers, 401 with Www-Authenticate challenge, or 403 on invalid token. +- GET /healthz — Health check, returns { status: "ok" } + +### Trusted publisher format + +Publishers are configured as `owner/repo:workflow.yml` via the ALLOWED_PUBLISHERS environment variable. The workflow filename is extracted from the GitHub OIDC token's `job_workflow_ref` claim. + +### Ref matching + +ALLOWED_REFS supports exact matches and trailing wildcard patterns (e.g. `refs/tags/v*` matches `refs/tags/v1.0.0`). + +## Environment variables + +- PORT (default: 3000) — Server port +- DEV_MODE (default: false) — Skip OIDC verification, accept DEV_TOKEN +- DEV_TOKEN (default: trupu-dev-token) — Static token for dev mode +- ALLOWED_PUBLISHERS — Comma-separated owner/repo:workflow.yml entries +- ALLOWED_REFS — Comma-separated ref patterns (supports trailing *) +- OIDC_AUDIENCE (default: https://registry.example.com) — Expected aud claim + +## Docker Compose files + +- docker-compose.yml — Local development with bundled Traefik (file provider) +- docker-compose.dev.yml — Dev override enabling DEV_MODE and Traefik dashboard +- docker-compose.dokploy.yml — Production deployment on Dokploy (uses Docker labels, external dokploy-network) + +## Testing + +Tests use vitest with mock providers. 23 tests covering: +- Auth endpoint (Bearer, Basic, missing auth, dev mode) +- GitHub provider (parsePublishers, extractWorkflow, matchRef) + +Run: `pnpm test` + +## CI/CD + +GitHub Actions workflow (.github/workflows/default.yml): +- ci — lint, format:check, test, build +- deploy — Astro docs to GitHub Pages +- release — Changesets version + npm publish with provenance +- publish-docker — Build and push Docker image to ghcr.io + +Permissions follow least privilege: read-only default, elevated per job. + +## Tech stack + +- Runtime: Node.js 24 +- Framework: Hono + @hono/node-server +- OIDC: jose (JWT verification via JWKS) +- Bundler: esbuild (single-file minified output) +- Tests: vitest +- Docs: Astro Starlight +- Package manager: pnpm +- Linting: eslint + typescript-eslint +- Formatting: prettier + +## Links + +- Documentation: https://nmerget.github.io/trupu/ +- npm: https://www.npmjs.com/package/@trupu/server +- Docker: ghcr.io/nmerget/trupu +- GitHub: https://github.com/nmerget/trupu diff --git a/packages/docs/astro.config.mjs b/packages/docs/astro.config.mjs index ae4d4db..4392013 100644 --- a/packages/docs/astro.config.mjs +++ b/packages/docs/astro.config.mjs @@ -47,6 +47,10 @@ export default defineConfig({ items: [ { label: 'Auth Flow', slug: 'reference/auth-flow' }, { label: 'API Endpoints', slug: 'reference/api-endpoints' }, + { + label: 'GitHub Actions Workflow', + slug: 'reference/github-actions-workflow', + }, ], }, ], diff --git a/packages/docs/src/content/docs/deployment/dokploy.md b/packages/docs/src/content/docs/deployment/dokploy.md index a0ce21e..bd910df 100644 --- a/packages/docs/src/content/docs/deployment/dokploy.md +++ b/packages/docs/src/content/docs/deployment/dokploy.md @@ -39,10 +39,12 @@ services: - dokploy-network labels: - traefik.enable=true - - traefik.http.services.trupu.loadbalancer.server.port=3000 + - traefik.http.services.trupu-server.loadbalancer.server.port=3000 registry: image: registry:2 + ports: + - '127.0.0.1:6000:5000' environment: REGISTRY_STORAGE_DELETE_ENABLED: 'true' volumes: @@ -52,12 +54,14 @@ services: labels: - traefik.enable=true # TODO: Change registry.example.com to your registry domain - - traefik.http.routers.registry.rule=Host(`registry.example.com`) - - traefik.http.routers.registry.entrypoints=websecure - - traefik.http.routers.registry.tls=true - - traefik.http.routers.registry.tls.certresolver=letsencrypt - - traefik.http.routers.registry.middlewares=trupu-auth - - traefik.http.services.registry.loadbalancer.server.port=5000 + - traefik.http.routers.trupu-registry.rule=Host(`registry.example.com`) + - traefik.http.routers.trupu-registry.entrypoints=websecure + - traefik.http.routers.trupu-registry.tls=true + - traefik.http.routers.trupu-registry.tls.certresolver=letsencrypt + - traefik.http.routers.trupu-registry.middlewares=trupu-auth + - traefik.http.routers.trupu-registry.service=trupu-registry-svc + - traefik.http.services.trupu-registry-svc.loadbalancer.server.port=5000 + # ForwardAuth middleware - traefik.http.middlewares.trupu-auth.forwardauth.address=http://trupu:3000/auth - traefik.http.middlewares.trupu-auth.forwardauth.authResponseHeaders=X-Trupu-Repository,X-Trupu-Workflow,X-Trupu-Ref,Www-Authenticate @@ -79,6 +83,10 @@ The key values to change: The registry has no public port of its own. All external traffic goes through Dokploy's Traefik, which routes requests based on the `Host()` domain. Traefik also handles TLS via Let's Encrypt automatically. ::: +:::caution +Dokploy auto-generates Traefik labels for each service. To avoid conflicts, all Traefik router/service/middleware names in this compose are prefixed with `trupu-`. Make sure to disable Dokploy's built-in domain configuration for this compose project — the labels handle routing directly. +::: + ## Step 3: Deploy Click **Deploy** in Dokploy. This will: @@ -100,47 +108,7 @@ Dokploy's Traefik will automatically: ## Step 4: Configure GitHub Actions -In your repository, create `.github/workflows/publish.yml`: - -```yaml -name: Publish Image - -on: - push: - tags: ['v*'] - -permissions: - id-token: write - contents: read - -jobs: - push: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Get OIDC token - id: oidc - run: | - # TODO: Change audience to match your OIDC_AUDIENCE - TOKEN=$(curl -s -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ - "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://registry.example.com" | jq -r .value) - echo "token=$TOKEN" >> "$GITHUB_OUTPUT" - - - name: Login to registry - # TODO: Change to your registry domain - run: echo "${{ steps.oidc.outputs.token }}" | docker login registry.example.com -u oauth2 --password-stdin - - - name: Build and push - run: | - # TODO: Change to your registry domain and image name - docker build -t registry.example.com/my-image:latest . - docker push registry.example.com/my-image:latest -``` - -:::caution -The `audience` parameter in the OIDC token request must exactly match your `OIDC_AUDIENCE` environment variable. -::: +See the [GitHub Actions Workflow](../../reference/github-actions-workflow/) reference for a complete example workflow. Set the `REGISTRY` env variable to your registry domain from Step 2. ## How it works on Dokploy @@ -169,22 +137,40 @@ The `docker-compose.dokploy.yml` differs from the local development setup: ## Pulling images from the registry -Pushes from GitHub Actions go through Traefik and require OIDC authentication. But services running on the same Dokploy server can pull directly from the registry container, bypassing Traefik entirely — no authentication needed. +The registry's port 5000 is bound to `127.0.0.1:6000` on the host — accessible from the server itself but not from the internet. External traffic goes through Traefik with OIDC auth, while internal services pull directly via localhost without authentication. -Use the registry's internal hostname and port in your Dokploy compose services: +Other Dokploy compose services can pull images using `localhost:6000`: ```yaml services: app: - image: registry:5000/my-image:latest + image: localhost:6000/my-image:latest networks: - dokploy-network + +networks: + dokploy-network: + external: true +``` + +Add `localhost:6000` to the Docker daemon's insecure registries on your Dokploy server (the registry runs plain HTTP internally). + +Edit `/etc/docker/daemon.json`: + +```json +{ + "insecure-registries": ["localhost:6000"] +} ``` -This works because the registry container is on the `dokploy-network` and exposes port 5000 internally without any auth middleware. +Then restart Docker: + +```bash +sudo systemctl restart docker +``` :::caution -External pulls (from outside the Docker network) must go through Traefik and require authentication, just like pushes. +External pulls must go through Traefik at `registry.example.com` and require OIDC authentication. ::: ## Garbage collection @@ -196,14 +182,15 @@ The Docker registry does not automatically clean up deleted image layers. Over t 1. In the Dokploy dashboard, open your trupu **Compose** service 2. Go to the **Schedule Jobs** tab 3. Create a new **Compose Job** targeting the `registry` service -4. Set the command to: +4. Set **Shell Type** to **Sh** +5. Set the command to: ```bash registry garbage-collect /etc/docker/registry/config.yml --delete-untagged ``` -5. Set the cron schedule — for example `0 3 * * *` to run daily at 3 AM -6. Save the job +6. Set the cron schedule — for example `0 3 * * *` to run daily at 3 AM +7. Save the job The `--delete-untagged` flag also removes manifests that are no longer referenced by any tag. diff --git a/packages/docs/src/content/docs/getting-started/setup.md b/packages/docs/src/content/docs/getting-started/setup.md index 00943f5..7509aee 100644 --- a/packages/docs/src/content/docs/getting-started/setup.md +++ b/packages/docs/src/content/docs/getting-started/setup.md @@ -41,42 +41,7 @@ This starts three services: ## GitHub Actions workflow -In your repository, create `.github/workflows/publish.yml`: - -```yaml -name: Publish Image - -on: - push: - tags: ['v*'] - -permissions: - id-token: write - contents: read - -jobs: - push: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Get OIDC token - id: oidc - run: | - TOKEN=$(curl -s -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ - "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://registry.example.com" | jq -r .value) - echo "token=$TOKEN" >> "$GITHUB_OUTPUT" - - - name: Login to registry - run: echo "${{ steps.oidc.outputs.token }}" | docker login registry.example.com:5000 -u oauth2 --password-stdin - - - name: Build and push - run: | - docker build -t registry.example.com:5000/my-image:latest . - docker push registry.example.com:5000/my-image:latest -``` - -The `id-token: write` permission is required for GitHub to issue OIDC tokens. The `audience` must match your `OIDC_AUDIENCE` environment variable. +See the [GitHub Actions Workflow](../../reference/github-actions-workflow/) reference for a complete example workflow that pushes images to your trupu-protected registry. ## Local development diff --git a/packages/docs/src/content/docs/reference/github-actions-workflow.md b/packages/docs/src/content/docs/reference/github-actions-workflow.md new file mode 100644 index 0000000..d2d451f --- /dev/null +++ b/packages/docs/src/content/docs/reference/github-actions-workflow.md @@ -0,0 +1,59 @@ +--- +title: GitHub Actions Workflow +description: Example workflow for pushing Docker images to your trupu-protected registry. +--- + +To push images from GitHub Actions, your workflow needs to request an OIDC token and use it to authenticate with the registry. + +Create `.github/workflows/publish.yml` in your repository: + +```yaml +name: Publish Image + +on: + push: + tags: ['v*'] + +permissions: + id-token: write + contents: read + +env: + # TODO: Change to your registry domain + REGISTRY: registry.example.com + # TODO: Change to your image name + IMAGE_NAME: my-image + +jobs: + push: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Get OIDC token + id: oidc + run: | + TOKEN=$(curl -s -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ + "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://${{ env.REGISTRY }}" | jq -r .value) + echo "token=$TOKEN" >> "$GITHUB_OUTPUT" + + - name: Login to registry + run: echo "${{ steps.oidc.outputs.token }}" | docker login ${{ env.REGISTRY }} -u oauth2 --password-stdin + + - name: Build and push + run: | + docker build -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest . + docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest +``` + +## Key points + +- `id-token: write` — required for GitHub to issue OIDC tokens +- `REGISTRY` — your registry domain (e.g. `registry.example.com`), no `https://` prefix +- `IMAGE_NAME` — the image name under the registry (e.g. `my-app`) +- `audience` — must match your `OIDC_AUDIENCE` environment variable on the trupu server, including the `https://` prefix +- The workflow filename (e.g. `publish.yml`) must match the workflow part of your `ALLOWED_PUBLISHERS` config + +:::caution +The `audience` parameter in the OIDC token request must exactly match your `OIDC_AUDIENCE` environment variable. A mismatch will result in a 403 error. +:::