mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-09 09:35:55 +00:00
Merge branch 'master' into badges
This commit is contained in:
@@ -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" }
|
||||
}
|
||||
|
||||
@@ -211,7 +211,7 @@ afterEvaluate {
|
||||
val fontLtGtRegex = Regex("[^>]*>.*<font[^>]*>.*</font>.*</string>")
|
||||
val unbracketedColorRegex = Regex("color=#[abcdefABCDEF0-9]{3,6}")
|
||||
val correctHtmlRegex = Regex("[^>]*>.*<b>.*</b>.*</string>|[^>]*>.*<i>.*</i>.*</string>|[^>]*>.*<u>.*</u>.*</string>|[^>]*>.*<font[^>]*>.*</font>.*</string>")
|
||||
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("<string name=\"", "").trim().substringBefore("\"")
|
||||
|
||||
@@ -238,7 +238,7 @@ afterEvaluate {
|
||||
if (was.length == substring.length) break
|
||||
}
|
||||
return if (formats.any { it.startsWith("1$") || it.startsWith("2$") || it.startsWith("3$") || it.startsWith("4$") }) {
|
||||
formats.sortedBy { it.trim('s', 'd', 'f', '$').toIntOrNull() ?: throw Exception("Formatting don't have positional arguments: $this \nin $filepath") }
|
||||
formats.sortedBy { it.substringBefore('$').toIntOrNull() ?: throw Exception("Formatting don't have positional arguments: $this \nin $filepath") }
|
||||
} else {
|
||||
formats
|
||||
}
|
||||
|
||||
+43
-2
@@ -14,7 +14,8 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.*
|
||||
@@ -24,10 +25,14 @@ import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.datetime.*
|
||||
|
||||
// Each dot-separated label is ASCII letters/digits with single internal hyphens (mirrors simplexmq SimplexName.hs nameLabelP).
|
||||
private val simplexNameLabelRegex = Regex("[A-Za-z0-9]+(-[A-Za-z0-9]+)*")
|
||||
|
||||
private val simplexNameSaleStart = LocalDateTime(2026, 12, 12, 18, 0).toInstant(TimeZone.UTC)
|
||||
private const val SIMPLEX_DOMAINS_URL = "https://simplex.domains/"
|
||||
|
||||
// Set the user's own (prefix "@") or a channel's (prefix "#") SimpleX name.
|
||||
// The field is prefilled with the full prefixed name; `save` receives the encoded name (or null to
|
||||
// clear) and returns true on success (it shows its own error alert otherwise).
|
||||
@@ -128,6 +133,13 @@ fun SetSimplexDomainView(
|
||||
}
|
||||
}
|
||||
|
||||
fun saleCountdown(msRemaining: Long): String {
|
||||
val total = (msRemaining / 1000).coerceAtLeast(0)
|
||||
val days = total / 86400
|
||||
val dayStr = String.format(generalGetString(if (days == 1L) MR.strings.ttl_day else MR.strings.ttl_days), days)
|
||||
return dayStr + " " + String.format(generalGetString(MR.strings.countdown_hrs_min_sec), total / 3600 % 24, total / 60 % 60, total % 60)
|
||||
}
|
||||
|
||||
ModalView(close = { onClose(close) }, cardScreen = true) {
|
||||
ColumnWithScrollBar {
|
||||
AppBarTitle(title)
|
||||
@@ -163,7 +175,7 @@ fun SetSimplexDomainView(
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_open_in_new),
|
||||
stringResource(MR.strings.register_test_name),
|
||||
{ openBrowserAlert("https://github.com/simplex-chat/simplex-chat/blob/master/docs/guide/register-simplex-name.md", uriHandler) },
|
||||
{ openBrowserAlert("https://simplex.domains/#testing", uriHandler) },
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
iconColor = MaterialTheme.colors.primary
|
||||
)
|
||||
@@ -179,6 +191,35 @@ fun SetSimplexDomainView(
|
||||
}
|
||||
}
|
||||
}
|
||||
SectionDividerSpaced()
|
||||
val msToSaleStart = remember { mutableStateOf(simplexNameSaleStart.toEpochMilliseconds() - System.currentTimeMillis()) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (msToSaleStart.value > 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,6 +403,11 @@
|
||||
<string name="get_simplex_name_beta">Get SimpleX name (BETA)</string>
|
||||
<string name="channel_simplex_name">Channel SimpleX name</string>
|
||||
<string name="register_test_name">How to register a test name</string>
|
||||
<string name="simplex_name_sales">SimpleX name sale starts in</string>
|
||||
<string name="until_register_simplex_domain">until you can register a SimpleX domain</string>
|
||||
<string name="update_app_register_simplex_domain">Update the app to register a SimpleX domain</string>
|
||||
<string name="countdown_hrs_min_sec">%1$02d hrs %2$02d min %3$02d sec</string>
|
||||
<string name="simplex_name_sales_footer">Crowdfunding investors can reserve names before the sale starts:</string>
|
||||
<string name="remove_name">Remove name</string>
|
||||
<string name="save_verb">Save</string>
|
||||
<string name="edit_verb">Edit</string>
|
||||
|
||||
+15
-6
@@ -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") }
|
||||
|
||||
+71
-4
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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://<fingerprint>@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
|
||||
@@ -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"]
|
||||
@@ -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:
|
||||
Executable
+105
@@ -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()
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user