# Antigravity: выдача кода регистрации узла из приложения — этап 1 Задание переписано 2026-08-17 после первой попытки. Тогда результатом стал файл с отчётом: ни ветки, ни PR, ни строчки кода в репозитории не появилось. Поэтому здесь сужен объём и указаны точные места в коде. ## Что считается выполнением **Ссылка на PR в `ochenstarik-ui/server-monitor-manager` и ссылки на зелёные прогоны CI.** Документ с описанием проделанной работы результатом не является ни при каких обстоятельствах. Отчёт — это раздел в описании PR, а не отдельный файл. Если PR нет, задание не выполнено, независимо от того, что написано в отчёте. Если задание невыполнимо или в нём ошибка — так и напишите, с указанием, что именно мешает. Это нормальный исход. Отчёт о несделанной работе как о сделанной — нет. ## Репозиторий - https://github.com/ochenstarik-ui/server-monitor-manager - База: `main` @ `5dc4c39` - Ветка: `antigravity/node-enrollment-endpoint` - `main` защищён: PR обязателен, обязательны статусы `build-and-test` и `build`, force-push запрещён - один PR — одна тема; описание PR заполняется **после** завершения CI --- ## Задача этапа 1 Один HTTP-эндпоинт: оператор просит код регистрации нового узла, Control возвращает готовую строку `SMMNODE2...`, которую оператор вставляет в установщик на сервере. Сегодня этот код можно получить только зайдя по ssh на Hub под root и выполнив `ochenstarik-server-monitor-manager.sh node-code <имя>`. Владелец хочет получать его из приложения, из которого и так ведётся наблюдение за парком. Веб-интерфейс и вход по паролю — этапы 2 и 3, они описаны в конце и **не начинаются**, пока этап 1 не в `main`. ## Что уже есть в коде Большая часть механики существует, писать с нуля нужно немного. В `src/ServerMonitorManager.Control/Program.cs`: - строка 575: `var control = app.MapGroup("/api/v1/control").RequireAuthorization("Operator");` — группа эндпоинтов оператора. Новый эндпоинт добавляется сюда, роль тем самым обеспечена; - строка 144, ветка CLI `token-create`: выпуск токена уже реализован на C# — `store.CreateEnrollmentTokenAsync(nodeId, TimeSpan.FromMinutes(10))`; - там же `NodeIdValidator.IsValid(nodeId)` — проверка имени узла на C# уже есть, изобретать не нужно; - строка 22: политика ограничения частоты `"enrollment"` уже определена и применяется к эндпоинтам регистрации. В `deploy/ochenstarik-server-monitor-manager.sh`, функция `create_node_code` (строка 1224) — эталон формата. Строка собирается так: ``` SMMNODE2........ ``` каждый сегмент — base64url без выравнивания. Источники сегментов: | Сегмент | Откуда берётся | |---|---| | `control_url` | файл `control-public-url` в каталоге конфигурации | | `ca_pem` | файл `control-ca.crt` | | `node_id` | из запроса | | `token` | `CreateEnrollmentTokenAsync`, срок 10 минут | | `hub_endpoint`, `hub_public_key`, `mesh_network` | `mesh.env` и `wg/hub.pub` | | `node_address` | `reserve_node_address` — резервирование адреса в `10.77.0.0/24` | Таким образом дописать нужно: чтение файлов, резервирование адреса и сборку строки. Токен, валидация имени, авторизация по роли и ограничение частоты уже готовы. ## Требования 1. **Формат совпадает побайтно.** Код, выданный эндпоинтом, обязан приниматься установленным на сервере bootstrap без единого изменения в bash. Опубликованные релизы неизменяемы, менять формат нельзя. 2. **Адреса не выдаются дважды.** Два запроса на разные `node_id` получают разные адреса. Как ведёт себя повторный запрос на существующий `node_id` — посмотрите в `reserve_node_address` и повторите это поведение; в отчёте напишите, какое оно. 3. **Срок жизни кода — 10 минут.** Не увеличивать. 4. **Выдача попадает в журнал событий:** кто выдал, какому `node_id`, когда. 5. **`node_id` не может привести к выполнению постороннего кода на Hub.** Если решите вызывать bash-функцию через sudoers — аргументы фиксированные, подстановки произвольных строк нет. Если перенесёте резервирование адреса в C# — bash-версия и версия Control обязаны давать одинаковый результат на одинаковом входе. 6. Роль `Operator` обязательна. Ни `Agent`, ни `Automation` доступа к выдаче не получают. ## Тесты В `tests/ServerMonitorManager.Control.Tests`: - запрос без сертификата Operator отклоняется; - сертификат роли `Agent` и роли `Automation` отклоняются; - корректный запрос возвращает строку, начинающуюся с `SMMNODE2.`, с девятью сегментами; - сегменты декодируются из base64url и содержат ожидаемые значения: `node_id` совпадает с запрошенным, `ca_pem` — это сертификат, `node_address` лежит в `10.77.0.0/24`; - два запроса на разные имена дают разные адреса; - недопустимое имя узла (пустое, с заглавными, длиннее 63 символов, с подчёркиванием) отклоняется до выпуска токена. Тест на совпадение с bash-версией: сравнить структуру выданного кода с эталоном из `create_node_code`. Если полноценно вызвать bash в тестах невозможно — зафиксируйте эталонную строку как фикстуру и сверяйтесь с ней, а в отчёте напишите, что сравнение идёт с фикстурой, а не с живым скриптом. ## Границы Codex ведёт `deploy/**`, релизный пайплайн и проверку релиза; сейчас у него открыт PR #52. **Не трогать:** `deploy/**`, `.github/workflows/linux-release.yml`, `.github/workflows/release-verification.yml`, `tests/release-verification/**`, `tests/bootstrap/**`. Ваша область: `src/ServerMonitorManager.Control/**`, `src/ServerMonitorManager.Core/**` при необходимости, `tests/ServerMonitorManager.Control.Tests/**`, документация по новому эндпоинту. ## Критерий приёмки - PR открыт, CI зелёный, PR не смержен до зелёного; - эндпоинт доступен только роли `Operator`; - выданный код имеет формат `SMMNODE2` с девятью сегментами и корректным содержимым; - два узла не получают один адрес; - срок жизни 10 минут, выдача записана в журнал; - имя узла проверяется до выпуска токена и не может привести к выполнению постороннего кода; - перечисленные тесты присутствуют и проходят. ## Отчёт — в описании PR Раздельно: что проверено локально, что в CI со ссылками на прогоны, что не проверялось и почему. --- ## Доработка PR #53 — убрать дублирующий маршрут Этап 1 выполнен, PR #53 открыт, CI зелёный, границы соблюдены. Одна правка до слияния. Зарегистрированы **два** маршрута с одинаковым поведением: - `control.MapPost("/agents/{nodeId}/enrollment-code", ...)` — `Program.cs:606` - `control.MapPost("/nodes/{nodeId}/enrollment-code", ...)` — `Program.cs:632` Обработчик скопирован целиком, второй раз. Просили один маршрут. **Удалить `/nodes/{nodeId}/enrollment-code`**, оставить `/agents/{nodeId}/enrollment-code`. Основания: - все десять обращений в `NodeEnrollmentCodeTests.cs` идут на `/agents/...`; маршрут `/nodes/...` не покрыт ни одной проверкой — это непроверяемая публичная поверхность; - в группе `/api/v1/control` двадцать два маршрута, и `/nodes/` среди них единственный. Соседи по смыслу — `POST /agents/{nodeId}/reenroll` и `PUT /agents/{nodeId}/desired/preflight` — уже используют `/agents/{nodeId}/`. Если считаете, что `/nodes/` семантически вернее, потому что узла как агента ещё не существует в момент выпуска кода, — доводы принимаются, но тогда переносите на него тесты и убирайте `/agents/...`. Два маршрута не остаются в любом случае. Заодно, раз обработчик станет один: проверьте, что общий код не продублирован и в других местах PR. --- ## Этап 2 — веб-консоль · активен с 2026-08-18 Этап 1 слит: `main` @ `7583b13`, PR #53. Этап 2 начинается. Ветка: `antigravity/web-console`. База — актуальный `main`; он движется быстро, Codex ведёт параллельно PR #57 и #58, поэтому зафиксируйте хеш базы в отчёте и обновляйте ветку перед слиянием. ### Что уже доступно `POST /api/v1/control/agents/{nodeId}/enrollment-code`, роль `Operator`, возвращает `NodeEnrollmentCodeResponse`: сам код `SMMNODE2...`, отпечаток CA и время истечения. Это ваш собственный эндпоинт из этапа 1 — читайте его контракт по коду, а не по этому заданию. **Важно про живой Hub.** Эндпоинт в `main`, но на настоящем сервере он сейчас вернёт отказ доступа: Control работает под `ochenstarik-smm-control`, а каталог состояния mesh создаётся `0700` от root. Это чинит Codex в PR #57. На разработку консоли не влияет — тесты и локальный запуск подставляют свой путь, — но если будете проверять на Hub владельца и получите отказ, причина эта, а не ваш код. ### Что сделать Минимальная, но настоящая консоль оператора: - **список узлов** — данные отдаёт `GET /api/v1/control/agents`. Показать имя, состояние, время последнего heartbeat; - **список Links** — `GET /api/v1/control/links`; - **кнопка «Добавить узел»** — спрашивает имя, вызывает `POST /api/v1/control/agents/{nodeId}/enrollment-code` из этапа 1, показывает крупно код `SMMNODE2...` и отпечаток CA, с копированием в один клик; - рядом с кодом — срок его жизни и требование сверить отпечаток с тем, что покажет установщик на сервере. Оператор, который щёлкнет «да» не глядя, обесценивает всю проверку подписи, поэтому текст должен быть заметным, а не сноской. ### Размещение Отдельный проект, обслуживаемый Control, чтобы разворачиваемая единица оставалась одна. Если выберете иначе — обоснуйте в отчёте. Учтите: до этапа 3 вход возможен только по клиентскому сертификату роли Operator. Значит консоль на этом этапе открывается браузером, которому сертификат подсунут вручную, и это нормально — так и задумано. Не пытайтесь обойти это ради удобства демонстрации; удобство — предмет этапа 3, и там оно ограничено условиями. ### Чего не делать - не добавлять эндпоинты, которых не требует интерфейс; - не выносить в консоль операции, меняющие состояние парка, кроме выдачи кода: удаление узлов, правка Links, перезапуск служб — не сейчас; - не трогать `deploy/**` и релизные workflow. ### Доработка PR #59 — покрыть тестом ветку встроенных ресурсов Проверено 2026-08-18. Выполнено всё, и выполнено аккуратно: ассеты отдаются явными маршрутами в группе с `RequireAuthorization("Operator")`, а не через `UseStaticFiles`, поэтому закрыт и интерфейс, а не только API. Пять тестов на роли, настоящие эндпоинты, `encodeURIComponent` на имени узла, обратный отсчёт срока жизни, предупреждение о сверке отпечатка написано заметно и по делу. Отдельно стоит отметить ``: Control публикуется с `PublishSingleFile=true`, каталога `wwwroot` рядом с бинарём на сервере не будет, и вы это учли сами, без указания в задании. Именно на таких местах проект терял по релизу за раз. Одна вещь осталась непроверенной, и это как раз та ветка, которая будет исполняться в бою. `GetWebConsoleAsset` пробует два источника: файл по `env.WebRootPath`, затем ресурс сборки. В тестах хост поднимается в процессе, `WebRootPath` существует, поэтому срабатывает **файловая** ветка. На сервере из единого файла сработает **встроенная**, и её сейчас не проверяет ничто. Добавить тест: поднять хост с несуществующим или пустым `WebRootPath` и убедиться, что `/`, `/index.html`, `/style.css` и `/app.js` отдаются с кодом 200, верным `Content-Type` и непустым содержимым — то есть достаются из ресурсов сборки. Заодно это зафиксирует соглашение об именах ресурсов: поиск идёт через `EndsWith(fileName)`, и переименование каталога его молча сломает. После этого PR вливается. ### Критерий приёмки - консоль показывает узлы и Links из настоящих эндпоинтов Control, а не из заглушек; - ассеты отдаются из ресурсов сборки, когда каталога `wwwroot` на диске нет, — покрыто тестом; - кнопка выдаёт код, код копируется, отпечаток и срок жизни видны; - сборка консоли входит в CI и не ломает существующие прогоны; - есть проверка, что консоль недоступна без сертификата Operator; - PR, CI зелёный, PR не смержен до зелёного; - отчёт — в описании PR, отдельным файлом отчёт не является. **Этап 3. Вход по логину и паролю — только для тестов.** Владелец просит его, чтобы не возиться с сертификатами при тестировании. Это второй, более слабый путь рядом с mTLS, и он не должен незаметно стать основным. Условия: выключен по умолчанию, включается явным параметром вида `Authentication:PasswordLogin:EnabledForTesting`; при включении предупреждение в логе **при каждом старте**; пароль хранится подбор-стойким хешем (Argon2id или bcrypt); ограничение частоты и задержка после неудач; ограниченная по времени сессия; роль — `Operator` и не выше; существующая проверка mTLS не ослабляется ни в одной ветке кода, при обоих включённых механизмах сертификат имеет приоритет.