Хостинг не пускает в Telegram: обхожу блокировку через Cloudflare Worker
Лайфхак ♡
через мостик ♡
Захотела я, чтобы анонсы новых записей сами улетали в Telegram-канал. Написала код, запустила на сервере и получила таймаут. Не 403, не 404, а просто тишину, как в чате, где все делают вид, что тебя не видят. Оказалось, что хостинг не открывает ни "api.telegram.org", ни даже "t.me". Зато GitHub, Яндекс и Cloudflare открываются без проблем. Очень избирательная сеть, уважаю.
Сначала убедимся, что это не я накосячила
Первым делом я проверила самое простое: сходила с сервера curl'ом на несколько адресов. Логика такая: если виноват мой код, то и Яндекс не откроется.
curl -4 -s -o /dev/null -w "%{http_code}\n" --max-time 8 https://api.telegram.org/ # 000
curl -4 -s -o /dev/null -w "%{http_code}\n" --max-time 8 https://t.me # 000
curl -4 -s -o /dev/null -w "%{http_code}\n" --max-time 8 https://yandex.ru # 302
curl -4 -s -o /dev/null -w "%{http_code}\n" --max-time 8 https://api.github.com # 200
"000" значит, что соединение даже не началось. С моего компьютера тот же Telegram отвечает нормально, так что дело не в токене и не в коде, а в сети сервера. Вердикт: это не баг, это фича хостинга. Прокси у соседнего проекта на этом сервере тоже давно лежал и не отвечал, так что вариант «одолжу чужой» отпал.
Идея: мостик на Cloudflare Worker
Cloudflare с сервера открывается, а Worker умеет принять запрос и переслать его дальше. Получается мостик: сервер стучится к Cloudflare, а тот уже спокойно разговаривает с Telegram. Бесплатного тарифа за глаза.
Минимальный Worker выглядит вот так:
const SECRET = 'придумай-длинную-случайную-строку';
export default {
async fetch(request) {
const url = new URL(request.url);
if (!url.pathname.startsWith('/' + SECRET + '/')) return new Response('not found', { status: 404 });
url.pathname = url.pathname.slice(SECRET.length + 1);
url.hostname = 'api.telegram.org';
url.protocol = 'https:';
url.port = '';
return fetch(new Request(url, request));
},
};
Секретный кусок в пути нужен, чтобы твоим мостиком не пользовался каждый желающий. Иначе получится бесплатный прокси для всего интернета, а оно тебе надо? Со стороны Laravel меняется одна строка: базовый адрес API берётся из ".env".
private function endpoint(string $method): string
{
return rtrim((string) config('services.telegram.api_url'), '/').'/bot'.config('services.telegram.token').'/'.$method;
}
Пишешь в ".env" строку "TELEGRAM_API_URL=https://имя.твой-аккаунт.workers.dev/секрет", и текстовые посты полетели. Красота, можно расходиться. Но нет.
Плот-твист: с картинками всё сложнее
Текст ушёл, я обрадовалась и зря. С картинками началась сага. Сначала я по привычке отдавала Telegram ссылку на файл, и он отвечал "failed to get HTTP URL content": мой сайт его серверы скачать не смогли. Ладно, подумала я, загружу файл прямо с сервера. И запрос завис. Просто повис и ждёт чего-то, как я ответа от человека, которому написала «привет» пять дней назад.
Стала искать границу. Файлы до 16 КБ проходили за доли секунды, а начиная примерно с 22 КБ запрос просто висел. То же самое было и при отправке на посторонний сайт httpbin. При этом скачивание на сервер летало: 2 МБ с Cloudflare за полсекунды. Размер пакета проверила отдельно ("ping -M do -s 1472"), всё нормально. Вывод: хостинг режет большие исходящие отправки на зарубежные адреса. Как я это выясняла по шагам, рассказала в статье про два проекта на одном VPS.
Решение: режем файл на кусочки, как торт
Ограничение действует на каждое соединение, а не на общий объём. Значит, если файл не лезет целиком, скармливаем его порциями. Сервер режет JPEG на куски по 12 КБ и шлёт каждый в Worker, а Worker складывает их в KV (это бесплатное хранилище Cloudflare). Последним запросом сервер говорит: «Собери всё в кучу и отправь в Telegram».
Со стороны PHP это выглядит так:
$chunks = str_split($photo, $chunkSize);
foreach ($chunks as $index => $chunk) {
$this->client()->timeout(20)
->withBody($chunk, 'application/octet-stream')
->post($base.'/chunk?id='.$id.'&i='.$index);
}
$response = $this->client()->timeout(45)->post($base.'/photo', [
'token' => config('services.telegram.token'),
'chat_id' => config('services.telegram.chat_id'),
'caption' => $caption,
'parse_mode' => 'HTML',
'id' => $id,
'count' => count($chunks),
]);
А Worker принимает куски и собирает файл обратно:
if (path === '/chunk' && request.method === 'POST') {
await env.CHUNKS.put(id + ':' + i, await request.arrayBuffer(), { expirationTtl: 900 });
return json({ ok: true });
}
if (path === '/photo' && request.method === 'POST') {
// достаём куски по порядку и склеиваем в один файл
const form = new FormData();
form.append('photo', new Blob(parts, { type: 'image/jpeg' }), 'cover.jpg');
const resp = await fetch('https://api.telegram.org/bot' + p.token + '/sendPhoto', { method: 'POST', body: form });
return new Response(await resp.text(), { status: resp.status });
}
Как подключить KV и не сойти с ума
В дашборде Cloudflare: Storage и databases, затем Workers KV, создать пространство имён. Потом идёшь в Worker, Settings, Bindings, добавляешь KV namespace и называешь переменную "CHUNKS". В коде Worker появится "env.CHUNKS". Учти, что KV согласуется не мгновенно (eventual consistency, привет), поэтому при чтении кусков я делаю до 8 повторов с паузой в полсекунды. Без этого иногда файл собирается с дыркой.
Мелочи, которые спасли нервы
Картинку перед отправкой я перегоняю в JPEG не шире 1280 пикселей через GD: webp Telegram как фото переваривает так себе. Если фото не ушло, пост не пропадает, а отправляется текстом с карточкой ссылки. Так что худший сценарий «пост без картинки», а не «пост в никуда». Номер сообщения я сохраняю в базе, поэтому подпись потом можно поправить через "editMessageCaption" или вообще удалить пост.
И маленький совет про сам дашборд Cloudflare. Код Worker в его редакторе набирать по символам нельзя: редактор теряет фокус и обрывает ввод на полуслове. Копируй в буфер обмена и вставляй целиком. Ну и раз уж мы заговорили про запросы к чужим адресам, про безопасность такого дела я писала отдельно: SSRF в PHP.
Комментарии
Буду рада вашим мыслям, идеям и вопросам!
Давайте обсуждать ♡
Делитесь
своими мыслями
— мне очень
это важно ♡