# Задача 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=<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`, диапазон 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.
