SMS-шлюз на роутере: как отправлять коды без договора с оператором
Коды подтверждения нужны любому продукту с телефонным логином, а договор с SMS-агрегатором — это юрлицо, подписанный сендер и минимальный платёж. При этом на столе стоит TP-Link Deco X20-4G с обычной SIM-картой, и его приложение умеет отправлять SMS. Значит, аппаратно всё уже есть — не хватает только API.
Документированного API у него нет. Популярные реверс-инженерные проекты вокруг роутеров TP-Link реализуют SMS только для старых MR-моделей, а не для Deco. Дальше — про то, во что это превратилось.
#Один вход, несколько выходов
Сначала о том, что получилось, — потому что форма шлюза целиком продиктована тем, что выяснилось по дороге.
Три вызывающих: центр уведомлений 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 прямо на роутере.
И вот тут — самая поучительная часть истории. Весь 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 на оба поля, а требование «хотя бы одно из двух» проверяется кодом. Схема умеет сказать «это поле обязательно», но не умеет сказать «одно из этих двух» — и попытка выразить второе первым тихо ломает контракт.
#Наружу — только панель
Публичный эндпоинт отправки SMS — это чужой счёт за трафик и репутация SIM-карты. Поэтому наружу опубликована только админ-панель: у /v1/* просто нет маршрута в Traefik. Novu и ZITADEL ходят к шлюзу по имени контейнера в общих docker-сетях. Токен и подпись при этом никуда не делись — но первым рубежом стоит отсутствие маршрута, а не проверка заголовка.
Сверху — обычные предохранители от собственных ошибок: минимальный интервал между сообщениями на один номер и суточный потолок отправок. Цикл в чужом коде не должен превращаться в счёт от оператора.
#Что из этого стоит забрать
- ▸404 не всегда значит «не тот путь». Иногда он значит «не та сессия» — и это разные диагнозы с разной ценой.
- ▸Если основной путь — потребительское железо, запасной нужен с первого дня, а проверка его восстановления не должна стоить денег за попытку.
- ▸Генератор схемы описывает поля, а не отношения между ними. Всё, что звучит как «одно из», проверяет код.
- ▸Самый надёжный способ не отдать наружу опасный эндпоинт — не заводить для него маршрут.