feat(gateway): include production Telegram user gateway

Version the NestJS TDLib gateway with Docker deployment, migrations, automated tests and platform integration documentation while excluding all account sessions, credentials, runtime databases and backups.
This commit is contained in:
wmq
2026-08-01 18:38:20 +08:00
parent af49d22ca7
commit 06112602ec
142 changed files with 23107 additions and 7 deletions
+43
View File
@@ -0,0 +1,43 @@
FROM node:20-bookworm-slim AS builder
RUN apt-get update \
&& apt-get install -y --no-install-recommends g++ make python3 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json package-lock.json* ./
RUN --mount=type=cache,target=/root/.npm \
npm ci \
--prefer-offline \
--fetch-retries=8 \
--fetch-retry-mintimeout=2000 \
--fetch-retry-maxtimeout=30000 \
--fetch-timeout=600000
COPY tsconfig.json nest-cli.json ./
COPY src/ ./src/
RUN npm run build
RUN npm prune --omit=dev
FROM node:20-bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends tini ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --chown=node:node --from=builder /app/dist ./dist
COPY --chown=node:node --from=builder /app/node_modules ./node_modules
COPY --chown=node:node package.json ./
RUN mkdir -p /app/uploads /app/tdlib_data \
&& chown node:node /app/uploads /app/tdlib_data
USER node
EXPOSE 3000
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["node", "dist/main.js"]
+107
View File
@@ -0,0 +1,107 @@
services:
postgres:
image: postgres:16-alpine
container_name: tgw-postgres
environment:
POSTGRES_USER: ${DB_USERNAME:-postgres}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_DATABASE:-telegram_gateway}
volumes:
- pgdata:/var/lib/postgresql/data
- ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ${DB_USERNAME:-postgres} -d ${DB_DATABASE:-telegram_gateway}']
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: tgw-redis
volumes:
- redisdata:/data
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
timeout: 5s
retries: 5
app:
build:
context: ..
dockerfile: docker/Dockerfile
container_name: tgw-app
ports:
- '3000:3000'
env_file:
- ../.env
environment:
NODE_ENV: production
DB_TYPE: postgres
DB_SYNCHRONIZE: 'false'
DB_MIGRATIONS_RUN: 'true'
DB_HOST: postgres
DB_PORT: 5432
DB_USERNAME: ${DB_USERNAME:-postgres}
DB_PASSWORD: ${DB_PASSWORD}
DB_DATABASE: ${DB_DATABASE:-telegram_gateway}
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_ENABLED: 'true'
JWT_SECRET: ${JWT_SECRET}
TDLIB_PATH: /app/node_modules/@prebuilt-tdlib/linux-x64-glibc/libtdjson.so
TG_API_ID: ${TG_API_ID}
TG_API_HASH: ${TG_API_HASH}
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
volumes:
- uploads:/app/uploads
- tdlibdata:/app/tdlib_data
restart: unless-stopped
healthcheck:
test:
[
'CMD',
'node',
'-e',
"fetch('http://127.0.0.1:3000/api/admin/ready').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))",
]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
webhook-receiver:
profiles: ['test']
image: node:20-bookworm-slim
container_name: tgw-webhook-receiver
command: ['node', '/receiver/webhook-receiver.js']
environment:
WEBHOOK_TEST_SECRET: ${WEBHOOK_TEST_SECRET:-gateway-test-secret}
WEBHOOK_FAIL_COUNT: ${WEBHOOK_FAIL_COUNT:-3}
volumes:
- ./scripts/webhook-receiver.js:/receiver/webhook-receiver.js:ro
ports:
- '18080:8080'
restart: unless-stopped
socks5-proxy:
profiles: ['test']
image: node:20-bookworm-slim
container_name: tgw-socks5-proxy
command: ['node', '/proxy/socks5-proxy.js']
volumes:
- ./scripts/socks5-proxy.js:/proxy/socks5-proxy.js:ro
ports:
- '11080:1080'
- '11081:1081'
restart: unless-stopped
volumes:
pgdata:
redisdata:
uploads:
tdlibdata:
@@ -0,0 +1,50 @@
param(
[Parameter(Mandatory = $true)]
[string]$Destination
)
$ErrorActionPreference = 'Stop'
$resolved = [System.IO.Path]::GetFullPath($Destination)
New-Item -ItemType Directory -Force -Path $resolved | Out-Null
$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$backupDir = Join-Path $resolved "telegram-gateway-$timestamp"
New-Item -ItemType Directory -Force -Path $backupDir | Out-Null
docker exec tgw-postgres pg_dump -U postgres -d telegram_gateway -Fc -f /tmp/gateway.dump
docker cp tgw-postgres:/tmp/gateway.dump (Join-Path $backupDir 'postgres.dump')
docker exec tgw-redis redis-cli SAVE | Out-Null
docker cp tgw-redis:/data/dump.rdb (Join-Path $backupDir 'redis.rdb')
docker run --rm --volumes-from tgw-app -v "${backupDir}:/backup" `
node:20-bookworm-slim `
tar -czf /backup/tdlib-data.tar.gz -C /app tdlib_data uploads
$publicTables = [int](docker exec tgw-postgres psql -U postgres -d telegram_gateway -Atc `
"select count(*) from information_schema.tables where table_schema='public';")
$tenantRows = [int](docker exec tgw-postgres psql -U postgres -d telegram_gateway -Atc `
'select count(*) from tenants;')
$accountRows = [int](docker exec tgw-postgres psql -U postgres -d telegram_gateway -Atc `
'select count(*) from tg_accounts;')
$redisDbSize = [int](docker exec tgw-redis redis-cli DBSIZE)
$tdlibFileCount = [int](docker exec tgw-app sh -c `
"find /app/tdlib_data /app/uploads -type f 2>/dev/null | wc -l")
$manifest = @{
createdAt = (Get-Date).ToUniversalTime().ToString('o')
postgres = 'postgres.dump'
redis = 'redis.rdb'
tdlib = 'tdlib-data.tar.gz'
sha256 = @{}
expected = @{
publicTables = $publicTables
tenantRows = $tenantRows
accountRows = $accountRows
redisDbSize = $redisDbSize
tdlibFileCount = $tdlibFileCount
}
}
foreach ($file in @('postgres.dump', 'redis.rdb', 'tdlib-data.tar.gz')) {
$manifest.sha256[$file] = (Get-FileHash -Algorithm SHA256 (Join-Path $backupDir $file)).Hash
}
$manifest | ConvertTo-Json -Depth 4 | Set-Content -Encoding utf8 (Join-Path $backupDir 'manifest.json')
Write-Output $backupDir
@@ -0,0 +1,35 @@
#!/bin/sh
# Install TDLib shared library for the gateway
# This script downloads and installs pre-built tdlib
set -e
TDLIB_VERSION="1.8.0"
TDLIB_URL="https://github.com/tdlib/td/archive/refs/tags/v${TDLIB_VERSION}.tar.gz"
echo "Installing TDLib v${TDLIB_VERSION}..."
# Install build dependencies
apk add --no-cache \
g++ \
make \
cmake \
openssl-dev \
zlib-dev \
linux-headers \
git
# Build TDLib
cd /tmp
wget -q "${TDLIB_URL}" -O tdlib.tar.gz
tar xzf tdlib.tar.gz
cd "td-${TDLIB_VERSION}"
mkdir build && cd build
cmake -DCMAKE_BUILD_TYPE=Release ..
cmake --build . --target tdjson -j$(nproc)
# Install
cp libtdjson.so /usr/local/lib/
cp libtdjson.so.${TDLIB_VERSION} /usr/local/lib/
echo "TDLib installed successfully to /usr/local/lib/libtdjson.so"
@@ -0,0 +1,112 @@
param(
[Parameter(Mandatory = $true)]
[string]$BackupDirectory
)
$ErrorActionPreference = 'Stop'
$source = [System.IO.Path]::GetFullPath($BackupDirectory)
$manifest = Get-Content (Join-Path $source 'manifest.json') | ConvertFrom-Json
foreach ($file in @('postgres.dump', 'redis.rdb', 'tdlib-data.tar.gz')) {
$actual = (Get-FileHash -Algorithm SHA256 (Join-Path $source $file)).Hash
if ($actual -ne $manifest.sha256.$file) {
throw "Checksum mismatch: $file"
}
}
$postgresContainer = 'tgw-restore-postgres'
$redisContainer = 'tgw-restore-redis'
$filesVolume = 'tgw_restore_files'
function Remove-TestResources {
$containerNames = @(docker ps -a --format '{{.Names}}')
if ($containerNames -contains $postgresContainer) {
docker rm -f $postgresContainer | Out-Null
}
if ($containerNames -contains $redisContainer) {
docker rm -f $redisContainer | Out-Null
}
$volumeNames = @(docker volume ls --format '{{.Name}}')
if ($volumeNames -contains $filesVolume) {
docker volume rm $filesVolume | Out-Null
}
}
try {
Remove-TestResources
docker run -d --name $postgresContainer -e POSTGRES_PASSWORD=restore-test `
-e POSTGRES_DB=telegram_gateway_restore postgres:16-alpine | Out-Null
$postgresReady = $false
for ($attempt = 0; $attempt -lt 30; $attempt++) {
docker exec $postgresContainer pg_isready -U postgres -d telegram_gateway_restore 2>$null | Out-Null
if ($LASTEXITCODE -eq 0) {
$postgresReady = $true
break
}
Start-Sleep -Seconds 1
}
if (-not $postgresReady) {
throw 'Isolated PostgreSQL restore container did not become ready'
}
docker cp (Join-Path $source 'postgres.dump') "${postgresContainer}:/tmp/gateway.dump"
docker exec $postgresContainer pg_restore -U postgres -d telegram_gateway_restore /tmp/gateway.dump
$tables = [int](docker exec $postgresContainer psql -U postgres -d telegram_gateway_restore -Atc `
"select count(*) from information_schema.tables where table_schema='public';")
$tenants = [int](docker exec $postgresContainer psql -U postgres -d telegram_gateway_restore -Atc `
'select count(*) from tenants;')
$accounts = [int](docker exec $postgresContainer psql -U postgres -d telegram_gateway_restore -Atc `
'select count(*) from tg_accounts;')
if ($tables -ne [int]$manifest.expected.publicTables -or
$tenants -ne [int]$manifest.expected.tenantRows -or
$accounts -ne [int]$manifest.expected.accountRows) {
throw "PostgreSQL restore count mismatch"
}
docker create --name $redisContainer redis:7-alpine | Out-Null
docker cp (Join-Path $source 'redis.rdb') "${redisContainer}:/data/dump.rdb"
docker run --rm -v "${source}:/backup:ro" redis:7-alpine `
redis-check-rdb /backup/redis.rdb | Out-Null
if ($LASTEXITCODE -ne 0) {
throw 'Redis RDB integrity check failed'
}
docker start $redisContainer | Out-Null
$redisReady = $false
for ($attempt = 0; $attempt -lt 20; $attempt++) {
$ping = docker exec $redisContainer redis-cli PING 2>$null
if ($ping -eq 'PONG') {
$redisReady = $true
break
}
Start-Sleep -Milliseconds 500
}
if (-not $redisReady) {
throw 'Isolated Redis restore container did not become ready'
}
$redisSize = [int](docker exec $redisContainer redis-cli DBSIZE)
# BullMQ keys have TTLs and may legitimately expire between SAVE and this
# isolated restore. The RDB checksum/load must succeed and the restored key
# count must never exceed the snapshot-time upper bound.
if ($redisSize -gt [int]$manifest.expected.redisDbSize) {
throw "Redis restore produced more keys than the snapshot"
}
docker volume create $filesVolume | Out-Null
docker run --rm -v "${filesVolume}:/restore" -v "${source}:/backup:ro" `
node:20-bookworm-slim `
tar -xzf /backup/tdlib-data.tar.gz -C /restore
$fileCount = [int](docker run --rm -v "${filesVolume}:/restore:ro" `
node:20-bookworm-slim sh -c `
"find /restore/tdlib_data /restore/uploads -type f 2>/dev/null | wc -l")
if ($fileCount -ne [int]$manifest.expected.tdlibFileCount) {
throw "TDLib/uploads restore file count mismatch"
}
Write-Output (
"restore_verified tables=$tables tenants=$tenants accounts=$accounts " +
"redis_keys=$redisSize redis_snapshot_max=$($manifest.expected.redisDbSize) " +
"files=$fileCount checksums=ok"
)
} finally {
Remove-TestResources
}
@@ -0,0 +1,38 @@
param(
[Parameter(Mandatory = $true)]
[string]$EnvironmentFile
)
$ErrorActionPreference = 'Stop'
$envPath = [System.IO.Path]::GetFullPath($EnvironmentFile)
if (-not (Test-Path -LiteralPath $envPath -PathType Leaf)) {
throw "Environment file not found"
}
$bytes = New-Object byte[] 48
$generator = [System.Security.Cryptography.RandomNumberGenerator]::Create()
try {
$generator.GetBytes($bytes)
} finally {
$generator.Dispose()
}
$newSecret = [Convert]::ToBase64String($bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_')
$found = $false
$updated = foreach ($line in [System.IO.File]::ReadAllLines($envPath)) {
if ($line -match '^JWT_SECRET=') {
$found = $true
"JWT_SECRET=$newSecret"
} else {
$line
}
}
if (-not $found) {
$updated += "JWT_SECRET=$newSecret"
}
$temporaryPath = "$envPath.rotate.tmp"
$utf8WithoutBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllLines($temporaryPath, $updated, $utf8WithoutBom)
Move-Item -LiteralPath $temporaryPath -Destination $envPath -Force
Write-Output 'JWT_SECRET rotated'
@@ -0,0 +1,123 @@
const net = require('net');
const http = require('http');
const listenPort = Number(process.env.SOCKS5_PORT || 1080);
const metricsPort = Number(process.env.SOCKS5_METRICS_PORT || 1081);
const stats = { accepted: 0, connected: 0, failed: 0, active: 0 };
function reply(socket, code, addressType = 1) {
const address = addressType === 4 ? Buffer.alloc(16) : Buffer.alloc(4);
socket.write(Buffer.concat([
Buffer.from([5, code, 0, addressType]),
address,
Buffer.from([0, 0]),
]));
}
function consumeRequest(socket, state) {
if (state.phase === 'greeting') {
if (state.buffer.length < 2) return;
const methodsLength = state.buffer[1];
if (state.buffer.length < 2 + methodsLength) return;
const methods = state.buffer.subarray(2, 2 + methodsLength);
state.buffer = state.buffer.subarray(2 + methodsLength);
if (state.buffer[0] !== undefined && state.buffer[0] !== 5) {
socket.destroy();
return;
}
if (!methods.includes(0)) {
socket.end(Buffer.from([5, 255]));
return;
}
socket.write(Buffer.from([5, 0]));
state.phase = 'request';
}
if (state.phase !== 'request' || state.buffer.length < 4) return;
const [version, command, , addressType] = state.buffer;
if (version !== 5 || command !== 1) {
reply(socket, command === 1 ? 1 : 7);
socket.end();
return;
}
let offset = 4;
let host;
if (addressType === 1) {
if (state.buffer.length < offset + 4 + 2) return;
host = Array.from(state.buffer.subarray(offset, offset + 4)).join('.');
offset += 4;
} else if (addressType === 3) {
if (state.buffer.length < offset + 1) return;
const length = state.buffer[offset++];
if (state.buffer.length < offset + length + 2) return;
host = state.buffer.subarray(offset, offset + length).toString('utf8');
offset += length;
} else if (addressType === 4) {
if (state.buffer.length < offset + 16 + 2) return;
const words = [];
for (let index = 0; index < 16; index += 2) {
words.push(state.buffer.readUInt16BE(offset + index).toString(16));
}
host = words.join(':');
offset += 16;
} else {
reply(socket, 8);
socket.end();
return;
}
const port = state.buffer.readUInt16BE(offset);
state.buffer = state.buffer.subarray(offset + 2);
state.phase = 'connecting';
const upstream = net.createConnection({ host, port });
upstream.setTimeout(30000);
upstream.once('connect', () => {
stats.connected += 1;
stats.active += 1;
console.log(JSON.stringify({ event: 'connect', host, port, addressType }));
reply(socket, 0, addressType === 4 ? 4 : 1);
if (state.buffer.length) upstream.write(state.buffer);
socket.pipe(upstream);
upstream.pipe(socket);
});
upstream.once('error', (error) => {
stats.failed += 1;
console.error(JSON.stringify({ event: 'connect_error', host, port, code: error.code }));
if (!socket.destroyed) {
reply(socket, error.code === 'ECONNREFUSED' ? 5 : 4);
socket.end();
}
});
upstream.once('close', () => {
if (stats.active > 0) stats.active -= 1;
});
socket.once('close', () => upstream.destroy());
}
const server = net.createServer((socket) => {
stats.accepted += 1;
const state = { phase: 'greeting', buffer: Buffer.alloc(0) };
socket.setTimeout(30000);
socket.on('data', (chunk) => {
if (state.phase === 'connecting') return;
state.buffer = Buffer.concat([state.buffer, chunk]);
consumeRequest(socket, state);
});
socket.on('timeout', () => socket.destroy());
socket.on('error', () => {});
});
server.listen(listenPort, '0.0.0.0', () => {
console.log(`SOCKS5 test proxy listening on ${listenPort}`);
});
http.createServer((request, response) => {
if (request.url !== '/stats') {
response.writeHead(404).end();
return;
}
response.writeHead(200, { 'content-type': 'application/json' });
response.end(JSON.stringify(stats));
}).listen(metricsPort, '0.0.0.0');
@@ -0,0 +1,41 @@
const http = require('http');
const crypto = require('crypto');
const secret = process.env.WEBHOOK_TEST_SECRET || '';
const received = [];
let failuresRemaining = Number(process.env.WEBHOOK_FAIL_COUNT || 0);
http.createServer((req, res) => {
if (req.method === 'GET' && req.url === '/received') {
res.writeHead(200, { 'content-type': 'application/json' });
return res.end(JSON.stringify(received));
}
if (req.method === 'POST' && (req.url === '/events' || req.url === '/fail')) {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
const body = Buffer.concat(chunks).toString();
const expected = `sha256=${crypto.createHmac('sha256', secret).update(body).digest('hex')}`;
const signatureValid = !secret || req.headers['x-webhook-signature'] === expected;
received.push({
path: req.url,
signatureValid,
body: JSON.parse(body),
receivedAt: new Date().toISOString(),
});
if (!signatureValid) {
res.writeHead(401);
return res.end('invalid signature');
}
if (req.url === '/fail' && failuresRemaining-- > 0) {
res.writeHead(503);
return res.end('intentional failure');
}
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ accepted: true }));
});
return;
}
res.writeHead(404);
res.end('not found');
}).listen(8080, '0.0.0.0');