# 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.<control_url>.<ca_pem>.<node_id>.<token>.<hub_endpoint>.<hub_public_key>.<node_address>.<mesh_network>
```

каждый сегмент — 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` на имени узла, обратный отсчёт срока жизни, предупреждение о сверке отпечатка написано заметно и по делу.

Отдельно стоит отметить `<EmbeddedResource Include="wwwroot\**" />`: 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 не ослабляется ни в одной ветке кода, при обоих включённых механизмах сертификат имеет приоритет.
