diff --git a/.env.sample b/.env.sample index 779ebcd..f362dbb 100644 --- a/.env.sample +++ b/.env.sample @@ -21,14 +21,16 @@ SECRET_DJANGO_SP_VERSION=v1 SECRET_MINIO_RP_VERSION=v1 # MINIO_ROOT_USER SECRET_MINIO_RU_VERSION=v1 -# COLLABORATION_SERVER_SECRET -SECRET_COLLAB_SS_VERSION=v1 # POSTGRES_PASSWORD SECRET_POSTGRES_P_VERSION=v1 # Y_PROVIDER_API_KEY SECRET_Y_API_KEY_VERSION=v1 # DJANGO_HOST_EMAIL_PASSWORD SECRET_EMAIL_PASS_VERSION=v1 +# JWT_PRIVATE_KEY of the backend (RSA PEM, must be inserted manually, see README) +SECRET_JWT_PK_VERSION=v1 # generate=false +# YHUB_JWT_PRIVATE_KEY of the collaboration server (RSA PEM, must be inserted manually, see README) +SECRET_YHUB_JWT_PK_VERSION=v1 # generate=false ############################################################################## # EMAIL @@ -106,3 +108,13 @@ LOGGING_LEVEL_LOGGERS_APP=INFO # Header the backend reads for media auth subrequests. # Upstream default matches the recipe's nginx config (X-Original-URL). # MEDIA_AUTH_ORIGINAL_URL_HEADER=HTTP_X_ORIGINAL_URL + +############################################################################## +# COLLABORATION SERVER (yhub) - SOFT MIGRATION +############################################################################## +# Since v6 the document content lives in the collaboration server, not in the +# media bucket. With SOFT_MIGRATION, a pre-v6 document is copied over the first +# time it is opened. Only set to false once +# `abra app cmd backend migrate_documents` has covered all documents +# (or on a fresh v6 install); unmigrated documents would otherwise open empty. +# SOFT_MIGRATION=true diff --git a/README.md b/README.md index 087eed4..84c46b2 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ ## Quick start * `abra app new lasuite-docs --secrets` +* Insert the two RSA keys, which abra cannot generate (see below) * `abra app config ` * `abra app deploy ` @@ -27,6 +28,27 @@ You should then be able to visit the landing page of your app, but not yet to lo * Migrations run automatically on backend startup. To trigger manually: `abra app cmd backend migrate` * Minio buckets are created automatically on first deploy. To manually trigger: `abra app cmd minio minio_initialize` +## RSA keys + +Since v6, the backend and the collaboration server (`yhub`) authenticate each other with signed tokens. Each needs its own RSA private key, never the same one: + +``` +openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out jwt.pem +openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out yhub-jwt.pem +abra app secret insert jwt_pk v1 jwt.pem -f +abra app secret insert yhub_jwt_pk v1 yhub-jwt.pem -f +rm jwt.pem yhub-jwt.pem +``` + +## Upgrading from v5 to v6 + +v6 replaces the collaboration server. Document content moves from the minio bucket to the new `yhub` service, which stores it in its own `yhub` database. If you skip the migration, every existing document opens **empty**. See [UPGRADE.md](https://github.com/suitenumerique/docs/blob/main/UPGRADE.md) upstream for details. + +1. Insert the two RSA keys (see "RSA keys" above). The `collab_ss` secret is no longer used. +2. Leave `SOFT_MIGRATION` at its default (`true`) and deploy. The `yhub` schema is created on startup. A document is copied to yhub the first time someone opens it. +3. Copy all remaining documents: `abra app cmd backend migrate_documents`. You can pass arguments, e.g. `abra app cmd backend migrate_documents -- --dry-run`. Re-run it with `--retry-failed` if some documents failed. +4. Only after that, you may set `SOFT_MIGRATION=false`. Keep the minio bucket and its object versions: they hold the document history from before v6. + ## Configure Authentication lasuite-docs **requires** an OpenID Connect (OIDC) single sign-on provider; deployment has been tested with [Keycloak](https://git.coopcloud.tech/coop-cloud/keycloak), which we recommend, or you could also try [Authentik](https://git.coopcloud.tech/coop-cloud/authentik), both of which are installable using Co-op Cloud. diff --git a/abra-entrypoint.sh b/abra-entrypoint.sh index 470e0dd..3a0a241 100644 --- a/abra-entrypoint.sh +++ b/abra-entrypoint.sh @@ -7,7 +7,6 @@ set -e [ -f /run/secrets/minio_ru ] && export AWS_S3_ACCESS_KEY_ID="$(cat /run/secrets/minio_ru)" [ -f /run/secrets/django_sp ] && export DJANGO_SUPERUSER_PASSWORD="$(cat /run/secrets/django_sp)" [ -f /run/secrets/oidc_rpcs ] && export OIDC_RP_CLIENT_SECRET="$(cat /run/secrets/oidc_rpcs)" -[ -f /run/secrets/collab_ss ] && export COLLABORATION_SERVER_SECRET="$(cat /run/secrets/collab_ss)" [ -f /run/secrets/y_api_key ] && export Y_PROVIDER_API_KEY="$(cat /run/secrets/y_api_key)" [ -f /run/secrets/email_pass ] && export DJANGO_EMAIL_HOST_PASSWORD="$(cat /run/secrets/email_pass)" diff --git a/abra.sh b/abra.sh index 68d7d5f..260e817 100755 --- a/abra.sh +++ b/abra.sh @@ -1,8 +1,8 @@ # Set any config versions here # Docs: https://docs.coopcloud.tech/maintainers/handbook/#manage-configs -export ABRA_ENTRYPOINT_VERSION=v5 -export NGINX_CONF_VERSION=v3 -export PG_BACKUP_VERSION=v4 +export ABRA_ENTRYPOINT_VERSION=v6 +export NGINX_CONF_VERSION=v4 +export PG_BACKUP_VERSION=v5 export MINIO_INITIALIZE_VERSION=v2 export MIGRATE_VERSION=v1 @@ -18,3 +18,11 @@ migrate() { minio_initialize() { /minio-initialize.sh } + +# Hand all pre-v6 documents from the media bucket over to the collaboration +# server (yhub). Resumable and safe to re-run; extra args are passed through, +# e.g. --dry-run, --retry-failed, --concurrency N +migrate_documents() { + source /abra-entrypoint.sh -e + python manage.py migrate_documents "$@" +} diff --git a/compose.yml b/compose.yml index c7e5179..a0d2d28 100644 --- a/compose.yml +++ b/compose.yml @@ -54,8 +54,10 @@ x-common-env: &common-env OPENAI_SDK_BASE_URL: https://openaiendpoint.com OPENAI_SDK_API_KEY: password AI_MODEL: llama - # Collaboration - COLLABORATION_API_URL: https://$DOMAIN/collaboration/api/ + # Collaboration server (yhub), reached in-stack + YHUB_API_BASE_URL: http://yhub:3002 + # RSA key signing the tokens the backend presents to yhub and y-provider + JWT_PRIVATE_KEY_FILE: /run/secrets/jwt_pk # Email app URL (used in email templates; matches recipe DOMAIN) DJANGO_EMAIL_URL_APP: "https://${DOMAIN}" # Conversion (file upload → Yjs via y-provider) @@ -91,11 +93,26 @@ x-postgres-env: &postgres-env x-yprovider-env: &yprovider-env COLLABORATION_LOGGING: "true" # Y_PROVIDER_API_KEY supplied via secrets - COLLABORATION_API_URL: http://y-provider:4444/api/ COLLABORATION_SERVER_ORIGIN: https://${DOMAIN} - # COLLABORATION_SERVER_SECRET supplied via secrets COLLABORATION_BACKEND_BASE_URL: https://${DOMAIN} - COLLABORATION_WS_URL: wss://${DOMAIN}/collaboration/ws/ + # The last segment is YHUB_ORG (default: docs) + COLLABORATION_WS_URL: wss://${DOMAIN}/collaboration/ws/v1/docs + +x-yhub-env: &yhub-env + PORT: 3002 + REDIS: redis://yhub-valkey:6379 + # POSTGRES is built from the postgres_p secret in the entrypoint + COLLABORATION_BACKEND_BASE_URL: https://${DOMAIN} + COLLABORATION_SERVER_ORIGIN: https://${DOMAIN} + Y_PROVIDER_API_KEY_FILE: /run/secrets/y_api_key + YHUB_JWT_PRIVATE_KEY_FILE: /run/secrets/yhub_jwt_pk + # Seed documents from the pre-v6 media bucket the first time they are opened. + # Only turn off once `abra app cmd backend migrate_documents` covered all documents. + SOFT_MIGRATION: "${SOFT_MIGRATION:-true}" + LEGACY_S3_ENDPOINT_URL: http://minio:9000 + LEGACY_S3_ACCESS_KEY_ID_FILE: /run/secrets/minio_ru + LEGACY_S3_SECRET_ACCESS_KEY_FILE: /run/secrets/minio_rp + LEGACY_S3_BUCKET_NAME: docs-media-storage x-minio-env: &minio-env MINIO_ROOT_USER_FILE: /run/secrets/minio_ru @@ -147,12 +164,12 @@ services: - django_sk - django_sp - oidc_rpcs - - collab_ss - minio_rp - minio_ru - postgres_p - y_api_key - email_pass + - jwt_pk celery: image: lasuite/impress-backend:v6.0.1 @@ -177,13 +194,55 @@ services: - django_sk - django_sp - oidc_rpcs - - collab_ss - minio_rp + - minio_ru - postgres_p - y_api_key - email_pass + - jwt_pk + # Collaboration server: serves the websocket and holds the document content + yhub: + image: lasuite/impress-yhub:v6.0.1 + networks: + - backend + environment: *yhub-env + user: "${DOCKER_USER:-1000}" + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://localhost:3002/collaboration/ready/v1').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"] + interval: 15s + timeout: 5s + retries: 20 + start_period: 30s + # The server never creates its schema: run the idempotent init-db before every start + entrypoint: > + sh -c "export POSTGRES=\"postgres://docs:$$(node -e 'process.stdout.write(encodeURIComponent(require(\"fs\").readFileSync(\"/run/secrets/postgres_p\",\"utf8\").trim()))')@db:5432/yhub\" && node node_modules/@y/hub/bin/init-db.js && exec /usr/local/bin/entrypoint \"$$@\"" -- + command: ["node", "--import", "./dist/sentry.js", "dist/server.js"] + secrets: + - postgres_p + - y_api_key + - yhub_jwt_pk + - minio_ru + - minio_rp + # Valkey of yhub: holds updates not yet persisted to postgres, so it is a store, not a cache + yhub-valkey: + image: valkey/valkey:8.1.10-alpine + command: ["valkey-server", "--maxmemory-policy", "volatile-lru", "--appendonly", "yes", "--appendfsync", "everysec"] + healthcheck: + test: ["CMD", "valkey-cli", "ping"] + interval: 15s + timeout: 5s + retries: 3 + networks: + - backend + volumes: + - yhub_valkey:/data + deploy: + labels: + backupbot.backup: "${ENABLE_BACKUPS:-true}" + + # Conversion service only since v6 (no longer serves the collaboration) y-provider: image: lasuite/impress-y-provider:v6.0.1 networks: @@ -233,7 +292,7 @@ services: labels: backupbot.backup: "${ENABLE_BACKUPS:-true}" backupbot.backup.pre-hook: "/pg_backup.sh backup" - backupbot.backup.volumes.postgres.path: "backup.sql" + backupbot.backup.volumes.postgres.path: "backup.sql,backup-yhub.sql" backupbot.restore.post-hook: '/pg_backup.sh restore' configs: - source: pg_backup @@ -320,6 +379,7 @@ networks: volumes: postgres: minio: + yhub_valkey: configs: nginx_conf: @@ -352,9 +412,12 @@ secrets: postgres_p: external: true name: ${STACK_NAME}_postgres_p_${SECRET_POSTGRES_P_VERSION} - collab_ss: + jwt_pk: external: true - name: ${STACK_NAME}_collab_ss_${SECRET_COLLAB_SS_VERSION} + name: ${STACK_NAME}_jwt_pk_${SECRET_JWT_PK_VERSION} + yhub_jwt_pk: + external: true + name: ${STACK_NAME}_yhub_jwt_pk_${SECRET_YHUB_JWT_PK_VERSION} minio_rp: external: true name: ${STACK_NAME}_minio_rp_${SECRET_MINIO_RP_VERSION} diff --git a/nginx.conf.tmpl b/nginx.conf.tmpl index 07d5d07..bc5dd5f 100644 --- a/nginx.conf.tmpl +++ b/nginx.conf.tmpl @@ -47,7 +47,14 @@ server { try_files $uri @proxy_to_docs_backend; } - # Proxy auth for collaboration server + location /external_api { + try_files $uri @proxy_to_docs_backend; + } + + # Collaboration server (yhub). Only the routes below are published: it also + # serves prune, create-ydoc, reset-connections, migrate, restore-ydoc and + # reset-ydoc, which the backend calls in-stack and which must not be + # reachable from the outside. location /collaboration/ws/ { # Ensure WebSocket upgrade proxy_http_version 1.1; @@ -55,7 +62,7 @@ server { proxy_set_header Connection "Upgrade"; # Collaboration server - proxy_pass http://y-provider:4444; + proxy_pass http://{{ env "STACK_NAME" }}_yhub:3002; # Set appropriate timeout for WebSocket proxy_read_timeout 86400; @@ -67,9 +74,11 @@ server { proxy_set_header Host $host; } - location /collaboration/api/ { - # Collaboration server - proxy_pass http://y-provider:4444; + # Document routes (http fallback, history) and the public keys of yhub + location ~ ^/collaboration/(ydoc|activity|changeset|rollback|jwks)/ { + proxy_pass http://{{ env "STACK_NAME" }}_yhub:3002; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header Origin $http_origin; proxy_set_header Host $host; } diff --git a/pg_backup.sh b/pg_backup.sh index 7ff7c51..13df4a6 100644 --- a/pg_backup.sh +++ b/pg_backup.sh @@ -3,10 +3,16 @@ set -e BACKUP_FILE='/var/lib/postgresql/data/pgdata/backup.sql' +# database of the collaboration server (yhub), since v6 +YHUB_DB='yhub' +YHUB_BACKUP_FILE='/var/lib/postgresql/data/pgdata/backup-yhub.sql' function backup { export PGPASSWORD=$(cat $POSTGRES_PASSWORD_FILE) pg_dump -U ${POSTGRES_USER} ${POSTGRES_DB} > $BACKUP_FILE + if psql -U ${POSTGRES_USER} -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname='${YHUB_DB}'" | grep -q 1; then + pg_dump -U ${POSTGRES_USER} ${YHUB_DB} > $YHUB_BACKUP_FILE + fi } function restore { @@ -27,6 +33,12 @@ function restore { createdb -U ${POSTGRES_USER} ${POSTGRES_DB} psql -U ${POSTGRES_USER} -d ${POSTGRES_DB} -1 -f $BACKUP_FILE + if [ -f $YHUB_BACKUP_FILE ]; then + psql -U ${POSTGRES_USER} -d postgres -c "DROP DATABASE IF EXISTS ${YHUB_DB} WITH (FORCE);" + createdb -U ${POSTGRES_USER} ${YHUB_DB} + psql -U ${POSTGRES_USER} -d ${YHUB_DB} -1 -f $YHUB_BACKUP_FILE + fi + trap - EXIT INT TERM restore_config } diff --git a/release/next b/release/next new file mode 100644 index 0000000..506945f --- /dev/null +++ b/release/next @@ -0,0 +1,22 @@ +⚠️ Major upgrade to Docs v6: the collaboration server is replaced by the new `yhub` +service, and existing documents have to be migrated to it, or they open EMPTY. + +Before deploying, generate and insert two RSA keys: + + openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out jwt.pem + openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out yhub-jwt.pem + abra app secret insert jwt_pk v1 jwt.pem -f + abra app secret insert yhub_jwt_pk v1 yhub-jwt.pem -f + +and add to your app config: + + SECRET_JWT_PK_VERSION=v1 # generate=false + SECRET_YHUB_JWT_PK_VERSION=v1 # generate=false + +`SECRET_COLLAB_SS_VERSION` and the `collab_ss` secret are no longer used. + +After deploying (SOFT_MIGRATION is on by default), migrate all documents: + + abra app cmd backend migrate_documents + +Only then may you set SOFT_MIGRATION=false. See the README for details.