OpenAI и Telegram Bot API с сервера в России: SSH-туннель вместо VPN

OpenAI и Telegram Bot API с сервера в России: SSH-туннель вместо VPN

У меня есть небольшой сервер. Это старый комп, который стоит в кафе моего друга и крутит ИИ-агента которого я разрабатываю для работы с рекламой. Агент общается с юзером через Telegram-бота, а думает через API OpenAI. Пока я разрабатывал локально с включённым VPN, всё работало. Но на сервере естственно все немного не так.

В статье расскажу, почему в итоге выбрал не VPN, а два SSH-туннеля через VPS. Это около 20 строк конфигурации, и ни одно приложение допиливать не пришлось.

Две разные поломки

Сначала обращаемся к обоим API напрямую с сервера.

OpenAI отвечает быстро и вежливо (ответ сокращён):

$ curl -s https://api.openai.com/v1/models
{"error": {"code": "unsupported_country_region_territory", ...}}

Статус ответа — 403. Соединение установилось, TLS прошёл, сервер OpenAI нас увидел, посмотрел на IP и отказал. Значит, дело не в сети, а в геоограничении на стороне сервиса.

С Telegram всё иначе. Выглядит это примерно так:

$ curl -v https://api.telegram.org/
*   Trying 149.154.167.220:443...
* Connected to api.telegram.org (149.154.167.220) port 443
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
... тишина, потом таймаут

TCP-соединение есть, а TLS-рукопожатие не завершается. Так обычно выглядит DPI у провайдера: он видит в первом пакете TLS имя сайта (SNI) и дальше трафик не пропускает. Сервер Telegram наш запрос вообще не видел.

Различать эти случаи полезно. Если ответ пришёл с понятной ошибкой - блокирует сервис. Если соединение виснет или рвётся на рукопожатии - блокирует сеть. Решение в итоге одно.

Почему не VPN

Первое, что приходит в голову, — поставить на сервер VPN-клиент. Но я решил пойти другим путем, и вот почему:

  • VPN заворачивает весь трафик. Агент ходит ещё в VK Рекламу, Яндекс Директ, и всем им лучше видеть российский IP. Поэтому пришлось бы настраивать маршруты для отдельных адресов, а у крупных API эти адреса иногда меняются.
  • VPN и Docker на одной машине — отдельная головная боль: маршруты, DNS внутри контейнеров, MTU.
  • Если VPN отвалится, отвалится всё, включая SSH, через который я захожу на сервер.

Мне нужно было гораздо меньше: чтобы ровно два адреса, api.openai.com и api.telegram.org, ходили через другую страну. Всё остальное пусть идёт как шло.

Идея: SSH local forwarding

SSH умеет пробрасывать порты. Команда

ssh -L 127.0.0.1:443:api.openai.com:443 [email protected]

означает: «слушай на моём порту 443. Всё, что туда придёт, передай через SSH-соединение на VPS. Пусть VPS сам откроет соединение с api.openai.com:443 и перешлёт байты туда и обратно».

  контейнер n8n                сервер в кафе                 VPS (за границей)            OpenAI
 ┌─────────────┐  TLS   ┌──────────────────────┐   SSH   ┌──────────────────┐  TLS   ┌──────────┐
 │ https://api │ ─────► │ 172.17.0.1:443       │ ══════► │ sshd             │ ─────► │ api.     │
 │ .openai.com │        │ (порт туннеля)       │         │ connect(api.     │        │ openai.  │
 └─────────────┘        └──────────────────────┘         │  openai.com:443) │        │ com      │
                                                         └──────────────────┘        └──────────┘

Получается как VPN, только для одного адреса и порта.

Почему TLS при этом не ломается

Самое неочевидное: приложение думает, что обращается к api.openai.com, а на деле подключается к локальному адресу. Почему не ругается проверка сертификата?

SSH-туннель пересылает байты, а не HTTP-запросы. Внутри туннеля едет обычный TLS-сеанс между приложением и настоящим сервером OpenAI. Приложение отправляет SNI api.openai.com, получает настоящий сертификат OpenAI и проверяет его как обычно. Никакой подмены сертификатов и своего CA не нужно.

Отсюда же важное свойство для безопасности: VPS не видит содержимое запросов. Он знает только, что мы ходим к api.openai.com. Ключ OpenAI, промпты и ответы для него — зашифрованные байты.

