Задания, отчёты и патчи, лежавшие в C:\Users\Ochenstarik\projects и в домашней папке, перенесены в agents/. Разложено по агентам там, где имя файла позволяло определить автора; остальное — в _salvage-2026-08-18/ и разбирается вручную. Патчи в notes/salvage-2026-08-18/ — незакоммиченная работа из брошенных рабочих копий: она существовала только на диске. Тяжёлое (релизные архивы, инсталляторы, наборы данных) в репозиторий не попало: оно лежит рядом, в Agent_projects/_archive и Agent_projects/_data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
184 lines
19 KiB
Markdown
184 lines
19 KiB
Markdown
# 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 не ослабляется ни в одной ветке кода, при обоих включённых механизмах сертификат имеет приоритет.
|