Задания, отчёты и патчи, лежавшие в 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>
23 KiB
Задача 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
var sshArguments = new[] { "-p", port, $"{terminalUser}@{profile.Host}" };
Ни -F none, ни UserKnownHostsFile, ни StrictHostKeyChecking. Терминал использует системный ~/.ssh/known_hosts и поведение по умолчанию (ask). Пользователь подтвердил fingerprint в приложении — а терминал открывается мимо этого доверия и может принять другой ключ по обычному консольному промпту. Половина P0-4 остаётся открытой, и именно на том канале, где даётся полноценная shell-сессия.
Что сделать:
- Передавать в терминал тот же набор опций, что и в
RunRestrictedCommandAsync:-F none,-o StrictHostKeyChecking=yes,-o UserKnownHostsFile=<pin>,GlobalKnownHostsFile=none,KnownHostsCommand=none,UpdateHostKeys=no,CheckHostIP=no. - Перед запуском проверять
SshHostKeyTrust.IsTrusted(...)и отказывать с тем же сообщением, что и мониторинг. - Учесть, что
UseShellExecute = true+wt.exe new-tab— аргументы уходят через оболочку Windows Terminal: проверить экранирование пути pin-файла (пробелы вApplicationDataпути реальны), либо перейти наwt.exe new-tab --title ... -- ssh ...с явным разделителем--. - Тест в
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." — на английском, в остальном русскоязычном интерфейсе, и без подсказки, что чинится это через «Изменить сервер».
Что сделать:
- Отдельный статус профиля
HostKeyPendingConfirmationвместо исключения при опросе. - В карточке сервера — кнопка «Подтвердить host key», вызывающая
ConfirmHostKeyAsyncнапрямую, без диалога редактирования. - Сообщение — на языке интерфейса; в тексте назвать конкретное действие.
- Разовая миграция при старте: если профилей без fingerprint > 0 — один
InfoBarсо списком имён.
A3. Переименование сервера требует нового keyscan и подтверждения
MainPage.xaml.cs — ConfirmHostKeyAsync вызывается в обработчике редактирования безусловно. Смена только Name (или User) приводит к сетевому ssh-keyscan и модальному диалогу. При недоступном сервере переименование вообще невозможно.
Что сделать: пропускать диалог, если Host/Port не изменились и IsTrusted(...) для сохранённого fingerprint возвращает true; в этом случае переносить fingerprint в новый профиль.
A4. Диспетчеризация в helper по строке кода ошибки
Где: ProvisioningHelperServer.ExecuteRequestAsync
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, и провиженинг встанет молча.
Что сделать:
- В
update_role agentпересчитывать и переписыватьSMM_AgentUid(идемпотентно,sed -iпо ключу или полная перегенерация файла с сохранением остальных значений). - В helper при старте: если
SMM_AgentUidне соответствует существующему пользователю — писать явную ошибку в journal, а не молча отклонять соединения. - Проверка в
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):
- При старте Control и далее периодически (интервал — новая настройка
Control__LinkReconciliationSeconds, диапазон 30–3600, по умолчанию 300):- получить факт через
link-list; desired=Active, факта нет →ApplyConnectAsync, событиеlink.reapplied, запись в audit;- факт есть, записи в БД нет либо
desired=Disabled→ApplyDisconnectAsync, событиеlink.orphan-removed, audit; - до успешного восстановления держать
ActualState = Partial.
- получить факт через
- Если
link-listвернул «таблицы нет» — не считать это «правил ноль»: перевести все Active-Links вPartial, поднять событиеmesh.firewall-missing, повторять попытку с backoff. Ошибочная трактовка здесь приведёт к массовому «переприменению» в несуществующую таблицу. - Реконсиляция должна быть идемпотентной и не конфликтовать с
LinkService._reconciliationLocks— переиспользовать те же семафоры поlink.Id. - Ограничение частоты: не чаще одного полного прохода в
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
{
"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
- Новое действие
verify-manifest MANIFEST SIGNATURE. verify_archive→ сверка хэша архива с manifest, а не с соседним.sha256; старый путь оставить только под явнымSMM_ALLOW_UNSIGNED=1с громким предупреждением (для локальной разработки).- Отказ
update-*при несовместимой паре версий Control ↔ Agent ↔ helper (installer-contract.md§6 это уже требует). 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.