From dbbe3c8f0ae1a2a152468e77291ac9c7302dff4f Mon Sep 17 00:00:00 2001 From: Roi Glinik Date: Wed, 20 May 2026 23:54:10 +0300 Subject: [PATCH 1/2] Robusta fork: gate JWT exp requirement behind env var Self-host customers' existing ANON_KEY/SERVICE_ROLE_KEY JWTs predate the upstream realtime hard-requirement on the 'exp' claim. To avoid forcing every customer to rotate keys on upgrade, this fork makes 'exp' optional when the new env var JWT_REQUIRE_EXP=false is set. Default (JWT_REQUIRE_EXP=true) matches upstream behavior exactly. Changes: * config/runtime.exs: read JWT_REQUIRE_EXP env var into :realtime, :jwt_require_exp app env * channels_authorization.ex: toggle required = ["role", "exp"] vs ["role"] based on the flag * jwt_verification.ex: add token_config(opts), omit exp validator when flag off AND token has no exp claim. Expired tokens still rejected when exp is present. * Dockerfile: reuse prebuilt supabase/realtime:v2.96.0 image for pgdelta artifacts instead of running the heavy bun-based pgdelta-builder stage. * .github/workflows/robusta-build.yml: manual + push-triggered build pipeline targeting GCP Artifact Registry and (opt-in) Docker Hub. --- .github/workflows/robusta-build.yml | 111 ++++++++++++++++++ Dockerfile | 48 ++------ config/runtime.exs | 8 +- .../channels/auth/channels_authorization.ex | 9 +- .../channels/auth/jwt_verification.ex | 38 ++++-- 5 files changed, 166 insertions(+), 48 deletions(-) create mode 100644 .github/workflows/robusta-build.yml diff --git a/.github/workflows/robusta-build.yml b/.github/workflows/robusta-build.yml new file mode 100644 index 000000000..db8960762 --- /dev/null +++ b/.github/workflows/robusta-build.yml @@ -0,0 +1,111 @@ +name: Robusta — Build realtime fork image + +# Two ways to trigger: +# 1. Manual via "Run workflow" in the Actions tab (pick branch + supply image_tag). +# 2. Push to a branch matching robusta-build/** — tag is derived from the branch suffix. +on: + workflow_dispatch: + inputs: + image_tag: + description: 'Image tag to publish (e.g. v2.96.0-robusta.1)' + required: true + default: 'v2.96.0-robusta.1' + push_dockerhub: + description: 'Also push to robustadev/realtime on Docker Hub' + required: false + default: 'false' + type: choice + options: ['true', 'false'] + push: + branches: + - 'robusta-build/**' + +# Cancel in-progress runs for the same ref +concurrency: + group: realtime-fork-build-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + + permissions: + contents: 'read' + id-token: 'write' # required for GCP workload identity federation + + steps: + - uses: actions/checkout@v4 + + - name: Resolve image tag + id: tag + run: | + if [ -n "${{ github.event.inputs.image_tag }}" ]; then + TAG='${{ github.event.inputs.image_tag }}' + else + BRANCH="${GITHUB_REF#refs/heads/}" + TAG="${BRANCH#robusta-build/}" + [ -z "$TAG" ] && TAG="v2.96.0-robusta.dev" + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "Resolved image tag: $TAG" + + - uses: 'google-github-actions/auth@v2' + with: + project_id: 'genuine-flight-317411' + workload_identity_provider: 'projects/429189597230/locations/global/workloadIdentityPools/github/providers/robusta-repos' + + - name: Set up gcloud CLI + uses: google-github-actions/setup-gcloud@v2 + with: + project_id: genuine-flight-317411 + + - name: Configure Docker for Artifact Registry + run: gcloud auth configure-docker us-central1-docker.pkg.dev --quiet + + - name: Login to Docker Hub + if: github.event.inputs.push_dockerhub == 'true' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push (GCP Artifact Registry) + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + platforms: linux/amd64 + push: true + tags: | + us-central1-docker.pkg.dev/genuine-flight-317411/devel/realtime:${{ steps.tag.outputs.tag }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Build and push (Docker Hub, opt-in) + if: github.event.inputs.push_dockerhub == 'true' + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + platforms: linux/amd64 + push: true + tags: | + robustadev/realtime:${{ steps.tag.outputs.tag }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Summary + run: | + { + echo "### Built image" + echo '' + echo '```' + echo "us-central1-docker.pkg.dev/genuine-flight-317411/devel/realtime:${{ steps.tag.outputs.tag }}" + if [ "${{ github.event.inputs.push_dockerhub }}" = "true" ]; then + echo "robustadev/realtime:${{ steps.tag.outputs.tag }}" + fi + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/Dockerfile b/Dockerfile index f02bd9a59..3c5c50b69 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,41 +3,11 @@ ARG OTP_VERSION=27.3 ARG DEBIAN_VERSION=bookworm-20250929-slim ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}" -# @supabase/pg-delta@1.0.0-alpha.24 -ARG PG_DELTA_COMMIT=102ef99ae5aabb29510d48b39fbb8ecee34f5458 - -FROM debian:${DEBIAN_VERSION} AS pgdelta-builder -ARG PG_DELTA_COMMIT -ARG BUN_VERSION=1.3.14 - -RUN set -eux; \ - apt-get update -y; \ - apt-get install -y --no-install-recommends curl ca-certificates unzip xz-utils; \ - curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}"; \ - export PATH="/root/.bun/bin:${PATH}"; \ - mkdir -p /build && cd /build; \ - curl -fsSL "https://github.com/supabase/pg-toolbelt/archive/${PG_DELTA_COMMIT}.tar.gz" \ - | tar xz --strip-components=1; \ - bun install --frozen-lockfile --ignore-scripts; \ - cd /build/packages/pg-delta; \ - bun build --compile src/cli/bin/cli.ts --outfile /tmp/pgdelta; \ - /tmp/pgdelta --help > /dev/null; \ - xz -9 -e -T0 -c /tmp/pgdelta > /tmp/pgdelta.xz; \ - cd / && find build -path '*/@libpg-query/parser/wasm/libpg-query.wasm' \ - | tar -czf /tmp/libpg-query.tar.gz -T -; \ - printf '%s\n' \ - '#!/bin/sh' \ - 'set -e' \ - 'BIN=/app/.pgdelta-cache/pgdelta' \ - 'if [ ! -x "$BIN" ]; then' \ - ' mkdir -p "$(dirname "$BIN")"' \ - ' xz -dcT0 /usr/local/share/pgdelta/pgdelta.xz > "$BIN"' \ - ' chmod +x "$BIN"' \ - 'fi' \ - 'exec "$BIN" "$@"' \ - > /tmp/pgdelta-wrapper; \ - chmod +x /tmp/pgdelta-wrapper; \ - rm -rf /tmp/pgdelta /build /root/.bun /var/lib/apt/lists/* + +# Robusta: reuse the prebuilt pgdelta + libpg-query artifacts from the +# upstream supabase/realtime image instead of running the heavy +# pgdelta-builder stage (saves ~2GB peak RAM during the build). +FROM supabase/realtime:v2.96.0 AS pgdelta-source FROM ${BUILDER_IMAGE} AS builder @@ -114,10 +84,10 @@ RUN apt-get update -y && \ libstdc++6 openssl libncurses5 locales iptables sudo tini curl awscli jq xz-utils && \ apt-get clean && rm -rf /var/lib/apt/lists/* -COPY --from=pgdelta-builder /tmp/pgdelta.xz /usr/local/share/pgdelta/pgdelta.xz -COPY --from=pgdelta-builder /tmp/pgdelta-wrapper /usr/local/bin/pgdelta -COPY --from=pgdelta-builder /tmp/libpg-query.tar.gz /tmp/libpg-query.tar.gz -RUN tar -C / -xzf /tmp/libpg-query.tar.gz && rm /tmp/libpg-query.tar.gz +# Robusta: pgdelta artifacts copied from the upstream realtime image. +COPY --from=pgdelta-source /usr/local/share/pgdelta/pgdelta.xz /usr/local/share/pgdelta/pgdelta.xz +COPY --from=pgdelta-source /usr/local/bin/pgdelta /usr/local/bin/pgdelta +COPY --from=pgdelta-source /build/node_modules/.bun/@libpg-query+parser@17.6.3/node_modules/@libpg-query/parser/wasm/libpg-query.wasm /build/node_modules/.bun/@libpg-query+parser@17.6.3/node_modules/@libpg-query/parser/wasm/libpg-query.wasm # Set the locale RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen diff --git a/config/runtime.exs b/config/runtime.exs index 1b3be1a0a..89ba8dfe5 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -54,6 +54,10 @@ janitor_run_after_in_ms = Env.get_integer("JANITOR_RUN_AFTER_IN_MS", :timer.minu janitor_schedule_randomize = Env.get_boolean("JANITOR_SCHEDULE_RANDOMIZE", true) janitor_schedule_timer_in_ms = Env.get_integer("JANITOR_SCHEDULE_TIMER_IN_MS", :timer.hours(4)) jwt_claim_validators = System.get_env("JWT_CLAIM_VALIDATORS", "{}") +# Robusta-specific opt-out: when set to "false", a missing `exp` claim is +# accepted. Present-but-expired tokens are still rejected. Default matches +# upstream (true = exp required). +jwt_require_exp = Env.get_boolean("JWT_REQUIRE_EXP", true) log_level = System.get_env("LOG_LEVEL", "info") |> String.to_existing_atom() log_throttle_janitor_interval_in_ms = Env.get_integer("LOG_THROTTLE_JANITOR_INTERVAL_IN_MS", :timer.minutes(10)) logflare_logger_backend_url = System.get_env("LOGFLARE_LOGGER_BACKEND_URL", "https://api.logflare.app") @@ -219,7 +223,9 @@ config :realtime, metrics_pusher_interval_ms: metrics_pusher_interval_ms, metrics_pusher_timeout_ms: metrics_pusher_timeout_ms, metrics_pusher_compress: metrics_pusher_compress, - metrics_pusher_extra_labels: metrics_pusher_extra_labels + metrics_pusher_extra_labels: metrics_pusher_extra_labels, + # Robusta-specific opt-out for the JWT `exp` claim requirement. + jwt_require_exp: jwt_require_exp if config_env() != :test && run_janitor do config :realtime, diff --git a/lib/realtime_web/channels/auth/channels_authorization.ex b/lib/realtime_web/channels/auth/channels_authorization.ex index d29076e7e..f7691658d 100644 --- a/lib/realtime_web/channels/auth/channels_authorization.ex +++ b/lib/realtime_web/channels/auth/channels_authorization.ex @@ -18,7 +18,14 @@ defmodule RealtimeWeb.ChannelsAuthorization do def authorize_conn(token, jwt_secret, jwt_jwks) do case authorize(token, jwt_secret, jwt_jwks) do {:ok, claims} -> - required = ["role", "exp"] + # Robusta-specific opt-out: when JWT_REQUIRE_EXP=false, do not require + # the `exp` claim. Present-but-expired tokens are still rejected upstream + # by JwtVerification. + required = + if Application.get_env(:realtime, :jwt_require_exp, true), + do: ["role", "exp"], + else: ["role"] + claims_keys = Map.keys(claims) if Enum.all?(required, &(&1 in claims_keys)), diff --git a/lib/realtime_web/channels/auth/jwt_verification.ex b/lib/realtime_web/channels/auth/jwt_verification.ex index 0828c28c1..3fe7abc25 100644 --- a/lib/realtime_web/channels/auth/jwt_verification.ex +++ b/lib/realtime_web/channels/auth/jwt_verification.ex @@ -11,11 +11,28 @@ defmodule RealtimeWeb.JwtVerification do @impl true def token_config do - Application.fetch_env!(:realtime, :jwt_claim_validators) - |> Enum.reduce(%{}, fn {claim_key, expected_val}, claims -> - add_claim_validator(claims, claim_key, expected_val) - end) - |> add_claim_validator("exp") + token_config(include_exp: true) + end + + # Robusta-specific opt-out: when `include_exp` is false, the `exp` validator + # is omitted so a token missing `exp` can still pass validation. Joken + # silently skips validators for claims not present in the token, so when + # `exp` is present it will still be validated regardless of this flag. + # (We pass true unconditionally in production, but JwtVerification.verify/3 + # may build a custom config without `exp` when the env var disables it AND + # the incoming token has no `exp` claim.) + def token_config(opts) do + base = + Application.fetch_env!(:realtime, :jwt_claim_validators) + |> Enum.reduce(%{}, fn {claim_key, expected_val}, claims -> + add_claim_validator(claims, claim_key, expected_val) + end) + + if Keyword.get(opts, :include_exp, true) do + add_claim_validator(base, "exp") + else + base + end end defp add_claim_validator(claims, "exp") do @@ -38,10 +55,17 @@ defmodule RealtimeWeb.JwtVerification do """ @spec verify(binary(), binary(), binary() | nil) :: {:ok, map()} | {:error, any()} def verify(token, jwt_secret, jwt_jwks) when is_binary(token) do - with {:ok, _claims} <- check_claims_format(token), + with {:ok, claims} <- check_claims_format(token), {:ok, header} <- check_header_format(token), {:ok, signer} <- generate_signer(header, jwt_secret, jwt_jwks) do - JwtAuthToken.verify_and_validate(token, signer) + # Robusta-specific opt-out: when JWT_REQUIRE_EXP=false AND the token has + # no `exp` claim, build a token_config that omits the `exp` validator. + # In all other cases (flag on, or exp present) behaviour matches upstream. + include_exp = + Application.get_env(:realtime, :jwt_require_exp, true) or Map.has_key?(claims, "exp") + + token_config = JwtAuthToken.token_config(include_exp: include_exp) + Joken.verify_and_validate(token_config, token, signer) else {:error, _e} = error -> error end From c0c3b8b64202823b144237f24575352f06daea76 Mon Sep 17 00:00:00 2001 From: Roi Glinik Date: Sun, 24 May 2026 23:40:08 +0300 Subject: [PATCH 2/2] Robusta fork: build arm64 image alongside amd64 Adds an arm64 matrix entry running on ubuntu-24.04-arm so a multi-arch fork image is available (pushed as :-arm64) for local testing on Apple Silicon. Native runner avoids QEMU emulation, which is slow and flaky for Elixir/Erlang builds. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/robusta-build.yml | 37 +++++++++++++++++++---------- 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/.github/workflows/robusta-build.yml b/.github/workflows/robusta-build.yml index db8960762..3eff89d9c 100644 --- a/.github/workflows/robusta-build.yml +++ b/.github/workflows/robusta-build.yml @@ -27,7 +27,20 @@ concurrency: jobs: build: - runs-on: ubuntu-latest + runs-on: ${{ matrix.runner }} + + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-latest + platform: linux/amd64 + tag_suffix: '' + - arch: arm64 + runner: ubuntu-24.04-arm + platform: linux/arm64 + tag_suffix: '-arm64' permissions: contents: 'read' @@ -77,12 +90,12 @@ jobs: with: context: . file: Dockerfile - platforms: linux/amd64 + platforms: ${{ matrix.platform }} push: true tags: | - us-central1-docker.pkg.dev/genuine-flight-317411/devel/realtime:${{ steps.tag.outputs.tag }} - cache-from: type=gha - cache-to: type=gha,mode=max + us-central1-docker.pkg.dev/genuine-flight-317411/devel/realtime:${{ steps.tag.outputs.tag }}${{ matrix.tag_suffix }} + cache-from: type=gha,scope=${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=${{ matrix.arch }} - name: Build and push (Docker Hub, opt-in) if: github.event.inputs.push_dockerhub == 'true' @@ -90,22 +103,22 @@ jobs: with: context: . file: Dockerfile - platforms: linux/amd64 + platforms: ${{ matrix.platform }} push: true tags: | - robustadev/realtime:${{ steps.tag.outputs.tag }} - cache-from: type=gha - cache-to: type=gha,mode=max + robustadev/realtime:${{ steps.tag.outputs.tag }}${{ matrix.tag_suffix }} + cache-from: type=gha,scope=${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=${{ matrix.arch }} - name: Summary run: | { - echo "### Built image" + echo "### Built image (${{ matrix.arch }})" echo '' echo '```' - echo "us-central1-docker.pkg.dev/genuine-flight-317411/devel/realtime:${{ steps.tag.outputs.tag }}" + echo "us-central1-docker.pkg.dev/genuine-flight-317411/devel/realtime:${{ steps.tag.outputs.tag }}${{ matrix.tag_suffix }}" if [ "${{ github.event.inputs.push_dockerhub }}" = "true" ]; then - echo "robustadev/realtime:${{ steps.tag.outputs.tag }}" + echo "robustadev/realtime:${{ steps.tag.outputs.tag }}${{ matrix.tag_suffix }}" fi echo '```' } >> "$GITHUB_STEP_SUMMARY"