Против DPI это тоже работает. Провайдер видит только SSH-соединение с VPS, а TLS к Telegram идёт уже внутри него.

Как заставить контейнеры ходить в туннель

Приложения работают в Docker, и тут есть две тонкости.

Где слушать. На 127.0.0.1 нельзя: у контейнера свой localhost, до loopback хоста он не достанет. На 0.0.0.0 тоже не стоит: тогда туннель станет доступен всей локальной сети кафе. Правильный вариант — адрес моста docker0, обычно 172.17.0.1. До него достают контейнеры, а локальная сеть — нет.

Как направить имя. В Docker Compose есть extra_hosts, по сути строка в /etc/hosts контейнера. Специальное значение host-gateway Docker сам заменит на адрес хоста:

services:
  n8n:
    extra_hosts:
      - "api.openai.com:host-gateway"

  tg-gateway:
    extra_hosts:
      - "api.telegram.org:host-gateway"
    environment:
      TELEGRAM_API_BASE: https://api.telegram.org:8444

Внутри контейнера api.openai.com теперь указывает на 172.17.0.1. Всё остальное, включая VK и Яндекс, резолвится как обычно и идёт напрямую. Я проверил: из контейнера в отдельной сети compose (не в стандартной bridge) этот адрес тоже доступен.

Эту настройку кладем в отдельный файл docker-compose.prod.yml. При локальной разработке он не подключается, и там запросы идут обычным путём.

Почему 443 и 8444

Почему OpenAI на порту 443, а Telegram на 8444?

В моём случае к OpenAI обращаются воркфлоу n8n, и в них URL записан как https://api.openai.com/..., без порта. Менять все воркфлоу ради прода не хотелось, поэтому туннель к OpenAI слушает именно 443.

Для Telegram у меня свой маленький шлюз, и базовый адрес API в нём настраивается (TELEGRAM_API_BASE). Два туннеля не могут слушать один и тот же порт на одном адресе, поэтому Telegram получил 8444.

Порт в URL не мешает TLS: сертификат проверяется по имени хоста, а не по порту. https://api.telegram.org:8444 для клиента — тот же api.telegram.org.

Порт 443 меньше 1024, такие порты может открыть только root. Поэтому туннель запускается системной службой от root, а не от моего пользователя.

Чтобы туннель жил сам: autossh и systemd

Обычный ssh -L упадёт при первом обрыве связи, а сервер у меня на Wi-Fi. Поэтому туннель держит autossh под присмотром systemd.

Скрипт /usr/local/sbin/api-tunnel.sh:

#!/bin/sh
set -eu
. /etc/default/api-tunnel          # VPS_HOST, VPS_PORT, VPS_USER
ip=$(ip -4 addr show docker0 | awk '/inet /{split($2, a, "/"); print a[1]; exit}')
[ -n "$ip" ] || { echo "нет моста docker0"; exit 1; }
export AUTOSSH_GATETIME=0
exec /usr/bin/autossh -M 0 -N \
  -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o ExitOnForwardFailure=yes \
  -o StrictHostKeyChecking=accept-new -o BatchMode=yes \
  -i /root/.ssh/id_api_tunnel -p "$VPS_PORT" \
  -L "$ip:443:api.openai.com:443" -L "$ip:8444:api.telegram.org:443" \
  "$VPS_USER@$VPS_HOST"

Что здесь важно:

Параметр Зачем
-N Не запускать команду на VPS, только пробрасывать порты
-M 0 Отключить собственный мониторинг autossh: живость проверяет сам SSH
ServerAliveInterval=30, ServerAliveCountMax=3 Если VPS не отвечает полторы минуты, соединение считается мёртвым, и autossh переподключается. Без этого зависший туннель может висеть часами
ExitOnForwardFailure=yes Если порт открыть не удалось, SSH завершается, а не работает молча без туннеля
BatchMode=yes Никаких вопросов про пароль: служба не может на них ответить
AUTOSSH_GATETIME=0 Перезапускать, даже если первое подключение сразу упало (например, сеть ещё не поднялась после загрузки)

Адрес docker0 определяется при каждом запуске, а не захардкожен в конфиг. Если подсеть Docker когда-нибудь поменяется, туннель подстроится сам.

