Создание голосового навыка для Яндекс станции без vps и регистрации домена

У меня есть домашний медиасервер, который хотелось включать и выключать голосом через Яндекс Станцию.

Задача кажется простой, но на практике быстро упирается в инфраструктуру:

  • Яндекс Диалоги требуют публичный HTTPS-домен
  • Покупать домен ради одной домашней задачи мне не хотелось
  • Настраивать TLS, reverse proxy, проброс портов и поддержку сертификатов - тоже
  • ngrok как временное решение не подходит (из РФ он недоступен)

В этой статье покажу как я решил задачу без покупки домена, без ручной настройки SSL, используя:

  • OpenWRT-роутер
  • Wake-on-LAN
  • NestJS
  • Публичный HTTPS-туннель через tunyl

В статье будут вставки фрагментов кода, проект целиком можно посмотреть в гитхабе:

Задача и ограничения

Что есть:

  • Домашний медиасервер
  • Роутер на OpenWRT, доступный в локальной сети
  • Яндекс Станция, которая должна: включать и выключать сервер

Что важно:

  • Сервер находится за NAT
  • Входящие подключения извне недоступны
  • Яндекс требует HTTPS + домен
  • Решение должно быть максимально простым в эксплуатации

Общая схема работы

Создание голосового навыка для Яндекс станции без vps и регистрации домена
  1. Яндекс Станция активирует навык
  2. Сервер Яндекс Диалогов отправляет HTTP-запрос
  3. Запрос приходит на приложение, запущенное на роутере
  4. Приложение:либо отправляет Wake-on-LAN пакетлибо делает HTTP-запрос для выключения сервера

Как включается и выключается сервер

Включение через Wake-on-LAN

Сервер включается через отправку UDP пакета на порт 9.

Сетевая карта ловит пакет и запускает систему.

На моей плате (Atermiter X99) WOL включён по умолчанию.

Выключение через HTTP

Для выключения сервера я использую обычный HTTP-запрос к локальному API сервера.

Рассматривал вариант с SSH (shutdown / reboot), но отказался:

  • Лишний доступ
  • Больше рисков
  • Сложнее контролировать

Серверная часть: NestJS + TypeScript

В качестве HTTP-сервера используется nodejs приложение с использованием фреймворка NestJS.

Инициализация проекта:

nest new turn-on-mrserver

Начинаем с e2e-теста

Я предпочитаю TDD, поэтому первым делом описываю e2e-тест, ориентируясь на документацию Яндекс Диалогов.

Пример теста:

it('Power on', () => { return request(app.getHttpServer()) .post('/api/power') .send({ request: { command: 'включи сервер', original_utterance: 'Включи сервер', nlu: { tokens: ['включи', 'сервер'], entities: [], }, markup: { dangerous_context: false, }, type: 'SimpleUtterance', }, version: '1.0', }) .expect({ response: { text: 'Питание сервера включено', tts: 'Питание сервера включено', end_session: true, }, version: '1.0', }); });

Контроллер

Обработчик входящих запросов:

@Post('api/power') async powerManagement(@Body() { request }: IncomingRequestDto) { const { message } = await this.powerService.handle(request.nlu.tokens); return { response: { text: message, tts: message, end_session: true, }, version: '1.0', }; }

Обработка команд

Для сложных сценариев у Яндекса есть интенты, но в моём случае достаточно анализа ключевых слов.

export const powerOnTokens = ['включи', 'запусти', 'вруби']; export const powerOffTokens = ['выключи', 'останови', 'выруби', 'потуши'];
async handle(tokens: string[]) { const tokensSet = new Set(tokens); if (powerOnTokens.some(t => tokensSet.has(t))) { return this.turnOn(); } if (powerOffTokens.some(t => tokensSet.has(t))) { return this.turnOff(); } return { message: 'Команда не распознана' }; }

Wake-on-LAN и выключение

async turnOn() { try { await wol.wake(this.configService.get('SERVER_MAC_ADDRESS')); return { message: 'Питание сервера включено' }; } catch { return { message: 'Не удалось включить сервер' }; } } async turnOff() { try { await fetch(this.configService.get('SERVER_API_ADDRESS'), { method: 'POST', body: this.configService.get('SERVER_API_PASSWORD'), }); return { message: 'Питание сервера выключено' }; } catch { return { message: 'Не удалось выключить сервер' }; } }

Проверяем тесты

npm run test:e2e PASS test/app.e2e-spec.ts ✓ Power on ✓ Power on

Безопасность: ограничение по IP

Приложение доступно из интернета, значит нужна защита.

Самый простой вариант — whitelist IP-адресов Яндекса.

@Injectable() export class IpWhitelistGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { const request = context.switchToHttp().getRequest<Request>(); const clientIp = this.getClientIp(request); if (!this.ipService.isAllowed(clientIp)) { throw new ForbiddenException(); } return true; } private getClientIp(request: Request): string { const xRealIp = request.headersDistinct['x-real-ip']?.at(0); return xRealIp ?? request.ip; } }

Важно:

  • При работе через reverse proxy можно доверять x-real-ip
  • При прямом доступе — нельзя, злоумышленник может подменить этот заголовок

Список IP Яндекса: https://yandex.ru/ips

Как дать Яндексу HTTPS-доступ без домена

И вот тут появляется главный инфраструктурный вопрос.

Яндекс требует:

  • Доменное имя
  • HTTPS

Покупать домен, настраивать TLS, поддерживать сертификаты ради домашнего навыка не хотелось.

Для решения я использовал сервис tunyl который:

  • Даёт бесплатный публичный HTTPS-домен
  • Проксирует запросы на локальный порт
  • Не требует DNS и настройки SSL

После создания сайта сервис отображает созданный домен и команду для подключения:

npx start-tunyl@latest --port 3000 --token <TOKEN>

Нам необходимо запомнить <TOKEN> и домен, они нам пригодятся на следующих шагах

Интеграция tunyl прямо в приложение

Чтобы не запускать два процесса (отдельно наше приложение, а отдельно tunyl прокси), я подключил туннель прямо в main.ts:

import { Tunnel } from 'start-tunyl'; async function bootstrap() { const localPort = 3300; const app = await NestFactory.create(AppModule); await app.listen(localPort); const proxy = new Tunnel({ agentAccessToken: process.env.PNODE_ACCESS_TOKEN, localPort, }); proxy.start(); proxy.on('stopped', () => app.close()); }

Переменные окружения

SERVER_MAC_ADDRESS=00:e0:a0:b0:c0:d0 SERVER_API_ADDRESS=http://192.168.0.10:8080 SERVER_API_PASSWORD=123 PNODE_ACCESS_TOKEN=...

Проверка через curl

curl https://mrserver.tunyl.com/api/power \ -H "Content-Type: application/json" \ -d '{ "request": { "command": "включи сервер" }, "version": "1.0" }'

Подключение навыка в Яндекс Диалогах

  1. Переходим: https://dialogs.yandex.ru/developer
  2. Создаём навык
  3. В поле Webhook URL указываем:

https://mrserver.tunyl.com/api/power

Во вкладке «Тестирование» можно проверить работу навыка текстом.

Создание голосового навыка для Яндекс станции без vps и регистрации домена

Итог

В результате получилось:

  • Голосовое управление сервером
  • Без покупки домена
  • Без настройки SSL
  • Без проброса портов
  • С минимальной инфраструктурой

Подход хорошо подходит для:

  • Домашних сервисов
  • Демо-стендов
  • Тестовых интеграций
  • Любых сценариев, где нужен быстрый HTTPS-доступ к локальному приложению
3
1 комментарий