From 1fa7e5b22639d45c7623e11b0884a4c9155723f9 Mon Sep 17 00:00:00 2001 From: shum Date: Tue, 28 Jul 2026 11:48:49 +0000 Subject: [PATCH] scripts: add chat relay docker setup --- scripts/relay/.dockerignore | 4 ++ scripts/relay/.env.example | 13 ++++ scripts/relay/Dockerfile | 70 +++++++++++++++++++++ scripts/relay/docker-compose.yaml | 74 ++++++++++++++++++++++ scripts/relay/entrypoint.py | 100 ++++++++++++++++++++++++++++++ 5 files changed, 261 insertions(+) create mode 100644 scripts/relay/.dockerignore create mode 100644 scripts/relay/.env.example create mode 100644 scripts/relay/Dockerfile create mode 100644 scripts/relay/docker-compose.yaml create mode 100755 scripts/relay/entrypoint.py diff --git a/scripts/relay/.dockerignore b/scripts/relay/.dockerignore new file mode 100644 index 0000000000..00bc43ddaf --- /dev/null +++ b/scripts/relay/.dockerignore @@ -0,0 +1,4 @@ +# The Dockerfile clones the source itself and only needs entrypoint.py from the +# build context. Exclude everything else, especially .env (secrets) and out/. +* +!entrypoint.py diff --git a/scripts/relay/.env.example b/scripts/relay/.env.example new file mode 100644 index 0000000000..3917532c2a --- /dev/null +++ b/scripts/relay/.env.example @@ -0,0 +1,13 @@ +# Copy to .env and edit per server (relay1 / relay2 / relay3). + +# Relay identity. +RELAY_NAME="My Relay" +RELAY_WEB_DOMAIN=relay1.example.com + +# PostgreSQL credentials (used by both the db service and the relay's DB_CONN). +POSTGRES_USER=simplex +POSTGRES_DB=simplex_chat_relay +POSTGRES_PASSWORD=change-me + +# Source tag to build (relay mode requires v7.0.0-beta.4 or later). +CHAT_REF=v7.0.0-beta.5 diff --git a/scripts/relay/Dockerfile b/scripts/relay/Dockerfile new file mode 100644 index 0000000000..6323cadcd0 --- /dev/null +++ b/scripts/relay/Dockerfile @@ -0,0 +1,70 @@ +# syntax=docker/dockerfile:1 +# Build simplex-chat in relay mode with PostgreSQL support, then ship it on a +# slim runtime with only the libraries the binary links against. A bare +# `scratch` image cannot run this dynamically-linked binary. + +ARG CHAT_REF=v7.0.0-beta.5 +ARG GHC=9.6.3 +ARG CABAL=3.10.1.0 + +# ---- build ---------------------------------------------------------------- +FROM ubuntu:22.04 AS build + +ARG GHC +ARG CABAL +ENV DEBIAN_FRONTEND=noninteractive \ + PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH" + +# Build toolchain and simplex-chat dependencies (libpq-dev for client_postgres). +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl git build-essential \ + libpq-dev libgmp3-dev zlib1g-dev libnuma-dev libssl-dev \ + llvm-12 llvm-12-dev \ + && rm -rf /var/lib/apt/lists/* + +# GHC + cabal via ghcup, then fetch the package index into this layer. +RUN curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org \ + | BOOTSTRAP_HASKELL_NONINTERACTIVE=1 \ + BOOTSTRAP_HASKELL_GHC_VERSION="${GHC}" \ + BOOTSTRAP_HASKELL_CABAL_VERSION="${CABAL}" sh \ + && ghcup set ghc "${GHC}" \ + && ghcup set cabal "${CABAL}" \ + && cabal update + +# CHAT_REF is declared here, after the toolchain layers, so changing the tag +# reuses everything above and rebuilds only the clone + compile below. +ARG CHAT_REF +RUN git clone --depth 1 --branch "${CHAT_REF}" \ + https://github.com/simplex-chat/simplex-chat /project +WORKDIR /project + +# Compile the executable with PostgreSQL persistence and strip it. The cabal +# store (compiled dependencies) and dist-newstyle are cache mounts, so a +# different tag recompiles only changed code, not every dependency. +RUN --mount=type=cache,target=/root/.cabal/store,sharing=locked \ + --mount=type=cache,target=/project/dist-newstyle,sharing=locked \ + cp scripts/cabal.project.local.linux cabal.project.local \ + && cabal build -fclient_postgres exe:simplex-chat \ + && bin=$(find dist-newstyle -name simplex-chat -type f -executable | head -n1) \ + && install -m 0755 "$bin" /simplex-chat-relay \ + && strip /simplex-chat-relay + +# ---- runtime -------------------------------------------------------------- +FROM debian:stable-slim AS runtime + +# python3 runs the entrypoint; the rest are the binary's shared libraries. +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates python3 \ + libpq5 libgmp10 libssl3 zlib1g libnuma1 libffi8 \ + && rm -rf /var/lib/apt/lists/* \ + && useradd -m -u 1000 relay + +COPY --from=build /simplex-chat-relay /usr/local/bin/simplex-chat-relay +COPY entrypoint.py /usr/local/bin/entrypoint.py + +# Run as non-root; host bind-mounts must be writable by this UID. +USER relay + +# The relay (GHC runtime) shuts down cleanly on SIGINT, not SIGTERM. +STOPSIGNAL SIGINT +ENTRYPOINT ["python3", "/usr/local/bin/entrypoint.py"] diff --git a/scripts/relay/docker-compose.yaml b/scripts/relay/docker-compose.yaml new file mode 100644 index 0000000000..3e2f1b768b --- /dev/null +++ b/scripts/relay/docker-compose.yaml @@ -0,0 +1,74 @@ +name: simplex-chat-relay + +services: + relay: + build: + context: . + args: + CHAT_REF: ${CHAT_REF:-v7.0.0-beta.5} + image: chat-relay:latest # set your image/registry name + container_name: chat-relay # optional: rename + depends_on: + db: + condition: service_healthy + environment: + RELAY_NAME: ${RELAY_NAME} + RELAY_WEB_DOMAIN: ${RELAY_WEB_DOMAIN} + # Password comes from PGPASSWORD (libpq reads it), so it stays out of the + # process argv and needs no URL-encoding. + DB_CONN: postgresql://${POSTGRES_USER}@db:5432/${POSTGRES_DB} + PGPASSWORD: ${POSTGRES_PASSWORD} + volumes: + # Channel previews + CORS config, served by the host's system Caddy. + - /var/www/relay-web-channels:/var/www/relay-web-channels + # Relay address is written here: `cat out/relay-address.txt`. + - ./out:/out + restart: unless-stopped + + db: + image: postgres:18 + container_name: chat-relay-db # optional: rename + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + # Parallel workers use POSIX shared memory; the default 64 MB /dev/shm can + # be too small and cause "could not resize shared memory segment" errors. + shm_size: 256mb + volumes: + # PG18+ uses a version-specific PGDATA (/var/lib/postgresql/18/docker) and + # a VOLUME at /var/lib/postgresql. Mount there so major-version upgrades + # can use pg_upgrade --link. + - pgdata:/var/lib/postgresql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 5s + timeout: 5s + retries: 10 + # Tuning for ~5 GB RAM / 4 cores. Match to the actual server. + # `--name=value` is postgres's equivalent of `-c name=value`. + command: + - postgres + - --max_connections=100 + - --shared_buffers=1280MB + - --effective_cache_size=3840MB + - --maintenance_work_mem=320MB + - --checkpoint_completion_target=0.9 + - --wal_buffers=16MB + - --default_statistics_target=100 + - --random_page_cost=1.1 + - --effective_io_concurrency=200 + - --work_mem=6301kB + - --huge_pages=off + - --jit=off + - --wal_compression=lz4 + - --min_wal_size=1GB + - --max_wal_size=4GB + - --max_worker_processes=4 + - --max_parallel_workers_per_gather=2 + - --max_parallel_workers=4 + - --max_parallel_maintenance_workers=2 + restart: unless-stopped + +volumes: + pgdata: diff --git a/scripts/relay/entrypoint.py b/scripts/relay/entrypoint.py new file mode 100755 index 0000000000..31d3deb9a8 --- /dev/null +++ b/scripts/relay/entrypoint.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Record the relay address, then run the relay with no wrapper in the way. + +On the first start (or whenever /out/relay-address.txt is missing) a short +one-shot invocation creates the profile and address if needed, prints the +address, and saves it. Then the real relay is exec'd, so it runs as PID 1 with +no Python left in the process tree. Steady-state starts skip straight to the +exec. + +The address is not stored in the database as a ready-to-use string (it is a +binary conn-req blob plus short-link data, re-encoded on display), so the +relay's own output is the canonical source. +""" +import os +import subprocess +import sys + +BIN = "simplex-chat-relay" +WEB_ROOT = "/var/www/relay-web-channels" +ADDR_FILE = "/out/relay-address.txt" +CAPTURE_TIMEOUT = 180 # seconds; first address creation involves an SMP round-trip + + +def require(name): + value = os.environ.get(name) + if not value: + sys.exit(f"{name} is required") + return value + + +def find_address(text): + for token in text.split(): + if token.startswith("https://") or token.startswith("simplex:"): + return token + return None + + +def capture_address(oneshot): + """One-shot: create the profile/address if needed, print it, and save it. + + Runs before the real relay starts, so there is never a second agent + subscribing to the same queues. If it can't capture (e.g. SMP briefly + unreachable), the relay still creates and serves the address, and the next + start retries because the file is still missing. + """ + cmd = oneshot + ["--create-schema", "-t", "0", "-e", "/sa"] + try: + out = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + errors="replace", + timeout=CAPTURE_TIMEOUT, + ).stdout + except subprocess.TimeoutExpired as exc: + out = exc.stdout or "" + sys.stdout.write(out) # keep it in the container logs + sys.stdout.flush() + + address = find_address(out) + if address: + with open(ADDR_FILE, "w") as f: + f.write(address + "\n") + else: + sys.stderr.write("entrypoint: relay address not captured; will retry next start\n") + + +def main(): + name = require("RELAY_NAME") + domain = require("RELAY_WEB_DOMAIN") + conn = require("DB_CONN") + image_file = os.environ.get("RELAY_IMAGE_FILE") + + os.makedirs(f"{WEB_ROOT}/channel", exist_ok=True) + os.makedirs("/out", exist_ok=True) + + common = [BIN, "--relay", "--headless", "--user-display-name", name] + + # First start (or the file was removed): bootstrap and capture the address, + # then the real relay reuses it. The avatar only applies at profile + # creation, so it goes on the one-shot, not the long-running relay. + if not os.path.exists(ADDR_FILE): + oneshot = common + (["--user-image-file", image_file] if image_file else []) + ["-d", conn] + capture_address(oneshot) + + # Replace this process with the relay: PID 1, native signals, no wrapper. + relay = common + [ + "--relay-web-domain", domain, + "--relay-web-dir", f"{WEB_ROOT}/channel", + "--relay-web-cors-file", f"{WEB_ROOT}/cors.conf", + "--relay-web-interval", "30", + "-d", conn, + "--create-schema", + ] + os.execvp(relay[0], relay) + + +if __name__ == "__main__": + main()