Сначала я вписал всё это прямо в ExecStart= юнита. Ничего не заработало: systemd по-своему обрабатывает $ и кавычки. Вынести логику в отдельный скрипт оказалось проще и читабельнее.

Юнит /etc/systemd/system/api-tunnel.service:

[Unit]
Description=SSH-туннель к OpenAI и Telegram через VPS
After=network-online.target docker.service
Wants=network-online.target
Requires=docker.service

[Service]
ExecStart=/usr/local/sbin/api-tunnel.sh
Restart=always
RestartSec=15

[Install]
WantedBy=multi-user.target

Requires=docker.service нужен, потому что без Docker нет моста docker0, а значит, и адреса, на котором слушать.

VPS: ключ, который умеет только одно

На VPS лежит приватный ключ сервера. Если сервер в кафе украдут или взломают, этим ключом можно будет зайти на VPS с Amnezia. Поэтому ключу я разрешил ровно одно действие.

На VPS c Amnezia заводим отдельного пользователя без пароля:

adduser --disabled-password --gecos "" tunnel

В его ~/.ssh/authorized_keys ключ записан с ограничениями (одной строкой):

restrict,port-forwarding,command="/bin/false",permitopen="api.openai.com:443",permitopen="api.telegram.org:443" ssh-ed25519 AAAA... api-tunnel@server

Что значит:

  • restrict — запретить всё: терминал, пересылку агента, X11, все виды проброса портов.
  • port-forwarding — вернуть только проброс портов.
  • permitopen="host:port" — пробрасывать можно только к этим двум адресам. Попытка открыть туннель куда-то ещё будет отклонена.
  • command="/bin/false" — даже если попросить выполнить команду, выполнится /bin/false, и сессия закончится.

В итоге с этим ключом нельзя получить shell на VPS, нельзя использовать VPS как прокси в интернет и нельзя ходить ни к каким адресам, кроме двух API. Вход root по SSH на VPS у меня отключён, и туннелю он не нужен.

Как проверить работоспособность

Для OpenAI удобно использовать код ответа без ключа:

curl -s -o /dev/null -w '%{http_code}\n' \
  --resolve api.openai.com:443:172.17.0.1 https://api.openai.com/v1/models
  • 401 — запрос дошёл до OpenAI и не прошёл проверку ключа. Туннель работает.
  • 403 — запрос дошёл, но из России, то есть мимо туннеля.
  • 000 — до туннеля вообще не достучались: служба не запущена или порт не тот.

--resolve делает то же, что extra_hosts в контейнере: подменяет адрес для имени, оставляя имя в TLS. Так туннель можно проверить прямо с хоста.

Ту же проверку я встроил в скрипт деплоя, только изнутри контейнера n8n:

docker compose exec -T n8n node -e \
  'fetch("https://api.openai.com/v1/models").then(r => console.log(r.status))'

Если после обновления вместо 401 пришло что-то другое, деплой считается неудачным и откатывается. Для Telegram похожая проверка: шлюз бота после старта должен успешно получить данные бота через API.

Что учесть

  • VPS с amnezia — единая точка отказа. Пока он недоступен, бот не получает сообщения, а агент не может думать. Когда VPS вернётся, autossh восстановит туннель сам. Но клиенты API должны уметь повторять запросы после сетевых ошибок, иначе их придётся перезапускать.
  • Только исходящие соединения. Бот получает обновления long polling'ом, то есть сам спрашивает Telegram. С вебхуками схема не сработает: Telegram стучался бы на сервер напрямую.
  • Страна VPS должна быть в списке поддерживаемых OpenAI. Имена api.openai.com и api.telegram.org резолвит VPS, и запрос к API уходит с его IP.
  • Задержка. Добавляется путь до VPS и обратно. Для API, который думает секундами, это незаметно.

Итог

Два проброшенных порта, одна строка extra_hosts на сервис и пользователь на VPS с урезанным ключом. Приложения не знают ни о каком туннеле. Сертификаты настоящие, VPS не видит содержимого запросов, а всё остальное ходит напрямую. На машине нет VPN-клиента, и ломаться в сети почти нечему.

Перед этим у меня было две версии, «поставить VPN» и «переписать приложение на прокси». Оказалось SSH умеет это из коробки уже лет двадцать.

Подписывайтесь на мой канал в Tg про разработку – Sleepless Tech