server-monitor-manager/agents/_salvage-2026-08-18/smm-deliverables/Старые задачи/smm-task-2-2026-07-31.md
Ochenstarik 23eb3f5233 chore(agents): разбор рабочих папок с диска на 2026-08-18
Задания, отчёты и патчи, лежавшие в 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>
2026-08-18 14:19:54 +07:00

214 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Задача 2 — целостность состояния и доверенная поставка
Базовая ревизия: `main` @ `c320b7d` (после PR #7, #8, #9).
Предыдущий документ: `smm-improvement-plan-2026-07-30.md` (ревизия `2d28b8d`).
---
## 0. Приёмка задачи 1
| Пункт | Статус | Комментарий |
|---|---|---|
| **P0-1** токен в `argv` | ✅ **принято** | Токен передаётся файлом `0400 agent:agent` в `/var/lib/...-enrollment` (`0710 root:agent`), путь — не значение — уходит в `argv`. Inline-токен теперь явно отвергается (`Program.cs`, exit 2). Есть валидация режима файла, размера, base64url-алфавита, `ZeroMemory`, удаление на всех путях. Тесты: `test-enrollment-token-argv.sh`, `EnrollmentTokenTests.cs`, 13 grep-проверок в `test-bootstrap-contract.sh`. Каталог вынесен из `STATE_DIR` — Control до него не дотянется. Сделано аккуратно. |
| **P0-5** DoS root-helper | ✅ **принято** | `SO_PEERCRED` со сверкой uid, обработка в `Task.Run` с `SemaphoreSlim(4)`, таймаут соединения 30 с, буферное чтение через `ArrayPool`, троттлинг запросов и неавторизованных попыток (per-uid + глобальный), анти-флуд логирования. Плюс сквозная отмена в `TimezoneProvisioningExecutor.ExecuteAsync` с `RecoverAfterCancellationAsync`. Сверх запрошенного — хорошо. |
| **P0-3** SSH-ключ на диске | ✅ **принято** | `SshPrivateKeySession`: явный DACL только для текущего SID, `FileMode.CreateNew`, один файл на сессию команды, `IAsyncDisposable`, чистка orphan'ов при старте `App`. |
| **P0-4** host key pinning | 🟡 **принято частично** | Мониторинг закрыт полностью: `StrictHostKeyChecking=yes`, `-F none`, `IdentityAgent=none`, `GlobalKnownHostsFile=none`, `KnownHostsCommand=none`, `UpdateHostKeys=no`, отдельный pin-файл на endpoint, диалог подтверждения с предупреждением «сверьте через доверенную консоль». **Интерактивный терминал не закрыт** — см. A1. |
| **P0-2** Links после reboot | ⛔ **не начато** | Раздел 2 этой задачи. |
| **P0-6** подпись поставки | ⛔ **не начато** | Раздел 3 этой задачи. |
Качество исполнения задачи 1 высокое: каждое исправление сопровождено тестом, добавлены два новых тест-проекта (`Desktop.Security.Tests`, `test-enrollment-token-argv.sh`) и они подключены в CI. Замечания ниже — это доводка, а не переделка.
---
## 1. Долги по принятой работе (блок A, ~12 дня)
### A1. Интерактивный терминал игнорирует подтверждённый fingerprint — P0
**Где:** `src/ServerMonitorManager.Desktop/SshMonitorService.cs:194-229`
```csharp
var sshArguments = new[] { "-p", port, $"{terminalUser}@{profile.Host}" };
```
Ни `-F none`, ни `UserKnownHostsFile`, ни `StrictHostKeyChecking`. Терминал использует системный `~/.ssh/known_hosts` и поведение по умолчанию (`ask`). Пользователь подтвердил fingerprint в приложении — а терминал открывается мимо этого доверия и может принять другой ключ по обычному консольному промпту. Половина P0-4 остаётся открытой, и именно на том канале, где даётся полноценная shell-сессия.
**Что сделать:**
1. Передавать в терминал тот же набор опций, что и в `RunRestrictedCommandAsync`: `-F none`, `-o StrictHostKeyChecking=yes`, `-o UserKnownHostsFile=<pin>`, `GlobalKnownHostsFile=none`, `KnownHostsCommand=none`, `UpdateHostKeys=no`, `CheckHostIP=no`.
2. Перед запуском проверять `SshHostKeyTrust.IsTrusted(...)` и отказывать с тем же сообщением, что и мониторинг.
3. Учесть, что `UseShellExecute = true` + `wt.exe new-tab` — аргументы уходят через оболочку Windows Terminal: проверить экранирование пути pin-файла (пробелы в `ApplicationData` пути реальны), либо перейти на `wt.exe new-tab --title ... -- ssh ...` с явным разделителем `--`.
4. Тест в `Desktop.Security.Tests`: построение аргументной строки терминала содержит `StrictHostKeyChecking=yes` и путь pin-файла; профиль без `HostKeyFingerprint` терминал не открывает.
### A2. Существующие профили после обновления перестают работать без внятного объяснения
`ServerProfileData.HostKeyFingerprint` — новое опциональное поле; `ServerStorage` использует reflection-сериализатор, поэтому старый `servers.json` загрузится с `null`. Дальше `RunRestrictedCommandAsync` бросает `"SSH host key is not explicitly confirmed for this server profile."`**на английском**, в остальном русскоязычном интерфейсе, и без подсказки, что чинится это через «Изменить сервер».
**Что сделать:**
1. Отдельный статус профиля `HostKeyPendingConfirmation` вместо исключения при опросе.
2. В карточке сервера — кнопка «Подтвердить host key», вызывающая `ConfirmHostKeyAsync` напрямую, без диалога редактирования.
3. Сообщение — на языке интерфейса; в тексте назвать конкретное действие.
4. Разовая миграция при старте: если профилей без fingerprint > 0 — один `InfoBar` со списком имён.
### A3. Переименование сервера требует нового keyscan и подтверждения
`MainPage.xaml.cs``ConfirmHostKeyAsync` вызывается в обработчике редактирования безусловно. Смена только `Name` (или `User`) приводит к сетевому `ssh-keyscan` и модальному диалогу. При недоступном сервере переименование вообще невозможно.
**Что сделать:** пропускать диалог, если `Host`/`Port` не изменились и `IsTrusted(...)` для сохранённого fingerprint возвращает `true`; в этом случае переносить fingerprint в новый профиль.
### A4. Диспетчеризация в helper по строке кода ошибки
**Где:** `ProvisioningHelperServer.ExecuteRequestAsync`
```csharp
var response = Execute(request, timezoneExecutor: null);
if (response.Code != "execution.unavailable" || _timezoneExecutor is null) return response;
```
Ветка исполнения выбирается по тому, что синхронный путь вернул конкретную строку. Любой будущий модуль, вернувший `execution.unavailable` по своей причине, будет отправлен в timezone-исполнитель. Заменить явным решением: `request.Execution is not null && request.ActionType == "system.base-install"` → async-путь, иначе — синхронный.
### A5. `SMM_AgentUid` не обновляется при `update-agent`
`agent.env` с `SMM_AgentUid` пишется только в `install_agent`. `update_role agent` перезаписывает бинарники и юниты, но не `agent.env`. Если uid системного пользователя изменился (переустановка, восстановление из backup другой машины, ручное вмешательство) — helper отвергнет **все** запросы Agent'а по `SO_PEERCRED`, и провиженинг встанет молча.
**Что сделать:**
1. В `update_role agent` пересчитывать и переписывать `SMM_AgentUid` (идемпотентно, `sed -i` по ключу или полная перегенерация файла с сохранением остальных значений).
2. В helper при старте: если `SMM_AgentUid` не соответствует существующему пользователю — писать явную ошибку в journal, а не молча отклонять соединения.
3. Проверка в `test-bootstrap-contract.sh`.
### A6. Синхронная обёртка `Execute` над `ExecuteAsync`
`TimezoneProvisioningExecutor.Execute(...) => ExecuteAsync(...).GetAwaiter().GetResult()` — классический источник дедлоков и мёртвый код в production-пути. Оставить только если её реально используют тесты; иначе удалить, тесты перевести на async.
---
## 2. Блок B — состояние в UI = состояние в системе (P0-2)
Главная задача этого спринта. Полное описание проблемы — в `smm-improvement-plan-2026-07-30.md`, раздел P0-2. Кратко: `ochenstarik-smm-firewall.service` при каждом старте делает `nft delete table inet ochenstarik_smm` и загружает пустую цепочку `links`; Control при старте переприменяет только **disabled**-политики. После перезагрузки Hub все `Active`-Links показаны рабочими, но трафик заблокирован. Это нарушение критерия приёмки №12 собственного ТЗ и прямое расхождение с `docs/linux-bootstrap.md:82`, где такое переприменение описано как существующее поведение.
### B1. Helper: чтение фактического состояния
`deploy/ochenstarik-smm-policy-apply` — новое действие `link-list`:
- вывод `nft -j list chain inet ochenstarik_smm links`, парсинг только правил с комментарием `smm:<source>:<target>:<proto>:<port>`;
- формат вывода — по одной записи в строке, стабильный и машиночитаемый: `source<TAB>target<TAB>proto<TAB>port`;
- ненулевой exit code, если таблицы/цепочки нет (это отдельное состояние «firewall не поднят», а не «правил ноль»);
- в testing-режиме (`SMM_POLICY_TESTING=1`) — чтение из фикстуры, как сделано для остальных действий;
- запись в `sudoers` не меняется (шаблон `$POLICY_HELPER *` уже покрывает).
Добавить действие `link-apply-batch` (правила списком на stdin, применение одной транзакцией `nft -f`) — при переприменении 50 Link'ов текущая схема породит 50 процессов `sudo`.
### B2. Control: сервис реконсиляции
Новый `LinkStartupReconciliationService : BackgroundService` (или расширение существующего `LinkExpirationBackgroundService`):
1. **При старте Control** и далее периодически (интервал — новая настройка `Control__LinkReconciliationSeconds`, диапазон 303600, по умолчанию 300):
- получить факт через `link-list`;
- `desired=Active`, факта нет → `ApplyConnectAsync`, событие `link.reapplied`, запись в audit;
- факт есть, записи в БД нет либо `desired=Disabled``ApplyDisconnectAsync`, событие `link.orphan-removed`, audit;
- до успешного восстановления держать `ActualState = Partial`.
2. **Если `link-list` вернул «таблицы нет»** — не считать это «правил ноль»: перевести все Active-Links в `Partial`, поднять событие `mesh.firewall-missing`, повторять попытку с backoff. Ошибочная трактовка здесь приведёт к массовому «переприменению» в несуществующую таблицу.
3. Реконсиляция должна быть идемпотентной и не конфликтовать с `LinkService._reconciliationLocks` — переиспользовать те же семафоры по `link.Id`.
4. Ограничение частоты: не чаще одного полного прохода в `LinkReconciliationSeconds`, независимо от числа событий.
### B3. Desktop
- В `LinksPage` показывать `ActualState` отдельно от `DesiredState` и явный бейдж расхождения («Политика не применена»).
- Обработка событий `link.reapplied`, `link.orphan-removed`, `mesh.firewall-missing` в потоке событий.
### B4. Emergency-команда
`ochenstarik-smm-emergency firewall-restore` восстанавливает deny-by-default и, по документации, «разрешающие Link-правила после этого должен повторно применить Control». Сейчас Control об этом не узнаёт. Добавить: команда ставит маркер-файл, сервис реконсиляции при обнаружении маркера выполняет внеочередной проход и снимает маркер после успеха.
### B5. Тесты
- **Unit:** фейковый `ILinkPolicyApplier` + фейковый источник факт-состояния; сценарии: пустой факт при трёх Active → три connect; лишнее правило → один disconnect; отсутствие таблицы → все в `Partial`, ни одного connect; повторный проход без изменений → ноль вызовов.
- **Bash:** `link-list` на фикстуре вывода `nft -j` (включая правила с чужими комментариями, которые трогать нельзя).
- **Acceptance:** в `tests/acceptance/three-server-mesh.sh` при `SMM_ACCEPT_REBOOT=1` после reboot Hub проверять **фактическую связность** (`nc -z` через Link), а не только состояние в API. Сейчас тест проверяет только API — именно поэтому дефект и не был пойман.
### B6. Критерий приёмки блока B
После `reboot` Hub и каждого Node: для всех Link с `DesiredState=Active` связность восстанавливается автоматически не позднее одного интервала реконсиляции; для `Disabled` — остаётся заблокированной; ни одно правило без соответствующей записи в БД в цепочке не остаётся; расхождение отображается в Desktop до момента устранения.
---
## 3. Блок C — доверенная поставка (P0-6)
Сейчас bootstrap сверяет `ARCHIVE.sha256`, лежащий рядом с архивом в том же релизе, а manifest не подписан и содержит только хэш самого bootstrap-скрипта. Кто может опубликовать релиз — может опубликовать и хэш. `update-control` / `update-agent` примут такой архив и заменят бинарники, работающие с root-правами.
### C1. Формат manifest v2
```json
{
"schema": "smm-release-manifest/v2",
"version": "v0.2.0-beta.1",
"released_at": "2026-08-.....",
"components": {
"bootstrap": { "file": "...sh", "sha256": "..." },
"linux-x64": { "file": "...linux-x64.tar.gz", "sha256": "..." },
"linux-arm64": { "file": "...linux-arm64.tar.gz", "sha256": "..." },
"windows-msix":{ "file": "...win-x64.msix", "sha256": "..." }
},
"versions": { "control": "...", "agent": "...", "helper": "...", "desktop": "..." },
"compatibility": { "minimum_control": "...", "minimum_agent": "...", "helper_protocol": "1" }
}
```
### C2. Подпись
- `cosign sign-blob` (keyless/OIDC, без хранения приватного ключа) либо `minisign` с ключом в GitHub Secrets.
- Публичный ключ / идентификатор issuer'а **вшивается константой в bootstrap-скрипт и в Desktop** — не скачивается вместе с релизом, иначе подпись бессмысленна.
- Проверять `.sig` до любого разбора manifest; при отсутствии инструмента проверки — отказ, а не предупреждение.
### C3. Изменения в bootstrap
1. Новое действие `verify-manifest MANIFEST SIGNATURE`.
2. `verify_archive` → сверка хэша архива **с manifest**, а не с соседним `.sha256`; старый путь оставить только под явным `SMM_ALLOW_UNSIGNED=1` с громким предупреждением (для локальной разработки).
3. Отказ `update-*` при несовместимой паре версий Control ↔ Agent ↔ helper (`installer-contract.md` §6 это уже требует).
4. `PROGRAM_VERSION` перестаёт быть `0.2.0-dev` — подставлять тег при упаковке в release workflow.
### C4. CI
- Запинить все GitHub Actions по commit SHA (сейчас `@v6`, `@v5`, `@v2` — плавающие теги).
- `.github/dependabot.yml` для `github-actions` и `nuget`.
- Сузить `permissions: contents: write` до job'а публикации.
- Негативный тест: подменить байт в архиве → bootstrap обязан отказать; подменить хэш в manifest без переподписи → обязан отказать.
- SBOM (`dotnet CycloneDX`) как артефакт релиза.
### C5. Критерий приёмки блока C
Модифицированный архив, модифицированный manifest и manifest без подписи отвергаются bootstrap'ом; каждый случай покрыт тестом в CI. Установка и обновление возможны только из подписанного релиза (кроме явного dev-режима).
---
## 4. Порядок и объём
| Блок | Содержание | Оценка |
|---|---|---|
| **A** | Долги по задаче 1 (A1 — обязательно, остальное желательно в том же PR) | 12 дня |
| **B** | Реконсиляция Links + фактическая проверка в acceptance | 35 дней |
| **C** | Подписанный manifest и проверка совместимости версий | 24 дня |
Делать в порядке A → B → C. Блок A мал и закрывает открытую половину P0-4 — не откладывать его в отдельный спринт. Блок B выше по приоритету, чем C: ложное «зелёное» состояние в инструменте безопасности опаснее, чем неподписанный релиз на этапе alpha, где артефакты пока ставит только автор.
**PR-разбиение:** три отдельных PR (`fix(desktop): pin terminal host key`, `feat(control): reconcile link policies`, `feat(release): signed compatibility manifest`) — как и в задаче 1, по одной теме на PR.
---
## 5. Definition of Done задачи 2
- [ ] Терминал использует тот же pinned host key, что и мониторинг; профиль без подтверждённого fingerprint терминал не открывает
- [ ] Профили из предыдущей версии подтверждаются одним действием, без пересоздания сервера
- [ ] `SMM_AgentUid` корректен после `update-agent`; несоответствие видно в journal
- [ ] После reboot Hub и Node factual state == desired state, проверено связностью в acceptance-скрипте
- [ ] Расхождение desired/factual видно в Desktop
- [ ] Отсутствие nftables-таблицы отличается от «правил ноль» и не приводит к ложным connect
- [ ] Manifest подписан, публичный ключ вшит в bootstrap, подмена архива/manifest отвергается — с тестами
- [ ] Несовместимые версии Control/Agent/helper блокируют обновление
- [ ] Actions запиннены по SHA, dependabot включён
- [ ] Roadmap обновлён: закрыты пункты Этапа 3 (физический acceptance — частично), Этапа 7 (подпись manifest)
- [ ] `docs/linux-bootstrap.md:82` приведён в соответствие с реализацией
---
## 6. Не входит в задачу 2
Xray (Этапы 1112), каркас `IProvisioningModule` и остальные модули провиженинга, роль Monitor в bootstrap, MVVM-рефакторинг Desktop, локализация — это задача 3 и далее. Обоснование очерёдности — в `smm-improvement-plan-2026-07-30.md`, разделы 5 и 7.