Feat: Surge Compatible MTProto DC Config

This commit is contained in:
SukkaW
2026-08-13 22:34:09 +08:00
parent 3ec3f02d28
commit cf76e874d3
9 changed files with 772 additions and 220 deletions

View File

@@ -0,0 +1,180 @@
import dns from 'node:dns/promises';
import { Buffer } from 'node:buffer';
import picocolors from 'picocolors';
import { fastStringArrayJoin } from 'foxts/fast-string-array-join';
import { stableHash } from 'stable-hash';
import type { Span } from '../trace';
import { $$fetch } from './fetch-retry';
import { getTelegramBackupIPFromBase64 } from './get-telegram-backup-ip';
import type { TelegramBackupEndpoint } from './get-telegram-backup-ip';
import { appendArrayInPlace } from 'foxts/append-array-in-place';
interface FetchTelegramBackupEndpointsOptions {
includeTestServers?: boolean
}
let productionEndpointsPromise: Promise<TelegramBackupEndpoint[]> | undefined;
let testEndpointsPromise: Promise<TelegramBackupEndpoint[]> | undefined;
function deduplicateEndpoints(endpoints: readonly TelegramBackupEndpoint[]) {
const deduplicated = new Map<string, TelegramBackupEndpoint>();
endpoints.forEach(endpoint => deduplicated.set(stableHash([
endpoint.dcId,
endpoint.ip,
endpoint.port,
endpoint.secret ? Buffer.from(endpoint.secret).toString('base64') : null
]), endpoint));
return Array.from(deduplicated.values());
}
async function fetchDnsEndpoints(span: Span, domain: string, traceName: string) {
const endpoints: TelegramBackupEndpoint[] = [];
const resolvers = ['8.8.8.8', '1.0.0.1'].map((ip) => {
const resolver = new dns.Resolver();
resolver.setServers([ip]);
return Object.assign(resolver, { server: ip });
});
await span.traceChildAsync(traceName, () => Promise.all(resolvers.map(async (resolver) => {
try {
const response = await resolver.resolveTxt(domain);
const strings = response.map(result => fastStringArrayJoin(result, ''));
if (strings.length !== 2) {
throw new TypeError(`Unexpected TXT record count: ${strings.length}`);
}
const base64 = strings[0].length > strings[1].length
? strings[0] + strings[1]
: strings[1] + strings[0];
const decoded = getTelegramBackupIPFromBase64(base64);
appendArrayInPlace(endpoints, decoded);
console.log('[telegram backup ip]', picocolors.green('DNS TXT'), { domain, endpoints: decoded, server: resolver.server });
} catch (error) {
console.error('[telegram backup ip]', picocolors.red('DNS TXT error'), { domain }, error);
}
})));
return deduplicateEndpoints(endpoints);
}
async function fetchRealtimeDatabaseEndpoints(span: Span) {
return span.traceChildAsync('backup source 2: Firebase Realtime DB', async () => {
try {
const data = await (await $$fetch('https://reserve-5a846.firebaseio.com/ipconfigv3.json')).json();
if (typeof data !== 'string' || data.length !== 344) {
throw new TypeError('Firebase Realtime DB data format is invalid');
}
const endpoints = getTelegramBackupIPFromBase64(data);
console.log('[telegram backup ip]', picocolors.green('Firebase Realtime DB'), { endpoints });
return endpoints;
} catch (error) {
console.error('[telegram backup ip]', picocolors.red('Firebase Realtime DB error'), error);
return [];
}
});
}
async function fetchValueStoreEndpoints(span: Span) {
return span.traceChildAsync('backup source 3: Firebase Value Store', async () => {
try {
const json = await (await $$fetch('https://firestore.googleapis.com/v1/projects/reserve-5a846/databases/(default)/documents/ipconfig/v3', {
headers: {
Accept: '*/*',
// Google rejects this request when the shared HTTP cache adds an Origin.
Origin: undefined
}
})).json();
if (
!json || typeof json !== 'object'
|| !('fields' in json) || typeof json.fields !== 'object' || !json.fields
|| !('data' in json.fields) || typeof json.fields.data !== 'object' || !json.fields.data
|| !('stringValue' in json.fields.data) || typeof json.fields.data.stringValue !== 'string'
|| json.fields.data.stringValue.length !== 344
) {
throw new TypeError('Firebase Value Store data format is invalid');
}
const endpoints = getTelegramBackupIPFromBase64(json.fields.data.stringValue);
console.log('[telegram backup ip]', picocolors.green('Firebase Value Store'), { endpoints });
return endpoints;
} catch (error) {
console.error('[telegram backup ip]', picocolors.red('Firebase Value Store error'), error);
return [];
}
});
}
async function fetchAppEngineEndpoints(span: Span, url: string, traceName: string) {
return span.traceChildAsync(traceName, async () => {
try {
const data = (await (await $$fetch(url)).text()).trim();
if (data.length !== 344) {
throw new TypeError(`Google App Engine data has an unexpected length: ${data.length}`);
}
const endpoints = getTelegramBackupIPFromBase64(data);
console.log('[telegram backup ip]', picocolors.green('Google App Engine'), { url, endpoints });
return endpoints;
} catch (error) {
console.error('[telegram backup ip]', picocolors.red('Google App Engine error'), { url }, error);
return [];
}
});
}
async function fetchProductionEndpoints(span: Span) {
const endpointGroups = await Promise.all([
fetchDnsEndpoints(span, 'apv3.stel.com', 'backup source 1: DNS TXT'),
fetchRealtimeDatabaseEndpoints(span),
fetchValueStoreEndpoints(span),
fetchAppEngineEndpoints(span, 'https://dns-telegram.appspot.com', 'backup source 4: Google App Engine')
]);
return deduplicateEndpoints(endpointGroups.flat());
}
async function fetchTestEndpoints(span: Span) {
const endpointGroups = await Promise.all([
fetchDnsEndpoints(span, 'tapv3.stel.com', 'test backup source 1: DNS TXT'),
fetchAppEngineEndpoints(span, 'https://dns-telegram.appspot.com/test', 'test backup source 4: Google App Engine')
]);
return deduplicateEndpoints(endpointGroups.flat());
}
function getProductionEndpoints(span: Span) {
if (productionEndpointsPromise) {
return span.traceChildAsync('reuse production backup endpoints', () => productionEndpointsPromise!);
}
productionEndpointsPromise = fetchProductionEndpoints(span);
return productionEndpointsPromise;
}
function getTestEndpoints(span: Span) {
if (testEndpointsPromise) {
return span.traceChildAsync('reuse test backup endpoints', () => testEndpointsPromise!);
}
testEndpointsPromise = fetchTestEndpoints(span);
return testEndpointsPromise;
}
/**
* Fetches Telegram's signed backup endpoints once per worker process. The CIDR
* and MTProto config tasks share the production promise; only the CIDR task
* requests the additional test-server promise.
*/
export async function fetchTelegramBackupEndpoints(
span: Span,
{ includeTestServers = false }: FetchTelegramBackupEndpointsOptions = {}
) {
if (!includeTestServers) return getProductionEndpoints(span);
const [productionEndpoints, testEndpoints] = await Promise.all([
getProductionEndpoints(span),
getTestEndpoints(span)
]);
return deduplicateEndpoints([...productionEndpoints, ...testEndpoints]);
}

