Хостинг не пускает в Telegram: обхожу блокировку через Cloudflare Worker

0

Лайфхак ♡

Хостинг не пускает в Telegram: обхожу блокировку через Cloudflare Worker
Telegram
через мостик ♡

Захотела я, чтобы анонсы новых записей сами улетали в 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.

Комментарии

Буду рада вашим мыслям, идеям и вопросам!
Давайте обсуждать ♡

Пока никто не написал.
Станьте первым! ♡

Поддержать проект

Угостить кофе по СБП

Перевод через Систему быстрых платежей, сумма любая. Спасибо, что вы здесь ♡

QR-код для перевода по СБП
Или другим способом
Дневник на почте

Подписаться на блог

Когда выйдет новая запись, я пришлю письмо со ссылкой. Без рассылок «просто так» и без спама. Отписаться можно одним нажатием.

Или следить в Telegram