server-monitor-manager/agents/hermes/notes/salvage-2026-08-18/hermes-desktop-attachments/desktop-attachments/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

23 KiB
Raw Blame History

Задача 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

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.csConfirmHostKeyAsync вызывается в обработчике редактирования безусловно. Смена только 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, и провиженинг встанет молча.

Что сделать:

  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=DisabledApplyDisconnectAsync, событие 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

{
  "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.