From 78a6688711bd1f99b32f4dea656d34cd4584ab67 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Wed, 23 Sep 2026 18:17:41 +0200 Subject: [PATCH 1/9] Name the first Zitadel org and admin through env vars Add ZITADEL_ORG_NAME and ZITADEL_ADMIN_{FIRST,LAST,DISPLAY}_NAME (defaults keep the previous ZITADEL naming for dev); the prod example uses Iker Demo / admin@ikerdemo.cat. --- .env.prod.example | 6 +++++- compose.yml | 9 ++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.env.prod.example b/.env.prod.example index f390ca7..41c54cf 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -21,7 +21,11 @@ ZITADEL_EXTERNAL_PORT=443 ZITADEL_EXTERNAL_SECURE=true ZITADEL_TLS_MODE=external ZITADEL_DATABASE_PASSWORD=CHANGE_ME -ZITADEL_ADMIN_USERNAME=admin +ZITADEL_ORG_NAME=Iker Demo +ZITADEL_ADMIN_USERNAME=admin@ikerdemo.cat +ZITADEL_ADMIN_FIRST_NAME=Iker Demo +ZITADEL_ADMIN_LAST_NAME=Admin +ZITADEL_ADMIN_DISPLAY_NAME=Iker Demo Admin ZITADEL_ADMIN_PASSWORD=CHANGE_ME # --- SeaweedFS (S3) ---------------------------------------------------------- diff --git a/compose.yml b/compose.yml index 3f09dc3..efd219e 100644 --- a/compose.yml +++ b/compose.yml @@ -69,8 +69,15 @@ services: ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: ${POSTGRES_USER} ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: ${POSTGRES_PASSWORD} ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable - # Initial console admin. Login: @zitadel. + # First organization and its console admin. Only read when the instance + # is created; changing them later doesn't rename anything that exists. + # A username without "@" gets `@` appended; one with "@" is + # kept as is (e.g. admin@example.com). + ZITADEL_FIRSTINSTANCE_ORG_NAME: ${ZITADEL_ORG_NAME:-ZITADEL} ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME: ${ZITADEL_ADMIN_USERNAME} + ZITADEL_FIRSTINSTANCE_ORG_HUMAN_FIRSTNAME: ${ZITADEL_ADMIN_FIRST_NAME:-ZITADEL} + ZITADEL_FIRSTINSTANCE_ORG_HUMAN_LASTNAME: ${ZITADEL_ADMIN_LAST_NAME:-Admin} + ZITADEL_FIRSTINSTANCE_ORG_HUMAN_DISPLAYNAME: ${ZITADEL_ADMIN_DISPLAY_NAME:-ZITADEL Admin} ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: ${ZITADEL_ADMIN_PASSWORD} ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: "false" # Service account + PAT written on init; each consuming project's own -- 2.54.0 From 7d1a3e75d804d31b0e5db584e2634664f3663656 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 15:05:41 +0200 Subject: [PATCH 2/9] Raise musify proxy buffers for the OIDC session cookies The OIDC callback answers with the session cookies (encrypted tokens), which don't fit nginx's default 4k response-header buffer. --- edge/conf.d/musify.conf | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/edge/conf.d/musify.conf b/edge/conf.d/musify.conf index a5163cd..b8d14b2 100644 --- a/edge/conf.d/musify.conf +++ b/edge/conf.d/musify.conf @@ -15,6 +15,13 @@ server { server_name musify.ikerdemo.cat; client_max_body_size 1024m; + # The OIDC callback answers with the session cookies (encrypted tokens), + # bigger than the 4k default response-header buffer. + proxy_buffer_size 32k; + proxy_buffers 8 32k; + proxy_busy_buffers_size 64k; + large_client_header_buffers 4 16k; + ssl_certificate /etc/letsencrypt/live/musify.ikerdemo.cat/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/musify.ikerdemo.cat/privkey.pem; include /etc/nginx/snippets/tls.conf; -- 2.54.0 From 6dcbd9589a1e898415c5d1dd903f5709b7edcc6f Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 15:05:42 +0200 Subject: [PATCH 3/9] Serve the Zitadel login from the Login V2 container Zitadel v4 redirects every sign-in to /ui/v2/login, which is served by a separate container (zitadel-login) that this stack didn't run, so logging in on a fresh instance ended in a 404. - Add zitadel-login, with its own service account (login-client) whose PAT Zitadel writes to a volume only that container mounts. - Configure the Login V2 base URL (ZITADEL_LOGIN_BASE_URL): its own port in dev, same hostname as Zitadel behind the edge in prod. - Pin zitadel and zitadel-login to v4.19.1 and add healthchecks. - Run Zitadel as root, as Zitadel's own compose does, so it can write the PATs into root-owned mounts (the Linux bind mount and the new volume). - Route /ui/v2/login on the auth vhost to the login container and drop the no-op grpc_set_header. - Document the extra steps for an instance created before this. --- .env.example | 5 ++++ .env.prod.example | 3 +++ README.md | 36 +++++++++++++++++++++++++-- compose.dev.yml | 6 +++++ compose.yml | 54 ++++++++++++++++++++++++++++++++++++++++- edge/conf.d/musify.conf | 8 +++++- 6 files changed, 108 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index f059b1c..88ab36e 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,11 @@ ZITADEL_MASTERKEY=MasterkeyNeedsToHave32Characters ZITADEL_EXTERNAL_DOMAIN=host.docker.internal ZITADEL_EXTERNAL_PORT=8080 ZITADEL_EXTERNAL_SECURE=false +ZITADEL_PUBLIC_SCHEME=http +# Login V2 UI (zitadel-login). In dev it has its own port; the URL has no +# trailing slash. Only read when the Zitadel instance is created. +ZITADEL_LOGIN_HOST_PORT=3100 +ZITADEL_LOGIN_BASE_URL=http://host.docker.internal:3100/ui/v2/login ZITADEL_DATABASE_PASSWORD=zitadel # Console login: @zitadel. ZITADEL_ADMIN_USERNAME=admin diff --git a/.env.prod.example b/.env.prod.example index 41c54cf..5de0990 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -20,6 +20,9 @@ ZITADEL_EXTERNAL_DOMAIN=auth.example.com ZITADEL_EXTERNAL_PORT=443 ZITADEL_EXTERNAL_SECURE=true ZITADEL_TLS_MODE=external +ZITADEL_PUBLIC_SCHEME=https +# Login V2 UI, routed by the edge under the same hostname. No trailing slash. +ZITADEL_LOGIN_BASE_URL=https://auth.example.com/ui/v2/login ZITADEL_DATABASE_PASSWORD=CHANGE_ME ZITADEL_ORG_NAME=Iker Demo ZITADEL_ADMIN_USERNAME=admin@ikerdemo.cat diff --git a/README.md b/README.md index 1172a40..5c19ffa 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ cp .env.example .env docker compose -f compose.yml -f compose.dev.yml up -d ``` -That starts Postgres, Zitadel, SeaweedFS, RabbitMQ and Jaeger, with their +That starts Postgres, Zitadel (and its login UI), SeaweedFS, RabbitMQ and Jaeger, with their ports published to `localhost`. Add `--profile tools` to also start pgAdmin. In production, the base `compose.yml` file **is** the production shape @@ -60,6 +60,7 @@ external network to reach `postgres`, `zitadel`, `seaweedfs`, `rabbitmq` and |---|---|---| | PostgreSQL | `5432` | one instance, one database per project | | Zitadel | `8080` | OIDC/OAuth2, console at `/ui/console` | +| Zitadel login | `3100` | Login V2 UI at `/ui/v2/login` (under `8080`'s hostname in prod) | | SeaweedFS | `8333` / `8888` / `9333` | S3 API / filer / master | | RabbitMQ | `5672` / `15672` | AMQP / management UI | | Jaeger | `16686` / `4317` / `4318` | UI / OTLP gRPC / OTLP HTTP | @@ -95,6 +96,37 @@ with an example value that assumes these repos happen to sit as sibling folders. If yours don't, change it. Not versioned, and regenerated whenever the Zitadel volume is reset. +### The Zitadel login (Login V2) + +Zitadel v4 serves its sign-in pages from a separate container, +`zitadel-login` (Next.js), under `/ui/v2/login`. Zitadel redirects every +login there, so it has to be running for anyone to sign in. It calls the +Zitadel API with its own service account (`login-client`), whose PAT Zitadel +writes on init to the `zitadel_bootstrap` volume, which only `zitadel-login` +mounts. + +- **Dev**: no proxy, so the login has its own port (`ZITADEL_LOGIN_HOST_PORT`, + `3100`) and `ZITADEL_LOGIN_BASE_URL` points the browser at it. +- **Prod**: the edge routes `/ui/v2/login` on Zitadel's hostname to + `infra-zitadel-login`, and everything else to Zitadel. +- `zitadel` and `zitadel-login` are pinned to the same version; bump both + together. + +`ZITADEL_LOGIN_BASE_URL` and the `login-client` PAT are only applied when the +instance is created. On an instance created before `zitadel-login` existed: + +1. In the console, create a service user `login-client`, give it the + instance role **IAM Login Client**, and create a PAT for it. +2. Copy it into the volume and restart the login: + + ```bash + docker cp login-client.pat infra-zitadel:/bootstrap/login-client.pat + docker restart infra-zitadel-login + ``` + +3. If the instance still uses the old login, set Login V2 as required with + base URI `${ZITADEL_LOGIN_BASE_URL}/` in the instance's feature settings. + ## The public edge `edge/compose.yml` is the only thing that publishes host ports 80/443: one @@ -138,7 +170,7 @@ re-creates its piece on the next `up`, since both are idempotent. ``` Infrastructure/ -├─ compose.yml # postgres, zitadel, seaweedfs, rabbitmq, jaeger: the prod shape +├─ compose.yml # postgres, zitadel (+ login), seaweedfs, rabbitmq, jaeger: the prod shape ├─ edge/ # shared public nginx + certbot (prod only): compose.yml, conf.d/, snippets/ ├─ compose.dev.yml # dev overlay: publishes ports, adds pgAdmin ├─ .env.example / .env.prod.example diff --git a/compose.dev.yml b/compose.dev.yml index 77d2974..b235237 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -15,6 +15,12 @@ services: ports: - "${ZITADEL_EXTERNAL_PORT}:8080" + # No edge in dev to put the login under Zitadel's own port, so it gets its + # own (ZITADEL_LOGIN_BASE_URL in .env points the browser at it). + zitadel-login: + ports: + - "${ZITADEL_LOGIN_HOST_PORT}:3000" + seaweedfs: ports: - "9333:9333" # master / admin UI diff --git a/compose.yml b/compose.yml index efd219e..6fa4981 100644 --- a/compose.yml +++ b/compose.yml @@ -45,8 +45,13 @@ services: # setup (both idempotent) and then starts, so the same command works on a # cold and on a warm volume. zitadel: - image: ghcr.io/zitadel/zitadel:latest + # Pinned together with zitadel-login below: bump both at once. + image: ghcr.io/zitadel/zitadel:v4.19.1 container_name: infra-zitadel + # The image runs as uid 1000, but Docker creates both the bind mount + # (on Linux) and the bootstrap volume as root, so Zitadel couldn't write + # the PATs into them. Same as Zitadel's own compose. + user: "0" # `disabled` (dev, plain http) makes Zitadel advertise http URLs; behind a # TLS-terminating proxy it must be `external` or the console calls # http:// and the CSP blocks it. @@ -86,11 +91,57 @@ services: ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME: Admin Service Account ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE: "2099-01-01T00:00:00Z" ZITADEL_FIRSTINSTANCE_PATPATH: /output/admin-sa.pat + # Service account the login UI (zitadel-login) calls the API with. Its + # PAT goes to a volume only that container mounts, not to /output. + ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_USERNAME: login-client + ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_NAME: Automatically Initialized IAM_LOGIN_CLIENT + ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_PAT_EXPIRATIONDATE: "2099-01-01T00:00:00Z" + ZITADEL_FIRSTINSTANCE_LOGINCLIENTPATPATH: /bootstrap/login-client.pat + # Login V2: sign-in pages are served by zitadel-login, not by Zitadel + # itself. Like the FIRSTINSTANCE values, these are only read when the + # instance is created (see README for an existing instance). + ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: "true" + ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI: ${ZITADEL_LOGIN_BASE_URL}/ + ZITADEL_OIDC_DEFAULTLOGINURLV2: ${ZITADEL_LOGIN_BASE_URL}/login?authRequest= + ZITADEL_OIDC_DEFAULTLOGOUTURLV2: ${ZITADEL_LOGIN_BASE_URL}/logout?post_logout_redirect= + ZITADEL_SAML_DEFAULTLOGINURLV2: ${ZITADEL_LOGIN_BASE_URL}/login?samlRequest= depends_on: postgres: condition: service_healthy volumes: - ./zitadel/.output:/output + - zitadel_bootstrap:/bootstrap + healthcheck: + test: ["CMD", "/app/zitadel", "ready"] + interval: 10s + timeout: 30s + retries: 12 + start_period: 20s + networks: [infra-net] + restart: unless-stopped + + # Login V2 UI (Next.js), served under /ui/v2/login. It calls the API + # directly on infra-net, so it has to present the public Host/scheme for + # Zitadel to find the instance. + zitadel-login: + image: ghcr.io/zitadel/zitadel-login:v4.19.1 + container_name: infra-zitadel-login + environment: + ZITADEL_API_URL: http://zitadel:8080 + NEXT_PUBLIC_BASE_PATH: /ui/v2/login + ZITADEL_SERVICE_USER_TOKEN_FILE: /bootstrap/login-client.pat + CUSTOM_REQUEST_HEADERS: Host:${ZITADEL_EXTERNAL_DOMAIN},X-Forwarded-Proto:${ZITADEL_PUBLIC_SCHEME} + healthcheck: + test: ["CMD", "/bin/sh", "-c", "node /app/healthcheck.mjs http://localhost:3000/ui/v2/login/healthy"] + interval: 10s + timeout: 30s + retries: 12 + start_period: 20s + depends_on: + zitadel: + condition: service_healthy + volumes: + - zitadel_bootstrap:/bootstrap:ro networks: [infra-net] restart: unless-stopped @@ -166,5 +217,6 @@ services: volumes: postgres_data: + zitadel_bootstrap: seaweedfs_data: rabbitmq_data: diff --git a/edge/conf.d/musify.conf b/edge/conf.d/musify.conf index b8d14b2..497669f 100644 --- a/edge/conf.d/musify.conf +++ b/edge/conf.d/musify.conf @@ -80,11 +80,17 @@ server { ssl_certificate_key /etc/letsencrypt/live/musify.ikerdemo.cat/privkey.pem; include /etc/nginx/snippets/tls.conf; + # Login V2 UI, a separate container (see ../compose.yml). + location /ui/v2/login { + set $upstream http://infra-zitadel-login:3000; + proxy_pass $upstream; + include /etc/nginx/snippets/proxy.conf; + } + location / { set $upstream http://zitadel:8080; proxy_pass $upstream; include /etc/nginx/snippets/proxy.conf; - grpc_set_header Host $host; } } -- 2.54.0 From 3e1ee281251eb83c69731d922c0772903bb4329e Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 15:05:43 +0200 Subject: [PATCH 4/9] Keep RabbitMQ data on recreate and proxy to unique container names RabbitMQ stores its data under rabbit@, and the hostname changed with every recreated container, so an image update or an .env change came up with an empty broker. Pin it to `rabbitmq` and document how to carry the definitions over on an existing deployment. Every project shares infra-net, where a generic service name like `api` resolves to every container that has it, round-robin, across projects. The edge now proxies to container names (musify-api, infra-zitadel, ...). --- README.md | 28 ++++++++++++++++++++++++++-- compose.yml | 4 ++++ edge/conf.d/musify.conf | 10 +++++----- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 5c19ffa..763413e 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,30 @@ docker compose --env-file .env.prod -f compose.yml up -d A consuming project's own production compose joins `infra-net` as an external network to reach `postgres`, `zitadel`, `seaweedfs`, `rabbitmq` and -`jaeger` by container name (see Musify's `deploy/compose.prod.yml`). +`jaeger` by name (see Musify's `deploy/compose.prod.yml`). + +Every project on `infra-net` shares one DNS namespace, and Docker answers a +service name with *every* container that has it, round-robin. Two projects +each with a service called `api` would get each other's traffic. So anything +that crosses projects (the edge, or one project calling into this stack) +should use the unique `container_name` (`infra-postgres`, `musify-api`, ...), +never a generic service name. + +### RabbitMQ and its hostname + +RabbitMQ keeps its data under its node name, `rabbit@`, so the +container has a fixed `hostname: rabbitmq`. Without it every recreated +container (an image update, an `.env` change) came up with a new hostname +and an empty broker. A deployment that ran before this was fixed has its +data under the old random name; carry the definitions (users, vhosts, +permissions, queues, exchanges) over when applying the change, ideally with +the queues drained, since queued messages aren't part of the export: + +```bash +docker exec infra-rabbitmq rabbitmqctl export_definitions /var/lib/rabbitmq/defs.json +docker compose --env-file .env.prod -f compose.yml up -d rabbitmq +docker exec infra-rabbitmq rabbitmqctl import_definitions /var/lib/rabbitmq/defs.json +``` ### Ports (development) @@ -143,7 +166,8 @@ docker compose -f edge/compose.yml up -d - **Vhosts** live in `edge/conf.d/` (one file per project; `00-common.conf` has the shared resolver). Every upstream is a `set $upstream ...`, resolved per request, so a project's stack being down never stops the edge from - starting. Shared bits are in `edge/snippets/`. Check a change with + starting. Upstreams use the target's `container_name` (see "Running it" + above for why). Shared bits are in `edge/snippets/`. Check a change with `docker exec edge-nginx nginx -t`, then `docker exec edge-nginx nginx -s reload`. - **Certificates** are state, not versioned (`edge/certbot/`). To issue one for new hostnames (their DNS has to already point at the host): diff --git a/compose.yml b/compose.yml index 6fa4981..d7964e6 100644 --- a/compose.yml +++ b/compose.yml @@ -192,6 +192,10 @@ services: rabbitmq: image: rabbitmq:4-management-alpine container_name: infra-rabbitmq + # The node is named rabbit@ and its data lives under that name. + # Without a fixed hostname every recreated container gets a new one and + # starts on an empty data directory (users, vhosts, queues all gone). + hostname: rabbitmq environment: RABBITMQ_DEFAULT_USER: ${RABBITMQ_DEFAULT_USER} RABBITMQ_DEFAULT_PASS: ${RABBITMQ_DEFAULT_PASS} diff --git a/edge/conf.d/musify.conf b/edge/conf.d/musify.conf index 497669f..264bb44 100644 --- a/edge/conf.d/musify.conf +++ b/edge/conf.d/musify.conf @@ -27,7 +27,7 @@ server { include /etc/nginx/snippets/tls.conf; location / { - set $upstream http://web-player:3000; + set $upstream http://musify-web-player:3000; proxy_pass $upstream; include /etc/nginx/snippets/proxy.conf; } @@ -44,7 +44,7 @@ server { include /etc/nginx/snippets/tls.conf; location / { - set $upstream http://api:5111; + set $upstream http://musify-api:5111; proxy_pass $upstream; include /etc/nginx/snippets/proxy.conf; } @@ -62,7 +62,7 @@ server { include /etc/nginx/snippets/tls.conf; location / { - set $upstream http://gateway:8081; + set $upstream http://musify-gateway:8081; proxy_pass $upstream; include /etc/nginx/snippets/proxy.conf; proxy_buffering off; @@ -88,7 +88,7 @@ server { } location / { - set $upstream http://zitadel:8080; + set $upstream http://infra-zitadel:8080; proxy_pass $upstream; include /etc/nginx/snippets/proxy.conf; } @@ -107,7 +107,7 @@ server { include /etc/nginx/snippets/tls.conf; location / { - set $upstream http://seaweedfs:8333; + set $upstream http://infra-seaweedfs:8333; proxy_pass $upstream; include /etc/nginx/snippets/proxy.conf; proxy_request_buffering off; -- 2.54.0 From 22213a5592f0618936febf495f2f54ef1e842e79 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 15:05:44 +0200 Subject: [PATCH 5/9] Harden the edge: catch-all server, modern TLS and HSTS - Unknown hostnames and direct IP access no longer land on the first vhost (Gitea): port 80 closes the connection, except for ACME challenges, and port 443 refuses the handshake. - Mozilla intermediate ciphers (forward secrecy only, no CBC), session cache, no session tickets, and HSTS with a short max-age for now. - Set X-Forwarded-For to the client address instead of appending to whatever the client sent. --- README.md | 7 +++++++ edge/conf.d/01-default.conf | 25 +++++++++++++++++++++++++ edge/snippets/proxy.conf | 4 +++- edge/snippets/tls.conf | 13 ++++++++++++- 4 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 edge/conf.d/01-default.conf diff --git a/README.md b/README.md index 763413e..7d132da 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,13 @@ docker compose -f edge/compose.yml up -d starting. Upstreams use the target's `container_name` (see "Running it" above for why). Shared bits are in `edge/snippets/`. Check a change with `docker exec edge-nginx nginx -t`, then `docker exec edge-nginx nginx -s reload`. +- **Unknown hostnames** hit `01-default.conf`: port 80 closes the connection + (ACME challenges still work), port 443 refuses the TLS handshake. Nothing + reaches a project unless its vhost names the host. +- **TLS** settings (`snippets/tls.conf`) follow Mozilla's intermediate + profile and send HSTS with a 1-day `max-age`. Raise it once every hostname + is confirmed to work over https: browsers remember it, so a long value + can't be taken back quickly. - **Certificates** are state, not versioned (`edge/certbot/`). To issue one for new hostnames (their DNS has to already point at the host): diff --git a/edge/conf.d/01-default.conf b/edge/conf.d/01-default.conf new file mode 100644 index 0000000..3c78331 --- /dev/null +++ b/edge/conf.d/01-default.conf @@ -0,0 +1,25 @@ +# Catch-all for Host headers no vhost claims (scanners, direct IP access, +# stale DNS). Without it nginx hands them to the first vhost it loaded, +# i.e. Gitea. ACME stays reachable here so a certificate for a new hostname +# can be issued before its vhost exists. +server { + listen 80 default_server; + listen [::]:80 default_server; + server_name _; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 444; + } +} + +# No certificate to present for an unknown name, so refuse the handshake. +server { + listen 443 ssl default_server; + listen [::]:443 ssl default_server; + server_name _; + ssl_reject_handshake on; +} diff --git a/edge/snippets/proxy.conf b/edge/snippets/proxy.conf index 56bd34f..a3386ab 100644 --- a/edge/snippets/proxy.conf +++ b/edge/snippets/proxy.conf @@ -1,7 +1,9 @@ proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; -proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +# The edge is the first hop, so whatever X-Forwarded-For the client sent is +# made up: replace it instead of appending to it. +proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; diff --git a/edge/snippets/tls.conf b/edge/snippets/tls.conf index e79c9dd..e1afd10 100644 --- a/edge/snippets/tls.conf +++ b/edge/snippets/tls.conf @@ -1,2 +1,13 @@ +# Mozilla "intermediate" profile, without the DHE suites (they'd need a +# dhparam file): forward secrecy on every connection, no CBC. ssl_protocols TLSv1.2 TLSv1.3; -ssl_ciphers HIGH:!aNULL:!MD5; +ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305; +ssl_prefer_server_ciphers off; +ssl_session_cache shared:SSL:10m; +ssl_session_timeout 1d; +ssl_session_tickets off; + +# Short max-age for now: raise it (e.g. 63072000) once every hostname is +# confirmed working over https. A location that sets its own add_header +# stops inheriting this one. +add_header Strict-Transport-Security "max-age=86400" always; -- 2.54.0 From 0aa4cb14109a15d77381565fbba443e0be71c80a Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 15:05:45 +0200 Subject: [PATCH 6/9] Pin images, rotate logs, cap Jaeger and bind dev ports to loopback - Pin seaweedfs (4.47), jaeger (1.76.0), certbot (v5.8.0) and pgadmin (9.18) instead of :latest. - Rotate container logs (json-file, 3 x 10 MB) in every stack. - Cap Jaeger's in-memory storage at 100k traces and add a healthcheck. - Publish dev ports on 127.0.0.1 (DEV_BIND_ADDRESS) so the dev credentials aren't reachable from the LAN. - Fix the prod command in compose.yml's header, the README's note on who writes the admin PAT, and the certbot command's line breaks. --- .env.example | 5 +++++ README.md | 13 ++++++++----- compose.dev.yml | 38 +++++++++++++++++++++++++------------- compose.yml | 30 +++++++++++++++++++++++++++--- edge/compose.yml | 12 +++++++++++- 5 files changed, 76 insertions(+), 22 deletions(-) diff --git a/.env.example b/.env.example index 88ab36e..61c2885 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,11 @@ # # cp .env.example .env +# Address the dev ports are published on. 127.0.0.1 keeps them off the LAN. +# Use 0.0.0.0 only if your hosts file maps host.docker.internal to the LAN IP +# (then the browser can't reach 127.0.0.1-only ports through that name). +DEV_BIND_ADDRESS=127.0.0.1 + # --- PostgreSQL (shared instance, one database per project) ----------------- POSTGRES_USER=postgres POSTGRES_PASSWORD=postgres diff --git a/README.md b/README.md index 7d132da..c73a447 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,9 @@ docker compose -f compose.yml -f compose.dev.yml up -d ``` That starts Postgres, Zitadel (and its login UI), SeaweedFS, RabbitMQ and Jaeger, with their -ports published to `localhost`. Add `--profile tools` to also start pgAdmin. +ports published on `127.0.0.1` only (`DEV_BIND_ADDRESS`), so the dev +credentials aren't reachable from the LAN. Add `--profile tools` to also +start pgAdmin. In production, the base `compose.yml` file **is** the production shape already. There are no published ports, and everything is reachable only on @@ -106,9 +108,8 @@ match this repo's `.env`". This file is the source of truth for them. ### The Zitadel admin PAT -`zitadel-init` (in the compose file of whichever project runs it first) -writes a service-account personal access token to `zitadel/.output/admin-sa.pat` -on first init. Every consuming project mounts that same folder read-only, via +Zitadel itself writes a service-account personal access token to +`zitadel/.output/admin-sa.pat` when the instance is created. Every consuming project mounts that same folder read-only, via its own `ZITADEL_ADMIN_PAT_DIR` setting, to call the Zitadel management API and create its own project/apps. @@ -180,7 +181,9 @@ docker compose -f edge/compose.yml up -d for new hostnames (their DNS has to already point at the host): ```bash - docker compose -f edge/compose.yml run --rm --entrypoint certbot certbot certonly --webroot -w /var/www/certbot -d host1.example.com -d host2.example.com --non-interactive --agree-tos -m you@example.com + docker compose -f edge/compose.yml run --rm --entrypoint certbot certbot \ + certonly --webroot -w /var/www/certbot -d host1.example.com -d host2.example.com \ + --non-interactive --agree-tos -m you@example.com ``` Then add the `server` blocks pointing at diff --git a/compose.dev.yml b/compose.dev.yml index b235237..f62ab4f 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -5,43 +5,49 @@ name: infra-dev # of joining infra-net. Adds pgAdmin. # # docker compose -f compose.yml -f compose.dev.yml up -d +# +# Ports are bound to DEV_BIND_ADDRESS (127.0.0.1 by default), so the dev +# credentials in .env are not reachable from the LAN. Other containers still +# reach them through host.docker.internal. The browser on the host does too +# as long as the hosts file maps host.docker.internal to 127.0.0.1; if yours +# maps it to the LAN IP, set DEV_BIND_ADDRESS=0.0.0.0 (see .env.example). services: postgres: ports: - - "${POSTGRES_HOST_PORT}:5432" + - "${DEV_BIND_ADDRESS:-127.0.0.1}:${POSTGRES_HOST_PORT}:5432" zitadel: ports: - - "${ZITADEL_EXTERNAL_PORT}:8080" + - "${DEV_BIND_ADDRESS:-127.0.0.1}:${ZITADEL_EXTERNAL_PORT}:8080" # No edge in dev to put the login under Zitadel's own port, so it gets its # own (ZITADEL_LOGIN_BASE_URL in .env points the browser at it). zitadel-login: ports: - - "${ZITADEL_LOGIN_HOST_PORT}:3000" + - "${DEV_BIND_ADDRESS:-127.0.0.1}:${ZITADEL_LOGIN_HOST_PORT}:3000" seaweedfs: ports: - - "9333:9333" # master / admin UI - - "8888:8888" # filer - - "8333:8333" # S3 API + - "${DEV_BIND_ADDRESS:-127.0.0.1}:9333:9333" # master / admin UI + - "${DEV_BIND_ADDRESS:-127.0.0.1}:8888:8888" # filer + - "${DEV_BIND_ADDRESS:-127.0.0.1}:8333:8333" # S3 API rabbitmq: ports: - - "5672:5672" # AMQP - - "15672:15672" # management UI + - "${DEV_BIND_ADDRESS:-127.0.0.1}:5672:5672" # AMQP + - "${DEV_BIND_ADDRESS:-127.0.0.1}:15672:15672" # management UI jaeger: ports: - - "16686:16686" # UI - - "4317:4317" # OTLP gRPC - - "4318:4318" # OTLP HTTP + - "${DEV_BIND_ADDRESS:-127.0.0.1}:16686:16686" # UI + - "${DEV_BIND_ADDRESS:-127.0.0.1}:4317:4317" # OTLP gRPC + - "${DEV_BIND_ADDRESS:-127.0.0.1}:4318:4318" # OTLP HTTP # Optional Postgres UI: docker compose ... --profile tools up -d pgadmin: profiles: [tools] - image: dpage/pgadmin4:latest + image: dpage/pgadmin4:9.18 container_name: infra-pgadmin environment: PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL} @@ -51,9 +57,15 @@ services: postgres: condition: service_healthy ports: - - "${PGADMIN_HOST_PORT}:80" + - "${DEV_BIND_ADDRESS:-127.0.0.1}:${PGADMIN_HOST_PORT}:80" volumes: - pgadmin_data:/var/lib/pgadmin + # Same rotation as compose.yml (YAML anchors don't cross files). + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" restart: unless-stopped volumes: diff --git a/compose.yml b/compose.yml index d7964e6..eeaf141 100644 --- a/compose.yml +++ b/compose.yml @@ -7,13 +7,21 @@ # of joining a network. See README.md for why. # # dev docker compose -f compose.yml -f compose.dev.yml up -d -# prod docker compose --env-file .env.prod -f compose.yml -f compose.prod.yml up -d +# prod docker compose --env-file .env.prod -f compose.yml up -d # # A consuming project's own deploy stack does NOT get its own copy of any of # these services. It only needs this stack to already be up, and its own # one-shot bootstrap jobs (zitadel-init, seaweedfs-init) to create its own # OIDC apps / bucket against the shared instances below. +# Docker's default json-file driver never rotates, so logs would grow until +# the disk fills up. +x-logging: &default-logging + driver: json-file + options: + max-size: "10m" + max-file: "3" + networks: infra-net: name: infra-net @@ -38,6 +46,7 @@ services: timeout: 5s retries: 10 networks: [infra-net] + logging: *default-logging restart: unless-stopped # --- Identity ----------------------------------------------------------- @@ -118,6 +127,7 @@ services: retries: 12 start_period: 20s networks: [infra-net] + logging: *default-logging restart: unless-stopped # Login V2 UI (Next.js), served under /ui/v2/login. It calls the API @@ -143,6 +153,7 @@ services: volumes: - zitadel_bootstrap:/bootstrap:ro networks: [infra-net] + logging: *default-logging restart: unless-stopped # --- Storage -------------------------------------------------------------- @@ -152,7 +163,7 @@ services: # handed to consuming projects. No bucket is created here; each # consuming project's own seaweedfs-init one-shot creates its own. seaweedfs: - image: chrislusf/seaweedfs:latest + image: chrislusf/seaweedfs:4.47 container_name: infra-seaweedfs entrypoint: ["/bin/sh", "-c"] command: @@ -184,6 +195,7 @@ services: timeout: 5s retries: 10 networks: [infra-net] + logging: *default-logging restart: unless-stopped # --- Messaging -------------------------------------------------------- @@ -207,16 +219,28 @@ services: timeout: 5s retries: 10 networks: [infra-net] + logging: *default-logging restart: unless-stopped # --- Tracing -------------------------------------------------------------- # OTLP collector + trace UI, shared by any project's OpenTelemetry exporter. + # Last Jaeger v1 release (v1 is deprecated; v2 is jaegertracing/jaeger). jaeger: - image: jaegertracing/all-in-one:latest + image: jaegertracing/all-in-one:1.76.0 container_name: infra-jaeger environment: COLLECTOR_OTLP_ENABLED: "true" + # Traces are kept in memory; without a cap they grow until the + # container runs out of RAM. The oldest are dropped first. + MEMORY_MAX_TRACES: "100000" + healthcheck: + # Admin port: answers 200 once the collector and query service are up. + test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:14269/"] + interval: 10s + timeout: 5s + retries: 10 networks: [infra-net] + logging: *default-logging restart: unless-stopped volumes: diff --git a/edge/compose.yml b/edge/compose.yml index 9199f3d..4e882f1 100644 --- a/edge/compose.yml +++ b/edge/compose.yml @@ -10,6 +10,14 @@ name: edge +# Docker's default json-file driver never rotates, so logs would grow until +# the disk fills up. +x-logging: &default-logging + driver: json-file + options: + max-size: "10m" + max-file: "3" + networks: infra-net: name: infra-net @@ -36,10 +44,11 @@ services: - ./certbot/www:/var/www/certbot:ro - ./certbot/conf:/etc/letsencrypt:ro networks: [gitea, infra-net] + logging: *default-logging restart: unless-stopped certbot: - image: certbot/certbot + image: certbot/certbot:v5.8.0 container_name: edge-certbot entrypoint: - /bin/sh @@ -48,4 +57,5 @@ services: volumes: - ./certbot/conf:/etc/letsencrypt - ./certbot/www:/var/www/certbot + logging: *default-logging restart: unless-stopped -- 2.54.0 From 668cdb17b09293b0d5c76f6ad549ace841f558bf Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 17:05:01 +0200 Subject: [PATCH 7/9] Move the dev Zitadel login to port 3900 Ping's web app already listens on 3100 in dev, so both couldn't run at the same time. --- .env.example | 4 ++-- README.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 61c2885..dfe82a0 100644 --- a/.env.example +++ b/.env.example @@ -25,8 +25,8 @@ ZITADEL_EXTERNAL_SECURE=false ZITADEL_PUBLIC_SCHEME=http # Login V2 UI (zitadel-login). In dev it has its own port; the URL has no # trailing slash. Only read when the Zitadel instance is created. -ZITADEL_LOGIN_HOST_PORT=3100 -ZITADEL_LOGIN_BASE_URL=http://host.docker.internal:3100/ui/v2/login +ZITADEL_LOGIN_HOST_PORT=3900 +ZITADEL_LOGIN_BASE_URL=http://host.docker.internal:3900/ui/v2/login ZITADEL_DATABASE_PASSWORD=zitadel # Console login: @zitadel. ZITADEL_ADMIN_USERNAME=admin diff --git a/README.md b/README.md index c73a447..ca27319 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ docker exec infra-rabbitmq rabbitmqctl import_definitions /var/lib/rabbitmq/defs |---|---|---| | PostgreSQL | `5432` | one instance, one database per project | | Zitadel | `8080` | OIDC/OAuth2, console at `/ui/console` | -| Zitadel login | `3100` | Login V2 UI at `/ui/v2/login` (under `8080`'s hostname in prod) | +| Zitadel login | `3900` | Login V2 UI at `/ui/v2/login` (under `8080`'s hostname in prod) | | SeaweedFS | `8333` / `8888` / `9333` | S3 API / filer / master | | RabbitMQ | `5672` / `15672` | AMQP / management UI | | Jaeger | `16686` / `4317` / `4318` | UI / OTLP gRPC / OTLP HTTP | @@ -130,7 +130,7 @@ writes on init to the `zitadel_bootstrap` volume, which only `zitadel-login` mounts. - **Dev**: no proxy, so the login has its own port (`ZITADEL_LOGIN_HOST_PORT`, - `3100`) and `ZITADEL_LOGIN_BASE_URL` points the browser at it. + `3900`) and `ZITADEL_LOGIN_BASE_URL` points the browser at it. - **Prod**: the edge routes `/ui/v2/login` on Zitadel's hostname to `infra-zitadel-login`, and everything else to Zitadel. - `zitadel` and `zitadel-login` are pinned to the same version; bump both -- 2.54.0 From 33bf75d2457a23f90443bb76a5c560a19e47ff49 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 17:05:12 +0200 Subject: [PATCH 8/9] Give every project its own credentials on the shared services Every project used the Postgres superuser, the RabbitMQ administrator, an admin S3 identity and the Zitadel instance-admin PAT, so any one of them could read or change the others' data. A new one-shot, `provision` (runs on every `up`, idempotent), gives each project in x-projects its own: - Postgres role, owner of its database. No database is open to PUBLIC anymore, so a role can only connect to its own. An existing database and everything in it is handed over to the project's role. - RabbitMQ user, with permissions on its own vhost only. - Zitadel service account, PROJECT_OWNER of its own project only; its PAT is written to zitadel/.output// for that project's zitadel-init to mount instead of the admin PAT. SeaweedFS renders one S3 identity per project, limited to its bucket, and refuses to start with a clear message on a malformed or repeated key. The admin accounts stay with this stack. postgres-init is gone: provision creates the databases. *.sh files are checked out with LF so they run in the Linux containers from a Windows checkout. --- .env.example | 26 ++- .env.prod.example | 26 ++- .gitattributes | 3 + README.md | 70 +++++-- compose.yml | 92 +++++++-- postgres-init/010-create-app-databases.sql | 9 - provision/provision.sh | 227 +++++++++++++++++++++ provision/seaweedfs-s3-config.sh | 60 ++++++ 8 files changed, 458 insertions(+), 55 deletions(-) create mode 100644 .gitattributes delete mode 100644 postgres-init/010-create-app-databases.sql create mode 100644 provision/provision.sh create mode 100644 provision/seaweedfs-s3-config.sh diff --git a/.env.example b/.env.example index dfe82a0..caaf06a 100644 --- a/.env.example +++ b/.env.example @@ -8,7 +8,7 @@ # (then the browser can't reach 127.0.0.1-only ports through that name). DEV_BIND_ADDRESS=127.0.0.1 -# --- PostgreSQL (shared instance, one database per project) ----------------- +# --- PostgreSQL (shared instance; superuser for this stack only) ------------- POSTGRES_USER=postgres POSTGRES_PASSWORD=postgres POSTGRES_HOST_PORT=5432 @@ -32,11 +32,11 @@ ZITADEL_DATABASE_PASSWORD=zitadel ZITADEL_ADMIN_USERNAME=admin ZITADEL_ADMIN_PASSWORD=Password1! -# --- SeaweedFS (S3) ---------------------------------------------------------- +# --- SeaweedFS (S3; admin identity for this stack only) --------------------- S3_ACCESS_KEY=admin_access_key S3_SECRET_KEY=admin_secret_key -# --- RabbitMQ ----------------------------------------------------------------- +# --- RabbitMQ (administrator for this stack only) --------------------------- RABBITMQ_DEFAULT_USER=guest RABBITMQ_DEFAULT_PASS=guest @@ -44,3 +44,23 @@ RABBITMQ_DEFAULT_PASS=guest PGADMIN_DEFAULT_EMAIL=admin@infra.dev PGADMIN_DEFAULT_PASSWORD=admin PGADMIN_HOST_PORT=5050 + +# --- Per-project credentials (see provision/) --------------------------------- +# Each project gets its own Postgres role/database, RabbitMQ user/vhost and S3 +# identity limited to its bucket; the admin accounts above stay with this +# stack. Each project's own .env copies its values from here. An empty value +# means that project doesn't get that service. +MUSIFY_DB_PASSWORD=musify +MUSIFY_RABBITMQ_PASSWORD=musify +MUSIFY_S3_ACCESS_KEY=musify_access_key +MUSIFY_S3_SECRET_KEY=musify_secret_key + +PING_DB_PASSWORD=ping +PING_RABBITMQ_PASSWORD= +PING_S3_ACCESS_KEY=ping_access_key +PING_S3_SECRET_KEY=ping_secret_key + +DOCKERMANAGER_DB_PASSWORD=dockermanager +DOCKERMANAGER_RABBITMQ_PASSWORD= +DOCKERMANAGER_S3_ACCESS_KEY= +DOCKERMANAGER_S3_SECRET_KEY= diff --git a/.env.prod.example b/.env.prod.example index 5de0990..d4ec27e 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -10,7 +10,7 @@ # the Zitadel masterkey needs exactly 32 characters) and auth.example.com # with the real hostname the shared edge (edge/) terminates TLS for. -# --- PostgreSQL --------------------------------------------------------------- +# --- PostgreSQL (superuser for this stack only) ----------------------------- POSTGRES_USER=postgres POSTGRES_PASSWORD=CHANGE_ME @@ -29,12 +29,32 @@ ZITADEL_ADMIN_USERNAME=admin@ikerdemo.cat ZITADEL_ADMIN_FIRST_NAME=Iker Demo ZITADEL_ADMIN_LAST_NAME=Admin ZITADEL_ADMIN_DISPLAY_NAME=Iker Demo Admin +# Must pass Zitadel's password policy (upper, lower, number, symbol, 8+). ZITADEL_ADMIN_PASSWORD=CHANGE_ME -# --- SeaweedFS (S3) ---------------------------------------------------------- +# --- SeaweedFS (S3; admin identity for this stack only) --------------------- S3_ACCESS_KEY=CHANGE_ME S3_SECRET_KEY=CHANGE_ME -# --- RabbitMQ ----------------------------------------------------------------- +# --- RabbitMQ (administrator for this stack only) --------------------------- RABBITMQ_DEFAULT_USER=CHANGE_ME RABBITMQ_DEFAULT_PASS=CHANGE_ME + +# --- Per-project credentials (see provision/) --------------------------------- +# Each project's own .env.prod copies its values from here. S3 keys: only +# A-Z a-z 0-9 _ + / = . - (hex is fine). Empty = that project doesn't get +# that service. +MUSIFY_DB_PASSWORD=CHANGE_ME +MUSIFY_RABBITMQ_PASSWORD=CHANGE_ME +MUSIFY_S3_ACCESS_KEY=CHANGE_ME +MUSIFY_S3_SECRET_KEY=CHANGE_ME + +PING_DB_PASSWORD=CHANGE_ME +PING_RABBITMQ_PASSWORD= +PING_S3_ACCESS_KEY=CHANGE_ME +PING_S3_SECRET_KEY=CHANGE_ME + +DOCKERMANAGER_DB_PASSWORD=CHANGE_ME +DOCKERMANAGER_RABBITMQ_PASSWORD= +DOCKERMANAGER_S3_ACCESS_KEY= +DOCKERMANAGER_S3_SECRET_KEY= diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9f26f22 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Shell scripts run inside Linux containers: a CRLF checkout (Windows, +# core.autocrlf) would break them. +*.sh text eol=lf diff --git a/README.md b/README.md index ca27319..3070ad2 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,7 @@ provisioning, and keeping in sync) its own copy, they run once, here. This repo doesn't assume anything about where the other projects are checked out. Each one just needs a local path to this repo's checkout (see -"The Zitadel admin PAT" below), which you set once in that project's own -`.env`. +"The Zitadel PAT" below), which you set once in that project's own `.env`. Each project keeps its own independent, fast `docker compose up` for the things that actually are its own: API/worker/gateway containers, database @@ -23,7 +22,7 @@ needs to know a consuming project exists. | Here (shared) | Stays in each project | |---|---| -| PostgreSQL (one instance, one database per project) | The project's own EF Core migrations, connection pooling config | +| PostgreSQL (one instance, one database and role per project) | The project's own EF Core migrations, connection pooling config | | Zitadel (OIDC provider) | The project's own OIDC app registration (`zitadel-init`), JWT validation config | | SeaweedFS (S3 storage) | The project's own bucket (`seaweedfs-init`), key layout | | RabbitMQ (broker) | The project's own exchanges/queues/consumers | @@ -83,7 +82,7 @@ docker exec infra-rabbitmq rabbitmqctl import_definitions /var/lib/rabbitmq/defs | Service | Port | Notes | |---|---|---| -| PostgreSQL | `5432` | one instance, one database per project | +| PostgreSQL | `5432` | one instance, one database and role per project | | Zitadel | `8080` | OIDC/OAuth2, console at `/ui/console` | | Zitadel login | `3900` | Login V2 UI at `/ui/v2/login` (under `8080`'s hostname in prod) | | SeaweedFS | `8333` / `8888` / `9333` | S3 API / filer / master | @@ -98,27 +97,60 @@ running (in Docker or from the IDE). No shared Docker network is needed in development, which is what keeps every project's own `up` independent and fast, with this stack as the only prerequisite. -### Shared secrets +### Per-project credentials -`POSTGRES_USER`/`POSTGRES_PASSWORD`, `S3_ACCESS_KEY`/`S3_SECRET_KEY`, -`RABBITMQ_DEFAULT_USER`/`RABBITMQ_DEFAULT_PASS` and `PUBLIC_AUTH_URL` (i.e. -`http://host.docker.internal:${ZITADEL_EXTERNAL_PORT}` in dev) are the values -every consuming project's own `.env.example` copies and documents as "must -match this repo's `.env`". This file is the source of truth for them. +No project gets an admin account. `provision` (a one-shot that runs on +every `up`, see `provision/`) gives each project listed in `compose.yml`'s +`x-projects` its own credentials, from the `_*` settings in the env +file: -### The Zitadel admin PAT +| Service | What the project gets | Setting | +|---|---|---| +| PostgreSQL | role ``, owner of database `_db`; no other role can connect to it, and it can't connect to any other | `_DB_PASSWORD` | +| RabbitMQ | user ``, only on vhost `` | `_RABBITMQ_PASSWORD` | +| SeaweedFS | S3 identity limited to the project's bucket (it may create that bucket, nothing else) | `_S3_ACCESS_KEY` / `_SECRET_KEY` | +| Zitadel | service account `-provisioner`, owner of the project's own Zitadel project only; its PAT is in `zitadel/.output//zitadel.pat` | (the project name, in `compose.yml`) | -Zitadel itself writes a service-account personal access token to -`zitadel/.output/admin-sa.pat` when the instance is created. Every consuming project mounts that same folder read-only, via -its own `ZITADEL_ADMIN_PAT_DIR` setting, to call the Zitadel management API -and create its own project/apps. +An empty setting means that project doesn't use that service. Each +project's own `.env.example` copies its values from here and says so; this +repo's env file is the source of truth. Passwords are re-applied on every +`up`, so rotating one is: change it here and in the project, `up` both. + +The admin accounts (`POSTGRES_*`, `RABBITMQ_DEFAULT_*`, `S3_ACCESS_KEY` / +`S3_SECRET_KEY`, `zitadel/.output/admin-sa.pat`) stay with this stack. + +Two limits worth knowing: the Zitadel `PROJECT_OWNER` role can still read +the organization's users (every project's users are in the one +organization); and S3 keys must only use `A-Z a-z 0-9 _ + / = . -` (they go +into SeaweedFS's JSON config as they are). + +**Adding a project**: add it to `x-projects`, add its `_*` lines +to `provision`'s and `seaweedfs`'s environment in `compose.yml` (with its +bucket and Zitadel project name), put its values in `.env.example` / +`.env.prod.example`, and `up`. + +**Moving an existing deployment over**: fill in the new `_*` +values and `up`. `provision` hands over the existing databases (and every +table, sequence, type, view and function in them) to each project's role, +reuses the existing Zitadel projects and apps, and leaves the buckets as +they are. Then switch each project's env file to its own credentials. The +only thing that doesn't carry over is RabbitMQ: a project now uses its own +vhost, so drain its queues on `/` first; its consumers recreate their +exchanges and queues in the new vhost. + +### The Zitadel PAT + +Each project's `zitadel-init` mounts **its own** folder, +`zitadel/.output//`, read-only (via that project's +`ZITADEL_PAT_DIR` setting) and uses the PAT in it to create its OIDC apps in +its own Zitadel project. It can't see or change any other project's. That setting has no universally correct value. It's a path (relative or absolute) to wherever *you* cloned this repo on your machine, from the consuming project's `deploy/` folder. Each project's `.env.example` ships with an example value that assumes these repos happen to sit as sibling -folders. If yours don't, change it. Not versioned, and regenerated whenever -the Zitadel volume is reset. +folders. If yours don't, change it. Not versioned; `provision` issues a new +PAT whenever the one there stops working (e.g. after a Zitadel volume reset). ### The Zitadel login (Login V2) @@ -208,6 +240,6 @@ Infrastructure/ ├─ edge/ # shared public nginx + certbot (prod only): compose.yml, conf.d/, snippets/ ├─ compose.dev.yml # dev overlay: publishes ports, adds pgAdmin ├─ .env.example / .env.prod.example -├─ postgres-init/ # creates the per-project application databases -└─ zitadel/.output/ # PAT written by Zitadel on init (not versioned) +├─ provision/ # per-project credentials: Postgres, RabbitMQ, Zitadel, S3 identities +└─ zitadel/.output/ # admin PAT + one folder per project with its own PAT (not versioned) ``` diff --git a/compose.yml b/compose.yml index eeaf141..a030582 100644 --- a/compose.yml +++ b/compose.yml @@ -22,15 +22,21 @@ x-logging: &default-logging max-size: "10m" max-file: "3" +# The consuming projects that get their own credentials (see provision/). +# Adding one: add it here, give it the _* settings in both +# `provision` and `seaweedfs` below, and their values in the env files. +x-projects: &projects musify ping dockermanager + networks: infra-net: name: infra-net services: # --- Database --------------------------------------------------------- - # One instance, one database per consuming project (see postgres-init/). - # Zitadel provisions its own `zitadel` database itself, from the admin - # credentials below, so it does not need an init script. + # One instance, one database and one role per consuming project (created + # by `provision` below). Zitadel creates its own `zitadel` database and + # user itself, from the admin credentials. The superuser is only for this + # stack: no project gets it. postgres: image: postgres:17-alpine container_name: infra-postgres @@ -39,7 +45,6 @@ services: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} volumes: - postgres_data:/var/lib/postgresql/data - - ./postgres-init:/docker-entrypoint-initdb.d:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] interval: 10s @@ -94,8 +99,8 @@ services: ZITADEL_FIRSTINSTANCE_ORG_HUMAN_DISPLAYNAME: ${ZITADEL_ADMIN_DISPLAY_NAME:-ZITADEL Admin} ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: ${ZITADEL_ADMIN_PASSWORD} ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: "false" - # Service account + PAT written on init; each consuming project's own - # zitadel-init one-shot uses it to create its own project/apps. + # Instance-admin service account + PAT written on init. Only `provision` + # uses it, to give each project its own, project-scoped account. ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME: zitadel-admin-sa ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME: Admin Service Account ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE: "2099-01-01T00:00:00Z" @@ -158,27 +163,31 @@ services: # --- Storage -------------------------------------------------------------- # S3-compatible object store shared by every project. All-in-one: master + - # volume + filer + S3 gateway. The S3 identity file is rendered from the - # environment on start so the credentials can never drift from the ones - # handed to consuming projects. No bucket is created here; each - # consuming project's own seaweedfs-init one-shot creates its own. + # volume + filer + S3 gateway. The S3 identities are rendered from the + # environment on start (provision/seaweedfs-s3-config.sh): an admin one + # for this stack, and one per project limited to that project's bucket. + # No bucket is created here; each consuming project's own seaweedfs-init + # one-shot creates its own. seaweedfs: image: chrislusf/seaweedfs:4.47 container_name: infra-seaweedfs + environment: + PROJECTS: *projects + S3_ACCESS_KEY: ${S3_ACCESS_KEY} + S3_SECRET_KEY: ${S3_SECRET_KEY} + MUSIFY_S3_ACCESS_KEY: ${MUSIFY_S3_ACCESS_KEY:-} + MUSIFY_S3_SECRET_KEY: ${MUSIFY_S3_SECRET_KEY:-} + MUSIFY_S3_BUCKET: webapi-storage + PING_S3_ACCESS_KEY: ${PING_S3_ACCESS_KEY:-} + PING_S3_SECRET_KEY: ${PING_S3_SECRET_KEY:-} + PING_S3_BUCKET: ping-storage + DOCKERMANAGER_S3_ACCESS_KEY: ${DOCKERMANAGER_S3_ACCESS_KEY:-} + DOCKERMANAGER_S3_SECRET_KEY: ${DOCKERMANAGER_S3_SECRET_KEY:-} + DOCKERMANAGER_S3_BUCKET: dockermanager entrypoint: ["/bin/sh", "-c"] command: - | - cat > /tmp/s3.conf <<'JSON' - { - "identities": [ - { - "name": "infra", - "credentials": [{ "accessKey": "${S3_ACCESS_KEY}", "secretKey": "${S3_SECRET_KEY}" }], - "actions": ["Admin", "Read", "Write"] - } - ] - } - JSON + sh /provision/seaweedfs-s3-config.sh >/tmp/s3.conf || exit 1 exec weed server \ -ip.bind=0.0.0.0 \ -dir=/data \ @@ -189,6 +198,7 @@ services: -s3.config=/tmp/s3.conf volumes: - seaweedfs_data:/data + - ./provision:/provision:ro healthcheck: test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:9333/cluster/healthz"] interval: 10s @@ -243,6 +253,46 @@ services: logging: *default-logging restart: unless-stopped + # --- Per-project credentials ---------------------------------------------- + # One-shot, runs on every `up` (idempotent): each project's own Postgres + # role/database, RabbitMQ user/vhost and Zitadel service account, from the + # _* settings (see provision/provision.sh). An empty setting skips + # that service for that project. + provision: + image: postgres:17-alpine + container_name: infra-provision + entrypoint: ["/bin/sh", "/provision/provision.sh"] + environment: + PROJECTS: *projects + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + RABBITMQ_DEFAULT_USER: ${RABBITMQ_DEFAULT_USER} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_DEFAULT_PASS} + ZITADEL_EXTERNAL_DOMAIN: ${ZITADEL_EXTERNAL_DOMAIN} + ZITADEL_PUBLIC_SCHEME: ${ZITADEL_PUBLIC_SCHEME} + MUSIFY_DB_PASSWORD: ${MUSIFY_DB_PASSWORD:-} + MUSIFY_RABBITMQ_PASSWORD: ${MUSIFY_RABBITMQ_PASSWORD:-} + MUSIFY_ZITADEL_PROJECT: Musify + PING_DB_PASSWORD: ${PING_DB_PASSWORD:-} + PING_RABBITMQ_PASSWORD: ${PING_RABBITMQ_PASSWORD:-} + PING_ZITADEL_PROJECT: Ping + DOCKERMANAGER_DB_PASSWORD: ${DOCKERMANAGER_DB_PASSWORD:-} + DOCKERMANAGER_RABBITMQ_PASSWORD: ${DOCKERMANAGER_RABBITMQ_PASSWORD:-} + DOCKERMANAGER_ZITADEL_PROJECT: "" + volumes: + - ./provision:/provision:ro + - ./zitadel/.output:/output + depends_on: + postgres: + condition: service_healthy + rabbitmq: + condition: service_healthy + zitadel: + condition: service_healthy + networks: [infra-net] + logging: *default-logging + restart: "no" + volumes: postgres_data: zitadel_bootstrap: diff --git a/postgres-init/010-create-app-databases.sql b/postgres-init/010-create-app-databases.sql deleted file mode 100644 index df7030a..0000000 --- a/postgres-init/010-create-app-databases.sql +++ /dev/null @@ -1,9 +0,0 @@ --- Runs once, only on first init of the postgres_data volume. --- Zitadel provisions its own "zitadel" database itself, from the --- ZITADEL_DATABASE_POSTGRES_ADMIN_* credentials in compose.yml, so it does --- not need to be created here. This script only creates the plain --- application databases each project's own connection string points at. - -CREATE DATABASE musify_db; -CREATE DATABASE ping_db; -CREATE DATABASE dockermanager_db; diff --git a/provision/provision.sh b/provision/provision.sh new file mode 100644 index 0000000..bbc4d6f --- /dev/null +++ b/provision/provision.sh @@ -0,0 +1,227 @@ +#!/bin/sh +# Gives every consuming project its own credentials on the shared services, +# so no project holds another's data or the admin accounts: +# +# Postgres role , owner of database _db, which no +# other role can connect to +# RabbitMQ user with permissions only on vhost +# Zitadel service account -provisioner, PROJECT_OWNER of the +# project's own Zitadel project only; its PAT is written to +# /output//zitadel.pat for that project's zitadel-init +# +# (S3 identities are rendered into SeaweedFS's own config when it starts, see +# seaweedfs-s3-config.sh.) +# +# Idempotent: runs on every `up`. A service is only provisioned for a project +# when its setting is non-empty (no MUSIFY_RABBITMQ_PASSWORD, no RabbitMQ +# user for Musify). Passwords are re-applied on every run, so changing one +# in the env file and running `up` again rotates it. +set -eu -o pipefail + +apk add --no-cache curl jq >/dev/null + +# Value of the _ setting, empty when unset. +setting() { eval "printf '%s' \"\${$(echo "$1" | tr '[:lower:]' '[:upper:]')_$2:-}\""; } + +# --- Postgres ---------------------------------------------------------------- +export PGHOST=postgres PGUSER="$POSTGRES_USER" PGPASSWORD="$POSTGRES_PASSWORD" + +# By default every role may connect to every database (PUBLIC has CONNECT), +# including `zitadel` and `postgres`. Only the owner, roles granted it +# explicitly (Zitadel's own user on `zitadel`) and the superuser keep it. +lock_down_postgres() { + psql -q -v ON_ERROR_STOP=1 -d postgres <<'SQL' +SELECT format('REVOKE CONNECT, TEMPORARY ON DATABASE %I FROM PUBLIC', datname) +FROM pg_database WHERE NOT datistemplate \gexec +SQL + echo " postgres: no database open to PUBLIC" +} + +provision_postgres() { + local project=$1 password=$2 db="$1_db" + psql -q -v ON_ERROR_STOP=1 -d postgres -v role="$project" -v pw="$password" -v db="$db" <<'SQL' +SELECT format('CREATE ROLE %I LOGIN', :'role') +WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = :'role') \gexec +ALTER ROLE :"role" WITH LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE PASSWORD :'pw'; +SELECT format('CREATE DATABASE %I OWNER %I', :'db', :'role') +WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = :'db') \gexec +ALTER DATABASE :"db" OWNER TO :"role"; +REVOKE CONNECT, TEMPORARY ON DATABASE :"db" FROM PUBLIC; +SQL + + # A database from before this (created by the superuser, as were the + # tables its migrations made) is handed over: the project's migrations + # have to own what they alter. + psql -q -v ON_ERROR_STOP=1 -d "$db" -v role="$project" <<'SQL' >/dev/null +SELECT set_config('provision.role', :'role', false); +DO $$ +DECLARE + target text := current_setting('provision.role'); + r record; +BEGIN + FOR r IN + SELECT n.nspname FROM pg_namespace n + WHERE n.nspname NOT LIKE 'pg\_%' AND n.nspname <> 'information_schema' + AND pg_get_userbyid(n.nspowner) NOT IN (target, 'pg_database_owner') + AND NOT EXISTS (SELECT FROM pg_depend d WHERE d.classid = 'pg_namespace'::regclass AND d.objid = n.oid AND d.deptype = 'e') + LOOP + EXECUTE format('ALTER SCHEMA %I OWNER TO %I', r.nspname, target); + END LOOP; + + -- Indexes and the sequences behind serial/identity columns follow their + -- table; extension members stay with the extension. + FOR r IN + SELECT c.oid::regclass AS obj, c.relkind FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname NOT LIKE 'pg\_%' AND n.nspname <> 'information_schema' + AND c.relkind IN ('r', 'p', 'v', 'm', 'S', 'f') + AND pg_get_userbyid(c.relowner) <> target + AND NOT EXISTS (SELECT FROM pg_depend d WHERE d.classid = 'pg_class'::regclass AND d.objid = c.oid AND d.deptype IN ('a', 'i', 'e')) + LOOP + EXECUTE format('ALTER %s %s OWNER TO %I', + CASE r.relkind WHEN 'v' THEN 'VIEW' WHEN 'm' THEN 'MATERIALIZED VIEW' + WHEN 'S' THEN 'SEQUENCE' WHEN 'f' THEN 'FOREIGN TABLE' ELSE 'TABLE' END, + r.obj, target); + END LOOP; + + -- Enums, domains, composite and range types (not a table's row type nor + -- array types, which follow their element type). + FOR r IN + SELECT t.oid::regtype AS obj, t.typtype FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname NOT LIKE 'pg\_%' AND n.nspname <> 'information_schema' + AND t.typtype IN ('e', 'd', 'c', 'r', 'm') AND t.typelem = 0 + AND (t.typrelid = 0 OR (SELECT relkind FROM pg_class WHERE oid = t.typrelid) = 'c') + AND pg_get_userbyid(t.typowner) <> target + AND NOT EXISTS (SELECT FROM pg_depend d WHERE d.classid = 'pg_type'::regclass AND d.objid = t.oid AND d.deptype IN ('i', 'e')) + LOOP + EXECUTE format('ALTER %s %s OWNER TO %I', CASE r.typtype WHEN 'd' THEN 'DOMAIN' ELSE 'TYPE' END, r.obj, target); + END LOOP; + + FOR r IN + SELECT p.oid::regprocedure AS obj FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname NOT LIKE 'pg\_%' AND n.nspname <> 'information_schema' + AND pg_get_userbyid(p.proowner) <> target + AND NOT EXISTS (SELECT FROM pg_depend d WHERE d.classid = 'pg_proc'::regclass AND d.objid = p.oid AND d.deptype = 'e') + LOOP + EXECUTE format('ALTER ROUTINE %s OWNER TO %I', r.obj, target); + END LOOP; +END $$; +SQL + echo " postgres: role $project owns database $db" +} + +# --- RabbitMQ ---------------------------------------------------------------- +rabbitmq_put() { + curl -fsS -o /dev/null -X PUT "http://rabbitmq:15672/api$1" \ + -u "$RABBITMQ_DEFAULT_USER:$RABBITMQ_DEFAULT_PASS" \ + -H "Content-Type: application/json" --data-raw "$2" +} + +provision_rabbitmq() { + local project=$1 password=$2 + rabbitmq_put "/vhosts/$project" '{}' + rabbitmq_put "/users/$project" "$(jq -n --arg pw "$password" '{password: $pw, tags: ""}')" + rabbitmq_put "/permissions/$project/$project" '{"configure": ".*", "write": ".*", "read": ".*"}' + echo " rabbitmq: user $project, only on vhost $project" +} + +# --- Zitadel ----------------------------------------------------------------- +# zitadel [body]: prints the response body; on a +# non-2xx answer also reports it on stderr and returns non-zero. It calls +# Zitadel on infra-net, so it presents the public Host/scheme for Zitadel to +# find the instance (same as zitadel-login). +zitadel() { + local response code + response=$(curl -sS -w '\n%{http_code}' -X "$2" "http://zitadel:8080$3" \ + -H "Authorization: Bearer $1" -H "Content-Type: application/json" \ + -H "Host: $ZITADEL_EXTERNAL_DOMAIN" -H "X-Forwarded-Proto: $ZITADEL_PUBLIC_SCHEME" \ + ${4+--data-raw "$4"}) + code=$(printf '%s\n' "$response" | tail -n1) + response=$(printf '%s\n' "$response" | sed '$d') + printf '%s\n' "$response" + case "$code" in + 2??) return 0 ;; + *) printf 'zitadel: %s %s -> %s %s\n' "$2" "$3" "$code" "$response" >&2; return 1 ;; + esac +} + +# Stops the run when a lookup or creation didn't produce a value. +need() { [ -n "$2" ] && [ "$2" != null ] || { echo "zitadel: got no $1" >&2; exit 1; }; } + +provision_zitadel() { + local project=$1 zitadel_project=$2 username="$1-provisioner" + local project_id user_id member_roles pat_file token state + + project_id=$(zitadel "$ADMIN_PAT" POST /management/v1/projects/_search \ + "$(jq -n --arg n "$zitadel_project" '{queries: [{nameQuery: {name: $n, method: "TEXT_QUERY_METHOD_EQUALS"}}]}')" \ + | jq -r '.result // [] | first | .id // empty') + if [ -z "$project_id" ]; then + project_id=$(zitadel "$ADMIN_PAT" POST /management/v1/projects "$(jq -n --arg n "$zitadel_project" '{name: $n}')" | jq -r .id) + fi + need "project id for $zitadel_project" "$project_id" + + user_id=$(zitadel "$ADMIN_PAT" POST /management/v1/users/_search \ + "$(jq -n --arg u "$username" '{queries: [{userNameQuery: {userName: $u, method: "TEXT_QUERY_METHOD_EQUALS"}}]}')" \ + | jq -r '.result // [] | first | .id // empty') + if [ -z "$user_id" ]; then + user_id=$(zitadel "$ADMIN_PAT" POST /management/v1/users/machine \ + "$(jq -n --arg u "$username" --arg p "$zitadel_project" \ + '{userName: $u, name: ($p + " provisioner"), description: ("zitadel-init of " + $p + ": manages that project only"), accessTokenType: "ACCESS_TOKEN_TYPE_BEARER"}')" \ + | jq -r .userId) + fi + need "user id for $username" "$user_id" + + # Owner of this one project (its apps, roles and grants) and nothing else. + member_roles=$(zitadel "$ADMIN_PAT" POST "/management/v1/projects/$project_id/members/_search" \ + "$(jq -n --arg u "$user_id" '{queries: [{userIdQuery: {userId: $u}}]}')" \ + | jq -c '.result // [] | first | .roles // empty') + if [ -z "$member_roles" ]; then + zitadel "$ADMIN_PAT" POST "/management/v1/projects/$project_id/members" \ + "$(jq -n --arg u "$user_id" '{userId: $u, roles: ["PROJECT_OWNER"]}')" >/dev/null + elif [ "$member_roles" != '["PROJECT_OWNER"]' ]; then + zitadel "$ADMIN_PAT" PUT "/management/v1/projects/$project_id/members/$user_id" '{"roles": ["PROJECT_OWNER"]}' >/dev/null + fi + + # Keep a PAT that still works. A new one (they can't be read back) is only + # issued when there is none, or it was revoked or lost with a volume reset. + pat_file="/output/$project/zitadel.pat" + mkdir -p "/output/$project" + chmod 700 "/output/$project" + if [ -s "$pat_file" ] \ + && zitadel "$(tr -d '\r\n' <"$pat_file")" GET /auth/v1/users/me 2>/dev/null \ + | jq -e --arg u "$username" '.user.userName == $u' >/dev/null 2>&1; then + state="kept" + else + token=$(zitadel "$ADMIN_PAT" POST "/management/v1/users/$user_id/pats" '{"expirationDate": "2099-01-01T00:00:00Z"}' | jq -r .token) + need "PAT for $username" "$token" + (umask 077 && printf '%s\n' "$token" >"$pat_file.tmp") + mv "$pat_file.tmp" "$pat_file" + state="issued" + fi + echo " zitadel: $username owns project \"$zitadel_project\", PAT $state" +} + +# ----------------------------------------------------------------------------- +echo "==> waiting for Zitadel's admin PAT" +deadline=$(( $(date +%s) + 180 )) +until [ -s /output/admin-sa.pat ]; do + [ "$(date +%s)" -lt "$deadline" ] || { echo "no /output/admin-sa.pat; check 'docker logs infra-zitadel'" >&2; exit 1; } + sleep 3 +done +ADMIN_PAT=$(tr -d '\r\n' shared" +lock_down_postgres + +for project in $PROJECTS; do + echo "==> $project" + value=$(setting "$project" DB_PASSWORD) + [ -z "$value" ] || provision_postgres "$project" "$value" + value=$(setting "$project" RABBITMQ_PASSWORD) + [ -z "$value" ] || provision_rabbitmq "$project" "$value" + value=$(setting "$project" ZITADEL_PROJECT) + [ -z "$value" ] || provision_zitadel "$project" "$value" +done +echo "==> done" diff --git a/provision/seaweedfs-s3-config.sh b/provision/seaweedfs-s3-config.sh new file mode 100644 index 0000000..67bac18 --- /dev/null +++ b/provision/seaweedfs-s3-config.sh @@ -0,0 +1,60 @@ +#!/bin/sh +# Prints SeaweedFS's S3 identity config (JSON) from the environment: +# +# infra S3_ACCESS_KEY / S3_SECRET_KEY, admin on every bucket (for +# operating the stack, not for projects) +# _S3_ACCESS_KEY / _SECRET_KEY, limited to the bucket +# _S3_BUCKET (it may create it, nothing else) +# +# A project without keys gets no identity. Runs in the SeaweedFS container on +# every start, so the credentials can't drift from the env file. +set -eu + +upper() { echo "$1" | tr '[:lower:]' '[:upper:]'; } +setting() { eval "printf '%s' \"\${$(upper "$1")_$2:-}\""; } + +# The values go into JSON as they are: keep them to characters that need no +# escaping (what `openssl rand -hex` / -base64 produce). +check() { + case "$2" in + '' | *[!A-Za-z0-9_+/=.-]*) echo "seaweedfs-s3-config: $1 must be non-empty and only use A-Z a-z 0-9 _ + / = . -" >&2; exit 1 ;; + esac +} + +identity() { + printf ' {"name": "%s", "credentials": [{"accessKey": "%s", "secretKey": "%s"}], "actions": [%s]}' "$1" "$2" "$3" "$4" +} + +# SeaweedFS refuses to start (and keeps restarting) on a repeated access key, +# e.g. two values left at CHANGE_ME: say which ones instead. +seen=" " +unique() { + case "$seen" in + *" $2 "*) echo "seaweedfs-s3-config: $1 repeats an access key already used above; every identity needs its own" >&2; exit 1 ;; + esac + seen="$seen$2 " +} + +check S3_ACCESS_KEY "$S3_ACCESS_KEY" +unique S3_ACCESS_KEY "$S3_ACCESS_KEY" +check S3_SECRET_KEY "$S3_SECRET_KEY" +printf '{\n "identities": [\n' +identity infra "$S3_ACCESS_KEY" "$S3_SECRET_KEY" '"Admin", "Read", "Write"' + +for project in $PROJECTS; do + access=$(setting "$project" S3_ACCESS_KEY) + [ -n "$access" ] || continue + secret=$(setting "$project" S3_SECRET_KEY) + bucket=$(setting "$project" S3_BUCKET) + prefix=$(upper "$project") + check "${prefix}_S3_ACCESS_KEY" "$access" + check "${prefix}_S3_SECRET_KEY" "$secret" + check "${prefix}_S3_BUCKET" "$bucket" + unique "${prefix}_S3_ACCESS_KEY" "$access" + # Admin: is what lets it create (and configure) that one bucket. + printf ',\n' + identity "$project" "$access" "$secret" \ + "\"Admin:$bucket\", \"Read:$bucket\", \"Write:$bucket\", \"List:$bucket\", \"Tagging:$bucket\"" + echo "seaweedfs-s3-config: $project -> bucket $bucket" >&2 +done +printf '\n ]\n}\n' -- 2.54.0 From 8b942ab6c2e9fcb3bfe26e01edfb6cb51ee8250e Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 17:11:50 +0200 Subject: [PATCH 9/9] Give docker-manager its project on the shared Zitadel Its OIDC login isn't implemented yet, but it's meant to use the same Zitadel as Musify and Ping. Provision its Zitadel project and service account now, so its future zitadel-init only has to create its apps. Document that every project shares one Zitadel organization: one account per person across all projects, with single sign-on between them. --- README.md | 11 +++++++++++ compose.yml | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3070ad2..02d4b0f 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,17 @@ only thing that doesn't carry over is RabbitMQ: a project now uses its own vhost, so drain its queues on `/` first; its consumers recreate their exchanges and queues in the new vhost. +### One Zitadel for every project + +Every project signs its users in against the same instance and the same +organization (the first one, `ZITADEL_ORG_NAME`), each with its own Zitadel +project for its apps. So there is one account per person, not one per +project: someone who registers in Musify can sign in to Ping with the same +account, and a session in one app is reused by the others (single sign-on), +because none of the projects requires a per-project grant or role to sign +in. Keep it that way when adding a project: point its issuer at this +Zitadel (`PUBLIC_AUTH_URL`), and don't create a separate organization for it. + ### The Zitadel PAT Each project's `zitadel-init` mounts **its own** folder, diff --git a/compose.yml b/compose.yml index a030582..96fb313 100644 --- a/compose.yml +++ b/compose.yml @@ -278,7 +278,7 @@ services: PING_ZITADEL_PROJECT: Ping DOCKERMANAGER_DB_PASSWORD: ${DOCKERMANAGER_DB_PASSWORD:-} DOCKERMANAGER_RABBITMQ_PASSWORD: ${DOCKERMANAGER_RABBITMQ_PASSWORD:-} - DOCKERMANAGER_ZITADEL_PROJECT: "" + DOCKERMANAGER_ZITADEL_PROJECT: Docker Manager volumes: - ./provision:/provision:ro - ./zitadel/.output:/output -- 2.54.0