feat(telegram): split connector into home validator + remote bot
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 53s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Failing after 2m6s

Move all Telegram egress off the main host. The single connector held the
bot token, long-polled Telegram and answered the gateway/backend over the
trusted internal network, so the whole component (including login validation)
shared fate with its VPN sidecar. Split it into two binaries that share the
token:

- cmd/validator (home, no VPN): Mini App initData + Login Widget HMAC only,
  never calls the Bot API. The gateway dials it for Telegram auth, so game
  login is now independent of Telegram reachability.
- cmd/bot (remote): Bot API long-poll + sendMessage, the only component
  reaching Telegram. It holds no inbound port — it dials the gateway over a
  new reverse mTLS bot-link (pkg/proto/botlink/v1) and executes the send
  commands the gateway pushes.

The gateway funnels sends to the bot-link: out-of-app push is fire-and-forget
(at-most-once, dropped if no bot is connected); the backend admin broadcasts
reach a gateway-served relay that forwards them and awaits the bot's ack
(SendToUser/SendToGameChannel contract preserved). mTLS (pkg/mtls) is the one
inter-service link that leaves the trusted segment; validator<->gateway and
the relay stay plaintext internal. The bot is Telegram-rate-limited.

One bot now; the gateway bot registry, an owns_updates flag and per-command
ids leave seams for N later. Webhook rejected (one URL per token, adds inbound
+ a static address).

The unified test contour runs the split (the bot keeps its VPN sidecar and
dials the gateway by its internal name; bot-link certs from deploy/gen-certs.sh,
generated in CI). The prod wiring — the bot on a separate host (no VPN), the
gateway bot-link port published, PROD_ certs with scheduled rotation, an SSH
deploy of both hosts together — is the deferred final stage (PRERELEASE.md TX,
Stage 18).

