diff --git a/.env.example b/.env.example index ee9ea033e..55423d413 100644 --- a/.env.example +++ b/.env.example @@ -161,6 +161,13 @@ GITTENSORY_REVIEW_DRAFT=false # For `docker compose up` self-hosting (NOT the Cloudflare Worker deploy). Copy this file to `.env` # (gitignored), UNCOMMENT + fill the required Core secrets in section 2, then add the runtime values below. # Every value here is a SAMPLE placeholder — never commit real secrets. +# +# RECOMMENDED over pasting secret values into this file at all: docker-compose.yml's native `secrets:` +# mounts (secrets/README.md) cover the Core secrets above plus TOKEN_ENCRYPTION_SECRET, +# DRAFT_TOKEN_ENCRYPTION_SECRET, SELFHOST_SETUP_TOKEN, ORB_ENROLLMENT_SECRET, and PAGERDUTY_ROUTING_KEY. +# Run `./scripts/selfhost-init-secrets.sh` once, then write each real value into its file under secrets/ +# instead of uncommenting the var here — an inline .env value always takes priority if you set both, so +# migrating is safe to do one secret at a time. # PUBLIC_API_ORIGIN=https://reviews.example.com # REQUIRED before the first-run setup wizards (GET /setup and # # GET /orb/setup). The wizard embeds this origin in the GitHub App diff --git a/.env.selfhost.example b/.env.selfhost.example index 95220217a..2d8903f98 100644 --- a/.env.selfhost.example +++ b/.env.selfhost.example @@ -13,6 +13,14 @@ # ============================================================================= # 1. Required secrets — the app will not start without these # ============================================================================= +# +# RECOMMENDED: use native Docker Compose secret files instead of pasting values below. Run +# `./scripts/selfhost-init-secrets.sh` once, then write each real secret into its file under +# secrets/ (see secrets/README.md) — docker-compose.yml already wires the matching _FILE +# var for every secret in this section, so if you go that route you can leave the plain vars below +# commented out entirely. Either path works; an inline value here always wins over a secret file if +# both are set. GITHUB_APP_PRIVATE_KEY_FILE below is the one that has always supported this — the +# rest of the secrets in this section now do too. GITHUB_APP_ID=123456 GITHUB_APP_SLUG=my-gittensory-app GITHUB_APP_PRIVATE_KEY_FILE=/run/secrets/github-app-private-key.pem # or GITHUB_APP_PRIVATE_KEY= inline @@ -21,7 +29,8 @@ GITTENSOR_REGISTRY_URL=https://example.invalid/registry.json # The four secrets below are commented out ON PURPOSE — do not paste a placeholder string here. # Generate a real random value for EACH ONE individually (e.g. `openssl rand -hex 32`) and uncomment # it; the app's boot-time preflight check refuses to start with a known-placeholder or too-short -# value, so copy-pasting the same string into more than one of these will also fail preflight. +# value, so copy-pasting the same string into more than one of these will also fail preflight. Or +# skip uncommenting these entirely and write the value into secrets/.txt instead (see above). # GITHUB_WEBHOOK_SECRET= # the sole HMAC key GitHub webhook deliveries are verified against # GITTENSORY_API_TOKEN= # server-to-server API bearer token — bypasses per-repo write checks # GITTENSORY_MCP_TOKEN= # shared MCP bearer token diff --git a/.gitignore b/.gitignore index 37840feb9..a55b9c157 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,12 @@ output/ .env.* !.env.example !.env.selfhost.example +# Docker Compose secret files: the actual secret VALUES an operator drops into +# secrets/.txt for the native `secrets:` mount (docker-compose.yml), read via the existing generic +# _FILE convention (src/selfhost/load-file-secrets.ts). Same exposure class as .env above -- never +# committed. README.md stays tracked (it's the setup doc, not a secret) via the negation below. +secrets/* +!secrets/README.md docker-compose.override.yml !docker-compose.override.yml.example *.local diff --git a/apps/gittensory-ui/src/routes/docs.self-hosting-security.tsx b/apps/gittensory-ui/src/routes/docs.self-hosting-security.tsx index f5ba5abfa..83aeb69a4 100644 --- a/apps/gittensory-ui/src/routes/docs.self-hosting-security.tsx +++ b/apps/gittensory-ui/src/routes/docs.self-hosting-security.tsx @@ -52,6 +52,23 @@ function SelfHostingSecurity() { }, ]} /> +

+ docker-compose.yml ships native Docker Compose secrets: mounts for + the highest-value secrets (the GitHub App private key, webhook secret, API/MCP/internal-job + tokens, the setup token, the two token-encryption master keys, the Orb enrollment secret, + and the PagerDuty routing key) — file-mounted at /run/secrets/<name>, + never exposed via docker inspect or docker compose config the way + a plain environment:/env_file value is. This is purely additive: + an inline .env value always takes priority if you set both, so you can migrate + one secret at a time, or not at all. See secrets/README.md for the full file + list. +

