# Задача 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, ~1–2 дня) ### 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=`, `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::::`; - формат вывода — по одной записи в строке, стабильный и машиночитаемый: `sourcetargetprotoport`; - ненулевой 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`, диапазон 30–3600, по умолчанию 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) | 1–2 дня | | **B** | Реконсиляция Links + фактическая проверка в acceptance | 3–5 дней | | **C** | Подписанный manifest и проверка совместимости версий | 2–4 дня | Делать в порядке 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 (Этапы 11–12), каркас `IProvisioningModule` и остальные модули провиженинга, роль Monitor в bootstrap, MVVM-рефакторинг Desktop, локализация — это задача 3 и далее. Обоснование очерёдности — в `smm-improvement-plan-2026-07-30.md`, разделы 5 и 7.