Задания, отчёты и патчи, лежавшие в 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>
214 lines
23 KiB
Markdown
214 lines
23 KiB
Markdown
# Задача 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.
|