+ secrets/github_webhook_secret.txt +docker compose up -d --no-deps gittensory`} + />

Private policy

diff --git a/docker-compose.yml b/docker-compose.yml index 4005e7fdb..ba2f71385 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -116,6 +116,20 @@ services: REDIS_URL: "${REDIS_URL:-redis://redis:6379}" # Enables the GitHub GET-response cache when >0 (0 disables); not a per-entry TTL, see .env.example. GITHUB_CACHE_TTL_SECONDS: "${GITHUB_CACHE_TTL_SECONDS:-20}" + # Native secret-file mounts (see the top-level `secrets:` block + secrets/README.md). Each points at + # the file Compose mounts below; src/selfhost/load-file-secrets.ts only reads it when the plain + # variable above is unset, so an inline .env value always takes priority over these — purely + # additive, zero effect on an operator who has never touched secrets/. + GITHUB_APP_PRIVATE_KEY_FILE: "${GITHUB_APP_PRIVATE_KEY_FILE:-/run/secrets/github_app_private_key}" + GITHUB_WEBHOOK_SECRET_FILE: "${GITHUB_WEBHOOK_SECRET_FILE:-/run/secrets/github_webhook_secret}" + GITTENSORY_API_TOKEN_FILE: "${GITTENSORY_API_TOKEN_FILE:-/run/secrets/gittensory_api_token}" + GITTENSORY_MCP_TOKEN_FILE: "${GITTENSORY_MCP_TOKEN_FILE:-/run/secrets/gittensory_mcp_token}" + INTERNAL_JOB_TOKEN_FILE: "${INTERNAL_JOB_TOKEN_FILE:-/run/secrets/internal_job_token}" + SELFHOST_SETUP_TOKEN_FILE: "${SELFHOST_SETUP_TOKEN_FILE:-/run/secrets/selfhost_setup_token}" + TOKEN_ENCRYPTION_SECRET_FILE: "${TOKEN_ENCRYPTION_SECRET_FILE:-/run/secrets/token_encryption_secret}" + DRAFT_TOKEN_ENCRYPTION_SECRET_FILE: "${DRAFT_TOKEN_ENCRYPTION_SECRET_FILE:-/run/secrets/draft_token_encryption_secret}" + ORB_ENROLLMENT_SECRET_FILE: "${ORB_ENROLLMENT_SECRET_FILE:-/run/secrets/orb_enrollment_secret}" + PAGERDUTY_ROUTING_KEY_FILE: "${PAGERDUTY_ROUTING_KEY_FILE:-/run/secrets/pagerduty_routing_key}" # Uncomment for Qdrant RAG vector store (--profile qdrant): # QDRANT_URL: http://qdrant:6333 # Uncomment for Ollama AI (--profile ollama): @@ -132,6 +146,19 @@ services: # is deep-merged over (gitignored — never commit real policy; see config/examples/ for generic templates). # Absent ⇒ Docker mounts an empty dir ⇒ defaults apply. - ./gittensory-config:/config:ro + # Mounted read-only at /run/secrets/ (Compose's default target). See the top-level `secrets:` + # block above and secrets/README.md. + secrets: + - github_app_private_key + - github_webhook_secret + - gittensory_api_token + - gittensory_mcp_token + - internal_job_token + - selfhost_setup_token + - token_encryption_secret + - draft_token_encryption_secret + - orb_enrollment_secret + - pagerduty_routing_key depends_on: redis: condition: service_healthy @@ -938,6 +965,36 @@ services: interval: 30s retries: 5 +# Native Docker Compose secrets: file-mounted (at /run/secrets/), never +# exposed via `docker inspect`/`docker compose config` the way a plain `environment:`/`env_file` value is. +# Read by the app's existing generic _FILE loader (src/selfhost/load-file-secrets.ts) via the +# `environment:` defaults set below on the gittensory service. Every file here is created empty (a no-op -- +# an inline .env value always wins, see secrets/README.md) by `scripts/selfhost-init-secrets.sh`, which +# every deploy runs before `docker compose build/up` so a missing file source can never break a deploy for +# an operator who has not opted into this convention. Scoped to the always-on gittensory service's own +# highest-value secrets for now -- the same convention extends to any other service the same way. +secrets: + github_app_private_key: + file: ./secrets/github_app_private_key.pem + github_webhook_secret: + file: ./secrets/github_webhook_secret.txt + gittensory_api_token: + file: ./secrets/gittensory_api_token.txt + gittensory_mcp_token: + file: ./secrets/gittensory_mcp_token.txt + internal_job_token: + file: ./secrets/internal_job_token.txt + selfhost_setup_token: + file: ./secrets/selfhost_setup_token.txt + token_encryption_secret: + file: ./secrets/token_encryption_secret.txt + draft_token_encryption_secret: + file: ./secrets/draft_token_encryption_secret.txt + orb_enrollment_secret: + file: ./secrets/orb_enrollment_secret.txt + pagerduty_routing_key: + file: ./secrets/pagerduty_routing_key.txt + volumes: gittensory-data: gittensory-pg: diff --git a/scripts/deploy-selfhost-image.sh b/scripts/deploy-selfhost-image.sh index 114937588..419de5d48 100755 --- a/scripts/deploy-selfhost-image.sh +++ b/scripts/deploy-selfhost-image.sh @@ -108,6 +108,9 @@ YAML mapfile -t compose_args < <(compose_file_args) compose_args+=(-f "$override_file") +echo "selfhost image deploy: ensuring secret placeholder files exist" +"$SCRIPT_DIR/selfhost-init-secrets.sh" + echo "selfhost image deploy: pulling $IMAGE" docker compose "${compose_args[@]}" pull --policy always "$SERVICE" diff --git a/scripts/deploy-selfhost-prebuilt.sh b/scripts/deploy-selfhost-prebuilt.sh index 0718ea169..33e3a2c28 100755 --- a/scripts/deploy-selfhost-prebuilt.sh +++ b/scripts/deploy-selfhost-prebuilt.sh @@ -76,6 +76,11 @@ run_sentry_upload() { sh -lc 'apt-get update >/dev/null && apt-get install -y --no-install-recommends ca-certificates git >/dev/null && git config --global --add safe.directory /work && (npx -y "$SENTRY_CLI_PACKAGE" releases new "$SENTRY_RELEASE" >/tmp/gittensory-sentry-release-new.log 2>&1 || true) && npx -y "$SENTRY_CLI_PACKAGE" releases set-commits "$SENTRY_RELEASE" --auto && npx -y "$SENTRY_CLI_PACKAGE" sourcemaps inject dist && node scripts/validate-selfhost-sourcemap.mjs && npx -y "$SENTRY_CLI_PACKAGE" sourcemaps upload --release="$SENTRY_RELEASE" dist && npx -y "$SENTRY_CLI_PACKAGE" releases finalize "$SENTRY_RELEASE" && chown -R "$HOST_UID:$HOST_GID" dist node_modules package-lock.json' } +run_init_secrets() { + echo "selfhost deploy: ensuring secret placeholder files exist" + "$SCRIPT_DIR/selfhost-init-secrets.sh" +} + run_compose_deploy() { local override_file local -a compose_args @@ -126,6 +131,7 @@ env_put SENTRY_RELEASE "$SENTRY_RELEASE" env_put GITTENSORY_VERSION "$SENTRY_RELEASE" run_node_build +run_init_secrets run_sentry_upload run_compose_deploy diff --git a/scripts/selfhost-init-secrets.sh b/scripts/selfhost-init-secrets.sh new file mode 100755 index 000000000..8192d3477 --- /dev/null +++ b/scripts/selfhost-init-secrets.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Ensure every Docker Compose secret file docker-compose.yml's `gittensory` service references +# actually exists on disk, so `docker compose build`/`up` never fails on a missing `secrets:` source +# file -- Compose requires the file to exist even for an operator who has never touched this feature +# and is relying entirely on inline .env values (see secrets/README.md: an inline value always wins +# over the file, so a placeholder here is a pure no-op for that operator). +# +# IDEMPOTENT AND NON-DESTRUCTIVE: only ever creates a MISSING file, empty, chmod 600. Never touches a +# file that already exists (whether it's still an empty placeholder or a real secret an operator has +# since populated) -- safe to run on every deploy, unconditionally. +# +# Usage: +# ./scripts/selfhost-init-secrets.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR/.." + +SECRETS_DIR="secrets" + +# Keep in sync with the `secrets:` table in docker-compose.yml and secrets/README.md. +SECRET_FILES=( + "github_app_private_key.pem" + "github_webhook_secret.txt" + "gittensory_api_token.txt" + "gittensory_mcp_token.txt" + "internal_job_token.txt" + "selfhost_setup_token.txt" + "token_encryption_secret.txt" + "draft_token_encryption_secret.txt" + "orb_enrollment_secret.txt" + "pagerduty_routing_key.txt" +) + +mkdir -p "$SECRETS_DIR" + +created=0 +for name in "${SECRET_FILES[@]}"; do + path="$SECRETS_DIR/$name" + if [ ! -e "$path" ]; then + : >"$path" + chmod 600 "$path" + created=$((created + 1)) + fi +done + +if [ "$created" -gt 0 ]; then + echo "selfhost init-secrets: created $created empty placeholder file(s) in $SECRETS_DIR/" +else + echo "selfhost init-secrets: all secret files already present, nothing to do" +fi diff --git a/secrets/README.md b/secrets/README.md new file mode 100644 index 000000000..723224a30 --- /dev/null +++ b/secrets/README.md @@ -0,0 +1,68 @@ +# Docker Compose secret files + +Native, orchestrator-managed secret storage for the self-host stack (`docker-compose.yml`'s +top-level `secrets:` block), per the "Prefer secret files" guidance on +[/docs/self-hosting-security](https://gittensory.aethereal.dev/docs/self-hosting-security). + +## Why + +Putting a real secret's *value* directly in `.env` means it's readable via `docker inspect`, +`docker compose config`, and any process on the host with access to the container's environment. +Mounting it as a **file** instead keeps the value out of both — the container only ever sees a +path, and the app reads the file's contents itself at startup. + +## How it works + +Every secret below is optional and additive. **Nothing here is required** — if you're not ready to +set up secret files, leave `.env` exactly as it is today (`GITHUB_APP_PRIVATE_KEY=...` etc. inline) +and this directory has no effect. `docker-compose.yml` sets a `_FILE=/run/secrets/` +default for every secret listed below, but the app's existing generic loader +(`src/selfhost/load-file-secrets.ts`) only ever reads the file when the plain `` variable is +**not already set** — an inline `.env` value always wins. So the two mechanisms coexist safely: +migrate one secret at a time, or never migrate at all. + +To use a secret file instead of an inline `.env` value: + +1. Remove (or leave commented) the plain `=...` line in `.env`. +2. Write the raw secret value into the matching file below, with no surrounding quotes and no + trailing newline requirement (the loader trims whitespace): + ```sh + printf '%s' 'your-real-secret-value' > secrets/github_webhook_secret.txt + ``` + For the GitHub App private key specifically, write the full PEM file as-is: + ```sh + cp /path/to/your-downloaded-key.pem secrets/github_app_private_key.pem + ``` +3. Restart the `gittensory` service (`docker compose up -d --no-deps gittensory`, or run + `./scripts/selfhost-update.sh`). + +Every file below is `chmod 600`-worthy — the init script that creates the empty placeholders +(`scripts/selfhost-init-secrets.sh`) does this for you; keep that permission if you edit a file by +hand afterward. + +## Files + +| File | Env var | Purpose | +|---|---|---| +| `github_app_private_key.pem` | `GITHUB_APP_PRIVATE_KEY_FILE` | Your GitHub App's private key (PEM). | +| `github_webhook_secret.txt` | `GITHUB_WEBHOOK_SECRET_FILE` | HMAC key GitHub webhook deliveries are verified against. | +| `gittensory_api_token.txt` | `GITTENSORY_API_TOKEN_FILE` | Server-to-server API bearer token. | +| `gittensory_mcp_token.txt` | `GITTENSORY_MCP_TOKEN_FILE` | Shared MCP bearer token. | +| `internal_job_token.txt` | `INTERNAL_JOB_TOKEN_FILE` | Gates internal-only routes (e.g. `/v1/internal/*`). | +| `selfhost_setup_token.txt` | `SELFHOST_SETUP_TOKEN_FILE` | Unlocks the first-run `/setup` wizard. | +| `token_encryption_secret.txt` | `TOKEN_ENCRYPTION_SECRET_FILE` | AES-256-GCM master secret for maintainer BYOK keys at rest. | +| `draft_token_encryption_secret.txt` | `DRAFT_TOKEN_ENCRYPTION_SECRET_FILE` | AES-256-GCM secret for the contributor OAuth token (draft flow). | +| `orb_enrollment_secret.txt` | `ORB_ENROLLMENT_SECRET_FILE` | One-time enrollment secret for brokered Orb mode. | +| `pagerduty_routing_key.txt` | `PAGERDUTY_ROUTING_KEY_FILE` | PagerDuty Events API v2 routing key (experimental paging integration). | + +This is not the full list of every secret-shaped env var the stack supports (AI provider API keys, +Discord/Slack webhooks, Postgres/Grafana credentials for their optional profiles, etc.) — it covers +the vars used by the always-on `gittensory` service. The same `_FILE` convention works for any +of those too; add a matching `secrets:` entry in `docker-compose.yml` (or a +`docker-compose.override.yml`) if you want the same treatment for one of them. + +## Never commit real files here + +Everything in this directory except this README is gitignored. `scripts/selfhost-init-secrets.sh` +only ever creates **empty** placeholder files (so `docker compose build`/`up` never fails on a +missing file) — it never overwrites a file that already exists, so it's always safe to re-run.