Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions .github/workflows/robusta-build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
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: ${{ 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'
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: ${{ matrix.platform }}
push: true
tags: |
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'
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
platforms: ${{ matrix.platform }}
push: true
tags: |
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 (${{ matrix.arch }})"
echo ''
echo '```'
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 }}${{ matrix.tag_suffix }}"
fi
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
48 changes: 9 additions & 39 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 8 additions & 1 deletion lib/realtime_web/channels/auth/channels_authorization.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
38 changes: 31 additions & 7 deletions lib/realtime_web/channels/auth/jwt_verification.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading