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 user@vps.example.com
означает: «слушай на моём порту 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