View File

@@ -19,6 +19,42 @@ xwIDAQAB
-----END RSA PUBLIC KEY-----
`;
export interface TelegramBackupEndpoint {
dcId: number,
ip: string,
port: number,
secret?: Uint8Array
}
// Telegram's original, pre-AccessPointRule backup schema:
// help.configSimple#d997c3c5 date:int expires:int dc_id:int
// ip_port_list:Vector<ipPort> = help.ConfigSimple;
// It applies one DC ID to a bare vector of (ipv4, port) pairs. GramJS only
// generates TgApi.help.ConfigSimple for the current #5a592a6c rule-based schema,
// so the removed legacy constructor has no TgApi symbol to reference.
const LEGACY_CONFIG_SIMPLE_CONSTRUCTOR_ID = 0xD9_97_C3_C5;
// The legacy vector contains bare ipPort values, so TgBinaryReader#tgReadVector
// cannot decode it as a vector of boxed TL objects. GramJS does not export the
// core vector constructor ID, either, so validate it explicitly before reading.
const TL_VECTOR_CONSTRUCTOR_ID = 0x1C_B5_C4_15;
function ipv4ToString(ipv4: number) {
return bigint2ip(
ipv4 > 0
? BigInt(ipv4)
: (2n ** 32n) + BigInt(ipv4),
4
);
}
function validateBackupEndpoint(endpoint: TelegramBackupEndpoint) {
if (endpoint.dcId < 1 || endpoint.dcId > 5 || endpoint.port < 1 || endpoint.port > 65535) {
throw new TypeError(`Invalid Telegram backup endpoint: DC ${endpoint.dcId}, port ${endpoint.port}`);
}
return endpoint;
}
export function getTelegramBackupIPFromBase64(base64: string) {
// 1. Check base64 size
if (base64.length !== 344) {
@@ -31,7 +67,7 @@ export function getTelegramBackupIPFromBase64(base64: string) {
// 3. Decode base64 to Buffer
const decoded = base64ToUint8Array(base64);
if (decoded.length !== 256) {
throw new TypeError('Decoded buffer length is not 344 bytes, received ' + decoded.length);
throw new TypeError('Decoded buffer length is not 256 bytes, received ' + decoded.length);
}
// 4. RSA decrypt (public key, "decrypt signature" - usually means "verify and extract")
@@ -79,33 +115,58 @@ export function getTelegramBackupIPFromBase64(base64: string) {
const parser = new TgBinaryReader(Buffer.from(decryptedCbc.buffer, decryptedCbc.byteOffset, decryptedCbc.byteLength));
const len = parser.readInt();
if (len < 8 || len > 208) throw new Error(`Invalid TL data length: ${len}`);
if (len < 4 || len > 204 || len % 4 !== 0) throw new Error(`Invalid TL data length: ${len}`);
const constructorId = parser.readInt();
const constructorId = parser.readInt() >>> 0;
if (constructorId !== TgApi.help.ConfigSimple.CONSTRUCTOR_ID) {
throw new Error(`Invalid constructor ID: ${constructorId.toString(16)}`);
const endpoints: TelegramBackupEndpoint[] = [];
let date: number;
let expires: number;
if (constructorId === TgApi.help.ConfigSimple.CONSTRUCTOR_ID) {
const payload = decryptedCbc.subarray(8, 4 + len);
const configSimple = TgApi.help.ConfigSimple.fromReader(new TgBinaryReader(Buffer.from(payload.buffer, payload.byteOffset, payload.byteLength)));
date = configSimple.date;
expires = configSimple.expires;
for (let ruleIndex = 0, ruleCount = configSimple.rules.length; ruleIndex < ruleCount; ruleIndex++) {
const rule = configSimple.rules[ruleIndex];
for (let ipIndex = 0, ipCount = rule.ips.length; ipIndex < ipCount; ipIndex++) {
const ip = rule.ips[ipIndex];
endpoints.push(validateBackupEndpoint({
dcId: rule.dcId,
ip: ipv4ToString(ip.ipv4),
port: ip.port,
...((ip instanceof TgApi.IpPortSecret) && { secret: ip.secret })
}));
}
}
} else if (constructorId === LEGACY_CONFIG_SIMPLE_CONSTRUCTOR_ID) {
date = parser.readInt();
expires = parser.readInt();
const dcId = parser.readInt();
const vectorConstructorId = parser.readInt() >>> 0;
const count = parser.readInt();
if (vectorConstructorId !== TL_VECTOR_CONSTRUCTOR_ID || count < 1 || count > 1024) {
throw new Error('Invalid legacy Telegram backup endpoint vector');
}
for (let i = 0; i < count; i++) {
endpoints.push(validateBackupEndpoint({
dcId,
ip: ipv4ToString(parser.readInt()),
port: parser.readInt()
}));
}
} else {
throw new Error(`Invalid constructor ID: 0x${constructorId.toString(16)}`);
}
const payload = decryptedCbc.subarray(8, len);
const now = Math.floor(Date.now() / 1000);
if (date >= now + 20 * 60 || expires <= now - 20 * 60) {
throw new Error(`Telegram backup configuration is outside its validity interval (${date}...${expires}, now ${now})`);
}
const configSimple = TgApi.help.ConfigSimple.fromReader(new TgBinaryReader(Buffer.from(payload.buffer, payload.byteOffset, payload.byteLength)));
return configSimple.rules.flatMap(rule => rule.ips.map(ip => {
switch (ip.CONSTRUCTOR_ID) {
case TgApi.IpPort.CONSTRUCTOR_ID:
case TgApi.IpPortSecret.CONSTRUCTOR_ID:
return {
ip: bigint2ip(
ip.ipv4 > 0
? BigInt(ip.ipv4)
: (2n ** 32n) + BigInt(ip.ipv4),
4
),
port: ip.port
};
default:
throw new TypeError(`Unknown IP type: 0x${ip.CONSTRUCTOR_ID.toString(16)}`);
}
}));
return endpoints;
}

View File

@@ -0,0 +1,96 @@
import { describe, it } from 'mocha';
import { expect } from 'earl';
import {
DC_OPTION_FLAG_IPV6,
DC_OPTION_FLAG_STATIC,
mergeFallbackEndpoints,
TELEGRAM_BOOTSTRAP_ENDPOINTS
} from './mtproto-dc-config';
import type { MTProtoDCConfig } from './mtproto-dc-config';
import { setBit } from 'foxts/bitwise';
describe('MTProto DC config', () => {
it('merges static fallbacks and removes exact duplicates', () => {
const config: MTProtoDCConfig = {
version: 1,
date: 1,
expires: 2,
this_dc: 5,
options: [
{ id: 5, ip: '91.108.56.191', port: 443, flags: 0 },
{ id: 5, ip: '91.108.56.191', port: 443, flags: DC_OPTION_FLAG_STATIC }
]
};
const result = mergeFallbackEndpoints(config, [
{ dcId: 5, ip: '91.108.56.201', port: 443 },
{ dcId: 5, ip: '91.108.56.191', port: 443 }
]);
expect(result.backupAdded).toEqual(1);
expect(result.duplicatesRemoved).toEqual(1);
expect(config.options.filter(option => option.ip === '91.108.56.191').length).toEqual(1);
expect(config.options.some(option => (
option.id === 5
&& option.ip === '91.108.56.201'
&& option.flags === DC_OPTION_FLAG_STATIC
))).toEqual(true);
expect(config.options.some(option => (
option.id === 5
&& option.ip === '2001:b28:f23f:f005::a'
&& option.flags === (setBit(DC_OPTION_FLAG_STATIC, DC_OPTION_FLAG_IPV6))
))).toEqual(true);
});
it('matches expanded and compressed spellings of the same IPv6 endpoint', () => {
const config: MTProtoDCConfig = {
version: 1,
date: 1,
expires: 2,
this_dc: 1,
options: [
// help.getConfig returns IPv6 fully expanded, the bootstrap list does not.
{ id: 1, ip: '2001:0b28:f23d:f001:0000:0000:0000:000a', port: 443, flags: DC_OPTION_FLAG_IPV6 }
]
};
const result = mergeFallbackEndpoints(config, []);
const ipv6Options = config.options.filter(option => option.ip.includes(':'));
// The DC1 bootstrap merges into the existing option instead of being appended.
expect(ipv6Options.filter(option => option.id === 1).length).toEqual(1);
expect(ipv6Options.some(option => (
option.id === 1
&& option.ip === '2001:b28:f23d:f001::a'
&& option.flags === setBit(DC_OPTION_FLAG_IPV6, DC_OPTION_FLAG_STATIC)
))).toEqual(true);
// DC2-5 IPv6 bootstraps are still absent from the config, so they get added.
expect(result.bootstrapAdded).toEqual(TELEGRAM_BOOTSTRAP_ENDPOINTS.length - 1);
});
it('keeps functional variants separate and preserves backup secrets', () => {
const config: MTProtoDCConfig = {
version: 1,
date: 1,
expires: 2,
this_dc: 2,
options: [
{ id: 2, ip: '149.154.167.50', port: 443, flags: 1 << 3 }
]
};
mergeFallbackEndpoints(config, [{
dcId: 2,
ip: '149.154.167.50',
port: 443,
secret: Uint8Array.from([1, 2, 3])
}]);
expect(config.options.some(option => (
option.ip === '149.154.167.50'
&& option.secret === 'AQID'
&& option.flags === (setBit(1 << 10, DC_OPTION_FLAG_STATIC))
))).toEqual(true);
});
});

View File

@@ -0,0 +1,194 @@
import { Buffer } from 'node:buffer';
import { stableHash } from 'stable-hash';
import type { Api as TgApi } from 'telegram';
import type { TelegramBackupEndpoint } from './get-telegram-backup-ip';
import { setBit, getBit } from 'foxts/bitwise';
import { bigint2ip, ip2bigint } from 'fast-cidr-tools';
import { isProbablyIpv6 } from 'foxts/is-probably-ip';
export const DC_OPTION_FLAG_IPV6 = 1 << 0;
export const DC_OPTION_FLAG_MEDIA_ONLY = 1 << 1;
export const DC_OPTION_FLAG_TCPO_ONLY = 1 << 2;
export const DC_OPTION_FLAG_CDN = 1 << 3;
export const DC_OPTION_FLAG_STATIC = 1 << 4;
export const DC_OPTION_FLAG_THIS_PORT_ONLY = 1 << 5;
export const DC_OPTION_FLAG_SECRET = 1 << 10;
export interface MTProtoDCConfigOption {
id: number,
ip: string,
port: number,
flags: number,
secret?: string
}
export interface MTProtoDCConfig {
version: 1,
date: number,
expires: number,
this_dc: number,
options: MTProtoDCConfigOption[]
}
export interface MTProtoEndpoint {
dcId: number,
ip: string,
port: number,
secret?: Uint8Array
}
export const TELEGRAM_BOOTSTRAP_ENDPOINTS: readonly MTProtoEndpoint[] = [
{ dcId: 1, ip: '149.154.175.50', port: 443 },
{ dcId: 1, ip: '2001:b28:f23d:f001::a', port: 443 },
{ dcId: 2, ip: '149.154.167.50', port: 443 },
{ dcId: 2, ip: '149.154.167.51', port: 443 },
{ dcId: 2, ip: '95.161.76.100', port: 443 },
{ dcId: 2, ip: '2001:67c:4e8:f002::a', port: 443 },
{ dcId: 3, ip: '149.154.175.100', port: 443 },
{ dcId: 3, ip: '2001:b28:f23d:f003::a', port: 443 },
{ dcId: 4, ip: '149.154.167.91', port: 443 },
{ dcId: 4, ip: '2001:67c:4e8:f004::a', port: 443 },
{ dcId: 5, ip: '149.154.171.5', port: 443 },
{ dcId: 5, ip: '2001:b28:f23f:f005::a', port: 443 }
];
type DcOptionWithFlags = TgApi.DcOption & { flags?: number };
/**
* help.getConfig returns IPv6 addresses fully expanded (leading zeros in every
* hextet, no "::"), while TELEGRAM_BOOTSTRAP_ENDPOINTS is written in the
* canonical RFC 5952 form. Without canonicalizing, mergeEndpoint's string
* compare never matches the two spellings of the same address, so every IPv6
* bootstrap gets appended as a duplicate option and the live option never
* receives DC_OPTION_FLAG_STATIC. IPv4 has a single spelling and is passed
* through untouched.
*/
function canonicalizeIp(ip: string) {
if (!isProbablyIpv6(ip)) return ip;
return bigint2ip(ip2bigint(ip, 6), 6, true);
}
function deriveDcOptionFlags(option: TgApi.DcOption) {
let flags = 0;
if (option.ipv6) flags = setBit(flags, DC_OPTION_FLAG_IPV6);
if (option.mediaOnly) flags = setBit(flags, DC_OPTION_FLAG_MEDIA_ONLY);
if (option.tcpoOnly) flags = setBit(flags, DC_OPTION_FLAG_TCPO_ONLY);
if (option.cdn) flags = setBit(flags, DC_OPTION_FLAG_CDN);
if (option.static) flags = setBit(flags, DC_OPTION_FLAG_STATIC);
if (option.thisPortOnly) flags = setBit(flags, DC_OPTION_FLAG_THIS_PORT_ONLY);
if (option.secret) flags = setBit(flags, DC_OPTION_FLAG_SECRET);
return flags;
}
export function normalizeTelegramConfig(config: TgApi.Config): MTProtoDCConfig {
return {
version: 1,
date: config.date,
expires: config.expires,
this_dc: config.thisDc,
options: config.dcOptions.map((rawOption) => {
const option = rawOption as DcOptionWithFlags;
const normalized: MTProtoDCConfigOption = {
id: option.id,
ip: canonicalizeIp(option.ipAddress),
port: option.port,
flags: Number.isSafeInteger(option.flags) ? option.flags! : deriveDcOptionFlags(option)
};
if (option.secret) {
normalized.secret = Buffer.from(option.secret).toString('base64');
}
return normalized;
})
};
}
function fallbackFlags(ip: string, hasSecret: boolean) {
let flags = DC_OPTION_FLAG_STATIC;
if (ip.includes(':')) flags = setBit(flags, DC_OPTION_FLAG_IPV6);
if (hasSecret) flags = setBit(flags, DC_OPTION_FLAG_SECRET);
return flags;
}
function mergeEndpoint(config: MTProtoDCConfig, endpoint: MTProtoEndpoint) {
const secret = endpoint.secret ? Buffer.from(endpoint.secret).toString('base64') : undefined;
const ip = canonicalizeIp(endpoint.ip);
const functionalFlags = setBit(setBit(DC_OPTION_FLAG_MEDIA_ONLY, DC_OPTION_FLAG_TCPO_ONLY), DC_OPTION_FLAG_CDN);
let matched = false;
for (let i = 0, len = config.options.length; i < len; i++) {
const option = config.options[i];
if (
option.id !== endpoint.dcId
|| option.ip !== ip
|| option.port !== endpoint.port
|| getBit(option.flags, functionalFlags)
|| option.secret !== secret
) {
continue;
}
option.flags = setBit(option.flags, fallbackFlags(ip, secret !== undefined));
matched = true;
}
if (!matched) {
config.options.push({
id: endpoint.dcId,
ip,
port: endpoint.port,
flags: fallbackFlags(ip, secret !== undefined),
...(!(secret === undefined) && { secret })
});
}
return !matched;
}
function deduplicateOptions(config: MTProtoDCConfig) {
const previousCount = config.options.length;
const seen = new Set<string>();
config.options = config.options.filter((option) => {
const key = stableHash(option);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
return previousCount - config.options.length;
}
export function mergeFallbackEndpoints(
config: MTProtoDCConfig,
backupEndpoints: readonly TelegramBackupEndpoint[]
) {
// mergeEndpoint compares IP strings, so both sides must already be canonical.
// normalizeTelegramConfig canonicalizes the help.getConfig response, but the
// merge cannot assume its input came from there.
for (let i = 0, len = config.options.length; i < len; i++) {
const option = config.options[i];
option.ip = canonicalizeIp(option.ip);
}
let backupAdded = 0;
for (let i = 0, len = backupEndpoints.length; i < len; i++) {
const endpoint = backupEndpoints[i];
if (mergeEndpoint(config, endpoint)) backupAdded++;
}
let bootstrapAdded = 0;
for (let i = 0, len = TELEGRAM_BOOTSTRAP_ENDPOINTS.length; i < len; i++) {
const endpoint = TELEGRAM_BOOTSTRAP_ENDPOINTS[i];
if (mergeEndpoint(config, endpoint)) bootstrapAdded++;
}
return {
backupAdded,
bootstrapAdded,
duplicatesRemoved: deduplicateOptions(config)
};
}