diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift
index b9048becda..9711f3d6f8 100644
--- a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift
+++ b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift
@@ -802,6 +802,8 @@ private func saveAddressSettings(_ settings: AddressSettingsState, _ savedSettin
}
}
+private let simplexNameSaleStart = Calendar(identifier: .gregorian).date(from: DateComponents(timeZone: TimeZone(identifier: "UTC"), year: 2026, month: 12, day: 12, hour: 18))!
+
struct SetSimplexDomainView: View {
let title: LocalizedStringKey
let footer: LocalizedStringKey
@@ -815,6 +817,8 @@ struct SetSimplexDomainView: View {
@State private var original = ""
@State private var didSave = false
@State private var editing = false
+ @State private var timeToSaleStart = simplexNameSaleStart.timeIntervalSinceNow
+ @State private var saleTimer: Timer? = nil
@FocusState private var nameFocused: Bool
init(title: LocalizedStringKey, footer: LocalizedStringKey, prompt: String, simplexName: String, broadcastWarning: String? = nil, save: @escaping (String?) async -> Bool) {
@@ -873,7 +877,7 @@ struct SetSimplexDomainView: View {
Section {
if editing {
Button {
- openBrowserAlert(uri: "https://github.com/simplex-chat/simplex-chat/blob/master/docs/guide/register-simplex-name.md")
+ openBrowserAlert(uri: "https://simplex.domains/#testing")
} label: {
HStack {
Text("How to register a test name")
@@ -901,15 +905,39 @@ struct SetSimplexDomainView: View {
}
}
}
+ Section {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(verbatim: saleCountdown(timeToSaleStart))
+ Text(timeToSaleStart > 0 ? "until you can register a SimpleX domain" : "Update the app to register a SimpleX domain")
+ .font(.caption)
+ .foregroundColor(theme.colors.secondary)
+ }
+ } header: {
+ Text("SimpleX name sale starts in")
+ .foregroundColor(theme.colors.secondary)
+ } footer: {
+ Text("Crowdfunding investors can reserve names before the sale starts: [simplex.domains](https://simplex.domains/)")
+ .foregroundColor(theme.colors.secondary)
+ .padding(.bottom)
+ }
}
+ .modifier(ThemedBackground(grouped: true))
.navigationTitle(title)
.navigationBarTitleDisplayMode(.large)
.onAppear {
if editing {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { nameFocused = true }
}
+ if timeToSaleStart > 0 {
+ saleTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { t in
+ timeToSaleStart = simplexNameSaleStart.timeIntervalSinceNow
+ if timeToSaleStart <= 0 { t.invalidate() }
+ }
+ }
}
.onDisappear {
+ saleTimer?.invalidate()
+ saleTimer = nil
if !didSave, !saving, changed, isValid {
let domain = normalized(simplexName)
let saveName = save
@@ -948,6 +976,21 @@ struct SetSimplexDomainView: View {
: addSimplexTLD((t.hasPrefix("@") || t.hasPrefix("#") ? String(t.dropFirst()) : t).lowercased())
}
+ private func saleCountdown(_ remaining: TimeInterval) -> String {
+ let total = max(0, Int(remaining))
+ let days = total / 86400
+ let dayStr = String.localizedStringWithFormat(
+ days == 1
+ ? NSLocalizedString("%d day", comment: "time interval")
+ : NSLocalizedString("%d days", comment: "time interval"),
+ days
+ )
+ return dayStr + " " + String.localizedStringWithFormat(
+ NSLocalizedString("%02d hrs %02d min %02d sec", comment: "countdown"),
+ total / 3600 % 24, total / 60 % 60, total % 60
+ )
+ }
+
private func addSimplexTLD(_ d: String) -> String {
if d.contains(".") { d } else { "\(d).simplex" }
}
diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts
index ec4235d344..413667c968 100644
--- a/apps/multiplatform/common/build.gradle.kts
+++ b/apps/multiplatform/common/build.gradle.kts
@@ -211,7 +211,7 @@ afterEvaluate {
val fontLtGtRegex = Regex("[^>]*>.*<font[^>]*>.*</font>.*")
val unbracketedColorRegex = Regex("color=#[abcdefABCDEF0-9]{3,6}")
val correctHtmlRegex = Regex("[^>]*>.*.*.*|[^>]*>.*.*.*|[^>]*>.*.*.*|[^>]*>.*]*>.*.*")
- val possibleFormat = listOf("s", "d", "1\$s", "2\$s", "3\$s", "4\$s", "1\$d", "2\$d", "3\$d", "4\$d", "2s", "f")
+ val possibleFormat = listOf("s", "d", "1\$s", "2\$s", "3\$s", "4\$s", "1\$d", "2\$d", "3\$d", "4\$d", "1\$02d", "2\$02d", "3\$02d", "2s", "f")
fun String.id(): String = replace(" 0) {
+ delay(1000)
+ msToSaleStart.value = simplexNameSaleStart.toEpochMilliseconds() - System.currentTimeMillis()
+ }
+ }
+ SectionView(stringResource(MR.strings.simplex_name_sales)) {
+ SectionItemView {
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text(saleCountdown(msToSaleStart.value))
+ Text(
+ stringResource(if (msToSaleStart.value > 0) MR.strings.until_register_simplex_domain else MR.strings.update_app_register_simplex_domain),
+ color = MaterialTheme.colors.secondary,
+ fontSize = 12.sp
+ )
+ }
+ }
+ }
+ SectionTextFooter(buildAnnotatedString {
+ append(generalGetString(MR.strings.simplex_name_sales_footer))
+ append(" ")
+ withLink(LinkAnnotation.Url(SIMPLEX_DOMAINS_URL) { uriHandler.openUriCatching(SIMPLEX_DOMAINS_URL) }) {
+ withStyle(SpanStyle(color = MaterialTheme.colors.primary)) {
+ append("simplex.domains")
+ }
+ }
+ })
SectionBottomSpacer()
}
}
diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml
index 48a464606d..b42ee2f6cd 100644
--- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml
+++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml
@@ -403,6 +403,11 @@
Get SimpleX name (BETA)
Channel SimpleX name
How to register a test name
+ SimpleX name sale starts in
+ until you can register a SimpleX domain
+ Update the app to register a SimpleX domain
+ %1$02d hrs %2$02d min %3$02d sec
+ Crowdfunding investors can reserve names before the sale starts:
Remove name
Save
Edit
diff --git a/apps/multiplatform/external/nanohttpd/build.gradle.kts b/apps/multiplatform/external/nanohttpd/build.gradle.kts
index fb24922208..7841d8f803 100644
--- a/apps/multiplatform/external/nanohttpd/build.gradle.kts
+++ b/apps/multiplatform/external/nanohttpd/build.gradle.kts
@@ -26,16 +26,25 @@ java {
targetCompatibility = jvmVersion
}
-// Without this the jar records the build machine's timestamps, file order and file modes,
-// which makes the desktop packages unreproducible
-tasks.jar {
- // Checked here and not during configuration, so that Android builds, which don't use nanohttpd,
- // work without the submodule
+val upstreamSources = sourceSets.main.get().java.matching { include("org/nanohttpd/**") }
+
+// compileJava and jar silently succeed without the sources, so the check needs its own task.
+// It cannot run during configuration, Android builds must work without the submodule.
+val checkUpstreamSources by tasks.registering {
doFirst {
- if (!upstream.file("core/src/main/java").asFile.isDirectory) {
+ if (upstreamSources.isEmpty) {
throw GradleException("nanohttpd sources are missing, run: git submodule update --init --recursive")
}
}
+}
+
+tasks.compileJava {
+ dependsOn(checkUpstreamSources)
+}
+
+// Without this the jar records the build machine's timestamps, file order and file modes,
+// which makes the desktop packages unreproducible
+tasks.jar {
isPreserveFileTimestamps = false
isReproducibleFileOrder = true
filePermissions { unix("644") }
diff --git a/docs/CHAT-RELAY.md b/docs/CHAT-RELAY.md
index a06c06026f..8f94aa8090 100644
--- a/docs/CHAT-RELAY.md
+++ b/docs/CHAT-RELAY.md
@@ -1,6 +1,6 @@
---
title: Hosting your own Chat Relay
-revision: 16.07.2026
+revision: 28.07.2026
---
# Hosting your own Chat Relay
@@ -20,6 +20,7 @@ This guide explains how to set up a chat relay on a Linux server, how to run it,
- [Relay options](#relay-options)
- [Get the relay address](#get-the-relay-address)
- [Run relay commands](#run-relay-commands)
+- [Run with Docker](#run-with-docker)
- [Channel web previews](#channel-web-previews)
- [Relay web options](#relay-web-options)
- [Serve the previews with Caddy](#serve-the-previews-with-caddy)
@@ -126,6 +127,73 @@ simplex-chat-relay -d /home/relay/relay -e "/set profile image file /home/relay/
systemctl start simplex-relay
```
+## Run with Docker
+
+The relay can also be built and run with Docker Compose, using PostgreSQL for storage. The files are in [`scripts/relay`](https://github.com/simplex-chat/simplex-chat/tree/master/scripts/relay).
+
+1. Clone the repository and switch to the relay directory:
+
+ ```sh
+ git clone https://github.com/simplex-chat/simplex-chat
+ cd simplex-chat/scripts/relay
+ ```
+
+2. Copy the example environment file, then set `RELAY_NAME`, `RELAY_WEB_DOMAIN` and `POSTGRES_PASSWORD` in it:
+
+ ```sh
+ cp .env.example .env
+ ```
+
+3. Create the directory for the previews and the CORS file, owned by the container's user (UID `1000`):
+
+ ```sh
+ mkdir -p /var/www/relay-web-channels/channel
+ chown -R 1000:1000 /var/www/relay-web-channels
+ chmod 0755 /var/www/relay-web-channels
+ ```
+
+4. Create the output directory for the relay address, then build and start:
+
+ ```sh
+ mkdir -p out && chown 1000:1000 out
+ docker compose build
+ docker compose up -d
+ ```
+
+ The first build compiles from source and takes a while.
+
+5. Read the relay address, written on the first start:
+
+ ```sh
+ cat out/relay-address.txt
+ ```
+
+To give the relay a picture, put a small `.png`/`.jpg`/`.jpeg` file (large images are rejected) next to the compose file and add a `docker-compose.override.yml`:
+
+```yaml
+services:
+ relay:
+ environment:
+ RELAY_IMAGE_FILE: /avatar.png
+ volumes:
+ - ./avatar.png:/avatar.png:ro
+```
+
+To run a one-off command against the relay's database, override the entrypoint:
+
+```sh
+docker compose run --rm --entrypoint sh relay -c \
+ 'simplex-chat-relay -d "$DB_CONN" -e "/set profile image file /avatar.png"'
+```
+
+Relay metrics from the database are published by [sql_exporter](https://github.com/burningalchemist/sql_exporter) on `127.0.0.1:9399/metrics`, with the queries in `sql_exporter.yml`.
+
+Relay database live in PostgreSQL docker volume. To print the full path to PostgreSQL database, execute in the host:
+
+```sh
+docker volume inspect simplex-chat-relay_pgdata --format '{{.Mountpoint}}'
+```
+
## Channel web previews
Chat relays can render recent messages of its public channels as JSON files, which can be served over HTTPS using a web server to create channel web previews. This is optional.
@@ -219,11 +287,9 @@ Create `/etc/systemd/system/simplex-cors-sync.service`:
```ini
[Unit]
Description=Sync SimpleX relay CORS config to Caddy
-StartLimitIntervalSec=30
-StartLimitBurst=10
+StartLimitIntervalSec=0
[Service]
Type=oneshot
-ExecStartPre=/bin/sleep 2
ExecStart=/usr/local/bin/simplex-cors-sync.sh
```
@@ -236,6 +302,7 @@ After=caddy.service
[Path]
PathChanged=/var/www/relay-web-channels/cors.conf
Unit=simplex-cors-sync.service
+TriggerLimitIntervalSec=0
[Install]
WantedBy=multi-user.target
```
diff --git a/scripts/desktop/prepare-vlc-linux.sh b/scripts/desktop/prepare-vlc-linux.sh
index ef1ee1b308..be30c7a4c9 100755
--- a/scripts/desktop/prepare-vlc-linux.sh
+++ b/scripts/desktop/prepare-vlc-linux.sh
@@ -12,7 +12,7 @@ vlc_dir=$root_dir/apps/multiplatform/common/src/commonMain/cpp/desktop/libs/linu
mkdir $vlc_dir || exit 0
-vlc_tag='v3.0.21-1'
+vlc_tag='v3.0.23-2'
vlc_url="https://github.com/simplex-chat/vlc/releases/download/${vlc_tag}/vlc-linux-${ARCH}.appimage"
cd /tmp
diff --git a/scripts/desktop/prepare-vlc-mac.sh b/scripts/desktop/prepare-vlc-mac.sh
index 180acf4426..0e83f44dcf 100755
--- a/scripts/desktop/prepare-vlc-mac.sh
+++ b/scripts/desktop/prepare-vlc-mac.sh
@@ -10,7 +10,7 @@ else
vlc_arch=intel64
fi
-vlc_tag='v3.0.21-1'
+vlc_tag='v3.0.23-2'
vlc_url="https://github.com/simplex-chat/vlc/releases/download/${vlc_tag}/vlc-macos-${ARCH}.zip"
function readlink() {
diff --git a/scripts/desktop/prepare-vlc-windows.sh b/scripts/desktop/prepare-vlc-windows.sh
index 4e65528ca0..cf9b553f15 100644
--- a/scripts/desktop/prepare-vlc-windows.sh
+++ b/scripts/desktop/prepare-vlc-windows.sh
@@ -10,7 +10,7 @@ vlc_dir=$root_dir/apps/multiplatform/common/src/commonMain/cpp/desktop/libs/wind
rm -rf $vlc_dir
mkdir -p $vlc_dir/vlc || exit 0
-vlc_tag='v3.0.21-1'
+vlc_tag='v3.0.23-2'
vlc_url="https://github.com/simplex-chat/vlc/releases/download/${vlc_tag}/vlc-win-x86_64.zip"
cd /tmp
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..7bfbd89615
--- /dev/null
+++ b/scripts/relay/.env.example
@@ -0,0 +1,21 @@
+# Copy to .env and edit.
+
+RELAY_NAME="My Relay"
+RELAY_WEB_DOMAIN=relay1.example.com
+
+POSTGRES_USER=simplex
+POSTGRES_DB=simplex_chat_relay
+POSTGRES_PASSWORD=change-me
+
+# Ref to build: tag, branch or commit.
+CHAT_REF=88df79d1e26921c7d1835fc8484fade596356fb6
+
+# SMP server for the relay address, used when the address is created.
+#RELAY_ADDRESS_SERVER=smp://@smp.example.com
+
+# GHC runtime options, without the +RTS/-RTS markers.
+#RELAY_RTS_OPTS="-N -F1.2 -A16m -I0.01 -Iw15"
+
+# Database connection pool and internal queue size.
+#RELAY_POOL_SIZE=4
+#RELAY_QUEUE_SIZE=65536
diff --git a/scripts/relay/Dockerfile b/scripts/relay/Dockerfile
new file mode 100644
index 0000000000..1a731adf41
--- /dev/null
+++ b/scripts/relay/Dockerfile
@@ -0,0 +1,70 @@
+# syntax=docker/dockerfile:1
+
+ARG CHAT_REF=88df79d1e26921c7d1835fc8484fade596356fb6
+ARG GHC=9.6.3
+# 3.10.1.0 predates the Hackage root key rotation and fails `cabal update`.
+ARG CABAL=3.10.2.0
+
+# ---- build ----------------------------------------------------------------
+FROM ubuntu:22.04 AS build
+
+ARG GHC
+ARG CABAL
+ENV DEBIAN_FRONTEND=noninteractive \
+ PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH"
+
+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/*
+
+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}"
+
+# Kept out of the layer above so that a failure here does not reinstall GHC.
+# Retried because a failed fetch falls back to the mirrors in Hackage's
+# mirrors.json, which are dead, and the last one aborts the build.
+RUN cabal update || { sleep 5; cabal update; } || { sleep 20; cabal update; }
+
+# Declared after the toolchain layers so that changing the ref does not rebuild
+# them. Unlike `clone --branch`, this form also accepts a commit hash.
+ARG CHAT_REF
+WORKDIR /project
+RUN git init -q . \
+ && git remote add origin https://github.com/simplex-chat/simplex-chat \
+ && git fetch -q --depth 1 origin "${CHAT_REF}" \
+ && git checkout -q FETCH_HEAD
+
+# The cache mounts keep compiled dependencies across ref changes.
+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
+
+# The binary is dynamically linked, so it needs these libraries at runtime.
+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
+
+# Bind-mounted host directories must be writable by this UID.
+USER relay
+
+# The relay 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..569d403966
--- /dev/null
+++ b/scripts/relay/docker-compose.yaml
@@ -0,0 +1,87 @@
+name: simplex-chat-relay
+
+services:
+ relay:
+ build:
+ context: .
+ args:
+ CHAT_REF: ${CHAT_REF:-88df79d1e26921c7d1835fc8484fade596356fb6}
+ image: chat-relay:latest # set your image/registry name
+ container_name: chat-relay
+ depends_on:
+ db:
+ condition: service_healthy
+ environment:
+ RELAY_NAME: ${RELAY_NAME}
+ RELAY_WEB_DOMAIN: ${RELAY_WEB_DOMAIN}
+ RELAY_ADDRESS_SERVER: ${RELAY_ADDRESS_SERVER:-}
+ # Empty values fall back to the entrypoint defaults.
+ RELAY_RTS_OPTS: ${RELAY_RTS_OPTS:-}
+ RELAY_POOL_SIZE: ${RELAY_POOL_SIZE:-}
+ RELAY_QUEUE_SIZE: ${RELAY_QUEUE_SIZE:-}
+ # Password is passed as PGPASSWORD to keep it out of argv.
+ DB_CONN: postgresql://${POSTGRES_USER}@db:5432/${POSTGRES_DB}
+ PGPASSWORD: ${POSTGRES_PASSWORD}
+ volumes:
+ - /var/www/relay-web-channels:/var/www/relay-web-channels
+ - ./out:/out
+ restart: unless-stopped
+
+ metrics:
+ image: burningalchemist/sql_exporter:latest
+ container_name: chat-relay-metrics
+ depends_on:
+ db:
+ condition: service_healthy
+ environment:
+ # Percent-encode the password if it contains URL-reserved characters.
+ SQLEXPORTER_TARGET_DSN: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable
+ volumes:
+ - ./sql_exporter.yml:/etc/sql_exporter/sql_exporter.yml:ro
+ command: ["-config.file=/etc/sql_exporter/sql_exporter.yml"]
+ ports:
+ - "127.0.0.1:9399:9399"
+ restart: unless-stopped
+
+ db:
+ image: postgres:18
+ container_name: chat-relay-db
+ environment:
+ POSTGRES_USER: ${POSTGRES_USER}
+ POSTGRES_DB: ${POSTGRES_DB}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
+ shm_size: 256mb # parallel workers need more than the 64 MB default
+ volumes:
+ # PG18+ keeps PGDATA in a version-specific subdirectory of this volume.
+ - 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.
+ 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..522e87eb22
--- /dev/null
+++ b/scripts/relay/entrypoint.py
@@ -0,0 +1,105 @@
+#!/usr/bin/env python3
+"""Save the relay address on first start, then exec the relay.
+
+The address is printed only when it is created, and is not stored in the
+database in its displayed form, so it is captured from the relay's output.
+"""
+import os
+import shlex
+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
+DEFAULT_RTS_OPTS = "-N -F1.2 -A16m -I0.01 -Iw15"
+DEFAULT_POOL_SIZE = "4" # the binary defaults to a single connection
+DEFAULT_QUEUE_SIZE = "65536"
+
+
+def require(name):
+ value = os.environ.get(name)
+ if not value:
+ sys.exit(f"{name} is required")
+ return value
+
+
+def rts_args():
+ """RELAY_RTS_OPTS holds bare options; the +RTS/-RTS markers are added here."""
+ opts = [
+ o
+ for o in shlex.split(os.environ.get("RELAY_RTS_OPTS") or DEFAULT_RTS_OPTS)
+ if o not in ("+RTS", "-RTS")
+ ]
+ return ["+RTS", *opts, "-RTS"] if opts else []
+
+
+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):
+ """Create the address if needed and save it. Runs before the relay starts."""
+ 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)
+ 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]
+
+ # Only applied when the address is created, which the one-shot below does.
+ address_server = os.environ.get("RELAY_ADDRESS_SERVER")
+ if address_server:
+ common += ["--relay-address-server", address_server]
+
+ # The image is applied only when the profile is created.
+ if not os.path.exists(ADDR_FILE):
+ oneshot = common + (["--user-image-file", image_file] if image_file else []) + ["-d", conn]
+ capture_address(oneshot)
+
+ 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",
+ "--pool-size", os.environ.get("RELAY_POOL_SIZE") or DEFAULT_POOL_SIZE,
+ "--queue-size", os.environ.get("RELAY_QUEUE_SIZE") or DEFAULT_QUEUE_SIZE,
+ ] + rts_args()
+ os.execvp(relay[0], relay)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/relay/sql_exporter.yml b/scripts/relay/sql_exporter.yml
new file mode 100644
index 0000000000..ee8f62218b
--- /dev/null
+++ b/scripts/relay/sql_exporter.yml
@@ -0,0 +1,71 @@
+global:
+ min_interval: 0s
+ max_connections: 3
+ max_idle_connections: 3
+
+target:
+ name: chat_relay
+ # Replaced by SQLEXPORTER_TARGET_DSN. A non-empty value is required here
+ # because the DSN is validated before environment variables are applied.
+ data_source_name: "postgresql://replaced-by-env"
+ collectors: [chat_relay]
+
+collectors:
+ - collector_name: chat_relay
+ metrics:
+ - metric_name: chat_relay_channels
+ type: gauge
+ help: "Channels by relay status."
+ key_labels: [status]
+ values: [channels]
+ query: |
+ SELECT relay_own_status AS status, count(*) AS channels
+ FROM simplex_v1_chat_schema.groups
+ WHERE relay_own_status IS NOT NULL
+ GROUP BY relay_own_status
+
+ - metric_name: chat_relay_published_channels
+ type: gauge
+ help: "Served channels that have a public address."
+ values: [channels]
+ query: |
+ SELECT count(*) AS channels
+ FROM simplex_v1_chat_schema.groups g
+ JOIN simplex_v1_chat_schema.group_profiles gp
+ ON gp.group_profile_id = g.group_profile_id
+ WHERE gp.public_group_id IS NOT NULL
+ AND g.relay_own_status IN ('active', 'accepted')
+
+ - metric_name: chat_relay_members
+ type: gauge
+ help: "Members across all channels."
+ values: [members]
+ query: |
+ SELECT count(*) AS members FROM simplex_v1_chat_schema.group_members
+
+ - metric_name: chat_relay_pending_deliveries
+ type: gauge
+ help: "Messages queued for delivery."
+ values: [deliveries]
+ query: |
+ SELECT count(*) AS deliveries
+ FROM simplex_v1_chat_schema.msg_deliveries
+ WHERE delivery_status = 'snd_pending'
+
+ - metric_name: chat_relay_oldest_pending_delivery_seconds
+ type: gauge
+ help: "Age of the oldest message queued for delivery."
+ values: [age]
+ query: |
+ SELECT COALESCE(EXTRACT(EPOCH FROM (now() - min(created_at))), 0) AS age
+ FROM simplex_v1_chat_schema.msg_deliveries
+ WHERE delivery_status = 'snd_pending'
+
+ - metric_name: chat_relay_messages_24h
+ type: gauge
+ help: "Chat items created in the last 24 hours."
+ values: [messages]
+ query: |
+ SELECT count(*) AS messages
+ FROM simplex_v1_chat_schema.chat_items
+ WHERE created_at > now() - interval '24 hours'