# Как открыть доступ к локальной LLM из интернета без проброса портов: туннели и reverse proxy

> AI Release · @ai_release1 · https://ai-release.org/guides/kak-otkryt-dostup-k-lokalnoy-llm-iz-interneta-bez-probr.html

## TL;DR
- Время выполнения: 40–60 минут
- Уровень сложности: Intermediate
- Понадобится: локальный сервер модели с OpenAI-совместимым HTTP API (Ollama на `127.0.0.1:11434`, llama.cpp server на `127.0.0.1:8080` или vLLM на `127.0.0.1:8000` — точные порты и флаги сверьте с документацией вашей сборки); `cloudflared` (Cloudflare Tunnel) или `ngrok`; Caddy или nginx как reverse proxy; `curl`; Linux/macOS с systemd (или Docker); домен, делегированный в Cloudflare (для именованного туннеля). Версии пакетов сверьте с документацией.

## Требования и подготовка
![Иллюстрация к разделу «Требования и подготовка»](https://ai-release.org/guides/img/kak-otkryt-dostup-k-lokalnoy-llm-iz-interneta-bez-probr-ru-1.jpg)


- **ОС:** Linux (проверено на Ubuntu-подобных) или macOS. Windows — только через WSL2.
- **Права:** `sudo` для установки пакетов и регистрации systemd-юнита.
- **Модель уже работает локально.** Сначала поднимите инференс и убедитесь, что он отвечает по HTTP. Если не знаете, сколько RAM/VRAM нужно под ваш размер и квантизацию, посмотрите [таблицу по размеру и квантизации](https://ai-release.org/guides/localnye-ii-modeli-skolko-ram-i-vram.html?utm_source=tg&utm_medium=channel&utm_campaign=guide_inline&utm_content=guide_to_guide) — это дешевле, чем ловить OOM после проброса.
- **Свободный домен или поддомен** вида `llm.<ваш-домен>` — для именованного туннеля.
- **Порт 7844/TCP наружу** — это исходящее соединение `cloudflared` к edge Cloudflare. Входящие порты на роутере открывать не нужно.

**Пример:** минимальная проверка, что модель жива локально.

```bash
curl -s http://127.0.0.1:11434/api/tags
```

Ожидаемый вывод — JSON вида `{"models":[{"name":"<имя-модели>",...}]}`. Если получили список моделей, имя одной из них берите для запросов дальше.

Схема потока данных, которую вы соберёте:

```
клиент (Интернет)
   │  HTTPS + Authorization: Bearer <токен>
   ▼
Cloudflare edge  (TLS-терминация, DNS llm.<ваш-домен>)
   │  исходящее соединение cloudflared → edge, TCP 7844
   ▼
cloudflared (ваш хост)
   │  http://127.0.0.1:8080
   ▼
Caddy (проверка Bearer-токена, отключён буферинг ответа)
   │  http://127.0.0.1:11434
   ▼
локальный сервер модели (Ollama / llama.cpp / vLLM)
```

**Зачем:** обратный прокси выполняет две роли, которые нельзя вешать на сам инференс-сервер: проверку токена и отключение буферизации для стриминга. Туннель не публикует порт — он держит исходящее соединение, поэтому NAT и белый IP не нужны.

**Как проверить:** запустите `ss -ltnp | grep -E '11434|8080'` — должны быть видны слушающие сокеты только на `127.0.0.1`.

## Шаг 1. Привяжите инференс к localhost и проверьте API

**Что сделать:** убедитесь, что сервер модели слушает loopback и отвечает на OpenAI-совместимый эндпоинт.

**Зачем:** если сервис уже слушает `0.0.0.0`, вы случайно опубликовали его в локальную сеть. Прокси и туннель должны быть единственной точкой входа.

**Как проверить:** два запроса — список моделей и короткая генерация.

```bash
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:11434/v1/models
curl -s http://127.0.0.1:11434/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"model":"<имя-модели>","messages":[{"role":"user","content":"ping"}],"max_tokens":16}'
```

Ожидаемо: `200` и JSON с полем `choices`. Для Ollama привязка к адресу задаётся переменной окружения `OLLAMA_HOST`, например `OLLAMA_HOST=127.0.0.1:11434` в unit-файле systemd — синтаксис и поддерживаемые значения сверьте с документацией Ollama.

**Пример:** если сервис в Docker и вы хотите, чтобы прокси на хосте его видел, публикуйте порт только на loopback:

```bash
docker run -d --name llm -p 127.0.0.1:11434:11434 <образ>
```

Почему это работает: `-p 127.0.0.1:...` создаёт правило DNAT только для loopback-интерфейса, снаружи хоста порт недоступен.

## Шаг 2. Поднимите туннель и reverse proxy с токеном
![Иллюстрация к разделу «Шаг 2. Поднимите туннель и reverse proxy с токеном»](https://ai-release.org/guides/img/kak-otkryt-dostup-k-lokalnoy-llm-iz-interneta-bez-probr-ru-2.jpg)


**Что сделать:** создайте именованный туннель Cloudflare, направьте его на Caddy, а Caddy — на модель.

**Зачем:** туннель даёт стабильный HTTPS-URL без открытых портов; Caddy добавляет авторизацию, которой у локального инференса обычно нет.

**Как проверить:** по шагам ниже.

1. Авторизация и создание туннеля (интерактивно, один раз):

```bash
cloudflared tunnel login
cloudflared tunnel create llm-gw
cloudflared tunnel route dns llm-gw llm.<ваш-домен>
```

`login` откроет браузер и положит сертификат в `~/.cloudflared/`. Команда `create` выдаст UUID и файл `~/.cloudflared/<UUID>.json` — это credentials, их не коммитят в git.

2. Конфиг `~/.cloudflared/config.yml`:

```yaml
tunnel: llm-gw
credentials-file: /home/<пользователь>/.cloudflared/<UUID>.json
ingress:
  - hostname: llm.<ваш-домен>
    service: http://127.0.0.1:8080
  - service: http_status:404
```

Правило `http_status:404` обязательно последним — Cloudflare Tunnel требует catch-all.

3. `Caddyfile` рядом:

```
{
    http_port 8080
}

http://llm.<ваш-домен> {
    @badAuth not header Authorization "Bearer <ДЛИННЫЙ_СЛУЧАЙНЫЙ_ТОКЕН>"
    respond @badAuth "Unauthorized" 401

    reverse_proxy 127.0.0.1:11434 {
        flush_interval -1
    }
}
```

Схема `http://` в адресе сайта — потому что TLS уже терминируется на edge Cloudflare; `http_port 8080` переносит слушатель Caddy с 80 на 8080, чтобы не занимать стандартный порт. `flush_interval -1` отключает буферизацию — без него стриминг токенов придёт одним куском в конце.

4. Запуск и валидация:

```bash
caddy validate --config ./Caddyfile --adapter caddyfile
caddy run --config ./Caddyfile --adapter caddyfile
cloudflared tunnel run llm-gw
```

**Пример:** токен сгенерируйте локально, не придумывайте руками:

```bash
openssl rand -hex 32
```

Почему это работает: `cloudflared` сам инициирует соединение к edge и держит его; edge маршрутизирует запросы на `llm.<ваш-домен>` в этот туннель, дальше запрос идёт по loopback до Caddy.

Для быстрой проверки без домена есть временный туннель `cloudflared tunnel --url http://127.0.0.1:11434`, но он публикует порт модели **без авторизации** и выдаёт нестабильный URL — только для теста на 5 минут.

Альтернатива на nginx вместо Caddy:

```nginx
location / {
    if ($http_authorization != "Bearer <ТОКЕН>") { return 401; }
    proxy_pass http://127.0.0.1:11434;
    proxy_buffering off;
    proxy_read_timeout 300s;
}
```

## Шаг 3. Проверьте доступ из интернета и закройте лишнее
![Иллюстрация к разделу «Шаг 3. Проверьте доступ из интернета и закройте лишнее»](https://ai-release.org/guides/img/kak-otkryt-dostup-k-lokalnoy-llm-iz-interneta-bez-probr-ru-3.jpg)


**Что сделать:** прогоните три проверки — отказ без токена, успех с токеном, стриминг.

**Зачем:** первые две показывают, что авторизация включена, третья — что длинные ответы не буферизуются.

**Как проверить:**

```bash
curl -s -o /dev/null -w "%{http_code}\n" https://llm.<ваш-домен>/v1/models
curl -s https://llm.<ваш-домен>/v1/models -H "Authorization: Bearer <ТОКЕН>"
curl -N https://llm.<ваш-домен>/v1/chat/completions \
  -H "Authorization: Bearer <ТОКЕН>" -H "Content-Type: application/json" \
  -d '{"model":"<имя-модели>","stream":true,"messages":[{"role":"user","content":"Считай до 5"}]}'
```

Ожидаемо: `401` в первой команде; JSON со списком моделей во второй; в третьей — строки `data: {...}`, приходящие по одной, а не всё сразу в конце. Флаг `curl -N` отключает буферизацию на стороне клиента, иначе вы не отличите клиентскую задержку от серверной.

Итоговая таблица проверок:

| Что проверяем | Команда | Ожидаемый результат |
|---|---|---|
| Модель жива локально | `curl -s http://127.0.0.1:11434/api/tags` | JSON со списком моделей |
| Порт не торчит наружу | `ss -ltnp \| grep 11434` | только `127.0.0.1:11434` |
| Авторизация включена | `curl -o /dev/null -w "%{http_code}" https://llm.<ваш-домен>/v1/models` | `401` |
| Доступ с токеном | тот же запрос с `-H "Authorization: Bearer <ТОКЕН>"` | `200` + JSON |
| Стриминг работает | `curl -N ... "stream":true` | поток строк `data:` |

Чек-лист перед тем, как считать работу законченной:

- [ ] `cloudflared` и Caddy запущены как systemd-сервисы (`systemctl enable --now cloudflared caddy`), а не в открытом терминале
- [ ] Токен лежит в `Caddyfile` с правами `600`, файл не в git
- [ ] В `config.yml` есть catch-all правило `http_status:404`
- [ ] Временный `trycloudflare`-туннель остановлен
- [ ] В логах `journalctl -u cloudflared -n 50` нет повторяющихся ошибок подключения

## Возможные проблемы и решения

**Проблема. Туннель отвечает 502 Bad Gateway, локально всё работает**
- Ошибка: `ERR Unable to reach the origin service. The service may be down or it may not be responding to traffic from cloudflared error="dial tcp 127.0.0.1:8080: connect: connection refused"`
- Причина: `cloudflared` ходит на `127.0.0.1:8080`, а Caddy слушает другой порт — например, вы не добавили блок `{ http_port 8080 }` и он занял `:80`.
- Решение: проверьте фактический порт и приведите конфиг в соответствие.

```bash
ss -ltnp | grep caddy
```

Если в выводе `:80`, а не `:8080` — добавьте глобальный блок с `http_port 8080` в начало `Caddyfile`, перезапустите `systemctl restart caddy` и повторите `curl`.

**Проблема. Локальный curl не подключается к модели**
- Ошибка: `curl: (7) Failed to connect to 127.0.0.1 port 11434: Connection refused`
- Причина: сервер модели не запущен, либо слушает не loopback (например, `0.0.0.0` внутри контейнера без публикации порта).
- Решение: проверьте процесс и сокеты, затем поднимите сервис.

```bash
systemctl status <имя-юнита-модели>
ss -ltnp | grep 11434
```

Если сокета нет вообще — сервис упал, смотрите `journalctl -u <имя-юнита> -n 100`. Если сокет есть, но на другом адресе — приведите `OLLAMA_HOST` (или аналог) к `127.0.0.1:11434`.

**Проблема. OpenAI-совместимый клиент получает 401, хотя токен передан**
- Ошибка: `401 Unauthorized` в ответе, в логах Caddy — запрос отклонён правилом `@badAuth`
- Причина: клиент отправляет `Authorization: Bearer sk-...`, а вы настроили Basic-авторизацию (`basicauth`), которая ждёт `Authorization: Basic base64(login:password)`. Форматы несовместимы.
- Решение: используйте matcher по точному значению заголовка, как в `Caddyfile` из Шага 2, и тот же токен пропишите в клиенте.

```bash
curl -s -o /dev/null -w "%{http_code}\n" https://llm.<ваш-домен>/v1/models \
  -H "Authorization: Bearer <ТОКЕН>"
```

`200` означает, что формат заголовка совпал. Если получаете `401` и здесь — сверьте токен посимвольно (частая причина: лишний пробел или перевод строки при копировании).

## Резюме и следующие шаги
Вы подняли цепочку «локальная модель → Caddy с Bearer-токеном → Cloudflare Tunnel», не открывая входящих портов, и проверили её тремя запросами. Дальше имеет смысл подключить к этому эндпоинту прикладной сценарий — например, [ИИ-помощника для интернет-магазина на локальной модели](https://ai-release.org/guides/kak-sobrat-ii-pomoschnika-dlya-internet-magazina-na-lok.html?utm_source=tg&utm_medium=channel&utm_campaign=guide_inline&utm_content=guide_to_guide), и вынести токен в секрет-хранилище вместо `Caddyfile`.

## FAQ

**Нужен ли белый IP и проброс портов на роутере?**
Нет. `cloudflared` устанавливает исходящее соединение к edge Cloudflare по TCP 7844, поэтому NAT и динамический IP не мешают.

**Можно ли обойтись без домена?**
Для теста — да, временный туннель `cloudflared tunnel --url http://127.0.0.1:11434` выдаст URL на `trycloudflare.com`. Для постоянной работы — нет: URL меняется, а авторизации по умолчанию нет.

**Заменяет ли туннель авторизацию?**
Нет. Туннель скрывает порт, но не проверяет, кто пришёл. Проверку токена делает reverse proxy (Caddy/nginx) — без неё ваш эндпоинт открыт всем, кто узнал URL.

**Что произойдёт при обрыве связи с интернетом?**
`cloudflared` переподключится автоматически, но только если запущен как сервис. Запуск из терминала умрёт вместе с сессией — поэтому `systemctl enable --now cloudflared` в чек-листе обязателен.