Docs: ARCHITECTURE, PRERELEASE (phase TX), platform/telegram + gateway +
backend + deploy READMEs, FUNCTIONAL(+ru), CLAUDE.md, .env.example.
This commit is contained in:
Ilia Denisov
2026-06-21 00:19:07 +02:00
parent 2a8717c930
commit 6aeb529f13
42 changed files with 3073 additions and 714 deletions
+6 -2
View File
@@ -38,8 +38,12 @@ VITE_GATEWAY_URL=
GRAFANA_ROOT_URL=/_gm/grafana/ # set the full https URL behind a real domain
GRAFANA_ADMIN_PASSWORD=admin
# --- Telegram connector -----------------------------------------------------
AWG_CONF= # required; AmneziaWG sidecar config
# --- Telegram validator + bot -----------------------------------------------
# The token is shared: the validator uses it as the HMAC secret, the bot for the
# Bot API. The bot-link wiring (validator/relay/mTLS addresses) is hard-wired in
# docker-compose.yml; the mTLS material is NOT here — run `deploy/gen-certs.sh`
# (writes deploy/certs/, gitignored) before `docker compose up`.
AWG_CONF= # required; AmneziaWG sidecar config (the bot's Telegram egress)
TELEGRAM_BOT_TOKEN= # required
TELEGRAM_GAME_CHANNEL_ID=
TELEGRAM_MINIAPP_URL= # required
+22 -16
View File
@@ -1,8 +1,8 @@
# deploy
The full Scrabble contour: `backend` + `gateway` + the static `landing` + Postgres +
the Telegram connector (with a VPN sidecar) + the observability stack (OTel
Collector → Prometheus + Tempo → Grafana), fronted by a **caddy** that owns a single
the Telegram `validator` + `bot` (the bot with a VPN sidecar) + the observability stack
(OTel Collector → Prometheus + Tempo → Grafana), fronted by a **caddy** that owns a single
`/_gm` Basic-Auth (the admin console + Grafana). Topology and the decision record are in
[`../docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md) §13; this file is the
operational reference for **every environment variable**.
@@ -16,7 +16,8 @@ operational reference for **every environment variable**.
| `landing` | built (`gateway/Dockerfile`, target `landing`) | Static landing page at `/` (caddy:2-alpine + the shared Vite build, `deploy/landing/Caddyfile`); absorbs stray public paths. |
| `backend` | built (`backend/Dockerfile`) | Domain service; bakes in the DAWG dictionaries; runs migrations at boot. |
| `postgres` | `postgres:17-alpine` | Database (named volume, `pg_isready` healthcheck). |
| `vpn` + `telegram` | sidecar + built (`platform/telegram/Dockerfile`) | Telegram connector; egresses through the AmneziaWG sidecar; internal gRPC at `telegram:9091`. |
| `validator` | built (`platform/telegram/Dockerfile`, target `validator`) | Telegram HMAC validator (no VPN, no Bot API); internal gRPC at `validator:9091`. Game login depends only on this. |
| `vpn` + `bot` | sidecar + built (`platform/telegram/Dockerfile`, target `bot`) | Telegram bot; egresses through the AmneziaWG sidecar; holds no inbound port — dials the gateway bot-link (mTLS) at `gateway:9443`. |
| `otelcol` | `otel/opentelemetry-collector-contrib` | OTLP/gRPC `:4317` → Prometheus scrape (`:9464`) + Tempo. |
| `prometheus` | `prom/prometheus` | Metrics, 15d retention. |
| `tempo` | `grafana/tempo` | Traces, 72h retention. |
@@ -58,12 +59,13 @@ compose binds from this directory.
| Variable | Gitea kind | Purpose |
| --- | --- | --- |
| `POSTGRES_PASSWORD` | secret | Postgres password (also embedded in `BACKEND_POSTGRES_DSN`). |
| `AWG_CONF` | secret | AmneziaWG config for the VPN sidecar (the connector's only egress). **Must not contain a `DNS=` line** — it hijacks the shared netns's resolv.conf and breaks the connector resolving `otelcol` (telemetry export). Without it, Docker's resolver handles both `otelcol` and `api.telegram.org`. |
| `AWG_CONF` | secret | AmneziaWG config for the VPN sidecar (the bot's only Telegram egress in the test contour). **Must not contain a `DNS=` line** — it hijacks the shared netns's resolv.conf and breaks the bot resolving `otelcol` / `gateway`. Without it, Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`. |
| `GM_BASICAUTH_HASH` | secret | bcrypt hash gating `/_gm` (admin console + Grafana). Generate with `docker run --rm caddy:2-alpine caddy hash-password --plaintext '<pw>'`. |
| `TELEGRAM_MINIAPP_URL` | variable | The Mini App URL the connector hands out in deep links / buttons. |
| `TELEGRAM_MINIAPP_URL` | variable | The Mini App URL the bot hands out in deep links / buttons. |
**Plus the bot token**`TELEGRAM_BOT_TOKEN` (secret). It defaults to empty in
compose, but the connector **fails at boot** when it is empty.
**Plus the bot token**`TELEGRAM_BOT_TOKEN` (secret), shared by the validator (HMAC
secret) and the bot (Bot API). It defaults to empty in compose, but both **fail at
boot** when it is empty.
## Optional variables (with defaults)
@@ -72,7 +74,7 @@ compose, but the connector **fails at boot** when it is empty.
| `POSTGRES_DB` | variable | `scrabble` | Database name. |
| `POSTGRES_USER` | variable | `scrabble` | Database user. |
| `DICT_VERSION` | variable | `v1.2.1` | `scrabble-dictionary` release tag baked into the backend image as the **seed for a fresh volume** (build-arg). A live contour changes dictionary through the admin console, not this; on a seeded volume a changed value is ignored (the recorded `.seed_version` marker wins — the seed-drift guard, ARCHITECTURE.md §5). Set per contour as `TEST_`/`PROD_DICT_VERSION`. |
| `LOG_LEVEL` | variable | `info` | Shared log level for backend / gateway / connector (`debug\|info\|warn\|error`). |
| `LOG_LEVEL` | variable | `info` | Shared log level for backend / gateway / validator / bot (`debug\|info\|warn\|error`). |
| `CADDY_SITE_ADDRESS` | variable | `:80` | Caddy site address. Test: `:80` (host caddy terminates TLS). Prod: a domain, so caddy does its own ACME. |
| `GM_BASICAUTH_USER` | variable | `gm` | Username for the `/_gm` Basic-Auth. |
| `GRAFANA_ROOT_URL` | variable | `/_gm/grafana/` | Grafana root URL (sub-path serving). Set the full `https://<domain>/_gm/grafana/` behind a real domain. |
@@ -95,14 +97,18 @@ These are hard-wired in `docker-compose.yml` (no `${...}`), pointing the service
at each other on the `internal` network — listed here so they are not mistaken for
missing config: `BACKEND_POSTGRES_DSN` (→ `postgres`, `search_path=backend`),
`GATEWAY_BACKEND_HTTP_URL`/`_GRPC_ADDR` (→ `backend`),
`GATEWAY_CONNECTOR_ADDR`/`BACKEND_CONNECTOR_ADDR` (→ `telegram:9091`), and all three
services' `*_OTEL_*_EXPORTER=otlp` `OTEL_EXPORTER_OTLP_ENDPOINT=http://otelcol:4317`
(`_INSECURE=true`). The connector shares the VPN sidecar's netns: routing to the
collector's internal IP is fine (connected route), but its `AWG_CONF` must **not**
set a `DNS=` directive — that hijacks resolv.conf and breaks resolving `otelcol`
("produced zero addresses"); without it the netns uses Docker's resolver, which
resolves both `otelcol` and `api.telegram.org`. `GATEWAY_ADMIN_*` is intentionally
**unset** — caddy owns `/_gm` in the contour.
`GATEWAY_VALIDATOR_ADDR` (→ `validator:9091`), `BACKEND_CONNECTOR_ADDR` (→ the gateway
bot-link relay `gateway:9092`), the bot's `TELEGRAM_GATEWAY_ADDR` (→ `gateway:9443`,
mTLS) with the `GATEWAY_BOTLINK_*` / `TELEGRAM_BOTLINK_*` cert paths under `/certs` (the
mTLS material is generated by `deploy/gen-certs.sh`, gitignored, regenerated each
deploy), and all services' `*_OTEL_*_EXPORTER=otlp`
`OTEL_EXPORTER_OTLP_ENDPOINT=http://otelcol:4317`
(`_INSECURE=true`). The bot shares the VPN sidecar's netns: routing to the
collector's / gateway's internal IP is fine (connected route), but its `AWG_CONF` must
**not** set a `DNS=` directive — that hijacks resolv.conf and breaks resolving `otelcol`
/ `gateway` ("produced zero addresses"); without it the netns uses Docker's resolver,
which resolves `otelcol`, `gateway` and `api.telegram.org`. `GATEWAY_ADMIN_*` is
intentionally **unset** — caddy owns `/_gm` in the contour.
## Host-side setup (outside this repo)
+81 -22
View File
@@ -1,6 +1,6 @@
# Full deploy descriptor for the Scrabble test contour: backend + gateway +
# Postgres + the Telegram connector (with its VPN sidecar) + the observability
# stack (OTel Collector -> Prometheus + Tempo -> Grafana). Driven by
# Postgres + the Telegram validator + bot (the bot with its VPN sidecar) + the
# observability stack (OTel Collector -> Prometheus + Tempo -> Grafana). Driven by
# .gitea/workflows/ci.yaml (`docker compose up -d --build`); env values are
# interpolated from Gitea Actions TEST_ secrets/variables exported by the deploy
# job (see deploy/.env.example for the unprefixed names).
@@ -19,8 +19,10 @@
# the test contour; the host caddy terminates TLS and forwards. For prod
# (no host caddy) set CADDY_SITE_ADDRESS to the domain so the caddy
# does its own ACME — the contour is then self-contained.
# - The connector egresses to api.telegram.org through the `vpn` sidecar
# (network_mode: service:vpn); it answers internal gRPC at `telegram:9091`.
# - The validator answers internal gRPC at `validator:9091` (no VPN, HMAC only).
# The bot egresses to api.telegram.org through the `vpn` sidecar (network_mode:
# service:vpn) and dials the gateway bot-link (mTLS) at `gateway:9443`. The
# backend admin relay reaches the gateway at `gateway:9092` (plaintext).
name: scrabble
# Bound every container's json-file logs. R7 measured the backend emitting a
@@ -82,7 +84,9 @@ services:
BACKEND_POSTGRES_MAX_OPEN_CONNS: "40"
BACKEND_HTTP_ADDR: ":8080"
BACKEND_GRPC_ADDR: ":9090"
BACKEND_CONNECTOR_ADDR: telegram:9091
# Admin broadcasts go to the gateway's bot-link relay, which forwards them to
# the remote bot and reports back whether they were delivered.
BACKEND_CONNECTOR_ADDR: gateway:9092
BACKEND_LOG_LEVEL: ${LOG_LEVEL:-info}
BACKEND_SERVICE_NAME: scrabble-backend
BACKEND_OTEL_TRACES_EXPORTER: otlp
@@ -135,7 +139,17 @@ services:
GATEWAY_HTTP_ADDR: ":8081"
GATEWAY_BACKEND_HTTP_URL: http://backend:8080
GATEWAY_BACKEND_GRPC_ADDR: backend:9090
GATEWAY_CONNECTOR_ADDR: telegram:9091
# Telegram auth validates against the home validator (plaintext, internal).
GATEWAY_VALIDATOR_ADDR: validator:9091
# The reverse bot-link: the bot dials :9443 over mTLS; the backend admin relay
# reaches the gateway at :9092 (plaintext, internal). In the test contour both
# listeners stay on the internal network (the bot shares the VPN netns); in prod
# the bot is a separate host and :9443 is published with public certificates.
GATEWAY_BOTLINK_ADDR: ":9443"
GATEWAY_BOTLINK_RELAY_ADDR: ":9092"
GATEWAY_BOTLINK_TLS_CERT: /certs/gateway.crt
GATEWAY_BOTLINK_TLS_KEY: /certs/gateway.key
GATEWAY_BOTLINK_TLS_CA: /certs/ca.crt
GATEWAY_LOG_LEVEL: ${LOG_LEVEL:-info}
GATEWAY_SERVICE_NAME: scrabble-gateway
GATEWAY_OTEL_TRACES_EXPORTER: otlp
@@ -146,6 +160,10 @@ services:
GOMAXPROCS: "3"
# GATEWAY_ADMIN_* intentionally unset: in the deployed contour the front
# caddy owns the /_gm Basic-Auth and routes /_gm to the backend directly.
# The bot-link mTLS material (CA + gateway server leaf). Generated by
# deploy/gen-certs.sh for the test contour; supplied from PROD_ secrets in prod.
volumes:
- ${SCRABBLE_CONFIG_DIR:-.}/certs:/certs:ro
# R7 tuned: the gateway holds one h2c connection per player, so at 500 players it
# bursts into a 2-core cap (~2.49% transport_error on game.state); 3 cores absorbs
# the bursts. Per-connection overhead is the realistic prod cost — size for it.
@@ -182,7 +200,39 @@ services:
memory: 128M
networks: [internal]
# --- Telegram connector (egress via the VPN sidecar) -----------------------
# --- Telegram validator (home; HMAC only, no VPN, no Telegram egress) -------
# The validator holds the bot token solely as the HMAC secret and never reaches
# the Bot API, so it runs on the main network with no VPN. Game login validates
# against it and stays up even when the remote bot or the bot-link is down.
validator:
container_name: scrabble-telegram-validator
image: scrabble-telegram-validator:latest
build:
context: ..
dockerfile: platform/telegram/Dockerfile
target: validator
restart: unless-stopped
logging: *default-logging
environment:
# The token is the HMAC secret only; the validator never calls the Bot API. An
# empty value crash-loops only the validator; the rest of the contour comes up.
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
TELEGRAM_VALIDATOR_GRPC_ADDR: ":9091"
TELEGRAM_LOG_LEVEL: ${LOG_LEVEL:-info}
TELEGRAM_SERVICE_NAME: scrabble-telegram-validator
TELEGRAM_OTEL_TRACES_EXPORTER: otlp
TELEGRAM_OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_ENDPOINT: http://otelcol:4317
OTEL_EXPORTER_OTLP_INSECURE: "true"
GOMAXPROCS: "1"
deploy:
resources:
limits:
cpus: "1.0"
memory: 128M
networks: [internal]
# --- Telegram bot (egress via the VPN sidecar in test; dials the gateway) ---
vpn:
container_name: scrabble-telegram-vpn
image: docker.iliadenisov.ru/developer/amneziawg-sidecar:latest
@@ -195,40 +245,49 @@ services:
internal:
aliases: [telegram]
telegram:
container_name: scrabble-telegram
image: scrabble-telegram:latest
bot:
container_name: scrabble-telegram-bot
image: scrabble-telegram-bot:latest
build:
context: ..
dockerfile: platform/telegram/Dockerfile
target: bot
restart: unless-stopped
logging: *default-logging
depends_on: [vpn]
network_mode: "service:vpn"
environment:
# The bot token lives ONLY in this container (ARCHITECTURE.md §12). The connector
# requires it at boot; an empty value leaves the Telegram side down while the rest
# of the contour still comes up.
# The bot token lives on the bot host (ARCHITECTURE.md §12). The bot requires it
# at boot; an empty value leaves the bot down while the rest of the contour comes up.
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
TELEGRAM_GAME_CHANNEL_ID: ${TELEGRAM_GAME_CHANNEL_ID:-}
TELEGRAM_MINIAPP_URL: ${TELEGRAM_MINIAPP_URL:?set TELEGRAM_MINIAPP_URL}
TELEGRAM_GRPC_ADDR: ":9091"
TELEGRAM_TEST_ENV: ${TELEGRAM_TEST_ENV:-false}
TELEGRAM_API_BASE_URL: ${TELEGRAM_API_BASE_URL:-}
TELEGRAM_OWNS_UPDATES: "true"
# The bot dials the gateway bot-link over mTLS. In test it reaches the gateway by
# its internal service name through the VPN netns (Docker resolver, off-tunnel,
# like otelcol); in prod it is a separate host dialing the gateway's public port.
TELEGRAM_GATEWAY_ADDR: gateway:9443
TELEGRAM_BOTLINK_SERVER_NAME: gateway
TELEGRAM_BOTLINK_TLS_CERT: /certs/bot.crt
TELEGRAM_BOTLINK_TLS_KEY: /certs/bot.key
TELEGRAM_BOTLINK_TLS_CA: /certs/ca.crt
TELEGRAM_LOG_LEVEL: ${LOG_LEVEL:-info}
TELEGRAM_SERVICE_NAME: scrabble-telegram
# The connector shares the VPN sidecar's netns. Routing to the collector's
# internal IP stays off the tunnel (connected route), but the sidecar's DNS
# hijacks name resolution: AWG_CONF must NOT carry a `DNS=` directive, else
# `otelcol` won't resolve ("produced zero addresses"). Without DNS= the netns
# uses Docker's resolver, which resolves both otelcol and api.telegram.org
# (see deploy/README.md).
TELEGRAM_SERVICE_NAME: scrabble-telegram-bot
# The bot shares the VPN sidecar's netns. Routing to internal IPs stays off the
# tunnel (connected route), but the sidecar's DNS hijacks name resolution:
# AWG_CONF must NOT carry a `DNS=` directive, else `otelcol` and `gateway` won't
# resolve. Without DNS= the netns uses Docker's resolver, which resolves the
# internal services and api.telegram.org (see deploy/README.md).
TELEGRAM_OTEL_TRACES_EXPORTER: otlp
TELEGRAM_OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_ENDPOINT: http://otelcol:4317
OTEL_EXPORTER_OTLP_INSECURE: "true"
# The connector is light (the stress run does not drive Telegram); one P suffices.
# The bot is light (the stress run does not drive Telegram); one P suffices.
GOMAXPROCS: "1"
volumes:
- ${SCRABBLE_CONFIG_DIR:-.}/certs:/certs:ro
deploy:
resources:
limits:
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# Generate the bot-link mTLS material for the TEST contour: a private CA, a gateway
# server leaf and a bot client leaf. The gateway requires the leaf + the bot the
# client leaf to bring up the reverse bot-link; the CA signs both so each peer trusts
# only the other.
#
# Production certificates come from PROD_ secrets, NOT this script. Private keys never
# leave the host/secrets; deploy/certs/ is gitignored. The script is idempotent: it
# reuses an existing CA + leaves unless --force is passed (rotation re-mints the
# leaves from the same long-lived CA).
#
# Usage:
# deploy/gen-certs.sh [--force] [dir]
# Env:
# BOTLINK_GATEWAY_NAME gateway certificate SAN/CN (default: gateway, the compose
# service name the bot dials in the test contour)
set -euo pipefail
force=0
dir=""
for arg in "$@"; do
case "$arg" in
--force) force=1 ;;
*) dir="$arg" ;;
esac
done
dir="${dir:-$(cd "$(dirname "$0")" && pwd)/certs}"
gw_name="${BOTLINK_GATEWAY_NAME:-gateway}"
mkdir -p "$dir"
cd "$dir"
if [[ -f gateway.crt && -f bot.crt && "$force" -ne 1 ]]; then
echo "gen-certs: certificates already present in $dir (use --force to rotate the leaves)"
exit 0
fi
# CA: long-lived (10y), reused across leaf rotations.
if [[ ! -f ca.crt || ! -f ca.key ]]; then
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
-keyout ca.key -out ca.crt -days 3650 -subj "/CN=scrabble-botlink-ca"
echo "gen-certs: created CA"
fi
# Gateway server leaf (SAN matches the name the bot dials).
openssl req -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
-keyout gateway.key -out gateway.csr -subj "/CN=${gw_name}"
openssl x509 -req -in gateway.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-out gateway.crt -days 825 \
-extfile <(printf "subjectAltName=DNS:%s,DNS:localhost\nextendedKeyUsage=serverAuth\nkeyUsage=critical,digitalSignature\n" "$gw_name")
# Bot client leaf.
openssl req -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
-keyout bot.key -out bot.csr -subj "/CN=scrabble-bot"
openssl x509 -req -in bot.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-out bot.crt -days 825 \
-extfile <(printf "extendedKeyUsage=clientAuth\nkeyUsage=critical,digitalSignature\n")
rm -f gateway.csr bot.csr ca.srl
chmod 600 ./*.key
echo "gen-certs: wrote ca.crt, gateway.crt/key (CN=${gw_name}), bot.crt/key to $dir"