~/potatohd.ruEN
← Все статьи
13 августа 2026·8 мин

SMS-шлюз на роутере: как отправлять коды без договора с оператором

Goreverse engineeringOTPsystem design

Коды подтверждения нужны любому продукту с телефонным логином, а договор с SMS-агрегатором — это юрлицо, подписанный сендер и минимальный платёж. При этом на столе стоит TP-Link Deco X20-4G с обычной SIM-картой, и его приложение умеет отправлять SMS. Значит, аппаратно всё уже есть — не хватает только API.

Документированного API у него нет. Популярные реверс-инженерные проекты вокруг роутеров TP-Link реализуют SMS только для старых MR-моделей, а не для Deco. Дальше — про то, во что это превратилось.

#Один вход, несколько выходов

Сначала о том, что получилось, — потому что форма шлюза целиком продиктована тем, что выяснилось по дороге.

Схема: Novu, ZITADEL и собственные сервисы отправляют SMS через один шлюз, который сначала пробует роутер, затем карьерный API MTS Exolve, затем SMS.ru
Вызывающих трое, путь отправки один, бэкендов под ним — сколько нужно

Три вызывающих: центр уведомлений Novu (его встроенный «SMS Webhook»), ZITADEL как SMS-провайдер для OTP и остальные сервисы напрямую. У всех троих один HTTP-эндпоинт и один внутренний контракт; чем именно ушло сообщение — их не касается.

Под этим эндпоинтом — цепочка бэкендов. Основной пробуется первым; если он падает или уже помечен как лежащий, отправка уходит через запасной. Вызывающий видит успех, а не аварию. Отдельная деталь, которая экономит деньги: пока основной помечен лежащим, фоновая проверка периодически пробует его вернуть, и для карьерного API это бесплатный read-only вызов списка отправителей, а не тестовая SMS. В историю в ClickHouse пишется, какой именно бэкенд доставил каждое сообщение, — иначе через месяц не ответить на вопрос «мы вообще ещё через роутер шлём?».

#Вход в роутер: криптография решаемая, контроллер — нет

Локальный веб-интерфейс Deco говорит по luci: запросы вида /cgi-bin/luci/;stok=<stok>/admin/..., а современная прошивка шифрует сам вход — RSA поверх AES. Эта часть оказалась решаемой: логика подтверждена перекрёстно, двумя независимыми реверс-инженерными реализациями, и после входа сессия спокойно читает, например, статус WAN и LTE.

Дальше начинается интересное. Где именно живёт отправка SMS, в документации нет. Ответ нашёлся в распакованной прошивке — причём той самой сборки, что стоит на живом устройстве: контроллер admin/mobile_app/sms, формы send_sms, read_sms_list, get_unread_count, параметры number и content (текст в base64), хранилище — SQLite прямо на роутере.

Схема: сессия веб-админки успешно входит и читает обычные формы luci, но получает 404 на admin/mobile_app/sms; тот же контроллер отвечает сессии приложения Deco
Адрес известен точно — и всё равно закрыт: контроллер обслуживает не ту сессию, с которой пришли

И вот тут — самая поучительная часть истории. Весь admin/mobile_app/* возвращает 404 сессии веб-админки. Не 401 и не 403: с точки зрения этой сессии контроллера просто не существует, хотя он лежит в прошивке и работает. Обслуживается он только контекстом приложения Deco.

Такой 404 — ловушка для отладки. Он выглядит как «неправильно угадал путь», и на него легко потратить день, перебирая имена форм. На самом деле путь был верным с первой попытки, неверной была сессия.

Отсюда второй путь: делать то же самое через облако TP-Link — тот же протокол, которым пользуется приложение (REST плюс бинарный туннель), восстановленный захватом трафика приложения. Он работает и реально отправляет SMS. Цена — короткоживущий токен доступа: однажды его протухание молча положило доставку, поэтому шлюз теперь продлевает токен сам, а когда не может — говорит об этом по имени, а не отдаёт голый 502.

#Почему в проде для OTP всё-таки карьерный API

Здравый смысл здесь важнее азарта. Роутер — потребительское железо на домашнем канале: он перезагружается, обновляет прошивку, теряет связь. Для кода подтверждения, который пользователь ждёт прямо сейчас, это плохой основной путь. Поэтому в бою для OTP стоит карьерный API (MTS Exolve), а роутер и облако остаются рабочими альтернативами и запасными путями.

Мелочь оттуда же, стоившая времени: в теле запроса Exolve поле number — это отправитель, а получатель называется destination. Прочитать это как «номер, куда шлём» — естественно и неправильно.

#Ошибка, из-за которой не уходил ни один код

Самая дорогая поломка была не в реверс-инжиниринге, а в схеме собственного API.

У шлюза одно тело запроса на два диалекта: свои сервисы шлют {to, text}, Novu — {to, content}. Пока тело разбиралось вручную, это работало. При переходе на генерацию схемы поля без omitempty стали обязательными — и валидатор начал требовать text и content одновременно, в каждом запросе.

Результат: каждая попытка входа по телефону получала 422 ещё до того, как обработчик успевал что-то сделать. В приложении это выглядело как «Не удалось отправить код», а сам шлюз при этом был совершенно здоров — /healthz зелёный, бэкенды живые, в логах ни одной ошибки отправки, потому что отправки не было. Ломающее изменение приехало не с новой фичей, а с миграцией на другой способ описывать то же самое тело.

Лечение простое: omitempty на оба поля, а требование «хотя бы одно из двух» проверяется кодом. Схема умеет сказать «это поле обязательно», но не умеет сказать «одно из этих двух» — и попытка выразить второе первым тихо ломает контракт.

#Наружу — только панель

Схема: Traefik публикует только путь /admin, а /v1/sms/* доступен исключительно из внутренней сети, откуда его вызывают Novu и ZITADEL по имени контейнера
Эндпоинт отправки не имеет публичного маршрута вообще — не «закрыт токеном», а не существует снаружи

Публичный эндпоинт отправки SMS — это чужой счёт за трафик и репутация SIM-карты. Поэтому наружу опубликована только админ-панель: у /v1/* просто нет маршрута в Traefik. Novu и ZITADEL ходят к шлюзу по имени контейнера в общих docker-сетях. Токен и подпись при этом никуда не делись — но первым рубежом стоит отсутствие маршрута, а не проверка заголовка.

Сверху — обычные предохранители от собственных ошибок: минимальный интервал между сообщениями на один номер и суточный потолок отправок. Цикл в чужом коде не должен превращаться в счёт от оператора.

#Что из этого стоит забрать

  • 404 не всегда значит «не тот путь». Иногда он значит «не та сессия» — и это разные диагнозы с разной ценой.
  • Если основной путь — потребительское железо, запасной нужен с первого дня, а проверка его восстановления не должна стоить денег за попытку.
  • Генератор схемы описывает поля, а не отношения между ними. Всё, что звучит как «одно из», проверяет код.
  • Самый надёжный способ не отдать наружу опасный эндпоинт — не заводить для него маршрут.

// свободен для заказов

Нужен сайт или сервис под ключ?