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:
@@ -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');
|
||||
Reference in New Issue
Block a user